@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.21
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 +12 -9
- package/bin/tot.mjs +219 -44
- package/package.json +7 -2
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +736 -0
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +269 -0
- package/src/commands/clone.mjs +713 -0
- package/src/commands/dev.mjs +441 -93
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +486 -0
- package/src/commands/grants.mjs +14 -7
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +12 -8
- package/src/commands/pr.mjs +425 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +403 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +91 -29
- package/src/commands/submit.mjs +1360 -131
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +11 -5
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- 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/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +26 -11
- package/src/obstacle-beacon.cjs +3 -3
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +30 -4
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
- package/src/commands/checkout.mjs +0 -330
package/src/commands/login.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Runs the MCP OAuth 2.1 PKCE loopback (the same ceremony `claude mcp add` runs):
|
|
5
5
|
* opens the browser to the MCP's authorize page, the developer signs in with their
|
|
6
6
|
* ToT identity + approves, and the loopback catches the code and exchanges it for a
|
|
7
|
-
* token cached at ~/.tot/credentials.json. Every later command (`tot
|
|
7
|
+
* token cached at ~/.tot/credentials.json. Every later command (`tot clone`,
|
|
8
8
|
* `tot submit`, …) then runs as that developer with NO re-auth.
|
|
9
9
|
*
|
|
10
10
|
* The MCP defaults to the same target `tot submit` talks to (env MCP_BASE_URL /
|
|
@@ -25,6 +25,7 @@ import { openBrowser } from "../open.mjs";
|
|
|
25
25
|
import { fail } from "../errors.mjs";
|
|
26
26
|
import { cockpitRecoveryUrl, normalizeEmailHint, redactEmailForHint, emailFromJwt } from "../auth.mjs";
|
|
27
27
|
import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
28
|
+
import { versionStamp } from "../mcp.mjs";
|
|
28
29
|
|
|
29
30
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
30
31
|
|
|
@@ -108,7 +109,7 @@ const USAGE = `tot login — sign in to Token of Trust
|
|
|
108
109
|
link a later \`tot feedback\` report to your invite + session) — not meant to be
|
|
109
110
|
typed by hand.
|
|
110
111
|
|
|
111
|
-
After signing in, run \`tot whoami\` to confirm, then \`tot
|
|
112
|
+
After signing in, run \`tot whoami\` to confirm, then \`tot clone\` / \`tot submit\`.`;
|
|
112
113
|
|
|
113
114
|
/**
|
|
114
115
|
* The core of `tot login`: run the OAuth ceremony (browser loopback, or the
|
|
@@ -119,7 +120,7 @@ After signing in, run \`tot whoami\` to confirm, then \`tot checkout\` / \`tot s
|
|
|
119
120
|
* without duplicating this.
|
|
120
121
|
* @returns {Promise<object>} the credentials written to disk.
|
|
121
122
|
*/
|
|
122
|
-
export async function loginAndCache(mcpUrl, env = process.env, { log = () => {}, device = false } = {}) {
|
|
123
|
+
export async function loginAndCache(mcpUrl, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}), device = false } = {}) {
|
|
123
124
|
const path = defaultCredentialsPath(env);
|
|
124
125
|
const prior = readCredentials(path);
|
|
125
126
|
const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
|
|
@@ -195,11 +196,10 @@ export async function offerSignIn(mcpUrl, env = process.env, {
|
|
|
195
196
|
* `log` carries the fingerprint + "waiting for approval" lines to the terminal.
|
|
196
197
|
* @returns {Promise<object>} the credentials written to disk.
|
|
197
198
|
*/
|
|
198
|
-
export async function redeemAndCache(mcpUrl, code, env = process.env, { log = () => {} } = {}) {
|
|
199
|
+
export async function redeemAndCache(mcpUrl, code, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}) } = {}) {
|
|
199
200
|
const path = defaultCredentialsPath(env);
|
|
200
201
|
const prior = readCredentials(path);
|
|
201
|
-
const
|
|
202
|
-
const creds = await rendezvousLoginFlow({ mcpUrl, clientId, code, log });
|
|
202
|
+
const creds = await rendezvousLoginFlow({ mcpUrl, code, log });
|
|
203
203
|
const merged = mergeActivityBridge(prior, mcpUrl, creds);
|
|
204
204
|
writeCredentials(path, merged);
|
|
205
205
|
return merged;
|
|
@@ -255,6 +255,10 @@ export async function run(argv, _ctx) {
|
|
|
255
255
|
return 0;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
// Show which CLI/runtime is actually running BEFORE anything else — the first thing
|
|
259
|
+
// a "why did sign-in behave oddly" investigation needs (e.g. a stale shadowing `tot`).
|
|
260
|
+
console.error(versionStamp());
|
|
261
|
+
|
|
258
262
|
const mcpUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
259
263
|
|
|
260
264
|
if (args.code) {
|
|
@@ -265,7 +269,7 @@ export async function run(argv, _ctx) {
|
|
|
265
269
|
cacheTraceId(env, args.traceId);
|
|
266
270
|
cacheEmailHint(env, args.emailHint);
|
|
267
271
|
console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
|
|
268
|
-
console.log(" Next: `tot whoami` to confirm, or `tot
|
|
272
|
+
console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
|
|
269
273
|
return 0;
|
|
270
274
|
} catch (e) {
|
|
271
275
|
console.error(
|
|
@@ -283,7 +287,7 @@ export async function run(argv, _ctx) {
|
|
|
283
287
|
await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
|
|
284
288
|
cacheTraceId(env, args.traceId);
|
|
285
289
|
console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
|
|
286
|
-
console.log(" Next: `tot whoami` to confirm, or `tot
|
|
290
|
+
console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
|
|
287
291
|
return 0;
|
|
288
292
|
} catch (e) {
|
|
289
293
|
console.error(
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot pr` — see and manage the candidate PRs `tot submit` opens, mirroring
|
|
3
|
+
* `gh pr`.
|
|
4
|
+
*
|
|
5
|
+
* tot pr [list] list your OPEN candidate PRs for this store
|
|
6
|
+
* tot pr view <N|id> show one candidate (by PR number or changeId)
|
|
7
|
+
* tot pr close <N|id> close (reject) a candidate without merging
|
|
8
|
+
*
|
|
9
|
+
* A candidate PR is the reviewable unit `tot submit` creates. By default a
|
|
10
|
+
* re-submit UPDATES your open candidate; `tot submit --new` forks another. This
|
|
11
|
+
* command fills the gap the raw submit loop left — a first-party way to list your
|
|
12
|
+
* open candidates and to close one (candidate close was otherwise gated to
|
|
13
|
+
* version-control apps only).
|
|
14
|
+
*
|
|
15
|
+
* Runs from inside a tenant checkout (it derives the forge repo from the
|
|
16
|
+
* checkout's origin remote, exactly as `tot submit` does) and reads over the MCP
|
|
17
|
+
* `candidate_status` / `candidate_close` tools. Dependency-free (global fetch +
|
|
18
|
+
* `git`).
|
|
19
|
+
*
|
|
20
|
+
* OPERATOR MODE (unit U17). `tot pr list --tenant <t>` lists a tenant's OPEN
|
|
21
|
+
* candidate queue WITHOUT a checkout — the read-only companion to `tot ship --pr <N>
|
|
22
|
+
* --tenant <t>` (U16). It reuses U16's exact transport: the SAME `GET /api/changes`
|
|
23
|
+
* HTTP endpoint + operator-secret Bearer auth (`resolveOperatorSecret`,
|
|
24
|
+
* `normalizeChangesQueue` from ship.mjs). `view`/`close` stay developer-only.
|
|
25
|
+
*/
|
|
26
|
+
import { execFileSync } from "node:child_process";
|
|
27
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
28
|
+
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
29
|
+
import { fail } from "../errors.mjs";
|
|
30
|
+
import { repoNameFromRemote, currentBranch } from "./submit.mjs";
|
|
31
|
+
// U17 reuses U16's operator-secret transport helpers verbatim (same env precedence,
|
|
32
|
+
// same queue normalisation) so the operator `tot pr list` and `tot ship --pr` speak
|
|
33
|
+
// one wire, not two.
|
|
34
|
+
import { resolveOperatorSecret, normalizeChangesQueue } from "./ship.mjs";
|
|
35
|
+
import {
|
|
36
|
+
defaultCandidateStatePath,
|
|
37
|
+
readActiveChangeId,
|
|
38
|
+
clearActiveChangeId,
|
|
39
|
+
} from "../candidate-state.mjs";
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
42
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
43
|
+
const SUBCOMMANDS = ["list", "view", "close"];
|
|
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
|
+
|
|
63
|
+
const USAGE = `tot pr — see and manage candidate PRs
|
|
64
|
+
|
|
65
|
+
tot pr [list] list your open candidate PRs for this store
|
|
66
|
+
tot pr list --tenant <t> OPERATOR: list a tenant's open candidate PRs (no checkout)
|
|
67
|
+
tot pr view <N|id> show one candidate PR (by PR number or changeId)
|
|
68
|
+
tot pr close <N|id> close (reject) a candidate PR without merging
|
|
69
|
+
tot pr close <N|id> --reason "<why>" record why it was closed (audit note)
|
|
70
|
+
tot pr --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
71
|
+
|
|
72
|
+
DEVELOPER (default) — run inside your OWN store checkout: lists/manages the
|
|
73
|
+
candidates \`tot submit\` opens. A re-submit updates your open one by default;
|
|
74
|
+
\`tot submit --new\` forks another.
|
|
75
|
+
|
|
76
|
+
OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
|
|
77
|
+
queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
|
|
78
|
+
It reads \`GET /api/changes\` with an operator secret:
|
|
79
|
+
|
|
80
|
+
tot pr list --tenant <t> list <t>'s open candidate PRs
|
|
81
|
+
tot pr list --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
|
|
82
|
+
tot pr list --secret <s> operator secret (prefer the env vars below)
|
|
83
|
+
|
|
84
|
+
Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
|
|
85
|
+
GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
|
|
86
|
+
|
|
87
|
+
/** Parse `tot pr` argv into { sub, target, reason, mcp, identity, tenant, url, secret, help }. Pure. */
|
|
88
|
+
export function parsePrArgs(argv) {
|
|
89
|
+
const a = {
|
|
90
|
+
sub: null,
|
|
91
|
+
target: null,
|
|
92
|
+
reason: null,
|
|
93
|
+
mcp: null,
|
|
94
|
+
identity: null,
|
|
95
|
+
tenant: null,
|
|
96
|
+
url: null,
|
|
97
|
+
secret: null,
|
|
98
|
+
help: false,
|
|
99
|
+
};
|
|
100
|
+
const positional = [];
|
|
101
|
+
for (let i = 0; i < argv.length; i++) {
|
|
102
|
+
const t = argv[i];
|
|
103
|
+
if (t === "--mcp") a.mcp = argv[++i];
|
|
104
|
+
else if (t === "--identity") a.identity = argv[++i];
|
|
105
|
+
else if (t === "--reason") a.reason = argv[++i];
|
|
106
|
+
else if (t === "--tenant") a.tenant = argv[++i];
|
|
107
|
+
else if (t === "--url") a.url = argv[++i];
|
|
108
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
109
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
110
|
+
else positional.push(t);
|
|
111
|
+
}
|
|
112
|
+
a.sub = positional[0] || "list";
|
|
113
|
+
a.target = positional[1] ?? null;
|
|
114
|
+
return a;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Normalize the `candidate_status` list response to a flat candidate array. Pure. */
|
|
118
|
+
export function extractCandidates(result) {
|
|
119
|
+
if (Array.isArray(result)) return result;
|
|
120
|
+
if (result && Array.isArray(result.candidates)) return result.candidates;
|
|
121
|
+
if (result && Array.isArray(result.environments)) return result.environments;
|
|
122
|
+
if (result && typeof result.changeId === "string") return [result];
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Find a candidate by PR number (all-digits target) or exact changeId. Pure. */
|
|
127
|
+
export function matchCandidate(candidates, target) {
|
|
128
|
+
if (target == null) return null;
|
|
129
|
+
if (/^\d+$/.test(target)) {
|
|
130
|
+
const n = Number(target);
|
|
131
|
+
return candidates.find((c) => c.prNumber === n) ?? null;
|
|
132
|
+
}
|
|
133
|
+
return candidates.find((c) => c.changeId === target) ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
|
|
138
|
+
* URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
|
|
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.
|
|
144
|
+
* @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
|
|
145
|
+
* previewUrl?:string|null, url?:string|null}} c
|
|
146
|
+
* @param {{ active?: boolean }} [opts]
|
|
147
|
+
*/
|
|
148
|
+
export function formatCandidateLine(c, { active = false } = {}) {
|
|
149
|
+
const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
|
|
150
|
+
const branch = c.branch ? c.branch : "(no branch)";
|
|
151
|
+
const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
|
|
152
|
+
const activePart = active ? " ← active" : "";
|
|
153
|
+
return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Operator queue listing (unit U17) ──────────────────────────────────────────────
|
|
157
|
+
//
|
|
158
|
+
// The read-only companion to `tot ship --pr <N> --tenant <t>` (U16): list a tenant's
|
|
159
|
+
// OPEN candidate queue WITHOUT a checkout. It reuses U16's exact transport — the SAME
|
|
160
|
+
// `GET /api/changes` endpoint (operator accept queue) + Bearer operator-secret auth
|
|
161
|
+
// (`resolveOperatorSecret` / `normalizeChangesQueue` from ship.mjs) — so a listing and
|
|
162
|
+
// a ship read one wire. NO merge/deploy/side-effect; pure listing.
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One-line summary of an OPEN operator-queue candidate for `tot pr list --tenant`.
|
|
166
|
+
* Two shapes come off `GET /api/changes`:
|
|
167
|
+
* - BUILT candidate (`built:true`, has a `changeId`): PR # / status / changeId /
|
|
168
|
+
* short head sha / preview URL — the fields an operator needs to pick a PR to ship.
|
|
169
|
+
* - NOT-BUILT forge PR (`built:false` / `changeId:null` / `status:"not-built"`): an
|
|
170
|
+
* open PR with no preview yet. Render it DISTINCTLY — no changeId (there is none),
|
|
171
|
+
* a `[not built]` tag, and a build HINT instead of a preview URL — so an operator
|
|
172
|
+
* sees it's listable but must `tot preview build` before it can ship.
|
|
173
|
+
* Adapts `formatCandidateLine`'s house style to the `GET /api/changes` shape (`status`
|
|
174
|
+
* not `state`; `previewUrl`; a `headSha` to short-render). Pure — unit-tested.
|
|
175
|
+
* @param {{prNumber?:number|null, status?:string|null, changeId?:string|null,
|
|
176
|
+
* headSha?:string|null, previewUrl?:string|null, built?:boolean}} c
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
export function formatOperatorCandidateLine(c) {
|
|
180
|
+
const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
|
|
181
|
+
const head = c.headSha ? c.headSha.slice(0, 8) : "(no head)";
|
|
182
|
+
// A not-built PR: no changeId, so route the operator to build it first.
|
|
183
|
+
const notBuilt = c.built === false || (!c.changeId && c.status === "not-built");
|
|
184
|
+
if (notBuilt) {
|
|
185
|
+
const hint =
|
|
186
|
+
typeof c.prNumber === "number"
|
|
187
|
+
? ` → tot preview build --pr ${c.prNumber}`
|
|
188
|
+
: " → tot preview build";
|
|
189
|
+
return ` PR ${pr} [not built] ${head}${hint}`;
|
|
190
|
+
}
|
|
191
|
+
const status = c.status ?? "?";
|
|
192
|
+
const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
|
|
193
|
+
return ` PR ${pr} [${status}] ${c.changeId} ${head}${urlPart}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Sort an OPEN candidate queue by PR number DESC (newest PR first); candidates
|
|
198
|
+
* without a PR number sort last, then stably by changeId. Pure — unit-tested.
|
|
199
|
+
* @param {ReturnType<typeof normalizeChangesQueue>} changes
|
|
200
|
+
*/
|
|
201
|
+
export function sortQueueByPrDesc(changes) {
|
|
202
|
+
return [...(changes || [])].sort((a, b) => {
|
|
203
|
+
const ap = typeof a.prNumber === "number" ? a.prNumber : -Infinity;
|
|
204
|
+
const bp = typeof b.prNumber === "number" ? b.prNumber : -Infinity;
|
|
205
|
+
if (ap !== bp) return bp - ap;
|
|
206
|
+
return String(a.changeId).localeCompare(String(b.changeId));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The OPERATOR `tot pr list --tenant <t>` flow: fetch the tenant's OPEN candidate
|
|
212
|
+
* queue over `GET /api/changes` (Bearer operator secret + `X-Tot-Owner` +
|
|
213
|
+
* `x-tot-capability: ship-on-behalf`, mirroring U16) and print each candidate one per
|
|
214
|
+
* line, sorted by PR number desc. Fail-closed: no secret → honest refusal (exit 2)
|
|
215
|
+
* BEFORE any network. `fetch` is injected so it's unit-tested with no live network.
|
|
216
|
+
*
|
|
217
|
+
* @param {{ tenant:string, secret:string, storefrontUrl?:string|null }} params
|
|
218
|
+
* @param {{ fetch?:typeof fetch }} [deps]
|
|
219
|
+
* @returns {Promise<number>} process exit code
|
|
220
|
+
*/
|
|
221
|
+
export async function runPrListOperator({ tenant, secret, storefrontUrl = null }, deps = {}) {
|
|
222
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
223
|
+
const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
224
|
+
|
|
225
|
+
if (!secret) {
|
|
226
|
+
console.error(
|
|
227
|
+
fail(
|
|
228
|
+
"no operator secret — view this tenant's queue from the storefront admin instead.",
|
|
229
|
+
`open https://${tenant}/admin → Publish tab to see the candidate queue and act on it. ` +
|
|
230
|
+
"Operators/CI: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret.",
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
return 2;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const authHeaders = {
|
|
237
|
+
authorization: `Bearer ${secret}`,
|
|
238
|
+
"x-tot-owner": tenant,
|
|
239
|
+
"x-tot-capability": "ship-on-behalf",
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
let res;
|
|
243
|
+
try {
|
|
244
|
+
res = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
|
|
245
|
+
} catch (e) {
|
|
246
|
+
console.error(
|
|
247
|
+
fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
|
|
248
|
+
);
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let data = {};
|
|
253
|
+
try {
|
|
254
|
+
data = await res.json();
|
|
255
|
+
} catch {
|
|
256
|
+
/* non-JSON / empty body */
|
|
257
|
+
}
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
const msg = data?.error || `HTTP ${res.status}`;
|
|
260
|
+
console.error(
|
|
261
|
+
fail(
|
|
262
|
+
`the candidate queue refused the request: ${msg}`,
|
|
263
|
+
res.status === 401 || res.status === 403
|
|
264
|
+
? "check the operator secret and that it's authorised for this tenant"
|
|
265
|
+
: "check --tenant / --url, then re-run",
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
return 1;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const changes = sortQueueByPrDesc(normalizeChangesQueue(data));
|
|
272
|
+
if (!changes.length) {
|
|
273
|
+
console.log(`No open candidate PRs for ${tenant}.`);
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
console.log(`Open PRs for ${tenant}:`);
|
|
277
|
+
for (const c of changes) {
|
|
278
|
+
console.log(formatOperatorCandidateLine(c));
|
|
279
|
+
}
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** @param {string[]} argv @param {any} ctx */
|
|
284
|
+
export async function run(argv, ctx) {
|
|
285
|
+
const env = process.env;
|
|
286
|
+
const args = parsePrArgs(argv);
|
|
287
|
+
if (args.help) {
|
|
288
|
+
console.log(USAGE);
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
if (!SUBCOMMANDS.includes(/** @type {any} */ (args.sub))) {
|
|
292
|
+
console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
|
|
293
|
+
return 2;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// OPERATOR MODE (U17): `--tenant <t>` lists that tenant's OPEN candidate queue with
|
|
297
|
+
// NO checkout, over the same HTTP transport `tot ship --pr` uses. Only `list` has an
|
|
298
|
+
// operator path today — `view`/`close` stay developer-only (checkout-bound).
|
|
299
|
+
if (args.tenant && `${args.tenant}`.trim()) {
|
|
300
|
+
if (args.sub !== "list") {
|
|
301
|
+
console.error(
|
|
302
|
+
fail(
|
|
303
|
+
`\`tot pr ${args.sub} --tenant\` isn't wired — only \`tot pr list --tenant\` has an operator path`,
|
|
304
|
+
"use `tot pr list --tenant <t>` to list, then act from a checkout",
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
return 2;
|
|
308
|
+
}
|
|
309
|
+
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
310
|
+
return await runPrListOperator({
|
|
311
|
+
tenant: `${args.tenant}`.trim(),
|
|
312
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
313
|
+
storefrontUrl,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (ctx.mode !== "checkout") {
|
|
318
|
+
console.error(
|
|
319
|
+
fail(
|
|
320
|
+
"`tot pr` runs from inside a tenant checkout",
|
|
321
|
+
"tot clone <tenant> <dir> (then `cd` in and re-run), or `tot pr list --tenant <t>` for operator mode",
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
return 2;
|
|
325
|
+
}
|
|
326
|
+
if ((args.sub === "view" || args.sub === "close") && !args.target) {
|
|
327
|
+
console.error(fail(`\`tot pr ${args.sub}\` needs a PR number or changeId`, `tot pr ${args.sub} <N>`));
|
|
328
|
+
return 2;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const workspace = ctx.workspacePath;
|
|
332
|
+
const tenant = ctx.tenant;
|
|
333
|
+
const gitSafe = (cargs) => {
|
|
334
|
+
try {
|
|
335
|
+
return execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
336
|
+
} catch {
|
|
337
|
+
return "";
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
341
|
+
if (!repo) {
|
|
342
|
+
console.error(
|
|
343
|
+
fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
|
|
344
|
+
);
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
349
|
+
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
350
|
+
const statePath = defaultCandidateStatePath(env);
|
|
351
|
+
// Branch-bound (u4): the active-pointer namespace is scoped to the current git
|
|
352
|
+
// branch, so the "← active" marker reflects THIS branch's candidate.
|
|
353
|
+
const scope = { mcpUrl: baseUrl, repo, branch: currentBranch(gitSafe) };
|
|
354
|
+
const client = createMcpClient(baseUrl);
|
|
355
|
+
try {
|
|
356
|
+
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
357
|
+
void session;
|
|
358
|
+
// Bind the active tenant so candidate_status/close read the right scope.
|
|
359
|
+
await client.callTool("client_switch", { tenant });
|
|
360
|
+
|
|
361
|
+
const candidates = extractCandidates(await client.callTool("candidate_status", { repo }));
|
|
362
|
+
|
|
363
|
+
if (args.sub === "list") {
|
|
364
|
+
if (!candidates.length) {
|
|
365
|
+
console.log(`No open candidate PRs for ${repo}. Run \`tot submit\` to open one.`);
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
const active = readActiveChangeId(statePath, scope);
|
|
369
|
+
console.log(`Open candidate PRs for ${repo}:`);
|
|
370
|
+
for (const c of candidates) {
|
|
371
|
+
const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
|
|
372
|
+
console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
|
|
373
|
+
}
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const match = matchCandidate(candidates, args.target);
|
|
378
|
+
if (!match) {
|
|
379
|
+
console.error(fail(`no open candidate matches "${args.target}"`, "tot pr list (to see your open candidates)"));
|
|
380
|
+
return 1;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (args.sub === "view") {
|
|
384
|
+
const active = readActiveChangeId(statePath, scope);
|
|
385
|
+
console.log(`PR ${typeof match.prNumber === "number" ? `#${match.prNumber}` : "#—"} — ${match.changeId}${match.changeId === active ? " (active)" : ""}`);
|
|
386
|
+
console.log(` state: ${match.state ?? "?"}`);
|
|
387
|
+
if (match.branch) console.log(` branch: ${match.branch}`);
|
|
388
|
+
if (match.headSha) console.log(` head: ${match.headSha}`);
|
|
389
|
+
if (match.baseSha) console.log(` base: ${match.baseSha}`);
|
|
390
|
+
console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
|
|
391
|
+
// ONLY the storefront-owned preview link -- never the raw forge/Gitea `url`.
|
|
392
|
+
const previewUrl = match.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, match.prNumber);
|
|
393
|
+
if (previewUrl) console.log(` ${previewUrl}`);
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// close
|
|
398
|
+
await client.callTool("candidate_close", {
|
|
399
|
+
repo,
|
|
400
|
+
changeId: match.changeId,
|
|
401
|
+
...(args.reason ? { reason: args.reason } : {}),
|
|
402
|
+
});
|
|
403
|
+
console.log(`✓ closed candidate ${match.changeId}${typeof match.prNumber === "number" ? ` (PR #${match.prNumber})` : ""}.`);
|
|
404
|
+
// If we just closed the remembered active candidate, forget it so the next
|
|
405
|
+
// plain `tot submit` starts a fresh one rather than resurrecting this handle.
|
|
406
|
+
if (readActiveChangeId(statePath, scope) === match.changeId) {
|
|
407
|
+
try {
|
|
408
|
+
clearActiveChangeId(statePath, scope);
|
|
409
|
+
} catch { /* best-effort */ }
|
|
410
|
+
}
|
|
411
|
+
return 0;
|
|
412
|
+
} catch (e) {
|
|
413
|
+
if (e instanceof AuthUnavailableError) {
|
|
414
|
+
console.error(fail("sign in to manage candidate PRs", e.hint || "run `tot login`, then re-run"));
|
|
415
|
+
return 1;
|
|
416
|
+
}
|
|
417
|
+
console.error(
|
|
418
|
+
fail(
|
|
419
|
+
`couldn't reach the candidate service: ${String(e?.message || e)}`,
|
|
420
|
+
"check your connection and that you're signed in, then re-run",
|
|
421
|
+
),
|
|
422
|
+
);
|
|
423
|
+
return 1;
|
|
424
|
+
}
|
|
425
|
+
}
|