@lumi-ai-lab/harness-data 0.0.1
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 +9 -0
- package/bin/harness-data.js +7 -0
- package/package.json +22 -0
- package/src/cli.js +44 -0
- package/src/commands/doctor.js +93 -0
- package/src/commands/install.js +116 -0
- package/src/commands/update.js +140 -0
- package/src/commands/version.js +35 -0
- package/src/lib/config.js +41 -0
- package/src/lib/exec.js +34 -0
- package/src/lib/git.js +70 -0
- package/src/lib/manifest.js +80 -0
- package/src/lib/package.js +9 -0
- package/src/lib/paths.js +67 -0
- package/src/lib/platform.js +9 -0
- package/src/lib/prompt.js +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Harness Data npm installer
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
npx @lumi-ai-lab/harness-data install
|
|
5
|
+
npx @lumi-ai-lab/harness-data doctor --dir ~/harness-data
|
|
6
|
+
npx @lumi-ai-lab/harness-data update --dir ~/harness-data
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
This package is only the installer and updater. The full Harness Data workspace is cloned and maintained outside the npm package.
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lumi-ai-lab/harness-data",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Installer and updater for Harness Data",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"harness-data": "bin/harness-data.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test",
|
|
16
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"license": "UNLICENSED"
|
|
22
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { installCommand } from "./commands/install.js";
|
|
2
|
+
import { updateCommand } from "./commands/update.js";
|
|
3
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
4
|
+
import { versionCommand } from "./commands/version.js";
|
|
5
|
+
|
|
6
|
+
const commands = new Set(["install", "update", "doctor", "version"]);
|
|
7
|
+
|
|
8
|
+
function parse(argv) {
|
|
9
|
+
const args = argv.slice(2);
|
|
10
|
+
const unknown = Boolean(args[0] && !commands.has(args[0]));
|
|
11
|
+
const command = commands.has(args[0]) ? args.shift() : "help";
|
|
12
|
+
const options = { _: [] };
|
|
13
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
14
|
+
const arg = args[i];
|
|
15
|
+
if (!arg.startsWith("--")) {
|
|
16
|
+
options._.push(arg);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const [rawKey, inline] = arg.slice(2).split("=", 2);
|
|
20
|
+
const key = rawKey.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
21
|
+
if (["yes", "skipWikisCheck", "check", "json"].includes(key)) {
|
|
22
|
+
options[key] = inline === undefined ? true : inline !== "false";
|
|
23
|
+
} else {
|
|
24
|
+
options[key] = inline ?? args[++i];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { command, options, unknown };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function main(argv) {
|
|
31
|
+
const { command, options, unknown } = parse(argv);
|
|
32
|
+
if (command === "install") return installCommand(options);
|
|
33
|
+
if (command === "update") return updateCommand(options);
|
|
34
|
+
if (command === "doctor") return doctorCommand(options);
|
|
35
|
+
if (command === "version") return versionCommand(options);
|
|
36
|
+
console.log(`Usage: harness-data <install|update|doctor|version> [options]
|
|
37
|
+
|
|
38
|
+
Commands:
|
|
39
|
+
install Clone/reuse a Harness Data workspace and complete setup
|
|
40
|
+
update Check and apply installer, repository, CLI, and wikis updates
|
|
41
|
+
doctor Diagnose workspace CLI, config, auth, index, and Agent hooks
|
|
42
|
+
version Print installer, repository, wikis, and manifest versions`);
|
|
43
|
+
if (unknown) process.exitCode = 1;
|
|
44
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { run } from "../lib/exec.js";
|
|
4
|
+
import { findWorkspaceDir } from "../lib/paths.js";
|
|
5
|
+
import { currentCommit, submoduleCommit } from "../lib/git.js";
|
|
6
|
+
import { validateCasConfigDir } from "../lib/config.js";
|
|
7
|
+
|
|
8
|
+
function existsExecutable(file) {
|
|
9
|
+
try {
|
|
10
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function agentOk(workspace, name) {
|
|
18
|
+
const target = path.join(workspace, `.${name}`);
|
|
19
|
+
const source = path.join(workspace, ".agents", name);
|
|
20
|
+
if (!fs.existsSync(target) || !fs.existsSync(source)) return false;
|
|
21
|
+
try {
|
|
22
|
+
return fs.realpathSync(target) === fs.realpathSync(source);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function tokenCheck(workspace, binary, env) {
|
|
29
|
+
const file = path.join(workspace, "bin", binary);
|
|
30
|
+
if (!existsExecutable(file)) return false;
|
|
31
|
+
const result = await run(file, ["config", "check-token"], { cwd: workspace, env, allowFailure: true });
|
|
32
|
+
return result.code === 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function configPathsValid(workspace) {
|
|
36
|
+
const file = path.join(workspace, "config", "qdm-cli-paths.env");
|
|
37
|
+
if (!fs.existsSync(file)) return false;
|
|
38
|
+
const content = fs.readFileSync(file, "utf8");
|
|
39
|
+
const matches = [...content.matchAll(/="([^"]+)"/g)].map((match) => match[1]);
|
|
40
|
+
return matches.length >= 3 && matches.every((entry) => fs.existsSync(entry));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function casCredentialsValid(dir) {
|
|
44
|
+
try {
|
|
45
|
+
validateCasConfigDir(dir);
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function collectDoctor(workspace, options = {}) {
|
|
53
|
+
const casConfigDir = options.casConfigDir || path.join(workspace, ".qdm-auth", "cas");
|
|
54
|
+
const env = { QDM_CAS_CONFIG_DIR: casConfigDir };
|
|
55
|
+
const checks = [];
|
|
56
|
+
const add = (name, ok, detail = "") => checks.push({ name, ok, detail });
|
|
57
|
+
|
|
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")));
|
|
60
|
+
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
|
+
}
|
|
63
|
+
add("config/harness-config.yaml", fs.existsSync(path.join(workspace, "config", "harness-config.yaml")));
|
|
64
|
+
add("config/qdm-cli-paths.env", fs.existsSync(path.join(workspace, "config", "qdm-cli-paths.env")));
|
|
65
|
+
add("config CLI paths", configPathsValid(workspace));
|
|
66
|
+
add("CAS credentials", casCredentialsValid(casConfigDir), casConfigDir);
|
|
67
|
+
add("CMR token", await tokenCheck(workspace, "qdm-cmr-cli", env));
|
|
68
|
+
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
|
+
add("Agent hook", agentOk(workspace, "claude") || agentOk(workspace, "codex"));
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
workspace,
|
|
74
|
+
mainCommit: await currentCommit(workspace),
|
|
75
|
+
wikisCommit: await submoduleCommit(workspace),
|
|
76
|
+
checks
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function doctorCommand(options = {}) {
|
|
81
|
+
const workspace = findWorkspaceDir(options.dir);
|
|
82
|
+
const report = await collectDoctor(workspace, options);
|
|
83
|
+
if (options.json) {
|
|
84
|
+
console.log(JSON.stringify(report, null, 2));
|
|
85
|
+
} else {
|
|
86
|
+
console.log(`Harness Data doctor: ${report.workspace}`);
|
|
87
|
+
for (const check of report.checks) {
|
|
88
|
+
console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name}${check.detail ? ` (${check.detail})` : ""}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
|
|
92
|
+
return report;
|
|
93
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
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";
|
|
8
|
+
import { installToolsFromManifest, manifestDigest, readManifest } from "../lib/manifest.js";
|
|
9
|
+
import { platformKey } from "../lib/platform.js";
|
|
10
|
+
import { collectDoctor } from "./doctor.js";
|
|
11
|
+
import { checkUpdates } from "./update.js";
|
|
12
|
+
import { packageVersion } from "../lib/package.js";
|
|
13
|
+
|
|
14
|
+
const defaultRepo = "https://github.com/lumi-ai-lab/harness-data.git";
|
|
15
|
+
|
|
16
|
+
async function requireCommands(commands) {
|
|
17
|
+
for (const command of commands) {
|
|
18
|
+
if (!(await commandExists(command))) throw new Error(`missing required command: ${command}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function prepareWorkspace(options) {
|
|
23
|
+
const target = resolveWorkspaceDir(options.dir || (looksLikeWorkspace(process.cwd()) ? process.cwd() : defaultWorkspaceDir()));
|
|
24
|
+
const repo = options.repo || defaultRepo;
|
|
25
|
+
const branch = options.branch || "master";
|
|
26
|
+
if (!fs.existsSync(target)) {
|
|
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 run("git", ["clone", "--recurse-submodules", "--branch", branch, repo, target], { stdio: "inherit" });
|
|
30
|
+
return target;
|
|
31
|
+
}
|
|
32
|
+
if (!(await isGitRepo(target)) || !looksLikeWorkspace(target)) {
|
|
33
|
+
throw new Error(`${target} exists but is not a valid harness-data workspace; pass --dir or move the directory`);
|
|
34
|
+
}
|
|
35
|
+
if (!(await confirm(`Reuse existing workspace ${target}?`, { yes: options.yes }))) throw new Error("install cancelled");
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function configureAuth(workspace, options) {
|
|
40
|
+
const casConfigDir = path.resolve(options.casConfigDir || path.join(workspace, ".qdm-auth", "cas"));
|
|
41
|
+
fs.mkdirSync(casConfigDir, { recursive: true, mode: 0o700 });
|
|
42
|
+
const env = { QDM_CAS_CONFIG_DIR: casConfigDir };
|
|
43
|
+
if (options.casConfigDir) {
|
|
44
|
+
validateCasConfigDir(casConfigDir);
|
|
45
|
+
} else if (options.yes) {
|
|
46
|
+
throw new Error("non-interactive install requires --cas-config-dir with existing CAS credentials");
|
|
47
|
+
} else {
|
|
48
|
+
await run(path.join(workspace, "bin", "cas-cli"), ["config", "set-credentials"], { cwd: workspace, env, stdio: "inherit" });
|
|
49
|
+
validateCasConfigDir(casConfigDir);
|
|
50
|
+
}
|
|
51
|
+
await run("sh", ["-c", `"${path.join(workspace, "bin", "qdm-cmr-cli")}" config set-token "$("${path.join(workspace, "bin", "cas-cli")}" token --app cmr)"`], {
|
|
52
|
+
cwd: workspace,
|
|
53
|
+
env,
|
|
54
|
+
stdio: "inherit"
|
|
55
|
+
});
|
|
56
|
+
await run("sh", ["-c", `"${path.join(workspace, "bin", "qdm-indicators-cli")}" config set-token "$("${path.join(workspace, "bin", "cas-cli")}" token --app indicators)"`], {
|
|
57
|
+
cwd: workspace,
|
|
58
|
+
env,
|
|
59
|
+
stdio: "inherit"
|
|
60
|
+
});
|
|
61
|
+
await run(path.join(workspace, "bin", "qdm-cmr-cli"), ["config", "check-token"], { cwd: workspace, env, stdio: "inherit" });
|
|
62
|
+
await run(path.join(workspace, "bin", "qdm-indicators-cli"), ["config", "check-token"], { cwd: workspace, env, stdio: "inherit" });
|
|
63
|
+
return casConfigDir;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function buildAndCheck(workspace, options) {
|
|
67
|
+
const cli = path.join(workspace, "bin", "data-harness-cli");
|
|
68
|
+
await run(cli, ["wikis", "build-index"], { cwd: workspace, stdio: "inherit" });
|
|
69
|
+
await run(cli, ["context", "--question", "会员复购为什么下降?", "--json"], { cwd: workspace, stdio: "inherit" });
|
|
70
|
+
let runFullCheck = !options.skipWikisCheck;
|
|
71
|
+
if (!options.yes && !options.skipWikisCheck) {
|
|
72
|
+
runFullCheck = await confirm("Run wikis check-all?", { defaultNo: false });
|
|
73
|
+
}
|
|
74
|
+
if (runFullCheck) {
|
|
75
|
+
const result = await run(cli, ["wikis", "check-all"], { cwd: workspace, stdio: "inherit", allowFailure: true });
|
|
76
|
+
if (result.code !== 0) console.warn("warning: wikis check-all reported issues");
|
|
77
|
+
} else {
|
|
78
|
+
console.warn("warning: wikis check-all skipped");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function installCommand(options = {}) {
|
|
83
|
+
platformKey();
|
|
84
|
+
await requireCommands(["git", "curl", "tar", "unzip"]);
|
|
85
|
+
const workspace = await prepareWorkspace(options);
|
|
86
|
+
const manifestPath = path.resolve(options.manifest || path.join(workspace, "bootstrap", "cli-manifest.json"));
|
|
87
|
+
if (!(await confirm("Download or refresh CLI binaries?", { yes: options.yes }))) throw new Error("install cancelled");
|
|
88
|
+
const manifest = await installToolsFromManifest(workspace, manifestPath);
|
|
89
|
+
const configExists = fs.existsSync(path.join(workspace, "config", "harness-config.yaml")) ||
|
|
90
|
+
fs.existsSync(path.join(workspace, "config", "qdm-cli-paths.env"));
|
|
91
|
+
const overwrite = configExists && !options.yes ? await confirm("Overwrite existing local config files?", { defaultNo: true }) : !configExists;
|
|
92
|
+
if (configExists && !overwrite) {
|
|
93
|
+
console.log("Reusing existing local config files");
|
|
94
|
+
} else {
|
|
95
|
+
writeLocalConfig(workspace, { overwrite: true });
|
|
96
|
+
}
|
|
97
|
+
const casConfigDir = await configureAuth(workspace, options);
|
|
98
|
+
await buildAndCheck(workspace, options);
|
|
99
|
+
const agent = await chooseAgent(options);
|
|
100
|
+
linkAgents(workspace, agent);
|
|
101
|
+
const doctor = await collectDoctor(workspace, { ...options, casConfigDir });
|
|
102
|
+
for (const check of doctor.checks) console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name}`);
|
|
103
|
+
if (doctor.checks.some((check) => !check.ok)) throw new Error("doctor failed; install is incomplete");
|
|
104
|
+
const updates = await checkUpdates(workspace);
|
|
105
|
+
if (updates.hasUpdates) {
|
|
106
|
+
console.log(`Updates available. Next step: npx @lumi-ai-lab/harness-data update --dir "${workspace}"`);
|
|
107
|
+
}
|
|
108
|
+
writeState(workspace, {
|
|
109
|
+
mainCommit: await currentCommit(workspace),
|
|
110
|
+
wikisCommit: await submoduleCommit(workspace),
|
|
111
|
+
manifestSha256: manifestDigest(manifest),
|
|
112
|
+
lastCheckAt: new Date().toISOString(),
|
|
113
|
+
packageVersion: packageVersion()
|
|
114
|
+
});
|
|
115
|
+
console.log(`Harness Data installed: ${workspace}`);
|
|
116
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { confirm } from "../lib/prompt.js";
|
|
4
|
+
import { findWorkspaceDir, readUserState, writeState } from "../lib/paths.js";
|
|
5
|
+
import { run } from "../lib/exec.js";
|
|
6
|
+
import { installToolsFromManifest, manifestDigest, readManifest } from "../lib/manifest.js";
|
|
7
|
+
import { behindCount, currentCommit, dirtyPaths, submoduleCommit, submodulePointerChanged, submoduleRemoteBehind } from "../lib/git.js";
|
|
8
|
+
import { packageVersion } from "../lib/package.js";
|
|
9
|
+
import { collectDoctor } from "./doctor.js";
|
|
10
|
+
|
|
11
|
+
async function npmLatest() {
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetch("https://registry.npmjs.org/@lumi-ai-lab%2Fharness-data/latest", { signal: AbortSignal.timeout(5000) });
|
|
14
|
+
if (!response.ok) return "";
|
|
15
|
+
const data = await response.json();
|
|
16
|
+
return data.version || "";
|
|
17
|
+
} catch {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function remoteManifest(workspace) {
|
|
23
|
+
const upstream = (await run("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { cwd: workspace, allowFailure: true })).stdout.trim();
|
|
24
|
+
if (!upstream) return null;
|
|
25
|
+
const result = await run("git", ["show", `${upstream}:bootstrap/cli-manifest.json`], { cwd: workspace, allowFailure: true });
|
|
26
|
+
if (result.code !== 0 || !result.stdout.trim()) return null;
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(result.stdout);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function versionChanged(current, latest) {
|
|
35
|
+
return latest && latest !== current;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function checkUpdates(workspace) {
|
|
39
|
+
const manifestPath = path.join(workspace, "bootstrap", "cli-manifest.json");
|
|
40
|
+
const currentInstaller = packageVersion();
|
|
41
|
+
const latestInstaller = await npmLatest();
|
|
42
|
+
const mainBehind = await behindCount(workspace);
|
|
43
|
+
const wikisBehind = await submoduleRemoteBehind(workspace);
|
|
44
|
+
const manifest = fs.existsSync(manifestPath) ? readManifest(manifestPath) : { tools: [] };
|
|
45
|
+
const digest = manifestDigest(manifest);
|
|
46
|
+
const remote = await remoteManifest(workspace);
|
|
47
|
+
const remoteDigest = remote ? manifestDigest(remote) : "";
|
|
48
|
+
const state = readUserState();
|
|
49
|
+
const updates = {
|
|
50
|
+
installer: { current: currentInstaller, latest: latestInstaller, update: versionChanged(currentInstaller, latestInstaller) },
|
|
51
|
+
mainRepo: { behind: mainBehind, update: mainBehind > 0 },
|
|
52
|
+
wikis: { remoteBehind: wikisBehind, pointerChanged: await submodulePointerChanged(workspace), update: wikisBehind > 0 },
|
|
53
|
+
cli: {
|
|
54
|
+
digest,
|
|
55
|
+
remoteDigest,
|
|
56
|
+
previousDigest: state.manifestSha256 || "",
|
|
57
|
+
update: Boolean((state.manifestSha256 && state.manifestSha256 !== digest) || (remoteDigest && remoteDigest !== digest)),
|
|
58
|
+
tools: manifest.tools || [],
|
|
59
|
+
remoteTools: remote?.tools || []
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
updates.wikis.update = updates.wikis.update || updates.wikis.pointerChanged;
|
|
63
|
+
updates.hasUpdates = updates.installer.update || updates.mainRepo.update || updates.wikis.update || updates.cli.update;
|
|
64
|
+
return updates;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function printUpdateSummary(updates) {
|
|
68
|
+
console.log("Update summary:");
|
|
69
|
+
console.log(`installer: ${updates.installer.current}${updates.installer.latest ? ` -> ${updates.installer.latest}` : " (latest unavailable)"}`);
|
|
70
|
+
console.log(`main repo: ${updates.mainRepo.behind} commits behind`);
|
|
71
|
+
console.log(`wikis: ${updates.wikis.remoteBehind} commits behind remote${updates.wikis.pointerChanged ? ", submodule pointer changed" : ""}`);
|
|
72
|
+
console.log(`CLI manifest: ${updates.cli.update ? "changed" : "unchanged or first check"}`);
|
|
73
|
+
const remoteByName = new Map(updates.cli.remoteTools.map((tool) => [tool.name, tool]));
|
|
74
|
+
for (const tool of updates.cli.tools) {
|
|
75
|
+
const remote = remoteByName.get(tool.name);
|
|
76
|
+
const target = remote && remote.version !== tool.version ? ` -> ${remote.version}` : "";
|
|
77
|
+
console.log(` ${tool.name} ${tool.version}${target}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function updateCommand(options = {}) {
|
|
82
|
+
const workspace = findWorkspaceDir(options.dir);
|
|
83
|
+
if (!fs.existsSync(workspace)) throw new Error(`workspace does not exist: ${workspace}`);
|
|
84
|
+
const mainDirty = await dirtyPaths(workspace);
|
|
85
|
+
const wikisDirty = fs.existsSync(path.join(workspace, "wikis")) ? await dirtyPaths(path.join(workspace, "wikis")) : [];
|
|
86
|
+
if (mainDirty.length || wikisDirty.length) {
|
|
87
|
+
console.error("Dirty worktree detected; update will not overwrite local changes.");
|
|
88
|
+
for (const line of mainDirty) console.error(`main: ${line}`);
|
|
89
|
+
for (const line of wikisDirty) console.error(`wikis: ${line}`);
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const updates = await checkUpdates(workspace);
|
|
94
|
+
printUpdateSummary(updates);
|
|
95
|
+
if (options.check) {
|
|
96
|
+
writeState(workspace, {
|
|
97
|
+
mainCommit: await currentCommit(workspace),
|
|
98
|
+
wikisCommit: await submoduleCommit(workspace),
|
|
99
|
+
manifestSha256: updates.cli.digest,
|
|
100
|
+
packageVersion: packageVersion(),
|
|
101
|
+
lastCheckAt: new Date().toISOString()
|
|
102
|
+
});
|
|
103
|
+
return updates;
|
|
104
|
+
}
|
|
105
|
+
if (!updates.hasUpdates) {
|
|
106
|
+
console.log("No repository or wikis updates detected.");
|
|
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
|
+
});
|
|
114
|
+
return updates;
|
|
115
|
+
}
|
|
116
|
+
if (!(await confirm(`Apply updates to ${workspace}?`, { yes: options.yes, defaultNo: true }))) throw new Error("update cancelled");
|
|
117
|
+
if (updates.mainRepo.update) {
|
|
118
|
+
await run("git", ["pull", "--ff-only"], { cwd: workspace, stdio: "inherit" });
|
|
119
|
+
}
|
|
120
|
+
if (updates.wikis.update || updates.mainRepo.update) {
|
|
121
|
+
if (!(await confirm("Update wikis submodule? This can change Agent behavior.", { yes: options.yes, defaultNo: true }))) {
|
|
122
|
+
console.warn("warning: wikis update skipped");
|
|
123
|
+
} else {
|
|
124
|
+
await run("git", ["submodule", "update", "--init", "--recursive", "--remote", "wikis"], { cwd: workspace, stdio: "inherit" });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
await installToolsFromManifest(workspace, path.join(workspace, "bootstrap", "cli-manifest.json"));
|
|
128
|
+
await run(path.join(workspace, "bin", "data-harness-cli"), ["wikis", "build-index"], { cwd: workspace, stdio: "inherit" });
|
|
129
|
+
const doctor = await collectDoctor(workspace, options);
|
|
130
|
+
for (const check of doctor.checks) console.log(`${check.ok ? "PASS" : "FAIL"} ${check.name}`);
|
|
131
|
+
if (doctor.checks.some((check) => !check.ok)) throw new Error("doctor failed after update");
|
|
132
|
+
writeState(workspace, {
|
|
133
|
+
mainCommit: await currentCommit(workspace),
|
|
134
|
+
wikisCommit: await submoduleCommit(workspace),
|
|
135
|
+
manifestSha256: updates.cli.digest,
|
|
136
|
+
packageVersion: packageVersion(),
|
|
137
|
+
lastCheckAt: new Date().toISOString()
|
|
138
|
+
});
|
|
139
|
+
console.log(`Harness Data updated: ${workspace}`);
|
|
140
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { findWorkspaceDir } from "../lib/paths.js";
|
|
4
|
+
import { currentCommit, submoduleCommit } from "../lib/git.js";
|
|
5
|
+
import { readManifest } from "../lib/manifest.js";
|
|
6
|
+
import { packageVersion } from "../lib/package.js";
|
|
7
|
+
|
|
8
|
+
export async function versionCommand(options = {}) {
|
|
9
|
+
const workspace = findWorkspaceDir(options.dir);
|
|
10
|
+
const manifestPath = path.join(workspace, "bootstrap", "cli-manifest.json");
|
|
11
|
+
const result = {
|
|
12
|
+
installer: packageVersion(),
|
|
13
|
+
workspace,
|
|
14
|
+
mainCommit: fs.existsSync(workspace) ? await currentCommit(workspace) : "",
|
|
15
|
+
wikisCommit: fs.existsSync(workspace) ? await submoduleCommit(workspace) : "",
|
|
16
|
+
tools: []
|
|
17
|
+
};
|
|
18
|
+
if (fs.existsSync(manifestPath)) {
|
|
19
|
+
result.tools = (readManifest(manifestPath).tools || []).map((tool) => ({
|
|
20
|
+
name: tool.name,
|
|
21
|
+
binary: tool.binary,
|
|
22
|
+
version: tool.version
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
if (options.json) {
|
|
26
|
+
console.log(JSON.stringify(result, null, 2));
|
|
27
|
+
} else {
|
|
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}`);
|
|
32
|
+
for (const tool of result.tools) console.log(`${tool.name} ${tool.version}`);
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function writeLocalConfig(workspace, options = {}) {
|
|
5
|
+
const configDir = path.join(workspace, "config");
|
|
6
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
7
|
+
const harness = path.join(configDir, "harness-config.yaml");
|
|
8
|
+
const env = path.join(configDir, "qdm-cli-paths.env");
|
|
9
|
+
if ((fs.existsSync(harness) || fs.existsSync(env)) && !options.overwrite) {
|
|
10
|
+
throw new Error("local config already exists; rerun interactively and confirm overwrite or remove the files");
|
|
11
|
+
}
|
|
12
|
+
const bin = (name) => path.join(workspace, "bin", name).replaceAll("\\", "/");
|
|
13
|
+
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
|
+
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
|
+
|
|
17
|
+
export function validateCasConfigDir(dir) {
|
|
18
|
+
const file = path.join(dir, "config.json");
|
|
19
|
+
let config;
|
|
20
|
+
try {
|
|
21
|
+
config = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`CAS config is missing or invalid: ${file}`);
|
|
24
|
+
}
|
|
25
|
+
if (!config?.cas?.username || !config?.cas?.password) {
|
|
26
|
+
throw new Error(`CAS config must contain non-empty cas.username and cas.password: ${file}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function linkAgents(workspace, agent) {
|
|
31
|
+
const pairs = [];
|
|
32
|
+
if (agent === "claude" || agent === "both") pairs.push([".agents/claude", ".claude"]);
|
|
33
|
+
if (agent === "codex" || agent === "both") pairs.push([".agents/codex", ".codex"]);
|
|
34
|
+
for (const [sourceRel, targetRel] of pairs) {
|
|
35
|
+
const source = path.join(workspace, sourceRel);
|
|
36
|
+
const target = path.join(workspace, targetRel);
|
|
37
|
+
if (!fs.existsSync(source)) throw new Error(`agent template missing: ${sourceRel}`);
|
|
38
|
+
if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true });
|
|
39
|
+
fs.symlinkSync(source, target, "junction");
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/lib/exec.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function run(command, args = [], options = {}) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const child = spawn(command, args, {
|
|
6
|
+
cwd: options.cwd,
|
|
7
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
8
|
+
shell: options.shell || false,
|
|
9
|
+
stdio: options.stdio || "pipe"
|
|
10
|
+
});
|
|
11
|
+
let stdout = "";
|
|
12
|
+
let stderr = "";
|
|
13
|
+
if (child.stdout) child.stdout.on("data", (chunk) => (stdout += chunk));
|
|
14
|
+
if (child.stderr) child.stderr.on("data", (chunk) => (stderr += chunk));
|
|
15
|
+
child.on("error", reject);
|
|
16
|
+
child.on("close", (code) => {
|
|
17
|
+
const result = { code, stdout, stderr };
|
|
18
|
+
if (code === 0 || options.allowFailure) {
|
|
19
|
+
resolve(result);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const detail = stderr.trim() || stdout.trim();
|
|
23
|
+
reject(new Error(`${command} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function commandExists(command) {
|
|
29
|
+
const result = await run(process.platform === "win32" ? "where" : "command", process.platform === "win32" ? [command] : ["-v", command], {
|
|
30
|
+
shell: process.platform !== "win32",
|
|
31
|
+
allowFailure: true
|
|
32
|
+
});
|
|
33
|
+
return result.code === 0;
|
|
34
|
+
}
|
package/src/lib/git.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { run } from "./exec.js";
|
|
4
|
+
|
|
5
|
+
export async function isGitRepo(dir) {
|
|
6
|
+
if (!fs.existsSync(path.join(dir, ".git"))) return false;
|
|
7
|
+
const result = await run("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir, allowFailure: true });
|
|
8
|
+
return result.code === 0 && result.stdout.trim() === "true";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function currentCommit(dir) {
|
|
12
|
+
const result = await run("git", ["rev-parse", "HEAD"], { cwd: dir, allowFailure: true });
|
|
13
|
+
return result.code === 0 ? result.stdout.trim() : "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function dirtyPaths(dir) {
|
|
17
|
+
const result = await run("git", ["status", "--porcelain"], { cwd: dir, allowFailure: true });
|
|
18
|
+
if (result.code !== 0) return ["<git status failed>"];
|
|
19
|
+
return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function isDirty(dir) {
|
|
23
|
+
return (await dirtyPaths(dir)).length > 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function currentBranch(dir) {
|
|
27
|
+
const result = await run("git", ["branch", "--show-current"], { cwd: dir, allowFailure: true });
|
|
28
|
+
return result.stdout.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function remoteTracking(dir) {
|
|
32
|
+
const result = await run("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { cwd: dir, allowFailure: true });
|
|
33
|
+
return result.code === 0 ? result.stdout.trim() : "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function behindCount(dir) {
|
|
37
|
+
const upstream = await remoteTracking(dir);
|
|
38
|
+
if (!upstream) return 0;
|
|
39
|
+
await run("git", ["fetch", "--quiet"], { cwd: dir, allowFailure: true });
|
|
40
|
+
const result = await run("git", ["rev-list", "--count", `HEAD..${upstream}`], { cwd: dir, allowFailure: true });
|
|
41
|
+
return Number(result.stdout.trim() || "0");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function submoduleCommit(dir, name = "wikis") {
|
|
45
|
+
const result = await run("git", ["-C", name, "rev-parse", "HEAD"], { cwd: dir, allowFailure: true });
|
|
46
|
+
return result.code === 0 ? result.stdout.trim() : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function submoduleRemoteBehind(dir, name = "wikis") {
|
|
50
|
+
const subdir = path.join(dir, name);
|
|
51
|
+
if (!fs.existsSync(subdir)) return 0;
|
|
52
|
+
await run("git", ["fetch", "--quiet"], { cwd: subdir, allowFailure: true });
|
|
53
|
+
const upstream = await remoteTracking(subdir);
|
|
54
|
+
if (!upstream) return 0;
|
|
55
|
+
const result = await run("git", ["rev-list", "--count", `HEAD..${upstream}`], { cwd: subdir, allowFailure: true });
|
|
56
|
+
return Number(result.stdout.trim() || "0");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function gitlink(dir, rev, name) {
|
|
60
|
+
const result = await run("git", ["ls-tree", rev, name], { cwd: dir, allowFailure: true });
|
|
61
|
+
return result.stdout.trim().split(/\s+/)[2] || "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function submodulePointerChanged(dir, name = "wikis") {
|
|
65
|
+
const upstream = await remoteTracking(dir);
|
|
66
|
+
if (!upstream) return false;
|
|
67
|
+
const current = await gitlink(dir, "HEAD", name);
|
|
68
|
+
const remote = await gitlink(dir, upstream, name);
|
|
69
|
+
return Boolean(current && remote && current !== remote);
|
|
70
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { run } from "./exec.js";
|
|
6
|
+
import { platformKey } from "./platform.js";
|
|
7
|
+
|
|
8
|
+
export function readManifest(file) {
|
|
9
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function manifestDigest(manifest) {
|
|
13
|
+
return crypto.createHash("sha256").update(JSON.stringify(manifest)).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function download(url, file) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const request = https.get(url, (response) => {
|
|
19
|
+
if ([301, 302, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
20
|
+
download(response.headers.location, file).then(resolve, reject);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (response.statusCode !== 200) {
|
|
24
|
+
response.resume();
|
|
25
|
+
reject(new Error(`download failed ${response.statusCode}: ${url}`));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
|
+
const out = fs.createWriteStream(file);
|
|
30
|
+
response.pipe(out);
|
|
31
|
+
out.on("finish", () => out.close(resolve));
|
|
32
|
+
out.on("error", reject);
|
|
33
|
+
});
|
|
34
|
+
request.on("error", reject);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function expectedSha256(asset) {
|
|
39
|
+
if (asset.sha256) return asset.sha256;
|
|
40
|
+
try {
|
|
41
|
+
const tmp = path.join(fs.mkdtempSync(path.join(process.cwd(), ".bootstrap-cache-sha-")), "asset.sha256");
|
|
42
|
+
await download(`${asset.url}.sha256`, tmp);
|
|
43
|
+
return fs.readFileSync(tmp, "utf8").trim().split(/\s+/)[0];
|
|
44
|
+
} catch {
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fileSha256(file) {
|
|
50
|
+
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function installToolsFromManifest(workspace, manifestPath) {
|
|
54
|
+
const manifest = readManifest(manifestPath);
|
|
55
|
+
const key = platformKey();
|
|
56
|
+
const cacheDir = path.join(workspace, ".bootstrap-cache");
|
|
57
|
+
const binDir = path.join(workspace, "bin");
|
|
58
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
59
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
60
|
+
|
|
61
|
+
for (const tool of manifest.tools || []) {
|
|
62
|
+
const asset = tool.platforms?.[key];
|
|
63
|
+
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));
|
|
65
|
+
console.log(`Downloading ${tool.name} ${tool.version} (${key})`);
|
|
66
|
+
await download(asset.url, archive);
|
|
67
|
+
const sha = await expectedSha256(asset);
|
|
68
|
+
if (sha && fileSha256(archive) !== sha) throw new Error(`${tool.name} sha256 mismatch`);
|
|
69
|
+
if (!sha) console.warn(`warning: ${tool.name} has no sha256; continuing without checksum`);
|
|
70
|
+
if (archive.endsWith(".zip")) {
|
|
71
|
+
await run("unzip", ["-o", archive, "-d", binDir], { stdio: "inherit" });
|
|
72
|
+
} else {
|
|
73
|
+
await run("tar", ["-xzf", archive, "-C", binDir], { stdio: "inherit" });
|
|
74
|
+
}
|
|
75
|
+
const binary = path.join(binDir, tool.binary);
|
|
76
|
+
if (!fs.existsSync(binary)) throw new Error(`${tool.binary} was not extracted to bin/`);
|
|
77
|
+
fs.chmodSync(binary, 0o755);
|
|
78
|
+
}
|
|
79
|
+
return manifest;
|
|
80
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export function packageVersion() {
|
|
6
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "..", "package.json"), "utf8"));
|
|
8
|
+
return pkg.version;
|
|
9
|
+
}
|
package/src/lib/paths.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const appDir = path.join("lumi-ai-lab", "harness-data");
|
|
6
|
+
const stateDir = path.join("lumi-ai-lab", "harness-data-installer");
|
|
7
|
+
|
|
8
|
+
export function expandHome(value) {
|
|
9
|
+
if (!value) return value;
|
|
10
|
+
if (value === "~") return os.homedir();
|
|
11
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2));
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
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);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function userStatePath() {
|
|
22
|
+
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", stateDir, "state.json");
|
|
23
|
+
if (process.platform === "win32") return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), stateDir, "state.json");
|
|
24
|
+
return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"), stateDir, "state.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readUserState() {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(userStatePath(), "utf8"));
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeState(workspace, patch) {
|
|
36
|
+
const file = userStatePath();
|
|
37
|
+
const state = {
|
|
38
|
+
...readUserState(),
|
|
39
|
+
lastInstallDir: workspace,
|
|
40
|
+
updatedAt: new Date().toISOString(),
|
|
41
|
+
...patch
|
|
42
|
+
};
|
|
43
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
44
|
+
fs.writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
45
|
+
const local = path.join(workspace, ".harness", "installer-state.json");
|
|
46
|
+
fs.mkdirSync(path.dirname(local), { recursive: true });
|
|
47
|
+
fs.writeFileSync(local, `${JSON.stringify(state, null, 2)}\n`);
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveWorkspaceDir(value) {
|
|
52
|
+
return path.resolve(expandHome(value || defaultWorkspaceDir()));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function findWorkspaceDir(explicitDir) {
|
|
56
|
+
if (explicitDir) return resolveWorkspaceDir(explicitDir);
|
|
57
|
+
if (looksLikeWorkspace(process.cwd())) return process.cwd();
|
|
58
|
+
const state = readUserState();
|
|
59
|
+
if (state.lastInstallDir) return path.resolve(state.lastInstallDir);
|
|
60
|
+
return defaultWorkspaceDir();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function looksLikeWorkspace(dir) {
|
|
64
|
+
return fs.existsSync(path.join(dir, "bootstrap", "cli-manifest.json")) &&
|
|
65
|
+
fs.existsSync(path.join(dir, ".agents")) &&
|
|
66
|
+
fs.existsSync(path.join(dir, "wikis"));
|
|
67
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function platformKey() {
|
|
2
|
+
const os = process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : process.platform === "win32" ? "windows" : process.platform;
|
|
3
|
+
const arch = process.arch === "x64" ? "amd64" : process.arch;
|
|
4
|
+
const key = `${os}-${arch}`;
|
|
5
|
+
if (!["darwin-arm64", "darwin-amd64", "linux-arm64", "linux-amd64"].includes(key)) {
|
|
6
|
+
throw new Error(`unsupported platform: ${key}`);
|
|
7
|
+
}
|
|
8
|
+
return key;
|
|
9
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
|
|
4
|
+
export async function confirm(message, options = {}) {
|
|
5
|
+
if (options.yes) return true;
|
|
6
|
+
const suffix = options.defaultNo ? " [y/N] " : " [Y/n] ";
|
|
7
|
+
const rl = readline.createInterface({ input, output });
|
|
8
|
+
try {
|
|
9
|
+
const answer = (await rl.question(message + suffix)).trim().toLowerCase();
|
|
10
|
+
if (!answer) return !options.defaultNo;
|
|
11
|
+
return answer === "y" || answer === "yes";
|
|
12
|
+
} finally {
|
|
13
|
+
rl.close();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function chooseAgent(options = {}) {
|
|
18
|
+
if (options.agent) return options.agent;
|
|
19
|
+
if (options.yes) throw new Error("non-interactive install requires --agent claude|codex|both");
|
|
20
|
+
const rl = readline.createInterface({ input, output });
|
|
21
|
+
try {
|
|
22
|
+
const answer = (await rl.question("Choose Agent: claude, codex, both [codex] ")).trim().toLowerCase();
|
|
23
|
+
const value = answer || "codex";
|
|
24
|
+
if (!["claude", "codex", "both"].includes(value)) throw new Error("agent must be claude, codex, or both");
|
|
25
|
+
return value;
|
|
26
|
+
} finally {
|
|
27
|
+
rl.close();
|
|
28
|
+
}
|
|
29
|
+
}
|