mirror of https://github.com/garrytan/gstack.git
85 lines
3.2 KiB
Plaintext
Executable File
85 lines
3.2 KiB
Plaintext
Executable File
#!/usr/bin/env bun
|
|
// gstack-egress-receipt — bun script that BRIDGES shell callers (the bash
|
|
// egress sinks: gstack-telemetry-sync, gstack-update-check, gstack-brain-sync,
|
|
// and the sourced helpers in gstack-egress-lib.sh) into lib/egress-receipt.ts.
|
|
//
|
|
// Usage:
|
|
// gstack-egress-receipt write --sink S --host H --class C \
|
|
// (--payload-file F | --no-payload) [--consent "key=value"]
|
|
// → prints the receipt id on stdout, exit 0.
|
|
// → exit 3 + "EGRESS_RECEIPT_FAILED: ..." on stderr when the receipt
|
|
// cannot be written. Fail-closed callers MUST then refuse the send.
|
|
//
|
|
// gstack-egress-receipt outcome <receipt-id> <status>
|
|
// → best-effort response-status record; never blocks anything.
|
|
//
|
|
// Home: GSTACK_HOME, legacy GSTACK_STATE_DIR, else ~/.gstack.
|
|
// The payload file is hashed as-is: pass the SAME file to curl (`-d @file`)
|
|
// so the receipt hash matches the exact bytes sent (scan-at-sink precedent).
|
|
|
|
import fs from 'node:fs';
|
|
import { EGRESS_RECEIPT_FAILED, sha256Hex, writeOutcome, writeReceipt } from '../lib/egress-receipt';
|
|
|
|
function parseArgs(args: string[], valueFlags: string[], boolFlags: string[]) {
|
|
const values = new Map<string, string>();
|
|
const flags = new Set<string>();
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (valueFlags.includes(arg)) {
|
|
const value = args[++index];
|
|
if (value == null) usage(`${arg} requires a value`);
|
|
values.set(arg, value);
|
|
} else if (boolFlags.includes(arg)) {
|
|
flags.add(arg);
|
|
} else {
|
|
usage(`unknown option: ${arg}`);
|
|
}
|
|
}
|
|
return { values, flags };
|
|
}
|
|
|
|
function usage(message: string): never {
|
|
process.stderr.write(`gstack-egress-receipt: ${message}\n`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const [command, ...rest] = process.argv.slice(2);
|
|
|
|
if (command === 'write') {
|
|
const { values, flags } = parseArgs(rest,
|
|
['--sink', '--host', '--class', '--payload-file', '--consent'], ['--no-payload']);
|
|
const payloadFile = values.get('--payload-file');
|
|
if (!payloadFile && !flags.has('--no-payload')) usage('write requires --payload-file or --no-payload');
|
|
try {
|
|
let bytes = 0;
|
|
let sha256: string | null = null; // --no-payload = git-class op, subprocess owns the bytes
|
|
if (payloadFile) {
|
|
const payload = fs.readFileSync(payloadFile);
|
|
bytes = payload.byteLength;
|
|
sha256 = sha256Hex(payload);
|
|
}
|
|
const { id } = writeReceipt({
|
|
sink: values.get('--sink') as string,
|
|
host: values.get('--host') as string,
|
|
payloadClass: values.get('--class') as string,
|
|
bytes,
|
|
sha256,
|
|
consent: values.get('--consent') ?? 'unspecified',
|
|
});
|
|
process.stdout.write(`${id}\n`);
|
|
} catch (error) {
|
|
process.stderr.write(`${EGRESS_RECEIPT_FAILED}: ${(error as Error)?.message ?? error}\n`);
|
|
process.exit(3);
|
|
}
|
|
} else if (command === 'outcome') {
|
|
const [receipt, status, ...extra] = rest;
|
|
if (!receipt || !status || extra.length) usage('Usage: gstack-egress-receipt outcome <receipt-id> <status>');
|
|
try {
|
|
writeOutcome({ receipt, status });
|
|
} catch {
|
|
// Best-effort: the pre-send receipt is the invariant, the outcome is bookkeeping.
|
|
}
|
|
} else {
|
|
usage('Usage: gstack-egress-receipt write|outcome ...');
|
|
}
|