@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi-ai-lab/harness-data",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Installer and updater for Harness Data",
5
5
  "type": "module",
6
6
  "bin": {
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
  }
@@ -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 run("git", ["clone", "--recurse-submodules", "--branch", branch, repo, target], { stdio: "inherit" });
30
- return target;
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 workspace = await prepareWorkspace(options);
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 updates = await checkUpdates(workspace);
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
  }
@@ -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 updates = await checkUpdates(workspace);
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 run("git", ["pull", "--ff-only"], { cwd: workspace, stdio: "inherit" });
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 run("git", ["submodule", "update", "--init", "--recursive", "--remote", "wikis"], { cwd: workspace, stdio: "inherit" });
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
- export async function behindCount(dir) {
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 run("git", ["fetch", "--quiet"], { cwd: dir, allowFailure: true });
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 run("git", ["fetch", "--quiet"], { cwd: subdir, allowFailure: true });
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 });
@@ -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
- download(response.headers.location, file).then(resolve, reject);
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
- async function expectedSha256(asset) {
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 download(`${asset.url}.sha256`, tmp);
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, path.basename(new URL(asset.url).pathname));
153
+ const archive = path.join(cacheDir, assetName(asset));
65
154
  console.log(`Downloading ${tool.name} ${tool.version} (${key})`);
66
- await download(asset.url, archive);
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")) {