@dormer/cli 0.0.0-stage → 0.1.3
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/README.md +201 -2
- package/bin/dormer.mjs +26 -0
- package/dist/cli.js +1301 -0
- package/dist/index.js +676 -0
- package/package.json +57 -4
- package/src/args.ts +68 -0
- package/src/backend.ts +142 -0
- package/src/cli.ts +45 -0
- package/src/commands/build.ts +105 -0
- package/src/commands/create.ts +132 -0
- package/src/commands/dev.ts +128 -0
- package/src/commands/doctor.ts +141 -0
- package/src/commands/inspect.ts +60 -0
- package/src/commands/package.ts +159 -0
- package/src/config.ts +151 -0
- package/src/host.ts +85 -0
- package/src/index.ts +8 -0
- package/src/resolve.ts +75 -0
- package/src/ui.ts +57 -0
- package/templates/react/README.md +34 -0
- package/templates/react/_gitignore +5 -0
- package/templates/react/dormer.config.json +16 -0
- package/templates/react/package.json +29 -0
- package/templates/react/renderer/index.html +12 -0
- package/templates/react/renderer/src/App.tsx +34 -0
- package/templates/react/renderer/src/main.tsx +6 -0
- package/templates/react/renderer/src/style.css +8 -0
- package/templates/react/renderer/src/vite-env.d.ts +2 -0
- package/templates/react/renderer/tsconfig.json +15 -0
- package/templates/react/renderer/vite.config.ts +7 -0
- package/templates/react/src/main.ts +34 -0
- package/templates/react/src/shared.ts +10 -0
- package/templates/react/tsconfig.json +15 -0
- package/templates/svelte/README.md +34 -0
- package/templates/svelte/_gitignore +5 -0
- package/templates/svelte/dormer.config.json +16 -0
- package/templates/svelte/package.json +27 -0
- package/templates/svelte/renderer/index.html +12 -0
- package/templates/svelte/renderer/src/App.svelte +32 -0
- package/templates/svelte/renderer/src/main.ts +5 -0
- package/templates/svelte/renderer/src/style.css +8 -0
- package/templates/svelte/renderer/src/vite-env.d.ts +2 -0
- package/templates/svelte/renderer/tsconfig.json +15 -0
- package/templates/svelte/renderer/vite.config.ts +7 -0
- package/templates/svelte/src/main.ts +34 -0
- package/templates/svelte/src/shared.ts +10 -0
- package/templates/svelte/tsconfig.json +15 -0
- package/templates/vanilla/README.md +34 -0
- package/templates/vanilla/_gitignore +5 -0
- package/templates/vanilla/dormer.config.json +16 -0
- package/templates/vanilla/package.json +24 -0
- package/templates/vanilla/renderer/index.html +12 -0
- package/templates/vanilla/renderer/src/main.ts +31 -0
- package/templates/vanilla/renderer/src/style.css +8 -0
- package/templates/vanilla/renderer/src/vite-env.d.ts +2 -0
- package/templates/vanilla/renderer/tsconfig.json +14 -0
- package/templates/vanilla/renderer/vite.config.ts +6 -0
- package/templates/vanilla/src/main.ts +34 -0
- package/templates/vanilla/src/shared.ts +10 -0
- package/templates/vanilla/tsconfig.json +15 -0
- package/templates/vue/README.md +34 -0
- package/templates/vue/_gitignore +5 -0
- package/templates/vue/dormer.config.json +16 -0
- package/templates/vue/package.json +27 -0
- package/templates/vue/renderer/index.html +12 -0
- package/templates/vue/renderer/src/App.vue +36 -0
- package/templates/vue/renderer/src/main.ts +5 -0
- package/templates/vue/renderer/src/style.css +8 -0
- package/templates/vue/renderer/src/vite-env.d.ts +8 -0
- package/templates/vue/renderer/tsconfig.json +15 -0
- package/templates/vue/renderer/vite.config.ts +7 -0
- package/templates/vue/src/main.ts +34 -0
- package/templates/vue/src/shared.ts +10 -0
- package/templates/vue/tsconfig.json +15 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// dormer dev: Vite dev server + esbuild watch of the backend + the native host.
|
|
2
|
+
//
|
|
3
|
+
// Restart model: when the backend bundle changes the whole host process is restarted (windows are
|
|
4
|
+
// recreated, renderer state is lost; the Vite page itself keeps HMR). Restarting only the Node
|
|
5
|
+
// sidecar while keeping windows open needs host support (see README "Limitations").
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import { once } from "node:events";
|
|
8
|
+
import type { ChildProcess } from "node:child_process";
|
|
9
|
+
import { type CommandSpec, type ParsedArgs, flagList, flagNumber, flagString } from "../args.ts";
|
|
10
|
+
import { loadApp } from "../config.ts";
|
|
11
|
+
import { formatMessages, summarizeExternals, watchBackend, type WatchHandle } from "../backend.ts";
|
|
12
|
+
import { resolveHostBinary, spawnHost } from "../host.ts";
|
|
13
|
+
import { findViteConfig, loadVite } from "../resolve.ts";
|
|
14
|
+
import { EXIT, c, log } from "../ui.ts";
|
|
15
|
+
|
|
16
|
+
export const spec: CommandSpec = {
|
|
17
|
+
name: "dev",
|
|
18
|
+
summary: "Run the app with the Vite dev server, backend rebuilds and the native host",
|
|
19
|
+
usage: "dev [--port <n>] [--host <name>] [--pass-env <VAR>]...",
|
|
20
|
+
description: `Starts Vite for renderer/ (HMR), bundles the backend with esbuild in watch mode to
|
|
21
|
+
.dormer/dev/main.js and launches dormer-host with --dev --dev-url <vite url>. The backend sees
|
|
22
|
+
DORMER_DEV_URL and should call win.loadURL(process.env.DORMER_DEV_URL) (templates do). When the
|
|
23
|
+
backend bundle changes the host is restarted. Ctrl-C or closing the last window stops everything.`,
|
|
24
|
+
options: {
|
|
25
|
+
port: { type: "number", description: "Preferred Vite port (the next free port is used if busy)" },
|
|
26
|
+
host: { type: "string", description: "Host name Vite listens on", default: "localhost" },
|
|
27
|
+
"pass-env": { type: "list", description: "Environment variable to forward into the Node sidecar (repeatable)" },
|
|
28
|
+
},
|
|
29
|
+
examples: ["dormer dev", "dormer dev --port 3000 --pass-env MY_API_KEY"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function portFree(port: number, host: string): Promise<boolean> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const s = net.createServer();
|
|
35
|
+
s.once("error", () => resolve(false));
|
|
36
|
+
s.listen(port, host, () => s.close(() => resolve(true)));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function findFreePort(preferred: number, host: string): Promise<number> {
|
|
40
|
+
for (let p = preferred; p < preferred + 50; p++) if (await portFree(p, host)) return p;
|
|
41
|
+
throw new Error(`no free port in ${preferred}-${preferred + 49}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const alive = (p: ChildProcess | undefined): p is ChildProcess => Boolean(p && p.exitCode === null && p.signalCode === null);
|
|
45
|
+
|
|
46
|
+
export async function run(args: ParsedArgs): Promise<number> {
|
|
47
|
+
const app = loadApp();
|
|
48
|
+
const { config, paths } = app;
|
|
49
|
+
const host = resolveHostBinary(app.root); // fail before anything is started
|
|
50
|
+
const hostName = flagString(args.flags, "host") ?? "localhost";
|
|
51
|
+
const port = await findFreePort(flagNumber(args.flags, "port") ?? config.dev?.port ?? 5173, hostName);
|
|
52
|
+
|
|
53
|
+
const { vite, version } = await loadVite(app.root);
|
|
54
|
+
const configFile = findViteConfig(paths.rendererDir);
|
|
55
|
+
if (!configFile) log.warn(`no vite.config.* in ${paths.rendererDir}; add one using @dormer/vite-plugin`);
|
|
56
|
+
const server = await vite.createServer({
|
|
57
|
+
root: paths.rendererDir,
|
|
58
|
+
configFile: configFile ?? false,
|
|
59
|
+
clearScreen: false,
|
|
60
|
+
logLevel: "info",
|
|
61
|
+
server: { port, strictPort: true, host: hostName },
|
|
62
|
+
});
|
|
63
|
+
await server.listen();
|
|
64
|
+
const url = server.resolvedUrls?.local[0] ?? `http://${hostName}:${port}/`;
|
|
65
|
+
log.ok(`vite ${version} dev server ready at ${c.bold(url)}`);
|
|
66
|
+
|
|
67
|
+
const passEnv = new Set<string>(["DORMER_DEV_URL", "DORMER_APP_VERSION", ...(config.dev?.passEnv ?? []), ...flagList(args.flags, "pass-env")]);
|
|
68
|
+
if (process.env.DORMER_SELFTEST !== undefined) passEnv.add("DORMER_SELFTEST");
|
|
69
|
+
|
|
70
|
+
let child: ChildProcess | undefined;
|
|
71
|
+
let generation = 0;
|
|
72
|
+
let pendingRestart = false;
|
|
73
|
+
let shuttingDown = false;
|
|
74
|
+
let watcher: WatchHandle | undefined;
|
|
75
|
+
|
|
76
|
+
const shutdown = async (code: number): Promise<never> => {
|
|
77
|
+
if (shuttingDown) await new Promise(() => {}); // another shutdown is in progress; wait for it to exit
|
|
78
|
+
shuttingDown = true;
|
|
79
|
+
if (alive(child)) {
|
|
80
|
+
child.kill("SIGTERM");
|
|
81
|
+
await Promise.race([once(child, "exit"), new Promise((r) => setTimeout(r, 3000))]);
|
|
82
|
+
if (alive(child)) child.kill("SIGKILL");
|
|
83
|
+
}
|
|
84
|
+
await watcher?.dispose().catch(() => {});
|
|
85
|
+
await server.close().catch(() => {});
|
|
86
|
+
process.exit(code);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const launch = () => {
|
|
90
|
+
const myGeneration = ++generation;
|
|
91
|
+
const proc = spawnHost({
|
|
92
|
+
host: host.path, appDir: app.root, entry: paths.devBackendOut, node: process.execPath, name: config.name,
|
|
93
|
+
dev: true, devUrl: url, passEnv: [...passEnv],
|
|
94
|
+
env: { DORMER_DEV_URL: url, DORMER_APP_VERSION: config.version },
|
|
95
|
+
});
|
|
96
|
+
child = proc;
|
|
97
|
+
log.ok(`host started (pid ${proc.pid}) from ${host.path} [${host.source}]`);
|
|
98
|
+
proc.on("error", (e) => { log.error(`failed to start host: ${e.message}`); void shutdown(EXIT.failure); });
|
|
99
|
+
proc.on("exit", (code, signal) => {
|
|
100
|
+
if (shuttingDown || myGeneration !== generation) return;
|
|
101
|
+
if (pendingRestart) { pendingRestart = false; launch(); return; }
|
|
102
|
+
log.info(`host exited (${signal ?? `code ${code}`}); stopping dev server`);
|
|
103
|
+
void shutdown(code ?? EXIT.ok);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const restart = () => {
|
|
108
|
+
if (!alive(child)) { launch(); return; }
|
|
109
|
+
pendingRestart = true;
|
|
110
|
+
log.step("backend changed — restarting host (windows are recreated)");
|
|
111
|
+
const victim = child;
|
|
112
|
+
victim.kill("SIGTERM");
|
|
113
|
+
setTimeout(() => { if (alive(victim)) victim.kill("SIGKILL"); }, 3000).unref();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let builds = 0;
|
|
117
|
+
watcher = await watchBackend({ root: app.root, entry: paths.entry, outfile: paths.devBackendOut, sourcemap: true, external: config.backend?.external }, (r) => {
|
|
118
|
+
builds++;
|
|
119
|
+
if (r.warnings.length) console.error(formatMessages(r.warnings, "warning"));
|
|
120
|
+
if (!r.ok) { log.error(`backend bundle failed (${builds === 1 ? "fix the error to start the host" : "keeping the previous host running"})\n${formatMessages(r.errors, "error")}`); return; }
|
|
121
|
+
if (builds === 1) { log.ok(`backend bundled → ${paths.devBackendOut} (${summarizeExternals(r.externals)})`); launch(); }
|
|
122
|
+
else restart();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
process.on("SIGINT", () => { console.log(); void shutdown(130); });
|
|
126
|
+
process.on("SIGTERM", () => void shutdown(143));
|
|
127
|
+
return new Promise<number>(() => {}); // runs until shutdown() exits the process
|
|
128
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// dormer doctor: environment checks with fix hints, plus the capability summary of the app.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import type { CommandSpec, ParsedArgs } from "../args.ts";
|
|
6
|
+
import { CONFIG_FILE, type DormerConfig, findAppRoot, loadConfig, resolvePaths } from "../config.ts";
|
|
7
|
+
import { CLI_DIR, hostPackageName, resolveHostBinary } from "../host.ts";
|
|
8
|
+
import { findPackageDir, readJson, which } from "../resolve.ts";
|
|
9
|
+
import { CliError, EXIT, c } from "../ui.ts";
|
|
10
|
+
|
|
11
|
+
export const spec: CommandSpec = {
|
|
12
|
+
name: "doctor",
|
|
13
|
+
summary: "Check the toolchain, the native host and platform prerequisites",
|
|
14
|
+
usage: "doctor",
|
|
15
|
+
description: `Exit code 1 when a check fails (warnings do not fail). Windows and macOS checks are
|
|
16
|
+
written from documentation and have not been verified on those platforms yet.`,
|
|
17
|
+
options: {},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type Status = "ok" | "warn" | "fail";
|
|
21
|
+
export interface Check { name: string; status: Status; detail: string; hint?: string }
|
|
22
|
+
|
|
23
|
+
function sh(cmd: string, args: string[]): { ok: boolean; out: string } {
|
|
24
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
25
|
+
return { ok: !r.error && r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function nodeCheck(version: string = process.versions.node): Check {
|
|
29
|
+
const [major, minor] = version.split(".").map(Number);
|
|
30
|
+
if (major < 22) return { name: "Node.js", status: "fail", detail: `v${version} (22 or newer required)`, hint: "install Node 24 LTS from https://nodejs.org or with your version manager" };
|
|
31
|
+
if (major === 22 && minor < 18) return { name: "Node.js", status: "warn", detail: `v${version} (type stripping needs --experimental-strip-types before 22.18; the CLI re-executes itself with it)`, hint: "upgrade to Node 22.18+ or 24 to avoid the re-exec" };
|
|
32
|
+
return { name: "Node.js", status: "ok", detail: `v${version}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hostCheck(root: string): Check {
|
|
36
|
+
try { const h = resolveHostBinary(root); return { name: "dormer-host", status: "ok", detail: `${h.path} (${h.source === "env" ? "DORMER_HOST_BIN" : h.source === "cargo-target" ? "cargo target dir" : hostPackageName()})` }; }
|
|
37
|
+
catch (e) { const err = e as CliError; return { name: "dormer-host", status: "fail", detail: err.message, hint: err.hints?.slice(1).join("; ") }; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function linuxChecks(): Check[] {
|
|
41
|
+
const checks: Check[] = [];
|
|
42
|
+
if (sh("pkg-config", ["--exists", "webkit2gtk-4.1"]).ok) checks.push({ name: "WebKitGTK 4.1", status: "ok", detail: `development package present (${sh("pkg-config", ["--modversion", "webkit2gtk-4.1"]).out})` });
|
|
43
|
+
else {
|
|
44
|
+
const ld = sh("ldconfig", ["-p"]);
|
|
45
|
+
if (ld.ok && /libwebkit2gtk-4\.1/.test(ld.out)) checks.push({ name: "WebKitGTK 4.1", status: "ok", detail: "runtime library present (dev headers missing: only needed to build the host)", hint: "to build dormer-host: sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev pkg-config" });
|
|
46
|
+
else checks.push({ name: "WebKitGTK 4.1", status: "fail", detail: "libwebkit2gtk-4.1 not found", hint: "sudo apt install libwebkit2gtk-4.1-0 (runtime) or libwebkit2gtk-4.1-dev (to build the host); Fedora: webkit2gtk4.1" });
|
|
47
|
+
}
|
|
48
|
+
const bus = process.env.DBUS_SESSION_BUS_ADDRESS || (process.env.XDG_RUNTIME_DIR && fs.existsSync(path.join(process.env.XDG_RUNTIME_DIR, "bus")) ? `unix:path=${path.join(process.env.XDG_RUNTIME_DIR, "bus")}` : "");
|
|
49
|
+
checks.push(bus ? { name: "session D-Bus", status: "ok", detail: bus } : { name: "session D-Bus", status: "warn", detail: "no session bus (DBUS_SESSION_BUS_ADDRESS unset, no $XDG_RUNTIME_DIR/bus)", hint: "WebKitGTK and notifications want one: run under dbus-run-session -- <command>" });
|
|
50
|
+
const display = process.env.WAYLAND_DISPLAY ? `wayland (${process.env.WAYLAND_DISPLAY})` : process.env.DISPLAY ? `x11 (${process.env.DISPLAY})` : "";
|
|
51
|
+
checks.push(display ? { name: "display", status: "ok", detail: display } : { name: "display", status: "warn", detail: "no DISPLAY or WAYLAND_DISPLAY", hint: 'headless machines: xvfb-run -a -s "-screen 0 1280x800x24" dbus-run-session -- npm run dev' });
|
|
52
|
+
const nvidia = fs.existsSync("/proc/driver/nvidia/version") || Boolean(which("nvidia-smi"));
|
|
53
|
+
if (nvidia) checks.push({ name: "GPU driver", status: "warn", detail: "NVIDIA driver detected", hint: "if windows stay blank or crash in the compositor, set WEBKIT_DISABLE_DMABUF_RENDERER=1" });
|
|
54
|
+
const headless = ["xvfb-run", "dbus-run-session"].filter((t) => !which(t));
|
|
55
|
+
checks.push(headless.length ? { name: "headless tools", status: "warn", detail: `missing ${headless.join(", ")} (only needed for headless test runs)`, hint: "sudo apt install xvfb dbus" } : { name: "headless tools", status: "ok", detail: "xvfb-run and dbus-run-session available" });
|
|
56
|
+
return checks;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function macosChecks(): Check[] {
|
|
60
|
+
// UNVERIFIED on macOS: written from Apple documentation.
|
|
61
|
+
const clt = sh("xcode-select", ["-p"]);
|
|
62
|
+
const sw = sh("sw_vers", ["-productVersion"]);
|
|
63
|
+
const major = Number(sw.out.split(".")[0]);
|
|
64
|
+
return [
|
|
65
|
+
clt.ok ? { name: "Xcode command line tools", status: "ok", detail: clt.out } : { name: "Xcode command line tools", status: "fail", detail: "not installed", hint: "run: xcode-select --install" },
|
|
66
|
+
sw.ok ? (major >= 13 ? { name: "macOS", status: "ok", detail: sw.out } : { name: "macOS", status: "warn", detail: `${sw.out} (13+ recommended for WKWebView features Dormer relies on)` }) : { name: "macOS", status: "warn", detail: "sw_vers unavailable" },
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function windowsChecks(): Check[] {
|
|
71
|
+
// UNVERIFIED on Windows: the Evergreen WebView2 runtime registers its version under `pv` at these keys.
|
|
72
|
+
const keys = [
|
|
73
|
+
"HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
|
74
|
+
"HKLM\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
|
75
|
+
"HKCU\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
|
76
|
+
];
|
|
77
|
+
for (const k of keys) {
|
|
78
|
+
const r = sh("reg", ["query", k, "/v", "pv"]);
|
|
79
|
+
const m = r.ok ? /pv\s+REG_SZ\s+(\S+)/.exec(r.out) : null;
|
|
80
|
+
if (m && m[1] !== "0.0.0.0") return [{ name: "WebView2 runtime", status: "ok", detail: `version ${m[1]}` }];
|
|
81
|
+
}
|
|
82
|
+
return [{ name: "WebView2 runtime", status: "fail", detail: "Evergreen WebView2 runtime not registered", hint: "install it from https://developer.microsoft.com/microsoft-edge/webview2/ (Windows 11 ships it)" }];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function projectChecks(root: string | undefined): Check[] {
|
|
86
|
+
const checks: Check[] = [];
|
|
87
|
+
const esb = findPackageDir(CLI_DIR, "esbuild");
|
|
88
|
+
checks.push(esb ? { name: "esbuild", status: "ok", detail: `v${readJson<{ version: string }>(path.join(esb, "package.json")).version}` } : { name: "esbuild", status: "fail", detail: "not resolvable from @dormer/cli", hint: "reinstall @dormer/cli" });
|
|
89
|
+
if (!root) return checks;
|
|
90
|
+
const vite = findPackageDir(root, "vite");
|
|
91
|
+
checks.push(vite ? { name: "vite", status: "ok", detail: `v${readJson<{ version: string }>(path.join(vite, "package.json")).version} (${vite})` } : { name: "vite", status: "fail", detail: `not installed in ${root}`, hint: "npm install (vite is a devDependency of every template)" });
|
|
92
|
+
for (const p of ["@dormer/api", "@dormer/renderer", "@dormer/vite-plugin"]) {
|
|
93
|
+
const dir = findPackageDir(root, p);
|
|
94
|
+
checks.push(dir ? { name: p, status: "ok", detail: fs.realpathSync(dir) } : { name: p, status: "warn", detail: "not installed", hint: `npm install ${p}` });
|
|
95
|
+
}
|
|
96
|
+
return checks;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function capabilitySummary(config: DormerConfig, root: string): string[] {
|
|
100
|
+
const paths = resolvePaths(root, config);
|
|
101
|
+
const lines = [
|
|
102
|
+
`${c.bold(config.name)} ${config.version} (${config.identifier})${config.description ? ` — ${config.description}` : ""}`,
|
|
103
|
+
`entry ${path.relative(root, paths.entry)} · renderer ${path.relative(root, paths.rendererDir)}/ · node pin ${config.node?.version ?? c.dim("none")}`,
|
|
104
|
+
];
|
|
105
|
+
const windows = Object.entries(config.windows ?? {});
|
|
106
|
+
if (windows.length === 0) lines.push(`windows: ${c.dim("none declared (renderers get app-local ipc:*/send:* only)")}`);
|
|
107
|
+
for (const [name, w] of windows) lines.push(`window ${c.bold(name)}: ${(w.capabilities ?? []).length ? w.capabilities!.join(", ") : c.dim("no capabilities")}`);
|
|
108
|
+
if (config.backend?.external?.length) lines.push(`backend externals: ${config.backend.external.join(", ")}`);
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function runChecks(root: string | undefined): Check[] {
|
|
113
|
+
const checks: Check[] = [nodeCheck(), hostCheck(root ?? process.cwd())];
|
|
114
|
+
if (process.platform === "linux") checks.push(...linuxChecks());
|
|
115
|
+
else if (process.platform === "darwin") checks.push(...macosChecks());
|
|
116
|
+
else if (process.platform === "win32") checks.push(...windowsChecks());
|
|
117
|
+
checks.push(...projectChecks(root));
|
|
118
|
+
return checks;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function run(_args: ParsedArgs): Promise<number> {
|
|
122
|
+
const root = findAppRoot(process.cwd());
|
|
123
|
+
console.log(`${c.bold("dormer doctor")} — ${process.platform} ${process.arch}${root ? `, app at ${root}` : ` (no ${CONFIG_FILE} found from ${process.cwd()})`}`);
|
|
124
|
+
const checks = runChecks(root);
|
|
125
|
+
const icon: Record<Status, string> = { ok: c.green("✓"), warn: c.yellow("!"), fail: c.red("✗") };
|
|
126
|
+
const width = Math.max(...checks.map((k) => k.name.length)) + 2;
|
|
127
|
+
for (const k of checks) {
|
|
128
|
+
console.log(` ${icon[k.status]} ${k.name.padEnd(width)}${k.detail}`);
|
|
129
|
+
if (k.hint && k.status !== "ok") console.log(` ${c.dim("hint:")} ${k.hint}`);
|
|
130
|
+
}
|
|
131
|
+
if (root) {
|
|
132
|
+
console.log(`\n${c.bold("App")} (${path.join(root, CONFIG_FILE)})`);
|
|
133
|
+
try { for (const l of capabilitySummary(loadConfig(root), root)) console.log(` ${l}`); }
|
|
134
|
+
catch (e) { console.log(` ${c.red("✗")} ${(e as Error).message}`); return EXIT.config; }
|
|
135
|
+
}
|
|
136
|
+
if (process.platform === "darwin" || process.platform === "win32") console.log(`\n${c.dim("note: platform checks on this OS are unverified; please report false results")}`);
|
|
137
|
+
const fails = checks.filter((k) => k.status === "fail").length;
|
|
138
|
+
const warns = checks.filter((k) => k.status === "warn").length;
|
|
139
|
+
console.log(`\n${fails ? c.red(`${fails} problem(s)`) : c.green("no problems")}${warns ? `, ${warns} warning(s)` : ""}`);
|
|
140
|
+
return fails ? EXIT.failure : EXIT.ok;
|
|
141
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// dormer inspect: build (unless --skip-build), launch the app in inspection mode and print, per
|
|
2
|
+
// window, the element-tree outline plus the snapshot path. Made for agents and CI: the same data a
|
|
3
|
+
// screen reader sees, without opening devtools.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { type CommandSpec, type ParsedArgs, flagBool, flagNumber, flagString } from "../args.ts";
|
|
7
|
+
import { loadApp } from "../config.ts";
|
|
8
|
+
import { resolveHostBinary, spawnHost } from "../host.ts";
|
|
9
|
+
import { CliError, EXIT, c, log } from "../ui.ts";
|
|
10
|
+
import { buildApp } from "./build.ts";
|
|
11
|
+
|
|
12
|
+
export const spec: CommandSpec = {
|
|
13
|
+
name: "inspect",
|
|
14
|
+
summary: "Launch the app, capture each window's element tree (and snapshot), print an outline",
|
|
15
|
+
usage: "inspect [--out <dir>] [--skip-build] [--json] [--timeout <ms>] [--settle <ms>] [--lines <n>]",
|
|
16
|
+
description: `Runs the built app with DORMER_INSPECT_OUT set. @dormer/api then captures every window once it
|
|
17
|
+
has loaded: an accessibility-style element tree (roles, names, states, bounds) as JSON and text, and a
|
|
18
|
+
PNG snapshot where the platform supports it (macOS today). The app exits by itself; the outline is
|
|
19
|
+
printed here. Files land in .dormer/inspect/ by default.`,
|
|
20
|
+
options: {
|
|
21
|
+
out: { type: "string", description: "Output directory (default .dormer/inspect)" },
|
|
22
|
+
"skip-build": { type: "boolean", description: "Reuse dist/ instead of rebuilding" },
|
|
23
|
+
json: { type: "boolean", description: "Print inspect.json instead of the outline" },
|
|
24
|
+
timeout: { type: "number", description: "Give up after this many milliseconds (default 20000)" },
|
|
25
|
+
settle: { type: "number", description: "Wait this long after page load before capturing (default 700)" },
|
|
26
|
+
lines: { type: "number", description: "Outline lines to print per window (default 80)" },
|
|
27
|
+
},
|
|
28
|
+
examples: ["dormer inspect", "dormer inspect --skip-build --lines 200", "dormer inspect --json | jq .windows[0].nodes"],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export async function run(args: ParsedArgs): Promise<number> {
|
|
32
|
+
const app = loadApp();
|
|
33
|
+
if (!flagBool(args.flags, "skip-build")) await buildApp(app, { quiet: true });
|
|
34
|
+
const host = resolveHostBinary(app.root);
|
|
35
|
+
const out = path.resolve(app.root, flagString(args.flags, "out") ?? path.join(".dormer", "inspect"));
|
|
36
|
+
fs.rmSync(out, { recursive: true, force: true }); fs.mkdirSync(out, { recursive: true });
|
|
37
|
+
const timeout = flagNumber(args.flags, "timeout") ?? 20000;
|
|
38
|
+
const proc = spawnHost({
|
|
39
|
+
host: host.path, appDir: app.paths.rendererOut, entry: app.paths.backendOut, node: process.execPath, name: app.config.name,
|
|
40
|
+
passEnv: ["DORMER_INSPECT_OUT", "DORMER_INSPECT_SETTLE_MS", "DORMER_INSPECT_TIMEOUT_MS"],
|
|
41
|
+
env: { DORMER_INSPECT_OUT: out, DORMER_INSPECT_SETTLE_MS: String(flagNumber(args.flags, "settle") ?? 700), DORMER_INSPECT_TIMEOUT_MS: String(timeout), DORMER_APP_VERSION: app.config.version, RUST_LOG: process.env.RUST_LOG ?? "warn" },
|
|
42
|
+
});
|
|
43
|
+
const code = await new Promise<number>((res) => { const t = setTimeout(() => { proc.kill("SIGKILL"); res(124); }, timeout + 10000); proc.on("exit", (c) => { clearTimeout(t); res(c ?? 1); }); });
|
|
44
|
+
const summaryPath = path.join(out, "inspect.json");
|
|
45
|
+
if (!fs.existsSync(summaryPath)) throw new CliError(`no inspection result (host exited with ${code})`, { exitCode: EXIT.failure, hints: ["does the app create a window on ready?", "run with RUST_LOG=info for the host log"] });
|
|
46
|
+
const summary = JSON.parse(fs.readFileSync(summaryPath, "utf8")) as { ok: boolean; timedOut: boolean; windows: Array<{ route: number; title: string; url: string; nodes: number; truncated: boolean; outline: string; png: string | null; pngError?: string; error?: string }> };
|
|
47
|
+
if (flagBool(args.flags, "json")) { process.stdout.write(JSON.stringify(summary, null, 2) + "\n"); return summary.ok ? EXIT.ok : EXIT.failure; }
|
|
48
|
+
const lines = flagNumber(args.flags, "lines") ?? 80;
|
|
49
|
+
for (const w of summary.windows) {
|
|
50
|
+
if (w.error) { log.error(`window ${w.route}: ${w.error}`); continue; }
|
|
51
|
+
console.log(`${c.bold(`window ${w.route}`)} ${c.dim(w.url)} ${w.nodes} nodes${w.truncated ? " (truncated)" : ""}`);
|
|
52
|
+
console.log(` snapshot: ${w.png ?? c.dim(`none (${w.pngError ?? "not supported here"})`)}`);
|
|
53
|
+
console.log(` tree: ${w.outline.replace(/\.outline\.txt$/, ".tree.json")}`);
|
|
54
|
+
const outline = fs.readFileSync(w.outline, "utf8").split("\n").filter(Boolean);
|
|
55
|
+
console.log(outline.slice(0, lines).map((l) => ` ${l}`).join("\n"));
|
|
56
|
+
if (outline.length > lines) console.log(c.dim(` … ${outline.length - lines} more lines in ${w.outline}`));
|
|
57
|
+
}
|
|
58
|
+
if (summary.timedOut) log.error("timed out before any window could be inspected");
|
|
59
|
+
return summary.ok ? EXIT.ok : EXIT.failure;
|
|
60
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// dormer package: writes dist/bundle.json and shells out to the Rust `dormer-bundle` tool.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { type CommandSpec, type ParsedArgs, flagBool, flagList, flagString } from "../args.ts";
|
|
6
|
+
import { BUNDLE_TARGETS, type BundleTarget, type DormerConfig, loadApp } from "../config.ts";
|
|
7
|
+
import { CLI_DIR, isExecutable, resolveHostBinary } from "../host.ts";
|
|
8
|
+
import { findPackageDir, findUp, which } from "../resolve.ts";
|
|
9
|
+
import { CliError, EXIT, c, log } from "../ui.ts";
|
|
10
|
+
import { buildApp } from "./build.ts";
|
|
11
|
+
|
|
12
|
+
export const spec: CommandSpec = {
|
|
13
|
+
name: "package",
|
|
14
|
+
summary: "Produce installers for the current platform with dormer-bundle",
|
|
15
|
+
usage: "package [--target <t>]... [--out <dir>] [--skip-build] [--dry-run]",
|
|
16
|
+
description: `Builds the app (unless --skip-build), writes dist/bundle.json describing it, then runs
|
|
17
|
+
\`dormer-bundle --config dist/bundle.json --out <dir>\`. The bundler binary is looked up in
|
|
18
|
+
$DORMER_BUNDLE_BIN, the cargo target directory and PATH; when missing, instructions are printed
|
|
19
|
+
and the exit code is 2. --dry-run stops after writing bundle.json.`,
|
|
20
|
+
options: {
|
|
21
|
+
target: { type: "list", alias: "t", description: "Installer format (repeatable)", values: BUNDLE_TARGETS },
|
|
22
|
+
out: { type: "string", description: "Output directory", default: "dist/bundle" },
|
|
23
|
+
"skip-build": { type: "boolean", description: "Reuse the existing dist/ instead of rebuilding" },
|
|
24
|
+
"dry-run": { type: "boolean", description: "Only write dist/bundle.json" },
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Interface agreed with tools/bundler (`dormer-bundle --config <file> --out <dir>`). */
|
|
29
|
+
export interface BundleSpec {
|
|
30
|
+
name: string;
|
|
31
|
+
identifier: string;
|
|
32
|
+
version: string;
|
|
33
|
+
description: string;
|
|
34
|
+
/** Absolute path of the built app (dist/): contains renderer/, main.js, dormer-app.json. */
|
|
35
|
+
appDir: string;
|
|
36
|
+
/** Backend entry relative to appDir. */
|
|
37
|
+
entry: string;
|
|
38
|
+
hostBinary: string;
|
|
39
|
+
nodeBinary: string;
|
|
40
|
+
targets: BundleTarget[];
|
|
41
|
+
icons: string[];
|
|
42
|
+
linux: { depends: string[] };
|
|
43
|
+
macos: { entitlements: string | null; signingIdentity: string | null; minimumSystemVersion: string | null };
|
|
44
|
+
windows: { signCommand: string | null };
|
|
45
|
+
/** Ship `*.map` files from dist/ (off by default: they are build artefacts, 1 MB for the PoC). */
|
|
46
|
+
sourceMaps: boolean;
|
|
47
|
+
/** Full ICU data file to ship next to a small-icu Node (`node.icu: "full"`), else null. */
|
|
48
|
+
nodeIcuData: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `icudt<N>l.dat` next to the Node binary (placed there by `dormer-bundle fetch-node` from a Dormer dist). */
|
|
52
|
+
export function findIcuData(nodeBinary: string): string | undefined {
|
|
53
|
+
const dir = path.dirname(nodeBinary);
|
|
54
|
+
try { const f = fs.readdirSync(dir).find((n) => /^icudt\d+l\.dat$/.test(n)); return f ? path.join(dir, f) : undefined; } catch { return undefined; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function defaultTargets(platform: NodeJS.Platform = process.platform): BundleTarget[] {
|
|
58
|
+
if (platform === "darwin") return ["app", "dmg"];
|
|
59
|
+
if (platform === "win32") return ["nsis"];
|
|
60
|
+
return ["deb", "appimage"];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_LINUX_DEPENDS = ["libwebkit2gtk-4.1-0", "libgtk-3-0"];
|
|
64
|
+
|
|
65
|
+
export function makeBundleSpec(config: DormerConfig, o: { root: string; appDir: string; hostBinary: string; nodeBinary: string; targets: BundleTarget[] }): BundleSpec {
|
|
66
|
+
const b = config.bundle ?? {};
|
|
67
|
+
let icons = (b.icons ?? []).map((p) => path.resolve(o.root, p));
|
|
68
|
+
if (icons.length === 0 && fs.existsSync(path.join(o.root, "icons"))) {
|
|
69
|
+
icons = fs.readdirSync(path.join(o.root, "icons")).filter((f) => /\.(png|icns|ico|svg)$/i.test(f)).map((f) => path.join(o.root, "icons", f));
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
name: config.name,
|
|
73
|
+
identifier: config.identifier,
|
|
74
|
+
version: config.version,
|
|
75
|
+
description: config.description ?? "",
|
|
76
|
+
appDir: o.appDir,
|
|
77
|
+
entry: "main.js",
|
|
78
|
+
hostBinary: o.hostBinary,
|
|
79
|
+
nodeBinary: o.nodeBinary,
|
|
80
|
+
targets: o.targets,
|
|
81
|
+
icons,
|
|
82
|
+
linux: { depends: b.linux?.depends ?? DEFAULT_LINUX_DEPENDS },
|
|
83
|
+
macos: { entitlements: b.macos?.entitlements ? path.resolve(o.root, b.macos.entitlements) : null, signingIdentity: b.macos?.signingIdentity ?? null, minimumSystemVersion: b.macos?.minimumSystemVersion ?? "13.0" },
|
|
84
|
+
windows: { signCommand: b.windows?.signCommand ?? null },
|
|
85
|
+
sourceMaps: b.sourceMaps ?? false,
|
|
86
|
+
nodeIcuData: config.node?.icu === "full" ? (findIcuData(o.nodeBinary) ?? null) : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveBundlerBinary(appRoot: string): { path?: string; tried: string[] } {
|
|
91
|
+
const exe = process.platform === "win32" ? "dormer-bundle.exe" : "dormer-bundle";
|
|
92
|
+
const tried: string[] = [];
|
|
93
|
+
if (process.env.DORMER_BUNDLE_BIN) { tried.push(process.env.DORMER_BUNDLE_BIN); if (isExecutable(process.env.DORMER_BUNDLE_BIN)) return { path: process.env.DORMER_BUNDLE_BIN, tried }; }
|
|
94
|
+
const workspaces = new Set<string>();
|
|
95
|
+
for (const start of [appRoot, CLI_DIR]) { const ws = findUp(start, "Cargo.toml"); if (ws) workspaces.add(ws); }
|
|
96
|
+
for (const ws of workspaces) {
|
|
97
|
+
for (const profile of ["release", "debug"]) {
|
|
98
|
+
const candidate = path.join(process.env.CARGO_TARGET_DIR ?? path.join(ws, "target"), profile, exe);
|
|
99
|
+
tried.push(candidate);
|
|
100
|
+
if (isExecutable(candidate)) return { path: candidate, tried };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Prebuilt package for this platform (optional dependency of @dormer/cli), then $PATH.
|
|
104
|
+
const pkg = findPackageDir(appRoot, bundlePackageName()) ?? findPackageDir(CLI_DIR, bundlePackageName());
|
|
105
|
+
const fromPkg = pkg ? path.join(pkg, "bin", exe) : path.join(appRoot, "node_modules", bundlePackageName(), "bin", exe);
|
|
106
|
+
tried.push(fromPkg);
|
|
107
|
+
if (isExecutable(fromPkg)) return { path: fromPkg, tried };
|
|
108
|
+
const onPath = which("dormer-bundle");
|
|
109
|
+
tried.push("$PATH");
|
|
110
|
+
return { path: onPath, tried };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The npm package that carries the prebuilt packaging tool for this platform. */
|
|
114
|
+
export function bundlePackageName(): string { return `@dormer/bundle-${process.platform}-${process.arch}`; }
|
|
115
|
+
|
|
116
|
+
export function resolveNodeBinary(config: DormerConfig): string {
|
|
117
|
+
const candidate = config.node?.binary ?? process.env.DORMER_NODE_BIN ?? process.execPath;
|
|
118
|
+
if (!isExecutable(candidate)) throw new CliError(`Node binary not found: ${candidate}`, { exitCode: EXIT.noInput, hints: ['set "node.binary" in dormer.config.json or DORMER_NODE_BIN'] });
|
|
119
|
+
return candidate;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function run(args: ParsedArgs): Promise<number> {
|
|
123
|
+
const app = loadApp();
|
|
124
|
+
const { config, paths } = app;
|
|
125
|
+
if (!flagBool(args.flags, "skip-build")) await buildApp(app, { quiet: true });
|
|
126
|
+
else if (!fs.existsSync(path.join(paths.distDir, "dormer-app.json"))) throw new CliError("--skip-build given but dist/dormer-app.json does not exist", { exitCode: EXIT.noInput, hints: [`run ${c.bold("dormer build")} first`] });
|
|
127
|
+
|
|
128
|
+
const requested = flagList(args.flags, "target") as BundleTarget[];
|
|
129
|
+
const targets = requested.length ? requested : config.bundle?.targets ?? defaultTargets();
|
|
130
|
+
const host = resolveHostBinary(app.root);
|
|
131
|
+
const nodeBinary = resolveNodeBinary(config);
|
|
132
|
+
const pinned = config.node?.version;
|
|
133
|
+
if (pinned && pinned.split(".")[0] !== process.versions.node.split(".")[0] && nodeBinary === process.execPath) {
|
|
134
|
+
log.warn(`dormer.config.json pins Node ${pinned} but the app is packaged with the running Node ${process.versions.node}; set "node.binary" to a Node ${pinned} executable to ship that one`);
|
|
135
|
+
}
|
|
136
|
+
const bundle = makeBundleSpec(config, { root: app.root, appDir: paths.distDir, hostBinary: host.path, nodeBinary, targets });
|
|
137
|
+
const specPath = path.join(paths.distDir, "bundle.json");
|
|
138
|
+
fs.writeFileSync(specPath, JSON.stringify(bundle, null, 2) + "\n");
|
|
139
|
+
log.ok(`wrote ${specPath} (targets: ${targets.join(", ")})`);
|
|
140
|
+
if (flagBool(args.flags, "dry-run")) return EXIT.ok;
|
|
141
|
+
|
|
142
|
+
const outDir = path.resolve(app.root, flagString(args.flags, "out") ?? "dist/bundle");
|
|
143
|
+
const bundler = resolveBundlerBinary(app.root);
|
|
144
|
+
if (!bundler.path) {
|
|
145
|
+
log.error("dormer-bundle (the Rust packaging tool from tools/bundler) was not found");
|
|
146
|
+
log.hint(`looked at: ${bundler.tried.join(", ")}`);
|
|
147
|
+
log.hint(`inside the Dormer repository: ${c.bold("cargo build --release -p dormer-bundle")}`);
|
|
148
|
+
log.hint(`or set ${c.bold("DORMER_BUNDLE_BIN=/path/to/dormer-bundle")}`);
|
|
149
|
+
log.hint(`the generated ${specPath} can be passed to it manually: dormer-bundle --config ${specPath} --out ${outDir}`);
|
|
150
|
+
return EXIT.missingTool;
|
|
151
|
+
}
|
|
152
|
+
log.step(`running ${bundler.path} --config ${specPath} --out ${outDir}`);
|
|
153
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
154
|
+
const r = spawnSync(bundler.path, ["--config", specPath, "--out", outDir], { stdio: "inherit" });
|
|
155
|
+
if (r.error) throw new CliError(`failed to run dormer-bundle: ${r.error.message}`);
|
|
156
|
+
if (r.status !== 0) { log.error(`dormer-bundle exited with ${r.status ?? r.signal}`); return r.status ?? EXIT.failure; }
|
|
157
|
+
log.ok(`installers written to ${outDir}`);
|
|
158
|
+
return EXIT.ok;
|
|
159
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// dormer.config.json: schema, loading, validation and derived paths.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CliError, EXIT, c } from "./ui.ts";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_FILE = "dormer.config.json";
|
|
7
|
+
export const BUNDLE_TARGETS = ["deb", "rpm", "appimage", "app", "dmg", "nsis", "msi"] as const;
|
|
8
|
+
export type BundleTarget = (typeof BUNDLE_TARGETS)[number];
|
|
9
|
+
|
|
10
|
+
export interface WindowConfig { capabilities?: string[] }
|
|
11
|
+
export interface DormerConfig {
|
|
12
|
+
name: string;
|
|
13
|
+
identifier: string;
|
|
14
|
+
version: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Backend entry, relative to the app root. Default `src/main.ts`. */
|
|
17
|
+
entry?: string;
|
|
18
|
+
/** Vite root of the renderer, relative to the app root. Default `renderer`. */
|
|
19
|
+
renderer?: string;
|
|
20
|
+
/** Per-window capability sets (ADR-0006); `main` is the first window. */
|
|
21
|
+
windows?: Record<string, WindowConfig>;
|
|
22
|
+
node?: {
|
|
23
|
+
/** Node.js major (or full) version the packaged app ships with, e.g. "24". */
|
|
24
|
+
version?: string;
|
|
25
|
+
/** Explicit Node binary for `dormer package`; default: the Node running the CLI. */
|
|
26
|
+
binary?: string;
|
|
27
|
+
/**
|
|
28
|
+
* `"full"` ships the complete ICU locale data next to a small-icu Dormer Node build (about 31 MB);
|
|
29
|
+
* default `"small"` keeps the embedded English-only data. Official full-icu binaries ignore this.
|
|
30
|
+
*/
|
|
31
|
+
icu?: "small" | "full";
|
|
32
|
+
};
|
|
33
|
+
backend?: {
|
|
34
|
+
/** Extra package names to leave out of the backend bundle (native addons are detected automatically). */
|
|
35
|
+
external?: string[];
|
|
36
|
+
};
|
|
37
|
+
dev?: { port?: number; passEnv?: string[] };
|
|
38
|
+
bundle?: {
|
|
39
|
+
targets?: BundleTarget[];
|
|
40
|
+
icons?: string[];
|
|
41
|
+
linux?: { depends?: string[] };
|
|
42
|
+
macos?: { entitlements?: string; signingIdentity?: string; minimumSystemVersion?: string };
|
|
43
|
+
windows?: { signCommand?: string };
|
|
44
|
+
/** Ship source maps found under dist/ (default false). */
|
|
45
|
+
sourceMaps?: boolean;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AppPaths {
|
|
50
|
+
root: string;
|
|
51
|
+
configPath: string;
|
|
52
|
+
entry: string;
|
|
53
|
+
rendererDir: string;
|
|
54
|
+
distDir: string;
|
|
55
|
+
rendererOut: string;
|
|
56
|
+
backendOut: string;
|
|
57
|
+
devDir: string;
|
|
58
|
+
devBackendOut: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Walks up from `start` to find the directory holding dormer.config.json. */
|
|
62
|
+
export function findAppRoot(start: string = process.cwd()): string | undefined {
|
|
63
|
+
let dir = path.resolve(start);
|
|
64
|
+
for (;;) {
|
|
65
|
+
if (fs.existsSync(path.join(dir, CONFIG_FILE))) return dir;
|
|
66
|
+
const parent = path.dirname(dir);
|
|
67
|
+
if (parent === dir) return undefined;
|
|
68
|
+
dir = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z0-9-]+)+$/;
|
|
73
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
|
|
74
|
+
|
|
75
|
+
/** Validates a parsed config object; returns a list of problems (empty when valid). */
|
|
76
|
+
export function validateConfig(cfg: unknown): string[] {
|
|
77
|
+
const problems: string[] = [];
|
|
78
|
+
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) return ["top level must be an object"];
|
|
79
|
+
const o = cfg as Record<string, unknown>;
|
|
80
|
+
const str = (k: string, required: boolean) => {
|
|
81
|
+
if (o[k] === undefined) { if (required) problems.push(`"${k}" is required`); return; }
|
|
82
|
+
if (typeof o[k] !== "string" || (o[k] as string).length === 0) problems.push(`"${k}" must be a non-empty string`);
|
|
83
|
+
};
|
|
84
|
+
str("name", true); str("identifier", true); str("version", true); str("description", false); str("entry", false); str("renderer", false);
|
|
85
|
+
if (typeof o.identifier === "string" && !IDENTIFIER_RE.test(o.identifier)) problems.push(`"identifier" must be reverse-DNS like com.example.myapp (got "${o.identifier}")`);
|
|
86
|
+
if (typeof o.version === "string" && !VERSION_RE.test(o.version)) problems.push(`"version" must be semver like 1.2.3 (got "${o.version}")`);
|
|
87
|
+
if (o.windows !== undefined) {
|
|
88
|
+
if (!o.windows || typeof o.windows !== "object" || Array.isArray(o.windows)) problems.push(`"windows" must be an object keyed by window name`);
|
|
89
|
+
else for (const [name, w] of Object.entries(o.windows as Record<string, unknown>)) {
|
|
90
|
+
const caps = (w as { capabilities?: unknown })?.capabilities;
|
|
91
|
+
if (caps !== undefined && (!Array.isArray(caps) || caps.some((x) => typeof x !== "string"))) problems.push(`"windows.${name}.capabilities" must be an array of strings`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const node = o.node as { version?: unknown; binary?: unknown } | undefined;
|
|
95
|
+
if (node !== undefined) {
|
|
96
|
+
if (!node || typeof node !== "object") problems.push(`"node" must be an object`);
|
|
97
|
+
else {
|
|
98
|
+
if (node.version !== undefined && (typeof node.version !== "string" || !/^\d+(\.\d+){0,2}$/.test(node.version))) problems.push(`"node.version" must be a version like "24" or "24.4.0"`);
|
|
99
|
+
if (node.binary !== undefined && typeof node.binary !== "string") problems.push(`"node.binary" must be a path string`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const bundle = o.bundle as { targets?: unknown } | undefined;
|
|
103
|
+
if (bundle?.targets !== undefined) {
|
|
104
|
+
if (!Array.isArray(bundle.targets)) problems.push(`"bundle.targets" must be an array`);
|
|
105
|
+
else for (const t of bundle.targets) if (!(BUNDLE_TARGETS as readonly string[]).includes(String(t))) problems.push(`"bundle.targets" contains unknown target "${String(t)}" (known: ${BUNDLE_TARGETS.join(", ")})`);
|
|
106
|
+
}
|
|
107
|
+
return problems;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function loadConfig(root: string): DormerConfig {
|
|
111
|
+
const file = path.join(root, CONFIG_FILE);
|
|
112
|
+
if (!fs.existsSync(file)) {
|
|
113
|
+
throw new CliError(`no ${CONFIG_FILE} found in ${root} (or any parent directory)`, {
|
|
114
|
+
exitCode: EXIT.config,
|
|
115
|
+
hints: [`run this command inside a Dormer app, or create one with ${c.bold("dormer create my-app --template vanilla")}`],
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
let parsed: unknown;
|
|
119
|
+
try { parsed = JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
120
|
+
catch (e) { throw new CliError(`${file} is not valid JSON: ${(e as Error).message}`, { exitCode: EXIT.config }); }
|
|
121
|
+
const problems = validateConfig(parsed);
|
|
122
|
+
if (problems.length) throw new CliError(`${file} is invalid:\n - ${problems.join("\n - ")}`, { exitCode: EXIT.config, hints: ["see packages/cli/README.md for the config schema"] });
|
|
123
|
+
return parsed as DormerConfig;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function resolvePaths(root: string, config: DormerConfig): AppPaths {
|
|
127
|
+
const distDir = path.join(root, "dist");
|
|
128
|
+
const devDir = path.join(root, ".dormer", "dev");
|
|
129
|
+
return {
|
|
130
|
+
root,
|
|
131
|
+
configPath: path.join(root, CONFIG_FILE),
|
|
132
|
+
entry: path.resolve(root, config.entry ?? "src/main.ts"),
|
|
133
|
+
rendererDir: path.resolve(root, config.renderer ?? "renderer"),
|
|
134
|
+
distDir,
|
|
135
|
+
rendererOut: path.join(distDir, "renderer"),
|
|
136
|
+
backendOut: path.join(distDir, "main.js"),
|
|
137
|
+
devDir,
|
|
138
|
+
devBackendOut: path.join(devDir, "main.js"),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Loads config from the nearest app root (cwd upwards) and derives paths. */
|
|
143
|
+
export function loadApp(cwd: string = process.cwd()): { root: string; config: DormerConfig; paths: AppPaths } {
|
|
144
|
+
const root = findAppRoot(cwd) ?? cwd;
|
|
145
|
+
const config = loadConfig(root);
|
|
146
|
+
return { root, config, paths: resolvePaths(root, config) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function assertExists(file: string, what: string, hint?: string): void {
|
|
150
|
+
if (!fs.existsSync(file)) throw new CliError(`${what} not found: ${file}`, { exitCode: EXIT.noInput, hints: hint ? [hint] : [] });
|
|
151
|
+
}
|