@tokenoftrust/cli 1.4.0-rc.20 → 1.4.0-rc.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tot.mjs +52 -54
- package/package.json +6 -1
- package/src/activity.mjs +5 -4
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +2 -2
- package/src/commands/accept.mjs +489 -50
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +1 -0
- package/src/commands/cleanup.mjs +2 -1
- package/src/commands/clone.mjs +51 -20
- package/src/commands/dev.mjs +30 -12
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +6 -2
- package/src/commands/grants.mjs +6 -4
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +3 -4
- package/src/commands/pr.mjs +6 -5
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview.mjs +9 -1
- package/src/commands/rollback.mjs +6 -4
- package/src/commands/ship.mjs +19 -1
- package/src/commands/start.mjs +59 -11
- package/src/commands/submit.mjs +526 -68
- package/src/commands/sync.mjs +11 -0
- package/src/commands/validate.mjs +10 -4
- package/src/dev-heartbeat.mjs +2 -1
- package/src/errors.mjs +8 -4
- package/src/git-credential.mjs +185 -0
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/oauth.mjs +12 -8
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +3 -3
- package/src/sample.mjs +3 -3
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
package/src/commands/sync.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { execFileSync } from "node:child_process";
|
|
33
33
|
import { fail } from "../errors.mjs";
|
|
34
|
+
import { ensureTokenlessRemote } from "../git-credential.mjs";
|
|
34
35
|
|
|
35
36
|
/** The protected branch `tot sync` fetches + merges from by default. */
|
|
36
37
|
export const DEFAULT_SYNC_BRANCH = "preview";
|
|
@@ -159,6 +160,16 @@ export async function run(argv, ctx) {
|
|
|
159
160
|
}
|
|
160
161
|
};
|
|
161
162
|
|
|
163
|
+
// Self-heal a LEGACY checkout (unit u10, absorbs u7): `tot login` refreshes
|
|
164
|
+
// this CLI's own session, never the token baked into a checkout's remote at
|
|
165
|
+
// clone time — the exact reason a stale checkout's `git fetch origin` (below)
|
|
166
|
+
// used to 401 even right after signing back in. Best-effort, never blocks sync.
|
|
167
|
+
try {
|
|
168
|
+
ensureTokenlessRemote(git);
|
|
169
|
+
} catch {
|
|
170
|
+
/* best-effort — see above */
|
|
171
|
+
}
|
|
172
|
+
|
|
162
173
|
// Refuse a dirty tree up front — a merge on top of uncommitted edits is how
|
|
163
174
|
// local work gets silently entangled with the merge, and we never sweep
|
|
164
175
|
// anything in with `git add -A`. Commit or stash first, then re-run.
|
|
@@ -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
|
|
@@ -92,7 +93,12 @@ export function run(argv, ctx) {
|
|
|
92
93
|
|
|
93
94
|
const errors = findings.filter((f) => f.level === ERROR);
|
|
94
95
|
const warns = findings.filter((f) => f.level === WARN);
|
|
95
|
-
|
|
96
|
+
// Never present the checkout PATH as if it were the tenant name — when the
|
|
97
|
+
// tenant couldn't be resolved (e.g. an invalid/missing .tot/config.json),
|
|
98
|
+
// the findings below say why; the header should say so too, not disguise
|
|
99
|
+
// a directory as a domain.
|
|
100
|
+
const label = target.tenantId ? target.tenantId : `(tenant unresolved) ${target.dir}`;
|
|
101
|
+
console.log(`\ntot validate — ${label}\n`);
|
|
96
102
|
for (const f of findings) {
|
|
97
103
|
const tag = f.level === ERROR ? "✗" : "⚠";
|
|
98
104
|
console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
|
package/src/dev-heartbeat.mjs
CHANGED
|
@@ -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/errors.mjs
CHANGED
|
@@ -32,13 +32,17 @@ 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
|
-
constructor(what,
|
|
44
|
+
constructor(what, opts = {}) {
|
|
45
|
+
const { next, exitCode = 1, cause } = opts;
|
|
42
46
|
super(what);
|
|
43
47
|
this.name = "CliError";
|
|
44
48
|
this.what = what;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for `tot` acting as a git credential helper (unit u10,
|
|
3
|
+
* workstream tot-merge-conflict-resolution-ux — absorbs u7's `tot fetch`
|
|
4
|
+
* self-heal idea) — so a checkout never needs a LIVE forge token baked into
|
|
5
|
+
* `.git/config`'s remote URL. `tot clone` configures a fresh checkout's
|
|
6
|
+
* `credential.helper` to `CREDENTIAL_HELPER` (git's own extension point for
|
|
7
|
+
* exactly this — see `git help gitcredentials`), which git then invokes with
|
|
8
|
+
* `get`/`store`/`erase` on every network operation instead of reading a
|
|
9
|
+
* persisted secret. This module holds the pieces BOTH the `git-credential`
|
|
10
|
+
* command (src/commands/git-credential.mjs, which mints fresh tokens via the
|
|
11
|
+
* MCP) and the self-heal migration (called at the top of every command that
|
|
12
|
+
* touches git — submit/sync/… — so ANY CLI touch of a legacy checkout
|
|
13
|
+
* migrates it) share: parsing/formatting git's credential protocol, the
|
|
14
|
+
* on-disk credential cache, and rewriting a checkout's remote to drop its
|
|
15
|
+
* embedded token.
|
|
16
|
+
*
|
|
17
|
+
* Dependency-free — node:fs/os/path/crypto only. The MCP mint itself (real
|
|
18
|
+
* network I/O) lives in the command layer, which this module never imports.
|
|
19
|
+
*/
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { readCredentials, writeCredentials } from "./token-store.mjs";
|
|
24
|
+
|
|
25
|
+
/** The git config value that routes credential requests through `tot` — the
|
|
26
|
+
* `!` tells git to run this as a shell command (git appends the operation,
|
|
27
|
+
* e.g. `get`, as the final argument) — the same convention `gh auth
|
|
28
|
+
* git-credential` uses. Repo-LOCAL only (never --global): a checkout not
|
|
29
|
+
* built with `tot` must never have its credential resolution silently
|
|
30
|
+
* redirected. */
|
|
31
|
+
export const CREDENTIAL_HELPER = "!tot git-credential";
|
|
32
|
+
|
|
33
|
+
/** How long a minted credential is trusted before `tot git-credential get`
|
|
34
|
+
* mints a fresh one — comfortably under the forge push token's own
|
|
35
|
+
* multi-hour expiry (see pushPreviewRef in submit.mjs), so a long-running
|
|
36
|
+
* session still self-refreshes well before the cached one goes stale. */
|
|
37
|
+
export const CREDENTIAL_TTL_MS = 20 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split an authenticated forge remote URL (basic-auth `user:token@host`, as
|
|
41
|
+
* the MCP mints it via `tenant_checkout`) into its tokenless public URL +
|
|
42
|
+
* the embedded credential, so the token can be handed to git EPHEMERALLY for
|
|
43
|
+
* one operation instead of being persisted in `.git/config`. Returns null
|
|
44
|
+
* when the URL won't parse or carries no token — the caller then falls back
|
|
45
|
+
* to the checkout's existing remote. Pure — unit-tested.
|
|
46
|
+
* @param {string} remoteUrl
|
|
47
|
+
* @returns {{ publicUrl: string, username: string, token: string }|null}
|
|
48
|
+
*/
|
|
49
|
+
export function splitAuthedRemote(remoteUrl) {
|
|
50
|
+
try {
|
|
51
|
+
const u = new URL(String(remoteUrl));
|
|
52
|
+
const token = u.password ? decodeURIComponent(u.password) : "";
|
|
53
|
+
if (!token) return null;
|
|
54
|
+
const username = u.username ? decodeURIComponent(u.username) : "";
|
|
55
|
+
return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The `http.extraheader` value that hands a basic-auth credential to a
|
|
63
|
+
* SINGLE git invocation (base64 of `user:token`) — so a freshly-minted forge
|
|
64
|
+
* token authenticates one operation without ever being written to
|
|
65
|
+
* `.git/config`. Pure — unit-tested.
|
|
66
|
+
* @param {string} username
|
|
67
|
+
* @param {string} token
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function basicAuthExtraHeader(username, token) {
|
|
71
|
+
const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
|
|
72
|
+
return `Authorization: Basic ${b64}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse git's credential-helper protocol (key=value lines, terminated by a
|
|
77
|
+
* blank line or EOF) into a plain object. A malformed line is skipped rather
|
|
78
|
+
* than throwing — git's own helpers are lenient the same way. Pure.
|
|
79
|
+
* @param {string} text
|
|
80
|
+
* @returns {Record<string,string>}
|
|
81
|
+
*/
|
|
82
|
+
export function parseCredentialInput(text) {
|
|
83
|
+
/** @type {Record<string,string>} */
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const line of String(text).split("\n")) {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (!trimmed) continue;
|
|
88
|
+
const i = trimmed.indexOf("=");
|
|
89
|
+
if (i <= 0) continue;
|
|
90
|
+
out[trimmed.slice(0, i)] = trimmed.slice(i + 1);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format a credential response for git's `get` operation — only the fields
|
|
97
|
+
* present are emitted (git only needs `username`/`password` filled in;
|
|
98
|
+
* echoing `protocol`/`host` back is harmless and conventional). Pure.
|
|
99
|
+
* @param {Record<string,string|undefined|null>} fields
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function formatCredentialOutput(fields) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
for (const key of ["protocol", "host", "path", "username", "password"]) {
|
|
105
|
+
if (fields[key] !== undefined && fields[key] !== null) lines.push(`${key}=${fields[key]}`);
|
|
106
|
+
}
|
|
107
|
+
return `${lines.join("\n")}\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A filesystem-safe, collision-resistant cache key for one (tenant, tag) —
|
|
111
|
+
* hashed (not the raw tenant string) so an unusual tenant name can never
|
|
112
|
+
* escape `~/.tot/git-credentials/` or collide across tags. Pure. */
|
|
113
|
+
export function credentialCacheKey(tenant, tag) {
|
|
114
|
+
return createHash("sha256").update(`${tenant}|${tag}`).digest("hex").slice(0, 32);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Absolute path to one (tenant, tag)'s cached credential — same `~/.tot`
|
|
118
|
+
* root (and `TOT_HOME` override) as the OAuth session cache. */
|
|
119
|
+
export function credentialCachePath(tenant, tag, env = process.env) {
|
|
120
|
+
const home = env.TOT_HOME || homedir();
|
|
121
|
+
return join(home, ".tot", "git-credentials", `${credentialCacheKey(tenant, tag)}.json`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Is a cached credential still trusted? A cache with no `mintedAt` is
|
|
125
|
+
* treated as stale (mint fresh rather than trust an unknown age). Pure. */
|
|
126
|
+
export function isFreshCredential(cred, { now = Date.now(), ttlMs = CREDENTIAL_TTL_MS } = {}) {
|
|
127
|
+
return Boolean(cred && cred.username && cred.password && cred.mintedAt && now - cred.mintedAt < ttlMs);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read a cached credential (reusing token-store.mjs's generic reader), or
|
|
131
|
+
* null if absent/unreadable/malformed/stale. Never throws. */
|
|
132
|
+
export function readCachedCredential(filePath, opts = {}) {
|
|
133
|
+
const cred = readCredentials(filePath);
|
|
134
|
+
return isFreshCredential(cred, opts) ? cred : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Cache a freshly-minted credential — atomic write, owner-only permissions
|
|
138
|
+
* (0600 in a 0700 dir, via token-store.mjs's writer): this file holds a LIVE
|
|
139
|
+
* forge push token, same security bar as the OAuth session cache. */
|
|
140
|
+
export function writeCachedCredential(filePath, { username, password }, { now = Date.now() } = {}) {
|
|
141
|
+
writeCredentials(filePath, { username, password, mintedAt: now });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Self-heal a LEGACY checkout: if `origin`'s remote still carries an
|
|
146
|
+
* embedded token (the pre-u10 `tot clone` shape, or one predating `tot
|
|
147
|
+
* login` entirely), strip it — rewriting the remote to the tokenless public
|
|
148
|
+
* URL — and install the credential helper so future git operations mint
|
|
149
|
+
* fresh creds through `tot` instead of relying on a token that silently
|
|
150
|
+
* expires. Meant to be called at the top of every command that touches git,
|
|
151
|
+
* best-effort (the caller decides how to handle a thrown error — this never
|
|
152
|
+
* blocks the actual command on a migration hiccup). A no-op on an
|
|
153
|
+
* already-migrated, tokenless, or non-http(s) (e.g. ssh) remote.
|
|
154
|
+
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
155
|
+
* @returns {{ migrated: boolean }}
|
|
156
|
+
*/
|
|
157
|
+
export function ensureTokenlessRemote(git) {
|
|
158
|
+
let remote;
|
|
159
|
+
try {
|
|
160
|
+
remote = git(["remote", "get-url", "origin"]).trim();
|
|
161
|
+
} catch {
|
|
162
|
+
return { migrated: false }; // no `origin` (or not a git repo) — nothing to migrate
|
|
163
|
+
}
|
|
164
|
+
let migrated = false;
|
|
165
|
+
try {
|
|
166
|
+
const u = new URL(remote);
|
|
167
|
+
if (u.password) {
|
|
168
|
+
git(["remote", "set-url", "origin", `${u.protocol}//${u.host}${u.pathname}`]);
|
|
169
|
+
migrated = true;
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
return { migrated }; // not a parseable URL (e.g. an ssh remote) — leave it alone entirely
|
|
173
|
+
}
|
|
174
|
+
let helper = "";
|
|
175
|
+
try {
|
|
176
|
+
helper = git(["config", "--local", "--get", "credential.helper"]).trim();
|
|
177
|
+
} catch {
|
|
178
|
+
/* unset — falls through to configuring it below */
|
|
179
|
+
}
|
|
180
|
+
if (helper !== CREDENTIAL_HELPER) {
|
|
181
|
+
git(["config", "--local", "credential.helper", CREDENTIAL_HELPER]);
|
|
182
|
+
migrated = true;
|
|
183
|
+
}
|
|
184
|
+
return { migrated };
|
|
185
|
+
}
|
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`, unit A2), 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 C5 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
|
+
// ── C5: 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 (C5). 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
|
+
* C5 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
|
@@ -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);
|
|
@@ -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
|
}) {
|
|
@@ -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
|
@@ -57,11 +57,11 @@ function targetLabel({ pr, changeId }) {
|
|
|
57
57
|
* context?: "developer"|"operator",
|
|
58
58
|
* pinnedSha?: string|null,
|
|
59
59
|
* artifactDigest?: string|null,
|
|
60
|
-
* includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }
|
|
60
|
+
* includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|null,
|
|
61
61
|
* rollbackTarget?: { receiptId: string, aggregateSha: string } | null,
|
|
62
62
|
* paywall?: { allowed: boolean, message?: string|null } | null,
|
|
63
|
-
* refs?: Array<{ ref: string, sha?: string|null, reason?: string|null }
|
|
64
|
-
* bypassedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|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
65
|
* bypassedPreviewSha?: string|null,
|
|
66
66
|
* }} params
|
|
67
67
|
* @returns {string[]} plan lines (no leading/trailing blank line)
|
package/src/sample.mjs
CHANGED
|
@@ -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
|
|