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.
@@ -0,0 +1,182 @@
1
+ import { ulid } from "ulid";
2
+ import { getDb } from "./db.js";
3
+
4
+ function now() {
5
+ return Date.now();
6
+ }
7
+
8
+ export function createSession({ title = "Untitled" } = {}) {
9
+ const db = getDb();
10
+ const id = "s_" + ulid();
11
+ const t = now();
12
+ db.prepare(
13
+ "INSERT INTO sessions (id, title, created_at, updated_at, graph_version) VALUES (?, ?, ?, ?, 0)",
14
+ ).run(id, title, t, t);
15
+ return { id, title, createdAt: t, updatedAt: t, graphVersion: 0 };
16
+ }
17
+
18
+ export function listSessions() {
19
+ const db = getDb();
20
+ const rows = db
21
+ .prepare(
22
+ "SELECT id, title, created_at AS createdAt, updated_at AS updatedAt, graph_version AS graphVersion FROM sessions ORDER BY updated_at DESC",
23
+ )
24
+ .all();
25
+ return rows.map((r) => ({
26
+ ...r,
27
+ nodeCount: db
28
+ .prepare("SELECT COUNT(*) AS c FROM nodes WHERE session_id = ?")
29
+ .get(r.id).c,
30
+ }));
31
+ }
32
+
33
+ export function getSession(id) {
34
+ const db = getDb();
35
+ const session = db
36
+ .prepare(
37
+ "SELECT id, title, created_at AS createdAt, updated_at AS updatedAt, graph_version AS graphVersion FROM sessions WHERE id = ?",
38
+ )
39
+ .get(id);
40
+ if (!session) return null;
41
+ const nodes = db
42
+ .prepare("SELECT id, x, y, data FROM nodes WHERE session_id = ?")
43
+ .all(id)
44
+ .map((n) => ({ id: n.id, x: n.x, y: n.y, data: safeParse(n.data) }));
45
+ const edges = db
46
+ .prepare("SELECT id, source, target, data FROM edges WHERE session_id = ?")
47
+ .all(id)
48
+ .map((e) => ({
49
+ id: e.id,
50
+ source: e.source,
51
+ target: e.target,
52
+ data: safeParse(e.data),
53
+ }));
54
+ return { ...session, nodes, edges };
55
+ }
56
+
57
+ export function renameSession(id, title) {
58
+ const db = getDb();
59
+ const res = db
60
+ .prepare("UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?")
61
+ .run(title, now(), id);
62
+ return res.changes > 0;
63
+ }
64
+
65
+ export function deleteSession(id) {
66
+ const db = getDb();
67
+ const res = db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
68
+ return res.changes > 0;
69
+ }
70
+
71
+ const MAX_STR = 10_000;
72
+
73
+ function cleanStr(v) {
74
+ if (typeof v !== "string") return "";
75
+ return v.length > MAX_STR ? v.slice(0, MAX_STR) : v;
76
+ }
77
+
78
+ function cleanData(v) {
79
+ try {
80
+ const json = JSON.stringify(v ?? {});
81
+ return json.length > MAX_STR * 10 ? "{}" : json;
82
+ } catch {
83
+ return "{}";
84
+ }
85
+ }
86
+
87
+ export function saveGraph(sessionId, { nodes = [], edges = [], expectedVersion = null }) {
88
+ const db = getDb();
89
+ const sessionExists = db
90
+ .prepare("SELECT 1 FROM sessions WHERE id = ?")
91
+ .get(sessionId);
92
+ if (!sessionExists) {
93
+ const err = new Error(`Session not found: ${sessionId}`);
94
+ err.code = "SESSION_NOT_FOUND";
95
+ err.status = 404;
96
+ throw err;
97
+ }
98
+
99
+ const versionRow = db
100
+ .prepare("SELECT graph_version AS graphVersion FROM sessions WHERE id = ?")
101
+ .get(sessionId);
102
+ const currentVersion = versionRow?.graphVersion ?? 0;
103
+ if (
104
+ typeof expectedVersion === "number" &&
105
+ Number.isFinite(expectedVersion) &&
106
+ expectedVersion !== currentVersion
107
+ ) {
108
+ const err = new Error(
109
+ `Graph version conflict for session ${sessionId}: expected ${expectedVersion}, got ${currentVersion}`,
110
+ );
111
+ err.code = "GRAPH_VERSION_CONFLICT";
112
+ err.status = 409;
113
+ err.currentVersion = currentVersion;
114
+ throw err;
115
+ }
116
+
117
+ // Validate edges reference existing nodes (drop dangling).
118
+ const nodeIds = new Set(nodes.map((n) => n?.id).filter(Boolean).map(String));
119
+ const cleanEdges = edges.filter(
120
+ (e) => e?.id && e?.source && e?.target && nodeIds.has(String(e.source)) && nodeIds.has(String(e.target)),
121
+ );
122
+
123
+ const tx = db.transaction(() => {
124
+ db.prepare("DELETE FROM nodes WHERE session_id = ?").run(sessionId);
125
+ db.prepare("DELETE FROM edges WHERE session_id = ?").run(sessionId);
126
+
127
+ const insNode = db.prepare(
128
+ "INSERT INTO nodes (session_id, id, x, y, data) VALUES (?, ?, ?, ?, ?)",
129
+ );
130
+ for (const n of nodes) {
131
+ if (!n?.id) continue;
132
+ const x = Number(n.x ?? n.position?.x ?? 0);
133
+ const y = Number(n.y ?? n.position?.y ?? 0);
134
+ insNode.run(
135
+ sessionId,
136
+ cleanStr(String(n.id)),
137
+ Number.isFinite(x) ? x : 0,
138
+ Number.isFinite(y) ? y : 0,
139
+ cleanData(n.data),
140
+ );
141
+ }
142
+
143
+ const insEdge = db.prepare(
144
+ "INSERT INTO edges (session_id, id, source, target, data) VALUES (?, ?, ?, ?, ?)",
145
+ );
146
+ for (const e of cleanEdges) {
147
+ insEdge.run(
148
+ sessionId,
149
+ cleanStr(String(e.id)),
150
+ cleanStr(String(e.source)),
151
+ cleanStr(String(e.target)),
152
+ cleanData(e.data),
153
+ );
154
+ }
155
+
156
+ db.prepare("UPDATE sessions SET updated_at = ?, graph_version = graph_version + 1 WHERE id = ?").run(
157
+ now(),
158
+ sessionId,
159
+ );
160
+
161
+ return db
162
+ .prepare("SELECT graph_version AS graphVersion FROM sessions WHERE id = ?")
163
+ .get(sessionId).graphVersion;
164
+ });
165
+
166
+ const nextVersion = tx();
167
+ return { ok: true, graphVersion: nextVersion };
168
+ }
169
+
170
+ function safeParse(json) {
171
+ try {
172
+ return JSON.parse(json);
173
+ } catch {
174
+ return {};
175
+ }
176
+ }
177
+
178
+ export function ensureDefaultSession() {
179
+ const sessions = listSessions();
180
+ if (sessions.length > 0) return sessions[0];
181
+ return createSession({ title: "My first graph" });
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "1.0.3",
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,15 +8,16 @@
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",
15
16
  "build": "npm run ui:build",
16
- "test": "node --test tests/**/*.test.js",
17
+ "test": "node scripts/run-tests.mjs",
17
18
  "setup": "node bin/ima2.js setup",
18
19
  "prepublishOnly": "npm run build && npm run lint:pkg",
19
- "lint:pkg": "node -e \"const p=require('./package.json'); if(!p.name||!p.version||!p.bin) throw new Error('missing fields')\"",
20
+ "lint:pkg": "node -e \"const p=require('./package.json'); const req=['name','version','bin']; for(const k of req){if(!p[k])throw new Error('missing '+k)} const mustInclude=['bin/','lib/','server.js']; for(const f of mustInclude){if(!p.files.includes(f))throw new Error('files[] must include '+f)}\"",
20
21
  "release:patch": "npm version patch && npm publish && git push origin main --tags",
21
22
  "release:minor": "npm version minor && npm publish && git push origin main --tags",
22
23
  "release:major": "npm version major && npm publish && git push origin main --tags"
@@ -35,6 +36,7 @@
35
36
  },
36
37
  "files": [
37
38
  "bin/",
39
+ "lib/",
38
40
  "ui/dist/",
39
41
  "assets/",
40
42
  "server.js",
@@ -42,7 +44,7 @@
42
44
  "README.md"
43
45
  ],
44
46
  "engines": {
45
- "node": ">=18"
47
+ "node": ">=20"
46
48
  },
47
49
  "dependencies": {
48
50
  "better-sqlite3": "^12.9.0",
package/server.js CHANGED
@@ -4,8 +4,10 @@ import { writeFile, mkdir, readFile, readdir, stat } from "fs/promises";
4
4
  import { join, dirname } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { spawn } from "child_process";
7
+ import { spawnBin, onShutdown } from "./bin/lib/platform.js";
7
8
  import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync as fsReadFileSync } from "fs";
8
9
  import { homedir } from "os";
10
+ import { randomBytes } from "crypto";
9
11
  import { newNodeId, saveNode, loadNodeB64, loadNodeMeta, loadAssetB64 } from "./lib/nodeStore.js";
10
12
  import { startJob, finishJob, listJobs, setJobPhase } from "./lib/inflight.js";
11
13
  import {
@@ -17,18 +19,25 @@ import {
17
19
  saveGraph,
18
20
  ensureDefaultSession,
19
21
  } from "./lib/sessionStore.js";
22
+ import { trashAsset, restoreAsset } from "./lib/assetLifecycle.js";
20
23
 
21
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
25
  const app = express();
23
26
 
24
- // Load API key from env or .ima2/config.json
27
+ // Load API key from env or ${IMA2_CONFIG_DIR || ~/.ima2}/config.json
28
+ // (with legacy fallback to <packageRoot>/.ima2/config.json for existing installs)
25
29
  let apiKey = process.env.OPENAI_API_KEY;
26
30
  if (!apiKey) {
27
- const cfgPath = join(__dirname, ".ima2", "config.json");
28
- if (existsSync(cfgPath)) {
31
+ const configDir = process.env.IMA2_CONFIG_DIR || join(homedir(), ".ima2");
32
+ const candidates = [
33
+ join(configDir, "config.json"),
34
+ join(__dirname, ".ima2", "config.json"),
35
+ ];
36
+ for (const cfgPath of candidates) {
37
+ if (!existsSync(cfgPath)) continue;
29
38
  try {
30
39
  const cfg = JSON.parse(await readFile(cfgPath, "utf-8"));
31
- if (cfg.apiKey) apiKey = cfg.apiKey;
40
+ if (cfg.apiKey) { apiKey = cfg.apiKey; break; }
32
41
  } catch {}
33
42
  }
34
43
  }
@@ -271,6 +280,7 @@ async function listImages(baseDir) {
271
280
  async function walk(dir, depth) {
272
281
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
273
282
  for (const e of entries) {
283
+ if (e.name === ".trash") continue;
274
284
  const full = join(dir, e.name);
275
285
  if (e.isDirectory() && depth > 0) {
276
286
  await walk(full, depth - 1);
@@ -287,7 +297,14 @@ app.get("/api/history", async (req, res) => {
287
297
  try {
288
298
  const dir = join(__dirname, "generated");
289
299
  await mkdir(dir, { recursive: true });
290
- 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
+
291
308
  const imgs = await listImages(dir);
292
309
  const rows = await Promise.all(imgs.map(async ({ full, rel, name }) => {
293
310
  const st = await stat(full).catch(() => null);
@@ -309,16 +326,91 @@ app.get("/api/history", async (req, res) => {
309
326
  provider: meta?.provider || "oauth",
310
327
  usage: meta?.usage || null,
311
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,
312
334
  };
313
335
  }));
314
- rows.sort((a, b) => b.createdAt - a.createdAt);
315
- 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 });
316
385
  } catch (err) {
317
386
  console.error("[history] error:", err.message);
318
387
  res.status(500).json({ error: err.message });
319
388
  }
320
389
  });
321
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
+
322
414
  // ── OAuth status ──
323
415
  app.get("/api/oauth/status", async (_req, res) => {
324
416
  try {
@@ -426,7 +518,8 @@ app.post("/api/generate", async (req, res) => {
426
518
  let totalWebSearchCalls = 0;
427
519
  for (const r of results) {
428
520
  if (r.status === "fulfilled" && r.value.b64) {
429
- const filename = `${Date.now()}_${images.length}.${format}`;
521
+ const rand = randomBytes(4).toString("hex");
522
+ const filename = `${Date.now()}_${rand}_${images.length}.${format}`;
430
523
  await writeFile(join(__dirname, "generated", filename), Buffer.from(r.value.b64, "base64"));
431
524
  // Sidecar metadata for /api/history reconstruction
432
525
  const meta = {
@@ -574,7 +667,7 @@ app.post("/api/edit", async (req, res) => {
574
667
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
575
668
 
576
669
  await mkdir(join(__dirname, "generated"), { recursive: true });
577
- const filename = `${Date.now()}.png`;
670
+ const filename = `${Date.now()}_${randomBytes(4).toString("hex")}.png`;
578
671
  await writeFile(join(__dirname, "generated", filename), Buffer.from(resultB64, "base64"));
579
672
  const meta = {
580
673
  prompt,
@@ -705,6 +798,8 @@ app.post("/api/node/generate", async (req, res) => {
705
798
  const meta = {
706
799
  nodeId,
707
800
  parentNodeId,
801
+ sessionId,
802
+ clientNodeId,
708
803
  prompt,
709
804
  options: { quality, size, format },
710
805
  createdAt: Date.now(),
@@ -918,7 +1013,7 @@ app.get("/api/billing", async (_req, res) => {
918
1013
  // ── Start OAuth proxy as child process ──
919
1014
  function startOAuthProxy() {
920
1015
  console.log(`Starting openai-oauth on port ${OAUTH_PORT}...`);
921
- const child = spawn("npx", ["openai-oauth", "--port", String(OAUTH_PORT)], {
1016
+ const child = spawnBin("npx", ["openai-oauth", "--port", String(OAUTH_PORT)], {
922
1017
  stdio: ["ignore", "pipe", "pipe"],
923
1018
  env: { ...process.env },
924
1019
  });
@@ -943,7 +1038,12 @@ function startOAuthProxy() {
943
1038
 
944
1039
  // ── Boot ──
945
1040
  const PORT = process.env.PORT || 3333;
946
- const oauthChild = startOAuthProxy();
1041
+ // Tests (and some CI contexts) can opt out of the OAuth proxy subprocess.
1042
+ // The proxy is a user-facing login helper, not required for /api/health or
1043
+ // offline unit tests, and starting it on Windows CI can add 7-10s latency.
1044
+ const oauthChild = process.env.IMA2_NO_OAUTH_PROXY === "1"
1045
+ ? null
1046
+ : startOAuthProxy();
947
1047
 
948
1048
  // CLI discovery: advertise running server under ~/.ima2/server.json
949
1049
  const __advertisePath = join(homedir(), ".ima2", "server.json");
@@ -971,15 +1071,9 @@ function __unadvertise() {
971
1071
  } catch {}
972
1072
  }
973
1073
 
974
- process.on("SIGINT", () => {
975
- __unadvertise();
976
- oauthChild.kill();
977
- process.exit();
978
- });
979
- process.on("SIGTERM", () => {
1074
+ onShutdown(() => {
980
1075
  __unadvertise();
981
- oauthChild.kill();
982
- process.exit();
1076
+ try { oauthChild?.kill(); } catch {}
983
1077
  });
984
1078
  process.on("exit", __unadvertise);
985
1079