@niroai/niro 0.16.0 → 0.18.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.
- package/CHANGELOG.md +149 -0
- package/README.md +123 -302
- package/bin/niro.js +12 -1
- package/docs/agent-antigravity.md +37 -0
- package/docs/agent-claude-code.md +48 -0
- package/docs/agent-claude-desktop.md +47 -0
- package/docs/agent-cline.md +42 -0
- package/docs/agent-codex.md +45 -0
- package/docs/agent-copilot-cli.md +42 -0
- package/docs/agent-cursor.md +44 -0
- package/docs/agent-factory.md +42 -0
- package/docs/agent-gemini-cli.md +42 -0
- package/docs/agent-goose.md +42 -0
- package/docs/agent-kiro.md +37 -0
- package/docs/agent-opencode.md +37 -0
- package/docs/agent-vscode.md +45 -0
- package/docs/agent-windsurf.md +38 -0
- package/docs/answer-shapes.md +69 -0
- package/docs/builds-and-updates.md +68 -0
- package/docs/cloud-README.md +372 -0
- package/docs/configuring-a-folder.md +51 -0
- package/docs/connect-ci.md +24 -0
- package/docs/connect-overview.md +20 -0
- package/docs/connect-review-bots.md +17 -0
- package/docs/connect-team-server.md +12 -0
- package/docs/connect-terminal.md +49 -0
- package/docs/connect-web-agents.md +9 -0
- package/docs/connect-your-ai-assistant.md +59 -175
- package/docs/coverage-audit-and-consent.md +51 -0
- package/docs/evaluate-what-niro-did.md +271 -0
- package/docs/faq-assistants-and-mcp.md +122 -0
- package/docs/faq-feedback-and-receipts.md +33 -0
- package/docs/faq-indexing.md +127 -0
- package/docs/faq-install-and-setup.md +103 -0
- package/docs/faq-licence-and-plans.md +86 -0
- package/docs/faq-login-and-account.md +69 -0
- package/docs/faq-offline.md +34 -0
- package/docs/faq-performance.md +33 -0
- package/docs/faq-privacy-and-security.md +113 -0
- package/docs/faq-uninstall.md +47 -0
- package/docs/faq-updates.md +59 -0
- package/docs/faq-worktrees-and-branches.md +59 -0
- package/docs/faq-wrong-or-missing-answers.md +275 -0
- package/docs/hosted-only.md +33 -0
- package/docs/how-the-graph-works.md +47 -0
- package/docs/index.md +157 -0
- package/docs/licence-and-plans.md +72 -0
- package/docs/lifecycle-debug.md +48 -0
- package/docs/lifecycle-onboarding.md +32 -0
- package/docs/lifecycle-overview.md +77 -0
- package/docs/lifecycle-plan-and-design.md +44 -0
- package/docs/lifecycle-review.md +30 -0
- package/docs/lifecycle-security-and-privacy.md +35 -0
- package/docs/lifecycle-test.md +28 -0
- package/docs/lifecycle-write-code.md +35 -0
- package/docs/llms-full.txt +8015 -0
- package/docs/llms.txt +126 -0
- package/docs/playbook-add-a-feature.md +108 -0
- package/docs/playbook-change-safely.md +93 -0
- package/docs/playbook-data-and-events.md +107 -0
- package/docs/playbook-debug-after-a-change.md +96 -0
- package/docs/playbook-debug-config.md +95 -0
- package/docs/playbook-debug-exception.md +124 -0
- package/docs/playbook-debug-failing-service-call.md +119 -0
- package/docs/playbook-debug-slow-path.md +80 -0
- package/docs/playbook-debug-wrong-value.md +137 -0
- package/docs/playbook-onboard.md +95 -0
- package/docs/playbook-review-a-change.md +72 -0
- package/docs/playbook-test-a-change.md +73 -0
- package/docs/reference-cli.md +113 -0
- package/docs/reference-config-files.md +42 -0
- package/docs/reference-engine-commands.md +56 -0
- package/docs/reference-environment-variables.md +89 -0
- package/docs/reference-exit-codes.md +67 -0
- package/docs/reference-limits.md +75 -0
- package/docs/reference-mcp-tools.md +326 -0
- package/docs/reference-messages.md +180 -0
- package/docs/secrets-and-masking.md +48 -0
- package/docs/supported-c.md +130 -0
- package/docs/supported-cpp.md +157 -0
- package/docs/supported-csharp.md +211 -0
- package/docs/supported-go.md +258 -0
- package/docs/supported-java.md +226 -0
- package/docs/supported-javascript.md +223 -0
- package/docs/supported-kotlin.md +124 -0
- package/docs/supported-overview.md +89 -0
- package/docs/supported-php.md +157 -0
- package/docs/supported-python.md +224 -0
- package/docs/supported-ruby.md +162 -0
- package/docs/supported-rust.md +150 -0
- package/docs/supported-swift.md +133 -0
- package/docs/supported-typescript.md +220 -0
- package/docs/tokens-speed-and-accuracy.md +99 -0
- package/docs/troubleshooting.md +42 -0
- package/docs/uninstall.md +74 -0
- package/docs/what-leaves-your-machine.md +71 -0
- package/docs/where-things-are-stored.md +59 -0
- package/docs/worktrees-and-branches.md +55 -0
- package/package.json +5 -3
- package/src/commands/add-repo.js +5 -0
- package/src/commands/auditSend.js +67 -0
- package/src/commands/build.js +6 -0
- package/src/commands/docsSend.js +73 -0
- package/src/commands/engine.js +120 -0
- package/src/commands/engineRoutes.js +105 -0
- package/src/commands/guide.js +254 -0
- package/src/commands/init.js +16 -7
- package/src/commands/initLocal.js +96 -0
- package/src/commands/journalSend.js +47 -0
- package/src/commands/login.js +89 -12
- package/src/commands/mcp.js +22 -6
- package/src/commands/plan.js +99 -0
- package/src/commands/repoPush.js +33 -0
- package/src/commands/requestIntegration.js +38 -0
- package/src/commands/share.js +56 -0
- package/src/commands/start.js +451 -371
- package/src/commands/status.js +57 -0
- package/src/commands/takeover.js +6 -0
- package/src/commands/uninstall.js +180 -0
- package/src/commands/upgrade.js +38 -8
- package/src/commands/watch.js +420 -6
- package/src/index.js +174 -3
- package/src/lib/agentApi.js +32 -1
- package/src/lib/api.js +20 -6
- package/src/lib/auditUpload.js +293 -0
- package/src/lib/branchDrift.js +149 -0
- package/src/lib/choose.js +116 -0
- package/src/lib/cloudGuard.js +74 -0
- package/src/lib/credentials.js +2 -2
- package/src/lib/docsMarked.js +55 -0
- package/src/lib/docsState.js +213 -0
- package/src/lib/docsUpload.js +481 -0
- package/src/lib/engine.js +619 -0
- package/src/lib/folderSync.js +2 -1
- package/src/lib/graphCheck.js +40 -0
- package/src/lib/guide.js +293 -0
- package/src/lib/guideSearch.js +252 -0
- package/src/lib/integrationRequest.js +89 -0
- package/src/lib/journalUpload.js +147 -0
- package/src/lib/languages.js +129 -0
- package/src/lib/lease.js +375 -0
- package/src/lib/localBuild.js +285 -0
- package/src/lib/localEngine.js +210 -0
- package/src/lib/localOnboard.js +124 -0
- package/src/lib/localWorktrees.js +243 -0
- package/src/lib/machineCheck.js +58 -0
- package/src/lib/mcpClients.js +10 -1
- package/src/lib/migrationNotice.js +118 -0
- package/src/lib/modelFetch.js +153 -0
- package/src/lib/nestedOutput.js +23 -9
- package/src/lib/onboardingLog.js +34 -0
- package/src/lib/planUsage.js +165 -0
- package/src/lib/progressBar.js +46 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/redact.js +155 -0
- package/src/lib/release.js +33 -0
- package/src/lib/repoPush.js +495 -0
- package/src/lib/secret.js +1 -1
- package/src/lib/service.js +372 -63
- package/src/lib/shareCode.js +367 -0
- package/src/lib/shareExclusions.js +114 -0
- package/src/lib/shareFiles.js +129 -0
- package/src/lib/spinner.js +9 -5
- package/src/lib/watchDaemon.js +33 -10
- package/src/lib/watchState.js +12 -1
- package/src/lib/windowsPaths.js +67 -0
- package/src/mcp/lineCheck.js +382 -0
- package/src/mcp/lineName.js +92 -0
- package/src/mcp/localBackend.js +751 -0
- package/src/mcp/localMode.js +383 -0
- package/src/mcp/localSkill.js +189 -0
- package/src/mcp/server.js +610 -79
- package/src/mcp/setup.js +519 -131
- package/src/mcp/shellSearchHook.js +509 -0
- package/src/mcp/teardown.js +173 -91
- package/src/mcp/usingNiro.js +39 -0
- package/docs/working-on-branches.md +0 -162
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New git worktrees of repositories the local engine holds, so the watcher can build them.
|
|
3
|
+
*
|
|
4
|
+
* `git worktree add` makes a new folder on a new branch. niro-local indexes it the moment somebody
|
|
5
|
+
* runs `niro-local build` there (it makes a temporary copy of the project that holds the repository),
|
|
6
|
+
* but nothing on the machine ran that build: the watcher only knew the folders niro-local already
|
|
7
|
+
* listed, so a new worktree stayed unindexed until a person noticed (release finding 6).
|
|
8
|
+
*
|
|
9
|
+
* **Which worktrees count as new.** Those that appear after the watcher first saw the repository.
|
|
10
|
+
* The first time a repository is seen, every worktree it has is written down and none is built:
|
|
11
|
+
* switching this on must not start a build of every old worktree on the machine, some of which
|
|
12
|
+
* nobody wants indexed. What has been seen is kept in `~/.niro/local-worktrees.json` (with a copy
|
|
13
|
+
* one round old beside it, `.bak`), so a worktree made while the watcher was stopped is still built
|
|
14
|
+
* when it starts again. A build that fails is started again twice, minutes apart, then given up.
|
|
15
|
+
*
|
|
16
|
+
* **The engine decides what happens.** This only finds the folder. The build is the same
|
|
17
|
+
* `niro-local build --path <folder>` a person would type, so a refusal (another checkout already
|
|
18
|
+
* holds that branch, say) is the engine's own words.
|
|
19
|
+
*
|
|
20
|
+
* Read with git's own command, `git worktree list --porcelain`, rather than by reading files under
|
|
21
|
+
* `.git`: the layout of those files is git's business and differs for bare repositories.
|
|
22
|
+
*/
|
|
23
|
+
const fs = require("fs");
|
|
24
|
+
const os = require("os");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
const { execFile } = require("child_process");
|
|
27
|
+
const windowsPaths = require("./windowsPaths");
|
|
28
|
+
|
|
29
|
+
const STATE_FILE = () => path.join(os.homedir(), ".niro", "local-worktrees.json");
|
|
30
|
+
|
|
31
|
+
/** Run git in `cwd`, resolving its standard output, or null when git cannot answer. Never throws. */
|
|
32
|
+
function git(args, cwd) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const program = windowsPaths.gitProgram();
|
|
35
|
+
if (!program) return resolve(null);
|
|
36
|
+
execFile(program, args, { cwd, encoding: "utf8", timeout: 5000 }, (err, stdout) => {
|
|
37
|
+
resolve(err ? null : String(stdout));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The repository this folder is in: `{ common, top }`, git's shared directory (one per repository,
|
|
44
|
+
* whichever worktree asks) and the top of this folder's own worktree. Null outside git.
|
|
45
|
+
*/
|
|
46
|
+
async function repositoryOf(folder) {
|
|
47
|
+
// The common directory may come back relative to `folder` (git before 2.31 has no
|
|
48
|
+
// --path-format), so it is resolved against it; the top is always absolute.
|
|
49
|
+
const out = await git(["rev-parse", "--git-common-dir", "--show-toplevel"], folder);
|
|
50
|
+
if (!out) return null;
|
|
51
|
+
const [common, top] = out.trim().split("\n");
|
|
52
|
+
return common && top ? { common: path.resolve(folder, common), top: path.resolve(top) } : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The worktrees of the repository `folder` is in, from `git worktree list --porcelain`: a path for
|
|
57
|
+
* each one that is on a branch. Detached and bare entries are left out, since a build there has no
|
|
58
|
+
* branch to make a copy for.
|
|
59
|
+
*/
|
|
60
|
+
async function worktreesOf(folder) {
|
|
61
|
+
const out = await git(["worktree", "list", "--porcelain"], folder);
|
|
62
|
+
if (!out) return [];
|
|
63
|
+
const found = [];
|
|
64
|
+
for (const block of out.split(/\n\s*\n/)) {
|
|
65
|
+
const lines = block.split("\n");
|
|
66
|
+
const at = lines.find((l) => l.startsWith("worktree "));
|
|
67
|
+
if (!at || !lines.some((l) => l.startsWith("branch "))) continue;
|
|
68
|
+
found.push(path.resolve(at.slice("worktree ".length)));
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** How many times a new worktree's build is started before giving up, and the waits between tries. */
|
|
74
|
+
const MAX_ATTEMPTS = 3;
|
|
75
|
+
const RETRY_DELAYS_MS = [2 * 60_000, 10 * 60_000];
|
|
76
|
+
|
|
77
|
+
/** One repository's record: worktree folders already dealt with, and builds still being retried. */
|
|
78
|
+
function repositoryRecord(value) {
|
|
79
|
+
if (Array.isArray(value)) return { seen: value, retry: {} }; // the first shape (439af22)
|
|
80
|
+
if (value && typeof value === "object" && Array.isArray(value.seen)) {
|
|
81
|
+
return { seen: value.seen, retry: value.retry && typeof value.retry === "object" ? value.retry : {} };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readState(file) {
|
|
87
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
88
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
|
89
|
+
const repositories = parsed.version === 2 ? parsed.repositories || {} : parsed;
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [common, value] of Object.entries(repositories)) {
|
|
92
|
+
const record = repositoryRecord(value);
|
|
93
|
+
if (record) out[common] = record;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The state, and how it was got: `fresh` (no file yet), `file`, `backup` (the file could not be
|
|
100
|
+
* read, so the copy kept from the round before was used), or `lost` (neither could be read).
|
|
101
|
+
*
|
|
102
|
+
* A file that cannot be read used to count as empty, so every repository was "seen for the first
|
|
103
|
+
* time" and a worktree made in that window was recorded and never built (VP-rel-watch N3). The
|
|
104
|
+
* copy from the round before is at most one round old, so the window is still worked out.
|
|
105
|
+
*/
|
|
106
|
+
function loadState(file = STATE_FILE()) {
|
|
107
|
+
try {
|
|
108
|
+
return { state: readState(file), from: "file" };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err && err.code === "ENOENT") return { state: {}, from: "fresh" };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
return { state: readState(`${file}.bak`), from: "backup" };
|
|
114
|
+
} catch {
|
|
115
|
+
return { state: {}, from: "lost" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Write the state for this round. The file as it was (when it could be read) is kept as `.bak`
|
|
121
|
+
* first, so a file damaged later still has a readable copy one round old beside it.
|
|
122
|
+
*/
|
|
123
|
+
function saveState(state, { file = STATE_FILE(), keepBackup = true } = {}) {
|
|
124
|
+
try {
|
|
125
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
126
|
+
if (keepBackup) {
|
|
127
|
+
try {
|
|
128
|
+
fs.copyFileSync(file, `${file}.bak`);
|
|
129
|
+
} catch {
|
|
130
|
+
// No file yet: nothing to keep.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
134
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: 2, repositories: state }, null, 2) + "\n", { mode: 0o600 });
|
|
135
|
+
try {
|
|
136
|
+
fs.renameSync(tmp, file);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
fs.rmSync(tmp, { force: true });
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// Not fatal: the caller keeps the state in memory while the file cannot be written.
|
|
144
|
+
return (err && err.code) || (err && err.message) || "unknown error";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The last state worked out for each file path, kept while that file cannot be written: then the
|
|
150
|
+
* file on disk is old or unreadable, and reading it again would forget what was seen and how often
|
|
151
|
+
* a build was tried, so a refused worktree would be offered every round and never given up
|
|
152
|
+
* (VP-rel-watch-2 note 3). One entry per file path (one in practice), dropped once it is written.
|
|
153
|
+
*/
|
|
154
|
+
const unsaved = new Map();
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Folders to build: for each repository holding one of `held` (the folders niro-local lists), the
|
|
158
|
+
* same place inside each of its worktrees that are new since the repository was first seen and that
|
|
159
|
+
* niro-local does not hold yet. `held` folders inside one service of a monorepo map to that service's
|
|
160
|
+
* folder in the new worktree, which is where `niro-local build` would be typed.
|
|
161
|
+
*
|
|
162
|
+
* A folder is returned, and its build started by the caller, at most `MAX_ATTEMPTS` times: again
|
|
163
|
+
* after 2 minutes and after 10 more while niro-local still does not hold it (a build that failed, or
|
|
164
|
+
* one refused for a reason that has since gone), then given up with one line. It counts as dealt with
|
|
165
|
+
* once niro-local holds it. Entries for folders gone from disk, and for repositories no held folder is
|
|
166
|
+
* in any more, are dropped, so the file does not grow for ever.
|
|
167
|
+
*/
|
|
168
|
+
async function newWorktreeFolders(held, { file = STATE_FILE(), repositoryOf: repoOf = repositoryOf,
|
|
169
|
+
worktreesOf: listOf = worktreesOf, log = () => {}, now = Date.now() } = {}) {
|
|
170
|
+
const heldSet = new Set(held.map((f) => path.resolve(f)));
|
|
171
|
+
const kept = unsaved.get(file);
|
|
172
|
+
const { state: before, from } = kept ? { state: kept, from: "memory" } : loadState(file);
|
|
173
|
+
const next = {};
|
|
174
|
+
const wanted = [];
|
|
175
|
+
const byRepository = new Map();
|
|
176
|
+
for (const folder of heldSet) {
|
|
177
|
+
const repo = await repoOf(folder);
|
|
178
|
+
if (!repo) continue;
|
|
179
|
+
const relative = path.relative(repo.top, folder);
|
|
180
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) continue;
|
|
181
|
+
if (!byRepository.has(repo.common)) byRepository.set(repo.common, { folder, relatives: new Set() });
|
|
182
|
+
byRepository.get(repo.common).relatives.add(relative);
|
|
183
|
+
}
|
|
184
|
+
for (const [common, { folder, relatives }] of byRepository) {
|
|
185
|
+
const old = before[common];
|
|
186
|
+
const firstSight = !old;
|
|
187
|
+
const seen = new Set(firstSight ? [] : old.seen);
|
|
188
|
+
const retry = firstSight ? {} : old.retry;
|
|
189
|
+
const record = { seen: [], retry: {} };
|
|
190
|
+
for (const tree of await listOf(folder)) {
|
|
191
|
+
for (const relative of relatives) {
|
|
192
|
+
const target = path.join(tree, relative);
|
|
193
|
+
// A worktree on a commit without this folder (the service was added later) has nothing to build.
|
|
194
|
+
if (!isDirectory(target)) continue;
|
|
195
|
+
if (firstSight || seen.has(target) || heldSet.has(target)) {
|
|
196
|
+
record.seen.push(target);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const tried = retry[target] || { attempts: 0, nextAt: 0 };
|
|
200
|
+
if (tried.attempts >= MAX_ATTEMPTS) {
|
|
201
|
+
log(` Gave up building ${target} after ${tried.attempts} tries; run \`${require("./release").rebuildCommand()}\` there to see why.`);
|
|
202
|
+
record.seen.push(target);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (now < tried.nextAt) {
|
|
206
|
+
record.retry[target] = tried;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
wanted.push(target);
|
|
210
|
+
const attempts = tried.attempts + 1;
|
|
211
|
+
record.retry[target] = { attempts, nextAt: now + (RETRY_DELAYS_MS[attempts - 1] || 0) };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
record.seen.sort();
|
|
215
|
+
next[common] = record;
|
|
216
|
+
}
|
|
217
|
+
const problem = saveState(next, { file, keepBackup: from === "file" });
|
|
218
|
+
// Said after the write, so each line says what happened; and once, not every round, because a
|
|
219
|
+
// file that cannot be written is read from memory on the next round and nothing new is to be said.
|
|
220
|
+
if (from === "backup") {
|
|
221
|
+
log(` ${file} could not be read; used its copy from the round before${problem ? "" : ", and wrote it again"}.`);
|
|
222
|
+
} else if (from === "lost") {
|
|
223
|
+
log(` ${file} could not be read and has no readable copy${problem ? "" : "; it is written again from now on"}. A worktree made just before is not built by itself: run \`${require("./release").rebuildCommand()}\` there.`);
|
|
224
|
+
}
|
|
225
|
+
if (problem) {
|
|
226
|
+
if (!kept) log(` ${file} could not be written (${problem}). What this watcher has seen is kept in memory until it can be, so a refused worktree is still given up after ${MAX_ATTEMPTS} tries; after a restart it may be offered again.`);
|
|
227
|
+
unsaved.set(file, next);
|
|
228
|
+
} else {
|
|
229
|
+
if (kept) log(` ${file} is written again.`);
|
|
230
|
+
unsaved.delete(file);
|
|
231
|
+
}
|
|
232
|
+
return wanted;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isDirectory(folder) {
|
|
236
|
+
try {
|
|
237
|
+
return fs.statSync(folder).isDirectory();
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = { newWorktreeFolders, worktreesOf, repositoryOf, loadState, STATE_FILE, MAX_ATTEMPTS, RETRY_DELAYS_MS };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 1 of `niro start`: can this machine run Niro Local, before any account exists?
|
|
3
|
+
*
|
|
4
|
+
* Read-only, and every probe can be injected so a test never reads a disk. There is no Docker
|
|
5
|
+
* check: the engine's graph is one SQLite file, and whether it opens is asked of the engine
|
|
6
|
+
* itself once it is installed (graphCheck.js, step 3).
|
|
7
|
+
*/
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
|
|
11
|
+
const engine = require("./engine");
|
|
12
|
+
|
|
13
|
+
const MIN_NODE_MAJOR = 18;
|
|
14
|
+
const LOW_DISK_BYTES = 2 * 1024 ** 3;
|
|
15
|
+
|
|
16
|
+
/** "macOS 15" from Darwin 24, which is how the kernel and the product number differ. */
|
|
17
|
+
function osName(platform, release) {
|
|
18
|
+
const major = Number(String(release).split(".")[0]);
|
|
19
|
+
if (platform === "darwin") return major >= 20 ? `macOS ${major - 9}` : `macOS (Darwin ${release})`;
|
|
20
|
+
if (platform === "linux") return "Linux";
|
|
21
|
+
if (platform === "win32") return "Windows";
|
|
22
|
+
return platform;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function check({
|
|
26
|
+
platform = process.platform,
|
|
27
|
+
arch = process.arch,
|
|
28
|
+
release = os.release(),
|
|
29
|
+
nodeVersion = process.version,
|
|
30
|
+
statfs = (p) => fs.statfsSync(p),
|
|
31
|
+
home = os.homedir(),
|
|
32
|
+
} = {}) {
|
|
33
|
+
let platformProblem = null;
|
|
34
|
+
try {
|
|
35
|
+
engine.label(platform, arch);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
platformProblem = err.message;
|
|
38
|
+
}
|
|
39
|
+
let freeBytes = null;
|
|
40
|
+
try {
|
|
41
|
+
const s = statfs(home);
|
|
42
|
+
freeBytes = Number(s.bavail) * Number(s.bsize);
|
|
43
|
+
} catch {
|
|
44
|
+
// Unknown is reported as unknown, never as low.
|
|
45
|
+
}
|
|
46
|
+
const nodeMajor = Number(/^v?(\d+)/.exec(nodeVersion || "")?.[1] || 0);
|
|
47
|
+
return {
|
|
48
|
+
os: osName(platform, release),
|
|
49
|
+
arch,
|
|
50
|
+
freeBytes,
|
|
51
|
+
lowDisk: freeBytes !== null && freeBytes < LOW_DISK_BYTES,
|
|
52
|
+
node: nodeVersion,
|
|
53
|
+
nodeOk: nodeMajor >= MIN_NODE_MAJOR,
|
|
54
|
+
platformProblem,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { check, osName, LOW_DISK_BYTES };
|
package/src/lib/mcpClients.js
CHANGED
|
@@ -238,4 +238,13 @@ function allClientIds() {
|
|
|
238
238
|
return Object.keys(CLIENTS);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Display names, as the installer shows them ("Claude Code"). Takes client ids or what
|
|
243
|
+
* configuredClients() returns ({ configured: [{ id }] }), which is what callers pass.
|
|
244
|
+
*/
|
|
245
|
+
function clientNames(found) {
|
|
246
|
+
const ids = Array.isArray(found) ? found : ((found && found.configured) || []).map((c) => c.id);
|
|
247
|
+
return ids.map((id) => (CLIENTS[id] && CLIENTS[id].name) || id);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = { configuredClients, clientNames, allClientIds, MARKER_FILES, MAX_CONFIG_BYTES };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to tell a developer, after an upgrade, about where their folders are indexed.
|
|
3
|
+
*
|
|
4
|
+
* The local engine indexes a repository on the developer's own machine instead of uploading it.
|
|
5
|
+
* A machine that has used Niro for a while has folders set up the other way: watched, hashed
|
|
6
|
+
* and uploaded into a cloud project. Both keep working, and nothing here changes either.
|
|
7
|
+
*
|
|
8
|
+
* **What this exists to prevent.** A developer installs the engine, or upgrades into a version
|
|
9
|
+
* that has it, and assumes their code has stopped leaving the machine. It has not: the folders
|
|
10
|
+
* they onboarded before are still being uploaded by the same daemon as yesterday. Saying so
|
|
11
|
+
* once, with the command that moves one, is the difference between a product they understand
|
|
12
|
+
* and a nasty surprise.
|
|
13
|
+
*
|
|
14
|
+
* **What it deliberately does NOT do.** It does not stop the uploading daemon and it does not
|
|
15
|
+
* offer to release a cloud project. The plan's migration list asks for both, with the line "the
|
|
16
|
+
* CLI no longer refreshes them", and that will be true when the CLI stops maintaining cloud
|
|
17
|
+
* projects. It does not today: a pinned repository is still answered by the cloud by design,
|
|
18
|
+
* and the bridge still routes to it. Telling somebody we no longer refresh something we do
|
|
19
|
+
* refresh would be worse than saying nothing.
|
|
20
|
+
*
|
|
21
|
+
* It reads local state only: no network call, nothing that can hang an upgrade.
|
|
22
|
+
*/
|
|
23
|
+
const watchState = require("./watchState");
|
|
24
|
+
const engine = require("./engine");
|
|
25
|
+
const color = require("./color");
|
|
26
|
+
const release = require("./release");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Is the engine on this machine at all, cache or PATH or named by the environment?
|
|
30
|
+
*
|
|
31
|
+
* Whichever of those it is, the daemon will run it, so the notice is owed. Reading the
|
|
32
|
+
* download cache alone said nothing to a developer running an engine from their PATH, who
|
|
33
|
+
* is exactly the person indexing locally and still uploading yesterday's folders.
|
|
34
|
+
*/
|
|
35
|
+
function engineHere() {
|
|
36
|
+
const found = engine.resolve();
|
|
37
|
+
return found ? found.path : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The lines to print, or an empty list when there is nothing worth saying.
|
|
42
|
+
*
|
|
43
|
+
* Nothing is said on a machine with no engine, which is every existing customer until they ask
|
|
44
|
+
* for one: an upgrade is not the place to advertise. Nothing is said either when no folder is
|
|
45
|
+
* uploaded, because then there is nothing to be surprised by.
|
|
46
|
+
*/
|
|
47
|
+
function lines({ installed = engineHere, folders = watchState.allLocal, cloud = release.cloud } = {}) {
|
|
48
|
+
if (!cloud) return webLines(listed(folders));
|
|
49
|
+
let enginePath = null;
|
|
50
|
+
try {
|
|
51
|
+
enginePath = installed();
|
|
52
|
+
} catch {
|
|
53
|
+
enginePath = null;
|
|
54
|
+
}
|
|
55
|
+
if (!enginePath) return [];
|
|
56
|
+
|
|
57
|
+
let uploaded = [];
|
|
58
|
+
try {
|
|
59
|
+
uploaded = folders() || [];
|
|
60
|
+
} catch {
|
|
61
|
+
uploaded = [];
|
|
62
|
+
}
|
|
63
|
+
if (uploaded.length === 0) return [];
|
|
64
|
+
|
|
65
|
+
const count = uploaded.length;
|
|
66
|
+
const out = [
|
|
67
|
+
` ${color.yellow("Where your code is indexed:")} ${count} folder${count === 1 ? "" : "s"} ` +
|
|
68
|
+
`on this machine ${count === 1 ? "is" : "are"} still indexed in the cloud,`,
|
|
69
|
+
" which means the watcher uploads changed files from them as you work. The local engine",
|
|
70
|
+
" is installed, so you can move a folder onto it instead:",
|
|
71
|
+
"",
|
|
72
|
+
];
|
|
73
|
+
for (const folder of uploaded.slice(0, 5)) {
|
|
74
|
+
out.push(` ${folder.folderPath}`);
|
|
75
|
+
}
|
|
76
|
+
if (uploaded.length > 5) {
|
|
77
|
+
out.push(` ... and ${uploaded.length - 5} more`);
|
|
78
|
+
}
|
|
79
|
+
out.push(
|
|
80
|
+
"",
|
|
81
|
+
// `init` adds the folder and builds it (both the engine's and niro's). Without the cloud it is `niro init`, which
|
|
82
|
+
// passes the licence on; the released engine refuses a build typed directly.
|
|
83
|
+
` To move one: cd <folder> && ${color.cyan(require("./release").cloud ? "niro-local init" : "niro init")}`,
|
|
84
|
+
" Its questions are then answered from the graph on this machine, and nothing from it",
|
|
85
|
+
" is uploaded. The folders you leave alone keep working exactly as they do now.",
|
|
86
|
+
""
|
|
87
|
+
);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function listed(folders) {
|
|
92
|
+
try {
|
|
93
|
+
return folders() || [];
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A release without the cloud: the folders set up with Niro web are no longer uploaded at all,
|
|
101
|
+
* because the watcher has nothing to sync in this release. Say so, and how to keep using Niro on
|
|
102
|
+
* one, instead of letting the developer find out when a cloud answer goes stale.
|
|
103
|
+
*/
|
|
104
|
+
function webLines(uploaded) {
|
|
105
|
+
if (uploaded.length === 0) return [];
|
|
106
|
+
const count = uploaded.length;
|
|
107
|
+
const out = [
|
|
108
|
+
` ${color.yellow("Folders from Niro web:")} ${count} folder${count === 1 ? " was" : "s were"} set up with Niro web on this machine.`,
|
|
109
|
+
" This version indexes on this machine and no longer uploads them:",
|
|
110
|
+
"",
|
|
111
|
+
];
|
|
112
|
+
for (const folder of uploaded.slice(0, 5)) out.push(` ${folder.folderPath}`);
|
|
113
|
+
if (count > 5) out.push(` ... and ${count - 5} more`);
|
|
114
|
+
out.push("", ` To keep using Niro on one: cd <folder> && ${color.cyan("niro init")}`, "");
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { lines, webLines };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Puts the local engine's embedding model on disk for `niro docs-send`, split out of
|
|
3
|
+
* docsUpload.js so each file has one job.
|
|
4
|
+
*
|
|
5
|
+
* The engine never downloads anything (niro-local `reuse/embed.py`): `niro-local docs
|
|
6
|
+
* --model-info` names the files with their URL, size and sha256, and this fetches them. A file
|
|
7
|
+
* reaches its final name only after its size and sha256 match, so the engine sees either the
|
|
8
|
+
* right file or no file.
|
|
9
|
+
*/
|
|
10
|
+
const crypto = require("crypto");
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const { Readable } = require("stream");
|
|
14
|
+
const { pipeline } = require("stream/promises");
|
|
15
|
+
|
|
16
|
+
const localEngine = require("./localEngine");
|
|
17
|
+
|
|
18
|
+
/** How long one model file download may take. The larger file is about 90 MB. */
|
|
19
|
+
const DEFAULT_MODEL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
/** Read when used, not when this file is loaded (the trap `lease.js` describes). */
|
|
22
|
+
function modelTimeoutMs() {
|
|
23
|
+
return Number(process.env.NIRO_DOCS_MODEL_TIMEOUT_MS) || DEFAULT_MODEL_TIMEOUT_MS;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------------------------
|
|
27
|
+
// The embedding model
|
|
28
|
+
// ---------------------------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Files already checked this process, keyed by path, so a 90 MB file is not hashed every round. */
|
|
31
|
+
const verified = new Map();
|
|
32
|
+
|
|
33
|
+
function sha256Of(file) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const hash = crypto.createHash("sha256");
|
|
36
|
+
fs.createReadStream(file)
|
|
37
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
38
|
+
.on("error", reject)
|
|
39
|
+
.on("end", () => resolve(hash.digest("hex")));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function alreadyInPlace(dest, spec) {
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
stat = fs.statSync(dest);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (stat.size !== spec.bytes) return false;
|
|
51
|
+
const key = `${stat.size}:${stat.mtimeMs}`;
|
|
52
|
+
if (verified.get(dest) === key) return true;
|
|
53
|
+
if ((await sha256Of(dest)) !== spec.sha256) return false;
|
|
54
|
+
verified.set(dest, key);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The engine's manifest, checked before anything is written where it points. */
|
|
59
|
+
function readManifest(text) {
|
|
60
|
+
const info = JSON.parse(text);
|
|
61
|
+
const directory = info && info.directory;
|
|
62
|
+
if (typeof directory !== "string" || !path.isAbsolute(directory)) {
|
|
63
|
+
throw new Error("the engine's model manifest has no absolute directory");
|
|
64
|
+
}
|
|
65
|
+
const files = Array.isArray(info.files) ? info.files : [];
|
|
66
|
+
for (const spec of files) {
|
|
67
|
+
const ok =
|
|
68
|
+
spec &&
|
|
69
|
+
typeof spec.name === "string" &&
|
|
70
|
+
// A plain file name: letters, digits, dots, dashes and underscores, not starting with a
|
|
71
|
+
// dot. That rules out "" (which joins to the folder itself, so the download landed
|
|
72
|
+
// beside it), ".", "..", hidden names and anything with a separator in it.
|
|
73
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(spec.name) &&
|
|
74
|
+
typeof spec.url === "string" &&
|
|
75
|
+
/^https:\/\//.test(spec.url) &&
|
|
76
|
+
typeof spec.sha256 === "string" &&
|
|
77
|
+
/^[0-9a-f]{64}$/.test(spec.sha256) &&
|
|
78
|
+
Number.isInteger(spec.bytes) &&
|
|
79
|
+
spec.bytes > 0;
|
|
80
|
+
if (!ok) throw new Error("the engine's model manifest has a file entry this cannot use");
|
|
81
|
+
}
|
|
82
|
+
return { directory, files };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function downloadOne(spec, dest, fetchImpl) {
|
|
86
|
+
const part = `${dest}.${process.pid}.part`;
|
|
87
|
+
const abort = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => abort.abort(), modelTimeoutMs());
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetchImpl(spec.url, { signal: abort.signal });
|
|
91
|
+
if (!response || !response.ok || !response.body) {
|
|
92
|
+
throw new Error(`HTTP ${response ? response.status : "no answer"}`);
|
|
93
|
+
}
|
|
94
|
+
const hash = crypto.createHash("sha256");
|
|
95
|
+
let size = 0;
|
|
96
|
+
await pipeline(
|
|
97
|
+
Readable.fromWeb(response.body),
|
|
98
|
+
async function* check(source) {
|
|
99
|
+
for await (const chunk of source) {
|
|
100
|
+
size += chunk.length;
|
|
101
|
+
// Stopped as soon as it is too big, so a wrong file cannot fill the disk.
|
|
102
|
+
if (size > spec.bytes) throw new Error("larger than the manifest says");
|
|
103
|
+
hash.update(chunk);
|
|
104
|
+
yield chunk;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
fs.createWriteStream(part, { mode: 0o644 })
|
|
108
|
+
);
|
|
109
|
+
if (size !== spec.bytes) throw new Error(`size ${size}, expected ${spec.bytes}`);
|
|
110
|
+
if (hash.digest("hex") !== spec.sha256) throw new Error("sha256 does not match the manifest");
|
|
111
|
+
fs.renameSync(part, dest);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// Nothing half-downloaded or wrong is ever left where the engine looks.
|
|
114
|
+
try {
|
|
115
|
+
fs.unlinkSync(part);
|
|
116
|
+
} catch {
|
|
117
|
+
// Never created.
|
|
118
|
+
}
|
|
119
|
+
if (abort.signal.aborted) throw new Error(`${spec.name}: timed out`);
|
|
120
|
+
throw new Error(`${spec.name}: ${(err && err.message) || "download failed"}`);
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Put the embedding model where the engine looks for it, if it is not there yet.
|
|
128
|
+
*
|
|
129
|
+
* The engine never downloads anything; it names the files, their size and sha256 in
|
|
130
|
+
* `docs --model-info`, which needs no licence (niro-local `cli.py`, `_cmd_docs`). Each file is
|
|
131
|
+
* downloaded beside its final name and renamed into place only after its size and sha256 match,
|
|
132
|
+
* so the engine sees either the right file or no file.
|
|
133
|
+
*/
|
|
134
|
+
async function ensureModel({ fetchImpl = (...args) => fetch(...args), beforeEach = () => {} } = {}) {
|
|
135
|
+
// Off in this release (src/lib/release.js `llmDocs`): the 90 MB model is not fetched.
|
|
136
|
+
if (!require("./release").llmDocs) return { fetched: 0, present: 0, off: true };
|
|
137
|
+
const { directory, files } = readManifest(await localEngine.run(["docs", "--model-info"]));
|
|
138
|
+
const result = { fetched: 0, present: 0 };
|
|
139
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
140
|
+
for (const spec of files) {
|
|
141
|
+
beforeEach();
|
|
142
|
+
const dest = path.join(directory, spec.name);
|
|
143
|
+
if (await alreadyInPlace(dest, spec)) {
|
|
144
|
+
result.present += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
await downloadOne(spec, dest, fetchImpl);
|
|
148
|
+
result.fetched += 1;
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { ensureModel };
|
package/src/lib/nestedOutput.js
CHANGED
|
@@ -14,36 +14,50 @@
|
|
|
14
14
|
* process.stdout.write is untouched: the upload progress bar repaints one line with \r, and
|
|
15
15
|
* prefixing every repaint would smear it across the terminal.
|
|
16
16
|
*/
|
|
17
|
-
let current =
|
|
17
|
+
let current = "";
|
|
18
18
|
|
|
19
|
-
/** How deep the output is nested right now
|
|
19
|
+
/** How deep the output is nested right now, in columns. */
|
|
20
20
|
function currentIndent() {
|
|
21
|
+
return current.length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What starts every nested line right now: spaces, or the setup rail ("│ "). Writers that
|
|
25
|
+
* bypass console (prompt, spinner, progress) prefix their own lines with this. */
|
|
26
|
+
function currentPad() {
|
|
21
27
|
return current;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
function withIndent(spaces, fn) {
|
|
25
|
-
|
|
31
|
+
return withPrefix(" ".repeat(spaces), fn);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* withIndent with any prefix. `niro start` passes its rail, "│ ", so what a command prints
|
|
36
|
+
* inside a step stays on the rail instead of breaking it.
|
|
37
|
+
*/
|
|
38
|
+
function withPrefix(prefix, fn) {
|
|
26
39
|
const shift = (orig) => (...args) => {
|
|
27
40
|
const text = args.map((a) => (typeof a === "string" ? a : String(a))).join(" ");
|
|
28
|
-
// A line with nothing but spaces is emitted
|
|
29
|
-
//
|
|
30
|
-
orig(text.split("\n").map((l) => (l.trim() ?
|
|
41
|
+
// A line with nothing but spaces is emitted as the prefix alone, trimmed: empty for an
|
|
42
|
+
// indent, a bare "│" for the rail. Never trailing whitespace.
|
|
43
|
+
orig(text.split("\n").map((l) => (l.trim() ? prefix + l : prefix.trimEnd())).join("\n"));
|
|
31
44
|
};
|
|
32
45
|
const log = console.log;
|
|
33
46
|
const error = console.error;
|
|
34
47
|
const warn = console.warn;
|
|
35
|
-
|
|
48
|
+
const outer = current;
|
|
49
|
+
current += prefix;
|
|
36
50
|
console.log = shift(log);
|
|
37
51
|
console.error = shift(error);
|
|
38
52
|
console.warn = shift(warn);
|
|
39
53
|
return Promise.resolve()
|
|
40
54
|
.then(fn)
|
|
41
55
|
.finally(() => {
|
|
42
|
-
current
|
|
56
|
+
current = outer;
|
|
43
57
|
console.log = log;
|
|
44
58
|
console.error = error;
|
|
45
59
|
console.warn = warn;
|
|
46
60
|
});
|
|
47
61
|
}
|
|
48
62
|
|
|
49
|
-
module.exports = { withIndent, currentIndent };
|
|
63
|
+
module.exports = { withIndent, withPrefix, currentIndent, currentPad };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where `niro start` notes how long each step took and how it ended, in
|
|
3
|
+
* ~/.niro/onboarding.json, as `[{step, startedAt, endedAt, outcome}]`.
|
|
4
|
+
*
|
|
5
|
+
* Kept on the machine only; whether it is ever sent is an open question (Q11). A failure to
|
|
6
|
+
* write is swallowed: a note about the wizard must never stop the wizard.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
|
|
12
|
+
function file(home = os.homedir()) {
|
|
13
|
+
return path.join(home, ".niro", "onboarding.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function record(entry, { home = os.homedir() } = {}) {
|
|
17
|
+
try {
|
|
18
|
+
const where = file(home);
|
|
19
|
+
let list = [];
|
|
20
|
+
try {
|
|
21
|
+
list = JSON.parse(fs.readFileSync(where, "utf8"));
|
|
22
|
+
if (!Array.isArray(list)) list = [];
|
|
23
|
+
} catch {
|
|
24
|
+
list = [];
|
|
25
|
+
}
|
|
26
|
+
list.push(entry);
|
|
27
|
+
fs.mkdirSync(path.dirname(where), { recursive: true });
|
|
28
|
+
fs.writeFileSync(where, JSON.stringify(list, null, 2) + "\n");
|
|
29
|
+
} catch {
|
|
30
|
+
// ponytail: best effort by design, see above.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { record, file };
|