@nanhara/hara 0.120.0 → 0.121.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/CHANGELOG.md +39 -0
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +24 -2
- package/dist/config.js +31 -5
- package/dist/context/mentions.js +17 -7
- package/dist/desk.js +64 -0
- package/dist/feedback.js +2 -7
- package/dist/fs-read.js +157 -0
- package/dist/fs-write.js +105 -0
- package/dist/index.js +103 -3
- package/dist/security/secrets.js +75 -0
- package/dist/serve/server.js +5 -5
- package/dist/serve/sessions.js +5 -1
- package/dist/session/store.js +58 -16
- package/dist/tools/builtin.js +74 -15
- package/dist/tools/edit.js +10 -2
- package/dist/tools/patch.js +45 -15
- package/dist/tools/registry.js +20 -6
- package/dist/tools/result-limit.js +37 -0
- package/dist/tools/web.js +227 -41
- package/dist/tui/App.js +1 -1
- package/dist/tui/InputBox.js +62 -4
- package/dist/tui/input-history.js +167 -0
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +6 -4
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.121.1 — field-feedback reliability & credential safety
|
|
9
|
+
|
|
10
|
+
- **Terminal resize no longer erases the composer.** Hara clears stale Ink output only before a real
|
|
11
|
+
width change; height-only window drags keep the input box visible and width changes repaint immediately.
|
|
12
|
+
- **Credentials stay out of durable transcripts and generated code.** Session writes deeply redact likely
|
|
13
|
+
keys/tokens/passwords (including tool inputs/results); legacy content is redacted when loaded and migrated
|
|
14
|
+
on its next atomic, private save. Interactive pastes get a warning without echoing the value. The agent must use environment references,
|
|
15
|
+
never literal secrets in code/commands, and may only populate a real-secret `.env` when explicitly asked.
|
|
16
|
+
- **`web_search` has a verified mainland path.** A configured Tavily request races the keyless Bing China
|
|
17
|
+
HTML path; Baidu, Google, and DuckDuckGo remain concurrent secondary fallbacks. Provider timeouts are
|
|
18
|
+
isolated, and JavaScript-only `web_fetch` pages now explain that a browser/API/connector is required
|
|
19
|
+
instead of returning a misleading empty body.
|
|
20
|
+
- **Long setup commands fail usefully.** Package installs automatically become background jobs unless the
|
|
21
|
+
caller explicitly sets timeout/background, while ngrok tunnels preflight local authentication once and
|
|
22
|
+
stop with a focused fix instead of cycling through unrelated tunnel tools.
|
|
23
|
+
- **Persistent desktop sessions re-read configuration.** New/resumed `hara serve` sessions pick up rotated
|
|
24
|
+
`apiKey`/model/baseURL values without a restart; empty env/project values no longer mask valid global
|
|
25
|
+
config, and a 401 says the configured credential expired rather than asking users to paste it into chat.
|
|
26
|
+
|
|
27
|
+
## 0.121.0 — `hara desk` · crash-safe coding/files · bounded interactive I/O
|
|
28
|
+
|
|
29
|
+
- **`hara desk` connects the CLI to the shared coordination desk.** Register an agent, post/list/
|
|
30
|
+
claim/complete/ack/cancel tasks, and persist the desk token with owner-only file permissions. The
|
|
31
|
+
client identifies itself as `hara-cli`, so mixed Codex/Claude Code/hara fleets remain legible.
|
|
32
|
+
- **Coding edits are transaction-safe.** `write_file` uses same-directory atomic replacement;
|
|
33
|
+
multi-file patches validate every destination before writing and roll back already-committed
|
|
34
|
+
files if a later commit fails. Undo snapshots are captured before mutation, failed writes do not
|
|
35
|
+
create false undo history, and symlink/non-regular destinations are rejected consistently.
|
|
36
|
+
- **Large files stay useful without flooding memory or context.** Slice reads stream only the
|
|
37
|
+
requested line window, `@file` mentions have explicit byte/line caps, and tool results are
|
|
38
|
+
bounded centrally (including plugin/MCP results) with clear truncation metadata.
|
|
39
|
+
- **Interactive input is friendlier and bounded.** The TUI composer has shell-style up/down history
|
|
40
|
+
with draft restoration, duplicate suppression, navigation reset after edits, and a fixed maximum
|
|
41
|
+
history size. The ask-mode sentinel is also source-safe on every supported Node runtime.
|
|
42
|
+
- **Faster cold start.** Unicode-width data and the write/edit tool graph are loaded only when the
|
|
43
|
+
active command needs them, reducing startup work for lightweight commands such as `--version`.
|
|
44
|
+
- **Production dependency hardening.** The Lark SDK's Axios transport is pinned to a patched 1.x
|
|
45
|
+
release instead of its vulnerable `~1.13.3` range; the production npm audit is clean at release.
|
|
46
|
+
|
|
8
47
|
## 0.120.0 — `hara feedback`: one door for humans and agents
|
|
9
48
|
|
|
10
49
|
- **`hara feedback "what happened"`** files a structured GitHub issue (repo hara-cli/hara,
|
package/dist/agent/failover.js
CHANGED
|
@@ -38,7 +38,7 @@ export function failoverAction(kind, s) {
|
|
|
38
38
|
export function errorHint(kind) {
|
|
39
39
|
switch (kind) {
|
|
40
40
|
case "auth":
|
|
41
|
-
return " —
|
|
41
|
+
return " — the configured credential was rejected or expired; update ~/.hara/config.json, the active profile, or its environment variable, then retry. Do not paste the key into chat";
|
|
42
42
|
case "rate_limit":
|
|
43
43
|
return " — rate-limited; wait a moment, or set `fallbackModel` to auto-switch";
|
|
44
44
|
case "overloaded":
|
package/dist/agent/loop.js
CHANGED
|
@@ -16,6 +16,7 @@ import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_ST
|
|
|
16
16
|
import { setTurnPhase } from "./phase.js";
|
|
17
17
|
import { recordTouch } from "./touched.js";
|
|
18
18
|
import { resolve as resolvePath } from "node:path";
|
|
19
|
+
import { redactSensitiveText } from "../security/secrets.js";
|
|
19
20
|
/** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
|
|
20
21
|
const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
|
|
21
22
|
/** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
|
|
@@ -65,7 +66,14 @@ re-reading a big file after every edit is the slowest habit an agent can have.
|
|
|
65
66
|
When an attempt FAILS, never repeat it unchanged — read the error, form a hypothesis about the cause, and
|
|
66
67
|
change something (arguments / approach / tool) before trying again. After two failed variants of the same
|
|
67
68
|
approach, stop: re-plan from what you learned, or ask the user, stating concisely what you tried and what
|
|
68
|
-
the errors said. Repeating a failed action hoping for a different result is how sessions die.
|
|
69
|
+
the errors said. Repeating a failed action hoping for a different result is how sessions die.
|
|
70
|
+
Never put a literal password, API key, token, App Secret, Authorization header, or other credential in a
|
|
71
|
+
source file or shell command. Reference an environment variable instead (for example process.env.API_KEY or
|
|
72
|
+
$API_KEY). Keep real values in the user's environment or an approved secret store; do not create/populate a
|
|
73
|
+
.env file with a real secret unless the user explicitly asks and it is excluded from version control. Never
|
|
74
|
+
echo credentials back. Session persistence redacts likely secrets as a last line of defense, but that does
|
|
75
|
+
not make embedding credentials acceptable.
|
|
76
|
+
For broad,
|
|
69
77
|
open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
|
|
70
78
|
independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
|
|
71
79
|
mid-task arrive marked as interjections — triage them (refine current / queue as todo / urgent-switch)
|
|
@@ -88,7 +96,10 @@ site, build output), check they are newer than their sources (compare mtimes or
|
|
|
88
96
|
the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
|
|
89
97
|
When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
|
|
90
98
|
that ordering is authoritative — never skip the middle steps, or you serve stale output and the user sees
|
|
91
|
-
two-day-old work.
|
|
99
|
+
two-day-old work. Package-manager installs auto-start as background jobs when you omit background/timeout;
|
|
100
|
+
poll the returned job until it exits before depending on its packages. Before opening a public tunnel,
|
|
101
|
+
verify that provider's authentication/config once; if it is missing, stop and ask instead of trying a chain
|
|
102
|
+
of unrelated tunnel tools. After completing a task, give a one-line summary.`;
|
|
92
103
|
/** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
|
|
93
104
|
* (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
|
|
94
105
|
function gatewayNote() {
|
|
@@ -118,6 +129,17 @@ export async function runAgent(history, opts) {
|
|
|
118
129
|
let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
|
|
119
130
|
let triedFallback = false;
|
|
120
131
|
let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
|
|
132
|
+
// Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
|
|
133
|
+
// transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
|
|
134
|
+
const latestUser = [...history].reverse().find((m) => m.role === "user");
|
|
135
|
+
const sensitive = latestUser?.role === "user" ? redactSensitiveText(latestUser.content).redactions : [];
|
|
136
|
+
if (sensitive.length && !opts.quiet) {
|
|
137
|
+
const note = "⚠ possible credential detected — the saved session copy will be redacted; prefer passing secrets through environment variables.";
|
|
138
|
+
if (ctx.ui)
|
|
139
|
+
ctx.ui.notice(note);
|
|
140
|
+
else if (stdout.isTTY)
|
|
141
|
+
out(c.yellow(note + "\n"));
|
|
142
|
+
}
|
|
121
143
|
// Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
|
|
122
144
|
// nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
|
|
123
145
|
// non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
|
package/dist/config.js
CHANGED
|
@@ -49,6 +49,28 @@ export function readRawConfig() {
|
|
|
49
49
|
return {};
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
const ROUTING_CONFIG_KEYS = new Set(["provider", "apiKey", "model", "baseURL"]);
|
|
53
|
+
/** Empty routing values are not meaningful credentials/endpoints. Ignore them at each precedence layer so
|
|
54
|
+
* an empty project override (or launcher-exported empty env var) cannot hide a valid global config value. */
|
|
55
|
+
function withoutBlankRoutingValues(input) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [key, value] of Object.entries(input)) {
|
|
58
|
+
if (ROUTING_CONFIG_KEYS.has(key) && typeof value === "string") {
|
|
59
|
+
const trimmed = value.trim();
|
|
60
|
+
if (!trimmed)
|
|
61
|
+
continue;
|
|
62
|
+
out[key] = trimmed;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
out[key] = value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function nonBlankEnv(value) {
|
|
71
|
+
const trimmed = value?.trim();
|
|
72
|
+
return trimmed || undefined;
|
|
73
|
+
}
|
|
52
74
|
/** Nearest project override `.hara/config.json`, searching cwd up to the repo root. */
|
|
53
75
|
function readProjectConfig(cwd) {
|
|
54
76
|
let dir = resolve(cwd);
|
|
@@ -121,12 +143,16 @@ export function loadConfig(opts = {}) {
|
|
|
121
143
|
const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
|
|
122
144
|
const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
|
|
123
145
|
const overlay = overlayName && overlayMap && overlayMap[overlayName] ? overlayMap[overlayName] : {};
|
|
124
|
-
const merged = {
|
|
125
|
-
|
|
146
|
+
const merged = {
|
|
147
|
+
...withoutBlankRoutingValues(globalBase),
|
|
148
|
+
...withoutBlankRoutingValues(project),
|
|
149
|
+
...withoutBlankRoutingValues(overlay),
|
|
150
|
+
};
|
|
151
|
+
const provider = (nonBlankEnv(process.env.HARA_PROVIDER) ?? merged.provider ?? "anthropic");
|
|
126
152
|
const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
|
|
127
|
-
const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
|
|
128
|
-
const baseURL = process.env.HARA_BASE_URL ?? merged.baseURL ?? d.baseURL;
|
|
129
|
-
const apiKey = process.env.HARA_API_KEY ?? process.env[d.envKey] ?? merged.apiKey;
|
|
153
|
+
const model = nonBlankEnv(process.env.HARA_MODEL) ?? merged.model ?? d.model;
|
|
154
|
+
const baseURL = nonBlankEnv(process.env.HARA_BASE_URL) ?? merged.baseURL ?? d.baseURL;
|
|
155
|
+
const apiKey = nonBlankEnv(process.env.HARA_API_KEY) ?? nonBlankEnv(process.env[d.envKey]) ?? merged.apiKey;
|
|
130
156
|
const approval = (process.env.HARA_APPROVAL ?? merged.approval ?? "suggest");
|
|
131
157
|
const sandbox = (process.env.HARA_SANDBOX ?? merged.sandbox ?? "off");
|
|
132
158
|
const theme = (process.env.HARA_THEME ?? merged.theme ?? "dark");
|
package/dist/context/mentions.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// @file mentions — expand `@path` references in user input into appended file contents,
|
|
2
2
|
// and provide fuzzy file candidates for REPL tab-completion.
|
|
3
|
-
import {
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
|
|
6
6
|
import { fuzzyRank } from "../fuzzy.js";
|
|
7
7
|
import { mediaTypeFor } from "../images.js";
|
|
8
|
+
import { readTextPrefixSync } from "../fs-read.js";
|
|
8
9
|
const MAX_FILE = 50_000;
|
|
9
10
|
// @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
|
|
10
11
|
const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
@@ -53,9 +54,10 @@ function expandRef(ref, cwd) {
|
|
|
53
54
|
// don't inline binary image bytes as text — paste with Ctrl+V (or drag the file in) to attach visually
|
|
54
55
|
if (mediaTypeFor(abs))
|
|
55
56
|
return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
57
|
+
const prefix = readTextPrefixSync(abs, MAX_FILE);
|
|
58
|
+
if (prefix.binary)
|
|
59
|
+
return `Referenced \`${ref}\` appears to be binary — it was not inserted into the model context.`;
|
|
60
|
+
const txt = prefix.text + (prefix.truncated ? "\n…[truncated]" : "");
|
|
59
61
|
return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
|
|
60
62
|
}
|
|
61
63
|
if (st.isDirectory()) {
|
|
@@ -73,16 +75,24 @@ function expandRef(ref, cwd) {
|
|
|
73
75
|
const cache = new Map();
|
|
74
76
|
const CACHE_MS = 5000;
|
|
75
77
|
function projectEntries(cwd) {
|
|
76
|
-
const
|
|
78
|
+
const key = resolve(cwd);
|
|
79
|
+
const hit = cache.get(key);
|
|
77
80
|
const now = Date.now();
|
|
78
81
|
if (hit && now - hit.at < CACHE_MS)
|
|
79
82
|
return hit.entries;
|
|
80
|
-
const files = listProjectFiles(
|
|
83
|
+
const files = listProjectFiles(key);
|
|
81
84
|
// files + their directory prefixes (so `@src/` drills into the subtree)
|
|
82
85
|
const entries = [...dirPrefixes(files), ...files];
|
|
83
|
-
cache.set(
|
|
86
|
+
cache.set(key, { at: now, entries });
|
|
84
87
|
return entries;
|
|
85
88
|
}
|
|
89
|
+
/** Invalidate the derived @path inventory after a coding tool creates/deletes a file. */
|
|
90
|
+
export function invalidateFileCandidates(cwd) {
|
|
91
|
+
if (cwd)
|
|
92
|
+
cache.delete(resolve(cwd));
|
|
93
|
+
else
|
|
94
|
+
cache.clear();
|
|
95
|
+
}
|
|
86
96
|
/**
|
|
87
97
|
* File/dir candidates whose path matches `query`, for @ autocomplete.
|
|
88
98
|
* Recurses subdirectories (git-tracked + untracked, or a filesystem walk outside git),
|
package/dist/desk.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// `hara desk` client — how a hara agent connects to the hara-desk coordination server (the closed-
|
|
2
|
+
// source identity registry + task board). This is the OPEN-SOURCE side of the wire: a thin HTTP
|
|
3
|
+
// client + local credential storage. The desk itself (auth, board, L2 ack) lives in hara-desk.
|
|
4
|
+
//
|
|
5
|
+
// Credentials live in ~/.hara/desk.json (0600): the desk URL + this agent's token, written once at
|
|
6
|
+
// `hara desk register`. Every later call reads them back. The token is a bearer secret — never logged.
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
const credsPath = () => join(homedir(), ".hara", "desk.json");
|
|
11
|
+
export function loadCreds() {
|
|
12
|
+
try {
|
|
13
|
+
const c = JSON.parse(readFileSync(credsPath(), "utf8"));
|
|
14
|
+
return c.url && c.token ? c : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function saveCreds(c) {
|
|
21
|
+
const dir = join(homedir(), ".hara");
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
const p = credsPath();
|
|
24
|
+
writeFileSync(p, JSON.stringify(c, null, 2));
|
|
25
|
+
try {
|
|
26
|
+
chmodSync(p, 0o600);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* best-effort on non-posix */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** One HTTP call to the desk. Auth via bearer token when creds are present. Throws on non-2xx with
|
|
33
|
+
* the server's error message (so the CLI can surface a real reason, not "request failed"). */
|
|
34
|
+
export async function deskCall(url, method, path, opts = {}) {
|
|
35
|
+
const r = await fetch(url.replace(/\/$/, "") + path, {
|
|
36
|
+
method,
|
|
37
|
+
headers: {
|
|
38
|
+
"content-type": "application/json",
|
|
39
|
+
...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),
|
|
40
|
+
},
|
|
41
|
+
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
42
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10000),
|
|
43
|
+
});
|
|
44
|
+
const text = await r.text();
|
|
45
|
+
let data = {};
|
|
46
|
+
try {
|
|
47
|
+
data = text ? JSON.parse(text) : {};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
data = { raw: text };
|
|
51
|
+
}
|
|
52
|
+
if (!r.ok)
|
|
53
|
+
throw new Error(data?.error || `desk ${r.status}: ${text.slice(0, 200)}`);
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
56
|
+
/** Register this machine's agent with a desk and persist the returned credentials. `client`
|
|
57
|
+
* identifies the kind of client (defaults "hara-cli"); the desk is client-agnostic, so any agent
|
|
58
|
+
* may pass its own label. */
|
|
59
|
+
export async function registerAgent(url, enrollKey, name, owner, client = "hara-cli") {
|
|
60
|
+
const r = await deskCall(url, "POST", "/register", { body: { enrollKey, name, owner, client } });
|
|
61
|
+
const creds = { url, agentId: r.agentId, owner: r.owner, token: r.token };
|
|
62
|
+
saveCreds(creds);
|
|
63
|
+
return creds;
|
|
64
|
+
}
|
package/dist/feedback.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// humans and agents file through the same door (gh CLI when present, copy-paste text otherwise).
|
|
4
4
|
// The session tail is OFF by default and explicitly opt-in (--session) because issues are public.
|
|
5
5
|
import { platform, release, arch } from "node:os";
|
|
6
|
+
import { redactSensitiveText } from "./security/secrets.js";
|
|
6
7
|
export function collectEnv(version, model) {
|
|
7
8
|
return {
|
|
8
9
|
version,
|
|
@@ -14,13 +15,7 @@ export function collectEnv(version, model) {
|
|
|
14
15
|
/** Strip credential-looking material from text that is about to become a PUBLIC issue.
|
|
15
16
|
* Deliberately aggressive: false positives cost a little readability, false negatives leak keys. */
|
|
16
17
|
export function redact(text) {
|
|
17
|
-
return text
|
|
18
|
-
.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "sk-***")
|
|
19
|
-
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "gh*_***")
|
|
20
|
-
.replace(/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, "AWS-KEY-***")
|
|
21
|
-
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer ***")
|
|
22
|
-
.replace(/\b(api[_-]?key|apikey|token|secret|password|passwd|authorization)(["']?\s*[:=]\s*["']?)[^\s"',;]{6,}/gi, "$1$2***")
|
|
23
|
-
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "JWT-***");
|
|
18
|
+
return redactSensitiveText(text).text;
|
|
24
19
|
}
|
|
25
20
|
/** The structured issue body — same shape as .github/ISSUE_TEMPLATE/bug_report.yml so hand-filed
|
|
26
21
|
* and command-filed issues read identically to the triage side. */
|
package/dist/fs-read.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Bounded streaming line reader for files too large to load as one string. It stores only the requested
|
|
2
|
+
// window and a capped prefix of each line, so huge logs/JSONL and minified one-line files stay safe.
|
|
3
|
+
import { closeSync, createReadStream, fstatSync, openSync, readSync } from "node:fs";
|
|
4
|
+
export class BinaryFileError extends Error {
|
|
5
|
+
constructor(path) {
|
|
6
|
+
super(`${path} appears to be binary (NUL byte detected)`);
|
|
7
|
+
this.name = "BinaryFileError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const DEFAULT_LINE_CAP = 2000;
|
|
11
|
+
const DEFAULT_MAX_SCAN = 64 * 1024 * 1024;
|
|
12
|
+
/** Read only enough bytes to produce a bounded UTF-8 prefix (used by synchronous @file expansion). */
|
|
13
|
+
export function readTextPrefixSync(path, maxChars) {
|
|
14
|
+
const chars = Math.max(0, Math.floor(maxChars));
|
|
15
|
+
const fd = openSync(path, "r");
|
|
16
|
+
try {
|
|
17
|
+
const size = fstatSync(fd).size;
|
|
18
|
+
// Four bytes per Unicode scalar plus a small boundary cushion guarantees enough decoded input for
|
|
19
|
+
// `chars` without allocating the entire file. readSync can short-read, so fill in a loop.
|
|
20
|
+
const byteLimit = Math.min(size, chars * 4 + 4);
|
|
21
|
+
const buffer = Buffer.allocUnsafe(byteLimit);
|
|
22
|
+
let read = 0;
|
|
23
|
+
while (read < byteLimit) {
|
|
24
|
+
const n = readSync(fd, buffer, read, byteLimit - read, read);
|
|
25
|
+
if (n === 0)
|
|
26
|
+
break;
|
|
27
|
+
read += n;
|
|
28
|
+
}
|
|
29
|
+
const bytes = buffer.subarray(0, read);
|
|
30
|
+
const binary = bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
|
|
31
|
+
const decoded = binary ? "" : bytes.toString("utf8");
|
|
32
|
+
return {
|
|
33
|
+
text: decoded.slice(0, chars),
|
|
34
|
+
truncated: size > read || decoded.length > chars,
|
|
35
|
+
binary,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Render a line slice without retaining the entire file. Total line count is shown only when EOF is reached. */
|
|
43
|
+
export async function streamFileSlice(path, offset = 1, limit = 300, options = {}) {
|
|
44
|
+
const start = Math.max(1, Math.floor(offset));
|
|
45
|
+
const want = Math.max(1, Math.floor(limit));
|
|
46
|
+
const requestedEnd = start + want - 1;
|
|
47
|
+
const lineCap = Math.max(1, Math.floor(options.lineCap ?? DEFAULT_LINE_CAP));
|
|
48
|
+
const maxScan = Math.max(lineCap, Math.floor(options.maxScanChars ?? DEFAULT_MAX_SCAN));
|
|
49
|
+
const rendered = [];
|
|
50
|
+
let lineNo = 1;
|
|
51
|
+
let linePrefix = "";
|
|
52
|
+
let lineChars = 0;
|
|
53
|
+
let lineEndsWithCr = false;
|
|
54
|
+
let sawData = false;
|
|
55
|
+
let endedWithNewline = false;
|
|
56
|
+
let scanned = 0;
|
|
57
|
+
let hasMore = false;
|
|
58
|
+
let scanLimited = false;
|
|
59
|
+
let stoppedEarly = false;
|
|
60
|
+
const append = (part) => {
|
|
61
|
+
if (!part.length)
|
|
62
|
+
return;
|
|
63
|
+
sawData = true;
|
|
64
|
+
endedWithNewline = false;
|
|
65
|
+
if (lineNo > requestedEnd) {
|
|
66
|
+
hasMore = true;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (lineNo >= start) {
|
|
70
|
+
lineChars += part.length;
|
|
71
|
+
if (linePrefix.length < lineCap)
|
|
72
|
+
linePrefix += part.slice(0, lineCap - linePrefix.length);
|
|
73
|
+
lineEndsWithCr = part.endsWith("\r");
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const finishLine = (partial = false) => {
|
|
77
|
+
const current = lineNo;
|
|
78
|
+
if (current >= start && current <= requestedEnd) {
|
|
79
|
+
let chars = lineChars;
|
|
80
|
+
let prefix = linePrefix;
|
|
81
|
+
if (!partial && lineEndsWithCr) {
|
|
82
|
+
chars = Math.max(0, chars - 1);
|
|
83
|
+
if (prefix.endsWith("\r"))
|
|
84
|
+
prefix = prefix.slice(0, -1);
|
|
85
|
+
}
|
|
86
|
+
const omitted = Math.max(0, chars - prefix.length);
|
|
87
|
+
const tail = partial
|
|
88
|
+
? `…[line continues; scan stopped after ${scanned} chars]`
|
|
89
|
+
: omitted
|
|
90
|
+
? `…[+${omitted} chars]`
|
|
91
|
+
: "";
|
|
92
|
+
rendered.push(`${String(current).padStart(6)}\t${prefix}${tail}`);
|
|
93
|
+
}
|
|
94
|
+
lineNo++;
|
|
95
|
+
linePrefix = "";
|
|
96
|
+
lineChars = 0;
|
|
97
|
+
lineEndsWithCr = false;
|
|
98
|
+
if (current > requestedEnd)
|
|
99
|
+
hasMore = true;
|
|
100
|
+
};
|
|
101
|
+
const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 64 * 1024 });
|
|
102
|
+
try {
|
|
103
|
+
for await (const raw of stream) {
|
|
104
|
+
const chunk = String(raw);
|
|
105
|
+
if (chunk.length)
|
|
106
|
+
sawData = true;
|
|
107
|
+
if (scanned < 4096 && chunk.slice(0, 4096 - scanned).includes("\0"))
|
|
108
|
+
throw new BinaryFileError(path);
|
|
109
|
+
scanned += chunk.length;
|
|
110
|
+
let at = 0;
|
|
111
|
+
while (at < chunk.length) {
|
|
112
|
+
const newline = chunk.indexOf("\n", at);
|
|
113
|
+
if (newline < 0) {
|
|
114
|
+
append(chunk.slice(at));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
append(chunk.slice(at, newline));
|
|
118
|
+
finishLine();
|
|
119
|
+
endedWithNewline = true;
|
|
120
|
+
at = newline + 1;
|
|
121
|
+
if (hasMore)
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
if (hasMore) {
|
|
125
|
+
stoppedEarly = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (scanned >= maxScan) {
|
|
129
|
+
scanLimited = true;
|
|
130
|
+
stoppedEarly = true;
|
|
131
|
+
if (lineNo >= start && lineNo <= requestedEnd)
|
|
132
|
+
finishLine(true);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
stream.destroy();
|
|
139
|
+
}
|
|
140
|
+
if (!stoppedEarly) {
|
|
141
|
+
// A trailing newline creates no phantom line, matching String#split + trailing-empty removal.
|
|
142
|
+
if (!endedWithNewline || !sawData)
|
|
143
|
+
finishLine();
|
|
144
|
+
const total = lineNo - 1;
|
|
145
|
+
if (start > total)
|
|
146
|
+
return `(file has ${total} lines — offset ${start} is past the end)`;
|
|
147
|
+
const end = start + rendered.length - 1;
|
|
148
|
+
const sliced = start > 1 || end < total;
|
|
149
|
+
const head = sliced ? `(lines ${start}–${end} of ${total}${end < total ? ` — continue with offset:${end + 1}` : ""})\n` : "";
|
|
150
|
+
return head + rendered.join("\n");
|
|
151
|
+
}
|
|
152
|
+
const end = start + Math.max(0, rendered.length - 1);
|
|
153
|
+
if (scanLimited) {
|
|
154
|
+
return `(large file scan stopped after ${scanned} chars — use grep or bash byte-range tools for a narrower target)\n${rendered.join("\n")}`;
|
|
155
|
+
}
|
|
156
|
+
return `(lines ${start}–${end}; more lines follow — continue with offset:${end + 1})\n${rendered.join("\n")}`;
|
|
157
|
+
}
|
package/dist/fs-write.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Crash-safe UTF-8 writes for coding tools. Content is staged beside the destination, fsynced, then
|
|
2
|
+
// renamed into place so a killed process never leaves a half-written source file.
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { link, lstat, mkdir, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
5
|
+
export class FileChangedError extends Error {
|
|
6
|
+
code = "HARA_FILE_CHANGED";
|
|
7
|
+
constructor(path) {
|
|
8
|
+
super(`File changed while the edit was being prepared: ${path}. Re-read it and retry the edit.`);
|
|
9
|
+
this.name = "FileChangedError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
let tempSequence = 0;
|
|
13
|
+
async function writeTarget(path) {
|
|
14
|
+
try {
|
|
15
|
+
const info = await lstat(path);
|
|
16
|
+
// Replacing a symlink would silently break it. Stage beside the real target instead so edits retain
|
|
17
|
+
// the link (common in dotfile repos and generated workspace layouts).
|
|
18
|
+
if (info.isSymbolicLink())
|
|
19
|
+
return await realpath(path);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error?.code !== "ENOENT")
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
async function syncDirectory(path) {
|
|
28
|
+
// Directory fsync makes the rename durable across a power loss on POSIX. Some filesystems/platforms
|
|
29
|
+
// reject opening directories, so durability degrades gracefully after the file itself was synced.
|
|
30
|
+
try {
|
|
31
|
+
const handle = await open(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
await handle.sync();
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await handle.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* best effort */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Atomically replace/create a UTF-8 file, optionally refusing to overwrite a newer disk version. */
|
|
44
|
+
export async function atomicWriteText(path, content, options = {}) {
|
|
45
|
+
const target = await writeTarget(path);
|
|
46
|
+
const dir = dirname(target);
|
|
47
|
+
await mkdir(dir, { recursive: true });
|
|
48
|
+
let mode = 0o666;
|
|
49
|
+
try {
|
|
50
|
+
mode = (await stat(target)).mode & 0o777;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error?.code !== "ENOENT")
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
// Keep the staging basename fixed-size: prefixing the destination's full basename would make a
|
|
57
|
+
// perfectly valid near-NAME_MAX file impossible to edit because the temporary name becomes longer.
|
|
58
|
+
const temp = join(dir, `.hara-${process.pid}-${Date.now().toString(36)}-${tempSequence++}.tmp`);
|
|
59
|
+
let staged = false;
|
|
60
|
+
try {
|
|
61
|
+
const handle = await open(temp, "wx", mode);
|
|
62
|
+
staged = true;
|
|
63
|
+
try {
|
|
64
|
+
await handle.writeFile(content, "utf8");
|
|
65
|
+
await handle.sync();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await handle.close();
|
|
69
|
+
}
|
|
70
|
+
if (options.expected === null) {
|
|
71
|
+
// link(2) is an atomic create-if-absent operation. A plain rename would overwrite a file that
|
|
72
|
+
// appeared after validation, defeating create's no-clobber contract.
|
|
73
|
+
try {
|
|
74
|
+
await link(temp, target);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error?.code === "EEXIST")
|
|
78
|
+
throw new FileChangedError(path);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
await unlink(temp);
|
|
82
|
+
staged = false;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (typeof options.expected === "string") {
|
|
86
|
+
let current;
|
|
87
|
+
try {
|
|
88
|
+
current = await readFile(target, "utf8");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new FileChangedError(path);
|
|
92
|
+
}
|
|
93
|
+
if (current !== options.expected)
|
|
94
|
+
throw new FileChangedError(path);
|
|
95
|
+
}
|
|
96
|
+
await rename(temp, target);
|
|
97
|
+
staged = false;
|
|
98
|
+
}
|
|
99
|
+
await syncDirectory(dir);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (staged)
|
|
103
|
+
await unlink(temp).catch(() => { });
|
|
104
|
+
}
|
|
105
|
+
}
|