@tokenoftrust/cli 1.3.4-rc.3 → 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.3",
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";
@@ -928,7 +929,7 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
928
929
  stages: [{ afterMs: 20000, text: "still setting up the preview engine (first run only)…" }],
929
930
  });
930
931
  try {
931
- runPnpmInstall(stagingDir, { logPath: installLog });
932
+ await runPnpmInstall(stagingDir, { logPath: installLog });
932
933
  } finally {
933
934
  installSpin.stop();
934
935
  }
@@ -1022,7 +1023,7 @@ function ensureCorepackPnpm(runnerDir, { logPath } = {}) {
1022
1023
  * caller's spinner owns the screen. On failure we surface a clean, business-
1023
1024
  * readable message + the log path — never the raw node/pnpm firehose.
1024
1025
  */
1025
- function runPnpmInstall(runnerDir, { logPath } = {}) {
1026
+ async function runPnpmInstall(runnerDir, { logPath } = {}) {
1026
1027
  const fd = logPath ? openSync(logPath, "a") : null;
1027
1028
  const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
1028
1029
  // npm FIRST: it ships with EVERY Node (including 25+, where corepack is no
@@ -1055,6 +1056,7 @@ function runPnpmInstall(runnerDir, { logPath } = {}) {
1055
1056
  continue;
1056
1057
  }
1057
1058
  // The launcher ran; the install itself failed. That's the actionable error.
1059
+ await emitObstacle("install-failed");
1058
1060
  throw new CliError(
1059
1061
  `couldn't set up the store preview engine${pnpmFailureHint(logPath)}` +
1060
1062
  (logPath ? `\n details: ${logPath}` : ""),
@@ -1064,6 +1066,7 @@ function runPnpmInstall(runnerDir, { logPath } = {}) {
1064
1066
  // Every launcher ENOENT'd → there's no pnpm on this machine and corepack
1065
1067
  // couldn't provide one (corepack isn't bundled on Node 25+). npm ships with
1066
1068
  // every Node, so `npm i -g pnpm` is the escape hatch that always exists.
1069
+ await emitObstacle("pnpm-missing");
1067
1070
  throw new CliError(
1068
1071
  "couldn't set up the store preview engine — pnpm isn't available on this machine" +
1069
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
+ }