@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 +44 -16
- package/package.json +1 -1
- package/src/commands/checkout.mjs +5 -0
- package/src/commands/dev.mjs +95 -26
- package/src/obstacle-beacon.cjs +111 -0
- package/src/obstacle.mjs +35 -0
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
|
-
|
|
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
|
-
//
|
|
47
|
-
// the
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
"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
|
});
|
package/src/commands/dev.mjs
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
//
|
|
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,
|
|
811
|
+
const exact = pinnedPublicCacheDir(cacheRoot, wantVersion);
|
|
747
812
|
if (exact) {
|
|
748
|
-
// Trust-but-verify: a `public-<
|
|
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(
|
|
754
|
-
|
|
755
|
-
|
|
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 };
|
package/src/obstacle.mjs
ADDED
|
@@ -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
|
+
}
|