Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/local.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ run heroku app locally

```
USAGE
$ heroku local [PROCESSNAME] [-e <value>] [-p <value>] [-f <value>]
$ heroku local [PROCESSNAME] [-e <value>] [-p <value>] [-f <value>] [--start-cmd <value>]

ARGUMENTS
[PROCESSNAME] name of the process
Expand All @@ -23,6 +23,7 @@ FLAGS
-e, --env=<value> location of env file (defaults to .env)
-f, --procfile=<value> use a different Procfile
-p, --port=<value> port to listen on
--start-cmd=<value> command to run as a web process when there’s no Procfile

DESCRIPTION
run heroku app locally
Expand Down Expand Up @@ -70,7 +71,7 @@ run heroku app locally

```
USAGE
$ heroku local:start [PROCESSNAME] [-e <value>] [-p <value>] [-f <value>]
$ heroku local:start [PROCESSNAME] [-e <value>] [-p <value>] [-f <value>] [--start-cmd <value>]

ARGUMENTS
[PROCESSNAME] name of the process
Expand All @@ -79,6 +80,7 @@ FLAGS
-e, --env=<value> location of env file (defaults to .env)
-f, --procfile=<value> use a different Procfile
-p, --port=<value> port to listen on
--start-cmd=<value> command to run as a web process when there’s no Procfile

DESCRIPTION
run heroku app locally
Expand Down
59 changes: 50 additions & 9 deletions src/commands/local/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as color from '@heroku/heroku-cli-util/color'
import {Args, Command, Flags} from '@oclif/core'
import fs from 'fs'

import {validateEnvFile} from '../../lib/local/env-file-validator.js'
import {fork as foreman} from '../../lib/local/fork-foreman.js'
import {fork as foreman, isForemanExitError} from '../../lib/local/fork-foreman.js'
import {loadProc} from '../../lib/local/load-foreman-procfile.js'

export default class Index extends Command {
Expand Down Expand Up @@ -45,6 +46,13 @@ Start the application specified by a Procfile (defaults to ./Procfile)`
description: 'restart process if it dies',
hidden: true,
}),
'start-cmd': Flags.string({
description: 'command to run as web when no Procfile is present',
}),
}

public hasProcfile(procfilePath: string): boolean {
return fs.existsSync(procfilePath) && fs.statSync(procfilePath).isFile()
}

// Proxy method to make procfile loading testable
Expand All @@ -53,8 +61,13 @@ Start the application specified by a Procfile (defaults to ./Procfile)`
}

async run() {
const execArgv = ['start']
const {args, flags} = await this.parse(Index)
const processName = args.processname
const procfile = flags.procfile || 'Procfile'
const hasProcfile = this.hasProcfile(procfile)
const startCmd = flags['start-cmd']
const useStartCmd = !hasProcfile && !processName && Boolean(startCmd)
const execArgv = useStartCmd ? ['run'] : ['start']

if (flags.restart) {
this.error('--restart is no longer available\nUse forego instead: https://github.com/ddollar/forego')
Expand All @@ -66,20 +79,48 @@ Start the application specified by a Procfile (defaults to ./Procfile)`

const envFile = validateEnvFile(flags.env, this.warn.bind(this))

if (flags.procfile) execArgv.push('--procfile', flags.procfile)
execArgv.push('--env', envFile)
if (flags.port) execArgv.push('--port', flags.port)

if (args.processname) {
execArgv.push(args.processname)
} else {
const procfile = flags.procfile || 'Procfile'
if (flags.port) {
if (useStartCmd) {
this.warn('The specified port, ' + color.label(flags.port) + ', is being ignored when using --start-cmd.')
} else {
execArgv.push('--port', flags.port)
}
}

if (flags.procfile && hasProcfile) execArgv.push('--procfile', flags.procfile)

if (processName) {
execArgv.push(processName)
} else if (hasProcfile) {
if (startCmd) {
this.warn(`The specified start command, ${color.label(startCmd)}, is being ignored.`)
}

const procHash = this.loadProcfile(procfile)
const processes = Object.keys(procHash).filter(x => x !== 'release')
execArgv.push(processes.join(','))
} else {
if (!startCmd) {
this.error(
`No ${procfile} found.\nAdd a Procfile to add process types.\nhttps://devcenter.heroku.com/articles/procfile\nOr specify a start command with --start-cmd.`,
)
}

const resolvedStartCmd = startCmd ?? ''
execArgv.push('--', 'sh', '-c', resolvedStartCmd)
}

await this.runForeman(execArgv)
try {
await this.runForeman(execArgv)
} catch (error: unknown) {
if (isForemanExitError(error)) {
this.exit(error.exitCode)
}

throw error
}
}

// Proxy method to make foreman calls testable
Expand Down
13 changes: 10 additions & 3 deletions src/commands/local/run.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import * as color from '@heroku/heroku-cli-util/color'

import {Command, Flags} from '@oclif/core'

import {validateEnvFile} from '../../lib/local/env-file-validator.js'
import {fork as foreman} from '../../lib/local/fork-foreman.js'
import {fork as foreman, isForemanExitError} from '../../lib/local/fork-foreman.js'
import {revertSortedArgs} from '../../lib/run/helpers.js'

export default class Run extends Command {
Expand Down Expand Up @@ -44,6 +43,14 @@ export default class Run extends Command {
execArgv.push('--') // disable node-foreman flag parsing
execArgv.push(...commandArgs as string[]) // eslint-disable-line unicorn/no-array-push-push

await foreman(execArgv)
try {
await foreman(execArgv)
} catch (error: unknown) {
if (isForemanExitError(error)) {
this.exit(error.exitCode)
}

throw error
}
}
}
12 changes: 10 additions & 2 deletions src/commands/local/version.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Command} from '@oclif/core'

import {fork as foreman} from '../../lib/local/fork-foreman.js'
import {fork as foreman, isForemanExitError} from '../../lib/local/fork-foreman.js'

export default class Version extends Command {
static description = 'display node-foreman version'
Expand All @@ -9,6 +9,14 @@ export default class Version extends Command {
await this.parse(Version)

const execArgv = ['--version']
await foreman(execArgv)
try {
await foreman(execArgv)
} catch (error: unknown) {
if (isForemanExitError(error)) {
this.exit(error.exitCode)
}

throw error
}
}
}
28 changes: 23 additions & 5 deletions src/lib/local/fork-foreman.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
import {ux} from '@oclif/core/ux'
import {fork as forkChildProcess} from 'child_process'
import * as fs from 'fs'
import * as path from 'path'
import {fileURLToPath} from 'url'

export class ForemanExitError extends Error {
public exitCode: number

public constructor(exitCode: number) {
super(`Foreman exited with code ${exitCode}`)
this.exitCode = exitCode
this.name = 'ForemanExitError'
}
}

export function fork(argv: string[]): Promise<void> {
const script = getForemanScriptPath()
const nf = forkChildProcess(script, argv, {stdio: 'inherit'})

return new Promise(resolve => {
nf.on('exit', (code: number) => {
if (code !== 0) ux.exit(code)
resolve()
return new Promise((resolve, reject) => {
nf.on('error', reject)
nf.on('exit', (code: null | number) => {
if (code === 0) {
resolve()
return
}

reject(new ForemanExitError(code ?? 1))
})
})
}

export function isForemanExitError(error: unknown): error is ForemanExitError {
return error instanceof ForemanExitError
}

// depending if this is being ran before or after compilation
// we need to check for `.ts` and `.js` extensions and use
// the appropriate one.
Expand Down
56 changes: 56 additions & 0 deletions test/unit/commands/local/index.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ describe('local', function () {
expect(error.message).to.not.contain('Invalid flag')
}
})

it('accepts --start-cmd flag', async function () {
sandbox.stub(Local.prototype, 'runForeman').resolves()
sandbox.stub(Local.prototype, 'hasProcfile').returns(false)
const {error} = await runCommand(Local, ['--start-cmd', 'npm start'])
// If command parsing reaches execution, the flag was accepted
if (error) {
expect(error.message).to.not.contain('Invalid flag')
}
})
})

describe('error handling', function () {
Expand All @@ -60,10 +70,14 @@ describe('local', function () {

describe('argument construction', function () {
let runForemanStub: sinon.SinonStub
let hasProcfileStub: sinon.SinonStub
let loadProcfileStub: sinon.SinonStub
let originalCwd: string

beforeEach(function () {
runForemanStub = sandbox.stub(Local.prototype, 'runForeman').resolves()
hasProcfileStub = sandbox.stub(Local.prototype, 'hasProcfile').returns(true)
loadProcfileStub = sandbox.stub(Local.prototype, 'loadProcfile').returns({web: 'npm start'})
originalCwd = process.cwd()
})

Expand Down Expand Up @@ -110,6 +124,8 @@ describe('local', function () {
it('uses default procfile when none specified', async function () {
// Change to fixtures directory so the test can find the default Procfile
process.chdir('test/fixtures/local')
hasProcfileStub.restore()
loadProcfileStub.restore()

// This test verifies that when no procfile is specified, it defaults to 'Procfile'
// and calls loadProc with the default path
Expand All @@ -130,6 +146,7 @@ describe('local', function () {
beforeEach(function () {
sandbox = sinon.createSandbox()
runForemanStub = sandbox.stub(Local.prototype, 'runForeman').resolves()
sandbox.stub(Local.prototype, 'hasProcfile').returns(true)
})

afterEach(function () {
Expand Down Expand Up @@ -200,6 +217,45 @@ describe('local', function () {
})
})

describe('start command precedence', function () {
let runForemanStub: sinon.SinonStub
let hasProcfileStub: sinon.SinonStub
let loadProcfileStub: sinon.SinonStub

beforeEach(function () {
runForemanStub = sandbox.stub(Local.prototype, 'runForeman').resolves()
hasProcfileStub = sandbox.stub(Local.prototype, 'hasProcfile').returns(true)
loadProcfileStub = sandbox.stub(Local.prototype, 'loadProcfile').returns({web: 'node server.js'})
})

it('uses procfile and warns when both procfile and --start-cmd are provided', async function () {
const {stderr} = await runCommand(Local, ['--start-cmd', 'npm start'])

expect(loadProcfileStub.calledOnce).to.be.true
expect(runForemanStub.calledOnce).to.be.true
expect(stderr).to.contain('is being ignored')
})

it('uses --start-cmd when no procfile is found', async function () {
hasProcfileStub.returns(false)
await runCommand(Local, ['--start-cmd', 'python app.py'])

expect(loadProcfileStub.called).to.be.false
expect(runForemanStub.calledOnce).to.be.true
expect(runForemanStub.firstCall.args[0]).to.deep.equal(['run', '--env', '.env', '--', 'sh', '-c', 'python app.py'])
})

it('errors when no procfile and no --start-cmd are provided', async function () {
hasProcfileStub.returns(false)
const {error} = await runCommand(Local)

expect(error?.message).to.equal(
'No Procfile found.\nAdd a Procfile to add process types.\nhttps://devcenter.heroku.com/articles/procfile\nOr specify a start command with --start-cmd.',
)
expect(runForemanStub.called).to.be.false
})
})

describe('environment file integration', function () {
let sandbox: ReturnType<typeof sinon.createSandbox>
let runForemanStub: sinon.SinonStub
Expand Down
Loading