@hearthlog/tooling 0.1.3 → 0.1.5

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,7 +2,7 @@
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`, `deploy-ledger`, and `split` run the build and then a deploy tool; every other
5
+ // package); `ensure`, `deploy`, and `deploy-ledger` run the build and then a deploy tool; every other
6
6
  // command goes to the CLI.
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { createRequire } from 'node:module';
@@ -33,7 +33,7 @@ if (native) {
33
33
 
34
34
  // The deploy tools read the site's generated files, so each starts with the build (which does nothing when they are
35
35
  // current). They work from a tenant repository because they run from this package, not from the host's scripts.
36
- const deployTools = { ensure: 'deploy/ensure-tenant.ts', deploy: 'deploy/deploy-tenant.ts', 'deploy-ledger': 'deploy/deploy-ledger.ts', split: 'deploy/split-tenant.ts' };
36
+ const deployTools = { ensure: 'deploy/ensure-tenant.ts', deploy: 'deploy/deploy-tenant.ts', 'deploy-ledger': 'deploy/deploy-ledger.ts' };
37
37
  if (deployTools[command]) {
38
38
  const site = rest[rest.indexOf('--site') + 1];
39
39
  const env = { ...process.env, ...(rest.includes('--site') && site ? { VITE_STOR_TENANT: site } : {}) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hearthlog/tooling",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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.3"
31
+ "@hearthlog/contracts": "0.1.5"
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);
@@ -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
  }
@@ -105,7 +105,7 @@ export interface InfrastructureDefinition {
105
105
  ledgerAdminEmail: string;
106
106
  /** Shared Cloudflare Access team origin pushed to every site Worker. */
107
107
  accessTeamDomain: string;
108
- /** The Cloudflare account that holds the shared stack; `tenant:split declare` pins a moved site to it. */
108
+ /** The Cloudflare account that holds the shared stack. */
109
109
  accountId?: string;
110
110
  /** Browser application and team-wide SSO lifetimes, in whole hours. */
111
111
  accessSessionDuration?: string;
@@ -10,6 +10,7 @@ import { readFileSync } from 'node:fs';
10
10
  import { execFileSync } from 'node:child_process';
11
11
  import { join, resolve } from 'node:path';
12
12
  import { localGatewayOriginAllowed, readLocalLedgerCredentialSync } from '../dev/local-development.js';
13
+ import { activeSiteId } from '../root.js';
13
14
  import {
14
15
  e2eLedgerStubPlugin,
15
16
  localAiDecisionsPlugin,
@@ -21,7 +22,17 @@ import {
21
22
  localReceivedInvoiceFilesPlugin,
22
23
  } from './dev-plugins.js';
23
24
 
24
- import { defineConfig, type ProxyOptions, type UserConfig } from 'vite';
25
+ import { defineConfig, searchForWorkspaceRoot, type ProxyOptions, type UserConfig } from 'vite';
26
+
27
+ /** The `@hearthlog/*` packages the site depends on (the platform and its modules). */
28
+ function hearthlogDependencies(): string[] {
29
+ try {
30
+ const manifest = JSON.parse(readFileSync('package.json', 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
31
+ return Object.keys({ ...manifest.dependencies, ...manifest.devDependencies }).filter((name) => name.startsWith('@hearthlog/') && name !== '@hearthlog/generated');
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
25
36
 
26
37
  function installedVersion(name: string): string {
27
38
  try {
@@ -65,7 +76,10 @@ const buildTimestamp = process.env.STOR_BUILD_TIMESTAMP?.trim() || new Date().to
65
76
  // Tenant build selection: VITE_STOR_TENANT=bravo writes the site into
66
77
  // build/bravo (matching wrangler env.bravo assets.directory). The default
67
78
  // tenant keeps the historical ./build output used by the top-level Worker.
68
- const tenantId = process.env.VITE_STOR_TENANT?.trim() || 'st';
79
+ // Without VITE_STOR_TENANT a host with one site builds that site; the value is
80
+ // set here so the plugins and `import.meta.env` (the service worker) agree.
81
+ const tenantId = activeSiteId();
82
+ process.env.VITE_STOR_TENANT = tenantId;
69
83
  const buildDirectory = process.env.STOR_BUILD_OUTPUT_ROOT?.trim() ||
70
84
  (tenantId === 'st' ? 'build' : `build/${tenantId}`);
71
85
  const svelteKitOutDir = process.env.STOR_SVELTEKIT_OUT_DIR?.trim() || '.svelte-kit';
@@ -228,8 +242,9 @@ return defineConfig(({ command, mode }): UserConfig => {
228
242
  },
229
243
  server: {
230
244
  ...devServerProxy,
231
- // Module and SDK sources live outside src/; Kit's default list omits them.
232
- fs: { allow: [resolve('modules'), resolve('packages')] }
245
+ // Module and SDK sources live outside src/; Kit's default list omits them. In a workspace the platform's packages
246
+ // are linked from outside the site, so the workspace root is allowed too.
247
+ fs: { allow: [resolve('modules'), resolve('packages'), searchForWorkspaceRoot(process.cwd())] }
233
248
  },
234
249
  resolve: {
235
250
  alias: {
@@ -293,7 +308,9 @@ return defineConfig(({ command, mode }): UserConfig => {
293
308
  },
294
309
  ],
295
310
  optimizeDeps: {
296
- exclude: ['sqlocal', 'microlighter']
311
+ // The platform and the modules ship Svelte and TypeScript source; installed from the registry they sit in node_modules,
312
+ // where the dependency scan cannot resolve their relative imports. They are compiled with the site, not pre-bundled.
313
+ exclude: ['sqlocal', 'microlighter', ...hearthlogDependencies()]
297
314
  },
298
315
  build: {
299
316
  // Bundle Microlighter's lazy grammars, which Vite skips in dependencies by default.
@@ -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) {