@tokenoftrust/cli 1.3.4-rc.4 → 1.3.4-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tot.mjs +8 -0
- package/package.json +2 -1
- package/src/app-scaffold.mjs +84 -0
- package/src/banner.mjs +46 -0
- package/src/commands/app/dev.mjs +268 -0
- package/src/commands/app/index.mjs +35 -0
- package/src/commands/app/scaffold.mjs +57 -0
- package/src/commands/dev.mjs +57 -13
- package/src/commands/start.mjs +76 -17
- package/src/commands/validate.mjs +4 -0
- package/src/dev-logs.mjs +34 -7
- package/src/validate.mjs +77 -8
- package/src/vendor/private-apps-devkit.mjs +490 -0
- package/template/private-app/.env.example +10 -0
- package/template/private-app/Dockerfile +12 -0
- package/template/private-app/README.md +45 -0
- package/template/private-app/fixtures/order.created.cloudevent.json +36 -0
- package/template/private-app/server.js +81 -0
- package/template/private-app/tot-app.json +29 -0
- package/template/sample-store/content/chrome.html +140 -0
- package/template/sample-store/content/chrome.json +86 -0
- package/template/sample-store/content/home.html +121 -0
- package/template/sample-store/content/home.json +50 -0
- package/template/sample-store/content/pages/about.json +10 -0
- package/template/sample-store/content/pages/privacy.json +10 -0
- package/template/sample-store/content/pages/shipping-returns.json +10 -0
- package/template/sample-store/content/pages-html/blogs/news.html +26 -0
- package/template/sample-store/content/pages-html/pages/about-us.html +44 -0
- package/template/sample-store/content/pages-html/pages/contact-us.html +48 -0
- package/template/sample-store/content/pages-html/pages/privacy-policy.html +27 -0
- package/template/sample-store/content/pages-html/pages/shipping-returns.html +25 -0
- package/template/sample-store/public/logo.svg +6 -0
- package/template/sample-store/public/pages/home.css +120 -0
- package/template/sample-store/public/pages/mkt.css +185 -0
- package/template/sample-store/public/pages/page.css +155 -0
- package/template/sample-store/public/themes/sample.css +76 -0
- package/template/sample-store/theme.json +38 -0
package/src/commands/start.mjs
CHANGED
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
import { scaffoldSample, isSampleCheckout, sampleConfig, SAMPLE_DIR_NAME } from "../sample.mjs";
|
|
68
68
|
import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
|
|
69
69
|
import { streamDevLogs } from "../dev-logs.mjs";
|
|
70
|
+
import { milestoneBanner, cockpitUrlFrom, DEVELOPER_COCKPIT } from "../banner.mjs";
|
|
70
71
|
import { IDEAS } from "./ideas.mjs";
|
|
71
72
|
|
|
72
73
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
@@ -196,15 +197,36 @@ export async function run(argv, ctx) {
|
|
|
196
197
|
await preflight(ctx, env);
|
|
197
198
|
console.log(` ✓ signed in as ${describeIdentity(session)} via ${mcpOrigin(baseUrl)}`);
|
|
198
199
|
|
|
200
|
+
// Resolve the activity-bridge credential + the hosted Developer Cockpit URL
|
|
201
|
+
// ONCE, up front — both the pre-pick "keep the cockpit alive" heartbeat below
|
|
202
|
+
// (b2) and the post-boot heartbeat + milestone banners (u2) use them.
|
|
203
|
+
const bridgeEnv = activityBridgeEnv(env);
|
|
204
|
+
const cockpitUrl = cockpitUrlFrom(bridgeEnv.TOT_DEV_ACTIVITY_URL);
|
|
205
|
+
|
|
206
|
+
// b2 — a >1-store identity blocks on the interactive store picker below, and
|
|
207
|
+
// (before this) the hosted cockpit got NO heartbeat until the dev server
|
|
208
|
+
// spawned much later, so a multi-store dev's cockpit sat on Step 1 forever
|
|
209
|
+
// while the terminal quietly waited for a number. Start beating NOW (no local
|
|
210
|
+
// URL yet — the store isn't checked out) so the cockpit keeps receiving
|
|
211
|
+
// liveness during the pick + checkout + boot; the post-boot heartbeat (with
|
|
212
|
+
// the live localhost URL) takes over once we have it.
|
|
213
|
+
const stopEarlyHeartbeat = startHeartbeatFromEnv(bridgeEnv, {});
|
|
214
|
+
|
|
199
215
|
// 3. store — auto-pick, use --tenant, use the remembered one, or choose (A4).
|
|
200
216
|
// Keep the raw client_list so a tool ERROR (unauthenticated / not-entitled) is
|
|
201
217
|
// surfaced with who + where, not collapsed to an empty list (fb-...f45zg9).
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
218
|
+
let tenant;
|
|
219
|
+
try {
|
|
220
|
+
const listResp = await client.callTool("client_list", {});
|
|
221
|
+
const stores = normalizeStores(listResp);
|
|
222
|
+
tenant = await resolveTenant(stores, args, env, baseUrl, {
|
|
223
|
+
session,
|
|
224
|
+
listErr: storeListError(listResp),
|
|
225
|
+
});
|
|
226
|
+
} catch (e) {
|
|
227
|
+
stopEarlyHeartbeat();
|
|
228
|
+
throw e;
|
|
229
|
+
}
|
|
208
230
|
|
|
209
231
|
// 4. checkout → ./<tenant> (reuse an existing checkout on a re-run),
|
|
210
232
|
// OVERLAPPED (C1) with prefetching the runner: the native artifact by
|
|
@@ -225,11 +247,15 @@ export async function run(argv, ctx) {
|
|
|
225
247
|
prefetchRuntime(client, devArgs, env, runtime, ctx),
|
|
226
248
|
]);
|
|
227
249
|
|
|
250
|
+
// u3 — say EXACTLY where the code landed + THE one next step, so a freshly
|
|
251
|
+
// checked-out dev is never left wondering what to open. Absolute path (works
|
|
252
|
+
// from any directory) and a single, unambiguous action.
|
|
253
|
+
printCheckoutLanding(dir, cockpitUrl);
|
|
254
|
+
|
|
228
255
|
// 5. dev — native by default (F3), Docker on --docker or the fallback
|
|
229
256
|
// above; wait for the server, open the browser (C/D).
|
|
230
257
|
const ctxDev = detectContext(dir);
|
|
231
258
|
let url, handle;
|
|
232
|
-
const bridgeEnv = activityBridgeEnv(env);
|
|
233
259
|
if (runtime.useDocker) {
|
|
234
260
|
const plan = buildContainerPlan(dir, devArgs, ctxDev);
|
|
235
261
|
url = plan.url;
|
|
@@ -244,7 +270,10 @@ export async function run(argv, ctx) {
|
|
|
244
270
|
// Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
|
|
245
271
|
// URL for the life of the run — CLI-side, using the cached bridge credential
|
|
246
272
|
// (no-op when none is cached). The runner keeps reporting file-saves itself.
|
|
247
|
-
|
|
273
|
+
// Hand off from the pre-pick (URL-less) beat to this URL-bearing one so the
|
|
274
|
+
// cockpit's live local link lights up exactly when the server is up (b2).
|
|
275
|
+
stopEarlyHeartbeat();
|
|
276
|
+
const stopHeartbeat = startHeartbeatFromEnv(bridgeEnv, { url, cwd: dir });
|
|
248
277
|
handle.done.finally(() => stopHeartbeat());
|
|
249
278
|
|
|
250
279
|
// The runner's stdio is "piped" (its logs are held until the aha), so this
|
|
@@ -264,11 +293,11 @@ export async function run(argv, ctx) {
|
|
|
264
293
|
// (A3) so the "instant" claim is measured. The "Connect Claude" step is
|
|
265
294
|
// intentionally removed for now — a blocking prompt here meant Ctrl-C'ing it
|
|
266
295
|
// tore down the dev server; revisit AI-connect as a non-blocking step later.
|
|
267
|
-
printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt));
|
|
296
|
+
printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt), cockpitUrl);
|
|
268
297
|
|
|
269
298
|
// 7. hand the terminal to the running dev server until Ctrl-C.
|
|
270
299
|
console.log("\n Watching your store — edit content/home.html + save. Ctrl-C to stop.\n");
|
|
271
|
-
streamDevLogs(handle.child);
|
|
300
|
+
streamDevLogs(handle.child, { cockpitUrl });
|
|
272
301
|
return handle.done;
|
|
273
302
|
} catch (e) {
|
|
274
303
|
console.error(formatError(e));
|
|
@@ -547,8 +576,14 @@ async function resolveTenant(stores, args, env, baseUrl, { session = null, listE
|
|
|
547
576
|
next: `tot start --tenant <tenant> (one of: ${pick.stores.map((s) => s.id).join(", ")})`,
|
|
548
577
|
});
|
|
549
578
|
}
|
|
550
|
-
|
|
579
|
+
// Loud + unmissable: a >1-store dev is often looking at their browser
|
|
580
|
+
// cockpit (which is waiting on this pick), so make the terminal shout for
|
|
581
|
+
// their attention rather than sitting as a quiet one-liner (b2).
|
|
582
|
+
console.log("");
|
|
583
|
+
console.log(" ⚑ ACTION NEEDED IN YOUR TERMINAL — you can build on several stores.");
|
|
584
|
+
console.log(" Pick one here to light up your cockpit:\n");
|
|
551
585
|
pick.stores.forEach((s, i) => console.log(` ${i + 1}. ${s.id}${s.name ? ` — ${s.name}` : ""}`));
|
|
586
|
+
console.log("");
|
|
552
587
|
const idx = await promptChoice(pick.stores.length);
|
|
553
588
|
console.log("");
|
|
554
589
|
tenant = pick.stores[idx].id;
|
|
@@ -611,13 +646,37 @@ export function formatElapsed(ms) {
|
|
|
611
646
|
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
612
647
|
}
|
|
613
648
|
|
|
614
|
-
/**
|
|
615
|
-
*
|
|
616
|
-
|
|
649
|
+
/**
|
|
650
|
+
* u3 — after checkout, tell the developer EXACTLY where their code landed and
|
|
651
|
+
* THE single next step. `dir` is absolute (from resolve(cwd, tenant)) so the
|
|
652
|
+
* "open this" path works no matter what directory they're in. When we know the
|
|
653
|
+
* Developer Cockpit URL, the one next step is framed as "edit a line → see it in
|
|
654
|
+
* your cockpit"; otherwise it's the same edit-to-see-it-reload action.
|
|
655
|
+
*/
|
|
656
|
+
function printCheckoutLanding(dir, cockpitUrl = null) {
|
|
617
657
|
console.log("");
|
|
618
|
-
console.log(`
|
|
619
|
-
|
|
620
|
-
console.log(
|
|
658
|
+
console.log(` 📁 Your store code is at: ${dir}`);
|
|
659
|
+
const where = cockpitUrl ? `your ${DEVELOPER_COCKPIT}` : "your browser";
|
|
660
|
+
console.log(` Next: open it in your editor and change one line in`);
|
|
661
|
+
console.log(` content/home.html — you'll see it reflected in ${where}.`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** The crafted "you're live" ending — a PROMINENT milestone banner (u2) that,
|
|
665
|
+
* when we hold a Developer Cockpit URL, explicitly sends the developer BACK to
|
|
666
|
+
* their cockpit as the next place to look; then the AI-wow (G), seeded with
|
|
667
|
+
* IDEAS[0] (G2/G3 — one prompt list shared with `tot ideas`, no drift). */
|
|
668
|
+
function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
|
|
669
|
+
const lines = [
|
|
670
|
+
`✨ You're live.${elapsed ? ` (${elapsed})` : ""}`,
|
|
671
|
+
` ${url}`,
|
|
672
|
+
" Edit content/home.html + save → it reloads.",
|
|
673
|
+
];
|
|
674
|
+
if (cockpitUrl) {
|
|
675
|
+
lines.push("");
|
|
676
|
+
lines.push(`➜ Back to your ${DEVELOPER_COCKPIT} to watch it happen:`);
|
|
677
|
+
lines.push(` ${cockpitUrl}`);
|
|
678
|
+
}
|
|
679
|
+
console.log(milestoneBanner(lines));
|
|
621
680
|
console.log(" " + versionStamp("native"));
|
|
622
681
|
console.log("");
|
|
623
682
|
console.log(" Now try, in Claude:");
|
|
@@ -79,6 +79,10 @@ export function run(argv, ctx) {
|
|
|
79
79
|
const { ok, findings } = validateTenant(target.dir, {
|
|
80
80
|
tenantId: target.tenantId ?? undefined,
|
|
81
81
|
scope: target.scope ?? undefined,
|
|
82
|
+
// A checkout / bare-tenant target is served by the ToT storefront platform, so
|
|
83
|
+
// commerce tenants own the framework routes (/collections, /account, …). Only an
|
|
84
|
+
// arbitrary --workspace <dir> stays conservative (unless its config resolves the host).
|
|
85
|
+
mode: args.workspace ? "workspace" : "monorepo",
|
|
82
86
|
});
|
|
83
87
|
|
|
84
88
|
if (args.json) {
|
package/src/dev-logs.mjs
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* (dev.mjs). Extracted so the two entrypoints stream identically instead of one
|
|
5
5
|
* inheriting the raw vite/astro firehose while the other collapses it.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* stdout/stderr.
|
|
7
|
+
* Depends only on the shared, dependency-free banner helper — otherwise pure
|
|
8
|
+
* line buffering + filtering over a child's stdout/stderr.
|
|
9
9
|
*/
|
|
10
|
+
import { milestoneBanner, DEVELOPER_COCKPIT } from "./banner.mjs";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Stream the running dev server's output in BUSINESS terms. The runner + Vite +
|
|
@@ -16,17 +17,32 @@
|
|
|
16
17
|
* save-reload into one clean "↻ your store reloaded", drop the known internal
|
|
17
18
|
* noise, and pass anything else through (indented) so nothing important is
|
|
18
19
|
* hidden. Ctrl-C still tears the server down (the child owns the TTY signals).
|
|
20
|
+
*
|
|
21
|
+
* The FIRST save→reload is the aha milestone (u2): with a `cockpitUrl` it's a
|
|
22
|
+
* PROMINENT banner that sends the developer back to their Developer Cockpit to
|
|
23
|
+
* see the change; every reload after that is the quiet "↻ your store reloaded"
|
|
24
|
+
* line so a working dev loop doesn't get spammed with banners.
|
|
19
25
|
* @param {import("node:child_process").ChildProcess} child
|
|
26
|
+
* @param {{ cockpitUrl?: string|null, out?: (s: string) => void, reloadDebounceMs?: number }} [opts]
|
|
27
|
+
* `out` is the sink (default: process.stdout.write) — injectable for tests.
|
|
28
|
+
* `reloadDebounceMs` collapses a single save's HMR burst into one line (default 1000ms).
|
|
20
29
|
*/
|
|
21
|
-
export function streamDevLogs(
|
|
30
|
+
export function streamDevLogs(
|
|
31
|
+
child,
|
|
32
|
+
{ cockpitUrl = null, out = (s) => process.stdout.write(s), reloadDebounceMs = 1000 } = {},
|
|
33
|
+
) {
|
|
22
34
|
// Startup churn + tool internals — never user-facing. Matched AFTER stripping
|
|
23
35
|
// the runner/Vite "HH:MM:SS " timestamp prefix (see `body` below), so a
|
|
24
36
|
// timestamped internal line like "10:50:17 [vite] connected" is still dropped.
|
|
37
|
+
// The node:* / ExperimentalWarning / (node:NNNN) / "--trace-warnings" and the
|
|
38
|
+
// boot "fatal: not a git repository" lines are dropped too (b4) — scary,
|
|
39
|
+
// non-actionable boot noise that reads as a broken first run.
|
|
25
40
|
const NOISE =
|
|
26
|
-
/^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d)/i;
|
|
41
|
+
/^(\[vite\]|\[types\]|\[@astrojs|\[WARN\]|▲|┃|astro\s+v[\d.]|(Local|Network)\s+http|watching for file changes|Scope: all \d|copy-tenant-assets:|.*dependency optimized|.*optimized dependencies changed|.*program reload|\d+ deprecated|Packages:\s*\+|Progress:\s*resolved|Downloading @|node_modules\/|devDependencies:|\+\s+\w+@|Done in \d|\(node:\d+\)|ExperimentalWarning|node:internal\/|\(Use `node --trace-warnings|fatal: not a git repository)/i;
|
|
27
42
|
// A real save-triggered reload (not startup "program reload" churn).
|
|
28
43
|
const RELOAD = /(hmr update|page reload)/i;
|
|
29
44
|
let reloadPending = null;
|
|
45
|
+
let sawFirstReload = false;
|
|
30
46
|
const emit = (line) => {
|
|
31
47
|
const t = line.replace(/\s+$/, "");
|
|
32
48
|
if (!t) return;
|
|
@@ -35,13 +51,24 @@ export function streamDevLogs(child) {
|
|
|
35
51
|
const body = t.replace(/^\d{1,2}:\d{2}:\d{2}(\.\d+)?\s+/, "").replace(/^\s+/, "");
|
|
36
52
|
if (RELOAD.test(body)) {
|
|
37
53
|
if (reloadPending) return; // debounce a burst into one line
|
|
38
|
-
reloadPending = setTimeout(() => { reloadPending = null; },
|
|
54
|
+
reloadPending = setTimeout(() => { reloadPending = null; }, reloadDebounceMs);
|
|
39
55
|
if (reloadPending.unref) reloadPending.unref();
|
|
40
|
-
|
|
56
|
+
if (!sawFirstReload && cockpitUrl) {
|
|
57
|
+
sawFirstReload = true;
|
|
58
|
+
out(milestoneBanner([
|
|
59
|
+
"↻ Your edit is live — your store reloaded.",
|
|
60
|
+
"",
|
|
61
|
+
`➜ See it in your ${DEVELOPER_COCKPIT}:`,
|
|
62
|
+
` ${cockpitUrl}`,
|
|
63
|
+
]) + "\n");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
sawFirstReload = true;
|
|
67
|
+
out(" ↻ your store reloaded\n");
|
|
41
68
|
return;
|
|
42
69
|
}
|
|
43
70
|
if (NOISE.test(body)) return;
|
|
44
|
-
|
|
71
|
+
out(` ${t}\n`);
|
|
45
72
|
};
|
|
46
73
|
lineStream(child.stdout, emit);
|
|
47
74
|
lineStream(child.stderr, emit);
|
package/src/validate.mjs
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* mirror the reconcile pipeline's own gate (@tot/private-controlplane
|
|
8
8
|
* customization-reconcile.ts `mapToArtifact` / `validateRawHtml`). Kept in step
|
|
9
9
|
* with the in-repo source of truth `scripts/tenant/validate.mjs` (which the CI
|
|
10
|
-
* regression test imports)
|
|
11
|
-
*
|
|
10
|
+
* regression test imports) — including its platform-route awareness (commerce
|
|
11
|
+
* tenants own /collections, /products/<handle>, /search, /saved, /account, /cart,
|
|
12
|
+
* so links into those are not false `link-dangling` warnings). FOLLOW-UP: collapse
|
|
13
|
+
* the two onto this module as the single source once the package is the install surface.
|
|
12
14
|
*
|
|
13
15
|
* Pure + dependency-free (readFileSync only). `validateTenant(dir)` is the entry
|
|
14
16
|
* point; `validateConfigShape` is exported for focused testing.
|
|
@@ -333,10 +335,72 @@ const HREF_RE = /\bhref\s*=\s*"([^"]*)"/gi;
|
|
|
333
335
|
const SRC_RE = /\b(?:src|srcset)\s*=\s*"([^"]*)"/gi;
|
|
334
336
|
const STYLE_OPEN_WITH_ATTRS_RE = /<style\s+[^>]*>/i;
|
|
335
337
|
|
|
338
|
+
// --- platform-owned routes (framework surfaces, NOT tenant pages) ------------
|
|
339
|
+
/**
|
|
340
|
+
* When the tenant is served BY this ToT storefront platform, the platform itself
|
|
341
|
+
* owns a set of framework routes — catalog (`/collections`, `/collections/<handle>`,
|
|
342
|
+
* `/products/<handle>`), `/search`, `/saved`, the auth/account route (`/account`),
|
|
343
|
+
* and `/cart` when cart/checkout is on. A COMMERCE tenant that links into those is
|
|
344
|
+
* linking to a live platform surface, so such links must NOT be flagged
|
|
345
|
+
* `link-dangling` — only genuinely-missing tenant pages/assets should warn.
|
|
346
|
+
*
|
|
347
|
+
* MARKETING tenants get none of these (their commerce affordances are off), so they
|
|
348
|
+
* stay strict. Standalone / unresolved hosts also stay conservative — they can't
|
|
349
|
+
* assume the eventual host is this platform — UNLESS the config resolves the host
|
|
350
|
+
* (`"hostPlatform": "tot-storefront"`).
|
|
351
|
+
*
|
|
352
|
+
* The route→feature mapping mirrors `resolveTenantFeatures` in
|
|
353
|
+
* `@tot/public-runtime` tenant.ts (catalog / productSearch / savedItems) — keep in step.
|
|
354
|
+
*/
|
|
355
|
+
const COMMERCE_ROUTE_FEATURES = { catalog: true, productSearch: true, savedItems: true };
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param {any} config parsed `.tot/config.json` (or null)
|
|
359
|
+
* @param {boolean} hostIsPlatform is the serving host this ToT storefront platform?
|
|
360
|
+
* @returns {{active:boolean, owns:(path:string)=>boolean}}
|
|
361
|
+
*/
|
|
362
|
+
function resolvePlatformRouteOwnership(config, hostIsPlatform) {
|
|
363
|
+
const inert = { active: false, owns: () => false };
|
|
364
|
+
if (!hostIsPlatform) return inert; // standalone / unresolved host → conservative
|
|
365
|
+
const siteType = config?.siteType ?? "commerce"; // unset == commerce (platform default)
|
|
366
|
+
if (siteType === "marketing") return inert; // marketing owns no commerce routes
|
|
367
|
+
// commerce feature defaults, with any explicit per-flag overrides layered on
|
|
368
|
+
const features = { ...COMMERCE_ROUTE_FEATURES };
|
|
369
|
+
const overrides = config?.features;
|
|
370
|
+
if (overrides && typeof overrides === "object") {
|
|
371
|
+
for (const [k, v] of Object.entries(overrides)) if (v !== undefined && k in features) features[k] = v;
|
|
372
|
+
}
|
|
373
|
+
const cartEnabled = config?.capabilities?.cartCheckout?.enabled ?? true; // commerce default: on
|
|
374
|
+
|
|
375
|
+
const exact = new Set(["/account"]); // the auth/account route the platform currently serves
|
|
376
|
+
const prefixes = ["/account/"];
|
|
377
|
+
if (features.catalog) {
|
|
378
|
+
exact.add("/collections");
|
|
379
|
+
prefixes.push("/collections/", "/products/");
|
|
380
|
+
}
|
|
381
|
+
if (features.productSearch) exact.add("/search");
|
|
382
|
+
if (features.savedItems) exact.add("/saved");
|
|
383
|
+
if (cartEnabled) {
|
|
384
|
+
exact.add("/cart");
|
|
385
|
+
prefixes.push("/cart/");
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
active: true,
|
|
389
|
+
owns(path) {
|
|
390
|
+
const norm = path.replace(/\/+$/, "") || "/";
|
|
391
|
+
if (exact.has(norm)) return true;
|
|
392
|
+
return prefixes.some((p) => path.startsWith(p));
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
336
397
|
/**
|
|
337
398
|
* Full static validation of a tenant directory (content/ public/ theme.json [.tot/]).
|
|
338
399
|
* @param {string} tenantDir absolute path to the tenant dir
|
|
339
|
-
* @param {{tenantId?:string, scope?:string}} [opts]
|
|
400
|
+
* @param {{tenantId?:string, scope?:string, mode?:"monorepo"|"workspace"}} [opts]
|
|
401
|
+
* `mode` — "monorepo" (default): served by this platform, so commerce tenants own
|
|
402
|
+
* the framework routes. "workspace": a standalone checkout, conservative about
|
|
403
|
+
* platform-route ownership unless the config resolves the host (`hostPlatform`).
|
|
340
404
|
* @returns {{ok:boolean, findings:Finding[]}}
|
|
341
405
|
*/
|
|
342
406
|
export function validateTenant(tenantDir, opts = {}) {
|
|
@@ -370,6 +434,10 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
370
434
|
);
|
|
371
435
|
}
|
|
372
436
|
const scope = opts.scope || config?.scope;
|
|
437
|
+
// platform-route awareness: which framework routes the host platform owns for this tenant
|
|
438
|
+
const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
|
|
439
|
+
const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
|
|
440
|
+
const platformRoutes = resolvePlatformRouteOwnership(config, hostIsPlatform);
|
|
373
441
|
if (config?.capabilities) {
|
|
374
442
|
findings.push(...validateCapabilitiesDoc(config.capabilities, ".tot/config.json capabilities", config));
|
|
375
443
|
}
|
|
@@ -456,7 +524,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
456
524
|
}
|
|
457
525
|
|
|
458
526
|
for (const m of html.matchAll(HREF_RE)) {
|
|
459
|
-
findings.push(...checkLink(m[1].trim(), r, scope, pageTargets));
|
|
527
|
+
findings.push(...checkLink(m[1].trim(), r, scope, pageTargets, platformRoutes.owns));
|
|
460
528
|
}
|
|
461
529
|
for (const m of html.matchAll(SRC_RE)) {
|
|
462
530
|
const f = checkAsset(m[1].trim(), r, publicDir, opts.tenantId || config?.tenant);
|
|
@@ -480,7 +548,7 @@ function buildPageTargetSet(contentDir, pagesDir) {
|
|
|
480
548
|
return set;
|
|
481
549
|
}
|
|
482
550
|
|
|
483
|
-
function checkLink(href, file, scope, pageTargets) {
|
|
551
|
+
function checkLink(href, file, scope, pageTargets, ownsPlatformRoute = () => false) {
|
|
484
552
|
const out = [];
|
|
485
553
|
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return out;
|
|
486
554
|
if (/^https?:\/\/localhost(?::\d+)?/i.test(href)) {
|
|
@@ -501,9 +569,10 @@ function checkLink(href, file, scope, pageTargets) {
|
|
|
501
569
|
if (href.startsWith("/tenants/")) return out;
|
|
502
570
|
const path = href.split(/[?#]/)[0];
|
|
503
571
|
const norm = path.replace(/\/+$/, "") || "/";
|
|
504
|
-
if (
|
|
505
|
-
|
|
506
|
-
|
|
572
|
+
if (pageTargets.has(path) || pageTargets.has(norm) || pageTargets.has(norm + "/")) return out;
|
|
573
|
+
// a framework route the host platform owns (e.g. /collections, /account) is not a dangling tenant page
|
|
574
|
+
if (ownsPlatformRoute(path)) return out;
|
|
575
|
+
out.push(mk(WARN, "link-dangling", file, `internal link ${path} does not resolve to a known page (would 404)`));
|
|
507
576
|
return out;
|
|
508
577
|
}
|
|
509
578
|
out.push(mk(WARN, "link-relative", file, `relative link "${href}" — internal links should be root-absolute (/foo/)`));
|