auto-harness-client 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +287 -0
  2. package/package.json +5 -1
  3. package/src/cli/admin-login.js +71 -0
  4. package/src/cli/allowlist.js +34 -0
  5. package/src/cli/args.js +41 -0
  6. package/src/cli/cli-errors.js +16 -0
  7. package/src/cli/commands/api.js +91 -0
  8. package/src/cli/commands/dependency-conflict.js +15 -0
  9. package/src/cli/commands/doctor.js +164 -0
  10. package/src/cli/commands/host-drain.js +22 -0
  11. package/src/cli/commands/host-inventory-get.js +40 -0
  12. package/src/cli/commands/host-inventory-set.js +66 -0
  13. package/src/cli/commands/host-inventory.js +15 -0
  14. package/src/cli/commands/host-list.js +87 -0
  15. package/src/cli/commands/host-post-action.js +38 -0
  16. package/src/cli/commands/host-repo-rm.js +128 -0
  17. package/src/cli/commands/host-repo.js +11 -0
  18. package/src/cli/commands/host-resume.js +17 -0
  19. package/src/cli/commands/host.js +25 -0
  20. package/src/cli/commands/repo-list.js +84 -0
  21. package/src/cli/commands/repo-rm.js +83 -0
  22. package/src/cli/commands/repo.js +15 -0
  23. package/src/cli/commands/service-account-create.js +114 -0
  24. package/src/cli/commands/service-account-list.js +84 -0
  25. package/src/cli/commands/service-account-rm.js +48 -0
  26. package/src/cli/commands/service-account.js +19 -0
  27. package/src/cli/commands/whoami.js +22 -0
  28. package/src/cli/config.js +96 -0
  29. package/src/cli/index.js +21 -0
  30. package/src/cli/main.js +69 -0
  31. package/src/cli/path-segment.js +20 -0
  32. package/src/cli/read-stdin.js +9 -0
  33. package/src/cli/report-error.js +34 -0
  34. package/src/cli/service-account-format.js +24 -0
  35. package/src/cli/usage.js +66 -0
  36. package/src/errors.js +1 -0
  37. package/src/index.d.ts +64 -4
  38. package/src/index.js +76 -30
  39. package/src/resolve-target.js +29 -13
@@ -0,0 +1,164 @@
1
+ import { AutoHarnessClient, AutoHarnessError } from "../../index.js";
2
+ import { loginAsAdmin } from "../admin-login.js";
3
+ import { allowlistPrincipal } from "../allowlist.js";
4
+ import { parseFlags } from "../args.js";
5
+ import { CliUsageError } from "../cli-errors.js";
6
+ import { GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS, resolveConfig } from "../config.js";
7
+
8
+ // A raw API Gateway endpoint bypasses CloudFront, which is what injects the ingress token; the
9
+ // host daemon's own usage text says never to use one (see cli-usage.ts).
10
+ const EXECUTE_API_HOST = /\.execute-api\.[^.]+\.amazonaws\.com$/i;
11
+
12
+ // Matches `AutoHarnessClient`'s default `requestTimeoutMs`.
13
+ const REACHABILITY_TIMEOUT_MS = 30_000;
14
+
15
+ /** Runs the url/reachability/auth checks and prints one `<status> <name>: <reason>` line each.
16
+ * Returns 1 if any check `fail`s, else 0 — a `warn` never fails the overall run. */
17
+ export async function runDoctor(argv, io) {
18
+ const { flags, positionals } = parseFlags(argv, {
19
+ valueFlags: GLOBAL_VALUE_FLAGS,
20
+ booleanFlags: GLOBAL_BOOLEAN_FLAGS,
21
+ });
22
+ if (positionals.length > 0) {
23
+ throw new CliUsageError(`doctor takes no arguments; received: ${positionals.join(" ")}`);
24
+ }
25
+ const config = await resolveConfig(flags, io);
26
+ const checks = [
27
+ checkUrlShape(config.baseUrl, config.allowInsecureHttp),
28
+ await checkReachability(config.baseUrl, io.fetch, io.timeoutSignal ?? AbortSignal.timeout),
29
+ await checkAuth(config, io),
30
+ ];
31
+ for (const check of checks) io.stdout.write(formatCheck(check));
32
+ return checks.some((check) => check.status === "fail") ? 1 : 0;
33
+ }
34
+
35
+ /** `AutoHarnessClient` strips a trailing `/api/v1` from `baseUrl` itself; `/health` lives at the
36
+ * site root, so this mirrors that same normalization for the raw (non-`client.request`) fetch. */
37
+ function siteOrigin(baseUrl) {
38
+ return baseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
39
+ }
40
+
41
+ function checkUrlShape(baseUrl, allowInsecureHttp) {
42
+ let url;
43
+ try {
44
+ url = new URL(baseUrl);
45
+ } catch {
46
+ return { name: "url", status: "fail", message: `${baseUrl} is not a valid URL` };
47
+ }
48
+ if (url.protocol === "http:" && !allowInsecureHttp) {
49
+ return {
50
+ name: "url",
51
+ status: "fail",
52
+ message: "baseUrl uses http:// (pass --allow-insecure-http only for local/loopback dev)",
53
+ };
54
+ }
55
+ if (EXECUTE_API_HOST.test(url.hostname)) {
56
+ return {
57
+ name: "url",
58
+ status: "warn",
59
+ message:
60
+ "raw API Gateway URL bypasses CloudFront, which injects the ingress token, so " +
61
+ "requests will be rejected; use the CloudFront WebUrl from the deploy output instead",
62
+ };
63
+ }
64
+ return { name: "url", status: "ok", message: `using ${baseUrl}` };
65
+ }
66
+
67
+ /** `timeoutSignal(ms)` defaults to `AbortSignal.timeout`; tests inject one they can abort,
68
+ * since a real 30-second timer cannot be fast-forwarded. */
69
+ async function checkReachability(baseUrl, fetchFn, timeoutSignal) {
70
+ const url = `${siteOrigin(baseUrl)}/health`;
71
+ // Bounded like `AutoHarnessClient`'s default, and it covers the body read below as well as
72
+ // the connection — without it a stalled server hangs doctor forever instead of failing.
73
+ const signal = timeoutSignal(REACHABILITY_TIMEOUT_MS);
74
+ let response;
75
+ let body;
76
+ try {
77
+ response = await fetchFn(url, { signal });
78
+ if (response.status === 200) {
79
+ // A non-JSON body is a failed check, but a timeout mid-body must surface as the timeout
80
+ // rather than be swallowed here — the same rule `AutoHarnessClient#request` applies.
81
+ body = await response.json().catch((error) => {
82
+ if (signal.aborted) throw error;
83
+ return undefined;
84
+ });
85
+ }
86
+ } catch (error) {
87
+ return {
88
+ name: "reachability",
89
+ status: "fail",
90
+ message: `GET /health failed: ${error.message}`,
91
+ };
92
+ }
93
+ if (response.status !== 200) {
94
+ return {
95
+ name: "reachability",
96
+ status: "fail",
97
+ message: `GET /health returned HTTP ${response.status}`,
98
+ };
99
+ }
100
+ if (!body || body.ok !== true) {
101
+ return {
102
+ name: "reachability",
103
+ status: "fail",
104
+ message: 'GET /health did not return {"ok":true}',
105
+ };
106
+ }
107
+ return { name: "reachability", status: "ok", message: "control plane is reachable" };
108
+ }
109
+
110
+ /**
111
+ * In admin mode the login itself is attempted here, inside this check's own try/catch — not
112
+ * eagerly in `resolveConfig` — so a bad admin password only fails this one check and still
113
+ * lets the url/reachability checks above it print, the same way a rejected API key does.
114
+ */
115
+ async function checkAuth(config, io) {
116
+ if (!config.apiKey && !config.adminMode) {
117
+ return {
118
+ name: "auth",
119
+ status: "warn",
120
+ message: "no API key configured; only unauthenticated checks ran",
121
+ };
122
+ }
123
+ let fetchFn = io.fetch;
124
+ if (config.adminMode) {
125
+ try {
126
+ fetchFn = (
127
+ await loginAsAdmin(io, config.baseUrl, config.adminUsername, config.allowInsecureHttp)
128
+ ).fetch;
129
+ } catch (error) {
130
+ return { name: "auth", status: "fail", message: error.message };
131
+ }
132
+ }
133
+ let client;
134
+ try {
135
+ client = new AutoHarnessClient({
136
+ baseUrl: config.baseUrl,
137
+ apiKey: config.apiKey,
138
+ fetch: fetchFn,
139
+ allowInsecureHttp: config.allowInsecureHttp,
140
+ });
141
+ } catch (error) {
142
+ return { name: "auth", status: "fail", message: error.message };
143
+ }
144
+ try {
145
+ const principal = allowlistPrincipal(await client.request("/auth/me"));
146
+ const role = principal.role ?? "unknown";
147
+ const capabilities = Array.isArray(principal.capabilities)
148
+ ? principal.capabilities.join(", ")
149
+ : "none";
150
+ const message = config.adminMode
151
+ ? `authenticated as ${principal.username} (role ${role}; capabilities: ${capabilities})`
152
+ : `authenticated as role ${role} (capabilities: ${capabilities})`;
153
+ return { name: "auth", status: "ok", message };
154
+ } catch (error) {
155
+ if (error instanceof AutoHarnessError && error.status === 401) {
156
+ return { name: "auth", status: "fail", message: "API key rejected" };
157
+ }
158
+ return { name: "auth", status: "fail", message: `GET /auth/me failed: ${error.message}` };
159
+ }
160
+ }
161
+
162
+ function formatCheck({ status, name, message }) {
163
+ return `${status} ${name}: ${message}\n`;
164
+ }
@@ -0,0 +1,22 @@
1
+ import { runHostIdPostAction } from "./host-post-action.js";
2
+
3
+ /** `POST /hosts/drain` with `{ hostId }` in the body. */
4
+ export async function runHostDrain(argv, io) {
5
+ return runHostIdPostAction(argv, io, {
6
+ commandName: "drain",
7
+ path: "/hosts/drain",
8
+ onSuccess: (result, hostId, flags) => {
9
+ if (flags["--json"]) {
10
+ io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
11
+ return;
12
+ }
13
+ const runningSessionIds = result?.runningSessionIds ?? [];
14
+ io.stdout.write(
15
+ `host ${hostId} is draining (${runningSessionIds.length} session(s) still running)\n`,
16
+ );
17
+ if (runningSessionIds.length > 0) {
18
+ io.stdout.write(`${runningSessionIds.map((id) => ` ${id}`).join("\n")}\n`);
19
+ }
20
+ },
21
+ });
22
+ }
@@ -0,0 +1,40 @@
1
+ import { parseFlags } from "../args.js";
2
+ import { CliUsageError } from "../cli-errors.js";
3
+ import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
4
+ import { pathSegment } from "../path-segment.js";
5
+
6
+ const USAGE = "usage: auto-harness host inventory get <hostId> [--json]";
7
+
8
+ /** `GET /hosts/<hostId>/inventory`. */
9
+ export async function runHostInventoryGet(argv, io) {
10
+ const { flags, positionals } = parseFlags(argv, {
11
+ valueFlags: GLOBAL_VALUE_FLAGS,
12
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
13
+ });
14
+ const [hostId] = positionals;
15
+ if (!hostId || positionals.length > 1) throw new CliUsageError(USAGE);
16
+ const hostSegment = pathSegment(hostId, "hostId");
17
+ const client = await createClient(flags, io);
18
+ const record = await client.request(`/hosts/${hostSegment}/inventory`);
19
+ io.stdout.write(
20
+ flags["--json"] ? `${JSON.stringify(record, null, 2)}\n` : formatInventory(record),
21
+ );
22
+ return 0;
23
+ }
24
+
25
+ function formatInventory(record) {
26
+ const lines = [`version: ${record.version ?? 0}`];
27
+ const repositories = record.repositories ?? [];
28
+ if (repositories.length === 0) {
29
+ lines.push("repositories: (none)");
30
+ } else {
31
+ lines.push("repositories:");
32
+ for (const repository of repositories) {
33
+ const worktreeCount = (repository.worktrees ?? []).length;
34
+ const label = worktreeCount === 1 ? "worktree" : "worktrees";
35
+ lines.push(` ${repository.id} ${repository.path} (${worktreeCount} ${label})`);
36
+ }
37
+ }
38
+ lines.push(`provider accounts: ${(record.providerAccounts ?? []).length}`);
39
+ return `${lines.join("\n")}\n`;
40
+ }
@@ -0,0 +1,66 @@
1
+ import { checkAdminLoginUsage } from "../admin-login.js";
2
+ import { parseFlags } from "../args.js";
3
+ import { CliUsageError } from "../cli-errors.js";
4
+ import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
5
+ import { pathSegment } from "../path-segment.js";
6
+ import { readStdin } from "../read-stdin.js";
7
+
8
+ const USAGE = "usage: auto-harness host inventory set <hostId> --file <path|->";
9
+
10
+ const MISSING_VERSION_MESSAGE =
11
+ "the document has no integer `version` field: omitting it silently disables optimistic " +
12
+ "concurrency on the server, so a concurrent change could be overwritten with no error; " +
13
+ "start from `auto-harness host inventory get <hostId> --json` and edit that";
14
+
15
+ /**
16
+ * `PUT /hosts/<hostId>/inventory`. The PUT is authoritative — it replaces the whole record —
17
+ * so this sends the file/stdin text verbatim as the request body rather than re-serializing a
18
+ * parsed copy; it only parses to validate that the document is a JSON object carrying the
19
+ * integer `version` the server needs for optimistic concurrency.
20
+ */
21
+ export async function runHostInventorySet(argv, io) {
22
+ const { flags, positionals } = parseFlags(argv, {
23
+ valueFlags: [...GLOBAL_VALUE_FLAGS, "--file"],
24
+ booleanFlags: GLOBAL_BOOLEAN_FLAGS,
25
+ });
26
+ const [hostId] = positionals;
27
+ if (!hostId || positionals.length > 1 || !flags["--file"]) throw new CliUsageError(USAGE);
28
+ const hostSegment = pathSegment(hostId, "hostId");
29
+ // Both this command's own `--file -` and `--admin-password-stdin` need stdin; catch the
30
+ // conflict before reading either, rather than letting one silently drain the other's input.
31
+ const stdinClaimedBy = flags["--file"] === "-" ? "host inventory set --file -" : undefined;
32
+ checkAdminLoginUsage(flags, io.env, stdinClaimedBy);
33
+ const text = await readDocument(flags["--file"], io);
34
+ validateDocument(text);
35
+ const client = await createClient(flags, io);
36
+ const result = await client.request(`/hosts/${hostSegment}/inventory`, {
37
+ method: "PUT",
38
+ body: text,
39
+ });
40
+ io.stdout.write(`inventory for host ${hostId} set to version ${result?.version}\n`);
41
+ return 0;
42
+ }
43
+
44
+ async function readDocument(path, io) {
45
+ if (path === "-") return readStdin(io.stdin);
46
+ try {
47
+ return await io.readFile(path, "utf8");
48
+ } catch (error) {
49
+ throw new CliUsageError(`could not read --file ${path}: ${error.message}`);
50
+ }
51
+ }
52
+
53
+ function validateDocument(text) {
54
+ let document;
55
+ try {
56
+ document = JSON.parse(text);
57
+ } catch {
58
+ throw new CliUsageError("--file must contain valid JSON");
59
+ }
60
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
61
+ throw new CliUsageError("--file must contain a JSON object");
62
+ }
63
+ if (!Number.isInteger(document.version)) {
64
+ throw new CliUsageError(MISSING_VERSION_MESSAGE);
65
+ }
66
+ }
@@ -0,0 +1,15 @@
1
+ import { CliUsageError } from "../cli-errors.js";
2
+ import { runHostInventoryGet } from "./host-inventory-get.js";
3
+ import { runHostInventorySet } from "./host-inventory-set.js";
4
+
5
+ const USAGE = `usage: auto-harness host inventory <get|set> ...
6
+ auto-harness host inventory get <hostId> [--json]
7
+ auto-harness host inventory set <hostId> --file <path|->`;
8
+
9
+ /** Dispatches `host inventory <get|set>`. */
10
+ export async function runHostInventory(argv, io) {
11
+ const [action, ...rest] = argv;
12
+ if (action === "get") return runHostInventoryGet(rest, io);
13
+ if (action === "set") return runHostInventorySet(rest, io);
14
+ throw new CliUsageError(USAGE);
15
+ }
@@ -0,0 +1,87 @@
1
+ import { parseFlags } from "../args.js";
2
+ import { CliUsageError } from "../cli-errors.js";
3
+ import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
4
+
5
+ // This repo's list/history invariant forbids unbounded collection of pages at storage, so
6
+ // `--all` stops here and warns rather than following `nextCursor` forever.
7
+ const MAX_ALL_PAGES = 20;
8
+
9
+ /** `GET /hosts`, optionally filtered by `online`/`offline` and paged with `limit`/`cursor`.
10
+ * `--all` follows `nextCursor` itself, capped at `MAX_ALL_PAGES` pages. */
11
+ export async function runHostList(argv, io) {
12
+ const { flags, positionals } = parseFlags(argv, {
13
+ valueFlags: [...GLOBAL_VALUE_FLAGS, "--limit", "--cursor"],
14
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--online", "--offline", "--all", "--json"],
15
+ });
16
+ if (positionals.length > 0) {
17
+ throw new CliUsageError(`host list takes no arguments; received: ${positionals.join(" ")}`);
18
+ }
19
+ if (flags["--online"] && flags["--offline"]) {
20
+ throw new CliUsageError("--online and --offline are mutually exclusive");
21
+ }
22
+ const client = await createClient(flags, io);
23
+ return flags["--all"] ? runListAll(client, flags, io) : runListOnePage(client, flags, io);
24
+ }
25
+
26
+ function buildQuery(flags, cursor) {
27
+ const query = new URLSearchParams();
28
+ if (flags["--online"]) query.set("online", "online");
29
+ if (flags["--offline"]) query.set("online", "offline");
30
+ if (flags["--limit"] !== undefined) query.set("limit", flags["--limit"]);
31
+ if (cursor !== undefined) query.set("cursor", cursor);
32
+ const suffix = query.toString();
33
+ return suffix ? `/hosts?${suffix}` : "/hosts";
34
+ }
35
+
36
+ async function runListOnePage(client, flags, io) {
37
+ const page = await client.request(buildQuery(flags, flags["--cursor"]));
38
+ if (flags["--json"]) {
39
+ io.stdout.write(`${JSON.stringify(page, null, 2)}\n`);
40
+ return 0;
41
+ }
42
+ io.stdout.write(formatHostLines(page.items ?? []));
43
+ if (page.nextCursor) {
44
+ io.stdout.write(
45
+ `more hosts available; pass --cursor ${page.nextCursor} to continue (or --all)\n`,
46
+ );
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ async function runListAll(client, flags, io) {
52
+ const items = [];
53
+ const seenCursors = new Set();
54
+ let cursor = flags["--cursor"];
55
+ for (let pageCount = 0; pageCount < MAX_ALL_PAGES; pageCount += 1) {
56
+ const page = await client.request(buildQuery(flags, cursor));
57
+ items.push(...(page.items ?? []));
58
+ cursor = page.nextCursor || undefined;
59
+ if (!cursor) break;
60
+ // Mirrors the same guard in AutoHarnessClient#listCatalog: a server repeating a cursor is a
61
+ // bug worth failing loudly on, not something to paper over as "more pages than the cap".
62
+ if (seenCursors.has(cursor)) throw new Error("repeated pagination cursor for /hosts");
63
+ seenCursors.add(cursor);
64
+ }
65
+ if (cursor) {
66
+ io.stderr.write(
67
+ `warning: --all stopped after ${MAX_ALL_PAGES} pages; more hosts remain ` +
68
+ `(nextCursor: ${cursor})\n`,
69
+ );
70
+ }
71
+ if (flags["--json"]) {
72
+ io.stdout.write(`${JSON.stringify({ items }, null, 2)}\n`);
73
+ return 0;
74
+ }
75
+ io.stdout.write(formatHostLines(items));
76
+ return 0;
77
+ }
78
+
79
+ function formatHostLines(items) {
80
+ if (items.length === 0) return "(no hosts)\n";
81
+ return `${items
82
+ .map(
83
+ (host) =>
84
+ `${host.hostId} ${host.online ? "online" : "offline"}${host.draining ? " draining" : ""}`,
85
+ )
86
+ .join("\n")}\n`;
87
+ }
@@ -0,0 +1,38 @@
1
+ import { AutoHarnessError } from "../../index.js";
2
+ import { parseFlags } from "../args.js";
3
+ import { CliUsageError } from "../cli-errors.js";
4
+ import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
5
+ import { reportError } from "../report-error.js";
6
+
7
+ /**
8
+ * Shared shape for `host drain` and `host resume`: both POST `{ hostId }` in the body — never
9
+ * the path, see `local-routes-host-drain.ts` — and both can 409 on a host connection race. A
10
+ * 409 is reported here, inside the command, rather than left to `main.js`'s catch: once that
11
+ * catch runs there is no way back into this function to add the trailing retry hint, so this
12
+ * prints the normal error line itself (via `reportError`) and then the hint, in that order.
13
+ * `onSuccess(result, hostId, flags)` writes the success output — it closes over its own `io`
14
+ * rather than taking one here, so it does not shadow this function's `io` parameter.
15
+ */
16
+ export async function runHostIdPostAction(argv, io, { commandName, path, onSuccess }) {
17
+ const { flags, positionals } = parseFlags(argv, {
18
+ valueFlags: GLOBAL_VALUE_FLAGS,
19
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
20
+ });
21
+ const [hostId] = positionals;
22
+ if (!hostId || positionals.length > 1) {
23
+ throw new CliUsageError(`usage: auto-harness host ${commandName} <hostId> [--json]`);
24
+ }
25
+ const client = await createClient(flags, io);
26
+ let result;
27
+ try {
28
+ result = await client.request(path, { method: "POST", body: JSON.stringify({ hostId }) });
29
+ } catch (error) {
30
+ const exitCode = reportError(error, io);
31
+ if (error instanceof AutoHarnessError && error.status === 409) {
32
+ io.stderr.write("hint: the host's connection changed mid-request; retrying is safe\n");
33
+ }
34
+ return exitCode;
35
+ }
36
+ onSuccess(result, hostId, flags);
37
+ return 0;
38
+ }
@@ -0,0 +1,128 @@
1
+ import { AutoHarnessError } from "../../index.js";
2
+ import { parseFlags } from "../args.js";
3
+ import { CliUsageError } from "../cli-errors.js";
4
+ import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
5
+ import { pathSegment } from "../path-segment.js";
6
+
7
+ const USAGE = "usage: auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]";
8
+ const MAX_ATTEMPTS = 3;
9
+
10
+ /**
11
+ * Detaches one repository from a host's inventory as a safe read-modify-write: GET the full
12
+ * record, remove only the target repository (its worktrees go with it — that is a projection
13
+ * of the repository, not a separate thing to delete), and PUT back everything else — including
14
+ * `providerAccounts` — exactly as read, with the read `version` kept. On a 409 (someone else
15
+ * wrote first) it re-reads and re-applies, up to `MAX_ATTEMPTS` PUTs total; any other status is
16
+ * not retried.
17
+ */
18
+ export async function runHostRepoRm(argv, io) {
19
+ const { flags, positionals } = parseFlags(argv, {
20
+ valueFlags: GLOBAL_VALUE_FLAGS,
21
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--dry-run", "--json"],
22
+ });
23
+ const [hostId, repositoryId] = positionals;
24
+ if (!hostId || !repositoryId || positionals.length > 2) throw new CliUsageError(USAGE);
25
+ pathSegment(hostId, "hostId"); // validate before createClient, which may log in
26
+ const client = await createClient(flags, io);
27
+ const record = await getInventory(client, hostId);
28
+ const removal = extractRepository(record, hostId, repositoryId);
29
+ if (flags["--dry-run"]) {
30
+ printResult(io, flags, { dryRun: true, hostId, ...removal });
31
+ return 0;
32
+ }
33
+ return removeWithRetry(client, io, flags, hostId, repositoryId, record, removal);
34
+ }
35
+
36
+ function getInventory(client, hostId) {
37
+ return client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`);
38
+ }
39
+
40
+ /** Finds the repository by id. By default throws (exit 1) listing what is actually attached;
41
+ * pass `required: false` to get `null` instead — used on a retry's re-read, where the
42
+ * repository being gone already means someone else's write reached the same goal. */
43
+ function extractRepository(record, hostId, repositoryId, { required = true } = {}) {
44
+ const repositories = record.repositories ?? [];
45
+ const repository = repositories.find((repo) => repo.id === repositoryId);
46
+ if (!repository) {
47
+ if (!required) return null;
48
+ const attached = repositories.map((repo) => repo.id);
49
+ throw new Error(
50
+ `repository ${repositoryId} is not attached to host ${hostId}; attached repositories: ` +
51
+ (attached.length > 0 ? attached.join(", ") : "(none)"),
52
+ );
53
+ }
54
+ const worktreeIds = (repository.worktrees ?? []).map((worktree) => worktree.id);
55
+ const remaining = repositories.filter((repo) => repo.id !== repositoryId);
56
+ return { repository, worktreeIds, remaining };
57
+ }
58
+
59
+ async function removeWithRetry(client, io, flags, hostId, repositoryId, record, removal) {
60
+ let currentRecord = record;
61
+ let current = removal;
62
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
63
+ const fromVersion = currentRecord.version ?? 0;
64
+ const document = { ...currentRecord, repositories: current.remaining, version: fromVersion };
65
+ try {
66
+ const result = await client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`, {
67
+ method: "PUT",
68
+ body: JSON.stringify(document),
69
+ });
70
+ printResult(io, flags, {
71
+ dryRun: false,
72
+ hostId,
73
+ repository: current.repository,
74
+ worktreeIds: current.worktreeIds,
75
+ fromVersion,
76
+ toVersion: result?.version,
77
+ });
78
+ return 0;
79
+ } catch (error) {
80
+ const conflict = error instanceof AutoHarnessError && error.status === 409;
81
+ if (!conflict) throw error;
82
+ if (attempt === MAX_ATTEMPTS) {
83
+ throw new Error(
84
+ `inventory for host ${hostId} kept changing; gave up after ${MAX_ATTEMPTS} attempts`,
85
+ { cause: error },
86
+ );
87
+ }
88
+ currentRecord = await getInventory(client, hostId);
89
+ const next = extractRepository(currentRecord, hostId, repositoryId, { required: false });
90
+ if (!next) {
91
+ printResult(io, flags, {
92
+ convergedElsewhere: true,
93
+ hostId,
94
+ repository: current.repository,
95
+ worktreeIds: current.worktreeIds,
96
+ toVersion: currentRecord.version,
97
+ });
98
+ return 0;
99
+ }
100
+ current = next;
101
+ }
102
+ }
103
+ /* v8 ignore next 2 -- the loop above always returns or throws before falling out */
104
+ return 1;
105
+ }
106
+
107
+ function printResult(io, flags, info) {
108
+ if (flags["--json"]) {
109
+ io.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
110
+ return;
111
+ }
112
+ if (info.convergedElsewhere) {
113
+ io.stdout.write(
114
+ `repository ${info.repository.id} was already removed from host ${info.hostId} by ` +
115
+ `another writer (now at version ${info.toVersion})\n`,
116
+ );
117
+ return;
118
+ }
119
+ const verb = info.dryRun ? "would remove" : "removed";
120
+ const lines = [
121
+ `${verb} repository ${info.repository.id} (${info.repository.path}) from host ${info.hostId}`,
122
+ ];
123
+ if (info.worktreeIds.length > 0) {
124
+ lines.push(` worktrees removed with it: ${info.worktreeIds.join(", ")}`);
125
+ }
126
+ if (!info.dryRun) lines.push(`version ${info.fromVersion} → ${info.toVersion}`);
127
+ io.stdout.write(`${lines.join("\n")}\n`);
128
+ }
@@ -0,0 +1,11 @@
1
+ import { CliUsageError } from "../cli-errors.js";
2
+ import { runHostRepoRm } from "./host-repo-rm.js";
3
+
4
+ const USAGE = "usage: auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]";
5
+
6
+ /** Dispatches `host repo <rm>`. */
7
+ export async function runHostRepo(argv, io) {
8
+ const [action, ...rest] = argv;
9
+ if (action === "rm") return runHostRepoRm(rest, io);
10
+ throw new CliUsageError(USAGE);
11
+ }
@@ -0,0 +1,17 @@
1
+ import { runHostIdPostAction } from "./host-post-action.js";
2
+
3
+ /** `POST /hosts/resume` with `{ hostId }` in the body. Idempotent: safe on a host that is not
4
+ * currently draining. */
5
+ export async function runHostResume(argv, io) {
6
+ return runHostIdPostAction(argv, io, {
7
+ commandName: "resume",
8
+ path: "/hosts/resume",
9
+ onSuccess: (result, hostId, flags) => {
10
+ if (flags["--json"]) {
11
+ io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
12
+ return;
13
+ }
14
+ io.stdout.write(`host ${hostId} resumed (safe to run even if it was not draining)\n`);
15
+ },
16
+ });
17
+ }
@@ -0,0 +1,25 @@
1
+ import { CliUsageError } from "../cli-errors.js";
2
+ import { runHostDrain } from "./host-drain.js";
3
+ import { runHostInventory } from "./host-inventory.js";
4
+ import { runHostList } from "./host-list.js";
5
+ import { runHostRepo } from "./host-repo.js";
6
+ import { runHostResume } from "./host-resume.js";
7
+
8
+ const USAGE = `usage: auto-harness host <subcommand> ...
9
+ auto-harness host list [--online | --offline] [--limit N] [--cursor C] [--all] [--json]
10
+ auto-harness host drain <hostId> [--json]
11
+ auto-harness host resume <hostId> [--json]
12
+ auto-harness host inventory get <hostId> [--json]
13
+ auto-harness host inventory set <hostId> --file <path|->
14
+ auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]`;
15
+
16
+ /** Dispatches `host <subcommand>` to its own module — mirrors `main.js`'s own dispatch. */
17
+ export async function runHost(argv, io) {
18
+ const [subcommand, ...rest] = argv;
19
+ if (subcommand === "list") return runHostList(rest, io);
20
+ if (subcommand === "drain") return runHostDrain(rest, io);
21
+ if (subcommand === "resume") return runHostResume(rest, io);
22
+ if (subcommand === "inventory") return runHostInventory(rest, io);
23
+ if (subcommand === "repo") return runHostRepo(rest, io);
24
+ throw new CliUsageError(USAGE);
25
+ }