@genex-ai/cli-demo 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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/package.json +45 -0
  4. package/src/commands/init.ts +131 -0
  5. package/src/commands/publish.ts +151 -0
  6. package/src/config.ts +102 -0
  7. package/src/index.ts +238 -0
  8. package/src/lib/auth.ts +365 -0
  9. package/src/lib/copy-templates.ts +81 -0
  10. package/src/lib/env.ts +109 -0
  11. package/src/lib/project.ts +109 -0
  12. package/src/lib/ssh.ts +104 -0
  13. package/src/lib/store.ts +102 -0
  14. package/src/utils/colors.ts +25 -0
  15. package/src/utils/logger.ts +40 -0
  16. package/templates/README.md +19 -0
  17. package/templates/agents/genex-helper.md +16 -0
  18. package/templates/commands/genex-status.md +13 -0
  19. package/templates/skills/genex-getting-started/SKILL.md +41 -0
  20. package/templates/skills/genex-threejs-atmosphere-aerial-perspective/SKILL.md +30 -0
  21. package/templates/skills/genex-threejs-atmosphere-aerial-perspective/references/atmosphere.md +29 -0
  22. package/templates/skills/genex-threejs-bloom/SKILL.md +30 -0
  23. package/templates/skills/genex-threejs-bloom/references/bloom.md +29 -0
  24. package/templates/skills/genex-threejs-camera-direction/SKILL.md +36 -0
  25. package/templates/skills/genex-threejs-camera-direction/references/camera-rigs.md +38 -0
  26. package/templates/skills/genex-threejs-exposure-color-grading/SKILL.md +30 -0
  27. package/templates/skills/genex-threejs-exposure-color-grading/references/exposure-grading.md +30 -0
  28. package/templates/skills/genex-threejs-image-pipeline/SKILL.md +30 -0
  29. package/templates/skills/genex-threejs-image-pipeline/references/image-pipeline.md +39 -0
  30. package/templates/skills/genex-threejs-procedural-animation/SKILL.md +31 -0
  31. package/templates/skills/genex-threejs-procedural-animation/references/procedural-motion.md +33 -0
  32. package/templates/skills/genex-threejs-procedural-architecture/SKILL.md +30 -0
  33. package/templates/skills/genex-threejs-procedural-architecture/references/architecture-systems.md +31 -0
  34. package/templates/skills/genex-threejs-procedural-fields/SKILL.md +31 -0
  35. package/templates/skills/genex-threejs-procedural-fields/references/field-systems.md +35 -0
  36. package/templates/skills/genex-threejs-procedural-geometry/SKILL.md +30 -0
  37. package/templates/skills/genex-threejs-procedural-geometry/references/mesh-systems.md +36 -0
  38. package/templates/skills/genex-threejs-procedural-materials/SKILL.md +30 -0
  39. package/templates/skills/genex-threejs-procedural-materials/references/material-systems.md +31 -0
  40. package/templates/skills/genex-threejs-procedural-planets/SKILL.md +30 -0
  41. package/templates/skills/genex-threejs-procedural-planets/references/planet-systems.md +30 -0
  42. package/templates/skills/genex-threejs-procedural-vegetation/SKILL.md +32 -0
  43. package/templates/skills/genex-threejs-procedural-vegetation/references/vegetation-systems.md +37 -0
  44. package/templates/skills/genex-threejs-procedural-vfx/SKILL.md +31 -0
  45. package/templates/skills/genex-threejs-procedural-vfx/references/vfx-systems.md +30 -0
  46. package/templates/skills/genex-threejs-raymarched-space-effects/SKILL.md +30 -0
  47. package/templates/skills/genex-threejs-raymarched-space-effects/references/space-effects.md +30 -0
  48. package/templates/skills/genex-threejs-screen-space-ambient-occlusion/SKILL.md +29 -0
  49. package/templates/skills/genex-threejs-screen-space-ambient-occlusion/references/ambient-occlusion.md +29 -0
  50. package/templates/skills/genex-threejs-shadow-systems/SKILL.md +30 -0
  51. package/templates/skills/genex-threejs-shadow-systems/references/shadow-systems.md +30 -0
  52. package/templates/skills/genex-threejs-skill-router/SKILL.md +48 -0
  53. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +53 -0
  54. package/templates/skills/genex-threejs-spectral-ocean/SKILL.md +30 -0
  55. package/templates/skills/genex-threejs-spectral-ocean/references/spectral-ocean.md +31 -0
  56. package/templates/skills/genex-threejs-temporal-surfaces/SKILL.md +30 -0
  57. package/templates/skills/genex-threejs-temporal-surfaces/references/temporal-surfaces.md +29 -0
  58. package/templates/skills/genex-threejs-visual-validation/SKILL.md +32 -0
  59. package/templates/skills/genex-threejs-visual-validation/references/visual-validation.md +42 -0
  60. package/templates/skills/genex-threejs-volumetric-clouds/SKILL.md +29 -0
  61. package/templates/skills/genex-threejs-volumetric-clouds/references/volumetric-clouds.md +30 -0
  62. package/templates/skills/genex-threejs-water-optics/SKILL.md +30 -0
  63. package/templates/skills/genex-threejs-water-optics/references/water-optics.md +29 -0
package/src/index.ts ADDED
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { runInit, type InitOptions } from "./commands/init.ts";
6
+ import { runPublish, type PublishOptions } from "./commands/publish.ts";
7
+ import { DEFAULT_API_URL, DEFAULT_AUTH_URL } from "./config.ts";
8
+ import { createLogger } from "./utils/logger.ts";
9
+ import { c } from "./utils/colors.ts";
10
+
11
+ type CliOptions = InitOptions & PublishOptions;
12
+
13
+ function getVersion(): string {
14
+ try {
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(
17
+ readFileSync(path.resolve(here, "..", "package.json"), "utf8"),
18
+ ) as { version?: string };
19
+ return pkg.version ?? "0.0.0";
20
+ } catch {
21
+ return "0.0.0";
22
+ }
23
+ }
24
+
25
+ const HELP = `${c.bold("genex")} — set up your ~/.claude workspace, authorize, and publish 3D games.
26
+
27
+ ${c.bold("Usage")}
28
+ genex init [<name>] [options] Scaffold + authorize + create the draft project.
29
+ genex publish [options] Push the built game and list it in the gallery.
30
+
31
+ ${c.bold("Options for `init`")}
32
+ <name> Project name (positional; default: current directory name).
33
+ --name <name> Same as the positional name.
34
+ --dir <path> Destination workspace (default: ~/.claude).
35
+ --env <path> Token env file (default: ~/.genex/env).
36
+ --auth-url <url> Override the auth site (default: ${DEFAULT_AUTH_URL}).
37
+ --api-url <url> Override the API base URL (default: ${DEFAULT_API_URL}).
38
+ --colyseus-url <url> Override the multiplayer URL (stored in project metadata).
39
+ --no-auth Only scaffold templates; skip authorization.
40
+ --force Overwrite existing files (default: never overwrite).
41
+ --timeout <seconds> How long to wait for the auth redirect (default: 300).
42
+
43
+ ${c.bold("Options for `publish`")}
44
+ --no-push Skip the git push; only flip the gallery flag.
45
+ --title <title> Gallery title.
46
+ --description <text> Gallery description.
47
+ --api-url <url> Override the API base URL.
48
+ --env <path> Token env file (default: ~/.genex/env).
49
+
50
+ ${c.bold("Global")}
51
+ --quiet Reduce output.
52
+ -h, --help Show this help.
53
+ -v, --version Show the version.
54
+
55
+ ${c.bold("Environment")}
56
+ GENEX_AUTH_URL Overrides the default auth site URL.
57
+ GENEX_API_URL Overrides the default API base URL.
58
+ GENEX_COLYSEUS_URL Overrides the default multiplayer URL.
59
+ GENEX_BROWSER Command used to open the browser (falls back to BROWSER).
60
+
61
+ ${c.bold("Examples")}
62
+ genex init my-game
63
+ genex init my-game --api-url http://localhost:3000 --auth-url http://localhost:5173
64
+ genex publish
65
+ genex publish --no-push --title "My Game"
66
+ `;
67
+
68
+ interface ParsedArgs {
69
+ command: string | undefined;
70
+ options: CliOptions;
71
+ help: boolean;
72
+ version: boolean;
73
+ error?: string;
74
+ }
75
+
76
+ function parseArgs(argv: string[]): ParsedArgs {
77
+ const parsed: ParsedArgs = {
78
+ command: undefined,
79
+ options: {},
80
+ help: false,
81
+ version: false,
82
+ };
83
+
84
+ // The value-taking flags and where they land on the options object.
85
+ const needsValue = new Set([
86
+ "--dir",
87
+ "--env",
88
+ "--auth-url",
89
+ "--api-url",
90
+ "--colyseus-url",
91
+ "--name",
92
+ "--title",
93
+ "--description",
94
+ "--timeout",
95
+ ]);
96
+
97
+ let i = 0;
98
+ while (i < argv.length) {
99
+ const arg = argv[i++];
100
+ if (arg === undefined) break;
101
+
102
+ switch (arg) {
103
+ case "-h":
104
+ case "--help":
105
+ parsed.help = true;
106
+ break;
107
+ case "-v":
108
+ case "--version":
109
+ parsed.version = true;
110
+ break;
111
+ case "--no-auth":
112
+ parsed.options.noAuth = true;
113
+ break;
114
+ case "--no-push":
115
+ parsed.options.noPush = true;
116
+ break;
117
+ case "--force":
118
+ parsed.options.force = true;
119
+ break;
120
+ case "--quiet":
121
+ parsed.options.quiet = true;
122
+ break;
123
+ default: {
124
+ if (needsValue.has(arg)) {
125
+ const value = argv[i++];
126
+ if (value === undefined) {
127
+ parsed.error = `Missing value for ${arg}`;
128
+ return parsed;
129
+ }
130
+ applyValueFlag(parsed.options, arg, value);
131
+ } else if (arg.startsWith("-")) {
132
+ parsed.error = `Unknown option: ${arg}`;
133
+ return parsed;
134
+ } else if (parsed.command === undefined) {
135
+ parsed.command = arg;
136
+ } else if (parsed.options.name === undefined) {
137
+ // Second positional → project name (e.g. `genex init my-game`).
138
+ parsed.options.name = arg;
139
+ } else {
140
+ parsed.error = `Unexpected argument: ${arg}`;
141
+ return parsed;
142
+ }
143
+ break;
144
+ }
145
+ }
146
+ }
147
+
148
+ return parsed;
149
+ }
150
+
151
+ function applyValueFlag(
152
+ options: CliOptions,
153
+ flag: string,
154
+ value: string,
155
+ ): void {
156
+ switch (flag) {
157
+ case "--dir":
158
+ options.dir = value;
159
+ break;
160
+ case "--env":
161
+ options.envPath = value;
162
+ break;
163
+ case "--auth-url":
164
+ options.authUrl = value;
165
+ break;
166
+ case "--api-url":
167
+ options.apiUrl = value;
168
+ break;
169
+ case "--colyseus-url":
170
+ options.colyseusUrl = value;
171
+ break;
172
+ case "--name":
173
+ options.name = value;
174
+ break;
175
+ case "--title":
176
+ options.title = value;
177
+ break;
178
+ case "--description":
179
+ options.description = value;
180
+ break;
181
+ case "--timeout": {
182
+ const n = Number(value);
183
+ if (!Number.isFinite(n) || n <= 0) {
184
+ throw new Error(`Invalid --timeout value: ${value}`);
185
+ }
186
+ options.timeoutSec = n;
187
+ break;
188
+ }
189
+ }
190
+ }
191
+
192
+ async function main(): Promise<void> {
193
+ const log = createLogger();
194
+ let parsed: ParsedArgs;
195
+ try {
196
+ parsed = parseArgs(process.argv.slice(2));
197
+ } catch (err) {
198
+ log.error(err instanceof Error ? err.message : String(err));
199
+ process.exitCode = 1;
200
+ return;
201
+ }
202
+
203
+ if (parsed.error) {
204
+ log.error(parsed.error);
205
+ log.plain(`Run ${c.cyan("genex --help")} for usage.`);
206
+ process.exitCode = 1;
207
+ return;
208
+ }
209
+
210
+ if (parsed.version) {
211
+ log.plain(getVersion());
212
+ return;
213
+ }
214
+
215
+ if (parsed.help || parsed.command === undefined) {
216
+ log.plain(HELP);
217
+ return;
218
+ }
219
+
220
+ switch (parsed.command) {
221
+ case "init":
222
+ await runInit(parsed.options);
223
+ break;
224
+ case "publish":
225
+ await runPublish(parsed.options);
226
+ break;
227
+ default:
228
+ log.error(`Unknown command: ${parsed.command}`);
229
+ log.plain(`Run ${c.cyan("genex --help")} for usage.`);
230
+ process.exitCode = 1;
231
+ }
232
+ }
233
+
234
+ main().catch((err: unknown) => {
235
+ const log = createLogger();
236
+ log.error(err instanceof Error ? err.message : String(err));
237
+ process.exitCode = 1;
238
+ });
@@ -0,0 +1,365 @@
1
+ import http from "node:http";
2
+ import crypto from "node:crypto";
3
+ import readline from "node:readline";
4
+ import { spawn } from "node:child_process";
5
+ import { URL } from "node:url";
6
+ import type { AddressInfo } from "node:net";
7
+ import type { Logger } from "../utils/logger.ts";
8
+
9
+ export interface AuthorizeOptions {
10
+ log: Logger;
11
+ /** How long to wait for the redirect before giving up. Default 5 minutes. */
12
+ timeoutMs?: number;
13
+ /**
14
+ * Opens a URL in the user's browser and returns whether the attempt was made.
15
+ * `onError` is invoked if the launch fails asynchronously (e.g. the opener
16
+ * binary is missing). Injectable for testing; defaults to a cross-platform
17
+ * opener.
18
+ */
19
+ open?: (url: string, onError: () => void) => boolean;
20
+ /**
21
+ * Whether to also accept a token pasted into the terminal (handy over SSH or
22
+ * when the browser can't reach localhost). Defaults to true when stdin is a TTY.
23
+ */
24
+ interactive?: boolean;
25
+ }
26
+
27
+ const SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Genex</title>
28
+ <style>body{font-family:system-ui,sans-serif;background:#0b0b0f;color:#eaeaea;display:grid;place-items:center;height:100vh;margin:0}
29
+ .card{text-align:center;padding:2rem 3rem;border:1px solid #26262e;border-radius:14px;background:#13131a}
30
+ h1{font-size:1.3rem;margin:0 0 .5rem}p{color:#9a9aa6;margin:0}</style></head>
31
+ <body><div class="card"><h1>✓ Authorized</h1><p>You can close this tab and return to your terminal.</p></div></body></html>`;
32
+
33
+ const ERROR_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Genex</title></head>
34
+ <body style="font-family:system-ui,sans-serif"><h1>Authorization failed</h1>
35
+ <p>No token was provided. Please return to your terminal and try again.</p></body></html>`;
36
+
37
+ /**
38
+ * Run the interactive authorization flow and resolve with the auth token.
39
+ *
40
+ * Spins up a temporary loopback HTTP server, opens the browser to the auth
41
+ * site (passing the local `redirect_uri` and a CSRF `state`), and waits for the
42
+ * site to hand the token back — either by redirecting to the callback with a
43
+ * `?token=` query, or by POSTing `{ token }` (JSON or form-encoded) to it.
44
+ *
45
+ * If the browser can't be opened, the URL is printed for the user to open
46
+ * manually. When `interactive`, a pasted token is also accepted as a fallback.
47
+ */
48
+ export async function authorize(
49
+ authBaseUrl: string,
50
+ options: AuthorizeOptions,
51
+ ): Promise<string> {
52
+ const {
53
+ log,
54
+ timeoutMs = 5 * 60 * 1000,
55
+ open = openBrowser,
56
+ interactive = Boolean(process.stdin.isTTY),
57
+ } = options;
58
+
59
+ const state = crypto.randomBytes(16).toString("hex");
60
+ const server = http.createServer();
61
+
62
+ return await new Promise<string>((resolve, reject) => {
63
+ let settled = false;
64
+ let timer: NodeJS.Timeout | undefined;
65
+ let rl: readline.Interface | undefined;
66
+
67
+ const cleanup = (): void => {
68
+ if (timer) clearTimeout(timer);
69
+ if (rl) rl.close();
70
+ server.close();
71
+ };
72
+
73
+ const succeed = (token: string): void => {
74
+ if (settled) return;
75
+ settled = true;
76
+ cleanup();
77
+ resolve(token);
78
+ };
79
+
80
+ const fail = (err: Error): void => {
81
+ if (settled) return;
82
+ settled = true;
83
+ cleanup();
84
+ reject(err);
85
+ };
86
+
87
+ server.on("request", (req, res) => {
88
+ handleCallback(req, res, state, log)
89
+ .then((token) => {
90
+ if (token) succeed(token);
91
+ })
92
+ .catch((err: unknown) => {
93
+ // A single bad request must not abort a legitimate pending auth, but
94
+ // we must always end the response so the client isn't left hanging.
95
+ log.dim(`callback error: ${String(err)}`);
96
+ if (!res.writableEnded) {
97
+ res.writeHead(500);
98
+ res.end();
99
+ }
100
+ });
101
+ });
102
+
103
+ server.on("error", (err) => fail(err as Error));
104
+
105
+ // Port 0 → OS assigns a free ephemeral port on loopback.
106
+ server.listen(0, "127.0.0.1", () => {
107
+ const { port } = server.address() as AddressInfo;
108
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
109
+ const authUrl = buildAuthUrl(authBaseUrl, redirectUri, state);
110
+
111
+ log.step("Opening your browser to authorize…");
112
+ log.dim(authUrl);
113
+
114
+ let warned = false;
115
+ const warnManual = (): void => {
116
+ if (warned || settled) return;
117
+ warned = true;
118
+ log.warn(
119
+ "Couldn't open a browser automatically. Open the URL above manually to continue.",
120
+ );
121
+ };
122
+
123
+ const opened = open(authUrl, warnManual);
124
+ if (!opened) warnManual();
125
+
126
+ if (interactive) {
127
+ rl = readline.createInterface({
128
+ input: process.stdin,
129
+ output: process.stdout,
130
+ });
131
+ rl.question(
132
+ "\nWaiting for the browser redirect… or paste your token here and press Enter:\n> ",
133
+ (answer) => {
134
+ const token = answer.trim();
135
+ if (token) succeed(token);
136
+ },
137
+ );
138
+ }
139
+
140
+ timer = setTimeout(() => {
141
+ fail(
142
+ new Error(
143
+ "Timed out waiting for authorization. Re-run the command to try again.",
144
+ ),
145
+ );
146
+ }, timeoutMs);
147
+ });
148
+ });
149
+ }
150
+
151
+ function buildAuthUrl(
152
+ authBaseUrl: string,
153
+ redirectUri: string,
154
+ state: string,
155
+ ): string {
156
+ const url = new URL(`${authBaseUrl}/cli/auth`);
157
+ url.searchParams.set("redirect_uri", redirectUri);
158
+ url.searchParams.set("state", state);
159
+ return url.toString();
160
+ }
161
+
162
+ /**
163
+ * Handle a request to the loopback server. Returns the token when the callback
164
+ * carried one (and the state matched), otherwise `null`.
165
+ */
166
+ async function handleCallback(
167
+ req: http.IncomingMessage,
168
+ res: http.ServerResponse,
169
+ expectedState: string,
170
+ log: Logger,
171
+ ): Promise<string | null> {
172
+ // Permit the auth site's https origin to POST to our loopback server.
173
+ res.setHeader("Access-Control-Allow-Origin", "*");
174
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
175
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
176
+
177
+ const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
178
+
179
+ if (req.method === "OPTIONS") {
180
+ res.writeHead(204);
181
+ res.end();
182
+ return null;
183
+ }
184
+
185
+ if (reqUrl.pathname !== "/callback") {
186
+ res.writeHead(404);
187
+ res.end();
188
+ return null;
189
+ }
190
+
191
+ let token: string | null = null;
192
+ let state: string | null = null;
193
+
194
+ if (req.method === "GET") {
195
+ token = reqUrl.searchParams.get("token");
196
+ state = reqUrl.searchParams.get("state");
197
+ } else if (req.method === "POST") {
198
+ let parsed: ParsedBody;
199
+ try {
200
+ parsed = await readBody(req);
201
+ } catch (err) {
202
+ // Malformed / oversized body — reject this request but keep waiting.
203
+ log.dim(`ignoring malformed callback body: ${String(err)}`);
204
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
205
+ res.end(ERROR_HTML);
206
+ return null;
207
+ }
208
+ token = parsed.token;
209
+ state = parsed.state ?? reqUrl.searchParams.get("state");
210
+ } else {
211
+ res.writeHead(405);
212
+ res.end();
213
+ return null;
214
+ }
215
+
216
+ // Guard against cross-site request forgery on the loopback callback. The
217
+ // state is mandatory: a missing state (null) must fail just like a mismatch.
218
+ if (state !== expectedState) {
219
+ log.dim("ignoring callback with missing or mismatched state");
220
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
221
+ res.end(ERROR_HTML);
222
+ return null;
223
+ }
224
+
225
+ if (!token) {
226
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
227
+ res.end(ERROR_HTML);
228
+ return null;
229
+ }
230
+
231
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
232
+ res.end(SUCCESS_HTML);
233
+ return token;
234
+ }
235
+
236
+ interface ParsedBody {
237
+ token: string | null;
238
+ state: string | null;
239
+ }
240
+
241
+ async function readBody(req: http.IncomingMessage): Promise<ParsedBody> {
242
+ const chunks: Buffer[] = [];
243
+ let total = 0;
244
+ const MAX = 1024 * 1024; // 1 MB cap — tokens are tiny; reject anything huge.
245
+
246
+ for await (const chunk of req) {
247
+ const buf = chunk as Buffer;
248
+ total += buf.length;
249
+ if (total > MAX) {
250
+ throw new Error("request body too large");
251
+ }
252
+ chunks.push(buf);
253
+ }
254
+
255
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
256
+ if (!raw) return { token: null, state: null };
257
+
258
+ const contentType = req.headers["content-type"] ?? "";
259
+
260
+ if (contentType.includes("application/json")) {
261
+ try {
262
+ const obj = JSON.parse(raw) as Record<string, unknown>;
263
+ return {
264
+ token: typeof obj.token === "string" ? obj.token : null,
265
+ state: typeof obj.state === "string" ? obj.state : null,
266
+ };
267
+ } catch {
268
+ return { token: null, state: null };
269
+ }
270
+ }
271
+
272
+ // Fall back to form-encoded (application/x-www-form-urlencoded).
273
+ const params = new URLSearchParams(raw);
274
+ return {
275
+ token: params.get("token"),
276
+ state: params.get("state"),
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Best-effort cross-platform browser opener. Returns false if we couldn't even
282
+ * launch the platform's open command.
283
+ */
284
+ export function openBrowser(url: string, onError: () => void = () => {}): boolean {
285
+ let command: string;
286
+ let args: string[];
287
+
288
+ // Respect an explicit opener override (the conventional BROWSER variable, or
289
+ // our own GENEX_BROWSER which takes precedence). Tokenized like a shell
290
+ // command so both arguments (`open -a Safari`) and quoted paths containing
291
+ // spaces (`"/Applications/My Browser.app/.../My Browser"`) work. The URL is
292
+ // appended as the final argument.
293
+ const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
294
+ const customParts = custom ? tokenizeCommand(custom) : [];
295
+
296
+ if (customParts.length > 0) {
297
+ command = customParts[0] as string;
298
+ args = [...customParts.slice(1), url];
299
+ } else {
300
+ switch (process.platform) {
301
+ case "darwin":
302
+ command = "open";
303
+ args = [url];
304
+ break;
305
+ case "win32":
306
+ command = "cmd";
307
+ // `start` needs an empty title arg so a quoted URL isn't taken as one.
308
+ args = ["/c", "start", "", url];
309
+ break;
310
+ default:
311
+ command = "xdg-open";
312
+ args = [url];
313
+ break;
314
+ }
315
+ }
316
+
317
+ try {
318
+ const child = spawn(command, args, {
319
+ stdio: "ignore",
320
+ detached: true,
321
+ });
322
+ // If the binary is missing, 'error' fires asynchronously — surface it so the
323
+ // caller can tell the user to open the URL manually, but don't let the
324
+ // unhandled error crash the process.
325
+ child.on("error", () => onError());
326
+ child.unref();
327
+ return true;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Split a command string into argv, honoring single/double quotes so that
335
+ * arguments and quoted paths-with-spaces both survive. This is a deliberately
336
+ * small tokenizer — not a full shell parser (no escapes, globs, or variable
337
+ * expansion) — which is all an opener command needs.
338
+ */
339
+ export function tokenizeCommand(input: string): string[] {
340
+ const tokens: string[] = [];
341
+ let current = "";
342
+ let quote: '"' | "'" | null = null;
343
+ let inToken = false;
344
+
345
+ for (const ch of input) {
346
+ if (quote) {
347
+ if (ch === quote) quote = null;
348
+ else current += ch;
349
+ } else if (ch === '"' || ch === "'") {
350
+ quote = ch;
351
+ inToken = true;
352
+ } else if (/\s/.test(ch)) {
353
+ if (inToken) {
354
+ tokens.push(current);
355
+ current = "";
356
+ inToken = false;
357
+ }
358
+ } else {
359
+ current += ch;
360
+ inToken = true;
361
+ }
362
+ }
363
+ if (inToken) tokens.push(current);
364
+ return tokens;
365
+ }
@@ -0,0 +1,81 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface CopyResult {
5
+ /** Files written, as paths relative to the source root. */
6
+ copied: string[];
7
+ /** Files left untouched because they already existed at the destination. */
8
+ skipped: string[];
9
+ }
10
+
11
+ export interface CopyOptions {
12
+ /**
13
+ * Overwrite files that already exist at the destination. Defaults to `false`,
14
+ * which is the whole point of this helper: never clobber the user's files.
15
+ */
16
+ force?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Recursively copy everything from `srcDir` into `destDir`, merging into any
21
+ * existing directory tree.
22
+ *
23
+ * Existing files at the destination are **never overwritten** unless
24
+ * `force` is set — they are reported in `skipped` instead. Directories are
25
+ * created as needed. Symlinks in the source are ignored for safety.
26
+ */
27
+ export async function copyTemplates(
28
+ srcDir: string,
29
+ destDir: string,
30
+ opts: CopyOptions = {},
31
+ ): Promise<CopyResult> {
32
+ const result: CopyResult = { copied: [], skipped: [] };
33
+ await walk(srcDir, srcDir, destDir, opts, result);
34
+ return result;
35
+ }
36
+
37
+ async function walk(
38
+ rootSrc: string,
39
+ src: string,
40
+ dest: string,
41
+ opts: CopyOptions,
42
+ result: CopyResult,
43
+ ): Promise<void> {
44
+ const entries = await fs.readdir(src, { withFileTypes: true });
45
+
46
+ for (const entry of entries) {
47
+ const srcPath = path.join(src, entry.name);
48
+ const destPath = path.join(dest, entry.name);
49
+ const rel = path.relative(rootSrc, srcPath);
50
+
51
+ if (entry.isDirectory()) {
52
+ // Create the directory up front so empty directories are mirrored too.
53
+ await fs.mkdir(destPath, { recursive: true });
54
+ await walk(rootSrc, srcPath, destPath, opts, result);
55
+ continue;
56
+ }
57
+
58
+ if (!entry.isFile()) {
59
+ // Skip symlinks, sockets, fifos, etc.
60
+ continue;
61
+ }
62
+
63
+ if (!opts.force && (await exists(destPath))) {
64
+ result.skipped.push(rel);
65
+ continue;
66
+ }
67
+
68
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
69
+ await fs.copyFile(srcPath, destPath);
70
+ result.copied.push(rel);
71
+ }
72
+ }
73
+
74
+ async function exists(p: string): Promise<boolean> {
75
+ try {
76
+ await fs.access(p);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }