ima2-gen 1.0.10 → 1.1.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/README.md +98 -201
- package/assets/screenshot.png +0 -0
- package/assets/screenshots/classic-generate-light.png +0 -0
- package/assets/screenshots/node-graph-branching.png +0 -0
- package/assets/screenshots/settings-oauth-generation.png +0 -0
- package/assets/screenshots/settings-workspace.png +0 -0
- package/assets/screenshots/style-sheet-editor.png +0 -0
- package/bin/ima2.js +12 -4
- package/bin/lib/storage-doctor.js +38 -0
- package/config.js +5 -0
- package/docs/API.md +189 -0
- package/docs/README.ja.md +182 -0
- package/docs/README.ko.md +182 -0
- package/docs/README.zh-CN.md +182 -0
- package/docs/RECOVER_OLD_IMAGES.md +91 -0
- package/lib/historyList.js +1 -0
- package/lib/imageModels.js +32 -0
- package/lib/oauthProxy.js +8 -4
- package/lib/openDirectory.js +35 -0
- package/lib/storageMigration.js +174 -9
- package/package.json +3 -2
- package/routes/edit.js +15 -0
- package/routes/generate.js +15 -0
- package/routes/index.js +2 -1
- package/routes/nodes.js +18 -1
- package/routes/sessions.js +18 -1
- package/routes/storage.js +39 -0
- package/ui/dist/assets/index-CqpVoXpZ.css +1 -0
- package/ui/dist/assets/index-IHSd1z1a.js +22 -0
- package/ui/dist/assets/index-IHSd1z1a.js.map +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CBrmEeD7.css +0 -1
- package/ui/dist/assets/index-DRST1V_0.js +0 -22
- package/ui/dist/assets/index-DRST1V_0.js.map +0 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# ima2-gen
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/ima2-gen)
|
|
4
|
+
[](https://nodejs.org/)
|
|
5
|
+
[](../LICENSE)
|
|
6
|
+
|
|
7
|
+
> **其他语言**: [English](../README.md) · [한국어](README.ko.md) · [日本語](README.ja.md)
|
|
8
|
+
|
|
9
|
+
`ima2-gen` 是一个本地图像生成工作室,让你像使用小型桌面应用一样使用 ChatGPT/Codex OAuth 图像生成流程。
|
|
10
|
+
|
|
11
|
+
用 `npx` 启动,登录 Codex OAuth,输入 prompt,然后通过历史记录、参考图、style sheet 和节点分支持续迭代。默认图像生成路径不需要 OpenAI API key。
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
## 快速开始
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx ima2-gen serve
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
然后打开 `http://localhost:3333`。
|
|
22
|
+
|
|
23
|
+
如果还没有登录 Codex:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @openai/codex login
|
|
27
|
+
npx ima2-gen serve
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
也可以全局安装:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g ima2-gen
|
|
34
|
+
ima2 serve
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 能做什么
|
|
38
|
+
|
|
39
|
+
- **Classic mode**:快速生成、编辑,并把当前图片继续作为参考图使用。
|
|
40
|
+
- **Node mode**:从一张满意的图出发,向多个方向分支探索。
|
|
41
|
+
- **Local gallery**:把生成结果保存在本机,并按 session 查看历史。
|
|
42
|
+
- **Reference images**:支持拖放、粘贴和文件选择;大图会在上传前自动压缩。
|
|
43
|
+
- **Style sheets**:保存一套视觉方向,并复用到 classic/node prompt。
|
|
44
|
+
- **Observable jobs**:用 request ID 追踪进行中和最近完成的任务。
|
|
45
|
+
|
|
46
|
+
## 图像生成只走 OAuth
|
|
47
|
+
|
|
48
|
+
当前图像生成通过本地 Codex/ChatGPT OAuth 路径执行。
|
|
49
|
+
|
|
50
|
+
即使 env/config 里有 API key,它也只可能用于 billing 检查或 style-sheet 提取等辅助功能。生成接口收到 `provider: "api"` 时会返回 `APIKEY_DISABLED`。
|
|
51
|
+
|
|
52
|
+
如果设置页显示 **Configured but disabled**,意思是检测到了 API key,但图像生成仍然使用 OAuth。
|
|
53
|
+
|
|
54
|
+

|
|
55
|
+
|
|
56
|
+
## 模型建议
|
|
57
|
+
|
|
58
|
+
如果想要稳定、均衡的结果,建议先从 **`gpt-5.4`** 开始。
|
|
59
|
+
|
|
60
|
+
- `gpt-5.4` — 推荐的均衡选择。
|
|
61
|
+
- `gpt-5.4-mini` — 当前应用默认值,适合快速草稿。
|
|
62
|
+
- `gpt-5.5` — 在支持的环境中是质量最强的选择。但它可能消耗更多额度,也可能需要更新 Codex CLI,或依赖账号/后端路径是否开放对应的图像 capability。
|
|
63
|
+
|
|
64
|
+
Quality 支持 `low`, `medium`, `high`;moderation 支持 `auto`, `low`。
|
|
65
|
+
|
|
66
|
+
## 工作流
|
|
67
|
+
|
|
68
|
+
### Classic mode
|
|
69
|
+
|
|
70
|
+
适合快速做出一张图并继续调整。
|
|
71
|
+
|
|
72
|
+
1. 写 prompt。
|
|
73
|
+
2. 需要时添加参考图。
|
|
74
|
+
3. 选择模型、quality、size、format、moderation。
|
|
75
|
+
4. 生成后复制、下载,或继续从当前结果迭代。
|
|
76
|
+
|
|
77
|
+
### Node mode
|
|
78
|
+
|
|
79
|
+
适合把想法分叉后对比。
|
|
80
|
+
|
|
81
|
+

|
|
82
|
+
|
|
83
|
+
每个节点都有自己的 prompt 和结果。根节点可以附加本地参考图;子节点会使用父图作为来源。完成的任务会通过 request ID 重新匹配,因此刷新或 graph version conflict 后也能恢复结果。
|
|
84
|
+
|
|
85
|
+
### Settings 和 Style sheets
|
|
86
|
+
|
|
87
|
+
Settings workspace 会把账号、模型、主题和语言设置从生成面板中分离出来。
|
|
88
|
+
|
|
89
|
+

|
|
90
|
+
|
|
91
|
+
Style sheet 用来保存可复用的视觉方向。
|
|
92
|
+
|
|
93
|
+

|
|
94
|
+
|
|
95
|
+
## CLI 命令
|
|
96
|
+
|
|
97
|
+
### Server
|
|
98
|
+
|
|
99
|
+
| Command | Description |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `ima2 serve` | 启动本地 Web server |
|
|
102
|
+
| `ima2 setup` | 重新配置认证 |
|
|
103
|
+
| `ima2 status` | 查看 config 和 OAuth 状态 |
|
|
104
|
+
| `ima2 doctor` | 诊断 Node、package、config、auth |
|
|
105
|
+
| `ima2 open` | 打开 Web UI |
|
|
106
|
+
| `ima2 reset` | 删除已保存的 config |
|
|
107
|
+
|
|
108
|
+
### Client
|
|
109
|
+
|
|
110
|
+
这些命令需要先运行 `ima2 serve`。
|
|
111
|
+
|
|
112
|
+
| Command | Description |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `ima2 gen <prompt>` | 从 CLI 生成图片 |
|
|
115
|
+
| `ima2 edit <file> --prompt <text>` | 编辑已有图片 |
|
|
116
|
+
| `ima2 ls` | 查看本地历史 |
|
|
117
|
+
| `ima2 show <name>` | 打开生成文件 |
|
|
118
|
+
| `ima2 ps` | 查看进行中的任务 |
|
|
119
|
+
| `ima2 ping` | 检查 server 是否可用 |
|
|
120
|
+
|
|
121
|
+
Server 端口会写入 `~/.ima2/server.json`。也可以用 `--server <url>` 或 `IMA2_SERVER=http://localhost:3333` 覆盖。
|
|
122
|
+
|
|
123
|
+
## 配置
|
|
124
|
+
|
|
125
|
+
优先级:
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
environment variables > ~/.ima2/config.json > built-in defaults
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
| Variable | Default | Description |
|
|
132
|
+
|---|---:|---|
|
|
133
|
+
| `IMA2_PORT` / `PORT` | `3333` | Web server port |
|
|
134
|
+
| `IMA2_OAUTH_PROXY_PORT` / `OAUTH_PORT` | `10531` | OAuth proxy port |
|
|
135
|
+
| `IMA2_SERVER` | — | CLI target override |
|
|
136
|
+
| `IMA2_CONFIG_DIR` | `~/.ima2` | Config 和 SQLite 位置 |
|
|
137
|
+
| `IMA2_GENERATED_DIR` | `~/.ima2/generated` | 生成图片目录 |
|
|
138
|
+
| `IMA2_NO_OAUTH_PROXY` | — | 设为 `1` 时关闭 OAuth proxy 自动启动 |
|
|
139
|
+
| `IMA2_INFLIGHT_TERMINAL_TTL_MS` | `30000` | 调试用 recent job 保留时间 |
|
|
140
|
+
| `OPENAI_API_KEY` | — | 辅助功能用 API key,不用于图像生成 |
|
|
141
|
+
|
|
142
|
+
## API 文档
|
|
143
|
+
|
|
144
|
+
接口列表已移到 [API Reference](API.md)。
|
|
145
|
+
|
|
146
|
+
## 常见问题
|
|
147
|
+
|
|
148
|
+
**`ima2 ping` 提示 server unreachable**
|
|
149
|
+
先启动 `ima2 serve`,再检查 `~/.ima2/server.json`。也可以运行 `ima2 ping --server http://localhost:3333`。
|
|
150
|
+
|
|
151
|
+
**OAuth 登录失败**
|
|
152
|
+
运行 `npx @openai/codex login`,用 `ima2 status` 确认状态,然后重启 `ima2 serve`。
|
|
153
|
+
|
|
154
|
+
**生成图片时返回 `APIKEY_DISABLED`**
|
|
155
|
+
当前 build 需要使用 OAuth 生成。API-key image generation 是有意关闭的。
|
|
156
|
+
|
|
157
|
+
**大参考图上传失败**
|
|
158
|
+
JPEG/PNG 会在上传前自动压缩。如果仍然失败,请转成更低分辨率的 JPEG/PNG。HEIC/HEIF 不支持浏览器路径。
|
|
159
|
+
|
|
160
|
+
**更新后看不到旧图库图片**
|
|
161
|
+
新版本把生成图片目录从已安装的 package 文件夹移到了 `~/.ima2/generated`。请运行 `ima2 doctor`,并查看[旧图片恢复指南](RECOVER_OLD_IMAGES.md)。
|
|
162
|
+
|
|
163
|
+
**只有 `gpt-5.5` 失败**
|
|
164
|
+
请先更新 Codex CLI 后再试。如果仍然失败,说明当前账号或后端路径下 `gpt-5.5` 的图像 capability 或额度策略可能还不同;稳定替代方案是使用 `gpt-5.4`。
|
|
165
|
+
|
|
166
|
+
**端口突然变成 `3457`**
|
|
167
|
+
shell 可能继承了其他本地工具的 `PORT=3457`。运行 `unset PORT`,或使用 `IMA2_PORT=3333 ima2 serve`。
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
git clone https://github.com/lidge-jun/ima2-gen.git
|
|
173
|
+
cd ima2-gen
|
|
174
|
+
npm install
|
|
175
|
+
npm run dev
|
|
176
|
+
npm test
|
|
177
|
+
npm run build
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Recover Old Generated Images
|
|
2
|
+
|
|
3
|
+
`ima2-gen` moved generated images to a safer user-data folder in `v1.0.8`.
|
|
4
|
+
|
|
5
|
+
## What changed
|
|
6
|
+
|
|
7
|
+
Versions up to `v1.0.7` stored generated images inside the installed package:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
<global node_modules>/ima2-gen/generated
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Starting with `v1.0.8`, generated images are stored under your user data folder:
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
macOS / Linux: ~/.ima2/generated
|
|
17
|
+
Windows: %USERPROFILE%\.ima2\generated
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This prevents future package updates from mixing application code and runtime user files.
|
|
21
|
+
|
|
22
|
+
## Why images may look missing
|
|
23
|
+
|
|
24
|
+
Sorry for the scare. Older global installs may have kept images inside the package folder. If that old global install folder was replaced during an update, the previous `generated/` folder may no longer be on disk.
|
|
25
|
+
|
|
26
|
+
`ima2-gen` can copy old images only when the old folder still exists. If no old folder can be found, recovery may require a backup.
|
|
27
|
+
|
|
28
|
+
## First check
|
|
29
|
+
|
|
30
|
+
Run:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
ima2 doctor
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The Storage section shows the current gallery folder and whether legacy folders were found.
|
|
37
|
+
|
|
38
|
+
## macOS / Linux: find old folders
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
find ~/.ima2 ~/.npm-global ~/.nvm ~/.fnm ~/.volta ~/.bun ~/.config/yarn ~/Library/pnpm ~/.local/share/pnpm ~/.asdf ~/.local/share/mise /usr/local /opt/homebrew \
|
|
42
|
+
-path '*ima2-gen/generated' -type d 2>/dev/null
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If you used `npx` or `npm exec`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
find "$(npm config get cache)/_npx" \
|
|
49
|
+
-path '*/node_modules/ima2-gen/generated' -type d 2>/dev/null
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Windows PowerShell: find old folders
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
$roots = @($env:USERPROFILE, $env:APPDATA, $env:LOCALAPPDATA, $env:NVM_HOME, "C:\Program Files\nodejs")
|
|
56
|
+
foreach ($r in $roots) {
|
|
57
|
+
if (Test-Path $r) {
|
|
58
|
+
Get-ChildItem -Path $r -Recurse -Directory -ErrorAction SilentlyContinue |
|
|
59
|
+
Where-Object { $_.FullName -match 'ima2-gen[\\/]+generated$' } |
|
|
60
|
+
Select-Object -ExpandProperty FullName
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If you used `npx` or `npm exec`:
|
|
66
|
+
|
|
67
|
+
```powershell
|
|
68
|
+
Get-ChildItem "$env:LOCALAPPDATA\npm-cache\_npx" -Recurse -Directory -Filter generated -ErrorAction SilentlyContinue |
|
|
69
|
+
Where-Object { $_.FullName -match 'node_modules\\ima2-gen\\generated$' } |
|
|
70
|
+
Select-Object -ExpandProperty FullName
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Copy found files into the new location
|
|
74
|
+
|
|
75
|
+
macOS / Linux:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
mkdir -p ~/.ima2/generated
|
|
79
|
+
cp -n "/path/to/old/ima2-gen/generated/"* ~/.ima2/generated/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Windows PowerShell:
|
|
83
|
+
|
|
84
|
+
```powershell
|
|
85
|
+
New-Item -ItemType Directory -Force "$env:USERPROFILE\.ima2\generated"
|
|
86
|
+
Copy-Item "C:\path\to\old\ima2-gen\generated\*" "$env:USERPROFILE\.ima2\generated" -Recurse -Force:$false
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Important
|
|
90
|
+
|
|
91
|
+
Do not delete old global install folders or npm caches until you confirm your images are visible in the app again.
|
package/lib/historyList.js
CHANGED
|
@@ -45,6 +45,7 @@ export async function listHistoryRows(baseDir = config.storage.generatedDir) {
|
|
|
45
45
|
quality: meta?.quality || null,
|
|
46
46
|
size: meta?.size || null,
|
|
47
47
|
format: meta?.format || name.split(".").pop(),
|
|
48
|
+
model: meta?.model || null,
|
|
48
49
|
provider: meta?.provider || "oauth",
|
|
49
50
|
usage: meta?.usage || null,
|
|
50
51
|
webSearchCalls: meta?.webSearchCalls || 0,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const FALLBACK_IMAGE_MODEL = "gpt-5.4-mini";
|
|
2
|
+
const VALID_IMAGE_MODELS = new Set(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]);
|
|
3
|
+
const UNSUPPORTED_IMAGE_MODELS = new Set(["gpt-5.3-codex-spark"]);
|
|
4
|
+
|
|
5
|
+
export function normalizeImageModel(ctx, rawModel) {
|
|
6
|
+
const configured = ctx?.config?.imageModels;
|
|
7
|
+
const fallback = configured?.default ?? FALLBACK_IMAGE_MODEL;
|
|
8
|
+
const valid = configured?.valid ?? VALID_IMAGE_MODELS;
|
|
9
|
+
const unsupported = configured?.unsupported ?? UNSUPPORTED_IMAGE_MODELS;
|
|
10
|
+
|
|
11
|
+
if (typeof rawModel !== "string" || rawModel.length === 0) {
|
|
12
|
+
return { model: valid.has(fallback) ? fallback : FALLBACK_IMAGE_MODEL };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (unsupported.has(rawModel)) {
|
|
16
|
+
return {
|
|
17
|
+
error: "model is listed by OAuth but does not support image_generation: gpt-5.3-codex-spark",
|
|
18
|
+
code: "IMAGE_MODEL_UNSUPPORTED",
|
|
19
|
+
status: 400,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!valid.has(rawModel)) {
|
|
24
|
+
return {
|
|
25
|
+
error: "model must be one of: gpt-5.5, gpt-5.4, gpt-5.4-mini",
|
|
26
|
+
code: "INVALID_IMAGE_MODEL",
|
|
27
|
+
status: 400,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { model: rawModel };
|
|
32
|
+
}
|
package/lib/oauthProxy.js
CHANGED
|
@@ -155,6 +155,7 @@ export async function generateViaOAuth(
|
|
|
155
155
|
options = {},
|
|
156
156
|
) {
|
|
157
157
|
const oauthUrl = getOAuthUrl(ctx);
|
|
158
|
+
const model = options.model || ctx.config?.imageModels?.default || "gpt-5.4-mini";
|
|
158
159
|
const tools = [
|
|
159
160
|
{ type: "web_search" },
|
|
160
161
|
{
|
|
@@ -181,7 +182,7 @@ export async function generateViaOAuth(
|
|
|
181
182
|
method: "POST",
|
|
182
183
|
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
|
|
183
184
|
body: JSON.stringify({
|
|
184
|
-
model
|
|
185
|
+
model,
|
|
185
186
|
input: [
|
|
186
187
|
{ role: "developer", content: GENERATE_DEVELOPER_PROMPT },
|
|
187
188
|
{ role: "user", content: userContent },
|
|
@@ -194,6 +195,7 @@ export async function generateViaOAuth(
|
|
|
194
195
|
|
|
195
196
|
logEvent("oauth", "response", {
|
|
196
197
|
requestId,
|
|
198
|
+
model,
|
|
197
199
|
status: res.status,
|
|
198
200
|
contentType: res.headers.get("content-type"),
|
|
199
201
|
});
|
|
@@ -236,7 +238,7 @@ export async function generateViaOAuth(
|
|
|
236
238
|
method: "POST",
|
|
237
239
|
headers: { "Content-Type": "application/json" },
|
|
238
240
|
body: JSON.stringify({
|
|
239
|
-
model
|
|
241
|
+
model,
|
|
240
242
|
input: [{ role: "user", content: buildUserTextPrompt(prompt, mode) }],
|
|
241
243
|
tools: [{ type: "image_generation", quality, size, moderation }],
|
|
242
244
|
stream: false,
|
|
@@ -260,15 +262,16 @@ export async function generateViaOAuth(
|
|
|
260
262
|
return { b64: imageB64, usage, webSearchCalls, revisedPrompt };
|
|
261
263
|
}
|
|
262
264
|
|
|
263
|
-
export async function editViaOAuth(prompt, imageB64, quality, size, moderation = "low", mode = "auto", ctx = {}, requestId = null) {
|
|
265
|
+
export async function editViaOAuth(prompt, imageB64, quality, size, moderation = "low", mode = "auto", ctx = {}, requestId = null, options = {}) {
|
|
264
266
|
const oauthUrl = getOAuthUrl(ctx);
|
|
267
|
+
const model = options.model || ctx.config?.imageModels?.default || "gpt-5.4-mini";
|
|
265
268
|
const textPrompt = buildEditTextPrompt(prompt, mode);
|
|
266
269
|
|
|
267
270
|
const res = await fetch(`${oauthUrl}/v1/responses`, {
|
|
268
271
|
method: "POST",
|
|
269
272
|
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
|
|
270
273
|
body: JSON.stringify({
|
|
271
|
-
model
|
|
274
|
+
model,
|
|
272
275
|
input: [
|
|
273
276
|
{ role: "developer", content: EDIT_DEVELOPER_PROMPT },
|
|
274
277
|
{
|
|
@@ -287,6 +290,7 @@ export async function editViaOAuth(prompt, imageB64, quality, size, moderation =
|
|
|
287
290
|
|
|
288
291
|
logEvent("oauth-edit", "response", {
|
|
289
292
|
requestId,
|
|
293
|
+
model,
|
|
290
294
|
status: res.status,
|
|
291
295
|
contentType: res.headers.get("content-type"),
|
|
292
296
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export async function openDirectory(dir, options = {}) {
|
|
5
|
+
await mkdir(dir, { recursive: true });
|
|
6
|
+
const platform = options.platform || process.platform;
|
|
7
|
+
const spawnImpl = options.spawnImpl || spawn;
|
|
8
|
+
const command =
|
|
9
|
+
platform === "darwin" ? "open"
|
|
10
|
+
: platform === "win32" ? "explorer"
|
|
11
|
+
: "xdg-open";
|
|
12
|
+
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
try {
|
|
15
|
+
const child = spawnImpl(command, [dir], {
|
|
16
|
+
detached: true,
|
|
17
|
+
stdio: "ignore",
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
});
|
|
20
|
+
let settled = false;
|
|
21
|
+
const done = (result) => {
|
|
22
|
+
if (settled) return;
|
|
23
|
+
settled = true;
|
|
24
|
+
resolve(result);
|
|
25
|
+
};
|
|
26
|
+
child.on("error", (err) => {
|
|
27
|
+
done({ ok: false, error: err.message || String(err) });
|
|
28
|
+
});
|
|
29
|
+
child.unref?.();
|
|
30
|
+
setTimeout(() => done({ ok: true }), 50).unref?.();
|
|
31
|
+
} catch (err) {
|
|
32
|
+
resolve({ ok: false, error: err?.message || String(err) });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
package/lib/storageMigration.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { mkdir, readdir, copyFile, stat } from "node:fs/promises";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { mkdir, readdir, copyFile, stat, constants } from "node:fs/promises";
|
|
3
2
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
4
|
|
|
6
5
|
const PACKAGE_NAME = "ima2-gen";
|
|
6
|
+
const RECOVERY_DOCS_PATH = "docs/RECOVER_OLD_IMAGES.md";
|
|
7
7
|
|
|
8
8
|
function addStats(a, b) {
|
|
9
9
|
return {
|
|
@@ -24,12 +24,13 @@ async function copyMissingTree(srcDir, dstDir) {
|
|
|
24
24
|
continue;
|
|
25
25
|
}
|
|
26
26
|
if (!entry.isFile()) continue;
|
|
27
|
-
|
|
27
|
+
try {
|
|
28
|
+
await copyFile(src, dst, constants.COPYFILE_EXCL);
|
|
29
|
+
stats.copied += 1;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err?.code !== "EEXIST") throw err;
|
|
28
32
|
stats.skippedExisting += 1;
|
|
29
|
-
continue;
|
|
30
33
|
}
|
|
31
|
-
await copyFile(src, dst);
|
|
32
|
-
stats.copied += 1;
|
|
33
34
|
}
|
|
34
35
|
return stats;
|
|
35
36
|
}
|
|
@@ -42,7 +43,7 @@ function isSameOrInside(child, parent) {
|
|
|
42
43
|
|
|
43
44
|
export async function migrateGeneratedStorage(ctx, options = {}) {
|
|
44
45
|
const targetDir = ctx.config.storage.generatedDir;
|
|
45
|
-
const candidates = options.legacyDirs || getLegacyGeneratedCandidates(ctx, options.env);
|
|
46
|
+
const candidates = options.legacyDirs || await getLegacyGeneratedCandidates(ctx, options.env);
|
|
46
47
|
const result = {
|
|
47
48
|
copied: 0,
|
|
48
49
|
skippedExisting: 0,
|
|
@@ -75,13 +76,18 @@ export async function migrateGeneratedStorage(ctx, options = {}) {
|
|
|
75
76
|
return result;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
export function getLegacyGeneratedCandidates(ctx, env = process.env) {
|
|
79
|
+
export async function getLegacyGeneratedCandidates(ctx, env = process.env) {
|
|
79
80
|
const home = env.IMA2_TEST_HOME || homedir();
|
|
80
81
|
const execPath = env.IMA2_TEST_EXEC_PATH || process.execPath;
|
|
81
82
|
const argv1 = env.IMA2_TEST_ARGV1 || process.argv[1] || "";
|
|
82
83
|
const nodePrefix = dirname(dirname(execPath));
|
|
83
84
|
const prefixes = getGlobalPrefixCandidates({ env, execPath, argv1 });
|
|
84
85
|
const appData = env.APPDATA || join(home, "AppData", "Roaming");
|
|
86
|
+
const localAppData = env.LOCALAPPDATA || join(home, "AppData", "Local");
|
|
87
|
+
const npmCache = env.npm_config_cache || join(home, ".npm");
|
|
88
|
+
const xdgDataHome = env.XDG_DATA_HOME || join(home, ".local", "share");
|
|
89
|
+
const pnpmHome = env.PNPM_HOME || "";
|
|
90
|
+
const nvmHome = env.NVM_HOME || join(appData, "nvm");
|
|
85
91
|
|
|
86
92
|
const candidates = [
|
|
87
93
|
join(ctx.rootDir, "generated"),
|
|
@@ -90,6 +96,12 @@ export function getLegacyGeneratedCandidates(ctx, env = process.env) {
|
|
|
90
96
|
join(home, ".nvm", "versions", "node", process.version, "lib", "node_modules", PACKAGE_NAME, "generated"),
|
|
91
97
|
join(home, ".volta", "tools", "image", "packages", PACKAGE_NAME, "lib", "node_modules", PACKAGE_NAME, "generated"),
|
|
92
98
|
join(home, ".fnm", "node-versions", process.version, "installation", "lib", "node_modules", PACKAGE_NAME, "generated"),
|
|
99
|
+
join(home, ".bun", "install", "global", "node_modules", PACKAGE_NAME, "generated"),
|
|
100
|
+
join(home, ".config", "yarn", "global", "node_modules", PACKAGE_NAME, "generated"),
|
|
101
|
+
join(localAppData, "Yarn", "Data", "global", "node_modules", PACKAGE_NAME, "generated"),
|
|
102
|
+
join(localAppData, "Volta", "tools", "image", "packages", PACKAGE_NAME, "lib", "node_modules", PACKAGE_NAME, "generated"),
|
|
103
|
+
join(nvmHome, process.version, "node_modules", PACKAGE_NAME, "generated"),
|
|
104
|
+
join(dirname(execPath), "node_modules", PACKAGE_NAME, "generated"),
|
|
93
105
|
];
|
|
94
106
|
|
|
95
107
|
for (const prefix of prefixes) {
|
|
@@ -98,7 +110,160 @@ export function getLegacyGeneratedCandidates(ctx, env = process.env) {
|
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
candidates.push(join(nodePrefix, "lib", "node_modules", PACKAGE_NAME, "generated"));
|
|
101
|
-
|
|
113
|
+
candidates.push(
|
|
114
|
+
...await expandOneLevelCandidates([
|
|
115
|
+
[join(home, ".nvm", "versions", "node"), ["*", "lib", "node_modules", PACKAGE_NAME, "generated"]],
|
|
116
|
+
[join(home, ".fnm", "node-versions"), ["*", "installation", "lib", "node_modules", PACKAGE_NAME, "generated"]],
|
|
117
|
+
[join(home, ".asdf", "installs", "nodejs"), ["*", "lib", "node_modules", PACKAGE_NAME, "generated"]],
|
|
118
|
+
[join(home, ".local", "share", "mise", "installs", "node"), ["*", "lib", "node_modules", PACKAGE_NAME, "generated"]],
|
|
119
|
+
[join(home, "Library", "pnpm", "global"), ["*", "node_modules", PACKAGE_NAME, "generated"]],
|
|
120
|
+
[join(xdgDataHome, "pnpm", "global"), ["*", "node_modules", PACKAGE_NAME, "generated"]],
|
|
121
|
+
[join(localAppData, "pnpm", "global"), ["*", "node_modules", PACKAGE_NAME, "generated"]],
|
|
122
|
+
[pnpmHome ? join(pnpmHome, "global") : "", ["*", "node_modules", PACKAGE_NAME, "generated"]],
|
|
123
|
+
[join(npmCache, "_npx"), ["*", "node_modules", PACKAGE_NAME, "generated"]],
|
|
124
|
+
[join(localAppData, "npm-cache", "_npx"), ["*", "node_modules", PACKAGE_NAME, "generated"]],
|
|
125
|
+
[join(appData, "npm-cache", "_npx"), ["*", "node_modules", PACKAGE_NAME, "generated"]],
|
|
126
|
+
[nvmHome, ["*", "node_modules", PACKAGE_NAME, "generated"]],
|
|
127
|
+
]),
|
|
128
|
+
);
|
|
129
|
+
return uniqueResolvedCandidates(candidates);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function inspectGeneratedStorage(ctx, options = {}) {
|
|
133
|
+
const env = options.env || process.env;
|
|
134
|
+
const targetDir = ctx.config.storage.generatedDir;
|
|
135
|
+
try {
|
|
136
|
+
const candidates = options.legacyDirs || await getLegacyGeneratedCandidates(ctx, env);
|
|
137
|
+
const targetFileCount = await countFiles(targetDir);
|
|
138
|
+
const legacySources = [];
|
|
139
|
+
|
|
140
|
+
for (const candidate of candidates) {
|
|
141
|
+
if (isSameOrInside(candidate, targetDir) || isSameOrInside(targetDir, candidate)) continue;
|
|
142
|
+
try {
|
|
143
|
+
const candidateStat = await stat(candidate);
|
|
144
|
+
if (!candidateStat.isDirectory()) continue;
|
|
145
|
+
const fileCount = await countFiles(candidate);
|
|
146
|
+
if (fileCount > 0) legacySources.push({ path: candidate, fileCount });
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (err?.code !== "ENOENT") {
|
|
149
|
+
console.warn("[storage] legacy candidate inspect skipped:", candidate, err.message);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const legacyFilesFound = legacySources.reduce((sum, source) => sum + source.fileCount, 0);
|
|
155
|
+
const state =
|
|
156
|
+
targetFileCount > 0 ? "ok"
|
|
157
|
+
: legacyFilesFound > 0 ? "recoverable"
|
|
158
|
+
: "not_found";
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
targetDir,
|
|
163
|
+
generatedDirLabel: labelPath(targetDir, env),
|
|
164
|
+
targetExists: await isDirectory(targetDir),
|
|
165
|
+
targetFileCount,
|
|
166
|
+
legacyCandidatesScanned: candidates.length,
|
|
167
|
+
legacySourcesFound: legacySources.length,
|
|
168
|
+
legacyFilesFound,
|
|
169
|
+
legacySources,
|
|
170
|
+
overrides: {
|
|
171
|
+
generatedDir: Boolean(env.IMA2_GENERATED_DIR),
|
|
172
|
+
configDir: Boolean(env.IMA2_CONFIG_DIR),
|
|
173
|
+
},
|
|
174
|
+
state,
|
|
175
|
+
messageKind: state === "not_found" ? "apology" : state,
|
|
176
|
+
recoveryDocsPath: RECOVERY_DOCS_PATH,
|
|
177
|
+
doctorCommand: "ima2 doctor",
|
|
178
|
+
};
|
|
179
|
+
} catch (err) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
targetDir,
|
|
183
|
+
generatedDirLabel: labelPath(targetDir, env),
|
|
184
|
+
targetExists: false,
|
|
185
|
+
targetFileCount: 0,
|
|
186
|
+
legacyCandidatesScanned: 0,
|
|
187
|
+
legacySourcesFound: 0,
|
|
188
|
+
legacyFilesFound: 0,
|
|
189
|
+
legacySources: [],
|
|
190
|
+
overrides: {
|
|
191
|
+
generatedDir: Boolean(env.IMA2_GENERATED_DIR),
|
|
192
|
+
configDir: Boolean(env.IMA2_CONFIG_DIR),
|
|
193
|
+
},
|
|
194
|
+
state: "unknown",
|
|
195
|
+
messageKind: "unknown",
|
|
196
|
+
recoveryDocsPath: RECOVERY_DOCS_PATH,
|
|
197
|
+
doctorCommand: "ima2 doctor",
|
|
198
|
+
error: err?.message || String(err),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function expandOneLevelCandidates(patterns) {
|
|
204
|
+
const candidates = [];
|
|
205
|
+
for (const [baseDir, segments] of patterns) {
|
|
206
|
+
if (!baseDir) continue;
|
|
207
|
+
candidates.push(...await expandOneLevelPattern(baseDir, segments));
|
|
208
|
+
}
|
|
209
|
+
return candidates;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function expandOneLevelPattern(baseDir, segments) {
|
|
213
|
+
const wildcardIndex = segments.indexOf("*");
|
|
214
|
+
if (wildcardIndex < 0) return [join(baseDir, ...segments)];
|
|
215
|
+
|
|
216
|
+
const before = segments.slice(0, wildcardIndex);
|
|
217
|
+
const after = segments.slice(wildcardIndex + 1);
|
|
218
|
+
const wildcardBase = join(baseDir, ...before);
|
|
219
|
+
try {
|
|
220
|
+
const entries = await readdir(wildcardBase, { withFileTypes: true });
|
|
221
|
+
return entries
|
|
222
|
+
.filter((entry) => entry.isDirectory())
|
|
223
|
+
.map((entry) => join(wildcardBase, entry.name, ...after));
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err?.code === "ENOENT") return [];
|
|
226
|
+
console.warn("[storage] legacy candidate scan skipped:", wildcardBase, err.message);
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function countFiles(dir) {
|
|
232
|
+
try {
|
|
233
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
234
|
+
let count = 0;
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
if (entry.name === ".trash") continue;
|
|
237
|
+
const fullPath = join(dir, entry.name);
|
|
238
|
+
if (entry.isDirectory()) count += await countFiles(fullPath);
|
|
239
|
+
else if (entry.isFile()) count += 1;
|
|
240
|
+
}
|
|
241
|
+
return count;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (err?.code === "ENOENT") return 0;
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function isDirectory(dir) {
|
|
249
|
+
try {
|
|
250
|
+
return (await stat(dir)).isDirectory();
|
|
251
|
+
} catch {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function uniqueResolvedCandidates(candidates) {
|
|
257
|
+
return Array.from(new Set(candidates.filter(Boolean).map((p) => resolve(p))));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function labelPath(targetPath, env = process.env) {
|
|
261
|
+
const home = env.IMA2_TEST_HOME || homedir();
|
|
262
|
+
const resolved = resolve(targetPath);
|
|
263
|
+
const resolvedHome = resolve(home);
|
|
264
|
+
if (resolved === resolvedHome) return "~";
|
|
265
|
+
if (resolved.startsWith(resolvedHome + sep)) return `~${sep}${resolved.slice(resolvedHome.length + 1)}`;
|
|
266
|
+
return resolved;
|
|
102
267
|
}
|
|
103
268
|
|
|
104
269
|
function getGlobalPrefixCandidates({ env, execPath, argv1 }) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ima2-gen",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Local OAuth image generation studio with classic and node workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ima2": "./bin/ima2.js"
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"lib/",
|
|
40
40
|
"routes/",
|
|
41
41
|
"ui/dist/",
|
|
42
|
+
"docs/",
|
|
42
43
|
"assets/",
|
|
43
44
|
"server.js",
|
|
44
45
|
"config.js",
|