@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
package/src/mcp.ts ADDED
@@ -0,0 +1,320 @@
1
+ // ============================================================================
2
+ // draft mcp — one-shot client for a running game's MCP automation endpoint.
3
+ //
4
+ // draft mcp instances # live game instances
5
+ // draft mcp tools # tools/list (name<TAB>description)
6
+ // draft mcp tools --schema <name> # one tool's inputSchema
7
+ // draft mcp call <tool> [json-args] # tools/call, text on stdout
8
+ // draft mcp screenshot <file.png> # capture_screenshot → file
9
+ // draft mcp run <script.ts> [-- args] # launch game → run script → kill
10
+ // draft mcp stdio # stdio→HTTP JSON-RPC bridge
11
+ //
12
+ // Output contract: payload on stdout, diagnostics on stderr.
13
+ // exit 0 = ok, 1 = tool/transport/usage error (ArgError is caught by
14
+ // index.ts and exits 1, same as every other draft command).
15
+ // Text output is capped at --max-bytes (default 256 KiB); image/binary
16
+ // blocks never print inline — use --out <file>.
17
+ // ============================================================================
18
+
19
+ import {
20
+ GameClient,
21
+ launchGame,
22
+ listGameInstances,
23
+ type GameClientOptions,
24
+ type MCPToolResult
25
+ } from "@downdraft/engine/mcp/client";
26
+ import { spawn } from "node:child_process";
27
+ import { mkdirSync, writeFileSync } from "node:fs";
28
+ import { dirname, resolve } from "node:path";
29
+ import { ArgError, parseArgs, print, renderHelp } from "./args";
30
+ import { getCommand } from "./usage";
31
+
32
+ const DEFAULT_MAX_BYTES = 256 * 1024;
33
+
34
+ interface McpFlags {
35
+ app: string;
36
+ pid: number;
37
+ url: string;
38
+ token: string;
39
+ json: boolean;
40
+ out: string;
41
+ maxBytes: number;
42
+ schema: string;
43
+ game: string;
44
+ entry: string;
45
+ headed: boolean;
46
+ gpu: string;
47
+ deterministic: boolean;
48
+ port: number;
49
+ timeout: number;
50
+ }
51
+
52
+ function selectorFrom(f: McpFlags): GameClientOptions {
53
+ return {
54
+ appId: f.app || undefined,
55
+ pid: f.pid || undefined,
56
+ url: f.url || undefined,
57
+ token: f.token || undefined,
58
+ timeoutMs: f.timeout || undefined,
59
+ };
60
+ }
61
+
62
+ /** Print text content to stdout, capped at maxBytes with a stderr notice. */
63
+ function printCapped(text: string, maxBytes: number): void {
64
+ const buf = Buffer.from(text, "utf8");
65
+ if (buf.length <= maxBytes) {
66
+ process.stdout.write(text.endsWith("\n") ? text : text + "\n");
67
+ return;
68
+ }
69
+ process.stdout.write(buf.subarray(0, maxBytes).toString("utf8"));
70
+ process.stderr.write(`draft-mcp: truncated ${buf.length - maxBytes} bytes (--max-bytes to adjust)\n`);
71
+ }
72
+
73
+ /** Image/binary blocks are never printed inline — describe or write them. */
74
+ function writeBinaryBlocks(result: MCPToolResult, outPath: string): void {
75
+ let i = 0;
76
+ for (let _i = 0, _it = result.content ?? [], _n = _it.length; _i < _n; _i++) { const c = _it[_i];
77
+ if (c.type !== "image") continue;
78
+ const name = i === 0 ? outPath : `${outPath}.${i}`;
79
+ mkdirSync(dirname(resolve(name)), { recursive: true });
80
+ writeFileSync(name, Buffer.from(c.data, "base64"));
81
+ process.stderr.write(`draft-mcp: wrote ${name} (${c.mimeType}, ${Buffer.from(c.data, "base64").length} bytes)\n`);
82
+ i++;
83
+ }
84
+ }
85
+
86
+ function printResult(result: MCPToolResult, f: McpFlags): void {
87
+ if (f.out) writeBinaryBlocks(result, f.out);
88
+
89
+ if (f.json) {
90
+ // Raw envelope, with image data elided to a size marker unless --out
91
+ // already captured the bytes to disk.
92
+ const elided = {
93
+ ...result,
94
+ content: (result.content ?? []).map((c) =>
95
+ c.type === "image" && !f.out
96
+ ? { type: "image", mimeType: c.mimeType, bytes: Buffer.from(c.data, "base64").length }
97
+ : c,
98
+ ),
99
+ };
100
+ printCapped(JSON.stringify(elided, null, 2), f.maxBytes);
101
+ if (result.isError) process.exit(1);
102
+ return;
103
+ }
104
+
105
+ if (result.isError) {
106
+ (result.content ?? []).forEach((c) => {
107
+ if (c.type === "text") process.stderr.write(c.text + "\n");
108
+ });
109
+ process.exit(1);
110
+ }
111
+ const textBlocks = (result.content ?? []).filter((c) => c.type === "text");
112
+ if (textBlocks.length > 0) {
113
+ printCapped(textBlocks.map((c) => ("text" in c ? c.text : "")).join("\n"), f.maxBytes);
114
+ }
115
+ // Non-text blocks that weren't written via --out get a one-line marker.
116
+ (result.content ?? []).forEach((c) => {
117
+ if (c.type === "image") {
118
+ const bytes = Buffer.from(c.data, "base64").length;
119
+ process.stderr.write(`draft-mcp: [image ${c.mimeType}: ${bytes} bytes${f.out ? " → file" : " — pass --out <file>"}]\n`);
120
+ }
121
+ });
122
+ }
123
+
124
+ /** stdio→HTTP bridge: newline-delimited JSON-RPC on stdin forwarded to the
125
+ * game's HTTP transport. Replaces .devin/mcp-stdio-bridge.mjs. */
126
+ async function stdioBridge(sel: GameClientOptions): Promise<void> {
127
+ const client = await GameClient.connect(sel);
128
+ process.stderr.write(`draft-mcp: stdio bridge → ${client.url}\n`);
129
+
130
+ let buf = "";
131
+ process.stdin.setEncoding("utf8");
132
+ process.stdin.on("data", (chunk: string) => {
133
+ buf += chunk;
134
+ let idx: number;
135
+ while ((idx = buf.indexOf("\n")) >= 0) {
136
+ const line = buf.slice(0, idx).trim();
137
+ buf = buf.slice(idx + 1);
138
+ if (line) void handleLine(line);
139
+ }
140
+ });
141
+ process.stdin.on("end", () => process.exit(0));
142
+
143
+ async function handleLine(line: string): Promise<void> {
144
+ let msg: { id?: number | string; method: string; params?: Record<string, unknown> };
145
+ try {
146
+ msg = JSON.parse(line);
147
+ } catch {
148
+ write({ jsonrpc: "2.0", id: 0, error: { code: -32700, message: "Parse error" } });
149
+ return;
150
+ }
151
+ // Notifications (no id) — ignore, don't respond.
152
+ if (msg.id === undefined || msg.id === null) return;
153
+ try {
154
+ let result: unknown;
155
+ if (msg.method === "resources/list") result = { resources: [] };
156
+ else if (msg.method === "prompts/list") result = { prompts: [] };
157
+ else if (msg.method === "shutdown") result = {};
158
+ else result = await client.request(msg.method, msg.params ?? {});
159
+ write({ jsonrpc: "2.0", id: msg.id, result });
160
+ } catch (e) {
161
+ write({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: (e as Error).message } });
162
+ }
163
+ }
164
+ function write(obj: unknown): void {
165
+ process.stdout.write(JSON.stringify(obj) + "\n");
166
+ }
167
+ }
168
+
169
+ /** draft mcp run <script> — launch a game, run the script with a connected
170
+ * client available via env (DOWNDRAFT_MCP_URL / DOWNDRAFT_MCP_TOKEN), then
171
+ * kill the game. Propagates the script's exit code. */
172
+ async function runScript(script: string, scriptArgs: string[], f: McpFlags): Promise<void> {
173
+ const launched = await launchGame({
174
+ game: f.game || undefined,
175
+ entry: f.entry || undefined,
176
+ headed: f.headed,
177
+ deterministic: f.deterministic,
178
+ gpu: (f.gpu || undefined) as "auto" | "hardware" | "swiftshader" | undefined,
179
+ port: f.port || undefined,
180
+ mirrorOutput: true,
181
+ });
182
+ process.stderr.write(`draft-mcp: game up on MCP port ${launched.port} (pid ${launched.pid})\n`);
183
+ // NB: process.exit() must run AFTER launched.kill() — inside try/finally
184
+ // it would terminate before the finally block executes.
185
+ const child = spawn("bun", [script, ...scriptArgs], {
186
+ cwd: process.cwd(),
187
+ stdio: "inherit",
188
+ env: {
189
+ ...process.env,
190
+ DOWNDRAFT_MCP_URL: `http://localhost:${launched.port}/mcp`,
191
+ ...(launched.token ? { DOWNDRAFT_MCP_TOKEN: launched.token } : {}),
192
+ DOWNDRAFT_MCP_PID: String(launched.pid),
193
+ },
194
+ });
195
+ const code = await new Promise<number>((res) => {
196
+ child.on("exit", (c) => res(c ?? 1));
197
+ child.on("error", (e) => { process.stderr.write(`draft-mcp: ${e.message}\n`); res(1); });
198
+ });
199
+ await launched.kill();
200
+ const errs = launched.getConsoleErrors();
201
+ if (errs.length > 0) {
202
+ process.stderr.write(`draft-mcp: ${errs.length} console error(s) in game output:\n`);
203
+ for (const e of errs.slice(0, 20)) process.stderr.write(` ${e}\n`);
204
+ }
205
+ process.exit(code);
206
+ }
207
+
208
+ export async function mcp(argv: string[]): Promise<void> {
209
+ const entry = getCommand("mcp")!;
210
+ const parsed = parseArgs(argv, entry.schema);
211
+ if (parsed.help) {
212
+ print(renderHelp(entry.usage, entry.schema));
213
+ return;
214
+ }
215
+
216
+ const f: McpFlags = {
217
+ app: parsed.flags.app as string,
218
+ pid: parsed.flags.pid as number,
219
+ url: parsed.flags.url as string,
220
+ token: parsed.flags.token as string,
221
+ json: parsed.flags.json as boolean,
222
+ out: parsed.flags.out as string,
223
+ maxBytes: (parsed.flags["max-bytes"] as number) || DEFAULT_MAX_BYTES,
224
+ schema: parsed.flags.schema as string,
225
+ game: parsed.flags.game as string,
226
+ entry: parsed.flags.entry as string,
227
+ headed: parsed.flags.headed as boolean,
228
+ gpu: parsed.flags.gpu as string,
229
+ deterministic: parsed.flags.deterministic as boolean,
230
+ port: parsed.flags.port as number,
231
+ timeout: parsed.flags.timeout as number,
232
+ };
233
+
234
+ const [action, ...rest] = parsed.positionals;
235
+ if (!action) throw new ArgError("Missing required positional: <action>");
236
+
237
+ switch (action) {
238
+ case "instances": {
239
+ const insts = listGameInstances({ appId: f.app || undefined });
240
+ if (insts.length === 0) {
241
+ process.stderr.write("draft-mcp: no running instances\n");
242
+ return;
243
+ }
244
+ insts.forEach((i) => {
245
+ print(`${i.pid}\t${i.port}\t${i.appId ?? "-"}`);
246
+ });
247
+ return;
248
+ }
249
+
250
+ case "tools": {
251
+ const client = await GameClient.connect(selectorFrom(f));
252
+ if (f.schema) {
253
+ const tools = await client.listTools();
254
+ const tool = tools.find((t) => t.name === f.schema);
255
+ if (!tool) {
256
+ process.stderr.write(`draft-mcp: unknown tool "${f.schema}"\n`);
257
+ process.exit(1);
258
+ }
259
+ printCapped(JSON.stringify(tool.inputSchema, null, 2), f.maxBytes);
260
+ return;
261
+ }
262
+ const tools = await client.listTools();
263
+ tools.forEach((t) => {
264
+ print(`${t.name}\t${t.description ?? ""}`);
265
+ });
266
+ return;
267
+ }
268
+
269
+ case "call": {
270
+ const [toolName, jsonArgs] = rest;
271
+ if (!toolName) throw new ArgError("draft mcp call requires <tool> [json-args]");
272
+ let args: Record<string, unknown> = {};
273
+ if (jsonArgs) {
274
+ try {
275
+ args = JSON.parse(jsonArgs);
276
+ } catch (e) {
277
+ throw new ArgError(`invalid JSON arguments: ${(e as Error).message}`);
278
+ }
279
+ }
280
+ const client = await GameClient.connect(selectorFrom(f));
281
+ const result = await client.call(toolName, args, { timeoutMs: f.timeout || undefined });
282
+ printResult(result, f);
283
+ return;
284
+ }
285
+
286
+ case "screenshot": {
287
+ const [file] = rest;
288
+ if (!file) throw new ArgError("draft mcp screenshot requires <file.png>");
289
+ const client = await GameClient.connect(selectorFrom(f));
290
+ let shotArgs: Record<string, unknown> = {};
291
+ if (rest[1]) {
292
+ try {
293
+ shotArgs = JSON.parse(rest[1]);
294
+ } catch (e) {
295
+ throw new ArgError(`invalid JSON arguments: ${(e as Error).message}`);
296
+ }
297
+ }
298
+ const meta = await client.screenshot(file, shotArgs);
299
+ print(`wrote ${file} (${meta.width}x${meta.height})`);
300
+ return;
301
+ }
302
+
303
+ case "stdio": {
304
+ await stdioBridge(selectorFrom(f));
305
+ // Bridge runs until stdin closes — keep the process alive.
306
+ await new Promise(() => {});
307
+ return;
308
+ }
309
+
310
+ case "run": {
311
+ const [script, ...scriptArgs] = rest;
312
+ if (!script) throw new ArgError("draft mcp run requires <script.ts>");
313
+ await runScript(resolve(script), scriptArgs, f);
314
+ return;
315
+ }
316
+
317
+ default:
318
+ throw new ArgError(`Unknown mcp action: ${action} (instances | tools | call | screenshot | stdio | run)`);
319
+ }
320
+ }
package/src/new.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { createLogger } from "@downdraft/engine";
2
+ import { basename, resolve } from "path";
3
+ import { parseArgs, print, renderHelp } from "./args";
4
+ import { listTemplates, scaffold, type ScaffoldOptions } from "./scaffold";
5
+ import { getCommand } from "./usage";
6
+
7
+ const log = createLogger();
8
+
9
+ export async function newProject(args: string[]): Promise<void> {
10
+ const entry = getCommand("new")!;
11
+ const parsed = parseArgs(args, entry.schema);
12
+ if (parsed.help) {
13
+ print(renderHelp(entry.usage, entry.schema));
14
+ return;
15
+ }
16
+
17
+ const templateArg = parsed.flags.template as string;
18
+ const nameArg = parsed.flags.name as string | undefined;
19
+ const descriptionArg = parsed.flags.description as string | undefined;
20
+ const authorArg = parsed.flags.author as string | undefined;
21
+ const versionArg = parsed.flags.version as string;
22
+ const aiCompanion = parsed.flags["ai-companion"] as boolean;
23
+ const force = parsed.flags.force as boolean;
24
+ const listFlag = parsed.flags["list-templates"] as boolean;
25
+
26
+ if (listFlag) {
27
+ const templates = listTemplates();
28
+ log.info("scaffold", `Available templates:`);
29
+ for (let i = 0; i < templates.length; i++) {
30
+ log.info("scaffold", ` - ${templates[i]}`);
31
+ }
32
+ return;
33
+ }
34
+
35
+ const availableTemplates = listTemplates();
36
+ if (!availableTemplates.includes(templateArg)) {
37
+ log.error("new", `Unknown template: "${templateArg}"`);
38
+ log.info("new", `Available templates: ${availableTemplates.join(", ")}`);
39
+ process.exit(1);
40
+ }
41
+
42
+ const targetPath = parsed.positionals[0] ?? ".";
43
+ const absTarget = resolve(targetPath);
44
+ const projectName = nameArg || basename(absTarget);
45
+
46
+ log.info("scaffold", `
47
+ ╔══════════════════════════════════════════╗
48
+ ║ DownDraft Engine — New Project ║
49
+ ╚══════════════════════════════════════════╝
50
+ `);
51
+
52
+ const opts: ScaffoldOptions = {
53
+ template: templateArg,
54
+ name: projectName,
55
+ description: descriptionArg,
56
+ author: authorArg,
57
+ version: versionArg,
58
+ aiCompanion,
59
+ force,
60
+ };
61
+
62
+ await scaffold(absTarget, opts);
63
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,101 @@
1
+ // ============================================================================
2
+ // paths.ts — monorepo vs standalone-game resolution for the draft CLI
3
+ // ============================================================================
4
+ //
5
+ // The CLI runs in two layouts:
6
+ //
7
+ // 1. Monorepo — the workspace `draft` bin, games at <root>/games/<name>.
8
+ // 2. Standalone — @downdraft/cli installed as a game repo devDep; the game
9
+ // directory IS the project root (no games/ level).
10
+ //
11
+ // Resolution rules:
12
+ // - The monorepo root is detected by walking up looking for a package.json
13
+ // named "downdraft-engine" (the CLI's own install location is checked too,
14
+ // so `draft --game <x>` still works when invoked from outside the repo).
15
+ // - A game directory is identified by `downdraft.config.json`, falling back
16
+ // to `src/native-entry.ts` (un-migrated games). Inside the monorepo,
17
+ // `games/<name>` resolves by name; standalone games are found by walking
18
+ // up from cwd.
19
+
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { basename, dirname, join, resolve, sep } from "node:path";
22
+
23
+ export const GAME_CONFIG_FILE = "downdraft.config.json";
24
+ const NATIVE_ENTRY_FILE = "src/native-entry.ts";
25
+
26
+ function isGameDir(dir: string): boolean {
27
+ return existsSync(join(dir, GAME_CONFIG_FILE))
28
+ || existsSync(join(dir, NATIVE_ENTRY_FILE));
29
+ }
30
+
31
+ /**
32
+ * Find the downdraft monorepo root — a directory whose package.json is named
33
+ * "downdraft-engine" and contains packages/engine. Searches cwd ancestry first,
34
+ * then the CLI's own location (packages/cli/src → ../../.. in the monorepo).
35
+ * Returns null when running inside a standalone game repo.
36
+ */
37
+ export function findMonorepoRoot(start: string = process.cwd()): string | null {
38
+ const candidates = [resolve(start), resolve(import.meta.dir, "../../..")];
39
+ for (let _i = 0, _it = candidates, _n = _it.length; _i < _n; _i++) { const seed = _it[_i];
40
+ let dir = seed;
41
+ for (;;) {
42
+ try {
43
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
44
+ if (pkg.name === "downdraft-engine" && existsSync(join(dir, "packages/engine"))) {
45
+ return dir;
46
+ }
47
+ } catch { /* no package.json here — keep walking */ }
48
+ const up = dirname(dir);
49
+ if (up === dir) break;
50
+ dir = up;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Walk up from `start` looking for a game directory — `downdraft.config.json`
58
+ * or `src/native-entry.ts`. Works for both `games/<name>` inside the monorepo
59
+ * and standalone game repos.
60
+ */
61
+ export function findGameDirUpward(start: string = process.cwd()): string | null {
62
+ let dir = resolve(start);
63
+ for (;;) {
64
+ if (isGameDir(dir)) return dir;
65
+ const up = dirname(dir);
66
+ if (up === dir) return null;
67
+ dir = up;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Resolve a game's directory.
73
+ * - `name` given + monorepo → `<root>/games/<name>` (must exist).
74
+ * - `name` given + standalone → cwd's game dir, if its basename matches.
75
+ * - no name → walk up from cwd for the nearest game dir.
76
+ * Returns null when nothing resolves.
77
+ */
78
+ export function resolveGameDir(name?: string): string | null {
79
+ const upward = findGameDirUpward();
80
+ if (!name) return upward;
81
+ // The cwd's own game wins when its name matches — standalone repos can live
82
+ // anywhere, and the CLI's own location may still detect the monorepo.
83
+ if (upward && basename(upward) === name) return upward;
84
+ const root = findMonorepoRoot();
85
+ if (root) {
86
+ const dir = join(root, "games", name);
87
+ if (existsSync(dir)) return dir;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * The directory builds should spawn from / write `dist` into — the monorepo
94
+ * root when the game lives inside it (shared root dist), else the game
95
+ * directory itself. The CLI's own location must NOT trigger the monorepo
96
+ * branch for standalone games (linked installs resolve back into the repo).
97
+ */
98
+ export function buildCwd(gameDir: string): string {
99
+ const root = findMonorepoRoot();
100
+ return root && resolve(gameDir).startsWith(root + sep) ? root : gameDir;
101
+ }