@tokenoftrust/cli 1.4.0-rc.1 → 1.4.0-rc.11

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.
@@ -0,0 +1,214 @@
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
+ import { execFileSync } from "node:child_process";
21
+ import { createMcpClient } from "../mcp.mjs";
22
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
23
+ import { fail } from "../errors.mjs";
24
+ import { repoNameFromRemote, currentBranch } from "./submit.mjs";
25
+ import {
26
+ defaultCandidateStatePath,
27
+ readActiveChangeId,
28
+ clearActiveChangeId,
29
+ } from "../candidate-state.mjs";
30
+
31
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
32
+ const SUBCOMMANDS = ["list", "view", "close"];
33
+
34
+ const USAGE = `tot pr — see and manage your candidate PRs
35
+
36
+ tot pr [list] list your open candidate PRs for this store
37
+ tot pr view <N|id> show one candidate PR (by PR number or changeId)
38
+ tot pr close <N|id> close (reject) a candidate PR without merging
39
+ tot pr close <N|id> --reason "<why>" record why it was closed (audit note)
40
+ tot pr --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
41
+
42
+ A candidate PR is what \`tot submit\` opens for review. A re-submit updates your
43
+ open one by default; \`tot submit --new\` forks another. Use these to see and
44
+ manage them.`;
45
+
46
+ /** Parse `tot pr` argv into { sub, target, reason, mcp, identity, help }. Pure. */
47
+ export function parsePrArgs(argv) {
48
+ const a = { sub: null, target: null, reason: null, mcp: null, identity: null, help: false };
49
+ const positional = [];
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const t = argv[i];
52
+ if (t === "--mcp") a.mcp = argv[++i];
53
+ else if (t === "--identity") a.identity = argv[++i];
54
+ else if (t === "--reason") a.reason = argv[++i];
55
+ else if (t === "--help" || t === "-h") a.help = true;
56
+ else positional.push(t);
57
+ }
58
+ a.sub = positional[0] || "list";
59
+ a.target = positional[1] ?? null;
60
+ return a;
61
+ }
62
+
63
+ /** Normalize the `candidate_status` list response to a flat candidate array. Pure. */
64
+ export function extractCandidates(result) {
65
+ if (Array.isArray(result)) return result;
66
+ if (result && Array.isArray(result.candidates)) return result.candidates;
67
+ if (result && Array.isArray(result.environments)) return result.environments;
68
+ if (result && typeof result.changeId === "string") return [result];
69
+ return [];
70
+ }
71
+
72
+ /** Find a candidate by PR number (all-digits target) or exact changeId. Pure. */
73
+ export function matchCandidate(candidates, target) {
74
+ if (target == null) return null;
75
+ if (/^\d+$/.test(target)) {
76
+ const n = Number(target);
77
+ return candidates.find((c) => c.prNumber === n) ?? null;
78
+ }
79
+ return candidates.find((c) => c.changeId === target) ?? null;
80
+ }
81
+
82
+ /**
83
+ * One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
84
+ * URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
85
+ * branch-bound candidates) and where its preview lives. Prefers the candidate's
86
+ * `previewUrl`, falling back to the PR `url`. `active` marks the one THIS checkout's
87
+ * branch resolves to. Pure — unit-tested.
88
+ * @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
89
+ * previewUrl?:string|null, url?:string|null}} c
90
+ * @param {{ active?: boolean }} [opts]
91
+ */
92
+ export function formatCandidateLine(c, { active = false } = {}) {
93
+ const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
94
+ const branch = c.branch ? c.branch : "(no branch)";
95
+ const previewUrl = c.previewUrl || c.url || null;
96
+ const urlPart = previewUrl ? ` ${previewUrl}` : "";
97
+ const activePart = active ? " ← active" : "";
98
+ return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
99
+ }
100
+
101
+ /** @param {string[]} argv @param {any} ctx */
102
+ export async function run(argv, ctx) {
103
+ const env = process.env;
104
+ const args = parsePrArgs(argv);
105
+ if (args.help) {
106
+ console.log(USAGE);
107
+ return 0;
108
+ }
109
+ if (!SUBCOMMANDS.includes(args.sub)) {
110
+ console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
111
+ return 2;
112
+ }
113
+ if (ctx.mode !== "checkout") {
114
+ console.error(
115
+ fail("`tot pr` runs from inside a tenant checkout", "tot clone <tenant> <dir> (then `cd` in and re-run)"),
116
+ );
117
+ return 2;
118
+ }
119
+ if ((args.sub === "view" || args.sub === "close") && !args.target) {
120
+ console.error(fail(`\`tot pr ${args.sub}\` needs a PR number or changeId`, `tot pr ${args.sub} <N>`));
121
+ return 2;
122
+ }
123
+
124
+ const workspace = ctx.workspacePath;
125
+ const tenant = ctx.tenant;
126
+ const gitSafe = (cargs) => {
127
+ try {
128
+ return execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
129
+ } catch {
130
+ return "";
131
+ }
132
+ };
133
+ const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
134
+ if (!repo) {
135
+ console.error(
136
+ fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
137
+ );
138
+ return 1;
139
+ }
140
+
141
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
142
+ const statePath = defaultCandidateStatePath(env);
143
+ // Branch-bound (u4): the active-pointer namespace is scoped to the current git
144
+ // branch, so the "← active" marker reflects THIS branch's candidate.
145
+ const scope = { mcpUrl: baseUrl, repo, branch: currentBranch(gitSafe) };
146
+ const client = createMcpClient(baseUrl);
147
+ try {
148
+ const session = await establishSession(client, { env, prefer: args.identity || undefined });
149
+ void session;
150
+ // Bind the active tenant so candidate_status/close read the right scope.
151
+ await client.callTool("client_switch", { tenant });
152
+
153
+ const candidates = extractCandidates(await client.callTool("candidate_status", { repo }));
154
+
155
+ if (args.sub === "list") {
156
+ if (!candidates.length) {
157
+ console.log(`No open candidate PRs for ${repo}. Run \`tot submit\` to open one.`);
158
+ return 0;
159
+ }
160
+ const active = readActiveChangeId(statePath, scope);
161
+ console.log(`Open candidate PRs for ${repo}:`);
162
+ for (const c of candidates) {
163
+ console.log(formatCandidateLine(c, { active: !!active && c.changeId === active }));
164
+ }
165
+ return 0;
166
+ }
167
+
168
+ const match = matchCandidate(candidates, args.target);
169
+ if (!match) {
170
+ console.error(fail(`no open candidate matches "${args.target}"`, "tot pr list (to see your open candidates)"));
171
+ return 1;
172
+ }
173
+
174
+ if (args.sub === "view") {
175
+ const active = readActiveChangeId(statePath, scope);
176
+ console.log(`PR ${typeof match.prNumber === "number" ? `#${match.prNumber}` : "#—"} — ${match.changeId}${match.changeId === active ? " (active)" : ""}`);
177
+ console.log(` state: ${match.state ?? "?"}`);
178
+ if (match.branch) console.log(` branch: ${match.branch}`);
179
+ if (match.headSha) console.log(` head: ${match.headSha}`);
180
+ if (match.baseSha) console.log(` base: ${match.baseSha}`);
181
+ console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
182
+ if (match.url) console.log(` ${match.url}`);
183
+ return 0;
184
+ }
185
+
186
+ // close
187
+ await client.callTool("candidate_close", {
188
+ repo,
189
+ changeId: match.changeId,
190
+ ...(args.reason ? { reason: args.reason } : {}),
191
+ });
192
+ console.log(`✓ closed candidate ${match.changeId}${typeof match.prNumber === "number" ? ` (PR #${match.prNumber})` : ""}.`);
193
+ // If we just closed the remembered active candidate, forget it so the next
194
+ // plain `tot submit` starts a fresh one rather than resurrecting this handle.
195
+ if (readActiveChangeId(statePath, scope) === match.changeId) {
196
+ try {
197
+ clearActiveChangeId(statePath, scope);
198
+ } catch { /* best-effort */ }
199
+ }
200
+ return 0;
201
+ } catch (e) {
202
+ if (e instanceof AuthUnavailableError) {
203
+ console.error(fail("sign in to manage candidate PRs", e.hint || "run `tot login`, then re-run"));
204
+ return 1;
205
+ }
206
+ console.error(
207
+ fail(
208
+ `couldn't reach the candidate service: ${String(e?.message || e)}`,
209
+ "check your connection and that you're signed in, then re-run",
210
+ ),
211
+ );
212
+ return 1;
213
+ }
214
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `tot preview` — submit your store for PREVIEW (the deliberate, gated step).
3
+ *
4
+ * This is the first-class verb for the dev → preview → ship loop:
5
+ *
6
+ * tot dev run your store locally with save→reload
7
+ * tot preview push it to a reviewable preview (validate → reconcile → compliance) ← you are here
8
+ * tot ship promote a reconciled preview live ← unit u3
9
+ *
10
+ * The whole preview flow (validate, push the preview ref, open/update the PR-backed
11
+ * candidate, stream the reconcile/compliance/preview result) lives in submit.mjs — this
12
+ * module is a thin wrapper that runs that SAME flow and, on success, teaches the next
13
+ * verb (`tot ship`). Keeping the flow in one place means `tot preview` and its teaching
14
+ * aliases (`tot submit` / `tot deploy`, below) share exactly one implementation — every
15
+ * submit flag works identically through preview, with no chance of drift.
16
+ *
17
+ * TEACHING ALIASES: `tot submit` and `tot deploy` are the same preview flow. They run it
18
+ * unchanged and then print a one-line hint teaching the rename (and `tot ship`), so a
19
+ * developer who reaches for the old verb is nudged onto the new model without losing a
20
+ * step. Pass `{ alias }` (the verb the developer typed) to get that hint.
21
+ *
22
+ * Dependency-free (delegates to submit.mjs, which uses global fetch + `git`).
23
+ */
24
+ import { run as runPreviewFlow, parseArgs, renderUsage } from "./submit.mjs";
25
+
26
+ /** The ship hint printed after a successful `tot preview` — teaches the next verb.
27
+ * Pure — unit-tested. */
28
+ export const shipHint = () =>
29
+ "\n → next: once this preview reconciles cleanly, `tot ship` promotes it live.";
30
+
31
+ /** The teaching hint printed when the preview flow is reached via an old verb
32
+ * (`tot submit` / `tot deploy`). Names the rename AND the ship verb in one line.
33
+ * Pure — unit-tested. */
34
+ export const aliasHint = (alias) =>
35
+ `\n ℹ \`tot ${alias}\` is now \`tot preview\` — and \`tot ship\` promotes it live.`;
36
+
37
+ /** The hint to print after the flow returns, or null when there's nothing to teach
38
+ * (the flow failed). An alias gets the rename+ship hint; the first-class verb gets
39
+ * the ship hint; a non-zero exit teaches nothing (the push didn't land). Pure. */
40
+ export function postRunHint(code, alias) {
41
+ if (code !== 0) return null;
42
+ return alias ? aliasHint(alias) : shipHint();
43
+ }
44
+
45
+ /**
46
+ * @param {string[]} argv
47
+ * @param {any} ctx
48
+ * @param {{ alias?: string|null }} [opts] — `alias` is the old verb the developer typed
49
+ * (`"submit"` / `"deploy"`) when this flow is reached as a teaching alias; null/omitted
50
+ * for the first-class `tot preview`.
51
+ */
52
+ export async function run(argv, ctx, { alias = null } = {}) {
53
+ const verb = alias || "preview";
54
+ const args = parseArgs(argv);
55
+
56
+ // Handle --help here so we can brand the usage with the typed verb and, for an
57
+ // alias, teach the rename — without also printing the post-success ship hint.
58
+ if (args.help) {
59
+ console.log(renderUsage(verb));
60
+ if (alias) console.log(aliasHint(alias));
61
+ return 0;
62
+ }
63
+
64
+ const code = await runPreviewFlow(argv, ctx, { verb });
65
+
66
+ // Teach the next step only on a successful preview (the push landed). An alias
67
+ // gets the rename+ship hint; the first-class verb just gets the ship hint.
68
+ const hint = postRunHint(code, alias);
69
+ if (hint) console.log(hint);
70
+ return code;
71
+ }