@downdraft/cli 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +11 -0
  2. package/package.json +36 -3
  3. package/scripts/package-mobile.mjs +779 -0
  4. package/scripts/package-native.mjs +616 -0
  5. package/src/args.ts +333 -0
  6. package/src/assets.ts +523 -0
  7. package/src/blob-store-s3.ts +145 -0
  8. package/src/build-games.ts +45 -0
  9. package/src/build.ts +40 -0
  10. package/src/bun-env.d.ts +6 -0
  11. package/src/debug.ts +56 -0
  12. package/src/dev.ts +231 -0
  13. package/src/dist.ts +36 -0
  14. package/src/export.ts +38 -0
  15. package/src/index.ts +131 -0
  16. package/src/list-games.ts +37 -0
  17. package/src/mcp.ts +320 -0
  18. package/src/new.ts +63 -0
  19. package/src/paths.ts +101 -0
  20. package/src/plugin-command.ts +214 -0
  21. package/src/process-utils.ts +179 -0
  22. package/src/release.ts +374 -0
  23. package/src/scaffold-plugin.ts +280 -0
  24. package/src/scaffold.ts +207 -0
  25. package/src/test.ts +187 -0
  26. package/src/usage.ts +255 -0
  27. package/templates/_shared/ai-companion/devin-config.eta +11 -0
  28. package/templates/_shared/ai-companion/engine-prompt.eta +155 -0
  29. package/templates/_shared/ai-companion/logging-rule.eta +3 -0
  30. package/templates/_shared/ai-companion/loops-rule.eta +3 -0
  31. package/templates/_shared/partials/config-json-fields.eta +3 -0
  32. package/templates/_shared/partials/dev-deps.eta +8 -0
  33. package/templates/_shared/partials/package-json-fields.eta +12 -0
  34. package/templates/_shared/partials/tsconfig.eta +16 -0
  35. package/templates/_shared/partials/vscode-extensions.eta +11 -0
  36. package/templates/_shared/partials/vscode-settings.eta +29 -0
  37. package/templates/_shared/partials/vscode-tasks.eta +93 -0
  38. package/templates/full/.gitignore.eta +5 -0
  39. package/templates/full/.vscode/extensions.json.eta +1 -0
  40. package/templates/full/.vscode/settings.json.eta +1 -0
  41. package/templates/full/.vscode/tasks.json.eta +1 -0
  42. package/templates/full/README.md.eta +45 -0
  43. package/templates/full/assets/shaders/.eta +0 -0
  44. package/templates/full/downdraft.config.json.eta +10 -0
  45. package/templates/full/package.json.eta +9 -0
  46. package/templates/full/src/entities/.eta +0 -0
  47. package/templates/full/src/game-module.ts.eta +30 -0
  48. package/templates/full/src/native-entry.ts.eta +14 -0
  49. package/templates/full/src/sim-host.ts.eta +19 -0
  50. package/templates/full/src/sim-worker.ts.eta +99 -0
  51. package/templates/full/src/systems/.eta +0 -0
  52. package/templates/full/tsconfig.json.eta +1 -0
  53. package/templates/gamemodule/README.md.eta +32 -0
  54. package/templates/gamemodule/downdraft.config.json.eta +3 -0
  55. package/templates/gamemodule/package.json.eta +9 -0
  56. package/templates/gamemodule/src/game-module.ts.eta +63 -0
  57. package/templates/gamemodule/src/native-entry.ts.eta +14 -0
  58. package/templates/gamemodule/src/sim-host.ts.eta +25 -0
  59. package/templates/gamemodule/src/sim-worker.ts.eta +24 -0
  60. package/templates/gamemodule/tsconfig.json.eta +1 -0
  61. package/templates/minimal/.gitignore.eta +5 -0
  62. package/templates/minimal/.vscode/extensions.json.eta +1 -0
  63. package/templates/minimal/.vscode/settings.json.eta +1 -0
  64. package/templates/minimal/.vscode/tasks.json.eta +1 -0
  65. package/templates/minimal/README.md.eta +32 -0
  66. package/templates/minimal/downdraft.config.json.eta +3 -0
  67. package/templates/minimal/package.json.eta +9 -0
  68. package/templates/minimal/src/game-module.ts.eta +44 -0
  69. package/templates/minimal/src/native-entry.ts.eta +14 -0
  70. package/templates/minimal/tsconfig.json.eta +1 -0
  71. package/templates/physics/.gitignore.eta +5 -0
  72. package/templates/physics/.vscode/extensions.json.eta +1 -0
  73. package/templates/physics/.vscode/settings.json.eta +1 -0
  74. package/templates/physics/.vscode/tasks.json.eta +1 -0
  75. package/templates/physics/README.md.eta +36 -0
  76. package/templates/physics/downdraft.config.json.eta +6 -0
  77. package/templates/physics/package.json.eta +9 -0
  78. package/templates/physics/src/game-module.ts.eta +29 -0
  79. package/templates/physics/src/native-entry.ts.eta +14 -0
  80. package/templates/physics/src/sim-host.ts.eta +19 -0
  81. package/templates/physics/src/sim-worker.ts.eta +155 -0
  82. package/templates/physics/tsconfig.json.eta +1 -0
@@ -0,0 +1,207 @@
1
+ import { confinePath, createLogger } from "@downdraft/engine";
2
+ import { Eta } from "eta";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
4
+ import { basename, dirname, join, relative, resolve } from "path";
5
+
6
+ const log = createLogger();
7
+
8
+ const TEMPLATES_DIR = resolve(import.meta.dir, "../templates");
9
+
10
+ export interface ScaffoldOptions {
11
+ template: string;
12
+ name: string;
13
+ description?: string;
14
+ author?: string;
15
+ version: string;
16
+ aiCompanion: boolean;
17
+ force: boolean;
18
+ }
19
+
20
+ export function listTemplates(): string[] {
21
+ if (!existsSync(TEMPLATES_DIR)) return [];
22
+ const entries = readdirSync(TEMPLATES_DIR);
23
+ const templates: string[] = [];
24
+ for (let i = 0; i < entries.length; i++) {
25
+ const entry = entries[i];
26
+ if (entry.startsWith("_")) continue;
27
+ const fullPath = join(TEMPLATES_DIR, entry);
28
+ if (statSync(fullPath).isDirectory()) {
29
+ templates.push(entry);
30
+ }
31
+ }
32
+ return templates;
33
+ }
34
+
35
+ function createEtaInstance(templateDir: string): Eta {
36
+ const eta = new Eta({
37
+ views: templateDir,
38
+ autoEscape: false,
39
+ useWith: true,
40
+ functionHeader: `
41
+ const kebabCase = (s) => s.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\\s+/g, '-').toLowerCase();
42
+ const pascalCase = (s) => s.replace(/(^|[-\\s])(.)/g, (_, __, c) => c.toUpperCase());
43
+ const camelCase = (s) => { const p = pascalCase(s); return p.charAt(0).toLowerCase() + p.slice(1); };
44
+ const titleCase = (s) => s.replace(/(^|[-\\s])(.)/g, (_, __, c) => ' ' + c.toUpperCase()).trim();
45
+ `,
46
+ });
47
+ return eta;
48
+ }
49
+
50
+ export async function scaffold(targetPath: string, opts: ScaffoldOptions): Promise<void> {
51
+ // Validate template name to prevent path traversal via the template parameter.
52
+ // Template names must be relative, non-traversing identifiers confined to the templates dir.
53
+ confinePath(TEMPLATES_DIR, opts.template);
54
+
55
+ const templateDir = join(TEMPLATES_DIR, opts.template);
56
+ if (!existsSync(templateDir)) {
57
+ log.error("scaffold", `Template "${opts.template}" not found at ${templateDir}`);
58
+ log.info("scaffold", `Available templates: ${listTemplates().join(", ")}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ // Validate the target path to prevent path traversal outside the current working directory.
63
+ const absTarget = confinePath(process.cwd(), targetPath);
64
+
65
+ if (existsSync(absTarget)) {
66
+ const entries = readdirSync(absTarget);
67
+ if (entries.length > 0 && !opts.force) {
68
+ log.error("scaffold", `Target directory "${absTarget}" is not empty. Use --force to override.`);
69
+ process.exit(1);
70
+ }
71
+ }
72
+
73
+ mkdirSync(absTarget, { recursive: true });
74
+
75
+ const eta = createEtaInstance(TEMPLATES_DIR);
76
+
77
+ const templateData = {
78
+ name: opts.name,
79
+ description: opts.description,
80
+ author: opts.author,
81
+ version: opts.version,
82
+ aiCompanion: opts.aiCompanion,
83
+ template: opts.template,
84
+ plugins: getPluginsForTemplate(opts.template),
85
+ };
86
+
87
+ log.info("scaffold", ` Template: ${opts.template}`);
88
+ log.info("scaffold", ` Name: ${opts.name}`);
89
+ log.info("scaffold", ` Version: ${opts.version}`);
90
+ if (opts.description) log.info("scaffold", ` Description: ${opts.description}`);
91
+ if (opts.author) log.info("scaffold", ` Author: ${opts.author}`);
92
+ log.info("scaffold", ` AI Companion: ${opts.aiCompanion ? "yes" : "no"}`);
93
+ log.info("scaffold", ` Target: ${absTarget}`);
94
+
95
+ const files = collectTemplateFiles(templateDir);
96
+ let createdCount = 0;
97
+
98
+ for (let i = 0; i < files.length; i++) {
99
+ const relPath = files[i];
100
+ const srcFile = join(templateDir, relPath);
101
+ const srcBasename = basename(relPath);
102
+ if (srcBasename === ".eta") {
103
+ const dirPath = join(absTarget, dirname(relPath));
104
+ mkdirSync(dirPath, { recursive: true });
105
+ continue;
106
+ }
107
+
108
+ const destRel = relPath.replace(/\.eta$/, "");
109
+ const destFile = join(absTarget, destRel);
110
+
111
+ mkdirSync(dirname(destFile), { recursive: true });
112
+
113
+ const content = readFileSync(srcFile, "utf-8");
114
+
115
+ let rendered: string;
116
+ try {
117
+ rendered = await eta.renderStringAsync(content, templateData);
118
+ } catch (err) {
119
+ log.error("scaffold", `Failed to render template: ${relPath}`);
120
+ throw err;
121
+ }
122
+
123
+ writeFileSync(destFile, rendered);
124
+ createdCount++;
125
+ }
126
+
127
+ if (opts.aiCompanion) {
128
+ const aiCompanionDir = join(TEMPLATES_DIR, "_shared", "ai-companion");
129
+ if (existsSync(aiCompanionDir)) {
130
+ const aiFiles = collectTemplateFiles(aiCompanionDir);
131
+ for (let i = 0; i < aiFiles.length; i++) {
132
+ const relPath = aiFiles[i];
133
+ const srcFile = join(aiCompanionDir, relPath);
134
+ const content = readFileSync(srcFile, "utf-8");
135
+
136
+ let rendered: string;
137
+ try {
138
+ rendered = await eta.renderStringAsync(content, templateData);
139
+ } catch (err) {
140
+ log.error("scaffold", `Failed to render AI companion template: ${relPath}`);
141
+ throw err;
142
+ }
143
+
144
+ const destMap: Record<string, string> = {
145
+ "devin-config.eta": ".devin/config.json",
146
+ "logging-rule.eta": ".devin/rules/logging.md",
147
+ "loops-rule.eta": ".devin/rules/loops.md",
148
+ "engine-prompt.eta": "engine-prompt.md",
149
+ };
150
+
151
+ const baseName = basename(relPath);
152
+ const destRel = destMap[baseName];
153
+ if (!destRel) continue;
154
+
155
+ const destFile = join(absTarget, destRel);
156
+ mkdirSync(dirname(destFile), { recursive: true });
157
+ writeFileSync(destFile, rendered);
158
+ createdCount++;
159
+ }
160
+ }
161
+ }
162
+
163
+ log.info("scaffold", `Created ${createdCount} files`);
164
+ log.info("scaffold", `Project scaffolded at: ${absTarget}`);
165
+ log.info("scaffold", ``);
166
+ log.info("scaffold", `Next steps:`);
167
+ log.info("scaffold", ` cd ${relative(process.cwd(), absTarget) || "."}`);
168
+ log.info("scaffold", ` bun install`);
169
+ log.info("scaffold", ` draft dev`);
170
+ }
171
+
172
+ function collectTemplateFiles(dir: string, base: string = dir): string[] {
173
+ const results: string[] = [];
174
+ if (!existsSync(dir)) return results;
175
+
176
+ const entries = readdirSync(dir);
177
+ for (let i = 0; i < entries.length; i++) {
178
+ const entry = entries[i];
179
+ const fullPath = join(dir, entry);
180
+ const stat = statSync(fullPath);
181
+ if (stat.isDirectory()) {
182
+ results.push(...collectTemplateFiles(fullPath, base));
183
+ } else {
184
+ const rel = relative(base, fullPath);
185
+ results.push(rel);
186
+ }
187
+ }
188
+ return results;
189
+ }
190
+
191
+ function getPluginsForTemplate(template: string): string[] {
192
+ switch (template) {
193
+ case "minimal":
194
+ return [];
195
+ case "physics":
196
+ return ["physics-rapier"];
197
+ case "full":
198
+ return ["physics-rapier", "water", "marching-cubes", "models", "devtools"];
199
+ case "gamemodule":
200
+ // GameModule-based scaffold — uses the declarative startGame() API.
201
+ // No plugins pre-wired; the developer adds EngineLibrary descriptors
202
+ // and feature plugins in their GameModule.
203
+ return [];
204
+ default:
205
+ return [];
206
+ }
207
+ }
package/src/test.ts ADDED
@@ -0,0 +1,187 @@
1
+ import { createLogger } from "@downdraft/engine";
2
+ import { spawn } from "child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { createServer } from "node:net";
5
+ import { basename, resolve } from "node:path";
6
+ import { parseArgs, print, renderHelp } from "./args";
7
+ import { findMonorepoRoot, resolveGameDir } from "./paths";
8
+ import { getCommand } from "./usage";
9
+
10
+ const log = createLogger();
11
+
12
+ type Renderer = "gpu" | "cpu";
13
+
14
+ /** Grab an OS-assigned free TCP port so e2e runs don't collide with a
15
+ * running dev instance on the old pinned port (9976). */
16
+ function findFreePort(): Promise<number> {
17
+ return new Promise((resolve, reject) => {
18
+ const srv = createServer();
19
+ srv.listen(0, "127.0.0.1", () => {
20
+ const addr = srv.address();
21
+ const port = typeof addr === "object" && addr ? addr.port : 0;
22
+ srv.close(() => (port ? resolve(port) : reject(new Error("no port assigned"))));
23
+ });
24
+ srv.on("error", reject);
25
+ });
26
+ }
27
+
28
+ interface TestArgs {
29
+ game: string;
30
+ mcpPort: number;
31
+ spec: string | null;
32
+ renderer: Renderer;
33
+ deterministic: boolean;
34
+ headed: boolean;
35
+ verbose: boolean;
36
+ }
37
+
38
+ function parseTestArgs(args: string[]): TestArgs {
39
+ const entry = getCommand("test")!;
40
+ const parsed = parseArgs(args, entry.schema);
41
+ if (parsed.help) {
42
+ print(renderHelp(entry.usage, entry.schema));
43
+ process.exit(0);
44
+ }
45
+ const port = parsed.flags.port as number;
46
+ return {
47
+ game: parsed.flags.game as string,
48
+ mcpPort: port,
49
+ spec: (parsed.flags.spec as string) || null,
50
+ renderer: parsed.flags.renderer as Renderer,
51
+ deterministic: !(parsed.flags["no-deterministic"] as boolean),
52
+ headed: parsed.flags.headed as boolean,
53
+ verbose: parsed.flags.verbose as boolean,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Check whether xvfb-run is available on the system.
59
+ */
60
+ function hasXvfb(): boolean {
61
+ try {
62
+ return existsSync("/usr/bin/xvfb-run") || existsSync("/usr/local/bin/xvfb-run");
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+
69
+
70
+ export async function runTest(args: string[]): Promise<void> {
71
+ const opts = parseTestArgs(args);
72
+ const monorepoRoot = findMonorepoRoot();
73
+ const gameDir = resolveGameDir(opts.game || undefined);
74
+
75
+ if (!opts.game && gameDir) {
76
+ opts.game = basename(gameDir);
77
+ }
78
+ if (!opts.game) {
79
+ log.error("test", `No game specified and none could be inferred from "${process.cwd()}".`);
80
+ log.error("test", `Run "draft test --game=<name>" or pass --spec=<path>.`);
81
+ process.exit(1);
82
+ }
83
+
84
+ // Default spec: <monorepo>/tests/e2e/<game>-smoke.spec.ts, or the standalone
85
+ // game's own tests/e2e dir. When neither exists, require --spec.
86
+ let specPath = opts.spec ? resolve(opts.spec) : null;
87
+ if (!specPath) {
88
+ const candidates = [
89
+ monorepoRoot ? resolve(monorepoRoot, "tests", "e2e", `${opts.game}-smoke.spec.ts`) : null,
90
+ gameDir ? resolve(gameDir, "tests", "e2e", `${opts.game}-smoke.spec.ts`) : null,
91
+ ].filter((p): p is string => !!p);
92
+ specPath = candidates.find((p) => existsSync(p)) ?? candidates[0];
93
+ }
94
+ if (!specPath || !existsSync(specPath)) {
95
+ log.error("test", `No test spec found for game "${opts.game}".`);
96
+ log.error("test", `Looked in: tests/e2e/${opts.game}-smoke.spec.ts`);
97
+ log.error("test", `Pass --spec=<path> to specify a spec file.`);
98
+ process.exit(1);
99
+ }
100
+
101
+ if (!opts.mcpPort) {
102
+ opts.mcpPort = await findFreePort();
103
+ }
104
+
105
+ log.info("test", `
106
+ ╔══════════════════════════════════════════╗
107
+ ║ DownDraft Engine — E2E Test Runner ║
108
+ ╚══════════════════════════════════════════╝
109
+ `);
110
+
111
+ log.info("test", ` Game: ${opts.game}`);
112
+ log.info("test", ` Spec: ${specPath}`);
113
+ log.info("test", ` MCP port: ${opts.mcpPort}`);
114
+ log.info("test", ` Renderer: ${opts.renderer === "cpu" ? "SwiftShader (software)" : "hardware GPU"}`);
115
+ log.info("test", ` Deterministic: ${opts.deterministic}`);
116
+ log.info("test", ` Headed: ${opts.headed}`);
117
+ if (opts.verbose) log.info("test", ` Verbose: on`);
118
+ log.info("test", "");
119
+
120
+ const env: Record<string, string> = {
121
+ ...process.env,
122
+ MCP_PORT: String(opts.mcpPort),
123
+ MCP_TIMEOUT_MS: "120000",
124
+ };
125
+
126
+ if (opts.renderer === "cpu") {
127
+ env.DOWNDRAFT_GPU = "swiftshader";
128
+ } else {
129
+ env.DOWNDRAFT_GPU = "hardware";
130
+ }
131
+ if (opts.deterministic) {
132
+ env.DOWNDRAFT_DETERMINISTIC = "1";
133
+ }
134
+ if (opts.headed) {
135
+ // Show the window instead of running headless.
136
+ env.DOWNDRAFT_HEADED = "1";
137
+ }
138
+
139
+ // If no display is available, wrap in xvfb-run (common in CI).
140
+ let cmd = "bun";
141
+ let cmdArgs = ["test", specPath];
142
+ const noDisplay = !env.DISPLAY;
143
+ if (noDisplay && hasXvfb()) {
144
+ log.info("test", "No DISPLAY detected — wrapping in xvfb-run");
145
+ cmd = "xvfb-run";
146
+ cmdArgs = ["-a", "--", "bun", "test", specPath];
147
+ } else if (noDisplay) {
148
+ log.warn("test", "No DISPLAY and xvfb-run not found — the native window may fail to start.");
149
+ log.warn("test", "Install xvfb with: sudo apt install xvfb");
150
+ }
151
+
152
+ const child = spawn(cmd, cmdArgs, {
153
+ cwd: monorepoRoot ?? gameDir ?? process.cwd(),
154
+ stdio: "inherit",
155
+ env,
156
+ });
157
+
158
+ const shutdown = () => {
159
+ try { child.kill("SIGINT"); } catch {}
160
+ setTimeout(() => {
161
+ try { process.kill(-child.pid!, "SIGKILL"); } catch {}
162
+ process.exit(130);
163
+ }, 2000);
164
+ };
165
+ process.on("SIGINT", shutdown);
166
+ process.on("SIGTERM", shutdown);
167
+
168
+ // Await the child process so `draft test` blocks until the spec (and the
169
+ // spawned game) fully finishes + cleans up. Without this, the async
170
+ // function resolves immediately after spawning while the game is still
171
+ // booting, and the next sequential e2e run starts concurrently — colliding
172
+ // on the MCP port.
173
+ const exitCode = await new Promise<number>((resolve) => {
174
+ child.on("exit", (code) => resolve(code ?? 1));
175
+ child.on("error", (err) => {
176
+ log.error("test", `Failed to spawn test process: ${err.message}`);
177
+ resolve(1);
178
+ });
179
+ });
180
+
181
+ if (exitCode === 0) {
182
+ log.info("test", "All tests passed.");
183
+ } else {
184
+ log.error("test", `Tests failed with exit code ${exitCode}.`);
185
+ }
186
+ process.exit(exitCode);
187
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,255 @@
1
+ // ============================================================================
2
+ // usage.ts — per-command usage strings + schema registry
3
+ // ============================================================================
4
+ //
5
+ // Single source of truth for every `draft` subcommand's synopsis, flags, and
6
+ // positionals. `index.ts` generates the top-level help from this registry;
7
+ // each command module imports its schema + usage string for `--help` output.
8
+ //
9
+
10
+ import type { CommandSchema } from "./args";
11
+
12
+ export interface CommandEntry {
13
+ /** Command name as typed on the CLI (e.g. `"new"`). */
14
+ name: string;
15
+ /** One-line synopsis shown after `Usage:`. */
16
+ usage: string;
17
+ /** Short description for the top-level command list. */
18
+ summary: string;
19
+ /** Argument schema (flags + positionals). */
20
+ schema: CommandSchema;
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Desktop target vocabulary (normalized): win | linux | mac | all
25
+ // Mobile target vocabulary: android | ios | all
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const DESKTOP_TARGETS = ["win", "linux", "mac", "all"] as const;
29
+ const BUILD_TARGETS = ["current", "win", "linux", "mac"] as const;
30
+ const RELEASE_TARGETS = ["win", "linux", "mac", "android", "all"] as const;
31
+ const RELEASE_STAGES = ["build", "package", "release"] as const;
32
+ const RENDERER_TARGETS = ["gpu", "cpu"] as const;
33
+ const BUILD_MODES = ["dev", "debug", "prod"] as const;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Command registry
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export const COMMANDS: CommandEntry[] = [
40
+ {
41
+ name: "release",
42
+ usage: "draft release [options]",
43
+ summary: "Native build + package pipeline — compiles the game to a standalone binary via scripts/package-native.mjs",
44
+ schema: {
45
+ flags: [
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
+ { name: "games", type: "string", description: "Comma-separated game names (e.g. sandjongg,to-the-ocean)" },
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" },
50
+ { name: "stage", type: "string", default: "release", enum: [...RELEASE_STAGES], description: "Stage: build, package, or release (all compile the native binary)" },
51
+ { name: "mode", type: "string", default: "prod", enum: [...BUILD_MODES], description: "Build mode" },
52
+ { name: "abi", type: "string", default: "arm64-v8a", description: "Android ABI(s): arm64-v8a, x86_64, or all (android target only)" },
53
+ { name: "min-sdk", type: "string", description: "Android minimum SDK (default 30; android target only)" },
54
+ { name: "node-flavor", type: "string", description: "Embedded Node build flavor: full (default) or lite — lite drops intl/inspector/sqlite/amaro (android target only)" },
55
+ { name: "libs", type: "string", description: "Force-include optional engine cdylibs, comma-separated (android target only)" },
56
+ { name: "exclude-libs", type: "string", description: "Exclude optional engine cdylibs, comma-separated (android target only)" },
57
+ { name: "no-strip", type: "boolean", description: "Keep debug info/symtab in packaged .so's for native debugging (android target only)" },
58
+ { name: "out", type: "string", default: "release", description: "Artifact output directory" },
59
+ { name: "skip-build", type: "boolean", description: "Alias for --stage=package" },
60
+ { name: "build-only", type: "boolean", description: "Alias for --stage=build" },
61
+ { name: "mcp", type: "boolean", description: "Retain the MCP automation endpoint in the packaged binary" },
62
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
63
+ ],
64
+ },
65
+ },
66
+ {
67
+ name: "new",
68
+ usage: "draft new [path] [options]",
69
+ summary: "Scaffold a new game project",
70
+ schema: {
71
+ positionals: [{ name: "path", description: "Target directory (default: current dir)" }],
72
+ flags: [
73
+ { name: "template", type: "string", default: "minimal", description: "Project template (minimal | physics | full | gamemodule)" },
74
+ { name: "name", type: "string", description: "Project name (defaults to directory basename)" },
75
+ { name: "description", type: "string", description: "Project description" },
76
+ { name: "author", type: "string", description: "Author name (written as { name } in package.json)" },
77
+ { name: "version", type: "string", default: "0.1.0", description: "Initial version" },
78
+ { name: "ai-companion", type: "boolean", description: "Scaffold .devin/ config + engine-prompt.md" },
79
+ { name: "force", type: "boolean", description: "Scaffold into a non-empty directory" },
80
+ { name: "list-templates", type: "boolean", description: "List available templates and exit" },
81
+ ],
82
+ },
83
+ },
84
+ {
85
+ name: "dev",
86
+ usage: "draft dev [options]",
87
+ summary: "Start the game on the native runtime (winit + wgpu; Bun, Node, or Deno host — see --runtime). Run from a game directory (cwd inference).",
88
+ schema: {
89
+ flags: [
90
+ { name: "entry", type: "string", description: "Game entrypoint file (defaults to src/native-entry.ts)" },
91
+ { name: "port", type: "number", description: "MCP HTTP port (default: auto-assign)" },
92
+ { name: "runtime", type: "string", enum: ["bun", "node", "deno"], description: "JS runtime hosting the dev shell (default: bun → node → deno auto-detect)" },
93
+ { name: "watch", type: "boolean", description: "Back-compat no-op — the HMR dev shell always watches" },
94
+ { name: "no-hmr", type: "boolean", description: "Disable HMR — spawn the entry directly (bun run), no dev shell" },
95
+ { name: "native", type: "boolean", description: "Back-compat alias — native is the default runtime" },
96
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
97
+ ],
98
+ },
99
+ },
100
+ {
101
+ name: "debug",
102
+ usage: "draft debug [path] [options]",
103
+ summary: "Run the engine in debug mode with profiling/visualization",
104
+ schema: {
105
+ positionals: [{ name: "path", description: "Project path (default: current dir)" }],
106
+ flags: [
107
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
108
+ { name: "no-devtools", type: "boolean", description: "Disable the devtools overlay (sets DOWNDRAFT_DISABLE_DEVTOOLS=1)" },
109
+ { name: "inspector", type: "boolean", description: "Enable the Node inspector (chrome://inspect)" },
110
+ ],
111
+ },
112
+ },
113
+ {
114
+ name: "build",
115
+ usage: "draft build [path] [options]",
116
+ summary: "Build a game for a target platform",
117
+ schema: {
118
+ positionals: [{ name: "path", description: "Project path (default: current dir)" }],
119
+ flags: [
120
+ { name: "game", alias: "g", type: "string", description: "Game to build (resolves games/<game>; overrides path)" },
121
+ { name: "target", type: "string", default: "current", enum: [...BUILD_TARGETS], description: "Target platform" },
122
+ { name: "mode", type: "string", default: "prod", enum: [...BUILD_MODES], description: "Build mode" },
123
+ { name: "out", type: "string", default: "dist", description: "Output directory" },
124
+ { name: "no-minify", type: "boolean", description: "Disable minification" },
125
+ { name: "sourcemap", type: "boolean", description: "Generate source maps (on by default in non-prod modes)" },
126
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
127
+ ],
128
+ },
129
+ },
130
+ {
131
+ name: "build-games",
132
+ usage: "draft build-games --games=<csv> --platforms=<csv>",
133
+ summary: "Deprecated alias for `draft release --games=<csv>` (VSCode task)",
134
+ schema: {
135
+ flags: [
136
+ { name: "games", type: "string", required: true, description: "Comma-separated game directory names (e.g. sandjongg)" },
137
+ { name: "platforms", type: "string", required: true, description: "Comma-separated desktop targets (win | linux | mac; legacy :format suffixes are ignored)" },
138
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
139
+ ],
140
+ },
141
+ },
142
+ {
143
+ name: "dist",
144
+ usage: "draft dist [options]",
145
+ summary: "Package a game for distribution (deprecated alias for `draft release`)",
146
+ schema: {
147
+ flags: [
148
+ { name: "game", alias: "g", type: "string", required: true, description: "Game to package (games/<game>)" },
149
+ { name: "target", alias: "t", type: "string", default: "all", enum: [...DESKTOP_TARGETS], description: "Target platform" },
150
+ { name: "config", alias: "c", type: "string", description: "Explicit path to a build.config.ts / config file" },
151
+ { name: "project-dir", type: "string", description: "Override the project directory (default: repo root)" },
152
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
153
+ ],
154
+ },
155
+ },
156
+ {
157
+ name: "export",
158
+ usage: "draft export [path] [options]",
159
+ summary: "Package a built game for distribution (per-platform launchers)",
160
+ schema: {
161
+ positionals: [{ name: "path", description: "Project path (default: current dir)" }],
162
+ flags: [
163
+ { name: "target", type: "string", default: "all", enum: [...DESKTOP_TARGETS], description: "Target platform" },
164
+ { name: "out", type: "string", default: "export", description: "Output directory" },
165
+ { name: "no-compress", type: "boolean", description: "Disable compression" },
166
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
167
+ ],
168
+ },
169
+ },
170
+ {
171
+ name: "assets",
172
+ usage: "draft assets <command> [project] [options]",
173
+ summary: "Manage remote asset packs (pull, push, list, init, add, add-store)",
174
+ schema: {
175
+ positionals: [
176
+ { name: "command", required: true, description: "init | add-store | add | pull | push | list" },
177
+ { name: "project", description: "Project path (default: current dir)" },
178
+ ],
179
+ flags: [
180
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
181
+ ],
182
+ },
183
+ },
184
+ {
185
+ name: "mcp",
186
+ usage: "draft mcp <action> [args] [options]",
187
+ summary: "Call MCP automation tools on a running game (instances | tools | call | screenshot | stdio | run)",
188
+ schema: {
189
+ positionals: [
190
+ { name: "action", required: true, description: "instances | tools | call | screenshot | stdio | run" },
191
+ { name: "args", variadic: true, description: "Action args: <tool> [json] | <file.png> | <script> [script-args]" },
192
+ ],
193
+ flags: [
194
+ { name: "app", type: "string", description: "Select instance by appId" },
195
+ { name: "pid", type: "number", description: "Select instance by PID" },
196
+ { name: "url", type: "string", description: "Explicit MCP HTTP URL (skips PID-file discovery)" },
197
+ { name: "token", type: "string", description: "Bearer token (auto-read from <pid>.token otherwise)" },
198
+ { name: "json", type: "boolean", description: "Print the raw result envelope (image data elided to {bytes} unless --out)" },
199
+ { name: "out", type: "string", description: "Write image/binary result blocks to file(s)" },
200
+ { name: "max-bytes", type: "number", description: "Cap printed output in bytes (default 262144)" },
201
+ { name: "schema", type: "string", description: "tools action: print one tool's inputSchema JSON" },
202
+ { name: "game", alias: "g", type: "string", description: "run action: game name (games/<name>)" },
203
+ { name: "entry", type: "string", description: "run action: explicit native entry file" },
204
+ { name: "headed", type: "boolean", description: "run action: show the game window" },
205
+ { name: "gpu", type: "string", enum: ["auto", "hardware", "swiftshader"], description: "run action: WebGPU backend (default: swiftshader)" },
206
+ { name: "deterministic", type: "boolean", description: "run action: DOWNDRAFT_DETERMINISTIC=1 (fixed seed, paused render loop)" },
207
+ { name: "port", alias: "p", type: "number", description: "run action: pin the MCP port (default: auto)" },
208
+ { name: "timeout", type: "number", description: "Per-request timeout ms; readiness budget for run" },
209
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
210
+ ],
211
+ },
212
+ },
213
+ {
214
+ name: "test",
215
+ usage: "draft test [options]",
216
+ summary: "Run e2e tests via bun:test (SwiftShader + deterministic by default; default specs use the in-game MCP RPC harness)",
217
+ schema: {
218
+ flags: [
219
+ { name: "game", alias: "g", type: "string", required: true, description: "Game to test (games/<game>)" },
220
+ { name: "spec", alias: "s", type: "string", description: "Spec file to run (default: tests/e2e/<game>-smoke.spec.ts)" },
221
+ { name: "port", alias: "p", type: "number", default: 0, description: "MCP port (0 = auto-assign a free port)" },
222
+ { name: "renderer", alias: "r", type: "string", default: "cpu", enum: [...RENDERER_TARGETS], description: "WebGPU backend: cpu=SwiftShader, gpu=hardware" },
223
+ { name: "no-deterministic", type: "boolean", description: "Disable fixed seed / render loop pause" },
224
+ { name: "headed", type: "boolean", description: "Show the window instead of running headless" },
225
+ { name: "verbose", alias: "v", type: "boolean", description: "Verbose logging" },
226
+ ],
227
+ },
228
+ },
229
+ ];
230
+
231
+ /** Look up a command entry by name. */
232
+ export function getCommand(name: string): CommandEntry | undefined {
233
+ return COMMANDS.find((c) => c.name === name);
234
+ }
235
+
236
+ /** Render the top-level `draft` help string from the registry. */
237
+ export function renderTopLevelHelp(version: string): string {
238
+ const lines: string[] = [];
239
+ lines.push(`DownDraft Engine CLI v${version}`);
240
+ lines.push("");
241
+ lines.push("Usage: draft <command> [options]");
242
+ lines.push("");
243
+ lines.push("Commands:");
244
+ const maxName = Math.max(...COMMANDS.map((c) => c.name.length));
245
+ COMMANDS.forEach((c) => {
246
+ lines.push(` ${c.name.padEnd(maxName + 2)} ${c.summary}`);
247
+ });
248
+ lines.push("");
249
+ lines.push("Global options:");
250
+ lines.push(" -h, --help Show help for a command (draft <cmd> --help)");
251
+ lines.push(" -V, --version Print the CLI version and exit");
252
+ lines.push("");
253
+ lines.push("Run `draft help <command>` for command-specific details.");
254
+ return lines.join("\n");
255
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "mcpServers": {
3
+ "downdraft": {
4
+ "command": "bun",
5
+ "args": ["run", "src/main.ts"],
6
+ "env": {
7
+ "DOWNDRAFT_MCP": "1"
8
+ }
9
+ }
10
+ }
11
+ }