ima2-gen 1.1.10 → 1.1.11

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 (91) hide show
  1. package/README.md +6 -1
  2. package/bin/commands/capabilities.js +90 -0
  3. package/bin/commands/capabilities.ts +93 -0
  4. package/bin/commands/config.js +11 -143
  5. package/bin/commands/config.ts +27 -141
  6. package/bin/commands/defaults.js +180 -0
  7. package/bin/commands/defaults.ts +192 -0
  8. package/bin/commands/edit.js +35 -2
  9. package/bin/commands/edit.ts +33 -2
  10. package/bin/commands/gen.js +42 -4
  11. package/bin/commands/gen.ts +39 -4
  12. package/bin/commands/ls.js +3 -1
  13. package/bin/commands/ls.ts +2 -1
  14. package/bin/commands/multimode.js +62 -5
  15. package/bin/commands/multimode.ts +56 -5
  16. package/bin/commands/node.js +11 -1
  17. package/bin/commands/node.ts +10 -1
  18. package/bin/commands/observability.js +1 -1
  19. package/bin/commands/observability.ts +1 -1
  20. package/bin/commands/ps.js +1 -1
  21. package/bin/commands/ps.ts +1 -1
  22. package/bin/commands/skill.js +62 -0
  23. package/bin/commands/skill.ts +70 -0
  24. package/bin/ima2.js +10 -1
  25. package/bin/ima2.ts +10 -1
  26. package/bin/lib/config-store.js +156 -0
  27. package/bin/lib/config-store.ts +163 -0
  28. package/bin/lib/recover-output.js +104 -0
  29. package/bin/lib/recover-output.ts +139 -0
  30. package/docs/CLI.md +64 -8
  31. package/docs/migration/runtime-test-inventory.md +12 -1
  32. package/lib/canvasVersionStore.js +2 -0
  33. package/lib/canvasVersionStore.ts +2 -0
  34. package/lib/capabilities.js +70 -0
  35. package/lib/capabilities.ts +84 -0
  36. package/lib/cardNewsGenerator.js +6 -0
  37. package/lib/cardNewsGenerator.ts +6 -0
  38. package/lib/cardNewsPlannerPrompt.js +2 -0
  39. package/lib/cardNewsPlannerPrompt.ts +2 -0
  40. package/lib/generationCancel.js +18 -0
  41. package/lib/generationCancel.ts +28 -0
  42. package/lib/historyIndex.js +34 -0
  43. package/lib/historyIndex.ts +51 -0
  44. package/lib/inflight.js +31 -0
  45. package/lib/inflight.ts +34 -0
  46. package/lib/localImportStore.js +2 -0
  47. package/lib/localImportStore.ts +2 -0
  48. package/lib/nodeStore.js +2 -0
  49. package/lib/nodeStore.ts +2 -0
  50. package/lib/oauthProxy/prompts.js +17 -2
  51. package/lib/oauthProxy/prompts.ts +17 -2
  52. package/lib/openDirectory.js +1 -1
  53. package/lib/openDirectory.ts +1 -1
  54. package/lib/responsesImageAdapter.js +39 -8
  55. package/lib/responsesImageAdapter.ts +62 -7
  56. package/lib/visibleTextLanguagePolicy.js +7 -0
  57. package/lib/visibleTextLanguagePolicy.ts +7 -0
  58. package/package.json +3 -2
  59. package/routes/capabilities.js +13 -0
  60. package/routes/capabilities.ts +18 -0
  61. package/routes/edit.js +29 -2
  62. package/routes/edit.ts +33 -2
  63. package/routes/generate.js +38 -2
  64. package/routes/generate.ts +42 -2
  65. package/routes/health.js +2 -3
  66. package/routes/health.ts +2 -3
  67. package/routes/history.js +56 -22
  68. package/routes/history.ts +71 -21
  69. package/routes/index.js +2 -0
  70. package/routes/index.ts +2 -0
  71. package/routes/multimode.js +144 -27
  72. package/routes/multimode.ts +187 -50
  73. package/routes/nodes.js +25 -6
  74. package/routes/nodes.ts +35 -6
  75. package/skills/ima2/SKILL.md +206 -0
  76. package/ui/dist/.vite/manifest.json +10 -10
  77. package/ui/dist/assets/{CardNewsWorkspace-C9Cpxuxc.js → CardNewsWorkspace-j4ULtNdk.js} +1 -1
  78. package/ui/dist/assets/NodeCanvas-Bc7BUViM.js +7 -0
  79. package/ui/dist/assets/{PromptImportDialog-D8EMO--u.js → PromptImportDialog-DBKprBEo.js} +2 -2
  80. package/ui/dist/assets/{PromptImportDiscoverySection-BB2FrKuq.js → PromptImportDiscoverySection-m5v55Zsy.js} +1 -1
  81. package/ui/dist/assets/PromptImportFolderSection-DnPvJkfJ.js +1 -0
  82. package/ui/dist/assets/{PromptLibraryPanel-Z-4B8RSs.js → PromptLibraryPanel-BMSqfK9C.js} +2 -2
  83. package/ui/dist/assets/{SettingsWorkspace-DBYdgpPI.js → SettingsWorkspace-Cj3LD0uu.js} +1 -1
  84. package/ui/dist/assets/{index-Deo5wBiA.js → index-9aOJKFI-.js} +1 -1
  85. package/ui/dist/assets/index-De-AWE6B.css +1 -0
  86. package/ui/dist/assets/index-tQhOLR-C.js +28 -0
  87. package/ui/dist/index.html +2 -2
  88. package/ui/dist/assets/NodeCanvas-BllpfcQW.js +0 -7
  89. package/ui/dist/assets/PromptImportFolderSection-aVteBUcb.js +0 -1
  90. package/ui/dist/assets/index-B2TDuGqy.css +0 -1
  91. package/ui/dist/assets/index-DFlbOIxI.js +0 -25
package/README.md CHANGED
@@ -53,7 +53,7 @@ persists, reboot and run the update before starting ima2 again.
53
53
  - **Node mode**: branch a good image into multiple directions without losing the original.
54
54
  - **Multimode batches**: launch several Classic outputs from one prompt, watch slot-by-slot progress, and continue from the best result.
55
55
  - **Canvas Mode**: zoom, pan, annotate, erase, clean backgrounds, keep transparent previews, and export either alpha or matte-backed versions.
56
- - **Local gallery**: keep generated assets on your machine with session-aware history.
56
+ - **Local gallery**: keep generated assets on your machine with session-aware history. By default the gallery shows the current session and an All Images toggle reveals the full history; the default scope is sticky across sessions.
57
57
  - **Reference images**: drag, drop, paste, and attach up to 5 references; large images are compressed before upload.
58
58
  - **Prompt library imports**: import local prompt packs, GitHub folders, and curated GPT-image prompt hints into the built-in prompt library.
59
59
  - **Mobile shell**: use the app bar, compose sheet, and compact settings toggle on smaller screens.
@@ -196,6 +196,11 @@ environment variables > ~/.ima2/config.json > built-in defaults
196
196
  | `IMA2_LOG_LEVEL` | `warn` | Normal serve defaults to `warn`; dev mode defaults to `debug`; supports `debug`, `info`, `warn`, `error`, or `silent` |
197
197
  | `IMA2_INFLIGHT_TERMINAL_TTL_MS` | `30000` | Recent terminal job retention for debug views |
198
198
  | `OPENAI_API_KEY` | — | API key for the `provider: "api"` Responses API image path and auxiliary API-key features |
199
+ | `IMA2_API_IMAGE_MODEL_DEFAULT` | `gpt-5.4-mini` | Default image model for `provider: "api"` |
200
+ | `IMA2_API_REASONING_EFFORT` | `low` | Default reasoning effort for `provider: "api"` |
201
+ | `IMA2_API_IMAGE_SIZE` | `1024x1024` | Default size for `provider: "api"` |
202
+ | `IMA2_API_ALLOW_WEB_SEARCH` | `true` | Toggle web search for `provider: "api"` |
203
+ | `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
199
204
 
200
205
  ### Logging modes
201
206
 
@@ -0,0 +1,90 @@
1
+ import { readFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { config } from "../../config.js";
5
+ import { buildIma2Capabilities } from "../../lib/capabilities.js";
6
+ import { parseArgs } from "../lib/args.js";
7
+ import { resolveServer, request } from "../lib/client.js";
8
+ import { color, dieWithError, json, out } from "../lib/output.js";
9
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
10
+ const PACKAGE_PATH = join(ROOT, "package.json");
11
+ const HELP = `
12
+ ima2 capabilities [--json] [--server <url>] [--require-server]
13
+
14
+ Print agent-friendly capability metadata.
15
+
16
+ Options:
17
+ --json Print JSON
18
+ --server <url> Query a specific running server
19
+ --require-server Fail instead of falling back to local package metadata
20
+ `;
21
+ const FLAGS = {
22
+ json: { type: "boolean" },
23
+ server: { type: "string" },
24
+ "require-server": { type: "boolean" },
25
+ help: { short: "h", type: "boolean" },
26
+ };
27
+ function packageVersion() {
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(PACKAGE_PATH, "utf-8"));
30
+ return pkg.version || "?";
31
+ }
32
+ catch {
33
+ return "?";
34
+ }
35
+ }
36
+ function localCapabilities() {
37
+ return buildIma2Capabilities({
38
+ appConfig: config,
39
+ packageVersion: packageVersion(),
40
+ source: "local",
41
+ server: null,
42
+ });
43
+ }
44
+ async function readCapabilities(args) {
45
+ try {
46
+ const server = await resolveServer({ serverFlag: args.server });
47
+ return await request(server.base, "/api/capabilities", { timeoutMs: 5000 });
48
+ }
49
+ catch (error) {
50
+ if (args.server || args["require-server"])
51
+ throw error;
52
+ return localCapabilities();
53
+ }
54
+ }
55
+ function printText(capabilities) {
56
+ out(`ima2 capabilities (${capabilities.source})`);
57
+ out(`version: ${capabilities.version}`);
58
+ out(`server: ${capabilities.server || "none"}`);
59
+ out("");
60
+ out("defaults:");
61
+ out(` oauth model: ${capabilities.defaults?.oauth?.model}`);
62
+ out(` oauth reasoning: ${capabilities.defaults?.oauth?.reasoningEffort}`);
63
+ out(` api model: ${capabilities.defaults?.api?.model}`);
64
+ out(` api reasoning: ${capabilities.defaults?.api?.reasoningEffort}`);
65
+ out("");
66
+ out("valid:");
67
+ out(` models: ${capabilities.valid?.imageModels?.supported?.join(", ")}`);
68
+ out(` reasoning: ${capabilities.valid?.reasoningEfforts?.join(", ")}`);
69
+ out(` quality: ${capabilities.valid?.quality?.join(", ")}`);
70
+ out("");
71
+ out(`limits: refs=${capabilities.limits?.maxRefCount}, images=${capabilities.limits?.maxGeneratedImages}`);
72
+ out(color.dim(`maxParallel: ${capabilities.limits?.maxParallel?.value} (${capabilities.limits?.maxParallel?.note})`));
73
+ }
74
+ export default async function capabilitiesCmd(argv) {
75
+ const args = parseArgs(argv, { flags: FLAGS });
76
+ if (args.help) {
77
+ out(HELP);
78
+ return;
79
+ }
80
+ try {
81
+ const capabilities = await readCapabilities(args);
82
+ if (args.json)
83
+ json(capabilities);
84
+ else
85
+ printText(capabilities);
86
+ }
87
+ catch (error) {
88
+ dieWithError(error);
89
+ }
90
+ }
@@ -0,0 +1,93 @@
1
+ import { readFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { config } from "../../config.js";
5
+ import { buildIma2Capabilities } from "../../lib/capabilities.js";
6
+ import { parseArgs } from "../lib/args.js";
7
+ import { resolveServer, request } from "../lib/client.js";
8
+ import { color, dieWithError, json, out } from "../lib/output.js";
9
+
10
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
11
+ const PACKAGE_PATH = join(ROOT, "package.json");
12
+
13
+ const HELP = `
14
+ ima2 capabilities [--json] [--server <url>] [--require-server]
15
+
16
+ Print agent-friendly capability metadata.
17
+
18
+ Options:
19
+ --json Print JSON
20
+ --server <url> Query a specific running server
21
+ --require-server Fail instead of falling back to local package metadata
22
+ `;
23
+
24
+ const FLAGS = {
25
+ json: { type: "boolean" },
26
+ server: { type: "string" },
27
+ "require-server": { type: "boolean" },
28
+ help: { short: "h", type: "boolean" },
29
+ };
30
+
31
+ function packageVersion(): string {
32
+ try {
33
+ const pkg = JSON.parse(readFileSync(PACKAGE_PATH, "utf-8")) as { version?: string };
34
+ return pkg.version || "?";
35
+ } catch {
36
+ return "?";
37
+ }
38
+ }
39
+
40
+ function localCapabilities() {
41
+ return buildIma2Capabilities({
42
+ appConfig: config,
43
+ packageVersion: packageVersion(),
44
+ source: "local",
45
+ server: null,
46
+ });
47
+ }
48
+
49
+ async function readCapabilities(args: ReturnType<typeof parseArgs>) {
50
+ try {
51
+ const server = await resolveServer({ serverFlag: args.server });
52
+ return await request(server.base, "/api/capabilities", { timeoutMs: 5000 });
53
+ } catch (error) {
54
+ if (args.server || args["require-server"]) throw error;
55
+ return localCapabilities();
56
+ }
57
+ }
58
+
59
+ function printText(capabilities: any): void {
60
+ out(`ima2 capabilities (${capabilities.source})`);
61
+ out(`version: ${capabilities.version}`);
62
+ out(`server: ${capabilities.server || "none"}`);
63
+ out("");
64
+ out("defaults:");
65
+ out(` oauth model: ${capabilities.defaults?.oauth?.model}`);
66
+ out(` oauth reasoning: ${capabilities.defaults?.oauth?.reasoningEffort}`);
67
+ out(` api model: ${capabilities.defaults?.api?.model}`);
68
+ out(` api reasoning: ${capabilities.defaults?.api?.reasoningEffort}`);
69
+ out("");
70
+ out("valid:");
71
+ out(` models: ${capabilities.valid?.imageModels?.supported?.join(", ")}`);
72
+ out(` reasoning: ${capabilities.valid?.reasoningEfforts?.join(", ")}`);
73
+ out(` quality: ${capabilities.valid?.quality?.join(", ")}`);
74
+ out("");
75
+ out(`limits: refs=${capabilities.limits?.maxRefCount}, images=${capabilities.limits?.maxGeneratedImages}`);
76
+ out(color.dim(`maxParallel: ${capabilities.limits?.maxParallel?.value} (${capabilities.limits?.maxParallel?.note})`));
77
+ }
78
+
79
+ export default async function capabilitiesCmd(argv: string[]) {
80
+ const args = parseArgs(argv, { flags: FLAGS });
81
+ if (args.help) {
82
+ out(HELP);
83
+ return;
84
+ }
85
+
86
+ try {
87
+ const capabilities = await readCapabilities(args);
88
+ if (args.json) json(capabilities);
89
+ else printText(capabilities);
90
+ } catch (error) {
91
+ dieWithError(error);
92
+ }
93
+ }
@@ -1,10 +1,7 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
1
  import { createInterface } from "readline/promises";
3
2
  import { parseArgs } from "../lib/args.js";
4
3
  import { out, die, color, json } from "../lib/output.js";
5
- import { config as runtimeConfig } from "../../config.js";
6
- const CONFIG_FILE = runtimeConfig.storage.configFile;
7
- const CONFIG_DIR = runtimeConfig.storage.configDir;
4
+ import { CONFIG_FILE, buildEffectiveConfig, deleteNestedKey, displayPath, envOverrideForKey, getNestedKey, isAuthConfigKey, isSensitiveConfigKey, isWritableConfigKey, loadFileCfg, parseConfigValue, redactValue, restartNotice, saveFileCfg, setNestedKey, } from "../lib/config-store.js";
8
5
  const HELP = `
9
6
  ima2 config <subcommand> [options]
10
7
 
@@ -28,130 +25,6 @@ const FLAGS = {
28
25
  yes: { short: "y", type: "boolean" },
29
26
  help: { short: "h", type: "boolean" },
30
27
  };
31
- // Keys config set is allowed to write
32
- const KNOWN_KEYS = new Set([
33
- "imageModels.default",
34
- "imageModels.reasoningEffort",
35
- "log.level",
36
- "features.cardNews",
37
- "cardNewsPlanner.enabled",
38
- "cardNewsPlanner.model",
39
- "cardNewsPlanner.timeoutMs",
40
- "cardNewsPlanner.deterministicFallback",
41
- "comfy.defaultUrl",
42
- "comfy.uploadTimeoutMs",
43
- "comfy.maxUploadBytes",
44
- "storage.generatedDir",
45
- "storage.generatedDirName",
46
- "server.port",
47
- "server.host",
48
- "server.bodyLimit",
49
- "oauth.proxyPort",
50
- "oauth.statusTimeoutMs",
51
- "oauth.restartDelayMs",
52
- "limits.maxRefCount",
53
- "limits.maxParallel",
54
- "history.defaultPageSize",
55
- "history.maxPageCap",
56
- ]);
57
- // Auth keys live in the same file but must go through setup/login
58
- const AUTH_KEYS = new Set(["provider", "apiKey"]);
59
- const REDACT_PATTERN = /token|secret|apikey|password/i;
60
- const ALWAYS_REDACT = new Set(["provider", "apiKey", "oauth.token", "oauth.refreshToken"]);
61
- // Env var that overrides each writable key
62
- const KEY_TO_ENV = {
63
- "imageModels.default": "IMA2_IMAGE_MODEL_DEFAULT",
64
- "imageModels.reasoningEffort": "IMA2_REASONING_EFFORT",
65
- "log.level": "IMA2_LOG_LEVEL",
66
- "features.cardNews": "IMA2_CARD_NEWS",
67
- "server.port": "IMA2_PORT",
68
- "server.host": "IMA2_HOST",
69
- "server.bodyLimit": "IMA2_BODY_LIMIT",
70
- "oauth.proxyPort": "IMA2_OAUTH_PROXY_PORT",
71
- "storage.generatedDir": "IMA2_GENERATED_DIR",
72
- "cardNewsPlanner.enabled": "IMA2_CARD_NEWS_PLANNER",
73
- "cardNewsPlanner.model": "IMA2_CARD_NEWS_PLANNER_MODEL",
74
- "cardNewsPlanner.timeoutMs": "IMA2_CARD_NEWS_PLANNER_TIMEOUT_MS",
75
- "limits.maxParallel": "IMA2_MAX_PARALLEL",
76
- "limits.maxRefCount": "IMA2_MAX_REF_COUNT",
77
- "history.defaultPageSize": "IMA2_HISTORY_PAGE_SIZE",
78
- };
79
- function redactValue(key, value) {
80
- if (ALWAYS_REDACT.has(key) || REDACT_PATTERN.test(key)) {
81
- return value ? "<redacted>" : value;
82
- }
83
- return value;
84
- }
85
- function loadFileCfg() {
86
- if (!existsSync(CONFIG_FILE))
87
- return {};
88
- try {
89
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
90
- }
91
- catch {
92
- return {};
93
- }
94
- }
95
- function saveFileCfg(cfg) {
96
- mkdirSync(CONFIG_DIR, { recursive: true });
97
- writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
98
- }
99
- function getNestedKey(obj, dotKey) {
100
- const parts = dotKey.split(".");
101
- let cur = obj;
102
- for (const p of parts) {
103
- if (cur == null || typeof cur !== "object")
104
- return undefined;
105
- cur = cur[p];
106
- }
107
- return cur;
108
- }
109
- function setNestedKey(obj, dotKey, value) {
110
- const parts = dotKey.split(".");
111
- let cur = obj;
112
- for (let i = 0; i < parts.length - 1; i++) {
113
- if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object")
114
- cur[parts[i]] = {};
115
- cur = cur[parts[i]];
116
- }
117
- cur[parts[parts.length - 1]] = value;
118
- }
119
- function deleteNestedKey(obj, dotKey) {
120
- const parts = dotKey.split(".");
121
- let cur = obj;
122
- for (let i = 0; i < parts.length - 1; i++) {
123
- if (cur == null || typeof cur !== "object")
124
- return false;
125
- cur = cur[parts[i]];
126
- }
127
- if (cur == null || typeof cur !== "object")
128
- return false;
129
- const last = parts[parts.length - 1];
130
- if (!(last in cur))
131
- return false;
132
- delete cur[last];
133
- return true;
134
- }
135
- function stripSets(v) {
136
- if (v instanceof Set)
137
- return [...v];
138
- if (Array.isArray(v))
139
- return v.map(stripSets);
140
- if (v && typeof v === "object") {
141
- const r = {};
142
- for (const [k, val] of Object.entries(v))
143
- r[k] = stripSets(val);
144
- return r;
145
- }
146
- return v;
147
- }
148
- function buildEffectiveConfig() {
149
- return stripSets(runtimeConfig);
150
- }
151
- function displayPath(p) {
152
- const home = process.env.HOME || "";
153
- return home && p.startsWith(home) ? p.replace(home, "~") : p;
154
- }
155
28
  async function pathSub(_argv) {
156
29
  out(CONFIG_FILE);
157
30
  }
@@ -198,26 +71,21 @@ async function setSub(argv) {
198
71
  const [key, rawValue] = args.positional;
199
72
  if (!key || rawValue === undefined)
200
73
  die(2, "usage: config set <key> <value>");
201
- if (AUTH_KEYS.has(key)) {
74
+ if (isAuthConfigKey(key)) {
202
75
  die(2, `"${key}" is an auth key. Use 'ima2 setup' or 'ima2 login' to change authentication.`);
203
76
  }
204
- if (!KNOWN_KEYS.has(key)) {
77
+ if (!isWritableConfigKey(key)) {
205
78
  die(2, `unknown config key: "${key}". Run 'ima2 config ls --effective' to see the config structure.`);
206
79
  }
207
- // Parse value: try JSON, fall back to raw string
208
- let value = rawValue;
209
- try {
210
- value = JSON.parse(rawValue);
211
- }
212
- catch { }
80
+ const value = parseConfigValue(rawValue);
213
81
  // Warn if env var is overriding this key
214
- const envVar = KEY_TO_ENV[key];
215
- if (envVar && process.env[envVar] !== undefined) {
216
- out(color.yellow(`warning: env ${envVar}=${process.env[envVar]} is currently overriding this value.`));
82
+ const override = envOverrideForKey(key);
83
+ if (override) {
84
+ out(color.yellow(`warning: env ${override.envVar}=${override.value} is currently overriding this value.`));
217
85
  out(`The file change will only apply after unsetting the env var and restarting the server.`);
218
86
  }
219
87
  // Confirm if writing a sensitive key
220
- if ((ALWAYS_REDACT.has(key) || REDACT_PATTERN.test(key)) && !args.yes) {
88
+ if (isSensitiveConfigKey(key) && !args.yes) {
221
89
  const rl = createInterface({ input: process.stdin, output: process.stdout });
222
90
  const ans = await rl.question(`warning: "${key}" is a sensitive credential. Write to config file? [y/N] `);
223
91
  rl.close();
@@ -230,14 +98,14 @@ async function setSub(argv) {
230
98
  setNestedKey(fileCfg, key, value);
231
99
  saveFileCfg(fileCfg);
232
100
  out(color.green("✓ ") + `wrote ${key}=${JSON.stringify(value)} to ${displayPath(CONFIG_FILE)}`);
233
- out(color.dim("note: server must be restarted to pick up config changes (run `ima2 serve`)"));
101
+ out(color.dim(restartNotice()));
234
102
  }
235
103
  async function rmSub(argv) {
236
104
  const args = parseArgs(argv, { flags: FLAGS });
237
105
  const key = args.positional[0];
238
106
  if (!key)
239
107
  die(2, "key required. Usage: config rm <key>");
240
- if (AUTH_KEYS.has(key)) {
108
+ if (isAuthConfigKey(key)) {
241
109
  die(2, `"${key}" is an auth key. Use 'ima2 setup' or 'ima2 login' to change authentication.`);
242
110
  }
243
111
  const fileCfg = loadFileCfg();
@@ -248,7 +116,7 @@ async function rmSub(argv) {
248
116
  }
249
117
  saveFileCfg(fileCfg);
250
118
  out(color.green("✓ ") + `removed ${key} from ${displayPath(CONFIG_FILE)}`);
251
- out(color.dim("note: server must be restarted to pick up config changes (run `ima2 serve`)"));
119
+ out(color.dim(restartNotice()));
252
120
  }
253
121
  const SUB = {
254
122
  path: pathSub,
@@ -1,11 +1,23 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
1
  import { createInterface } from "readline/promises";
3
2
  import { parseArgs } from "../lib/args.js";
4
3
  import { out, die, color, json } from "../lib/output.js";
5
- import { config as runtimeConfig } from "../../config.js";
6
-
7
- const CONFIG_FILE = runtimeConfig.storage.configFile;
8
- const CONFIG_DIR = runtimeConfig.storage.configDir;
4
+ import {
5
+ CONFIG_FILE,
6
+ buildEffectiveConfig,
7
+ deleteNestedKey,
8
+ displayPath,
9
+ envOverrideForKey,
10
+ getNestedKey,
11
+ isAuthConfigKey,
12
+ isSensitiveConfigKey,
13
+ isWritableConfigKey,
14
+ loadFileCfg,
15
+ parseConfigValue,
16
+ redactValue,
17
+ restartNotice,
18
+ saveFileCfg,
19
+ setNestedKey,
20
+ } from "../lib/config-store.js";
9
21
 
10
22
  const HELP = `
11
23
  ima2 config <subcommand> [options]
@@ -32,130 +44,6 @@ const FLAGS = {
32
44
  help: { short: "h", type: "boolean" },
33
45
  };
34
46
 
35
- // Keys config set is allowed to write
36
- const KNOWN_KEYS = new Set([
37
- "imageModels.default",
38
- "imageModels.reasoningEffort",
39
- "log.level",
40
- "features.cardNews",
41
- "cardNewsPlanner.enabled",
42
- "cardNewsPlanner.model",
43
- "cardNewsPlanner.timeoutMs",
44
- "cardNewsPlanner.deterministicFallback",
45
- "comfy.defaultUrl",
46
- "comfy.uploadTimeoutMs",
47
- "comfy.maxUploadBytes",
48
- "storage.generatedDir",
49
- "storage.generatedDirName",
50
- "server.port",
51
- "server.host",
52
- "server.bodyLimit",
53
- "oauth.proxyPort",
54
- "oauth.statusTimeoutMs",
55
- "oauth.restartDelayMs",
56
- "limits.maxRefCount",
57
- "limits.maxParallel",
58
- "history.defaultPageSize",
59
- "history.maxPageCap",
60
- ]);
61
-
62
- // Auth keys live in the same file but must go through setup/login
63
- const AUTH_KEYS = new Set(["provider", "apiKey"]);
64
-
65
- const REDACT_PATTERN = /token|secret|apikey|password/i;
66
- const ALWAYS_REDACT = new Set(["provider", "apiKey", "oauth.token", "oauth.refreshToken"]);
67
-
68
- // Env var that overrides each writable key
69
- const KEY_TO_ENV: Record<string, string> = {
70
- "imageModels.default": "IMA2_IMAGE_MODEL_DEFAULT",
71
- "imageModels.reasoningEffort": "IMA2_REASONING_EFFORT",
72
- "log.level": "IMA2_LOG_LEVEL",
73
- "features.cardNews": "IMA2_CARD_NEWS",
74
- "server.port": "IMA2_PORT",
75
- "server.host": "IMA2_HOST",
76
- "server.bodyLimit": "IMA2_BODY_LIMIT",
77
- "oauth.proxyPort": "IMA2_OAUTH_PROXY_PORT",
78
- "storage.generatedDir": "IMA2_GENERATED_DIR",
79
- "cardNewsPlanner.enabled": "IMA2_CARD_NEWS_PLANNER",
80
- "cardNewsPlanner.model": "IMA2_CARD_NEWS_PLANNER_MODEL",
81
- "cardNewsPlanner.timeoutMs": "IMA2_CARD_NEWS_PLANNER_TIMEOUT_MS",
82
- "limits.maxParallel": "IMA2_MAX_PARALLEL",
83
- "limits.maxRefCount": "IMA2_MAX_REF_COUNT",
84
- "history.defaultPageSize": "IMA2_HISTORY_PAGE_SIZE",
85
- };
86
-
87
- function redactValue(key: string, value: any): any {
88
- if (ALWAYS_REDACT.has(key) || REDACT_PATTERN.test(key)) {
89
- return value ? "<redacted>" : value;
90
- }
91
- return value;
92
- }
93
-
94
- function loadFileCfg(): Record<string, any> {
95
- if (!existsSync(CONFIG_FILE)) return {};
96
- try { return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); }
97
- catch { return {}; }
98
- }
99
-
100
- function saveFileCfg(cfg: Record<string, any>) {
101
- mkdirSync(CONFIG_DIR, { recursive: true });
102
- writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
103
- }
104
-
105
- function getNestedKey(obj: any, dotKey: string): any {
106
- const parts = dotKey.split(".");
107
- let cur = obj;
108
- for (const p of parts) {
109
- if (cur == null || typeof cur !== "object") return undefined;
110
- cur = cur[p];
111
- }
112
- return cur;
113
- }
114
-
115
- function setNestedKey(obj: any, dotKey: string, value: any): void {
116
- const parts = dotKey.split(".");
117
- let cur = obj;
118
- for (let i = 0; i < parts.length - 1; i++) {
119
- if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") cur[parts[i]] = {};
120
- cur = cur[parts[i]];
121
- }
122
- cur[parts[parts.length - 1]] = value;
123
- }
124
-
125
- function deleteNestedKey(obj: any, dotKey: string): boolean {
126
- const parts = dotKey.split(".");
127
- let cur = obj;
128
- for (let i = 0; i < parts.length - 1; i++) {
129
- if (cur == null || typeof cur !== "object") return false;
130
- cur = cur[parts[i]];
131
- }
132
- if (cur == null || typeof cur !== "object") return false;
133
- const last = parts[parts.length - 1];
134
- if (!(last in cur)) return false;
135
- delete cur[last];
136
- return true;
137
- }
138
-
139
- function stripSets(v: any): any {
140
- if (v instanceof Set) return [...v];
141
- if (Array.isArray(v)) return v.map(stripSets);
142
- if (v && typeof v === "object") {
143
- const r: any = {};
144
- for (const [k, val] of Object.entries(v)) r[k] = stripSets(val);
145
- return r;
146
- }
147
- return v;
148
- }
149
-
150
- function buildEffectiveConfig(): Record<string, any> {
151
- return stripSets(runtimeConfig);
152
- }
153
-
154
- function displayPath(p: string): string {
155
- const home = process.env.HOME || "";
156
- return home && p.startsWith(home) ? p.replace(home, "~") : p;
157
- }
158
-
159
47
  async function pathSub(_argv: string[]) {
160
48
  out(CONFIG_FILE);
161
49
  }
@@ -193,26 +81,24 @@ async function setSub(argv: string[]) {
193
81
  const [key, rawValue] = args.positional;
194
82
  if (!key || rawValue === undefined) die(2, "usage: config set <key> <value>");
195
83
 
196
- if (AUTH_KEYS.has(key)) {
84
+ if (isAuthConfigKey(key)) {
197
85
  die(2, `"${key}" is an auth key. Use 'ima2 setup' or 'ima2 login' to change authentication.`);
198
86
  }
199
- if (!KNOWN_KEYS.has(key)) {
87
+ if (!isWritableConfigKey(key)) {
200
88
  die(2, `unknown config key: "${key}". Run 'ima2 config ls --effective' to see the config structure.`);
201
89
  }
202
90
 
203
- // Parse value: try JSON, fall back to raw string
204
- let value: any = rawValue;
205
- try { value = JSON.parse(rawValue); } catch {}
91
+ const value = parseConfigValue(rawValue);
206
92
 
207
93
  // Warn if env var is overriding this key
208
- const envVar = KEY_TO_ENV[key];
209
- if (envVar && process.env[envVar] !== undefined) {
210
- out(color.yellow(`warning: env ${envVar}=${process.env[envVar]} is currently overriding this value.`));
94
+ const override = envOverrideForKey(key);
95
+ if (override) {
96
+ out(color.yellow(`warning: env ${override.envVar}=${override.value} is currently overriding this value.`));
211
97
  out(`The file change will only apply after unsetting the env var and restarting the server.`);
212
98
  }
213
99
 
214
100
  // Confirm if writing a sensitive key
215
- if ((ALWAYS_REDACT.has(key) || REDACT_PATTERN.test(key)) && !args.yes) {
101
+ if (isSensitiveConfigKey(key) && !args.yes) {
216
102
  const rl = createInterface({ input: process.stdin, output: process.stdout });
217
103
  const ans = await rl.question(`warning: "${key}" is a sensitive credential. Write to config file? [y/N] `);
218
104
  rl.close();
@@ -224,7 +110,7 @@ async function setSub(argv: string[]) {
224
110
  saveFileCfg(fileCfg);
225
111
 
226
112
  out(color.green("✓ ") + `wrote ${key}=${JSON.stringify(value)} to ${displayPath(CONFIG_FILE)}`);
227
- out(color.dim("note: server must be restarted to pick up config changes (run `ima2 serve`)"));
113
+ out(color.dim(restartNotice()));
228
114
  }
229
115
 
230
116
  async function rmSub(argv: string[]) {
@@ -232,7 +118,7 @@ async function rmSub(argv: string[]) {
232
118
  const key = args.positional[0];
233
119
  if (!key) die(2, "key required. Usage: config rm <key>");
234
120
 
235
- if (AUTH_KEYS.has(key)) {
121
+ if (isAuthConfigKey(key)) {
236
122
  die(2, `"${key}" is an auth key. Use 'ima2 setup' or 'ima2 login' to change authentication.`);
237
123
  }
238
124
 
@@ -244,7 +130,7 @@ async function rmSub(argv: string[]) {
244
130
  }
245
131
  saveFileCfg(fileCfg);
246
132
  out(color.green("✓ ") + `removed ${key} from ${displayPath(CONFIG_FILE)}`);
247
- out(color.dim("note: server must be restarted to pick up config changes (run `ima2 serve`)"));
133
+ out(color.dim(restartNotice()));
248
134
  }
249
135
 
250
136
  type Sub = (argv: any[]) => Promise<void>;