@tokenoftrust/cli 1.0.0
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/LICENSE +202 -0
- package/README.md +50 -0
- package/bin/tot.mjs +124 -0
- package/package.json +41 -0
- package/src/auth.mjs +132 -0
- package/src/commands/checkout.mjs +233 -0
- package/src/commands/dev.mjs +564 -0
- package/src/commands/doctor.mjs +172 -0
- package/src/commands/ideas.mjs +45 -0
- package/src/commands/login.mjs +107 -0
- package/src/commands/start.mjs +450 -0
- package/src/commands/submit.mjs +284 -0
- package/src/commands/validate.mjs +99 -0
- package/src/commands/whoami.mjs +69 -0
- package/src/context.mjs +97 -0
- package/src/errors.mjs +64 -0
- package/src/last-tenant.mjs +49 -0
- package/src/mcp.mjs +100 -0
- package/src/oauth.mjs +409 -0
- package/src/open.mjs +63 -0
- package/src/token-store.mjs +65 -0
- package/src/validate.mjs +291 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot submit` — submit your store for PREVIEW (the deliberate, gated step).
|
|
3
|
+
*
|
|
4
|
+
* From inside a tenant checkout:
|
|
5
|
+
* 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
|
|
6
|
+
* 2. push your committed work to the tenant repo's `preview` ref (triggers reconcile),
|
|
7
|
+
* 3. report back — reconcile result + compliance verdict + the preview URL — from the MCP.
|
|
8
|
+
*
|
|
9
|
+
* This is submit-for-PREVIEW, not ship-to-live (`change_accept` / a future `tot ship`
|
|
10
|
+
* is the separate ship gate). Step 3 calls the MCP `preview_status` read-back
|
|
11
|
+
* (~/.tot-mcp/handoffs/2026-07-09-tot-mcp-tot-submit-contract.md): given the commit
|
|
12
|
+
* just pushed it returns { status, reconcile, compliance, previewUrl } and we poll it
|
|
13
|
+
* while reconcile is pending. If that tool isn't present (older MCP) the command still
|
|
14
|
+
* validates + pushes and reports "reconcile pending" — degrading visibly, never a crash.
|
|
15
|
+
*
|
|
16
|
+
* Polling (E2): every call carries `waitMs` so a preview_status-aware MCP long-polls
|
|
17
|
+
* (blocks up to waitMs, waking immediately on arrival) instead of us sleeping blind
|
|
18
|
+
* between calls. An MCP that doesn't honor it still returns fast while pending, so
|
|
19
|
+
* pollPreviewStatus measures elapsed time and sleeps the rest of delayMs itself —
|
|
20
|
+
* the original fixed-interval poll, automatically as a fallback, no version check
|
|
21
|
+
* needed. `--watch` just raises the budget (longer waitMs, more attempts) so an
|
|
22
|
+
* attached dev can stay and watch reconcile+compliance resolve live.
|
|
23
|
+
*
|
|
24
|
+
* Watching through to accept (E1b): `--watch` also sets untilShipped, so once
|
|
25
|
+
* reconcile resolves cleanly the loop keeps going (preview_status reuses the
|
|
26
|
+
* SAME waitMs budget server-side to long-poll for a ship decision too — one
|
|
27
|
+
* poll axis, not two) until change_accept ships it or the attempts budget
|
|
28
|
+
* runs out. A failed reconcile never waits for ship — it can't have shipped.
|
|
29
|
+
* Detached devs get the E1 email instead; this is the attached half.
|
|
30
|
+
*
|
|
31
|
+
* Dependency-free (global fetch + `git`).
|
|
32
|
+
*/
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
35
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
36
|
+
import { resolveSession, AuthUnavailableError } from "../auth.mjs";
|
|
37
|
+
import { validateTenant, ERROR } from "../validate.mjs";
|
|
38
|
+
import { openBrowser } from "../open.mjs";
|
|
39
|
+
import { fail } from "../errors.mjs";
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
42
|
+
const DEFAULT_REF = "preview";
|
|
43
|
+
|
|
44
|
+
function parseArgs(argv) {
|
|
45
|
+
const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, help: false };
|
|
46
|
+
for (let i = 0; i < argv.length; i++) {
|
|
47
|
+
const t = argv[i];
|
|
48
|
+
if (t === "--mcp") a.mcp = argv[++i];
|
|
49
|
+
else if (t === "--identity") a.identity = argv[++i];
|
|
50
|
+
else if (t === "--ref") a.ref = argv[++i];
|
|
51
|
+
else if (t === "--skip-validate") a.skipValidate = true;
|
|
52
|
+
else if (t === "--no-wait") a.noWait = true;
|
|
53
|
+
else if (t === "--watch") a.watch = true;
|
|
54
|
+
else if (t === "--no-open") a.noOpen = true;
|
|
55
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
56
|
+
}
|
|
57
|
+
return a;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const USAGE = `tot submit — submit your store for preview
|
|
61
|
+
|
|
62
|
+
tot submit validate → push the preview ref → stream the result
|
|
63
|
+
tot submit --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
64
|
+
tot submit --skip-validate push without the local lint (not recommended)
|
|
65
|
+
tot submit --ref <name> push ref (default: ${DEFAULT_REF})
|
|
66
|
+
tot submit --no-wait push and exit without polling for the reconcile result
|
|
67
|
+
tot submit --no-open don't open the preview URL in the browser on success
|
|
68
|
+
tot submit --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)`;
|
|
69
|
+
|
|
70
|
+
// Default bounded wait (~20s, matching the pre-E2 fixed poll's total budget) vs.
|
|
71
|
+
// --watch's longer per-call long-poll + more attempts (~8 min ceiling) for a dev
|
|
72
|
+
// who's deliberately staying attached to watch reconcile+compliance resolve live.
|
|
73
|
+
const DEFAULT_POLL = { attempts: 8, delayMs: 2500 };
|
|
74
|
+
const WATCH_POLL = { attempts: 24, delayMs: 20_000, waitMs: 20_000, untilShipped: true };
|
|
75
|
+
|
|
76
|
+
const redactUrl = (s) => String(s).replace(/\/\/[^/@\s]*@/g, "//***@");
|
|
77
|
+
|
|
78
|
+
/** @param {string[]} argv @param {any} ctx */
|
|
79
|
+
export async function run(argv, ctx) {
|
|
80
|
+
const env = process.env;
|
|
81
|
+
const args = parseArgs(argv);
|
|
82
|
+
if (args.help) {
|
|
83
|
+
console.log(USAGE);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (ctx.mode !== "checkout") {
|
|
87
|
+
console.error(
|
|
88
|
+
fail(
|
|
89
|
+
"`tot submit` runs from inside a tenant checkout",
|
|
90
|
+
"tot checkout <tenant> --clone <dir> (then `cd` in, commit your work, and re-run)",
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
return 2;
|
|
94
|
+
}
|
|
95
|
+
const workspace = ctx.workspacePath;
|
|
96
|
+
const tenant = ctx.tenant;
|
|
97
|
+
const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
98
|
+
|
|
99
|
+
// 1. validate locally — refuse on errors.
|
|
100
|
+
if (!args.skipValidate) {
|
|
101
|
+
const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
|
|
102
|
+
if (!ok) {
|
|
103
|
+
const errs = findings.filter((f) => f.level === ERROR);
|
|
104
|
+
console.error(
|
|
105
|
+
fail(`${errs.length} validation error(s)`, "fix these (below), or re-run with --skip-validate") + "\n",
|
|
106
|
+
);
|
|
107
|
+
for (const f of errs) console.error(` ✗ [${f.rule}] ${f.file} — ${f.message}`);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
console.error("~ validated (no errors)");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. push the preview ref over the checkout's authenticated remote.
|
|
114
|
+
let commit;
|
|
115
|
+
try {
|
|
116
|
+
commit = git(["rev-parse", "HEAD"]).trim();
|
|
117
|
+
} catch {
|
|
118
|
+
console.error(fail("no commits here yet", "git add <files> && git commit -m '…', then re-run"));
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
const short = commit.slice(0, 9);
|
|
122
|
+
console.error(`~ pushing ${short} → ${args.ref} (origin)`);
|
|
123
|
+
try {
|
|
124
|
+
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
|
|
125
|
+
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error(
|
|
128
|
+
fail(
|
|
129
|
+
`push failed: ${redactUrl(String(e.stderr || e.message || e))}`,
|
|
130
|
+
"check your commit and that the checkout's remote is reachable, then re-run",
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
console.log(`\n+ submitted ${short} to ${args.ref}.`);
|
|
136
|
+
|
|
137
|
+
// 3. report reconcile + compliance + preview URL from the MCP (graceful seam).
|
|
138
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
139
|
+
const client = createMcpClient(baseUrl);
|
|
140
|
+
try {
|
|
141
|
+
await client.initialize();
|
|
142
|
+
await resolveSession(client, { env, prefer: args.identity || undefined });
|
|
143
|
+
// Set the active tenant so preview_status reads the right scope (it keys on
|
|
144
|
+
// the session's tenant + the commit — no tenant arg of its own).
|
|
145
|
+
await client.callTool("client_switch", { tenant });
|
|
146
|
+
const status = args.noWait
|
|
147
|
+
? normalizePreviewStatus(await client.callTool("preview_status", { commit }))
|
|
148
|
+
: await pollPreviewStatus(client, commit, {
|
|
149
|
+
...(args.watch ? WATCH_POLL : DEFAULT_POLL),
|
|
150
|
+
onTick: (s, i) => {
|
|
151
|
+
if (s.status === "pending") console.error(`~ reconcile running for ${short} … (${i + 1})`);
|
|
152
|
+
else if (s.status === "reconciled" && !s.shipped) {
|
|
153
|
+
console.error(`~ reconciled — waiting for a ship decision … (${i + 1})`);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
reportStatus(status, tenant, { open: !args.noOpen });
|
|
158
|
+
return status?.status === "failed" ? 1 : 0;
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (e instanceof AuthUnavailableError) {
|
|
161
|
+
console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
|
|
162
|
+
} else {
|
|
163
|
+
console.log(` (reconcile is running — the result read-back isn't available yet: ${String(e?.message || e)})`);
|
|
164
|
+
}
|
|
165
|
+
console.log(` Your push is in; the preview updates once reconcile completes.`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Normalize a `preview_status` tool response to the contract shape the CLI reports
|
|
172
|
+
* on: { status, reconcile:{ok,errors}, compliance:{verdict,detail}, previewUrl,
|
|
173
|
+
* shipped }. A live tool always returns status pending|reconciled|failed; anything
|
|
174
|
+
* else (an older MCP without the tool's flat fields) normalizes to "unknown" so the
|
|
175
|
+
* CLI degrades visibly instead of pretending it reconciled. `shipped` (E1b) is
|
|
176
|
+
* null until change_accept ships this exact commit. Pure — unit-tested.
|
|
177
|
+
*/
|
|
178
|
+
export function normalizePreviewStatus(r) {
|
|
179
|
+
const status = r?.status;
|
|
180
|
+
const known = status === "pending" || status === "reconciled" || status === "failed";
|
|
181
|
+
return {
|
|
182
|
+
status: known ? status : "unknown",
|
|
183
|
+
reconcile: r?.reconcile ?? null,
|
|
184
|
+
compliance: r?.compliance ?? null,
|
|
185
|
+
previewUrl: r?.previewUrl ?? null,
|
|
186
|
+
shipped: r?.shipped ?? null,
|
|
187
|
+
raw: r,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Poll `preview_status` while reconcile is pending (and, with untilShipped, while
|
|
193
|
+
* reconciled-but-not-yet-shipped). E2: every call carries `waitMs` so a
|
|
194
|
+
* preview_status that supports it long-polls (blocks up to waitMs, waking
|
|
195
|
+
* immediately on the report's — or, once reconciled, the ship decision's —
|
|
196
|
+
* arrival) instead of us guessing an interval — most calls resolve on attempt 1 or
|
|
197
|
+
* 2 instead of walking the full attempts budget. An MCP that ignores waitMs (older
|
|
198
|
+
* deploy, or genuinely still pending) just returns fast; we measure elapsed time
|
|
199
|
+
* and sleep the REST of delayMs ourselves, so the net cadence degrades to exactly
|
|
200
|
+
* the pre-E2 fixed-interval poll — no version check needed, the fallback is
|
|
201
|
+
* automatic. Stops as soon as status resolves to "failed"/"unknown" (a failed
|
|
202
|
+
* reconcile can't ship), or resolves to "reconciled" AND (not untilShipped, or
|
|
203
|
+
* already shipped). Injectable delay/attempts/waitMs for tests.
|
|
204
|
+
* @param {{callTool:Function}} client
|
|
205
|
+
* @param {string} commit
|
|
206
|
+
* @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean, onTick?: (s:object,i:number)=>void }} [opts]
|
|
207
|
+
*/
|
|
208
|
+
export async function pollPreviewStatus(
|
|
209
|
+
client,
|
|
210
|
+
commit,
|
|
211
|
+
{ attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, onTick } = {},
|
|
212
|
+
) {
|
|
213
|
+
let last = null;
|
|
214
|
+
for (let i = 0; i < attempts; i++) {
|
|
215
|
+
const startedAt = Date.now();
|
|
216
|
+
const args = waitMs ? { commit, waitMs } : { commit };
|
|
217
|
+
last = normalizePreviewStatus(await client.callTool("preview_status", args));
|
|
218
|
+
if (onTick) onTick(last, i);
|
|
219
|
+
const stillWatchingForShip = untilShipped && last.status === "reconciled" && !last.shipped;
|
|
220
|
+
if (last.status !== "pending" && !stillWatchingForShip) return last;
|
|
221
|
+
if (i < attempts - 1) {
|
|
222
|
+
const remaining = delayMs - (Date.now() - startedAt);
|
|
223
|
+
if (remaining > 0) await delay(remaining);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return last;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const CHECK_GLYPH = { pass: "✓", fail: "✗", warn: "⚠", skip: "~" };
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Print one per-rule compliance check: id/label, required-ness, why it didn't
|
|
233
|
+
* pass (detail), and a concrete fix suggestion (hint) when there is one. This is
|
|
234
|
+
* the moat over a bare pass/fail — an agent (or a human) reading `tot submit`
|
|
235
|
+
* output should know exactly what to change and why, with no dashboard round-trip.
|
|
236
|
+
*/
|
|
237
|
+
function reportComplianceCheck(c) {
|
|
238
|
+
const glyph = CHECK_GLYPH[c.status] ?? "?";
|
|
239
|
+
const tag = c.required ? " (required)" : "";
|
|
240
|
+
const detail = c.detail ? ` — ${c.detail}` : "";
|
|
241
|
+
console.log(` ${glyph} [${c.id}] ${c.label}${tag}${detail}`);
|
|
242
|
+
if (c.hint) console.log(` → fix: ${c.hint}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Print the reconcile/compliance/preview result and, on a clean reconcile with a
|
|
247
|
+
* preview URL, open it in the browser (unless opts.open === false).
|
|
248
|
+
*/
|
|
249
|
+
function reportStatus(s, tenant, { open = true } = {}) {
|
|
250
|
+
if (!s || s.status === "unknown") {
|
|
251
|
+
console.log(
|
|
252
|
+
` (this MCP doesn't return the per-commit reconcile result yet — your push is in;\n` +
|
|
253
|
+
` the preview updates once reconcile runs. Check the preview dashboard.)`,
|
|
254
|
+
);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (s.status === "pending") {
|
|
258
|
+
console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const rc = s.reconcile;
|
|
262
|
+
if (rc) {
|
|
263
|
+
if (rc.ok) console.log(` ✓ reconcile ok`);
|
|
264
|
+
else {
|
|
265
|
+
console.log(` ✗ reconcile failed:`);
|
|
266
|
+
for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (s.compliance?.verdict) {
|
|
270
|
+
console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
|
|
271
|
+
for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
|
|
272
|
+
}
|
|
273
|
+
if (s.shipped) {
|
|
274
|
+
console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
|
|
275
|
+
} else if (s.status === "reconciled") {
|
|
276
|
+
console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
|
|
277
|
+
}
|
|
278
|
+
if (s.previewUrl) {
|
|
279
|
+
console.log(`\n Preview: ${s.previewUrl}`);
|
|
280
|
+
if (open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
|
|
281
|
+
console.log(" (opened in your browser)");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot validate` — lint your store before you commit/submit.
|
|
3
|
+
*
|
|
4
|
+
* Context-aware target resolution (see src/context.mjs):
|
|
5
|
+
* checkout — validate the checkout root itself (the flat tenant layout).
|
|
6
|
+
* monorepo — `tot validate <tenant>` → tenants/<tenant>/; or --workspace <dir>.
|
|
7
|
+
* loose — needs an explicit --workspace <dir>.
|
|
8
|
+
*
|
|
9
|
+
* tot validate (inside a checkout) lint this store
|
|
10
|
+
* tot validate <tenant> (inside the monorepo) lint tenants/<tenant>/
|
|
11
|
+
* tot validate --workspace DIR lint a specific checkout directory
|
|
12
|
+
* tot validate --json machine-readable findings
|
|
13
|
+
*
|
|
14
|
+
* Exit 0 = no errors (warnings allowed); 1 = one or more errors; 2 = usage / no target.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { validateTenant, ERROR, WARN } from "../validate.mjs";
|
|
19
|
+
import { fail } from "../errors.mjs";
|
|
20
|
+
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const a = { tenant: null, workspace: null, json: false, help: false };
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const t = argv[i];
|
|
25
|
+
if (t === "--workspace") a.workspace = argv[++i];
|
|
26
|
+
else if (t === "--json") a.json = true;
|
|
27
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
28
|
+
else if (!t.startsWith("--") && !a.tenant) a.tenant = t;
|
|
29
|
+
}
|
|
30
|
+
return a;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const USAGE = `tot validate — lint your store before you submit
|
|
34
|
+
|
|
35
|
+
tot validate (inside a checkout) lint this store
|
|
36
|
+
tot validate <tenant> (inside the monorepo) lint tenants/<tenant>/
|
|
37
|
+
tot validate --workspace <dir> lint a specific checkout directory
|
|
38
|
+
tot validate --json machine-readable findings`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the tenant directory + id + scope to validate, from args + context.
|
|
42
|
+
* @returns {{ dir?: string, tenantId?: string|null, scope?: string|null, error?: string }}
|
|
43
|
+
*/
|
|
44
|
+
function resolveTarget(args, ctx) {
|
|
45
|
+
if (args.workspace) {
|
|
46
|
+
return { dir: resolve(args.workspace), tenantId: ctx.config?.tenant ?? null, scope: ctx.config?.scope ?? null };
|
|
47
|
+
}
|
|
48
|
+
if (args.tenant) {
|
|
49
|
+
if (ctx.mode === "monorepo") {
|
|
50
|
+
return { dir: join(ctx.repoRoot, "tenants", args.tenant), tenantId: args.tenant };
|
|
51
|
+
}
|
|
52
|
+
// A bare tenant name outside the monorepo has no directory to resolve.
|
|
53
|
+
return { error: `"${args.tenant}" is a tenant name, but you're not in a storefront monorepo. Run from a checkout, or use --workspace <dir>.` };
|
|
54
|
+
}
|
|
55
|
+
if (ctx.mode === "checkout") {
|
|
56
|
+
return { dir: ctx.workspacePath, tenantId: ctx.tenant, scope: ctx.config?.scope ?? null };
|
|
57
|
+
}
|
|
58
|
+
return { error: "nothing to validate — run inside a tenant checkout, pass a <tenant> (in the monorepo), or --workspace <dir>." };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string[]} argv @param {any} ctx */
|
|
62
|
+
export function run(argv, ctx) {
|
|
63
|
+
const args = parseArgs(argv);
|
|
64
|
+
if (args.help) {
|
|
65
|
+
console.log(USAGE);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const target = resolveTarget(args, ctx);
|
|
70
|
+
if (target.error) {
|
|
71
|
+
console.error(fail(target.error, "tot checkout <tenant> --clone <dir>, or pass --workspace <dir>"));
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
if (!existsSync(target.dir)) {
|
|
75
|
+
console.error(fail(`no tenant directory at ${target.dir}`, "confirm the path, or `tot checkout <tenant> --clone <dir>`"));
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { ok, findings } = validateTenant(target.dir, {
|
|
80
|
+
tenantId: target.tenantId ?? undefined,
|
|
81
|
+
scope: target.scope ?? undefined,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (args.json) {
|
|
85
|
+
console.log(JSON.stringify({ ok, findings }, null, 2));
|
|
86
|
+
return ok ? 0 : 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const errors = findings.filter((f) => f.level === ERROR);
|
|
90
|
+
const warns = findings.filter((f) => f.level === WARN);
|
|
91
|
+
console.log(`\ntot validate — ${target.tenantId ?? target.dir}\n`);
|
|
92
|
+
for (const f of findings) {
|
|
93
|
+
const tag = f.level === ERROR ? "✗" : "⚠";
|
|
94
|
+
console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
|
|
95
|
+
}
|
|
96
|
+
if (findings.length === 0) console.log(" (no findings)");
|
|
97
|
+
console.log(`\n${errors.length === 0 ? "✔" : "✖"} ${errors.length} error(s), ${warns.length} warning(s).\n`);
|
|
98
|
+
return errors.length === 0 ? 0 : 1;
|
|
99
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot whoami` — who is `tot` signed in as, and what can they touch.
|
|
3
|
+
*
|
|
4
|
+
* Reads the cached session (~/.tot/credentials.json). If a live token is present
|
|
5
|
+
* it best-effort asks the MCP for the stores this identity may act on (proving the
|
|
6
|
+
* token still works), degrading cleanly to the cached status if the MCP is
|
|
7
|
+
* unreachable or the identity isn't entitled yet. Operator credentials in the env
|
|
8
|
+
* are reported too, since they take precedence for tool calls.
|
|
9
|
+
*
|
|
10
|
+
* Dependency-free.
|
|
11
|
+
*/
|
|
12
|
+
import { defaultCredentialsPath, readCredentials, isExpired } from "../token-store.mjs";
|
|
13
|
+
import { hasOperatorCreds, resolveDeveloperSession } from "../auth.mjs";
|
|
14
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
15
|
+
import { normalizeStores } from "./checkout.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pure summary of the cached session — no network. Returned for both display and
|
|
19
|
+
* tests: { signedIn, expired, mcpUrl, expiresAt }.
|
|
20
|
+
*/
|
|
21
|
+
export function sessionStatus(creds, { now = Date.now() } = {}) {
|
|
22
|
+
if (!creds || !creds.accessToken) return { signedIn: false, expired: false, mcpUrl: null, expiresAt: null };
|
|
23
|
+
return {
|
|
24
|
+
signedIn: true,
|
|
25
|
+
expired: isExpired(creds, { now }),
|
|
26
|
+
mcpUrl: creds.mcpUrl || null,
|
|
27
|
+
expiresAt: creds.expiresAt || null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @param {string[]} argv @param {any} _ctx */
|
|
32
|
+
export async function run(argv, _ctx) {
|
|
33
|
+
const env = process.env;
|
|
34
|
+
const creds = readCredentials(defaultCredentialsPath(env));
|
|
35
|
+
const status = sessionStatus(creds);
|
|
36
|
+
|
|
37
|
+
if (hasOperatorCreds(env)) {
|
|
38
|
+
console.log(`operator credentials set in this shell (TOT_APP_DOMAIN=${env.TOT_APP_DOMAIN}).`);
|
|
39
|
+
console.log(" these take precedence for `tot` tool calls.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!status.signedIn) {
|
|
43
|
+
console.log("developer: not signed in.");
|
|
44
|
+
if (!hasOperatorCreds(env)) console.log(" → next: tot login");
|
|
45
|
+
return hasOperatorCreds(env) ? 0 : 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const when = status.expiresAt ? new Date(status.expiresAt).toISOString() : "unknown";
|
|
49
|
+
console.log(`developer: signed in via ${status.mcpUrl || "the MCP"}${status.expired ? " (token expired — will refresh on next use)" : ""}.`);
|
|
50
|
+
console.log(` token expires: ${when}`);
|
|
51
|
+
|
|
52
|
+
// Best-effort live check: resolve the session (refreshes if needed) and list the
|
|
53
|
+
// stores this identity can act on. Never fails the command — a gated/unentitled
|
|
54
|
+
// identity or an unreachable MCP just shows the cached status above.
|
|
55
|
+
try {
|
|
56
|
+
const client = createMcpClient(status.mcpUrl || env.MCP_BASE_URL || env.TOT_MCP_URL || "https://mcp.tokenoftrust.com");
|
|
57
|
+
await client.initialize();
|
|
58
|
+
await resolveDeveloperSession(client, env);
|
|
59
|
+
const stores = normalizeStores(await client.callTool("client_list", {}));
|
|
60
|
+
if (stores.length) {
|
|
61
|
+
console.log(` stores you can build on: ${stores.map((s) => s.id).join(", ")}`);
|
|
62
|
+
} else {
|
|
63
|
+
console.log(" (no stores resolved yet for this identity)");
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
console.log(" (couldn't reach the MCP to list your stores right now — your cached session is above)");
|
|
67
|
+
}
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
package/src/context.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context detection — "be smart about where `tot` is invoked from."
|
|
3
|
+
*
|
|
4
|
+
* A globally-installed `tot` runs in one of three places, and every command
|
|
5
|
+
* dispatches off which one:
|
|
6
|
+
*
|
|
7
|
+
* monorepo — inside a full storefront checkout (pnpm-workspace.yaml +
|
|
8
|
+
* apps/storefront + tenants/). This is us / a platform dev.
|
|
9
|
+
* `tot dev` here runs the in-tree astro dev; `tot checkout`
|
|
10
|
+
* can suggest cloning a sibling dir.
|
|
11
|
+
* checkout — inside a STANDALONE tenant checkout: the flat, content-only
|
|
12
|
+
* shape `content/ public/ theme.json .tot/config.json` a
|
|
13
|
+
* developer clones. The tenant is read from .tot/config.json.
|
|
14
|
+
* `tot dev` here boots the bundled runner against this dir.
|
|
15
|
+
* loose — anywhere else. `tot checkout <tenant>` still works (it's how
|
|
16
|
+
* you GET a checkout); commands that need a workspace say so.
|
|
17
|
+
*
|
|
18
|
+
* Detection walks UP from the cwd so `tot` works from any subdirectory of a
|
|
19
|
+
* checkout or the monorepo, the way `git` does. Dependency-free.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
/** Walk up from `start` (inclusive) to the filesystem root, yielding each dir. */
|
|
25
|
+
function* ancestors(start) {
|
|
26
|
+
let dir = resolve(start);
|
|
27
|
+
for (;;) {
|
|
28
|
+
yield dir;
|
|
29
|
+
const parent = dirname(dir);
|
|
30
|
+
if (parent === dir) return; // hit the root
|
|
31
|
+
dir = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A dir is the storefront monorepo root if it carries all three markers. */
|
|
36
|
+
function isMonorepoRoot(dir) {
|
|
37
|
+
return (
|
|
38
|
+
existsSync(join(dir, "pnpm-workspace.yaml")) &&
|
|
39
|
+
existsSync(join(dir, "apps", "storefront")) &&
|
|
40
|
+
existsSync(join(dir, "tenants"))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A dir is a standalone tenant checkout if it carries a readable .tot/config.json. */
|
|
45
|
+
function readCheckoutConfig(dir) {
|
|
46
|
+
const cfgPath = join(dir, ".tot", "config.json");
|
|
47
|
+
if (!existsSync(cfgPath)) return null;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(readFileSync(cfgPath, "utf8"));
|
|
50
|
+
} catch {
|
|
51
|
+
return null; // present but malformed — treat as "not a clean checkout"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Detect the invocation context.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} [cwd] - defaults to process.cwd().
|
|
59
|
+
* @returns {{
|
|
60
|
+
* mode: "monorepo" | "checkout" | "loose",
|
|
61
|
+
* cwd: string,
|
|
62
|
+
* repoRoot: string | null, // set in monorepo mode
|
|
63
|
+
* workspacePath: string | null,// set in checkout mode (the checkout root)
|
|
64
|
+
* config: object | null, // .tot/config.json contents in checkout mode
|
|
65
|
+
* tenant: string | null, // tenant inferred from the checkout config
|
|
66
|
+
* }}
|
|
67
|
+
*/
|
|
68
|
+
export function detectContext(cwd = process.cwd()) {
|
|
69
|
+
const base = {
|
|
70
|
+
cwd: resolve(cwd),
|
|
71
|
+
repoRoot: null,
|
|
72
|
+
workspacePath: null,
|
|
73
|
+
config: null,
|
|
74
|
+
tenant: null,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Monorepo wins if we're inside one (its own tenant checkouts live under
|
|
78
|
+
// tenants/<id>/, which do NOT carry a root .tot/config.json, so there's no
|
|
79
|
+
// ambiguity in practice — but check monorepo first regardless).
|
|
80
|
+
for (const dir of ancestors(cwd)) {
|
|
81
|
+
if (isMonorepoRoot(dir)) {
|
|
82
|
+
return { ...base, mode: "monorepo", repoRoot: dir };
|
|
83
|
+
}
|
|
84
|
+
const cfg = readCheckoutConfig(dir);
|
|
85
|
+
if (cfg) {
|
|
86
|
+
return {
|
|
87
|
+
...base,
|
|
88
|
+
mode: "checkout",
|
|
89
|
+
workspacePath: dir,
|
|
90
|
+
config: cfg,
|
|
91
|
+
tenant: typeof cfg.tenant === "string" ? cfg.tenant : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { ...base, mode: "loose" };
|
|
97
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place `tot` turns a failure into words. Every command routes its
|
|
3
|
+
* terminal errors through here so the developer NEVER sees a raw stack trace —
|
|
4
|
+
* they see `✗ <what happened> → next: <the exact command to run>`.
|
|
5
|
+
*
|
|
6
|
+
* Two shapes flow in:
|
|
7
|
+
* - CliError — a failure we raised on purpose, already carrying its next step.
|
|
8
|
+
* - anything else (thrown Error, AuthUnavailableError, a string) — we render
|
|
9
|
+
* its message and, when we can recognise it, its remedy.
|
|
10
|
+
*
|
|
11
|
+
* Dependency-free. Kept import-cycle-free by duck-typing AuthUnavailableError
|
|
12
|
+
* (matched on `.name`/`.hint`) rather than importing auth.mjs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A failure worth surfacing with a concrete next step.
|
|
17
|
+
* @param {string} what - what went wrong, in plain words.
|
|
18
|
+
* @param {{ next?: string, exitCode?: number, cause?: unknown }} [opts]
|
|
19
|
+
* next - the exact command (or one-line instruction) to run next.
|
|
20
|
+
* exitCode - process exit code to use (default 1).
|
|
21
|
+
*/
|
|
22
|
+
export class CliError extends Error {
|
|
23
|
+
constructor(what, { next, exitCode = 1, cause } = {}) {
|
|
24
|
+
super(what);
|
|
25
|
+
this.name = "CliError";
|
|
26
|
+
this.what = what;
|
|
27
|
+
this.next = next || null;
|
|
28
|
+
this.exitCode = exitCode;
|
|
29
|
+
if (cause !== undefined) this.cause = cause;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Render `what` (+ optional `next`) in the house style. No trailing newline. */
|
|
34
|
+
export function fail(what, next) {
|
|
35
|
+
const head = `✗ ${what}`;
|
|
36
|
+
return next ? `${head}\n → next: ${next}` : head;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format ANY error into the house style. Never emits a stack trace.
|
|
41
|
+
* @param {unknown} err
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function formatError(err) {
|
|
45
|
+
if (err instanceof CliError) return fail(err.what, err.next);
|
|
46
|
+
// AuthUnavailableError, duck-typed to avoid an import cycle with auth.mjs.
|
|
47
|
+
if (err && typeof err === "object" && err.name === "AuthUnavailableError") {
|
|
48
|
+
return fail(String(err.message), err.hint || null);
|
|
49
|
+
}
|
|
50
|
+
if (err && typeof err === "object" && "message" in err && err.message) {
|
|
51
|
+
return fail(String(err.message), null);
|
|
52
|
+
}
|
|
53
|
+
return fail(String(err), null);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Exit code an error asks for (CliError carries its own; default 1). */
|
|
57
|
+
export function exitCodeFor(err) {
|
|
58
|
+
return err instanceof CliError && Number.isInteger(err.exitCode) ? err.exitCode : 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Print a formatted failure to stderr. */
|
|
62
|
+
export function printError(err, write = (s) => console.error(s)) {
|
|
63
|
+
write(formatError(err));
|
|
64
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "last tenant" cache for `tot start` (A4 — smart zero-arg default). After
|
|
3
|
+
* a multi-store identity picks (or is told) a tenant, remember it here so a
|
|
4
|
+
* bare `tot start` on the next run just goes instead of re-prompting.
|
|
5
|
+
*
|
|
6
|
+
* ONE file, `~/.tot/last-tenant.json`, scoped by `mcpUrl` — a different MCP
|
|
7
|
+
* means a different set of stores, so a tenant remembered there doesn't carry
|
|
8
|
+
* over. Same atomic-write discipline as token-store.mjs (0600 in a 0700 dir,
|
|
9
|
+
* write-tmp-then-rename). Dependency-free (node:fs/os/path).
|
|
10
|
+
*
|
|
11
|
+
* `TOT_HOME` overrides the home dir (used by tests to point at a temp dir).
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** Absolute path to the last-tenant cache for this environment. */
|
|
20
|
+
export function defaultLastTenantPath(env = process.env) {
|
|
21
|
+
const home = env.TOT_HOME || homedir();
|
|
22
|
+
return join(home, ".tot", "last-tenant.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read back the remembered tenant for `mcpUrl`, or null if there isn't one
|
|
27
|
+
* (absent, unreadable, malformed, or remembered against a different MCP).
|
|
28
|
+
* Never throws — a miss just means "nothing remembered".
|
|
29
|
+
*/
|
|
30
|
+
export function readLastTenant(filePath, mcpUrl) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
33
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
34
|
+
if (parsed.mcpUrl !== mcpUrl) return null;
|
|
35
|
+
return typeof parsed.tenant === "string" && parsed.tenant ? parsed.tenant : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Remember `tenant` for `mcpUrl`, atomically and owner-only. */
|
|
42
|
+
export function writeLastTenant(filePath, { mcpUrl, tenant }) {
|
|
43
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
44
|
+
const tmp = `${filePath}.tmp`;
|
|
45
|
+
const record = { mcpUrl, tenant, updatedAt: Date.now() };
|
|
46
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
47
|
+
renameSync(tmp, filePath);
|
|
48
|
+
chmodSync(filePath, 0o600);
|
|
49
|
+
}
|