@foxden-app/foxclaw 0.5.2 → 0.5.4
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 +20 -0
- package/dist/update.d.ts +20 -0
- package/dist/update.js +110 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.5.4 - 2026-06-04
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 修复 `/update` 遗留 `self-update.json` pending 后永久显示“升级已经在进行中”的问题:pending 超过 15 分钟会自动转为失败状态,Telegram 会回报失败,下一次 `/update` 可以重新发起。
|
|
9
|
+
- 这个补丁专门兜底 0.5.2 在 `KillMode=control-group` 下被杀掉的旧 updater,以及任何未来被外部中断、没有写完成状态的升级进程。
|
|
10
|
+
|
|
11
|
+
### English
|
|
12
|
+
- Fixed `/update` getting permanently stuck on "update already running" after a leftover `self-update.json` pending state: pending updates now expire as failed after 15 minutes, Telegram reports the failure, and the next `/update` can start again.
|
|
13
|
+
- This specifically recovers from the 0.5.2 updater killed by `KillMode=control-group`, and from any future updater interruption that fails to write a terminal status.
|
|
14
|
+
|
|
15
|
+
## 0.5.3 - 2026-06-04
|
|
16
|
+
|
|
17
|
+
### 中文
|
|
18
|
+
- 修复 0.5.2 中 `/update` 完成回报丢失的问题:Linux 自升级子进程现在通过独立的 user systemd transient service 运行,不再留在 `foxclaw.service` control group 里,因此 `KillMode=control-group` 重启服务时不会提前杀掉 updater。
|
|
19
|
+
- 保留 `KillMode=control-group` 的 app-server 清理能力,同时让升级进程能在服务重启后继续写入完成状态,由新服务启动后的轮询发送 Telegram 成功/失败回报。
|
|
20
|
+
|
|
21
|
+
### English
|
|
22
|
+
- Fixed missing `/update` completion reports in 0.5.2: on Linux the self-update worker now runs in a separate user systemd transient service instead of the `foxclaw.service` control group, so `KillMode=control-group` no longer kills the updater during service restart.
|
|
23
|
+
- Kept `KillMode=control-group` app-server cleanup while allowing the updater to write its final status after restart; the newly started service polls that status and sends the Telegram success/failure report.
|
|
24
|
+
|
|
5
25
|
## 0.5.2 - 2026-06-04
|
|
6
26
|
|
|
7
27
|
### 中文
|
package/dist/update.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AppLocale } from './types.js';
|
|
2
|
+
export declare const SELF_UPDATE_PENDING_TIMEOUT_MS: number;
|
|
2
3
|
export type SelfUpdateState = 'pending' | 'succeeded' | 'failed';
|
|
3
4
|
export interface SelfUpdateStatus {
|
|
4
5
|
state: SelfUpdateState;
|
|
@@ -31,6 +32,8 @@ interface CreateSelfUpdateRuntimeOptions {
|
|
|
31
32
|
statusPath: string;
|
|
32
33
|
logPath: string;
|
|
33
34
|
codexCliBin?: string;
|
|
35
|
+
pendingTimeoutMs?: number;
|
|
36
|
+
now?: () => Date;
|
|
34
37
|
}
|
|
35
38
|
interface PerformSelfUpdateOptions {
|
|
36
39
|
entryPoint: string;
|
|
@@ -40,6 +43,12 @@ interface PerformSelfUpdateOptions {
|
|
|
40
43
|
codexCliBin?: string;
|
|
41
44
|
env?: NodeJS.ProcessEnv;
|
|
42
45
|
}
|
|
46
|
+
export interface SelfUpdateLaunchCommand {
|
|
47
|
+
command: string;
|
|
48
|
+
args: string[];
|
|
49
|
+
env: NodeJS.ProcessEnv;
|
|
50
|
+
viaSystemdRun: boolean;
|
|
51
|
+
}
|
|
43
52
|
export interface SelfUpdateOutcome {
|
|
44
53
|
ok: boolean;
|
|
45
54
|
fromVersion: string;
|
|
@@ -53,5 +62,16 @@ export declare function resolveCodexUpdateInstaller(codexCliBin: string, nodePat
|
|
|
53
62
|
export declare function readSelfUpdateStatus(statusFile: string): SelfUpdateStatus | null;
|
|
54
63
|
export declare function writeSelfUpdateStatus(statusFile: string, status: SelfUpdateStatus): void;
|
|
55
64
|
export declare function createSelfUpdateRuntime(options: CreateSelfUpdateRuntimeOptions): SelfUpdateRuntime;
|
|
65
|
+
export declare function buildSelfUpdateLaunchCommand(options: {
|
|
66
|
+
entryPoint: string;
|
|
67
|
+
nodePath: string;
|
|
68
|
+
statusFile: string;
|
|
69
|
+
logPath: string;
|
|
70
|
+
codexCliBin?: string;
|
|
71
|
+
env?: NodeJS.ProcessEnv;
|
|
72
|
+
platform?: NodeJS.Platform;
|
|
73
|
+
systemdRunPath?: string | null;
|
|
74
|
+
unitName?: string;
|
|
75
|
+
}): SelfUpdateLaunchCommand;
|
|
56
76
|
export declare function performSelfUpdate(options: PerformSelfUpdateOptions): SelfUpdateOutcome;
|
|
57
77
|
export {};
|
package/dist/update.js
CHANGED
|
@@ -5,6 +5,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
5
5
|
const PACKAGE_SPEC = '@foxden-app/foxclaw@latest';
|
|
6
6
|
const CODEX_PACKAGE_SPEC = '@openai/codex@latest';
|
|
7
7
|
const UPDATE_STATUS_FILENAME = 'self-update.json';
|
|
8
|
+
export const SELF_UPDATE_PENDING_TIMEOUT_MS = 15 * 60_000;
|
|
8
9
|
export function selfUpdateStatusPath(statusPath) {
|
|
9
10
|
return path.join(path.dirname(statusPath), UPDATE_STATUS_FILENAME);
|
|
10
11
|
}
|
|
@@ -148,11 +149,33 @@ export function writeSelfUpdateStatus(statusFile, status) {
|
|
|
148
149
|
fs.writeFileSync(temporaryFile, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
149
150
|
fs.renameSync(temporaryFile, statusFile);
|
|
150
151
|
}
|
|
152
|
+
function readNormalizedSelfUpdateStatus(statusFile, pendingTimeoutMs, now) {
|
|
153
|
+
const status = readSelfUpdateStatus(statusFile);
|
|
154
|
+
if (!status || status.state !== 'pending' || pendingTimeoutMs <= 0) {
|
|
155
|
+
return status;
|
|
156
|
+
}
|
|
157
|
+
const updatedAtMs = Date.parse(status.updatedAt);
|
|
158
|
+
const nowMs = now().getTime();
|
|
159
|
+
const isStale = Number.isNaN(updatedAtMs) || nowMs - updatedAtMs > pendingTimeoutMs;
|
|
160
|
+
if (!isStale) {
|
|
161
|
+
return status;
|
|
162
|
+
}
|
|
163
|
+
const failed = {
|
|
164
|
+
...status,
|
|
165
|
+
state: 'failed',
|
|
166
|
+
error: `self-update timed out after ${Math.round(pendingTimeoutMs / 60_000)} minute(s) without writing a completion status; the previous updater may have been interrupted`,
|
|
167
|
+
updatedAt: new Date(nowMs).toISOString(),
|
|
168
|
+
};
|
|
169
|
+
writeSelfUpdateStatus(statusFile, failed);
|
|
170
|
+
return failed;
|
|
171
|
+
}
|
|
151
172
|
export function createSelfUpdateRuntime(options) {
|
|
152
173
|
const statusFile = selfUpdateStatusPath(options.statusPath);
|
|
174
|
+
const pendingTimeoutMs = options.pendingTimeoutMs ?? SELF_UPDATE_PENDING_TIMEOUT_MS;
|
|
175
|
+
const now = options.now ?? (() => new Date());
|
|
153
176
|
return {
|
|
154
177
|
async launch(scopeId, locale) {
|
|
155
|
-
const current =
|
|
178
|
+
const current = readNormalizedSelfUpdateStatus(statusFile, pendingTimeoutMs, now);
|
|
156
179
|
if (current?.state === 'pending') {
|
|
157
180
|
throw new Error('A FoxClaw update is already running.');
|
|
158
181
|
}
|
|
@@ -171,12 +194,33 @@ export function createSelfUpdateRuntime(options) {
|
|
|
171
194
|
fs.mkdirSync(path.dirname(options.logPath), { recursive: true });
|
|
172
195
|
const logFd = fs.openSync(options.logPath, 'a', 0o600);
|
|
173
196
|
try {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
197
|
+
const launch = buildSelfUpdateLaunchCommand({
|
|
198
|
+
entryPoint: options.entryPoint,
|
|
199
|
+
nodePath: options.nodePath,
|
|
200
|
+
statusFile,
|
|
201
|
+
logPath: options.logPath,
|
|
202
|
+
...(options.codexCliBin ? { codexCliBin: options.codexCliBin } : {}),
|
|
178
203
|
});
|
|
179
|
-
|
|
204
|
+
if (launch.viaSystemdRun) {
|
|
205
|
+
const result = spawnSync(launch.command, launch.args, {
|
|
206
|
+
stdio: ['ignore', logFd, logFd],
|
|
207
|
+
env: launch.env,
|
|
208
|
+
});
|
|
209
|
+
if (result.error) {
|
|
210
|
+
throw result.error;
|
|
211
|
+
}
|
|
212
|
+
if (result.status !== 0) {
|
|
213
|
+
throw new Error(`${launch.command} ${launch.args.join(' ')} exited with status ${result.status ?? 'unknown'}.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const child = spawn(launch.command, launch.args, {
|
|
218
|
+
detached: true,
|
|
219
|
+
stdio: ['ignore', logFd, logFd],
|
|
220
|
+
env: launch.env,
|
|
221
|
+
});
|
|
222
|
+
child.unref();
|
|
223
|
+
}
|
|
180
224
|
}
|
|
181
225
|
catch (error) {
|
|
182
226
|
writeSelfUpdateStatus(statusFile, {
|
|
@@ -198,13 +242,47 @@ export function createSelfUpdateRuntime(options) {
|
|
|
198
242
|
}
|
|
199
243
|
},
|
|
200
244
|
async readStatus() {
|
|
201
|
-
return
|
|
245
|
+
return readNormalizedSelfUpdateStatus(statusFile, pendingTimeoutMs, now);
|
|
202
246
|
},
|
|
203
247
|
async clearStatus() {
|
|
204
248
|
fs.rmSync(statusFile, { force: true });
|
|
205
249
|
},
|
|
206
250
|
};
|
|
207
251
|
}
|
|
252
|
+
export function buildSelfUpdateLaunchCommand(options) {
|
|
253
|
+
const env = options.codexCliBin
|
|
254
|
+
? { ...(options.env ?? process.env), CODEX_CLI_BIN: options.codexCliBin }
|
|
255
|
+
: { ...(options.env ?? process.env) };
|
|
256
|
+
const updateArgs = [options.entryPoint, 'update', '--notification-file', options.statusFile];
|
|
257
|
+
const platform = options.platform ?? process.platform;
|
|
258
|
+
const systemdRunPath = options.systemdRunPath === undefined
|
|
259
|
+
? resolveCommand('systemd-run', env)
|
|
260
|
+
: options.systemdRunPath;
|
|
261
|
+
if (platform === 'linux' && systemdRunPath) {
|
|
262
|
+
const unitName = options.unitName ?? `foxclaw-update-${process.pid}-${Date.now()}`;
|
|
263
|
+
return {
|
|
264
|
+
command: systemdRunPath,
|
|
265
|
+
args: [
|
|
266
|
+
'--user',
|
|
267
|
+
'--collect',
|
|
268
|
+
`--unit=${unitName}`,
|
|
269
|
+
`--property=StandardOutput=append:${options.logPath}`,
|
|
270
|
+
`--property=StandardError=append:${options.logPath}`,
|
|
271
|
+
...systemdSetEnvArgs(env),
|
|
272
|
+
options.nodePath,
|
|
273
|
+
...updateArgs,
|
|
274
|
+
],
|
|
275
|
+
env,
|
|
276
|
+
viaSystemdRun: true,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
command: options.nodePath,
|
|
281
|
+
args: updateArgs,
|
|
282
|
+
env,
|
|
283
|
+
viaSystemdRun: false,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
208
286
|
export function performSelfUpdate(options) {
|
|
209
287
|
const env = options.env ?? process.env;
|
|
210
288
|
let toVersion = null;
|
|
@@ -295,6 +373,31 @@ function executableCandidates(commandName, nodePath, env, preferred = []) {
|
|
|
295
373
|
...(env.PATH || '').split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, commandName)),
|
|
296
374
|
].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);
|
|
297
375
|
}
|
|
376
|
+
function resolveCommand(commandName, env) {
|
|
377
|
+
for (const directory of (env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
378
|
+
const candidate = path.join(directory, commandName);
|
|
379
|
+
if (fs.existsSync(candidate)) {
|
|
380
|
+
return candidate;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const fallback of ['/usr/bin', '/bin', '/usr/local/bin']) {
|
|
384
|
+
const candidate = path.join(fallback, commandName);
|
|
385
|
+
if (fs.existsSync(candidate)) {
|
|
386
|
+
return candidate;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
function systemdSetEnvArgs(env) {
|
|
392
|
+
return Object.entries(env)
|
|
393
|
+
.filter((entry) => {
|
|
394
|
+
const [key, value] = entry;
|
|
395
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
|
|
396
|
+
&& typeof value === 'string'
|
|
397
|
+
&& !value.includes('\0');
|
|
398
|
+
})
|
|
399
|
+
.map(([key, value]) => `--setenv=${key}=${value}`);
|
|
400
|
+
}
|
|
298
401
|
function buildInstallerEnv(entryPoint, installer, env) {
|
|
299
402
|
const pnpmHome = installer.manager === 'pnpm'
|
|
300
403
|
? installer.pnpmHome ?? inferPnpmHomeFromEntryPoint(entryPoint)
|