@downdraft/cli 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +11 -0
  2. package/package.json +36 -3
  3. package/scripts/default-icon.png +0 -0
  4. package/scripts/package-desktop.mjs +931 -0
  5. package/scripts/package-mobile.mjs +822 -0
  6. package/scripts/package-native.mjs +638 -0
  7. package/src/args.ts +333 -0
  8. package/src/assets.ts +523 -0
  9. package/src/blob-store-s3.ts +145 -0
  10. package/src/build-games.ts +45 -0
  11. package/src/build.ts +40 -0
  12. package/src/bun-env.d.ts +6 -0
  13. package/src/debug.ts +56 -0
  14. package/src/dev.ts +231 -0
  15. package/src/dist.ts +36 -0
  16. package/src/export.ts +38 -0
  17. package/src/index.ts +131 -0
  18. package/src/list-games.ts +37 -0
  19. package/src/mcp.ts +320 -0
  20. package/src/new.ts +63 -0
  21. package/src/paths.ts +101 -0
  22. package/src/plugin-command.ts +214 -0
  23. package/src/process-utils.ts +179 -0
  24. package/src/release.ts +432 -0
  25. package/src/scaffold-plugin.ts +280 -0
  26. package/src/scaffold.ts +207 -0
  27. package/src/test.ts +187 -0
  28. package/src/usage.ts +256 -0
  29. package/templates/_shared/ai-companion/devin-config.eta +11 -0
  30. package/templates/_shared/ai-companion/engine-prompt.eta +155 -0
  31. package/templates/_shared/ai-companion/logging-rule.eta +3 -0
  32. package/templates/_shared/ai-companion/loops-rule.eta +3 -0
  33. package/templates/_shared/partials/config-json-fields.eta +3 -0
  34. package/templates/_shared/partials/dev-deps.eta +8 -0
  35. package/templates/_shared/partials/package-json-fields.eta +12 -0
  36. package/templates/_shared/partials/tsconfig.eta +16 -0
  37. package/templates/_shared/partials/vscode-extensions.eta +11 -0
  38. package/templates/_shared/partials/vscode-settings.eta +29 -0
  39. package/templates/_shared/partials/vscode-tasks.eta +93 -0
  40. package/templates/full/.gitignore.eta +5 -0
  41. package/templates/full/.vscode/extensions.json.eta +1 -0
  42. package/templates/full/.vscode/settings.json.eta +1 -0
  43. package/templates/full/.vscode/tasks.json.eta +1 -0
  44. package/templates/full/README.md.eta +45 -0
  45. package/templates/full/assets/shaders/.eta +0 -0
  46. package/templates/full/downdraft.config.json.eta +10 -0
  47. package/templates/full/package.json.eta +9 -0
  48. package/templates/full/src/entities/.eta +0 -0
  49. package/templates/full/src/game-module.ts.eta +30 -0
  50. package/templates/full/src/native-entry.ts.eta +14 -0
  51. package/templates/full/src/sim-host.ts.eta +19 -0
  52. package/templates/full/src/sim-worker.ts.eta +99 -0
  53. package/templates/full/src/systems/.eta +0 -0
  54. package/templates/full/tsconfig.json.eta +1 -0
  55. package/templates/gamemodule/README.md.eta +32 -0
  56. package/templates/gamemodule/downdraft.config.json.eta +3 -0
  57. package/templates/gamemodule/package.json.eta +9 -0
  58. package/templates/gamemodule/src/game-module.ts.eta +63 -0
  59. package/templates/gamemodule/src/native-entry.ts.eta +14 -0
  60. package/templates/gamemodule/src/sim-host.ts.eta +25 -0
  61. package/templates/gamemodule/src/sim-worker.ts.eta +24 -0
  62. package/templates/gamemodule/tsconfig.json.eta +1 -0
  63. package/templates/minimal/.gitignore.eta +5 -0
  64. package/templates/minimal/.vscode/extensions.json.eta +1 -0
  65. package/templates/minimal/.vscode/settings.json.eta +1 -0
  66. package/templates/minimal/.vscode/tasks.json.eta +1 -0
  67. package/templates/minimal/README.md.eta +32 -0
  68. package/templates/minimal/downdraft.config.json.eta +3 -0
  69. package/templates/minimal/package.json.eta +9 -0
  70. package/templates/minimal/src/game-module.ts.eta +44 -0
  71. package/templates/minimal/src/native-entry.ts.eta +14 -0
  72. package/templates/minimal/tsconfig.json.eta +1 -0
  73. package/templates/physics/.gitignore.eta +5 -0
  74. package/templates/physics/.vscode/extensions.json.eta +1 -0
  75. package/templates/physics/.vscode/settings.json.eta +1 -0
  76. package/templates/physics/.vscode/tasks.json.eta +1 -0
  77. package/templates/physics/README.md.eta +36 -0
  78. package/templates/physics/downdraft.config.json.eta +6 -0
  79. package/templates/physics/package.json.eta +9 -0
  80. package/templates/physics/src/game-module.ts.eta +29 -0
  81. package/templates/physics/src/native-entry.ts.eta +14 -0
  82. package/templates/physics/src/sim-host.ts.eta +19 -0
  83. package/templates/physics/src/sim-worker.ts.eta +155 -0
  84. package/templates/physics/tsconfig.json.eta +1 -0
@@ -0,0 +1,214 @@
1
+ // ============================================================================
2
+ // `dd plugin` — CLI subcommand for plugin management.
3
+ //
4
+ // Usage:
5
+ // dd plugin new <name> --format <format> --game <game> [options]
6
+ // dd plugin list [--game <game>]
7
+ // ============================================================================
8
+
9
+ import { createLogger } from "@downdraft/engine";
10
+ import { existsSync, readdirSync, readFileSync } from "fs";
11
+ import { basename, join } from "path";
12
+ import { print } from "./args";
13
+ import { findGameDirUpward, findMonorepoRoot } from "./paths";
14
+ import { scaffoldPlugin } from "./scaffold-plugin";
15
+
16
+ const log = createLogger();
17
+
18
+ export async function pluginCommand(args: string[]): Promise<void> {
19
+ // `dd mod new ...` injects --mod flag so scaffoldPlugin generates mod.json.
20
+ // `dd plugin new ...` defaults to plugin.json (legacy).
21
+ let modMode = false;
22
+ if (args[0] === "--mod") {
23
+ modMode = true;
24
+ args = args.slice(1);
25
+ }
26
+ const subcommand = args[0];
27
+ const rest = args.slice(1);
28
+
29
+ switch (subcommand) {
30
+ case "new":
31
+ await pluginNew(rest, modMode);
32
+ break;
33
+ case "list":
34
+ pluginList(rest);
35
+ break;
36
+ case "help":
37
+ case "--help":
38
+ case "-h":
39
+ printPluginHelp(modMode);
40
+ break;
41
+ default:
42
+ log.error("plugin", `Unknown subcommand: "${subcommand ?? ""}"`);
43
+ printPluginHelp(modMode);
44
+ process.exit(1);
45
+ }
46
+ }
47
+
48
+ async function pluginNew(args: string[], modMode: boolean): Promise<void> {
49
+ // Simple arg parsing: dd plugin new <name> --format <format> --game <game> [--version <ver>] [--author <name>] [--description <desc>] [--target <dir>] [--force] [--mod] [--with <ext>]
50
+ const positional: string[] = [];
51
+ const flags: Record<string, string | boolean> = {};
52
+ for (let i = 0; i < args.length; i++) {
53
+ const a = args[i];
54
+ if (a.startsWith("--")) {
55
+ const key = a.slice(2);
56
+ const next = args[i + 1];
57
+ if (next && !next.startsWith("--")) {
58
+ flags[key] = next;
59
+ i++;
60
+ } else {
61
+ flags[key] = true;
62
+ }
63
+ } else {
64
+ positional.push(a);
65
+ }
66
+ }
67
+
68
+ const id = positional[0];
69
+ if (!id) {
70
+ log.error("plugin", `Usage: dd ${modMode ? "mod" : "plugin"} new <name> --format <format> --game <game>`);
71
+ process.exit(1);
72
+ }
73
+
74
+ const format = (flags.format as string) ?? (modMode ? "asset" : "worker-js");
75
+ const game = (flags.game as string) ?? "";
76
+ if (!game) {
77
+ log.error("plugin", "--game is required (e.g. --game downdraft-overburden)");
78
+ process.exit(1);
79
+ }
80
+
81
+ const validFormats = ["worker-js", "quickjs", "wasm", "asset"];
82
+ if (!validFormats.includes(format)) {
83
+ log.error("plugin", `Invalid format "${format}". Valid: ${validFormats.join(", ")}`);
84
+ process.exit(1);
85
+ }
86
+
87
+ const name = (flags.name as string) ?? id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
88
+ const version = (flags.version as string) ?? "1.0.0";
89
+ const author = flags.author as string | undefined;
90
+ const description = flags.description as string | undefined;
91
+ const targetDir = (flags.target as string) ?? ".";
92
+ const force = flags.force === true;
93
+
94
+ // Parse --with <ext> for mod extensions (repeatable).
95
+ const withFlags: string[] = [];
96
+ if (flags.with) {
97
+ if (typeof flags.with === "string") withFlags.push(flags.with);
98
+ // Also scan args for repeated --with
99
+ for (let i = 0; i < args.length; i++) {
100
+ if (args[i] === "--with" && i + 1 < args.length && !args[i + 1].startsWith("--")) {
101
+ if (!withFlags.includes(args[i + 1])) withFlags.push(args[i + 1]);
102
+ }
103
+ }
104
+ }
105
+ const validExtensions = ["assets", "maps", "physics", "shader-postfx", "shader-material"];
106
+ withFlags.forEach((ext) => {
107
+ if (!validExtensions.includes(ext)) {
108
+ log.error("plugin", `Invalid --with extension "${ext}". Valid: ${validExtensions.join(", ")}`);
109
+ process.exit(1);
110
+ }
111
+ });
112
+
113
+ await scaffoldPlugin({
114
+ id,
115
+ name,
116
+ format: format as any,
117
+ game,
118
+ version,
119
+ author,
120
+ description,
121
+ targetDir,
122
+ force,
123
+ mod: modMode,
124
+ extensions: withFlags.length > 0 ? withFlags as any : undefined,
125
+ });
126
+ }
127
+
128
+ function pluginList(args: string[]): void {
129
+ // Find all games/*/plugins/*/{plugin.json,mod.json} (monorepo), or
130
+ // <gameDir>/plugins/* when running inside a standalone game repo.
131
+ const monorepoRoot = findMonorepoRoot();
132
+ const filterGame = args.find((a) => !a.startsWith("--"));
133
+
134
+ let gameDirs: { name: string; dir: string }[];
135
+ if (monorepoRoot) {
136
+ const gamesDir = join(monorepoRoot, "games");
137
+ if (!existsSync(gamesDir)) {
138
+ log.info("plugin", "No games/ directory found.");
139
+ return;
140
+ }
141
+ gameDirs = readdirSync(gamesDir)
142
+ .filter((g) => existsSync(join(gamesDir, g)))
143
+ .map((g) => ({ name: g, dir: join(gamesDir, g) }));
144
+ } else {
145
+ const gameDir = findGameDirUpward();
146
+ if (!gameDir) {
147
+ log.info("plugin", "No games/ directory found and not inside a game repo.");
148
+ return;
149
+ }
150
+ gameDirs = [{ name: basename(gameDir), dir: gameDir }];
151
+ }
152
+
153
+ let found = 0;
154
+
155
+ for (let _i = 0, _it = gameDirs, _n = _it.length; _i < _n; _i++) { const { name: game, dir: gameDirPath } = _it[_i];
156
+ if (filterGame && game !== filterGame) continue;
157
+ const pluginsDir = join(gameDirPath, "plugins");
158
+ if (!existsSync(pluginsDir)) continue;
159
+ for (const plugin of readdirSync(pluginsDir)) {
160
+ // Prefer mod.json, fall back to plugin.json.
161
+ const modPath = join(pluginsDir, plugin, "mod.json");
162
+ const pluginPath = join(pluginsDir, plugin, "plugin.json");
163
+ const manifestPath = existsSync(modPath) ? modPath : existsSync(pluginPath) ? pluginPath : null;
164
+ if (!manifestPath) continue;
165
+ const isMod = manifestPath === modPath;
166
+ try {
167
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
168
+ const extCount = manifest.extensions
169
+ ? Object.values(manifest.extensions).reduce(
170
+ (n: number, v: any) => n + (Array.isArray(v) ? v.length : (v && typeof v === "object" ? Object.keys(v).length : 0)),
171
+ 0,
172
+ )
173
+ : 0;
174
+ const extLabel = isMod && extCount > 0 ? ` (${extCount} ext)` : "";
175
+ log.info("plugin", ` ${game}/${plugin} — ${manifest.name ?? plugin} v${manifest.version ?? "?"} [${manifest.format ?? "?"}/${manifest.tier ?? "?"}]${isMod ? " [mod]" : ""}${extLabel}`);
176
+ found++;
177
+ } catch {
178
+ log.warn("plugin", ` ${game}/${plugin} — invalid ${isMod ? "mod.json" : "plugin.json"}`);
179
+ }
180
+ }
181
+ }
182
+
183
+ if (found === 0) {
184
+ log.info("plugin", "No plugins or mods found.");
185
+ } else {
186
+ log.info("plugin", `Found ${found} plugin(s)/mod(s).`);
187
+ }
188
+ }
189
+
190
+ function printPluginHelp(modMode: boolean): void {
191
+ const cmd = modMode ? "mod" : "plugin";
192
+ print(`
193
+ dd ${cmd} — manage user-authored ${modMode ? "mods" : "plugins"}
194
+
195
+ Usage:
196
+ dd ${cmd} new <name> --format <format> --game <game> [options]
197
+ dd ${cmd} list [--game <game>]
198
+
199
+ Commands:
200
+ new Scaffold a new ${modMode ? "mod" : "plugin"} directory
201
+ list List discovered ${modMode ? "mods + plugins" : "plugins + mods"} in games/*/plugins/*
202
+
203
+ Options for 'new':
204
+ --format <format> Plugin format: worker-js, quickjs, wasm, asset${modMode ? " (default: asset)" : " (default: worker-js)"}
205
+ --game <game> Target game appId (required, e.g. downdraft-overburden)
206
+ --name <name> Display name (default: derived from id)
207
+ --version <ver> Plugin version (default: 1.0.0)
208
+ --author <name> Author name
209
+ --description <desc> Short description
210
+ --target <dir> Target directory (default: current dir)
211
+ --force Overwrite if directory exists${modMode ? `
212
+ --with <ext> Include extension bucket (repeatable): assets, maps, physics, shader-postfx, shader-material` : ""}
213
+ `);
214
+ }
@@ -0,0 +1,179 @@
1
+ // ============================================================================
2
+ // Cross-platform process-tree kill
3
+ // ============================================================================
4
+ //
5
+ // Used by `draft dev`'s shutdown handler to tear down a spawned dev-shell
6
+ // process tree reliably on Linux, macOS, and Windows.
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import { readdirSync, readFileSync } from "node:fs";
10
+ import { platform } from "node:os";
11
+
12
+ export interface ProcInfo {
13
+ pid: number;
14
+ ppid: number;
15
+ cmdline: string;
16
+ }
17
+
18
+ const IS_WIN = platform() === "win32";
19
+ const IS_MAC = platform() === "darwin";
20
+
21
+ /**
22
+ * The executable name for `npx` on this platform. On Windows, `npx` is a
23
+ * `npx.cmd` batch shim — `spawn("npx", ...)` without `shell: true` fails with
24
+ * ENOENT because there's no `npx` PE executable on PATH. Spawning `npx.cmd`
25
+ * directly avoids both the ENOENT and shell-quoting issues.
26
+ */
27
+ export function npxBinary(): string {
28
+ return IS_WIN ? "npx.cmd" : "npx";
29
+ }
30
+
31
+ /**
32
+ * Synchronous sleep for use in kill sequences and signal handlers where the
33
+ * event loop can't be relied upon. `Atomics.wait` on a private SharedArrayBuffer
34
+ * is a pure in-process blocking sleep (no subprocess, works on the main thread
35
+ * in Node/Bun — unlike browsers). Falls back to a spin loop where SAB is
36
+ * unavailable.
37
+ */
38
+ export function sleepSync(ms: number): void {
39
+ try {
40
+ const buf = new Int32Array(new SharedArrayBuffer(4));
41
+ Atomics.wait(buf, 0, 0, ms);
42
+ } catch {
43
+ const end = Date.now() + ms;
44
+ while (Date.now() < end) { /* spin */ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Enumerate all processes on the system with pid, ppid, and command line.
50
+ *
51
+ * - Linux: reads `/proc/<pid>/cmdline` + `/proc/<pid>/stat` (no subprocess).
52
+ * - macOS: `ps -ax -o pid=,ppid=,command=`.
53
+ * - Windows: PowerShell `Get-CimInstance Win32_Process`.
54
+ * - Other: falls back to POSIX `ps -e -o pid=,ppid=,command=`.
55
+ */
56
+ export function listProcesses(): ProcInfo[] {
57
+ if (platform() === "linux") return listLinux();
58
+ if (IS_MAC) return listPs(["-ax"]);
59
+ if (IS_WIN) return listWindows();
60
+ return listPs(["-e"]);
61
+ }
62
+
63
+ function listLinux(): ProcInfo[] {
64
+ const out: ProcInfo[] = [];
65
+ try {
66
+ for (const entry of readdirSync("/proc")) {
67
+ if (!/^\d+$/.test(entry)) continue;
68
+ const pid = Number(entry);
69
+ let ppid = 0;
70
+ let cmdline = "";
71
+ try {
72
+ const stat = readFileSync(`/proc/${entry}/stat`, "utf8");
73
+ const rparen = stat.lastIndexOf(")");
74
+ if (rparen >= 0) {
75
+ // Fields after comm: state ppid ... (comm may contain spaces/parens
76
+ // so we parse from the last ")").
77
+ const rest = stat.slice(rparen + 2).split(" ");
78
+ ppid = Number(rest[1]);
79
+ }
80
+ // /proc/<pid>/cmdline is NUL-separated; join with spaces.
81
+ const raw = readFileSync(`/proc/${entry}/cmdline`, "utf8");
82
+ cmdline = raw.replace(/\0+$/g, "").replace(/\0/g, " ").trim();
83
+ } catch {
84
+ continue; // process exited between readdir and read
85
+ }
86
+ if (cmdline) out.push({ pid, ppid, cmdline });
87
+ }
88
+ } catch {
89
+ // /proc unavailable — shouldn't happen on Linux, but degrade gracefully.
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function listPs(extraArgs: string[]): ProcInfo[] {
95
+ const r = spawnSync("ps", [...extraArgs, "-o", "pid=,ppid=,command="], {
96
+ encoding: "utf8",
97
+ });
98
+ if (r.status !== 0 || !r.stdout) return [];
99
+ return parsePs(r.stdout);
100
+ }
101
+
102
+ function parsePs(stdout: string): ProcInfo[] {
103
+ return stdout
104
+ .split("\n")
105
+ .map((line) => {
106
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
107
+ if (!m) return null;
108
+ return { pid: Number(m[1]), ppid: Number(m[2]), cmdline: m[3] };
109
+ })
110
+ .filter((x): x is ProcInfo => x !== null);
111
+ }
112
+
113
+ function listWindows(): ProcInfo[] {
114
+ // One line per process: "PID|PPID|CommandLine". CommandLine may contain "|",
115
+ // so only split on the first two.
116
+ const script =
117
+ "Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CommandLine | " +
118
+ 'ForEach-Object { "$($_.ProcessId)|$($_.ParentProcessId)|$($_.CommandLine)" }';
119
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
120
+ encoding: "utf8",
121
+ windowsHide: true,
122
+ });
123
+ if (r.status !== 0 || !r.stdout) return [];
124
+ return r.stdout
125
+ .split("\n")
126
+ .map((line) => {
127
+ const parts = line.split("|");
128
+ if (parts.length < 2) return null;
129
+ const pid = Number(parts[0]);
130
+ const ppid = Number(parts[1]);
131
+ if (!Number.isFinite(pid) || pid <= 0) return null;
132
+ return { pid, ppid, cmdline: parts.slice(2).join("|") };
133
+ })
134
+ .filter((x): x is ProcInfo => x !== null);
135
+ }
136
+
137
+ /**
138
+ * Collect all descendant PIDs of `rootPid` by walking the pid→ppid map.
139
+ * Returns descendants in BFS order (children before grandchildren).
140
+ */
141
+ export function collectDescendants(rootPid: number, procs: ProcInfo[]): number[] {
142
+ const childrenOf = new Map<number, number[]>();
143
+ for (let _i = 0, _it = procs, _n = _it.length; _i < _n; _i++) { const p = _it[_i];
144
+ if (p.pid === rootPid) continue;
145
+ const list = childrenOf.get(p.ppid) ?? [];
146
+ list.push(p.pid);
147
+ childrenOf.set(p.ppid, list);
148
+ }
149
+ const result: number[] = [];
150
+ const queue = [rootPid];
151
+ while (queue.length) {
152
+ const cur = queue.shift()!;
153
+ const kids = childrenOf.get(cur);
154
+ if (kids) kids.forEach((k) => { result.push(k); queue.push(k); });
155
+ }
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * Kill a process and its entire descendant tree. Used by `draft dev`'s
161
+ * shutdown handler — unlike `process.kill(-pid)` (process-group kill), this
162
+ * works correctly even when the child was NOT spawned with `detached: true`
163
+ * (i.e. it's in the same process group as the parent, so `-pid` would kill
164
+ * the parent's group too).
165
+ *
166
+ * Sends SIGTERM first, waits briefly, then SIGKILLs survivors.
167
+ */
168
+ export function killProcessTree(rootPid: number): void {
169
+ const signal = (sig: NodeJS.Signals) => {
170
+ try { process.kill(rootPid, sig); } catch { /* already dead */ }
171
+ const procs = listProcesses();
172
+ for (const pid of collectDescendants(rootPid, procs)) {
173
+ try { process.kill(pid, sig); } catch { /* already dead */ }
174
+ }
175
+ };
176
+ signal("SIGTERM");
177
+ sleepSync(2000);
178
+ signal("SIGKILL");
179
+ }