Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.1 40307120db fix(cli): a broken pipe always wins and exits 0; deterministic EPIPE tests
The EPIPE test flaked once on the macOS arm64 lane (exit 1). The handler only knew
EPIPE, but a stream already torn down by the first failed write can follow it with
ERR_STREAM_DESTROYED or ECONNRESET, a write can throw instead of emitting, and the
error can surface as an uncaught exception. All of those now exit 0 at once (stream
error events, io.out/io.err writes, uncaughtException, the top-level catch), so no later
code can turn a closed pipe into a failure.

The tests no longer depend on timing: one closes the read end before the child has
started (its first write meets a closed pipe), one after the first chunk of a 5,000
record answer, both with a 60 s timeout. 50 runs under nice -n 19 with twice as many
busy loops as CPUs: 0 failures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:08:25 -06:00
james.maesandClaude Fable 5.1 924fddae25 packaging: qqq 1.0.0 checksums; update-packaging refuses missing or zero sums (#15)
docs/packaging carries the real sha256 sums from the cli-v1.0.0 release. scripts/update-packaging.mjs now parses shasum lines strictly, requires 64 lowercase hex and non-zero sums for every target, validates both files before writing anything, and exits 1 with nothing written otherwise; covered by scripts/update-packaging.test.mjs (npm test runs test:scripts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:03:32 -06:00
james.maesandClaude Fable 5.1 1bc4ddd0be release: qqq 1.0.0 (#11)
qqq 1.0.0: the command-line client and the full-screen terminal UI (qqq ui) for QQQ applications over the UI API (/qqq/v1), with @qrun-io/qqq-cli 1.0.0, @qrun-io/qqq-core 1.0.0, @qrun-io/qqq-ui-api-client 1.8.0 and @qrun-io/qqq-ui-api-conformance 1.6.0 going final together. Squash of the release/1.0.0 branch at 79ba68e (the branch is kept with the full signed history; the tags v1.8.0 and cli-v1.0.0 point at that commit, which is what the released binaries were built from). See CHANGELOG.md [1.0.0] and docs/release-notes/1.0.0.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:49:06 -06:00
11 changed files with 212 additions and 44 deletions

No files matched your search

+6 -1
View File
@@ -26,7 +26,12 @@ See [`docs/release-runbook.md`](../release-runbook.md). In short: `node scripts/
release with `dist/qqq-*` and `dist/SHA256SUMS`, `node scripts/update-packaging.mjs <that SHA256SUMS>`,
copy `homebrew-tap/Formula/qqq.rb` to the tap and `qqq.json` to the bucket (the bucket's Excavator
also picks up new releases by itself). Use the uploaded sums, not a
local rebuild: each build stamps its commit, so checksums differ between builds.
local rebuild: each build stamps its commit, so checksums differ between builds. The script exits 1 and writes
nothing when a sum is missing or malformed, and never writes zeros; check the result anyway with
`git diff` and `grep -c 0000000000000000 docs/packaging/qqq.json docs/packaging/homebrew-tap/Formula/qqq.rb`
(both must print 0) before copying the files out. The tap's CI and the bucket's manifest tests would catch
zeros (a download that does not match) only if their pull requests are opened and their checks are read;
checkpoint the sums here first, not there.
## Where the formula and manifest live
+4 -4
View File
@@ -12,22 +12,22 @@ class Qqq < Formula
on_macos do
on_arm do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-arm64"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
sha256 "5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-x64"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
sha256 "eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1"
end
end
on_linux do
on_arm do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-arm64"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
sha256 "25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc"
end
on_intel do
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-x64"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
sha256 "804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41"
end
end
+3 -1
View File
@@ -21,6 +21,8 @@ here (`Formula/qctl.rb`) once this tap exists, with `homebrew-qctl` kept as a po
The formula is generated in qqq-cli: after a `cli-v<version>` GitHub release, run
`node scripts/update-packaging.mjs <release SHA256SUMS>` there and copy
`docs/packaging/homebrew-tap/Formula/qqq.rb` here. CI (qqq-orb `homebrew-tap` profile) fetches every
platform's download, installs the formula and runs its test.
platform's download, installs the formula and runs its test, which fails on a wrong or all-zero sum, but
only once the pull request here is opened and its checks are read. The script refuses to write missing or
malformed sums; still compare the sums in the diff with the release's `SHA256SUMS` before opening the PR.
Apache-2.0
+1 -1
View File
@@ -10,7 +10,7 @@
"architecture": {
"64bit": {
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v1.0.0/qqq-windows-x64.exe",
"hash": "0000000000000000000000000000000000000000000000000000000000000000"
"hash": "cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176"
}
},
"bin": [
+6
View File
@@ -180,9 +180,15 @@ release (the channel Homebrew and Scoop follow); a release candidate is reached
```sh
gh release download cli-v1.0.0 --repo QRun-IO/qqq-cli -p SHA256SUMS -D /tmp/qqq-1.0.0
node scripts/update-packaging.mjs /tmp/qqq-1.0.0/SHA256SUMS
git diff docs/packaging # every sha256 differs from the placeholder, none is all zeros
git commit -S -am "packaging: qqq 1.0.0" && git push # in qqq-cli, through a pull request
```
The script exits 1 and writes nothing if `SHA256SUMS` lacks a target or has a malformed sum. Compare each sum
in the diff with the release's `SHA256SUMS` by eye before committing: the tap's CI and the bucket's manifest
tests catch a wrong sum (the download will not match) only when someone opens those pull requests and reads
their checks.
Then copy `docs/packaging/homebrew-tap/Formula/qqq.rb` to `Formula/qqq.rb` in `QRun-IO/homebrew-tap`
and `docs/packaging/qqq.json` to `bucket/qqq.json` in `QRun-IO/scoop-bucket`, a pull request on each. The tap's
CI fetches every platform's download, installs the formula and runs its test (which insists on
+2 -1
View File
@@ -18,7 +18,8 @@
"spec:merge-extensions": "node scripts/merge-extensions.mjs",
"generate": "npm run generate -w @qrun-io/qqq-ui-api-client && node scripts/gen-operations.mjs",
"build": "npm run build -w @qrun-io/qqq-ui-api-client && npm run build -w @qrun-io/qqq-core && npm run build -w @qrun-io/qqq-ui-api-conformance && npm run build -w @qrun-io/qqq-cli",
"test": "npm test --workspaces --if-present",
"test": "npm test --workspaces --if-present && npm run test:scripts",
"test:scripts": "node --test scripts/update-packaging.test.mjs",
"mock": "prism mock --dynamic --port 4010 packages/ui-api-client/openapi/qqq-v1.json",
"prepublishOnly": "npm run build",
"lint": "node scripts/lint.mjs",
+29 -5
View File
@@ -14,31 +14,55 @@ import { Assistant, Collaboration, Printing, missingQBitMessage, runProcess, typ
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieHeaderFor, target, type Target } from './session.js'
/** A write that ends the process quietly (status 0) when it throws because the reader has gone. */
function safeWrite(stream: NodeJS.WriteStream, text: string): void {
try {
stream.write(text)
} catch (error) {
if (isBrokenPipe(error)) process.exit(0)
throw error
}
}
/**
* Where output goes. The CLI writes to the process streams; `runCli` (the TUI's `:` command line)
* points these at a pane for the duration of one command.
*/
export const io = {
out: (text: string): void => { process.stdout.write(text) },
err: (text: string): void => { process.stderr.write(text) },
out: (text: string): void => { safeWrite(process.stdout, text) },
err: (text: string): void => { safeWrite(process.stderr, text) },
isTTY: (): boolean => Boolean(process.stdout.isTTY),
/** The terminal width table output fits nested results in; undefined when not on a terminal. */
width: (): number | undefined => (process.stdout.isTTY ? process.stdout.columns : undefined),
}
/** What a write to a pipe whose reader has gone ends with: EPIPE, or (macOS, a stream already torn down) the errors that follow it. */
const BROKEN_PIPE_CODES = new Set(['EPIPE', 'ECONNRESET', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END'])
/** Whether an error is a write to a pipe whose reader has gone (`qqq table query | head -1`). */
export function isBrokenPipe(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === 'EPIPE'
return BROKEN_PIPE_CODES.has(String((error as NodeJS.ErrnoException | undefined)?.code))
}
/** Exits quietly with status 0 when stdout or stderr is a pipe nobody reads any more, as `yes | head` does. */
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code)): void {
/**
* A pipe nobody reads any more ends the command at once with status 0, as `yes | head` does, whatever
* else was going on: the process exits from the stream's error event, from a write that throws, and
* from an uncaught error that is one, so no later code can turn it into a failure.
*/
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code), on: (event: 'uncaughtException', listener: (error: Error) => void) => unknown = (event, listener) => process.on(event, listener)): void {
for (const stream of streams) {
stream.on('error', (error) => {
if (isBrokenPipe(error)) exit(0)
else throw error
})
}
on('uncaughtException', (error) => {
if (isBrokenPipe(error)) exit(0)
else {
process.stderr.write(`${error.stack ?? error.message}\n`)
process.exit(1)
}
})
}
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
+1 -4
View File
@@ -33,10 +33,7 @@ importLegacyCredentials().then(() => program.parseAsync()).catch((error: unknown
return
}
// a stream that failed with EPIPE (an export piped into head): the reader left, nothing to report
if (isBrokenPipe(error)) {
process.exitCode = 0
return
}
if (isBrokenPipe(error)) process.exit(0)
const { code, text } = describeError(error, Boolean(program.opts().trace))
process.stderr.write(text + '\n')
process.exitCode = code
+24 -7
View File
@@ -20,7 +20,7 @@ describe('broken pipes', () => {
stub = await startStub([...BASE_ROUTES,
[/^GET \/metaData$/, () => ({ tables: { big: { name: 'big', label: 'Big' } }, processes: {}, apps: {} })],
[/^GET \/metaData\/table\/big$/, () => ({ name: 'big', label: 'Big', primaryKeyField: 'id', capabilities: ['TABLE_QUERY'], fields: { id: { name: 'id', label: 'Id', type: 'INTEGER' }, note: { name: 'note', label: 'Note', type: 'STRING' } } })],
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 2000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 5000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
])
})
after(async () => {
@@ -30,23 +30,40 @@ describe('broken pipes', () => {
test('the helper exits 0 on EPIPE and rethrows anything else', () => {
assert.ok(isBrokenPipe(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })))
assert.ok(isBrokenPipe(Object.assign(new Error('destroyed'), { code: 'ERR_STREAM_DESTROYED' })))
assert.ok(!isBrokenPipe(new Error('other')))
const listeners: Array<(e: Error) => void> = []
const exits: number[] = []
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code))
let uncaught: ((e: Error) => void) | undefined
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code), (_, f) => { uncaught = f })
listeners[0]!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
assert.deepEqual(exits, [0])
uncaught!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
assert.deepEqual(exits, [0, 0])
assert.throws(() => listeners[0]!(new Error('disk full')), /disk full/)
})
test('a closed stdout ends the command quietly with status 0 and no stack trace', async () => {
/** Runs a query whose output is far larger than a pipe buffer, closing the read end when `closeAt` says so. */
const query = async (closeAt: 'before-any-write' | 'first-chunk') => {
const env = { ...process.env, ...box.env, QQQ_TOKEN: 't', NO_COLOR: '1' }
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '2000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '5000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let stderr = ''
child.stderr.on('data', (d: Buffer) => (stderr += d.toString('utf8')))
child.stdout.once('data', () => child.stdout.destroy())
// before-any-write: the reader is gone before the child has even started, so its first write meets a closed pipe
if (closeAt === 'before-any-write') child.stdout.destroy()
else child.stdout.once('data', () => child.stdout.destroy())
const code = await new Promise<number | null>((resolve) => child.on('close', resolve))
assert.equal(code, 0)
return { code, stderr }
}
test('a reader that is gone before the first write ends the command quietly with status 0', { timeout: 60_000 }, async () => {
const { code, stderr } = await query('before-any-write')
assert.equal(code, 0, stderr)
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
})
test('a reader that leaves after the first chunk ends the command quietly with status 0', { timeout: 60_000 }, async () => {
const { code, stderr } = await query('first-chunk')
assert.equal(code, 0, stderr)
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
})
})
+63 -20
View File
@@ -10,36 +10,79 @@
* Run it on the binaries that were uploaded to the GitHub release, not on a local rebuild: the
* commit is stamped into each binary, so every build has different checksums.
*
* Refuses to write anything (exit 1, both files untouched) when a sum is missing, is not 64 lowercase hex digits,
* is all zeros, or when a placeholder in the files is left unfilled.
*
* Usage: node scripts/update-packaging.mjs [dist/SHA256SUMS]
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const HEX64 = /^[0-9a-f]{64}$/
const root = resolve(import.meta.dirname, '..')
const sumsPath = resolve(process.argv[2] ?? join(root, 'dist/SHA256SUMS'))
const version = JSON.parse(readFileSync(join(root, 'packages/cli/package.json'), 'utf8')).version
const sums = new Map(readFileSync(sumsPath, 'utf8').trim().split('\n').map((line) => line.split(/\s+/).reverse()))
const FORMULA_SUMS = /(url "[^"]*\/(qqq-[a-z0-9-]+)"\n\s*sha256 ")([0-9a-f]{64})"/g
function sum(file) {
const hash = sums.get(file)
if (!hash) {
console.error(`update-packaging: ${sumsPath} has no ${file}`)
process.exit(1)
/** Parses `shasum -a 256` output (`<hash> <file>`, `*` before the name tolerated) into a Map of file to hash. */
export function parseSums(text) {
const sums = new Map()
for (const line of text.split('\n')) {
if (line.trim() === '') continue
const match = line.match(/^(\S+)\s+\*?(\S+)$/)
if (!match) throw new Error(`unparseable SHA256SUMS line: ${JSON.stringify(line)}`)
sums.set(match[2], match[1].toLowerCase())
}
return sums
}
function sumFor(sums, file) {
const hash = sums.get(file)
if (hash === undefined) throw new Error(`SHA256SUMS has no ${file}`)
if (!HEX64.test(hash)) throw new Error(`SHA256SUMS has a malformed sum for ${file}: ${JSON.stringify(hash)}`)
if (/^0+$/.test(hash)) throw new Error(`SHA256SUMS has an all-zero sum for ${file}`)
return hash
}
const formulaPath = join(root, 'docs/packaging/homebrew-tap/Formula/qqq.rb')
let formula = readFileSync(formulaPath, 'utf8').replace(/^(\s*version ")[^"]+"/m, `$1${version}"`)
formula = formula.replace(/(url "[^"]*\/(qqq-[a-z0-9-]+)"\n\s*sha256 ")[0-9a-f]{64}"/g, (_, head, file) => `${head}${sum(file)}"`)
writeFileSync(formulaPath, formula)
/** Returns the new text of both packaging files; throws, leaving the caller nothing to write, on any problem. */
export function updatePackaging({ version, sums, formula, manifest }) {
let formulaCount = 0
const newFormula = formula
.replace(/^(\s*version ")[^"]+"/m, `$1${version}"`)
.replace(FORMULA_SUMS, (_, head, file) => {
formulaCount++
return `${head}${sumFor(sums, file)}"`
})
if (formulaCount === 0) throw new Error('the formula has no url/sha256 pairs to fill')
const left = newFormula.match(/sha256 "(?![0-9a-f]{64}")[^"]*"/g)
if (left || /sha256 "0{64}"/.test(newFormula)) throw new Error(`the formula still has unfilled sha256 values: ${left ?? 'zeros'}`)
const manifestPath = join(root, 'docs/packaging/qqq.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
manifest.version = version
manifest.architecture['64bit'].url = `https://github.com/QRun-IO/qqq-cli/releases/download/cli-v${version}/qqq-windows-x64.exe`
manifest.architecture['64bit'].hash = sum('qqq-windows-x64.exe')
writeFileSync(manifestPath, JSON.stringify(manifest, null, 4) + '\n')
const parsed = JSON.parse(manifest)
parsed.version = version
parsed.architecture['64bit'].url = `https://github.com/QRun-IO/qqq-cli/releases/download/cli-v${version}/qqq-windows-x64.exe`
parsed.architecture['64bit'].hash = sumFor(sums, 'qqq-windows-x64.exe')
return { formula: newFormula, manifest: JSON.stringify(parsed, null, 4) + '\n', formulaCount }
}
console.log(`docs/packaging/homebrew-tap/Formula/qqq.rb and docs/packaging/qqq.json now name ${version} with the sums from ${sumsPath}`)
function main() {
const sumsPath = resolve(process.argv[2] ?? join(root, 'dist/SHA256SUMS'))
const version = JSON.parse(readFileSync(join(root, 'packages/cli/package.json'), 'utf8')).version
const formulaPath = join(root, 'docs/packaging/homebrew-tap/Formula/qqq.rb')
const manifestPath = join(root, 'docs/packaging/qqq.json')
try {
const out = updatePackaging({
version,
sums: parseSums(readFileSync(sumsPath, 'utf8')),
formula: readFileSync(formulaPath, 'utf8'),
manifest: readFileSync(manifestPath, 'utf8'),
})
writeFileSync(formulaPath, out.formula)
writeFileSync(manifestPath, out.manifest)
console.log(`docs/packaging/homebrew-tap/Formula/qqq.rb (${out.formulaCount} sums) and docs/packaging/qqq.json now name ${version} with the sums from ${sumsPath}`)
} catch (error) {
console.error(`update-packaging: ${error.message}; nothing was written`)
process.exit(1)
}
}
// Run only as a script, not when the tests import it (realpath: argv[1] may go through a symlink such as macOS /var).
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(import.meta.filename)) main()
+73
View File
@@ -0,0 +1,73 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/** Tests for scripts/update-packaging.mjs. Run: node --test scripts/update-packaging.test.mjs */
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { cpSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { parseSums, updatePackaging } from './update-packaging.mjs'
const ZERO = '0'.repeat(64)
const h = (c) => c.repeat(64)
const FILES = ['qqq-darwin-arm64', 'qqq-darwin-x64', 'qqq-linux-arm64', 'qqq-linux-x64']
const sumsText = (omit = []) =>
[...FILES, 'qqq-linux-x64-musl', 'qqq-windows-x64.exe'].filter((f) => !omit.includes(f)).map((f, i) => `${'abcdef0123456'[i].repeat(64)} ${f}`).join('\n') + '\n'
const formula = `class Qqq < Formula
version "0.0.0"
${FILES.map((f) => ` url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/${f}"\n sha256 "${ZERO}"`).join('\n')}
end
`
const manifest = JSON.stringify({ version: '0.0.0', architecture: { '64bit': { url: 'x', hash: ZERO } } }, null, 4) + '\n'
test('fills every sum from shasum output and never leaves zeros', () => {
const out = updatePackaging({ version: '1.0.0', sums: parseSums(sumsText()), formula, manifest })
assert.equal(out.formulaCount, 4)
assert.ok(!out.formula.includes(ZERO) && !out.manifest.includes(ZERO))
assert.match(out.formula, /version "1\.0\.0"/)
assert.ok(out.formula.includes(`qqq-darwin-arm64"\n sha256 "${h('a')}"`))
assert.equal(JSON.parse(out.manifest).architecture['64bit'].hash, h('f'))
})
test('a missing target throws', () => {
assert.throws(() => updatePackaging({ version: '1.0.0', sums: parseSums(sumsText(['qqq-linux-x64'])), formula, manifest }), /no qqq-linux-x64/)
assert.throws(() => updatePackaging({ version: '1.0.0', sums: parseSums(sumsText(['qqq-windows-x64.exe'])), formula, manifest }), /no qqq-windows-x64\.exe/)
})
test('malformed, short, uppercase-free or zero sums throw', () => {
for (const bad of ['abc', ZERO, 'g'.repeat(64)]) {
const sums = parseSums(sumsText().replace(h('a'), bad))
assert.throws(() => updatePackaging({ version: '1.0.0', sums, formula, manifest }), /malformed|all-zero/)
}
assert.throws(() => parseSums('only-one-column\n'), /unparseable/)
})
test('a formula with nothing to fill throws', () => {
assert.throws(() => updatePackaging({ version: '1.0.0', sums: parseSums(sumsText()), formula: 'class Qqq < Formula\nend\n', manifest }), /no url\/sha256/)
})
test('the CLI exits 1 and writes nothing when a sum is missing', () => {
const dir = mkdtempSync(join(tmpdir(), 'update-packaging-'))
mkdirSync(join(dir, 'scripts'))
cpSync(join(import.meta.dirname, 'update-packaging.mjs'), join(dir, 'scripts/update-packaging.mjs'))
mkdirSync(join(dir, 'packages/cli'), { recursive: true })
writeFileSync(join(dir, 'packages/cli/package.json'), '{"version":"1.0.0"}')
mkdirSync(join(dir, 'docs/packaging/homebrew-tap/Formula'), { recursive: true })
writeFileSync(join(dir, 'docs/packaging/homebrew-tap/Formula/qqq.rb'), formula)
writeFileSync(join(dir, 'docs/packaging/qqq.json'), manifest)
writeFileSync(join(dir, 'SHA256SUMS'), sumsText(['qqq-linux-arm64']))
const run = spawnSync(process.execPath, [join(dir, 'scripts/update-packaging.mjs'), join(dir, 'SHA256SUMS')], { encoding: 'utf8' })
assert.equal(run.status, 1)
assert.match(run.stderr, /nothing was written/)
assert.equal(readFileSync(join(dir, 'docs/packaging/homebrew-tap/Formula/qqq.rb'), 'utf8'), formula)
assert.equal(readFileSync(join(dir, 'docs/packaging/qqq.json'), 'utf8'), manifest)
writeFileSync(join(dir, 'SHA256SUMS'), sumsText())
const ok = spawnSync(process.execPath, [join(dir, 'scripts/update-packaging.mjs'), join(dir, 'SHA256SUMS')], { encoding: 'utf8' })
assert.equal(ok.status, 0, ok.stderr)
assert.ok(!readFileSync(join(dir, 'docs/packaging/qqq.json'), 'utf8').includes(ZERO))
})