gstack/bin/gstack-egress-receipt

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 ...');
}