@osfactory/har 0.49.2 → 0.50.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/dist/index.js CHANGED
@@ -29790,10 +29790,32 @@ var init_slot_launch_guard_occupied = __esm({
29790
29790
  }
29791
29791
  });
29792
29792
 
29793
+ // src/utils/package-runner.ts
29794
+ function commandExists(command2) {
29795
+ const result = (0, import_child_process6.spawnSync)("sh", ["-c", `command -v ${JSON.stringify(command2)}`], {
29796
+ encoding: "utf8"
29797
+ });
29798
+ return result.status === 0 && Boolean(result.stdout?.trim());
29799
+ }
29800
+ function packageRunner() {
29801
+ if (cached !== void 0) return cached;
29802
+ const available = RUNNERS.find((runner) => commandExists(runner.split(" ")[0]));
29803
+ cached = available ?? RUNNERS[0];
29804
+ return cached;
29805
+ }
29806
+ var import_child_process6, RUNNERS, cached;
29807
+ var init_package_runner = __esm({
29808
+ "src/utils/package-runner.ts"() {
29809
+ "use strict";
29810
+ import_child_process6 = require("child_process");
29811
+ RUNNERS = ["npx --yes", "bunx", "pnpm dlx", "yarn dlx"];
29812
+ }
29813
+ });
29814
+
29793
29815
  // src/core/slot-preflight.ts
29794
29816
  function listPm2Processes() {
29795
29817
  try {
29796
- const raw = (0, import_child_process6.execSync)("npx --yes pm2 jlist", {
29818
+ const raw = (0, import_child_process7.execSync)(`${packageRunner()} pm2 jlist`, {
29797
29819
  encoding: "utf8",
29798
29820
  stdio: ["pipe", "pipe", "ignore"],
29799
29821
  timeout: 3e3
@@ -29874,7 +29896,7 @@ function inspectSlotReadiness(repoPath, agentId, options = {}) {
29874
29896
  remediation: "Stop the other harness session (./.har/teardown.sh in that repo) or use a different agent slot.",
29875
29897
  details: { processes: foreign.processes }
29876
29898
  });
29877
- remediations.push("Inspect with: npx pm2 jlist | grep agent-" + agentId);
29899
+ remediations.push(`Inspect with: ${packageRunner()} pm2 jlist | grep agent-${agentId}`);
29878
29900
  }
29879
29901
  if (usesPm2 && pm2Procs) {
29880
29902
  const slotPrefix = `har-${projectName}-agent-${agentId}-`;
@@ -30008,7 +30030,7 @@ function formatPreflightReport(agentId, readiness) {
30008
30030
  }
30009
30031
  return lines2.join("\n");
30010
30032
  }
30011
- var path16, import_child_process6;
30033
+ var path16, import_child_process7;
30012
30034
  var init_slot_preflight = __esm({
30013
30035
  "src/core/slot-preflight.ts"() {
30014
30036
  "use strict";
@@ -30019,14 +30041,15 @@ var init_slot_preflight = __esm({
30019
30041
  init_slot_launch_guard_occupied();
30020
30042
  init_slot_ports();
30021
30043
  init_slot_registry();
30022
- import_child_process6 = require("child_process");
30044
+ import_child_process7 = require("child_process");
30045
+ init_package_runner();
30023
30046
  }
30024
30047
  });
30025
30048
 
30026
30049
  // src/core/slot-status.ts
30027
30050
  function readGitRemote(repoPath) {
30028
30051
  try {
30029
- const remote = (0, import_child_process7.execSync)("git remote get-url origin", {
30052
+ const remote = (0, import_child_process8.execSync)("git remote get-url origin", {
30030
30053
  cwd: repoPath,
30031
30054
  encoding: "utf8",
30032
30055
  stdio: ["pipe", "pipe", "ignore"]
@@ -30038,7 +30061,7 @@ function readGitRemote(repoPath) {
30038
30061
  }
30039
30062
  function runGit2(cwd, args) {
30040
30063
  try {
30041
- return (0, import_child_process7.execSync)(`git ${args}`, {
30064
+ return (0, import_child_process8.execSync)(`git ${args}`, {
30042
30065
  cwd,
30043
30066
  encoding: "utf8",
30044
30067
  stdio: ["pipe", "pipe", "ignore"]
@@ -30114,7 +30137,7 @@ function lastBuildPass(run2) {
30114
30137
  }
30115
30138
  function listPm2Processes2() {
30116
30139
  try {
30117
- const raw = (0, import_child_process7.execSync)("npx --yes pm2 jlist", {
30140
+ const raw = (0, import_child_process8.execSync)(`${packageRunner()} pm2 jlist`, {
30118
30141
  encoding: "utf8",
30119
30142
  stdio: ["pipe", "pipe", "ignore"],
30120
30143
  timeout: 3e3
@@ -30217,14 +30240,14 @@ function collectEnvironmentStatus(repoPath) {
30217
30240
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
30218
30241
  };
30219
30242
  }
30220
- var fs16, os6, path17, import_child_process7, BYPASS_WARNING_MS;
30243
+ var fs16, os6, path17, import_child_process8, BYPASS_WARNING_MS;
30221
30244
  var init_slot_status = __esm({
30222
30245
  "src/core/slot-status.ts"() {
30223
30246
  "use strict";
30224
30247
  fs16 = __toESM(require("fs"));
30225
30248
  os6 = __toESM(require("os"));
30226
30249
  path17 = __toESM(require("path"));
30227
- import_child_process7 = require("child_process");
30250
+ import_child_process8 = require("child_process");
30228
30251
  init_env();
30229
30252
  init_manifest();
30230
30253
  init_stages();
@@ -30232,6 +30255,7 @@ var init_slot_status = __esm({
30232
30255
  init_runs();
30233
30256
  init_slot_registry();
30234
30257
  init_slot_preflight();
30258
+ init_package_runner();
30235
30259
  BYPASS_WARNING_MS = 15 * 60 * 1e3;
30236
30260
  }
30237
30261
  });
@@ -32336,7 +32360,8 @@ async function syncRepoWithPortal(options, controlApiUrl) {
32336
32360
  );
32337
32361
  const { selected: newRuns } = selectSince(runs, runsSince, runTimestamp);
32338
32362
  let repoId;
32339
- await syncRunBatches(newRuns, repoPath, runsTarget, () => repoId, dryRun, async (batch, i) => {
32363
+ const deltaRepoId = () => stored?.repoId ?? repoId;
32364
+ await syncRunBatches(newRuns, repoPath, runsTarget, deltaRepoId, dryRun, async (batch, i) => {
32340
32365
  const body = i === 0 ? { ...syncBody, runs: batch } : { path: repoPath, runs: batch };
32341
32366
  const res = await postPortal(portal, "/api/sync", body, dryRun);
32342
32367
  if (i === 0 && typeof res?.repositoryId === "string") repoId = res.repositoryId;
@@ -53490,8 +53515,8 @@ exec "${otelHookCommand}" run \\
53490
53515
  }
53491
53516
  return wrapperPath;
53492
53517
  }
53493
- function commandExists2(command2) {
53494
- const result = (0, import_child_process10.spawnSync)("sh", ["-c", `command -v ${JSON.stringify(command2)}`], {
53518
+ function commandExists3(command2) {
53519
+ const result = (0, import_child_process11.spawnSync)("sh", ["-c", `command -v ${JSON.stringify(command2)}`], {
53495
53520
  encoding: "utf8"
53496
53521
  });
53497
53522
  return result.status === 0 && Boolean(result.stdout?.trim());
@@ -53501,25 +53526,37 @@ function resolveOtelHookCommand(hooksHome) {
53501
53526
  if (fs41.existsSync(local)) return local;
53502
53527
  return null;
53503
53528
  }
53529
+ function resolveHooksInstaller(hooksHome) {
53530
+ const candidates = [
53531
+ {
53532
+ command: "npm",
53533
+ args: ["install", "--prefix", hooksHome, "--no-fund", "--no-audit", OTEL_HOOKS_PACKAGE]
53534
+ },
53535
+ { command: "bun", args: ["add", "--cwd", hooksHome, OTEL_HOOKS_PACKAGE] },
53536
+ { command: "pnpm", args: ["add", "--dir", hooksHome, OTEL_HOOKS_PACKAGE] }
53537
+ ];
53538
+ return candidates.find((candidate) => commandExists3(candidate.command)) ?? null;
53539
+ }
53504
53540
  function tryInstallPackage(hooksHome) {
53505
53541
  fs41.mkdirSync(hooksHome, { recursive: true });
53506
- if (!commandExists2("npm")) {
53542
+ const installer = resolveHooksInstaller(hooksHome);
53543
+ if (!installer) {
53507
53544
  return {
53508
53545
  ok: false,
53509
- detail: "npm not found; install Node.js to use @osfactory/otel-hook"
53546
+ detail: "no package manager found; install Node.js or bun to use @osfactory/otel-hook"
53510
53547
  };
53511
53548
  }
53512
- const result = (0, import_child_process10.spawnSync)(
53513
- "npm",
53514
- ["install", "--prefix", hooksHome, "--no-fund", "--no-audit", OTEL_HOOKS_PACKAGE],
53515
- { encoding: "utf8", timeout: 18e4 }
53516
- );
53549
+ const described = `${installer.command} ${installer.args.join(" ")}`;
53550
+ const result = (0, import_child_process11.spawnSync)(installer.command, installer.args, {
53551
+ encoding: "utf8",
53552
+ timeout: 18e4
53553
+ });
53517
53554
  if (result.status === 0 && fs41.existsSync(localOtelHookBin(hooksHome))) {
53518
- return { ok: true, detail: `npm install --prefix ${hooksHome} ${OTEL_HOOKS_PACKAGE}` };
53555
+ return { ok: true, detail: described };
53519
53556
  }
53520
53557
  return {
53521
53558
  ok: false,
53522
- detail: `npm install ${OTEL_HOOKS_PACKAGE} failed: ${(result.stderr || result.stdout || "").trim().slice(0, 400)}`
53559
+ detail: `${described} failed: ${(result.stderr || result.stdout || "").trim().slice(0, 400)}`
53523
53560
  };
53524
53561
  }
53525
53562
  function uninstallLegacyPythonHooks(hooksHome) {
@@ -53530,9 +53567,9 @@ function uninstallLegacyPythonHooks(hooksHome) {
53530
53567
  ["pip3", ["uninstall", "-y", "opentelemetry-hooks"]]
53531
53568
  ];
53532
53569
  for (const [cmd, args] of attempts) {
53533
- if (!commandExists2(cmd)) continue;
53570
+ if (!commandExists3(cmd)) continue;
53534
53571
  try {
53535
- (0, import_child_process10.spawnSync)(cmd, args, { encoding: "utf8", timeout: 6e4 });
53572
+ (0, import_child_process11.spawnSync)(cmd, args, { encoding: "utf8", timeout: 6e4 });
53536
53573
  } catch {
53537
53574
  }
53538
53575
  }
@@ -53564,7 +53601,7 @@ function normalizeLegacyProviderFlagsInCommand(command2) {
53564
53601
  }
53565
53602
  function runSetupProvider(providerId, otelHookCommand, wrapperPath) {
53566
53603
  const hookCommand = `${wrapperPath} --provider ${providerId}`;
53567
- const result = (0, import_child_process10.spawnSync)(
53604
+ const result = (0, import_child_process11.spawnSync)(
53568
53605
  otelHookCommand,
53569
53606
  [
53570
53607
  "setup",
@@ -53805,11 +53842,11 @@ function ensureOtelHooks(options) {
53805
53842
  function disableOtelHooksExport() {
53806
53843
  return writeOtelHooksConfig(buildOtelHooksConfig({ enabled: false }));
53807
53844
  }
53808
- var import_child_process10, fs41, os15, path47, OTEL_HOOKS_PACKAGE, OTEL_HOOKS_PROVIDERS, OTEL_HOOKS_AGENTS, LEGACY_CURSOR_ONLY_HOOK_EVENTS;
53845
+ var import_child_process11, fs41, os15, path47, OTEL_HOOKS_PACKAGE, OTEL_HOOKS_PROVIDERS, OTEL_HOOKS_AGENTS, LEGACY_CURSOR_ONLY_HOOK_EVENTS;
53809
53846
  var init_otel_hooks = __esm({
53810
53847
  "src/core/otel-hooks.ts"() {
53811
53848
  "use strict";
53812
- import_child_process10 = require("child_process");
53849
+ import_child_process11 = require("child_process");
53813
53850
  fs41 = __toESM(require("fs"));
53814
53851
  os15 = __toESM(require("os"));
53815
53852
  path47 = __toESM(require("path"));
@@ -70178,13 +70215,13 @@ var path44 = __toESM(require("path"));
70178
70215
  var readline5 = __toESM(require("readline"));
70179
70216
 
70180
70217
  // src/utils/clipboard.ts
70181
- var import_child_process8 = require("child_process");
70182
- function commandExists(command2) {
70183
- const probe = process.platform === "win32" ? (0, import_child_process8.spawnSync)("where", [command2], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] }) : (0, import_child_process8.spawnSync)("which", [command2], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] });
70218
+ var import_child_process9 = require("child_process");
70219
+ function commandExists2(command2) {
70220
+ const probe = process.platform === "win32" ? (0, import_child_process9.spawnSync)("where", [command2], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] }) : (0, import_child_process9.spawnSync)("which", [command2], { encoding: "utf8", stdio: ["ignore", "ignore", "ignore"] });
70184
70221
  return probe.status === 0;
70185
70222
  }
70186
70223
  function tryPipeCommand(command2, args, text) {
70187
- const result = (0, import_child_process8.spawnSync)(command2, args, {
70224
+ const result = (0, import_child_process9.spawnSync)(command2, args, {
70188
70225
  input: text,
70189
70226
  encoding: "utf8",
70190
70227
  stdio: ["pipe", "ignore", "ignore"]
@@ -70199,12 +70236,12 @@ function writeOsc52Clipboard(text, stream = process.stderr) {
70199
70236
  return true;
70200
70237
  }
70201
70238
  function copyViaPlatformTools(text) {
70202
- if (process.platform === "darwin" && commandExists("pbcopy")) {
70239
+ if (process.platform === "darwin" && commandExists2("pbcopy")) {
70203
70240
  if (tryPipeCommand("pbcopy", [], text)) return { ok: true, method: "pbcopy" };
70204
70241
  }
70205
70242
  if (process.platform === "win32") {
70206
- if (commandExists("powershell")) {
70207
- const result = (0, import_child_process8.spawnSync)(
70243
+ if (commandExists2("powershell")) {
70244
+ const result = (0, import_child_process9.spawnSync)(
70208
70245
  "powershell",
70209
70246
  [
70210
70247
  "-NoProfile",
@@ -70220,18 +70257,18 @@ function copyViaPlatformTools(text) {
70220
70257
  );
70221
70258
  if (result.status === 0 && !result.error) return { ok: true, method: "powershell" };
70222
70259
  }
70223
- if (commandExists("clip") && tryPipeCommand("clip", [], text)) {
70260
+ if (commandExists2("clip") && tryPipeCommand("clip", [], text)) {
70224
70261
  return { ok: true, method: "clip" };
70225
70262
  }
70226
70263
  }
70227
70264
  if (process.platform === "linux" || process.platform === "freebsd" || process.platform === "openbsd") {
70228
- if (commandExists("wl-copy") && tryPipeCommand("wl-copy", [], text)) {
70265
+ if (commandExists2("wl-copy") && tryPipeCommand("wl-copy", [], text)) {
70229
70266
  return { ok: true, method: "wl-copy" };
70230
70267
  }
70231
- if (commandExists("xclip") && tryPipeCommand("xclip", ["-selection", "clipboard"], text)) {
70268
+ if (commandExists2("xclip") && tryPipeCommand("xclip", ["-selection", "clipboard"], text)) {
70232
70269
  return { ok: true, method: "xclip" };
70233
70270
  }
70234
- if (commandExists("xsel") && tryPipeCommand("xsel", ["--clipboard", "--input"], text)) {
70271
+ if (commandExists2("xsel") && tryPipeCommand("xsel", ["--clipboard", "--input"], text)) {
70235
70272
  return { ok: true, method: "xsel" };
70236
70273
  }
70237
70274
  }
@@ -70450,7 +70487,7 @@ init_validation();
70450
70487
  init_control_config();
70451
70488
 
70452
70489
  // src/core/control-lifecycle.ts
70453
- var import_child_process9 = require("child_process");
70490
+ var import_child_process10 = require("child_process");
70454
70491
  var path46 = __toESM(require("path"));
70455
70492
 
70456
70493
  // src/core/control-image.ts
@@ -70503,7 +70540,7 @@ function buildDockerRunArgs(options) {
70503
70540
  return args;
70504
70541
  }
70505
70542
  function runDocker(args) {
70506
- const result = (0, import_child_process9.spawnSync)("docker", args, {
70543
+ const result = (0, import_child_process10.spawnSync)("docker", args, {
70507
70544
  stdio: "inherit",
70508
70545
  env: process.env
70509
70546
  });
@@ -70513,7 +70550,7 @@ function runDocker(args) {
70513
70550
  return result.status ?? 1;
70514
70551
  }
70515
70552
  function controlContainerExists(name = CONTROL_CONTAINER_NAME) {
70516
- const result = (0, import_child_process9.spawnSync)("docker", ["ps", "-aq", "-f", `name=^${name}$`], {
70553
+ const result = (0, import_child_process10.spawnSync)("docker", ["ps", "-aq", "-f", `name=^${name}$`], {
70517
70554
  encoding: "utf8",
70518
70555
  stdio: ["pipe", "pipe", "ignore"]
70519
70556
  });
@@ -72353,13 +72390,13 @@ init_portal_credentials();
72353
72390
  // src/core/portal-login.ts
72354
72391
  var http = __toESM(require("http"));
72355
72392
  var import_crypto = require("crypto");
72356
- var import_child_process11 = require("child_process");
72393
+ var import_child_process12 = require("child_process");
72357
72394
  var CALLBACK_PATH = "/callback";
72358
72395
  var LOGIN_TIMEOUT_MS = 3 * 6e4;
72359
72396
  function openBrowser(url) {
72360
72397
  const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
72361
72398
  try {
72362
- const child = (0, import_child_process11.spawn)(cmd, args, { stdio: "ignore", detached: true });
72399
+ const child = (0, import_child_process12.spawn)(cmd, args, { stdio: "ignore", detached: true });
72363
72400
  child.on("error", () => {
72364
72401
  });
72365
72402
  child.unref();
@@ -73519,7 +73556,7 @@ __export(util_exports, {
73519
73556
  assertNever: () => assertNever,
73520
73557
  assertNotEqual: () => assertNotEqual,
73521
73558
  assignProp: () => assignProp,
73522
- cached: () => cached,
73559
+ cached: () => cached2,
73523
73560
  captureStackTrace: () => captureStackTrace,
73524
73561
  cleanEnum: () => cleanEnum,
73525
73562
  cleanRegex: () => cleanRegex,
@@ -73584,7 +73621,7 @@ function jsonStringifyReplacer(_2, value) {
73584
73621
  return value.toString();
73585
73622
  return value;
73586
73623
  }
73587
- function cached(getter) {
73624
+ function cached2(getter) {
73588
73625
  const set = false;
73589
73626
  return {
73590
73627
  get value() {
@@ -73673,7 +73710,7 @@ var captureStackTrace = Error.captureStackTrace ? Error.captureStackTrace : (...
73673
73710
  function isObject(data) {
73674
73711
  return typeof data === "object" && data !== null && !Array.isArray(data);
73675
73712
  }
73676
- var allowsEval = cached(() => {
73713
+ var allowsEval = cached2(() => {
73677
73714
  if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) {
73678
73715
  return false;
73679
73716
  }
@@ -75168,7 +75205,7 @@ function handleOptionalObjectResult(result, final, key, input) {
75168
75205
  }
75169
75206
  var $ZodObject = /* @__PURE__ */ $constructor("$ZodObject", (inst, def) => {
75170
75207
  $ZodType.init(inst, def);
75171
- const _normalized = cached(() => {
75208
+ const _normalized = cached2(() => {
75172
75209
  const keys = Object.keys(def.shape);
75173
75210
  for (const k of keys) {
75174
75211
  if (!(def.shape[k] instanceof $ZodType)) {
@@ -75403,7 +75440,7 @@ var $ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("$ZodDiscriminatedUnio
75403
75440
  }
75404
75441
  return propValues;
75405
75442
  });
75406
- const disc = cached(() => {
75443
+ const disc = cached2(() => {
75407
75444
  const opts = def.options;
75408
75445
  const map = /* @__PURE__ */ new Map();
75409
75446
  for (const o of opts) {
@@ -69,6 +69,11 @@ how this project is built and tested. Use toolchain variables from `.env.agent.<
69
69
  The stock verify section is keyed by `HARNESS_ECOSYSTEM`; it is a starting point,
70
70
  not the repo's final contract. Replace conventions that do not match this project.
71
71
 
72
+ `${NPM_BIN}` may resolve to bun, pnpm, or yarn as well as npm, so keep Node steps
73
+ package-manager agnostic: always `${NPM_BIN:-npm} run <script>` (never bare
74
+ `${NPM_BIN} test`, which runs bun's own test runner), and never npm-only flags
75
+ such as `--prefix` — use a subshell like `(cd docs && ${NPM_BIN:-npm} run build)`.
76
+
72
77
  **Tier contract:**
73
78
 
74
79
  | Mode | Command | Intent |
@@ -25,7 +25,7 @@ Prefer targeted edits over full rewrites. Key files to review:
25
25
  Keep this accurate — it is the harness index. Update whenever scripts, stages, or workflow change.
26
26
 
27
27
  ### `.har/harness.env`, `verify.sh`, `provision-toolchain.sh`, `ecosystem.agent.template.cjs`, `CLAUDE.agent.md`
28
- Align commands and instructions with the current stack. Verify steps must use toolchain paths from `.env.agent.<id>` (`PYTHON_BIN`, `NPM_BIN`, `XCODEBUILD_BIN`, …) — never hardcoded venv or interpreter paths. Replace stock ecosystem conventions that do not match the repository; do not leave npm/pytest/go/cargo/maven/gradle examples in place by accident.
28
+ Align commands and instructions with the current stack. Verify steps must use toolchain paths from `.env.agent.<id>` (`PYTHON_BIN`, `NPM_BIN`, `XCODEBUILD_BIN`, …) — never hardcoded venv or interpreter paths. `NPM_BIN` may be bun, pnpm, or yarn, so Node steps must use `${NPM_BIN:-npm} run <script>` and avoid npm-only flags such as `--prefix`. Replace stock ecosystem conventions that do not match the repository; do not leave npm/pytest/go/cargo/maven/gradle examples in place by accident.
29
29
 
30
30
  ### `.har/env.template`, `setup-infra.sh`, `docker-compose.agent.yml`
31
31
  Update only if infra changed.
@@ -28,7 +28,7 @@ case "$COMMAND" in
28
28
  ENV_FILE="$(resolve_agent_env_file "$AGENT_ID" "$REPO_ROOT" || true)"
29
29
  WORKTREE_DIR="$(existing_slot_worktree "$AGENT_ID")"
30
30
  REG_FILE="$(slot_registry_file "$AGENT_ID")"
31
- PM2_RAW=$(npx --yes pm2 jlist 2>/dev/null || true)
31
+ PM2_RAW=$($(har_pkg_exec) pm2 jlist 2>/dev/null || true)
32
32
  PM2_FOUND=false
33
33
  PM2_FOREIGN=false
34
34
  PM2_LEGACY=false
@@ -123,22 +123,23 @@ process.stdin.on('end', () => {
123
123
  logs)
124
124
  SERVICE="${3:-}"
125
125
  if [ -n "$SERVICE" ]; then
126
- npx pm2 logs "${PM2_SLOT_PREFIX}-${SERVICE}" --lines 100
126
+ $(har_pkg_exec) pm2 logs "${PM2_SLOT_PREFIX}-${SERVICE}" --lines 100
127
127
  else
128
- npx pm2 logs --name "${PM2_SLOT_PREFIX}" --lines 100
128
+ $(har_pkg_exec) pm2 logs --name "${PM2_SLOT_PREFIX}" --lines 100
129
129
  fi
130
130
  ;;
131
131
 
132
132
  restart)
133
133
  SERVICE="${3:-}"
134
+ PKG_EXEC="$(har_pkg_exec)"
134
135
  if [ -n "$SERVICE" ]; then
135
- npx pm2 restart "${PM2_SLOT_PREFIX}-${SERVICE}"
136
+ $(har_pkg_exec) pm2 restart "${PM2_SLOT_PREFIX}-${SERVICE}"
136
137
  else
137
- npx pm2 jlist 2>/dev/null | node -e "
138
+ $(har_pkg_exec) pm2 jlist 2>/dev/null | node -e "
138
139
  const prefix = '${PM2_SLOT_PREFIX}-';
139
140
  const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
140
141
  const names = d.filter(x => x.name && x.name.startsWith(prefix)).map(x => x.name);
141
- names.forEach(n => require('child_process').execSync('npx pm2 restart ' + n, {stdio:'inherit'}));
142
+ names.forEach(n => require('child_process').execSync('${PKG_EXEC} pm2 restart ' + n, {stdio:'inherit'}));
142
143
  if (names.length === 0) console.log('No processes found for ${PM2_SLOT_PREFIX}');
143
144
  " 2>/dev/null || true
144
145
  fi
@@ -184,7 +184,7 @@ har_check_foreign_pm2() {
184
184
  local agent_id="$1"
185
185
  local pm2_raw
186
186
  har_harness_uses_pm2 || return 0
187
- pm2_raw="$(npx --yes pm2 jlist 2>/dev/null || true)"
187
+ pm2_raw="$($(har_pkg_exec) pm2 jlist 2>/dev/null || true)"
188
188
  [ -n "$pm2_raw" ] || return 0
189
189
  set +e
190
190
  echo "$pm2_raw" | node -e "
@@ -51,6 +51,10 @@ export HARNESS_AGENT_SLOT_MAX=5
51
51
  # Ecosystem: auto (detect from manifests) | node | python | go | rust | java | ruby | none
52
52
  export HARNESS_ECOSYSTEM="auto"
53
53
  # export HARNESS_INSTALL_CMD="make deps"
54
+ # Node package manager: auto-detected from package.json "packageManager" and the
55
+ # lockfile (bun.lock → bun, pnpm-lock.yaml → pnpm, yarn.lock → yarn, else npm),
56
+ # falling back to whichever is installed. Set to pin one: npm | bun | pnpm | yarn
57
+ # export HARNESS_NODE_PACKAGE_MANAGER="bun"
54
58
 
55
59
  # Shared infrastructure — space-separated docker compose service names from
56
60
  # docker-compose.agent.yml. Started ONCE by setup-infra.sh and shared by all
@@ -66,6 +70,119 @@ har_infra_enabled() {
66
70
  esac
67
71
  }
68
72
 
73
+ # ── Node package manager helpers ──────────────────────────────────────────────
74
+ # Mirrored verbatim in harness.env and provision-toolchain.sh so a sourcing
75
+ # script and the provisioning subprocess resolve the same tool.
76
+
77
+ # Package managers HAR can drive, in fallback preference order.
78
+ HAR_NODE_PACKAGE_MANAGERS="npm bun pnpm yarn"
79
+
80
+ # Package manager the repo declares: an explicit HARNESS_NODE_PACKAGE_MANAGER
81
+ # wins, then package.json "packageManager", then the lockfile. Empty when the
82
+ # repo says nothing.
83
+ har_node_declared_package_manager() {
84
+ local dir="${1:-.}"
85
+
86
+ if [ -n "${HARNESS_NODE_PACKAGE_MANAGER:-}" ]; then
87
+ echo "$HARNESS_NODE_PACKAGE_MANAGER"
88
+ return
89
+ fi
90
+ if [ -f "$dir/package.json" ]; then
91
+ local field
92
+ field="$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"\([a-z]*\)@.*/\1/p' "$dir/package.json" | head -1)"
93
+ if [ -n "$field" ]; then
94
+ echo "$field"
95
+ return
96
+ fi
97
+ fi
98
+ if [ -f "$dir/bun.lock" ] || [ -f "$dir/bun.lockb" ]; then echo bun; return; fi
99
+ if [ -f "$dir/pnpm-lock.yaml" ]; then echo pnpm; return; fi
100
+ if [ -f "$dir/yarn.lock" ]; then echo yarn; return; fi
101
+ if [ -f "$dir/package-lock.json" ]; then echo npm; return; fi
102
+ echo ""
103
+ }
104
+
105
+ # Package manager to actually run. A manager the repo declares but this machine
106
+ # lacks falls back to one that is installed, so a repo pinned to npm still
107
+ # provisions on a bun-only machine (and the reverse).
108
+ har_node_package_manager() {
109
+ local dir="${1:-.}"
110
+ local declared
111
+ declared="$(har_node_declared_package_manager "$dir")"
112
+
113
+ if [ -n "$declared" ] && command -v "$declared" >/dev/null 2>&1; then
114
+ echo "$declared"
115
+ return
116
+ fi
117
+
118
+ local candidate
119
+ for candidate in $HAR_NODE_PACKAGE_MANAGERS; do
120
+ if command -v "$candidate" >/dev/null 2>&1; then
121
+ echo "$candidate"
122
+ return
123
+ fi
124
+ done
125
+
126
+ echo "${declared:-npm}"
127
+ }
128
+
129
+ # Lockfile a manager writes, so a substitute install can clean up after itself.
130
+ har_node_lockfile() {
131
+ case "$1" in
132
+ bun) echo "bun.lock" ;;
133
+ npm) echo "package-lock.json" ;;
134
+ pnpm) echo "pnpm-lock.yaml" ;;
135
+ yarn) echo "yarn.lock" ;;
136
+ *) echo "" ;;
137
+ esac
138
+ }
139
+
140
+ # Install dependencies in a directory. When a substitute manager stands in for
141
+ # the one the repo declares, any lockfile it creates is removed afterwards:
142
+ # provisioning must not migrate the repo to a different package manager.
143
+ # (bun writes bun.lock even under --no-save, so the flags alone are not enough.)
144
+ har_node_install() {
145
+ local dir="$1"
146
+ local manager="$2"
147
+ local declared="${3:-}"
148
+ local substituting=false
149
+ local lockfile=""
150
+ local had_lockfile=false
151
+
152
+ if [ -n "$declared" ] && [ "$declared" != "$manager" ]; then
153
+ substituting=true
154
+ lockfile="$(har_node_lockfile "$manager")"
155
+ if [ -n "$lockfile" ] && [ -e "$dir/$lockfile" ]; then
156
+ had_lockfile=true
157
+ fi
158
+ fi
159
+
160
+ local code=0
161
+ (cd "$dir" && "$manager" install --silent) || code=$?
162
+
163
+ if [ "$substituting" = true ] && [ "$had_lockfile" = false ] && [ -n "$lockfile" ]; then
164
+ rm -f "$dir/$lockfile"
165
+ [ "$manager" = "bun" ] && rm -f "$dir/bun.lockb"
166
+ fi
167
+ return $code
168
+ }
169
+
170
+ # Package runner (npx equivalent) for one-off CLIs such as pm2, including the
171
+ # flag that keeps it non-interactive. Takes a manager name, or none to resolve
172
+ # from the environment and PATH.
173
+ har_pkg_exec() {
174
+ case "${1:-${HARNESS_NODE_PACKAGE_MANAGER:-}}" in
175
+ bun) echo "bunx"; return ;;
176
+ pnpm) echo "pnpm dlx"; return ;;
177
+ yarn) echo "yarn dlx"; return ;;
178
+ npm) echo "npx --yes"; return ;;
179
+ esac
180
+ if command -v npx >/dev/null 2>&1; then echo "npx --yes"; return; fi
181
+ if command -v bunx >/dev/null 2>&1; then echo "bunx"; return; fi
182
+ if command -v pnpm >/dev/null 2>&1; then echo "pnpm dlx"; return; fi
183
+ echo "npx --yes"
184
+ }
185
+
69
186
  # Postgres client — uses host tools when installed, otherwise docker exec into
70
187
  # the shared db container. Usage: har_pg psql -d postgres -c "SELECT 1"
71
188
  # har_pg createdb -T template_x agent_1
@@ -249,13 +249,13 @@ DEBUG_PORT="$DEBUG_PORT" \
249
249
 
250
250
  PM2_REGEX="$(har_pm2_delete_regex "$AGENT_ID")"
251
251
  # Stop existing processes for this agent (project-scoped — never touch other harnesses).
252
- npx --yes pm2 delete "$PM2_REGEX" 2>/dev/null || true
252
+ $(har_pkg_exec) pm2 delete "$PM2_REGEX" 2>/dev/null || true
253
253
 
254
254
  # Start PM2 processes
255
255
  log "Starting PM2 processes..."
256
256
  cd "$WORK_DIR"
257
- npx pm2 start "$ECOSYSTEM_FILE"
258
- npx pm2 save --force >/dev/null 2>&1 || true
257
+ $(har_pkg_exec) pm2 start "$ECOSYSTEM_FILE"
258
+ $(har_pkg_exec) pm2 save --force >/dev/null 2>&1 || true
259
259
 
260
260
  # Health check
261
261
  if [ -n "${HARNESS_HEALTH_CHECK_PATH:-}" ]; then