@tokenoftrust/cli 1.3.4-rc.2 → 1.3.4-rc.4

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 CHANGED
@@ -40,21 +40,49 @@ if (!meets) {
40
40
  " → next: install Node " + RECOMMENDED_NODE + " (LTS) — nvm: `nvm install " + RECOMMENDED_NODE +
41
41
  " && nvm use " + RECOMMENDED_NODE + "`, or https://nodejs.org/ — then re-run the same command.\n"
42
42
  );
43
- process.exit(1);
44
- }
43
+ // Beacon the hosted cockpit that this machine dead-ended on the Node floor, then
44
+ // exit. This is THE headline obstacle: an invited dev whose `tot login` (the
45
+ // pasted setup command) can't even run because their Node is too old. We're
46
+ // pre-login, pre-ESM, maybe pre-`fetch` — so the beacon reads the bridge
47
+ // credential straight from the pasted flags and posts over require("https").
48
+ // Best-effort and time-boxed: the ✗ message is already printed; we only linger
49
+ // (≤ the beacon's own timeout) to deliver telemetry before exit(1).
50
+ beaconNodeTooOld(nodeVersion, function () { process.exit(1); });
51
+ } else {
52
+ // Supported Node from here on. Hand off to the ESM CLI; pathToFileURL keeps
53
+ // the import specifier correct on Windows drive-letter paths too.
54
+ var path = require("path");
55
+ var pathToFileURL = require("url").pathToFileURL;
56
+ var entry = pathToFileURL(path.join(__dirname, "tot.mjs")).href;
45
57
 
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;
58
+ // new Function with a CONSTANT body ("return import(u)") nothing is ever
59
+ // interpolated into the code string; the entry URL travels as an argument. This
60
+ // indirection exists only so pre-import() parsers never see the import syntax.
61
+ new Function("u", "return import(u)")(entry).catch(function (e) {
62
+ // tot.mjs formats + exits on its own errors; landing here means the CLI
63
+ // itself failed to LOAD on a supported Node — a packaging bug, worth the detail.
64
+ process.stderr.write("✗ tot failed to start: " + ((e && e.message) || e) + "\n");
65
+ process.exit(1);
66
+ });
67
+ }
51
68
 
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
- });
69
+ // Fire the node-too-old obstacle beacon, then always call `done` (exactly once,
70
+ // bounded by the beacon's timeout). Isolated + fully guarded: any hiccup
71
+ // missing flags, an old Node that can't require a .cjs, a network stall — just
72
+ // falls through to `done`, so the exit path is never blocked or altered. The
73
+ // heavy lifting lives in the shared ES5 helper so the wire shape can't drift.
74
+ function beaconNodeTooOld(have, done) {
75
+ try {
76
+ var mod = require("../src/obstacle-beacon.cjs");
77
+ var act = mod.parseActivityArgs(process.argv);
78
+ if (!act.url || !act.token) { done(); return; }
79
+ var cliVersion;
80
+ try { cliVersion = require("../package.json").version; } catch (e) {}
81
+ mod.beacon(
82
+ { url: act.url, token: act.token, kind: "node-too-old", have: have, need: MIN_NODE, cliVersion: cliVersion },
83
+ done
84
+ );
85
+ } catch (e) {
86
+ done();
87
+ }
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.3.4-rc.2",
3
+ "version": "1.3.4-rc.4",
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",
@@ -24,6 +24,7 @@ import { createMcpClient } from "../mcp.mjs";
24
24
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
25
25
  import { CliError, fail, formatError } from "../errors.mjs";
26
26
  import { writeNvmrc } from "../sample.mjs";
27
+ import { emitObstacle } from "../obstacle.mjs";
27
28
 
28
29
  const execFileP = promisify(execFile);
29
30
 
@@ -202,6 +203,10 @@ async function cloneRepo(gitRemote, dir, redact) {
202
203
  try {
203
204
  await git(["clone", gitRemote, dir]);
204
205
  } catch (e) {
206
+ // Beacon the cockpit before we surface the error — covers this path for both
207
+ // `tot checkout` and `tot start` (which clones through here). Awaited so the
208
+ // packet lands before the process prints + exits; swallowed either way.
209
+ await emitObstacle("clone-failed");
205
210
  throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
206
211
  next: `check the target dir is empty and you can reach the remote, then re-run`,
207
212
  });
@@ -42,6 +42,7 @@ import { setTimeout as delay } from "node:timers/promises";
42
42
  import { createMcpClient, CLI_VERSION, setRunnerVersion, versionStamp } from "../mcp.mjs";
43
43
  import { establishSession } from "../auth.mjs";
44
44
  import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
45
+ import { emitObstacle } from "../obstacle.mjs";
45
46
  import { CliError, fail, formatError } from "../errors.mjs";
46
47
  import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
47
48
  import { startProgress } from "../progress.mjs";
@@ -435,12 +436,24 @@ export async function resolveRendererSource(args, { client } = {}) {
435
436
  * version, so a mismatch is visible, not silent. If nothing exact/minor/≤-ceiling is
436
437
  * published, we THROW — a release gap to fix by publishing the aligned runner, not paper over.
437
438
  *
439
+ * PIN-DIRECTION DECISION (ADR 0011 — don't relitigate inline): the runner version
440
+ * should be DECLARED BY THE PRODUCT, not derived from the CLI's identity. The
441
+ * ladder below reflects the migration:
442
+ * 1. explicit flag/env pin — developer intent, always wins
443
+ * 2. declaredVersion — the STORE's own `.tot/config.json#runnerVersion`
444
+ * (rust-toolchain.toml-style; the target state)
445
+ * 3. exact CLI-version match — TRANSITIONAL lockstep rung. Delete it (and the
446
+ * lockstep publish regime) once tenant_checkout
447
+ * stamps runnerVersion into every checkout —
448
+ * see ADR 0011 for the exit criteria.
449
+ * 4. CLI-minor / ≤-ceiling — degraded-but-safe fallbacks (never a floating tag)
450
+ *
438
451
  * Pure (no I/O) for testability.
439
452
  * @param {any} meta npm packument (`dist-tags` + `versions`)
440
- * @param {{ cliVersion: string, explicitPin?: string|null }} opts
453
+ * @param {{ cliVersion: string, explicitPin?: string|null, declaredVersion?: string|null }} opts
441
454
  * @returns {{ version: string, reason: string }}
442
455
  */
443
- export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
456
+ export function pickRunnerVersion(meta, { cliVersion, explicitPin, declaredVersion }) {
444
457
  const distTags = meta?.["dist-tags"] || {};
445
458
  const versions = Object.keys(meta?.versions || {});
446
459
 
@@ -457,12 +470,21 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
457
470
  };
458
471
 
459
472
  // 1) Explicit pin (flag/env): the deliberate escape hatch — honored verbatim,
460
- // INCLUDING above the ceiling (someone testing a newer runner on purpose). This is
461
- // the ONLY way past the ceiling; every automatic path below respects it.
473
+ // INCLUDING above the ceiling (someone testing a newer runner on purpose).
462
474
  if (explicitPin) {
463
475
  return { version: distTags[explicitPin] || explicitPin, reason: `pinned ${explicitPin}` };
464
476
  }
465
477
 
478
+ // 1.5) PRODUCT-DECLARED version (ADR 0011): the store's checkout says which runner
479
+ // it runs — the CLI is just the resolver. Honored verbatim when published, INCLUDING
480
+ // above the ceiling: a declaration newer than this CLI means the CLI is what's stale
481
+ // (the caller nudges an update), not that the product is wrong. A declaration that
482
+ // ISN'T published is a product release gap — fall through to the normal ladder
483
+ // rather than hard-failing the developer's loop (the caller warns loudly).
484
+ if (declaredVersion && versions.includes(declaredVersion)) {
485
+ return { version: declaredVersion, reason: "declared by the store checkout (runnerVersion)" };
486
+ }
487
+
466
488
  // 2) EXACT CLI-version match — the primary path for a lockstep release: the runner
467
489
  // is published at the SAME version as the CLI (incl. prereleases like 1.3.0-rc.0,
468
490
  // which the stable-only minor match below deliberately skips). This is what makes
@@ -507,6 +529,28 @@ export function pickRunnerVersion(meta, { cliVersion, explicitPin }) {
507
529
  );
508
530
  }
509
531
 
532
+ /**
533
+ * The runner version a store checkout DECLARES for itself (ADR 0011) — the
534
+ * `runnerVersion` field of `<workspace>/.tot/config.json`. This is the
535
+ * rust-toolchain.toml of the storefront: the PRODUCT (via tenant_checkout
536
+ * stamping it server-side) owns which runtime the store runs; the CLI just
537
+ * resolves it. Returns null when absent/malformed/not-semver — silence is
538
+ * correct: an undeclared checkout falls back to the transitional lockstep rung.
539
+ * Pure-ish (one file read) + exported for tests.
540
+ * @param {string|null|undefined} workspaceDir
541
+ * @returns {string|null}
542
+ */
543
+ export function declaredRunnerVersion(workspaceDir) {
544
+ if (!workspaceDir) return null;
545
+ try {
546
+ const cfg = JSON.parse(readFileSync(join(workspaceDir, ".tot", "config.json"), "utf8"));
547
+ const v = cfg?.runnerVersion;
548
+ return typeof v === "string" && /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(v) ? v : null;
549
+ } catch {
550
+ return null;
551
+ }
552
+ }
553
+
510
554
  /** Parse the numeric {major, minor} from a semver (prerelease suffix ignored). Null if unparseable. */
511
555
  export function majorMinor(v) {
512
556
  const m = /^(\d+)\.(\d+)\./.exec(String(v || ""));
@@ -531,7 +575,7 @@ function compareStableAsc(a, b) {
531
575
  * override with `--renderer-version` / TOT_RUNNER_VERSION. Package/registry
532
576
  * overridable via env for testing.
533
577
  */
534
- export async function resolvePublicRendererSource(args, env = process.env) {
578
+ export async function resolvePublicRendererSource(args, env = process.env, { declaredVersion = null } = {}) {
535
579
  const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
536
580
  const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
537
581
  const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
@@ -541,11 +585,29 @@ export async function resolvePublicRendererSource(args, env = process.env) {
541
585
  throw new Error(`npm metadata for ${pkg} failed: HTTP ${res.status} ${res.statusText}`);
542
586
  }
543
587
  const meta = await res.json();
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) {
588
+ const { version, reason } = pickRunnerVersion(meta, { cliVersion: CLI_VERSION, explicitPin, declaredVersion });
589
+ if (!explicitPin && declaredVersion) {
590
+ if (version === declaredVersion) {
591
+ // The product-declared path (ADR 0011) the intended steady state, not skew.
592
+ // A declaration NEWER than this CLI means the CLI is the stale half: nudge.
593
+ const mm = majorMinor(declaredVersion);
594
+ const cliMM = majorMinor(CLI_VERSION);
595
+ if (mm && cliMM && (mm.major > cliMM.major || (mm.major === cliMM.major && mm.minor > cliMM.minor))) {
596
+ console.warn(
597
+ ` ~ this store declares runner ${declaredVersion}, newer than your CLI (${CLI_VERSION}) — ` +
598
+ `if anything misbehaves: npm i -g @tokenoftrust/cli@latest`,
599
+ );
600
+ }
601
+ } else {
602
+ console.warn(
603
+ ` ⚠ this store declares runner ${declaredVersion} but that version isn't on npm — ` +
604
+ `using ${version} (${reason}). The store's runnerVersion needs a published release.`,
605
+ );
606
+ }
607
+ } else if (!explicitPin && version !== CLI_VERSION) {
608
+ // No declaration (transitional lockstep regime — ADR 0011): exact is the goal.
609
+ // Resolving something ELSE means CLI/runner releases are skewed — say so LOUDLY
610
+ // instead of silently running a mismatched runner.
549
611
  console.warn(
550
612
  ` ⚠ runner ${version} — no exact @${CLI_VERSION} published (${reason}). ` +
551
613
  `CLI/runner versions are SKEWED; publish the runner at ${CLI_VERSION} to align ` +
@@ -733,26 +795,31 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
733
795
  }
734
796
 
735
797
  // kind === "none" — no override, not in the monorepo: fetch the PUBLIC runner
736
- // from npm, PINNED to this CLI's version. No MCP, no entitlement, no login.
798
+ // from npm. Version = what the STORE declares (ADR 0011), else pinned to this
799
+ // CLI's version (transitional lockstep). No MCP, no entitlement, no login.
737
800
  const explicitPin = args.rendererVersion || env.TOT_RUNNER_VERSION || null;
738
-
739
- // Fully-offline fast path: when the runner published lockstep at this exact CLI
740
- // version is already cached, reuse it without touching npm — honouring "don't
741
- // hit npm when the RIGHT version is already cached" without ever reusing a
742
- // version the pin didn't choose. Safe because `public-<CLI_VERSION>` only exists
743
- // if a prior run fetched exactly that (pinned) version. Skipped when an explicit
744
- // pin is set (that must go through resolution).
801
+ const declared = declaredRunnerVersion(args.workspace || ctx?.workspacePath);
802
+ const wantVersion = declared || CLI_VERSION;
803
+
804
+ // Fully-offline fast path: when the WANTED version (declared, else lockstep) is
805
+ // already cached, reuse it without touching npm honouring "don't hit npm when
806
+ // the RIGHT version is already cached" without ever reusing a version the
807
+ // resolution wouldn't choose. Safe because `public-<version>` only exists if a
808
+ // prior run fetched exactly that version. Skipped when an explicit pin is set
809
+ // (that must go through resolution).
745
810
  if (!explicitPin) {
746
- const exact = pinnedPublicCacheDir(cacheRoot, CLI_VERSION);
811
+ const exact = pinnedPublicCacheDir(cacheRoot, wantVersion);
747
812
  if (exact) {
748
- // Trust-but-verify: a `public-<CLI_VERSION>` dir SHOULD be a current runner,
813
+ // Trust-but-verify: a `public-<version>` dir SHOULD be a current runner,
749
814
  // but if it can't report its own version it predates the --version surface
750
815
  // (a corrupt/half-migrated cache) — force a fresh fetch rather than run a
751
816
  // runner we can't identify. When it DOES answer, reuse it (fully offline-safe).
752
817
  if (probeRunnerVersion(exact)) {
753
- console.error(`~ renderer: cached public runner ${CLI_VERSION} (matches this CLI)`);
754
- setRunnerVersion(CLI_VERSION);
755
- prunePublicRunnerCache(cacheRoot, CLI_VERSION);
818
+ console.error(
819
+ `~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
820
+ );
821
+ setRunnerVersion(wantVersion);
822
+ prunePublicRunnerCache(cacheRoot, wantVersion);
756
823
  return exact;
757
824
  }
758
825
  console.error(`~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`);
@@ -762,7 +829,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
762
829
 
763
830
  let pub;
764
831
  try {
765
- pub = await resolvePublicRendererSource(args, env);
832
+ pub = await resolvePublicRendererSource(args, env, { declaredVersion: declared });
766
833
  } catch (e) {
767
834
  // OFFLINE / npm unreachable — no way to resolve the pinned version. Last
768
835
  // resort: reuse ANY complete cached runner rather than hard-failing `tot dev`.
@@ -862,7 +929,7 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
862
929
  stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
863
930
  });
864
931
  try {
865
- runPnpmInstall(stagingDir, { logPath: installLog });
932
+ await runPnpmInstall(stagingDir, { logPath: installLog });
866
933
  } finally {
867
934
  installSpin.stop();
868
935
  }
@@ -956,7 +1023,7 @@ function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
956
1023
  * caller's spinner owns the screen. On failure we surface a clean, business-
957
1024
  * readable message + the log path — never the raw node/pnpm firehose.
958
1025
  */
959
- function runPnpmInstall(runnerDir, { logPath } = {}) {
1026
+ async function runPnpmInstall(runnerDir, { logPath } = {}) {
960
1027
  const fd = logPath ? openSync(logPath, "a") : null;
961
1028
  const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
962
1029
  // npm FIRST: it ships with EVERY Node (including 25+, where corepack is no
@@ -989,6 +1056,7 @@ function runPnpmInstall(runnerDir, { logPath } = {}) {
989
1056
  continue;
990
1057
  }
991
1058
  // The launcher ran; the install itself failed. That's the actionable error.
1059
+ await emitObstacle("install-failed");
992
1060
  throw new CliError(
993
1061
  `couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
994
1062
  (logPath ? `\n details: ${logPath}` : ""),
@@ -998,6 +1066,7 @@ function runPnpmInstall(runnerDir, { logPath } = {}) {
998
1066
  // Every launcher ENOENT'd → there's no pnpm on this machine and corepack
999
1067
  // couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
1000
1068
  // every Node, so `npm i -g pnpm` is the escape hatch that always exists.
1069
+ await emitObstacle("pnpm-missing");
1001
1070
  throw new CliError(
1002
1071
  "couldn't set up the store preview engine — pnpm isn't available on this machine" +
1003
1072
  (logPath ? `\n details: ${logPath}` : ""),
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The obstacle beacon — a fire-and-forget POST that tells the hosted cockpit a
3
+ * `tot start` / `tot checkout` failed, so it can show the exact fix in the bridge
4
+ * strip (obstacle lane, server side already shipped). Best-effort telemetry that
5
+ * rides ALONGSIDE the house-style `✗ … → next:` error; it must NEVER change,
6
+ * delay past its timeout, or fail that error path.
7
+ *
8
+ * KEEP THIS FILE ES5/CommonJS: var, string concat, function expressions — no
9
+ * arrow functions, template literals, optional chaining, or const/let. The ES5
10
+ * launcher (bin/tot.cjs) require()s this on a machine that FAILED the Node
11
+ * floor (Node < 22.12, no global `fetch`, no ESM) to beacon `node-too-old`
12
+ * pre-login — the exact old-Node dead-end the whole obstacle lane exists for.
13
+ * So this uses require("https"/"http"), and a test keeps it parseable by
14
+ * ancient Nodes. The ESM side (src/obstacle.mjs) imports beaconAsync from here
15
+ * too, so the wire shape + endpoint live in ONE place and can't drift.
16
+ *
17
+ * The event contract is DECIDED (owned by the server's rail-obstacle-lane):
18
+ * POST <activity-url>/api/dev/activity Authorization: Bearer <token>
19
+ * { event:"obstacle", kind, have?, need?, cliVersion?, at? }
20
+ * Send ONLY these machine-readable fields — the human remediation copy is composed
21
+ * SERVER-side per kind, so guidance changes without a CLI release. The server
22
+ * stamps its own reportedAt (skew-proof) and a later heartbeat CLEARS the obstacle.
23
+ */
24
+ "use strict";
25
+
26
+ var VALID_KINDS = { "node-too-old": 1, "pnpm-missing": 1, "install-failed": 1, "clone-failed": 1 };
27
+ var TIMEOUT_MS = 2000;
28
+
29
+ /**
30
+ * Pull --activity-url / --activity-token straight out of argv. The launcher runs
31
+ * BEFORE any arg framework (and before login has cached anything), so on the
32
+ * node-too-old path this is the only source of the bridge credential — the pasted
33
+ * setup command carries both flags. Returns {} for whatever is absent.
34
+ */
35
+ function parseActivityArgs(argv) {
36
+ var out = { url: undefined, token: undefined };
37
+ if (!argv) return out;
38
+ for (var i = 0; i < argv.length; i++) {
39
+ if (argv[i] === "--activity-url") out.url = argv[i + 1];
40
+ else if (argv[i] === "--activity-token") out.token = argv[i + 1];
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * Fire one obstacle beacon. Everything is wrapped so it can never throw into the
47
+ * caller's error path; `done` is invoked EXACTLY once (on success, error, timeout,
48
+ * or a skip) — the launcher passes process.exit as `done` so the process lingers
49
+ * only as long as the beacon (≤ TIMEOUT_MS) before exiting.
50
+ * opts: { url, token, kind, have?, need?, cliVersion?, at? }
51
+ */
52
+ function beacon(opts, done) {
53
+ var finished = false;
54
+ function finish() {
55
+ if (finished) return;
56
+ finished = true;
57
+ try { if (timer) clearTimeout(timer); } catch (e) {}
58
+ if (typeof done === "function") { try { done(); } catch (e) {} }
59
+ }
60
+ var timer = null;
61
+ try {
62
+ opts = opts || {};
63
+ if (!opts.url || !opts.token || !VALID_KINDS[opts.kind]) { finish(); return; }
64
+
65
+ var body = { event: "obstacle", kind: opts.kind };
66
+ if (opts.have) body.have = opts.have;
67
+ if (opts.need) body.need = opts.need;
68
+ if (opts.cliVersion) body.cliVersion = opts.cliVersion;
69
+ body.at = opts.at || Date.now();
70
+ var payload = JSON.stringify(body);
71
+
72
+ var endpoint = String(opts.url).replace(/\/+$/, "") + "/api/dev/activity";
73
+ var lib = endpoint.indexOf("http://") === 0 ? require("http") : require("https");
74
+
75
+ // A hard timeout guarantees the caller's exit (or await) is bounded even if
76
+ // the socket hangs — telemetry never holds a failing developer hostage.
77
+ timer = setTimeout(finish, TIMEOUT_MS);
78
+
79
+ var req = lib.request(
80
+ endpoint,
81
+ {
82
+ method: "POST",
83
+ headers: {
84
+ "content-type": "application/json",
85
+ authorization: "Bearer " + opts.token,
86
+ "content-length": Buffer.byteLength(payload),
87
+ },
88
+ },
89
+ function (res) {
90
+ // Drain and finish — we don't care about the status, only that it was sent.
91
+ res.on("data", function () {});
92
+ res.on("end", finish);
93
+ res.on("error", finish);
94
+ }
95
+ );
96
+ req.on("error", finish);
97
+ req.write(payload);
98
+ req.end();
99
+ } catch (e) {
100
+ finish();
101
+ }
102
+ }
103
+
104
+ /** Promise wrapper for the ESM side (src/obstacle.mjs) so a failure path can await delivery. */
105
+ function beaconAsync(opts) {
106
+ return new Promise(function (resolve) {
107
+ try { beacon(opts, resolve); } catch (e) { resolve(); }
108
+ });
109
+ }
110
+
111
+ module.exports = { parseActivityArgs: parseActivityArgs, beacon: beacon, beaconAsync: beaconAsync };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * ESM front door to the obstacle beacon (src/obstacle-beacon.cjs). The post-login
3
+ * failure paths — `pnpm-missing`, `install-failed`, `clone-failed` — run on a
4
+ * supported Node where the bridge credential is already cached, so they resolve
5
+ * {url, token} from ~/.tot/credentials.json (the same source activityBridgeEnv +
6
+ * the heartbeat use) rather than argv. `node-too-old` does NOT come through here:
7
+ * it fires from the ES5 launcher pre-login, where creds may not exist yet.
8
+ *
9
+ * emitObstacle is awaited at the failure site so the packet is delivered before
10
+ * the process prints its `✗ … → next:` and exits — but it is fully swallowed, so
11
+ * telemetry can never disrupt or delay (past the beacon's own ~2s timeout) the
12
+ * error the developer actually needs to see.
13
+ */
14
+ import beaconCore from "./obstacle-beacon.cjs";
15
+ import { CLI_VERSION } from "./mcp.mjs";
16
+ import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
17
+
18
+ /**
19
+ * Best-effort obstacle beacon for a post-login failure. No-op (silent) when no
20
+ * bridge credential is cached — the developer signed in with a build that didn't
21
+ * carry the activity flags, or ran a bare `tot login`.
22
+ * @param {"pnpm-missing"|"install-failed"|"clone-failed"} kind
23
+ * @param {{ have?: string, need?: string, env?: NodeJS.ProcessEnv }} [opts]
24
+ */
25
+ export async function emitObstacle(kind, { have, need, env = process.env } = {}) {
26
+ try {
27
+ const creds = readCredentials(defaultCredentialsPath(env));
28
+ const url = creds?.activityUrl;
29
+ const token = creds?.activityToken;
30
+ if (!url || !token) return;
31
+ await beaconCore.beaconAsync({ url, token, kind, have, need, cliVersion: CLI_VERSION });
32
+ } catch {
33
+ /* best-effort telemetry — never disrupts the error path */
34
+ }
35
+ }