ima2-gen 3.25.0 → 3.26.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/CHANGELOG.md +7 -0
- package/README.md +10 -2
- package/bin/commands/update.js +161 -0
- package/bin/ima2.js +11 -8
- package/bin/lib/helpText.js +1 -0
- package/bin/lib/npmUpdate.js +149 -0
- package/bin/lib/updateNotice.js +30 -0
- package/config.js +10 -0
- package/docs/API.md +13 -0
- package/docs/migration/runtime-test-inventory.md +6 -1
- package/lib/updateCache.js +128 -0
- package/lib/updateCheck.js +118 -0
- package/lib/updateVersion.js +59 -0
- package/package.json +2 -2
- package/routes/events.js +8 -3
- package/routes/index.js +2 -0
- package/routes/update.js +67 -0
- package/server.js +7 -0
- package/skills/ima2/SKILL.md +4 -0
- package/ui/dist/.vite/manifest.json +71 -64
- package/ui/dist/assets/{AgentWorkspace-DXPJup3F.js → AgentWorkspace-DGEWNErN.js} +1 -1
- package/ui/dist/assets/App-CZZNsSpb.css +1 -0
- package/ui/dist/assets/App-b7KJO01r.js +9 -0
- package/ui/dist/assets/AssetGenWorkspace-woj0orEW.js +2 -0
- package/ui/dist/assets/{AssetMediaLightbox-CV9ME5oR.js → AssetMediaLightbox-39eeVLgg.js} +1 -1
- package/ui/dist/assets/{AssetsWorkspace-DWzOEvQE.js → AssetsWorkspace-feT-v8BY.js} +1 -1
- package/ui/dist/assets/{CardNewsWorkspace-B1qS17V6.js → CardNewsWorkspace-Bce7Qq0s.js} +1 -1
- package/ui/dist/assets/{GenerationRequestLogPanel-DyFi62oC.js → GenerationRequestLogPanel-D6jO_fXj.js} +1 -1
- package/ui/dist/assets/{HomeWorkspace-BHdt4qCg.js → HomeWorkspace-C7SABT2o.js} +1 -1
- package/ui/dist/assets/{KeyingPanel-CyycINzW.js → KeyingPanel-DWaV9fRG.js} +1 -1
- package/ui/dist/assets/{NodeCanvas-BdKhUDjc.js → NodeCanvas-ybOBBYto.js} +1 -1
- package/ui/dist/assets/{PromptBuilderPanel-CMXjpe3Y.js → PromptBuilderPanel-C1DLHD9Q.js} +1 -1
- package/ui/dist/assets/PromptImportDialog-OGyYU5K5.js +2 -0
- package/ui/dist/assets/{PromptImportDiscoverySection-C0WBOpMu.js → PromptImportDiscoverySection-Cwigr40k.js} +1 -1
- package/ui/dist/assets/{PromptImportFolderSection-DqAoW1I_.js → PromptImportFolderSection-B1XqjRbl.js} +1 -1
- package/ui/dist/assets/{PromptLibraryPanel-B6a4fySv.js → PromptLibraryPanel-v7hIoYy-.js} +2 -2
- package/ui/dist/assets/{SettingsWorkspace-BF_e8kUk.js → SettingsWorkspace-CGZXJLgN.js} +1 -1
- package/ui/dist/assets/{SpriteRecipeWorkspace-BbkGaNby.js → SpriteRecipeWorkspace-Cm5wizr5.js} +1 -1
- package/ui/dist/assets/{index-CDZTiRZg.js → index-CJ-sBrSx.js} +9 -8
- package/ui/dist/assets/{index-BrM_ZMPs.js → index-CUMOJ4Vx.js} +3 -3
- package/ui/dist/assets/{pptxgen.es-DWAWViwz.js → pptxgen.es-XHiluGIr.js} +1 -1
- package/ui/dist/assets/{promptBuilderStore-CCMVetZf.js → promptBuilderStore-BKNNtgtk.js} +1 -1
- package/ui/dist/assets/useAgentDialogFocus-CZabQdET.js +1 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/App-BdynRa0c.js +0 -9
- package/ui/dist/assets/AssetGenWorkspace-CFjZEEJn.js +0 -2
- package/ui/dist/assets/PromptImportDialog-Dd1sw7H6.js +0 -2
- package/ui/dist/assets/useAgentDialogFocus-Cxfj1Zq7.js +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.26.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Update notices and `ima2 update`** — an npm install checks the registry for a newer ima2 at most once every 20 hours while the server runs (preview installs follow the `preview` tag) and caches the answer in `~/.ima2/version.json`. Interactive commands such as `ima2 serve`, `ima2 start` and `ima2 status` print one line when an update is waiting, and the web UI shows an update button in the top strip (a floating pill on phones) with the command to run, release notes and Dismiss. `ima2 update` installs the new version globally, restarts the login service or background server it owns and waits until the new version answers; `--check`, `--tag latest|preview`, `--yes` and `--json` are supported. After an update the CLI or the web UI says "ima2 updated to vX" once. `IMA2_DISABLE_UPDATE_CHECK=1` turns the automatic check off.
|
|
15
|
+
- **Desktop update states in the menu bar** — the macOS menu bar icon gains a dot while an update is available, downloading or ready (Windows and Linux switch to the update tray icon). The tray menu, app menu, tray popup and the app window show the same state: *Download Update vX*, *Downloading Update vX… n%*, *Restart to Update (vX)*, *Up to date (vX)*. With Auto-update on, the app checks every six hours. After an update relaunch it shows "ima2 updated to vX" once, with a *What's New* link. Installs started from the app window still ask for confirmation.
|
|
16
|
+
|
|
10
17
|
## [3.25.0] - 2026-09-30
|
|
11
18
|
|
|
12
19
|
### Added
|
package/README.md
CHANGED
|
@@ -165,12 +165,19 @@ See [docs/DOCKER.md](docs/DOCKER.md) for compose usage, required environment, an
|
|
|
165
165
|
|
|
166
166
|
Video generation needs Grok OAuth (option 2 or 3). If you already use GPT OAuth, run `ima2 grok login` to add video; it defaults to the manual-paste flow.
|
|
167
167
|
|
|
168
|
-
To update,
|
|
168
|
+
To update, run:
|
|
169
169
|
|
|
170
170
|
```bash
|
|
171
|
-
|
|
171
|
+
ima2 update # or: ima2 update --check
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
+
It installs the latest release globally, restarts the login service or background server
|
|
175
|
+
it owns, and waits until the new version answers. A server running in a terminal
|
|
176
|
+
(`ima2 serve`) only needs a restart afterwards. ima2 tells you when an update is waiting:
|
|
177
|
+
interactive commands print one line and the web UI shows an update button in the top
|
|
178
|
+
strip. Set `IMA2_DISABLE_UPDATE_CHECK=1` to turn the background check off. Without the
|
|
179
|
+
command, stop the server with Ctrl+C (or `ima2 stop`) and run `npm install -g ima2-gen@latest`.
|
|
180
|
+
|
|
174
181
|
Ctrl+C shuts down cleanly: it closes the database, stops child processes and releases file locks. If an install fails, check the reported npm permissions or stop the specific `ima2` process yourself; the installer does not perform broad process cleanup.
|
|
175
182
|
|
|
176
183
|
Using npx instead? See [docs/NPX_QUICKSTART.md](docs/NPX_QUICKSTART.md) for the `npx ima2-gen serve` workflow.
|
|
@@ -269,6 +276,7 @@ Grok video generation defaults to canonical `grok-imagine-video-1.5`; `grok-imag
|
|
|
269
276
|
| `ima2 logs [-n N] [-f]` | Show or follow the background server log |
|
|
270
277
|
| `ima2 service <sub>` | Background service: `install`/`uninstall`/`start`/`stop`/`restart`/`status`/`logs`/`repair` — launchd on macOS, systemd user unit on Linux, auto-start on login with crash restart |
|
|
271
278
|
| `ima2 setup` | Reconfigure saved auth |
|
|
279
|
+
| `ima2 update [--check] [--tag latest\|preview] [--yes] [--json]` | Update a global npm install and restart the service or background server it owns; `--check` only reports |
|
|
272
280
|
| `ima2 status` | Show config and OAuth status |
|
|
273
281
|
| `ima2 doctor` | Diagnose Node, package, config, and auth |
|
|
274
282
|
| `ima2 doctor image-probe [--json]` | Run sanitized image probes for no-image diagnostics |
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { config } from "../../config.js";
|
|
4
|
+
import { errInfo } from "../../lib/errInfo.js";
|
|
5
|
+
import { markSeen } from "../../lib/updateCache.js";
|
|
6
|
+
import { checkForUpdate } from "../../lib/updateCheck.js";
|
|
7
|
+
import { defaultTag, isNewer, isUpdateTag, parseVersion } from "../../lib/updateVersion.js";
|
|
8
|
+
import { confirmDestructiveAction } from "../lib/destructive-confirm.js";
|
|
9
|
+
import { classifyInstall, defaultExec, defaultFs, installGlobal, planRestart, readInstalledVersion, runNewCli, runtimeSnapshot, serviceOwned } from "../lib/npmUpdate.js";
|
|
10
|
+
const USAGE = "Usage: ima2 update [--check] [--tag latest|preview] [--yes] [--json]\nCheck or install the latest global npm release. --check never installs or restarts.";
|
|
11
|
+
const RESTART_POLL_MS = 500;
|
|
12
|
+
const RESTART_TIMEOUT_MS = 20_000;
|
|
13
|
+
class UsageError extends Error {
|
|
14
|
+
}
|
|
15
|
+
function parseOptions(args, current) {
|
|
16
|
+
const opts = { check: false, yes: false, json: args.includes("--json"), tag: defaultTag(current) };
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
const arg = args[i];
|
|
19
|
+
if (arg === "--check")
|
|
20
|
+
opts.check = true;
|
|
21
|
+
else if (arg === "--yes" || arg === "-y")
|
|
22
|
+
opts.yes = true;
|
|
23
|
+
else if (arg === "--json")
|
|
24
|
+
opts.json = true;
|
|
25
|
+
else if (arg === "--tag") {
|
|
26
|
+
const tag = args[++i];
|
|
27
|
+
if (!isUpdateTag(tag))
|
|
28
|
+
throw new UsageError("--tag must be latest or preview");
|
|
29
|
+
opts.tag = tag;
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
throw new UsageError(`Unknown option: ${arg}`);
|
|
33
|
+
}
|
|
34
|
+
return opts;
|
|
35
|
+
}
|
|
36
|
+
function refuseUnsupported(ctx) {
|
|
37
|
+
const install = classifyInstall(ctx.deps.pkgDir, ctx.deps.exec, ctx.deps.fs);
|
|
38
|
+
if (install.kind === "source")
|
|
39
|
+
throw new UsageError("This is a source checkout. Update it with: git pull && npm install && npm run build");
|
|
40
|
+
if (install.kind === "other")
|
|
41
|
+
throw new UsageError(`ima2 is not a global npm install (${install.reason}). Install globally with: npm i -g ima2-gen@${ctx.opts.tag}`);
|
|
42
|
+
}
|
|
43
|
+
async function confirmUpdate(ctx) {
|
|
44
|
+
ctx.say(`v${ctx.result.current} -> v${ctx.result.latest}`);
|
|
45
|
+
if (ctx.opts.yes)
|
|
46
|
+
return true;
|
|
47
|
+
if (ctx.opts.json || !ctx.deps.isTTY)
|
|
48
|
+
throw new UsageError("Updating requires --yes in non-interactive or --json mode.");
|
|
49
|
+
return ctx.deps.confirm("Install this update?");
|
|
50
|
+
}
|
|
51
|
+
function restartHint(kind) {
|
|
52
|
+
return kind === "service" ? "ima2 service restart" : "ima2 restart";
|
|
53
|
+
}
|
|
54
|
+
async function proveRestart(ctx) {
|
|
55
|
+
const now = ctx.deps.now ?? (() => performance.now());
|
|
56
|
+
const deadline = now() + RESTART_TIMEOUT_MS;
|
|
57
|
+
let runtime = await ctx.deps.runtime();
|
|
58
|
+
while (now() < deadline && !(runtime.live && runtime.version === ctx.result.latest)) {
|
|
59
|
+
await (ctx.deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(Math.min(RESTART_POLL_MS, deadline - now()));
|
|
60
|
+
runtime = await ctx.deps.runtime();
|
|
61
|
+
}
|
|
62
|
+
return runtime;
|
|
63
|
+
}
|
|
64
|
+
async function restartUpdated(ctx, plan, exec) {
|
|
65
|
+
if (plan.kind === "none")
|
|
66
|
+
return;
|
|
67
|
+
ctx.result.restart = { kind: plan.kind, ok: false, version: null };
|
|
68
|
+
if (plan.kind === "desktop") {
|
|
69
|
+
ctx.say("The running server belongs to the ima2 desktop app, which updates itself; it was left running.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (plan.kind === "foreground") {
|
|
73
|
+
ctx.say(`Restart your running 'ima2 serve' (${plan.url}) to use v${ctx.result.latest}.`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const args = plan.kind === "service" ? ["service", "restart"] : ["restart"];
|
|
78
|
+
if (runNewCli(ctx.deps.pkgDir, args, exec) === 0) {
|
|
79
|
+
const runtime = await proveRestart(ctx);
|
|
80
|
+
ctx.result.restart.version = runtime.version;
|
|
81
|
+
ctx.result.restart.ok = runtime.live && runtime.version === ctx.result.latest;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { /* Subprocess/probe errors are surfaced with the actionable restart hint below. */ }
|
|
85
|
+
if (!ctx.result.restart.ok)
|
|
86
|
+
throw new Error(`Updated to v${ctx.result.latest}, but the restart failed. Run: ${restartHint(plan.kind)}`);
|
|
87
|
+
}
|
|
88
|
+
async function installUpdate(ctx) {
|
|
89
|
+
const { deps, opts, result } = ctx;
|
|
90
|
+
const exec = (cmd, args, options) => deps.exec(cmd, args, { ...options, inherit: !opts.json && Boolean(options?.inherit) });
|
|
91
|
+
const owned = deps.serviceOwned(deps.pkgDir);
|
|
92
|
+
const plan = planRestart({ ...await deps.runtime(), serviceOwned: owned });
|
|
93
|
+
installGlobal(result.latest, exec, process.platform, deps.fs);
|
|
94
|
+
const installed = readInstalledVersion(deps.pkgDir, deps.fs);
|
|
95
|
+
if (installed === null || installed !== result.latest)
|
|
96
|
+
throw new Error(`npm finished but ima2 is still v${installed ?? "unknown"}`);
|
|
97
|
+
result.updated = true;
|
|
98
|
+
deps.markSeen(installed);
|
|
99
|
+
ctx.say(`Updated to v${installed}`);
|
|
100
|
+
await restartUpdated(ctx, plan, exec);
|
|
101
|
+
}
|
|
102
|
+
async function executeUpdate(ctx) {
|
|
103
|
+
if (!ctx.opts.check)
|
|
104
|
+
refuseUnsupported(ctx);
|
|
105
|
+
const cache = await ctx.deps.check(ctx.opts.tag);
|
|
106
|
+
if (!cache.latest_version || !parseVersion(cache.latest_version))
|
|
107
|
+
throw new Error("Update check returned an invalid version");
|
|
108
|
+
ctx.result.latest = cache.latest_version;
|
|
109
|
+
ctx.result.available = isNewer(cache.latest_version, ctx.deps.current);
|
|
110
|
+
if (!ctx.result.available) {
|
|
111
|
+
ctx.say(`Up to date (v${ctx.deps.current})`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
if (ctx.opts.check) {
|
|
115
|
+
const command = ctx.opts.tag === "preview" ? "ima2 update --tag preview" : "ima2 update";
|
|
116
|
+
ctx.say(`Update available: v${cache.latest_version} (current v${ctx.deps.current}). Run: ${command}`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (!await confirmUpdate(ctx)) {
|
|
120
|
+
ctx.say("Aborted.");
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
await installUpdate(ctx);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
export async function runUpdate(args, deps) {
|
|
127
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
128
|
+
deps.log(USAGE);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
const json = args.includes("--json");
|
|
132
|
+
const result = { current: deps.current, latest: null, tag: defaultTag(deps.current), available: false, updated: false, restart: null };
|
|
133
|
+
let code;
|
|
134
|
+
try {
|
|
135
|
+
const opts = parseOptions(args, deps.current);
|
|
136
|
+
result.tag = opts.tag;
|
|
137
|
+
code = await executeUpdate({ deps, opts, result, say: (line) => { if (!json)
|
|
138
|
+
deps.log(line); } });
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
result.error = errInfo(error).message;
|
|
142
|
+
code = error instanceof UsageError ? 2 : 1;
|
|
143
|
+
if (!json)
|
|
144
|
+
deps.error(`${result.error}${error instanceof UsageError ? `\n${USAGE}` : ""}`);
|
|
145
|
+
}
|
|
146
|
+
if (json)
|
|
147
|
+
deps.log(JSON.stringify(result));
|
|
148
|
+
return code;
|
|
149
|
+
}
|
|
150
|
+
export default async function update(args = []) {
|
|
151
|
+
const pkgDir = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
152
|
+
const json = args.includes("--json");
|
|
153
|
+
process.exitCode = await runUpdate(args, {
|
|
154
|
+
current: readInstalledVersion(pkgDir) ?? "?", pkgDir, exec: defaultExec, fs: defaultFs,
|
|
155
|
+
check: (tag) => checkForUpdate(tag), isTTY: Boolean(process.stdin.isTTY),
|
|
156
|
+
confirm: (question) => confirmDestructiveAction(question, false),
|
|
157
|
+
serviceOwned: (dir) => serviceOwned(dir, config.storage.configDir, { log: (line) => { if (!json)
|
|
158
|
+
console.error(line); } }),
|
|
159
|
+
runtime: runtimeSnapshot, markSeen, log: console.log, error: console.error,
|
|
160
|
+
});
|
|
161
|
+
}
|
package/bin/ima2.js
CHANGED
|
@@ -43,9 +43,7 @@ if (process.argv[2] === "doctor" && process.argv.slice(3).includes("--installati
|
|
|
43
43
|
const { config: runtimeConfig } = await import("../config.js");
|
|
44
44
|
const { doctor } = await import("./commands/doctor.js");
|
|
45
45
|
const { maybePromptGithubStar } = await import("./lib/star-prompt.js");
|
|
46
|
-
// Config
|
|
47
|
-
// Legacy installs that stored config at <packageRoot>/.ima2/config.json will be
|
|
48
|
-
// migrated on first write.
|
|
46
|
+
// Config honors IMA2_CONFIG_DIR; legacy package-local config migrates on first write.
|
|
49
47
|
const CONFIG_DIR = runtimeConfig.storage.configDir;
|
|
50
48
|
const CONFIG_FILE = runtimeConfig.storage.configFile;
|
|
51
49
|
const LEGACY_CONFIG_FILE = join(ROOT, ".ima2", "config.json");
|
|
@@ -297,10 +295,7 @@ async function showStatus() {
|
|
|
297
295
|
}
|
|
298
296
|
console.log("");
|
|
299
297
|
}
|
|
300
|
-
/**
|
|
301
|
-
* Auth verdicts for `ima2 status`. When a server is advertised, its live proxy verdict is
|
|
302
|
-
* folded in: a session file can look fine while ChatGPT has already revoked it.
|
|
303
|
-
*/
|
|
298
|
+
/** Fold an advertised server's live proxy verdict into the stored auth status. */
|
|
304
299
|
async function buildAuthReport() {
|
|
305
300
|
const { gptAuthStatus, grokAuthStatus } = await import("../lib/authStatus.js");
|
|
306
301
|
const { resolveChatgptSession } = await import("../lib/chatgptAuth.js");
|
|
@@ -344,7 +339,7 @@ if (args.includes("-v") || args.includes("--version")) {
|
|
|
344
339
|
console.log(pkg.version);
|
|
345
340
|
exitFlushed(0);
|
|
346
341
|
}
|
|
347
|
-
const helpOwningCommands = ["doctor", "gen", "video", "edit", "vectorize", "ls", "show", "ps", "cancel", "session", "history", "prompt", "multimode", "node", "annotate", "canvas-versions", "metadata", "comfy", "cardnews", "inflight", "storage", "billing", "providers", "oauth", "grok", "gpt", "login", "config", "defaults", "models", "capabilities", "tools", "skill", "ping", "backfill-thumbs", "service", "start", "restart", "stop", "logs"];
|
|
342
|
+
const helpOwningCommands = ["update", "doctor", "gen", "video", "edit", "vectorize", "ls", "show", "ps", "cancel", "session", "history", "prompt", "multimode", "node", "annotate", "canvas-versions", "metadata", "comfy", "cardnews", "inflight", "storage", "billing", "providers", "oauth", "grok", "gpt", "login", "config", "defaults", "models", "capabilities", "tools", "skill", "ping", "backfill-thumbs", "service", "start", "restart", "stop", "logs"];
|
|
348
343
|
if (!command) {
|
|
349
344
|
showHelp();
|
|
350
345
|
exitFlushed(1);
|
|
@@ -353,7 +348,15 @@ if ((args.includes("-h") || args.includes("--help")) && !helpOwningCommands.incl
|
|
|
353
348
|
showHelp();
|
|
354
349
|
exitFlushed(0);
|
|
355
350
|
}
|
|
351
|
+
const { printUpdateNotice } = await import("./lib/updateNotice.js");
|
|
352
|
+
printUpdateNotice(command, args, pkg.version);
|
|
356
353
|
switch (command) {
|
|
354
|
+
case "update": {
|
|
355
|
+
const { default: updateCmd } = await import("./commands/update.js");
|
|
356
|
+
await updateCmd(args.slice(1));
|
|
357
|
+
exitFlushed(Number(process.exitCode ?? 0));
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
357
360
|
case "serve":
|
|
358
361
|
if (args.includes("--background")) {
|
|
359
362
|
const { runRuntimeCommand } = await import("./commands/runtimeCommands.js");
|
package/bin/lib/helpText.js
CHANGED
|
@@ -19,6 +19,7 @@ export function renderHelp(pkg) {
|
|
|
19
19
|
doctor Diagnose environment and setup
|
|
20
20
|
open Open web UI in browser
|
|
21
21
|
reset Reset configuration
|
|
22
|
+
update Update ima2 to the latest npm release (--check, --tag preview)
|
|
22
23
|
|
|
23
24
|
Client commands (require a running 'ima2 serve'):
|
|
24
25
|
gen <prompt> Generate image(s) from prompt (ima2 gen --help)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from "node:path";
|
|
4
|
+
import { parseVersion, PACKAGE_NAME } from "../../lib/updateVersion.js";
|
|
5
|
+
import { launchdPlistPath, parseServiceConfigDir, systemdUnitPath } from "./serviceManager.js";
|
|
6
|
+
export const defaultExec = (cmd, args, opts = {}) => {
|
|
7
|
+
const result = spawnSync(cmd, args, {
|
|
8
|
+
encoding: "utf8", windowsHide: true, shell: opts.shell ?? false,
|
|
9
|
+
stdio: opts.inherit ? ["inherit", "inherit", "pipe"] : ["ignore", "pipe", "pipe"],
|
|
10
|
+
});
|
|
11
|
+
const stderr = result.stderr ?? result.error?.message ?? "";
|
|
12
|
+
if (opts.inherit && stderr)
|
|
13
|
+
process.stderr.write(stderr);
|
|
14
|
+
return { status: result.status, stdout: result.stdout ?? "", stderr };
|
|
15
|
+
};
|
|
16
|
+
export const defaultFs = {
|
|
17
|
+
exists: existsSync, realpath: realpathSync, readFile: (p) => readFileSync(p, "utf8"),
|
|
18
|
+
};
|
|
19
|
+
export function npmCommand(platform = process.platform) {
|
|
20
|
+
return platform === "win32" ? "npm.cmd" : "npm";
|
|
21
|
+
}
|
|
22
|
+
export function npmInvocation(platform = process.platform, execPath = process.execPath, fs = defaultFs) {
|
|
23
|
+
if (platform !== "win32")
|
|
24
|
+
return { command: "npm", args: [], shell: false };
|
|
25
|
+
const paths = execPath.includes("\\") ? win32 : { dirname, join };
|
|
26
|
+
const cli = paths.join(paths.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js");
|
|
27
|
+
return fs.exists(cli) ? { command: execPath, args: [cli], shell: false }
|
|
28
|
+
: { command: npmCommand(platform), args: [], shell: true };
|
|
29
|
+
}
|
|
30
|
+
export function globalRoot(exec, platform = process.platform, fs = defaultFs) {
|
|
31
|
+
const npm = npmInvocation(platform, process.execPath, fs);
|
|
32
|
+
const result = exec(npm.command, [...npm.args, "root", "-g"], { inherit: false, shell: npm.shell });
|
|
33
|
+
return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : null;
|
|
34
|
+
}
|
|
35
|
+
export function classifyInstall(pkgDir, exec, fs = defaultFs, platform = process.platform) {
|
|
36
|
+
if (fs.exists(join(pkgDir, "server.ts")) || fs.exists(join(pkgDir, ".git")))
|
|
37
|
+
return { kind: "source", pkgDir };
|
|
38
|
+
const root = globalRoot(exec, platform, fs);
|
|
39
|
+
const paths = platform === "win32" ? win32 : { join };
|
|
40
|
+
try {
|
|
41
|
+
if (root && fs.realpath(pkgDir) === fs.realpath(paths.join(root, PACKAGE_NAME)))
|
|
42
|
+
return { kind: "global", pkgDir, root };
|
|
43
|
+
}
|
|
44
|
+
catch { /* Missing paths cannot establish global-install ownership. */ }
|
|
45
|
+
const normalized = pkgDir.replaceAll("\\", "/");
|
|
46
|
+
const reason = /\/_npx\//.test(normalized) ? "npx" : /\/node_modules\//.test(normalized) ? "local" : "unknown";
|
|
47
|
+
return { kind: "other", pkgDir, reason };
|
|
48
|
+
}
|
|
49
|
+
export function installGlobal(version, exec, platform = process.platform, fs = defaultFs) {
|
|
50
|
+
if (!parseVersion(version))
|
|
51
|
+
throw new Error("Invalid update version");
|
|
52
|
+
const npm = npmInvocation(platform, process.execPath, fs);
|
|
53
|
+
const result = exec(npm.command, [...npm.args, "install", "--global", `${PACKAGE_NAME}@${version}`, "--no-fund", "--no-audit"], { inherit: true, shell: npm.shell });
|
|
54
|
+
if (result.status === 0)
|
|
55
|
+
return;
|
|
56
|
+
const hint = /EACCES|EPERM/.test(result.stderr) ? ` Fix the npm prefix permissions or run: sudo npm i -g ${PACKAGE_NAME}@${version}` : "";
|
|
57
|
+
throw new Error(`npm install failed (exit ${result.status ?? "unknown"}).${hint}`);
|
|
58
|
+
}
|
|
59
|
+
export function readInstalledVersion(pkgDir, fs = defaultFs) {
|
|
60
|
+
try {
|
|
61
|
+
const pkg = JSON.parse(fs.readFile(join(pkgDir, "package.json")));
|
|
62
|
+
return pkg !== null && typeof pkg === "object" && "version" in pkg && typeof pkg.version === "string"
|
|
63
|
+
&& parseVersion(pkg.version) ? pkg.version : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null; /* A missing/corrupt manifest cannot prove installation success. */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function runtimeSnapshot() {
|
|
70
|
+
const { collectRuntimeStatus } = await import("../commands/runtimeStatus.js");
|
|
71
|
+
const report = await collectRuntimeStatus();
|
|
72
|
+
return { live: report.liveness === "live", launcher: report.runtime?.launcher ?? null,
|
|
73
|
+
url: report.runtime?.url ?? null, version: report.runtime?.version ?? null, managed: report.serviceOwnership === "managed" };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A service restart stops whatever server answers first, so it is chosen only when no live
|
|
77
|
+
* server exists or the live one is that service. A live desktop, terminal or background
|
|
78
|
+
* server keeps running and gets its own plan.
|
|
79
|
+
*/
|
|
80
|
+
export function planRestart(input) {
|
|
81
|
+
if (input.live && input.launcher === "desktop")
|
|
82
|
+
return { kind: "desktop" };
|
|
83
|
+
if (input.serviceOwned && (!input.live || input.managed || input.launcher === "service"))
|
|
84
|
+
return { kind: "service" };
|
|
85
|
+
if (!input.live || input.managed)
|
|
86
|
+
return { kind: "none" };
|
|
87
|
+
if (input.launcher === "background")
|
|
88
|
+
return { kind: "background" };
|
|
89
|
+
if (input.launcher === "foreground" && input.url)
|
|
90
|
+
return { kind: "foreground", url: input.url };
|
|
91
|
+
return { kind: "none" };
|
|
92
|
+
}
|
|
93
|
+
export function runNewCli(pkgDir, args, exec) {
|
|
94
|
+
return exec(process.execPath, [join(pkgDir, "bin", "ima2.js"), ...args], { inherit: true, shell: false }).status ?? 1;
|
|
95
|
+
}
|
|
96
|
+
export function serviceArtifactPath(platform = process.platform) {
|
|
97
|
+
return platform === "darwin" ? launchdPlistPath() : platform === "linux" ? systemdUnitPath() : null;
|
|
98
|
+
}
|
|
99
|
+
function serviceServerPath(text, platform) {
|
|
100
|
+
if (platform === "darwin") {
|
|
101
|
+
const array = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1];
|
|
102
|
+
const server = array ? Array.from(array.matchAll(/<string>([^<]*)<\/string>/g))[1]?.[1] : null;
|
|
103
|
+
return server?.replaceAll("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("'", "'").replaceAll("&", "&") ?? null;
|
|
104
|
+
}
|
|
105
|
+
const command = /^ExecStart=(.+)$/m.exec(text)?.[1];
|
|
106
|
+
const args = command?.match(/"[^"]*"|'[^']*'|\S+/g);
|
|
107
|
+
return args?.length === 2 ? args[1].replace(/^(["'])(.*)\1$/, "$2") : null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The config dir the artifact declares, or null when a declaration exists that the simple
|
|
111
|
+
* parser cannot read (quoted systemd assignments, escapes): ownership must then fail closed
|
|
112
|
+
* instead of falling back to the default ~/.ima2.
|
|
113
|
+
*/
|
|
114
|
+
function declaredConfigDir(text) {
|
|
115
|
+
const mentions = text.split("\n").filter((line) => line.includes("IMA2_CONFIG_DIR")).length;
|
|
116
|
+
const plain = /<key>IMA2_CONFIG_DIR<\/key>/.test(text) || /^Environment=IMA2_CONFIG_DIR=[^"'\\\s]*$/m.test(text);
|
|
117
|
+
if (mentions > 0 && (!plain || mentions > 1))
|
|
118
|
+
return null;
|
|
119
|
+
return parseServiceConfigDir(text);
|
|
120
|
+
}
|
|
121
|
+
function canonicalConfig(path, fs) {
|
|
122
|
+
try {
|
|
123
|
+
return fs.realpath(path);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return resolve(path);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function serviceOwned(pkgDir, configDir, opts = {}) {
|
|
130
|
+
const platform = opts.platform ?? process.platform;
|
|
131
|
+
const fs = opts.fs ?? defaultFs;
|
|
132
|
+
const artifact = serviceArtifactPath(platform);
|
|
133
|
+
if (!artifact || !fs.exists(artifact))
|
|
134
|
+
return false;
|
|
135
|
+
try {
|
|
136
|
+
const text = fs.readFile(artifact);
|
|
137
|
+
const server = serviceServerPath(text, platform);
|
|
138
|
+
if (server && isAbsolute(server) && basename(server) === "server.js") {
|
|
139
|
+
const inside = relative(fs.realpath(pkgDir), fs.realpath(server));
|
|
140
|
+
const declared = declaredConfigDir(text);
|
|
141
|
+
if (inside && inside !== ".." && !inside.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(inside)
|
|
142
|
+
&& declared !== null && canonicalConfig(declared, fs) === canonicalConfig(configDir, fs))
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch { /* An unreadable/unparseable artifact cannot prove ownership. */ }
|
|
147
|
+
(opts.log ?? console.error)("A login service exists but belongs to another install; not restarting it.");
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { claimUpdatedNotice, readVersionCache } from "../../lib/updateCache.js";
|
|
2
|
+
import { defaultTag, isNewer, releaseUrl } from "../../lib/updateVersion.js";
|
|
3
|
+
const NOTICE_COMMANDS = new Set(["serve", "start", "status", "open", "doctor", "ls", "ps"]);
|
|
4
|
+
const SILENT_FLAGS = new Set(["--json", "--quiet", "-q", "-h", "--help", "-v", "--version"]);
|
|
5
|
+
function eligible(input) {
|
|
6
|
+
return input.isTTY && input.env.IMA2_DISABLE_UPDATE_CHECK !== "1" && input.env.IMA2_DESKTOP !== "1"
|
|
7
|
+
&& NOTICE_COMMANDS.has(input.command ?? "") && !input.args.some((arg) => SILENT_FLAGS.has(arg));
|
|
8
|
+
}
|
|
9
|
+
export function updateNoticeLine(input) {
|
|
10
|
+
const { cache, current } = input;
|
|
11
|
+
if (!eligible(input) || cache.tag !== defaultTag(current) || !isNewer(cache.latest_version, current)
|
|
12
|
+
|| cache.dismissed_version === cache.latest_version)
|
|
13
|
+
return null;
|
|
14
|
+
const command = cache.tag === "preview" ? "ima2 update --tag preview" : "ima2 update";
|
|
15
|
+
return `Update available: v${cache.latest_version} (current v${current}). Run: ${command}`;
|
|
16
|
+
}
|
|
17
|
+
export function printUpdateNotice(command, args, current, deps = { isTTY: Boolean(process.stdout.isTTY), env: process.env,
|
|
18
|
+
readCache: readVersionCache, claim: claimUpdatedNotice, write: (line) => process.stderr.write(`${line}\n`) }) {
|
|
19
|
+
if (!eligible({ command, args, isTTY: deps.isTTY, env: deps.env }))
|
|
20
|
+
return;
|
|
21
|
+
try {
|
|
22
|
+
const line = updateNoticeLine({ command, args, current, cache: deps.readCache(), isTTY: deps.isTTY, env: deps.env });
|
|
23
|
+
if (line)
|
|
24
|
+
deps.write(line);
|
|
25
|
+
const updated = deps.claim(current);
|
|
26
|
+
if (updated)
|
|
27
|
+
deps.write(`ima2 updated to v${updated} — what's new: ${releaseUrl(updated)}`);
|
|
28
|
+
}
|
|
29
|
+
catch { /* Notices are best-effort and must not interrupt the requested command. */ }
|
|
30
|
+
}
|
package/config.js
CHANGED
|
@@ -112,6 +112,16 @@ export function defaultLogLevelForEnv(runtimeEnv = env) {
|
|
|
112
112
|
}
|
|
113
113
|
export const config = {
|
|
114
114
|
security: LAN_SECURITY_POLICY,
|
|
115
|
+
update: {
|
|
116
|
+
registry: (env.IMA2_NPM_REGISTRY || "https://registry.npmjs.org").replace(/\/+$/, ""),
|
|
117
|
+
freshMs: 20 * 60 * 60_000,
|
|
118
|
+
staleMs: 40 * 60 * 60_000,
|
|
119
|
+
tickMs: 60 * 60_000,
|
|
120
|
+
backoffMinMs: 60_000,
|
|
121
|
+
backoffMaxMs: 60 * 60_000,
|
|
122
|
+
timeoutMs: 8_000,
|
|
123
|
+
disabled: env.IMA2_DISABLE_UPDATE_CHECK === "1",
|
|
124
|
+
},
|
|
115
125
|
diagnostics: {
|
|
116
126
|
keyTimeoutMs: Math.min(30000, pickPositiveInt(env.IMA2_DIAGNOSTIC_KEY_TIMEOUT_MS, fileCfg.diagnostics?.keyTimeoutMs, 5000)),
|
|
117
127
|
runtimeTimeoutMs: Math.min(30000, pickPositiveInt(env.IMA2_DIAGNOSTIC_RUNTIME_TIMEOUT_MS, fileCfg.diagnostics?.runtimeTimeoutMs, 1500)),
|
package/docs/API.md
CHANGED
|
@@ -912,6 +912,17 @@ The studio's one-time star prompt stars the repository with the user's own `gh`
|
|
|
912
912
|
| `POST` | `/api/github/star` | Stars via `gh api -X PUT /user/starred/lidge-ai/ima2-gen` and marks prompted; `409 GH_UNAUTHENTICATED`, `502 GH_FAILED` |
|
|
913
913
|
| `POST` | `/api/github/star/dismiss` | Marks prompted without starring |
|
|
914
914
|
|
|
915
|
+
## Updates
|
|
916
|
+
|
|
917
|
+
npm installs check the registry for a newer ima2 while the server runs (at most once every 20 hours, hourly retries with backoff; preview installs follow the `preview` tag). State lives under the config dir: `version.json` (check results), `update-dismissed.json` and `update-seen/<version>` markers. Servers launched by the desktop app report `surface: "desktop"`, never check npm and never claim notices; the app updates itself. `IMA2_DISABLE_UPDATE_CHECK=1` stops the automatic check only.
|
|
918
|
+
|
|
919
|
+
| Method | Path | Result |
|
|
920
|
+
|---|---|---|
|
|
921
|
+
| `GET` | `/api/update/badge` | `{ surface, enabled, currentVersion, latestVersion, available, dismissed, stale, checkedAt, tag, command, releaseUrl, noticePending }` from the cache, no network. The web UI receives the same badge as an `update` frame when it opens `GET /api/events` (npm servers only, no `id`), then re-reads this route every 10 minutes and claims the notice only when `noticePending` |
|
|
922
|
+
| `POST` | `/api/update/check` | Forced registry check, then the badge; `503 UPDATE_CHECK_FAILED` with `{ message, badge }` on failure |
|
|
923
|
+
| `POST` | `/api/update/dismiss` | Body `{ version }`; hides that version's badge; `400 INVALID_VERSION` |
|
|
924
|
+
| `POST` | `/api/update/notice` | `{ updatedTo: string \| null }` — the one-time "updated to vX" claim (null on first run, downgrade or repeat) |
|
|
925
|
+
|
|
915
926
|
|
|
916
927
|
## Common Error Codes
|
|
917
928
|
|
|
@@ -1069,6 +1080,8 @@ Most server routes under `/api/*` have a CLI wrapper. The exception is **Agent M
|
|
|
1069
1080
|
| `POST /api/auth/switch` / `GET /api/auth/switch/:sessionId` | Web UI (Settings > QuotaCard > Switch Account) and `ima2 grok login` |
|
|
1070
1081
|
| `POST /api/oauth/restart` | `ima2 login`, `ima2 gpt login`, `ima2 gpt logout` |
|
|
1071
1082
|
| `GET /api/health` | `ima2 ping` |
|
|
1083
|
+
| `GET /api/update/badge` / `POST /api/update/check` | `ima2 update --check` (the CLI checks the registry itself and shares the cache) |
|
|
1084
|
+
| `POST /api/update/dismiss` / `POST /api/update/notice` | Web UI only (update popover and the one-time toast) |
|
|
1072
1085
|
| `GET /api/capabilities` | `ima2 capabilities` |
|
|
1073
1086
|
| `GET /api/config/grok-planner` | — (Grok planner model query) |
|
|
1074
1087
|
| `PATCH /api/config/grok-planner` | — (Grok planner model update) |
|
|
@@ -4,7 +4,7 @@ Generated by `npm run test:inventory` (script: `scripts/classify-tests.mjs`).
|
|
|
4
4
|
|
|
5
5
|
_Tests considered "runtime-importing" if they import from `../lib/`, `../routes/`, `../bin/`, `../server`, or `../config`._
|
|
6
6
|
|
|
7
|
-
Total:
|
|
7
|
+
Total: 541 (runtime: 246, contract: 295)
|
|
8
8
|
|
|
9
9
|
## Runtime-importing tests
|
|
10
10
|
- `tests/advertise-payload-contract.test.ts`
|
|
@@ -230,6 +230,9 @@ Total: 536 (runtime: 243, contract: 293)
|
|
|
230
230
|
- `tests/transparent-alpha-verification.test.ts`
|
|
231
231
|
- `tests/transparent-background-contract.test.ts`
|
|
232
232
|
- `tests/transparent-background-route.test.ts`
|
|
233
|
+
- `tests/update-cli.test.ts`
|
|
234
|
+
- `tests/update-core.test.ts`
|
|
235
|
+
- `tests/update-routes.test.ts`
|
|
233
236
|
- `tests/vectorize-image-contract.test.ts`
|
|
234
237
|
- `tests/video-chroma-key.test.ts`
|
|
235
238
|
- `tests/video-concat.test.ts`
|
|
@@ -366,6 +369,7 @@ Total: 536 (runtime: 243, contract: 293)
|
|
|
366
369
|
- `tests/desktop-takeover.test.ts`
|
|
367
370
|
- `tests/desktop-titlebar-contract.test.ts`
|
|
368
371
|
- `tests/desktop-tray.test.ts`
|
|
372
|
+
- `tests/desktop-update-state.test.ts`
|
|
369
373
|
- `tests/desktop-updater.test.ts`
|
|
370
374
|
- `tests/desktop-window-open.test.ts`
|
|
371
375
|
- `tests/direct-mode-visual-contract.test.ts`
|
|
@@ -532,6 +536,7 @@ Total: 536 (runtime: 243, contract: 293)
|
|
|
532
536
|
- `tests/ui-radius-scale-contract.test.ts`
|
|
533
537
|
- `tests/ui-touch-target-contract.test.ts`
|
|
534
538
|
- `tests/ui-typography-rules-contract.test.ts`
|
|
539
|
+
- `tests/update-ui-contract.test.ts`
|
|
535
540
|
- `tests/vectorize-cli-contract.test.ts`
|
|
536
541
|
- `tests/vectorize-panel-contract.test.ts`
|
|
537
542
|
- `tests/video-continuity-ui-contract.test.ts`
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
import { errInfo } from "./errInfo.js";
|
|
6
|
+
import { compareVersions, isNewer, isUpdateTag, parseVersion, releaseUrl } from "./updateVersion.js";
|
|
7
|
+
export function versionCachePath(configDir = config.storage.configDir) {
|
|
8
|
+
return join(configDir, "version.json");
|
|
9
|
+
}
|
|
10
|
+
function readObject(path) {
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {}; // Missing or corrupt update state has the same empty read view.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function highestSeen(path) {
|
|
20
|
+
try {
|
|
21
|
+
return readdirSync(join(dirname(path), "update-seen"), { withFileTypes: true })
|
|
22
|
+
.filter((entry) => entry.isFile() && parseVersion(entry.name) !== null)
|
|
23
|
+
.reduce((highest, entry) => highest === null || compareVersions(entry.name, highest) > 0 ? entry.name : highest, null);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null; // The marker directory does not exist on the first run.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function readVersionCache(path = versionCachePath()) {
|
|
30
|
+
const version = readObject(path);
|
|
31
|
+
const dismissed = readObject(join(dirname(path), "update-dismissed.json"));
|
|
32
|
+
return {
|
|
33
|
+
latest_version: typeof version.latest_version === "string" ? version.latest_version : null,
|
|
34
|
+
last_checked_at: typeof version.last_checked_at === "number" && Number.isFinite(version.last_checked_at) ? version.last_checked_at : null,
|
|
35
|
+
tag: isUpdateTag(version.tag) ? version.tag : null,
|
|
36
|
+
dismissed_version: typeof dismissed.dismissed_version === "string" ? dismissed.dismissed_version : null,
|
|
37
|
+
last_seen_version: highestSeen(path),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Windows refuses to replace a file another process has open (EPERM/EBUSY/EACCES) for a moment;
|
|
41
|
+
// concurrent writers and readers of version.json hit that window, so the rename retries briefly.
|
|
42
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
43
|
+
const RENAME_ATTEMPTS = 20;
|
|
44
|
+
const RENAME_RETRY_MS = 25;
|
|
45
|
+
function sleepSync(ms) {
|
|
46
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
47
|
+
}
|
|
48
|
+
function renameWithRetry(from, to) {
|
|
49
|
+
for (let attempt = 1;; attempt++) {
|
|
50
|
+
try {
|
|
51
|
+
renameSync(from, to);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (attempt >= RENAME_ATTEMPTS || !RENAME_RETRY_CODES.has(errInfo(error).code ?? ""))
|
|
56
|
+
throw error;
|
|
57
|
+
sleepSync(RENAME_RETRY_MS);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function writeOwner(path, value) {
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
63
|
+
const tmp = `${path}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
64
|
+
try {
|
|
65
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
|
66
|
+
renameWithRetry(tmp, path);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
try {
|
|
70
|
+
unlinkSync(tmp);
|
|
71
|
+
}
|
|
72
|
+
catch { /* best-effort removal after a failed atomic write */ }
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function createSeen(version, path) {
|
|
77
|
+
if (!parseVersion(version))
|
|
78
|
+
throw new Error("Invalid update version");
|
|
79
|
+
const dir = join(dirname(path), "update-seen");
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
try {
|
|
82
|
+
closeSync(openSync(join(dir, version), "wx", 0o600));
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (errInfo(error).code === "EEXIST")
|
|
87
|
+
return false;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Record the version installed by the CLI without claiming a UI notice. */
|
|
92
|
+
export function markSeen(version, path = versionCachePath()) {
|
|
93
|
+
createSeen(version, path);
|
|
94
|
+
}
|
|
95
|
+
export function updateVersionCache(patch, path = versionCachePath()) {
|
|
96
|
+
const fresh = readVersionCache(path);
|
|
97
|
+
const next = { ...fresh, ...patch };
|
|
98
|
+
if (["latest_version", "last_checked_at", "tag"].some((key) => Object.hasOwn(patch, key))) {
|
|
99
|
+
writeOwner(path, { latest_version: next.latest_version, last_checked_at: next.last_checked_at, tag: next.tag });
|
|
100
|
+
}
|
|
101
|
+
if (Object.hasOwn(patch, "dismissed_version")) {
|
|
102
|
+
writeOwner(join(dirname(path), "update-dismissed.json"), { dismissed_version: next.dismissed_version });
|
|
103
|
+
}
|
|
104
|
+
if (patch.last_seen_version !== undefined && patch.last_seen_version !== null)
|
|
105
|
+
markSeen(patch.last_seen_version, path);
|
|
106
|
+
return readVersionCache(path);
|
|
107
|
+
}
|
|
108
|
+
export function claimUpdatedNotice(current, path = versionCachePath()) {
|
|
109
|
+
const previous = highestSeen(path);
|
|
110
|
+
if (!createSeen(current, path))
|
|
111
|
+
return null;
|
|
112
|
+
return previous !== null && compareVersions(current, previous) > 0 ? current : null;
|
|
113
|
+
}
|
|
114
|
+
export function buildBadge(input) {
|
|
115
|
+
const { cache, current, tag, enabled, surface, now } = input;
|
|
116
|
+
const latest = cache.latest_version;
|
|
117
|
+
const dismissed = latest !== null && cache.dismissed_version === latest;
|
|
118
|
+
return {
|
|
119
|
+
surface, enabled: enabled && surface === "npm", currentVersion: current, latestVersion: latest,
|
|
120
|
+
available: surface === "npm" && cache.tag === tag && isNewer(latest, current) && !dismissed,
|
|
121
|
+
dismissed,
|
|
122
|
+
stale: cache.last_checked_at === null || cache.tag !== tag || now - cache.last_checked_at >= (input.staleMs ?? config.update.staleMs),
|
|
123
|
+
checkedAt: cache.last_checked_at, tag,
|
|
124
|
+
command: tag === "preview" ? "ima2 update --tag preview" : "ima2 update",
|
|
125
|
+
releaseUrl: latest !== null ? releaseUrl(latest) : null,
|
|
126
|
+
noticePending: surface === "npm" && cache.last_seen_version !== null && compareVersions(current, cache.last_seen_version) > 0,
|
|
127
|
+
};
|
|
128
|
+
}
|