@nanhara/hara 0.122.0 → 0.122.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +24 -11
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +33 -10
  5. package/dist/agent/touched.js +4 -0
  6. package/dist/checkpoints.js +103 -17
  7. package/dist/cli.js +16 -0
  8. package/dist/config.js +141 -12
  9. package/dist/context/agents-md.js +44 -9
  10. package/dist/context/mentions.js +10 -4
  11. package/dist/context/subdir-hints.js +40 -7
  12. package/dist/cron/runner.js +372 -37
  13. package/dist/cron/store.js +11 -3
  14. package/dist/exec/jobs.js +88 -20
  15. package/dist/fs-read.js +318 -3
  16. package/dist/fs-walk.js +8 -2
  17. package/dist/fs-write.js +197 -11
  18. package/dist/gateway/discord.js +2 -4
  19. package/dist/gateway/feishu.js +4 -6
  20. package/dist/gateway/flows-pending.js +38 -31
  21. package/dist/gateway/matrix.js +3 -5
  22. package/dist/gateway/mattermost.js +2 -4
  23. package/dist/gateway/outbound-files.js +379 -0
  24. package/dist/gateway/serve.js +121 -73
  25. package/dist/gateway/signal.js +4 -6
  26. package/dist/gateway/slack.js +4 -6
  27. package/dist/gateway/telegram.js +3 -5
  28. package/dist/gateway/tmux-routes.js +11 -3
  29. package/dist/gateway/wecom.js +7 -8
  30. package/dist/gateway/weixin.js +22 -12
  31. package/dist/hooks.js +12 -6
  32. package/dist/index.js +142 -61
  33. package/dist/mcp/client.js +164 -12
  34. package/dist/memory/store.js +68 -22
  35. package/dist/org/planner.js +36 -10
  36. package/dist/org/review-chain.js +360 -24
  37. package/dist/org/roles.js +4 -2
  38. package/dist/profile/profile.js +152 -27
  39. package/dist/recall.js +4 -2
  40. package/dist/runtime.js +37 -0
  41. package/dist/sandbox.js +142 -33
  42. package/dist/search/semindex.js +182 -53
  43. package/dist/search/zvec-store.js +121 -42
  44. package/dist/security/permissions.js +326 -19
  45. package/dist/security/private-state.js +299 -0
  46. package/dist/security/project-trust.js +6 -0
  47. package/dist/security/sensitive-files.js +723 -0
  48. package/dist/security/subprocess-env.js +210 -0
  49. package/dist/serve/server.js +2 -1
  50. package/dist/skills/skills.js +16 -7
  51. package/dist/tools/builtin.js +53 -27
  52. package/dist/tools/cron.js +6 -0
  53. package/dist/tools/edit.js +15 -5
  54. package/dist/tools/external_agent.js +110 -16
  55. package/dist/tools/memory.js +38 -8
  56. package/dist/tools/patch.js +37 -17
  57. package/dist/tools/search.js +100 -40
  58. package/dist/tools/send.js +11 -5
  59. package/package.json +11 -10
  60. package/runtime-bootstrap.cjs +72 -0
@@ -1,10 +1,12 @@
1
1
  // Search/listing tools — grep (regex across files), glob (path patterns), ls (one directory).
2
2
  // All read-only (kind: "read"), so they never hit the approval gate and run in parallel.
3
- import { readdirSync, statSync } from "node:fs";
3
+ import { lstatSync, readdirSync, statSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
5
  import { basename, dirname, isAbsolute, resolve, join, relative, sep } from "node:path";
6
6
  import { registerTool } from "./registry.js";
7
7
  import { IGNORE_DIRS, walkFiles } from "../fs-walk.js";
8
+ import { isSensitiveFilePath, sensitiveFileError, sensitiveFilesAllowed, SENSITIVE_SEARCH_GLOBS, } from "../security/sensitive-files.js";
9
+ import { toolSubprocessEnv } from "../security/subprocess-env.js";
8
10
  const MAX_OUT = 60_000;
9
11
  const MAX_MATCHES = 300;
10
12
  const MAX_FILE_BYTES = 2 * 1024 * 1024;
@@ -99,10 +101,16 @@ function runRipgrep(pattern, root, isFile, cwd, glob, ignoreCase) {
99
101
  args.push("--glob", `!**/${ignored}/**`);
100
102
  if (glob)
101
103
  args.push("--glob", glob);
104
+ // Keep protected exclusions last: ripgrep resolves overlapping globs in argument order, so a caller's
105
+ // positive glob must not re-include a safe-looking .env template during a broad directory search.
106
+ if (!isFile && !sensitiveFilesAllowed()) {
107
+ for (const protectedGlob of SENSITIVE_SEARCH_GLOBS)
108
+ args.push("--glob", `!${protectedGlob}`);
109
+ }
102
110
  if (ignoreCase)
103
111
  args.push("--ignore-case");
104
112
  args.push("--", pattern, target);
105
- const child = spawn("rg", args, { cwd: runCwd, stdio: ["ignore", "pipe", "pipe"] });
113
+ const child = spawn("rg", args, { cwd: runCwd, stdio: ["ignore", "pipe", "pipe"], env: toolSubprocessEnv() });
106
114
  const lines = [];
107
115
  let matches = 0;
108
116
  let scanned = 0;
@@ -151,6 +159,8 @@ function runRipgrep(pattern, root, isFile, cwd, glob, ignoreCase) {
151
159
  if (typeof pathText !== "string" || !Number.isFinite(lineNo) || typeof content !== "string")
152
160
  return;
153
161
  const absolute = resolve(runCwd, pathText);
162
+ if (isSensitiveFilePath(absolute))
163
+ return; // defense if a user glob overrides rg's exclusion ordering
154
164
  const shown = toPosix(relative(cwd, absolute)) || toPosix(relative(root, absolute)) || basename(absolute);
155
165
  const rendered = `${shown}:${lineNo}: ${content.replace(/\r?\n$/, "").trim().slice(0, 300)}`;
156
166
  lines.push(rendered);
@@ -212,7 +222,7 @@ const NODE_GREP_SOURCE = String.raw `
212
222
  "use strict";
213
223
  const fs = require("node:fs");
214
224
  const path = require("node:path");
215
- const MAX_INPUT_BYTES = 64 * 1024;
225
+ const MAX_INPUT_BYTES = 16 * 1024 * 1024;
216
226
  let input = "";
217
227
  let inputRejected = false;
218
228
  function execute() {
@@ -272,33 +282,23 @@ function execute() {
272
282
  return patternAt === patternParts.length;
273
283
  }
274
284
 
275
- const ignored = new Set(cfg.ignoreDirs);
276
- const files = [];
277
- if (cfg.isFile) files.push(cfg.root);
278
- else {
279
- const stack = [cfg.root];
280
- while (stack.length && files.length < cfg.maxFiles) {
281
- const dir = stack.pop();
282
- let entries;
283
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
284
- catch (error) { continue; }
285
- for (const entry of entries) {
286
- if (files.length >= cfg.maxFiles) break;
287
- const absolute = path.join(dir, entry.name);
288
- if (entry.isDirectory()) {
289
- if (!ignored.has(entry.name)) stack.push(absolute);
290
- } else if (entry.isFile()) files.push(absolute);
291
- }
292
- }
293
- }
285
+ // Candidate discovery and the complete protected-file policy run in the parent process. The worker gets
286
+ // only paths whose exact identity was already approved, then verifies that identity again on the opened fd.
287
+ const files = Array.isArray(cfg.files) ? cfg.files : [];
294
288
 
295
289
  const readBuffer = Buffer.allocUnsafe(cfg.maxFileBytes + 1);
296
- function safeReadText(absolute) {
290
+ function sameIdentity(info, candidate) {
291
+ return String(info.dev) === candidate.dev && String(info.ino) === candidate.ino;
292
+ }
293
+ function safeReadText(candidate) {
294
+ const absolute = candidate.path;
297
295
  let fd;
298
296
  try {
299
- fd = fs.openSync(absolute, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
300
- const info = fs.fstatSync(fd);
301
- if (!info.isFile() || info.size > cfg.maxFileBytes) return null;
297
+ const before = fs.lstatSync(absolute, { bigint: true });
298
+ if (!before.isFile() || before.nlink !== 1n || !sameIdentity(before, candidate)) return null;
299
+ fd = fs.openSync(absolute, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOFOLLOW || 0));
300
+ const info = fs.fstatSync(fd, { bigint: true });
301
+ if (!info.isFile() || info.nlink !== 1n || !sameIdentity(info, candidate) || info.size > BigInt(cfg.maxFileBytes)) return null;
302
302
  let total = 0;
303
303
  while (total <= cfg.maxFileBytes) {
304
304
  const count = fs.readSync(fd, readBuffer, total, cfg.maxFileBytes + 1 - total, total);
@@ -306,6 +306,10 @@ function execute() {
306
306
  total += count;
307
307
  }
308
308
  if (total > cfg.maxFileBytes) return null;
309
+ const latest = fs.fstatSync(fd, { bigint: true });
310
+ const current = fs.lstatSync(absolute, { bigint: true });
311
+ if (!latest.isFile() || latest.nlink !== 1n || !sameIdentity(latest, candidate) || !sameIdentity(current, candidate)) return null;
312
+ if (latest.size !== info.size || latest.mtimeNs !== info.mtimeNs || latest.ctimeNs !== info.ctimeNs) return null;
309
313
  const bytes = readBuffer.subarray(0, total);
310
314
  if (bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0)) return null;
311
315
  return bytes.toString("utf8");
@@ -320,12 +324,13 @@ function execute() {
320
324
  let matches = 0;
321
325
  let scanned = 0;
322
326
  let outputChars = 0;
323
- let truncated = files.length >= cfg.maxFiles;
324
- for (const absolute of files) {
327
+ let truncated = cfg.candidatesTruncated === true;
328
+ for (const candidate of files) {
329
+ const absolute = candidate.path;
325
330
  if (matches >= cfg.maxMatches || outputChars >= cfg.maxOutputChars) { truncated = true; break; }
326
331
  const relativeForGlob = path.relative(cfg.isFile ? cfg.cwd : cfg.root, absolute).split(path.sep).join("/");
327
332
  if (cfg.glob && !matchesGlob(cfg.glob, relativeForGlob)) continue;
328
- const text = safeReadText(absolute);
333
+ const text = safeReadText(candidate);
329
334
  if (text === null) continue;
330
335
  scanned++;
331
336
  const fileLines = text.split("\n");
@@ -364,6 +369,30 @@ function normalizeGrepWorkerResult(parsed) {
364
369
  };
365
370
  }
366
371
  function runNodeGrep(pattern, root, isFile, cwd, glob, ignoreCase) {
372
+ const allowSensitive = sensitiveFilesAllowed();
373
+ const discovered = isFile ? [root] : walkFiles(root, 8_001).map((path) => join(root, path));
374
+ const candidatesTruncated = !isFile && discovered.length > 8_000;
375
+ const files = [];
376
+ for (const path of discovered.slice(0, 8_000)) {
377
+ // Broad searches intentionally omit every .env variant, including safe templates. Explicitly searching
378
+ // one safe template remains supported, matching the ripgrep branch.
379
+ const securityBase = basename(path).replace(/:.*$/u, "").replace(/[. ]+$/u, "").toLowerCase();
380
+ if (!allowSensitive && !isFile && (securityBase === ".env" || securityBase.startsWith(".env.")))
381
+ continue;
382
+ if (sensitiveFileError(path, "search"))
383
+ continue;
384
+ try {
385
+ const info = lstatSync(path, { bigint: true });
386
+ // A hard-linked safe alias cannot prove where its other name lives. Reject every multi-link candidate
387
+ // before it can cross into the isolated regex worker.
388
+ if (!info.isFile() || info.nlink !== 1n || info.size > BigInt(MAX_FILE_BYTES))
389
+ continue;
390
+ files.push({ path, dev: String(info.dev), ino: String(info.ino) });
391
+ }
392
+ catch {
393
+ // Raced, unreadable, symlink, FIFO, or device: omit it rather than weakening the read boundary.
394
+ }
395
+ }
367
396
  const payload = JSON.stringify({
368
397
  pattern,
369
398
  root,
@@ -371,13 +400,14 @@ function runNodeGrep(pattern, root, isFile, cwd, glob, ignoreCase) {
371
400
  cwd,
372
401
  glob,
373
402
  ignoreCase,
374
- ignoreDirs: [...IGNORE_DIRS],
403
+ files,
404
+ candidatesTruncated,
375
405
  maxFiles: 8_000,
376
406
  maxFileBytes: MAX_FILE_BYTES,
377
407
  maxMatches: MAX_MATCHES,
378
408
  maxOutputChars: MAX_OUT,
379
409
  });
380
- if (Buffer.byteLength(payload, "utf8") > 64 * 1024) {
410
+ if (Buffer.byteLength(payload, "utf8") > 16 * 1024 * 1024) {
381
411
  return Promise.resolve({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: "grep input exceeds its safety limit" });
382
412
  }
383
413
  return new Promise((resolveResult) => {
@@ -385,7 +415,7 @@ function runNodeGrep(pattern, root, isFile, cwd, glob, ignoreCase) {
385
415
  const args = isBun ? ["-e", NODE_GREP_SOURCE] : ["--max-old-space-size=128", "-e", NODE_GREP_SOURCE];
386
416
  // In a Bun --compile build process.execPath is the hara executable. BUN_BE_BUN makes that embedded
387
417
  // runtime act as the Bun CLI for this isolated eval subprocess instead of recursively launching hara.
388
- const env = isBun ? { ...process.env, BUN_BE_BUN: "1" } : process.env;
418
+ const env = toolSubprocessEnv(process.env, isBun ? { BUN_BE_BUN: "1" } : {});
389
419
  const child = spawn(process.execPath, args, { stdio: ["pipe", "pipe", "pipe"], env });
390
420
  let stdout = "";
391
421
  let stderr = "";
@@ -460,6 +490,9 @@ registerTool({
460
490
  if (typeof input.glob === "string" && input.glob.length > MAX_GLOB_CHARS)
461
491
  return `Error: grep glob exceeds ${MAX_GLOB_CHARS} characters.`;
462
492
  const root = absOf(input.path, ctx.cwd);
493
+ const denied = sensitiveFileError(root, "search");
494
+ if (denied)
495
+ return denied;
463
496
  let isFile = false;
464
497
  try {
465
498
  const info = statSync(root);
@@ -470,19 +503,35 @@ registerTool({
470
503
  catch {
471
504
  return `Error: no such path: ${input.path ?? "."}`;
472
505
  }
473
- const rg = await runRipgrep(pattern, root, isFile, ctx.cwd, typeof input.glob === "string" ? input.glob : undefined, input.ignore_case === true);
474
- if (rg.kind === "timeout")
506
+ const searchArgs = [
507
+ pattern,
508
+ root,
509
+ isFile,
510
+ ctx.cwd,
511
+ typeof input.glob === "string" ? input.glob : undefined,
512
+ input.ignore_case === true,
513
+ ];
514
+ // ripgrep opens pathnames itself, so a cooperating background process could exchange a candidate after
515
+ // discovery and restore it before the post-filter. While the protected-file policy is active, use the
516
+ // hardened worker that binds device+inode and reads through O_NOFOLLOW fds. The faster rg path is safe to
517
+ // opt back into only with the explicit one-process sensitive-file waiver.
518
+ const primary = sensitiveFilesAllowed()
519
+ ? await runRipgrep(...searchArgs)
520
+ : await runNodeGrep(...searchArgs);
521
+ if (primary.kind === "timeout")
475
522
  return `Error: grep exceeded its ${GREP_TIMEOUT_MS}ms safety timeout and was stopped.`;
476
- let { lines, matches, scanned } = rg;
477
- let truncated = rg.truncated;
478
- if (rg.kind === "missing" || rg.kind === "error") {
523
+ if (primary.kind === "invalid")
524
+ return `Error: invalid regex: ${primary.error ?? "invalid pattern"}`;
525
+ let { lines, matches, scanned } = primary;
526
+ let truncated = primary.truncated;
527
+ if (sensitiveFilesAllowed() && (primary.kind === "missing" || primary.kind === "error")) {
479
528
  const fallback = await runNodeGrep(pattern, root, isFile, ctx.cwd, typeof input.glob === "string" ? input.glob : undefined, input.ignore_case === true);
480
529
  if (fallback.kind === "timeout")
481
530
  return `Error: grep exceeded its ${GREP_TIMEOUT_MS}ms safety timeout and was stopped.`;
482
531
  if (fallback.kind === "invalid")
483
532
  return `Error: invalid regex: ${fallback.error ?? "invalid pattern"}`;
484
533
  if (fallback.kind !== "ok") {
485
- const rgDetail = rg.kind === "error" && rg.error ? `; ripgrep: ${rg.error}` : "";
534
+ const rgDetail = primary.kind === "error" && primary.error ? `; ripgrep: ${primary.error}` : "";
486
535
  return `Error: grep regex failed safely: ${fallback.error ?? "unknown worker error"}${rgDetail}`;
487
536
  }
488
537
  lines = fallback.lines;
@@ -490,6 +539,9 @@ registerTool({
490
539
  scanned = fallback.scanned;
491
540
  truncated = fallback.truncated;
492
541
  }
542
+ else if (primary.kind !== "ok") {
543
+ return `Error: grep regex failed safely: ${primary.error ?? "unknown worker error"}`;
544
+ }
493
545
  if (!lines.length)
494
546
  return `No matches for /${input.pattern}/ (scanned ${scanned} files).`;
495
547
  let body = lines.join("\n");
@@ -513,6 +565,9 @@ registerTool({
513
565
  kind: "read",
514
566
  async run(input, ctx) {
515
567
  const root = absOf(input.path, ctx.cwd);
568
+ const denied = sensitiveFileError(root, "search");
569
+ if (denied)
570
+ return denied;
516
571
  const pattern = typeof input.pattern === "string" ? input.pattern : "";
517
572
  if (pattern.length > MAX_GLOB_CHARS)
518
573
  return `Error: glob pattern exceeds ${MAX_GLOB_CHARS} characters.`;
@@ -552,6 +607,9 @@ registerTool({
552
607
  kind: "read",
553
608
  async run(input, ctx) {
554
609
  const dir = absOf(input.path, ctx.cwd);
610
+ const denied = sensitiveFileError(dir, "list");
611
+ if (denied)
612
+ return denied;
555
613
  let entries;
556
614
  try {
557
615
  entries = readdirSync(dir, { withFileTypes: true });
@@ -559,8 +617,9 @@ registerTool({
559
617
  catch (e) {
560
618
  return `Error: cannot list ${input.path ?? "."}: ${e.message}`;
561
619
  }
620
+ const protectedCount = entries.filter((entry) => !entry.isDirectory() && isSensitiveFilePath(join(dir, entry.name))).length;
562
621
  const rows = entries
563
- .filter((e) => !(e.isDirectory() && e.name === ".git"))
622
+ .filter((e) => !(e.isDirectory() && e.name === ".git") && (e.isDirectory() || !isSensitiveFilePath(join(dir, e.name))))
564
623
  .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
565
624
  .map((e) => {
566
625
  if (e.isDirectory())
@@ -574,7 +633,8 @@ registerTool({
574
633
  }
575
634
  return ` ${e.name} ${c_size(size)}`;
576
635
  });
577
- return rows.length ? rows.join("\n") : "(empty directory)";
636
+ const note = protectedCount ? `(${protectedCount} protected file${protectedCount === 1 ? "" : "s"} hidden)` : "";
637
+ return rows.length ? rows.join("\n") + (note ? `\n${note}` : "") : note || "(empty directory)";
578
638
  },
579
639
  });
580
640
  function c_size(n) {
@@ -1,6 +1,6 @@
1
1
  import { registerTool } from "./registry.js";
2
- import { appendFileSync, existsSync, statSync } from "node:fs";
3
2
  import { resolve } from "node:path";
3
+ import { queueOutboundSnapshot } from "../gateway/outbound-files.js";
4
4
  // `send_file` — the agent's ONLY way to push a file/image to the user when running inside `hara gateway`
5
5
  // (Telegram / WeChat). The gateway sets HARA_GATEWAY_OUTBOX to a per-message temp file; this tool appends the
6
6
  // path there, and after the headless run ends the daemon delivers each queued file to the chat via the platform
@@ -26,10 +26,16 @@ if (process.env.HARA_GATEWAY) {
26
26
  if (!outbox)
27
27
  return "send_file only works inside `hara gateway` — there is no chat to send to here.";
28
28
  const p = resolve(ctx.cwd, String(input.path ?? ""));
29
- if (!existsSync(p) || !statSync(p).isFile())
30
- return `No such file: ${p || "(none)"} — create the file first, then call send_file with its absolute path.`;
31
- appendFileSync(outbox, p + "\n");
32
- return `Queued for delivery to the user in this chat: ${p}`;
29
+ try {
30
+ await queueOutboundSnapshot(p, outbox);
31
+ return `Queued a private snapshot for delivery to the user in this chat: ${p}`;
32
+ }
33
+ catch (error) {
34
+ if (error?.code === "ENOENT") {
35
+ return `No such file: ${p || "(none)"} — create the file first, then call send_file with its absolute path.`;
36
+ }
37
+ return error instanceof Error ? error.message : `Unable to queue ${p} for delivery.`;
38
+ }
33
39
  },
34
40
  });
35
41
  }
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.122.0",
3
+ "version": "0.122.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
- "hara": "dist/index.js"
6
+ "hara": "runtime-bootstrap.cjs"
7
7
  },
8
8
  "type": "module",
9
9
  "files": [
10
10
  "dist",
11
11
  "!dist/bin",
12
+ "runtime-bootstrap.cjs",
12
13
  "README.md",
13
14
  "CHANGELOG.md",
14
15
  "SECURITY.md",
@@ -35,16 +36,16 @@
35
36
  "url": "git+https://github.com/hara-cli/hara.git"
36
37
  },
37
38
  "engines": {
38
- "node": ">=20"
39
+ "node": ">=22.12.0"
39
40
  },
40
41
  "scripts": {
41
- "build": "tsc",
42
- "prepare": "tsc",
43
- "dev": "tsx src/index.ts",
44
- "start": "node dist/index.js",
45
- "test": "tsc && node --test test/*.test.mjs",
46
- "build:binary": "tsc && bun scripts/build-binary.ts dist/bin/hara",
47
- "build:binaries": "tsc && bun scripts/build-binary.ts dist/bin/hara-darwin-arm64 bun-darwin-arm64 && bun scripts/build-binary.ts dist/bin/hara-darwin-x64 bun-darwin-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-x64 bun-linux-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-arm64 bun-linux-arm64"
42
+ "build": "tsc && node scripts/normalize-dist-modes.mjs",
43
+ "prepare": "npm run build",
44
+ "dev": "tsx src/cli.ts",
45
+ "start": "node runtime-bootstrap.cjs",
46
+ "test": "npm run build && node --test test/*.test.mjs",
47
+ "build:binary": "npm run build && bun scripts/build-binary.ts dist/bin/hara",
48
+ "build:binaries": "npm run build && bun scripts/build-binary.ts dist/bin/hara-darwin-arm64 bun-darwin-arm64 && bun scripts/build-binary.ts dist/bin/hara-darwin-x64 bun-darwin-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-x64 bun-linux-x64 && bun scripts/build-binary.ts dist/bin/hara-linux-arm64 bun-linux-arm64"
48
49
  },
49
50
  "publishConfig": {
50
51
  "access": "public"
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // This file is the npm/Docker entry point. Keep it dependency-free and parseable by old Node releases so
5
+ // users get an upgrade instruction instead of a SyntaxError from Hara's ESM output or its dependencies.
6
+ var MIN_NODE_MAJOR = 22;
7
+ var MIN_NODE_VERSION = "22.12.0";
8
+
9
+ function supportedNodeVersion(version) {
10
+ var match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
11
+ if (!match) return false;
12
+ var current = [Number(match[1]), Number(match[2]), Number(match[3])];
13
+ var minimum = MIN_NODE_VERSION.split(".").map(Number);
14
+ for (var index = 0; index < minimum.length; index += 1) {
15
+ if (current[index] > minimum[index]) return true;
16
+ if (current[index] < minimum[index]) return false;
17
+ }
18
+ return true;
19
+ }
20
+
21
+ function unsupportedNodeMessage(versions) {
22
+ versions = versions || process.versions;
23
+ if (versions.bun) return null;
24
+
25
+ var version = String(versions.node || "unknown");
26
+ if (supportedNodeVersion(version)) return null;
27
+ var major = Number.parseInt(version, 10);
28
+ var detail = major === MIN_NODE_MAJOR
29
+ ? "This Node.js 22 release is below Hara's supported " + MIN_NODE_VERSION + " floor."
30
+ : "This Node.js release is below Hara's supported " + MIN_NODE_VERSION + " floor.";
31
+ return [
32
+ "Hara requires Node.js " + MIN_NODE_VERSION + " or newer (detected " + version + ").",
33
+ detail,
34
+ "Upgrade with: nvm install " + MIN_NODE_MAJOR + " && nvm use " + MIN_NODE_MAJOR,
35
+ "Or install the standalone Hara binary, which does not require Node.js:",
36
+ " curl -fsSL https://raw.githubusercontent.com/hara-cli/hara/main/install.sh | sh",
37
+ ].join("\n");
38
+ }
39
+
40
+ function failStart(error) {
41
+ var message = error && error.message ? error.message : String(error);
42
+ process.stderr.write("hara: failed to start: " + message + "\n");
43
+ process.exitCode = 1;
44
+ }
45
+
46
+ function main() {
47
+ var runtimeError = unsupportedNodeMessage(process.versions);
48
+ if (runtimeError) {
49
+ process.stderr.write(runtimeError + "\n");
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+
54
+ try {
55
+ // Keeping import() inside a string prevents legacy parsers from seeing unsupported ESM syntax. This
56
+ // branch is reached only on the supported Node floor (or Bun when used as a script runtime).
57
+ var load = Function("specifier", "return import(specifier)");
58
+ var entry = require("url").pathToFileURL(require("path").join(__dirname, "dist", "index.js")).href;
59
+ load(entry).catch(failStart);
60
+ } catch (error) {
61
+ failStart(error);
62
+ }
63
+ }
64
+
65
+ module.exports = {
66
+ MIN_NODE_MAJOR: MIN_NODE_MAJOR,
67
+ MIN_NODE_VERSION: MIN_NODE_VERSION,
68
+ supportedNodeVersion: supportedNodeVersion,
69
+ unsupportedNodeMessage: unsupportedNodeMessage,
70
+ };
71
+
72
+ if (require.main === module) main();