@harness-mix/cli 0.2.3 → 0.2.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 +13 -0
- package/README.md +469 -467
- package/output/native-build/desktop-controller.mjs +1 -1
- package/output/native-build/renderer-extension.js +23 -4
- package/package.json +11 -9
- package/scripts/antigravity-adapter-test.cjs +647 -626
- package/scripts/codex-adapter-test.cjs +162 -127
- package/scripts/collaboration-test.cjs +274 -262
- package/scripts/jsonl-stdin-test.cjs +40 -31
- package/scripts/kiro-cursor-adapters-test.cjs +124 -100
- package/scripts/native-acp-depth-test.cjs +30 -5
- package/scripts/native-update-apply-test.cjs +269 -215
- package/scripts/native-update.cjs +78 -0
- package/scripts/native-vendor-adapters-test.cjs +196 -154
- package/scripts/salvage-rollout-writes.cjs +72 -0
- package/scripts/zcode-adapter-test.cjs +329 -0
- package/scripts/zcode-live-probe.cjs +66 -0
- package/src/main/adapters/antigravity.js +1428 -1418
- package/src/main/adapters/codex.js +656 -649
- package/src/main/adapters/native-acp-command.js +51 -48
- package/src/main/adapters/native-acp.js +47 -12
- package/src/main/adapters/qoder.js +12 -8
- package/src/main/adapters/zcode.js +921 -10
- package/src/main/host/collaboration.js +723 -715
- package/src/main/host/jsonl.js +130 -120
- package/src/main/native/config.js +9 -9
- package/src/main/native/launcher.js +252 -237
- package/src/main/native/process-utils.js +157 -57
- package/src/main/native/protocol.js +1221 -1187
- package/src/main/native/update-state.js +123 -110
- package/src/main/native/updater.js +460 -394
- package/src/native-ui/desktop-control/src/renderer-cdp-control-session.ts +358 -358
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3211 -3181
- package/src/native-ui/renderer-extension/src/settings/connections-page.ts +2 -2
|
@@ -1,394 +1,460 @@
|
|
|
1
|
-
// Harness Mix auto-update.
|
|
2
|
-
// - git channel: fast-forward the local checkout from its upstream, reinstall
|
|
3
|
-
// dependencies and rebuild native artifacts when their inputs changed.
|
|
4
|
-
// - npm channel: compare the installed version with the registry and apply via
|
|
5
|
-
// `npm install -g`, with lock, retries, pending and crash-loop rollback.
|
|
6
|
-
// - State/lock/semver live in ./update-state. Failures never block launch.
|
|
7
|
-
const { execFileSync, spawn, spawnSync } = require('node:child_process');
|
|
8
|
-
const path = require('node:path');
|
|
9
|
-
const fs = require('node:fs');
|
|
10
|
-
const {
|
|
11
|
-
acquireLock, compareVersions, detectChannel, readState, writeState,
|
|
12
|
-
} = require('./update-state');
|
|
13
|
-
|
|
14
|
-
function makeGit(root, exec = spawnSync) {
|
|
15
|
-
return (args, timeout = 20000) => {
|
|
16
|
-
const result = exec('git', args, {
|
|
17
|
-
cwd: root,
|
|
18
|
-
encoding: 'utf8',
|
|
19
|
-
timeout,
|
|
20
|
-
windowsHide: true,
|
|
21
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
22
|
-
});
|
|
23
|
-
if (result.error) throw result.error;
|
|
24
|
-
if (result.status !== 0) throw new Error(String(result.stderr || `git ${args[0]} failed`).trim());
|
|
25
|
-
return String(result.stdout).trim();
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
git(['
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
1
|
+
// Harness Mix auto-update.
|
|
2
|
+
// - git channel: fast-forward the local checkout from its upstream, reinstall
|
|
3
|
+
// dependencies and rebuild native artifacts when their inputs changed.
|
|
4
|
+
// - npm channel: compare the installed version with the registry and apply via
|
|
5
|
+
// `npm install -g`, with lock, retries, pending and crash-loop rollback.
|
|
6
|
+
// - State/lock/semver live in ./update-state. Failures never block launch.
|
|
7
|
+
const { execFileSync, spawn, spawnSync } = require('node:child_process');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const {
|
|
11
|
+
acquireLock, compareVersions, detectChannel, readState, writeState,
|
|
12
|
+
} = require('./update-state');
|
|
13
|
+
|
|
14
|
+
function makeGit(root, exec = spawnSync) {
|
|
15
|
+
return (args, timeout = 20000) => {
|
|
16
|
+
const result = exec('git', args, {
|
|
17
|
+
cwd: root,
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
timeout,
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
22
|
+
});
|
|
23
|
+
if (result.error) throw result.error;
|
|
24
|
+
if (result.status !== 0) throw new Error(String(result.stderr || `git ${args[0]} failed`).trim());
|
|
25
|
+
return String(result.stdout).trim();
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// spawn-based drop-in for spawnSync with the same { error, status, stdout, stderr }
|
|
30
|
+
// result shape. Host-side callers must never block their event loop on git/npm,
|
|
31
|
+
// so they hand this to makeGit()/resolveRegistry() instead of spawnSync.
|
|
32
|
+
function asyncRun() {
|
|
33
|
+
return (cmd, args, options = {}) => new Promise(resolve => {
|
|
34
|
+
const child = spawn(cmd, args, { ...options, windowsHide: true });
|
|
35
|
+
let stdout = '';
|
|
36
|
+
let stderr = '';
|
|
37
|
+
child.stdout?.on('data', chunk => { stdout += String(chunk); });
|
|
38
|
+
child.stderr?.on('data', chunk => { stderr += String(chunk); });
|
|
39
|
+
child.on('error', error => resolve({ error, stdout, stderr, status: null }));
|
|
40
|
+
child.on('close', status => resolve({ error: null, stdout, stderr, status }));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// remoteState classifies the checkout relative to its upstream:
|
|
45
|
+
// current | ahead | diverged | dirty | available (fast-forward possible).
|
|
46
|
+
async function remoteState(root, git = makeGit(root)) {
|
|
47
|
+
const head = await git(['rev-parse', 'HEAD']);
|
|
48
|
+
let upstream = 'origin/main';
|
|
49
|
+
try {
|
|
50
|
+
upstream = await git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
51
|
+
} catch { /* no upstream configured: fall back to origin/main */ }
|
|
52
|
+
// The refspec must stay a single argument: splitting `origin/release/1.2`
|
|
53
|
+
// on '/' would make git fetch two unrelated refs.
|
|
54
|
+
const [remoteName, ...ref] = upstream.split('/');
|
|
55
|
+
await git(['fetch', '--quiet', remoteName, ref.join('/')], 30000);
|
|
56
|
+
const remote = await git(['rev-parse', upstream]);
|
|
57
|
+
if (remote === head) return { state: 'current', head, remote, upstream };
|
|
58
|
+
const base = await git(['merge-base', 'HEAD', upstream]);
|
|
59
|
+
if (base === remote) return { state: 'ahead', head, remote, upstream };
|
|
60
|
+
if (base !== head) return { state: 'diverged', head, remote, upstream };
|
|
61
|
+
const dirty = (await git(['status', '--porcelain', '--untracked-files=no'])).length > 0;
|
|
62
|
+
if (dirty) return { state: 'dirty', head, remote, upstream };
|
|
63
|
+
return { state: 'available', head, remote, upstream };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Child output must never be inherited: in the host/helper context stdout may
|
|
67
|
+
// be a protocol channel, and even at the launcher it interleaves with progress
|
|
68
|
+
// lines. stdio is piped and only the failure tail is surfaced.
|
|
69
|
+
function hookErrorDetail(error) {
|
|
70
|
+
const stderr = String((error && error.stderr) || '').trim();
|
|
71
|
+
const tail = stderr ? stderr.split('\n').slice(-3).join(' | ') : (error && error.message) || String(error);
|
|
72
|
+
return tail.slice(0, 300);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function defaultHooks(root) {
|
|
76
|
+
return {
|
|
77
|
+
install() {
|
|
78
|
+
try {
|
|
79
|
+
execFileSync(npmCommand(), ['install'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new Error(`npm install 失败:${hookErrorDetail(error)}`);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
build() {
|
|
85
|
+
try {
|
|
86
|
+
execFileSync(process.execPath, [path.join(root, 'scripts/build-native.cjs')], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new Error(`build:native 失败:${hookErrorDetail(error)}`);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function applyUpdate(root, state, git = makeGit(root), hooks = defaultHooks(root)) {
|
|
95
|
+
await git(['merge', '--ff-only', state.upstream]);
|
|
96
|
+
const changed = (await git(['diff', '--name-only', state.head, state.remote])).split('\n').filter(Boolean);
|
|
97
|
+
const install = changed.some(file => file === 'package.json' || file === 'package-lock.json');
|
|
98
|
+
const rebuild = install || changed.some(file => file.startsWith('src/') || file.startsWith('scripts/'));
|
|
99
|
+
try {
|
|
100
|
+
if (install) await hooks.install();
|
|
101
|
+
if (rebuild) await hooks.build();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// A failed install/build would leave a half-updated tree behind; restore it.
|
|
104
|
+
try { git(['reset', '--hard', state.head]); } catch { /* keep the original failure */ }
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
updated: true,
|
|
109
|
+
from: state.head.slice(0, 8),
|
|
110
|
+
to: state.remote.slice(0, 8),
|
|
111
|
+
changed: changed.length,
|
|
112
|
+
installed: install,
|
|
113
|
+
rebuilt: rebuild,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function autoUpdate({ root, log = console.log, exec, hooks } = {}) {
|
|
118
|
+
const git = makeGit(root, exec);
|
|
119
|
+
let state;
|
|
120
|
+
try {
|
|
121
|
+
state = await remoteState(root, git);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log(`[Harness Mix] 自动更新检查失败(不影响启动):${error.message}`);
|
|
124
|
+
return { updated: false, failed: true };
|
|
125
|
+
}
|
|
126
|
+
switch (state.state) {
|
|
127
|
+
case 'current':
|
|
128
|
+
log('[Harness Mix] 已是最新版本');
|
|
129
|
+
return { updated: false, state: state.state };
|
|
130
|
+
case 'ahead':
|
|
131
|
+
log('[Harness Mix] 本地提交领先远端,跳过自动更新');
|
|
132
|
+
return { updated: false, state: state.state };
|
|
133
|
+
case 'diverged':
|
|
134
|
+
log('[Harness Mix] 本地与远端已分叉,请手动处理后重启,跳过自动更新');
|
|
135
|
+
return { updated: false, state: state.state };
|
|
136
|
+
case 'dirty':
|
|
137
|
+
log('[Harness Mix] 工作区存在未提交改动,跳过自动更新');
|
|
138
|
+
return { updated: false, state: state.state };
|
|
139
|
+
default: {
|
|
140
|
+
log(`[Harness Mix] 发现新版本 ${state.head.slice(0, 8)} → ${state.remote.slice(0, 8)},正在更新…`);
|
|
141
|
+
let outcome;
|
|
142
|
+
try {
|
|
143
|
+
outcome = await applyUpdate(root, state, git, hooks || defaultHooks(root));
|
|
144
|
+
} catch (error) {
|
|
145
|
+
log(`[Harness Mix] 更新失败并已回退(不影响启动):${error.message}`);
|
|
146
|
+
return { updated: false, failed: true };
|
|
147
|
+
}
|
|
148
|
+
log(`[Harness Mix] 更新完成:${outcome.changed} 个文件` +
|
|
149
|
+
`${outcome.installed ? ',依赖已重装' : ''}${outcome.rebuilt ? ',原生组件已重建' : ''}`);
|
|
150
|
+
return outcome;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ---------------- npm channel ---------------- */
|
|
156
|
+
|
|
157
|
+
const REGISTRY_CACHE_FILE = 'update-registry-cache.json';
|
|
158
|
+
|
|
159
|
+
function readJson(file) {
|
|
160
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function npmCommand() {
|
|
164
|
+
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function resolveRegistry({ env = process.env, run = spawnSync } = {}) {
|
|
168
|
+
if (env.HARNESS_MIX_UPDATE_REGISTRY) return env.HARNESS_MIX_UPDATE_REGISTRY;
|
|
169
|
+
if (env.npm_config_registry) return env.npm_config_registry;
|
|
170
|
+
const result = await run(npmCommand(), ['config', 'get', 'registry'], { encoding: 'utf8', windowsHide: true, timeout: 10000 });
|
|
171
|
+
const value = result && result.status === 0 ? String(result.stdout || '').trim() : '';
|
|
172
|
+
return value && value !== 'undefined' && value !== 'null' ? value : 'https://registry.npmjs.org/';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function fetchLatestVersion({ registry, dataDir, fetchImpl = fetch, timeoutMs = 5000 } = {}) {
|
|
176
|
+
const url = `${String(registry).replace(/\/+$/, '')}/harness-mix`;
|
|
177
|
+
const cacheFile = dataDir ? path.join(dataDir, REGISTRY_CACHE_FILE) : null;
|
|
178
|
+
const cache = cacheFile ? readJson(cacheFile) : null;
|
|
179
|
+
const headers = { accept: 'application/vnd.npm.install-v1+json' };
|
|
180
|
+
if (cache && cache.etag) headers['if-none-match'] = cache.etag;
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
183
|
+
try {
|
|
184
|
+
const response = await fetchImpl(url, { headers, signal: controller.signal });
|
|
185
|
+
if (response.status === 304 && cache && cache.version) return cache.version;
|
|
186
|
+
if (!response.ok) throw new Error(`registry HTTP ${response.status}`);
|
|
187
|
+
const body = await response.json();
|
|
188
|
+
const version = body && body['dist-tags'] && body['dist-tags'].latest;
|
|
189
|
+
if (!version) throw new Error('registry response has no dist-tags.latest');
|
|
190
|
+
if (cacheFile) {
|
|
191
|
+
try {
|
|
192
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
193
|
+
fs.writeFileSync(cacheFile, JSON.stringify({ etag: response.headers && response.headers.get ? response.headers.get('etag') : null, version, at: Date.now() }));
|
|
194
|
+
} catch { /* cache is best-effort */ }
|
|
195
|
+
}
|
|
196
|
+
return version;
|
|
197
|
+
} finally {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function npmGlobalRoot({ run = spawnSync } = {}) {
|
|
203
|
+
const result = run(npmCommand(), ['root', '-g'], { encoding: 'utf8', windowsHide: true, timeout: 10000 });
|
|
204
|
+
return result && result.status === 0 ? String(result.stdout || '').trim() : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isGlobalInstall(root, deps = {}) {
|
|
208
|
+
const globalRoot = npmGlobalRoot(deps);
|
|
209
|
+
if (!globalRoot) return false;
|
|
210
|
+
const normalize = value => path.resolve(value).toLowerCase().replace(/[\\/]+$/, '');
|
|
211
|
+
return normalize(root).startsWith(normalize(globalRoot));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function npmInstallGlobal(version, { run = spawnSync, log = () => {}, delay = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) {
|
|
215
|
+
const args = ['install', '-g', `harness-mix@${version}`, '--no-audit', '--no-fund'];
|
|
216
|
+
let last = '';
|
|
217
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
218
|
+
const result = run(npmCommand(), args, { encoding: 'utf8', windowsHide: true, timeout: 180000, env: { ...process.env, npm_config_update_notifier: 'false' } });
|
|
219
|
+
if (result && result.status === 0) return { ok: true, attempts: attempt };
|
|
220
|
+
last = result ? String(result.error || result.stderr || `exit ${result.status}`).trim() : 'npm run failed';
|
|
221
|
+
log(`[Harness Mix] npm 安装失败(第 ${attempt}/3 次):${last.split('\n').pop()}`);
|
|
222
|
+
if (attempt < 3) await delay(2000);
|
|
223
|
+
}
|
|
224
|
+
return { ok: false, attempts: 3, error: last };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Runs `scripts/launch-codex.cjs` from the (possibly just updated) checkout and
|
|
228
|
+
// forwards its exit code. HARNESS_MIX_UPDATED guards against update loops.
|
|
229
|
+
function reexecLauncher(root, args = []) {
|
|
230
|
+
return new Promise(resolve => {
|
|
231
|
+
const child = spawn(process.execPath, [path.join(root, 'scripts', 'launch-codex.cjs'), ...args], {
|
|
232
|
+
env: { ...process.env, HARNESS_MIX_UPDATED: '1' },
|
|
233
|
+
stdio: 'inherit',
|
|
234
|
+
windowsHide: true,
|
|
235
|
+
});
|
|
236
|
+
child.on('error', () => resolve(1));
|
|
237
|
+
child.on('exit', code => resolve(code === null ? 0 : code));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* ---------------- orchestration ---------------- */
|
|
242
|
+
|
|
243
|
+
// One launch-time update pass. Returns:
|
|
244
|
+
// { updated, restartRequired, repaired, rolledBack, pending, failed, ... }
|
|
245
|
+
// restartRequired tells the caller to re-exec the launcher so new code loads.
|
|
246
|
+
// `hooks` lets the desktop-triggered helper inject lock-aware build handling;
|
|
247
|
+
// `completePendingBuild` (launcher boot path) finishes a build the helper had
|
|
248
|
+
// to defer because the running Desktop held the native binaries locked.
|
|
249
|
+
async function runUpdateFlow({ root, dataDir, log = console.log, stopDesktop = async () => {}, mode = 'apply', hooks = null, completePendingBuild = false, deps = {} } = {}) {
|
|
250
|
+
const {
|
|
251
|
+
gitExec,
|
|
252
|
+
run = spawnSync,
|
|
253
|
+
fetchImpl = fetch,
|
|
254
|
+
delay = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
255
|
+
env = process.env,
|
|
256
|
+
} = deps;
|
|
257
|
+
const halt = async () => { await stopDesktop(); await delay(3000); };
|
|
258
|
+
const flowHooks = () => hooks || defaultHooks(root);
|
|
259
|
+
|
|
260
|
+
const currentVersion = (readJson(path.join(root, 'package.json')) || {}).version || '0.0.0';
|
|
261
|
+
const channel = detectChannel(root);
|
|
262
|
+
let state = readState(dataDir);
|
|
263
|
+
|
|
264
|
+
if (channel === 'portable') {
|
|
265
|
+
log('[Harness Mix] 当前安装形态不支持自动更新(portable 通道未实现)');
|
|
266
|
+
return { updated: false, channel };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// A version the user installed by hand is not ours to track.
|
|
270
|
+
if (channel === 'npm' && state.appliedVersion && state.appliedVersion !== currentVersion && state.prevVersion !== currentVersion) {
|
|
271
|
+
state = writeState(dataDir, { ...state, phase: 'idle', appliedVersion: null, prevVersion: null, attempts: 0, pendingVersion: null, pendingBuild: null });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let lockBusy = false;
|
|
275
|
+
async function mutate(fn) {
|
|
276
|
+
let release;
|
|
277
|
+
try {
|
|
278
|
+
release = acquireLock(dataDir, 'update');
|
|
279
|
+
} catch (error) {
|
|
280
|
+
lockBusy = true;
|
|
281
|
+
log(`[Harness Mix] 跳过更新:${error.message}`);
|
|
282
|
+
return { skipped: true };
|
|
283
|
+
}
|
|
284
|
+
try { return { value: await fn() }; } finally { release(); }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 1) Repair an apply that was interrupted by a crash or a hard kill.
|
|
288
|
+
if (state.phase === 'applying') {
|
|
289
|
+
log('[Harness Mix] 检测到上次更新中断,正在修复…');
|
|
290
|
+
const repaired = await mutate(async () => {
|
|
291
|
+
if (state.channel === 'npm') {
|
|
292
|
+
if (state.pendingVersion && state.pendingVersion === currentVersion) {
|
|
293
|
+
// The install actually completed before the crash: keep it, fix bookkeeping.
|
|
294
|
+
writeState(dataDir, { ...readState(dataDir), phase: 'idle', pendingVersion: null, appliedVersion: state.pendingVersion, prevVersion: state.appliedVersion, appliedAt: Date.now() });
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
await halt();
|
|
298
|
+
const target = state.appliedVersion || state.prevVersion;
|
|
299
|
+
if (target) {
|
|
300
|
+
const result = await npmInstallGlobal(target, { run, log, delay });
|
|
301
|
+
if (!result.ok) return false;
|
|
302
|
+
}
|
|
303
|
+
} else if (state.channel === 'git' && state.preUpdateHead) {
|
|
304
|
+
await halt();
|
|
305
|
+
makeGit(root, gitExec)(['reset', '--hard', state.preUpdateHead]);
|
|
306
|
+
// Sources are back to the old head: rebuild so the binaries match it.
|
|
307
|
+
await flowHooks().build();
|
|
308
|
+
}
|
|
309
|
+
writeState(dataDir, { ...readState(dataDir), phase: 'idle', pendingVersion: null, pendingBuild: null });
|
|
310
|
+
return true;
|
|
311
|
+
});
|
|
312
|
+
if (repaired.value) return { updated: false, repaired: true, restartRequired: true };
|
|
313
|
+
if (repaired.skipped) return { updated: false, failed: true, reason: 'lock' };
|
|
314
|
+
return { updated: false, failed: true };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 1.5) Finish a build the desktop-triggered helper deferred because the
|
|
318
|
+
// running Desktop held the native binaries locked (launcher boot path: the
|
|
319
|
+
// desktop is stopped via halt() first, so the files are writable again).
|
|
320
|
+
if (state.pendingBuild && completePendingBuild) {
|
|
321
|
+
log('[Harness Mix] 正在完成上次推迟的原生构建…');
|
|
322
|
+
const completed = await mutate(async () => {
|
|
323
|
+
await halt();
|
|
324
|
+
try {
|
|
325
|
+
await flowHooks().build();
|
|
326
|
+
} catch (error) {
|
|
327
|
+
log(`[Harness Mix] 补完成构建失败,回退到更新前版本:${error.message}`);
|
|
328
|
+
if (state.channel === 'git' && state.preUpdateHead) makeGit(root, gitExec)(['reset', '--hard', state.preUpdateHead]);
|
|
329
|
+
writeState(dataDir, { ...readState(dataDir), pendingBuild: null, appliedVersion: null, prevVersion: null, attempts: 0 });
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
writeState(dataDir, { ...readState(dataDir), pendingBuild: null });
|
|
333
|
+
return true;
|
|
334
|
+
});
|
|
335
|
+
if (completed.value) return { updated: false, repaired: true };
|
|
336
|
+
if (completed.skipped) return { updated: false, failed: true, reason: 'lock' };
|
|
337
|
+
return { updated: false, failed: true };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 2) Boot bookkeeping and crash-loop rollback.
|
|
341
|
+
if (state.appliedVersion && state.lastBootOkAt < state.appliedAt) {
|
|
342
|
+
state = writeState(dataDir, { ...state, attempts: state.attempts + 1 });
|
|
343
|
+
if (state.attempts >= 2 && state.prevVersion) {
|
|
344
|
+
log(`[Harness Mix] ${state.appliedVersion} 连续两次启动未成功,自动回滚到 ${state.prevVersion}…`);
|
|
345
|
+
const rolledBack = await mutate(async () => {
|
|
346
|
+
await halt();
|
|
347
|
+
if (state.channel === 'npm') {
|
|
348
|
+
const result = await npmInstallGlobal(state.prevVersion, { run, log, delay });
|
|
349
|
+
if (!result.ok) return false;
|
|
350
|
+
} else if (state.channel === 'git' && state.preUpdateHead) {
|
|
351
|
+
makeGit(root, gitExec)(['reset', '--hard', state.preUpdateHead]);
|
|
352
|
+
await flowHooks().build();
|
|
353
|
+
}
|
|
354
|
+
writeState(dataDir, { ...readState(dataDir), phase: 'idle', appliedVersion: null, prevVersion: null, attempts: 0, pendingVersion: null, pendingBuild: null, rolledBackAt: Date.now() });
|
|
355
|
+
return true;
|
|
356
|
+
});
|
|
357
|
+
if (rolledBack.value) {
|
|
358
|
+
log(`[Harness Mix] 已回滚到 ${state.prevVersion};如问题持续请运行 npm run diagnostics 反馈。`);
|
|
359
|
+
return { updated: false, rolledBack: true, restartRequired: true };
|
|
360
|
+
}
|
|
361
|
+
return { updated: false, failed: true, reason: 'lock' };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 3a) git channel.
|
|
366
|
+
if (channel === 'git') {
|
|
367
|
+
let remote;
|
|
368
|
+
try {
|
|
369
|
+
remote = await remoteState(root, makeGit(root, gitExec));
|
|
370
|
+
} catch (error) {
|
|
371
|
+
log(`[Harness Mix] 自动更新检查失败(不影响启动):${error.message}`);
|
|
372
|
+
return { updated: false, failed: true };
|
|
373
|
+
}
|
|
374
|
+
if (remote.state !== 'available') {
|
|
375
|
+
const messages = {
|
|
376
|
+
current: '[Harness Mix] 已是最新版本',
|
|
377
|
+
ahead: '[Harness Mix] 本地提交领先远端,跳过自动更新',
|
|
378
|
+
diverged: '[Harness Mix] 本地与远端已分叉,请手动处理后重启,跳过自动更新',
|
|
379
|
+
dirty: '[Harness Mix] 工作区存在未提交改动,跳过自动更新',
|
|
380
|
+
};
|
|
381
|
+
if (messages[remote.state]) log(messages[remote.state]);
|
|
382
|
+
return { updated: false, state: remote.state };
|
|
383
|
+
}
|
|
384
|
+
if (mode === 'check') return { updated: false, available: true, to: remote.remote.slice(0, 8) };
|
|
385
|
+
log(`[Harness Mix] 发现新版本 ${remote.head.slice(0, 8)} → ${remote.remote.slice(0, 8)},正在更新…`);
|
|
386
|
+
const applied = await mutate(async () => {
|
|
387
|
+
await halt();
|
|
388
|
+
writeState(dataDir, { ...readState(dataDir), channel: 'git', phase: 'applying', preUpdateHead: remote.head });
|
|
389
|
+
let outcome = null;
|
|
390
|
+
let failure = null;
|
|
391
|
+
try {
|
|
392
|
+
outcome = await applyUpdate(root, remote, makeGit(root, gitExec), flowHooks());
|
|
393
|
+
} catch (error) {
|
|
394
|
+
failure = error;
|
|
395
|
+
} finally {
|
|
396
|
+
writeState(dataDir, { ...readState(dataDir), phase: 'idle' });
|
|
397
|
+
}
|
|
398
|
+
if (failure) {
|
|
399
|
+
log(`[Harness Mix] 更新失败并已回退(不影响启动):${failure.message}`);
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
writeState(dataDir, { ...readState(dataDir), channel: 'git', appliedVersion: remote.remote.slice(0, 8), prevVersion: remote.head.slice(0, 8), appliedAt: Date.now(), pendingVersion: null });
|
|
403
|
+
log(`[Harness Mix] 更新完成:${outcome.changed} 个文件` +
|
|
404
|
+
`${outcome.installed ? ',依赖已重装' : ''}${outcome.rebuilt ? ',原生组件已重建' : ''}`);
|
|
405
|
+
return outcome;
|
|
406
|
+
});
|
|
407
|
+
if (applied.skipped) return { updated: false, failed: true, reason: 'lock' };
|
|
408
|
+
if (applied.value) return { ...applied.value, restartRequired: true };
|
|
409
|
+
return { updated: false, failed: true };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// 3b) npm channel.
|
|
413
|
+
if (!isGlobalInstall(root, { run })) {
|
|
414
|
+
log('[Harness Mix] 当前不是 npm 全局安装,跳过自动更新;可手动运行 npm install -g harness-mix@latest');
|
|
415
|
+
return { updated: false, reason: 'not-global' };
|
|
416
|
+
}
|
|
417
|
+
let latest = null;
|
|
418
|
+
try {
|
|
419
|
+
latest = await fetchLatestVersion({ registry: await resolveRegistry({ env, run }), dataDir, fetchImpl });
|
|
420
|
+
} catch (error) {
|
|
421
|
+
log(`[Harness Mix] 自动更新检查失败(不影响启动):${error.message}`);
|
|
422
|
+
return { updated: false, failed: true };
|
|
423
|
+
}
|
|
424
|
+
if (!latest) return { updated: false, state: 'current' };
|
|
425
|
+
if (!env.HARNESS_MIX_UPDATE_PRERELEASE && String(latest).includes('-') && !currentVersion.includes('-')) {
|
|
426
|
+
log('[Harness Mix] 远端仅有预发布版本,已跳过(设置 HARNESS_MIX_UPDATE_PRERELEASE=1 可启用)');
|
|
427
|
+
return { updated: false, state: 'current' };
|
|
428
|
+
}
|
|
429
|
+
const target = state.pendingVersion || latest;
|
|
430
|
+
if (compareVersions(target, currentVersion) <= 0 && !state.pendingVersion) {
|
|
431
|
+
log('[Harness Mix] 已是最新版本');
|
|
432
|
+
return { updated: false, state: 'current' };
|
|
433
|
+
}
|
|
434
|
+
if (mode === 'check') return { updated: false, available: true, to: target };
|
|
435
|
+
log(`[Harness Mix] 发现新版本 ${currentVersion} → ${target},正在更新…`);
|
|
436
|
+
const applied = await mutate(async () => {
|
|
437
|
+
await halt();
|
|
438
|
+
writeState(dataDir, { ...readState(dataDir), channel: 'npm', phase: 'applying', pendingVersion: target });
|
|
439
|
+
const result = await npmInstallGlobal(target, { run, log, delay });
|
|
440
|
+
if (!result.ok) {
|
|
441
|
+
writeState(dataDir, { ...readState(dataDir), phase: 'idle', pendingVersion: target, pendingBuild: null });
|
|
442
|
+
log(`[Harness Mix] 更新失败,已安排下次启动重试(${target})`);
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
writeState(dataDir, { ...readState(dataDir), phase: 'idle', pendingVersion: null, appliedVersion: target, prevVersion: currentVersion, appliedAt: Date.now(), lastCheckAt: Date.now() });
|
|
446
|
+
return true;
|
|
447
|
+
});
|
|
448
|
+
if (applied.skipped) return { updated: false, failed: true, reason: 'lock' };
|
|
449
|
+
if (applied.value) {
|
|
450
|
+
log(`[Harness Mix] 更新完成 ${currentVersion} → ${target},正在重新启动…`);
|
|
451
|
+
return { updated: true, from: currentVersion, to: target, restartRequired: true };
|
|
452
|
+
}
|
|
453
|
+
return { updated: false, failed: true, pending: target };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
module.exports = {
|
|
457
|
+
autoUpdate, remoteState, applyUpdate, makeGit, asyncRun, defaultHooks,
|
|
458
|
+
runUpdateFlow, reexecLauncher, fetchLatestVersion, npmInstallGlobal,
|
|
459
|
+
resolveRegistry, isGlobalInstall, npmGlobalRoot, readJson,
|
|
460
|
+
};
|