@nanhara/hara 0.53.0 → 0.67.0

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.
@@ -102,21 +102,40 @@ registerTool({
102
102
  }
103
103
  }
104
104
  }
105
- // PHASE 2 — commit all changes + show each diff.
106
- const summary = [];
107
- for (const pl of plans) {
108
- if (pl.type === "delete") {
109
- await unlink(pl.abs);
110
- emitDiff(pl.path, pl.before, "", ctx.ui);
111
- summary.push(`deleted ${pl.path}`);
105
+ // PHASE 2 — commit all changes. Truly all-or-nothing: if any write fails mid-way, roll back the ones
106
+ // already applied (restore updated/deleted files, remove created ones) so the tree is never half-patched.
107
+ const applied = [];
108
+ try {
109
+ for (const pl of plans) {
110
+ if (pl.type === "delete") {
111
+ await unlink(pl.abs);
112
+ }
113
+ else {
114
+ await mkdir(dirname(pl.abs), { recursive: true });
115
+ await writeFile(pl.abs, pl.after, "utf8");
116
+ }
117
+ applied.push(pl);
112
118
  }
113
- else {
114
- await mkdir(dirname(pl.abs), { recursive: true });
115
- await writeFile(pl.abs, pl.after, "utf8");
116
- emitDiff(pl.path, pl.before, pl.after, ctx.ui);
117
- summary.push(`${pl.type === "create" ? "created" : "updated"} ${pl.path}`);
119
+ }
120
+ catch (e) {
121
+ for (const pl of applied.reverse()) {
122
+ try {
123
+ if (pl.type === "create" && !pl.existed)
124
+ await unlink(pl.abs); // remove a file we created
125
+ else
126
+ await writeFile(pl.abs, pl.before, "utf8"); // restore an updated/deleted file's prior content
127
+ }
128
+ catch {
129
+ /* best-effort rollback */
130
+ }
118
131
  }
132
+ return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
119
133
  }
134
+ // All writes succeeded → now show diffs + record the undo snapshot.
135
+ const summary = plans.map((pl) => {
136
+ emitDiff(pl.path, pl.before, pl.type === "delete" ? "" : pl.after, ctx.ui);
137
+ return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
138
+ });
120
139
  recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
121
140
  return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
122
141
  },
package/dist/tools/web.js CHANGED
@@ -1,7 +1,67 @@
1
1
  // web_fetch — fetch an http(s) URL and return readable text (HTML reduced to text). Read-only.
2
- // Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash).
2
+ // Uses Node's global fetch (Node >=20). NOT sandboxed (network egress is in-process, not via bash)
3
+ // so it carries an SSRF guard: private/loopback/link-local targets are refused, re-checked on every
4
+ // redirect hop, and the body is read under a hard byte ceiling.
3
5
  import { registerTool } from "./registry.js";
6
+ import { lookup } from "node:dns/promises";
7
+ import { isIP } from "node:net";
4
8
  const MAX = 60_000;
9
+ /** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
10
+ export function isPrivateIp(ip) {
11
+ const host = ip.replace(/^\[|\]$/g, "");
12
+ if (isIP(host) === 4) {
13
+ const p = host.split(".").map(Number);
14
+ return p[0] === 0 || p[0] === 10 || p[0] === 127 || (p[0] === 172 && p[1] >= 16 && p[1] <= 31) || (p[0] === 192 && p[1] === 168) || (p[0] === 169 && p[1] === 254) || (p[0] === 100 && p[1] >= 64 && p[1] <= 127);
15
+ }
16
+ const l = host.toLowerCase();
17
+ if (l === "::1" || l === "::")
18
+ return true;
19
+ if (l.startsWith("fe80") || l.startsWith("fc") || l.startsWith("fd"))
20
+ return true; // link-local + unique-local
21
+ const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(l); // IPv4-mapped IPv6
22
+ return m ? isPrivateIp(m[1]) : false;
23
+ }
24
+ /** Refuse to fetch a host that is (or resolves to) a private/internal address — defeats metadata-endpoint
25
+ * / localhost SSRF. Throws (caught by the caller) on a blocked or unresolvable host. */
26
+ async function assertPublicHost(hostname) {
27
+ const host = hostname.replace(/^\[|\]$/g, "");
28
+ if (isIP(host)) {
29
+ if (isPrivateIp(host))
30
+ throw new Error(`refusing to fetch ${host} (private/loopback address)`);
31
+ return;
32
+ }
33
+ const addrs = await lookup(host, { all: true });
34
+ for (const a of addrs)
35
+ if (isPrivateIp(a.address))
36
+ throw new Error(`refusing to fetch ${host} — resolves to a private/internal address (${a.address})`);
37
+ }
38
+ /** Read a fetch Response body up to `maxBytes`, then stop (avoids materializing a huge / bomb body). */
39
+ async function readCapped(res, maxBytes) {
40
+ if (!res.body)
41
+ return res.text();
42
+ const reader = res.body.getReader();
43
+ const chunks = [];
44
+ let total = 0;
45
+ for (;;) {
46
+ const { done, value } = await reader.read();
47
+ if (done)
48
+ break;
49
+ if (value) {
50
+ chunks.push(value);
51
+ total += value.length;
52
+ }
53
+ if (total >= maxBytes) {
54
+ try {
55
+ await reader.cancel();
56
+ }
57
+ catch {
58
+ /* already closing */
59
+ }
60
+ break;
61
+ }
62
+ }
63
+ return Buffer.concat(chunks).toString("utf8");
64
+ }
5
65
  /** Strip HTML to a readable-ish plain-text approximation (no dependency). */
6
66
  export function htmlToText(html) {
7
67
  return html
@@ -148,17 +208,30 @@ registerTool({
148
208
  const ctrl = new AbortController();
149
209
  const timer = setTimeout(() => ctrl.abort(), 30_000);
150
210
  try {
151
- const res = await fetch(url, {
152
- signal: ctrl.signal,
153
- redirect: "follow",
154
- headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
155
- });
211
+ // Follow redirects manually so the SSRF guard runs on EVERY hop (a public URL can 30x to 169.254…).
212
+ let current = url;
213
+ let res;
214
+ for (let hop = 0;; hop++) {
215
+ await assertPublicHost(current.hostname);
216
+ res = await fetch(current, {
217
+ signal: ctrl.signal,
218
+ redirect: "manual",
219
+ headers: { "user-agent": "hara-cli", accept: "text/html,text/plain,application/json,*/*" },
220
+ });
221
+ const loc = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
222
+ if (!loc || hop >= 5)
223
+ break;
224
+ const next = new URL(loc, current);
225
+ if (next.protocol !== "http:" && next.protocol !== "https:")
226
+ return "Error: redirect to a non-http(s) URL was blocked.";
227
+ current = next;
228
+ }
156
229
  const ct = res.headers.get("content-type") ?? "";
157
- const raw = await res.text();
230
+ const raw = await readCapped(res, cap * 4); // byte ceiling (HTML→text shrinks; cap*4 leaves headroom)
158
231
  let text = /html/i.test(ct) ? htmlToText(raw) : raw;
159
232
  if (text.length > cap)
160
233
  text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
161
- return `# ${url.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
234
+ return `# ${current.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
162
235
  }
163
236
  catch (e) {
164
237
  return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
package/dist/tui/App.js CHANGED
@@ -62,7 +62,7 @@ function Working() {
62
62
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
63
63
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[n % frames.length] }), _jsx(Text, { dimColor: true, children: ` working ${Math.floor(n / 10)}s · esc to interrupt` })] }));
64
64
  }
65
- export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage }) {
65
+ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim }) {
66
66
  const { exit } = useApp();
67
67
  const [history, setHistory] = useState([]);
68
68
  const [current, setCurrent] = useState([]);
@@ -209,5 +209,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
209
209
  else if (key.tab && key.shift && cycleApproval)
210
210
  setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
211
211
  });
212
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
212
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
213
213
  }
@@ -7,6 +7,7 @@ import { Box, Text, useInput, useStdout } from "ink";
7
7
  import { useMemo, useState } from "react";
8
8
  import { fileCandidates } from "../context/mentions.js";
9
9
  import { imagePathFromPaste } from "../images.js";
10
+ import { vimNormal } from "./vim.js";
10
11
  export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
11
12
  export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
12
13
  const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
@@ -84,7 +85,7 @@ function InputLine({ value, cursor }) {
84
85
  return _jsx(Text, { children: nodes });
85
86
  }
86
87
  /** Top border (session) + prompt line + bottom border (usage) + ModeBar, with an @path popup. */
87
- export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
88
+ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, vim = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
88
89
  const { stdout } = useStdout();
89
90
  const w = width ?? stdout?.columns ?? 80;
90
91
  const [value, setValue] = useState("");
@@ -92,6 +93,9 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
92
93
  const [sel, setSel] = useState(0);
93
94
  const [dismissed, setDismissed] = useState(false);
94
95
  const [images, setImages] = useState([]);
96
+ const [mode, setMode] = useState("insert"); // vim only
97
+ const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
98
+ const [register, setRegister] = useState(""); // vim yank/delete register
95
99
  const set = (v, c) => {
96
100
  setValue(v);
97
101
  setCursor(c);
@@ -116,6 +120,8 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
116
120
  onSubmit?.(text, images.length ? images : undefined);
117
121
  set("", 0);
118
122
  setImages([]);
123
+ setMode("insert"); // a fresh prompt starts in insert
124
+ setPending("");
119
125
  };
120
126
  const mention = activeMention(value, cursor);
121
127
  const candidates = useMemo(() => (isActive && mention && !dismissed ? fileCandidates(cwd, mention.query, 8) : []), [cwd, isActive, dismissed, mention?.query, mention?.start]);
@@ -143,8 +149,36 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
143
149
  return;
144
150
  }
145
151
  if (key.escape) {
146
- if (popupOpen)
152
+ if (popupOpen) {
147
153
  setDismissed(true);
154
+ return;
155
+ }
156
+ if (vim && mode === "insert") {
157
+ setMode("normal");
158
+ setPending("");
159
+ }
160
+ return;
161
+ }
162
+ // vim NORMAL mode: printable keys are commands, not text (Enter/arrows/backspace still navigate/submit)
163
+ if (vim && mode === "normal") {
164
+ if (key.return)
165
+ return submit(value);
166
+ if (key.leftArrow)
167
+ return setCursor((c) => Math.max(0, c - 1));
168
+ if (key.rightArrow)
169
+ return setCursor((c) => Math.min(value.length, c + 1));
170
+ if (key.backspace || key.delete)
171
+ return setCursor((c) => Math.max(0, c - 1));
172
+ if (input && !key.ctrl && !key.meta) {
173
+ const st = vimNormal({ value, cursor, mode, pending, register }, input);
174
+ setValue(st.value);
175
+ setCursor(st.cursor);
176
+ setMode(st.mode);
177
+ setPending(st.pending);
178
+ setRegister(st.register);
179
+ setSel(0);
180
+ setDismissed(false);
181
+ }
148
182
  return;
149
183
  }
150
184
  if (key.return) {
@@ -204,5 +238,5 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
204
238
  set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
205
239
  }
206
240
  }, { isActive });
207
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
241
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsxs(Box, { children: [_jsx(Text, { color: vim ? (mode === "normal" ? "yellow" : "green") : "cyan", children: vim && mode === "normal" ? "◆ " : "› " }), value.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsx(InputLine, { value: value, cursor: cursor }))] }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(BottomBorder, { s: status, width: w }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, _jsx(ModeBar, { approval: status.approval })] }));
208
242
  }
@@ -0,0 +1,115 @@
1
+ const isSpace = (ch) => ch === " " || ch === "\t";
2
+ const clamp = (c, n) => Math.max(0, Math.min(n, c));
3
+ /** Start of the next word (run of non-space), vim `w`. */
4
+ function nextWord(v, c) {
5
+ const n = v.length;
6
+ let i = c;
7
+ if (i < n && !isSpace(v[i]))
8
+ while (i < n && !isSpace(v[i]))
9
+ i++;
10
+ while (i < n && isSpace(v[i]))
11
+ i++;
12
+ return i;
13
+ }
14
+ /** Start of the previous word, vim `b`. */
15
+ function prevWord(v, c) {
16
+ let i = c - 1;
17
+ while (i > 0 && isSpace(v[i]))
18
+ i--;
19
+ while (i > 0 && !isSpace(v[i - 1]))
20
+ i--;
21
+ return Math.max(0, i);
22
+ }
23
+ /** End index of the current/next word, vim `e`. */
24
+ function wordEnd(v, c) {
25
+ const n = v.length;
26
+ let i = c + 1;
27
+ while (i < n && isSpace(v[i]))
28
+ i++;
29
+ while (i < n - 1 && !isSpace(v[i + 1]))
30
+ i++;
31
+ return Math.min(n, i);
32
+ }
33
+ /** Apply one printable key in NORMAL mode, returning the next state. Special keys (Esc/Enter/arrows/
34
+ * backspace) are handled by the caller. Unknown keys are no-ops. */
35
+ export function vimNormal(st, input) {
36
+ const { value, cursor } = st;
37
+ const n = value.length;
38
+ const move = (c) => ({ ...st, cursor: clamp(c, n), pending: "" });
39
+ const toInsert = (v, c, register = st.register) => ({ value: v, cursor: clamp(c, v.length), mode: "insert", pending: "", register });
40
+ const toNormal = (patch) => ({ ...st, pending: "", ...patch });
41
+ // operator-pending: d{motion} / c{motion}
42
+ if (st.pending === "d" || st.pending === "c") {
43
+ const op = st.pending;
44
+ if (input === op)
45
+ return op === "c" ? toInsert("", 0, value) : toNormal({ value: "", cursor: 0, register: value }); // dd / cc
46
+ const ranges = {
47
+ w: [cursor, nextWord(value, cursor)],
48
+ e: [cursor, Math.min(n, wordEnd(value, cursor) + 1)],
49
+ b: [prevWord(value, cursor), cursor],
50
+ $: [cursor, n],
51
+ "0": [0, cursor],
52
+ };
53
+ const r = ranges[op === "c" && input === "w" ? "e" : input]; // cw acts like ce (vim quirk)
54
+ if (!r)
55
+ return toNormal({}); // unknown motion → cancel the operator
56
+ const [from, to] = r;
57
+ const deleted = value.slice(from, to);
58
+ const next = value.slice(0, from) + value.slice(to);
59
+ return op === "c" ? toInsert(next, from, deleted) : toNormal({ value: next, cursor: clamp(from, next.length), register: deleted });
60
+ }
61
+ if (st.pending === "g")
62
+ return input === "g" ? move(0) : toNormal({});
63
+ switch (input) {
64
+ // motions
65
+ case "h":
66
+ return move(cursor - 1);
67
+ case "l":
68
+ return move(cursor + 1);
69
+ case "0":
70
+ return move(0);
71
+ case "$":
72
+ return move(n);
73
+ case "w":
74
+ return move(nextWord(value, cursor));
75
+ case "b":
76
+ return move(prevWord(value, cursor));
77
+ case "e":
78
+ return move(wordEnd(value, cursor));
79
+ case "G":
80
+ return move(n);
81
+ // enter insert mode
82
+ case "i":
83
+ return toInsert(value, cursor);
84
+ case "a":
85
+ return toInsert(value, cursor + 1);
86
+ case "I":
87
+ return toInsert(value, value.length - value.trimStart().length);
88
+ case "A":
89
+ case "o": // single-line: open ≈ append at end
90
+ return toInsert(value, n);
91
+ // edits
92
+ case "x": {
93
+ if (!n)
94
+ return toNormal({});
95
+ return toNormal({ value: value.slice(0, cursor) + value.slice(cursor + 1), cursor: clamp(cursor, n - 1), register: value[cursor] ?? "" });
96
+ }
97
+ case "D":
98
+ return toNormal({ value: value.slice(0, cursor), cursor: clamp(cursor, cursor), register: value.slice(cursor) });
99
+ case "C":
100
+ return toInsert(value.slice(0, cursor), cursor, value.slice(cursor));
101
+ case "p":
102
+ return toNormal({ value: value.slice(0, cursor + 1) + st.register + value.slice(cursor + 1), cursor: cursor + st.register.length });
103
+ case "P":
104
+ return toNormal({ value: value.slice(0, cursor) + st.register + value.slice(cursor), cursor: cursor + Math.max(0, st.register.length - 1) });
105
+ // operators
106
+ case "d":
107
+ return { ...st, pending: "d" };
108
+ case "c":
109
+ return { ...st, pending: "c" };
110
+ case "g":
111
+ return { ...st, pending: "g" };
112
+ default:
113
+ return toNormal({}); // ignore everything else (printable keys don't insert in normal mode)
114
+ }
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.53.0",
3
+ "version": "0.67.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"
@@ -8,8 +8,10 @@
8
8
  "type": "module",
9
9
  "files": [
10
10
  "dist",
11
+ "!dist/bin",
11
12
  "README.md",
12
13
  "CHANGELOG.md",
14
+ "SECURITY.md",
13
15
  "LICENSE",
14
16
  "CLA.md",
15
17
  "plugins"
@@ -40,7 +42,9 @@
40
42
  "prepare": "tsc",
41
43
  "dev": "tsx src/index.ts",
42
44
  "start": "node dist/index.js",
43
- "test": "tsc && node --test test/*.test.mjs"
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"
44
48
  },
45
49
  "publishConfig": {
46
50
  "access": "public"