@tokenoftrust/storefront-runner 2.0.2 → 2.0.3
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/package.json +1 -1
- package/scripts/lib/tenant-dirs.mjs +80 -0
- package/scripts/tenant/lint-embeds.mjs +180 -0
- package/scripts/tenant/materialize-tenant-content.mjs +533 -0
- package/scripts/tenant/validate.mjs +837 -0
- package/apps/storefront/scripts/build-v84-clean-clone-standalone.mjs +0 -68
- package/apps/storefront/scripts/deny-v84-clean-clone-standalone-capabilities.mjs +0 -111
- package/apps/storefront/scripts/deny-v84-dogfood-capabilities.mjs +0 -2
- package/apps/storefront/scripts/deny-v84-lifecycle-capabilities.mjs +0 -247
- package/apps/storefront/scripts/dogfood-v84-nonportable-routes.ts +0 -73
- package/apps/storefront/scripts/fixed-v84-native-install-producer.ts +0 -79
- package/apps/storefront/scripts/generated/v84-clean-clone-replay.bundle.mjs +0 -10982
- package/apps/storefront/scripts/import-v84-through-ws2.ts +0 -38
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling-dependency.ts +0 -2
- package/apps/storefront/scripts/initialize-v84-lifecycle-tooling.ts +0 -4
- package/apps/storefront/scripts/install-v84-native-through-ws1.ts +0 -185
- package/apps/storefront/scripts/prove-clone-isolation.ts +0 -506
- package/apps/storefront/scripts/replay-v84-clean-clone-pass.ts +0 -148
- package/apps/storefront/scripts/replay-v84-clean-clone-standalone.ts +0 -35
- package/apps/storefront/scripts/replay-v84-clean-clone.ts +0 -214
- package/apps/storefront/scripts/run-fixed-v84-native-install-producer.mjs +0 -18
- package/apps/storefront/scripts/run-v84-clone-isolation-proof.mjs +0 -18
- package/apps/storefront/scripts/run-v84-clone-lifecycle-proof.mjs +0 -18
- package/apps/storefront/scripts/run-v84-import-through-ws2-proof.mjs +0 -17
- package/apps/storefront/scripts/run-v84-nonportable-route-dogfood.mjs +0 -15
- package/apps/storefront/scripts/sanitize-v84-proof-environment.mjs +0 -21
- package/apps/storefront/scripts/v84CleanCloneGraphPin.ts +0 -42
- package/apps/storefront/scripts/v84CleanCloneSourceGraph.ts +0 -145
- package/apps/storefront/scripts/validate-v84-clone-lifecycle.ts +0 -18
- package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +0 -531
- package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +0 -878
- package/apps/storefront/src/components/admin/AdminBlogTab.astro +0 -1435
- package/apps/storefront/src/components/admin/AdminCheckoutSettingsPanel.astro +0 -226
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +0 -593
- package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +0 -465
- package/apps/storefront/src/components/admin/AdminIntegrationsTab.astro +0 -105
- package/apps/storefront/src/components/admin/AdminProductsTab.astro +0 -901
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +0 -2983
- package/apps/storefront/src/components/admin/AdminReportingTab.astro +0 -435
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +0 -782
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +0 -611
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +0 -1223
- package/apps/storefront/src/lib/dev/cockpitStore.ts +0 -57
- package/apps/storefront/src/lib/publish/adminPublishTab.ts +0 -4625
- package/apps/storefront/src/lib/publish/apex-readiness.ts +0 -360
- package/apps/storefront/src/lib/publish/buildStream.ts +0 -269
- package/apps/storefront/src/lib/publish/collapsePersistence.ts +0 -72
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +0 -267
- package/apps/storefront/src/lib/publish/domainState.ts +0 -360
- package/apps/storefront/src/lib/publish/lifecycleState.ts +0 -369
- package/apps/storefront/src/lib/publish/liveReleaseSummary.ts +0 -29
- package/apps/storefront/src/lib/publish/mergeDoctorOnFailure.ts +0 -78
- package/apps/storefront/src/lib/publish/mergeHealthBanner.ts +0 -62
- package/apps/storefront/src/lib/publish/mergeRowFindings.ts +0 -124
- package/apps/storefront/src/lib/publish/mergeSupportEscalation.ts +0 -75
- package/apps/storefront/src/lib/publish/panelPolling.ts +0 -78
- package/apps/storefront/src/lib/publish/publish-guard.ts +0 -88
- package/apps/storefront/src/lib/publish/publishFormat.ts +0 -165
- package/apps/storefront/src/lib/publish/publishHero.ts +0 -177
- package/apps/storefront/src/lib/publish/reviewRelease/changeState.ts +0 -66
- package/apps/storefront/src/lib/publish/reviewRelease/copy.ts +0 -141
- package/apps/storefront/src/lib/publish/reviewRelease/index.ts +0 -41
- package/apps/storefront/src/lib/publish/reviewRelease/permissions.ts +0 -47
- package/apps/storefront/src/lib/publish/reviewRelease/releaseState.ts +0 -42
- package/apps/storefront/src/lib/publish/reviewRelease/types.ts +0 -59
- package/apps/storefront/src/lib/publish/reviewRelease/viewModel.ts +0 -440
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/actions.ts +0 -161
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/controller.ts +0 -325
- package/apps/storefront/src/lib/publish/reviewReleaseWorkspace/render.ts +0 -241
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +0 -439
- package/apps/storefront/src/lib/publish/sseRoute.ts +0 -119
- package/apps/storefront/src/lib/publish/stageBundleUpload.ts +0 -215
- package/apps/storefront/src/lib/publish/streamLogConsole.ts +0 -142
- package/apps/storefront/src/lib/publish/versionNumber.ts +0 -52
- package/apps/storefront/src/pages/admin/ops-timeline.astro +0 -420
- package/apps/storefront/src/pages/admin/review-release.astro +0 -265
- package/apps/storefront/src/pages/admin/select.astro +0 -96
- package/apps/storefront/src/pages/admin.astro +0 -570
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +0 -86
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration-install.ts +0 -174
- package/apps/storefront/src/pages/api/apps/admin/gtm-migration.ts +0 -96
- package/apps/storefront/src/pages/api/apps/admin/health.ts +0 -44
- package/apps/storefront/src/pages/api/apps/admin/install.ts +0 -125
- package/apps/storefront/src/pages/api/apps/admin/integration-lifecycle.ts +0 -118
- package/apps/storefront/src/pages/api/apps/admin/integrations.ts +0 -89
- package/apps/storefront/src/pages/api/apps/admin/list.ts +0 -26
- package/apps/storefront/src/pages/api/apps/admin/platform-adapters.ts +0 -85
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +0 -79
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +0 -80
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +0 -98
- package/apps/storefront/src/pages/api/apps/admin/update.ts +0 -126
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +0 -70
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +0 -51
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +0 -255
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +0 -87
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +0 -162
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +0 -39
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +0 -49
- package/apps/storefront/src/pages/api/apps/v1/health.ts +0 -32
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +0 -59
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +0 -48
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +0 -73
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +0 -21
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +0 -74
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +0 -47
- package/apps/storefront/src/pages/api/cache-purge.ts +0 -55
- package/apps/storefront/src/pages/api/compliance/tamper.ts +0 -88
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +0 -154
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +0 -120
- package/apps/storefront/src/pages/api/integrations/v1/[operation].ts +0 -108
- package/apps/storefront/src/pages/api/publish/stage-bundle.ts +0 -70
- package/apps/storefront/src/pages/api/test/dev-session.ts +0 -133
- package/apps/storefront/src/pages/cockpit.astro +0 -1548
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +0 -119
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +0 -105
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +0 -177
- package/apps/storefront/src/pages/dashboard/index.astro +0 -160
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Materialize a Gitea-hosted tenant's content into the monorepo working tree.
|
|
4
|
+
*
|
|
5
|
+
* Real tenants live in per-tenant Gitea repos (git.tokenoftrust.com/storefront/
|
|
6
|
+
* <repo>) — the SOURCE OF TRUTH. The monorepo's `tenants/<id>/` dirs for those
|
|
7
|
+
* tenants are gitignored, disposable LOCAL/PER-TENANT build input (the same
|
|
8
|
+
* stance as apps/storefront/public/tenants/, see .gitignore). The shared
|
|
9
|
+
* Storefront Worker reads published tenant revisions from KV/R2 and does NOT run
|
|
10
|
+
* this first. Local tenant development and a named per-tenant static publish do.
|
|
11
|
+
* Committed-fixture tenants (home, sample-store.e2e.test) are NOT materialized
|
|
12
|
+
* — they're tracked in git and this script refuses to touch them.
|
|
13
|
+
*
|
|
14
|
+
* Which tenants are Gitea-classified lives in `tenants/.gitea-tenants.json`
|
|
15
|
+
* (the same manifest the CI reintroduction guard reads —
|
|
16
|
+
* scripts/tenant/check-no-tracked-real-tenants.mjs).
|
|
17
|
+
*
|
|
18
|
+
* What it does per tenant (idempotent; pinnable to a ref):
|
|
19
|
+
* 1. `git clone` the tenant repo to a temp dir and check out `--ref` (default:
|
|
20
|
+
* the repo's default branch). Full clone — tenant repos are small and a SHA
|
|
21
|
+
* ref can't be shallow-fetched portably.
|
|
22
|
+
* 2. Read the repo's own `.tot/config.json` mappings (workspace → monorepo
|
|
23
|
+
* artifact path, e.g. `content/` → `tenants/<id>/content/`); fall back to
|
|
24
|
+
* the canonical shape (theme.json, content/, public/) when absent.
|
|
25
|
+
* 3. Copy each mapped workspace path into the monorepo target, REPLACING the
|
|
26
|
+
* target tree (stale files from a previous materialize are removed). Every
|
|
27
|
+
* target must resolve under `tenants/<id>/` — anything else is refused.
|
|
28
|
+
* 4. Copy `.tot/config.json` itself (the CLI + validate flows read it) and
|
|
29
|
+
* stamp `tenants/<id>/.materialized.json` with { repo, ref, sha, at } so
|
|
30
|
+
* `--check` and humans can see what's populated.
|
|
31
|
+
*
|
|
32
|
+
* Modes:
|
|
33
|
+
* node scripts/tenant/materialize-tenant-content.mjs <tenant> [--ref <sha|branch>]
|
|
34
|
+
* node scripts/tenant/materialize-tenant-content.mjs <tenant> --remote <cloneUrl>
|
|
35
|
+
* # e.g. the single-use gitRemote minted by the MCP `tenant_checkout` tool
|
|
36
|
+
* node scripts/tenant/materialize-tenant-content.mjs --all # every manifest tenant
|
|
37
|
+
* node scripts/tenant/materialize-tenant-content.mjs --check # verify populated; no network
|
|
38
|
+
* node scripts/tenant/materialize-tenant-content.mjs --check-pinned [--expect-ref <sha>] [--allow-unpinned]
|
|
39
|
+
* # stricter deploy gate: verify populated AND materialized from Gitea at a
|
|
40
|
+
* # PINNED ref (.materialized.json present, pinned, on the expected ref); no network
|
|
41
|
+
*
|
|
42
|
+
* Auth (never logged; tokens are redacted from every error):
|
|
43
|
+
* TENANT_GIT_BASE clone base. Default https://git.tokenoftrust.com/storefront
|
|
44
|
+
* TENANT_GIT_USER / TENANT_GIT_TOKEN
|
|
45
|
+
* basic-auth pair injected into the clone URL (CI: a
|
|
46
|
+
* read-only Gitea token; local dev can instead rely on an
|
|
47
|
+
* ambient credential helper and set neither). For a HUMAN
|
|
48
|
+
* running this manually (e.g. handing an agent a scoped
|
|
49
|
+
* token instead of ambient creds), mint a short-lived
|
|
50
|
+
* per-repo read-only TENANT_GIT_TOKEN with
|
|
51
|
+
* scripts/tenant/mint-read-token.sh <tenant> — it never
|
|
52
|
+
* exposes the app's MCP client-assertion signing key to
|
|
53
|
+
* whoever consumes the output.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import {
|
|
57
|
+
cpSync,
|
|
58
|
+
existsSync,
|
|
59
|
+
mkdtempSync,
|
|
60
|
+
readFileSync,
|
|
61
|
+
rmSync,
|
|
62
|
+
writeFileSync,
|
|
63
|
+
} from "node:fs";
|
|
64
|
+
import { execFileSync } from "node:child_process";
|
|
65
|
+
import { tmpdir } from "node:os";
|
|
66
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
67
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
68
|
+
|
|
69
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
const repoRoot = resolve(here, "..", "..");
|
|
71
|
+
|
|
72
|
+
export const MANIFEST_PATH = "tenants/.gitea-tenants.json";
|
|
73
|
+
const TENANT_ID_RE = /^[a-z0-9][a-z0-9.-]{1,60}[a-z0-9]$/;
|
|
74
|
+
const DEFAULT_GIT_BASE = "https://git.tokenoftrust.com/storefront";
|
|
75
|
+
|
|
76
|
+
/** Load the Gitea-tenant manifest. Returns { tenants: { <id>: { repo } } }. */
|
|
77
|
+
export function loadManifest(root = repoRoot) {
|
|
78
|
+
const p = join(root, MANIFEST_PATH);
|
|
79
|
+
if (!existsSync(p)) {
|
|
80
|
+
throw new Error(`${MANIFEST_PATH} not found — no Gitea-classified tenants declared`);
|
|
81
|
+
}
|
|
82
|
+
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
83
|
+
if (!parsed || typeof parsed.tenants !== "object" || parsed.tenants === null) {
|
|
84
|
+
throw new Error(`${MANIFEST_PATH}: expected a "tenants" object`);
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Redact any secret substrings (tokens) from a message before it can escape. */
|
|
90
|
+
export function redact(message, secrets) {
|
|
91
|
+
let out = String(message);
|
|
92
|
+
for (const s of secrets) if (s) out = out.split(s).join("***REDACTED***");
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the authenticated clone URL. The token rides ONLY in the returned URL
|
|
98
|
+
* (passed to git, never printed); callers must route errors through `redact`.
|
|
99
|
+
*/
|
|
100
|
+
export function buildCloneUrl({ base, repo, user, token }) {
|
|
101
|
+
const clean = base.replace(/\/+$/, "");
|
|
102
|
+
if (!token) return `${clean}/${repo}.git`;
|
|
103
|
+
const u = new URL(`${clean}/${repo}.git`);
|
|
104
|
+
u.username = user || "token";
|
|
105
|
+
u.password = token;
|
|
106
|
+
return u.toString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a mapping's monorepo target and REFUSE anything that escapes
|
|
111
|
+
* `tenants/<id>/` — a hostile/typo'd `.tot/config.json` must not be able to
|
|
112
|
+
* write elsewhere in the tree (compare reconcile's manifest deny-by-default).
|
|
113
|
+
*/
|
|
114
|
+
export function resolveTargetPath(root, tenant, repoPath) {
|
|
115
|
+
const target = resolve(root, repoPath);
|
|
116
|
+
const bound = resolve(root, "tenants", tenant) + sep;
|
|
117
|
+
if (!target.startsWith(bound) && target !== resolve(root, "tenants", tenant)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`mapping target "${repoPath}" escapes tenants/${tenant}/ — refusing`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return target;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The canonical mapping fallback (matches buildTotCheckoutConfig). */
|
|
126
|
+
function canonicalMappings(tenant) {
|
|
127
|
+
return [
|
|
128
|
+
{ workspace: "theme.json", repo: `tenants/${tenant}/theme.json`, kind: "file" },
|
|
129
|
+
{ workspace: "content/", repo: `tenants/${tenant}/content/`, kind: "tree" },
|
|
130
|
+
{ workspace: "public/", repo: `tenants/${tenant}/public/`, kind: "tree" },
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Copy one mapping from the checked-out tenant repo into the monorepo tree.
|
|
136
|
+
* `tree` targets are replaced wholesale so a removed file doesn't linger from a
|
|
137
|
+
* previous materialize. Missing OPTIONAL workspace paths are skipped (a tenant
|
|
138
|
+
* without public/ is fine); the reconcile-side required-artifact rules (theme +
|
|
139
|
+
* home present) are enforced by validate/reconcile, not here.
|
|
140
|
+
*/
|
|
141
|
+
export function copyMapping({ checkoutDir, root, tenant, mapping, log = (/** @type {string} */ _m) => {} }) {
|
|
142
|
+
const src = join(checkoutDir, mapping.workspace.replace(/\/+$/, ""));
|
|
143
|
+
const target = resolveTargetPath(root, tenant, mapping.repo.replace(/\/+$/, ""));
|
|
144
|
+
if (!existsSync(src)) {
|
|
145
|
+
log(` ~ ${mapping.workspace} absent in repo — skipped`);
|
|
146
|
+
return { copied: false, target };
|
|
147
|
+
}
|
|
148
|
+
if (mapping.kind === "tree" && existsSync(target)) {
|
|
149
|
+
rmSync(target, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
cpSync(src, target, { recursive: true });
|
|
152
|
+
log(` + ${mapping.workspace} → ${mapping.repo}`);
|
|
153
|
+
return { copied: true, target };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Materialize one tenant. `gitBase` may be a file:// URL (tests) or an
|
|
158
|
+
* authenticated https URL base. Returns { tenant, ref, sha, mappingsCopied }.
|
|
159
|
+
*/
|
|
160
|
+
export function materializeTenant({
|
|
161
|
+
tenant,
|
|
162
|
+
repo = tenant,
|
|
163
|
+
ref = null,
|
|
164
|
+
root = repoRoot,
|
|
165
|
+
gitBase = DEFAULT_GIT_BASE,
|
|
166
|
+
/** Full clone URL override (e.g. a tenant_checkout-minted single-use remote). */
|
|
167
|
+
remote = null,
|
|
168
|
+
user = process.env.TENANT_GIT_USER,
|
|
169
|
+
token = process.env.TENANT_GIT_TOKEN,
|
|
170
|
+
log = (/** @type {string} */ _m) => {},
|
|
171
|
+
}) {
|
|
172
|
+
if (!TENANT_ID_RE.test(tenant)) {
|
|
173
|
+
throw new Error(`invalid tenant id "${tenant}"`);
|
|
174
|
+
}
|
|
175
|
+
const manifest = loadManifest(root);
|
|
176
|
+
if (!manifest.tenants[tenant]) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`"${tenant}" is not a Gitea-classified tenant (${MANIFEST_PATH}) — its ` +
|
|
179
|
+
`tenants/ dir is committed, not materialized. Refusing to overwrite it.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
// A full remote URL may itself carry a credential — treat it as a secret too.
|
|
183
|
+
// (scp-style remotes like git@host:path aren't URL-parseable; they carry no
|
|
184
|
+
// inline password, so an unparseable remote contributes no secret.)
|
|
185
|
+
let remoteSecret = null;
|
|
186
|
+
if (remote) {
|
|
187
|
+
try {
|
|
188
|
+
remoteSecret = new URL(remote).password || null;
|
|
189
|
+
} catch {
|
|
190
|
+
remoteSecret = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const secrets = [token, remoteSecret].filter(Boolean);
|
|
194
|
+
const cloneUrl = remote ?? buildCloneUrl({ base: gitBase, repo, user, token });
|
|
195
|
+
const work = mkdtempSync(join(tmpdir(), `materialize-${tenant}-`));
|
|
196
|
+
const git = (args, cwd = work) =>
|
|
197
|
+
execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
|
|
198
|
+
.toString()
|
|
199
|
+
.trim();
|
|
200
|
+
try {
|
|
201
|
+
try {
|
|
202
|
+
git(["clone", "--quiet", cloneUrl, work], tmpdir());
|
|
203
|
+
} catch (e) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`clone of ${gitBase.startsWith("file:") ? repo : `${gitBase}/${repo}`} failed: ` +
|
|
206
|
+
redact(e.stderr?.toString() || e.message, secrets),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (ref) {
|
|
210
|
+
try {
|
|
211
|
+
git(["checkout", "--quiet", ref]);
|
|
212
|
+
} catch {
|
|
213
|
+
// A SHA not on the default branch (e.g. a merged-then-advanced tip)
|
|
214
|
+
// may need an explicit fetch before checkout.
|
|
215
|
+
git(["fetch", "--quiet", "origin", ref]);
|
|
216
|
+
git(["checkout", "--quiet", "FETCH_HEAD"]);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
// No --ref = the repo's DEFAULT branch, which for tenant repos is often
|
|
220
|
+
// days behind the promoted/preview content. Loud, unmissable, but not fatal —
|
|
221
|
+
// local iteration against the default branch is legitimate; publishes are
|
|
222
|
+
// separately guarded by ci-www-publish.sh's PUBLISH_REF requirement.
|
|
223
|
+
console.warn(
|
|
224
|
+
`[materialize-tenant-content] WARNING: no --ref given — materializing ${tenant} from the ` +
|
|
225
|
+
`repo's DEFAULT branch, which may be stale. Pin a commit (--ref <sha|branch>) for anything ` +
|
|
226
|
+
`feeding a build or publish.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const sha = git(["rev-parse", "HEAD"]);
|
|
230
|
+
|
|
231
|
+
// Mappings: the repo's own .tot/config.json wins; canonical shape otherwise.
|
|
232
|
+
let mappings = canonicalMappings(tenant);
|
|
233
|
+
const cfgPath = join(work, ".tot", "config.json");
|
|
234
|
+
if (existsSync(cfgPath)) {
|
|
235
|
+
const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
|
|
236
|
+
if (Array.isArray(cfg.mappings) && cfg.mappings.length > 0) {
|
|
237
|
+
mappings = cfg.mappings;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let mappingsCopied = 0;
|
|
242
|
+
for (const m of mappings) {
|
|
243
|
+
if (typeof m?.repo !== "string" || (m.kind !== "file" && m.kind !== "tree")) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`.tot/config.json: malformed mapping for "${m?.workspace ?? "?"}" ` +
|
|
246
|
+
`(need string repo + kind file|tree)`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const { copied } = copyMapping({ checkoutDir: work, root, tenant, mapping: m, log });
|
|
250
|
+
if (copied) mappingsCopied++;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The checkout config itself + the provenance stamp.
|
|
254
|
+
if (existsSync(cfgPath)) {
|
|
255
|
+
const cfgTarget = resolveTargetPath(root, tenant, `tenants/${tenant}/.tot/config.json`);
|
|
256
|
+
cpSync(cfgPath, cfgTarget);
|
|
257
|
+
}
|
|
258
|
+
// The assets-r2-versioned opt-in marker:
|
|
259
|
+
// copy-tenant-assets.mjs reads this from the MATERIALIZED tree, not the repo
|
|
260
|
+
// directly, so it must ride along here or the marker a tenant commits in
|
|
261
|
+
// Gitea would silently never take effect on a real build.
|
|
262
|
+
const versionedMarkerSrc = join(work, ".tot", "assets-versioned");
|
|
263
|
+
if (existsSync(versionedMarkerSrc)) {
|
|
264
|
+
const versionedMarkerTarget = resolveTargetPath(
|
|
265
|
+
root,
|
|
266
|
+
tenant,
|
|
267
|
+
`tenants/${tenant}/.tot/assets-versioned`,
|
|
268
|
+
);
|
|
269
|
+
cpSync(versionedMarkerSrc, versionedMarkerTarget);
|
|
270
|
+
log(` + .tot/assets-versioned → tenants/${tenant}/.tot/assets-versioned`);
|
|
271
|
+
}
|
|
272
|
+
const stamp = resolveTargetPath(root, tenant, `tenants/${tenant}/.materialized.json`);
|
|
273
|
+
writeFileSync(
|
|
274
|
+
stamp,
|
|
275
|
+
JSON.stringify(
|
|
276
|
+
{ tenant, repo, ref: ref ?? "(default branch)", sha, at: new Date().toISOString() },
|
|
277
|
+
null,
|
|
278
|
+
2,
|
|
279
|
+
) + "\n",
|
|
280
|
+
);
|
|
281
|
+
log(` ✓ ${tenant} materialized @ ${sha.slice(0, 10)} (${mappingsCopied} mapping(s))`);
|
|
282
|
+
return { tenant, ref, sha, mappingsCopied };
|
|
283
|
+
} finally {
|
|
284
|
+
rmSync(work, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `--check`: every manifest tenant dir must exist and carry content (at least a
|
|
290
|
+
* theme or content/ dir — the build globs would otherwise silently render an
|
|
291
|
+
* empty tenant). Returns { ok, missing: string[] }. No network.
|
|
292
|
+
*/
|
|
293
|
+
export function checkMaterialized(root = repoRoot) {
|
|
294
|
+
const manifest = loadManifest(root);
|
|
295
|
+
const missing = [];
|
|
296
|
+
for (const tenant of Object.keys(manifest.tenants)) {
|
|
297
|
+
const dir = join(root, "tenants", tenant);
|
|
298
|
+
const populated =
|
|
299
|
+
existsSync(join(dir, "theme.json")) ||
|
|
300
|
+
existsSync(join(dir, "content")) ||
|
|
301
|
+
existsSync(join(dir, "content.json"));
|
|
302
|
+
if (!populated) missing.push(tenant);
|
|
303
|
+
}
|
|
304
|
+
return { ok: missing.length === 0, missing };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* `--check-pinned`: the stricter DEPLOY gate. Beyond presence (checkMaterialized),
|
|
309
|
+
* every manifest tenant must carry a `.materialized.json` provenance stamp proving
|
|
310
|
+
* it was materialized from Gitea at a PINNED ref — mirroring ci-www-publish.sh's
|
|
311
|
+
* PUBLISH_REF contract so a Worker deploy can never bake a stale local-disk tree
|
|
312
|
+
* (local tenants/ is a disposable cache, never a source of truth). No network.
|
|
313
|
+
* Returns { ok, problems: [{ tenant, reason }] } where
|
|
314
|
+
* reason ∈ "missing" | "unpinned" | "off-ref":
|
|
315
|
+
* missing no stamp / unpopulated dir
|
|
316
|
+
* unpinned stamp exists but ref == "(default branch)" (materialized off the
|
|
317
|
+
* tenant repo's default branch, which may be stale). Tolerated only
|
|
318
|
+
* with allowUnpinned.
|
|
319
|
+
* off-ref expectRef given and the stamp's sha/ref doesn't match it.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} [root]
|
|
322
|
+
* @param {object} [opts]
|
|
323
|
+
* @param {string|null} [opts.expectRef] assert the stamp is this exact commit (sha or ref;
|
|
324
|
+
* short-sha prefix matches, like git)
|
|
325
|
+
* @param {boolean} [opts.allowUnpinned] tolerate a default-branch (unpinned) materialize
|
|
326
|
+
* @param {string[]|null} [opts.onlyTenants] restrict the check to these tenant ids (a per-tenant
|
|
327
|
+
* STATIC publish only ships one tenant's routes, so it
|
|
328
|
+
* need only guard that tenant; null ⇒ every Gitea tenant
|
|
329
|
+
* for explicit operator diagnostics)
|
|
330
|
+
*/
|
|
331
|
+
export function checkPinned(
|
|
332
|
+
root = repoRoot,
|
|
333
|
+
{ expectRef = null, allowUnpinned = false, onlyTenants = null } = {},
|
|
334
|
+
) {
|
|
335
|
+
const manifest = loadManifest(root);
|
|
336
|
+
const problems = [];
|
|
337
|
+
const scope = onlyTenants ? new Set(onlyTenants) : null;
|
|
338
|
+
for (const tenant of Object.keys(manifest.tenants)) {
|
|
339
|
+
if (scope && !scope.has(tenant)) continue;
|
|
340
|
+
const dir = join(root, "tenants", tenant);
|
|
341
|
+
const populated =
|
|
342
|
+
existsSync(join(dir, "theme.json")) ||
|
|
343
|
+
existsSync(join(dir, "content")) ||
|
|
344
|
+
existsSync(join(dir, "content.json"));
|
|
345
|
+
const stampPath = join(dir, ".materialized.json");
|
|
346
|
+
if (!populated) {
|
|
347
|
+
// Nothing on disk at all — bakes NOTHING (vs "missing": populated content
|
|
348
|
+
// of unknown provenance). A framework-only build may tolerate this; a
|
|
349
|
+
// deploy never should.
|
|
350
|
+
problems.push({ tenant, reason: "absent" });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (!existsSync(stampPath)) {
|
|
354
|
+
problems.push({ tenant, reason: "missing" });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
let stamp;
|
|
358
|
+
try {
|
|
359
|
+
stamp = JSON.parse(readFileSync(stampPath, "utf8"));
|
|
360
|
+
} catch {
|
|
361
|
+
problems.push({ tenant, reason: "missing" });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const ref = typeof stamp.ref === "string" ? stamp.ref : "";
|
|
365
|
+
const sha = typeof stamp.sha === "string" ? stamp.sha : "";
|
|
366
|
+
const pinned = ref !== "" && ref !== "(default branch)";
|
|
367
|
+
if (!pinned && !allowUnpinned) {
|
|
368
|
+
problems.push({ tenant, reason: "unpinned" });
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (expectRef) {
|
|
372
|
+
const shaMatch =
|
|
373
|
+
sha.length >= 7 && (sha.startsWith(expectRef) || expectRef.startsWith(sha));
|
|
374
|
+
const refMatch = ref !== "" && ref === expectRef;
|
|
375
|
+
if (!shaMatch && !refMatch) {
|
|
376
|
+
problems.push({ tenant, reason: "off-ref" });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return { ok: problems.length === 0, problems };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Human-readable refusal for a failed `checkPinned` (shared by the CLI + the
|
|
384
|
+
* build-time guard). `problems` is checkPinned().problems; `expectRef` is echoed
|
|
385
|
+
* into the off-ref explanation when set. */
|
|
386
|
+
export function describePinnedProblems(problems, /** @type {string|null} */ expectRef = null) {
|
|
387
|
+
const how = {
|
|
388
|
+
absent: "not materialized (no tenant content on disk at all)",
|
|
389
|
+
missing: "not materialized (populated but no tenants/<id>/.materialized.json)",
|
|
390
|
+
unpinned:
|
|
391
|
+
"materialized from the tenant repo's DEFAULT branch (no pinned ref) — may be stale",
|
|
392
|
+
"off-ref": `materialized at a different ref than expected (${expectRef})`,
|
|
393
|
+
};
|
|
394
|
+
return (
|
|
395
|
+
"REFUSING: tenant content is not materialized from Gitea at a pinned ref.\n" +
|
|
396
|
+
problems.map(({ tenant, reason }) => ` ✗ ${tenant}: ${how[reason]}`).join("\n") +
|
|
397
|
+
"\n\nLocal tenants/ is a disposable cache, never a source of truth. Re-materialize\n" +
|
|
398
|
+
"at the promoted commit:\n" +
|
|
399
|
+
" node scripts/tenant/materialize-tenant-content.mjs <tenant> --ref <sha>\n" +
|
|
400
|
+
"(--allow-unpinned deliberately tolerates a default-branch materialize)."
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Throw if any Gitea-classified tenant is unmaterialized/unpinned/off-ref — the
|
|
406
|
+
* intrinsic build gate. Called from the storefront's `astro:build:start` hook so
|
|
407
|
+
* `astro build` refuses to bake empty/stale tenant content on its own, not only
|
|
408
|
+
* when routed through deploy.sh. No network.
|
|
409
|
+
*
|
|
410
|
+
* Skips silently when the manifest is absent (a pruned standalone runner tree
|
|
411
|
+
* declares no Gitea tenants — nothing to guard).
|
|
412
|
+
*
|
|
413
|
+
* @param {object} [opts]
|
|
414
|
+
* @param {string} [opts.root] monorepo root (default: this repo)
|
|
415
|
+
* @param {string|null} [opts.expectRef] assert every stamp is this exact commit
|
|
416
|
+
* @param {boolean} [opts.allowUnpinned] tolerate a default-branch materialize
|
|
417
|
+
* @param {string[]|null} [opts.onlyTenants] restrict to these tenant ids (per-tenant static
|
|
418
|
+
* publish); null ⇒ every Gitea tenant (operator check)
|
|
419
|
+
*/
|
|
420
|
+
export function assertPinnedMaterialized({
|
|
421
|
+
root = repoRoot,
|
|
422
|
+
expectRef = null,
|
|
423
|
+
allowUnpinned = false,
|
|
424
|
+
onlyTenants = null,
|
|
425
|
+
} = {}) {
|
|
426
|
+
if (!existsSync(join(root, MANIFEST_PATH))) return;
|
|
427
|
+
const { ok, problems } = checkPinned(root, { expectRef, allowUnpinned, onlyTenants });
|
|
428
|
+
if (!ok) throw new Error(describePinnedProblems(problems, expectRef));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function parseArgs(argv) {
|
|
432
|
+
const a = {
|
|
433
|
+
tenant: null,
|
|
434
|
+
ref: null,
|
|
435
|
+
remote: null,
|
|
436
|
+
all: false,
|
|
437
|
+
check: false,
|
|
438
|
+
checkPinned: false,
|
|
439
|
+
expectRef: null,
|
|
440
|
+
allowUnpinned: false,
|
|
441
|
+
help: false,
|
|
442
|
+
};
|
|
443
|
+
for (let i = 0; i < argv.length; i++) {
|
|
444
|
+
const t = argv[i];
|
|
445
|
+
if (t === "--ref") a.ref = argv[++i];
|
|
446
|
+
else if (t === "--remote") a.remote = argv[++i];
|
|
447
|
+
else if (t === "--all") a.all = true;
|
|
448
|
+
else if (t === "--check") a.check = true;
|
|
449
|
+
else if (t === "--check-pinned") a.checkPinned = true;
|
|
450
|
+
else if (t === "--expect-ref") a.expectRef = argv[++i];
|
|
451
|
+
else if (t === "--allow-unpinned") a.allowUnpinned = true;
|
|
452
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
453
|
+
else if (!t.startsWith("--") && !a.tenant) a.tenant = t;
|
|
454
|
+
else throw new Error(`unknown argument: ${t}`);
|
|
455
|
+
}
|
|
456
|
+
if (a.remote && a.all) throw new Error("--remote targets ONE tenant; not valid with --all");
|
|
457
|
+
return a;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const USAGE = `materialize-tenant-content — populate tenants/<id>/ from its Gitea repo
|
|
461
|
+
|
|
462
|
+
node scripts/tenant/materialize-tenant-content.mjs <tenant> [--ref <sha|branch>] [--remote <cloneUrl>]
|
|
463
|
+
node scripts/tenant/materialize-tenant-content.mjs --all
|
|
464
|
+
node scripts/tenant/materialize-tenant-content.mjs --check
|
|
465
|
+
node scripts/tenant/materialize-tenant-content.mjs --check-pinned [--expect-ref <sha>] [--allow-unpinned]
|
|
466
|
+
|
|
467
|
+
env: TENANT_GIT_BASE (default ${DEFAULT_GIT_BASE}), TENANT_GIT_USER, TENANT_GIT_TOKEN`;
|
|
468
|
+
|
|
469
|
+
function main() {
|
|
470
|
+
const args = parseArgs(process.argv.slice(2));
|
|
471
|
+
if (args.help) {
|
|
472
|
+
console.log(USAGE);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const gitBase = process.env.TENANT_GIT_BASE || DEFAULT_GIT_BASE;
|
|
476
|
+
if (args.check) {
|
|
477
|
+
const { ok, missing } = checkMaterialized();
|
|
478
|
+
if (!ok) {
|
|
479
|
+
console.error(
|
|
480
|
+
`NOT MATERIALIZED: ${missing.join(", ")}\n` +
|
|
481
|
+
`Run: node scripts/tenant/materialize-tenant-content.mjs --all`,
|
|
482
|
+
);
|
|
483
|
+
process.exit(1);
|
|
484
|
+
}
|
|
485
|
+
console.log("All Gitea-classified tenants are materialized.");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (args.checkPinned) {
|
|
489
|
+
const { ok, problems } = checkPinned(repoRoot, {
|
|
490
|
+
expectRef: args.expectRef,
|
|
491
|
+
allowUnpinned: args.allowUnpinned,
|
|
492
|
+
});
|
|
493
|
+
if (!ok) {
|
|
494
|
+
console.error(describePinnedProblems(problems, args.expectRef));
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
console.log("All Gitea-classified tenants materialized from Gitea at a pinned ref.");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const manifest = loadManifest();
|
|
501
|
+
const targets = args.all
|
|
502
|
+
? Object.keys(manifest.tenants)
|
|
503
|
+
: args.tenant
|
|
504
|
+
? [args.tenant]
|
|
505
|
+
: null;
|
|
506
|
+
if (!targets) {
|
|
507
|
+
console.error(USAGE);
|
|
508
|
+
process.exit(2);
|
|
509
|
+
}
|
|
510
|
+
for (const tenant of targets) {
|
|
511
|
+
const repo = manifest.tenants[tenant]?.repo ?? tenant;
|
|
512
|
+
console.log(`Materializing ${tenant} (repo ${repo})…`);
|
|
513
|
+
materializeTenant({
|
|
514
|
+
tenant,
|
|
515
|
+
repo,
|
|
516
|
+
ref: args.ref,
|
|
517
|
+
remote: args.remote,
|
|
518
|
+
gitBase,
|
|
519
|
+
log: (s) => console.log(s),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const invokedDirectly =
|
|
525
|
+
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
526
|
+
if (invokedDirectly) {
|
|
527
|
+
try {
|
|
528
|
+
main();
|
|
529
|
+
} catch (e) {
|
|
530
|
+
console.error("ERROR:", redact(String(e?.message || e), [process.env.TENANT_GIT_TOKEN]));
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
}
|