@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,288 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
rename,
|
|
7
|
+
writeFile,
|
|
8
|
+
} from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_LOCAL_LEDGER_URL = "http://127.0.0.1:8787";
|
|
12
|
+
export const DEFAULT_LOCAL_LEDGER_CREDENTIAL_PATH =
|
|
13
|
+
".wrangler/development/st-local-ledger.json";
|
|
14
|
+
|
|
15
|
+
export interface LocalLedgerCredential {
|
|
16
|
+
version: 1;
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
appId: string;
|
|
19
|
+
token: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface EnsureLocalLedgerOptions {
|
|
24
|
+
adminToken: string;
|
|
25
|
+
baseUrl?: string;
|
|
26
|
+
credentialPath?: string;
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EnsureLocalLedgerResult {
|
|
31
|
+
credential: LocalLedgerCredential;
|
|
32
|
+
reused: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function localGatewayOriginAllowed(
|
|
36
|
+
origin: string | undefined,
|
|
37
|
+
host: string | undefined,
|
|
38
|
+
): boolean {
|
|
39
|
+
if (origin === undefined) return true;
|
|
40
|
+
if (host === undefined) return false;
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(origin);
|
|
43
|
+
return (
|
|
44
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
45
|
+
!url.username &&
|
|
46
|
+
!url.password &&
|
|
47
|
+
url.pathname === "/" &&
|
|
48
|
+
!url.search &&
|
|
49
|
+
!url.hash &&
|
|
50
|
+
url.host.toLowerCase() === host.toLowerCase()
|
|
51
|
+
);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function localLedgerCredentialPath(
|
|
58
|
+
projectRoot = process.cwd(),
|
|
59
|
+
): string {
|
|
60
|
+
return path.resolve(
|
|
61
|
+
projectRoot,
|
|
62
|
+
process.env.LEDGER_LOCAL_CREDENTIAL_PATH ??
|
|
63
|
+
DEFAULT_LOCAL_LEDGER_CREDENTIAL_PATH,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function readLocalLedgerCredential(
|
|
68
|
+
credentialPath = localLedgerCredentialPath(),
|
|
69
|
+
): Promise<LocalLedgerCredential | undefined> {
|
|
70
|
+
try {
|
|
71
|
+
return parseLocalLedgerCredential(
|
|
72
|
+
JSON.parse(await readFile(credentialPath, "utf8")),
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readLocalLedgerCredentialSync(
|
|
80
|
+
credentialPath = localLedgerCredentialPath(),
|
|
81
|
+
): LocalLedgerCredential | undefined {
|
|
82
|
+
try {
|
|
83
|
+
return parseLocalLedgerCredential(
|
|
84
|
+
JSON.parse(readFileSync(credentialPath, "utf8")),
|
|
85
|
+
);
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseLocalLedgerCredential(
|
|
92
|
+
value: unknown,
|
|
93
|
+
): LocalLedgerCredential | undefined {
|
|
94
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const record = value as Record<string, unknown>;
|
|
98
|
+
if (
|
|
99
|
+
record.version !== 1 ||
|
|
100
|
+
typeof record.baseUrl !== "string" ||
|
|
101
|
+
!isHttpUrl(record.baseUrl) ||
|
|
102
|
+
typeof record.appId !== "string" ||
|
|
103
|
+
record.appId.length === 0 ||
|
|
104
|
+
typeof record.token !== "string" ||
|
|
105
|
+
record.token.length === 0 ||
|
|
106
|
+
typeof record.createdAt !== "string" ||
|
|
107
|
+
!Number.isFinite(Date.parse(record.createdAt))
|
|
108
|
+
) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
version: 1,
|
|
113
|
+
baseUrl: normalizeBaseUrl(record.baseUrl),
|
|
114
|
+
appId: record.appId,
|
|
115
|
+
token: record.token,
|
|
116
|
+
createdAt: record.createdAt,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function ensureLocalLedgerCredential(
|
|
121
|
+
options: EnsureLocalLedgerOptions,
|
|
122
|
+
): Promise<EnsureLocalLedgerResult> {
|
|
123
|
+
if (!options.adminToken) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
"ADMIN_TOKEN is missing; configure ledger/.dev.vars before starting Vite",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const baseUrl = normalizeBaseUrl(
|
|
129
|
+
options.baseUrl ??
|
|
130
|
+
process.env.LEDGER_LOCAL_URL ??
|
|
131
|
+
DEFAULT_LOCAL_LEDGER_URL,
|
|
132
|
+
);
|
|
133
|
+
const credentialPath =
|
|
134
|
+
options.credentialPath ?? localLedgerCredentialPath();
|
|
135
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
136
|
+
|
|
137
|
+
await requireHealthyLedger(fetchImpl, baseUrl);
|
|
138
|
+
|
|
139
|
+
const existing = await readLocalLedgerCredential(credentialPath);
|
|
140
|
+
if (
|
|
141
|
+
existing?.baseUrl === baseUrl &&
|
|
142
|
+
(await credentialIsActive(fetchImpl, existing))
|
|
143
|
+
) {
|
|
144
|
+
await chmod(credentialPath, 0o600);
|
|
145
|
+
return { credential: existing, reused: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const response = await fetchImpl(new URL("/admin/v1/apps", baseUrl), {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: {
|
|
151
|
+
Authorization: `Bearer ${options.adminToken}`,
|
|
152
|
+
"Content-Type": "application/json",
|
|
153
|
+
},
|
|
154
|
+
body: JSON.stringify({
|
|
155
|
+
name:
|
|
156
|
+
`st-local-${new Date().toISOString().replaceAll(/[^0-9]/g, "")}` +
|
|
157
|
+
`-${crypto.randomUUID().slice(0, 8)}`,
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Local Ledger app provisioning failed with HTTP ${response.status}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const body: unknown = await response.json();
|
|
166
|
+
const credential = credentialFromProvisioningResponse(body, baseUrl);
|
|
167
|
+
await writeCredential(credentialPath, credential);
|
|
168
|
+
return { credential, reused: false };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function requireHealthyLedger(
|
|
172
|
+
fetchImpl: typeof fetch,
|
|
173
|
+
baseUrl: string,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
let response: Response;
|
|
176
|
+
try {
|
|
177
|
+
response = await fetchImpl(new URL("/health", baseUrl));
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Local Ledger is unavailable at ${baseUrl}; start pnpm ledger:dev first`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (response.status !== 204) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`Local Ledger health check failed with HTTP ${response.status}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function credentialIsActive(
|
|
191
|
+
fetchImpl: typeof fetch,
|
|
192
|
+
credential: LocalLedgerCredential,
|
|
193
|
+
): Promise<boolean> {
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetchImpl(
|
|
196
|
+
new URL("/session", credential.baseUrl),
|
|
197
|
+
{
|
|
198
|
+
headers: {
|
|
199
|
+
Authorization: `Bearer ${credential.token}`,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
if (!response.ok) return false;
|
|
204
|
+
const value: unknown = await response.json();
|
|
205
|
+
return (
|
|
206
|
+
value !== null &&
|
|
207
|
+
typeof value === "object" &&
|
|
208
|
+
!Array.isArray(value) &&
|
|
209
|
+
(value as Record<string, unknown>).appId === credential.appId
|
|
210
|
+
);
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function credentialFromProvisioningResponse(
|
|
217
|
+
value: unknown,
|
|
218
|
+
baseUrl: string,
|
|
219
|
+
): LocalLedgerCredential {
|
|
220
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
221
|
+
throw new Error("Local Ledger returned an invalid provisioning response");
|
|
222
|
+
}
|
|
223
|
+
const response = value as Record<string, unknown>;
|
|
224
|
+
const app =
|
|
225
|
+
response.app !== null &&
|
|
226
|
+
typeof response.app === "object" &&
|
|
227
|
+
!Array.isArray(response.app)
|
|
228
|
+
? response.app as Record<string, unknown>
|
|
229
|
+
: undefined;
|
|
230
|
+
if (
|
|
231
|
+
typeof response.token !== "string" ||
|
|
232
|
+
response.token.length === 0 ||
|
|
233
|
+
typeof app?.id !== "string" ||
|
|
234
|
+
app.id.length === 0
|
|
235
|
+
) {
|
|
236
|
+
throw new Error("Local Ledger returned an invalid provisioning response");
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
version: 1,
|
|
240
|
+
baseUrl,
|
|
241
|
+
appId: app.id,
|
|
242
|
+
token: response.token,
|
|
243
|
+
createdAt: new Date().toISOString(),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function writeCredential(
|
|
248
|
+
credentialPath: string,
|
|
249
|
+
credential: LocalLedgerCredential,
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
const directory = path.dirname(credentialPath);
|
|
252
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
253
|
+
const temporaryPath =
|
|
254
|
+
`${credentialPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
255
|
+
await writeFile(
|
|
256
|
+
temporaryPath,
|
|
257
|
+
`${JSON.stringify(credential, null, 2)}\n`,
|
|
258
|
+
{
|
|
259
|
+
encoding: "utf8",
|
|
260
|
+
flag: "wx",
|
|
261
|
+
mode: 0o600,
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
await rename(temporaryPath, credentialPath);
|
|
265
|
+
await chmod(credentialPath, 0o600);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function normalizeBaseUrl(value: string): string {
|
|
269
|
+
if (!isHttpUrl(value)) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`LEDGER_LOCAL_URL must be an HTTP URL, received ${value}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const url = new URL(value);
|
|
275
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
276
|
+
url.search = "";
|
|
277
|
+
url.hash = "";
|
|
278
|
+
return url.toString().replace(/\/$/, "");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isHttpUrl(value: string): boolean {
|
|
282
|
+
try {
|
|
283
|
+
const url = new URL(value);
|
|
284
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ensureLocalLedgerCredential,
|
|
3
|
+
localLedgerCredentialPath,
|
|
4
|
+
} from "./local-development.js";
|
|
5
|
+
|
|
6
|
+
const result = await ensureLocalLedgerCredential({
|
|
7
|
+
adminToken: process.env.ADMIN_TOKEN ?? "",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
console.log(
|
|
11
|
+
`${result.reused ? "Reused" : "Provisioned"} local Ledger gateway ` +
|
|
12
|
+
`credential at ${localLedgerCredentialPath()} (token not printed)`,
|
|
13
|
+
);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* `pnpm build:generated`: write everything the build generates, once, for one site.
|
|
4
|
+
*
|
|
5
|
+
* .generated/src/ the shared package (@hearthlog/generated) for the active site: the module
|
|
6
|
+
* catalog, icons, repositories, entity types and schema, data APIs, and the
|
|
7
|
+
* Ledger's event schemas
|
|
8
|
+
* .generated/sites/<id>/ the site's tenant module, boot shell (app.html), and gateway table
|
|
9
|
+
* routes/, app.css, service-worker/, static/, app.d.ts the SvelteKit host (see host/write-host.ts), with the
|
|
10
|
+
* site's module routes composed into routes/(modules)/
|
|
11
|
+
*
|
|
12
|
+
* A site's modules are the ones in `tenants/<id>/modules.lock.json`. The catalog is written first, in this
|
|
13
|
+
* process, because everything else reads it; the generators that do run in processes of their own, so they
|
|
14
|
+
* see the new catalog. The inputs (tenants.json, the site's files, the lock's manifests and entity
|
|
15
|
+
* definitions, the core entity definitions, and the generators) are hashed; a build with the same hash and
|
|
16
|
+
* the outputs in place does nothing.
|
|
17
|
+
*
|
|
18
|
+
* pnpm build:generated [--site <id>] [--all-modules] [--check] [--force]
|
|
19
|
+
*
|
|
20
|
+
* The site defaults to VITE_STOR_TENANT, then "st". `--all-modules` writes the catalog of every installed
|
|
21
|
+
* module and nothing else (`pnpm lock:modules` reads it). `--check` fails when the outputs are stale.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
import { generateCatalog, GENERATED_DIRECTORY, loadModules, modulesInLock, siteDirectory } from "../compose/compose-modules.js";
|
|
31
|
+
import { packageDirectory } from "../host/write-host.js";
|
|
32
|
+
import { hostRoot } from "../root.js";
|
|
33
|
+
|
|
34
|
+
const ROOT = hostRoot();
|
|
35
|
+
const STATE_PATH = join(GENERATED_DIRECTORY, ".build.json");
|
|
36
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const TOOLING = resolve(HERE, "..");
|
|
38
|
+
|
|
39
|
+
/** What the generators read besides the modules: the core entity definitions, the tooling, the boot shell template. */
|
|
40
|
+
const SHARED_INPUTS = [
|
|
41
|
+
"tenants.json",
|
|
42
|
+
"src/routes",
|
|
43
|
+
"static",
|
|
44
|
+
].map((path) => join(ROOT, path));
|
|
45
|
+
const GENERATOR_INPUTS = [...["generate", "compose", "tenants", "host"].map((folder) => join(TOOLING, folder)), join(TOOLING, "..", "templates")];
|
|
46
|
+
|
|
47
|
+
export interface BuildOptions {
|
|
48
|
+
siteId: string;
|
|
49
|
+
all?: boolean;
|
|
50
|
+
check?: boolean;
|
|
51
|
+
force?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface BuildState {
|
|
55
|
+
hash: string;
|
|
56
|
+
site: string;
|
|
57
|
+
all: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function files(path: string): string[] {
|
|
61
|
+
if (!existsSync(path)) return [];
|
|
62
|
+
if (!statSync(path).isDirectory()) return [path];
|
|
63
|
+
return readdirSync(path, { withFileTypes: true })
|
|
64
|
+
.filter((entry) => entry.name !== "node_modules" && !entry.name.endsWith(".test.ts"))
|
|
65
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
66
|
+
.flatMap((entry) => files(join(path, entry.name)));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The hash of everything a build of this site and mode reads. */
|
|
70
|
+
export function inputHash({ siteId, all = false }: Pick<BuildOptions, "siteId" | "all">): string {
|
|
71
|
+
const installed = loadModules();
|
|
72
|
+
const modules = all ? installed : modulesInLock(installed, siteId);
|
|
73
|
+
const inputs = [
|
|
74
|
+
...SHARED_INPUTS,
|
|
75
|
+
join(packageDirectory("@hearthlog/contracts"), "src"),
|
|
76
|
+
join(packageDirectory("@hearthlog/shell"), "static"),
|
|
77
|
+
join(packageDirectory("@hearthlog/shell"), "src", "app.template.html"),
|
|
78
|
+
...GENERATOR_INPUTS,
|
|
79
|
+
...(all ? [] : files(join(ROOT, "tenants", siteId))),
|
|
80
|
+
...modules.flatMap(({ directory }) => [join(directory, "hearthlog.module.json"), join(directory, "src", "data")]),
|
|
81
|
+
];
|
|
82
|
+
const hash = createHash("sha256").update(`${siteId}\0${all}\0`);
|
|
83
|
+
for (const file of inputs.flatMap(files)) {
|
|
84
|
+
hash.update(`${relative(ROOT, file)}\0`).update(readFileSync(file)).update("\0");
|
|
85
|
+
}
|
|
86
|
+
return hash.digest("hex");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function outputs(siteId: string, all: boolean): string[] {
|
|
90
|
+
const shared = ["modules", "module-icons", "module-repositories", "types", "schema", "standard-commands", "standard-db", "event-schemas"]
|
|
91
|
+
.map((name) => join(GENERATED_DIRECTORY, "src", `${name}.generated.ts`));
|
|
92
|
+
if (all) return shared.slice(0, 3);
|
|
93
|
+
return [
|
|
94
|
+
...shared,
|
|
95
|
+
join(siteDirectory(siteId), "tenant.ts"),
|
|
96
|
+
join(siteDirectory(siteId), "app.html"),
|
|
97
|
+
join(siteDirectory(siteId), "server-modules.ts"),
|
|
98
|
+
join(siteDirectory(siteId), "worker.ts"),
|
|
99
|
+
join(siteDirectory(siteId), "wrangler.jsonc"),
|
|
100
|
+
join(GENERATED_DIRECTORY, "routes", "(modules)", "startup.ts"),
|
|
101
|
+
join(GENERATED_DIRECTORY, "routes", "+layout.svelte"),
|
|
102
|
+
join(GENERATED_DIRECTORY, "module-sources.generated.css"),
|
|
103
|
+
join(GENERATED_DIRECTORY, "app.css"),
|
|
104
|
+
join(GENERATED_DIRECTORY, "app.d.ts"),
|
|
105
|
+
join(GENERATED_DIRECTORY, "tsconfig.json"),
|
|
106
|
+
join(GENERATED_DIRECTORY, "service-worker", "index.ts"),
|
|
107
|
+
join(GENERATED_DIRECTORY, "static", "_headers"),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readState(): BuildState | undefined {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(readFileSync(STATE_PATH, "utf8")) as BuildState;
|
|
114
|
+
} catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isCurrent({ siteId, all = false }: BuildOptions): boolean {
|
|
120
|
+
const state = readState();
|
|
121
|
+
return state !== undefined
|
|
122
|
+
&& state.site === siteId
|
|
123
|
+
&& state.all === all
|
|
124
|
+
&& state.hash === inputHash({ siteId, all })
|
|
125
|
+
&& outputs(siteId, all).every((path) => existsSync(path));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function runGenerator(script: string, siteId: string): void {
|
|
129
|
+
const result = spawnSync(process.execPath, [...process.execArgv, join(TOOLING, script)], {
|
|
130
|
+
cwd: ROOT,
|
|
131
|
+
stdio: "inherit",
|
|
132
|
+
env: { ...process.env, VITE_STOR_TENANT: siteId },
|
|
133
|
+
});
|
|
134
|
+
if (result.status !== 0) throw new Error(`${script} failed`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function build(options: BuildOptions): Promise<"built" | "current"> {
|
|
138
|
+
const { siteId, all = false } = options;
|
|
139
|
+
if (!options.force && isCurrent(options)) return "current";
|
|
140
|
+
if (options.check) throw new Error(`The generated files are stale for ${all ? "every module" : `site ${siteId}`} (run pnpm build:generated)`);
|
|
141
|
+
await generateCatalog({ siteId, all });
|
|
142
|
+
if (!all) {
|
|
143
|
+
runGenerator("generate/generate-entities.ts", siteId);
|
|
144
|
+
runGenerator("generate/generate-tenant.ts", siteId);
|
|
145
|
+
runGenerator("host/write-host.ts", siteId);
|
|
146
|
+
runGenerator("compose/compose-modules.ts", siteId);
|
|
147
|
+
}
|
|
148
|
+
mkdirSync(GENERATED_DIRECTORY, { recursive: true });
|
|
149
|
+
const state: BuildState = { hash: inputHash({ siteId, all }), site: siteId, all };
|
|
150
|
+
writeFileSync(STATE_PATH, `${JSON.stringify(state)}\n`, "utf8");
|
|
151
|
+
return "built";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function main(argv: string[]): Promise<void> {
|
|
155
|
+
const site = argv.indexOf("--site");
|
|
156
|
+
const siteId = (site >= 0 ? argv[site + 1] : undefined) ?? (process.env.VITE_STOR_TENANT?.trim() || "st");
|
|
157
|
+
const outcome = await build({ siteId, all: argv.includes("--all-modules"), check: argv.includes("--check"), force: argv.includes("--force") });
|
|
158
|
+
if (outcome === "current" && !argv.includes("--quiet")) console.log(`Generated files are current for ${siteId}.`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
162
|
+
main(process.argv.slice(2)).catch((error: unknown) => {
|
|
163
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Brand Icon Generator
|
|
4
|
+
*
|
|
5
|
+
* Renders the brand mark into one icon set per tenant under
|
|
6
|
+
* tenants/<site-id>/static/icons/<site-id>/ (favicon, Apple touch icon, PWA and maskable icons),
|
|
7
|
+
* plus the default packages/shell/static/favicon.ico. The tile colour comes from the tenant
|
|
8
|
+
* accent; tenants without one use the original teal. Rasterizing uses the
|
|
9
|
+
* Chromium that Playwright already installs.
|
|
10
|
+
*
|
|
11
|
+
* The output is committed. Re-run after changing the mark or a tenant accent:
|
|
12
|
+
*
|
|
13
|
+
* pnpm generate:icons
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { chromium } from "@playwright/test";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
brandColor,
|
|
23
|
+
brandTileSvg,
|
|
24
|
+
BRAND_MARK_DEFAULT_COLOR,
|
|
25
|
+
} from "@hearthlog/contracts/tenant/brand";
|
|
26
|
+
import { themeVariablesFor } from "@hearthlog/contracts/tenant/theme";
|
|
27
|
+
import {
|
|
28
|
+
loadTenantConfig,
|
|
29
|
+
REPOSITORY_ROOT,
|
|
30
|
+
validateCrossSite,
|
|
31
|
+
} from "../tenants/tenant-config.js";
|
|
32
|
+
|
|
33
|
+
const MARK_ON_TILE = "#fbf9fa";
|
|
34
|
+
|
|
35
|
+
interface IconSpec {
|
|
36
|
+
file: string;
|
|
37
|
+
size: number;
|
|
38
|
+
scale: number;
|
|
39
|
+
/** Rounded tile with transparent corners instead of full bleed. */
|
|
40
|
+
radius?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ICONS: readonly IconSpec[] = [
|
|
44
|
+
// Full bleed: iOS applies its own mask and rejects transparency.
|
|
45
|
+
{ file: "apple-touch-icon.png", size: 180, scale: 0.62 },
|
|
46
|
+
{ file: "pwa-192.png", size: 192, scale: 0.66, radius: 112 },
|
|
47
|
+
{ file: "pwa-512.png", size: 512, scale: 0.66, radius: 112 },
|
|
48
|
+
// Maskable icons keep the mark inside the 80% safe circle.
|
|
49
|
+
{ file: "maskable-512.png", size: 512, scale: 0.5 },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const FAVICON_SIZES = [16, 32, 48] as const;
|
|
53
|
+
|
|
54
|
+
/** Packs PNGs into one ICO container (PNG-compressed entries). */
|
|
55
|
+
function icoFromPngs(entries: readonly { size: number; png: Buffer }[]): Buffer {
|
|
56
|
+
const header = Buffer.alloc(6);
|
|
57
|
+
header.writeUInt16LE(1, 2);
|
|
58
|
+
header.writeUInt16LE(entries.length, 4);
|
|
59
|
+
let offset = 6 + entries.length * 16;
|
|
60
|
+
const directory = entries.map(({ size, png }) => {
|
|
61
|
+
const entry = Buffer.alloc(16);
|
|
62
|
+
entry.writeUInt8(size, 0);
|
|
63
|
+
entry.writeUInt8(size, 1);
|
|
64
|
+
entry.writeUInt16LE(1, 4);
|
|
65
|
+
entry.writeUInt16LE(32, 6);
|
|
66
|
+
entry.writeUInt32LE(png.length, 8);
|
|
67
|
+
entry.writeUInt32LE(offset, 12);
|
|
68
|
+
offset += png.length;
|
|
69
|
+
return entry;
|
|
70
|
+
});
|
|
71
|
+
return Buffer.concat([header, ...directory, ...entries.map(({ png }) => png)]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { sites } = await loadTenantConfig();
|
|
75
|
+
validateCrossSite(sites);
|
|
76
|
+
|
|
77
|
+
const browser = await chromium.launch();
|
|
78
|
+
const page = await browser.newPage();
|
|
79
|
+
|
|
80
|
+
async function render(svg: string, size: number): Promise<Buffer> {
|
|
81
|
+
await page.setViewportSize({ width: size, height: size });
|
|
82
|
+
await page.setContent(
|
|
83
|
+
`<body style="margin:0;background:transparent"><div style="width:${size}px;height:${size}px">` +
|
|
84
|
+
svg.replace("<svg ", `<svg width="${size}" height="${size}" `) +
|
|
85
|
+
`</div></body>`,
|
|
86
|
+
);
|
|
87
|
+
return page.screenshot({ omitBackground: true, type: "png" });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function faviconIco(svg: string): Promise<Buffer> {
|
|
91
|
+
return icoFromPngs(
|
|
92
|
+
await Promise.all(
|
|
93
|
+
FAVICON_SIZES.map(async (size) => ({
|
|
94
|
+
size,
|
|
95
|
+
png: await render(svg, size),
|
|
96
|
+
})),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function faviconSvg(background: string): string {
|
|
102
|
+
return brandTileSvg({ background, fill: MARK_ON_TILE, scale: 0.7, radius: 112 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
for (const site of sites) {
|
|
107
|
+
const accent = site.theme.accent;
|
|
108
|
+
const action = themeVariablesFor(accent, "light")["--stor-action"];
|
|
109
|
+
const background = brandColor(accent, action);
|
|
110
|
+
const directory = join(REPOSITORY_ROOT, "tenants", site.id, "static", "icons", site.id);
|
|
111
|
+
await mkdir(directory, { recursive: true });
|
|
112
|
+
|
|
113
|
+
for (const { file, size, scale, radius } of ICONS) {
|
|
114
|
+
const svg = brandTileSvg({ background, fill: MARK_ON_TILE, scale, radius });
|
|
115
|
+
await writeFile(join(directory, file), await render(svg, size));
|
|
116
|
+
}
|
|
117
|
+
await writeFile(join(directory, "favicon.ico"), await faviconIco(faviconSvg(background)));
|
|
118
|
+
console.log(`Generated icons for "${site.id}" (${background}).`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Default for clients that request /favicon.ico without reading the page; the shell ships it to every site.
|
|
122
|
+
await writeFile(
|
|
123
|
+
join(REPOSITORY_ROOT, "packages", "shell", "static", "favicon.ico"),
|
|
124
|
+
await faviconIco(faviconSvg(BRAND_MARK_DEFAULT_COLOR)),
|
|
125
|
+
);
|
|
126
|
+
} finally {
|
|
127
|
+
await browser.close();
|
|
128
|
+
}
|