@tokenoftrust/cli 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tot.mjs +115 -3
- package/package.json +1 -1
- package/src/activity.mjs +378 -0
- package/src/commands/accept.mjs +445 -33
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +289 -10
- package/src/commands/dev.mjs +401 -135
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/pr.mjs +30 -7
- package/src/commands/revert.mjs +322 -0
- package/src/commands/ship.mjs +24 -4
- package/src/commands/start.mjs +40 -8
- package/src/commands/submit.mjs +839 -135
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +6 -1
- package/src/git-credential.mjs +184 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/plan.mjs +75 -2
- package/src/validate.mjs +52 -0
package/bin/tot.mjs
CHANGED
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
* tot preview push your store to a reviewable preview ← built (validate + push preview ref; MCP preview_status read-back). `submit`/`deploy` are teaching aliases.
|
|
17
17
|
* tot ship publish the current green aggregate live ← built (GET/POST /api/changes/ship → b09 orchestrator; exact plan + y/N confirm; waits for VERIFIED live truth)
|
|
18
18
|
* tot accept / tot merge queue a PR into the preview aggregate ← built (b08: plan + y/N confirm → POST /api/changes/integrate = b07 queue enqueue; NO merge-to-main, NO go-live; refuses non-TTY without --yes)
|
|
19
|
+
* tot sync fetch `preview` + merge it into your branch ← built (b16: the common accept-conflict recovery path; local-only, stops safely on conflict)
|
|
19
20
|
* tot rollback [<version>] instant re-point to a prior live version ← built (u3 promotion_status/promotion_rollback seam; diff + y/N confirm; refuses non-TTY / ineligible)
|
|
20
21
|
* tot pr list / view / close your candidate PRs ← built (candidate_status/candidate_close; gh-pr-shaped)
|
|
22
|
+
* tot branches full branch-cleanup report (every branch, not just open PRs) ← built (b14: candidate_list)
|
|
23
|
+
* tot cleanup owner-confirmed branch GC (deletes terminal branches only) ← built (b14: candidate_list + candidate_delete; --dry-run classifies only)
|
|
21
24
|
* tot doctor check this machine is ready
|
|
22
25
|
* tot ideas copy-paste AI prompts that reliably wow
|
|
23
26
|
* tot feedback send a note to ToT + your recent CLI activity ← built (activity-log.mjs → feedback_submit MCP tool)
|
|
@@ -37,6 +40,7 @@ import { readFileSync } from "node:fs";
|
|
|
37
40
|
import { detectContext } from "../src/context.mjs";
|
|
38
41
|
import { printError } from "../src/errors.mjs";
|
|
39
42
|
import { recordActivity, redactArgs } from "../src/activity-log.mjs";
|
|
43
|
+
import { emitActivity, capRendered } from "../src/activity.mjs";
|
|
40
44
|
import { maybeNotifyUpdate } from "../src/update-check.mjs";
|
|
41
45
|
|
|
42
46
|
const BUILD_ORDER = ["clone", "validate", "dev", "preview"];
|
|
@@ -66,10 +70,15 @@ tot — Token of Trust developer CLI
|
|
|
66
70
|
tot preview push your store to a reviewable preview
|
|
67
71
|
tot ship publish the tenant's current green aggregate live (plan → confirm → ship)
|
|
68
72
|
tot accept / tot merge queue a PR into the preview aggregate — operator verb, no go-live (plan → confirm → integrate)
|
|
73
|
+
tot sync fetch \`preview\` and merge it into your local branch (accept-conflict recovery)
|
|
69
74
|
tot rollback [<version>] instant re-point to a prior live version (list → confirm → rollback)
|
|
75
|
+
tot revert --preview <PR|sha> remove already-integrated content from the preview aggregate via a new revert commit — no force-reset (plan → confirm → revert)
|
|
76
|
+
tot hotfix --pr <N> OWNER-ONLY exception: release an urgent fix from main to live, bypassing unshipped preview work (plan → confirm → release → auto forward-integrate)
|
|
70
77
|
tot retire evict a candidate PR's preview to reclaim space — operator verb, rebuildable (plan → confirm → evict)
|
|
71
78
|
tot go-live cut the apex domain over to the storefront (readiness → confirm → cutover)
|
|
72
79
|
tot pr list / view / close your candidate PRs
|
|
80
|
+
tot branches full branch-cleanup report — every branch, any PR state, cleanup eligibility
|
|
81
|
+
tot cleanup owner-confirmed branch GC (--dry-run to classify only; deletes terminal branches only)
|
|
73
82
|
tot doctor check this machine is ready
|
|
74
83
|
tot ideas copy-paste AI prompts that reliably wow
|
|
75
84
|
tot feedback "<msg>" send feedback to Token of Trust (attaches recent activity)
|
|
@@ -146,6 +155,15 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
146
155
|
return run(rest, ctx);
|
|
147
156
|
}
|
|
148
157
|
|
|
158
|
+
// Plumbing, not a developer-facing verb (like git's own credential helpers,
|
|
159
|
+
// never listed in `git help`) — `tot clone` configures it as this checkout's
|
|
160
|
+
// `credential.helper`, and git alone invokes it (get/store/erase) on every
|
|
161
|
+
// fetch/push. See src/commands/git-credential.mjs's header (unit u10).
|
|
162
|
+
if (cmd === "git-credential") {
|
|
163
|
+
const { run } = await import("../src/commands/git-credential.mjs");
|
|
164
|
+
return run(rest, ctx);
|
|
165
|
+
}
|
|
166
|
+
|
|
149
167
|
if (cmd === "validate") {
|
|
150
168
|
const { run } = await import("../src/commands/validate.mjs");
|
|
151
169
|
return run(rest, ctx);
|
|
@@ -184,11 +202,42 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
184
202
|
return run(rest, ctx);
|
|
185
203
|
}
|
|
186
204
|
|
|
205
|
+
// `sync` is the common accept-conflict recovery path (P1 item 12): fetches the
|
|
206
|
+
// protected `preview` branch and merges it into the developer's local branch so
|
|
207
|
+
// they can resolve locally, then re-`tot preview`. Purely local — no push, no
|
|
208
|
+
// MCP call, never touches the shared `preview`/`main` refs themselves. See
|
|
209
|
+
// sync.mjs's header for the full contract.
|
|
210
|
+
if (cmd === "sync") {
|
|
211
|
+
const { run } = await import("../src/commands/sync.mjs");
|
|
212
|
+
return run(rest, ctx);
|
|
213
|
+
}
|
|
214
|
+
|
|
187
215
|
if (cmd === "rollback") {
|
|
188
216
|
const { run } = await import("../src/commands/rollback.mjs");
|
|
189
217
|
return run(rest, ctx);
|
|
190
218
|
}
|
|
191
219
|
|
|
220
|
+
// `revert --preview <PR|integration-sha>` removes already-integrated content from
|
|
221
|
+
// the protected `preview` aggregate via a NEW auditable revert commit (b21) — NO
|
|
222
|
+
// force-reset, NO touch to main/live. Backed by POST /api/changes/revert (b07's
|
|
223
|
+
// TenantIntegrationQueue.enqueueRevert). To undo something already LIVE, that's
|
|
224
|
+
// `tot rollback`, not this. See revert.mjs's header.
|
|
225
|
+
if (cmd === "revert") {
|
|
226
|
+
const { run } = await import("../src/commands/revert.mjs");
|
|
227
|
+
return run(rest, ctx);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// `hotfix --pr <N>` is the OWNER-ONLY EXCEPTION lane (b22): release an urgent fix
|
|
231
|
+
// from `main` to live while `preview` still holds other unshipped work, EXCLUDING
|
|
232
|
+
// that unshipped preview head, then automatically forward-integrate main → preview
|
|
233
|
+
// and re-validate. It is DELIBERATELY a distinct verb — never a `--base main` flag
|
|
234
|
+
// on `tot ship` (which publishes the whole green preview aggregate). Backed by
|
|
235
|
+
// GET/POST /api/changes/hotfix → b22's HotfixOrchestrator. See hotfix.mjs's header.
|
|
236
|
+
if (cmd === "hotfix") {
|
|
237
|
+
const { run } = await import("../src/commands/hotfix.mjs");
|
|
238
|
+
return run(rest, ctx);
|
|
239
|
+
}
|
|
240
|
+
|
|
192
241
|
// `retire` evicts a candidate PR's hosted preview to reclaim space (unit U7) —
|
|
193
242
|
// a DISTINCT operator verb from `accept`/reject: retire touches no change
|
|
194
243
|
// lifecycle and is reversible-by-rebuild (`tot preview build`). See retire.mjs.
|
|
@@ -207,6 +256,24 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
207
256
|
return run(rest, ctx);
|
|
208
257
|
}
|
|
209
258
|
|
|
259
|
+
// `branches` is the FULL branch-cleanup report (every branch, any PR state) —
|
|
260
|
+
// distinct from `tot pr list`, which only shows OPEN PRs (P1 item 9). Read-only;
|
|
261
|
+
// always safe to run, and the report `tot cleanup` reuses before deleting anything.
|
|
262
|
+
if (cmd === "branches") {
|
|
263
|
+
const { run } = await import("../src/commands/branches.mjs");
|
|
264
|
+
return run(rest, ctx);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// `cleanup` is owner-confirmed BRANCH GC (P1 items 9/10) — an irreversible git-ref
|
|
268
|
+
// delete, distinct from `tot retire`'s hosted-preview eviction (rebuildable, no
|
|
269
|
+
// branch touched). `--dry-run` classifies and prints; it never deletes. Age alone
|
|
270
|
+
// never makes a branch eligible; main/preview are always kept; orphans are
|
|
271
|
+
// quarantined, never auto-deleted. See cleanup.mjs's header.
|
|
272
|
+
if (cmd === "cleanup") {
|
|
273
|
+
const { run } = await import("../src/commands/cleanup.mjs");
|
|
274
|
+
return run(rest, ctx);
|
|
275
|
+
}
|
|
276
|
+
|
|
210
277
|
if (cmd === "app") {
|
|
211
278
|
const { run } = await import("../src/commands/app/index.mjs");
|
|
212
279
|
return run(rest, ctx);
|
|
@@ -238,12 +305,39 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
238
305
|
return 2;
|
|
239
306
|
}
|
|
240
307
|
|
|
308
|
+
/**
|
|
309
|
+
* The safe `subcommand` value for a command's activity event. Only the fixed
|
|
310
|
+
* sub-dispatcher verbs (e.g. `app scaffold` / `app dev`) are surfaced — an
|
|
311
|
+
* arbitrary positional (a tenant name, a path, a code) is NEVER used, so the
|
|
312
|
+
* activity `subcommand` field can't carry PII even before redaction. Returns
|
|
313
|
+
* undefined for every command without a fixed subcommand vocabulary.
|
|
314
|
+
*/
|
|
315
|
+
function safeSubcommand(cmd, rest) {
|
|
316
|
+
if (cmd === "app") {
|
|
317
|
+
const sub = rest.find((t) => t && !t.startsWith("-"));
|
|
318
|
+
if (sub === "scaffold" || sub === "dev") return sub;
|
|
319
|
+
}
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
241
323
|
async function main() {
|
|
242
324
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
243
325
|
const ctx = detectContext();
|
|
244
326
|
const startedAt = Date.now();
|
|
327
|
+
const command = cmd || "(none)";
|
|
328
|
+
const subcommand = safeSubcommand(cmd, rest);
|
|
245
329
|
let code = 0;
|
|
246
330
|
let errMsg = null;
|
|
331
|
+
|
|
332
|
+
// D3: emit `cli.command.invoked` on start — best-effort, a SILENT no-op without a
|
|
333
|
+
// hosted-bridge credential (never a network call / never blocks). Fired without
|
|
334
|
+
// await so it adds no latency to the command; settled alongside the result below.
|
|
335
|
+
const invokedEmit = emitActivity({
|
|
336
|
+
action: "cli.command.invoked",
|
|
337
|
+
outcome: { status: "invoked" },
|
|
338
|
+
payload: { args: { command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION, node: process.version } },
|
|
339
|
+
});
|
|
340
|
+
|
|
247
341
|
try {
|
|
248
342
|
code = await dispatch(cmd, rest, ctx);
|
|
249
343
|
return code;
|
|
@@ -251,17 +345,35 @@ async function main() {
|
|
|
251
345
|
errMsg = e?.message || String(e);
|
|
252
346
|
throw e;
|
|
253
347
|
} finally {
|
|
348
|
+
const exitCode = errMsg ? 1 : (code ?? 0);
|
|
349
|
+
const durationMs = Date.now() - startedAt;
|
|
254
350
|
// Best-effort activity breadcrumb (never throws, never blocks). `feedback`'s own
|
|
255
351
|
// free-text message is omitted — it's user-typed and belongs only in the report.
|
|
256
352
|
recordActivity({
|
|
257
353
|
ts: new Date().toISOString(),
|
|
258
354
|
v: VERSION,
|
|
259
|
-
cmd:
|
|
355
|
+
cmd: command,
|
|
260
356
|
args: cmd === "feedback" ? ["«omitted»"] : redactArgs(rest),
|
|
261
|
-
code:
|
|
262
|
-
ms:
|
|
357
|
+
code: exitCode,
|
|
358
|
+
ms: durationMs,
|
|
263
359
|
...(errMsg ? { err: String(errMsg).slice(0, 200) } : {}),
|
|
264
360
|
});
|
|
361
|
+
// D3: emit `cli.command.result` (exit code + duration + a bounded/redacted
|
|
362
|
+
// rendered field). The house-style error text is capped + run through the JS
|
|
363
|
+
// redaction mirror; per the D0 catalog the `cli.*` rendered lane is empty-
|
|
364
|
+
// allowlisted, so it's DROPPED by design (the safe default for a high-volume
|
|
365
|
+
// action) — the emit path still exercises the mirror. Bounded-await here (with
|
|
366
|
+
// the invoked emit) so a live bridge flushes before the process exits; a no-op
|
|
367
|
+
// when there's no credential.
|
|
368
|
+
const resultEmit = emitActivity({
|
|
369
|
+
action: "cli.command.result",
|
|
370
|
+
outcome: { status: errMsg ? "failed" : "succeeded", durationMs },
|
|
371
|
+
payload: {
|
|
372
|
+
args: { command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION, exitCode, durationMs },
|
|
373
|
+
...(errMsg ? { rendered: { output: capRendered(errMsg) } } : {}),
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
await Promise.allSettled([invokedEmit, resultEmit]);
|
|
265
377
|
// Nudge if a newer/unsupported version exists (drawn from cache — instant),
|
|
266
378
|
// and kick a detached registry refresh if stale. Never throws, never blocks.
|
|
267
379
|
maybeNotifyUpdate(VERSION);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
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
|
+
}
|