Skip to content

Commit b9b8fed

Browse files
feat: adding --start-cmd flag to heroku local when no Procfile is present (#3638)
* updating to leverage foreman for output formatting * small text update for description * Apply suggestion from @heicheng18 Co-authored-by: Helen Cheng <48834224+heicheng18@users.noreply.github.com> Signed-off-by: Michael Malave <michael.malave@salesforce.com> * Apply suggestion from @heicheng18 Co-authored-by: Helen Cheng <48834224+heicheng18@users.noreply.github.com> Signed-off-by: Michael Malave <michael.malave@salesforce.com> * Apply suggestion from @heicheng18 Co-authored-by: Helen Cheng <48834224+heicheng18@users.noreply.github.com> Signed-off-by: Michael Malave <michael.malave@salesforce.com> * Apply suggestion from @heicheng18 Co-authored-by: Helen Cheng <48834224+heicheng18@users.noreply.github.com> Signed-off-by: Michael Malave <michael.malave@salesforce.com> * Apply suggestion from @heicheng18 Co-authored-by: Helen Cheng <48834224+heicheng18@users.noreply.github.com> Signed-off-by: Michael Malave <michael.malave@salesforce.com> * Apply suggestion from @heicheng18 Co-authored-by: Helen Cheng <48834224+heicheng18@users.noreply.github.com> Signed-off-by: Michael Malave <michael.malave@salesforce.com> * Apply suggestion from @heicheng18 Co-authored-by: Helen Cheng <48834224+heicheng18@users.noreply.github.com> Signed-off-by: Michael Malave <michael.malave@salesforce.com> * fixes applied to tests following cx updates * merge in main and lint fix --------- Signed-off-by: Michael Malave <michael.malave@salesforce.com> Co-authored-by: Helen Cheng <48834224+heicheng18@users.noreply.github.com>
1 parent f1f19ca commit b9b8fed

File tree

6 files changed

+153
-21
lines changed

6 files changed

+153
-21
lines changed

docs/local.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ run heroku app locally
1414

1515
```
1616
USAGE
17-
$ heroku local [PROCESSNAME] [-e <value>] [-p <value>] [-f <value>]
17+
$ heroku local [PROCESSNAME] [-e <value>] [-p <value>] [-f <value>] [--start-cmd <value>]
1818
1919
ARGUMENTS
2020
[PROCESSNAME] name of the process
@@ -23,6 +23,7 @@ FLAGS
2323
-e, --env=<value> location of env file (defaults to .env)
2424
-f, --procfile=<value> use a different Procfile
2525
-p, --port=<value> port to listen on
26+
--start-cmd=<value> command to run as a web process when there’s no Procfile
2627
2728
DESCRIPTION
2829
run heroku app locally
@@ -70,7 +71,7 @@ run heroku app locally
7071

7172
```
7273
USAGE
73-
$ heroku local:start [PROCESSNAME] [-e <value>] [-p <value>] [-f <value>]
74+
$ heroku local:start [PROCESSNAME] [-e <value>] [-p <value>] [-f <value>] [--start-cmd <value>]
7475
7576
ARGUMENTS
7677
[PROCESSNAME] name of the process
@@ -79,6 +80,7 @@ FLAGS
7980
-e, --env=<value> location of env file (defaults to .env)
8081
-f, --procfile=<value> use a different Procfile
8182
-p, --port=<value> port to listen on
83+
--start-cmd=<value> command to run as a web process when there’s no Procfile
8284
8385
DESCRIPTION
8486
run heroku app locally

src/commands/local/index.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as color from '@heroku/heroku-cli-util/color'
22
import {Args, Command, Flags} from '@oclif/core'
3+
import fs from 'fs'
34

45
import {validateEnvFile} from '../../lib/local/env-file-validator.js'
5-
import {fork as foreman} from '../../lib/local/fork-foreman.js'
6+
import {fork as foreman, isForemanExitError} from '../../lib/local/fork-foreman.js'
67
import {loadProc} from '../../lib/local/load-foreman-procfile.js'
78

89
export default class Index extends Command {
@@ -45,6 +46,13 @@ Start the application specified by a Procfile (defaults to ./Procfile)`
4546
description: 'restart process if it dies',
4647
hidden: true,
4748
}),
49+
'start-cmd': Flags.string({
50+
description: 'command to run as web when no Procfile is present',
51+
}),
52+
}
53+
54+
public hasProcfile(procfilePath: string): boolean {
55+
return fs.existsSync(procfilePath) && fs.statSync(procfilePath).isFile()
4856
}
4957

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

5563
async run() {
56-
const execArgv = ['start']
5764
const {args, flags} = await this.parse(Index)
65+
const processName = args.processname
66+
const procfile = flags.procfile || 'Procfile'
67+
const hasProcfile = this.hasProcfile(procfile)
68+
const startCmd = flags['start-cmd']
69+
const useStartCmd = !hasProcfile && !processName && Boolean(startCmd)
70+
const execArgv = useStartCmd ? ['run'] : ['start']
5871

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

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

69-
if (flags.procfile) execArgv.push('--procfile', flags.procfile)
7082
execArgv.push('--env', envFile)
71-
if (flags.port) execArgv.push('--port', flags.port)
7283

73-
if (args.processname) {
74-
execArgv.push(args.processname)
75-
} else {
76-
const procfile = flags.procfile || 'Procfile'
84+
if (flags.port) {
85+
if (useStartCmd) {
86+
this.warn('The specified port, ' + color.label(flags.port) + ', is being ignored when using --start-cmd.')
87+
} else {
88+
execArgv.push('--port', flags.port)
89+
}
90+
}
91+
92+
if (flags.procfile && hasProcfile) execArgv.push('--procfile', flags.procfile)
93+
94+
if (processName) {
95+
execArgv.push(processName)
96+
} else if (hasProcfile) {
97+
if (startCmd) {
98+
this.warn(`The specified start command, ${color.label(startCmd)}, is being ignored.`)
99+
}
100+
77101
const procHash = this.loadProcfile(procfile)
78102
const processes = Object.keys(procHash).filter(x => x !== 'release')
79103
execArgv.push(processes.join(','))
104+
} else {
105+
if (!startCmd) {
106+
this.error(
107+
`No ${procfile} found.\nAdd a Procfile to add process types.\nhttps://devcenter.heroku.com/articles/procfile\nOr specify a start command with --start-cmd.`,
108+
)
109+
}
110+
111+
const resolvedStartCmd = startCmd ?? ''
112+
execArgv.push('--', 'sh', '-c', resolvedStartCmd)
80113
}
81114

82-
await this.runForeman(execArgv)
115+
try {
116+
await this.runForeman(execArgv)
117+
} catch (error: unknown) {
118+
if (isForemanExitError(error)) {
119+
this.exit(error.exitCode)
120+
}
121+
122+
throw error
123+
}
83124
}
84125

85126
// Proxy method to make foreman calls testable

src/commands/local/run.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import * as color from '@heroku/heroku-cli-util/color'
2-
32
import {Command, Flags} from '@oclif/core'
43

54
import {validateEnvFile} from '../../lib/local/env-file-validator.js'
6-
import {fork as foreman} from '../../lib/local/fork-foreman.js'
5+
import {fork as foreman, isForemanExitError} from '../../lib/local/fork-foreman.js'
76
import {revertSortedArgs} from '../../lib/run/helpers.js'
87

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

47-
await foreman(execArgv)
46+
try {
47+
await foreman(execArgv)
48+
} catch (error: unknown) {
49+
if (isForemanExitError(error)) {
50+
this.exit(error.exitCode)
51+
}
52+
53+
throw error
54+
}
4855
}
4956
}

src/commands/local/version.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Command} from '@oclif/core'
22

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

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

1111
const execArgv = ['--version']
12-
await foreman(execArgv)
12+
try {
13+
await foreman(execArgv)
14+
} catch (error: unknown) {
15+
if (isForemanExitError(error)) {
16+
this.exit(error.exitCode)
17+
}
18+
19+
throw error
20+
}
1321
}
1422
}

src/lib/local/fork-foreman.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,39 @@
1-
import {ux} from '@oclif/core/ux'
21
import {fork as forkChildProcess} from 'child_process'
32
import * as fs from 'fs'
43
import * as path from 'path'
54
import {fileURLToPath} from 'url'
65

6+
export class ForemanExitError extends Error {
7+
public exitCode: number
8+
9+
public constructor(exitCode: number) {
10+
super(`Foreman exited with code ${exitCode}`)
11+
this.exitCode = exitCode
12+
this.name = 'ForemanExitError'
13+
}
14+
}
15+
716
export function fork(argv: string[]): Promise<void> {
817
const script = getForemanScriptPath()
918
const nf = forkChildProcess(script, argv, {stdio: 'inherit'})
1019

11-
return new Promise(resolve => {
12-
nf.on('exit', (code: number) => {
13-
if (code !== 0) ux.exit(code)
14-
resolve()
20+
return new Promise((resolve, reject) => {
21+
nf.on('error', reject)
22+
nf.on('exit', (code: null | number) => {
23+
if (code === 0) {
24+
resolve()
25+
return
26+
}
27+
28+
reject(new ForemanExitError(code ?? 1))
1529
})
1630
})
1731
}
1832

33+
export function isForemanExitError(error: unknown): error is ForemanExitError {
34+
return error instanceof ForemanExitError
35+
}
36+
1937
// depending if this is being ran before or after compilation
2038
// we need to check for `.ts` and `.js` extensions and use
2139
// the appropriate one.

test/unit/commands/local/index.unit.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ describe('local', function () {
3939
expect(error.message).to.not.contain('Invalid flag')
4040
}
4141
})
42+
43+
it('accepts --start-cmd flag', async function () {
44+
sandbox.stub(Local.prototype, 'runForeman').resolves()
45+
sandbox.stub(Local.prototype, 'hasProcfile').returns(false)
46+
const {error} = await runCommand(Local, ['--start-cmd', 'npm start'])
47+
// If command parsing reaches execution, the flag was accepted
48+
if (error) {
49+
expect(error.message).to.not.contain('Invalid flag')
50+
}
51+
})
4252
})
4353

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

6171
describe('argument construction', function () {
6272
let runForemanStub: sinon.SinonStub
73+
let hasProcfileStub: sinon.SinonStub
74+
let loadProcfileStub: sinon.SinonStub
6375
let originalCwd: string
6476

6577
beforeEach(function () {
6678
runForemanStub = sandbox.stub(Local.prototype, 'runForeman').resolves()
79+
hasProcfileStub = sandbox.stub(Local.prototype, 'hasProcfile').returns(true)
80+
loadProcfileStub = sandbox.stub(Local.prototype, 'loadProcfile').returns({web: 'npm start'})
6781
originalCwd = process.cwd()
6882
})
6983

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

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

135152
afterEach(function () {
@@ -200,6 +217,45 @@ describe('local', function () {
200217
})
201218
})
202219

220+
describe('start command precedence', function () {
221+
let runForemanStub: sinon.SinonStub
222+
let hasProcfileStub: sinon.SinonStub
223+
let loadProcfileStub: sinon.SinonStub
224+
225+
beforeEach(function () {
226+
runForemanStub = sandbox.stub(Local.prototype, 'runForeman').resolves()
227+
hasProcfileStub = sandbox.stub(Local.prototype, 'hasProcfile').returns(true)
228+
loadProcfileStub = sandbox.stub(Local.prototype, 'loadProcfile').returns({web: 'node server.js'})
229+
})
230+
231+
it('uses procfile and warns when both procfile and --start-cmd are provided', async function () {
232+
const {stderr} = await runCommand(Local, ['--start-cmd', 'npm start'])
233+
234+
expect(loadProcfileStub.calledOnce).to.be.true
235+
expect(runForemanStub.calledOnce).to.be.true
236+
expect(stderr).to.contain('is being ignored')
237+
})
238+
239+
it('uses --start-cmd when no procfile is found', async function () {
240+
hasProcfileStub.returns(false)
241+
await runCommand(Local, ['--start-cmd', 'python app.py'])
242+
243+
expect(loadProcfileStub.called).to.be.false
244+
expect(runForemanStub.calledOnce).to.be.true
245+
expect(runForemanStub.firstCall.args[0]).to.deep.equal(['run', '--env', '.env', '--', 'sh', '-c', 'python app.py'])
246+
})
247+
248+
it('errors when no procfile and no --start-cmd are provided', async function () {
249+
hasProcfileStub.returns(false)
250+
const {error} = await runCommand(Local)
251+
252+
expect(error?.message).to.equal(
253+
'No Procfile found.\nAdd a Procfile to add process types.\nhttps://devcenter.heroku.com/articles/procfile\nOr specify a start command with --start-cmd.',
254+
)
255+
expect(runForemanStub.called).to.be.false
256+
})
257+
})
258+
203259
describe('environment file integration', function () {
204260
let sandbox: ReturnType<typeof sinon.createSandbox>
205261
let runForemanStub: sinon.SinonStub

0 commit comments

Comments
 (0)