@hearthlog/tooling 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/bin/hearthlog.mjs +52 -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 +194 -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 +167 -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 +136 -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/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 +110 -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,212 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* `hearthlog plan` and `hearthlog apply`: provision one tenant from its tenant files.
|
|
4
|
+
*
|
|
5
|
+
* pnpm hearthlog plan --site jst [--json] read-only: resources, steps, problems
|
|
6
|
+
* pnpm hearthlog apply --site jst --yes create resources and deploy, resumably
|
|
7
|
+
* pnpm hearthlog apply --site st --yes --stop-before frontend provision the stack, switch no traffic
|
|
8
|
+
*
|
|
9
|
+
* `apply` changes Cloudflare, so it needs `--yes` and a plan without problems.
|
|
10
|
+
* It runs the deploy tools in order (`hearthlog ensure`, `deploy-ledger`, `deploy`), each of which is idempotent,
|
|
11
|
+
* and records every finished step
|
|
12
|
+
* in `.wrangler/tenants/<site>/receipt.json` (protected state, no secrets). A
|
|
13
|
+
* second run skips finished steps, so credentials are minted once; nothing it
|
|
14
|
+
* runs deletes a bucket, a Ledger, or an Access application, and it never
|
|
15
|
+
* passes `--rotate-token`.
|
|
16
|
+
*
|
|
17
|
+
* --skip-build deploy the build output already in place (a release built by another job)
|
|
18
|
+
* --dry-run run the tools' dry runs; changes nothing and records no receipt
|
|
19
|
+
* --no-bootstrap refuse to create a Ledger token or signing key (implied in CI): first provisioning is local
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
ApplyError,
|
|
29
|
+
newReceipt,
|
|
30
|
+
parseReceipt,
|
|
31
|
+
planDeployment,
|
|
32
|
+
renderPlan,
|
|
33
|
+
runApply,
|
|
34
|
+
serializeReceipt,
|
|
35
|
+
type DeploymentPlan,
|
|
36
|
+
type PlanInput,
|
|
37
|
+
type StepExecutor,
|
|
38
|
+
type StepId,
|
|
39
|
+
type TenantResources,
|
|
40
|
+
} from "@hearthlog/tooling";
|
|
41
|
+
|
|
42
|
+
import { loadModules, modulesForFeatures } from "../compose/compose-modules.js";
|
|
43
|
+
import { resolveDedicatedAdminToken } from "../deploy/dedicated-ledger.js";
|
|
44
|
+
import { isCi } from "../deploy/deploy-runner.js";
|
|
45
|
+
import {
|
|
46
|
+
infrastructureFor,
|
|
47
|
+
loadTenantConfig,
|
|
48
|
+
REPOSITORY_ROOT,
|
|
49
|
+
siteById,
|
|
50
|
+
type InfrastructureDefinition,
|
|
51
|
+
type ResolvedSite,
|
|
52
|
+
} from "../tenants/tenant-config.js";
|
|
53
|
+
import { moduleBindings } from "../tenants/wrangler-config.js";
|
|
54
|
+
|
|
55
|
+
export function receiptPath(siteId: string): string {
|
|
56
|
+
return join(REPOSITORY_ROOT, ".wrangler", "tenants", siteId, "receipt.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tenantResources(site: ResolvedSite, shared: InfrastructureDefinition): TenantResources {
|
|
60
|
+
const own = infrastructureFor(site, shared);
|
|
61
|
+
const keyed = own as unknown as Record<string, string | undefined>;
|
|
62
|
+
return {
|
|
63
|
+
ledgerWorker: own.ledgerWorker,
|
|
64
|
+
buckets: moduleBindings(site.features, own).r2.map(({ binding, bucket_name }) => ({ binding, bucket: bucket_name })),
|
|
65
|
+
calendarFeedsBucket: own.calendarFeedsBucket,
|
|
66
|
+
calendarPublicWorker: own.calendarPublicWorker,
|
|
67
|
+
publicWorkers: modulesForFeatures(loadModules(), site.features).flatMap(({ manifest }) => {
|
|
68
|
+
const name = manifest.publicWorker ? keyed[`${manifest.publicWorker.name}PublicWorker`] : undefined;
|
|
69
|
+
return name ? [name] : [];
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function buildPlan(siteId: string): Promise<DeploymentPlan> {
|
|
75
|
+
const { infrastructure, sites } = await loadTenantConfig();
|
|
76
|
+
const site = siteById(sites, siteId);
|
|
77
|
+
const input: PlanInput = {
|
|
78
|
+
site: {
|
|
79
|
+
id: site.id,
|
|
80
|
+
origin: site.hostname,
|
|
81
|
+
features: site.features,
|
|
82
|
+
authorizedEmails: site.authorizedEmails,
|
|
83
|
+
ledgerAppName: site.ledgerAppName,
|
|
84
|
+
deployment: site.deployment ? { resourcePrefix: site.deployment.resourcePrefix, accountId: site.deployment.accountId } : null,
|
|
85
|
+
},
|
|
86
|
+
modules: modulesForFeatures(loadModules(), site.features).map(({ manifest }) => ({ id: manifest.id, version: manifest.version })),
|
|
87
|
+
resources: tenantResources(site, infrastructure),
|
|
88
|
+
accessTeamDomain: infrastructureFor(site, infrastructure).accessTeamDomain,
|
|
89
|
+
others: sites.filter((other) => other.id !== site.id).map((other) => ({ id: other.id, resources: tenantResources(other, infrastructure) })),
|
|
90
|
+
// The shared stack holds every module's resources under the shared names.
|
|
91
|
+
shared: infrastructure.ledgerWorker === "" ? undefined : tenantResources({ deployment: null, features: loadModules().flatMap(({ manifest }) => manifest.features.map((feature) => feature.id)) } as unknown as ResolvedSite, infrastructure),
|
|
92
|
+
};
|
|
93
|
+
return planDeployment(input);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const BIN = fileURLToPath(new URL("../../bin/hearthlog.mjs", import.meta.url));
|
|
97
|
+
|
|
98
|
+
/** Run another `hearthlog` command from this package, so a tenant repository needs none of this repository's scripts. */
|
|
99
|
+
function run(args: string[]): Promise<void> {
|
|
100
|
+
return new Promise((resolveRun, reject) => {
|
|
101
|
+
const child = spawn(process.execPath, [BIN, ...args], { cwd: REPOSITORY_ROOT, stdio: "inherit" });
|
|
102
|
+
child.on("error", reject);
|
|
103
|
+
child.on("exit", (code) => (code === 0 ? resolveRun() : reject(new Error(`hearthlog ${args.join(" ")} exited with ${String(code)}`))));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The origin must send an unauthenticated visitor to sign in, not serve the app. */
|
|
108
|
+
export async function checkProtected(origin: string, fetcher: typeof fetch = fetch): Promise<void> {
|
|
109
|
+
const response = await fetcher(origin, { redirect: "manual" });
|
|
110
|
+
if (response.status === 200) throw new Error(`${origin} answered 200 without signing in: the site is not protected`);
|
|
111
|
+
if (![301, 302, 303, 307, 308, 401, 403].includes(response.status) && response.type !== "opaqueredirect") {
|
|
112
|
+
throw new Error(`${origin} answered ${response.status}; expected a sign-in redirect or a refusal`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ExecutorOptions {
|
|
117
|
+
skipBuild?: boolean;
|
|
118
|
+
dryRun?: boolean;
|
|
119
|
+
/** Refuse to create a Ledger token or signing key; a run that has no administrator token for its Ledger stops. */
|
|
120
|
+
noBootstrap?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function executorsFor(siteId: string, dedicated: boolean, options: ExecutorOptions = {}): Record<StepId, StepExecutor> {
|
|
124
|
+
const site = ["--site", siteId];
|
|
125
|
+
const dry = options.dryRun ? ["--dry-run"] : [];
|
|
126
|
+
return {
|
|
127
|
+
resources: () => run(["ensure", ...site, "--skip-ledger", "--skip-secrets", ...dry]),
|
|
128
|
+
// The first deploy mints the administrator token and signing key; later ones only push code.
|
|
129
|
+
ledger: async () => {
|
|
130
|
+
const known = !dedicated || Boolean(await resolveDedicatedAdminToken(siteId));
|
|
131
|
+
if (!known && options.noBootstrap) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`No administrator token for the ${siteId} Ledger (set LEDGER_ADMIN_TOKEN_${siteId.toUpperCase().replace(/-/g, "_")}). ` +
|
|
134
|
+
"This run does not create credentials: provision the stack once with `hearthlog apply` on your machine.",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
await run(["deploy-ledger", ...site, ...(known ? [] : ["--bootstrap-secrets"]), ...dry]);
|
|
138
|
+
},
|
|
139
|
+
// Creates the application with the site's event policy and gives the gateway its token; an existing token is kept.
|
|
140
|
+
"ledger-app": () => run(["ensure", ...site, ...dry]),
|
|
141
|
+
frontend: () => run(["deploy", ...site, ...(options.skipBuild ? ["--skip-build"] : []), ...dry]),
|
|
142
|
+
verify: async (step) => {
|
|
143
|
+
const plan = await buildPlan(siteId);
|
|
144
|
+
console.log(step.summary);
|
|
145
|
+
if (!options.dryRun) await checkProtected(plan.origin);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function loadReceipt(plan: DeploymentPlan) {
|
|
151
|
+
try {
|
|
152
|
+
return parseReceipt(await readFile(receiptPath(plan.siteId), "utf8"));
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return newReceipt(plan);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function main(argv: string[]): Promise<void> {
|
|
160
|
+
const [command, ...rest] = argv.filter((argument) => argument !== "--");
|
|
161
|
+
const flag = (name: string) => rest.includes(name);
|
|
162
|
+
const siteIndex = rest.indexOf("--site");
|
|
163
|
+
const siteId = siteIndex === -1 ? undefined : rest[siteIndex + 1]?.trim();
|
|
164
|
+
if ((command !== "plan" && command !== "apply") || !siteId) {
|
|
165
|
+
console.error("Usage: pnpm hearthlog plan --site <id> [--json]\n pnpm hearthlog apply --site <id> --yes");
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const plan = await buildPlan(siteId);
|
|
169
|
+
if (command === "plan") {
|
|
170
|
+
process.stdout.write(flag("--json") ? `${JSON.stringify(plan, null, 2)}\n` : renderPlan(plan));
|
|
171
|
+
process.exit(plan.problems.length ? 1 : 0);
|
|
172
|
+
}
|
|
173
|
+
process.stdout.write(renderPlan(plan));
|
|
174
|
+
if (!flag("--yes")) {
|
|
175
|
+
console.error("\nThis would change Cloudflare. Run again with --yes to apply.");
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
const stopIndex = rest.indexOf("--stop-before");
|
|
179
|
+
const stopBefore = stopIndex === -1 ? undefined : (rest[stopIndex + 1] as StepId | undefined);
|
|
180
|
+
if (stopIndex !== -1 && !plan.steps.some((step) => step.id === stopBefore)) {
|
|
181
|
+
console.error(`--stop-before needs one of: ${plan.steps.map((step) => step.id).join(", ")}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
const dryRun = flag("--dry-run");
|
|
185
|
+
try {
|
|
186
|
+
const result = await runApply(plan, {
|
|
187
|
+
stopBefore,
|
|
188
|
+
receipt: dryRun ? newReceipt(plan) : await loadReceipt(plan),
|
|
189
|
+
executors: executorsFor(plan.siteId, plan.topology === "dedicated", {
|
|
190
|
+
skipBuild: flag("--skip-build"),
|
|
191
|
+
dryRun,
|
|
192
|
+
noBootstrap: flag("--no-bootstrap") || isCi(),
|
|
193
|
+
}),
|
|
194
|
+
save: async (receipt) => {
|
|
195
|
+
if (dryRun) return;
|
|
196
|
+
const path = receiptPath(plan.siteId);
|
|
197
|
+
await mkdir(dirname(path), { recursive: true });
|
|
198
|
+
await writeFile(path, serializeReceipt(receipt), { mode: 0o600 });
|
|
199
|
+
},
|
|
200
|
+
log: (message) => console.log(`\n== ${message}`),
|
|
201
|
+
});
|
|
202
|
+
console.log(`\nApplied ${plan.siteId}: ran ${result.ran.join(", ") || "nothing"}; skipped ${result.skipped.join(", ") || "nothing"}.`);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error instanceof ApplyError) {
|
|
205
|
+
console.error(`\n${error.message}\nRun the same command again to resume from there.`);
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(process.argv.slice(2));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Emit the deployed site ids as a JSON array (for the CI build matrix).
|
|
4
|
+
* Sites on their own stack (tenants/<id>/deployment.json) are left out.
|
|
5
|
+
*
|
|
6
|
+
* Usage: pnpm list:sites
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { loadTenantConfig, sharedSites } from "../tenants/tenant-config.js";
|
|
10
|
+
|
|
11
|
+
const { sites } = await loadTenantConfig();
|
|
12
|
+
// A site on its own stack is deployed by its operator with that stack's own
|
|
13
|
+
// Ledger administrator token, which CI does not hold.
|
|
14
|
+
const scoped = sharedSites(sites);
|
|
15
|
+
console.log(JSON.stringify(scoped.map((site) => site.id)));
|