@hearthlog/tooling 0.1.4 → 0.1.6

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/bin/hearthlog.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  // The `hearthlog` command: runs the TypeScript tools through tsx. `build`, `init`, `export`, `check-module`, `lock`, `artifact`, and
3
3
  // `gallery-public:build` start before a site has generated files (or write their own), so they run alone; the
4
4
  // `apple:*` commands run the build and then the tools of `@hearthlog/native` (a dependency of the site, not of this
5
- // package); `ensure`, `deploy`, and `deploy-ledger` run the build and then a deploy tool; every other
6
- // command goes to the CLI.
5
+ // package); `ensure`, `deploy`, and `deploy-ledger` run the build and then a deploy tool; `dev-ledger` runs the build
6
+ // and then the dev server with a local Ledger; every other command goes to the CLI.
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { createRequire } from 'node:module';
9
9
  import { join } from 'node:path';
@@ -41,6 +41,12 @@ if (deployTools[command]) {
41
41
  process.exit(built === 0 ? (spawnSync(process.execPath, [tsx, source(deployTools[command]), ...rest], { stdio: 'inherit', env }).status ?? 1) : built);
42
42
  }
43
43
 
44
+ // The dev server with a local Ledger starts that Ledger from the generated configuration: build it first.
45
+ if (command === 'dev-ledger') {
46
+ const built = run([source('generate/build-generated.ts'), '--quiet']);
47
+ process.exit(built === 0 ? run([source('dev/dev-ledger.ts'), ...rest]) : built);
48
+ }
49
+
44
50
  // The plan reads the site's generated files too: build them first (a no-op when they are current). `apply` does not,
45
51
  // because `apply --skip-build` deploys the release already in place and must not rewrite it.
46
52
  if (command === 'plan') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hearthlog/tooling",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "license": "AGPL-3.0-only",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "tsx": "^4.23.15",
31
- "@hearthlog/contracts": "0.1.4"
31
+ "@hearthlog/contracts": "0.1.6"
32
32
  },
33
33
  "bin": {
34
34
  "hearthlog": "./bin/hearthlog.mjs"
@@ -12,9 +12,10 @@
12
12
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
13
  import { dirname, join, matchesGlob, relative, resolve, sep } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
- import { hostRoot } from "../root.js";
15
+ import { PLATFORM_ROOT } from "../root.js";
16
16
 
17
- const ROOT = hostRoot();
17
+ // Layers are a property of the platform's packages, wherever the check runs from.
18
+ const ROOT = PLATFORM_ROOT;
18
19
  const IMPORT = /(?:^|[^\w$.])(?:import|export)\s[^'"]*?from\s+['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)|^\s*import\s+['"]([^'"]+)['"]/gm;
19
20
 
20
21
  export interface LayerConfig {
@@ -17,7 +17,7 @@
17
17
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
18
18
  import { dirname, join, relative, resolve, sep } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
- import { hostRoot } from "../root.js";
20
+ import { hostRoot, PLATFORM_ROOT } from "../root.js";
21
21
 
22
22
  const ROOT = hostRoot();
23
23
  const SOURCE = /\.(ts|svelte|js)$/;
@@ -89,6 +89,15 @@ export async function loadModuleDomain(root = ROOT): Promise<ModuleDomain> {
89
89
  };
90
90
  }
91
91
 
92
+ /**
93
+ * The directories a check reads, each with the root it is under: the given ones under the host, or by default the host's
94
+ * own and the platform's (which are the same checkout unless the host is a site beside the platform's packages).
95
+ */
96
+ function scanned(root: string, platform: string, given: string[] | undefined, host: string[], platformDirectories: string[]): Array<[string, string]> {
97
+ if (given) return given.map((directory) => [root, directory]);
98
+ return [...host.map((directory): [string, string] => [root, directory]), ...platformDirectories.map((directory): [string, string] => [platform, directory])];
99
+ }
100
+
92
101
  const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
93
102
 
94
103
  /**
@@ -99,8 +108,10 @@ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\
99
108
  */
100
109
  export function coreNamesNoDomain(
101
110
  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"],
111
+ directories?: string[],
103
112
  domain: ModuleDomain = { names: [], eventTypes: [] },
113
+ /** The platform checkout, when it is not the host (`directories` are then the host's, under `root`). */
114
+ platform = root,
104
115
  ): string[] {
105
116
  const modulesDirectory = join(root, "modules");
106
117
  const featureIds = (existsSync(modulesDirectory) ? readdirSync(modulesDirectory) : []).flatMap((name) => {
@@ -113,16 +124,16 @@ export function coreNamesNoDomain(
113
124
  const quoted = quotedNames.length ? new RegExp(`(["'\`])(${quotedNames.map(escapeRegExp).join("|")})\\1`) : null;
114
125
  const events = domain.eventTypes.length ? new RegExp(`\\b(${domain.eventTypes.map(escapeRegExp).join("|")})\\b`) : null;
115
126
  const problems: string[] = [];
116
- for (const directory of directories) {
117
- for (const file of files(join(root, directory))) {
127
+ for (const [base, directory] of scanned(root, platform, directories, ["ledger/src"], ["packages/tooling/src/host", "packages/cloudflare/src", "packages/ledger/src", "packages/contracts/src", "packages/runtime/src", "packages/ui/src"])) {
128
+ for (const file of files(join(base, directory))) {
118
129
  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
130
  readFileSync(file, "utf8").split("\n").forEach((line, index) => {
120
131
  // Comments may use a domain as an example.
121
132
  if (/^\s*(\*|\/\/|\/\*)/.test(line)) return;
122
133
  const name = quoted?.exec(line);
123
- if (name) problems.push(`${relative(root, file)}:${index + 1}: the core names ${name[1]}${name[2]}${name[1]}`);
134
+ if (name) problems.push(`${relative(base, file)}:${index + 1}: the core names ${name[1]}${name[2]}${name[1]}`);
124
135
  const event = events?.exec(line);
125
- if (event) problems.push(`${relative(root, file)}:${index + 1}: the core names the event type ${event[1]}`);
136
+ if (event) problems.push(`${relative(base, file)}:${index + 1}: the core names the event type ${event[1]}`);
126
137
  });
127
138
  }
128
139
  }
@@ -135,16 +146,16 @@ export function coreNamesNoDomain(
135
146
  * to one would break the day the file is no longer in this tree. The SDK facade, which still reaches
136
147
  * into `src/lib` by relative path, and the generated files themselves are exempt.
137
148
  */
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[] {
149
+ export function directGeneratedImports(root = ROOT, directories?: string[], platform = root): string[] {
139
150
  const problems: string[] = [];
140
151
  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))) {
152
+ for (const [base, directory] of scanned(root, platform, directories, ["src", "ledger"], ["scripts", "packages/tooling/src", "packages/cloudflare/src", "packages/ledger/src", "packages/contracts/src", "packages/runtime/src", "packages/ui/src"])) {
153
+ for (const file of files(join(base, directory))) {
143
154
  // The scripts' own tests write fixture files whose text looks like imports, and the generators emit import lines.
144
155
  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
156
  for (const specifier of specifiers(file)) {
146
157
  if ((specifier.startsWith(".") || specifier.startsWith("#lib")) && generatedFile.test(specifier)) {
147
- problems.push(`${relative(root, file)}: "${specifier}" is a generated file (import it through #generated/*)`);
158
+ problems.push(`${relative(base, file)}: "${specifier}" is a generated file (import it through #generated/*)`);
148
159
  }
149
160
  }
150
161
  }
@@ -152,7 +163,7 @@ export function directGeneratedImports(root = ROOT, directories = ["src", "worke
152
163
  return problems;
153
164
  }
154
165
 
155
- export function checkBoundaries(root = ROOT, domain?: ModuleDomain): string[] {
166
+ export function checkBoundaries(root = ROOT, domain?: ModuleDomain, platform = root): string[] {
156
167
  const problems: string[] = [];
157
168
  const modulesDirectory = join(root, "modules");
158
169
  // A platform workspace or a tenant repository has no modules directory.
@@ -174,21 +185,22 @@ export function checkBoundaries(root = ROOT, domain?: ModuleDomain): string[] {
174
185
  }
175
186
  }
176
187
  const generated = /\.generated\.ts$|[\\/]\(modules\)[\\/]/;
177
- for (const directory of ["src", "worker", "packages"]) {
178
- for (const file of files(join(root, directory))) {
188
+ for (const [base, directory] of scanned(root, platform, undefined, ["src"], ["packages"])) {
189
+ for (const file of files(join(base, directory))) {
179
190
  if (generated.test(file)) continue;
180
191
  for (const specifier of specifiers(file)) {
181
- if (specifier.startsWith("@hearthlog/module-")) problems.push(`${relative(root, file)}: the core imports a module ("${specifier}")`);
192
+ if (specifier.startsWith("@hearthlog/module-")) problems.push(`${relative(base, file)}: the core imports a module ("${specifier}")`);
182
193
  }
183
194
  }
184
195
  }
185
- problems.push(...coreNamesNoDomain(root, undefined, domain));
186
- problems.push(...directGeneratedImports(root));
196
+ problems.push(...coreNamesNoDomain(root, undefined, domain, platform));
197
+ problems.push(...directGeneratedImports(root, undefined, platform));
187
198
  return problems;
188
199
  }
189
200
 
190
201
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
191
- const problems = checkBoundaries(ROOT, await loadModuleDomain(ROOT));
202
+ // The host (its modules, its own code, the generated catalog) and the platform's packages may be different checkouts.
203
+ const problems = checkBoundaries(ROOT, await loadModuleDomain(ROOT), PLATFORM_ROOT);
192
204
  if (problems.length) {
193
205
  console.error(`Module boundaries are broken:\n - ${problems.join("\n - ")}`);
194
206
  process.exitCode = 1;
@@ -186,7 +186,7 @@ for (const stack of stackNames) {
186
186
  if (JSON.stringify(feedSites) !== JSON.stringify(expectedSites)) problems.push(`${label}: the feed Worker should bind ${expectedSites.join(", ")}`);
187
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
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}`);
189
+ if ((!ownSite || (ownSite.features as readonly string[]).includes("gallery")) && read("gallery-public").name !== siteInfrastructure.galleryPublicWorker) problems.push(`${label}: the public Gallery Worker should be named ${siteInfrastructure.galleryPublicWorker}`);
190
190
  }
191
191
 
192
192
  for (const site of sites) problems.push(...(await moduleLockProblems(site)));
@@ -12,7 +12,7 @@
12
12
  * gitignored `.generated/` by `pnpm build:generated`, which runs
13
13
  * this first because everything else reads it. A site's modules
14
14
  * are the ones in its `tenants/<id>/modules.lock.json`.
15
- * (default) The routes of one tenant (VITE_STOR_TENANT, default "st"):
15
+ * (default) The routes of one tenant (VITE_STOR_TENANT, default the host's only site, else "st"):
16
16
  * thin wrappers in the gitignored `src/routes/(modules)/` for
17
17
  * the modules whose features the tenant enables, so a tenant
18
18
  * without a module ships none of its pages.
@@ -27,7 +27,7 @@ import { dirname, join, relative, resolve, sep } from "node:path";
27
27
  import { fileURLToPath, pathToFileURL } from "node:url";
28
28
  import { isDeepStrictEqual } from "node:util";
29
29
  import { CORE_ROUTE_PATHS } from "../host/core-routes.js";
30
- import { hostRoot } from "../root.js";
30
+ import { activeSiteId, hostRoot } from "../root.js";
31
31
 
32
32
  import { findCollisions, validateManifest, type ModuleManifest } from "@hearthlog/contracts";
33
33
 
@@ -91,13 +91,13 @@ export function installedModuleDirectories(root = REPOSITORY_ROOT): string[] {
91
91
  }
92
92
 
93
93
  /**
94
- * The modules a host has: those in its `modules/` folder (a checkout that develops them) and the packages
95
- * it installs (a site that uses them). Given a directory, only that folder is scanned.
94
+ * The modules a host has: those in its `modules/` folder (a checkout that develops them, or a link to one) and
95
+ * the packages it installs (a site that uses them). Given a directory, only that folder is scanned.
96
96
  */
97
97
  export function loadModules(directory?: string): LoadedModule[] {
98
98
  const scanned = (folder: string): string[] => existsSync(folder)
99
99
  ? readdirSync(folder, { withFileTypes: true })
100
- .filter((entry) => entry.isDirectory() && existsSync(join(folder, entry.name, "hearthlog.module.json")))
100
+ .filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && existsSync(join(folder, entry.name, "hearthlog.module.json")))
101
101
  .map((entry) => join(folder, entry.name))
102
102
  : [];
103
103
  const directories = directory !== undefined ? scanned(directory) : [...scanned(MODULES_DIRECTORY), ...installedModuleDirectories()];
@@ -448,7 +448,7 @@ export async function generateCatalog({ siteId, all = false, check = false }: Ca
448
448
  async function composeRoutes(): Promise<void> {
449
449
  const modules = loadModules();
450
450
  checkComposition(modules);
451
- const siteId = process.env.VITE_STOR_TENANT?.trim() || "st";
451
+ const siteId = activeSiteId();
452
452
  const { loadTenantConfig, siteById } = await import("../tenants/tenant-config.js");
453
453
  const { sites } = await loadTenantConfig(undefined, { only: siteId });
454
454
  const site = siteById(sites, siteId);
@@ -477,7 +477,7 @@ async function composeRoutes(): Promise<void> {
477
477
 
478
478
  export async function main(argv = process.argv.slice(2)): Promise<void> {
479
479
  const site = argv.indexOf("--site");
480
- const siteId = (site >= 0 ? argv[site + 1] : undefined) ?? (process.env.VITE_STOR_TENANT?.trim() || "st");
480
+ const siteId = (site >= 0 ? argv[site + 1] : undefined) ?? activeSiteId();
481
481
  if (argv.includes("--catalog")) await generateCatalog({ siteId, all: argv.includes("--all"), check: argv.includes("--check") });
482
482
  else await composeRoutes();
483
483
  }
@@ -18,6 +18,7 @@ import { fileURLToPath } from "node:url";
18
18
 
19
19
  import { MODULE_API_VERSION } from "@hearthlog/contracts";
20
20
 
21
+ import { PLATFORM_ROOT } from "../root.js";
21
22
  import { REPOSITORY_ROOT } from "../tenants/tenant-config.js";
22
23
 
23
24
  const TEMPLATE = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates", "module");
@@ -92,10 +93,10 @@ export const load = ({ url }: { url: URL }) => deferLocalPage(url, {}, async ()
92
93
  `,
93
94
  "README.md": `# ${label}
94
95
 
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.
96
+ A module for the Hearthlog platform. It reaches the app only through \`@hearthlog/sdk\`; the
97
+ \`ModuleManifest\` type in \`@hearthlog/contracts\` documents what a module may declare (pages, entities,
98
+ repositories, gateway code, startup code, attention badges), and \`pnpm check:modules\` enforces the
99
+ boundary.
99
100
  `,
100
101
  };
101
102
  }
@@ -138,7 +139,8 @@ export function exportModule(modulesDirectory: string, name: string, out: string
138
139
  if (manifest.dependencies && Object.keys(manifest.dependencies).length === 0) delete manifest.dependencies;
139
140
  delete manifest.private;
140
141
  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
+ // The repository the module leaves: its pnpm and test tooling, and its license.
143
+ const root = JSON.parse(readFileSync(join(PLATFORM_ROOT, "package.json"), "utf8")) as { devDependencies?: Record<string, string>; packageManager?: string };
142
144
  const publishable = {
143
145
  ...manifest,
144
146
  repository: { type: "git", url: `git+https://github.com/hearthlog/hearthlog-module-${name}.git` },
@@ -160,7 +162,7 @@ export function exportModule(modulesDirectory: string, name: string, out: string
160
162
  writeFileSync(join(out, "pnpm-workspace.yaml"), "onlyBuiltDependencies:\n - esbuild\n");
161
163
  copyFileSync(join(TEMPLATE, "ci.yml.tmpl"), join(out, ".github", "workflows", "ci.yml"));
162
164
  copyFileSync(join(TEMPLATE, "release.yml.tmpl"), join(out, ".github", "workflows", "release.yml"));
163
- cpSync(join(REPOSITORY_ROOT, "LICENSE"), join(out, "LICENSE"));
165
+ cpSync(join(PLATFORM_ROOT, "LICENSE"), join(out, "LICENSE"));
164
166
  const readme = join(out, "README.md");
165
167
  const existing = existsSync(readme) ? readFileSync(readme, "utf8").trimEnd() : `# ${title(name)}`;
166
168
  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`);
@@ -130,7 +130,7 @@ export async function deploySite(options: Options): Promise<void> {
130
130
  async function deployOwnPublicWorkers(site: ResolvedSite, dryRun: boolean, skipBuild: boolean): Promise<void> {
131
131
  if (!site.deployment) return;
132
132
  const args = ["deploy", ...(dryRun ? ["--dry-run"] : [])];
133
- if (site.features.includes("gallery")) {
133
+ if ((site.features as readonly string[]).includes("gallery")) {
134
134
  if (skipBuild) {
135
135
  // A release built elsewhere carries its Worker bundle; only the configs are written here.
136
136
  await generateHostConfigs(site.id);
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `hearthlog dev-ledger [vite options]`: the site against a Ledger of its own on this machine, in one command. The
3
+ * `hearthlog` command builds the generated files first; this starts the site's Ledger Worker (`wrangler dev` on
4
+ * 127.0.0.1:8787, its data in `.wrangler/development/ledger-state/`), provisions the gateway credential the dev server
5
+ * reads (as `provision-local` does), then runs `vite dev` with the arguments given. Stopping either stops both.
6
+ *
7
+ * The Ledger's local secrets are written once to `.wrangler/development/ledger.dev.vars`: random, mode 600, and never
8
+ * committed (a site ignores `.wrangler/`). Delete `.wrangler/development/` to start from an empty Ledger.
9
+ */
10
+ import { spawn, type ChildProcess } from "node:child_process";
11
+ import { randomBytes } from "node:crypto";
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { GENERATED_DIRECTORY, siteDirectory } from "../compose/compose-modules.js";
18
+ import { activeSiteId, hostRoot } from "../root.js";
19
+ import { DEFAULT_LOCAL_LEDGER_URL, ensureLocalLedgerCredential, localLedgerCredentialPath } from "./local-development.js";
20
+
21
+ export const DEVELOPMENT_DIRECTORY = join(".wrangler", "development");
22
+ export const LEDGER_DEV_VARS = join(DEVELOPMENT_DIRECTORY, "ledger.dev.vars");
23
+
24
+ /** A new site's local Ledger secrets, in the `.dev.vars` format `wrangler dev --env-file` reads. */
25
+ export function ledgerDevVars(secret: () => string = () => randomBytes(32).toString("hex")): string {
26
+ return `ADMIN_TOKEN=${secret()}\nTOKEN_SIGNING_KEY_CURRENT=${secret()}\nALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173\n`;
27
+ }
28
+
29
+ /** The `KEY=VALUE` lines of a `.dev.vars` file; blank lines and `#` comments are skipped. */
30
+ export function parseDevVars(text: string): Record<string, string> {
31
+ const values: Record<string, string> = {};
32
+ for (const line of text.split(/\r?\n/)) {
33
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
34
+ if (match && !line.trimStart().startsWith("#")) values[match[1]!] = match[2]!.replace(/^(["'])(.*)\1$/, "$2");
35
+ }
36
+ return values;
37
+ }
38
+
39
+ /** The Ledger configuration the build wrote for a site: its own (a dedicated Ledger), else the host's shared one. */
40
+ export function ledgerConfigPath(siteId: string): string {
41
+ for (const candidate of [join(siteDirectory(siteId), "ledger", "wrangler.jsonc"), join(GENERATED_DIRECTORY, "shared", "ledger", "wrangler.jsonc")]) {
42
+ if (existsSync(candidate)) return candidate;
43
+ }
44
+ throw new Error(`No Ledger configuration for ${siteId} in .generated/: run hearthlog build`);
45
+ }
46
+
47
+ /** A tool's entry script as the site installs it, run with this Node rather than through a package manager. */
48
+ function binary(root: string, name: string, bin: string): string {
49
+ const manifestPath = createRequire(join(root, "package.json")).resolve(`${name}/package.json`);
50
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { bin?: string | Record<string, string> };
51
+ const entry = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[bin];
52
+ if (!entry) throw new Error(`${name} has no ${bin} binary`);
53
+ return join(dirname(manifestPath), entry);
54
+ }
55
+
56
+ async function waitForHealth(url: string, stopped: () => boolean): Promise<boolean> {
57
+ for (let attempt = 0; attempt < 120 && !stopped(); attempt++) {
58
+ try {
59
+ if ((await fetch(new URL("/health", url))).ok) return true;
60
+ } catch {
61
+ // Not listening yet.
62
+ }
63
+ await new Promise((done) => setTimeout(done, 500));
64
+ }
65
+ return false;
66
+ }
67
+
68
+ export async function devLedger(viteArguments: readonly string[]): Promise<number> {
69
+ const root = hostRoot();
70
+ const site = activeSiteId(root);
71
+ const varsPath = join(root, LEDGER_DEV_VARS);
72
+ mkdirSync(join(root, DEVELOPMENT_DIRECTORY), { recursive: true });
73
+ if (!existsSync(varsPath)) {
74
+ writeFileSync(varsPath, ledgerDevVars(), { mode: 0o600 });
75
+ console.log(`Wrote the local Ledger's secrets to ${LEDGER_DEV_VARS}`);
76
+ }
77
+ const adminToken = parseDevVars(readFileSync(varsPath, "utf8")).ADMIN_TOKEN ?? "";
78
+ const ledgerUrl = new URL(DEFAULT_LOCAL_LEDGER_URL);
79
+
80
+ const children = new Set<ChildProcess>();
81
+ let stopping = false;
82
+ let exitCode = 0;
83
+ let finished: (code: number) => void = () => {};
84
+ const done = new Promise<number>((resolveDone) => (finished = resolveDone));
85
+ const stop = (code: number) => {
86
+ if (stopping) return;
87
+ stopping = true;
88
+ exitCode = code;
89
+ // Each child leads a process group of its own, so this stops what it started too (wrangler's workerd).
90
+ for (const child of children) {
91
+ try {
92
+ process.kill(-child.pid!, "SIGTERM");
93
+ } catch {
94
+ // Already gone.
95
+ }
96
+ }
97
+ if (children.size === 0) finished(exitCode);
98
+ };
99
+ const start = (script: string, args: readonly string[]) => {
100
+ const child = spawn(process.execPath, [script, ...args], { cwd: root, stdio: "inherit", detached: true });
101
+ children.add(child);
102
+ child.on("exit", (code) => {
103
+ children.delete(child);
104
+ stop(code ?? 0);
105
+ if (children.size === 0) finished(exitCode);
106
+ });
107
+ };
108
+ process.on("SIGINT", () => stop(0));
109
+ process.on("SIGTERM", () => stop(0));
110
+
111
+ start(binary(root, "wrangler", "wrangler"), [
112
+ "dev",
113
+ "--config", ledgerConfigPath(site),
114
+ "--env-file", varsPath,
115
+ "--persist-to", join(root, DEVELOPMENT_DIRECTORY, "ledger-state"),
116
+ "--ip", ledgerUrl.hostname,
117
+ "--port", ledgerUrl.port,
118
+ ]);
119
+ if (!(await waitForHealth(ledgerUrl.href, () => stopping))) {
120
+ if (!stopping) console.error(`The local Ledger did not answer at ${new URL("/health", ledgerUrl).href}`);
121
+ stop(1);
122
+ return done;
123
+ }
124
+ try {
125
+ const { reused } = await ensureLocalLedgerCredential({ adminToken, baseUrl: ledgerUrl.href, credentialPath: localLedgerCredentialPath(root) });
126
+ console.log(`${reused ? "Reused" : "Provisioned"} the dev server's credential for the local Ledger (token not printed)`);
127
+ } catch (error) {
128
+ console.error(error instanceof Error ? error.message : String(error));
129
+ stop(1);
130
+ return done;
131
+ }
132
+ start(binary(root, "vite", "vite"), ["dev", ...viteArguments]);
133
+ return done;
134
+ }
135
+
136
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
137
+ process.exitCode = await devLedger(process.argv.slice(2).filter((argument) => argument !== "--"));
138
+ }
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * pnpm build:generated [--site <id>] [--all-modules] [--check] [--force]
19
19
  *
20
- * The site defaults to VITE_STOR_TENANT, then "st". `--all-modules` writes the catalog of every installed
20
+ * The site defaults to VITE_STOR_TENANT, then the host's only site, then "st". `--all-modules` writes the catalog of every installed
21
21
  * module and nothing else (`pnpm lock:modules` reads it). `--check` fails when the outputs are stale.
22
22
  */
23
23
 
@@ -29,7 +29,7 @@ import { fileURLToPath } from "node:url";
29
29
 
30
30
  import { generateCatalog, GENERATED_DIRECTORY, loadModules, modulesInLock, siteDirectory } from "../compose/compose-modules.js";
31
31
  import { packageDirectory } from "../host/write-host.js";
32
- import { hostRoot } from "../root.js";
32
+ import { activeSiteId, hostRoot } from "../root.js";
33
33
 
34
34
  const ROOT = hostRoot();
35
35
  const STATE_PATH = join(GENERATED_DIRECTORY, ".build.json");
@@ -153,7 +153,7 @@ export async function build(options: BuildOptions): Promise<"built" | "current">
153
153
 
154
154
  async function main(argv: string[]): Promise<void> {
155
155
  const site = argv.indexOf("--site");
156
- const siteId = (site >= 0 ? argv[site + 1] : undefined) ?? (process.env.VITE_STOR_TENANT?.trim() || "st");
156
+ const siteId = (site >= 0 ? argv[site + 1] : undefined) ?? activeSiteId();
157
157
  const outcome = await build({ siteId, all: argv.includes("--all-modules"), check: argv.includes("--check"), force: argv.includes("--force") });
158
158
  if (outcome === "current" && !argv.includes("--quiet")) console.log(`Generated files are current for ${siteId}.`);
159
159
  }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Resolves one site from tenants.json and emits the runtime module the
6
6
  * browser app consumes, plus the tenant-branded app shell. The site is
7
- * selected with VITE_STOR_TENANT (default: "st").
7
+ * selected with VITE_STOR_TENANT (default: the host's only site, else "st").
8
8
  *
9
9
  * Usage: VITE_STOR_TENANT=bravo pnpm build:generated
10
10
  *
@@ -27,6 +27,7 @@ import {
27
27
  } from "../tenants/tenant-config.js";
28
28
  import { generateHostConfigs } from "../tenants/host-configs.js";
29
29
  import { generateTenantWranglerConfigs } from "../tenants/wrangler-config.js";
30
+ import { activeSiteId } from "../root.js";
30
31
  import { brandMarkMaskUrl, wordmarkParts } from "@hearthlog/contracts/tenant/brand";
31
32
  import {
32
33
  themeVariablesCss,
@@ -137,7 +138,7 @@ if (/%%BRAND_[A-Z_]+%%/.test(brandable)) {
137
138
  }
138
139
 
139
140
  if (process.env.STOR_SKIP_TENANT_GENERATION !== "1") {
140
- await generateTenant(process.env.VITE_STOR_TENANT?.trim() || "st", {
141
+ await generateTenant(activeSiteId(), {
141
142
  generateWranglerConfigs: process.env.STOR_WRANGLER_CONFIGS !== "0",
142
143
  });
143
144
  }
@@ -19,7 +19,7 @@ import { createRequire } from "node:module";
19
19
  import { dirname, join, relative, resolve, sep } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
 
22
- import { hostRoot } from "../root.js";
22
+ import { activeSiteId, hostRoot } from "../root.js";
23
23
  import { CORE_ROUTE_FILES } from "./core-routes.js";
24
24
 
25
25
  const ROOT = hostRoot();
@@ -112,7 +112,7 @@ export async function writeHost(siteId: string): Promise<void> {
112
112
  }
113
113
 
114
114
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
115
- writeHost(process.env.VITE_STOR_TENANT?.trim() || "st").catch((error: unknown) => {
115
+ writeHost(activeSiteId()).catch((error: unknown) => {
116
116
  console.error(error instanceof Error ? error.message : String(error));
117
117
  process.exitCode = 1;
118
118
  });
package/src/root.ts CHANGED
@@ -1,5 +1,35 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * The platform checkout this tooling runs from: the repository with `packages/`, `scripts/`, and the license. Only the
7
+ * repository's own checks and exports use it; everything a site owns is under `hostRoot()`.
8
+ */
9
+ export const PLATFORM_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
10
+
11
+ /**
12
+ * The site a build works on: `VITE_STOR_TENANT` when it is set, otherwise the host's only site (a repository
13
+ * made by `hearthlog init` or `hearthlog export` holds one), otherwise "st", the development site of the platform.
14
+ */
15
+ export function activeSiteId(root: string = hostRoot()): string {
16
+ const named = process.env.VITE_STOR_TENANT?.trim();
17
+ if (named) return named;
18
+ const sites = new Set<string>();
19
+ const tenants = join(root, "tenants");
20
+ if (existsSync(tenants)) {
21
+ for (const entry of readdirSync(tenants, { withFileTypes: true })) {
22
+ if (entry.isDirectory() && existsSync(join(tenants, entry.name, "tenant.json"))) sites.add(entry.name);
23
+ }
24
+ }
25
+ try {
26
+ const listed = (JSON.parse(readFileSync(join(root, "tenants.json"), "utf8")) as { sites?: Array<{ id?: unknown }> }).sites ?? [];
27
+ for (const site of listed) if (typeof site.id === "string") sites.add(site.id);
28
+ } catch {
29
+ // No tenants.json, or one the tenant configuration check reports: the directories decide.
30
+ }
31
+ return sites.size === 1 ? [...sites][0]! : "st";
32
+ }
3
33
 
4
34
  /**
5
35
  * The repository a command works on: the nearest directory, from the working directory upward, that
@@ -81,7 +81,7 @@ export async function generateHostConfigs(stack: Stack): Promise<string[]> {
81
81
  infrastructure = infrastructureFor(site, shared);
82
82
  ledgerAccount = site.deployment.accountId;
83
83
  calendarSites = [site.id];
84
- gallery = site.features.includes("gallery");
84
+ gallery = (site.features as readonly string[]).includes("gallery");
85
85
  } else if (calendarSites.length === 0) {
86
86
  return written;
87
87
  }
@@ -6,10 +6,12 @@ import Icons from 'unplugin-icons/vite'
6
6
  import { entityGeneratorPlugin } from './vite-plugin-entity-gen.js';
7
7
  import { tenantManifestPlugin } from './vite-plugin-tenant-manifest.js';
8
8
  import { workerAssetsPlugin } from './vite-plugin-worker-assets.js';
9
+ import { sourcePackages } from './source-packages.js';
9
10
  import { readFileSync } from 'node:fs';
10
11
  import { execFileSync } from 'node:child_process';
11
12
  import { join, resolve } from 'node:path';
12
13
  import { localGatewayOriginAllowed, readLocalLedgerCredentialSync } from '../dev/local-development.js';
14
+ import { activeSiteId } from '../root.js';
13
15
  import {
14
16
  e2eLedgerStubPlugin,
15
17
  localAiDecisionsPlugin,
@@ -21,17 +23,7 @@ import {
21
23
  localReceivedInvoiceFilesPlugin,
22
24
  } from './dev-plugins.js';
23
25
 
24
- import { defineConfig, type ProxyOptions, type UserConfig } from 'vite';
25
-
26
- /** The `@hearthlog/*` packages the site depends on (the platform and its modules). */
27
- function hearthlogDependencies(): string[] {
28
- try {
29
- const manifest = JSON.parse(readFileSync('package.json', 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
30
- return Object.keys({ ...manifest.dependencies, ...manifest.devDependencies }).filter((name) => name.startsWith('@hearthlog/') && name !== '@hearthlog/generated');
31
- } catch {
32
- return [];
33
- }
34
- }
26
+ import { defineConfig, searchForWorkspaceRoot, type ProxyOptions, type UserConfig } from 'vite';
35
27
 
36
28
  function installedVersion(name: string): string {
37
29
  try {
@@ -75,7 +67,10 @@ const buildTimestamp = process.env.STOR_BUILD_TIMESTAMP?.trim() || new Date().to
75
67
  // Tenant build selection: VITE_STOR_TENANT=bravo writes the site into
76
68
  // build/bravo (matching wrangler env.bravo assets.directory). The default
77
69
  // tenant keeps the historical ./build output used by the top-level Worker.
78
- const tenantId = process.env.VITE_STOR_TENANT?.trim() || 'st';
70
+ // Without VITE_STOR_TENANT a host with one site builds that site; the value is
71
+ // set here so the plugins and `import.meta.env` (the service worker) agree.
72
+ const tenantId = activeSiteId();
73
+ process.env.VITE_STOR_TENANT = tenantId;
79
74
  const buildDirectory = process.env.STOR_BUILD_OUTPUT_ROOT?.trim() ||
80
75
  (tenantId === 'st' ? 'build' : `build/${tenantId}`);
81
76
  const svelteKitOutDir = process.env.STOR_SVELTEKIT_OUT_DIR?.trim() || '.svelte-kit';
@@ -130,6 +125,7 @@ return defineConfig(({ command, mode }): UserConfig => {
130
125
  ? readLocalLedgerCredentialSync()
131
126
  : undefined;
132
127
  const isE2E = process.env.VITE_E2E === '1';
128
+ const sources = sourcePackages();
133
129
  const usesSameOriginLedger = Boolean(localLedgerCredential) || isE2E;
134
130
  // The dev proxy needs both: the renderer's address and its bearer token (from .dev.vars or the environment).
135
131
  const invoiceRendererOrigin = readDevVar('INVOICE_RENDERER_ORIGIN');
@@ -238,8 +234,9 @@ return defineConfig(({ command, mode }): UserConfig => {
238
234
  },
239
235
  server: {
240
236
  ...devServerProxy,
241
- // Module and SDK sources live outside src/; Kit's default list omits them.
242
- fs: { allow: [resolve('modules'), resolve('packages')] }
237
+ // Module and SDK sources live outside src/; Kit's default list omits them. In a workspace the platform's packages
238
+ // are linked from outside the site, so the workspace root is allowed too.
239
+ fs: { allow: [resolve('modules'), resolve('packages'), searchForWorkspaceRoot(process.cwd())] }
243
240
  },
244
241
  resolve: {
245
242
  alias: {
@@ -305,7 +302,12 @@ return defineConfig(({ command, mode }): UserConfig => {
305
302
  optimizeDeps: {
306
303
  // The platform and the modules ship Svelte and TypeScript source; installed from the registry they sit in node_modules,
307
304
  // where the dependency scan cannot resolve their relative imports. They are compiled with the site, not pre-bundled.
308
- exclude: ['sqlocal', 'microlighter', ...hearthlogDependencies()]
305
+ exclude: ['sqlocal', 'microlighter', ...sources.filter((name) => name !== '@hearthlog/generated')]
306
+ },
307
+ ssr: {
308
+ // For the same reason server rendering must compile them: externalized, Node would load their TypeScript from
309
+ // node_modules, which it refuses to strip. The pattern also catches a platform package the walk did not reach.
310
+ noExternal: [/^@hearthlog\//, ...sources],
309
311
  },
310
312
  build: {
311
313
  // Bundle Microlighter's lazy grammars, which Vite skips in dependencies by default.
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+
4
+ interface PackageManifest {
5
+ dependencies?: Record<string, string>;
6
+ devDependencies?: Record<string, string>;
7
+ peerDependencies?: Record<string, string>;
8
+ optionalDependencies?: Record<string, string>;
9
+ }
10
+
11
+ function readManifest(directory: string): PackageManifest | undefined {
12
+ try {
13
+ return JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as PackageManifest;
14
+ } catch {
15
+ return undefined;
16
+ }
17
+ }
18
+
19
+ /** Node's lookup of `name` from `directory`: the nearest `node_modules/<name>` up the tree, as its real path. */
20
+ function packageDirectory(name: string, directory: string): string | undefined {
21
+ for (let current = directory; ; current = dirname(current)) {
22
+ const candidate = basename(current) === 'node_modules' ? join(current, name) : join(current, 'node_modules', name);
23
+ if (existsSync(join(candidate, 'package.json'))) return realpathSync(candidate);
24
+ if (dirname(current) === current) return undefined;
25
+ }
26
+ }
27
+
28
+ /** The platform's packages and the modules (any package with a module manifest, whatever its scope) ship source. */
29
+ function shipsSource(name: string, directory: string): boolean {
30
+ return name.startsWith('@hearthlog/') || existsSync(join(directory, 'hearthlog.module.json'));
31
+ }
32
+
33
+ /**
34
+ * The packages a site installs that ship Svelte and TypeScript source instead of JavaScript: every `@hearthlog/*`
35
+ * package and every module, direct or transitive. They are reached through the site's dependencies and, from each
36
+ * of them, through their own (peer) dependencies, so a module's `@hearthlog/czech` or a private-scope module counts.
37
+ */
38
+ export function sourcePackages(root = process.cwd()): string[] {
39
+ const site = readManifest(root);
40
+ if (!site) return [];
41
+ const found = new Set<string>();
42
+ const visited = new Set<string>();
43
+ const visit = (names: Iterable<string>, from: string): void => {
44
+ for (const name of names) {
45
+ const directory = packageDirectory(name, from);
46
+ if (!directory || !shipsSource(name, directory)) continue;
47
+ found.add(name);
48
+ if (visited.has(directory)) continue;
49
+ visited.add(directory);
50
+ const manifest = readManifest(directory);
51
+ visit(Object.keys({ ...manifest?.dependencies, ...manifest?.peerDependencies, ...manifest?.optionalDependencies }), directory);
52
+ }
53
+ };
54
+ visit(Object.keys({ ...site.dependencies, ...site.devDependencies }), resolve(root));
55
+ return [...found].sort();
56
+ }
@@ -9,6 +9,7 @@
9
9
  import type { Plugin } from 'vite';
10
10
 
11
11
  import { themeVariablesFor } from '../../../contracts/src/tenant/theme.js';
12
+ import { activeSiteId } from '../root.js';
12
13
  import { loadTenantConfig, siteById } from '../tenants/tenant-config.js';
13
14
 
14
15
  const MANIFEST_PATH = '/manifest.webmanifest';
@@ -40,7 +41,7 @@ async function manifestJson(siteId: string): Promise<string> {
40
41
  }
41
42
 
42
43
  export function tenantManifestPlugin(): Plugin {
43
- const siteId = process.env.VITE_STOR_TENANT?.trim() || 'st';
44
+ const siteId = activeSiteId();
44
45
  return {
45
46
  name: 'tenant-manifest',
46
47
  configureServer(server) {
@@ -6,8 +6,13 @@ modules it ships, and everything else the app needs is written by `pnpm build:ge
6
6
  ```bash
7
7
  pnpm install
8
8
  pnpm dev # builds the generated files, then starts Vite
9
+ pnpm dev:ledger # the same, with a Ledger of its own on this machine (127.0.0.1:8787)
9
10
  pnpm build # a static build in build/
10
11
  ```
11
12
 
12
13
  `tenants.json` holds placeholder infrastructure names: it makes the site load, and it cannot be deployed until real
13
14
  values replace them.
15
+
16
+ `pnpm dev:ledger` runs the site's Ledger Worker with `wrangler dev`, gives the dev server a credential for it, and
17
+ starts Vite; Ctrl+C stops both. Its secrets, data, and credential live in `.wrangler/development/` (ignored): delete
18
+ that folder to start from an empty Ledger.
@@ -9,3 +9,6 @@ node_modules/
9
9
  # Native apps (`hearthlog apple:*`): build output and personal device settings
10
10
  /.apple-build/
11
11
  /resign.local.json
12
+ # Deployment token files and the local Ledger's secrets, data and credential (`pnpm dev:ledger`): never committed
13
+ /.wrangler/
14
+ .dev.vars
@@ -5,6 +5,7 @@
5
5
  "scripts": {
6
6
  "build:generated": "hearthlog build",
7
7
  "dev": "hearthlog build && vite dev",
8
+ "dev:ledger": "hearthlog dev-ledger",
8
9
  "build": "hearthlog build && vite build",
9
10
  "check": "hearthlog build && svelte-kit sync && tsc --noEmit -p tsconfig.json --skipLibCheck"
10
11
  }