@tokenoftrust/cli 1.4.0-rc.0 → 1.4.0-rc.10
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 +19 -13
- package/bin/tot.mjs +51 -11
- package/package.json +2 -2
- package/src/candidate-state.mjs +97 -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 +200 -0
- package/src/commands/preview.mjs +71 -0
- package/src/commands/ship.mjs +53 -0
- package/src/commands/start.mjs +34 -14
- package/src/commands/submit.mjs +239 -23
- 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/token-store.mjs +38 -15
package/src/commands/submit.mjs
CHANGED
|
@@ -46,12 +46,19 @@ import { validateTenant, ERROR } from "../validate.mjs";
|
|
|
46
46
|
import { openBrowser } from "../open.mjs";
|
|
47
47
|
import { startProgress } from "../progress.mjs";
|
|
48
48
|
import { fail } from "../errors.mjs";
|
|
49
|
+
import {
|
|
50
|
+
defaultCandidateStatePath,
|
|
51
|
+
readActiveChangeId,
|
|
52
|
+
writeActiveChangeId,
|
|
53
|
+
mintFreshChangeId,
|
|
54
|
+
isTerminalCandidateState,
|
|
55
|
+
} from "../candidate-state.mjs";
|
|
49
56
|
|
|
50
57
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
51
58
|
const DEFAULT_REF = "preview";
|
|
52
59
|
|
|
53
60
|
export function parseArgs(argv) {
|
|
54
|
-
const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, message: null, summary: null, help: false };
|
|
61
|
+
const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, new: false, help: false };
|
|
55
62
|
for (let i = 0; i < argv.length; i++) {
|
|
56
63
|
const t = argv[i];
|
|
57
64
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -60,28 +67,48 @@ export function parseArgs(argv) {
|
|
|
60
67
|
else if (t === "-m" || t === "--message") a.message = argv[++i];
|
|
61
68
|
else if (t === "--summary") a.summary = argv[++i];
|
|
62
69
|
else if (t === "--skip-validate") a.skipValidate = true;
|
|
70
|
+
else if (t === "--no-commit") a.noCommit = true;
|
|
63
71
|
else if (t === "--no-wait") a.noWait = true;
|
|
64
72
|
else if (t === "--watch") a.watch = true;
|
|
65
73
|
else if (t === "--no-open") a.noOpen = true;
|
|
74
|
+
else if (t === "--new") a.new = true;
|
|
66
75
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
67
76
|
}
|
|
68
77
|
return a;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Render the usage block for whichever verb invoked this flow. `preview` is the
|
|
82
|
+
* first-class verb; `submit`/`deploy` reach the same flow as teaching aliases, so
|
|
83
|
+
* the help they print names the verb the developer actually typed (see preview.mjs).
|
|
84
|
+
* @param {string} [verb]
|
|
85
|
+
*/
|
|
86
|
+
export function renderUsage(verb = "preview") {
|
|
87
|
+
return `tot ${verb} — submit your store for preview
|
|
88
|
+
|
|
89
|
+
tot ${verb} validate → push the preview ref → stream the result
|
|
90
|
+
tot ${verb} --new open a NEW candidate PR instead of updating your open one
|
|
91
|
+
tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
92
|
+
tot ${verb} --skip-validate push without the local lint (not recommended)
|
|
93
|
+
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
94
|
+
tot ${verb} --ref <name> push ref (default: ${DEFAULT_REF})
|
|
95
|
+
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
96
|
+
tot ${verb} --summary "<text>" longer description to accompany the title
|
|
97
|
+
tot ${verb} --no-wait push and exit without polling for the reconcile result
|
|
98
|
+
tot ${verb} --no-open don't open the preview URL in the browser on success
|
|
99
|
+
tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
100
|
+
|
|
101
|
+
By default a re-run UPDATES your open candidate PR (like pushing more commits
|
|
102
|
+
to a GitHub PR), rather than opening a new one each time. Use --new to fork a
|
|
103
|
+
fresh candidate PR; the next plain \`tot ${verb}\` then updates THAT one. Manage your
|
|
104
|
+
open candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
105
|
+
merged or closed, a re-run automatically opens a fresh one.
|
|
72
106
|
|
|
73
|
-
|
|
74
|
-
tot submit --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
75
|
-
tot submit --skip-validate push without the local lint (not recommended)
|
|
76
|
-
tot submit --ref <name> push ref (default: ${DEFAULT_REF})
|
|
77
|
-
tot submit -m "<title>" one-line summary of what changed (the approver sees this)
|
|
78
|
-
tot submit --summary "<text>" longer description to accompany the title
|
|
79
|
-
tot submit --no-wait push and exit without polling for the reconcile result
|
|
80
|
-
tot submit --no-open don't open the preview URL in the browser on success
|
|
81
|
-
tot submit --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
107
|
+
Once a preview reconciles cleanly, \`tot ship\` promotes it live.
|
|
82
108
|
|
|
83
109
|
If you omit -m, a summary is generated from git (commit subject + the diff vs
|
|
84
110
|
what's live in preview) so the change record the approver reviews is never blank.`;
|
|
111
|
+
}
|
|
85
112
|
|
|
86
113
|
// Default bounded wait (~20s, matching the pre-E2 fixed poll's total budget) vs.
|
|
87
114
|
// --watch's longer per-call long-poll + more attempts (~8 min ceiling) for a dev
|
|
@@ -123,6 +150,126 @@ function printChangeSummary({ title, body, autoTitle }) {
|
|
|
123
150
|
if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
|
|
124
151
|
}
|
|
125
152
|
|
|
153
|
+
// ─── auto-commit the known content trees (unit u2) ───────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The content trees `tot preview` may auto-commit on a dirty tree — and ONLY
|
|
157
|
+
* these. A store's reviewable content lives here; anything a developer edits
|
|
158
|
+
* OUTSIDE these (src/, config, stray files) is out of scope for an automatic
|
|
159
|
+
* commit and is REFUSED rather than silently swept in. Explicit paths only —
|
|
160
|
+
* never `git add -A`/`git add .`.
|
|
161
|
+
*/
|
|
162
|
+
export const KNOWN_CONTENT_TREES = ["content/", "public/", ".tot/"];
|
|
163
|
+
export const KNOWN_CONTENT_FILES = ["theme.json"];
|
|
164
|
+
|
|
165
|
+
/** Is this repo-relative path inside a known content tree (or the one known
|
|
166
|
+
* top-level file)? Pure — unit-tested. */
|
|
167
|
+
export function isKnownContentPath(path) {
|
|
168
|
+
const p = String(path).replace(/^\.\//, "");
|
|
169
|
+
return KNOWN_CONTENT_FILES.includes(p) || KNOWN_CONTENT_TREES.some((t) => p.startsWith(t));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Flatten `git status --porcelain` (v1) output to the ordered, deduped set of
|
|
174
|
+
* dirty repo-relative paths. Each line is `XY <path>`; a rename/copy is
|
|
175
|
+
* `XY <old> -> <new>` and contributes BOTH paths (the old one is being removed
|
|
176
|
+
* from its tree too, so it's part of the scope decision). Run with
|
|
177
|
+
* `core.quotePath=false` upstream so unicode paths arrive literal. Pure —
|
|
178
|
+
* unit-tested.
|
|
179
|
+
* @param {string} text
|
|
180
|
+
* @returns {string[]}
|
|
181
|
+
*/
|
|
182
|
+
export function parsePorcelainPaths(text) {
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const line of String(text).split("\n")) {
|
|
186
|
+
if (line.length < 4) continue; // "XY p" is the shortest real entry
|
|
187
|
+
const rest = line.slice(3);
|
|
188
|
+
const parts = rest.includes(" -> ") ? rest.split(" -> ") : [rest];
|
|
189
|
+
for (const raw of parts) {
|
|
190
|
+
const p = raw.trim();
|
|
191
|
+
if (p && !seen.has(p)) {
|
|
192
|
+
seen.add(p);
|
|
193
|
+
out.push(p);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Partition dirty paths into the known content trees vs everything else. Pure —
|
|
202
|
+
* unit-tested.
|
|
203
|
+
* @param {string[]} paths
|
|
204
|
+
* @returns {{ known: string[], unknown: string[] }}
|
|
205
|
+
*/
|
|
206
|
+
export function classifyDirtyPaths(paths) {
|
|
207
|
+
const known = [];
|
|
208
|
+
const unknown = [];
|
|
209
|
+
for (const p of paths) (isKnownContentPath(p) ? known : unknown).push(p);
|
|
210
|
+
return { known, unknown };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A concise auto commit subject when the developer gave no -m — derived from the
|
|
214
|
+
* staged files so the commit is never a blank "(untitled change)". Pure. */
|
|
215
|
+
export function autoCommitSubject(files = []) {
|
|
216
|
+
if (files.length === 0) return "update store content";
|
|
217
|
+
if (files.length === 1) return `update ${files[0]}`;
|
|
218
|
+
return `update ${files.length} content files`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The message for `tot preview`'s pre-preview auto-commit. An explicit `-m` wins
|
|
223
|
+
* as the subject; otherwise autoCommitSubject derives one from the staged files.
|
|
224
|
+
* The body (shortstat + file list) is built by the SAME buildChangeSummary the
|
|
225
|
+
* approver summary uses, so a commit and the change record it later feeds read
|
|
226
|
+
* consistently. Returns a git commit message (subject, blank line, body). Pure —
|
|
227
|
+
* unit-tested.
|
|
228
|
+
* @param {{ message?: string|null, files?: string[], statLine?: string }} input
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
export function buildAutoCommitMessage({ message, files = [], statLine = "" } = {}) {
|
|
232
|
+
const subject = (message && message.trim()) || autoCommitSubject(files);
|
|
233
|
+
const { body } = buildChangeSummary({ message: subject, files, statLine });
|
|
234
|
+
return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Auto-commit the known content trees before previewing (unit u2). On a DIRTY
|
|
239
|
+
* tree `tot preview` commits your content edits for you, so a preview always
|
|
240
|
+
* reflects your working changes — but ONLY the known trees (content/, public/,
|
|
241
|
+
* theme.json, .tot/), staged by EXPLICIT path (never `git add -A`). If anything
|
|
242
|
+
* dirty falls OUTSIDE those, it REFUSES (out-of-scope src/config/stray edits are
|
|
243
|
+
* never silently swept into a store commit). --no-commit opts out entirely
|
|
244
|
+
* (preview whatever's already committed — u1's behavior).
|
|
245
|
+
*
|
|
246
|
+
* Returns exactly one of:
|
|
247
|
+
* { skipped: true } — --no-commit.
|
|
248
|
+
* { clean: true } — nothing dirty; preview HEAD as-is.
|
|
249
|
+
* { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
|
|
250
|
+
* { committed: true, sha, files } — staged the known dirty paths and committed.
|
|
251
|
+
*
|
|
252
|
+
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
253
|
+
* @param {{ message?: string|null, noCommit?: boolean }} [opts]
|
|
254
|
+
*/
|
|
255
|
+
export function autoCommitKnownTrees(git, { message = null, noCommit = false } = {}) {
|
|
256
|
+
if (noCommit) return { skipped: true };
|
|
257
|
+
const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
|
|
258
|
+
const dirty = parsePorcelainPaths(status);
|
|
259
|
+
if (dirty.length === 0) return { clean: true };
|
|
260
|
+
const { known, unknown } = classifyDirtyPaths(dirty);
|
|
261
|
+
if (unknown.length) return { refused: true, unknown, known };
|
|
262
|
+
// Stage ONLY the known dirty paths, each by explicit path — this records adds,
|
|
263
|
+
// modifications AND deletions within them, and can never reach outside the set.
|
|
264
|
+
git(["add", "--", ...known]);
|
|
265
|
+
const files = parseNameStatus(git(["diff", "--cached", "--name-status"])).map((e) => e.path);
|
|
266
|
+
const statLine = git(["diff", "--cached", "--shortstat"]).trim();
|
|
267
|
+
const msg = buildAutoCommitMessage({ message, files, statLine });
|
|
268
|
+
git(["commit", "--no-verify", "-m", msg]);
|
|
269
|
+
const sha = git(["rev-parse", "HEAD"]).trim();
|
|
270
|
+
return { committed: true, sha, files };
|
|
271
|
+
}
|
|
272
|
+
|
|
126
273
|
// ─── PR-backed candidate (g1b candidate_open, unit c1) ──────────────────────────
|
|
127
274
|
|
|
128
275
|
/**
|
|
@@ -277,19 +424,26 @@ function reportCandidate(result, changeId) {
|
|
|
277
424
|
console.log(` ~ PR-backed candidate not opened: ${msg}`);
|
|
278
425
|
}
|
|
279
426
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
427
|
+
/**
|
|
428
|
+
* The preview flow — validate, push the preview ref, open/update the PR-backed
|
|
429
|
+
* candidate, and stream back the reconcile/compliance/preview result. Reached by
|
|
430
|
+
* `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
|
|
431
|
+
* wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
|
|
432
|
+
* copy (usage + the not-in-checkout error) with whatever the developer typed.
|
|
433
|
+
* @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
|
|
434
|
+
*/
|
|
435
|
+
export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
282
436
|
const env = process.env;
|
|
283
437
|
const args = parseArgs(argv);
|
|
284
438
|
if (args.help) {
|
|
285
|
-
console.log(
|
|
439
|
+
console.log(renderUsage(verb));
|
|
286
440
|
return 0;
|
|
287
441
|
}
|
|
288
442
|
if (ctx.mode !== "checkout") {
|
|
289
443
|
console.error(
|
|
290
444
|
fail(
|
|
291
|
-
|
|
292
|
-
"tot
|
|
445
|
+
`\`tot ${verb}\` runs from inside a tenant checkout`,
|
|
446
|
+
"tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
|
|
293
447
|
),
|
|
294
448
|
);
|
|
295
449
|
return 2;
|
|
@@ -298,6 +452,41 @@ export async function run(argv, ctx) {
|
|
|
298
452
|
const tenant = ctx.tenant;
|
|
299
453
|
const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
300
454
|
|
|
455
|
+
// 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
|
|
456
|
+
// content edits BEFORE previewing so the preview reflects your working changes.
|
|
457
|
+
// Only content/, public/, theme.json, .tot/ (staged by explicit path, never
|
|
458
|
+
// `git add -A`); anything dirty outside those is refused, not silently swept in.
|
|
459
|
+
// --no-commit opts out (preview whatever's already committed).
|
|
460
|
+
let auto;
|
|
461
|
+
try {
|
|
462
|
+
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
|
|
463
|
+
} catch (e) {
|
|
464
|
+
console.error(
|
|
465
|
+
fail(
|
|
466
|
+
`auto-commit failed: ${String(e?.stderr || e?.message || e)}`,
|
|
467
|
+
"commit your content manually (git add / git commit), or re-run with --no-commit",
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
return 1;
|
|
471
|
+
}
|
|
472
|
+
if (auto.refused) {
|
|
473
|
+
console.error(
|
|
474
|
+
fail(
|
|
475
|
+
`${auto.unknown.length} change(s) are outside the store content trees — refusing to auto-commit`,
|
|
476
|
+
"commit (or stash/revert) those yourself, then re-run — or use --no-commit to preview only what's already committed",
|
|
477
|
+
) + "\n",
|
|
478
|
+
);
|
|
479
|
+
for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
|
|
480
|
+
console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
|
|
481
|
+
if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
|
|
482
|
+
return 1;
|
|
483
|
+
}
|
|
484
|
+
if (auto.committed) {
|
|
485
|
+
console.error(
|
|
486
|
+
`~ auto-committed ${auto.sha.slice(0, 9)} (${auto.files.length} content file(s)) — pass -m "…" to set the message, --no-commit to skip`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
301
490
|
// 1. validate locally — refuse on errors.
|
|
302
491
|
if (!args.skipValidate) {
|
|
303
492
|
const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
|
|
@@ -381,13 +570,40 @@ export async function run(argv, ctx) {
|
|
|
381
570
|
// 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
|
|
382
571
|
// failure here (older MCP, VC not configured, preview-access capability) is
|
|
383
572
|
// reported and swallowed, never blocking the preview push that already landed.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
573
|
+
//
|
|
574
|
+
// Which candidate this submit lands on (gh-pr-like):
|
|
575
|
+
// --new → fork a FRESH candidate and remember it as active;
|
|
576
|
+
// otherwise → the remembered active candidate (from a prior --new /
|
|
577
|
+
// roll), else the STABLE per-dev-per-tenant default.
|
|
578
|
+
// If the chosen candidate turns out to be merged/closed, roll to a fresh one
|
|
579
|
+
// so a re-submit is never wedged on a dead PR.
|
|
580
|
+
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
581
|
+
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
582
|
+
const statePath = defaultCandidateStatePath(env);
|
|
583
|
+
const stableId = deriveChangeId(tenant, actorKeyFor(session));
|
|
584
|
+
const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo }) : null;
|
|
585
|
+
let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
|
|
586
|
+
// Persist when we diverge from the stable default (a --new fork, or a
|
|
587
|
+
// previously-remembered active pointer) so the next plain submit follows it.
|
|
588
|
+
let persist = args.new || (!!active && active !== stableId);
|
|
589
|
+
|
|
590
|
+
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
591
|
+
|
|
592
|
+
if (candidate && isTerminalCandidateState(candidate.state)) {
|
|
593
|
+
const rolled = mintFreshChangeId(stableId);
|
|
594
|
+
console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
595
|
+
changeId = rolled;
|
|
596
|
+
persist = true;
|
|
597
|
+
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Remember the active candidate only on a real, non-terminal open (best-effort;
|
|
601
|
+
// never let a state-write failure break the submit).
|
|
602
|
+
if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
|
|
603
|
+
try {
|
|
604
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, changeId });
|
|
605
|
+
} catch { /* best-effort local hint — a miss just re-derives the stable id */ }
|
|
606
|
+
}
|
|
391
607
|
|
|
392
608
|
let status;
|
|
393
609
|
if (args.noWait) {
|
|
@@ -68,11 +68,11 @@ export function run(argv, ctx) {
|
|
|
68
68
|
|
|
69
69
|
const target = resolveTarget(args, ctx);
|
|
70
70
|
if (target.error) {
|
|
71
|
-
console.error(fail(target.error, "tot
|
|
71
|
+
console.error(fail(target.error, "tot clone <tenant>, or pass --workspace <dir>"));
|
|
72
72
|
return 2;
|
|
73
73
|
}
|
|
74
74
|
if (!existsSync(target.dir)) {
|
|
75
|
-
console.error(fail(`no tenant directory at ${target.dir}`, "confirm the path, or `tot
|
|
75
|
+
console.error(fail(`no tenant directory at ${target.dir}`, "confirm the path, or `tot clone <tenant>`"));
|
|
76
76
|
return 2;
|
|
77
77
|
}
|
|
78
78
|
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { defaultCredentialsPath, readCredentials, isExpired, activeProfile } from "../token-store.mjs";
|
|
12
12
|
import { establishSession } from "../auth.mjs";
|
|
13
13
|
import { createMcpClient } from "../mcp.mjs";
|
|
14
|
-
import { normalizeStores, storeListError } from "./
|
|
14
|
+
import { normalizeStores, storeListError, noStoresGuidance } from "./clone.mjs";
|
|
15
15
|
import { recordServerPolicy } from "../update-check.mjs";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -67,7 +67,11 @@ export async function run(argv, _ctx) {
|
|
|
67
67
|
} else if (listErr) {
|
|
68
68
|
console.log(` (couldn't list your stores: ${listErr} — try \`tot login\` again)`);
|
|
69
69
|
} else {
|
|
70
|
-
|
|
70
|
+
// Status-aware (card c2): an UNLINKED identity is told to link (not "may
|
|
71
|
+
// still be propagating" — that's only the genuine zero-grants case).
|
|
72
|
+
const g = noStoresGuidance(listResp);
|
|
73
|
+
console.log(` (${g.headline})`);
|
|
74
|
+
console.log(` → next: ${g.next}`);
|
|
71
75
|
}
|
|
72
76
|
} catch {
|
|
73
77
|
console.log(" (couldn't reach the MCP to list your stores right now — your cached session is above)");
|
package/src/context.mjs
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* monorepo — inside a full storefront checkout (pnpm-workspace.yaml +
|
|
8
8
|
* apps/storefront + tenants/). This is us / a platform dev.
|
|
9
|
-
* `tot dev` here runs the in-tree astro dev; `tot
|
|
9
|
+
* `tot dev` here runs the in-tree astro dev; `tot clone`
|
|
10
10
|
* can suggest cloning a sibling dir.
|
|
11
11
|
* checkout — inside a STANDALONE tenant checkout: the flat, content-only
|
|
12
12
|
* shape `content/ public/ theme.json .tot/config.json` a
|
|
13
13
|
* developer clones. The tenant is read from .tot/config.json.
|
|
14
14
|
* `tot dev` here boots the bundled runner against this dir.
|
|
15
|
-
* loose — anywhere else. `tot
|
|
15
|
+
* loose — anywhere else. `tot clone <tenant>` still works (it's how
|
|
16
16
|
* you GET a checkout); commands that need a workspace say so.
|
|
17
17
|
*
|
|
18
18
|
* Detection walks UP from the cwd so `tot` works from any subdirectory of a
|
package/src/oauth.mjs
CHANGED
|
@@ -84,8 +84,15 @@ export async function registerClient(
|
|
|
84
84
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
85
85
|
body: JSON.stringify({
|
|
86
86
|
client_name: CLIENT_NAME,
|
|
87
|
+
// Include the device-code grant: the browserless rendezvous + B3 device flows
|
|
88
|
+
// redeem their device_code at /oauth/token with this grant, so a client
|
|
89
|
+
// registered WITHOUT it gets "unauthorized_client: grant_type is invalid".
|
|
90
|
+
grant_types: [
|
|
91
|
+
"authorization_code",
|
|
92
|
+
"refresh_token",
|
|
93
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
94
|
+
],
|
|
87
95
|
redirect_uris: [redirectUri],
|
|
88
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
89
96
|
response_types: ["code"],
|
|
90
97
|
token_endpoint_auth_method: "none",
|
|
91
98
|
scope: SCOPE,
|
|
@@ -285,41 +292,50 @@ export async function loginFlow({
|
|
|
285
292
|
}
|
|
286
293
|
}
|
|
287
294
|
|
|
288
|
-
// ──
|
|
295
|
+
// ── Browserless CLI sign-in — RFC 8628 RENDEZVOUS (`tot login --code <handle>`) ──
|
|
289
296
|
//
|
|
290
|
-
// The invited developer pastes the
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
// the
|
|
294
|
-
//
|
|
295
|
-
//
|
|
297
|
+
// The invited developer pastes the NON-SECRET rendezvous handle their cockpit
|
|
298
|
+
// rendered. Unlike the retired bearer-redeem (a secret received elsewhere, replayed
|
|
299
|
+
// once), the terminal generates its OWN PKCE key here, ATTACHES only the public
|
|
300
|
+
// challenge to the pending rendezvous, prints a human-verifiable fingerprint, and
|
|
301
|
+
// polls the standard token endpoint (device_code grant + PKCE) until the developer
|
|
302
|
+
// approves that fingerprint in the cockpit. Same credentials shape as
|
|
303
|
+
// loginFlow/deviceLoginFlow, same dynamically-registered client_id, so later silent
|
|
304
|
+
// refreshes go through the standard token endpoint like any other session.
|
|
296
305
|
//
|
|
297
|
-
// Wire contract (CLI → MCP):
|
|
298
|
-
// POST
|
|
299
|
-
// {
|
|
300
|
-
//
|
|
301
|
-
//
|
|
306
|
+
// Wire contract (CLI → MCP), all off the MCP origin (from the AS token endpoint):
|
|
307
|
+
// POST /oauth/device/attach (application/json)
|
|
308
|
+
// { rendezvous_code, client_id, code_challenge, code_challenge_method: "S256" }
|
|
309
|
+
// → 200 { device_code, user_fingerprint, interval, expires_in }
|
|
310
|
+
// → 400 { error, error_description } (invalid_grant | invalid_client)
|
|
311
|
+
// POST /oauth/token (form) grant_type=…:device_code, device_code, code_verifier,
|
|
312
|
+
// client_id → RFC 8628 polling until approved/denied/expired.
|
|
302
313
|
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
export function redeemCodeEndpoint(mcpUrl, meta) {
|
|
314
|
+
// Device-bound by construction: the pasted handle attaches only a PUBLIC PKCE
|
|
315
|
+
// challenge (intercepting it grants nothing — an interceptor's terminal shows a
|
|
316
|
+
// DIFFERENT fingerprint the developer won't approve), and only the terminal holding
|
|
317
|
+
// the matching verifier can redeem the device_code at /oauth/token.
|
|
318
|
+
|
|
319
|
+
/** An MCP device/rendezvous endpoint — the MCP origin (from the AS token endpoint)
|
|
320
|
+
* + a fixed path. Kept beside the flow so the paths live in exactly one place. */
|
|
321
|
+
export function deviceEndpoint(mcpUrl, meta, path) {
|
|
312
322
|
const origin = meta?.token_endpoint ? new URL(meta.token_endpoint) : new URL(mcpUrl);
|
|
313
|
-
return new URL(
|
|
323
|
+
return new URL(path, origin).toString();
|
|
314
324
|
}
|
|
315
325
|
|
|
316
|
-
/**
|
|
317
|
-
* response
|
|
318
|
-
|
|
319
|
-
|
|
326
|
+
/** Attach the terminal's PKCE challenge to a pending rendezvous. Returns the raw
|
|
327
|
+
* response { device_code, user_fingerprint, interval, expires_in }. Throws a clear,
|
|
328
|
+
* non-stack error on rejection (an expired / already-used handle). */
|
|
329
|
+
export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientId, challenge }, fetchImpl = fetch) {
|
|
330
|
+
const res = await fetchImpl(attachEndpoint, {
|
|
320
331
|
method: "POST",
|
|
321
332
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
322
|
-
body: JSON.stringify({
|
|
333
|
+
body: JSON.stringify({
|
|
334
|
+
rendezvous_code: rendezvousCode,
|
|
335
|
+
client_id: clientId,
|
|
336
|
+
code_challenge: challenge,
|
|
337
|
+
code_challenge_method: "S256",
|
|
338
|
+
}),
|
|
323
339
|
});
|
|
324
340
|
const text = await res.text();
|
|
325
341
|
let body;
|
|
@@ -328,31 +344,61 @@ export async function redeemInviteCode(redeemEndpoint, { code, clientId, scope =
|
|
|
328
344
|
const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
|
|
329
345
|
throw new Error(`your sign-in code was not accepted (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
|
|
330
346
|
}
|
|
331
|
-
if (!body.
|
|
347
|
+
if (!body.device_code || !body.user_fingerprint) {
|
|
348
|
+
throw new Error("the attach endpoint returned no device_code/fingerprint");
|
|
349
|
+
}
|
|
332
350
|
return body;
|
|
333
351
|
}
|
|
334
352
|
|
|
335
353
|
/**
|
|
336
|
-
* Run the full browserless
|
|
337
|
-
* — the invite
|
|
338
|
-
* shape
|
|
339
|
-
*
|
|
354
|
+
* Run the full browserless rendezvous sign-in and return a persistable credentials
|
|
355
|
+
* record — the invite sibling of loginFlow()/deviceLoginFlow(), same credentials
|
|
356
|
+
* shape + dynamically-registered client_id (reused when the caller cached one for
|
|
357
|
+
* this MCP). The terminal generates its own PKCE key, attaches, surfaces the
|
|
358
|
+
* fingerprint via `log` for the developer to confirm in the cockpit, then polls the
|
|
359
|
+
* token endpoint until approved. Injectable (`fetchImpl`, `log`, `sleep`, `now`) so
|
|
360
|
+
* it's testable with no network and no real waiting.
|
|
340
361
|
* @returns {Promise<object>} credentials to hand to writeCredentials()
|
|
341
362
|
*/
|
|
342
|
-
export async function
|
|
363
|
+
export async function rendezvousLoginFlow({
|
|
343
364
|
mcpUrl,
|
|
344
|
-
clientId,
|
|
345
365
|
code,
|
|
346
366
|
fetchImpl = fetch,
|
|
367
|
+
log = () => {},
|
|
368
|
+
sleep = delay,
|
|
347
369
|
now = () => Date.now(),
|
|
348
370
|
}) {
|
|
349
371
|
const meta = await discoverMetadata(mcpUrl, fetchImpl);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
372
|
+
// Always register a FRESH client for a rendezvous sign-in — do NOT reuse a cached
|
|
373
|
+
// clientId. A client cached by an older CLI build was registered without the
|
|
374
|
+
// device-code grant, so /oauth/token would reject the device grant with
|
|
375
|
+
// "unauthorized_client". Registration is cheap and a rendezvous has no consent
|
|
376
|
+
// screen to re-trigger (unlike the loopback/device flows, which reuse the cache).
|
|
377
|
+
const resolvedClientId = await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl);
|
|
378
|
+
const { verifier, challenge } = generatePkce();
|
|
379
|
+
|
|
380
|
+
const attach = await attachRendezvous(
|
|
381
|
+
deviceEndpoint(mcpUrl, meta, "/oauth/device/attach"),
|
|
382
|
+
{ rendezvousCode: code, clientId: resolvedClientId, challenge },
|
|
354
383
|
fetchImpl,
|
|
355
384
|
);
|
|
385
|
+
|
|
386
|
+
log("");
|
|
387
|
+
log(` Confirm this code in your browser to finish signing in: ${attach.user_fingerprint}`);
|
|
388
|
+
log(" Waiting for you to approve it in Token of Trust …");
|
|
389
|
+
|
|
390
|
+
const token = await pollDeviceToken(
|
|
391
|
+
meta.token_endpoint,
|
|
392
|
+
{
|
|
393
|
+
deviceCode: attach.device_code,
|
|
394
|
+
clientId: resolvedClientId,
|
|
395
|
+
codeVerifier: verifier,
|
|
396
|
+
intervalSec: attach.interval,
|
|
397
|
+
expiresInSec: attach.expires_in,
|
|
398
|
+
},
|
|
399
|
+
fetchImpl,
|
|
400
|
+
{ sleep, now },
|
|
401
|
+
);
|
|
356
402
|
return credentialsFromToken({
|
|
357
403
|
mcpUrl,
|
|
358
404
|
clientId: resolvedClientId,
|
|
@@ -395,7 +441,7 @@ export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, s
|
|
|
395
441
|
* normal "keep waiting" responses — so this resolves `{ pending: true }`
|
|
396
442
|
* (with `slowDown` set) for those instead of throwing.
|
|
397
443
|
*/
|
|
398
|
-
async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl) {
|
|
444
|
+
async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl) {
|
|
399
445
|
const res = await fetchImpl(tokenEndpoint, {
|
|
400
446
|
method: "POST",
|
|
401
447
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
@@ -403,6 +449,10 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
|
|
|
403
449
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
404
450
|
device_code: deviceCode,
|
|
405
451
|
client_id: clientId,
|
|
452
|
+
// The rendezvous flow binds the grant to the terminal's PKCE key: the
|
|
453
|
+
// verifier proves this is the same terminal that attached the challenge.
|
|
454
|
+
// Absent for the plain RFC 8628 device flow (no PKCE) — omitted then.
|
|
455
|
+
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
|
|
406
456
|
}).toString(),
|
|
407
457
|
});
|
|
408
458
|
const text = await res.text();
|
|
@@ -428,7 +478,7 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
|
|
|
428
478
|
*/
|
|
429
479
|
export async function pollDeviceToken(
|
|
430
480
|
tokenEndpoint,
|
|
431
|
-
{ deviceCode, clientId, intervalSec, expiresInSec },
|
|
481
|
+
{ deviceCode, clientId, codeVerifier, intervalSec, expiresInSec },
|
|
432
482
|
fetchImpl = fetch,
|
|
433
483
|
{ sleep = delay, now = () => Date.now() } = {},
|
|
434
484
|
) {
|
|
@@ -437,7 +487,7 @@ export async function pollDeviceToken(
|
|
|
437
487
|
for (;;) {
|
|
438
488
|
await sleep(intervalMs);
|
|
439
489
|
if (now() >= deadline) throw new Error("the device code expired before it was approved");
|
|
440
|
-
const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl);
|
|
490
|
+
const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl);
|
|
441
491
|
if (!r.pending) return r.token;
|
|
442
492
|
if (r.slowDown) intervalMs += 5000;
|
|
443
493
|
}
|
package/src/obstacle-beacon.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The obstacle beacon — a fire-and-forget POST that tells the hosted cockpit a
|
|
3
|
-
* `tot start` / `tot
|
|
3
|
+
* `tot start` / `tot clone` failed, so it can show the exact fix in the bridge
|
|
4
4
|
* strip (obstacle lane, server side already shipped). Best-effort telemetry that
|
|
5
5
|
* rides ALONGSIDE the house-style `✗ … → next:` error; it must NEVER change,
|
|
6
6
|
* delay past its timeout, or fail that error path.
|