@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.
Files changed (86) hide show
  1. package/LICENSE +235 -0
  2. package/bin/hearthlog.mjs +61 -0
  3. package/package.json +64 -3
  4. package/src/apply.ts +73 -0
  5. package/src/check/check-layers.ts +118 -0
  6. package/src/check/check-modules.ts +198 -0
  7. package/src/check/check-sdk-exports.ts +79 -0
  8. package/src/check/check-tenant-config.ts +208 -0
  9. package/src/cli/hearthlog.ts +212 -0
  10. package/src/cli/list-sites.ts +15 -0
  11. package/src/compose/compose-modules.ts +490 -0
  12. package/src/compose/module-scaffold.ts +195 -0
  13. package/src/deploy/artifact-compare.ts +93 -0
  14. package/src/deploy/artifact.ts +127 -0
  15. package/src/deploy/async-utils.ts +71 -0
  16. package/src/deploy/backup-tenants.ts +139 -0
  17. package/src/deploy/cloudflare-access.ts +789 -0
  18. package/src/deploy/copy-tenant.ts +395 -0
  19. package/src/deploy/dedicated-ledger.ts +77 -0
  20. package/src/deploy/deploy-ledger.ts +162 -0
  21. package/src/deploy/deploy-runner.ts +240 -0
  22. package/src/deploy/deploy-tenant.ts +170 -0
  23. package/src/deploy/deployment-security.ts +141 -0
  24. package/src/deploy/ensure-tenant.ts +988 -0
  25. package/src/deploy/event-policy.ts +23 -0
  26. package/src/deploy/frontend-dry-run.ts +18 -0
  27. package/src/deploy/ledger-credential.ts +48 -0
  28. package/src/deploy/ledger-scripts.ts +13 -0
  29. package/src/deploy/split-tenant.ts +295 -0
  30. package/src/dev/local-development.ts +288 -0
  31. package/src/dev/provision-local.ts +13 -0
  32. package/src/generate/build-generated.ts +166 -0
  33. package/src/generate/generate-brand-icons.ts +128 -0
  34. package/src/generate/generate-entities.ts +627 -0
  35. package/src/generate/generate-tenant.ts +161 -0
  36. package/src/host/core-routes.ts +39 -0
  37. package/src/host/write-host.ts +119 -0
  38. package/src/index.ts +8 -0
  39. package/src/init/check-module.ts +143 -0
  40. package/src/init/export-tenant.ts +124 -0
  41. package/src/init/init.ts +168 -0
  42. package/src/move.ts +189 -0
  43. package/src/plan.ts +160 -0
  44. package/src/public-workers/build-gallery-public.ts +114 -0
  45. package/src/public-workers/calendar-public-config.ts +27 -0
  46. package/src/public-workers/check-calendar-public-route.ts +41 -0
  47. package/src/public-workers/check-gallery-public-route.ts +43 -0
  48. package/src/public-workers/gallery-public-config.ts +22 -0
  49. package/src/receipt.ts +74 -0
  50. package/src/root.ts +16 -0
  51. package/src/security/audit-push.ts +141 -0
  52. package/src/security/forbidden-identities.ts +107 -0
  53. package/src/security/forbidden-init.ts +88 -0
  54. package/src/tenants/host-configs.ts +96 -0
  55. package/src/tenants/ledger-config.ts +57 -0
  56. package/src/tenants/lock-file.ts +45 -0
  57. package/src/tenants/module-lock.ts +58 -0
  58. package/src/tenants/tenant-config.ts +887 -0
  59. package/src/tenants/tenant-copy-plan.ts +119 -0
  60. package/src/tenants/tenant-schema.ts +171 -0
  61. package/src/tenants/tenant-wrangler.ts +37 -0
  62. package/src/tenants/wrangler-base.ts +50 -0
  63. package/src/tenants/wrangler-config.ts +266 -0
  64. package/src/vite/config.ts +310 -0
  65. package/src/vite/dev-plugins.ts +338 -0
  66. package/src/vite/vite-plugin-entity-gen.ts +70 -0
  67. package/src/vite/vite-plugin-tenant-manifest.ts +61 -0
  68. package/src/vite/vite-plugin-worker-assets.ts +42 -0
  69. package/templates/host/app.d.ts.txt +31 -0
  70. package/templates/host/microlighter.d.ts.txt +11 -0
  71. package/templates/module/ci.yml.tmpl +44 -0
  72. package/templates/module/gitignore.tmpl +7 -0
  73. package/templates/module/gitleaks.toml.tmpl +11 -0
  74. package/templates/module/release.yml.tmpl +53 -0
  75. package/templates/tenant/README.md.tmpl +13 -0
  76. package/templates/tenant/generated-package.json.tmpl +16 -0
  77. package/templates/tenant/github-workflows/deploy.yml.tmpl +111 -0
  78. package/templates/tenant/gitignore.tmpl +11 -0
  79. package/templates/tenant/package.json.tmpl +11 -0
  80. package/templates/tenant/patches/sqlocal@0.18.0.patch +18 -0
  81. package/templates/tenant/pnpm-workspace.yaml.tmpl +8 -0
  82. package/templates/tenant/tenants/__id__/tenant.json.tmpl +8 -0
  83. package/templates/tenant/tenants.json.tmpl +17 -0
  84. package/templates/tenant/tsconfig.json.tmpl +21 -0
  85. package/templates/tenant/vite.config.ts.tmpl +5 -0
  86. package/README.md +0 -3
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Creating and exporting modules (plan, phases 7.4 and 7.6).
4
+ *
5
+ * pnpm module:new -- <name> [--feature <id>] a working module from the template, in modules/<name>/
6
+ * pnpm module:export -- <name> --out <directory> that module as a repository of its own
7
+ *
8
+ * A module made here composes without a single edit to the core: it needs only
9
+ * its directory, and a tenant that lists its feature. The export is the layout
10
+ * of a module repository: the module at the root with its own license and README, a `files` list that leaves out
11
+ * the tests, provenance publishing, the platform as peers and dev dependencies, `pnpm test` (which is `hearthlog
12
+ * check-module`: a site that enables the module builds, then its unit tests run), and the CI and tag-driven release workflows.
13
+ */
14
+
15
+ import { cpSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
16
+ import { dirname, join, relative, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { MODULE_API_VERSION } from "@hearthlog/contracts";
20
+
21
+ import { REPOSITORY_ROOT } from "../tenants/tenant-config.js";
22
+
23
+ const TEMPLATE = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates", "module");
24
+ const NAME = /^[a-z][a-z0-9-]{1,30}$/;
25
+
26
+ export interface ScaffoldOptions {
27
+ name: string;
28
+ /** The feature id a tenant lists; the module's name by default. Persisted in tenants, so never renamed. */
29
+ feature?: string;
30
+ publisher?: string;
31
+ }
32
+
33
+ function title(name: string): string {
34
+ return name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, " ");
35
+ }
36
+
37
+ /** The files of a minimal, working module: one feature, one page, no data. */
38
+ export function moduleTemplate(options: ScaffoldOptions): Record<string, string> {
39
+ const { name } = options;
40
+ if (!NAME.test(name)) throw new Error(`"${name}" must be lowercase letters, digits and hyphens`);
41
+ const feature = options.feature ?? name;
42
+ if (!NAME.test(feature)) throw new Error(`The feature id "${feature}" must be lowercase letters, digits and hyphens`);
43
+ const publisher = options.publisher ?? "org.example";
44
+ const label = title(name);
45
+ return {
46
+ "package.json": `${JSON.stringify({
47
+ name: `@hearthlog/module-${name}`,
48
+ version: "0.1.0",
49
+ license: "AGPL-3.0-only",
50
+ private: true,
51
+ type: "module",
52
+ exports: {
53
+ "./hearthlog.module.json": "./hearthlog.module.json",
54
+ "./pages/*.js": "./src/pages/*.ts",
55
+ "./pages/*.svelte": "./src/pages/*.svelte",
56
+ },
57
+ dependencies: { "@hearthlog/sdk": "workspace:*" },
58
+ }, null, 2)}\n`,
59
+ "hearthlog.module.json": `${JSON.stringify({
60
+ manifestVersion: 1,
61
+ id: `${publisher}.${name}`,
62
+ package: `@hearthlog/module-${name}`,
63
+ version: "0.1.0",
64
+ compatibility: { moduleApi: MODULE_API_VERSION },
65
+ features: [{
66
+ id: feature,
67
+ path: `/${feature}`,
68
+ label,
69
+ subtitle: `${label} for your workspace`,
70
+ keywords: `${name} ${feature}`,
71
+ navGroup: "Tools",
72
+ icon: "puzzle-outline",
73
+ }],
74
+ routes: [{ path: `/${feature}`, page: "./src/pages/home.svelte", load: "./src/pages/home.ts" }],
75
+ }, null, 2)}\n`,
76
+ "src/pages/home.ts": `import { deferLocalPage } from "@hearthlog/sdk/runtime";
77
+
78
+ export const load = ({ url }: { url: URL }) => deferLocalPage(url, {}, async () => ({}));
79
+ `,
80
+ "src/pages/home.svelte": `<script lang="ts">
81
+ import { PageHeader } from "@hearthlog/sdk/ui";
82
+ import { tenant } from "@hearthlog/sdk/runtime";
83
+ </script>
84
+
85
+ <svelte:head><title>${label} | {tenant.title}</title></svelte:head>
86
+ <div class="stor-page w-full p-2.5 sm:p-3">
87
+ <PageHeader title="${label}" />
88
+ <section class="stor-panel p-[var(--stor-panel-padding)]">
89
+ <p class="text-sm text-[var(--stor-muted-text)]">${label} is installed.</p>
90
+ </section>
91
+ </div>
92
+ `,
93
+ "README.md": `# ${label}
94
+
95
+ A module for the stor platform. It reaches the app only through \`@hearthlog/sdk\`; see
96
+ \`docs/modules.md\` in the platform repository for what a module may declare (pages, entities,
97
+ repositories, gateway code, startup code, attention badges) and \`pnpm check:modules\` for the
98
+ boundary it enforces.
99
+ `,
100
+ };
101
+ }
102
+
103
+ export function scaffoldModule(modulesDirectory: string, options: ScaffoldOptions): string {
104
+ const directory = join(modulesDirectory, options.name);
105
+ if (existsSync(directory)) throw new Error(`${relative(REPOSITORY_ROOT, directory)} already exists`);
106
+ for (const [file, content] of Object.entries(moduleTemplate(options))) {
107
+ const path = join(directory, file);
108
+ mkdirSync(dirname(path), { recursive: true });
109
+ writeFileSync(path, content);
110
+ }
111
+ return directory;
112
+ }
113
+
114
+ /**
115
+ * A module as a repository of its own: its files at the root, `workspace:`
116
+ * references turned into ranges, a license, and a README that says what is
117
+ * still missing (CI and publishing, which wait for the platform packages).
118
+ */
119
+ export function exportModule(modulesDirectory: string, name: string, out: string, platformRange = ">=0.1.0 <0.2.0"): string[] {
120
+ const source = join(modulesDirectory, name);
121
+ if (!existsSync(join(source, "hearthlog.module.json"))) throw new Error(`modules/${name} is not a module`);
122
+ if (existsSync(out) && readdirSync(out).length > 0) throw new Error(`${out} is not empty`);
123
+ cpSync(source, out, { recursive: true, filter: (path) => !/(^|\/)(node_modules|\.git|dist)(\/|$)/.test(path) });
124
+ const packagePath = join(out, "package.json");
125
+ const manifest = JSON.parse(readFileSync(packagePath, "utf8")) as {
126
+ name?: string; private?: boolean; dependencies?: Record<string, string>; peerDependencies?: Record<string, string>; license?: string;
127
+ };
128
+ const platform = Object.keys(manifest.dependencies ?? {}).filter((dependency) => dependency.startsWith("@hearthlog/"));
129
+ // A platform package the sources import must be declared, or the repository cannot resolve it.
130
+ const imported = new Set<string>();
131
+ for (const file of listFiles(join(out, "src")).filter((candidate) => /\.(ts|svelte)$/.test(candidate))) {
132
+ for (const match of readFileSync(file, "utf8").matchAll(/from ["'](@hearthlog\/[a-z-]+)[/"']/g)) imported.add(match[1]!);
133
+ }
134
+ const undeclared = [...imported].filter((dependency) => !platform.includes(dependency) && dependency !== manifest.name && !dependency.startsWith("@hearthlog/module-"));
135
+ if (undeclared.length > 0) throw new Error(`modules/${name} imports ${undeclared.join(", ")} without declaring it in its package.json`);
136
+ manifest.peerDependencies = { ...(manifest.peerDependencies ?? {}), ...Object.fromEntries(platform.map((dependency) => [dependency, platformRange])) };
137
+ for (const dependency of platform) delete manifest.dependencies![dependency];
138
+ if (manifest.dependencies && Object.keys(manifest.dependencies).length === 0) delete manifest.dependencies;
139
+ delete manifest.private;
140
+ manifest.license = "AGPL-3.0-only";
141
+ const root = JSON.parse(readFileSync(join(REPOSITORY_ROOT, "package.json"), "utf8")) as { devDependencies?: Record<string, string>; packageManager?: string };
142
+ const publishable = {
143
+ ...manifest,
144
+ repository: { type: "git", url: `git+https://github.com/hearthlog/hearthlog-module-${name}.git` },
145
+ publishConfig: { access: "public", provenance: true },
146
+ files: ["src", ...(existsSync(join(out, "public")) ? ["public"] : []), "hearthlog.module.json", "!**/*.test.ts"],
147
+ packageManager: root.packageManager,
148
+ scripts: { test: "hearthlog check-module" },
149
+ // The platform the module is developed and tested against; the peer range says which releases it accepts.
150
+ devDependencies: {
151
+ ...Object.fromEntries([...platform, "@hearthlog/tooling"].sort().map((dependency) => [dependency, platformRange])),
152
+ vitest: root.devDependencies?.vitest ?? "^4.1.11",
153
+ },
154
+ };
155
+ writeFileSync(packagePath, `${JSON.stringify(publishable, null, 2)}\n`);
156
+ mkdirSync(join(out, ".github", "workflows"), { recursive: true });
157
+ copyFileSync(join(TEMPLATE, "gitignore.tmpl"), join(out, ".gitignore"));
158
+ copyFileSync(join(TEMPLATE, "gitleaks.toml.tmpl"), join(out, ".gitleaks.toml"));
159
+ // vitest's esbuild needs its install script; pnpm 10 asks for it by name.
160
+ writeFileSync(join(out, "pnpm-workspace.yaml"), "onlyBuiltDependencies:\n - esbuild\n");
161
+ copyFileSync(join(TEMPLATE, "ci.yml.tmpl"), join(out, ".github", "workflows", "ci.yml"));
162
+ copyFileSync(join(TEMPLATE, "release.yml.tmpl"), join(out, ".github", "workflows", "release.yml"));
163
+ cpSync(join(REPOSITORY_ROOT, "LICENSE"), join(out, "LICENSE"));
164
+ const readme = join(out, "README.md");
165
+ const existing = existsSync(readme) ? readFileSync(readme, "utf8").trimEnd() : `# ${title(name)}`;
166
+ writeFileSync(readme, `${existing}\n\n## Status\n\nIt installs against \`@hearthlog/sdk\` ${platformRange}.\n\n\`\`\`bash\npnpm install\npnpm test # generates a site that enables this module, installs it, builds it, then runs the unit tests\n\`\`\`\n\nRelease: set \`version\` in \`package.json\`, commit, and push the tag \`v<version>\`; the release workflow publishes with provenance after approval.\n`);
167
+ return listFiles(out).map((file) => relative(out, file)).sort();
168
+ }
169
+
170
+ function listFiles(directory: string): string[] {
171
+ return readdirSync(directory).flatMap((entry) => {
172
+ const path = join(directory, entry);
173
+ return statSync(path).isDirectory() ? listFiles(path) : [path];
174
+ });
175
+ }
176
+
177
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
178
+ const args = process.argv.slice(2).filter((argument) => argument !== "--");
179
+ const [command, name] = args;
180
+ const value = (flag: string) => {
181
+ const index = args.indexOf(flag);
182
+ return index === -1 ? undefined : args[index + 1];
183
+ };
184
+ const modules = join(REPOSITORY_ROOT, "modules");
185
+ if (command === "new" && name) {
186
+ const directory = scaffoldModule(modules, { name, feature: value("--feature") });
187
+ console.log(`Created ${relative(REPOSITORY_ROOT, directory)}. Next:\n 1. pnpm install\n 2. pnpm generate:modules && pnpm generate:entities\n 3. add "${value("--feature") ?? name}" to a site's features in tenants.json, then pnpm compose`);
188
+ } else if (command === "export" && name && value("--out")) {
189
+ const files = exportModule(modules, name, resolve(value("--out")!));
190
+ console.log(`Exported ${files.length} file(s) of modules/${name} to ${resolve(value("--out")!)}`);
191
+ } else {
192
+ console.error("Usage: pnpm module:new -- <name> [--feature <id>]\n pnpm module:export -- <name> --out <directory>");
193
+ process.exit(1);
194
+ }
195
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What a built site ships, reduced to what must not change when the same site is built somewhere else (another
3
+ * checkout, another lockfile): its routes, the names of its chunks without their content hashes, its offline precache
4
+ * list, the gateway table, the Ledger event contracts, and the generated Worker configuration. File hashes are not part
5
+ * of the comparison (a different install changes them); how many files agree byte for byte is reported only.
6
+ *
7
+ * `hearthlog artifact compare --site <id> --against <host root>` builds the comparison of the host in the current
8
+ * directory with another built host (the handover gate of a site that moves into a repository of its own), and
9
+ * `scripts/tarball-equivalence.ts` uses the same functions to compare a workspace build with one made from tarballs.
10
+ */
11
+
12
+ import { createHash } from "node:crypto";
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { createRequire } from "node:module";
15
+ import { dirname, join, relative } from "node:path";
16
+
17
+ import { siteBuildDirectory } from "../tenants/wrangler-config.js";
18
+
19
+ export interface Fingerprint {
20
+ routes: string[];
21
+ chunks: string[];
22
+ precache: string[];
23
+ contents: string[];
24
+ gateway: string;
25
+ contracts: string;
26
+ wrangler: string;
27
+ }
28
+
29
+ function walk(directory: string): string[] {
30
+ if (!existsSync(directory)) return [];
31
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
32
+ const path = join(directory, entry.name);
33
+ return entry.isDirectory() ? walk(path) : [path];
34
+ });
35
+ }
36
+
37
+ /** `name-AbC123xy.js` and `AbC123xy.js` become `name-#.js` and `#.js`: Vite and Kit name chunks by content hash. */
38
+ export function unhash(path: string): string {
39
+ return path.replace(/(^|[-./])[A-Za-z0-9_-]{8}(?=\.[a-z0-9]+$)/, "$1#");
40
+ }
41
+
42
+ /** The fingerprint of the site built in `root` (its `build` directory, Kit's output directory, and `.generated`). */
43
+ export async function fingerprint(root: string, siteId: string, kitDirectory: string = join(root, ".svelte-kit")): Promise<Fingerprint> {
44
+ const shell = dirname(createRequire(join(root, "package.json")).resolve("@hearthlog/shell/package.json"));
45
+ const offline = await import(join(shell, "src/service-worker/offline-bundle.ts"));
46
+ const buildDirectory = join(root, siteBuildDirectory(siteId));
47
+ const generated = join(root, ".generated");
48
+ if (!existsSync(join(buildDirectory, "service-worker.js"))) throw new Error(`${root} has no build of ${siteId}: run its build first`);
49
+ const precache: string[] = offline
50
+ .precacheEntries([...offline.parseServiceWorkerManifestPaths(readFileSync(join(buildDirectory, "service-worker.js"), "utf8")), ...offline.PRECACHE_EXTRA_PATHS].map((path: string) => ({ path, revision: null })))
51
+ .map((entry: { url: string }) => unhash(entry.url));
52
+ const manifest = readFileSync(join(kitDirectory, "output/server/manifest.js"), "utf8");
53
+ const routes = [...manifest.matchAll(/id:\s*"([^"]*)"/g)].map((match) => match[1]!).sort();
54
+ const paths = walk(join(buildDirectory, "_app"));
55
+ return {
56
+ routes,
57
+ chunks: paths.map((path) => unhash(relative(buildDirectory, path))).sort(),
58
+ precache: precache.sort(),
59
+ contents: paths.map((path) => createHash("sha256").update(readFileSync(path)).digest("hex")),
60
+ gateway: readFileSync(join(generated, "sites", siteId, "server-modules.ts"), "utf8"),
61
+ contracts: readFileSync(join(generated, "src/event-schemas.generated.ts"), "utf8"),
62
+ wrangler: readFileSync(join(generated, "sites", siteId, "wrangler.jsonc"), "utf8"),
63
+ };
64
+ }
65
+
66
+ /** What differs between two fingerprints; empty when the site is the same. `names` says which side each is in the messages. */
67
+ export function compareFingerprints(label: string, left: Fingerprint, right: Fingerprint, names: [string, string] = ["left", "right"]): { problems: string[]; identical: number } {
68
+ const problems: string[] = [];
69
+ const list = (name: string, a: string[], b: string[]) => {
70
+ // A multiset difference: after hashes are stripped many chunks share a name, so counts matter.
71
+ const only = (x: string[], y: string[]) => {
72
+ const remaining = [...y];
73
+ return x.filter((item) => {
74
+ const index = remaining.indexOf(item);
75
+ if (index < 0) return true;
76
+ remaining.splice(index, 1);
77
+ return false;
78
+ });
79
+ };
80
+ const missing = only(a, b);
81
+ const extra = only(b, a);
82
+ if (missing.length + extra.length > 0) {
83
+ problems.push(`${label}: ${name} differ (${missing.length} only in the ${names[0]} build, ${extra.length} only in the ${names[1]} build)\n - ${missing.slice(0, 8).join("\n - ")}\n + ${extra.slice(0, 8).join("\n + ")}`);
84
+ }
85
+ };
86
+ list("routes", left.routes, right.routes);
87
+ list("chunk names", left.chunks, right.chunks);
88
+ list("precache entries", left.precache, right.precache);
89
+ if (left.gateway !== right.gateway) problems.push(`${label}: the gateway table differs`);
90
+ if (left.contracts !== right.contracts) problems.push(`${label}: the Ledger contracts differ`);
91
+ if (left.wrangler !== right.wrangler) problems.push(`${label}: the generated Worker config differs`);
92
+ return { problems, identical: left.contents.filter((hash) => right.contents.includes(hash)).length };
93
+ }
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * `hearthlog artifact manifest|verify --site <id>`: the seal between a tenant's build job and its deploy job.
4
+ *
5
+ * The build job has no secrets. It builds the site (`build/<id>`, the generated files, the Gallery Worker) and seals
6
+ * them in `artifact-manifest.json`: the hash of every file, the hash of the lockfile, and one hash over all of it. The
7
+ * deploy job, which does hold secrets, restores the files, installs the same lockfile, and runs `verify` first, so
8
+ * what it deploys is what the build job made and nothing it had to build itself.
9
+ *
10
+ * `compare --site <id> --against <root>` is the handover gate of a site that moves into a repository of its own: its
11
+ * structure (routes, chunk names, precache list, gateway table, Ledger contracts, Worker config) must equal that of the
12
+ * same site built in the other checkout (see artifact-compare.ts).
13
+ */
14
+
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { join, relative, resolve, sep } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { compareFingerprints, fingerprint } from "./artifact-compare.js";
21
+ import { GENERATED_DIRECTORY } from "../compose/compose-modules.js";
22
+ import { REPOSITORY_ROOT } from "../tenants/tenant-config.js";
23
+ import { siteBuildDirectory } from "../tenants/wrangler-config.js";
24
+
25
+ export const MANIFEST_FILE = "artifact-manifest.json";
26
+
27
+ export interface ArtifactManifest {
28
+ schemaVersion: 1;
29
+ site: string;
30
+ /** Hash of the lockfile the build installed. */
31
+ lockfile: string;
32
+ /** Hash of every file, by path relative to the host root. */
33
+ files: Record<string, string>;
34
+ /** One hash over the lockfile and all the file hashes. */
35
+ sha256: string;
36
+ }
37
+
38
+ const sha256 = (data: Buffer | string) => createHash("sha256").update(data).digest("hex");
39
+
40
+ function filesUnder(directory: string): string[] {
41
+ if (!existsSync(directory)) return [];
42
+ return readdirSync(directory, { withFileTypes: true })
43
+ .filter((entry) => entry.name !== "node_modules" && entry.name !== ".DS_Store")
44
+ .sort((a, b) => a.name.localeCompare(b.name))
45
+ .flatMap((entry) => (entry.isDirectory() ? filesUnder(join(directory, entry.name)) : [join(directory, entry.name)]));
46
+ }
47
+
48
+ function digest(lockfile: string, files: Record<string, string>): string {
49
+ return sha256([lockfile, ...Object.entries(files).sort(([a], [b]) => a.localeCompare(b)).map(([path, hash]) => `${path}\0${hash}`)].join("\n"));
50
+ }
51
+
52
+ /** The paths a deploy needs: the site's build output and every generated file. */
53
+ export function artifactPaths(siteId: string, root: string = REPOSITORY_ROOT): string[] {
54
+ return [join(root, siteBuildDirectory(siteId)), join(root, relative(REPOSITORY_ROOT, GENERATED_DIRECTORY))];
55
+ }
56
+
57
+ export function buildManifest(siteId: string, root: string = REPOSITORY_ROOT, paths: string[] = artifactPaths(siteId, root)): ArtifactManifest {
58
+ const lockfile = sha256(readFileSync(join(root, "pnpm-lock.yaml")));
59
+ const files: Record<string, string> = {};
60
+ for (const file of paths.flatMap(filesUnder)) files[relative(root, file).split(sep).join("/")] = sha256(readFileSync(file));
61
+ return { schemaVersion: 1, site: siteId, lockfile, files, sha256: digest(lockfile, files) };
62
+ }
63
+
64
+ /** Why the files on disk are not the ones the manifest sealed; empty when they are. */
65
+ export function manifestProblems(manifest: ArtifactManifest, root: string = REPOSITORY_ROOT, paths: string[] = artifactPaths(manifest.site, root)): string[] {
66
+ const problems: string[] = [];
67
+ const current = buildManifest(manifest.site, root, paths);
68
+ if (manifest.schemaVersion !== 1) problems.push(`the manifest has schema version ${String(manifest.schemaVersion)}, not 1`);
69
+ if (current.lockfile !== manifest.lockfile) problems.push("pnpm-lock.yaml is not the lockfile the build installed");
70
+ for (const path of Object.keys(manifest.files)) {
71
+ if (!(path in current.files)) problems.push(`${path} is missing`);
72
+ else if (current.files[path] !== manifest.files[path]) problems.push(`${path} changed since the build`);
73
+ }
74
+ for (const path of Object.keys(current.files)) if (!(path in manifest.files)) problems.push(`${path} was not part of the build`);
75
+ if (digest(manifest.lockfile, manifest.files) !== manifest.sha256) problems.push("the manifest's own hash does not match its entries");
76
+ return problems;
77
+ }
78
+
79
+ async function main(argv: string[]): Promise<void> {
80
+ const [command, ...rest] = argv;
81
+ const siteIndex = rest.indexOf("--site");
82
+ const siteId = siteIndex === -1 ? undefined : rest[siteIndex + 1];
83
+ if ((command !== "manifest" && command !== "verify" && command !== "compare") || !siteId) {
84
+ console.error("Usage: hearthlog artifact manifest --site <id>\n hearthlog artifact verify --site <id>\n hearthlog artifact compare --site <id> --against <built host root>");
85
+ process.exit(1);
86
+ }
87
+ if (command === "compare") {
88
+ const againstIndex = rest.indexOf("--against");
89
+ const other = againstIndex === -1 ? undefined : rest[againstIndex + 1];
90
+ if (!other) throw new Error("Usage: hearthlog artifact compare --site <id> --against <built host root>");
91
+ const here = await fingerprint(REPOSITORY_ROOT, siteId);
92
+ const there = await fingerprint(resolve(other), siteId);
93
+ const { problems, identical } = compareFingerprints(siteId, there, here, ["other", "this"]);
94
+ console.log(`${siteId}: ${here.routes.length} routes, ${here.chunks.length} chunks, ${here.precache.length} precache entries; ${identical} of ${there.contents.length} files under _app are byte-identical (informational)`);
95
+ if (problems.length > 0) {
96
+ console.error(`This build does not match the other one:\n${problems.join("\n")}`);
97
+ process.exit(1);
98
+ }
99
+ console.log(`The build of ${siteId} here ships what the build in ${resolve(other)} ships.`);
100
+ return;
101
+ }
102
+ const path = join(REPOSITORY_ROOT, MANIFEST_FILE);
103
+ if (command === "manifest") {
104
+ const manifest = buildManifest(siteId);
105
+ if (Object.keys(manifest.files).length === 0) throw new Error(`There is nothing to seal for ${siteId}: build it first`);
106
+ writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
107
+ console.log(`Sealed ${Object.keys(manifest.files).length} files of ${siteId}: ${manifest.sha256}`);
108
+ return;
109
+ }
110
+ const manifest = JSON.parse(readFileSync(path, "utf8")) as ArtifactManifest;
111
+ if (manifest.site !== siteId) throw new Error(`The manifest is for ${manifest.site}, not ${siteId}`);
112
+ const problems = manifestProblems(manifest);
113
+ if (problems.length > 0) {
114
+ console.error(`The artifact does not match its manifest:\n${problems.slice(0, 20).map((problem) => ` - ${problem}`).join("\n")}`);
115
+ process.exit(1);
116
+ }
117
+ console.log(`The artifact of ${siteId} matches its manifest: ${manifest.sha256}`);
118
+ }
119
+
120
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
121
+ try {
122
+ await main(process.argv.slice(2));
123
+ } catch (error) {
124
+ console.error(error instanceof Error ? error.message : String(error));
125
+ process.exitCode = 1;
126
+ }
127
+ }
@@ -0,0 +1,71 @@
1
+ import { availableParallelism } from "node:os";
2
+
3
+ /**
4
+ * Resolve a bounded worker-pool size. The default leaves one CPU available for
5
+ * the orchestrator and caps parallel Cloudflare/build work at four workers.
6
+ */
7
+ export function defaultConcurrency(): number {
8
+ return Math.min(4, Math.max(1, availableParallelism() - 1));
9
+ }
10
+
11
+ export function parseConcurrency(
12
+ value: string | undefined,
13
+ fallback = defaultConcurrency(),
14
+ ): number {
15
+ if (value === undefined || value.trim() === "") return fallback;
16
+ const parsed = Number(value);
17
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 32) {
18
+ throw new Error("Concurrency must be an integer between 1 and 32");
19
+ }
20
+ return parsed;
21
+ }
22
+
23
+ /** Run independent work with a fixed-size pool while preserving result order. */
24
+ export async function mapWithConcurrency<T, R>(
25
+ items: readonly T[],
26
+ concurrency: number,
27
+ task: (item: T, index: number) => Promise<R>,
28
+ ): Promise<R[]> {
29
+ if (items.length === 0) return [];
30
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
31
+ throw new Error("Concurrency must be a positive integer");
32
+ }
33
+
34
+ const results = new Array<R>(items.length);
35
+ let nextIndex = 0;
36
+
37
+ async function worker(): Promise<void> {
38
+ while (true) {
39
+ const index = nextIndex++;
40
+ if (index >= items.length) return;
41
+ results[index] = await task(items[index], index);
42
+ }
43
+ }
44
+
45
+ const workerResults = await Promise.allSettled(
46
+ Array.from(
47
+ { length: Math.min(concurrency, items.length) },
48
+ () => worker(),
49
+ ),
50
+ );
51
+ const failure = workerResults.find(
52
+ (result): result is PromiseRejectedResult => result.status === "rejected",
53
+ );
54
+ if (failure) throw failure.reason;
55
+ return results;
56
+ }
57
+
58
+ export async function settleWithConcurrency<T>(
59
+ items: readonly T[],
60
+ concurrency: number,
61
+ task: (item: T, index: number) => Promise<void>,
62
+ ): Promise<PromiseSettledResult<void>[]> {
63
+ return await mapWithConcurrency(items, concurrency, async (item, index) => {
64
+ try {
65
+ await task(item, index);
66
+ return { status: "fulfilled", value: undefined } as const;
67
+ } catch (reason) {
68
+ return { status: "rejected", reason } as const;
69
+ }
70
+ });
71
+ }
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Tenant Backup
4
+ *
5
+ * Exports every site's Ledger application using the canonical
6
+ * ledger-logical-export format (see packages/ledger/scripts/logical-backup.ts). Sites
7
+ * must have a stored credential from `pnpm provision:tenants`.
8
+ *
9
+ * Environment:
10
+ * LEDGER_BASE_URL e.g. http://127.0.0.1:8787 (local) or the deployed
11
+ * Ledger worker URL
12
+ * LEDGER_ADMIN_TOKEN administrator token, used to list all applications
13
+ * so apps outside tenants.json are reported
14
+ * LEDGER_OUTPUT_ROOT optional directory for the exports (default: .wrangler/backups)
15
+ *
16
+ * Usage: pnpm ledger:export:tenants [-- --output-suffix my-run]
17
+ */
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { mkdir } from "node:fs/promises";
21
+ import { join } from "node:path";
22
+
23
+ import {
24
+ loadTenantConfig,
25
+ REPOSITORY_ROOT,
26
+ } from "../tenants/tenant-config.js";
27
+ import {
28
+ legacyTenantCredentialPath,
29
+ tenantCredentialPath,
30
+ } from "./ledger-credential.js";
31
+ import { ledgerScript } from "./ledger-scripts.js";
32
+
33
+ const baseUrl = (process.env.LEDGER_BASE_URL ?? "").replace(/\/$/, "");
34
+ const adminToken = process.env.LEDGER_ADMIN_TOKEN ?? "";
35
+ if (!baseUrl || !adminToken) {
36
+ console.error(
37
+ "LEDGER_BASE_URL and LEDGER_ADMIN_TOKEN are required " +
38
+ "(see ledger/.dev.vars.example for local values).",
39
+ );
40
+ process.exit(1);
41
+ }
42
+
43
+ const args = process.argv.slice(2);
44
+ const suffixIndex = args.indexOf("--output-suffix");
45
+ const suffix = suffixIndex !== -1 ? `-${args[suffixIndex + 1]}` : "";
46
+ const stamp = new Date().toISOString().replace(/\D/g, "").slice(0, 14);
47
+ const outputRoot =
48
+ process.env.LEDGER_OUTPUT_ROOT ?? join(REPOSITORY_ROOT, ".wrangler", "backups");
49
+
50
+ const { sites } = await loadTenantConfig();
51
+ const siteByAppName = new Map(sites.map((site) => [site.ledgerAppName, site]));
52
+
53
+ const response = await fetch(`${baseUrl}/admin/v1/apps`, {
54
+ headers: { Authorization: `Bearer ${adminToken}` },
55
+ });
56
+ if (!response.ok) {
57
+ console.error(`Listing applications failed with ${response.status}`);
58
+ process.exit(1);
59
+ }
60
+ const { apps } = (await response.json()) as { apps: Array<{ id: string; name: string; revokedAt: string | null }> };
61
+
62
+ const activeApps = apps.filter((app) => app.revokedAt === null);
63
+ const failures: string[] = [];
64
+ const skipped: string[] = [];
65
+
66
+ await mkdir(outputRoot, { recursive: true, mode: 0o700 });
67
+
68
+ for (const app of activeApps) {
69
+ const site = siteByAppName.get(app.name);
70
+ if (!site) {
71
+ skipped.push(app.name);
72
+ console.warn(`No tenants.json site for app "${app.name}" (${app.id}); skipped.`);
73
+ continue;
74
+ }
75
+
76
+ let token: string;
77
+ try {
78
+ const credentialFile = tenantCredentialPath(site.id);
79
+ const fallbackCredentialFile = legacyTenantCredentialPath(site.id);
80
+ const credential = JSON.parse(
81
+ await import("node:fs/promises").then((fs) =>
82
+ fs.readFile(
83
+ credentialFile,
84
+ "utf8",
85
+ ).catch(() => fs.readFile(fallbackCredentialFile, "utf8")),
86
+ ),
87
+ ) as {
88
+ appId: string;
89
+ token: string;
90
+ };
91
+ if (credential.appId !== app.id) {
92
+ skipped.push(app.name);
93
+ console.warn(
94
+ `Stored credential for ${site.id} points at ${credential.appId}, ` +
95
+ `not ${app.id}; run pnpm provision:tenants first.`,
96
+ );
97
+ continue;
98
+ }
99
+ token = credential.token;
100
+ } catch {
101
+ skipped.push(app.name);
102
+ console.warn(
103
+ `No stored credential for site ${site.id}; run pnpm provision:tenants first.`,
104
+ );
105
+ continue;
106
+ }
107
+
108
+ const output = join(outputRoot, `${app.name}${suffix}-${stamp}`);
109
+ console.log(`\nExporting app "${app.name}" (site ${site.id}) into ${output}...`);
110
+ const exitCode = await new Promise<number | null>((resolvePromise) => {
111
+ const backup = ledgerScript("logical-backup", ["export", "--output", output]);
112
+ const child = spawn(
113
+ backup.command,
114
+ backup.args,
115
+ {
116
+ cwd: REPOSITORY_ROOT,
117
+ stdio: "inherit",
118
+ env: {
119
+ ...process.env,
120
+ LEDGER_BASE_URL: baseUrl,
121
+ LEDGER_AUTH_TOKEN: token,
122
+ },
123
+ },
124
+ );
125
+ child.once("error", () => resolvePromise(1));
126
+ child.once("exit", (code) => resolvePromise(code));
127
+ });
128
+ if (exitCode !== 0) failures.push(app.name);
129
+ }
130
+
131
+ console.log(
132
+ `\nBackup finished: ${activeApps.length - skipped.length - failures.length} exported, ` +
133
+ `${skipped.length} skipped, ${failures.length} failed.`,
134
+ );
135
+ if (skipped.length > 0) console.log(`Skipped apps: ${skipped.join(", ")}`);
136
+ if (failures.length > 0) {
137
+ console.error(`Failed apps: ${failures.join(", ")}`);
138
+ process.exitCode = 1;
139
+ }