mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
Multi-host + Living Notes. Multi-host foundation: shareable whole-topology staged spin-up brings a rig topology up across multiple hosts in stages; VPS product-factory multi-host hardening ships with rig host verbs (add / list / doctor, capped at three; one built-in product-factory-vps posture, three- valued per item); the transport posture is documented (ssh for pane ops, http-bearer for daemon REST, no cross-transport fallback); the For-You feed aggregates activity across every registered host; and rig file moves files across the topology's registered hosts. Living Notes: durable INTENT / PLAN / DELIVERED signal layer at mission and slice altitude with agent authorship + timestamps; cheap composer surfaces make it the first-class place agents record decisions, plans, and delivered work; the one-structure review contract reads left-to-right as a single vertical stack (INTENT above, PLAN + mockup in the middle, DELIVERED with paired proof at the bottom); a plan change deletes the old plan and writes a new one - never demotes or stacks multiple competing plans. Operationalize the SDLC control plane: conventions SSOT ships in source at docs/reference/sdlc-conventions.md (Living Notes UI section names, proof-contract format + plannedRef mockup pairing, staged- approval locks, C1 proof header + closed sets, three role contracts, curation rule, elastic-middle doctrine, advisory fail-open audit posture); rig scope slice create scaffolds the convention sections + proof + PROOF.md + an IMPLEMENTATION-PRD.md skeleton for every template kind; rig scope audit + rig workspace doctor gain sdlc- convention advisories (records-and-advises, gates on HIGH only); the mission-slice-sop skill ships in canonical source with byte-parity guard; rig proof / rig scope slice create / rig scope slice approve help + cli-reference teach the SDLC flow. BREAKING: rig ps default flips to consolidated all-active-rigs compact projection (the v0.4.0 current-rig-only default is retired - it hid running rigs from the operator's field of view). --json is scope-not-shape (still a bare array with existing per-entry keys; scope widens to all non-archived rigs including stopped ones; one-line migration for the old fleet firehose: rig ps --nodes -A --full). -A / --all-rigs keeps exactly ONE meaning (the --nodes fleet widener; bare -A is a structured teaching error). --nodes names its scope everywhere (session default local-only; --host <id> --nodes requires --rig or -A; multi-host fan-out is rollup-only by default; the explicit ladder --all-hosts --nodes -A --full fans out per-node with hostId-stamped projected rows). --all-hosts / --hosts --json emits the shared AggregatedPayload (items + hosts with closed-enum statuses: ok | unreachable | unsupported-transport | auth-failed). Operator UX: agent altitude coordination panel scopes multi-agent state to the right altitude (workspace / mission / slice) so the coordination surface reads without per-seat drowning; composes with the workspace observability tabs from 0.4.1. Docs closeout catches docs/as-built/architecture.md, docs/as-built/cli-reference.md, and the codemaps up to shipped state. Migrations: additive only. Existing v0.4.3 databases upgrade by running rig daemon start. Behavior change (rig ps default view): the compact all-active-rigs projection replaces the current-rig-only default. If your call sites relied on the v0.4.3 default shape, add --full for the rich per-node projection or --rig <name> for the prior scope. Public-safety scrub applied inline to packages/ui/twin/corrective/ fixtures-corrective.ts (one demo-fixture role identifier neutralized to the demo-namespace baseline established at 0.4.1). See CHANGELOG.md and docs/releases/v0.4.4.md.
590 lines
27 KiB
TypeScript
590 lines
27 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { sendCommand, type SendDeps } from "../src/commands/send.js";
|
|
import { captureCommand, type CaptureDeps } from "../src/commands/capture.js";
|
|
import { psCommand, type PsDeps } from "../src/commands/ps.js";
|
|
import { whoamiCommand, type WhoamiDeps } from "../src/commands/whoami.js";
|
|
import type { CrossHostResult } from "../src/cross-host-executor.js";
|
|
import type { HostRegistryLoadResult } from "../src/host-registry.js";
|
|
|
|
// Capture stdout / stderr / exitCode for command actions.
|
|
interface CapturedOutput {
|
|
stdoutLines: string[];
|
|
stderrLines: string[];
|
|
stdoutWrites: string[];
|
|
stderrWrites: string[];
|
|
exitCode: number | undefined;
|
|
}
|
|
|
|
let captured: CapturedOutput;
|
|
let originalLog: typeof console.log;
|
|
let originalError: typeof console.error;
|
|
let originalStdoutWrite: typeof process.stdout.write;
|
|
let originalStderrWrite: typeof process.stderr.write;
|
|
|
|
beforeEach(() => {
|
|
captured = { stdoutLines: [], stderrLines: [], stdoutWrites: [], stderrWrites: [], exitCode: undefined };
|
|
originalLog = console.log;
|
|
originalError = console.error;
|
|
originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
console.log = (...args: unknown[]) => { captured.stdoutLines.push(args.map(String).join(" ")); };
|
|
console.error = (...args: unknown[]) => { captured.stderrLines.push(args.map(String).join(" ")); };
|
|
process.stdout.write = ((s: string | Uint8Array) => { captured.stdoutWrites.push(typeof s === "string" ? s : Buffer.from(s).toString("utf-8")); return true; }) as typeof process.stdout.write;
|
|
process.stderr.write = ((s: string | Uint8Array) => { captured.stderrWrites.push(typeof s === "string" ? s : Buffer.from(s).toString("utf-8")); return true; }) as typeof process.stderr.write;
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
console.log = originalLog;
|
|
console.error = originalError;
|
|
process.stdout.write = originalStdoutWrite;
|
|
process.stderr.write = originalStderrWrite;
|
|
captured.exitCode = process.exitCode;
|
|
process.exitCode = undefined;
|
|
});
|
|
|
|
const KNOWN_REGISTRY: HostRegistryLoadResult = {
|
|
ok: true,
|
|
registry: {
|
|
hosts: [{ id: "vm-a", transport: "ssh", target: "vm-a.local" }],
|
|
},
|
|
};
|
|
|
|
function deps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): SendDeps & CaptureDeps {
|
|
const lifecycleDeps = {} as never;
|
|
const clientFactory = (() => ({}) as never) as never;
|
|
return {
|
|
lifecycleDeps,
|
|
clientFactory,
|
|
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
|
|
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// `rig send --host <id>`
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("send --host (cross-host short-circuit)", () => {
|
|
it("happy path: forwards reconstructed argv to the executor and surfaces remote stdout verbatim", async () => {
|
|
const captureCalls: { argv?: readonly string[] } = {};
|
|
const cmd = sendCommand(deps({
|
|
run: async (_h, argv) => {
|
|
captureCalls.argv = argv;
|
|
return { ok: true, failedStep: "none", stdout: "Sent to dev-impl@my-rig\nVerified: yes\n", stderr: "", remoteExitCode: 0 };
|
|
},
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hello world", "--verify"], { from: "user" });
|
|
expect(captureCalls.argv).toEqual(["rig", "send", "dev-impl@my-rig", "hello world", "--verify"]);
|
|
expect(captured.stdoutLines[0]).toBe("[via host=vm-a (vm-a.local)]");
|
|
const stdoutText = captured.stdoutWrites.join("");
|
|
expect(stdoutText).toContain("Verified: yes");
|
|
expect(process.exitCode).toBeUndefined();
|
|
});
|
|
|
|
it("--verify honesty: SSH success + remote 'Verified: no' surfaces in output, NOT collapsed into success+silence", async () => {
|
|
const cmd = sendCommand(deps({
|
|
run: async () => ({ ok: true, failedStep: "none", stdout: "Sent to dev-impl@my-rig\nVerified: no\n", stderr: "", remoteExitCode: 0 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hi", "--verify"], { from: "user" });
|
|
const stdoutText = captured.stdoutWrites.join("");
|
|
expect(stdoutText).toContain("Verified: no"); // remote authority surfaced
|
|
// ssh layer succeeded; CLI does NOT inject Verified: yes.
|
|
expect(stdoutText).not.toMatch(/^Verified: yes$/m);
|
|
});
|
|
|
|
it("forwards --force, --wait-for-idle, --json flags into the remote argv", async () => {
|
|
const captureCalls: { argv?: readonly string[] } = {};
|
|
const cmd = sendCommand(deps({
|
|
run: async (_h, argv) => {
|
|
captureCalls.argv = argv;
|
|
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
|
|
},
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--force", "--json"], { from: "user" });
|
|
expect(captureCalls.argv).toContain("--force");
|
|
expect(captureCalls.argv).toContain("--json");
|
|
});
|
|
|
|
it("JSON output wraps the executor result in a cross_host envelope", async () => {
|
|
const cmd = sendCommand(deps({
|
|
run: async () => ({ ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--json"], { from: "user" });
|
|
const out = JSON.parse(captured.stdoutLines[0]!);
|
|
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
|
expect(out.result.ok).toBe(true);
|
|
});
|
|
|
|
it("unknown host id surfaces a friendly error with discoverability hint", async () => {
|
|
const cmd = sendCommand(deps());
|
|
await cmd.parseAsync(["--host", "vm-unknown", "s@r", "msg"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-unknown'");
|
|
expect(captured.stderrLines.join("\n")).toContain("vm-a"); // known-id hint
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("registry load failure surfaces the error", async () => {
|
|
const cmd = sendCommand(deps({
|
|
registry: { ok: false, error: "host registry not found at /nope" },
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("host registry not found");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("ssh-unreachable failure → exit 1 with operator-actionable message", async () => {
|
|
const cmd = sendCommand(deps({
|
|
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "ssh: connect to host: Connection refused" }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
|
|
expect(captured.stderrLines.join("\n")).toContain("Verify SSH access");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("permission-gate failure → exit 1 with field-note hint", async () => {
|
|
const cmd = sendCommand(deps({
|
|
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("permission/auth gate");
|
|
expect(captured.stderrLines.join("\n")).toContain("See keychain doc");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("remote-daemon-unreachable → exit 1 with daemon-start hint", async () => {
|
|
const cmd = sendCommand(deps({
|
|
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("could not reach the remote daemon");
|
|
expect(captured.stderrLines.join("\n")).toContain("rig daemon start");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("remote-command-failed → exit 1 with remote stderr surfaced", async () => {
|
|
const cmd = sendCommand(deps({
|
|
run: async () => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "session not found", remoteExitCode: 3 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("remote rig command on host=vm-a failed");
|
|
expect(captured.stderrLines.join("\n")).toContain("session not found");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("JSON output for failure surfaces failedStep + permission-gate hint under result", async () => {
|
|
const cmd = sendCommand(deps({
|
|
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--json"], { from: "user" });
|
|
const out = JSON.parse(captured.stdoutLines[0]!);
|
|
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
|
expect(out.result.ok).toBe(false);
|
|
expect(out.result.failedStep).toBe("permission-gate");
|
|
expect(out.result.hint).toBe("See keychain doc");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// `rig capture --host <id>`
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("capture --host (cross-host short-circuit)", () => {
|
|
it("happy path: forwards reconstructed argv with session + lines", async () => {
|
|
const captureCalls: { argv?: readonly string[] } = {};
|
|
const cmd = captureCommand(deps({
|
|
run: async (_h, argv) => {
|
|
captureCalls.argv = argv;
|
|
return { ok: true, failedStep: "none", stdout: "pane content here\n", stderr: "", remoteExitCode: 0 };
|
|
},
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "--lines", "50"], { from: "user" });
|
|
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "50"]);
|
|
expect(captured.stdoutLines[0]).toBe("[via host=vm-a (vm-a.local)]");
|
|
expect(captured.stdoutWrites.join("")).toContain("pane content here");
|
|
});
|
|
|
|
it("multi-target form: forwards --rig and --pod when no session is supplied", async () => {
|
|
const captureCalls: { argv?: readonly string[] } = {};
|
|
const cmd = captureCommand(deps({
|
|
run: async (_h, argv) => {
|
|
captureCalls.argv = argv;
|
|
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
|
|
},
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "--rig", "my-rig", "--pod", "dev", "--json"], { from: "user" });
|
|
expect(captureCalls.argv).toEqual(["rig", "capture", "--rig", "my-rig", "--pod", "dev", "--lines", "20", "--json"]);
|
|
});
|
|
|
|
it("unknown host id surfaces a friendly error", async () => {
|
|
const cmd = captureCommand(deps());
|
|
await cmd.parseAsync(["--host", "vm-bogus", "s@r"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("ssh-unreachable failure surfaces actionable error", async () => {
|
|
const cmd = captureCommand(deps({
|
|
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "Connection refused" }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("JSON output wraps result in cross_host envelope", async () => {
|
|
const cmd = captureCommand(deps({
|
|
run: async () => ({ ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "s@r", "--json"], { from: "user" });
|
|
const out = JSON.parse(captured.stdoutLines[0]!);
|
|
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
|
expect(out.result.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compat regression: `rig send` and `rig capture` WITHOUT --host MUST NOT add
|
|
// the cross-host annotation and MUST NOT call the executor at all.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("compat regression: no --host means no cross-host annotation", () => {
|
|
it("send help text mentions --host (so operators discover it)", () => {
|
|
const cmd = sendCommand(deps());
|
|
expect(cmd.helpInformation()).toContain("--host");
|
|
});
|
|
|
|
it("capture help text mentions --host", () => {
|
|
const cmd = captureCommand(deps());
|
|
expect(cmd.helpInformation()).toContain("--host");
|
|
});
|
|
|
|
it("send without --host does NOT call the cross-host executor (registry loader untouched, executor untouched)", async () => {
|
|
const loaderSpy = vi.fn();
|
|
const runSpy = vi.fn();
|
|
const cmd = sendCommand({
|
|
lifecycleDeps: makeFailingLifecycleDeps(),
|
|
clientFactory: () => ({} as never),
|
|
hostRegistryLoader: loaderSpy as never,
|
|
crossHostRun: runSpy as never,
|
|
});
|
|
await cmd.parseAsync(["s@r", "msg"], { from: "user" });
|
|
// local path short-circuits at "Daemon not running" without consulting cross-host machinery.
|
|
expect(loaderSpy).not.toHaveBeenCalled();
|
|
expect(runSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("capture without --host does NOT call the cross-host executor", async () => {
|
|
const loaderSpy = vi.fn();
|
|
const runSpy = vi.fn();
|
|
const cmd = captureCommand({
|
|
lifecycleDeps: makeFailingLifecycleDeps(),
|
|
clientFactory: () => ({} as never),
|
|
hostRegistryLoader: loaderSpy as never,
|
|
crossHostRun: runSpy as never,
|
|
});
|
|
await cmd.parseAsync(["s@r"], { from: "user" });
|
|
expect(loaderSpy).not.toHaveBeenCalled();
|
|
expect(runSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function makeFailingLifecycleDeps(): never {
|
|
// Stub LifecycleDeps that responds with "no daemon" on every probe — the
|
|
// local-path branch short-circuits with "Daemon not running" before doing
|
|
// anything cross-host.
|
|
return {
|
|
exists: (_p: string) => false,
|
|
readFileText: (_p: string) => "",
|
|
spawnDetached: () => ({ pid: 0 }),
|
|
isPidAlive: (_p: number) => false,
|
|
fetchHealthDetail: async () => ({ ok: false, code: "unreachable" }),
|
|
} as never;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// `rig ps --host <id>` (C9b)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function psDeps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): PsDeps {
|
|
return {
|
|
lifecycleDeps: {} as never,
|
|
clientFactory: (() => ({}) as never) as never,
|
|
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
|
|
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
|
|
};
|
|
}
|
|
|
|
describe("ps --host (cross-host short-circuit)", () => {
|
|
it("argv reconstruction: forwards every shaping flag in operator-declared order", async () => {
|
|
const captureCalls: { argv?: readonly string[] } = {};
|
|
const cmd = psCommand(psDeps({
|
|
run: async (_h, argv) => {
|
|
captureCalls.argv = argv;
|
|
return { ok: true, failedStep: "none", stdout: '[{"rigName":"r1"}]\n', stderr: "", remoteExitCode: 0 };
|
|
},
|
|
}));
|
|
await cmd.parseAsync([
|
|
"--host", "vm-a", "--nodes", "-A", "--full", "--limit", "20",
|
|
"--fields", "rigName,sessionStatus", "--summary", "--filter", "status=running", "--json",
|
|
], { from: "user" });
|
|
expect(captureCalls.argv).toEqual([
|
|
"rig", "ps", "--nodes", "--full", "--all-rigs", "--limit", "20", "--fields", "rigName,sessionStatus",
|
|
"--summary", "--filter", "status=running", "--json",
|
|
]);
|
|
});
|
|
|
|
it("argv reconstruction: bare `--host vm-a` produces ['rig','ps'] with no extra flags", async () => {
|
|
const captureCalls: { argv?: readonly string[] } = {};
|
|
const cmd = psCommand(psDeps({
|
|
run: async (_h, argv) => {
|
|
captureCalls.argv = argv;
|
|
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
|
|
},
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
|
expect(captureCalls.argv).toEqual(["rig", "ps"]);
|
|
});
|
|
|
|
it("verbatim remote stdout passthrough on success — no double-JSON-wrapping", async () => {
|
|
const remoteJson = '[{"rigName":"r1","status":"running"},{"rigName":"r2","status":"stopped"}]\n';
|
|
const cmd = psCommand(psDeps({
|
|
run: async () => ({ ok: true, failedStep: "none", stdout: remoteJson, stderr: "", remoteExitCode: 0 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
|
const stdoutText = captured.stdoutWrites.join("");
|
|
// Output is verbatim remote stdout; NOT wrapped in {cross_host:..., result:...}.
|
|
expect(stdoutText).toBe(remoteJson);
|
|
expect(stdoutText).not.toContain("cross_host");
|
|
});
|
|
|
|
it("ssh-unreachable failure surfaces actionable error and exits 1", async () => {
|
|
const cmd = psCommand(psDeps({
|
|
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "ssh: connect refused" }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("permission-gate failure surfaces hint and exits 1", async () => {
|
|
const cmd = psCommand(psDeps({
|
|
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("permission/auth gate");
|
|
expect(captured.stderrLines.join("\n")).toContain("See keychain doc");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("remote-daemon-unreachable surfaces daemon-start hint", async () => {
|
|
const cmd = psCommand(psDeps({
|
|
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("could not reach the remote daemon");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("remote-command-failed surfaces remote stderr", async () => {
|
|
const cmd = psCommand(psDeps({
|
|
run: async () => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "Some rig error", remoteExitCode: 3 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("remote rig command on host=vm-a failed");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("unknown host id surfaces friendly error with discoverability hint", async () => {
|
|
const cmd = psCommand(psDeps());
|
|
await cmd.parseAsync(["--host", "vm-bogus"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("JSON output for failure surfaces top-level failedStep envelope", async () => {
|
|
const cmd = psCommand(psDeps({
|
|
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
|
const out = JSON.parse(captured.stdoutLines[0]!);
|
|
expect(out.ok).toBe(false);
|
|
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
|
expect(out.failedStep).toBe("permission-gate");
|
|
expect(out.hint).toBe("See keychain doc");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("help text mentions --host (operator discoverability)", () => {
|
|
const cmd = psCommand(psDeps());
|
|
expect(cmd.helpInformation()).toContain("--host");
|
|
});
|
|
|
|
it("compat regression: ps without --host does NOT call registry loader OR executor", async () => {
|
|
const loaderSpy = vi.fn();
|
|
const runSpy = vi.fn();
|
|
const cmd = psCommand({
|
|
lifecycleDeps: makeFailingLifecycleDeps(),
|
|
clientFactory: () => ({} as never),
|
|
hostRegistryLoader: loaderSpy as never,
|
|
crossHostRun: runSpy as never,
|
|
});
|
|
await cmd.parseAsync([], { from: "user" });
|
|
expect(loaderSpy).not.toHaveBeenCalled();
|
|
expect(runSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// `rig whoami --host <id>` (C9b')
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function whoamiDeps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): WhoamiDeps {
|
|
return {
|
|
lifecycleDeps: {} as never,
|
|
clientFactory: (() => ({}) as never) as never,
|
|
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
|
|
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
|
|
};
|
|
}
|
|
|
|
describe("whoami --host (cross-host short-circuit)", () => {
|
|
it("argv reconstruction: forwards --node-id, --session, --json in operator-declared order", async () => {
|
|
const captureCalls: { argv?: readonly string[] } = {};
|
|
const cmd = whoamiCommand(whoamiDeps({
|
|
run: async (_h, argv) => {
|
|
captureCalls.argv = argv;
|
|
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
|
|
},
|
|
}));
|
|
await cmd.parseAsync([
|
|
"--host", "vm-a", "--node-id", "01ABC", "--session", "foo@rig", "--json",
|
|
], { from: "user" });
|
|
expect(captureCalls.argv).toEqual([
|
|
"rig", "whoami", "--node-id", "01ABC", "--session", "foo@rig", "--json",
|
|
]);
|
|
});
|
|
|
|
it("argv reconstruction: bare `--host vm-a` produces ['rig','whoami'] with no extra flags", async () => {
|
|
const captureCalls: { argv?: readonly string[] } = {};
|
|
const cmd = whoamiCommand(whoamiDeps({
|
|
run: async (_h, argv) => {
|
|
captureCalls.argv = argv;
|
|
return { ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 };
|
|
},
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
|
expect(captureCalls.argv).toEqual(["rig", "whoami"]);
|
|
});
|
|
|
|
it("cross-host runs BEFORE local identity-source resolution (no local --node-id/--session required)", async () => {
|
|
// Critical for whoami: the local resolveIdentitySource() walks env vars and
|
|
// tmux pane metadata. Cross-host must NOT require any of that — the remote
|
|
// rig has its own identity context.
|
|
const cmd = whoamiCommand(whoamiDeps({
|
|
run: async () => ({ ok: true, failedStep: "none", stdout: '{"rigName":"remote"}\n', stderr: "", remoteExitCode: 0 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
|
// Should NOT have errored on "Cannot determine identity"; should have
|
|
// successfully written remote stdout.
|
|
expect(captured.stderrLines.join("\n")).not.toContain("Cannot determine identity");
|
|
const stdoutText = captured.stdoutWrites.join("");
|
|
expect(stdoutText).toContain("remote");
|
|
});
|
|
|
|
it("verbatim remote stdout passthrough on success — no double-JSON-wrapping", async () => {
|
|
const remoteJson = '{"resolvedBy":"node_id","identity":{"rigName":"x"}}\n';
|
|
const cmd = whoamiCommand(whoamiDeps({
|
|
run: async () => ({ ok: true, failedStep: "none", stdout: remoteJson, stderr: "", remoteExitCode: 0 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
|
const stdoutText = captured.stdoutWrites.join("");
|
|
expect(stdoutText).toBe(remoteJson);
|
|
expect(stdoutText).not.toContain("cross_host");
|
|
});
|
|
|
|
it("ssh-unreachable failure exits 1 with actionable message", async () => {
|
|
const cmd = whoamiCommand(whoamiDeps({
|
|
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "Connection refused" }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("unknown host id surfaces friendly error", async () => {
|
|
const cmd = whoamiCommand(whoamiDeps());
|
|
await cmd.parseAsync(["--host", "vm-bogus"], { from: "user" });
|
|
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("JSON output for failure surfaces top-level failedStep envelope", async () => {
|
|
const cmd = whoamiCommand(whoamiDeps({
|
|
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
|
|
}));
|
|
await cmd.parseAsync(["--host", "vm-a", "--json"], { from: "user" });
|
|
const out = JSON.parse(captured.stdoutLines[0]!);
|
|
expect(out.ok).toBe(false);
|
|
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
|
|
expect(out.failedStep).toBe("remote-daemon-unreachable");
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it("help text mentions --host", () => {
|
|
const cmd = whoamiCommand(whoamiDeps());
|
|
expect(cmd.helpInformation()).toContain("--host");
|
|
});
|
|
|
|
it("compat regression: whoami without --host does NOT call registry loader OR executor", async () => {
|
|
const loaderSpy = vi.fn();
|
|
const runSpy = vi.fn();
|
|
const cmd = whoamiCommand({
|
|
lifecycleDeps: makeFailingLifecycleDeps(),
|
|
clientFactory: () => ({} as never),
|
|
hostRegistryLoader: loaderSpy as never,
|
|
crossHostRun: runSpy as never,
|
|
});
|
|
// Passing --node-id avoids the "cannot determine identity" branch; the
|
|
// local daemon-down path runs after that (or partial-result; either way
|
|
// does NOT consult the cross-host machinery).
|
|
await cmd.parseAsync(["--node-id", "abc"], { from: "user" });
|
|
expect(loaderSpy).not.toHaveBeenCalled();
|
|
expect(runSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// `rig ps --host <id>` — OPR.0.4.0.34 breadth-flag forwarding over SSH
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("ps --host (cross-host argv forwarding, OPR.0.4.0.34)", () => {
|
|
function psWithCapture(cap: { argv?: readonly string[] }) {
|
|
return psCommand(deps({
|
|
run: async (_h, argv) => {
|
|
cap.argv = argv;
|
|
return { ok: true, failedStep: "none", stdout: "[]", stderr: "", remoteExitCode: 0 };
|
|
},
|
|
}) as unknown as PsDeps);
|
|
}
|
|
|
|
it("forwards -A/--all-rigs into the remote SSH argv (breadth preserved across the hop)", async () => {
|
|
const cap: { argv?: readonly string[] } = {};
|
|
await psWithCapture(cap).parseAsync(["--host", "vm-a", "-A", "--nodes", "--json"], { from: "user" });
|
|
expect(cap.argv).toContain("--all-rigs");
|
|
expect(cap.argv).toContain("--nodes");
|
|
});
|
|
|
|
it("forwards --active into the remote SSH argv (also covers --running via normalization)", async () => {
|
|
const capRunning: { argv?: readonly string[] } = {};
|
|
await psWithCapture(capRunning).parseAsync(["--host", "vm-a", "--running", "--json"], { from: "user" });
|
|
expect(capRunning.argv).toContain("--active");
|
|
|
|
const capActive: { argv?: readonly string[] } = {};
|
|
await psWithCapture(capActive).parseAsync(["--host", "vm-a", "--active", "--json"], { from: "user" });
|
|
expect(capActive.argv).toContain("--active");
|
|
});
|
|
});
|