Skip to content

Commit f3c4e6d

Browse files
authored
feat: Prepend launcher to heroku run commands for fir (#3374)
* Add helpers to conditionally prepend launcher to commands * Prepends heroku run commands with `--launcher` with `--no-launcher` fallback to disable Introduces a --no-launcher flag to both the run and run:detached commands, allowing users to prevent the automatic prepending of 'launcher' to commands on CNB apps. Updates command descriptions and integration tests to cover the new flag. * Refactor `heroku run:inside` command to use shared helper for prepending `-- launcher` Replaces the use of buildCommand and app stack checks with buildCommandWithLauncher for constructing the dyno command. This simplifies logic and centralizes launcher handling. * Add tests for buildCommandWithLauncher helper Introduces unit tests for the buildCommandWithLauncher function, covering CNB and non-CNB app scenarios and the launcher disable flag.
1 parent 82c0564 commit f3c4e6d

File tree

6 files changed

+80
-16
lines changed

6 files changed

+80
-16
lines changed

packages/cli/src/commands/run/detached.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {Command, flags} from '@heroku-cli/command'
33
import {DynoSizeCompletion, ProcessTypeCompletion} from '@heroku-cli/command/lib/completions'
44
import {ux} from '@oclif/core'
55
import Dyno from '../../lib/run/dyno'
6-
import {buildCommand} from '../../lib/run/helpers'
6+
import {buildCommandWithLauncher} from '../../lib/run/helpers'
77
import logDisplayer from '../../lib/run/log-displayer'
88

99
export default class RunDetached extends Command {
@@ -22,6 +22,10 @@ export default class RunDetached extends Command {
2222
size: flags.string({char: 's', description: 'dyno size', completion: DynoSizeCompletion}),
2323
tail: flags.boolean({char: 't', description: 'continually stream logs'}),
2424
type: flags.string({description: 'process type', completion: ProcessTypeCompletion}),
25+
'no-launcher': flags.boolean({
26+
description: 'don’t prepend ‘launcher’ before a command',
27+
default: false,
28+
}),
2529
}
2630

2731
async run() {
@@ -30,7 +34,7 @@ export default class RunDetached extends Command {
3034
const opts = {
3135
heroku: this.heroku,
3236
app: flags.app,
33-
command: buildCommand(argv as string[]),
37+
command: await buildCommandWithLauncher(this.heroku, flags.app, argv as string[], flags['no-launcher']),
3438
size: flags.size,
3539
type: flags.type,
3640
env: flags.env,

packages/cli/src/commands/run/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import {ux} from '@oclif/core'
44
import debugFactory from 'debug'
55
import * as Heroku from '@heroku-cli/schema'
66
import Dyno from '../../lib/run/dyno'
7-
import {buildCommand, revertSortedArgs} from '../../lib/run/helpers'
7+
import {buildCommandWithLauncher, revertSortedArgs} from '../../lib/run/helpers'
88

99
const debug = debugFactory('heroku:run')
1010

1111
export default class Run extends Command {
12-
static description = 'run a one-off process inside a heroku dyno\nShows a notification if the dyno takes more than 20 seconds to start.'
12+
static description = 'run a one-off process inside a heroku dyno\nShows a notification if the dyno takes more than 20 seconds to start.\nHeroku automatically prepends ‘launcher’ to the command on CNB apps (use --no-launcher to disable).'
1313

1414
static examples = [
1515
'$ heroku run bash',
@@ -29,17 +29,22 @@ export default class Run extends Command {
2929
'no-tty': flags.boolean({description: 'force the command to not run in a tty'}),
3030
listen: flags.boolean({description: 'listen on a local port', hidden: true}),
3131
'no-notify': flags.boolean({description: 'disables notification when dyno is up (alternatively use HEROKU_NOTIFICATIONS=0)'}),
32+
'no-launcher': flags.boolean({
33+
description: 'don’t prepend ‘launcher’ before a command',
34+
default: false,
35+
}),
3236
}
3337

3438
async run() {
3539
const {argv, flags} = await this.parse(Run)
3640
const command = revertSortedArgs(process.argv, argv as string[])
41+
const builtCommand = await buildCommandWithLauncher(this.heroku, flags.app, command, flags['no-launcher'])
3742
const opts = {
3843
'exit-code': flags['exit-code'],
3944
'no-tty': flags['no-tty'],
4045
app: flags.app,
4146
attach: true,
42-
command: buildCommand(command),
47+
command: builtCommand,
4348
env: flags.env,
4449
heroku: this.heroku,
4550
listen: flags.listen,

packages/cli/src/commands/run/inside.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import {Args, ux} from '@oclif/core'
33
import debugFactory from 'debug'
44
import heredoc from 'tsheredoc'
55
import Dyno from '../../lib/run/dyno'
6-
import {buildCommand} from '../../lib/run/helpers'
7-
import {App} from '../../lib/types/fir'
6+
import {buildCommandWithLauncher} from '../../lib/run/helpers'
87

98
const debug = debugFactory('heroku:run:inside')
109

@@ -57,17 +56,10 @@ export default class RunInside extends Command {
5756

5857
const {dyno_name: dynoName} = args
5958
const {app: appName, 'exit-code': exitCode, listen, 'no-launcher': noLauncher} = flags
60-
const prependLauncher = !noLauncher
61-
62-
const {body: app} = await this.heroku.get<App>(
63-
`/apps/${appName}`, {
64-
headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'},
65-
})
66-
const appStackIsCnb = app.stack.name === 'cnb'
6759

6860
const opts = {
6961
app: appName,
70-
command: buildCommand(argv.slice(1) as string[], appStackIsCnb && prependLauncher),
62+
command: await buildCommandWithLauncher(this.heroku, appName, argv.slice(1) as string[], noLauncher),
7163
dyno: dynoName,
7264
'exit-code': exitCode,
7365
heroku: this.heroku,

packages/cli/src/lib/run/helpers.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/* eslint-disable @typescript-eslint/ban-ts-comment */
22
import {ux} from '@oclif/core'
3+
import type {APIClient} from '@heroku-cli/command'
4+
import type {App} from '../types/fir'
35

46
// this function exists because oclif sorts argv
57
// and to capture all non-flag command inputs
@@ -60,3 +62,30 @@ export function buildEnvFromFlag(flag: string) {
6062

6163
return env
6264
}
65+
66+
/**
67+
* Determines whether to prepend `launcher` to the command for a given app.
68+
* Behavior: Only prepend on CNB stack apps and when not explicitly disabled.
69+
*/
70+
export async function shouldPrependLauncher(heroku: APIClient, appName: string, disableLauncher: boolean): Promise<boolean> {
71+
if (disableLauncher) return false
72+
73+
const {body: app} = await heroku.get<App>(`/apps/${appName}` , {
74+
headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'},
75+
})
76+
77+
return (app.stack && app.stack.name) === 'cnb'
78+
}
79+
80+
/**
81+
* Builds the command string, automatically deciding whether to prepend `launcher`.
82+
*/
83+
export async function buildCommandWithLauncher(
84+
heroku: APIClient,
85+
appName: string,
86+
args: string[],
87+
disableLauncher: boolean,
88+
): Promise<string> {
89+
const prependLauncher = await shouldPrependLauncher(heroku, appName, disableLauncher)
90+
return buildCommand(args, prependLauncher)
91+
}

packages/cli/test/integration/run.integration.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ describe('run', function () {
2020
expect(ctx.stdout).to.include('1 2 3')
2121
})
2222

23+
testFactory()
24+
.stub(runHelper, 'revertSortedArgs', () => ['echo 1 2 3'])
25+
.command(['run', '--app=heroku-cli-ci-smoke-test-app', '--no-launcher', 'echo 1 2 3'])
26+
.it('respects --no-launcher', async ctx => {
27+
expect(ctx.stdout).to.include('1 2 3')
28+
})
29+
2330
testFactory()
2431
.skip()
2532
.stub(runHelper, 'revertSortedArgs', () => ['ruby -e "puts ARGV[0]" "{"foo": "bar"} " '])

packages/cli/test/unit/lib/run/helpers.unit.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {expect, test} from '@oclif/test'
22

3-
import {buildCommand, buildEnvFromFlag} from '../../../../src/lib/run/helpers'
3+
import {buildCommand, buildEnvFromFlag, buildCommandWithLauncher} from '../../../../src/lib/run/helpers'
44

55
describe('helpers.buildCommand()', function () {
66
[
@@ -52,3 +52,30 @@ describe('helpers.buildEnvFromFlag()', function () {
5252
})
5353
})
5454

55+
// New tests for buildCommandWithLauncher
56+
57+
describe('helpers.buildCommandWithLauncher()', function () {
58+
it('prepends launcher on CNB apps when not disabled', async function () {
59+
const heroku: any = {
60+
get: async () => ({body: {stack: {name: 'cnb'}}}),
61+
}
62+
const cmd = await buildCommandWithLauncher(heroku, 'my-app', ['echo', 'foo bar'], false)
63+
expect(cmd).to.equal('launcher echo "foo bar"')
64+
})
65+
66+
it('does not prepend launcher on non-CNB apps', async function () {
67+
const heroku: any = {
68+
get: async () => ({body: {stack: {name: 'heroku-22'}}}),
69+
}
70+
const cmd = await buildCommandWithLauncher(heroku, 'my-app', ['echo', 'foo'], false)
71+
expect(cmd).to.equal('echo foo')
72+
})
73+
74+
it('does not prepend launcher when disabled even on CNB', async function () {
75+
const heroku: any = {
76+
get: async () => ({body: {stack: {name: 'cnb'}}}),
77+
}
78+
const cmd = await buildCommandWithLauncher(heroku, 'my-app', ['echo', 'foo'], true)
79+
expect(cmd).to.equal('echo foo')
80+
})
81+
})

0 commit comments

Comments
 (0)