@downdraft/cli 0.0.0 → 0.1.1

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 (84) hide show
  1. package/README.md +11 -0
  2. package/package.json +36 -3
  3. package/scripts/default-icon.png +0 -0
  4. package/scripts/package-desktop.mjs +931 -0
  5. package/scripts/package-mobile.mjs +822 -0
  6. package/scripts/package-native.mjs +638 -0
  7. package/src/args.ts +333 -0
  8. package/src/assets.ts +523 -0
  9. package/src/blob-store-s3.ts +145 -0
  10. package/src/build-games.ts +45 -0
  11. package/src/build.ts +40 -0
  12. package/src/bun-env.d.ts +6 -0
  13. package/src/debug.ts +56 -0
  14. package/src/dev.ts +231 -0
  15. package/src/dist.ts +36 -0
  16. package/src/export.ts +38 -0
  17. package/src/index.ts +131 -0
  18. package/src/list-games.ts +37 -0
  19. package/src/mcp.ts +320 -0
  20. package/src/new.ts +63 -0
  21. package/src/paths.ts +101 -0
  22. package/src/plugin-command.ts +214 -0
  23. package/src/process-utils.ts +179 -0
  24. package/src/release.ts +432 -0
  25. package/src/scaffold-plugin.ts +280 -0
  26. package/src/scaffold.ts +207 -0
  27. package/src/test.ts +187 -0
  28. package/src/usage.ts +256 -0
  29. package/templates/_shared/ai-companion/devin-config.eta +11 -0
  30. package/templates/_shared/ai-companion/engine-prompt.eta +155 -0
  31. package/templates/_shared/ai-companion/logging-rule.eta +3 -0
  32. package/templates/_shared/ai-companion/loops-rule.eta +3 -0
  33. package/templates/_shared/partials/config-json-fields.eta +3 -0
  34. package/templates/_shared/partials/dev-deps.eta +8 -0
  35. package/templates/_shared/partials/package-json-fields.eta +12 -0
  36. package/templates/_shared/partials/tsconfig.eta +16 -0
  37. package/templates/_shared/partials/vscode-extensions.eta +11 -0
  38. package/templates/_shared/partials/vscode-settings.eta +29 -0
  39. package/templates/_shared/partials/vscode-tasks.eta +93 -0
  40. package/templates/full/.gitignore.eta +5 -0
  41. package/templates/full/.vscode/extensions.json.eta +1 -0
  42. package/templates/full/.vscode/settings.json.eta +1 -0
  43. package/templates/full/.vscode/tasks.json.eta +1 -0
  44. package/templates/full/README.md.eta +45 -0
  45. package/templates/full/assets/shaders/.eta +0 -0
  46. package/templates/full/downdraft.config.json.eta +10 -0
  47. package/templates/full/package.json.eta +9 -0
  48. package/templates/full/src/entities/.eta +0 -0
  49. package/templates/full/src/game-module.ts.eta +30 -0
  50. package/templates/full/src/native-entry.ts.eta +14 -0
  51. package/templates/full/src/sim-host.ts.eta +19 -0
  52. package/templates/full/src/sim-worker.ts.eta +99 -0
  53. package/templates/full/src/systems/.eta +0 -0
  54. package/templates/full/tsconfig.json.eta +1 -0
  55. package/templates/gamemodule/README.md.eta +32 -0
  56. package/templates/gamemodule/downdraft.config.json.eta +3 -0
  57. package/templates/gamemodule/package.json.eta +9 -0
  58. package/templates/gamemodule/src/game-module.ts.eta +63 -0
  59. package/templates/gamemodule/src/native-entry.ts.eta +14 -0
  60. package/templates/gamemodule/src/sim-host.ts.eta +25 -0
  61. package/templates/gamemodule/src/sim-worker.ts.eta +24 -0
  62. package/templates/gamemodule/tsconfig.json.eta +1 -0
  63. package/templates/minimal/.gitignore.eta +5 -0
  64. package/templates/minimal/.vscode/extensions.json.eta +1 -0
  65. package/templates/minimal/.vscode/settings.json.eta +1 -0
  66. package/templates/minimal/.vscode/tasks.json.eta +1 -0
  67. package/templates/minimal/README.md.eta +32 -0
  68. package/templates/minimal/downdraft.config.json.eta +3 -0
  69. package/templates/minimal/package.json.eta +9 -0
  70. package/templates/minimal/src/game-module.ts.eta +44 -0
  71. package/templates/minimal/src/native-entry.ts.eta +14 -0
  72. package/templates/minimal/tsconfig.json.eta +1 -0
  73. package/templates/physics/.gitignore.eta +5 -0
  74. package/templates/physics/.vscode/extensions.json.eta +1 -0
  75. package/templates/physics/.vscode/settings.json.eta +1 -0
  76. package/templates/physics/.vscode/tasks.json.eta +1 -0
  77. package/templates/physics/README.md.eta +36 -0
  78. package/templates/physics/downdraft.config.json.eta +6 -0
  79. package/templates/physics/package.json.eta +9 -0
  80. package/templates/physics/src/game-module.ts.eta +29 -0
  81. package/templates/physics/src/native-entry.ts.eta +14 -0
  82. package/templates/physics/src/sim-host.ts.eta +19 -0
  83. package/templates/physics/src/sim-worker.ts.eta +155 -0
  84. package/templates/physics/tsconfig.json.eta +1 -0
@@ -0,0 +1,638 @@
1
+ // ============================================================================
2
+ // package-native.mjs — compile a game's native entry into a standalone Bun
3
+ // binary plus a staged runtime tree:
4
+ //
5
+ // bun packages/cli/scripts/package-native.mjs games/mining-rpg/src/native-entry.ts /tmp/out/mining
6
+ // bun scripts/package-native.mjs --mcp <entry> <outfile> # retain the MCP endpoint
7
+ // --target=win|linux|mac cross-compile (default: host)
8
+ // --mode=dev|debug|prod prod minifies (default: prod)
9
+ // --product-name=<name> PE/branding product name (win target)
10
+ // --product-version=<v> PE/branding version (win target)
11
+ // --icon=<path> .ico/.png icon to stamp (win target)
12
+ //
13
+ // By default the in-process MCP automation endpoint is compiled OUT of
14
+ // packaged binaries (__DD_MCP_STRIP__ define → dead code). --mcp retains it:
15
+ // the endpoint still stays off at runtime unless DOWNDRAFT_MCP=1 is set
16
+ // (see native-host.ts).
17
+ //
18
+ // Produces:
19
+ // <outfile> compiled binary
20
+ // <outdir>/native/*.so platform lib + engine cdylibs (dlopen'd)
21
+ // <outdir>/dd-assets/<repo-rel> ?url imports + glob-matched asset files
22
+ //
23
+ // The runtime Bun.plugin() loaders in bun-preload.ts handle ?raw/.wgsl/?url
24
+ // for `bun run`, but Bun.build resolves modules on disk first — query
25
+ // suffixes fail resolution before onLoad fires. This plugin mirrors those
26
+ // loaders with the build-time contract (onResolve strips the suffix, onLoad
27
+ // returns contents + a real loader).
28
+ //
29
+ // Packaging transforms (compile pass only):
30
+ // - `new Worker(new URL("x.ts", import.meta.url))` — workers are embedded
31
+ // as extra entrypoints; the specifier is rewritten to the module's
32
+ // /$bunfs/root/<repo-rel>.js path so it resolves inside the binary.
33
+ // - `new URL("asset.png", import.meta.url)` (non-script) — rewritten to a
34
+ // file:// URL into the staged dd-assets tree.
35
+ // - `import.meta.glob(...)` — rewritten to createGlob(<repo-rel-dir>),
36
+ // which maps onto dd-assets in packaged builds.
37
+ // - `import.meta.dir` / `.dirname` / `.url` — rewritten to the module's
38
+ // /$bunfs/root/<repo-rel> path so glob/worker resolution behaves like
39
+ // dev. (In compiled binaries every module reports import.meta.dir as
40
+ // /$bunfs/root — the source directory must be baked in per module.)
41
+ // ============================================================================
42
+
43
+ import { createHash } from "node:crypto";
44
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
45
+ import { createRequire } from "node:module";
46
+ import { tmpdir } from "node:os";
47
+ import { basename, dirname, join, relative, resolve } from "node:path";
48
+ import { fileURLToPath } from "node:url";
49
+
50
+ // CRATES is shared with the repo's scripts/native-crates.mjs when this file
51
+ // runs inside the monorepo. In a published @downdraft/cli install the repo
52
+ // scripts tree doesn't exist — fall back to the platform lib only (engine
53
+ // cdylibs are additionally probed inside the installed @downdraft/engine
54
+ // package below, so standalone packaging still finds them).
55
+ let CRATES = [];
56
+ let libFileName = (libName, nodePlat) =>
57
+ nodePlat.startsWith("win32") ? `${libName}.dll`
58
+ : nodePlat.startsWith("darwin") ? `lib${libName}.dylib` : `lib${libName}.so`;
59
+ try {
60
+ ({ CRATES, libFileName } = await import(
61
+ fileURLToPath(new URL("../../../scripts/native-crates.mjs", import.meta.url))));
62
+ } catch { /* published package — engine crates probed via node resolution */ }
63
+
64
+ const argv = process.argv.slice(2);
65
+ // --mcp retains the MCP automation endpoint in the packaged binary (still
66
+ // gated at runtime by DOWNDRAFT_MCP=1). Default: strip it.
67
+ const retainMcp = argv.includes("--mcp");
68
+ // --target=<win|linux|mac> selects the Bun compile target and the platform
69
+ // arch used for native-lib staging. Default: the host platform.
70
+ const targetArg = argv.find((a) => a.startsWith("--target="))?.split("=")[1];
71
+ // --mode=<dev|debug|prod> — prod minifies; dev/debug leave the bundle readable.
72
+ const modeArg = argv.find((a) => a.startsWith("--mode="))?.split("=")[1] ?? "prod";
73
+ // Branding flags — value may itself contain '=' so split on the first only.
74
+ const flagValue = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 2);
75
+ const productNameFlag = flagValue("product-name");
76
+ const productVersionFlag = flagValue("product-version");
77
+ const iconFlag = flagValue("icon");
78
+ const positional = argv.filter((a) => !a.startsWith("--"));
79
+ const [entry, outfileArg] = positional;
80
+ if (!entry || !outfileArg) {
81
+ console.error("usage: bun package-native.mjs [--mcp] [--target=win|linux|mac] [--mode=dev|debug|prod] <entry.ts> <outfile>");
82
+ process.exit(1);
83
+ }
84
+
85
+ // Target triple: release vocabulary (win|linux|mac) → bun compile target +
86
+ // node <platform>-<arch> key used for native lib staging. mac targets Apple
87
+ // Silicon by default (the dominant desktop arch); win/linux target x64.
88
+ const TARGETS = {
89
+ win: { plat: "win32-x64", bun: "bun-windows-x64", exe: true },
90
+ linux: { plat: "linux-x64", bun: "bun-linux-x64", exe: false },
91
+ mac: { plat: "darwin-arm64", bun: "bun-darwin-arm64", exe: false },
92
+ };
93
+ const hostKey = `${process.platform}-${process.arch}`;
94
+ if (targetArg && !TARGETS[targetArg]) {
95
+ console.error(`unknown --target "${targetArg}" — expected win, linux, or mac`);
96
+ process.exit(1);
97
+ }
98
+ const target = targetArg ? TARGETS[targetArg] : { plat: hostKey, bun: undefined, exe: process.platform === "win32" };
99
+ let outfile = outfileArg;
100
+ if (target.exe && !outfile.endsWith(".exe")) outfile += ".exe";
101
+
102
+ const repoRoot = process.cwd();
103
+ const QUERY_RE = /\?(raw|url|json)$/;
104
+ const WORKER_RE = /new\s+Worker\(\s*new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
105
+ const ASSET_URL_RE = /new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
106
+ // import.meta is frequently written as `(import.meta as any)` in this
107
+ // codebase — both the bare and parenthesized forms must match.
108
+ const IMPORT_META = /(?:\(\s*import\.meta\s+as\s+any\s*\)|import\.meta(?:\s+as\s+any)?)/;
109
+ const GLOB_RE = new RegExp(IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1`, "g");
110
+ // Staging scan covers all glob call forms: import.meta.glob("pat"), the
111
+ // common _glob("pat") alias, and createGlob(modDir)("pat").
112
+ const STAGE_GLOB_RE = new RegExp(
113
+ `(?:${IMPORT_META.source}\\.glob|_glob|globAssets)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
114
+ // Module-relative file reads: resolve(__dirname, "./zstd.wasm"),
115
+ // join(import.meta.dir, "data.bin") — stage the referenced file.
116
+ const STAGE_READ_RE = new RegExp(
117
+ String.raw`(?:resolve|join)\(\s*(?:__dirname|${IMPORT_META.source}\.(?:dir|dirname|url))\s*,\s*(["'\`])([^"'\`]+)\1`, "g");
118
+ const META_DIR_RE = new RegExp(IMPORT_META.source + String.raw`\.(?:dir|dirname)\b`, "g");
119
+ const META_URL_RE = new RegExp(IMPORT_META.source + String.raw`\.url\b`, "g");
120
+ // require("@downdraft/engine/platform/glob-polyfill") — a runtime package
121
+ // specifier can't resolve inside a compiled binary; rewrite to the
122
+ // injected __ddCreateGlob binding.
123
+ const REQUIRE_GLOB_RE = /require\(\s*["'`]@downdraft\/engine\/platform\/glob-polyfill["'`]\s*\)/g;
124
+ const GLOB_CALLEE_RE = new RegExp(IMPORT_META.source + String.raw`\.glob`);
125
+ const SCRIPT_EXT_RE = /\.(ts|tsx|js|jsx|mts|mjs|cts|cjs)$/i;
126
+ const DYN_IMPORT_RE = /import\(\s*([a-zA-Z_$][\w$]*)\s*\)/g;
127
+
128
+ function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
129
+
130
+ /** Resolve a module specifier to an absolute path (relative or bare). */
131
+ function resolveSpec(spec, importer) {
132
+ try {
133
+ return spec.startsWith(".") || spec.startsWith("/")
134
+ ? resolve(dirname(importer), spec)
135
+ : Bun.resolveSync(spec, dirname(importer));
136
+ } catch { return null; }
137
+ }
138
+
139
+ /** repo-rel path of a module file as embedded in the binary (.js suffix). */
140
+ function bunfsPath(absPath) {
141
+ return `/$bunfs/root/${relative(repoRoot, absPath).replace(/\\/g, "/").replace(/\.[jt]sx?$/, ".js")}`;
142
+ }
143
+
144
+ // Worker files referenced via `new Worker(new URL("...", import.meta.url))`
145
+ // must be passed as extra entrypoints — Bun bundles each one separately into
146
+ // the binary. Discovered by scanning every module that enters the graph.
147
+ const workerEntries = new Set();
148
+
149
+ // Dynamic imports with a variable specifier (`const spec = "..."; import(spec)`)
150
+ // are invisible to the bundler — used to keep platform-native out of browser
151
+ // bundles. In packaged binaries they can only resolve to /$bunfs paths of
152
+ // modules embedded as extra entrypoints, so we resolve the bound literal,
153
+ // embed it, and rewrite the specifier. Keyed by `<file>:<ident>` → spec info.
154
+ const dynImports = new Map();
155
+
156
+ // Files staged under <outdir>/dd-assets/<rel>: ?url imports, asset
157
+ // new URL(...) refs, and import.meta.glob matches.
158
+ const assetFiles = new Set();
159
+
160
+ /** Repo-relative staging path for an absolute file path. */
161
+ function assetRel(absPath) {
162
+ const rel = relative(repoRoot, absPath).replace(/\\/g, "/");
163
+ if (!rel.startsWith("..")) return rel;
164
+ // Outside the repo (e.g. standalone node_modules) — hash the DIRECTORY so
165
+ // that a module's baked dirname and its sibling assets land together.
166
+ const h = createHash("sha1").update(dirname(absPath)).digest("hex").slice(0, 8);
167
+ return `external/${h}/${basename(absPath)}`;
168
+ }
169
+
170
+ function walkDir(dir, results = []) {
171
+ let entries;
172
+ try { entries = readdirSync(dir); } catch { return results; }
173
+ for (let _i = 0, _it = entries, _n = _it.length; _i < _n; _i++) { const e = _it[_i];
174
+ const full = `${dir}/${e}`;
175
+ let st;
176
+ try { st = statSync(full); } catch { continue; }
177
+ if (st.isDirectory()) walkDir(full, results);
178
+ else results.push(full);
179
+ }
180
+ return results;
181
+ }
182
+
183
+ const makePlugin = (collectWorkers) => ({
184
+ name: "downdraft-native-build",
185
+ setup(build) {
186
+ const resolveImport = (spec, importer) => {
187
+ // Relative → join on the importer; bare specifiers → Bun's resolver
188
+ // (workspace package specifiers like @downdraft/engine/... need it).
189
+ const base = spec.startsWith(".") || spec.startsWith("/")
190
+ ? resolve(dirname(importer), spec)
191
+ : Bun.resolveSync(spec, dirname(importer));
192
+ // Engine convention: `./x.wgsl` may resolve to a TS module `x.wgsl.ts`.
193
+ return existsSync(base) ? base : (existsSync(`${base}.ts`) ? `${base}.ts` : base);
194
+ };
195
+ // Strip ?raw / ?url / ?json so the bundler resolves the real file.
196
+ build.onResolve({ filter: /\?(raw|url|json)$/ }, (args) => {
197
+ const clean = args.path.replace(QUERY_RE, "");
198
+ const kind = args.path.match(QUERY_RE)[1];
199
+ const resolved = resolveImport(clean, args.importer);
200
+ // Engine convention: `./x.wgsl` may resolve to a TS module x.wgsl.ts —
201
+ // those are real TS modules, so leave them in the default namespace.
202
+ if (resolved.endsWith(".ts")) return { path: resolved };
203
+ return { path: resolved, namespace: `dd-${kind}` };
204
+ });
205
+ // ?raw / bare .wgsl/.glsl → text default export (matches bun-preload).
206
+ build.onLoad({ filter: /.*/, namespace: "dd-raw" }, (args) => ({
207
+ contents: `export default ${JSON.stringify(readFileSync(args.path, "utf-8"))};`,
208
+ loader: "js",
209
+ }));
210
+ // ?json → parse at build time, embed as a JS literal.
211
+ build.onLoad({ filter: /.*/, namespace: "dd-json" }, (args) => ({
212
+ contents: `export default ${JSON.stringify(JSON.parse(readFileSync(args.path, "utf-8")))};`,
213
+ loader: "js",
214
+ }));
215
+ // ?url → file:// URL into the staged dd-assets tree (packaged) or the
216
+ // absolute path (scan pass — keeps dev-mode parity for non-compiled use).
217
+ build.onLoad({ filter: /.*/, namespace: "dd-url" }, (args) => {
218
+ assetFiles.add(args.path);
219
+ if (collectWorkers) {
220
+ return { contents: `export default ${JSON.stringify(args.path)};`, loader: "js" };
221
+ }
222
+ const rel = assetRel(args.path);
223
+ return {
224
+ contents:
225
+ `import { dirname as __ddDirname } from "node:path";` +
226
+ `import { pathToFileURL as __ddP2F } from "node:url";` +
227
+ `export default __ddP2F(__ddDirname(process.execPath) + "/dd-assets/" + ${JSON.stringify(rel)}).href;`,
228
+ loader: "js",
229
+ };
230
+ });
231
+ // Bare .wgsl/.glsl imports (no ?raw suffix) → text default export.
232
+ ["wgsl", "glsl"].forEach((ext) => {
233
+ build.onResolve({ filter: new RegExp(`\\.${ext}$`) }, (args) => {
234
+ const resolved = resolveImport(args.path, args.importer);
235
+ if (resolved.endsWith(".ts")) return { path: resolved };
236
+ return { path: resolved, namespace: "dd-raw" };
237
+ });
238
+ });
239
+ // CSS — no DOM in native mode; discard like the runtime loader does.
240
+ // Resolve to an absolute path — a relative namespaced path can be
241
+ // re-serialized as "dd-css:./x.css" and rejected as a bogus package.
242
+ // Unresolvable package-rooted CSS (exports-map-hidden) still stubs out.
243
+ build.onResolve({ filter: /\.css$/ }, (args) => {
244
+ // A re-serialized "dd-css:<path>" specifier (Bun transpile cache replay)
245
+ // must be returned unchanged — resolving it as a package/relative path
246
+ // wraps it in another "dd-css:<dir>" layer each pass until ENAMETOOLONG.
247
+ if (args.path.startsWith("dd-css:")) {
248
+ return { path: args.path.slice("dd-css:".length), namespace: "dd-css" };
249
+ }
250
+ let path;
251
+ try { path = resolveImport(args.path, args.importer); }
252
+ catch { path = resolve(dirname(args.importer), args.path); }
253
+ return { path, namespace: "dd-css" };
254
+ });
255
+ build.onLoad({ filter: /.*/, namespace: "dd-css" }, () => ({
256
+ contents: `export default "";`, loader: "js",
257
+ }));
258
+ // Scan every real source file for Worker entrypoints, asset refs, and
259
+ // glob patterns. In the compile pass, rewrite module-local paths:
260
+ // inside a compiled binary import.meta flattens to /$bunfs/root, so
261
+ // each module's original repo-relative location is baked in.
262
+ build.onLoad({ filter: /\.[jt]sx?$/ }, (args) => {
263
+ const src = readFileSync(args.path, "utf-8");
264
+ const modDir = dirname(args.path);
265
+ const modRel = assetRel(args.path);
266
+ const modBase = basename(modRel).replace(/\.[jt]sx?$/, ".js");
267
+ const stagedDir = dirname(modRel);
268
+
269
+ // Worker entrypoints — needed in both passes (pass 1 collects).
270
+ for (const m of src.matchAll(WORKER_RE)) {
271
+ const p = resolve(modDir, m[1]);
272
+ if (existsSync(p)) workerEntries.add(p);
273
+ }
274
+ // Asset refs: new URL("x.png", import.meta.url) where x isn't a script.
275
+ for (const m of src.matchAll(ASSET_URL_RE)) {
276
+ if (SCRIPT_EXT_RE.test(m[1])) continue;
277
+ const p = resolve(modDir, m[1]);
278
+ if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
279
+ }
280
+ // Dynamic imports through variable specifiers: `const s = "spec";
281
+ // await import(s)`. The bundler can't see them — resolve the bound
282
+ // literal, embed the module, and rewrite the specifier to its
283
+ // /$bunfs path in the compile pass.
284
+ for (const m of src.matchAll(DYN_IMPORT_RE)) {
285
+ const ident = m[1];
286
+ const bind = src.match(new RegExp("(?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*([\"'`])([^\"'`]+)\\1"));
287
+ if (!bind) continue;
288
+ const spec = bind[2];
289
+ if (!spec || spec.startsWith("node:") || spec.startsWith("bun:")) continue;
290
+ const abs = resolveSpec(spec, args.path);
291
+ if (!abs || !existsSync(abs)) continue;
292
+ workerEntries.add(abs);
293
+ dynImports.set(`${args.path}:${ident}`, { ident, spec, abs });
294
+ }
295
+
296
+ // Module-relative file reads — stage so the baked dirname resolves.
297
+ for (const m of src.matchAll(STAGE_READ_RE)) {
298
+ if (SCRIPT_EXT_RE.test(m[2])) continue;
299
+ const p = resolve(modDir, m[2]);
300
+ if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
301
+ }
302
+ // Glob patterns — stage the entire subtree under the pattern's
303
+ // static prefix. Matched files routinely reference siblings the
304
+ // glob doesn't cover (GLB → ../Textures/x.png, mod.json →
305
+ // shaders/*.wgsl), so the whole asset root must ship. Covers
306
+ // import.meta.glob (rewritten below), the _glob alias, and
307
+ // createGlob(modDir)(...) — the latter two resolve through
308
+ // import.meta.dir/__dirname, fixed by the baked-dir rewrite.
309
+ for (const m of src.matchAll(STAGE_GLOB_RE)) {
310
+ const pattern = m[2] ?? m[4];
311
+ if (!pattern) continue;
312
+ const rootParts = [];
313
+ for (const p of pattern.split("/")) {
314
+ if (p.includes("*") || p.includes("{")) break;
315
+ rootParts.push(p);
316
+ }
317
+ const searchDir = resolve(modDir, ...rootParts);
318
+ if (existsSync(searchDir) && statSync(searchDir).isDirectory()) {
319
+ for (const f of walkDir(searchDir)) assetFiles.add(f);
320
+ }
321
+ }
322
+
323
+ if (collectWorkers) return undefined;
324
+
325
+ let out = src;
326
+ // 1. Worker URLs first — before import.meta.url is rewritten.
327
+ out = out.replace(WORKER_RE, (m, spec) => {
328
+ const rel = relative(repoRoot, resolve(modDir, spec))
329
+ .replace(/\\/g, "/").replace(/\.[jt]sx?$/, ".js");
330
+ return m.replace(`"${spec}"`, `"/$bunfs/root/${rel}"`)
331
+ .replace(`'${spec}'`, `"/$bunfs/root/${rel}"`)
332
+ .replace(`\`${spec}\``, `"/$bunfs/root/${rel}"`);
333
+ });
334
+ // 2. Asset URL refs need no rewrite — import.meta.url already points
335
+ // at the staged dd-assets dir, so new URL("./x.png", import.meta.url)
336
+ // resolves there on its own.
337
+ // 2b. Variable-specifier dynamic imports → embedded $bunfs path.
338
+ for (const m of out.matchAll(DYN_IMPORT_RE)) {
339
+ const ident = m[1];
340
+ const info = dynImports.get(`${args.path}:${ident}`);
341
+ if (!info) continue;
342
+ const decl = new RegExp("((?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*)([\"'`])" + escapeRe(info.spec) + "\\2");
343
+ out = out.replace(decl, `$1$2${bunfsPath(info.abs)}$2`);
344
+ }
345
+
346
+ // 3. import.meta.glob → createGlob bound to the module's staged dir.
347
+ let needsGlob = false;
348
+ out = out.replace(GLOB_RE, (m) => { needsGlob = true; return m.replace(GLOB_CALLEE_RE, "__ddGlob"); });
349
+ // 3b. require() of the glob-polyfill package — unresolvable inside a
350
+ // compiled binary; swap in the injected import binding.
351
+ if (REQUIRE_GLOB_RE.test(out)) {
352
+ needsGlob = true;
353
+ out = out.replace(REQUIRE_GLOB_RE, "({ createGlob: __ddCreateGlob })");
354
+ }
355
+ // 4. import.meta.dir / .url / bare __dirname → the module's location
356
+ // inside the staged dd-assets tree (real FS — sibling asset reads,
357
+ // glob enumeration, and wasm/binary loads all resolve there).
358
+ // Bun flattens all of these to /$bunfs/root in compiled binaries.
359
+ // Apply unconditionally — a file that only reads import.meta.url for
360
+ // a `new URL("./x.png", ...)` still needs the rewrite even though no
361
+ // earlier step changed its source.
362
+ const metaOut = out
363
+ .replace(META_DIR_RE, "__ddModDir")
364
+ .replace(META_URL_RE, "__ddModUrl")
365
+ .replace(/(?<![.\w$])__dirname\b/g, "__ddModDir");
366
+ if (metaOut !== out || needsGlob) {
367
+ out =
368
+ `import { dirname as __ddDirname } from "node:path";\n` +
369
+ `import { pathToFileURL as __ddP2F } from "node:url";\n` +
370
+ `const __ddModDir = __ddDirname(process.execPath) + "/dd-assets/" + ${JSON.stringify(stagedDir === "." ? "" : stagedDir + "/")}.replace(/\\/$/, "");\n` +
371
+ `const __ddModUrl = __ddP2F(__ddModDir + "/" + ${JSON.stringify(modBase)}).href;\n` +
372
+ (needsGlob
373
+ ? `import { createGlob as __ddCreateGlob } from "@downdraft/engine/platform/glob-polyfill";\n` +
374
+ // Lazy — eager construction runs at module-init time and can
375
+ // hit a TDZ on consts declared later in the same module
376
+ // (glob-polyfill.ts itself matches REQUIRE_GLOB_RE via a
377
+ // comment).
378
+ `const __ddGlob = (p, o) => __ddCreateGlob(__ddModDir)(p, o);\n`
379
+ : "") +
380
+ metaOut;
381
+ }
382
+ const loader = { ts: "ts", tsx: "tsx", js: "js", jsx: "jsx", mts: "ts", mjs: "js" }[args.path.split(".").pop()] || "ts";
383
+ return out === src ? undefined : { contents: out, loader };
384
+ });
385
+ },
386
+ });
387
+
388
+ const scanOutdir = mkdtempSync(join(tmpdir(), "dd-scan-"));
389
+
390
+ // Pass 1: bundle without compile to discover Worker entrypoints. Worker
391
+ // files enter the module graph only once listed as entrypoints, so loop to
392
+ // a fixpoint — this also discovers workers spawned by workers and dynamic
393
+ // imports reachable only inside worker bundles.
394
+ let prevCount = -1;
395
+ while (workerEntries.size !== prevCount) {
396
+ prevCount = workerEntries.size;
397
+ const scan = await Bun.build({
398
+ entrypoints: [resolve(entry), ...workerEntries],
399
+ target: "bun",
400
+ plugins: [makePlugin(true)],
401
+ splitting: false,
402
+ sourcemap: "none",
403
+ minify: false,
404
+ define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
405
+ outdir: scanOutdir,
406
+ });
407
+ if (!scan.success) {
408
+ scan.logs.forEach((msg) => { console.error(msg);; });
409
+ process.exit(1);
410
+ }
411
+ }
412
+
413
+ const entrypoints = [resolve(entry), ...workerEntries];
414
+ if (workerEntries.size) {
415
+ console.log(`workers: ${[...workerEntries].map((p) => p.split("/").pop()).join(", ")}`);
416
+ }
417
+
418
+ const result = await Bun.build({
419
+ entrypoints,
420
+ compile: { outfile: resolve(outfile), ...(target.bun ? { target: target.bun } : {}) },
421
+ target: "bun",
422
+ plugins: [makePlugin(false)],
423
+ splitting: false,
424
+ sourcemap: "none",
425
+ minify: modeArg === "prod",
426
+ // Strip the MCP endpoint from distributed binaries unless --mcp was passed.
427
+ define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
428
+ // koffi is the Node-only FFI fallback (dead under Bun); its native addon
429
+ // can't bundle, and the import is guarded by runtime detection anyway.
430
+ external: ["koffi"],
431
+ });
432
+
433
+ if (!result.success) {
434
+ result.logs.forEach((msg) => { console.error(msg);; });
435
+ process.exit(1);
436
+ }
437
+ result.outputs.forEach((out) => {
438
+ console.log(`${out.kind.padEnd(18)} ${out.path} ${(out.size / 1024 / 1024).toFixed(1)}MB`);
439
+ });
440
+
441
+ // ── Windows PE branding ──
442
+ // Stamp version info + icon into the compiled .exe so it doesn't identify
443
+ // itself as "Bun runtime". resedit is pure JS — no wine/rcedit needed.
444
+ // Metadata precedence: --product-name/--product-version/--icon flags, then
445
+ // the game's package.json (productName / build.productName, version,
446
+ // build.icon or icon.ico|icon.png in the game dir), then the game name.
447
+ const gameDir = dirname(dirname(resolve(entry))); // src/native-entry.ts → game root
448
+ if (target.exe) {
449
+ let pkg = {};
450
+ try { pkg = JSON.parse(readFileSync(join(gameDir, "package.json"), "utf-8")); } catch { /* standalone entry */ }
451
+ const titleize = (s) => String(s).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
452
+ const productName = productNameFlag ?? pkg.productName ?? pkg.build?.productName
453
+ ?? titleize(pkg.name ?? basename(outfile, ".exe"));
454
+ const version = productVersionFlag ?? pkg.version ?? "0.0.0";
455
+ const author = typeof pkg.author === "string" ? pkg.author : pkg.author?.name ?? "DownDraft Engine";
456
+ let iconPath = iconFlag
457
+ ?? (pkg.build?.icon ? resolve(gameDir, pkg.build.icon) : undefined);
458
+ if (!iconPath) {
459
+ iconPath = ["icon.ico", "assets/icon.ico", "icon.png", "assets/icon.png"]
460
+ .map((p) => join(gameDir, p)).find((p) => existsSync(p));
461
+ }
462
+ try {
463
+ const ResEdit = await import("resedit");
464
+ const exe = ResEdit.NtExecutable.from(readFileSync(resolve(outfile)));
465
+ // Bun appends a `.bun` trailer section after `.reloc`, which
466
+ // NtExecutableResource.from() rejects outright — it only tolerates
467
+ // .reloc after .rsrc. Hide .bun from the order check; resources are
468
+ // regenerated in place below (noGrow), so no section ever moves.
469
+ const allSections = exe.getAllSections.bind(exe);
470
+ let rsrcVa = -1;
471
+ let reloc;
472
+ for (const s of allSections()) {
473
+ if (s.info.name === ".rsrc") rsrcVa = s.info.virtualAddress;
474
+ if (s.info.name === ".reloc") reloc = s;
475
+ }
476
+ exe.getAllSections = () =>
477
+ allSections().filter((s) => s === reloc || s.info.virtualAddress <= rsrcVa || rsrcVa < 0);
478
+ const res = ResEdit.NtExecutableResource.from(exe);
479
+ exe.getAllSections = allSections;
480
+ const lang = { lang: 1033, codepage: 1200 };
481
+ const vi = ResEdit.Resource.VersionInfo.createEmpty();
482
+ // PE versions are numeric quads — strip any semver suffix/prefix.
483
+ const verNum = (version.match(/\d+(?:\.\d+)*/) ?? ["0.0.0"])[0];
484
+ vi.setFileVersion(verNum, lang.lang);
485
+ vi.setProductVersion(verNum, lang.lang);
486
+ vi.setStringValues(lang, {
487
+ CompanyName: author,
488
+ FileDescription: productName,
489
+ FileVersion: version,
490
+ InternalName: basename(outfile, ".exe"),
491
+ OriginalFilename: basename(outfile),
492
+ ProductName: productName,
493
+ ProductVersion: version,
494
+ }, /* addToAvailableLanguage */ true);
495
+ // Replace Bun's stock version resource entirely — Windows reads the
496
+ // first RT_VERSION it finds, so a leftover one can shadow ours.
497
+ res.entries = res.entries.filter((e) => e.type !== 16);
498
+ vi.outputToResourceEntries(res.entries);
499
+ for (const e of res.entries.filter((e) => e.type === 16)) e.lang = lang.lang;
500
+
501
+ if (iconPath && existsSync(iconPath)) {
502
+ const iconBin = readFileSync(iconPath);
503
+ let items;
504
+ if (iconPath.endsWith(".ico")) {
505
+ items = ResEdit.Data.IconFile.from(iconBin).icons;
506
+ } else if (iconPath.endsWith(".png")) {
507
+ // PNG-compressed icons are valid in PE resources (Vista+). Width and
508
+ // height come from the IHDR chunk (bytes 16–24).
509
+ const w = iconBin.readUInt32BE(16), h = iconBin.readUInt32BE(20);
510
+ items = [new ResEdit.Data.RawIconItem(iconBin, w, h, 32)];
511
+ }
512
+ if (items?.length) {
513
+ // Reuse an existing icon-group id when present (Bun embeds one);
514
+ // otherwise allocate 101 — Windows picks the lowest group anyway.
515
+ const existing = res.entries.filter((e) => e.type === 14); // RT_GROUP_ICON
516
+ const groupId = existing.length ? existing[0].id : 101;
517
+ ResEdit.Resource.IconGroupEntry.replaceIconsForResource(
518
+ res.entries, groupId, lang.lang, items);
519
+ }
520
+ } else {
521
+ // No game icon — drop Bun's ~270KB stock icon (RT_ICON + its
522
+ // RT_GROUP_ICON) so the version strings fit inside the existing .rsrc
523
+ // under noGrow below. The exe falls back to the generic Windows icon,
524
+ // which is better branding than shipping the Bun logo.
525
+ res.entries = res.entries.filter((e) => e.type !== 3 && e.type !== 14);
526
+ }
527
+
528
+ // noGrow: fit inside the existing .rsrc — the new data must not push
529
+ // .reloc/.bun because pe-library can't relocate arbitrary trailers.
530
+ // Bun's stock .rsrc is ~270KB, ample for version strings + an icon.
531
+ res.outputResource(exe, /* noGrow */ true);
532
+ writeFileSync(resolve(outfile), Buffer.from(exe.generate()));
533
+ console.log(`branding: ${productName} v${version}${iconPath ? ` + icon ${basename(iconPath)}` : ""}`);
534
+ } catch (e) {
535
+ // Cosmetic only — never fail the package over resource stamping.
536
+ console.warn(`warning: PE branding skipped (${e?.message ?? e})`);
537
+ }
538
+ }
539
+
540
+ // ── Stage runtime tree ──
541
+ const outdir = dirname(resolve(outfile));
542
+ const platKey = target.plat;
543
+ const libExt = platKey.startsWith("win32") ? ".dll" : platKey.startsWith("darwin") ? ".dylib" : ".so";
544
+
545
+ const nativeDir = join(outdir, "native");
546
+ mkdirSync(nativeDir, { recursive: true });
547
+
548
+ // Package resolution rooted at the game dir — works both inside the
549
+ // monorepo (workspace links) and in a standalone game repo (node_modules).
550
+ const gameRequire = createRequire(resolve(entry));
551
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
552
+ const monorepoRoot = resolve(scriptDir, "../../..");
553
+
554
+ function pkgRoot(name) {
555
+ try { return dirname(gameRequire.resolve(`${name}/package.json`)); }
556
+ catch { return null; }
557
+ }
558
+
559
+ // Platform lib — the unified libdowndraft_platform (Rust, statically links
560
+ // wgpu — no external deps) stages under native/<platform>-<arch>/. Probe
561
+ // order: monorepo dev staging, the installed platform-native package, the
562
+ // per-target @downdraft/native-<plat>-<arch> package.
563
+ const platLibCandidates = [...new Set([
564
+ join(monorepoRoot, "packages/platform-native/native", platKey),
565
+ pkgRoot("@downdraft/platform-native") && join(pkgRoot("@downdraft/platform-native"), "native", platKey),
566
+ pkgRoot(`@downdraft/native-${platKey}`) && join(pkgRoot(`@downdraft/native-${platKey}`), "lib"),
567
+ ].filter(Boolean))];
568
+ const platStageDir = platLibCandidates.find((d) => existsSync(d));
569
+ if (platStageDir) {
570
+ for (const f of readdirSync(platStageDir)) {
571
+ if (f.endsWith(libExt)) copyFileSync(join(platStageDir, f), join(nativeDir, f));
572
+ }
573
+ } else {
574
+ console.error(`no libdowndraft_platform build found for ${platKey} — searched: ${platLibCandidates.join(", ")}`);
575
+ process.exit(1);
576
+ }
577
+
578
+ // Engine cdylibs — staged if a build exists: new dist/<plat>-<arch>/ and
579
+ // dist/ layouts (build-native.mjs), the installed @downdraft/engine package
580
+ // (standalone games), plus the legacy standalone-crate target/ + crate-dir
581
+ // layouts.
582
+ const engineCrates = CRATES.filter((c) => c.pkg !== "downdraft-platform");
583
+ const engineRoot = pkgRoot("@downdraft/engine");
584
+ const staged = new Set();
585
+ engineCrates.forEach((crate) => {
586
+ const file = libFileName(crate.lib, platKey);
587
+ // crate.dir is packages/engine/libraries/<name>/native — strip the
588
+ // package prefix to probe inside an installed @downdraft/engine.
589
+ const pkgRel = crate.dir.replace(/^packages\/engine\//, "");
590
+ const candidates = [
591
+ join(repoRoot, crate.dest, platKey, file),
592
+ join(repoRoot, crate.dest, file),
593
+ join(repoRoot, crate.dir, "target", "release", file),
594
+ join(repoRoot, crate.dir, file),
595
+ ...(engineRoot ? [
596
+ join(engineRoot, pkgRel, "dist", platKey, file),
597
+ join(engineRoot, pkgRel, "dist", file),
598
+ ] : []),
599
+ ];
600
+ for (let _i = 0, _it = candidates, _n = _it.length; _i < _n; _i++) {
601
+ const p = _it[_i];
602
+ if (existsSync(p) && !staged.has(file)) {
603
+ copyFileSync(p, join(nativeDir, file));
604
+ staged.add(file);
605
+ break;
606
+ }
607
+ }
608
+ });
609
+
610
+ // Game-native libs — convention: <game>/native/dist/<platform>-<arch>/ holds
611
+ // artifacts the game built itself (cdylib .so/.dll/.dylib loaded via the FFI
612
+ // adapter, or .node napi addons loaded via createRequire/process.dlopen).
613
+ // Staged flat into native/ so resolveNativeLibrary's <execDir>/native lookup
614
+ // and execDir-relative .node resolution both hit.
615
+ const gameNativeDir = join(gameDir, "native", "dist", platKey);
616
+ if (existsSync(gameNativeDir)) {
617
+ let gameLibs = 0;
618
+ for (const f of readdirSync(gameNativeDir)) {
619
+ if (f.endsWith(libExt) || f.endsWith(".node")) {
620
+ copyFileSync(join(gameNativeDir, f), join(nativeDir, f));
621
+ staged.add(f);
622
+ gameLibs++;
623
+ }
624
+ }
625
+ if (gameLibs) console.log(`staged ${gameLibs} game-native lib(s) from ${gameNativeDir}`);
626
+ }
627
+
628
+ // Assets referenced via ?url / new URL / import.meta.glob.
629
+ const assetRoot = join(outdir, "dd-assets");
630
+ let stagedAssets = 0;
631
+ for (const f of assetFiles.values()) {
632
+ const dest = join(assetRoot, assetRel(f));
633
+ mkdirSync(dirname(dest), { recursive: true });
634
+ copyFileSync(f, dest);
635
+ stagedAssets++;
636
+ }
637
+
638
+ console.log(`staged: ${readdirSync(nativeDir).filter((f) => f.endsWith(libExt)).length} native libs, ${stagedAssets} assets`);