@downdraft/cli 0.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@downdraft/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/knackstedt/downdraft.git",
Binary file
@@ -0,0 +1,931 @@
1
+ // ============================================================================
2
+ // package-desktop.mjs — linux desktop packaging: staged app dir + formats
3
+ // ============================================================================
4
+ //
5
+ // bun packages/cli/scripts/package-desktop.mjs [flags] <entry.ts> <outdir>
6
+ //
7
+ // --runtime=bun|node|deno JS host embedded in the package (default:
8
+ // package.json build.runtime, else bun)
9
+ // --format=<csv> dir | deb | appimage | flatpak (default:
10
+ // package.json build.linux.target, else dir)
11
+ // --mode=dev|debug|prod bundle mode (default: prod)
12
+ // --product-name=<name> display name (default: pkg.productName)
13
+ // --product-version=<v> version (default: pkg.version)
14
+ // --app-id=<id> reverse-DNS id (default: com.downdraft.<game>)
15
+ // --executable-name=<n> binary name (default: kebab-case productName)
16
+ // --icon=<path> png icon (default: build.linux.icon →
17
+ // build.icon → assets/icon.png → embedded)
18
+ // --node-bin=<path> node binary to ship (default: PATH lookup)
19
+ // --deno-bin=<path> deno binary to ship (default: PATH lookup)
20
+ // --mcp retain the MCP automation endpoint
21
+ //
22
+ // Runtime matrix — what the "binary" actually is per --runtime:
23
+ // bun → `bun build --compile` via package-native.mjs — one self-contained
24
+ // executable embedding the whole bundle (delegated subprocess).
25
+ // node → emitted .mjs bundle tree + a shipped node binary + launcher.
26
+ // deno → emitted .mjs bundle tree + a shipped deno binary + launcher.
27
+ // (deno compile can't embed workers-at-absolute-paths — the bundle
28
+ // tree is also what dd-assets/import.meta resolution anchors on.)
29
+ //
30
+ // App dir layout — identical shape across runtimes so all downstream
31
+ // resolution (lib-paths, dd-assets, workers, game-native .so/.node) is
32
+ // uniform. Everything anchors on dirname(process.execPath) == appdir root:
33
+ //
34
+ // <exe> primary executable — compiled binary (bun) or a
35
+ // relocatable launcher script (node/deno)
36
+ // node|deno runtime binary (emit runtimes only)
37
+ // bundle/ emitted .mjs tree + index.js + package.json
38
+ // bundle/node_modules/ koffi + @koromix/koffi-<plat> prebuild (node only)
39
+ // native/ libdowndraft_*.so + game-native dist/<plat>/*
40
+ // (includes .node addons — napi loads under all
41
+ // three desktop runtimes)
42
+ // dd-assets/ ?url / new URL / import.meta.glob staged assets
43
+ //
44
+ // electron-builder-style config lives in the game's package.json `build`:
45
+ //
46
+ // build: {
47
+ // appId, productName, icon, runtime, executableName,
48
+ // files: [ <paths copied into the appdir> ],
49
+ // linux: { target: ["deb","AppImage","flatpak"], category, maintainer,
50
+ // synopsis, description, icon },
51
+ // deb: { section, priority, depends: [] },
52
+ // flatpak:{ runtime, runtimeVersion, sdk, finishArgs: [] }
53
+ // }
54
+ //
55
+ // Formats:
56
+ // dir — the staged appdir verbatim (what package-native produces)
57
+ // deb — /usr/lib/<exe> + /usr/bin symlink + desktop file + icons,
58
+ // assembled with dpkg-deb
59
+ // appimage — AppDir + AppRun + appimagetool (downloaded+cached on demand)
60
+ // flatpak — generated manifest + flatpak-builder + build-bundle
61
+ // ============================================================================
62
+
63
+ import { spawnSync } from "node:child_process";
64
+ import { createHash } from "node:crypto";
65
+ import {
66
+ chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync,
67
+ readdirSync, readFileSync, realpathSync, statSync, symlinkSync, writeFileSync,
68
+ } from "node:fs";
69
+ import { tmpdir } from "node:os";
70
+ import { basename, dirname, join, relative, resolve } from "node:path";
71
+ import { fileURLToPath } from "node:url";
72
+
73
+ // ── Args ──
74
+
75
+ const argv = process.argv.slice(2);
76
+ const flagValue = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
77
+ const hasFlag = (name) => argv.includes(`--${name}`);
78
+ const positional = argv.filter((a) => !a.startsWith("--"));
79
+ const [entry, outdirArg] = positional;
80
+
81
+ if (!entry || !outdirArg) {
82
+ console.error(
83
+ "usage: bun package-desktop.mjs [--runtime=bun|node|deno] [--format=dir|deb|appimage|flatpak,...] " +
84
+ "[--mode=...] [--product-name=] [--product-version=] [--app-id=] [--executable-name=] [--icon=] " +
85
+ "[--node-bin=] [--deno-bin=] [--mcp] <entry.ts> <outdir>");
86
+ process.exit(1);
87
+ }
88
+
89
+ const gameDir = dirname(dirname(resolve(entry))); // src/native-entry.ts → game root
90
+ let pkg = {};
91
+ try { pkg = JSON.parse(readFileSync(join(gameDir, "package.json"), "utf-8")); } catch { /* standalone entry */ }
92
+ const build = pkg.build ?? {};
93
+ const linuxCfg = build.linux ?? {};
94
+
95
+ const runtime = flagValue("runtime") ?? build.runtime ?? "bun";
96
+ if (!["bun", "node", "deno"].includes(runtime)) {
97
+ console.error(`unknown --runtime "${runtime}" — expected bun, node, or deno`);
98
+ process.exit(1);
99
+ }
100
+ const formats = (flagValue("format") ?? (Array.isArray(linuxCfg.target) ? linuxCfg.target.join(",") : linuxCfg.target) ?? "dir")
101
+ .split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
102
+ const KNOWN_FORMATS = new Set(["dir", "deb", "appimage", "flatpak"]);
103
+ for (const f of formats) {
104
+ if (!KNOWN_FORMATS.has(f)) {
105
+ console.error(`unknown --format "${f}" — expected dir, deb, appimage, or flatpak`);
106
+ process.exit(1);
107
+ }
108
+ }
109
+ const mode = flagValue("mode") ?? "prod";
110
+ const retainMcp = hasFlag("mcp");
111
+
112
+ const titleize = (s) => String(s).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
113
+ const productName = flagValue("product-name") ?? pkg.productName ?? build.productName ?? titleize(pkg.name ?? basename(gameDir));
114
+ const appId = flagValue("app-id") ?? build.appId ?? `com.downdraft.${basename(gameDir).replace(/[^a-zA-Z0-9]/g, "")}`;
115
+ const version = flagValue("product-version") ?? pkg.version ?? "0.0.1";
116
+ const exeName = (flagValue("executable-name") ?? build.executableName ?? productName)
117
+ .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || basename(gameDir);
118
+ const maintainer = linuxCfg.maintainer
119
+ ?? (typeof pkg.author === "string" ? pkg.author : pkg.author?.name) ?? "DownDraft";
120
+ const category = linuxCfg.category ?? "Game";
121
+ const synopsis = linuxCfg.synopsis ?? pkg.description ?? productName;
122
+ const description = linuxCfg.description ?? pkg.description ?? synopsis;
123
+
124
+ const outdir = resolve(outdirArg);
125
+ const appdir = join(outdir, `${basename(gameDir)}-linux-${runtime}`);
126
+ const platKey = `${process.platform}-${process.arch}`;
127
+ const repoRoot = process.cwd();
128
+
129
+ const run = (cmd, args, opts = {}) => {
130
+ console.log(` $ ${cmd} ${args.join(" ")}`);
131
+ const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
132
+ if (r.status !== 0) throw new Error(`${cmd} failed (exit ${r.status})`);
133
+ return r;
134
+ };
135
+ const runOut = (cmd, args, opts = {}) => {
136
+ const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
137
+ return r.status === 0 ? (r.stdout ?? "").trim() : null;
138
+ };
139
+
140
+ mkdirSync(appdir, { recursive: true });
141
+ console.log(`[package-desktop] ${productName} v${version} (${appId}) — runtime=${runtime}, formats=${formats.join(",")}`);
142
+
143
+ // ── Icon resolution ──
144
+
145
+ const DEFAULT_ICON = fileURLToPath(new URL("./default-icon.png", import.meta.url));
146
+ function resolveIcon() {
147
+ const explicit = flagValue("icon") ?? linuxCfg.icon ?? build.icon;
148
+ const candidates = [
149
+ explicit ? resolve(gameDir, explicit) : null,
150
+ join(gameDir, "assets/icon.png"),
151
+ join(gameDir, "assets/icon.ico"),
152
+ join(gameDir, "icon.png"),
153
+ ].filter(Boolean);
154
+ return candidates.find((p) => existsSync(p)) ?? (existsSync(DEFAULT_ICON) ? DEFAULT_ICON : null);
155
+ }
156
+ const iconPath = resolveIcon();
157
+ if (!iconPath) console.warn("[package-desktop] no icon found and embedded default missing");
158
+
159
+ // ── Shared bundle machinery (mirrors package-mobile.mjs; the emit-tree
160
+ // flavor. Path anchors are dirname(process.execPath) — the appdir root —
161
+ // for every runtime.) ──
162
+
163
+ const QUERY_RE = /\?(raw|url|json)$/;
164
+ const WORKER_RE = /new\s+Worker\(\s*new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
165
+ // Rewrite variant — additionally consumes new URL's closing paren so the
166
+ // Worker's own arg list (options object, close paren) stays balanced.
167
+ const WORKER_URL_RE = /new\s+Worker\(\s*new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)\s*(?:\.href|\.toString\(\s*\))?\s*/g;
168
+ const ASSET_URL_RE = /new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
169
+ const IMPORT_META = /(?:\(\s*import\.meta\s+as\s+any\s*\)|import\.meta(?:\s+as\s+any)?)/;
170
+ const GLOB_RE = new RegExp(IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1`, "g");
171
+ const GLOB_EAGER_RE = new RegExp(
172
+ IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1\s*,\s*(\{[^)]*\beager\s*:\s*true[^)]*\})\s*\)`, "g");
173
+ const STAGE_GLOB_RE = new RegExp(
174
+ `(?:${IMPORT_META.source}\\.glob|_glob|globAssets)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
175
+ const STAGE_READ_RE = new RegExp(
176
+ String.raw`(?:resolve|join)\(\s*(?:__dirname|${IMPORT_META.source}\.(?:dir|dirname|url))\s*,\s*(["'\`])([^"'\`]+)\1`, "g");
177
+ const META_DIR_RE = new RegExp(IMPORT_META.source + String.raw`\.(?:dir|dirname)\b`, "g");
178
+ const META_URL_RE = new RegExp(IMPORT_META.source + String.raw`\.url\b`, "g");
179
+ const REQUIRE_GLOB_RE = /require\(\s*["'`]@downdraft\/engine\/platform\/glob-polyfill["'`]\s*\)/g;
180
+ const GLOB_CALLEE_RE = new RegExp(IMPORT_META.source + String.raw`\.glob`);
181
+ const SCRIPT_EXT_RE = /\.(ts|tsx|js|jsx|mts|cts|cjs)$/i; // .mjs staged as an asset (execArgv loaders)
182
+ const DYN_IMPORT_RE = /import\(\s*([a-zA-Z_$][\w$]*)\s*\)/g;
183
+
184
+ function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
185
+
186
+ function resolveSpec(spec, importer) {
187
+ try {
188
+ return spec.startsWith(".") || spec.startsWith("/")
189
+ ? resolve(dirname(importer), spec)
190
+ : Bun.resolveSync(spec, dirname(importer));
191
+ } catch { return null; }
192
+ }
193
+
194
+ /** Repo-relative bundle path for a module, with .mjs extension. Bun's
195
+ * `[dir]` naming escapes out-of-root `..` segments as `_.._`. */
196
+ function bundlePath(absPath) {
197
+ return relative(repoRoot, absPath).replace(/\\/g, "/").replace(/\.[jt]sx?$/, ".mjs")
198
+ .split("/").map((s) => (s === ".." ? "_.._" : s)).join("/");
199
+ }
200
+
201
+ function assetRel(absPath) {
202
+ const rel = relative(repoRoot, absPath).replace(/\\/g, "/");
203
+ if (!rel.startsWith("..")) return rel;
204
+ const h = createHash("sha1").update(dirname(absPath)).digest("hex").slice(0, 8);
205
+ return `external/${h}/${basename(absPath)}`;
206
+ }
207
+
208
+ function walkDir(dir, results = []) {
209
+ let entries;
210
+ try { entries = readdirSync(dir); } catch { return results; }
211
+ for (const e of entries) {
212
+ const full = `${dir}/${e}`;
213
+ let st;
214
+ try { st = statSync(full); } catch { continue; }
215
+ if (st.isDirectory()) walkDir(full, results);
216
+ else results.push(full);
217
+ }
218
+ return results;
219
+ }
220
+
221
+ // Mirrors globToRegex in packages/engine/core/src/platform/glob-polyfill.ts
222
+ // — keep in sync.
223
+ function globToRegex(pattern) {
224
+ const regex = pattern
225
+ .replace(/[.+^$()|[\]\\]/g, "\\$&")
226
+ .replace(/\*\*\//g, "\x00")
227
+ .replace(/\*\*/g, "\x01")
228
+ .replace(/\*/g, "[^/]*")
229
+ .replace(/\{([^}]+)\}/g, (_, group) => `(${group.replace(/,/g, "|")})`)
230
+ .replace(/\x00/g, "(?:.*/)?")
231
+ .replace(/\x01/g, ".*");
232
+ return new RegExp(`^${regex}$`, "i");
233
+ }
234
+
235
+ /** Expand an import.meta.glob pattern against the real FS at package time. */
236
+ function expandGlob(fromDir, pattern) {
237
+ const rootParts = [];
238
+ for (const p of pattern.split("/")) {
239
+ if (p.includes("*") || p.includes("{")) break;
240
+ rootParts.push(p);
241
+ }
242
+ const searchDir = resolve(fromDir, ...rootParts);
243
+ if (!existsSync(searchDir) || !statSync(searchDir).isDirectory()) return [];
244
+ const matchRegex = globToRegex(pattern.replace(/^\.\//, ""));
245
+ return walkDir(searchDir)
246
+ .map((f) => relative(fromDir, f).replace(/\\/g, "/"))
247
+ .filter((rel) => matchRegex.test(rel) || matchRegex.test(`./${rel}`))
248
+ .sort();
249
+ }
250
+
251
+ const workerEntries = new Set();
252
+ const dynImports = new Map();
253
+ const assetFiles = new Set();
254
+
255
+ // Emitted path anchor — every runtime resolves the appdir through
256
+ // dirname(process.execPath): bun → the compiled binary, node → the shipped
257
+ // node binary, deno → the shipped deno binary. All live at the appdir root.
258
+ const APPDIR = "__ddAppDir";
259
+
260
+ const makeEmitPlugin = (collectWorkers) => ({
261
+ name: "downdraft-desktop-emit",
262
+ setup(build_) {
263
+ const resolveImport = (spec, importer) => {
264
+ const base = spec.startsWith(".") || spec.startsWith("/")
265
+ ? resolve(dirname(importer), spec)
266
+ : Bun.resolveSync(spec, dirname(importer));
267
+ return existsSync(base) ? base : (existsSync(`${base}.ts`) ? `${base}.ts` : base);
268
+ };
269
+ build_.onResolve({ filter: /\?(raw|url|json)$/ }, (args) => {
270
+ const clean = args.path.replace(QUERY_RE, "");
271
+ const kind = args.path.match(QUERY_RE)[1];
272
+ const resolved = resolveImport(clean, args.importer);
273
+ if (resolved.endsWith(".ts")) return { path: resolved };
274
+ return { path: resolved, namespace: `dd-${kind}` };
275
+ });
276
+ build_.onLoad({ filter: /.*/, namespace: "dd-raw" }, (args) => ({
277
+ contents: `export default ${JSON.stringify(readFileSync(args.path, "utf-8"))};`,
278
+ loader: "js",
279
+ }));
280
+ build_.onLoad({ filter: /.*/, namespace: "dd-json" }, (args) => ({
281
+ contents: `export default ${JSON.stringify(JSON.parse(readFileSync(args.path, "utf-8")))};`,
282
+ loader: "js",
283
+ }));
284
+ // ?url → file:// URL into the staged dd-assets tree (packaged) or the
285
+ // absolute path (scan pass).
286
+ build_.onLoad({ filter: /.*/, namespace: "dd-url" }, (args) => {
287
+ assetFiles.add(args.path);
288
+ if (collectWorkers) {
289
+ return { contents: `export default ${JSON.stringify(args.path)};`, loader: "js" };
290
+ }
291
+ const rel = assetRel(args.path);
292
+ return {
293
+ contents:
294
+ `import { dirname as __ddDirname } from "node:path";` +
295
+ `import { pathToFileURL as __ddP2F } from "node:url";` +
296
+ `export default __ddP2F(__ddDirname(process.execPath) + "/dd-assets/" + ${JSON.stringify(rel)}).href;`,
297
+ loader: "js",
298
+ };
299
+ });
300
+ ["wgsl", "glsl"].forEach((ext) => {
301
+ build_.onResolve({ filter: new RegExp(`\\.${ext}$`) }, (args) => {
302
+ const resolved = resolveImport(args.path, args.importer);
303
+ if (resolved.endsWith(".ts")) return { path: resolved };
304
+ return { path: resolved, namespace: "dd-raw" };
305
+ });
306
+ });
307
+ build_.onResolve({ filter: /\.css$/ }, (args) => {
308
+ if (args.path.startsWith("dd-css:")) {
309
+ return { path: args.path.slice("dd-css:".length), namespace: "dd-css" };
310
+ }
311
+ let path;
312
+ try { path = resolveImport(args.path, args.importer); }
313
+ catch { path = resolve(dirname(args.importer), args.path); }
314
+ return { path, namespace: "dd-css" };
315
+ });
316
+ build_.onLoad({ filter: /.*/, namespace: "dd-css" }, () => ({
317
+ contents: `export default "";`, loader: "js",
318
+ }));
319
+ build_.onLoad({ filter: /\.[jt]sx?$/ }, (args) => {
320
+ const src = readFileSync(args.path, "utf-8");
321
+ const modDir = dirname(args.path);
322
+ const modRel = assetRel(args.path);
323
+ const modBase = basename(modRel).replace(/\.[jt]sx?$/, ".js");
324
+ const stagedDir = dirname(modRel);
325
+
326
+ // Worker entrypoints — needed in both passes (pass 1 collects).
327
+ for (const m of src.matchAll(WORKER_RE)) {
328
+ const p = resolve(modDir, m[1]);
329
+ if (existsSync(p)) workerEntries.add(p);
330
+ }
331
+ // Asset refs: new URL("x.png", import.meta.url) where x isn't a script.
332
+ for (const m of src.matchAll(ASSET_URL_RE)) {
333
+ // Scripts are bundled; .mjs loaders (worker-bootstrap, wgsl-loader)
334
+ // are spawned via execArgv and must exist as real files — stage them.
335
+ if (SCRIPT_EXT_RE.test(m[1])) continue;
336
+ const p = resolve(modDir, m[1]);
337
+ if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
338
+ }
339
+ for (const m of src.matchAll(DYN_IMPORT_RE)) {
340
+ const ident = m[1];
341
+ const bind = src.match(new RegExp("(?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*([\"'`])([^\"'`]+)\\1"));
342
+ if (!bind) continue;
343
+ const spec = bind[2];
344
+ if (!spec || spec.startsWith("node:") || spec.startsWith("bun:")) continue;
345
+ const abs = resolveSpec(spec, args.path);
346
+ if (!abs || !existsSync(abs)) continue;
347
+ workerEntries.add(abs);
348
+ dynImports.set(`${args.path}:${ident}`, { ident, spec, abs });
349
+ }
350
+ for (const m of src.matchAll(STAGE_READ_RE)) {
351
+ if (SCRIPT_EXT_RE.test(m[2])) continue;
352
+ const p = resolve(modDir, m[2]);
353
+ if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
354
+ }
355
+ for (const m of src.matchAll(STAGE_GLOB_RE)) {
356
+ const pattern = m[2] ?? m[4];
357
+ if (!pattern) continue;
358
+ const rootParts = [];
359
+ for (const p of pattern.split("/")) {
360
+ if (p.includes("*") || p.includes("{")) break;
361
+ rootParts.push(p);
362
+ }
363
+ const searchDir = resolve(modDir, ...rootParts);
364
+ if (existsSync(searchDir) && statSync(searchDir).isDirectory()) {
365
+ for (const f of walkDir(searchDir)) assetFiles.add(f);
366
+ }
367
+ }
368
+
369
+ const loader = { ts: "ts", tsx: "tsx", js: "js", jsx: "jsx", mts: "ts" }[args.path.split(".").pop()] || "ts";
370
+ let needsGlob = false;
371
+
372
+ // 0. Eager module globs → static imports generated at package time.
373
+ const eagerDecls = [];
374
+ let eagerSeq = 0;
375
+ let out = src.replace(GLOB_EAGER_RE, (m, _q, pattern, opts) => {
376
+ if (/\bquery\s*:/.test(opts)) {
377
+ needsGlob = true;
378
+ return m.replace(GLOB_CALLEE_RE, "__ddGlob");
379
+ }
380
+ const spec = /\bimport\s*:\s*["']([A-Za-z_$][\w$]*)["']/.exec(opts)?.[1];
381
+ const name = `__ddEager${eagerSeq++}`;
382
+ const entries = expandGlob(modDir, pattern).map((f, i) => {
383
+ const rel = f.startsWith(".") ? f : "./" + f;
384
+ const ident = `${name}$${i}`;
385
+ eagerDecls.push(`import * as ${ident} from ${JSON.stringify(rel)};`);
386
+ return `${JSON.stringify(rel)}: ${spec ? `${ident}.${spec}` : ident}`;
387
+ });
388
+ eagerDecls.push(`const ${name} = {${entries.length ? "\n " + entries.join(",\n ") + ",\n" : ""}};`);
389
+ return name;
390
+ });
391
+ const eagerPrologue = eagerDecls.length ? eagerDecls.join("\n") + "\n" : "";
392
+
393
+ if (collectWorkers) return eagerPrologue ? { contents: eagerPrologue + out, loader } : undefined;
394
+
395
+ let needsAppDir = false;
396
+
397
+ // 1. Worker URLs → file:// URL into the emitted bundle tree. Deno
398
+ // requires a URL (bare paths throw), node's Worker polyfill accepts
399
+ // URL or path — the file:// URL form serves both.
400
+ out = out.replace(WORKER_URL_RE, (m, spec) => {
401
+ needsAppDir = true;
402
+ const rel = bundlePath(resolve(modDir, spec));
403
+ return `new Worker(new URL("file://" + ${APPDIR} + "/bundle/" + ${JSON.stringify(rel)})`;
404
+ });
405
+ // 1.5 new URL("<asset>", import.meta.url) → absolute file URL into the
406
+ // staged dd-assets tree (see package-mobile for the assetRel note).
407
+ out = out.replace(ASSET_URL_RE, (m, spec) => {
408
+ const p = resolve(modDir, spec);
409
+ if (!existsSync(p)) return m;
410
+ needsAppDir = true;
411
+ return `new URL("file://" + ${APPDIR} + "/dd-assets/" + ${JSON.stringify(assetRel(p))})`;
412
+ });
413
+ // 2. Variable-specifier dynamic imports → file:// URL of the emitted
414
+ // bundle module (file:// because deno rejects bare abs paths).
415
+ let hadDyn = false;
416
+ for (const m of out.matchAll(DYN_IMPORT_RE)) {
417
+ const ident = m[1];
418
+ const info = dynImports.get(`${args.path}:${ident}`);
419
+ if (!info) continue;
420
+ hadDyn = true; needsAppDir = true;
421
+ const decl = new RegExp("((?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*)([\"'`])" + escapeRe(info.spec) + "\\2");
422
+ out = out.replace(decl, `$1$2file://@@APPDIR@@/${bundlePath(info.abs)}$2`);
423
+ }
424
+ // 3. import.meta.glob → createGlob bound to the staged dir.
425
+ out = out.replace(GLOB_RE, (m) => { needsGlob = true; return m.replace(GLOB_CALLEE_RE, "__ddGlob"); });
426
+ if (REQUIRE_GLOB_RE.test(out)) {
427
+ needsGlob = true;
428
+ out = out.replace(REQUIRE_GLOB_RE, "({ createGlob: __ddCreateGlob })");
429
+ }
430
+ // 4. import.meta.dir/.url/__dirname → module's staged dir under
431
+ // dd-assets (real FS).
432
+ const metaOut = out
433
+ .replace(META_DIR_RE, "__ddModDir")
434
+ .replace(META_URL_RE, "__ddModUrl")
435
+ .replace(/(?<![.\w$])__dirname\b/g, "__ddModDir");
436
+ if (metaOut !== out) needsAppDir = true;
437
+ if (metaOut !== out || needsGlob || needsAppDir) {
438
+ out =
439
+ `import { dirname as __ddDirname } from "node:path";\n` +
440
+ `import { pathToFileURL as __ddP2F } from "node:url";\n` +
441
+ `const ${APPDIR} = __ddDirname(process.execPath);\n` +
442
+ `const __ddModDir = ${APPDIR} + "/dd-assets/" + ${JSON.stringify(stagedDir === "." ? "" : stagedDir + "/")}.replace(/\\/$/, "");\n` +
443
+ `const __ddModUrl = __ddP2F(__ddModDir + "/" + ${JSON.stringify(modBase)}).href;\n` +
444
+ (needsGlob
445
+ ? `import { createGlob as __ddCreateGlob } from "@downdraft/engine/platform/glob-polyfill";\n` +
446
+ // Lazy — eager construction runs at module-init time and can
447
+ // hit a TDZ on consts declared later in the same module
448
+ // (glob-polyfill.ts itself matches REQUIRE_GLOB_RE via a
449
+ // comment and crashed exactly this way).
450
+ `const __ddGlob = (p, o) => __ddCreateGlob(__ddModDir)(p, o);\n`
451
+ : "") +
452
+ metaOut;
453
+ }
454
+ // @@APPDIR@@ sentinel → appdir expression (post meta rewrite).
455
+ out = out.replaceAll("@@APPDIR@@", `" + ${APPDIR} + "`);
456
+ if (eagerPrologue) out = eagerPrologue + out;
457
+ return out === src ? undefined : { contents: out, loader };
458
+ });
459
+ },
460
+ });
461
+
462
+ // ── Scan pass: discover Worker entrypoints to a fixpoint ──
463
+
464
+ const scanOutdir = mkdtempSync(join(tmpdir(), "dd-scan-"));
465
+ let prevCount = -1;
466
+ while (workerEntries.size !== prevCount) {
467
+ prevCount = workerEntries.size;
468
+ const scan = await Bun.build({
469
+ entrypoints: [resolve(entry), ...workerEntries],
470
+ target: "node",
471
+ plugins: [makeEmitPlugin(true)],
472
+ splitting: false,
473
+ sourcemap: "none",
474
+ minify: false,
475
+ define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
476
+ outdir: scanOutdir,
477
+ external: ["koffi", "bun:ffi", "bun:test", "bun"],
478
+ });
479
+ if (!scan.success) {
480
+ scan.logs.forEach((msg) => { console.error(msg); });
481
+ process.exit(1);
482
+ }
483
+ }
484
+
485
+ const entrypoints = [resolve(entry), ...workerEntries];
486
+ if (workerEntries.size) {
487
+ console.log(`workers: ${[...workerEntries].map((p) => p.split("/").pop()).join(", ")}`);
488
+ }
489
+
490
+ // ── Stage engine + game-native libs into <appdir>/native ──
491
+
492
+ const libExt = platKey.startsWith("win32") ? ".dll" : platKey.startsWith("darwin") ? ".dylib" : ".so";
493
+
494
+ async function pkgRoot(name) {
495
+ try {
496
+ const { createRequire } = await import("node:module");
497
+ const req = createRequire(resolve(entry));
498
+ return dirname(req.resolve(`${name}/package.json`));
499
+ } catch { return null; }
500
+ }
501
+
502
+ async function stageEngineLibs(nativeDir) {
503
+ mkdirSync(nativeDir, { recursive: true });
504
+ // Same probe order as package-native.mjs — platform dir, installed
505
+ // platform-native, per-target npm platform package.
506
+ const platPkg = await pkgRoot("@downdraft/platform-native");
507
+ const platNpm = await pkgRoot(`@downdraft/native-${platKey}`);
508
+ const engineRoot = await pkgRoot("@downdraft/engine");
509
+ const platLibCandidates = [...new Set([
510
+ join(repoRoot, "packages/platform-native/native", platKey),
511
+ platPkg && join(platPkg, "native", platKey),
512
+ platNpm && join(platNpm, "lib"),
513
+ ].filter(Boolean))];
514
+ const platStageDir = platLibCandidates.find((d) => existsSync(d));
515
+ if (platStageDir) {
516
+ for (const f of readdirSync(platStageDir)) {
517
+ if (f.endsWith(libExt)) copyFileSync(join(platStageDir, f), join(nativeDir, f));
518
+ }
519
+ } else {
520
+ throw new Error(`no libdowndraft_platform build found for ${platKey} — searched: ${platLibCandidates.join(", ")}`);
521
+ }
522
+ // Engine cdylibs via the shared crate registry (same candidates as
523
+ // package-native.mjs).
524
+ let CRATES = [];
525
+ try {
526
+ ({ CRATES } = await import(fileURLToPath(new URL("../../../scripts/native-crates.mjs", import.meta.url))));
527
+ } catch { /* published package — probed via node resolution below */ }
528
+ const libFileName = (n) => platKey.startsWith("win32") ? `${n}.dll`
529
+ : platKey.startsWith("darwin") ? `lib${n}.dylib` : `lib${n}.so`;
530
+ for (const crate of CRATES.filter((c) => c.pkg !== "downdraft-platform")) {
531
+ const file = libFileName(crate.lib);
532
+ const pkgRel = crate.dir.replace(/^packages\/engine\//, "");
533
+ const candidates = [
534
+ join(repoRoot, crate.dest, platKey, file),
535
+ join(repoRoot, crate.dest, file),
536
+ join(repoRoot, crate.dir, "target", "release", file),
537
+ join(repoRoot, crate.dir, file),
538
+ ...(engineRoot ? [
539
+ join(engineRoot, pkgRel, "dist", platKey, file),
540
+ join(engineRoot, pkgRel, "dist", file),
541
+ ] : []),
542
+ ];
543
+ for (const p of candidates) {
544
+ if (existsSync(p)) { copyFileSync(p, join(nativeDir, file)); break; }
545
+ }
546
+ }
547
+ }
548
+
549
+ /** Game-owned native artifacts: <game>/native/dist/<plat>/ → appdir/native/.
550
+ * Covers .so cdylibs AND .node napi addons (all three desktop runtimes can
551
+ * load napi). The convention game-side loaders resolve — see
552
+ * games/<name>/src/tests/native/native-bridge-lib.ts. */
553
+ function stageGameNative(nativeDir) {
554
+ const dist = join(gameDir, "native", "dist", platKey);
555
+ if (!existsSync(dist)) return;
556
+ mkdirSync(nativeDir, { recursive: true });
557
+ for (const f of readdirSync(dist)) {
558
+ copyFileSync(join(dist, f), join(nativeDir, f));
559
+ console.log(` game-native: ${f}`);
560
+ }
561
+ }
562
+
563
+ // ── Runtime dispatch: build the appdir ──
564
+
565
+ const nativeDir = join(appdir, "native");
566
+
567
+ if (runtime === "bun") {
568
+ // Delegate to package-native.mjs — outfile lands at <appdir>/<exe> and its
569
+ // native/ + dd-assets/ siblings land at <appdir>/ exactly.
570
+ const nativeScript = fileURLToPath(new URL("./package-native.mjs", import.meta.url));
571
+ run("bun", [
572
+ nativeScript,
573
+ "--target=linux",
574
+ `--mode=${mode}`,
575
+ `--product-name=${productName}`,
576
+ `--product-version=${version}`,
577
+ ...(retainMcp ? ["--mcp"] : []),
578
+ resolve(entry),
579
+ join(appdir, exeName),
580
+ ], { cwd: repoRoot });
581
+ } else {
582
+ // Emit the bundle tree into <appdir>/bundle/.
583
+ const bundleDir = join(appdir, "bundle");
584
+ mkdirSync(bundleDir, { recursive: true });
585
+ const buildResult = await Bun.build({
586
+ entrypoints,
587
+ target: "node",
588
+ root: repoRoot,
589
+ naming: { entry: "[dir]/[name].mjs" },
590
+ plugins: [makeEmitPlugin(false)],
591
+ splitting: false,
592
+ sourcemap: "none",
593
+ minify: mode === "prod",
594
+ define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
595
+ external: ["koffi", "bun:ffi", "bun:test", "bun"],
596
+ outdir: bundleDir,
597
+ });
598
+ if (!buildResult.success) {
599
+ buildResult.logs.forEach((msg) => { console.error(msg); });
600
+ process.exit(1);
601
+ }
602
+
603
+ // Entry shim + type:module marker (same pattern as package-mobile).
604
+ const entryRel = bundlePath(resolve(entry));
605
+ writeFileSync(join(bundleDir, "index.js"), `await import("./${entryRel}");\n`);
606
+ writeFileSync(join(bundleDir, "package.json"), JSON.stringify({ type: "module" }) + "\n");
607
+
608
+ // koffi + platform prebuild — node's FFI fallback needs real files.
609
+ if (runtime === "node") {
610
+ const { createRequire } = await import("node:module");
611
+ const gameRequire = createRequire(resolve(entry));
612
+ let koffiRoot = null;
613
+ try { koffiRoot = dirname(gameRequire.resolve("koffi")); } catch { /* absent */ }
614
+ if (!koffiRoot) {
615
+ console.warn("[package-desktop] koffi not resolvable — FFI will fail under the node runtime");
616
+ } else {
617
+ const nm = join(bundleDir, "node_modules");
618
+ cpSync(koffiRoot, join(nm, "koffi"), { recursive: true });
619
+ const koffiVer = JSON.parse(readFileSync(join(koffiRoot, "package.json"), "utf-8")).version;
620
+ const prebuild = `@koromix/koffi-${platKey}`;
621
+ let src = join(dirname(koffiRoot), prebuild);
622
+ if (!existsSync(join(src, "package.json"))) src = null;
623
+ // bun's .bun store exposes packages through symlinks — cpSync chokes
624
+ // on the link itself (ERR_FS_CP_EINVAL), so copy the real dir.
625
+ if (src) src = realpathSync(src);
626
+ if (!src) src = await fetchKoffiPrebuild(prebuild, koffiVer);
627
+ if (!src) {
628
+ console.warn(`[package-desktop] ${prebuild}@${koffiVer} unavailable — FFI will fail`);
629
+ } else {
630
+ cpSync(src, join(nm, "@koromix", basename(prebuild)), { recursive: true });
631
+ }
632
+ }
633
+ }
634
+
635
+ // Runtime binary + launcher. The runtime binary sits at the appdir root so
636
+ // dirname(process.execPath) anchors native/ + dd-assets/ + bundle/.
637
+ const rtBin = flagValue(`${runtime}-bin`) ?? runOut("sh", ["-c", `command -v ${runtime}`]);
638
+ if (!rtBin) throw new Error(`${runtime} not found on PATH — pass --${runtime}-bin=<path>`);
639
+ copyFileSync(realpathSync(rtBin), join(appdir, runtime));
640
+ chmodSync(join(appdir, runtime), 0o755);
641
+
642
+ const rtArgs = runtime === "deno" ? "run -A --no-config" : "";
643
+ writeFileSync(join(appdir, exeName),
644
+ `#!/bin/sh\n` +
645
+ `# ${productName} — ${runtime} runtime launcher (package-desktop.mjs)\n` +
646
+ `SELF="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")"\n` +
647
+ `DIR="$(dirname "$SELF")"\n` +
648
+ `exec "$DIR/${runtime}" ${rtArgs} "$DIR/bundle/index.js" "$@"\n`);
649
+ chmodSync(join(appdir, exeName), 0o755);
650
+
651
+ // Engine cdylibs (bun's path stages these inside package-native).
652
+ await stageEngineLibs(nativeDir);
653
+ }
654
+
655
+ // Game-native artifacts (.so + .node) → appdir/native/.
656
+ stageGameNative(nativeDir);
657
+
658
+ // Follow relative imports inside staged script assets — execArgv loader
659
+ // files (wgsl-loader.mjs, worker-bootstrap.mjs) are staged raw, not
660
+ // bundled, so their own module deps never enter the graph otherwise.
661
+ {
662
+ const ASSET_IMPORT_RE = /(?:from|import|register)\s*\(?\s*(["'`])(\.[^"'`]+)\1/g;
663
+ let grew = true;
664
+ while (grew) {
665
+ grew = false;
666
+ for (const f of [...assetFiles]) {
667
+ if (!/\.(mjs|cjs|js)$/.test(f)) continue;
668
+ let src;
669
+ try { src = readFileSync(f, "utf-8"); } catch { continue; }
670
+ for (const m of src.matchAll(ASSET_IMPORT_RE)) {
671
+ const p = resolve(dirname(f), m[2]);
672
+ if (existsSync(p) && statSync(p).isFile() && !assetFiles.has(p)) {
673
+ assetFiles.add(p);
674
+ grew = true;
675
+ }
676
+ }
677
+ }
678
+ }
679
+ }
680
+
681
+ // dd-assets — the emit runtimes need them staged; the bun path already staged
682
+ // them inside package-native (assetFiles is populated by the scan pass here
683
+ // either way, and re-copying identical files is harmless but skipped).
684
+ if (runtime !== "bun") {
685
+ const assetRoot = join(appdir, "dd-assets");
686
+ let stagedAssets = 0;
687
+ for (const f of assetFiles.values()) {
688
+ const dest = join(assetRoot, assetRel(f));
689
+ mkdirSync(dirname(dest), { recursive: true });
690
+ copyFileSync(f, dest);
691
+ stagedAssets++;
692
+ }
693
+ console.log(`staged: ${nativeDir ? readdirSync(nativeDir).filter((f) => f.endsWith(libExt)).length : 0} native libs, ${stagedAssets} assets`);
694
+ }
695
+
696
+ // build.files — electron-builder-style extra files into the appdir root.
697
+ for (const f of Array.isArray(build.files) ? build.files : []) {
698
+ const src = resolve(gameDir, typeof f === "string" ? f : f.from);
699
+ const dest = join(appdir, typeof f === "string" ? basename(f) : (f.to ?? basename(f.from)));
700
+ if (!existsSync(src)) { console.warn(`[package-desktop] files entry missing: ${src}`); continue; }
701
+ cpSync(src, dest, { recursive: true });
702
+ }
703
+
704
+ // ── Shared format assets: .desktop + icons + metainfo ──
705
+
706
+ function desktopFile(iconName = exeName) {
707
+ return (
708
+ `[Desktop Entry]\n` +
709
+ `Type=Application\n` +
710
+ `Name=${productName}\n` +
711
+ `Comment=${synopsis}\n` +
712
+ `Exec=${exeName}\n` +
713
+ `Icon=${iconName}\n` +
714
+ `Terminal=false\n` +
715
+ `Categories=${category}\n`);
716
+ }
717
+
718
+ function metainfoXml() {
719
+ return (
720
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
721
+ `<component type="desktop-application">\n` +
722
+ ` <id>${appId}</id>\n` +
723
+ ` <name>${productName}</name>\n` +
724
+ ` <summary>${synopsis}</summary>\n` +
725
+ ` <launchable type="desktop-id">${appId}.desktop</launchable>\n` +
726
+ `</component>\n`);
727
+ }
728
+
729
+ /** Resize the icon into hicolor sizes via jimp (already a cli dep); falls
730
+ * back to copying the source verbatim at 256px when jimp can't load it. */
731
+ async function iconSet(destDir) {
732
+ const sizes = [48, 128, 256];
733
+ const written = {};
734
+ let img = null;
735
+ try {
736
+ const { Jimp } = await import("jimp");
737
+ img = await Jimp.read(iconPath);
738
+ } catch { /* unreadable — verbatim fallback below */ }
739
+ for (const s of sizes) {
740
+ const dir = join(destDir, `${s}x${s}`, "apps");
741
+ mkdirSync(dir, { recursive: true });
742
+ const dest = join(dir, `${exeName}.png`);
743
+ try {
744
+ if (!img) throw new Error("no decoder");
745
+ await img.clone().resize({ w: s, h: s }).write(dest);
746
+ } catch {
747
+ copyFileSync(iconPath, dest);
748
+ }
749
+ written[s] = dest;
750
+ }
751
+ return written;
752
+ }
753
+
754
+ const artifacts = [];
755
+ const pkgLabel = productName.replace(/\s+/g, "-");
756
+
757
+ // ── Format: dir — the appdir itself ──
758
+
759
+ if (formats.includes("dir")) {
760
+ artifacts.push(`dir ${appdir}`);
761
+ }
762
+
763
+ // ── Format: deb ──
764
+
765
+ if (formats.includes("deb")) {
766
+ const debCfg = build.deb ?? {};
767
+ const stage = mkdtempSync(join(tmpdir(), "dd-deb-"));
768
+ const root = join(stage, "pkg");
769
+ const libDir = join(root, "usr/lib", exeName);
770
+ mkdirSync(libDir, { recursive: true });
771
+ cpSync(appdir, libDir, { recursive: true, verbatimSymlinks: true });
772
+
773
+ mkdirSync(join(root, "usr/bin"), { recursive: true });
774
+ symlinkSync(`../lib/${exeName}/${exeName}`, join(root, "usr/bin", exeName));
775
+
776
+ mkdirSync(join(root, "usr/share/applications"), { recursive: true });
777
+ writeFileSync(join(root, "usr/share/applications", `${appId}.desktop`), desktopFile());
778
+ if (iconPath) await iconSet(join(root, "usr/share/icons/hicolor"));
779
+ mkdirSync(join(root, "usr/share/metainfo"), { recursive: true });
780
+ writeFileSync(join(root, "usr/share/metainfo", `${appId}.metainfo.xml`), metainfoXml());
781
+
782
+ const sizeKb = parseInt(runOut("du", ["-sk", libDir])?.split("\t")[0] ?? "0", 10);
783
+ const depends = (debCfg.depends ?? ["libc6", "libstdc++6", "libvulkan1"]).join(", ");
784
+ mkdirSync(join(root, "DEBIAN"), { recursive: true });
785
+ writeFileSync(join(root, "DEBIAN/control"),
786
+ `Package: ${exeName}\n` +
787
+ `Version: ${version}\n` +
788
+ `Section: ${debCfg.section ?? "games"}\n` +
789
+ `Priority: ${debCfg.priority ?? "optional"}\n` +
790
+ `Architecture: amd64\n` +
791
+ `Installed-Size: ${sizeKb}\n` +
792
+ `Maintainer: ${maintainer}\n` +
793
+ `Depends: ${depends}\n` +
794
+ `Description: ${synopsis}\n` +
795
+ ` ${description.replace(/\n/g, "\n ")}\n`);
796
+
797
+ const out = join(outdir, `${exeName}_${version}+${runtime}_amd64.deb`);
798
+ run("dpkg-deb", ["--build", "--root-owner-group", root, out]);
799
+ artifacts.push(`deb ${out}`);
800
+ }
801
+
802
+ // ── Format: appimage ──
803
+
804
+ if (formats.includes("appimage")) {
805
+ const stage = mkdtempSync(join(tmpdir(), "dd-appimage-"));
806
+ const aDir = join(stage, "AppDir");
807
+ mkdirSync(aDir, { recursive: true });
808
+ cpSync(appdir, aDir, { recursive: true, verbatimSymlinks: true });
809
+
810
+ // AppRun — the entrypoint the AppImage runtime execs. Symlink to the exe;
811
+ // the launcher's readlink -f resolves it to the real file at AppDir root.
812
+ symlinkSync(exeName, join(aDir, "AppRun"));
813
+ writeFileSync(join(aDir, `${exeName}.desktop`), desktopFile());
814
+ if (iconPath) {
815
+ copyFileSync(iconPath, join(aDir, `${exeName}.png`));
816
+ symlinkSync(`${exeName}.png`, join(aDir, ".DirIcon"));
817
+ }
818
+
819
+ const tool = await appimagetool();
820
+ const out = join(outdir, `${pkgLabel}-${version}-linux-${runtime}.AppImage`);
821
+ run(tool, [aDir, out], {
822
+ env: {
823
+ ...process.env,
824
+ ARCH: "x86_64",
825
+ // appimagetool is itself an AppImage — extract+run without FUSE.
826
+ APPIMAGE_EXTRACT_AND_RUN: "1",
827
+ },
828
+ });
829
+ chmodSync(out, 0o755);
830
+ artifacts.push(`appimage ${out}`);
831
+ }
832
+
833
+ /** Locate or download appimagetool into the tool cache. */
834
+ async function appimagetool() {
835
+ const cacheDir = join(process.env.XDG_CACHE_HOME ?? join(process.env.HOME, ".cache"), "downdraft", "tools");
836
+ const cached = join(cacheDir, "appimagetool-x86_64");
837
+ if (existsSync(cached)) return cached;
838
+ const url = "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage";
839
+ console.log(`[package-desktop] fetching appimagetool → ${cached}`);
840
+ mkdirSync(cacheDir, { recursive: true });
841
+ const res = await fetch(url, { redirect: "follow" });
842
+ if (!res.ok) throw new Error(`appimagetool download failed: ${res.status}`);
843
+ writeFileSync(cached, Buffer.from(await res.arrayBuffer()));
844
+ chmodSync(cached, 0o755);
845
+ return cached;
846
+ }
847
+
848
+ // ── Format: flatpak ──
849
+
850
+ if (formats.includes("flatpak")) {
851
+ const fpCfg = build.flatpak ?? {};
852
+ const stage = mkdtempSync(join(tmpdir(), "dd-flatpak-"));
853
+ const buildDir = join(stage, "build");
854
+ const repoDir = join(stage, "repo");
855
+
856
+ const desktopFilePath = join(stage, `${appId}.desktop`);
857
+ writeFileSync(desktopFilePath, desktopFile(appId));
858
+ // Flatpak only exports icons whose basename matches the app-id.
859
+ const iconStage = join(stage, `${appId}.png`);
860
+ if (iconPath) copyFileSync(iconPath, iconStage);
861
+
862
+ const finishArgs = fpCfg.finishArgs ?? [
863
+ "--socket=wayland", "--socket=fallback-x11", "--share=ipc",
864
+ "--device=dri", "--socket=pulseaudio",
865
+ "--filesystem=home", "--share=network",
866
+ ];
867
+ const manifest = {
868
+ "app-id": appId,
869
+ "runtime": fpCfg.runtime ?? "org.freedesktop.Platform",
870
+ "runtime-version": fpCfg.runtimeVersion ?? "25.08",
871
+ "sdk": fpCfg.sdk ?? "org.freedesktop.Sdk",
872
+ "command": exeName,
873
+ "separate-locales": false,
874
+ "finish-args": finishArgs,
875
+ "modules": [{
876
+ "name": exeName,
877
+ "buildsystem": "simple",
878
+ // A `bun build --compile` binary keeps its embedded bundle appended
879
+ // past the ELF sections — strip(1) rewrites the file and silently
880
+ // drops it, turning the app into a bare `bun` CLI. Disable it.
881
+ "build-options": { "strip": false, "no-debuginfo": true },
882
+ "sources": [
883
+ { "type": "dir", "path": appdir, "dest": "appdir" },
884
+ { "type": "file", "path": desktopFilePath },
885
+ ...(iconPath ? [{ "type": "file", "path": iconStage }] : []),
886
+ ],
887
+ "build-commands": [
888
+ `mkdir -p /app/lib/${exeName} /app/bin`,
889
+ `cp -a appdir/. /app/lib/${exeName}/`,
890
+ `ln -s /app/lib/${exeName}/${exeName} /app/bin/${exeName}`,
891
+ `install -Dm644 ${basename(desktopFilePath)} /app/share/applications/${appId}.desktop`,
892
+ ...(iconPath ? [`install -Dm644 ${appId}.png /app/share/icons/hicolor/256x256/apps/${appId}.png`] : []),
893
+ ],
894
+ }],
895
+ };
896
+ const manifestPath = join(stage, `${appId}.json`);
897
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
898
+
899
+ if (!runOut("sh", ["-c", "command -v flatpak-builder"])) {
900
+ throw new Error("flatpak-builder not installed — required for the flatpak format");
901
+ }
902
+ // --state-dir must share the target's filesystem (flatpak-builder hard-
903
+ // links source files); the default cwd-based dir fails when TMPDIR is a
904
+ // different mount than the checkout.
905
+ run("flatpak-builder", ["--repo", repoDir, "--state-dir", join(stage, "state"), "--force-clean", buildDir, manifestPath]);
906
+ const out = join(outdir, `${pkgLabel}-${version}-linux-${runtime}.flatpak`);
907
+ run("flatpak", ["build-bundle", repoDir, out, appId]);
908
+ artifacts.push(`flatpak ${out}`);
909
+ }
910
+
911
+ // ── koffi prebuild fetch (same mechanism as package-mobile) ──
912
+
913
+ async function fetchKoffiPrebuild(pkgName, ver) {
914
+ try {
915
+ const meta = await (await fetch(
916
+ `https://registry.npmjs.org/${pkgName.replace("/", "%2F")}`)).json();
917
+ const tarball = meta?.versions?.[ver]?.dist?.tarball ?? meta?.versions?.[meta["dist-tags"]?.latest]?.dist?.tarball;
918
+ if (!tarball) return null;
919
+ const dir = mkdtempSync(join(tmpdir(), "dd-koffi-"));
920
+ const tgz = join(dir, "pkg.tgz");
921
+ writeFileSync(tgz, Buffer.from(await (await fetch(tarball)).arrayBuffer()));
922
+ run("tar", ["-xzf", tgz, "-C", dir]);
923
+ return join(dir, "package");
924
+ } catch (e) {
925
+ console.warn(`[package-desktop] koffi prebuild fetch failed: ${e?.message ?? e}`);
926
+ return null;
927
+ }
928
+ }
929
+
930
+ console.log(`[package-desktop] done (${runtime}):`);
931
+ for (const a of artifacts) console.log(` ${a}`);
@@ -146,7 +146,7 @@ const GLOB_RE = new RegExp(IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([
146
146
  const GLOB_EAGER_RE = new RegExp(
147
147
  IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1\s*,\s*(\{[^)]*\beager\s*:\s*true[^)]*\})\s*\)`, "g");
148
148
  const STAGE_GLOB_RE = new RegExp(
149
- `(?:${IMPORT_META.source}\\.glob|_glob)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
149
+ `(?:${IMPORT_META.source}\\.glob|_glob|globAssets)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
150
150
  const STAGE_READ_RE = new RegExp(
151
151
  String.raw`(?:resolve|join)\(\s*(?:__dirname|${IMPORT_META.source}\.(?:dir|dirname|url))\s*,\s*(["'\`])([^"'\`]+)\1`, "g");
152
152
  const META_DIR_RE = new RegExp(IMPORT_META.source + String.raw`\.(?:dir|dirname)\b`, "g");
@@ -414,7 +414,11 @@ const makePlugin = (collectWorkers) => ({
414
414
  `const __ddModUrl = __ddP2F(__ddModDir + "/" + ${JSON.stringify(modBase)}).href;\n` +
415
415
  (needsGlob
416
416
  ? `import { createGlob as __ddCreateGlob } from "@downdraft/engine/platform/glob-polyfill";\n` +
417
- `const __ddGlob = __ddCreateGlob(__ddModDir);\n`
417
+ // Lazy — eager construction runs at module-init time and can
418
+ // hit a TDZ on consts declared later in the same module
419
+ // (glob-polyfill.ts itself matches REQUIRE_GLOB_RE via a
420
+ // comment).
421
+ `const __ddGlob = (p, o) => __ddCreateGlob(__ddModDir)(p, o);\n`
418
422
  : "") +
419
423
  metaOut;
420
424
  }
@@ -577,6 +581,31 @@ if (envPairs.length) {
577
581
  writeFileSync(join(bundleDir, "dd-env.txt"), envPairs.join("\n") + "\n");
578
582
  }
579
583
 
584
+ // Game-native addons — convention: <game>/native/dist/android-<arch>/*.node
585
+ // are napi modules the game built itself. .node files aren't jniLibs (the OS
586
+ // only installs lib*.so), so they ride inside the extracted bundle instead:
587
+ // the shell unpacks them to a real path under DOWNDRAFT_BUNDLE_DIR/native/
588
+ // and require()/process.dlopen resolves napi_* against the RTLD_GLOBAL
589
+ // libnode (the -Wl,-z,global link flag exists for exactly this).
590
+ // Staged BEFORE manifest.txt so the extractor's file list covers them.
591
+ // (.so siblings are handled per-ABI in the native-libs section below.)
592
+ {
593
+ let staged = 0;
594
+ for (const abi of abis) {
595
+ const platDir = join(gameDir, "native", "dist",
596
+ `android-${abi.startsWith("arm64") ? "arm64" : "x64"}`);
597
+ if (!existsSync(platDir)) continue;
598
+ const dest = join(bundleDir, "native", `android-${abi.startsWith("arm64") ? "arm64" : "x64"}`);
599
+ for (const f of readdirSync(platDir)) {
600
+ if (!f.endsWith(".node")) continue;
601
+ mkdirSync(dest, { recursive: true });
602
+ copyFileSync(join(platDir, f), join(dest, f));
603
+ staged++;
604
+ }
605
+ }
606
+ if (staged) console.log(`[package-mobile] staged ${staged} game-native addon(s) into bundle/native/`);
607
+ }
608
+
580
609
  // manifest.txt — the shell's extractor follows this verbatim (AAssetDir
581
610
  // can't enumerate subdirectories). aapt2 silently drops asset paths with
582
611
  // dot-prefixed segments (.bun, .gitignore), so each line is
@@ -645,11 +674,11 @@ for (const abi of abis) {
645
674
  ? join(monorepoRoot, `packages/node-mobile/build/dist/android-${nodeFlavor}`, abi, "libnode.so")
646
675
  : null,
647
676
  join(monorepoRoot, "packages/node-mobile/build/dist/android", abi, "libnode.so"),
648
- join(monorepoRoot, `packages/node-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/lib/libnode.so`),
677
+ join(monorepoRoot, `packages/native-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/lib/libnode.so`),
649
678
  ].filter(Boolean);
650
679
  let libnode = libnodeCandidates.find(existsSync);
651
680
  if (!libnode) {
652
- try { libnode = join(dirname(gameRequire.resolve(`@downdraft/node-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/package.json`)), "lib/libnode.so"); } catch { /* absent */ }
681
+ try { libnode = join(dirname(gameRequire.resolve(`@downdraft/native-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/package.json`)), "lib/libnode.so"); } catch { /* absent */ }
653
682
  }
654
683
  if (!libnode || !existsSync(libnode)) {
655
684
  throw new Error(`libnode.so for ${abi} not found — build via packages/node-mobile/scripts/build-android.mjs`);
@@ -703,6 +732,20 @@ for (const abi of abis) {
703
732
  if (existsSync(src)) copyFileSync(src, join(libDir, file));
704
733
  }
705
734
 
735
+ // Game-native cdylibs — <game>/native/dist/android-<arch>/lib*.so ride in
736
+ // jniLibs like the engine crates: the OS installs them to the app's native
737
+ // lib dir, where koffi/Deno.dlopen resolves them by bare soname (same
738
+ // mechanism lib-paths.ts's android branch relies on).
739
+ const gamePlatDir = join(gameDir, "native", "dist", nodePlat);
740
+ if (existsSync(gamePlatDir)) {
741
+ for (const f of readdirSync(gamePlatDir)) {
742
+ if (f.startsWith("lib") && f.endsWith(".so")) {
743
+ copyFileSync(join(gamePlatDir, f), join(libDir, f));
744
+ console.log(`[package-mobile] staged game-native ${f} → lib/${abi}/`);
745
+ }
746
+ }
747
+ }
748
+
706
749
  // Strip staged .so's — debug info and the symtab never map at runtime, so
707
750
  // this is pure APK/install size (libnode alone sheds ~25MB). Crash
708
751
  // symbolication still works: .dynsym and unwind info are preserved.
@@ -110,7 +110,7 @@ const GLOB_RE = new RegExp(IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([
110
110
  // Staging scan covers all glob call forms: import.meta.glob("pat"), the
111
111
  // common _glob("pat") alias, and createGlob(modDir)("pat").
112
112
  const STAGE_GLOB_RE = new RegExp(
113
- `(?:${IMPORT_META.source}\\.glob|_glob)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
113
+ `(?:${IMPORT_META.source}\\.glob|_glob|globAssets)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
114
114
  // Module-relative file reads: resolve(__dirname, "./zstd.wasm"),
115
115
  // join(import.meta.dir, "data.bin") — stage the referenced file.
116
116
  const STAGE_READ_RE = new RegExp(
@@ -371,7 +371,11 @@ const makePlugin = (collectWorkers) => ({
371
371
  `const __ddModUrl = __ddP2F(__ddModDir + "/" + ${JSON.stringify(modBase)}).href;\n` +
372
372
  (needsGlob
373
373
  ? `import { createGlob as __ddCreateGlob } from "@downdraft/engine/platform/glob-polyfill";\n` +
374
- `const __ddGlob = __ddCreateGlob(__ddModDir);\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`
375
379
  : "") +
376
380
  metaOut;
377
381
  }
@@ -440,8 +444,8 @@ result.outputs.forEach((out) => {
440
444
  // Metadata precedence: --product-name/--product-version/--icon flags, then
441
445
  // the game's package.json (productName / build.productName, version,
442
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
443
448
  if (target.exe) {
444
- const gameDir = dirname(dirname(resolve(entry))); // src/native-entry.ts → game root
445
449
  let pkg = {};
446
450
  try { pkg = JSON.parse(readFileSync(join(gameDir, "package.json"), "utf-8")); } catch { /* standalone entry */ }
447
451
  const titleize = (s) => String(s).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
@@ -603,6 +607,24 @@ engineCrates.forEach((crate) => {
603
607
  }
604
608
  });
605
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
+
606
628
  // Assets referenced via ?url / new URL / import.meta.glob.
607
629
  const assetRoot = join(outdir, "dd-assets");
608
630
  let stagedAssets = 0;
package/src/release.ts CHANGED
@@ -56,6 +56,8 @@ interface ReleaseArgs {
56
56
  out: string;
57
57
  retainMcp: boolean;
58
58
  verbose: boolean;
59
+ runtime: string | null;
60
+ format: string | null;
59
61
  }
60
62
 
61
63
  // ---------------------------------------------------------------------------
@@ -84,10 +86,6 @@ function parseReleaseArgs(args: string[]): ReleaseArgs {
84
86
  if (buildOnly) stage = "build";
85
87
  else if (skipBuild) stage = "package";
86
88
 
87
- if (parsed.flags.format) {
88
- log.warn("release", "--format is gone — native packaging produces a single binary + staged tree.");
89
- }
90
-
91
89
  return {
92
90
  game,
93
91
  games,
@@ -103,6 +101,8 @@ function parseReleaseArgs(args: string[]): ReleaseArgs {
103
101
  out: (parsed.flags.out as string) || "release",
104
102
  retainMcp: parsed.flags.mcp as boolean,
105
103
  verbose: parsed.flags.verbose as boolean,
104
+ runtime: (parsed.flags.runtime as string) || null,
105
+ format: (parsed.flags.format as string) || null,
106
106
  };
107
107
  }
108
108
 
@@ -173,20 +173,27 @@ function classifyTargets(target: string): string[] {
173
173
  // Game info resolution
174
174
  // ---------------------------------------------------------------------------
175
175
 
176
- function getGameInfo(gameDir: string, game: string): { productName: string; appId: string; version: string } {
176
+ interface GameInfo {
177
+ productName: string;
178
+ appId: string;
179
+ version: string;
180
+ /** The electron-builder-style `build` block from the game's package.json. */
181
+ build: Record<string, any>;
182
+ }
183
+
184
+ function getGameInfo(gameDir: string, game: string): GameInfo {
177
185
  const pkgPath = resolve(gameDir, "package.json");
178
- if (!existsSync(pkgPath)) {
179
- return { productName: game, appId: `com.downdraft.${game}`, version: "0.0.0" };
180
- }
186
+ const fallback: GameInfo = { productName: game, appId: `com.downdraft.${game}`, version: "0.0.0", build: {} };
187
+ if (!existsSync(pkgPath)) return fallback;
181
188
  try {
182
189
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
183
190
  const productName = pkg.productName ?? pkg.build?.productName ??
184
191
  String(pkg.name ?? game).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
185
192
  const appId = pkg.build?.appId ?? `com.downdraft.${game}`;
186
193
  const version = pkg.version ?? "0.0.0";
187
- return { productName, appId, version };
194
+ return { productName, appId, version, build: pkg.build ?? {} };
188
195
  } catch {
189
- return { productName: game, appId: `com.downdraft.${game}`, version: "0.0.0" };
196
+ return fallback;
190
197
  }
191
198
  }
192
199
 
@@ -205,42 +212,93 @@ function packageNative(
205
212
  gameDir: string,
206
213
  targets: string[],
207
214
  opts: ReleaseArgs,
208
- info: { productName: string; appId: string; version: string },
215
+ info: GameInfo,
209
216
  ): boolean {
210
217
  const entry = resolve(gameDir, "src/native-entry.ts");
211
218
  if (!existsSync(entry)) {
212
219
  log.error("release:package", `No src/native-entry.ts in ${gameDir} — the game has no native entry.`);
213
220
  return false;
214
221
  }
215
- // The packaging script ships inside @downdraft/cli (files: ["scripts"]) so
216
- // standalone game repos resolve it through the installed package, not the
217
- // engine monorepo.
218
- const script = fileURLToPath(new URL("../scripts/package-native.mjs", import.meta.url));
222
+ // The packaging scripts ship inside @downdraft/cli (files: ["scripts"]) so
223
+ // standalone game repos resolve them through the installed package, not
224
+ // the engine monorepo.
225
+ const scriptNative = fileURLToPath(new URL("../scripts/package-native.mjs", import.meta.url));
226
+ const scriptDesktop = fileURLToPath(new URL("../scripts/package-desktop.mjs", import.meta.url));
219
227
  // cwd is the relativization root for staged assets — the monorepo root
220
228
  // when present (engine sources relativize cleanly), else the game dir.
221
229
  const cwd = findMonorepoRoot(gameDir) ?? gameDir;
230
+
231
+ // Runtime matrix: --runtime flag → build.runtime in package.json → bun.
232
+ // "all" expands to the full matrix (one appdir per runtime).
233
+ const RUNTIMES = ["bun", "node", "deno"];
234
+ const requested = opts.runtime ?? info.build.runtime ?? "bun";
235
+ const runtimes = requested === "all" ? RUNTIMES : [requested];
236
+ for (const r of runtimes) {
237
+ if (!RUNTIMES.includes(r)) {
238
+ log.error("release:package", `Unknown runtime "${r}" — expected bun, node, deno, or all.`);
239
+ return false;
240
+ }
241
+ }
242
+ // Non-bun runtimes are linux-only today — the emit-tree packaging hasn't
243
+ // been wired for win/mac.
244
+ const nonBun = runtimes.filter((r) => r !== "bun");
245
+ if (nonBun.length && targets.some((t) => t !== "linux")) {
246
+ log.warn("release:package", `--runtime=${nonBun.join(",")} is linux-only today — win/mac targets still embed bun.`);
247
+ }
248
+
222
249
  let ok = true;
223
250
  targets.forEach((target) => {
224
- const outfile = resolve(opts.out, `${game}-${target}`);
225
- const argv = [
226
- script,
227
- `--target=${target}`,
228
- `--mode=${opts.mode}`,
229
- `--product-name=${info.productName}`,
230
- `--product-version=${info.version}`,
231
- ...(opts.retainMcp ? ["--mcp"] : []),
232
- entry,
233
- outfile,
234
- ];
235
- log.info("release:package", `bun ${argv.map((a) => basename(a)).join(" ")}`);
236
- const result = spawnSync("bun", argv, {
237
- cwd,
238
- stdio: "inherit",
239
- env: process.env,
240
- });
241
- if (result.status !== 0) {
242
- log.error("release:package", `Native packaging failed for ${game} (${target})`);
243
- ok = false;
251
+ for (const runtime of runtimes) {
252
+ if (target === "linux") {
253
+ // Linux: package-desktop.mjs owns the format matrix (dir, deb,
254
+ // appimage, flatpak). Formats come from --format → build.linux.target
255
+ // → "dir".
256
+ const format = opts.format
257
+ ?? (Array.isArray(info.build.linux?.target) ? info.build.linux.target.join(",") : info.build.linux?.target)
258
+ ?? "dir";
259
+ const argv = [
260
+ scriptDesktop,
261
+ `--runtime=${runtime}`,
262
+ `--format=${format}`,
263
+ `--mode=${opts.mode}`,
264
+ `--product-name=${info.productName}`,
265
+ `--product-version=${info.version}`,
266
+ `--app-id=${info.appId}`,
267
+ ...(opts.retainMcp ? ["--mcp"] : []),
268
+ entry,
269
+ resolve(opts.out),
270
+ ];
271
+ log.info("release:package", `bun package-desktop.mjs --runtime=${runtime} --format=${format} ${basename(entry)}`);
272
+ const result = spawnSync("bun", argv, { cwd, stdio: "inherit", env: process.env });
273
+ if (result.status !== 0) {
274
+ log.error("release:package", `Packaging failed for ${game} (${target}/${runtime})`);
275
+ ok = false;
276
+ }
277
+ continue;
278
+ }
279
+ // win/mac: single-binary bun compile via package-native.mjs.
280
+ if (runtime !== "bun") continue;
281
+ const outfile = resolve(opts.out, `${game}-${target}`);
282
+ const argv = [
283
+ scriptNative,
284
+ `--target=${target}`,
285
+ `--mode=${opts.mode}`,
286
+ `--product-name=${info.productName}`,
287
+ `--product-version=${info.version}`,
288
+ ...(opts.retainMcp ? ["--mcp"] : []),
289
+ entry,
290
+ outfile,
291
+ ];
292
+ log.info("release:package", `bun ${argv.map((a) => basename(a)).join(" ")}`);
293
+ const result = spawnSync("bun", argv, {
294
+ cwd,
295
+ stdio: "inherit",
296
+ env: process.env,
297
+ });
298
+ if (result.status !== 0) {
299
+ log.error("release:package", `Native packaging failed for ${game} (${target})`);
300
+ ok = false;
301
+ }
244
302
  }
245
303
  });
246
304
  return ok;
@@ -259,7 +317,7 @@ function packageAndroid(
259
317
  game: string,
260
318
  gameDir: string,
261
319
  opts: ReleaseArgs,
262
- info: { productName: string; appId: string; version: string },
320
+ info: GameInfo,
263
321
  ): boolean {
264
322
  const entry = resolve(gameDir, "src/native-entry.ts");
265
323
  if (!existsSync(entry)) {
@@ -340,8 +398,8 @@ export async function release(args: string[]): Promise<void> {
340
398
 
341
399
  for (let _i = 0, _it = games, _n = _it.length; _i < _n; _i++) { const game = _it[_i];
342
400
  const gameDir = resolveGameDir(game) ?? resolve(monorepoRoot ?? process.cwd(), "games", game);
343
- const { productName, appId, version } = getGameInfo(gameDir, game);
344
- log.info("release", `Game: ${game} | Product: "${productName}" | AppId: ${appId} | v${version}`);
401
+ const info = getGameInfo(gameDir, game);
402
+ log.info("release", `Game: ${game} | Product: "${info.productName}" | AppId: ${info.appId} | v${info.version}`);
345
403
  log.info("release", "");
346
404
 
347
405
  // Native packaging is a single compile step — build/package/release
@@ -349,8 +407,8 @@ export async function release(args: string[]): Promise<void> {
349
407
  const isAndroid = targets.every((t) => MOBILE_TARGETS.has(t));
350
408
  log.info("release", `[package] Compiling ${game} (${targets.join(", ")})...`);
351
409
  const ok = isAndroid
352
- ? packageAndroid(game, gameDir, opts, { productName, appId, version })
353
- : packageNative(game, gameDir, targets, opts, { productName, appId, version });
410
+ ? packageAndroid(game, gameDir, opts, info)
411
+ : packageNative(game, gameDir, targets, opts, info);
354
412
  if (!ok) {
355
413
  log.error("release", `Packaging failed for ${game} — skipping.`);
356
414
  fail = 1;
package/src/usage.ts CHANGED
@@ -46,7 +46,8 @@ export const COMMANDS: CommandEntry[] = [
46
46
  { name: "game", alias: "g", type: "string", description: "Game to release (games/<game>). For multiple games, use --games. If omitted, infers from the current directory, or defaults to the only game in games/." },
47
47
  { name: "games", type: "string", description: "Comma-separated game names (e.g. sandjongg,to-the-ocean)" },
48
48
  { name: "target", alias: "t", type: "string", default: "all", enum: [...RELEASE_TARGETS], description: "Target platform(s): win, linux, mac, android, or all (android can't mix with desktop targets)" },
49
- { name: "format", type: "string", description: "Deprecated no-op — native packaging produces a single binary + staged tree" },
49
+ { name: "format", type: "string", description: "Package format(s), comma-separated: dir, deb, appimage, flatpak (linux only; default: build.linux.target in package.json, else dir)" },
50
+ { name: "runtime", type: "string", enum: ["bun", "node", "deno", "all"], description: "JS runtime embedded in the package: bun (compiled binary), node, deno, or all (default: build.runtime in package.json, else bun). node/deno are linux-only today" },
50
51
  { name: "stage", type: "string", default: "release", enum: [...RELEASE_STAGES], description: "Stage: build, package, or release (all compile the native binary)" },
51
52
  { name: "mode", type: "string", default: "prod", enum: [...BUILD_MODES], description: "Build mode" },
52
53
  { name: "abi", type: "string", default: "arm64-v8a", description: "Android ABI(s): arm64-v8a, x86_64, or all (android target only)" },