@hearthlog/tooling 0.1.5 → 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.5",
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.5"
31
+ "@hearthlog/contracts": "0.1.6"
32
32
  },
33
33
  "bin": {
34
34
  "hearthlog": "./bin/hearthlog.mjs"
@@ -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
+ }
@@ -6,6 +6,7 @@ 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';
@@ -24,16 +25,6 @@ import {
24
25
 
25
26
  import { defineConfig, searchForWorkspaceRoot, type ProxyOptions, type UserConfig } from 'vite';
26
27
 
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
- }
36
-
37
28
  function installedVersion(name: string): string {
38
29
  try {
39
30
  return JSON.parse(readFileSync(join('node_modules', name, 'package.json'), 'utf8')).version;
@@ -134,6 +125,7 @@ return defineConfig(({ command, mode }): UserConfig => {
134
125
  ? readLocalLedgerCredentialSync()
135
126
  : undefined;
136
127
  const isE2E = process.env.VITE_E2E === '1';
128
+ const sources = sourcePackages();
137
129
  const usesSameOriginLedger = Boolean(localLedgerCredential) || isE2E;
138
130
  // The dev proxy needs both: the renderer's address and its bearer token (from .dev.vars or the environment).
139
131
  const invoiceRendererOrigin = readDevVar('INVOICE_RENDERER_ORIGIN');
@@ -310,7 +302,12 @@ return defineConfig(({ command, mode }): UserConfig => {
310
302
  optimizeDeps: {
311
303
  // The platform and the modules ship Svelte and TypeScript source; installed from the registry they sit in node_modules,
312
304
  // where the dependency scan cannot resolve their relative imports. They are compiled with the site, not pre-bundled.
313
- 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],
314
311
  },
315
312
  build: {
316
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
+ }
@@ -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
  }