@dormer/cli 0.0.0-stage → 0.1.0

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.
Files changed (74) hide show
  1. package/README.md +194 -2
  2. package/bin/dormer.mjs +26 -0
  3. package/dist/cli.js +1301 -0
  4. package/dist/index.js +676 -0
  5. package/package.json +59 -4
  6. package/src/args.ts +68 -0
  7. package/src/backend.ts +142 -0
  8. package/src/cli.ts +45 -0
  9. package/src/commands/build.ts +105 -0
  10. package/src/commands/create.ts +132 -0
  11. package/src/commands/dev.ts +128 -0
  12. package/src/commands/doctor.ts +141 -0
  13. package/src/commands/inspect.ts +60 -0
  14. package/src/commands/package.ts +159 -0
  15. package/src/config.ts +151 -0
  16. package/src/host.ts +85 -0
  17. package/src/index.ts +8 -0
  18. package/src/resolve.ts +75 -0
  19. package/src/ui.ts +57 -0
  20. package/templates/react/README.md +34 -0
  21. package/templates/react/_gitignore +5 -0
  22. package/templates/react/dormer.config.json +16 -0
  23. package/templates/react/package.json +29 -0
  24. package/templates/react/renderer/index.html +12 -0
  25. package/templates/react/renderer/src/App.tsx +34 -0
  26. package/templates/react/renderer/src/main.tsx +6 -0
  27. package/templates/react/renderer/src/style.css +8 -0
  28. package/templates/react/renderer/src/vite-env.d.ts +2 -0
  29. package/templates/react/renderer/tsconfig.json +15 -0
  30. package/templates/react/renderer/vite.config.ts +7 -0
  31. package/templates/react/src/main.ts +34 -0
  32. package/templates/react/src/shared.ts +10 -0
  33. package/templates/react/tsconfig.json +15 -0
  34. package/templates/svelte/README.md +34 -0
  35. package/templates/svelte/_gitignore +5 -0
  36. package/templates/svelte/dormer.config.json +16 -0
  37. package/templates/svelte/package.json +27 -0
  38. package/templates/svelte/renderer/index.html +12 -0
  39. package/templates/svelte/renderer/src/App.svelte +32 -0
  40. package/templates/svelte/renderer/src/main.ts +5 -0
  41. package/templates/svelte/renderer/src/style.css +8 -0
  42. package/templates/svelte/renderer/src/vite-env.d.ts +2 -0
  43. package/templates/svelte/renderer/tsconfig.json +15 -0
  44. package/templates/svelte/renderer/vite.config.ts +7 -0
  45. package/templates/svelte/src/main.ts +34 -0
  46. package/templates/svelte/src/shared.ts +10 -0
  47. package/templates/svelte/tsconfig.json +15 -0
  48. package/templates/vanilla/README.md +34 -0
  49. package/templates/vanilla/_gitignore +5 -0
  50. package/templates/vanilla/dormer.config.json +16 -0
  51. package/templates/vanilla/package.json +24 -0
  52. package/templates/vanilla/renderer/index.html +12 -0
  53. package/templates/vanilla/renderer/src/main.ts +31 -0
  54. package/templates/vanilla/renderer/src/style.css +8 -0
  55. package/templates/vanilla/renderer/src/vite-env.d.ts +2 -0
  56. package/templates/vanilla/renderer/tsconfig.json +14 -0
  57. package/templates/vanilla/renderer/vite.config.ts +6 -0
  58. package/templates/vanilla/src/main.ts +34 -0
  59. package/templates/vanilla/src/shared.ts +10 -0
  60. package/templates/vanilla/tsconfig.json +15 -0
  61. package/templates/vue/README.md +34 -0
  62. package/templates/vue/_gitignore +5 -0
  63. package/templates/vue/dormer.config.json +16 -0
  64. package/templates/vue/package.json +27 -0
  65. package/templates/vue/renderer/index.html +12 -0
  66. package/templates/vue/renderer/src/App.vue +36 -0
  67. package/templates/vue/renderer/src/main.ts +5 -0
  68. package/templates/vue/renderer/src/style.css +8 -0
  69. package/templates/vue/renderer/src/vite-env.d.ts +8 -0
  70. package/templates/vue/renderer/tsconfig.json +15 -0
  71. package/templates/vue/renderer/vite.config.ts +7 -0
  72. package/templates/vue/src/main.ts +34 -0
  73. package/templates/vue/src/shared.ts +10 -0
  74. package/templates/vue/tsconfig.json +15 -0
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@dormer/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "dormer CLI: create, dev, build, package and doctor for Dormer desktop apps",
5
+ "type": "module",
6
+ "license": "Apache-2.0 OR MIT",
7
+ "bin": {
8
+ "dormer": "bin/dormer.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./dist/index.js",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "dist",
17
+ "src",
18
+ "templates",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "test": "node --test test/*.test.ts",
23
+ "test:e2e-linux": "test/e2e-linux.sh",
24
+ "prepack": "node ../../scripts/sync-templates.mjs && node build.mjs",
25
+ "postpack": "node ../../scripts/sync-templates.mjs --clean",
26
+ "build": "node build.mjs",
27
+ "prepare": "node build.mjs"
28
+ },
29
+ "dependencies": {
30
+ "esbuild": "^0.28.0"
31
+ },
32
+ "peerDependencies": {
33
+ "vite": ">=5.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "vite": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "engines": {
41
+ "node": ">=22.6"
42
+ },
43
+ "optionalDependencies": {
44
+ "@dormer/host-darwin-arm64": "0.1.0",
45
+ "@dormer/bundle-darwin-arm64": "0.1.0",
46
+ "@dormer/host-darwin-x64": "0.1.0",
47
+ "@dormer/bundle-darwin-x64": "0.1.0",
48
+ "@dormer/host-linux-x64": "0.1.0",
49
+ "@dormer/bundle-linux-x64": "0.1.0",
50
+ "@dormer/host-win32-x64": "0.1.0",
51
+ "@dormer/bundle-win32-x64": "0.1.0"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/veerareddyvishal144/dormer.git",
59
+ "directory": "packages/cli"
60
+ }
61
+ }
package/src/args.ts ADDED
@@ -0,0 +1,68 @@
1
+ // Minimal argument parser driven by a per-command option spec (also used to render --help).
2
+ import { CliError, EXIT, c } from "./ui.ts";
3
+
4
+ export type OptionType = "string" | "boolean" | "list" | "number";
5
+ export interface OptionSpec { type: OptionType; description: string; alias?: string; values?: readonly string[]; default?: string | number | boolean }
6
+ export interface CommandSpec {
7
+ name: string;
8
+ summary: string;
9
+ usage: string;
10
+ description?: string;
11
+ options: Record<string, OptionSpec>;
12
+ examples?: string[];
13
+ }
14
+ export type FlagValue = string | number | boolean | string[];
15
+ export interface ParsedArgs { positionals: string[]; flags: Record<string, FlagValue> }
16
+
17
+ const GLOBAL_OPTIONS: Record<string, OptionSpec> = { help: { type: "boolean", description: "Show help for this command", alias: "h" } };
18
+
19
+ export function parseArgs(argv: string[], spec: CommandSpec): ParsedArgs {
20
+ const options = { ...GLOBAL_OPTIONS, ...spec.options };
21
+ const aliases = new Map<string, string>();
22
+ for (const [name, o] of Object.entries(options)) if (o.alias) aliases.set(o.alias, name);
23
+ const flags: Record<string, FlagValue> = {};
24
+ const positionals: string[] = [];
25
+ const usage = (msg: string) => new CliError(msg, { exitCode: EXIT.usage, hints: [`run ${c.bold(`dormer ${spec.name} --help`)} for usage`] });
26
+
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const a = argv[i];
29
+ if (a === "--") { positionals.push(...argv.slice(i + 1)); break; }
30
+ if (!a.startsWith("-") || a === "-") { positionals.push(a); continue; }
31
+ let name: string; let inline: string | undefined;
32
+ if (a.startsWith("--")) { const eq = a.indexOf("="); name = eq === -1 ? a.slice(2) : a.slice(2, eq); if (eq !== -1) inline = a.slice(eq + 1); }
33
+ else { const short = a.slice(1); const full = aliases.get(short); if (!full) throw usage(`unknown option ${a}`); name = full; }
34
+ let negated = false;
35
+ if (!options[name] && name.startsWith("no-") && options[name.slice(3)]?.type === "boolean") { name = name.slice(3); negated = true; }
36
+ const o = options[name];
37
+ if (!o) throw usage(`unknown option --${name}`);
38
+ if (o.type === "boolean") { if (inline !== undefined) throw usage(`--${name} does not take a value`); flags[name] = !negated; continue; }
39
+ const value = inline ?? argv[++i];
40
+ if (value === undefined) throw usage(`--${name} requires a value`);
41
+ if (o.values && !o.values.includes(value)) throw usage(`--${name} must be one of: ${o.values.join(", ")} (got "${value}")`);
42
+ if (o.type === "number") { const n = Number(value); if (!Number.isFinite(n)) throw usage(`--${name} must be a number`); flags[name] = n; continue; }
43
+ if (o.type === "list") { (flags[name] = (flags[name] as string[] | undefined) ?? []); (flags[name] as string[]).push(value); continue; }
44
+ flags[name] = value;
45
+ }
46
+ for (const [name, o] of Object.entries(spec.options)) if (o.default !== undefined && flags[name] === undefined) flags[name] = o.default;
47
+ return { positionals, flags };
48
+ }
49
+
50
+ export function renderHelp(spec: CommandSpec): string {
51
+ const lines = [`${c.bold(`dormer ${spec.name}`)} — ${spec.summary}`, "", `Usage: dormer ${spec.usage}`];
52
+ if (spec.description) lines.push("", spec.description.trim());
53
+ const options = { ...spec.options, ...GLOBAL_OPTIONS };
54
+ const names = Object.entries(options).map(([n, o]) => [`--${n}${o.alias ? `, -${o.alias}` : ""}${o.type === "boolean" ? "" : o.type === "list" ? " <value>…" : " <value>"}`, o] as const);
55
+ const width = Math.max(...names.map(([n]) => n.length)) + 2;
56
+ lines.push("", "Options:");
57
+ for (const [n, o] of names) {
58
+ const extras = [o.values ? `one of ${o.values.join("|")}` : "", o.default !== undefined ? `default ${String(o.default)}` : ""].filter(Boolean).join(", ");
59
+ lines.push(` ${n.padEnd(width)}${o.description}${extras ? c.dim(` (${extras})`) : ""}`);
60
+ }
61
+ if (spec.examples?.length) { lines.push("", "Examples:"); for (const e of spec.examples) lines.push(` ${e}`); }
62
+ return lines.join("\n");
63
+ }
64
+
65
+ export const flagString = (flags: Record<string, FlagValue>, name: string): string | undefined => (typeof flags[name] === "string" ? (flags[name] as string) : undefined);
66
+ export const flagBool = (flags: Record<string, FlagValue>, name: string): boolean => flags[name] === true;
67
+ export const flagNumber = (flags: Record<string, FlagValue>, name: string): number | undefined => (typeof flags[name] === "number" ? (flags[name] as number) : undefined);
68
+ export const flagList = (flags: Record<string, FlagValue>, name: string): string[] => (Array.isArray(flags[name]) ? (flags[name] as string[]) : []);
package/src/backend.ts ADDED
@@ -0,0 +1,142 @@
1
+ // Bundling the Node backend with esbuild.
2
+ //
3
+ // Output is one ESM file (plus source map) for `platform: node`. Everything the entry imports is
4
+ // bundled, including the `@dormer/*` packages, which are published as TypeScript sources (their
5
+ // `exports` point at `src/index.ts`): bundling them here is what lets the output run on any Node
6
+ // 22+ without type stripping and without the app's node_modules at runtime. Left external:
7
+ // - `node:*` and bare builtins;
8
+ // - `*.node` files and packages that look like native addons (gypfile/binary/napi fields, or a
9
+ // dependency on node-gyp-build, bindings, node-pre-gyp, prebuild-install...): they are
10
+ // reported in `externals` so `dormer package` can ship them from node_modules;
11
+ // - names listed in dormer.config.json `backend.external`.
12
+ // A `createRequire` banner makes `require()` calls inside bundled CommonJS (e.g. msgpackr's
13
+ // optional native accelerator lookup) resolve relative to the output file at runtime.
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { isBuiltin } from "node:module";
17
+ import * as esbuild from "esbuild";
18
+ import { CliError, EXIT, c } from "./ui.ts";
19
+
20
+ export interface BundleOptions {
21
+ root: string;
22
+ entry: string;
23
+ outfile: string;
24
+ minify?: boolean;
25
+ sourcemap?: boolean;
26
+ external?: string[];
27
+ define?: Record<string, string>;
28
+ }
29
+ export interface BundleResult { outfile: string; bytes: number; externals: string[]; warnings: esbuild.Message[] }
30
+
31
+ const NATIVE_DEP_RE = /^(node-gyp-build|node-gyp-build-optional-packages|bindings|prebuild-install|prebuildify|@mapbox\/node-pre-gyp|node-pre-gyp|node-addon-api|cmake-js|nan|napi-build-utils|pkg-prebuilds)$/;
32
+
33
+ export function packageName(specifier: string): string | undefined {
34
+ if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("node:")) return undefined;
35
+ const parts = specifier.split("/");
36
+ return specifier.startsWith("@") ? (parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined) : parts[0];
37
+ }
38
+
39
+ function findPackageJson(fromDir: string, name: string): Record<string, unknown> | undefined {
40
+ let dir = fromDir;
41
+ for (;;) {
42
+ const f = path.join(dir, "node_modules", name, "package.json");
43
+ if (fs.existsSync(f)) { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return undefined; } }
44
+ const parent = path.dirname(dir);
45
+ if (parent === dir) return undefined;
46
+ dir = parent;
47
+ }
48
+ }
49
+
50
+ export function looksNative(pkg: Record<string, unknown>): boolean {
51
+ if (pkg.gypfile || pkg.binary || pkg.napi) return true;
52
+ for (const k of ["dependencies", "optionalDependencies", "peerDependencies"]) {
53
+ const deps = pkg[k];
54
+ if (deps && typeof deps === "object" && Object.keys(deps as object).some((d) => NATIVE_DEP_RE.test(d))) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function externalsPlugin(externals: Set<string>, explicit: string[]): esbuild.Plugin {
60
+ return {
61
+ name: "dormer-externals",
62
+ setup(build) {
63
+ build.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }));
64
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
65
+ if (args.kind === "entry-point") return undefined;
66
+ const name = packageName(args.path);
67
+ if (!name) return undefined;
68
+ if (isBuiltin(name)) return { path: args.path, external: true };
69
+ if (explicit.includes(name)) { externals.add(name); return { path: args.path, external: true }; }
70
+ if (name.startsWith("@dormer/")) return undefined; // always bundled (TypeScript sources)
71
+ const pkg = findPackageJson(args.resolveDir, name);
72
+ if (pkg && looksNative(pkg)) { externals.add(name); return { path: args.path, external: true }; }
73
+ return undefined;
74
+ });
75
+ },
76
+ };
77
+ }
78
+
79
+ const BANNER = `import { createRequire as __dormerCreateRequire } from "node:module";\nconst require = __dormerCreateRequire(import.meta.url);\n`;
80
+
81
+ export function buildOptions(o: BundleOptions, externals: Set<string>): esbuild.BuildOptions {
82
+ return {
83
+ entryPoints: [o.entry],
84
+ outfile: o.outfile,
85
+ bundle: true,
86
+ platform: "node",
87
+ format: "esm",
88
+ target: "node22",
89
+ conditions: ["node", "import", "module", "default"],
90
+ mainFields: ["module", "main"],
91
+ sourcemap: o.sourcemap === false ? false : true,
92
+ minify: o.minify === true,
93
+ legalComments: "none",
94
+ logLevel: "silent",
95
+ absWorkingDir: o.root,
96
+ banner: { js: BANNER },
97
+ define: { "process.env.DORMER_BUNDLED": '"1"', ...o.define },
98
+ plugins: [externalsPlugin(externals, o.external ?? [])],
99
+ metafile: true,
100
+ };
101
+ }
102
+
103
+ export function formatMessages(messages: esbuild.Message[], kind: "error" | "warning"): string {
104
+ return esbuild.formatMessagesSync(messages, { kind, color: Boolean(process.stdout.isTTY) }).join("");
105
+ }
106
+
107
+ export async function bundleBackend(o: BundleOptions): Promise<BundleResult> {
108
+ if (!fs.existsSync(o.entry)) throw new CliError(`backend entry not found: ${o.entry}`, { exitCode: EXIT.noInput, hints: [`set "entry" in dormer.config.json (default src/main.ts)`] });
109
+ const externals = new Set<string>();
110
+ fs.mkdirSync(path.dirname(o.outfile), { recursive: true });
111
+ let result: esbuild.BuildResult;
112
+ try { result = await esbuild.build(buildOptions(o, externals)); }
113
+ catch (e) {
114
+ const err = e as { errors?: esbuild.Message[] };
115
+ if (err.errors?.length) throw new CliError(`backend bundle failed:\n${formatMessages(err.errors, "error")}`, { hints: [`entry: ${o.entry}`] });
116
+ throw e;
117
+ }
118
+ const bytes = fs.statSync(o.outfile).size;
119
+ return { outfile: o.outfile, bytes, externals: [...externals].sort(), warnings: result.warnings };
120
+ }
121
+
122
+ export interface WatchHandle { dispose(): Promise<void> }
123
+ export type WatchCallback = (r: { ok: boolean; errors: esbuild.Message[]; warnings: esbuild.Message[]; externals: string[] }) => void;
124
+
125
+ /** Rebuilds `outfile` whenever the backend sources change; `onBuild` fires after every attempt (first included). */
126
+ export async function watchBackend(o: BundleOptions, onBuild: WatchCallback): Promise<WatchHandle> {
127
+ if (!fs.existsSync(o.entry)) throw new CliError(`backend entry not found: ${o.entry}`, { exitCode: EXIT.noInput, hints: [`set "entry" in dormer.config.json (default src/main.ts)`] });
128
+ const externals = new Set<string>();
129
+ fs.mkdirSync(path.dirname(o.outfile), { recursive: true });
130
+ const options = buildOptions(o, externals);
131
+ options.plugins!.push({
132
+ name: "dormer-watch-notify",
133
+ setup(build) { build.onEnd((r) => onBuild({ ok: r.errors.length === 0, errors: r.errors, warnings: r.warnings, externals: [...externals].sort() })); },
134
+ });
135
+ const ctx = await esbuild.context(options);
136
+ await ctx.watch();
137
+ return { dispose: () => ctx.dispose() };
138
+ }
139
+
140
+ export function summarizeExternals(externals: string[]): string {
141
+ return externals.length ? `${externals.length} native/external package(s) kept out of the bundle: ${c.bold(externals.join(", "))}` : "no external packages (everything bundled)";
142
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,45 @@
1
+ // `dormer` command dispatcher. Each command module exports `spec` (options, help) and `run`.
2
+ import path from "node:path";
3
+ import { type CommandSpec, type ParsedArgs, parseArgs, renderHelp } from "./args.ts";
4
+ import { CLI_DIR } from "./host.ts";
5
+ import { readJson } from "./resolve.ts";
6
+ import { CliError, EXIT, c, reportError } from "./ui.ts";
7
+ import * as create from "./commands/create.ts";
8
+ import * as dev from "./commands/dev.ts";
9
+ import * as build from "./commands/build.ts";
10
+ import * as pkg from "./commands/package.ts";
11
+ import * as doctor from "./commands/doctor.ts";
12
+ import * as inspect from "./commands/inspect.ts";
13
+
14
+ interface Command { spec: CommandSpec; run(args: ParsedArgs): Promise<number> }
15
+ const COMMANDS: Record<string, Command> = { create, dev, build, package: pkg, doctor, inspect };
16
+
17
+ function version(): string { try { return readJson<{ version: string }>(path.join(CLI_DIR, "package.json")).version; } catch { return "0.0.0"; } }
18
+
19
+ function globalHelp(): string {
20
+ const width = Math.max(...Object.keys(COMMANDS).map((n) => n.length)) + 2;
21
+ return [
22
+ `${c.bold("dormer")} ${version()} — build desktop apps with Node.js and the system WebView`,
23
+ "",
24
+ "Usage: dormer <command> [options]",
25
+ "",
26
+ "Commands:",
27
+ ...Object.values(COMMANDS).map((cmd) => ` ${cmd.spec.name.padEnd(width)}${cmd.spec.summary}`),
28
+ "",
29
+ "Run `dormer <command> --help` for the options of a command.",
30
+ "Environment: DORMER_HOST_BIN (host binary), DORMER_BUNDLE_BIN (packaging tool), DORMER_CLI_DEBUG=1 (stack traces).",
31
+ ].join("\n");
32
+ }
33
+
34
+ export async function main(argv: string[]): Promise<number> {
35
+ const [name, ...rest] = argv;
36
+ if (!name || name === "--help" || name === "-h" || name === "help") { console.log(globalHelp()); return EXIT.ok; }
37
+ if (name === "--version" || name === "-v") { console.log(version()); return EXIT.ok; }
38
+ const cmd = COMMANDS[name];
39
+ if (!cmd) throw new CliError(`unknown command "${name}"`, { exitCode: EXIT.usage, hints: [`commands: ${Object.keys(COMMANDS).join(", ")}`, "run `dormer --help`"] });
40
+ const args = parseArgs(rest, cmd.spec);
41
+ if (args.flags.help) { console.log(renderHelp(cmd.spec)); return EXIT.ok; }
42
+ return cmd.run(args);
43
+ }
44
+
45
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => process.exit(reportError(err)));
@@ -0,0 +1,105 @@
1
+ // dormer build: vite build (renderer) + esbuild (backend) + manifest.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { type CommandSpec, type ParsedArgs, flagBool } from "../args.ts";
5
+ import { CONFIG_FILE, type AppPaths, type DormerConfig, loadApp } from "../config.ts";
6
+ import { bundleBackend, formatMessages, summarizeExternals, type BundleResult } from "../backend.ts";
7
+ import { findViteConfig, loadVite, readJson } from "../resolve.ts";
8
+ import { CLI_DIR } from "../host.ts";
9
+ import { CliError, EXIT, c, formatBytes, log } from "../ui.ts";
10
+
11
+ export const spec: CommandSpec = {
12
+ name: "build",
13
+ summary: "Build the renderer and the backend into dist/",
14
+ usage: "build [--minify] [--no-sourcemap] [--quiet]",
15
+ description: `Runs \`vite build\` for the renderer (→ dist/renderer), bundles the backend with esbuild
16
+ (→ dist/main.js + .map), copies ${CONFIG_FILE} and writes dist/dormer-app.json. dist/ is owned by
17
+ this command and is recreated on every run.`,
18
+ options: {
19
+ minify: { type: "boolean", description: "Minify the backend bundle" },
20
+ sourcemap: { type: "boolean", description: "Emit dist/main.js.map (use --no-sourcemap to disable)", default: true },
21
+ quiet: { type: "boolean", description: "Only print warnings and errors from Vite" },
22
+ },
23
+ };
24
+
25
+ export interface AppManifest {
26
+ manifestVersion: 1;
27
+ name: string;
28
+ identifier: string;
29
+ version: string;
30
+ description?: string;
31
+ /** Backend entry, relative to the manifest's directory. */
32
+ entry: string;
33
+ /** Renderer assets directory, relative to the manifest's directory (served as dormer://app/). */
34
+ assets: string;
35
+ node: { version?: string };
36
+ /** Packages left outside the bundle (native addons); must be shipped from node_modules. */
37
+ externals: string[];
38
+ windows?: DormerConfig["windows"];
39
+ builtAt: string;
40
+ cli: string;
41
+ }
42
+
43
+ export function cliVersion(): string {
44
+ try { return readJson<{ version: string }>(path.join(CLI_DIR, "package.json")).version; } catch { return "0.0.0"; }
45
+ }
46
+
47
+ export function makeManifest(config: DormerConfig, externals: string[]): AppManifest {
48
+ return {
49
+ manifestVersion: 1,
50
+ name: config.name,
51
+ identifier: config.identifier,
52
+ version: config.version,
53
+ description: config.description,
54
+ entry: "main.js",
55
+ assets: "renderer",
56
+ node: { version: config.node?.version },
57
+ externals,
58
+ windows: config.windows,
59
+ builtAt: new Date().toISOString(),
60
+ cli: cliVersion(),
61
+ };
62
+ }
63
+
64
+ export interface BuildOutput { manifest: AppManifest; backend: BundleResult; manifestPath: string }
65
+
66
+ export async function buildApp(app: { root: string; config: DormerConfig; paths: AppPaths }, opts: { minify?: boolean; sourcemap?: boolean; quiet?: boolean } = {}): Promise<BuildOutput> {
67
+ const { config, paths } = app;
68
+ if (!fs.existsSync(paths.rendererDir)) throw new CliError(`renderer directory not found: ${paths.rendererDir}`, { exitCode: EXIT.noInput, hints: [`set "renderer" in ${CONFIG_FILE} (default "renderer")`] });
69
+ if (!fs.existsSync(paths.entry)) throw new CliError(`backend entry not found: ${paths.entry}`, { exitCode: EXIT.noInput, hints: [`set "entry" in ${CONFIG_FILE} (default "src/main.ts")`] });
70
+
71
+ fs.rmSync(paths.distDir, { recursive: true, force: true });
72
+ fs.mkdirSync(paths.distDir, { recursive: true });
73
+
74
+ const { vite, version } = await loadVite(app.root);
75
+ log.step(`building renderer with vite ${version} → ${path.relative(app.root, paths.rendererOut)}`);
76
+ const configFile = findViteConfig(paths.rendererDir);
77
+ if (!configFile) log.warn(`no vite.config.* in ${paths.rendererDir}; add one using @dormer/vite-plugin`);
78
+ await vite.build({
79
+ root: paths.rendererDir,
80
+ configFile: configFile ?? false,
81
+ logLevel: opts.quiet ? "warn" : "info",
82
+ build: { outDir: paths.rendererOut, emptyOutDir: true },
83
+ });
84
+ if (!fs.existsSync(path.join(paths.rendererOut, "index.html"))) {
85
+ throw new CliError(`vite build produced no ${path.join(paths.rendererOut, "index.html")}`, { hints: ["the renderer must have an index.html at its root; check build.outDir is not overridden"] });
86
+ }
87
+
88
+ log.step(`bundling backend ${path.relative(app.root, paths.entry)} → ${path.relative(app.root, paths.backendOut)}`);
89
+ const backend = await bundleBackend({ root: app.root, entry: paths.entry, outfile: paths.backendOut, minify: opts.minify, sourcemap: opts.sourcemap !== false, external: config.backend?.external });
90
+ if (backend.warnings.length) console.error(formatMessages(backend.warnings, "warning"));
91
+ log.info(`backend ${formatBytes(backend.bytes)}; ${summarizeExternals(backend.externals)}`);
92
+
93
+ fs.copyFileSync(paths.configPath, path.join(paths.distDir, CONFIG_FILE));
94
+ const manifest = makeManifest(config, backend.externals);
95
+ const manifestPath = path.join(paths.distDir, "dormer-app.json");
96
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
97
+ log.ok(`built ${c.bold(`${config.name} ${config.version}`)} → ${paths.distDir}`);
98
+ return { manifest, backend, manifestPath };
99
+ }
100
+
101
+ export async function run(args: ParsedArgs): Promise<number> {
102
+ const app = loadApp();
103
+ await buildApp(app, { minify: flagBool(args.flags, "minify"), sourcemap: args.flags.sourcemap !== false, quiet: flagBool(args.flags, "quiet") });
104
+ return EXIT.ok;
105
+ }
@@ -0,0 +1,132 @@
1
+ // dormer create <dir> --template <name>
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { type CommandSpec, type ParsedArgs, flagBool, flagString } from "../args.ts";
5
+ import { CLI_DIR } from "../host.ts";
6
+ import { CliError, EXIT, c, log } from "../ui.ts";
7
+
8
+ export const TEMPLATES = ["vanilla", "react", "vue", "svelte"] as const;
9
+ const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
10
+
11
+ export const spec: CommandSpec = {
12
+ name: "create",
13
+ summary: "Scaffold a new Dormer app from a template",
14
+ usage: "create <dir> --template <name> [--pm npm] [--link] [--force]",
15
+ description: `Copies templates/<name> into <dir>, sets the package name and bundle identifier
16
+ (com.example.<name>) and prints the next steps. Templates depend on the published @dormer/*
17
+ packages; inside the Dormer monorepo pass --link to reference the local packages instead.`,
18
+ options: {
19
+ template: { type: "string", alias: "t", description: "Template to copy", values: TEMPLATES },
20
+ pm: { type: "string", description: "Package manager used in the printed next steps", values: PACKAGE_MANAGERS, default: "npm" },
21
+ link: { type: "boolean", description: "Write file: paths to this monorepo's @dormer/* packages (no publishing needed)" },
22
+ force: { type: "boolean", description: "Allow writing into a non-empty directory" },
23
+ },
24
+ examples: ["dormer create my-app --template react", "dormer create ../scratch/app --template svelte --link"],
25
+ };
26
+
27
+ /** Packages a template may reference, with their locations inside the monorepo (used by --link). */
28
+ export const MONOREPO_PACKAGES: Record<string, string> = {
29
+ "@dormer/api": "packages/api",
30
+ "@dormer/renderer": "packages/renderer",
31
+ "@dormer/vite-plugin": "packages/vite-plugin",
32
+ "@dormer/cli": "packages/cli",
33
+ // transitive dependencies of @dormer/api, linked too so npm can satisfy them without a registry
34
+ "@dormer/node-runtime": "runtime/node-runtime",
35
+ "@dormer/ipc": "runtime/ipc/ts",
36
+ };
37
+
38
+ export function templatesDir(): string {
39
+ for (const d of [path.join(CLI_DIR, "templates"), path.resolve(CLI_DIR, "..", "..", "templates")]) {
40
+ if (fs.existsSync(path.join(d, "vanilla", "package.json"))) return d;
41
+ }
42
+ throw new CliError("templates directory not found", { exitCode: EXIT.noInput, hints: [`expected ${path.join(CLI_DIR, "templates")} or ${path.resolve(CLI_DIR, "..", "..", "templates")}`] });
43
+ }
44
+
45
+ export function monorepoRoot(): string | undefined {
46
+ const root = path.resolve(CLI_DIR, "..", "..");
47
+ return fs.existsSync(path.join(root, "packages", "api", "package.json")) && fs.existsSync(path.join(root, "runtime", "ipc", "ts", "package.json")) ? root : undefined;
48
+ }
49
+
50
+ export function packageNameFor(dir: string): string {
51
+ const base = path.basename(path.resolve(dir)).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "");
52
+ return base || "dormer-app";
53
+ }
54
+ export function identifierFor(name: string): string {
55
+ const seg = name.replace(/[^a-z0-9-]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase() || "app";
56
+ return `com.example.${/^[a-z]/.test(seg) ? seg : `app-${seg}`}`;
57
+ }
58
+
59
+ const TEXT_EXT = new Set([".json", ".md", ".html", ".ts", ".tsx", ".vue", ".svelte", ".js", ".mjs", ".cjs", ".css", ".txt", ".yml", ".yaml"]);
60
+
61
+ /** Recursively copies a template; `_gitignore` becomes `.gitignore` (npm would drop a real one). */
62
+ export function copyTemplate(src: string, dest: string, vars: Record<string, string>): string[] {
63
+ const written: string[] = [];
64
+ const walk = (from: string, to: string) => {
65
+ fs.mkdirSync(to, { recursive: true });
66
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
67
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".dormer") continue;
68
+ const target = path.join(to, entry.name === "_gitignore" ? ".gitignore" : entry.name);
69
+ const source = path.join(from, entry.name);
70
+ if (entry.isDirectory()) { walk(source, target); continue; }
71
+ if (TEXT_EXT.has(path.extname(entry.name)) || entry.name === "_gitignore") {
72
+ let text = fs.readFileSync(source, "utf8");
73
+ for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`{{${k}}}`, v);
74
+ fs.writeFileSync(target, text);
75
+ } else fs.copyFileSync(source, target);
76
+ written.push(target);
77
+ }
78
+ };
79
+ walk(src, dest);
80
+ return written;
81
+ }
82
+
83
+ /** Rewrites @dormer/* dependency ranges to file: links into the monorepo. */
84
+ export function linkMonorepoPackages(pkgJsonPath: string, repoRoot: string): string[] {
85
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as Record<string, Record<string, string> | undefined>;
86
+ const linked: string[] = [];
87
+ for (const field of ["dependencies", "devDependencies"]) {
88
+ const deps = pkg[field]; if (!deps) continue;
89
+ for (const name of Object.keys(deps)) {
90
+ const rel = MONOREPO_PACKAGES[name];
91
+ if (rel) { deps[name] = `file:${path.join(repoRoot, rel)}`; linked.push(name); }
92
+ }
93
+ }
94
+ // Transitive @dormer/* dependencies must be visible to npm's resolver as well.
95
+ pkg.devDependencies ??= {};
96
+ for (const [name, rel] of Object.entries(MONOREPO_PACKAGES)) {
97
+ if (!pkg.dependencies?.[name] && !pkg.devDependencies[name]) { pkg.devDependencies[name] = `file:${path.join(repoRoot, rel)}`; linked.push(name); }
98
+ }
99
+ fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n");
100
+ return linked;
101
+ }
102
+
103
+ export async function run(args: ParsedArgs): Promise<number> {
104
+ const target = args.positionals[0];
105
+ const template = flagString(args.flags, "template");
106
+ if (!target) throw new CliError("missing <dir>", { exitCode: EXIT.usage, hints: [`example: ${c.bold("dormer create my-app --template vanilla")}`] });
107
+ if (!template) throw new CliError("missing --template", { exitCode: EXIT.usage, hints: [`choose one of: ${TEMPLATES.join(", ")}`] });
108
+ const dest = path.resolve(target);
109
+ const src = path.join(templatesDir(), template);
110
+ if (!fs.existsSync(path.join(src, "package.json"))) throw new CliError(`template "${template}" not found at ${src}`, { exitCode: EXIT.noInput });
111
+ if (fs.existsSync(dest) && fs.readdirSync(dest).length > 0 && !flagBool(args.flags, "force")) {
112
+ throw new CliError(`${dest} exists and is not empty`, { exitCode: EXIT.usage, hints: ["choose another directory or pass --force"] });
113
+ }
114
+ const name = packageNameFor(dest);
115
+ const vars = { name, identifier: identifierFor(name), template };
116
+ log.step(`creating ${c.bold(name)} from the ${template} template in ${dest}`);
117
+ const files = copyTemplate(src, dest, vars);
118
+ log.info(`${files.length} files written`);
119
+
120
+ if (flagBool(args.flags, "link")) {
121
+ const repo = monorepoRoot();
122
+ if (!repo) throw new CliError("--link requires running the CLI from a checkout of the Dormer monorepo", { exitCode: EXIT.usage, hints: ["without --link the templates reference the published @dormer/* packages"] });
123
+ const linked = linkMonorepoPackages(path.join(dest, "package.json"), repo);
124
+ log.info(`linked ${linked.length} @dormer packages to ${repo} via file: paths`);
125
+ }
126
+
127
+ const pm = flagString(args.flags, "pm") ?? "npm";
128
+ const runCmd = (s: string) => (pm === "npm" ? `npm run ${s}` : `${pm} ${s}`);
129
+ const rel = path.relative(process.cwd(), dest) || ".";
130
+ console.log(`\n${c.green("Done.")} Next steps:\n\n cd ${rel}\n ${pm} install\n ${runCmd("dev")} ${c.dim("# Vite dev server + native window with live backend rebuilds")}\n ${runCmd("build")} ${c.dim("# dist/renderer + dist/main.js + dist/dormer-app.json")}\n ${runCmd("package")} ${c.dim("# installers via dormer-bundle")}\n\nRun ${c.bold("npx dormer doctor")} if the window does not open.\n`);
131
+ return EXIT.ok;
132
+ }