@lumi-ai-lab/harness-data 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/cli.js +5 -1
- package/src/commands/install.js +16 -10
- package/src/commands/update.js +28 -10
- package/src/lib/git-auth.js +117 -0
- package/src/lib/git.js +13 -4
- package/src/lib/manifest.js +99 -10
package/README.md
CHANGED
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
```bash
|
|
4
4
|
npx @lumi-ai-lab/harness-data install
|
|
5
|
+
npx @lumi-ai-lab/harness-data install --git-protocol ssh
|
|
6
|
+
npx @lumi-ai-lab/harness-data install --git-protocol https
|
|
7
|
+
GITHUB_TOKEN=... npx @lumi-ai-lab/harness-data install --yes --agent codex --git-protocol https --github-token-env GITHUB_TOKEN --cas-config-dir /secure/path/to/cas
|
|
5
8
|
npx @lumi-ai-lab/harness-data doctor --dir ~/harness-data
|
|
6
9
|
npx @lumi-ai-lab/harness-data update --dir ~/harness-data
|
|
7
10
|
```
|
|
8
11
|
|
|
9
12
|
This package is only the installer and updater. The full Harness Data workspace is cloned and maintained outside the npm package.
|
|
13
|
+
|
|
14
|
+
Private GitHub repository access defaults to `--git-protocol auto`: SSH is tried first, then HTTPS. HTTPS uses Git Credential Manager, `gh auth login`, or a token supplied through `--github-token-env`; GitHub account passwords are not supported.
|
|
15
|
+
|
|
16
|
+
The `qdm-cmr-cli`, `qdm-indicators-cli`, and `cas-cli` binaries are downloaded from private GitHub Releases in `pengmide/qdm-cmr-cli`, `pengmide/qdm-indicators-cli`, and `pengmide/qdm-cas-cli`. The installer uses `gh auth login` first and falls back to `--github-token-env`.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -39,6 +39,10 @@ Commands:
|
|
|
39
39
|
install Clone/reuse a Harness Data workspace and complete setup
|
|
40
40
|
update Check and apply installer, repository, CLI, and wikis updates
|
|
41
41
|
doctor Diagnose workspace CLI, config, auth, index, and Agent hooks
|
|
42
|
-
version Print installer, repository, wikis, and manifest versions
|
|
42
|
+
version Print installer, repository, wikis, and manifest versions
|
|
43
|
+
|
|
44
|
+
Install options:
|
|
45
|
+
--git-protocol auto|ssh|https Git protocol for private repositories (default: auto)
|
|
46
|
+
--github-token-env NAME Token env var for HTTPS fallback or CI`);
|
|
43
47
|
if (unknown) process.exitCode = 1;
|
|
44
48
|
}
|
package/src/commands/install.js
CHANGED
|
@@ -10,8 +10,7 @@ import { platformKey } from "../lib/platform.js";
|
|
|
10
10
|
import { collectDoctor } from "./doctor.js";
|
|
11
11
|
import { checkUpdates } from "./update.js";
|
|
12
12
|
import { packageVersion } from "../lib/package.js";
|
|
13
|
-
|
|
14
|
-
const defaultRepo = "https://github.com/lumi-ai-lab/harness-data.git";
|
|
13
|
+
import { gitUrls, repoProtocol, repoUrl, resolveInstallProtocol, runGitWithProtocol, syncWikisSubmodule } from "../lib/git-auth.js";
|
|
15
14
|
|
|
16
15
|
async function requireCommands(commands) {
|
|
17
16
|
for (const command of commands) {
|
|
@@ -21,19 +20,22 @@ async function requireCommands(commands) {
|
|
|
21
20
|
|
|
22
21
|
async function prepareWorkspace(options) {
|
|
23
22
|
const target = resolveWorkspaceDir(options.dir || (looksLikeWorkspace(process.cwd()) ? process.cwd() : defaultWorkspaceDir()));
|
|
24
|
-
const repo = options.repo || defaultRepo;
|
|
25
23
|
const branch = options.branch || "master";
|
|
26
24
|
if (!fs.existsSync(target)) {
|
|
25
|
+
const protocol = await resolveInstallProtocol(options);
|
|
26
|
+
const repo = options.repo || gitUrls[protocol].repo;
|
|
27
27
|
if (!(await confirm(`Clone ${repo} to ${target}?`, { yes: options.yes }))) throw new Error("install cancelled");
|
|
28
28
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
29
|
-
await
|
|
30
|
-
|
|
29
|
+
await runGitWithProtocol(protocol, ["clone", "--branch", branch, repo, target], { ...options, stdio: "inherit" });
|
|
30
|
+
await syncWikisSubmodule(target, protocol);
|
|
31
|
+
await runGitWithProtocol(protocol, ["submodule", "update", "--init", "--recursive", "wikis"], { ...options, cwd: target, stdio: "inherit" });
|
|
32
|
+
return { workspace: target, gitProtocol: protocol, repoUrl: repo };
|
|
31
33
|
}
|
|
32
34
|
if (!(await isGitRepo(target)) || !looksLikeWorkspace(target)) {
|
|
33
35
|
throw new Error(`${target} exists but is not a valid harness-data workspace; pass --dir or move the directory`);
|
|
34
36
|
}
|
|
35
37
|
if (!(await confirm(`Reuse existing workspace ${target}?`, { yes: options.yes }))) throw new Error("install cancelled");
|
|
36
|
-
return target;
|
|
38
|
+
return { workspace: target, gitProtocol: await repoProtocol(target), repoUrl: await repoUrl(target) };
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
async function configureAuth(workspace, options) {
|
|
@@ -82,10 +84,11 @@ async function buildAndCheck(workspace, options) {
|
|
|
82
84
|
export async function installCommand(options = {}) {
|
|
83
85
|
platformKey();
|
|
84
86
|
await requireCommands(["git", "curl", "tar", "unzip"]);
|
|
85
|
-
const
|
|
87
|
+
const prepared = await prepareWorkspace(options);
|
|
88
|
+
const workspace = prepared.workspace;
|
|
86
89
|
const manifestPath = path.resolve(options.manifest || path.join(workspace, "bootstrap", "cli-manifest.json"));
|
|
87
90
|
if (!(await confirm("Download or refresh CLI binaries?", { yes: options.yes }))) throw new Error("install cancelled");
|
|
88
|
-
const manifest = await installToolsFromManifest(workspace, manifestPath);
|
|
91
|
+
const manifest = await installToolsFromManifest(workspace, manifestPath, options);
|
|
89
92
|
const configExists = fs.existsSync(path.join(workspace, "config", "harness-config.yaml")) ||
|
|
90
93
|
fs.existsSync(path.join(workspace, "config", "qdm-cli-paths.env"));
|
|
91
94
|
const overwrite = configExists && !options.yes ? await confirm("Overwrite existing local config files?", { defaultNo: true }) : !configExists;
|
|
@@ -101,7 +104,8 @@ export async function installCommand(options = {}) {
|
|
|
101
104
|
const doctor = await collectDoctor(workspace, { ...options, casConfigDir });
|
|
102
105
|
for (const check of doctor.checks) console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name}`);
|
|
103
106
|
if (doctor.checks.some((check) => !check.ok)) throw new Error("doctor failed; install is incomplete");
|
|
104
|
-
const
|
|
107
|
+
const gitProtocol = prepared.gitProtocol || await repoProtocol(workspace) || "ssh";
|
|
108
|
+
const updates = await checkUpdates(workspace, { ...options, gitProtocol });
|
|
105
109
|
if (updates.hasUpdates) {
|
|
106
110
|
console.log(`Updates available. Next step: npx @lumi-ai-lab/harness-data update --dir "${workspace}"`);
|
|
107
111
|
}
|
|
@@ -110,7 +114,9 @@ export async function installCommand(options = {}) {
|
|
|
110
114
|
wikisCommit: await submoduleCommit(workspace),
|
|
111
115
|
manifestSha256: manifestDigest(manifest),
|
|
112
116
|
lastCheckAt: new Date().toISOString(),
|
|
113
|
-
packageVersion: packageVersion()
|
|
117
|
+
packageVersion: packageVersion(),
|
|
118
|
+
gitProtocol,
|
|
119
|
+
repoUrl: prepared.repoUrl
|
|
114
120
|
});
|
|
115
121
|
console.log(`Harness Data installed: ${workspace}`);
|
|
116
122
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -7,6 +7,7 @@ import { installToolsFromManifest, manifestDigest, readManifest } from "../lib/m
|
|
|
7
7
|
import { behindCount, currentCommit, dirtyPaths, submoduleCommit, submodulePointerChanged, submoduleRemoteBehind } from "../lib/git.js";
|
|
8
8
|
import { packageVersion } from "../lib/package.js";
|
|
9
9
|
import { collectDoctor } from "./doctor.js";
|
|
10
|
+
import { normalizeGitProtocol, repoProtocol, repoUrl, runGitWithProtocol, syncWikisSubmodule } from "../lib/git-auth.js";
|
|
10
11
|
|
|
11
12
|
async function npmLatest() {
|
|
12
13
|
try {
|
|
@@ -35,12 +36,12 @@ function versionChanged(current, latest) {
|
|
|
35
36
|
return latest && latest !== current;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
export async function checkUpdates(workspace) {
|
|
39
|
+
export async function checkUpdates(workspace, gitOptions = {}) {
|
|
39
40
|
const manifestPath = path.join(workspace, "bootstrap", "cli-manifest.json");
|
|
40
41
|
const currentInstaller = packageVersion();
|
|
41
42
|
const latestInstaller = await npmLatest();
|
|
42
|
-
const mainBehind = await behindCount(workspace);
|
|
43
|
-
const wikisBehind = await submoduleRemoteBehind(workspace);
|
|
43
|
+
const mainBehind = await behindCount(workspace, gitOptions);
|
|
44
|
+
const wikisBehind = await submoduleRemoteBehind(workspace, "wikis", gitOptions);
|
|
44
45
|
const manifest = fs.existsSync(manifestPath) ? readManifest(manifestPath) : { tools: [] };
|
|
45
46
|
const digest = manifestDigest(manifest);
|
|
46
47
|
const remote = await remoteManifest(workspace);
|
|
@@ -78,6 +79,13 @@ function printUpdateSummary(updates) {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
async function updateProtocol(workspace, options) {
|
|
83
|
+
const requested = normalizeGitProtocol(options.gitProtocol);
|
|
84
|
+
if (requested !== "auto") return requested;
|
|
85
|
+
const state = readUserState();
|
|
86
|
+
return state.gitProtocol || await repoProtocol(workspace) || "ssh";
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
export async function updateCommand(options = {}) {
|
|
82
90
|
const workspace = findWorkspaceDir(options.dir);
|
|
83
91
|
if (!fs.existsSync(workspace)) throw new Error(`workspace does not exist: ${workspace}`);
|
|
@@ -90,7 +98,10 @@ export async function updateCommand(options = {}) {
|
|
|
90
98
|
process.exitCode = 1;
|
|
91
99
|
return;
|
|
92
100
|
}
|
|
93
|
-
const
|
|
101
|
+
const gitProtocol = await updateProtocol(workspace, options);
|
|
102
|
+
await syncWikisSubmodule(workspace, gitProtocol);
|
|
103
|
+
const originUrl = await repoUrl(workspace);
|
|
104
|
+
const updates = await checkUpdates(workspace, { ...options, gitProtocol });
|
|
94
105
|
printUpdateSummary(updates);
|
|
95
106
|
if (options.check) {
|
|
96
107
|
writeState(workspace, {
|
|
@@ -98,7 +109,9 @@ export async function updateCommand(options = {}) {
|
|
|
98
109
|
wikisCommit: await submoduleCommit(workspace),
|
|
99
110
|
manifestSha256: updates.cli.digest,
|
|
100
111
|
packageVersion: packageVersion(),
|
|
101
|
-
lastCheckAt: new Date().toISOString()
|
|
112
|
+
lastCheckAt: new Date().toISOString(),
|
|
113
|
+
gitProtocol,
|
|
114
|
+
repoUrl: originUrl
|
|
102
115
|
});
|
|
103
116
|
return updates;
|
|
104
117
|
}
|
|
@@ -109,22 +122,25 @@ export async function updateCommand(options = {}) {
|
|
|
109
122
|
wikisCommit: await submoduleCommit(workspace),
|
|
110
123
|
manifestSha256: updates.cli.digest,
|
|
111
124
|
packageVersion: packageVersion(),
|
|
112
|
-
lastCheckAt: new Date().toISOString()
|
|
125
|
+
lastCheckAt: new Date().toISOString(),
|
|
126
|
+
gitProtocol,
|
|
127
|
+
repoUrl: originUrl
|
|
113
128
|
});
|
|
114
129
|
return updates;
|
|
115
130
|
}
|
|
116
131
|
if (!(await confirm(`Apply updates to ${workspace}?`, { yes: options.yes, defaultNo: true }))) throw new Error("update cancelled");
|
|
117
132
|
if (updates.mainRepo.update) {
|
|
118
|
-
await
|
|
133
|
+
await runGitWithProtocol(gitProtocol, ["pull", "--ff-only"], { ...options, cwd: workspace, stdio: "inherit" });
|
|
119
134
|
}
|
|
120
135
|
if (updates.wikis.update || updates.mainRepo.update) {
|
|
121
136
|
if (!(await confirm("Update wikis submodule? This can change Agent behavior.", { yes: options.yes, defaultNo: true }))) {
|
|
122
137
|
console.warn("warning: wikis update skipped");
|
|
123
138
|
} else {
|
|
124
|
-
await
|
|
139
|
+
await syncWikisSubmodule(workspace, gitProtocol);
|
|
140
|
+
await runGitWithProtocol(gitProtocol, ["submodule", "update", "--init", "--recursive", "--remote", "wikis"], { ...options, cwd: workspace, stdio: "inherit" });
|
|
125
141
|
}
|
|
126
142
|
}
|
|
127
|
-
await installToolsFromManifest(workspace, path.join(workspace, "bootstrap", "cli-manifest.json"));
|
|
143
|
+
await installToolsFromManifest(workspace, path.join(workspace, "bootstrap", "cli-manifest.json"), options);
|
|
128
144
|
await run(path.join(workspace, "bin", "data-harness-cli"), ["wikis", "build-index"], { cwd: workspace, stdio: "inherit" });
|
|
129
145
|
const doctor = await collectDoctor(workspace, options);
|
|
130
146
|
for (const check of doctor.checks) console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name}`);
|
|
@@ -134,7 +150,9 @@ export async function updateCommand(options = {}) {
|
|
|
134
150
|
wikisCommit: await submoduleCommit(workspace),
|
|
135
151
|
manifestSha256: updates.cli.digest,
|
|
136
152
|
packageVersion: packageVersion(),
|
|
137
|
-
lastCheckAt: new Date().toISOString()
|
|
153
|
+
lastCheckAt: new Date().toISOString(),
|
|
154
|
+
gitProtocol,
|
|
155
|
+
repoUrl: originUrl
|
|
138
156
|
});
|
|
139
157
|
console.log(`Harness Data updated: ${workspace}`);
|
|
140
158
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { run } from "./exec.js";
|
|
5
|
+
|
|
6
|
+
export const gitUrls = {
|
|
7
|
+
ssh: {
|
|
8
|
+
repo: "git@github.com:lumi-ai-lab/harness-data.git",
|
|
9
|
+
wikis: "git@github.com:lumi-ai-lab/harness-data-wikis.git"
|
|
10
|
+
},
|
|
11
|
+
https: {
|
|
12
|
+
repo: "https://github.com/lumi-ai-lab/harness-data.git",
|
|
13
|
+
wikis: "https://github.com/lumi-ai-lab/harness-data-wikis.git"
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const protocols = new Set(["auto", "ssh", "https"]);
|
|
18
|
+
|
|
19
|
+
export function normalizeGitProtocol(value = "auto") {
|
|
20
|
+
if (!protocols.has(value)) throw new Error("--git-protocol must be one of: auto, ssh, https");
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function protocolFromUrl(url = "") {
|
|
25
|
+
if (url.startsWith("git@") || url.startsWith("ssh://")) return "ssh";
|
|
26
|
+
if (url.startsWith("https://")) return "https";
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function repoUrl(workspace) {
|
|
31
|
+
const result = await run("git", ["remote", "get-url", "origin"], { cwd: workspace, allowFailure: true });
|
|
32
|
+
return result.stdout.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function repoProtocol(workspace) {
|
|
36
|
+
return protocolFromUrl(await repoUrl(workspace));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function httpsFailureMessage() {
|
|
40
|
+
return [
|
|
41
|
+
"HTTPS access to the private GitHub repositories failed.",
|
|
42
|
+
"Fix by configuring a GitHub SSH key, running gh auth login, enabling Git Credential Manager,",
|
|
43
|
+
"or passing --github-token-env GITHUB_TOKEN with a token stored in that environment variable."
|
|
44
|
+
].join(" ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tokenEnvName(options = {}) {
|
|
48
|
+
const name = options.githubTokenEnv;
|
|
49
|
+
if (!name) return "";
|
|
50
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("--github-token-env must be a valid environment variable name");
|
|
51
|
+
if (!process.env[name]) throw new Error(`environment variable ${name} is empty or unset`);
|
|
52
|
+
return name;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function withHttpsAuthEnv(options, callback) {
|
|
56
|
+
const name = tokenEnvName(options);
|
|
57
|
+
if (!name) return callback({ GIT_TERMINAL_PROMPT: "0" });
|
|
58
|
+
|
|
59
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-data-git-"));
|
|
60
|
+
const askpass = path.join(dir, "askpass.sh");
|
|
61
|
+
fs.writeFileSync(askpass, [
|
|
62
|
+
"#!/bin/sh",
|
|
63
|
+
"case \"$1\" in",
|
|
64
|
+
"*Username*) printf '%s\\n' x-access-token ;;",
|
|
65
|
+
"*Password*) printenv \"$GITHUB_TOKEN_ENV_NAME\" ;;",
|
|
66
|
+
"*) printf '\\n' ;;",
|
|
67
|
+
"esac",
|
|
68
|
+
""
|
|
69
|
+
].join("\n"), { mode: 0o700 });
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return await callback({
|
|
73
|
+
GIT_ASKPASS: askpass,
|
|
74
|
+
GITHUB_TOKEN_ENV_NAME: name,
|
|
75
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
76
|
+
});
|
|
77
|
+
} finally {
|
|
78
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runGitWithProtocol(protocol, commandArgs, options = {}) {
|
|
83
|
+
if (protocol === "https") {
|
|
84
|
+
return withHttpsAuthEnv(options, (env) => run("git", commandArgs, { ...options, env: { ...(options.env || {}), ...env } }));
|
|
85
|
+
}
|
|
86
|
+
const env = {
|
|
87
|
+
...(options.env || {}),
|
|
88
|
+
GIT_SSH_COMMAND: "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
|
|
89
|
+
};
|
|
90
|
+
return run("git", commandArgs, { ...options, env });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function canAccess(protocol, url, options) {
|
|
94
|
+
const result = await runGitWithProtocol(protocol, ["ls-remote", url, "HEAD"], { ...options, allowFailure: true });
|
|
95
|
+
return result.code === 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function resolveInstallProtocol(options = {}) {
|
|
99
|
+
const requested = normalizeGitProtocol(options.gitProtocol);
|
|
100
|
+
if (requested === "ssh") {
|
|
101
|
+
if (await canAccess("ssh", gitUrls.ssh.wikis, options)) return "ssh";
|
|
102
|
+
throw new Error("SSH access to the private GitHub repositories failed. Configure a GitHub SSH key or retry with --git-protocol https.");
|
|
103
|
+
}
|
|
104
|
+
if (requested === "https") {
|
|
105
|
+
if (await canAccess("https", gitUrls.https.wikis, options)) return "https";
|
|
106
|
+
throw new Error(httpsFailureMessage());
|
|
107
|
+
}
|
|
108
|
+
if (await canAccess("ssh", gitUrls.ssh.wikis, options)) return "ssh";
|
|
109
|
+
console.warn("SSH unavailable, falling back to HTTPS");
|
|
110
|
+
if (await canAccess("https", gitUrls.https.wikis, options)) return "https";
|
|
111
|
+
throw new Error(httpsFailureMessage());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function syncWikisSubmodule(workspace, protocol, options = {}) {
|
|
115
|
+
await run("git", ["config", "submodule.wikis.url", gitUrls[protocol].wikis], { cwd: workspace, stdio: options.stdio || "inherit" });
|
|
116
|
+
await run("git", ["submodule", "sync", "wikis"], { cwd: workspace, stdio: options.stdio || "inherit" });
|
|
117
|
+
}
|
package/src/lib/git.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { run } from "./exec.js";
|
|
4
|
+
import { runGitWithProtocol } from "./git-auth.js";
|
|
4
5
|
|
|
5
6
|
export async function isGitRepo(dir) {
|
|
6
7
|
if (!fs.existsSync(path.join(dir, ".git"))) return false;
|
|
@@ -33,10 +34,18 @@ export async function remoteTracking(dir) {
|
|
|
33
34
|
return result.code === 0 ? result.stdout.trim() : "";
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
async function fetchQuiet(dir, options = {}) {
|
|
38
|
+
if (options.gitProtocol) {
|
|
39
|
+
await runGitWithProtocol(options.gitProtocol, ["fetch", "--quiet"], { ...options, cwd: dir, allowFailure: true });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
await run("git", ["fetch", "--quiet"], { cwd: dir, allowFailure: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function behindCount(dir, options = {}) {
|
|
37
46
|
const upstream = await remoteTracking(dir);
|
|
38
47
|
if (!upstream) return 0;
|
|
39
|
-
await
|
|
48
|
+
await fetchQuiet(dir, options);
|
|
40
49
|
const result = await run("git", ["rev-list", "--count", `HEAD..${upstream}`], { cwd: dir, allowFailure: true });
|
|
41
50
|
return Number(result.stdout.trim() || "0");
|
|
42
51
|
}
|
|
@@ -46,10 +55,10 @@ export async function submoduleCommit(dir, name = "wikis") {
|
|
|
46
55
|
return result.code === 0 ? result.stdout.trim() : "";
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
export async function submoduleRemoteBehind(dir, name = "wikis") {
|
|
58
|
+
export async function submoduleRemoteBehind(dir, name = "wikis", options = {}) {
|
|
50
59
|
const subdir = path.join(dir, name);
|
|
51
60
|
if (!fs.existsSync(subdir)) return 0;
|
|
52
|
-
await
|
|
61
|
+
await fetchQuiet(subdir, options);
|
|
53
62
|
const upstream = await remoteTracking(subdir);
|
|
54
63
|
if (!upstream) return 0;
|
|
55
64
|
const result = await run("git", ["rev-list", "--count", `HEAD..${upstream}`], { cwd: subdir, allowFailure: true });
|
package/src/lib/manifest.js
CHANGED
|
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import https from "node:https";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { run } from "./exec.js";
|
|
5
|
+
import { commandExists, run } from "./exec.js";
|
|
6
6
|
import { platformKey } from "./platform.js";
|
|
7
7
|
|
|
8
8
|
export function readManifest(file) {
|
|
@@ -13,11 +13,14 @@ export function manifestDigest(manifest) {
|
|
|
13
13
|
return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function download(url, file) {
|
|
16
|
+
function download(url, file, headers = {}) {
|
|
17
17
|
return new Promise((resolve, reject) => {
|
|
18
|
-
const request = https.get(url, (response) => {
|
|
18
|
+
const request = https.get(url, { headers }, (response) => {
|
|
19
19
|
if ([301, 302, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
20
|
-
|
|
20
|
+
const current = new URL(url);
|
|
21
|
+
const next = new URL(response.headers.location, url);
|
|
22
|
+
const nextHeaders = current.hostname === next.hostname ? headers : {};
|
|
23
|
+
download(response.headers.location, file, nextHeaders).then(resolve, reject);
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
23
26
|
if (response.statusCode !== 200) {
|
|
@@ -35,11 +38,97 @@ function download(url, file) {
|
|
|
35
38
|
});
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
function assetName(asset) {
|
|
42
|
+
return path.basename(new URL(asset.url).pathname);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function githubAssetParts(asset) {
|
|
46
|
+
const url = new URL(asset.url);
|
|
47
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
48
|
+
if (url.hostname !== "github.com" || parts.length < 6 || parts[2] !== "releases" || parts[3] !== "download") return null;
|
|
49
|
+
return {
|
|
50
|
+
repo: `${parts[0]}/${parts[1]}`,
|
|
51
|
+
tag: parts[4],
|
|
52
|
+
name: parts.slice(5).join("/")
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function ghAuthenticated() {
|
|
57
|
+
if (!(await commandExists("gh"))) return false;
|
|
58
|
+
const result = await run("gh", ["auth", "status"], { allowFailure: true });
|
|
59
|
+
return result.code === 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function downloadPrivateWithGh(asset, file) {
|
|
63
|
+
const parts = githubAssetParts(asset);
|
|
64
|
+
if (!parts || !(await ghAuthenticated())) return false;
|
|
65
|
+
const dir = fs.mkdtempSync(path.join(path.dirname(file), "gh-download-"));
|
|
66
|
+
try {
|
|
67
|
+
const result = await run("gh", ["release", "download", parts.tag, "--repo", parts.repo, "--pattern", parts.name, "--dir", dir], { allowFailure: true });
|
|
68
|
+
if (result.code !== 0) return false;
|
|
69
|
+
const downloaded = path.join(dir, parts.name);
|
|
70
|
+
if (!fs.existsSync(downloaded)) return false;
|
|
71
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
72
|
+
fs.renameSync(downloaded, file);
|
|
73
|
+
return true;
|
|
74
|
+
} finally {
|
|
75
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function githubToken(options = {}) {
|
|
80
|
+
const name = options.githubTokenEnv;
|
|
81
|
+
if (!name) return "";
|
|
82
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("--github-token-env must be a valid environment variable name");
|
|
83
|
+
return process.env[name] || "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function githubAssetApiUrl(asset, token) {
|
|
87
|
+
const parts = githubAssetParts(asset);
|
|
88
|
+
if (!parts) return "";
|
|
89
|
+
const dir = fs.mkdtempSync(path.join(process.cwd(), ".bootstrap-cache-gh-"));
|
|
90
|
+
const tmp = path.join(dir, "release.json");
|
|
91
|
+
try {
|
|
92
|
+
await download(`https://api.github.com/repos/${parts.repo}/releases/tags/${parts.tag}`, tmp, {
|
|
93
|
+
Authorization: `Bearer ${token}`,
|
|
94
|
+
"User-Agent": "harness-data-installer"
|
|
95
|
+
});
|
|
96
|
+
const release = JSON.parse(fs.readFileSync(tmp, "utf8"));
|
|
97
|
+
return (release.assets || []).find((item) => item.name === parts.name)?.url || "";
|
|
98
|
+
} finally {
|
|
99
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function downloadPrivateWithToken(asset, file, options = {}) {
|
|
104
|
+
const token = githubToken(options);
|
|
105
|
+
if (!token) return false;
|
|
106
|
+
const apiUrl = await githubAssetApiUrl(asset, token);
|
|
107
|
+
if (!apiUrl) return false;
|
|
108
|
+
await download(apiUrl, file, {
|
|
109
|
+
Authorization: `Bearer ${token}`,
|
|
110
|
+
Accept: "application/octet-stream",
|
|
111
|
+
"User-Agent": "harness-data-installer"
|
|
112
|
+
});
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function downloadAsset(tool, asset, file, options = {}) {
|
|
117
|
+
if (!tool.private) {
|
|
118
|
+
await download(asset.url, file);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
console.log(`Downloading private ${tool.name} asset from ${tool.repo}`);
|
|
122
|
+
if (await downloadPrivateWithGh(asset, file)) return;
|
|
123
|
+
if (await downloadPrivateWithToken(asset, file, options)) return;
|
|
124
|
+
throw new Error(`private GitHub Release asset requires gh auth login or --github-token-env GITHUB_TOKEN: ${assetName(asset)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function expectedSha256(tool, asset, options = {}) {
|
|
39
128
|
if (asset.sha256) return asset.sha256;
|
|
40
129
|
try {
|
|
41
130
|
const tmp = path.join(fs.mkdtempSync(path.join(process.cwd(), ".bootstrap-cache-sha-")), "asset.sha256");
|
|
42
|
-
await
|
|
131
|
+
await downloadAsset(tool, { ...asset, url: `${asset.url}.sha256` }, tmp, options);
|
|
43
132
|
return fs.readFileSync(tmp, "utf8").trim().split(/\s+/)[0];
|
|
44
133
|
} catch {
|
|
45
134
|
return "";
|
|
@@ -50,7 +139,7 @@ function fileSha256(file) {
|
|
|
50
139
|
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
51
140
|
}
|
|
52
141
|
|
|
53
|
-
export async function installToolsFromManifest(workspace, manifestPath) {
|
|
142
|
+
export async function installToolsFromManifest(workspace, manifestPath, options = {}) {
|
|
54
143
|
const manifest = readManifest(manifestPath);
|
|
55
144
|
const key = platformKey();
|
|
56
145
|
const cacheDir = path.join(workspace, ".bootstrap-cache");
|
|
@@ -61,10 +150,10 @@ export async function installToolsFromManifest(workspace, manifestPath) {
|
|
|
61
150
|
for (const tool of manifest.tools || []) {
|
|
62
151
|
const asset = tool.platforms?.[key];
|
|
63
152
|
if (!asset?.url) throw new Error(`manifest missing ${tool.name} asset for ${key}`);
|
|
64
|
-
const archive = path.join(cacheDir,
|
|
153
|
+
const archive = path.join(cacheDir, assetName(asset));
|
|
65
154
|
console.log(`Downloading ${tool.name} ${tool.version} (${key})`);
|
|
66
|
-
await
|
|
67
|
-
const sha = await expectedSha256(asset);
|
|
155
|
+
await downloadAsset(tool, asset, archive, options);
|
|
156
|
+
const sha = await expectedSha256(tool, asset, options);
|
|
68
157
|
if (sha && fileSha256(archive) !== sha) throw new Error(`${tool.name} sha256 mismatch`);
|
|
69
158
|
if (!sha) console.warn(`warning: ${tool.name} has no sha256; continuing without checksum`);
|
|
70
159
|
if (archive.endsWith(".zip")) {
|