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.
- package/README.md +467 -0
- package/package.json +5 -1
- package/src/cli/admin-login.js +71 -0
- package/src/cli/allowlist.js +34 -0
- package/src/cli/args.js +57 -0
- package/src/cli/cli-errors.js +16 -0
- package/src/cli/commands/api.js +91 -0
- package/src/cli/commands/attach-repository.js +117 -0
- package/src/cli/commands/dependency-conflict.js +15 -0
- package/src/cli/commands/detach-repository.js +109 -0
- package/src/cli/commands/doctor.js +164 -0
- package/src/cli/commands/host-drain.js +22 -0
- package/src/cli/commands/host-inventory-get.js +40 -0
- package/src/cli/commands/host-inventory-set.js +66 -0
- package/src/cli/commands/host-inventory.js +15 -0
- package/src/cli/commands/host-list.js +87 -0
- package/src/cli/commands/host-post-action.js +38 -0
- package/src/cli/commands/host-repo-add.js +81 -0
- package/src/cli/commands/host-repo-rm.js +51 -0
- package/src/cli/commands/host-repo.js +16 -0
- package/src/cli/commands/host-resume.js +17 -0
- package/src/cli/commands/host-smoke-format.js +30 -0
- package/src/cli/commands/host-smoke-poll.js +77 -0
- package/src/cli/commands/host-smoke-provider.js +172 -0
- package/src/cli/commands/host-smoke-repository.js +49 -0
- package/src/cli/commands/host-smoke-session-attempt.js +104 -0
- package/src/cli/commands/host-smoke-teardown.js +164 -0
- package/src/cli/commands/host-smoke.js +151 -0
- package/src/cli/commands/host.js +31 -0
- package/src/cli/commands/parse-worktree-flag.js +30 -0
- package/src/cli/commands/repo-add.js +38 -0
- package/src/cli/commands/repo-list.js +84 -0
- package/src/cli/commands/repo-rm.js +83 -0
- package/src/cli/commands/repo.js +18 -0
- package/src/cli/commands/service-account-create.js +114 -0
- package/src/cli/commands/service-account-list.js +84 -0
- package/src/cli/commands/service-account-rm.js +48 -0
- package/src/cli/commands/service-account.js +19 -0
- package/src/cli/commands/session-cancel.js +28 -0
- package/src/cli/commands/session-create.js +117 -0
- package/src/cli/commands/session-get.js +26 -0
- package/src/cli/commands/session-logs.js +65 -0
- package/src/cli/commands/session-target.js +29 -0
- package/src/cli/commands/session.js +22 -0
- package/src/cli/commands/whoami.js +22 -0
- package/src/cli/config.js +96 -0
- package/src/cli/index.js +21 -0
- package/src/cli/main.js +71 -0
- package/src/cli/path-segment.js +20 -0
- package/src/cli/read-stdin.js +9 -0
- package/src/cli/report-error.js +34 -0
- package/src/cli/service-account-format.js +24 -0
- package/src/cli/session-format.js +15 -0
- package/src/cli/usage.js +83 -0
- package/src/cli/wait-for-session.js +46 -0
- package/src/errors.js +1 -0
- package/src/index.d.ts +64 -4
- package/src/index.js +76 -30
- package/src/resolve-target.js +29 -13
|
@@ -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,18 @@
|
|
|
1
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
2
|
+
import { runRepoAdd } from "./repo-add.js";
|
|
3
|
+
import { runRepoList } from "./repo-list.js";
|
|
4
|
+
import { runRepoRm } from "./repo-rm.js";
|
|
5
|
+
|
|
6
|
+
const USAGE = `usage: auto-harness repo <subcommand> ...
|
|
7
|
+
auto-harness repo add --name <name> --url <url> [--default-branch <branch>] [--json]
|
|
8
|
+
auto-harness repo list [--limit N] [--cursor C] [--all] [--json]
|
|
9
|
+
auto-harness repo rm <repositoryId> [--json]`;
|
|
10
|
+
|
|
11
|
+
/** Dispatches `repo <subcommand>` to its own module — mirrors `host.js`'s own dispatch. */
|
|
12
|
+
export async function runRepo(argv, io) {
|
|
13
|
+
const [subcommand, ...rest] = argv;
|
|
14
|
+
if (subcommand === "add") return runRepoAdd(rest, io);
|
|
15
|
+
if (subcommand === "list") return runRepoList(rest, io);
|
|
16
|
+
if (subcommand === "rm") return runRepoRm(rest, io);
|
|
17
|
+
throw new CliUsageError(USAGE);
|
|
18
|
+
}
|
|
@@ -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,28 @@
|
|
|
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 { formatSessionLine } from "../session-format.js";
|
|
6
|
+
|
|
7
|
+
const USAGE = "usage: auto-harness session cancel <sessionId> [--json]";
|
|
8
|
+
|
|
9
|
+
/** `POST /sessions/<id>/cancel`, via the library's `cancelSession()` (which encodes the id
|
|
10
|
+
* itself). A 404 (unknown id) or 409 (already terminal) surfaces through the normal
|
|
11
|
+
* `reportError` path in `main.js`, same as every other command. */
|
|
12
|
+
export async function runSessionCancel(argv, io) {
|
|
13
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
14
|
+
valueFlags: GLOBAL_VALUE_FLAGS,
|
|
15
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
|
|
16
|
+
});
|
|
17
|
+
const [sessionId] = positionals;
|
|
18
|
+
if (!sessionId || positionals.length > 1) throw new CliUsageError(USAGE);
|
|
19
|
+
// Validates before any request; the encoded value itself is unused since
|
|
20
|
+
// client.cancelSession() encodes the raw id again on its own.
|
|
21
|
+
pathSegment(sessionId, "sessionId");
|
|
22
|
+
const client = await createClient(flags, io);
|
|
23
|
+
const session = await client.cancelSession(sessionId);
|
|
24
|
+
io.stdout.write(
|
|
25
|
+
flags["--json"] ? `${JSON.stringify(session, null, 2)}\n` : formatSessionLine(session),
|
|
26
|
+
);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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 { formatSessionLine } from "../session-format.js";
|
|
5
|
+
import { waitForSession } from "../wait-for-session.js";
|
|
6
|
+
import { resolveSessionTarget } from "./session-target.js";
|
|
7
|
+
|
|
8
|
+
const USAGE =
|
|
9
|
+
"usage: auto-harness session create --repo <repositoryId> " +
|
|
10
|
+
"(--provider <id|name> | --command <id|name>) --prompt <text> [--timeout <seconds>] " +
|
|
11
|
+
"[--ref <ref>] [--concurrency-id <id>] [--wait [--wait-timeout <seconds>]] [--json]";
|
|
12
|
+
|
|
13
|
+
const VALUE_FLAGS = [
|
|
14
|
+
"--repo",
|
|
15
|
+
"--provider",
|
|
16
|
+
"--command",
|
|
17
|
+
"--prompt",
|
|
18
|
+
"--timeout",
|
|
19
|
+
"--ref",
|
|
20
|
+
"--concurrency-id",
|
|
21
|
+
"--wait-timeout",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Mirrors the create-session form's own default in
|
|
25
|
+
// services/web/src/components/session-timeout-field.tsx (`SessionTimeoutField`,
|
|
26
|
+
// `initialSeconds = 600`). The server requires `timeout`; there is no server-side default
|
|
27
|
+
// (`sessionTimeoutError` in modules/shared/src/validation.ts rejects `undefined`).
|
|
28
|
+
const DEFAULT_TIMEOUT_SECONDS = 600;
|
|
29
|
+
|
|
30
|
+
// Arbitrary, small: keeps `--wait` responsive without hammering the API.
|
|
31
|
+
const WAIT_POLL_INTERVAL_MS = 2_000;
|
|
32
|
+
|
|
33
|
+
function parsePositiveNumber(value, label) {
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
36
|
+
throw new CliUsageError(`${label} must be a positive number`);
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateFlags(flags) {
|
|
42
|
+
for (const name of VALUE_FLAGS) {
|
|
43
|
+
if (flags[name] !== undefined && flags[name].trim() === "") {
|
|
44
|
+
throw new CliUsageError(`${name} was given an empty value`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!flags["--repo"] || flags["--prompt"] === undefined) throw new CliUsageError(USAGE);
|
|
48
|
+
const hasProvider = flags["--provider"] !== undefined;
|
|
49
|
+
const hasCommand = flags["--command"] !== undefined;
|
|
50
|
+
if (hasProvider === hasCommand) {
|
|
51
|
+
throw new CliUsageError("exactly one of --provider or --command is required");
|
|
52
|
+
}
|
|
53
|
+
if (flags["--wait-timeout"] !== undefined && !flags["--wait"]) {
|
|
54
|
+
throw new CliUsageError("--wait-timeout requires --wait");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `POST /sessions`. `--provider`/`--command` accept either an id or a name (see
|
|
60
|
+
* `session-target.js`); `--timeout` defaults to `DEFAULT_TIMEOUT_SECONDS` since the server
|
|
61
|
+
* requires it but has no default of its own. With `--wait`, polls via `waitForSession()` —
|
|
62
|
+
* status changes go to stderr, the final session record to stdout — and exits 0 only when the
|
|
63
|
+
* session `completed` with `exitCode === 0`; any other terminal status, or the wait timing out,
|
|
64
|
+
* exits 1 (a timed-out wait never cancels the session).
|
|
65
|
+
*/
|
|
66
|
+
export async function runSessionCreate(argv, io) {
|
|
67
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
68
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, ...VALUE_FLAGS],
|
|
69
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--wait", "--json"],
|
|
70
|
+
});
|
|
71
|
+
if (positionals.length > 0) throw new CliUsageError(USAGE);
|
|
72
|
+
validateFlags(flags);
|
|
73
|
+
const timeout =
|
|
74
|
+
flags["--timeout"] !== undefined
|
|
75
|
+
? parsePositiveNumber(flags["--timeout"], "--timeout")
|
|
76
|
+
: DEFAULT_TIMEOUT_SECONDS;
|
|
77
|
+
const waitTimeoutSeconds = flags["--wait"]
|
|
78
|
+
? flags["--wait-timeout"] !== undefined
|
|
79
|
+
? parsePositiveNumber(flags["--wait-timeout"], "--wait-timeout")
|
|
80
|
+
: timeout
|
|
81
|
+
: undefined;
|
|
82
|
+
|
|
83
|
+
const client = await createClient(flags, io);
|
|
84
|
+
const target = await resolveSessionTarget(client, flags);
|
|
85
|
+
const created = await client.createSession({
|
|
86
|
+
repositoryId: flags["--repo"],
|
|
87
|
+
prompt: flags["--prompt"],
|
|
88
|
+
target,
|
|
89
|
+
timeout,
|
|
90
|
+
...(flags["--ref"] !== undefined ? { ref: flags["--ref"] } : {}),
|
|
91
|
+
...(flags["--concurrency-id"] !== undefined
|
|
92
|
+
? { concurrencyId: flags["--concurrency-id"] }
|
|
93
|
+
: {}),
|
|
94
|
+
});
|
|
95
|
+
if (!flags["--wait"]) return writeResult(io, flags, created, 0);
|
|
96
|
+
|
|
97
|
+
const { timedOut, session } = await waitForSession(client, created.id, {
|
|
98
|
+
timeoutMs: waitTimeoutSeconds * 1000,
|
|
99
|
+
intervalMs: WAIT_POLL_INTERVAL_MS,
|
|
100
|
+
onStatus: (status) => io.stderr.write(`session ${created.id}: ${status}\n`),
|
|
101
|
+
});
|
|
102
|
+
if (timedOut) {
|
|
103
|
+
io.stderr.write(
|
|
104
|
+
`session ${created.id} is still running after ${waitTimeoutSeconds}s; not cancelling it\n`,
|
|
105
|
+
);
|
|
106
|
+
return writeResult(io, flags, session, 1);
|
|
107
|
+
}
|
|
108
|
+
const succeeded = session.status === "completed" && session.exitCode === 0;
|
|
109
|
+
return writeResult(io, flags, session, succeeded ? 0 : 1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeResult(io, flags, session, exitCode) {
|
|
113
|
+
io.stdout.write(
|
|
114
|
+
flags["--json"] ? `${JSON.stringify(session, null, 2)}\n` : formatSessionLine(session),
|
|
115
|
+
);
|
|
116
|
+
return exitCode;
|
|
117
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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 { formatSessionLine } from "../session-format.js";
|
|
6
|
+
|
|
7
|
+
const USAGE = "usage: auto-harness session get <sessionId> [--json]";
|
|
8
|
+
|
|
9
|
+
/** `GET /sessions/<id>`, via the library's `getSession()` (which encodes the id itself). */
|
|
10
|
+
export async function runSessionGet(argv, io) {
|
|
11
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
12
|
+
valueFlags: GLOBAL_VALUE_FLAGS,
|
|
13
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
|
|
14
|
+
});
|
|
15
|
+
const [sessionId] = positionals;
|
|
16
|
+
if (!sessionId || positionals.length > 1) throw new CliUsageError(USAGE);
|
|
17
|
+
// Validates before any request (and before an admin-mode login); the encoded value itself is
|
|
18
|
+
// unused since client.getSession() encodes the raw id again on its own.
|
|
19
|
+
pathSegment(sessionId, "sessionId");
|
|
20
|
+
const client = await createClient(flags, io);
|
|
21
|
+
const session = await client.getSession(sessionId);
|
|
22
|
+
io.stdout.write(
|
|
23
|
+
flags["--json"] ? `${JSON.stringify(session, null, 2)}\n` : formatSessionLine(session),
|
|
24
|
+
);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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 session logs <sessionId> [--limit N] [--cursor C] [--json]";
|
|
7
|
+
|
|
8
|
+
// Mirrors DEFAULT_LOG_QUERY_LIMIT in services/api/src/log-query.ts — used only to guess whether
|
|
9
|
+
// a page came back full when --limit was not given (see the pagination note below).
|
|
10
|
+
const DEFAULT_LOG_LIMIT = 1000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `GET /sessions/<id>/logs`. Unlike `repo list`/`host list`, this endpoint returns `{ items }`
|
|
14
|
+
* with no `nextCursor` — its only continuation knob is `since` (services/api/src/log-query.ts),
|
|
15
|
+
* a whole ISO-8601 timestamp, exclusive. This CLI exposes that as `--cursor` for a pagination
|
|
16
|
+
* vocabulary consistent with the other list commands, mapped straight to `since`. Passing the
|
|
17
|
+
* last printed item's own `timestamp` back as the next `--cursor` therefore excludes *every*
|
|
18
|
+
* record sharing that exact timestamp, not only the one already shown — coarser than a true row
|
|
19
|
+
* cursor, but that is the bounded contract `parseLogQuery` actually offers (the true `after`
|
|
20
|
+
* cursor is internal-only, used by viewer reconnects, and never exposed over REST). One page is
|
|
21
|
+
* printed; never looped, per this repo's list/history invariant.
|
|
22
|
+
*/
|
|
23
|
+
export async function runSessionLogs(argv, io) {
|
|
24
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
25
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, "--limit", "--cursor"],
|
|
26
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
|
|
27
|
+
});
|
|
28
|
+
const [sessionId] = positionals;
|
|
29
|
+
if (!sessionId || positionals.length > 1) throw new CliUsageError(USAGE);
|
|
30
|
+
for (const name of ["--limit", "--cursor"]) {
|
|
31
|
+
if (flags[name] !== undefined && flags[name].trim() === "") {
|
|
32
|
+
throw new CliUsageError(`${name} was given an empty value`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const limit = flags["--limit"] !== undefined ? Number(flags["--limit"]) : DEFAULT_LOG_LIMIT;
|
|
36
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
37
|
+
throw new CliUsageError("--limit must be a positive integer");
|
|
38
|
+
}
|
|
39
|
+
const sessionSegment = pathSegment(sessionId, "sessionId");
|
|
40
|
+
const client = await createClient(flags, io);
|
|
41
|
+
const query = new URLSearchParams();
|
|
42
|
+
if (flags["--limit"] !== undefined) query.set("limit", flags["--limit"]);
|
|
43
|
+
if (flags["--cursor"] !== undefined) query.set("since", flags["--cursor"]);
|
|
44
|
+
const suffix = query.toString();
|
|
45
|
+
const page = await client.request(
|
|
46
|
+
`/sessions/${sessionSegment}/logs${suffix ? `?${suffix}` : ""}`,
|
|
47
|
+
);
|
|
48
|
+
if (flags["--json"]) {
|
|
49
|
+
io.stdout.write(`${JSON.stringify(page, null, 2)}\n`);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
const items = page.items ?? [];
|
|
53
|
+
io.stdout.write(formatLogLines(items));
|
|
54
|
+
if (items.length === limit) {
|
|
55
|
+
io.stdout.write(
|
|
56
|
+
`more logs may be available; pass --cursor ${items.at(-1).timestamp} to continue\n`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatLogLines(items) {
|
|
63
|
+
if (items.length === 0) return "(no logs)\n";
|
|
64
|
+
return `${items.map((item) => `${item.timestamp} [${item.stream}] ${item.content}`).join("\n")}\n`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves `--provider <id|name>` or `--command <id|name>` to a `TargetRef` for `createSession()`.
|
|
3
|
+
* Exactly one of `flags["--provider"]`/`flags["--command"]` must already be set — callers
|
|
4
|
+
* validate that mutual-exclusivity/required check before calling this.
|
|
5
|
+
*
|
|
6
|
+
* Lists the relevant catalog once (`client.listProviders()`/`listCommands()`) and checks for an
|
|
7
|
+
* exact `id` match locally. A hit returns an already-id-shaped ref, which `resolveTargetSpecs`
|
|
8
|
+
* (inside `client.createSession()`) passes straight through with no further request. A miss
|
|
9
|
+
* returns a `providerName`/`commandName` ref instead — deliberately *not* resolved here — so
|
|
10
|
+
* `createSession()`'s own `resolveCreateSessionTargets()` resolves it, reusing its exact
|
|
11
|
+
* not-found/ambiguous-name errors (`UNKNOWN_PROVIDER_NAME`, `AMBIGUOUS_PROVIDER_NAME`, ...)
|
|
12
|
+
* rather than reimplementing that matching. One consequence: a mistyped id surfaces as
|
|
13
|
+
* `no provider named "<value>"`, not a distinct "unknown id" error.
|
|
14
|
+
*/
|
|
15
|
+
export async function resolveSessionTarget(client, flags) {
|
|
16
|
+
if (flags["--provider"] !== undefined) {
|
|
17
|
+
return resolveOne(
|
|
18
|
+
await client.listProviders(),
|
|
19
|
+
flags["--provider"],
|
|
20
|
+
"providerId",
|
|
21
|
+
"providerName",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return resolveOne(await client.listCommands(), flags["--command"], "commandId", "commandName");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveOne(items, value, idKey, nameKey) {
|
|
28
|
+
return items.some((item) => item.id === value) ? { [idKey]: value } : { [nameKey]: value };
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
2
|
+
import { runSessionCancel } from "./session-cancel.js";
|
|
3
|
+
import { runSessionCreate } from "./session-create.js";
|
|
4
|
+
import { runSessionGet } from "./session-get.js";
|
|
5
|
+
import { runSessionLogs } from "./session-logs.js";
|
|
6
|
+
|
|
7
|
+
const USAGE = `usage: auto-harness session <subcommand> ...
|
|
8
|
+
auto-harness session create --repo <repositoryId> (--provider <id|name> | --command <id|name>) --prompt <text>
|
|
9
|
+
[--timeout <seconds>] [--ref <ref>] [--concurrency-id <id>] [--wait [--wait-timeout <seconds>]] [--json]
|
|
10
|
+
auto-harness session get <sessionId> [--json]
|
|
11
|
+
auto-harness session logs <sessionId> [--limit N] [--cursor C] [--json]
|
|
12
|
+
auto-harness session cancel <sessionId> [--json]`;
|
|
13
|
+
|
|
14
|
+
/** Dispatches `session <subcommand>` to its own module — mirrors `host.js`'s own dispatch. */
|
|
15
|
+
export async function runSession(argv, io) {
|
|
16
|
+
const [subcommand, ...rest] = argv;
|
|
17
|
+
if (subcommand === "create") return runSessionCreate(rest, io);
|
|
18
|
+
if (subcommand === "get") return runSessionGet(rest, io);
|
|
19
|
+
if (subcommand === "logs") return runSessionLogs(rest, io);
|
|
20
|
+
if (subcommand === "cancel") return runSessionCancel(rest, io);
|
|
21
|
+
throw new CliUsageError(USAGE);
|
|
22
|
+
}
|