@tokenoftrust/storefront-runner 2.2.86 → 2.2.87

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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The render-time read of a tenant's declared platform config (`.tot/config.json`
3
+ * → siteType/compliance/capabilities/features; see
4
+ * `@tot/public-runtime/declared-tenant-config`). Every render applies it the same
5
+ * way, on top of the resolved tenant record:
6
+ *
7
+ * 1. the checkout's own file in the build tree — what `tot dev` grafts in and
8
+ * what the static-publish build materializes from the tenant repo;
9
+ * 2. the per-environment artifact the reconcile derived from the same file at the
10
+ * reconciled commit — what the hosted Worker serves preview and live from.
11
+ *
12
+ * Both are the same file at the same commit, so a tenant renders identically on
13
+ * every surface; a field honored by one is honored by all.
14
+ */
15
+ import {
16
+ applyDeclaredTenantConfig,
17
+ declaredTenantConfig,
18
+ declaredTenantConfigArtifactPath,
19
+ type DeclaredTenantConfig,
20
+ } from "@tot/public-runtime/declared-tenant-config";
21
+ import {
22
+ readPublishedArtifact,
23
+ readVersionArtifact,
24
+ tenantIdFromPath,
25
+ type CustomizationEnv,
26
+ type TenantConfig,
27
+ } from "@tot/public-runtime";
28
+
29
+ // The checkout's own `.tot/config.json`, read eagerly (tiny JSON). From src/config/
30
+ // the repo root is four levels up; both tenant tiers are globbed, and the explicit
31
+ // `.tot` literal matches the dotdir.
32
+ const checkoutConfigFiles = import.meta.glob<Record<string, unknown>>(
33
+ ["../../../../tenants/*/.tot/config.json", "../../../../tenants/e2e/*/.tot/config.json"],
34
+ { eager: true, import: "default" },
35
+ );
36
+
37
+ /** dir id → that checkout's parsed `.tot/config.json`. */
38
+ export function checkoutTotConfigs(): Map<string, Record<string, unknown>> {
39
+ const map = new Map<string, Record<string, unknown>>();
40
+ for (const [key, config] of Object.entries(checkoutConfigFiles)) {
41
+ const id = tenantIdFromPath(key);
42
+ if (id && config && typeof config === "object") map.set(id, config);
43
+ }
44
+ return map;
45
+ }
46
+
47
+ /** The declaration in the checkout's own `.tot/config.json`, or `{}` when it has none. */
48
+ export function checkoutDeclaredTenantConfig(tenantId: string): DeclaredTenantConfig {
49
+ return declaredTenantConfig(checkoutConfigFiles[`../../../../tenants/${tenantId}/.tot/config.json`]
50
+ ?? checkoutConfigFiles[`../../../../tenants/e2e/${tenantId}/.tot/config.json`]);
51
+ }
52
+
53
+ /** The declaration the reconcile published for this environment, or `{}`. */
54
+ export async function publishedDeclaredTenantConfig(
55
+ kvGet: (key: string) => Promise<string | null>,
56
+ tenantId: string,
57
+ env: CustomizationEnv,
58
+ ): Promise<DeclaredTenantConfig> {
59
+ const raw = await readPublishedArtifact(kvGet, tenantId, env, declaredTenantConfigArtifactPath(tenantId));
60
+ if (!raw) return {};
61
+ try {
62
+ return declaredTenantConfig(JSON.parse(raw));
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ /** The declaration pinned to one immutable version (a candidate under review), or `{}`. */
69
+ export async function versionDeclaredTenantConfig(
70
+ kvGet: (key: string) => Promise<string | null>,
71
+ tenantId: string,
72
+ versionId: string,
73
+ ): Promise<DeclaredTenantConfig> {
74
+ const raw = await readVersionArtifact(kvGet, tenantId, versionId, declaredTenantConfigArtifactPath(tenantId));
75
+ if (!raw) return {};
76
+ try {
77
+ return declaredTenantConfig(JSON.parse(raw));
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ /** The tenant as it renders in `env`: its record with every declaration applied. */
84
+ export async function withDeclaredTenantConfig(
85
+ tenant: TenantConfig,
86
+ kvGet: (key: string) => Promise<string | null>,
87
+ env: CustomizationEnv,
88
+ ): Promise<TenantConfig> {
89
+ const fromCheckout = applyDeclaredTenantConfig(tenant, checkoutDeclaredTenantConfig(tenant.tenant_id));
90
+ return applyDeclaredTenantConfig(
91
+ fromCheckout,
92
+ await publishedDeclaredTenantConfig(kvGet, tenant.tenant_id, env),
93
+ );
94
+ }
@@ -97,6 +97,7 @@ import {
97
97
  import { recordActivity } from "@/lib/activity/recordActivity";
98
98
  import { middlewareRewriteTargetV1 } from "./routeBranch.js";
99
99
  import { declaredTenantOrigins } from "@/lib/tenantOrigins";
100
+ import { withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
100
101
  import {
101
102
  applyInternalContentResponsePolicy,
102
103
  internalContentGrantMatchesPlan,
@@ -147,7 +148,7 @@ export interface RenderChainInput {
147
148
  export const TENANT_ORIGINS_CAPTURE_HEADER = "x-tot-tenant-origins";
148
149
 
149
150
  export async function runRenderChain(input: RenderChainInput): Promise<Response> {
150
- const { context, next, host, url, tenant, basePath, renderPath, cache, kv } = input;
151
+ const { context, next, host, url, tenant: tenantRecord, basePath, renderPath, cache, kv } = input;
151
152
  const { request, locals } = context;
152
153
  const isInternalPublication = input.publicationRoute.action !== "fail-closed" &&
153
154
  input.publicationRoute.manifest.visibility === "internal";
@@ -177,7 +178,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
177
178
  // pin can only ever name its own version; a mismatched/expired/absent token
178
179
  // falls through to the normal channel-resolved read below, same fail-closed
179
180
  // posture as the `__preview` token.
180
- const candidatePin = await resolveCandidateContentPin(url, tenant.tenant_id);
181
+ const candidatePin = await resolveCandidateContentPin(url, tenantRecord.tenant_id);
181
182
  // Per-developer AI-edit draft: the signed
182
183
  // tot_dev_draft cookie's mere PRESENCE opts the request out of the shared page
183
184
  // cache below (a draft view must never be cached publicly); it's verified —
@@ -192,7 +193,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
192
193
  const nowSeconds = Math.floor(Date.now() / 1000);
193
194
  const customization = await resolveCustomizationEnv({
194
195
  isPreviewHost,
195
- tenantId: tenant.tenant_id,
196
+ tenantId: tenantRecord.tenant_id,
196
197
  token: previewToken,
197
198
  secret: previewSecret,
198
199
  nowSeconds,
@@ -230,6 +231,16 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
230
231
  if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
231
232
  return response;
232
233
  }
234
+
235
+ // The tenant as it renders in THIS environment: its record with the declared
236
+ // `.tot/config.json` fields applied (compliance, siteType, capabilities,
237
+ // features). Everything below — static bundle, notices, gates, features,
238
+ // capabilities — reads this, never the bare record.
239
+ const tenant = await withDeclaredTenantConfig(
240
+ tenantRecord,
241
+ kv ? (key: string) => kv.get(key) : async () => null,
242
+ customization.env,
243
+ );
233
244
  const publicationTimestamp = publicationSource.status === "selected"
234
245
  ? publicationSource.publicationTimestamp
235
246
  : exactPublicationTimestamp(await readEnv("TOT_PUBLICATION_TIMESTAMP"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.86",
3
+ "version": "2.2.87",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -19,6 +19,7 @@
19
19
  "./durable-assets": "./src/durable-assets.ts",
20
20
  "./retention-holds": "./src/retention-holds.ts",
21
21
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
22
+ "./declared-tenant-config": "./src/declared-tenant-config.mjs",
22
23
  "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
23
24
  "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs",
24
25
  "./integration/visitor-region": "./src/integration/visitor-region.mjs"
@@ -0,0 +1,22 @@
1
+ /** Types for the dependency-free declared-tenant-config module (declared-tenant-config.mjs). */
2
+ import type { TenantConfig } from "./tenant.js";
3
+
4
+ export type DeclaredTenantConfigField = "siteType" | "compliance" | "capabilities" | "features";
5
+ export type DeclaredTenantConfig = Partial<Pick<TenantConfig, DeclaredTenantConfigField>>;
6
+
7
+ export const DECLARED_TENANT_CONFIG_FIELDS: readonly DeclaredTenantConfigField[];
8
+ export const TOT_CONFIG_KEYS: readonly string[];
9
+ export const COMPLIANCE_KEYS: readonly string[];
10
+ export const FEATURE_KEYS: readonly string[];
11
+ export const SITE_TYPES_LIST: readonly string[];
12
+ export function declaredTenantConfig(config: unknown): DeclaredTenantConfig;
13
+ export function isEmptyDeclaredTenantConfig(declared: DeclaredTenantConfig | null | undefined): boolean;
14
+ export function applyDeclaredTenantConfig<T extends Partial<Pick<TenantConfig, DeclaredTenantConfigField>>>(
15
+ tenant: T,
16
+ declared: DeclaredTenantConfig | null | undefined,
17
+ ): T;
18
+ export function readDeclaredTenantConfigAt(
19
+ source: { readFile(path: string, ref: string): Promise<string | null> },
20
+ ref: string,
21
+ ): Promise<DeclaredTenantConfig>;
22
+ export function declaredTenantConfigArtifactPath(tenantId: string): string;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The tenant's declared platform config: the `.tot/config.json` fields that become
3
+ * TenantConfig fields on every surface that renders the tenant.
4
+ *
5
+ * `.tot/config.json` is the ONE declared source for these fields. Every reader goes
6
+ * through this module:
7
+ * - `tot dev` and the static-publish build read the checkout's own file from the
8
+ * build tree (apps/storefront/src/config/resolver.ts `declaredConfigFor`);
9
+ * - the hosted Worker reads the per-environment artifact the reconcile derives from
10
+ * the same file at the reconciled commit (`declaredTenantConfigArtifactPath`);
11
+ * - `tot validate` refuses any top-level key not in `TOT_CONFIG_KEYS`, so a field
12
+ * no surface reads cannot pass validation.
13
+ * All of them apply it with `applyDeclaredTenantConfig`, so a field honored on one
14
+ * surface is honored on all of them.
15
+ *
16
+ * Plain JS on purpose (same reason as embed-catalog.mjs): plain-`node` tooling and
17
+ * the CLI's parity test read it alongside the TypeScript runtime.
18
+ */
19
+
20
+ /** `.tot/config.json` keys that become TenantConfig fields. */
21
+ export const DECLARED_TENANT_CONFIG_FIELDS = Object.freeze([
22
+ "siteType",
23
+ "compliance",
24
+ "capabilities",
25
+ "features",
26
+ ]);
27
+
28
+ /**
29
+ * Every top-level `.tot/config.json` key the platform reads: the reconcile mapping
30
+ * keys, the CLI's host hint, provenance markers (`sample` from `tot dev --sample`,
31
+ * `seed` from the tot-mcp repo seed), an editor's `$schema`, and the declared
32
+ * TenantConfig fields.
33
+ */
34
+ export const TOT_CONFIG_KEYS = Object.freeze([
35
+ "$schema",
36
+ "tenant",
37
+ "scope",
38
+ "mappings",
39
+ "hostPlatform",
40
+ "sample",
41
+ "seed",
42
+ ...DECLARED_TENANT_CONFIG_FIELDS,
43
+ ]);
44
+
45
+ /** Every `compliance` key the platform renders or enforces (ComplianceConfig, tenant.ts). */
46
+ export const COMPLIANCE_KEYS = Object.freeze([
47
+ "ruleProfile",
48
+ "minAge",
49
+ "nicotineWarning",
50
+ "shippingRestriction",
51
+ "pactAct",
52
+ "adultSignature",
53
+ "prop65",
54
+ "purchaseLimit",
55
+ "stateEligibility",
56
+ "exciseTax",
57
+ ]);
58
+
59
+ /** Every `features` key (TenantFeatures, tenant.ts). */
60
+ export const FEATURE_KEYS = Object.freeze([
61
+ "catalog",
62
+ "productSearch",
63
+ "savedItems",
64
+ "quickView",
65
+ "newsletter",
66
+ "commerceSitemap",
67
+ "productJsonLd",
68
+ "ageGate",
69
+ ]);
70
+
71
+ /** Every `siteType` value (SiteType, tenant.ts). */
72
+ export const SITE_TYPES_LIST = Object.freeze(["commerce", "marketing"]);
73
+
74
+ const SITE_TYPES = new Set(SITE_TYPES_LIST);
75
+
76
+ const isPlainObject = (value) =>
77
+ value !== null && typeof value === "object" && !Array.isArray(value);
78
+
79
+ /**
80
+ * The declared TenantConfig fields from a parsed `.tot/config.json`. A field whose
81
+ * value has the wrong shape is omitted, never coerced (`tot validate` reports it).
82
+ *
83
+ * @param {unknown} config
84
+ */
85
+ export function declaredTenantConfig(config) {
86
+ /** @type {Record<string, unknown>} */
87
+ const out = {};
88
+ if (!isPlainObject(config)) return out;
89
+ const { siteType, compliance, capabilities, features } = /** @type {Record<string, unknown>} */ (config);
90
+ if (typeof siteType === "string" && SITE_TYPES.has(siteType)) out.siteType = siteType;
91
+ if (isPlainObject(compliance)) out.compliance = compliance;
92
+ if (isPlainObject(capabilities)) out.capabilities = capabilities;
93
+ if (isPlainObject(features)) out.features = features;
94
+ return out;
95
+ }
96
+
97
+ /** True when the declaration sets nothing. */
98
+ export function isEmptyDeclaredTenantConfig(declared) {
99
+ return !declared || Object.keys(declared).length === 0;
100
+ }
101
+
102
+ /**
103
+ * Overlay a declaration onto a resolved tenant. `siteType` is replaced; the object
104
+ * fields merge key by key, so a declaration adds to — and can set individual keys
105
+ * of — what the platform record already carries, but never erases a key it omits.
106
+ *
107
+ * @template T
108
+ * @param {T} tenant
109
+ * @param {Record<string, unknown> | null | undefined} declared
110
+ * @returns {T}
111
+ */
112
+ export function applyDeclaredTenantConfig(tenant, declared) {
113
+ if (isEmptyDeclaredTenantConfig(declared)) return tenant;
114
+ const out = /** @type {Record<string, unknown>} */ ({ ...tenant });
115
+ for (const field of DECLARED_TENANT_CONFIG_FIELDS) {
116
+ const value = declared[field];
117
+ if (value === undefined) continue;
118
+ out[field] = isPlainObject(value) && isPlainObject(out[field])
119
+ ? { ...out[field], ...value }
120
+ : value;
121
+ }
122
+ return /** @type {T} */ (out);
123
+ }
124
+
125
+ /**
126
+ * The declaration a tenant repo commits at `ref`, read through any source with the
127
+ * reconcile's `readFile(path, ref)` shape. `{}` when the file is absent or unreadable.
128
+ *
129
+ * @param {{ readFile(path: string, ref: string): Promise<string | null> }} source
130
+ * @param {string} ref
131
+ */
132
+ export async function readDeclaredTenantConfigAt(source, ref) {
133
+ try {
134
+ const raw = await source.readFile(".tot/config.json", ref);
135
+ return raw ? declaredTenantConfig(JSON.parse(raw)) : {};
136
+ } catch {
137
+ return {};
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Artifact path of the declaration the reconcile derives by reading `.tot/config.json`,
143
+ * in the same colocated `tenants/<id>/…` space as theme/capabilities so it is
144
+ * pinned per version and resolved per environment like every other artifact.
145
+ *
146
+ * @param {string} tenantId
147
+ */
148
+ export function declaredTenantConfigArtifactPath(tenantId) {
149
+ return `tenants/${tenantId}/tenant-config.json`;
150
+ }
@@ -0,0 +1,109 @@
1
+ // @ts-check
2
+ /**
3
+ * Records which pid `tot dev` last bound a given port under, so a later
4
+ * invocation that finds the port busy can tell a STALE occupant (our own
5
+ * earlier `tot dev` process, no longer running — the socket just hasn't been
6
+ * released yet, or the process crashed without a clean shutdown) from a
7
+ * genuinely live one (a different, healthy server — ours or someone else's).
8
+ *
9
+ * Without this, `firstFreePort` silently drifts to the next port either way —
10
+ * fine for "someone else's server is using 4321", actively misleading after a
11
+ * crash: the developer's browser is still pointed at the old port, and nothing
12
+ * tells them the server they expect there isn't the one that just started
13
+ * (fb-1791304494289-ussj8l). Dependency-free (node:fs/os/path only) so it
14
+ * copies into the standalone runner like port-check.mjs.
15
+ */
16
+ import { mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ export const PORT_LOCK_DIR = join(homedir(), ".tot", "run", "dev-ports");
21
+
22
+ /** Record that `pid` (default: this process) is bound to `port`. Best-effort. */
23
+ export function lockPort(port, { pid = process.pid, dir = PORT_LOCK_DIR } = {}) {
24
+ try {
25
+ mkdirSync(dir, { recursive: true });
26
+ writeFileSync(join(dir, String(port)), String(pid));
27
+ } catch {
28
+ /* best-effort — a failed lock must never block `tot dev` from starting */
29
+ }
30
+ }
31
+
32
+ /** Release a port lock (best-effort; safe to call more than once). */
33
+ export function unlockPort(port, { dir = PORT_LOCK_DIR } = {}) {
34
+ try {
35
+ rmSync(join(dir, String(port)), { force: true });
36
+ } catch {
37
+ /* best-effort */
38
+ }
39
+ }
40
+
41
+ /** The pid last recorded as bound to `port`, or null when no lock is on file / unreadable. */
42
+ export function readPortLock(port, { dir = PORT_LOCK_DIR } = {}) {
43
+ try {
44
+ const pid = Number(readFileSync(join(dir, String(port)), "utf8").trim());
45
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /** Is a pid a live process? (signal 0 probe; EPERM = alive but not ours.) */
52
+ export function processAlive(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (e) {
57
+ return /** @type {NodeJS.ErrnoException} */ (e)?.code === "EPERM";
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Why is `requestedPort` unavailable?
63
+ * - `{ kind: "ours", pid }` — an EARLIER `tot dev` session still holds it and is
64
+ * still running. It may be wedged (a renderer crash leaves the server process
65
+ * alive and bound), so the developer must be told which process to stop.
66
+ * - `{ kind: "stale", pid }` — our own recorded occupant is gone; the socket is
67
+ * lingering past a crash/kill.
68
+ * - `{ kind: "other" }` — busy with no lock of ours: someone else's server.
69
+ * - `null` — the port isn't busy right now.
70
+ * @param {number} requestedPort
71
+ * @param {{ isFree: (port:number)=>Promise<boolean>, pidAlive?: (pid:number)=>boolean, dir?: string }} deps
72
+ * @returns {Promise<{kind:"ours"|"stale", pid:number}|{kind:"other"}|null>}
73
+ */
74
+ export async function classifyBusyPort(requestedPort, { isFree, pidAlive = processAlive, dir = PORT_LOCK_DIR }) {
75
+ if (await isFree(requestedPort)) return null;
76
+ const pid = readPortLock(requestedPort, { dir });
77
+ if (pid == null) return { kind: "other" };
78
+ return { kind: pidAlive(pid) ? "ours" : "stale", pid };
79
+ }
80
+
81
+ /**
82
+ * The line `tot dev` prints when it starts on `resolvedPort` because
83
+ * `requestedPort` was busy. Every case involving an earlier `tot dev` session
84
+ * says so and names the process, so a browser tab left on the old port is never
85
+ * silently a different server.
86
+ * @param {number} requestedPort
87
+ * @param {number} resolvedPort
88
+ * @param {{kind:"ours"|"stale", pid:number}|{kind:"other"}|null} busy
89
+ */
90
+ export function busyPortMessage(requestedPort, resolvedPort, busy) {
91
+ const tab = ` A browser tab on :${requestedPort} is NOT this session — use the URL below.`;
92
+ if (busy?.kind === "ours") {
93
+ return (
94
+ ` ⚠ port ${requestedPort} is held by an earlier \`tot dev\` session that is still running ` +
95
+ `(pid ${busy.pid}) — starting on ${resolvedPort} instead.\n` +
96
+ ` If that session crashed or stopped reloading, stop it (\`kill ${busy.pid}\`) and restart to get ${requestedPort} back.\n` +
97
+ tab
98
+ );
99
+ }
100
+ if (busy?.kind === "stale") {
101
+ return (
102
+ ` ⚠ port ${requestedPort} is still held after an earlier \`tot dev\` session (pid ${busy.pid}) exited ` +
103
+ `— starting on ${resolvedPort} instead.\n` +
104
+ ` Find the holder with \`lsof -nP -iTCP:${requestedPort} -sTCP:LISTEN\` and stop it to get ${requestedPort} back.\n` +
105
+ tab
106
+ );
107
+ }
108
+ return ` ⚠ port ${requestedPort} was busy — starting on ${resolvedPort} instead.`;
109
+ }
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+ /**
3
+ * Pure planning for `tot dev --workspace` grafts: which checkout-relative paths
4
+ * get copied into the in-tree `tenants/<id>/` layout the app globs, and whether
5
+ * each is a file or a directory tree. Decision-only — scripts/tot-dev.mjs does
6
+ * the actual fs copy/backup/watch effects per planned entry.
7
+ */
8
+
9
+ /**
10
+ * @param {Array<{workspace?: unknown, kind?: unknown}>} mappings the checkout's
11
+ * own `.tot/config.json` `mappings` array (content/, public/, theme.json, …)
12
+ * @returns {{ workspace: string, isDir: boolean }[]}
13
+ */
14
+ export function standaloneGraftPlan(mappings) {
15
+ const plan = [];
16
+ const seen = new Set();
17
+ for (const map of Array.isArray(mappings) ? mappings : []) {
18
+ const workspace = String(map?.workspace ?? "").replace(/\/+$/, "");
19
+ if (!workspace || seen.has(workspace)) continue;
20
+ seen.add(workspace);
21
+ plan.push({ workspace, isDir: map?.kind !== "file" });
22
+ }
23
+ // `.tot/config.json` is never itself one of the declared `mappings` (those map
24
+ // content/public/theme.json) but IS the tenant's own declared platform config
25
+ // — siteType/compliance/capabilities/features (see
26
+ // apps/storefront/src/config/devTenantSeed.ts TOT_CONFIG_TENANT_FIELDS and
27
+ // resolver.ts declaredConfigs()) — the same file
28
+ // scripts/tenant/materialize-tenant-content.mjs copies for a Gitea-sourced
29
+ // tenant. Graft it unconditionally so a regulated store's mandated notices
30
+ // resolve from the SAME declared config locally as they would once published.
31
+ if (!seen.has(".tot/config.json")) {
32
+ plan.push({ workspace: ".tot/config.json", isDir: false });
33
+ }
34
+ return plan;
35
+ }
@@ -63,8 +63,10 @@ import { dirname, resolve, join } from "node:path";
63
63
  import { fileURLToPath } from "node:url";
64
64
  import { resolveDevSourceRoot } from "./dev/tenant-source-root.mjs";
65
65
  import { tenantDirRelative, tenantDirSegments } from "./lib/tenant-dirs.mjs";
66
- import { firstFreePort } from "./dev/port-check.mjs";
66
+ import { firstFreePort, isPortFree } from "./dev/port-check.mjs";
67
+ import { busyPortMessage, classifyBusyPort, lockPort, unlockPort } from "./dev/port-lock.mjs";
67
68
  import { resync, startWatch } from "./dev/checkout-watch.mjs";
69
+ import { standaloneGraftPlan } from "./dev/standalone-graft-plan.mjs";
68
70
  import { findMissingCtaIds } from "./tenant/validate.mjs";
69
71
 
70
72
  const here = dirname(fileURLToPath(import.meta.url));
@@ -273,25 +275,18 @@ if (workspacePath) {
273
275
  console.log(`\n tot dev — standalone (${tenant})`);
274
276
  console.log(` checkout: ${wsRoot}`);
275
277
 
276
- for (const map of mappings) {
277
- // `workspace` is the checkout-relative prefix (e.g. "content/", "theme.json").
278
- // COLOCATION: a tenant IS the repo-root dir `tenants/<id>/`, and the checkout
279
- // (a `git subtree split --prefix=tenants/<id>` of that dir) has the SAME layout
280
- // at its root — so the graft is an identity map: content/ -> tenants/<id>/content/,
281
- // public/ -> tenants/<id>/public/, theme.json -> tenants/<id>/theme.json. We map
282
- // structurally (ignoring any legacy `repo` field in the checkout's .tot/config.json).
283
- const wsRel = String(map.workspace ?? "").replace(/\/+$/, "");
284
- if (!wsRel) continue;
278
+ /**
279
+ * Graft one checkout path (`wsRel`, checkout-relative) into the in-tree
280
+ * `tenants/<tenant>/` layout the app globs, with the same move-aside/restore +
281
+ * watch-and-resync machinery every mapping gets. Returns true if grafted.
282
+ */
283
+ function graftOne(wsRel, isDir) {
285
284
  const repoRel = join(tenantDirRelative(tenant), wsRel);
286
285
 
287
286
  const source = resolve(wsRoot, wsRel); // real files in the checkout
288
287
  const target = resolve(repoRoot, repoRel); // where the app globs them
289
- const isDir = map.kind !== "file";
290
288
 
291
- if (!existsSync(source)) {
292
- console.warn(` ⚠ checkout has no ${wsRel} — skipping (${map.kind ?? "?"})`);
293
- continue;
294
- }
289
+ if (!existsSync(source)) return false;
295
290
 
296
291
  // Ensure the destination's parent exists (e.g. content/ or themes/tenants/).
297
292
  mkdirSync(dirname(target), { recursive: true });
@@ -322,9 +317,31 @@ if (workspacePath) {
322
317
  }
323
318
 
324
319
  console.log(` ↪ ${repoRel} ⇐ ${wsRel}${backup ? " (in-tree content backed up)" : ""}`);
320
+ return true;
325
321
  }
326
322
 
327
- if (grafts.length === 0) {
323
+ // `workspace` is the checkout-relative prefix (e.g. "content/", "theme.json").
324
+ // COLOCATION: a tenant IS the repo-root dir `tenants/<id>/`, and the checkout
325
+ // (a `git subtree split --prefix=tenants/<id>` of that dir) has the SAME layout
326
+ // at its root — so the graft is an identity map: content/ -> tenants/<id>/content/,
327
+ // public/ -> tenants/<id>/public/, theme.json -> tenants/<id>/theme.json. We map
328
+ // structurally (ignoring any legacy `repo` field in the checkout's .tot/config.json).
329
+ // standaloneGraftPlan ALSO appends `.tot/config.json` unconditionally — see its
330
+ // own doc comment. Its success does NOT count toward "did we graft a usable
331
+ // site" below: a checkout with config but no content/public/theme is still
332
+ // nothing to serve.
333
+ let mappedCount = 0;
334
+ for (const { workspace, isDir } of standaloneGraftPlan(mappings)) {
335
+ const grafted = graftOne(workspace, isDir);
336
+ if (workspace === ".tot/config.json") {
337
+ if (!grafted) console.warn(` ⚠ checkout has no .tot/config.json — compliance/capabilities/siteType resolve undeclared.`);
338
+ continue;
339
+ }
340
+ if (grafted) mappedCount++;
341
+ else console.warn(` ⚠ checkout has no ${workspace} — skipping`);
342
+ }
343
+
344
+ if (mappedCount === 0) {
328
345
  console.error(`✗ nothing to graft — the checkout had none of the mapped prefixes.`);
329
346
  cleanupGrafts();
330
347
  process.exit(1);
@@ -354,12 +371,19 @@ if (workspacePath) {
354
371
  // which then mismatches the URL we (and the CLI's liveness poll) advertise.
355
372
  // Instead we pick the first free port ourselves and PRINT it, so a busy default
356
373
  // port "just works" (better UX than failing). See port-check.mjs.
374
+ //
375
+ // BEFORE drifting, classify WHY the requested port is busy (port-lock.mjs). When
376
+ // an earlier `tot dev` session holds it — still running but possibly wedged after
377
+ // a renderer crash, or exited with the socket lingering — the developer's browser
378
+ // tab on that port is NOT this session, so say so and name the process to stop.
357
379
  const requestedPort = Number(flag("port", "4321"));
380
+ const busy = await classifyBusyPort(requestedPort, { isFree: isPortFree });
358
381
  const resolvedPort = await firstFreePort(requestedPort);
359
382
  const port = String(resolvedPort);
360
383
  if (resolvedPort !== requestedPort) {
361
- console.log(` ⚠ port ${requestedPort} was busy — starting on ${resolvedPort} instead.`);
384
+ console.log(busyPortMessage(requestedPort, resolvedPort, busy));
362
385
  }
386
+ lockPort(resolvedPort);
363
387
 
364
388
  // Land the developer on their store ROOT (plain save→reload HMR against their own
365
389
  // site) — not the /dev cockpit, which is a hosted-only surface excluded from the
@@ -429,15 +453,20 @@ const child = spawn(
429
453
  { cwd: appDir, stdio: "inherit", env: spawnEnv },
430
454
  );
431
455
 
432
- // Clean up grafts on every exit path so we never leave symlinks in the tree.
456
+ // Clean up grafts + the port lock on every exit path so we never leave
457
+ // symlinks in the tree, or a lock a LATER run would misread as still live.
433
458
  function shutdown(signal) {
434
459
  child.kill(signal);
435
460
  }
436
- child.on("exit", (code) => {
461
+ function cleanupAll() {
437
462
  cleanupGrafts();
463
+ unlockPort(resolvedPort);
464
+ }
465
+ child.on("exit", (code) => {
466
+ cleanupAll();
438
467
  process.exit(code ?? 0);
439
468
  });
440
469
  process.on("SIGINT", () => shutdown("SIGINT"));
441
470
  process.on("SIGTERM", () => shutdown("SIGTERM"));
442
471
  // Belt-and-suspenders: if the parent dies without the child exit firing.
443
- process.on("exit", cleanupGrafts);
472
+ process.on("exit", cleanupAll);