@allocator-one/harness-cli 1.15.1 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -14,6 +14,8 @@ import {
14
14
  JOBS_MAX_PAGES,
15
15
  JOB_STATES,
16
16
  LocalError,
17
+ PLAN_LIST_MAX_LIMIT,
18
+ PLAN_STATUSES,
17
19
  RELATION_CREATE_TYPES,
18
20
  RELATION_UPDATE_TYPES,
19
21
  SESSION_PATH,
@@ -73,9 +75,11 @@ import {
73
75
  memberSetRole,
74
76
  milestoneCreate,
75
77
  milestoneList,
78
+ orgCredentialsPath,
76
79
  planCreate,
77
80
  planEdit,
78
81
  planHold,
82
+ planList,
79
83
  planRelease,
80
84
  planShow,
81
85
  prepareProcedureRun,
@@ -100,7 +104,8 @@ import {
100
104
  requireIdentifiers,
101
105
  requireJobId,
102
106
  requireJobsArgs,
103
- requirePlanId,
107
+ requirePlanListArgs,
108
+ requirePlanRef,
104
109
  requireProcedureId,
105
110
  requireProcedureName,
106
111
  requireReadyClaimArgs,
@@ -133,13 +138,14 @@ import {
133
138
  update,
134
139
  upload,
135
140
  userList,
141
+ validOrgId,
136
142
  validateBaseUrl,
137
143
  validateIdempotencyKey,
138
144
  version,
139
145
  watchStopSignal,
140
146
  workflowStates,
141
147
  writeCredentials
142
- } from "./chunk-L2OWOZ4N.js";
148
+ } from "./chunk-GVTZKAXC.js";
143
149
  import {
144
150
  CLI_VERSION,
145
151
  OPERATOR_PACKAGE,
@@ -336,6 +342,172 @@ async function exec(ctx, opts) {
336
342
  return { json: { exit_code: exitCode }, human: "", exitCode };
337
343
  }
338
344
 
345
+ // src/commands/review.ts
346
+ import { execFileSync } from "child_process";
347
+ function isRecord2(value) {
348
+ return typeof value === "object" && value !== null && !Array.isArray(value);
349
+ }
350
+ var SLUG = /^([\w.-]+)\/([\w.-]+)$/;
351
+ function githubSlug(remote) {
352
+ const match = /^(?:(?:https?|ssh|git):\/\/(?:[^@/\s]+@)?|[\w.-]+@)github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(remote.trim());
353
+ return match ? `${match[1]}/${match[2]}` : void 0;
354
+ }
355
+ function originRemote() {
356
+ try {
357
+ const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
358
+ return githubSlug(url);
359
+ } catch {
360
+ return void 0;
361
+ }
362
+ }
363
+ function prNumber(digits, input) {
364
+ const number = Number(digits);
365
+ if (!Number.isSafeInteger(number) || number < 1) throw new UsageError(`not a pull request number: ${input}`);
366
+ return number;
367
+ }
368
+ var NAME = /^[\w.-]+$/;
369
+ function fromUrl(input) {
370
+ let url;
371
+ try {
372
+ url = new URL(input);
373
+ } catch {
374
+ return null;
375
+ }
376
+ if (!["https:", "http:"].includes(url.protocol) || !/^(www\.)?github\.com$/i.test(url.hostname)) return null;
377
+ let segments;
378
+ try {
379
+ segments = url.pathname.split("/").filter((segment) => segment !== "").map(decodeURIComponent);
380
+ } catch {
381
+ return null;
382
+ }
383
+ const [owner, repo, kind, number] = segments;
384
+ if (kind !== "pull" || !owner || !repo || !number || !NAME.test(owner) || !NAME.test(repo) || !/^\d+$/.test(number)) {
385
+ return null;
386
+ }
387
+ return [owner, repo, number];
388
+ }
389
+ function parsePullRequest(input, repo, origin = originRemote) {
390
+ const trimmed = input.trim();
391
+ const url = fromUrl(trimmed);
392
+ if (url) return { owner: url[0], repo: url[1], number: prNumber(url[2], input) };
393
+ const named2 = /^([\w.-]+)\/([\w.-]+)#(\d+)$/.exec(trimmed);
394
+ if (named2) return { owner: named2[1], repo: named2[2], number: prNumber(named2[3], input) };
395
+ const bare = /^#?(\d+)$/.exec(trimmed);
396
+ if (!bare) throw new UsageError(`not a pull request: ${input} \u2014 use owner/repo#<pr>, its URL, or <pr> with --repo`);
397
+ const slug = repo ?? origin();
398
+ const parts = slug === void 0 ? null : SLUG.exec(slug.trim());
399
+ if (!parts) throw new UsageError("name the repository: owner/repo#<pr>, the pull request URL, or --repo owner/repo");
400
+ return { owner: parts[1], repo: parts[2], number: prNumber(bare[1], input) };
401
+ }
402
+ var label = (pr) => `${pr.owner}/${pr.repo}#${pr.number}`;
403
+ var gatePath = (pr) => `/api/v1/reviews/prs/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${pr.number}`;
404
+ async function fetchGate(ctx, pr) {
405
+ const body = await ctx.client.get(gatePath(pr));
406
+ if (!isRecord2(body.data)) throw new CommandError(`unexpected gate status for ${label(pr)}: no data`);
407
+ return body.data;
408
+ }
409
+ function latestAttempt(lifecycle) {
410
+ const attempts = Array.isArray(lifecycle["attempts"]) ? lifecycle["attempts"].filter(isRecord2) : [];
411
+ return attempts.reduce(
412
+ (latest, attempt) => latest === void 0 || Number(attempt["number"]) > Number(latest["number"]) ? attempt : latest,
413
+ void 0
414
+ );
415
+ }
416
+ function recovery(lifecycle) {
417
+ const latest = latestAttempt(lifecycle);
418
+ const sent = isRecord2(lifecycle["recovery"]) ? lifecycle["recovery"] : void 0;
419
+ const state2 = String(sent?.["state"] ?? latest?.["state"] ?? "");
420
+ if (state2 !== "failed" && state2 !== "timed_out") return void 0;
421
+ const reason = sent?.["terminal_reason"] ?? latest?.["terminal_reason"];
422
+ return {
423
+ number: latest?.["number"],
424
+ state: state2 === "timed_out" ? "timed out" : state2,
425
+ reason: typeof reason === "string" ? reason : null,
426
+ evidence: sent ? sent["evidence_recorded"] === true : latest?.["review_run_id"] != null,
427
+ next: sent?.["next"] === "automatic_retry" ? "automatic_retry" : "retry"
428
+ };
429
+ }
430
+ function describe(r) {
431
+ const why = [r.reason, r.evidence ? null : "no evidence recorded"].filter((part) => part !== null).join(", ");
432
+ return `attempt ${clean(String(r.number))} ${r.state}${why === "" ? "" : ` (${clean(why)})`}`;
433
+ }
434
+ function projection(gate2, key) {
435
+ const value = gate2[key];
436
+ return isRecord2(value) && typeof value["status"] === "string" ? clean(value["status"]) : "unknown";
437
+ }
438
+ async function status(ctx, pr) {
439
+ const gate2 = await fetchGate(ctx, pr);
440
+ const lifecycle = isRecord2(gate2["lifecycle"]) ? gate2["lifecycle"] : void 0;
441
+ const head = isRecord2(gate2["head"]) && typeof gate2["head"]["sha"] === "string" ? gate2["head"]["sha"].slice(0, 10) : "?";
442
+ const lines = [
443
+ `${label(pr)} at ${clean(head)}`,
444
+ `enforced: ${projection(gate2, "enforced")} advisory: ${projection(gate2, "advisory")}`
445
+ ];
446
+ if (lifecycle) {
447
+ lines.push(`gate check: ${clean(String(lifecycle["state"] ?? "unknown")).replaceAll("_", " ")}`);
448
+ const r = recovery(lifecycle);
449
+ if (r) {
450
+ lines.push(`latest ${describe(r)}`);
451
+ lines.push(
452
+ r.next === "automatic_retry" ? "Harness retries it automatically; check again shortly." : named(`Retry it with: harness review retry ${label(pr)} --reason "\u2026"`)
453
+ );
454
+ }
455
+ }
456
+ return { json: gate2, human: lines.join("\n") + "\n" };
457
+ }
458
+ var REFUSALS = {
459
+ execution_not_terminal: "the latest attempt is still running; wait for it to finish",
460
+ attempt_changed: "another attempt was created meanwhile; read the status and run this again",
461
+ check_changed: "the head's gate check changed meanwhile; run this again",
462
+ head_changed: "the pull request's head moved; the new head gets its own check",
463
+ advisory_not_converged: "the head's advisory review has not converged; converge it first",
464
+ retry_conflict: "this attempt was already retried with a different reason",
465
+ merged: "the pull request is merged"
466
+ };
467
+ async function retry(ctx, pr, reason) {
468
+ const gate2 = await fetchGate(ctx, pr);
469
+ const lifecycle = isRecord2(gate2["lifecycle"]) ? gate2["lifecycle"] : void 0;
470
+ if (!lifecycle || typeof lifecycle["check_id"] !== "string" || typeof lifecycle["head_sha"] !== "string") {
471
+ throw new CommandError(`${label(pr)} has no gate check at its current head: nothing to retry`);
472
+ }
473
+ const r = recovery(lifecycle);
474
+ if (r?.next === "automatic_retry") {
475
+ throw new CommandError(named(`Harness retries attempt ${clean(String(r.number))} itself; run harness review status ${label(pr)} shortly`));
476
+ }
477
+ const previous = latestAttempt(lifecycle);
478
+ const previousId = typeof previous?.["id"] === "string" ? previous["id"] : null;
479
+ const body = {
480
+ head_sha: lifecycle["head_sha"],
481
+ check_id: lifecycle["check_id"],
482
+ previous_attempt_id: previousId,
483
+ // One key per attempt retried: running this again for the same attempt returns the attempt already
484
+ // queued instead of paying for another review run.
485
+ request_key: `harness-cli:${previousId ?? lifecycle["check_id"]}`,
486
+ reason: reason?.trim() || "Retry requested with harness review retry"
487
+ };
488
+ let response;
489
+ try {
490
+ response = await ctx.client.post(`${gatePath(pr)}/retry`, body, { actor: false });
491
+ } catch (error) {
492
+ if (error instanceof HttpError && error.status === 409) {
493
+ const code = error.message.trim().split(/[\s:]+/).pop() ?? "";
494
+ throw new CommandError(`could not retry ${label(pr)}: ${REFUSALS[code] ?? clean(error.message)}`);
495
+ }
496
+ if (error instanceof HttpError && error.status === 403) {
497
+ throw new CommandError(`${error.message} \u2014 a retry needs a login with reviews:write`);
498
+ }
499
+ throw error;
500
+ }
501
+ const attempt = isRecord2(response.data) ? response.data : {};
502
+ const outcome = response["outcome"];
503
+ const verb = outcome === "existing" ? "Already queued" : "Queued";
504
+ return {
505
+ json: { attempt, outcome },
506
+ human: `${verb} attempt ${clean(String(attempt["number"]))} (${clean(String(attempt["id"]))}) for ${label(pr)}
507
+ `
508
+ };
509
+ }
510
+
339
511
  // src/core/auth.ts
340
512
  import { createHash, randomBytes } from "crypto";
341
513
  import { execFile } from "child_process";
@@ -365,6 +537,9 @@ function challengeFor(verifier) {
365
537
  async function login(baseUrl, options = {}) {
366
538
  const validated = validateBaseUrl(baseUrl);
367
539
  if (!validated.ok) return { ok: false, message: validated.message };
540
+ if (options.org !== void 0 && !validOrgId(options.org)) {
541
+ return { ok: false, message: "org must be an organization UUID" };
542
+ }
368
543
  const base = baseUrl.replace(/\/+$/, "");
369
544
  if (options.noBrowser === true || !browserAvailable(options)) {
370
545
  return manualLogin(base, options);
@@ -437,6 +612,7 @@ async function loopbackLogin(baseUrl, options) {
437
612
  const detail = error instanceof Error ? error.message : String(error);
438
613
  return { ok: false, message: `could not open a browser: ${detail}` };
439
614
  }
615
+ if (options.org !== void 0) notify(`Choose organization ${options.org} on the login page; another organization's token will be refused.`);
440
616
  notify(`Opening ${url}`);
441
617
  notify("Waiting for the browser authorization (Ctrl-C to abort)\u2026");
442
618
  const callback = await callbackPromise;
@@ -523,15 +699,15 @@ function awaitCallback(server, state2, timeoutMs) {
523
699
  });
524
700
  server.on("request", (request, response) => {
525
701
  const respond = callbackResponder(response);
526
- const respondWithPage = (status, message, pageState) => {
702
+ const respondWithPage = (status2, message, pageState) => {
527
703
  if (callbackPageResponses >= MAX_PAGE_RESPONSES) {
528
704
  respondEmpty(response, 429);
529
705
  return Promise.resolve();
530
706
  }
531
707
  callbackPageResponses += 1;
532
- return respond(status, message, pageState);
708
+ return respond(status2, message, pageState);
533
709
  };
534
- const respondAtRoot = (status, message, pageState) => {
710
+ const respondAtRoot = (status2, message, pageState) => {
535
711
  const rootPhase = phase === "terminal" ? "terminal" : "waiting";
536
712
  const responses = rootPageResponses.get(rootPhase) ?? 0;
537
713
  if (responses >= MAX_PAGE_RESPONSES) {
@@ -539,7 +715,7 @@ function awaitCallback(server, state2, timeoutMs) {
539
715
  return Promise.resolve();
540
716
  }
541
717
  rootPageResponses.set(rootPhase, responses + 1);
542
- return respond(status, message, pageState);
718
+ return respond(status2, message, pageState);
543
719
  };
544
720
  let url;
545
721
  try {
@@ -623,12 +799,12 @@ function awaitCallback(server, state2, timeoutMs) {
623
799
  resolve({
624
800
  ok: true,
625
801
  code,
626
- respond: async (status, message, pageState) => {
627
- finalResponse = { status, message, pageState };
802
+ respond: async (status2, message, pageState) => {
803
+ finalResponse = { status: status2, message, pageState };
628
804
  phase = "terminal";
629
805
  const responders = acceptedResponders.splice(0);
630
806
  await Promise.all(
631
- responders.map((acceptedRespond) => trackResponse(acceptedRespond(status, message, pageState)))
807
+ responders.map((acceptedRespond) => trackResponse(acceptedRespond(status2, message, pageState)))
632
808
  );
633
809
  await drainTrackedResponses();
634
810
  }
@@ -640,7 +816,7 @@ function awaitCallback(server, state2, timeoutMs) {
640
816
  });
641
817
  }
642
818
  function callbackResponder(response) {
643
- return async (status, message, state2) => {
819
+ return async (status2, message, state2) => {
644
820
  if (response.writableEnded || response.destroyed) return;
645
821
  return new Promise((resolve) => {
646
822
  let flushTimer;
@@ -671,7 +847,7 @@ function callbackResponder(response) {
671
847
  }
672
848
  try {
673
849
  const html = loginPage(message, state2);
674
- response.writeHead(status, {
850
+ response.writeHead(status2, {
675
851
  "cache-control": "no-store",
676
852
  connection: "close",
677
853
  "content-length": String(Buffer.byteLength(html)),
@@ -691,10 +867,10 @@ function callbackResponder(response) {
691
867
  });
692
868
  };
693
869
  }
694
- function respondEmpty(response, status) {
870
+ function respondEmpty(response, status2) {
695
871
  response.once("error", () => void 0);
696
872
  try {
697
- response.writeHead(status, {
873
+ response.writeHead(status2, {
698
874
  "cache-control": "no-store",
699
875
  connection: "close",
700
876
  "x-content-type-options": "nosniff"
@@ -733,6 +909,7 @@ async function exchangeCode(baseUrl, code, verifier, options) {
733
909
  async function manualLogin(baseUrl, options) {
734
910
  const notify = options.notify ?? defaultNotify;
735
911
  const prompt = options.prompt ?? defaultPrompt;
912
+ if (options.org !== void 0) notify(`Choose organization ${options.org} on the login page; another organization's token will be refused.`);
736
913
  notify(
737
914
  `Open ${baseUrl}/cli/login in a browser, authorize, and paste the token below (an outside expert pastes the pxc_ credential minted in their expert view instead).`
738
915
  );
@@ -741,17 +918,23 @@ async function manualLogin(baseUrl, options) {
741
918
  return verifyAndStore(baseUrl, token, options, { minted: false, kind: null });
742
919
  }
743
920
  async function verifyAndStore(baseUrl, token, options, received) {
744
- const path = options.credentialsPath ?? defaultCredentialsPath(options.env ?? process.env);
921
+ const defaultPath = options.credentialsPath ?? defaultCredentialsPath(options.env ?? process.env);
922
+ const path = options.org === void 0 ? defaultPath : orgCredentialsPath(defaultPath, baseUrl, options.org);
745
923
  const client = bareClient(baseUrl, token, options);
746
924
  const expert = isExpertToken(token);
747
925
  let kind = received.kind;
748
926
  try {
749
927
  if (expert) {
928
+ if (options.org !== void 0) return { ok: false, message: "cannot verify an outside expert credential against a repo org \u2014 nothing was stored" };
750
929
  await client.get(EXPERT_ASSIGNMENTS_PATH);
751
930
  } else {
752
931
  const described = isLoginCredential(token) ? await describeSession(client) : null;
932
+ if (described !== null) kind = described.session.kind ?? kind;
933
+ if (options.org !== void 0 && (typeof described?.session.org?.["id"] !== "string" || described.session.org["id"].toLowerCase() !== options.org.toLowerCase())) {
934
+ const revoked = received.minted && await revokeSession(client, token);
935
+ return { ok: false, message: `the received token does not verify organization ${options.org} \u2014 nothing was stored` + (revoked ? ", and the token was revoked" : "") };
936
+ }
753
937
  if (described === null) await client.get("/api/v1/teams");
754
- else kind = described.session.kind ?? kind;
755
938
  }
756
939
  } catch (error) {
757
940
  if (error instanceof HttpError) {
@@ -815,14 +998,14 @@ async function describeSession(client) {
815
998
  }
816
999
  }
817
1000
  function sessionOf(data) {
818
- if (!isRecord2(data)) return NO_SESSION;
1001
+ if (!isRecord3(data)) return NO_SESSION;
819
1002
  const text = (value) => typeof value === "string" ? value : null;
820
1003
  return {
821
1004
  kind: text(data["kind"]),
822
1005
  expires_at: text(data["expires_at"]),
823
1006
  scopes: Array.isArray(data["scopes"]) ? data["scopes"].filter((scope) => typeof scope === "string") : null,
824
- user: isRecord2(data["user"]) ? data["user"] : null,
825
- org: isRecord2(data["org"]) ? data["org"] : null
1007
+ user: isRecord3(data["user"]) ? data["user"] : null,
1008
+ org: isRecord3(data["org"]) ? data["org"] : null
826
1009
  };
827
1010
  }
828
1011
  async function revokeSession(client, token) {
@@ -957,12 +1140,12 @@ function sessionLines(session) {
957
1140
  return lines;
958
1141
  }
959
1142
  function expertSeat(data) {
960
- if (!isRecord2(data) || !isRecord2(data["expert"])) return null;
961
- const plan2 = isRecord2(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
962
- const steps = Array.isArray(data["assignments"]) ? data["assignments"].filter(isRecord2).length : 0;
1143
+ if (!isRecord3(data) || !isRecord3(data["expert"])) return null;
1144
+ const plan2 = isRecord3(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
1145
+ const steps = Array.isArray(data["assignments"]) ? data["assignments"].filter(isRecord3).length : 0;
963
1146
  return { id: data["expert"]["id"], role: data["expert"]["role"] ?? null, plan: plan2, assigned_steps: steps };
964
1147
  }
965
- function isRecord2(value) {
1148
+ function isRecord3(value) {
966
1149
  return typeof value === "object" && value !== null && !Array.isArray(value);
967
1150
  }
968
1151
  function bareClient(baseUrl, token, options) {
@@ -1564,6 +1747,22 @@ function interactive() {
1564
1747
  const ci = process.env["CI"];
1565
1748
  return process.stdout.isTTY === true && (ci === void 0 || ci === "");
1566
1749
  }
1750
+ function canConfirmRepoLogin() {
1751
+ return process.stdin.isTTY === true && process.stderr.isTTY === true;
1752
+ }
1753
+ async function confirmRepoLogin(baseUrl, source, path) {
1754
+ if (source !== "repo-config" || isHostTrusted(baseUrl)) return true;
1755
+ const answer = await promptLine(
1756
+ `This repo's config (${path ?? ".harness-cli/config.json"}) targets ${baseUrl} \u2014 log in there? [y/N] `
1757
+ );
1758
+ if (answer.trim().toLowerCase() !== "y") {
1759
+ writeStderr(`harness: not logging in to ${baseUrl} \u2014 set HARNESS_API_URL to override the repo config
1760
+ `);
1761
+ return false;
1762
+ }
1763
+ trustHost(baseUrl);
1764
+ return true;
1765
+ }
1567
1766
  async function withConfig(jsonFlag, mutating, run2) {
1568
1767
  const resolved = resolveConfig();
1569
1768
  for (const warning of resolved.warnings) writeStderr(warning + "\n");
@@ -1575,22 +1774,14 @@ async function withConfig(jsonFlag, mutating, run2) {
1575
1774
  if (resolved.kind === "needs-login") {
1576
1775
  if (!interactive()) {
1577
1776
  writeStderr(loginHelp(resolved.baseUrl));
1777
+ if (resolved.org !== void 0) writeStderr(`Choose organization ${resolved.org} when logging in.
1778
+ `);
1578
1779
  return exitWith(2);
1579
1780
  }
1580
- if (resolved.hostSource === "repo-config" && !isHostTrusted(resolved.baseUrl)) {
1581
- const answer = await promptLine(
1582
- `This repo's config (${resolved.repoConfigPath ?? ".harness-cli/config.json"}) targets ${resolved.baseUrl} \u2014 log in there? [y/N] `
1583
- );
1584
- if (answer.trim().toLowerCase() !== "y") {
1585
- writeStderr(
1586
- `harness: not logging in to ${resolved.baseUrl} \u2014 set HARNESS_API_URL to override the repo config
1587
- `
1588
- );
1589
- return exitWith(2);
1590
- }
1591
- trustHost(resolved.baseUrl);
1781
+ if (!await confirmRepoLogin(resolved.baseUrl, resolved.hostSource, resolved.repoConfigPath)) {
1782
+ return exitWith(2);
1592
1783
  }
1593
- const loginResult = await login(resolved.baseUrl, {});
1784
+ const loginResult = await login(resolved.baseUrl, resolved.org === void 0 ? {} : { org: resolved.org });
1594
1785
  if (!loginResult.ok) {
1595
1786
  writeStderr(`harness: ${clean(loginResult.message)}
1596
1787
  `);
@@ -1926,32 +2117,37 @@ plan.command("create").description("create a plan from a JSON or YAML file (the
1926
2117
  return planCreate(ctx, readPlanFile(opts.file), { ...opts, team: team2 });
1927
2118
  });
1928
2119
  });
1929
- plan.command("show").description("a plan's status, budgets, revision, steps, edges and wait conditions").argument("[id]").option("--json", "emit JSON").action(async (id, opts) => {
2120
+ plan.command("list").description("a team's plans, newest first: root, status, held, revision and title").option("--team <key>", "the team whose issues root the plans (or HARNESS_TEAM)").option("--status <status>", PLAN_STATUSES.join(" | ")).option("--limit <n>", `maximum plans (default 50, at most ${PLAN_LIST_MAX_LIMIT}; there are no pages)`, integer).option("--json", "emit JSON").action(async (opts) => {
2121
+ requirePlanListArgs(opts);
2122
+ await withConfig(opts.json === true, false, (ctx) => planList(ctx, opts));
2123
+ });
2124
+ var PLAN_ARGUMENT = "the plan's id, or its root issue's identifier (e.g. FR-1)";
2125
+ plan.command("show").description("a plan's status, budgets, revision, steps, edges and wait conditions").argument("[plan]", PLAN_ARGUMENT).option("--json", "emit JSON").action(async (id, opts) => {
1930
2126
  await withConfig(opts.json === true, false, (ctx) => {
1931
- if (id === void 0) throw new UsageError("usage: harness plan show <plan-id>");
2127
+ if (id === void 0) throw new UsageError("usage: harness plan show <plan-id-or-root>");
1932
2128
  return planShow(ctx, id);
1933
2129
  });
1934
2130
  });
1935
- plan.command("edit").description("change step executors and keys, and budgets, from a JSON or YAML file").argument("[id]").option("--file <path>", 'the edit: .json is JSON, anything else YAML ("-" for stdin)').option("--json", "emit JSON").action(async (id, opts) => {
2131
+ plan.command("edit").description("change step executors and keys, and budgets, from a JSON or YAML file").argument("[plan]", PLAN_ARGUMENT).option("--file <path>", 'the edit: .json is JSON, anything else YAML ("-" for stdin)').option("--json", "emit JSON").action(async (id, opts) => {
1936
2132
  await withConfig(opts.json === true, true, (ctx) => {
1937
2133
  if (id === void 0 || opts.file === void 0) {
1938
- throw new UsageError("usage: harness plan edit <plan-id> --file <edits.json|edits.yaml>");
2134
+ throw new UsageError("usage: harness plan edit <plan-id-or-root> --file <edits.json|edits.yaml>");
1939
2135
  }
1940
- requirePlanId(id);
2136
+ requirePlanRef(id);
1941
2137
  return planEdit(ctx, id, readPlanFile(opts.file));
1942
2138
  });
1943
2139
  });
1944
- plan.command("release").description("release a plan as yourself: freeze its next revision and let its ready steps run").argument("[id]").option("--collaborator <who>", "a person of this organization (id or email) for the plan room (repeatable)", collect, []).option("--retry-budget <n>", "retries per failed Operator step", integer).option("--spend-budget-cents <n>", "the plan's spend budget; needed to release Operator steps", integer).option("--json", "emit JSON").action(
2140
+ plan.command("release").description("release a plan as yourself: freeze its next revision and let its ready steps run").argument("[plan]", PLAN_ARGUMENT).option("--collaborator <who>", "a person of this organization (id or email) for the plan room (repeatable)", collect, []).option("--retry-budget <n>", "retries per failed Operator step", integer).option("--spend-budget-cents <n>", "the plan's spend budget; needed to release Operator steps", integer).option("--json", "emit JSON").action(
1945
2141
  async (id, opts) => {
1946
2142
  await withConfig(opts.json === true, true, (ctx) => {
1947
- if (id === void 0) throw new UsageError("usage: harness plan release <plan-id>");
2143
+ if (id === void 0) throw new UsageError("usage: harness plan release <plan-id-or-root>");
1948
2144
  return planRelease(ctx, id, { ...opts, collaborators: opts.collaborator });
1949
2145
  });
1950
2146
  }
1951
2147
  );
1952
- plan.command("hold").description("pause every step of a plan until it is released again").argument("[id]").option("--json", "emit JSON").action(async (id, opts) => {
2148
+ plan.command("hold").description("pause every step of a plan until it is released again").argument("[plan]", PLAN_ARGUMENT).option("--json", "emit JSON").action(async (id, opts) => {
1953
2149
  await withConfig(opts.json === true, true, (ctx) => {
1954
- if (id === void 0) throw new UsageError("usage: harness plan hold <plan-id>");
2150
+ if (id === void 0) throw new UsageError("usage: harness plan hold <plan-id-or-root>");
1955
2151
  return planHold(ctx, id);
1956
2152
  });
1957
2153
  });
@@ -2347,15 +2543,15 @@ link.command("rm").argument("[issue]").argument("[id-or-url]").option("--json",
2347
2543
  });
2348
2544
  }
2349
2545
  );
2350
- var label = program.command("label").description("labels");
2351
- label.command("list").option("--json", "emit JSON").action(async (opts) => {
2546
+ var label2 = program.command("label").description("labels");
2547
+ label2.command("list").option("--json", "emit JSON").action(async (opts) => {
2352
2548
  await withConfig(
2353
2549
  opts.json === true,
2354
2550
  false,
2355
2551
  (ctx) => labelList(ctx)
2356
2552
  );
2357
2553
  });
2358
- label.command("create").argument("[name]").option("--color <color>", "label color").option("--json", "emit JSON").action(
2554
+ label2.command("create").argument("[name]").option("--color <color>", "label color").option("--json", "emit JSON").action(
2359
2555
  async (name, opts) => {
2360
2556
  await withConfig(
2361
2557
  opts.json === true,
@@ -2364,10 +2560,10 @@ label.command("create").argument("[name]").option("--color <color>", "label colo
2364
2560
  );
2365
2561
  }
2366
2562
  );
2367
- label.command("update").description("rename or recolor a label").argument("[id]").option("--name <name>", "new name").option("--color <color>", "swatch name (red, blue, \u2026) or a hex code").option("--json", "emit JSON").action(async (id, opts) => {
2563
+ label2.command("update").description("rename or recolor a label").argument("[id]").option("--name <name>", "new name").option("--color <color>", "swatch name (red, blue, \u2026) or a hex code").option("--json", "emit JSON").action(async (id, opts) => {
2368
2564
  await withConfig(opts.json === true, true, (ctx) => labelUpdate(ctx, id, opts));
2369
2565
  });
2370
- label.command("delete").description("delete a label (refused while issues carry it)").argument("[id]").option("--yes", "confirm the deletion").option("--json", "emit JSON").action(async (id, opts) => {
2566
+ label2.command("delete").description("delete a label (refused while issues carry it)").argument("[id]").option("--yes", "confirm the deletion").option("--json", "emit JSON").action(async (id, opts) => {
2371
2567
  await withConfig(opts.json === true, true, (ctx) => labelDelete(ctx, id, opts));
2372
2568
  });
2373
2569
  var team = program.command("team").description("teams");
@@ -2478,7 +2674,7 @@ program.command("skills").description("the organization's skills assigned to Ope
2478
2674
  await withConfig(opts.json === true, false, (ctx) => skillsList(ctx));
2479
2675
  });
2480
2676
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
2481
- const { startMcpServer } = await import("./server-KVRQBVXI.js");
2677
+ const { startMcpServer } = await import("./server-F6YRKNC2.js");
2482
2678
  await startMcpServer(invokedAs);
2483
2679
  });
2484
2680
  var keys = program.command("keys").description("the org's provider API keys, handed to tooling without printing them").enablePositionalOptions();
@@ -2500,6 +2696,17 @@ keys.command("exec").description(
2500
2696
  (ctx) => exec(ctx, { command, override: opts.override === true, only })
2501
2697
  );
2502
2698
  });
2699
+ var review = program.command("review").description("a pull request's enforced Review Council gate, and a retry of its latest attempt");
2700
+ review.command("status").description("the gate's enforced and advisory status, and a failed latest attempt with what comes next").argument("<pr>", "owner/repo#<pr>, the pull request URL, or <pr> with --repo or this checkout's origin").option("--repo <owner/repo>", "the repository a bare pull request number belongs to").option("--json", "emit JSON").action(async (pr, opts) => {
2701
+ const ref = parsePullRequest(pr, opts.repo);
2702
+ await withConfig(opts.json === true, false, (ctx) => status(ctx, ref));
2703
+ });
2704
+ review.command("retry").description(
2705
+ "retry the latest gate attempt of the pull request's current head (needs reviews:write); the head, check and attempt are read from the gate status"
2706
+ ).argument("<pr>", "owner/repo#<pr>, the pull request URL, or <pr> with --repo or this checkout's origin").option("--repo <owner/repo>", "the repository a bare pull request number belongs to").option("--reason <text>", "why the attempt is retried, recorded with it").option("--json", "emit JSON").action(async (pr, opts) => {
2707
+ const ref = parsePullRequest(pr, opts.repo);
2708
+ await withConfig(opts.json === true, true, (ctx) => retry(ctx, ref, opts.reason));
2709
+ });
2503
2710
  program.command("login").description("sign in via the browser (infra.one session)").option("--url <url>", "host to sign in to").option("--no-browser", "print the URL and prompt for a pasted token instead").option(
2504
2711
  "--timeout <ms>",
2505
2712
  "how long to wait for the browser callback",
@@ -2510,20 +2717,36 @@ program.command("login").description("sign in via the browser (infra.one session
2510
2717
  for (const warning of repoLookup.warnings)
2511
2718
  writeStderr(warning + "\n");
2512
2719
  const repoUrl = repoLookup.config?.url;
2513
- const url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? repoUrl ?? storedLoginUrl() ?? DEFAULT_BASE_URL;
2720
+ const url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? repoUrl ?? (repoLookup.config?.org === void 0 ? storedLoginUrl() : void 0) ?? DEFAULT_BASE_URL;
2514
2721
  if (url === repoUrl && opts.url === void 0 && blankToUndefined(process.env["HARNESS_API_URL"]) === void 0) {
2515
2722
  writeStderr(
2516
2723
  `harness: host ${url} comes from ${repoLookup.config?.path}
2517
2724
  `
2518
2725
  );
2519
2726
  }
2727
+ const repoSelected = opts.url === void 0 && blankToUndefined(process.env["HARNESS_API_URL"]) === void 0 && repoUrl !== void 0;
2728
+ if (repoSelected && !isHostTrusted(url)) {
2729
+ if (!canConfirmRepoLogin()) {
2730
+ writeStderr("harness: cannot confirm a repo-selected host without a terminal \u2014 pass --url explicitly\n");
2731
+ return exitWith(2);
2732
+ }
2733
+ if (!await confirmRepoLogin(url, "repo-config", repoLookup.config?.path)) return exitWith(2);
2734
+ }
2520
2735
  const options = { noBrowser: !opts.browser };
2736
+ if (repoLookup.config?.org !== void 0) options.org = repoLookup.config.org;
2521
2737
  if (opts.timeout !== void 0) options.timeoutMs = opts.timeout;
2522
2738
  await finishAuth(await login(url, options), opts.json === true);
2523
2739
  }
2524
2740
  );
2525
2741
  program.command("logout").description("revoke and forget the stored login").option("--json", "emit JSON").action(async (opts) => {
2526
- await finishAuth(await logout(), opts.json === true);
2742
+ const selected = resolveConfig({ ...process.env, HARNESS_API_TOKEN: void 0 });
2743
+ if (selected.kind === "error") {
2744
+ writeStderr(`harness: ${selected.message}
2745
+ `);
2746
+ return exitWith(2);
2747
+ }
2748
+ const path = selected.kind === "ok" ? selected.config.credentialsPath : selected.credentialsPath;
2749
+ await finishAuth(await logout(path === void 0 ? {} : { credentialsPath: path }), opts.json === true);
2527
2750
  });
2528
2751
  program.command("whoami").description("show the resolved host and credential source").option("--json", "emit JSON").action(async (opts) => {
2529
2752
  const resolved = resolveConfig();
@@ -2536,6 +2759,8 @@ program.command("whoami").description("show the resolved host and credential sou
2536
2759
  }
2537
2760
  if (resolved.kind === "needs-login") {
2538
2761
  writeStderr(loginHelp(resolved.baseUrl));
2762
+ if (resolved.org !== void 0) writeStderr(`Choose organization ${resolved.org} when logging in.
2763
+ `);
2539
2764
  return exitWith(2);
2540
2765
  }
2541
2766
  const source = blankToUndefined(process.env["HARNESS_API_TOKEN"]) !== void 0 ? "environment" : "stored login";