@lumi-ai-lab/harness-data 0.0.4 → 0.0.5

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
@@ -1,19 +1,43 @@
1
1
  # Harness Data npm installer
2
2
 
3
+ Install a Harness Data runtime in the current directory:
4
+
3
5
  ```bash
4
6
  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
8
- npx @lumi-ai-lab/harness-data install --yes --agent pi --cas-config-dir /secure/path/to/cas
9
- npx @lumi-ai-lab/harness-data doctor --dir ~/harness-data
10
- npx @lumi-ai-lab/harness-data update --dir ~/harness-data
11
7
  ```
12
8
 
13
- This package is only the installer and updater. The full Harness Data workspace is cloned and maintained outside the npm package.
9
+ Install into an explicit runtime directory:
10
+
11
+ ```bash
12
+ npx @lumi-ai-lab/harness-data install --dir /path/to/runtime
13
+ ```
14
+
15
+ Use a GitHub token for private Release assets:
16
+
17
+ ```bash
18
+ npx @lumi-ai-lab/harness-data install --github-token ...
19
+ ```
20
+
21
+ or:
22
+
23
+ ```bash
24
+ GITHUB_TOKEN=... npx @lumi-ai-lab/harness-data install
25
+ ```
26
+
27
+ Without a GitHub token, the installer interactively asks for local absolute paths to `cas-cli`, `qdm-indicators-cli`, `qdm-cmr-cli`, and `harness-data-wikis`. CAS username and password are always collected interactively.
28
+
29
+ Update an existing runtime interactively:
30
+
31
+ ```bash
32
+ npx @lumi-ai-lab/harness-data update
33
+ ```
34
+
35
+ Diagnose a runtime:
14
36
 
15
- 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.
37
+ ```bash
38
+ npx @lumi-ai-lab/harness-data doctor
39
+ ```
16
40
 
17
- 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`.
41
+ The runtime is assembled from the `harness-data` runtime bundle, platform-specific CLI Release assets, `harness-data-wikis`, generated local config, CAS credentials, and selected Agent symlinks.
18
42
 
19
- `--agent` supports `claude`, `codex`, `pi`, `both` (Claude + Codex), and `all` (Claude + Codex + Pi).
43
+ `--agent` supports `claude`, `codex`, `pi`, and `all`; the default is `all`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi-ai-lab/harness-data",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Installer and updater for Harness Data",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -36,13 +36,13 @@ export async function main(argv) {
36
36
  console.log(`Usage: harness-data <install|update|doctor|version> [options]
37
37
 
38
38
  Commands:
39
- install Clone/reuse a Harness Data workspace and complete setup
40
- update Check and apply installer, repository, CLI, and wikis updates
39
+ install Install a Harness Data runtime in the current directory
40
+ update Interactively check and apply runtime, CLI, and wikis updates
41
41
  doctor Diagnose workspace CLI, config, auth, index, and Agent hooks
42
42
  version Print installer, repository, wikis, and manifest versions
43
43
 
44
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`);
45
+ --dir PATH Runtime directory (default: current directory)
46
+ --github-token TOKEN GitHub token for private Release assets`);
47
47
  if (unknown) process.exitCode = 1;
48
48
  }
@@ -2,8 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { run } from "../lib/exec.js";
4
4
  import { findWorkspaceDir } from "../lib/paths.js";
5
- import { currentCommit, submoduleCommit } from "../lib/git.js";
6
- import { validateCasConfigDir } from "../lib/config.js";
5
+ import { binaryName } from "../lib/platform.js";
7
6
 
8
7
  function existsExecutable(file) {
9
8
  try {
@@ -16,7 +15,7 @@ function existsExecutable(file) {
16
15
 
17
16
  function agentOk(workspace, name) {
18
17
  const target = path.join(workspace, `.${name}`);
19
- const source = path.join(workspace, ".agents", name);
18
+ const source = path.join(workspace, "agents", name);
20
19
  if (!fs.existsSync(target) || !fs.existsSync(source)) return false;
21
20
  try {
22
21
  return fs.realpathSync(target) === fs.realpathSync(source);
@@ -26,7 +25,7 @@ function agentOk(workspace, name) {
26
25
  }
27
26
 
28
27
  async function tokenCheck(workspace, binary, env) {
29
- const file = path.join(workspace, "bin", binary);
28
+ const file = path.join(workspace, "bin", binaryName(binary));
30
29
  if (!existsExecutable(file)) return false;
31
30
  const result = await run(file, ["config", "check-token"], { cwd: workspace, env, allowFailure: true });
32
31
  return result.code === 0;
@@ -40,9 +39,9 @@ function configPathsValid(workspace) {
40
39
  return matches.length >= 3 && matches.every((entry) => fs.existsSync(entry));
41
40
  }
42
41
 
43
- function casCredentialsValid(dir) {
42
+ function jsonFileValid(file) {
44
43
  try {
45
- validateCasConfigDir(dir);
44
+ JSON.parse(fs.readFileSync(file, "utf8"));
46
45
  return true;
47
46
  } catch {
48
47
  return false;
@@ -55,24 +54,27 @@ export async function collectDoctor(workspace, options = {}) {
55
54
  const checks = [];
56
55
  const add = (name, ok, detail = "") => checks.push({ name, ok, detail });
57
56
 
58
- add("workspace", fs.existsSync(path.join(workspace, "bootstrap", "cli-manifest.json")), workspace);
59
- add("wikis submodule", fs.existsSync(path.join(workspace, "wikis", ".git")) || fs.existsSync(path.join(workspace, ".git", "modules", "wikis")));
57
+ add("runtime", fs.existsSync(path.join(workspace, "bootstrap", "cli-manifest.json")) && fs.existsSync(path.join(workspace, "agents")), workspace);
58
+ add("wikis/spec", fs.existsSync(path.join(workspace, "wikis", "spec")));
59
+ add("wikis/playbooks", fs.existsSync(path.join(workspace, "wikis", "playbooks")));
60
+ add("wikis/templates", fs.existsSync(path.join(workspace, "wikis", "templates")));
60
61
  for (const binary of ["data-harness-cli", "qdm-cmr-cli", "qdm-indicators-cli", "cas-cli"]) {
61
- add(`bin/${binary}`, existsExecutable(path.join(workspace, "bin", binary)));
62
+ add(`bin/${binary}`, existsExecutable(path.join(workspace, "bin", binaryName(binary))));
62
63
  }
63
64
  add("config/harness-config.yaml", fs.existsSync(path.join(workspace, "config", "harness-config.yaml")));
64
65
  add("config/qdm-cli-paths.env", fs.existsSync(path.join(workspace, "config", "qdm-cli-paths.env")));
65
66
  add("config CLI paths", configPathsValid(workspace));
66
- add("CAS credentials", casCredentialsValid(casConfigDir), casConfigDir);
67
+ add("CAS credentials file", jsonFileValid(path.join(casConfigDir, "config.json")), casConfigDir);
67
68
  add("CMR token", await tokenCheck(workspace, "qdm-cmr-cli", env));
68
69
  add("Indicators token", await tokenCheck(workspace, "qdm-indicators-cli", env));
69
- add("wikis index", fs.existsSync(path.join(workspace, ".harness", "index", "wikis-index.json")) || fs.existsSync(path.join(workspace, ".harness", "index", "wikis-runtime-index.json")));
70
70
  add("Agent hook", agentOk(workspace, "claude") || agentOk(workspace, "codex") || agentOk(workspace, "pi"));
71
+ if (!fs.existsSync(path.join(workspace, ".harness", "index", "wikis-index.json")) &&
72
+ !fs.existsSync(path.join(workspace, ".harness", "index", "wikis-runtime-index.json"))) {
73
+ checks.push({ name: "wikis index", ok: true, detail: "missing; run data-harness-cli wikis build-index --skip-checks" });
74
+ }
71
75
 
72
76
  return {
73
77
  workspace,
74
- mainCommit: await currentCommit(workspace),
75
- wikisCommit: await submoduleCommit(workspace),
76
78
  checks
77
79
  };
78
80
  }
@@ -83,7 +85,7 @@ export async function doctorCommand(options = {}) {
83
85
  if (options.json) {
84
86
  console.log(JSON.stringify(report, null, 2));
85
87
  } else {
86
- console.log(`Harness Data doctor: ${report.workspace}`);
88
+ console.log(`Harness Data runtime doctor: ${report.workspace}`);
87
89
  for (const check of report.checks) {
88
90
  console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name}${check.detail ? ` (${check.detail})` : ""}`);
89
91
  }
@@ -1,16 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { commandExists, run } from "../lib/exec.js";
4
- import { writeLocalConfig, validateCasConfigDir, linkAgents } from "../lib/config.js";
5
- import { confirm, chooseAgent } from "../lib/prompt.js";
6
- import { defaultWorkspaceDir, looksLikeWorkspace, resolveWorkspaceDir, writeState } from "../lib/paths.js";
7
- import { isGitRepo, currentCommit, submoduleCommit } from "../lib/git.js";
4
+ import { writeLocalConfig, linkAgents } from "../lib/config.js";
5
+ import { ask, askSecret, chooseAgent } from "../lib/prompt.js";
6
+ import { resolveWorkspaceDir, writeState } from "../lib/paths.js";
8
7
  import { installToolsFromManifest, manifestDigest, readManifest } from "../lib/manifest.js";
9
- import { platformKey } from "../lib/platform.js";
8
+ import { binaryName, platformKey } from "../lib/platform.js";
10
9
  import { collectDoctor } from "./doctor.js";
11
- import { checkUpdates } from "./update.js";
12
10
  import { packageVersion } from "../lib/package.js";
13
- import { gitUrls, repoProtocol, repoUrl, resolveInstallProtocol, runGitWithProtocol, syncWikisSubmodule } from "../lib/git-auth.js";
11
+ import { downloadReleaseAsset, findReleaseAsset, githubToken, hasGithubAuth, latestRelease } from "../lib/github.js";
12
+
13
+ const runtimeRepo = "lumi-ai-lab/harness-data";
14
+ const wikisRepo = "lumi-ai-lab/harness-data-wikis";
14
15
 
15
16
  async function requireCommands(commands) {
16
17
  for (const command of commands) {
@@ -18,107 +19,192 @@ async function requireCommands(commands) {
18
19
  }
19
20
  }
20
21
 
21
- async function prepareWorkspace(options) {
22
- const target = resolveWorkspaceDir(options.dir || (looksLikeWorkspace(process.cwd()) ? process.cwd() : defaultWorkspaceDir()));
23
- const branch = options.branch || "master";
24
- if (!fs.existsSync(target)) {
25
- const protocol = await resolveInstallProtocol(options);
26
- const repo = options.repo || gitUrls[protocol].repo;
27
- if (!(await confirm(`Clone ${repo} to ${target}?`, { yes: options.yes }))) throw new Error("install cancelled");
28
- fs.mkdirSync(path.dirname(target), { recursive: true });
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 };
22
+ async function prepareRuntimeDir(options) {
23
+ const runtimeDir = resolveWorkspaceDir(options.dir || process.cwd());
24
+ fs.mkdirSync(runtimeDir, { recursive: true });
25
+ return runtimeDir;
26
+ }
27
+
28
+ export async function installRuntimeBundle(runtimeDir, options = {}) {
29
+ if (!options.force && fs.existsSync(path.join(runtimeDir, "agents")) &&
30
+ fs.existsSync(path.join(runtimeDir, "config")) &&
31
+ fs.existsSync(path.join(runtimeDir, "bootstrap", "cli-manifest.json"))) {
32
+ return { tag: "", skipped: true };
33
+ }
34
+
35
+ const release = await latestRelease(runtimeRepo, options);
36
+ const tag = release.tag_name;
37
+ const assetName = `harness-data-runtime-${tag}.tar.gz`;
38
+ const asset = findReleaseAsset(release, assetName);
39
+ const shaAsset = findReleaseAsset(release, `${assetName}.sha256`);
40
+ if (!asset) throw new Error(`runtime bundle asset missing in ${runtimeRepo} ${tag}: ${assetName}`);
41
+
42
+ const cacheDir = path.join(runtimeDir, ".bootstrap-cache");
43
+ fs.mkdirSync(cacheDir, { recursive: true });
44
+ const archive = path.join(cacheDir, assetName);
45
+ await downloadReleaseAsset(asset, archive, options);
46
+ if (shaAsset) {
47
+ const shaFile = `${archive}.sha256`;
48
+ await downloadReleaseAsset(shaAsset, shaFile, options);
49
+ const expected = fs.readFileSync(shaFile, "utf8").trim().split(/\s+/)[0];
50
+ const crypto = await import("node:crypto");
51
+ const actual = crypto.createHash("sha256").update(fs.readFileSync(archive)).digest("hex");
52
+ if (expected && actual !== expected) throw new Error("runtime bundle sha256 mismatch");
53
+ } else {
54
+ console.warn("warning: runtime bundle has no sha256 asset; continuing without checksum");
55
+ }
56
+
57
+ const extractDir = fs.mkdtempSync(path.join(cacheDir, "runtime-"));
58
+ await run("tar", ["-xzf", archive, "-C", extractDir], { stdio: "inherit" });
59
+ for (const dir of ["agents", "bootstrap"]) {
60
+ const source = path.join(extractDir, dir);
61
+ if (!fs.existsSync(source)) throw new Error(`runtime bundle missing ${dir}/`);
62
+ fs.rmSync(path.join(runtimeDir, dir), { recursive: true, force: true });
63
+ fs.cpSync(source, path.join(runtimeDir, dir), { recursive: true });
33
64
  }
34
- if (!(await isGitRepo(target)) || !looksLikeWorkspace(target)) {
35
- throw new Error(`${target} exists but is not a valid harness-data workspace; pass --dir or move the directory`);
65
+ const configSource = path.join(extractDir, "config");
66
+ if (!fs.existsSync(configSource)) throw new Error("runtime bundle missing config/");
67
+ fs.mkdirSync(path.join(runtimeDir, "config"), { recursive: true });
68
+ for (const file of fs.readdirSync(configSource)) {
69
+ fs.copyFileSync(path.join(configSource, file), path.join(runtimeDir, "config", file));
36
70
  }
37
- if (!(await confirm(`Reuse existing workspace ${target}?`, { yes: options.yes }))) throw new Error("install cancelled");
38
- return { workspace: target, gitProtocol: await repoProtocol(target), repoUrl: await repoUrl(target) };
71
+ fs.rmSync(extractDir, { recursive: true, force: true });
72
+ return { tag, skipped: false };
39
73
  }
40
74
 
41
- async function configureAuth(workspace, options) {
42
- const casConfigDir = path.resolve(options.casConfigDir || path.join(workspace, ".qdm-auth", "cas"));
43
- fs.mkdirSync(casConfigDir, { recursive: true, mode: 0o700 });
44
- const env = { QDM_CAS_CONFIG_DIR: casConfigDir };
45
- if (options.casConfigDir) {
46
- validateCasConfigDir(casConfigDir);
47
- } else if (options.yes) {
48
- throw new Error("non-interactive install requires --cas-config-dir with existing CAS credentials");
49
- } else {
50
- await run(path.join(workspace, "bin", "cas-cli"), ["config", "set-credentials"], { cwd: workspace, env, stdio: "inherit" });
51
- validateCasConfigDir(casConfigDir);
75
+ function executable(file) {
76
+ try {
77
+ fs.accessSync(file, fs.constants.X_OK);
78
+ return true;
79
+ } catch {
80
+ return false;
52
81
  }
53
- await run("sh", ["-c", `"${path.join(workspace, "bin", "qdm-cmr-cli")}" config set-token "$("${path.join(workspace, "bin", "cas-cli")}" token --app cmr)"`], {
54
- cwd: workspace,
55
- env,
56
- stdio: "inherit"
57
- });
58
- await run("sh", ["-c", `"${path.join(workspace, "bin", "qdm-indicators-cli")}" config set-token "$("${path.join(workspace, "bin", "cas-cli")}" token --app indicators)"`], {
59
- cwd: workspace,
60
- env,
61
- stdio: "inherit"
62
- });
63
- await run(path.join(workspace, "bin", "qdm-cmr-cli"), ["config", "check-token"], { cwd: workspace, env, stdio: "inherit" });
64
- await run(path.join(workspace, "bin", "qdm-indicators-cli"), ["config", "check-token"], { cwd: workspace, env, stdio: "inherit" });
65
- return casConfigDir;
66
82
  }
67
83
 
68
- export async function buildAndCheck(workspace, options) {
69
- const cli = path.join(workspace, "bin", "data-harness-cli");
70
- const buildIndexArgs = ["wikis", "build-index"];
71
- if (options.skipWikisCheck) buildIndexArgs.push("--skip-checks");
72
- await run(cli, buildIndexArgs, { cwd: workspace, stdio: "inherit" });
73
- await run(cli, ["context", "--question", "会员复购为什么下降?", "--json"], { cwd: workspace, stdio: "inherit" });
74
- let runFullCheck = !options.skipWikisCheck;
75
- if (!options.yes && !options.skipWikisCheck) {
76
- runFullCheck = await confirm("Run wikis check-all?", { defaultNo: false });
84
+ async function promptExecutable(runtimeDir, name, options = {}) {
85
+ const auto = path.join(runtimeDir, "bin", binaryName(name));
86
+ if (executable(auto)) return auto;
87
+ const value = await ask(`Path to ${name}:`, options);
88
+ const file = path.resolve(value);
89
+ if (!executable(file)) throw new Error(`${name} path is missing or not executable: ${file}`);
90
+ return file;
91
+ }
92
+
93
+ async function installLocalTools(runtimeDir, options = {}) {
94
+ const binDir = path.join(runtimeDir, "bin");
95
+ fs.mkdirSync(binDir, { recursive: true });
96
+ const installed = {};
97
+ for (const name of ["cas-cli", "qdm-indicators-cli", "qdm-cmr-cli"]) {
98
+ const source = await promptExecutable(runtimeDir, name, options);
99
+ const target = path.join(binDir, binaryName(name));
100
+ if (path.resolve(source) !== path.resolve(target)) {
101
+ fs.copyFileSync(source, target);
102
+ fs.chmodSync(target, 0o755);
103
+ }
104
+ installed[name] = { mode: "local-path", source };
77
105
  }
78
- if (runFullCheck) {
79
- const result = await run(cli, ["wikis", "check-all"], { cwd: workspace, stdio: "inherit", allowFailure: true });
80
- if (result.code !== 0) console.warn("warning: wikis check-all reported issues");
81
- } else {
82
- console.warn("warning: wikis check-all skipped");
106
+ return installed;
107
+ }
108
+
109
+ async function installWikis(runtimeDir, options = {}) {
110
+ const target = path.join(runtimeDir, "wikis");
111
+ if (githubToken(options)) {
112
+ if (fs.existsSync(path.join(target, ".git"))) {
113
+ await run("git", ["-C", target, "pull", "--ff-only"], { stdio: "inherit" });
114
+ } else {
115
+ fs.rmSync(target, { recursive: true, force: true });
116
+ await run("git", ["clone", `https://x-access-token:${githubToken(options)}@github.com/${wikisRepo}.git`, target], { stdio: "inherit" });
117
+ }
118
+ return { mode: "github", path: target };
83
119
  }
120
+ if (await hasGithubAuth(options)) {
121
+ if (fs.existsSync(path.join(target, ".git"))) {
122
+ await run("git", ["-C", target, "pull", "--ff-only"], { stdio: "inherit" });
123
+ } else {
124
+ fs.rmSync(target, { recursive: true, force: true });
125
+ await run("gh", ["repo", "clone", wikisRepo, target], { stdio: "inherit" });
126
+ }
127
+ return { mode: "github", path: target };
128
+ }
129
+
130
+ const auto = path.join(runtimeDir, "harness-data-wikis");
131
+ const source = fs.existsSync(auto) ? auto : path.resolve(await ask("Path to harness-data-wikis:", options));
132
+ for (const dir of ["spec", "playbooks", "templates"]) {
133
+ if (!fs.existsSync(path.join(source, dir))) throw new Error(`harness-data-wikis missing ${dir}/: ${source}`);
134
+ }
135
+ fs.rmSync(target, { recursive: true, force: true });
136
+ fs.cpSync(source, target, { recursive: true });
137
+ return { mode: "local-path", source, path: target };
138
+ }
139
+
140
+ function casConfigDir(runtimeDir) {
141
+ return path.join(runtimeDir, ".qdm-auth", "cas");
142
+ }
143
+
144
+ async function writeCasCredentials(runtimeDir, options = {}) {
145
+ const username = await ask("CAS username:", options);
146
+ const password = await askSecret("CAS password:", options);
147
+ if (!username || !password) throw new Error("CAS username and password are required");
148
+ const dir = casConfigDir(runtimeDir);
149
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
150
+ fs.writeFileSync(path.join(dir, "config.json"), `${JSON.stringify({ cas: { username, password } }, null, 2)}\n`, { mode: 0o600 });
151
+ return dir;
152
+ }
153
+
154
+ async function configureTokens(runtimeDir, casDir) {
155
+ const env = { QDM_CAS_CONFIG_DIR: casDir };
156
+ const bin = (name) => path.join(runtimeDir, "bin", binaryName(name));
157
+ const cmrToken = (await run(bin("cas-cli"), ["token", "--app", "cmr"], { cwd: runtimeDir, env })).stdout.trim();
158
+ await run(bin("qdm-cmr-cli"), ["config", "set-token", cmrToken], { cwd: runtimeDir, env, stdio: "inherit" });
159
+ const indicatorsToken = (await run(bin("cas-cli"), ["token", "--app", "indicators"], { cwd: runtimeDir, env })).stdout.trim();
160
+ await run(bin("qdm-indicators-cli"), ["config", "set-token", indicatorsToken], { cwd: runtimeDir, env, stdio: "inherit" });
161
+ await run(bin("qdm-cmr-cli"), ["config", "check-token"], { cwd: runtimeDir, env, stdio: "inherit" });
162
+ await run(bin("qdm-indicators-cli"), ["config", "check-token"], { cwd: runtimeDir, env, stdio: "inherit" });
163
+ }
164
+
165
+ export async function buildAndCheck(runtimeDir, options = {}) {
166
+ const cli = path.join(runtimeDir, "bin", binaryName("data-harness-cli"));
167
+ const result = await run(cli, ["wikis", "build-index", "--skip-checks"], { cwd: runtimeDir, stdio: "inherit", allowFailure: true });
168
+ if (result.code !== 0) console.warn("warning: wikis build-index --skip-checks failed; continue install and retry manually later");
84
169
  }
85
170
 
86
171
  export async function installCommand(options = {}) {
87
172
  platformKey();
88
- await requireCommands(["git", "curl", "tar", "unzip"]);
89
- const prepared = await prepareWorkspace(options);
90
- const workspace = prepared.workspace;
91
- const manifestPath = path.resolve(options.manifest || path.join(workspace, "bootstrap", "cli-manifest.json"));
92
- if (!(await confirm("Download or refresh CLI binaries?", { yes: options.yes }))) throw new Error("install cancelled");
93
- const manifest = await installToolsFromManifest(workspace, manifestPath, options);
94
- const configExists = fs.existsSync(path.join(workspace, "config", "harness-config.yaml")) ||
95
- fs.existsSync(path.join(workspace, "config", "qdm-cli-paths.env"));
96
- const overwrite = configExists && !options.yes ? await confirm("Overwrite existing local config files?", { defaultNo: true }) : !configExists;
97
- if (configExists && !overwrite) {
98
- console.log("Reusing existing local config files");
173
+ await requireCommands(["git", "tar", "unzip"]);
174
+ const runtimeDir = await prepareRuntimeDir(options);
175
+ const bundle = await installRuntimeBundle(runtimeDir, options);
176
+ const manifestPath = path.resolve(options.manifest || path.join(runtimeDir, "bootstrap", "cli-manifest.json"));
177
+ const tokenMode = await hasGithubAuth(options);
178
+
179
+ let manifest;
180
+ let localTools = {};
181
+ if (tokenMode) {
182
+ manifest = await installToolsFromManifest(runtimeDir, manifestPath, options);
99
183
  } else {
100
- writeLocalConfig(workspace, { overwrite: true });
184
+ manifest = readManifest(manifestPath);
185
+ await installToolsFromManifest(runtimeDir, manifestPath, { ...options, tools: ["data-harness-cli"] });
186
+ localTools = await installLocalTools(runtimeDir, options);
101
187
  }
102
- const casConfigDir = await configureAuth(workspace, options);
103
- await buildAndCheck(workspace, options);
104
- const agent = await chooseAgent(options);
105
- linkAgents(workspace, agent);
106
- const doctor = await collectDoctor(workspace, { ...options, casConfigDir });
188
+
189
+ await installWikis(runtimeDir, options);
190
+ writeLocalConfig(runtimeDir, { overwrite: true });
191
+ const casDir = await writeCasCredentials(runtimeDir, options);
192
+ await configureTokens(runtimeDir, casDir);
193
+ await buildAndCheck(runtimeDir, options);
194
+ linkAgents(runtimeDir, await chooseAgent(options));
195
+
196
+ const doctor = await collectDoctor(runtimeDir, { ...options, casConfigDir: casDir });
107
197
  for (const check of doctor.checks) console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name}`);
108
198
  if (doctor.checks.some((check) => !check.ok)) throw new Error("doctor failed; install is incomplete");
109
- const gitProtocol = prepared.gitProtocol || await repoProtocol(workspace) || "ssh";
110
- const updates = await checkUpdates(workspace, { ...options, gitProtocol });
111
- if (updates.hasUpdates) {
112
- console.log(`Updates available. Next step: npx @lumi-ai-lab/harness-data update --dir "${workspace}"`);
113
- }
114
- writeState(workspace, {
115
- mainCommit: await currentCommit(workspace),
116
- wikisCommit: await submoduleCommit(workspace),
199
+
200
+ writeState(runtimeDir, {
201
+ installMode: tokenMode ? "github-token" : "local-path",
202
+ runtimeTag: bundle.tag,
203
+ localTools,
204
+ tools: manifest.installedTools || {},
117
205
  manifestSha256: manifestDigest(manifest),
118
- lastCheckAt: new Date().toISOString(),
119
206
  packageVersion: packageVersion(),
120
- gitProtocol,
121
- repoUrl: prepared.repoUrl
207
+ lastCheckAt: new Date().toISOString()
122
208
  });
123
- console.log(`Harness Data installed: ${workspace}`);
209
+ console.log(`Harness Data runtime installed: ${runtimeDir}`);
124
210
  }
@@ -4,10 +4,11 @@ import { confirm } from "../lib/prompt.js";
4
4
  import { findWorkspaceDir, readUserState, writeState } from "../lib/paths.js";
5
5
  import { run } from "../lib/exec.js";
6
6
  import { installToolsFromManifest, manifestDigest, readManifest } from "../lib/manifest.js";
7
- import { behindCount, currentCommit, dirtyPaths, submoduleCommit, submodulePointerChanged, submoduleRemoteBehind } from "../lib/git.js";
8
7
  import { packageVersion } from "../lib/package.js";
8
+ import { platformKey } from "../lib/platform.js";
9
+ import { githubToken, latestRelease } from "../lib/github.js";
10
+ import { buildAndCheck, installRuntimeBundle } from "./install.js";
9
11
  import { collectDoctor } from "./doctor.js";
10
- import { normalizeGitProtocol, repoProtocol, repoUrl, runGitWithProtocol, syncWikisSubmodule } from "../lib/git-auth.js";
11
12
 
12
13
  async function npmLatest() {
13
14
  try {
@@ -20,139 +21,169 @@ async function npmLatest() {
20
21
  }
21
22
  }
22
23
 
23
- async function remoteManifest(workspace) {
24
- const upstream = (await run("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { cwd: workspace, allowFailure: true })).stdout.trim();
25
- if (!upstream) return null;
26
- const result = await run("git", ["show", `${upstream}:bootstrap/cli-manifest.json`], { cwd: workspace, allowFailure: true });
27
- if (result.code !== 0 || !result.stdout.trim()) return null;
28
- try {
29
- return JSON.parse(result.stdout);
30
- } catch {
31
- return null;
32
- }
24
+ function archiveSuffix(url) {
25
+ if (url.endsWith(".zip")) return "zip";
26
+ if (url.endsWith(".tar.gz")) return "tar.gz";
27
+ return "";
33
28
  }
34
29
 
35
- function versionChanged(current, latest) {
36
- return latest && latest !== current;
30
+ function toolAssetName(tool, tag, key) {
31
+ const current = tool.platforms?.[key]?.url || "";
32
+ const suffix = archiveSuffix(current) || (key.startsWith("windows-") ? "zip" : "tar.gz");
33
+ return `${tool.binary}-${tag}-${key}.${suffix}`;
37
34
  }
38
35
 
39
- export async function checkUpdates(workspace, gitOptions = {}) {
40
- const manifestPath = path.join(workspace, "bootstrap", "cli-manifest.json");
41
- const currentInstaller = packageVersion();
42
- const latestInstaller = await npmLatest();
43
- const mainBehind = await behindCount(workspace, gitOptions);
44
- const wikisBehind = await submoduleRemoteBehind(workspace, "wikis", gitOptions);
45
- const manifest = fs.existsSync(manifestPath) ? readManifest(manifestPath) : { tools: [] };
46
- const digest = manifestDigest(manifest);
47
- const remote = await remoteManifest(workspace);
48
- const remoteDigest = remote ? manifestDigest(remote) : "";
49
- const state = readUserState();
50
- const updates = {
51
- installer: { current: currentInstaller, latest: latestInstaller, update: versionChanged(currentInstaller, latestInstaller) },
52
- mainRepo: { behind: mainBehind, update: mainBehind > 0 },
53
- wikis: { remoteBehind: wikisBehind, pointerChanged: await submodulePointerChanged(workspace), update: wikisBehind > 0 },
54
- cli: {
55
- digest,
56
- remoteDigest,
57
- previousDigest: state.manifestSha256 || "",
58
- update: Boolean((state.manifestSha256 && state.manifestSha256 !== digest) || (remoteDigest && remoteDigest !== digest)),
59
- tools: manifest.tools || [],
60
- remoteTools: remote?.tools || []
61
- }
36
+ function releaseAsset(release, name) {
37
+ return (release.assets || []).find((asset) => asset.name === name);
38
+ }
39
+
40
+ function oneToolManifest(manifest, tool, tag, asset, key) {
41
+ return {
42
+ ...manifest,
43
+ tools: [{
44
+ ...tool,
45
+ version: tag,
46
+ platforms: {
47
+ [key]: {
48
+ url: asset.browser_download_url || `https://github.com/${tool.repo}/releases/download/${tag}/${asset.name}`,
49
+ sha256: ""
50
+ }
51
+ }
52
+ }]
62
53
  };
63
- updates.wikis.update = updates.wikis.update || updates.wikis.pointerChanged;
64
- updates.hasUpdates = updates.installer.update || updates.mainRepo.update || updates.wikis.update || updates.cli.update;
65
- return updates;
66
54
  }
67
55
 
68
- function printUpdateSummary(updates) {
69
- console.log("Update summary:");
70
- console.log(`installer: ${updates.installer.current}${updates.installer.latest ? ` -> ${updates.installer.latest}` : " (latest unavailable)"}`);
71
- console.log(`main repo: ${updates.mainRepo.behind} commits behind`);
72
- console.log(`wikis: ${updates.wikis.remoteBehind} commits behind remote${updates.wikis.pointerChanged ? ", submodule pointer changed" : ""}`);
73
- console.log(`CLI manifest: ${updates.cli.update ? "changed" : "unchanged or first check"}`);
74
- const remoteByName = new Map(updates.cli.remoteTools.map((tool) => [tool.name, tool]));
75
- for (const tool of updates.cli.tools) {
76
- const remote = remoteByName.get(tool.name);
77
- const target = remote && remote.version !== tool.version ? ` -> ${remote.version}` : "";
78
- console.log(` ${tool.name} ${tool.version}${target}`);
56
+ async function maybeUpdateTool(runtimeDir, manifest, tool, options, state) {
57
+ const key = platformKey();
58
+ const release = await latestRelease(tool.repo, options);
59
+ const tag = release.tag_name;
60
+ const assetName = toolAssetName(tool, tag, key);
61
+ const asset = releaseAsset(release, assetName);
62
+ if (!asset) {
63
+ console.warn(`warning: ${tool.name} latest release has no ${assetName}; skipped`);
64
+ return null;
65
+ }
66
+ const current = state.tools?.[tool.name] || {};
67
+ const tagChanged = current.version && current.version !== tag;
68
+ const firstInstall = !current.version;
69
+ if (!tagChanged && !firstInstall) {
70
+ console.log(`${tool.name}: up to date (${tag})`);
71
+ return null;
79
72
  }
73
+ console.log(`${tool.name} has update:`);
74
+ console.log(` current: ${current.version || "unknown"}`);
75
+ console.log(` remote: ${tag}`);
76
+ if (!(await confirm(`Update ${tool.name}?`, { defaultNo: true }))) {
77
+ console.log(`Skipped ${tool.name}`);
78
+ return null;
79
+ }
80
+ const updatedManifest = oneToolManifest(manifest, tool, tag, asset, key);
81
+ const installed = await installToolsFromManifest(runtimeDir, path.join(runtimeDir, ".bootstrap-cache", `${tool.name}-manifest.json`), {
82
+ ...options,
83
+ manifestOverride: updatedManifest
84
+ });
85
+ return installed.installedTools?.[tool.name] || { version: tag, asset: assetName };
80
86
  }
81
87
 
82
- async function updateProtocol(workspace, options) {
83
- const requested = normalizeGitProtocol(options.gitProtocol);
84
- if (requested !== "auto") return requested;
88
+ async function updateWikis(runtimeDir, options, state) {
89
+ const wikisDir = path.join(runtimeDir, "wikis");
90
+ if (!githubToken(options) && state.installMode !== "github-token") {
91
+ console.log("wikis: local path mode; check manually");
92
+ return null;
93
+ }
94
+ if (!fs.existsSync(path.join(wikisDir, ".git"))) {
95
+ console.log("wikis: not a git checkout; skipped");
96
+ return null;
97
+ }
98
+ await run("git", ["-C", wikisDir, "fetch", "origin"], { stdio: "inherit" });
99
+ const local = (await run("git", ["-C", wikisDir, "rev-parse", "HEAD"])).stdout.trim();
100
+ const remote = (await run("git", ["-C", wikisDir, "rev-parse", "origin/HEAD"], { allowFailure: true })).stdout.trim();
101
+ if (!remote || local === remote) {
102
+ console.log(`wikis: up to date (${local.slice(0, 12)})`);
103
+ return null;
104
+ }
105
+ console.log("wikis has update:");
106
+ console.log(` current: ${local}`);
107
+ console.log(` remote: ${remote}`);
108
+ if (!(await confirm("Update wikis?", { defaultNo: true }))) {
109
+ console.log("Skipped wikis");
110
+ return null;
111
+ }
112
+ await run("git", ["-C", wikisDir, "pull", "--ff-only"], { stdio: "inherit" });
113
+ return { commit: (await run("git", ["-C", wikisDir, "rev-parse", "HEAD"])).stdout.trim() };
114
+ }
115
+
116
+ export async function checkUpdates(workspace, options = {}) {
85
117
  const state = readUserState();
86
- return state.gitProtocol || await repoProtocol(workspace) || "ssh";
118
+ const latestInstaller = await npmLatest();
119
+ return {
120
+ installer: { current: packageVersion(), latest: latestInstaller, update: Boolean(latestInstaller && latestInstaller !== packageVersion()) },
121
+ state,
122
+ hasUpdates: Boolean(latestInstaller && latestInstaller !== packageVersion())
123
+ };
87
124
  }
88
125
 
89
126
  export async function updateCommand(options = {}) {
90
- const workspace = findWorkspaceDir(options.dir);
91
- if (!fs.existsSync(workspace)) throw new Error(`workspace does not exist: ${workspace}`);
92
- const mainDirty = await dirtyPaths(workspace);
93
- const wikisDirty = fs.existsSync(path.join(workspace, "wikis")) ? await dirtyPaths(path.join(workspace, "wikis")) : [];
94
- if (mainDirty.length || wikisDirty.length) {
95
- console.error("Dirty worktree detected; update will not overwrite local changes.");
96
- for (const line of mainDirty) console.error(`main: ${line}`);
97
- for (const line of wikisDirty) console.error(`wikis: ${line}`);
98
- process.exitCode = 1;
99
- return;
100
- }
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 });
105
- printUpdateSummary(updates);
106
- if (options.check) {
107
- writeState(workspace, {
108
- mainCommit: await currentCommit(workspace),
109
- wikisCommit: await submoduleCommit(workspace),
110
- manifestSha256: updates.cli.digest,
111
- packageVersion: packageVersion(),
112
- lastCheckAt: new Date().toISOString(),
113
- gitProtocol,
114
- repoUrl: originUrl
115
- });
116
- return updates;
117
- }
118
- if (!updates.hasUpdates) {
119
- console.log("No repository or wikis updates detected.");
120
- writeState(workspace, {
121
- mainCommit: await currentCommit(workspace),
122
- wikisCommit: await submoduleCommit(workspace),
123
- manifestSha256: updates.cli.digest,
124
- packageVersion: packageVersion(),
125
- lastCheckAt: new Date().toISOString(),
126
- gitProtocol,
127
- repoUrl: originUrl
128
- });
129
- return updates;
130
- }
131
- if (!(await confirm(`Apply updates to ${workspace}?`, { yes: options.yes, defaultNo: true }))) throw new Error("update cancelled");
132
- if (updates.mainRepo.update) {
133
- await runGitWithProtocol(gitProtocol, ["pull", "--ff-only"], { ...options, cwd: workspace, stdio: "inherit" });
127
+ const runtimeDir = findWorkspaceDir(options.dir);
128
+ if (!fs.existsSync(runtimeDir)) throw new Error(`runtime directory does not exist: ${runtimeDir}`);
129
+ const state = readUserState();
130
+ const manifestPath = path.join(runtimeDir, "bootstrap", "cli-manifest.json");
131
+ const manifest = readManifest(manifestPath);
132
+ let changed = false;
133
+ let runtimeTag = state.runtimeTag || "";
134
+ const nextTools = { ...(state.tools || {}) };
135
+
136
+ const latestInstaller = await npmLatest();
137
+ if (latestInstaller && latestInstaller !== packageVersion()) {
138
+ console.log(`installer has update: ${packageVersion()} -> ${latestInstaller}`);
139
+ console.log("Run the same npx command again to use the latest npm installer.");
140
+ } else {
141
+ console.log(`installer: up to date (${packageVersion()})`);
134
142
  }
135
- if (updates.wikis.update || updates.mainRepo.update) {
136
- if (!(await confirm("Update wikis submodule? This can change Agent behavior.", { yes: options.yes, defaultNo: true }))) {
137
- console.warn("warning: wikis update skipped");
143
+
144
+ const runtimeRelease = await latestRelease("lumi-ai-lab/harness-data", options);
145
+ if (state.runtimeTag && state.runtimeTag !== runtimeRelease.tag_name) {
146
+ console.log(`runtime bundle has update: ${state.runtimeTag} -> ${runtimeRelease.tag_name}`);
147
+ if (await confirm("Update runtime bundle?", { defaultNo: true })) {
148
+ const bundle = await installRuntimeBundle(runtimeDir, { ...options, force: true });
149
+ runtimeTag = bundle.tag || runtimeRelease.tag_name;
150
+ changed = true;
138
151
  } else {
139
- await syncWikisSubmodule(workspace, gitProtocol);
140
- await runGitWithProtocol(gitProtocol, ["submodule", "update", "--init", "--recursive", "--remote", "wikis"], { ...options, cwd: workspace, stdio: "inherit" });
152
+ console.log("Skipped runtime bundle");
141
153
  }
154
+ } else {
155
+ console.log(`runtime bundle: up to date (${state.runtimeTag || runtimeRelease.tag_name})`);
156
+ }
157
+
158
+ for (const tool of manifest.tools || []) {
159
+ if (state.installMode === "local-path" && tool.name !== "data-harness-cli") {
160
+ console.log(`${tool.name}: local path mode; check manually`);
161
+ continue;
162
+ }
163
+ const installed = await maybeUpdateTool(runtimeDir, manifest, tool, options, state);
164
+ if (installed) {
165
+ nextTools[tool.name] = installed;
166
+ changed = true;
167
+ }
168
+ }
169
+
170
+ const wikis = await updateWikis(runtimeDir, options, state);
171
+ if (wikis) changed = true;
172
+
173
+ if (changed) {
174
+ await buildAndCheck(runtimeDir, options);
175
+ const doctor = await collectDoctor(runtimeDir, options);
176
+ for (const check of doctor.checks) console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name}${check.detail ? ` (${check.detail})` : ""}`);
177
+ writeState(runtimeDir, {
178
+ ...state,
179
+ runtimeTag,
180
+ tools: nextTools,
181
+ manifestSha256: manifestDigest(manifest),
182
+ lastCheckAt: new Date().toISOString()
183
+ });
184
+ console.log("Harness Data runtime updated.");
185
+ } else {
186
+ writeState(runtimeDir, { ...state, lastCheckAt: new Date().toISOString() });
187
+ console.log("No selected updates were applied.");
142
188
  }
143
- await installToolsFromManifest(workspace, path.join(workspace, "bootstrap", "cli-manifest.json"), options);
144
- await run(path.join(workspace, "bin", "data-harness-cli"), ["wikis", "build-index"], { cwd: workspace, stdio: "inherit" });
145
- const doctor = await collectDoctor(workspace, options);
146
- for (const check of doctor.checks) console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name}`);
147
- if (doctor.checks.some((check) => !check.ok)) throw new Error("doctor failed after update");
148
- writeState(workspace, {
149
- mainCommit: await currentCommit(workspace),
150
- wikisCommit: await submoduleCommit(workspace),
151
- manifestSha256: updates.cli.digest,
152
- packageVersion: packageVersion(),
153
- lastCheckAt: new Date().toISOString(),
154
- gitProtocol,
155
- repoUrl: originUrl
156
- });
157
- console.log(`Harness Data updated: ${workspace}`);
158
189
  }
@@ -1,18 +1,18 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { findWorkspaceDir } from "../lib/paths.js";
4
- import { currentCommit, submoduleCommit } from "../lib/git.js";
3
+ import { findWorkspaceDir, readUserState } from "../lib/paths.js";
5
4
  import { readManifest } from "../lib/manifest.js";
6
5
  import { packageVersion } from "../lib/package.js";
7
6
 
8
7
  export async function versionCommand(options = {}) {
9
8
  const workspace = findWorkspaceDir(options.dir);
10
9
  const manifestPath = path.join(workspace, "bootstrap", "cli-manifest.json");
10
+ const state = readUserState();
11
11
  const result = {
12
12
  installer: packageVersion(),
13
- workspace,
14
- mainCommit: fs.existsSync(workspace) ? await currentCommit(workspace) : "",
15
- wikisCommit: fs.existsSync(workspace) ? await submoduleCommit(workspace) : "",
13
+ runtime: workspace,
14
+ runtimeTag: state.runtimeTag || "",
15
+ installMode: state.installMode || "",
16
16
  tools: []
17
17
  };
18
18
  if (fs.existsSync(manifestPath)) {
@@ -26,9 +26,9 @@ export async function versionCommand(options = {}) {
26
26
  console.log(JSON.stringify(result, null, 2));
27
27
  } else {
28
28
  console.log(`installer ${result.installer}`);
29
- console.log(`workspace ${result.workspace}`);
30
- if (result.mainCommit) console.log(`main ${result.mainCommit}`);
31
- if (result.wikisCommit) console.log(`wikis ${result.wikisCommit}`);
29
+ console.log(`runtime ${result.runtime}`);
30
+ if (result.runtimeTag) console.log(`runtime bundle ${result.runtimeTag}`);
31
+ if (result.installMode) console.log(`install mode ${result.installMode}`);
32
32
  for (const tool of result.tools) console.log(`${tool.name} ${tool.version}`);
33
33
  }
34
34
  return result;
package/src/lib/config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { binaryName } from "./platform.js";
3
4
 
4
5
  export function writeLocalConfig(workspace, options = {}) {
5
6
  const configDir = path.join(workspace, "config");
@@ -9,7 +10,7 @@ export function writeLocalConfig(workspace, options = {}) {
9
10
  if ((fs.existsSync(harness) || fs.existsSync(env)) && !options.overwrite) {
10
11
  throw new Error("local config already exists; rerun interactively and confirm overwrite or remove the files");
11
12
  }
12
- const bin = (name) => path.join(workspace, "bin", name).replaceAll("\\", "/");
13
+ const bin = (name) => path.join(workspace, "bin", binaryName(name)).replaceAll("\\", "/");
13
14
  fs.writeFileSync(harness, `paths:\n spec: wikis/spec\n playbooks: wikis/playbooks\n templates: wikis/templates\n\ncli:\n qdm_cmr_cli: ${bin("qdm-cmr-cli")}\n qdm_indicators_cli: ${bin("qdm-indicators-cli")}\n qdm_cas_cli: ${bin("cas-cli")}\n`);
14
15
  fs.writeFileSync(env, `export QDM_CMR_CLI="${bin("qdm-cmr-cli")}"\nexport QDM_INDICATORS_CLI="${bin("qdm-indicators-cli")}"\nexport QDM_CAS_CLI="${bin("cas-cli")}"\n`);
15
16
  }
@@ -29,9 +30,9 @@ export function validateCasConfigDir(dir) {
29
30
 
30
31
  export function linkAgents(workspace, agent) {
31
32
  const pairs = [];
32
- if (agent === "claude" || agent === "both" || agent === "all") pairs.push([".agents/claude", ".claude"]);
33
- if (agent === "codex" || agent === "both" || agent === "all") pairs.push([".agents/codex", ".codex"]);
34
- if (agent === "pi" || agent === "all") pairs.push([".agents/pi", ".pi"]);
33
+ if (agent === "claude" || agent === "all") pairs.push(["agents/claude", ".claude"]);
34
+ if (agent === "codex" || agent === "all") pairs.push(["agents/codex", ".codex"]);
35
+ if (agent === "pi" || agent === "all") pairs.push(["agents/pi", ".pi"]);
35
36
  for (const [sourceRel, targetRel] of pairs) {
36
37
  const source = path.join(workspace, sourceRel);
37
38
  const target = path.join(workspace, targetRel);
@@ -40,21 +40,17 @@ function httpsFailureMessage() {
40
40
  return [
41
41
  "HTTPS access to the private GitHub repositories failed.",
42
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."
43
+ "or passing --github-token TOKEN."
44
44
  ].join(" ");
45
45
  }
46
46
 
47
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;
48
+ return options.githubToken || process.env.GITHUB_TOKEN || "";
53
49
  }
54
50
 
55
51
  async function withHttpsAuthEnv(options, callback) {
56
- const name = tokenEnvName(options);
57
- if (!name) return callback({ GIT_TERMINAL_PROMPT: "0" });
52
+ const token = tokenEnvName(options);
53
+ if (!token) return callback({ GIT_TERMINAL_PROMPT: "0" });
58
54
 
59
55
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-data-git-"));
60
56
  const askpass = path.join(dir, "askpass.sh");
@@ -62,7 +58,7 @@ async function withHttpsAuthEnv(options, callback) {
62
58
  "#!/bin/sh",
63
59
  "case \"$1\" in",
64
60
  "*Username*) printf '%s\\n' x-access-token ;;",
65
- "*Password*) printenv \"$GITHUB_TOKEN_ENV_NAME\" ;;",
61
+ "*Password*) printf '%s\\n' \"$GITHUB_TOKEN_VALUE\" ;;",
66
62
  "*) printf '\\n' ;;",
67
63
  "esac",
68
64
  ""
@@ -71,7 +67,7 @@ async function withHttpsAuthEnv(options, callback) {
71
67
  try {
72
68
  return await callback({
73
69
  GIT_ASKPASS: askpass,
74
- GITHUB_TOKEN_ENV_NAME: name,
70
+ GITHUB_TOKEN_VALUE: token,
75
71
  GIT_TERMINAL_PROMPT: "0"
76
72
  });
77
73
  } finally {
@@ -0,0 +1,52 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { commandExists, run } from "./exec.js";
4
+ import { download } from "./manifest.js";
5
+
6
+ const userAgent = "harness-data-installer";
7
+
8
+ export function githubToken(options = {}) {
9
+ return options.githubToken || process.env.GITHUB_TOKEN || "";
10
+ }
11
+
12
+ export async function hasGithubAuth(options = {}) {
13
+ if (githubToken(options)) return true;
14
+ if (!(await commandExists("gh"))) return false;
15
+ const result = await run("gh", ["auth", "status"], { allowFailure: true });
16
+ return result.code === 0;
17
+ }
18
+
19
+ export function githubHeaders(options = {}) {
20
+ const headers = { "User-Agent": userAgent };
21
+ const token = githubToken(options);
22
+ if (token) headers.Authorization = `Bearer ${token}`;
23
+ return headers;
24
+ }
25
+
26
+ export async function githubJson(url, options = {}) {
27
+ if (!githubToken(options) && await commandExists("gh")) {
28
+ const apiPath = new URL(url).pathname.replace(/^\/repos\//, "repos/");
29
+ const result = await run("gh", ["api", apiPath], { allowFailure: true });
30
+ if (result.code === 0 && result.stdout.trim()) return JSON.parse(result.stdout);
31
+ }
32
+ const tmp = path.join(fs.mkdtempSync(path.join(process.cwd(), ".github-api-")), "response.json");
33
+ try {
34
+ await download(url, tmp, githubHeaders(options));
35
+ return JSON.parse(fs.readFileSync(tmp, "utf8"));
36
+ } finally {
37
+ fs.rmSync(path.dirname(tmp), { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ export async function latestRelease(repo, options = {}) {
42
+ return githubJson(`https://api.github.com/repos/${repo}/releases/latest`, options);
43
+ }
44
+
45
+ export function findReleaseAsset(release, name) {
46
+ return (release.assets || []).find((asset) => asset.name === name);
47
+ }
48
+
49
+ export async function downloadReleaseAsset(asset, file, options = {}) {
50
+ const headers = { ...githubHeaders(options), Accept: "application/octet-stream" };
51
+ await download(asset.url, file, headers);
52
+ }
@@ -13,7 +13,7 @@ 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, headers = {}) {
16
+ export function download(url, file, headers = {}) {
17
17
  return new Promise((resolve, reject) => {
18
18
  const request = https.get(url, { headers }, (response) => {
19
19
  if ([301, 302, 307, 308].includes(response.statusCode) && response.headers.location) {
@@ -77,10 +77,7 @@ async function downloadPrivateWithGh(asset, file) {
77
77
  }
78
78
 
79
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] || "";
80
+ return options.githubToken || process.env.GITHUB_TOKEN || "";
84
81
  }
85
82
 
86
83
  async function githubAssetApiUrl(asset, token) {
@@ -121,7 +118,7 @@ async function downloadAsset(tool, asset, file, options = {}) {
121
118
  console.log(`Downloading private ${tool.name} asset from ${tool.repo}`);
122
119
  if (await downloadPrivateWithGh(asset, file)) return;
123
120
  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)}`);
121
+ throw new Error(`private GitHub Release asset requires gh auth login, GITHUB_TOKEN, or --github-token: ${assetName(asset)}`);
125
122
  }
126
123
 
127
124
  async function expectedSha256(tool, asset, options = {}) {
@@ -140,21 +137,25 @@ function fileSha256(file) {
140
137
  }
141
138
 
142
139
  export async function installToolsFromManifest(workspace, manifestPath, options = {}) {
143
- const manifest = readManifest(manifestPath);
140
+ const manifest = options.manifestOverride || readManifest(manifestPath);
141
+ const only = options.tools ? new Set(options.tools) : null;
144
142
  const key = platformKey();
145
143
  const cacheDir = path.join(workspace, ".bootstrap-cache");
146
144
  const binDir = path.join(workspace, "bin");
147
145
  fs.mkdirSync(cacheDir, { recursive: true });
148
146
  fs.mkdirSync(binDir, { recursive: true });
147
+ const installedTools = {};
149
148
 
150
149
  for (const tool of manifest.tools || []) {
150
+ if (only && !only.has(tool.name)) continue;
151
151
  const asset = tool.platforms?.[key];
152
152
  if (!asset?.url) throw new Error(`manifest missing ${tool.name} asset for ${key}`);
153
153
  const archive = path.join(cacheDir, assetName(asset));
154
154
  console.log(`Downloading ${tool.name} ${tool.version} (${key})`);
155
155
  await downloadAsset(tool, asset, archive, options);
156
156
  const sha = await expectedSha256(tool, asset, options);
157
- if (sha && fileSha256(archive) !== sha) throw new Error(`${tool.name} sha256 mismatch`);
157
+ const actualSha = fileSha256(archive);
158
+ if (sha && actualSha !== sha) throw new Error(`${tool.name} sha256 mismatch`);
158
159
  if (!sha) console.warn(`warning: ${tool.name} has no sha256; continuing without checksum`);
159
160
  if (archive.endsWith(".zip")) {
160
161
  await run("unzip", ["-o", archive, "-d", binDir], { stdio: "inherit" });
@@ -164,6 +165,12 @@ export async function installToolsFromManifest(workspace, manifestPath, options
164
165
  const binary = path.join(binDir, tool.binary);
165
166
  if (!fs.existsSync(binary)) throw new Error(`${tool.binary} was not extracted to bin/`);
166
167
  fs.chmodSync(binary, 0o755);
168
+ installedTools[tool.name] = {
169
+ version: tool.version || "",
170
+ asset: assetName(asset),
171
+ sha256: sha || actualSha
172
+ };
167
173
  }
174
+ Object.defineProperty(manifest, "installedTools", { value: installedTools, enumerable: false });
168
175
  return manifest;
169
176
  }
package/src/lib/paths.js CHANGED
@@ -2,7 +2,6 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- const appDir = path.join("lumi-ai-lab", "harness-data");
6
5
  const stateDir = path.join("lumi-ai-lab", "harness-data-installer");
7
6
 
8
7
  export function expandHome(value) {
@@ -13,9 +12,7 @@ export function expandHome(value) {
13
12
  }
14
13
 
15
14
  export function defaultWorkspaceDir() {
16
- if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", appDir);
17
- if (process.platform === "win32") return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), appDir);
18
- return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), appDir);
15
+ return process.cwd();
19
16
  }
20
17
 
21
18
  export function userStatePath() {
@@ -62,6 +59,6 @@ export function findWorkspaceDir(explicitDir) {
62
59
 
63
60
  export function looksLikeWorkspace(dir) {
64
61
  return fs.existsSync(path.join(dir, "bootstrap", "cli-manifest.json")) &&
65
- fs.existsSync(path.join(dir, ".agents")) &&
62
+ fs.existsSync(path.join(dir, "agents")) &&
66
63
  fs.existsSync(path.join(dir, "wikis"));
67
64
  }
@@ -2,8 +2,12 @@ export function platformKey() {
2
2
  const os = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform === "win32" ? "windows" : process.platform;
3
3
  const arch = process.arch === "x64" ? "amd64" : process.arch;
4
4
  const key = `${os}-${arch}`;
5
- if (!["darwin-arm64", "darwin-amd64", "linux-arm64", "linux-amd64"].includes(key)) {
5
+ if (!["darwin-arm64", "darwin-amd64", "linux-amd64", "windows-amd64"].includes(key)) {
6
6
  throw new Error(`unsupported platform: ${key}`);
7
7
  }
8
8
  return key;
9
9
  }
10
+
11
+ export function binaryName(name) {
12
+ return process.platform === "win32" ? `${name}.exe` : name;
13
+ }
package/src/lib/prompt.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import readline from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
+ import { Writable } from "node:stream";
3
4
 
4
- const agentChoices = ["claude", "codex", "pi", "both", "all"];
5
+ const agentChoices = ["claude", "codex", "pi", "all"];
5
6
 
6
7
  export async function confirm(message, options = {}) {
7
8
  if (options.yes) return true;
@@ -19,17 +20,47 @@ export async function confirm(message, options = {}) {
19
20
  export async function chooseAgent(options = {}) {
20
21
  if (options.agent) {
21
22
  const value = String(options.agent).trim().toLowerCase();
22
- if (!agentChoices.includes(value)) throw new Error("agent must be claude, codex, pi, both, or all");
23
+ if (!agentChoices.includes(value)) throw new Error("agent must be claude, codex, pi, or all");
23
24
  return value;
24
25
  }
25
- if (options.yes) throw new Error("non-interactive install requires --agent claude|codex|pi|both|all");
26
+ if (options.yes) return "all";
26
27
  const rl = readline.createInterface({ input, output });
27
28
  try {
28
- const answer = (await rl.question("Choose Agent: claude, codex, pi, both, all [codex] ")).trim().toLowerCase();
29
- const value = answer || "codex";
30
- if (!agentChoices.includes(value)) throw new Error("agent must be claude, codex, pi, both, or all");
29
+ const answer = (await rl.question("Choose Agent: claude, codex, pi, all [all] ")).trim().toLowerCase();
30
+ const value = answer || "all";
31
+ if (!agentChoices.includes(value)) throw new Error("agent must be claude, codex, pi, or all");
31
32
  return value;
32
33
  } finally {
33
34
  rl.close();
34
35
  }
35
36
  }
37
+
38
+ export async function ask(message, options = {}) {
39
+ if (options.value) return String(options.value);
40
+ if (options.yes) throw new Error(`${message} is required`);
41
+ const rl = readline.createInterface({ input, output });
42
+ try {
43
+ return (await rl.question(`${message} `)).trim();
44
+ } finally {
45
+ rl.close();
46
+ }
47
+ }
48
+
49
+ export async function askSecret(message, options = {}) {
50
+ if (options.value) return String(options.value);
51
+ if (options.yes) throw new Error(`${message} is required`);
52
+ const muted = new Writable({
53
+ write(_chunk, _encoding, callback) {
54
+ callback();
55
+ }
56
+ });
57
+ output.write(`${message} `);
58
+ const rl = readline.createInterface({ input, output: muted, terminal: true });
59
+ try {
60
+ const answer = (await rl.question("")).trim();
61
+ output.write("\n");
62
+ return answer;
63
+ } finally {
64
+ rl.close();
65
+ }
66
+ }