@tokenoftrust/cli 1.4.0 → 1.4.1
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 +115 -3
- package/package.json +1 -1
- package/src/activity.mjs +378 -0
- package/src/commands/accept.mjs +445 -33
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +289 -10
- package/src/commands/dev.mjs +401 -135
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/pr.mjs +30 -7
- package/src/commands/revert.mjs +322 -0
- package/src/commands/ship.mjs +24 -4
- package/src/commands/start.mjs +40 -8
- package/src/commands/submit.mjs +839 -135
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +6 -1
- package/src/git-credential.mjs +184 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/plan.mjs +75 -2
- package/src/validate.mjs +52 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot git-credential` — git's own credential-helper protocol (see `git help
|
|
3
|
+
* gitcredentials`), implemented over the developer's cached `tot login`
|
|
4
|
+
* session (unit u10, workstream tot-merge-conflict-resolution-ux). `tot
|
|
5
|
+
* clone` configures a fresh checkout's `credential.helper` to run this (see
|
|
6
|
+
* ../git-credential.mjs's CREDENTIAL_HELPER), so `git fetch`/`git push`/`git
|
|
7
|
+
* pull` — run DIRECTLY by the developer, not just through `tot preview` —
|
|
8
|
+
* transparently mint a fresh forge token instead of ever needing one
|
|
9
|
+
* persisted in `.git/config`. This is the fix for the "the panel-prescribed
|
|
10
|
+
* `git fetch origin` fails even after `tot login`" dead-end: `tot login`
|
|
11
|
+
* only ever refreshed the CLI's OWN MCP session, never the token baked into
|
|
12
|
+
* a checkout's remote URL at clone time.
|
|
13
|
+
*
|
|
14
|
+
* git invokes this with ONE positional arg (`get`/`store`/`erase`) and the
|
|
15
|
+
* request on stdin (key=value lines, blank-line/EOF terminated). Only `get`
|
|
16
|
+
* does real work — this CLI never persists a forge credential of its own
|
|
17
|
+
* beyond the short-lived cache in ../git-credential.mjs, so `store`/`erase`
|
|
18
|
+
* are no-ops (git calls them after a successful/failed auth respectively; we
|
|
19
|
+
* just drain stdin and exit 0, the correct behavior for a stateless helper).
|
|
20
|
+
*
|
|
21
|
+
* FAILS SILENT, NEVER LOUD: `get` prints NOTHING and exits non-zero on any
|
|
22
|
+
* problem (not signed in, MCP unreachable, cwd isn't a recognizable tenant
|
|
23
|
+
* checkout) — git then falls through to its next configured helper or its
|
|
24
|
+
* own prompt, exactly as if this helper weren't configured. A stack trace or
|
|
25
|
+
* a malformed credential line here would otherwise corrupt EVERY git
|
|
26
|
+
* operation in the checkout. It also NEVER triggers an interactive sign-in —
|
|
27
|
+
* this runs as a non-interactive subprocess of `git`, so a missing session
|
|
28
|
+
* fails through rather than trying to open a browser mid-`git fetch`.
|
|
29
|
+
*
|
|
30
|
+
* Dependency-free (global fetch + `git`, via the same MCP client + auth
|
|
31
|
+
* module every other command uses).
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
36
|
+
import { establishSession } from "../auth.mjs";
|
|
37
|
+
import { checkoutTenant } from "./clone.mjs";
|
|
38
|
+
import { detectContext } from "../context.mjs";
|
|
39
|
+
import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
|
|
40
|
+
import {
|
|
41
|
+
parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
|
|
42
|
+
credentialCachePath, readCachedCredential, writeCachedCredential,
|
|
43
|
+
} from "../git-credential.mjs";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
46
|
+
|
|
47
|
+
/** Does `remoteUrl`'s host equal `host` (case-insensitive)? An unparseable or
|
|
48
|
+
* missing remote URL never matches — fail closed. Exported for testing. */
|
|
49
|
+
export function hostMatches(remoteUrl, host) {
|
|
50
|
+
if (!remoteUrl || !host) return false;
|
|
51
|
+
try {
|
|
52
|
+
// scp-like remotes (git@host:owner/repo.git) have no scheme — synthesize one so URL can parse the host.
|
|
53
|
+
const normalized = /^[^/]+@[^/:]+:/.test(remoteUrl) ? `ssh://${remoteUrl.replace(":", "/")}` : remoteUrl;
|
|
54
|
+
return new URL(normalized).host.toLowerCase() === host.toLowerCase();
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read the credential request git writes to stdin — blocking is correct
|
|
61
|
+
* here: git writes the request then closes its end, so this returns as
|
|
62
|
+
* soon as it's fully sent. Never blocks on an interactive terminal (git
|
|
63
|
+
* NEVER attaches a TTY to a credential helper's stdin — only a human
|
|
64
|
+
* poking at this command directly by hand would) and never throws (an
|
|
65
|
+
* unreadable/absent stdin is treated as an empty request). Exported so a
|
|
66
|
+
* test can inject a canned request instead of touching real fd 0. */
|
|
67
|
+
export function readStdin() {
|
|
68
|
+
if (process.stdin.isTTY) return "";
|
|
69
|
+
try {
|
|
70
|
+
return readFileSync(0, "utf8");
|
|
71
|
+
} catch {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint (or reuse a cached) forge credential for the tenant the CURRENT
|
|
78
|
+
* directory checks out — the same `tenant_checkout` MCP call `tot clone`
|
|
79
|
+
* itself uses, so the credential is exactly as push-capable. Returns null on
|
|
80
|
+
* ANY failure (wrong dir, not signed in, MCP unreachable) rather than
|
|
81
|
+
* throwing — `get` treats null as "say nothing, exit non-zero".
|
|
82
|
+
*
|
|
83
|
+
* `createClient`/`establish`/`checkout` are injected (default to the real MCP
|
|
84
|
+
* client + auth + clone.mjs's checkoutTenant) purely so this is testable
|
|
85
|
+
* without a live MCP — same DI shape as chooseChangeId's injected `mint` /
|
|
86
|
+
* resolveDeveloperSession's injected `fetchImpl` elsewhere in this CLI.
|
|
87
|
+
* SCOPED TO THE CHECKOUT'S OWN REMOTE: `expectedHost` (git's requested host,
|
|
88
|
+
* from the credential-helper request) is checked against the host of this
|
|
89
|
+
* checkout's `origin` remote before a credential is ever minted or returned
|
|
90
|
+
* — a `get` for any OTHER host returns null (silent fail), never handing the
|
|
91
|
+
* tenant's forge token to a host this checkout doesn't itself push to. This
|
|
92
|
+
* matters because `credential.helper` is invoked per-URL by git, and a
|
|
93
|
+
* globally-scoped helper (or a checkout with a submodule / unrelated remote)
|
|
94
|
+
* must not become a way to exfiltrate the token to an arbitrary host.
|
|
95
|
+
* @param {{
|
|
96
|
+
* env?: NodeJS.ProcessEnv, cwd?: string, expectedHost?: string,
|
|
97
|
+
* createClient?: typeof createMcpClient,
|
|
98
|
+
* establish?: typeof establishSession,
|
|
99
|
+
* checkout?: typeof checkoutTenant,
|
|
100
|
+
* }} [opts]
|
|
101
|
+
* @returns {Promise<{username:string,password:string}|null>}
|
|
102
|
+
*/
|
|
103
|
+
export async function mintOrCacheCredential({
|
|
104
|
+
env = process.env, cwd = process.cwd(), expectedHost = null,
|
|
105
|
+
createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
|
|
106
|
+
} = {}) {
|
|
107
|
+
const ctx = detectContext(cwd);
|
|
108
|
+
if (ctx.mode !== "checkout" || !ctx.tenant) return null;
|
|
109
|
+
|
|
110
|
+
const git = (cargs) =>
|
|
111
|
+
execFileSync("git", ["-C", ctx.workspacePath, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
112
|
+
let originUrl = null;
|
|
113
|
+
let repoName = null;
|
|
114
|
+
try {
|
|
115
|
+
originUrl = git(["remote", "get-url", "origin"]).trim();
|
|
116
|
+
repoName = repoNameFromRemote(originUrl);
|
|
117
|
+
} catch {
|
|
118
|
+
/* fall through — tagFromRepoName degrades to "main" on a null repo name */
|
|
119
|
+
}
|
|
120
|
+
if (expectedHost && !hostMatches(originUrl, expectedHost)) return null;
|
|
121
|
+
const tag = tagFromRepoName(repoName, ctx.tenant);
|
|
122
|
+
|
|
123
|
+
const cachePath = credentialCachePath(ctx.tenant, tag, env);
|
|
124
|
+
const cached = readCachedCredential(cachePath);
|
|
125
|
+
if (cached) return { username: cached.username, password: cached.password };
|
|
126
|
+
|
|
127
|
+
const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
128
|
+
const client = createClient(baseUrl);
|
|
129
|
+
try {
|
|
130
|
+
await establish(client, { env });
|
|
131
|
+
} catch {
|
|
132
|
+
return null; // not signed in (or session unrefreshable) — nothing this helper can do
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const res = await checkout(client, { tenant: ctx.tenant, tag, cloneDir: null });
|
|
136
|
+
const cred = splitAuthedRemote(res.gitRemote || "");
|
|
137
|
+
if (!cred) return null;
|
|
138
|
+
const fresh = { username: cred.username, password: cred.token };
|
|
139
|
+
writeCachedCredential(cachePath, fresh);
|
|
140
|
+
return fresh;
|
|
141
|
+
} catch {
|
|
142
|
+
return null; // MCP unreachable / tenant_checkout refused — say nothing, exit non-zero
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const USAGE = `tot git-credential — git credential-helper protocol over your \`tot login\` session
|
|
147
|
+
|
|
148
|
+
Configured automatically by \`tot clone\` (credential.helper = !tot git-credential)
|
|
149
|
+
in every checkout it creates — you should never need to run this by hand.
|
|
150
|
+
See \`git help gitcredentials\` for the protocol this implements.`;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string[]} argv argv[0] is git's operation: get|store|erase
|
|
154
|
+
* @param {any} _ctx unused — this command derives its OWN context from cwd
|
|
155
|
+
* (mintOrCacheCredential), since git invokes it with the credentialed
|
|
156
|
+
* repo's directory as cwd, which may differ from wherever `tot` itself
|
|
157
|
+
* was dispatched from.
|
|
158
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string, readStdin?: typeof readStdin }
|
|
159
|
+
* & Parameters<typeof mintOrCacheCredential>[0]} [opts]
|
|
160
|
+
*/
|
|
161
|
+
export async function run(argv, _ctx, opts = {}) {
|
|
162
|
+
const { env = process.env, readStdin: read = readStdin } = opts;
|
|
163
|
+
const op = argv[0];
|
|
164
|
+
if (!op || op === "--help" || op === "-h") {
|
|
165
|
+
console.log(USAGE);
|
|
166
|
+
return op ? 0 : 2;
|
|
167
|
+
}
|
|
168
|
+
// git always writes a request to stdin, even for store/erase — drain it either way
|
|
169
|
+
// so the subprocess exits cleanly instead of leaving git's write blocked on a full pipe.
|
|
170
|
+
const request = parseCredentialInput(read());
|
|
171
|
+
|
|
172
|
+
if (op !== "get") return 0; // store/erase: stateless, nothing to persist or drop.
|
|
173
|
+
|
|
174
|
+
const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
|
|
175
|
+
if (!cred) return 1; // silent — let git fall through to its next helper / its own prompt.
|
|
176
|
+
process.stdout.write(formatCredentialOutput({
|
|
177
|
+
protocol: request.protocol, host: request.host, username: cred.username, password: cred.password,
|
|
178
|
+
}));
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot hotfix` (unit b22) — the OWNER-ONLY EXCEPTION LANE: release an urgent
|
|
3
|
+
* production fix from `main` to live WHILE `preview` still contains other unshipped
|
|
4
|
+
* work, EXCLUDING that unshipped preview head, then automatically forward-integrate
|
|
5
|
+
* `main` into `preview` and re-validate.
|
|
6
|
+
*
|
|
7
|
+
* tot dev / preview build + review the fix (a `main`-based candidate)
|
|
8
|
+
* tot hotfix --pr N release THAT reviewed fix from main to live, bypassing preview ← you are here
|
|
9
|
+
*
|
|
10
|
+
* This is a DELIBERATELY DISTINCT verb — NEVER a `--base main` flag on `tot ship`.
|
|
11
|
+
* `tot ship` publishes the tenant's current green PREVIEW aggregate; `tot hotfix`
|
|
12
|
+
* does the opposite in the one case that warrants it: it ships a `main`-based fix
|
|
13
|
+
* that does NOT include the in-flight preview work, and only then carries `main`
|
|
14
|
+
* forward into `preview`. Because rewinding what goes live around the normal queue
|
|
15
|
+
* is a serious exception, it ALWAYS shows the exact plan — including the unshipped
|
|
16
|
+
* preview work it BYPASSES — and requires an explicit confirm.
|
|
17
|
+
*
|
|
18
|
+
* THE FLOW (mirrors `tot ship`'s plan → confirm → act → honest-terminal-state):
|
|
19
|
+
* 1. GET the read-only PLAN from `/api/changes/hotfix` — the candidate, the
|
|
20
|
+
* current live rollback target, the unshipped preview work bypassed, and the
|
|
21
|
+
* paywall verdict. Zero side effects.
|
|
22
|
+
* 2. Print the EXACT plan and require ONE explicit confirm (default NO). `--yes`
|
|
23
|
+
* confirms non-interactively; a non-TTY without `--yes` REFUSES.
|
|
24
|
+
* 3. On confirm, POST `/api/changes/hotfix` with `confirmGoLive: true`.
|
|
25
|
+
* 4. Report the orchestrator's HONEST terminal state verbatim — `shipped`
|
|
26
|
+
* (live VERIFIED on the fix; the ONLY "shipped" state), `refused`,
|
|
27
|
+
* `merge_failed` / `build_failed` / `promote_failed` / `record_failed`
|
|
28
|
+
* (recoverable, never a false live) — PLUS the automatic forward-integration
|
|
29
|
+
* outcome (green, or red + a fix-forward nudge; a red forward-integration
|
|
30
|
+
* never un-ships the live fix).
|
|
31
|
+
*
|
|
32
|
+
* TRANSPORT: the operator-secret Bearer + `X-Tot-Owner`, the SAME transport as
|
|
33
|
+
* `tot ship` / `tot revert`. Dependency-free (global fetch + the shared plan module).
|
|
34
|
+
*/
|
|
35
|
+
import { fail } from "../errors.mjs";
|
|
36
|
+
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
37
|
+
import { startProgress } from "../progress.mjs";
|
|
38
|
+
import { openBrowser } from "../open.mjs";
|
|
39
|
+
// Reuse `tot ship`'s operator-secret precedence + live-url derivation verbatim so
|
|
40
|
+
// ship + hotfix speak ONE operator-auth contract.
|
|
41
|
+
import { resolveOperatorSecret, liveUrlFor } from "./ship.mjs";
|
|
42
|
+
|
|
43
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
44
|
+
|
|
45
|
+
const USAGE = `tot hotfix — OWNER-ONLY: release an urgent fix from main to live, bypassing preview
|
|
46
|
+
|
|
47
|
+
tot hotfix --pr <N> --tenant <t>
|
|
48
|
+
tot hotfix --change <changeId> --tenant <t>
|
|
49
|
+
|
|
50
|
+
The EXPLICIT EXCEPTION lane for an urgent production fix while \`preview\` holds
|
|
51
|
+
other unshipped work. It releases the reviewed, main-based fix to live WITHOUT the
|
|
52
|
+
unshipped preview head, then automatically forward-integrates main → preview and
|
|
53
|
+
re-validates the aggregate. It is NOT \`tot ship\` with a flag — \`tot ship\`
|
|
54
|
+
publishes the current green preview aggregate; \`tot hotfix\` deliberately excludes it.
|
|
55
|
+
|
|
56
|
+
It ALWAYS prints the exact plan — including the unshipped preview work it BYPASSES —
|
|
57
|
+
and asks for an explicit confirm. A non-TTY without --yes is refused.
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
--pr <N> The reviewed hotfix candidate's PR number.
|
|
61
|
+
--change <changeId> The reviewed hotfix candidate's change id (alternative to --pr).
|
|
62
|
+
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
63
|
+
current checkout's tenant when run inside one.
|
|
64
|
+
--message <msg> Optional merge-commit message.
|
|
65
|
+
--url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
|
|
66
|
+
--secret <s> operator secret (prefer the env vars below)
|
|
67
|
+
--yes, -y Skip the interactive confirm (still an explicit affirmative).
|
|
68
|
+
--no-open Don't open the live URL in your browser.
|
|
69
|
+
--help, -h Show this help.
|
|
70
|
+
|
|
71
|
+
For the ordinary release of the whole green preview aggregate, use \`tot ship\`.
|
|
72
|
+
To undo something already LIVE, use \`tot rollback\`.
|
|
73
|
+
|
|
74
|
+
Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
|
|
75
|
+
GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
|
|
76
|
+
|
|
77
|
+
/** Parse `tot hotfix` argv. Pure — unit-testable. */
|
|
78
|
+
export function parseHotfixArgs(argv) {
|
|
79
|
+
const a = {
|
|
80
|
+
pr: null,
|
|
81
|
+
change: null,
|
|
82
|
+
tenant: null,
|
|
83
|
+
message: null,
|
|
84
|
+
url: null,
|
|
85
|
+
secret: null,
|
|
86
|
+
yes: false,
|
|
87
|
+
noOpen: false,
|
|
88
|
+
help: false,
|
|
89
|
+
};
|
|
90
|
+
for (let i = 0; i < argv.length; i++) {
|
|
91
|
+
const t = argv[i];
|
|
92
|
+
if (t === "--pr") a.pr = argv[++i];
|
|
93
|
+
else if (t === "--change" || t === "--changeId") a.change = argv[++i];
|
|
94
|
+
else if (t === "--tenant") a.tenant = argv[++i];
|
|
95
|
+
else if (t === "--message" || t === "-m") a.message = argv[++i];
|
|
96
|
+
else if (t === "--url") a.url = argv[++i];
|
|
97
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
98
|
+
else if (t === "--yes" || t === "-y") a.yes = true;
|
|
99
|
+
else if (t === "--no-open") a.noOpen = true;
|
|
100
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
101
|
+
}
|
|
102
|
+
return a;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Normalise a `?pr` value to a positive integer, or null. Pure. */
|
|
106
|
+
export function parsePrNumber(raw) {
|
|
107
|
+
const t = (raw == null ? "" : `${raw}`).trim().replace(/^#/, "");
|
|
108
|
+
return /^\d+$/.test(t) ? Number(t) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Normalise a `GET /api/changes/hotfix` body — the {@link HotfixReleasePlan} (an
|
|
113
|
+
* exception ready to confirm) or a refusal. Read defensively (crossed the wire as
|
|
114
|
+
* JSON). Pure — unit-tested.
|
|
115
|
+
* @param {any} data
|
|
116
|
+
*/
|
|
117
|
+
export function normalizeHotfixPlan(data) {
|
|
118
|
+
const o = data && typeof data === "object" ? data : {};
|
|
119
|
+
if (o.ok === true) {
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
tenantId: typeof o.tenantId === "string" ? o.tenantId : null,
|
|
123
|
+
changeId: typeof o.changeId === "string" ? o.changeId : null,
|
|
124
|
+
prNumber: typeof o.prNumber === "number" ? o.prNumber : null,
|
|
125
|
+
bypassedPreviewSha: typeof o.bypassedPreviewSha === "string" ? o.bypassedPreviewSha : null,
|
|
126
|
+
bypassedPrs: Array.isArray(o.bypassedPrs) ? o.bypassedPrs : [],
|
|
127
|
+
rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
|
|
128
|
+
paywall:
|
|
129
|
+
o.paywall && typeof o.paywall === "object"
|
|
130
|
+
? { allowed: o.paywall.allowed === true, message: typeof o.paywall.message === "string" ? o.paywall.message : null }
|
|
131
|
+
: { allowed: true, message: null },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: typeof o.reason === "string" ? o.reason : "unknown",
|
|
137
|
+
message: typeof o.message === "string" ? o.message : "the hotfix plan was refused for an unknown reason",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalise a `POST /api/changes/hotfix` body — the {@link HotfixReleaseResult}.
|
|
143
|
+
* `state` is the ONLY honest terminal authority: render "shipped live" for
|
|
144
|
+
* `"shipped"` and nothing else. Pure — unit-tested.
|
|
145
|
+
* @param {any} data
|
|
146
|
+
*/
|
|
147
|
+
export function normalizeHotfixResult(data) {
|
|
148
|
+
const o = data && typeof data === "object" ? data : {};
|
|
149
|
+
const fwd = o.forwardIntegration && typeof o.forwardIntegration === "object" ? o.forwardIntegration : null;
|
|
150
|
+
return {
|
|
151
|
+
ok: o.ok === true,
|
|
152
|
+
state: typeof o.state === "string" ? o.state : "unknown",
|
|
153
|
+
reason: typeof o.reason === "string" ? o.reason : null,
|
|
154
|
+
message: typeof o.message === "string" ? o.message : "",
|
|
155
|
+
mainSha: typeof o.mainSha === "string" ? o.mainSha : null,
|
|
156
|
+
artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
|
|
157
|
+
receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
|
|
158
|
+
rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
|
|
159
|
+
forwardIntegration: fwd
|
|
160
|
+
? {
|
|
161
|
+
ok: fwd.ok === true,
|
|
162
|
+
queueState: typeof fwd.queueState === "string" ? fwd.queueState : null,
|
|
163
|
+
runState: typeof fwd.runState === "string" ? fwd.runState : null,
|
|
164
|
+
reason: typeof fwd.reason === "string" ? fwd.reason : null,
|
|
165
|
+
aggregateSha: typeof fwd.aggregateSha === "string" ? fwd.aggregateSha : null,
|
|
166
|
+
statusMessage: typeof fwd.statusMessage === "string" ? fwd.statusMessage : null,
|
|
167
|
+
}
|
|
168
|
+
: null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A clear next step per hotfix plan/release refusal reason. Pure. */
|
|
173
|
+
export function hotfixNextStep(reason) {
|
|
174
|
+
switch (reason) {
|
|
175
|
+
case "no_candidate":
|
|
176
|
+
return "pass --pr <N> (or --change <id>) naming the reviewed hotfix candidate";
|
|
177
|
+
case "paywall":
|
|
178
|
+
return "upgrade the storefront subscription to enable go-live";
|
|
179
|
+
case "golive_unconfirmed":
|
|
180
|
+
return "re-run `tot hotfix` and confirm the plan";
|
|
181
|
+
case "digest_mismatch":
|
|
182
|
+
return "main moved under review — re-preview the hotfix against current main, then re-run `tot hotfix`";
|
|
183
|
+
default:
|
|
184
|
+
return "re-run `tot hotfix`";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
|
|
189
|
+
async function readJsonSafe(res) {
|
|
190
|
+
try {
|
|
191
|
+
return await res.json();
|
|
192
|
+
} catch {
|
|
193
|
+
return {};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The hotfix flow: GET the plan, print it + confirm, POST to release, report the
|
|
199
|
+
* honest terminal state + the forward-integration outcome. `fetch`/`confirmPlan`/
|
|
200
|
+
* `openUrl`/`progress` are injected so it's unit-tested with no live network/TTY.
|
|
201
|
+
*
|
|
202
|
+
* @param {{ tenant:string, prNumber:number|null, changeId:string|null,
|
|
203
|
+
* message:string|null, secret:string, storefrontUrl?:string|null,
|
|
204
|
+
* yes?:boolean, noOpen?:boolean }} params
|
|
205
|
+
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
|
|
206
|
+
* openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
|
|
207
|
+
* @returns {Promise<number>} process exit code
|
|
208
|
+
*/
|
|
209
|
+
export async function runHotfix(
|
|
210
|
+
{ tenant, prNumber, changeId, message = null, secret, storefrontUrl = null, yes = false, noOpen = false },
|
|
211
|
+
deps = {},
|
|
212
|
+
) {
|
|
213
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
214
|
+
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
215
|
+
const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
216
|
+
const label = prNumber != null ? `PR #${prNumber}` : changeId ? changeId : "the hotfix candidate";
|
|
217
|
+
|
|
218
|
+
if (!secret) {
|
|
219
|
+
console.error(
|
|
220
|
+
fail(
|
|
221
|
+
"a hotfix is an OWNER action — it needs an operator secret",
|
|
222
|
+
"set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
return 2;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const authHeaders = {
|
|
229
|
+
authorization: `Bearer ${secret}`,
|
|
230
|
+
"x-tot-owner": tenant,
|
|
231
|
+
"x-tot-capability": "ship-on-behalf",
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// 1. GET the read-only plan (candidate + bypassed preview work + rollback + paywall).
|
|
235
|
+
const query = prNumber != null ? `pr=${encodeURIComponent(prNumber)}` : `changeId=${encodeURIComponent(changeId || "")}`;
|
|
236
|
+
let planRes;
|
|
237
|
+
try {
|
|
238
|
+
planRes = await fetchImpl(`${base}/api/changes/hotfix?${query}`, { method: "GET", headers: authHeaders });
|
|
239
|
+
} catch (e) {
|
|
240
|
+
console.error(
|
|
241
|
+
fail(`couldn't reach the hotfix plan at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
|
|
242
|
+
);
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
const planData = await readJsonSafe(planRes);
|
|
246
|
+
if (!planRes.ok && planData?.ok !== false) {
|
|
247
|
+
const msg = planData?.error || `HTTP ${planRes.status}`;
|
|
248
|
+
console.error(
|
|
249
|
+
fail(
|
|
250
|
+
`the hotfix plan was refused: ${msg}`,
|
|
251
|
+
planRes.status === 401 || planRes.status === 403
|
|
252
|
+
? "check the operator secret and that it's authorised for this tenant"
|
|
253
|
+
: "check --tenant / --url / --pr, then re-run",
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
const plan = normalizeHotfixPlan(planData);
|
|
259
|
+
if (!plan.ok) {
|
|
260
|
+
console.error(fail(plan.message, hotfixNextStep(plan.reason)));
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 2. Print the EXACT plan — including the unshipped preview work it BYPASSES —
|
|
265
|
+
// then require ONE explicit confirm (default NO).
|
|
266
|
+
const liveUrl = liveUrlFor(tenant);
|
|
267
|
+
const planLines = planForAction({
|
|
268
|
+
action: "hotfix",
|
|
269
|
+
tenant,
|
|
270
|
+
pr: plan.prNumber,
|
|
271
|
+
changeId: plan.changeId,
|
|
272
|
+
bypassedPrs: plan.bypassedPrs,
|
|
273
|
+
bypassedPreviewSha: plan.bypassedPreviewSha,
|
|
274
|
+
rollbackTarget: plan.rollbackTarget,
|
|
275
|
+
paywall: plan.paywall,
|
|
276
|
+
targets: { live: liveUrl },
|
|
277
|
+
});
|
|
278
|
+
const { confirmed, reason } = await confirmPlan(planLines, {
|
|
279
|
+
yes,
|
|
280
|
+
question: `Release ${label} from main to live for ${tenant}, BYPASSING the unshipped preview work?`,
|
|
281
|
+
});
|
|
282
|
+
if (!confirmed) {
|
|
283
|
+
if (reason === "non-tty") {
|
|
284
|
+
console.error(
|
|
285
|
+
fail(
|
|
286
|
+
"`tot hotfix` needs an interactive terminal to confirm this live exception",
|
|
287
|
+
"run it from a terminal, or pass --yes to confirm non-interactively",
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
return 2;
|
|
291
|
+
}
|
|
292
|
+
console.log(" Hotfix cancelled — nothing changed.");
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 3. RELEASE — the human go-live gate (confirmGoLive).
|
|
297
|
+
const progress = deps.progress === false ? null : startProgress("releasing hotfix…");
|
|
298
|
+
let res;
|
|
299
|
+
try {
|
|
300
|
+
res = await fetchImpl(`${base}/api/changes/hotfix`, {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: { "content-type": "application/json", ...authHeaders },
|
|
303
|
+
body: JSON.stringify({
|
|
304
|
+
confirmGoLive: true,
|
|
305
|
+
...(prNumber != null ? { prNumber } : {}),
|
|
306
|
+
...(plan.changeId ? { changeId: plan.changeId } : {}),
|
|
307
|
+
...(message ? { message } : {}),
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
310
|
+
} catch (e) {
|
|
311
|
+
progress?.stop();
|
|
312
|
+
console.error(
|
|
313
|
+
fail(`couldn't reach the hotfix endpoint at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
|
|
314
|
+
);
|
|
315
|
+
return 1;
|
|
316
|
+
}
|
|
317
|
+
const resultData = await readJsonSafe(res);
|
|
318
|
+
progress?.stop();
|
|
319
|
+
|
|
320
|
+
return reportHotfixResult(normalizeHotfixResult(resultData), { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Report the orchestrator's HONEST terminal state + the automatic forward-integration
|
|
325
|
+
* outcome. `shipped` is the ONLY state rendered as "shipped live"; a red
|
|
326
|
+
* forward-integration is surfaced as a fix-forward nudge but never un-ships live.
|
|
327
|
+
* @returns {number} process exit code
|
|
328
|
+
*/
|
|
329
|
+
export function reportHotfixResult(result, { tenant, liveUrl, noOpen, openUrl }) {
|
|
330
|
+
if (result.state === "shipped") {
|
|
331
|
+
console.log(`\n ✓ shipped hotfix live for ${tenant} — the unshipped preview work was NOT included.`);
|
|
332
|
+
if (result.mainSha) console.log(` main: ${result.mainSha}`);
|
|
333
|
+
if (result.receiptId) console.log(` receipt: ${result.receiptId}`);
|
|
334
|
+
if (liveUrl) {
|
|
335
|
+
console.log(` Live: ${liveUrl}`);
|
|
336
|
+
if (!noOpen && openUrl && openUrl(liveUrl)) console.log(" (opened in your browser)");
|
|
337
|
+
}
|
|
338
|
+
// The automatic forward-integration: honest whether green or red.
|
|
339
|
+
const fwd = result.forwardIntegration;
|
|
340
|
+
if (fwd && fwd.ok) {
|
|
341
|
+
console.log(` forward-integrated main → preview (green${fwd.aggregateSha ? `, ${fwd.aggregateSha}` : ""}).`);
|
|
342
|
+
} else if (fwd) {
|
|
343
|
+
console.log(
|
|
344
|
+
` ⚠ forward-integration main → preview is ${fwd.queueState ?? "?"}/${fwd.runState ?? "—"}` +
|
|
345
|
+
`${fwd.reason ? ` (${fwd.reason})` : ""} — the live hotfix is fine, but preview needs a fix-forward.`,
|
|
346
|
+
);
|
|
347
|
+
if (fwd.statusMessage) console.log(` why: ${fwd.statusMessage}`);
|
|
348
|
+
console.log(" → next: resolve preview against main, then `tot preview`.");
|
|
349
|
+
}
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
if (result.state === "refused") {
|
|
353
|
+
console.error(fail(result.message || `hotfix refused (${result.reason})`, hotfixNextStep(result.reason)));
|
|
354
|
+
return 1;
|
|
355
|
+
}
|
|
356
|
+
if (
|
|
357
|
+
result.state === "merge_failed" ||
|
|
358
|
+
result.state === "build_failed" ||
|
|
359
|
+
result.state === "promote_failed" ||
|
|
360
|
+
result.state === "record_failed"
|
|
361
|
+
) {
|
|
362
|
+
console.error(
|
|
363
|
+
fail(
|
|
364
|
+
result.message || `hotfix ${result.state.replace("_", " ")}`,
|
|
365
|
+
"re-run `tot hotfix` — the release is idempotent and safe to retry",
|
|
366
|
+
),
|
|
367
|
+
);
|
|
368
|
+
return 1;
|
|
369
|
+
}
|
|
370
|
+
console.error(
|
|
371
|
+
fail(
|
|
372
|
+
`unexpected hotfix response (state: ${result.state})`,
|
|
373
|
+
"re-run `tot hotfix`; check the storefront logs if it persists",
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* @param {string[]} argv
|
|
381
|
+
* @param {any} ctx
|
|
382
|
+
*/
|
|
383
|
+
export async function run(argv, ctx) {
|
|
384
|
+
const env = process.env;
|
|
385
|
+
const args = parseHotfixArgs(argv);
|
|
386
|
+
if (args.help) {
|
|
387
|
+
console.log(USAGE);
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const tenant = (args.tenant || (ctx?.mode === "checkout" ? ctx.tenant : null) || ctx?.tenant || "").trim();
|
|
392
|
+
if (!tenant) {
|
|
393
|
+
console.error(
|
|
394
|
+
fail(
|
|
395
|
+
"`tot hotfix` needs a target tenant",
|
|
396
|
+
"pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
return 2;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const prNumber = parsePrNumber(args.pr);
|
|
403
|
+
const changeId = (args.change || "").trim() || null;
|
|
404
|
+
if (prNumber == null && !changeId) {
|
|
405
|
+
console.error(
|
|
406
|
+
fail(
|
|
407
|
+
"`tot hotfix` needs the reviewed hotfix candidate",
|
|
408
|
+
"pass --pr <N> (a PR number) or --change <changeId>.",
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
return 2;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
415
|
+
return await runHotfix(
|
|
416
|
+
{
|
|
417
|
+
tenant,
|
|
418
|
+
prNumber,
|
|
419
|
+
changeId,
|
|
420
|
+
message: (args.message || "").trim() || null,
|
|
421
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
422
|
+
storefrontUrl,
|
|
423
|
+
yes: args.yes,
|
|
424
|
+
noOpen: args.noOpen,
|
|
425
|
+
},
|
|
426
|
+
{ openUrl: (u) => openBrowser(u) },
|
|
427
|
+
);
|
|
428
|
+
}
|
package/src/commands/pr.mjs
CHANGED
|
@@ -42,6 +42,24 @@ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
|
42
42
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
43
43
|
const SUBCOMMANDS = ["list", "view", "close"];
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The storefront-owned, shareable `/preview/<tenant>/pr/<N>` link — NEVER the
|
|
47
|
+
* forge/Gitea `url` (2026-08-18 incident: a raw forge PR URL reached an
|
|
48
|
+
* owner). `candidate_status` (the local-checkout MCP tool) has no
|
|
49
|
+
* `previewUrl` field at all, unlike the operator `GET /api/changes` path — so
|
|
50
|
+
* this constructs it the same way `runPrListOperator`'s caller resolves
|
|
51
|
+
* `storefrontUrl`, from the same env/--url override chain. Pure.
|
|
52
|
+
* @param {string} storefrontUrl
|
|
53
|
+
* @param {string} tenant
|
|
54
|
+
* @param {number|null|undefined} prNumber
|
|
55
|
+
* @returns {string|null}
|
|
56
|
+
*/
|
|
57
|
+
export function buildPreviewUrl(storefrontUrl, tenant, prNumber) {
|
|
58
|
+
if (typeof prNumber !== "number" || !tenant) return null;
|
|
59
|
+
const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
60
|
+
return `${base}/preview/${tenant}/pr/${prNumber}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
45
63
|
const USAGE = `tot pr — see and manage candidate PRs
|
|
46
64
|
|
|
47
65
|
tot pr [list] list your open candidate PRs for this store
|
|
@@ -118,9 +136,11 @@ export function matchCandidate(candidates, target) {
|
|
|
118
136
|
/**
|
|
119
137
|
* One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
|
|
120
138
|
* URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
|
|
121
|
-
* branch-bound candidates) and where its preview lives.
|
|
122
|
-
*
|
|
123
|
-
*
|
|
139
|
+
* branch-bound candidates) and where its preview lives. ONLY `previewUrl` (the
|
|
140
|
+
* storefront-owned `/preview/<tenant>/pr/<N>` link) — NEVER `url` (the forge/
|
|
141
|
+
* Gitea `html_url`), which must never reach a terminal (2026-08-18 incident:
|
|
142
|
+
* a raw forge PR URL reached an owner). `active` marks the one
|
|
143
|
+
* THIS checkout's branch resolves to. Pure — unit-tested.
|
|
124
144
|
* @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
|
|
125
145
|
* previewUrl?:string|null, url?:string|null}} c
|
|
126
146
|
* @param {{ active?: boolean }} [opts]
|
|
@@ -128,8 +148,7 @@ export function matchCandidate(candidates, target) {
|
|
|
128
148
|
export function formatCandidateLine(c, { active = false } = {}) {
|
|
129
149
|
const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
|
|
130
150
|
const branch = c.branch ? c.branch : "(no branch)";
|
|
131
|
-
const
|
|
132
|
-
const urlPart = previewUrl ? ` ${previewUrl}` : "";
|
|
151
|
+
const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
|
|
133
152
|
const activePart = active ? " ← active" : "";
|
|
134
153
|
return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
|
|
135
154
|
}
|
|
@@ -326,6 +345,7 @@ export async function run(argv, ctx) {
|
|
|
326
345
|
}
|
|
327
346
|
|
|
328
347
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
348
|
+
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
329
349
|
const statePath = defaultCandidateStatePath(env);
|
|
330
350
|
// Branch-bound (u4): the active-pointer namespace is scoped to the current git
|
|
331
351
|
// branch, so the "← active" marker reflects THIS branch's candidate.
|
|
@@ -347,7 +367,8 @@ export async function run(argv, ctx) {
|
|
|
347
367
|
const active = readActiveChangeId(statePath, scope);
|
|
348
368
|
console.log(`Open candidate PRs for ${repo}:`);
|
|
349
369
|
for (const c of candidates) {
|
|
350
|
-
|
|
370
|
+
const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
|
|
371
|
+
console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
|
|
351
372
|
}
|
|
352
373
|
return 0;
|
|
353
374
|
}
|
|
@@ -366,7 +387,9 @@ export async function run(argv, ctx) {
|
|
|
366
387
|
if (match.headSha) console.log(` head: ${match.headSha}`);
|
|
367
388
|
if (match.baseSha) console.log(` base: ${match.baseSha}`);
|
|
368
389
|
console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
|
|
369
|
-
|
|
390
|
+
// ONLY the storefront-owned preview link -- never the raw forge/Gitea `url`.
|
|
391
|
+
const previewUrl = match.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, match.prNumber);
|
|
392
|
+
if (previewUrl) console.log(` ${previewUrl}`);
|
|
370
393
|
return 0;
|
|
371
394
|
}
|
|
372
395
|
|