@tokenoftrust/cli 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/bin/tot.mjs +148 -57
- package/package.json +6 -1
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +4 -4
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +3 -3
- package/src/commands/accept.mjs +498 -59
- package/src/commands/app/dev.mjs +8 -4
- package/src/commands/app/index.mjs +3 -3
- package/src/commands/app/scaffold.mjs +1 -1
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +264 -0
- package/src/commands/clone.mjs +307 -25
- package/src/commands/dev.mjs +440 -156
- package/src/commands/doctor.mjs +4 -4
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +9 -5
- package/src/commands/grants.mjs +7 -5
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/ideas.mjs +2 -2
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +5 -6
- package/src/commands/pr.mjs +62 -25
- package/src/commands/preview-build.mjs +6 -6
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview-retry-evidence.mjs +156 -0
- package/src/commands/preview.mjs +19 -3
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +18 -16
- package/src/commands/ship.mjs +51 -14
- package/src/commands/start.mjs +101 -59
- package/src/commands/submit.mjs +1183 -169
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +10 -4
- package/src/commands/whoami.mjs +1 -1
- package/src/dev-heartbeat.mjs +3 -2
- package/src/dev-logs.mjs +2 -2
- package/src/errors.mjs +11 -4
- package/src/git-credential.mjs +257 -0
- package/src/last-tenant.mjs +1 -1
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +18 -14
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +83 -15
- package/src/sample.mjs +4 -4
- package/src/validate.mjs +187 -15
- package/src/vendor/private-apps-devkit.mjs +3 -3
- package/src/viewer-session.mjs +118 -0
- package/template/private-app/README.md +12 -6
- package/src/commands/retire.mjs +0 -203
package/src/activity.mjs
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emitActivity — the CLI's operational-activity emitter.
|
|
3
|
+
*
|
|
4
|
+
* This is a hand-maintained JS MIRROR of the 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"). 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
|
+
* @param {{ action: any, actor?: any, source?: string, outcome?: any, scope?: any, payload?: any, at?: any, id?: any }} input
|
|
195
|
+
*/
|
|
196
|
+
export function createActivityEvent({ action, actor, source = "cli", outcome, scope, payload, at, id }) {
|
|
197
|
+
if (!isCliActionKey(action)) throw new Error(`activity: unknown CLI action "${action}" (not in the CLI mirror catalog)`);
|
|
198
|
+
const { payload: redacted, report } = redactPayload(action, payload);
|
|
199
|
+
const event = {
|
|
200
|
+
v: ACTIVITY_SCHEMA_VERSION,
|
|
201
|
+
id: id ?? newEventId(),
|
|
202
|
+
at: at ?? new Date().toISOString(),
|
|
203
|
+
actor,
|
|
204
|
+
action,
|
|
205
|
+
source,
|
|
206
|
+
scope: scope ?? {},
|
|
207
|
+
outcome,
|
|
208
|
+
...(redacted ? { payload: redacted } : {}),
|
|
209
|
+
};
|
|
210
|
+
return { event, report };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
214
|
+
// Bridge resolution + emit
|
|
215
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/** Hard cap on how long a single emit may take before it's abandoned (never blocks a command). */
|
|
218
|
+
const EMIT_TIMEOUT_MS = 1500;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The ONE activity-telemetry kill switch — the SAME flag name the server
|
|
222
|
+
* honours (`apps/storefront/src/lib/activity/killSwitch.ts`). Set
|
|
223
|
+
* `ACTIVITY_TELEMETRY_DISABLED=1` (or true/yes/on) and every CLI emit becomes a silent
|
|
224
|
+
* no-op — no build, no redaction, no network — while the command itself runs unchanged
|
|
225
|
+
* (fail-open). Unset ⇒ telemetry enabled (the normal state).
|
|
226
|
+
*/
|
|
227
|
+
export const ACTIVITY_KILL_SWITCH_FLAG = "ACTIVITY_TELEMETRY_DISABLED";
|
|
228
|
+
export function isActivityDisabled(env = process.env) {
|
|
229
|
+
const v = env && env[ACTIVITY_KILL_SWITCH_FLAG];
|
|
230
|
+
return typeof v === "string" && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve the local→hosted activity-bridge credential, or null when none is
|
|
235
|
+
* configured (→ emitActivity is a no-op). Mirrors the resolution the obstacle
|
|
236
|
+
* beacon (obstacle.mjs) and the heartbeat (dev-heartbeat.mjs) use: the env override
|
|
237
|
+
* `TOT_DEV_ACTIVITY_URL`/`TOT_DEV_ACTIVITY_TOKEN` (threaded to runner-spawning
|
|
238
|
+
* paths) wins, else the `activityUrl`/`activityToken` cached in ~/.tot/credentials.json.
|
|
239
|
+
*/
|
|
240
|
+
export function resolveActivityBridge(env = process.env) {
|
|
241
|
+
const url = env.TOT_DEV_ACTIVITY_URL;
|
|
242
|
+
const token = env.TOT_DEV_ACTIVITY_TOKEN;
|
|
243
|
+
if (url && token) return { url, token };
|
|
244
|
+
try {
|
|
245
|
+
const creds = readCredentials(defaultCredentialsPath(env));
|
|
246
|
+
if (creds?.activityUrl && creds?.activityToken) {
|
|
247
|
+
return { url: creds.activityUrl, token: creds.activityToken };
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
/* best-effort — no bridge */
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The event's actor — kind `dev` (or `agent` when the caller signals an automated
|
|
257
|
+
* run) with an OPAQUE, non-reversible id, per the contract (actor.id in the core
|
|
258
|
+
* zone is NEVER raw PII). Until the canonical pseudonymization salt lands (an open
|
|
259
|
+
* question — see redaction.ts header), we derive the id from an
|
|
260
|
+
* already-opaque handle: the masked emailHint if present, else a one-way hash of the
|
|
261
|
+
* bridge token, else "anonymous". Never reversible, never raw PII.
|
|
262
|
+
*/
|
|
263
|
+
export function resolveActor(env = process.env) {
|
|
264
|
+
const kind = env.TOT_ACTOR_KIND === "agent" ? "agent" : "dev";
|
|
265
|
+
let seed = "anonymous";
|
|
266
|
+
try {
|
|
267
|
+
const creds = readCredentials(defaultCredentialsPath(env));
|
|
268
|
+
if (creds?.emailHint) seed = `hint:${creds.emailHint}`;
|
|
269
|
+
else if (creds?.activityToken) seed = `tok:${creds.activityToken}`;
|
|
270
|
+
} catch {
|
|
271
|
+
/* fall through to anonymous */
|
|
272
|
+
}
|
|
273
|
+
const id = seed === "anonymous" ? "anonymous" : createHash("sha256").update(seed).digest("hex").slice(0, 16);
|
|
274
|
+
return { kind, id };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The scope threaded onto every CLI event — the storefront-minted invite→problem
|
|
279
|
+
* `traceId` from the credential cache, when present, so a CLI event and the server
|
|
280
|
+
* event it triggers share one trace (matching the existing feedback/heartbeat use).
|
|
281
|
+
*/
|
|
282
|
+
function resolveScope(env = process.env, extra = {}) {
|
|
283
|
+
const scope = /** @type {any} */ ({ ...extra });
|
|
284
|
+
try {
|
|
285
|
+
const creds = readCredentials(defaultCredentialsPath(env));
|
|
286
|
+
if (creds?.traceId && !scope.traceId) scope.traceId = creds.traceId;
|
|
287
|
+
} catch {
|
|
288
|
+
/* best-effort */
|
|
289
|
+
}
|
|
290
|
+
return scope;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Emit one activity event. SILENT NO-OP without a bridge credential (returns
|
|
295
|
+
* `{ sent:false }`) — no network call, never throws, never blocks a command. With
|
|
296
|
+
* a credential it builds the redacted envelope (via createActivityEvent) and POSTs
|
|
297
|
+
* it best-effort to `<url>/api/dev/activity`, bounded by EMIT_TIMEOUT_MS.
|
|
298
|
+
*
|
|
299
|
+
* Returns `{ sent, event?, report? }` so callers/tests can assert what would be
|
|
300
|
+
* emitted without any network. `fetchImpl` and `bridge` are injectable for tests.
|
|
301
|
+
*
|
|
302
|
+
* @param {{ action?: string, outcome?: object, actor?: object, scope?: object,
|
|
303
|
+
* payload?: object, source?: string, env?: NodeJS.ProcessEnv,
|
|
304
|
+
* fetchImpl?: typeof fetch, bridge?: {url:string,token:string}|null }} [input]
|
|
305
|
+
* @returns {Promise<{sent:boolean, event?:object, report?:object, disabled?:boolean}>}
|
|
306
|
+
*/
|
|
307
|
+
export async function emitActivity({
|
|
308
|
+
action,
|
|
309
|
+
outcome,
|
|
310
|
+
actor,
|
|
311
|
+
scope,
|
|
312
|
+
payload,
|
|
313
|
+
source = "cli",
|
|
314
|
+
env = process.env,
|
|
315
|
+
fetchImpl,
|
|
316
|
+
bridge,
|
|
317
|
+
} = {}) {
|
|
318
|
+
try {
|
|
319
|
+
// Kill switch: telemetry off ⇒ true no-op (no build/redaction/network),
|
|
320
|
+
// fail-open — the command is unaffected.
|
|
321
|
+
if (isActivityDisabled(env)) return { sent: false, disabled: true };
|
|
322
|
+
const resolvedBridge = bridge !== undefined ? bridge : resolveActivityBridge(env);
|
|
323
|
+
// Build the redacted event even when we won't send it — so a caller/test can
|
|
324
|
+
// inspect the exact shape, and so redaction always runs on the emit path.
|
|
325
|
+
const { event, report } = createActivityEvent({
|
|
326
|
+
action,
|
|
327
|
+
actor: actor ?? resolveActor(env),
|
|
328
|
+
source,
|
|
329
|
+
outcome,
|
|
330
|
+
scope: resolveScope(env, scope ?? {}),
|
|
331
|
+
payload,
|
|
332
|
+
});
|
|
333
|
+
if (!resolvedBridge) return { sent: false, event, report };
|
|
334
|
+
|
|
335
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
336
|
+
if (typeof doFetch !== "function") return { sent: false, event, report };
|
|
337
|
+
|
|
338
|
+
const controller = new AbortController();
|
|
339
|
+
const timer = setTimeout(() => controller.abort(), EMIT_TIMEOUT_MS);
|
|
340
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
341
|
+
try {
|
|
342
|
+
await doFetch(`${String(resolvedBridge.url).replace(/\/+$/, "")}/api/dev/activity`, {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: {
|
|
345
|
+
"content-type": "application/json",
|
|
346
|
+
authorization: `Bearer ${resolvedBridge.token}`,
|
|
347
|
+
},
|
|
348
|
+
// `event: "activity"` tags the stream for the bridge ingest to route,
|
|
349
|
+
// alongside the standard envelope. Best-effort — an older bridge that doesn't
|
|
350
|
+
// recognize it simply ignores the post.
|
|
351
|
+
body: JSON.stringify({ event: "activity", ...event }),
|
|
352
|
+
signal: controller.signal,
|
|
353
|
+
});
|
|
354
|
+
} catch {
|
|
355
|
+
/* best-effort — a failed/offline/aborted post never disrupts the command */
|
|
356
|
+
} finally {
|
|
357
|
+
clearTimeout(timer);
|
|
358
|
+
}
|
|
359
|
+
return { sent: true, event, report };
|
|
360
|
+
} catch {
|
|
361
|
+
// Absolutely never throw into a command's control flow.
|
|
362
|
+
return { sent: false };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Cap + surface a bit of rendered output for a command-result event. The full raw
|
|
368
|
+
* stdout is NEVER stored — this caps to MAX_VALUE_LEN and hands the string to the
|
|
369
|
+
* redaction mirror via payload.rendered. NB the `cli.command.*` actions carry an
|
|
370
|
+
* EMPTY renderedAllow in the action catalog, so this string is DROPPED at redaction by
|
|
371
|
+
* design (the safe default for a high-volume action) — running it through the
|
|
372
|
+
* mirror is the belt-and-suspenders the contract prescribes, and keeps the emit
|
|
373
|
+
* path honest if the allowlist ever opens. Pure.
|
|
374
|
+
*/
|
|
375
|
+
export function capRendered(text) {
|
|
376
|
+
const s = String(text ?? "").trim();
|
|
377
|
+
if (!s) return undefined;
|
|
378
|
+
return s.slice(0, MAX_VALUE_LEN);
|
|
379
|
+
}
|
package/src/app-scaffold.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Private App scaffolding — `tot app scaffold <name>` (PrivateApps epic
|
|
3
|
-
*
|
|
2
|
+
* Private App scaffolding — `tot app scaffold <name>` (PrivateApps epic).
|
|
3
|
+
* Mirrors `sample.mjs`'s `scaffoldSample`: materialize a bundled
|
|
4
4
|
* template onto disk, idempotently, fully offline. No login, no MCP, no
|
|
5
5
|
* network — a developer building a Storefront Private App gets a runnable
|
|
6
6
|
* skeleton before they've registered anything with Token of Trust.
|
|
@@ -63,14 +63,14 @@ export function scaffoldApp(destDir, { force = false, log = () => {} } = {}) {
|
|
|
63
63
|
const err = new Error(
|
|
64
64
|
`${dir} isn't empty and isn't an app scaffold — scaffold into an empty directory (or pass a new name)`,
|
|
65
65
|
);
|
|
66
|
-
err.code =
|
|
66
|
+
/** @type {any} */ (err).code ="ENOTEMPTY_APP";
|
|
67
67
|
throw err;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const template = appTemplateDir();
|
|
71
71
|
if (!existsSync(template)) {
|
|
72
72
|
const err = new Error("the private-app template isn't available in this release yet — it's coming soon.");
|
|
73
|
-
err.code =
|
|
73
|
+
/** @type {any} */ (err).code ="TEMPLATE_UNAVAILABLE";
|
|
74
74
|
throw err;
|
|
75
75
|
}
|
|
76
76
|
|
package/src/auth.mjs
CHANGED
|
@@ -26,8 +26,13 @@ import { refreshAccessToken, credentialsFromToken } from "./oauth.mjs";
|
|
|
26
26
|
|
|
27
27
|
/** Thrown when no provider can authenticate — carries actionable guidance. */
|
|
28
28
|
export class AuthUnavailableError extends Error {
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} message
|
|
31
|
+
* @param {{ hint?: string|null, reason?: string|null }} [opts]
|
|
32
|
+
*/
|
|
33
|
+
constructor(message, opts = {}) {
|
|
30
34
|
super(message);
|
|
35
|
+
const { hint, reason } = opts;
|
|
31
36
|
this.name = "AuthUnavailableError";
|
|
32
37
|
this.hint = hint || null;
|
|
33
38
|
this.reason = reason || null;
|
|
@@ -72,7 +77,10 @@ export function credentialEmailHint(creds) {
|
|
|
72
77
|
);
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Hosted cockpit URL for regenerating local CLI credentials, when we cached one.
|
|
82
|
+
* @param {any} activityUrl @param {string|null} [emailHint]
|
|
83
|
+
*/
|
|
76
84
|
export function cockpitRecoveryUrl(activityUrl, emailHint = null) {
|
|
77
85
|
if (!activityUrl) return null;
|
|
78
86
|
try {
|
|
@@ -125,10 +133,10 @@ export function warnLegacyOperatorEnv(env = process.env, warn = (m) => console.e
|
|
|
125
133
|
* every tool call carries the developer identity. Fails with a clear next step
|
|
126
134
|
* (never a stack trace) when there's no usable session.
|
|
127
135
|
*
|
|
128
|
-
* @param {import("./mcp.mjs").createMcpClient extends (...a:any)=>infer R ? R : any} client
|
|
136
|
+
* @param {(import("./mcp.mjs").createMcpClient extends (...a:any)=>infer R ? R : any)|null} client
|
|
129
137
|
* @param {NodeJS.ProcessEnv} env
|
|
130
138
|
* @param {{ fetchImpl?: typeof fetch, now?: number }} [deps] injectable for tests
|
|
131
|
-
* @returns {Promise<{ identity: "developer", appDomain: null, token: string }>}
|
|
139
|
+
* @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null }>}
|
|
132
140
|
*/
|
|
133
141
|
export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
134
142
|
const { fetchImpl = fetch, now = Date.now() } = deps;
|
|
@@ -219,7 +227,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
219
227
|
* are never read for auth.
|
|
220
228
|
*
|
|
221
229
|
* @param {ReturnType<import("./mcp.mjs").createMcpClient>} client
|
|
222
|
-
* @param {{ env?: NodeJS.ProcessEnv, initialize?: () => Promise<any
|
|
230
|
+
* @param {{ env?: NodeJS.ProcessEnv, initialize?: () => Promise<any>, prefer?: string }} [opts]
|
|
223
231
|
* @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null }>}
|
|
224
232
|
*/
|
|
225
233
|
export async function establishSession(client, opts = {}) {
|
package/src/candidate-state.mjs
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
* nothing here (backward-compatible with the stateless original). This file only
|
|
8
8
|
* records a DIVERGENCE from that stable default:
|
|
9
9
|
*
|
|
10
|
-
* - `tot submit --
|
|
10
|
+
* - `tot submit --fork-candidate` forks a fresh candidate and remembers it here, so the
|
|
11
11
|
* NEXT plain `tot submit` keeps updating the NEW PR (like pushing more commits
|
|
12
12
|
* to a `gh pr` branch), not the old one; and
|
|
13
13
|
* - a terminal-roll (the active candidate was merged/closed) records the fresh
|
|
14
14
|
* candidate it rolled to, so you're never wedged submitting to a dead PR.
|
|
15
15
|
*
|
|
16
16
|
* ONE file, `~/.tot/candidates.json`, a map keyed by `<mcpUrl>::<repo>` on the
|
|
17
|
-
* DEFAULT branch and `<mcpUrl>::<repo>::<branch>` on any other (
|
|
17
|
+
* DEFAULT branch and `<mcpUrl>::<repo>::<branch>` on any other (branch-bound
|
|
18
18
|
* candidates): a different MCP, repo, OR non-default git branch is a different
|
|
19
19
|
* candidate namespace, so a feature branch gets its OWN candidate PR instead of
|
|
20
20
|
* fighting main's over the same handle. The default branch deliberately keeps the
|
|
@@ -123,7 +123,7 @@ export function clearActiveChangeId(filePath, { mcpUrl, repo, branch }) {
|
|
|
123
123
|
/**
|
|
124
124
|
* A fresh candidate handle forked from a stable base — `<baseId>-<suffix>`, still
|
|
125
125
|
* matching candidate_open's `[a-z0-9._-]` handle grammar. The suffix defaults to
|
|
126
|
-
* 6 random hex chars (so two `--
|
|
126
|
+
* 6 random hex chars (so two `--fork-candidate` runs never collide); tests inject a fixed
|
|
127
127
|
* suffix. Pure given `suffix`.
|
|
128
128
|
*/
|
|
129
129
|
export function mintFreshChangeId(baseId, suffix = randomBytes(3).toString("hex")) {
|