@tokenoftrust/cli 1.4.0-rc.1 → 1.4.0-rc.11
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/README.md +12 -9
- package/bin/tot.mjs +51 -11
- package/package.json +2 -2
- package/src/candidate-state.mjs +137 -0
- package/src/commands/{checkout.mjs → clone.mjs} +129 -28
- package/src/commands/dev.mjs +110 -15
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/grants.mjs +8 -3
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +19 -12
- package/src/commands/pr.mjs +214 -0
- package/src/commands/preview.mjs +71 -0
- package/src/commands/ship.mjs +667 -0
- package/src/commands/start.mjs +34 -14
- package/src/commands/submit.mjs +458 -41
- package/src/commands/validate.mjs +2 -2
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/oauth.mjs +92 -42
- package/src/obstacle-beacon.cjs +1 -1
package/src/commands/dev.mjs
CHANGED
|
@@ -138,7 +138,7 @@ export async function run(argv, ctx) {
|
|
|
138
138
|
console.error(
|
|
139
139
|
fail(
|
|
140
140
|
"nothing to run — you're not inside a tenant checkout",
|
|
141
|
-
"tot
|
|
141
|
+
"tot clone <tenant> (then `cd` in and re-run), pass --workspace <dir>, or try `tot dev --sample`",
|
|
142
142
|
),
|
|
143
143
|
);
|
|
144
144
|
return 2;
|
|
@@ -194,7 +194,7 @@ async function runNativePublic(workspace, args, ctx) {
|
|
|
194
194
|
const cfg = readWorkspaceConfig(workspace);
|
|
195
195
|
if (!cfg) {
|
|
196
196
|
throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
|
|
197
|
-
next: "tot
|
|
197
|
+
next: "tot clone <tenant> (produces a runnable checkout)",
|
|
198
198
|
exitCode: 2,
|
|
199
199
|
});
|
|
200
200
|
}
|
|
@@ -280,7 +280,7 @@ async function runNative(workspace, args, ctx) {
|
|
|
280
280
|
const cfg = readWorkspaceConfig(workspace);
|
|
281
281
|
if (!cfg) {
|
|
282
282
|
throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
|
|
283
|
-
next: "tot
|
|
283
|
+
next: "tot clone <tenant> (produces a runnable checkout)",
|
|
284
284
|
exitCode: 2,
|
|
285
285
|
});
|
|
286
286
|
}
|
|
@@ -700,6 +700,66 @@ export function pinnedPublicCacheDir(cacheRoot, version) {
|
|
|
700
700
|
return existsSync(join(dir, ".tot-cache-complete")) ? dir : null;
|
|
701
701
|
}
|
|
702
702
|
|
|
703
|
+
/**
|
|
704
|
+
* List the installed package dirs that could carry a platform-native binary,
|
|
705
|
+
* normalised so a scoped package reads as `@scope+name` (mirroring pnpm's virtual
|
|
706
|
+
* store naming) regardless of the on-disk layout. Prefers the pnpm virtual store
|
|
707
|
+
* (`node_modules/.pnpm/*`); falls back to a shallow `node_modules` scan (one level
|
|
708
|
+
* into `@scope/`) for an npm/flat install. Best-effort — returns [] on any error.
|
|
709
|
+
*/
|
|
710
|
+
function collectNativePackageDirs(runnerDir) {
|
|
711
|
+
const pnpmDir = join(runnerDir, "node_modules", ".pnpm");
|
|
712
|
+
if (existsSync(pnpmDir)) return readdirSync(pnpmDir);
|
|
713
|
+
const nm = join(runnerDir, "node_modules");
|
|
714
|
+
if (!existsSync(nm)) return [];
|
|
715
|
+
const names = [];
|
|
716
|
+
for (const e of readdirSync(nm, { withFileTypes: true })) {
|
|
717
|
+
if (e.name.startsWith("@") && e.isDirectory()) {
|
|
718
|
+
for (const s of readdirSync(join(nm, e.name))) names.push(`${e.name}+${s}`);
|
|
719
|
+
} else {
|
|
720
|
+
names.push(e.name);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return names;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* True unless the installed renderer is POISONED for this host's CPU arch — the
|
|
728
|
+
* "the dev server didn't come up" root cause. Platform-native binding packages
|
|
729
|
+
* are named `<family>-<os>-<cpu>[-<abi>]` (e.g. `@esbuild/darwin-arm64`,
|
|
730
|
+
* `lightningcss-linux-x64-gnu`, `@rollup/rollup-win32-x64-msvc`). npm's optional-
|
|
731
|
+
* deps bug (npm/cli#4828) can materialise a DIFFERENT arch's variant than the host
|
|
732
|
+
* needs (e.g. darwin-x64 on an arm64 Mac) — pnpm gets it right. We flag the tree
|
|
733
|
+
* as poisoned exactly when a family ships a binding for our OS but NOT our OS+CPU,
|
|
734
|
+
* which is the precise shape of the `Cannot find native binding` boot crash. A
|
|
735
|
+
* family with no variant for our OS at all is a cross-platform optional dep that's
|
|
736
|
+
* correctly absent, so it never trips the check. Never throws; on any probe error
|
|
737
|
+
* it returns true (trust the cache) so a health probe can't itself break `tot dev`.
|
|
738
|
+
*
|
|
739
|
+
* `platform`/`arch` are injectable so this is testable off the host's real arch.
|
|
740
|
+
*/
|
|
741
|
+
export function rendererCacheHealthy(runnerDir, { platform = process.platform, arch = process.arch } = {}) {
|
|
742
|
+
try {
|
|
743
|
+
const re = /^(.*?)[-+](darwin|linux|win32|freebsd|android|openharmony)-([a-z0-9]+)/;
|
|
744
|
+
const families = new Map(); // family -> Set("<os>-<cpu>")
|
|
745
|
+
for (const name of collectNativePackageDirs(runnerDir)) {
|
|
746
|
+
const m = name.match(re);
|
|
747
|
+
if (!m) continue;
|
|
748
|
+
const [, family, os, cpu] = m;
|
|
749
|
+
if (!families.has(family)) families.set(family, new Set());
|
|
750
|
+
families.get(family).add(`${os}-${cpu}`);
|
|
751
|
+
}
|
|
752
|
+
const want = `${platform}-${arch}`;
|
|
753
|
+
for (const variants of families.values()) {
|
|
754
|
+
const hasOurOs = [...variants].some((v) => v.startsWith(`${platform}-`));
|
|
755
|
+
if (hasOurOs && !variants.has(want)) return false; // wrong-arch binary present, ours missing
|
|
756
|
+
}
|
|
757
|
+
return true;
|
|
758
|
+
} catch {
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
703
763
|
/**
|
|
704
764
|
* Delete every OTHER public-runner cache dir (`public-*` except `keepVersion`)
|
|
705
765
|
* once we've resolved the version this CLI pins to — so a pre-alignment dir
|
|
@@ -814,8 +874,12 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
814
874
|
// Trust-but-verify: a `public-<version>` dir SHOULD be a current runner,
|
|
815
875
|
// but if it can't report its own version it predates the --version surface
|
|
816
876
|
// (a corrupt/half-migrated cache) — force a fresh fetch rather than run a
|
|
817
|
-
// runner we can't identify.
|
|
818
|
-
|
|
877
|
+
// runner we can't identify. It must ALSO carry native bindings for THIS
|
|
878
|
+
// host's arch (rendererCacheHealthy) — a cache poisoned with the wrong-arch
|
|
879
|
+
// bindings (npm/cli#4828) would otherwise be reused forever and crash astro
|
|
880
|
+
// at boot with the swallowed "dev server didn't come up". When both hold,
|
|
881
|
+
// reuse it (fully offline-safe).
|
|
882
|
+
if (probeRunnerVersion(exact) && rendererCacheHealthy(exact)) {
|
|
819
883
|
console.error(
|
|
820
884
|
`~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
|
|
821
885
|
);
|
|
@@ -823,7 +887,11 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
823
887
|
prunePublicRunnerCache(cacheRoot, wantVersion);
|
|
824
888
|
return exact;
|
|
825
889
|
}
|
|
826
|
-
console.error(
|
|
890
|
+
console.error(
|
|
891
|
+
rendererCacheHealthy(exact)
|
|
892
|
+
? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
|
|
893
|
+
: `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
|
|
894
|
+
);
|
|
827
895
|
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
|
|
828
896
|
}
|
|
829
897
|
}
|
|
@@ -888,7 +956,15 @@ function sourceVersionKey(source) {
|
|
|
888
956
|
export async function installRunnerTarball({ source, version, isUrl = true, strip = 0 }, { log = (m) => console.error(m) } = {}) {
|
|
889
957
|
const runnerDir = join(RENDERER_CACHE_ROOT, version);
|
|
890
958
|
const marker = join(runnerDir, ".tot-cache-complete");
|
|
891
|
-
if (existsSync(marker))
|
|
959
|
+
if (existsSync(marker)) {
|
|
960
|
+
// Reuse ONLY if the cached tree carries native bindings for this host's arch.
|
|
961
|
+
// A cache poisoned with the wrong-arch binaries (npm/cli#4828 — e.g. darwin-x64
|
|
962
|
+
// on an arm64 Mac) is otherwise trusted forever, and astro crashes at boot with
|
|
963
|
+
// `Cannot find native binding`, swallowed as "the dev server didn't come up".
|
|
964
|
+
// Unhealthy → fall through and rebuild from the source below.
|
|
965
|
+
if (rendererCacheHealthy(runnerDir)) return runnerDir; // already downloaded + installed
|
|
966
|
+
log(`~ store preview engine cache is missing native bindings for ${process.platform}-${process.arch} — rebuilding it…`);
|
|
967
|
+
}
|
|
892
968
|
|
|
893
969
|
// First run only — set the expectation so the one-time cost doesn't read as a
|
|
894
970
|
// hang: this downloads + installs the renderer once, then every later run of
|
|
@@ -939,6 +1015,21 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
|
|
|
939
1015
|
// entry that a later `tot dev` would treat as ready.
|
|
940
1016
|
rmSync(runnerDir, { recursive: true, force: true });
|
|
941
1017
|
renameSync(stagingDir, runnerDir);
|
|
1018
|
+
// Fence a fresh install against npm/cli#4828: if the installer left the wrong
|
|
1019
|
+
// arch's native bindings (or none) for this host, DON'T stamp the completion
|
|
1020
|
+
// marker — an unmarked tree is never reused, so the next run reinstalls cleanly
|
|
1021
|
+
// instead of caching the poison and crashing astro at boot. Fail loud + actionable
|
|
1022
|
+
// rather than swallow it as "the dev server didn't come up".
|
|
1023
|
+
if (!rendererCacheHealthy(runnerDir)) {
|
|
1024
|
+
await emitObstacle("renderer-native-bindings-missing");
|
|
1025
|
+
throw new CliError(
|
|
1026
|
+
`the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}`,
|
|
1027
|
+
{
|
|
1028
|
+
next: "install pnpm (`npm i -g pnpm`, or `corepack enable`) and re-run `tot start` — pnpm installs the platform-native bits npm can skip (npm/cli#4828)",
|
|
1029
|
+
exitCode: 2,
|
|
1030
|
+
},
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
942
1033
|
writeFileSync(marker, new Date().toISOString());
|
|
943
1034
|
} finally {
|
|
944
1035
|
if (isUrl) rmSync(archivePath, { force: true });
|
|
@@ -1059,21 +1150,25 @@ function spawnAsyncResult(cmd, args, opts = {}) {
|
|
|
1059
1150
|
export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
|
|
1060
1151
|
const fd = logPath ? openSync(logPath, "a") : null;
|
|
1061
1152
|
const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1153
|
+
// pnpm FIRST: the renderer tree is a bundle of native optional deps (rolldown,
|
|
1154
|
+
// esbuild, sharp, @rollup, lightningcss, @tailwindcss/oxide, @astrojs/compiler,
|
|
1155
|
+
// workerd), and npm's optional-deps bug (npm/cli#4828) routinely installs the
|
|
1156
|
+
// WRONG arch's binary or none — poisoning the cache so astro crashes at boot with
|
|
1157
|
+
// `Cannot find native binding`. pnpm resolves per-platform optional bindings
|
|
1158
|
+
// correctly (proven end-to-end). ensureCorepackPnpm() ran just above, so on the
|
|
1159
|
+
// Node floor (22.12, which bundles corepack) pnpm is available; `corepack pnpm`
|
|
1160
|
+
// is the shim path if a bare `pnpm` isn't on PATH. npm stays LAST as the escape
|
|
1161
|
+
// hatch for hosts with neither pnpm nor corepack — where rendererCacheHealthy()
|
|
1162
|
+
// then fences a poisoned result rather than shipping a broken cache silently.
|
|
1068
1163
|
// If a launcher isn't installed at all (ENOENT) we move on; a launcher that
|
|
1069
1164
|
// RAN but whose install failed is the real error and stops the loop.
|
|
1070
1165
|
// REQUIRES a runner >= 1.3.4-rc.2 — older runner tarballs still carry
|
|
1071
1166
|
// `workspace:*` deps npm rejects (harmless here: pickRunnerVersion pins the
|
|
1072
1167
|
// runner to this CLI's version, so this CLI never installs those).
|
|
1073
1168
|
const attempts = [
|
|
1074
|
-
{ cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
|
|
1075
1169
|
{ cmd: "pnpm", args: installArgs },
|
|
1076
1170
|
{ cmd: "corepack", args: ["pnpm", ...installArgs] },
|
|
1171
|
+
{ cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
|
|
1077
1172
|
];
|
|
1078
1173
|
try {
|
|
1079
1174
|
for (const { cmd, args } of attempts) {
|
|
@@ -1240,7 +1335,7 @@ export function buildContainerPlan(workspace, args, _ctx) {
|
|
|
1240
1335
|
const cfg = readWorkspaceConfig(workspace);
|
|
1241
1336
|
if (!cfg) {
|
|
1242
1337
|
throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
|
|
1243
|
-
next: "tot
|
|
1338
|
+
next: "tot clone <tenant> (produces a runnable checkout)",
|
|
1244
1339
|
exitCode: 2,
|
|
1245
1340
|
});
|
|
1246
1341
|
}
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `tot doctor` — is this machine ready to run the loop?
|
|
3
3
|
*
|
|
4
|
-
* Standalone (unlike the in-monorepo doctor): checks the things `tot
|
|
4
|
+
* Standalone (unlike the in-monorepo doctor): checks the things `tot clone`
|
|
5
5
|
* and `tot dev`/`tot start` actually need — Node, git, an MCP URL, whether an
|
|
6
6
|
* auth identity is available, and Docker — plus reports the detected context
|
|
7
7
|
* so a developer knows which mode `tot` will use here.
|
|
@@ -187,7 +187,8 @@ export async function run(argv, ctx) {
|
|
|
187
187
|
}
|
|
188
188
|
console.log(
|
|
189
189
|
failed === 0
|
|
190
|
-
? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot
|
|
190
|
+
? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot clone." +
|
|
191
|
+
"\n The loop: tot dev → tot preview → tot ship.\n"
|
|
191
192
|
: args.fix
|
|
192
193
|
? `\n✖ ${failed} check(s) still failing — see the exact next command(s) above, then re-run \`tot doctor\`.\n`
|
|
193
194
|
: `\n✖ ${failed} check(s) failed — try \`tot doctor --fix\`, or fix the above, then re-run \`tot doctor\`.\n`,
|
|
@@ -199,5 +200,5 @@ function describeContext(ctx) {
|
|
|
199
200
|
if (ctx.mode === "monorepo") return `storefront monorepo (${ctx.repoRoot})`;
|
|
200
201
|
if (ctx.mode === "checkout")
|
|
201
202
|
return `tenant checkout${ctx.tenant ? ` for "${ctx.tenant}"` : ""} (${ctx.workspacePath})`;
|
|
202
|
-
return "loose (not inside a checkout — `tot
|
|
203
|
+
return "loose (not inside a checkout — `tot clone <tenant>` to get one)";
|
|
203
204
|
}
|
package/src/commands/grants.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
|
|
27
27
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
28
28
|
import { createMcpClient } from "../mcp.mjs";
|
|
29
|
-
import { storeListError } from "./
|
|
29
|
+
import { storeListError, noStoresGuidance } from "./clone.mjs";
|
|
30
30
|
import { offerSignIn } from "./login.mjs";
|
|
31
31
|
import { recordServerPolicy } from "../update-check.mjs";
|
|
32
32
|
|
|
@@ -238,8 +238,13 @@ export async function run(argv, _ctx) {
|
|
|
238
238
|
return 1;
|
|
239
239
|
}
|
|
240
240
|
if (rows.length === 0) {
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
// Status-aware, like whoami/checkout (card c2): an UNLINKED identity is told to
|
|
242
|
+
// link, not given the "may still be propagating" copy — that's only the genuine
|
|
243
|
+
// linked-but-zero-grants case.
|
|
244
|
+
const g = noStoresGuidance(resp);
|
|
245
|
+
const line = g.headline.charAt(0).toUpperCase() + g.headline.slice(1);
|
|
246
|
+
console.log(`\n${line}.`);
|
|
247
|
+
console.log(`Next: ${g.next}`);
|
|
243
248
|
return 0;
|
|
244
249
|
}
|
|
245
250
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot link` — link your signed-in Token of Trust identity to the ToT identity
|
|
3
|
+
* broker so your store scope can be resolved.
|
|
4
|
+
*
|
|
5
|
+
* Some developers sign in fine (a valid ToT OAuth session) but their identity
|
|
6
|
+
* isn't yet linked to the broker that maps a person → the tenants they can build
|
|
7
|
+
* on — so `tot start` / `tot clone` / `tot whoami` resolve ZERO stores. That's
|
|
8
|
+
* NOT a "you weren't invited" problem and NOT "the invite is still propagating";
|
|
9
|
+
* it's a one-time link step. This command drives it end-to-end from the terminal:
|
|
10
|
+
*
|
|
11
|
+
* identity_link_begin → the MCP returns an authUrl + a poll handle
|
|
12
|
+
* open the authUrl → you approve the link in the browser
|
|
13
|
+
* identity_link_poll(handle) → we poll until it's linked, then confirm scope
|
|
14
|
+
*
|
|
15
|
+
* It reuses the SAME auth ceremony as the rest of the CLI (establishSession — the
|
|
16
|
+
* cached `tot login` session, offering an inline sign-in when there's none and
|
|
17
|
+
* we're on a TTY), so a not-signed-in developer isn't dead-ended.
|
|
18
|
+
*
|
|
19
|
+
* Dependency-free (node built-ins via mcp.mjs / auth.mjs / open.mjs).
|
|
20
|
+
*/
|
|
21
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
22
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
23
|
+
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
24
|
+
import { offerSignIn } from "./login.mjs";
|
|
25
|
+
import { openBrowser } from "../open.mjs";
|
|
26
|
+
import { CliError, fail, formatError } from "../errors.mjs";
|
|
27
|
+
import { normalizeStores } from "./clone.mjs";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const a = { mcp: null, help: false };
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const t = argv[i];
|
|
35
|
+
if (t === "--mcp") a.mcp = argv[++i];
|
|
36
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
37
|
+
}
|
|
38
|
+
return a;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const USAGE = `tot link — link your Token of Trust identity so your stores resolve
|
|
42
|
+
|
|
43
|
+
tot link open the browser, approve the link, confirm your scope
|
|
44
|
+
tot link --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
45
|
+
|
|
46
|
+
Run this when \`tot whoami\` / \`tot start\` say your identity isn't linked yet.`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tolerant extraction of the fields `identity_link_begin` returns. The link URL
|
|
50
|
+
* and poll handle field names can vary by server version, so probe the known
|
|
51
|
+
* candidates (the `pollHandle` name is fixed by the identity_link_poll contract).
|
|
52
|
+
* Pure + exported so it's unit-tested without any I/O.
|
|
53
|
+
* @param {unknown} res
|
|
54
|
+
* @returns {{ authUrl: string|null, pollHandle: string|null }}
|
|
55
|
+
*/
|
|
56
|
+
export function linkBeginFields(res) {
|
|
57
|
+
const c = res && typeof res === "object" && !Array.isArray(res) ? res : {};
|
|
58
|
+
const authUrl =
|
|
59
|
+
c.authUrl ||
|
|
60
|
+
c.url ||
|
|
61
|
+
c.verificationUrl ||
|
|
62
|
+
c.verificationUriComplete ||
|
|
63
|
+
c.verification_uri_complete ||
|
|
64
|
+
null;
|
|
65
|
+
const pollHandle = c.pollHandle || c.handle || c.poll_handle || c.pollHandleId || null;
|
|
66
|
+
return {
|
|
67
|
+
authUrl: typeof authUrl === "string" && authUrl ? authUrl : null,
|
|
68
|
+
pollHandle: typeof pollHandle === "string" && pollHandle ? pollHandle : null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Classify an `identity_link_poll` result into 'linked' | 'pending' | a raw error
|
|
74
|
+
* status. Tolerant of the shape (a boolean flag, or a status/state string) so a
|
|
75
|
+
* server-version drift doesn't strand the poll. Pure + exported for unit tests.
|
|
76
|
+
* @param {unknown} res
|
|
77
|
+
* @returns {"linked"|"pending"|string}
|
|
78
|
+
*/
|
|
79
|
+
export function linkPollStatus(res) {
|
|
80
|
+
const c = res && typeof res === "object" && !Array.isArray(res) ? res : {};
|
|
81
|
+
if (c.linked === true || c.done === true || c.complete === true) return "linked";
|
|
82
|
+
const raw =
|
|
83
|
+
(typeof c.status === "string" && c.status) || (typeof c.state === "string" && c.state) || "";
|
|
84
|
+
const s = raw.toLowerCase();
|
|
85
|
+
if (!s) return "pending";
|
|
86
|
+
// The broker namespaces its poll status with a `link_` prefix (e.g. `link_pending`,
|
|
87
|
+
// `link_approved`, `link_denied`) — strip it before matching so a normal "still
|
|
88
|
+
// waiting" state isn't misread as an unknown/error status and doesn't abort the poll.
|
|
89
|
+
// (Regression: `link_pending` fell through to the error branch and killed `tot link`
|
|
90
|
+
// the instant the developer hadn't yet finished the browser step.)
|
|
91
|
+
const bare = s.replace(/^link[_-]/, "");
|
|
92
|
+
if (/^(linked|link|complete|completed|done|ok|success|succeeded|active|approved|granted)$/.test(bare))
|
|
93
|
+
return "linked";
|
|
94
|
+
if (/^(pending|waiting|in_?progress|processing|started|created|authorizing|polling)$/.test(bare))
|
|
95
|
+
return "pending";
|
|
96
|
+
return s; // a terminal error status (link_denied, link_expired, …) — surfaced to the caller
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @param {string[]} argv @param {any} _ctx */
|
|
100
|
+
export async function run(argv, _ctx) {
|
|
101
|
+
const env = process.env;
|
|
102
|
+
const args = parseArgs(argv);
|
|
103
|
+
if (args.help) {
|
|
104
|
+
console.log(USAGE);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
109
|
+
const client = createMcpClient(baseUrl);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Same auth ordering as checkout/start — developer bearer BEFORE initialize —
|
|
113
|
+
// and the same inline sign-in offer so a not-signed-in dev isn't dead-ended.
|
|
114
|
+
try {
|
|
115
|
+
await establishSession(client, { env });
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (e instanceof AuthUnavailableError && e.reason === "missing") {
|
|
118
|
+
const signedIn = await offerSignIn(client.mcpUrl, env, {});
|
|
119
|
+
if (!signedIn) throw e;
|
|
120
|
+
await establishSession(client, { env });
|
|
121
|
+
} else {
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
console.error(`~ signed in → ${client.mcpUrl}`);
|
|
126
|
+
|
|
127
|
+
console.error("~ identity_link_begin — asking Token of Trust to start the link");
|
|
128
|
+
let begin;
|
|
129
|
+
try {
|
|
130
|
+
begin = await client.callTool("identity_link_begin", {});
|
|
131
|
+
} catch (e) {
|
|
132
|
+
throw new CliError(`couldn't start the identity link: ${String(e?.message || e)}`, {
|
|
133
|
+
next: "your MCP may not support `tot link` yet — run `tot whoami` for the current guidance",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const { authUrl, pollHandle } = linkBeginFields(begin);
|
|
137
|
+
if (!pollHandle) {
|
|
138
|
+
throw new CliError("Token of Trust didn't return a link handle to poll", {
|
|
139
|
+
next: "re-run `tot link`, or run `tot whoami` for the current guidance",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (authUrl) {
|
|
144
|
+
const opened = openBrowser(authUrl);
|
|
145
|
+
console.log(
|
|
146
|
+
opened
|
|
147
|
+
? `\n+ opening your browser to finish linking:\n ${authUrl}`
|
|
148
|
+
: `\nOpen this URL to finish linking your identity:\n ${authUrl}`,
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
console.log("\n+ finishing the link — no approval step needed …");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const linked = await pollLink(client, pollHandle, {
|
|
155
|
+
log: (m) => console.error(m),
|
|
156
|
+
});
|
|
157
|
+
if (!linked) {
|
|
158
|
+
throw new CliError("the identity link didn't complete in time", {
|
|
159
|
+
next: "finish the approval in your browser, then re-run `tot link`",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log("\n+ your identity is linked.");
|
|
164
|
+
// Confirm scope now resolves — best-effort, so a transient list hiccup doesn't
|
|
165
|
+
// fail an otherwise-successful link.
|
|
166
|
+
try {
|
|
167
|
+
const list = await client.callTool("client_list", {});
|
|
168
|
+
const stores = normalizeStores(list);
|
|
169
|
+
if (stores.length) {
|
|
170
|
+
console.log(` stores you can build on: ${stores.map((s) => s.id).join(", ")}`);
|
|
171
|
+
console.log(" Next: `tot start` (or `tot clone <tenant>`).");
|
|
172
|
+
} else {
|
|
173
|
+
console.log(" Next: `tot start` — if it still shows no stores, ask your ToT contact for a store invite.");
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
console.log(" Next: `tot start` to build your store.");
|
|
177
|
+
}
|
|
178
|
+
return 0;
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if (e instanceof AuthUnavailableError || e instanceof CliError) {
|
|
181
|
+
console.error(formatError(e));
|
|
182
|
+
return e instanceof CliError ? (e.exitCode ?? 1) : 1;
|
|
183
|
+
}
|
|
184
|
+
console.error(fail(`link failed: ${String(e?.message || e)}`));
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Poll `identity_link_poll(pollHandle)` until the link is complete, or give up
|
|
191
|
+
* after `timeoutMs`. Returns true on 'linked', false on timeout; throws a CliError
|
|
192
|
+
* on a definite error status the server reports. Injectable clock/interval keep it
|
|
193
|
+
* unit-testable, but the default path is the live poll.
|
|
194
|
+
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
195
|
+
* @param {string} pollHandle
|
|
196
|
+
* @param {{ timeoutMs?: number, intervalMs?: number, log?: (m:string)=>void }} [opts]
|
|
197
|
+
* @returns {Promise<boolean>}
|
|
198
|
+
*/
|
|
199
|
+
export async function pollLink(client, pollHandle, { timeoutMs = 120000, intervalMs = 2500, log = () => {} } = {}) {
|
|
200
|
+
const deadline = Date.now() + timeoutMs;
|
|
201
|
+
let announced = false;
|
|
202
|
+
while (Date.now() < deadline) {
|
|
203
|
+
let res;
|
|
204
|
+
try {
|
|
205
|
+
res = await client.callTool("identity_link_poll", { pollHandle });
|
|
206
|
+
} catch (e) {
|
|
207
|
+
// A transient poll error isn't fatal — keep trying until the deadline.
|
|
208
|
+
await delay(intervalMs);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const status = linkPollStatus(res);
|
|
212
|
+
if (status === "linked") return true;
|
|
213
|
+
if (status !== "pending") {
|
|
214
|
+
throw new CliError(`Token of Trust reported the link couldn't complete (${status})`, {
|
|
215
|
+
next: "re-run `tot link`, or run `tot whoami` for the current guidance",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (!announced) {
|
|
219
|
+
log("~ waiting for you to approve the link in your browser …");
|
|
220
|
+
announced = true;
|
|
221
|
+
}
|
|
222
|
+
await delay(intervalMs);
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
package/src/commands/login.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Runs the MCP OAuth 2.1 PKCE loopback (the same ceremony `claude mcp add` runs):
|
|
5
5
|
* opens the browser to the MCP's authorize page, the developer signs in with their
|
|
6
6
|
* ToT identity + approves, and the loopback catches the code and exchanges it for a
|
|
7
|
-
* token cached at ~/.tot/credentials.json. Every later command (`tot
|
|
7
|
+
* token cached at ~/.tot/credentials.json. Every later command (`tot clone`,
|
|
8
8
|
* `tot submit`, …) then runs as that developer with NO re-auth.
|
|
9
9
|
*
|
|
10
10
|
* The MCP defaults to the same target `tot submit` talks to (env MCP_BASE_URL /
|
|
@@ -19,12 +19,13 @@
|
|
|
19
19
|
*
|
|
20
20
|
* Dependency-free (node built-ins via oauth.mjs).
|
|
21
21
|
*/
|
|
22
|
-
import { loginFlow, deviceLoginFlow,
|
|
22
|
+
import { loginFlow, deviceLoginFlow, rendezvousLoginFlow, NoOpenerError } from "../oauth.mjs";
|
|
23
23
|
import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
|
|
24
24
|
import { openBrowser } from "../open.mjs";
|
|
25
25
|
import { fail } from "../errors.mjs";
|
|
26
26
|
import { cockpitRecoveryUrl, normalizeEmailHint, redactEmailForHint, emailFromJwt } from "../auth.mjs";
|
|
27
27
|
import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
28
|
+
import { versionStamp } from "../mcp.mjs";
|
|
28
29
|
|
|
29
30
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
30
31
|
|
|
@@ -108,7 +109,7 @@ const USAGE = `tot login — sign in to Token of Trust
|
|
|
108
109
|
link a later \`tot feedback\` report to your invite + session) — not meant to be
|
|
109
110
|
typed by hand.
|
|
110
111
|
|
|
111
|
-
After signing in, run \`tot whoami\` to confirm, then \`tot
|
|
112
|
+
After signing in, run \`tot whoami\` to confirm, then \`tot clone\` / \`tot submit\`.`;
|
|
112
113
|
|
|
113
114
|
/**
|
|
114
115
|
* The core of `tot login`: run the OAuth ceremony (browser loopback, or the
|
|
@@ -188,23 +189,25 @@ export async function offerSignIn(mcpUrl, env = process.env, {
|
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
/**
|
|
191
|
-
* The core of `tot login --code`: run the browserless
|
|
192
|
-
*
|
|
193
|
-
*
|
|
192
|
+
* The core of `tot login --code`: run the browserless RENDEZVOUS sign-in (attach the
|
|
193
|
+
* terminal's PKCE challenge to the pasted rendezvous handle, surface the fingerprint,
|
|
194
|
+
* poll until the developer approves it in the cockpit) and cache the grant, reusing a
|
|
195
|
+
* previously-registered client for THIS MCP so we don't re-register on every login.
|
|
196
|
+
* `log` carries the fingerprint + "waiting for approval" lines to the terminal.
|
|
194
197
|
* @returns {Promise<object>} the credentials written to disk.
|
|
195
198
|
*/
|
|
196
|
-
export async function redeemAndCache(mcpUrl, code, env = process.env) {
|
|
199
|
+
export async function redeemAndCache(mcpUrl, code, env = process.env, { log = () => {} } = {}) {
|
|
197
200
|
const path = defaultCredentialsPath(env);
|
|
198
201
|
const prior = readCredentials(path);
|
|
199
202
|
const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
|
|
200
|
-
const creds = await
|
|
203
|
+
const creds = await rendezvousLoginFlow({ mcpUrl, clientId, code, log });
|
|
201
204
|
const merged = mergeActivityBridge(prior, mcpUrl, creds);
|
|
202
205
|
writeCredentials(path, merged);
|
|
203
206
|
return merged;
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
/**
|
|
207
|
-
* `loginFlow`/`deviceLoginFlow`/`
|
|
210
|
+
* `loginFlow`/`deviceLoginFlow`/`rendezvousLoginFlow` all return the bare OAuth shape —
|
|
208
211
|
* none of them know about activityToken/activityUrl, a SEPARATE credential this
|
|
209
212
|
* same file caches via cacheActivityBridge. A bare re-login (no --code) after a
|
|
210
213
|
* prior --code sign-in would otherwise silently drop it on the next `writeCredentials`
|
|
@@ -253,17 +256,21 @@ export async function run(argv, _ctx) {
|
|
|
253
256
|
return 0;
|
|
254
257
|
}
|
|
255
258
|
|
|
259
|
+
// Show which CLI/runtime is actually running BEFORE anything else — the first thing
|
|
260
|
+
// a "why did sign-in behave oddly" investigation needs (e.g. a stale shadowing `tot`).
|
|
261
|
+
console.error(versionStamp());
|
|
262
|
+
|
|
256
263
|
const mcpUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
257
264
|
|
|
258
265
|
if (args.code) {
|
|
259
266
|
console.error(`~ signing in to Token of Trust with your invite code (${mcpUrl})`);
|
|
260
267
|
try {
|
|
261
|
-
await redeemAndCache(mcpUrl, args.code, env);
|
|
268
|
+
await redeemAndCache(mcpUrl, args.code, env, { log: (m) => console.error(m) });
|
|
262
269
|
cacheActivityBridge(env, args.activityToken, args.activityUrl);
|
|
263
270
|
cacheTraceId(env, args.traceId);
|
|
264
271
|
cacheEmailHint(env, args.emailHint);
|
|
265
272
|
console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
|
|
266
|
-
console.log(" Next: `tot whoami` to confirm, or `tot
|
|
273
|
+
console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
|
|
267
274
|
return 0;
|
|
268
275
|
} catch (e) {
|
|
269
276
|
console.error(
|
|
@@ -281,7 +288,7 @@ export async function run(argv, _ctx) {
|
|
|
281
288
|
await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
|
|
282
289
|
cacheTraceId(env, args.traceId);
|
|
283
290
|
console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
|
|
284
|
-
console.log(" Next: `tot whoami` to confirm, or `tot
|
|
291
|
+
console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
|
|
285
292
|
return 0;
|
|
286
293
|
} catch (e) {
|
|
287
294
|
console.error(
|