@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.20

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.
@@ -1,330 +0,0 @@
1
- /**
2
- * `tot checkout` — clone the tenant store you're entitled to build on, with an
3
- * authenticated remote already configured, ready for the local loop.
4
- *
5
- * tot checkout list the stores you can build on
6
- * tot checkout <tenant> show the checkout for <tenant>
7
- * tot checkout <tenant> --clone DIR clone it to DIR with an authed remote
8
- *
9
- * This is the SAME MCP code path a developer gets when they switch to a tenant:
10
- * sign-in → client_switch(tenant) → tenant_checkout, where the MCP derives your
11
- * per-tenant Git user and mints a FRESH, single-active, repo-scoped push
12
- * credential (a later checkout for the same tenant rotates it). `tot` performs NO
13
- * privileged forge work itself — the MCP owns that.
14
- *
15
- * Auth is the developer's own ToT identity (the cached `tot login` session,
16
- * resolved via src/auth.mjs). When there's no session yet and we're on a TTY, we
17
- * offer to sign in right here and retry — no "run tot login, then re-run".
18
- *
19
- * Dependency-free (global fetch + `git` via child_process).
20
- */
21
- import { execFile } from "node:child_process";
22
- import { promisify } from "node:util";
23
- import { createMcpClient } from "../mcp.mjs";
24
- import { establishSession, AuthUnavailableError } from "../auth.mjs";
25
- import { offerSignIn } from "./login.mjs";
26
- import { CliError, fail, formatError } from "../errors.mjs";
27
- import { writeNvmrc } from "../sample.mjs";
28
- import { emitObstacle } from "../obstacle.mjs";
29
-
30
- const execFileP = promisify(execFile);
31
-
32
- const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
33
-
34
- function parseArgs(argv) {
35
- const a = {
36
- tenant: null,
37
- tag: "main",
38
- clone: null,
39
- mcp: null,
40
- printRemote: false,
41
- help: false,
42
- };
43
- for (let i = 0; i < argv.length; i++) {
44
- const t = argv[i];
45
- if (t === "--tag") a.tag = argv[++i];
46
- else if (t === "--clone") a.clone = argv[++i];
47
- else if (t === "--mcp") a.mcp = argv[++i];
48
- else if (t === "--print-remote") a.printRemote = true;
49
- else if (t === "--help" || t === "-h") a.help = true;
50
- else if (!t.startsWith("--") && !a.tenant) a.tenant = t;
51
- }
52
- return a;
53
- }
54
-
55
- const USAGE = `tot checkout — clone a tenant store you can build on
56
-
57
- tot checkout list the stores you can build on
58
- tot checkout <tenant> show the checkout for <tenant>
59
- tot checkout <tenant> --clone <dir> clone it locally (authenticated remote configured)
60
-
61
- Options:
62
- --tag <tag> which repo (repo = "<tenant>-<tag>"). Default: main.
63
- --clone <dir> git clone the authenticated remote into <dir>.
64
- --mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
65
- ${DEFAULT_MCP_URL}.
66
- --print-remote also print the authenticated remote (contains a live token!).`;
67
-
68
- /** Redact known secrets from any string before it hits the terminal. */
69
- function makeRedactor(env) {
70
- const secrets = [env.TOT_API_KEY, env.TOT_SECRET_KEY].filter(Boolean);
71
- return (s) => {
72
- let o = String(s);
73
- for (const x of secrets) o = o.split(x).join("***");
74
- return o;
75
- };
76
- }
77
-
78
- /**
79
- * @param {string[]} argv - args AFTER `checkout`
80
- * @param {import("../context.mjs").detectContext extends (...a:any)=>infer R ? R : any} ctx
81
- */
82
- export async function run(argv, ctx) {
83
- const env = process.env;
84
- const args = parseArgs(argv);
85
- if (args.help) {
86
- console.log(USAGE);
87
- return 0;
88
- }
89
-
90
- // Infer the tenant from a standalone checkout when run without one.
91
- if (!args.tenant && ctx.mode === "checkout" && ctx.tenant) {
92
- args.tenant = ctx.tenant;
93
- }
94
-
95
- const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
96
- const redact = makeRedactor(env);
97
- const client = createMcpClient(baseUrl);
98
-
99
- try {
100
- // Attach auth in the right order relative to the handshake (developer bearer
101
- // BEFORE initialize) — see establishSession. When there's no session yet and
102
- // we're on a TTY, offer to sign in inline and retry once, so a not-signed-in
103
- // developer isn't dead-ended at "run tot login, then re-run".
104
- try {
105
- await establishSession(client, { env });
106
- } catch (e) {
107
- if (e instanceof AuthUnavailableError && e.reason === "missing") {
108
- const signedIn = await offerSignIn(client.mcpUrl, env, {});
109
- if (!signedIn) throw e; // declined/non-TTY → the crisp error below
110
- await establishSession(client, { env }); // retry once, in-flow
111
- } else {
112
- throw e;
113
- }
114
- }
115
- console.error(`~ signed in → ${client.mcpUrl}`);
116
-
117
- // No tenant → list the stores this identity can build on and stop.
118
- if (!args.tenant) {
119
- const list = await client.callTool("client_list", {});
120
- printClientList(list);
121
- return 0;
122
- }
123
-
124
- console.error(`~ client_switch → ${args.tenant}`);
125
- console.error(
126
- `~ tenant_checkout (tag=${args.tag}) — MCP mints a fresh repo-scoped credential`,
127
- );
128
- const res = await checkoutTenant(client, {
129
- tenant: args.tenant,
130
- tag: args.tag,
131
- cloneDir: args.clone || null,
132
- redact,
133
- });
134
- console.log(`\n+ checkout ready. repo: ${res.cloneUrl || res.publicUrl}`);
135
-
136
- if (res.cloned) {
137
- console.log(`+ cloned. HEAD: ${res.head}`);
138
- console.log(`\nYour local working clone is at ${res.dir} with an authenticated remote.`);
139
- console.log(` cd ${res.dir} && tot dev # run it locally with save→reload`);
140
- console.log(` (the minted token lives in .git/config; a later checkout rotates it)`);
141
- return 0;
142
- }
143
-
144
- console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
145
- console.log(`Re-run with --clone <dir> to clone with the authenticated remote configured.`);
146
- if (args.printRemote) {
147
- console.log(
148
- `\nAUTHENTICATED remote (contains a live token — handle carefully):\n${res.gitRemote}`,
149
- );
150
- }
151
- return 0;
152
- } catch (e) {
153
- if (e instanceof AuthUnavailableError) {
154
- console.error(formatError(e));
155
- return 1;
156
- }
157
- if (e instanceof CliError) {
158
- console.error(formatError(e));
159
- return e.exitCode ?? 1;
160
- }
161
- console.error(fail(`checkout failed: ${redact(String(e?.message || e))}`));
162
- return 1;
163
- }
164
- }
165
-
166
- /**
167
- * The privileged checkout core, composable in-process (used by `tot checkout`
168
- * and by `tot start`): client_switch → tenant_checkout → optionally clone. The
169
- * MCP mints a fresh, repo-scoped push credential each time. Assumes `client` is
170
- * already initialized + has a validated session.
171
- *
172
- * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
173
- * @param {{ tenant: string, tag?: string, cloneDir?: string|null, redact?: (s:string)=>string }} opts
174
- * @returns {Promise<{ gitRemote: string, cloneUrl: string|null, publicUrl: string,
175
- * cloned: boolean, dir: string|null, head: string|null }>}
176
- */
177
- export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
178
- await client.callTool("client_switch", { tenant });
179
- const checkout = await client.callTool("tenant_checkout", { tenant, tag });
180
-
181
- // A non-checkout result (not provisioned / not entitled / failed) must surface
182
- // the MCP's human message + a concrete next step, NEVER a raw JSON.stringify
183
- // dump (James's 2026-07-19 first-experience failure).
184
- const err = checkoutError(checkout);
185
- if (err) {
186
- throw new CliError(redact(err.message), { next: err.next });
187
- }
188
- const gitRemote = checkout.gitRemote;
189
- const cloneUrl = checkout.cloneUrl ?? null;
190
- const u = new URL(gitRemote);
191
- const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
192
-
193
- if (!cloneDir) {
194
- return { gitRemote, cloneUrl, publicUrl, cloned: false, dir: null, head: null };
195
- }
196
- const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact);
197
- return { gitRemote, cloneUrl, publicUrl, cloned: true, dir, head };
198
- }
199
-
200
- /**
201
- * git clone the authenticated remote into `dir`. Throws CliError on failure.
202
- * Runs git as a NON-BLOCKING child process (promisified execFile) so the clone
203
- * doesn't stall the Node event loop — `tot start` runs this inside a
204
- * `Promise.all([...])` alongside the renderer prefetch, and a synchronous clone
205
- * would serialize what's meant to overlap.
206
- */
207
- async function cloneRepo(gitRemote, dir, redact) {
208
- const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
209
- console.log(`+ git clone → ${dir}`);
210
- try {
211
- await git(["clone", gitRemote, dir]);
212
- } catch (e) {
213
- // Beacon the cockpit before we surface the error — covers this path for both
214
- // `tot checkout` and `tot start` (which clones through here). Awaited so the
215
- // packet lands before the process prints + exits; swallowed either way.
216
- await emitObstacle("clone-failed");
217
- throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
218
- next: `check the target dir is empty and you can reach the remote, then re-run`,
219
- });
220
- }
221
- const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
222
- writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
223
- return { dir, head };
224
- }
225
-
226
- /**
227
- * Classify a `tenant_checkout` tool result: null when it's a genuine, usable
228
- * checkout (carries a gitRemote), otherwise a human { message, next } pair so
229
- * callers surface the MCP's own words + a concrete next step instead of dumping
230
- * raw JSON (James's 2026-07-19 first-experience failure: a not-provisioned store
231
- * printed JSON.stringify(checkout)). callTool unwraps the tool result to its
232
- * structuredContent / parsed text, so a failure surfaces as an error-ish status
233
- * ('forbidden' | 'invalid_input' | 'checkout_failed' | 'error'), a `message`, or
234
- * simply a missing gitRemote. When the repo isn't provisioned yet we speak to the
235
- * INVITED DEVELOPER ("your store isn't set up yet"), not the operator — dropping
236
- * the `repo_provision` jargon the MCP aims at whoever provisions. Any other
237
- * failure surfaces the MCP's own message. Pure + exported so it's unit-tested
238
- * without any I/O.
239
- * @param {unknown} checkout
240
- * @returns {{ message: string, next: string }|null}
241
- */
242
- export function checkoutError(checkout) {
243
- const c = checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null;
244
- if (c && c.gitRemote) return null; // a usable checkout — never an error
245
- const msg =
246
- (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
247
- (c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
248
- null;
249
- // The store's repo isn't provisioned yet — it isn't set up for this developer.
250
- if (typeof msg === "string" && /not provisioned/i.test(msg)) {
251
- return {
252
- message: "your store isn't set up on Token of Trust yet",
253
- next: "ask your Token of Trust contact to finish setting up your store, then re-run",
254
- };
255
- }
256
- return {
257
- message: msg || "the store checkout couldn't be completed",
258
- next: "confirm you're entitled to this store — `tot checkout` (lists your stores)",
259
- };
260
- }
261
-
262
- /**
263
- * Normalize the (shape-varying) `client_list` response into a plain, sorted
264
- * list of the stores this identity can build on. Shared by `tot checkout`'s
265
- * listing and `tot start`'s auto-pick so both read the same fields.
266
- * @param {unknown} list
267
- * @returns {Array<{ id: string, name: string, raw: any }>}
268
- */
269
- export function normalizeStores(list) {
270
- const rows = Array.isArray(list) ? list : list?.clients || list?.tenants || [];
271
- if (!Array.isArray(rows)) return [];
272
- return rows
273
- .map((r) => ({
274
- id: r?.tenant || r?.id || r?.clientId || r?.appDomain || null,
275
- name: r?.displayName || r?.name || "",
276
- raw: r,
277
- }))
278
- .filter((r) => r.id);
279
- }
280
-
281
- /**
282
- * Detect whether a `client_list` tool result is an ERROR result rather than a
283
- * genuinely empty-but-successful store list — so callers surface it (identity +
284
- * origin + reason, per fb-1783905718950-f45zg9) instead of collapsing it to an
285
- * empty list and dead-ending at "ask for an invite". callTool unwraps a tool
286
- * result to its structuredContent / parsed text, so an error surfaces as a
287
- * status other than ok/success, an `error`/`isError` field, or an unparseable
288
- * `raw` text blob. A response that carries a resolvable store array (even empty)
289
- * is always a success. Returns a short human reason, or null when it's not an
290
- * error. Pure + exported so it's unit-tested without any I/O.
291
- * @param {unknown} list
292
- * @returns {string|null}
293
- */
294
- export function storeListError(list) {
295
- if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
296
- // A resolvable store array present → it succeeded, never an error.
297
- if (Array.isArray(list.clients) || Array.isArray(list.tenants)) return null;
298
- const msg =
299
- list.message ||
300
- (typeof list.error === "string" ? list.error : list.error?.message) ||
301
- null;
302
- if (list.isError) return msg || "the store list request returned an error";
303
- if (typeof list.status === "string" && !/^(ok|success)$/i.test(list.status)) {
304
- return msg || `the store list request returned status "${list.status}"`;
305
- }
306
- if (list.error) return msg || "the store list request returned an error";
307
- if (typeof list.raw === "string" && list.raw.trim()) return list.raw.trim();
308
- return null;
309
- }
310
-
311
- function printClientList(list) {
312
- const err = storeListError(list);
313
- const stores = normalizeStores(list);
314
- if (err && stores.length === 0) {
315
- console.log(`\nCouldn't list your stores: ${err}`);
316
- console.log("Run `tot whoami` to check your session, or `tot login` again.");
317
- return;
318
- }
319
- if (stores.length === 0) {
320
- console.log("\nNo stores available to build on for this identity yet.");
321
- console.log("If you were just invited, it may still be propagating — try again in a minute.");
322
- if (list && !Array.isArray(list)) console.log(JSON.stringify(list, null, 2));
323
- return;
324
- }
325
- console.log("\nStores you can build on:\n");
326
- for (const s of stores) {
327
- console.log(` ${s.id}${s.name ? ` — ${s.name}` : ""}`);
328
- }
329
- console.log(`\nNext: tot checkout <tenant> --clone <dir>`);
330
- }