@tokenoftrust/cli 1.3.4-rc.0 → 1.3.4-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/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.0",
3
+ "version": "1.3.4-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",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "type": "module",
22
22
  "bin": {
23
- "tot": "./bin/tot.mjs"
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": ">=20"
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
 
@@ -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";
@@ -425,12 +425,15 @@ export async function resolveRendererSource(args, { client } = {}) {
425
425
  * is keyed by version, upgrading the CLI busts the stale-runner cache automatically
426
426
  * (the 2026-07-14 "cached runner (prior tot dev)" staleness).
427
427
  *
428
- * GRACEFUL + SEQUENCING-SAFE: an explicit pin (`--renderer-version` / TOT_RUNNER_VERSION)
429
- * always wins. Otherwise we prefer the CLI-minor match, and FALL BACK to the `latest`
430
- * dist-tag when no aligned version is published yet so this is safe to ship BEFORE
431
- * the runner is republished at CLI-aligned versions (today runner=0.1.x, cli=1.2.x →
432
- * no 1.2 match falls back to latest). Once the coupled publish lands, the pin
433
- * activates on its own with no further code change.
428
+ * EXACT-FIRST (the CLI requests its OWN version): with lockstep publishing the runner
429
+ * is published at the SAME version as the CLI, so the exact match (step 2) is the normal
430
+ * path. An explicit pin (`--renderer-version` / TOT_RUNNER_VERSION) always wins. When no
431
+ * exact match exists we degrade ONLY DOWNWARD a runner at or below the CLI's major.minor
432
+ * (never ahead) and NEVER to a floating dist-tag like `latest` (a channel can point at a
433
+ * version this CLI doesn't expect; that floating-tag drift is the exact bug this avoids).
434
+ * The caller emits a LOUD skew warning whenever the resolved version isn't the exact CLI
435
+ * version, so a mismatch is visible, not silent. If nothing exact/minor/≤-ceiling is
436
+ * published, we THROW — a release gap to fix by publishing the aligned runner, not paper over.
434
437
  *
435
438
  * Pure (no I/O) for testability.
436
439
  * @param {any} meta npm packument (`dist-tags` + `versions`)
@@ -493,10 +496,12 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
493
496
  const version = capped[capped.length - 1];
494
497
  return { version, reason: `highest ≤ CLI major.minor ${cliMM ? `${cliMM.major}.${cliMM.minor}` : "?"}` };
495
498
  }
496
- // Last resort: `latest`, but ONLY if it doesn't breach the ceiling.
497
- if (distTags.latest && withinCeiling(distTags.latest)) {
498
- return { version: distTags.latest, reason: "fell back to latest (within CLI ceiling)" };
499
- }
499
+ // NO floating-tag last resort. We deliberately do NOT fall back to the `latest`
500
+ // dist-tag: a channel can drift behind/ahead of what THIS CLI expects (the exact
501
+ // failure mode that shipped stale CLIs see the copy-paste pin in the storefront
502
+ // cockpit). If nothing exact/minor/≤-ceiling is published, that's a release gap to
503
+ // fix by publishing the runner at the CLI's version — not something to paper over
504
+ // with whatever `latest` happens to point at.
500
505
  throw new Error(
501
506
  `no runner version at or below the CLI's major.minor (${cliMM ? `${cliMM.major}.${cliMM.minor}` : cliVersion})`,
502
507
  );
@@ -536,7 +541,17 @@ export async function resolvePublicRendererSource(args, env = process.env) {
536
541
  throw new Error(`npm metadata for ${pkg} failed: HTTP ${res.status} ${res.statusText}`);
537
542
  }
538
543
  const meta = await res.json();
539
- const { version } = pickRunnerVersion(meta, { cliVersion: CLI_VERSION, explicitPin });
544
+ const { version, reason } = pickRunnerVersion(meta, { cliVersion: CLI_VERSION, explicitPin });
545
+ // Exact is the goal (lockstep publish → runner@CLI_VERSION always present). If we
546
+ // resolved something ELSE, the versions are skewed — surface it LOUDLY instead of
547
+ // silently running a mismatched runner (the class of bug this whole change targets).
548
+ if (!explicitPin && version !== CLI_VERSION) {
549
+ console.warn(
550
+ ` ⚠ runner ${version} — no exact @${CLI_VERSION} published (${reason}). ` +
551
+ `CLI/runner versions are SKEWED; publish the runner at ${CLI_VERSION} to align ` +
552
+ `(or pin with --renderer-version to silence).`,
553
+ );
554
+ }
540
555
  const tarball = meta?.versions?.[version]?.dist?.tarball;
541
556
  if (!tarball) throw new Error(`no published ${pkg}@${version} on npm`);
542
557
  return { kind: "public", version, url: tarball, strip: 1, cacheKey: `public-${version}` };
@@ -837,10 +852,12 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
837
852
  // reach) — an invited dev's machine config must never decide where the
838
853
  // runner's public deps come from. A project-level .npmrc wins over the user's.
839
854
  writeFileSync(join(stagingDir, ".npmrc"), "registry=https://registry.npmjs.org/\n");
840
- ensureCorepackPnpm(stagingDir);
841
855
  // The install is the long, noisy step — tick a spinner while its output goes
842
856
  // to a log, so the terminal shows one clean line instead of the pnpm firehose.
857
+ // corepack setup logs to the SAME file so its failures aren't invisible (they
858
+ // were the silent cause of "couldn't set up the store preview engine").
843
859
  const installLog = join(RENDERER_CACHE_ROOT, `${version}.install.log`);
860
+ ensureCorepackPnpm(stagingDir, { logPath: installLog });
844
861
  const installSpin = startProgress("installing the store preview engine…", {
845
862
  stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
846
863
  });
@@ -904,7 +921,7 @@ function extractTarball(archivePath, destDir, { strip = 0 } = {}) {
904
921
  * itself is missing (very old Node), pnpm install below will surface that
905
922
  * clearly instead.
906
923
  */
907
- function ensureCorepackPnpm(runnerDir) {
924
+ function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
908
925
  const pkgPath = join(runnerDir, "package.json");
909
926
  if (!existsSync(pkgPath)) return;
910
927
  let pm;
@@ -914,8 +931,22 @@ function ensureCorepackPnpm(runnerDir) {
914
931
  return;
915
932
  }
916
933
  if (!pm) return;
917
- spawnSync("corepack", ["enable"], { stdio: "ignore" });
918
- spawnSync("corepack", ["prepare", pm, "--activate"], { stdio: "ignore" });
934
+ const fd = logPath ? openSync(logPath, "a") : null;
935
+ try {
936
+ // `enable` writes global shims (needs write access to the Node bin dir — an
937
+ // invited dev often lacks it); `prepare --activate` caches+activates the
938
+ // pinned pnpm in corepack's OWN store, which `corepack pnpm …` can then run
939
+ // WITHOUT the global shim (see runPnpmInstall's fallback). Capture both to the
940
+ // log — a silent corepack failure was why the install error carried no cause.
941
+ for (const args of [["enable"], ["prepare", pm, "--activate"]]) {
942
+ const r = spawnSync("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
943
+ if (fd !== null && (r.error || r.status !== 0)) {
944
+ writeSync(fd, `[tot] corepack ${args.join(" ")} → ${r.error?.code || r.error?.message || `exit ${r.status}`}\n`);
945
+ }
946
+ }
947
+ } finally {
948
+ if (fd !== null) closeSync(fd);
949
+ }
919
950
  }
920
951
 
921
952
  /**
@@ -927,18 +958,51 @@ function ensureCorepackPnpm(runnerDir) {
927
958
  */
928
959
  function runPnpmInstall(runnerDir, { logPath } = {}) {
929
960
  const fd = logPath ? openSync(logPath, "a") : null;
961
+ const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
962
+ // npm FIRST: it ships with EVERY Node (including 25+, where corepack is no
963
+ // longer bundled), so it's the launcher with zero machine-specific setup —
964
+ // no global pnpm, no corepack shim dance. The runner tree is built to be
965
+ // npm-installable (build-runner.mjs rewrites `workspace:*` → "*" and emits an
966
+ // npm `workspaces` field; verified end-to-end with both installers). pnpm and
967
+ // the corepack-pinned pnpm remain as fallbacks for hosts with a broken npm.
968
+ // If a launcher isn't installed at all (ENOENT) we move on; a launcher that
969
+ // RAN but whose install failed is the real error and stops the loop.
970
+ // REQUIRES a runner >= 1.3.4-rc.2 — older runner tarballs still carry
971
+ // `workspace:*` deps npm rejects (harmless here: pickRunnerVersion pins the
972
+ // runner to this CLI's version, so this CLI never installs those).
973
+ const attempts = [
974
+ { cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
975
+ { cmd: "pnpm", args: installArgs },
976
+ { cmd: "corepack", args: ["pnpm", ...installArgs] },
977
+ ];
930
978
  try {
931
- const r = spawnSync("pnpm", ["install", "--config.dangerouslyAllowAllBuilds=true"], {
932
- cwd: runnerDir,
933
- // Send both streams to the log fd (or swallow them) — never inherit.
934
- stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
935
- });
936
- if (r.status !== 0) {
937
- throw new Error(
979
+ for (const { cmd, args } of attempts) {
980
+ const r = spawnSync(cmd, args, {
981
+ cwd: runnerDir,
982
+ // Send both streams to the log fd (or swallow them) — never inherit.
983
+ stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
984
+ });
985
+ if (r.status === 0) return; // installed
986
+ if (r.error?.code === "ENOENT") {
987
+ // This launcher isn't on the machine — record it and try the next one.
988
+ if (fd !== null) writeSync(fd, `[tot] ${cmd} not found (ENOENT) — trying the next launcher\n`);
989
+ continue;
990
+ }
991
+ // The launcher ran; the install itself failed. That's the actionable error.
992
+ throw new CliError(
938
993
  `couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
939
994
  (logPath ? `\n details: ${logPath}` : ""),
995
+ { next: "check the details log above, then re-run `tot start` (it resumes from the cache)" },
940
996
  );
941
997
  }
998
+ // Every launcher ENOENT'd → there's no pnpm on this machine and corepack
999
+ // couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
1000
+ // every Node, so `npm i -g pnpm` is the escape hatch that always exists.
1001
+ throw new CliError(
1002
+ "couldn't set up the store preview engine — pnpm isn't available on this machine" +
1003
+ (logPath ? `\n details: ${logPath}` : ""),
1004
+ { next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
1005
+ );
942
1006
  } finally {
943
1007
  if (fd !== null) closeSync(fd);
944
1008
  }
@@ -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
- const nodeMajor = Number(process.versions.node.split(".")[0]);
62
- checks.push({ name: "node >= 20", pass: nodeMajor >= 20, detail: `have ${process.versions.node}`, blocking: true });
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.
@@ -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
- const nodeMajor = Number(process.versions.node.split(".")[0]);
288
- if (nodeMajor < 20) {
289
- throw new CliError(`Node 20+ is required (have ${process.versions.node})`, {
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 };