From 774902b6472676705eab4e7f0617e705dd7a0321 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:46 +0900 Subject: [PATCH 01/23] feat(bridge): windows-native fallbacks for state and config dirs --- bridge/config.test.ts | 48 ++++++++++++++++++++++++++++++++++++------- bridge/config.ts | 39 ++++++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/bridge/config.test.ts b/bridge/config.test.ts index e7b6f491..931c4382 100644 --- a/bridge/config.test.ts +++ b/bridge/config.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { homedir } from "node:os"; import { join } from "node:path"; -import { defaultSocketPath, loadConfig } from "./config.ts"; +import { defaultConfigDir, defaultSocketPath, defaultStateDir, loadConfig } from "./config.ts"; // loadConfig is the deployment contract — env vars in, a resolved Config out. Pure (just reads // process.env + homedir), so we drive it by mutating the environment and restoring it after. @@ -72,13 +72,14 @@ describe("loadConfig", () => { expect(cfg.transcript).toBe(true); // One root by default, and it is a list of one rather than a special case (issue #92). expect(cfg.journalRoots.claude).toHaveLength(1); - expect(cfg.journalRoots.claude[0]).toEndWith("/.claude/projects"); + expect(cfg.journalRoots.claude[0]).toBe(join(homedir(), ".claude", "projects")); // OpenCode keeps ONE sqlite database at the top of its XDG data dir — no per-session files. expect(cfg.journalRoots.opencode).toEqual([join(homedir(), ".local", "share", "opencode")]); expect(cfg.submitKeys).toEqual(["Enter"]); expect(cfg.trustedUser).toBe(""); expect(cfg.allowedOrigins).toEqual([]); expect(cfg.notifyDelayMs).toBe(30_000); + expect(cfg.stateDir).toBe(defaultStateDir()); // Host-header validation is opt-in (empty = off, legacy behaviour). expect(cfg.publicHosts).toEqual([]); // Per-device auth is off by default (empty header = feature disabled). @@ -185,9 +186,9 @@ describe("loadConfig", () => { process.env.PI_CODING_AGENT_DIR = "/srv/pi"; process.env.XDG_DATA_HOME = "/srv/share"; const cfg = loadConfig(); - expect(cfg.journalRoots.codex).toEqual(["/srv/codex/sessions"]); - expect(cfg.journalRoots.pi).toEqual(["/srv/pi/sessions"]); - expect(cfg.journalRoots.opencode).toEqual(["/srv/share/opencode"]); + expect(cfg.journalRoots.codex).toEqual([join("/srv/codex", "sessions")]); + expect(cfg.journalRoots.pi).toEqual([join("/srv/pi", "sessions")]); + expect(cfg.journalRoots.opencode).toEqual([join("/srv/share", "opencode")]); }); test("an explicit COLLIE_* root beats the harness's home var", () => { @@ -203,8 +204,16 @@ describe("loadConfig", () => { expect(loadConfig().commandsFile).toBe(join("/srv/herdr/plugins/collie", "commands.toml")); expect(loadConfig().keysFile).toBe(join("/srv/herdr/plugins/collie", "keys.toml")); delete process.env.HERDR_PLUGIN_CONFIG_DIR; - expect(loadConfig().commandsFile).toBe(join(homedir(), ".config", "collie", "commands.toml")); - expect(loadConfig().keysFile).toBe(join(homedir(), ".config", "collie", "keys.toml")); + expect(loadConfig().commandsFile).toBe(join(defaultConfigDir(), "commands.toml")); + expect(loadConfig().keysFile).toBe(join(defaultConfigDir(), "keys.toml")); + }); + + test("HERDR_PLUGIN_STATE_DIR still overrides COLLIE_STATE_DIR and the platform fallback", () => { + process.env.COLLIE_STATE_DIR = "/legacy/state"; + process.env.HERDR_PLUGIN_STATE_DIR = "/plugin/state"; + expect(loadConfig().stateDir).toBe("/plugin/state"); + delete process.env.HERDR_PLUGIN_STATE_DIR; + expect(loadConfig().stateDir).toBe("/legacy/state"); }); test("reads the per-device auth header and allowlist", () => { @@ -296,6 +305,31 @@ describe("loadConfig", () => { // Pure — both platform branches are testable from any host (expectations use join() so the // host's separator never leaks into the assertion). +describe("defaultStateDir/defaultConfigDir", () => { + test("win32 falls back to AppData-backed dirs when env is absent", () => { + expect(defaultStateDir("win32", {}, "C:\\Users\\u")).toBe( + join("C:\\Users\\u", "AppData", "Local", "collie", "state"), + ); + expect(defaultConfigDir("win32", {}, "C:\\Users\\u")).toBe( + join("C:\\Users\\u", "AppData", "Roaming", "collie"), + ); + }); + + test("win32 honours LOCALAPPDATA and APPDATA when present", () => { + expect(defaultStateDir("win32", { LOCALAPPDATA: "D:\\Local" }, "C:\\Users\\u")).toBe( + join("D:\\Local", "collie", "state"), + ); + expect(defaultConfigDir("win32", { APPDATA: "D:\\Roaming" }, "C:\\Users\\u")).toBe( + join("D:\\Roaming", "collie"), + ); + }); + + test("unix defaults stay unchanged", () => { + expect(defaultStateDir("linux", {}, "/home/u")).toBe(join("/home/u", ".local", "state", "collie")); + expect(defaultConfigDir("linux", {}, "/home/u")).toBe(join("/home/u", ".config", "collie")); + }); +}); + describe("defaultSocketPath", () => { test("unix default lives under ~/.config/herdr", () => { expect(defaultSocketPath("linux", {}, "/home/u")).toBe(join("/home/u", ".config", "herdr", "herdr.sock")); diff --git a/bridge/config.ts b/bridge/config.ts index 96a27a6c..47b6f94c 100644 --- a/bridge/config.ts +++ b/bridge/config.ts @@ -227,11 +227,44 @@ export function defaultSocketPath( return join(home, ".config", "herdr", "herdr.sock"); } +/** + * Collie's default state directory: `~/.local/state/collie` on Unix, + * `%LOCALAPPDATA%\collie\state` on Windows. Pure so both branches are unit-testable on any + * platform. + */ +export function defaultStateDir( + platform: NodeJS.Platform = process.platform, + env: Record = process.env, + home: string = homedir(), +): string { + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA ?? join(home, "AppData", "Local"); + return join(localAppData, "collie", "state"); + } + return join(home, ".local", "state", "collie"); +} + +/** + * Collie's default config directory: `~/.config/collie` on Unix, + * `%APPDATA%\collie` on Windows. Pure so both branches are unit-testable on any platform. + */ +export function defaultConfigDir( + platform: NodeJS.Platform = process.platform, + env: Record = process.env, + home: string = homedir(), +): string { + if (platform === "win32") { + const appData = env.APPDATA ?? join(home, "AppData", "Roaming"); + return join(appData, "collie"); + } + return join(home, ".config", "collie"); +} + export function loadConfig(): Config { const stateDir = process.env.HERDR_PLUGIN_STATE_DIR ?? process.env.COLLIE_STATE_DIR ?? - join(homedir(), ".local", "state", "collie"); + defaultStateDir(); const submitKeys = envList("COLLIE_SUBMIT_KEYS"); @@ -239,8 +272,8 @@ export function loadConfig(): Config { // Resolved exactly the way scripts/collie-ctl.sh resolves it MINUS the `herdr` shell-out: the // launcher passes HERDR_PLUGIN_CONFIG_DIR into the unit (and the launchd plist) precisely so this // process never has to ask the CLI, and the two entry points must not disagree about which dir - // that is. ~/.config/collie is the same last-resort default the shim ends on. - const configDir = process.env.HERDR_PLUGIN_CONFIG_DIR ?? join(homedir(), ".config", "collie"); + // that is. The helper keeps the platform-specific last-resort default the shim ends on. + const configDir = process.env.HERDR_PLUGIN_CONFIG_DIR ?? defaultConfigDir(); return { socketPath: process.env.HERDR_SOCKET_PATH ?? defaultSocketPath(), From ae3b67ab322a06bab2c63a3e1c02be60f6544688 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:46 +0900 Subject: [PATCH 02/23] fix(bridge): case-aware path comparisons for win32 --- bridge/journal/files.test.ts | 40 +++++++++++++++++++++++++++++++ bridge/journal/files.ts | 5 ++-- bridge/pathcmp.test.ts | 36 ++++++++++++++++++++++++++++ bridge/pathcmp.ts | 41 ++++++++++++++++++++++++++++++++ bridge/server.test.ts | 7 +++--- bridge/server.ts | 7 +++--- bridge/sessions.test.ts | 46 ++++++++++++++++++++---------------- bridge/sessions.ts | 5 ++-- 8 files changed, 156 insertions(+), 31 deletions(-) create mode 100644 bridge/journal/files.test.ts create mode 100644 bridge/pathcmp.test.ts create mode 100644 bridge/pathcmp.ts diff --git a/bridge/journal/files.test.ts b/bridge/journal/files.test.ts new file mode 100644 index 00000000..c353a925 --- /dev/null +++ b/bridge/journal/files.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { containedRealpath } from "./files.ts"; + +describe("containedRealpath", () => { + async function fixture() { + const created = await mkdtemp(join(tmpdir(), "collie-files-")); + const base = await realpath(created); + const root = join(base, "root"); + await mkdir(root, { recursive: true }); + + const inside = join(root, "inside.txt"); + await Bun.write(inside, "inside\n"); + + const outsideDir = join(base, "outside"); + await mkdir(outsideDir, { recursive: true }); + const outside = join(outsideDir, "outside.txt"); + await Bun.write(outside, "outside\n"); + + const sneakyDir = join(root, "sneaky"); + await symlink(outsideDir, sneakyDir, process.platform === "win32" ? "junction" : "dir"); + const escaped = join(sneakyDir, "outside.txt"); + + return { base, root, inside, outside, escaped }; + } + + test("keeps real files under the root and rejects outside-root or symlinked-out paths", async () => { + const { base, root, inside, outside, escaped } = await fixture(); + try { + expect(await containedRealpath(inside, root)).toBe(inside); + expect(await containedRealpath(outside, root)).toBeNull(); + expect(await containedRealpath(escaped, root)).toBeNull(); + } finally { + await rm(base, { recursive: true, force: true }); + } + }); +}); diff --git a/bridge/journal/files.ts b/bridge/journal/files.ts index 132eed36..6f074d89 100644 --- a/bridge/journal/files.ts +++ b/bridge/journal/files.ts @@ -22,7 +22,8 @@ // conversation), but it reaches further back — `COLLIE_TRANSCRIPT=off` disables the feature wholesale. import { realpath, stat } from "node:fs/promises"; -import { sep } from "node:path"; + +import { pathStartsWithChild } from "../pathcmp.ts"; /** Most bytes we will ever pull off one log. Beyond this we keep the TAIL (newest turns). */ export const MAX_TRANSCRIPT_BYTES = 32 * 1024 * 1024; // 32 MB @@ -49,7 +50,7 @@ export async function containedRealpath(candidate: string, root: string): Promis const real = await realpath(candidate).catch(() => null); const realRoot = await realpath(root).catch(() => null); if (real === null || realRoot === null) return null; - return real === realRoot || real.startsWith(realRoot + sep) ? real : null; + return pathStartsWithChild(real, realRoot) ? real : null; } /** diff --git a/bridge/pathcmp.test.ts b/bridge/pathcmp.test.ts new file mode 100644 index 00000000..5446fdcb --- /dev/null +++ b/bridge/pathcmp.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; + +import { pathEq, pathStartsWithChild } from "./pathcmp.ts"; + +describe("pathEq", () => { + test("win32 comparisons ignore case", () => { + expect(pathEq("C:\\Case\\Root", "c:\\case\\root", "win32")).toBe(true); + }); + + test("POSIX comparisons stay strict", () => { + expect(pathEq("/tmp/Root", "/tmp/root", "linux")).toBe(false); + }); +}); + +describe("pathStartsWithChild", () => { + test("win32 accepts exact and nested children regardless of case", () => { + expect(pathStartsWithChild("C:\\Case\\Root", "c:\\case\\root", "win32")).toBe(true); + expect(pathStartsWithChild("C:\\Case\\Root\\child", "c:\\case\\root", "win32")).toBe(true); + }); + + test("win32 still rejects sibling prefixes", () => { + expect(pathStartsWithChild("C:\\Case\\RootX\\child", "c:\\case\\root", "win32")).toBe(false); + }); + + test("win32 tolerates trailing separators and root paths", () => { + expect(pathEq("C:\\Case\\Root\\", "c:\\case\\root", "win32")).toBe(true); + expect(pathStartsWithChild("C:\\Case\\Root\\child\\", "c:\\case\\root\\", "win32")).toBe(true); + expect(pathStartsWithChild("C:\\Windows", "C:\\", "win32")).toBe(true); + }); + + test("POSIX comparisons stay strict", () => { + expect(pathStartsWithChild("/tmp/Root/child", "/tmp/root", "linux")).toBe(false); + expect(pathStartsWithChild("/tmp/root/child", "/tmp/root/", "linux")).toBe(true); + expect(pathStartsWithChild("/tmp/root", "/", "linux")).toBe(true); + }); +}); diff --git a/bridge/pathcmp.ts b/bridge/pathcmp.ts new file mode 100644 index 00000000..af61dcd2 --- /dev/null +++ b/bridge/pathcmp.ts @@ -0,0 +1,41 @@ +import { posix, win32 } from "node:path"; + +function normalizeForEquality(path: string, platform: NodeJS.Platform): string { + if (platform !== "win32") return path; + const normalized = path.replace(/\//g, "\\"); + const root = win32.parse(normalized).root; + let end = normalized.length; + while (end > root.length && normalized[end - 1] === "\\") end--; + return normalized.slice(0, end).toLowerCase(); +} + +function normalizeForContainment(path: string, platform: NodeJS.Platform): string { + if (platform === "win32") return normalizeForEquality(path, platform); + const root = posix.parse(path).root; + let end = path.length; + while (end > root.length && path[end - 1] === "/") end--; + return path.slice(0, end); +} + +/** Case-sensitive everywhere except win32, where comparisons are case-insensitive and normalize equivalent Windows path spellings. */ +export function pathEq(a: string, b: string, platform: NodeJS.Platform = process.platform): boolean { + return normalizeForEquality(a, platform) === normalizeForEquality(b, platform); +} + +/** + * Whether `child` is the same path as `root` or lives directly beneath it. + * win32 comparisons ignore case; other platforms keep the existing strict behavior while + * ignoring redundant trailing separators for containment. + */ +export function pathStartsWithChild( + child: string, + root: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const childFold = normalizeForContainment(child, platform); + const rootFold = normalizeForContainment(root, platform); + if (childFold === rootFold) return true; + const sep = platform === "win32" ? "\\" : "/"; + const boundary = rootFold.endsWith(sep) ? rootFold : `${rootFold}${sep}`; + return childFold.startsWith(boundary); +} diff --git a/bridge/server.test.ts b/bridge/server.test.ts index 369e8517..e29ea511 100644 --- a/bridge/server.test.ts +++ b/bridge/server.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; import { BUILD_HEADER, @@ -237,19 +238,19 @@ describe("isHostAllowed", () => { }); describe("resolveStaticPath — static path traversal guard", () => { - const WEB = "/srv/collie/web/dist"; + const WEB = join("srv", "collie", "web", "dist"); test("resolves a normal file under the web dir", () => { expect(resolveStaticPath("/assets/app.js", WEB)).toEqual({ rel: "assets/app.js", - full: "/srv/collie/web/dist/assets/app.js", + full: join(WEB, "assets", "app.js"), }); }); test("maps / to index.html", () => { expect(resolveStaticPath("/", WEB)).toEqual({ rel: "index.html", - full: "/srv/collie/web/dist/index.html", + full: join(WEB, "index.html"), }); }); diff --git a/bridge/server.ts b/bridge/server.ts index 3785cc05..7df9082d 100644 --- a/bridge/server.ts +++ b/bridge/server.ts @@ -1,11 +1,12 @@ import { mkdir } from "node:fs/promises"; import { homedir } from "node:os"; -import { extname, join, normalize, sep } from "node:path"; +import { extname, join, normalize } from "node:path"; import type { ActivityLedger } from "./activity.ts"; import type { AuditLog } from "./audit.ts"; import type { Config } from "./config.ts"; import type { HerdrClient, PaneRead } from "./herdr-client.ts"; import { computeEtag, gzipJsonResponse, notModified } from "./http-cache.ts"; +import { pathStartsWithChild } from "./pathcmp.ts"; import type { NotifyPrefs, NotifyPrefsStore } from "./notify-prefs.ts"; import { createOperatorCommands } from "./operator-commands.ts"; import { createOperatorKeys } from "./operator-keys.ts"; @@ -1362,7 +1363,7 @@ export function withBuildHeader(res: Response, id: string): Response { /** * Resolve a request pathname to an absolute path under `webDir`, or null if it escapes. Pure + - * exported for tests. The `full === webDir || full.startsWith(webDir + sep)` check rejects both + * exported for tests. The `pathStartsWithChild(full, webDir)` check rejects both * `..` traversal AND a sibling dir that merely shares the prefix (e.g. `web/dist-x` vs `web/dist`) — * a bare `startsWith(webDir)` would let the latter through. */ @@ -1372,7 +1373,7 @@ export function resolveStaticPath( ): { rel: string; full: string } | null { const rel = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); const full = normalize(join(webDir, rel)); - if (full !== webDir && !full.startsWith(webDir + sep)) return null; + if (!pathStartsWithChild(full, webDir)) return null; return { rel, full }; } diff --git a/bridge/sessions.test.ts b/bridge/sessions.test.ts index 1c6800a3..3a338702 100644 --- a/bridge/sessions.test.ts +++ b/bridge/sessions.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; import { deriveConfigRoot, @@ -17,6 +18,9 @@ import type { AgentStatus, AgentView } from "./types.ts"; // factory (no real socket/fs) so spawn/list/dispose lifecycle is verified purely, per the repo's // injected-fake convention (see state-engine.test.ts). +const ROOT = join("cfg", "herdr"); +const socketPath = (root: string, ...parts: string[]) => join(root, ...parts); + // ── pure path helpers ───────────────────────────────────────────────────────── describe("deriveConfigRoot", () => { @@ -58,33 +62,33 @@ describe("herdTagFor", () => { }); describe("discoverSessionSockets", () => { - const root = "/cfg/herdr"; + const root = ROOT; test("finds the default socket plus each sessions//herdr.sock that exists", () => { const present = new Set([ - `${root}/herdr.sock`, - `${root}/sessions/alpha/herdr.sock`, + socketPath(root, "herdr.sock"), + socketPath(root, "sessions", "alpha", "herdr.sock"), // 'zeta' dir exists but its socket does not (session cleanly stopped → socket removed). ]); const found = discoverSessionSockets( root, - (dir) => (dir === `${root}/sessions` ? ["alpha", "zeta"] : []), + (dir) => (dir === socketPath(root, "sessions") ? ["alpha", "zeta"] : []), (p) => present.has(p), ); expect(found).toEqual([ - { name: "default", socketPath: `${root}/herdr.sock` }, - { name: "alpha", socketPath: `${root}/sessions/alpha/herdr.sock` }, + { name: "default", socketPath: socketPath(root, "herdr.sock") }, + { name: "alpha", socketPath: socketPath(root, "sessions", "alpha", "herdr.sock") }, ]); }); test("omits the default when its socket is absent (default session not running)", () => { - const present = new Set([`${root}/sessions/only/herdr.sock`]); + const present = new Set([socketPath(root, "sessions", "only", "herdr.sock")]); const found = discoverSessionSockets( root, () => ["only"], (p) => present.has(p), ); - expect(found).toEqual([{ name: "only", socketPath: `${root}/sessions/only/herdr.sock` }]); + expect(found).toEqual([{ name: "only", socketPath: socketPath(root, "sessions", "only", "herdr.sock") }]); }); test("returns nothing when no sockets exist", () => { @@ -143,8 +147,8 @@ function makeRegistry(opts: { present?: string[]; snapshots?: Record; } = {}): Harness { - const configRoot = opts.configRoot ?? "/cfg/herdr"; - const primarySocketPath = opts.primarySocketPath ?? `${configRoot}/herdr.sock`; + const configRoot = opts.configRoot ?? ROOT; + const primarySocketPath = opts.primarySocketPath ?? socketPath(configRoot, "herdr.sock"); const fakes = new Map(); const spawns: string[] = []; let dirs = opts.sessionDirs ?? []; @@ -194,7 +198,7 @@ describe("SessionRegistry — construction & lookup", () => { }); test("a named-session primary keeps its own name and is still the default lookup", () => { - const h = makeRegistry({ primarySocketPath: "/cfg/herdr/sessions/work/herdr.sock" }); + const h = makeRegistry({ primarySocketPath: socketPath(ROOT, "sessions", "work", "herdr.sock") }); expect(h.registry.primary).toBe("work"); expect(h.registry.get()?.name).toBe("work"); expect(h.registry.get("work")).toBe(h.registry.get()); @@ -206,9 +210,9 @@ describe("SessionRegistry — list()", () => { const h = makeRegistry({ sessionDirs: ["zeta", "alpha"], present: [ - "/cfg/herdr/herdr.sock", - "/cfg/herdr/sessions/zeta/herdr.sock", - "/cfg/herdr/sessions/alpha/herdr.sock", + socketPath(ROOT, "herdr.sock"), + socketPath(ROOT, "sessions", "zeta", "herdr.sock"), + socketPath(ROOT, "sessions", "alpha", "herdr.sock"), ], snapshots: { default: { bridge: "connected", agents: [agent("d1", "blocked"), agent("d2", "idle")] }, @@ -227,7 +231,7 @@ describe("SessionRegistry — list()", () => { test("an unreachable session reports reachable:false and zeroed counts", async () => { const h = makeRegistry({ sessionDirs: ["down"], - present: ["/cfg/herdr/herdr.sock", "/cfg/herdr/sessions/down/herdr.sock"], + present: [socketPath(ROOT, "herdr.sock"), socketPath(ROOT, "sessions", "down", "herdr.sock")], snapshots: { // Stale last-known agents, but the last poll failed → treated as unreachable with 0 counts. down: { bridge: "disconnected", agents: [agent("x1", "blocked")] }, @@ -250,7 +254,7 @@ describe("SessionRegistry — refresh() lifecycle", () => { test("starts runtimes for newly-appeared sessions and does not respawn the primary", async () => { const h = makeRegistry({ sessionDirs: ["demo"], - present: ["/cfg/herdr/herdr.sock", "/cfg/herdr/sessions/demo/herdr.sock"], + present: [socketPath(ROOT, "herdr.sock"), socketPath(ROOT, "sessions", "demo", "herdr.sock")], }); await h.registry.refresh(); // primary spawned at construction; demo spawned on refresh; default NOT respawned though present. @@ -264,13 +268,13 @@ describe("SessionRegistry — refresh() lifecycle", () => { test("disposes a session whose socket vanished (engine + poker stopped, notifications cleared)", async () => { const h = makeRegistry({ sessionDirs: ["demo"], - present: ["/cfg/herdr/herdr.sock", "/cfg/herdr/sessions/demo/herdr.sock"], + present: [socketPath(ROOT, "herdr.sock"), socketPath(ROOT, "sessions", "demo", "herdr.sock")], }); await h.registry.refresh(); const demo = h.fakes.get("demo")!; // Socket removed (session stopped) → next refresh disposes it. h.setDirs([]); - h.setPresent(["/cfg/herdr/herdr.sock"]); + h.setPresent([socketPath(ROOT, "herdr.sock")]); await h.registry.refresh(); expect(demo.disposed).toEqual({ engine: 1, poker: 1, notifications: 1 }); expect(h.registry.get("demo")).toBeUndefined(); @@ -279,7 +283,7 @@ describe("SessionRegistry — refresh() lifecycle", () => { test("never disposes the primary, even when discovery finds nothing", async () => { const h = makeRegistry({ sessionDirs: ["demo"], - present: ["/cfg/herdr/herdr.sock", "/cfg/herdr/sessions/demo/herdr.sock"], + present: [socketPath(ROOT, "herdr.sock"), socketPath(ROOT, "sessions", "demo", "herdr.sock")], }); await h.registry.refresh(); const primaryFake = h.fakes.get("default")!; @@ -295,7 +299,7 @@ describe("SessionRegistry — refresh() lifecycle", () => { const h = makeRegistry({ multiSession: false, sessionDirs: ["demo"], - present: ["/cfg/herdr/herdr.sock", "/cfg/herdr/sessions/demo/herdr.sock"], + present: [socketPath(ROOT, "herdr.sock"), socketPath(ROOT, "sessions", "demo", "herdr.sock")], }); await h.registry.refresh(); expect(h.spawns).toEqual(["default"]); // demo never discovered @@ -306,7 +310,7 @@ describe("SessionRegistry — refresh() lifecycle", () => { test("disposeAll stops every runtime including the primary", async () => { const h = makeRegistry({ sessionDirs: ["demo"], - present: ["/cfg/herdr/herdr.sock", "/cfg/herdr/sessions/demo/herdr.sock"], + present: [socketPath(ROOT, "herdr.sock"), socketPath(ROOT, "sessions", "demo", "herdr.sock")], }); await h.registry.refresh(); const primaryFake = h.fakes.get("default")!; diff --git a/bridge/sessions.ts b/bridge/sessions.ts index 657706c9..20fd79b3 100644 --- a/bridge/sessions.ts +++ b/bridge/sessions.ts @@ -1,5 +1,6 @@ import { basename, dirname, join } from "node:path"; +import { pathEq } from "./pathcmp.ts"; import type { EventPoker } from "./event-poker.ts"; import type { HerdrClient } from "./herdr-client.ts"; import type { NotificationCoordinator } from "./notifications.ts"; @@ -42,7 +43,7 @@ export function herdTagFor(isPrimary: boolean, name: string): string { export function deriveConfigRoot(socketPath: string): string { const dir = dirname(socketPath); // OR /sessions/ const parent = dirname(dir); // OR /sessions - if (basename(parent) === "sessions") return dirname(parent); + if (pathEq(basename(parent), "sessions")) return dirname(parent); return dir; } @@ -52,7 +53,7 @@ export function deriveConfigRoot(socketPath: string): string { */ export function sessionNameFor(socketPath: string, configRoot: string): string { const dir = dirname(socketPath); // OR /sessions/ - if (dir === configRoot) return DEFAULT_SESSION_NAME; + if (pathEq(dir, configRoot)) return DEFAULT_SESSION_NAME; return basename(dir); } From ff0d9afd26586346b1b4a18d4d50249f7741a19b Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:47 +0900 Subject: [PATCH 03/23] fix(bridge): force-kill shutdown note and service-neutral wording --- bridge/index.ts | 8 ++++---- bridge/types.ts | 2 +- web/src/lib/types.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bridge/index.ts b/bridge/index.ts index 3342fa2b..55e18480 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -209,13 +209,13 @@ const server = startServer({ cfg, registry, push, snooze, notifyPrefs, updateMon const shutdown = async () => { console.log("\n[bridge] shutting down"); - // Stop accepting new connections and let in-flight requests drain briefly (non-forced stop) - // before we tear down the poll loops and exit. + // POSIX signal path only: Windows may end in a forced kill (e.g. taskkill), so + // `process.on("exit")` is not an async persistence hook. Debounce-period flushes keep activity + // correct while running; a hard kill can still drop the last debounce window, and this final + // flush is best-effort. await server.stop(); clearInterval(refreshTimer); registry.disposeAll(); - // Writes are debounced, so the last few seconds of "you looked at this" live only in memory — - // persist them before exiting, or every restart quietly resurrects alerts you'd already cleared. activity.stop(); await activity.flush(); clearInterval(sweepTimer); diff --git a/bridge/types.ts b/bridge/types.ts index 3b21b1da..2e1c17a5 100644 --- a/bridge/types.ts +++ b/bridge/types.ts @@ -216,7 +216,7 @@ export interface UpdateStatus { majorAvailable: string | null; /** GitHub release page for `majorAvailable`, or null when there is none. */ majorUrl: string | null; - /** The running process is behind the on-disk bridge source — needs `systemctl --user restart collie`. */ + /** The running process is behind the on-disk bridge source — restart the bridge to pick it up. */ bridgeStale: boolean; /** When the upstream check last completed (epoch ms), or null if it hasn't run yet. */ checkedAt: number | null; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 151f966e..13a4e0f9 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -166,7 +166,7 @@ export interface UpdateInfo { majorAvailable: string | null; /** GitHub release page for `majorAvailable`, or null when there is none. */ majorUrl: string | null; - /** The running bridge PROCESS is behind the on-disk code — a `systemctl restart` picks it up. */ + /** The running bridge PROCESS is behind the on-disk code — restart the bridge to pick it up. */ bridgeStale: boolean; /** When the upstream check last ran (epoch ms), or null if it hasn't. */ checkedAt: number | null; From 6c5520ee6465701a80b91b06dcbb9b7fe16d63ae Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:47 +0900 Subject: [PATCH 04/23] feat(scripts): cross-platform version gate in typescript --- package.json | 4 +- scripts/check-version.test.ts | 52 +++++++++++++++++++++++++ scripts/check-version.ts | 71 +++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 scripts/check-version.test.ts create mode 100644 scripts/check-version.ts diff --git a/package.json b/package.json index 472118ed..92dfb52c 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "build": "bun run typecheck && cd web && bun install && bun run typecheck && bun run build", "build:web": "cd web && bun run build", "web:dev": "cd web && bun run dev", - "test": "bun test ./bridge ./scripts && bash scripts/collie-ctl.test.sh", + "test": "bun test ./bridge ./scripts", + "test:ctl-posix": "bash scripts/collie-ctl.test.sh", "test:ctl": "bash scripts/collie-ctl.test.sh", + "check-version": "bun scripts/check-version.ts", "typecheck": "bunx tsc --noEmit" }, "devDependencies": { diff --git a/scripts/check-version.test.ts b/scripts/check-version.test.ts new file mode 100644 index 00000000..6f61ea51 --- /dev/null +++ b/scripts/check-version.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { checkVersion } from "./check-version.ts"; + +const VERSION = "1.2.3"; + +function makeFixture(args: { toml: string; pkg: string; web: string; changelog: string[] }): string { + const root = mkdtempSync(join(tmpdir(), "collie-check-version-")); + mkdirSync(join(root, "web"), { recursive: true }); + writeFileSync(join(root, "herdr-plugin.toml"), `id = "herdr.collie"\nversion = "${args.toml}"\n`); + writeFileSync(join(root, "package.json"), `${JSON.stringify({ version: args.pkg }, null, 2)}\n`); + writeFileSync(join(root, "web/package.json"), `${JSON.stringify({ version: args.web }, null, 2)}\n`); + writeFileSync( + join(root, "CHANGELOG.md"), + `# Changelog\n\n${args.changelog.map((v) => `## [${v}] - 2026-08-22`).join("\n\n")}\n`, + ); + return root; +} + +function cleanup(root: string): void { + rmSync(root, { recursive: true, force: true }); +} + +describe("checkVersion", () => { + test("accepts matching versions across the manifest, package.json, web/package.json, and CHANGELOG", async () => { + const root = makeFixture({ toml: VERSION, pkg: VERSION, web: VERSION, changelog: [VERSION, "1.2.2"] }); + try { + await expect(checkVersion(root)).resolves.toBe(VERSION); + } finally { + cleanup(root); + } + }); + + test("rejects when one artifact disagrees", async () => { + const root = makeFixture({ toml: VERSION, pkg: VERSION, web: "1.2.4", changelog: [VERSION, "1.2.2"] }); + try { + let error: Error | undefined; + try { + await checkVersion(root); + } catch (e) { + error = e as Error; + } + expect(error).toBeDefined(); + expect(error?.message).toContain("version mismatch"); + expect(error?.message).toContain("web/package.json"); + } finally { + cleanup(root); + } + }); +}); diff --git a/scripts/check-version.ts b/scripts/check-version.ts new file mode 100644 index 00000000..cf225995 --- /dev/null +++ b/scripts/check-version.ts @@ -0,0 +1,71 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +export const DEFAULT_ROOT = resolve(SCRIPT_DIR, ".."); + +const TOML_VERSION_RE = /^\s*version\s*=\s*"([^"]+)"/m; +const PACKAGE_VERSION_RE = /"version"\s*:\s*"([^"]+)"/m; +const CHANGELOG_VERSION_RE = /^##\s*\[([0-9][^\]]*)\]/m; + +function firstMatch(text: string, re: RegExp): string | undefined { + return re.exec(text)?.[1]; +} + +async function readVersion(filePath: string, re: RegExp): Promise { + try { + return firstMatch(await readFile(filePath, "utf8"), re); + } catch { + return undefined; + } +} + +function display(value: string | undefined): string { + return value || ""; +} + +function note(label: string, value: string | undefined): string { + return ` ${label.padEnd(18)} ${display(value)}`; +} + +export async function checkVersion(rootDir: string = DEFAULT_ROOT): Promise { + const root = resolve(rootDir); + + const tomlVersion = await readVersion(join(root, "herdr-plugin.toml"), TOML_VERSION_RE); + if (!tomlVersion) throw new Error("could not read version from herdr-plugin.toml"); + + const packageVersion = await readVersion(join(root, "package.json"), PACKAGE_VERSION_RE); + const webVersion = await readVersion(join(root, "web/package.json"), PACKAGE_VERSION_RE); + const changelogVersion = await readVersion(join(root, "CHANGELOG.md"), CHANGELOG_VERSION_RE); + + if (packageVersion !== tomlVersion || webVersion !== tomlVersion || changelogVersion !== tomlVersion) { + throw new Error( + [ + "version mismatch — all four must equal the canonical herdr-plugin.toml version:", + note("herdr-plugin.toml", `${tomlVersion} (canonical)`), + note("package.json", packageVersion), + note("web/package.json", webVersion), + note("CHANGELOG.md", changelogVersion), + " → bump all three files to the same version and add a matching CHANGELOG entry.", + ].join("\n"), + ); + } + + return tomlVersion; +} + +async function run(rootDir: string): Promise { + try { + const version = await checkVersion(rootDir); + console.log(`✓ version ${version} consistent across manifest, package.json, web/package.json, CHANGELOG`); + return 0; + } catch (error) { + console.error(`✗ ${(error as Error).message}`); + return 1; + } +} + +if (import.meta.main) { + process.exit(await run(process.argv[2] ?? DEFAULT_ROOT)); +} From 929f578829ea8faa656cd5bb51e68a2bd0ae1d39 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:47 +0900 Subject: [PATCH 05/23] feat(scripts): cross-platform ctl skeleton --- scripts/ctl/main.test.ts | 66 ++++++++++ scripts/ctl/main.ts | 179 ++++++++++++++++++++++++++++ scripts/ctl/types.ts | 251 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 496 insertions(+) create mode 100644 scripts/ctl/main.test.ts create mode 100644 scripts/ctl/main.ts create mode 100644 scripts/ctl/types.ts diff --git a/scripts/ctl/main.test.ts b/scripts/ctl/main.test.ts new file mode 100644 index 00000000..c25dfa6e --- /dev/null +++ b/scripts/ctl/main.test.ts @@ -0,0 +1,66 @@ +import { spawn } from "node:child_process"; +import { join } from "node:path"; + +import { describe, expect, test } from "bun:test"; + +type RunResult = { + code: number | null; + stdout: string; + stderr: string; +}; + +function runCtl(...args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [join(import.meta.dir, "main.ts"), ...args], { + cwd: join(import.meta.dir, "../.."), + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once("error", reject); + child.once("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +describe("ctl command dispatch", () => { + test("--help exits successfully and lists every verb", async () => { + const result = await runCtl("--help"); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + for (const verb of [ + "start", + "stop", + "restart", + "uninstall", + "update", + "build", + "serve", + "unserve", + "status", + "url", + "version", + "qr", + "logs", + "push-keys", + "push-test", + "exec-bridge", + "apply-update", + ]) { + expect(result.stdout).toContain(` ${verb}`); + } + }); + + test("an unknown verb prints usage to stderr and exits 2", async () => { + const result = await runCtl("not-a-ctl-verb"); + expect(result.code).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("unknown verb: not-a-ctl-verb"); + expect(result.stderr).toContain("Usage: bun scripts/ctl/main.ts [args...]"); + }); +}); diff --git a/scripts/ctl/main.ts b/scripts/ctl/main.ts new file mode 100644 index 00000000..82a9e9c1 --- /dev/null +++ b/scripts/ctl/main.ts @@ -0,0 +1,179 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +import type { Ctx, ShellOptions, ShellResult, Verb, VerbHandler } from "./types.ts"; + +export { + hasLaunchd, + hasSystemd, + hasWindowsTask, + parseTailscaleDnsName, + parseTailscaleRootFingerprint, + selectBackendName, + waitForTcpReadiness, +} from "./types.ts"; + +/** User-facing verbs handled by ctl. */ +export const PUBLIC_VERBS = [ + "start", + "stop", + "restart", + "uninstall", + "update", + "build", + "serve", + "unserve", + "status", + "url", + "version", + "qr", + "logs", + "push-keys", + "push-test", +] as const satisfies readonly Verb[]; + +/** Verbs reserved for service supervisors and the update hand-off. */ +export const INTERNAL_VERBS = ["exec-bridge", "apply-update"] as const satisfies readonly Verb[]; + +/** Every accepted verb, in the order shown by {@link USAGE}. */ +export const ALL_VERBS = [...PUBLIC_VERBS, ...INTERNAL_VERBS] as const; + +/** Stable command-line help text; it deliberately names internal verbs so dispatch is discoverable. */ +export const USAGE = [ + "Usage: bun scripts/ctl/main.ts [args...]", + "", + "Verbs:", + ...PUBLIC_VERBS.map((verb) => ` ${verb}`), + "", + "Internal verbs:", + ...INTERNAL_VERBS.map((verb) => ` ${verb}`), + "", + "Use --help to show this message.", +].join("\n"); + +/** + * Run an executable with separated arguments and capture both output streams. + * + * This is the default implementation of {@link Ctx.shell}. It intentionally does not invoke a + * command interpreter, which keeps paths and user-controlled arguments literal on Unix and Windows. + */ +export async function runShell( + command: string, + args: readonly string[] = [], + options: ShellOptions = {}, +): Promise { + const child = Bun.spawn([command, ...args], { + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new Response(child.stdout).text(); + const stderr = new Response(child.stderr).text(); + const exitCode = await child.exited; + return { stdout: await stdout, stderr: await stderr, exitCode }; +} + +function envPath(name: string): string | undefined { + const value = process.env[name]; + return value === undefined || value === "" ? undefined : value; +} + +function defaultConfigDir(home: string): string { + if (process.platform === "win32") { + return join(envPath("APPDATA") ?? join(home, "AppData", "Roaming"), "collie"); + } + return join(home, ".config", "collie"); +} + +function defaultStateDir(home: string): string { + if (process.platform === "win32") { + return join(envPath("LOCALAPPDATA") ?? join(home, "AppData", "Local"), "collie", "state"); + } + return join(home, ".local", "state", "collie"); +} + +function defaultSocketPath(home: string): string { + if (process.platform === "win32") { + return join(envPath("APPDATA") ?? join(home, "AppData", "Roaming"), "herdr", "herdr.sock"); + } + return join(home, ".config", "herdr", "herdr.sock"); +} + +/** + * Create the default context used by a command-line invocation. + * + * Environment overrides mirror the bridge's launcher contract: injected plugin paths win over the + * platform fallbacks, and state overrides retain the existing HERDR_PLUGIN_STATE_DIR then + * COLLIE_STATE_DIR precedence. + */ +export function createContext(): Ctx { + const home = homedir(); + return { + configDir: envPath("HERDR_PLUGIN_CONFIG_DIR") ?? defaultConfigDir(home), + stateDir: + envPath("HERDR_PLUGIN_STATE_DIR") ?? envPath("COLLIE_STATE_DIR") ?? defaultStateDir(home), + socketPath: envPath("HERDR_SOCKET_PATH") ?? defaultSocketPath(home), + log: (...args) => console.log(...args), + shell: runShell, + }; +} + +const notImplemented = (verb: Verb): VerbHandler => async () => { + throw new Error(`ctl verb '${verb}' is not implemented in the ctl skeleton`); +}; + +const handlers: Record = { + start: notImplemented("start"), + stop: notImplemented("stop"), + restart: notImplemented("restart"), + uninstall: notImplemented("uninstall"), + update: notImplemented("update"), + build: notImplemented("build"), + serve: notImplemented("serve"), + unserve: notImplemented("unserve"), + status: notImplemented("status"), + url: notImplemented("url"), + version: notImplemented("version"), + qr: notImplemented("qr"), + logs: notImplemented("logs"), + "push-keys": notImplemented("push-keys"), + "push-test": notImplemented("push-test"), + "exec-bridge": notImplemented("exec-bridge"), + "apply-update": notImplemented("apply-update"), +}; + +function isVerb(value: string | undefined): value is Verb { + return value !== undefined && ALL_VERBS.some((candidate) => candidate === value); +} + +/** + * Parse and dispatch one ctl command, returning the process exit status. + * + * Help is handled before context creation so it is safe in a fresh checkout. Unknown verbs always + * emit the complete usage text on standard error and return status 2; command failures return 1. + */ +export async function dispatch(argv: readonly string[], ctx?: Ctx): Promise { + const [rawVerb, ...args] = argv; + if (rawVerb === "--help" || rawVerb === "-h" || rawVerb === "help") { + console.log(USAGE); + return 0; + } + if (!isVerb(rawVerb)) { + if (rawVerb !== undefined) console.error(`unknown verb: ${rawVerb}`); + console.error(USAGE); + return 2; + } + + try { + await handlers[rawVerb](ctx ?? createContext(), args); + return 0; + } catch (error) { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } +} + +if (import.meta.main) { + process.exitCode = await dispatch(process.argv.slice(2)); +} diff --git a/scripts/ctl/types.ts b/scripts/ctl/types.ts new file mode 100644 index 00000000..350faf20 --- /dev/null +++ b/scripts/ctl/types.ts @@ -0,0 +1,251 @@ +/** The result returned by {@link Shell} after a child process has finished. */ +export interface ShellResult { + /** Captured standard output, decoded as UTF-8. */ + stdout: string; + /** Captured standard error, decoded as UTF-8. */ + stderr: string; + /** The child process exit status. A successful process returns zero. */ + exitCode: number; +} + +/** Options that affect one {@link Shell} invocation without changing the caller's environment. */ +export interface ShellOptions { + /** Directory in which to start the child process. */ + cwd?: string; +} + +/** + * The command runner shared by ctl verbs and service backends. + * + * `command` is an executable name or path and `args` are passed as individual arguments; no shell + * interpolation is performed. The promise resolves for non-zero exits so callers can include the + * command's diagnostic in their own error, while a process-spawn failure rejects it. + */ +export type Shell = ( + command: string, + args?: readonly string[], + options?: ShellOptions, +) => Promise; + +/** A command and its already-separated arguments, suitable for passing to {@link Ctx.shell}. */ +export interface ShellCommand { + /** Executable name or path. */ + command: string; + /** Arguments passed without shell parsing. */ + args: readonly string[]; +} + +/** + * Runtime paths and side-effect seams shared by every ctl verb. + * + * `configDir` contains operator configuration such as `.env`; `stateDir` contains runtime files + * such as logs and Tailscale ownership state; `socketPath` is Herdr's local IPC endpoint. `log` is + * the user-facing output sink, and `shell` is the only subprocess boundary verbs should use. + */ +export interface Ctx { + /** Directory containing Collie's operator configuration. */ + configDir: string; + /** Directory containing Collie's runtime state and file logs. */ + stateDir: string; + /** Herdr's local socket or named-pipe path. */ + socketPath: string; + /** Optional checkout root used by lifecycle and operational verbs. */ + rootDir?: string; + /** Optional environment overlay used by injected lifecycle operations. */ + env?: Record; + /** Write a user-facing ctl message. */ + log(...args: unknown[]): void; + /** Run one executable without invoking a command shell. */ + shell: Shell; +} + +/** The service-supervisor families supported by the ctl backend selector. */ +export type BackendName = "systemd" | "launchd" | "windows-task"; + +/** + * The lifecycle and log contract implemented by each service supervisor backend. + * + * Backends receive the shared context on every operation so they can be constructed and tested + * without global paths. `install` creates or refreshes the supervisor definition, `start` and + * `stop` control it, `isActive` reports the supervisor's current state, and `logsCmd` returns a + * platform-specific command for reading the most recent log lines. + */ +export interface ServiceBackend { + /** Install or refresh the per-user service definition. */ + install(ctx: Ctx): Promise; + /** Start the installed service. */ + start(ctx: Ctx): Promise; + /** Stop the service without deleting its definition. */ + stop(ctx: Ctx): Promise; + /** Remove the per-user service definition and registration. */ + uninstall?(ctx: Ctx): Promise; + /** Report whether the supervisor considers the service active. */ + isActive(ctx: Ctx): Promise; + /** Build the command used to display the requested number of recent log lines. */ + logsCmd(ctx: Ctx, lines?: number): ShellCommand; +} + +/** The complete set of ctl verbs, including supervisor-only internal verbs. */ +export type Verb = + | "start" + | "stop" + | "restart" + | "uninstall" + | "update" + | "build" + | "serve" + | "unserve" + | "status" + | "url" + | "version" + | "qr" + | "logs" + | "push-keys" + | "push-test" + | "exec-bridge" + | "apply-update"; + +/** Whether a command-line executable can be resolved without invoking a shell. */ +function commandAvailable(name: string): boolean { + return Bun.which(name) !== null; +} + +/** + * Whether a usable per-user systemd instance is available. + * + * This mirrors `collie-ctl.sh`: finding `systemctl` is not enough; its user manager must answer + * `show-environment` successfully. + */ +export function hasSystemd(): boolean { + if (!commandAvailable("systemctl")) return false; + try { + return Bun.spawnSync(["systemctl", "--user", "show-environment"], { + stdout: "ignore", + stderr: "ignore", + }).success; + } catch { + return false; + } +} + +/** Whether the Darwin per-user launchd domain and its command-line client are available. */ +export function hasLaunchd(): boolean { + return process.platform === "darwin" && commandAvailable("launchctl"); +} + +/** Whether Windows Task Scheduler can be addressed through its built-in command-line client. */ +export function hasWindowsTask(): boolean { + return ( + process.platform === "win32" && + (commandAvailable("schtasks") || commandAvailable("schtasks.exe")) + ); +} + +/** Select the first available service family, or `undefined` for an unsupervised host. */ +export function selectBackendName(): BackendName | undefined { + if (hasSystemd()) return "systemd"; + if (hasLaunchd()) return "launchd"; + if (hasWindowsTask()) return "windows-task"; + return undefined; +} + +/** Options for the loopback TCP readiness probe. */ +export interface TcpReadinessOptions { + host?: string; + attempts?: number; + intervalMs?: number; +} + +/** + * Probe the bridge's TCP listener, replacing Bash's `/dev/tcp` probe. + * + * A successful connection is closed immediately. The probe intentionally checks only that the + * listener accepts TCP, not that a particular HTTP route is healthy. + */ +export async function waitForTcpReadiness( + port: number, + options: TcpReadinessOptions = {}, +): Promise { + const host = options.host ?? "127.0.0.1"; + const attempts = Math.max(1, Math.floor(options.attempts ?? 25)); + const intervalMs = Math.max(0, Math.floor(options.intervalMs ?? 200)); + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const socket = await Bun.connect({ + hostname: host, + port, + socket: { + open() {}, + data() {}, + error() {}, + close() {}, + }, + }); + socket.end(); + return true; + } catch { + if (attempt + 1 < attempts && intervalMs > 0) await Bun.sleep(intervalMs); + } + } + return false; +} + +type JsonObject = Record; + +function objectValue(value: unknown): JsonObject | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonObject) + : null; +} + +/** Parse a Tailscale JSON object, returning `null` for invalid or non-object JSON. */ +export function parseTailscaleJson(text: string): JsonObject | null { + try { + return objectValue(JSON.parse(text)); + } catch { + return null; + } +} + +/** Extract and normalize Tailscale's `Self.DNSName`. */ +export function parseTailscaleDnsName(statusJson: string): string | null { + const root = parseTailscaleJson(statusJson); + const self = objectValue(root?.Self); + const dnsName = self?.DNSName; + if (typeof dnsName !== "string" || dnsName === "") return null; + const normalized = dnsName.replace(/\.$/, ""); + return normalized === "" ? null : normalized; +} + +/** + * Read the fingerprint used to prove ownership of a Tailscale root handler. + * + * The result is `absent` when no `/` handler exists, `|proxy:` for a root proxy, + * or `|other` for a non-proxy root. `null` means the status JSON could not be trusted. + */ +export function parseTailscaleRootFingerprint( + statusJson: string, + hostPort: string, + port: string | number, +): string | null { + const config = parseTailscaleJson(statusJson); + if (config === null) return null; + + const web = objectValue(config.Web); + const host = objectValue(web?.[hostPort]); + const handlers = objectValue(host?.Handlers); + if (handlers === null || !Object.prototype.hasOwnProperty.call(handlers, "/")) { + return "absent"; + } + + const tcp = objectValue(config.TCP); + const listener = objectValue(tcp?.[String(port)]); + const protocol = listener?.HTTP === true ? "http" : listener?.HTTPS === true ? "https" : "other"; + const rootHandler = objectValue(handlers["/"]); + const proxy = rootHandler?.Proxy; + return typeof proxy === "string" && proxy !== "" ? `${protocol}|proxy:${proxy}` : `${protocol}|other`; +} + +/** A callable implementation for one parsed ctl verb. */ +export type VerbHandler = (ctx: Ctx, args: readonly string[]) => Promise; From 384c06f321bfee9a854020278c5318cce696d7c4 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:47 +0900 Subject: [PATCH 06/23] feat(scripts): windows task scheduler, systemd and launchd backends --- scripts/ctl/backends/backends.test.ts | 241 ++++++++++++++++++++++++++ scripts/ctl/backends/common.ts | 81 +++++++++ scripts/ctl/backends/launchd.ts | 174 +++++++++++++++++++ scripts/ctl/backends/systemd.ts | 116 +++++++++++++ scripts/ctl/backends/windows.ts | 197 +++++++++++++++++++++ 5 files changed, 809 insertions(+) create mode 100644 scripts/ctl/backends/backends.test.ts create mode 100644 scripts/ctl/backends/common.ts create mode 100644 scripts/ctl/backends/launchd.ts create mode 100644 scripts/ctl/backends/systemd.ts create mode 100644 scripts/ctl/backends/windows.ts diff --git a/scripts/ctl/backends/backends.test.ts b/scripts/ctl/backends/backends.test.ts new file mode 100644 index 00000000..5945e4e6 --- /dev/null +++ b/scripts/ctl/backends/backends.test.ts @@ -0,0 +1,241 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import type { Ctx, ShellResult } from "../types.ts"; +import { + createWindowsBackend, + parseBridgePids, + parseScheduledTaskActive, + renderBridgePidQuery, + renderTaskRegistration, +} from "./windows.ts"; +import { + createSystemdBackend, + renderSystemdUnit, + systemdUnitFile, +} from "./systemd.ts"; +import { + createLaunchdBackend, + launchdAgentFile, + launchdTarget, + renderLaunchdPlist, +} from "./launchd.ts"; + +type Call = { command: string; args: readonly string[] }; + +function result(stdout = "", stderr = "", exitCode = 0): ShellResult { + return { stdout, stderr, exitCode }; +} + +function context( + responses: ShellResult[] = [], +): { ctx: Ctx; calls: Call[] } { + const calls: Call[] = []; + return { + calls, + ctx: { + configDir: "C:\\Users\\collie\\config", + stateDir: "C:\\Users\\collie\\state", + socketPath: "C:\\Users\\collie\\herdr.sock", + log() {}, + shell: async (command, args = []) => { + calls.push({ command, args }); + return responses.shift() ?? result(); + }, + }, + }; +} + +async function temporaryHome(): Promise { + return await mkdtemp(join(tmpdir(), "collie-backend-")); +} + +async function remove(path: string): Promise { + await rm(path, { recursive: true, force: true }); +} + +describe("Windows Task Scheduler backend", () => { + test("pins registration, per-user start, and logon action details", async () => { + const { ctx, calls } = context(); + const backend = createWindowsBackend({ rootDir: "C:\\checkout", bun: "bun" }); + + await backend.install(ctx); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + command: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + renderTaskRegistration(ctx, { rootDir: "C:\\checkout", bun: "bun" }), + ], + }); + expect(calls[0]?.args.at(-1)).toContain("New-ScheduledTaskTrigger -AtLogOn -User $identity"); + expect(calls[0]?.args.at(-1)).toContain("-WorkingDirectory 'C:\\checkout'"); + expect(calls[0]?.args.at(-1)).toContain("scripts/ctl/main.ts"); + expect(calls[0]?.args.at(-1)).toContain("exec-bridge"); + expect(calls[0]?.args.at(-1)).toContain("RestartCount 999"); + expect(calls[0]?.args.at(-1)).toContain("RestartInterval (New-TimeSpan -Minutes 1)"); + expect(calls[0]?.args.at(-1)).toContain("C:\\Users\\collie\\state\\collie.log"); + + await backend.start(ctx); + expect(calls[1]).toEqual({ + command: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Start-ScheduledTask -TaskName 'CollieBridge'", + ], + }); + }); + + test("stops the task and force-kills a surviving bridge PID", async () => { + const { ctx, calls } = context([result(), result("421\r\n"), result()]); + const backend = createWindowsBackend({ rootDir: "C:\\checkout" }); + + await backend.stop(ctx); + + expect(calls).toEqual([ + { + command: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Stop-ScheduledTask -TaskName 'CollieBridge' -ErrorAction SilentlyContinue", + ], + }, + { + command: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + renderBridgePidQuery("C:\\checkout"), + ], + }, + { command: "taskkill", args: ["/PID", "421", "/T", "/F"] }, + ]); + }); + + test("uninstalls through the built-in cmdlet and propagates registration failures", async () => { + const { ctx, calls } = context(); + const backend = createWindowsBackend(); + + await backend.uninstall(ctx); + expect(calls[0]).toEqual({ + command: "powershell.exe", + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Unregister-ScheduledTask -TaskName 'CollieBridge' -Confirm:$false -ErrorAction SilentlyContinue", + ], + }); + + const failed = context([result("", "registration failed", 23)]); + await expect(createWindowsBackend().install(failed.ctx)).rejects.toThrow("registration failed"); + expect(failed.calls).toHaveLength(1); + }); + + test("parses running state and only numeric process ids", () => { + expect(parseScheduledTaskActive("TaskName: \\CollieBridge\r\nStatus: Running\r\n")).toBe(true); + expect(parseScheduledTaskActive("TaskName: \\CollieBridge\r\nStatus: Ready\r\n")).toBe(false); + expect(parseBridgePids("421\r\nnot-a-pid\r\n 900 \n")).toEqual(["421", "900"]); + expect(renderBridgePidQuery("C:\\checkout")).toContain("Get-CimInstance Win32_Process"); + }); +}); + +describe("systemd --user backend", () => { + test("writes the unit and preserves enable, stop, reset, and journal commands", async () => { + const home = await temporaryHome(); + try { + const { ctx, calls } = context(); + const options = { homeDir: home, rootDir: "/checkout", bun: "bun" }; + const backend = createSystemdBackend(options); + + await backend.install(ctx); + expect(await readFile(systemdUnitFile(options), "utf8")).toBe(renderSystemdUnit(ctx, options)); + expect(calls).toEqual([ + { command: "systemctl", args: ["--user", "daemon-reload"] }, + ]); + + await backend.start(ctx); + await backend.stop(ctx); + expect(backend.logsCmd(ctx, 9)).toEqual({ + command: "journalctl", + args: ["--user", "-u", "collie", "-n", "9", "--no-pager"], + }); + await backend.uninstall(ctx); + expect(calls).toEqual([ + { command: "systemctl", args: ["--user", "daemon-reload"] }, + { command: "systemctl", args: ["--user", "enable", "--now", "collie"] }, + { command: "systemctl", args: ["--user", "disable", "--now", "collie"] }, + { command: "systemctl", args: ["--user", "daemon-reload"] }, + { command: "systemctl", args: ["--user", "reset-failed", "collie"] }, + ]); + await expect(stat(systemdUnitFile(options))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await remove(home); + } + }); +}); + +describe("launchd backend", () => { + test("writes the per-user plist and uses bootstrap, disable, and bootout", async () => { + const home = await temporaryHome(); + try { + const { ctx, calls } = context(); + const options = { homeDir: home, uid: 42, rootDir: "/checkout", bun: "bun" }; + const backend = createLaunchdBackend(options); + + await backend.install(ctx); + expect(await readFile(launchdAgentFile(options), "utf8")).toBe(renderLaunchdPlist(ctx, options)); + expect(await readFile(launchdAgentFile(options), "utf8")).toContain( + "scripts/ctl/main.ts", + ); + await backend.start(ctx); + await backend.stop(ctx); + await backend.uninstall(ctx); + + expect(calls).toEqual([ + { command: "launchctl", args: ["bootout", launchdTarget(options)] }, + { command: "launchctl", args: ["enable", launchdTarget(options)] }, + { command: "launchctl", args: ["bootstrap", "gui/42", launchdAgentFile(options)] }, + { command: "launchctl", args: ["disable", launchdTarget(options)] }, + { command: "launchctl", args: ["bootout", launchdTarget(options)] }, + { command: "launchctl", args: ["enable", launchdTarget(options)] }, + ]); + await expect(stat(launchdAgentFile(options))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await remove(home); + } + }); + + test("reports active only when launchctl print exposes a running process", async () => { + const active = context([result("pid = 1234\n")]); + expect(await createLaunchdBackend({ uid: 9 }).isActive(active.ctx)).toBe(true); + const loaded = context([result("state = loaded\n")]); + expect(await createLaunchdBackend({ uid: 9 }).isActive(loaded.ctx)).toBe(false); + const missing = context([result("", "not found", exitCodeForMissingTask())]); + expect(await createLaunchdBackend({ uid: 9 }).isActive(missing.ctx)).toBe(false); + }); +}); + +function exitCodeForMissingTask(): number { + return 113; +} \ No newline at end of file diff --git a/scripts/ctl/backends/common.ts b/scripts/ctl/backends/common.ts new file mode 100644 index 00000000..c36740aa --- /dev/null +++ b/scripts/ctl/backends/common.ts @@ -0,0 +1,81 @@ +import { fileURLToPath } from "node:url"; + +import type { Ctx, ServiceBackend, ShellCommand, ShellResult } from "../types.ts"; + +/** The backend surface plus the destructive operation needed by the uninstall verb. */ +export type InstalledServiceBackend = ServiceBackend & { + uninstall(ctx: Ctx): Promise; +}; + +/** The checkout containing scripts/ctl when no test or embedding override is supplied. */ +export const DEFAULT_CHECKOUT_ROOT = fileURLToPath(new URL("../../../", import.meta.url)); + +/** Run a command and turn a non-zero result into an actionable ctl error. */ +export async function checkedShell( + ctx: Ctx, + command: string, + args: readonly string[] = [], +): Promise { + const result = await ctx.shell(command, args); + if (result.exitCode !== 0) { + const detail = result.stderr.trim() || result.stdout.trim(); + throw new Error( + `command failed (${result.exitCode}): ${[command, ...args].join(" ")}${ + detail.length > 0 ? `\n${detail}` : "" + }`, + ); + } + return result; +} + +/** Run an idempotent supervisor cleanup operation, matching the shell implementation's semantics. */ +export async function bestEffortShell( + ctx: Ctx, + command: string, + args: readonly string[] = [], +): Promise { + try { + const result = await ctx.shell(command, args); + return result; + } catch { + return undefined; + } +} + +/** Normalize a tail count without allowing a command argument injection. */ +export function logLineCount(lines: number | undefined): number { + if (lines === undefined || !Number.isFinite(lines)) return 50; + return Math.max(0, Math.floor(lines)); +} + +/** Build a platform-neutral command used by the info verb's injected log reader. */ +export function tailCommand(file: string, lines: number | undefined): ShellCommand { + return { command: "tail", args: ["-n", String(logLineCount(lines)), file] }; +} + +/** Resolve a generated service's checkout root while keeping fixture paths injectable. */ +export function checkoutRoot(rootDir: string | undefined): string { + return rootDir ?? DEFAULT_CHECKOUT_ROOT; +} + +/** Use a bare Bun command by default, while allowing installations with a non-standard binary. */ +export function bunBinary(binary: string | undefined): string { + return binary ?? process.env.BUN_BINARY ?? "bun"; +} + +/** Escape a value for use as PowerShell single-quoted string content. */ +export function powershellLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +/** The common lifecycle extension used by all three backend implementations. */ +export interface BackendFactoryOptions { + rootDir?: string; + bun?: string; +} + +/** Type guard-like helper for keeping backend factories honest at compile time. */ +export function asInstalledBackend(backend: T): T { + return backend; +} + diff --git a/scripts/ctl/backends/launchd.ts b/scripts/ctl/backends/launchd.ts new file mode 100644 index 00000000..1f35bfe4 --- /dev/null +++ b/scripts/ctl/backends/launchd.ts @@ -0,0 +1,174 @@ +import { chmod, mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +import type { Ctx, ShellCommand } from "../types.ts"; +import { + asInstalledBackend, + bestEffortShell, + bunBinary, + checkedShell, + checkoutRoot, + logLineCount, + type BackendFactoryOptions, + type InstalledServiceBackend, +} from "./common.ts"; + +export const LAUNCHD_AGENT_LABEL = "herdr.collie"; +export const AGENT_LABEL = LAUNCHD_AGENT_LABEL; + +export interface LaunchdBackendOptions extends BackendFactoryOptions { + /** Home directory used for the per-user LaunchAgent, injectable for fixture tests. */ + homeDir?: string; + /** UID used to address launchd's per-user GUI domain, injectable for fixture tests. */ + uid?: string | number; + /** Agent label, kept configurable for isolated integration fixtures. */ + label?: string; +} + +interface ResolvedLaunchdOptions { + rootDir: string; + bun: string; + homeDir: string; + uid: string; + label: string; +} + +function resolvedOptions(options: LaunchdBackendOptions): ResolvedLaunchdOptions { + const uid = options.uid ?? process.getuid?.() ?? process.env.UID ?? "0"; + return { + rootDir: checkoutRoot(options.rootDir), + bun: bunBinary(options.bun), + homeDir: options.homeDir ?? homedir(), + uid: String(uid), + label: options.label ?? LAUNCHD_AGENT_LABEL, + }; +} + +export function launchdDomain(options: LaunchdBackendOptions = {}): string { + return `gui/${resolvedOptions(options).uid}`; +} + +export function launchdTarget(options: LaunchdBackendOptions = {}): string { + const resolved = resolvedOptions(options); + return `${launchdDomain(options)}/${resolved.label}`; +} + +export function launchdAgentFile(options: LaunchdBackendOptions = {}): string { + const resolved = resolvedOptions(options); + return join(resolved.homeDir, "Library", "LaunchAgents", `${resolved.label}.plist`); +} + +function xmlEscape(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +/** Render the per-user plist parallel to the old launchd agent generated by collie-ctl.sh. */ +export function renderLaunchdPlist(ctx: Ctx, options: LaunchdBackendOptions = {}): string { + const resolved = resolvedOptions(options); + const rootDir = ctx.rootDir ?? resolved.rootDir; + const logFile = join(ctx.stateDir, "collie.log"); + const env = [ + ["HERDR_PLUGIN_CONFIG_DIR", ctx.configDir], + ["HERDR_PLUGIN_STATE_DIR", ctx.stateDir], + ["HERDR_SOCKET_PATH", ctx.socketPath], + ] as const; + const environment = env + .map( + ([name, value]) => + ` ${name}\n ${xmlEscape(value)}`, + ) + .join("\n"); + + return ` + + + + Label + ${xmlEscape(resolved.label)} + ProgramArguments + + ${xmlEscape(resolved.bun)} + scripts/ctl/main.ts + exec-bridge + + WorkingDirectory + ${xmlEscape(rootDir)} + EnvironmentVariables + +${environment} + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 5 + StandardOutPath + ${xmlEscape(logFile)} + StandardErrorPath + ${xmlEscape(logFile)} + + +`; +} + +export function createLaunchdBackend(options: LaunchdBackendOptions = {}): InstalledServiceBackend { + const resolved = resolvedOptions(options); + const agentFile = launchdAgentFile(options); + const domain = `gui/${resolved.uid}`; + const target = `${domain}/${resolved.label}`; + + return asInstalledBackend({ + async install(ctx: Ctx): Promise { + await mkdir(dirname(agentFile), { recursive: true }); + await writeFile(agentFile, renderLaunchdPlist(ctx, options), "utf8"); + // launchd rejects an agent plist that is left world-writable by an unusual umask. + await chmod(agentFile, 0o644); + }, + + async start(ctx: Ctx): Promise { + // bootout makes start idempotent when an earlier install is still loaded. + await bestEffortShell(ctx, "launchctl", ["bootout", target]); + await bestEffortShell(ctx, "launchctl", ["enable", target]); + await checkedShell(ctx, "launchctl", ["bootstrap", domain, agentFile]); + }, + + async stop(ctx: Ctx): Promise { + // Disable persists across logins; bootout stops the currently loaded agent. + await bestEffortShell(ctx, "launchctl", ["disable", target]); + await bestEffortShell(ctx, "launchctl", ["bootout", target]); + }, + + async uninstall(ctx: Ctx): Promise { + await rm(agentFile, { force: true }); + // Reset launchd's disabled bit so reinstalling the same label can run at login. + await bestEffortShell(ctx, "launchctl", ["enable", target]); + }, + + async isActive(ctx: Ctx): Promise { + try { + const result = await ctx.shell("launchctl", ["print", target]); + if (result.exitCode !== 0) return false; + return /(?:^|\n)\s*pid\s*=\s*\d+/i.test(result.stdout) + || /(?:^|\n)\s*state\s*=\s*running/i.test(result.stdout); + } catch { + return false; + } + }, + + logsCmd(_ctx: Ctx, lines?: number): ShellCommand { + return { + command: "tail", + args: ["-n", String(logLineCount(lines)), join(_ctx.stateDir, "collie.log")], + }; + }, + }); +} + +export const launchdBackend = createLaunchdBackend(); +export const backend = launchdBackend; +export default launchdBackend; diff --git a/scripts/ctl/backends/systemd.ts b/scripts/ctl/backends/systemd.ts new file mode 100644 index 00000000..b27d934a --- /dev/null +++ b/scripts/ctl/backends/systemd.ts @@ -0,0 +1,116 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +import type { Ctx, ShellCommand } from "../types.ts"; +import { + asInstalledBackend, + bunBinary, + checkedShell, + checkoutRoot, + logLineCount, + type BackendFactoryOptions, + type InstalledServiceBackend, +} from "./common.ts"; + +export const SYSTEMD_UNIT_NAME = "collie"; +export const SYSTEMD_UNIT = SYSTEMD_UNIT_NAME; + +export interface SystemdBackendOptions extends BackendFactoryOptions { + /** Home directory used for the per-user unit, injectable for fixture tests. */ + homeDir?: string; + /** Unit name, kept configurable for isolated integration fixtures. */ + unitName?: string; +} + +function resolvedOptions(options: SystemdBackendOptions): Required> { + return { + rootDir: checkoutRoot(options.rootDir), + bun: bunBinary(options.bun), + homeDir: options.homeDir ?? homedir(), + unitName: options.unitName ?? SYSTEMD_UNIT_NAME, + }; +} + +export function systemdUnitFile(options: SystemdBackendOptions = {}): string { + const resolved = resolvedOptions(options); + return join(resolved.homeDir, ".config", "systemd", "user", `${resolved.unitName}.service`); +} + +/** Render the generated systemd --user unit, preserving collie-ctl.sh's service policy. */ +export function renderSystemdUnit(ctx: Ctx, options: SystemdBackendOptions = {}): string { + const resolved = resolvedOptions(options); + const rootDir = ctx.rootDir ?? resolved.rootDir; + return `[Unit] +Description=Collie +After=default.target +# Never give up restarting - a phone-only operator cannot run systemctl reset-failed. +StartLimitIntervalSec=0 + +[Service] +Type=simple +WorkingDirectory=${rootDir} +ExecStart=${resolved.bun} scripts/ctl/main.ts exec-bridge +Restart=on-failure +RestartSec=5 +# Keep the remote shell bridge unprivileged and isolate its temporary files. +NoNewPrivileges=yes +PrivateTmp=yes +Environment=HERDR_SOCKET_PATH=${ctx.socketPath} +Environment=COLLIE_PORT=8787 +Environment=HERDR_PLUGIN_CONFIG_DIR=${ctx.configDir} +Environment=HERDR_PLUGIN_STATE_DIR=${ctx.stateDir} +EnvironmentFile=-${join(ctx.configDir, ".env")} + +[Install] +WantedBy=default.target +`; +} + +export function createSystemdBackend(options: SystemdBackendOptions = {}): InstalledServiceBackend { + const resolved = resolvedOptions(options); + const unitFile = systemdUnitFile(options); + const unitName = resolved.unitName; + + return asInstalledBackend({ + async install(ctx: Ctx): Promise { + await mkdir(dirname(unitFile), { recursive: true }); + await writeFile(unitFile, renderSystemdUnit(ctx, options), "utf8"); + await checkedShell(ctx, "systemctl", ["--user", "daemon-reload"]); + }, + + async start(ctx: Ctx): Promise { + await checkedShell(ctx, "systemctl", ["--user", "enable", "--now", unitName]); + }, + + async stop(ctx: Ctx): Promise { + await checkedShell(ctx, "systemctl", ["--user", "disable", "--now", unitName]); + }, + + async uninstall(ctx: Ctx): Promise { + await rm(unitFile, { force: true }); + await checkedShell(ctx, "systemctl", ["--user", "daemon-reload"]); + await checkedShell(ctx, "systemctl", ["--user", "reset-failed", unitName]); + }, + + async isActive(ctx: Ctx): Promise { + try { + const result = await ctx.shell("systemctl", ["--user", "is-active", unitName]); + return result.exitCode === 0 && result.stdout.trim() === "active"; + } catch { + return false; + } + }, + + logsCmd(_ctx: Ctx, lines?: number): ShellCommand { + return { + command: "journalctl", + args: ["--user", "-u", unitName, "-n", String(logLineCount(lines)), "--no-pager"], + }; + }, + }); +} + +export const systemdBackend = createSystemdBackend(); +export const backend = systemdBackend; +export default systemdBackend; diff --git a/scripts/ctl/backends/windows.ts b/scripts/ctl/backends/windows.ts new file mode 100644 index 00000000..d4bdab9f --- /dev/null +++ b/scripts/ctl/backends/windows.ts @@ -0,0 +1,197 @@ +import { join } from "node:path"; + +import type { Ctx, ShellCommand } from "../types.ts"; +import { + asInstalledBackend, + bunBinary, + checkedShell, + checkoutRoot, + logLineCount, + powershellLiteral, + type BackendFactoryOptions, + type InstalledServiceBackend, +} from "./common.ts"; + +export const WINDOWS_TASK_NAME = "CollieBridge"; +export const TASK_NAME = WINDOWS_TASK_NAME; + +export interface WindowsBackendOptions extends BackendFactoryOptions { + /** Task name, injectable for isolated fixtures while keeping the shipped name stable. */ + taskName?: string; + /** PowerShell executable used to invoke the built-in Scheduled Tasks cmdlets. */ + powershell?: string; + /** Built-in task query executable. */ + schtasks?: string; + /** Built-in process termination executable. */ + taskkill?: string; +} + +interface ResolvedWindowsOptions { + rootDir: string; + bun: string; + taskName: string; + powershell: string; + schtasks: string; + taskkill: string; +} + +function resolvedOptions(options: WindowsBackendOptions): ResolvedWindowsOptions { + return { + rootDir: checkoutRoot(options.rootDir), + bun: bunBinary(options.bun), + taskName: options.taskName ?? WINDOWS_TASK_NAME, + powershell: options.powershell ?? "powershell.exe", + schtasks: options.schtasks ?? "schtasks", + taskkill: options.taskkill ?? "taskkill", + }; +} + +function powershellArgs(script: string): readonly string[] { + return [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ]; +} + +/** + * The action that Task Scheduler launches at logon. The environment is explicit because a task + * does not inherit the shell that invoked `ctl`, and the final redirection keeps supervisor output + * in Collie's state directory even when the bridge exits before exec-bridge can create its child. + */ +function taskActionArguments(ctx: Ctx, options: ResolvedWindowsOptions): string { + const logFile = join(ctx.stateDir, "collie.log"); + const action = [ + `New-Item -ItemType Directory -Force -Path ${powershellLiteral(ctx.stateDir)} | Out-Null`, + `$env:HERDR_PLUGIN_CONFIG_DIR = ${powershellLiteral(ctx.configDir)}`, + `$env:HERDR_PLUGIN_STATE_DIR = ${powershellLiteral(ctx.stateDir)}`, + `$env:HERDR_SOCKET_PATH = ${powershellLiteral(ctx.socketPath)}`, + `& ${powershellLiteral(options.bun)} ${powershellLiteral("scripts/ctl/main.ts")} ${powershellLiteral("exec-bridge")} *> ${powershellLiteral(logFile)}`, + ].join("; "); + return `-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { ${action} }"`; +} + +/** Render the PowerShell registration script used by install. */ +export function renderTaskRegistration(ctx: Ctx, options: WindowsBackendOptions = {}): string { + const resolved = resolvedOptions(options); + const rootDir = ctx.rootDir ?? resolved.rootDir; + const actionArguments = taskActionArguments(ctx, resolved); + return [ + `$identity = [Security.Principal.WindowsIdentity]::GetCurrent().Name`, + `$action = New-ScheduledTaskAction -Execute ${powershellLiteral("powershell.exe")} -Argument ${powershellLiteral(actionArguments)} -WorkingDirectory ${powershellLiteral(rootDir)}`, + `$trigger = New-ScheduledTaskTrigger -AtLogOn -User $identity`, + `$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType Interactive -RunLevel Limited`, + `$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -StartWhenAvailable`, + `Register-ScheduledTask -TaskName ${powershellLiteral(resolved.taskName)} -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description ${powershellLiteral("Collie mobile bridge for Herdr")} -Force | Out-Null`, + ].join("; "); +} + +/** Render the bounded process query used by Windows stop's force-kill fallback. */ +export function renderBridgePidQuery(rootDir: string): string { + return [ + `$root = ${powershellLiteral(rootDir)}`, + `Get-CimInstance Win32_Process -Filter ${powershellLiteral("Name = 'bun.exe'")} | Where-Object { $_.CommandLine -and $_.CommandLine.Contains('exec-bridge') -and ($_.CommandLine.Contains($root) -or $_.CommandLine.Contains('scripts/ctl/main.ts')) } | Select-Object -ExpandProperty ProcessId`, + ].join("; "); +} + +/** Parse the PID-only output from renderBridgePidQuery. */ +export function parseBridgePids(stdout: string): string[] { + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => /^\d+$/.test(line)); +} + +/** Parse schtasks' localized list output without treating Ready as running. */ +export function parseScheduledTaskActive(stdout: string): boolean { + return stdout.split(/\r?\n/).some((line) => { + const trimmed = line.trim(); + return /^(?:status|state)\s*:\s*running$/i.test(trimmed) || /^running$/i.test(trimmed); + }); +} + +export function createWindowsBackend(options: WindowsBackendOptions = {}): InstalledServiceBackend { + const resolved = resolvedOptions(options); + + return asInstalledBackend({ + async install(ctx: Ctx): Promise { + await checkedShell( + ctx, + resolved.powershell, + powershellArgs(renderTaskRegistration(ctx, options)), + ); + }, + + async start(ctx: Ctx): Promise { + await checkedShell( + ctx, + resolved.powershell, + powershellArgs( + `Start-ScheduledTask -TaskName ${powershellLiteral(resolved.taskName)}`, + ), + ); + }, + + async stop(ctx: Ctx): Promise { + await checkedShell( + ctx, + resolved.powershell, + powershellArgs( + `Stop-ScheduledTask -TaskName ${powershellLiteral(resolved.taskName)} -ErrorAction SilentlyContinue`, + ), + ); + + const pidResult = await checkedShell( + ctx, + resolved.powershell, + powershellArgs(renderBridgePidQuery(ctx.rootDir ?? resolved.rootDir)), + ); + for (const pid of parseBridgePids(pidResult.stdout)) { + await checkedShell(ctx, resolved.taskkill, ["/PID", pid, "/T", "/F"]); + } + }, + + async uninstall(ctx: Ctx): Promise { + await checkedShell( + ctx, + resolved.powershell, + powershellArgs( + `Unregister-ScheduledTask -TaskName ${powershellLiteral(resolved.taskName)} -Confirm:$false -ErrorAction SilentlyContinue`, + ), + ); + }, + + async isActive(ctx: Ctx): Promise { + try { + const result = await ctx.shell(resolved.schtasks, [ + "/query", + "/tn", + resolved.taskName, + "/fo", + "LIST", + ]); + return result.exitCode === 0 && parseScheduledTaskActive(result.stdout); + } catch { + return false; + } + }, + + logsCmd(ctx: Ctx, lines?: number): ShellCommand { + const count = logLineCount(lines); + const file = join(ctx.stateDir, "collie.log"); + return { + command: resolved.powershell, + args: powershellArgs( + `if (Test-Path -LiteralPath ${powershellLiteral(file)}) { Get-Content -LiteralPath ${powershellLiteral(file)} -Tail ${count} } else { '(no log)' }`, + ), + }; + }, + }); +} + +export const windowsBackend = createWindowsBackend(); +export const backend = windowsBackend; +export default windowsBackend; From 9ba188184d03ccd93cf8b572349a0caea569f683 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:47 +0900 Subject: [PATCH 07/23] feat(scripts): lifecycle verbs for ctl --- scripts/ctl/verbs-lifecycle.test.ts | 314 +++++++++++ scripts/ctl/verbs-lifecycle.ts | 777 ++++++++++++++++++++++++++++ 2 files changed, 1091 insertions(+) create mode 100644 scripts/ctl/verbs-lifecycle.test.ts create mode 100644 scripts/ctl/verbs-lifecycle.ts diff --git a/scripts/ctl/verbs-lifecycle.test.ts b/scripts/ctl/verbs-lifecycle.test.ts new file mode 100644 index 00000000..e1be9ac9 --- /dev/null +++ b/scripts/ctl/verbs-lifecycle.test.ts @@ -0,0 +1,314 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +import { runShell } from "./main.ts"; +import { + parseReleaseTags, + restart, + start, + stop, + uninstall, + update, + type LifecycleBackend, + type LifecycleDeps, +} from "./verbs-lifecycle.ts"; +import type { Ctx } from "./types.ts"; + +async function fixture(name: string): Promise<{ root: string; ctx: Ctx }> { + const root = await mkdtemp(join(tmpdir(), `collie-ctl-lifecycle-${name}-`)); + const configDir = join(root, "config"); + const stateDir = join(root, "state"); + await mkdir(configDir, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + return { + root, + ctx: { + rootDir: root, + configDir, + stateDir, + socketPath: join(root, "herdr.sock"), + log() {}, + shell: runShell, + }, + }; +} + +async function clean(root: string): Promise { + await rm(root, { recursive: true, force: true }); +} + +function backend(calls: string[]): LifecycleBackend { + return { + async install() { + calls.push("install"); + }, + async start() { + calls.push("start"); + }, + async stop() { + calls.push("stop"); + }, + async uninstall() { + calls.push("uninstall"); + }, + }; +} + +function deps( + ctx: Ctx, + calls: string[], + extra: Partial = {}, +): LifecycleDeps { + return { + backend: backend(calls), + ops: { + ensureBuild: async () => { + calls.push("ensure-build"); + }, + build: async () => { + calls.push("build"); + }, + unserve: async () => { + calls.push("unserve"); + }, + }, + rootDir: ctx.rootDir, + waitForReadiness: async (port) => { + calls.push(`ready:${port}`); + return true; + }, + ...extra, + }; +} + +async function git(root: string, args: string[], expected = 0): Promise { + const result = await runShell("git", ["-C", root, ...args]); + expect(result.exitCode, `${args.join(" ")}\n${result.stderr}`).toBe(expected); + return result.stdout; +} + +async function commit(root: string, message: string): Promise { + await git(root, ["add", "-A"]); + const result = await runShell("git", [ + "-c", + "user.name=collie-test", + "-c", + "user.email=test@example.invalid", + "-C", + root, + "commit", + "-qm", + message, + ]); + expect(result.exitCode, result.stderr).toBe(0); +} + +async function originRepo(parent: string, version: string): Promise { + const origin = join(parent, "origin"); + await mkdir(origin, { recursive: true }); + await git(origin, ["init", "-q", "-b", "main"]); + await writeFile(join(origin, "herdr-plugin.toml"), `version = "${version}"\n`, "utf8"); + await writeFile(join(origin, "VERSION"), version, "utf8"); + await commit(origin, "initial"); + return origin; +} + +describe("ctl lifecycle verbs", () => { + test("start builds once, starts the backend, waits for readiness, and prints its URL", async () => { + const { root, ctx } = await fixture("start"); + try { + const calls: string[] = []; + const output: unknown[][] = []; + const loggedCtx: Ctx = { ...ctx, log: (...args) => output.push(args) }; + const lifecycle = deps(loggedCtx, calls, { publicUrl: "https://collie.example" }); + + await start(loggedCtx, lifecycle); + + expect(calls).toEqual(["ensure-build", "install", "start", "ready:8787"]); + expect(output.flat().join(" ")).toContain("https://collie.example"); + } finally { + await clean(root); + } + }); + + test("stop and restart delegate to the backend without reinstalling it", async () => { + const { root, ctx } = await fixture("restart"); + try { + const calls: string[] = []; + const lifecycle = deps(ctx, calls); + await stop(ctx, lifecycle); + await restart(ctx, lifecycle); + expect(calls).toEqual(["stop", "stop", "start"]); + } finally { + await clean(root); + } + }); + + test("uninstall removes registration, serve ownership, and pidfile but preserves config and checkout", async () => { + const { root, ctx } = await fixture("uninstall"); + try { + const envFile = join(ctx.configDir, ".env"); + const checkoutMarker = join(root, "checkout-marker"); + const pidFile = join(ctx.configDir, "collie.pid"); + await writeFile(envFile, "COLLIE_PORT=9012\n", "utf8"); + await writeFile(checkoutMarker, "checkout", "utf8"); + await writeFile(pidFile, "12345\n", "utf8"); + const calls: string[] = []; + const lifecycle = deps(ctx, calls); + + await uninstall(ctx, lifecycle); + + expect(calls).toEqual(["stop", "unserve", "uninstall"]); + expect(await Bun.file(envFile).exists()).toBe(true); + expect(await Bun.file(checkoutMarker).exists()).toBe(true); + expect(await Bun.file(pidFile).exists()).toBe(false); + } finally { + await clean(root); + } + }); + + test("start reports a readiness timeout instead of claiming the service is ready", async () => { + const { root, ctx } = await fixture("timeout"); + try { + const calls: string[] = []; + const lifecycle = deps(ctx, calls, { + waitForReadiness: async () => false, + }); + await expect(start(ctx, lifecycle)).rejects.toThrow("did not become ready"); + expect(calls).toEqual(["ensure-build", "install", "start"]); + } finally { + await clean(root); + } + }); + + test("advances a linked clone with fetch plus ff-only pull, then rebuilds and restarts", async () => { + const { root, ctx } = await fixture("linked"); + try { + const origin = await originRepo(root, "1.0.0"); + const clone = join(root, "clone"); + const cloneResult = await runShell("git", ["clone", "-q", origin, clone]); + expect(cloneResult.exitCode, cloneResult.stderr).toBe(0); + await writeFile(join(origin, "VERSION"), "1.0.1", "utf8"); + await writeFile(join(origin, "herdr-plugin.toml"), 'version = "1.0.1"\n', "utf8"); + await commit(origin, "release"); + + const calls: string[] = []; + const lifecycle = deps( + { ...ctx, rootDir: clone }, + calls, + { rootDir: clone }, + ); + await update({ ...ctx, rootDir: clone }, lifecycle); + + expect(await readFile(join(clone, "VERSION"), "utf8")).toBe("1.0.1"); + expect(calls).toContain("build"); + expect(calls).toContain("stop"); + expect(calls).toContain("start"); + expect((await git(clone, ["symbolic-ref", "--short", "HEAD"])).trim()).toBe("main"); + } finally { + await clean(root); + } + }); + + test("advances a detached checkout by fetching and checking out FETCH_HEAD", async () => { + const { root, ctx } = await fixture("detached"); + try { + const origin = await originRepo(root, "1.0.0"); + const managed = join(root, "managed"); + await mkdir(managed, { recursive: true }); + await git(managed, ["init", "-q"]); + await git(managed, ["remote", "add", "origin", origin]); + await git(managed, ["fetch", "-q", "--depth", "1", "origin", "HEAD"]); + await git(managed, ["checkout", "-q", "--detach", "FETCH_HEAD"]); + await writeFile(join(origin, "VERSION"), "1.0.1", "utf8"); + await writeFile(join(origin, "herdr-plugin.toml"), 'version = "1.0.1"\n', "utf8"); + await commit(origin, "release"); + + const calls: string[] = []; + const lifecycle = deps({ ...ctx, rootDir: managed }, calls, { rootDir: managed }); + await update({ ...ctx, rootDir: managed }, lifecycle); + + expect(await readFile(join(managed, "VERSION"), "utf8")).toBe("1.0.1"); + expect(calls).toContain("build"); + expect((await git(managed, ["symbolic-ref", "-q", "HEAD"], 1)).trim()).toBe(""); + } finally { + await clean(root); + } + }); + + test("follows detached release tags within the installed major and crosses one major per flag", async () => { + const { root, ctx } = await fixture("detached-tags"); + try { + const origin = await originRepo(root, "1.0.0"); + await git(origin, ["tag", "v1.0.0"]); + await writeFile(join(origin, "VERSION"), "1.1.0", "utf8"); + await writeFile(join(origin, "herdr-plugin.toml"), 'version = "1.1.0"\n', "utf8"); + await commit(origin, "minor release"); + await git(origin, ["tag", "v1.1.0"]); + await writeFile(join(origin, "VERSION"), "2.0.0", "utf8"); + await writeFile(join(origin, "herdr-plugin.toml"), 'version = "2.0.0"\n', "utf8"); + await commit(origin, "major release"); + await git(origin, ["tag", "v2.0.0"]); + + const managed = join(root, "managed"); + await mkdir(managed, { recursive: true }); + await git(managed, ["init", "-q"]); + await git(managed, ["remote", "add", "origin", origin]); + await git(managed, ["fetch", "-q", "--depth", "1", "origin", "refs/tags/v1.0.0"]); + await git(managed, ["checkout", "-q", "--detach", "FETCH_HEAD"]); + const calls: string[] = []; + const lifecycle = deps({ ...ctx, rootDir: managed }, calls, { rootDir: managed }); + + await update({ ...ctx, rootDir: managed }, lifecycle); + expect(await readFile(join(managed, "VERSION"), "utf8")).toBe("1.1.0"); + await update({ ...ctx, rootDir: managed }, lifecycle, ["--major"]); + expect(await readFile(join(managed, "VERSION"), "utf8")).toBe("2.0.0"); + } finally { + await clean(root); + } + }); + + test("refuses a linked major crossing unless --major is supplied", async () => { + const { root, ctx } = await fixture("major"); + try { + const origin = await originRepo(root, "1.0.0"); + const clone = join(root, "clone"); + const cloneResult = await runShell("git", ["clone", "-q", origin, clone]); + expect(cloneResult.exitCode, cloneResult.stderr).toBe(0); + await writeFile(join(origin, "VERSION"), "2.0.0", "utf8"); + await writeFile(join(origin, "herdr-plugin.toml"), 'version = "2.0.0"\n', "utf8"); + await commit(origin, "major"); + const before = (await git(clone, ["rev-parse", "HEAD"])).trim(); + const calls: string[] = []; + const lifecycle = deps({ ...ctx, rootDir: clone }, calls, { rootDir: clone }); + + await update({ ...ctx, rootDir: clone }, lifecycle); + expect((await git(clone, ["rev-parse", "HEAD"])).trim()).toBe(before); + expect(calls).not.toContain("build"); + + await update({ ...ctx, rootDir: clone }, lifecycle, ["--major"]); + expect((await git(clone, ["rev-parse", "HEAD"])).trim()).not.toBe(before); + } finally { + await clean(root); + } + }); + + test("parses annotated and lightweight strict release tags without prereleases", () => { + expect( + parseReleaseTags( + [ + "111 refs/tags/v1.0.0", + "222 refs/tags/v1.1.0", + "333 refs/tags/v1.1.0^{}", + "444 refs/tags/v2.0.0-beta.1", + "555 refs/tags/v2.0", + ].join("\n"), + ), + ).toEqual([ + expect.objectContaining({ name: "v1.0.0", commit: "111", peeled: false }), + expect.objectContaining({ name: "v1.1.0", commit: "333", peeled: true }), + ]); + }); +}); diff --git a/scripts/ctl/verbs-lifecycle.ts b/scripts/ctl/verbs-lifecycle.ts new file mode 100644 index 00000000..84596ff7 --- /dev/null +++ b/scripts/ctl/verbs-lifecycle.ts @@ -0,0 +1,777 @@ +import { readFile, rm, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import type { Ctx, ServiceBackend, ShellResult } from "./types.ts"; + +/** The supervisor methods lifecycle verbs need; log/status methods are deliberately not coupled in. */ +export interface LifecycleBackend + extends Pick { + /** Remove the supervisor registration, when the selected backend supports teardown. */ + uninstall?: (ctx: Ctx) => Promise; +} + +/** The injected operational seams exported by verbs-ops.ts. */ +export interface LifecycleOps { + /** Build unconditionally, as used by update. */ + build?: (ctx: Ctx) => Promise; + /** Optional explicit rebuild alias for callers that distinguish build from rebuild. */ + rebuild?: (ctx: Ctx) => Promise; + /** Optional first-run build operation. */ + ensureBuild?: (ctx: Ctx) => Promise; + /** Compatibility spelling for callers porting the shell verb literally. */ + ensure_build?: (ctx: Ctx) => Promise; + /** Remove only Collie's recorded Tailscale mapping. */ + unserve?: (ctx: Ctx) => Promise; +} + +/** Readiness options used by the default loopback TCP probe. */ +export interface ReadinessOptions { + host?: string; + attempts?: number; + intervalMs?: number; +} + +/** A readiness seam; tests subscribe to this exact state instead of sleeping. */ +export type ReadinessWaiter = ( + port: number, + options?: ReadinessOptions, +) => Promise; + +/** A command seam for real Git fixture tests and command-level unit tests. */ +export type LifecycleExecutor = ( + argv: string[], + options?: { cwd?: string }, +) => Promise; + +/** A Git seam whose arguments exclude the executable and whose cwd is explicit. */ +export type GitRunner = ( + args: readonly string[], + options: { cwd: string }, +) => Promise; + +/** A URL provider used by start after the bridge has become ready. */ +export type UrlProvider = (ctx: Ctx) => string | Promise; + +/** Dependencies for lifecycle verbs. */ +export interface LifecycleDeps { + backend: LifecycleBackend; + ops?: LifecycleOps; + /** Flat aliases are accepted for callers that inject the verbs-ops exports directly. */ + build?: LifecycleOps["build"]; + rebuild?: LifecycleOps["rebuild"]; + ensureBuild?: LifecycleOps["ensureBuild"]; + ensure_build?: LifecycleOps["ensure_build"]; + unserve?: LifecycleOps["unserve"]; + rootDir?: string; + env?: Record; + port?: number; + waitForReadiness?: ReadinessWaiter; + readinessOptions?: ReadinessOptions; + executor?: LifecycleExecutor; + git?: GitRunner; + exists?: (path: string) => Promise; + readText?: (path: string) => Promise; + distIndex?: string; + publicUrl?: string; + url?: string | UrlProvider; + getUrl?: UrlProvider; + printUrl?: (ctx: Ctx) => void | string | Promise; + removeRegistration?: (ctx: Ctx) => Promise; +} + +/** The command failure exposed by lifecycle operations, retaining the original Git argv. */ +export class LifecycleCommandError extends Error { + readonly argv: string[]; + readonly result: ShellResult; + + constructor(argv: string[], result: ShellResult) { + const detail = result.stderr.trim() || result.stdout.trim(); + super(`command failed (${result.exitCode}): ${argv.join(" ")}${detail ? `\n${detail}` : ""}`); + this.name = "LifecycleCommandError"; + this.argv = argv; + this.result = result; + } + + get exitCode(): number { + return this.result.exitCode; + } +} + +const DEFAULT_PORT = 8787; +const DEFAULT_ROOT = resolve(import.meta.dir, "../.."); +const PIDFILE = "collie.pid"; +const DIST_INDEX = join("web", "dist", "index.html"); +const SERVE_MAPPING = "tailscale-managed-handler"; +const MAJOR_ACTION = "herdr plugin action invoke update-major --plugin herdr.collie"; + +interface LifecycleEnvironment { + [key: string]: string | undefined; +} + +interface ReleaseTag { + name: string; + version: string; + commit: string; + major: number; + minor: number; + patch: number; + peeled: boolean; +} + +interface UpdateInvocation { + deps: LifecycleDeps; + args: readonly string[]; +} + +type LifecycleInput = LifecycleDeps | LifecycleBackend; + +function isDeps(value: LifecycleInput): value is LifecycleDeps { + return Object.prototype.hasOwnProperty.call(value, "backend"); +} + +function normalizeDeps(deps: LifecycleDeps): LifecycleDeps { + const direct: LifecycleOps = { + build: deps.build, + rebuild: deps.rebuild, + ensureBuild: deps.ensureBuild, + ensure_build: deps.ensure_build, + unserve: deps.unserve, + }; + const hasDirect = Object.values(direct).some((operation) => operation !== undefined); + if (!hasDirect) return deps; + return { ...deps, ops: { ...direct, ...(deps.ops ?? {}) } }; +} + +function resolveDeps(input: LifecycleInput, ops?: LifecycleOps): LifecycleDeps { + if (isDeps(input)) return normalizeDeps(input); + return { backend: input, ...(ops === undefined ? {} : { ops }) }; +} + +function rootDir(ctx: Ctx, deps: LifecycleDeps): string { + return deps.rootDir ?? ctx.rootDir ?? DEFAULT_ROOT; +} + +function operationContext(ctx: Ctx, deps: LifecycleDeps): Ctx { + const root = rootDir(ctx, deps); + const env = { + ...(ctx.env ?? {}), + ...(deps.env ?? {}), + }; + return { ...ctx, rootDir: root, env }; +} + +function parseDotEnvLine(line: string): [string, string] | undefined { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) return undefined; + const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(trimmed); + if (!match) return undefined; + let value = match[2] ?? ""; + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + return [match[1]!, value]; +} + +async function readConfigEnvironment( + ctx: Ctx, + deps: LifecycleDeps, +): Promise { + const environment: LifecycleEnvironment = { ...process.env }; + try { + const readConfig = deps.readText ?? ((path: string) => readFile(path, "utf8")); + const text = await readConfig(join(ctx.configDir, ".env")); + for (const line of text.split(/\r?\n/)) { + const parsed = parseDotEnvLine(line); + if (parsed) environment[parsed[0]] = parsed[1]; + } + } catch { + // .env is optional on a first install; build/exec operations report their own missing config. + } + Object.assign(environment, ctx.env ?? {}, deps.env ?? {}); + return environment; +} + +function parsePort(value: string | undefined): number { + if (value === undefined || !/^\d+$/.test(value)) return DEFAULT_PORT; + const port = Number(value); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : DEFAULT_PORT; +} + +async function defaultExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +async function exists(path: string, deps: LifecycleDeps): Promise { + return await (deps.exists ?? defaultExists)(path); +} + +async function defaultReadText(path: string): Promise { + return await readFile(path, "utf8"); +} + +async function execute( + ctx: Ctx, + deps: LifecycleDeps, + argv: string[], + cwd: string, +): Promise { + if (deps.executor) return await deps.executor(argv, { cwd }); + if (ctx.shell) { + const command = argv[0]; + if (command === undefined) throw new Error("ctl cannot execute an empty command"); + return await ctx.shell(command, argv.slice(1), { cwd }); + } + const child = Bun.spawn(argv, { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new Response(child.stdout).text(); + const stderr = new Response(child.stderr).text(); + const exitCode = await child.exited; + return { stdout: await stdout, stderr: await stderr, exitCode }; +} + +async function runGit( + ctx: Ctx, + deps: LifecycleDeps, + root: string, + args: readonly string[], +): Promise { + if (deps.git) return await deps.git(args, { cwd: root }); + return await execute(ctx, deps, ["git", "-C", root, ...args], root); +} + +async function checkedGit( + ctx: Ctx, + deps: LifecycleDeps, + root: string, + args: readonly string[], +): Promise { + const result = await runGit(ctx, deps, root, args); + if (result.exitCode !== 0) { + throw new LifecycleCommandError(["git", "-C", root, ...args], result); + } + return result; +} + +async function defaultReadiness( + port: number, + options: ReadinessOptions = {}, +): Promise { + const host = options.host ?? "127.0.0.1"; + const attempts = Math.max(1, Math.floor(options.attempts ?? 25)); + const intervalMs = Math.max(0, Math.floor(options.intervalMs ?? 200)); + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const socket = await Bun.connect({ + hostname: host, + port, + socket: { + open() {}, + data() {}, + error() {}, + close() {}, + }, + }); + socket.end(); + return true; + } catch { + if (attempt + 1 < attempts && intervalMs > 0) await Bun.sleep(intervalMs); + } + } + return false; +} + +async function ensureBuildInternal(ctx: Ctx, deps: LifecycleDeps): Promise { + const operation = operationContext(ctx, deps); + const operations = deps.ops; + const explicit = operations?.ensureBuild ?? operations?.ensure_build; + if (explicit) { + await explicit(operation); + return; + } + + const root = rootDir(ctx, deps); + const distIndex = deps.distIndex ?? join(root, DIST_INDEX); + if (await exists(distIndex, deps)) return; + + if (operations?.build) { + await operations.build(operation); + return; + } + throw new Error("ctl start requires an injected build or ensureBuild operation"); +} + +/** Ensure the first-run UI build exists, using only the injected verbs-ops seam. */ +export async function ensureBuild( + ctx: Ctx, + input: LifecycleInput, + ops?: LifecycleOps, +): Promise { + await ensureBuildInternal(ctx, resolveDeps(input, ops)); +} + +function parseServeUrl(text: string): string | undefined { + const line = text + .split(/\r?\n/) + .map((part) => part.trim()) + .find((part) => part.length > 0); + if (line === undefined) return undefined; + const fields = line.split("|"); + if (fields.length !== 3) return undefined; + const modePort = fields[0] ?? ""; + const hostPort = fields[1] ?? ""; + const match = /^(http|https):(\d+)$/.exec(modePort); + if (!match || hostPort === "") return undefined; + const mode = match[1]; + const port = match[2]; + if (mode === "https" && port === "443") { + return `https://${hostPort.replace(/:443$/, "")}`; + } + return `${mode}://${hostPort}`; +} + +async function resolveUrl( + ctx: Ctx, + deps: LifecycleDeps, + environment: LifecycleEnvironment, + port: number, +): Promise { + const provider = deps.getUrl ?? (typeof deps.url === "function" ? deps.url : undefined); + if (provider) { + const provided = (await provider(operationContext(ctx, deps))).trim(); + if (provided !== "") return provided; + } + const configured = + (typeof deps.url === "string" ? deps.url : undefined) ?? + deps.publicUrl ?? + environment.COLLIE_PUBLIC_URL; + if (configured?.trim()) return configured.trim(); + + try { + const mapping = await (deps.readText ?? defaultReadText)( + join(ctx.configDir, SERVE_MAPPING), + ); + const mapped = parseServeUrl(mapping); + if (mapped) return mapped; + } catch { + // A separate `serve` verb may not have published a mapping yet. + } + return `http://127.0.0.1:${port}`; +} + +async function waitUntilReady( + deps: LifecycleDeps, + port: number, +): Promise { + return await (deps.waitForReadiness ?? defaultReadiness)(port, deps.readinessOptions); +} + +async function printReadyUrl( + ctx: Ctx, + deps: LifecycleDeps, + environment: LifecycleEnvironment, + port: number, +): Promise { + if (deps.printUrl) { + const printed = await deps.printUrl(operationContext(ctx, deps)); + if (typeof printed === "string" && printed.trim() !== "") ctx.log(`open: ${printed.trim()}`); + return; + } + ctx.log(`open: ${await resolveUrl(ctx, deps, environment, port)}`); +} + +/** Start the bridge: first-run build, service registration, service start, readiness, and URL. */ +export function start( + ctx: Ctx, + deps: LifecycleDeps, +): Promise; +export function start( + ctx: Ctx, + backend: LifecycleBackend, + ops?: LifecycleOps, +): Promise; +export async function start( + ctx: Ctx, + input: LifecycleInput, + ops?: LifecycleOps, +): Promise { + const deps = resolveDeps(input, ops); + const environment = await readConfigEnvironment(ctx, deps); + const port = deps.port ?? parsePort(environment.COLLIE_PORT); + await ensureBuildInternal(ctx, deps); + await deps.backend.install(ctx); + await deps.backend.start(ctx); + if (!(await waitUntilReady(deps, port))) { + throw new Error(`bridge did not become ready on 127.0.0.1:${port}`); + } + await printReadyUrl(ctx, deps, environment, port); +} + +/** Stop the installed service without removing its registration. */ +export function stop(ctx: Ctx, deps: LifecycleDeps): Promise; +export function stop( + ctx: Ctx, + backend: LifecycleBackend, + ops?: LifecycleOps, +): Promise; +export async function stop( + ctx: Ctx, + input: LifecycleInput, + ops?: LifecycleOps, +): Promise { + const deps = resolveDeps(input, ops); + await deps.backend.stop(ctx); +} + +async function restartService(ctx: Ctx, deps: LifecycleDeps): Promise { + await deps.backend.stop(ctx); + await deps.backend.start(ctx); +} + +/** Restart by delegating the stop/start pair to the selected supervisor backend. */ +export function restart(ctx: Ctx, deps: LifecycleDeps): Promise; +export function restart( + ctx: Ctx, + backend: LifecycleBackend, + ops?: LifecycleOps, +): Promise; +export async function restart( + ctx: Ctx, + input: LifecycleInput, + ops?: LifecycleOps, +): Promise { + await restartService(ctx, resolveDeps(input, ops)); +} + +async function removeRegistration(ctx: Ctx, deps: LifecycleDeps): Promise { + if (deps.removeRegistration) { + await deps.removeRegistration(ctx); + return; + } + if (deps.backend.uninstall) await deps.backend.uninstall(ctx); +} + +/** + * Tear down only resources Collie owns. The config `.env` and checkout are intentionally untouched. + */ +export function uninstall(ctx: Ctx, deps: LifecycleDeps): Promise; +export function uninstall( + ctx: Ctx, + backend: LifecycleBackend, + ops?: LifecycleOps, +): Promise; +export async function uninstall( + ctx: Ctx, + input: LifecycleInput, + ops?: LifecycleOps, +): Promise { + const deps = resolveDeps(input, ops); + await deps.backend.stop(ctx); + if (deps.ops?.unserve) await deps.ops.unserve(operationContext(ctx, deps)); + await removeRegistration(ctx, deps); + await rm(join(ctx.configDir, PIDFILE), { force: true }); + ctx.log("uninstalled: service registration and Collie's managed serve mapping removed"); +} + +function parseUpdateInvocation( + input: LifecycleDeps | readonly string[], + other?: LifecycleDeps | readonly string[], +): UpdateInvocation { + if (Array.isArray(input)) { + if (other === undefined || Array.isArray(other)) { + throw new Error("ctl update requires lifecycle dependencies"); + } + return { deps: other as LifecycleDeps, args: input }; + } + return { + deps: input as LifecycleDeps, + args: other === undefined || Array.isArray(other) ? other ?? [] : [], + }; +} + +function wantsMajor(args: readonly string[]): boolean { + return args.some((arg) => arg === "--major"); +} + +function manifestVersion(text: string): string | undefined { + const match = /^\s*version\s*=\s*"([^"]+)"\s*$/m.exec(text); + return match?.[1]; +} + +async function installedVersion(deps: LifecycleDeps, root: string): Promise { + try { + const text = await (deps.readText ?? defaultReadText)(join(root, "herdr-plugin.toml")); + return manifestVersion(text) ?? ""; + } catch { + return ""; + } +} + +function versionParts(version: string): [number, number, number] | undefined { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim()); + if (!match) return undefined; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function majorOf(version: string): number | undefined { + const parts = versionParts(version); + return parts?.[0]; +} + +function compareVersions(left: string, right: string): number { + const a = versionParts(left); + const b = versionParts(right); + if (!a || !b) return 0; + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return a[index]! > b[index]! ? 1 : -1; + } + return 0; +} + +function parseReleaseTags(text: string): ReleaseTag[] { + const tags = new Map(); + for (const line of text.split(/\r?\n/)) { + const match = /^([0-9a-fA-F]+)\s+refs\/tags\/(v\d+\.\d+\.\d+)(\^\{\})?$/.exec( + line.trim(), + ); + if (!match) continue; + const version = match[2]!.slice(1); + const parts = versionParts(version); + if (!parts) continue; + const peeled = match[3] !== undefined; + const current: ReleaseTag = { + name: match[2]!, + version, + commit: match[1]!, + major: parts[0], + minor: parts[1], + patch: parts[2], + peeled, + }; + const previous = tags.get(current.name); + if (previous === undefined || peeled || !previous.peeled) tags.set(current.name, current); + } + return [...tags.values()].sort((left, right) => { + if (left.major !== right.major) return left.major - right.major; + if (left.minor !== right.minor) return left.minor - right.minor; + return left.patch - right.patch; + }); +} + +function highestTag(tags: readonly ReleaseTag[], major: number): ReleaseTag | undefined { + return [...tags].reverse().find((tag) => tag.major === major); +} + +function nextMajorTag(tags: readonly ReleaseTag[], major: number): ReleaseTag | undefined { + const next = tags.find((tag) => tag.major > major)?.major; + return next === undefined ? undefined : highestTag(tags, next); +} + +function announceMajor(ctx: Ctx, tag: ReleaseTag | undefined): void { + if (!tag) return; + ctx.log(`note: Collie ${tag.version} is out - a NEW MAJOR, which a routine update never takes.`); + ctx.log(` Read its release notes, then consent to it with: ${MAJOR_ACTION}`); +} + +async function currentHead( + ctx: Ctx, + deps: LifecycleDeps, + root: string, +): Promise { + return (await checkedGit(ctx, deps, root, ["rev-parse", "HEAD"])).stdout.trim(); +} + +async function isShallow( + ctx: Ctx, + deps: LifecycleDeps, + root: string, +): Promise { + return ( + (await checkedGit(ctx, deps, root, ["rev-parse", "--is-shallow-repository"])).stdout.trim() === + "true" + ); +} + +async function detachOnto( + ctx: Ctx, + deps: LifecycleDeps, + root: string, + ref: string, +): Promise { + const before = await currentHead(ctx, deps, root); + const fetchArgs = ["fetch"]; + if (await isShallow(ctx, deps, root)) fetchArgs.push("--depth", "1"); + fetchArgs.push("origin", ref); + await checkedGit(ctx, deps, root, fetchArgs); + await checkedGit(ctx, deps, root, ["checkout", "-q", "--detach", "--force", "FETCH_HEAD"]); + const after = await currentHead(ctx, deps, root); + ctx.log(`updated detached checkout to ${after.slice(0, 12)}`); + return before !== after; +} + +async function manifestAtRef( + ctx: Ctx, + deps: LifecycleDeps, + root: string, + ref: string, +): Promise { + const result = await runGit(ctx, deps, root, ["show", `${ref}:herdr-plugin.toml`]); + return result.exitCode === 0 ? manifestVersion(result.stdout) ?? "" : ""; +} + +async function updateLinked( + ctx: Ctx, + deps: LifecycleDeps, + root: string, + installed: string, + args: readonly string[], +): Promise { + const before = await currentHead(ctx, deps, root); + await checkedGit(ctx, deps, root, ["fetch", "origin"]); + const upstream = await runGit(ctx, deps, root, [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{u}", + ]); + const ref = upstream.exitCode === 0 ? upstream.stdout.trim() : ""; + if (ref && !wantsMajor(args)) { + const targetVersion = await manifestAtRef(ctx, deps, root, ref); + const installedMajor = majorOf(installed); + const targetMajor = majorOf(targetVersion); + if ( + installedMajor !== undefined && + targetMajor !== undefined && + targetMajor > installedMajor + ) { + ctx.log(`refusing to update: ${installed} -> ${targetVersion} (${ref}) crosses a MAJOR version.`); + ctx.log(` Consent explicitly with: ${MAJOR_ACTION} (or pass --major directly)`); + ctx.log(" (nothing was pulled - this checkout is unchanged)"); + return false; + } + } + ctx.log("updating linked checkout (git pull --ff-only)..."); + await checkedGit(ctx, deps, root, ["pull", "--ff-only"]); + const after = await currentHead(ctx, deps, root); + return before !== after; +} + +async function updateDetached( + ctx: Ctx, + deps: LifecycleDeps, + root: string, + installed: string, + args: readonly string[], +): Promise { + const installedMajor = majorOf(installed); + if (installedMajor === undefined) { + ctx.log("updating detached checkout (no readable version - following origin HEAD)..."); + return await detachOnto(ctx, deps, root, "HEAD"); + } + + const tagsResult = await runGit(ctx, deps, root, ["ls-remote", "--tags", "origin"]); + if (tagsResult.exitCode !== 0) { + throw new LifecycleCommandError( + ["git", "-C", root, "ls-remote", "--tags", "origin"], + tagsResult, + ); + } + const tags = parseReleaseTags(tagsResult.stdout); + // Older/local repositories may have no release tags at all. Preserve ADR 0006's original + // fetch-and-detach behavior in that case; once strict release tags exist, ADR 0020 governs. + if (tags.length === 0) { + ctx.log("updating detached checkout (no release tags - following origin HEAD)..."); + return await detachOnto(ctx, deps, root, "HEAD"); + } + + const nextMajor = nextMajorTag(tags, installedMajor); + const target = wantsMajor(args) + ? nextMajor + : highestTag(tags, installedMajor); + if (!target) { + if (!wantsMajor(args)) announceMajor(ctx, nextMajor); + return false; + } + + const before = await currentHead(ctx, deps, root); + if (target.commit === before || compareVersions(target.version, installed) <= 0) { + ctx.log(`already current - v${target.version} is the newest release of major ${target.major}.`); + if (!wantsMajor(args)) announceMajor(ctx, nextMajor); + return false; + } + + ctx.log(`updating detached checkout (fetch + detach onto ${target.name})...`); + return await detachOnto(ctx, deps, root, `refs/tags/${target.name}`); +} + +/** Advance a checkout according to ADR 0006 and the ADR 0020 major gate. */ +export async function updateCheckout( + ctx: Ctx, + deps: LifecycleDeps, + args: readonly string[] = [], +): Promise { + const root = rootDir(ctx, deps); + const gitDir = await runGit(ctx, deps, root, ["rev-parse", "--git-dir"]); + if (gitDir.exitCode !== 0) { + throw new Error(`${root} is not a git checkout - reinstall with: herdr plugin install AltanS/collie --yes`); + } + const installed = await installedVersion(deps, root); + const symbolicRef = await runGit(ctx, deps, root, ["symbolic-ref", "-q", "HEAD"]); + if (symbolicRef.exitCode === 0) { + return await updateLinked(ctx, deps, root, installed, args); + } + return await updateDetached(ctx, deps, root, installed, args); +} + +/** Update the checkout, rebuild it, and restart the already-registered service. */ +export function update( + ctx: Ctx, + deps: LifecycleDeps, + args?: readonly string[], +): Promise; +export function update( + ctx: Ctx, + args: readonly string[], + deps: LifecycleDeps, +): Promise; +export async function update( + ctx: Ctx, + input: LifecycleDeps | readonly string[], + other?: LifecycleDeps | readonly string[], +): Promise { + const invocation = parseUpdateInvocation(input, other); + const { deps, args } = invocation; + if (!(await updateCheckout(ctx, deps, args))) return; + const operation = operationContext(ctx, deps); + const rebuild = deps.ops?.rebuild ?? deps.ops?.build; + if (!rebuild) throw new Error("ctl update requires an injected build operation"); + await rebuild(operation); + await restartService(ctx, deps); + ctx.log("update complete"); +} + +export const ensure_build = ensureBuild; +export const cmdStart = start; +export const cmdStop = stop; +export const cmdRestart = restart; +export const cmdUninstall = uninstall; +export const cmdUpdate = update; +export const runStart = start; +export const runStop = stop; +export const runRestart = restart; +export const runUninstall = uninstall; +export const runUpdate = update; + +export { parseReleaseTags }; From 1923d9b9dde4aff96c2434b669b7c6ab9fa749b1 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:47 +0900 Subject: [PATCH 08/23] feat(scripts): info verbs for ctl --- scripts/ctl/verbs-info.test.ts | 183 ++++++++++++++++++++++++++ scripts/ctl/verbs-info.ts | 234 +++++++++++++++++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 scripts/ctl/verbs-info.test.ts create mode 100644 scripts/ctl/verbs-info.ts diff --git a/scripts/ctl/verbs-info.test.ts b/scripts/ctl/verbs-info.test.ts new file mode 100644 index 00000000..5e7592e2 --- /dev/null +++ b/scripts/ctl/verbs-info.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +import { + logs, + qr, + status, + url, + version, + type CtlCtx, + type InfoDeps, +} from "./verbs-info.ts"; + +const CTX: CtlCtx = { + configDir: "/cfg", + stateDir: "/state", + socketPath: "/socket", +}; + +const serveFile = join(CTX.configDir, "tailscale-managed-handler"); +const logFile = join(CTX.stateDir, "collie.log"); + +function makeDeps(files: Record = {}, extra: Partial = {}): InfoDeps { + const entries = new Map(Object.entries(files)); + return { + exists: async (path: string) => entries.has(path), + readText: async (path: string) => { + const text = entries.get(path); + if (text === undefined) throw new Error(`ENOENT: ${path}`); + return text; + }, + ...extra, + }; +} + +// The info verbs are mostly pure routing: the hard part is which state to read, not how to print it. +// These tests pin the routing against injected stubs so nothing needs the real filesystem, service +// manager or qr renderer. + +describe("version", () => { + test("returns the version string parsed from package.json via the injected reader", async () => { + const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url)); + const packageJson = await Bun.file(packageJsonPath).text(); + const expected = (JSON.parse(packageJson) as { version: string }).version; + expect(await version({ readPackageJson: async () => packageJson })).toBe(expected); + }); +}); + +describe("status", () => { + test("renders running when the backend is active and the socket exists", async () => { + let isActiveCalls = 0; + const out = await status( + CTX, + makeDeps( + { + [CTX.socketPath]: "socket-present", + [serveFile]: "http:8787|host.example:8787|http://127.0.0.1:8787\n", + }, + { + backend: { + kind: "windows", + isActive: async () => { + isActiveCalls += 1; + return true; + }, + }, + }, + ), + ); + expect(isActiveCalls).toBe(1); + expect(out).toContain("running"); + expect(out).toContain("backend: active (windows)"); + expect(out).toContain("socket: present"); + expect(out).toContain("serve: http://host.example:8787 -> http://127.0.0.1:8787"); + }); + + test("renders stopped when the backend is present but the socket is missing", async () => { + const out = await status( + CTX, + makeDeps( + {}, + { + backend: { + kind: "systemd", + isActive: async () => false, + }, + }, + ), + ); + expect(out).toContain("stopped"); + expect(out).toContain("backend: inactive (systemd)"); + expect(out).toContain("socket: missing"); + expect(out).toContain("serve: none"); + }); + + test("renders no-backend when no backend is injected", async () => { + const out = await status(CTX, makeDeps()); + expect(out).toContain("no-backend"); + expect(out).toContain("backend: unavailable"); + expect(out).toContain("socket: missing"); + expect(out).toContain("serve: none"); + }); +}); + +describe("url", () => { + test("reconstructs an http URL from the serve mapping file", async () => { + const out = await url( + CTX, + makeDeps({ [serveFile]: "http:8787|host.example:8787|http://127.0.0.1:8787\n" }), + ); + expect(out).toBe("http://host.example:8787"); + }); + + test("reconstructs an https URL and drops the default :443 port", async () => { + const out = await url( + CTX, + makeDeps({ [serveFile]: "https:443|host.example:443|http://127.0.0.1:8787\n" }), + ); + expect(out).toBe("https://host.example"); + }); +}); + +describe("logs", () => { + test("tails stateDir/collie.log on the windows backend", async () => { + const tailCalls: string[] = []; + const out = await logs( + CTX, + { + backend: { + kind: "windows", + isActive: async () => true, + logsCmd: async () => { + throw new Error("should not delegate windows logs to logsCmd"); + }, + }, + tailFile: async (path: string, lines: number) => { + tailCalls.push(`${path}:${lines}`); + return "tail-output"; + }, + }, + 12, + ); + expect(out).toBe("tail-output"); + expect(tailCalls).toEqual([`${logFile}:12`]); + }); + + test("delegates to the backend logs command on posix backends", async () => { + const out = await logs( + CTX, + { + backend: { + kind: "launchd", + isActive: async () => true, + logsCmd: async (lines: number) => `journalctl:${lines}`, + }, + tailFile: async () => { + throw new Error("should not tail stateDir/collie.log on posix backends"); + }, + }, + 7, + ); + expect(out).toBe("journalctl:7"); + }); +}); + +describe("qr", () => { + test("prefers a provided public URL and hands it to the renderer", async () => { + let seen = ""; + const out = await qr(CTX, { + publicUrl: "https://collie.example.com", + readText: async () => { + throw new Error("should not inspect the serve mapping when publicUrl is provided"); + }, + renderQr: async (urlValue: string) => { + seen = urlValue; + return `QR:${urlValue}`; + }, + }); + expect(seen).toBe("https://collie.example.com"); + expect(out).toBe("QR:https://collie.example.com"); + }); +}); diff --git a/scripts/ctl/verbs-info.ts b/scripts/ctl/verbs-info.ts new file mode 100644 index 00000000..7574131a --- /dev/null +++ b/scripts/ctl/verbs-info.ts @@ -0,0 +1,234 @@ +import { readFile, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { renderQr as defaultRenderQr } from "../qr.ts"; + +export interface CtlCtx { + configDir: string; + stateDir: string; + socketPath: string; + log?: (...args: unknown[]) => void; + shell?: unknown; +} + +export type BackendKind = "windows" | "systemd" | "launchd"; + +export interface ServiceBackend { + kind?: BackendKind; + isActive: () => boolean | Promise; + logsCmd?: (lines: number) => string | Promise; +} + +export interface InfoDeps { + backend?: ServiceBackend | null; + readText?: (path: string) => Promise; + exists?: (path: string) => Promise; + tailFile?: (path: string, lines: number) => Promise; + renderQr?: (url: string) => Promise; + readPackageJson?: () => Promise; + publicUrl?: string; +} + +export interface ServeRecord { + mode: "http" | "https"; + port: number; + hostPort: string; + proxy: string; +} + +type ServeState = + | { kind: "skipped"; publicUrl: string } + | { kind: "missing" } + | { kind: "invalid" } + | { kind: "mapped"; record: ServeRecord }; + +interface ResolvedDeps { + backend: ServiceBackend | null; + readText: (path: string) => Promise; + exists: (path: string) => Promise; + tailFile: (path: string, lines: number) => Promise; + renderQr: (url: string) => Promise; + readPackageJson: () => Promise; + publicUrl?: string; +} + +const MODULE_DIR = dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = join(MODULE_DIR, "../.."); +const DEFAULT_PACKAGE_JSON = join(ROOT_DIR, "package.json"); +const SERVE_HANDLER_FILE = "tailscale-managed-handler"; +const COLLIE_LOG_FILE = "collie.log"; + +async function defaultReadText(path: string): Promise { + return await readFile(path, "utf8"); +} + +async function defaultExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "ENOENT" + ); +} + +function tailText(text: string, lines: number): string { + if (lines <= 0) return ""; + const parts = text.split(/\r?\n/); + if (parts.length > 0 && parts[parts.length - 1] === "") parts.pop(); + return parts.slice(-lines).join("\n"); +} + +async function defaultTailFile( + path: string, + lines: number, + readText: (path: string) => Promise, +): Promise { + try { + return tailText(await readText(path), lines); + } catch (error) { + if (isMissingFileError(error)) return "(no log)"; + throw error; + } +} + +function resolveDeps(deps: InfoDeps = {}): ResolvedDeps { + const readText = deps.readText ?? defaultReadText; + const exists = deps.exists ?? defaultExists; + return { + backend: deps.backend ?? null, + readText, + exists, + tailFile: deps.tailFile ?? ((path, lines) => defaultTailFile(path, lines, readText)), + renderQr: deps.renderQr ?? defaultRenderQr, + readPackageJson: deps.readPackageJson ?? (() => readText(DEFAULT_PACKAGE_JSON)), + publicUrl: deps.publicUrl?.trim() || undefined, + }; +} + +function parseServeRecord(text: string): ServeRecord | null { + const line = text + .split(/\r?\n/) + .map((s) => s.trim()) + .find((s) => s.length > 0); + if (line === undefined) return null; + const parts = line.split("|").map((s) => s.trim()); + if (parts.length !== 3) return null; + const modePort = parts[0]!; + const hostPort = parts[1]!; + const proxy = parts[2]!; + const match = /^(http|https):(\d+)$/.exec(modePort); + if (!match || hostPort.length === 0 || proxy.length === 0) return null; + return { + mode: match[1]! as "http" | "https", + port: Number(match[2]!), + hostPort, + proxy, + }; +} + +export function serveUrl(record: ServeRecord): string { + if (record.mode === "http") return `http://${record.hostPort}`; + const host = record.hostPort.endsWith(":443") ? record.hostPort.slice(0, -4) : record.hostPort; + return `https://${host}`; +} + +async function readServeState(ctx: CtlCtx, deps: ResolvedDeps, includePublicUrl: boolean): Promise { + if (includePublicUrl && deps.publicUrl !== undefined) return { kind: "skipped", publicUrl: deps.publicUrl }; + const path = join(ctx.configDir, SERVE_HANDLER_FILE); + if (!(await deps.exists(path))) return { kind: "missing" }; + try { + const record = parseServeRecord(await deps.readText(path)); + return record ? { kind: "mapped", record } : { kind: "invalid" }; + } catch { + return { kind: "invalid" }; + } +} + +function formatServeSummary(state: ServeState): string { + switch (state.kind) { + case "skipped": + return `skipped -> ${state.publicUrl}`; + case "mapped": + return `${serveUrl(state.record)} -> ${state.record.proxy}`; + case "invalid": + return "invalid"; + case "missing": + return "none"; + } +} + +function backendLabel(backend: ServiceBackend | null, active: boolean | null): string { + if (backend === null) return "unavailable"; + const state = active ? "active" : "inactive"; + return backend.kind === undefined ? state : `${state} (${backend.kind})`; +} + +export async function status(ctx: CtlCtx, deps: InfoDeps = {}): Promise { + const resolved = resolveDeps(deps); + const socketPresentPromise = resolved.exists(ctx.socketPath); + const backendActivePromise = + resolved.backend === null ? Promise.resolve(null) : Promise.resolve(resolved.backend.isActive()); + const servePromise = readServeState(ctx, resolved, true); + const [socketPresent, backendActiveRaw, serve] = await Promise.all([ + socketPresentPromise, + backendActivePromise, + servePromise, + ]); + const backendActive = backendActiveRaw === null ? null : Boolean(backendActiveRaw); + const state = resolved.backend === null ? "no-backend" : socketPresent ? "running" : "stopped"; + return [ + state, + ` backend: ${backendLabel(resolved.backend, backendActive)}`, + ` socket: ${socketPresent ? "present" : "missing"}`, + ` serve: ${formatServeSummary(serve)}`, + ].join("\n"); +} + +export async function url(ctx: CtlCtx, deps: InfoDeps = {}): Promise { + const resolved = resolveDeps(deps); + const serve = await readServeState(ctx, resolved, false); + if (serve.kind === "mapped") return serveUrl(serve.record); + if (serve.kind === "invalid") throw new Error("invalid Collie-managed tailscale serve mapping"); + throw new Error("no Collie-managed tailscale serve mapping found"); +} + +export async function logs(ctx: CtlCtx, deps: InfoDeps = {}, lines = 50): Promise { + const resolved = resolveDeps(deps); + const count = Number.isFinite(lines) ? Math.max(0, Math.floor(lines)) : 50; + if (resolved.backend?.kind === "windows") { + return resolved.tailFile(join(ctx.stateDir, COLLIE_LOG_FILE), count); + } + if (resolved.backend?.logsCmd !== undefined) { + return await resolved.backend.logsCmd(count); + } + return resolved.tailFile(join(ctx.stateDir, COLLIE_LOG_FILE), count); +} + +export async function qr(ctx: CtlCtx, deps: InfoDeps = {}): Promise { + const resolved = resolveDeps(deps); + const target = resolved.publicUrl ?? (await url(ctx, deps)); + return await resolved.renderQr(target); +} + +export async function version(deps: InfoDeps = {}): Promise { + const resolved = resolveDeps(deps); + const parsed: unknown = JSON.parse(await resolved.readPackageJson()); + if (typeof parsed !== "object" || parsed === null) { + throw new Error("package.json does not contain a version string"); + } + const versionValue = (parsed as { version?: unknown }).version; + if (typeof versionValue !== "string" || versionValue.trim().length === 0) { + throw new Error("package.json does not contain a version string"); + } + return versionValue.trim(); +} From 8895d6646a276c2765976d33ac0a966bbdab8ea5 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:47 +0900 Subject: [PATCH 09/23] feat(scripts): build, serve and push verbs for ctl --- scripts/ctl/verbs-ops.test.ts | 238 ++++++++++++ scripts/ctl/verbs-ops.ts | 662 ++++++++++++++++++++++++++++++++++ 2 files changed, 900 insertions(+) create mode 100644 scripts/ctl/verbs-ops.test.ts create mode 100644 scripts/ctl/verbs-ops.ts diff --git a/scripts/ctl/verbs-ops.test.ts b/scripts/ctl/verbs-ops.test.ts new file mode 100644 index 00000000..c156583a --- /dev/null +++ b/scripts/ctl/verbs-ops.test.ts @@ -0,0 +1,238 @@ +import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +import { + atomicSwapDist, + build, + execBridge, + parseManagedMapping, + pushTest, + readManagedMapping, + serializeManagedMapping, + serve, + tailscaleRootAvailability, + tailscaleRootFingerprint, + unserve, + writeManagedMapping, + type CommandExecutor, + type ManagedServeMapping, + type OpsContext, +} from "./verbs-ops.ts"; + +async function fixture(): Promise<{ root: string; ctx: OpsContext }> { + const root = await mkdtemp(join(tmpdir(), "collie-ctl-ops-")); + const configDir = join(root, "config"); + const stateDir = join(root, "state"); + await mkdir(join(root, "web"), { recursive: true }); + await mkdir(configDir, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + return { root, ctx: { rootDir: root, configDir, stateDir, socketPath: join(root, "herdr.sock") } }; +} + +async function clean(root: string): Promise { + await rm(root, { recursive: true, force: true }); +} + +function result(exitCode = 0, stdout = "", stderr = "") { + return { exitCode, stdout, stderr }; +} + +const mapping: ManagedServeMapping = { + mode: "http", + port: 8787, + handler: "http:8787", + hostPort: "host.example:8787", + proxy: "http://127.0.0.1:8787", +}; + +const serveStatus = { + TCP: { "8787": { HTTP: true } }, + Web: { + "host.example:8787": { + Handlers: { "/": { Proxy: "http://127.0.0.1:8787" } }, + }, + }, +}; + +describe("ctl operational verbs", () => { + test("swaps a successful staged build and leaves no staging directory", async () => { + const { root, ctx } = await fixture(); + try { + const dist = join(root, "web", "dist"); + await mkdir(dist, { recursive: true }); + await writeFile(join(dist, "index.html"), "old", "utf8"); + const calls: string[] = []; + const executor: CommandExecutor = async (argv, options) => { + calls.push(`${argv.join(" ")} @ ${options?.cwd ?? ""}`); + if (argv.includes("build")) { + const staging = join(root, "web", "dist-staging"); + await mkdir(staging, { recursive: true }); + await writeFile(join(staging, "index.html"), "new", "utf8"); + } + return result(); + }; + + await build(ctx, { executor }); + + expect(await readFile(join(dist, "index.html"), "utf8")).toBe("new"); + await expect(readFile(join(root, "web", "dist-staging"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(calls[0]).toContain("bun scripts/check-version.ts"); + expect(calls.some((call) => call.includes("bun run typecheck"))).toBe(true); + expect(calls.some((call) => call.includes("bun install") && call.includes(join(root, "web")))).toBe(true); + } finally { + await clean(root); + } + }); + + test("a build failure propagates its non-zero status and preserves the live dist", async () => { + const { root, ctx } = await fixture(); + try { + const dist = join(root, "web", "dist"); + await mkdir(dist, { recursive: true }); + await writeFile(join(dist, "index.html"), "live", "utf8"); + const executor: CommandExecutor = async (argv) => + argv.includes("build") ? result(23, "", "synthetic web failure") : result(); + + await expect(build(ctx, { executor })).rejects.toMatchObject({ exitCode: 23 }); + expect(await readFile(join(dist, "index.html"), "utf8")).toBe("live"); + } finally { + await clean(root); + } + }); + + test("rolls a failed directory swap back to the original dist", async () => { + const { root } = await fixture(); + try { + const dist = join(root, "web", "dist"); + const staging = join(root, "web", "dist-staging"); + await mkdir(dist, { recursive: true }); + await mkdir(staging, { recursive: true }); + await writeFile(join(dist, "index.html"), "live", "utf8"); + await writeFile(join(staging, "index.html"), "candidate", "utf8"); + await expect( + atomicSwapDist(staging, dist, { + rename: async (from, to) => { + if (from === staging) throw new Error("synthetic rename failure"); + await rename(from, to); + }, + }), + ).rejects.toThrow("synthetic rename failure"); + expect(await readFile(join(dist, "index.html"), "utf8")).toBe("live"); + } finally { + await clean(root); + } + }); + + test("records and verifies only the exact owned Tailscale mapping", async () => { + const { root, ctx } = await fixture(); + try { + const file = join(ctx.configDir, "tailscale-managed-handler"); + const encoded = serializeManagedMapping(mapping); + expect(parseManagedMapping(encoded)).toEqual(mapping); + await writeManagedMapping(file, mapping); + expect(await readManagedMapping(file)).toEqual(mapping); + expect(tailscaleRootFingerprint(serveStatus, mapping.hostPort, mapping.port)).toBe( + "http|proxy:http://127.0.0.1:8787", + ); + expect(tailscaleRootAvailability(serveStatus, 8787, "http", mapping.proxy)).toBe("adoptable"); + expect( + tailscaleRootAvailability( + { TCP: { "8787": { HTTP: true } }, Web: { "host.example:8787": { Handlers: { "/": { Proxy: "http://127.0.0.1:9999" } } } } }, + 8787, + "http", + mapping.proxy, + ), + ).toBe("occupied"); + } finally { + await clean(root); + } + }); + + test("serve writes ownership and unserve removes only that mapping", async () => { + const { root, ctx } = await fixture(); + try { + let current: unknown = { TCP: {}, Web: {} }; + const calls: string[][] = []; + const executor: CommandExecutor = async (argv) => { + calls.push(argv); + if (argv[0] !== "tailscale") return result(); + if (argv[1] === "serve" && argv[2] === "status") return result(0, JSON.stringify(current)); + if (argv[1] === "status") return result(0, JSON.stringify({ Self: { DNSName: "host.example." } })); + if (argv[1] === "serve" && argv[2] === "--bg") { + current = serveStatus; + return result(); + } + if (argv[1] === "serve" && argv.at(-1) === "off") { + current = { TCP: {}, Web: {} }; + return result(); + } + return result(2, "", "unexpected tailscale command"); + }; + + await serve(ctx, { executor, mode: "http", port: 8787 }); + expect(await readManagedMapping(join(ctx.configDir, "tailscale-managed-handler"))).toEqual(mapping); + await unserve(ctx, { executor }); + expect(await readManagedMapping(join(ctx.configDir, "tailscale-managed-handler"))).toBeNull(); + expect(calls.some((argv) => argv.includes("reset"))).toBe(false); + expect(calls).toContainEqual(["tailscale", "serve", "--http=8787", "--set-path=/", "off"]); + } finally { + await clean(root); + } + }); + + test("wrapper failures are not swallowed", async () => { + const { root, ctx } = await fixture(); + try { + const executor: CommandExecutor = async (argv) => + argv.some((part) => part.endsWith("push-test.ts")) ? result(31, "", "synthetic push failure") : result(); + await expect(pushTest(ctx, ["title"], { executor })).rejects.toMatchObject({ exitCode: 31 }); + } finally { + await clean(root); + } + }); + + test("adapts the shared ctl shell contract without invoking a command interpreter", async () => { + const { root, ctx: base } = await fixture(); + try { + let invocation: { command: string; args: readonly string[]; cwd?: string } | undefined; + const ctx: OpsContext = { + ...base, + shell: async ( + command: string, + args: readonly string[] = [], + options: { cwd?: string } = {}, + ) => { + invocation = { command, args, cwd: options.cwd }; + return result(); + }, + }; + await pushTest(ctx, ["title"]); + expect(invocation).toEqual({ command: "bun", args: ["scripts/push-test.ts", "title"], cwd: root }); + } finally { + await clean(root); + } + }); + + test("exec-bridge passes the bridge environment and redirects both streams", async () => { + const { root, ctx } = await fixture(); + try { + let received: { argv: string[]; options: { cwd: string; env: Record; stdout: unknown; stderr: unknown } } | undefined; + await execBridge(ctx, { + spawner: async (argv, options) => { + received = { argv, options }; + return 0; + }, + }); + expect(received?.argv).toEqual(["bun", "bridge/index.ts"]); + expect(received?.options.cwd).toBe(root); + expect(received?.options.stdout).toBe(join(ctx.stateDir, "collie.log")); + expect(received?.options.stderr).toBe(join(ctx.stateDir, "collie.log")); + expect(received?.options.env.HERDR_PLUGIN_CONFIG_DIR).toBe(ctx.configDir); + expect(received?.options.env.HERDR_PLUGIN_STATE_DIR).toBe(ctx.stateDir); + } finally { + await clean(root); + } + }); +}); diff --git a/scripts/ctl/verbs-ops.ts b/scripts/ctl/verbs-ops.ts new file mode 100644 index 00000000..157b3b72 --- /dev/null +++ b/scripts/ctl/verbs-ops.ts @@ -0,0 +1,662 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +/** + * The part of the ctl context used by operational verbs. + * + * `shell` is intentionally structural here. Todo 5 owns the public ctl contracts, while this file + * also needs to be useful in isolation for fixture tests. At runtime it accepts the shared + * `shell(command, args, options)` function or an object exposing the same `run` shape. + */ +export interface OpsContext { + configDir: string; + stateDir: string; + socketPath?: string; + rootDir?: string; + env?: Record; + log?: (...args: unknown[]) => void; + shell?: unknown; +} + +/** A completed child-process invocation. Non-zero statuses are never silently discarded. */ +export interface CommandResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** The injectable process boundary used by tests and by the ctl shell contract. */ +export type CommandExecutor = ( + argv: string[], + options?: { cwd?: string; env?: Record }, +) => Promise; + +/** A process returned by an injected bridge spawner. */ +export interface SpawnedProcess { + exited: Promise; +} + +/** The subset of Bun.spawn options needed by exec-bridge. */ +export interface BridgeSpawnOptions { + cwd: string; + env: Record; + stdout: unknown; + stderr: unknown; +} + +/** Injectable process creation for exec-bridge tests. */ +export type ProcessSpawner = ( + argv: string[], + options: BridgeSpawnOptions, +) => SpawnedProcess | Promise | number | Promise; + +/** A parsed line from tailscale-managed-handler. */ +export interface ManagedServeMapping { + mode: "http" | "https"; + port: number; + handler: string; + hostPort: string; + proxy: string; +} + +/** The result used when deciding whether a Tailscale root can be replaced. */ +export type RootFingerprint = "absent" | "http|other" | "https|other" | "other|other" | `${"http" | "https"}|proxy:${string}`; + +/** Common options for the operational verbs. */ +export interface OpsOptions { + executor?: CommandExecutor; + rootDir?: string; + env?: Record; + bun?: string; + mappingFile?: string; +} + +/** Build-specific paths and failure-injection hooks. */ +export interface BuildOptions extends OpsOptions { + webDir?: string; + distDir?: string; + stagingDir?: string; + skipVersionCheck?: boolean; + skipTypecheck?: boolean; +} + +/** Injectable filesystem boundary for atomic directory-swap tests. */ +export interface DistSwapFileOps { + exists?: (path: string) => Promise; + rename?: (from: string, to: string) => Promise; + remove?: (path: string) => Promise; +} + +/** Serve-specific options. */ +export interface ServeOptions extends OpsOptions { + port?: number; + mode?: "http" | "https"; +} + +/** Bridge launch options. */ +export interface ExecBridgeOptions extends OpsOptions { + spawner?: ProcessSpawner; + logFile?: string; +} + +/** An operational child failure with its original argv and captured output. */ +export class CommandError extends Error { + readonly argv: string[]; + readonly result: CommandResult; + + constructor(argv: string[], result: CommandResult) { + const detail = result.stderr.trim() || result.stdout.trim(); + super(`command failed (${result.exitCode}): ${argv.join(" ")}${detail ? `\n${detail}` : ""}`); + this.name = "CommandError"; + this.argv = argv; + this.result = result; + } + + get exitCode(): number { + return this.result.exitCode; + } +} + +const MANAGED_HANDLER_FILE = "tailscale-managed-handler"; +const DEFAULT_PORT = 8787; + +function rootDir(ctx: OpsContext, options: OpsOptions = {}): string { + return options.rootDir ?? ctx.rootDir ?? resolve(import.meta.dir, "../.."); +} + +function bunCommand(options: OpsOptions): string { + return options.bun ?? process.env.BUN_BINARY ?? "bun"; +} + +function emitLog(ctx: OpsContext, ...args: unknown[]): void { + ctx.log?.(...args); +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" ? (value as Record) : undefined; +} + +function textValue(value: unknown): string { + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return new TextDecoder().decode(value); + return value === undefined || value === null ? "" : String(value); +} + +function normalizeCommandResult(value: unknown): CommandResult { + if (typeof value === "number") return { exitCode: value, stdout: "", stderr: "" }; + if (value === undefined || value === null) return { exitCode: 0, stdout: "", stderr: "" }; + const record = asRecord(value); + if (!record) throw new Error("ctl shell returned an unsupported result"); + const status = record.exitCode ?? record.code ?? record.status ?? 0; + if (typeof status !== "number") throw new Error("ctl shell returned a non-numeric exit code"); + return { + exitCode: status, + stdout: textValue(record.stdout), + stderr: textValue(record.stderr), + }; +} + +function shellExecutor(shell: unknown): CommandExecutor | undefined { + const invoke = (candidate: unknown, receiver?: unknown): CommandExecutor | undefined => { + if (typeof candidate !== "function") return undefined; + return async (argv, options) => { + const command = argv[0]; + if (command === undefined) throw new Error("ctl cannot execute an empty command"); + const args = argv.slice(1); + const shellOptions = options?.cwd === undefined ? {} : { cwd: options.cwd }; + const fn = candidate as ( + command: string, + args?: readonly string[], + options?: { cwd?: string }, + ) => unknown; + return normalizeCommandResult(await fn.call(receiver, command, args, shellOptions)); + }; + }; + return invoke(shell) ?? invoke(asRecord(shell)?.run, shell); +} + +async function spawnAndCapture( + argv: string[], + options: { cwd?: string; env?: Record } = {}, +): Promise { + const child = Bun.spawn(argv, { + cwd: options.cwd, + env: options.env, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = child.stdout ? new Response(child.stdout).text() : Promise.resolve(""); + const stderr = child.stderr ? new Response(child.stderr).text() : Promise.resolve(""); + const [exitCode, out, err] = await Promise.all([child.exited, stdout, stderr]); + return { exitCode, stdout: out, stderr: err }; +} + +async function execute( + ctx: OpsContext, + argv: string[], + options: OpsOptions = {}, + cwd: string = rootDir(ctx, options), + env?: Record, +): Promise { + const executor = options.executor ?? shellExecutor(ctx.shell); + if (executor) return executor(argv, { cwd, env }); + return spawnAndCapture(argv, { cwd, env }); +} + +async function checked( + ctx: OpsContext, + argv: string[], + options: OpsOptions, + cwd: string, + env?: Record, +): Promise { + const result = await execute(ctx, argv, options, cwd, env); + if (result.exitCode !== 0) throw new CommandError(argv, result); + return result; +} + +function envWithoutUndefined(env: Record): Record { + return Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined)); +} + +function parseDotEnvLine(line: string): [string, string] | undefined { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return undefined; + const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(trimmed); + if (!match) return undefined; + let value = match[2] ?? ""; + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + return [match[1]!, value]; +} + +async function childEnv(ctx: OpsContext, options: OpsOptions = {}, overrides: Record = {}): Promise> { + const merged: Record = { ...process.env, ...(ctx.env ?? {}), ...(options.env ?? {}) }; + try { + const dotenv = await readFile(join(ctx.configDir, ".env"), "utf8"); + for (const line of dotenv.split(/\r?\n/)) { + const parsed = parseDotEnvLine(line); + if (parsed) merged[parsed[0]] = parsed[1]; + } + } catch { + // The .env is optional on first install. The wrapped script reports missing configuration itself. + } + Object.assign(merged, overrides); + return envWithoutUndefined(merged); +} + +function mappingPath(ctx: OpsContext, options: OpsOptions = {}): string { + return options.mappingFile ?? join(ctx.configDir, MANAGED_HANDLER_FILE); +} + +function parsePort(value: string | undefined, fallback = DEFAULT_PORT): number { + if (value === undefined || !/^\d+$/.test(value)) return fallback; + const port = Number(value); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : fallback; +} + +/** Serialize the ownership record used by both serve and unserve. */ +export function serializeManagedMapping(mapping: ManagedServeMapping): string { + return `${mapping.handler}|${mapping.hostPort}|${mapping.proxy}\n`; +} + +/** Parse and validate an ownership record before it can authorize teardown. */ +export function parseManagedMapping(text: string): ManagedServeMapping { + const line = text.trim(); + const fields = line.split("|"); + if (fields.length !== 3) throw new Error(`invalid managed Tailscale handler state: ${line}`); + const [handler, hostPort, proxy] = fields; + let mode: "http" | "https"; + let port: number; + if (handler?.startsWith("http:")) { + mode = "http"; + port = parsePort(handler.slice("http:".length), 0); + if (!port) throw new Error(`invalid managed Tailscale handler state: ${line}`); + } else if (handler === "https:443") { + mode = "https"; + port = 443; + } else { + throw new Error(`invalid managed Tailscale handler state: ${line}`); + } + if (!hostPort || !hostPort.endsWith(`:${port}`)) { + throw new Error(`managed Tailscale HostPort does not match its listener: ${line}`); + } + if (!proxy || !/^http:\/\/127\.0\.0\.1:\d+$/.test(proxy)) { + throw new Error(`invalid managed Tailscale proxy target: ${line}`); + } + const proxyPort = parsePort(proxy.slice("http://127.0.0.1:".length), 0); + if (!proxyPort) throw new Error(`invalid managed Tailscale proxy target: ${line}`); + return { mode, port, handler: handler!, hostPort: hostPort!, proxy: proxy! }; +} + +/** Read an ownership record, returning null when Collie has never published a mapping. */ +export async function readManagedMapping(file: string): Promise { + try { + return parseManagedMapping(await readFile(file, "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +/** Write an ownership record without exposing a partially-written line to teardown. */ +export async function writeManagedMapping(file: string, mapping: ManagedServeMapping): Promise { + await mkdir(resolve(file, ".."), { recursive: true }); + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, serializeManagedMapping(mapping), "utf8"); + try { + await rename(temporary, file); + } catch (error) { + // Windows cannot rename over an existing file. Removing only our record is safe here: a new + // mapping is not published until this write succeeds, and the caller owns this path by contract. + if ((error as NodeJS.ErrnoException).code !== "EEXIST" && (error as NodeJS.ErrnoException).code !== "EPERM") { + throw error; + } + await rm(file, { force: true }); + await rename(temporary, file); + } + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +/** Remove a record after teardown has proved that the recorded mapping is still Collie's. */ +export async function removeManagedMapping(file: string): Promise { + await rm(file, { force: true }); +} + +function listenerProtocol(value: unknown): "http" | "https" | "other" { + const listener = asRecord(value); + if (listener?.HTTP === true) return "http"; + if (listener?.HTTPS === true) return "https"; + return "other"; +} + +function rootTargets(config: Record, port: number): unknown[] { + const web = asRecord(config.Web); + if (!web) return []; + return Object.entries(web) + .filter(([hostPort]) => hostPort.endsWith(`:${port}`)) + .map(([, server]) => asRecord(asRecord(server)?.Handlers)) + .filter( + (handlers): handlers is Record => + handlers !== undefined && Object.prototype.hasOwnProperty.call(handlers, "/"), + ) + .map((handlers) => asRecord(handlers["/"])?.Proxy); +} + +function foregroundHasRoot(config: Record, port: number): boolean { + const foreground = asRecord(config.Foreground); + if (!foreground) return false; + return Object.values(foreground).some((value) => { + const child = asRecord(value); + return Boolean(child && (rootTargets(child, port).length > 0 || foregroundHasRoot(child, port))); + }); +} + +/** Compute the exact root mapping that unserve is allowed to remove. */ +export function tailscaleRootFingerprint( + status: unknown, + hostPort: string, + port: number, +): RootFingerprint { + const config = asRecord(status); + if (!config) throw new Error("invalid Tailscale serve status"); + const web = asRecord(config.Web); + const server = web ? asRecord(web[hostPort]) : undefined; + const handlers = server ? asRecord(server.Handlers) : undefined; + if (!handlers || !("/" in handlers)) return "absent"; + const protocol = listenerProtocol(asRecord(config.TCP)?.[String(port)]); + const proxy = asRecord(handlers["/"])?.Proxy; + return typeof proxy === "string" && proxy ? `${protocol}|proxy:${proxy}` as RootFingerprint : `${protocol}|other`; +} + +/** Decide whether a root is free, adoptable, or occupied by somebody else. */ +export function tailscaleRootAvailability( + status: unknown, + port: number, + mode: "http" | "https", + expectedProxy: string, +): "free" | "adoptable" | "occupied" | "protocol-mismatch" { + const config = asRecord(status); + if (!config) throw new Error("invalid Tailscale serve status"); + const desired = mode === "http" ? "http" : "https"; + const hasMismatch = (candidate: Record): boolean => { + const candidateListener = asRecord(candidate.TCP)?.[String(port)]; + return candidateListener !== undefined && listenerProtocol(candidateListener) !== desired; + }; + const nestedMismatch = (candidate: Record): boolean => { + if (hasMismatch(candidate)) return true; + const foreground = asRecord(candidate.Foreground); + return Boolean(foreground && Object.values(foreground).some((value) => { + const child = asRecord(value); + return Boolean(child && nestedMismatch(child)); + })); + }; + if (nestedMismatch(config)) return "protocol-mismatch"; + if (foregroundHasRoot(config, port)) return "occupied"; + const targets = rootTargets(config, port); + if (targets.length === 0) return "free"; + return targets.every((target) => target === expectedProxy) ? "adoptable" : "occupied"; +} + +async function statusJson(ctx: OpsContext, options: OpsOptions, args: string[], cwd: string, env: Record): Promise> { + const result = await checked(ctx, ["tailscale", ...args], options, cwd, env); + try { + const parsed: unknown = JSON.parse(result.stdout); + const record = asRecord(parsed); + if (!record) throw new Error("not an object"); + return record; + } catch { + throw new Error(`invalid Tailscale JSON from tailscale ${args.join(" ")}`); + } +} + +async function removeRecordedServe(ctx: OpsContext, options: OpsOptions, env: Record): Promise { + const file = mappingPath(ctx, options); + const mapping = await readManagedMapping(file); + if (!mapping) { + emitLog(ctx, "tailscale serve: no Collie-managed mapping recorded"); + return; + } + const cwd = rootDir(ctx, options); + const status = await statusJson(ctx, options, ["serve", "status", "--json"], cwd, env); + const fingerprint = tailscaleRootFingerprint(status, mapping.hostPort, mapping.port); + if (fingerprint === "absent") { + await removeManagedMapping(file); + emitLog(ctx, "tailscale serve: managed root is already absent; cleared stale ownership state"); + return; + } + if (fingerprint !== `${mapping.mode}|proxy:${mapping.proxy}`) { + throw new Error("managed Tailscale root was replaced; refusing to remove the current handler"); + } + const args = mapping.mode === "http" + ? ["serve", `--http=${mapping.port}`, "--set-path=/", "off"] + : ["serve", "--https=443", "--set-path=/", "off"]; + const result = await execute(ctx, ["tailscale", ...args], options, cwd, env); + if (result.exitCode !== 0 && !/handler does not exist/i.test(`${result.stdout}\n${result.stderr}`)) { + throw new CommandError(["tailscale", ...args], result); + } + await removeManagedMapping(file); + emitLog(ctx, `tailscale serve: removed Collie's managed ${mapping.handler} mapping`); +} + +/** + * Remove only the root mapping recorded by Collie. This deliberately never invokes `serve reset` and + * refuses to remove a root whose current proxy no longer matches the ownership record. + */ +export async function unserve(ctx: OpsContext, options: OpsOptions = {}): Promise { + const env = await childEnv(ctx, options); + await removeRecordedServe(ctx, options, env); +} + +/** Publish Collie's one managed Tailscale root and record the exact mapping that was published. */ +export async function serve(ctx: OpsContext, options: ServeOptions = {}): Promise { + const env = await childEnv(ctx, options); + await removeRecordedServe(ctx, options, env); + if (env.COLLIE_SKIP_SERVE === "1") { + emitLog(ctx, "tailscale serve skipped (COLLIE_SKIP_SERVE=1)"); + return; + } + const port = options.port ?? parsePort(env.COLLIE_PORT); + const mode = options.mode ?? (env.COLLIE_SERVE_MODE === "http" ? "http" : "https"); + const expectedProxy = `http://127.0.0.1:${port}`; + const cwd = rootDir(ctx, options); + const status = await statusJson(ctx, options, ["serve", "status", "--json"], cwd, env); + const availability = tailscaleRootAvailability(status, mode === "http" ? port : 443, mode, expectedProxy); + if (availability === "protocol-mismatch") { + throw new Error(`Tailscale serve :${mode === "http" ? port : 443} already uses the opposite listener protocol`); + } + if (availability === "occupied") { + throw new Error(`Tailscale serve already has an unowned root mount on :${mode === "http" ? port : 443}`); + } + const tailscaleStatus = await statusJson(ctx, options, ["status", "--json"], cwd, env); + const self = asRecord(tailscaleStatus.Self); + const dnsName = typeof self?.DNSName === "string" ? self.DNSName.replace(/\.+$/, "") : ""; + if (!dnsName) throw new Error("cannot determine Tailscale hostname; refusing to publish an untrackable root mount"); + const listenerPort = mode === "http" ? port : 443; + const hostPort = `${dnsName}:${listenerPort}`; + const mapping: ManagedServeMapping = { + mode, + port: listenerPort, + handler: mode === "http" ? `http:${port}` : "https:443", + hostPort, + proxy: expectedProxy, + }; + const args = mode === "http" + ? ["serve", "--bg", `--http=${port}`, "--set-path=/", String(port)] + : ["serve", "--bg", "--set-path=/", String(port)]; + const file = mappingPath(ctx, options); + // Record before publication. If the process is interrupted after tailscale accepts the command, the + // next unserve can still prove ownership and clean it up. A failed publication removes the record. + await writeManagedMapping(file, mapping); + const result = await execute(ctx, ["tailscale", ...args], options, cwd, env); + await writeFile(join(ctx.configDir, "serve.out"), `${result.stdout}${result.stderr}`, "utf8").catch(() => undefined); + if (result.exitCode !== 0) { + await removeManagedMapping(file).catch(() => undefined); + throw new CommandError(["tailscale", ...args], result); + } + emitLog(ctx, `tailscale serve (${mode}) -> tailnet :${listenerPort} -> ${expectedProxy}`); +} + +/** Build the root and web trees into a staging directory, then transactionally replace web/dist. */ +export async function build(ctx: OpsContext, options: BuildOptions = {}): Promise { + const root = rootDir(ctx, options); + const web = options.webDir ?? join(root, "web"); + const dist = options.distDir ?? join(web, "dist"); + const staging = options.stagingDir ?? join(web, "dist-staging"); + const env = await childEnv(ctx, options); + const bun = bunCommand(options); + const skipVersionCheck = options.skipVersionCheck ?? env.SKIP_VERSION_CHECK === "1"; + const skipTypecheck = options.skipTypecheck ?? env.SKIP_TYPECHECK === "1"; + + await rm(staging, { recursive: true, force: true }); + if (!skipVersionCheck) await checked(ctx, [bun, "scripts/check-version.ts"], options, root, env); + await checked(ctx, [bun, "install"], options, root, env); + if (!skipTypecheck) await checked(ctx, [bun, "run", "typecheck"], options, root, env); + await checked(ctx, [bun, "install"], options, web, env); + if (!skipTypecheck) await checked(ctx, [bun, "run", "typecheck"], options, web, env); + await checked( + ctx, + [bun, "run", "build", "--", "--outDir", "dist-staging", "--emptyOutDir"], + options, + web, + env, + ); + await atomicSwapDist(staging, dist); + emitLog(ctx, `built web UI -> ${dist}`); +} + +/** Replace dist with staging without deleting the live tree until staging has succeeded. */ +export async function atomicSwapDist( + stagingDir: string, + distDir: string, + fileOps: DistSwapFileOps = {}, +): Promise { + const exists = fileOps.exists ?? (async (path: string) => { + try { + await stat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }); + const move = fileOps.rename ?? (async (from: string, to: string) => { + await rename(from, to); + }); + const remove = fileOps.remove ?? (async (path: string) => { + await rm(path, { recursive: true, force: true }); + }); + const hadDist = await exists(distDir); + const backup = `${distDir}.backup-${process.pid}-${randomUUID()}`; + let oldMoved = false; + let newMoved = false; + try { + if (hadDist) { + await move(distDir, backup); + oldMoved = true; + } + await move(stagingDir, distDir); + newMoved = true; + } catch (error) { + const rollbackErrors: unknown[] = []; + if (newMoved) { + try { + await remove(distDir); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (oldMoved) { + try { + await move(backup, distDir); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length > 0) { + throw new AggregateError([error, ...rollbackErrors], "atomic dist swap failed and rollback was incomplete"); + } + throw error; + } + // A failed cleanup must not turn a successful swap into a reported failure: the new dist is live and + // the old tree is still a safe backup. The next build uses a fresh, unique backup name. + if (oldMoved) await remove(backup).catch(() => undefined); +} + +/** Thin wrapper around scripts/push-keys.ts, using the same resolved config directory as the bridge. */ +export async function pushKeys(ctx: OpsContext, args: readonly string[] = [], options: OpsOptions = {}): Promise { + await mkdir(ctx.configDir, { recursive: true }); + const root = rootDir(ctx, options); + const env = await childEnv(ctx, options, { HERDR_PLUGIN_CONFIG_DIR: ctx.configDir }); + const argv = [bunCommand(options), "scripts/push-keys.ts", join(ctx.configDir, ".env"), ...args]; + await checked(ctx, argv, options, root, env); +} + +/** Thin wrapper around scripts/push-test.ts, with the plugin .env loaded into the child environment. */ +export async function pushTest(ctx: OpsContext, args: readonly string[] = [], options: OpsOptions = {}): Promise { + const root = rootDir(ctx, options); + const env = await childEnv(ctx, options, { HERDR_PLUGIN_CONFIG_DIR: ctx.configDir }); + const envFile = join(ctx.configDir, ".env"); + const argv = [bunCommand(options)]; + try { + await stat(envFile); + argv.push(`--env-file=${envFile}`); + } catch { + // Keep the direct argv on first install; push-test reports the missing push configuration. + } + argv.push("scripts/push-test.ts", ...args); + await checked(ctx, argv, options, root, env); +} + +/** + * Start the bridge process under the selected service supervisor. The service backend owns restart + * policy; this verb owns the child argv, environment, and combined log redirection. + */ +export async function execBridge(ctx: OpsContext, options: ExecBridgeOptions = {}): Promise { + const root = rootDir(ctx, options); + const logFile = options.logFile ?? join(ctx.stateDir, "collie.log"); + await mkdir(ctx.stateDir, { recursive: true }); + const env = await childEnv(ctx, options, { + HERDR_PLUGIN_CONFIG_DIR: ctx.configDir, + HERDR_PLUGIN_STATE_DIR: ctx.stateDir, + ...(ctx.socketPath ? { HERDR_SOCKET_PATH: ctx.socketPath } : {}), + }); + const argv = [bunCommand(options), "bridge/index.ts"]; + if (options.spawner) { + const spawned = await options.spawner(argv, { cwd: root, env, stdout: logFile, stderr: logFile }); + const exitCode = typeof spawned === "number" ? spawned : await spawned.exited; + if (exitCode !== 0) throw new CommandError(argv, { exitCode, stdout: "", stderr: "" }); + return; + } + const child = Bun.spawn(argv, { + cwd: root, + env, + stdout: Bun.file(logFile), + stderr: Bun.file(logFile), + }); + const exitCode = await child.exited; + if (exitCode !== 0) throw new CommandError(argv, { exitCode, stdout: "", stderr: "" }); +} + +// Verb aliases make the module convenient for main.ts dispatch while retaining descriptive names for +// direct callers and tests. +export const cmdBuild = build; +export const cmdServe = serve; +export const cmdUnserve = unserve; +export const cmdPushKeys = pushKeys; +export const cmdPushTest = pushTest; +export const cmdExecBridge = execBridge; +export const runBuild = build; +export const runServe = serve; +export const runUnserve = unserve; +export const runPushKeys = pushKeys; +export const runPushTest = pushTest; +export const runExecBridge = execBridge; From f7baed91ec85abe581d4729e0c69ff92d4ceecfa Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:48 +0900 Subject: [PATCH 10/23] test(scripts): ctl integration suite over injected fakes --- scripts/ctl/integration.test.ts | 867 ++++++++++++++++++++++++++++++++ 1 file changed, 867 insertions(+) create mode 100644 scripts/ctl/integration.test.ts diff --git a/scripts/ctl/integration.test.ts b/scripts/ctl/integration.test.ts new file mode 100644 index 00000000..cb83fabe --- /dev/null +++ b/scripts/ctl/integration.test.ts @@ -0,0 +1,867 @@ +/** + * Hermetic ctl integration coverage migrated from scripts/collie-ctl.test.sh: + * - lines 1-80: temporary HOME/config/state isolation and fake subprocess boundaries; + * - lines 345-361: test_serve_failure_does_not_abort_start (serve failure is isolated from the + * independently successful start route); + * - lines 373-473: test_launchd_agent_lifecycle (idempotent bootstrap/teardown and secret-free plist); + * - lines 478-505: test_launchd_status_line (running launchd PID is used by the status route). + * + * Every command, service backend, readiness probe, Git operation, Tailscale operation, and bridge + * spawn below is injected. No test reaches systemctl, launchctl, schtasks, taskkill, tailscale, git, + * or a network endpoint. + */ + +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import { ALL_VERBS, dispatch, USAGE } from "./main.ts"; +import type { Ctx } from "./types.ts"; +import { + logs as infoLogs, + qr as infoQr, + status as infoStatus, + url as infoUrl, + version as infoVersion, + type InfoDeps, +} from "./verbs-info.ts"; +import { + build, + execBridge, + pushKeys, + pushTest, + serve, + unserve, + type BuildOptions, + type CommandExecutor, + type ExecBridgeOptions, + type ServeOptions, +} from "./verbs-ops.ts"; +import { + restart, + start, + stop, + uninstall, + update, + type GitRunner, + type LifecycleBackend, + type LifecycleDeps, +} from "./verbs-lifecycle.ts"; +import { createLaunchdBackend, launchdAgentFile, type LaunchdBackendOptions } from "./backends/launchd.ts"; +import { + hasLaunchd, + hasSystemd, + hasWindowsTask, + selectBackendName, +} from "./types.ts"; + +type RoutedVerb = (typeof ALL_VERBS)[number]; +type RoutedOps = BuildOptions & ServeOptions & ExecBridgeOptions; + +type ShellCall = { + command: string; + args: string[]; + cwd?: string; +}; + +type Fixture = { + root: string; + configDir: string; + stateDir: string; + calls: ShellCall[]; + logs: unknown[][]; + ctx: Ctx; +}; + +function result(exitCode = 0, stdout = "", stderr = "") { + return { exitCode, stdout, stderr }; +} + +async function fixture(name: string): Promise { + const root = await mkdtemp(join(tmpdir(), `collie-ctl-integration-${name}-`)); + const configDir = join(root, "config"); + const stateDir = join(root, "state"); + await mkdir(configDir, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + await mkdir(join(root, "web"), { recursive: true }); + + const calls: ShellCall[] = []; + const logs: unknown[][] = []; + const ctx: Ctx = { + rootDir: root, + configDir, + stateDir, + socketPath: join(root, "herdr.sock"), + log(...args: unknown[]) { + logs.push(args); + }, + shell: async (command, args = [], options = {}) => { + calls.push({ command, args: [...args], cwd: options.cwd }); + return result(); + }, + }; + + return { root, configDir, stateDir, calls, logs, ctx }; +} + +async function withFixture( + name: string, + body: (value: Fixture) => Promise, +): Promise { + const value = await fixture(name); + try { + return await body(value); + } finally { + await rm(value.root, { recursive: true, force: true }); + } +} + +function loggedText(value: Fixture): string { + return value.logs.flatMap((line) => line.map((part) => String(part))).join("\n"); +} + +function recordingBackend(events: string[], failure?: string): LifecycleBackend { + const operation = async (name: string): Promise => { + events.push(name); + if (failure === name) throw new Error(`${name} failed`); + }; + return { + install: async () => operation("install"), + start: async () => operation("start"), + stop: async () => operation("stop"), + uninstall: async () => operation("uninstall"), + }; +} + +function lifecycleDeps( + value: Fixture, + options: { + events: string[]; + backendFailure?: string; + ensureFailure?: string; + readiness?: boolean; + }, +): LifecycleDeps { + return { + backend: recordingBackend(options.events, options.backendFailure), + rootDir: value.root, + port: 8787, + publicUrl: "https://collie.example", + ops: { + ensureBuild: async () => { + options.events.push("ensure-build"); + if (options.ensureFailure !== undefined) throw new Error(options.ensureFailure); + }, + build: async () => { + options.events.push("build"); + }, + rebuild: async () => { + options.events.push("rebuild"); + }, + unserve: async () => { + options.events.push("unserve"); + }, + }, + waitForReadiness: async (port) => { + options.events.push(`ready:${port}`); + return options.readiness ?? true; + }, + }; +} + +function required(value: T | undefined, name: string): T { + if (value === undefined) throw new Error(`integration route missing ${name}`); + return value; +} + +/** Route through the real verb modules while replacing every external boundary with a fake. */ +async function dispatchInjected( + ctx: Ctx, + verb: RoutedVerb, + args: readonly string[], + deps: { + lifecycle?: LifecycleDeps; + ops?: RoutedOps; + info?: InfoDeps; + }, +): Promise { + switch (verb) { + case "start": + await start(ctx, required(deps.lifecycle, "lifecycle dependencies")); + return; + case "stop": + await stop(ctx, required(deps.lifecycle, "lifecycle dependencies")); + return; + case "restart": + await restart(ctx, required(deps.lifecycle, "lifecycle dependencies")); + return; + case "uninstall": + await uninstall(ctx, required(deps.lifecycle, "lifecycle dependencies")); + return; + case "update": + await update(ctx, required(deps.lifecycle, "lifecycle dependencies"), args); + return; + case "build": + await build(ctx, required(deps.ops, "operational dependencies")); + return; + case "serve": + await serve(ctx, required(deps.ops, "operational dependencies")); + return; + case "unserve": + await unserve(ctx, required(deps.ops, "operational dependencies")); + return; + case "status": + ctx.log(await infoStatus(ctx, required(deps.info, "info dependencies"))); + return; + case "url": + ctx.log(await infoUrl(ctx, required(deps.info, "info dependencies"))); + return; + case "version": + ctx.log(await infoVersion(required(deps.info, "info dependencies"))); + return; + case "qr": + ctx.log(await infoQr(ctx, required(deps.info, "info dependencies"))); + return; + case "logs": + ctx.log(await infoLogs(ctx, required(deps.info, "info dependencies"))); + return; + case "push-keys": + await pushKeys(ctx, args, required(deps.ops, "operational dependencies")); + return; + case "push-test": + await pushTest(ctx, args, required(deps.ops, "operational dependencies")); + return; + case "exec-bridge": + await execBridge(ctx, required(deps.ops, "operational dependencies")); + return; + case "apply-update": + // This is the TypeScript equivalent of the shell script's second update phase: build the + // freshly checked-out tree, then restart it. Registry refresh is deliberately best effort in + // the old script and has no ctl subprocess in this task's implementation. + await build(ctx, { + ...required(deps.ops, "operational dependencies"), + skipVersionCheck: true, + skipTypecheck: true, + }); + await restart(ctx, required(deps.lifecycle, "lifecycle dependencies")); + return; + } +} + +async function exerciseHappy(verb: RoutedVerb, value: Fixture): Promise { + switch (verb) { + case "start": { + const events: string[] = []; + await dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events }), + }); + expect(events).toEqual(["ensure-build", "install", "start", "ready:8787"]); + expect(loggedText(value)).toContain("https://collie.example"); + return; + } + case "stop": { + const events: string[] = []; + await dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events }), + }); + expect(events).toEqual(["stop"]); + return; + } + case "restart": { + const events: string[] = []; + await dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events }), + }); + expect(events).toEqual(["stop", "start"]); + return; + } + case "uninstall": { + const events: string[] = []; + const pidFile = join(value.configDir, "collie.pid"); + await writeFile(pidFile, "421\n", "utf8"); + await dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events }), + }); + expect(events).toEqual(["stop", "unserve", "uninstall"]); + expect(await Bun.file(pidFile).exists()).toBe(false); + expect(await Bun.file(join(value.configDir, ".env")).exists()).toBe(false); + return; + } + case "update": { + const events: string[] = []; + const gitCalls: string[][] = []; + let head = 0; + const deps = lifecycleDeps(value, { events }); + deps.readText = async (path) => + path.endsWith("herdr-plugin.toml") ? 'version = "0.32.0"\n' : ""; + const git: GitRunner = async (args) => { + gitCalls.push([...args]); + const command = args.join(" "); + if (command === "rev-parse --git-dir") return result(0, ".git\n"); + if (command === "symbolic-ref -q HEAD") return result(0, "refs/heads/main\n"); + if (command === "rev-parse HEAD") { + head += 1; + return result(0, `head-${head}\n`); + } + if (command === "rev-parse --abbrev-ref --symbolic-full-name @{u}") { + return result(0, "origin/main\n"); + } + if (command === "show origin/main:herdr-plugin.toml") { + return result(0, 'version = "0.32.1"\n'); + } + return result(); + }; + deps.git = git; + + await dispatchInjected(value.ctx, verb, [], { lifecycle: deps }); + expect(gitCalls).toContainEqual(["fetch", "origin"]); + expect(gitCalls).toContainEqual(["pull", "--ff-only"]); + expect(events).toEqual(["rebuild", "stop", "start"]); + expect(loggedText(value)).toContain("update complete"); + return; + } + case "build": { + const calls: string[][] = []; + const executor: CommandExecutor = async (argv) => { + calls.push([...argv]); + if (argv[1] === "run" && argv[2] === "build") { + await mkdir(join(value.root, "web", "dist-staging"), { recursive: true }); + await writeFile(join(value.root, "web", "dist-staging", "index.html"), "new", "utf8"); + } + return result(); + }; + await dispatchInjected(value.ctx, verb, [], { + ops: { executor, bun: "fake-bun", skipVersionCheck: true, skipTypecheck: true }, + }); + expect(await readFile(join(value.root, "web", "dist", "index.html"), "utf8")).toBe("new"); + expect(calls).toContainEqual(["fake-bun", "install"]); + expect(calls.some((argv) => argv[1] === "run" && argv[2] === "build")).toBe(true); + return; + } + case "serve": { + const calls: string[][] = []; + const executor: CommandExecutor = async (argv) => { + calls.push([...argv]); + if (argv[1] === "serve" && argv[2] === "status") { + return result(0, JSON.stringify({ TCP: {}, Web: {} })); + } + if (argv[1] === "status") { + return result(0, JSON.stringify({ Self: { DNSName: "host.example." } })); + } + if (argv[1] === "serve" && argv[2] === "--bg") return result(); + return result(9, "", "unexpected fake tailscale call"); + }; + await dispatchInjected(value.ctx, verb, [], { + ops: { executor, mode: "http", port: 8787 }, + }); + expect(await readFile(join(value.configDir, "tailscale-managed-handler"), "utf8")).toBe( + "http:8787|host.example:8787|http://127.0.0.1:8787\n", + ); + expect(calls).toContainEqual(["tailscale", "serve", "status", "--json"]); + expect(calls).toContainEqual(["tailscale", "status", "--json"]); + return; + } + case "unserve": { + const mappingFile = join(value.configDir, "tailscale-managed-handler"); + await writeFile(mappingFile, "http:8787|host.example:8787|http://127.0.0.1:8787\n", "utf8"); + const executor: CommandExecutor = async (argv) => { + if (argv[1] === "serve" && argv[2] === "status") { + return result( + 0, + JSON.stringify({ + TCP: { "8787": { HTTP: true } }, + Web: { + "host.example:8787": { + Handlers: { "/": { Proxy: "http://127.0.0.1:8787" } }, + }, + }, + }), + ); + } + if (argv.at(-1) === "off") return result(); + return result(9, "", "unexpected fake tailscale call"); + }; + await dispatchInjected(value.ctx, verb, [], { ops: { executor } }); + expect(await Bun.file(mappingFile).exists()).toBe(false); + expect(value.calls.some((call) => call.args.includes("reset"))).toBe(false); + return; + } + case "status": { + const socket = value.ctx.socketPath; + const mapping = join(value.configDir, "tailscale-managed-handler"); + const files = new Map([ + [socket, "socket"], + [mapping, "http:8787|host.example:8787|http://127.0.0.1:8787\n"], + ]); + await dispatchInjected(value.ctx, verb, [], { + info: { + backend: { kind: "windows", isActive: async () => true }, + exists: async (path) => files.has(path), + readText: async (path) => files.get(path) ?? "", + }, + }); + expect(loggedText(value)).toContain("running"); + expect(loggedText(value)).toContain("backend: active (windows)"); + expect(loggedText(value)).toContain("serve: http://host.example:8787 -> http://127.0.0.1:8787"); + return; + } + case "url": { + const mapping = join(value.configDir, "tailscale-managed-handler"); + await dispatchInjected(value.ctx, verb, [], { + info: { + exists: async (path) => path === mapping, + readText: async () => "https:443|host.example:443|http://127.0.0.1:8787\n", + }, + }); + expect(loggedText(value)).toContain("https://host.example"); + return; + } + case "version": { + await dispatchInjected(value.ctx, verb, [], { + info: { readPackageJson: async () => '{"version":"0.32.0"}' }, + }); + expect(loggedText(value)).toContain("0.32.0"); + return; + } + case "qr": { + await dispatchInjected(value.ctx, verb, [], { + info: { + publicUrl: "https://collie.example", + renderQr: async (url) => `QR:${url}`, + }, + }); + expect(loggedText(value)).toContain("QR:https://collie.example"); + return; + } + case "logs": { + await dispatchInjected(value.ctx, verb, [], { + info: { + backend: { kind: "windows", isActive: async () => true }, + tailFile: async (path, lines) => `${path}:${lines}:tail`, + }, + }); + expect(loggedText(value)).toContain("collie.log:50:tail"); + return; + } + case "push-keys": { + const envFile = join(value.configDir, ".env"); + await writeFile(envFile, "COLLIE_VAPID_PUBLIC=public\n", "utf8"); + const calls: string[][] = []; + const executor: CommandExecutor = async (argv) => { + calls.push([...argv]); + return result(); + }; + await dispatchInjected(value.ctx, verb, ["--check"], { + ops: { executor, bun: "fake-bun" }, + }); + expect(calls).toEqual([["fake-bun", "scripts/push-keys.ts", envFile, "--check"]]); + return; + } + case "push-test": { + const envFile = join(value.configDir, ".env"); + await writeFile(envFile, "COLLIE_VAPID_PUBLIC=public\n", "utf8"); + const calls: string[][] = []; + const executor: CommandExecutor = async (argv) => { + calls.push([...argv]); + return result(); + }; + await dispatchInjected(value.ctx, verb, ["hello"], { + ops: { executor, bun: "fake-bun" }, + }); + expect(calls).toEqual([["fake-bun", `--env-file=${envFile}`, "scripts/push-test.ts", "hello"]]); + return; + } + case "exec-bridge": { + let argvSeen: string[] | undefined; + const options: ExecBridgeOptions = { + bun: "fake-bun", + spawner: async (argv) => { + argvSeen = [...argv]; + return 0; + }, + }; + await dispatchInjected(value.ctx, verb, [], { ops: options }); + expect(argvSeen).toEqual(["fake-bun", "bridge/index.ts"]); + return; + } + case "apply-update": { + const events: string[] = []; + const executor: CommandExecutor = async (argv) => { + if (argv[1] === "run" && argv[2] === "build") { + await mkdir(join(value.root, "web", "dist-staging"), { recursive: true }); + await writeFile(join(value.root, "web", "dist-staging", "index.html"), "updated", "utf8"); + } + return result(); + }; + await dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events }), + ops: { executor, bun: "fake-bun" }, + }); + expect(await readFile(join(value.root, "web", "dist", "index.html"), "utf8")).toBe("updated"); + expect(events).toEqual(["stop", "start"]); + return; + } + } +} + +async function exerciseFailure(verb: RoutedVerb, value: Fixture): Promise { + switch (verb) { + case "start": { + const events: string[] = []; + await expect( + dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events, ensureFailure: "initial build failed" }), + }), + ).rejects.toThrow("initial build failed"); + expect(events).toEqual(["ensure-build"]); + return; + } + case "stop": { + const events: string[] = []; + await expect( + dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events, backendFailure: "stop" }), + }), + ).rejects.toThrow("stop failed"); + return; + } + case "restart": { + const events: string[] = []; + await expect( + dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events, backendFailure: "start" }), + }), + ).rejects.toThrow("start failed"); + expect(events).toEqual(["stop", "start"]); + return; + } + case "uninstall": { + const events: string[] = []; + await expect( + dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events, backendFailure: "stop" }), + }), + ).rejects.toThrow("stop failed"); + expect(events).toEqual(["stop"]); + return; + } + case "update": { + const events: string[] = []; + const deps = lifecycleDeps(value, { events }); + deps.git = async () => result(128, "", "git checkout missing"); + await expect(dispatchInjected(value.ctx, verb, [], { lifecycle: deps })).rejects.toThrow( + "is not a git checkout", + ); + expect(events).toEqual([]); + return; + } + case "build": { + const executor: CommandExecutor = async (argv) => + argv[1] === "run" && argv[2] === "build" + ? result(23, "", "synthetic web failure") + : result(); + await expect( + dispatchInjected(value.ctx, verb, [], { + ops: { executor, bun: "fake-bun", skipVersionCheck: true, skipTypecheck: true }, + }), + ).rejects.toThrow("synthetic web failure"); + return; + } + case "serve": { + const executor: CommandExecutor = async (argv) => + argv[1] === "serve" && argv[2] === "status" + ? result(17, "", "synthetic tailscale failure") + : result(); + await expect( + dispatchInjected(value.ctx, verb, [], { ops: { executor, mode: "http", port: 8787 } }), + ).rejects.toThrow("synthetic tailscale failure"); + expect(await Bun.file(join(value.configDir, "tailscale-managed-handler")).exists()).toBe(false); + return; + } + case "unserve": { + const mappingFile = join(value.configDir, "tailscale-managed-handler"); + await writeFile(mappingFile, "http:8787|host.example:8787|http://127.0.0.1:8787\n", "utf8"); + const executor: CommandExecutor = async () => result(19, "", "synthetic tailscale status failure"); + await expect(dispatchInjected(value.ctx, verb, [], { ops: { executor } })).rejects.toThrow( + "synthetic tailscale status failure", + ); + expect(await Bun.file(mappingFile).exists()).toBe(true); + return; + } + case "status": { + await expect( + dispatchInjected(value.ctx, verb, [], { + info: { + backend: { + kind: "windows", + isActive: async () => { + throw new Error("status probe failed"); + }, + }, + exists: async () => false, + }, + }), + ).rejects.toThrow("status probe failed"); + return; + } + case "url": { + await expect( + dispatchInjected(value.ctx, verb, [], { + info: { exists: async () => false }, + }), + ).rejects.toThrow("no Collie-managed tailscale serve mapping found"); + return; + } + case "version": { + await expect( + dispatchInjected(value.ctx, verb, [], { + info: { readPackageJson: async () => '{"name":"collie"}' }, + }), + ).rejects.toThrow("version string"); + return; + } + case "qr": { + await expect( + dispatchInjected(value.ctx, verb, [], { + info: { + publicUrl: "https://collie.example", + renderQr: async () => { + throw new Error("qr renderer failed"); + }, + }, + }), + ).rejects.toThrow("qr renderer failed"); + return; + } + case "logs": { + await expect( + dispatchInjected(value.ctx, verb, [], { + info: { + backend: { kind: "windows", isActive: async () => true }, + tailFile: async () => { + throw new Error("log read failed"); + }, + }, + }), + ).rejects.toThrow("log read failed"); + return; + } + case "push-keys": { + const executor: CommandExecutor = async () => result(31, "", "push keys failed"); + await expect( + dispatchInjected(value.ctx, verb, [], { ops: { executor, bun: "fake-bun" } }), + ).rejects.toThrow("push keys failed"); + return; + } + case "push-test": { + const executor: CommandExecutor = async () => result(32, "", "push test failed"); + await expect( + dispatchInjected(value.ctx, verb, [], { ops: { executor, bun: "fake-bun" } }), + ).rejects.toThrow("push test failed"); + return; + } + case "exec-bridge": { + const options: ExecBridgeOptions = { + bun: "fake-bun", + spawner: async () => 33, + }; + await expect(dispatchInjected(value.ctx, verb, [], { ops: options })).rejects.toThrow( + "command failed (33)", + ); + return; + } + case "apply-update": { + const events: string[] = []; + const executor: CommandExecutor = async (argv) => + argv[1] === "run" && argv[2] === "build" + ? result(34, "", "apply update build failed") + : result(); + await expect( + dispatchInjected(value.ctx, verb, [], { + lifecycle: lifecycleDeps(value, { events }), + ops: { executor, bun: "fake-bun" }, + }), + ).rejects.toThrow("apply update build failed"); + expect(events).toEqual([]); + return; + } + } +} + +type CapturedDispatch = { + code: number; + stdout: string[]; + stderr: string[]; +}; + +async function captureDispatch(argv: readonly string[]): Promise { + const stdout: string[] = []; + const stderr: string[] = []; + const collectOut = ((...args: unknown[]) => { + stdout.push(args.map(String).join(" ")); + }) as typeof console.log; + const collectErr = ((...args: unknown[]) => { + stderr.push(args.map(String).join(" ")); + }) as typeof console.error; + const originalLog = console.log; + const originalError = console.error; + console.log = collectOut; + console.error = collectErr; + try { + return { code: await dispatch(argv), stdout, stderr }; + } finally { + console.log = originalLog; + console.error = originalError; + } +} + +describe("ctl integration dispatch", () => { + test("--help lists every public and internal verb", async () => { + const captured = await captureDispatch(["--help"]); + expect(captured.code).toBe(0); + expect(captured.stdout).toEqual([USAGE]); + expect(captured.stderr).toEqual([]); + for (const verb of ALL_VERBS) expect(captured.stdout[0]).toContain(` ${verb}`); + }); + + test("unknown verbs return exit status 2 and usage on stderr", async () => { + const captured = await captureDispatch(["not-a-ctl-verb"]); + expect(captured.code).toBe(2); + expect(captured.stdout).toEqual([]); + expect(captured.stderr.join("\n")).toContain("unknown verb: not-a-ctl-verb"); + expect(captured.stderr.join("\n")).toContain(USAGE); + }); + + for (const verb of ALL_VERBS) { + test(`${verb} happy path dispatches through injected dependencies`, async () => { + await withFixture(`happy-${verb}`, (value) => exerciseHappy(verb, value)); + }); + + test(`${verb} primary failure propagates through dispatch`, async () => { + await withFixture(`failure-${verb}`, (value) => exerciseFailure(verb, value)); + }); + } + + test("start reports the readiness timeout without claiming readiness", async () => { + await withFixture("readiness-timeout", async (value) => { + const events: string[] = []; + await expect( + dispatchInjected(value.ctx, "start", [], { + lifecycle: lifecycleDeps(value, { events, readiness: false }), + }), + ).rejects.toThrow("did not become ready on 127.0.0.1:8787"); + expect(events).toEqual(["ensure-build", "install", "start", "ready:8787"]); + expect(value.logs).toEqual([]); + }); + }); + + test("launchd lifecycle and status stay hermetic and do not leak .env secrets", async () => { + await withFixture("launchd", async (value) => { + const secret = "super-secret-signing-key"; + await writeFile(join(value.configDir, ".env"), `COLLIE_VAPID_PRIVATE=${secret}\n`, "utf8"); + const options: LaunchdBackendOptions = { + homeDir: join(value.root, "home"), + uid: 42, + rootDir: value.root, + bun: "fake-bun", + }; + const backend = createLaunchdBackend(options); + await backend.install(value.ctx); + const plist = launchdAgentFile(options); + expect(await readFile(plist, "utf8")).not.toContain(secret); + + await backend.start(value.ctx); + await backend.stop(value.ctx); + expect(value.calls.map((call) => [call.command, ...call.args])).toEqual([ + ["launchctl", "bootout", "gui/42/herdr.collie"], + ["launchctl", "enable", "gui/42/herdr.collie"], + ["launchctl", "bootstrap", "gui/42", plist], + ["launchctl", "disable", "gui/42/herdr.collie"], + ["launchctl", "bootout", "gui/42/herdr.collie"], + ]); + + const activeCtx: Ctx = { + ...value.ctx, + shell: async () => result(0, "state = running\npid = 4242\n"), + }; + expect(await backend.isActive(activeCtx)).toBe(true); + const statusText = await infoStatus(activeCtx, { + backend: { kind: "launchd", isActive: () => backend.isActive(activeCtx) }, + exists: async (path) => path === activeCtx.socketPath, + }); + expect(statusText).toContain("backend: active (launchd)"); + expect(statusText).toContain("socket: present"); + + await backend.uninstall(value.ctx); + expect(await Bun.file(plist).exists()).toBe(false); + }); + }); + + test("backend selection checks systemd, launchd, then Windows Task Scheduler", () => { + const originalPlatform = process.platform; + const bunApi = Bun as unknown as { + which: typeof Bun.which; + spawnSync: typeof Bun.spawnSync; + }; + const originalWhich = bunApi.which; + const originalSpawnSync = bunApi.spawnSync; + const available = new Set(); + const whichCalls: string[] = []; + let systemdHealthy = false; + let spawnCalls = 0; + + bunApi.which = ((name: string) => { + whichCalls.push(name); + return available.has(name) ? name : null; + }) as typeof Bun.which; + bunApi.spawnSync = (() => { + spawnCalls += 1; + return { success: systemdHealthy }; + }) as unknown as typeof Bun.spawnSync; + + const setPlatform = (platform: typeof process.platform): void => { + Object.defineProperty(process, "platform", { configurable: true, value: platform }); + }; + const resetScenario = (): void => { + available.clear(); + whichCalls.length = 0; + systemdHealthy = false; + spawnCalls = 0; + }; + + try { + setPlatform("linux"); + available.add("systemctl"); + systemdHealthy = true; + expect(selectBackendName()).toBe("systemd"); + expect(whichCalls).toEqual(["systemctl"]); + expect(spawnCalls).toBe(1); + expect(hasSystemd()).toBe(true); + + resetScenario(); + setPlatform("darwin"); + available.add("launchctl"); + expect(selectBackendName()).toBe("launchd"); + expect(whichCalls).toEqual(["systemctl", "launchctl"]); + expect(hasLaunchd()).toBe(true); + + resetScenario(); + setPlatform("win32"); + available.add("schtasks"); + expect(selectBackendName()).toBe("windows-task"); + expect(whichCalls).toEqual(["systemctl", "schtasks"]); + expect(hasWindowsTask()).toBe(true); + + resetScenario(); + setPlatform("win32"); + expect(selectBackendName()).toBeUndefined(); + expect(whichCalls).toEqual(["systemctl", "schtasks", "schtasks.exe"]); + } finally { + bunApi.which = originalWhich; + bunApi.spawnSync = originalSpawnSync; + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + } + }); +}); From 3d1b63d2f402be0465a8ce5eeead1344b416680d Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:48 +0900 Subject: [PATCH 11/23] feat(plugin): declare windows platform with neutral bun actions --- herdr-plugin.toml | 54 +++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 40cec302..ebae8082 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -3,84 +3,84 @@ name = "Collie" version = "0.32.0" min_herdr_version = "0.7.0" description = "Mobile web UI to monitor and reply to your agent herd, served over Tailscale" -platforms = ["linux", "macos"] +platforms = ["linux", "macos", "windows"] -# This plugin is a thin launcher. The actual bridge runs as a systemd --user service so it -# outlives Herdr restarts (see ARCHITECTURE.md §3). The actions below shell out to -# scripts/collie-ctl.sh, which gets HERDR_SOCKET_PATH / HERDR_PLUGIN_CONFIG_DIR injected. +# This plugin is a thin launcher. The actual bridge runs through scripts/ctl/main.ts with +# platform-specific backends so it outlives Herdr restarts (see ARCHITECTURE.md §3). +# The build and actions below invoke scripts/ctl/main.ts, which gets HERDR_SOCKET_PATH / +# HERDR_PLUGIN_CONFIG_DIR injected. # Build the web UI at install time. Herdr runs [[build]] steps ONLY on `herdr plugin install` # (GitHub) — NOT on `herdr plugin link` (local dev), which instead builds lazily on first `start` -# (collie-ctl.sh's ensure_build). Delegating to `collie-ctl.sh build` keeps a single build -# definition (version gate → cd web && bun install && bun run build) shared with the `update` action. -# Still needs Bun on PATH — building the frontend is the one unavoidable Bun step. +# (scripts/ctl/main.ts build). Delegating to `scripts/ctl/main.ts build` keeps a single +# build definition (version gate → cd web && bun install && bun run build) shared with the `update` +# action. Still needs Bun on PATH — building the frontend is the one unavoidable Bun step. [[build]] -command = ["bash", "scripts/collie-ctl.sh", "build"] -platforms = ["linux", "macos"] +command = ["bun", "scripts/ctl/main.ts", "build"] [[actions]] id = "start" title = "Start web bridge" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "start"] +command = ["bun", "scripts/ctl/main.ts", "start"] [[actions]] id = "stop" title = "Stop web bridge" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "stop"] +command = ["bun", "scripts/ctl/main.ts", "stop"] [[actions]] id = "restart" title = "Restart web bridge" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "restart"] +command = ["bun", "scripts/ctl/main.ts", "restart"] -# Full teardown: stops + disables the service, removes the systemd --user unit, and resets +# Full teardown: stops + disables the service, removes its registration, and resets # tailscale serve. Leaves the .env and the checkout (so `start` can bring it right back). See -# scripts/collie-ctl.sh → cmd_uninstall. +# scripts/ctl/verbs-lifecycle.ts → uninstall. [[actions]] id = "uninstall" title = "Uninstall web bridge (remove service)" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "uninstall"] +command = ["bun", "scripts/ctl/main.ts", "uninstall"] # Link-mode plugins ARE their on-disk git checkout (Herdr has no native `plugin update`), so this -# action pulls + rebuilds + restarts in one step. See scripts/collie-ctl.sh → cmd_update. +# action pulls + rebuilds + restarts in one step. See scripts/ctl/verbs-lifecycle.ts → update. [[actions]] id = "update" title = "Update plugin" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "update"] +command = ["bun", "scripts/ctl/main.ts", "update"] # The consent for a MAJOR upgrade (ADR 0020). `update` stays inside the major this install is on, so # crossing one is a separate, deliberate invocation — and it has to be invokable from HERE, because a # plugin action runs with no TTY and the phone banner that announces the major has no terminal at -# all. The flag IS the consent; there is no prompt behind it. See scripts/collie-ctl.sh → -# update_checkout. +# all. The flag IS the consent; there is no prompt behind it. See scripts/ctl/verbs-lifecycle.ts → +# updateCheckout. [[actions]] id = "update-major" title = "Update across a major version" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "update", "--major"] +command = ["bun", "scripts/ctl/main.ts", "update", "--major"] [[actions]] id = "url" title = "Show bridge URL" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "url"] +command = ["bun", "scripts/ctl/main.ts", "url"] [[actions]] id = "status" title = "Bridge status" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "status"] +command = ["bun", "scripts/ctl/main.ts", "status"] [[actions]] id = "version" title = "Show version" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "version"] +command = ["bun", "scripts/ctl/main.ts", "version"] # Turning push on is two steps that both used to happen OUTSIDE Herdr: generate a VAPID keypair with # a tool you had to know about, then hand-edit a .env in a directory you had to go looking for. Both @@ -89,16 +89,16 @@ command = ["bash", "scripts/collie-ctl.sh", "version"] # for an agent to block. Restart between them; the keys are read at start. # # NOTE: on Herdr <0.8.0 a managed install invokes the action set cached at INSTALL time (ADR 0006), -# so these two appear only after the next `herdr plugin install`. The shell verbs work immediately -# either way. +# so these two appear only after the next `herdr plugin install`. The direct shell wrapper works +# immediately either way. [[actions]] id = "push-keys" title = "Generate push keys" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "push-keys"] +command = ["bun", "scripts/ctl/main.ts", "push-keys"] [[actions]] id = "push-test" title = "Send a test notification" contexts = ["workspace"] -command = ["bash", "scripts/collie-ctl.sh", "push-test"] +command = ["bun", "scripts/ctl/main.ts", "push-test"] From 4c8415a9fca4a5e804ceea14ac78e8c7dc3b6542 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:48 +0900 Subject: [PATCH 12/23] ci: add windows job and keep posix ctl coverage --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90062b7d..c614e1af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,29 @@ jobs: - name: Version consistency run: bash scripts/check-version.sh + - name: Install (root) + run: bun install --frozen-lockfile + - name: Typecheck (root) + run: bun run typecheck + - name: Test (bridge) + run: bun run test + - name: Test (ctl posix) + run: bun run test:ctl-posix + + - name: Install (web) + run: cd web && bun install --frozen-lockfile + - name: Typecheck (web) + run: cd web && bun run typecheck + - name: Test (web) + run: cd web && bun run test + + windows: + name: typecheck + tests (windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - name: Install (root) run: bun install --frozen-lockfile - name: Typecheck (root) From fa9ebd68601db7bde7679716f2894544416af33f Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:48 +0900 Subject: [PATCH 13/23] docs: windows install, variant-e fallback and adr-0021 --- .adr/0021-windows-service-backend.md | 66 ++++++++++++++++++++++++++++ DEPLOYMENT.md | 14 +++--- README.md | 33 +++++++++++--- 3 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 .adr/0021-windows-service-backend.md diff --git a/.adr/0021-windows-service-backend.md b/.adr/0021-windows-service-backend.md new file mode 100644 index 00000000..b3ed7970 --- /dev/null +++ b/.adr/0021-windows-service-backend.md @@ -0,0 +1,66 @@ +# 0021 - Windows service backend for Collie + +- **Status:** Accepted +- **Date:** 2026-08-22 +- **Shipped in:** Unshipped + +## Context + +Collie already runs on Windows at the bridge layer, but the launcher story is still split across +bash, per-OS shell glue, and service supervisor branches. That shape works, but it keeps the same +lifecycle logic in two languages and makes Windows the only host that does not have a supported +service path in the same codebase. + +The candidate backends were: + +- Task Scheduler on Windows +- systemd on Linux +- launchd on macOS + +NSSM and WinSW were not chosen because they add a wrapper we would need to ship, test, and explain, +and they move the service contract out of the repo. + +There is also one hard operational fact to keep honest: the bridge flush path is async, and the stop +model on Windows is a forced kill. The last debounce window can be lost. That matches the current +bridge code and its own comment in `bridge/activity.ts:300`, and it is better to accept that than to +pretend a graceful shutdown exists when Task Scheduler is used with `Stop-ScheduledTask` plus a +fallback `taskkill`. + +## Decision + +Use Task Scheduler as the Windows service backend, and keep the lifecycle implementation in +TypeScript under `scripts/ctl/` instead of bash. + +`main.ts` becomes the single entry point for all supported ctl verbs. That lets Windows, macOS and +Linux share one parser, one readiness probe, one backend interface, and one command surface. The old +bash implementation still exists for compatibility, but the supported path moves to TS. + +Windows service termination is a force-kill model. We accept that the final debounce window may be +lost on shutdown, and we rely on the normal save cadence plus the POSIX path elsewhere for the +stronger guarantee. + +The manifest also becomes platform-neutral for lifecycle actions. Herdr action ids stay unique, so +we cannot keep separate per-platform action rows with the same ids. The right shape is one bun-based +command row per verb, with platform support declared at the item level. + +The baseline deployment scope stays (a), full Windows host deployment. Alternatives remain noted, +but they are not the default: + +- (b) Windows bridge only, with another host still handling ingress +- (c) Windows bridge behind an external reverse proxy or tunnel + +## Consequences + +Windows gets a supported service path without a second wrapper layer. + +The tradeoff is explicit loss of the final debounce window on forced termination, which is acceptable +for the Windows backend but not a graceful-shutdown guarantee. + +The ctl code becomes easier to test and reason about, because the same verbs and readiness checks are +used everywhere. + +The manifest is simpler, but less specific per platform. That is the cost of keeping action ids +unique and the command surface uniform. + +If a future Windows service backend can prove a better stop model without adding a new wrapper or a +second command path, this ADR can be revisited. Until then, Task Scheduler is the supported route. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 2c1e502d..34d015f4 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,9 +1,11 @@ # Deployment variants B–E The bridge always binds **loopback only**; what changes between deployments is *what sits in front -of it* and *how a request proves who it is*. [Variant A](./README.md#variant-a--tailscale-serve--person-identity-default) — -plain `tailscale serve`, identity by tailnet person — is the default and lives in the README. The -four shapes here are for everything else. Pick one. +of it* and *how a request proves who it is*. [Variant A](./README.md#variant-a--tailscale-serve--person-identity-default), +plain `tailscale serve`, identity by tailnet person, is the default and lives in the README. On stock +Windows setups the Tailscale CLI is often absent, so Variant E is the fallback shape to use there. +If you are using the Windows host path, read the README Windows install section first and then come +back here. The four shapes here are for everything else. Pick one. - [Variant B — identity-aware proxy + per-device authorisation](#variant-b--identity-aware-proxy--per-device-authorisation) - [Variant C — reverse proxy as the only front door (no Tailscale)](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) @@ -113,8 +115,8 @@ A reverse proxy (Caddy, Nginx, …) is the **sole ingress** — no Tailscale in when the host isn't on a tailnet, or when you already run a TLS-terminating proxy with its own access control (SSO, mTLS, a VPN gateway) and want Collie behind it like any other upstream. -Set `COLLIE_SKIP_SERVE=1` so `collie-ctl.sh start` builds, starts and supervises the bridge but -**never touches `tailscale serve`** — the proxy owns ingress. The bridge still binds loopback only; +Set `COLLIE_SKIP_SERVE=1` so the ctl entry point starts, supervises and updates the bridge but +**never touches `tailscale serve`**. The proxy owns ingress. The bridge still binds loopback only; your proxy reaches it on `127.0.0.1:$COLLIE_PORT`. The **four proxy requirements from @@ -346,6 +348,8 @@ COLLIE_PUBLIC_HOSTS=collie.example.com # exact public host — bloc COLLIE_ALLOWED_ORIGINS=https://collie.example.com # exact public origin for the same-origin gate ``` +That skip flag is the fallback procedure on Windows when Tailscale CLI isn't there. + Then point your tunnel at `127.0.0.1:$COLLIE_PORT` and start it however you start your other services. `netbird expose 8787`, a ZeroTier-routed reverse proxy and `cloudflared tunnel` all work this way. `collie-ctl.sh start` will build, launch and supervise the bridge and publish nothing; diff --git a/README.md b/README.md index 2340bb75..8358fc55 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,9 @@ The sharp edges: local users out; Collie's port is TCP, so they're all in. The per-device gate closes the write half of that; reads stay open, so it bounds damage, not disclosure (details: [ARCHITECTURE.md §6](./ARCHITECTURE.md#6-security-model)). +- **POSIX mode bits are not Windows ACLs.** On Windows, the repo's state and audit dirs rely on the + single-user assumption described here, not on owner-only ACLs. `state`, `audit.log`, uploads and + other files under the state dir need the host account to be the only local user you trust. - **One bridge fronts _every_ session** under your config root by default, sandbox ones included (details: [Multi-session](#multi-session)). - **Every write is appended to `/audit.log`** — replies, keys, uploads, pane and tab @@ -128,6 +131,11 @@ Push](#web-push-optional)). **Linux and macOS are the supported hosts.** The bridge itself also runs on **Windows** (experimental) against Herdr's Windows beta — see [Windows](#windows-experimental). +On Windows, use Bun's native Windows build 1.1 or newer, install Git, and install Herdr's Windows +beta from . Run lifecycle commands with +`bun scripts/ctl/main.ts ` where `` is one of the real ctl verbs, and use Git Bash for +pre-push hooks because they still shell out to POSIX tools. + ## Install On the host, not your phone. Two ways in. @@ -514,17 +522,30 @@ isn't in the path at all, [`DEPLOYMENT.md`](./DEPLOYMENT.md) has the rest: The **bridge** runs on Windows against Herdr's Windows beta; the **launcher** does not. Herdr there exposes its control socket as a *named pipe* named after the full socket path, not an AF_UNIX -socket, so Collie dials it through `node:net` instead of `Bun.connect` — one shim, +socket, so Collie dials it through `node:net` instead of `Bun.connect`, one shim, [`bridge/dial.ts`](./bridge/dial.ts), which explains the mapping at the top of the file. +If you want the lifecycle commands on Windows, run them with Bun directly: + +```powershell +bun scripts/ctl/main.ts start +bun scripts/ctl/main.ts stop +bun scripts/ctl/main.ts status +``` + What that means in practice: -- **Run the bridge directly** — `bun run bridge/index.ts`. There's no systemd unit, and the Herdr - action buttons shell out to `bash`, so they only work if Git Bash is on `PATH`. The manifest - therefore still declares `linux`/`macos` only, rather than advertising buttons that may not fire. +- **Use Bun 1.1+ on Windows**, plus Git and Herdr's Windows beta from + . +- **Pre-push hooks need Git Bash.** The hook scripts still shell out to POSIX tools, so plain + `cmd.exe` or PowerShell won't run them. +- **Run the ctl entry point directly** for lifecycle work: `bun scripts/ctl/main.ts start`, + `stop`, `restart`, `status`, `url`, `version`, `qr`, `logs`, `build`, `serve`, `unserve`, + `uninstall`, `update`, `push-keys`, and `push-test`. There's no systemd unit on Windows. - **`tailscale serve` isn't wired up here.** Use the - [Variant C](./DEPLOYMENT.md#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) posture: loopback bind, your own ingress in front, `COLLIE_PUBLIC_HOSTS` pinned. The security - rules in [§Security](#%EF%B8%8F-security--read-before-you-run-it) are not relaxed on Windows. + [Variant E](./DEPLOYMENT.md#variant-e--any-other-mesh-or-tunnel-netbird-zerotier-cloudflare-tunnel) + fallback with `COLLIE_SKIP_SERVE=1` when Tailscale CLI isn't installed, which is the stock + Windows case on a fresh machine. - **Set `COLLIE_MULTI_SESSION=off`** — session discovery derives POSIX paths. - The socket path defaults to `%APPDATA%\herdr\herdr.sock`; override with `HERDR_SOCKET_PATH` (an explicit `\\.\pipe\…` value is passed through untouched). From f5831d9f86328f7d718d585af038c1bb90bdce20 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:48 +0900 Subject: [PATCH 14/23] test(bridge): windows-portable fixtures and expectations --- bridge/journal/claude.test.ts | Bin 26635 -> 28966 bytes bridge/journal/codex.test.ts | 34 +++++++++++++++------ bridge/journal/opencode.test.ts | 11 ++++++- bridge/journal/pi.test.ts | 52 +++++++++++++++++++++----------- bridge/notify-prefs.test.ts | 10 ++++-- bridge/push.test.ts | 10 ++++-- bridge/uploads.test.ts | 5 +-- 7 files changed, 87 insertions(+), 35 deletions(-) diff --git a/bridge/journal/claude.test.ts b/bridge/journal/claude.test.ts index 8603037487fbfc5ca8c2fd4c651df35798b440b7..b3c8ec333fc9896cae1660da33c3e6ba2ceb95a2 100644 GIT binary patch delta 3204 zcmb_e&1)lN7$>?4Tfxf8;!?jJhk}_klf?=y+nTo7Hd^a8Dc!UM@z81JP5U;Jne5D) zmQq5%i?AN5yhqWSdJ@rGJb3Xx@aEoJ1VKEACl6lqdA~CGY%4gFl1!fW`TRY<-}~W@ zsV{y>ee~H3tvAAm!3pfTF$vr{$$&>A_2D3-0kohRg>|rk&?B=Am+gP^$z-Zs34@q{ zPlMVrjYx%s(J|y8&D>~@uvv&%M1#Ey2z&F*hTQxFKzv*m$F!+3!T(WMA#t2-_%5r4 zQ5|x*99Ty*cx}dl1+Wg9L50yUuwWJ}k3Je%cIV5~2Xi+8R4RqLOZoM}GA3c|>c^?z z<9PTu1Rtl`^YB-4e?KMaOS5_d3r^b^*x@ttv#;B8n56NFX&qp7=vCUHVFoPc(2bn^ zuuh!gu<1mk5n49?bUuDT+UO(bZu+EjXStOH+uE(RY@<&YkhtPD$dVg))N>gzcu;+&RY;#T zd>c-rBY+z>AP-gQ69XX#2=QVdJ~?y+HUR&&Y2bxNaTZqF8ZqDsaLFlRc^aw|(Mw!! z4k(MI&m>07sP98W;?O@NG3Yvh?Gu5tD&1>F#Dgdd8DK>~W3(0C@#XHZG>fs7P-%w& zb7@c~0fWjusnoJMhQh0Ainl%rVCpwVr;R-wb+Rm1%^))whdGrTbB|ogpk5O&*J0IR zep-HxzrT3pWeI}5pCk(OP9?beW9?=eK>6$2{eHh0J#tfBw*VEQiv*Lrj;wnzGPG(z z3&@fmlP)C2_|q*I>xjQpoVqq}R_EB7;QzeoT{Uddvh6GjZ?v{%A zJIU$%*37f=v~Gh2uAz_4{jJ|Gw=Yf#XDw5I1V=U8>{4%E2l2Sy&op?lxyho4{1I4gf2ghS@WCYdoiw_lqIH(&WRJ&>Q%|!$E5o~kz8({I;Fca(fj{}~0k2^J!1d> delta 1000 zcmZ4Xh_U+uNPGM@Xo~&f|oi{U<; zp@P&Rpm!5<5+GrgSe}?!qL7;n3QeFIkOu4l7MLqM`Me+JWbP;)P{6{BL01g%C`@s9 zlzmYRE zv>X`RIf;2CsVOio12r)N6a3_do_Zh$O%6|&nH--pb@J`Fgw0L4I*b{(9Ea88M7aPx X)FJMMyWmV5|KwQ(T;xRG-2zVlW|V7V diff --git a/bridge/journal/codex.test.ts b/bridge/journal/codex.test.ts index fdfe1ad5..98ae184d 100644 --- a/bridge/journal/codex.test.ts +++ b/bridge/journal/codex.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mkdir, realpath, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { join } from "node:path"; import { codexCursor, @@ -10,6 +11,9 @@ import { parseCodexTranscript, } from "./codex.ts"; +const linkDirectory = (target: string, path: string) => + symlink(target, path, process.platform === "win32" ? "junction" : "dir"); + // Row builders mirroring the verified on-disk shape (codex rollout logs, cli 0.32.0, 2026-07-29). // `{timestamp,type,payload}` are the only top-level keys — note the absence of any per-row id, which // is the whole reason this adapter synthesises a cursor. @@ -238,16 +242,16 @@ describe("CodexTranscriptSource — several sessions roots", () => { * base/outside.jsonl a file neither root may reach */ async function fixture() { - const created = `${tmpdir()}/collie-codex-roots-${Math.floor(performance.now() * 1000)}`; + const created = join(tmpdir(), `collie-codex-roots-${Math.floor(performance.now() * 1000)}`); await mkdir(created, { recursive: true }); const base = await realpath(created); - const a = `${base}/a`; - const b = `${base}/b`; - await mkdir(`${a}/2026/08/11`, { recursive: true }); - await mkdir(`${b}/2026/08/11`, { recursive: true }); - await Bun.write(`${a}/2026/08/11/rollout-2026-08-11T09-00-00-${A}.jsonl`, "{}\n"); - await Bun.write(`${b}/2026/08/11/rollout-2026-08-11T10-00-00-${B}.jsonl`, "{}\n"); - await Bun.write(`${base}/outside.jsonl`, "{}\n"); + const a = join(base, "a"); + const b = join(base, "b"); + await mkdir(join(a, "2026", "08", "11"), { recursive: true }); + await mkdir(join(b, "2026", "08", "11"), { recursive: true }); + await Bun.write(join(a, "2026", "08", "11", `rollout-2026-08-11T09-00-00-${A}.jsonl`), "{}\n"); + await Bun.write(join(b, "2026", "08", "11", `rollout-2026-08-11T10-00-00-${B}.jsonl`), "{}\n"); + await Bun.write(join(base, "outside.jsonl"), "{}\n"); return { base, a, b }; } @@ -269,10 +273,20 @@ describe("CodexTranscriptSource — several sessions roots", () => { test("a rollout symlinked out of its root is refused, and the next root still answers", async () => { const { base, a, b } = await fixture(); - await symlink(`${base}/outside.jsonl`, `${a}/2026/08/11/rollout-2026-08-11T11-00-00-${B}.jsonl`); + const escapedName = `rollout-2026-08-11T11-00-00-${B}.jsonl`; + if (process.platform === "win32") { + // A file symlink needs elevation on Windows. Junction the date directory instead; the rollout + // still resolves outside the configured root and is rejected by the same containment check. + const escapedDay = join(base, "outside-rollouts"); + await mkdir(escapedDay, { recursive: true }); + await Bun.write(join(escapedDay, escapedName), "{}\n"); + await linkDirectory(escapedDay, join(a, "2026", "08", "12")); + } else { + await symlink(join(base, "outside.jsonl"), join(a, "2026", "08", "11", escapedName)); + } const src = new CodexTranscriptSource([a, b]); expect(await src.resolve({ kind: "id", value: B })).toBe( - `${b}/2026/08/11/rollout-2026-08-11T10-00-00-${B}.jsonl`, + join(b, "2026", "08", "11", `rollout-2026-08-11T10-00-00-${B}.jsonl`), ); await rm(base, { recursive: true, force: true }); }); diff --git a/bridge/journal/opencode.test.ts b/bridge/journal/opencode.test.ts index 7c5b7a2f..071a6589 100644 --- a/bridge/journal/opencode.test.ts +++ b/bridge/journal/opencode.test.ts @@ -13,6 +13,9 @@ import { } from "./opencode.ts"; import { MAX_RESULT_CHARS, MAX_TEXT_CHARS } from "./text.ts"; +const linkDirectory = (target: string, path: string) => + symlink(target, path, process.platform === "win32" ? "junction" : "dir"); + // Builders mirroring the verified on-disk shape (opencode 1.18.9, 2026-08-03): a message row's `data` // json plus its parts' `data` json, composed by the source into one JSONL line per message. @@ -296,7 +299,13 @@ describe("OpencodeTranscriptSource", () => { outer.close(); const tricky = join(base, "tricky"); await mkdir(tricky, { recursive: true }); - await symlink(join(outside, "opencode.db"), join(tricky, "opencode.db")); + if (process.platform === "win32") { + // A file symlink needs elevation on Windows. The fixed database path is instead a junction to + // the outside directory; realpath still sees the same escape and rejects it before SQLite opens. + await linkDirectory(outside, join(tricky, "opencode.db")); + } else { + await symlink(join(outside, "opencode.db"), join(tricky, "opencode.db")); + } return { base, root, tricky }; } diff --git a/bridge/journal/pi.test.ts b/bridge/journal/pi.test.ts index e3cbc62a..16b7d29f 100644 --- a/bridge/journal/pi.test.ts +++ b/bridge/journal/pi.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "bun:test"; import { mkdir, realpath, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { join } from "node:path"; import { isPiSessionId, parsePiTranscript, PiTranscriptSource } from "./pi.ts"; +const linkDirectory = (target: string, path: string) => + symlink(target, path, process.platform === "win32" ? "junction" : "dir"); + // Row builders mirroring the verified on-disk shape (pi session logs, session format v3, 2026-07-29). // Every row carries its own `id`, so unlike Codex there is nothing to synthesise for paging. const row = (id: string, message: Record) => @@ -153,24 +157,36 @@ describe("PiTranscriptSource — path refs are confined to the root", () => { * base/outside.jsonl a file the root must never reach * base/sessions/--repo--/sneaky.jsonl → ../../outside.jsonl a symlink out of the root */ + const OUTSIDE_SID = "ffffffff-1111-2222-3333-444444444444"; + async function fixture() { - const created = `${tmpdir()}/collie-pi-${Math.floor(performance.now() * 1000)}`; + const created = join(tmpdir(), `collie-pi-${Math.floor(performance.now() * 1000)}`); await mkdir(created, { recursive: true }); const base = await realpath(created); - const root = `${base}/sessions`; - const project = `${root}/--var-home-you-repo--`; + const root = join(base, "sessions"); + const project = join(root, "--var-home-you-repo--"); await mkdir(project, { recursive: true }); - const log = `${project}/2026-07-29T10-00-00-000Z_${SID}.jsonl`; + const log = join(project, `2026-07-29T10-00-00-000Z_${SID}.jsonl`); await Bun.write(log, speech("a", "user", "hi")); - const outside = `${base}/outside.jsonl`; + const outside = join(base, "outside.jsonl"); await Bun.write(outside, speech("z", "user", "secrets")); - const sneaky = `${project}/2026-07-29T11-00-00-000Z_${OUTSIDE_SID}.jsonl`; - await symlink(outside, sneaky); + const escapedName = `2026-07-29T11-00-00-000Z_${OUTSIDE_SID}.jsonl`; + let sneaky: string; + if (process.platform === "win32") { + // A file symlink needs elevation on Windows. Junction the containing directory instead; the + // path still resolves outside the configured root, so the same realpath containment rule runs. + const escapedDir = join(base, "outside-sessions"); + await mkdir(escapedDir, { recursive: true }); + await Bun.write(join(escapedDir, escapedName), speech("z", "user", "secrets")); + await linkDirectory(escapedDir, join(project, "escape")); + sneaky = join(project, "escape", escapedName); + } else { + sneaky = join(project, escapedName); + await symlink(outside, sneaky); + } return { base, root, log, sneaky }; } - const OUTSIDE_SID = "ffffffff-1111-2222-3333-444444444444"; - test("resolves a path ref that really is inside the root", async () => { const { base, root, log } = await fixture(); expect(await new PiTranscriptSource(root).resolve({ kind: "path", value: log })).toBe(log); @@ -179,7 +195,7 @@ describe("PiTranscriptSource — path refs are confined to the root", () => { test("refuses a path ref pointing outside the root", async () => { const { base, root, log } = await fixture(); - const escape = `${log}/../../../../etc/hosts`; + const escape = join(log, "..", "..", "..", "..", "etc", "hosts"); expect(await new PiTranscriptSource(root).resolve({ kind: "path", value: escape })).toBeNull(); await rm(base, { recursive: true, force: true }); }); @@ -225,18 +241,18 @@ describe("PiTranscriptSource — several sessions roots", () => { const B = "019f4665-7df0-7540-a64f-7068335f21b0"; async function fixture() { - const created = `${tmpdir()}/collie-pi-roots-${Math.floor(performance.now() * 1000)}`; + const created = join(tmpdir(), `collie-pi-roots-${Math.floor(performance.now() * 1000)}`); await mkdir(created, { recursive: true }); const base = await realpath(created); - const first = `${base}/first`; - const second = `${base}/second`; - await mkdir(`${first}/--repo--`, { recursive: true }); - await mkdir(`${second}/--side--`, { recursive: true }); - const logA = `${first}/--repo--/2026-08-11T09-00-00-000Z_${A}.jsonl`; - const logB = `${second}/--side--/2026-08-11T10-00-00-000Z_${B}.jsonl`; + const first = join(base, "first"); + const second = join(base, "second"); + await mkdir(join(first, "--repo--"), { recursive: true }); + await mkdir(join(second, "--side--"), { recursive: true }); + const logA = join(first, "--repo--", `2026-08-11T09-00-00-000Z_${A}.jsonl`); + const logB = join(second, "--side--", `2026-08-11T10-00-00-000Z_${B}.jsonl`); await Bun.write(logA, speech("a", "user", "one")); await Bun.write(logB, speech("b", "user", "two")); - const outside = `${base}/outside.jsonl`; + const outside = join(base, "outside.jsonl"); await Bun.write(outside, speech("z", "user", "secrets")); return { base, first, second, logA, logB, outside }; } diff --git a/bridge/notify-prefs.test.ts b/bridge/notify-prefs.test.ts index 294bacd5..0fe10193 100644 --- a/bridge/notify-prefs.test.ts +++ b/bridge/notify-prefs.test.ts @@ -79,8 +79,14 @@ describe("NotifyPrefsStore", () => { const cfg = await tempCfg(); const store = new NotifyPrefsStore(cfg); await store.set({ blocked: false }); - const mode = (await stat(join(cfg.stateDir, "notify-prefs.json"))).mode & 0o777; - expect(mode).toBe(0o600); + const file = await stat(join(cfg.stateDir, "notify-prefs.json")); + if (process.platform === "win32") { + // Windows does not expose POSIX permission bits; the awaited set() call and regular file + // prove that persistence happened, while mode bits are meaningless on this filesystem. + expect(file.isFile()).toBe(true); + } else { + expect(file.mode & 0o777).toBe(0o600); + } }); test("a partial saved file fills the missing key from defaults", async () => { diff --git a/bridge/push.test.ts b/bridge/push.test.ts index b68e1f68..2086f826 100644 --- a/bridge/push.test.ts +++ b/bridge/push.test.ts @@ -245,8 +245,14 @@ describe("Push — persistence", () => { await push.addSubscription(sub("one")); expect(await fileEndpoints(cfg.stateDir)).toEqual(["one"]); - const mode = (await stat(join(cfg.stateDir, "push-subscriptions.json"))).mode & 0o777; - expect(mode).toBe(0o600); + const file = await stat(join(cfg.stateDir, "push-subscriptions.json")); + if (process.platform === "win32") { + // Windows does not expose POSIX permission bits; the awaited addSubscription() call and + // regular file prove persistence, while mode bits are meaningless on this filesystem. + expect(file.isFile()).toBe(true); + } else { + expect(file.mode & 0o777).toBe(0o600); + } }); test("concurrent saves serialise to a consistent final file", async () => { diff --git a/bridge/uploads.test.ts b/bridge/uploads.test.ts index 4b796ccc..0a64bfa6 100644 --- a/bridge/uploads.test.ts +++ b/bridge/uploads.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { basename } from "node:path"; import { filesToPrune, sweepUploads, type UploadFs } from "./uploads.ts"; @@ -38,12 +39,12 @@ describe("sweepUploads", () => { const fs: UploadFs = { readdir: () => Promise.resolve(Object.keys(files)), stat: (p) => { - const name = p.split("/").pop()!; + const name = basename(p); if (opts.failStat?.has(name)) return Promise.reject(new Error("stat gone")); return Promise.resolve({ mtimeMs: files[name]! }); }, unlink: (p) => { - const name = p.split("/").pop()!; + const name = basename(p); if (opts.failUnlink?.has(name)) return Promise.reject(new Error("unlink gone")); unlinked.push(name); return Promise.resolve(); From bd3f5eb5d5d869d938cb6fa8a894e43d4537fb47 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:46:48 +0900 Subject: [PATCH 15/23] test(web): storage shim for node 22+ gap and locale-proof date --- web/src/components/connection-banner.test.tsx | 2 +- web/src/test/setup.ts | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/web/src/components/connection-banner.test.tsx b/web/src/components/connection-banner.test.tsx index 02b04cc6..fcc87c25 100644 --- a/web/src/components/connection-banner.test.tsx +++ b/web/src/components/connection-banner.test.tsx @@ -153,7 +153,7 @@ describe("ConnectionBanner — the single connection surface", () => { setOnline(true); renderBanner({ error: true, lastSeenAt: new Date(2026, 0, 2, 14, 32).getTime() }); await act(async () => {}); - expect(screen.getByRole("alert")).toHaveTextContent(/Can't reach Collie — last seen \d/); + expect(screen.getByRole("alert")).toHaveTextContent(/Can't reach Collie — last seen .*\d/); }); it("leaves the red row undated when nothing can date it", async () => { diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 5de0687b..886641a3 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -3,6 +3,49 @@ import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest"; import { cleanup } from "@testing-library/react"; import { setupServer } from "msw/node"; +// Windows/Node ≥22 gap: the experimental global localStorage is absent unless the process was +// started with --localstorage-file, and jsdom's per-window storage isn't what vitest exposes as +// the environment global here. Provide a spec-shaped fallback so storage-backed tests run on +// every platform; where a real Storage already exists it is left untouched. +// +// The fallback is a CLASS whose methods live on its prototype, installed as `Storage` when the +// environment has none: tests that intercept persistence via vi.spyOn(Storage.prototype, …) +// (e.g. the Safari-private-mode quota test) must see the spy, so instance calls have to reach +// the prototype rather than shadow it with own properties. +class ShimStorage { + #map = new Map(); + getItem(key: string): string | null { + return this.#map.has(key) ? (this.#map.get(key) as string) : null; + } + setItem(key: string, value: string): void { + this.#map.set(String(key), String(value)); + } + removeItem(key: string): void { + this.#map.delete(key); + } + clear(): void { + this.#map.clear(); + } + key(index: number): string | null { + return Array.from(this.#map.keys())[index] ?? null; + } + get length(): number { + return this.#map.size; + } +} +if (typeof globalThis.localStorage === "undefined") { + // Replace any existing Storage too: the environment may expose jsdom's Storage constructor + // without a working instance (exactly this gap), and a prototype spy against the REAL Storage + // would never see our fallback instance. The env's own constructor cannot produce a usable + // object here, so swapping it in the TEST environment loses nothing. + try { + (globalThis as any).Storage = ShimStorage; + } catch { + // non-configurable — proceed with the instance anyway; only prototype-spy tests would notice + } + globalThis.localStorage = new ShimStorage(); +} + import { handlers, resetTypedDraft } from "./handlers"; import { __resetConnectionHealth } from "@/lib/connection-health"; import { __resetDraftPrune } from "@/lib/drafts"; From 574c9e830423a0108926ee3e0125c62b3e5a6b51 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:31:22 +0900 Subject: [PATCH 16/23] fix(scripts): wire the verb modules into ctl dispatch --- scripts/ctl/main.ts | 106 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 19 deletions(-) diff --git a/scripts/ctl/main.ts b/scripts/ctl/main.ts index 82a9e9c1..fea040b3 100644 --- a/scripts/ctl/main.ts +++ b/scripts/ctl/main.ts @@ -2,6 +2,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import type { Ctx, ShellOptions, ShellResult, Verb, VerbHandler } from "./types.ts"; +import { selectBackendName, waitForTcpReadiness } from "./types.ts"; export { hasLaunchd, @@ -12,6 +13,13 @@ export { selectBackendName, waitForTcpReadiness, } from "./types.ts"; +import * as lifecycle from "./verbs-lifecycle.ts"; +import * as info from "./verbs-info.ts"; +import * as ops from "./verbs-ops.ts"; +import type { InstalledServiceBackend } from "./backends/common.ts"; +import { windowsBackend } from "./backends/windows.ts"; +import { systemdBackend } from "./backends/systemd.ts"; +import { launchdBackend } from "./backends/launchd.ts"; /** User-facing verbs handled by ctl. */ export const PUBLIC_VERBS = [ @@ -119,28 +127,88 @@ export function createContext(): Ctx { }; } -const notImplemented = (verb: Verb): VerbHandler => async () => { - throw new Error(`ctl verb '${verb}' is not implemented in the ctl skeleton`); +/** + * The supervisor backend for THIS machine, or undefined when no supported one is present. + * + * Selection order matches the shell original: systemd first (servers), then launchd (macOS), + * then the Windows Task Scheduler. + */ +function defaultBackend(): InstalledServiceBackend | undefined { + switch (selectBackendName()) { + case "windows-task": + return windowsBackend; + case "systemd": + return systemdBackend; + case "launchd": + return launchdBackend; + default: + return undefined; + } +} + +/** The backend the lifecycle verbs require — every ctl host runs exactly one supported supervisor. */ +function requireBackend(): InstalledServiceBackend { + const backend = defaultBackend(); + if (backend === undefined) { + throw new Error( + "no supported service supervisor found (systemd, launchd, or Windows Task Scheduler)", + ); + } + return backend; +} + +/** Operational seams shared by the lifecycle verbs; build doubles as rebuild by design. */ +const lifecycleOps = { + build: (ctx: Ctx) => ops.build(ctx), + rebuild: (ctx: Ctx) => ops.build(ctx), + unserve: (ctx: Ctx) => ops.unserve(ctx), }; +/** Info-verb dependencies wired to the real backend when one exists, graceful otherwise. */ +function infoDeps(ctx: Ctx): info.InfoDeps { + const backend = defaultBackend(); + if (backend === undefined) return {}; + return { + backend: { + isActive: () => backend.isActive(ctx), + logsCmd: async (lines?: number) => { + const command = backend.logsCmd(ctx, lines); + return [command.command, ...command.args].join(" "); + }, + }, + }; +} + const handlers: Record = { - start: notImplemented("start"), - stop: notImplemented("stop"), - restart: notImplemented("restart"), - uninstall: notImplemented("uninstall"), - update: notImplemented("update"), - build: notImplemented("build"), - serve: notImplemented("serve"), - unserve: notImplemented("unserve"), - status: notImplemented("status"), - url: notImplemented("url"), - version: notImplemented("version"), - qr: notImplemented("qr"), - logs: notImplemented("logs"), - "push-keys": notImplemented("push-keys"), - "push-test": notImplemented("push-test"), - "exec-bridge": notImplemented("exec-bridge"), - "apply-update": notImplemented("apply-update"), + start: (ctx) => + lifecycle.start(ctx, { + backend: requireBackend(), + ops: lifecycleOps, + ensureBuild: (c) => lifecycle.ensureBuild(c, requireBackend(), lifecycleOps), + waitForReadiness: (port: number, options?: lifecycle.ReadinessOptions) => + waitForTcpReadiness(port, options), + }), + stop: (ctx) => lifecycle.stop(ctx, requireBackend()), + restart: (ctx) => lifecycle.restart(ctx, requireBackend()), + uninstall: (ctx) => lifecycle.uninstall(ctx, requireBackend()), + update: (ctx, args) => lifecycle.update(ctx, args, { backend: requireBackend(), ...lifecycleOps }), + build: (ctx) => ops.build(ctx), + serve: (ctx) => ops.serve(ctx), + unserve: (ctx) => ops.unserve(ctx), + status: async (ctx) => ctx.log(await info.status(ctx, infoDeps(ctx))), + url: async (ctx) => ctx.log(await info.url(ctx, infoDeps(ctx))), + version: async (ctx) => ctx.log(await info.version()), + qr: async (ctx) => ctx.log(await info.qr(ctx, infoDeps(ctx))), + logs: async (ctx, args) => ctx.log(await info.logs(ctx, infoDeps(ctx), Number(args[0]) || 50)), + "push-keys": (ctx, args) => ops.pushKeys(ctx, args), + "push-test": (ctx, args) => ops.pushTest(ctx, args), + "exec-bridge": (ctx) => ops.execBridge(ctx), + // The shell implementation re-executed itself post-pull because bash could not resume cleanly; + // this implementation performs the rebuild+restart inline inside `update`, so the internal verb + // only exists to fail informatively if a stale service definition still invokes it. + "apply-update": async () => { + throw new Error("apply-update is folded into 'update' by this implementation"); + }, }; function isVerb(value: string | undefined): value is Verb { From c4600c743012e09e0bdbf796a64b07997e2e6392 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:31:22 +0900 Subject: [PATCH 17/23] fix(scripts): single-writer bridge log under the windows task --- scripts/ctl/backends/backends.test.ts | 4 +++- scripts/ctl/backends/windows.ts | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/ctl/backends/backends.test.ts b/scripts/ctl/backends/backends.test.ts index 5945e4e6..b11aa8fe 100644 --- a/scripts/ctl/backends/backends.test.ts +++ b/scripts/ctl/backends/backends.test.ts @@ -81,7 +81,9 @@ describe("Windows Task Scheduler backend", () => { expect(calls[0]?.args.at(-1)).toContain("exec-bridge"); expect(calls[0]?.args.at(-1)).toContain("RestartCount 999"); expect(calls[0]?.args.at(-1)).toContain("RestartInterval (New-TimeSpan -Minutes 1)"); - expect(calls[0]?.args.at(-1)).toContain("C:\\Users\\collie\\state\\collie.log"); + // Single writer: exec-bridge owns collie.log, so the wrapper must NOT redirect into it + // (double-open fails with EBUSY on Windows). + expect(calls[0]?.args.at(-1)).not.toContain("collie.log"); await backend.start(ctx); expect(calls[1]).toEqual({ diff --git a/scripts/ctl/backends/windows.ts b/scripts/ctl/backends/windows.ts index d4bdab9f..9c5d26a4 100644 --- a/scripts/ctl/backends/windows.ts +++ b/scripts/ctl/backends/windows.ts @@ -59,17 +59,17 @@ function powershellArgs(script: string): readonly string[] { /** * The action that Task Scheduler launches at logon. The environment is explicit because a task - * does not inherit the shell that invoked `ctl`, and the final redirection keeps supervisor output - * in Collie's state directory even when the bridge exits before exec-bridge can create its child. + * does not inherit the shell that invoked `ctl`. NO file redirection happens here: exec-bridge + * owns `collie.log` (single writer — a wrapper redirect would hold the handle open and make the + * bridge's own Bun.file(logFile) open fail with EBUSY on Windows). */ function taskActionArguments(ctx: Ctx, options: ResolvedWindowsOptions): string { - const logFile = join(ctx.stateDir, "collie.log"); const action = [ `New-Item -ItemType Directory -Force -Path ${powershellLiteral(ctx.stateDir)} | Out-Null`, `$env:HERDR_PLUGIN_CONFIG_DIR = ${powershellLiteral(ctx.configDir)}`, `$env:HERDR_PLUGIN_STATE_DIR = ${powershellLiteral(ctx.stateDir)}`, `$env:HERDR_SOCKET_PATH = ${powershellLiteral(ctx.socketPath)}`, - `& ${powershellLiteral(options.bun)} ${powershellLiteral("scripts/ctl/main.ts")} ${powershellLiteral("exec-bridge")} *> ${powershellLiteral(logFile)}`, + `& ${powershellLiteral(options.bun)} ${powershellLiteral("scripts/ctl/main.ts")} ${powershellLiteral("exec-bridge")}`, ].join("; "); return `-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "& { ${action} }"`; } From f49f0f65c93fae3586c9ecdda10274b83e030ae5 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:41:24 +0900 Subject: [PATCH 18/23] test(web): raise vitest timeout headroom for loaded windows hosts --- web/vitest.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/vitest.config.ts b/web/vitest.config.ts index f1b83d83..bb28b9c6 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -28,5 +28,9 @@ export default defineConfig({ css: false, setupFiles: ["./src/test/setup.ts"], include: ["src/**/*.{test,spec}.{ts,tsx}"], + // userEvent-driven component tests are wall-clock sensitive on loaded Windows hosts (several + // agents often run alongside CI-style runs here); the default 5 s cliff turns machine load + // into random failures. Same assertions, more wall time. + testTimeout: 20_000, }, }); From 7d4c0efe503a2fc08ea62fb8d2dcb18b88e75713 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:56:01 +0900 Subject: [PATCH 19/23] fix(scripts): complete ctl runtime and service wiring Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- scripts/ctl/backends/backends.fixes.test.ts | 205 ++++++++++++++++++ scripts/ctl/backends/common.ts | 27 ++- scripts/ctl/backends/launchd.ts | 40 +++- scripts/ctl/backends/systemd.ts | 25 ++- scripts/ctl/backends/unsupervised.test.ts | 96 +++++++++ scripts/ctl/backends/unsupervised.ts | 153 ++++++++++++++ scripts/ctl/backends/windows.ts | 8 +- scripts/ctl/integration.test.ts | 28 ++- scripts/ctl/main.ts | 95 +-------- scripts/ctl/runtime.test.ts | 155 ++++++++++++++ scripts/ctl/runtime.ts | 221 ++++++++++++++++++++ scripts/ctl/verbs-info.ts | 3 +- scripts/ctl/verbs-lifecycle.test.ts | 77 ++++++- scripts/ctl/verbs-lifecycle.ts | 63 ++++-- scripts/ctl/verbs-ops.test.ts | 44 +++- scripts/ctl/verbs-ops.ts | 26 ++- 16 files changed, 1114 insertions(+), 152 deletions(-) create mode 100644 scripts/ctl/backends/backends.fixes.test.ts create mode 100644 scripts/ctl/backends/unsupervised.test.ts create mode 100644 scripts/ctl/backends/unsupervised.ts create mode 100644 scripts/ctl/runtime.test.ts create mode 100644 scripts/ctl/runtime.ts diff --git a/scripts/ctl/backends/backends.fixes.test.ts b/scripts/ctl/backends/backends.fixes.test.ts new file mode 100644 index 00000000..c2166ab9 --- /dev/null +++ b/scripts/ctl/backends/backends.fixes.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "bun:test"; + +import type { Ctx, ShellResult } from "../types.ts"; +import { bunBinary } from "./common.ts"; +import { createLaunchdBackend } from "./launchd.ts"; +import { renderSystemdUnit } from "./systemd.ts"; +import { renderBridgePidQuery } from "./windows.ts"; + +type Call = { command: string; args: readonly string[] }; + +function result(stdout = "", stderr = "", exitCode = 0): ShellResult { + return { stdout, stderr, exitCode }; +} + +function context(responses: readonly ShellResult[] = []): { ctx: Ctx; calls: Call[] } { + const pending = [...responses]; + const calls: Call[] = []; + return { + calls, + ctx: { + configDir: "C:\\Users\\collie\\config", + stateDir: "C:\\Users\\collie\\state", + socketPath: "C:\\Users\\collie\\herdr.sock", + log() {}, + shell: async (command, args = []) => { + calls.push({ command, args }); + return pending.shift() ?? result(); + }, + }, + }; +} + +describe("backend regression fixes", () => { + test("defaults to the current absolute Bun executable", () => { + // Given: no environment override. + const previous = process.env.BUN_BINARY; + try { + delete process.env.BUN_BINARY; + + // When: the backend resolves its Bun executable. + const resolved = bunBinary(undefined); + + // Then: it uses the current Bun process. + expect(resolved).toBe(process.execPath); + } finally { + if (previous === undefined) delete process.env.BUN_BINARY; + else process.env.BUN_BINARY = previous; + } + }); + + test("honors the BUN_BINARY environment override", () => { + // Given: an environment override. + const previous = process.env.BUN_BINARY; + try { + process.env.BUN_BINARY = "env-bun"; + + // When: no explicit backend executable is supplied. + const resolved = bunBinary(undefined); + + // Then: the environment executable wins over the process default. + expect(resolved).toBe("env-bun"); + } finally { + if (previous === undefined) delete process.env.BUN_BINARY; + else process.env.BUN_BINARY = previous; + } + }); + + test("honors an explicit Bun executable over the environment", () => { + // Given: both environment and explicit executable values. + const previous = process.env.BUN_BINARY; + try { + process.env.BUN_BINARY = "env-bun"; + + // When: an explicit backend executable is supplied. + const resolved = bunBinary("explicit-bun"); + + // Then: the explicit value wins. + expect(resolved).toBe("explicit-bun"); + } finally { + if (previous === undefined) delete process.env.BUN_BINARY; + else process.env.BUN_BINARY = previous; + } + }); + + test("quotes systemd paths and environment values without changing their contents", () => { + // Given: service paths containing spaces, backslashes, and double quotes. + const rootDir = String.raw`C:\checkout path\with "quotes"`; + const bun = String.raw`C:\Program Files\Bun "runtime"\bun.exe`; + const configDir = String.raw`C:\config path\value "quoted"`; + const stateDir = String.raw`C:\state path\value "quoted"`; + const socketPath = String.raw`C:\socket path\value "quoted"`; + const { ctx } = context(); + const specialContext: Ctx = { ...ctx, configDir, stateDir, socketPath, rootDir }; + + // When: the systemd unit is rendered. + const unit = renderSystemdUnit(specialContext, { rootDir, bun }); + + // Then: every systemd value is quoted and every embedded slash/quote is escaped. + expect(unit).toContain(String.raw`WorkingDirectory="C:\\checkout path\\with \"quotes\""`); + expect(unit).toContain( + String.raw`ExecStart="C:\\Program Files\\Bun \"runtime\"\\bun.exe" scripts/ctl/main.ts exec-bridge`, + ); + expect(unit).toContain( + String.raw`Environment="HERDR_SOCKET_PATH=C:\\socket path\\value \"quoted\""`, + ); + expect(unit).toContain( + String.raw`Environment="HERDR_PLUGIN_CONFIG_DIR=C:\\config path\\value \"quoted\""`, + ); + expect(unit).toContain( + String.raw`Environment="HERDR_PLUGIN_STATE_DIR=C:\\state path\\value \"quoted\""`, + ); + expect(unit).toContain( + String.raw`EnvironmentFile=-"C:\\config path\\value \"quoted\"\\.env"`, + ); + }); + + test("retries transient launchd bootstrap failures three times", async () => { + // Given: cleanup succeeds, then bootstrap fails twice before succeeding. + const { ctx, calls } = context([ + result(), + result(), + result("", "bootstrap race 1", 5), + result("", "bootstrap race 2", 5), + result(), + ]); + const waits: number[] = []; + const backend = createLaunchdBackend({ + uid: 42, + rootDir: "C:\\checkout", + bun: "bun", + retryWait: async (milliseconds) => { + waits.push(milliseconds); + }, + }); + + // When: launchd start performs bootstrap. + await backend.start(ctx); + + // Then: only the bounded retry waits occur before the successful third attempt. + expect(waits).toEqual([1000, 1000]); + expect(calls.filter((call) => call.args[0] === "bootstrap")).toHaveLength(3); + }); + + test("propagates the final launchd bootstrap error after three failures", async () => { + // Given: every bootstrap attempt fails and the retry waiter is immediate. + const finalFailure = "bootstrap failed for the third time"; + const { ctx, calls } = context([ + result(), + result(), + result("", "bootstrap failed first", 5), + result("", "bootstrap failed second", 5), + result("", finalFailure, 5), + ]); + const waits: number[] = []; + const backend = createLaunchdBackend({ + uid: 42, + retryWait: async (milliseconds) => { + waits.push(milliseconds); + }, + }); + + // When/Then: the final bootstrap diagnostic is not swallowed. + await expect(backend.start(ctx)).rejects.toThrow(finalFailure); + expect(waits).toEqual([1000, 1000]); + expect(calls.filter((call) => call.args[0] === "bootstrap")).toHaveLength(3); + }); + + test("does not retry unexpected launchd bootstrap errors", async () => { + // Given: launchd cannot be spawned at all, which is not a retryable exit status. + const unexpected = new Error("launchctl could not be spawned"); + const { ctx, calls } = context([result(), result()]); + const waits: number[] = []; + const shell = ctx.shell; + ctx.shell = async (command, args = []) => { + if (args[0] === "bootstrap") { + calls.push({ command, args }); + throw unexpected; + } + return await shell(command, args); + }; + const backend = createLaunchdBackend({ + uid: 42, + retryWait: async (milliseconds) => { + waits.push(milliseconds); + }, + }); + + // When/Then: the unexpected error propagates immediately. + await expect(backend.start(ctx)).rejects.toBe(unexpected); + expect(waits).toEqual([]); + expect(calls.filter((call) => call.args[0] === "bootstrap")).toHaveLength(1); + }); + + test("limits Windows force-kill discovery to the absolute bridge entrypoint", () => { + // Given: a checkout root whose bridge entrypoint is the only owned process target. + const query = renderBridgePidQuery("C:\\checkout"); + + // When/Then: the query names bridge/index.ts and has no broad ctl-main fallback. + expect(query).toContain("$bridge = 'C:\\checkout\\bridge\\index.ts'"); + expect(query).toContain("-match $bridgePattern"); + expect(query).not.toContain("scripts/ctl/main.ts"); + expect(query).not.toContain("exec-bridge"); + expect(query).not.toContain("-or"); + }); +}); diff --git a/scripts/ctl/backends/common.ts b/scripts/ctl/backends/common.ts index c36740aa..b125560a 100644 --- a/scripts/ctl/backends/common.ts +++ b/scripts/ctl/backends/common.ts @@ -10,6 +10,20 @@ export type InstalledServiceBackend = ServiceBackend & { /** The checkout containing scripts/ctl when no test or embedding override is supplied. */ export const DEFAULT_CHECKOUT_ROOT = fileURLToPath(new URL("../../../", import.meta.url)); +/** Build an actionable ctl error for a non-zero command result. */ +export function shellFailure( + command: string, + args: readonly string[], + result: ShellResult, +): Error { + const detail = result.stderr.trim() || result.stdout.trim(); + return new Error( + `command failed (${result.exitCode}): ${[command, ...args].join(" ")}${ + detail.length > 0 ? `\n${detail}` : "" + }`, + ); +} + /** Run a command and turn a non-zero result into an actionable ctl error. */ export async function checkedShell( ctx: Ctx, @@ -17,14 +31,7 @@ export async function checkedShell( args: readonly string[] = [], ): Promise { const result = await ctx.shell(command, args); - if (result.exitCode !== 0) { - const detail = result.stderr.trim() || result.stdout.trim(); - throw new Error( - `command failed (${result.exitCode}): ${[command, ...args].join(" ")}${ - detail.length > 0 ? `\n${detail}` : "" - }`, - ); - } + if (result.exitCode !== 0) throw shellFailure(command, args, result); return result; } @@ -58,9 +65,9 @@ export function checkoutRoot(rootDir: string | undefined): string { return rootDir ?? DEFAULT_CHECKOUT_ROOT; } -/** Use a bare Bun command by default, while allowing installations with a non-standard binary. */ +/** Resolve an absolute Bun executable while allowing explicit installation overrides. */ export function bunBinary(binary: string | undefined): string { - return binary ?? process.env.BUN_BINARY ?? "bun"; + return binary ?? process.env.BUN_BINARY ?? process.execPath; } /** Escape a value for use as PowerShell single-quoted string content. */ diff --git a/scripts/ctl/backends/launchd.ts b/scripts/ctl/backends/launchd.ts index 1f35bfe4..b27f8d02 100644 --- a/scripts/ctl/backends/launchd.ts +++ b/scripts/ctl/backends/launchd.ts @@ -7,8 +7,8 @@ import { asInstalledBackend, bestEffortShell, bunBinary, - checkedShell, checkoutRoot, + shellFailure, logLineCount, type BackendFactoryOptions, type InstalledServiceBackend, @@ -17,6 +17,8 @@ import { export const LAUNCHD_AGENT_LABEL = "herdr.collie"; export const AGENT_LABEL = LAUNCHD_AGENT_LABEL; +export type LaunchdRetryWaiter = (milliseconds: number) => Promise; + export interface LaunchdBackendOptions extends BackendFactoryOptions { /** Home directory used for the per-user LaunchAgent, injectable for fixture tests. */ homeDir?: string; @@ -24,6 +26,8 @@ export interface LaunchdBackendOptions extends BackendFactoryOptions { uid?: string | number; /** Agent label, kept configurable for isolated integration fixtures. */ label?: string; + /** Wait between transient bootstrap failures; production waits one second. */ + retryWait?: LaunchdRetryWaiter; } interface ResolvedLaunchdOptions { @@ -32,6 +36,13 @@ interface ResolvedLaunchdOptions { homeDir: string; uid: string; label: string; + retryWait: LaunchdRetryWaiter; +} + +interface LaunchdBootstrapOptions { + domain: string; + agentFile: string; + retryWait: LaunchdRetryWaiter; } function resolvedOptions(options: LaunchdBackendOptions): ResolvedLaunchdOptions { @@ -42,6 +53,7 @@ function resolvedOptions(options: LaunchdBackendOptions): ResolvedLaunchdOptions homeDir: options.homeDir ?? homedir(), uid: String(uid), label: options.label ?? LAUNCHD_AGENT_LABEL, + retryWait: options.retryWait ?? ((milliseconds) => Bun.sleep(milliseconds)), }; } @@ -63,6 +75,25 @@ function xmlEscape(value: string): string { return value.replace(/&/g, "&").replace(//g, ">"); } +const LAUNCHD_BOOTSTRAP_ATTEMPTS = 3; +const LAUNCHD_BOOTSTRAP_RETRY_MS = 1000; + +async function bootstrapLaunchd( + ctx: Ctx, + options: LaunchdBootstrapOptions, +): Promise { + const args = ["bootstrap", options.domain, options.agentFile]; + for (let attempt = 1; attempt <= LAUNCHD_BOOTSTRAP_ATTEMPTS; attempt += 1) { + const result = await ctx.shell("launchctl", args); + if (result.exitCode === 0) return; + if (attempt < LAUNCHD_BOOTSTRAP_ATTEMPTS) { + await options.retryWait(LAUNCHD_BOOTSTRAP_RETRY_MS); + continue; + } + throw shellFailure("launchctl", args, result); + } +} + /** Render the per-user plist parallel to the old launchd agent generated by collie-ctl.sh. */ export function renderLaunchdPlist(ctx: Ctx, options: LaunchdBackendOptions = {}): string { const resolved = resolvedOptions(options); @@ -121,6 +152,11 @@ export function createLaunchdBackend(options: LaunchdBackendOptions = {}): Insta const agentFile = launchdAgentFile(options); const domain = `gui/${resolved.uid}`; const target = `${domain}/${resolved.label}`; + const bootstrapOptions: LaunchdBootstrapOptions = { + domain, + agentFile, + retryWait: resolved.retryWait, + }; return asInstalledBackend({ async install(ctx: Ctx): Promise { @@ -134,7 +170,7 @@ export function createLaunchdBackend(options: LaunchdBackendOptions = {}): Insta // bootout makes start idempotent when an earlier install is still loaded. await bestEffortShell(ctx, "launchctl", ["bootout", target]); await bestEffortShell(ctx, "launchctl", ["enable", target]); - await checkedShell(ctx, "launchctl", ["bootstrap", domain, agentFile]); + await bootstrapLaunchd(ctx, bootstrapOptions); }, async stop(ctx: Ctx): Promise { diff --git a/scripts/ctl/backends/systemd.ts b/scripts/ctl/backends/systemd.ts index b27d934a..3e315921 100644 --- a/scripts/ctl/backends/systemd.ts +++ b/scripts/ctl/backends/systemd.ts @@ -37,6 +37,19 @@ export function systemdUnitFile(options: SystemdBackendOptions = {}): string { return join(resolved.homeDir, ".config", "systemd", "user", `${resolved.unitName}.service`); } +function systemdLiteral(value: string): string { + return `"${value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\\"') + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t")}"`; +} + +function systemdEnvironment(name: string, value: string): string { + return `Environment=${systemdLiteral(`${name}=${value}`)}`; +} + /** Render the generated systemd --user unit, preserving collie-ctl.sh's service policy. */ export function renderSystemdUnit(ctx: Ctx, options: SystemdBackendOptions = {}): string { const resolved = resolvedOptions(options); @@ -49,18 +62,18 @@ StartLimitIntervalSec=0 [Service] Type=simple -WorkingDirectory=${rootDir} -ExecStart=${resolved.bun} scripts/ctl/main.ts exec-bridge +WorkingDirectory=${systemdLiteral(rootDir)} +ExecStart=${systemdLiteral(resolved.bun)} scripts/ctl/main.ts exec-bridge Restart=on-failure RestartSec=5 # Keep the remote shell bridge unprivileged and isolate its temporary files. NoNewPrivileges=yes PrivateTmp=yes -Environment=HERDR_SOCKET_PATH=${ctx.socketPath} +${systemdEnvironment("HERDR_SOCKET_PATH", ctx.socketPath)} Environment=COLLIE_PORT=8787 -Environment=HERDR_PLUGIN_CONFIG_DIR=${ctx.configDir} -Environment=HERDR_PLUGIN_STATE_DIR=${ctx.stateDir} -EnvironmentFile=-${join(ctx.configDir, ".env")} +${systemdEnvironment("HERDR_PLUGIN_CONFIG_DIR", ctx.configDir)} +${systemdEnvironment("HERDR_PLUGIN_STATE_DIR", ctx.stateDir)} +EnvironmentFile=-${systemdLiteral(join(ctx.configDir, ".env"))} [Install] WantedBy=default.target diff --git a/scripts/ctl/backends/unsupervised.test.ts b/scripts/ctl/backends/unsupervised.test.ts new file mode 100644 index 00000000..a3529a5a --- /dev/null +++ b/scripts/ctl/backends/unsupervised.test.ts @@ -0,0 +1,96 @@ +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import { + createUnsupervisedBackend, + type DetachedSpawn, +} from "./unsupervised.ts"; +import type { Ctx } from "../types.ts"; + +async function fixture(): Promise<{ readonly root: string; readonly ctx: Ctx }> { + const root = await mkdtemp(join(tmpdir(), "collie-unsupervised-")); + const configDir = join(root, "config"); + const stateDir = join(root, "state"); + await mkdir(configDir, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + return { + root, + ctx: { + rootDir: root, + configDir, + stateDir, + socketPath: join(root, "herdr.sock"), + log() {}, + shell: async () => ({ stdout: "", stderr: "", exitCode: 0 }), + }, + }; +} + +describe("unsupervised fallback backend", () => { + test("starts the real ctl entrypoint detached and records its process group", async () => { + const { root, ctx } = await fixture(); + try { + const calls: Array<{ + readonly argv: readonly string[]; + readonly detached: boolean; + }> = []; + let unrefCount = 0; + const spawn: DetachedSpawn = (argv, options) => { + calls.push({ argv, detached: options.detached }); + return { + pid: 421, + unref() { + unrefCount += 1; + }, + }; + }; + const backend = createUnsupervisedBackend({ + rootDir: root, + bun: "absolute-bun", + spawn, + }); + + await backend.start(ctx); + + expect(calls).toEqual([ + { + argv: [ + "absolute-bun", + join(root, "scripts", "ctl", "main.ts"), + "exec-bridge", + ], + detached: true, + }, + ]); + expect(unrefCount).toBe(1); + expect(await readFile(join(ctx.configDir, "collie.pid"), "utf8")).toBe("421\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("stops the recorded process group and removes its pidfile", async () => { + const { root, ctx } = await fixture(); + try { + const signals: Array<{ readonly pid: number; readonly signal: number | string }> = []; + const backend = createUnsupervisedBackend({ + rootDir: root, + spawn: () => ({ pid: 421, unref() {} }), + kill: (pid, signal) => { + signals.push({ pid, signal }); + }, + }); + await backend.start(ctx); + + await backend.stop(ctx); + + expect(signals).toContainEqual({ pid: -421, signal: "SIGTERM" }); + expect(await Bun.file(join(ctx.configDir, "collie.pid")).exists()).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/ctl/backends/unsupervised.ts b/scripts/ctl/backends/unsupervised.ts new file mode 100644 index 00000000..5be2212c --- /dev/null +++ b/scripts/ctl/backends/unsupervised.ts @@ -0,0 +1,153 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { Ctx, ShellCommand } from "../types.ts"; +import { + asInstalledBackend, + bunBinary, + checkoutRoot, + tailCommand, + type BackendFactoryOptions, + type InstalledServiceBackend, +} from "./common.ts"; + +const PIDFILE = "collie.pid"; + +export interface DetachedProcess { + readonly pid: number; + unref(): void; +} + +export interface DetachedSpawnOptions { + readonly cwd: string; + readonly detached: true; +} + +export type DetachedSpawn = ( + argv: readonly string[], + options: DetachedSpawnOptions, +) => DetachedProcess; + +export type ProcessKiller = ( + pid: number, + signal: NodeJS.Signals | 0, +) => void; + +export interface UnsupervisedBackendOptions extends BackendFactoryOptions { + readonly spawn?: DetachedSpawn; + readonly kill?: ProcessKiller; +} + +function defaultSpawn( + argv: readonly string[], + options: DetachedSpawnOptions, +): DetachedProcess { + const child = Bun.spawn([...argv], { + cwd: options.cwd, + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + return { + pid: child.pid, + unref() { + child.unref(); + }, + }; +} + +function parsePid(text: string): number | undefined { + const value = text.trim(); + if (!/^[1-9]\d*$/.test(value)) return undefined; + const pid = Number(value); + return Number.isSafeInteger(pid) ? pid : undefined; +} + +function isMissingProcess(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + error.code === "ESRCH" + ); +} + +export function createUnsupervisedBackend( + options: UnsupervisedBackendOptions = {}, +): InstalledServiceBackend { + const root = checkoutRoot(options.rootDir); + const bun = bunBinary(options.bun); + const spawn = options.spawn ?? defaultSpawn; + const kill = options.kill ?? process.kill; + + const pidFile = (ctx: Ctx): string => join(ctx.configDir, PIDFILE); + + const stop = async (ctx: Ctx): Promise => { + let pid: number | undefined; + try { + pid = parsePid(await readFile(pidFile(ctx), "utf8")); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + throw error; + } + } + if (pid !== undefined) { + try { + kill(-pid, "SIGTERM"); + } catch (error) { + if (!isMissingProcess(error)) throw error; + } + } + await rm(pidFile(ctx), { force: true }); + }; + + return asInstalledBackend({ + async install(): Promise {}, + + async start(ctx: Ctx): Promise { + await stop(ctx); + await mkdir(ctx.configDir, { recursive: true }); + const currentRoot = ctx.rootDir ?? root; + const child = spawn( + [ + bun, + join(currentRoot, "scripts", "ctl", "main.ts"), + "exec-bridge", + ], + { cwd: currentRoot, detached: true }, + ); + await writeFile(pidFile(ctx), `${child.pid}\n`, "utf8"); + child.unref(); + ctx.log("bridge started (unsupervised fallback)"); + }, + + stop, + + async uninstall(ctx: Ctx): Promise { + await stop(ctx); + }, + + async isActive(ctx: Ctx): Promise { + try { + const pid = parsePid(await readFile(pidFile(ctx), "utf8")); + if (pid === undefined) return false; + kill(pid, 0); + return true; + } catch (error) { + if ( + isMissingProcess(error) || + (error instanceof Error && "code" in error && error.code === "ENOENT") + ) { + return false; + } + throw error; + } + }, + + logsCmd(ctx: Ctx, lines?: number): ShellCommand { + return tailCommand(join(ctx.stateDir, "collie.log"), lines); + }, + }); +} + +export const unsupervisedBackend = createUnsupervisedBackend(); diff --git a/scripts/ctl/backends/windows.ts b/scripts/ctl/backends/windows.ts index 9c5d26a4..892c82a1 100644 --- a/scripts/ctl/backends/windows.ts +++ b/scripts/ctl/backends/windows.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { join, resolve } from "node:path"; import type { Ctx, ShellCommand } from "../types.ts"; import { @@ -91,9 +91,11 @@ export function renderTaskRegistration(ctx: Ctx, options: WindowsBackendOptions /** Render the bounded process query used by Windows stop's force-kill fallback. */ export function renderBridgePidQuery(rootDir: string): string { + const bridgePath = resolve(rootDir, "bridge", "index.ts"); return [ - `$root = ${powershellLiteral(rootDir)}`, - `Get-CimInstance Win32_Process -Filter ${powershellLiteral("Name = 'bun.exe'")} | Where-Object { $_.CommandLine -and $_.CommandLine.Contains('exec-bridge') -and ($_.CommandLine.Contains($root) -or $_.CommandLine.Contains('scripts/ctl/main.ts')) } | Select-Object -ExpandProperty ProcessId`, + `$bridge = ${powershellLiteral(bridgePath)}`, + "$bridgePattern = '(?i)(?:^|\\s|\")' + [regex]::Escape($bridge) + '(?=$|\\s|\")'", + `Get-CimInstance Win32_Process -Filter ${powershellLiteral("Name = 'bun.exe'")} | Where-Object { $_.CommandLine -and $_.CommandLine -match $bridgePattern } | Select-Object -ExpandProperty ProcessId`, ].join("; "); } diff --git a/scripts/ctl/integration.test.ts b/scripts/ctl/integration.test.ts index cb83fabe..618f07d9 100644 --- a/scripts/ctl/integration.test.ts +++ b/scripts/ctl/integration.test.ts @@ -160,6 +160,12 @@ function lifecycleDeps( rebuild: async () => { options.events.push("rebuild"); }, + refreshRegistry: async () => { + options.events.push("refresh-registry"); + }, + serve: async () => { + options.events.push("serve"); + }, unserve: async () => { options.events.push("unserve"); }, @@ -257,7 +263,13 @@ async function exerciseHappy(verb: RoutedVerb, value: Fixture): Promise { await dispatchInjected(value.ctx, verb, [], { lifecycle: lifecycleDeps(value, { events }), }); - expect(events).toEqual(["ensure-build", "install", "start", "ready:8787"]); + expect(events).toEqual([ + "ensure-build", + "install", + "start", + "ready:8787", + "serve", + ]); expect(loggedText(value)).toContain("https://collie.example"); return; } @@ -308,7 +320,10 @@ async function exerciseHappy(verb: RoutedVerb, value: Fixture): Promise { if (command === "rev-parse --abbrev-ref --symbolic-full-name @{u}") { return result(0, "origin/main\n"); } - if (command === "show origin/main:herdr-plugin.toml") { + if (command === "rev-parse origin/main^{commit}") { + return result(0, "target-commit\n"); + } + if (command === "show target-commit:herdr-plugin.toml") { return result(0, 'version = "0.32.1"\n'); } return result(); @@ -317,8 +332,8 @@ async function exerciseHappy(verb: RoutedVerb, value: Fixture): Promise { await dispatchInjected(value.ctx, verb, [], { lifecycle: deps }); expect(gitCalls).toContainEqual(["fetch", "origin"]); - expect(gitCalls).toContainEqual(["pull", "--ff-only"]); - expect(events).toEqual(["rebuild", "stop", "start"]); + expect(gitCalls).toContainEqual(["merge", "--ff-only", "target-commit"]); + expect(events).toEqual(["rebuild", "stop", "start", "refresh-registry"]); expect(loggedText(value)).toContain("update complete"); return; } @@ -483,7 +498,10 @@ async function exerciseHappy(verb: RoutedVerb, value: Fixture): Promise { }, }; await dispatchInjected(value.ctx, verb, [], { ops: options }); - expect(argvSeen).toEqual(["fake-bun", "bridge/index.ts"]); + expect(argvSeen).toEqual([ + "fake-bun", + join(value.root, "bridge", "index.ts"), + ]); return; } case "apply-update": { diff --git a/scripts/ctl/main.ts b/scripts/ctl/main.ts index fea040b3..9974754f 100644 --- a/scripts/ctl/main.ts +++ b/scripts/ctl/main.ts @@ -1,8 +1,8 @@ import { homedir } from "node:os"; import { join } from "node:path"; -import type { Ctx, ShellOptions, ShellResult, Verb, VerbHandler } from "./types.ts"; -import { selectBackendName, waitForTcpReadiness } from "./types.ts"; +import { handlers } from "./runtime.ts"; +import type { Ctx, ShellOptions, ShellResult, Verb } from "./types.ts"; export { hasLaunchd, @@ -13,13 +13,6 @@ export { selectBackendName, waitForTcpReadiness, } from "./types.ts"; -import * as lifecycle from "./verbs-lifecycle.ts"; -import * as info from "./verbs-info.ts"; -import * as ops from "./verbs-ops.ts"; -import type { InstalledServiceBackend } from "./backends/common.ts"; -import { windowsBackend } from "./backends/windows.ts"; -import { systemdBackend } from "./backends/systemd.ts"; -import { launchdBackend } from "./backends/launchd.ts"; /** User-facing verbs handled by ctl. */ export const PUBLIC_VERBS = [ @@ -127,90 +120,6 @@ export function createContext(): Ctx { }; } -/** - * The supervisor backend for THIS machine, or undefined when no supported one is present. - * - * Selection order matches the shell original: systemd first (servers), then launchd (macOS), - * then the Windows Task Scheduler. - */ -function defaultBackend(): InstalledServiceBackend | undefined { - switch (selectBackendName()) { - case "windows-task": - return windowsBackend; - case "systemd": - return systemdBackend; - case "launchd": - return launchdBackend; - default: - return undefined; - } -} - -/** The backend the lifecycle verbs require — every ctl host runs exactly one supported supervisor. */ -function requireBackend(): InstalledServiceBackend { - const backend = defaultBackend(); - if (backend === undefined) { - throw new Error( - "no supported service supervisor found (systemd, launchd, or Windows Task Scheduler)", - ); - } - return backend; -} - -/** Operational seams shared by the lifecycle verbs; build doubles as rebuild by design. */ -const lifecycleOps = { - build: (ctx: Ctx) => ops.build(ctx), - rebuild: (ctx: Ctx) => ops.build(ctx), - unserve: (ctx: Ctx) => ops.unserve(ctx), -}; - -/** Info-verb dependencies wired to the real backend when one exists, graceful otherwise. */ -function infoDeps(ctx: Ctx): info.InfoDeps { - const backend = defaultBackend(); - if (backend === undefined) return {}; - return { - backend: { - isActive: () => backend.isActive(ctx), - logsCmd: async (lines?: number) => { - const command = backend.logsCmd(ctx, lines); - return [command.command, ...command.args].join(" "); - }, - }, - }; -} - -const handlers: Record = { - start: (ctx) => - lifecycle.start(ctx, { - backend: requireBackend(), - ops: lifecycleOps, - ensureBuild: (c) => lifecycle.ensureBuild(c, requireBackend(), lifecycleOps), - waitForReadiness: (port: number, options?: lifecycle.ReadinessOptions) => - waitForTcpReadiness(port, options), - }), - stop: (ctx) => lifecycle.stop(ctx, requireBackend()), - restart: (ctx) => lifecycle.restart(ctx, requireBackend()), - uninstall: (ctx) => lifecycle.uninstall(ctx, requireBackend()), - update: (ctx, args) => lifecycle.update(ctx, args, { backend: requireBackend(), ...lifecycleOps }), - build: (ctx) => ops.build(ctx), - serve: (ctx) => ops.serve(ctx), - unserve: (ctx) => ops.unserve(ctx), - status: async (ctx) => ctx.log(await info.status(ctx, infoDeps(ctx))), - url: async (ctx) => ctx.log(await info.url(ctx, infoDeps(ctx))), - version: async (ctx) => ctx.log(await info.version()), - qr: async (ctx) => ctx.log(await info.qr(ctx, infoDeps(ctx))), - logs: async (ctx, args) => ctx.log(await info.logs(ctx, infoDeps(ctx), Number(args[0]) || 50)), - "push-keys": (ctx, args) => ops.pushKeys(ctx, args), - "push-test": (ctx, args) => ops.pushTest(ctx, args), - "exec-bridge": (ctx) => ops.execBridge(ctx), - // The shell implementation re-executed itself post-pull because bash could not resume cleanly; - // this implementation performs the rebuild+restart inline inside `update`, so the internal verb - // only exists to fail informatively if a stale service definition still invokes it. - "apply-update": async () => { - throw new Error("apply-update is folded into 'update' by this implementation"); - }, -}; - function isVerb(value: string | undefined): value is Verb { return value !== undefined && ALL_VERBS.some((candidate) => candidate === value); } diff --git a/scripts/ctl/runtime.test.ts b/scripts/ctl/runtime.test.ts new file mode 100644 index 00000000..79578046 --- /dev/null +++ b/scripts/ctl/runtime.test.ts @@ -0,0 +1,155 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, test } from "bun:test"; + +import type { InstalledServiceBackend } from "./backends/common.ts"; +import { + backendForName, + createHandlers, + defaultRuntimeDependencies, + type RuntimeDependencies, +} from "./runtime.ts"; +import type { Ctx, ShellCommand, ShellResult } from "./types.ts"; + +type Fixture = { + readonly root: string; + readonly calls: string[]; + readonly output: string[]; + readonly ctx: Ctx; +}; + +async function fixture(name: string): Promise { + const root = await mkdtemp(join(tmpdir(), `collie-runtime-${name}-`)); + const configDir = join(root, "config"); + const stateDir = join(root, "state"); + await mkdir(configDir, { recursive: true }); + await mkdir(stateDir, { recursive: true }); + const calls: string[] = []; + const output: string[] = []; + return { + root, + calls, + output, + ctx: { + rootDir: root, + configDir, + stateDir, + socketPath: join(root, "herdr.sock"), + log: (...args) => output.push(args.map(String).join(" ")), + shell: async (command, args = []): Promise => { + calls.push([command, ...args].join(" ")); + return { stdout: "actual log line\n", stderr: "", exitCode: 0 }; + }, + }, + }; +} + +function backend(calls: string[]): InstalledServiceBackend { + return { + async install() { + calls.push("install"); + }, + async start() { + calls.push("backend-start"); + }, + async stop() { + calls.push("backend-stop"); + }, + async uninstall() { + calls.push("backend-uninstall"); + }, + async isActive() { + return true; + }, + logsCmd(): ShellCommand { + return { command: "log-reader", args: ["--lines", "7"] }; + }, + }; +} + +function dependencies(value: Fixture): RuntimeDependencies { + return { + ...defaultRuntimeDependencies, + backend: () => backend(value.calls), + backendKind: () => "systemd", + waitForReadiness: async () => true, + ops: { + ...defaultRuntimeDependencies.ops, + build: async () => { + value.calls.push("build"); + }, + serve: async () => { + value.calls.push("serve"); + }, + unserve: async () => { + value.calls.push("unserve"); + }, + }, + }; +} + +async function withFixture( + name: string, + run: (value: Fixture) => Promise, +): Promise { + const value = await fixture(name); + try { + await run(value); + } finally { + await rm(value.root, { recursive: true, force: true }); + } +} + +describe("real ctl runtime wiring", () => { + test("uses the unsupervised backend when no user supervisor is available", async () => { + await withFixture("fallback", async (value) => { + const fallback = backendForName(undefined); + + expect(fallback.logsCmd(value.ctx, 3).command).toBe("tail"); + }); + }); + + test("start publishes the managed front door after readiness", async () => { + await withFixture("start", async (value) => { + await createHandlers(dependencies(value)).start(value.ctx, []); + + expect(value.calls).toEqual(["build", "install", "backend-start", "serve"]); + }); + }); + + test("uninstall removes the managed front door before registration", async () => { + await withFixture("uninstall", async (value) => { + await createHandlers(dependencies(value)).uninstall(value.ctx, []); + + expect(value.calls).toEqual([ + "backend-stop", + "unserve", + "backend-uninstall", + ]); + }); + }); + + test("logs executes the backend reader and prints its output", async () => { + await withFixture("logs", async (value) => { + await createHandlers(dependencies(value)).logs(value.ctx, ["7"]); + + expect(value.calls).toContain("log-reader --lines 7"); + expect(value.output).toEqual(["actual log line\n"]); + }); + }); + + test("url uses the configured Variant E public URL", async () => { + await withFixture("url", async (value) => { + const ctx: Ctx = { + ...value.ctx, + env: { COLLIE_PUBLIC_URL: "https://collie.example" }, + }; + + await createHandlers(dependencies(value)).url(ctx, []); + + expect(value.output).toEqual(["https://collie.example"]); + }); + }); +}); diff --git a/scripts/ctl/runtime.ts b/scripts/ctl/runtime.ts new file mode 100644 index 00000000..b098ea14 --- /dev/null +++ b/scripts/ctl/runtime.ts @@ -0,0 +1,221 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { InstalledServiceBackend } from "./backends/common.ts"; +import { checkedShell } from "./backends/common.ts"; +import { launchdBackend } from "./backends/launchd.ts"; +import { systemdBackend } from "./backends/systemd.ts"; +import { unsupervisedBackend } from "./backends/unsupervised.ts"; +import { windowsBackend } from "./backends/windows.ts"; +import * as info from "./verbs-info.ts"; +import * as lifecycle from "./verbs-lifecycle.ts"; +import * as ops from "./verbs-ops.ts"; +import type { BackendName, Ctx, Verb, VerbHandler } from "./types.ts"; +import { selectBackendName, waitForTcpReadiness } from "./types.ts"; + +export interface RuntimeDependencies { + readonly backend: () => InstalledServiceBackend | undefined; + readonly backendKind: () => info.BackendKind | undefined; + readonly waitForReadiness: typeof waitForTcpReadiness; + readonly lifecycle: Pick< + typeof lifecycle, + "ensureBuild" | "restart" | "start" | "stop" | "uninstall" | "update" + >; + readonly info: Pick; + readonly ops: Pick< + typeof ops, + | "build" + | "execBridge" + | "pushKeys" + | "pushTest" + | "refreshRegistry" + | "serve" + | "unserve" + >; +} + +function defaultBackendKind(): info.BackendKind | undefined { + switch (selectBackendName()) { + case "windows-task": + return "windows"; + case "systemd": + return "systemd"; + case "launchd": + return "launchd"; + default: + return undefined; + } +} + +export function backendForName( + name: BackendName | undefined, +): InstalledServiceBackend { + switch (name) { + case "windows-task": + return windowsBackend; + case "systemd": + return systemdBackend; + case "launchd": + return launchdBackend; + default: + return unsupervisedBackend; + } +} + +function defaultBackend(): InstalledServiceBackend { + return backendForName(selectBackendName()); +} + +export const defaultRuntimeDependencies: RuntimeDependencies = { + backend: defaultBackend, + backendKind: defaultBackendKind, + waitForReadiness: waitForTcpReadiness, + lifecycle, + info, + ops, +}; + +function requireBackend(dependencies: RuntimeDependencies): InstalledServiceBackend { + const backend = dependencies.backend(); + if (backend === undefined) { + throw new Error( + "no supported service supervisor found (systemd, launchd, or Windows Task Scheduler)", + ); + } + return backend; +} + +function lifecycleOps(dependencies: RuntimeDependencies): lifecycle.LifecycleOps { + return { + build: (ctx) => dependencies.ops.build(ctx), + rebuild: (ctx) => dependencies.ops.build(ctx), + refreshRegistry: (ctx) => dependencies.ops.refreshRegistry(ctx), + serve: (ctx) => dependencies.ops.serve(ctx), + unserve: (ctx) => dependencies.ops.unserve(ctx), + }; +} + +function parseConfiguredUrl(text: string): string | undefined { + for (const line of text.split(/\r?\n/)) { + const match = /^(?:export\s+)?COLLIE_PUBLIC_URL\s*=\s*(.*)$/.exec(line.trim()); + if (match === null) continue; + const raw = match[1]?.trim() ?? ""; + const value = + raw.length >= 2 && + ((raw.startsWith('"') && raw.endsWith('"')) || + (raw.startsWith("'") && raw.endsWith("'"))) + ? raw.slice(1, -1) + : raw; + return value.trim() || undefined; + } + return undefined; +} + +function isMissingFileError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ); +} + +async function configuredPublicUrl(ctx: Ctx): Promise { + const direct = ctx.env?.COLLIE_PUBLIC_URL ?? process.env.COLLIE_PUBLIC_URL; + if (direct?.trim()) return direct.trim(); + try { + return parseConfiguredUrl(await readFile(join(ctx.configDir, ".env"), "utf8")); + } catch (error) { + if (isMissingFileError(error)) return undefined; + throw error; + } +} + +async function infoDeps( + ctx: Ctx, + dependencies: RuntimeDependencies, +): Promise { + const backend = dependencies.backend(); + const publicUrl = await configuredPublicUrl(ctx); + if (backend === undefined) { + return publicUrl === undefined ? {} : { publicUrl }; + } + return { + backend: { + kind: dependencies.backendKind(), + isActive: () => backend.isActive(ctx), + logsCmd: async (lines) => { + const command = backend.logsCmd(ctx, lines); + return (await checkedShell(ctx, command.command, command.args)).stdout; + }, + }, + ...(publicUrl === undefined ? {} : { publicUrl }), + }; +} + +export function createHandlers( + dependencies: RuntimeDependencies = defaultRuntimeDependencies, +): Readonly> { + const operations = lifecycleOps(dependencies); + return { + start: (ctx) => + dependencies.lifecycle.start(ctx, { + backend: requireBackend(dependencies), + ops: operations, + ensureBuild: (current) => + dependencies.lifecycle.ensureBuild( + current, + requireBackend(dependencies), + operations, + ), + waitForReadiness: dependencies.waitForReadiness, + }), + stop: (ctx) => dependencies.lifecycle.stop(ctx, requireBackend(dependencies)), + restart: (ctx) => dependencies.lifecycle.restart(ctx, requireBackend(dependencies)), + uninstall: (ctx) => + dependencies.lifecycle.uninstall( + ctx, + requireBackend(dependencies), + operations, + ), + update: (ctx, args) => + dependencies.lifecycle.update(ctx, args, { + backend: requireBackend(dependencies), + ...operations, + }), + build: (ctx) => dependencies.ops.build(ctx), + serve: (ctx) => dependencies.ops.serve(ctx), + unserve: (ctx) => dependencies.ops.unserve(ctx), + status: async (ctx) => + ctx.log( + await dependencies.info.status( + ctx, + await infoDeps(ctx, dependencies), + ), + ), + url: async (ctx) => + ctx.log( + await dependencies.info.url(ctx, await infoDeps(ctx, dependencies)), + ), + version: async (ctx) => ctx.log(await dependencies.info.version()), + qr: async (ctx) => + ctx.log( + await dependencies.info.qr(ctx, await infoDeps(ctx, dependencies)), + ), + logs: async (ctx, args) => + ctx.log( + await dependencies.info.logs( + ctx, + await infoDeps(ctx, dependencies), + Number(args[0]) || 50, + ), + ), + "push-keys": (ctx, args) => dependencies.ops.pushKeys(ctx, args), + "push-test": (ctx, args) => dependencies.ops.pushTest(ctx, args), + "exec-bridge": (ctx) => dependencies.ops.execBridge(ctx), + "apply-update": async () => { + throw new Error("apply-update is folded into 'update' by this implementation"); + }, + }; +} + +export const handlers = createHandlers(); diff --git a/scripts/ctl/verbs-info.ts b/scripts/ctl/verbs-info.ts index 7574131a..6c88673a 100644 --- a/scripts/ctl/verbs-info.ts +++ b/scripts/ctl/verbs-info.ts @@ -196,7 +196,8 @@ export async function status(ctx: CtlCtx, deps: InfoDeps = {}): Promise export async function url(ctx: CtlCtx, deps: InfoDeps = {}): Promise { const resolved = resolveDeps(deps); - const serve = await readServeState(ctx, resolved, false); + const serve = await readServeState(ctx, resolved, true); + if (serve.kind === "skipped") return serve.publicUrl; if (serve.kind === "mapped") return serveUrl(serve.record); if (serve.kind === "invalid") throw new Error("invalid Collie-managed tailscale serve mapping"); throw new Error("no Collie-managed tailscale serve mapping found"); diff --git a/scripts/ctl/verbs-lifecycle.test.ts b/scripts/ctl/verbs-lifecycle.test.ts index e1be9ac9..11ff5156 100644 --- a/scripts/ctl/verbs-lifecycle.test.ts +++ b/scripts/ctl/verbs-lifecycle.test.ts @@ -70,6 +70,12 @@ function deps( build: async () => { calls.push("build"); }, + refreshRegistry: async () => { + calls.push("refresh-registry"); + }, + serve: async () => { + calls.push("serve"); + }, unserve: async () => { calls.push("unserve"); }, @@ -126,13 +132,41 @@ describe("ctl lifecycle verbs", () => { await start(loggedCtx, lifecycle); - expect(calls).toEqual(["ensure-build", "install", "start", "ready:8787"]); + expect(calls).toEqual(["ensure-build", "install", "start", "ready:8787", "serve"]); expect(output.flat().join(" ")).toContain("https://collie.example"); } finally { await clean(root); } }); + test("start keeps the ready bridge running when front-door publication fails", async () => { + const { root, ctx } = await fixture("start-serve-failure"); + try { + const calls: string[] = []; + const output: unknown[][] = []; + const loggedCtx: Ctx = { ...ctx, log: (...args) => output.push(args) }; + const lifecycle = deps(loggedCtx, calls, { + ops: { + ensureBuild: async () => { + calls.push("ensure-build"); + }, + serve: async () => { + calls.push("serve"); + throw new Error("tailscale unavailable"); + }, + }, + }); + + await start(loggedCtx, lifecycle); + + expect(calls).toEqual(["ensure-build", "install", "start", "ready:8787", "serve"]); + expect(output.flat().join(" ")).toContain("tailscale unavailable"); + expect(output.flat().join(" ")).toContain("http://127.0.0.1:8787"); + } finally { + await clean(root); + } + }); + test("stop and restart delegate to the backend without reinstalling it", async () => { const { root, ctx } = await fixture("restart"); try { @@ -183,7 +217,7 @@ describe("ctl lifecycle verbs", () => { } }); - test("advances a linked clone with fetch plus ff-only pull, then rebuilds and restarts", async () => { + test("advances a linked clone to the pinned upstream commit, then rebuilds and restarts", async () => { const { root, ctx } = await fixture("linked"); try { const origin = await originRepo(root, "1.0.0"); @@ -206,13 +240,14 @@ describe("ctl lifecycle verbs", () => { expect(calls).toContain("build"); expect(calls).toContain("stop"); expect(calls).toContain("start"); + expect(calls).toContain("refresh-registry"); expect((await git(clone, ["symbolic-ref", "--short", "HEAD"])).trim()).toBe("main"); } finally { await clean(root); } }); - test("advances a detached checkout by fetching and checking out FETCH_HEAD", async () => { + test("refuses a detached checkout when the remote has no release tags", async () => { const { root, ctx } = await fixture("detached"); try { const origin = await originRepo(root, "1.0.0"); @@ -228,16 +263,46 @@ describe("ctl lifecycle verbs", () => { const calls: string[] = []; const lifecycle = deps({ ...ctx, rootDir: managed }, calls, { rootDir: managed }); - await update({ ...ctx, rootDir: managed }, lifecycle); + const before = (await git(managed, ["rev-parse", "HEAD"])).trim(); - expect(await readFile(join(managed, "VERSION"), "utf8")).toBe("1.0.1"); - expect(calls).toContain("build"); + await expect(update({ ...ctx, rootDir: managed }, lifecycle)).rejects.toThrow( + "no release tags", + ); + + expect((await git(managed, ["rev-parse", "HEAD"])).trim()).toBe(before); + expect(await readFile(join(managed, "VERSION"), "utf8")).toBe("1.0.0"); + expect(calls).not.toContain("build"); expect((await git(managed, ["symbolic-ref", "-q", "HEAD"], 1)).trim()).toBe(""); } finally { await clean(root); } }); + test("refuses a linked update when the target manifest version is unreadable", async () => { + const { root, ctx } = await fixture("linked-invalid-version"); + try { + const origin = await originRepo(root, "1.0.0"); + const clone = join(root, "clone"); + const cloneResult = await runShell("git", ["clone", "-q", origin, clone]); + expect(cloneResult.exitCode, cloneResult.stderr).toBe(0); + await writeFile(join(origin, "VERSION"), "untrusted", "utf8"); + await writeFile(join(origin, "herdr-plugin.toml"), "name = \"missing-version\"\n", "utf8"); + await commit(origin, "invalid manifest"); + const before = (await git(clone, ["rev-parse", "HEAD"])).trim(); + const calls: string[] = []; + const lifecycle = deps({ ...ctx, rootDir: clone }, calls, { rootDir: clone }); + + await expect(update({ ...ctx, rootDir: clone }, lifecycle)).rejects.toThrow( + "cannot verify target version", + ); + + expect((await git(clone, ["rev-parse", "HEAD"])).trim()).toBe(before); + expect(calls).not.toContain("build"); + } finally { + await clean(root); + } + }); + test("follows detached release tags within the installed major and crosses one major per flag", async () => { const { root, ctx } = await fixture("detached-tags"); try { diff --git a/scripts/ctl/verbs-lifecycle.ts b/scripts/ctl/verbs-lifecycle.ts index 84596ff7..b3fff75e 100644 --- a/scripts/ctl/verbs-lifecycle.ts +++ b/scripts/ctl/verbs-lifecycle.ts @@ -16,10 +16,14 @@ export interface LifecycleOps { build?: (ctx: Ctx) => Promise; /** Optional explicit rebuild alias for callers that distinguish build from rebuild. */ rebuild?: (ctx: Ctx) => Promise; + /** Refresh Herdr's registry after a linked checkout changes. */ + refreshRegistry?: (ctx: Ctx) => Promise; /** Optional first-run build operation. */ ensureBuild?: (ctx: Ctx) => Promise; /** Compatibility spelling for callers porting the shell verb literally. */ ensure_build?: (ctx: Ctx) => Promise; + /** Publish Collie's managed front door after the bridge becomes ready. */ + serve?: (ctx: Ctx) => Promise; /** Remove only Collie's recorded Tailscale mapping. */ unserve?: (ctx: Ctx) => Promise; } @@ -59,8 +63,10 @@ export interface LifecycleDeps { /** Flat aliases are accepted for callers that inject the verbs-ops exports directly. */ build?: LifecycleOps["build"]; rebuild?: LifecycleOps["rebuild"]; + refreshRegistry?: LifecycleOps["refreshRegistry"]; ensureBuild?: LifecycleOps["ensureBuild"]; ensure_build?: LifecycleOps["ensure_build"]; + serve?: LifecycleOps["serve"]; unserve?: LifecycleOps["unserve"]; rootDir?: string; env?: Record; @@ -133,8 +139,10 @@ function normalizeDeps(deps: LifecycleDeps): LifecycleDeps { const direct: LifecycleOps = { build: deps.build, rebuild: deps.rebuild, + refreshRegistry: deps.refreshRegistry, ensureBuild: deps.ensureBuild, ensure_build: deps.ensure_build, + serve: deps.serve, unserve: deps.unserve, }; const hasDirect = Object.values(direct).some((operation) => operation !== undefined); @@ -392,6 +400,17 @@ async function printReadyUrl( ctx.log(`open: ${await resolveUrl(ctx, deps, environment, port)}`); } +async function publishFrontDoor(ctx: Ctx, deps: LifecycleDeps): Promise { + const publish = deps.ops?.serve; + if (publish === undefined) return; + try { + await publish(operationContext(ctx, deps)); + } catch (error) { + if (!(error instanceof Error)) throw error; + ctx.log(`note: the front door did not come up (${error.message}); the bridge is still on loopback`); + } +} + /** Start the bridge: first-run build, service registration, service start, readiness, and URL. */ export function start( ctx: Ctx, @@ -416,6 +435,7 @@ export async function start( if (!(await waitUntilReady(deps, port))) { throw new Error(`bridge did not become ready on 127.0.0.1:${port}`); } + await publishFrontDoor(ctx, deps); await printReadyUrl(ctx, deps, environment, port); } @@ -647,23 +667,28 @@ async function updateLinked( "@{u}", ]); const ref = upstream.exitCode === 0 ? upstream.stdout.trim() : ""; - if (ref && !wantsMajor(args)) { - const targetVersion = await manifestAtRef(ctx, deps, root, ref); - const installedMajor = majorOf(installed); - const targetMajor = majorOf(targetVersion); - if ( - installedMajor !== undefined && - targetMajor !== undefined && - targetMajor > installedMajor - ) { + if (ref === "") { + throw new Error("cannot verify update target: linked checkout has no upstream"); + } + const targetCommit = ( + await checkedGit(ctx, deps, root, ["rev-parse", `${ref}^{commit}`]) + ).stdout.trim(); + const targetVersion = await manifestAtRef(ctx, deps, root, targetCommit); + const installedMajor = majorOf(installed); + const targetMajor = majorOf(targetVersion); + if (installedMajor === undefined || targetMajor === undefined) { + throw new Error( + `cannot verify target version (${installed || "unreadable"} -> ${targetVersion || "unreadable"})`, + ); + } + if (!wantsMajor(args) && targetMajor > installedMajor) { ctx.log(`refusing to update: ${installed} -> ${targetVersion} (${ref}) crosses a MAJOR version.`); ctx.log(` Consent explicitly with: ${MAJOR_ACTION} (or pass --major directly)`); ctx.log(" (nothing was pulled - this checkout is unchanged)"); return false; - } } - ctx.log("updating linked checkout (git pull --ff-only)..."); - await checkedGit(ctx, deps, root, ["pull", "--ff-only"]); + ctx.log(`updating linked checkout (fast-forward to ${targetCommit.slice(0, 12)})...`); + await checkedGit(ctx, deps, root, ["merge", "--ff-only", targetCommit]); const after = await currentHead(ctx, deps, root); return before !== after; } @@ -677,8 +702,7 @@ async function updateDetached( ): Promise { const installedMajor = majorOf(installed); if (installedMajor === undefined) { - ctx.log("updating detached checkout (no readable version - following origin HEAD)..."); - return await detachOnto(ctx, deps, root, "HEAD"); + throw new Error("cannot verify installed version; refusing detached update"); } const tagsResult = await runGit(ctx, deps, root, ["ls-remote", "--tags", "origin"]); @@ -689,11 +713,8 @@ async function updateDetached( ); } const tags = parseReleaseTags(tagsResult.stdout); - // Older/local repositories may have no release tags at all. Preserve ADR 0006's original - // fetch-and-detach behavior in that case; once strict release tags exist, ADR 0020 governs. if (tags.length === 0) { - ctx.log("updating detached checkout (no release tags - following origin HEAD)..."); - return await detachOnto(ctx, deps, root, "HEAD"); + throw new Error("remote has no release tags; refusing detached update"); } const nextMajor = nextMajorTag(tags, installedMajor); @@ -713,7 +734,7 @@ async function updateDetached( } ctx.log(`updating detached checkout (fetch + detach onto ${target.name})...`); - return await detachOnto(ctx, deps, root, `refs/tags/${target.name}`); + return await detachOnto(ctx, deps, root, target.commit); } /** Advance a checkout according to ADR 0006 and the ADR 0020 major gate. */ @@ -753,12 +774,16 @@ export async function update( ): Promise { const invocation = parseUpdateInvocation(input, other); const { deps, args } = invocation; + const root = rootDir(ctx, deps); + const linked = + (await runGit(ctx, deps, root, ["symbolic-ref", "-q", "HEAD"])).exitCode === 0; if (!(await updateCheckout(ctx, deps, args))) return; const operation = operationContext(ctx, deps); const rebuild = deps.ops?.rebuild ?? deps.ops?.build; if (!rebuild) throw new Error("ctl update requires an injected build operation"); await rebuild(operation); await restartService(ctx, deps); + if (linked) await deps.ops?.refreshRegistry?.(operation); ctx.log("update complete"); } diff --git a/scripts/ctl/verbs-ops.test.ts b/scripts/ctl/verbs-ops.test.ts index c156583a..d53155ff 100644 --- a/scripts/ctl/verbs-ops.test.ts +++ b/scripts/ctl/verbs-ops.test.ts @@ -10,6 +10,7 @@ import { parseManagedMapping, pushTest, readManagedMapping, + refreshRegistry, serializeManagedMapping, serve, tailscaleRootAvailability, @@ -78,9 +79,17 @@ describe("ctl operational verbs", () => { expect(await readFile(join(dist, "index.html"), "utf8")).toBe("new"); await expect(readFile(join(root, "web", "dist-staging"))).rejects.toMatchObject({ code: "ENOENT" }); - expect(calls[0]).toContain("bun scripts/check-version.ts"); - expect(calls.some((call) => call.includes("bun run typecheck"))).toBe(true); - expect(calls.some((call) => call.includes("bun install") && call.includes(join(root, "web")))).toBe(true); + expect(calls[0]).toContain(`${process.execPath} scripts/check-version.ts`); + expect( + calls.some((call) => call.includes(`${process.execPath} run typecheck`)), + ).toBe(true); + expect( + calls.some( + (call) => + call.includes(`${process.execPath} install`) && + call.includes(join(root, "web")), + ), + ).toBe(true); } finally { await clean(root); } @@ -193,6 +202,23 @@ describe("ctl operational verbs", () => { } }); + test("refreshes Herdr registration for a linked checkout", async () => { + const { root, ctx } = await fixture(); + try { + const calls: string[][] = []; + await refreshRegistry(ctx, { + executor: async (argv) => { + calls.push(argv); + return result(); + }, + }); + + expect(calls).toEqual([["herdr", "plugin", "link", root]]); + } finally { + await clean(root); + } + }); + test("adapts the shared ctl shell contract without invoking a command interpreter", async () => { const { root, ctx: base } = await fixture(); try { @@ -209,7 +235,11 @@ describe("ctl operational verbs", () => { }, }; await pushTest(ctx, ["title"]); - expect(invocation).toEqual({ command: "bun", args: ["scripts/push-test.ts", "title"], cwd: root }); + expect(invocation).toEqual({ + command: process.execPath, + args: ["scripts/push-test.ts", "title"], + cwd: root, + }); } finally { await clean(root); } @@ -218,19 +248,23 @@ describe("ctl operational verbs", () => { test("exec-bridge passes the bridge environment and redirects both streams", async () => { const { root, ctx } = await fixture(); try { + const logFile = join(ctx.stateDir, "collie.log"); + await writeFile(logFile, "stale failure\n", "utf8"); let received: { argv: string[]; options: { cwd: string; env: Record; stdout: unknown; stderr: unknown } } | undefined; await execBridge(ctx, { + bun: "bun", spawner: async (argv, options) => { received = { argv, options }; return 0; }, }); - expect(received?.argv).toEqual(["bun", "bridge/index.ts"]); + expect(received?.argv).toEqual(["bun", join(root, "bridge", "index.ts")]); expect(received?.options.cwd).toBe(root); expect(received?.options.stdout).toBe(join(ctx.stateDir, "collie.log")); expect(received?.options.stderr).toBe(join(ctx.stateDir, "collie.log")); expect(received?.options.env.HERDR_PLUGIN_CONFIG_DIR).toBe(ctx.configDir); expect(received?.options.env.HERDR_PLUGIN_STATE_DIR).toBe(ctx.stateDir); + expect(await readFile(logFile, "utf8")).toBe(""); } finally { await clean(root); } diff --git a/scripts/ctl/verbs-ops.ts b/scripts/ctl/verbs-ops.ts index 157b3b72..84679614 100644 --- a/scripts/ctl/verbs-ops.ts +++ b/scripts/ctl/verbs-ops.ts @@ -126,7 +126,7 @@ function rootDir(ctx: OpsContext, options: OpsOptions = {}): string { } function bunCommand(options: OpsOptions): string { - return options.bun ?? process.env.BUN_BINARY ?? "bun"; + return options.bun ?? process.env.BUN_BINARY ?? process.execPath; } function emitLog(ctx: OpsContext, ...args: unknown[]): void { @@ -616,6 +616,25 @@ export async function pushTest(ctx: OpsContext, args: readonly string[] = [], op await checked(ctx, argv, options, root, env); } +/** Best-effort registry refresh for linked checkouts; detached managed installs never call this. */ +export async function refreshRegistry( + ctx: OpsContext, + options: OpsOptions = {}, +): Promise { + const root = rootDir(ctx, options); + const env = await childEnv(ctx, options); + const argv = ["herdr", "plugin", "link", root]; + const result = await execute(ctx, argv, options, root, env); + if (result.exitCode !== 0) { + emitLog( + ctx, + `note: Herdr registry refresh failed; run: herdr plugin link "${root}"`, + ); + return; + } + emitLog(ctx, "herdr registry refreshed"); +} + /** * Start the bridge process under the selected service supervisor. The service backend owns restart * policy; this verb owns the child argv, environment, and combined log redirection. @@ -624,12 +643,13 @@ export async function execBridge(ctx: OpsContext, options: ExecBridgeOptions = { const root = rootDir(ctx, options); const logFile = options.logFile ?? join(ctx.stateDir, "collie.log"); await mkdir(ctx.stateDir, { recursive: true }); + await writeFile(logFile, "", "utf8"); const env = await childEnv(ctx, options, { HERDR_PLUGIN_CONFIG_DIR: ctx.configDir, HERDR_PLUGIN_STATE_DIR: ctx.stateDir, ...(ctx.socketPath ? { HERDR_SOCKET_PATH: ctx.socketPath } : {}), }); - const argv = [bunCommand(options), "bridge/index.ts"]; + const argv = [bunCommand(options), join(root, "bridge", "index.ts")]; if (options.spawner) { const spawned = await options.spawner(argv, { cwd: root, env, stdout: logFile, stderr: logFile }); const exitCode = typeof spawned === "number" ? spawned : await spawned.exited; @@ -654,9 +674,11 @@ export const cmdUnserve = unserve; export const cmdPushKeys = pushKeys; export const cmdPushTest = pushTest; export const cmdExecBridge = execBridge; +export const cmdRefreshRegistry = refreshRegistry; export const runBuild = build; export const runServe = serve; export const runUnserve = unserve; export const runPushKeys = pushKeys; export const runPushTest = pushTest; export const runExecBridge = execBridge; +export const runRefreshRegistry = refreshRegistry; From a00f6622a730186dc18e871ebe8697f6ae5bd55a Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:56:14 +0900 Subject: [PATCH 20/23] docs: align Windows lifecycle and ingress guidance Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .adr/0021-windows-service-backend.md | 26 ++++++++------ .adr/README.md | 5 +-- DEPLOYMENT.md | 18 ++++++---- README.md | 51 ++++++++++++++++++---------- 4 files changed, 63 insertions(+), 37 deletions(-) diff --git a/.adr/0021-windows-service-backend.md b/.adr/0021-windows-service-backend.md index b3ed7970..a56d435f 100644 --- a/.adr/0021-windows-service-backend.md +++ b/.adr/0021-windows-service-backend.md @@ -6,10 +6,10 @@ ## Context -Collie already runs on Windows at the bridge layer, but the launcher story is still split across -bash, per-OS shell glue, and service supervisor branches. That shape works, but it keeps the same -lifecycle logic in two languages and makes Windows the only host that does not have a supported -service path in the same codebase. +Collie already runs on Windows at the bridge layer, and the launcher now does too. The remaining +problem was the service backend. The old shape split lifecycle logic across bash, per-OS shell glue, +and service supervisor branches. That worked, but it kept the same lifecycle logic in two languages +and made Windows the only host without a supported service path in the same codebase. The candidate backends were: @@ -31,9 +31,9 @@ fallback `taskkill`. Use Task Scheduler as the Windows service backend, and keep the lifecycle implementation in TypeScript under `scripts/ctl/` instead of bash. -`main.ts` becomes the single entry point for all supported ctl verbs. That lets Windows, macOS and -Linux share one parser, one readiness probe, one backend interface, and one command surface. The old -bash implementation still exists for compatibility, but the supported path moves to TS. +`main.ts` is the single entry point for the supported ctl verbs. That lets Windows, macOS and Linux +share one parser, one readiness probe, one backend interface, and one command surface. The old bash +implementation still exists for compatibility, but the supported path is TS. Windows service termination is a force-kill model. We accept that the final debounce window may be lost on shutdown, and we rely on the normal save cadence plus the POSIX path elsewhere for the @@ -43,12 +43,17 @@ The manifest also becomes platform-neutral for lifecycle actions. Herdr action i we cannot keep separate per-platform action rows with the same ids. The right shape is one bun-based command row per verb, with platform support declared at the item level. -The baseline deployment scope stays (a), full Windows host deployment. Alternatives remain noted, -but they are not the default: +The baseline deployment scope stays, full Windows host deployment. Alternatives remain noted, but +they are not the default: - (b) Windows bridge only, with another host still handling ingress - (c) Windows bridge behind an external reverse proxy or tunnel +The validation host had neither the Tailscale executable nor its Windows service installed, so its +SC4 outcome is an explicit transition to deployment Variant E with `COLLIE_SKIP_SERVE=1`. Task +Scheduler owns the bridge lifecycle; another authenticated mesh or reverse proxy must own ingress. +The loopback URL proves bridge health but is not, by itself, reachable from a phone. + ## Consequences Windows gets a supported service path without a second wrapper layer. @@ -63,4 +68,5 @@ The manifest is simpler, but less specific per platform. That is the cost of kee unique and the command surface uniform. If a future Windows service backend can prove a better stop model without adding a new wrapper or a -second command path, this ADR can be revisited. Until then, Task Scheduler is the supported route. +second command path, this ADR can be revisited. Until then, Task Scheduler is the supported route, +and this record stays accepted. diff --git a/.adr/README.md b/.adr/README.md index f6f8a424..bb41f2ad 100644 --- a/.adr/README.md +++ b/.adr/README.md @@ -1,7 +1,7 @@ # Architecture decision records -Decisions with a **blast radius wider than the diff that made them** — the ones a future -contributor (or a future agent) would otherwise re-derive from scratch, or quietly reverse because +Decisions with a **blast radius wider than the diff that made them**, the ones a future +contributor, or a future agent, would otherwise re-derive from scratch or quietly reverse because the reasoning lived only in a PR thread. One file per decision, numbered in the order they were accepted: @@ -75,6 +75,7 @@ A superseded ADR is never deleted or edited into agreement with the present. Mar | [0017](./0017-recognising-a-password-prompt-changes-what-collie-says.md) | Recognising a password prompt changes what Collie says, never what it sends | Accepted | | [0018](./0018-operator-command-rows-replace-the-catalog.md) | The operator's command rows replace the catalog, never merge into it | Accepted | | [0020](./0020-a-major-upgrade-is-consented-by-flag.md) | A major upgrade is consented by flag; routine update follows tags within the major | Accepted | +| [0021](./0021-windows-service-backend.md) | Windows uses a per-user Task Scheduler service backend | Accepted | **0011–0016 and 0019 are not missing** — they are the pack/federation and lint-gate decisions, accepted on the `v1` diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 34d015f4..8284932a 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -2,10 +2,12 @@ The bridge always binds **loopback only**; what changes between deployments is *what sits in front of it* and *how a request proves who it is*. [Variant A](./README.md#variant-a--tailscale-serve--person-identity-default), -plain `tailscale serve`, identity by tailnet person, is the default and lives in the README. On stock -Windows setups the Tailscale CLI is often absent, so Variant E is the fallback shape to use there. -If you are using the Windows host path, read the README Windows install section first and then come -back here. The four shapes here are for everything else. Pick one. +plain `tailscale serve`, identity by tailnet person, is the default and lives in the README. On the +validated Windows host, the Tailscale executable and service were absent, so the run used documented +Variant E with `COLLIE_SKIP_SERVE=1`. That is the Windows fallback shape when another authenticated +mesh or proxy owns ingress. Loopback by itself is not phone-accessible. If you are using the Windows +host path, read the README Windows install section first and then come back here. The four shapes here +are for everything else. Pick one. - [Variant B — identity-aware proxy + per-device authorisation](#variant-b--identity-aware-proxy--per-device-authorisation) - [Variant C — reverse proxy as the only front door (no Tailscale)](#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) @@ -339,16 +341,18 @@ and a convenience in `collie-ctl.sh`; the bridge itself is a loopback HTTP serve `Host`, `Origin`, and two optional headers. Anything that can reach `127.0.0.1:$COLLIE_PORT` can front it. -Collie deliberately **manages** only one front door — the one this project runs and tests. For every +Collie deliberately **manages** only one front door, the one this project runs and tests. For every other tunnel you own the ingress and Collie stays out of the way: ```bash COLLIE_SKIP_SERVE=1 # never run tailscale serve -COLLIE_PUBLIC_HOSTS=collie.example.com # exact public host — blocks DNS rebinding +COLLIE_PUBLIC_HOSTS=collie.example.com # exact public host, blocks DNS rebinding COLLIE_ALLOWED_ORIGINS=https://collie.example.com # exact public origin for the same-origin gate ``` -That skip flag is the fallback procedure on Windows when Tailscale CLI isn't there. +That skip flag is the documented Windows fallback when the Tailscale executable or service is +absent. Use it with another authenticated mesh or proxy that owns ingress. The bridge on loopback +alone can't be reached from a phone. Then point your tunnel at `127.0.0.1:$COLLIE_PORT` and start it however you start your other services. `netbird expose 8787`, a ZeroTier-routed reverse proxy and `cloudflared tunnel` all work diff --git a/README.md b/README.md index 8358fc55..3bad6f88 100644 --- a/README.md +++ b/README.md @@ -337,9 +337,10 @@ open a pane, tap **Keys → Presets**, your buttons are there. Rejected row? ### Multi-session `COLLIE_MULTI_SESSION=on` (the default) discovers and serves every named Herdr session under your -config root, switchable from the header; `COLLIE_MULTI_SESSION=off` serves only the primary one. Every -session it finds is drivable through the same URL — including a private or sandbox one, which is why -[Security](#%EF%B8%8F-security--read-before-you-run-it) lists this as a sharp edge. +config root, switchable from the header. Windows session paths are supported, so you don't need to +turn it off there. `COLLIE_MULTI_SESSION=off` serves only the primary one. Every session it finds is +drivable through the same URL, including a private or sandbox one, which is why [Security](#%EF%B8%8F-security--read-before-you-run-it) +lists this as a sharp edge. ## Dark mode / light mode @@ -520,19 +521,25 @@ isn't in the path at all, [`DEPLOYMENT.md`](./DEPLOYMENT.md) has the rest: ## Windows (experimental) -The **bridge** runs on Windows against Herdr's Windows beta; the **launcher** does not. Herdr there -exposes its control socket as a *named pipe* named after the full socket path, not an AF_UNIX -socket, so Collie dials it through `node:net` instead of `Bun.connect`, one shim, +The bridge runs on Windows against Herdr's Windows beta, and the launcher does too. The Windows +service path is Task Scheduler, driven by `bun scripts/ctl/main.ts`. + +Herdr there exposes its control socket as a *named pipe* named after the full socket path, not an +AF_UNIX socket, so Collie dials it through `node:net` instead of `Bun.connect`, one shim, [`bridge/dial.ts`](./bridge/dial.ts), which explains the mapping at the top of the file. -If you want the lifecycle commands on Windows, run them with Bun directly: +Run the lifecycle commands on Windows with Bun directly: ```powershell bun scripts/ctl/main.ts start -bun scripts/ctl/main.ts stop bun scripts/ctl/main.ts status +bun scripts/ctl/main.ts stop ``` +`start` creates or refreshes a per-user Limited Task Scheduler task, sets it to run at login, and +turns on restart on failure. `status` reports the task state, the bridge probe, and the front-door +URL. `stop` stops the bridge while leaving the login task registered for the next `start` or login. + What that means in practice: - **Use Bun 1.1+ on Windows**, plus Git and Herdr's Windows beta from @@ -540,19 +547,27 @@ What that means in practice: - **Pre-push hooks need Git Bash.** The hook scripts still shell out to POSIX tools, so plain `cmd.exe` or PowerShell won't run them. - **Run the ctl entry point directly** for lifecycle work: `bun scripts/ctl/main.ts start`, - `stop`, `restart`, `status`, `url`, `version`, `qr`, `logs`, `build`, `serve`, `unserve`, - `uninstall`, `update`, `push-keys`, and `push-test`. There's no systemd unit on Windows. -- **`tailscale serve` isn't wired up here.** Use the - [Variant E](./DEPLOYMENT.md#variant-e--any-other-mesh-or-tunnel-netbird-zerotier-cloudflare-tunnel) - fallback with `COLLIE_SKIP_SERVE=1` when Tailscale CLI isn't installed, which is the stock - Windows case on a fresh machine. -- **Set `COLLIE_MULTI_SESSION=off`** — session discovery derives POSIX paths. + `status`, `stop`, `restart`, `url`, `version`, `qr`, `logs`, `build`, `serve`, `unserve`, + `uninstall`, `update`, `push-keys`, and `push-test`. +- **Tailscale is supported on Windows when it is installed.** When the host has no Tailscale CLI, + use the [Variant E](./DEPLOYMENT.md#variant-e--any-other-mesh-or-tunnel-netbird-zerotier-cloudflare-tunnel) + fallback with `COLLIE_SKIP_SERVE=1` and let another mesh or proxy own ingress. +- **Windows session paths are supported.** Leave `COLLIE_MULTI_SESSION` at the default unless you + want to serve only the primary session. - The socket path defaults to `%APPDATA%\herdr\herdr.sock`; override with `HERDR_SOCKET_PATH` (an explicit `\\.\pipe\…` value is passed through untouched). -**Want the lifecycle too?** The bridge has spoken Windows' named pipe since 0.15.0; a -community-maintained Task Scheduler setup (start/stop/update, no supported-tree guarantees) lives in -[`contrib/windows/`](./contrib/windows/README.md). +**Want the lifecycle too?** The bridge has spoken Windows' named pipe since 0.15.0, and the +supported service backend is Task Scheduler. The task is per-user, Limited, runs at login, and +restarts on failure. The supported Windows path is `bun scripts/ctl/main.ts start|status|stop`, with +`restart`, `url`, `version`, `qr`, `logs`, `build`, `serve`, `unserve`, `uninstall`, `update`, +`push-keys`, and `push-test` on the same entry point. + +`start` creates the task, starts the bridge, and best-effort publishes the configured front door. +`status` shows whether the task and bridge are healthy. `stop` stops the task and bridge but keeps +the registration; `uninstall` removes that registration and Collie's managed front-door mapping. +The forced-stop caveat still applies, so the last debounce window can be lost. Logs are written to +`%LOCALAPPDATA%\collie\state\collie.log`. **Is it actually working?** The bridge logs `[events] stream up` on start — the event stream works over the pipe, so Windows gets the same live updates as Linux, not degraded polling. From a3c698d9a7343ce1c75a30bab12b1e4fbb0fe3ae Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:00:09 +0900 Subject: [PATCH 21/23] style(scripts): remove trailing backend whitespace Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- scripts/ctl/backends/common.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/ctl/backends/common.ts b/scripts/ctl/backends/common.ts index b125560a..5794ad11 100644 --- a/scripts/ctl/backends/common.ts +++ b/scripts/ctl/backends/common.ts @@ -85,4 +85,3 @@ export interface BackendFactoryOptions { export function asInstalledBackend(backend: T): T { return backend; } - From 3f9dec4402587036966710b9819af8d050f9052e Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:26:07 +0900 Subject: [PATCH 22/23] fix(scripts): address lifecycle review findings Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- scripts/ctl/backends/backends.fixes.test.ts | 52 ++++++++++++++++++++- scripts/ctl/backends/backends.test.ts | 8 ++-- scripts/ctl/backends/launchd.ts | 35 +++++++++++--- scripts/ctl/backends/systemd.ts | 9 ++-- scripts/ctl/verbs-info.test.ts | 19 ++++++++ scripts/ctl/verbs-info.ts | 7 ++- scripts/ctl/verbs-ops.test.ts | 7 +++ scripts/ctl/verbs-ops.ts | 4 +- 8 files changed, 122 insertions(+), 19 deletions(-) diff --git a/scripts/ctl/backends/backends.fixes.test.ts b/scripts/ctl/backends/backends.fixes.test.ts index c2166ab9..3c920f32 100644 --- a/scripts/ctl/backends/backends.fixes.test.ts +++ b/scripts/ctl/backends/backends.fixes.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { Ctx, ShellResult } from "../types.ts"; +import type { InstalledServiceBackend } from "./common.ts"; import { bunBinary } from "./common.ts"; import { createLaunchdBackend } from "./launchd.ts"; -import { renderSystemdUnit } from "./systemd.ts"; +import { createSystemdBackend, renderSystemdUnit } from "./systemd.ts"; import { renderBridgePidQuery } from "./windows.ts"; type Call = { command: string; args: readonly string[] }; @@ -114,6 +115,16 @@ describe("backend regression fixes", () => { ); }); + test("reads systemd bridge logs from the file exec-bridge writes", () => { + const { ctx } = context(); + const command = createSystemdBackend().logsCmd(ctx, 17); + + expect(command).toEqual({ + command: "tail", + args: ["-n", "17", "C:\\Users\\collie\\state\\collie.log"], + }); + }); + test("retries transient launchd bootstrap failures three times", async () => { // Given: cleanup succeeds, then bootstrap fails twice before succeeding. const { ctx, calls } = context([ @@ -165,6 +176,45 @@ describe("backend regression fixes", () => { expect(calls.filter((call) => call.args[0] === "bootstrap")).toHaveLength(3); }); + test("falls back to an unsupervised process after launchd retries are exhausted", async () => { + const { ctx } = context([ + result(), + result(), + result("", "bootstrap failed first", 5), + result("", "bootstrap failed second", 5), + result("", "bootstrap failed third", 5), + ]); + const fallbackCalls: string[] = []; + const fallback: InstalledServiceBackend = { + async install() {}, + async start() { + fallbackCalls.push("start"); + }, + async stop() { + fallbackCalls.push("stop"); + }, + async uninstall() { + fallbackCalls.push("uninstall"); + }, + async isActive() { + fallbackCalls.push("is-active"); + return true; + }, + logsCmd() { + return { command: "tail", args: [] }; + }, + }; + const backend = createLaunchdBackend({ + uid: 42, + fallback, + retryWait: async () => {}, + }); + + await backend.start(ctx); + + expect(fallbackCalls).toEqual(["start"]); + }); + test("does not retry unexpected launchd bootstrap errors", async () => { // Given: launchd cannot be spawned at all, which is not a retryable exit status. const unexpected = new Error("launchctl could not be spawned"); diff --git a/scripts/ctl/backends/backends.test.ts b/scripts/ctl/backends/backends.test.ts index b11aa8fe..4e67f81a 100644 --- a/scripts/ctl/backends/backends.test.ts +++ b/scripts/ctl/backends/backends.test.ts @@ -163,7 +163,7 @@ describe("Windows Task Scheduler backend", () => { }); describe("systemd --user backend", () => { - test("writes the unit and preserves enable, stop, reset, and journal commands", async () => { + test("writes the unit and preserves enable, stop, reset, and file logs", async () => { const home = await temporaryHome(); try { const { ctx, calls } = context(); @@ -179,8 +179,8 @@ describe("systemd --user backend", () => { await backend.start(ctx); await backend.stop(ctx); expect(backend.logsCmd(ctx, 9)).toEqual({ - command: "journalctl", - args: ["--user", "-u", "collie", "-n", "9", "--no-pager"], + command: "tail", + args: ["-n", "9", join(ctx.stateDir, "collie.log")], }); await backend.uninstall(ctx); expect(calls).toEqual([ @@ -240,4 +240,4 @@ describe("launchd backend", () => { function exitCodeForMissingTask(): number { return 113; -} \ No newline at end of file +} diff --git a/scripts/ctl/backends/launchd.ts b/scripts/ctl/backends/launchd.ts index b27f8d02..53bae76e 100644 --- a/scripts/ctl/backends/launchd.ts +++ b/scripts/ctl/backends/launchd.ts @@ -13,6 +13,7 @@ import { type BackendFactoryOptions, type InstalledServiceBackend, } from "./common.ts"; +import { unsupervisedBackend } from "./unsupervised.ts"; export const LAUNCHD_AGENT_LABEL = "herdr.collie"; export const AGENT_LABEL = LAUNCHD_AGENT_LABEL; @@ -28,6 +29,8 @@ export interface LaunchdBackendOptions extends BackendFactoryOptions { label?: string; /** Wait between transient bootstrap failures; production waits one second. */ retryWait?: LaunchdRetryWaiter; + /** Process fallback used when the per-user launchd domain cannot bootstrap. */ + fallback?: InstalledServiceBackend; } interface ResolvedLaunchdOptions { @@ -37,6 +40,7 @@ interface ResolvedLaunchdOptions { uid: string; label: string; retryWait: LaunchdRetryWaiter; + fallback?: InstalledServiceBackend; } interface LaunchdBootstrapOptions { @@ -54,6 +58,7 @@ function resolvedOptions(options: LaunchdBackendOptions): ResolvedLaunchdOptions uid: String(uid), label: options.label ?? LAUNCHD_AGENT_LABEL, retryWait: options.retryWait ?? ((milliseconds) => Bun.sleep(milliseconds)), + fallback: options.fallback, }; } @@ -170,17 +175,26 @@ export function createLaunchdBackend(options: LaunchdBackendOptions = {}): Insta // bootout makes start idempotent when an earlier install is still loaded. await bestEffortShell(ctx, "launchctl", ["bootout", target]); await bestEffortShell(ctx, "launchctl", ["enable", target]); - await bootstrapLaunchd(ctx, bootstrapOptions); + try { + await bootstrapLaunchd(ctx, bootstrapOptions); + } catch (error) { + if (resolved.fallback === undefined) throw error; + const detail = error instanceof Error ? error.message : String(error); + ctx.log(`note: launchd bootstrap failed (${detail}); using unsupervised fallback`); + await resolved.fallback.start(ctx); + } }, async stop(ctx: Ctx): Promise { // Disable persists across logins; bootout stops the currently loaded agent. await bestEffortShell(ctx, "launchctl", ["disable", target]); await bestEffortShell(ctx, "launchctl", ["bootout", target]); + await resolved.fallback?.stop(ctx); }, async uninstall(ctx: Ctx): Promise { await rm(agentFile, { force: true }); + await resolved.fallback?.uninstall?.(ctx); // Reset launchd's disabled bit so reinstalling the same label can run at login. await bestEffortShell(ctx, "launchctl", ["enable", target]); }, @@ -188,12 +202,19 @@ export function createLaunchdBackend(options: LaunchdBackendOptions = {}): Insta async isActive(ctx: Ctx): Promise { try { const result = await ctx.shell("launchctl", ["print", target]); - if (result.exitCode !== 0) return false; - return /(?:^|\n)\s*pid\s*=\s*\d+/i.test(result.stdout) - || /(?:^|\n)\s*state\s*=\s*running/i.test(result.stdout); + if ( + result.exitCode === 0 && + ( + /(?:^|\n)\s*pid\s*=\s*\d+/i.test(result.stdout) || + /(?:^|\n)\s*state\s*=\s*running/i.test(result.stdout) + ) + ) { + return true; + } } catch { - return false; + // Fall through to the unsupervised process when launchd is unavailable. } + return await resolved.fallback?.isActive(ctx) ?? false; }, logsCmd(_ctx: Ctx, lines?: number): ShellCommand { @@ -205,6 +226,8 @@ export function createLaunchdBackend(options: LaunchdBackendOptions = {}): Insta }); } -export const launchdBackend = createLaunchdBackend(); +export const launchdBackend = createLaunchdBackend({ + fallback: unsupervisedBackend, +}); export const backend = launchdBackend; export default launchdBackend; diff --git a/scripts/ctl/backends/systemd.ts b/scripts/ctl/backends/systemd.ts index 3e315921..d05599a9 100644 --- a/scripts/ctl/backends/systemd.ts +++ b/scripts/ctl/backends/systemd.ts @@ -8,7 +8,7 @@ import { bunBinary, checkedShell, checkoutRoot, - logLineCount, + tailCommand, type BackendFactoryOptions, type InstalledServiceBackend, } from "./common.ts"; @@ -115,11 +115,8 @@ export function createSystemdBackend(options: SystemdBackendOptions = {}): Insta } }, - logsCmd(_ctx: Ctx, lines?: number): ShellCommand { - return { - command: "journalctl", - args: ["--user", "-u", unitName, "-n", String(logLineCount(lines)), "--no-pager"], - }; + logsCmd(ctx: Ctx, lines?: number): ShellCommand { + return tailCommand(join(ctx.stateDir, "collie.log"), lines); }, }); } diff --git a/scripts/ctl/verbs-info.test.ts b/scripts/ctl/verbs-info.test.ts index 5e7592e2..bf5f6fc5 100644 --- a/scripts/ctl/verbs-info.test.ts +++ b/scripts/ctl/verbs-info.test.ts @@ -94,6 +94,25 @@ describe("status", () => { expect(out).toContain("serve: none"); }); + test("renders stopped when a stale socket remains after the backend stops", async () => { + const out = await status( + CTX, + makeDeps( + { [CTX.socketPath]: "stale-socket" }, + { + backend: { + kind: "systemd", + isActive: async () => false, + }, + }, + ), + ); + + expect(out.split("\n")[0]).toBe("stopped"); + expect(out).toContain("backend: inactive (systemd)"); + expect(out).toContain("socket: present"); + }); + test("renders no-backend when no backend is injected", async () => { const out = await status(CTX, makeDeps()); expect(out).toContain("no-backend"); diff --git a/scripts/ctl/verbs-info.ts b/scripts/ctl/verbs-info.ts index 6c88673a..edd18772 100644 --- a/scripts/ctl/verbs-info.ts +++ b/scripts/ctl/verbs-info.ts @@ -185,7 +185,12 @@ export async function status(ctx: CtlCtx, deps: InfoDeps = {}): Promise servePromise, ]); const backendActive = backendActiveRaw === null ? null : Boolean(backendActiveRaw); - const state = resolved.backend === null ? "no-backend" : socketPresent ? "running" : "stopped"; + const state = + resolved.backend === null + ? "no-backend" + : backendActive && socketPresent + ? "running" + : "stopped"; return [ state, ` backend: ${backendLabel(resolved.backend, backendActive)}`, diff --git a/scripts/ctl/verbs-ops.test.ts b/scripts/ctl/verbs-ops.test.ts index d53155ff..677f45d8 100644 --- a/scripts/ctl/verbs-ops.test.ts +++ b/scripts/ctl/verbs-ops.test.ts @@ -249,7 +249,13 @@ describe("ctl operational verbs", () => { const { root, ctx } = await fixture(); try { const logFile = join(ctx.stateDir, "collie.log"); + const configuredSocket = join(root, "custom-herdr.sock"); await writeFile(logFile, "stale failure\n", "utf8"); + await writeFile( + join(ctx.configDir, ".env"), + `HERDR_SOCKET_PATH=${configuredSocket}\n`, + "utf8", + ); let received: { argv: string[]; options: { cwd: string; env: Record; stdout: unknown; stderr: unknown } } | undefined; await execBridge(ctx, { bun: "bun", @@ -264,6 +270,7 @@ describe("ctl operational verbs", () => { expect(received?.options.stderr).toBe(join(ctx.stateDir, "collie.log")); expect(received?.options.env.HERDR_PLUGIN_CONFIG_DIR).toBe(ctx.configDir); expect(received?.options.env.HERDR_PLUGIN_STATE_DIR).toBe(ctx.stateDir); + expect(received?.options.env.HERDR_SOCKET_PATH).toBe(configuredSocket); expect(await readFile(logFile, "utf8")).toBe(""); } finally { await clean(root); diff --git a/scripts/ctl/verbs-ops.ts b/scripts/ctl/verbs-ops.ts index 84679614..89b8d38b 100644 --- a/scripts/ctl/verbs-ops.ts +++ b/scripts/ctl/verbs-ops.ts @@ -647,8 +647,10 @@ export async function execBridge(ctx: OpsContext, options: ExecBridgeOptions = { const env = await childEnv(ctx, options, { HERDR_PLUGIN_CONFIG_DIR: ctx.configDir, HERDR_PLUGIN_STATE_DIR: ctx.stateDir, - ...(ctx.socketPath ? { HERDR_SOCKET_PATH: ctx.socketPath } : {}), }); + if (!env.HERDR_SOCKET_PATH && ctx.socketPath) { + env.HERDR_SOCKET_PATH = ctx.socketPath; + } const argv = [bunCommand(options), join(root, "bridge", "index.ts")]; if (options.spawner) { const spawned = await options.spawner(argv, { cwd: root, env, stdout: logFile, stderr: logFile }); From 58bf52636b48016fd32cf67b86c4c982c6e6a1a6 Mon Sep 17 00:00:00 2001 From: Kim Yejun <35421240+kimjunny@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:27:23 +0900 Subject: [PATCH 23/23] docs: describe the supported TypeScript ctl path Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- CLAUDE.md | 18 +++++++-------- README.md | 69 ++++++++++++++++++++++++++++--------------------------- 2 files changed, 44 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4cd6205e..5b896b49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,8 @@ a fork PR does carry a release commit, cherry-pick the functional commits with ` Doc-only changes (`*.md`) don't need a bump. This is enforced two ways, but **you are the first line — do it as part of the change, not after**: -- `scripts/check-version.sh` runs inside `scripts/collie-ctl.sh build` (a release can't build while - versions disagree). +- `scripts/check-version.sh` and `scripts/check-version.ts` enforce the same invariant; + `bun scripts/ctl/main.ts build` runs the TypeScript gate before a release build. - A **git pre-commit hook** (`scripts/git-hooks/pre-commit`, activate once with `scripts/install-hooks.sh`) blocks commits where functional code changed but the version didn't. Escape hatch for a single commit: `SKIP_VERSION_CHECK=1 git commit …`. @@ -93,18 +93,18 @@ the unit name; the Herdr action runs from anywhere. a rebuild is **immediately live — no restart**. - **Backend changes** (`bridge/*.ts`): Bun does **not** hot-reload the service — you must `systemctl --user restart collie`. Forgetting this is the #1 "my change didn't take" trap. -- `bun run build` (root) and `collie-ctl.sh build` **typecheck both sides first** (root tsc + web +- `bun run build` (root) and `bun scripts/ctl/main.ts build` **typecheck both sides first** (root tsc + web tsc), then build web to `dist-staging` and swap it in atomically — a failed build never empties a live `web/dist`. Bare `cd web && bun run build` still skips typechecking; don't ship from it. - **Tests:** frontend `cd web && bun run test` (Vitest + jsdom + Testing Library + MSW; no headless - browser); backend `bun run test` at the root — Bun's own runner over every pure-logic module in - `bridge/` (access checks, state engine, config, journal adapters, notifications, uploads, …) plus - `scripts/collie-ctl.test.sh`, which exercises the ctl lifecycle in a sandboxed HOME. - A **pre-push hook** (`scripts/git-hooks/pre-push`) runs **both** before + browser); `bun run test` at the root runs the bridge and TypeScript script tests. + `bun run test:ctl-posix` separately exercises the legacy POSIX shell lifecycle in a sandboxed HOME. + A **pre-push hook** (`scripts/git-hooks/pre-push`) runs the required suites before every push — override once with `SKIP_TESTS=1 git push`. The bits that genuinely need `Bun.serve` / `Bun.connect` (HTTP handlers, the socket client) stay unit-untested — Vitest-on-Node can't run them, so keep new backend logic pure/injectable enough for `bun test`, or exercise it through `web/`. -- Service: `systemd --user` unit `collie` on the deployment host; logs `journalctl --user -u collie -f`. +- Service: systemd on Linux, launchd on macOS, and Task Scheduler on Windows; direct logs use + `bun scripts/ctl/main.ts logs`. - **Dependencies must be 7 days old to install** (`bunfig.toml` + `web/bunfig.toml`, mirrored in `.npmrc` for npm users) — a compromised release is usually pulled within hours. A brand-new version resolving to an older one is the rule working, not a bug; CI's `--frozen-lockfile` is @@ -214,7 +214,7 @@ conforming reverse proxy per DEPLOYMENT.md Variant C (`COLLIE_SKIP_SERVE=1`) · optional identity/device gates · strict CSP. A socket call can type into a real terminal — treat the bridge as remote shell access. -**Collie manages exactly one front door: `tailscale serve`** — `collie-ctl.sh` publishes it, records +**Collie manages exactly one front door: `tailscale serve`** — `scripts/ctl/main.ts` publishes it, records the mapping in `tailscale-managed-handler`, and only ever tears down a mapping matching that record. Every other tunnel (NetBird, ZeroTier, Cloudflare Tunnel) is `COLLIE_SKIP_SERVE=1` + DEPLOYMENT.md Variant E: the operator owns the ingress, Collie publishes nothing. **Don't add a second managed front diff --git a/README.md b/README.md index 3bad6f88..ce3ccb8d 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ public access, Collie isn't built for it. Read the - [Commands](#commands) - [Manage & update](#manage--update) - [Deployment variants](#deployment-variants) · [B–E in `DEPLOYMENT.md`](./DEPLOYMENT.md) -- [Windows (experimental)](#windows-experimental) +- [Windows](#windows) - [Web Push](#web-push-optional) - [Troubleshooting](#troubleshooting) - [Architecture](#architecture) @@ -120,16 +120,15 @@ On the **host** (the tailnet node your agents run on). Need Herdr 0.7.0+ — che | [**Tailscale**](https://tailscale.com) | Front door for the default variant (`tailscale serve`); optional if you run [Variant C](./DEPLOYMENT.md#variant-c--reverse-proxy-as-the-only-front-door-no-tailscale) behind your own reverse proxy. Without any front door, the bridge is `127.0.0.1`-only. | | **git** | Clone, and the `update` command. | -Soft dependencies: **Node.js** (the control script uses it to extract your MagicDNS name from -`tailscale status --json`; without it the banner falls back to the loopback URL) and a **service -supervisor** — `systemd --user` on Linux, **launchd** on macOS (both ship with the OS); a host with -neither falls back to an unsupervised `nohup` process. You never install JS +Soft dependency: a **service supervisor** — `systemd --user` on Linux, **launchd** on macOS, or +Task Scheduler on Windows (all ship with the OS); a host with neither falls back to an unsupervised +process. You never install JS deps by hand — the build runs `bun install` for you; the backend imports only Bun + `node:*`. [`web-push`](https://www.npmjs.com/package/web-push) is optional and lazy (see [Web Push](#web-push-optional)). -**Linux and macOS are the supported hosts.** The bridge itself also runs on **Windows** -(experimental) against Herdr's Windows beta — see [Windows](#windows-experimental). +**Linux, macOS, and Windows are supported hosts.** Windows uses Herdr's Windows beta and a per-user +Task Scheduler backend — see [Windows](#windows). On Windows, use Bun's native Windows build 1.1 or newer, install Git, and install Herdr's Windows beta from . Run lifecycle commands with @@ -158,13 +157,13 @@ herdr plugin action invoke start --plugin herdr.collie Either way, `start` does four things: 1. **builds** `web/dist` if it's missing (typechecked, staged, swapped in atomically), -2. **starts the bridge** as the `systemd --user` service `collie` (`nohup` fallback without systemd), +2. **starts the bridge** under the host service backend (with an unsupervised fallback), 3. **publishes it on the tailnet** — literally `tailscale serve --bg 8787`: HTTPS on the host's MagicDNS name, `:443 → 127.0.0.1:8787`, tailnet-only, 4. **prints the banner** with the URL to open — walked through line by line in [First run](#first-run--what-youll-see). -> No Herdr? Run `scripts/collie-ctl.sh start` directly — same effect (config then lives in +> No Herdr? Run `bun scripts/ctl/main.ts start` directly — same effect (config then lives in > `~/.config/collie/.env`). ## First run — what you'll see @@ -174,7 +173,7 @@ Herdr's JSON envelope instead** — the same text is the action's *captured stdo `herdr plugin log list --plugin herdr.collie`. ```console -$ scripts/collie-ctl.sh start +$ bun scripts/ctl/main.ts start building web UI (first run)… # linked clone only; a GitHub install already built …bun install · typecheck · vite build output… bridge started (systemd --user: collie) @@ -357,28 +356,30 @@ apps no way to change that at runtime.) ## Commands -Every command works two ways: the **control script** on the host (`scripts/collie-ctl.sh `) or +Every command works two ways: the **TypeScript ctl entry point** on the host +(`bun scripts/ctl/main.ts `) or the equivalent **Herdr action** (`herdr plugin action invoke --plugin herdr.collie`, written -below as `invoke `). The ones you'll actually use: +below as `invoke `). The POSIX shell wrapper remains as a compatibility path. The commands +you'll actually use: -| Action | Control script | Herdr action | +| Action | Direct ctl | Herdr action | | --- | --- | --- | -| **Start** — build if needed, serve, print the URL | `collie-ctl.sh start` | `invoke start` | -| **Stop** — pause the bridge; removes nothing | `collie-ctl.sh stop` | `invoke stop` | -| **Restart** | `collie-ctl.sh restart` | `invoke restart` | -| **Status** — the *Collie is running* banner + URLs | `collie-ctl.sh status` | `invoke status` | -| **URL** — print the tailnet URL | `collie-ctl.sh url` | `invoke url` | -| **QR** — the same URL as a scannable code | `collie-ctl.sh qr` | — (script only) | -| **Version** — the running version (`0.x.y+sha`) | `collie-ctl.sh version` | `invoke version` | -| **Update** — advance the checkout + rebuild + restart | `collie-ctl.sh update` | `invoke update` | -| **Uninstall** — remove the service; keep `.env` + checkout | `collie-ctl.sh uninstall` | `invoke uninstall` | -| **Logs** — tail the journal / log file | `collie-ctl.sh logs` | — (script only) | -| **Push keys** — generate the VAPID keypair into your `.env` | `collie-ctl.sh push-keys` | `invoke push-keys` | -| **Push test** — send one notification to prove it works | `collie-ctl.sh push-test` | `invoke push-test` | - -The actions are declared in `herdr-plugin.toml` and each one shells out to the control script; list +| **Start** — build if needed, serve, print the URL | `bun scripts/ctl/main.ts start` | `invoke start` | +| **Stop** — pause the bridge; removes nothing | `bun scripts/ctl/main.ts stop` | `invoke stop` | +| **Restart** | `bun scripts/ctl/main.ts restart` | `invoke restart` | +| **Status** — backend, bridge, and URLs | `bun scripts/ctl/main.ts status` | `invoke status` | +| **URL** — print the tailnet URL | `bun scripts/ctl/main.ts url` | `invoke url` | +| **QR** — the same URL as a scannable code | `bun scripts/ctl/main.ts qr` | — (direct only) | +| **Version** — the installed version | `bun scripts/ctl/main.ts version` | `invoke version` | +| **Update** — advance the checkout + rebuild + restart | `bun scripts/ctl/main.ts update` | `invoke update` | +| **Uninstall** — remove the service; keep `.env` + checkout | `bun scripts/ctl/main.ts uninstall` | `invoke uninstall` | +| **Logs** — tail the bridge log | `bun scripts/ctl/main.ts logs` | — (direct only) | +| **Push keys** — generate the VAPID keypair into your `.env` | `bun scripts/ctl/main.ts push-keys` | `invoke push-keys` | +| **Push test** — send one notification to prove it works | `bun scripts/ctl/main.ts push-test` | `invoke push-test` | + +The actions are declared in `herdr-plugin.toml` and invoke the same TypeScript ctl entry point; list them live with `herdr plugin action list --plugin herdr.collie`. `build` · `serve` · `unserve` are -script-only too. +direct-only too. `start` and `status` end with the **Collie is running** banner — annotated line by line in [First run](#first-run--what-youll-see). Its version comes from the *served* bundle stamp, so it is @@ -393,7 +394,7 @@ banner** — the human-readable output is the action's *captured stdout*, read w Pause the bridge without removing anything (a later `start` brings it right back): ```bash -scripts/collie-ctl.sh stop # or: herdr plugin action invoke stop --plugin herdr.collie +bun scripts/ctl/main.ts stop # or: herdr plugin action invoke stop --plugin herdr.collie ``` To tear the service down completely — stop + disable it, remove the service definition (the @@ -402,7 +403,7 @@ Collie's own `tailscale serve` mapping (port-scoped, so other tailnet mappings o use `uninstall`. It leaves your `.env` and the checkout untouched: ```bash -scripts/collie-ctl.sh uninstall # or: herdr plugin action invoke uninstall --plugin herdr.collie +bun scripts/ctl/main.ts uninstall # or: herdr plugin action invoke uninstall --plugin herdr.collie ``` Then `herdr plugin uninstall herdr.collie` (or, for a linked clone, just deleting the directory) @@ -413,7 +414,7 @@ removes the plugin registration itself. The checkout *is* the plugin, and Herdr has no `plugin update` of its own. One command does the lot: ```bash -scripts/collie-ctl.sh update # or: herdr plugin action invoke update --plugin herdr.collie +bun scripts/ctl/main.ts update # or: herdr plugin action invoke update --plugin herdr.collie ``` It advances the checkout, rebuilds the UI and restarts the bridge (re-execing itself, so it's safe @@ -425,7 +426,7 @@ means you have to change something, so it is never inherited from a routine upda a new major is out and names the one that takes it — ```bash -herdr plugin action invoke update-major --plugin herdr.collie # or: scripts/collie-ctl.sh update --major +herdr plugin action invoke update-major --plugin herdr.collie # or: bun scripts/ctl/main.ts update --major ``` The flag is the whole consent; there is no prompt, because a Herdr action has no terminal to answer @@ -519,7 +520,7 @@ isn't in the path at all, [`DEPLOYMENT.md`](./DEPLOYMENT.md) has the rest: - **[D — off-host identity proxy over the tailnet](./DEPLOYMENT.md#variant-d--off-host-identity-proxy-over-the-tailnet)** — one central ingress node fronting Collie among your other services. - **[E — any other mesh or tunnel](./DEPLOYMENT.md#variant-e--any-other-mesh-or-tunnel-netbird-zerotier-cloudflare-tunnel)** — NetBird, ZeroTier, Cloudflare Tunnel: you own the ingress, Collie publishes nothing. -## Windows (experimental) +## Windows The bridge runs on Windows against Herdr's Windows beta, and the launcher does too. The Windows service path is Task Scheduler, driven by `bun scripts/ctl/main.ts`. @@ -766,7 +767,7 @@ Full design rationale in [`ARCHITECTURE.md`](./ARCHITECTURE.md). Clone it and `herdr plugin link` it ([Install](#install) above), then edit in place. - **The manifest is the plugin.** `herdr-plugin.toml` declares the actions listed in - [Commands](#commands), and each one shells out to `scripts/collie-ctl.sh`. Both are + [Commands](#commands), and each one invokes `scripts/ctl/main.ts`. Both are commented — read them, not a paraphrase of them here. - **One asymmetry in the dev loop:** `web/` rebuilds go live with no restart (the bridge serves `web/dist` from disk); `bridge/` changes need `systemctl --user restart collie`. Build, test and