ima2-gen 1.0.3 → 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/commands/show.js +4 -13
- package/bin/ima2.js +15 -13
- package/bin/lib/platform.js +89 -0
- package/lib/db.js +92 -0
- package/lib/inflight.js +57 -0
- package/lib/nodeStore.js +66 -0
- package/lib/sessionStore.js +182 -0
- package/package.json +5 -4
- package/server.js +20 -14
- package/ui/dist/assets/index-1wzizazR.css +1 -0
- package/ui/dist/assets/index-C7SQ3J8h.js +16 -0
- package/ui/dist/assets/index-C7SQ3J8h.js.map +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CGvmo0q2.js +0 -16
- package/ui/dist/assets/index-CGvmo0q2.js.map +0 -1
- package/ui/dist/assets/index-Dr1O_KZg.css +0 -1
package/bin/commands/show.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { parseArgs } from "../lib/args.js";
|
|
2
2
|
import { resolveServer, request } from "../lib/client.js";
|
|
3
|
-
import {
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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,17 @@ 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";
|
|
8
9
|
|
|
9
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
const ROOT = join(__dirname, "..");
|
|
11
12
|
const HOME = homedir();
|
|
12
|
-
|
|
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");
|
|
13
17
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
18
|
+
const LEGACY_CONFIG_FILE = join(ROOT, ".ima2", "config.json");
|
|
14
19
|
|
|
15
20
|
// Load package.json for version
|
|
16
21
|
let pkg = { version: "?", name: "ima2-gen" };
|
|
@@ -22,6 +27,10 @@ function loadConfig() {
|
|
|
22
27
|
if (existsSync(CONFIG_FILE)) {
|
|
23
28
|
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
24
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
|
+
}
|
|
25
34
|
return {};
|
|
26
35
|
}
|
|
27
36
|
|
|
@@ -66,7 +75,7 @@ async function setup() {
|
|
|
66
75
|
if (!hasAuth) {
|
|
67
76
|
console.log(" Running 'codex login' — follow the browser prompt.\n");
|
|
68
77
|
try {
|
|
69
|
-
execSync("npx @openai/codex login
|
|
78
|
+
execSync(`${resolveBin("npx")} @openai/codex login`, { stdio: "inherit" });
|
|
70
79
|
} catch {
|
|
71
80
|
console.log("\n Login failed or cancelled. You can retry with 'ima2 serve'.\n");
|
|
72
81
|
rl.close();
|
|
@@ -98,7 +107,7 @@ async function serve() {
|
|
|
98
107
|
if (hasUiSrc) {
|
|
99
108
|
console.log("\n ui/dist missing — running 'npm run build' first...\n");
|
|
100
109
|
try {
|
|
101
|
-
execSync("npm run build
|
|
110
|
+
execSync(`${resolveBin("npm")} run build`, { stdio: "inherit", cwd: ROOT });
|
|
102
111
|
} catch {
|
|
103
112
|
console.log("\n Build failed. Try: cd ui && npm install && npm run build\n");
|
|
104
113
|
process.exit(1);
|
|
@@ -219,17 +228,10 @@ async function doctor() {
|
|
|
219
228
|
function openBrowser() {
|
|
220
229
|
const port = process.env.PORT || 3333;
|
|
221
230
|
const url = `http://localhost:${port}`;
|
|
222
|
-
|
|
223
|
-
|
|
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" });
|
|
231
|
+
const res = openUrl(url);
|
|
232
|
+
if (res.ok) {
|
|
231
233
|
console.log(`\n Opening ${url} ...\n`);
|
|
232
|
-
}
|
|
234
|
+
} else {
|
|
233
235
|
console.log(`\n Could not open browser. Visit: ${url}\n`);
|
|
234
236
|
}
|
|
235
237
|
}
|
|
@@ -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
|
+
|
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
|
+
}
|
package/lib/inflight.js
ADDED
|
@@ -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
|
+
}
|
package/lib/nodeStore.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -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
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "GPT Image 2 generator with OAuth & API key support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
"ui:dev": "cd ui && npm run dev",
|
|
14
14
|
"ui:build": "cd ui && npm run build",
|
|
15
15
|
"build": "npm run ui:build",
|
|
16
|
-
"test": "node
|
|
16
|
+
"test": "node scripts/run-tests.mjs",
|
|
17
17
|
"setup": "node bin/ima2.js setup",
|
|
18
18
|
"prepublishOnly": "npm run build && npm run lint:pkg",
|
|
19
|
-
"lint:pkg": "node -e \"const p=require('./package.json'); if(!p.
|
|
19
|
+
"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
20
|
"release:patch": "npm version patch && npm publish && git push origin main --tags",
|
|
21
21
|
"release:minor": "npm version minor && npm publish && git push origin main --tags",
|
|
22
22
|
"release:major": "npm version major && npm publish && git push origin main --tags"
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"bin/",
|
|
38
|
+
"lib/",
|
|
38
39
|
"ui/dist/",
|
|
39
40
|
"assets/",
|
|
40
41
|
"server.js",
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
"README.md"
|
|
43
44
|
],
|
|
44
45
|
"engines": {
|
|
45
|
-
"node": ">=
|
|
46
|
+
"node": ">=20"
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
48
49
|
"better-sqlite3": "^12.9.0",
|