@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.
Files changed (54) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +148 -57
  3. package/package.json +6 -1
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +4 -4
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +3 -3
  8. package/src/commands/accept.mjs +498 -59
  9. package/src/commands/app/dev.mjs +8 -4
  10. package/src/commands/app/index.mjs +3 -3
  11. package/src/commands/app/scaffold.mjs +1 -1
  12. package/src/commands/branches.mjs +297 -0
  13. package/src/commands/cleanup.mjs +264 -0
  14. package/src/commands/clone.mjs +307 -25
  15. package/src/commands/dev.mjs +440 -156
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +180 -0
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +428 -0
  21. package/src/commands/ideas.mjs +2 -2
  22. package/src/commands/link.mjs +2 -2
  23. package/src/commands/login.mjs +5 -6
  24. package/src/commands/pr.mjs +62 -25
  25. package/src/commands/preview-build.mjs +6 -6
  26. package/src/commands/preview-doctor.mjs +225 -0
  27. package/src/commands/preview-retry-evidence.mjs +156 -0
  28. package/src/commands/preview.mjs +19 -3
  29. package/src/commands/revert.mjs +322 -0
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +51 -14
  32. package/src/commands/start.mjs +101 -59
  33. package/src/commands/submit.mjs +1183 -169
  34. package/src/commands/sync.mjs +203 -0
  35. package/src/commands/validate.mjs +10 -4
  36. package/src/commands/whoami.mjs +1 -1
  37. package/src/dev-heartbeat.mjs +3 -2
  38. package/src/dev-logs.mjs +2 -2
  39. package/src/errors.mjs +11 -4
  40. package/src/git-credential.mjs +257 -0
  41. package/src/last-tenant.mjs +1 -1
  42. package/src/mcp.mjs +6 -1
  43. package/src/merge-doctor-report.mjs +208 -0
  44. package/src/no-gitea-links.test.mjs +55 -0
  45. package/src/oauth.mjs +18 -14
  46. package/src/obstacle-beacon.cjs +2 -2
  47. package/src/obstacle.mjs +1 -1
  48. package/src/plan.mjs +83 -15
  49. package/src/sample.mjs +4 -4
  50. package/src/validate.mjs +187 -15
  51. package/src/vendor/private-apps-devkit.mjs +3 -3
  52. package/src/viewer-session.mjs +118 -0
  53. package/template/private-app/README.md +12 -6
  54. package/src/commands/retire.mjs +0 -203
@@ -0,0 +1,208 @@
1
+ /**
2
+ * merge-doctor-report — the SHARED, pure rendering + one-GET fetch of the hosted
3
+ * merge-doctor seam (`GET /<tenant>/api/preview/merge-doctor`), so every
4
+ * CLI surface speaks ONE doctor taxonomy:
5
+ *
6
+ * - `tot preview doctor` (preview-doctor.mjs) — the full, ordered report on demand.
7
+ * - `tot accept` / `tot ship` (accept.mjs / ship.mjs) — the PUSH: on a failed
8
+ * accept/integrate or a refused ship, auto-append the COMPACT summary right where
9
+ * the failure already surfaced, so a developer doesn't have to remember to run the
10
+ * doctor themselves.
11
+ *
12
+ * The render helpers here are a faithful PORT of the analyzer's own
13
+ * formatReport/attentionBanner/supportContext (mergeDoctor.ts §render / the
14
+ * scripts/tenant/gitea-merge-doctor.mjs mirror) — the published `@tokenoftrust/cli`
15
+ * is dependency-free and cannot import the app/scripts source, so this port is kept in
16
+ * sync with that taxonomy by hand.
17
+ *
18
+ * Dependency-light: `fail` + global fetch only. Imports NOTHING from the command
19
+ * modules (accept/ship/preview-doctor), so accept.mjs and ship.mjs can both depend on
20
+ * it without a cycle (preview-doctor.mjs already imports ship.mjs for the operator
21
+ * secret; routing the shared pieces through here keeps ship.mjs ⟷ preview-doctor.mjs
22
+ * acyclic).
23
+ */
24
+
25
+ /** The hosted seam path — one GET, one answer (composed server-side over the SAME
26
+ * reads the admin Publish tab builds, run through the shared analyzer). */
27
+ export const DOCTOR_PATH = "/api/preview/merge-doctor";
28
+
29
+ /** Who resolves a finding — the ticket-deflection axis. */
30
+ export const OWNERSHIP = { developer: "developer", platform: "platform", operator: "operator" };
31
+ export const SEV_GLYPH = { blocker: "✗", warn: "⚠", info: "•" };
32
+ export const OWN_TAG = { developer: "you", platform: "on-us", operator: "housekeeping" };
33
+
34
+ /**
35
+ * Defensively read the endpoint body into the analysis shape. The endpoint is a
36
+ * trust boundary, so a plausible field gap degrades rather than crashes. Pure.
37
+ * @param {any} data
38
+ * @returns {{ scopeKnown:boolean, verdict:string, findings:any[], counts:{blocker:number,warn:number,info:number} }}
39
+ */
40
+ export function normalizeAnalysis(data) {
41
+ const o = data && typeof data === "object" ? data : {};
42
+ const findings = Array.isArray(o.findings) ? o.findings : [];
43
+ const c = o.counts && typeof o.counts === "object" ? o.counts : {};
44
+ const counts = {
45
+ blocker: Number.isFinite(c.blocker) ? c.blocker : findings.filter((f) => f?.severity === "blocker").length,
46
+ warn: Number.isFinite(c.warn) ? c.warn : findings.filter((f) => f?.severity === "warn").length,
47
+ info: Number.isFinite(c.info) ? c.info : findings.filter((f) => f?.severity === "info").length,
48
+ };
49
+ return {
50
+ scopeKnown: o.scopeKnown === true,
51
+ verdict: typeof o.verdict === "string" ? o.verdict : "(no verdict returned)",
52
+ findings,
53
+ counts,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * The one-line banner shown at the top of the report — counts only
59
+ * DEVELOPER-actionable findings (what a person can self-serve); platform faults are
60
+ * narrated separately by {@link supportContext}. Pure.
61
+ */
62
+ export function attentionBanner(result) {
63
+ const dev = result.findings.filter((f) => f.ownership === OWNERSHIP.developer && f.severity !== "info");
64
+ const fixable = dev.filter((f) => f.action).length;
65
+ if (!dev.length) {
66
+ const platform = result.findings.filter((f) => f.ownership === OWNERSHIP.platform && f.severity === "blocker");
67
+ if (platform.length) return `${platform.length} issue(s) are on us — Retry, then Report to support if they persist.`;
68
+ return "Nothing needs your attention.";
69
+ }
70
+ const conflicts = dev.filter((f) => f.code === "PR_CONFLICT").length;
71
+ const builds = dev.filter((f) => f.code === "NOT_BUILT_PR").length;
72
+ const bits = [];
73
+ if (conflicts) bits.push(`${conflicts} conflict(s)${conflicts <= fixable ? " (1-click fix)" : ""}`);
74
+ if (builds) bits.push(`${builds} need a build`);
75
+ return `${dev.length} change(s) need your attention: ${bits.join(", ") || "see below"}.`;
76
+ }
77
+
78
+ /**
79
+ * The PRE-FILLED support escalation, generated ONLY when there are PLATFORM-owned
80
+ * findings (a retry didn't clear it) — so "I'm stuck" becomes a structured report.
81
+ * Returns null when nothing is platform-owned. Pure.
82
+ */
83
+ export function supportContext(result) {
84
+ const platform = result.findings.filter((f) => f.ownership === OWNERSHIP.platform);
85
+ if (!platform.length) return null;
86
+ const lines = [
87
+ `Tenant scope: ${result.scopeKnown ? "supplied" : "unknown"}`,
88
+ `Verdict: ${result.verdict}`,
89
+ "Platform-owned issues (a retry did not clear these — please investigate):",
90
+ ...platform.map((f) => ` • [${f.code}] ${f.subject} — ${f.detail}`),
91
+ "Next diagnostic hop: scripts/preview/pipeline-doctor.sh <tenant> --commit <headSha> (reconcile plane).",
92
+ ];
93
+ return lines.join("\n");
94
+ }
95
+
96
+ /** Render the analysis as the compact, ordered, agent-cheap report. Pure. */
97
+ export function formatReport(result) {
98
+ const lines = [];
99
+ lines.push("== gitea-merge-doctor ==");
100
+ lines.push(`VERDICT: ${result.verdict}`);
101
+ lines.push(attentionBanner(result));
102
+ lines.push("");
103
+ for (const f of result.findings) {
104
+ const act = f.action ? ` [action: ${f.action.label}]` : "";
105
+ lines.push(`${SEV_GLYPH[f.severity] ?? "?"} [${f.code}] (${OWN_TAG[f.ownership] ?? f.ownership}) ${f.subject}${act}`);
106
+ lines.push(` ${f.detail}`);
107
+ lines.push(` → ${f.remedy}`);
108
+ }
109
+ lines.push("");
110
+ lines.push(
111
+ `${result.counts.blocker} blocker(s), ${result.counts.warn} warning(s), ${result.counts.info} info. First ✗/⚠ above is the thing to fix.`,
112
+ );
113
+ const support = supportContext(result);
114
+ if (support) {
115
+ lines.push("");
116
+ lines.push("── if a platform issue persists after Retry, escalate with this (no free-text “stuck”): ──");
117
+ lines.push(support);
118
+ }
119
+ return lines.join("\n");
120
+ }
121
+
122
+ // ── Auto-surface on failure (the PUSH) ───────────────────────────────────────
123
+
124
+ /** How many findings the COMPACT summary lists before it defers the rest to the
125
+ * full `tot preview doctor` report — enough to name the thing to fix, not the whole
126
+ * multi-page report at a failure moment. */
127
+ export const COMPACT_FINDING_LIMIT = 3;
128
+
129
+ /**
130
+ * Render the COMPACT "here's what's blocking you" summary appended to a failed
131
+ * accept/integrate or a refused ship. Unlike {@link formatReport} (the full
132
+ * on-demand report), this lists ONLY the actionable (blocker/warn) findings, capped
133
+ * at {@link COMPACT_FINDING_LIMIT}, and points at the full report for the rest. Returns
134
+ * an EMPTY array when there's nothing actionable to say (a clean/info-only verdict, or
135
+ * no analysis at all) — so a failure whose cause the doctor can't see gets NO noise
136
+ * appended. Pure — returns the lines to print, never prints itself.
137
+ * @param {ReturnType<typeof normalizeAnalysis>|null} result
138
+ * @param {{ limit?: number }} [opts]
139
+ * @returns {string[]}
140
+ */
141
+ export function formatCompactSummary(result, { limit = COMPACT_FINDING_LIMIT } = {}) {
142
+ if (!result || !Array.isArray(result.findings)) return [];
143
+ const actionable = result.findings.filter((f) => f?.severity === "blocker" || f?.severity === "warn");
144
+ if (!actionable.length) return [];
145
+ const lines = [`\n ── merge doctor — what's blocking your merges:`];
146
+ lines.push(` ${attentionBanner(result)}`);
147
+ for (const f of actionable.slice(0, limit)) {
148
+ const act = f.action?.label ? ` [${f.action.label}]` : "";
149
+ lines.push(` ${SEV_GLYPH[f.severity] ?? "?"} [${f.code}] (${OWN_TAG[f.ownership] ?? f.ownership}) ${f.subject}${act}`);
150
+ if (f.remedy) lines.push(` → ${f.remedy}`);
151
+ }
152
+ const more = actionable.length - limit;
153
+ lines.push(
154
+ more > 0
155
+ ? ` …and ${more} more — run \`tot preview doctor\` for the full report.`
156
+ : ` Run \`tot preview doctor\` for the full report.`,
157
+ );
158
+ return lines;
159
+ }
160
+
161
+ /**
162
+ * One GET to the hosted merge-doctor over an ALREADY-RESOLVED transport (the SAME
163
+ * `base` + `authHeaders` the calling verb used to reach `/api/changes` /
164
+ * `/api/changes/ship`), normalized to the analysis shape. Best-effort by contract:
165
+ * ANY failure (unreachable, non-2xx incl. the 401/403 an auth-refused caller would
166
+ * also hit, non-JSON body, a throw) resolves to `null` — the doctor is a diagnostic
167
+ * ADD-ON at a failure moment, so it must never itself become a second failure. Pure
168
+ * given the injected fetch.
169
+ * @param {{ base:string, authHeaders:Record<string,string> }} transport
170
+ * @param {typeof fetch} [fetchImpl]
171
+ * @returns {Promise<ReturnType<typeof normalizeAnalysis>|null>}
172
+ */
173
+ export async function fetchDoctorAnalysis({ base, authHeaders }, fetchImpl = globalThis.fetch) {
174
+ try {
175
+ const res = await fetchImpl(`${String(base).replace(/\/+$/, "")}${DOCTOR_PATH}`, {
176
+ method: "GET",
177
+ headers: authHeaders,
178
+ });
179
+ if (!res || !res.ok) return null;
180
+ let data = {};
181
+ try {
182
+ data = await res.json();
183
+ } catch {
184
+ return null;
185
+ }
186
+ return normalizeAnalysis(data);
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Entry point: fetch the merge-doctor over the given transport and render the
194
+ * COMPACT failure summary. Returns the lines to print (or `[]` when there's nothing
195
+ * actionable / the doctor couldn't be reached). Never throws — a caller can
196
+ * `for (const l of await autoSurfaceDoctor(...)) console.error(l)` unconditionally at
197
+ * a failure return without touching the exit code.
198
+ * @param {{ base:string, authHeaders:Record<string,string>, fetchImpl?:typeof fetch, limit?:number }} opts
199
+ * @returns {Promise<string[]>}
200
+ */
201
+ export async function autoSurfaceDoctor({ base, authHeaders, fetchImpl = globalThis.fetch, limit }) {
202
+ try {
203
+ const analysis = await fetchDoctorAnalysis({ base, authHeaders }, fetchImpl);
204
+ return formatCompactSummary(analysis, { limit });
205
+ } catch {
206
+ return [];
207
+ }
208
+ }
@@ -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
+ });
package/src/oauth.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  * 5. exchange code -> { access_token, refresh_token, expires_in } with the PKCE
15
15
  * verifier, and hand back a credentials record the token-store persists.
16
16
  *
17
- * B3 adds the RFC 8628 device-authorization grant (deviceLoginFlow, below) for
17
+ * This adds the RFC 8628 device-authorization grant (deviceLoginFlow, below) for
18
18
  * headless/SSH/no-browser boxes where the loopback can never be reached — same
19
19
  * dynamically-registered client_id, same credentials shape, just a different
20
20
  * dance: print a code, poll the token endpoint until it's approved elsewhere.
@@ -38,7 +38,7 @@ const b64url = (buf) =>
38
38
  buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
39
39
 
40
40
  /** Thrown by loginFlow() when no browser opener exists on this box. Callers
41
- * catch it to fall through to deviceLoginFlow() (B3) instead of hanging. */
41
+ * catch it to fall through to deviceLoginFlow() instead of hanging. */
42
42
  export class NoOpenerError extends Error {
43
43
  constructor(authorizeUrl) {
44
44
  super("no browser opener available on this machine");
@@ -84,7 +84,7 @@ export async function registerClient(
84
84
  headers: { "Content-Type": "application/json", Accept: "application/json" },
85
85
  body: JSON.stringify({
86
86
  client_name: CLIENT_NAME,
87
- // Include the device-code grant: the browserless rendezvous + B3 device flows
87
+ // Include the device-code grant: the browserless rendezvous + device flows
88
88
  // redeem their device_code at /oauth/token with this grant, so a client
89
89
  // registered WITHOUT it gets "unauthorized_client: grant_type is invalid".
90
90
  grant_types: [
@@ -188,7 +188,7 @@ export function startLoopbackListener({ host = "127.0.0.1" } = {}) {
188
188
  let settle, reject;
189
189
  const callback = new Promise((res, rej) => { settle = res; reject = rej; });
190
190
  const server = http.createServer((req, res) => {
191
- const u = new URL(req.url, `http://${host}`);
191
+ const u = new URL(req.url ?? "/", `http://${host}`);
192
192
  if (u.pathname !== "/callback") {
193
193
  res.writeHead(404, { "Content-Type": "text/plain" });
194
194
  res.end("not found");
@@ -204,12 +204,12 @@ export function startLoopbackListener({ host = "127.0.0.1" } = {}) {
204
204
  settle({ code: u.searchParams.get("code"), state: u.searchParams.get("state") });
205
205
  }
206
206
  });
207
- const listening = new Promise((res, rej) => {
207
+ const listening = /** @type {Promise<void>} */ (new Promise((res, rej) => {
208
208
  server.once("error", rej);
209
209
  server.listen(0, host, () => res());
210
- });
210
+ }));
211
211
  return {
212
- async ready() { await listening; return server.address().port; },
212
+ async ready() { await listening; return /** @type {import("net").AddressInfo} */ (server.address()).port; },
213
213
  waitForCallback() { return callback; },
214
214
  close() { try { server.close(); } catch { /* already closed */ } },
215
215
  };
@@ -247,7 +247,7 @@ export async function loginFlow({
247
247
  clientId,
248
248
  fetchImpl = fetch,
249
249
  open = openBrowser,
250
- log = () => {},
250
+ log = /** @type {(m?: string) => void} */ (() => {}),
251
251
  now = () => Date.now(),
252
252
  }) {
253
253
  const meta = await discoverMetadata(mcpUrl, fetchImpl);
@@ -266,8 +266,8 @@ export async function loginFlow({
266
266
  const opened = open(authorizeUrl);
267
267
  if (!opened) {
268
268
  // No opener on this box (headless/SSH) — the loopback can never be hit
269
- // from here, so waiting on it would hang forever. Let the caller (B3:
270
- // login.mjs#loginAndCache) fall through to deviceLoginFlow() instead.
269
+ // from here, so waiting on it would hang forever. Let the caller
270
+ // (login.mjs#loginAndCache) fall through to deviceLoginFlow() instead.
271
271
  throw new NoOpenerError(authorizeUrl);
272
272
  }
273
273
 
@@ -364,7 +364,7 @@ export async function rendezvousLoginFlow({
364
364
  mcpUrl,
365
365
  code,
366
366
  fetchImpl = fetch,
367
- log = () => {},
367
+ log = /** @type {(m?: string) => void} */ (() => {}),
368
368
  sleep = delay,
369
369
  now = () => Date.now(),
370
370
  }) {
@@ -408,7 +408,7 @@ export async function rendezvousLoginFlow({
408
408
  });
409
409
  }
410
410
 
411
- // ── B3: device-code grant (RFC 8628) — headless/SSH/no-browser sign-in ────────
411
+ // ── Device-code grant (RFC 8628) — headless/SSH/no-browser sign-in ────────
412
412
 
413
413
  /**
414
414
  * RFC 8628 §3.1 — request a device_code + user_code to display. Same request
@@ -474,11 +474,15 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifi
474
474
  * expires), honoring the server's `interval` and the `slow_down` backoff
475
475
  * (RFC 8628 §3.5: +5s, keep polling — not a failure). Injectable `sleep`/
476
476
  * `now` so it's testable with no real waiting.
477
+ * @param {string} tokenEndpoint
478
+ * @param {{ deviceCode: any, clientId: any, codeVerifier?: any, intervalSec?: any, expiresInSec?: any }} params
479
+ * @param {typeof fetch} [fetchImpl]
480
+ * @param {{ sleep?: Function, now?: () => number }} [timing]
477
481
  * @returns {Promise<object>} the raw token response (→ credentialsFromToken)
478
482
  */
479
483
  export async function pollDeviceToken(
480
484
  tokenEndpoint,
481
- { deviceCode, clientId, codeVerifier, intervalSec, expiresInSec },
485
+ { deviceCode, clientId, codeVerifier = undefined, intervalSec, expiresInSec },
482
486
  fetchImpl = fetch,
483
487
  { sleep = delay, now = () => Date.now() } = {},
484
488
  ) {
@@ -505,7 +509,7 @@ export async function deviceLoginFlow({
505
509
  mcpUrl,
506
510
  clientId,
507
511
  fetchImpl = fetch,
508
- log = () => {},
512
+ log = /** @type {(m?: string) => void} */ (() => {}),
509
513
  sleep = delay,
510
514
  now = () => Date.now(),
511
515
  }) {
@@ -103,9 +103,9 @@ function beacon(opts, done) {
103
103
 
104
104
  /** Promise wrapper for the ESM side (src/obstacle.mjs) so a failure path can await delivery. */
105
105
  function beaconAsync(opts) {
106
- return new Promise(function (resolve) {
106
+ return /** @type {Promise<void>} */ (new Promise(function (resolve) {
107
107
  try { beacon(opts, resolve); } catch (e) { resolve(); }
108
- });
108
+ }));
109
109
  }
110
110
 
111
111
  module.exports = { parseActivityArgs: parseActivityArgs, beacon: beacon, beaconAsync: beaconAsync };
package/src/obstacle.mjs CHANGED
@@ -19,7 +19,7 @@ import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
19
19
  * Best-effort obstacle beacon for a post-login failure. No-op (silent) when no
20
20
  * bridge credential is cached — the developer signed in with a build that didn't
21
21
  * carry the activity flags, or ran a bare `tot login`.
22
- * @param {"pnpm-missing"|"install-failed"|"clone-failed"} kind
22
+ * @param {"pnpm-missing"|"install-failed"|"clone-failed"|"renderer-native-bindings-missing"} kind
23
23
  * @param {{ have?: string, need?: string, env?: NodeJS.ProcessEnv }} [opts]
24
24
  */
25
25
  export async function emitObstacle(kind, { have, need, env = process.env } = {}) {
package/src/plan.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * The shared "operation plan" affordance (unit U10) — the load-bearing
3
- * cross-cutting requirement from decision `operator-verb-and-hosting-model`:
4
- * every MUTATING operator verb (build / accept / ship / retire) must STATE
2
+ * The shared "operation plan" affordance — the load-bearing
3
+ * cross-cutting requirement that
4
+ * every MUTATING operator verb (build / accept / ship / cleanup) must STATE
5
5
  * EXACTLY what it will do — which PR is queued/integrated/deployed, which
6
6
  * deploy targets (preview / live) are touched, and their URLs — and get an
7
7
  * explicit confirm before acting. (`accept` now queue-integrates a PR into the
8
- * `preview` aggregate — unit b08 — rather than merging it to main.) No silent multi-step mutations, in either surface (the CLI
8
+ * `preview` aggregate — rather than merging it to main.) No silent multi-step mutations, in either surface (the CLI
9
9
  * here, and `AdminPublishTab.astro`'s confirm dialog, which renders the same
10
10
  * shape of plan text server-side/inline).
11
11
  *
@@ -15,8 +15,8 @@
15
15
  * half — print the plan, then gate on an explicit yes (reusing `prompt.mjs`'s
16
16
  * TTY-safe `promptYesNo`; a non-TTY without `--yes` never silently proceeds).
17
17
  *
18
- * SHIP has ONE meaning (unit b10 — supersedes the retired context-dependent
19
- * ship, decision `ship-context-dependent-semantics`): it publishes the
18
+ * SHIP has ONE meaning (supersedes the retired context-dependent ship
19
+ * semantics): it publishes the
20
20
  * tenant's CURRENT GREEN AGGREGATE — the batch of PRs that integrated
21
21
  * cleanly — to live. No merge, no PR/candidate targeting, no developer-vs-
22
22
  * operator branching. The plan states the pinned aggregate sha, its
@@ -46,19 +46,23 @@ function targetLabel({ pr, changeId }) {
46
46
  * console output, no network, no prompting.
47
47
  *
48
48
  * @param {{
49
- * action: "build"|"accept"|"ship"|"retire",
49
+ * action: "build"|"accept"|"ship"|"revert"|"cleanup"|"hotfix",
50
50
  * tenant: string,
51
51
  * pr?: number|string|null,
52
52
  * changeId?: string|null,
53
53
  * headSha?: string|null,
54
+ * integrationSha?: string|null,
54
55
  * endpoint?: string|null,
55
56
  * targets?: { preview?: string|null, live?: string|null },
56
57
  * context?: "developer"|"operator",
57
58
  * pinnedSha?: string|null,
58
59
  * artifactDigest?: string|null,
59
- * includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>,
60
+ * includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|null,
60
61
  * rollbackTarget?: { receiptId: string, aggregateSha: string } | null,
61
62
  * paywall?: { allowed: boolean, message?: string|null } | null,
63
+ * refs?: Array<{ ref: string, sha?: string|null, reason?: string|null }>|null,
64
+ * bypassedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|null,
65
+ * bypassedPreviewSha?: string|null,
62
66
  * }} params
63
67
  * @returns {string[]} plan lines (no leading/trailing blank line)
64
68
  */
@@ -68,6 +72,7 @@ export function planForAction({
68
72
  pr = null,
69
73
  changeId = null,
70
74
  headSha = null,
75
+ integrationSha = null,
71
76
  endpoint = null,
72
77
  targets = {},
73
78
  context = "operator",
@@ -76,6 +81,9 @@ export function planForAction({
76
81
  includedPrs = null,
77
82
  rollbackTarget = null,
78
83
  paywall = null,
84
+ refs = null,
85
+ bypassedPrs = null,
86
+ bypassedPreviewSha = null,
79
87
  }) {
80
88
  void context; // retained param — no action currently branches on it (ship, the
81
89
  // last one that did, is now ONE meaning; kept so a future action can opt in).
@@ -85,6 +93,7 @@ export function planForAction({
85
93
  if (pr != null && `${pr}`.trim()) lines.push(` PR: #${pr}`);
86
94
  if (changeId) lines.push(` change id: ${changeId}`);
87
95
  if (headSha) lines.push(` head sha: ${headSha}`);
96
+ if (integrationSha) lines.push(` integration sha: ${integrationSha}`);
88
97
  if (endpoint) lines.push(` endpoint: POST ${endpoint}`);
89
98
 
90
99
  switch (action) {
@@ -96,7 +105,7 @@ export function planForAction({
96
105
  break;
97
106
  }
98
107
  case "accept": {
99
- // Accept now means QUEUE-INTEGRATE-INTO-PREVIEW (unit b08), NOT merge-to-main:
108
+ // Accept now means QUEUE-INTEGRATE-INTO-PREVIEW, NOT merge-to-main:
100
109
  // the candidate lands in the protected `preview` aggregate (serialized merge →
101
110
  // rebuild → combined-evidence gate), and the aggregate goes live only later via
102
111
  // `tot ship`. So the plan states the integration, never a merge or a go-live.
@@ -107,8 +116,8 @@ export function planForAction({
107
116
  break;
108
117
  }
109
118
  case "ship": {
110
- // ONE meaning (b10): publish the tenant's CURRENT GREEN AGGREGATE to live.
111
- // No merge — b09's orchestrator ships the already-materialized, already-
119
+ // ONE meaning: publish the tenant's CURRENT GREEN AGGREGATE to live.
120
+ // No merge — the orchestrator ships the already-materialized, already-
112
121
  // reviewed digest. State exactly WHAT that is: the pinned sha, the
113
122
  // content-addressed artifact digest, every included PR, the rollback
114
123
  // target, and the go-live paywall verdict — so the human reviews the
@@ -136,8 +145,65 @@ export function planForAction({
136
145
  }
137
146
  break;
138
147
  }
139
- case "retire": {
140
- lines.push(` effect: evict ${label}'s preview environment + version (rebuildable).`);
148
+ case "cleanup": {
149
+ // Branch GC: delete ONLY the exact terminal refs a fresh
150
+ // server-side classification (candidate_list) marked eligible — never by
151
+ // age alone, never main/preview, never an orphan. State the exact set so
152
+ // the human confirms precisely what will be removed, not "some branches".
153
+ const list = Array.isArray(refs) ? refs : [];
154
+ lines.push(
155
+ ` effect: delete ${list.length} terminal candidate branch(es) — never main/preview, ` +
156
+ "never by age alone, never a quarantined orphan.",
157
+ );
158
+ for (const r of list) {
159
+ const shortSha = r?.sha ? ` ${String(r.sha).slice(0, 8)}` : "";
160
+ lines.push(` - ${r?.ref}${shortSha}${r?.reason ? ` — ${r.reason}` : ""}`);
161
+ }
162
+ break;
163
+ }
164
+ case "revert": {
165
+ // Revert: REMOVE already-integrated content from the protected `preview`
166
+ // aggregate by creating a NEW auditable revert commit — never a force-reset,
167
+ // never a branch delete. The aggregate rebuilds and ships only when green
168
+ // again. State exactly that: preview-only, a new commit, NO touch to main.
169
+ const from = tenant ? `${tenant}'s preview aggregate` : "the preview aggregate";
170
+ const what = integrationSha ? `integration ${integrationSha}` : label;
171
+ lines.push(
172
+ ` effect: revert ${what} out of ${from} — a NEW revert commit, NO force-reset, NO merge to main, NO go-live.`,
173
+ );
174
+ break;
175
+ }
176
+ case "hotfix": {
177
+ // Hotfix: the EXPLICIT EXCEPTION lane. Release the reviewed fix from
178
+ // `main` to live, EXCLUDING the unshipped `preview` work — then automatically
179
+ // forward-integrate `main` into `preview` and re-validate. State exactly that,
180
+ // and — critically — list the unshipped preview work this deliberately BYPASSES,
181
+ // so the human confirms an unmistakable exception, not an ordinary ship.
182
+ lines.push(
183
+ ` effect: OWNER HOTFIX — release ${label} from main to live${targets.live ? ` (${targets.live})` : ""}, ` +
184
+ `EXCLUDING the unshipped preview head, then forward-integrate main → preview + revalidate.`,
185
+ );
186
+ if (Array.isArray(bypassedPrs)) {
187
+ if (bypassedPrs.length === 0) {
188
+ lines.push(" bypasses: (nothing — preview has no unshipped work)");
189
+ } else {
190
+ lines.push(` ⚠ BYPASSES the unshipped preview work (${bypassedPrs.length}) — NOT included in this hotfix:`);
191
+ for (const p of bypassedPrs) {
192
+ const prLabel = p && p.prNumber != null ? `#${p.prNumber}` : p?.changeId || "(no PR)";
193
+ const shortSha = p && p.headSha ? ` ${String(p.headSha).slice(0, 8)}` : "";
194
+ lines.push(` - ${prLabel}${shortSha}`);
195
+ }
196
+ }
197
+ }
198
+ if (bypassedPreviewSha) lines.push(` preview head (bypassed): ${bypassedPreviewSha}`);
199
+ lines.push(
200
+ rollbackTarget
201
+ ? ` rollback to: ${rollbackTarget.aggregateSha} (receipt ${rollbackTarget.receiptId})`
202
+ : " rollback to: (none — first-ever ship)",
203
+ );
204
+ if (paywall && paywall.allowed === false) {
205
+ lines.push(` ⚠ paywall: ${paywall.message || "go-live is blocked by the storefront subscription gate"}`);
206
+ }
141
207
  break;
142
208
  }
143
209
  default: {
@@ -148,12 +214,14 @@ export function planForAction({
148
214
  return lines;
149
215
  }
150
216
 
151
- /** "build" → "Build-on-demand", "accept" → "Accept", "ship" "Ship", "retire" → "Retire". Pure. */
217
+ /** "build" → "Build-on-demand", "accept" → "Accept", "hotfix" → "Hotfix". Pure. */
152
218
  function titleFor(action) {
153
219
  if (action === "build") return "Build-on-demand";
154
220
  if (action === "accept") return "Accept";
155
221
  if (action === "ship") return "Ship";
156
- if (action === "retire") return "Retire";
222
+ if (action === "revert") return "Revert";
223
+ if (action === "cleanup") return "Cleanup";
224
+ if (action === "hotfix") return "Hotfix (owner-only exception lane)";
157
225
  return action ? action[0].toUpperCase() + action.slice(1) : "Operation";
158
226
  }
159
227
 
package/src/sample.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * (Earlier this borrowed a real vape merchant's identity + content to get a
23
23
  * populated catalog; that leaked private merchant IP into public npm and was
24
- * replaced — see the H1/H2 runner-sanitization work — with this generic sample.)
24
+ * replaced — see the runner-sanitization work — with this generic sample.)
25
25
  *
26
26
  * Dependency-free (node:fs + node:path only).
27
27
  */
@@ -94,7 +94,7 @@ export function pickNvmrcVersion(env = process.env) {
94
94
  const best = readdirSync(root)
95
95
  .map((name) => /^v(\d+)\.(\d+)\.(\d+)$/.exec(name))
96
96
  .filter((m) => m && nodeMeetsFloor(m.slice(1).join(".")))
97
- .map((m) => m.slice(1).map(Number))
97
+ .map((m) => /** @type {RegExpExecArray} */ (m).slice(1).map(Number))
98
98
  .sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2])[0];
99
99
  if (best) return best.join(".");
100
100
  } catch {
@@ -177,7 +177,7 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
177
177
  const err = new Error(
178
178
  `${dir} isn't empty and isn't a sample checkout — scaffold into an empty directory (or pass a new --workspace)`,
179
179
  );
180
- err.code = "ENOTEMPTY_SAMPLE";
180
+ /** @type {any} */ (err).code ="ENOTEMPTY_SAMPLE";
181
181
  throw err;
182
182
  }
183
183
 
@@ -187,7 +187,7 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
187
187
  "the sample store isn't available in this release yet — it's coming soon. " +
188
188
  "To build a real store now: `tot login --code <invite>` then `tot start`.",
189
189
  );
190
- err.code = "SAMPLE_UNAVAILABLE";
190
+ /** @type {any} */ (err).code ="SAMPLE_UNAVAILABLE";
191
191
  throw err;
192
192
  }
193
193