@hearthlog/tooling 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/bin/hearthlog.mjs +61 -0
- package/package.json +64 -3
- package/src/apply.ts +73 -0
- package/src/check/check-layers.ts +118 -0
- package/src/check/check-modules.ts +198 -0
- package/src/check/check-sdk-exports.ts +79 -0
- package/src/check/check-tenant-config.ts +208 -0
- package/src/cli/hearthlog.ts +212 -0
- package/src/cli/list-sites.ts +15 -0
- package/src/compose/compose-modules.ts +490 -0
- package/src/compose/module-scaffold.ts +195 -0
- package/src/deploy/artifact-compare.ts +93 -0
- package/src/deploy/artifact.ts +127 -0
- package/src/deploy/async-utils.ts +71 -0
- package/src/deploy/backup-tenants.ts +139 -0
- package/src/deploy/cloudflare-access.ts +789 -0
- package/src/deploy/copy-tenant.ts +395 -0
- package/src/deploy/dedicated-ledger.ts +77 -0
- package/src/deploy/deploy-ledger.ts +162 -0
- package/src/deploy/deploy-runner.ts +240 -0
- package/src/deploy/deploy-tenant.ts +170 -0
- package/src/deploy/deployment-security.ts +141 -0
- package/src/deploy/ensure-tenant.ts +988 -0
- package/src/deploy/event-policy.ts +23 -0
- package/src/deploy/frontend-dry-run.ts +18 -0
- package/src/deploy/ledger-credential.ts +48 -0
- package/src/deploy/ledger-scripts.ts +13 -0
- package/src/deploy/split-tenant.ts +295 -0
- package/src/dev/local-development.ts +288 -0
- package/src/dev/provision-local.ts +13 -0
- package/src/generate/build-generated.ts +166 -0
- package/src/generate/generate-brand-icons.ts +128 -0
- package/src/generate/generate-entities.ts +627 -0
- package/src/generate/generate-tenant.ts +161 -0
- package/src/host/core-routes.ts +39 -0
- package/src/host/write-host.ts +119 -0
- package/src/index.ts +8 -0
- package/src/init/check-module.ts +143 -0
- package/src/init/export-tenant.ts +124 -0
- package/src/init/init.ts +168 -0
- package/src/move.ts +189 -0
- package/src/plan.ts +160 -0
- package/src/public-workers/build-gallery-public.ts +114 -0
- package/src/public-workers/calendar-public-config.ts +27 -0
- package/src/public-workers/check-calendar-public-route.ts +41 -0
- package/src/public-workers/check-gallery-public-route.ts +43 -0
- package/src/public-workers/gallery-public-config.ts +22 -0
- package/src/receipt.ts +74 -0
- package/src/root.ts +16 -0
- package/src/security/audit-push.ts +141 -0
- package/src/security/forbidden-identities.ts +107 -0
- package/src/security/forbidden-init.ts +88 -0
- package/src/tenants/host-configs.ts +96 -0
- package/src/tenants/ledger-config.ts +57 -0
- package/src/tenants/lock-file.ts +45 -0
- package/src/tenants/module-lock.ts +58 -0
- package/src/tenants/tenant-config.ts +887 -0
- package/src/tenants/tenant-copy-plan.ts +119 -0
- package/src/tenants/tenant-schema.ts +171 -0
- package/src/tenants/tenant-wrangler.ts +37 -0
- package/src/tenants/wrangler-base.ts +50 -0
- package/src/tenants/wrangler-config.ts +266 -0
- package/src/vite/config.ts +310 -0
- package/src/vite/dev-plugins.ts +338 -0
- package/src/vite/vite-plugin-entity-gen.ts +70 -0
- package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
- package/src/vite/vite-plugin-worker-assets.ts +42 -0
- package/templates/host/app.d.ts.txt +31 -0
- package/templates/host/microlighter.d.ts.txt +11 -0
- package/templates/module/ci.yml.tmpl +44 -0
- package/templates/module/gitignore.tmpl +7 -0
- package/templates/module/gitleaks.toml.tmpl +11 -0
- package/templates/module/release.yml.tmpl +53 -0
- package/templates/tenant/README.md.tmpl +13 -0
- package/templates/tenant/generated-package.json.tmpl +16 -0
- package/templates/tenant/github-workflows/deploy.yml.tmpl +111 -0
- package/templates/tenant/gitignore.tmpl +11 -0
- package/templates/tenant/package.json.tmpl +11 -0
- package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
- package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
- package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
- package/templates/tenant/tenants.json.tmpl +17 -0
- package/templates/tenant/tsconfig.json.tmpl +21 -0
- package/templates/tenant/vite.config.ts.tmpl +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Module boundary check.
|
|
4
|
+
*
|
|
5
|
+
* A module (`modules/<name>/`) may import only the platform's published API
|
|
6
|
+
* (`@hearthlog/sdk/*`, `@hearthlog/contracts`), Svelte and SvelteKit (`svelte`,
|
|
7
|
+
* `$app/*`, `@sveltejs/kit`), icons, its declared third-party dependencies, and its own files;
|
|
8
|
+
* its tests may also use Vitest, Node built-ins, and `@hearthlog/sdk/testing`.
|
|
9
|
+
* Worker code (`src/server/`) may import only the SDK's Worker-safe entries.
|
|
10
|
+
* It may not reach into the app (`#lib`, `$lib`, relative paths leaving the
|
|
11
|
+
* module) or into another module. The app may import a module only through
|
|
12
|
+
* the generated files the composer writes.
|
|
13
|
+
*
|
|
14
|
+
* Usage: pnpm check:modules
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
18
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { hostRoot } from "../root.js";
|
|
21
|
+
|
|
22
|
+
const ROOT = hostRoot();
|
|
23
|
+
const SOURCE = /\.(ts|svelte|js)$/;
|
|
24
|
+
const IMPORT = /(?:^|[^\w$.])(?:import|export)\s[^'"]*?from\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)|^\s*import\s+['"]([^'"]+)['"]/gm;
|
|
25
|
+
|
|
26
|
+
function files(directory: string): string[] {
|
|
27
|
+
// A checkout that lacks a directory (a module repository, a scratch workspace) has nothing to scan there.
|
|
28
|
+
if (!existsSync(directory)) return [];
|
|
29
|
+
return readdirSync(directory).flatMap((name) => {
|
|
30
|
+
if (name === "node_modules" || name.startsWith(".")) return [];
|
|
31
|
+
const path = join(directory, name);
|
|
32
|
+
return statSync(path).isDirectory() ? files(path) : SOURCE.test(name) ? [path] : [];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function specifiers(path: string): string[] {
|
|
37
|
+
return [...readFileSync(path, "utf8").matchAll(IMPORT)].map((match) => match[1] ?? match[2] ?? match[3]!).filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function moduleImportProblem(specifier: string, file: string, moduleRoot: string, allowedPackages: readonly string[]): string | null {
|
|
41
|
+
if (specifier.startsWith("#lib") || specifier.startsWith("$lib")) return "imports the app directly";
|
|
42
|
+
if (specifier.startsWith(".")) {
|
|
43
|
+
const target = resolve(dirname(file), specifier);
|
|
44
|
+
return target === moduleRoot || target.startsWith(moduleRoot + sep) ? null : "leaves the module with a relative path";
|
|
45
|
+
}
|
|
46
|
+
if (specifier.startsWith("@hearthlog/module-")) return "imports another module";
|
|
47
|
+
// Worker code must not drag the browser app into the gateway bundle.
|
|
48
|
+
if (file.includes(`${sep}src${sep}server${sep}`)) {
|
|
49
|
+
const WORKER_SAFE = ["server", "server-testing", "entities", "axis", "format"];
|
|
50
|
+
if (specifier.startsWith("@hearthlog/sdk/") && !WORKER_SAFE.includes(specifier.slice("@hearthlog/sdk/".length))) {
|
|
51
|
+
return "pulls browser code into Worker code (use @hearthlog/sdk/server or entities)";
|
|
52
|
+
}
|
|
53
|
+
if (specifier === "svelte" || specifier.startsWith("svelte/") || specifier.startsWith("$app/") || specifier.startsWith("~icons/")) {
|
|
54
|
+
return "uses a browser-only package in Worker code";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (specifier.startsWith("@hearthlog/sdk/") || specifier === "@hearthlog/contracts") return null;
|
|
58
|
+
// The framework: `redirect`, `error`, and the like.
|
|
59
|
+
if (specifier === "@sveltejs/kit" && !file.includes(`${sep}src${sep}server${sep}`)) return null;
|
|
60
|
+
if (specifier === "svelte" || specifier.startsWith("svelte/") || specifier.startsWith("$app/") || specifier.startsWith("~icons/")) return null;
|
|
61
|
+
// Tests run under Vitest in Node.
|
|
62
|
+
if (/\.test\.ts$/.test(file) && (specifier === "vitest" || specifier.startsWith("node:"))) return null;
|
|
63
|
+
const name = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0]!;
|
|
64
|
+
return allowedPackages.includes(name) ? null : `uses ${name}, which its package.json does not declare`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What the installed modules define, from the catalog the composer generates. */
|
|
68
|
+
export interface ModuleDomain {
|
|
69
|
+
/** Entity names and table names, quoted in code. */
|
|
70
|
+
names: string[];
|
|
71
|
+
/** Event types (`TRANSACTION_ADDED`) of every module-owned entity. */
|
|
72
|
+
eventTypes: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The names the installed modules define: their entity and table names (from the generated module
|
|
77
|
+
* catalog) and event types (from the Ledger's generated contracts). A checkout without those
|
|
78
|
+
* generated files, such as a module repository or a scratch workspace, has none to guard.
|
|
79
|
+
*/
|
|
80
|
+
export async function loadModuleDomain(root = ROOT): Promise<ModuleDomain> {
|
|
81
|
+
const catalog = join(root, ".generated/src/modules.generated.ts");
|
|
82
|
+
const contracts = join(root, ".generated/src/event-schemas.generated.ts");
|
|
83
|
+
if (!existsSync(catalog) || !existsSync(contracts)) return { names: [], eventTypes: [] };
|
|
84
|
+
const { MODULE_ENTITY_DEFINITIONS } = (await import(catalog)) as { MODULE_ENTITY_DEFINITIONS: ReadonlyArray<{ name: string; table: string }> };
|
|
85
|
+
const { EVENT_OWNERS } = (await import(contracts)) as { EVENT_OWNERS: Record<string, string> };
|
|
86
|
+
return {
|
|
87
|
+
names: [...new Set(MODULE_ENTITY_DEFINITIONS.flatMap((definition) => [definition.name, definition.table]))],
|
|
88
|
+
eventTypes: Object.entries(EVENT_OWNERS).filter(([, owner]) => owner !== "core").map(([type]) => type),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The core must not name a module's feature, entity, table, or event type: it learns them from the
|
|
96
|
+
* catalog and contracts the composer generates. A quoted feature id (`'finance'`), a quoted entity
|
|
97
|
+
* or table name, or a module's event type in core code (not comments, tests, or generated files)
|
|
98
|
+
* means a domain leaked back in.
|
|
99
|
+
*/
|
|
100
|
+
export function coreNamesNoDomain(
|
|
101
|
+
root = ROOT,
|
|
102
|
+
directories = ["worker", "ledger/src", "packages/tooling/src/host", "packages/cloudflare/src", "packages/ledger/src", "packages/ledger-admin/src", "packages/contracts/src", "packages/runtime/src", "packages/ui/src"],
|
|
103
|
+
domain: ModuleDomain = { names: [], eventTypes: [] },
|
|
104
|
+
): string[] {
|
|
105
|
+
const modulesDirectory = join(root, "modules");
|
|
106
|
+
const featureIds = (existsSync(modulesDirectory) ? readdirSync(modulesDirectory) : []).flatMap((name) => {
|
|
107
|
+
const manifest = join(modulesDirectory, name, "hearthlog.module.json");
|
|
108
|
+
if (!existsSync(manifest)) return [];
|
|
109
|
+
return (JSON.parse(readFileSync(manifest, "utf8")) as { features?: Array<{ id: string }> }).features?.map((feature) => feature.id) ?? [];
|
|
110
|
+
});
|
|
111
|
+
const quotedNames = [...new Set([...featureIds, ...domain.names])];
|
|
112
|
+
if (!quotedNames.length && !domain.eventTypes.length) return [];
|
|
113
|
+
const quoted = quotedNames.length ? new RegExp(`(["'\`])(${quotedNames.map(escapeRegExp).join("|")})\\1`) : null;
|
|
114
|
+
const events = domain.eventTypes.length ? new RegExp(`\\b(${domain.eventTypes.map(escapeRegExp).join("|")})\\b`) : null;
|
|
115
|
+
const problems: string[] = [];
|
|
116
|
+
for (const directory of directories) {
|
|
117
|
+
for (const file of files(join(root, directory))) {
|
|
118
|
+
if (/\.generated\.ts$|\.test\.ts$|\.d\.ts$|[\\/]generated\.ts$|worker-configuration/.test(file) || file.includes(`${sep}generated${sep}`) || file.includes(`${sep}(modules)${sep}`)) continue;
|
|
119
|
+
readFileSync(file, "utf8").split("\n").forEach((line, index) => {
|
|
120
|
+
// Comments may use a domain as an example.
|
|
121
|
+
if (/^\s*(\*|\/\/|\/\*)/.test(line)) return;
|
|
122
|
+
const name = quoted?.exec(line);
|
|
123
|
+
if (name) problems.push(`${relative(root, file)}:${index + 1}: the core names ${name[1]}${name[2]}${name[1]}`);
|
|
124
|
+
const event = events?.exec(line);
|
|
125
|
+
if (event) problems.push(`${relative(root, file)}:${index + 1}: the core names the event type ${event[1]}`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return problems;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Generated files (the module catalog, schema, types, and the like) are imported only through
|
|
134
|
+
* `#generated/*`, which the host maps to wherever its build writes them. A relative or `#lib` path
|
|
135
|
+
* to one would break the day the file is no longer in this tree. The SDK facade, which still reaches
|
|
136
|
+
* into `src/lib` by relative path, and the generated files themselves are exempt.
|
|
137
|
+
*/
|
|
138
|
+
export function directGeneratedImports(root = ROOT, directories = ["src", "worker", "ledger", "scripts", "calendar-public", "packages/tooling/src", "packages/cloudflare/src", "packages/ledger/src", "packages/ledger-admin/src", "packages/contracts/src", "packages/runtime/src", "packages/ui/src"]): string[] {
|
|
139
|
+
const problems: string[] = [];
|
|
140
|
+
const generatedFile = /(^|[\\/])[\w-]+\.generated(\.js|\.ts)?$|(^|[\\/])tenant[\\/]generated(\.js|\.ts)?$/;
|
|
141
|
+
for (const directory of directories) {
|
|
142
|
+
for (const file of files(join(root, directory))) {
|
|
143
|
+
// The scripts' own tests write fixture files whose text looks like imports, and the generators emit import lines.
|
|
144
|
+
if (/\.generated\.ts$|[\\/]generated\.ts$/.test(file) || file.includes(`${sep}generated${sep}`) || file.includes(`${sep}(modules)${sep}`) || ((directory === "scripts" || directory === "packages/tooling/src") && /\.test\.ts$|(compose-modules|generate-[a-z-]+)\.ts$/.test(file))) continue;
|
|
145
|
+
for (const specifier of specifiers(file)) {
|
|
146
|
+
if ((specifier.startsWith(".") || specifier.startsWith("#lib")) && generatedFile.test(specifier)) {
|
|
147
|
+
problems.push(`${relative(root, file)}: "${specifier}" is a generated file (import it through #generated/*)`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return problems;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function checkBoundaries(root = ROOT, domain?: ModuleDomain): string[] {
|
|
156
|
+
const problems: string[] = [];
|
|
157
|
+
const modulesDirectory = join(root, "modules");
|
|
158
|
+
// A platform workspace or a tenant repository has no modules directory.
|
|
159
|
+
for (const name of existsSync(modulesDirectory) ? readdirSync(modulesDirectory) : []) {
|
|
160
|
+
const moduleRoot = join(modulesDirectory, name);
|
|
161
|
+
if (!statSync(moduleRoot).isDirectory()) continue;
|
|
162
|
+
const packageJson = JSON.parse(readFileSync(join(moduleRoot, "package.json"), "utf8")) as { dependencies?: Record<string, string> };
|
|
163
|
+
const allowed = Object.keys(packageJson.dependencies ?? {});
|
|
164
|
+
// A module's anonymous public Worker is part of the module; its build config is tooling.
|
|
165
|
+
const sourceFiles = [join(moduleRoot, "src"), join(moduleRoot, "public")]
|
|
166
|
+
.filter((directory) => existsSync(directory))
|
|
167
|
+
.flatMap((directory) => files(directory))
|
|
168
|
+
.filter((file) => !/vite\.config\.ts$/.test(file));
|
|
169
|
+
for (const file of sourceFiles) {
|
|
170
|
+
for (const specifier of specifiers(file)) {
|
|
171
|
+
const problem = moduleImportProblem(specifier, file, moduleRoot, allowed);
|
|
172
|
+
if (problem) problems.push(`${relative(root, file)}: "${specifier}" ${problem}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const generated = /\.generated\.ts$|[\\/]\(modules\)[\\/]/;
|
|
177
|
+
for (const directory of ["src", "worker", "packages"]) {
|
|
178
|
+
for (const file of files(join(root, directory))) {
|
|
179
|
+
if (generated.test(file)) continue;
|
|
180
|
+
for (const specifier of specifiers(file)) {
|
|
181
|
+
if (specifier.startsWith("@hearthlog/module-")) problems.push(`${relative(root, file)}: the core imports a module ("${specifier}")`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
problems.push(...coreNamesNoDomain(root, undefined, domain));
|
|
186
|
+
problems.push(...directGeneratedImports(root));
|
|
187
|
+
return problems;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
191
|
+
const problems = checkBoundaries(ROOT, await loadModuleDomain(ROOT));
|
|
192
|
+
if (problems.length) {
|
|
193
|
+
console.error(`Module boundaries are broken:\n - ${problems.join("\n - ")}`);
|
|
194
|
+
process.exitCode = 1;
|
|
195
|
+
} else {
|
|
196
|
+
console.log("Module boundaries hold.");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Every name a module's Svelte files import from `@hearthlog/sdk/*` must be an
|
|
4
|
+
* export of that SDK entry. `tsc` does not read `.svelte` files, so a missing
|
|
5
|
+
* export would otherwise surface only in the production build.
|
|
6
|
+
*
|
|
7
|
+
* It collects the names, writes them as one virtual TypeScript file, and
|
|
8
|
+
* type-checks that file inside the root program.
|
|
9
|
+
*
|
|
10
|
+
* Usage: pnpm check:sdk-exports
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { hostRoot } from "../root.js";
|
|
17
|
+
|
|
18
|
+
import ts from "typescript";
|
|
19
|
+
|
|
20
|
+
const ROOT = hostRoot();
|
|
21
|
+
const IMPORT = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+["'](@hearthlog\/sdk\/[a-z]+)["']/g;
|
|
22
|
+
|
|
23
|
+
function svelteFiles(directory: string): string[] {
|
|
24
|
+
return readdirSync(directory).flatMap((name) => {
|
|
25
|
+
if (name === "node_modules" || name.startsWith(".")) return [];
|
|
26
|
+
const path = join(directory, name);
|
|
27
|
+
return statSync(path).isDirectory() ? svelteFiles(path) : name.endsWith(".svelte") ? [path] : [];
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function importedNames(root = ROOT): Map<string, Set<string>> {
|
|
32
|
+
const names = new Map<string, Set<string>>();
|
|
33
|
+
for (const module of existsSync(join(root, "modules")) ? readdirSync(join(root, "modules")) : []) {
|
|
34
|
+
const source = join(root, "modules", module);
|
|
35
|
+
if (!statSync(source).isDirectory()) continue;
|
|
36
|
+
for (const file of [...svelteFiles(join(source, "src")), ...(statSync(join(source, "public"), { throwIfNoEntry: false }) ? svelteFiles(join(source, "public")) : [])]) {
|
|
37
|
+
for (const match of readFileSync(file, "utf8").matchAll(IMPORT)) {
|
|
38
|
+
const set = names.get(match[2]!) ?? new Set<string>();
|
|
39
|
+
for (const part of match[1]!.split(",")) {
|
|
40
|
+
const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]!.trim();
|
|
41
|
+
if (name) set.add(name);
|
|
42
|
+
}
|
|
43
|
+
names.set(match[2]!, set);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return names;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function missingExports(root = ROOT): string[] {
|
|
51
|
+
const names = importedNames(root);
|
|
52
|
+
const virtualFile = join(root, "modules", "__sdk-exports.ts");
|
|
53
|
+
const text = [...names].map(([entry, set]) => `import type { ${[...set].sort().join(", ")} } from '${entry}';`).join("\n");
|
|
54
|
+
const configPath = join(root, "tsconfig.json");
|
|
55
|
+
const config = ts.getParsedCommandLineOfConfigFile(configPath, {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => undefined });
|
|
56
|
+
if (!config) throw new Error("Cannot read tsconfig.json");
|
|
57
|
+
const host = ts.createCompilerHost(config.options);
|
|
58
|
+
const readFile = host.readFile.bind(host);
|
|
59
|
+
const fileExists = host.fileExists.bind(host);
|
|
60
|
+
const getSourceFile = host.getSourceFile.bind(host);
|
|
61
|
+
host.fileExists = (path) => path === virtualFile || fileExists(path);
|
|
62
|
+
host.readFile = (path) => (path === virtualFile ? text : readFile(path));
|
|
63
|
+
host.getSourceFile = (path, languageVersion, onError, shouldCreate) =>
|
|
64
|
+
path === virtualFile ? ts.createSourceFile(path, text, languageVersion) : getSourceFile(path, languageVersion, onError, shouldCreate);
|
|
65
|
+
const program = ts.createProgram([virtualFile], config.options, host);
|
|
66
|
+
return ts.getPreEmitDiagnostics(program, program.getSourceFile(virtualFile))
|
|
67
|
+
.filter((diagnostic) => [2305, 2724, 2307].includes(diagnostic.code))
|
|
68
|
+
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
72
|
+
const problems = missingExports();
|
|
73
|
+
if (problems.length) {
|
|
74
|
+
console.error(`A module imports names the SDK does not export:\n - ${problems.join("\n - ")}`);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
} else {
|
|
77
|
+
console.log("Every SDK import in module Svelte files resolves.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Tenant Config Check
|
|
4
|
+
*
|
|
5
|
+
* Validates tenants.json end to end and verifies the generated per-site
|
|
6
|
+
* wrangler configs. The tenant files are the single source of truth.
|
|
7
|
+
*
|
|
8
|
+
* Usage: pnpm check:tenants
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { join, relative } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { gatewayEntryPath, loadModules } from "../compose/compose-modules.js";
|
|
15
|
+
import {
|
|
16
|
+
infrastructureFor,
|
|
17
|
+
loadTenantConfig,
|
|
18
|
+
validateCrossSite,
|
|
19
|
+
validateDeploymentIsolation,
|
|
20
|
+
REPOSITORY_ROOT,
|
|
21
|
+
type InfrastructureDefinition,
|
|
22
|
+
type ResolvedSite,
|
|
23
|
+
} from "../tenants/tenant-config.js";
|
|
24
|
+
import {
|
|
25
|
+
buildTenantWranglerConfig,
|
|
26
|
+
generateTenantWranglerConfigs,
|
|
27
|
+
integrationsVariable,
|
|
28
|
+
moduleBindings,
|
|
29
|
+
serverDeclarations,
|
|
30
|
+
siteIdentityVariables,
|
|
31
|
+
siteBuildDirectory,
|
|
32
|
+
siteConfigDirectory,
|
|
33
|
+
sweepsSharedStack,
|
|
34
|
+
workerFirstEntries,
|
|
35
|
+
wranglerConfigPath,
|
|
36
|
+
} from "../tenants/wrangler-config.js";
|
|
37
|
+
import { WRANGLER_BASE } from "../tenants/wrangler-base.js";
|
|
38
|
+
import { generateHostConfigs, hostWorkerConfigPath } from "../tenants/host-configs.js";
|
|
39
|
+
import { moduleLockProblems } from "../tenants/module-lock.js";
|
|
40
|
+
import { isTenantSchemaCurrent } from "../tenants/tenant-schema.js";
|
|
41
|
+
|
|
42
|
+
function checkBase(): string[] {
|
|
43
|
+
const problems: string[] = [];
|
|
44
|
+
if (WRANGLER_BASE.assets.binding !== "ASSETS") problems.push('wrangler base: assets.binding must be "ASSETS"');
|
|
45
|
+
if (WRANGLER_BASE.assets.run_worker_first[0] !== "/api/ledger") problems.push('wrangler base: assets.run_worker_first must keep "/api/ledger" first');
|
|
46
|
+
if (WRANGLER_BASE.ai.binding !== "AI") problems.push("wrangler base: the AI binding is missing");
|
|
47
|
+
return problems;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A site's config carries exactly the bindings, variables, and gateway table its modules declare. */
|
|
51
|
+
function checkModuleBindings(
|
|
52
|
+
label: string,
|
|
53
|
+
site: Pick<ResolvedSite, "id" | "features">,
|
|
54
|
+
infrastructure: InfrastructureDefinition,
|
|
55
|
+
config: { vars: Record<string, unknown>; r2Buckets: Array<Record<string, unknown>>; images: unknown; main: unknown; workerFirst?: readonly string[] },
|
|
56
|
+
): string[] {
|
|
57
|
+
const problems: string[] = [];
|
|
58
|
+
const expected = moduleBindings(site.features, infrastructure);
|
|
59
|
+
for (const { binding, bucket_name } of expected.r2) {
|
|
60
|
+
if (config.r2Buckets.find((bucket) => bucket.binding === binding)?.bucket_name !== bucket_name) {
|
|
61
|
+
problems.push(`${label}: ${binding} bucket should be ${bucket_name}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const everyModuleBinding = loadModules().flatMap(({ manifest }) => manifest.server?.bindings ?? []);
|
|
65
|
+
const wanted = new Set(expected.r2.map((entry) => entry.binding));
|
|
66
|
+
for (const { binding, type } of everyModuleBinding) {
|
|
67
|
+
if (type === "r2" && !wanted.has(binding) && config.r2Buckets.some((bucket) => bucket.binding === binding)) {
|
|
68
|
+
problems.push(`${label}: binds ${binding}, which none of its modules declare`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (expected.images !== (typeof config.images === "object" && config.images !== null)) {
|
|
72
|
+
problems.push(`${label}: the images binding ${expected.images ? "is missing" : "belongs to a module the site does not enable"}`);
|
|
73
|
+
}
|
|
74
|
+
for (const [name, value] of Object.entries(expected.vars)) {
|
|
75
|
+
if (config.vars[name] !== value) problems.push(`${label}: vars.${name} should be ${value}`);
|
|
76
|
+
}
|
|
77
|
+
const everyVar = loadModules().flatMap(({ manifest }) => manifest.server?.vars ?? []);
|
|
78
|
+
for (const { name } of everyVar) {
|
|
79
|
+
if (!(name in expected.vars) && name in config.vars) problems.push(`${label}: vars.${name} belongs to a module the site does not enable`);
|
|
80
|
+
}
|
|
81
|
+
const entry = relative(siteConfigDirectory(site.id), gatewayEntryPath(site.id));
|
|
82
|
+
if (typeof config.main !== "string" || config.main.replace(/^\.\//, "") !== entry) {
|
|
83
|
+
problems.push(`${label}: main should be ./${entry}`);
|
|
84
|
+
}
|
|
85
|
+
// Every route a module serves must reach the Worker before the static assets.
|
|
86
|
+
for (const entry of workerFirstEntries(serverDeclarations(site.features).flatMap((server) => server.routes ?? []))) {
|
|
87
|
+
// A wildcard of a shorter prefix covers it (`/api/invoice-generation/*` covers `/api/invoice-generation/logs`).
|
|
88
|
+
const covered = (config.workerFirst ?? []).some((have) => have === entry || (have.endsWith("/*") && entry.startsWith(have.slice(0, -1))));
|
|
89
|
+
if (config.workerFirst && !covered) problems.push(`${label}: run_worker_first is missing ${entry}`);
|
|
90
|
+
}
|
|
91
|
+
return problems;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function checkGeneratedSite(
|
|
95
|
+
site: ResolvedSite,
|
|
96
|
+
infrastructure: InfrastructureDefinition,
|
|
97
|
+
generated: Record<string, unknown>,
|
|
98
|
+
): string[] {
|
|
99
|
+
const problems: string[] = [];
|
|
100
|
+
const asRecord = (value: unknown) => (value ?? {}) as Record<string, unknown>;
|
|
101
|
+
const label = `generated ${site.id}`;
|
|
102
|
+
|
|
103
|
+
if (generated.name !== site.id) {
|
|
104
|
+
problems.push(`${label}: name is ${JSON.stringify(generated.name)}`);
|
|
105
|
+
}
|
|
106
|
+
if (asRecord(generated.assets).directory !== relative(siteConfigDirectory(site.id), join(REPOSITORY_ROOT, siteBuildDirectory(site.id)))) {
|
|
107
|
+
problems.push(
|
|
108
|
+
`${label}: assets.directory is ${JSON.stringify(
|
|
109
|
+
asRecord(generated.assets).directory,
|
|
110
|
+
)}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const vars = asRecord(generated.vars);
|
|
114
|
+
if (vars.STOR_ADMIN_ENABLED !== String(site.admin)) {
|
|
115
|
+
problems.push(`${label}: vars.STOR_ADMIN_ENABLED mismatch`);
|
|
116
|
+
}
|
|
117
|
+
if (vars.LEDGER_ADMIN_EMAIL !== infrastructure.ledgerAdminEmail) {
|
|
118
|
+
problems.push(`${label}: vars.LEDGER_ADMIN_EMAIL mismatch`);
|
|
119
|
+
}
|
|
120
|
+
if (vars.STOR_AUTHORIZED_EMAILS !== site.authorizedEmails.join(",")) {
|
|
121
|
+
problems.push(
|
|
122
|
+
`${label}: vars.STOR_AUTHORIZED_EMAILS does not match the authorized email list`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (vars.AI_CAPABILITIES !== site.ai.join(",")) {
|
|
126
|
+
problems.push(`${label}: vars.AI_CAPABILITIES does not match the site's ai list`);
|
|
127
|
+
}
|
|
128
|
+
for (const [name, value] of Object.entries(siteIdentityVariables(site, infrastructure))) {
|
|
129
|
+
if (vars[name] !== value) problems.push(`${label}: vars.${name} does not match tenants.json`);
|
|
130
|
+
}
|
|
131
|
+
if (vars.INTEGRATIONS !== integrationsVariable(site)) {
|
|
132
|
+
problems.push(`${label}: vars.INTEGRATIONS does not match the site's integrations`);
|
|
133
|
+
}
|
|
134
|
+
if (asRecord(generated.ai).binding !== "AI") {
|
|
135
|
+
problems.push(`${label}: AI binding is missing`);
|
|
136
|
+
}
|
|
137
|
+
const services = (generated.services ?? []) as Array<Record<string, unknown>>;
|
|
138
|
+
if (
|
|
139
|
+
services.find((service) => service.binding === "LEDGER")?.service !==
|
|
140
|
+
infrastructure.ledgerWorker
|
|
141
|
+
) {
|
|
142
|
+
problems.push(`${label}: LEDGER binding does not target the Ledger`);
|
|
143
|
+
}
|
|
144
|
+
const r2Buckets = (generated.r2_buckets ?? []) as Array<Record<string, unknown>>;
|
|
145
|
+
problems.push(...checkModuleBindings(label, site, infrastructure, { vars, r2Buckets, images: generated.images, main: generated.main, workerFirst: (generated.assets as { run_worker_first?: string[] } | undefined)?.run_worker_first }));
|
|
146
|
+
return problems;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { infrastructure, sites, stacks } = await loadTenantConfig();
|
|
150
|
+
validateCrossSite(sites);
|
|
151
|
+
validateDeploymentIsolation(sites, infrastructure);
|
|
152
|
+
|
|
153
|
+
const problems: string[] = [...checkBase()];
|
|
154
|
+
|
|
155
|
+
// Generate every site's config and verify the content against the tenant files.
|
|
156
|
+
await generateTenantWranglerConfigs();
|
|
157
|
+
for (const site of sites) {
|
|
158
|
+
const siteInfrastructure = infrastructureFor(site, infrastructure);
|
|
159
|
+
const sweeps = sweepsSharedStack(stacks, infrastructure, site);
|
|
160
|
+
const raw = readFileSync(wranglerConfigPath(site.id), "utf8");
|
|
161
|
+
const generated = JSON.parse(raw) as Record<string, unknown>;
|
|
162
|
+
problems.push(...checkGeneratedSite(site, siteInfrastructure, generated));
|
|
163
|
+
const label = `generated ${site.id}`;
|
|
164
|
+
const scheduled = serverDeclarations(site.features).some((server) => server.scheduled);
|
|
165
|
+
// The shared stack's public copies are swept by one site; a site on its own stack sweeps its own.
|
|
166
|
+
if (sweeps && scheduled && JSON.stringify(generated.triggers) !== JSON.stringify(WRANGLER_BASE.triggers)) {
|
|
167
|
+
problems.push(`${label}: this site must sweep its public Gallery bucket (triggers)`);
|
|
168
|
+
}
|
|
169
|
+
if (!(sweeps && scheduled) && generated.triggers !== undefined) {
|
|
170
|
+
problems.push(`${label}: only the site that owns the public Gallery bucket sweeps it`);
|
|
171
|
+
}
|
|
172
|
+
const expected = buildTenantWranglerConfig(site, siteInfrastructure, { sweeps });
|
|
173
|
+
if (JSON.stringify(generated) !== JSON.stringify(expected)) problems.push(`${label}: the file differs from the builder output`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The Workers of each stack: the shared one (when a site uses it) and every site's own.
|
|
177
|
+
const stackNames = [...(stacks.some((stack) => stack.shared) ? ["shared"] : []), ...sites.filter((site) => site.deployment).map((site) => site.id)];
|
|
178
|
+
for (const stack of stackNames) {
|
|
179
|
+
const siteInfrastructure = stack === "shared" ? infrastructure : infrastructureFor(sites.find((site) => site.id === stack)!, infrastructure);
|
|
180
|
+
await generateHostConfigs(stack);
|
|
181
|
+
const read = (worker: "ledger" | "calendar-public" | "gallery-public") => JSON.parse(readFileSync(hostWorkerConfigPath(stack, worker), "utf8")) as Record<string, unknown>;
|
|
182
|
+
const label = `generated ${stack} stack`;
|
|
183
|
+
if (read("ledger").name !== siteInfrastructure.ledgerWorker) problems.push(`${label}: the Ledger Worker should be named ${siteInfrastructure.ledgerWorker}`);
|
|
184
|
+
const feedSites = (read("calendar-public").services as Array<{ service: string }>).map((service) => service.service).sort();
|
|
185
|
+
const expectedSites = (stack === "shared" ? stacks.filter((entry) => entry.shared).map((entry) => entry.id) : [stack]).sort();
|
|
186
|
+
if (JSON.stringify(feedSites) !== JSON.stringify(expectedSites)) problems.push(`${label}: the feed Worker should bind ${expectedSites.join(", ")}`);
|
|
187
|
+
// A site on its own stack has a public Gallery Worker only when it ships the Gallery; the shared stack always has one.
|
|
188
|
+
const ownSite = stack === "shared" ? undefined : sites.find((site) => site.id === stack)!;
|
|
189
|
+
if ((!ownSite || ownSite.features.includes("gallery")) && read("gallery-public").name !== siteInfrastructure.galleryPublicWorker) problems.push(`${label}: the public Gallery Worker should be named ${siteInfrastructure.galleryPublicWorker}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const site of sites) problems.push(...(await moduleLockProblems(site)));
|
|
193
|
+
|
|
194
|
+
if (!(await isTenantSchemaCurrent())) {
|
|
195
|
+
problems.push("tenants.schema.json is stale; run pnpm generate:tenant-schema");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (problems.length > 0) {
|
|
199
|
+
console.error("Tenant configuration is inconsistent:");
|
|
200
|
+
for (const problem of problems) console.error(` - ${problem}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(
|
|
205
|
+
`Tenant configuration OK: ${sites.length} site(s) (${sites
|
|
206
|
+
.map((site) => site.id)
|
|
207
|
+
.join(", ")}), generated wrangler configs verified.`,
|
|
208
|
+
);
|