ima2-gen 3.13.1 → 3.14.0

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.
Files changed (157) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +15 -2
  4. package/bin/commands/capabilities.js +5 -3
  5. package/bin/commands/config.js +15 -4
  6. package/bin/commands/defaults.js +7 -5
  7. package/bin/commands/doctor.js +88 -39
  8. package/bin/commands/gen.js +21 -4
  9. package/bin/commands/models.js +2 -2
  10. package/bin/commands/ping.js +1 -1
  11. package/bin/commands/prompt.js +5 -3
  12. package/bin/commands/service.js +32 -4
  13. package/bin/commands/tools.js +19 -3
  14. package/bin/commands/upscale.js +18 -4
  15. package/bin/commands/video.js +37 -50
  16. package/bin/ima2.js +28 -9
  17. package/bin/lib/characterResolve.js +2 -1
  18. package/bin/lib/client.js +155 -20
  19. package/bin/lib/config-store.js +40 -7
  20. package/bin/lib/doctor-bundle.js +6 -2
  21. package/bin/lib/doctor-checks.js +13 -29
  22. package/bin/lib/doctor-media.js +3 -3
  23. package/bin/lib/doctor-providers.js +48 -35
  24. package/bin/lib/doctor-report.js +78 -0
  25. package/bin/lib/doctor-runtime.js +173 -0
  26. package/bin/lib/mcpJob.js +19 -7
  27. package/bin/lib/output.js +3 -0
  28. package/bin/lib/platform.js +12 -9
  29. package/bin/lib/recover-output.js +9 -2
  30. package/bin/lib/sse.js +9 -1
  31. package/bin/lib/videoMcp.js +16 -8
  32. package/config.js +41 -5
  33. package/docs/API.md +154 -16
  34. package/docs/CLI.md +65 -1
  35. package/docs/DOCKER.md +19 -4
  36. package/docs/DOCKER.zh-CN.md +11 -4
  37. package/docs/DOCKER.zh-TW.md +11 -4
  38. package/docs/FAQ.md +13 -0
  39. package/docs/NPX_QUICKSTART.md +6 -0
  40. package/docs/README.ja.md +15 -2
  41. package/docs/README.ko.md +15 -2
  42. package/docs/README.zh-CN.md +15 -2
  43. package/docs/README.zh-TW.md +15 -2
  44. package/docs/migration/runtime-test-inventory.md +79 -8
  45. package/lib/agyArtifact.js +82 -0
  46. package/lib/agyArtifactRead.js +249 -0
  47. package/lib/agyImageAdapter.js +2 -329
  48. package/lib/agyProcess.js +122 -0
  49. package/lib/apiRequestBudget.js +48 -0
  50. package/lib/assetLifecycle.js +42 -9
  51. package/lib/canvasVersionStore.js +46 -14
  52. package/lib/capabilities.js +7 -1
  53. package/lib/configKeys.js +2 -0
  54. package/lib/errors/providerMap.js +26 -0
  55. package/lib/eventBus.js +10 -6
  56. package/lib/eventsPolicy.js +9 -0
  57. package/lib/geminiApiImageAdapter.js +1 -214
  58. package/lib/generatePipeline.js +39 -165
  59. package/lib/generatedMediaAccess.js +74 -0
  60. package/lib/generationErrors.js +3 -22
  61. package/lib/grokImageAdapter.js +2 -353
  62. package/lib/grokImageCore.js +1 -57
  63. package/lib/grokImageDownload.js +159 -0
  64. package/lib/grokImageDownloadPolicy.js +124 -0
  65. package/lib/grokImagePlanner.js +301 -0
  66. package/lib/grokMultimodeAdapter.js +1 -75
  67. package/lib/grokVideoDownload.js +137 -34
  68. package/lib/inflight.js +112 -112
  69. package/lib/jobs/terminalStore.js +45 -0
  70. package/lib/lanSessionStore.js +131 -0
  71. package/lib/localAccessPolicy.js +180 -0
  72. package/lib/localLanAccess.js +281 -0
  73. package/lib/logger.js +22 -13
  74. package/lib/mcp/downloadMediaResult.js +72 -78
  75. package/lib/multimodePipeline.js +39 -126
  76. package/lib/nodeGeneration.js +44 -101
  77. package/lib/nodeStore.js +25 -11
  78. package/lib/nodeValidation.js +5 -1
  79. package/lib/pinnedHttpGet.js +174 -0
  80. package/lib/promptImport/githubFolder.js +32 -18
  81. package/lib/promptImport/githubSource.js +14 -10
  82. package/lib/promptImport/parsePromptCandidates.js +32 -2
  83. package/lib/providers/adapters/agyOperations.js +179 -0
  84. package/lib/providers/adapters/comfy.js +1 -0
  85. package/lib/providers/adapters/geminiOperations.js +214 -0
  86. package/lib/providers/adapters/googleExecution.js +97 -0
  87. package/lib/providers/adapters/grokExecution.js +137 -0
  88. package/lib/providers/adapters/grokMultimodeOperations.js +83 -0
  89. package/lib/providers/adapters/grokOperations.js +63 -0
  90. package/lib/providers/adapters/openaiExecution.js +118 -0
  91. package/lib/providers/adapters/openaiOperations.js +201 -0
  92. package/lib/providers/adapters/openaiTypes.js +1 -0
  93. package/lib/providers/derive.js +4 -0
  94. package/lib/providers/deriveCore.js +2 -7
  95. package/lib/providers/execution/admission.js +25 -0
  96. package/lib/providers/execution/index.js +34 -0
  97. package/lib/providers/execution/legacy.js +17 -0
  98. package/lib/providers/execution/legacyClassic.js +66 -0
  99. package/lib/providers/execution/legacyEdit.js +30 -0
  100. package/lib/providers/execution/legacyMultimode.js +34 -0
  101. package/lib/providers/execution/legacyNode.js +41 -0
  102. package/lib/providers/execution/types.js +1 -0
  103. package/lib/providers/registry.js +19 -14
  104. package/lib/providers/surfaceSupport.js +22 -0
  105. package/lib/responsesImageAdapter.js +1 -389
  106. package/lib/responsesTransport.js +190 -0
  107. package/lib/spriteJobEvents.js +20 -2
  108. package/lib/ssePublish.js +5 -4
  109. package/lib/videoExtendI2vOperation.js +76 -0
  110. package/package.json +5 -2
  111. package/routes/edit.js +31 -123
  112. package/routes/events.js +140 -63
  113. package/routes/mcpMedia.js +10 -2
  114. package/routes/models.js +28 -25
  115. package/routes/videoExtended.js +9 -68
  116. package/server.js +24 -46
  117. package/ui/dist/.vite/manifest.json +90 -63
  118. package/ui/dist/assets/{AgentWorkspace-PbBytTeV.js → AgentWorkspace-CL3MLv3l.js} +2 -2
  119. package/ui/dist/assets/App-B9cEM6dJ.js +9 -0
  120. package/ui/dist/assets/AssetGenWorkspace-swWPvXrx.js +2 -0
  121. package/ui/dist/assets/AssetsWorkspace-CRVeYU2F.js +1 -0
  122. package/ui/dist/assets/{CardNewsWorkspace-BS3jKOja.js → CardNewsWorkspace-BAOxWLKf.js} +2 -2
  123. package/ui/dist/assets/GenerationRequestLogPanel-DZtYZm-D.js +1 -0
  124. package/ui/dist/assets/HomeWorkspace-BeZ1s0Jm.js +1 -0
  125. package/ui/dist/assets/KeyingPanel-ZyBSXioh.js +1 -0
  126. package/ui/dist/assets/NodeCanvas-BvvcCErf.js +7 -0
  127. package/ui/dist/assets/{PromptBuilderPanel-D97A4pwB.js → PromptBuilderPanel-DBycmShA.js} +2 -2
  128. package/ui/dist/assets/PromptImportDialog-ChvMv7ek.js +2 -0
  129. package/ui/dist/assets/PromptImportDiscoverySection-60py7whH.js +1 -0
  130. package/ui/dist/assets/PromptImportFolderSection-CCRh_-_7.js +1 -0
  131. package/ui/dist/assets/PromptLibraryPanel-BFWQO8OT.js +2 -0
  132. package/ui/dist/assets/SettingsWorkspace-B5Ghsgdj.js +1 -0
  133. package/ui/dist/assets/SpriteRecipeWorkspace-DRWdkmwU.js +1 -0
  134. package/ui/dist/assets/index-7ngEOVxt.js +5 -0
  135. package/ui/dist/assets/index-B5jy7ePg.css +1 -0
  136. package/ui/dist/assets/index-gxh_02DQ.js +22 -0
  137. package/ui/dist/assets/{pptxgen.es-D4pedbwM.js → pptxgen.es-OUNhQLEV.js} +1 -1
  138. package/ui/dist/assets/promptBuilderStore-BTUUzbBT.js +1 -0
  139. package/ui/dist/assets/useAgentDialogFocus-DSc2qY8p.js +1 -0
  140. package/ui/dist/index.html +3 -2
  141. package/ui/dist/assets/AssetGenWorkspace-B-TGlvVb.js +0 -2
  142. package/ui/dist/assets/AssetsWorkspace-LnGaW151.js +0 -1
  143. package/ui/dist/assets/GenerationRequestLogPanel-DuDWD_ic.js +0 -1
  144. package/ui/dist/assets/HomeWorkspace-DVP7UG6O.js +0 -1
  145. package/ui/dist/assets/KeyingPanel-5bRUFQaq.js +0 -1
  146. package/ui/dist/assets/NodeCanvas-BBEpERTb.js +0 -7
  147. package/ui/dist/assets/PromptImportDialog-C89MN5JY.js +0 -2
  148. package/ui/dist/assets/PromptImportDiscoverySection-BgRfDOs2.js +0 -1
  149. package/ui/dist/assets/PromptImportFolderSection-zTMyriT1.js +0 -1
  150. package/ui/dist/assets/PromptLibraryPanel-BD96qZu_.js +0 -2
  151. package/ui/dist/assets/SettingsWorkspace-BAOk3X_8.js +0 -1
  152. package/ui/dist/assets/SpriteRecipeWorkspace-ClIM5QRo.js +0 -1
  153. package/ui/dist/assets/index-0fpfY8vu.css +0 -1
  154. package/ui/dist/assets/index-BFciPGTn.js +0 -5
  155. package/ui/dist/assets/index-BbykeXR9.js +0 -30
  156. package/ui/dist/assets/promptBuilderStore-Do7M8g7J.js +0 -1
  157. package/ui/dist/assets/useAgentDialogFocus-B6-QgEVI.js +0 -1
@@ -4,9 +4,9 @@
4
4
  import { mkdir, writeFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
6
  import { parseArgs } from "../lib/args.js";
7
- import { resolveServer } from "../lib/client.js";
7
+ import { resolveServer, fetchServer } from "../lib/client.js";
8
8
  import { runMcpJob } from "../lib/mcpJob.js";
9
- import { color, die, fail, json, out } from "../lib/output.js";
9
+ import { color, die, exitCodeForError, fail, json, out } from "../lib/output.js";
10
10
  import { createCliRequestId } from "../lib/recover-output.js";
11
11
  const MCP_UPSCALE_TIMEOUT_MS = 5 * 60_000 + 120_000 + 30_000;
12
12
  const SCALE_FACTORS = new Set(["2", "4", "8", "16"]);
@@ -51,11 +51,24 @@ function parsePercent(value, label) {
51
51
  return n;
52
52
  }
53
53
  export default async function upscaleCmd(argv) {
54
+ try {
55
+ await upscale(argv);
56
+ }
57
+ catch (error) {
58
+ if (!(error instanceof Error))
59
+ throw error;
60
+ fail({ json: argv.includes("--json"), code: error.code ?? "MCP_UPSCALE_FAILED",
61
+ message: error.message, exitCode: exitCodeForError(error) });
62
+ }
63
+ }
64
+ async function upscale(argv) {
54
65
  const args = parseArgs(argv, SPEC);
55
66
  if (args.help) {
56
67
  out(HELP);
57
68
  return;
58
69
  }
70
+ if (args._unknown.length)
71
+ die(2, `unknown option: ${args._unknown[0]}`);
59
72
  const file = args.positional[0];
60
73
  if (!file)
61
74
  die(2, "usage: ima2 upscale <generated-file> [options]");
@@ -106,7 +119,7 @@ export default async function upscaleCmd(argv) {
106
119
  });
107
120
  const target = args.out ? String(args.out) : args["out-dir"] ? join(String(args["out-dir"]), result.filename) : undefined;
108
121
  if (target) {
109
- const response = await fetch(`${server.base}${result.url}`);
122
+ const response = await fetchServer(server.base, result.url);
110
123
  if (!response.ok)
111
124
  die(1, `failed to download result: HTTP ${response.status}`);
112
125
  await mkdir(dirname(target), { recursive: true });
@@ -119,6 +132,7 @@ export default async function upscaleCmd(argv) {
119
132
  }
120
133
  catch (error) {
121
134
  const typed = error;
122
- fail({ json: Boolean(args.json), code: typed.code ?? "MCP_UPSCALE_FAILED", message: typed.message, exitCode: 1 });
135
+ fail({ json: Boolean(args.json), code: typed.code ?? "MCP_UPSCALE_FAILED", message: typed.message,
136
+ exitCode: exitCodeForError(error) === 4 ? 4 : 1 });
123
137
  }
124
138
  }
@@ -1,7 +1,7 @@
1
1
  import { parseArgs } from "../lib/args.js";
2
- import { resolveServer } from "../lib/client.js";
2
+ import { resolveServer, fetchServer } from "../lib/client.js";
3
3
  import { streamSse } from "../lib/sse.js";
4
- import { out, die, color, exitCodeForError } from "../lib/output.js";
4
+ import { out, die, color, exitCodeForError, fail } from "../lib/output.js";
5
5
  import { writeFile, mkdir, readFile, stat } from "node:fs/promises";
6
6
  import { basename, dirname } from "node:path";
7
7
  import { runVideoGenerate } from "../lib/videoMcp.js";
@@ -69,10 +69,21 @@ async function writeBuffer(path, buf) {
69
69
  }
70
70
  async function downloadReturnedVideo(serverBase, data, outPath, signal) {
71
71
  const rawUrl = typeof data.url === "string" ? data.url : "";
72
- const url = rawUrl.startsWith("/") ? `${serverBase}${rawUrl}` : rawUrl;
73
- if (!url)
72
+ if (!rawUrl)
74
73
  die(1, "server did not return a video url");
75
- const res = await fetch(url, { signal });
74
+ let url;
75
+ try {
76
+ url = new URL(rawUrl, `${serverBase}/`);
77
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
78
+ throw new Error();
79
+ }
80
+ catch {
81
+ die(1, "server returned an invalid video url");
82
+ }
83
+ // External signed media retains its query, but never inherits server credentials.
84
+ const res = url.origin === new URL(serverBase).origin
85
+ ? await fetchServer(serverBase, url.href, { signal })
86
+ : await fetch(url.href, { signal, credentials: "omit", redirect: "error" });
76
87
  if (!res.ok)
77
88
  die(1, `failed to download video: HTTP ${res.status}`);
78
89
  await writeBuffer(outPath, Buffer.from(await res.arrayBuffer()));
@@ -171,6 +182,17 @@ const HELP = `
171
182
  ima2 video analyze 1780226256355_50252101.mp4
172
183
  `;
173
184
  export default async function videoCmd(argv) {
185
+ try {
186
+ await dispatchVideo(argv);
187
+ }
188
+ catch (error) {
189
+ if (!(error instanceof Error))
190
+ throw error;
191
+ fail({ json: argv.includes("--json"), code: error.code ?? "VIDEO_FAILED",
192
+ message: error.message, exitCode: exitCodeForError(error) });
193
+ }
194
+ }
195
+ async function dispatchVideo(argv) {
174
196
  const sub = argv[0];
175
197
  if (sub === "edit")
176
198
  return videoEditCmd(argv.slice(1));
@@ -249,15 +271,8 @@ async function videoEditCmd(argv) {
249
271
  if (!args.video)
250
272
  die(2, "--video <url> is required");
251
273
  parseTimeoutSeconds(args.timeout);
252
- let server;
253
- try {
254
- server = await resolveServer({ serverFlag: args.server });
255
- }
256
- catch (e) {
257
- die(exitCodeForError(e), e.message);
258
- throw e;
259
- }
260
- const res = await fetch(`${server.base}/api/video/edit`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, videoUrl: args.video }), signal: timeoutSignal(args.timeout) });
274
+ const server = await resolveServer({ serverFlag: args.server });
275
+ const res = await fetchServer(server.base, "/api/video/edit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, videoUrl: args.video }), signal: timeoutSignal(args.timeout) });
261
276
  const data = await readJsonResponse(res, "edit");
262
277
  if (!res.ok)
263
278
  die(1, `edit failed: ${data.error ?? res.status}`);
@@ -288,15 +303,8 @@ async function videoExtendCmd(argv) {
288
303
  if (duration < 2 || duration > 10)
289
304
  die(2, "--duration must be between 2 and 10");
290
305
  parseTimeoutSeconds(args.timeout);
291
- let server;
292
- try {
293
- server = await resolveServer({ serverFlag: args.server });
294
- }
295
- catch (e) {
296
- die(exitCodeForError(e), e.message);
297
- throw e;
298
- }
299
- const res = await fetch(`${server.base}/api/video/extend`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, videoUrl: args.video, duration }), signal: timeoutSignal(args.timeout) });
306
+ const server = await resolveServer({ serverFlag: args.server });
307
+ const res = await fetchServer(server.base, "/api/video/extend", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt, videoUrl: args.video, duration }), signal: timeoutSignal(args.timeout) });
300
308
  const data = await readJsonResponse(res, "extend");
301
309
  if (!res.ok)
302
310
  die(1, `extend failed: ${data.error ?? res.status}`);
@@ -354,14 +362,7 @@ async function videoContinueCmd(argv) {
354
362
  const model = args.model ? canonicalVideoModel(String(args.model)) : GROK_VIDEO_MODEL_BASE;
355
363
  validateCliVideoResolution(model, resolution, "image-to-video");
356
364
  parseTimeoutSeconds(args.timeout);
357
- let server;
358
- try {
359
- server = await resolveServer({ serverFlag: args.server });
360
- }
361
- catch (e) {
362
- die(exitCodeForError(e), e.message);
363
- throw e;
364
- }
365
+ const server = await resolveServer({ serverFlag: args.server });
365
366
  const requestId = `req_cli_video_continue_${Date.now().toString(36)}`;
366
367
  const body = {
367
368
  prompt,
@@ -407,14 +408,7 @@ async function videoFrameCmd(argv) {
407
408
  if (position !== "last" && !/^\d+(\.\d+)?$/.test(position))
408
409
  die(2, "--position must be a non-negative number");
409
410
  parseTimeoutSeconds(args.timeout);
410
- let server;
411
- try {
412
- server = await resolveServer({ serverFlag: args.server });
413
- }
414
- catch (e) {
415
- die(exitCodeForError(e), e.message);
416
- throw e;
417
- }
411
+ const server = await resolveServer({ serverFlag: args.server });
418
412
  // A path that exists on disk is uploaded; anything else is treated as a
419
413
  // generated filename the server can resolve itself. Before this, a clip saved
420
414
  // with -o was rejected as "not found" while sitting in the current directory,
@@ -427,13 +421,13 @@ async function videoFrameCmd(argv) {
427
421
  }
428
422
  catch { /* not a local path; fall through to the generated-file lookup */ }
429
423
  const res = localBytes
430
- ? await fetch(`${server.base}/api/video/frame`, {
424
+ ? await fetchServer(server.base, "/api/video/frame", {
431
425
  method: "POST",
432
426
  headers: { "Content-Type": "application/json" },
433
427
  body: JSON.stringify({ video: localBytes.toString("base64"), position }),
434
428
  signal: timeoutSignal(args.timeout),
435
429
  })
436
- : await fetch(`${server.base}/api/video/frame?file=${encodeURIComponent(file)}&position=${encodeURIComponent(position)}`, { signal: timeoutSignal(args.timeout) });
430
+ : await fetchServer(server.base, `/api/video/frame?file=${encodeURIComponent(file)}&position=${encodeURIComponent(position)}`, { signal: timeoutSignal(args.timeout) });
437
431
  if (!res.ok) {
438
432
  const payload = await readJsonResponse(res, "frame extraction");
439
433
  const detail = payload.error ?? String(res.status);
@@ -459,15 +453,8 @@ async function videoAnalyzeCmd(argv) {
459
453
  if (!videoUrl)
460
454
  die(2, "generated video filename required");
461
455
  parseTimeoutSeconds(args.timeout);
462
- let server;
463
- try {
464
- server = await resolveServer({ serverFlag: args.server });
465
- }
466
- catch (e) {
467
- die(exitCodeForError(e), e.message);
468
- throw e;
469
- }
470
- const res = await fetch(`${server.base}/api/video/analyze`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ videoUrl }), signal: timeoutSignal(args.timeout) });
456
+ const server = await resolveServer({ serverFlag: args.server });
457
+ const res = await fetchServer(server.base, "/api/video/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ videoUrl }), signal: timeoutSignal(args.timeout) });
471
458
  const data = await readJsonResponse(res, "analyze");
472
459
  if (!res.ok)
473
460
  die(1, `analyze failed: ${data.error || res.status}`);
package/bin/ima2.js CHANGED
@@ -7,16 +7,41 @@ import { join, dirname } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { spawn, execFileSync } from "child_process";
9
9
  import { confirmDestructiveAction } from "./lib/destructive-confirm.js";
10
- import { doctor } from "./commands/doctor.js";
11
10
  import { openUrl, killProcessTree } from "./lib/platform.js";
12
- import { maybePromptGithubStar } from "./lib/star-prompt.js";
13
11
  import { ensureFreshUiDist } from "./lib/ui-build.js";
14
12
  import { codexFileLoginArgs, detectCodexAuth } from "../lib/codexDetect.js";
15
13
  import { packageCliCommand } from "../lib/packageCli.js";
16
- import { config as runtimeConfig } from "../config.js";
17
14
  import { errInfo } from "../lib/errInfo.js";
18
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
16
  const ROOT = join(__dirname, "..");
17
+ let pkg = { version: "?", name: "ima2-gen" };
18
+ try {
19
+ const metadata = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
20
+ if (metadata && typeof metadata.version === "string" && typeof metadata.name === "string")
21
+ pkg = metadata;
22
+ }
23
+ catch { }
24
+ // Installation diagnosis must run before any account/config initialization.
25
+ if (process.argv[2] === "doctor" && process.argv.slice(3).includes("--installation")) {
26
+ const options = process.argv.slice(3);
27
+ const allowed = new Set(["--installation", "--json", "--help", "-h"]);
28
+ if (options.some((option) => !allowed.has(option)) || new Set(options).size !== options.length) {
29
+ console.error("Installation doctor accepts only --installation, --json and --help.");
30
+ exitFlushed(2);
31
+ }
32
+ if (options.includes("--help") || options.includes("-h")) {
33
+ console.log("Usage: ima2 doctor --installation [--json]\nOffline package, Node, native binding, skill and UI checks. No config, account or network checks.");
34
+ exitFlushed(0);
35
+ }
36
+ const { buildInstallationDoctorLines } = await import("./lib/doctor-runtime.js");
37
+ const { buildDoctorReport, renderDoctorReport } = await import("./lib/doctor-report.js");
38
+ const report = buildDoctorReport({ version: pkg.version, mode: "installation", lines: buildInstallationDoctorLines(ROOT) });
39
+ console.log(options.includes("--json") ? JSON.stringify(report) : renderDoctorReport(report));
40
+ exitFlushed(report.summary.exitCode);
41
+ }
42
+ const { config: runtimeConfig } = await import("../config.js");
43
+ const { doctor } = await import("./commands/doctor.js");
44
+ const { maybePromptGithubStar } = await import("./lib/star-prompt.js");
20
45
  // Config lives under runtimeConfig.storage.configDir (honors IMA2_CONFIG_DIR).
21
46
  // Legacy installs that stored config at <packageRoot>/.ima2/config.json will be
22
47
  // migrated on first write.
@@ -33,12 +58,6 @@ function runCodexLogin() {
33
58
  throw new Error("Codex login completed without a file-backed session for the GPT OAuth proxy");
34
59
  }
35
60
  }
36
- // Load package.json for version
37
- let pkg = { version: "?", name: "ima2-gen" };
38
- try {
39
- pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
40
- }
41
- catch { }
42
61
  function loadConfig() {
43
62
  if (existsSync(CONFIG_FILE)) {
44
63
  return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
@@ -1,9 +1,10 @@
1
1
  // wp4 046: CLI-side character element resolution + binding precheck.
2
2
  // Prechecks are UX hints only — the server response is the final authority
3
3
  // (041/046 server-authority clause); server error codes pass through unchanged.
4
+ import { fetchServer } from "./client.js";
4
5
  export async function resolveCharacterElement(serverBase, idOrName) {
5
6
  const base = serverBase.replace(/\/$/, "");
6
- const response = await fetch(`${base}/api/assets?kind=element&limit=500`);
7
+ const response = await fetchServer(base, "/api/assets?kind=element&limit=500");
7
8
  if (!response.ok)
8
9
  throw new Error(`failed to list elements: HTTP ${response.status}`);
9
10
  const payload = await response.json();
package/bin/lib/client.js CHANGED
@@ -1,7 +1,122 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { PROVIDER_ERROR_MAP, statusForErrorCode } from "../../lib/errors/providerMap.js";
4
5
  export const DEFAULT_PORT = 3333;
6
+ let binding;
7
+ /** Service discovery must not inherit a previous command's credential selection. */
8
+ export function clearServerBinding() { binding = undefined; }
9
+ function transportError(code, message, status) {
10
+ return Object.assign(new Error(message), { code, ...(status === undefined ? {} : { status }) });
11
+ }
12
+ function serverOrigin(value) {
13
+ try {
14
+ if (typeof value !== "string" || !/^https?:\/\//i.test(value) || /[\s\\?#@]/.test(value))
15
+ throw new Error();
16
+ const url = new URL(value);
17
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/")
18
+ throw new Error();
19
+ return url.origin;
20
+ }
21
+ catch {
22
+ throw transportError("SERVER_URL_INVALID", "Server must be an HTTP(S) origin without credentials, query, fragment or path.");
23
+ }
24
+ }
25
+ function transportUrl(value, base) {
26
+ try {
27
+ const url = new URL(value, base);
28
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash
29
+ || [...url.searchParams.keys()].some((key) => /^token(?:\[|$)/i.test(key)))
30
+ throw new Error();
31
+ return url;
32
+ }
33
+ catch {
34
+ throw transportError("SERVER_URL_INVALID", "Invalid server request URL; URL credentials and LAN query tokens are not supported.");
35
+ }
36
+ }
37
+ function transportHeaders(init, origin) {
38
+ try {
39
+ const headers = new Headers(init.headers);
40
+ if (headers.has("cookie") || headers.has("x-ima2-token"))
41
+ throw new Error();
42
+ if (binding?.origin === origin && binding.token)
43
+ headers.set("x-ima2-token", binding.token);
44
+ return headers;
45
+ }
46
+ catch {
47
+ throw transportError("SERVER_CREDENTIAL_CONFLICT", "Use IMA2_LAN_TOKEN with an explicit server; caller cookie/token headers are not supported.");
48
+ }
49
+ }
50
+ async function checkAccess(response) {
51
+ if (response.status >= 300 && response.status < 400) {
52
+ try {
53
+ await response.body?.cancel();
54
+ }
55
+ catch { /* Preserve the safe redirect error. */ }
56
+ throw transportError("SERVER_REDIRECT_REJECTED", "Server redirects are not allowed.");
57
+ }
58
+ if (response.status !== 401 && response.status !== 403)
59
+ return;
60
+ const denied = transportError("SERVER_ACCESS_DENIED", "Server authentication or access was denied.", response.status);
61
+ let body;
62
+ try {
63
+ body = await response.json();
64
+ }
65
+ catch {
66
+ throw denied;
67
+ }
68
+ const envelope = body && typeof body === "object" && !Array.isArray(body) ? body : {};
69
+ const nested = envelope.error && typeof envelope.error === "object" && !Array.isArray(envelope.error)
70
+ ? envelope.error : {};
71
+ const reserved = [envelope.code, nested.code].filter((code) => typeof code === "string"
72
+ && ["LAN_TOKEN_REQUIRED", "LOCAL_HOST_REJECTED", "LOCAL_ORIGIN_REJECTED", "SERVER_ACCESS_DENIED"].includes(code));
73
+ if (reserved.length) {
74
+ const code = reserved[0];
75
+ if (code === "SERVER_ACCESS_DENIED" || reserved.some((other) => other !== code)
76
+ || response.status !== (code === "LAN_TOKEN_REQUIRED" ? 401 : 403))
77
+ throw denied;
78
+ throw transportError(code, code === "LAN_TOKEN_REQUIRED"
79
+ ? "LAN authentication required. Specify a known --server or IMA2_SERVER and set IMA2_LAN_TOKEN."
80
+ : "Server access denied. Check the configured server Host/Origin policy.", response.status);
81
+ }
82
+ if (envelope.code !== undefined && nested.code !== undefined && envelope.code !== nested.code)
83
+ throw denied;
84
+ const code = typeof envelope.error === "string" ? envelope.code : nested.code;
85
+ const message = typeof envelope.error === "string" ? envelope.error : nested.message;
86
+ if (typeof code === "string" && typeof message === "string" && message.trim()
87
+ && (statusForErrorCode(code, 0) === 401 || Object.hasOwn(PROVIDER_ERROR_MAP, code))) {
88
+ throw Object.assign(transportError(code, message, response.status), { body });
89
+ }
90
+ throw denied;
91
+ }
92
+ /** Only explicit selection below can bind a secret; request URLs never do. */
93
+ export async function fetchServerUrl(url, init = {}) {
94
+ const target = transportUrl(url);
95
+ const headers = transportHeaders(init, target.origin);
96
+ let response;
97
+ try {
98
+ response = await fetch(target.href, { ...init, headers, credentials: "omit", redirect: "error" });
99
+ }
100
+ catch (error) {
101
+ if (init.signal?.aborted)
102
+ throw error;
103
+ // Node's native fetch rejects redirect:error before exposing a Response.
104
+ const cause = error?.cause;
105
+ if (cause?.message === "unexpected redirect") {
106
+ throw transportError("SERVER_REDIRECT_REJECTED", "Server redirects are not allowed.");
107
+ }
108
+ throw transportError("NETWORK_FAILED", "Server request failed.");
109
+ }
110
+ await checkAccess(response);
111
+ return response;
112
+ }
113
+ export async function fetchServer(base, pathOrUrl, init = {}) {
114
+ const origin = serverOrigin(base);
115
+ const target = transportUrl(pathOrUrl, `${origin}/`);
116
+ if (target.origin !== origin)
117
+ throw transportError("SERVER_URL_INVALID", "Server request must stay on its selected origin.");
118
+ return fetchServerUrl(target.href, init);
119
+ }
5
120
  function readAdvertise() {
6
121
  const p = process.env.IMA2_ADVERTISE_FILE ||
7
122
  join(process.env.IMA2_CONFIG_DIR || join(homedir(), ".ima2"), "server.json");
@@ -18,27 +133,38 @@ async function probe(base, timeoutMs = 600) {
18
133
  const controller = new AbortController();
19
134
  const timer = setTimeout(() => controller.abort(), timeoutMs);
20
135
  try {
21
- const r = await fetch(`${base}/api/health`, {
136
+ const r = await fetchServer(base, "/api/health", {
22
137
  signal: controller.signal,
23
138
  // CLI is short-lived: close the socket so process.exit() never races a
24
139
  // keep-alive handle (libuv UV_HANDLE_CLOSING assert on Windows, 260719).
25
140
  headers: { connection: "close" },
26
141
  });
27
142
  if (!r.ok)
28
- return null;
29
- return (await r.json());
143
+ throw transportError("SERVER_HTTP_ERROR", `Server health failed: HTTP ${r.status}`, r.status);
144
+ try {
145
+ return (await r.json());
146
+ }
147
+ catch (error) {
148
+ if (controller.signal.aborted)
149
+ throw error;
150
+ throw transportError("SERVER_INVALID_HEALTH", "Server returned an invalid health response.");
151
+ }
30
152
  }
31
- catch {
32
- return null;
153
+ catch (error) {
154
+ if (error?.code === "NETWORK_FAILED"
155
+ || (controller.signal.aborted && error?.name === "AbortError"))
156
+ return null;
157
+ throw error;
33
158
  }
34
159
  finally {
35
160
  clearTimeout(timer);
36
161
  }
37
162
  }
38
163
  export async function findRunningServer({ includeEnv = true } = {}) {
164
+ clearServerBinding();
165
+ if (includeEnv && process.env.IMA2_SERVER !== undefined)
166
+ return selectServer(process.env.IMA2_SERVER);
39
167
  const candidates = [];
40
- if (includeEnv && process.env.IMA2_SERVER)
41
- candidates.push(process.env.IMA2_SERVER.replace(/\/$/, ""));
42
168
  const adv = readAdvertise();
43
169
  if (adv?.backend?.url)
44
170
  candidates.push(String(adv.backend.url).replace(/\/$/, ""));
@@ -49,7 +175,8 @@ export async function findRunningServer({ includeEnv = true } = {}) {
49
175
  candidates.push(`http://localhost:${DEFAULT_PORT}`);
50
176
  const seen = new Set();
51
177
  const uniq = candidates.filter((c) => !seen.has(c) && seen.add(c));
52
- for (const base of uniq) {
178
+ for (const candidate of uniq) {
179
+ const base = serverOrigin(candidate);
53
180
  const health = await probe(base);
54
181
  if (health)
55
182
  return { base, health };
@@ -57,21 +184,29 @@ export async function findRunningServer({ includeEnv = true } = {}) {
57
184
  return null;
58
185
  }
59
186
  export async function resolveServer({ serverFlag } = {}) {
60
- if (serverFlag) {
61
- const base = serverFlag.replace(/\/$/, "");
187
+ clearServerBinding();
188
+ if (serverFlag !== undefined)
189
+ return selectServer(serverFlag);
190
+ const found = await findRunningServer();
191
+ if (found)
192
+ return found;
193
+ throw transportError("SERVER_UNREACHABLE", "Server unreachable; is 'ima2 serve' running?");
194
+ }
195
+ async function selectServer(value) {
196
+ const base = serverOrigin(value);
197
+ const selected = { origin: base, token: process.env.IMA2_LAN_TOKEN || "" };
198
+ binding = selected;
199
+ try {
62
200
  const health = await probe(base);
63
201
  if (health)
64
202
  return { base, health };
65
- const err = new Error(`server unreachable at ${base}`);
66
- err.code = "SERVER_UNREACHABLE";
67
- throw err;
203
+ throw transportError("SERVER_UNREACHABLE", "Selected server is unreachable.");
204
+ }
205
+ catch (error) {
206
+ if (binding === selected)
207
+ clearServerBinding();
208
+ throw error;
68
209
  }
69
- const found = await findRunningServer();
70
- if (found)
71
- return found;
72
- const err = new Error("server unreachable — is 'ima2 serve' running?");
73
- err.code = "SERVER_UNREACHABLE";
74
- throw err;
75
210
  }
76
211
  export async function request(base, path, { method = "GET", body, headers: extraHeaders, raw = false, timeoutMs = 180_000, } = {}) {
77
212
  const baseHeaders = raw
@@ -85,7 +220,7 @@ export async function request(base, path, { method = "GET", body, headers: extra
85
220
  let res;
86
221
  let text;
87
222
  try {
88
- res = await fetch(base + path, {
223
+ res = await fetchServer(base, path, {
89
224
  method,
90
225
  // connection: close — see the health-check note above.
91
226
  headers: { connection: "close", ...finalHeaders },
@@ -1,9 +1,12 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { config as runtimeConfig } from "../../config.js";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, openSync, closeSync, renameSync, rmSync } from "fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { config as runtimeConfig, CONFIG_SOURCE_FILE } from "../../config.js";
4
+ import { parsePublicOrigins } from "../../lib/localAccessPolicy.js";
3
5
  import { AUTH_CONFIG_KEYS, KEY_TO_ENV, WRITABLE_CONFIG_KEYS, isSensitiveConfigKey as isSensitiveConfigKeyShared, } from "../../lib/configKeys.js";
4
6
  export { KEY_TO_ENV, WRITABLE_CONFIG_KEYS };
5
7
  export const CONFIG_FILE = runtimeConfig.storage.configFile;
6
8
  export const CONFIG_DIR = runtimeConfig.storage.configDir;
9
+ let fileSource = CONFIG_SOURCE_FILE ?? CONFIG_FILE;
7
10
  export const AUTH_KEYS = AUTH_CONFIG_KEYS;
8
11
  export function isAuthConfigKey(key) {
9
12
  return AUTH_CONFIG_KEYS.has(key);
@@ -15,15 +18,28 @@ export function isSensitiveConfigKey(key) {
15
18
  return isSensitiveConfigKeyShared(key);
16
19
  }
17
20
  export function redactValue(key, value) {
18
- if (isSensitiveConfigKey(key))
21
+ if (key === "server.publicOrigins") {
22
+ try {
23
+ return parsePublicOrigins(value);
24
+ }
25
+ catch {
26
+ return "<invalid public origins>";
27
+ }
28
+ }
29
+ if (isSensitiveConfigKey(key) && key !== "security.lanTokenMaxBytes")
19
30
  return value ? "<redacted>" : value;
31
+ if (Array.isArray(value))
32
+ return value.map((item, index) => redactValue(`${key}.${index}`, item));
33
+ if (value && typeof value === "object") {
34
+ return Object.fromEntries(Object.entries(value).map(([child, item]) => [child, redactValue(key ? `${key}.${child}` : child, item)]));
35
+ }
20
36
  return value;
21
37
  }
22
38
  export function loadFileCfg() {
23
- if (!existsSync(CONFIG_FILE))
39
+ if (!existsSync(fileSource))
24
40
  return {};
25
41
  try {
26
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
42
+ return JSON.parse(readFileSync(fileSource, "utf-8"));
27
43
  }
28
44
  catch {
29
45
  return {};
@@ -40,8 +56,25 @@ export function loadCliDefaults() {
40
56
  };
41
57
  }
42
58
  export function saveFileCfg(cfg) {
43
- mkdirSync(CONFIG_DIR, { recursive: true });
44
- writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
59
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
60
+ const temporary = `${CONFIG_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
61
+ let descriptor;
62
+ let owned = false;
63
+ try {
64
+ descriptor = openSync(temporary, "wx", 0o600);
65
+ owned = true;
66
+ writeFileSync(descriptor, JSON.stringify(cfg, null, 2));
67
+ closeSync(descriptor);
68
+ descriptor = undefined;
69
+ renameSync(temporary, CONFIG_FILE);
70
+ fileSource = CONFIG_FILE;
71
+ }
72
+ finally {
73
+ if (descriptor !== undefined)
74
+ closeSync(descriptor);
75
+ if (owned)
76
+ rmSync(temporary, { force: true });
77
+ }
45
78
  }
46
79
  export function getNestedKey(obj, dotKey) {
47
80
  const parts = dotKey.split(".");
@@ -1,5 +1,6 @@
1
1
  import { hostname, platform, release } from "node:os";
2
2
  import { listProviders } from "../../lib/providers/registry.js";
3
+ import { buildDoctorReport } from "./doctor-report.js";
3
4
  const SECRET_PATTERN = /(sk-|xai-|apikey-|Bearer\s+[A-Za-z0-9._-]+|-----BEGIN)/i;
4
5
  function hashHostname(value) {
5
6
  let hash = 0;
@@ -8,15 +9,18 @@ function hashHostname(value) {
8
9
  return `h${(hash >>> 0).toString(16)}`;
9
10
  }
10
11
  export function buildDoctorBundle(input) {
12
+ const report = input.report ?? buildDoctorReport({ version: input.version, mode: "standard", lines: input.providerLines });
13
+ const providers = buildDoctorReport({ version: input.version, mode: "standard", lines: input.providerLines });
11
14
  return {
15
+ schemaVersion: report.schemaVersion, checks: report.checks, summary: report.summary,
12
16
  version: input.version,
13
17
  node: process.version,
14
18
  platform: `${platform()} ${release()}`,
15
19
  hostnameHash: hashHostname(hostname()),
16
- lanes: input.providerLines.map((line) => ({
20
+ lanes: providers.checks.filter((line) => line.lane).map((line) => ({
17
21
  lane: line.lane,
18
22
  kind: line.kind,
19
- text: SECRET_PATTERN.test(line.text) ? "[redacted]" : line.text,
23
+ text: line.message,
20
24
  })),
21
25
  };
22
26
  }