@tokenoftrust/cli 1.4.0 → 1.5.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/README.md +5 -0
- package/bin/tot.mjs +148 -57
- package/package.json +6 -1
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +4 -4
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +3 -3
- package/src/commands/accept.mjs +498 -59
- package/src/commands/app/dev.mjs +8 -4
- package/src/commands/app/index.mjs +3 -3
- package/src/commands/app/scaffold.mjs +1 -1
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +264 -0
- package/src/commands/clone.mjs +307 -25
- package/src/commands/dev.mjs +440 -156
- package/src/commands/doctor.mjs +4 -4
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +9 -5
- package/src/commands/grants.mjs +7 -5
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/ideas.mjs +2 -2
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +5 -6
- package/src/commands/pr.mjs +62 -25
- package/src/commands/preview-build.mjs +6 -6
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview-retry-evidence.mjs +156 -0
- package/src/commands/preview.mjs +19 -3
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +18 -16
- package/src/commands/ship.mjs +51 -14
- package/src/commands/start.mjs +101 -59
- package/src/commands/submit.mjs +1183 -169
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +10 -4
- package/src/commands/whoami.mjs +1 -1
- package/src/dev-heartbeat.mjs +3 -2
- package/src/dev-logs.mjs +2 -2
- package/src/errors.mjs +11 -4
- package/src/git-credential.mjs +257 -0
- package/src/last-tenant.mjs +1 -1
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +18 -14
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +83 -15
- package/src/sample.mjs +4 -4
- package/src/validate.mjs +187 -15
- package/src/vendor/private-apps-devkit.mjs +3 -3
- package/src/viewer-session.mjs +118 -0
- package/template/private-app/README.md +12 -6
- package/src/commands/retire.mjs +0 -203
package/src/commands/pr.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* tot pr close <N|id> close (reject) a candidate without merging
|
|
8
8
|
*
|
|
9
9
|
* A candidate PR is the reviewable unit `tot submit` creates. By default a
|
|
10
|
-
* re-submit UPDATES your open candidate; `tot submit --
|
|
10
|
+
* re-submit UPDATES your open candidate; `tot submit --fork-candidate` forks another. This
|
|
11
11
|
* command fills the gap the raw submit loop left — a first-party way to list your
|
|
12
12
|
* open candidates and to close one (candidate close was otherwise gated to
|
|
13
13
|
* version-control apps only).
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* `candidate_status` / `candidate_close` tools. Dependency-free (global fetch +
|
|
18
18
|
* `git`).
|
|
19
19
|
*
|
|
20
|
-
* OPERATOR MODE
|
|
20
|
+
* OPERATOR MODE. `tot pr list --tenant <t>` lists a tenant's OPEN
|
|
21
21
|
* candidate queue WITHOUT a checkout — the read-only companion to `tot ship --pr <N>
|
|
22
|
-
* --tenant <t
|
|
22
|
+
* --tenant <t>`. It reuses that command's exact transport: the SAME `GET /api/changes`
|
|
23
23
|
* HTTP endpoint + operator-secret Bearer auth (`resolveOperatorSecret`,
|
|
24
24
|
* `normalizeChangesQueue` from ship.mjs). `view`/`close` stay developer-only.
|
|
25
25
|
*/
|
|
@@ -28,9 +28,8 @@ import { createMcpClient } from "../mcp.mjs";
|
|
|
28
28
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
29
29
|
import { fail } from "../errors.mjs";
|
|
30
30
|
import { repoNameFromRemote, currentBranch } from "./submit.mjs";
|
|
31
|
-
//
|
|
32
|
-
// same queue normalisation) so the
|
|
33
|
-
// one wire, not two.
|
|
31
|
+
// `tot pr list` reuses `tot ship --pr`'s operator-secret transport helpers verbatim
|
|
32
|
+
// (same env precedence, same queue normalisation) so the two speak one wire, not two.
|
|
34
33
|
import { resolveOperatorSecret, normalizeChangesQueue } from "./ship.mjs";
|
|
35
34
|
import {
|
|
36
35
|
defaultCandidateStatePath,
|
|
@@ -42,6 +41,24 @@ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
|
42
41
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
43
42
|
const SUBCOMMANDS = ["list", "view", "close"];
|
|
44
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The storefront-owned, shareable `/preview/<tenant>/pr/<N>` link — NEVER the
|
|
46
|
+
* forge/Gitea `url` (2026-08-18 incident: a raw forge PR URL reached an
|
|
47
|
+
* owner). `candidate_status` (the local-checkout MCP tool) has no
|
|
48
|
+
* `previewUrl` field at all, unlike the operator `GET /api/changes` path — so
|
|
49
|
+
* this constructs it the same way `runPrListOperator`'s caller resolves
|
|
50
|
+
* `storefrontUrl`, from the same env/--url override chain. Pure.
|
|
51
|
+
* @param {string} storefrontUrl
|
|
52
|
+
* @param {string} tenant
|
|
53
|
+
* @param {number|null|undefined} prNumber
|
|
54
|
+
* @returns {string|null}
|
|
55
|
+
*/
|
|
56
|
+
export function buildPreviewUrl(storefrontUrl, tenant, prNumber) {
|
|
57
|
+
if (typeof prNumber !== "number" || !tenant) return null;
|
|
58
|
+
const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
59
|
+
return `${base}/preview/${tenant}/pr/${prNumber}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
const USAGE = `tot pr — see and manage candidate PRs
|
|
46
63
|
|
|
47
64
|
tot pr [list] list your open candidate PRs for this store
|
|
@@ -53,7 +70,7 @@ const USAGE = `tot pr — see and manage candidate PRs
|
|
|
53
70
|
|
|
54
71
|
DEVELOPER (default) — run inside your OWN store checkout: lists/manages the
|
|
55
72
|
candidates \`tot submit\` opens. A re-submit updates your open one by default;
|
|
56
|
-
\`tot submit --
|
|
73
|
+
\`tot submit --fork-candidate\` forks another.
|
|
57
74
|
|
|
58
75
|
OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
|
|
59
76
|
queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
|
|
@@ -117,10 +134,12 @@ export function matchCandidate(candidates, target) {
|
|
|
117
134
|
|
|
118
135
|
/**
|
|
119
136
|
* One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
|
|
120
|
-
* URL so a dev sees, at a glance, which git branch each candidate belongs to
|
|
121
|
-
* branch-bound candidates) and where its preview lives.
|
|
122
|
-
*
|
|
123
|
-
*
|
|
137
|
+
* URL so a dev sees, at a glance, which git branch each candidate belongs to
|
|
138
|
+
* (branch-bound candidates) and where its preview lives. ONLY `previewUrl` (the
|
|
139
|
+
* storefront-owned `/preview/<tenant>/pr/<N>` link) — NEVER `url` (the forge/
|
|
140
|
+
* Gitea `html_url`), which must never reach a terminal (2026-08-18 incident:
|
|
141
|
+
* a raw forge PR URL reached an owner). `active` marks the one
|
|
142
|
+
* THIS checkout's branch resolves to. Pure — unit-tested.
|
|
124
143
|
* @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
|
|
125
144
|
* previewUrl?:string|null, url?:string|null}} c
|
|
126
145
|
* @param {{ active?: boolean }} [opts]
|
|
@@ -128,16 +147,15 @@ export function matchCandidate(candidates, target) {
|
|
|
128
147
|
export function formatCandidateLine(c, { active = false } = {}) {
|
|
129
148
|
const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
|
|
130
149
|
const branch = c.branch ? c.branch : "(no branch)";
|
|
131
|
-
const
|
|
132
|
-
const urlPart = previewUrl ? ` ${previewUrl}` : "";
|
|
150
|
+
const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
|
|
133
151
|
const activePart = active ? " ← active" : "";
|
|
134
152
|
return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
|
|
135
153
|
}
|
|
136
154
|
|
|
137
|
-
// ─── Operator queue listing
|
|
155
|
+
// ─── Operator queue listing ──────────────────────────────────────────────────────────
|
|
138
156
|
//
|
|
139
|
-
// The read-only companion to `tot ship --pr <N> --tenant <t
|
|
140
|
-
// OPEN candidate queue WITHOUT a checkout. It reuses
|
|
157
|
+
// The read-only companion to `tot ship --pr <N> --tenant <t>`: list a tenant's
|
|
158
|
+
// OPEN candidate queue WITHOUT a checkout. It reuses that command's exact transport — the SAME
|
|
141
159
|
// `GET /api/changes` endpoint (operator accept queue) + Bearer operator-secret auth
|
|
142
160
|
// (`resolveOperatorSecret` / `normalizeChangesQueue` from ship.mjs) — so a listing and
|
|
143
161
|
// a ship read one wire. NO merge/deploy/side-effect; pure listing.
|
|
@@ -153,8 +171,15 @@ export function formatCandidateLine(c, { active = false } = {}) {
|
|
|
153
171
|
* sees it's listable but must `tot preview build` before it can ship.
|
|
154
172
|
* Adapts `formatCandidateLine`'s house style to the `GET /api/changes` shape (`status`
|
|
155
173
|
* not `state`; `previewUrl`; a `headSha` to short-render). Pure — unit-tested.
|
|
174
|
+
*
|
|
175
|
+
* A built row whose `evidence.pushFailed` is set (the MCP `change_report_evidence`
|
|
176
|
+
* push never landed — fb-1787938036559-ppmu4e) gets a `[evidence push failed]` tag
|
|
177
|
+
* + a `tot preview retry-evidence` hint, so the gap between "storefront thinks this
|
|
178
|
+
* is promotable" and "MCP has no evidence for it" is visible right where an
|
|
179
|
+
* operator is already looking, not just in the activity log.
|
|
156
180
|
* @param {{prNumber?:number|null, status?:string|null, changeId?:string|null,
|
|
157
|
-
* headSha?:string|null, previewUrl?:string|null, built?:boolean
|
|
181
|
+
* headSha?:string|null, previewUrl?:string|null, built?:boolean,
|
|
182
|
+
* evidence?:{pushFailed?:boolean}|null}} c
|
|
158
183
|
* @returns {string}
|
|
159
184
|
*/
|
|
160
185
|
export function formatOperatorCandidateLine(c) {
|
|
@@ -171,6 +196,13 @@ export function formatOperatorCandidateLine(c) {
|
|
|
171
196
|
}
|
|
172
197
|
const status = c.status ?? "?";
|
|
173
198
|
const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
|
|
199
|
+
if (c.evidence?.pushFailed) {
|
|
200
|
+
const retryHint =
|
|
201
|
+
typeof c.prNumber === "number"
|
|
202
|
+
? ` → tot preview retry-evidence --pr ${c.prNumber}`
|
|
203
|
+
: " → tot preview retry-evidence";
|
|
204
|
+
return ` PR ${pr} [${status}] [evidence push failed] ${c.changeId} ${head}${urlPart}${retryHint}`;
|
|
205
|
+
}
|
|
174
206
|
return ` PR ${pr} [${status}] ${c.changeId} ${head}${urlPart}`;
|
|
175
207
|
}
|
|
176
208
|
|
|
@@ -191,7 +223,7 @@ export function sortQueueByPrDesc(changes) {
|
|
|
191
223
|
/**
|
|
192
224
|
* The OPERATOR `tot pr list --tenant <t>` flow: fetch the tenant's OPEN candidate
|
|
193
225
|
* queue over `GET /api/changes` (Bearer operator secret + `X-Tot-Owner` +
|
|
194
|
-
* `x-tot-capability: ship-on-behalf
|
|
226
|
+
* `x-tot-capability: ship-on-behalf`) and print each candidate one per
|
|
195
227
|
* line, sorted by PR number desc. Fail-closed: no secret → honest refusal (exit 2)
|
|
196
228
|
* BEFORE any network. `fetch` is injected so it's unit-tested with no live network.
|
|
197
229
|
*
|
|
@@ -206,8 +238,9 @@ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }
|
|
|
206
238
|
if (!secret) {
|
|
207
239
|
console.error(
|
|
208
240
|
fail(
|
|
209
|
-
"
|
|
210
|
-
|
|
241
|
+
"no operator secret — view this tenant's queue from the storefront admin instead.",
|
|
242
|
+
`open https://${tenant}/admin → Publish tab to see the candidate queue and act on it. ` +
|
|
243
|
+
"Operators/CI: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret.",
|
|
211
244
|
),
|
|
212
245
|
);
|
|
213
246
|
return 2;
|
|
@@ -268,12 +301,12 @@ export async function run(argv, ctx) {
|
|
|
268
301
|
console.log(USAGE);
|
|
269
302
|
return 0;
|
|
270
303
|
}
|
|
271
|
-
if (!SUBCOMMANDS.includes(args.sub)) {
|
|
304
|
+
if (!SUBCOMMANDS.includes(/** @type {any} */ (args.sub))) {
|
|
272
305
|
console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
|
|
273
306
|
return 2;
|
|
274
307
|
}
|
|
275
308
|
|
|
276
|
-
// OPERATOR MODE
|
|
309
|
+
// OPERATOR MODE: `--tenant <t>` lists that tenant's OPEN candidate queue with
|
|
277
310
|
// NO checkout, over the same HTTP transport `tot ship --pr` uses. Only `list` has an
|
|
278
311
|
// operator path today — `view`/`close` stay developer-only (checkout-bound).
|
|
279
312
|
if (args.tenant && `${args.tenant}`.trim()) {
|
|
@@ -326,8 +359,9 @@ export async function run(argv, ctx) {
|
|
|
326
359
|
}
|
|
327
360
|
|
|
328
361
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
362
|
+
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
329
363
|
const statePath = defaultCandidateStatePath(env);
|
|
330
|
-
// Branch-bound
|
|
364
|
+
// Branch-bound: the active-pointer namespace is scoped to the current git
|
|
331
365
|
// branch, so the "← active" marker reflects THIS branch's candidate.
|
|
332
366
|
const scope = { mcpUrl: baseUrl, repo, branch: currentBranch(gitSafe) };
|
|
333
367
|
const client = createMcpClient(baseUrl);
|
|
@@ -347,7 +381,8 @@ export async function run(argv, ctx) {
|
|
|
347
381
|
const active = readActiveChangeId(statePath, scope);
|
|
348
382
|
console.log(`Open candidate PRs for ${repo}:`);
|
|
349
383
|
for (const c of candidates) {
|
|
350
|
-
|
|
384
|
+
const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
|
|
385
|
+
console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
|
|
351
386
|
}
|
|
352
387
|
return 0;
|
|
353
388
|
}
|
|
@@ -366,7 +401,9 @@ export async function run(argv, ctx) {
|
|
|
366
401
|
if (match.headSha) console.log(` head: ${match.headSha}`);
|
|
367
402
|
if (match.baseSha) console.log(` base: ${match.baseSha}`);
|
|
368
403
|
console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
|
|
369
|
-
|
|
404
|
+
// ONLY the storefront-owned preview link -- never the raw forge/Gitea `url`.
|
|
405
|
+
const previewUrl = match.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, match.prNumber);
|
|
406
|
+
if (previewUrl) console.log(` ${previewUrl}`);
|
|
370
407
|
return 0;
|
|
371
408
|
}
|
|
372
409
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `tot preview build --tenant <t> --pr <N>` — OPERATOR build-on-demand
|
|
2
|
+
* `tot preview build --tenant <t> --pr <N>` — OPERATOR build-on-demand.
|
|
3
3
|
*
|
|
4
4
|
* Materialize ANY PR's hosted preview (including one that isn't yours, and one that
|
|
5
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
|
-
*
|
|
6
|
+
* calls the session-authenticated storefront endpoint `POST /api/preview/build`,
|
|
7
|
+
* which runs the EXISTING `reconcileCandidate` against the PR head. The
|
|
8
8
|
* CLI passes the KNOWN descriptor (the head sha it holds), so there is NO forge
|
|
9
9
|
* PR-read dependency: reconcile reads the tenant files at that sha (content-addressed).
|
|
10
10
|
*
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
* URL) and confirms before acting (`--yes` to skip; a non-TTY without `--yes`
|
|
23
23
|
* aborts rather than acting silently). Build-on-demand is INERT — it flips no shared
|
|
24
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
|
|
26
|
-
* affordance every other mutating operator verb (accept/ship/
|
|
27
|
-
* `/admin` confirm dialog use
|
|
25
|
+
* itself is built by the SHARED plan module (`../plan.mjs`) — the same
|
|
26
|
+
* affordance every other mutating operator verb (accept/ship/reject) and the
|
|
27
|
+
* `/admin` confirm dialog use.
|
|
28
28
|
*
|
|
29
29
|
* Dependency-free (global fetch + `git` for the optional head-sha default).
|
|
30
30
|
*/
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot preview doctor --tenant <t>` — the merge self-serve DIAGNOSIS in the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Calls the hosted merge-doctor seam `GET /<tenant>/api/preview/merge-doctor`
|
|
5
|
+
* — which composes the SAME two server-side reads the admin Publish tab builds
|
|
6
|
+
* (`GET /api/changes` + `GET /api/changes/branches`) through the PURE `analyzeMerge`
|
|
7
|
+
* taxonomy — and prints its `{ scopeKnown, verdict, findings, counts }` as the compact,
|
|
8
|
+
* ordered (blocker → warn → info) report, each finding carrying who-owns-it and its
|
|
9
|
+
* remedy/action. It composes NO per-PR forge calls of its own: one GET, one answer.
|
|
10
|
+
*
|
|
11
|
+
* READ-ONLY — it mutates nothing, so there is NO plan/confirm gate (unlike `tot
|
|
12
|
+
* accept` / `tot preview build`). It just answers "what's blocking my merges, and
|
|
13
|
+
* what do I do about it?" in seconds.
|
|
14
|
+
*
|
|
15
|
+
* AUTH — the endpoint is session-gated exactly like `GET /api/changes`, so this
|
|
16
|
+
* reaches it by EITHER of the two transports `tot accept` already speaks:
|
|
17
|
+
* - OPERATOR SECRET (operators / headless CI): Bearer + `X-Tot-Owner` on the
|
|
18
|
+
* generic storefront origin (from PREVIEW_RECONCILE_SECRET / GRANTS_ADMIN_SECRET
|
|
19
|
+
* / TOT_OPERATOR_SECRET, or `--secret`).
|
|
20
|
+
* - VIEWER SESSION (an invited developer with no secret): mint a `tot_session`
|
|
21
|
+
* from their own `tot login` on the tenant's own host (`resolveViewerTransport`).
|
|
22
|
+
* A caller who cannot see the accept queue cannot get a diagnosis of it — an auth
|
|
23
|
+
* refusal (401/403) is relayed as a clean house-style failure, not a wrapped verdict.
|
|
24
|
+
*
|
|
25
|
+
* EXIT CODE — mirrors the `scripts/tenant/gitea-merge-doctor.mjs` driver: a report
|
|
26
|
+
* with any blocker exits 1 (so a CI gate fails on a blocked merge), otherwise 0.
|
|
27
|
+
*
|
|
28
|
+
* The rendering helpers are the SHARED port in `../merge-doctor-report.mjs` (a
|
|
29
|
+
* faithful PORT of the analyzer's own `formatReport`/`attentionBanner`/
|
|
30
|
+
* `supportContext` — mergeDoctor.ts §render / the gitea-merge-doctor.mjs mirror);
|
|
31
|
+
* this package is the dependency-free published `@tokenoftrust/cli`, so it cannot
|
|
32
|
+
* import the app/scripts source, and the port is kept in sync with that taxonomy by
|
|
33
|
+
* hand. They live in their own module so `tot accept` / `tot ship` can reuse the same
|
|
34
|
+
* fetch + compact render without a cycle back through this command's transports.
|
|
35
|
+
*
|
|
36
|
+
* Dependency-free (global fetch + the shared viewer/operator transports).
|
|
37
|
+
*/
|
|
38
|
+
import { fail } from "../errors.mjs";
|
|
39
|
+
import { resolveOperatorSecret } from "./ship.mjs";
|
|
40
|
+
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
41
|
+
import {
|
|
42
|
+
DOCTOR_PATH,
|
|
43
|
+
normalizeAnalysis,
|
|
44
|
+
attentionBanner,
|
|
45
|
+
supportContext,
|
|
46
|
+
formatReport,
|
|
47
|
+
} from "../merge-doctor-report.mjs";
|
|
48
|
+
|
|
49
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
50
|
+
|
|
51
|
+
const USAGE = `tot preview doctor — diagnose what's blocking this tenant's merges
|
|
52
|
+
|
|
53
|
+
tot preview doctor diagnose the current checkout's tenant
|
|
54
|
+
tot preview doctor --tenant <t> diagnose <t> (works without a checkout)
|
|
55
|
+
tot preview doctor --json emit the raw { scopeKnown, verdict, findings, counts }
|
|
56
|
+
|
|
57
|
+
Calls the hosted merge-doctor (GET /api/preview/merge-doctor), which composes the
|
|
58
|
+
SAME reads the admin Publish tab builds and runs them through the shared analyzer,
|
|
59
|
+
and prints the ordered report: blockers first, then warnings, then info — each
|
|
60
|
+
finding names who resolves it (you / on-us / housekeeping) and the exact remedy.
|
|
61
|
+
Read-only: it mutates nothing and needs no confirmation.
|
|
62
|
+
|
|
63
|
+
Exit code: 1 if the report has any blocker (so it can gate CI), else 0.
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
67
|
+
current checkout's tenant when run inside one.
|
|
68
|
+
--url <origin> Storefront origin for the operator-secret path
|
|
69
|
+
(default: env TOT_STOREFRONT_URL or ${DEFAULT_STOREFRONT_URL}).
|
|
70
|
+
--secret <s> Operator secret (prefer the env vars below).
|
|
71
|
+
--json Print the raw analysis JSON instead of the report.
|
|
72
|
+
--help, -h Show this help.
|
|
73
|
+
|
|
74
|
+
Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
|
|
75
|
+
GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret). Without one, an
|
|
76
|
+
invited developer's own \`tot login\` session is used.`;
|
|
77
|
+
|
|
78
|
+
/** Parse `tot preview doctor` argv. Pure — unit-testable. */
|
|
79
|
+
export function parsePreviewDoctorArgs(argv) {
|
|
80
|
+
const a = { tenant: null, url: null, secret: null, json: false, help: false };
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const t = argv[i];
|
|
83
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
84
|
+
else if (t === "--url") a.url = argv[++i];
|
|
85
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
86
|
+
else if (t === "--json") a.json = true;
|
|
87
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
88
|
+
}
|
|
89
|
+
return a;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The render/normalize helpers (normalizeAnalysis / attentionBanner /
|
|
93
|
+
// supportContext / formatReport) are the shared port in ../merge-doctor-report.mjs,
|
|
94
|
+
// imported at the top of this file.
|
|
95
|
+
|
|
96
|
+
// ── run ────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch + render the merge-doctor report for one tenant. Resolves the SAME two
|
|
100
|
+
* transports `tot accept` uses (operator secret → generic origin + X-Tot-Owner;
|
|
101
|
+
* else viewer session → tenant host + cookie), GETs the hosted seam, and prints the
|
|
102
|
+
* ordered report (or raw JSON). `fetch`/`resolveViewerTransport` are injected so it
|
|
103
|
+
* is unit-tested with no network. Returns the process exit code (blocker → 1).
|
|
104
|
+
*
|
|
105
|
+
* @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
|
|
106
|
+
* json?:boolean, env?:NodeJS.ProcessEnv }} params
|
|
107
|
+
* @param {{ fetch?:typeof fetch, resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
|
|
108
|
+
* @returns {Promise<number>}
|
|
109
|
+
*/
|
|
110
|
+
export async function runDoctor(
|
|
111
|
+
{ tenant, secret, storefrontUrl = null, json = false, env = process.env },
|
|
112
|
+
deps = {},
|
|
113
|
+
) {
|
|
114
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
115
|
+
const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
|
|
116
|
+
|
|
117
|
+
// Resolve the transport — operator secret preferred, else the developer's own
|
|
118
|
+
// viewer session on the tenant host.
|
|
119
|
+
let base;
|
|
120
|
+
let authHeaders;
|
|
121
|
+
if (secret) {
|
|
122
|
+
base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
123
|
+
authHeaders = {
|
|
124
|
+
authorization: `Bearer ${secret}`,
|
|
125
|
+
"x-tot-owner": tenant,
|
|
126
|
+
"x-tot-capability": "ship-on-behalf",
|
|
127
|
+
};
|
|
128
|
+
} else {
|
|
129
|
+
const viewer = await resolveViewer({ tenant, env, fetchImpl });
|
|
130
|
+
if (!viewer.ok) {
|
|
131
|
+
console.error(fail(viewer.message, viewer.hint));
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
base = viewer.base;
|
|
135
|
+
authHeaders = viewer.authHeaders;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let res;
|
|
139
|
+
try {
|
|
140
|
+
res = await fetchImpl(`${base}${DOCTOR_PATH}`, { method: "GET", headers: authHeaders });
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(
|
|
143
|
+
fail(
|
|
144
|
+
`couldn't reach the merge-doctor at ${base}: ${String(e?.message || e)}`,
|
|
145
|
+
"check --url / your network, then re-run",
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let data = {};
|
|
152
|
+
try {
|
|
153
|
+
data = await res.json();
|
|
154
|
+
} catch {
|
|
155
|
+
/* non-JSON / empty body */
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Session gating: the endpoint relays the queue read's auth refusal verbatim, so a
|
|
159
|
+
// caller who cannot see the accept queue cannot diagnose it.
|
|
160
|
+
if (res.status === 401 || res.status === 403) {
|
|
161
|
+
console.error(
|
|
162
|
+
fail(
|
|
163
|
+
`not authorized to diagnose ${tenant}'s merges: ${data?.error || `HTTP ${res.status}`}`,
|
|
164
|
+
"you need a live ship-on-behalf grant on this tenant (ask the store owner), or an operator secret authorised for it — then re-run",
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
return 2;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
console.error(
|
|
172
|
+
fail(
|
|
173
|
+
`the merge-doctor did not answer for ${tenant}: ${data?.error || `HTTP ${res.status}`}`,
|
|
174
|
+
"check --tenant / --url, then re-run",
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const analysis = normalizeAnalysis(data);
|
|
181
|
+
|
|
182
|
+
if (json) {
|
|
183
|
+
console.log(JSON.stringify(analysis, null, 2));
|
|
184
|
+
} else {
|
|
185
|
+
console.log(formatReport(analysis));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Mirror the scripts driver: any blocker fails the run so a CI gate catches it.
|
|
189
|
+
return analysis.counts.blocker > 0 ? 1 : 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {string[]} argv
|
|
194
|
+
* @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
|
|
195
|
+
*/
|
|
196
|
+
export async function run(argv, ctx) {
|
|
197
|
+
const env = process.env;
|
|
198
|
+
const args = parsePreviewDoctorArgs(argv);
|
|
199
|
+
if (args.help) {
|
|
200
|
+
console.log(USAGE);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const tenant = (args.tenant || ctx?.tenant || "").trim();
|
|
205
|
+
if (!tenant) {
|
|
206
|
+
console.error(
|
|
207
|
+
fail(
|
|
208
|
+
"no target tenant.",
|
|
209
|
+
"pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const storefrontUrl =
|
|
216
|
+
args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
217
|
+
|
|
218
|
+
return await runDoctor({
|
|
219
|
+
tenant,
|
|
220
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
221
|
+
storefrontUrl,
|
|
222
|
+
json: args.json,
|
|
223
|
+
env,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot preview retry-evidence --tenant <t> --pr <N>` — OPERATOR recovery verb.
|
|
3
|
+
*
|
|
4
|
+
* Re-push evidence for an ALREADY-BUILT candidate whose `change_report_evidence`
|
|
5
|
+
* push to MCP failed (surfaced as `evidence.pushFailed` on the candidate — see
|
|
6
|
+
* `formatOperatorCandidateLine`'s `[evidence push failed]` tag in `tot pr list
|
|
7
|
+
* --tenant`). Calls the session-authenticated `POST
|
|
8
|
+
* /api/changes/candidate-evidence-retry`, which recomputes + re-pushes evidence
|
|
9
|
+
* for the candidate's EXISTING version — no rebuild, no new content, safe to
|
|
10
|
+
* run repeatedly.
|
|
11
|
+
*
|
|
12
|
+
* DISTINCT FROM `tot preview build`: build re-runs the full reconcile against a
|
|
13
|
+
* head sha (for an orphaned/never-built PR). This targets an ALREADY-ready
|
|
14
|
+
* candidate whose content is fine but whose evidence never reached MCP — a
|
|
15
|
+
* narrower, cheaper fix for fb-1787938036559-ppmu4e.
|
|
16
|
+
*
|
|
17
|
+
* AUTH — same headless Bearer-operator-secret path as `tot preview build`:
|
|
18
|
+
* `Authorization: Bearer <secret>`, `X-Tot-Owner: <tenant>`,
|
|
19
|
+
* `X-Tot-Capability: ship-on-behalf`.
|
|
20
|
+
*
|
|
21
|
+
* Non-destructive + idempotent — no confirmation prompt.
|
|
22
|
+
*
|
|
23
|
+
* Dependency-free (global fetch).
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
26
|
+
|
|
27
|
+
/** Parse `tot preview retry-evidence` argv. Pure — unit-testable. */
|
|
28
|
+
export function parseRetryEvidenceArgs(argv) {
|
|
29
|
+
const a = { tenant: null, pr: null, changeId: null, url: null, secret: null, help: false };
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const t = argv[i];
|
|
32
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
33
|
+
else if (t === "--pr") a.pr = argv[++i];
|
|
34
|
+
else if (t === "--change-id") a.changeId = argv[++i];
|
|
35
|
+
else if (t === "--url") a.url = argv[++i];
|
|
36
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
37
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
38
|
+
}
|
|
39
|
+
return a;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderUsage() {
|
|
43
|
+
return `tot preview retry-evidence — operator recovery: re-push a candidate's evidence to MCP
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
tot preview retry-evidence --tenant <appDomain> --pr <N> [options]
|
|
47
|
+
tot preview retry-evidence --tenant <appDomain> --change-id <id> [options]
|
|
48
|
+
|
|
49
|
+
Re-computes and re-pushes evidence for an ALREADY-BUILT candidate whose MCP
|
|
50
|
+
push failed (shown as "[evidence push failed]" in \`tot pr list --tenant\`).
|
|
51
|
+
Does NOT rebuild or change content — safe to run repeatedly.
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
55
|
+
current checkout's tenant when run inside one.
|
|
56
|
+
--pr <N> PR number to retry evidence for.
|
|
57
|
+
--change-id <id> Optional explicit candidate id (defaults to pr-<N>).
|
|
58
|
+
--url <origin> Storefront origin. Defaults to $TOT_STOREFRONT_URL or
|
|
59
|
+
${DEFAULT_STOREFRONT_URL}.
|
|
60
|
+
--secret <s> Operator secret. Prefer the env vars below.
|
|
61
|
+
--help, -h Show this help.
|
|
62
|
+
|
|
63
|
+
Auth (operator secret, from env, first found):
|
|
64
|
+
PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string[]} argv
|
|
69
|
+
* @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
|
|
70
|
+
*/
|
|
71
|
+
export async function run(argv, ctx) {
|
|
72
|
+
const args = parseRetryEvidenceArgs(argv);
|
|
73
|
+
if (args.help) {
|
|
74
|
+
console.log(renderUsage());
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const base = (args.url || process.env.TOT_STOREFRONT_URL || process.env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL)
|
|
79
|
+
.trim()
|
|
80
|
+
.replace(/\/+$/, "");
|
|
81
|
+
|
|
82
|
+
const tenant = (args.tenant || ctx?.tenant || "").trim();
|
|
83
|
+
if (!tenant) {
|
|
84
|
+
console.error(
|
|
85
|
+
"✗ no target tenant.\n\n → next: pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), " +
|
|
86
|
+
"or run inside a store checkout.",
|
|
87
|
+
);
|
|
88
|
+
return 2;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const prRaw = args.pr;
|
|
92
|
+
const pr = prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
|
|
93
|
+
const changeId = (args.changeId || "").trim() || null;
|
|
94
|
+
if (pr == null && !changeId) {
|
|
95
|
+
console.error(
|
|
96
|
+
"✗ no candidate to retry evidence for.\n\n → next: pass --pr <N> (the PR number), or --change-id <id>.",
|
|
97
|
+
);
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const secret = (args.secret || process.env.PREVIEW_RECONCILE_SECRET || process.env.GRANTS_ADMIN_SECRET || process.env.TOT_OPERATOR_SECRET || "").trim();
|
|
102
|
+
if (!secret) {
|
|
103
|
+
console.error(
|
|
104
|
+
"✗ no operator secret.\n\n → next: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / " +
|
|
105
|
+
"TOT_OPERATOR_SECRET) in the environment.",
|
|
106
|
+
);
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const body = {
|
|
111
|
+
...(pr != null ? { pr } : {}),
|
|
112
|
+
...(changeId ? { changeId } : {}),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
let res;
|
|
116
|
+
try {
|
|
117
|
+
res = await fetch(`${base}/api/changes/candidate-evidence-retry`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
"content-type": "application/json",
|
|
121
|
+
authorization: `Bearer ${secret}`,
|
|
122
|
+
"x-tot-owner": tenant,
|
|
123
|
+
"x-tot-capability": "ship-on-behalf",
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify(body),
|
|
126
|
+
});
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error(`✗ could not reach ${base}: ${e?.message || e}\n\n → next: check --url / your network.`);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let data = {};
|
|
133
|
+
try {
|
|
134
|
+
data = await res.json();
|
|
135
|
+
} catch {
|
|
136
|
+
/* non-JSON error body */
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const label = pr != null ? `PR #${pr}` : changeId;
|
|
140
|
+
|
|
141
|
+
if (res.ok && data?.ok && data?.pushed) {
|
|
142
|
+
console.log(`✓ Evidence re-pushed for ${label} on ${tenant} (promotable: ${data.promotable}).`);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
if (res.ok && data?.ok && !data?.pushed) {
|
|
146
|
+
console.error(`✗ Evidence push still failed for ${label} on ${tenant}.`);
|
|
147
|
+
if (data.error) console.error(` • ${data.error}`);
|
|
148
|
+
console.error("\n → next: check MCP reachability, then re-run this command.");
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const message = data?.error || `HTTP ${res.status}`;
|
|
153
|
+
console.error(`✗ Retry did not succeed (HTTP ${res.status}).`);
|
|
154
|
+
console.error(` • ${message}`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
package/src/commands/preview.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* tot dev run your store locally with save→reload
|
|
7
7
|
* tot preview push it to a reviewable preview (validate → reconcile → compliance) ← you are here
|
|
8
|
-
* tot ship promote a reconciled preview live
|
|
8
|
+
* tot ship promote a reconciled preview live
|
|
9
9
|
*
|
|
10
10
|
* The whole preview flow (validate, push the preview ref, open/update the PR-backed
|
|
11
11
|
* candidate, stream the reconcile/compliance/preview result) lives in submit.mjs — this
|
|
@@ -45,12 +45,12 @@ export function postRunHint(code, alias) {
|
|
|
45
45
|
/**
|
|
46
46
|
* @param {string[]} argv
|
|
47
47
|
* @param {any} ctx
|
|
48
|
-
* @param {{ alias?: string|null }} [opts]
|
|
48
|
+
* @param {{ alias?: string|null }} [opts] - `alias` is the old verb the developer typed
|
|
49
49
|
* (`"submit"` / `"deploy"`) when this flow is reached as a teaching alias; null/omitted
|
|
50
50
|
* for the first-class `tot preview`.
|
|
51
51
|
*/
|
|
52
52
|
export async function run(argv, ctx, { alias = null } = {}) {
|
|
53
|
-
// `tot preview build …` is the OPERATOR build-on-demand subcommand
|
|
53
|
+
// `tot preview build …` is the OPERATOR build-on-demand subcommand —
|
|
54
54
|
// a distinct verb from the developer preview flow, so it's dispatched BEFORE the
|
|
55
55
|
// submit-flow arg parse. Only the first-class `tot preview` carries it (not the
|
|
56
56
|
// `submit`/`deploy` teaching aliases, which are the push-your-checkout flow).
|
|
@@ -59,6 +59,22 @@ export async function run(argv, ctx, { alias = null } = {}) {
|
|
|
59
59
|
return runBuild(argv.slice(1), ctx);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// `tot preview doctor …` is the read-only merge DIAGNOSIS subcommand —
|
|
63
|
+
// a distinct verb from the developer preview flow, dispatched before the submit-
|
|
64
|
+
// flow arg parse (like `build`). Only the first-class `tot preview` carries it.
|
|
65
|
+
if (!alias && argv[0] === "doctor") {
|
|
66
|
+
const { run: runDoctor } = await import("./preview-doctor.mjs");
|
|
67
|
+
return runDoctor(argv.slice(1), ctx);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// `tot preview retry-evidence …` is the OPERATOR recovery subcommand for a
|
|
71
|
+
// candidate whose evidence push to MCP failed (fb-1787938036559-ppmu4e) —
|
|
72
|
+
// dispatched before the submit-flow arg parse, like `build`/`doctor`.
|
|
73
|
+
if (!alias && argv[0] === "retry-evidence") {
|
|
74
|
+
const { run: runRetryEvidence } = await import("./preview-retry-evidence.mjs");
|
|
75
|
+
return runRetryEvidence(argv.slice(1), ctx);
|
|
76
|
+
}
|
|
77
|
+
|
|
62
78
|
const verb = alias || "preview";
|
|
63
79
|
const args = parseArgs(argv);
|
|
64
80
|
|