ima2-gen 1.0.2 → 1.0.4

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.
package/bin/ima2.js CHANGED
@@ -4,16 +4,33 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
4
  import { join, dirname } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { spawn, execSync } from "child_process";
7
+ import { networkInterfaces, homedir } from "os";
8
+ import { openUrl, resolveBin } from "./lib/platform.js";
7
9
 
8
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
11
  const ROOT = join(__dirname, "..");
10
- const CONFIG_DIR = join(ROOT, ".ima2");
12
+ const HOME = homedir();
13
+ // Config lives in $IMA2_CONFIG_DIR (tests) or ~/.ima2 to match server.js and
14
+ // ~/.ima2/server.json advertise path. Legacy installs that stored config at
15
+ // <packageRoot>/.ima2/config.json will be migrated on first write.
16
+ const CONFIG_DIR = process.env.IMA2_CONFIG_DIR || join(HOME, ".ima2");
11
17
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
18
+ const LEGACY_CONFIG_FILE = join(ROOT, ".ima2", "config.json");
19
+
20
+ // Load package.json for version
21
+ let pkg = { version: "?", name: "ima2-gen" };
22
+ try {
23
+ pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
24
+ } catch {}
12
25
 
13
26
  function loadConfig() {
14
27
  if (existsSync(CONFIG_FILE)) {
15
28
  return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
16
29
  }
30
+ // One-time read from legacy location so users who set up on <1.0.4 don't lose auth.
31
+ if (existsSync(LEGACY_CONFIG_FILE)) {
32
+ try { return JSON.parse(readFileSync(LEGACY_CONFIG_FILE, "utf-8")); } catch {}
33
+ }
17
34
  return {};
18
35
  }
19
36
 
@@ -52,13 +69,13 @@ async function setup() {
52
69
 
53
70
  // Check if codex auth exists
54
71
  const hasAuth =
55
- existsSync(join(process.env.HOME, ".codex", "auth.json")) ||
56
- existsSync(join(process.env.HOME, ".chatgpt-local", "auth.json"));
72
+ existsSync(join(HOME, ".codex", "auth.json")) ||
73
+ existsSync(join(HOME, ".chatgpt-local", "auth.json"));
57
74
 
58
75
  if (!hasAuth) {
59
76
  console.log(" Running 'codex login' — follow the browser prompt.\n");
60
77
  try {
61
- execSync("npx @openai/codex login", { stdio: "inherit" });
78
+ execSync(`${resolveBin("npx")} @openai/codex login`, { stdio: "inherit" });
62
79
  } catch {
63
80
  console.log("\n Login failed or cancelled. You can retry with 'ima2 serve'.\n");
64
81
  rl.close();
@@ -83,6 +100,25 @@ async function serve() {
83
100
  config = await setup();
84
101
  }
85
102
 
103
+ // Ensure ui/dist exists — if missing, auto-build (dev) or error (installed pkg)
104
+ const distIndex = join(ROOT, "ui", "dist", "index.html");
105
+ if (!existsSync(distIndex)) {
106
+ const hasUiSrc = existsSync(join(ROOT, "ui", "package.json"));
107
+ if (hasUiSrc) {
108
+ console.log("\n ui/dist missing — running 'npm run build' first...\n");
109
+ try {
110
+ execSync(`${resolveBin("npm")} run build`, { stdio: "inherit", cwd: ROOT });
111
+ } catch {
112
+ console.log("\n Build failed. Try: cd ui && npm install && npm run build\n");
113
+ process.exit(1);
114
+ }
115
+ } else {
116
+ console.log("\n ui/dist not found and ui/ source is missing.");
117
+ console.log(" This installation appears broken. Reinstall: npm i -g ima2-gen\n");
118
+ process.exit(1);
119
+ }
120
+ }
121
+
86
122
  const env = { ...process.env };
87
123
 
88
124
  if (config.provider === "api" && config.apiKey) {
@@ -102,10 +138,154 @@ async function serve() {
102
138
  process.on("SIGTERM", () => child.kill("SIGTERM"));
103
139
  }
104
140
 
141
+ async function showStatus() {
142
+ const config = loadConfig();
143
+ console.log(`\n ${pkg.name} v${pkg.version}\n`);
144
+ console.log(` Config file: ${CONFIG_FILE}`);
145
+ console.log(` Exists: ${existsSync(CONFIG_FILE) ? "yes" : "no"}\n`);
146
+
147
+ if (config.provider) {
148
+ console.log(` Provider: ${config.provider}`);
149
+ if (config.provider === "api") {
150
+ const key = config.apiKey || "";
151
+ console.log(` API Key: ${key ? key.slice(0, 8) + "..." + key.slice(-4) : "not set"}`);
152
+ }
153
+ console.log("");
154
+ } else {
155
+ console.log(" Status: not configured");
156
+ console.log(" Run 'ima2 setup' to configure.\n");
157
+ }
158
+
159
+ // Check OAuth auth files
160
+ const hasCodexAuth = existsSync(join(HOME, ".codex", "auth.json"));
161
+ const hasChatgptAuth = existsSync(join(HOME, ".chatgpt-local", "auth.json"));
162
+ console.log(` OAuth sessions:`);
163
+ console.log(` ~/.codex/auth.json ${hasCodexAuth ? "✓" : "✗"}`);
164
+ console.log(` ~/.chatgpt-local/auth.json ${hasChatgptAuth ? "✓" : "✗"}`);
165
+ console.log("");
166
+ }
167
+
168
+ async function doctor() {
169
+ console.log(`\n ${pkg.name} v${pkg.version} — Doctor\n`);
170
+
171
+ let ok = 0;
172
+ let fail = 0;
173
+
174
+ // Node version
175
+ const nodeVersion = process.version;
176
+ const nodeMajor = parseInt(nodeVersion.slice(1).split(".")[0]);
177
+ if (nodeMajor >= 18) {
178
+ console.log(` ✓ Node.js ${nodeVersion} (>= 18)`);
179
+ ok++;
180
+ } else {
181
+ console.log(` ✗ Node.js ${nodeVersion} (requires >= 18)`);
182
+ fail++;
183
+ }
184
+
185
+ // package.json exists
186
+ if (existsSync(join(ROOT, "package.json"))) {
187
+ console.log(" ✓ package.json found");
188
+ ok++;
189
+ } else {
190
+ console.log(" ✗ package.json missing");
191
+ fail++;
192
+ }
193
+
194
+ // node_modules
195
+ if (existsSync(join(ROOT, "node_modules"))) {
196
+ console.log(" ✓ node_modules installed");
197
+ ok++;
198
+ } else {
199
+ console.log(" ✗ node_modules missing — run 'npm install'");
200
+ fail++;
201
+ }
202
+
203
+ // .env
204
+ if (existsSync(join(ROOT, ".env"))) {
205
+ console.log(" ✓ .env file exists");
206
+ ok++;
207
+ } else {
208
+ console.log(" ⚠ .env file not found (optional — copy from .env.example)");
209
+ }
210
+
211
+ // Config
212
+ const config = loadConfig();
213
+ if (config.provider) {
214
+ console.log(` ✓ Configured: ${config.provider}`);
215
+ ok++;
216
+ } else {
217
+ console.log(" ⚠ Not configured — run 'ima2 setup'");
218
+ }
219
+
220
+ // Port availability (simple check)
221
+ const port = process.env.PORT || 3333;
222
+ console.log(` ℹ Default port: ${port}`);
223
+
224
+ console.log(`\n ${ok} passed, ${fail} failed\n`);
225
+ process.exit(fail > 0 ? 1 : 0);
226
+ }
227
+
228
+ function openBrowser() {
229
+ const port = process.env.PORT || 3333;
230
+ const url = `http://localhost:${port}`;
231
+ const res = openUrl(url);
232
+ if (res.ok) {
233
+ console.log(`\n Opening ${url} ...\n`);
234
+ } else {
235
+ console.log(`\n Could not open browser. Visit: ${url}\n`);
236
+ }
237
+ }
238
+
239
+ function showHelp() {
240
+ console.log(`
241
+ ${pkg.name} v${pkg.version} — GPT Image 2 Generator
242
+
243
+ Usage: ima2 <command> [options]
244
+
245
+ Server commands:
246
+ serve Start the image generation server
247
+ setup, login Configure API key or OAuth (interactive)
248
+ status Show current configuration status
249
+ doctor Diagnose environment and setup
250
+ open Open web UI in browser
251
+ reset Reset configuration
252
+
253
+ Client commands (require a running 'ima2 serve'):
254
+ gen <prompt> Generate image(s) from prompt (ima2 gen --help)
255
+ edit <file> Edit an existing image (ima2 edit --help)
256
+ ls List recent history (ima2 ls --help)
257
+ show <name> Show one history item (ima2 show --help)
258
+ ps List active jobs (ima2 ps --help)
259
+ ping Ping running server / check health
260
+
261
+ Options:
262
+ -v, --version Show version
263
+ -h, --help Show help
264
+
265
+ Examples:
266
+ ima2 serve Start server
267
+ ima2 gen "a shiba in space" Generate from CLI
268
+ ima2 gen "merge" --ref a.png --ref b.png -q high -o out.png
269
+ ima2 ls -n 10 Last 10 generations
270
+ ima2 ping Health check
271
+ `);
272
+ }
273
+
105
274
  // ── CLI ──
106
275
  const args = process.argv.slice(2);
107
276
  const command = args[0];
108
277
 
278
+ if (args.includes("-v") || args.includes("--version")) {
279
+ console.log(pkg.version);
280
+ process.exit(0);
281
+ }
282
+
283
+ if ((!command || args.includes("-h") || args.includes("--help"))
284
+ && !["gen", "edit", "ls", "show", "ps", "ping"].includes(command)) {
285
+ showHelp();
286
+ process.exit(command ? 0 : 1);
287
+ }
288
+
109
289
  switch (command) {
110
290
  case "serve":
111
291
  serve();
@@ -114,21 +294,37 @@ switch (command) {
114
294
  case "login":
115
295
  setup().then(() => console.log(" Done. Run 'ima2 serve' to start."));
116
296
  break;
297
+ case "status":
298
+ showStatus();
299
+ break;
300
+ case "doctor":
301
+ doctor();
302
+ break;
303
+ case "open":
304
+ openBrowser();
305
+ break;
117
306
  case "reset":
118
307
  if (existsSync(CONFIG_FILE)) {
119
308
  writeFileSync(CONFIG_FILE, "{}");
120
309
  console.log(" Config reset. Run 'ima2 serve' to reconfigure.");
310
+ } else {
311
+ console.log(" No config to reset.");
121
312
  }
122
313
  break;
314
+ case "gen":
315
+ case "edit":
316
+ case "ls":
317
+ case "show":
318
+ case "ps":
319
+ case "ping": {
320
+ const { setCliVersion } = await import("./lib/client.js");
321
+ setCliVersion(pkg.version);
322
+ const mod = await import(`./commands/${command}.js`);
323
+ await mod.default(args.slice(1));
324
+ break;
325
+ }
123
326
  default:
124
- console.log(`
125
- ima2-gen GPT Image 2 Generator
126
-
127
- Usage:
128
- ima2 serve Start the image generation server
129
- ima2 setup Configure API key or OAuth (interactive)
130
- ima2 reset Reset configuration
131
-
132
- First run of 'ima2 serve' will prompt for setup automatically.
133
- `);
327
+ console.log(` Unknown command: "${command}"`);
328
+ console.log(" Run 'ima2 --help' for usage.\n");
329
+ process.exit(1);
134
330
  }
@@ -0,0 +1,73 @@
1
+ // Tiny argv parser — no dependencies.
2
+ // Supports: --long, --long=val, --long val, -s, -s val, repeatable flags, positional, --.
3
+
4
+ export function parseArgs(argv, spec = {}) {
5
+ const shortMap = {};
6
+ for (const [name, def] of Object.entries(spec.flags || {})) {
7
+ if (def.short) shortMap[def.short] = name;
8
+ }
9
+
10
+ const out = { positional: [], _unknown: [] };
11
+ for (const [name, def] of Object.entries(spec.flags || {})) {
12
+ if (def.repeatable) out[name] = [];
13
+ else if ("default" in def) out[name] = def.default;
14
+ }
15
+
16
+ let i = 0;
17
+ let doubleDashSeen = false;
18
+ while (i < argv.length) {
19
+ const a = argv[i];
20
+ if (doubleDashSeen) {
21
+ out.positional.push(a);
22
+ i++;
23
+ continue;
24
+ }
25
+ if (a === "--") {
26
+ doubleDashSeen = true;
27
+ i++;
28
+ continue;
29
+ }
30
+ if (a.startsWith("--")) {
31
+ const eq = a.indexOf("=");
32
+ const name = eq > -1 ? a.slice(2, eq) : a.slice(2);
33
+ const def = (spec.flags || {})[name];
34
+ if (!def) {
35
+ out._unknown.push(a);
36
+ i++;
37
+ continue;
38
+ }
39
+ if (def.type === "boolean") {
40
+ out[name] = true;
41
+ i++;
42
+ } else {
43
+ const val = eq > -1 ? a.slice(eq + 1) : argv[i + 1];
44
+ if (eq === -1) i++;
45
+ if (def.repeatable) out[name].push(val);
46
+ else out[name] = val;
47
+ i++;
48
+ }
49
+ } else if (a.startsWith("-") && a.length > 1 && !/^-\d/.test(a)) {
50
+ const short = a.slice(1);
51
+ const name = shortMap[short];
52
+ if (!name) {
53
+ out._unknown.push(a);
54
+ i++;
55
+ continue;
56
+ }
57
+ const def = spec.flags[name];
58
+ if (def.type === "boolean") {
59
+ out[name] = true;
60
+ i++;
61
+ } else {
62
+ const val = argv[i + 1];
63
+ if (def.repeatable) out[name].push(val);
64
+ else out[name] = val;
65
+ i += 2;
66
+ }
67
+ } else {
68
+ out.positional.push(a);
69
+ i++;
70
+ }
71
+ }
72
+ return out;
73
+ }
@@ -0,0 +1,97 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ export const DEFAULT_PORT = 3333;
6
+
7
+ function readAdvertise() {
8
+ const p = join(homedir(), ".ima2", "server.json");
9
+ if (!existsSync(p)) return null;
10
+ try {
11
+ return JSON.parse(readFileSync(p, "utf-8"));
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ async function probe(base, timeoutMs = 600) {
18
+ try {
19
+ const r = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(timeoutMs) });
20
+ if (!r.ok) return null;
21
+ return await r.json();
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export async function resolveServer({ serverFlag } = {}) {
28
+ if (serverFlag) {
29
+ const base = serverFlag.replace(/\/$/, "");
30
+ const health = await probe(base);
31
+ if (health) return { base, health };
32
+ const err = new Error(`server unreachable at ${base}`);
33
+ err.code = "SERVER_UNREACHABLE";
34
+ throw err;
35
+ }
36
+ const candidates = [];
37
+ if (process.env.IMA2_SERVER) candidates.push(process.env.IMA2_SERVER.replace(/\/$/, ""));
38
+ const adv = readAdvertise();
39
+ if (adv?.port) candidates.push(`http://localhost:${adv.port}`);
40
+ candidates.push(`http://localhost:${DEFAULT_PORT}`);
41
+
42
+ const seen = new Set();
43
+ const uniq = candidates.filter((c) => !seen.has(c) && seen.add(c));
44
+
45
+ for (const base of uniq) {
46
+ const health = await probe(base);
47
+ if (health) return { base, health };
48
+ }
49
+ const err = new Error("server unreachable — is 'ima2 serve' running?");
50
+ err.code = "SERVER_UNREACHABLE";
51
+ throw err;
52
+ }
53
+
54
+ export async function request(base, path, { method = "GET", body, timeoutMs = 180_000 } = {}) {
55
+ const res = await fetch(base + path, {
56
+ method,
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ "X-ima2-client": `cli/${CLI_VERSION}`,
60
+ },
61
+ body: body !== undefined ? JSON.stringify(body) : undefined,
62
+ signal: AbortSignal.timeout(timeoutMs),
63
+ });
64
+ const text = await res.text();
65
+ let json = null;
66
+ try { json = JSON.parse(text); } catch {}
67
+ if (!res.ok) {
68
+ const err = new Error(json?.error || `HTTP ${res.status}`);
69
+ err.status = res.status;
70
+ err.code = json?.code || null;
71
+ err.body = json || text;
72
+ throw err;
73
+ }
74
+ return json;
75
+ }
76
+
77
+ export function normalizeGenerate(resp) {
78
+ if (!resp) return { images: [], elapsed: null, requestId: null };
79
+ if (Array.isArray(resp.images)) {
80
+ return {
81
+ images: resp.images.map((it) => ({ image: it.image, filename: it.filename })),
82
+ elapsed: resp.elapsed ?? null,
83
+ requestId: resp.requestId ?? null,
84
+ };
85
+ }
86
+ if (resp.image) {
87
+ return {
88
+ images: [{ image: resp.image, filename: resp.filename || null }],
89
+ elapsed: resp.elapsed ?? null,
90
+ requestId: resp.requestId ?? null,
91
+ };
92
+ }
93
+ return { images: [], elapsed: resp.elapsed ?? null, requestId: resp.requestId ?? null };
94
+ }
95
+
96
+ export let CLI_VERSION = "dev";
97
+ export function setCliVersion(v) { CLI_VERSION = v; }
@@ -0,0 +1,39 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname, extname, basename, join } from "node:path";
3
+
4
+ const MIME = {
5
+ png: "image/png",
6
+ jpg: "image/jpeg",
7
+ jpeg: "image/jpeg",
8
+ webp: "image/webp",
9
+ };
10
+
11
+ export async function fileToDataUri(path) {
12
+ const b64 = (await readFile(path)).toString("base64");
13
+ const ext = extname(path).slice(1).toLowerCase();
14
+ const mime = MIME[ext] || "image/png";
15
+ return `data:${mime};base64,${b64}`;
16
+ }
17
+
18
+ export async function dataUriToFile(dataUri, outPath) {
19
+ const m = dataUri.match(/^data:([^;]+);base64,(.+)$/);
20
+ const raw = m ? m[2] : dataUri;
21
+ await mkdir(dirname(outPath) || ".", { recursive: true });
22
+ await writeFile(outPath, Buffer.from(raw, "base64"));
23
+ return outPath;
24
+ }
25
+
26
+ export function defaultOutName(index, total, ext = "png") {
27
+ const now = new Date();
28
+ const pad = (n) => String(n).padStart(2, "0");
29
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
30
+ if (total <= 1) return `ima2-${stamp}.${ext}`;
31
+ return `ima2-${stamp}-${index}.${ext}`;
32
+ }
33
+
34
+ export async function readStdin() {
35
+ if (process.stdin.isTTY) return "";
36
+ const chunks = [];
37
+ for await (const c of process.stdin) chunks.push(c);
38
+ return Buffer.concat(chunks).toString("utf-8").trim();
39
+ }
@@ -0,0 +1,48 @@
1
+ const isTty = process.stdout.isTTY && !process.env.NO_COLOR;
2
+
3
+ export const color = {
4
+ dim: (s) => (isTty ? `\x1b[2m${s}\x1b[0m` : s),
5
+ bold: (s) => (isTty ? `\x1b[1m${s}\x1b[0m` : s),
6
+ red: (s) => (isTty ? `\x1b[31m${s}\x1b[0m` : s),
7
+ green: (s) => (isTty ? `\x1b[32m${s}\x1b[0m` : s),
8
+ yellow: (s) => (isTty ? `\x1b[33m${s}\x1b[0m` : s),
9
+ cyan: (s) => (isTty ? `\x1b[36m${s}\x1b[0m` : s),
10
+ };
11
+
12
+ export function out(msg = "") { process.stdout.write(msg + "\n"); }
13
+ export function err(msg = "") { process.stderr.write(msg + "\n"); }
14
+
15
+ export function die(code, msg) {
16
+ if (msg) err(color.red("✗ ") + msg);
17
+ process.exit(code);
18
+ }
19
+
20
+ export function json(obj) {
21
+ process.stdout.write(JSON.stringify(obj) + "\n");
22
+ }
23
+
24
+ export function table(rows, columns) {
25
+ if (rows.length === 0) return;
26
+ const widths = columns.map((c) =>
27
+ Math.max(c.label.length, ...rows.map((r) => {
28
+ const v = c.format ? c.format(r[c.key], r) : r[c.key];
29
+ return String(v ?? "").length;
30
+ })),
31
+ );
32
+ const pad = (s, w) => String(s ?? "").padEnd(w);
33
+ out(color.dim(columns.map((c, i) => pad(c.label, widths[i])).join(" ")));
34
+ out(color.dim(widths.map((w) => "─".repeat(w)).join(" ")));
35
+ for (const r of rows) {
36
+ out(columns.map((c, i) => pad(c.format ? c.format(r[c.key], r) : r[c.key], widths[i])).join(" "));
37
+ }
38
+ }
39
+
40
+ export function exitCodeForError(e) {
41
+ if (e.code === "SERVER_UNREACHABLE") return 3;
42
+ if (e.code === "APIKEY_DISABLED") return 4;
43
+ if (e.code === "SAFETY_REFUSAL") return 7;
44
+ if (e.name === "TimeoutError" || /abort/i.test(e.message || "")) return 8;
45
+ if (e.status >= 500) return 6;
46
+ if (e.status >= 400) return 5;
47
+ return 1;
48
+ }
@@ -0,0 +1,89 @@
1
+ // Cross-platform helpers (Windows / macOS / Linux / WSL).
2
+ // Keep this file tiny & dependency-free. Node 18+ only.
3
+
4
+ import { spawn, execSync } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+
7
+ export const isWin = process.platform === "win32";
8
+ export const isMac = process.platform === "darwin";
9
+ export const isLinux = !isWin && !isMac;
10
+
11
+ let _wslCached = null;
12
+ export function isWsl() {
13
+ if (_wslCached !== null) return _wslCached;
14
+ if (!isLinux) return (_wslCached = false);
15
+ try {
16
+ _wslCached = readFileSync("/proc/version", "utf-8").toLowerCase().includes("microsoft");
17
+ } catch {
18
+ _wslCached = false;
19
+ }
20
+ return _wslCached;
21
+ }
22
+
23
+ export function hasDesktopSession() {
24
+ return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
25
+ }
26
+
27
+ /**
28
+ * Resolve an executable name that differs between Windows and Unix.
29
+ * On Windows, npm global shims are .cmd files; spawn() without shell:true
30
+ * cannot resolve them and fails with ENOENT.
31
+ */
32
+ export function resolveBin(name) {
33
+ return isWin ? `${name}.cmd` : name;
34
+ }
35
+
36
+ /**
37
+ * spawn() wrapper that works for npm/npx/any PATH-resolved exe on Windows.
38
+ */
39
+ export function spawnBin(name, args, opts = {}) {
40
+ return spawn(resolveBin(name), args, { windowsHide: true, ...opts });
41
+ }
42
+
43
+ /**
44
+ * Open a URL in the user's default browser.
45
+ * Returns { ok: boolean, error?: string }.
46
+ * Handles WSL (via powershell.exe) and refuses on headless Linux without DISPLAY.
47
+ */
48
+ export function openUrl(url) {
49
+ try {
50
+ if (isMac) {
51
+ execSync(`open ${JSON.stringify(url)}`, { stdio: "ignore" });
52
+ } else if (isWin) {
53
+ execSync(`cmd /c start "" ${JSON.stringify(url)}`, { stdio: "ignore" });
54
+ } else if (isWsl()) {
55
+ // WSL: hand off to Windows via powershell
56
+ execSync(`powershell.exe -NoProfile -Command Start-Process ${JSON.stringify(url)}`, { stdio: "ignore" });
57
+ } else {
58
+ if (!hasDesktopSession()) {
59
+ return { ok: false, error: "no desktop session (DISPLAY/WAYLAND_DISPLAY unset)" };
60
+ }
61
+ execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: "ignore" });
62
+ }
63
+ return { ok: true };
64
+ } catch (e) {
65
+ return { ok: false, error: e.message || String(e) };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Register graceful shutdown handlers.
71
+ * Windows does NOT raise SIGTERM from the OS — SIGINT (Ctrl+C) and SIGBREAK
72
+ * (Ctrl+Break) are the observable signals. We still register SIGTERM so that
73
+ * Node-internal `child.kill("SIGTERM")` calls work in tests.
74
+ */
75
+ export function onShutdown(handler) {
76
+ const signals = isWin
77
+ ? ["SIGINT", "SIGTERM", "SIGBREAK"]
78
+ : ["SIGINT", "SIGTERM", "SIGHUP"];
79
+ for (const sig of signals) {
80
+ try {
81
+ process.on(sig, () => {
82
+ try { handler(sig); } finally { process.exit(0); }
83
+ });
84
+ } catch {
85
+ // Some signals aren't installable on certain platforms; ignore.
86
+ }
87
+ }
88
+ }
89
+