@tokenoftrust/cli 1.0.0

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,564 @@
1
+ /**
2
+ * `tot dev` — run your store locally with save→reload.
3
+ *
4
+ * The runtime splits by context:
5
+ *
6
+ * monorepo — the storefront runner IS present in the tree, so `tot dev`
7
+ * delegates to the proven scripts/tot-dev.mjs (astro dev + Vite HMR,
8
+ * monorepo or --workspace graft mode). Fast host loop for platform devs.
9
+ *
10
+ * checkout — a standalone tenant checkout has no runner. `tot dev` DOWNLOADS the
11
+ * published, moat-free RUNNER ARTIFACT (a plain tarball — the exact
12
+ * same tree Docker used to bake into the image, see
13
+ * scripts/build/build-runner.mjs) and runs it NATIVELY: no Docker, no
14
+ * container. Cached once per version under ~/.tot/cache/renderer/, so
15
+ * only the first run pays the download+install cost. The runner's own
16
+ * scripts/tot-dev.mjs grafts the checkout in and boots astro dev
17
+ * --host at http://localhost:<port>/<appDomain>/, with real (not
18
+ * polled) file-watch HMR. Nothing is published; product data renders
19
+ * from local fixtures. Docker remains available as a fallback
20
+ * (--docker, or automatically if the native artifact can't be
21
+ * fetched) — see runContainer below.
22
+ *
23
+ * IP note: the artifact is the pruned runner (control plane physically absent),
24
+ * vended by a short-lived signed URL (`dev_renderer_artifact`) gated on the same
25
+ * developer entitlement as the Docker pull token — no hand-provisioned AWS creds
26
+ * either way.
27
+ */
28
+ import { spawn, spawnSync, execFileSync } from "node:child_process";
29
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, createWriteStream } from "node:fs";
30
+ import { homedir, tmpdir } from "node:os";
31
+ import { join, resolve } from "node:path";
32
+ import { Readable } from "node:stream";
33
+ import { pipeline } from "node:stream/promises";
34
+ import { setTimeout as delay } from "node:timers/promises";
35
+ import { createMcpClient } from "../mcp.mjs";
36
+ import { resolveSession } from "../auth.mjs";
37
+ import { CliError, fail, formatError } from "../errors.mjs";
38
+ import { openBrowser, waitForServer } from "../open.mjs";
39
+
40
+ /** The published moat-free runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
41
+ const DEFAULT_DEV_IMAGE =
42
+ "242086487598.dkr.ecr.us-east-1.amazonaws.com/tot-dev:latest";
43
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
44
+ /** Where downloaded+installed renderer-artifact versions are cached, one dir per version. */
45
+ const RENDERER_CACHE_ROOT = join(homedir(), ".tot", "cache", "renderer");
46
+
47
+ export function parseArgs(argv) {
48
+ const a = {
49
+ workspace: null, port: "4321", image: null, mcp: null,
50
+ noLogin: false, noOpen: false, docker: false, help: false,
51
+ };
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const t = argv[i];
54
+ if (t === "--workspace") a.workspace = argv[++i];
55
+ else if (t === "--port") a.port = argv[++i];
56
+ else if (t === "--image") a.image = argv[++i];
57
+ else if (t === "--mcp") a.mcp = argv[++i];
58
+ else if (t === "--no-login") a.noLogin = true;
59
+ else if (t === "--no-open") a.noOpen = true;
60
+ else if (t === "--docker") a.docker = true;
61
+ else if (t === "--help" || t === "-h") a.help = true;
62
+ }
63
+ return a;
64
+ }
65
+
66
+ const USAGE = `tot dev — run your store locally with save→reload
67
+
68
+ tot dev <tenant> (in the monorepo) run tenants/<tenant>/
69
+ tot dev (in a checkout) run this store natively (no Docker)
70
+ tot dev --workspace <dir> run a specific checkout directory
71
+ tot dev --port <n> host port (default 4321)
72
+ tot dev --docker use the Docker runner image instead of the native path
73
+ tot dev --image <ref> runner image ref, only with --docker (default: the published image)
74
+ tot dev --no-open don't auto-open the browser when the server is up
75
+
76
+ Edit content/*.html or the theme + save → the browser reloads. Private local
77
+ preview — nothing is published. Prerequisites: Node.js and an invite — no Docker,
78
+ no hand-provisioned AWS creds.`;
79
+
80
+ /** @param {string[]} argv @param {any} ctx */
81
+ export function run(argv, ctx) {
82
+ const args = parseArgs(argv);
83
+ if (args.help) {
84
+ console.log(USAGE);
85
+ return 0;
86
+ }
87
+
88
+ // Monorepo: delegate to the in-tree runner (host astro dev + HMR).
89
+ if (ctx.mode === "monorepo" && !args.workspace) {
90
+ return runMonorepo(ctx, argv);
91
+ }
92
+
93
+ // Standalone checkout (or an explicit --workspace anywhere): native by default,
94
+ // Docker on request (--docker) or as an automatic fallback (see runStandalone).
95
+ const workspace = args.workspace
96
+ ? resolve(args.workspace)
97
+ : ctx.mode === "checkout"
98
+ ? ctx.workspacePath
99
+ : null;
100
+ if (!workspace) {
101
+ console.error(
102
+ fail(
103
+ "nothing to run — you're not inside a tenant checkout",
104
+ "tot checkout <tenant> --clone <dir> (then `cd` in and re-run), or pass --workspace <dir>",
105
+ ),
106
+ );
107
+ return 2;
108
+ }
109
+ return runStandalone(workspace, args, ctx);
110
+ }
111
+
112
+ /**
113
+ * Standalone-checkout entrypoint: native runtime by default (F3 — no Docker
114
+ * prerequisite), Docker on explicit request, and Docker as an automatic
115
+ * fallback when the native artifact can't be fetched (e.g. the MCP's
116
+ * dev_renderer_artifact isn't configured on this deployment yet) — so `tot dev`
117
+ * keeps working through the WS3 rollout instead of hard-failing.
118
+ */
119
+ async function runStandalone(workspace, args, ctx) {
120
+ if (args.docker) return runContainer(workspace, args, ctx);
121
+ try {
122
+ return await runNative(workspace, args, ctx);
123
+ } catch (e) {
124
+ if (e instanceof NativeArtifactUnavailableError) {
125
+ console.error(
126
+ `~ native runtime unavailable (${e.message}) — falling back to the Docker runner.`,
127
+ );
128
+ return runContainer(workspace, args, ctx);
129
+ }
130
+ console.error(formatError(e));
131
+ return e instanceof CliError ? (e.exitCode ?? 2) : 2;
132
+ }
133
+ }
134
+
135
+ function runMonorepo(ctx, argv) {
136
+ const script = join(ctx.repoRoot, "scripts", "tot-dev.mjs");
137
+ if (!existsSync(script)) {
138
+ console.error(`✗ expected the dev runner at ${script} but it's missing.`);
139
+ return 2;
140
+ }
141
+ return new Promise((resolvePromise) => {
142
+ const child = spawn(process.execPath, [script, ...argv], { stdio: "inherit" });
143
+ child.on("exit", (code) => resolvePromise(code ?? 0));
144
+ child.on("error", (e) => {
145
+ console.error(`✗ could not start the dev runner: ${e.message}`);
146
+ resolvePromise(1);
147
+ });
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Thrown when the native runtime can't be used for a reason that's about the
153
+ * artifact/service, not the developer's checkout — runStandalone catches this
154
+ * specifically and falls back to Docker instead of hard-failing. Exported so
155
+ * `tot start` (C1/F3 integration) can catch it too and fall back the same way.
156
+ */
157
+ export class NativeArtifactUnavailableError extends Error {}
158
+
159
+ /** Same URL shape buildContainerPlan uses — factored out so native + Docker agree. */
160
+ export function deriveUrl(cfg, port) {
161
+ const domain = typeof cfg.scope === "string" && cfg.scope.includes(".") ? cfg.scope : null;
162
+ return { domain, url: `http://localhost:${port}/${domain ? `${domain}/` : ""}` };
163
+ }
164
+
165
+ async function runNative(workspace, args, ctx) {
166
+ const cfg = readWorkspaceConfig(workspace);
167
+ if (!cfg) {
168
+ throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
169
+ next: "tot checkout <tenant> --clone <dir> (produces a runnable checkout)",
170
+ exitCode: 2,
171
+ });
172
+ }
173
+ const port = String(args.port || "4321");
174
+ const { domain, url } = deriveUrl(cfg, port);
175
+
176
+ const runnerDir = await ensureRendererArtifact(args);
177
+
178
+ printDevBanner({ tenant: cfg.tenant || null, url });
179
+
180
+ const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "inherit" });
181
+
182
+ // Auto-open the browser the moment the server answers (D). Non-blocking so
183
+ // Ctrl-C / logs are unaffected; --no-open suppresses it.
184
+ if (!args.noOpen) {
185
+ let opened = false;
186
+ waitForServer(url, { until: () => handle.exited })
187
+ .then((up) => {
188
+ if (up && !opened) {
189
+ opened = true;
190
+ openBrowser(url);
191
+ }
192
+ })
193
+ .catch(() => {});
194
+ }
195
+
196
+ return handle.done;
197
+ }
198
+
199
+ /**
200
+ * Get the moat-free renderer artifact for this host — a signed URL from the MCP
201
+ * (dev_renderer_artifact, same entitlement gate as the Docker pull token),
202
+ * downloaded + extracted + `pnpm install`'d ONCE per version under
203
+ * ~/.tot/cache/renderer/<version>/. Later runs of the same version reuse the
204
+ * cache with no network call at all. Throws NativeArtifactUnavailableError for
205
+ * anything that should fall back to Docker (MCP unreachable, not entitled, not
206
+ * configured, no corepack/pnpm on this host) rather than hard-failing `tot dev`.
207
+ *
208
+ * Exported + accepts an already-authenticated `client` (C1/F3 integration:
209
+ * `tot start` reuses its own session and overlaps this with the checkout clone
210
+ * instead of paying for a second client.initialize()+resolveSession() — same
211
+ * pattern as ensureRegistryLogin's `providedClient`). `tot dev` standalone
212
+ * omits it and this establishes its own, as before.
213
+ * @returns {Promise<string>} the cached, installed runner tree's root directory.
214
+ */
215
+ export async function ensureRendererArtifact(args, { client: providedClient } = {}) {
216
+ const baseUrl = args.mcp || process.env.MCP_BASE_URL || process.env.TOT_MCP_URL || DEFAULT_MCP_URL;
217
+ let credential;
218
+ try {
219
+ const client = providedClient || createMcpClient(baseUrl);
220
+ if (!providedClient) {
221
+ await client.initialize();
222
+ await resolveSession(client, { env: process.env });
223
+ }
224
+ const res = await client.callTool("dev_renderer_artifact", {});
225
+ if (!res?.url || !res?.version) {
226
+ throw new Error(res?.error || "no renderer-artifact URL returned");
227
+ }
228
+ credential = res;
229
+ } catch (e) {
230
+ throw new NativeArtifactUnavailableError(String(e?.message || e));
231
+ }
232
+
233
+ const runnerDir = join(RENDERER_CACHE_ROOT, credential.version);
234
+ const marker = join(runnerDir, ".tot-cache-complete");
235
+ if (existsSync(marker)) return runnerDir; // already downloaded + installed
236
+
237
+ console.error(`~ fetching the native renderer (version ${credential.version}, first run only)...`);
238
+ const archivePath = join(tmpdir(), `tot-renderer-${process.pid}-${Date.now()}.tar.gz`);
239
+ try {
240
+ await downloadFile(credential.url, archivePath);
241
+ const stagingDir = `${runnerDir}.staging-${process.pid}`;
242
+ rmSync(stagingDir, { recursive: true, force: true });
243
+ mkdirSync(stagingDir, { recursive: true });
244
+ extractTarball(archivePath, stagingDir);
245
+ ensureCorepackPnpm(stagingDir);
246
+ runPnpmInstall(stagingDir);
247
+ // Atomic-ish: only rename into the final, discoverable path once install
248
+ // succeeded, so a crashed/interrupted run never leaves a half-built cache
249
+ // entry that a later `tot dev` would treat as ready.
250
+ rmSync(runnerDir, { recursive: true, force: true });
251
+ renameSync(stagingDir, runnerDir);
252
+ writeFileSync(marker, new Date().toISOString());
253
+ } catch (e) {
254
+ throw new NativeArtifactUnavailableError(String(e?.message || e));
255
+ } finally {
256
+ rmSync(archivePath, { force: true });
257
+ }
258
+ return runnerDir;
259
+ }
260
+
261
+ /** Stream `url` to `destPath`. Throws on a non-2xx response or a network failure. */
262
+ async function downloadFile(url, destPath) {
263
+ const res = await fetch(url);
264
+ if (!res.ok || !res.body) {
265
+ throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
266
+ }
267
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
268
+ }
269
+
270
+ /** Extract a .tar.gz into `destDir` using the system `tar` (present on macOS/Linux/WSL). */
271
+ function extractTarball(archivePath, destDir) {
272
+ const r = spawnSync("tar", ["xzf", archivePath, "-C", destDir], { stdio: ["ignore", "ignore", "pipe"] });
273
+ if (r.status !== 0) {
274
+ throw new Error(`tar extraction failed: ${r.stderr?.toString().trim() || `exit ${r.status}`}`);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Activate the exact package manager the runner's package.json pins
280
+ * (packageManager: "pnpm@x.y.z"), via corepack — Node 20+ ships corepack, so
281
+ * this needs no separate pnpm install on the host. Best-effort: if corepack
282
+ * itself is missing (very old Node), pnpm install below will surface that
283
+ * clearly instead.
284
+ */
285
+ function ensureCorepackPnpm(runnerDir) {
286
+ const pkgPath = join(runnerDir, "package.json");
287
+ if (!existsSync(pkgPath)) return;
288
+ let pm;
289
+ try {
290
+ pm = JSON.parse(readFileSync(pkgPath, "utf8")).packageManager;
291
+ } catch {
292
+ return;
293
+ }
294
+ if (!pm) return;
295
+ spawnSync("corepack", ["enable"], { stdio: "ignore" });
296
+ spawnSync("corepack", ["prepare", pm, "--activate"], { stdio: "ignore" });
297
+ }
298
+
299
+ function runPnpmInstall(runnerDir) {
300
+ const r = spawnSync("pnpm", ["install", "--config.dangerouslyAllowAllBuilds=true"], {
301
+ cwd: runnerDir,
302
+ stdio: "inherit",
303
+ });
304
+ if (r.status !== 0) {
305
+ throw new Error(`pnpm install failed (exit ${r.status}) — is pnpm/corepack available on this host?`);
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Run the cached runner's own scripts/tot-dev.mjs in standalone (--workspace)
311
+ * mode — the exact code path the WS3 spike verified gives native, non-polled
312
+ * fs-watch HMR. Returns a handle shaped like spawnDevContainer's, so run()'s
313
+ * auto-open-browser logic works unchanged for either runtime. Exported (and
314
+ * `stdio` overridable) so `tot start` can pipe the logs instead of inheriting.
315
+ */
316
+ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit" } = {}) {
317
+ const script = join(runnerDir, "scripts", "tot-dev.mjs");
318
+ const child = spawn(
319
+ process.execPath,
320
+ [script, "--workspace", workspace, "--port", port],
321
+ { cwd: runnerDir, stdio },
322
+ );
323
+ const handle = { child, exited: false, done: null };
324
+ handle.done = new Promise((resolvePromise) => {
325
+ child.on("exit", (code) => {
326
+ handle.exited = true;
327
+ resolvePromise(code ?? 0);
328
+ });
329
+ child.on("error", (e) => {
330
+ handle.exited = true;
331
+ console.error(fail(`could not start the native dev runner: ${e.message}`));
332
+ resolvePromise(1);
333
+ });
334
+ });
335
+ return handle;
336
+ }
337
+
338
+ async function runContainer(workspace, args, ctx) {
339
+ let plan;
340
+ try {
341
+ plan = buildContainerPlan(workspace, args, ctx);
342
+ } catch (e) {
343
+ console.error(formatError(e));
344
+ return e instanceof CliError ? (e.exitCode ?? 2) : 2;
345
+ }
346
+
347
+ printDevBanner(plan);
348
+
349
+ const handle = await spawnDevContainer(plan, args, { stdio: "inherit" });
350
+
351
+ // Auto-open the browser the moment the server answers (D). Non-blocking so
352
+ // Ctrl-C / logs are unaffected; --no-open suppresses it.
353
+ if (!args.noOpen) {
354
+ let opened = false;
355
+ waitForServer(plan.url, { until: () => handle.exited })
356
+ .then((up) => {
357
+ if (up && !opened) {
358
+ opened = true;
359
+ openBrowser(plan.url);
360
+ }
361
+ })
362
+ .catch(() => {});
363
+ }
364
+
365
+ return handle.done;
366
+ }
367
+
368
+ /**
369
+ * Resolve the runner image ref the same way buildContainerPlan does — exported
370
+ * so a caller (`tot start`, for C1) can know the image, and therefore prefetch
371
+ * a registry login (below), before the checkout dir exists.
372
+ */
373
+ export function resolveDevImage(args, env = process.env) {
374
+ return args.image || env.TOT_DEV_IMAGE || DEFAULT_DEV_IMAGE;
375
+ }
376
+
377
+ /**
378
+ * Validate + assemble everything needed to run a standalone checkout in the
379
+ * runner container, without side effects. Throws a CliError (with the exact
380
+ * next command) when the checkout or Docker isn't ready. Shared by `tot dev`
381
+ * and `tot start`.
382
+ *
383
+ * @param {string} workspace @param {ReturnType<typeof parseArgs>} args @param {any} _ctx
384
+ * @returns {{ tenant: string|null, image: string, port: string, domain: string|null,
385
+ * url: string, workspace: string, dockerArgs: string[] }}
386
+ */
387
+ export function buildContainerPlan(workspace, args, _ctx) {
388
+ const cfg = readWorkspaceConfig(workspace);
389
+ if (!cfg) {
390
+ throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
391
+ next: "tot checkout <tenant> --clone <dir> (produces a runnable checkout)",
392
+ exitCode: 2,
393
+ });
394
+ }
395
+ if (!dockerAvailable()) {
396
+ throw new CliError("`tot dev` needs Docker to run a standalone checkout, but Docker isn't running", {
397
+ next:
398
+ process.platform === "darwin"
399
+ ? "open -a Docker (then re-run) — or install it: https://docs.docker.com/get-docker/"
400
+ : "start Docker (e.g. `sudo systemctl start docker`), then re-run — install: https://docs.docker.com/get-docker/",
401
+ exitCode: 2,
402
+ });
403
+ }
404
+
405
+ const image = resolveDevImage(args);
406
+ const port = String(args.port || "4321");
407
+ const domain = typeof cfg.scope === "string" && cfg.scope.includes(".") ? cfg.scope : null;
408
+ const url = `http://localhost:${port}/${domain ? `${domain}/` : ""}`;
409
+
410
+ // docker run equivalent of scripts/dev/docker-compose.pull.yml: bind-mount the
411
+ // checkout, map the port, force polling (reliable HMR across the mount), pull
412
+ // if the image is missing. --rm + --init so Ctrl-C tears it down cleanly.
413
+ const dockerArgs = [
414
+ "run",
415
+ "--rm",
416
+ "--init",
417
+ "--pull",
418
+ "missing",
419
+ "-p",
420
+ `${port}:4321`,
421
+ "-v",
422
+ `${workspace}:/workspace`,
423
+ "-e",
424
+ "CHOKIDAR_USEPOLLING=1",
425
+ "-e",
426
+ "CHOKIDAR_INTERVAL=300",
427
+ image,
428
+ ];
429
+
430
+ return { tenant: cfg.tenant || null, image, port, domain, url, workspace, dockerArgs };
431
+ }
432
+
433
+ /** The crafted "here's your running store" block (Vite/`vercel dev`-grade). */
434
+ export function printDevBanner(plan) {
435
+ console.error(`\n tot dev — ${plan.tenant || "(tenant)"}`);
436
+ console.error(` ➜ Local: ${plan.url}`);
437
+ console.error(` ➜ Edit: content/home.html + save → the browser reloads`);
438
+ console.error(` ➜ Private local preview — nothing is published. Ctrl-C to stop.\n`);
439
+ }
440
+
441
+ /**
442
+ * Pull-credential + spawn the runner container. Returns a handle:
443
+ * { child, done, exited } — `done` resolves to the exit code, `exited` is a
444
+ * live boolean (for readiness polling to bail if the container dies early).
445
+ * `stdio` is "inherit" (a normal `tot dev`) or "piped" (so `tot start` can hold
446
+ * the prompt on stdin and stream logs after the aha).
447
+ *
448
+ * @param {ReturnType<typeof buildContainerPlan>} plan
449
+ * @param {ReturnType<typeof parseArgs>} args
450
+ * @param {{ stdio?: "inherit"|"piped" }} [opts]
451
+ */
452
+ export async function spawnDevContainer(plan, args, { stdio = "inherit" } = {}) {
453
+ // Obtain a registry pull credential from the MCP (entitlement-gated) before
454
+ // pulling a private image. Best-effort: falls back to any existing docker login.
455
+ if (!args.noLogin && isPrivateRegistryImage(plan.image)) {
456
+ await ensureRegistryLogin(plan.image, args);
457
+ }
458
+
459
+ const stdioArr =
460
+ stdio === "piped" ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"];
461
+ const child = spawn("docker", plan.dockerArgs, { stdio: stdioArr });
462
+
463
+ const handle = { child, exited: false, done: null };
464
+ handle.done = new Promise((resolvePromise) => {
465
+ child.on("exit", (code) => {
466
+ handle.exited = true;
467
+ if (code && code !== 130 /* SIGINT */ && stdio === "inherit") {
468
+ console.error(
469
+ fail(
470
+ `the runner container exited (code ${code})`,
471
+ `if it couldn't pull ${plan.image}, sign in or ask your ToT contact for a registry pull credential, then re-run`,
472
+ ),
473
+ );
474
+ }
475
+ resolvePromise(code ?? 0);
476
+ });
477
+ child.on("error", (e) => {
478
+ handle.exited = true;
479
+ console.error(fail(`could not start Docker: ${e.message}`, "confirm Docker is installed and running, then re-run"));
480
+ resolvePromise(1);
481
+ });
482
+ });
483
+ return handle;
484
+ }
485
+
486
+ /** Does this image live in a private registry that needs an authenticated pull? */
487
+ export function isPrivateRegistryImage(image) {
488
+ const host = String(image).split("/")[0];
489
+ // A registry host has a dot or a port; ECR/GHCR/etc. Docker Hub short names don't.
490
+ return host.includes(".dkr.ecr.") || /\.(amazonaws|azurecr|pkg\.dev)\b/.test(host);
491
+ }
492
+
493
+ /**
494
+ * Entitlement-gated registry login: ask the MCP for a short-lived pull credential
495
+ * (`dev_image_pull_token`) and `docker login` with it. Best-effort — if the MCP,
496
+ * the session, or the tool isn't available yet, warn softly and continue (an
497
+ * existing `docker login`, or the docker pull error, takes over). This is the CLI
498
+ * half of ~/.tot-mcp/handoffs/2026-07-09-tot-mcp-dev-image-pull-token.md.
499
+ *
500
+ * Exported + accepts an already-authenticated `client` (C1: `tot start` reuses
501
+ * its own session and overlaps this with the checkout clone instead of paying
502
+ * for a second client.initialize()+resolveSession() serially afterward).
503
+ * `tot dev` standalone omits it and this establishes its own, as before.
504
+ */
505
+ export async function ensureRegistryLogin(image, args, { client: providedClient } = {}) {
506
+ const registry = String(image).split("/")[0];
507
+ const baseUrl = args.mcp || process.env.MCP_BASE_URL || process.env.TOT_MCP_URL || DEFAULT_MCP_URL;
508
+ try {
509
+ const client = providedClient || createMcpClient(baseUrl);
510
+ if (!providedClient) {
511
+ await client.initialize();
512
+ await resolveSession(client, { env: process.env });
513
+ }
514
+ const tok = await client.callTool("dev_image_pull_token", {});
515
+ const username = tok?.username || "AWS";
516
+ const password = tok?.password;
517
+ const reg = tok?.registry || registry;
518
+ if (!password) throw new Error("no credential returned");
519
+ execFileSync("docker", ["login", "--username", username, "--password-stdin", reg], {
520
+ input: password,
521
+ stdio: ["pipe", "ignore", "ignore"],
522
+ });
523
+ console.error(`~ registry sign-in ok (${reg})`);
524
+ } catch (e) {
525
+ console.error(
526
+ `~ (using existing docker login for ${registry} — MCP pull-token not available: ${String(e?.message || e)})`,
527
+ );
528
+ }
529
+ }
530
+
531
+ function readWorkspaceConfig(dir) {
532
+ const p = join(dir, ".tot", "config.json");
533
+ if (!existsSync(p)) return null;
534
+ try {
535
+ return JSON.parse(readFileSync(p, "utf8"));
536
+ } catch {
537
+ return null;
538
+ }
539
+ }
540
+
541
+ export function dockerAvailable() {
542
+ const r = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
543
+ stdio: ["ignore", "ignore", "ignore"],
544
+ });
545
+ return r.status === 0;
546
+ }
547
+
548
+ /**
549
+ * Best-effort local Docker remediation (macOS only — Docker Desktop is the
550
+ * thing `open -a` can launch unattended; other platforms report false and
551
+ * leave it to the caller's usual next-command message). Returns true once
552
+ * Docker answers, false if it doesn't within the wait window. Shared by
553
+ * `tot start`'s preflight and `tot doctor --fix` (F2).
554
+ */
555
+ export async function tryStartDocker() {
556
+ if (process.platform !== "darwin") return false;
557
+ console.log(" ~ Docker isn't running — starting Docker Desktop…");
558
+ spawnSync("open", ["-a", "Docker"], { stdio: "ignore" });
559
+ for (let i = 0; i < 30; i++) {
560
+ await delay(1500);
561
+ if (dockerAvailable()) return true;
562
+ }
563
+ return false;
564
+ }