Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.1 4f41f41a05 fix(release): update-packaging refuses to write missing sums; real 1.0.0 sums
scripts/update-packaging.mjs could not be shown to write zeros: run against the real
cli-v1.0.0 SHA256SUMS from the all-zero placeholders at 1bc4ddd it fills correct values
(reproduced in a clean worktree), and branch head 8fc630d already carried them. The old
script was fragile, though: it read lines with split/reverse (a `*file` binary-mode name
or a stray column would silently mis-key), never checked that a sum was 64 hex, accepted
all-zero sums, did not check that any placeholder had actually matched, and wrote the
formula before it knew the manifest would succeed.

Now it parses `shasum -a 256` lines strictly, requires every target to be 64 lowercase
hex and not all zeros, requires at least one url/sha256 pair and no unfilled sha256 left in
the formula, computes both files first and writes only if everything passed, otherwise
exits 1 with "nothing was written". Tested in scripts/update-packaging.test.mjs (fixture
SHA256SUMS; run by `npm test` via `test:scripts`). Docs (packaging README, release runbook
step 7, tap README) say to compare the diff with SHA256SUMS and that tap/bucket CI catches
a wrong or zero sum only when its PR is opened and read.

Ran against /Volumes/HD-2/Git.Local/QRun-IO/.worktrees/qqq-cli-release/dist/SHA256SUMS;
the packaging files are unchanged from 8fc630d, and each sum was checked by hand against it:
  darwin-arm64  5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211
  darwin-x64    eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1
  linux-arm64   25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc
  linux-x64     804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41
  windows-x64   cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176
(linux musl sums are in SHA256SUMS but not used by the formula or manifest.)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:57:49 -06:00
james.maesandClaude Fable 5.1 8fc630df42 packaging: qqq 1.0.0 checksums for the Homebrew formula and the Scoop manifest
Written by scripts/update-packaging.mjs from the SHA256SUMS published on the cli-v1.0.0 release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 12:50:53 -06:00
3 changed files with 16 additions and 54 deletions

No files matched your search

+5 -29
View File
@@ -14,55 +14,31 @@ 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 => { safeWrite(process.stdout, text) },
err: (text: string): void => { safeWrite(process.stderr, text) },
out: (text: string): void => { process.stdout.write(text) },
err: (text: string): void => { process.stderr.write(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 BROKEN_PIPE_CODES.has(String((error as NodeJS.ErrnoException | undefined)?.code))
return (error as NodeJS.ErrnoException | undefined)?.code === 'EPIPE'
}
/**
* 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 {
/** 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 {
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). */
+4 -1
View File
@@ -33,7 +33,10 @@ 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.exit(0)
if (isBrokenPipe(error)) {
process.exitCode = 0
return
}
const { code, text } = describeError(error, Boolean(program.opts().trace))
process.stderr.write(text + '\n')
process.exitCode = code
+7 -24
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: 5000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 2000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
])
})
after(async () => {
@@ -30,40 +30,23 @@ 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[] = []
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 })
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code))
listeners[0]!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
uncaught!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
assert.deepEqual(exits, [0, 0])
assert.deepEqual(exits, [0])
assert.throws(() => listeners[0]!(new Error('disk full')), /disk full/)
})
/** 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') => {
test('a closed stdout ends the command quietly with status 0 and no stack trace', async () => {
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', '5000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '2000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let stderr = ''
child.stderr.on('data', (d: Buffer) => (stderr += d.toString('utf8')))
// 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())
child.stdout.once('data', () => child.stdout.destroy())
const code = await new Promise<number | null>((resolve) => child.on('close', resolve))
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.equal(code, 0)
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
})
})