@indigoai-us/hq-cloud 6.18.87 → 6.18.89

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 (69) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +16 -0
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner.d.ts +5 -0
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/cli/reconcile-skip-reason.d.ts +23 -0
  8. package/dist/cli/reconcile-skip-reason.d.ts.map +1 -0
  9. package/dist/cli/reconcile-skip-reason.js +60 -0
  10. package/dist/cli/reconcile-skip-reason.js.map +1 -0
  11. package/dist/cli/rescue-core.d.ts +16 -1
  12. package/dist/cli/rescue-core.d.ts.map +1 -1
  13. package/dist/cli/rescue-core.js +83 -27
  14. package/dist/cli/rescue-core.js.map +1 -1
  15. package/dist/cli/share.d.ts +31 -1
  16. package/dist/cli/share.d.ts.map +1 -1
  17. package/dist/cli/share.js +193 -28
  18. package/dist/cli/share.js.map +1 -1
  19. package/dist/cli/sync.d.ts +36 -2
  20. package/dist/cli/sync.d.ts.map +1 -1
  21. package/dist/cli/sync.js +135 -27
  22. package/dist/cli/sync.js.map +1 -1
  23. package/dist/local-link-warning.d.ts +48 -0
  24. package/dist/local-link-warning.d.ts.map +1 -0
  25. package/dist/local-link-warning.js +138 -0
  26. package/dist/local-link-warning.js.map +1 -0
  27. package/dist/object-io.d.ts +16 -1
  28. package/dist/object-io.d.ts.map +1 -1
  29. package/dist/object-io.js +25 -2
  30. package/dist/object-io.js.map +1 -1
  31. package/dist/outposts/cli-billing.d.ts +2 -0
  32. package/dist/outposts/cli-billing.d.ts.map +1 -1
  33. package/dist/outposts/cli-billing.js +13 -1
  34. package/dist/outposts/cli-billing.js.map +1 -1
  35. package/dist/outposts/client.d.ts +37 -6
  36. package/dist/outposts/client.d.ts.map +1 -1
  37. package/dist/outposts/client.js +100 -18
  38. package/dist/outposts/client.js.map +1 -1
  39. package/dist/outposts/command.d.ts +11 -5
  40. package/dist/outposts/command.d.ts.map +1 -1
  41. package/dist/outposts/command.js +945 -63
  42. package/dist/outposts/command.js.map +1 -1
  43. package/dist/outposts/company-outposts.d.ts +92 -0
  44. package/dist/outposts/company-outposts.d.ts.map +1 -0
  45. package/dist/outposts/company-outposts.js +194 -0
  46. package/dist/outposts/company-outposts.js.map +1 -0
  47. package/dist/outposts/company.d.ts +67 -9
  48. package/dist/outposts/company.d.ts.map +1 -1
  49. package/dist/outposts/company.js +211 -11
  50. package/dist/outposts/company.js.map +1 -1
  51. package/dist/outposts/errors.d.ts +7 -0
  52. package/dist/outposts/errors.d.ts.map +1 -1
  53. package/dist/outposts/errors.js +7 -0
  54. package/dist/outposts/errors.js.map +1 -1
  55. package/dist/outposts/host-deps.d.ts +2 -0
  56. package/dist/outposts/host-deps.d.ts.map +1 -1
  57. package/dist/outposts/host-deps.js +12 -0
  58. package/dist/outposts/host-deps.js.map +1 -1
  59. package/dist/s3.d.ts +7 -1
  60. package/dist/s3.d.ts.map +1 -1
  61. package/dist/s3.js +33 -12
  62. package/dist/s3.js.map +1 -1
  63. package/dist/sync-progress.d.ts.map +1 -1
  64. package/dist/sync-progress.js +3 -0
  65. package/dist/sync-progress.js.map +1 -1
  66. package/dist/vault-client.d.ts.map +1 -1
  67. package/dist/vault-client.js +5 -1
  68. package/dist/vault-client.js.map +1 -1
  69. package/package.json +1 -1
@@ -9,15 +9,19 @@
9
9
  *
10
10
  * Personal Outposts are caller-scoped: hq-pro keys those `/outpost/*` calls on
11
11
  * the caller's Cognito sub. `--company <slug>` switches the same provision and
12
- * status commands onto the company Outpost, and `outposts jobs register` sends
13
- * a company job for server acceptance. `--id <outpostId>` still selects a
14
- * personal box; when omitted, personal calls target the caller's primary slot.
12
+ * status commands onto the company Outpost. `outposts jobs` registers, edits,
13
+ * pauses, resumes, deletes, and runs a company job. `--id <outpostId>` still
14
+ * selects a personal box; when omitted, personal calls target the caller's primary slot.
15
15
  *
16
16
  * Subcommands:
17
17
  * outposts provision [--company] — provision a box (paid; requires --yes)
18
18
  * outposts list — every personal Outpost you own
19
+ * outposts list --company - a company's Outposts (name, id, state, jobs)
20
+ * outposts create --company --name - another named company Outpost (billed to the company)
21
+ * --outpost <name|id> - picks a company Outpost on status, login, login-code
22
+ * and jobs register; optional when there is only one
19
23
  * outposts status [--id|--company] — live detail for one box
20
- * outposts jobs register — register a company job, print its revision
24
+ * outposts jobs — register, edit, pause, resume, delete, run-now
21
25
  * outposts exec <command...> — run a command on the box (sync/async/detach)
22
26
  * outposts exec-stage — presign an input upload for an async command
23
27
  * outposts exec-submit <cmd...> — fire-and-forget async submit
@@ -25,7 +29,7 @@
25
29
  * outposts codex-enable [--id] — enable / retry Codex on the box
26
30
  * outposts login [--id] — request a fresh login URL
27
31
  * outposts login-code <code> — hand the box its Claude sign-in code
28
- * outposts destroy [--id] --yes — tear the box down (destructive; flag-guarded)
32
+ * outposts destroy [--id | --company [--outpost]] [--yes] [--json] — tear a box down (destructive; confirmed first)
29
33
  * outposts heartbeat — hidden; box-side systemd loop
30
34
  * outposts session-host — hidden; box-side sessions for the desktop app
31
35
  * outposts self-deploy — hidden; make THIS host an unmanaged outpost
@@ -38,7 +42,8 @@
38
42
  import chalk from "chalk";
39
43
  import * as fs from "node:fs";
40
44
  import { OUTPOST_PRICE_CENTS } from "./billing.js";
41
- import { buildCompanyJobDefinition, companyPayerLabel, companyProvisionProgress, CompanyTargetError, describeCompanySignIn, explainCompanyFailure, isCompanySlug, parseCompanySignIn, isProvisionOutcomeUnknown, mergeCompanyJobs, } from "./company.js";
45
+ import { buildCompanyJobDefinition, mergeCompanyJobEdit, companyPayerLabel, companyProvisionProgress, CompanyTargetError, JobDefinitionError, describeCompanySignIn, explainCompanyFailure, isCompanySlug, parseCompanySignIn, companyCreateContinues, isProvisionOutcomeUnknown, mergeCompanyJobs, provisionErrorSkipsStatusRead, } from "./company.js";
46
+ import { companyOutpostChargeLine, companyOutpostsUnreadableLine, CompanyOutpostSelectError, explainCompanyOutpostCreateFailure, explainCompanyOutpostSelect, isCompanyOutpostId, isOutpostName, LEGACY_OUTPOST_NAME, safeLifecycle, selectCompanyOutpost, } from "./company-outposts.js";
42
47
  import { confirmChargeOrExit, confirmCompanyOutpostChargeOrExit, surfaceBillingBlocked, } from "./cli-billing.js";
43
48
  import { EXEC_MAX_TIMEOUT_SECONDS, OutpostsClient, } from "./client.js";
44
49
  import { OutpostExecWaitError, OutpostHttpError } from "./errors.js";
@@ -77,8 +82,39 @@ function fail(err) {
77
82
  }
78
83
  process.exit(1);
79
84
  }
80
- function failCompany(slug, err, intent) {
81
- console.error(explainCompanyFailure(slug, err, intent));
85
+ function companyFailureCode(err) {
86
+ if (err instanceof CompanyOutpostSelectError)
87
+ return err.reason;
88
+ if (err instanceof OutpostHttpError) {
89
+ if (err.code)
90
+ return err.code;
91
+ if (err.status === 403)
92
+ return "admin_required";
93
+ if (err.status === 401)
94
+ return "unauthorized";
95
+ }
96
+ if (err instanceof JobDefinitionError)
97
+ return "invalid_definition";
98
+ if (err instanceof CompanyTargetError)
99
+ return err.reason;
100
+ return "request_failed";
101
+ }
102
+ function failCompany(slug, err, intent, json) {
103
+ const message = err instanceof CompanyOutpostSelectError
104
+ ? explainCompanyOutpostSelect(isCompanySlug(slug) ? slug : "that company", err)
105
+ : explainCompanyFailure(slug, err, intent, json?.jobId);
106
+ if (json) {
107
+ process.stdout.write(JSON.stringify({
108
+ ok: false,
109
+ action: json.action,
110
+ jobId: json.jobId,
111
+ code: companyFailureCode(err),
112
+ message,
113
+ }) + "\n");
114
+ }
115
+ else {
116
+ console.error(message);
117
+ }
82
118
  process.exit(1);
83
119
  }
84
120
  async function requireCompany(client, slug) {
@@ -96,6 +132,151 @@ async function requireCompany(client, slug) {
96
132
  throw err;
97
133
  }
98
134
  }
135
+ /**
136
+ * The Outpost a company command acts on. `--outpost` is optional when the
137
+ * company has one Outpost. The legacy Outpost has no outpostId, so callers
138
+ * send none and older servers see the request they always did.
139
+ */
140
+ async function requireCompanyOutpost(client, company, flag, command) {
141
+ return selectCompanyOutpost(await client.listCompanyOutposts(company.uid), flag, command);
142
+ }
143
+ /** Exit when --outpost is passed without --company. */
144
+ function rejectOutpostWithoutCompany(opts) {
145
+ if (opts.outpost !== undefined && opts.company === undefined) {
146
+ console.error(explainCompanyOutpostSelect("", new CompanyOutpostSelectError("needs_company")));
147
+ process.exit(1);
148
+ }
149
+ }
150
+ /** " --outpost <name>" for next-step hints, only when the company has a choice to make. */
151
+ function outpostHint(outpost, passed) {
152
+ return passed !== undefined ? ` --outpost ${outpost.name}` : "";
153
+ }
154
+ /**
155
+ * `hq outposts list` is the only command that reads status after a rejected
156
+ * list query, and only to print the legacy row's state. The id from that
157
+ * read is not kept, so later commands still omit `outpostId`.
158
+ */
159
+ async function legacyListWithLifecycle(client, companyUid, list) {
160
+ const row = list.outposts[0];
161
+ if (!list.legacy || list.outposts.length !== 1 || !row || row.lifecycle)
162
+ return list;
163
+ try {
164
+ const status = await client.companyStatus(companyUid);
165
+ const lifecycle = safeLifecycle(status.outpost?.lifecycle);
166
+ if (!lifecycle)
167
+ return list;
168
+ return { ...list, outposts: [{ ...row, lifecycle }] };
169
+ }
170
+ catch (err) {
171
+ // Only the server's coded "no Outpost" answer is an empty list. A codeless
172
+ // 404 is not proof, so it fails instead of suggesting a billed create.
173
+ if (err instanceof OutpostHttpError && err.status === 404 && err.code === "not_found") {
174
+ return { outposts: [], legacy: true };
175
+ }
176
+ throw err;
177
+ }
178
+ }
179
+ async function listCompanyOutpostsCommand(client, slug, asJson) {
180
+ const company = await requireCompany(client, slug);
181
+ const list = await legacyListWithLifecycle(client, company.uid, await client.listCompanyOutposts(company.uid));
182
+ const unreadable = list.unreadable ?? 0;
183
+ // Every row unreadable is not "no Outpost": fail instead of an empty list or the create hint.
184
+ if (list.outposts.length === 0 && unreadable > 0) {
185
+ console.error(companyOutpostsUnreadableLine(company.slug));
186
+ process.exit(1);
187
+ }
188
+ if (asJson) {
189
+ process.stdout.write(JSON.stringify({ company: company.slug, legacy: list.legacy, outposts: list.outposts, ...(unreadable > 0 ? { unreadable } : {}) }, null, 2) + "\n");
190
+ return;
191
+ }
192
+ if (list.outposts.length === 0) {
193
+ console.log(`${company.slug} does not have an Outpost yet.`);
194
+ console.log(`Next: hq outposts provision --company ${company.slug}`);
195
+ return;
196
+ }
197
+ const nameW = Math.max(4, ...list.outposts.map((o) => o.name.length));
198
+ const idW = Math.max(2, ...list.outposts.map((o) => (o.outpostId ?? "").length));
199
+ console.log(chalk.bold(["NAME".padEnd(nameW), "ID".padEnd(idW), "STATE"].join(" ")));
200
+ for (const o of list.outposts) {
201
+ console.log([
202
+ o.name.padEnd(nameW),
203
+ (o.outpostId ?? "").padEnd(idW),
204
+ o.lifecycle ?? "",
205
+ ].join(" "));
206
+ }
207
+ if (list.legacy) {
208
+ console.log(chalk.dim(`${company.slug} has one Outpost, shown as ${LEGACY_OUTPOST_NAME}.`));
209
+ }
210
+ if (unreadable > 0) {
211
+ console.log(chalk.yellow(`${unreadable} more Outpost row(s) could not be read and are not shown.`));
212
+ }
213
+ }
214
+ async function createCompanyOutpostCommand(client, host, opts, wait = {}) {
215
+ const json = opts.json === true;
216
+ const name = opts.name?.trim() ?? "";
217
+ if (!isOutpostName(name)) {
218
+ console.error("An Outpost needs a name of 1 to 32 lowercase letters, digits or dashes, such as research. " +
219
+ `Next: re-run with --company ${isCompanySlug(opts.company) ? opts.company : "<slug>"} --name <name>.`);
220
+ if (json) {
221
+ writeCompanyProvisionJson({ ok: false, company: opts.company, state: "not_created", reason: "invalid_name" });
222
+ }
223
+ process.exit(1);
224
+ }
225
+ let company;
226
+ try {
227
+ company = await requireCompany(client, opts.company);
228
+ }
229
+ catch (err) {
230
+ if (err && typeof err === "object")
231
+ failedBeforeCreate.add(err);
232
+ throw err;
233
+ }
234
+ const charge = companyOutpostChargeLine(companyPayerLabel(company));
235
+ const unnamed = `The name ${name} is not sent. This server creates the company's main Outpost.`;
236
+ if (!opts.yes) {
237
+ if (!host.isStdinTty()) {
238
+ console.error(`Creating ${company.slug}'s main Outpost costs ${charge}. ${unnamed} ` +
239
+ `Next: re-run with --yes to confirm this charge.`);
240
+ if (json) {
241
+ writeCompanyProvisionJson({
242
+ ok: false,
243
+ company: company.slug,
244
+ state: "not_created",
245
+ reason: "confirmation_required",
246
+ });
247
+ }
248
+ process.exit(1);
249
+ }
250
+ // In --json the prompt goes to stderr so stdout stays one object.
251
+ const prompt = `Create ${company.slug}'s main Outpost? ${charge}. ${unnamed} Type yes to confirm: `;
252
+ if (json)
253
+ process.stderr.write(prompt);
254
+ else
255
+ process.stdout.write(prompt);
256
+ if (!(await host.confirm())) {
257
+ if (json) {
258
+ console.error("Nothing was created or billed.");
259
+ writeCompanyProvisionJson({
260
+ ok: false,
261
+ company: company.slug,
262
+ state: "not_created",
263
+ reason: "confirmation_declined",
264
+ });
265
+ }
266
+ else {
267
+ console.log("Nothing was created or billed.");
268
+ }
269
+ return;
270
+ }
271
+ }
272
+ else {
273
+ provisionProgress(json, `Confirmed: ${charge}. ${unnamed}`);
274
+ }
275
+ await startCompanyOutpost(client, company, wait, {
276
+ json,
277
+ explainRefusal: (err) => explainCompanyOutpostCreateFailure(company.slug, err),
278
+ });
279
+ }
99
280
  function printCompanyJobs(companySlug, jobs, healthAvailable) {
100
281
  if (jobs.length === 0) {
101
282
  console.log("No company jobs yet.");
@@ -108,116 +289,524 @@ function printCompanyJobs(companySlug, jobs, healthAvailable) {
108
289
  console.log(["JOB", "LAST RUN", "NEXT RUN", "STATUS"].join(" "));
109
290
  for (const job of jobs) {
110
291
  console.log([job.jobId, job.lastRunAt ?? "none", job.nextRunAt ?? "none", job.status].join(" "));
292
+ if (job.message)
293
+ console.log(` ${job.message}`);
294
+ if (job.nextStep)
295
+ console.log(` Next: ${job.nextStep}`);
111
296
  }
112
297
  }
113
298
  const COMPANY_PROVISION_POLL_MS = 5_000;
114
299
  // The create Lambda's own timeout is 600s.
115
300
  const COMPANY_PROVISION_MAX_WAIT_MS = 600_000;
116
301
  const COMPANY_PROVISION_PROGRESS_EVERY_MS = 30_000;
117
- async function provisionCompanyOutpost(client, slug, yes, wait = {}) {
118
- const company = await requireCompany(client, slug);
302
+ function writeCompanyProvisionJson(body) {
303
+ const payload = {
304
+ ok: body.ok,
305
+ company: body.company,
306
+ state: body.state,
307
+ };
308
+ if (body.outpostId)
309
+ payload.outpostId = body.outpostId;
310
+ if (body.code)
311
+ payload.code = body.code;
312
+ if (body.reason)
313
+ payload.reason = body.reason;
314
+ process.stdout.write(JSON.stringify(payload) + "\n");
315
+ }
316
+ /** Errors thrown before the create request was sent, so nothing can exist. */
317
+ const failedBeforeCreate = new WeakSet();
318
+ function companyProvisionFailureJson(company, err) {
319
+ if (err instanceof CompanyTargetError) {
320
+ return JSON.stringify({
321
+ ok: false,
322
+ company,
323
+ state: "not_created",
324
+ reason: err.reason,
325
+ }) + "\n";
326
+ }
327
+ const code = err instanceof OutpostHttpError ? err.code : undefined;
328
+ const reason = code ?? "request_failed";
329
+ let state = "unconfirmed";
330
+ if (err && typeof err === "object" && failedBeforeCreate.has(err))
331
+ state = "not_created";
332
+ else if (code === "already_exists" || code === "billing_required")
333
+ state = "blocked";
334
+ else if (code === "feature_disabled" ||
335
+ code === "unavailable" ||
336
+ code === "dependencies_unavailable" ||
337
+ code === "unauthorized" ||
338
+ code === "forbidden" ||
339
+ code === "admin_required" ||
340
+ (typeof code === "string" && code.startsWith("invalid_"))) {
341
+ state = "not_created";
342
+ }
343
+ return JSON.stringify({
344
+ ok: false,
345
+ company,
346
+ state,
347
+ ...(code ? { code } : {}),
348
+ reason,
349
+ }) + "\n";
350
+ }
351
+ function provisionProgress(json, line) {
352
+ if (json)
353
+ console.error(chalk.dim(line));
354
+ else
355
+ console.log(chalk.dim(line));
356
+ }
357
+ function correlationFrom(outpost, err) {
358
+ const fromError = err instanceof OutpostHttpError ? err : undefined;
359
+ const attemptId = typeof outpost?.attemptId === "string" ? outpost.attemptId : fromError?.attemptId;
360
+ const operationId = typeof outpost?.operationId === "string" ? outpost.operationId : fromError?.operationId;
361
+ return {
362
+ ...(attemptId ? { attemptId } : {}),
363
+ ...(operationId ? { operationId } : {}),
364
+ };
365
+ }
366
+ async function provisionCompanyOutpost(client, slug, yes, wait = {}, json = false) {
367
+ let company;
368
+ try {
369
+ company = await requireCompany(client, slug);
370
+ }
371
+ catch (err) {
372
+ if (err && typeof err === "object")
373
+ failedBeforeCreate.add(err);
374
+ throw err;
375
+ }
119
376
  confirmCompanyOutpostChargeOrExit({
120
377
  slug: company.slug,
121
378
  payerLabel: companyPayerLabel(company),
122
379
  yes,
380
+ json,
123
381
  });
382
+ await startCompanyOutpost(client, company, wait, { json });
383
+ }
384
+ /**
385
+ * Send the create, then report what status shows. `provision --company` and
386
+ * `create --company --name` both use this, so neither prints "not created"
387
+ * for a create that may still be running.
388
+ *
389
+ * A refusal from {@link provisionErrorSkipsStatusRead} is a real answer:
390
+ * `explainRefusal` may give its plain line (not in `--json`), otherwise it
391
+ * is rethrown for the command's error handler. Any other error, such as a
392
+ * codeless 5xx (API Gateway's 30s cut-off on a create that keeps running),
393
+ * `503 outcome_unknown`, a coded 5xx, a dropped connection or an unreadable
394
+ * body, says nothing final about whether the box exists, so status is read.
395
+ * With `json`, progress goes to stderr and stdout gets one object.
396
+ */
397
+ async function startCompanyOutpost(client, company, wait, opts = {}) {
398
+ const json = opts.json === true;
124
399
  let outpost;
400
+ let provisionError;
125
401
  try {
126
402
  outpost = (await client.provisionCompany(company.uid)).outpost;
127
403
  }
128
404
  catch (err) {
129
- // A codeless 5xx (API Gateway's 30s cut-off on a create that keeps
130
- // running), a dropped connection or an unreadable body says nothing about
131
- // whether the box exists. Re-read status instead of reporting a failure.
132
- if (!isProvisionOutcomeUnknown(err))
405
+ // A refusal or a coded "nothing changed" answer is final.
406
+ // Anything else may have created the box. Poll status before any failure text.
407
+ // A failed read, a single 404, or a timeout stays unconfirmed and keeps this error.
408
+ if (provisionErrorSkipsStatusRead(err)) {
409
+ const plain = opts.json ? undefined : opts.explainRefusal?.(err);
410
+ if (plain) {
411
+ console.error(plain);
412
+ process.exit(1);
413
+ }
133
414
  throw err;
134
- console.log(chalk.dim("The create request did not return a result in time. Checking status..."));
415
+ }
416
+ provisionError = err;
417
+ const line = isProvisionOutcomeUnknown(err)
418
+ ? "The create request did not return a result in time. Checking status..."
419
+ : "The create request returned an error. Checking status...";
420
+ provisionProgress(json, line);
135
421
  }
136
- const id = typeof outpost?.outpostId === "string" ? outpost.outpostId : undefined;
137
- const finished = await waitForCompanyOutpost(client, company.uid, outpost, wait);
422
+ const code = provisionError instanceof OutpostHttpError ? provisionError.code : undefined;
423
+ const finished = await waitForCompanyOutpost(client, company.uid, outpost, wait, {
424
+ json,
425
+ correlation: correlationFrom(outpost, provisionError),
426
+ trustInitial: outpost !== undefined,
427
+ announceInProgress: provisionError !== undefined,
428
+ });
429
+ // The id goes into a terminal line and JSON, so only a printable one is shown.
430
+ const rawId = finished.outpostId ?? outpost?.outpostId;
431
+ const id = typeof rawId === "string" && isCompanyOutpostId(rawId) ? rawId : undefined;
138
432
  const label = `${company.slug}'s Outpost${id ? ` (${id})` : ""}`;
139
433
  if (finished.state === "ready") {
434
+ if (json) {
435
+ writeCompanyProvisionJson({
436
+ ok: true,
437
+ company: company.slug,
438
+ state: "ready",
439
+ ...(id ? { outpostId: id } : {}),
440
+ });
441
+ return;
442
+ }
140
443
  console.log(chalk.green(`${label} is ready.`));
141
444
  console.log(chalk.dim(`Next: hq outposts status --company ${company.slug}`));
142
445
  return;
143
446
  }
144
447
  if (finished.state === "blocked") {
145
- console.error(finished.reason === "not_started"
146
- ? `${label} was not created; nothing was built. Next: re-run hq outposts provision --company ${company.slug} to try again.`
147
- : finished.reason === "billing_required"
148
- ? `Billing for ${company.slug} is blocked, so the Outpost could not finish provisioning. Next: check the company's billing in the console, then run hq outposts status --company ${company.slug}.`
149
- : `${label} stopped while provisioning and needs attention. Next: hq outposts status --company ${company.slug}, and contact support before retrying.`);
448
+ const notCreated = finished.reason === "not_started";
449
+ if (json) {
450
+ writeCompanyProvisionJson({
451
+ ok: false,
452
+ company: company.slug,
453
+ state: notCreated ? "not_created" : "blocked",
454
+ ...(id ? { outpostId: id } : {}),
455
+ ...(code ? { code } : {}),
456
+ ...(finished.reason ? { reason: finished.reason } : {}),
457
+ });
458
+ }
459
+ else if (notCreated) {
460
+ console.error(`${label} was not created; nothing was built. Next: re-run hq outposts provision --company ${company.slug} to try again.`);
461
+ }
462
+ else if (finished.reason === "billing_required") {
463
+ console.error(`Billing for ${company.slug} is blocked, so the Outpost could not finish provisioning. Next: check the company's billing in the console, then run hq outposts status --company ${company.slug}.`);
464
+ }
465
+ else {
466
+ console.error(`${label} stopped while provisioning and needs attention. Next: hq outposts status --company ${company.slug}, and contact support before retrying.`);
467
+ }
150
468
  // exitCode, not exit(): the action's catch must not add a second message.
151
469
  process.exitCode = 1;
152
470
  return;
153
471
  }
154
472
  if (finished.state === "unconfirmed") {
155
- console.error(`The result of the create request could not be confirmed; no Outpost status was readable for ${company.slug}. Next: hq outposts status --company ${company.slug} before retrying.`);
473
+ console.error(finished.signInFailed
474
+ ? `The result of the create request could not be confirmed because the status check needs you to sign in to HQ again. Next: run hq login, then run hq outposts status --company ${company.slug} first, before retrying.`
475
+ : `The result of the create request could not be confirmed. Next: run hq outposts status --company ${company.slug} first, before retrying.`);
476
+ if (json) {
477
+ writeCompanyProvisionJson({
478
+ ok: false,
479
+ company: company.slug,
480
+ state: "unconfirmed",
481
+ ...(id ? { outpostId: id } : {}),
482
+ ...(code ? { code } : {}),
483
+ reason: code ?? "unconfirmed",
484
+ });
485
+ }
156
486
  process.exitCode = 1;
157
487
  return;
158
488
  }
489
+ if (json) {
490
+ writeCompanyProvisionJson({
491
+ ok: true,
492
+ company: company.slug,
493
+ state: "pending",
494
+ ...(id ? { outpostId: id } : {}),
495
+ });
496
+ return;
497
+ }
159
498
  console.log(chalk.yellow(`Provisioning started for ${label}; it is not ready yet. Check it with: hq outposts status --company ${company.slug}`));
160
499
  }
500
+ function rowBelongsToCreate(outpost, poll) {
501
+ if (!outpost)
502
+ return false;
503
+ if (poll.correlation.attemptId || poll.correlation.operationId) {
504
+ return companyCreateContinues(outpost, poll.correlation);
505
+ }
506
+ if (poll.trustInitial)
507
+ return true;
508
+ return companyCreateContinues(outpost, {});
509
+ }
161
510
  /**
162
511
  * Poll company status until the box is ready or blocked, or the wait runs out.
163
- * Status read errors are retried; they do not change the outcome.
512
+ * Any status-read error, or a row that is not this create, is "not yet".
513
+ * Only an adopted row can end the wait.
164
514
  */
165
- async function waitForCompanyOutpost(client, companyUid, initial, wait) {
515
+ async function waitForCompanyOutpost(client, companyUid, initial, wait, poll) {
166
516
  const pollMs = wait.pollMs ?? COMPANY_PROVISION_POLL_MS;
167
517
  const maxWaitMs = wait.maxWaitMs ?? COMPANY_PROVISION_MAX_WAIT_MS;
168
518
  const pause = wait.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
169
519
  const now = wait.now ?? Date.now;
520
+ let outpostId = typeof initial?.outpostId === "string" ? initial.outpostId : undefined;
170
521
  let progress = initial ? companyProvisionProgress(initial) : undefined;
171
- if (progress && progress.state !== "pending")
172
- return progress;
522
+ let announced = false;
523
+ const announce = (state) => {
524
+ if (announced || !poll.announceInProgress || state.state !== "pending")
525
+ return;
526
+ announced = true;
527
+ const line = "Status shows a create in progress.";
528
+ if (poll.json)
529
+ console.error(line);
530
+ else
531
+ console.log(line);
532
+ };
533
+ if (progress && progress.state !== "pending") {
534
+ return outpostId ? { ...progress, outpostId } : progress;
535
+ }
536
+ if (progress?.state === "pending")
537
+ announce(progress);
173
538
  const started = now();
174
539
  let lastLifecycle = progress?.state === "pending" ? progress.lifecycle : undefined;
175
540
  let lastProgressAt = started;
176
541
  let anyRead = initial !== undefined;
177
542
  for (;;) {
178
543
  if (now() - started >= maxWaitMs) {
179
- return anyRead ? { state: "pending" } : { state: "unconfirmed" };
544
+ if (anyRead && progress)
545
+ return outpostId ? { ...progress, outpostId } : progress;
546
+ return outpostId ? { state: "unconfirmed", outpostId } : { state: "unconfirmed" };
180
547
  }
181
548
  await pause(pollMs);
182
549
  try {
550
+ // No outpostId: the deployed server rejects it with 400 invalid_body
551
+ // until hq-pro-agents #1217 ships. Rows are matched by attempt id instead.
183
552
  const status = await client.companyStatus(companyUid);
553
+ if (!rowBelongsToCreate(status.outpost, poll))
554
+ continue;
184
555
  progress = companyProvisionProgress(status.outpost);
185
556
  anyRead = true;
557
+ if (typeof status.outpost?.outpostId === "string")
558
+ outpostId = status.outpost.outpostId;
186
559
  }
187
560
  catch (err) {
188
- // Not found yet, a codeless 5xx or a network failure: keep waiting.
189
- // Auth and other client errors will not fix themselves; surface them.
190
- if (err instanceof OutpostHttpError &&
191
- err.status >= 400 &&
192
- err.status < 500 &&
193
- err.status !== 404) {
194
- throw err;
561
+ // A create was already sent, so no status-read error proves what happened.
562
+ // A sign-in failure will not fix itself: stop now, still unconfirmed.
563
+ if (err instanceof OutpostHttpError && (err.status === 401 || err.status === 403)) {
564
+ return { state: "unconfirmed", signInFailed: true, ...(outpostId ? { outpostId } : {}) };
195
565
  }
566
+ // Anything else is "not yet"; the timeout ends unconfirmed with the create's own error.
196
567
  continue;
197
568
  }
198
- if (progress.state !== "pending")
199
- return progress;
569
+ if (!progress || progress.state !== "pending") {
570
+ return outpostId && progress ? { ...progress, outpostId } : progress ?? { state: "unconfirmed" };
571
+ }
572
+ announce(progress);
200
573
  const elapsed = Math.round((now() - started) / 1000);
201
574
  if (progress.lifecycle !== lastLifecycle) {
202
575
  lastLifecycle = progress.lifecycle;
203
576
  lastProgressAt = now();
204
- console.log(chalk.dim(`Outpost is ${progress.lifecycle ?? "being created"} (${elapsed}s).`));
577
+ provisionProgress(poll.json, `Outpost is ${progress.lifecycle ?? "being created"} (${elapsed}s).`);
205
578
  }
206
579
  else if (now() - lastProgressAt >= COMPANY_PROVISION_PROGRESS_EVERY_MS) {
207
580
  lastProgressAt = now();
208
- console.log(chalk.dim(`Still ${progress.lifecycle ?? "provisioning"} (${elapsed}s).`));
581
+ provisionProgress(poll.json, `Still ${progress.lifecycle ?? "provisioning"} (${elapsed}s).`);
209
582
  }
210
583
  }
211
584
  }
212
- async function showCompanyStatus(client, slug, asJson) {
585
+ function writeCompanyDestroyJson(body) {
586
+ const payload = {
587
+ ok: body.ok,
588
+ company: body.company,
589
+ outpostId: body.outpostId ?? null,
590
+ state: body.state,
591
+ };
592
+ if (body.code)
593
+ payload.code = body.code;
594
+ if (body.reason)
595
+ payload.reason = body.reason;
596
+ process.stdout.write(JSON.stringify(payload) + "\n");
597
+ }
598
+ /**
599
+ * A destroy error the server sent as a final answer: a coded 4xx refusal
600
+ * such as `admin_required`, `reconciliation_required` or `not_found`.
601
+ * Anything else (every 5xx, coded or not, a codeless 4xx, a timeout, a
602
+ * dropped connection, `outcome_unknown`) may have started the teardown, so
603
+ * company status is read before anything is reported. The destroy itself is
604
+ * never retried.
605
+ */
606
+ function destroyErrorIsFinal(err) {
607
+ if (!(err instanceof OutpostHttpError))
608
+ return false;
609
+ if (err.code === undefined || err.code === "outcome_unknown")
610
+ return false;
611
+ return err.status >= 400 && err.status < 500;
612
+ }
613
+ /**
614
+ * Read company status until the Outpost reads destroyed, or the wait runs out.
615
+ * The first read is immediate. A coded 404 `not_found` means the row is gone,
616
+ * which is destroyed. `destroying` keeps waiting. Any other lifecycle stops
617
+ * the wait as not confirmed. Other read errors are "not yet".
618
+ */
619
+ async function waitForCompanyDestroy(client, companyUid, outpostId, wait, json) {
620
+ const pollMs = wait.pollMs ?? COMPANY_PROVISION_POLL_MS;
621
+ const maxWaitMs = wait.maxWaitMs ?? COMPANY_PROVISION_MAX_WAIT_MS;
622
+ const pause = wait.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
623
+ const now = wait.now ?? Date.now;
624
+ const started = now();
625
+ let sawDestroying = false;
626
+ let lastProgressAt = started;
627
+ for (let first = true;; first = false) {
628
+ if (!first) {
629
+ if (now() - started >= maxWaitMs) {
630
+ return sawDestroying ? { state: "destroying" } : { state: "unconfirmed" };
631
+ }
632
+ await pause(pollMs);
633
+ }
634
+ let lifecycle;
635
+ try {
636
+ const status = await client.companyStatus(companyUid, outpostId);
637
+ lifecycle = safeLifecycle(status.outpost?.lifecycle);
638
+ }
639
+ catch (err) {
640
+ // The server's coded "no active Outpost" is gone. A codeless 404 is not proof.
641
+ if (err instanceof OutpostHttpError && err.status === 404 && err.code === "not_found") {
642
+ return { state: "destroyed" };
643
+ }
644
+ // A sign-in failure will not fix itself: stop now, still unconfirmed.
645
+ if (err instanceof OutpostHttpError && (err.status === 401 || err.status === 403)) {
646
+ return { state: "unconfirmed", signInFailed: true };
647
+ }
648
+ // Anything else is "not yet"; the timeout ends unconfirmed.
649
+ continue;
650
+ }
651
+ if (lifecycle === "destroyed")
652
+ return { state: "destroyed" };
653
+ if (lifecycle !== "destroying") {
654
+ return lifecycle ? { state: "unconfirmed", lifecycle } : { state: "unconfirmed" };
655
+ }
656
+ const elapsed = Math.round((now() - started) / 1000);
657
+ if (!sawDestroying) {
658
+ sawDestroying = true;
659
+ lastProgressAt = now();
660
+ provisionProgress(json, `Outpost is destroying (${elapsed}s).`);
661
+ }
662
+ else if (now() - lastProgressAt >= COMPANY_PROVISION_PROGRESS_EVERY_MS) {
663
+ lastProgressAt = now();
664
+ provisionProgress(json, `Still destroying (${elapsed}s).`);
665
+ }
666
+ }
667
+ }
668
+ /** Plain refusal for destroy. Nothing was destroyed. */
669
+ function failCompanyDestroy(slug, outpostId, err, json) {
670
+ const reason = err instanceof CompanyOutpostSelectError
671
+ ? explainCompanyOutpostSelect(isCompanySlug(slug) ? slug : "that company", err)
672
+ : explainCompanyFailure(slug, err, "destroy");
673
+ console.error(reason);
674
+ if (json) {
675
+ writeCompanyDestroyJson({
676
+ ok: false,
677
+ company: slug,
678
+ ...(outpostId ? { outpostId } : {}),
679
+ state: "not_destroyed",
680
+ code: companyFailureCode(err),
681
+ reason,
682
+ });
683
+ }
684
+ process.exit(1);
685
+ }
686
+ /**
687
+ * `hq outposts destroy --company <slug> [--outpost <name|id>]`.
688
+ *
689
+ * Never touches the personal Outpost. Nothing is sent until the person
690
+ * confirms: at a terminal they type the Outpost name or yes; without one,
691
+ * `--yes` is required. With `--json`, only `--yes` confirms. After the call, company status decides what is
692
+ * reported: destroyed, still destroying, or not confirmed.
693
+ */
694
+ async function destroyCompanyOutpostCommand(client, host, opts, wait = {}) {
695
+ const json = opts.json === true;
696
+ // A script never confirms at a prompt: with --json, only --yes destroys.
697
+ if (json && !opts.yes) {
698
+ const reason = "--json needs --yes to destroy an Outpost. Nothing was sent. Next: re-run with --yes to confirm.";
699
+ console.error(reason);
700
+ writeCompanyDestroyJson({
701
+ ok: false,
702
+ company: opts.company,
703
+ state: "not_destroyed",
704
+ code: "confirmation_required",
705
+ reason,
706
+ });
707
+ process.exit(1);
708
+ }
709
+ let company;
710
+ let target;
711
+ try {
712
+ company = await requireCompany(client, opts.company);
713
+ target = await requireCompanyOutpost(client, company, opts.outpost, "hq outposts destroy");
714
+ }
715
+ catch (err) {
716
+ failCompanyDestroy(opts.company, undefined, err, json);
717
+ }
718
+ const id = target.outpostId;
719
+ const label = `${company.slug}'s Outpost ${target.name}${id ? ` (${id})` : ""}`;
720
+ if (!opts.yes) {
721
+ if (!host.isStdinTty()) {
722
+ console.error(`Destroying ${label} needs confirmation. Nothing was sent. Next: re-run with --yes to confirm.`);
723
+ process.exit(1);
724
+ }
725
+ process.stdout.write("This permanently destroys an Outpost and everything on it.\n" +
726
+ ` Company: ${company.slug}\n` +
727
+ ` Outpost: ${target.name}\n` +
728
+ ` Id: ${id ?? "none (the company's only Outpost)"}\n` +
729
+ `Type the Outpost name (${target.name}) or yes to confirm: `);
730
+ const answer = host.readAnswer
731
+ ? (await host.readAnswer()).trim()
732
+ : (await host.confirm()) ? "yes" : "";
733
+ if (answer !== target.name && !/^yes$/i.test(answer)) {
734
+ console.log("Nothing was destroyed.");
735
+ return;
736
+ }
737
+ }
738
+ else {
739
+ provisionProgress(json, `Confirmed with --yes: destroying ${label}.`);
740
+ }
741
+ let destroyError;
742
+ try {
743
+ await client.destroyCompany(company.uid, id);
744
+ }
745
+ catch (err) {
746
+ if (destroyErrorIsFinal(err))
747
+ failCompanyDestroy(company.slug, id, err, json);
748
+ destroyError = err;
749
+ provisionProgress(json, isProvisionOutcomeUnknown(err)
750
+ ? "The destroy request did not return a result in time. Checking status..."
751
+ : "The destroy request returned an error. Checking status...");
752
+ }
753
+ const finished = await waitForCompanyDestroy(client, company.uid, id, wait, json);
754
+ const code = destroyError instanceof OutpostHttpError ? destroyError.code : undefined;
755
+ const statusNext = `hq outposts status --company ${company.slug}${outpostHint(target, opts.outpost)}`;
756
+ if (finished.state === "destroyed") {
757
+ if (json) {
758
+ writeCompanyDestroyJson({ ok: true, company: company.slug, ...(id ? { outpostId: id } : {}), state: "destroyed" });
759
+ return;
760
+ }
761
+ console.log(chalk.green(`${label} is destroyed.`));
762
+ return;
763
+ }
764
+ if (finished.state === "destroying") {
765
+ const reason = `${label} is still being destroyed. Next: check it in a few minutes with ${statusNext}`;
766
+ if (json) {
767
+ console.error(reason);
768
+ writeCompanyDestroyJson({
769
+ ok: true,
770
+ company: company.slug,
771
+ ...(id ? { outpostId: id } : {}),
772
+ state: "destroying",
773
+ ...(code ? { code } : {}),
774
+ reason,
775
+ });
776
+ return;
777
+ }
778
+ console.log(chalk.yellow(reason));
779
+ return;
780
+ }
781
+ const reason = finished.signInFailed
782
+ ? `The destroy could not be confirmed because the status check needs you to sign in to HQ again. Next: run hq login, then ${statusNext} before retrying.`
783
+ : finished.lifecycle
784
+ ? `The destroy could not be confirmed: status shows ${label} as ${finished.lifecycle}. Next: ${statusNext} before retrying.`
785
+ : `The destroy could not be confirmed. Next: ${statusNext} before retrying.`;
786
+ console.error(reason);
787
+ if (json) {
788
+ writeCompanyDestroyJson({
789
+ ok: false,
790
+ company: company.slug,
791
+ ...(id ? { outpostId: id } : {}),
792
+ state: "unconfirmed",
793
+ code: code ?? "unconfirmed",
794
+ reason,
795
+ });
796
+ }
797
+ // exitCode, not exit(): the action's catch must not add a second message.
798
+ process.exitCode = 1;
799
+ }
800
+ async function showCompanyStatus(client, slug, asJson, outpostFlag) {
213
801
  const company = await requireCompany(client, slug);
214
- const status = await client.companyStatus(company.uid);
802
+ const target = await requireCompanyOutpost(client, company, outpostFlag, "hq outposts status");
803
+ const status = await client.companyStatus(company.uid, target.outpostId);
215
804
  const outpost = status.outpost ?? {};
216
- const registry = await client.listCompanyJobs(company.uid);
805
+ const registry = await client.listCompanyJobs(company.uid, target.outpostId);
217
806
  let health = null;
218
807
  let healthAvailable = false;
219
808
  try {
220
- health = await client.companyJobHealth(company.uid);
809
+ health = await client.companyJobHealth(company.uid, target.outpostId);
221
810
  healthAvailable = true;
222
811
  }
223
812
  catch (err) {
@@ -228,7 +817,7 @@ async function showCompanyStatus(client, slug, asJson) {
228
817
  const jobs = mergeCompanyJobs(registry, health);
229
818
  let signIn = { signedIn: false, state: "unknown" };
230
819
  try {
231
- signIn = parseCompanySignIn(await client.companySignIn(company.uid, "status"));
820
+ signIn = parseCompanySignIn(await client.companySignIn(company.uid, "status", undefined, target.outpostId));
232
821
  }
233
822
  catch (err) {
234
823
  // An older server has no sign-in route: unknown. A non-admin is told why.
@@ -239,6 +828,7 @@ async function showCompanyStatus(client, slug, asJson) {
239
828
  if (asJson) {
240
829
  process.stdout.write(JSON.stringify({
241
830
  company: { slug: company.slug, uid: company.uid, ...(company.name ? { name: company.name } : {}) },
831
+ outpostName: target.name,
242
832
  outpost,
243
833
  jobs,
244
834
  jobHealth: healthAvailable ? "available" : "unavailable",
@@ -254,7 +844,7 @@ async function showCompanyStatus(client, slug, asJson) {
254
844
  : "";
255
845
  console.log(`Company ${company.slug}${company.name ? ` (${company.name})` : ""}`);
256
846
  console.log(`Payer ${companyPayerLabel(company)}`);
257
- console.log(`Outpost ${outpostId}`);
847
+ console.log(`Outpost ${target.name}${outpostId ? ` (${outpostId})` : ""}`);
258
848
  console.log(`State ${lifecycle}`);
259
849
  if (instance)
260
850
  console.log(`Instance ${instance}`);
@@ -267,13 +857,14 @@ async function registerCompanyJobCommand(client, opts) {
267
857
  const slug = opts.company ?? "";
268
858
  const definition = buildCompanyJobDefinition(opts);
269
859
  const company = await requireCompany(client, slug);
860
+ const target = await requireCompanyOutpost(client, company, opts.outpost, "hq outposts jobs register");
270
861
  if (definition.schedule.kind === "recurring" &&
271
862
  opts.cron !== undefined &&
272
863
  definition.schedule.cron !== opts.cron.trim() &&
273
864
  !opts.json) {
274
865
  console.log(`Using schedule ${definition.schedule.cron} for --cron "${opts.cron.trim()}".`);
275
866
  }
276
- const created = await client.registerCompanyJob(company.uid, definition);
867
+ const created = await client.registerCompanyJob(company.uid, definition, target.outpostId);
277
868
  const jobId = created.job?.jobId;
278
869
  const revision = created.job?.acceptedRevision;
279
870
  if (!jobId || !revision) {
@@ -281,12 +872,184 @@ async function registerCompanyJobCommand(client, opts) {
281
872
  process.exit(1);
282
873
  }
283
874
  if (opts.json) {
284
- process.stdout.write(JSON.stringify({ jobId, acceptedRevision: revision, company: company.slug }, null, 2) + "\n");
875
+ process.stdout.write(JSON.stringify({ jobId, acceptedRevision: revision, company: company.slug, outpost: target.name }, null, 2) + "\n");
285
876
  return;
286
877
  }
287
- console.log(`Registered ${jobId} for ${company.slug}.`);
878
+ console.log(`Registered ${jobId} for ${company.slug} on Outpost ${target.name}.`);
288
879
  console.log(`Accepted revision: ${revision}`);
289
880
  }
881
+ const COMPANY_JOB_WIRE = {
882
+ edit: "update",
883
+ pause: "disable",
884
+ resume: "enable",
885
+ delete: "delete",
886
+ "run-now": "run-now",
887
+ };
888
+ const COMPANY_JOB_ID = /^job_[A-Za-z0-9]+$/;
889
+ const SAFE_TOKEN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
890
+ function safeToken(value) {
891
+ return typeof value === "string" && SAFE_TOKEN.test(value) ? value : undefined;
892
+ }
893
+ function stopCompanyJob(message, stop, code) {
894
+ if (stop.json) {
895
+ process.stdout.write(JSON.stringify({ ok: false, action: stop.action, jobId: stop.jobId, code, message }) + "\n");
896
+ }
897
+ else {
898
+ console.error(message);
899
+ }
900
+ process.exitCode = 1;
901
+ }
902
+ function deleteRerunLine(slug, outpostFlag, jobId, revision) {
903
+ const flag = outpostFlag && SAFE_TOKEN.test(outpostFlag) ? ` --outpost ${outpostFlag}` : "";
904
+ return `hq outposts jobs delete --company ${slug}${flag} ${jobId} --revision ${revision} --yes`;
905
+ }
906
+ function unconfirmedCompanyJob(slug) {
907
+ const status = isCompanySlug(slug)
908
+ ? `hq outposts status --company ${slug}`
909
+ : "hq outposts status --company <slug>";
910
+ return `The result could not be confirmed. The server may already have changed the job. Next: ${status}`;
911
+ }
912
+ async function runCompanyJobCommand(client, action, jobId, opts) {
913
+ const stop = { action, jobId, json: Boolean(opts.json) };
914
+ if (!COMPANY_JOB_ID.test(jobId)) {
915
+ stopCompanyJob("That job id is not valid. Next: copy it from hq outposts status --company <slug>.", stop, "invalid_job");
916
+ return;
917
+ }
918
+ const company = await requireCompany(client, opts.company ?? "");
919
+ // Same selector as register: a name or an id. Omitted means the company's default Outpost.
920
+ const target = opts.outpost === undefined
921
+ ? undefined
922
+ : await requireCompanyOutpost(client, company, opts.outpost, `hq outposts jobs ${action}`);
923
+ const outpostId = target?.outpostId;
924
+ const listed = await client.listCompanyJobs(company.uid, outpostId);
925
+ const job = listed.find((row) => row.jobId === jobId);
926
+ if (!job) {
927
+ stopCompanyJob(`No job ${jobId} on this Outpost. Next: pass --outpost <id> when the company has more than one, or run hq outposts status --company ${company.slug}.`, stop, "job_not_found");
928
+ return;
929
+ }
930
+ const revision = safeToken(job.acceptedRevision);
931
+ if (!revision) {
932
+ stopCompanyJob("Someone else changed this job. Reload it and try again.", stop, "job_changed");
933
+ return;
934
+ }
935
+ if (opts.revision !== undefined && safeToken(opts.revision) !== revision) {
936
+ stopCompanyJob("Someone else changed this job. Reload it and try again.", stop, "job_changed");
937
+ return;
938
+ }
939
+ if (action === "delete" && !opts.yes) {
940
+ stopCompanyJob(`This will delete job ${jobId} on ${company.slug}'s Outpost.\n` +
941
+ `Accepted revision: ${revision}\n` +
942
+ `Re-run with --yes to confirm: ${deleteRerunLine(company.slug, opts.outpost, jobId, revision)}`, stop, "confirmation_required");
943
+ return;
944
+ }
945
+ if ((action === "edit" || (action === "delete" && opts.yes)) && opts.revision === undefined) {
946
+ stopCompanyJob(action === "delete"
947
+ ? `Delete needs --revision ${revision} so a newer change is not removed.\n` +
948
+ `Next: ${deleteRerunLine(company.slug, opts.outpost, jobId, revision)}`
949
+ : `This edit needs the revision you last saw, so it does not overwrite a newer change. Next: re-run with --revision ${revision}.`, stop, "revision_required");
950
+ return;
951
+ }
952
+ if (action === "run-now" && job.status === "paused") {
953
+ stopCompanyJob(`This job is paused. Next: hq outposts jobs resume --company ${company.slug} ${jobId}`, stop, "paused");
954
+ return;
955
+ }
956
+ const definition = action === "edit"
957
+ ? mergeCompanyJobEdit(job, {
958
+ prompt: opts.prompt,
959
+ timezone: opts.timezone,
960
+ cron: opts.cron,
961
+ at: opts.at,
962
+ script: opts.script,
963
+ context: opts.context,
964
+ clearScript: opts.clearScript,
965
+ clearContext: opts.clearContext,
966
+ })
967
+ : undefined;
968
+ if (definition?.schedule.kind === "recurring" &&
969
+ opts.cron !== undefined &&
970
+ definition.schedule.cron !== opts.cron.trim() &&
971
+ !opts.json) {
972
+ console.log(`Using schedule ${definition.schedule.cron} for --cron "${opts.cron.trim()}".`);
973
+ }
974
+ const body = await client.companyJobCommand({
975
+ companyUid: company.uid,
976
+ action: COMPANY_JOB_WIRE[action],
977
+ jobId,
978
+ revision,
979
+ ...(outpostId ? { outpostId } : {}),
980
+ ...(definition ? { definition } : {}),
981
+ });
982
+ printCompanyJobResult(action, company.slug, jobId, body, Boolean(opts.json));
983
+ }
984
+ function printCompanyJobResult(action, slug, jobId, body, json) {
985
+ const stop = { action, jobId, json };
986
+ if (action === "run-now") {
987
+ const runId = safeToken(body.runId);
988
+ const returnedJob = safeToken(body.jobId);
989
+ if (!runId || (returnedJob !== undefined && returnedJob !== jobId)) {
990
+ stopCompanyJob(unconfirmedCompanyJob(slug), stop, "unconfirmed");
991
+ return;
992
+ }
993
+ if (json) {
994
+ process.stdout.write(JSON.stringify({ ok: true, company: slug, action, jobId, runId }) + "\n");
995
+ return;
996
+ }
997
+ console.log(`Started run ${runId} for ${jobId}.`);
998
+ console.log(`Next: hq outposts status --company ${slug}`);
999
+ return;
1000
+ }
1001
+ const job = body.job;
1002
+ const row = job && typeof job === "object" ? job : undefined;
1003
+ const returnedId = safeToken(row?.jobId);
1004
+ const revision = safeToken(row?.acceptedRevision);
1005
+ if (!row || returnedId !== jobId) {
1006
+ stopCompanyJob(unconfirmedCompanyJob(slug), stop, "unconfirmed");
1007
+ return;
1008
+ }
1009
+ if (action === "pause" && row.status !== "paused") {
1010
+ stopCompanyJob(unconfirmedCompanyJob(slug), stop, "unconfirmed");
1011
+ return;
1012
+ }
1013
+ if (action === "resume" && row.status !== "active") {
1014
+ stopCompanyJob(unconfirmedCompanyJob(slug), stop, "unconfirmed");
1015
+ return;
1016
+ }
1017
+ if (action === "delete" && row.deleted !== true) {
1018
+ stopCompanyJob(unconfirmedCompanyJob(slug), stop, "unconfirmed");
1019
+ return;
1020
+ }
1021
+ if (action === "edit" && !revision) {
1022
+ stopCompanyJob(unconfirmedCompanyJob(slug), stop, "unconfirmed");
1023
+ return;
1024
+ }
1025
+ const status = row.status === "paused" || row.status === "active" ? row.status : undefined;
1026
+ if (json) {
1027
+ process.stdout.write(JSON.stringify({
1028
+ ok: true,
1029
+ company: slug,
1030
+ action,
1031
+ jobId,
1032
+ ...(revision ? { acceptedRevision: revision } : {}),
1033
+ ...(status ? { status } : {}),
1034
+ ...(action === "delete" ? { deleted: true } : {}),
1035
+ }) + "\n");
1036
+ return;
1037
+ }
1038
+ if (action === "edit") {
1039
+ console.log(`Updated ${jobId} for ${slug}.`);
1040
+ console.log(`Accepted revision: ${revision}`);
1041
+ return;
1042
+ }
1043
+ if (action === "pause") {
1044
+ console.log(`Paused ${jobId} for ${slug}.`);
1045
+ return;
1046
+ }
1047
+ if (action === "resume") {
1048
+ console.log(`Resumed ${jobId} for ${slug}.`);
1049
+ return;
1050
+ }
1051
+ console.log(`Deleted ${jobId} for ${slug}.`);
1052
+ }
290
1053
  function quoteShellArgument(value) {
291
1054
  return `'${value.replaceAll("'", "'\\''")}'`;
292
1055
  }
@@ -415,22 +1178,64 @@ export function registerOutpostsCommand(program, deps) {
415
1178
  .option("--disk <gb>", "Root disk size in GB (EC2 only). Personal boxes only.")
416
1179
  .option("--client-ip <ip>", "Your public IP for the box's SSH ingress (optional; server derives it otherwise). Personal boxes only.")
417
1180
  .option("--company <slug>", "Provision the company's Outpost instead of your personal box")
418
- .option("--yes", "Confirm the $80/month charge (required to provision)")
1181
+ .option("--name <name>", "With --company: not sent. This server creates the company's main Outpost and does not take a name yet")
1182
+ .option("--yes", "Confirm the monthly charge (required to provision or create)")
1183
+ .option("--json", "With --company, emit one JSON object. Progress goes to stderr.")
419
1184
  .action(async function (opts) {
420
1185
  try {
1186
+ if (opts.name !== undefined && opts.company === undefined) {
1187
+ console.error("--name names a company Outpost. Next: re-run with --company <slug> --name <name>.");
1188
+ process.exit(1);
1189
+ }
1190
+ if (opts.company !== undefined && opts.name !== undefined) {
1191
+ if (opts.runtime !== undefined || opts.disk !== undefined || opts.clientIp !== undefined) {
1192
+ console.error("Those options are for a personal Outpost. Next: re-run with --company and --name, and leave out --runtime, --disk and --client-ip.");
1193
+ process.exit(1);
1194
+ }
1195
+ try {
1196
+ await createCompanyOutpostCommand(client, host, {
1197
+ company: opts.company,
1198
+ name: opts.name,
1199
+ ...(opts.yes !== undefined ? { yes: opts.yes } : {}),
1200
+ ...(opts.json ? { json: true } : {}),
1201
+ }, deps.companyProvisionWait);
1202
+ }
1203
+ catch (err) {
1204
+ // The create already reported this and called process.exit.
1205
+ if (err instanceof Error && err.message.startsWith("process.exit("))
1206
+ throw err;
1207
+ if (opts.json) {
1208
+ process.stdout.write(companyProvisionFailureJson(opts.company, err));
1209
+ process.exit(1);
1210
+ }
1211
+ failCompany(opts.company, err, "provision");
1212
+ }
1213
+ return;
1214
+ }
421
1215
  if (opts.company !== undefined) {
422
1216
  if (opts.runtime !== undefined || opts.disk !== undefined || opts.clientIp !== undefined) {
423
1217
  console.error("Those options are for a personal Outpost. Next: re-run with --company and --yes, and leave out --runtime, --disk and --client-ip.");
424
1218
  process.exit(1);
425
1219
  }
426
1220
  try {
427
- await provisionCompanyOutpost(client, opts.company, opts.yes, deps.companyProvisionWait);
1221
+ await provisionCompanyOutpost(client, opts.company, opts.yes, deps.companyProvisionWait, Boolean(opts.json));
428
1222
  }
429
1223
  catch (err) {
1224
+ // The charge gate already reported this and called process.exit.
1225
+ if (err instanceof Error && err.message.startsWith("process.exit("))
1226
+ throw err;
1227
+ if (opts.json) {
1228
+ process.stdout.write(companyProvisionFailureJson(opts.company, err));
1229
+ process.exit(1);
1230
+ }
430
1231
  failCompany(opts.company, err, "provision");
431
1232
  }
432
1233
  return;
433
1234
  }
1235
+ if (opts.json) {
1236
+ console.error("--json applies to a company Outpost. Next: re-run with --company <slug> --json, or drop --json.");
1237
+ process.exit(1);
1238
+ }
434
1239
  // Reject an unrecognized runtime rather than resolving it to claude:
435
1240
  // `--runtime codx` would otherwise hand back a silently Claude box.
436
1241
  if (opts.runtime !== undefined &&
@@ -500,9 +1305,19 @@ export function registerOutpostsCommand(program, deps) {
500
1305
  });
501
1306
  outposts
502
1307
  .command("list")
503
- .description("List every Outpost you own")
1308
+ .description("List every Outpost you own, or a company's Outposts with --company")
1309
+ .option("--company <slug>", "List the company's Outposts")
504
1310
  .option("--json", "Emit raw JSON")
505
1311
  .action(async function (opts) {
1312
+ if (opts.company !== undefined) {
1313
+ try {
1314
+ await listCompanyOutpostsCommand(client, opts.company, Boolean(opts.json));
1315
+ }
1316
+ catch (err) {
1317
+ failCompany(opts.company, err, "status");
1318
+ }
1319
+ return;
1320
+ }
506
1321
  try {
507
1322
  const rows = await client.list();
508
1323
  if (opts.json) {
@@ -546,8 +1361,10 @@ export function registerOutpostsCommand(program, deps) {
546
1361
  .description("Show live detail for one Outpost")
547
1362
  .option("--id <outpostId>", "Personal Outpost id (defaults to your primary box)")
548
1363
  .option("--company <slug>", "Show the company Outpost, its jobs, and each job's run status")
1364
+ .option("--outpost <name|id>", "Which company Outpost, when the company has more than one")
549
1365
  .option("--json", "Emit raw JSON")
550
1366
  .action(async function (opts) {
1367
+ rejectOutpostWithoutCompany(opts);
551
1368
  try {
552
1369
  if (opts.company !== undefined) {
553
1370
  if (opts.id !== undefined) {
@@ -555,7 +1372,7 @@ export function registerOutpostsCommand(program, deps) {
555
1372
  process.exit(1);
556
1373
  }
557
1374
  try {
558
- await showCompanyStatus(client, opts.company, Boolean(opts.json));
1375
+ await showCompanyStatus(client, opts.company, Boolean(opts.json), opts.outpost);
559
1376
  }
560
1377
  catch (err) {
561
1378
  failCompany(opts.company, err, "status");
@@ -575,7 +1392,7 @@ export function registerOutpostsCommand(program, deps) {
575
1392
  });
576
1393
  const jobs = outposts
577
1394
  .command("jobs")
578
- .description("Register a job on a company Outpost");
1395
+ .description("Register, edit, pause, resume, delete, or run a job on a company Outpost");
579
1396
  jobs
580
1397
  .command("register")
581
1398
  .description("Register a company job and print the accepted revision")
@@ -586,8 +1403,10 @@ export function registerOutpostsCommand(program, deps) {
586
1403
  .option("--at <iso>", "One-time ISO-8601 instant. Do not combine with --cron")
587
1404
  .option("--script <text>", "Optional precondition script stored with the job")
588
1405
  .option("--context <json>", "Optional JSON object stored with the job")
1406
+ .option("--outpost <name|id>", "Which company Outpost runs the job, when the company has more than one")
589
1407
  .option("--json", "Emit the job id and accepted revision as JSON")
590
1408
  .action(async function (opts) {
1409
+ rejectOutpostWithoutCompany(opts);
591
1410
  try {
592
1411
  await registerCompanyJobCommand(client, opts);
593
1412
  }
@@ -595,6 +1414,45 @@ export function registerOutpostsCommand(program, deps) {
595
1414
  failCompany(opts.company ?? "", err, "register");
596
1415
  }
597
1416
  });
1417
+ const companyJobActions = [
1418
+ { name: "edit", description: "Change a company job. Omitted fields stay as they are. Requires --revision", definition: true, confirm: false },
1419
+ { name: "pause", description: "Pause a company job", definition: false, confirm: false },
1420
+ { name: "resume", description: "Resume a paused company job", definition: false, confirm: false },
1421
+ { name: "delete", description: "Delete a company job (requires --yes)", definition: false, confirm: true },
1422
+ { name: "run-now", description: "Run a company job once now", definition: false, confirm: false },
1423
+ ];
1424
+ for (const spec of companyJobActions) {
1425
+ const command = jobs
1426
+ .command(spec.name)
1427
+ .description(spec.description)
1428
+ .argument("<jobId>", "Job id from hq outposts status --company <slug>")
1429
+ .option("--company <slug>", "Company that owns the Outpost")
1430
+ .option("--outpost <name|id>", "Which company Outpost, when the company has more than one")
1431
+ .option("--revision <rev>", "Accepted revision you last saw. Required for edit and delete --yes");
1432
+ if (spec.definition) {
1433
+ command
1434
+ .option("--prompt <text>", "What the job should do. Omit to keep the current prompt")
1435
+ .option("--timezone <name>", "IANA timezone, such as America/New_York. Omit to keep the current timezone")
1436
+ .option("--cron <expr>", 'Recurring schedule: rate(N minutes|hours|days), a five-field cron with * for day of month and month (such as "*/15 * * * *"; translated to the AWS form), or cron(MIN HOUR ? * DOW *). Omit to keep the current schedule')
1437
+ .option("--at <iso>", "One-time ISO-8601 instant. Do not combine with --cron")
1438
+ .option("--script <text>", "Precondition script stored with the job. Omit to keep the current script")
1439
+ .option("--context <json>", "JSON object stored with the job. Omit to keep the current context")
1440
+ .option("--clear-script", "Remove the job's precondition script")
1441
+ .option("--clear-context", "Remove the job's stored context");
1442
+ }
1443
+ if (spec.confirm) {
1444
+ command.option("--yes", "Confirm deletion of this job");
1445
+ }
1446
+ command.option("--json", "Emit the result as JSON").action(async function (jobId, opts) {
1447
+ rejectOutpostWithoutCompany(opts);
1448
+ try {
1449
+ await runCompanyJobCommand(client, spec.name, jobId, opts);
1450
+ }
1451
+ catch (err) {
1452
+ failCompany(opts.company ?? "", err, spec.name, opts.json ? { action: spec.name, jobId } : undefined);
1453
+ }
1454
+ });
1455
+ }
598
1456
  outposts
599
1457
  .command("exec <command...>")
600
1458
  .description("Run a shell command on an Outpost and print its output (use -- before flags meant for the remote command). " +
@@ -948,11 +1806,13 @@ export function registerOutpostsCommand(program, deps) {
948
1806
  .command("refresh-config")
949
1807
  .description("Re-apply the server's job runner configuration to the company Outpost (owner or admin)")
950
1808
  .requiredOption("--company <slug>", "Company slug")
1809
+ .option("--outpost <name|id>", "Which company Outpost, when the company has more than one")
951
1810
  .option("--json", "Emit raw JSON")
952
1811
  .action(async function (opts) {
953
1812
  try {
954
1813
  const company = await requireCompany(client, opts.company);
955
- await client.refreshCompanyConfig(company.uid);
1814
+ const target = await requireCompanyOutpost(client, company, opts.outpost, "hq outposts refresh-config");
1815
+ await client.refreshCompanyConfig(company.uid, target.outpostId);
956
1816
  // requireCompany validated opts.company; print the user's slug, never server text.
957
1817
  const slug = opts.company;
958
1818
  if (opts.json) {
@@ -960,7 +1820,7 @@ export function registerOutpostsCommand(program, deps) {
960
1820
  return;
961
1821
  }
962
1822
  console.log(chalk.green(`Configuration refresh started for ${slug}'s Outpost. It takes a few minutes.`));
963
- console.log(chalk.dim(`Next: hq outposts status --company ${slug}`));
1823
+ console.log(chalk.dim(`Next: hq outposts status --company ${slug}${outpostHint(target, opts.outpost)}`));
964
1824
  }
965
1825
  catch (err) {
966
1826
  failCompany(opts.company, err, "refresh");
@@ -989,8 +1849,10 @@ export function registerOutpostsCommand(program, deps) {
989
1849
  .description("Request a fresh login URL for an Outpost")
990
1850
  .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
991
1851
  .option("--company <slug>", "Request a new sign-in link for the company's Outpost (owner or admin)")
1852
+ .option("--outpost <name|id>", "Which company Outpost, when the company has more than one")
992
1853
  .option("--json", "Emit raw JSON")
993
1854
  .action(async function (opts) {
1855
+ rejectOutpostWithoutCompany(opts);
994
1856
  if (opts.company !== undefined) {
995
1857
  if (opts.id) {
996
1858
  console.error(chalk.red("Use either --company or --id, not both."));
@@ -998,13 +1860,14 @@ export function registerOutpostsCommand(program, deps) {
998
1860
  }
999
1861
  try {
1000
1862
  const company = await requireCompany(client, opts.company);
1001
- await client.companySignIn(company.uid, "regenerate");
1863
+ const target = await requireCompanyOutpost(client, company, opts.outpost, "hq outposts login");
1864
+ await client.companySignIn(company.uid, "regenerate", undefined, target.outpostId);
1002
1865
  if (opts.json) {
1003
- process.stdout.write(JSON.stringify({ ok: true, company: company.slug, action: "regenerate" }, null, 2) + "\n");
1866
+ process.stdout.write(JSON.stringify({ ok: true, company: company.slug, outpost: target.name, action: "regenerate" }, null, 2) + "\n");
1004
1867
  return;
1005
1868
  }
1006
- console.log(chalk.green(`A new sign-in link was requested for ${company.slug}'s Outpost.`));
1007
- console.log(chalk.dim(`Next: hq outposts status --company ${company.slug} to read it.`));
1869
+ console.log(chalk.green(`A new sign-in link was requested for ${company.slug}'s Outpost ${target.name}.`));
1870
+ console.log(chalk.dim(`Next: hq outposts status --company ${company.slug}${outpostHint(target, opts.outpost)} to read it.`));
1008
1871
  }
1009
1872
  catch (err) {
1010
1873
  failCompany(opts.company, err, "signin");
@@ -1034,8 +1897,10 @@ export function registerOutpostsCommand(program, deps) {
1034
1897
  .description("Submit the Claude sign-in code for an Outpost that is awaiting login")
1035
1898
  .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1036
1899
  .option("--company <slug>", "Sign in the company's Outpost (owner or admin)")
1900
+ .option("--outpost <name|id>", "Which company Outpost, when the company has more than one")
1037
1901
  .option("--json", "Emit raw JSON")
1038
1902
  .action(async function (code, opts) {
1903
+ rejectOutpostWithoutCompany(opts);
1039
1904
  const trimmed = code.trim();
1040
1905
  if (!trimmed) {
1041
1906
  console.error(chalk.red("Provide the sign-in code: hq outposts login-code [--company <slug>] <code>"));
@@ -1048,13 +1913,14 @@ export function registerOutpostsCommand(program, deps) {
1048
1913
  }
1049
1914
  try {
1050
1915
  const company = await requireCompany(client, opts.company);
1051
- await client.companySignIn(company.uid, "code", trimmed);
1916
+ const target = await requireCompanyOutpost(client, company, opts.outpost, "hq outposts login-code <code>");
1917
+ await client.companySignIn(company.uid, "code", trimmed, target.outpostId);
1052
1918
  if (opts.json) {
1053
- process.stdout.write(JSON.stringify({ ok: true, company: company.slug, action: "code" }, null, 2) + "\n");
1919
+ process.stdout.write(JSON.stringify({ ok: true, company: company.slug, outpost: target.name, action: "code" }, null, 2) + "\n");
1054
1920
  return;
1055
1921
  }
1056
- console.log(chalk.green(`Code submitted. ${company.slug}'s Outpost will finish signing in shortly.`));
1057
- console.log(chalk.dim(`Next: hq outposts status --company ${company.slug}`));
1922
+ console.log(chalk.green(`Code submitted. ${company.slug}'s Outpost ${target.name} will finish signing in shortly.`));
1923
+ console.log(chalk.dim(`Next: hq outposts status --company ${company.slug}${outpostHint(target, opts.outpost)}`));
1058
1924
  }
1059
1925
  catch (err) {
1060
1926
  failCompany(opts.company, err, "signin");
@@ -1080,8 +1946,24 @@ export function registerOutpostsCommand(program, deps) {
1080
1946
  .command("destroy")
1081
1947
  .description("Tear down (permanently destroy) an Outpost")
1082
1948
  .option("--id <outpostId>", "Outpost id (defaults to your primary box)")
1949
+ .option("--company <slug>", "Destroy one of the company's Outposts (owner or admin)")
1950
+ .option("--outpost <name|id>", "Which company Outpost, when the company has more than one")
1951
+ .option("--json", "With --company and --yes: print one result object on stdout")
1083
1952
  .option("--yes", "Confirm the irreversible teardown (required)")
1084
1953
  .action(async function (opts) {
1954
+ rejectOutpostWithoutCompany(opts);
1955
+ if (opts.company !== undefined) {
1956
+ if (opts.id) {
1957
+ console.error(chalk.red("Use either --company or --id, not both."));
1958
+ process.exit(1);
1959
+ }
1960
+ await destroyCompanyOutpostCommand(client, host, { company: opts.company, outpost: opts.outpost, yes: opts.yes, json: opts.json }, deps.companyDestroyWait);
1961
+ return;
1962
+ }
1963
+ if (opts.json) {
1964
+ console.error("--json applies to a company Outpost. Next: re-run with --company <slug> --yes --json, or drop --json.");
1965
+ process.exit(1);
1966
+ }
1085
1967
  const target = opts.id ?? "your primary Outpost";
1086
1968
  if (!opts.yes) {
1087
1969
  console.error(chalk.yellow(`This will permanently destroy ${target} and delete its cloud resources. ` +