@tokenoftrust/cli 1.4.0-rc.18 → 1.4.0-rc.19
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/bin/tot.mjs +49 -3
- package/package.json +1 -1
- package/src/activity.mjs +378 -0
- package/src/commands/clone.mjs +32 -0
- package/src/commands/dev.mjs +332 -97
- package/src/commands/pr.mjs +30 -7
- package/src/commands/ship.mjs +20 -1
- package/src/commands/submit.mjs +30 -0
- package/src/no-gitea-links.test.mjs +55 -0
package/bin/tot.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { readFileSync } from "node:fs";
|
|
|
40
40
|
import { detectContext } from "../src/context.mjs";
|
|
41
41
|
import { printError } from "../src/errors.mjs";
|
|
42
42
|
import { recordActivity, redactArgs } from "../src/activity-log.mjs";
|
|
43
|
+
import { emitActivity, capRendered } from "../src/activity.mjs";
|
|
43
44
|
import { maybeNotifyUpdate } from "../src/update-check.mjs";
|
|
44
45
|
|
|
45
46
|
const BUILD_ORDER = ["clone", "validate", "dev", "preview"];
|
|
@@ -295,12 +296,39 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
295
296
|
return 2;
|
|
296
297
|
}
|
|
297
298
|
|
|
299
|
+
/**
|
|
300
|
+
* The safe `subcommand` value for a command's activity event. Only the fixed
|
|
301
|
+
* sub-dispatcher verbs (e.g. `app scaffold` / `app dev`) are surfaced — an
|
|
302
|
+
* arbitrary positional (a tenant name, a path, a code) is NEVER used, so the
|
|
303
|
+
* activity `subcommand` field can't carry PII even before redaction. Returns
|
|
304
|
+
* undefined for every command without a fixed subcommand vocabulary.
|
|
305
|
+
*/
|
|
306
|
+
function safeSubcommand(cmd, rest) {
|
|
307
|
+
if (cmd === "app") {
|
|
308
|
+
const sub = rest.find((t) => t && !t.startsWith("-"));
|
|
309
|
+
if (sub === "scaffold" || sub === "dev") return sub;
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
298
314
|
async function main() {
|
|
299
315
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
300
316
|
const ctx = detectContext();
|
|
301
317
|
const startedAt = Date.now();
|
|
318
|
+
const command = cmd || "(none)";
|
|
319
|
+
const subcommand = safeSubcommand(cmd, rest);
|
|
302
320
|
let code = 0;
|
|
303
321
|
let errMsg = null;
|
|
322
|
+
|
|
323
|
+
// D3: emit `cli.command.invoked` on start — best-effort, a SILENT no-op without a
|
|
324
|
+
// hosted-bridge credential (never a network call / never blocks). Fired without
|
|
325
|
+
// await so it adds no latency to the command; settled alongside the result below.
|
|
326
|
+
const invokedEmit = emitActivity({
|
|
327
|
+
action: "cli.command.invoked",
|
|
328
|
+
outcome: { status: "invoked" },
|
|
329
|
+
payload: { args: { command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION, node: process.version } },
|
|
330
|
+
});
|
|
331
|
+
|
|
304
332
|
try {
|
|
305
333
|
code = await dispatch(cmd, rest, ctx);
|
|
306
334
|
return code;
|
|
@@ -308,17 +336,35 @@ async function main() {
|
|
|
308
336
|
errMsg = e?.message || String(e);
|
|
309
337
|
throw e;
|
|
310
338
|
} finally {
|
|
339
|
+
const exitCode = errMsg ? 1 : (code ?? 0);
|
|
340
|
+
const durationMs = Date.now() - startedAt;
|
|
311
341
|
// Best-effort activity breadcrumb (never throws, never blocks). `feedback`'s own
|
|
312
342
|
// free-text message is omitted — it's user-typed and belongs only in the report.
|
|
313
343
|
recordActivity({
|
|
314
344
|
ts: new Date().toISOString(),
|
|
315
345
|
v: VERSION,
|
|
316
|
-
cmd:
|
|
346
|
+
cmd: command,
|
|
317
347
|
args: cmd === "feedback" ? ["«omitted»"] : redactArgs(rest),
|
|
318
|
-
code:
|
|
319
|
-
ms:
|
|
348
|
+
code: exitCode,
|
|
349
|
+
ms: durationMs,
|
|
320
350
|
...(errMsg ? { err: String(errMsg).slice(0, 200) } : {}),
|
|
321
351
|
});
|
|
352
|
+
// D3: emit `cli.command.result` (exit code + duration + a bounded/redacted
|
|
353
|
+
// rendered field). The house-style error text is capped + run through the JS
|
|
354
|
+
// redaction mirror; per the D0 catalog the `cli.*` rendered lane is empty-
|
|
355
|
+
// allowlisted, so it's DROPPED by design (the safe default for a high-volume
|
|
356
|
+
// action) — the emit path still exercises the mirror. Bounded-await here (with
|
|
357
|
+
// the invoked emit) so a live bridge flushes before the process exits; a no-op
|
|
358
|
+
// when there's no credential.
|
|
359
|
+
const resultEmit = emitActivity({
|
|
360
|
+
action: "cli.command.result",
|
|
361
|
+
outcome: { status: errMsg ? "failed" : "succeeded", durationMs },
|
|
362
|
+
payload: {
|
|
363
|
+
args: { command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION, exitCode, durationMs },
|
|
364
|
+
...(errMsg ? { rendered: { output: capRendered(errMsg) } } : {}),
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
await Promise.allSettled([invokedEmit, resultEmit]);
|
|
322
368
|
// Nudge if a newer/unsupported version exists (drawn from cache — instant),
|
|
323
369
|
// and kick a detached registry refresh if stale. Never throws, never blocks.
|
|
324
370
|
maybeNotifyUpdate(VERSION);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.4.0-rc.
|
|
3
|
+
"version": "1.4.0-rc.19",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/activity.mjs
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emitActivity — the CLI's operational-activity emitter (card D3).
|
|
3
|
+
*
|
|
4
|
+
* This is a hand-maintained JS MIRROR of the D0 actor×action contract that lives,
|
|
5
|
+
* in TypeScript, in `packages/public-runtime/src/activity/` (event.ts / catalog.ts
|
|
6
|
+
* / redaction.ts). The CLI is published to npm STANDALONE and is deliberately pure
|
|
7
|
+
* JS with NO dependency on `@tot/public-runtime` (a TS package) — see that module's
|
|
8
|
+
* README ("Consuming this from the downstream cards → D3"). So instead of importing
|
|
9
|
+
* the contract, we mirror the slice of it the CLI needs, and a keep-in-step test
|
|
10
|
+
* (activity-mirror.test.mjs) fails LOUDLY the moment this mirror drifts from the TS
|
|
11
|
+
* source of truth (the same discipline as no-gitea-links.test.mjs).
|
|
12
|
+
*
|
|
13
|
+
* What this mirrors, exactly:
|
|
14
|
+
* • the ActivityEvent envelope shape + ACTIVITY_SCHEMA_VERSION + newEventId()
|
|
15
|
+
* • the CLI-relevant subset of ACTION_CATALOG (the `cli.*` action keys + their
|
|
16
|
+
* default-deny argsAllow/renderedAllow allowlists)
|
|
17
|
+
* • the redaction contract (REDACTION_MARKER, MAX_VALUE_LEN, the VALUE_CANARIES,
|
|
18
|
+
* the KEY_NAME_CANARY, and redactPayload/createActivityEvent) — byte-for-byte,
|
|
19
|
+
* so a value that the server-side contract would scrub is scrubbed here too.
|
|
20
|
+
* If you add a `cli.*` action, change an allowlist, or touch a canary in the TS
|
|
21
|
+
* source, you MUST update this file in lockstep — the keep-in-step test enforces it.
|
|
22
|
+
*
|
|
23
|
+
* Transport + gate (generalized from the obstacle beacon, obstacle-beacon.cjs):
|
|
24
|
+
* emitActivity() is a SILENT NO-OP without a bridge credential configured — it
|
|
25
|
+
* never makes a network call and never fails/blocks a command when the developer
|
|
26
|
+
* isn't connected to a hosted /dev bridge (a bare `tot login`, an older session,
|
|
27
|
+
* CI, the `--sample` path). With a credential it POSTs the redacted envelope to
|
|
28
|
+
* the SAME `<activityUrl>/api/dev/activity` endpoint the obstacle beacon and the
|
|
29
|
+
* dev heartbeat already use — best-effort, bounded by a hard timeout, and fully
|
|
30
|
+
* swallowed so telemetry can never disrupt or delay the developer's command.
|
|
31
|
+
*
|
|
32
|
+
* Dependency-free (node: builtins + global fetch, Node ≥22).
|
|
33
|
+
*/
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
// Envelope (mirror of event.ts)
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Envelope contract version — MIRROR of event.ts ACTIVITY_SCHEMA_VERSION. */
|
|
42
|
+
export const ACTIVITY_SCHEMA_VERSION = 1;
|
|
43
|
+
|
|
44
|
+
/** Node-native UUID v4 (Node ≥22 has globalThis.crypto). Mirror of event.ts newEventId(). */
|
|
45
|
+
export function newEventId() {
|
|
46
|
+
const c = globalThis.crypto;
|
|
47
|
+
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
|
48
|
+
// Fallback (should never be reached on a supported Node) — node:crypto.
|
|
49
|
+
return createHash("sha256").update(String(Math.random()) + Date.now()).digest("hex").slice(0, 32);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// Action catalog — the CLI-relevant SUBSET (mirror of the `cli.*` keys in catalog.ts).
|
|
54
|
+
//
|
|
55
|
+
// Keep this in lockstep with packages/public-runtime/src/activity/catalog.ts. Only
|
|
56
|
+
// the `cli.*` domain is mirrored here (the CLI never emits server/ui actions); the
|
|
57
|
+
// keep-in-step test asserts that (a) this set is EXACTLY the `cli.*` keys in the TS
|
|
58
|
+
// catalog and (b) each argsAllow/renderedAllow matches the TS entry.
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
export const CLI_ACTION_CATALOG = {
|
|
61
|
+
"cli.command.invoked": {
|
|
62
|
+
argsAllow: ["command", "subcommand", "cliVersion", "node"],
|
|
63
|
+
renderedAllow: [],
|
|
64
|
+
},
|
|
65
|
+
"cli.command.result": {
|
|
66
|
+
argsAllow: ["command", "subcommand", "cliVersion", "exitCode", "durationMs"],
|
|
67
|
+
renderedAllow: [],
|
|
68
|
+
},
|
|
69
|
+
"cli.obstacle.reported": {
|
|
70
|
+
argsAllow: ["kind", "have", "need", "cliVersion"],
|
|
71
|
+
renderedAllow: [],
|
|
72
|
+
},
|
|
73
|
+
"cli.signin.requested": {
|
|
74
|
+
argsAllow: ["cliVersion"],
|
|
75
|
+
renderedAllow: [],
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** All CLI action keys as a runtime array. */
|
|
80
|
+
export const CLI_ACTION_KEYS = Object.keys(CLI_ACTION_CATALOG);
|
|
81
|
+
|
|
82
|
+
/** Deny-by-default guard — is this an allowlisted CLI action key? */
|
|
83
|
+
export function isCliActionKey(v) {
|
|
84
|
+
return typeof v === "string" && Object.prototype.hasOwnProperty.call(CLI_ACTION_CATALOG, v);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
// Redaction contract (mirror of redaction.ts) — MUST match byte-for-byte.
|
|
89
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** Marker stored in place of a value that tripped a canary. Mirror of REDACTION_MARKER. */
|
|
92
|
+
export const REDACTION_MARKER = "«redacted»";
|
|
93
|
+
/** Cap on a stored rendered/arg string. Mirror of MAX_VALUE_LEN. */
|
|
94
|
+
export const MAX_VALUE_LEN = 512;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Secret/PII VALUE canaries — MIRROR of redaction.ts VALUE_CANARIES. The regex
|
|
98
|
+
* SOURCES here must stay identical to the TS side; the keep-in-step test compares
|
|
99
|
+
* them literally.
|
|
100
|
+
*/
|
|
101
|
+
export const VALUE_CANARIES = [
|
|
102
|
+
{ label: "private_key", re: /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----/ },
|
|
103
|
+
{ label: "jwt", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/ },
|
|
104
|
+
{ label: "bearer", re: /\bBearer\s+[A-Za-z0-9._~+/-]{12,}=*/i },
|
|
105
|
+
{ label: "aws_access_key", re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
106
|
+
{ label: "gh_token", re: /\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}/ },
|
|
107
|
+
{ label: "slack_token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
108
|
+
{ label: "openai_key", re: /\bsk-[A-Za-z0-9]{20,}/ },
|
|
109
|
+
{ label: "email", re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/ },
|
|
110
|
+
{ label: "card_number", re: /\b(?:\d[ -]?){13,19}\b/ },
|
|
111
|
+
{ label: "phone", re: /\b\+?\d[\d ().-]{9,}\d\b/ },
|
|
112
|
+
{ label: "high_entropy", re: /(?:^|[^A-Za-z0-9_-])[A-Za-z0-9_-]{28,}(?:$|[^A-Za-z0-9_-])/ },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/** Secret-looking KEY NAMES — dropped outright. MIRROR of redaction.ts KEY_NAME_CANARY. */
|
|
116
|
+
export const KEY_NAME_CANARY =
|
|
117
|
+
/(?:secret|token|passwd|password|authorization|api[_-]?key|private[_-]?key|credential|cookie|session|bearer|jwt)/i;
|
|
118
|
+
|
|
119
|
+
function isPrimitive(v) {
|
|
120
|
+
const t = typeof v;
|
|
121
|
+
return t === "string" || t === "number" || t === "boolean";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Scan a stringified value against the value canaries. Mirror of scanValueCanary. */
|
|
125
|
+
export function scanValueCanary(value) {
|
|
126
|
+
for (const c of VALUE_CANARIES) if (c.re.test(value)) return c.label;
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function redactLane(lane, raw, allow, hits) {
|
|
131
|
+
if (!raw) return undefined;
|
|
132
|
+
const allowSet = new Set(allow);
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
135
|
+
if (KEY_NAME_CANARY.test(key)) {
|
|
136
|
+
hits.push({ lane, key, reason: "key_name_canary" });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!allowSet.has(key)) {
|
|
140
|
+
hits.push({ lane, key, reason: "not_allowlisted" });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!isPrimitive(value)) {
|
|
144
|
+
hits.push({ lane, key, reason: "non_primitive" });
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const str = String(value).slice(0, MAX_VALUE_LEN);
|
|
148
|
+
const canary = scanValueCanary(str);
|
|
149
|
+
if (canary) {
|
|
150
|
+
hits.push({ lane, key, reason: "value_canary", canary });
|
|
151
|
+
out[key] = REDACTION_MARKER;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
out[key] = typeof value === "string" ? str : value;
|
|
155
|
+
}
|
|
156
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Apply the redaction contract to a raw payload for a CLI action. Pure. Mirror of
|
|
161
|
+
* redaction.ts redactPayload (restricted to the CLI catalog). An unknown action
|
|
162
|
+
* yields an empty payload (deny-all).
|
|
163
|
+
*/
|
|
164
|
+
export function redactPayload(action, raw) {
|
|
165
|
+
const hits = [];
|
|
166
|
+
const spec = CLI_ACTION_CATALOG[action];
|
|
167
|
+
if (!spec) {
|
|
168
|
+
if (raw?.args) for (const k of Object.keys(raw.args)) hits.push({ lane: "args", key: k, reason: "unknown_action" });
|
|
169
|
+
if (raw?.rendered) for (const k of Object.keys(raw.rendered)) hits.push({ lane: "rendered", key: k, reason: "unknown_action" });
|
|
170
|
+
return { payload: undefined, report: { keptArgs: [], keptRendered: [], hits, canaryTripped: false } };
|
|
171
|
+
}
|
|
172
|
+
const args = redactLane("args", raw?.args, spec.argsAllow, hits);
|
|
173
|
+
const renderedRaw = redactLane("rendered", raw?.rendered, spec.renderedAllow, hits);
|
|
174
|
+
const rendered = renderedRaw
|
|
175
|
+
? Object.fromEntries(Object.entries(renderedRaw).map(([k, v]) => [k, String(v)]))
|
|
176
|
+
: undefined;
|
|
177
|
+
const payload = args || rendered ? { ...(args ? { args } : {}), ...(rendered ? { rendered } : {}) } : undefined;
|
|
178
|
+
return {
|
|
179
|
+
payload,
|
|
180
|
+
report: {
|
|
181
|
+
keptArgs: args ? Object.keys(args) : [],
|
|
182
|
+
keptRendered: rendered ? Object.keys(rendered) : [],
|
|
183
|
+
hits,
|
|
184
|
+
canaryTripped: hits.some((h) => h.reason === "value_canary"),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The safe constructor — mirror of redaction.ts createActivityEvent. Fills v/id/at
|
|
191
|
+
* and runs the raw payload through redactPayload() so the returned event's payload
|
|
192
|
+
* is already default-denied + canary-scanned. Throws only on an out-of-catalog
|
|
193
|
+
* (non-`cli.*`) action — a programming error.
|
|
194
|
+
*/
|
|
195
|
+
export function createActivityEvent({ action, actor, source = "cli", outcome, scope, payload, at, id }) {
|
|
196
|
+
if (!isCliActionKey(action)) throw new Error(`activity: unknown CLI action "${action}" (not in the CLI mirror catalog)`);
|
|
197
|
+
const { payload: redacted, report } = redactPayload(action, payload);
|
|
198
|
+
const event = {
|
|
199
|
+
v: ACTIVITY_SCHEMA_VERSION,
|
|
200
|
+
id: id ?? newEventId(),
|
|
201
|
+
at: at ?? new Date().toISOString(),
|
|
202
|
+
actor,
|
|
203
|
+
action,
|
|
204
|
+
source,
|
|
205
|
+
scope: scope ?? {},
|
|
206
|
+
outcome,
|
|
207
|
+
...(redacted ? { payload: redacted } : {}),
|
|
208
|
+
};
|
|
209
|
+
return { event, report };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
// Bridge resolution + emit
|
|
214
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/** Hard cap on how long a single emit may take before it's abandoned (never blocks a command). */
|
|
217
|
+
const EMIT_TIMEOUT_MS = 1500;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The ONE activity-telemetry kill switch (card D8) — the SAME flag name the server
|
|
221
|
+
* honours (`apps/storefront/src/lib/activity/killSwitch.ts`). Set
|
|
222
|
+
* `ACTIVITY_TELEMETRY_DISABLED=1` (or true/yes/on) and every CLI emit becomes a silent
|
|
223
|
+
* no-op — no build, no redaction, no network — while the command itself runs unchanged
|
|
224
|
+
* (fail-open). Unset ⇒ telemetry enabled (the normal state).
|
|
225
|
+
*/
|
|
226
|
+
export const ACTIVITY_KILL_SWITCH_FLAG = "ACTIVITY_TELEMETRY_DISABLED";
|
|
227
|
+
export function isActivityDisabled(env = process.env) {
|
|
228
|
+
const v = env && env[ACTIVITY_KILL_SWITCH_FLAG];
|
|
229
|
+
return typeof v === "string" && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolve the local→hosted activity-bridge credential, or null when none is
|
|
234
|
+
* configured (→ emitActivity is a no-op). Mirrors the resolution the obstacle
|
|
235
|
+
* beacon (obstacle.mjs) and the heartbeat (dev-heartbeat.mjs) use: the env override
|
|
236
|
+
* `TOT_DEV_ACTIVITY_URL`/`TOT_DEV_ACTIVITY_TOKEN` (threaded to runner-spawning
|
|
237
|
+
* paths) wins, else the `activityUrl`/`activityToken` cached in ~/.tot/credentials.json.
|
|
238
|
+
*/
|
|
239
|
+
export function resolveActivityBridge(env = process.env) {
|
|
240
|
+
const url = env.TOT_DEV_ACTIVITY_URL;
|
|
241
|
+
const token = env.TOT_DEV_ACTIVITY_TOKEN;
|
|
242
|
+
if (url && token) return { url, token };
|
|
243
|
+
try {
|
|
244
|
+
const creds = readCredentials(defaultCredentialsPath(env));
|
|
245
|
+
if (creds?.activityUrl && creds?.activityToken) {
|
|
246
|
+
return { url: creds.activityUrl, token: creds.activityToken };
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
/* best-effort — no bridge */
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The event's actor — kind `dev` (or `agent` when the caller signals an automated
|
|
256
|
+
* run) with an OPAQUE, non-reversible id, per the D0 contract (actor.id in the core
|
|
257
|
+
* zone is NEVER raw PII). Until the canonical pseudonymization salt lands (an open
|
|
258
|
+
* question owned by D1/D4 — see redaction.ts header), we derive the id from an
|
|
259
|
+
* already-opaque handle: the masked emailHint if present, else a one-way hash of the
|
|
260
|
+
* bridge token, else "anonymous". Never reversible, never raw PII.
|
|
261
|
+
*/
|
|
262
|
+
export function resolveActor(env = process.env) {
|
|
263
|
+
const kind = env.TOT_ACTOR_KIND === "agent" ? "agent" : "dev";
|
|
264
|
+
let seed = "anonymous";
|
|
265
|
+
try {
|
|
266
|
+
const creds = readCredentials(defaultCredentialsPath(env));
|
|
267
|
+
if (creds?.emailHint) seed = `hint:${creds.emailHint}`;
|
|
268
|
+
else if (creds?.activityToken) seed = `tok:${creds.activityToken}`;
|
|
269
|
+
} catch {
|
|
270
|
+
/* fall through to anonymous */
|
|
271
|
+
}
|
|
272
|
+
const id = seed === "anonymous" ? "anonymous" : createHash("sha256").update(seed).digest("hex").slice(0, 16);
|
|
273
|
+
return { kind, id };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The scope threaded onto every CLI event — the storefront-minted invite→problem
|
|
278
|
+
* `traceId` from the credential cache, when present, so a CLI event and the server
|
|
279
|
+
* event it triggers share one trace (matching the existing feedback/heartbeat use).
|
|
280
|
+
*/
|
|
281
|
+
function resolveScope(env = process.env, extra = {}) {
|
|
282
|
+
const scope = { ...extra };
|
|
283
|
+
try {
|
|
284
|
+
const creds = readCredentials(defaultCredentialsPath(env));
|
|
285
|
+
if (creds?.traceId && !scope.traceId) scope.traceId = creds.traceId;
|
|
286
|
+
} catch {
|
|
287
|
+
/* best-effort */
|
|
288
|
+
}
|
|
289
|
+
return scope;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Emit one activity event. SILENT NO-OP without a bridge credential (returns
|
|
294
|
+
* `{ sent:false }`) — no network call, never throws, never blocks a command. With
|
|
295
|
+
* a credential it builds the redacted envelope (via createActivityEvent) and POSTs
|
|
296
|
+
* it best-effort to `<url>/api/dev/activity`, bounded by EMIT_TIMEOUT_MS.
|
|
297
|
+
*
|
|
298
|
+
* Returns `{ sent, event?, report? }` so callers/tests can assert what would be
|
|
299
|
+
* emitted without any network. `fetchImpl` and `bridge` are injectable for tests.
|
|
300
|
+
*
|
|
301
|
+
* @param {{ action: string, outcome: object, actor?: object, scope?: object,
|
|
302
|
+
* payload?: object, source?: string, env?: NodeJS.ProcessEnv,
|
|
303
|
+
* fetchImpl?: typeof fetch, bridge?: {url:string,token:string}|null }} input
|
|
304
|
+
* @returns {Promise<{sent:boolean, event?:object, report?:object}>}
|
|
305
|
+
*/
|
|
306
|
+
export async function emitActivity({
|
|
307
|
+
action,
|
|
308
|
+
outcome,
|
|
309
|
+
actor,
|
|
310
|
+
scope,
|
|
311
|
+
payload,
|
|
312
|
+
source = "cli",
|
|
313
|
+
env = process.env,
|
|
314
|
+
fetchImpl,
|
|
315
|
+
bridge,
|
|
316
|
+
} = {}) {
|
|
317
|
+
try {
|
|
318
|
+
// Kill switch (D8): telemetry off ⇒ true no-op (no build/redaction/network),
|
|
319
|
+
// fail-open — the command is unaffected.
|
|
320
|
+
if (isActivityDisabled(env)) return { sent: false, disabled: true };
|
|
321
|
+
const resolvedBridge = bridge !== undefined ? bridge : resolveActivityBridge(env);
|
|
322
|
+
// Build the redacted event even when we won't send it — so a caller/test can
|
|
323
|
+
// inspect the exact shape, and so redaction always runs on the emit path.
|
|
324
|
+
const { event, report } = createActivityEvent({
|
|
325
|
+
action,
|
|
326
|
+
actor: actor ?? resolveActor(env),
|
|
327
|
+
source,
|
|
328
|
+
outcome,
|
|
329
|
+
scope: resolveScope(env, scope ?? {}),
|
|
330
|
+
payload,
|
|
331
|
+
});
|
|
332
|
+
if (!resolvedBridge) return { sent: false, event, report };
|
|
333
|
+
|
|
334
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
335
|
+
if (typeof doFetch !== "function") return { sent: false, event, report };
|
|
336
|
+
|
|
337
|
+
const controller = new AbortController();
|
|
338
|
+
const timer = setTimeout(() => controller.abort(), EMIT_TIMEOUT_MS);
|
|
339
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
340
|
+
try {
|
|
341
|
+
await doFetch(`${String(resolvedBridge.url).replace(/\/+$/, "")}/api/dev/activity`, {
|
|
342
|
+
method: "POST",
|
|
343
|
+
headers: {
|
|
344
|
+
"content-type": "application/json",
|
|
345
|
+
authorization: `Bearer ${resolvedBridge.token}`,
|
|
346
|
+
},
|
|
347
|
+
// `event: "activity"` tags the stream for the bridge/D4 ingest to route,
|
|
348
|
+
// alongside the D0 envelope. Best-effort — an older bridge that doesn't
|
|
349
|
+
// recognize it simply ignores the post.
|
|
350
|
+
body: JSON.stringify({ event: "activity", ...event }),
|
|
351
|
+
signal: controller.signal,
|
|
352
|
+
});
|
|
353
|
+
} catch {
|
|
354
|
+
/* best-effort — a failed/offline/aborted post never disrupts the command */
|
|
355
|
+
} finally {
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
}
|
|
358
|
+
return { sent: true, event, report };
|
|
359
|
+
} catch {
|
|
360
|
+
// Absolutely never throw into a command's control flow.
|
|
361
|
+
return { sent: false };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Cap + surface a bit of rendered output for a command-result event. The full raw
|
|
367
|
+
* stdout is NEVER stored — this caps to MAX_VALUE_LEN and hands the string to the
|
|
368
|
+
* redaction mirror via payload.rendered. NB the `cli.command.*` actions carry an
|
|
369
|
+
* EMPTY renderedAllow in the D0 catalog, so this string is DROPPED at redaction by
|
|
370
|
+
* design (the safe default for a high-volume action) — running it through the
|
|
371
|
+
* mirror is the belt-and-suspenders the contract prescribes, and keeps the emit
|
|
372
|
+
* path honest if the allowlist ever opens. Pure.
|
|
373
|
+
*/
|
|
374
|
+
export function capRendered(text) {
|
|
375
|
+
const s = String(text ?? "").trim();
|
|
376
|
+
if (!s) return undefined;
|
|
377
|
+
return s.slice(0, MAX_VALUE_LEN);
|
|
378
|
+
}
|
package/src/commands/clone.mjs
CHANGED
|
@@ -194,6 +194,22 @@ export async function run(argv, ctx) {
|
|
|
194
194
|
*/
|
|
195
195
|
export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
|
|
196
196
|
await client.callTool("client_switch", { tenant });
|
|
197
|
+
// vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
|
|
198
|
+
// storefront version-control app BEFORE checkout, so tenant_checkout can resolve
|
|
199
|
+
// `appForCaller` for them instead of 403-ing "No version-control app is bound." The
|
|
200
|
+
// human's OWN session self-triggers it (no subject_token — the subject is the
|
|
201
|
+
// caller's already-verified identity); the MCP entitlement-gates + authors the bind.
|
|
202
|
+
// Best-effort + FAIL-OPEN: a missing tool / cold MCP / any fault must never block a
|
|
203
|
+
// checkout that would otherwise succeed, and if the developer is still unbound the
|
|
204
|
+
// tenant_checkout error below (via checkoutError) is the honest backstop. Binding is
|
|
205
|
+
// also ensured at the authority seams (native OAuth sign-in) + backfill; this line
|
|
206
|
+
// self-heals a legacy session that predates them.
|
|
207
|
+
try {
|
|
208
|
+
// Wire name is `identity_bind` (tot-mcp taxonomy: <subject>_<action>).
|
|
209
|
+
await client.callTool("identity_bind", {});
|
|
210
|
+
} catch {
|
|
211
|
+
/* fail-open — see above */
|
|
212
|
+
}
|
|
197
213
|
const checkout = await client.callTool("tenant_checkout", { tenant, tag });
|
|
198
214
|
|
|
199
215
|
// A non-checkout result (not provisioned / not entitled / failed) must surface
|
|
@@ -271,6 +287,22 @@ export function checkoutError(checkout) {
|
|
|
271
287
|
next: "ask your Token of Trust contact to finish setting up your store, then re-run",
|
|
272
288
|
};
|
|
273
289
|
}
|
|
290
|
+
// Entitled, but not yet BOUND to the version-control app (appForCaller resolves
|
|
291
|
+
// nothing for this identity) — the 08-18 vc-app-binding gap. Distinct from
|
|
292
|
+
// not-provisioned: the STORE is fine; the developer just isn't onboarded to act
|
|
293
|
+
// THROUGH the app yet. The checkout preflight tries to self-heal this; if it still
|
|
294
|
+
// surfaces, the binding seam/backfill hasn't reached this identity — say so plainly
|
|
295
|
+
// instead of the generic "confirm you're entitled" (they ARE entitled).
|
|
296
|
+
if (
|
|
297
|
+
typeof msg === "string" &&
|
|
298
|
+
/version-control app is bound|registered version-control app/i.test(msg)
|
|
299
|
+
) {
|
|
300
|
+
return {
|
|
301
|
+
message:
|
|
302
|
+
"your account isn't onboarded to check out this store yet (no version-control app is bound to your session)",
|
|
303
|
+
next: "re-run `tot login` to refresh your access, then retry — if it persists, ask your Token of Trust contact to finish onboarding your account",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
274
306
|
return {
|
|
275
307
|
message: msg || "the store checkout couldn't be completed",
|
|
276
308
|
next: "confirm you're entitled to this store — `tot clone` (lists your stores)",
|
package/src/commands/dev.mjs
CHANGED
|
@@ -615,9 +615,19 @@ export async function resolvePublicRendererSource(args, env = process.env, { dec
|
|
|
615
615
|
`(or pin with --renderer-version to silence).`,
|
|
616
616
|
);
|
|
617
617
|
}
|
|
618
|
-
const
|
|
619
|
-
if (!tarball) throw new Error(`no published ${pkg}@${version} on npm`);
|
|
620
|
-
|
|
618
|
+
const dist = meta?.versions?.[version]?.dist;
|
|
619
|
+
if (!dist?.tarball) throw new Error(`no published ${pkg}@${version} on npm`);
|
|
620
|
+
// `integrity` is the artifact's CONTENT identity (npm dist.integrity, else the
|
|
621
|
+
// legacy shasum) — installRunnerTarball verifies the downloaded bytes against
|
|
622
|
+
// it and records it so a same-version corrected republish busts the cache.
|
|
623
|
+
return {
|
|
624
|
+
kind: "public",
|
|
625
|
+
version,
|
|
626
|
+
url: dist.tarball,
|
|
627
|
+
strip: 1,
|
|
628
|
+
cacheKey: `public-${version}`,
|
|
629
|
+
integrity: dist.integrity || dist.shasum || null,
|
|
630
|
+
};
|
|
621
631
|
}
|
|
622
632
|
|
|
623
633
|
/**
|
|
@@ -637,7 +647,17 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
|
|
|
637
647
|
if (!res?.url || !res?.version) {
|
|
638
648
|
throw new Error(res?.error || "no renderer-artifact URL returned");
|
|
639
649
|
}
|
|
640
|
-
|
|
650
|
+
// Opportunistic content identity: recorded/verified when the MCP declares one
|
|
651
|
+
// (integrity/sha256); a server that doesn't is simply unverified (null), never
|
|
652
|
+
// an error — the cache then busts on version changes only, as before.
|
|
653
|
+
return {
|
|
654
|
+
kind: "entitled",
|
|
655
|
+
version: res.version,
|
|
656
|
+
url: res.url,
|
|
657
|
+
strip: 0,
|
|
658
|
+
cacheKey: res.version,
|
|
659
|
+
integrity: res.integrity || res.sha256 || null,
|
|
660
|
+
};
|
|
641
661
|
}
|
|
642
662
|
|
|
643
663
|
/**
|
|
@@ -676,7 +696,12 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
|
|
|
676
696
|
|
|
677
697
|
try {
|
|
678
698
|
const runnerDir = await installRunnerTarball(
|
|
679
|
-
{
|
|
699
|
+
{
|
|
700
|
+
source: credential.url,
|
|
701
|
+
version: credential.version,
|
|
702
|
+
isUrl: true,
|
|
703
|
+
integrity: credential.integrity || credential.sha256 || null,
|
|
704
|
+
},
|
|
680
705
|
{ log: (m) => console.error(m) },
|
|
681
706
|
);
|
|
682
707
|
setRunnerVersion(credential.version); // telemetry: stamp the entitled runner version, like ensureSampleRenderer
|
|
@@ -851,7 +876,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
851
876
|
console.error(`~ renderer: ${src.why}`);
|
|
852
877
|
return installRunnerTarball(
|
|
853
878
|
{ source: src.source, version: sourceVersionKey(src.source), isUrl: src.isUrl },
|
|
854
|
-
{ log: (m) => console.error(m) },
|
|
879
|
+
{ log: (m) => console.error(m), cacheRoot },
|
|
855
880
|
);
|
|
856
881
|
}
|
|
857
882
|
|
|
@@ -862,12 +887,16 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
862
887
|
const declared = declaredRunnerVersion(args.workspace || ctx?.workspacePath);
|
|
863
888
|
const wantVersion = declared || CLI_VERSION;
|
|
864
889
|
|
|
865
|
-
//
|
|
866
|
-
// already cached, reuse it
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
890
|
+
// Offline-safe fast path: when the WANTED version (declared, else lockstep) is
|
|
891
|
+
// already cached, reuse it — honouring "don't hit npm when the RIGHT version is
|
|
892
|
+
// already cached" without ever reusing a version the resolution wouldn't choose.
|
|
893
|
+
// Safe because `public-<version>` only exists if a prior run fetched exactly
|
|
894
|
+
// that version. Skipped when an explicit pin is set (that must go through
|
|
895
|
+
// resolution). One refinement over fully-offline: a QUICK, soft-fail registry
|
|
896
|
+
// probe (publishedRunnerIntegrity) revalidates the cached CONTENT identity when
|
|
897
|
+
// npm is reachable, so a corrected republish under the same version string is
|
|
898
|
+
// picked up automatically; offline/slow/unanswerable → trust the cache exactly
|
|
899
|
+
// as before (the probe can never block or fail the run).
|
|
871
900
|
if (!explicitPin) {
|
|
872
901
|
const exact = pinnedPublicCacheDir(cacheRoot, wantVersion);
|
|
873
902
|
if (exact) {
|
|
@@ -878,21 +907,31 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
878
907
|
// host's arch (rendererCacheHealthy) — a cache poisoned with the wrong-arch
|
|
879
908
|
// bindings (npm/cli#4828) would otherwise be reused forever and crash astro
|
|
880
909
|
// at boot with the swallowed "dev server didn't come up". When both hold,
|
|
881
|
-
// reuse it (
|
|
910
|
+
// reuse it (offline-safe).
|
|
882
911
|
if (probeRunnerVersion(exact) && rendererCacheHealthy(exact)) {
|
|
912
|
+
const published = await publishedRunnerIntegrity(env, wantVersion);
|
|
913
|
+
const recorded = readCacheMarker(exact)?.integrity || null;
|
|
914
|
+
if (!published || !recorded || published === recorded) {
|
|
915
|
+
console.error(
|
|
916
|
+
`~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
|
|
917
|
+
);
|
|
918
|
+
setRunnerVersion(wantVersion);
|
|
919
|
+
prunePublicRunnerCache(cacheRoot, wantVersion);
|
|
920
|
+
return exact;
|
|
921
|
+
}
|
|
922
|
+
// Same version string, different published contents — a corrected
|
|
923
|
+
// republish. Fall through to resolution + a fresh install (which also
|
|
924
|
+
// verifies the new bytes against the new integrity).
|
|
925
|
+
console.error(`~ renderer: ${wantVersion} was republished with different contents — refetching the corrected artifact`);
|
|
926
|
+
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none matches what npm now publishes
|
|
927
|
+
} else {
|
|
883
928
|
console.error(
|
|
884
|
-
|
|
929
|
+
rendererCacheHealthy(exact)
|
|
930
|
+
? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
|
|
931
|
+
: `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
|
|
885
932
|
);
|
|
886
|
-
|
|
887
|
-
prunePublicRunnerCache(cacheRoot, wantVersion);
|
|
888
|
-
return exact;
|
|
933
|
+
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
|
|
889
934
|
}
|
|
890
|
-
console.error(
|
|
891
|
-
rendererCacheHealthy(exact)
|
|
892
|
-
? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
|
|
893
|
-
: `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
|
|
894
|
-
);
|
|
895
|
-
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
|
|
896
935
|
}
|
|
897
936
|
}
|
|
898
937
|
|
|
@@ -923,8 +962,8 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
923
962
|
// The store preview engine (public npm ${PUBLIC_RUNNER_PACKAGE}@${pub.version}) — kept
|
|
924
963
|
// out of the user's way; the setup spinner below is the visible progress.
|
|
925
964
|
const dir = await installRunnerTarball(
|
|
926
|
-
{ source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
|
|
927
|
-
{ log: (m) => console.error(m) },
|
|
965
|
+
{ source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip, integrity: pub.integrity },
|
|
966
|
+
{ log: (m) => console.error(m), cacheRoot },
|
|
928
967
|
);
|
|
929
968
|
setRunnerVersion(pub.version); // telemetry: the runner version running this session
|
|
930
969
|
// The pinned version is now installed under public-<version> — drop any other
|
|
@@ -949,92 +988,269 @@ function sourceVersionKey(source) {
|
|
|
949
988
|
* authenticated (ensureRendererArtifact) and zero-login (ensureSampleRenderer)
|
|
950
989
|
* paths so they cache identically.
|
|
951
990
|
*
|
|
952
|
-
*
|
|
953
|
-
*
|
|
991
|
+
* Cache-poisoning invariants (the 2026-08-18 first-run hardening):
|
|
992
|
+
* • promote-on-success only — the install runs in a per-attempt staging dir and
|
|
993
|
+
* is renamed into the canonical slot ONLY after it fully succeeds, so a failed
|
|
994
|
+
* install can never become the cached artifact a later run resumes from.
|
|
995
|
+
* • the completion marker is a manifest carrying the source's CONTENT identity
|
|
996
|
+
* (`integrity`), so a corrected republish under the SAME version string is a
|
|
997
|
+
* cache miss (rebuild), not a stale hit — no manual `rm -rf` ever required.
|
|
998
|
+
* • a failed attempt auto-cleans and retries ONCE from a clean slate before
|
|
999
|
+
* surfacing the error (transient blips heal themselves); deterministic
|
|
1000
|
+
* failures (`e.permanent`) skip the retry and fail loud immediately.
|
|
1001
|
+
*
|
|
1002
|
+
* @param {{ source: string, version: string, isUrl?: boolean, strip?: number, integrity?: string|null }} spec
|
|
1003
|
+
* `integrity` is the source artifact's content identity when the resolver knows
|
|
1004
|
+
* it (npm `dist.integrity`/`dist.shasum`); used to verify the downloaded bytes
|
|
1005
|
+
* and to bust a cached entry whose recorded identity no longer matches.
|
|
1006
|
+
* @param {{ log?: (m: string) => void, cacheRoot?: string }} [opts]
|
|
954
1007
|
* @returns {Promise<string>} the cached, installed runner tree's root directory.
|
|
955
1008
|
*/
|
|
956
|
-
export async function installRunnerTarball(
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1009
|
+
export async function installRunnerTarball(
|
|
1010
|
+
{ source, version, isUrl = true, strip = 0, integrity = null },
|
|
1011
|
+
{ log = (m) => console.error(m), cacheRoot = RENDERER_CACHE_ROOT } = {},
|
|
1012
|
+
) {
|
|
1013
|
+
const runnerDir = join(cacheRoot, version);
|
|
1014
|
+
// Staging dirs from DEAD runs (crashed/killed installs) must not leak disk
|
|
1015
|
+
// forever — reap them here, the one funnel every install path goes through.
|
|
1016
|
+
sweepStaleStagingDirs(cacheRoot);
|
|
1017
|
+
|
|
1018
|
+
const localSource = isUrl ? null : resolveLocalTarball(source);
|
|
1019
|
+
// The EXPECTED content identity of the source artifact. A local tarball with no
|
|
1020
|
+
// caller-provided integrity is cheap to hash on every run, so a same-path
|
|
1021
|
+
// republish (new contents, same file name) busts the cache too.
|
|
1022
|
+
const expected = integrity || (isUrl ? null : fileIntegrity(localSource));
|
|
1023
|
+
|
|
1024
|
+
const cached = readCacheMarker(runnerDir);
|
|
1025
|
+
if (cached) {
|
|
1026
|
+
if (!rendererCacheHealthy(runnerDir)) {
|
|
1027
|
+
// A cache poisoned with the wrong-arch binaries (npm/cli#4828 — e.g.
|
|
1028
|
+
// darwin-x64 on an arm64 Mac) is otherwise trusted forever, and astro
|
|
1029
|
+
// crashes at boot with `Cannot find native binding`, swallowed as "the dev
|
|
1030
|
+
// server didn't come up". Fall through and rebuild from the source below.
|
|
1031
|
+
log(`~ store preview engine cache is missing native bindings for ${process.platform}-${process.arch} — rebuilding it…`);
|
|
1032
|
+
} else if (expected && cached.integrity && expected !== cached.integrity) {
|
|
1033
|
+
// CONTENT-HASH BUST: same version string, different artifact contents — a
|
|
1034
|
+
// corrected republish. The cached entry is stale by identity, not by label;
|
|
1035
|
+
// rebuild from the corrected source instead of serving the stale cache.
|
|
1036
|
+
log(`~ the preview engine's ${version} artifact changed upstream (same version, new contents) — rebuilding…`);
|
|
1037
|
+
} else {
|
|
1038
|
+
return runnerDir; // already downloaded + installed (and contents still match)
|
|
1039
|
+
}
|
|
967
1040
|
}
|
|
968
1041
|
|
|
969
1042
|
// First run only — set the expectation so the one-time cost doesn't read as a
|
|
970
1043
|
// hang: this downloads + installs the renderer once, then every later run of
|
|
971
1044
|
// this version is a no-network cache hit.
|
|
972
1045
|
log(`~ first run: setting up your store preview (~a minute, one-time — cached after this)…`);
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1046
|
+
// AUTO-CLEAN-AND-RETRY-ONCE: a transient failure (network blip mid-download, a
|
|
1047
|
+
// registry hiccup mid-install) heals itself with one clean re-attempt instead
|
|
1048
|
+
// of stopping a first run at an error only `rm -rf` folklore could clear.
|
|
1049
|
+
// Bounded to one retry so a genuinely-broken source still fails loudly.
|
|
1050
|
+
for (let attempt = 1; ; attempt++) {
|
|
1051
|
+
const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource;
|
|
1052
|
+
const stagingDir = `${runnerDir}.staging-${process.pid}`;
|
|
1053
|
+
try {
|
|
1054
|
+
if (isUrl) {
|
|
1055
|
+
// The fetch itself is otherwise silent (no per-byte output) and can run
|
|
1056
|
+
// tens of seconds on a cold cache — tick a spinner so it never looks hung.
|
|
1057
|
+
const spin = startProgress("downloading the store preview engine…");
|
|
1058
|
+
try {
|
|
1059
|
+
await downloadFile(source, archivePath);
|
|
1060
|
+
} finally {
|
|
1061
|
+
spin.stop();
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (!existsSync(archivePath)) {
|
|
1065
|
+
throw new Error(`renderer tarball not found: ${archivePath}`);
|
|
1066
|
+
}
|
|
1067
|
+
// Refuse to install bytes that don't match the source's declared identity —
|
|
1068
|
+
// a truncated/corrupted download would otherwise be cached as if complete.
|
|
1069
|
+
// (Transient by nature, so the retry above gets a fresh download.)
|
|
1070
|
+
if (expected && !tarballMatchesIntegrity(archivePath, expected)) {
|
|
1071
|
+
throw new Error(`the downloaded preview-engine tarball failed its integrity check (expected ${expected})`);
|
|
1072
|
+
}
|
|
1073
|
+
// The identity recorded in the completion manifest below — what future runs
|
|
1074
|
+
// compare against to detect a same-version republish. Hash the actual bytes
|
|
1075
|
+
// when the resolver couldn't tell us (e.g. the entitled signed-URL path).
|
|
1076
|
+
const contentId = expected || fileIntegrity(archivePath);
|
|
1077
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
1078
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
1079
|
+
extractTarball(archivePath, stagingDir, { strip });
|
|
1080
|
+
// Pin the runner install to PUBLIC npm. The moat-free runner has only public
|
|
1081
|
+
// deps, but the HOST's global ~/.npmrc may point `registry` at a private
|
|
1082
|
+
// mirror (an internal proxy that 502s, or one an invited developer can't
|
|
1083
|
+
// reach) — an invited dev's machine config must never decide where the
|
|
1084
|
+
// runner's public deps come from. A project-level .npmrc wins over the user's.
|
|
1085
|
+
writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
|
|
1086
|
+
// The install is the long, noisy step — tick a spinner while its output goes
|
|
1087
|
+
// to a log, so the terminal shows one clean line instead of the pnpm firehose.
|
|
1088
|
+
// corepack setup logs to the SAME file so its failures aren't invisible (they
|
|
1089
|
+
// were the silent cause of "couldn't set up the store preview engine").
|
|
1090
|
+
const installLog = join(cacheRoot, `${version}.install.log`);
|
|
1091
|
+
ensureCorepackPnpm(stagingDir, { logPath: installLog });
|
|
1092
|
+
const installSpin = startProgress("installing the store preview engine…", {
|
|
1093
|
+
stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
|
|
1094
|
+
});
|
|
980
1095
|
try {
|
|
981
|
-
await
|
|
1096
|
+
await runPnpmInstall(stagingDir, { logPath: installLog });
|
|
982
1097
|
} finally {
|
|
983
|
-
|
|
1098
|
+
installSpin.stop();
|
|
984
1099
|
}
|
|
1100
|
+
// Atomic-ish: only rename into the final, discoverable path once install
|
|
1101
|
+
// succeeded, so a crashed/interrupted run never leaves a half-built cache
|
|
1102
|
+
// entry that a later `tot dev` would treat as ready.
|
|
1103
|
+
rmSync(runnerDir, { recursive: true, force: true });
|
|
1104
|
+
renameSync(stagingDir, runnerDir);
|
|
1105
|
+
// Fence a fresh install against npm/cli#4828: if the installer left the wrong
|
|
1106
|
+
// arch's native bindings (or none) for this host, DON'T stamp the completion
|
|
1107
|
+
// marker — an unmarked tree is never reused, so the next run reinstalls cleanly
|
|
1108
|
+
// instead of caching the poison and crashing astro at boot. Fail loud + actionable
|
|
1109
|
+
// rather than swallow it as "the dev server didn't come up". Permanent: the
|
|
1110
|
+
// same installer on the same host would just produce the same result, so the
|
|
1111
|
+
// auto-retry is skipped.
|
|
1112
|
+
if (!rendererCacheHealthy(runnerDir)) {
|
|
1113
|
+
await emitObstacle("renderer-native-bindings-missing");
|
|
1114
|
+
const err = new CliError(
|
|
1115
|
+
`the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}`,
|
|
1116
|
+
{
|
|
1117
|
+
next: "install pnpm (`npm i -g pnpm`, or `corepack enable`) and re-run `tot start` — pnpm installs the platform-native bits npm can skip (npm/cli#4828)",
|
|
1118
|
+
exitCode: 2,
|
|
1119
|
+
},
|
|
1120
|
+
);
|
|
1121
|
+
err.permanent = true;
|
|
1122
|
+
throw err;
|
|
1123
|
+
}
|
|
1124
|
+
writeCacheMarker(runnerDir, { version, integrity: contentId });
|
|
1125
|
+
return runnerDir;
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
// A failed attempt must never survive on disk — not as staging debris, and
|
|
1128
|
+
// (by promote-on-success) it never reached the canonical slot at all.
|
|
1129
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
1130
|
+
if (e?.permanent === true || attempt >= 2) throw e;
|
|
1131
|
+
log(`~ that didn't work (${String(e?.message || e).split("\n")[0]}) — retrying once from a clean slate…`);
|
|
1132
|
+
} finally {
|
|
1133
|
+
if (isUrl) rmSync(archivePath, { force: true });
|
|
985
1134
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
// to a log, so the terminal shows one clean line instead of the pnpm firehose.
|
|
1001
|
-
// corepack setup logs to the SAME file so its failures aren't invisible (they
|
|
1002
|
-
// were the silent cause of "couldn't set up the store preview engine").
|
|
1003
|
-
const installLog = join(RENDERER_CACHE_ROOT, `${version}.install.log`);
|
|
1004
|
-
ensureCorepackPnpm(stagingDir, { logPath: installLog });
|
|
1005
|
-
const installSpin = startProgress("installing the store preview engine…", {
|
|
1006
|
-
stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
|
|
1007
|
-
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Read a cache entry's completion marker (`.tot-cache-complete`). Returns the
|
|
1140
|
+
* manifest object (at least `{ integrity: string|null }`), or null when the
|
|
1141
|
+
* marker is absent — i.e. the entry is incomplete/partial and must be treated
|
|
1142
|
+
* as if it didn't exist. A legacy pre-manifest marker (a bare timestamp string)
|
|
1143
|
+
* reads as complete-with-unknown-identity, so existing healthy caches survive
|
|
1144
|
+
* the upgrade without a forced rebuild.
|
|
1145
|
+
*/
|
|
1146
|
+
export function readCacheMarker(dir) {
|
|
1147
|
+
try {
|
|
1148
|
+
const raw = readFileSync(join(dir, ".tot-cache-complete"), "utf8");
|
|
1008
1149
|
try {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1150
|
+
const m = JSON.parse(raw);
|
|
1151
|
+
if (m && typeof m === "object") return { integrity: null, ...m };
|
|
1152
|
+
} catch {
|
|
1153
|
+
/* legacy timestamp-string marker */
|
|
1012
1154
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
//
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1155
|
+
return { integrity: null };
|
|
1156
|
+
} catch {
|
|
1157
|
+
return null; // no marker → never treat the entry as installed
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Stamp a cache entry complete: version + source content identity + when. */
|
|
1162
|
+
function writeCacheMarker(dir, { version, integrity }) {
|
|
1163
|
+
writeFileSync(
|
|
1164
|
+
join(dir, ".tot-cache-complete"),
|
|
1165
|
+
JSON.stringify({ version, integrity: integrity || null, completedAt: new Date().toISOString() }) + "\n",
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** sha512 SRI (`sha512-<base64>`, npm's `dist.integrity` format) of a file; null when unreadable. */
|
|
1170
|
+
function fileIntegrity(path) {
|
|
1171
|
+
try {
|
|
1172
|
+
return `sha512-${createHash("sha512").update(readFileSync(path)).digest("base64")}`;
|
|
1173
|
+
} catch {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Do the tarball's bytes match `expected` — an SRI string (`sha512-<b64>`, npm's
|
|
1180
|
+
* `dist.integrity`) or npm's legacy `dist.shasum` (bare 40-hex sha1)? Unknown
|
|
1181
|
+
* formats and probe errors return true: this check exists to catch corrupted
|
|
1182
|
+
* bytes, never to block an install on a format we can't verify.
|
|
1183
|
+
*/
|
|
1184
|
+
export function tarballMatchesIntegrity(archivePath, expected) {
|
|
1185
|
+
try {
|
|
1186
|
+
const want = String(expected).trim();
|
|
1187
|
+
const sri = /^(sha512|sha384|sha256|sha1)-([A-Za-z0-9+/=]+)$/.exec(want);
|
|
1188
|
+
if (sri) {
|
|
1189
|
+
return createHash(sri[1]).update(readFileSync(archivePath)).digest("base64") === sri[2];
|
|
1032
1190
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1191
|
+
if (/^[0-9a-f]{40}$/i.test(want)) {
|
|
1192
|
+
return createHash("sha1").update(readFileSync(archivePath)).digest("hex") === want.toLowerCase();
|
|
1193
|
+
}
|
|
1194
|
+
return true;
|
|
1195
|
+
} catch {
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Reap `<entry>.staging-<pid>` dirs left by DEAD processes — failed/killed
|
|
1202
|
+
* installs used to accumulate one orphaned staging tree per attempt, leaking
|
|
1203
|
+
* disk forever. A staging dir whose pid is still alive belongs to a concurrent
|
|
1204
|
+
* `tot dev` mid-install and is left alone. Best-effort: never throws, and never
|
|
1205
|
+
* touches this process's own staging dir (created fresh after this sweep).
|
|
1206
|
+
*/
|
|
1207
|
+
export function sweepStaleStagingDirs(cacheRoot, { pidAlive = processAlive } = {}) {
|
|
1208
|
+
try {
|
|
1209
|
+
if (!cacheRoot || !existsSync(cacheRoot)) return;
|
|
1210
|
+
for (const name of readdirSync(cacheRoot)) {
|
|
1211
|
+
const m = /\.staging-(\d+)$/.exec(name);
|
|
1212
|
+
if (!m) continue;
|
|
1213
|
+
const pid = Number(m[1]);
|
|
1214
|
+
if (pid === process.pid || pidAlive(pid)) continue;
|
|
1215
|
+
rmSync(join(cacheRoot, name), { recursive: true, force: true });
|
|
1216
|
+
}
|
|
1217
|
+
} catch {
|
|
1218
|
+
/* best-effort cache hygiene */
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** Is a pid a live process? (signal 0 probe; EPERM = alive but not ours.) */
|
|
1223
|
+
function processAlive(pid) {
|
|
1224
|
+
try {
|
|
1225
|
+
process.kill(pid, 0);
|
|
1226
|
+
return true;
|
|
1227
|
+
} catch (e) {
|
|
1228
|
+
return e?.code === "EPERM";
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* The registry-declared content identity (`dist.integrity`, else `dist.shasum`)
|
|
1234
|
+
* of the public runner at `version` — or null when npm can't answer QUICKLY
|
|
1235
|
+
* (offline, slow, 4xx/5xx, malformed). Used by ensureSampleRenderer's cached
|
|
1236
|
+
* fast path to detect a same-version republish without ever making the network
|
|
1237
|
+
* a hard dependency: null means "can't verify right now — trust the cache",
|
|
1238
|
+
* preserving the offline-reuse behavior exactly.
|
|
1239
|
+
*/
|
|
1240
|
+
export async function publishedRunnerIntegrity(env, version, { timeoutMs = 2000, fetchFn = fetch } = {}) {
|
|
1241
|
+
try {
|
|
1242
|
+
const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
|
|
1243
|
+
const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
|
|
1244
|
+
const res = await fetchFn(`${registry}/${pkg.replace("/", "%2f")}`, {
|
|
1245
|
+
headers: { accept: "application/json" },
|
|
1246
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1247
|
+
});
|
|
1248
|
+
if (!res.ok) return null;
|
|
1249
|
+
const dist = (await res.json())?.versions?.[version]?.dist;
|
|
1250
|
+
return dist?.integrity || dist?.shasum || null;
|
|
1251
|
+
} catch {
|
|
1252
|
+
return null;
|
|
1036
1253
|
}
|
|
1037
|
-
return runnerDir;
|
|
1038
1254
|
}
|
|
1039
1255
|
|
|
1040
1256
|
/** Strip an optional file:// prefix from a local tarball path and resolve it absolute. */
|
|
@@ -1190,22 +1406,27 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
|
|
|
1190
1406
|
continue;
|
|
1191
1407
|
}
|
|
1192
1408
|
// The launcher ran; the install itself failed. That's the actionable error.
|
|
1409
|
+
// (installRunnerTarball auto-retries this ONCE from a clean slate before it
|
|
1410
|
+
// reaches the user — so by the time this surfaces, it failed twice.)
|
|
1193
1411
|
await emitObstacle("install-failed");
|
|
1194
1412
|
throw new CliError(
|
|
1195
1413
|
`couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
|
|
1196
1414
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
1197
|
-
{ next: "check the details log above, then re-run `tot start` (it
|
|
1415
|
+
{ next: "check the details log above, then re-run `tot start` (it retries from a clean slate — no cache to clear)" },
|
|
1198
1416
|
);
|
|
1199
1417
|
}
|
|
1200
1418
|
// Every launcher ENOENT'd → there's no pnpm on this machine and corepack
|
|
1201
1419
|
// couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
|
|
1202
1420
|
// every Node, so `npm i -g pnpm` is the escape hatch that always exists.
|
|
1421
|
+
// Permanent: retrying can't conjure a launcher — skip the clean-slate retry.
|
|
1203
1422
|
await emitObstacle("pnpm-missing");
|
|
1204
|
-
|
|
1423
|
+
const err = new CliError(
|
|
1205
1424
|
"couldn't set up the store preview engine — pnpm isn't available on this machine" +
|
|
1206
1425
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
1207
1426
|
{ next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
|
|
1208
1427
|
);
|
|
1428
|
+
err.permanent = true;
|
|
1429
|
+
throw err;
|
|
1209
1430
|
} finally {
|
|
1210
1431
|
if (fd !== null) closeSync(fd);
|
|
1211
1432
|
}
|
|
@@ -1220,6 +1441,20 @@ function pnpmFailureHint(logPath) {
|
|
|
1220
1441
|
if (!logPath) return " — is pnpm/corepack available on this host?";
|
|
1221
1442
|
try {
|
|
1222
1443
|
const tail = readFileSync(logPath, "utf8").slice(-8000);
|
|
1444
|
+
// A 404 means the registry answered — a specific package/version doesn't
|
|
1445
|
+
// exist there. Since installs now run from a clean slate every attempt
|
|
1446
|
+
// (promote-on-success + auto-retry), this is a BROKEN RUNNER RELEASE (it
|
|
1447
|
+
// references an unpublished package), not the user's cache — no `rm -rf`
|
|
1448
|
+
// will help. Check this BEFORE the generic ERR_PNPM_FETCH match, since
|
|
1449
|
+
// pnpm's 404 error text also contains "ERR_PNPM_FETCH".
|
|
1450
|
+
const missing404 = tail.match(/ERR_PNPM_FETCH_404[^\n]*GET\s+(\S+)/i);
|
|
1451
|
+
if (missing404) {
|
|
1452
|
+
return (
|
|
1453
|
+
` — the preview engine references a package that isn't published (${missing404[1]});` +
|
|
1454
|
+
" that's a broken preview-engine release, not your machine — try again later or pin a" +
|
|
1455
|
+
" known-good version with --renderer-version"
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1223
1458
|
if (/npm\.tokenoftrust\.com|ERR_PNPM_FETCH|502|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(tail)) {
|
|
1224
1459
|
return " — the Token of Trust package registry looks unreachable right now; check your connection and retry";
|
|
1225
1460
|
}
|
package/src/commands/pr.mjs
CHANGED
|
@@ -42,6 +42,24 @@ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
|
42
42
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
43
43
|
const SUBCOMMANDS = ["list", "view", "close"];
|
|
44
44
|
|
|
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
|
+
|
|
45
63
|
const USAGE = `tot pr — see and manage candidate PRs
|
|
46
64
|
|
|
47
65
|
tot pr [list] list your open candidate PRs for this store
|
|
@@ -118,9 +136,11 @@ export function matchCandidate(candidates, target) {
|
|
|
118
136
|
/**
|
|
119
137
|
* One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
|
|
120
138
|
* URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
|
|
121
|
-
* branch-bound candidates) and where its preview lives.
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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.
|
|
124
144
|
* @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
|
|
125
145
|
* previewUrl?:string|null, url?:string|null}} c
|
|
126
146
|
* @param {{ active?: boolean }} [opts]
|
|
@@ -128,8 +148,7 @@ export function matchCandidate(candidates, target) {
|
|
|
128
148
|
export function formatCandidateLine(c, { active = false } = {}) {
|
|
129
149
|
const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
|
|
130
150
|
const branch = c.branch ? c.branch : "(no branch)";
|
|
131
|
-
const
|
|
132
|
-
const urlPart = previewUrl ? ` ${previewUrl}` : "";
|
|
151
|
+
const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
|
|
133
152
|
const activePart = active ? " ← active" : "";
|
|
134
153
|
return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
|
|
135
154
|
}
|
|
@@ -326,6 +345,7 @@ export async function run(argv, ctx) {
|
|
|
326
345
|
}
|
|
327
346
|
|
|
328
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;
|
|
329
349
|
const statePath = defaultCandidateStatePath(env);
|
|
330
350
|
// Branch-bound (u4): the active-pointer namespace is scoped to the current git
|
|
331
351
|
// branch, so the "← active" marker reflects THIS branch's candidate.
|
|
@@ -347,7 +367,8 @@ export async function run(argv, ctx) {
|
|
|
347
367
|
const active = readActiveChangeId(statePath, scope);
|
|
348
368
|
console.log(`Open candidate PRs for ${repo}:`);
|
|
349
369
|
for (const c of candidates) {
|
|
350
|
-
|
|
370
|
+
const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
|
|
371
|
+
console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
|
|
351
372
|
}
|
|
352
373
|
return 0;
|
|
353
374
|
}
|
|
@@ -366,7 +387,9 @@ export async function run(argv, ctx) {
|
|
|
366
387
|
if (match.headSha) console.log(` head: ${match.headSha}`);
|
|
367
388
|
if (match.baseSha) console.log(` base: ${match.baseSha}`);
|
|
368
389
|
console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
|
|
369
|
-
|
|
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}`);
|
|
370
393
|
return 0;
|
|
371
394
|
}
|
|
372
395
|
|
package/src/commands/ship.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { fail } from "../errors.mjs";
|
|
|
47
47
|
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
48
48
|
import { startProgress } from "../progress.mjs";
|
|
49
49
|
import { openBrowser } from "../open.mjs";
|
|
50
|
+
import { emitActivity } from "../activity.mjs";
|
|
50
51
|
|
|
51
52
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
52
53
|
|
|
@@ -414,7 +415,25 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
414
415
|
const shipData = await readJsonSafe(shipRes);
|
|
415
416
|
progress?.stop();
|
|
416
417
|
|
|
417
|
-
|
|
418
|
+
const shipResult = normalizeShipResult(shipData);
|
|
419
|
+
// D3: emit the ship publish lifecycle event — `tot ship` does no LOCAL git op
|
|
420
|
+
// (it's HTTP-orchestrated), so this marks the outcome of the publish step
|
|
421
|
+
// itself, distinct from the outer command's invoked/result pair. Fire-and-
|
|
422
|
+
// forget best-effort: a silent no-op without a hosted-bridge credential, never
|
|
423
|
+
// awaited, never throws, never alters the ship. `errorClass` stays low-
|
|
424
|
+
// cardinality (the orchestrator's own terminal state/reason, never free text).
|
|
425
|
+
const shipped = shipResult.state === "shipped";
|
|
426
|
+
void emitActivity({
|
|
427
|
+
action: "cli.command.result",
|
|
428
|
+
outcome: {
|
|
429
|
+
status: shipped ? "succeeded" : "failed",
|
|
430
|
+
...(shipped ? {} : { errorClass: `ship_${shipResult.state}` }),
|
|
431
|
+
},
|
|
432
|
+
scope: { tenantId: tenant },
|
|
433
|
+
payload: { args: { command: "ship", subcommand: "ship.publish" } },
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
return reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
|
|
418
437
|
}
|
|
419
438
|
|
|
420
439
|
/**
|
package/src/commands/submit.mjs
CHANGED
|
@@ -50,6 +50,7 @@ import { validateTenant, ERROR } from "../validate.mjs";
|
|
|
50
50
|
import { openBrowser } from "../open.mjs";
|
|
51
51
|
import { startProgress } from "../progress.mjs";
|
|
52
52
|
import { fail } from "../errors.mjs";
|
|
53
|
+
import { emitActivity } from "../activity.mjs";
|
|
53
54
|
import {
|
|
54
55
|
defaultCandidateStatePath,
|
|
55
56
|
readActiveChangeId,
|
|
@@ -81,6 +82,23 @@ export function candidateRefFor(changeId) {
|
|
|
81
82
|
return `${CANDIDATE_REF_PREFIX}${changeId}`;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* D3: emit a git-op lifecycle event for `tot submit`/`tot preview` — one per git
|
|
87
|
+
* operation (commit / push), carrying its own success/failure, so the timeline sees
|
|
88
|
+
* the individual git steps, not just the outer command's invoked/result pair. Uses
|
|
89
|
+
* the `cli.command.result` catalog key with a `git.<op>` subcommand (a fixed, safe
|
|
90
|
+
* value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
|
|
91
|
+
* credential, never awaited, never throws, never alters the command. `errorClass` is
|
|
92
|
+
* a low-cardinality class (never a raw git stderr, which can carry a token/path).
|
|
93
|
+
*/
|
|
94
|
+
function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
|
|
95
|
+
void emitActivity({
|
|
96
|
+
action: "cli.command.result",
|
|
97
|
+
outcome: { status: ok ? "succeeded" : "failed", ...(durationMs != null ? { durationMs } : {}), ...(errorClass ? { errorClass } : {}) },
|
|
98
|
+
payload: { args: { command, subcommand: `git.${op}`, ...(durationMs != null ? { durationMs } : {}) } },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
export function parseArgs(argv) {
|
|
85
103
|
// `ref: null` — an explicit `--ref` always wins; otherwise the push target is
|
|
86
104
|
// derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
|
|
@@ -890,11 +908,13 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
890
908
|
try {
|
|
891
909
|
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
|
|
892
910
|
} catch (e) {
|
|
911
|
+
emitGitOp("commit", false, { command: verb, errorClass: "git_commit_failed" });
|
|
893
912
|
const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
|
|
894
913
|
console.error(fail(msg, "commit your content manually (git add / git commit), or re-run with --no-commit"));
|
|
895
914
|
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
896
915
|
return 1;
|
|
897
916
|
}
|
|
917
|
+
if (auto.committed) emitGitOp("commit", true, { command: verb });
|
|
898
918
|
if (auto.refused) {
|
|
899
919
|
console.error(
|
|
900
920
|
fail(
|
|
@@ -1007,7 +1027,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1007
1027
|
try {
|
|
1008
1028
|
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
1009
1029
|
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
1030
|
+
emitGitOp("push", true, { command: verb });
|
|
1010
1031
|
} catch (pushErr) {
|
|
1032
|
+
emitGitOp("push", false, {
|
|
1033
|
+
command: verb,
|
|
1034
|
+
errorClass: isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr) ? "forge_auth" : "git_push_failed",
|
|
1035
|
+
});
|
|
1011
1036
|
const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
|
|
1012
1037
|
// This is the NO-SESSION path pushing the clone-time embedded credential —
|
|
1013
1038
|
// which rotation kills the moment any fresh mint happens elsewhere. An auth
|
|
@@ -1066,7 +1091,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1066
1091
|
try {
|
|
1067
1092
|
const { out } = await pushPreviewRef(git, mintRemote, { ref });
|
|
1068
1093
|
if (out && out.trim()) console.error(redactUrl(out.trim()));
|
|
1094
|
+
emitGitOp("push", true, { command: verb });
|
|
1069
1095
|
} catch (e) {
|
|
1096
|
+
emitGitOp("push", false, {
|
|
1097
|
+
command: verb,
|
|
1098
|
+
errorClass: isForgeAuthError(e?.stderr || e?.message || e) ? "forge_auth" : "git_push_failed",
|
|
1099
|
+
});
|
|
1070
1100
|
const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
|
|
1071
1101
|
console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
|
|
1072
1102
|
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Regression guard: the `tot` CLI must never print/reference a raw Gitea
|
|
2
|
+
// forge URL to a developer's terminal. Gitea's API returns `html_url` when a
|
|
3
|
+
// PR is opened (e.g. `https://git.tokenoftrust.com/storefront/<repo>/pulls/<n>`)
|
|
4
|
+
// -- the CLI must surface the storefront-owned `/preview/<tenant>/pr/<n>` link
|
|
5
|
+
// instead (see apps/storefront's matching noGiteaLinks.test.ts and its header
|
|
6
|
+
// for the full architecture reasoning: apps/CLI never expose the forge
|
|
7
|
+
// directly, the MCP proxies every read/write). Precipitating incident
|
|
8
|
+
// (2026-08-18): a raw git.tokenoftrust.com PR URL reached an owner reviewing
|
|
9
|
+
// tokenoftrust.com. Test fixtures are exempt (they legitimately mock a Gitea
|
|
10
|
+
// URL to test the forge client), everything else in `src` must stay clean.
|
|
11
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { join, relative, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
|
|
17
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
18
|
+
const SRC_ROOT = dirname(SELF);
|
|
19
|
+
|
|
20
|
+
const TEST_FILE_RE = /\.test\.[cm]?js$/;
|
|
21
|
+
const FORGE_HOST_RE = /\bgit\.tokenoftrust\.com\b/i;
|
|
22
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
|
|
23
|
+
|
|
24
|
+
function walk(dir, out = []) {
|
|
25
|
+
for (const entry of readdirSync(dir)) {
|
|
26
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
27
|
+
const full = join(dir, entry);
|
|
28
|
+
const st = statSync(full);
|
|
29
|
+
if (st.isDirectory()) {
|
|
30
|
+
walk(full, out);
|
|
31
|
+
} else if (/\.[cm]?js$/.test(entry)) {
|
|
32
|
+
out.push(full);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("no Gitea forge links in the CLI source outside test fixtures", () => {
|
|
39
|
+
const offenders = [];
|
|
40
|
+
for (const file of walk(SRC_ROOT)) {
|
|
41
|
+
if (TEST_FILE_RE.test(file)) continue;
|
|
42
|
+
if (file === SELF) continue;
|
|
43
|
+
const content = readFileSync(file, "utf8");
|
|
44
|
+
content.split("\n").forEach((line, i) => {
|
|
45
|
+
if (FORGE_HOST_RE.test(line)) {
|
|
46
|
+
offenders.push(`${relative(SRC_ROOT, file)}:${i + 1}: ${line.trim()}`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
offenders,
|
|
52
|
+
[],
|
|
53
|
+
`Found Gitea forge links in non-test CLI source:\n${offenders.join("\n")}`,
|
|
54
|
+
);
|
|
55
|
+
});
|