ima2-gen 1.0.3 → 1.0.5

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.
@@ -1,8 +1,7 @@
1
1
  import { parseArgs } from "../lib/args.js";
2
2
  import { resolveServer, request } from "../lib/client.js";
3
- import { execSync } from "node:child_process";
3
+ import { openUrl } from "../lib/platform.js";
4
4
  import { out, die, color, json, exitCodeForError } from "../lib/output.js";
5
- import { join } from "node:path";
6
5
 
7
6
  const SPEC = {
8
7
  flags: {
@@ -42,16 +41,8 @@ export default async function showCmd(argv) {
42
41
 
43
42
  if (args.reveal) {
44
43
  const url = item.url ? `${server.base}${item.url}` : null;
45
- try {
46
- if (process.platform === "darwin") {
47
- execSync(`open "${server.base}${item.url || ""}"`);
48
- } else if (process.platform === "win32") {
49
- execSync(`start "" "${url}"`);
50
- } else {
51
- execSync(`xdg-open "${url}"`);
52
- }
53
- } catch {
54
- out(color.yellow("(could not reveal)"));
55
- }
44
+ if (!url) { out(color.yellow("(no url)")); return; }
45
+ const res = openUrl(url);
46
+ if (!res.ok) out(color.yellow("(could not reveal)"));
56
47
  }
57
48
  }
package/bin/ima2.js CHANGED
@@ -5,12 +5,18 @@ import { join, dirname } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { spawn, execSync } from "child_process";
7
7
  import { networkInterfaces, homedir } from "os";
8
+ import { openUrl, resolveBin } from "./lib/platform.js";
9
+ import { detectCodexAuth } from "../lib/codexDetect.js";
8
10
 
9
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
12
  const ROOT = join(__dirname, "..");
11
13
  const HOME = homedir();
12
- const CONFIG_DIR = join(ROOT, ".ima2");
14
+ // Config lives in $IMA2_CONFIG_DIR (tests) or ~/.ima2 to match server.js and
15
+ // ~/.ima2/server.json advertise path. Legacy installs that stored config at
16
+ // <packageRoot>/.ima2/config.json will be migrated on first write.
17
+ const CONFIG_DIR = process.env.IMA2_CONFIG_DIR || join(HOME, ".ima2");
13
18
  const CONFIG_FILE = join(CONFIG_DIR, "config.json");
19
+ const LEGACY_CONFIG_FILE = join(ROOT, ".ima2", "config.json");
14
20
 
15
21
  // Load package.json for version
16
22
  let pkg = { version: "?", name: "ima2-gen" };
@@ -22,6 +28,10 @@ function loadConfig() {
22
28
  if (existsSync(CONFIG_FILE)) {
23
29
  return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
24
30
  }
31
+ // One-time read from legacy location so users who set up on <1.0.4 don't lose auth.
32
+ if (existsSync(LEGACY_CONFIG_FILE)) {
33
+ try { return JSON.parse(readFileSync(LEGACY_CONFIG_FILE, "utf-8")); } catch {}
34
+ }
25
35
  return {};
26
36
  }
27
37
 
@@ -58,22 +68,27 @@ async function setup() {
58
68
  saveConfig(config);
59
69
  console.log("\n Starting OAuth login...\n");
60
70
 
61
- // Check if codex auth exists
62
- const hasAuth =
63
- existsSync(join(HOME, ".codex", "auth.json")) ||
64
- existsSync(join(HOME, ".chatgpt-local", "auth.json"));
71
+ // Check if codex auth exists (file OR keyring via `codex login status`)
72
+ const auth = detectCodexAuth();
73
+ const hasAuth = auth.authed;
65
74
 
66
75
  if (!hasAuth) {
76
+ if (auth.platform === "win32") {
77
+ console.log(
78
+ " Windows note: OpenAI Codex has no documented native installer. Use WSL2 for best results.\n",
79
+ );
80
+ }
67
81
  console.log(" Running 'codex login' — follow the browser prompt.\n");
68
82
  try {
69
- execSync("npx @openai/codex login", { stdio: "inherit" });
83
+ execSync(`${resolveBin("npx")} @openai/codex login`, { stdio: "inherit" });
70
84
  } catch {
71
85
  console.log("\n Login failed or cancelled. You can retry with 'ima2 serve'.\n");
72
86
  rl.close();
73
87
  process.exit(1);
74
88
  }
75
89
  } else {
76
- console.log(" Existing OAuth session found.\n");
90
+ const how = auth.probe === "authed" ? "codex CLI" : "auth file";
91
+ console.log(` Existing OAuth session found (${how}).\n`);
77
92
  }
78
93
 
79
94
  saveConfig(config);
@@ -98,7 +113,7 @@ async function serve() {
98
113
  if (hasUiSrc) {
99
114
  console.log("\n ui/dist missing — running 'npm run build' first...\n");
100
115
  try {
101
- execSync("npm run build", { stdio: "inherit", cwd: ROOT });
116
+ execSync(`${resolveBin("npm")} run build`, { stdio: "inherit", cwd: ROOT });
102
117
  } catch {
103
118
  console.log("\n Build failed. Try: cd ui && npm install && npm run build\n");
104
119
  process.exit(1);
@@ -147,12 +162,22 @@ async function showStatus() {
147
162
  console.log(" Run 'ima2 setup' to configure.\n");
148
163
  }
149
164
 
150
- // Check OAuth auth files
151
- const hasCodexAuth = existsSync(join(HOME, ".codex", "auth.json"));
152
- const hasChatgptAuth = existsSync(join(HOME, ".chatgpt-local", "auth.json"));
165
+ // Check OAuth auth files + codex CLI probe
166
+ const auth = detectCodexAuth();
153
167
  console.log(` OAuth sessions:`);
154
- console.log(` ~/.codex/auth.json ${hasCodexAuth ? "✓" : "✗"}`);
155
- console.log(` ~/.chatgpt-local/auth.json ${hasChatgptAuth ? "✓" : "✗"}`);
168
+ console.log(` ${auth.files.codex} ${auth.fileHits.codex ? "✓" : "✗"}`);
169
+ console.log(` ${auth.files.chatgpt} ${auth.fileHits.chatgpt ? "✓" : "✗"}`);
170
+ if (auth.fileHits.xdgCodex) {
171
+ console.log(` ${auth.files.xdgCodex} ✓`);
172
+ }
173
+ const probeLabel =
174
+ auth.probe === "authed" ? "✓ authed"
175
+ : auth.probe === "unauthed" ? "✗ not logged in"
176
+ : "– codex CLI not found";
177
+ console.log(` codex login status ${probeLabel}`);
178
+ if (auth.platform === "win32") {
179
+ console.log(" (Windows: no native codex installer — use WSL2)");
180
+ }
156
181
  console.log("");
157
182
  }
158
183
 
@@ -219,17 +244,10 @@ async function doctor() {
219
244
  function openBrowser() {
220
245
  const port = process.env.PORT || 3333;
221
246
  const url = `http://localhost:${port}`;
222
-
223
- const platform = process.platform;
224
- let cmd;
225
- if (platform === "darwin") cmd = "open";
226
- else if (platform === "win32") cmd = "start";
227
- else cmd = "xdg-open";
228
-
229
- try {
230
- execSync(`${cmd} ${url}`, { stdio: "ignore" });
247
+ const res = openUrl(url);
248
+ if (res.ok) {
231
249
  console.log(`\n Opening ${url} ...\n`);
232
- } catch {
250
+ } else {
233
251
  console.log(`\n Could not open browser. Visit: ${url}\n`);
234
252
  }
235
253
  }
@@ -0,0 +1,97 @@
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
+ if (isWin) {
41
+ // Node 24 on Windows can throw EINVAL when spawning PATH-resolved .cmd
42
+ // shims directly with piped stdio. Routing through cmd.exe avoids that.
43
+ return spawn("cmd.exe", ["/d", "/s", "/c", `${name} ${args.join(" ")}`], {
44
+ windowsHide: true,
45
+ ...opts,
46
+ });
47
+ }
48
+ return spawn(resolveBin(name), args, { windowsHide: true, ...opts });
49
+ }
50
+
51
+ /**
52
+ * Open a URL in the user's default browser.
53
+ * Returns { ok: boolean, error?: string }.
54
+ * Handles WSL (via powershell.exe) and refuses on headless Linux without DISPLAY.
55
+ */
56
+ export function openUrl(url) {
57
+ try {
58
+ if (isMac) {
59
+ execSync(`open ${JSON.stringify(url)}`, { stdio: "ignore" });
60
+ } else if (isWin) {
61
+ execSync(`cmd /c start "" ${JSON.stringify(url)}`, { stdio: "ignore" });
62
+ } else if (isWsl()) {
63
+ // WSL: hand off to Windows via powershell
64
+ execSync(`powershell.exe -NoProfile -Command Start-Process ${JSON.stringify(url)}`, { stdio: "ignore" });
65
+ } else {
66
+ if (!hasDesktopSession()) {
67
+ return { ok: false, error: "no desktop session (DISPLAY/WAYLAND_DISPLAY unset)" };
68
+ }
69
+ execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: "ignore" });
70
+ }
71
+ return { ok: true };
72
+ } catch (e) {
73
+ return { ok: false, error: e.message || String(e) };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Register graceful shutdown handlers.
79
+ * Windows does NOT raise SIGTERM from the OS — SIGINT (Ctrl+C) and SIGBREAK
80
+ * (Ctrl+Break) are the observable signals. We still register SIGTERM so that
81
+ * Node-internal `child.kill("SIGTERM")` calls work in tests.
82
+ */
83
+ export function onShutdown(handler) {
84
+ const signals = isWin
85
+ ? ["SIGINT", "SIGTERM", "SIGBREAK"]
86
+ : ["SIGINT", "SIGTERM", "SIGHUP"];
87
+ for (const sig of signals) {
88
+ try {
89
+ process.on(sig, () => {
90
+ try { handler(sig); } finally { process.exit(0); }
91
+ });
92
+ } catch {
93
+ // Some signals aren't installable on certain platforms; ignore.
94
+ }
95
+ }
96
+ }
97
+
@@ -0,0 +1,120 @@
1
+ import { getDb } from "./db.js";
2
+ import { rename, unlink, mkdir, access } from "fs/promises";
3
+ import { join, resolve, sep } from "path";
4
+
5
+ const DIR = "generated";
6
+ const TRASH = ".trash";
7
+ const TRASH_TTL_MS = 10_000;
8
+
9
+ function resolveInGenerated(rootDir, relPath) {
10
+ if (typeof relPath !== "string" || relPath.length === 0) {
11
+ const err = new Error("filename required");
12
+ err.status = 400;
13
+ err.code = "INVALID_FILENAME";
14
+ throw err;
15
+ }
16
+ if (relPath.includes("\0")) {
17
+ const err = new Error("invalid filename");
18
+ err.status = 400;
19
+ err.code = "INVALID_FILENAME";
20
+ throw err;
21
+ }
22
+ const baseDir = resolve(rootDir, DIR);
23
+ const target = resolve(baseDir, relPath);
24
+ if (target !== baseDir && !target.startsWith(baseDir + sep)) {
25
+ const err = new Error("filename escapes generated/");
26
+ err.status = 400;
27
+ err.code = "INVALID_FILENAME";
28
+ throw err;
29
+ }
30
+ return target;
31
+ }
32
+
33
+ function nodesReferencingFilename(filename) {
34
+ // The client stores imageUrl as `/generated/<encoded filename>` in node data JSON.
35
+ // We scan all sessions' nodes for substring match on the decoded and encoded forms.
36
+ const db = getDb();
37
+ const encoded = encodeURIComponent(filename);
38
+ const rows = db
39
+ .prepare("SELECT session_id AS sessionId, id, data FROM nodes WHERE data LIKE ? OR data LIKE ?")
40
+ .all(`%${filename}%`, `%${encoded}%`);
41
+ return rows;
42
+ }
43
+
44
+ function markNodesAssetMissing(filename) {
45
+ const db = getDb();
46
+ const rows = nodesReferencingFilename(filename);
47
+ if (rows.length === 0) return { sessionsTouched: 0, nodesTouched: 0 };
48
+ const touchedSessions = new Set();
49
+ const update = db.prepare("UPDATE nodes SET data = ? WHERE session_id = ? AND id = ?");
50
+ const bumpSession = db.prepare("UPDATE sessions SET graph_version = graph_version + 1, updated_at = ? WHERE id = ?");
51
+ const tx = db.transaction(() => {
52
+ for (const r of rows) {
53
+ let data;
54
+ try { data = JSON.parse(r.data); } catch { data = {}; }
55
+ const imgRef = data?.imageUrl || "";
56
+ if (imgRef.includes(filename) || imgRef.includes(encodeURIComponent(filename))) {
57
+ data.imageUrl = null;
58
+ data.status = "asset-missing";
59
+ update.run(JSON.stringify(data), r.sessionId, r.id);
60
+ touchedSessions.add(r.sessionId);
61
+ }
62
+ }
63
+ const t = Date.now();
64
+ for (const sid of touchedSessions) bumpSession.run(t, sid);
65
+ });
66
+ tx();
67
+ return { sessionsTouched: touchedSessions.size, nodesTouched: rows.length };
68
+ }
69
+
70
+ export async function trashAsset(rootDir, filename) {
71
+ const src = resolveInGenerated(rootDir, filename);
72
+ try {
73
+ await access(src);
74
+ } catch {
75
+ const err = new Error("Asset not found");
76
+ err.status = 404;
77
+ err.code = "ASSET_NOT_FOUND";
78
+ throw err;
79
+ }
80
+ const trashDir = resolve(rootDir, DIR, TRASH);
81
+ await mkdir(trashDir, { recursive: true });
82
+ // Flatten filename (subdir separators -> __) so trash is flat & easy to restore
83
+ const flat = filename.replace(/[\\/]+/g, "__");
84
+ const trashPath = join(trashDir, `${Date.now()}_${flat}`);
85
+ await rename(src, trashPath);
86
+ // Move sidecar too (best-effort)
87
+ await rename(src + ".json", trashPath + ".json").catch(() => {});
88
+
89
+ const summary = markNodesAssetMissing(filename);
90
+
91
+ // Schedule hard delete after TTL
92
+ const unlinkAt = Date.now() + TRASH_TTL_MS;
93
+ setTimeout(async () => {
94
+ await unlink(trashPath).catch(() => {});
95
+ await unlink(trashPath + ".json").catch(() => {});
96
+ }, TRASH_TTL_MS).unref?.();
97
+
98
+ return {
99
+ ok: true,
100
+ trashId: trashPath.slice(trashDir.length + 1),
101
+ filename,
102
+ unlinkAt,
103
+ sessionsTouched: summary.sessionsTouched,
104
+ nodesTouched: summary.nodesTouched,
105
+ };
106
+ }
107
+
108
+ export async function restoreAsset(rootDir, trashId, originalFilename) {
109
+ const trashDir = resolve(rootDir, DIR, TRASH);
110
+ const src = resolve(trashDir, trashId);
111
+ if (!src.startsWith(trashDir + sep) && src !== trashDir) {
112
+ const err = new Error("invalid trashId");
113
+ err.status = 400;
114
+ throw err;
115
+ }
116
+ const dst = resolveInGenerated(rootDir, originalFilename);
117
+ await rename(src, dst);
118
+ await rename(src + ".json", dst + ".json").catch(() => {});
119
+ return { ok: true };
120
+ }
@@ -0,0 +1,69 @@
1
+ // Codex CLI / OAuth auth detection across platforms.
2
+ // References:
3
+ // - OpenAI Codex stores auth under CODEX_HOME (default ~/.codex/auth.json).
4
+ // - Legacy chatgpt-local stores auth under ~/.chatgpt-local/auth.json.
5
+ // - Auth may live in OS keyring instead of a file (file absence ≠ unauth).
6
+ // - Windows has no documented native install path; WSL is the supported path.
7
+ import { existsSync } from "node:fs";
8
+ import { execFileSync } from "node:child_process";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const HOME = homedir();
13
+
14
+ export function codexAuthPaths() {
15
+ const codexHome = process.env.CODEX_HOME || join(HOME, ".codex");
16
+ return {
17
+ codex: join(codexHome, "auth.json"),
18
+ chatgpt: join(HOME, ".chatgpt-local", "auth.json"),
19
+ xdgCodex: join(HOME, ".config", "codex", "auth.json"),
20
+ };
21
+ }
22
+
23
+ export function hasAuthFile() {
24
+ const p = codexAuthPaths();
25
+ return existsSync(p.codex) || existsSync(p.chatgpt) || existsSync(p.xdgCodex);
26
+ }
27
+
28
+ // Non-invasive probe: `codex login status` returns 0 when authed (file OR keyring).
29
+ // Returns: "authed" | "unauthed" | "missing" (codex binary not found)
30
+ export function codexLoginStatus(timeoutMs = 2000) {
31
+ const candidates =
32
+ process.platform === "win32"
33
+ ? ["codex.cmd", "codex.exe", "codex"]
34
+ : ["codex"];
35
+ for (const bin of candidates) {
36
+ try {
37
+ execFileSync(bin, ["login", "status"], {
38
+ stdio: "ignore",
39
+ timeout: timeoutMs,
40
+ windowsHide: true,
41
+ });
42
+ return "authed";
43
+ } catch (err) {
44
+ if (err && err.code === "ENOENT") continue;
45
+ // non-zero exit = binary exists but not authed
46
+ if (err && typeof err.status === "number") return "unauthed";
47
+ }
48
+ }
49
+ return "missing";
50
+ }
51
+
52
+ export function detectCodexAuth() {
53
+ const files = codexAuthPaths();
54
+ const fileHits = {
55
+ codex: existsSync(files.codex),
56
+ chatgpt: existsSync(files.chatgpt),
57
+ xdgCodex: existsSync(files.xdgCodex),
58
+ };
59
+ const probe = codexLoginStatus();
60
+ const authed = probe === "authed" || fileHits.codex || fileHits.chatgpt || fileHits.xdgCodex;
61
+ return {
62
+ authed,
63
+ probe,
64
+ files,
65
+ fileHits,
66
+ platform: process.platform,
67
+ wslHint: process.platform === "win32",
68
+ };
69
+ }
package/lib/db.js ADDED
@@ -0,0 +1,92 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync, existsSync } from "fs";
3
+ import { dirname, join } from "path";
4
+ import { homedir } from "os";
5
+
6
+ const DEFAULT_DB_DIR = join(homedir(), ".ima2");
7
+ const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, "sessions.db");
8
+
9
+ let db = null;
10
+
11
+ export function getDbPath() {
12
+ return process.env.IMA2_DB_PATH || DEFAULT_DB_PATH;
13
+ }
14
+
15
+ export function getDb() {
16
+ if (db) return db;
17
+ const dbPath = getDbPath();
18
+ const dir = dirname(dbPath);
19
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
20
+
21
+ db = new Database(dbPath);
22
+ db.pragma("journal_mode = WAL");
23
+ db.pragma("foreign_keys = ON");
24
+ migrate(db);
25
+ return db;
26
+ }
27
+
28
+ function migrate(database) {
29
+ database.exec(`
30
+ CREATE TABLE IF NOT EXISTS _meta (
31
+ key TEXT PRIMARY KEY,
32
+ value TEXT NOT NULL
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS sessions (
36
+ id TEXT PRIMARY KEY,
37
+ title TEXT NOT NULL DEFAULT 'Untitled',
38
+ created_at INTEGER NOT NULL,
39
+ updated_at INTEGER NOT NULL,
40
+ graph_version INTEGER NOT NULL DEFAULT 0
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS nodes (
44
+ session_id TEXT NOT NULL,
45
+ id TEXT NOT NULL,
46
+ x REAL NOT NULL DEFAULT 0,
47
+ y REAL NOT NULL DEFAULT 0,
48
+ data TEXT NOT NULL DEFAULT '{}',
49
+ PRIMARY KEY (session_id, id),
50
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS edges (
54
+ session_id TEXT NOT NULL,
55
+ id TEXT NOT NULL,
56
+ source TEXT NOT NULL,
57
+ target TEXT NOT NULL,
58
+ data TEXT NOT NULL DEFAULT '{}',
59
+ PRIMARY KEY (session_id, id),
60
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_nodes_session ON nodes(session_id);
64
+ CREATE INDEX IF NOT EXISTS idx_edges_session ON edges(session_id);
65
+ `);
66
+
67
+ const sessionColumns = database
68
+ .prepare("PRAGMA table_info(sessions)")
69
+ .all()
70
+ .map((row) => row.name);
71
+ if (!sessionColumns.includes("graph_version")) {
72
+ database.exec(
73
+ "ALTER TABLE sessions ADD COLUMN graph_version INTEGER NOT NULL DEFAULT 0",
74
+ );
75
+ }
76
+
77
+ const row = database.prepare("SELECT value FROM _meta WHERE key = 'schema_version'").get();
78
+ if (!row) {
79
+ database.prepare("INSERT INTO _meta (key, value) VALUES ('schema_version', '2')").run();
80
+ } else if (row.value !== "2") {
81
+ database
82
+ .prepare("UPDATE _meta SET value = '2' WHERE key = 'schema_version'")
83
+ .run();
84
+ }
85
+ }
86
+
87
+ export function closeDb() {
88
+ if (db) {
89
+ db.close();
90
+ db = null;
91
+ }
92
+ }
@@ -0,0 +1,57 @@
1
+ // In-memory inflight job registry.
2
+ // Tracks generation requests that are currently running on the server so clients
3
+ // can reconcile optimistic UI state after a reload or across tabs.
4
+ //
5
+ // This is intentionally process-local: if the server restarts, inflight jobs
6
+ // are lost (which is correct — the fetch they came from is already gone).
7
+
8
+ const jobs = new Map(); // requestId -> { requestId, kind, prompt, meta, startedAt, phase, phaseAt }
9
+
10
+ // Phases: "queued" → "streaming" (upstream connection open, waiting for image)
11
+ // → "decoding" (b64 received, writing to disk)
12
+ // finishJob removes the entry entirely.
13
+ export function startJob({ requestId, kind, prompt, meta = {} }) {
14
+ if (!requestId) return;
15
+ jobs.set(requestId, {
16
+ requestId,
17
+ kind,
18
+ prompt: typeof prompt === "string" ? prompt.slice(0, 500) : "",
19
+ meta,
20
+ startedAt: Date.now(),
21
+ phase: "queued",
22
+ phaseAt: Date.now(),
23
+ });
24
+ }
25
+
26
+ export function setJobPhase(requestId, phase) {
27
+ if (!requestId) return;
28
+ const j = jobs.get(requestId);
29
+ if (!j) return;
30
+ j.phase = phase;
31
+ j.phaseAt = Date.now();
32
+ }
33
+
34
+ export function finishJob(requestId, _options = {}) {
35
+ if (!requestId) return;
36
+ jobs.delete(requestId);
37
+ }
38
+
39
+ export function listJobs(filters = {}) {
40
+ // Stale reaping: > 10 min is almost certainly a crashed fetch.
41
+ const now = Date.now();
42
+ for (const [id, j] of jobs) {
43
+ if (now - j.startedAt > 10 * 60 * 1000) jobs.delete(id);
44
+ }
45
+ const { kind, sessionId } = filters;
46
+ return Array.from(jobs.values())
47
+ .filter((j) => {
48
+ if (kind && j.kind !== kind) return false;
49
+ if (sessionId && j.meta?.sessionId !== sessionId) return false;
50
+ return true;
51
+ })
52
+ .sort((a, b) => a.startedAt - b.startedAt);
53
+ }
54
+
55
+ export function _resetForTests() {
56
+ jobs.clear();
57
+ }
@@ -0,0 +1,66 @@
1
+ import { writeFile, readFile, access } from "fs/promises";
2
+ import { join, resolve, sep } from "path";
3
+ import { randomBytes } from "crypto";
4
+
5
+ const DIR = "generated";
6
+
7
+ export function newNodeId() {
8
+ return "n_" + randomBytes(5).toString("hex");
9
+ }
10
+
11
+ export async function saveNode(rootDir, { nodeId, b64, meta, ext = "png" }) {
12
+ const filename = `${nodeId}.${ext}`;
13
+ await writeFile(join(rootDir, DIR, filename), Buffer.from(b64, "base64"));
14
+ await writeFile(join(rootDir, DIR, filename + ".json"), JSON.stringify(meta, null, 2));
15
+ return { filename };
16
+ }
17
+
18
+ export async function loadNodeB64(rootDir, filename) {
19
+ const p = resolveGeneratedPath(rootDir, filename);
20
+ try { await access(p); } catch {
21
+ const err = new Error(`Node file not found: ${filename}`);
22
+ err.code = "NODE_NOT_FOUND";
23
+ err.status = 404;
24
+ throw err;
25
+ }
26
+ const buf = await readFile(p);
27
+ return buf.toString("base64");
28
+ }
29
+
30
+ export async function loadNodeMeta(rootDir, nodeId, ext = "png") {
31
+ try {
32
+ return JSON.parse(await readFile(join(rootDir, DIR, `${nodeId}.${ext}.json`), "utf-8"));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export async function loadAssetB64(rootDir, externalSrc) {
39
+ const p = resolveGeneratedPath(rootDir, externalSrc);
40
+ try { await access(p); } catch {
41
+ const err = new Error(`Asset file not found: ${externalSrc}`);
42
+ err.code = "NODE_NOT_FOUND";
43
+ err.status = 404;
44
+ throw err;
45
+ }
46
+ const buf = await readFile(p);
47
+ return buf.toString("base64");
48
+ }
49
+
50
+ function resolveGeneratedPath(rootDir, relPath) {
51
+ if (typeof relPath !== "string" || relPath.length === 0) {
52
+ const err = new Error("Asset path is required");
53
+ err.code = "NODE_SOURCE_INVALID";
54
+ err.status = 400;
55
+ throw err;
56
+ }
57
+ const baseDir = resolve(rootDir, DIR);
58
+ const target = resolve(baseDir, relPath);
59
+ if (target !== baseDir && !target.startsWith(baseDir + sep)) {
60
+ const err = new Error(`Asset path escapes generated/: ${relPath}`);
61
+ err.code = "NODE_SOURCE_INVALID";
62
+ err.status = 400;
63
+ throw err;
64
+ }
65
+ return target;
66
+ }