@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.
- package/README.md +7 -4
- package/bin/tot.mjs +169 -8
- package/package.json +1 -1
- package/src/activity.mjs +378 -0
- package/src/candidate-state.mjs +56 -16
- package/src/commands/accept.mjs +725 -0
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +289 -10
- package/src/commands/dev.mjs +479 -118
- package/src/commands/doctor.mjs +2 -1
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/pr.mjs +239 -15
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +40 -8
- package/src/commands/submit.mjs +1325 -146
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +6 -1
- package/src/git-credential.mjs +184 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +27 -1
- package/src/validate.mjs +52 -0
package/src/commands/pr.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 = {
|
|
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
|
-
/**
|
|
83
|
-
|
|
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
|
|
86
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|