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.
- package/bin/commands/show.js +4 -13
- package/bin/ima2.js +41 -23
- package/bin/lib/platform.js +97 -0
- package/lib/assetLifecycle.js +120 -0
- package/lib/codexDetect.js +69 -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 +7 -5
- package/server.js +113 -19
- package/ui/dist/assets/index-BlTTpUh8.js +16 -0
- package/ui/dist/assets/index-BlTTpUh8.js.map +1 -0
- package/ui/dist/assets/index-fsgUenJk.css +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,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
|
-
|
|
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
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(`
|
|
155
|
-
console.log(`
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
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
|
+
}
|