@lumi-ai-lab/harness-data 0.0.15 → 0.0.17
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/package.json +1 -1
- package/src/cli.js +3 -1
- package/src/commands/install.js +62 -19
- package/src/commands/update.js +6 -2
- package/src/lib/github.js +6 -1
- package/src/lib/log.js +4 -0
- package/src/lib/manifest.js +206 -31
- package/src/lib/prompt.js +26 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -44,6 +44,8 @@ Commands:
|
|
|
44
44
|
Install options:
|
|
45
45
|
--dir PATH Runtime directory (default: current directory)
|
|
46
46
|
--agent NAME claude, codex, pi, openclaw, hermes, both, or all
|
|
47
|
-
--github-token TOKEN GitHub token for private Release assets
|
|
47
|
+
--github-token TOKEN GitHub token for private Release assets
|
|
48
|
+
--cas-username USERNAME CAS username (skip interactive prompt)
|
|
49
|
+
--cas-password PASSWORD CAS password (skip interactive prompt)`);
|
|
48
50
|
if (unknown) process.exitCode = 1;
|
|
49
51
|
}
|
package/src/commands/install.js
CHANGED
|
@@ -37,6 +37,26 @@ async function prepareRuntimeDir(options) {
|
|
|
37
37
|
return runtimeDir;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function replaceRuntimePath(runtimeDir, name, stagedRoot, backups) {
|
|
41
|
+
const target = path.join(runtimeDir, name);
|
|
42
|
+
const backup = fs.mkdtempSync(path.join(runtimeDir, `.install-backup-${name}-`));
|
|
43
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
44
|
+
if (fs.existsSync(target)) fs.renameSync(target, backup);
|
|
45
|
+
backups.push({ name, target, backup });
|
|
46
|
+
fs.renameSync(path.join(stagedRoot, name), target);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function restoreRuntimeBackups(backups) {
|
|
50
|
+
for (const item of backups.slice().reverse()) {
|
|
51
|
+
fs.rmSync(item.target, { recursive: true, force: true });
|
|
52
|
+
if (fs.existsSync(item.backup)) fs.renameSync(item.backup, item.target);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cleanupRuntimeBackups(backups) {
|
|
57
|
+
for (const item of backups) fs.rmSync(item.backup, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
export async function installRuntimeBundle(runtimeDir, options = {}) {
|
|
41
61
|
if (!options.force && fs.existsSync(path.join(runtimeDir, "agents")) &&
|
|
42
62
|
fs.existsSync(path.join(runtimeDir, "config")) &&
|
|
@@ -56,7 +76,7 @@ export async function installRuntimeBundle(runtimeDir, options = {}) {
|
|
|
56
76
|
fs.mkdirSync(cacheDir, { recursive: true });
|
|
57
77
|
const archive = path.join(cacheDir, assetName);
|
|
58
78
|
action(`下载 harness-data-runtime ${tag}`);
|
|
59
|
-
await downloadReleaseAsset(asset, archive, options);
|
|
79
|
+
await downloadReleaseAsset(asset, archive, { ...options, progressLabel: assetName });
|
|
60
80
|
if (shaAsset) {
|
|
61
81
|
const shaFile = `${archive}.sha256`;
|
|
62
82
|
await downloadReleaseAsset(shaAsset, shaFile, options);
|
|
@@ -69,20 +89,28 @@ export async function installRuntimeBundle(runtimeDir, options = {}) {
|
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
const extractDir = fs.mkdtempSync(path.join(cacheDir, "runtime-"));
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
fs.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
fs.copyFileSync(path.join(configSource, file), path.join(
|
|
92
|
+
const stagedRoot = fs.mkdtempSync(path.join(runtimeDir, ".install-new-runtime-"));
|
|
93
|
+
const backups = [];
|
|
94
|
+
try {
|
|
95
|
+
// 在 Git Bash 中 tar 无法处理 Windows 绝对路径(E:\...),使用相对路径执行
|
|
96
|
+
await run("tar", ["-xzf", path.relative(cacheDir, archive), "-C", path.relative(cacheDir, extractDir)], { cwd: cacheDir });
|
|
97
|
+
for (const dir of ["agents", "bootstrap"]) if (!fs.existsSync(path.join(extractDir, dir))) throw new Error(`runtime bundle missing ${dir}/`);
|
|
98
|
+
const configSource = path.join(extractDir, "config");
|
|
99
|
+
if (!fs.existsSync(configSource)) throw new Error("runtime bundle missing config/");
|
|
100
|
+
|
|
101
|
+
for (const dir of ["agents", "bootstrap"]) fs.cpSync(path.join(extractDir, dir), path.join(stagedRoot, dir), { recursive: true });
|
|
102
|
+
fs.mkdirSync(path.join(stagedRoot, "config"), { recursive: true });
|
|
103
|
+
for (const file of fs.readdirSync(configSource)) fs.copyFileSync(path.join(configSource, file), path.join(stagedRoot, "config", file));
|
|
104
|
+
|
|
105
|
+
for (const name of ["agents", "bootstrap", "config"]) replaceRuntimePath(runtimeDir, name, stagedRoot, backups);
|
|
106
|
+
cleanupRuntimeBackups(backups);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
restoreRuntimeBackups(backups);
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
112
|
+
fs.rmSync(stagedRoot, { recursive: true, force: true });
|
|
84
113
|
}
|
|
85
|
-
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
86
114
|
ok(`runtime bundle ${tag}`);
|
|
87
115
|
return { tag, skipped: false };
|
|
88
116
|
}
|
|
@@ -165,8 +193,8 @@ function casConfigDir(runtimeDir) {
|
|
|
165
193
|
}
|
|
166
194
|
|
|
167
195
|
async function writeCasCredentials(runtimeDir, options = {}) {
|
|
168
|
-
const username = await ask("CAS 用户名:", options);
|
|
169
|
-
const password = await askSecret("CAS 密码:", options);
|
|
196
|
+
const username = options.casUsername || await ask("CAS 用户名:", options);
|
|
197
|
+
const password = options.casPassword || await askSecret("CAS 密码:", options);
|
|
170
198
|
if (!username || !password) throw new Error("CAS username and password are required");
|
|
171
199
|
const dir = casConfigDir(runtimeDir);
|
|
172
200
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -205,8 +233,10 @@ export async function buildAndCheck(runtimeDir, options = {}) {
|
|
|
205
233
|
return { ok: true, docs, recall, runtimeDocs };
|
|
206
234
|
}
|
|
207
235
|
|
|
208
|
-
export function printDoctorSummary(doctor) {
|
|
209
|
-
const
|
|
236
|
+
export function printDoctorSummary(doctor, options = {}) {
|
|
237
|
+
const nonBlocking = options.nonBlocking || (() => false);
|
|
238
|
+
const failed = doctor.checks.filter((check) => !check.ok && !nonBlocking(check));
|
|
239
|
+
const warnings = doctor.checks.filter((check) => !check.ok && nonBlocking(check));
|
|
210
240
|
if (!failed.length) {
|
|
211
241
|
ok("runtime");
|
|
212
242
|
ok("wikis/spec");
|
|
@@ -217,9 +247,11 @@ export function printDoctorSummary(doctor) {
|
|
|
217
247
|
ok("CAS 凭证");
|
|
218
248
|
ok("CMR Token");
|
|
219
249
|
ok("Indicators Token");
|
|
220
|
-
ok("Agent Hook");
|
|
250
|
+
if (!warnings.some((check) => check.name.startsWith("Agent hook"))) ok("Agent Hook");
|
|
251
|
+
for (const check of warnings) warn(`${check.name}${check.detail ? ` (${check.detail})` : ""}`);
|
|
221
252
|
return;
|
|
222
253
|
}
|
|
254
|
+
for (const check of warnings) warn(`${check.name}${check.detail ? ` (${check.detail})` : ""}`);
|
|
223
255
|
for (const check of failed) fail(`${check.name}${check.detail ? ` (${check.detail})` : ""}`);
|
|
224
256
|
}
|
|
225
257
|
|
|
@@ -259,6 +291,17 @@ export async function installCommand(options = {}) {
|
|
|
259
291
|
ok(`${Object.keys(manifest.installedTools || {}).length + Object.keys(localTools).length} 个 CLI 已安装到 bin/`);
|
|
260
292
|
blank();
|
|
261
293
|
|
|
294
|
+
// 及时持久化 CLI 安装状态,后续步骤失败时重新安装可跳过已下载的 CLI
|
|
295
|
+
writeState(runtimeDir, {
|
|
296
|
+
installMode: tokenMode ? "github-token" : "local-path",
|
|
297
|
+
runtimeTag: bundle.tag,
|
|
298
|
+
localTools,
|
|
299
|
+
tools: manifest.installedTools || {},
|
|
300
|
+
manifestSha256: manifestDigest(manifest),
|
|
301
|
+
packageVersion: packageVersion()
|
|
302
|
+
});
|
|
303
|
+
blank();
|
|
304
|
+
|
|
262
305
|
step(4, 8, "同步 Wikis 知识库");
|
|
263
306
|
await installWikis(runtimeDir, options);
|
|
264
307
|
blank();
|
package/src/commands/update.js
CHANGED
|
@@ -13,6 +13,10 @@ import { buildAndCheck, installRuntimeBundle, printDoctorSummary } from "./insta
|
|
|
13
13
|
import { collectDoctor } from "./doctor.js";
|
|
14
14
|
import { action, blank, header, ok, shortSha, skip, step, warn } from "../lib/log.js";
|
|
15
15
|
|
|
16
|
+
export function isNonBlockingUpdateDoctorCheck(check) {
|
|
17
|
+
return check.name === "Agent hook" || check.name.startsWith("Agent hook .");
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
async function npmLatest() {
|
|
17
21
|
try {
|
|
18
22
|
const response = await fetch("https://registry.npmjs.org/@lumi-ai-lab%2Fharness-data/latest", { signal: AbortSignal.timeout(5000) });
|
|
@@ -184,8 +188,8 @@ export async function updateCommand(options = {}) {
|
|
|
184
188
|
step(6, 6, "安装校验");
|
|
185
189
|
if (changed) {
|
|
186
190
|
const doctor = await collectDoctor(runtimeDir, trackingOptions);
|
|
187
|
-
printDoctorSummary(doctor);
|
|
188
|
-
if (doctor.checks.some((check) => !check.ok)) throw new Error("doctor failed; update is incomplete");
|
|
191
|
+
printDoctorSummary(doctor, { nonBlocking: isNonBlockingUpdateDoctorCheck });
|
|
192
|
+
if (doctor.checks.some((check) => !check.ok && !isNonBlockingUpdateDoctorCheck(check))) throw new Error("doctor failed; update is incomplete");
|
|
189
193
|
writeState(runtimeDir, {
|
|
190
194
|
...state,
|
|
191
195
|
runtimeTag,
|
package/src/lib/github.js
CHANGED
|
@@ -48,5 +48,10 @@ export function findReleaseAsset(release, name) {
|
|
|
48
48
|
|
|
49
49
|
export async function downloadReleaseAsset(asset, file, options = {}) {
|
|
50
50
|
const headers = { ...githubHeaders(options), Accept: "application/octet-stream" };
|
|
51
|
-
await download(asset.url, file, headers
|
|
51
|
+
await download(asset.url, file, headers, {
|
|
52
|
+
progressLabel: options.progressLabel,
|
|
53
|
+
log: options.log,
|
|
54
|
+
progress: options.progress,
|
|
55
|
+
progressWriter: options.progressWriter
|
|
56
|
+
});
|
|
52
57
|
}
|
package/src/lib/log.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// 在 Git Bash (Windows) 下,Node.js stdout 连接为 pipe 时默认缓冲输出。
|
|
2
|
+
// setBlocking(true) 让每次写入即时刷新,避免下载进度等日志需要回车才显示。
|
|
3
|
+
if (process.stdout._handle?.setBlocking) process.stdout._handle.setBlocking(true);
|
|
4
|
+
|
|
1
5
|
export function header(title, version, rows = []) {
|
|
2
6
|
console.log(`${title} ${version}`);
|
|
3
7
|
console.log("");
|
package/src/lib/manifest.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import https from "node:https";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { commandExists, run } from "./exec.js";
|
|
6
|
-
import { platformKey } from "./platform.js";
|
|
6
|
+
import { binaryName, platformKey } from "./platform.js";
|
|
7
7
|
import { action, skip, warn } from "./log.js";
|
|
8
8
|
|
|
9
9
|
export function readManifest(file) {
|
|
@@ -14,14 +14,107 @@ export function manifestDigest(manifest) {
|
|
|
14
14
|
return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
function formatBytes(value) {
|
|
18
|
+
if (value < 1024) return `${value} B`;
|
|
19
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
20
|
+
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function shouldShowProgress(options = {}) {
|
|
24
|
+
if (!options.progressLabel) return false;
|
|
25
|
+
if (options.progressWriter) return true;
|
|
26
|
+
if (options.log === false) return false;
|
|
27
|
+
if (options.progress === true) return true;
|
|
28
|
+
return Boolean(process.stdout.isTTY) && !process.env.CI;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function downloadProgress(label, total, options = {}) {
|
|
32
|
+
if (!shouldShowProgress(options)) return { tick() {}, done() {}, fail() {} };
|
|
33
|
+
const writer = options.progressWriter || process.stdout;
|
|
34
|
+
let downloaded = 0;
|
|
35
|
+
let lastLength = 0;
|
|
36
|
+
let lastRenderAt = 0;
|
|
37
|
+
let ended = false;
|
|
38
|
+
const width = 24;
|
|
39
|
+
const render = (done = false) => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (!done && now - lastRenderAt < 80) return;
|
|
42
|
+
lastRenderAt = now;
|
|
43
|
+
const percent = total ? Math.min(100, Math.floor((downloaded / total) * 100)) : 0;
|
|
44
|
+
const filled = total ? Math.min(width, Math.floor((percent / 100) * width)) : Math.floor((downloaded / 1024) % width);
|
|
45
|
+
const bar = `${"=".repeat(filled)}${" ".repeat(width - filled)}`;
|
|
46
|
+
const size = total ? `${formatBytes(downloaded)}/${formatBytes(total)}` : formatBytes(downloaded);
|
|
47
|
+
const prefix = done ? "下载完成" : "下载中";
|
|
48
|
+
const line = `${prefix} ${label} [${bar}] ${total ? `${percent}% ` : ""}${size}`;
|
|
49
|
+
writer.write(`\r${line}${" ".repeat(Math.max(0, lastLength - line.length))}`);
|
|
50
|
+
lastLength = line.length;
|
|
51
|
+
if (done) writer.write("\n");
|
|
52
|
+
};
|
|
53
|
+
render();
|
|
54
|
+
return {
|
|
55
|
+
tick(chunk) {
|
|
56
|
+
if (ended) return;
|
|
57
|
+
downloaded += chunk.length;
|
|
58
|
+
render();
|
|
59
|
+
},
|
|
60
|
+
done() {
|
|
61
|
+
if (ended) return;
|
|
62
|
+
ended = true;
|
|
63
|
+
if (total) downloaded = total;
|
|
64
|
+
render(true);
|
|
65
|
+
},
|
|
66
|
+
fail() {
|
|
67
|
+
if (ended) return;
|
|
68
|
+
ended = true;
|
|
69
|
+
writer.write("\n");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ghDownloadStatus(label, options = {}) {
|
|
75
|
+
const writer = options.progressWriter || process.stdout;
|
|
76
|
+
const enabled = Boolean(options.progressWriter) || (options.log !== false && Boolean(process.stdout.isTTY) && !process.env.CI);
|
|
77
|
+
if (!enabled) return { done() {}, fail() {} };
|
|
78
|
+
const frames = ["-", "\\", "|", "/"];
|
|
79
|
+
let index = 0;
|
|
80
|
+
let lastLength = 0;
|
|
81
|
+
let ended = false;
|
|
82
|
+
const render = (prefix) => {
|
|
83
|
+
const line = `${prefix} ${label}`;
|
|
84
|
+
writer.write(`\r${line}${" ".repeat(Math.max(0, lastLength - line.length))}`);
|
|
85
|
+
lastLength = line.length;
|
|
86
|
+
};
|
|
87
|
+
render(`下载中 ${frames[index]}`);
|
|
88
|
+
const timer = setInterval(() => {
|
|
89
|
+
index = (index + 1) % frames.length;
|
|
90
|
+
render(`下载中 ${frames[index]}`);
|
|
91
|
+
}, 120);
|
|
92
|
+
return {
|
|
93
|
+
done() {
|
|
94
|
+
if (ended) return;
|
|
95
|
+
ended = true;
|
|
96
|
+
clearInterval(timer);
|
|
97
|
+
render("下载完成");
|
|
98
|
+
writer.write("\n");
|
|
99
|
+
},
|
|
100
|
+
fail() {
|
|
101
|
+
if (ended) return;
|
|
102
|
+
ended = true;
|
|
103
|
+
clearInterval(timer);
|
|
104
|
+
writer.write("\n");
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function download(url, file, headers = {}, options = {}) {
|
|
18
110
|
return new Promise((resolve, reject) => {
|
|
19
111
|
const request = https.get(url, { headers }, (response) => {
|
|
20
112
|
if ([301, 302, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
21
113
|
const current = new URL(url);
|
|
22
114
|
const next = new URL(response.headers.location, url);
|
|
23
115
|
const nextHeaders = current.hostname === next.hostname ? headers : {};
|
|
24
|
-
|
|
116
|
+
response.resume();
|
|
117
|
+
download(response.headers.location, file, nextHeaders, options).then(resolve, reject);
|
|
25
118
|
return;
|
|
26
119
|
}
|
|
27
120
|
if (response.statusCode !== 200) {
|
|
@@ -31,9 +124,25 @@ export function download(url, file, headers = {}) {
|
|
|
31
124
|
}
|
|
32
125
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
33
126
|
const out = fs.createWriteStream(file);
|
|
127
|
+
const total = Number(response.headers["content-length"]) || 0;
|
|
128
|
+
const progress = downloadProgress(options.progressLabel, total, options);
|
|
129
|
+
let settled = false;
|
|
130
|
+
const rejectOnce = (error) => {
|
|
131
|
+
if (settled) return;
|
|
132
|
+
settled = true;
|
|
133
|
+
progress.fail();
|
|
134
|
+
reject(error);
|
|
135
|
+
};
|
|
136
|
+
response.on("data", (chunk) => progress.tick(chunk));
|
|
137
|
+
response.on("error", rejectOnce);
|
|
34
138
|
response.pipe(out);
|
|
35
|
-
out.on("finish", () => out.close(
|
|
36
|
-
|
|
139
|
+
out.on("finish", () => out.close(() => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
progress.done();
|
|
143
|
+
resolve();
|
|
144
|
+
}));
|
|
145
|
+
out.on("error", rejectOnce);
|
|
37
146
|
});
|
|
38
147
|
request.on("error", reject);
|
|
39
148
|
});
|
|
@@ -60,19 +169,61 @@ async function ghAuthenticated() {
|
|
|
60
169
|
return result.code === 0;
|
|
61
170
|
}
|
|
62
171
|
|
|
63
|
-
async function
|
|
172
|
+
async function downloadPrivateWithTokenValue(asset, file, token, options = {}) {
|
|
173
|
+
const apiUrl = await githubAssetApiUrl(asset, token);
|
|
174
|
+
if (!apiUrl) return false;
|
|
175
|
+
await download(apiUrl, file, {
|
|
176
|
+
Authorization: `Bearer ${token}`,
|
|
177
|
+
Accept: "application/octet-stream",
|
|
178
|
+
"User-Agent": "harness-data-installer"
|
|
179
|
+
}, {
|
|
180
|
+
progressLabel: assetName(asset),
|
|
181
|
+
log: options.log,
|
|
182
|
+
progress: options.progress,
|
|
183
|
+
progressWriter: options.progressWriter
|
|
184
|
+
});
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function downloadPrivateWithGh(asset, file, options = {}) {
|
|
64
189
|
const parts = githubAssetParts(asset);
|
|
65
190
|
if (!parts || !(await ghAuthenticated())) return false;
|
|
191
|
+
const failures = options.failures || [];
|
|
192
|
+
const tokenResult = await run("gh", ["auth", "token"], { allowFailure: true });
|
|
193
|
+
const token = tokenResult.code === 0 ? tokenResult.stdout.trim() : "";
|
|
194
|
+
if (token) {
|
|
195
|
+
try {
|
|
196
|
+
if (await downloadPrivateWithTokenValue(asset, file, token, options)) return true;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
failures.push(error.message);
|
|
199
|
+
// If Node cannot reach GitHub directly, keep the older gh download fallback available.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
66
202
|
const dir = fs.mkdtempSync(path.join(path.dirname(file), "gh-download-"));
|
|
203
|
+
const status = ghDownloadStatus(assetName(asset), options);
|
|
67
204
|
try {
|
|
68
|
-
const result = await run("gh", ["release", "download", parts.tag, "--repo", parts.repo, "--pattern", parts.name, "--dir", dir], {
|
|
69
|
-
|
|
205
|
+
const result = await run("gh", ["release", "download", parts.tag, "--repo", parts.repo, "--pattern", parts.name, "--dir", dir], {
|
|
206
|
+
allowFailure: true,
|
|
207
|
+
stdio: "pipe"
|
|
208
|
+
});
|
|
209
|
+
if (result.code !== 0) {
|
|
210
|
+
status.fail();
|
|
211
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
212
|
+
failures.push(`gh release download failed${detail ? `: ${detail}` : ""}`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
70
215
|
const downloaded = path.join(dir, parts.name);
|
|
71
|
-
if (!fs.existsSync(downloaded))
|
|
216
|
+
if (!fs.existsSync(downloaded)) {
|
|
217
|
+
status.fail();
|
|
218
|
+
failures.push(`gh release download did not produce ${parts.name}`);
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
72
221
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
222
|
fs.renameSync(downloaded, file);
|
|
223
|
+
status.done();
|
|
74
224
|
return true;
|
|
75
225
|
} finally {
|
|
226
|
+
status.fail();
|
|
76
227
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
77
228
|
}
|
|
78
229
|
}
|
|
@@ -101,31 +252,43 @@ async function githubAssetApiUrl(asset, token) {
|
|
|
101
252
|
async function downloadPrivateWithToken(asset, file, options = {}) {
|
|
102
253
|
const token = githubToken(options);
|
|
103
254
|
if (!token) return false;
|
|
104
|
-
|
|
105
|
-
if (!apiUrl) return false;
|
|
106
|
-
await download(apiUrl, file, {
|
|
107
|
-
Authorization: `Bearer ${token}`,
|
|
108
|
-
Accept: "application/octet-stream",
|
|
109
|
-
"User-Agent": "harness-data-installer"
|
|
110
|
-
});
|
|
111
|
-
return true;
|
|
255
|
+
return downloadPrivateWithTokenValue(asset, file, token, options);
|
|
112
256
|
}
|
|
113
257
|
|
|
114
258
|
async function downloadAsset(tool, asset, file, options = {}) {
|
|
259
|
+
if (!tool.private && !githubToken(options)) {
|
|
260
|
+
await download(asset.url, file, {}, { progressLabel: assetName(asset), log: options.log, progress: options.progress, progressWriter: options.progressWriter });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
115
264
|
if (!tool.private) {
|
|
116
|
-
|
|
265
|
+
try {
|
|
266
|
+
if (await downloadPrivateWithGh(asset, file, options)) return;
|
|
267
|
+
if (await downloadPrivateWithToken(asset, file, options)) return;
|
|
268
|
+
} catch {
|
|
269
|
+
// Public assets must remain downloadable when an unrelated GitHub token is stale or invalid.
|
|
270
|
+
}
|
|
271
|
+
await download(asset.url, file, {}, { progressLabel: assetName(asset), log: options.log, progress: options.progress, progressWriter: options.progressWriter });
|
|
117
272
|
return;
|
|
118
273
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
274
|
+
|
|
275
|
+
const failures = [];
|
|
276
|
+
const privateOptions = { ...options, failures };
|
|
277
|
+
if (await downloadPrivateWithGh(asset, file, privateOptions)) return;
|
|
278
|
+
try {
|
|
279
|
+
if (await downloadPrivateWithToken(asset, file, privateOptions)) return;
|
|
280
|
+
} catch (error) {
|
|
281
|
+
failures.push(error.message);
|
|
282
|
+
}
|
|
283
|
+
const detail = failures.filter(Boolean).join("; ");
|
|
284
|
+
throw new Error(`private GitHub Release asset requires gh auth login, GITHUB_TOKEN, or --github-token: ${assetName(asset)}${detail ? ` (${detail})` : ""}`);
|
|
122
285
|
}
|
|
123
286
|
|
|
124
287
|
async function expectedSha256(tool, asset, options = {}) {
|
|
125
288
|
if (asset.sha256) return asset.sha256;
|
|
126
289
|
try {
|
|
127
290
|
const tmp = path.join(fs.mkdtempSync(path.join(process.cwd(), ".bootstrap-cache-sha-")), "asset.sha256");
|
|
128
|
-
await downloadAsset(tool, { ...asset, url: `${asset.url}.sha256` }, tmp, options);
|
|
291
|
+
await downloadAsset(tool, { ...asset, url: `${asset.url}.sha256` }, tmp, { ...options, log: false, progress: false, progressWriter: null });
|
|
129
292
|
return fs.readFileSync(tmp, "utf8").trim().split(/\s+/)[0];
|
|
130
293
|
} catch {
|
|
131
294
|
return "";
|
|
@@ -149,7 +312,7 @@ function reusableInstalledTool(workspace, tool, asset, options = {}) {
|
|
|
149
312
|
const current = tool.version ? optionsStateTool(options, tool.name) : null;
|
|
150
313
|
if (!current?.version || current.version !== tool.version) return null;
|
|
151
314
|
if (!current.sha256) return null;
|
|
152
|
-
const binary = path.join(workspace, "bin", tool.binary);
|
|
315
|
+
const binary = path.join(workspace, "bin", binaryName(tool.binary));
|
|
153
316
|
if (!executable(binary)) return null;
|
|
154
317
|
const actualSha = fileSha256(binary);
|
|
155
318
|
if (actualSha !== current.sha256) return null;
|
|
@@ -165,6 +328,25 @@ function optionsStateTool(options, name) {
|
|
|
165
328
|
return options?.state?.tools?.[name] || null;
|
|
166
329
|
}
|
|
167
330
|
|
|
331
|
+
async function extractArchiveBinary(workspace, cacheDir, archive, binDir, tool) {
|
|
332
|
+
const extractDir = fs.mkdtempSync(path.join(cacheDir, `${tool.name}-extract-`));
|
|
333
|
+
try {
|
|
334
|
+
if (archive.endsWith(".zip")) {
|
|
335
|
+
await run("unzip", ["-o", path.relative(workspace, archive), "-d", path.relative(workspace, extractDir)], { cwd: workspace });
|
|
336
|
+
} else {
|
|
337
|
+
await run("tar", ["-xzf", path.relative(workspace, archive), "-C", path.relative(workspace, extractDir)], { cwd: workspace });
|
|
338
|
+
}
|
|
339
|
+
const extracted = path.join(extractDir, binaryName(tool.binary));
|
|
340
|
+
if (!fs.existsSync(extracted)) throw new Error(`${tool.binary} was not extracted to archive root`);
|
|
341
|
+
fs.chmodSync(extracted, 0o755);
|
|
342
|
+
const binary = path.join(binDir, binaryName(tool.binary));
|
|
343
|
+
fs.renameSync(extracted, binary);
|
|
344
|
+
return binary;
|
|
345
|
+
} finally {
|
|
346
|
+
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
168
350
|
export async function installToolsFromManifest(workspace, manifestPath, options = {}) {
|
|
169
351
|
const manifest = options.manifestOverride || readManifest(manifestPath);
|
|
170
352
|
const only = options.tools ? new Set(options.tools) : null;
|
|
@@ -192,14 +374,7 @@ export async function installToolsFromManifest(workspace, manifestPath, options
|
|
|
192
374
|
const actualSha = fileSha256(archive);
|
|
193
375
|
if (sha && actualSha !== sha) throw new Error(`${tool.name} sha256 mismatch`);
|
|
194
376
|
if (!sha) warn(`${tool.name} 未提供 sha256,已继续安装`);
|
|
195
|
-
|
|
196
|
-
await run("unzip", ["-o", archive, "-d", binDir]);
|
|
197
|
-
} else {
|
|
198
|
-
await run("tar", ["-xzf", archive, "-C", binDir]);
|
|
199
|
-
}
|
|
200
|
-
const binary = path.join(binDir, tool.binary);
|
|
201
|
-
if (!fs.existsSync(binary)) throw new Error(`${tool.binary} was not extracted to bin/`);
|
|
202
|
-
fs.chmodSync(binary, 0o755);
|
|
377
|
+
const binary = await extractArchiveBinary(workspace, cacheDir, archive, binDir, tool);
|
|
203
378
|
const binarySha = fileSha256(binary);
|
|
204
379
|
installedTools[tool.name] = {
|
|
205
380
|
version: tool.version || "",
|
package/src/lib/prompt.js
CHANGED
|
@@ -2,10 +2,16 @@ import readline from "node:readline/promises";
|
|
|
2
2
|
import { stdin as input, stdout as output } from "node:process";
|
|
3
3
|
import { Writable } from "node:stream";
|
|
4
4
|
import { agentChoices, agentChoiceText } from "./config.js";
|
|
5
|
+
import { run } from "./exec.js";
|
|
5
6
|
|
|
6
7
|
export async function confirm(message, options = {}) {
|
|
7
8
|
if (options.yes) return true;
|
|
8
9
|
const suffix = options.defaultNo ? " [y/N] " : " [Y/n] ";
|
|
10
|
+
if (!process.stdin.isTTY) {
|
|
11
|
+
const answer = (await shellRead(message + suffix)).toLowerCase();
|
|
12
|
+
if (!answer) return !options.defaultNo;
|
|
13
|
+
return answer === "y" || answer === "yes";
|
|
14
|
+
}
|
|
9
15
|
const rl = readline.createInterface({ input, output });
|
|
10
16
|
try {
|
|
11
17
|
const answer = (await rl.question(message + suffix)).trim().toLowerCase();
|
|
@@ -23,6 +29,12 @@ export async function chooseAgent(options = {}) {
|
|
|
23
29
|
return value;
|
|
24
30
|
}
|
|
25
31
|
if (options.yes) return "all";
|
|
32
|
+
if (!process.stdin.isTTY) {
|
|
33
|
+
const answer = (await shellRead(`选择 Agent:${agentChoiceText} [all] `)).toLowerCase();
|
|
34
|
+
const value = answer || "all";
|
|
35
|
+
if (!agentChoices.includes(value)) throw new Error(`agent must be ${agentChoiceText}`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
26
38
|
const rl = readline.createInterface({ input, output });
|
|
27
39
|
try {
|
|
28
40
|
const answer = (await rl.question(`选择 Agent:${agentChoiceText} [all] `)).trim().toLowerCase();
|
|
@@ -34,9 +46,22 @@ export async function chooseAgent(options = {}) {
|
|
|
34
46
|
}
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
async function shellRead(prompt) {
|
|
50
|
+
const escaped = prompt.replace(/'/g, "'\\''");
|
|
51
|
+
const result = await run("bash", ["-c", `read -p '${escaped}' -r input && echo "$input"`], { stdio: ["inherit", "pipe", "inherit"] });
|
|
52
|
+
return result.stdout.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function shellReadSecret(prompt) {
|
|
56
|
+
const escaped = prompt.replace(/'/g, "'\\''");
|
|
57
|
+
const result = await run("bash", ["-c", `read -s -p '${escaped}' -r input && echo "$input"`], { stdio: ["inherit", "pipe", "inherit"] });
|
|
58
|
+
return result.stdout.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
export async function ask(message, options = {}) {
|
|
38
62
|
if (options.value) return String(options.value);
|
|
39
63
|
if (options.yes) throw new Error(`${message} is required`);
|
|
64
|
+
if (!process.stdin.isTTY) return shellRead(`${message} `);
|
|
40
65
|
const rl = readline.createInterface({ input, output });
|
|
41
66
|
try {
|
|
42
67
|
return (await rl.question(`${message} `)).trim();
|
|
@@ -48,6 +73,7 @@ export async function ask(message, options = {}) {
|
|
|
48
73
|
export async function askSecret(message, options = {}) {
|
|
49
74
|
if (options.value) return String(options.value);
|
|
50
75
|
if (options.yes) throw new Error(`${message} is required`);
|
|
76
|
+
if (!process.stdin.isTTY) return shellReadSecret(`${message} `);
|
|
51
77
|
const muted = new Writable({
|
|
52
78
|
write(_chunk, _encoding, callback) {
|
|
53
79
|
callback();
|