@tokenoftrust/cli 1.4.0-rc.13 → 1.4.0-rc.15

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.
@@ -16,12 +16,22 @@
16
16
  * checkout's origin remote, exactly as `tot submit` does) and reads over the MCP
17
17
  * `candidate_status` / `candidate_close` tools. Dependency-free (global fetch +
18
18
  * `git`).
19
+ *
20
+ * OPERATOR MODE (unit U17). `tot pr list --tenant <t>` lists a tenant's OPEN
21
+ * candidate queue WITHOUT a checkout — the read-only companion to `tot ship --pr <N>
22
+ * --tenant <t>` (U16). It reuses U16's exact transport: the SAME `GET /api/changes`
23
+ * HTTP endpoint + operator-secret Bearer auth (`resolveOperatorSecret`,
24
+ * `normalizeChangesQueue` from ship.mjs). `view`/`close` stay developer-only.
19
25
  */
20
26
  import { execFileSync } from "node:child_process";
21
27
  import { createMcpClient } from "../mcp.mjs";
22
28
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
23
29
  import { fail } from "../errors.mjs";
24
30
  import { repoNameFromRemote, currentBranch } from "./submit.mjs";
31
+ // U17 reuses U16's operator-secret transport helpers verbatim (same env precedence,
32
+ // same queue normalisation) so the operator `tot pr list` and `tot ship --pr` speak
33
+ // one wire, not two.
34
+ import { resolveOperatorSecret, normalizeChangesQueue } from "./ship.mjs";
25
35
  import {
26
36
  defaultCandidateStatePath,
27
37
  readActiveChangeId,
@@ -29,29 +39,55 @@ import {
29
39
  } from "../candidate-state.mjs";
30
40
 
31
41
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
42
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
32
43
  const SUBCOMMANDS = ["list", "view", "close"];
33
44
 
34
- const USAGE = `tot pr — see and manage your candidate PRs
45
+ const USAGE = `tot pr — see and manage candidate PRs
35
46
 
36
47
  tot pr [list] list your open candidate PRs for this store
48
+ tot pr list --tenant <t> OPERATOR: list a tenant's open candidate PRs (no checkout)
37
49
  tot pr view <N|id> show one candidate PR (by PR number or changeId)
38
50
  tot pr close <N|id> close (reject) a candidate PR without merging
39
51
  tot pr close <N|id> --reason "<why>" record why it was closed (audit note)
40
52
  tot pr --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
41
53
 
42
- A candidate PR is what \`tot submit\` opens for review. A re-submit updates your
43
- open one by default; \`tot submit --new\` forks another. Use these to see and
44
- manage them.`;
54
+ DEVELOPER (default) run inside your OWN store checkout: lists/manages the
55
+ candidates \`tot submit\` opens. A re-submit updates your open one by default;
56
+ \`tot submit --new\` forks another.
57
+
58
+ OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
59
+ queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
60
+ It reads \`GET /api/changes\` with an operator secret:
61
+
62
+ tot pr list --tenant <t> list <t>'s open candidate PRs
63
+ tot pr list --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
64
+ tot pr list --secret <s> operator secret (prefer the env vars below)
65
+
66
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
67
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
45
68
 
46
- /** Parse `tot pr` argv into { sub, target, reason, mcp, identity, help }. Pure. */
69
+ /** Parse `tot pr` argv into { sub, target, reason, mcp, identity, tenant, url, secret, help }. Pure. */
47
70
  export function parsePrArgs(argv) {
48
- const a = { sub: null, target: null, reason: null, mcp: null, identity: null, help: false };
71
+ const a = {
72
+ sub: null,
73
+ target: null,
74
+ reason: null,
75
+ mcp: null,
76
+ identity: null,
77
+ tenant: null,
78
+ url: null,
79
+ secret: null,
80
+ help: false,
81
+ };
49
82
  const positional = [];
50
83
  for (let i = 0; i < argv.length; i++) {
51
84
  const t = argv[i];
52
85
  if (t === "--mcp") a.mcp = argv[++i];
53
86
  else if (t === "--identity") a.identity = argv[++i];
54
87
  else if (t === "--reason") a.reason = argv[++i];
88
+ else if (t === "--tenant") a.tenant = argv[++i];
89
+ else if (t === "--url") a.url = argv[++i];
90
+ else if (t === "--secret") a.secret = argv[++i];
55
91
  else if (t === "--help" || t === "-h") a.help = true;
56
92
  else positional.push(t);
57
93
  }
@@ -98,6 +134,132 @@ export function formatCandidateLine(c, { active = false } = {}) {
98
134
  return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
99
135
  }
100
136
 
137
+ // ─── Operator queue listing (unit U17) ──────────────────────────────────────────────
138
+ //
139
+ // The read-only companion to `tot ship --pr <N> --tenant <t>` (U16): list a tenant's
140
+ // OPEN candidate queue WITHOUT a checkout. It reuses U16's exact transport — the SAME
141
+ // `GET /api/changes` endpoint (operator accept queue) + Bearer operator-secret auth
142
+ // (`resolveOperatorSecret` / `normalizeChangesQueue` from ship.mjs) — so a listing and
143
+ // a ship read one wire. NO merge/deploy/side-effect; pure listing.
144
+
145
+ /**
146
+ * One-line summary of an OPEN operator-queue candidate for `tot pr list --tenant`.
147
+ * Two shapes come off `GET /api/changes`:
148
+ * - BUILT candidate (`built:true`, has a `changeId`): PR # / status / changeId /
149
+ * short head sha / preview URL — the fields an operator needs to pick a PR to ship.
150
+ * - NOT-BUILT forge PR (`built:false` / `changeId:null` / `status:"not-built"`): an
151
+ * open PR with no preview yet. Render it DISTINCTLY — no changeId (there is none),
152
+ * a `[not built]` tag, and a build HINT instead of a preview URL — so an operator
153
+ * sees it's listable but must `tot preview build` before it can ship.
154
+ * Adapts `formatCandidateLine`'s house style to the `GET /api/changes` shape (`status`
155
+ * not `state`; `previewUrl`; a `headSha` to short-render). Pure — unit-tested.
156
+ * @param {{prNumber?:number|null, status?:string|null, changeId?:string|null,
157
+ * headSha?:string|null, previewUrl?:string|null, built?:boolean}} c
158
+ * @returns {string}
159
+ */
160
+ export function formatOperatorCandidateLine(c) {
161
+ const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
162
+ const head = c.headSha ? c.headSha.slice(0, 8) : "(no head)";
163
+ // A not-built PR: no changeId, so route the operator to build it first.
164
+ const notBuilt = c.built === false || (!c.changeId && c.status === "not-built");
165
+ if (notBuilt) {
166
+ const hint =
167
+ typeof c.prNumber === "number"
168
+ ? ` → tot preview build --pr ${c.prNumber}`
169
+ : " → tot preview build";
170
+ return ` PR ${pr} [not built] ${head}${hint}`;
171
+ }
172
+ const status = c.status ?? "?";
173
+ const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
174
+ return ` PR ${pr} [${status}] ${c.changeId} ${head}${urlPart}`;
175
+ }
176
+
177
+ /**
178
+ * Sort an OPEN candidate queue by PR number DESC (newest PR first); candidates
179
+ * without a PR number sort last, then stably by changeId. Pure — unit-tested.
180
+ * @param {ReturnType<typeof normalizeChangesQueue>} changes
181
+ */
182
+ export function sortQueueByPrDesc(changes) {
183
+ return [...(changes || [])].sort((a, b) => {
184
+ const ap = typeof a.prNumber === "number" ? a.prNumber : -Infinity;
185
+ const bp = typeof b.prNumber === "number" ? b.prNumber : -Infinity;
186
+ if (ap !== bp) return bp - ap;
187
+ return String(a.changeId).localeCompare(String(b.changeId));
188
+ });
189
+ }
190
+
191
+ /**
192
+ * The OPERATOR `tot pr list --tenant <t>` flow: fetch the tenant's OPEN candidate
193
+ * queue over `GET /api/changes` (Bearer operator secret + `X-Tot-Owner` +
194
+ * `x-tot-capability: ship-on-behalf`, mirroring U16) and print each candidate one per
195
+ * line, sorted by PR number desc. Fail-closed: no secret → honest refusal (exit 2)
196
+ * BEFORE any network. `fetch` is injected so it's unit-tested with no live network.
197
+ *
198
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null }} params
199
+ * @param {{ fetch?:typeof fetch }} [deps]
200
+ * @returns {Promise<number>} process exit code
201
+ */
202
+ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }, deps = {}) {
203
+ const fetchImpl = deps.fetch || globalThis.fetch;
204
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
205
+
206
+ if (!secret) {
207
+ console.error(
208
+ fail(
209
+ "listing a tenant's queue is an OPERATOR action — it needs an operator secret",
210
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
211
+ ),
212
+ );
213
+ return 2;
214
+ }
215
+
216
+ const authHeaders = {
217
+ authorization: `Bearer ${secret}`,
218
+ "x-tot-owner": tenant,
219
+ "x-tot-capability": "ship-on-behalf",
220
+ };
221
+
222
+ let res;
223
+ try {
224
+ res = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
225
+ } catch (e) {
226
+ console.error(
227
+ fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
228
+ );
229
+ return 1;
230
+ }
231
+
232
+ let data = {};
233
+ try {
234
+ data = await res.json();
235
+ } catch {
236
+ /* non-JSON / empty body */
237
+ }
238
+ if (!res.ok) {
239
+ const msg = data?.error || `HTTP ${res.status}`;
240
+ console.error(
241
+ fail(
242
+ `the candidate queue refused the request: ${msg}`,
243
+ res.status === 401 || res.status === 403
244
+ ? "check the operator secret and that it's authorised for this tenant"
245
+ : "check --tenant / --url, then re-run",
246
+ ),
247
+ );
248
+ return 1;
249
+ }
250
+
251
+ const changes = sortQueueByPrDesc(normalizeChangesQueue(data));
252
+ if (!changes.length) {
253
+ console.log(`No open candidate PRs for ${tenant}.`);
254
+ return 0;
255
+ }
256
+ console.log(`Open PRs for ${tenant}:`);
257
+ for (const c of changes) {
258
+ console.log(formatOperatorCandidateLine(c));
259
+ }
260
+ return 0;
261
+ }
262
+
101
263
  /** @param {string[]} argv @param {any} ctx */
102
264
  export async function run(argv, ctx) {
103
265
  const env = process.env;
@@ -110,9 +272,34 @@ export async function run(argv, ctx) {
110
272
  console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
111
273
  return 2;
112
274
  }
275
+
276
+ // OPERATOR MODE (U17): `--tenant <t>` lists that tenant's OPEN candidate queue with
277
+ // NO checkout, over the same HTTP transport `tot ship --pr` uses. Only `list` has an
278
+ // operator path today — `view`/`close` stay developer-only (checkout-bound).
279
+ if (args.tenant && `${args.tenant}`.trim()) {
280
+ if (args.sub !== "list") {
281
+ console.error(
282
+ fail(
283
+ `\`tot pr ${args.sub} --tenant\` isn't wired — only \`tot pr list --tenant\` has an operator path`,
284
+ "use `tot pr list --tenant <t>` to list, then act from a checkout",
285
+ ),
286
+ );
287
+ return 2;
288
+ }
289
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
290
+ return await runPrListOperator({
291
+ tenant: `${args.tenant}`.trim(),
292
+ secret: resolveOperatorSecret(args.secret, env),
293
+ storefrontUrl,
294
+ });
295
+ }
296
+
113
297
  if (ctx.mode !== "checkout") {
114
298
  console.error(
115
- fail("`tot pr` runs from inside a tenant checkout", "tot clone <tenant> <dir> (then `cd` in and re-run)"),
299
+ fail(
300
+ "`tot pr` runs from inside a tenant checkout",
301
+ "tot clone <tenant> <dir> (then `cd` in and re-run), or `tot pr list --tenant <t>` for operator mode",
302
+ ),
116
303
  );
117
304
  return 2;
118
305
  }