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,84 @@
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. Mirrors `host list`.
7
+ const MAX_ALL_PAGES = 20;
8
+
9
+ /** `GET /repositories`, paged with `limit`/`cursor`. `--all` follows `nextCursor` itself,
10
+ * capped at `MAX_ALL_PAGES` pages. */
11
+ export async function runRepoList(argv, io) {
12
+ const { flags, positionals } = parseFlags(argv, {
13
+ valueFlags: [...GLOBAL_VALUE_FLAGS, "--limit", "--cursor"],
14
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--all", "--json"],
15
+ });
16
+ if (positionals.length > 0) {
17
+ throw new CliUsageError(`repo list takes no arguments; received: ${positionals.join(" ")}`);
18
+ }
19
+ const client = await createClient(flags, io);
20
+ return flags["--all"] ? runListAll(client, flags, io) : runListOnePage(client, flags, io);
21
+ }
22
+
23
+ function buildQuery(flags, cursor) {
24
+ const query = new URLSearchParams();
25
+ if (flags["--limit"] !== undefined) query.set("limit", flags["--limit"]);
26
+ if (cursor !== undefined) query.set("cursor", cursor);
27
+ const suffix = query.toString();
28
+ return suffix ? `/repositories?${suffix}` : "/repositories";
29
+ }
30
+
31
+ async function runListOnePage(client, flags, io) {
32
+ const page = await client.request(buildQuery(flags, flags["--cursor"]));
33
+ if (flags["--json"]) {
34
+ io.stdout.write(`${JSON.stringify(page, null, 2)}\n`);
35
+ return 0;
36
+ }
37
+ io.stdout.write(formatRepoLines(page.items ?? []));
38
+ if (page.nextCursor) {
39
+ io.stdout.write(
40
+ `more repositories available; pass --cursor ${page.nextCursor} to continue (or --all)\n`,
41
+ );
42
+ }
43
+ return 0;
44
+ }
45
+
46
+ async function runListAll(client, flags, io) {
47
+ const items = [];
48
+ const seenCursors = new Set();
49
+ let cursor = flags["--cursor"];
50
+ for (let pageCount = 0; pageCount < MAX_ALL_PAGES; pageCount += 1) {
51
+ const page = await client.request(buildQuery(flags, cursor));
52
+ items.push(...(page.items ?? []));
53
+ cursor = page.nextCursor || undefined;
54
+ if (!cursor) break;
55
+ // Mirrors the same guard in AutoHarnessClient#listCatalog: a server repeating a cursor is a
56
+ // bug worth failing loudly on, not something to paper over as "more pages than the cap".
57
+ if (seenCursors.has(cursor)) throw new Error("repeated pagination cursor for /repositories");
58
+ seenCursors.add(cursor);
59
+ }
60
+ if (cursor) {
61
+ io.stderr.write(
62
+ `warning: --all stopped after ${MAX_ALL_PAGES} pages; more repositories remain ` +
63
+ `(nextCursor: ${cursor})\n`,
64
+ );
65
+ }
66
+ if (flags["--json"]) {
67
+ io.stdout.write(`${JSON.stringify({ items }, null, 2)}\n`);
68
+ return 0;
69
+ }
70
+ io.stdout.write(formatRepoLines(items));
71
+ return 0;
72
+ }
73
+
74
+ function formatRepoLines(items) {
75
+ if (items.length === 0) return "(no repositories)\n";
76
+ return `${items.map(formatRepoLine).join("\n")}\n`;
77
+ }
78
+
79
+ function formatRepoLine(repo) {
80
+ const parts = [repo.id, repo.name];
81
+ if (repo.status) parts.push(repo.status);
82
+ if (repo.url) parts.push(repo.url);
83
+ return parts.join(" ");
84
+ }
@@ -0,0 +1,83 @@
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
+ import { conflictDependencies, isDependencyConflict } from "./dependency-conflict.js";
6
+
7
+ const USAGE = "usage: auto-harness repo rm <repositoryId> [--json]";
8
+
9
+ /**
10
+ * `DELETE /repositories/<id>`. A 409 is handled here rather than by the generic `reportError`
11
+ * pipeline: the whole point of this command is that the refusal explains itself, one concrete
12
+ * next step per blocking dependency, rather than a `dependencies` array dumped as raw JSON.
13
+ */
14
+ export async function runRepoRm(argv, io) {
15
+ const { flags, positionals } = parseFlags(argv, {
16
+ valueFlags: GLOBAL_VALUE_FLAGS,
17
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
18
+ });
19
+ const [repositoryId] = positionals;
20
+ if (!repositoryId || positionals.length > 1) throw new CliUsageError(USAGE);
21
+ const repositorySegment = pathSegment(repositoryId, "repositoryId");
22
+ const client = await createClient(flags, io);
23
+ try {
24
+ await client.request(`/repositories/${repositorySegment}`, {
25
+ method: "DELETE",
26
+ });
27
+ } catch (error) {
28
+ if (!isDependencyConflict(error)) throw error;
29
+ return printConflict(io, flags, error, repositoryId);
30
+ }
31
+ if (flags["--json"]) {
32
+ io.stdout.write(`${JSON.stringify({ deleted: true, id: repositoryId }, null, 2)}\n`);
33
+ } else {
34
+ io.stdout.write(`repository ${repositoryId} deleted\n`);
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ function printConflict(io, flags, error, repositoryId) {
40
+ const dependencies = conflictDependencies(error);
41
+ const hints = dependencies.map((dependency) => hintFor(dependency, dependencies, repositoryId));
42
+ if (flags["--json"]) {
43
+ io.stdout.write(`${JSON.stringify({ deleted: false, dependencies, hints }, null, 2)}\n`);
44
+ return 1;
45
+ }
46
+ io.stderr.write(`error: ${error.message} (HTTP ${error.status}, ${error.code})\n`);
47
+ for (const hint of hints) io.stderr.write(` ${hint}\n`);
48
+ return 1;
49
+ }
50
+
51
+ /** One concrete next step per dependency `kind`. `worktree` is special-cased: worktrees are
52
+ * removed by detaching the repository from its host, so the hint names that host — taken from a
53
+ * sibling `host-inventory` dependency in the same response when present, else the literal
54
+ * placeholder `<hostId>`. */
55
+ function hintFor(dependency, dependencies, repositoryId) {
56
+ const { kind, id, status } = dependency;
57
+ if (kind === "schedule") return `${kind} ${id}: auto-harness api DELETE /schedules/${id}`;
58
+ if (kind === "session") {
59
+ return (
60
+ `${kind} ${id} is still running (status: ${status}); wait for it, or ` +
61
+ `auto-harness api POST /sessions/${id}/cancel`
62
+ );
63
+ }
64
+ if (kind === "session-drain") {
65
+ return (
66
+ `${kind} ${id} (status: ${status}): auto-harness api POST ` +
67
+ `/repositories/${repositoryId}/session-drains/${id}/release`
68
+ );
69
+ }
70
+ if (kind === "host-inventory") {
71
+ return `${kind} ${id}: auto-harness host repo rm ${id} ${repositoryId}`;
72
+ }
73
+ if (kind === "worktree") {
74
+ const host = dependencies.find((candidate) => candidate.kind === "host-inventory");
75
+ const hostId = host ? host.id : "<hostId>";
76
+ return `${kind} ${id}: auto-harness host repo rm ${hostId} ${repositoryId}`;
77
+ }
78
+ if (kind === "integration" && id === "github-ingress") {
79
+ return `${kind} ${id}: remove this repository's binding from the GitHub ingress configuration`;
80
+ }
81
+ if (kind === "integration") return `${kind} ${id}: remove or retarget integration ${id}`;
82
+ return `${kind} ${id}`;
83
+ }
@@ -0,0 +1,15 @@
1
+ import { CliUsageError } from "../cli-errors.js";
2
+ import { runRepoList } from "./repo-list.js";
3
+ import { runRepoRm } from "./repo-rm.js";
4
+
5
+ const USAGE = `usage: auto-harness repo <subcommand> ...
6
+ auto-harness repo list [--limit N] [--cursor C] [--all] [--json]
7
+ auto-harness repo rm <repositoryId> [--json]`;
8
+
9
+ /** Dispatches `repo <subcommand>` to its own module — mirrors `host.js`'s own dispatch. */
10
+ export async function runRepo(argv, io) {
11
+ const [subcommand, ...rest] = argv;
12
+ if (subcommand === "list") return runRepoList(rest, io);
13
+ if (subcommand === "rm") return runRepoRm(rest, io);
14
+ throw new CliUsageError(USAGE);
15
+ }
@@ -0,0 +1,114 @@
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 { allowlistAccount, formatAccountLine } from "../service-account-format.js";
5
+
6
+ const USAGE =
7
+ "usage: auto-harness service-account create --name <name> --role <role> " +
8
+ "[--bound-host <hostId>] [--repositories <id,id,...>] (--key-file <path> | --print-key) [--json]";
9
+
10
+ /**
11
+ * `POST /auth/service-accounts`. The response's `apiKey` is shown exactly once — the server only
12
+ * stores a hash — so `--key-file`/`--print-key` is a required, mutually-exclusive choice about
13
+ * where that one-time value goes, validated (along with `--repositories`) before any request.
14
+ */
15
+ export async function runServiceAccountCreate(argv, io) {
16
+ const { flags, positionals } = parseFlags(argv, {
17
+ valueFlags: [
18
+ ...GLOBAL_VALUE_FLAGS,
19
+ "--name",
20
+ "--role",
21
+ "--bound-host",
22
+ "--repositories",
23
+ "--key-file",
24
+ ],
25
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--print-key", "--json"],
26
+ });
27
+ if (positionals.length > 0 || !flags["--name"] || !flags["--role"]) {
28
+ throw new CliUsageError(USAGE);
29
+ }
30
+ // An empty value (`--key-file "$UNSET"`) is a mistake, not an absent flag, and both must be
31
+ // caught before the request: an empty --key-file creates the account and only then fails to
32
+ // write its one-time key, orphaning it; an empty --bound-host sends `boundHostId: ""`, which
33
+ // risks minting an unbound key where a host-bound one was meant.
34
+ for (const name of ["--key-file", "--bound-host"]) {
35
+ if (flags[name] !== undefined && flags[name].trim() === "") {
36
+ throw new CliUsageError(`${name} was given an empty value`);
37
+ }
38
+ }
39
+ const hasKeyFile = flags["--key-file"] !== undefined;
40
+ const hasPrintKey = Boolean(flags["--print-key"]);
41
+ if (hasKeyFile === hasPrintKey) {
42
+ throw new CliUsageError("exactly one of --key-file or --print-key is required");
43
+ }
44
+ if (hasPrintKey && flags["--json"]) {
45
+ throw new CliUsageError("--json and --print-key cannot be combined");
46
+ }
47
+ const allowedRepositoryIds = parseRepositories(flags["--repositories"]);
48
+ if (hasKeyFile) await assertKeyFileAbsent(flags["--key-file"], io);
49
+ const client = await createClient(flags, io);
50
+ const result = await client.request("/auth/service-accounts", {
51
+ method: "POST",
52
+ body: JSON.stringify(buildBody(flags, allowedRepositoryIds)),
53
+ });
54
+ const account = allowlistAccount(result.account);
55
+ return hasKeyFile
56
+ ? finishKeyFile(io, flags["--key-file"], flags["--json"], account, result.apiKey)
57
+ : finishPrintKey(io, account, result.apiKey);
58
+ }
59
+
60
+ function buildBody(flags, allowedRepositoryIds) {
61
+ return {
62
+ name: flags["--name"],
63
+ role: flags["--role"],
64
+ ...(flags["--bound-host"] !== undefined ? { boundHostId: flags["--bound-host"] } : {}),
65
+ ...(allowedRepositoryIds !== undefined ? { allowedRepositoryIds } : {}),
66
+ };
67
+ }
68
+
69
+ function parseRepositories(value) {
70
+ if (value === undefined) return undefined;
71
+ const ids = value.split(",");
72
+ if (ids.some((id) => id === "")) {
73
+ throw new CliUsageError("--repositories must be a comma-separated list with no empty entries");
74
+ }
75
+ return ids;
76
+ }
77
+
78
+ /** Best-effort pre-check so an obviously doomed request never fires; the atomic `wx` write in
79
+ * `finishKeyFile` is the real guarantee against overwriting an existing file. */
80
+ async function assertKeyFileAbsent(path, io) {
81
+ let exists = true;
82
+ try {
83
+ await io.readFile(path, "utf8");
84
+ } catch {
85
+ exists = false;
86
+ }
87
+ if (exists)
88
+ throw new CliUsageError(`--key-file ${path} already exists; refusing to overwrite it`);
89
+ }
90
+
91
+ async function finishKeyFile(io, path, json, account, apiKey) {
92
+ try {
93
+ await io.writeFileExclusive(path, `${apiKey}\n`, { mode: 0o600 });
94
+ } catch (error) {
95
+ throw new Error(
96
+ `service account ${account.id} was created but its key could not be written to ${path} ` +
97
+ `(${error.message}); the key is now lost — remove it with ` +
98
+ `\`auto-harness service-account rm ${account.id}\` and create a new one`,
99
+ { cause: error },
100
+ );
101
+ }
102
+ if (json) {
103
+ io.stdout.write(`${JSON.stringify(account, null, 2)}\n`);
104
+ return 0;
105
+ }
106
+ io.stdout.write(`service account ${account.id} created\nkey written to ${path}\n`);
107
+ return 0;
108
+ }
109
+
110
+ function finishPrintKey(io, account, apiKey) {
111
+ io.stderr.write(`${formatAccountLine(account)}\n`);
112
+ io.stdout.write(`${apiKey}\n`);
113
+ return 0;
114
+ }
@@ -0,0 +1,84 @@
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 { allowlistAccount, formatAccountLine } from "../service-account-format.js";
5
+
6
+ // Mirrors `host list`'s and `repo list`'s own cap and guard.
7
+ const MAX_ALL_PAGES = 20;
8
+
9
+ /**
10
+ * `GET /auth/service-accounts`, paged with `limit`/`cursor`. Unlike `host list`/`repo list`,
11
+ * `--json` does *not* print the raw page: items are already server-sanitized, but this allowlists
12
+ * them anyway as defense in depth (see `service-account-format.js`) — including in JSON, so a
13
+ * field outside the allowlist can never leak through either output mode.
14
+ */
15
+ export async function runServiceAccountList(argv, io) {
16
+ const { flags, positionals } = parseFlags(argv, {
17
+ valueFlags: [...GLOBAL_VALUE_FLAGS, "--limit", "--cursor"],
18
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--all", "--json"],
19
+ });
20
+ if (positionals.length > 0) {
21
+ throw new CliUsageError(
22
+ `service-account list takes no arguments; received: ${positionals.join(" ")}`,
23
+ );
24
+ }
25
+ const client = await createClient(flags, io);
26
+ return flags["--all"] ? runListAll(client, flags, io) : runListOnePage(client, flags, io);
27
+ }
28
+
29
+ function buildQuery(flags, cursor) {
30
+ const query = new URLSearchParams();
31
+ if (flags["--limit"] !== undefined) query.set("limit", flags["--limit"]);
32
+ if (cursor !== undefined) query.set("cursor", cursor);
33
+ const suffix = query.toString();
34
+ return suffix ? `/auth/service-accounts?${suffix}` : "/auth/service-accounts";
35
+ }
36
+
37
+ async function runListOnePage(client, flags, io) {
38
+ const page = await client.request(buildQuery(flags, flags["--cursor"]));
39
+ const items = (page.items ?? []).map(allowlistAccount);
40
+ if (flags["--json"]) {
41
+ io.stdout.write(`${JSON.stringify({ items, nextCursor: page.nextCursor }, null, 2)}\n`);
42
+ return 0;
43
+ }
44
+ io.stdout.write(formatAccountLines(items));
45
+ if (page.nextCursor) {
46
+ io.stdout.write(
47
+ `more service accounts available; pass --cursor ${page.nextCursor} to continue (or --all)\n`,
48
+ );
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ async function runListAll(client, flags, io) {
54
+ const items = [];
55
+ const seenCursors = new Set();
56
+ let cursor = flags["--cursor"];
57
+ for (let pageCount = 0; pageCount < MAX_ALL_PAGES; pageCount += 1) {
58
+ const page = await client.request(buildQuery(flags, cursor));
59
+ items.push(...(page.items ?? []).map(allowlistAccount));
60
+ cursor = page.nextCursor || undefined;
61
+ if (!cursor) break;
62
+ if (seenCursors.has(cursor)) {
63
+ throw new Error("repeated pagination cursor for /auth/service-accounts");
64
+ }
65
+ seenCursors.add(cursor);
66
+ }
67
+ if (cursor) {
68
+ io.stderr.write(
69
+ `warning: --all stopped after ${MAX_ALL_PAGES} pages; more service accounts remain ` +
70
+ `(nextCursor: ${cursor})\n`,
71
+ );
72
+ }
73
+ if (flags["--json"]) {
74
+ io.stdout.write(`${JSON.stringify({ items }, null, 2)}\n`);
75
+ return 0;
76
+ }
77
+ io.stdout.write(formatAccountLines(items));
78
+ return 0;
79
+ }
80
+
81
+ function formatAccountLines(items) {
82
+ if (items.length === 0) return "(no service accounts)\n";
83
+ return `${items.map(formatAccountLine).join("\n")}\n`;
84
+ }
@@ -0,0 +1,48 @@
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
+ import { conflictDependencies, isDependencyConflict } from "./dependency-conflict.js";
6
+
7
+ const USAGE = "usage: auto-harness service-account rm <id> [--json]";
8
+
9
+ /** `DELETE /auth/service-accounts/<id>`. A conflict is handled here, generically — one line per
10
+ * dependency (`kind` + `id`) — rather than by the generic `reportError` pipeline, matching
11
+ * `repo rm`'s pattern without `repo rm`'s per-kind hints. */
12
+ export async function runServiceAccountRm(argv, io) {
13
+ const { flags, positionals } = parseFlags(argv, {
14
+ valueFlags: GLOBAL_VALUE_FLAGS,
15
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
16
+ });
17
+ const [id] = positionals;
18
+ if (!id || positionals.length > 1) throw new CliUsageError(USAGE);
19
+ const idSegment = pathSegment(id, "id");
20
+ const client = await createClient(flags, io);
21
+ try {
22
+ await client.request(`/auth/service-accounts/${idSegment}`, {
23
+ method: "DELETE",
24
+ });
25
+ } catch (error) {
26
+ if (!isDependencyConflict(error)) throw error;
27
+ return printConflict(io, flags, error, id);
28
+ }
29
+ if (flags["--json"]) {
30
+ io.stdout.write(`${JSON.stringify({ deleted: true, id }, null, 2)}\n`);
31
+ } else {
32
+ io.stdout.write(`service account ${id} deleted\n`);
33
+ }
34
+ return 0;
35
+ }
36
+
37
+ function printConflict(io, flags, error, id) {
38
+ const dependencies = conflictDependencies(error);
39
+ if (flags["--json"]) {
40
+ io.stdout.write(`${JSON.stringify({ deleted: false, id, dependencies }, null, 2)}\n`);
41
+ return 1;
42
+ }
43
+ io.stderr.write(`error: ${error.message} (HTTP ${error.status}, ${error.code})\n`);
44
+ for (const dependency of dependencies) {
45
+ io.stderr.write(` ${dependency.kind} ${dependency.id}\n`);
46
+ }
47
+ return 1;
48
+ }
@@ -0,0 +1,19 @@
1
+ import { CliUsageError } from "../cli-errors.js";
2
+ import { runServiceAccountCreate } from "./service-account-create.js";
3
+ import { runServiceAccountList } from "./service-account-list.js";
4
+ import { runServiceAccountRm } from "./service-account-rm.js";
5
+
6
+ const USAGE = `usage: auto-harness service-account <subcommand> ...
7
+ auto-harness service-account list [--limit N] [--cursor C] [--all] [--json]
8
+ auto-harness service-account create --name <name> --role <role> [--bound-host <hostId>]
9
+ [--repositories <id,id,...>] (--key-file <path> | --print-key) [--json]
10
+ auto-harness service-account rm <id> [--json]`;
11
+
12
+ /** Dispatches `service-account <subcommand>` to its own module — mirrors `host.js`'s dispatch. */
13
+ export async function runServiceAccount(argv, io) {
14
+ const [subcommand, ...rest] = argv;
15
+ if (subcommand === "list") return runServiceAccountList(rest, io);
16
+ if (subcommand === "create") return runServiceAccountCreate(rest, io);
17
+ if (subcommand === "rm") return runServiceAccountRm(rest, io);
18
+ throw new CliUsageError(USAGE);
19
+ }
@@ -0,0 +1,22 @@
1
+ import { allowlistPrincipal, formatWhoami } from "../allowlist.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
+
6
+ /** `GET /auth/me`, printing only the allowlisted principal fields (see allowlist.js for why the
7
+ * response shape is not trusted). */
8
+ export async function runWhoami(argv, io) {
9
+ const { flags, positionals } = parseFlags(argv, {
10
+ valueFlags: GLOBAL_VALUE_FLAGS,
11
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
12
+ });
13
+ if (positionals.length > 0) {
14
+ throw new CliUsageError(`whoami takes no arguments; received: ${positionals.join(" ")}`);
15
+ }
16
+ const client = await createClient(flags, io);
17
+ const principal = allowlistPrincipal(await client.request("/auth/me"));
18
+ io.stdout.write(
19
+ flags["--json"] ? `${JSON.stringify(principal, null, 2)}\n` : formatWhoami(principal),
20
+ );
21
+ return 0;
22
+ }
@@ -0,0 +1,96 @@
1
+ import { AutoHarnessClient } from "../index.js";
2
+ import { checkAdminLoginUsage, loginAsAdmin } from "./admin-login.js";
3
+ import { CliConfigError } from "./cli-errors.js";
4
+
5
+ export const GLOBAL_VALUE_FLAGS = ["--api-url", "--api-key-file", "--admin-username"];
6
+ export const GLOBAL_BOOLEAN_FLAGS = ["--allow-insecure-http", "--admin-password-stdin"];
7
+
8
+ /** A flag that is present wins, even with an empty value — `--api-url=` or
9
+ * `--api-key-file "$UNSET"` is an explicit choice that went wrong, not an absent flag. Falling
10
+ * back to the environment would silently use a different URL, or authenticate as a different
11
+ * principal, than the one the command line asked for. So an empty value is a config error. */
12
+ function explicitFlag(flags, name) {
13
+ if (!Object.hasOwn(flags, name)) return undefined;
14
+ if (flags[name].trim() === "") throw new CliConfigError(`${name} was given an empty value`);
15
+ return flags[name];
16
+ }
17
+
18
+ /** `--api-url`, else `HARNESS_API_URL`, else `HARNESS_API_HTTP` (the host daemon's own alias —
19
+ * operators already have one of these two set). Missing entirely is a config error naming both. */
20
+ export function resolveApiUrl(flags, env) {
21
+ const url = explicitFlag(flags, "--api-url") ?? (env.HARNESS_API_URL || env.HARNESS_API_HTTP);
22
+ if (!url) {
23
+ throw new CliConfigError(
24
+ "no API base URL configured: set --api-url, or the HARNESS_API_URL environment " +
25
+ "variable (alias: HARNESS_API_HTTP)",
26
+ );
27
+ }
28
+ return url;
29
+ }
30
+
31
+ /**
32
+ * Precedence: an explicit `--api-key-file` flag wins outright — it is the most specific,
33
+ * most intentional signal an operator can give on any one invocation. Failing that, the direct
34
+ * `HARNESS_API_KEY` value wins over the indirect `HARNESS_API_KEY_FILE` pointer, since a plain
35
+ * env var is one less level of indirection to reason about. Any consistent order satisfies the
36
+ * spec here; this one mirrors "flag beats env" from `resolveApiUrl` above.
37
+ */
38
+ export async function resolveApiKey(flags, env, readFile) {
39
+ const keyFile = explicitFlag(flags, "--api-key-file");
40
+ if (keyFile !== undefined) return readApiKeyFile(keyFile, readFile);
41
+ if (env.HARNESS_API_KEY) return env.HARNESS_API_KEY.trim();
42
+ if (env.HARNESS_API_KEY_FILE) return readApiKeyFile(env.HARNESS_API_KEY_FILE, readFile);
43
+ return undefined;
44
+ }
45
+
46
+ async function readApiKeyFile(path, readFile) {
47
+ let contents;
48
+ try {
49
+ contents = await readFile(path, "utf8");
50
+ } catch (error) {
51
+ throw new CliConfigError(`could not read API key file ${path}: ${error.message}`);
52
+ }
53
+ const trimmed = contents.trim();
54
+ if (!trimmed) throw new CliConfigError(`API key file ${path} is empty`);
55
+ return trimmed;
56
+ }
57
+
58
+ /**
59
+ * `--admin-password-stdin` replaces the whole apiKey identity with an admin username/password
60
+ * login, so its usage checks run here — unconditionally, for every command — before either an
61
+ * apiKey is resolved or (in `createClient`) the login request is made.
62
+ */
63
+ export async function resolveConfig(flags, io) {
64
+ const baseUrl = resolveApiUrl(flags, io.env);
65
+ checkAdminLoginUsage(flags, io.env);
66
+ const allowInsecureHttp = Boolean(flags["--allow-insecure-http"]);
67
+ if (flags["--admin-password-stdin"]) {
68
+ const adminUsername = explicitFlag(flags, "--admin-username") ?? "admin";
69
+ return { baseUrl, allowInsecureHttp, adminMode: true, adminUsername };
70
+ }
71
+ const apiKey = await resolveApiKey(flags, io.env, io.readFile);
72
+ return { baseUrl, apiKey, allowInsecureHttp };
73
+ }
74
+
75
+ /** Builds the real `AutoHarnessClient`; a constructor rejection (e.g. plaintext http with an
76
+ * apiKey set against a non-loopback host) is a configuration problem, not an API failure, so it
77
+ * is re-thrown as `CliConfigError` (exit 2) rather than surfacing as a generic exit-1 error. A
78
+ * failed admin login (a bad password, an unreachable server) is deliberately *not* wrapped this
79
+ * way — it is reported as a plain error (exit 1), matching a rejected API key rather than a
80
+ * usage/config problem. */
81
+ export async function createClient(flags, io) {
82
+ const config = await resolveConfig(flags, io);
83
+ const fetchFn = config.adminMode
84
+ ? (await loginAsAdmin(io, config.baseUrl, config.adminUsername, config.allowInsecureHttp)).fetch
85
+ : io.fetch;
86
+ try {
87
+ return new AutoHarnessClient({
88
+ baseUrl: config.baseUrl,
89
+ apiKey: config.apiKey,
90
+ fetch: fetchFn,
91
+ allowInsecureHttp: config.allowInsecureHttp,
92
+ });
93
+ } catch (error) {
94
+ throw new CliConfigError(error.message);
95
+ }
96
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+
4
+ import { main } from "./main.js";
5
+
6
+ // A thin wrapper: every dependency on the outside world is injected here, so `main()` and
7
+ // everything it calls stay unit-testable without spawning a process or touching the network.
8
+ const io = {
9
+ env: process.env,
10
+ fetch: globalThis.fetch,
11
+ stdout: process.stdout,
12
+ stderr: process.stderr,
13
+ stdin: process.stdin,
14
+ readFile,
15
+ // O_CREAT | O_EXCL ("wx") — never overwrites an existing file. Used only for
16
+ // `service-account create --key-file`, where a plaintext API key is written to disk exactly
17
+ // once and mode 0600 (passed by the caller) keeps it readable only by its owner.
18
+ writeFileExclusive: (path, data, options) => writeFile(path, data, { flag: "wx", ...options }),
19
+ };
20
+
21
+ process.exitCode = await main(process.argv.slice(2), io);
@@ -0,0 +1,69 @@
1
+ import { runApi } from "./commands/api.js";
2
+ import { runDoctor } from "./commands/doctor.js";
3
+ import { runHost } from "./commands/host.js";
4
+ import { runRepo } from "./commands/repo.js";
5
+ import { runServiceAccount } from "./commands/service-account.js";
6
+ import { runWhoami } from "./commands/whoami.js";
7
+ import { GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "./config.js";
8
+ import { reportError } from "./report-error.js";
9
+ import { usage } from "./usage.js";
10
+
11
+ const HELP_TOKENS = new Set(["help", "--help", "-h"]);
12
+
13
+ /**
14
+ * A global flag (`--admin-password-stdin`, `--api-url`, ...) is recognized wherever it appears,
15
+ * not only after the command name — `auto-harness --admin-password-stdin service-account create`
16
+ * reads the same as `auto-harness service-account create --admin-password-stdin`. This walks
17
+ * only the *leading* run of `--flag` tokens (an unknown leading flag, e.g. a mistyped one or the
18
+ * rejected `--api-key`, is left in place and falls through to the usage/exit-2 path below,
19
+ * exactly as an unrecognized command would), moves each one after the command name, and hands
20
+ * the rest of `argv` to the matched subcommand's own `parseFlags` untouched.
21
+ */
22
+ function hoistLeadingGlobalFlags(argv) {
23
+ const hoisted = [];
24
+ let index = 0;
25
+ while (index < argv.length && argv[index].startsWith("--")) {
26
+ const arg = argv[index];
27
+ const equals = arg.indexOf("=");
28
+ const name = equals === -1 ? arg : arg.slice(0, equals);
29
+ if (GLOBAL_BOOLEAN_FLAGS.includes(name) && equals === -1) {
30
+ hoisted.push(arg);
31
+ index += 1;
32
+ } else if (GLOBAL_VALUE_FLAGS.includes(name) && equals !== -1) {
33
+ hoisted.push(arg);
34
+ index += 1;
35
+ } else if (GLOBAL_VALUE_FLAGS.includes(name) && index + 1 < argv.length) {
36
+ hoisted.push(arg, argv[index + 1]);
37
+ index += 2;
38
+ } else {
39
+ break;
40
+ }
41
+ }
42
+ return { command: argv[index], rest: [...argv.slice(index + 1), ...hoisted] };
43
+ }
44
+
45
+ /**
46
+ * Runs the CLI end to end and resolves to a process exit code — 0 on success, 1 for an
47
+ * API/HTTP failure or a failed `doctor` check, 2 for a usage or configuration error. Every
48
+ * dependency on the outside world (env, fetch, the standard streams, file reads) is injected
49
+ * through `io` so this never spawns a process or touches the real network in tests.
50
+ */
51
+ export async function main(argv, io) {
52
+ const { command, rest } = hoistLeadingGlobalFlags(argv);
53
+ if (command === undefined || HELP_TOKENS.has(command)) {
54
+ io.stdout.write(usage());
55
+ return 0;
56
+ }
57
+ try {
58
+ if (command === "api") return await runApi(rest, io);
59
+ if (command === "whoami") return await runWhoami(rest, io);
60
+ if (command === "doctor") return await runDoctor(rest, io);
61
+ if (command === "host") return await runHost(rest, io);
62
+ if (command === "repo") return await runRepo(rest, io);
63
+ if (command === "service-account") return await runServiceAccount(rest, io);
64
+ io.stderr.write(usage());
65
+ return 2;
66
+ } catch (error) {
67
+ return reportError(error, io);
68
+ }
69
+ }