@tokenoftrust/cli 1.3.4-rc.3 → 1.3.4-rc.5
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/bin/tot.cjs +44 -16
- package/bin/tot.mjs +8 -0
- package/package.json +2 -1
- package/src/app-scaffold.mjs +84 -0
- package/src/banner.mjs +46 -0
- package/src/commands/app/dev.mjs +268 -0
- package/src/commands/app/index.mjs +35 -0
- package/src/commands/app/scaffold.mjs +57 -0
- package/src/commands/checkout.mjs +5 -0
- package/src/commands/dev.mjs +61 -14
- package/src/commands/start.mjs +76 -17
- package/src/commands/validate.mjs +4 -0
- package/src/dev-logs.mjs +34 -7
- package/src/obstacle-beacon.cjs +111 -0
- package/src/obstacle.mjs +35 -0
- package/src/validate.mjs +77 -8
- package/src/vendor/private-apps-devkit.mjs +490 -0
- package/template/private-app/.env.example +10 -0
- package/template/private-app/Dockerfile +12 -0
- package/template/private-app/README.md +45 -0
- package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
- package/template/private-app/server.js +81 -0
- package/template/private-app/tot-app.json +29 -0
- package/template/sample-store/content/chrome.html +140 -0
- package/template/sample-store/content/chrome.json +86 -0
- package/template/sample-store/content/home.html +121 -0
- package/template/sample-store/content/home.json +50 -0
- package/template/sample-store/content/pages/about.json +10 -0
- package/template/sample-store/content/pages/privacy.json +10 -0
- package/template/sample-store/content/pages/shipping-returns.json +10 -0
- package/template/sample-store/content/pages-html/blogs/news.html +26 -0
- package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
- package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
- package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
- package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
- package/template/sample-store/public/logo.svg +6 -0
- package/template/sample-store/public/pages/home.css +120 -0
- package/template/sample-store/public/pages/mkt.css +185 -0
- package/template/sample-store/public/pages/page.css +155 -0
- package/template/sample-store/public/themes/sample.css +76 -0
- package/template/sample-store/theme.json +38 -0
package/src/commands/dev.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
42
42
|
import { createMcpClient, CLI_VERSION, setRunnerVersion, versionStamp } from "../mcp.mjs";
|
|
43
43
|
import { establishSession } from "../auth.mjs";
|
|
44
44
|
import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
|
|
45
|
+
import { emitObstacle } from "../obstacle.mjs";
|
|
45
46
|
import { CliError, fail, formatError } from "../errors.mjs";
|
|
46
47
|
import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
|
|
47
48
|
import { startProgress } from "../progress.mjs";
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
} from "../sample.mjs";
|
|
52
53
|
import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
|
|
53
54
|
import { streamDevLogs } from "../dev-logs.mjs";
|
|
55
|
+
import { cockpitUrlFrom } from "../banner.mjs";
|
|
54
56
|
|
|
55
57
|
/** The published runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
|
|
56
58
|
const DEFAULT_DEV_IMAGE =
|
|
@@ -251,7 +253,7 @@ function runMonorepo(ctx, argv) {
|
|
|
251
253
|
// still tears the runner down — the child stays in our process group and
|
|
252
254
|
// owns the TTY signal.
|
|
253
255
|
const child = spawn(process.execPath, [script, ...argv], { stdio: ["ignore", "pipe", "pipe"], env });
|
|
254
|
-
streamDevLogs(child);
|
|
256
|
+
streamDevLogs(child, { cockpitUrl: cockpitUrlFrom(env.TOT_DEV_ACTIVITY_URL) });
|
|
255
257
|
child.on("exit", (code) => resolvePromise(code ?? 0));
|
|
256
258
|
child.on("error", (e) => {
|
|
257
259
|
console.error(`✗ could not start the dev runner: ${e.message}`);
|
|
@@ -322,7 +324,7 @@ export function bootNative(runnerDir, workspace, port, url, args) {
|
|
|
322
324
|
// dropped, real errors pass through. Ctrl-C still tears the server down — the
|
|
323
325
|
// child stays in our process group and owns the TTY signals.
|
|
324
326
|
const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "piped", env: bridgeEnv });
|
|
325
|
-
streamDevLogs(handle.child);
|
|
327
|
+
streamDevLogs(handle.child, { cockpitUrl: cockpitUrlFrom(bridgeEnv.TOT_DEV_ACTIVITY_URL) });
|
|
326
328
|
|
|
327
329
|
// Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
|
|
328
330
|
// URL while the runner runs — CLI-side, using the SAME bridge credential the
|
|
@@ -928,7 +930,7 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
|
|
|
928
930
|
stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
|
|
929
931
|
});
|
|
930
932
|
try {
|
|
931
|
-
runPnpmInstall(stagingDir, { logPath: installLog });
|
|
933
|
+
await runPnpmInstall(stagingDir, { logPath: installLog });
|
|
932
934
|
} finally {
|
|
933
935
|
installSpin.stop();
|
|
934
936
|
}
|
|
@@ -982,12 +984,15 @@ function extractTarball(archivePath, destDir, { strip = 0 } = {}) {
|
|
|
982
984
|
|
|
983
985
|
/**
|
|
984
986
|
* Activate the exact package manager the runner's package.json pins
|
|
985
|
-
* (packageManager: "pnpm@x.y.z"), via corepack — Node
|
|
986
|
-
* this
|
|
987
|
-
*
|
|
988
|
-
*
|
|
987
|
+
* (packageManager: "pnpm@x.y.z"), via corepack — Node 22.12+ (the CLI's floor)
|
|
988
|
+
* always bundles corepack, so this PINS a real pnpm without any global `npm i -g
|
|
989
|
+
* pnpm` (which floats whatever's latest). Best-effort: if corepack itself is
|
|
990
|
+
* missing or disabled (a locked-down host), pnpm install below falls through to
|
|
991
|
+
* the pnpm-missing obstacle instead of failing here.
|
|
992
|
+
* @param {string} runnerDir
|
|
993
|
+
* @param {{ logPath?: string, spawnFn?: typeof spawnSync }} [opts]
|
|
989
994
|
*/
|
|
990
|
-
function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
|
|
995
|
+
export function ensureCorepackPnpm(runnerDir, { logPath, spawnFn = spawnSync } = {}) {
|
|
991
996
|
const pkgPath = join(runnerDir, "package.json");
|
|
992
997
|
if (!existsSync(pkgPath)) return;
|
|
993
998
|
let pm;
|
|
@@ -1005,7 +1010,7 @@ function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
|
|
|
1005
1010
|
// WITHOUT the global shim (see runPnpmInstall's fallback). Capture both to the
|
|
1006
1011
|
// log — a silent corepack failure was why the install error carried no cause.
|
|
1007
1012
|
for (const args of [["enable"], ["prepare", pm, "--activate"]]) {
|
|
1008
|
-
const r =
|
|
1013
|
+
const r = spawnFn("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
|
|
1009
1014
|
if (fd !== null && (r.error || r.status !== 0)) {
|
|
1010
1015
|
writeSync(fd, `[tot] corepack ${args.join(" ")} → ${r.error?.code || r.error?.message || `exit ${r.status}`}\n`);
|
|
1011
1016
|
}
|
|
@@ -1021,8 +1026,37 @@ function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
|
|
|
1021
1026
|
* churn) is captured to a log file instead of flooding the terminal, so the
|
|
1022
1027
|
* caller's spinner owns the screen. On failure we surface a clean, business-
|
|
1023
1028
|
* readable message + the log path — never the raw node/pnpm firehose.
|
|
1029
|
+
* @param {string} runnerDir
|
|
1030
|
+
* @param {{ logPath?: string, spawnFn?: typeof spawnSync }} [opts]
|
|
1024
1031
|
*/
|
|
1025
|
-
|
|
1032
|
+
/**
|
|
1033
|
+
* Run a child process to completion WITHOUT blocking the event loop, resolving
|
|
1034
|
+
* the SAME `{ status, error }` shape spawnSync returns so runPnpmInstall's
|
|
1035
|
+
* launcher-fallback logic reads it unchanged. This non-blocking spawn is what
|
|
1036
|
+
* lets the "installing the store preview engine…" spinner keep ticking during
|
|
1037
|
+
* the long npm install (spawnSync would freeze it at "(0s)"). An ENOENT (launcher
|
|
1038
|
+
* not on PATH) surfaces via the async 'error' event as `{ status: null, error }`,
|
|
1039
|
+
* matching what spawnSync produced for the same case.
|
|
1040
|
+
* @param {string} cmd @param {string[]} args @param {object} opts
|
|
1041
|
+
* @returns {Promise<{ status: number|null, error: Error|null, signal?: string|null }>}
|
|
1042
|
+
*/
|
|
1043
|
+
function spawnAsyncResult(cmd, args, opts = {}) {
|
|
1044
|
+
return new Promise((resolvePromise) => {
|
|
1045
|
+
let settled = false;
|
|
1046
|
+
const settle = (v) => { if (!settled) { settled = true; resolvePromise(v); } };
|
|
1047
|
+
let child;
|
|
1048
|
+
try {
|
|
1049
|
+
child = spawn(cmd, args, opts);
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
settle({ status: null, error });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
child.on("error", (error) => settle({ status: null, error }));
|
|
1055
|
+
child.on("close", (status, signal) => settle({ status, error: null, signal }));
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
|
|
1026
1060
|
const fd = logPath ? openSync(logPath, "a") : null;
|
|
1027
1061
|
const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
|
|
1028
1062
|
// npm FIRST: it ships with EVERY Node (including 25+, where corepack is no
|
|
@@ -1043,7 +1077,13 @@ function runPnpmInstall(runnerDir, { logPath } = {}) {
|
|
|
1043
1077
|
];
|
|
1044
1078
|
try {
|
|
1045
1079
|
for (const { cmd, args } of attempts) {
|
|
1046
|
-
|
|
1080
|
+
// Awaited: the default spawnFn (spawnAsyncResult) runs the launcher
|
|
1081
|
+
// NON-BLOCKING so the caller's "installing…" spinner keeps ticking during
|
|
1082
|
+
// the (long, one-time) npm install instead of freezing at "(0s)" — a
|
|
1083
|
+
// synchronous spawnSync would hold the event loop and starve the interval.
|
|
1084
|
+
// Tests inject a synchronous stub returning { status, error }; awaiting a
|
|
1085
|
+
// plain (non-thenable) value is a no-op, so that contract is unchanged.
|
|
1086
|
+
const r = await spawnFn(cmd, args, {
|
|
1047
1087
|
cwd: runnerDir,
|
|
1048
1088
|
// Send both streams to the log fd (or swallow them) — never inherit.
|
|
1049
1089
|
stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
|
|
@@ -1055,6 +1095,7 @@ function runPnpmInstall(runnerDir, { logPath } = {}) {
|
|
|
1055
1095
|
continue;
|
|
1056
1096
|
}
|
|
1057
1097
|
// The launcher ran; the install itself failed. That's the actionable error.
|
|
1098
|
+
await emitObstacle("install-failed");
|
|
1058
1099
|
throw new CliError(
|
|
1059
1100
|
`couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
|
|
1060
1101
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
@@ -1064,6 +1105,7 @@ function runPnpmInstall(runnerDir, { logPath } = {}) {
|
|
|
1064
1105
|
// Every launcher ENOENT'd → there's no pnpm on this machine and corepack
|
|
1065
1106
|
// couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
|
|
1066
1107
|
// every Node, so `npm i -g pnpm` is the escape hatch that always exists.
|
|
1108
|
+
await emitObstacle("pnpm-missing");
|
|
1067
1109
|
throw new CliError(
|
|
1068
1110
|
"couldn't set up the store preview engine — pnpm isn't available on this machine" +
|
|
1069
1111
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
@@ -1107,11 +1149,16 @@ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit",
|
|
|
1107
1149
|
// doesn't understand the bare string "piped", so map it to the array here.
|
|
1108
1150
|
const stdioArr = stdio === "piped" ? ["ignore", "pipe", "pipe"] : stdio;
|
|
1109
1151
|
// `env` (e.g. activityBridgeEnv()) merges OVER process.env — {} is a pure
|
|
1110
|
-
// passthrough, identical to the old no-env-key behavior.
|
|
1152
|
+
// passthrough, identical to the old no-env-key behavior. Append `--no-warnings`
|
|
1153
|
+
// to NODE_OPTIONS so the runner's Node process never emits the scary
|
|
1154
|
+
// `(node:NNNN) ExperimentalWarning: …` boot noise to the developer (b4);
|
|
1155
|
+
// appended (not replaced) so any host-set NODE_OPTIONS is preserved.
|
|
1156
|
+
const mergedEnv = { ...process.env, ...env };
|
|
1157
|
+
mergedEnv.NODE_OPTIONS = `${mergedEnv.NODE_OPTIONS ? `${mergedEnv.NODE_OPTIONS} ` : ""}--no-warnings`;
|
|
1111
1158
|
const child = spawn(
|
|
1112
1159
|
process.execPath,
|
|
1113
1160
|
[script, "--workspace", workspace, "--port", port],
|
|
1114
|
-
{ cwd: runnerDir, stdio: stdioArr, env:
|
|
1161
|
+
{ cwd: runnerDir, stdio: stdioArr, env: mergedEnv },
|
|
1115
1162
|
);
|
|
1116
1163
|
const handle = { child, exited: false, done: null };
|
|
1117
1164
|
handle.done = new Promise((resolvePromise) => {
|
|
@@ -1145,7 +1192,7 @@ async function runContainer(workspace, args, ctx) {
|
|
|
1145
1192
|
// piping is safe; Ctrl-C still stops the container (docker stays in our
|
|
1146
1193
|
// process group and forwards the signal, with `--init` reaping it inside).
|
|
1147
1194
|
const handle = await spawnDevContainer(plan, args, { stdio: "piped" });
|
|
1148
|
-
streamDevLogs(handle.child);
|
|
1195
|
+
streamDevLogs(handle.child, { cockpitUrl: cockpitUrlFrom(activityBridgeEnv().TOT_DEV_ACTIVITY_URL) });
|
|
1149
1196
|
|
|
1150
1197
|
// Heartbeat the hosted cockpit (G1) CLI-side while the container runs — the
|
|
1151
1198
|
// container reports file-saves via the threaded env, but the CLI owns the
|
package/src/commands/start.mjs
CHANGED
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
import { scaffoldSample, isSampleCheckout, sampleConfig, SAMPLE_DIR_NAME } from "../sample.mjs";
|
|
68
68
|
import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
|
|
69
69
|
import { streamDevLogs } from "../dev-logs.mjs";
|
|
70
|
+
import { milestoneBanner, cockpitUrlFrom, DEVELOPER_COCKPIT } from "../banner.mjs";
|
|
70
71
|
import { IDEAS } from "./ideas.mjs";
|
|
71
72
|
|
|
72
73
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
@@ -196,15 +197,36 @@ export async function run(argv, ctx) {
|
|
|
196
197
|
await preflight(ctx, env);
|
|
197
198
|
console.log(` ✓ signed in as ${describeIdentity(session)} via ${mcpOrigin(baseUrl)}`);
|
|
198
199
|
|
|
200
|
+
// Resolve the activity-bridge credential + the hosted Developer Cockpit URL
|
|
201
|
+
// ONCE, up front — both the pre-pick "keep the cockpit alive" heartbeat below
|
|
202
|
+
// (b2) and the post-boot heartbeat + milestone banners (u2) use them.
|
|
203
|
+
const bridgeEnv = activityBridgeEnv(env);
|
|
204
|
+
const cockpitUrl = cockpitUrlFrom(bridgeEnv.TOT_DEV_ACTIVITY_URL);
|
|
205
|
+
|
|
206
|
+
// b2 — a >1-store identity blocks on the interactive store picker below, and
|
|
207
|
+
// (before this) the hosted cockpit got NO heartbeat until the dev server
|
|
208
|
+
// spawned much later, so a multi-store dev's cockpit sat on Step 1 forever
|
|
209
|
+
// while the terminal quietly waited for a number. Start beating NOW (no local
|
|
210
|
+
// URL yet — the store isn't checked out) so the cockpit keeps receiving
|
|
211
|
+
// liveness during the pick + checkout + boot; the post-boot heartbeat (with
|
|
212
|
+
// the live localhost URL) takes over once we have it.
|
|
213
|
+
const stopEarlyHeartbeat = startHeartbeatFromEnv(bridgeEnv, {});
|
|
214
|
+
|
|
199
215
|
// 3. store — auto-pick, use --tenant, use the remembered one, or choose (A4).
|
|
200
216
|
// Keep the raw client_list so a tool ERROR (unauthenticated / not-entitled) is
|
|
201
217
|
// surfaced with who + where, not collapsed to an empty list (fb-...f45zg9).
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
218
|
+
let tenant;
|
|
219
|
+
try {
|
|
220
|
+
const listResp = await client.callTool("client_list", {});
|
|
221
|
+
const stores = normalizeStores(listResp);
|
|
222
|
+
tenant = await resolveTenant(stores, args, env, baseUrl, {
|
|
223
|
+
session,
|
|
224
|
+
listErr: storeListError(listResp),
|
|
225
|
+
});
|
|
226
|
+
} catch (e) {
|
|
227
|
+
stopEarlyHeartbeat();
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
208
230
|
|
|
209
231
|
// 4. checkout → ./<tenant> (reuse an existing checkout on a re-run),
|
|
210
232
|
// OVERLAPPED (C1) with prefetching the runner: the native artifact by
|
|
@@ -225,11 +247,15 @@ export async function run(argv, ctx) {
|
|
|
225
247
|
prefetchRuntime(client, devArgs, env, runtime, ctx),
|
|
226
248
|
]);
|
|
227
249
|
|
|
250
|
+
// u3 — say EXACTLY where the code landed + THE one next step, so a freshly
|
|
251
|
+
// checked-out dev is never left wondering what to open. Absolute path (works
|
|
252
|
+
// from any directory) and a single, unambiguous action.
|
|
253
|
+
printCheckoutLanding(dir, cockpitUrl);
|
|
254
|
+
|
|
228
255
|
// 5. dev — native by default (F3), Docker on --docker or the fallback
|
|
229
256
|
// above; wait for the server, open the browser (C/D).
|
|
230
257
|
const ctxDev = detectContext(dir);
|
|
231
258
|
let url, handle;
|
|
232
|
-
const bridgeEnv = activityBridgeEnv(env);
|
|
233
259
|
if (runtime.useDocker) {
|
|
234
260
|
const plan = buildContainerPlan(dir, devArgs, ctxDev);
|
|
235
261
|
url = plan.url;
|
|
@@ -244,7 +270,10 @@ export async function run(argv, ctx) {
|
|
|
244
270
|
// Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
|
|
245
271
|
// URL for the life of the run — CLI-side, using the cached bridge credential
|
|
246
272
|
// (no-op when none is cached). The runner keeps reporting file-saves itself.
|
|
247
|
-
|
|
273
|
+
// Hand off from the pre-pick (URL-less) beat to this URL-bearing one so the
|
|
274
|
+
// cockpit's live local link lights up exactly when the server is up (b2).
|
|
275
|
+
stopEarlyHeartbeat();
|
|
276
|
+
const stopHeartbeat = startHeartbeatFromEnv(bridgeEnv, { url, cwd: dir });
|
|
248
277
|
handle.done.finally(() => stopHeartbeat());
|
|
249
278
|
|
|
250
279
|
// The runner's stdio is "piped" (its logs are held until the aha), so this
|
|
@@ -264,11 +293,11 @@ export async function run(argv, ctx) {
|
|
|
264
293
|
// (A3) so the "instant" claim is measured. The "Connect Claude" step is
|
|
265
294
|
// intentionally removed for now — a blocking prompt here meant Ctrl-C'ing it
|
|
266
295
|
// tore down the dev server; revisit AI-connect as a non-blocking step later.
|
|
267
|
-
printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt));
|
|
296
|
+
printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt), cockpitUrl);
|
|
268
297
|
|
|
269
298
|
// 7. hand the terminal to the running dev server until Ctrl-C.
|
|
270
299
|
console.log("\n Watching your store — edit content/home.html + save. Ctrl-C to stop.\n");
|
|
271
|
-
streamDevLogs(handle.child);
|
|
300
|
+
streamDevLogs(handle.child, { cockpitUrl });
|
|
272
301
|
return handle.done;
|
|
273
302
|
} catch (e) {
|
|
274
303
|
console.error(formatError(e));
|
|
@@ -547,8 +576,14 @@ async function resolveTenant(stores, args, env, baseUrl, { session = null, listE
|
|
|
547
576
|
next: `tot start --tenant <tenant> (one of: ${pick.stores.map((s) => s.id).join(", ")})`,
|
|
548
577
|
});
|
|
549
578
|
}
|
|
550
|
-
|
|
579
|
+
// Loud + unmissable: a >1-store dev is often looking at their browser
|
|
580
|
+
// cockpit (which is waiting on this pick), so make the terminal shout for
|
|
581
|
+
// their attention rather than sitting as a quiet one-liner (b2).
|
|
582
|
+
console.log("");
|
|
583
|
+
console.log(" ⚑ ACTION NEEDED IN YOUR TERMINAL — you can build on several stores.");
|
|
584
|
+
console.log(" Pick one here to light up your cockpit:\n");
|
|
551
585
|
pick.stores.forEach((s, i) => console.log(` ${i + 1}. ${s.id}${s.name ? ` — ${s.name}` : ""}`));
|
|
586
|
+
console.log("");
|
|
552
587
|
const idx = await promptChoice(pick.stores.length);
|
|
553
588
|
console.log("");
|
|
554
589
|
tenant = pick.stores[idx].id;
|
|
@@ -611,13 +646,37 @@ export function formatElapsed(ms) {
|
|
|
611
646
|
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
612
647
|
}
|
|
613
648
|
|
|
614
|
-
/**
|
|
615
|
-
*
|
|
616
|
-
|
|
649
|
+
/**
|
|
650
|
+
* u3 — after checkout, tell the developer EXACTLY where their code landed and
|
|
651
|
+
* THE single next step. `dir` is absolute (from resolve(cwd, tenant)) so the
|
|
652
|
+
* "open this" path works no matter what directory they're in. When we know the
|
|
653
|
+
* Developer Cockpit URL, the one next step is framed as "edit a line → see it in
|
|
654
|
+
* your cockpit"; otherwise it's the same edit-to-see-it-reload action.
|
|
655
|
+
*/
|
|
656
|
+
function printCheckoutLanding(dir, cockpitUrl = null) {
|
|
617
657
|
console.log("");
|
|
618
|
-
console.log(`
|
|
619
|
-
|
|
620
|
-
console.log(
|
|
658
|
+
console.log(` 📁 Your store code is at: ${dir}`);
|
|
659
|
+
const where = cockpitUrl ? `your ${DEVELOPER_COCKPIT}` : "your browser";
|
|
660
|
+
console.log(` Next: open it in your editor and change one line in`);
|
|
661
|
+
console.log(` content/home.html — you'll see it reflected in ${where}.`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The crafted "you're live" ending — a PROMINENT milestone banner (u2) that,
|
|
665
|
+
* when we hold a Developer Cockpit URL, explicitly sends the developer BACK to
|
|
666
|
+
* their cockpit as the next place to look; then the AI-wow (G), seeded with
|
|
667
|
+
* IDEAS[0] (G2/G3 — one prompt list shared with `tot ideas`, no drift). */
|
|
668
|
+
function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
|
|
669
|
+
const lines = [
|
|
670
|
+
`✨ You're live.${elapsed ? ` (${elapsed})` : ""}`,
|
|
671
|
+
` ${url}`,
|
|
672
|
+
" Edit content/home.html + save → it reloads.",
|
|
673
|
+
];
|
|
674
|
+
if (cockpitUrl) {
|
|
675
|
+
lines.push("");
|
|
676
|
+
lines.push(`➜ Back to your ${DEVELOPER_COCKPIT} to watch it happen:`);
|
|
677
|
+
lines.push(` ${cockpitUrl}`);
|
|
678
|
+
}
|
|
679
|
+
console.log(milestoneBanner(lines));
|
|
621
680
|
console.log(" " + versionStamp("native"));
|
|
622
681
|
console.log("");
|
|
623
682
|
console.log(" Now try, in Claude:");
|
|
@@ -79,6 +79,10 @@ export function run(argv, ctx) {
|
|
|
79
79
|
const { ok, findings } = validateTenant(target.dir, {
|
|
80
80
|
tenantId: target.tenantId ?? undefined,
|
|
81
81
|
scope: target.scope ?? undefined,
|
|
82
|
+
// A checkout / bare-tenant target is served by the ToT storefront platform, so
|
|
83
|
+
// commerce tenants own the framework routes (/collections, /account, …). Only an
|
|
84
|
+
// arbitrary --workspace <dir> stays conservative (unless its config resolves the host).
|
|
85
|
+
mode: args.workspace ? "workspace" : "monorepo",
|
|
82
86
|
});
|
|
83
87
|
|
|
84
88
|
if (args.json) {
|
package/src/dev-logs.mjs
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* (dev.mjs). Extracted so the two entrypoints stream identically instead of one
|
|
5
5
|
* inheriting the raw vite/astro firehose while the other collapses it.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* stdout/stderr.
|
|
7
|
+
* Depends only on the shared, dependency-free banner helper — otherwise pure
|
|
8
|
+
* line buffering + filtering over a child's stdout/stderr.
|
|
9
9
|
*/
|
|
10
|
+
import { milestoneBanner, DEVELOPER_COCKPIT } from "./banner.mjs";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Stream the running dev server's output in BUSINESS terms. The runner + Vite +
|
|
@@ -16,17 +17,32 @@
|
|
|
16
17
|
* save-reload into one clean "↻ your store reloaded", drop the known internal
|
|
17
18
|
* noise, and pass anything else through (indented) so nothing important is
|
|
18
19
|
* hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
|
|
20
|
+
*
|
|
21
|
+
* The FIRST save→reload is the aha milestone (u2): with a `cockpitUrl` it's a
|
|
22
|
+
* PROMINENT banner that sends the developer back to their Developer Cockpit to
|
|
23
|
+
* see the change; every reload after that is the quiet "↻ your store reloaded"
|
|
24
|
+
* line so a working dev loop doesn't get spammed with banners.
|
|
19
25
|
* @param {import("node:child_process").ChildProcess} child
|
|
26
|
+
* @param {{ cockpitUrl?: string|null, out?: (s: string) => void, reloadDebounceMs?: number }} [opts]
|
|
27
|
+
* `out` is the sink (default: process.stdout.write) — injectable for tests.
|
|
28
|
+
* `reloadDebounceMs` collapses a single save's HMR burst into one line (default 1000ms).
|
|
20
29
|
*/
|
|
21
|
-
export function streamDevLogs(
|
|
30
|
+
export function streamDevLogs(
|
|
31
|
+
child,
|
|
32
|
+
{ cockpitUrl = null, out = (s) => process.stdout.write(s), reloadDebounceMs = 1000 } = {},
|
|
33
|
+
) {
|
|
22
34
|
// Startup churn + tool internals — never user-facing. Matched AFTER stripping
|
|
23
35
|
// the runner/Vite "HH:MM:SS " timestamp prefix (see `body` below), so a
|
|
24
36
|
// timestamped internal line like "10:50:17 [vite] connected" is still dropped.
|
|
37
|
+
// The node:* / ExperimentalWarning / (node:NNNN) / "--trace-warnings" and the
|
|
38
|
+
// boot "fatal: not a git repository" lines are dropped too (b4) — scary,
|
|
39
|
+
// non-actionable boot noise that reads as a broken first run.
|
|
25
40
|
const NOISE =
|
|
26
|
-
/^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d)/i;
|
|
41
|
+
/^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d|\(node:\d+\)|ExperimentalWarning|node:internal\/|\(Use `node --trace-warnings|fatal: not a git repository)/i;
|
|
27
42
|
// A real save-triggered reload (not startup "program reload" churn).
|
|
28
43
|
const RELOAD = /(hmr update|page reload)/i;
|
|
29
44
|
let reloadPending = null;
|
|
45
|
+
let sawFirstReload = false;
|
|
30
46
|
const emit = (line) => {
|
|
31
47
|
const t = line.replace(/\s+$/, "");
|
|
32
48
|
if (!t) return;
|
|
@@ -35,13 +51,24 @@ export function streamDevLogs(child) {
|
|
|
35
51
|
const body = t.replace(/^\d{1,2}:\d{2}:\d{2}(\.\d+)?\s+/, "").replace(/^\s+/, "");
|
|
36
52
|
if (RELOAD.test(body)) {
|
|
37
53
|
if (reloadPending) return; // debounce a burst into one line
|
|
38
|
-
reloadPending = setTimeout(() => { reloadPending = null; },
|
|
54
|
+
reloadPending = setTimeout(() => { reloadPending = null; }, reloadDebounceMs);
|
|
39
55
|
if (reloadPending.unref) reloadPending.unref();
|
|
40
|
-
|
|
56
|
+
if (!sawFirstReload && cockpitUrl) {
|
|
57
|
+
sawFirstReload = true;
|
|
58
|
+
out(milestoneBanner([
|
|
59
|
+
"↻ Your edit is live — your store reloaded.",
|
|
60
|
+
"",
|
|
61
|
+
`➜ See it in your ${DEVELOPER_COCKPIT}:`,
|
|
62
|
+
` ${cockpitUrl}`,
|
|
63
|
+
]) + "\n");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
sawFirstReload = true;
|
|
67
|
+
out(" ↻ your store reloaded\n");
|
|
41
68
|
return;
|
|
42
69
|
}
|
|
43
70
|
if (NOISE.test(body)) return;
|
|
44
|
-
|
|
71
|
+
out(` ${t}\n`);
|
|
45
72
|
};
|
|
46
73
|
lineStream(child.stdout, emit);
|
|
47
74
|
lineStream(child.stderr, emit);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The obstacle beacon — a fire-and-forget POST that tells the hosted cockpit a
|
|
3
|
+
* `tot start` / `tot checkout` failed, so it can show the exact fix in the bridge
|
|
4
|
+
* strip (obstacle lane, server side already shipped). Best-effort telemetry that
|
|
5
|
+
* rides ALONGSIDE the house-style `✗ … → next:` error; it must NEVER change,
|
|
6
|
+
* delay past its timeout, or fail that error path.
|
|
7
|
+
*
|
|
8
|
+
* KEEP THIS FILE ES5/CommonJS: var, string concat, function expressions — no
|
|
9
|
+
* arrow functions, template literals, optional chaining, or const/let. The ES5
|
|
10
|
+
* launcher (bin/tot.cjs) require()s this on a machine that FAILED the Node
|
|
11
|
+
* floor (Node < 22.12, no global `fetch`, no ESM) to beacon `node-too-old`
|
|
12
|
+
* pre-login — the exact old-Node dead-end the whole obstacle lane exists for.
|
|
13
|
+
* So this uses require("https"/"http"), and a test keeps it parseable by
|
|
14
|
+
* ancient Nodes. The ESM side (src/obstacle.mjs) imports beaconAsync from here
|
|
15
|
+
* too, so the wire shape + endpoint live in ONE place and can't drift.
|
|
16
|
+
*
|
|
17
|
+
* The event contract is DECIDED (owned by the server's rail-obstacle-lane):
|
|
18
|
+
* POST <activity-url>/api/dev/activity Authorization: Bearer <token>
|
|
19
|
+
* { event:"obstacle", kind, have?, need?, cliVersion?, at? }
|
|
20
|
+
* Send ONLY these machine-readable fields — the human remediation copy is composed
|
|
21
|
+
* SERVER-side per kind, so guidance changes without a CLI release. The server
|
|
22
|
+
* stamps its own reportedAt (skew-proof) and a later heartbeat CLEARS the obstacle.
|
|
23
|
+
*/
|
|
24
|
+
"use strict";
|
|
25
|
+
|
|
26
|
+
var VALID_KINDS = { "node-too-old": 1, "pnpm-missing": 1, "install-failed": 1, "clone-failed": 1 };
|
|
27
|
+
var TIMEOUT_MS = 2000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pull --activity-url / --activity-token straight out of argv. The launcher runs
|
|
31
|
+
* BEFORE any arg framework (and before login has cached anything), so on the
|
|
32
|
+
* node-too-old path this is the only source of the bridge credential — the pasted
|
|
33
|
+
* setup command carries both flags. Returns {} for whatever is absent.
|
|
34
|
+
*/
|
|
35
|
+
function parseActivityArgs(argv) {
|
|
36
|
+
var out = { url: undefined, token: undefined };
|
|
37
|
+
if (!argv) return out;
|
|
38
|
+
for (var i = 0; i < argv.length; i++) {
|
|
39
|
+
if (argv[i] === "--activity-url") out.url = argv[i + 1];
|
|
40
|
+
else if (argv[i] === "--activity-token") out.token = argv[i + 1];
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fire one obstacle beacon. Everything is wrapped so it can never throw into the
|
|
47
|
+
* caller's error path; `done` is invoked EXACTLY once (on success, error, timeout,
|
|
48
|
+
* or a skip) — the launcher passes process.exit as `done` so the process lingers
|
|
49
|
+
* only as long as the beacon (≤ TIMEOUT_MS) before exiting.
|
|
50
|
+
* opts: { url, token, kind, have?, need?, cliVersion?, at? }
|
|
51
|
+
*/
|
|
52
|
+
function beacon(opts, done) {
|
|
53
|
+
var finished = false;
|
|
54
|
+
function finish() {
|
|
55
|
+
if (finished) return;
|
|
56
|
+
finished = true;
|
|
57
|
+
try { if (timer) clearTimeout(timer); } catch (e) {}
|
|
58
|
+
if (typeof done === "function") { try { done(); } catch (e) {} }
|
|
59
|
+
}
|
|
60
|
+
var timer = null;
|
|
61
|
+
try {
|
|
62
|
+
opts = opts || {};
|
|
63
|
+
if (!opts.url || !opts.token || !VALID_KINDS[opts.kind]) { finish(); return; }
|
|
64
|
+
|
|
65
|
+
var body = { event: "obstacle", kind: opts.kind };
|
|
66
|
+
if (opts.have) body.have = opts.have;
|
|
67
|
+
if (opts.need) body.need = opts.need;
|
|
68
|
+
if (opts.cliVersion) body.cliVersion = opts.cliVersion;
|
|
69
|
+
body.at = opts.at || Date.now();
|
|
70
|
+
var payload = JSON.stringify(body);
|
|
71
|
+
|
|
72
|
+
var endpoint = String(opts.url).replace(/\/+$/, "") + "/api/dev/activity";
|
|
73
|
+
var lib = endpoint.indexOf("http://") === 0 ? require("http") : require("https");
|
|
74
|
+
|
|
75
|
+
// A hard timeout guarantees the caller's exit (or await) is bounded even if
|
|
76
|
+
// the socket hangs — telemetry never holds a failing developer hostage.
|
|
77
|
+
timer = setTimeout(finish, TIMEOUT_MS);
|
|
78
|
+
|
|
79
|
+
var req = lib.request(
|
|
80
|
+
endpoint,
|
|
81
|
+
{
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: {
|
|
84
|
+
"content-type": "application/json",
|
|
85
|
+
authorization: "Bearer " + opts.token,
|
|
86
|
+
"content-length": Buffer.byteLength(payload),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
function (res) {
|
|
90
|
+
// Drain and finish — we don't care about the status, only that it was sent.
|
|
91
|
+
res.on("data", function () {});
|
|
92
|
+
res.on("end", finish);
|
|
93
|
+
res.on("error", finish);
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
req.on("error", finish);
|
|
97
|
+
req.write(payload);
|
|
98
|
+
req.end();
|
|
99
|
+
} catch (e) {
|
|
100
|
+
finish();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Promise wrapper for the ESM side (src/obstacle.mjs) so a failure path can await delivery. */
|
|
105
|
+
function beaconAsync(opts) {
|
|
106
|
+
return new Promise(function (resolve) {
|
|
107
|
+
try { beacon(opts, resolve); } catch (e) { resolve(); }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { parseActivityArgs: parseActivityArgs, beacon: beacon, beaconAsync: beaconAsync };
|
package/src/obstacle.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESM front door to the obstacle beacon (src/obstacle-beacon.cjs). The post-login
|
|
3
|
+
* failure paths — `pnpm-missing`, `install-failed`, `clone-failed` — run on a
|
|
4
|
+
* supported Node where the bridge credential is already cached, so they resolve
|
|
5
|
+
* {url, token} from ~/.tot/credentials.json (the same source activityBridgeEnv +
|
|
6
|
+
* the heartbeat use) rather than argv. `node-too-old` does NOT come through here:
|
|
7
|
+
* it fires from the ES5 launcher pre-login, where creds may not exist yet.
|
|
8
|
+
*
|
|
9
|
+
* emitObstacle is awaited at the failure site so the packet is delivered before
|
|
10
|
+
* the process prints its `✗ … → next:` and exits — but it is fully swallowed, so
|
|
11
|
+
* telemetry can never disrupt or delay (past the beacon's own ~2s timeout) the
|
|
12
|
+
* error the developer actually needs to see.
|
|
13
|
+
*/
|
|
14
|
+
import beaconCore from "./obstacle-beacon.cjs";
|
|
15
|
+
import { CLI_VERSION } from "./mcp.mjs";
|
|
16
|
+
import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Best-effort obstacle beacon for a post-login failure. No-op (silent) when no
|
|
20
|
+
* bridge credential is cached — the developer signed in with a build that didn't
|
|
21
|
+
* carry the activity flags, or ran a bare `tot login`.
|
|
22
|
+
* @param {"pnpm-missing"|"install-failed"|"clone-failed"} kind
|
|
23
|
+
* @param {{ have?: string, need?: string, env?: NodeJS.ProcessEnv }} [opts]
|
|
24
|
+
*/
|
|
25
|
+
export async function emitObstacle(kind, { have, need, env = process.env } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const creds = readCredentials(defaultCredentialsPath(env));
|
|
28
|
+
const url = creds?.activityUrl;
|
|
29
|
+
const token = creds?.activityToken;
|
|
30
|
+
if (!url || !token) return;
|
|
31
|
+
await beaconCore.beaconAsync({ url, token, kind, have, need, cliVersion: CLI_VERSION });
|
|
32
|
+
} catch {
|
|
33
|
+
/* best-effort telemetry — never disrupts the error path */
|
|
34
|
+
}
|
|
35
|
+
}
|