@tokenoftrust/cli 1.3.4-rc.1 → 1.3.4-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tot.cjs +60 -0
- package/bin/tot.mjs +1 -0
- package/package.json +3 -3
- package/src/commands/checkout.mjs +2 -0
- package/src/commands/dev.mjs +151 -36
- package/src/commands/doctor.mjs +20 -2
- package/src/commands/start.mjs +4 -4
- package/src/ensure-node.mjs +44 -0
- package/src/sample.mjs +45 -0
package/bin/tot.cjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `tot` bin entry: an ES5-only CommonJS launcher whose ONLY job is to
|
|
4
|
+
* enforce the Node floor, then hand off to the real ESM CLI (bin/tot.mjs).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists (and why it must stay ES5/CJS): `engines.node` is advisory —
|
|
7
|
+
* npm warns (EBADENGINE) and installs anyway. And the ESM entry can't guard
|
|
8
|
+
* itself: the module loader PARSES the whole static import graph before
|
|
9
|
+
* evaluating a single line, so on an old Node any modern syntax anywhere in
|
|
10
|
+
* src/ becomes a raw SyntaxError before a version check could run. A CJS file
|
|
11
|
+
* written in ES5 parses on every Node ever shipped, so THIS message — not a
|
|
12
|
+
* stack trace — is what a Node 10/12/14/16 user sees.
|
|
13
|
+
*
|
|
14
|
+
* KEEP THIS FILE ES5: var, string concat, no arrow functions, no template
|
|
15
|
+
* literals, no optional chaining, no const/let. The dynamic import() is
|
|
16
|
+
* hidden inside new Function so old parsers never see the syntax.
|
|
17
|
+
*/
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
// BY-NECESSITY COPY of the floor in src/ensure-node.mjs (this file can't import
|
|
21
|
+
// ESM) — the floor is Astro's engines requirement, the recommendation is the
|
|
22
|
+
// current LTS. A test asserts the two files stay in sync; bump BOTH together.
|
|
23
|
+
var MIN_NODE = "22.12.0";
|
|
24
|
+
var RECOMMENDED_NODE = "24";
|
|
25
|
+
|
|
26
|
+
var nodeVersion = process.versions.node;
|
|
27
|
+
var have = nodeVersion.split(".");
|
|
28
|
+
var floor = MIN_NODE.split(".");
|
|
29
|
+
var meets = false;
|
|
30
|
+
for (var i = 0; i < 3; i++) {
|
|
31
|
+
var h = parseInt(have[i], 10) || 0;
|
|
32
|
+
var f = parseInt(floor[i], 10) || 0;
|
|
33
|
+
if (h !== f) { meets = h > f; break; }
|
|
34
|
+
if (i === 2) meets = true; // equal on all three parts
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!meets) {
|
|
38
|
+
process.stderr.write(
|
|
39
|
+
"✗ tot needs Node 22.12 or newer — you're on Node " + nodeVersion + ".\n" +
|
|
40
|
+
" → next: install Node " + RECOMMENDED_NODE + " (LTS) — nvm: `nvm install " + RECOMMENDED_NODE +
|
|
41
|
+
" && nvm use " + RECOMMENDED_NODE + "`, or https://nodejs.org/ — then re-run the same command.\n"
|
|
42
|
+
);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Supported Node from here on. Hand off to the ESM CLI; pathToFileURL keeps
|
|
47
|
+
// the import specifier correct on Windows drive-letter paths too.
|
|
48
|
+
var path = require("path");
|
|
49
|
+
var pathToFileURL = require("url").pathToFileURL;
|
|
50
|
+
var entry = pathToFileURL(path.join(__dirname, "tot.mjs")).href;
|
|
51
|
+
|
|
52
|
+
// new Function with a CONSTANT body ("return import(u)") — nothing is ever
|
|
53
|
+
// interpolated into the code string; the entry URL travels as an argument. This
|
|
54
|
+
// indirection exists only so pre-import() parsers never see the import syntax.
|
|
55
|
+
new Function("u", "return import(u)")(entry).catch(function (e) {
|
|
56
|
+
// tot.mjs formats + exits on its own errors; landing here means the CLI
|
|
57
|
+
// itself failed to LOAD on a supported Node — a packaging bug, worth the detail.
|
|
58
|
+
process.stderr.write("✗ tot failed to start: " + ((e && e.message) || e) + "\n");
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
package/bin/tot.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* fetches the published storefront runner; `tot checkout/validate/submit` are
|
|
26
26
|
* pure Node. Dependency-free by design so `npm i -g @tokenoftrust/cli` stays light.
|
|
27
27
|
*/
|
|
28
|
+
import "../src/ensure-node.mjs"; // hard Node-version gate — must stay first (see the module doc)
|
|
28
29
|
import { readFileSync } from "node:fs";
|
|
29
30
|
import { detectContext } from "../src/context.mjs";
|
|
30
31
|
import { printError } from "../src/errors.mjs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.3.4-rc.
|
|
3
|
+
"version": "1.3.4-rc.3",
|
|
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",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
],
|
|
21
21
|
"type": "module",
|
|
22
22
|
"bin": {
|
|
23
|
-
"tot": "./bin/tot.
|
|
23
|
+
"tot": "./bin/tot.cjs"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"bin",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"LICENSE"
|
|
30
30
|
],
|
|
31
31
|
"engines": {
|
|
32
|
-
"node": ">=
|
|
32
|
+
"node": ">=22.12.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
35
35
|
"access": "public",
|
|
@@ -23,6 +23,7 @@ import { promisify } from "node:util";
|
|
|
23
23
|
import { createMcpClient } from "../mcp.mjs";
|
|
24
24
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
25
25
|
import { CliError, fail, formatError } from "../errors.mjs";
|
|
26
|
+
import { writeNvmrc } from "../sample.mjs";
|
|
26
27
|
|
|
27
28
|
const execFileP = promisify(execFile);
|
|
28
29
|
|
|
@@ -206,6 +207,7 @@ async function cloneRepo(gitRemote, dir, redact) {
|
|
|
206
207
|
});
|
|
207
208
|
}
|
|
208
209
|
const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
|
|
210
|
+
writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
|
|
209
211
|
return { dir, head };
|
|
210
212
|
}
|
|
211
213
|
|
package/src/commands/dev.mjs
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
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, openSync, closeSync } from "node:fs";
|
|
35
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, readdirSync, createWriteStream, openSync, closeSync, writeSync } 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";
|
|
@@ -435,12 +435,24 @@ export async function resolveRendererSource(args, { client } = {}) {
|
|
|
435
435
|
* version, so a mismatch is visible, not silent. If nothing exact/minor/≤-ceiling is
|
|
436
436
|
* published, we THROW — a release gap to fix by publishing the aligned runner, not paper over.
|
|
437
437
|
*
|
|
438
|
+
* PIN-DIRECTION DECISION (ADR 0011 — don't relitigate inline): the runner version
|
|
439
|
+
* should be DECLARED BY THE PRODUCT, not derived from the CLI's identity. The
|
|
440
|
+
* ladder below reflects the migration:
|
|
441
|
+
* 1. explicit flag/env pin — developer intent, always wins
|
|
442
|
+
* 2. declaredVersion — the STORE's own `.tot/config.json#runnerVersion`
|
|
443
|
+
* (rust-toolchain.toml-style; the target state)
|
|
444
|
+
* 3. exact CLI-version match — TRANSITIONAL lockstep rung. Delete it (and the
|
|
445
|
+
* lockstep publish regime) once tenant_checkout
|
|
446
|
+
* stamps runnerVersion into every checkout —
|
|
447
|
+
* see ADR 0011 for the exit criteria.
|
|
448
|
+
* 4. CLI-minor / ≤-ceiling — degraded-but-safe fallbacks (never a floating tag)
|
|
449
|
+
*
|
|
438
450
|
* Pure (no I/O) for testability.
|
|
439
451
|
* @param {any} meta npm packument (`dist-tags` + `versions`)
|
|
440
|
-
* @param {{ cliVersion: string, explicitPin?: string|null }} opts
|
|
452
|
+
* @param {{ cliVersion: string, explicitPin?: string|null, declaredVersion?: string|null }} opts
|
|
441
453
|
* @returns {{ version: string, reason: string }}
|
|
442
454
|
*/
|
|
443
|
-
export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
|
|
455
|
+
export function pickRunnerVersion(meta, { cliVersion, explicitPin, declaredVersion }) {
|
|
444
456
|
const distTags = meta?.["dist-tags"] || {};
|
|
445
457
|
const versions = Object.keys(meta?.versions || {});
|
|
446
458
|
|
|
@@ -457,12 +469,21 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
|
|
|
457
469
|
};
|
|
458
470
|
|
|
459
471
|
// 1) Explicit pin (flag/env): the deliberate escape hatch — honored verbatim,
|
|
460
|
-
// INCLUDING above the ceiling (someone testing a newer runner on purpose).
|
|
461
|
-
// the ONLY way past the ceiling; every automatic path below respects it.
|
|
472
|
+
// INCLUDING above the ceiling (someone testing a newer runner on purpose).
|
|
462
473
|
if (explicitPin) {
|
|
463
474
|
return { version: distTags[explicitPin] || explicitPin, reason: `pinned ${explicitPin}` };
|
|
464
475
|
}
|
|
465
476
|
|
|
477
|
+
// 1.5) PRODUCT-DECLARED version (ADR 0011): the store's checkout says which runner
|
|
478
|
+
// it runs — the CLI is just the resolver. Honored verbatim when published, INCLUDING
|
|
479
|
+
// above the ceiling: a declaration newer than this CLI means the CLI is what's stale
|
|
480
|
+
// (the caller nudges an update), not that the product is wrong. A declaration that
|
|
481
|
+
// ISN'T published is a product release gap — fall through to the normal ladder
|
|
482
|
+
// rather than hard-failing the developer's loop (the caller warns loudly).
|
|
483
|
+
if (declaredVersion && versions.includes(declaredVersion)) {
|
|
484
|
+
return { version: declaredVersion, reason: "declared by the store checkout (runnerVersion)" };
|
|
485
|
+
}
|
|
486
|
+
|
|
466
487
|
// 2) EXACT CLI-version match — the primary path for a lockstep release: the runner
|
|
467
488
|
// is published at the SAME version as the CLI (incl. prereleases like 1.3.0-rc.0,
|
|
468
489
|
// which the stable-only minor match below deliberately skips). This is what makes
|
|
@@ -507,6 +528,28 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
|
|
|
507
528
|
);
|
|
508
529
|
}
|
|
509
530
|
|
|
531
|
+
/**
|
|
532
|
+
* The runner version a store checkout DECLARES for itself (ADR 0011) — the
|
|
533
|
+
* `runnerVersion` field of `<workspace>/.tot/config.json`. This is the
|
|
534
|
+
* rust-toolchain.toml of the storefront: the PRODUCT (via tenant_checkout
|
|
535
|
+
* stamping it server-side) owns which runtime the store runs; the CLI just
|
|
536
|
+
* resolves it. Returns null when absent/malformed/not-semver — silence is
|
|
537
|
+
* correct: an undeclared checkout falls back to the transitional lockstep rung.
|
|
538
|
+
* Pure-ish (one file read) + exported for tests.
|
|
539
|
+
* @param {string|null|undefined} workspaceDir
|
|
540
|
+
* @returns {string|null}
|
|
541
|
+
*/
|
|
542
|
+
export function declaredRunnerVersion(workspaceDir) {
|
|
543
|
+
if (!workspaceDir) return null;
|
|
544
|
+
try {
|
|
545
|
+
const cfg = JSON.parse(readFileSync(join(workspaceDir, ".tot", "config.json"), "utf8"));
|
|
546
|
+
const v = cfg?.runnerVersion;
|
|
547
|
+
return typeof v === "string" && /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(v) ? v : null;
|
|
548
|
+
} catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
510
553
|
/** Parse the numeric {major, minor} from a semver (prerelease suffix ignored). Null if unparseable. */
|
|
511
554
|
export function majorMinor(v) {
|
|
512
555
|
const m = /^(\d+)\.(\d+)\./.exec(String(v || ""));
|
|
@@ -531,7 +574,7 @@ function compareStableAsc(a, b) {
|
|
|
531
574
|
* override with `--renderer-version` / TOT_RUNNER_VERSION. Package/registry
|
|
532
575
|
* overridable via env for testing.
|
|
533
576
|
*/
|
|
534
|
-
export async function resolvePublicRendererSource(args, env = process.env) {
|
|
577
|
+
export async function resolvePublicRendererSource(args, env = process.env, { declaredVersion = null } = {}) {
|
|
535
578
|
const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
|
|
536
579
|
const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
|
|
537
580
|
const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
|
|
@@ -541,11 +584,29 @@ export async function resolvePublicRendererSource(args, env = process.env) {
|
|
|
541
584
|
throw new Error(`npm metadata for ${pkg} failed: HTTP ${res.status} ${res.statusText}`);
|
|
542
585
|
}
|
|
543
586
|
const meta = await res.json();
|
|
544
|
-
const { version, reason } = pickRunnerVersion(meta, { cliVersion: CLI_VERSION, explicitPin });
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
587
|
+
const { version, reason } = pickRunnerVersion(meta, { cliVersion: CLI_VERSION, explicitPin, declaredVersion });
|
|
588
|
+
if (!explicitPin && declaredVersion) {
|
|
589
|
+
if (version === declaredVersion) {
|
|
590
|
+
// The product-declared path (ADR 0011) — the intended steady state, not skew.
|
|
591
|
+
// A declaration NEWER than this CLI means the CLI is the stale half: nudge.
|
|
592
|
+
const mm = majorMinor(declaredVersion);
|
|
593
|
+
const cliMM = majorMinor(CLI_VERSION);
|
|
594
|
+
if (mm && cliMM && (mm.major > cliMM.major || (mm.major === cliMM.major && mm.minor > cliMM.minor))) {
|
|
595
|
+
console.warn(
|
|
596
|
+
` ~ this store declares runner ${declaredVersion}, newer than your CLI (${CLI_VERSION}) — ` +
|
|
597
|
+
`if anything misbehaves: npm i -g @tokenoftrust/cli@latest`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
console.warn(
|
|
602
|
+
` ⚠ this store declares runner ${declaredVersion} but that version isn't on npm — ` +
|
|
603
|
+
`using ${version} (${reason}). The store's runnerVersion needs a published release.`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
} else if (!explicitPin && version !== CLI_VERSION) {
|
|
607
|
+
// No declaration (transitional lockstep regime — ADR 0011): exact is the goal.
|
|
608
|
+
// Resolving something ELSE means CLI/runner releases are skewed — say so LOUDLY
|
|
609
|
+
// instead of silently running a mismatched runner.
|
|
549
610
|
console.warn(
|
|
550
611
|
` ⚠ runner ${version} — no exact @${CLI_VERSION} published (${reason}). ` +
|
|
551
612
|
`CLI/runner versions are SKEWED; publish the runner at ${CLI_VERSION} to align ` +
|
|
@@ -733,26 +794,31 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
733
794
|
}
|
|
734
795
|
|
|
735
796
|
// kind === "none" — no override, not in the monorepo: fetch the PUBLIC runner
|
|
736
|
-
// from npm
|
|
797
|
+
// from npm. Version = what the STORE declares (ADR 0011), else pinned to this
|
|
798
|
+
// CLI's version (transitional lockstep). No MCP, no entitlement, no login.
|
|
737
799
|
const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
800
|
+
const declared = declaredRunnerVersion(args.workspace || ctx?.workspacePath);
|
|
801
|
+
const wantVersion = declared || CLI_VERSION;
|
|
802
|
+
|
|
803
|
+
// Fully-offline fast path: when the WANTED version (declared, else lockstep) is
|
|
804
|
+
// already cached, reuse it without touching npm — honouring "don't hit npm when
|
|
805
|
+
// the RIGHT version is already cached" without ever reusing a version the
|
|
806
|
+
// resolution wouldn't choose. Safe because `public-<version>` only exists if a
|
|
807
|
+
// prior run fetched exactly that version. Skipped when an explicit pin is set
|
|
808
|
+
// (that must go through resolution).
|
|
745
809
|
if (!explicitPin) {
|
|
746
|
-
const exact = pinnedPublicCacheDir(cacheRoot,
|
|
810
|
+
const exact = pinnedPublicCacheDir(cacheRoot, wantVersion);
|
|
747
811
|
if (exact) {
|
|
748
|
-
// Trust-but-verify: a `public-<
|
|
812
|
+
// Trust-but-verify: a `public-<version>` dir SHOULD be a current runner,
|
|
749
813
|
// but if it can't report its own version it predates the --version surface
|
|
750
814
|
// (a corrupt/half-migrated cache) — force a fresh fetch rather than run a
|
|
751
815
|
// runner we can't identify. When it DOES answer, reuse it (fully offline-safe).
|
|
752
816
|
if (probeRunnerVersion(exact)) {
|
|
753
|
-
console.error(
|
|
754
|
-
|
|
755
|
-
|
|
817
|
+
console.error(
|
|
818
|
+
`~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
|
|
819
|
+
);
|
|
820
|
+
setRunnerVersion(wantVersion);
|
|
821
|
+
prunePublicRunnerCache(cacheRoot, wantVersion);
|
|
756
822
|
return exact;
|
|
757
823
|
}
|
|
758
824
|
console.error(`~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`);
|
|
@@ -762,7 +828,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
762
828
|
|
|
763
829
|
let pub;
|
|
764
830
|
try {
|
|
765
|
-
pub = await resolvePublicRendererSource(args, env);
|
|
831
|
+
pub = await resolvePublicRendererSource(args, env, { declaredVersion: declared });
|
|
766
832
|
} catch (e) {
|
|
767
833
|
// OFFLINE / npm unreachable — no way to resolve the pinned version. Last
|
|
768
834
|
// resort: reuse ANY complete cached runner rather than hard-failing `tot dev`.
|
|
@@ -852,10 +918,12 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
|
|
|
852
918
|
// reach) — an invited dev's machine config must never decide where the
|
|
853
919
|
// runner's public deps come from. A project-level .npmrc wins over the user's.
|
|
854
920
|
writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
|
|
855
|
-
ensureCorepackPnpm(stagingDir);
|
|
856
921
|
// The install is the long, noisy step — tick a spinner while its output goes
|
|
857
922
|
// to a log, so the terminal shows one clean line instead of the pnpm firehose.
|
|
923
|
+
// corepack setup logs to the SAME file so its failures aren't invisible (they
|
|
924
|
+
// were the silent cause of "couldn't set up the store preview engine").
|
|
858
925
|
const installLog = join(RENDERER_CACHE_ROOT, `${version}.install.log`);
|
|
926
|
+
ensureCorepackPnpm(stagingDir, { logPath: installLog });
|
|
859
927
|
const installSpin = startProgress("installing the store preview engine…", {
|
|
860
928
|
stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
|
|
861
929
|
});
|
|
@@ -919,7 +987,7 @@ function extractTarball(archivePath, destDir, { strip = 0 } = {}) {
|
|
|
919
987
|
* itself is missing (very old Node), pnpm install below will surface that
|
|
920
988
|
* clearly instead.
|
|
921
989
|
*/
|
|
922
|
-
function ensureCorepackPnpm(runnerDir) {
|
|
990
|
+
function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
|
|
923
991
|
const pkgPath = join(runnerDir, "package.json");
|
|
924
992
|
if (!existsSync(pkgPath)) return;
|
|
925
993
|
let pm;
|
|
@@ -929,8 +997,22 @@ function ensureCorepackPnpm(runnerDir) {
|
|
|
929
997
|
return;
|
|
930
998
|
}
|
|
931
999
|
if (!pm) return;
|
|
932
|
-
|
|
933
|
-
|
|
1000
|
+
const fd = logPath ? openSync(logPath, "a") : null;
|
|
1001
|
+
try {
|
|
1002
|
+
// `enable` writes global shims (needs write access to the Node bin dir — an
|
|
1003
|
+
// invited dev often lacks it); `prepare --activate` caches+activates the
|
|
1004
|
+
// pinned pnpm in corepack's OWN store, which `corepack pnpm …` can then run
|
|
1005
|
+
// WITHOUT the global shim (see runPnpmInstall's fallback). Capture both to the
|
|
1006
|
+
// log — a silent corepack failure was why the install error carried no cause.
|
|
1007
|
+
for (const args of [["enable"], ["prepare", pm, "--activate"]]) {
|
|
1008
|
+
const r = spawnSync("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
|
|
1009
|
+
if (fd !== null && (r.error || r.status !== 0)) {
|
|
1010
|
+
writeSync(fd, `[tot] corepack ${args.join(" ")} → ${r.error?.code || r.error?.message || `exit ${r.status}`}\n`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
} finally {
|
|
1014
|
+
if (fd !== null) closeSync(fd);
|
|
1015
|
+
}
|
|
934
1016
|
}
|
|
935
1017
|
|
|
936
1018
|
/**
|
|
@@ -942,18 +1024,51 @@ function ensureCorepackPnpm(runnerDir) {
|
|
|
942
1024
|
*/
|
|
943
1025
|
function runPnpmInstall(runnerDir, { logPath } = {}) {
|
|
944
1026
|
const fd = logPath ? openSync(logPath, "a") : null;
|
|
1027
|
+
const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
|
|
1028
|
+
// npm FIRST: it ships with EVERY Node (including 25+, where corepack is no
|
|
1029
|
+
// longer bundled), so it's the launcher with zero machine-specific setup —
|
|
1030
|
+
// no global pnpm, no corepack shim dance. The runner tree is built to be
|
|
1031
|
+
// npm-installable (build-runner.mjs rewrites `workspace:*` → "*" and emits an
|
|
1032
|
+
// npm `workspaces` field; verified end-to-end with both installers). pnpm and
|
|
1033
|
+
// the corepack-pinned pnpm remain as fallbacks for hosts with a broken npm.
|
|
1034
|
+
// If a launcher isn't installed at all (ENOENT) we move on; a launcher that
|
|
1035
|
+
// RAN but whose install failed is the real error and stops the loop.
|
|
1036
|
+
// REQUIRES a runner >= 1.3.4-rc.2 — older runner tarballs still carry
|
|
1037
|
+
// `workspace:*` deps npm rejects (harmless here: pickRunnerVersion pins the
|
|
1038
|
+
// runner to this CLI's version, so this CLI never installs those).
|
|
1039
|
+
const attempts = [
|
|
1040
|
+
{ cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
|
|
1041
|
+
{ cmd: "pnpm", args: installArgs },
|
|
1042
|
+
{ cmd: "corepack", args: ["pnpm", ...installArgs] },
|
|
1043
|
+
];
|
|
945
1044
|
try {
|
|
946
|
-
const
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1045
|
+
for (const { cmd, args } of attempts) {
|
|
1046
|
+
const r = spawnSync(cmd, args, {
|
|
1047
|
+
cwd: runnerDir,
|
|
1048
|
+
// Send both streams to the log fd (or swallow them) — never inherit.
|
|
1049
|
+
stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
|
|
1050
|
+
});
|
|
1051
|
+
if (r.status === 0) return; // installed
|
|
1052
|
+
if (r.error?.code === "ENOENT") {
|
|
1053
|
+
// This launcher isn't on the machine — record it and try the next one.
|
|
1054
|
+
if (fd !== null) writeSync(fd, `[tot] ${cmd} not found (ENOENT) — trying the next launcher\n`);
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
// The launcher ran; the install itself failed. That's the actionable error.
|
|
1058
|
+
throw new CliError(
|
|
953
1059
|
`couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
|
|
954
1060
|
(logPath ? `\n details: ${logPath}` : ""),
|
|
1061
|
+
{ next: "check the details log above, then re-run `tot start` (it resumes from the cache)" },
|
|
955
1062
|
);
|
|
956
1063
|
}
|
|
1064
|
+
// Every launcher ENOENT'd → there's no pnpm on this machine and corepack
|
|
1065
|
+
// couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
|
|
1066
|
+
// every Node, so `npm i -g pnpm` is the escape hatch that always exists.
|
|
1067
|
+
throw new CliError(
|
|
1068
|
+
"couldn't set up the store preview engine — pnpm isn't available on this machine" +
|
|
1069
|
+
(logPath ? `\n details: ${logPath}` : ""),
|
|
1070
|
+
{ next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
|
|
1071
|
+
);
|
|
957
1072
|
} finally {
|
|
958
1073
|
if (fd !== null) closeSync(fd);
|
|
959
1074
|
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { existsSync, mkdirSync } from "node:fs";
|
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { join } from "node:path";
|
|
25
25
|
import { hasOperatorCreds } from "../auth.mjs";
|
|
26
|
+
import { MIN_NODE, nodeMeetsFloor } from "../ensure-node.mjs";
|
|
26
27
|
import { clientPackages, osLabel } from "../mcp.mjs";
|
|
27
28
|
import { defaultCredentialsPath, readCredentials, isExpired } from "../token-store.mjs";
|
|
28
29
|
import { dockerAvailable, tryStartDocker } from "./dev.mjs";
|
|
@@ -58,8 +59,25 @@ const USAGE = `tot doctor — is this machine ready to run the loop?
|
|
|
58
59
|
export function collectChecks(_ctx, env = process.env) {
|
|
59
60
|
const checks = [];
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
checks.push({
|
|
63
|
+
name: `node >= ${MIN_NODE}`,
|
|
64
|
+
pass: nodeMeetsFloor(process.versions.node),
|
|
65
|
+
detail: `have ${process.versions.node}`,
|
|
66
|
+
blocking: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Native Windows isn't a supported host for the local loop (the runner install
|
|
70
|
+
// + tar extraction assume a POSIX toolchain) — WSL is, and reports as linux
|
|
71
|
+
// here. Say so up front instead of letting `tot dev` fail with an opaque ENOENT.
|
|
72
|
+
const nativeWindows = process.platform === "win32";
|
|
73
|
+
checks.push({
|
|
74
|
+
name: "supported platform",
|
|
75
|
+
pass: !nativeWindows,
|
|
76
|
+
detail: nativeWindows
|
|
77
|
+
? "native Windows isn't supported yet — run tot inside WSL (https://learn.microsoft.com/windows/wsl/install)"
|
|
78
|
+
: "macOS / Linux / WSL",
|
|
79
|
+
blocking: true,
|
|
80
|
+
});
|
|
63
81
|
|
|
64
82
|
// Informational: the versions + OS this invocation is running on — the same
|
|
65
83
|
// context the run banners/error footers stamp, surfaced up front for a bug report.
|
package/src/commands/start.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import { resolve } from "node:path";
|
|
|
49
49
|
import { createInterface } from "node:readline/promises";
|
|
50
50
|
|
|
51
51
|
import { detectContext } from "../context.mjs";
|
|
52
|
+
import { MIN_NODE, RECOMMENDED_NODE, nodeMeetsFloor } from "../ensure-node.mjs";
|
|
52
53
|
import { createMcpClient, versionStamp } from "../mcp.mjs";
|
|
53
54
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
54
55
|
import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
|
|
@@ -284,10 +285,9 @@ export async function run(argv, ctx) {
|
|
|
284
285
|
async function runSampleStart(args, ctx, env, startedAt) {
|
|
285
286
|
try {
|
|
286
287
|
// Minimal preflight — the free path needs ONLY Node (no git, no Docker, no auth).
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
next: "upgrade Node, then re-run",
|
|
288
|
+
if (!nodeMeetsFloor(process.versions.node)) {
|
|
289
|
+
throw new CliError(`Node ${MIN_NODE}+ is required (have ${process.versions.node})`, {
|
|
290
|
+
next: `install Node ${RECOMMENDED_NODE} (LTS), then re-run`,
|
|
291
291
|
exitCode: 2,
|
|
292
292
|
});
|
|
293
293
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Node floor — single ESM source of truth — plus a runtime gate that is
|
|
3
|
+
* DEFENSE IN DEPTH behind bin/tot.cjs.
|
|
4
|
+
*
|
|
5
|
+
* THE FLOOR IS SET BY WHAT `tot dev` RUNS, not by the CLI's own code: the store
|
|
6
|
+
* preview engine is Astro (engines >=22.12.0) + wrangler (>=22). A CLI that let
|
|
7
|
+
* an older Node through would pass login/checkout and then fail deep inside the
|
|
8
|
+
* runner — the exact opaque dead-end this gate exists to prevent. Keep MIN_NODE
|
|
9
|
+
* aligned with the runner's real dependency floor when upgrading Astro.
|
|
10
|
+
*
|
|
11
|
+
* `engines.node` in package.json is advisory: `npm i -g` prints EBADENGINE and
|
|
12
|
+
* installs anyway (only `engine-strict=true` blocks it), so the floor must be
|
|
13
|
+
* enforced at runtime. The PRIMARY gate is bin/tot.cjs (the ES5 CommonJS
|
|
14
|
+
* launcher): ESM parses the entire static import graph before evaluating
|
|
15
|
+
* anything, so a check inside the .mjs world can be preempted by a SyntaxError
|
|
16
|
+
* on a Node old enough to matter. bin/tot.cjs carries a BY-NECESSITY COPY of
|
|
17
|
+
* this floor (it can't import ESM) — a test keeps the two in sync. This module
|
|
18
|
+
* re-runs the same check on the ESM side for anyone invoking `node bin/tot.mjs`
|
|
19
|
+
* directly (bypassing the bin shim). SIDE-EFFECTING BY DESIGN; bin/tot.mjs
|
|
20
|
+
* imports it first.
|
|
21
|
+
*/
|
|
22
|
+
export const MIN_NODE = "22.12.0"; // Astro's engines floor — see the module doc
|
|
23
|
+
export const RECOMMENDED_NODE = "24"; // current LTS — what the fix-it copy suggests
|
|
24
|
+
|
|
25
|
+
/** Does `version` (e.g. "22.12.0") meet the MIN_NODE floor? */
|
|
26
|
+
export function nodeMeetsFloor(version) {
|
|
27
|
+
const [maj = 0, min = 0, pat = 0] = String(version).split(".").map((n) => parseInt(n, 10) || 0);
|
|
28
|
+
const [fMaj, fMin, fPat] = MIN_NODE.split(".").map((n) => parseInt(n, 10));
|
|
29
|
+
return maj !== fMaj ? maj > fMaj : min !== fMin ? min > fMin : pat >= fPat;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The house-style gate failure, shared by this module and (as copy) bin/tot.cjs. */
|
|
33
|
+
export function floorMessage(haveVersion) {
|
|
34
|
+
return (
|
|
35
|
+
`✗ tot needs Node ${MIN_NODE.replace(/\.0$/, "")} or newer — you're on Node ${haveVersion}.\n` +
|
|
36
|
+
` → next: install Node ${RECOMMENDED_NODE} (LTS) — nvm: \`nvm install ${RECOMMENDED_NODE} && nvm use ${RECOMMENDED_NODE}\`, ` +
|
|
37
|
+
`or https://nodejs.org/ — then re-run the same command.\n`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!nodeMeetsFloor(process.versions.node)) {
|
|
42
|
+
process.stderr.write(floorMessage(process.versions.node));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
package/src/sample.mjs
CHANGED
|
@@ -28,11 +28,55 @@
|
|
|
28
28
|
import {
|
|
29
29
|
cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync,
|
|
30
30
|
} from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
31
32
|
import { fileURLToPath } from "node:url";
|
|
32
33
|
import { dirname, join, resolve } from "node:path";
|
|
34
|
+
import { RECOMMENDED_NODE, nodeMeetsFloor } from "./ensure-node.mjs";
|
|
33
35
|
|
|
34
36
|
const here = dirname(fileURLToPath(import.meta.url)); // packages/cli/src
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Drop an `.nvmrc` into a fresh checkout/scaffold (when the repo doesn't carry
|
|
40
|
+
* one). Inert text — but nvm/fnm/asdf all read it, so developers with
|
|
41
|
+
* version-manager shell hooks land on a supported Node just by cd-ing into
|
|
42
|
+
* their store, and a plain `nvm use` works with no argument. Best-effort:
|
|
43
|
+
* never fails the checkout.
|
|
44
|
+
* @param {string} dir @param {NodeJS.ProcessEnv} [env]
|
|
45
|
+
*/
|
|
46
|
+
export function writeNvmrc(dir, env = process.env) {
|
|
47
|
+
try {
|
|
48
|
+
const p = join(dir, ".nvmrc");
|
|
49
|
+
if (existsSync(p)) return; // the store repo's own pin wins
|
|
50
|
+
writeFileSync(p, pickNvmrcVersion(env) + "\n");
|
|
51
|
+
} catch {
|
|
52
|
+
/* a missing .nvmrc never blocks the loop */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The version `.nvmrc` should pin: the NEWEST Node the developer ALREADY has
|
|
58
|
+
* installed under nvm that meets the floor — so `nvm use` succeeds with zero
|
|
59
|
+
* new downloads — falling back to the recommended LTS major when nvm is absent
|
|
60
|
+
* or has nothing recent enough (there `nvm use` correctly prompts an install).
|
|
61
|
+
* Pure given env; exported for tests.
|
|
62
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function pickNvmrcVersion(env = process.env) {
|
|
66
|
+
try {
|
|
67
|
+
const root = join(env.NVM_DIR || join(homedir(), ".nvm"), "versions", "node");
|
|
68
|
+
const best = readdirSync(root)
|
|
69
|
+
.map((name) => /^v(\d+)\.(\d+)\.(\d+)$/.exec(name))
|
|
70
|
+
.filter((m) => m && nodeMeetsFloor(m.slice(1).join(".")))
|
|
71
|
+
.map((m) => m.slice(1).map(Number))
|
|
72
|
+
.sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2])[0];
|
|
73
|
+
if (best) return best.join(".");
|
|
74
|
+
} catch {
|
|
75
|
+
/* no nvm dir — fall through to the LTS recommendation */
|
|
76
|
+
}
|
|
77
|
+
return RECOMMENDED_NODE;
|
|
78
|
+
}
|
|
79
|
+
|
|
36
80
|
/** The generic sample tenant the runner registers with a compliance block (see module doc). */
|
|
37
81
|
export const SAMPLE_TENANT = "sample-store.example";
|
|
38
82
|
/** Dotted scope → the runner's path-prefix route + registry lookup (must contain a dot per context.mjs). */
|
|
@@ -128,6 +172,7 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
|
|
|
128
172
|
}
|
|
129
173
|
mkdirSync(join(dir, ".tot"), { recursive: true });
|
|
130
174
|
writeFileSync(join(dir, ".tot", "config.json"), JSON.stringify(sampleConfig(), null, 2) + "\n");
|
|
175
|
+
writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
|
|
131
176
|
|
|
132
177
|
log(` ✓ scaffolded a sample store → ${dir}`);
|
|
133
178
|
return { dir, tenant: SAMPLE_TENANT, scope: SAMPLE_SCOPE, created: true };
|