Skip to content

Commit ad81615

Browse files
authored
refactor: migrate from qqjs to execa with custom wrapper (#3678)
* refactor: migrate from qqjs to execa with custom wrapper Replaces qqjs dependency with execa v9 and introduces a lightweight exec.ts wrapper that preserves qqjs behavior (stdio inheritance, command logging, error handling). Changes: - Created scripts/utils/exec.ts wrapper with x(), shell(), stdout(), and run() helpers - Migrated all qqjs usage to exec.ts (install-scripts.js, version.js, smoke tests) - Fixed execa v8→v9 breaking changes in homebrew.js and plugin tests - Updated import syntax: import execa from 'execa' → import {execa} from 'execa' - Replaced execa.command() with shell() helper (removed in v9) - Updated stdio array syntax to named properties - Removed qqjs dependency (63 packages removed) The exec.ts wrapper provides a consistent API across all scripts: - x(cmd, args, opts): Execute with args array (stdio: 'inherit' default) - shell(cmd, opts): Execute shell commands (strings with pipes, etc.) - stdout(cmd, args, opts): Capture stdout as string (trims trailing newline) - run(fn): Async error handler (logs errors, sets process.exitCode) - config.silent: Toggle command logging All existing functionality preserved, no behavior changes. * chore: reorder properties in exec.ts for consistency * refactor: simplify and rename exec.ts to script-exec.ts Simplifications: - Removed unused config.silent feature (only ever set to false) - Inlined console.log calls (removed log() helper) - Removed intermediate defaultOptions variables - Reduced from 75 lines to 60 lines (20% reduction) Renamed exec.ts → script-exec.ts to clarify purpose: - script-exec.ts provides wrappers for shell scripts (stdio: 'inherit') - Tests that need to capture output use execa directly Changes: - scripts/utils/exec.ts → scripts/utils/script-exec.ts - Updated all imports across 5 files - smoke.acceptance.test.ts: uses execa directly (needs output capture) - plugin.acceptance.test.ts: uses script-exec.ts (no capture needed) - Fixed TypeScript syntax in install-scripts.js (removed 'as const') All tests pass with proper type checking. * refactor: convert script-exec to plain JavaScript Converted script-exec from TypeScript to plain JavaScript to eliminate the build step dependency. Scripts can now run directly without requiring 'npm run build' first, which is more reliable for CI/CD pipelines. Changes: - src/lib/scripts/script-exec.ts → scripts/utils/script-exec.js - Removed all TypeScript type annotations - Reverted all imports back to ../utils/script-exec.js paths - No build step required Benefits: - Scripts work immediately without compilation - Simpler CI/CD - no build step before running scripts - Fewer failure points in release automation - 60 lines of clean, dependency-minimal JavaScript Validation: - All scripts pass syntax check - install-scripts.js executes correctly - version.js executes and returns version - All tests type check correctly * fix linting
1 parent d547ed5 commit ad81615

8 files changed

Lines changed: 402 additions & 955 deletions

File tree

package-lock.json

Lines changed: 305 additions & 909 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"date-fns": "^2.30.0",
4646
"debug": "^4.4.3",
4747
"eventsource": "^4",
48-
"execa": "5.1.1",
48+
"execa": "^9.6.1",
4949
"filesize": "^10.1",
5050
"foreman": "^3.0.1",
5151
"fs-extra": "^11.3.0",
@@ -130,7 +130,6 @@
130130
"mock-stdin": "^1",
131131
"nock": "^14.0.12",
132132
"oclif": "^4.22.87",
133-
"qqjs": "0.3.11",
134133
"rimraf": "5.0.5",
135134
"sinon": "^21.0.2",
136135
"source-map-support": "^0.5.21",
Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
#!/usr/bin/env node
22

3-
import execa from 'execa'
43
import path from 'node:path'
54
import {fileURLToPath} from 'node:url'
6-
import qq from 'qqjs'
75

86
import getHerokuS3Bucket from '../utils/get-heroku-s3-bucket.js'
97
import isStableRelease from '../utils/is-stable-release.js'
8+
import {run, shell} from '../utils/script-exec.js'
109

1110
const __filename = fileURLToPath(import.meta.url)
1211
const __dirname = path.dirname(__filename)
@@ -16,25 +15,17 @@ const opts = {
1615
stdio: 'inherit',
1716
}
1817

19-
qq.config.silent = false
18+
await run(async () => {
19+
const {GITHUB_REF_NAME, GITHUB_REF_TYPE} = process.env
2020

21-
try {
22-
await qq.run(async () => {
23-
const {GITHUB_REF_NAME, GITHUB_REF_TYPE} = process.env
24-
25-
if (isStableRelease(GITHUB_REF_TYPE, GITHUB_REF_NAME)) {
26-
const HEROKU_S3_BUCKET = await getHerokuS3Bucket()
27-
await execa.command(`aws s3 cp --content-type text/plain --cache-control max-age=604800 ./install-standalone.sh s3://${HEROKU_S3_BUCKET}/install-standalone.sh`, opts)
28-
await execa.command(`aws s3 cp --content-type text/plain --cache-control max-age=604800 ./install-standalone.sh s3://${HEROKU_S3_BUCKET}/install.sh`, opts)
29-
await execa.command(`aws s3 cp --content-type text/plain --cache-control max-age=604800 ./install-ubuntu.sh s3://${HEROKU_S3_BUCKET}/install-ubuntu.sh`, opts)
30-
} else {
31-
console.log('Not on stable release, skipping updating install scripts')
32-
// eslint-disable-next-line n/no-process-exit
33-
process.exit(0)
34-
}
35-
})
36-
} catch (error) {
37-
console.error(error)
38-
// eslint-disable-next-line n/no-process-exit
39-
process.exit(1)
40-
}
21+
if (isStableRelease(GITHUB_REF_TYPE, GITHUB_REF_NAME)) {
22+
const HEROKU_S3_BUCKET = await getHerokuS3Bucket()
23+
await shell(`aws s3 cp --content-type text/plain --cache-control max-age=604800 ./install-standalone.sh s3://${HEROKU_S3_BUCKET}/install-standalone.sh`, opts)
24+
await shell(`aws s3 cp --content-type text/plain --cache-control max-age=604800 ./install-standalone.sh s3://${HEROKU_S3_BUCKET}/install.sh`, opts)
25+
await shell(`aws s3 cp --content-type text/plain --cache-control max-age=604800 ./install-ubuntu.sh s3://${HEROKU_S3_BUCKET}/install-ubuntu.sh`, opts)
26+
} else {
27+
console.log('Not on stable release, skipping updating install scripts')
28+
// eslint-disable-next-line n/no-process-exit
29+
process.exit(0)
30+
}
31+
})

scripts/release/homebrew.js

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import execa from 'execa'
21
import crypto from 'node:crypto'
32
import fs from 'node:fs'
43
import path from 'node:path'
@@ -9,6 +8,7 @@ import {rimrafSync} from 'rimraf'
98

109
import getHerokuS3Bucket from '../utils/get-heroku-s3-bucket.js'
1110
import isStableRelease from '../utils/is-stable-release.js'
11+
import {run, shell, x} from '../utils/script-exec.js'
1212

1313
const __filename = fileURLToPath(import.meta.url)
1414
const __dirname = path.dirname(__filename)
@@ -41,7 +41,7 @@ const ARCH_ARM = 'arm64'
4141
function downloadFileFromS3(s3Path, fileName, downloadPath) {
4242
const downloadTo = path.join(downloadPath, fileName)
4343
const commandStr = `aws s3 cp s3://${HEROKU_S3_BUCKET}/${s3Path}/${fileName} ${downloadTo}`
44-
return execa.command(commandStr)
44+
return shell(commandStr)
4545
}
4646

4747
async function updateHerokuFormula(brewDir) {
@@ -94,7 +94,7 @@ async function updateHerokuFormula(brewDir) {
9494

9595
async function setupGit() {
9696
const githubSetupPath = path.join(__dirname, '..', 'utils', '_github_setup')
97-
await execa(githubSetupPath)
97+
await x(githubSetupPath, [])
9898
}
9999

100100
async function updateHomebrew() {
@@ -108,7 +108,7 @@ async function updateHomebrew() {
108108
await setupGit()
109109

110110
console.log(`cloning https://github.com/heroku/homebrew-brew to ${homebrewDir}`)
111-
await execa(
111+
await x(
112112
'git',
113113
[
114114
'clone',
@@ -122,7 +122,7 @@ async function updateHomebrew() {
122122

123123
// run in git in cloned heroku/homebrew-brew git directory
124124
const git = async (args, opts = {}) => {
125-
await execa('git', ['-C', homebrewDir, ...args], opts)
125+
await x('git', ['-C', homebrewDir, ...args], opts)
126126
}
127127

128128
console.log('updating local git...')
@@ -135,10 +135,6 @@ async function updateHomebrew() {
135135
}
136136
}
137137

138-
try {
138+
await run(async () => {
139139
await updateHomebrew()
140-
} catch (error) {
141-
console.error('error running scripts/release/homebrew.js', error)
142-
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit
143-
process.exit(1)
144-
}
140+
})

scripts/utils/script-exec.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {execa} from 'execa'
2+
3+
/**
4+
* Execute a command with args array
5+
* Logs the command and uses stdio: 'inherit' by default
6+
*/
7+
export function x(command, args, options) {
8+
console.log(`$ ${command} ${args.join(' ')}`)
9+
return execa(command, args, {
10+
stdio: 'inherit',
11+
...options,
12+
})
13+
}
14+
15+
/**
16+
* Execute a shell command (string with interpolation, pipes, etc.)
17+
* Logs the command and uses stdio: 'inherit' by default
18+
*/
19+
export function shell(command, options) {
20+
console.log(`$ ${command}`)
21+
return execa(command, {
22+
shell: true,
23+
stdio: 'inherit',
24+
...options,
25+
})
26+
}
27+
28+
/**
29+
* Execute a command and return stdout as string
30+
* Trims trailing newline
31+
*/
32+
export async function stdout(command, args, options) {
33+
console.log(`$ ${command} ${args.join(' ')}`)
34+
const result = await execa(command, args, {
35+
...options,
36+
stderr: 'inherit',
37+
stdin: 'inherit',
38+
stdout: 'pipe',
39+
})
40+
const output = typeof result.stdout === 'string' ? result.stdout : ''
41+
return output.replace(/\n$/, '')
42+
}
43+
44+
/**
45+
* Run an async function with error handling
46+
* Catches errors, logs them, and sets process.exitCode
47+
*/
48+
export async function run(fn) {
49+
try {
50+
return await fn()
51+
} catch (error) {
52+
if (error instanceof Error) {
53+
console.error(error.stack || error.message)
54+
} else {
55+
console.error(error)
56+
}
57+
58+
process.exitCode = 1
59+
}
60+
}

scripts/utils/version.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
33
import {fileURLToPath} from 'node:url'
4-
import qq from 'qqjs'
4+
5+
import {stdout} from './script-exec.js'
56

67
const __filename = fileURLToPath(import.meta.url)
78
const __dirname = path.dirname(__filename)
@@ -11,7 +12,7 @@ export default async function getVersion() {
1112
let {version} = packageJson
1213
if (version.includes('-')) {
1314
const channel = version.split('-')[1].split('.')[0]
14-
const sha = await qq.x.stdout('git', ['rev-parse', '--short', 'HEAD'])
15+
const sha = await stdout('git', ['rev-parse', '--short', 'HEAD'])
1516
version = `${version.split('-')[0]}-${channel}.${sha}`
1617
}
1718

test/acceptance/plugin.acceptance.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import execa from 'execa'
21
import fs from 'fs-extra'
32
import path from 'node:path'
43
import {fileURLToPath} from 'node:url'
54

5+
import {x} from '../../scripts/utils/script-exec.js'
6+
67
const __filename = fileURLToPath(import.meta.url)
78
const __dirname = path.dirname(__filename)
89
const plugins = ['@heroku-cli/plugin-applink']
@@ -64,11 +65,13 @@ describe('plugins', function () {
6465
cloneUrl = repoUrl
6566
}
6667

67-
await execa('git', ['clone', cloneUrl, cwd])
68-
const opts = {cwd, stdio: [0, 1, 2]}
69-
await execa('git', ['checkout', `v${pkg.version}`], opts)
70-
await execa('npm', [], opts)
71-
await execa('npm', ['test'], opts)
68+
await x('git', ['clone', cloneUrl, cwd])
69+
const opts = {
70+
cwd, stderr: 'inherit', stdin: 'inherit', stdout: 'inherit',
71+
} as const
72+
await x('git', ['checkout', `v${pkg.version}`], opts)
73+
await x('npm', [], opts)
74+
await x('npm', ['test'], opts)
7275
})
7376
}
7477
})

test/acceptance/smoke.acceptance.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// tslint:disable no-console
22
import ansis from 'ansis'
33
import {expect} from 'chai'
4+
import {execa} from 'execa'
45
import fs from 'fs-extra'
56
import path from 'node:path'
67
import {fileURLToPath} from 'node:url'
7-
import * as qq from 'qqjs'
88

99
import normalizeTableOutput from '../helpers/utils/normalize-table-output.js'
1010
import commandsOutput from './commands-output.js'
@@ -16,7 +16,8 @@ const bin = path.join(__dirname, '../../bin/run')
1616

1717
function run(args = '') {
1818
console.log(`$ heroku ${args}`)
19-
return qq.x([bin, args].join(' '), {stdio: undefined})
19+
// Use execa directly to capture output for test assertions
20+
return execa([bin, args].join(' '), {shell: true})
2021
}
2122

2223
// Smoke tests expect the CI account: heroku-cli@salesforce.com, app heroku-cli-ci-smoke-test-app,

0 commit comments

Comments
 (0)