@tokenoftrust/cli 1.3.1-rc.1 → 1.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.3.1-rc.1",
3
+ "version": "1.3.2",
4
4
  "description": "Token of Trust developer CLI — check out a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -36,6 +36,7 @@
36
36
  "registry": "https://registry.npmjs.org"
37
37
  },
38
38
  "scripts": {
39
- "test": "node --test"
39
+ "test": "node --test",
40
+ "prepublishOnly": "node ../../scripts/build/check-cli-clean.mjs ."
40
41
  }
41
42
  }
@@ -49,6 +49,7 @@ import {
49
49
  scaffoldSample, isSampleCheckout, sampleConfig,
50
50
  resolveRendererSource as resolveLocalRendererSource, newestCachedRunner, SAMPLE_DIR_NAME,
51
51
  } from "../sample.mjs";
52
+ import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
52
53
 
53
54
  /** The published runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
54
55
  const DEFAULT_DEV_IMAGE =
@@ -307,7 +308,14 @@ export function bootNativeEnv(args) {
307
308
  }
308
309
 
309
310
  export function bootNative(runnerDir, workspace, port, url, args) {
310
- const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "inherit", env: bootNativeEnv(args) });
311
+ const bridgeEnv = bootNativeEnv(args);
312
+ const handle = spawnNativeDev(runnerDir, workspace, port, { stdio: "inherit", env: bridgeEnv });
313
+
314
+ // Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
315
+ // URL while the runner runs — CLI-side, using the SAME bridge credential the
316
+ // runner's file-save path uses (no-op in --sample, which threads no credential).
317
+ const stopHeartbeat = startHeartbeatFromEnv(bridgeEnv, { url });
318
+ handle.done.finally(() => stopHeartbeat());
311
319
 
312
320
  // Auto-open the browser the moment the server answers (D). Non-blocking so
313
321
  // Ctrl-C / logs are unaffected; --no-open suppresses it.
@@ -986,6 +994,12 @@ async function runContainer(workspace, args, ctx) {
986
994
 
987
995
  const handle = await spawnDevContainer(plan, args, { stdio: "inherit" });
988
996
 
997
+ // Heartbeat the hosted cockpit (G1) CLI-side while the container runs — the
998
+ // container reports file-saves via the threaded env, but the CLI owns the
999
+ // version + live URL. Same bridge credential (activityBridgeEnv), no-op absent.
1000
+ const stopHeartbeat = startHeartbeatFromEnv(activityBridgeEnv(), { url: plan.url });
1001
+ handle.done.finally(() => stopHeartbeat());
1002
+
989
1003
  // Auto-open the browser the moment the server answers (D). Non-blocking so
990
1004
  // Ctrl-C / logs are unaffected; --no-open suppresses it.
991
1005
  if (!args.noOpen) {
@@ -64,6 +64,7 @@ import {
64
64
  activityBridgeEnv, NativeArtifactUnavailableError,
65
65
  } from "./dev.mjs";
66
66
  import { scaffoldSample, isSampleCheckout, sampleConfig, SAMPLE_DIR_NAME } from "../sample.mjs";
67
+ import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
67
68
  import { IDEAS } from "./ideas.mjs";
68
69
 
69
70
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -226,6 +227,7 @@ export async function run(argv, ctx) {
226
227
  // above; wait for the server, open the browser (C/D).
227
228
  const ctxDev = detectContext(dir);
228
229
  let url, handle;
230
+ const bridgeEnv = activityBridgeEnv(env);
229
231
  if (runtime.useDocker) {
230
232
  const plan = buildContainerPlan(dir, devArgs, ctxDev);
231
233
  url = plan.url;
@@ -234,9 +236,15 @@ export async function run(argv, ctx) {
234
236
  } else {
235
237
  url = deriveUrl(ctxDev.config || {}, devArgs.port).url;
236
238
  console.log(` → starting dev … ${url}`);
237
- handle = spawnNativeDev(runtime.runnerDir, dir, devArgs.port, { stdio: "piped", env: activityBridgeEnv(env) });
239
+ handle = spawnNativeDev(runtime.runnerDir, dir, devArgs.port, { stdio: "piped", env: bridgeEnv });
238
240
  }
239
241
 
242
+ // Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
243
+ // URL for the life of the run — CLI-side, using the cached bridge credential
244
+ // (no-op when none is cached). The runner keeps reporting file-saves itself.
245
+ const stopHeartbeat = startHeartbeatFromEnv(bridgeEnv, { url });
246
+ handle.done.finally(() => stopHeartbeat());
247
+
240
248
  // The runner's stdio is "piped" (its logs are held until the aha), so this
241
249
  // boot would otherwise be a silent 5–60s gap. Tick a spinner over it.
242
250
  const up = await waitForBoot(url, handle);
@@ -666,23 +674,28 @@ function connectClaude() {
666
674
  * hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
667
675
  */
668
676
  function streamDevLogs(child) {
669
- // Startup churn + tool internals — never user-facing.
677
+ // Startup churn + tool internals — never user-facing. Matched AFTER stripping
678
+ // the runner/Vite "HH:MM:SS " timestamp prefix (see `body` below), so a
679
+ // timestamped internal line like "10:50:17 [vite] connected" is still dropped.
670
680
  const NOISE =
671
- /^\s*(\[@astrojs|astro\s+v[\d.]|┃|▲|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\[vite\] connected|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d)/i;
681
+ /^(\[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;
672
682
  // A real save-triggered reload (not startup "program reload" churn).
673
683
  const RELOAD = /(hmr update|page reload)/i;
674
684
  let reloadPending = null;
675
685
  const emit = (line) => {
676
686
  const t = line.replace(/\s+$/, "");
677
687
  if (!t) return;
678
- if (RELOAD.test(t)) {
688
+ // The runner/Vite prefix most lines with an "HH:MM:SS " (or ".mmm ")
689
+ // timestamp — strip it before matching so the filters catch them.
690
+ const body = t.replace(/^\d{1,2}:\d{2}:\d{2}(\.\d+)?\s+/, "").replace(/^\s+/, "");
691
+ if (RELOAD.test(body)) {
679
692
  if (reloadPending) return; // debounce a burst into one line
680
693
  reloadPending = setTimeout(() => { reloadPending = null; }, 1000);
681
694
  if (reloadPending.unref) reloadPending.unref();
682
695
  process.stdout.write(" ↻ your store reloaded\n");
683
696
  return;
684
697
  }
685
- if (NOISE.test(t)) return;
698
+ if (NOISE.test(body)) return;
686
699
  process.stdout.write(` ${t}\n`);
687
700
  };
688
701
  lineStream(child.stdout, emit);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * CLI-side heartbeat for the local→hosted activity bridge (G1).
3
+ *
4
+ * While `tot dev` / `tot start` is running, the CLI (which knows its own
5
+ * version, the resolved runner version, the port + tenant, and the cached
6
+ * activity-bridge credential) POSTs a periodic heartbeat to the hosted
7
+ * `/api/dev/activity` endpoint. The hosted cockpit turns the latest heartbeat
8
+ * into a LIVE, clickable local-dev link + the running CLI version.
9
+ *
10
+ * It emits CLI-side (not from the runner) because the CLI is the one process
11
+ * that holds ALL of {version, port, tenant, bridge credential} — the runner
12
+ * keeps emitting file-save events as before. Same bearer + same endpoint as the
13
+ * file-save path; no new secret.
14
+ *
15
+ * Contract (identical posture to the runner's file-save bridge): best-effort,
16
+ * NEVER throws or blocks, the interval timer is unref'd so it can't hold the
17
+ * process open, and with no bridge credential (a bare `tot login`, an older
18
+ * session, or the zero-login `--sample` path) it's a silent no-op.
19
+ * Dependency-free (global fetch, Node 20+).
20
+ */
21
+ import { CLI_VERSION, clientPackages } from "./mcp.mjs";
22
+
23
+ /** ~10s between beats — frequent enough that the cockpit's ~30s live window
24
+ * tolerates a missed beat without the badge flapping, cheap enough to ignore. */
25
+ const HEARTBEAT_INTERVAL_MS = 10_000;
26
+
27
+ /**
28
+ * POST one heartbeat body, best-effort. No-op (returns undefined) without both
29
+ * an activity URL and a bearer token. Never throws — a failed/offline hosted
30
+ * worker just means the cockpit doesn't light up this beat.
31
+ * @param {{ activityUrl?: string, token?: string, url?: string,
32
+ * cliVersion?: string, runnerVersion?: string|null }} args
33
+ */
34
+ export function postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion } = {}) {
35
+ if (!activityUrl || !token) return undefined;
36
+ /** @type {Record<string, unknown>} */
37
+ const body = { event: "heartbeat", cliVersion, at: Date.now() };
38
+ if (runnerVersion) body.runnerVersion = runnerVersion;
39
+ if (url) body.url = url;
40
+ return fetch(`${String(activityUrl).replace(/\/+$/, "")}/api/dev/activity`, {
41
+ method: "POST",
42
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
43
+ body: JSON.stringify(body),
44
+ }).catch(() => {
45
+ /* best-effort — hosted panel just won't show this beat */
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Start beating every `intervalMs` until the returned stop() is called. Fires
51
+ * one beat IMMEDIATELY so the cockpit lights up without waiting a full interval.
52
+ * Returns a no-op stop() when there's no bridge credential (sample / not-signed-
53
+ * in), so callers can wire it unconditionally.
54
+ * @param {{ activityUrl?: string, token?: string, url?: string,
55
+ * cliVersion?: string, runnerVersion?: string|null, intervalMs?: number }} [opts]
56
+ * @returns {() => void} stop the heartbeat (idempotent).
57
+ */
58
+ export function startDevHeartbeat({
59
+ activityUrl,
60
+ token,
61
+ url,
62
+ cliVersion = CLI_VERSION,
63
+ runnerVersion,
64
+ intervalMs = HEARTBEAT_INTERVAL_MS,
65
+ } = {}) {
66
+ if (!activityUrl || !token) return () => {}; // no bridge / sample mode → no-op
67
+ const beat = () => postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion });
68
+ beat();
69
+ const timer = setInterval(beat, intervalMs);
70
+ if (typeof timer.unref === "function") timer.unref();
71
+ return () => clearInterval(timer);
72
+ }
73
+
74
+ /**
75
+ * Convenience wrapper for the runner-spawn sites: start a heartbeat from the
76
+ * same bridge env that's threaded to the runner ({TOT_DEV_ACTIVITY_URL,
77
+ * TOT_DEV_ACTIVITY_TOKEN}, or {} for the `--sample` path — which yields a no-op,
78
+ * exactly the desired "never leak a real credential from sample" behavior). The
79
+ * runner version defaults to whatever `tot dev` resolved this invocation
80
+ * (clientPackages().runner — null until the runner is resolved).
81
+ * @param {{ TOT_DEV_ACTIVITY_URL?: string, TOT_DEV_ACTIVITY_TOKEN?: string }} bridgeEnv
82
+ * @param {{ url?: string, runnerVersion?: string|null }} [opts]
83
+ * @returns {() => void} stop the heartbeat.
84
+ */
85
+ export function startHeartbeatFromEnv(bridgeEnv, { url, runnerVersion } = {}) {
86
+ return startDevHeartbeat({
87
+ activityUrl: bridgeEnv?.TOT_DEV_ACTIVITY_URL,
88
+ token: bridgeEnv?.TOT_DEV_ACTIVITY_TOKEN,
89
+ url,
90
+ runnerVersion: runnerVersion ?? clientPackages().runner,
91
+ });
92
+ }
package/src/sample.mjs CHANGED
@@ -9,16 +9,19 @@
9
9
  * regulated tenant's identity so the runner's tenant registry resolves it with
10
10
  * its `compliance` block on.
11
11
  *
12
- * WHY GIANTVAPES (not a novel synthetic domain): compliance + catalog are
13
- * resolved by the RUNNER's tenant registry (apps/storefront/src/config/tenants.ts),
14
- * keyed by domain. `giantvapes.com` is a registered 21+/PACT vape store WITH a
15
- * `compliance` block and a 50-product catalog fixture baked into the runner, so
16
- * the sample lights up age-gate + nicotine warning + a populated catalog with
17
- * zero server. A truly novel domain (e.g. "sample.example") is unclaimed the
18
- * runner falls back to HOME_TENANT (siteType "marketing", NO compliance, empty
19
- * catalog), which would defeat the whole "the free taste still does compliance"
20
- * goalso we deliberately borrow giantvapes' identity for the sample. It's the
21
- * smallest of the vapor tenants (448K), so it keeps the npm tarball light.
12
+ * WHY sample-store.example (a FICTIONAL store, not a real merchant): compliance
13
+ * is resolved by the RUNNER's SANITIZED tenant registry
14
+ * (apps/storefront/src/config/tenants.ts rewritten at build time by
15
+ * scripts/build/build-runner.mjs), keyed by domain. The runner registers ONE
16
+ * generic sample tenant, `sample-store.example`, that carries a `compliance`
17
+ * block (minAge 21, nicotineWarning), so the sample lights up the age-gate +
18
+ * nicotine warning off a NON-merchant identity with zero server. The bundled
19
+ * template below (`template/sample-store/`) is neutral, fictional "Sample Vapor
20
+ * Co." content no real merchant's brand, copy, or assets ship here.
21
+ *
22
+ * (Earlier this borrowed a real vape merchant's identity + content to get a
23
+ * populated catalog; that leaked private merchant IP into public npm and was
24
+ * replaced — see the H1/H2 runner-sanitization work — with this generic sample.)
22
25
  *
23
26
  * Dependency-free (node:fs + node:path only).
24
27
  */
@@ -30,10 +33,10 @@ import { dirname, join, resolve } from "node:path";
30
33
 
31
34
  const here = dirname(fileURLToPath(import.meta.url)); // packages/cli/src
32
35
 
33
- /** The tenant identity the sample borrows so compliance + catalog resolve (see module doc). */
34
- export const SAMPLE_TENANT = "giantvapes";
36
+ /** The generic sample tenant the runner registers with a compliance block (see module doc). */
37
+ export const SAMPLE_TENANT = "sample-store.example";
35
38
  /** Dotted scope → the runner's path-prefix route + registry lookup (must contain a dot per context.mjs). */
36
- export const SAMPLE_SCOPE = "giantvapes.com";
39
+ export const SAMPLE_SCOPE = "sample-store.example";
37
40
  /** Default directory name scaffolded when the user doesn't name one. */
38
41
  export const SAMPLE_DIR_NAME = "sample-store";
39
42