ima2-gen 1.0.4 → 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.
package/bin/ima2.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "url";
6
6
  import { spawn, execSync } from "child_process";
7
7
  import { networkInterfaces, homedir } from "os";
8
8
  import { openUrl, resolveBin } from "./lib/platform.js";
9
+ import { detectCodexAuth } from "../lib/codexDetect.js";
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const ROOT = join(__dirname, "..");
@@ -67,12 +68,16 @@ async function setup() {
67
68
  saveConfig(config);
68
69
  console.log("\n Starting OAuth login...\n");
69
70
 
70
- // Check if codex auth exists
71
- const hasAuth =
72
- existsSync(join(HOME, ".codex", "auth.json")) ||
73
- 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;
74
74
 
75
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
+ }
76
81
  console.log(" Running 'codex login' — follow the browser prompt.\n");
77
82
  try {
78
83
  execSync(`${resolveBin("npx")} @openai/codex login`, { stdio: "inherit" });
@@ -82,7 +87,8 @@ async function setup() {
82
87
  process.exit(1);
83
88
  }
84
89
  } else {
85
- 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`);
86
92
  }
87
93
 
88
94
  saveConfig(config);
@@ -156,12 +162,22 @@ async function showStatus() {
156
162
  console.log(" Run 'ima2 setup' to configure.\n");
157
163
  }
158
164
 
159
- // Check OAuth auth files
160
- const hasCodexAuth = existsSync(join(HOME, ".codex", "auth.json"));
161
- const hasChatgptAuth = existsSync(join(HOME, ".chatgpt-local", "auth.json"));
165
+ // Check OAuth auth files + codex CLI probe
166
+ const auth = detectCodexAuth();
162
167
  console.log(` OAuth sessions:`);
163
- console.log(` ~/.codex/auth.json ${hasCodexAuth ? "✓" : "✗"}`);
164
- 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
+ }
165
181
  console.log("");
166
182
  }
167
183
 
@@ -37,6 +37,14 @@ export function resolveBin(name) {
37
37
  * spawn() wrapper that works for npm/npx/any PATH-resolved exe on Windows.
38
38
  */
39
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
+ }
40
48
  return spawn(resolveBin(name), args, { windowsHide: true, ...opts });
41
49
  }
42
50
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "GPT Image 2 generator with OAuth & API key support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/ima2.js serve",
11
- "dev": "node --watch server.js",
11
+ "dev": "node scripts/dev.mjs",
12
+ "dev:server": "node --watch server.js",
12
13
  "ui:install": "cd ui && npm install",
13
14
  "ui:dev": "cd ui && npm run dev",
14
15
  "ui:build": "cd ui && npm run build",
package/server.js CHANGED
@@ -7,6 +7,7 @@ import { spawn } from "child_process";
7
7
  import { spawnBin, onShutdown } from "./bin/lib/platform.js";
8
8
  import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync as fsReadFileSync } from "fs";
9
9
  import { homedir } from "os";
10
+ import { randomBytes } from "crypto";
10
11
  import { newNodeId, saveNode, loadNodeB64, loadNodeMeta, loadAssetB64 } from "./lib/nodeStore.js";
11
12
  import { startJob, finishJob, listJobs, setJobPhase } from "./lib/inflight.js";
12
13
  import {
@@ -18,6 +19,7 @@ import {
18
19
  saveGraph,
19
20
  ensureDefaultSession,
20
21
  } from "./lib/sessionStore.js";
22
+ import { trashAsset, restoreAsset } from "./lib/assetLifecycle.js";
21
23
 
22
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
25
  const app = express();
@@ -278,6 +280,7 @@ async function listImages(baseDir) {
278
280
  async function walk(dir, depth) {
279
281
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
280
282
  for (const e of entries) {
283
+ if (e.name === ".trash") continue;
281
284
  const full = join(dir, e.name);
282
285
  if (e.isDirectory() && depth > 0) {
283
286
  await walk(full, depth - 1);
@@ -294,7 +297,14 @@ app.get("/api/history", async (req, res) => {
294
297
  try {
295
298
  const dir = join(__dirname, "generated");
296
299
  await mkdir(dir, { recursive: true });
297
- const limit = Math.min(parseInt(req.query.limit) || 50, 200);
300
+ const limitRaw = parseInt(req.query.limit);
301
+ const limit = Math.min(Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : 50, 500);
302
+ const beforeTs = parseInt(req.query.before);
303
+ const beforeFn = typeof req.query.beforeFilename === "string" ? req.query.beforeFilename : null;
304
+ const sinceTs = parseInt(req.query.since);
305
+ const sessionId = typeof req.query.sessionId === "string" ? req.query.sessionId : null;
306
+ const groupBy = req.query.groupBy === "session" ? "session" : null;
307
+
298
308
  const imgs = await listImages(dir);
299
309
  const rows = await Promise.all(imgs.map(async ({ full, rel, name }) => {
300
310
  const st = await stat(full).catch(() => null);
@@ -316,16 +326,91 @@ app.get("/api/history", async (req, res) => {
316
326
  provider: meta?.provider || "oauth",
317
327
  usage: meta?.usage || null,
318
328
  webSearchCalls: meta?.webSearchCalls || 0,
329
+ sessionId: meta?.sessionId || null,
330
+ nodeId: meta?.nodeId || null,
331
+ parentNodeId: meta?.parentNodeId || null,
332
+ clientNodeId: meta?.clientNodeId || null,
333
+ kind: meta?.kind || null,
319
334
  };
320
335
  }));
321
- rows.sort((a, b) => b.createdAt - a.createdAt);
322
- res.json({ items: rows.slice(0, limit), total: rows.length });
336
+
337
+ // composite sort: createdAt DESC, filename DESC (stable tiebreaker)
338
+ rows.sort((a, b) => {
339
+ if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt;
340
+ return b.filename < a.filename ? -1 : b.filename > a.filename ? 1 : 0;
341
+ });
342
+
343
+ let filtered = rows;
344
+ if (Number.isFinite(sinceTs)) {
345
+ filtered = filtered.filter((r) => r.createdAt > sinceTs);
346
+ }
347
+ if (Number.isFinite(beforeTs)) {
348
+ filtered = filtered.filter((r) => {
349
+ if (r.createdAt < beforeTs) return true;
350
+ if (r.createdAt === beforeTs && beforeFn) return r.filename < beforeFn;
351
+ return false;
352
+ });
353
+ }
354
+ if (sessionId) {
355
+ filtered = filtered.filter((r) => r.sessionId === sessionId);
356
+ }
357
+
358
+ const page = filtered.slice(0, limit);
359
+ const nextCursor = page.length === limit && filtered.length > limit
360
+ ? { before: page[page.length - 1].createdAt, beforeFilename: page[page.length - 1].filename }
361
+ : null;
362
+
363
+ if (groupBy === "session") {
364
+ // Group by sessionId while preserving createdAt DESC order overall.
365
+ const groups = new Map(); // sessionId|null -> { sessionId, items, lastUsedAt }
366
+ const loose = [];
367
+ for (const r of page) {
368
+ if (r.sessionId) {
369
+ let g = groups.get(r.sessionId);
370
+ if (!g) {
371
+ g = { sessionId: r.sessionId, items: [], lastUsedAt: r.createdAt };
372
+ groups.set(r.sessionId, g);
373
+ }
374
+ g.items.push(r);
375
+ if (r.createdAt > g.lastUsedAt) g.lastUsedAt = r.createdAt;
376
+ } else {
377
+ loose.push(r);
378
+ }
379
+ }
380
+ const sessions = Array.from(groups.values()).sort((a, b) => b.lastUsedAt - a.lastUsedAt);
381
+ return res.json({ sessions, loose, total: rows.length, nextCursor });
382
+ }
383
+
384
+ res.json({ items: page, total: rows.length, nextCursor });
323
385
  } catch (err) {
324
386
  console.error("[history] error:", err.message);
325
387
  res.status(500).json({ error: err.message });
326
388
  }
327
389
  });
328
390
 
391
+ // ── Asset lifecycle: soft-delete to .trash/, auto-purge after TTL ──
392
+ app.delete("/api/history/:filename", async (req, res) => {
393
+ try {
394
+ const filename = decodeURIComponent(req.params.filename);
395
+ const result = await trashAsset(__dirname, filename);
396
+ res.json(result);
397
+ } catch (err) {
398
+ res.status(err.status || 500).json({ error: err.message, code: err.code });
399
+ }
400
+ });
401
+
402
+ app.post("/api/history/:filename/restore", async (req, res) => {
403
+ try {
404
+ const filename = decodeURIComponent(req.params.filename);
405
+ const trashId = typeof req.body?.trashId === "string" ? req.body.trashId : null;
406
+ if (!trashId) return res.status(400).json({ error: "trashId required" });
407
+ const result = await restoreAsset(__dirname, trashId, filename);
408
+ res.json(result);
409
+ } catch (err) {
410
+ res.status(err.status || 500).json({ error: err.message });
411
+ }
412
+ });
413
+
329
414
  // ── OAuth status ──
330
415
  app.get("/api/oauth/status", async (_req, res) => {
331
416
  try {
@@ -433,7 +518,8 @@ app.post("/api/generate", async (req, res) => {
433
518
  let totalWebSearchCalls = 0;
434
519
  for (const r of results) {
435
520
  if (r.status === "fulfilled" && r.value.b64) {
436
- const filename = `${Date.now()}_${images.length}.${format}`;
521
+ const rand = randomBytes(4).toString("hex");
522
+ const filename = `${Date.now()}_${rand}_${images.length}.${format}`;
437
523
  await writeFile(join(__dirname, "generated", filename), Buffer.from(r.value.b64, "base64"));
438
524
  // Sidecar metadata for /api/history reconstruction
439
525
  const meta = {
@@ -581,7 +667,7 @@ app.post("/api/edit", async (req, res) => {
581
667
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
582
668
 
583
669
  await mkdir(join(__dirname, "generated"), { recursive: true });
584
- const filename = `${Date.now()}.png`;
670
+ const filename = `${Date.now()}_${randomBytes(4).toString("hex")}.png`;
585
671
  await writeFile(join(__dirname, "generated", filename), Buffer.from(resultB64, "base64"));
586
672
  const meta = {
587
673
  prompt,
@@ -712,6 +798,8 @@ app.post("/api/node/generate", async (req, res) => {
712
798
  const meta = {
713
799
  nodeId,
714
800
  parentNodeId,
801
+ sessionId,
802
+ clientNodeId,
715
803
  prompt,
716
804
  options: { quality, size, format },
717
805
  createdAt: Date.now(),