Files
backnotprop__plannotator/apps/hook/server/cli.test.ts
Michael Ramos 5f33b72b2f feat(remote): tailnet auto-advertise, ready QR code, and a first-class --tailscale mode (#1280)
* feat(remote): resolve urlHost auto from Tailscale for advertised URLs

PLANNOTATOR_URL_HOST=auto (or config urlHost: "auto") detects this
machine's tailnet host at first use in a remote session: MagicDNS name
from tailscale status --json, falling back to the single tailscale ip -4
CGNAT address. Detection is cached per process, never spawns in local
sessions, warns once and falls back to localhost on failure, and stays
strictly display-only: binding remains governed by PLANNOTATOR_REMOTE.

Pure parsers live in the new @plannotator/shared/tailscale module,
vendored to the Pi extension; both runtimes mirror the resolution.

* feat(remote): render a terminal QR code for remote-ready session URLs

Remote sessions print their advertised URL as the lifeline; the usual
next step is opening it on another device (iPad, phone, laptop off the
VPS). handleServerReady now also renders a compact unicode QR of that
URL via the zero-dependency uqr package, TTY-gated so piped stderr and
hook transcripts keep only the plain URL line.

Pi keeps URL-only parity: its ready surface is an in-chat notification,
not a TTY stream, so a QR block would not render there.

* feat(cli): first-class --tailscale mode for review and annotate sessions

plannotator review --tailscale (also annotate and annotate-last/last)
publishes the session over the user's tailnet: the server stays
loopback-bound and the CLI orchestrates tailscale serve --bg
--https=<port> http://127.0.0.1:<port>, then advertises the HTTPS
tailnet URL with a terminal QR code. Nothing listens beyond localhost
and nothing is ever public (serve, never funnel).

Guarantees: preconditions fail with actionable errors (CLI missing,
daemon down or logged out); a pre-existing serve mapping on the chosen
port aborts instead of being stolen and other ports are never touched;
every mapping the process creates is torn down on normal completion,
SIGINT/SIGTERM, and errors via the exit-routed cleanup handler. When
combined with PLANNOTATOR_REMOTE or SSH detection, --tailscale wins and
forces local mode with a stderr notice, which also restores the random
local port so simultaneous sessions get distinct serve mappings.

* fix(remote): await tailscale-ready failures, harden serve teardown and conflict detection

Review fixes for #1280 (external review plus internal security review).

Startup failures no longer hang the session: startReviewServer and
startAnnotateServer now await async ready handlers and stop the server
on rejection, and the CLI's --tailscale ready path resolves publishing
failures itself with an actionable stderr message and exit 1. Under the
bang-prefix skill a hanging loopback server blocked the whole Claude
Code prompt.

Serve teardown is checked, not assumed: a failed off retries once, then
warns with the exact manual command, and a port is only forgotten after
a successful off. SIGHUP (terminal close) is now routed through
process.exit like SIGINT/SIGTERM so exit-time cleanup runs. Docs no
longer claim guaranteed cleanup: --bg mappings survive SIGKILL and
reboots, and the manual removal command is documented.

Conflict detection sees foreground serve sessions (Foreground.*.TCP),
which Tailscale prefers over background mappings, and fails CLOSED on
unrecognizable serve status output instead of assuming the port is
free. The extracted serve URL must match the requested port, so a
version-dependent output shape cannot advertise another mapping's URL.

The annotate agent terminal is gated off by default under --tailscale
behind the existing PLANNOTATOR_AGENT_TERMINAL_REMOTE opt-in: the PTY
token is not an auth boundary against network peers, and tailnet
reachability implies terminal reachability.

Also: --tailscale is rejected with a clear error on unsupported
subcommands and documented in review/annotate/annotate-last and
top-level help; the remote-ready QR renders only for URLs actually
reachable off-machine (never localhost); urlHost is suppressed for
--tailscale runs so the local-session warning cannot mislead; the
duplicated auto-host resolution moved into the shared vendored module;
tailscale-serve tests restore module and process state via a reset
seam.
2026-08-12 12:07:30 -07:00

328 lines
11 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
formatInteractiveNoArgClarification,
formatSubcommandHelp,
formatTopLevelHelp,
formatVersion,
hasHelpFlag,
isInteractiveNoArgInvocation,
isSubcommandHelpInvocation,
isTopLevelHelpInvocation,
isUninstallConfirmationAccepted,
isVersionInvocation,
parseStrictAnnotateOptions,
parseUninstallOptions,
} from "./cli";
describe("CLI top-level help", () => {
test("recognizes top-level --help", () => {
expect(isTopLevelHelpInvocation(["--help"])).toBe(true);
expect(isTopLevelHelpInvocation(["-h"])).toBe(true);
expect(isTopLevelHelpInvocation([])).toBe(false);
expect(isTopLevelHelpInvocation(["review", "--help"])).toBe(false);
});
test("renders concise top-level usage", () => {
const output = formatTopLevelHelp();
expect(output).toContain("plannotator --help");
expect(output).toContain("plannotator --version, -v");
expect(output).toContain("plannotator [--browser <name>]");
expect(output).toContain("plannotator review [--git | --gitbutler] [--tailscale] [PR_URL]");
expect(output).toContain("plannotator annotate <file.md | file.txt | file.html | https://... | folder/>");
expect(output).toContain("[--markdown] [--no-jina]");
expect(output).toContain("plannotator annotate-last [--stdin]");
expect(output).toContain("plannotator copilot-last [--gate] [--json] [--hook]");
expect(output).toContain("plannotator setup-goal <interview|facts>");
expect(output).toContain("plannotator uninstall [--purge] [--yes]");
expect(output).toContain("Run 'plannotator <command> --help' for command-specific usage.");
expect(output).toContain("running 'plannotator' without arguments is for hook integration");
});
});
describe("CLI subcommand help", () => {
test("hasHelpFlag detects --help / -h anywhere", () => {
expect(hasHelpFlag(["--help"])).toBe(true);
expect(hasHelpFlag(["-h"])).toBe(true);
expect(hasHelpFlag(["file.md", "--help"])).toBe(true);
expect(hasHelpFlag(["--git"])).toBe(false);
expect(hasHelpFlag([])).toBe(false);
});
test("recognizes `review --help` as a subcommand help invocation", () => {
expect(isSubcommandHelpInvocation(["review", "--help"])).toBe("review");
expect(isSubcommandHelpInvocation(["review", "-h"])).toBe("review");
// help flag may appear after other args (agents probe in various ways)
expect(isSubcommandHelpInvocation(["annotate", "file.md", "--help"])).toBe(
"annotate",
);
});
test("does not treat a real review invocation as help", () => {
expect(isSubcommandHelpInvocation(["review"])).toBeNull();
expect(isSubcommandHelpInvocation(["review", "--git"])).toBeNull();
expect(isSubcommandHelpInvocation(["review", "--gitbutler"])).toBeNull();
expect(
isSubcommandHelpInvocation([
"review",
"https://github.com/owner/repo/pull/1",
]),
).toBeNull();
});
test("resolves the `last` alias to annotate-last help", () => {
expect(isSubcommandHelpInvocation(["last", "--help"])).toBe("annotate-last");
expect(isSubcommandHelpInvocation(["annotate-last", "--help"])).toBe(
"annotate-last",
);
});
test("covers every command advertised in top-level help", () => {
// Each command listed in formatTopLevelHelp() must respond to --help so the
// advertised "run 'plannotator <command> --help'" contract holds.
for (const sub of [
"annotate",
"copilot-last",
"setup-goal",
"archive",
"sessions",
"uninstall",
"improve-context",
]) {
expect(isSubcommandHelpInvocation([sub, "--help"])).toBe(sub);
}
});
test("ignores help flags for unknown / internal subcommands", () => {
expect(isSubcommandHelpInvocation(["opencode-review", "--help"])).toBeNull();
expect(isSubcommandHelpInvocation(["install-runtime", "--help"])).toBeNull();
expect(isSubcommandHelpInvocation(["--help"])).toBeNull();
expect(isSubcommandHelpInvocation([])).toBeNull();
});
test("renders subcommand-specific usage", () => {
expect(formatSubcommandHelp("review")).toContain(
"plannotator review [--git | --gitbutler]",
);
expect(formatSubcommandHelp("review")).toContain("--gitbutler");
expect(formatSubcommandHelp("review")).toContain("PR_URL");
expect(formatSubcommandHelp("annotate")).toContain("--no-jina");
expect(formatSubcommandHelp("annotate")).toContain("--require-approval");
expect(formatSubcommandHelp("annotate")).toContain("--result-file <path>");
expect(formatSubcommandHelp("annotate-last")).not.toContain(
"--require-approval",
);
expect(formatSubcommandHelp("sessions")).toContain("--open [N]");
expect(formatSubcommandHelp("uninstall")).toContain(
"Local plans, history, drafts",
);
expect(formatSubcommandHelp("uninstall")).toContain(
"not stored on a Plannotator server",
);
// unknown key falls back to top-level help
expect(formatSubcommandHelp("nope")).toBe(formatTopLevelHelp());
});
});
describe("uninstall CLI options", () => {
test("defaults to preserving data and requiring confirmation", () => {
expect(parseUninstallOptions([])).toEqual({
purge: false,
yes: false,
dryRun: false,
});
});
test("parses purge, automation, and preview flags", () => {
expect(
parseUninstallOptions(["--dry-run", "--purge", "-y"]),
).toEqual({
purge: true,
yes: true,
dryRun: true,
});
});
test("rejects unknown and duplicate options", () => {
expect(() => parseUninstallOptions(["--force"])).toThrow(
"Unknown uninstall option",
);
expect(() => parseUninstallOptions(["--purge", "--purge"])).toThrow(
"--purge may only be specified once",
);
expect(() => parseUninstallOptions(["--yes", "-y"])).toThrow(
"--yes/-y may only be specified once",
);
expect(() => parseUninstallOptions(["--dry-run", "--dry-run"])).toThrow(
"--dry-run may only be specified once",
);
});
test("uses a stronger confirmation for purge", () => {
expect(isUninstallConfirmationAccepted("yes", false)).toBe(true);
expect(isUninstallConfirmationAccepted("Y", false)).toBe(true);
expect(isUninstallConfirmationAccepted("", false)).toBe(false);
expect(isUninstallConfirmationAccepted("yes", true)).toBe(false);
expect(isUninstallConfirmationAccepted(" PURGE ", true)).toBe(true);
});
});
describe("strict annotate CLI options", () => {
test("extracts strict options before or after the target path", () => {
const strictOrderings = [
["plan.md", "--require-approval", "--result-file", "result.json"],
["plan.md", "--result-file", "result.json", "--require-approval"],
["--require-approval", "plan.md", "--result-file", "result.json"],
["--require-approval", "--result-file", "result.json", "plan.md"],
["--result-file", "result.json", "plan.md", "--require-approval"],
["--result-file", "result.json", "--require-approval", "plan.md"],
];
for (const ordering of strictOrderings) {
expect(
parseStrictAnnotateOptions([
"annotate",
...ordering,
"--gate",
"--json",
]),
).toEqual({
requireApproval: true,
resultFile: "result.json",
remainingArgs: ["annotate", "plan.md", "--gate", "--json"],
});
}
});
test("allows either strict option independently", () => {
expect(
parseStrictAnnotateOptions([
"annotate",
"plan.md",
"--gate",
"--json",
"--require-approval",
]),
).toEqual({
requireApproval: true,
remainingArgs: ["annotate", "plan.md", "--gate", "--json"],
});
expect(
parseStrictAnnotateOptions([
"annotate",
"--result-file",
"result.json",
"plan.md",
"--gate",
"--json",
]),
).toEqual({
requireApproval: false,
resultFile: "result.json",
remainingArgs: ["annotate", "plan.md", "--gate", "--json"],
});
});
test("leaves ordinary direct arguments unchanged", () => {
const args = [
"annotate",
"plan.md",
"--gate",
"--json",
"--markdown",
];
expect(parseStrictAnnotateOptions(args)).toEqual({
requireApproval: false,
remainingArgs: args,
});
});
test("requires annotate --gate --json without --hook", () => {
for (const args of [
["review", "--gate", "--json", "--require-approval"],
["annotate-last", "--gate", "--json", "--require-approval"],
["annotate", "plan.md", "--json", "--require-approval"],
["annotate", "plan.md", "--gate", "--require-approval"],
[
"annotate",
"plan.md",
"--gate",
"--json",
"--hook",
"--require-approval",
],
]) {
expect(() => parseStrictAnnotateOptions(args)).toThrow();
}
});
test("rejects missing and duplicate strict option values", () => {
expect(() =>
parseStrictAnnotateOptions([
"annotate",
"plan.md",
"--gate",
"--json",
"--result-file",
]),
).toThrow("Missing value for --result-file");
expect(() =>
parseStrictAnnotateOptions([
"annotate",
"plan.md",
"--gate",
"--json",
"--result-file",
"first.json",
"--result-file",
"second.json",
]),
).toThrow("--result-file may only be specified once");
expect(() =>
parseStrictAnnotateOptions([
"annotate",
"plan.md",
"--gate",
"--json",
"--require-approval",
"--require-approval",
]),
).toThrow("--require-approval may only be specified once");
});
});
describe("CLI --version", () => {
test("recognizes --version and -v", () => {
expect(isVersionInvocation(["--version"])).toBe(true);
expect(isVersionInvocation(["-v"])).toBe(true);
expect(isVersionInvocation([])).toBe(false);
expect(isVersionInvocation(["review"])).toBe(false);
});
test("formats version string", () => {
const output = formatVersion();
expect(output).toStartWith("plannotator ");
});
});
describe("interactive no-arg invocation", () => {
test("detects bare interactive invocation only when stdin is a TTY", () => {
expect(isInteractiveNoArgInvocation([], true)).toBe(true);
expect(isInteractiveNoArgInvocation([], false)).toBe(false);
expect(isInteractiveNoArgInvocation([], undefined)).toBe(false);
expect(isInteractiveNoArgInvocation(["review"], true)).toBe(false);
});
test("renders clarification for interactive users", () => {
const output = formatInteractiveNoArgClarification();
expect(output).toContain("usually launched automatically by Claude Code hooks");
expect(output).toContain("It expects hook JSON on stdin.");
expect(output).toContain("plannotator review");
expect(output).toContain("plannotator setup-goal interview bundle.json --json");
expect(output).toContain("plannotator sessions");
expect(output).toContain("plannotator uninstall");
expect(output).toContain("Run 'plannotator --help' for top-level usage.");
});
});