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.
- package/.env.example +10 -0
- package/CHANGELOG.md +14 -0
- package/README.md +15 -2
- package/bin/commands/capabilities.js +5 -3
- package/bin/commands/config.js +15 -4
- package/bin/commands/defaults.js +7 -5
- package/bin/commands/doctor.js +88 -39
- package/bin/commands/gen.js +21 -4
- package/bin/commands/models.js +2 -2
- package/bin/commands/ping.js +1 -1
- package/bin/commands/prompt.js +5 -3
- package/bin/commands/service.js +32 -4
- package/bin/commands/tools.js +19 -3
- package/bin/commands/upscale.js +18 -4
- package/bin/commands/video.js +37 -50
- package/bin/ima2.js +28 -9
- package/bin/lib/characterResolve.js +2 -1
- package/bin/lib/client.js +155 -20
- package/bin/lib/config-store.js +40 -7
- package/bin/lib/doctor-bundle.js +6 -2
- package/bin/lib/doctor-checks.js +13 -29
- package/bin/lib/doctor-media.js +3 -3
- package/bin/lib/doctor-providers.js +48 -35
- package/bin/lib/doctor-report.js +78 -0
- package/bin/lib/doctor-runtime.js +173 -0
- package/bin/lib/mcpJob.js +19 -7
- package/bin/lib/output.js +3 -0
- package/bin/lib/platform.js +12 -9
- package/bin/lib/recover-output.js +9 -2
- package/bin/lib/sse.js +9 -1
- package/bin/lib/videoMcp.js +16 -8
- package/config.js +41 -5
- package/docs/API.md +154 -16
- package/docs/CLI.md +65 -1
- package/docs/DOCKER.md +19 -4
- package/docs/DOCKER.zh-CN.md +11 -4
- package/docs/DOCKER.zh-TW.md +11 -4
- package/docs/FAQ.md +13 -0
- package/docs/NPX_QUICKSTART.md +6 -0
- package/docs/README.ja.md +15 -2
- package/docs/README.ko.md +15 -2
- package/docs/README.zh-CN.md +15 -2
- package/docs/README.zh-TW.md +15 -2
- package/docs/migration/runtime-test-inventory.md +79 -8
- package/lib/agyArtifact.js +82 -0
- package/lib/agyArtifactRead.js +249 -0
- package/lib/agyImageAdapter.js +2 -329
- package/lib/agyProcess.js +122 -0
- package/lib/apiRequestBudget.js +48 -0
- package/lib/assetLifecycle.js +42 -9
- package/lib/canvasVersionStore.js +46 -14
- package/lib/capabilities.js +7 -1
- package/lib/configKeys.js +2 -0
- package/lib/errors/providerMap.js +26 -0
- package/lib/eventBus.js +10 -6
- package/lib/eventsPolicy.js +9 -0
- package/lib/geminiApiImageAdapter.js +1 -214
- package/lib/generatePipeline.js +39 -165
- package/lib/generatedMediaAccess.js +74 -0
- package/lib/generationErrors.js +3 -22
- package/lib/grokImageAdapter.js +2 -353
- package/lib/grokImageCore.js +1 -57
- package/lib/grokImageDownload.js +159 -0
- package/lib/grokImageDownloadPolicy.js +124 -0
- package/lib/grokImagePlanner.js +301 -0
- package/lib/grokMultimodeAdapter.js +1 -75
- package/lib/grokVideoDownload.js +137 -34
- package/lib/inflight.js +112 -112
- package/lib/jobs/terminalStore.js +45 -0
- package/lib/lanSessionStore.js +131 -0
- package/lib/localAccessPolicy.js +180 -0
- package/lib/localLanAccess.js +281 -0
- package/lib/logger.js +22 -13
- package/lib/mcp/downloadMediaResult.js +72 -78
- package/lib/multimodePipeline.js +39 -126
- package/lib/nodeGeneration.js +44 -101
- package/lib/nodeStore.js +25 -11
- package/lib/nodeValidation.js +5 -1
- package/lib/pinnedHttpGet.js +174 -0
- package/lib/promptImport/githubFolder.js +32 -18
- package/lib/promptImport/githubSource.js +14 -10
- package/lib/promptImport/parsePromptCandidates.js +32 -2
- package/lib/providers/adapters/agyOperations.js +179 -0
- package/lib/providers/adapters/comfy.js +1 -0
- package/lib/providers/adapters/geminiOperations.js +214 -0
- package/lib/providers/adapters/googleExecution.js +97 -0
- package/lib/providers/adapters/grokExecution.js +137 -0
- package/lib/providers/adapters/grokMultimodeOperations.js +83 -0
- package/lib/providers/adapters/grokOperations.js +63 -0
- package/lib/providers/adapters/openaiExecution.js +118 -0
- package/lib/providers/adapters/openaiOperations.js +201 -0
- package/lib/providers/adapters/openaiTypes.js +1 -0
- package/lib/providers/derive.js +4 -0
- package/lib/providers/deriveCore.js +2 -7
- package/lib/providers/execution/admission.js +25 -0
- package/lib/providers/execution/index.js +34 -0
- package/lib/providers/execution/legacy.js +17 -0
- package/lib/providers/execution/legacyClassic.js +66 -0
- package/lib/providers/execution/legacyEdit.js +30 -0
- package/lib/providers/execution/legacyMultimode.js +34 -0
- package/lib/providers/execution/legacyNode.js +41 -0
- package/lib/providers/execution/types.js +1 -0
- package/lib/providers/registry.js +19 -14
- package/lib/providers/surfaceSupport.js +22 -0
- package/lib/responsesImageAdapter.js +1 -389
- package/lib/responsesTransport.js +190 -0
- package/lib/spriteJobEvents.js +20 -2
- package/lib/ssePublish.js +5 -4
- package/lib/videoExtendI2vOperation.js +76 -0
- package/package.json +5 -2
- package/routes/edit.js +31 -123
- package/routes/events.js +140 -63
- package/routes/mcpMedia.js +10 -2
- package/routes/models.js +28 -25
- package/routes/videoExtended.js +9 -68
- package/server.js +24 -46
- package/ui/dist/.vite/manifest.json +90 -63
- package/ui/dist/assets/{AgentWorkspace-PbBytTeV.js → AgentWorkspace-CL3MLv3l.js} +2 -2
- package/ui/dist/assets/App-B9cEM6dJ.js +9 -0
- package/ui/dist/assets/AssetGenWorkspace-swWPvXrx.js +2 -0
- package/ui/dist/assets/AssetsWorkspace-CRVeYU2F.js +1 -0
- package/ui/dist/assets/{CardNewsWorkspace-BS3jKOja.js → CardNewsWorkspace-BAOxWLKf.js} +2 -2
- package/ui/dist/assets/GenerationRequestLogPanel-DZtYZm-D.js +1 -0
- package/ui/dist/assets/HomeWorkspace-BeZ1s0Jm.js +1 -0
- package/ui/dist/assets/KeyingPanel-ZyBSXioh.js +1 -0
- package/ui/dist/assets/NodeCanvas-BvvcCErf.js +7 -0
- package/ui/dist/assets/{PromptBuilderPanel-D97A4pwB.js → PromptBuilderPanel-DBycmShA.js} +2 -2
- package/ui/dist/assets/PromptImportDialog-ChvMv7ek.js +2 -0
- package/ui/dist/assets/PromptImportDiscoverySection-60py7whH.js +1 -0
- package/ui/dist/assets/PromptImportFolderSection-CCRh_-_7.js +1 -0
- package/ui/dist/assets/PromptLibraryPanel-BFWQO8OT.js +2 -0
- package/ui/dist/assets/SettingsWorkspace-B5Ghsgdj.js +1 -0
- package/ui/dist/assets/SpriteRecipeWorkspace-DRWdkmwU.js +1 -0
- package/ui/dist/assets/index-7ngEOVxt.js +5 -0
- package/ui/dist/assets/index-B5jy7ePg.css +1 -0
- package/ui/dist/assets/index-gxh_02DQ.js +22 -0
- package/ui/dist/assets/{pptxgen.es-D4pedbwM.js → pptxgen.es-OUNhQLEV.js} +1 -1
- package/ui/dist/assets/promptBuilderStore-BTUUzbBT.js +1 -0
- package/ui/dist/assets/useAgentDialogFocus-DSc2qY8p.js +1 -0
- package/ui/dist/index.html +3 -2
- package/ui/dist/assets/AssetGenWorkspace-B-TGlvVb.js +0 -2
- package/ui/dist/assets/AssetsWorkspace-LnGaW151.js +0 -1
- package/ui/dist/assets/GenerationRequestLogPanel-DuDWD_ic.js +0 -1
- package/ui/dist/assets/HomeWorkspace-DVP7UG6O.js +0 -1
- package/ui/dist/assets/KeyingPanel-5bRUFQaq.js +0 -1
- package/ui/dist/assets/NodeCanvas-BBEpERTb.js +0 -7
- package/ui/dist/assets/PromptImportDialog-C89MN5JY.js +0 -2
- package/ui/dist/assets/PromptImportDiscoverySection-BgRfDOs2.js +0 -1
- package/ui/dist/assets/PromptImportFolderSection-zTMyriT1.js +0 -1
- package/ui/dist/assets/PromptLibraryPanel-BD96qZu_.js +0 -2
- package/ui/dist/assets/SettingsWorkspace-BAOk3X_8.js +0 -1
- package/ui/dist/assets/SpriteRecipeWorkspace-ClIM5QRo.js +0 -1
- package/ui/dist/assets/index-0fpfY8vu.css +0 -1
- package/ui/dist/assets/index-BFciPGTn.js +0 -5
- package/ui/dist/assets/index-BbykeXR9.js +0 -30
- package/ui/dist/assets/promptBuilderStore-Do7M8g7J.js +0 -1
- package/ui/dist/assets/useAgentDialogFocus-B6-QgEVI.js +0 -1
package/bin/commands/upscale.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
}
|
package/bin/commands/video.js
CHANGED
|
@@ -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
|
-
|
|
73
|
-
if (!url)
|
|
72
|
+
if (!rawUrl)
|
|
74
73
|
die(1, "server did not return a video url");
|
|
75
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
463
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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 },
|
package/bin/lib/config-store.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
-
import {
|
|
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 (
|
|
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(
|
|
39
|
+
if (!existsSync(fileSource))
|
|
24
40
|
return {};
|
|
25
41
|
try {
|
|
26
|
-
return JSON.parse(readFileSync(
|
|
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
|
-
|
|
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(".");
|
package/bin/lib/doctor-bundle.js
CHANGED
|
@@ -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:
|
|
20
|
+
lanes: providers.checks.filter((line) => line.lane).map((line) => ({
|
|
17
21
|
lane: line.lane,
|
|
18
22
|
kind: line.kind,
|
|
19
|
-
text:
|
|
23
|
+
text: line.message,
|
|
20
24
|
})),
|
|
21
25
|
};
|
|
22
26
|
}
|