@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
|
@@ -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
|
-
*
|
|
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()
|
|
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 +
|
|
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
|
|
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
|
-
// ──
|
|
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
|
}) {
|
package/src/obstacle-beacon.cjs
CHANGED
|
@@ -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
|
|
3
|
-
* cross-cutting requirement
|
|
4
|
-
* every MUTATING operator verb (build / accept / ship /
|
|
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 —
|
|
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 (
|
|
19
|
-
*
|
|
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"|"
|
|
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
|
|
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
|
|
111
|
-
// No merge —
|
|
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 "
|
|
140
|
-
|
|
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",
|
|
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 === "
|
|
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
|
|
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 =
|
|
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 =
|
|
190
|
+
/** @type {any} */ (err).code ="SAMPLE_UNAVAILABLE";
|
|
191
191
|
throw err;
|
|
192
192
|
}
|
|
193
193
|
|