@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.
- package/LICENSE +202 -0
- package/README.md +50 -0
- package/bin/tot.mjs +124 -0
- package/package.json +41 -0
- package/src/auth.mjs +132 -0
- package/src/commands/checkout.mjs +233 -0
- package/src/commands/dev.mjs +564 -0
- package/src/commands/doctor.mjs +172 -0
- package/src/commands/ideas.mjs +45 -0
- package/src/commands/login.mjs +107 -0
- package/src/commands/start.mjs +450 -0
- package/src/commands/submit.mjs +284 -0
- package/src/commands/validate.mjs +99 -0
- package/src/commands/whoami.mjs +69 -0
- package/src/context.mjs +97 -0
- package/src/errors.mjs +64 -0
- package/src/last-tenant.mjs +49 -0
- package/src/mcp.mjs +100 -0
- package/src/oauth.mjs +409 -0
- package/src/open.mjs +63 -0
- package/src/token-store.mjs +65 -0
- package/src/validate.mjs +291 -0
|
@@ -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
|
+
}
|