@tokenoftrust/cli 1.4.1 → 2.0.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 +58 -79
- package/package.json +6 -1
- package/src/activity.mjs +15 -14
- 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 +65 -38
- 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 +4 -3
- package/src/commands/cleanup.mjs +7 -11
- package/src/commands/clone.mjs +23 -20
- package/src/commands/dev.mjs +42 -24
- package/src/commands/doctor.mjs +4 -4
- package/src/commands/git-credential.mjs +2 -2
- package/src/commands/go-live.mjs +9 -5
- package/src/commands/grants.mjs +7 -5
- package/src/commands/hotfix.mjs +1 -1
- 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 +33 -19
- 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 +5 -5
- package/src/commands/rollback.mjs +18 -16
- package/src/commands/ship.mjs +29 -12
- package/src/commands/start.mjs +61 -51
- package/src/commands/submit.mjs +360 -50
- package/src/commands/sync.mjs +2 -2
- package/src/commands/validate.mjs +4 -3
- 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 +94 -21
- package/src/last-tenant.mjs +1 -1
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/oauth.mjs +18 -14
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +16 -21
- package/src/sample.mjs +4 -4
- package/src/validate.mjs +135 -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/commands/sync.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `tot sync` — the common candidate CONFLICT-RECOVERY path (branch-lifecycle
|
|
3
3
|
* contract, docs/architecture/branch-lifecycle-and-integration-preview.md,
|
|
4
|
-
* "Two developers touch the same file"
|
|
4
|
+
* "Two developers touch the same file"):
|
|
5
5
|
*
|
|
6
6
|
* Both candidate previews may be green in isolation. After the first
|
|
7
7
|
* integrates, the second may become conflicted. The queue blocks that PR
|
|
@@ -160,7 +160,7 @@ export async function run(argv, ctx) {
|
|
|
160
160
|
}
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
-
// Self-heal a LEGACY checkout
|
|
163
|
+
// Self-heal a LEGACY checkout: `tot login` refreshes
|
|
164
164
|
// this CLI's own session, never the token baked into a checkout's remote at
|
|
165
165
|
// clone time — the exact reason a stale checkout's `git fetch origin` (below)
|
|
166
166
|
// used to 401 even right after signing back in. Best-effort, never blocks sync.
|
|
@@ -71,12 +71,13 @@ export function run(argv, ctx) {
|
|
|
71
71
|
console.error(fail(target.error, "tot clone <tenant>, or pass --workspace <dir>"));
|
|
72
72
|
return 2;
|
|
73
73
|
}
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const dir = /** @type {string} */ (target.dir);
|
|
75
|
+
if (!existsSync(dir)) {
|
|
76
|
+
console.error(fail(`no tenant directory at ${dir}`, "confirm the path, or `tot clone <tenant>`"));
|
|
76
77
|
return 2;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
const { ok, findings } = validateTenant(
|
|
80
|
+
const { ok, findings } = validateTenant(dir, {
|
|
80
81
|
tenantId: target.tenantId ?? undefined,
|
|
81
82
|
scope: target.scope ?? undefined,
|
|
82
83
|
// A checkout / bare-tenant target is served by the ToT storefront platform, so
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -67,7 +67,7 @@ export async function run(argv, _ctx) {
|
|
|
67
67
|
} else if (listErr) {
|
|
68
68
|
console.log(` (couldn't list your stores: ${listErr} — try \`tot login\` again)`);
|
|
69
69
|
} else {
|
|
70
|
-
// Status-aware
|
|
70
|
+
// Status-aware: an UNLINKED identity is told to link (not "may
|
|
71
71
|
// still be propagating" — that's only the genuine zero-grants case).
|
|
72
72
|
const g = noStoresGuidance(listResp);
|
|
73
73
|
console.log(` (${g.headline})`);
|
package/src/dev-heartbeat.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI-side heartbeat for the local→hosted activity bridge
|
|
2
|
+
* CLI-side heartbeat for the local→hosted activity bridge.
|
|
3
3
|
*
|
|
4
4
|
* While `tot dev` / `tot start` is running, the CLI (which knows its own
|
|
5
5
|
* version, the resolved runner version, the port + tenant, and the cached
|
|
@@ -35,7 +35,8 @@ const OS_INFO = { os: os.platform(), osVersion: os.release(), arch: os.arch() };
|
|
|
35
35
|
* an activity URL and a bearer token. Never throws — a failed/offline hosted
|
|
36
36
|
* worker just means the cockpit doesn't light up this beat.
|
|
37
37
|
* @param {{ activityUrl?: string, token?: string, url?: string,
|
|
38
|
-
* cliVersion?: string, runnerVersion?: string|null, editor?: string|null
|
|
38
|
+
* cliVersion?: string, runnerVersion?: string|null, editor?: string|null,
|
|
39
|
+
* cwd?: string|null }} args
|
|
39
40
|
*/
|
|
40
41
|
export function postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion, editor, cwd } = {}) {
|
|
41
42
|
if (!activityUrl || !token) return undefined;
|
package/src/dev-logs.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { milestoneBanner, DEVELOPER_COCKPIT } from "./banner.mjs";
|
|
|
18
18
|
* noise, and pass anything else through (indented) so nothing important is
|
|
19
19
|
* hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
|
|
20
20
|
*
|
|
21
|
-
* The FIRST save→reload is the aha milestone
|
|
21
|
+
* The FIRST save→reload is the aha milestone: with a `cockpitUrl` it's a
|
|
22
22
|
* PROMINENT banner that sends the developer back to their Developer Cockpit to
|
|
23
23
|
* see the change; every reload after that is the quiet "↻ your store reloaded"
|
|
24
24
|
* line so a working dev loop doesn't get spammed with banners.
|
|
@@ -35,7 +35,7 @@ export function streamDevLogs(
|
|
|
35
35
|
// the runner/Vite "HH:MM:SS " timestamp prefix (see `body` below), so a
|
|
36
36
|
// timestamped internal line like "10:50:17 [vite] connected" is still dropped.
|
|
37
37
|
// The node:* / ExperimentalWarning / (node:NNNN) / "--trace-warnings" and the
|
|
38
|
-
// boot "fatal: not a git repository" lines are dropped too
|
|
38
|
+
// boot "fatal: not a git repository" lines are dropped too — scary,
|
|
39
39
|
// non-actionable boot noise that reads as a broken first run.
|
|
40
40
|
const NOISE =
|
|
41
41
|
/^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d|\(node:\d+\)|ExperimentalWarning|node:internal\/|\(Use `node --trace-warnings|fatal: not a git repository)/i;
|
package/src/errors.mjs
CHANGED
|
@@ -32,13 +32,20 @@ function versionFooter() {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* A failure worth surfacing with a concrete next step.
|
|
35
|
+
* @typedef {object} CliErrorOpts
|
|
36
|
+
* @property {string} [next] - the exact command (or one-line instruction) to run next.
|
|
37
|
+
* @property {number} [exitCode] - process exit code to use (default 1).
|
|
38
|
+
* @property {unknown} [cause]
|
|
39
|
+
*
|
|
35
40
|
* @param {string} what - what went wrong, in plain words.
|
|
36
|
-
* @param {
|
|
37
|
-
* next - the exact command (or one-line instruction) to run next.
|
|
38
|
-
* exitCode - process exit code to use (default 1).
|
|
41
|
+
* @param {CliErrorOpts} [opts]
|
|
39
42
|
*/
|
|
40
43
|
export class CliError extends Error {
|
|
41
|
-
|
|
44
|
+
/** True when retrying the operation cannot change its outcome. */
|
|
45
|
+
permanent;
|
|
46
|
+
|
|
47
|
+
constructor(what, opts = {}) {
|
|
48
|
+
const { next, exitCode = 1, cause } = opts;
|
|
42
49
|
super(what);
|
|
43
50
|
this.name = "CliError";
|
|
44
51
|
this.what = what;
|
package/src/git-credential.mjs
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
|
-
import { join } from "node:path";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
23
|
+
import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
23
24
|
import { readCredentials, writeCredentials } from "./token-store.mjs";
|
|
24
25
|
|
|
25
26
|
/** The git config value that routes credential requests through `tot` — the
|
|
@@ -80,6 +81,7 @@ export function basicAuthExtraHeader(username, token) {
|
|
|
80
81
|
* @returns {Record<string,string>}
|
|
81
82
|
*/
|
|
82
83
|
export function parseCredentialInput(text) {
|
|
84
|
+
/** @type {Record<string,string>} */
|
|
83
85
|
const out = {};
|
|
84
86
|
for (const line of String(text).split("\n")) {
|
|
85
87
|
const trimmed = line.trim();
|
|
@@ -140,20 +142,98 @@ export function writeCachedCredential(filePath, { username, password }, { now =
|
|
|
140
142
|
writeCredentials(filePath, { username, password, mintedAt: now });
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/** Single-quote a string for safe interpolation into the /bin/sh shim. Pure. */
|
|
146
|
+
function shq(s) {
|
|
147
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Absolute path to the stable, node-version-independent credential-helper shim
|
|
151
|
+
* under `~/.tot/bin` (honors `TOT_HOME`). A checkout's git config points at THIS by
|
|
152
|
+
* absolute path — never a bare `!tot` — so `nvm use` (which swaps the per-node `tot`)
|
|
153
|
+
* can't silently redirect forge auth to an older/missing CLI. Pure. */
|
|
154
|
+
export function forgeShimPath(env = process.env) {
|
|
155
|
+
const home = env.TOT_HOME || homedir();
|
|
156
|
+
return join(home, ".tot", "bin", "tot-git-credential");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The shim's contents: pin the ABSOLUTE node + CLI entry active at write time, with a
|
|
160
|
+
* PATH-search fallback for node, and FAIL LOUD to stderr (never silently) when no node
|
|
161
|
+
* is found — so a broken helper says how to fix itself instead of yielding an opaque
|
|
162
|
+
* "Repository not found". Pure — unit-tested. */
|
|
163
|
+
export function renderForgeShim(nodePath, entryPath) {
|
|
164
|
+
return [
|
|
165
|
+
"#!/bin/sh",
|
|
166
|
+
"# Managed by tot — stable forge credential helper (regenerated each run; do not edit).",
|
|
167
|
+
"# Decouples git auth from which node/tot is active in the shell (nvm-proof).",
|
|
168
|
+
`NODE=${shq(nodePath)}`,
|
|
169
|
+
'[ -x "$NODE" ] || NODE="$(command -v node 2>/dev/null)"',
|
|
170
|
+
'if [ -z "$NODE" ]; then',
|
|
171
|
+
' echo "tot: no node runtime for the git credential helper — reinstall: npm i -g @tokenoftrust/cli@latest" >&2',
|
|
172
|
+
" exit 1",
|
|
173
|
+
"fi",
|
|
174
|
+
`exec "$NODE" ${shq(entryPath)} git-credential "$@"`,
|
|
175
|
+
"",
|
|
176
|
+
].join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Write/refresh the shim (0700 dir, 0755 file). Best-effort — returns the shim path,
|
|
180
|
+
* or "" if it couldn't be written (caller then falls back to the bare `!tot` helper). */
|
|
181
|
+
export function writeForgeShim(env = process.env, nodePath = process.execPath, entryPath = process.argv[1]) {
|
|
182
|
+
try {
|
|
183
|
+
if (!nodePath || !entryPath) return "";
|
|
184
|
+
const p = forgeShimPath(env);
|
|
185
|
+
mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
|
|
186
|
+
writeFileSync(p, renderForgeShim(nodePath, entryPath), { mode: 0o755 });
|
|
187
|
+
chmodSync(p, 0o755);
|
|
188
|
+
return p;
|
|
189
|
+
} catch {
|
|
190
|
+
return "";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Install (or refresh) the HOST-SCOPED forge credential helper on a checkout so that:
|
|
196
|
+
* - it runs the stable `~/.tot` shim by ABSOLUTE path (nvm-proof), not a bare `!tot`;
|
|
197
|
+
* - a leading EMPTY reset value clears any inherited GLOBAL helper for this host — the
|
|
198
|
+
* fix for `credential.helper = osxkeychain` (every Mac dev) running first, returning
|
|
199
|
+
* a stale forge cred, and shadowing our helper so `git pull` 404s even after login.
|
|
200
|
+
* Idempotent: a no-op when the host-scoped list is already `["", <ourHelper>]`. Returns
|
|
201
|
+
* whether it changed anything. `writeShim` is injected for tests. Pure git I/O otherwise.
|
|
202
|
+
* @param {(cargs: string[]) => string} git
|
|
203
|
+
* @param {{ host?: string, env?: NodeJS.ProcessEnv, nodePath?: string, entryPath?: string, writeShim?: typeof writeForgeShim }} [options]
|
|
204
|
+
* @returns {boolean} changed
|
|
205
|
+
*/
|
|
206
|
+
export function installForgeCredentialHelper(git, {
|
|
207
|
+
host, env = process.env, nodePath = process.execPath, entryPath = process.argv[1],
|
|
208
|
+
writeShim = writeForgeShim,
|
|
209
|
+
} = {}) {
|
|
210
|
+
if (!host) return false;
|
|
211
|
+
const shim = writeShim(env, nodePath, entryPath);
|
|
212
|
+
const helperValue = shim ? `!${shim}` : CREDENTIAL_HELPER;
|
|
213
|
+
const key = `credential.https://${host}.helper`;
|
|
214
|
+
let raw = null;
|
|
215
|
+
try { raw = git(["config", "--local", "--get-all", key]); } catch { raw = null; }
|
|
216
|
+
// git prints one value per line (an empty value = an empty line); our desired list is
|
|
217
|
+
// ["", helperValue] → "\n<helperValue>". Already correct ⇒ nothing to do.
|
|
218
|
+
if (raw !== null && raw.replace(/\n+$/, "") === `\n${helperValue}`) return false;
|
|
219
|
+
try { git(["config", "--local", "--unset-all", key]); } catch { /* none set yet */ }
|
|
220
|
+
git(["config", "--local", "--add", key, ""]); // reset: clear inherited (osxkeychain) for this host
|
|
221
|
+
git(["config", "--local", "--add", key, helperValue]); // our helper is now the sole one
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
143
225
|
/**
|
|
144
|
-
* Self-heal a LEGACY checkout:
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* best-effort (the caller decides how to handle a thrown error — this never
|
|
151
|
-
* blocks the actual command on a migration hiccup). A no-op on an
|
|
152
|
-
* already-migrated, tokenless, or non-http(s) (e.g. ssh) remote.
|
|
226
|
+
* Self-heal a LEGACY checkout, best-effort: strip any embedded token from `origin`
|
|
227
|
+
* (the pre-u10 shape whose token silently expires), then install the stable, host-scoped
|
|
228
|
+
* credential helper (see installForgeCredentialHelper) so future git ops mint fresh creds
|
|
229
|
+
* through the `~/.tot` shim — nvm-proof and un-shadowable by osxkeychain. Called at the top
|
|
230
|
+
* of every command that touches git. A no-op on an already-migrated remote; leaves a
|
|
231
|
+
* non-http(s) (e.g. ssh) remote entirely alone. Never blocks the command on a hiccup.
|
|
153
232
|
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
233
|
+
* @param {{ env?: NodeJS.ProcessEnv, nodePath?: string, entryPath?: string, writeShim?: typeof writeForgeShim }} [opts]
|
|
154
234
|
* @returns {{ migrated: boolean }}
|
|
155
235
|
*/
|
|
156
|
-
export function ensureTokenlessRemote(git) {
|
|
236
|
+
export function ensureTokenlessRemote(git, opts = {}) {
|
|
157
237
|
let remote;
|
|
158
238
|
try {
|
|
159
239
|
remote = git(["remote", "get-url", "origin"]).trim();
|
|
@@ -161,8 +241,10 @@ export function ensureTokenlessRemote(git) {
|
|
|
161
241
|
return { migrated: false }; // no `origin` (or not a git repo) — nothing to migrate
|
|
162
242
|
}
|
|
163
243
|
let migrated = false;
|
|
244
|
+
let host = "";
|
|
164
245
|
try {
|
|
165
246
|
const u = new URL(remote);
|
|
247
|
+
host = u.host;
|
|
166
248
|
if (u.password) {
|
|
167
249
|
git(["remote", "set-url", "origin", `${u.protocol}//${u.host}${u.pathname}`]);
|
|
168
250
|
migrated = true;
|
|
@@ -170,15 +252,6 @@ export function ensureTokenlessRemote(git) {
|
|
|
170
252
|
} catch {
|
|
171
253
|
return { migrated }; // not a parseable URL (e.g. an ssh remote) — leave it alone entirely
|
|
172
254
|
}
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
helper = git(["config", "--local", "--get", "credential.helper"]).trim();
|
|
176
|
-
} catch {
|
|
177
|
-
/* unset — falls through to configuring it below */
|
|
178
|
-
}
|
|
179
|
-
if (helper !== CREDENTIAL_HELPER) {
|
|
180
|
-
git(["config", "--local", "credential.helper", CREDENTIAL_HELPER]);
|
|
181
|
-
migrated = true;
|
|
182
|
-
}
|
|
255
|
+
if (installForgeCredentialHelper(git, { ...opts, host })) migrated = true;
|
|
183
256
|
return { migrated };
|
|
184
257
|
}
|
package/src/last-tenant.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The "last tenant" cache for `tot start` (
|
|
2
|
+
* The "last tenant" cache for `tot start` (smart zero-arg default). After
|
|
3
3
|
* a multi-store identity picks (or is told) a tenant, remember it here so a
|
|
4
4
|
* bare `tot start` on the next run just goes instead of re-prompting.
|
|
5
5
|
*
|
package/src/mcp.mjs
CHANGED
|
@@ -142,7 +142,12 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
142
142
|
return parsed?.result;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
/**
|
|
145
|
+
/**
|
|
146
|
+
* Call an MCP tool and unwrap its structured / text result to a plain object.
|
|
147
|
+
* @param {string} name
|
|
148
|
+
* @param {unknown} [args]
|
|
149
|
+
* @returns {Promise<any>}
|
|
150
|
+
*/
|
|
146
151
|
async function callTool(name, args) {
|
|
147
152
|
const r = await callRaw("tools/call", { name, arguments: args });
|
|
148
153
|
if (r?.structuredContent) return r.structuredContent;
|
|
@@ -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
|
+
}
|
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 } = {}) {
|