@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.
Files changed (41) hide show
  1. package/bin/tot.cjs +44 -16
  2. package/bin/tot.mjs +8 -0
  3. package/package.json +2 -1
  4. package/src/app-scaffold.mjs +84 -0
  5. package/src/banner.mjs +46 -0
  6. package/src/commands/app/dev.mjs +268 -0
  7. package/src/commands/app/index.mjs +35 -0
  8. package/src/commands/app/scaffold.mjs +57 -0
  9. package/src/commands/checkout.mjs +5 -0
  10. package/src/commands/dev.mjs +61 -14
  11. package/src/commands/start.mjs +76 -17
  12. package/src/commands/validate.mjs +4 -0
  13. package/src/dev-logs.mjs +34 -7
  14. package/src/obstacle-beacon.cjs +111 -0
  15. package/src/obstacle.mjs +35 -0
  16. package/src/validate.mjs +77 -8
  17. package/src/vendor/private-apps-devkit.mjs +490 -0
  18. package/template/private-app/.env.example +10 -0
  19. package/template/private-app/Dockerfile +12 -0
  20. package/template/private-app/README.md +45 -0
  21. package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
  22. package/template/private-app/server.js +81 -0
  23. package/template/private-app/tot-app.json +29 -0
  24. package/template/sample-store/content/chrome.html +140 -0
  25. package/template/sample-store/content/chrome.json +86 -0
  26. package/template/sample-store/content/home.html +121 -0
  27. package/template/sample-store/content/home.json +50 -0
  28. package/template/sample-store/content/pages/about.json +10 -0
  29. package/template/sample-store/content/pages/privacy.json +10 -0
  30. package/template/sample-store/content/pages/shipping-returns.json +10 -0
  31. package/template/sample-store/content/pages-html/blogs/news.html +26 -0
  32. package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
  33. package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
  34. package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
  35. package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
  36. package/template/sample-store/public/logo.svg +6 -0
  37. package/template/sample-store/public/pages/home.css +120 -0
  38. package/template/sample-store/public/pages/mkt.css +185 -0
  39. package/template/sample-store/public/pages/page.css +155 -0
  40. package/template/sample-store/public/themes/sample.css +76 -0
  41. package/template/sample-store/theme.json +38 -0
@@ -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 20+ ships corepack, so
986
- * this needs no separate pnpm install on the host. Best-effort: if corepack
987
- * itself is missing (very old Node), pnpm install below will surface that
988
- * clearly instead.
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 = spawnSync("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
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
- function runPnpmInstall(runnerDir, { logPath } = {}) {
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
- const r = spawnSync(cmd, args, {
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: { ...process.env, ...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
@@ -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
- const listResp = await client.callTool("client_list", {});
203
- const stores = normalizeStores(listResp);
204
- const tenant = await resolveTenant(stores, args, env, baseUrl, {
205
- session,
206
- listErr: storeListError(listResp),
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
- const stopHeartbeat = startHeartbeatFromEnv(bridgeEnv, { url });
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
- console.log(" Which store do you want to work on?\n");
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
- /** The crafted "you're live" ending — leads with the AI-wow (G), seeded with
615
- * IDEAS[0] (G2/G3 one prompt list shared with `tot ideas`, no drift). */
616
- function printLiveEnding(tenant, url, elapsed) {
649
+ /**
650
+ * u3after 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(` You're live.${elapsed ? ` (${elapsed})` : ""}`);
619
- console.log(` ${url}`);
620
- console.log(" Edit content/home.html + save it reloads.");
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
- * Dependency-free (no imports) pure line buffering + filtering over a child's
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(child) {
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; }, 1000);
54
+ reloadPending = setTimeout(() => { reloadPending = null; }, reloadDebounceMs);
39
55
  if (reloadPending.unref) reloadPending.unref();
40
- process.stdout.write(" ↻ your store reloaded\n");
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
- process.stdout.write(` ${t}\n`);
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 };
@@ -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
+ }