@tokenoftrust/cli 1.3.0 → 1.3.1-rc.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 +1 -1
- package/src/commands/checkout.mjs +44 -7
- package/src/commands/dev.mjs +174 -22
- package/src/commands/start.mjs +70 -9
- package/src/dev-heartbeat.mjs +92 -0
- package/src/mcp.mjs +24 -0
- package/src/update-check-worker.mjs +5 -2
- package/src/update-check.mjs +88 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1-rc.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",
|
|
@@ -166,14 +166,15 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
166
166
|
await client.callTool("client_switch", { tenant });
|
|
167
167
|
const checkout = await client.callTool("tenant_checkout", { tenant, tag });
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
);
|
|
169
|
+
// A non-checkout result (not provisioned / not entitled / failed) must surface
|
|
170
|
+
// the MCP's human message + a concrete next step, NEVER a raw JSON.stringify
|
|
171
|
+
// dump (James's 2026-07-19 first-experience failure).
|
|
172
|
+
const err = checkoutError(checkout);
|
|
173
|
+
if (err) {
|
|
174
|
+
throw new CliError(redact(err.message), { next: err.next });
|
|
176
175
|
}
|
|
176
|
+
const gitRemote = checkout.gitRemote;
|
|
177
|
+
const cloneUrl = checkout.cloneUrl ?? null;
|
|
177
178
|
const u = new URL(gitRemote);
|
|
178
179
|
const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
|
|
179
180
|
|
|
@@ -200,6 +201,42 @@ function cloneRepo(gitRemote, dir, redact) {
|
|
|
200
201
|
return { dir, head };
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Classify a `tenant_checkout` tool result: null when it's a genuine, usable
|
|
206
|
+
* checkout (carries a gitRemote), otherwise a human { message, next } pair so
|
|
207
|
+
* callers surface the MCP's own words + a concrete next step instead of dumping
|
|
208
|
+
* raw JSON (James's 2026-07-19 first-experience failure: a not-provisioned store
|
|
209
|
+
* printed JSON.stringify(checkout)). callTool unwraps the tool result to its
|
|
210
|
+
* structuredContent / parsed text, so a failure surfaces as an error-ish status
|
|
211
|
+
* ('forbidden' | 'invalid_input' | 'checkout_failed' | 'error'), a `message`, or
|
|
212
|
+
* simply a missing gitRemote. When the repo isn't provisioned yet we speak to the
|
|
213
|
+
* INVITED DEVELOPER ("your store isn't set up yet"), not the operator — dropping
|
|
214
|
+
* the `repo_provision` jargon the MCP aims at whoever provisions. Any other
|
|
215
|
+
* failure surfaces the MCP's own message. Pure + exported so it's unit-tested
|
|
216
|
+
* without any I/O.
|
|
217
|
+
* @param {unknown} checkout
|
|
218
|
+
* @returns {{ message: string, next: string }|null}
|
|
219
|
+
*/
|
|
220
|
+
export function checkoutError(checkout) {
|
|
221
|
+
const c = checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null;
|
|
222
|
+
if (c && c.gitRemote) return null; // a usable checkout — never an error
|
|
223
|
+
const msg =
|
|
224
|
+
(c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
|
|
225
|
+
(c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
|
|
226
|
+
null;
|
|
227
|
+
// The store's repo isn't provisioned yet — it isn't set up for this developer.
|
|
228
|
+
if (typeof msg === "string" && /not provisioned/i.test(msg)) {
|
|
229
|
+
return {
|
|
230
|
+
message: "your store isn't set up on Token of Trust yet",
|
|
231
|
+
next: "ask your Token of Trust contact to finish setting up your store, then re-run",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
message: msg || "the store checkout couldn't be completed",
|
|
236
|
+
next: "confirm you're entitled to this store — `tot checkout` (lists your stores)",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
203
240
|
/**
|
|
204
241
|
* Normalize the (shape-varying) `client_list` response into a plain, sorted
|
|
205
242
|
* list of the stores this identity can build on. Shared by `tot checkout`'s
|
package/src/commands/dev.mjs
CHANGED
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
* either way.
|
|
33
33
|
*/
|
|
34
34
|
import { spawn, spawnSync, execFileSync } from "node:child_process";
|
|
35
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, readdirSync, createWriteStream } from "node:fs";
|
|
35
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, readdirSync, createWriteStream, openSync, closeSync } from "node:fs";
|
|
36
36
|
import { homedir, tmpdir } from "node:os";
|
|
37
37
|
import { join, resolve } from "node:path";
|
|
38
38
|
import { createHash } from "node:crypto";
|
|
39
39
|
import { Readable } from "node:stream";
|
|
40
40
|
import { pipeline } from "node:stream/promises";
|
|
41
41
|
import { setTimeout as delay } from "node:timers/promises";
|
|
42
|
-
import { createMcpClient, CLI_VERSION } from "../mcp.mjs";
|
|
42
|
+
import { createMcpClient, CLI_VERSION, setRunnerVersion } from "../mcp.mjs";
|
|
43
43
|
import { establishSession } from "../auth.mjs";
|
|
44
44
|
import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
|
|
45
45
|
import { CliError, fail, formatError } from "../errors.mjs";
|
|
@@ -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
|
|
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.
|
|
@@ -418,7 +426,21 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
|
|
|
418
426
|
const distTags = meta?.["dist-tags"] || {};
|
|
419
427
|
const versions = Object.keys(meta?.versions || {});
|
|
420
428
|
|
|
421
|
-
//
|
|
429
|
+
// INVARIANT: the runner must never run AHEAD of the CLI's major.minor. A runner
|
|
430
|
+
// published for a newer minor can expect CLI features this CLI doesn't have, so a
|
|
431
|
+
// too-new runner is a silent-skew bug. `cliMM` is this CLI's ceiling; `withinCeiling`
|
|
432
|
+
// gates every non-explicit path below. (numeric major.minor compare; a prerelease
|
|
433
|
+
// suffix on the CLI, e.g. 1.3.0-rc.2, is ignored — 1.3 is still the ceiling.)
|
|
434
|
+
const cliMM = majorMinor(cliVersion);
|
|
435
|
+
const withinCeiling = (v) => {
|
|
436
|
+
if (!cliMM) return true; // unparseable CLI version → don't block resolution
|
|
437
|
+
const mm = majorMinor(v);
|
|
438
|
+
return !!mm && (mm.major < cliMM.major || (mm.major === cliMM.major && mm.minor <= cliMM.minor));
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// 1) Explicit pin (flag/env): the deliberate escape hatch — honored verbatim,
|
|
442
|
+
// INCLUDING above the ceiling (someone testing a newer runner on purpose). This is
|
|
443
|
+
// the ONLY way past the ceiling; every automatic path below respects it.
|
|
422
444
|
if (explicitPin) {
|
|
423
445
|
return { version: distTags[explicitPin] || explicitPin, reason: `pinned ${explicitPin}` };
|
|
424
446
|
}
|
|
@@ -427,11 +449,13 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
|
|
|
427
449
|
// is published at the SAME version as the CLI (incl. prereleases like 1.3.0-rc.0,
|
|
428
450
|
// which the stable-only minor match below deliberately skips). This is what makes
|
|
429
451
|
// `tot@1.3.0-rc.0` pull `runner@1.3.0-rc.0` instead of falling back to stale latest.
|
|
452
|
+
// (Same version ⇒ same major.minor ⇒ always within the ceiling.)
|
|
430
453
|
if (versions.includes(cliVersion)) {
|
|
431
454
|
return { version: cliVersion, reason: "exact CLI-version match" };
|
|
432
455
|
}
|
|
433
456
|
|
|
434
457
|
// 3) CLI-minor match: highest published <major>.<minor>.* (numeric patch order).
|
|
458
|
+
// Constrained to the CLI's exact minor, so this is within the ceiling by construction.
|
|
435
459
|
const m = /^(\d+)\.(\d+)\./.exec(cliVersion || "");
|
|
436
460
|
if (m) {
|
|
437
461
|
const prefix = `${m[1]}.${m[2]}.`;
|
|
@@ -443,9 +467,40 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
|
|
|
443
467
|
}
|
|
444
468
|
}
|
|
445
469
|
|
|
446
|
-
// 4) Fallback: the
|
|
447
|
-
|
|
448
|
-
|
|
470
|
+
// 4) Fallback: the highest STABLE version AT OR BELOW the CLI's major.minor ceiling.
|
|
471
|
+
// This replaces a blind `latest` — `latest` can be a HIGHER minor than this CLI
|
|
472
|
+
// (e.g. CLI 1.2.x but runner latest 1.3.0), which would run the runner ahead of the
|
|
473
|
+
// CLI. Running slightly BEHIND (a lower minor) is the safe direction.
|
|
474
|
+
const capped = versions
|
|
475
|
+
.filter((v) => /^\d+\.\d+\.\d+$/.test(v) && withinCeiling(v))
|
|
476
|
+
.sort(compareStableAsc);
|
|
477
|
+
if (capped.length) {
|
|
478
|
+
const version = capped[capped.length - 1];
|
|
479
|
+
return { version, reason: `highest ≤ CLI major.minor ${cliMM ? `${cliMM.major}.${cliMM.minor}` : "?"}` };
|
|
480
|
+
}
|
|
481
|
+
// Last resort: `latest`, but ONLY if it doesn't breach the ceiling.
|
|
482
|
+
if (distTags.latest && withinCeiling(distTags.latest)) {
|
|
483
|
+
return { version: distTags.latest, reason: "fell back to latest (within CLI ceiling)" };
|
|
484
|
+
}
|
|
485
|
+
throw new Error(
|
|
486
|
+
`no runner version at or below the CLI's major.minor (${cliMM ? `${cliMM.major}.${cliMM.minor}` : cliVersion})`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Parse the numeric {major, minor} from a semver (prerelease suffix ignored). Null if unparseable. */
|
|
491
|
+
export function majorMinor(v) {
|
|
492
|
+
const m = /^(\d+)\.(\d+)\./.exec(String(v || ""));
|
|
493
|
+
return m ? { major: Number(m[1]), minor: Number(m[2]) } : null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Ascending comparator for STABLE x.y.z strings (numeric per segment). */
|
|
497
|
+
function compareStableAsc(a, b) {
|
|
498
|
+
const pa = a.split(".").map(Number);
|
|
499
|
+
const pb = b.split(".").map(Number);
|
|
500
|
+
for (let i = 0; i < 3; i++) {
|
|
501
|
+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
|
502
|
+
}
|
|
503
|
+
return 0;
|
|
449
504
|
}
|
|
450
505
|
|
|
451
506
|
/**
|
|
@@ -562,7 +617,9 @@ export function pinnedPublicCacheDir(cacheRoot, version) {
|
|
|
562
617
|
function prunePublicRunnerCache(cacheRoot, keepVersion) {
|
|
563
618
|
try {
|
|
564
619
|
if (!cacheRoot || !existsSync(cacheRoot)) return;
|
|
565
|
-
|
|
620
|
+
// keepVersion == null → keep NOTHING (drop every public-* dir); used when a
|
|
621
|
+
// cached runner failed its version probe and none can be trusted.
|
|
622
|
+
const keep = keepVersion == null ? null : `public-${keepVersion}`;
|
|
566
623
|
for (const name of readdirSync(cacheRoot)) {
|
|
567
624
|
if (!name.startsWith("public-") || name === keep) continue;
|
|
568
625
|
rmSync(join(cacheRoot, name), { recursive: true, force: true });
|
|
@@ -572,6 +629,32 @@ function prunePublicRunnerCache(cacheRoot, keepVersion) {
|
|
|
572
629
|
}
|
|
573
630
|
}
|
|
574
631
|
|
|
632
|
+
/**
|
|
633
|
+
* Probe a runner tree for its version by invoking its own `--version` surface
|
|
634
|
+
* (`node <runner>/scripts/tot-dev.mjs --version`, the net-new rc.2 runner bin).
|
|
635
|
+
* Returns the trimmed version string, or null when the runner is too OLD to answer
|
|
636
|
+
* (pre-`--version`, i.e. pre-rc.2), missing, or errors. Never throws. Short timeout
|
|
637
|
+
* so a hung runner can't stall `tot dev`.
|
|
638
|
+
*
|
|
639
|
+
* This is the detector behind "force an upgrade if it can't tell us its version":
|
|
640
|
+
* a runner that can't report a version is by definition stale and must not be reused.
|
|
641
|
+
*/
|
|
642
|
+
export function probeRunnerVersion(runnerDir, { timeoutMs = 4000 } = {}) {
|
|
643
|
+
try {
|
|
644
|
+
const script = join(runnerDir, "scripts", "tot-dev.mjs");
|
|
645
|
+
if (!existsSync(script)) return null;
|
|
646
|
+
const out = execFileSync(process.execPath, [script, "--version"], {
|
|
647
|
+
timeout: timeoutMs,
|
|
648
|
+
encoding: "utf8",
|
|
649
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
650
|
+
});
|
|
651
|
+
const v = String(out).trim().split(/\s+/)[0];
|
|
652
|
+
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
653
|
+
} catch {
|
|
654
|
+
return null; // old runner (no --version), timeout, or spawn failure → treat as unversioned
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
575
658
|
/**
|
|
576
659
|
* Resolve the renderer for the ZERO-LOGIN sample / public-fallback path WITHOUT
|
|
577
660
|
* any MCP call — the whole point of the free taste. Resolution order:
|
|
@@ -605,6 +688,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
605
688
|
});
|
|
606
689
|
}
|
|
607
690
|
console.error(`~ renderer: ${src.why} (${src.dir})`);
|
|
691
|
+
setRunnerVersion(probeRunnerVersion(src.dir)); // telemetry: version of an override/in-tree runner
|
|
608
692
|
return src.dir;
|
|
609
693
|
}
|
|
610
694
|
|
|
@@ -629,9 +713,18 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
629
713
|
if (!explicitPin) {
|
|
630
714
|
const exact = pinnedPublicCacheDir(cacheRoot, CLI_VERSION);
|
|
631
715
|
if (exact) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
716
|
+
// Trust-but-verify: a `public-<CLI_VERSION>` dir SHOULD be a current runner,
|
|
717
|
+
// but if it can't report its own version it predates the --version surface
|
|
718
|
+
// (a corrupt/half-migrated cache) — force a fresh fetch rather than run a
|
|
719
|
+
// runner we can't identify. When it DOES answer, reuse it (fully offline-safe).
|
|
720
|
+
if (probeRunnerVersion(exact)) {
|
|
721
|
+
console.error(`~ renderer: cached public runner ${CLI_VERSION} (matches this CLI)`);
|
|
722
|
+
setRunnerVersion(CLI_VERSION);
|
|
723
|
+
prunePublicRunnerCache(cacheRoot, CLI_VERSION);
|
|
724
|
+
return exact;
|
|
725
|
+
}
|
|
726
|
+
console.error(`~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`);
|
|
727
|
+
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
|
|
635
728
|
}
|
|
636
729
|
}
|
|
637
730
|
|
|
@@ -647,6 +740,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
647
740
|
const cached = newestCachedRunner(cacheRoot);
|
|
648
741
|
if (cached && existsSync(join(cached, "scripts", "tot-dev.mjs"))) {
|
|
649
742
|
console.error(`~ renderer: offline — reusing cached runner ${cached} (couldn't reach npm to pin the version)`);
|
|
743
|
+
setRunnerVersion(probeRunnerVersion(cached)); // telemetry: best-effort version of the offline reuse
|
|
650
744
|
return cached;
|
|
651
745
|
}
|
|
652
746
|
throw new CliError(
|
|
@@ -658,11 +752,13 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
658
752
|
},
|
|
659
753
|
);
|
|
660
754
|
}
|
|
661
|
-
|
|
755
|
+
// The store preview engine (public npm ${PUBLIC_RUNNER_PACKAGE}@${pub.version}) — kept
|
|
756
|
+
// out of the user's way; the setup spinner below is the visible progress.
|
|
662
757
|
const dir = await installRunnerTarball(
|
|
663
758
|
{ source: pub.url, version: pub.cacheKey, isUrl: true, strip: pub.strip },
|
|
664
759
|
{ log: (m) => console.error(m) },
|
|
665
760
|
);
|
|
761
|
+
setRunnerVersion(pub.version); // telemetry: the runner version running this session
|
|
666
762
|
// The pinned version is now installed under public-<version> — drop any other
|
|
667
763
|
// public-* dirs (e.g. the stale public-0.1.0) so they can never be reused.
|
|
668
764
|
prunePublicRunnerCache(cacheRoot, pub.version);
|
|
@@ -697,14 +793,14 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
|
|
|
697
793
|
// First run only — set the expectation so the one-time cost doesn't read as a
|
|
698
794
|
// hang: this downloads + installs the renderer once, then every later run of
|
|
699
795
|
// this version is a no-network cache hit.
|
|
700
|
-
log(`~ first run:
|
|
796
|
+
log(`~ first run: setting up your store preview (~a minute, one-time — cached after this)…`);
|
|
701
797
|
const localSource = isUrl ? null : resolveLocalTarball(source);
|
|
702
798
|
const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${Date.now()}.tar.gz`) : localSource;
|
|
703
799
|
try {
|
|
704
800
|
if (isUrl) {
|
|
705
801
|
// The fetch itself is otherwise silent (no per-byte output) and can run
|
|
706
802
|
// tens of seconds on a cold cache — tick a spinner so it never looks hung.
|
|
707
|
-
const spin = startProgress("downloading the
|
|
803
|
+
const spin = startProgress("downloading the store preview engine…");
|
|
708
804
|
try {
|
|
709
805
|
await downloadFile(source, archivePath);
|
|
710
806
|
} finally {
|
|
@@ -718,8 +814,24 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
|
|
|
718
814
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
719
815
|
mkdirSync(stagingDir, { recursive: true });
|
|
720
816
|
extractTarball(archivePath, stagingDir, { strip });
|
|
817
|
+
// Pin the runner install to PUBLIC npm. The moat-free runner has only public
|
|
818
|
+
// deps, but the HOST's global ~/.npmrc may point `registry` at a private
|
|
819
|
+
// mirror (an internal proxy that 502s, or one an invited developer can't
|
|
820
|
+
// reach) — an invited dev's machine config must never decide where the
|
|
821
|
+
// runner's public deps come from. A project-level .npmrc wins over the user's.
|
|
822
|
+
writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
|
|
721
823
|
ensureCorepackPnpm(stagingDir);
|
|
722
|
-
|
|
824
|
+
// The install is the long, noisy step — tick a spinner while its output goes
|
|
825
|
+
// to a log, so the terminal shows one clean line instead of the pnpm firehose.
|
|
826
|
+
const installLog = join(RENDERER_CACHE_ROOT, `${version}.install.log`);
|
|
827
|
+
const installSpin = startProgress("installing the store preview engine…", {
|
|
828
|
+
stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
|
|
829
|
+
});
|
|
830
|
+
try {
|
|
831
|
+
runPnpmInstall(stagingDir, { logPath: installLog });
|
|
832
|
+
} finally {
|
|
833
|
+
installSpin.stop();
|
|
834
|
+
}
|
|
723
835
|
// Atomic-ish: only rename into the final, discoverable path once install
|
|
724
836
|
// succeeded, so a crashed/interrupted run never leaves a half-built cache
|
|
725
837
|
// entry that a later `tot dev` would treat as ready.
|
|
@@ -789,14 +901,48 @@ function ensureCorepackPnpm(runnerDir) {
|
|
|
789
901
|
spawnSync("corepack", ["prepare", pm, "--activate"], { stdio: "ignore" });
|
|
790
902
|
}
|
|
791
903
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
904
|
+
/**
|
|
905
|
+
* Install the runner's deps QUIETLY: the package manager's raw stdout (dozens of
|
|
906
|
+
* "Scope: all N workspace projects" lines, registry retry [WARN]s, dep-graph
|
|
907
|
+
* churn) is captured to a log file instead of flooding the terminal, so the
|
|
908
|
+
* caller's spinner owns the screen. On failure we surface a clean, business-
|
|
909
|
+
* readable message + the log path — never the raw node/pnpm firehose.
|
|
910
|
+
*/
|
|
911
|
+
function runPnpmInstall(runnerDir, { logPath } = {}) {
|
|
912
|
+
const fd = logPath ? openSync(logPath, "a") : null;
|
|
913
|
+
try {
|
|
914
|
+
const r = spawnSync("pnpm", ["install", "--config.dangerouslyAllowAllBuilds=true"], {
|
|
915
|
+
cwd: runnerDir,
|
|
916
|
+
// Send both streams to the log fd (or swallow them) — never inherit.
|
|
917
|
+
stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
|
|
918
|
+
});
|
|
919
|
+
if (r.status !== 0) {
|
|
920
|
+
throw new Error(
|
|
921
|
+
`couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
|
|
922
|
+
(logPath ? `\n details: ${logPath}` : ""),
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
} finally {
|
|
926
|
+
if (fd !== null) closeSync(fd);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Turn a pnpm-install failure into a human hint by scanning the captured log —
|
|
932
|
+
* the most common cause is the ToT package registry being unreachable, which the
|
|
933
|
+
* raw log buries under retry noise. Best-effort; empty string when we can't tell.
|
|
934
|
+
*/
|
|
935
|
+
function pnpmFailureHint(logPath) {
|
|
936
|
+
if (!logPath) return " — is pnpm/corepack available on this host?";
|
|
937
|
+
try {
|
|
938
|
+
const tail = readFileSync(logPath, "utf8").slice(-8000);
|
|
939
|
+
if (/npm\.tokenoftrust\.com|ERR_PNPM_FETCH|502|ECONNREFUSED|ETIMEDOUT|ENOTFOUND/i.test(tail)) {
|
|
940
|
+
return " — the Token of Trust package registry looks unreachable right now; check your connection and retry";
|
|
941
|
+
}
|
|
942
|
+
} catch {
|
|
943
|
+
/* ignore — fall through to the generic message */
|
|
799
944
|
}
|
|
945
|
+
return "";
|
|
800
946
|
}
|
|
801
947
|
|
|
802
948
|
/**
|
|
@@ -848,6 +994,12 @@ async function runContainer(workspace, args, ctx) {
|
|
|
848
994
|
|
|
849
995
|
const handle = await spawnDevContainer(plan, args, { stdio: "inherit" });
|
|
850
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
|
+
|
|
851
1003
|
// Auto-open the browser the moment the server answers (D). Non-blocking so
|
|
852
1004
|
// Ctrl-C / logs are unaffected; --no-open suppresses it.
|
|
853
1005
|
if (!args.noOpen) {
|
package/src/commands/start.mjs
CHANGED
|
@@ -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:
|
|
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);
|
|
@@ -257,9 +265,8 @@ export async function run(argv, ctx) {
|
|
|
257
265
|
printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt));
|
|
258
266
|
|
|
259
267
|
// 7. hand the terminal to the running dev server until Ctrl-C.
|
|
260
|
-
console.log("\n
|
|
261
|
-
handle.child
|
|
262
|
-
handle.child.stderr?.pipe(process.stderr);
|
|
268
|
+
console.log("\n Watching your store — edit content/home.html + save. Ctrl-C to stop.\n");
|
|
269
|
+
streamDevLogs(handle.child);
|
|
263
270
|
return handle.done;
|
|
264
271
|
} catch (e) {
|
|
265
272
|
console.error(formatError(e));
|
|
@@ -323,9 +330,8 @@ async function runSampleStart(args, ctx, env, startedAt) {
|
|
|
323
330
|
// non-blocking step later (see the run() note above).
|
|
324
331
|
printSampleLiveEnding(url, formatElapsed(Date.now() - startedAt));
|
|
325
332
|
|
|
326
|
-
console.log("\n
|
|
327
|
-
handle.child
|
|
328
|
-
handle.child.stderr?.pipe(process.stderr);
|
|
333
|
+
console.log("\n Watching your store — edit content/*.html + save. Ctrl-C to stop.\n");
|
|
334
|
+
streamDevLogs(handle.child);
|
|
329
335
|
return handle.done;
|
|
330
336
|
} catch (e) {
|
|
331
337
|
console.error(formatError(e));
|
|
@@ -430,7 +436,9 @@ async function prefetchRuntime(client, devArgs, env, runtime, ctx) {
|
|
|
430
436
|
if (!(e instanceof NativeArtifactUnavailableError)) throw e;
|
|
431
437
|
// Entitled artifact unavailable — stay native on the public runner (no Docker).
|
|
432
438
|
try {
|
|
433
|
-
|
|
439
|
+
// Normal path when the entitled artifact isn't configured for this
|
|
440
|
+
// deployment — use the public preview engine (no Docker). Silent: the
|
|
441
|
+
// download/install spinner below is the user-facing progress.
|
|
434
442
|
runtime.runnerDir = await ensureSampleRenderer(devArgs, ctx, { env });
|
|
435
443
|
} catch (e2) {
|
|
436
444
|
// Only an actual "can't reach the public runner" failure should fall to
|
|
@@ -438,7 +446,7 @@ async function prefetchRuntime(client, devArgs, env, runtime, ctx) {
|
|
|
438
446
|
// say) would otherwise be silently masked behind a confusing Docker fallback
|
|
439
447
|
// that may not even be installed. Mirrors dev.mjs's runStandalone.
|
|
440
448
|
if (!(e2 instanceof NativeArtifactUnavailableError)) throw e2;
|
|
441
|
-
console.log(` ~
|
|
449
|
+
console.log(` ~ couldn't fetch the store preview over the network — trying Docker instead.`);
|
|
442
450
|
runtime.useDocker = true;
|
|
443
451
|
await prefetchDockerLogin(client, devArgs, env);
|
|
444
452
|
}
|
|
@@ -656,6 +664,59 @@ function connectClaude() {
|
|
|
656
664
|
spawnSync("claude", [IDEAS[0]], { stdio: "inherit" });
|
|
657
665
|
}
|
|
658
666
|
|
|
667
|
+
/**
|
|
668
|
+
* Stream the running dev server's output in BUSINESS terms. The runner + Vite +
|
|
669
|
+
* Astro emit a lot of internal chatter (dependency optimization, HMR internals,
|
|
670
|
+
* build banners, "watching for file changes", pnpm tails). A developer cares
|
|
671
|
+
* about two things: that a save took effect, and any real error. So collapse a
|
|
672
|
+
* save-reload into one clean "↻ your store reloaded", drop the known internal
|
|
673
|
+
* noise, and pass anything else through (indented) so nothing important is
|
|
674
|
+
* hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
|
|
675
|
+
*/
|
|
676
|
+
function streamDevLogs(child) {
|
|
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.
|
|
680
|
+
const NOISE =
|
|
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;
|
|
682
|
+
// A real save-triggered reload (not startup "program reload" churn).
|
|
683
|
+
const RELOAD = /(hmr update|page reload)/i;
|
|
684
|
+
let reloadPending = null;
|
|
685
|
+
const emit = (line) => {
|
|
686
|
+
const t = line.replace(/\s+$/, "");
|
|
687
|
+
if (!t) return;
|
|
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)) {
|
|
692
|
+
if (reloadPending) return; // debounce a burst into one line
|
|
693
|
+
reloadPending = setTimeout(() => { reloadPending = null; }, 1000);
|
|
694
|
+
if (reloadPending.unref) reloadPending.unref();
|
|
695
|
+
process.stdout.write(" ↻ your store reloaded\n");
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
if (NOISE.test(body)) return;
|
|
699
|
+
process.stdout.write(` ${t}\n`);
|
|
700
|
+
};
|
|
701
|
+
lineStream(child.stdout, emit);
|
|
702
|
+
lineStream(child.stderr, emit);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Call `cb` once per complete line of `stream` (dependency-free line buffering). */
|
|
706
|
+
function lineStream(stream, cb) {
|
|
707
|
+
if (!stream) return;
|
|
708
|
+
let buf = "";
|
|
709
|
+
stream.on("data", (chunk) => {
|
|
710
|
+
buf += chunk.toString();
|
|
711
|
+
let nl;
|
|
712
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
713
|
+
cb(buf.slice(0, nl));
|
|
714
|
+
buf = buf.slice(nl + 1);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
stream.on("end", () => { if (buf.trim()) cb(buf); });
|
|
718
|
+
}
|
|
719
|
+
|
|
659
720
|
// ── small prompt helpers (respect non-TTY so nothing hangs in CI) ────────────
|
|
660
721
|
|
|
661
722
|
function isInteractive() {
|
|
@@ -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/mcp.mjs
CHANGED
|
@@ -23,6 +23,22 @@ export const CLI_VERSION = (() => {
|
|
|
23
23
|
}
|
|
24
24
|
})();
|
|
25
25
|
|
|
26
|
+
// The resolved @tokenoftrust/storefront-runner version for THIS invocation, stashed
|
|
27
|
+
// by `tot dev` once it resolves/probes the runner (setRunnerVersion). Null until then
|
|
28
|
+
// — most commands never run the runner. Reported in the handshake (clientInfo.packages)
|
|
29
|
+
// so telemetry sees the version of EVERY package running by default, not just the CLI.
|
|
30
|
+
let RUNNER_VERSION = null;
|
|
31
|
+
|
|
32
|
+
/** Record the runner version resolved this invocation, so the next MCP handshake reports it. */
|
|
33
|
+
export function setRunnerVersion(v) {
|
|
34
|
+
RUNNER_VERSION = v || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The package-version map reported to the server by default: each package + its running version. */
|
|
38
|
+
export function clientPackages() {
|
|
39
|
+
return { cli: CLI_VERSION, runner: RUNNER_VERSION };
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
/**
|
|
27
43
|
* @param {string} baseUrl - MCP base URL; `/mcp` is appended if absent.
|
|
28
44
|
* @param {{ token?: string, clientVersion?: string }} [opts] - optional developer OAuth
|
|
@@ -105,6 +121,14 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
105
121
|
async function initialize(
|
|
106
122
|
clientInfo = { name: "tot-cli", version: opts.clientVersion || CLI_VERSION },
|
|
107
123
|
) {
|
|
124
|
+
// Report the version of EVERY package running by default (cli + runner), not just
|
|
125
|
+
// the CLI. `version` stays the CLI version for back-compat with the existing
|
|
126
|
+
// server-side support-policy check (update-awareness Layer 2); `packages` is the
|
|
127
|
+
// richer per-package telemetry. Additive: a server that ignores `packages` still
|
|
128
|
+
// reads `version` exactly as before.
|
|
129
|
+
if (clientInfo && clientInfo.packages === undefined) {
|
|
130
|
+
clientInfo = { ...clientInfo, packages: clientPackages() };
|
|
131
|
+
}
|
|
108
132
|
await callRaw("initialize", {
|
|
109
133
|
protocolVersion: "2025-06-18",
|
|
110
134
|
capabilities: {},
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* Runs standalone (`node src/update-check-worker.mjs`), inheriting the parent env
|
|
8
8
|
* (TOT_HOME / TOT_NPM_REGISTRY). Best-effort: any failure just exits quietly.
|
|
9
9
|
*/
|
|
10
|
-
import { runRefresh } from "./update-check.mjs";
|
|
10
|
+
import { runRefresh, runPolicyRefresh } from "./update-check.mjs";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// Both best-effort + independent: the npm `latest` dist-tag (Layer 1) and the
|
|
13
|
+
// storefront-hosted support policy (Layer 2). allSettled so one failing never
|
|
14
|
+
// aborts the other; the process exits regardless.
|
|
15
|
+
Promise.allSettled([runRefresh(), runPolicyRefresh()]).finally(() => process.exit(0));
|
package/src/update-check.mjs
CHANGED
|
@@ -35,6 +35,13 @@ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
|
35
35
|
|
|
36
36
|
const PKG = "@tokenoftrust/cli";
|
|
37
37
|
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
38
|
+
// Storefront-hosted, KV-dynamic CLI version-support policy (Layer 2 source #2 —
|
|
39
|
+
// see auth.mjs for source #1, the MCP `cliPolicy`). PUBLIC GET returning
|
|
40
|
+
// { minSupported?, recommended?, message? }; overridable like the npm registry.
|
|
41
|
+
// NOTE the TRAILING SLASH: the deployed astro/CF route answers at
|
|
42
|
+
// `/api/cli/policy/` (200 JSON); the slashless form 404s on the Worker. Use the
|
|
43
|
+
// canonical trailing-slash URL so the fetch resolves.
|
|
44
|
+
const DEFAULT_POLICY_URL = "https://storefront.tokenoftrust.workers.dev/api/cli/policy/";
|
|
38
45
|
const FETCH_TIMEOUT_MS = 3000;
|
|
39
46
|
|
|
40
47
|
/** Absolute path to the update-check cache for this environment. */
|
|
@@ -84,6 +91,55 @@ export function isNewer(latest, current) {
|
|
|
84
91
|
return false;
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Full-semver precedence compare for the SERVER-POLICY path only. Unlike isNewer
|
|
96
|
+
* (which strips the prerelease suffix, so it never nudges a stable user onto a
|
|
97
|
+
* prerelease), this honors prereleases per semver — so ToT's policy can steer
|
|
98
|
+
* rc→rc (recommend 1.3.1-rc.5 to someone on 1.3.1-rc.3) and treat a full release
|
|
99
|
+
* as newer than any of its prereleases (1.3.1 > 1.3.1-rc.5). Returns -1/0/1;
|
|
100
|
+
* malformed input compares as equal-ish (never throws).
|
|
101
|
+
*/
|
|
102
|
+
export function comparePolicyVersion(a, b) {
|
|
103
|
+
const parse = (s) => {
|
|
104
|
+
const [main, pre = ""] = String(s || "").split("-");
|
|
105
|
+
const nums = main.split(".").map((n) => parseInt(n, 10) || 0);
|
|
106
|
+
return { nums: [nums[0] || 0, nums[1] || 0, nums[2] || 0], pre: pre ? pre.split(".") : [] };
|
|
107
|
+
};
|
|
108
|
+
const x = parse(a);
|
|
109
|
+
const y = parse(b);
|
|
110
|
+
for (let i = 0; i < 3; i++) {
|
|
111
|
+
if (x.nums[i] !== y.nums[i]) return x.nums[i] > y.nums[i] ? 1 : -1;
|
|
112
|
+
}
|
|
113
|
+
// Equal main version: a version WITH a prerelease is LOWER than one without.
|
|
114
|
+
if (x.pre.length === 0 && y.pre.length === 0) return 0;
|
|
115
|
+
if (x.pre.length === 0) return 1;
|
|
116
|
+
if (y.pre.length === 0) return -1;
|
|
117
|
+
// Both prereleases: compare identifier by identifier (semver §11).
|
|
118
|
+
const n = Math.max(x.pre.length, y.pre.length);
|
|
119
|
+
for (let i = 0; i < n; i++) {
|
|
120
|
+
const xi = x.pre[i];
|
|
121
|
+
const yi = y.pre[i];
|
|
122
|
+
if (xi === undefined) return -1; // fewer identifiers → lower precedence
|
|
123
|
+
if (yi === undefined) return 1;
|
|
124
|
+
const xn = /^\d+$/.test(xi);
|
|
125
|
+
const yn = /^\d+$/.test(yi);
|
|
126
|
+
if (xn && yn) {
|
|
127
|
+
const d = parseInt(xi, 10) - parseInt(yi, 10);
|
|
128
|
+
if (d !== 0) return d > 0 ? 1 : -1;
|
|
129
|
+
} else if (xn !== yn) {
|
|
130
|
+
return xn ? -1 : 1; // numeric identifiers are lower precedence than alphanumeric
|
|
131
|
+
} else if (xi !== yi) {
|
|
132
|
+
return xi > yi ? 1 : -1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** True if `target` is a strictly higher SEMVER (prerelease-aware) than `current`. */
|
|
139
|
+
export function isNewerPolicy(target, current) {
|
|
140
|
+
return comparePolicyVersion(target, current) > 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
87
143
|
/**
|
|
88
144
|
* Decide what (if anything) to tell the user, from the cached state only.
|
|
89
145
|
* Returns null or { level: "required"|"recommended"|"available", latest, message? }.
|
|
@@ -95,14 +151,17 @@ export function updateNotice(current, env = process.env) {
|
|
|
95
151
|
|
|
96
152
|
const policy = cache.policy;
|
|
97
153
|
if (policy) {
|
|
98
|
-
|
|
154
|
+
// Policy path uses the prerelease-aware compare so ToT can steer rc→rc and
|
|
155
|
+
// hard-gate a bad rc; the Layer-1 npm compare below deliberately stays on
|
|
156
|
+
// isNewer (never surfaces a same-release prerelease to a stable user).
|
|
157
|
+
if (policy.minSupported && isNewerPolicy(policy.minSupported, current)) {
|
|
99
158
|
return {
|
|
100
159
|
level: "required",
|
|
101
160
|
latest: policy.recommended || cache.latest || policy.minSupported,
|
|
102
161
|
message: policy.message,
|
|
103
162
|
};
|
|
104
163
|
}
|
|
105
|
-
if (policy.recommended &&
|
|
164
|
+
if (policy.recommended && isNewerPolicy(policy.recommended, current)) {
|
|
106
165
|
return { level: "recommended", latest: policy.recommended, message: policy.message };
|
|
107
166
|
}
|
|
108
167
|
}
|
|
@@ -180,6 +239,33 @@ export function recordServerPolicy(policy, env = process.env) {
|
|
|
180
239
|
if (policy && typeof policy === "object") writeCache({ policy }, env);
|
|
181
240
|
}
|
|
182
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Fetch the STOREFRONT-hosted CLI version policy (Layer 2, source #2) and record
|
|
244
|
+
* it. A dependency-free public GET, best-effort like runRefresh: never throws,
|
|
245
|
+
* never blocks. URL overridable via TOT_CLI_POLICY_URL (like TOT_NPM_REGISTRY).
|
|
246
|
+
* Only records when the body carries at least one policy field, so an empty
|
|
247
|
+
* `{}` (the route's unset default) is a true no-op and can't clobber an
|
|
248
|
+
* MCP-sourced policy already in the cache.
|
|
249
|
+
*/
|
|
250
|
+
export async function runPolicyRefresh(env = process.env) {
|
|
251
|
+
try {
|
|
252
|
+
const url = env.TOT_CLI_POLICY_URL || DEFAULT_POLICY_URL;
|
|
253
|
+
const res = await fetch(url, {
|
|
254
|
+
headers: { accept: "application/json" },
|
|
255
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
256
|
+
});
|
|
257
|
+
if (!res.ok) return;
|
|
258
|
+
const body = await res.json();
|
|
259
|
+
if (!body || typeof body !== "object") return;
|
|
260
|
+
const { minSupported, recommended, message } = body;
|
|
261
|
+
if (minSupported || recommended || message) {
|
|
262
|
+
recordServerPolicy({ minSupported, recommended, message }, env);
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
/* best-effort: offline / timeout / bad JSON / gated host — just skip */
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
183
269
|
/**
|
|
184
270
|
* Spawn the cache refresh in a DETACHED, unref'd child so it never delays this
|
|
185
271
|
* process or holds it open. Skips when the cache is still fresh. Never throws.
|