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

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/commands/pr.mjs +180 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.13",
3
+ "version": "1.4.0-rc.14",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -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,118 @@ 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
+ * Surfaces PR # (or `#—`), the review-environment status, the changeId, the short
148
+ * head sha, and the preview URL — the fields an operator needs to pick a PR to ship.
149
+ * Adapts `formatCandidateLine`'s house style to the `GET /api/changes` shape (`status`
150
+ * not `state`; `previewUrl`; a `headSha` to short-render). Pure — unit-tested.
151
+ * @param {{prNumber?:number|null, status?:string|null, changeId:string,
152
+ * headSha?:string|null, previewUrl?:string|null}} c
153
+ * @returns {string}
154
+ */
155
+ export function formatOperatorCandidateLine(c) {
156
+ const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
157
+ const status = c.status ?? "?";
158
+ const head = c.headSha ? c.headSha.slice(0, 8) : "(no head)";
159
+ const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
160
+ return ` PR ${pr} [${status}] ${c.changeId} ${head}${urlPart}`;
161
+ }
162
+
163
+ /**
164
+ * Sort an OPEN candidate queue by PR number DESC (newest PR first); candidates
165
+ * without a PR number sort last, then stably by changeId. Pure — unit-tested.
166
+ * @param {ReturnType<typeof normalizeChangesQueue>} changes
167
+ */
168
+ export function sortQueueByPrDesc(changes) {
169
+ return [...(changes || [])].sort((a, b) => {
170
+ const ap = typeof a.prNumber === "number" ? a.prNumber : -Infinity;
171
+ const bp = typeof b.prNumber === "number" ? b.prNumber : -Infinity;
172
+ if (ap !== bp) return bp - ap;
173
+ return String(a.changeId).localeCompare(String(b.changeId));
174
+ });
175
+ }
176
+
177
+ /**
178
+ * The OPERATOR `tot pr list --tenant <t>` flow: fetch the tenant's OPEN candidate
179
+ * queue over `GET /api/changes` (Bearer operator secret + `X-Tot-Owner` +
180
+ * `x-tot-capability: ship-on-behalf`, mirroring U16) and print each candidate one per
181
+ * line, sorted by PR number desc. Fail-closed: no secret → honest refusal (exit 2)
182
+ * BEFORE any network. `fetch` is injected so it's unit-tested with no live network.
183
+ *
184
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null }} params
185
+ * @param {{ fetch?:typeof fetch }} [deps]
186
+ * @returns {Promise<number>} process exit code
187
+ */
188
+ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }, deps = {}) {
189
+ const fetchImpl = deps.fetch || globalThis.fetch;
190
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
191
+
192
+ if (!secret) {
193
+ console.error(
194
+ fail(
195
+ "listing a tenant's queue is an OPERATOR action — it needs an operator secret",
196
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
197
+ ),
198
+ );
199
+ return 2;
200
+ }
201
+
202
+ const authHeaders = {
203
+ authorization: `Bearer ${secret}`,
204
+ "x-tot-owner": tenant,
205
+ "x-tot-capability": "ship-on-behalf",
206
+ };
207
+
208
+ let res;
209
+ try {
210
+ res = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
211
+ } catch (e) {
212
+ console.error(
213
+ fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
214
+ );
215
+ return 1;
216
+ }
217
+
218
+ let data = {};
219
+ try {
220
+ data = await res.json();
221
+ } catch {
222
+ /* non-JSON / empty body */
223
+ }
224
+ if (!res.ok) {
225
+ const msg = data?.error || `HTTP ${res.status}`;
226
+ console.error(
227
+ fail(
228
+ `the candidate queue refused the request: ${msg}`,
229
+ res.status === 401 || res.status === 403
230
+ ? "check the operator secret and that it's authorised for this tenant"
231
+ : "check --tenant / --url, then re-run",
232
+ ),
233
+ );
234
+ return 1;
235
+ }
236
+
237
+ const changes = sortQueueByPrDesc(normalizeChangesQueue(data));
238
+ if (!changes.length) {
239
+ console.log(`No open candidate PRs for ${tenant}.`);
240
+ return 0;
241
+ }
242
+ console.log(`Open PRs for ${tenant}:`);
243
+ for (const c of changes) {
244
+ console.log(formatOperatorCandidateLine(c));
245
+ }
246
+ return 0;
247
+ }
248
+
101
249
  /** @param {string[]} argv @param {any} ctx */
102
250
  export async function run(argv, ctx) {
103
251
  const env = process.env;
@@ -110,9 +258,34 @@ export async function run(argv, ctx) {
110
258
  console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
111
259
  return 2;
112
260
  }
261
+
262
+ // OPERATOR MODE (U17): `--tenant <t>` lists that tenant's OPEN candidate queue with
263
+ // NO checkout, over the same HTTP transport `tot ship --pr` uses. Only `list` has an
264
+ // operator path today — `view`/`close` stay developer-only (checkout-bound).
265
+ if (args.tenant && `${args.tenant}`.trim()) {
266
+ if (args.sub !== "list") {
267
+ console.error(
268
+ fail(
269
+ `\`tot pr ${args.sub} --tenant\` isn't wired — only \`tot pr list --tenant\` has an operator path`,
270
+ "use `tot pr list --tenant <t>` to list, then act from a checkout",
271
+ ),
272
+ );
273
+ return 2;
274
+ }
275
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
276
+ return await runPrListOperator({
277
+ tenant: `${args.tenant}`.trim(),
278
+ secret: resolveOperatorSecret(args.secret, env),
279
+ storefrontUrl,
280
+ });
281
+ }
282
+
113
283
  if (ctx.mode !== "checkout") {
114
284
  console.error(
115
- fail("`tot pr` runs from inside a tenant checkout", "tot clone <tenant> <dir> (then `cd` in and re-run)"),
285
+ fail(
286
+ "`tot pr` runs from inside a tenant checkout",
287
+ "tot clone <tenant> <dir> (then `cd` in and re-run), or `tot pr list --tenant <t>` for operator mode",
288
+ ),
116
289
  );
117
290
  return 2;
118
291
  }