@tokenoftrust/storefront-runner 3.10.4 → 3.11.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.
@@ -25,6 +25,7 @@
25
25
  import { useCallback, useEffect, useRef, useState } from "preact/hooks";
26
26
  import type { CartComplianceDecision } from "@tot/public-runtime";
27
27
  import {
28
+ bypassesComplianceGate,
28
29
  clearStoredRef,
29
30
  isStoreNotPublicRefusal,
30
31
  parsePreflightResponse,
@@ -118,6 +119,8 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
118
119
  const pollToken = useRef<{ cancelled: boolean } | null>(null);
119
120
  /** Set by the last preflight POST when the store-not-public gate answered it. */
120
121
  const gateRefused = useRef(false);
122
+ /** `proceed`, reachable from the submit listener registered before it is defined. */
123
+ const proceedRef = useRef<((line: PendingLine, ref: string | null) => void) | null>(null);
121
124
 
122
125
  const stopPolling = useCallback(() => {
123
126
  if (pollToken.current) pollToken.current.cancelled = true;
@@ -144,6 +147,32 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
144
147
  quantity,
145
148
  title: form.dataset.productTitle ?? "this item",
146
149
  };
150
+ // Whether the store asks for this step is the server's call at click time, read from the
151
+ // store's current settings: a static page carries no decision frozen at build time. Only a
152
+ // readable "not-required" + clear answer skips the dialog; anything else opens it.
153
+ void probe(line).then((res) => {
154
+ if (bypassesComplianceGate(res)) proceedRef.current?.(line, null);
155
+ else open(line);
156
+ });
157
+ }
158
+ async function probe(line: PendingLine): Promise<PreflightResponse | null> {
159
+ try {
160
+ const res = await fetch(preflightUrl, {
161
+ method: "POST",
162
+ headers: { "content-type": "application/json" },
163
+ body: JSON.stringify({
164
+ lines: [{ handle: line.handle, sku: line.sku, variantId: line.variantId, quantity: line.quantity }],
165
+ destination: null,
166
+ ageAffirmed: false,
167
+ }),
168
+ });
169
+ if (!res.ok) return null;
170
+ return parsePreflightResponse(await res.json());
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+ function open(line: PendingLine) {
147
176
  setPending(line);
148
177
  setDecision(null);
149
178
  setError(null);
@@ -200,6 +229,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
200
229
  if (typeof line.form.requestSubmit === "function") line.form.requestSubmit();
201
230
  else line.form.submit();
202
231
  }, [preflightUrl, stopPolling]);
232
+ proceedRef.current = proceed;
203
233
 
204
234
  /**
205
235
  * One preflight POST. `null` for a transport error or any body we cannot fully
@@ -3,16 +3,31 @@
3
3
  type StatusState = "ok" | "err";
4
4
 
5
5
  interface SaveResponse {
6
+ readBack?: string;
6
7
  notice?: string;
7
8
  error?: string;
8
9
  errors?: unknown;
9
10
  }
10
11
 
12
+ export const SAVED_IN_EFFECT = "Saved. In effect on your preview and live store.";
13
+
11
14
  export function saveFailureMessage(status: number, data: SaveResponse): string {
12
15
  if (Array.isArray(data.errors) && data.errors.length) return data.errors.join(" ");
13
16
  return data.error || `Save failed (${status})`;
14
17
  }
15
18
 
19
+ /**
20
+ * What the form reports for a save response. "Saved" only when the server read the save back
21
+ * through the readers' own resolver (`readBack: "in-effect"`); an accepted save the edge has not
22
+ * picked up yet reports its notice without the ok state.
23
+ */
24
+ export function saveResultStatus(httpOk: boolean, status: number, data: SaveResponse): { message: string; state?: StatusState } {
25
+ if (!httpOk) return { message: saveFailureMessage(status, data), state: "err" };
26
+ if (data.readBack === "in-effect") return { message: SAVED_IN_EFFECT, state: "ok" };
27
+ if (data.readBack === "pending") return { message: data.notice || "Accepted. Reload to confirm it is in effect." };
28
+ return { message: "The save could not be confirmed in effect. Reload to check.", state: "err" };
29
+ }
30
+
16
31
  export function mountCheckoutRequirementsForm(form: HTMLElement): void {
17
32
  const endpoint = form.dataset.endpoint || "/api/preview/checkout-requirements";
18
33
  const targetTenant = form.dataset.targetTenant || "";
@@ -43,8 +58,8 @@ export function mountCheckoutRequirementsForm(form: HTMLElement): void {
43
58
  } catch {
44
59
  // non-JSON error body
45
60
  }
46
- if (res.ok) setStatus(data.notice || "Saved to test", "ok");
47
- else setStatus(saveFailureMessage(res.status, data), "err");
61
+ const result = saveResultStatus(res.ok, res.status, data);
62
+ setStatus(result.message, result.state);
48
63
  } catch {
49
64
  setStatus("Save failed — network error", "err");
50
65
  } finally {
@@ -3,13 +3,14 @@
3
3
  * /admin → Settings → Checkout requirements.
4
4
  *
5
5
  * They are merchant-operated commerce settings (ADR 0035 "operational"), saved as the
6
- * tenant-owned artifact `tenants/<id>/checkout-requirements.json` on the publish spine — saved
7
- * to `test`, promoted to `live` with the rest of the store. Every switch defaults off and can
8
- * only make checkout stricter, so no write can weaken what the platform enforces. They apply
9
- * only while the store checks out on the shared test store; a store with its own checkout store,
10
- * and production, keep the full fail-closed contract regardless of what is saved here.
6
+ * tenant-owned artifact `tenants/<id>/checkout-requirements.json` to the store's operational
7
+ * selection. A save is in effect at once on the store's preview AND its live store — every
8
+ * reader resolves it through `resolveArtifactSelection`, with no promote. Every switch defaults
9
+ * off and can only make checkout stricter, so no write can weaken what the platform enforces.
10
+ * They apply only while the store checks out on the shared test store; a store with its own
11
+ * checkout store, and production, keep the full fail-closed contract regardless of what is saved here.
11
12
  */
12
- import { readPublishedArtifact, type CustomizationEnv } from "@tot/public-runtime";
13
+ import { operationalSettingArtifactPath, readPublishedArtifact, type CustomizationEnv } from "@tot/public-runtime";
13
14
  import { readKv } from "@/lib/publication/shared/runtimeEnv";
14
15
 
15
16
  export interface CheckoutRequirements {
@@ -30,7 +31,7 @@ export const DEFAULT_CHECKOUT_REQUIREMENTS: Readonly<CheckoutRequirements> = Obj
30
31
  });
31
32
 
32
33
  export function checkoutRequirementsArtifactPath(tenantId: string): string {
33
- return `tenants/${tenantId}/checkout-requirements.json`;
34
+ return operationalSettingArtifactPath(tenantId, "checkout-requirements.json");
34
35
  }
35
36
 
36
37
  /**
@@ -82,14 +82,10 @@ export async function usesSharedTestStore(commerce: { storeSubDomain?: string }
82
82
  }
83
83
 
84
84
  /**
85
- * Whether the storefront's checkout compliance gate runs before handoff. It follows the shared
86
- * test store's policy for a tenant checking out there; every other checkout keeps the gate.
85
+ * Whether the storefront's pre-cart compliance step runs, for a resolved policy. It follows the
86
+ * shared test store's policy for a tenant checking out there; `null` (every other checkout) keeps
87
+ * it. Decided per request by `/api/compliance/preflight`, never at page build time.
87
88
  */
88
- export async function checkoutComplianceGateRequired(
89
- tenant: Pick<TenantConfig, "tenant_id">,
90
- commerce: { storeSubDomain?: string } | null | undefined,
91
- env: CustomizationEnv,
92
- ): Promise<boolean> {
93
- const policy = await sharedStorePolicy(tenant, commerce, env);
89
+ export function complianceGateRequiredFor(policy: SharedStoreEvidencePolicy | null): boolean {
94
90
  return !policy || policy.rules === "enforce" || policy.verification === "required";
95
91
  }
@@ -52,6 +52,11 @@ export interface PreflightResponse {
52
52
  decision: CartComplianceDecision;
53
53
  /** Present only on the live-provider path; `null` on sandbox / no provider. */
54
54
  verification: LiveVerificationMeta | null;
55
+ /**
56
+ * Whether this store requires the pre-cart compliance step, decided by the server from the
57
+ * store's current settings at click time. Absent is read as `"required"`.
58
+ */
59
+ gate?: "required" | "not-required";
55
60
  }
56
61
 
57
62
  /**
@@ -117,9 +122,18 @@ export function parsePreflightResponse(raw: unknown): PreflightResponse | null {
117
122
  return {
118
123
  decision: r.decision,
119
124
  verification: "verification" in r ? parseVerificationMeta(r.verification) : null,
125
+ gate: r.gate === "not-required" ? "not-required" : "required",
120
126
  };
121
127
  }
122
128
 
129
+ /**
130
+ * True when a click-time probe lets the item go straight to the cart: the store does not require
131
+ * the pre-cart step and the cart is already clear. Anything else, including no answer, opens it.
132
+ */
133
+ export function bypassesComplianceGate(res: PreflightResponse | null): boolean {
134
+ return res !== null && res.gate === "not-required" && res.decision.status === "clear";
135
+ }
136
+
123
137
  /** True when the decision carries the provider's real "did not pass" block. */
124
138
  function hasVerificationFailure(decision: CartComplianceDecision): boolean {
125
139
  return decision.blocks.some(
@@ -34,7 +34,7 @@ import {
34
34
  type TotVerificationClient,
35
35
  type TotVerificationStatus,
36
36
  } from "@/lib/compliance/verification/totVerificationClient";
37
- import { sharedStorePolicy } from "@/lib/checkout/commerce/sharedStoreAdmission";
37
+ import { complianceGateRequiredFor, sharedStorePolicy } from "@/lib/checkout/commerce/sharedStoreAdmission";
38
38
 
39
39
  export const prerender = false;
40
40
 
@@ -282,9 +282,12 @@ export async function POST(context: APIContext): Promise<Response> {
282
282
  }
283
283
 
284
284
  // The shared test store applies the store's own saved requirements, computed here from the
285
- // tenant's config — never from the request, so a shopper cannot waive one.
285
+ // tenant's config — never from the request, so a shopper cannot waive one. `gate` tells the
286
+ // product page's checkout gate, at click time, whether the store asks for the pre-cart dialog
287
+ // at all, so a static page never carries a decision frozen at build time.
286
288
  const policy = await sharedStorePolicy(context.locals.tenant, context.locals.commerce, context.locals.customizationEnv);
287
- return json(await evaluatePreflight(context, body, policy ? { verification: policy.verification, rules: policy.rules } : {}));
289
+ const gate = complianceGateRequiredFor(policy) ? "required" : "not-required";
290
+ return json({ ...(await evaluatePreflight(context, body, policy ? { verification: policy.verification, rules: policy.rules } : {})), gate });
288
291
  }
289
292
 
290
293
  export interface PreflightOptions {
@@ -44,7 +44,6 @@ import type {
44
44
  import { resolveCartSigning, shopperCheckoutUnavailableHtml } from "@/lib/checkout/shared/checkoutCommerce";
45
45
  import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
46
46
  import { isRegulatedTenant } from "@/lib/compliance/enforcement";
47
- import { checkoutComplianceGateRequired } from "@/lib/checkout/commerce/sharedStoreAdmission";
48
47
  import { resolveMemberContext } from "@/lib/membership/eligibility";
49
48
  import { truncate } from "@/lib/content/shared/format";
50
49
  import { productLd, breadcrumbLd, reviewLd } from "@/lib/content/shared/jsonld";
@@ -240,7 +239,6 @@ let checkoutCart:
240
239
  | undefined;
241
240
  let checkoutUnavailable = false;
242
241
  const sampleHandoff = isLocalSampleCheckout(tenant.tenant_id);
243
- const complianceGateRequired = await checkoutComplianceGateRequired(tenant, commerce, Astro.locals.customizationEnv);
244
242
  if (commerce || sampleHandoff) {
245
243
  // Fail closed for production signed checkout: a signed tenant with no
246
244
  // cart-validation secret builds NO cart (never a tamperable form). See
@@ -434,9 +432,10 @@ const structuredData = {
434
432
  />
435
433
  {/* Regulated-tenant checkout ENFORCEMENT gate. Intercepts the
436
434
  managed-cart handoff to clear age + ship-to compliance server-side
437
- before the external checkout. Only mounts when a store is wired, the
438
- tenant is regulated, and that store's policy requires a check. */}
439
- {checkoutCart && !sampleHandoff && isRegulatedTenant(tenant) && compliance?.minAge && complianceGateRequired && (
435
+ before the external checkout. Mounts when a store is wired and the
436
+ tenant is regulated; whether the store's policy asks for the dialog
437
+ is answered by preflight at click time, never frozen into this page. */}
438
+ {checkoutCart && !sampleHandoff && isRegulatedTenant(tenant) && compliance?.minAge && (
440
439
  <CheckoutComplianceGate
441
440
  client:load
442
441
  preflightUrl={withBase(apiBasePath, "/api/compliance/preflight")}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.10.4",
3
+ "version": "3.11.0",
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",
@@ -9,8 +9,9 @@
9
9
  import { readdirSync, readFileSync } from "node:fs";
10
10
  import { mkdir, writeFile } from "node:fs/promises";
11
11
  import { dirname, relative, resolve } from "node:path";
12
- import { fileURLToPath, pathToFileURL } from "node:url";
12
+ import { fileURLToPath } from "node:url";
13
13
 
14
+ import { isMainModule } from "../../../scripts/lib/is-main-module.mjs";
14
15
  const here = dirname(fileURLToPath(import.meta.url));
15
16
  const repoRoot = resolve(here, "../../..");
16
17
  export const OUTPUT_DIR = relative(repoRoot, resolve(here, "../schemas/managed-analytics/v1"));
@@ -92,6 +93,6 @@ async function write() {
92
93
  for (const [path, text] of files) await writeFile(resolve(repoRoot, path), text, "utf8");
93
94
  }
94
95
 
95
- if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
96
+ if (isMainModule(import.meta.url)) {
96
97
  await (process.argv.includes("--check") ? check() : write());
97
98
  }
@@ -61,6 +61,14 @@ const HIGH_VOLUME: readonly ActivitySink[] = ["logs", "analytics"];
61
61
  const LIFECYCLE: readonly ActivitySink[] = ["logs", "timeline"];
62
62
  const PROVENANCE: readonly ActivitySink[] = ["logs", "analytics", "timeline"];
63
63
 
64
+ // What an operational-setting save read back: which selection each env's reader resolved
65
+ // (`readBackDiagnostics` in the storefront's operational-read-back.ts emits exactly these).
66
+ const OPERATIONAL_READ_BACK_ARGS = [
67
+ "readBack",
68
+ "testSource", "testVersionId", "testMatches", "testRev",
69
+ "liveSource", "liveVersionId", "liveMatches", "liveRev",
70
+ ] as const;
71
+
64
72
  /**
65
73
  * THE CATALOG. Add a new action by adding a key here (and its JS mirror for the
66
74
  * CLI).
@@ -320,14 +328,14 @@ export const ACTION_CATALOG = {
320
328
  // route's own auth (resolveOwnerSession): a merchant saving their own tenant, or
321
329
  // a ToT-staff operator "operating as" another tenant via the admin picker.
322
330
  actors: ["merchant", "admin"], source: "server", sinks: LIFECYCLE,
323
- argsAllow: ["versionId", "scope"], renderedAllow: [],
331
+ argsAllow: ["versionId", "scope", ...OPERATIONAL_READ_BACK_ARGS], renderedAllow: [],
324
332
  desc: "A tenant's checkout styling was saved to a version.",
325
333
  },
326
334
  "content.checkout_requirements.saved": {
327
335
  // A store's shared-test-store checkout requirements (Settings → Checkout requirements)
328
- // were saved to a versioned test artifact. Same actors and spine as checkout styling.
336
+ // were saved to the store's operational selection. Same actors, spine and read-back args as checkout styling.
329
337
  actors: ["merchant", "admin"], source: "server", sinks: LIFECYCLE,
330
- argsAllow: ["versionId", "scope"], renderedAllow: [],
338
+ argsAllow: ["versionId", "scope", ...OPERATIONAL_READ_BACK_ARGS], renderedAllow: [],
331
339
  desc: "A store's checkout requirements were saved to a version.",
332
340
  },
333
341
  "change.go_live": {
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import type { CustomizationEnv } from "./customization-preview.js";
22
22
  import type { DeepPartial, ThemeTokens } from "./tenant.js";
23
- import { readPublishedArtifact, type KvGet } from "./customization-runtime.js";
23
+ import { operationalSettingArtifactPath, readPublishedArtifact, type KvGet } from "./customization-runtime.js";
24
24
 
25
25
  /**
26
26
  * Color levers exposed on the checkout page. Deliberately a small, opinionated
@@ -187,7 +187,7 @@ function fontStackFromFamily(family: unknown, fallback: string): string {
187
187
  * per environment.
188
188
  */
189
189
  export function checkoutStyleArtifactPath(tenantId: string): string {
190
- return `tenants/${tenantId}/checkout-style.json`;
190
+ return operationalSettingArtifactPath(tenantId, "checkout-style.json");
191
191
  }
192
192
 
193
193
  /**
@@ -79,6 +79,67 @@ export function derivedArtifactPath(tenantId: string, name: string): string {
79
79
  /** The derived catalog listing projection (`catalogListingProjection` in the kit's catalog module). */
80
80
  export const CATALOG_LISTING_ARTIFACT = "catalog-listing.json";
81
81
 
82
+ /**
83
+ * Operational settings (ADR 0035): records a merchant edits from /admin, in effect with no
84
+ * deploy and no promote. Each is saved to the store's operational selection (the pointer's
85
+ * per-path `test` selection) and read from there for EVERY env and channel, so a save is what the
86
+ * preview, the live store and the final-cart authority all read. A path not listed here is
87
+ * repo-authored content: a channel pointer, when engaged, owns it.
88
+ */
89
+ export const OPERATIONAL_SETTING_FILES = Object.freeze(["checkout-requirements.json", "checkout-style.json"] as const);
90
+ export type OperationalSettingFile = (typeof OPERATIONAL_SETTING_FILES)[number];
91
+
92
+ export function operationalSettingArtifactPath(tenantId: string, file: OperationalSettingFile): string {
93
+ return `tenants/${tenantId}/${file}`;
94
+ }
95
+
96
+ export function isOperationalSettingPath(path: string): boolean {
97
+ const match = /^tenants\/[^/]+\/([^/]+)$/.exec(path);
98
+ return match?.[1] !== undefined && (OPERATIONAL_SETTING_FILES as readonly string[]).includes(match[1]);
99
+ }
100
+
101
+ /**
102
+ * Which selection a reader of (env, path) consults on `pointer`:
103
+ * - `operational`: an operational setting the store has saved — the same selection for every env;
104
+ * - `channel`: the channel's immutable site version (authoritative once engaged);
105
+ * - `legacy`: the per-path selection of a tenant not cut over to channels.
106
+ * `null` when nothing is selected. Readers ({@link resolvePublishedArtifactKey}) and the writer
107
+ * (`publishToTest`, which refuses a save this does not select) both resolve through it.
108
+ */
109
+ export type ArtifactSelection =
110
+ | { source: "operational"; versionId: string }
111
+ | { source: "channel"; channel: ChannelName }
112
+ | { source: "legacy"; env: CustomizationEnv; versionId: string };
113
+
114
+ export function resolveArtifactSelection(
115
+ pointer: PointerRecord,
116
+ env: CustomizationEnv,
117
+ path: string,
118
+ ): ArtifactSelection | null {
119
+ if (isOperationalSettingPath(path)) {
120
+ const versionId = pointer.test?.[path];
121
+ if (versionId) return { source: "operational", versionId };
122
+ }
123
+ const channel = channelFromEnv(env);
124
+ if (pointer.channels?.[channel]) return { source: "channel", channel };
125
+ const versionId = pointer[env]?.[path];
126
+ return versionId ? { source: "legacy", env, versionId } : null;
127
+ }
128
+
129
+ /**
130
+ * The selection {@link resolvePublishedArtifactKey} consults for (tenant, env, path), read fresh,
131
+ * with the revision of the pointer record it was resolved from (`null` when there is no record).
132
+ */
133
+ export async function readArtifactSelection(
134
+ get: KvGet,
135
+ tenantId: string,
136
+ env: CustomizationEnv,
137
+ path: string,
138
+ ): Promise<{ rev: number; selection: ArtifactSelection | null } | null> {
139
+ const pointer = await readPointer(get, tenantId);
140
+ return pointer ? { rev: pointer.rev, selection: resolveArtifactSelection(pointer, env, path) } : null;
141
+ }
142
+
82
143
  async function readPointer(
83
144
  get: KvGet,
84
145
  tenantId: string,
@@ -269,7 +330,9 @@ export async function readVersionedArtifact(
269
330
  * content). Never throws on malformed data — returns null so the caller falls
270
331
  * back to the build-time artifact.
271
332
  *
272
- * Resolution is versioned-first with a legacy fallback:
333
+ * Resolution follows {@link resolveArtifactSelection}:
334
+ * - An operational setting the store has saved resolves to that saved version
335
+ * for every env and channel.
273
336
  * - If the tenant has a channel pointer for the resolved channel
274
337
  * (`channelFromEnv(env)`), resolve via the immutable site version. A channel
275
338
  * pointer is AUTHORITATIVE — if the snapshot omits this path we return null
@@ -298,24 +361,18 @@ export async function resolvePublishedArtifactKey(
298
361
  env: CustomizationEnv,
299
362
  path: string,
300
363
  ): Promise<string | null> {
301
- const pointer = await readPointer(get, tenantId);
302
- if (!pointer) return null;
364
+ const selection = (await readArtifactSelection(get, tenantId, env, path))?.selection;
365
+ if (!selection) return null;
366
+ if (selection.source !== "channel") return customizationArtifactKey(tenantId, selection.versionId);
303
367
 
304
- const channel = channelFromEnv(env);
305
- if (pointer.channels?.[channel]) {
306
- // Versioned model engaged for this channel — authoritative. The shared preview renders a
307
- // version's captured images the way a pinned preview of it does; `live` never reads the
308
- // overlay (its public path is the published bundle, which carries its own rewrites).
309
- const { versionId } = await resolveServableChannelVersion(get, tenantId, channel);
310
- if (!versionId) return null;
311
- return channel === "preview"
312
- ? resolveCapturedVersionArtifactKey(get, tenantId, versionId, path)
313
- : resolveVersionArtifactKey(get, tenantId, versionId, path);
314
- }
315
-
316
- // Legacy per-path selection.
317
- const versionId = pointer?.[env]?.[path];
318
- return versionId ? customizationArtifactKey(tenantId, versionId) : null;
368
+ // Versioned model engaged for this channel — authoritative. The shared preview renders a
369
+ // version's captured images the way a pinned preview of it does; `live` never reads the
370
+ // overlay (its public path is the published bundle, which carries its own rewrites).
371
+ const { versionId } = await resolveServableChannelVersion(get, tenantId, selection.channel);
372
+ if (!versionId) return null;
373
+ return selection.channel === "preview"
374
+ ? resolveCapturedVersionArtifactKey(get, tenantId, versionId, path)
375
+ : resolveVersionArtifactKey(get, tenantId, versionId, path);
319
376
  }
320
377
 
321
378
  /**
@@ -43,6 +43,7 @@
43
43
  */
44
44
  import { execFileSync } from "node:child_process";
45
45
 
46
+ import { isMainModule } from "../lib/is-main-module.mjs";
46
47
  /** Umbrella branch this epic folds into. */
47
48
  export const DEFAULT_UMBRELLA = "feature/8500-storefront-integration";
48
49
 
@@ -493,6 +494,6 @@ function renderReport(r) {
493
494
  }
494
495
 
495
496
  // Run only when invoked directly (not when imported by the test).
496
- if (import.meta.url === `file://${process.argv[1]}`) {
497
+ if (isMainModule(import.meta.url)) {
497
498
  main();
498
499
  }
@@ -0,0 +1,20 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * Whether the module at `moduleUrl` is the script node was asked to run. Compared by real path on
6
+ * both sides: node reports `import.meta.url` resolved through symlinks but leaves `process.argv[1]`
7
+ * as typed, so a plain comparison is false whenever the script is reached through a symlink, and
8
+ * then main never runs and the process exits 0 having done nothing.
9
+ * @param {string} moduleUrl
10
+ * @param {string | undefined} [argv1]
11
+ * @returns {boolean}
12
+ */
13
+ export function isMainModule(moduleUrl, argv1 = process.argv[1]) {
14
+ if (!argv1) return false;
15
+ try {
16
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import { readdirSync, readFileSync, statSync } from "node:fs";
28
28
  import { join, extname } from "node:path";
29
+ import { isMainModule } from "../lib/is-main-module.mjs";
29
30
  import { EMBED_PROVIDERS } from "../../packages/public-runtime/src/embed-catalog.mjs";
30
31
 
31
32
  /**
@@ -169,4 +170,4 @@ function main() {
169
170
  }
170
171
 
171
172
  // Only run main when invoked directly, not when imported by the parity test.
172
- if (import.meta.url === `file://${process.argv[1]}`) main();
173
+ if (isMainModule(import.meta.url)) main();
@@ -69,7 +69,8 @@ import {
69
69
  import { execFileSync } from "node:child_process";
70
70
  import { tmpdir } from "node:os";
71
71
  import { dirname, join, resolve, sep } from "node:path";
72
- import { fileURLToPath, pathToFileURL } from "node:url";
72
+ import { fileURLToPath } from "node:url";
73
+ import { isMainModule } from "../lib/is-main-module.mjs";
73
74
  import { tenantDirRelative } from "../lib/tenant-dirs.mjs";
74
75
  import { parseMappingTarget } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
75
76
 
@@ -648,9 +649,7 @@ function main() {
648
649
  }
649
650
  }
650
651
 
651
- const invokedDirectly =
652
- process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
653
- if (invokedDirectly) {
652
+ if (isMainModule(import.meta.url)) {
654
653
  try {
655
654
  main();
656
655
  } catch (e) {
@@ -28,6 +28,7 @@
28
28
  import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
29
29
  import { dirname, resolve, join, relative } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
+ import { isMainModule } from "../lib/is-main-module.mjs";
31
32
  import { KNOWN_EMBEDS, lintTenantEmbeds } from "./lint-embeds.mjs";
32
33
  import { listTenantDirIds, tenantDirSegments } from "../lib/tenant-dirs.mjs";
33
34
  import {
@@ -352,7 +353,6 @@ function validateThemeHeadings(theme, file) {
352
353
  return out;
353
354
  }
354
355
 
355
-
356
356
  const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
357
357
  const KNOWN_BLOCKS = new Set([
358
358
  "hero",
@@ -1388,8 +1388,7 @@ function resolveTenantDir(argv) {
1388
1388
  return { dir: tenantDir(id), tenantId: id };
1389
1389
  }
1390
1390
 
1391
- const isMain = resolve(process.argv[1] || "") === resolve(fileURLToPath(import.meta.url));
1392
- if (isMain) {
1391
+ if (isMainModule(import.meta.url)) {
1393
1392
  const argv = process.argv.slice(2);
1394
1393
  const asJson = argv.includes("--json");
1395
1394
  const profileIndex = argv.indexOf("--profile");