@tokenoftrust/cli 1.4.0-rc.9 → 1.4.1

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
- import { repoNameFromRemote } from "./submit.mjs";
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,73 @@ 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
+ /**
46
+ * The storefront-owned, shareable `/preview/<tenant>/pr/<N>` link — NEVER the
47
+ * forge/Gitea `url` (2026-08-18 incident: a raw forge PR URL reached an
48
+ * owner). `candidate_status` (the local-checkout MCP tool) has no
49
+ * `previewUrl` field at all, unlike the operator `GET /api/changes` path — so
50
+ * this constructs it the same way `runPrListOperator`'s caller resolves
51
+ * `storefrontUrl`, from the same env/--url override chain. Pure.
52
+ * @param {string} storefrontUrl
53
+ * @param {string} tenant
54
+ * @param {number|null|undefined} prNumber
55
+ * @returns {string|null}
56
+ */
57
+ export function buildPreviewUrl(storefrontUrl, tenant, prNumber) {
58
+ if (typeof prNumber !== "number" || !tenant) return null;
59
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
60
+ return `${base}/preview/${tenant}/pr/${prNumber}`;
61
+ }
62
+
63
+ const USAGE = `tot pr — see and manage candidate PRs
35
64
 
36
65
  tot pr [list] list your open candidate PRs for this store
66
+ tot pr list --tenant <t> OPERATOR: list a tenant's open candidate PRs (no checkout)
37
67
  tot pr view <N|id> show one candidate PR (by PR number or changeId)
38
68
  tot pr close <N|id> close (reject) a candidate PR without merging
39
69
  tot pr close <N|id> --reason "<why>" record why it was closed (audit note)
40
70
  tot pr --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
41
71
 
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.`;
72
+ DEVELOPER (default) run inside your OWN store checkout: lists/manages the
73
+ candidates \`tot submit\` opens. A re-submit updates your open one by default;
74
+ \`tot submit --new\` forks another.
75
+
76
+ OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
77
+ queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
78
+ It reads \`GET /api/changes\` with an operator secret:
79
+
80
+ tot pr list --tenant <t> list <t>'s open candidate PRs
81
+ tot pr list --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
82
+ tot pr list --secret <s> operator secret (prefer the env vars below)
83
+
84
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
85
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
45
86
 
46
- /** Parse `tot pr` argv into { sub, target, reason, mcp, identity, help }. Pure. */
87
+ /** Parse `tot pr` argv into { sub, target, reason, mcp, identity, tenant, url, secret, help }. Pure. */
47
88
  export function parsePrArgs(argv) {
48
- const a = { sub: null, target: null, reason: null, mcp: null, identity: null, help: false };
89
+ const a = {
90
+ sub: null,
91
+ target: null,
92
+ reason: null,
93
+ mcp: null,
94
+ identity: null,
95
+ tenant: null,
96
+ url: null,
97
+ secret: null,
98
+ help: false,
99
+ };
49
100
  const positional = [];
50
101
  for (let i = 0; i < argv.length; i++) {
51
102
  const t = argv[i];
52
103
  if (t === "--mcp") a.mcp = argv[++i];
53
104
  else if (t === "--identity") a.identity = argv[++i];
54
105
  else if (t === "--reason") a.reason = argv[++i];
106
+ else if (t === "--tenant") a.tenant = argv[++i];
107
+ else if (t === "--url") a.url = argv[++i];
108
+ else if (t === "--secret") a.secret = argv[++i];
55
109
  else if (t === "--help" || t === "-h") a.help = true;
56
110
  else positional.push(t);
57
111
  }
@@ -79,11 +133,150 @@ export function matchCandidate(candidates, target) {
79
133
  return candidates.find((c) => c.changeId === target) ?? null;
80
134
  }
81
135
 
82
- /** One-line candidate summary for `tot pr list`. */
83
- function line(c) {
136
+ /**
137
+ * One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
138
+ * URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
139
+ * branch-bound candidates) and where its preview lives. ONLY `previewUrl` (the
140
+ * storefront-owned `/preview/<tenant>/pr/<N>` link) — NEVER `url` (the forge/
141
+ * Gitea `html_url`), which must never reach a terminal (2026-08-18 incident:
142
+ * a raw forge PR URL reached an owner). `active` marks the one
143
+ * THIS checkout's branch resolves to. Pure — unit-tested.
144
+ * @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
145
+ * previewUrl?:string|null, url?:string|null}} c
146
+ * @param {{ active?: boolean }} [opts]
147
+ */
148
+ export function formatCandidateLine(c, { active = false } = {}) {
149
+ const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
150
+ const branch = c.branch ? c.branch : "(no branch)";
151
+ const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
152
+ const activePart = active ? " ← active" : "";
153
+ return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
154
+ }
155
+
156
+ // ─── Operator queue listing (unit U17) ──────────────────────────────────────────────
157
+ //
158
+ // The read-only companion to `tot ship --pr <N> --tenant <t>` (U16): list a tenant's
159
+ // OPEN candidate queue WITHOUT a checkout. It reuses U16's exact transport — the SAME
160
+ // `GET /api/changes` endpoint (operator accept queue) + Bearer operator-secret auth
161
+ // (`resolveOperatorSecret` / `normalizeChangesQueue` from ship.mjs) — so a listing and
162
+ // a ship read one wire. NO merge/deploy/side-effect; pure listing.
163
+
164
+ /**
165
+ * One-line summary of an OPEN operator-queue candidate for `tot pr list --tenant`.
166
+ * Two shapes come off `GET /api/changes`:
167
+ * - BUILT candidate (`built:true`, has a `changeId`): PR # / status / changeId /
168
+ * short head sha / preview URL — the fields an operator needs to pick a PR to ship.
169
+ * - NOT-BUILT forge PR (`built:false` / `changeId:null` / `status:"not-built"`): an
170
+ * open PR with no preview yet. Render it DISTINCTLY — no changeId (there is none),
171
+ * a `[not built]` tag, and a build HINT instead of a preview URL — so an operator
172
+ * sees it's listable but must `tot preview build` before it can ship.
173
+ * Adapts `formatCandidateLine`'s house style to the `GET /api/changes` shape (`status`
174
+ * not `state`; `previewUrl`; a `headSha` to short-render). Pure — unit-tested.
175
+ * @param {{prNumber?:number|null, status?:string|null, changeId?:string|null,
176
+ * headSha?:string|null, previewUrl?:string|null, built?:boolean}} c
177
+ * @returns {string}
178
+ */
179
+ export function formatOperatorCandidateLine(c) {
84
180
  const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
85
- const url = c.url ? ` ${c.url}` : "";
86
- return ` PR ${pr} ${c.changeId} [${c.state ?? "?"}]${url}`;
181
+ const head = c.headSha ? c.headSha.slice(0, 8) : "(no head)";
182
+ // A not-built PR: no changeId, so route the operator to build it first.
183
+ const notBuilt = c.built === false || (!c.changeId && c.status === "not-built");
184
+ if (notBuilt) {
185
+ const hint =
186
+ typeof c.prNumber === "number"
187
+ ? ` → tot preview build --pr ${c.prNumber}`
188
+ : " → tot preview build";
189
+ return ` PR ${pr} [not built] ${head}${hint}`;
190
+ }
191
+ const status = c.status ?? "?";
192
+ const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
193
+ return ` PR ${pr} [${status}] ${c.changeId} ${head}${urlPart}`;
194
+ }
195
+
196
+ /**
197
+ * Sort an OPEN candidate queue by PR number DESC (newest PR first); candidates
198
+ * without a PR number sort last, then stably by changeId. Pure — unit-tested.
199
+ * @param {ReturnType<typeof normalizeChangesQueue>} changes
200
+ */
201
+ export function sortQueueByPrDesc(changes) {
202
+ return [...(changes || [])].sort((a, b) => {
203
+ const ap = typeof a.prNumber === "number" ? a.prNumber : -Infinity;
204
+ const bp = typeof b.prNumber === "number" ? b.prNumber : -Infinity;
205
+ if (ap !== bp) return bp - ap;
206
+ return String(a.changeId).localeCompare(String(b.changeId));
207
+ });
208
+ }
209
+
210
+ /**
211
+ * The OPERATOR `tot pr list --tenant <t>` flow: fetch the tenant's OPEN candidate
212
+ * queue over `GET /api/changes` (Bearer operator secret + `X-Tot-Owner` +
213
+ * `x-tot-capability: ship-on-behalf`, mirroring U16) and print each candidate one per
214
+ * line, sorted by PR number desc. Fail-closed: no secret → honest refusal (exit 2)
215
+ * BEFORE any network. `fetch` is injected so it's unit-tested with no live network.
216
+ *
217
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null }} params
218
+ * @param {{ fetch?:typeof fetch }} [deps]
219
+ * @returns {Promise<number>} process exit code
220
+ */
221
+ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }, deps = {}) {
222
+ const fetchImpl = deps.fetch || globalThis.fetch;
223
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
224
+
225
+ if (!secret) {
226
+ console.error(
227
+ fail(
228
+ "listing a tenant's queue is an OPERATOR action — it needs an operator secret",
229
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
230
+ ),
231
+ );
232
+ return 2;
233
+ }
234
+
235
+ const authHeaders = {
236
+ authorization: `Bearer ${secret}`,
237
+ "x-tot-owner": tenant,
238
+ "x-tot-capability": "ship-on-behalf",
239
+ };
240
+
241
+ let res;
242
+ try {
243
+ res = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
244
+ } catch (e) {
245
+ console.error(
246
+ fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
247
+ );
248
+ return 1;
249
+ }
250
+
251
+ let data = {};
252
+ try {
253
+ data = await res.json();
254
+ } catch {
255
+ /* non-JSON / empty body */
256
+ }
257
+ if (!res.ok) {
258
+ const msg = data?.error || `HTTP ${res.status}`;
259
+ console.error(
260
+ fail(
261
+ `the candidate queue refused the request: ${msg}`,
262
+ res.status === 401 || res.status === 403
263
+ ? "check the operator secret and that it's authorised for this tenant"
264
+ : "check --tenant / --url, then re-run",
265
+ ),
266
+ );
267
+ return 1;
268
+ }
269
+
270
+ const changes = sortQueueByPrDesc(normalizeChangesQueue(data));
271
+ if (!changes.length) {
272
+ console.log(`No open candidate PRs for ${tenant}.`);
273
+ return 0;
274
+ }
275
+ console.log(`Open PRs for ${tenant}:`);
276
+ for (const c of changes) {
277
+ console.log(formatOperatorCandidateLine(c));
278
+ }
279
+ return 0;
87
280
  }
88
281
 
89
282
  /** @param {string[]} argv @param {any} ctx */
@@ -98,9 +291,34 @@ export async function run(argv, ctx) {
98
291
  console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
99
292
  return 2;
100
293
  }
294
+
295
+ // OPERATOR MODE (U17): `--tenant <t>` lists that tenant's OPEN candidate queue with
296
+ // NO checkout, over the same HTTP transport `tot ship --pr` uses. Only `list` has an
297
+ // operator path today — `view`/`close` stay developer-only (checkout-bound).
298
+ if (args.tenant && `${args.tenant}`.trim()) {
299
+ if (args.sub !== "list") {
300
+ console.error(
301
+ fail(
302
+ `\`tot pr ${args.sub} --tenant\` isn't wired — only \`tot pr list --tenant\` has an operator path`,
303
+ "use `tot pr list --tenant <t>` to list, then act from a checkout",
304
+ ),
305
+ );
306
+ return 2;
307
+ }
308
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
309
+ return await runPrListOperator({
310
+ tenant: `${args.tenant}`.trim(),
311
+ secret: resolveOperatorSecret(args.secret, env),
312
+ storefrontUrl,
313
+ });
314
+ }
315
+
101
316
  if (ctx.mode !== "checkout") {
102
317
  console.error(
103
- fail("`tot pr` runs from inside a tenant checkout", "tot clone <tenant> <dir> (then `cd` in and re-run)"),
318
+ fail(
319
+ "`tot pr` runs from inside a tenant checkout",
320
+ "tot clone <tenant> <dir> (then `cd` in and re-run), or `tot pr list --tenant <t>` for operator mode",
321
+ ),
104
322
  );
105
323
  return 2;
106
324
  }
@@ -127,8 +345,11 @@ export async function run(argv, ctx) {
127
345
  }
128
346
 
129
347
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
348
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
130
349
  const statePath = defaultCandidateStatePath(env);
131
- const scope = { mcpUrl: baseUrl, repo };
350
+ // Branch-bound (u4): the active-pointer namespace is scoped to the current git
351
+ // branch, so the "← active" marker reflects THIS branch's candidate.
352
+ const scope = { mcpUrl: baseUrl, repo, branch: currentBranch(gitSafe) };
132
353
  const client = createMcpClient(baseUrl);
133
354
  try {
134
355
  const session = await establishSession(client, { env, prefer: args.identity || undefined });
@@ -146,7 +367,8 @@ export async function run(argv, ctx) {
146
367
  const active = readActiveChangeId(statePath, scope);
147
368
  console.log(`Open candidate PRs for ${repo}:`);
148
369
  for (const c of candidates) {
149
- console.log(line(c) + (active && c.changeId === active ? " ← active" : ""));
370
+ const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
371
+ console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
150
372
  }
151
373
  return 0;
152
374
  }
@@ -165,7 +387,9 @@ export async function run(argv, ctx) {
165
387
  if (match.headSha) console.log(` head: ${match.headSha}`);
166
388
  if (match.baseSha) console.log(` base: ${match.baseSha}`);
167
389
  console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
168
- if (match.url) console.log(` ${match.url}`);
390
+ // ONLY the storefront-owned preview link -- never the raw forge/Gitea `url`.
391
+ const previewUrl = match.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, match.prNumber);
392
+ if (previewUrl) console.log(` ${previewUrl}`);
169
393
  return 0;
170
394
  }
171
395
 
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `tot preview build --tenant <t> --pr <N>` — OPERATOR build-on-demand (unit U3).
3
+ *
4
+ * Materialize ANY PR's hosted preview (including one that isn't yours, and one that
5
+ * was orphaned — its webhook never processed so it has no ReviewEnvironment). It
6
+ * calls the session-authenticated storefront endpoint `POST /api/preview/build`
7
+ * (unit U1), which runs the EXISTING `reconcileCandidate` against the PR head. The
8
+ * CLI passes the KNOWN descriptor (the head sha it holds), so there is NO forge
9
+ * PR-read dependency: reconcile reads the tenant files at that sha (content-addressed).
10
+ *
11
+ * This is distinct from `tot preview` (the developer's push-your-checkout-to-preview
12
+ * flow, which talks MCP). `tot preview build` is an OPERATOR verb over a specific
13
+ * `--tenant`/`--pr`, gated server-side on owner / ship-on-behalf privilege.
14
+ *
15
+ * AUTH — the storefront endpoint accepts the headless Bearer-operator-secret path
16
+ * (`resolveOwnerSession` fallback). This verb sends `Authorization: Bearer <secret>`
17
+ * (from `PREVIEW_RECONCILE_SECRET` / `GRANTS_ADMIN_SECRET` / `TOT_OPERATOR_SECRET`,
18
+ * or `--secret`), `X-Tot-Owner: <tenant>`, and `X-Tot-Capability: ship-on-behalf` —
19
+ * mirroring the `/admin` AdminPublishTab accept call.
20
+ *
21
+ * SELF-DECLARING — it prints the EXACT plan (which PR → which tenant → the preview
22
+ * URL) and confirms before acting (`--yes` to skip; a non-TTY without `--yes`
23
+ * aborts rather than acting silently). Build-on-demand is INERT — it flips no shared
24
+ * channel and is not go-live — so this is a materialize, not a deploy. The plan
25
+ * itself is built by the SHARED plan module (`../plan.mjs`, unit U10) — the same
26
+ * affordance every other mutating operator verb (accept/ship/retire) and the
27
+ * `/admin` confirm dialog use, per decision `operator-verb-and-hosting-model`.
28
+ *
29
+ * Dependency-free (global fetch + `git` for the optional head-sha default).
30
+ */
31
+ import { execFileSync } from "node:child_process";
32
+ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
33
+
34
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
35
+
36
+ /** Parse `tot preview build` argv. Pure — unit-testable. */
37
+ export function parseBuildArgs(argv) {
38
+ const a = {
39
+ tenant: null,
40
+ pr: null,
41
+ headSha: null,
42
+ baseSha: null,
43
+ changeId: null,
44
+ url: null,
45
+ secret: null,
46
+ yes: false,
47
+ help: false,
48
+ };
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const t = argv[i];
51
+ if (t === "--tenant") a.tenant = argv[++i];
52
+ else if (t === "--pr") a.pr = argv[++i];
53
+ else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
54
+ else if (t === "--base-sha" || t === "--base") a.baseSha = argv[++i];
55
+ else if (t === "--change-id") a.changeId = argv[++i];
56
+ else if (t === "--url") a.url = argv[++i];
57
+ else if (t === "--secret") a.secret = argv[++i];
58
+ else if (t === "--yes" || t === "-y") a.yes = true;
59
+ else if (t === "--help" || t === "-h") a.help = true;
60
+ }
61
+ return a;
62
+ }
63
+
64
+ export function renderUsage() {
65
+ return `tot preview build — operator build-on-demand: materialize a PR's hosted preview
66
+
67
+ Usage:
68
+ tot preview build --tenant <appDomain> --pr <N> [--head-sha <sha>] [options]
69
+
70
+ Materializes (or rebuilds) the candidate preview for PR #N of <tenant> by running
71
+ the server-side reconcile. Recovers an orphaned PR (one with no built preview yet).
72
+ It performs NO merge, NO go-live, and flips NO shared channel.
73
+
74
+ Options:
75
+ --tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
76
+ current checkout's tenant when run inside one.
77
+ --pr <N> PR number to build.
78
+ --head-sha <sha> PR head commit to materialize. Defaults to \`git rev-parse
79
+ HEAD\` when run inside a git checkout.
80
+ --base-sha <sha> Optional merge-base (projection metadata only).
81
+ --change-id <id> Optional explicit candidate id (defaults to pr-<N>).
82
+ --url <origin> Storefront origin. Defaults to $TOT_STOREFRONT_URL or
83
+ ${DEFAULT_STOREFRONT_URL}.
84
+ --secret <s> Operator secret. Prefer the env vars below.
85
+ --yes, -y Skip the confirmation prompt.
86
+ --help, -h Show this help.
87
+
88
+ Auth (operator secret, from env, first found):
89
+ PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET`;
90
+ }
91
+
92
+ /** Best-effort local HEAD sha (only when invoked inside a git checkout). */
93
+ function gitHeadSha() {
94
+ try {
95
+ return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim() || null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @param {string[]} argv
103
+ * @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
104
+ */
105
+ export async function run(argv, ctx) {
106
+ const args = parseBuildArgs(argv);
107
+ if (args.help) {
108
+ console.log(renderUsage());
109
+ return 0;
110
+ }
111
+
112
+ const base = (args.url || process.env.TOT_STOREFRONT_URL || process.env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL)
113
+ .trim()
114
+ .replace(/\/+$/, "");
115
+
116
+ const tenant = (args.tenant || ctx?.tenant || "").trim();
117
+ if (!tenant) {
118
+ console.error(
119
+ "✗ no target tenant.\n\n → next: pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), " +
120
+ "or run inside a store checkout.",
121
+ );
122
+ return 2;
123
+ }
124
+
125
+ const prRaw = args.pr;
126
+ const pr = prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
127
+ if (pr == null && !args.changeId) {
128
+ console.error("✗ no PR to build.\n\n → next: pass --pr <N> (the PR number to materialize).");
129
+ return 2;
130
+ }
131
+
132
+ const headSha = (args.headSha || gitHeadSha() || "").trim();
133
+ if (!headSha) {
134
+ console.error(
135
+ "✗ no head sha.\n\n → next: pass --head-sha <sha> (the PR head commit to build), " +
136
+ "or run inside a checkout of that commit.",
137
+ );
138
+ return 2;
139
+ }
140
+
141
+ const secret = (args.secret || process.env.PREVIEW_RECONCILE_SECRET || process.env.GRANTS_ADMIN_SECRET || process.env.TOT_OPERATOR_SECRET || "").trim();
142
+ if (!secret) {
143
+ console.error(
144
+ "✗ no operator secret.\n\n → next: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / " +
145
+ "TOT_OPERATOR_SECRET) in the environment.",
146
+ );
147
+ return 2;
148
+ }
149
+
150
+ const previewUrl = pr != null ? `${base}/preview/${encodeURIComponent(tenant)}/pr/${pr}` : null;
151
+
152
+ // Self-declaring: state the EXACT plan before acting (the shared plan module).
153
+ const planLines = planForAction({
154
+ action: "build",
155
+ tenant,
156
+ pr,
157
+ changeId: args.changeId,
158
+ headSha,
159
+ endpoint: `${base}/api/preview/build`,
160
+ targets: { preview: previewUrl },
161
+ });
162
+ const { confirmed, reason } = await printPlanAndConfirm(planLines, {
163
+ yes: args.yes,
164
+ question: "Build this preview now?",
165
+ });
166
+ if (!confirmed) {
167
+ if (reason === "non-tty") {
168
+ console.error("✗ refusing to build without confirmation on a non-TTY.\n\n → next: re-run with --yes.");
169
+ return 1;
170
+ }
171
+ console.log("Aborted — nothing was built.");
172
+ return 1;
173
+ }
174
+
175
+ const body = {
176
+ ...(pr != null ? { pr } : {}),
177
+ ...(args.changeId ? { changeId: args.changeId } : {}),
178
+ headSha,
179
+ ...(args.baseSha ? { baseSha: args.baseSha } : {}),
180
+ };
181
+
182
+ let res;
183
+ try {
184
+ res = await fetch(`${base}/api/preview/build`, {
185
+ method: "POST",
186
+ headers: {
187
+ "content-type": "application/json",
188
+ authorization: `Bearer ${secret}`,
189
+ "x-tot-owner": tenant,
190
+ "x-tot-capability": "ship-on-behalf",
191
+ },
192
+ body: JSON.stringify(body),
193
+ });
194
+ } catch (e) {
195
+ console.error(`✗ could not reach ${base}: ${e?.message || e}\n\n → next: check --url / your network.`);
196
+ return 1;
197
+ }
198
+
199
+ let data = {};
200
+ try {
201
+ data = await res.json();
202
+ } catch {
203
+ /* non-JSON error body */
204
+ }
205
+
206
+ const resolvedUrl = data?.previewUrl || data?.environment?.previewUrl || previewUrl || null;
207
+
208
+ if (res.ok && data?.ok) {
209
+ const status = data?.environment?.status || "ready";
210
+ console.log(`✓ Preview built (status: ${status}).`);
211
+ if (resolvedUrl) console.log(`\n ${resolvedUrl}\n`);
212
+ return 0;
213
+ }
214
+
215
+ // Blocked (422) or an auth/other failure.
216
+ const errors = Array.isArray(data?.errors) && data.errors.length
217
+ ? data.errors
218
+ : [data?.error || `HTTP ${res.status}`];
219
+ console.error(`✗ Build did not succeed (HTTP ${res.status}).`);
220
+ for (const e of errors) console.error(` • ${e}`);
221
+ if (resolvedUrl && res.status === 422) {
222
+ console.error(`\n The PR page will show the reason: ${resolvedUrl}`);
223
+ }
224
+ return 1;
225
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `tot preview` — submit your store for PREVIEW (the deliberate, gated step).
3
+ *
4
+ * This is the first-class verb for the dev → preview → ship loop:
5
+ *
6
+ * tot dev run your store locally with save→reload
7
+ * tot preview push it to a reviewable preview (validate → reconcile → compliance) ← you are here
8
+ * tot ship promote a reconciled preview live ← unit u3
9
+ *
10
+ * The whole preview flow (validate, push the preview ref, open/update the PR-backed
11
+ * candidate, stream the reconcile/compliance/preview result) lives in submit.mjs — this
12
+ * module is a thin wrapper that runs that SAME flow and, on success, teaches the next
13
+ * verb (`tot ship`). Keeping the flow in one place means `tot preview` and its teaching
14
+ * aliases (`tot submit` / `tot deploy`, below) share exactly one implementation — every
15
+ * submit flag works identically through preview, with no chance of drift.
16
+ *
17
+ * TEACHING ALIASES: `tot submit` and `tot deploy` are the same preview flow. They run it
18
+ * unchanged and then print a one-line hint teaching the rename (and `tot ship`), so a
19
+ * developer who reaches for the old verb is nudged onto the new model without losing a
20
+ * step. Pass `{ alias }` (the verb the developer typed) to get that hint.
21
+ *
22
+ * Dependency-free (delegates to submit.mjs, which uses global fetch + `git`).
23
+ */
24
+ import { run as runPreviewFlow, parseArgs, renderUsage } from "./submit.mjs";
25
+
26
+ /** The ship hint printed after a successful `tot preview` — teaches the next verb.
27
+ * Pure — unit-tested. */
28
+ export const shipHint = () =>
29
+ "\n → next: once this preview reconciles cleanly, `tot ship` promotes it live.";
30
+
31
+ /** The teaching hint printed when the preview flow is reached via an old verb
32
+ * (`tot submit` / `tot deploy`). Names the rename AND the ship verb in one line.
33
+ * Pure — unit-tested. */
34
+ export const aliasHint = (alias) =>
35
+ `\n ℹ \`tot ${alias}\` is now \`tot preview\` — and \`tot ship\` promotes it live.`;
36
+
37
+ /** The hint to print after the flow returns, or null when there's nothing to teach
38
+ * (the flow failed). An alias gets the rename+ship hint; the first-class verb gets
39
+ * the ship hint; a non-zero exit teaches nothing (the push didn't land). Pure. */
40
+ export function postRunHint(code, alias) {
41
+ if (code !== 0) return null;
42
+ return alias ? aliasHint(alias) : shipHint();
43
+ }
44
+
45
+ /**
46
+ * @param {string[]} argv
47
+ * @param {any} ctx
48
+ * @param {{ alias?: string|null }} [opts] — `alias` is the old verb the developer typed
49
+ * (`"submit"` / `"deploy"`) when this flow is reached as a teaching alias; null/omitted
50
+ * for the first-class `tot preview`.
51
+ */
52
+ export async function run(argv, ctx, { alias = null } = {}) {
53
+ // `tot preview build …` is the OPERATOR build-on-demand subcommand (unit U3) —
54
+ // a distinct verb from the developer preview flow, so it's dispatched BEFORE the
55
+ // submit-flow arg parse. Only the first-class `tot preview` carries it (not the
56
+ // `submit`/`deploy` teaching aliases, which are the push-your-checkout flow).
57
+ if (!alias && argv[0] === "build") {
58
+ const { run: runBuild } = await import("./preview-build.mjs");
59
+ return runBuild(argv.slice(1), ctx);
60
+ }
61
+
62
+ const verb = alias || "preview";
63
+ const args = parseArgs(argv);
64
+
65
+ // Handle --help here so we can brand the usage with the typed verb and, for an
66
+ // alias, teach the rename — without also printing the post-success ship hint.
67
+ if (args.help) {
68
+ console.log(renderUsage(verb));
69
+ if (alias) console.log(aliasHint(alias));
70
+ return 0;
71
+ }
72
+
73
+ const code = await runPreviewFlow(argv, ctx, { verb });
74
+
75
+ // Teach the next step only on a successful preview (the push landed). An alias
76
+ // gets the rename+ship hint; the first-class verb just gets the ship hint.
77
+ const hint = postRunHint(code, alias);
78
+ if (hint) console.log(hint);
79
+ return code;
80
+ }