#!/usr/bin/env node // Usage: scripts/mosaic queue [args], or node packages/queue/src/cli.mjs. // // Reads: list | show ID | next [SEAT] // Changes: add --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT] // [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required] // move ID STATE [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT] // release ID | assign ID SEAT | note ID TEXT | set ID FIELD VALUE [--reason TEXT] // genesis --root PATH --branch NAME --map PATH // accept-history --reason TEXT --yes // review request ID | review resolve ID ATTEMPT --comment N // review abandon ID ATTEMPT --reason TEXT --yes // review record ID --verdict approve|changes --comment N --candidate DIGEST // Each needs --op ID and an actor (--by NAME or $MOSAIC_AGENT_NAME). // Read only: review verify-commit ID REF // No log: render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent) // sync [--op ID] | snapshot --out DIR | unlock [--check-gate] // // Exit codes: 0 ok; 1 operation failed; 2 invalid data or refused; // 3 uncertain (visible or durable, not acknowledged; for a request, not // known to be posted); 4 usage. import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { QueueError } from "./errors.mjs"; import { SET_FIELDS } from "./queue.mjs"; import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, verifyCommit, verifySnapshot } from "./store.mjs"; const USAGE = [ "usage: queue list | show ID | next [SEAT]", " queue add --op ID --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT] [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required]", " queue move ID STATE --op ID [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT]", " queue release ID --op ID | assign ID SEAT --op ID | note ID TEXT --op ID", ` queue set ID FIELD VALUE --op ID [--reason TEXT] (fields: ${SET_FIELDS.join(", ")})`, " queue genesis --op ID --root PATH --branch NAME --map PATH", " queue accept-history --op ID --reason TEXT --yes", " queue review request ID --op ID | review resolve ID ATTEMPT --comment N --op ID", " queue review abandon ID ATTEMPT --reason TEXT --yes --op ID", " queue review record ID --verdict approve|changes --comment N --candidate DIGEST --op ID", " queue review verify-commit ID REF", " queue render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent)", " queue sync [--op ID] | snapshot --out DIR | unlock [--check-gate]", " every change takes --by NAME, else $MOSAIC_AGENT_NAME", ].join("\n"); const VALUE_FLAGS = new Set([ "--op", "--by", "--piece", "--gate", "--brief", "--issue", "--note", "--owner", "--gate-owner", "--after", "--reviewer", "--reason", "--candidate", "--evidence", "--root", "--branch", "--map", "--snapshot", "--base-file", "--out", "--verdict", "--comment", ]); const REPEATED = new Set(["--issue", "--after", "--reviewer"]); const BOOL_FLAGS = new Set(["--required", "--yes", "--check", "--current", "--base-absent", "--check-gate"]); function usage(message) { return new QueueError(message, 4); } export function parseArgs(argv) { const pos = []; const flags = new Map(); for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--") { pos.push(...argv.slice(i + 1)); break; } if (VALUE_FLAGS.has(a)) { if (i + 1 >= argv.length) throw usage(`missing value for ${a}`); const v = argv[++i]; if (REPEATED.has(a)) flags.set(a, [...(flags.get(a) ?? []), v]); else if (flags.has(a)) throw usage(`${a} given twice`); else flags.set(a, v); } else if (BOOL_FLAGS.has(a)) { flags.set(a, true); } else if (a.startsWith("--")) { throw usage(`unknown option ${a}`); } else { pos.push(a); } } return { pos, flags }; } function allow(flags, names) { for (const k of flags.keys()) if (!names.includes(k)) throw usage(`${k} does not apply here`); } function positional(pos, n, what) { if (pos.length !== n) throw usage(`expected ${what}`); } function intArg(v, what) { if (!/^[1-9][0-9]{0,6}$/.test(v)) throw usage(`${what} must be a positive integer: ${JSON.stringify(v)}`); return Number(v); } // Gitea comment ids outgrow a row id's seven digits. function commentArg(v) { if (v === null) throw usage("--comment N is required"); if (!/^[1-9][0-9]{0,15}$/.test(v) || !Number.isSafeInteger(Number(v))) throw usage(`--comment must be a comment id: ${JSON.stringify(v)}`); return Number(v); } function review(pos, flags, f, change, opts) { const [sub, ...rest] = pos; switch (sub) { case "request": allow(flags, CHANGE); positional(rest, 1, "review request ID"); return change("review-request", { id: intArg(rest[0], "ID") }); case "resolve": allow(flags, [...CHANGE, "--comment"]); positional(rest, 2, "review resolve ID ATTEMPT"); return change("review-resolve", { id: intArg(rest[0], "ID"), attempt: rest[1], comment: commentArg(f("--comment")) }); case "abandon": allow(flags, [...CHANGE, "--reason", "--yes"]); positional(rest, 2, "review abandon ID ATTEMPT"); if (f("--reason") === null) throw usage("review abandon needs --reason"); return change("review-abandon", { id: intArg(rest[0], "ID"), attempt: rest[1], reason: f("--reason") }, { yes: flags.has("--yes") }); case "record": allow(flags, [...CHANGE, "--verdict", "--comment", "--candidate"]); positional(rest, 1, "review record ID"); for (const k of ["--verdict", "--candidate"]) if (f(k) === null) throw usage(`review record needs ${k}`); return change("review-record", { id: intArg(rest[0], "ID"), verdict: f("--verdict"), comment: commentArg(f("--comment")), candidate: f("--candidate") }); case "verify-commit": allow(flags, []); positional(rest, 2, "review verify-commit ID REF"); return verifyCommit(opts, intArg(rest[0], "ID"), rest[1]); default: throw usage("review takes request, resolve, abandon, record or verify-commit"); } } function afterArg(v) { const m = /^([1-9][0-9]{0,6})(?::(done|settled))?$/.exec(v); if (!m) throw usage(`--after takes ID or ID:settled: ${JSON.stringify(v)}`); return { id: Number(m[1]), when: m[2] ?? "done" }; } function listArg(v) { return v === "" || v === "none" ? [] : v.split(","); } function setValue(field, v) { switch (field) { case "after": return listArg(v).map(afterArg); case "reviewers": return listArg(v); case "issues": case "closes": return listArg(v).map((n) => intArg(n.replace(/^#/, ""), field)); case "required": if (v !== "true" && v !== "false") throw usage("required takes true or false"); return v === "true"; default: return v; } } const CHANGE = ["--op", "--by"]; export function run(argv, opts = {}) { const [verb, ...rest] = argv; if (verb === undefined || verb === "--help" || verb === "-h" || verb === "help") return { out: [USAGE], err: [], code: verb === undefined ? 4 : 0 }; const { pos, flags } = parseArgs(rest); const f = (k) => flags.get(k) ?? null; const change = (v, args, extra = {}) => mutate(opts, { verb: v, op: f("--op"), args, by: f("--by"), ...extra }); switch (verb) { case "list": allow(flags, []); positional(pos, 0, "no arguments"); return list(opts); case "show": allow(flags, []); positional(pos, 1, "show ID"); return show(opts, intArg(pos[0], "ID")); case "next": allow(flags, []); if (pos.length > 1) throw usage("expected next [SEAT]"); return next(opts, pos[0] ?? null); case "add": { allow(flags, [...CHANGE, "--piece", "--gate", "--brief", "--issue", "--note", "--owner", "--gate-owner", "--after", "--reviewer", "--required"]); positional(pos, 0, "only options"); for (const k of ["--piece", "--gate", "--brief"]) if (f(k) === null) throw usage(`add needs ${k}`); return change("add", { piece: f("--piece"), gate: f("--gate"), brief: f("--brief"), issues: (flags.get("--issue") ?? []).map((n) => intArg(n.replace(/^#/, ""), "--issue")), note: f("--note"), owner: f("--owner"), gateOwner: f("--gate-owner"), after: flags.has("--after") ? flags.get("--after").map(afterArg) : null, reviewers: flags.get("--reviewer") ?? null, required: flags.has("--required") ? true : null, }); } case "move": allow(flags, [...CHANGE, "--reason", "--candidate", "--issue", "--evidence"]); positional(pos, 2, "move ID STATE"); if ((flags.get("--issue") ?? []).length > 1) throw usage("move takes one --issue"); return change("move", { id: intArg(pos[0], "ID"), to: pos[1], reason: f("--reason"), candidate: f("--candidate"), evidence: f("--evidence"), issue: flags.has("--issue") ? intArg(flags.get("--issue")[0].replace(/^#/, ""), "--issue") : null, }); case "release": allow(flags, CHANGE); positional(pos, 1, "release ID"); return change("release", { id: intArg(pos[0], "ID") }); case "assign": allow(flags, CHANGE); positional(pos, 2, "assign ID SEAT"); return change("assign", { id: intArg(pos[0], "ID"), seat: pos[1] }); case "note": allow(flags, CHANGE); positional(pos, 2, "note ID TEXT"); return change("note", { id: intArg(pos[0], "ID"), text: pos[1] }); case "set": allow(flags, [...CHANGE, "--reason"]); positional(pos, 3, "set ID FIELD VALUE"); if (!SET_FIELDS.includes(pos[1])) throw usage(`set fields: ${SET_FIELDS.join(", ")}`); return change("set", { id: intArg(pos[0], "ID"), field: pos[1], value: setValue(pos[1], pos[2]), reason: f("--reason") }); case "genesis": allow(flags, [...CHANGE, "--root", "--branch", "--map"]); positional(pos, 0, "only options"); for (const k of ["--root", "--branch", "--map"]) if (f(k) === null) throw usage(`genesis needs ${k}`); return change("genesis", { root: f("--root"), branch: f("--branch"), map: f("--map") }); case "accept-history": allow(flags, [...CHANGE, "--reason", "--yes"]); positional(pos, 0, "only options"); if (f("--reason") === null) throw usage("accept-history needs --reason"); return change("accept-history", { reason: f("--reason") }, { yes: flags.has("--yes") }); case "review": return review(pos, flags, f, change, opts); case "render": allow(flags, ["--check"]); positional(pos, 0, "no arguments"); return renderView(opts, { check: flags.has("--check") }); case "verify": if (flags.has("--snapshot")) { allow(flags, ["--snapshot", "--base-file", "--base-absent"]); positional(pos, 0, "only options"); return verifySnapshot(f("--snapshot"), { baseFile: f("--base-file"), baseAbsent: flags.has("--base-absent") }); } allow(flags, ["--current"]); positional(pos, 0, "no arguments"); return verify(opts, { current: flags.has("--current") }); case "sync": allow(flags, ["--op"]); positional(pos, 0, "no arguments"); return sync(opts, f("--op")); case "snapshot": allow(flags, ["--out"]); positional(pos, 0, "only options"); if (f("--out") === null) throw usage("snapshot needs --out DIR"); return snapshot(opts, f("--out")); case "unlock": allow(flags, ["--check-gate"]); positional(pos, 0, "no arguments"); return unlock(opts, { checkGateOnly: flags.has("--check-gate") }); default: throw usage(`unknown verb ${JSON.stringify(verb)}\n${USAGE}`); } } export function main(argv) { let res; try { res = run(argv); } catch (err) { if (err instanceof QueueError) { process.stderr.write(`queue: ${err.message}\n`); return err.code; } process.stderr.write(`queue: ${err?.stack ?? err}\n`); return 1; } for (const line of res.err) process.stderr.write(`${line}\n`); for (const line of res.out) process.stdout.write(`${line}\n`); return res.code; } if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { process.exitCode = main(process.argv.slice(2)); }