@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
package/src/release.ts ADDED
@@ -0,0 +1,432 @@
1
+ // ============================================================================
2
+ // draft release — native build + package pipeline
3
+ // ============================================================================
4
+ //
5
+ // Replaces the separate `build`, `dist`, `export`, `mobile`, and
6
+ // `build-games` commands with a single pipeline:
7
+ //
8
+ // draft release [--game=<name>] [--target=<csv>]
9
+ // (auto-detects the game from cwd or games/ when --game is omitted)
10
+ // [--stage=<build|package|release>] [--games=<csv>]
11
+ // [--mode=<dev|debug|prod>] [--out=<dir>]
12
+ // [--mcp] [--verbose]
13
+ //
14
+ // Stages (all stages produce the same artifact — a compiled Bun binary plus
15
+ // the staged native/ + dd-assets/ tree — via scripts/package-native.mjs):
16
+ // build = compile only
17
+ // package = package an existing build (for native: same compile step)
18
+ // release = build + package + collect to release/ (default)
19
+ //
20
+ // Targets: win | linux | mac | all
21
+ //
22
+ // The old commands remain as backward-compat aliases that delegate here with
23
+ // a deprecation warning.
24
+ //
25
+
26
+ import { createLogger } from "@downdraft/engine";
27
+ import { spawnSync } from "node:child_process";
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { basename, resolve } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { parseArgs, print, renderHelp } from "./args";
32
+ import { formatGamesList, listGames } from "./list-games";
33
+ import { findGameDirUpward, findMonorepoRoot, resolveGameDir } from "./paths";
34
+ import { getCommand } from "./usage";
35
+
36
+ const log = createLogger();
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Types
40
+ // ---------------------------------------------------------------------------
41
+
42
+ type Stage = "build" | "package" | "release";
43
+
44
+ interface ReleaseArgs {
45
+ game: string | null;
46
+ games: string[];
47
+ target: string;
48
+ stage: Stage;
49
+ mode: string;
50
+ abi: string;
51
+ minSdk: string | null;
52
+ nodeFlavor: string | null;
53
+ libs: string | null;
54
+ excludeLibs: string | null;
55
+ noStrip: boolean;
56
+ out: string;
57
+ retainMcp: boolean;
58
+ verbose: boolean;
59
+ runtime: string | null;
60
+ format: string | null;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Arg parsing
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function parseReleaseArgs(args: string[]): ReleaseArgs {
68
+ const entry = getCommand("release")!;
69
+ const parsed = parseArgs(args, entry.schema);
70
+ if (parsed.help) {
71
+ print(renderHelp(entry.usage, entry.schema));
72
+ process.exit(0);
73
+ }
74
+
75
+ const game = (parsed.flags.game as string) || null;
76
+ const gamesFlag = (parsed.flags.games as string) || "";
77
+ const games = gamesFlag
78
+ ? gamesFlag.split(",").map((s) => s.trim()).filter(Boolean)
79
+ : game ? [game] : [];
80
+
81
+ const stageFlag = (parsed.flags.stage as string) || "release";
82
+ // --skip-build and --build-only are convenience aliases for --stage.
83
+ const skipBuild = parsed.flags["skip-build"] as boolean;
84
+ const buildOnly = parsed.flags["build-only"] as boolean;
85
+ let stage: Stage = stageFlag as Stage;
86
+ if (buildOnly) stage = "build";
87
+ else if (skipBuild) stage = "package";
88
+
89
+ return {
90
+ game,
91
+ games,
92
+ target: parsed.flags.target as string,
93
+ stage,
94
+ mode: (parsed.flags.mode as string) || "prod",
95
+ abi: (parsed.flags.abi as string) || "arm64-v8a",
96
+ minSdk: (parsed.flags["min-sdk"] as string) || null,
97
+ nodeFlavor: (parsed.flags["node-flavor"] as string) || null,
98
+ libs: (parsed.flags.libs as string) || null,
99
+ excludeLibs: (parsed.flags["exclude-libs"] as string) || null,
100
+ noStrip: parsed.flags["no-strip"] as boolean,
101
+ out: (parsed.flags.out as string) || "release",
102
+ retainMcp: parsed.flags.mcp as boolean,
103
+ verbose: parsed.flags.verbose as boolean,
104
+ runtime: (parsed.flags.runtime as string) || null,
105
+ format: (parsed.flags.format as string) || null,
106
+ };
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Game resolution
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Resolve the game(s) to release when neither --game nor --games is given.
115
+ *
116
+ * Resolution order:
117
+ * 1. Explicit flags (--game / --games) — handled by the caller before this.
118
+ * 2. cwd inference — walk up from `process.cwd()` looking for
119
+ * `downdraft.config.json` or `src/native-entry.ts`. Lets games run
120
+ * `draft release` from their own directory (the scaffolded
121
+ * `package.json` sets `"release": "draft release"`).
122
+ * 3. Single-game fallback — if `games/` contains exactly one game, use it.
123
+ *
124
+ * Returns the detected game name, or `null` if none could be determined.
125
+ */
126
+ function detectGame(monorepoRoot: string | null): string | null {
127
+ // 2. Cwd inference: walk up looking for the game-dir markers.
128
+ const upward = findGameDirUpward();
129
+ if (upward) return basename(upward);
130
+
131
+ // 3. Single-game fallback (monorepo only).
132
+ if (monorepoRoot) {
133
+ const games = listGames(monorepoRoot);
134
+ if (games.length === 1) {
135
+ log.info("release", `No game specified — defaulting to the only game found: ${games[0]}`);
136
+ return games[0];
137
+ }
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Target classification
145
+ // ---------------------------------------------------------------------------
146
+
147
+ const DESKTOP_TARGETS = new Set(["win", "linux", "mac"]);
148
+ const MOBILE_TARGETS = new Set(["android"]);
149
+
150
+ function classifyTargets(target: string): string[] {
151
+ if (target === "all") {
152
+ return ["win", "linux", "mac"];
153
+ }
154
+ const parts = target.split(",").map((s) => s.trim()).filter(Boolean);
155
+ parts.forEach((t) => {
156
+ if (t === "ios") {
157
+ log.error("release", `iOS is not implemented yet — the native mobile port currently supports android only.`);
158
+ process.exit(1);
159
+ }
160
+ if (!DESKTOP_TARGETS.has(t) && !MOBILE_TARGETS.has(t)) {
161
+ log.error("release", `Unknown target: ${t}. Use win, linux, mac, android, or all.`);
162
+ process.exit(1);
163
+ }
164
+ });
165
+ if (parts.some((t) => MOBILE_TARGETS.has(t)) && parts.some((t) => DESKTOP_TARGETS.has(t))) {
166
+ log.error("release", `Can't mix mobile and desktop targets in one release — run android separately.`);
167
+ process.exit(1);
168
+ }
169
+ return parts;
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Game info resolution
174
+ // ---------------------------------------------------------------------------
175
+
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 {
185
+ const pkgPath = resolve(gameDir, "package.json");
186
+ const fallback: GameInfo = { productName: game, appId: `com.downdraft.${game}`, version: "0.0.0", build: {} };
187
+ if (!existsSync(pkgPath)) return fallback;
188
+ try {
189
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
190
+ const productName = pkg.productName ?? pkg.build?.productName ??
191
+ String(pkg.name ?? game).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
192
+ const appId = pkg.build?.appId ?? `com.downdraft.${game}`;
193
+ const version = pkg.version ?? "0.0.0";
194
+ return { productName, appId, version, build: pkg.build ?? {} };
195
+ } catch {
196
+ return fallback;
197
+ }
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Native packaging — scripts/package-native.mjs
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Compile the game's native entry into a standalone Bun binary plus the
206
+ * staged runtime tree (<out>/<game>-<target> + sibling native/ + dd-assets/).
207
+ * One compile covers both the build and package stages — the binary IS the
208
+ * artifact.
209
+ */
210
+ function packageNative(
211
+ game: string,
212
+ gameDir: string,
213
+ targets: string[],
214
+ opts: ReleaseArgs,
215
+ info: GameInfo,
216
+ ): boolean {
217
+ const entry = resolve(gameDir, "src/native-entry.ts");
218
+ if (!existsSync(entry)) {
219
+ log.error("release:package", `No src/native-entry.ts in ${gameDir} — the game has no native entry.`);
220
+ return false;
221
+ }
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));
227
+ // cwd is the relativization root for staged assets — the monorepo root
228
+ // when present (engine sources relativize cleanly), else the game dir.
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
+
249
+ let ok = true;
250
+ targets.forEach((target) => {
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
+ }
302
+ }
303
+ });
304
+ return ok;
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Android packaging — scripts/package-mobile.mjs
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /**
312
+ * Bundle the game + native libs + embedded libnode into an APK. Single
313
+ * target ("android") produces lib/<abi>/ entries for --abi (default
314
+ * arm64-v8a). Unlike the desktop path the artifact is the APK itself.
315
+ */
316
+ function packageAndroid(
317
+ game: string,
318
+ gameDir: string,
319
+ opts: ReleaseArgs,
320
+ info: GameInfo,
321
+ ): boolean {
322
+ const entry = resolve(gameDir, "src/native-entry.ts");
323
+ if (!existsSync(entry)) {
324
+ log.error("release:package", `No src/native-entry.ts in ${gameDir} — the game has no native entry.`);
325
+ return false;
326
+ }
327
+ const script = fileURLToPath(new URL("../scripts/package-mobile.mjs", import.meta.url));
328
+ const cwd = findMonorepoRoot(gameDir) ?? gameDir;
329
+ const outfile = resolve(opts.out, `${game}-android.apk`);
330
+ const argv = [
331
+ script,
332
+ `--mode=${opts.mode}`,
333
+ `--abi=${opts.abi}`,
334
+ `--app-id=${info.appId}`,
335
+ `--app-name=${info.productName}`,
336
+ `--version=${info.version}`,
337
+ ...(opts.minSdk ? [`--min-sdk=${opts.minSdk}`] : []),
338
+ ...(opts.nodeFlavor ? [`--node-flavor=${opts.nodeFlavor}`] : []),
339
+ ...(opts.libs ? [`--libs=${opts.libs}`] : []),
340
+ ...(opts.excludeLibs ? [`--exclude-libs=${opts.excludeLibs}`] : []),
341
+ ...(opts.noStrip ? ["--no-strip"] : []),
342
+ ...(opts.retainMcp ? ["--mcp"] : []),
343
+ entry,
344
+ outfile,
345
+ ];
346
+ log.info("release:package", `bun ${argv.map((a) => basename(a)).join(" ")}`);
347
+ const result = spawnSync("bun", argv, { cwd, stdio: "inherit", env: process.env });
348
+ if (result.status !== 0) {
349
+ log.error("release:package", `Android packaging failed for ${game}`);
350
+ return false;
351
+ }
352
+ return true;
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Main entry point
357
+ // ---------------------------------------------------------------------------
358
+
359
+ export async function release(args: string[]): Promise<void> {
360
+ const opts = parseReleaseArgs(args);
361
+ // Monorepo root when running inside the engine repo; null for standalone
362
+ // game repos (the game directory itself is the project root).
363
+ const monorepoRoot = findMonorepoRoot();
364
+
365
+ // Auto-detect the game when neither --game nor --games is given.
366
+ let games = opts.games;
367
+ if (games.length === 0) {
368
+ const detected = detectGame(monorepoRoot);
369
+ if (detected) {
370
+ games = [detected];
371
+ opts.game = detected;
372
+ } else {
373
+ log.error("release", "No game specified and none could be auto-detected.");
374
+ log.error("release", "Run \"draft release\" from a game directory, or use \"--game <name>\" / \"--games <csv>\" from the engine root.");
375
+ log.error("release", "Available games:");
376
+ print(formatGamesList(monorepoRoot ?? process.cwd()));
377
+ process.exit(1);
378
+ }
379
+ }
380
+
381
+ const targets = classifyTargets(opts.target);
382
+
383
+ log.info("release", `
384
+ ╔══════════════════════════════════════════╗
385
+ ║ DownDraft Engine — Release ║
386
+ ╚══════════════════════════════════════════╝
387
+ `);
388
+
389
+ log.info("release", ` Game(s): ${games.join(", ")}`);
390
+ log.info("release", ` Target: ${opts.target}`);
391
+ log.info("release", ` Stage: ${opts.stage}`);
392
+ log.info("release", ` Mode: ${opts.mode}`);
393
+ log.info("release", ` Output: ${opts.out}`);
394
+ if (opts.verbose) log.info("release", ` Verbose: on`);
395
+ log.info("release", "");
396
+
397
+ let fail = 0;
398
+
399
+ for (let _i = 0, _it = games, _n = _it.length; _i < _n; _i++) { const game = _it[_i];
400
+ const gameDir = resolveGameDir(game) ?? resolve(monorepoRoot ?? process.cwd(), "games", game);
401
+ const info = getGameInfo(gameDir, game);
402
+ log.info("release", `Game: ${game} | Product: "${info.productName}" | AppId: ${info.appId} | v${info.version}`);
403
+ log.info("release", "");
404
+
405
+ // Native packaging is a single compile step — build/package/release
406
+ // all produce the same artifact.
407
+ const isAndroid = targets.every((t) => MOBILE_TARGETS.has(t));
408
+ log.info("release", `[package] Compiling ${game} (${targets.join(", ")})...`);
409
+ const ok = isAndroid
410
+ ? packageAndroid(game, gameDir, opts, info)
411
+ : packageNative(game, gameDir, targets, opts, info);
412
+ if (!ok) {
413
+ log.error("release", `Packaging failed for ${game} — skipping.`);
414
+ fail = 1;
415
+ continue;
416
+ }
417
+
418
+ log.info("release", `${game} done.`);
419
+ log.info("release", "");
420
+ }
421
+
422
+ // Summary
423
+ log.info("release", "");
424
+ if (fail) {
425
+ log.error("release", "One or more games failed — see output above.");
426
+ } else {
427
+ log.info("release", "All selected games processed successfully.");
428
+ }
429
+ log.info("release", `Artifacts: ${opts.out}/`);
430
+
431
+ process.exit(fail);
432
+ }