ima2-gen 1.0.2 → 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/README.md +211 -40
- package/assets/screenshot.png +0 -0
- package/bin/commands/edit.js +70 -0
- package/bin/commands/gen.js +136 -0
- package/bin/commands/ls.js +49 -0
- package/bin/commands/ping.js +28 -0
- package/bin/commands/ps.js +46 -0
- package/bin/commands/show.js +48 -0
- package/bin/ima2.js +210 -14
- package/bin/lib/args.js +73 -0
- package/bin/lib/client.js +97 -0
- package/bin/lib/files.js +39 -0
- package/bin/lib/output.js +48 -0
- 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 +14 -6
- package/server.js +624 -72
- 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 +24 -0
- package/public/index.html +0 -1075
package/bin/ima2.js
CHANGED
|
@@ -4,16 +4,33 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
|
4
4
|
import { join, dirname } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { spawn, execSync } from "child_process";
|
|
7
|
+
import { networkInterfaces, homedir } from "os";
|
|
8
|
+
import { openUrl, resolveBin } from "./lib/platform.js";
|
|
7
9
|
|
|
8
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
const ROOT = join(__dirname, "..");
|
|
10
|
-
const
|
|
12
|
+
const HOME = homedir();
|
|
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");
|
|
11
17
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
18
|
+
const LEGACY_CONFIG_FILE = join(ROOT, ".ima2", "config.json");
|
|
19
|
+
|
|
20
|
+
// Load package.json for version
|
|
21
|
+
let pkg = { version: "?", name: "ima2-gen" };
|
|
22
|
+
try {
|
|
23
|
+
pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
|
|
24
|
+
} catch {}
|
|
12
25
|
|
|
13
26
|
function loadConfig() {
|
|
14
27
|
if (existsSync(CONFIG_FILE)) {
|
|
15
28
|
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
16
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
|
+
}
|
|
17
34
|
return {};
|
|
18
35
|
}
|
|
19
36
|
|
|
@@ -52,13 +69,13 @@ async function setup() {
|
|
|
52
69
|
|
|
53
70
|
// Check if codex auth exists
|
|
54
71
|
const hasAuth =
|
|
55
|
-
existsSync(join(
|
|
56
|
-
existsSync(join(
|
|
72
|
+
existsSync(join(HOME, ".codex", "auth.json")) ||
|
|
73
|
+
existsSync(join(HOME, ".chatgpt-local", "auth.json"));
|
|
57
74
|
|
|
58
75
|
if (!hasAuth) {
|
|
59
76
|
console.log(" Running 'codex login' — follow the browser prompt.\n");
|
|
60
77
|
try {
|
|
61
|
-
execSync("npx @openai/codex login
|
|
78
|
+
execSync(`${resolveBin("npx")} @openai/codex login`, { stdio: "inherit" });
|
|
62
79
|
} catch {
|
|
63
80
|
console.log("\n Login failed or cancelled. You can retry with 'ima2 serve'.\n");
|
|
64
81
|
rl.close();
|
|
@@ -83,6 +100,25 @@ async function serve() {
|
|
|
83
100
|
config = await setup();
|
|
84
101
|
}
|
|
85
102
|
|
|
103
|
+
// Ensure ui/dist exists — if missing, auto-build (dev) or error (installed pkg)
|
|
104
|
+
const distIndex = join(ROOT, "ui", "dist", "index.html");
|
|
105
|
+
if (!existsSync(distIndex)) {
|
|
106
|
+
const hasUiSrc = existsSync(join(ROOT, "ui", "package.json"));
|
|
107
|
+
if (hasUiSrc) {
|
|
108
|
+
console.log("\n ui/dist missing — running 'npm run build' first...\n");
|
|
109
|
+
try {
|
|
110
|
+
execSync(`${resolveBin("npm")} run build`, { stdio: "inherit", cwd: ROOT });
|
|
111
|
+
} catch {
|
|
112
|
+
console.log("\n Build failed. Try: cd ui && npm install && npm run build\n");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
console.log("\n ui/dist not found and ui/ source is missing.");
|
|
117
|
+
console.log(" This installation appears broken. Reinstall: npm i -g ima2-gen\n");
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
86
122
|
const env = { ...process.env };
|
|
87
123
|
|
|
88
124
|
if (config.provider === "api" && config.apiKey) {
|
|
@@ -102,10 +138,154 @@ async function serve() {
|
|
|
102
138
|
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
103
139
|
}
|
|
104
140
|
|
|
141
|
+
async function showStatus() {
|
|
142
|
+
const config = loadConfig();
|
|
143
|
+
console.log(`\n ${pkg.name} v${pkg.version}\n`);
|
|
144
|
+
console.log(` Config file: ${CONFIG_FILE}`);
|
|
145
|
+
console.log(` Exists: ${existsSync(CONFIG_FILE) ? "yes" : "no"}\n`);
|
|
146
|
+
|
|
147
|
+
if (config.provider) {
|
|
148
|
+
console.log(` Provider: ${config.provider}`);
|
|
149
|
+
if (config.provider === "api") {
|
|
150
|
+
const key = config.apiKey || "";
|
|
151
|
+
console.log(` API Key: ${key ? key.slice(0, 8) + "..." + key.slice(-4) : "not set"}`);
|
|
152
|
+
}
|
|
153
|
+
console.log("");
|
|
154
|
+
} else {
|
|
155
|
+
console.log(" Status: not configured");
|
|
156
|
+
console.log(" Run 'ima2 setup' to configure.\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check OAuth auth files
|
|
160
|
+
const hasCodexAuth = existsSync(join(HOME, ".codex", "auth.json"));
|
|
161
|
+
const hasChatgptAuth = existsSync(join(HOME, ".chatgpt-local", "auth.json"));
|
|
162
|
+
console.log(` OAuth sessions:`);
|
|
163
|
+
console.log(` ~/.codex/auth.json ${hasCodexAuth ? "✓" : "✗"}`);
|
|
164
|
+
console.log(` ~/.chatgpt-local/auth.json ${hasChatgptAuth ? "✓" : "✗"}`);
|
|
165
|
+
console.log("");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function doctor() {
|
|
169
|
+
console.log(`\n ${pkg.name} v${pkg.version} — Doctor\n`);
|
|
170
|
+
|
|
171
|
+
let ok = 0;
|
|
172
|
+
let fail = 0;
|
|
173
|
+
|
|
174
|
+
// Node version
|
|
175
|
+
const nodeVersion = process.version;
|
|
176
|
+
const nodeMajor = parseInt(nodeVersion.slice(1).split(".")[0]);
|
|
177
|
+
if (nodeMajor >= 18) {
|
|
178
|
+
console.log(` ✓ Node.js ${nodeVersion} (>= 18)`);
|
|
179
|
+
ok++;
|
|
180
|
+
} else {
|
|
181
|
+
console.log(` ✗ Node.js ${nodeVersion} (requires >= 18)`);
|
|
182
|
+
fail++;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// package.json exists
|
|
186
|
+
if (existsSync(join(ROOT, "package.json"))) {
|
|
187
|
+
console.log(" ✓ package.json found");
|
|
188
|
+
ok++;
|
|
189
|
+
} else {
|
|
190
|
+
console.log(" ✗ package.json missing");
|
|
191
|
+
fail++;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// node_modules
|
|
195
|
+
if (existsSync(join(ROOT, "node_modules"))) {
|
|
196
|
+
console.log(" ✓ node_modules installed");
|
|
197
|
+
ok++;
|
|
198
|
+
} else {
|
|
199
|
+
console.log(" ✗ node_modules missing — run 'npm install'");
|
|
200
|
+
fail++;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// .env
|
|
204
|
+
if (existsSync(join(ROOT, ".env"))) {
|
|
205
|
+
console.log(" ✓ .env file exists");
|
|
206
|
+
ok++;
|
|
207
|
+
} else {
|
|
208
|
+
console.log(" ⚠ .env file not found (optional — copy from .env.example)");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Config
|
|
212
|
+
const config = loadConfig();
|
|
213
|
+
if (config.provider) {
|
|
214
|
+
console.log(` ✓ Configured: ${config.provider}`);
|
|
215
|
+
ok++;
|
|
216
|
+
} else {
|
|
217
|
+
console.log(" ⚠ Not configured — run 'ima2 setup'");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Port availability (simple check)
|
|
221
|
+
const port = process.env.PORT || 3333;
|
|
222
|
+
console.log(` ℹ Default port: ${port}`);
|
|
223
|
+
|
|
224
|
+
console.log(`\n ${ok} passed, ${fail} failed\n`);
|
|
225
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function openBrowser() {
|
|
229
|
+
const port = process.env.PORT || 3333;
|
|
230
|
+
const url = `http://localhost:${port}`;
|
|
231
|
+
const res = openUrl(url);
|
|
232
|
+
if (res.ok) {
|
|
233
|
+
console.log(`\n Opening ${url} ...\n`);
|
|
234
|
+
} else {
|
|
235
|
+
console.log(`\n Could not open browser. Visit: ${url}\n`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function showHelp() {
|
|
240
|
+
console.log(`
|
|
241
|
+
${pkg.name} v${pkg.version} — GPT Image 2 Generator
|
|
242
|
+
|
|
243
|
+
Usage: ima2 <command> [options]
|
|
244
|
+
|
|
245
|
+
Server commands:
|
|
246
|
+
serve Start the image generation server
|
|
247
|
+
setup, login Configure API key or OAuth (interactive)
|
|
248
|
+
status Show current configuration status
|
|
249
|
+
doctor Diagnose environment and setup
|
|
250
|
+
open Open web UI in browser
|
|
251
|
+
reset Reset configuration
|
|
252
|
+
|
|
253
|
+
Client commands (require a running 'ima2 serve'):
|
|
254
|
+
gen <prompt> Generate image(s) from prompt (ima2 gen --help)
|
|
255
|
+
edit <file> Edit an existing image (ima2 edit --help)
|
|
256
|
+
ls List recent history (ima2 ls --help)
|
|
257
|
+
show <name> Show one history item (ima2 show --help)
|
|
258
|
+
ps List active jobs (ima2 ps --help)
|
|
259
|
+
ping Ping running server / check health
|
|
260
|
+
|
|
261
|
+
Options:
|
|
262
|
+
-v, --version Show version
|
|
263
|
+
-h, --help Show help
|
|
264
|
+
|
|
265
|
+
Examples:
|
|
266
|
+
ima2 serve Start server
|
|
267
|
+
ima2 gen "a shiba in space" Generate from CLI
|
|
268
|
+
ima2 gen "merge" --ref a.png --ref b.png -q high -o out.png
|
|
269
|
+
ima2 ls -n 10 Last 10 generations
|
|
270
|
+
ima2 ping Health check
|
|
271
|
+
`);
|
|
272
|
+
}
|
|
273
|
+
|
|
105
274
|
// ── CLI ──
|
|
106
275
|
const args = process.argv.slice(2);
|
|
107
276
|
const command = args[0];
|
|
108
277
|
|
|
278
|
+
if (args.includes("-v") || args.includes("--version")) {
|
|
279
|
+
console.log(pkg.version);
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if ((!command || args.includes("-h") || args.includes("--help"))
|
|
284
|
+
&& !["gen", "edit", "ls", "show", "ps", "ping"].includes(command)) {
|
|
285
|
+
showHelp();
|
|
286
|
+
process.exit(command ? 0 : 1);
|
|
287
|
+
}
|
|
288
|
+
|
|
109
289
|
switch (command) {
|
|
110
290
|
case "serve":
|
|
111
291
|
serve();
|
|
@@ -114,21 +294,37 @@ switch (command) {
|
|
|
114
294
|
case "login":
|
|
115
295
|
setup().then(() => console.log(" Done. Run 'ima2 serve' to start."));
|
|
116
296
|
break;
|
|
297
|
+
case "status":
|
|
298
|
+
showStatus();
|
|
299
|
+
break;
|
|
300
|
+
case "doctor":
|
|
301
|
+
doctor();
|
|
302
|
+
break;
|
|
303
|
+
case "open":
|
|
304
|
+
openBrowser();
|
|
305
|
+
break;
|
|
117
306
|
case "reset":
|
|
118
307
|
if (existsSync(CONFIG_FILE)) {
|
|
119
308
|
writeFileSync(CONFIG_FILE, "{}");
|
|
120
309
|
console.log(" Config reset. Run 'ima2 serve' to reconfigure.");
|
|
310
|
+
} else {
|
|
311
|
+
console.log(" No config to reset.");
|
|
121
312
|
}
|
|
122
313
|
break;
|
|
314
|
+
case "gen":
|
|
315
|
+
case "edit":
|
|
316
|
+
case "ls":
|
|
317
|
+
case "show":
|
|
318
|
+
case "ps":
|
|
319
|
+
case "ping": {
|
|
320
|
+
const { setCliVersion } = await import("./lib/client.js");
|
|
321
|
+
setCliVersion(pkg.version);
|
|
322
|
+
const mod = await import(`./commands/${command}.js`);
|
|
323
|
+
await mod.default(args.slice(1));
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
123
326
|
default:
|
|
124
|
-
console.log(`
|
|
125
|
-
ima2
|
|
126
|
-
|
|
127
|
-
Usage:
|
|
128
|
-
ima2 serve Start the image generation server
|
|
129
|
-
ima2 setup Configure API key or OAuth (interactive)
|
|
130
|
-
ima2 reset Reset configuration
|
|
131
|
-
|
|
132
|
-
First run of 'ima2 serve' will prompt for setup automatically.
|
|
133
|
-
`);
|
|
327
|
+
console.log(` Unknown command: "${command}"`);
|
|
328
|
+
console.log(" Run 'ima2 --help' for usage.\n");
|
|
329
|
+
process.exit(1);
|
|
134
330
|
}
|
package/bin/lib/args.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Tiny argv parser — no dependencies.
|
|
2
|
+
// Supports: --long, --long=val, --long val, -s, -s val, repeatable flags, positional, --.
|
|
3
|
+
|
|
4
|
+
export function parseArgs(argv, spec = {}) {
|
|
5
|
+
const shortMap = {};
|
|
6
|
+
for (const [name, def] of Object.entries(spec.flags || {})) {
|
|
7
|
+
if (def.short) shortMap[def.short] = name;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const out = { positional: [], _unknown: [] };
|
|
11
|
+
for (const [name, def] of Object.entries(spec.flags || {})) {
|
|
12
|
+
if (def.repeatable) out[name] = [];
|
|
13
|
+
else if ("default" in def) out[name] = def.default;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let i = 0;
|
|
17
|
+
let doubleDashSeen = false;
|
|
18
|
+
while (i < argv.length) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (doubleDashSeen) {
|
|
21
|
+
out.positional.push(a);
|
|
22
|
+
i++;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (a === "--") {
|
|
26
|
+
doubleDashSeen = true;
|
|
27
|
+
i++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (a.startsWith("--")) {
|
|
31
|
+
const eq = a.indexOf("=");
|
|
32
|
+
const name = eq > -1 ? a.slice(2, eq) : a.slice(2);
|
|
33
|
+
const def = (spec.flags || {})[name];
|
|
34
|
+
if (!def) {
|
|
35
|
+
out._unknown.push(a);
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (def.type === "boolean") {
|
|
40
|
+
out[name] = true;
|
|
41
|
+
i++;
|
|
42
|
+
} else {
|
|
43
|
+
const val = eq > -1 ? a.slice(eq + 1) : argv[i + 1];
|
|
44
|
+
if (eq === -1) i++;
|
|
45
|
+
if (def.repeatable) out[name].push(val);
|
|
46
|
+
else out[name] = val;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
} else if (a.startsWith("-") && a.length > 1 && !/^-\d/.test(a)) {
|
|
50
|
+
const short = a.slice(1);
|
|
51
|
+
const name = shortMap[short];
|
|
52
|
+
if (!name) {
|
|
53
|
+
out._unknown.push(a);
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const def = spec.flags[name];
|
|
58
|
+
if (def.type === "boolean") {
|
|
59
|
+
out[name] = true;
|
|
60
|
+
i++;
|
|
61
|
+
} else {
|
|
62
|
+
const val = argv[i + 1];
|
|
63
|
+
if (def.repeatable) out[name].push(val);
|
|
64
|
+
else out[name] = val;
|
|
65
|
+
i += 2;
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
out.positional.push(a);
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_PORT = 3333;
|
|
6
|
+
|
|
7
|
+
function readAdvertise() {
|
|
8
|
+
const p = join(homedir(), ".ima2", "server.json");
|
|
9
|
+
if (!existsSync(p)) return null;
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function probe(base, timeoutMs = 600) {
|
|
18
|
+
try {
|
|
19
|
+
const r = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
20
|
+
if (!r.ok) return null;
|
|
21
|
+
return await r.json();
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function resolveServer({ serverFlag } = {}) {
|
|
28
|
+
if (serverFlag) {
|
|
29
|
+
const base = serverFlag.replace(/\/$/, "");
|
|
30
|
+
const health = await probe(base);
|
|
31
|
+
if (health) return { base, health };
|
|
32
|
+
const err = new Error(`server unreachable at ${base}`);
|
|
33
|
+
err.code = "SERVER_UNREACHABLE";
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
const candidates = [];
|
|
37
|
+
if (process.env.IMA2_SERVER) candidates.push(process.env.IMA2_SERVER.replace(/\/$/, ""));
|
|
38
|
+
const adv = readAdvertise();
|
|
39
|
+
if (adv?.port) candidates.push(`http://localhost:${adv.port}`);
|
|
40
|
+
candidates.push(`http://localhost:${DEFAULT_PORT}`);
|
|
41
|
+
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const uniq = candidates.filter((c) => !seen.has(c) && seen.add(c));
|
|
44
|
+
|
|
45
|
+
for (const base of uniq) {
|
|
46
|
+
const health = await probe(base);
|
|
47
|
+
if (health) return { base, health };
|
|
48
|
+
}
|
|
49
|
+
const err = new Error("server unreachable — is 'ima2 serve' running?");
|
|
50
|
+
err.code = "SERVER_UNREACHABLE";
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function request(base, path, { method = "GET", body, timeoutMs = 180_000 } = {}) {
|
|
55
|
+
const res = await fetch(base + path, {
|
|
56
|
+
method,
|
|
57
|
+
headers: {
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"X-ima2-client": `cli/${CLI_VERSION}`,
|
|
60
|
+
},
|
|
61
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
62
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
63
|
+
});
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
let json = null;
|
|
66
|
+
try { json = JSON.parse(text); } catch {}
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
const err = new Error(json?.error || `HTTP ${res.status}`);
|
|
69
|
+
err.status = res.status;
|
|
70
|
+
err.code = json?.code || null;
|
|
71
|
+
err.body = json || text;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
return json;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function normalizeGenerate(resp) {
|
|
78
|
+
if (!resp) return { images: [], elapsed: null, requestId: null };
|
|
79
|
+
if (Array.isArray(resp.images)) {
|
|
80
|
+
return {
|
|
81
|
+
images: resp.images.map((it) => ({ image: it.image, filename: it.filename })),
|
|
82
|
+
elapsed: resp.elapsed ?? null,
|
|
83
|
+
requestId: resp.requestId ?? null,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (resp.image) {
|
|
87
|
+
return {
|
|
88
|
+
images: [{ image: resp.image, filename: resp.filename || null }],
|
|
89
|
+
elapsed: resp.elapsed ?? null,
|
|
90
|
+
requestId: resp.requestId ?? null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { images: [], elapsed: resp.elapsed ?? null, requestId: resp.requestId ?? null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export let CLI_VERSION = "dev";
|
|
97
|
+
export function setCliVersion(v) { CLI_VERSION = v; }
|
package/bin/lib/files.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, extname, basename, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const MIME = {
|
|
5
|
+
png: "image/png",
|
|
6
|
+
jpg: "image/jpeg",
|
|
7
|
+
jpeg: "image/jpeg",
|
|
8
|
+
webp: "image/webp",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function fileToDataUri(path) {
|
|
12
|
+
const b64 = (await readFile(path)).toString("base64");
|
|
13
|
+
const ext = extname(path).slice(1).toLowerCase();
|
|
14
|
+
const mime = MIME[ext] || "image/png";
|
|
15
|
+
return `data:${mime};base64,${b64}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function dataUriToFile(dataUri, outPath) {
|
|
19
|
+
const m = dataUri.match(/^data:([^;]+);base64,(.+)$/);
|
|
20
|
+
const raw = m ? m[2] : dataUri;
|
|
21
|
+
await mkdir(dirname(outPath) || ".", { recursive: true });
|
|
22
|
+
await writeFile(outPath, Buffer.from(raw, "base64"));
|
|
23
|
+
return outPath;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function defaultOutName(index, total, ext = "png") {
|
|
27
|
+
const now = new Date();
|
|
28
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
29
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
30
|
+
if (total <= 1) return `ima2-${stamp}.${ext}`;
|
|
31
|
+
return `ima2-${stamp}-${index}.${ext}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function readStdin() {
|
|
35
|
+
if (process.stdin.isTTY) return "";
|
|
36
|
+
const chunks = [];
|
|
37
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
38
|
+
return Buffer.concat(chunks).toString("utf-8").trim();
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const isTty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
|
|
3
|
+
export const color = {
|
|
4
|
+
dim: (s) => (isTty ? `\x1b[2m${s}\x1b[0m` : s),
|
|
5
|
+
bold: (s) => (isTty ? `\x1b[1m${s}\x1b[0m` : s),
|
|
6
|
+
red: (s) => (isTty ? `\x1b[31m${s}\x1b[0m` : s),
|
|
7
|
+
green: (s) => (isTty ? `\x1b[32m${s}\x1b[0m` : s),
|
|
8
|
+
yellow: (s) => (isTty ? `\x1b[33m${s}\x1b[0m` : s),
|
|
9
|
+
cyan: (s) => (isTty ? `\x1b[36m${s}\x1b[0m` : s),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function out(msg = "") { process.stdout.write(msg + "\n"); }
|
|
13
|
+
export function err(msg = "") { process.stderr.write(msg + "\n"); }
|
|
14
|
+
|
|
15
|
+
export function die(code, msg) {
|
|
16
|
+
if (msg) err(color.red("✗ ") + msg);
|
|
17
|
+
process.exit(code);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function json(obj) {
|
|
21
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function table(rows, columns) {
|
|
25
|
+
if (rows.length === 0) return;
|
|
26
|
+
const widths = columns.map((c) =>
|
|
27
|
+
Math.max(c.label.length, ...rows.map((r) => {
|
|
28
|
+
const v = c.format ? c.format(r[c.key], r) : r[c.key];
|
|
29
|
+
return String(v ?? "").length;
|
|
30
|
+
})),
|
|
31
|
+
);
|
|
32
|
+
const pad = (s, w) => String(s ?? "").padEnd(w);
|
|
33
|
+
out(color.dim(columns.map((c, i) => pad(c.label, widths[i])).join(" ")));
|
|
34
|
+
out(color.dim(widths.map((w) => "─".repeat(w)).join(" ")));
|
|
35
|
+
for (const r of rows) {
|
|
36
|
+
out(columns.map((c, i) => pad(c.format ? c.format(r[c.key], r) : r[c.key], widths[i])).join(" "));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function exitCodeForError(e) {
|
|
41
|
+
if (e.code === "SERVER_UNREACHABLE") return 3;
|
|
42
|
+
if (e.code === "APIKEY_DISABLED") return 4;
|
|
43
|
+
if (e.code === "SAFETY_REFUSAL") return 7;
|
|
44
|
+
if (e.name === "TimeoutError" || /abort/i.test(e.message || "")) return 8;
|
|
45
|
+
if (e.status >= 500) return 6;
|
|
46
|
+
if (e.status >= 400) return 5;
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
@@ -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
|
+
|