diff --git a/src/cli.js b/src/cli.js index 9aeb724..f28e015 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1244,12 +1244,51 @@ function statusCmd(args) { return code } -function voyageHandoffCmd(names) { - if (!names || names.length === 0) { +function voyageHandoffCmd(args) { + // Collect voyage names, respecting --session flag. + // Bare --session applies retroactively to the last voyage name + // (or forward if no voyage names yet). --session= applies to + // the next voyage name (with fallback to last if none follows). + const items = [] + let nextSession = null + let waitingForSession = false + + for (const arg of args) { + if (arg === "--session") { + waitingForSession = true + continue + } + if (arg.startsWith("--session=")) { + nextSession = arg.slice("--session=".length) + continue + } + if (waitingForSession) { + // bare --session : retroactive to last, or forward if none + if (items.length > 0) { + items[items.length - 1].session = arg + } else { + nextSession = arg + } + waitingForSession = false + continue + } + // positional voyage name + const session = nextSession ?? `voyage-${arg}` + items.push({ voyage: arg, session }) + nextSession = null + } + + // Unconsumed nextSession falls back to last item + if (nextSession && items.length > 0) { + items[items.length - 1].session = nextSession + } + + if (items.length === 0) { console.error("Usage: armada voyage-handoff [...]") return 1 } - console.log(formatHandoffBlock(names)) + + console.log(formatHandoffBlock(items)) return 0 } async function releaseCmd(args) { diff --git a/src/feature-commands.js b/src/feature-commands.js index 1de2c58..32c118d 100644 --- a/src/feature-commands.js +++ b/src/feature-commands.js @@ -32,6 +32,9 @@ export function validateName(name) { if (name.startsWith(".") || name.endsWith(".")) { throw new Error(`invalid feature name "${name}": must not start or end with "."`) } + if (name.startsWith("voyage-")) { + throw new Error(`invalid feature name "${name}": must not start with "voyage-" (use --name if you need that exact tmux session)`) + } if (name.length > 64) throw new Error(`invalid feature name "${name}": must be 64 chars or fewer`) } diff --git a/src/handoff.js b/src/handoff.js index bd82d25..cec8ba0 100644 --- a/src/handoff.js +++ b/src/handoff.js @@ -1,8 +1,21 @@ -export function formatHandoffBlock(sessions) { - if (!sessions || sessions.length === 0) return "" +/** + * Format a handoff block for dispatched voyages. + * + * @param {(string|{voyage: string, session: string})[]} items + * - string: voyage name (session defaults to `voyage-${name}`) + * - object: explicit { voyage, session } override + */ +export function formatHandoffBlock(items) { + if (!items || items.length === 0) return "" - const lines = sessions.map( - (name) => ` - ${name} (tmux session: ${name}) attach: armada voyage attach ${name}` + const entries = items.map((item) => { + const voyage = typeof item === "string" ? item : item.voyage + const session = typeof item === "string" ? `voyage-${item}` : item.session + return { voyage, session } + }) + + const lines = entries.map( + (e) => ` - ${e.voyage} (tmux session: ${e.session}) attach: armada voyage attach ${e.session}` ) return `--- HANDOFF --- diff --git a/tests/feature-name-validation.test.js b/tests/feature-name-validation.test.js new file mode 100644 index 0000000..cd47fff --- /dev/null +++ b/tests/feature-name-validation.test.js @@ -0,0 +1,15 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { validateName } from "../src/feature-commands.js" + +test("validateName rejects names starting with voyage-", () => { + assert.throws(() => validateName("voyage-foo"), /must not start with "voyage-"/) + assert.throws(() => validateName("voyage-"), /must not start with "voyage-"/) +}) + +test("validateName accepts names ending with -voyage or containing voyage-", () => { + assert.doesNotThrow(() => validateName("foo-voyage")) + assert.doesNotThrow(() => validateName("my-voyage-foo")) + assert.doesNotThrow(() => validateName("voyage")) + assert.doesNotThrow(() => validateName("myfeature")) +}) diff --git a/tests/handoff.test.js b/tests/handoff.test.js new file mode 100644 index 0000000..8cdf79d --- /dev/null +++ b/tests/handoff.test.js @@ -0,0 +1,92 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { formatHandoffBlock } from "../src/handoff.js" +import { runCli } from "./helpers.js" + +// --- formatHandoffBlock unit tests --- + +test("formatHandoffBlock with string inputs prefixes session with voyage-", () => { + const out = formatHandoffBlock(["doc-chat", "billing"]) + assert.match(out, /\(tmux session: voyage-doc-chat\)/) + assert.match(out, /\(tmux session: voyage-billing\)/) + assert.match(out, /armada voyage attach voyage-doc-chat/) + assert.match(out, /armada voyage attach voyage-billing/) + // voyage name itself stays as-is in the label + assert.match(out, /- doc-chat/) + assert.match(out, /- billing/) +}) + +test("formatHandoffBlock with object inputs uses session verbatim", () => { + const out = formatHandoffBlock([ + { voyage: "doc-chat", session: "custom-session" }, + { voyage: "billing", session: "voyage-billing" }, + ]) + assert.match(out, /\(tmux session: custom-session\)/) + assert.match(out, /\(tmux session: voyage-billing\)/) + assert.match(out, /armada voyage attach custom-session/) + assert.match(out, /armada voyage attach voyage-billing/) +}) + +test("formatHandoffBlock with mixed string and object inputs", () => { + const out = formatHandoffBlock([ + "doc-chat", + { voyage: "billing", session: "billing-custom" }, + ]) + assert.match(out, /\(tmux session: voyage-doc-chat\)/) + assert.match(out, /\(tmux session: billing-custom\)/) +}) + +test("formatHandoffBlock with empty array returns empty string", () => { + assert.strictEqual(formatHandoffBlock([]), "") +}) + +test("formatHandoffBlock with null returns empty string", () => { + assert.strictEqual(formatHandoffBlock(null), "") +}) + +test("formatHandoffBlock with undefined returns empty string", () => { + assert.strictEqual(formatHandoffBlock(undefined), "") +}) + +// --- voyageHandoffCmd integration tests --- + +test("voyage-handoff doc-chat uses prefixed session name", async () => { + const { stdout, code } = await runCli(["voyage-handoff", "doc-chat"]) + assert.strictEqual(code, 0) + assert.match(stdout, /voyage-doc-chat/) + assert.match(stdout, /armada voyage attach voyage-doc-chat/) +}) + +test("voyage-handoff doc-chat --session custom-session uses custom session", async () => { + const { stdout, code } = await runCli(["voyage-handoff", "doc-chat", "--session", "custom-session"]) + assert.strictEqual(code, 0) + assert.match(stdout, /\(tmux session: custom-session\)/) + assert.match(stdout, /armada voyage attach custom-session/) + assert.doesNotMatch(stdout, /voyage-doc-chat/) // not used as session for that line +}) + +test("voyage-handoff doc-chat --session=custom-session uses equals form", async () => { + const { stdout, code } = await runCli(["voyage-handoff", "doc-chat", "--session=custom-session"]) + assert.strictEqual(code, 0) + assert.match(stdout, /\(tmux session: custom-session\)/) + assert.match(stdout, /armada voyage attach custom-session/) +}) + +test("voyage-handoff multiple names each get correct sessions", async () => { + const { stdout, code } = await runCli(["voyage-handoff", "doc-chat", "--session", "s1", "billing", "--session=s2", "pricing"]) + assert.strictEqual(code, 0) + // doc-chat gets session s1 + assert.match(stdout, /doc-chat.*\(tmux session: s1\)/) + assert.match(stdout, /armada voyage attach s1/) + // billing gets session voyage-billing (reset after s1 consumed) + assert.match(stdout, /billing.*\(tmux session: voyage-billing\)/) + // pricing gets session s2 + assert.match(stdout, /pricing.*\(tmux session: s2\)/) +}) + +test("voyage-handoff with no names exits 1", async () => { + const { stdout, stderr, code } = await runCli(["voyage-handoff"]) + assert.strictEqual(code, 1) + assert.match(stderr, /Usage:/) + assert.strictEqual(stdout, "") +})