auto-harness-client 0.6.0 → 0.8.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 (59) hide show
  1. package/README.md +467 -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 +57 -0
  6. package/src/cli/cli-errors.js +16 -0
  7. package/src/cli/commands/api.js +91 -0
  8. package/src/cli/commands/attach-repository.js +117 -0
  9. package/src/cli/commands/dependency-conflict.js +15 -0
  10. package/src/cli/commands/detach-repository.js +109 -0
  11. package/src/cli/commands/doctor.js +164 -0
  12. package/src/cli/commands/host-drain.js +22 -0
  13. package/src/cli/commands/host-inventory-get.js +40 -0
  14. package/src/cli/commands/host-inventory-set.js +66 -0
  15. package/src/cli/commands/host-inventory.js +15 -0
  16. package/src/cli/commands/host-list.js +87 -0
  17. package/src/cli/commands/host-post-action.js +38 -0
  18. package/src/cli/commands/host-repo-add.js +81 -0
  19. package/src/cli/commands/host-repo-rm.js +51 -0
  20. package/src/cli/commands/host-repo.js +16 -0
  21. package/src/cli/commands/host-resume.js +17 -0
  22. package/src/cli/commands/host-smoke-format.js +30 -0
  23. package/src/cli/commands/host-smoke-poll.js +77 -0
  24. package/src/cli/commands/host-smoke-provider.js +172 -0
  25. package/src/cli/commands/host-smoke-repository.js +49 -0
  26. package/src/cli/commands/host-smoke-session-attempt.js +104 -0
  27. package/src/cli/commands/host-smoke-teardown.js +164 -0
  28. package/src/cli/commands/host-smoke.js +151 -0
  29. package/src/cli/commands/host.js +31 -0
  30. package/src/cli/commands/parse-worktree-flag.js +30 -0
  31. package/src/cli/commands/repo-add.js +38 -0
  32. package/src/cli/commands/repo-list.js +84 -0
  33. package/src/cli/commands/repo-rm.js +83 -0
  34. package/src/cli/commands/repo.js +18 -0
  35. package/src/cli/commands/service-account-create.js +114 -0
  36. package/src/cli/commands/service-account-list.js +84 -0
  37. package/src/cli/commands/service-account-rm.js +48 -0
  38. package/src/cli/commands/service-account.js +19 -0
  39. package/src/cli/commands/session-cancel.js +28 -0
  40. package/src/cli/commands/session-create.js +117 -0
  41. package/src/cli/commands/session-get.js +26 -0
  42. package/src/cli/commands/session-logs.js +65 -0
  43. package/src/cli/commands/session-target.js +29 -0
  44. package/src/cli/commands/session.js +22 -0
  45. package/src/cli/commands/whoami.js +22 -0
  46. package/src/cli/config.js +96 -0
  47. package/src/cli/index.js +21 -0
  48. package/src/cli/main.js +71 -0
  49. package/src/cli/path-segment.js +20 -0
  50. package/src/cli/read-stdin.js +9 -0
  51. package/src/cli/report-error.js +34 -0
  52. package/src/cli/service-account-format.js +24 -0
  53. package/src/cli/session-format.js +15 -0
  54. package/src/cli/usage.js +83 -0
  55. package/src/cli/wait-for-session.js +46 -0
  56. package/src/errors.js +1 -0
  57. package/src/index.d.ts +64 -4
  58. package/src/index.js +76 -30
  59. package/src/resolve-target.js +29 -13
@@ -0,0 +1,16 @@
1
+ /** A malformed invocation: a bad flag, a missing/invalid argument, invalid JSON input. Exit 2. */
2
+ export class CliUsageError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "CliUsageError";
6
+ }
7
+ }
8
+
9
+ /** Configuration could not be resolved: no base URL, an unreadable key file, an invalid
10
+ * baseUrl/apiKey combination rejected by `AutoHarnessClient` itself. Exit 2. */
11
+ export class CliConfigError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "CliConfigError";
15
+ }
16
+ }
@@ -0,0 +1,91 @@
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 { readStdin } from "../read-stdin.js";
6
+
7
+ const USAGE = "usage: auto-harness api <METHOD> <path> [--body <json> | --body-file <path|->]";
8
+
9
+ // Methods the control-plane API uses. An explicit list rather than HTTP's token grammar: Fetch
10
+ // also rejects valid tokens (CONNECT, TRACE, TRACK), and a typo should be a usage error (exit 2)
11
+ // before anything is sent, not a Fetch TypeError surfacing as an API failure (exit 1).
12
+ const METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
13
+
14
+ // Segments the WHATWG URL parser resolves as "." and "..", percent-encoded forms included
15
+ // (compared case-insensitively). Letting one through would resolve the request outside
16
+ // `/api/v1` — e.g. `/../../health` becomes `/health` — with the bearer token still attached.
17
+ const DOT_SEGMENTS = new Set([".", "..", "%2e", "%2e%2e", ".%2e", "%2e."]);
18
+
19
+ /**
20
+ * Generic escape hatch for any route. Prints the response's parsed JSON, pretty-printed, on
21
+ * success; prints nothing for a 204 or otherwise empty body. Deliberately does not filter the
22
+ * response the way `whoami`/`doctor` do — an operator asking for `GET /auth/me` through this
23
+ * command gets the raw body, hashes included, because filtering an explicit raw-response request
24
+ * would defeat the point of having an escape hatch at all.
25
+ */
26
+ export async function runApi(argv, io) {
27
+ const { flags, positionals } = parseFlags(argv, {
28
+ valueFlags: [...GLOBAL_VALUE_FLAGS, "--body", "--body-file"],
29
+ booleanFlags: GLOBAL_BOOLEAN_FLAGS,
30
+ });
31
+ const [method, path] = positionals;
32
+ if (!method || !path || positionals.length > 2) throw new CliUsageError(USAGE);
33
+ if (flags["--body"] !== undefined && flags["--body-file"] !== undefined) {
34
+ throw new CliUsageError("--body and --body-file are mutually exclusive");
35
+ }
36
+ const httpMethod = method.toUpperCase();
37
+ if (!METHODS.has(httpMethod)) {
38
+ throw new CliUsageError(
39
+ `unsupported HTTP method ${method}; use one of ${[...METHODS].join(", ")}`,
40
+ );
41
+ }
42
+ const apiPath = normalizeApiPath(path);
43
+ // Both this command's own `--body-file -` and `--admin-password-stdin` need stdin; catch the
44
+ // conflict before reading either, rather than letting one silently drain the other's input.
45
+ const stdinClaimedBy = flags["--body-file"] === "-" ? "api --body-file -" : undefined;
46
+ checkAdminLoginUsage(flags, io.env, stdinClaimedBy);
47
+ const body = await resolveBody(flags, io);
48
+ const client = await createClient(flags, io);
49
+ const result = await client.request(apiPath, {
50
+ method: httpMethod,
51
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
52
+ });
53
+ if (result !== undefined) io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
54
+ return 0;
55
+ }
56
+
57
+ async function resolveBody(flags, io) {
58
+ let text;
59
+ if (flags["--body"] !== undefined) text = flags["--body"];
60
+ else if (flags["--body-file"] === "-") text = await readStdin(io.stdin);
61
+ else if (flags["--body-file"] !== undefined) {
62
+ text = await readBodyFile(flags["--body-file"], io.readFile);
63
+ } else return undefined;
64
+ try {
65
+ return JSON.parse(text);
66
+ } catch {
67
+ throw new CliUsageError("--body/--body-file must contain valid JSON");
68
+ }
69
+ }
70
+
71
+ async function readBodyFile(path, readFile) {
72
+ try {
73
+ return await readFile(path, "utf8");
74
+ } catch (error) {
75
+ throw new CliUsageError(`could not read --body-file ${path}: ${error.message}`);
76
+ }
77
+ }
78
+
79
+ /** The client already prefixes every path with `/api/v1`, so accept both `/hosts` and
80
+ * `/api/v1/hosts` by stripping a leading `/api/v1` segment before handing the path along.
81
+ * Rejects any dot segment in the path part (the query string is not a path), splitting on `\`
82
+ * as well as `/` because an https URL treats a backslash as a segment separator. */
83
+ function normalizeApiPath(path) {
84
+ const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
85
+ const stripped = withLeadingSlash.replace(/^\/api\/v1(?=\/|$)/, "");
86
+ const pathPart = stripped.split(/[?#]/, 1)[0];
87
+ if (pathPart.split(/[/\\]/).some((segment) => DOT_SEGMENTS.has(segment.toLowerCase()))) {
88
+ throw new CliUsageError(`path must not contain "." or ".." segments: ${path}`);
89
+ }
90
+ return stripped === "" ? "/" : stripped;
91
+ }
@@ -0,0 +1,117 @@
1
+ import { AutoHarnessError } from "../../index.js";
2
+ import { pathSegment } from "../path-segment.js";
3
+
4
+ const MAX_ATTEMPTS = 3;
5
+
6
+ /** GET the current inventory record for a host — shared by the pre-write "already attached"
7
+ * check, the dry-run preview, and every retry's re-read, so all three see the same document. */
8
+ function getInventory(client, hostId) {
9
+ return client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`);
10
+ }
11
+
12
+ function findAttachedRepository(record, repositoryId) {
13
+ return (record.repositories ?? []).find((repo) => repo.id === repositoryId);
14
+ }
15
+
16
+ /**
17
+ * Attaches `entry` (an inventory repository entry — see `parseWorktreeFlags` and
18
+ * `host-repo-add.js` for how it is built) to a host's inventory as a safe read-modify-write,
19
+ * mirroring `host-repo-rm.js`'s remove flow: GET the full record, add only the new entry to
20
+ * `repositories`, and PUT back everything else — including `providerAccounts` — exactly as
21
+ * read, keeping the read `version`. A repository already attached to the host is never
22
+ * overwritten; the caller must remove it first. On a 409 (someone else wrote first) this
23
+ * re-reads and re-applies, up to `MAX_ATTEMPTS` PUTs total; any other status is not retried.
24
+ *
25
+ * `{ dryRun: true }` does the same read and "already attached" check but returns before ever
26
+ * writing, so `host repo add --dry-run` and a real attach can never disagree about whether the
27
+ * attach would succeed. Exported (rather than folded into the command) because a later
28
+ * `host smoke` command reuses this exact logic.
29
+ */
30
+ export async function attachRepository(client, hostId, entry, { dryRun = false } = {}) {
31
+ const record = await getInventory(client, hostId);
32
+ const existing = findAttachedRepository(record, entry.id);
33
+ if (existing) {
34
+ throw new Error(
35
+ `repository ${entry.id} is already attached to host ${hostId} at ${existing.path}`,
36
+ );
37
+ }
38
+ if (dryRun) {
39
+ return {
40
+ attached: false,
41
+ dryRun: true,
42
+ convergedElsewhere: false,
43
+ hostId,
44
+ repository: entry,
45
+ worktreeIds: entry.worktrees.map((worktree) => worktree.id),
46
+ fromVersion: record.version ?? 0,
47
+ };
48
+ }
49
+ return attachWithRetry(client, hostId, entry, record);
50
+ }
51
+
52
+ async function attachWithRetry(client, hostId, entry, record) {
53
+ let currentRecord = record;
54
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
55
+ const fromVersion = currentRecord.version ?? 0;
56
+ const document = {
57
+ ...currentRecord,
58
+ repositories: [...(currentRecord.repositories ?? []), entry],
59
+ version: fromVersion,
60
+ };
61
+ try {
62
+ const result = await client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`, {
63
+ method: "PUT",
64
+ body: JSON.stringify(document),
65
+ });
66
+ return {
67
+ attached: true,
68
+ dryRun: false,
69
+ convergedElsewhere: false,
70
+ hostId,
71
+ repository: entry,
72
+ worktreeIds: entry.worktrees.map((worktree) => worktree.id),
73
+ fromVersion,
74
+ toVersion: result?.version,
75
+ };
76
+ } catch (error) {
77
+ const conflict = error instanceof AutoHarnessError && error.status === 409;
78
+ if (!conflict) throw error;
79
+ if (attempt === MAX_ATTEMPTS) {
80
+ throw new Error(
81
+ `inventory for host ${hostId} kept changing; gave up after ${MAX_ATTEMPTS} attempts`,
82
+ { cause: error },
83
+ );
84
+ }
85
+ currentRecord = await getInventory(client, hostId);
86
+ const convergence = reconcileConflict(currentRecord, hostId, entry);
87
+ if (convergence) return convergence;
88
+ }
89
+ }
90
+ /* v8 ignore next 2 -- the loop above always returns or throws before falling out */
91
+ return undefined;
92
+ }
93
+
94
+ /** After a 409, someone else changed the inventory first. If they attached this same repository
95
+ * at the same path, that is the outcome this call wanted — report convergence rather than
96
+ * erroring, exactly like `host repo rm`'s "already removed by another writer" case. A different
97
+ * path is a real conflict: report it using the record now on the server, not our own intent, so
98
+ * the error names what is actually attached. Neither case retries the loop again. */
99
+ function reconcileConflict(currentRecord, hostId, entry) {
100
+ const existing = findAttachedRepository(currentRecord, entry.id);
101
+ if (!existing) return undefined;
102
+ if (existing.path !== entry.path) {
103
+ throw new Error(
104
+ `repository ${entry.id} was attached to host ${hostId} at ${existing.path} by another ` +
105
+ "writer while this command was adding it",
106
+ );
107
+ }
108
+ return {
109
+ attached: true,
110
+ dryRun: false,
111
+ convergedElsewhere: true,
112
+ hostId,
113
+ repository: existing,
114
+ worktreeIds: (existing.worktrees ?? []).map((worktree) => worktree.id),
115
+ toVersion: currentRecord.version,
116
+ };
117
+ }
@@ -0,0 +1,15 @@
1
+ import { AutoHarnessError } from "../../index.js";
2
+
3
+ /** True for a refused delete: a 409 CONFLICT whose body carries `dependencies` (see
4
+ * `AutoHarnessClient#request`, which puts the whole `error` body on `AutoHarnessError#details`).
5
+ * Shared by `repo rm` and `service-account rm`, which both handle this status themselves rather
6
+ * than letting the generic `reportError` print it as an opaque JSON blob. */
7
+ export function isDependencyConflict(error) {
8
+ return error instanceof AutoHarnessError && error.status === 409;
9
+ }
10
+
11
+ /** The dependency array from a conflict's error body, or `[]` if absent/malformed. */
12
+ export function conflictDependencies(error) {
13
+ const dependencies = error.details?.dependencies;
14
+ return Array.isArray(dependencies) ? dependencies : [];
15
+ }
@@ -0,0 +1,109 @@
1
+ import { AutoHarnessError } from "../../index.js";
2
+ import { pathSegment } from "../path-segment.js";
3
+
4
+ const MAX_ATTEMPTS = 3;
5
+
6
+ /** GET the current inventory record for a host — shared by the pre-write extraction, the
7
+ * dry-run preview, and every retry's re-read, so all three see the same document. */
8
+ function getInventory(client, hostId) {
9
+ return client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`);
10
+ }
11
+
12
+ /** Finds the repository by id. By default throws (exit 1) listing what is actually attached;
13
+ * pass `required: false` to get `null` instead — used on a retry's re-read, where the
14
+ * repository being gone already means someone else's write reached the same goal. */
15
+ function extractRepository(record, hostId, repositoryId, { required = true } = {}) {
16
+ const repositories = record.repositories ?? [];
17
+ const repository = repositories.find((repo) => repo.id === repositoryId);
18
+ if (!repository) {
19
+ if (!required) return null;
20
+ const attached = repositories.map((repo) => repo.id);
21
+ throw new Error(
22
+ `repository ${repositoryId} is not attached to host ${hostId}; attached repositories: ` +
23
+ (attached.length > 0 ? attached.join(", ") : "(none)"),
24
+ );
25
+ }
26
+ const worktreeIds = (repository.worktrees ?? []).map((worktree) => worktree.id);
27
+ const remaining = repositories.filter((repo) => repo.id !== repositoryId);
28
+ return { repository, worktreeIds, remaining };
29
+ }
30
+
31
+ /**
32
+ * Detaches one repository from a host's inventory as a safe read-modify-write, mirroring
33
+ * `attach-repository.js`'s attach flow: GET the full record, remove only the target repository
34
+ * (its worktrees go with it — that is a projection of the repository, not a separate thing to
35
+ * delete), and PUT back everything else — including `providerAccounts` — exactly as read,
36
+ * keeping the read `version`. On a 409 (someone else wrote first) this re-reads and re-applies,
37
+ * up to `MAX_ATTEMPTS` PUTs total; any other status is not retried.
38
+ *
39
+ * `{ dryRun: true }` does the same read and lookup but returns before ever writing, so
40
+ * `host repo rm --dry-run` and a real detach can never disagree about what would be removed.
41
+ * Exported (rather than folded into the command) because `host smoke` reuses this exact logic
42
+ * for its own teardown.
43
+ */
44
+ export async function detachRepository(client, hostId, repositoryId, { dryRun = false } = {}) {
45
+ const record = await getInventory(client, hostId);
46
+ const removal = extractRepository(record, hostId, repositoryId);
47
+ if (dryRun) {
48
+ return {
49
+ detached: false,
50
+ dryRun: true,
51
+ convergedElsewhere: false,
52
+ hostId,
53
+ repository: removal.repository,
54
+ worktreeIds: removal.worktreeIds,
55
+ fromVersion: record.version ?? 0,
56
+ };
57
+ }
58
+ return removeWithRetry(client, hostId, repositoryId, record, removal);
59
+ }
60
+
61
+ async function removeWithRetry(client, hostId, repositoryId, record, removal) {
62
+ let currentRecord = record;
63
+ let current = removal;
64
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
65
+ const fromVersion = currentRecord.version ?? 0;
66
+ const document = { ...currentRecord, repositories: current.remaining, version: fromVersion };
67
+ try {
68
+ const result = await client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`, {
69
+ method: "PUT",
70
+ body: JSON.stringify(document),
71
+ });
72
+ return {
73
+ detached: true,
74
+ dryRun: false,
75
+ convergedElsewhere: false,
76
+ hostId,
77
+ repository: current.repository,
78
+ worktreeIds: current.worktreeIds,
79
+ fromVersion,
80
+ toVersion: result?.version,
81
+ };
82
+ } catch (error) {
83
+ const conflict = error instanceof AutoHarnessError && error.status === 409;
84
+ if (!conflict) throw error;
85
+ if (attempt === MAX_ATTEMPTS) {
86
+ throw new Error(
87
+ `inventory for host ${hostId} kept changing; gave up after ${MAX_ATTEMPTS} attempts`,
88
+ { cause: error },
89
+ );
90
+ }
91
+ currentRecord = await getInventory(client, hostId);
92
+ const next = extractRepository(currentRecord, hostId, repositoryId, { required: false });
93
+ if (!next) {
94
+ return {
95
+ detached: true,
96
+ dryRun: false,
97
+ convergedElsewhere: true,
98
+ hostId,
99
+ repository: current.repository,
100
+ worktreeIds: current.worktreeIds,
101
+ toVersion: currentRecord.version,
102
+ };
103
+ }
104
+ current = next;
105
+ }
106
+ }
107
+ /* v8 ignore next 2 -- the loop above always returns or throws before falling out */
108
+ return undefined;
109
+ }
@@ -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
+ }