Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> flag.
// Bare --session <value> applies retroactively to the last voyage name
// (or forward if no voyage names yet). --session=<value> 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 <value>: 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 <name> [<name>...]")
return 1
}
console.log(formatHandoffBlock(names))

console.log(formatHandoffBlock(items))
return 0
}
async function releaseCmd(args) {
Expand Down
3 changes: 3 additions & 0 deletions src/feature-commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}

Expand Down
21 changes: 17 additions & 4 deletions src/handoff.js
Original file line number Diff line number Diff line change
@@ -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 ---
Expand Down
15 changes: 15 additions & 0 deletions tests/feature-name-validation.test.js
Original file line number Diff line number Diff line change
@@ -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"))
})
92 changes: 92 additions & 0 deletions tests/handoff.test.js
Original file line number Diff line number Diff line change
@@ -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, "")
})