auto-harness-client 0.5.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 (43) hide show
  1. package/README.md +292 -0
  2. package/package.json +5 -1
  3. package/src/actions/drain.js +8 -0
  4. package/src/actions/index.d.ts +15 -1
  5. package/src/actions/index.js +2 -0
  6. package/src/actions/write-drain-outputs.js +18 -0
  7. package/src/cli/admin-login.js +71 -0
  8. package/src/cli/allowlist.js +34 -0
  9. package/src/cli/args.js +41 -0
  10. package/src/cli/cli-errors.js +16 -0
  11. package/src/cli/commands/api.js +91 -0
  12. package/src/cli/commands/dependency-conflict.js +15 -0
  13. package/src/cli/commands/doctor.js +164 -0
  14. package/src/cli/commands/host-drain.js +22 -0
  15. package/src/cli/commands/host-inventory-get.js +40 -0
  16. package/src/cli/commands/host-inventory-set.js +66 -0
  17. package/src/cli/commands/host-inventory.js +15 -0
  18. package/src/cli/commands/host-list.js +87 -0
  19. package/src/cli/commands/host-post-action.js +38 -0
  20. package/src/cli/commands/host-repo-rm.js +128 -0
  21. package/src/cli/commands/host-repo.js +11 -0
  22. package/src/cli/commands/host-resume.js +17 -0
  23. package/src/cli/commands/host.js +25 -0
  24. package/src/cli/commands/repo-list.js +84 -0
  25. package/src/cli/commands/repo-rm.js +83 -0
  26. package/src/cli/commands/repo.js +15 -0
  27. package/src/cli/commands/service-account-create.js +114 -0
  28. package/src/cli/commands/service-account-list.js +84 -0
  29. package/src/cli/commands/service-account-rm.js +48 -0
  30. package/src/cli/commands/service-account.js +19 -0
  31. package/src/cli/commands/whoami.js +22 -0
  32. package/src/cli/config.js +96 -0
  33. package/src/cli/index.js +21 -0
  34. package/src/cli/main.js +69 -0
  35. package/src/cli/path-segment.js +20 -0
  36. package/src/cli/read-stdin.js +9 -0
  37. package/src/cli/report-error.js +34 -0
  38. package/src/cli/service-account-format.js +24 -0
  39. package/src/cli/usage.js +66 -0
  40. package/src/errors.js +1 -0
  41. package/src/index.d.ts +64 -4
  42. package/src/index.js +76 -30
  43. package/src/resolve-target.js +29 -13
@@ -0,0 +1,20 @@
1
+ import { CliUsageError } from "./cli-errors.js";
2
+
3
+ /**
4
+ * Encodes one path segment taken from the command line, such as a host or repository id.
5
+ *
6
+ * `encodeURIComponent` leaves `.` untouched, so an id of `.` or `..` survives encoding and the
7
+ * URL parser then resolves it as a dot segment: `repo rm ..` would send
8
+ * `DELETE /api/v1/repositories/..`, which is `DELETE /api/v1/`. No real id is `.` or `..`, so
9
+ * both are rejected. Percent-encoded dots need no check here: encoding turns `%2e` into `%252e`,
10
+ * which the parser does not treat as a dot segment.
11
+ *
12
+ * Call it as soon as the positional is parsed, before `createClient` — in admin mode that makes
13
+ * a network login, and a bad id should not cost one.
14
+ */
15
+ export function pathSegment(value, label) {
16
+ if (value === "." || value === "..") {
17
+ throw new CliUsageError(`${label} must not be "." or ".."`);
18
+ }
19
+ return encodeURIComponent(value);
20
+ }
@@ -0,0 +1,9 @@
1
+ /** Reads an injected `stdin` (any async-iterable of `Buffer`/`string` chunks — the real
2
+ * `process.stdin`, or a `Readable.from([...])` in tests) fully into a UTF-8 string. */
3
+ export async function readStdin(stream) {
4
+ const chunks = [];
5
+ for await (const chunk of stream) {
6
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
7
+ }
8
+ return Buffer.concat(chunks).toString("utf8");
9
+ }
@@ -0,0 +1,34 @@
1
+ import { AutoHarnessError, AutoHarnessRequestTimeoutError } from "../errors.js";
2
+ import { CliConfigError, CliUsageError } from "./cli-errors.js";
3
+
4
+ /** Writes a one-line error (plus, for an `AutoHarnessError`, any `details` fields beyond
5
+ * `code`/`message` — e.g. a refused delete's `dependencies`) to `io.stderr` and returns the
6
+ * process exit code for it: 2 for a usage/config error, 1 for anything else. */
7
+ export function reportError(error, io) {
8
+ if (error instanceof CliUsageError || error instanceof CliConfigError) {
9
+ io.stderr.write(`error: ${error.message}\n`);
10
+ return 2;
11
+ }
12
+ if (error instanceof AutoHarnessRequestTimeoutError) {
13
+ io.stderr.write(`error: ${error.message}\n`);
14
+ return 1;
15
+ }
16
+ if (error instanceof AutoHarnessError) {
17
+ io.stderr.write(`error: ${error.message} (HTTP ${error.status}, ${error.code})\n`);
18
+ const extra = extraDetailFields(error.details);
19
+ if (extra) io.stderr.write(`${JSON.stringify(extra, null, 2)}\n`);
20
+ return 1;
21
+ }
22
+ io.stderr.write(`error: ${error.message}\n`);
23
+ return 1;
24
+ }
25
+
26
+ function extraDetailFields(details) {
27
+ if (!details || typeof details !== "object") return undefined;
28
+ const extra = {};
29
+ for (const [key, value] of Object.entries(details)) {
30
+ if (key === "code" || key === "message") continue;
31
+ extra[key] = value;
32
+ }
33
+ return Object.keys(extra).length > 0 ? extra : undefined;
34
+ }
@@ -0,0 +1,24 @@
1
+ import { allowlistPrincipal } from "./allowlist.js";
2
+
3
+ /**
4
+ * `allowlistPrincipal` plus `createdAt`. `GET /auth/service-accounts` items (and the `account`
5
+ * returned by `POST /auth/service-accounts`) are already server-sanitized (`publicPrincipal`)
6
+ * plus `name`/`createdAt`, but this is printed through the same allowlist as `whoami`/`doctor`
7
+ * as defense in depth — `createdAt` is added back on top since it is not part of a bare
8
+ * principal and so is not in `ALLOWED_PRINCIPAL_FIELDS`.
9
+ */
10
+ export function allowlistAccount(account) {
11
+ const allowed = allowlistPrincipal(account);
12
+ if (account && typeof account === "object" && Object.hasOwn(account, "createdAt")) {
13
+ allowed.createdAt = account.createdAt;
14
+ }
15
+ return allowed;
16
+ }
17
+
18
+ /** One-line human summary: id, name, role, and (when present) boundHostId/createdAt. */
19
+ export function formatAccountLine(account) {
20
+ const parts = [account.id, account.name, account.role];
21
+ if (account.boundHostId) parts.push(account.boundHostId);
22
+ if (account.createdAt) parts.push(account.createdAt);
23
+ return parts.join(" ");
24
+ }
@@ -0,0 +1,66 @@
1
+ export function usage() {
2
+ return `auto-harness - operator CLI for the Auto Harness control plane API
3
+
4
+ A global flag (--api-url, --api-key-file, --allow-insecure-http, --admin-password-stdin,
5
+ --admin-username) is recognized before or after the command name — both
6
+ \`auto-harness --admin-password-stdin whoami\` and \`auto-harness whoami --admin-password-stdin\`
7
+ work the same way.
8
+
9
+ Usage:
10
+ auto-harness api <METHOD> <path> [--body <json> | --body-file <path|->]
11
+ auto-harness whoami [--json]
12
+ auto-harness doctor
13
+ auto-harness host list [--online | --offline] [--limit N] [--cursor C] [--all] [--json]
14
+ auto-harness host drain <hostId> [--json]
15
+ auto-harness host resume <hostId> [--json]
16
+ auto-harness host inventory get <hostId> [--json]
17
+ auto-harness host inventory set <hostId> --file <path|->
18
+ auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]
19
+ auto-harness repo list [--limit N] [--cursor C] [--all] [--json]
20
+ auto-harness repo rm <repositoryId> [--json]
21
+ auto-harness service-account list [--limit N] [--cursor C] [--all] [--json]
22
+ auto-harness service-account create --name <name> --role <role> [--bound-host <hostId>]
23
+ [--repositories <id,id,...>] (--key-file <path> | --print-key) [--json]
24
+ auto-harness service-account rm <id> [--json]
25
+ auto-harness help | --help | -h
26
+
27
+ Configuration:
28
+ --api-url <url> Control plane base URL (else HARNESS_API_URL, else HARNESS_API_HTTP)
29
+ --api-key-file <path> Read the API key from a file, trimmed (else HARNESS_API_KEY_FILE)
30
+ --allow-insecure-http Allow a plain http:// baseUrl (loopback only; local dev)
31
+
32
+ The API key is never accepted as a command-line flag: it would land in \`ps\` output and shell
33
+ history. Set the HARNESS_API_KEY environment variable, or point --api-key-file /
34
+ HARNESS_API_KEY_FILE at a file holding it.
35
+
36
+ Admin bootstrap (no API key exists yet):
37
+ --admin-password-stdin Log in as an admin (password piped through stdin) instead of
38
+ using an API key; combine with --admin-username (default: admin)
39
+ --admin-username <name> Admin username for --admin-password-stdin (default: admin)
40
+
41
+ --admin-password-stdin reads the password from stdin (one trailing newline stripped), logs in
42
+ once via POST /auth/login, and carries the session cookie on every later request instead of an
43
+ API key — it never touches argv, shell history, or output. It cannot be combined with an API key
44
+ (--api-key-file, HARNESS_API_KEY, or HARNESS_API_KEY_FILE), nor with a command that also reads
45
+ stdin for its own input (\`api --body-file -\`, \`host inventory set --file -\`).
46
+
47
+ Examples:
48
+ auto-harness whoami
49
+ auto-harness api GET /hosts
50
+ auto-harness api POST /repositories --body '{"name":"org/repo","url":"https://github.com/org/repo"}'
51
+ auto-harness api DELETE /repositories/repo-1 --body-file -
52
+ auto-harness doctor
53
+ auto-harness host list --online
54
+ auto-harness host drain host-1
55
+ auto-harness host inventory get host-1 --json > inventory.json
56
+ auto-harness host repo rm host-1 repo-1 --dry-run
57
+ auto-harness repo list --all
58
+ auto-harness repo rm repo-1
59
+ auto-harness service-account list
60
+ auto-harness service-account create --name ci --role operator --print-key > /dev/null
61
+ KEY=$(auto-harness service-account create --name ci --role operator --print-key)
62
+ aws ssm get-parameter --name /auto-harness/admin-password --with-decryption \\
63
+ --query Parameter.Value --output text \\
64
+ | auto-harness --admin-password-stdin service-account create --name ci --role operator --print-key
65
+ `;
66
+ }
package/src/errors.js CHANGED
@@ -7,6 +7,7 @@ export class AutoHarnessError extends Error {
7
7
  this.retryAfter = options.retryAfter;
8
8
  this.operationId = options.operationId;
9
9
  this.statusUrl = options.statusUrl;
10
+ this.details = options.details;
10
11
  }
11
12
  }
12
13
 
package/src/index.d.ts CHANGED
@@ -1,6 +1,17 @@
1
+ /* eslint-disable max-lines -- public client declarations share one compatibility surface. */
1
2
  import type { Command, Provider } from "./catalog-types.js";
2
3
  export type { Command, Provider, ResumeRefCapture, UsageRates } from "./catalog-types.js";
3
4
 
5
+ export type SessionResult = {
6
+ summary: string;
7
+ summarySource: "agent" | "harness";
8
+ summaryTruncated?: true;
9
+ branch?: string;
10
+ filesChanged?: string[];
11
+ filesChangedTruncated?: true;
12
+ pullRequestUrl?: string;
13
+ };
14
+
4
15
  export type TargetRef =
5
16
  | { commandId: string; providerId?: never }
6
17
  | { providerId: string; commandId?: never };
@@ -31,7 +42,7 @@ export type RepositoryRef =
31
42
  export type SessionMetadataValue = string | number | boolean | null;
32
43
 
33
44
  /** Whether a session was created directly or fired by a schedule. */
34
- export type SessionType = "prompt" | "scheduled";
45
+ export type SessionType = "prompt" | "scheduled" | "workspace";
35
46
 
36
47
  /** Origin that requested the session. */
37
48
  export type SessionSource = "api" | "ui" | "webhook" | "schedule";
@@ -39,7 +50,7 @@ export type SessionSource = "api" | "ui" | "webhook" | "schedule";
39
50
  /** `source` values `POST /sessions` honors; anything else collapses to `"api"`. */
40
51
  export type CreatableSessionSource = "api" | "ui" | "webhook";
41
52
 
42
- export type CreateSessionInput = RepositoryRef & {
53
+ type CreateSessionOptions = {
43
54
  prompt: string;
44
55
  target: TargetSpec;
45
56
  fallbacks?: TargetSpec[];
@@ -52,11 +63,33 @@ export type CreateSessionInput = RepositoryRef & {
52
63
  metadata?: Record<string, SessionMetadataValue>;
53
64
  /** Defaults to `"api"`; `"ui"`/`"webhook"` pass through, anything else becomes `"api"`. */
54
65
  source?: CreatableSessionSource;
66
+ /** Raw setup scripts are not a session input; select a trusted profile by id. */
67
+ setupScript?: never;
68
+ };
69
+
70
+ /** Host-scoped, non-git create-session input. */
71
+ export type WorkspaceSessionInput = CreateSessionOptions & {
72
+ repositoryId: null;
73
+ workspacePoolId: string;
74
+ setupProfileId?: string;
75
+ destroyWorkspaceAfter?: boolean;
76
+ type?: "workspace";
77
+ ref?: never;
78
+ requiredLabels?: [];
55
79
  };
56
80
 
81
+ /** Existing repository-backed create-session input, retained unchanged. */
82
+ export type CreateSessionInput =
83
+ | (RepositoryRef & CreateSessionOptions & { type?: "prompt" | "scheduled" })
84
+ | WorkspaceSessionInput;
85
+
57
86
  export type Session = {
58
87
  id: string;
59
- repositoryId: string;
88
+ repositoryId: string | null;
89
+ workspacePoolId?: string;
90
+ workspaceSlotId?: string | null;
91
+ setupProfileId?: string;
92
+ destroyWorkspaceAfter?: boolean;
60
93
  prompt: string;
61
94
  target: TargetRef;
62
95
  fallbacks?: TargetRef[];
@@ -75,14 +108,31 @@ export type Session = {
75
108
  createdAt: string;
76
109
  url: string;
77
110
  created?: boolean;
111
+ /** Present on detail reads and terminal action results when available. */
112
+ result?: SessionResult;
113
+ parentSessionId?: string;
114
+ rootSessionId?: string;
78
115
  };
79
116
 
80
- /** Body accepted by `POST /sessions/:id/resume`. */
117
+ export type CreateChildSessionInput = {
118
+ prompt: string;
119
+ spawnKey: string;
120
+ priority?: number;
121
+ queueTtlSeconds?: number;
122
+ };
123
+
124
+ export type ListChildSessionsOptions = { limit?: number; cursor?: string };
125
+
126
+ /** Body accepted by `POST /sessions/:id/resume`. `target`/`fallbacks` are an optional
127
+ * rebinding override — passing `target` alone clears any inherited `fallbacks`, and
128
+ * a bare `fallbacks` without `target` is rejected. */
81
129
  export type ResumeSessionInput = {
82
130
  prompt?: string;
83
131
  concurrencyId?: string;
84
132
  timeout?: number;
85
133
  priority?: number;
134
+ target?: TargetSpec;
135
+ fallbacks?: TargetSpec[];
86
136
  };
87
137
 
88
138
  /** `status` filter accepted by `GET /sessions`. */
@@ -179,6 +229,10 @@ export class AutoHarnessError extends Error {
179
229
  operationId?: string;
180
230
  /** API-relative URL for the drain that fenced this request. */
181
231
  statusUrl?: string;
232
+ /** The complete `body.error` object from the response, when the server returned JSON —
233
+ * e.g. a refused delete's `dependencies` array. Undefined when the response had no parseable
234
+ * JSON `error` object. */
235
+ details?: Record<string, unknown>;
182
236
  constructor(
183
237
  message: string,
184
238
  options: {
@@ -187,6 +241,7 @@ export class AutoHarnessError extends Error {
187
241
  retryAfter?: string;
188
242
  operationId?: string;
189
243
  statusUrl?: string;
244
+ details?: Record<string, unknown>;
190
245
  },
191
246
  );
192
247
  }
@@ -232,6 +287,11 @@ export class AutoHarnessClient {
232
287
  constructor(options: AutoHarnessClientOptions);
233
288
  createSession(input: CreateSessionInput): Promise<Session & { created: boolean }>;
234
289
  getSession(id: string): Promise<Session>;
290
+ createChildSession(
291
+ parentId: string,
292
+ input: CreateChildSessionInput,
293
+ ): Promise<Session & { created: boolean }>;
294
+ listChildSessions(parentId: string, options?: ListChildSessionsOptions): Promise<SessionPage>;
235
295
  cancelSession(id: string): Promise<Session>;
236
296
  resumeSession(id: string, input?: ResumeSessionInput): Promise<Session & { created: boolean }>;
237
297
  listSessions(options?: ListSessionsOptions): Promise<SessionPage>;
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines -- one small client class covering every REST method is the published surface. */
1
2
  import {
2
3
  AutoHarnessDrainWaitTimeoutError,
3
4
  AutoHarnessError,
@@ -5,7 +6,7 @@ import {
5
6
  } from "./errors.js";
6
7
  import { assertSecureTransport } from "./loopback.js";
7
8
  import { resolveRepositoryId } from "./resolve-repository.js";
8
- import { resolveCreateSessionTargets } from "./resolve-target.js";
9
+ import { resolveCreateSessionTargets, resolveTargetSpecs } from "./resolve-target.js";
9
10
 
10
11
  export { AutoHarnessDrainWaitTimeoutError, AutoHarnessError, AutoHarnessRequestTimeoutError };
11
12
 
@@ -64,6 +65,7 @@ export class AutoHarnessClient {
64
65
  retryAfter: response.headers.get("retry-after") ?? undefined,
65
66
  operationId: error?.operationId,
66
67
  statusUrl: error?.statusUrl,
68
+ details: error,
67
69
  },
68
70
  );
69
71
  }
@@ -85,15 +87,42 @@ export class AutoHarnessClient {
85
87
  return this.request(`/sessions/${encodeURIComponent(id)}`);
86
88
  }
87
89
 
90
+ createChildSession(parentId, input) {
91
+ return this.request(`/sessions/${encodeURIComponent(parentId)}/children`, {
92
+ method: "POST",
93
+ body: JSON.stringify(input),
94
+ });
95
+ }
96
+
97
+ listChildSessions(parentId, options = {}) {
98
+ const query = new URLSearchParams();
99
+ if (options.limit !== undefined) query.set("limit", String(options.limit));
100
+ if (options.cursor !== undefined) query.set("cursor", options.cursor);
101
+ const suffix = query.toString();
102
+ return this.request(
103
+ suffix
104
+ ? `/sessions/${encodeURIComponent(parentId)}/children?${suffix}`
105
+ : `/sessions/${encodeURIComponent(parentId)}/children`,
106
+ );
107
+ }
108
+
88
109
  cancelSession(id) {
89
110
  return this.request(`/sessions/${encodeURIComponent(id)}/cancel`, { method: "POST" });
90
111
  }
91
112
 
92
- /** Resume a previously assigned session on its pinned host, native CLI resume where supported. */
93
- resumeSession(id, input) {
113
+ /**
114
+ * Resume a previously assigned session on its pinned host, native CLI resume where supported.
115
+ * An optional `target`/`fallbacks` override rebinds the resume to a different Command/Provider
116
+ * — a repoint applying on the next resume, instead of only to new dispatches — and its
117
+ * `providerName`/`commandName` sugar is resolved the same way `createSession()` resolves it.
118
+ * Resolution only runs when `target` is present, so an id-only or bodyless resume makes no
119
+ * extra `listCommands()`/`listProviders()` requests.
120
+ */
121
+ async resumeSession(id, input) {
122
+ const body = input?.target === undefined ? input : await resolveTargetSpecs(this, input);
94
123
  return this.request(`/sessions/${encodeURIComponent(id)}/resume`, {
95
124
  method: "POST",
96
- ...(input === undefined ? {} : { body: JSON.stringify(input) }),
125
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
97
126
  });
98
127
  }
99
128
 
@@ -161,37 +190,40 @@ export class AutoHarnessClient {
161
190
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
162
191
  throw new TypeError("timeoutMs must be a finite positive number");
163
192
  const deadline = Date.now() + timeoutMs;
164
- const clampedClient = () =>
165
- new AutoHarnessClient({
166
- baseUrl: this.baseUrl,
167
- apiKey: this.apiKey,
168
- allowInsecureHttp: this.allowInsecureHttp,
169
- fetch: this.fetch,
170
- requestTimeoutMs: Math.max(
171
- 1,
172
- Math.ceil(Math.min(this.requestTimeoutMs, deadline - Date.now())),
173
- ),
174
- });
175
- const rejectAtDeadline = (error, idForError) =>
176
- error instanceof AutoHarnessRequestTimeoutError && Date.now() >= deadline
193
+ const clampedClient = () => {
194
+ const remainingMs = deadline - Date.now();
195
+ const requestTimeoutMs = Math.max(1, Math.ceil(Math.min(this.requestTimeoutMs, remainingMs)));
196
+ return {
197
+ client: new AutoHarnessClient({
198
+ baseUrl: this.baseUrl,
199
+ apiKey: this.apiKey,
200
+ allowInsecureHttp: this.allowInsecureHttp,
201
+ fetch: this.fetch,
202
+ requestTimeoutMs,
203
+ }),
204
+ clampedToWaitBudget: remainingMs <= this.requestTimeoutMs,
205
+ };
206
+ };
207
+ const rejectAtDeadline = (error, idForError, clampedToWaitBudget) =>
208
+ error instanceof AutoHarnessRequestTimeoutError &&
209
+ (clampedToWaitBudget || Date.now() >= deadline)
177
210
  ? new AutoHarnessDrainWaitTimeoutError(idForError, operationId, timeoutMs)
178
211
  : error;
179
212
 
213
+ const repositoryLookup = clampedClient();
180
214
  const id = await resolveRepositoryId(
181
- { listRepositories: (listOptions) => clampedClient().listRepositories(listOptions) },
215
+ { listRepositories: (opts) => repositoryLookup.client.listRepositories(opts) },
182
216
  repositoryId,
183
217
  ).catch((error) => {
184
- throw rejectAtDeadline(error, repositoryId);
218
+ throw rejectAtDeadline(error, repositoryId, repositoryLookup.clampedToWaitBudget);
185
219
  });
186
220
  for (;;) {
187
- if (deadline - Date.now() <= 0) {
221
+ if (deadline - Date.now() <= 0)
188
222
  throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
189
- }
190
- const sessionDrain = await clampedClient()
191
- .getSessionDrain(id, operationId)
192
- .catch((error) => {
193
- throw rejectAtDeadline(error, id);
194
- });
223
+ const { client, clampedToWaitBudget } = clampedClient();
224
+ const sessionDrain = await client.getSessionDrain(id, operationId).catch((error) => {
225
+ throw rejectAtDeadline(error, id, clampedToWaitBudget);
226
+ });
195
227
  if (sessionDrain.status !== "draining") return sessionDrain;
196
228
  const delayMs = Math.min(pollIntervalMs, deadline - Date.now());
197
229
  if (delayMs <= 0) throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
@@ -224,12 +256,26 @@ export class AutoHarnessClient {
224
256
  }
225
257
 
226
258
  async listProviders() {
227
- const { items } = await this.request("/providers");
228
- return items;
259
+ return this.listCatalog("/providers");
229
260
  }
230
261
 
231
262
  async listCommands() {
232
- const { items } = await this.request("/commands");
233
- return items;
263
+ return this.listCatalog("/commands");
264
+ }
265
+
266
+ async listCatalog(path) {
267
+ const items = [];
268
+ const seen = new Set();
269
+ let suffix = "?limit=100";
270
+ for (let page = 0; page < 20; page += 1) {
271
+ const body = await this.request(`${path}${suffix}`);
272
+ items.push(...(body.items ?? []));
273
+ const cursor = body.nextCursor ?? null;
274
+ if (!cursor) return items;
275
+ if (seen.has(cursor)) throw new Error(`repeated pagination cursor for ${path}`);
276
+ seen.add(cursor);
277
+ suffix = `?limit=100&cursor=${encodeURIComponent(cursor)}`;
278
+ }
279
+ throw new Error(`pagination exceeded 20 pages for ${path}`);
234
280
  }
235
281
  }
@@ -16,31 +16,47 @@ async function resolveRef(ref, providers, commands) {
16
16
  }
17
17
 
18
18
  /**
19
- * Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks`, and
20
- * `input.repositoryName`, to their ids via `client.listProviders()`/`listCommands()`/
21
- * `listRepositories()`, each called at most once regardless of how many refs need it. Id-shaped
22
- * refs pass through untouched, so an all-id call makes no extra requests. Names are resolved
23
- * defensively against more than one match: create/update checks are read-then-write races rather
24
- * than atomic constraints, and legacy catalog rows are not rewritten.
19
+ * Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks` to their ids
20
+ * via `client.listProviders()`/`listCommands()`, each called at most once regardless of how many
21
+ * refs need it. Id-shaped refs pass through untouched, so an all-id call makes no extra requests.
22
+ * Names are resolved defensively against more than one match: create/update checks are
23
+ * read-then-write races rather than atomic constraints, and legacy catalog rows are not rewritten.
24
+ * Shared by session create and resume — resume has no repository to resolve, so it calls this
25
+ * directly instead of `resolveCreateSessionTargets`.
25
26
  */
26
- export async function resolveCreateSessionTargets(client, input) {
27
+ export async function resolveTargetSpecs(client, input) {
27
28
  let providersPromise;
28
29
  let commandsPromise;
29
30
  const providers = () => (providersPromise ??= client.listProviders());
30
31
  const commands = () => (commandsPromise ??= client.listCommands());
31
32
 
32
33
  const target = await resolveRef(input.target, providers, commands);
34
+ const resolved = { ...input, target };
35
+ if (input.fallbacks === undefined) return resolved;
36
+ const fallbacks = await Promise.all(
37
+ input.fallbacks.map((fallback) => resolveRef(fallback, providers, commands)),
38
+ );
39
+ return { ...resolved, fallbacks };
40
+ }
41
+
42
+ /**
43
+ * Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks`, and
44
+ * `input.repositoryName`, to their ids via `client.listProviders()`/`listCommands()`/
45
+ * `listRepositories()`.
46
+ */
47
+ export async function resolveCreateSessionTargets(client, input) {
48
+ if (Object.hasOwn(input, "setupScript")) {
49
+ throw new TypeError("setupScript is not accepted; use setupProfileId");
50
+ }
51
+ const withTargets = await resolveTargetSpecs(client, input);
52
+ if (input.repositoryId === null) return withTargets;
33
53
  const repositoryId = await resolveRepositoryId(
34
54
  client,
35
55
  input.repositoryId !== undefined
36
56
  ? input.repositoryId
37
57
  : { repositoryName: input.repositoryName },
38
58
  );
39
- const resolved = { ...input, repositoryId, target };
59
+ const resolved = { ...withTargets, repositoryId };
40
60
  delete resolved.repositoryName;
41
- if (input.fallbacks === undefined) return resolved;
42
- const fallbacks = await Promise.all(
43
- input.fallbacks.map((fallback) => resolveRef(fallback, providers, commands)),
44
- );
45
- return { ...resolved, fallbacks };
61
+ return resolved;
46
62
  }