@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,619 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Puts the local engine on the machine, once, and finds it every time after that.
|
|
3
|
+
*
|
|
4
|
+
* The engine is a compiled binary of its own, not part of this npm package. It is about 60 MB
|
|
5
|
+
* per platform and there are four of them, so shipping all four to every install would be
|
|
6
|
+
* 240 MB on a machine that needs one, and publishing it at all would hand the whole engine to
|
|
7
|
+
* anybody who runs `npm pack` (Niraj, 2026-10-14: the engine is "downloaded on first use from
|
|
8
|
+
* an authenticated endpoint, not published to npm").
|
|
9
|
+
*
|
|
10
|
+
* **The fast path first.** Every run after the first costs one `existsSync`. Nothing here
|
|
11
|
+
* touches the network when the engine is already in the cache, and nothing here is called
|
|
12
|
+
* when the CLI only needs to ASK the engine something: a download must be something a person
|
|
13
|
+
* started, not a side effect of the watcher checking whether a folder is indexed.
|
|
14
|
+
*
|
|
15
|
+
* **Where it comes from, and why the digest does not come with it.** The CLI asks a Niro
|
|
16
|
+
* service for a manifest naming the version, the address of the tarball for this platform and
|
|
17
|
+
* its SHA-256. The digest comes from the service, which is authenticated, and never from the
|
|
18
|
+
* host serving the download: a digest published beside the file it describes proves only that
|
|
19
|
+
* the two agree, so whoever controls the download would control both. With the digest from
|
|
20
|
+
* somewhere else, a tarball that has been replaced is refused before anything is unpacked.
|
|
21
|
+
*
|
|
22
|
+
* **One download at a time.** Two commands can start within a second of each other: the
|
|
23
|
+
* wizard and a watcher, or two terminals. So the download takes a lock, streams to a `.part`
|
|
24
|
+
* file, and the finished folder is put in place with a rename, which is atomic. A reader
|
|
25
|
+
* either sees the whole folder or does not see it.
|
|
26
|
+
*/
|
|
27
|
+
const crypto = require("crypto");
|
|
28
|
+
const fs = require("fs");
|
|
29
|
+
const os = require("os");
|
|
30
|
+
const path = require("path");
|
|
31
|
+
const { spawnSync } = require("child_process");
|
|
32
|
+
|
|
33
|
+
const api = require("./api");
|
|
34
|
+
const color = require("./color");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where the cache lives, beside the credentials and config the CLI already keeps.
|
|
38
|
+
*
|
|
39
|
+
* Worked out when it is used rather than when this file loads, which is how `receiptStore.js`
|
|
40
|
+
* does it too: a value fixed at load time cannot be repointed, and both a test and a machine
|
|
41
|
+
* with a small home partition need to repoint it.
|
|
42
|
+
*/
|
|
43
|
+
function home() {
|
|
44
|
+
return process.env.NIRO_ENGINE_HOME || path.join(os.homedir(), ".niro", "bin");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The manifest route, on the service that knows what this account may run. */
|
|
48
|
+
const MANIFEST_PATH = "/api/local-engine/engine";
|
|
49
|
+
|
|
50
|
+
/** The name of the executable inside the tarball's folder. */
|
|
51
|
+
const EXECUTABLE = process.platform === "win32" ? "niro-local.exe" : "niro-local";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How long to wait for another process's download, and how long a lock may sit untouched
|
|
55
|
+
* before it is treated as left behind by a process that died.
|
|
56
|
+
*
|
|
57
|
+
* Read when they are used, not when this file loads. A value fixed at load time ignores an
|
|
58
|
+
* override set by whatever started the process, and a test that sets one is then testing the
|
|
59
|
+
* default while appearing to test the override. Not hypothetical: the first run of this
|
|
60
|
+
* file's own tests sat for five minutes on a 300-second default it had asked to be 300
|
|
61
|
+
* milliseconds.
|
|
62
|
+
*/
|
|
63
|
+
function lockWaitMs() {
|
|
64
|
+
return Number(process.env.NIRO_ENGINE_LOCK_WAIT_MS) || 300_000;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function lockStaleMs() {
|
|
68
|
+
return Number(process.env.NIRO_ENGINE_LOCK_STALE_MS) || 600_000;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* This machine, as the release names it.
|
|
73
|
+
*
|
|
74
|
+
* Five labels exist because the engine is compiled, and a compiled binary is per platform and
|
|
75
|
+
* per processor. Anything else is told plainly that there is no engine for it rather than
|
|
76
|
+
* being given one that cannot run: "no build for your platform" is a far better message than
|
|
77
|
+
* an exec failure. Windows is built for Intel only (2026-11-10).
|
|
78
|
+
*/
|
|
79
|
+
function label(platform = process.platform, architecture = process.arch) {
|
|
80
|
+
const architectures = { x64: "x86_64", arm64: "arm64" };
|
|
81
|
+
const found = architectures[architecture];
|
|
82
|
+
if (platform === "darwin" && found) return `macos-${found}`;
|
|
83
|
+
if (platform === "linux" && found) return `linux-${found}`;
|
|
84
|
+
if (platform === "win32" && architecture === "x64") return "windows-x86_64";
|
|
85
|
+
const problem = new Error(
|
|
86
|
+
`There is no local engine for ${platform} ${architecture}. ` +
|
|
87
|
+
"It is built for macOS and Linux on Intel and ARM, and for Windows on Intel."
|
|
88
|
+
);
|
|
89
|
+
problem.code = "ENOENGINEFORPLATFORM";
|
|
90
|
+
throw problem;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Which service to ask. The lease comes from the same place, so one variable moves both. */
|
|
94
|
+
function manifestUrl() {
|
|
95
|
+
const base = process.env.NIRO_BILLING_URL || require("./lease").billingUrl();
|
|
96
|
+
return `${String(base).replace(/\/+$/, "")}${MANIFEST_PATH}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where a given version's engine lives once it is unpacked. */
|
|
100
|
+
function versionDir(version, which = label()) {
|
|
101
|
+
return path.join(home(), String(version), `niro-local-${which}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The engine on this machine, or null.
|
|
106
|
+
*
|
|
107
|
+
* `NIRO_LOCAL_BIN` wins, because a developer working on the engine points it at their own
|
|
108
|
+
* build and nothing should quietly download over that. Then the cache for the version asked
|
|
109
|
+
* for, if one was named; then the newest version in the cache, so a CLI that cannot reach us
|
|
110
|
+
* still runs the engine it already has.
|
|
111
|
+
*/
|
|
112
|
+
function installed(version = null) {
|
|
113
|
+
if (process.env.NIRO_LOCAL_BIN) return process.env.NIRO_LOCAL_BIN;
|
|
114
|
+
let which;
|
|
115
|
+
try {
|
|
116
|
+
which = label();
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (version) {
|
|
121
|
+
const exact = path.join(versionDir(version, which), EXECUTABLE);
|
|
122
|
+
return fs.existsSync(exact) ? exact : null;
|
|
123
|
+
}
|
|
124
|
+
let versions = [];
|
|
125
|
+
try {
|
|
126
|
+
versions = fs.readdirSync(home());
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const usable = versions
|
|
131
|
+
.filter((name) => fs.existsSync(path.join(versionDir(name, which), EXECUTABLE)))
|
|
132
|
+
.sort(compareVersions);
|
|
133
|
+
return usable.length ? path.join(versionDir(usable[usable.length - 1], which), EXECUTABLE) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A machine holds one version (Niraj, 2026-11-05: "we need to focus on that there is only one version on the
|
|
138
|
+
* system"). Once the published one is in place, every other version folder in the cache goes. A running engine keeps
|
|
139
|
+
* its file open, so one still answering a session finishes normally. A folder that will not go is left and
|
|
140
|
+
* never fails the install: the newest is the one started anyway. Dot files and downloads in progress are not
|
|
141
|
+
* version folders.
|
|
142
|
+
*/
|
|
143
|
+
function removeOtherVersions(keep) {
|
|
144
|
+
let names = [];
|
|
145
|
+
try {
|
|
146
|
+
names = fs.readdirSync(home());
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const name of names) {
|
|
151
|
+
if (name === String(keep) || name.startsWith(".") || name.includes(".part-")) continue;
|
|
152
|
+
// Only older ones, and only when no engine runs from them: a running engine reads files from its folder after it
|
|
153
|
+
// starts, and only the executable stays open (independent check: a read failed once 2.0.0 was deleted under it).
|
|
154
|
+
// A folder in use goes at a later start (`tidy`).
|
|
155
|
+
if (compareVersions(name, keep) >= 0 || inUse(name)) continue;
|
|
156
|
+
try {
|
|
157
|
+
if (fs.statSync(path.join(home(), name)).isDirectory()) {
|
|
158
|
+
fs.rmSync(path.join(home(), name), { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Left for the next install.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The newest version in the cache with an engine in it, by number, or null. */
|
|
167
|
+
function newestVersion(which = label()) {
|
|
168
|
+
let names = [];
|
|
169
|
+
try {
|
|
170
|
+
names = fs.readdirSync(home());
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const usable = names
|
|
175
|
+
.filter((name) => fs.existsSync(path.join(versionDir(name, which), EXECUTABLE)))
|
|
176
|
+
.sort(compareVersions);
|
|
177
|
+
return usable.length ? usable[usable.length - 1] : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The mark an engine started from this version's folder leaves while it runs. */
|
|
181
|
+
function marksIn(version) {
|
|
182
|
+
const dir = versionDir(version);
|
|
183
|
+
try {
|
|
184
|
+
return fs.readdirSync(dir).filter((name) => name.startsWith(".in-use-")).map((name) => path.join(dir, name));
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function alive(pid) {
|
|
191
|
+
try {
|
|
192
|
+
process.kill(pid, 0);
|
|
193
|
+
return true;
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return err.code === "EPERM";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Whether an engine runs from this version's folder. A mark whose process is gone is removed. */
|
|
200
|
+
function inUse(version) {
|
|
201
|
+
let running = false;
|
|
202
|
+
for (const mark of marksIn(version)) {
|
|
203
|
+
const pid = Number(path.basename(mark).slice(".in-use-".length));
|
|
204
|
+
if (pid && alive(pid)) running = true;
|
|
205
|
+
else fs.rmSync(mark, { force: true });
|
|
206
|
+
}
|
|
207
|
+
return running;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** At every start: the older versions no engine runs from any more go, so one is on the machine. */
|
|
211
|
+
function tidy() {
|
|
212
|
+
if (process.env.NIRO_LOCAL_BIN) return;
|
|
213
|
+
try {
|
|
214
|
+
const newest = newestVersion();
|
|
215
|
+
if (newest) removeOtherVersions(newest);
|
|
216
|
+
} catch {
|
|
217
|
+
// Never in the way of starting an engine.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Start an engine with `start()` (which returns the child process), marking its version's folder as in use for as
|
|
223
|
+
* long as the child runs. The starter's own mark covers the moment before the child has a pid. An engine from
|
|
224
|
+
* outside the cache (NIRO_LOCAL_BIN, the PATH) is started as it is.
|
|
225
|
+
*/
|
|
226
|
+
function startHeld(executable, start) {
|
|
227
|
+
tidy();
|
|
228
|
+
const relative = path.relative(home(), executable);
|
|
229
|
+
const version = relative.startsWith("..") || path.isAbsolute(relative) ? null : relative.split(path.sep)[0];
|
|
230
|
+
if (!version || !fs.existsSync(executable)) return start();
|
|
231
|
+
const mark = (pid) => {
|
|
232
|
+
const file = path.join(path.dirname(executable), `.in-use-${pid}`);
|
|
233
|
+
try {
|
|
234
|
+
fs.writeFileSync(file, "");
|
|
235
|
+
} catch {
|
|
236
|
+
return () => {};
|
|
237
|
+
}
|
|
238
|
+
return () => fs.rmSync(file, { force: true });
|
|
239
|
+
};
|
|
240
|
+
const releaseOwn = mark(process.pid);
|
|
241
|
+
let child;
|
|
242
|
+
try {
|
|
243
|
+
child = start();
|
|
244
|
+
} catch (err) {
|
|
245
|
+
releaseOwn();
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
if (child && child.pid) {
|
|
249
|
+
const release = mark(child.pid);
|
|
250
|
+
child.once("close", release);
|
|
251
|
+
child.once("error", release);
|
|
252
|
+
}
|
|
253
|
+
releaseOwn();
|
|
254
|
+
return child;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Bring the cached engine to the published version, for `niro upgrade`: the engine moves with the CLI, so an old
|
|
259
|
+
* one is not left running. Null, with nothing fetched, when there is none in the cache (a first download is a
|
|
260
|
+
* person's choice) or NIRO_LOCAL_BIN names somebody's own build.
|
|
261
|
+
*/
|
|
262
|
+
async function upgradeInstalled({ log = console.log } = {}) {
|
|
263
|
+
if (process.env.NIRO_LOCAL_BIN || !installed()) return null;
|
|
264
|
+
return ensure({ log, latest: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Oldest first, by number: 0.10.0 is newer than 0.9.0, which sorting the names as text gets backwards. That
|
|
269
|
+
* would have run the OLDER engine for good after an upgrade, on a home the newer one had already opened, and an
|
|
270
|
+
* older engine picks the first of a folder's rows and writes one branch's files into another's index
|
|
271
|
+
* (independent check of niro-local, 5 of 5 homes). A name with no numbers sorts below every version.
|
|
272
|
+
*/
|
|
273
|
+
function compareVersions(one, two) {
|
|
274
|
+
const parts = (name) => String(name).split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : -1));
|
|
275
|
+
const [a, b] = [parts(one), parts(two)];
|
|
276
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
277
|
+
const difference = (a[i] ?? 0) - (b[i] ?? 0);
|
|
278
|
+
if (difference) return difference;
|
|
279
|
+
}
|
|
280
|
+
return String(one).localeCompare(String(two));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The engine this machine would RUN, and where it came from, or null when there is none.
|
|
285
|
+
*
|
|
286
|
+
* **Why this sits beside `installed()`, which looks like the same question.** `installed()`
|
|
287
|
+
* answers "is there one in the download cache", which is what `niro engine install` needs: a
|
|
288
|
+
* developer whose package manager already put the engine on their PATH still wants `install`
|
|
289
|
+
* to fetch one into the cache. Everything else wants the engine that will actually be
|
|
290
|
+
* started, and that includes the plain name on the PATH, which is how the engine runs for
|
|
291
|
+
* anybody working on it. The two answers disagreeing is what made `niro engine status` say
|
|
292
|
+
* "not installed" on a machine where the engine runs perfectly well, reported 2026-10-16:
|
|
293
|
+
* the status line read the cache while `localEngine.run` used the PATH.
|
|
294
|
+
*
|
|
295
|
+
* `source` is `"env"` for `NIRO_LOCAL_BIN`, `"cache"` for the download cache, `"path"` for a
|
|
296
|
+
* name found on the PATH.
|
|
297
|
+
*/
|
|
298
|
+
function resolve() {
|
|
299
|
+
const named = process.env.NIRO_LOCAL_BIN;
|
|
300
|
+
if (named) return { path: named, source: "env" };
|
|
301
|
+
const cached = installed();
|
|
302
|
+
if (cached) return { path: cached, source: "cache" };
|
|
303
|
+
// The platform question comes before the PATH, and takes no VERSION at all. Both are
|
|
304
|
+
// answers to the same trap: this says "there is an engine", and the PATH cannot support a
|
|
305
|
+
// narrower claim than that. A version was asked for and silently ignored once (`installed`
|
|
306
|
+
// takes one), so the parameter is gone rather than documented. And on a platform we publish
|
|
307
|
+
// no engine for, something called niro-local on the PATH is somebody else's program:
|
|
308
|
+
// reporting it would turn an honest "not installed" into a confident wrong answer. A
|
|
309
|
+
// developer's own build on such a machine is still named by NIRO_LOCAL_BIN above, which is
|
|
310
|
+
// exactly what that variable is for.
|
|
311
|
+
try {
|
|
312
|
+
label();
|
|
313
|
+
} catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
const onPath = onThePath(EXECUTABLE);
|
|
317
|
+
return onPath ? { path: onPath, source: "path" } : null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Why tar failed: its own words, else why it never started (a missing tar.exe), else its exit code. */
|
|
321
|
+
function unpackProblem(result) {
|
|
322
|
+
const said = (result.stderr || "").trim();
|
|
323
|
+
if (said) return said;
|
|
324
|
+
if (result.error) return result.error.message;
|
|
325
|
+
return `tar exited with ${result.status}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Where a bare command name resolves on this machine's PATH, or null.
|
|
330
|
+
*
|
|
331
|
+
* Not needed to RUN the engine: `spawn` resolves a bare name itself. It is needed to SAY
|
|
332
|
+
* where the engine is, and to answer "is there one at all" before trying to run it.
|
|
333
|
+
*
|
|
334
|
+
* There is no extension list and no PATHEXT branch: the one name asked for is `EXECUTABLE`,
|
|
335
|
+
* which already carries `.exe` on Windows. On Windows `fs.accessSync(..., X_OK)` does not
|
|
336
|
+
* test an execute bit (Node documents it as behaving like `F_OK`), so there any file of that
|
|
337
|
+
* name counts, which is what Windows itself does with a `.exe`.
|
|
338
|
+
*
|
|
339
|
+
* Three details that each stop a wrong answer. The `isFile` check, because every directory
|
|
340
|
+
* passes an executable test, so a FOLDER called niro-local would otherwise be reported as the
|
|
341
|
+
* engine. `path.resolve`, so a relative PATH entry (`.`) gives an absolute answer rather than
|
|
342
|
+
* the bare name, which names no file once printed. And empty PATH entries are dropped rather
|
|
343
|
+
* than read as the current directory, which POSIX allows and nobody wants here: picking up a
|
|
344
|
+
* binary from whatever folder the developer happens to be in is not a feature.
|
|
345
|
+
*
|
|
346
|
+
* First match wins, which is what the shell does and what `spawn` did before anything called
|
|
347
|
+
* this. Two engines on one PATH is a real situation, an old one in /usr/local/bin and a new
|
|
348
|
+
* one in ~/.local/bin, and the one that would run is the one to report.
|
|
349
|
+
*/
|
|
350
|
+
function onThePath(name) {
|
|
351
|
+
const entries = String(process.env.PATH || "")
|
|
352
|
+
.split(path.delimiter)
|
|
353
|
+
.filter(Boolean);
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
const candidate = path.resolve(entry, name);
|
|
356
|
+
try {
|
|
357
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
358
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
359
|
+
} catch {
|
|
360
|
+
// Not there, or not runnable by us. Try the next one.
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** What the service says this account should run. */
|
|
367
|
+
/**
|
|
368
|
+
* The statuses that are the service ANSWERING rather than breaking.
|
|
369
|
+
*
|
|
370
|
+
* Every one of them is a sentence a developer can act on, written by the service: this plan
|
|
371
|
+
* does not include the local engine (403), this account is stopped (403), no engine is
|
|
372
|
+
* published for your platform yet (503), this deployment has no signing key (503), we do not
|
|
373
|
+
* build for your platform (400), we do not know who you are (401). None of them is a fault to
|
|
374
|
+
* investigate, so none of them should arrive looking like one. Anything else, 500 included,
|
|
375
|
+
* keeps the route and the status code, because then somebody has to debug it.
|
|
376
|
+
*/
|
|
377
|
+
const ANSWERED_STATUSES = new Set([400, 401, 403, 503]);
|
|
378
|
+
|
|
379
|
+
/** The sentence the service sent, or null when it sent none. */
|
|
380
|
+
function serviceSaid(err) {
|
|
381
|
+
const body = err && err.body;
|
|
382
|
+
if (!body) return null;
|
|
383
|
+
if (typeof body === "string") return body.trim() || null;
|
|
384
|
+
const said = body.error || body.message;
|
|
385
|
+
return typeof said === "string" && said.trim() ? said.trim() : null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Turn a refusal from the manifest endpoint into words, or return null to leave it alone.
|
|
390
|
+
*
|
|
391
|
+
* Why this exists: the HTTP layer names the route and the status in every failure, which is
|
|
392
|
+
* right for a fault and wrong for an answer. Asking for an engine before one is published
|
|
393
|
+
* printed `Error: GET /api/local-engine/engine failed: No local engine is published for
|
|
394
|
+
* linux-x86_64 yet` and then `(HTTP 503)`, so a correct, expected answer read as a crash in
|
|
395
|
+
* our code and handed the reader a route they can do nothing with (reported 2026-10-16).
|
|
396
|
+
*/
|
|
397
|
+
function manifestRefusal(err) {
|
|
398
|
+
if (!err || !ANSWERED_STATUSES.has(err.status)) return null;
|
|
399
|
+
const said = serviceSaid(err);
|
|
400
|
+
if (!said) return null;
|
|
401
|
+
const refusal = new Error(/[.!?]$/.test(said) ? said : `${said}.`);
|
|
402
|
+
// The code is how the command tells an answer from a fault. The status is deliberately NOT
|
|
403
|
+
// carried over: the printer appends "(HTTP n)" to anything that has one.
|
|
404
|
+
refusal.code = "EENGINEREFUSED";
|
|
405
|
+
return refusal;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function manifest({ which = label() } = {}) {
|
|
409
|
+
let data;
|
|
410
|
+
try {
|
|
411
|
+
data = await api.request("GET", MANIFEST_PATH, {
|
|
412
|
+
apiUrl: process.env.NIRO_BILLING_URL || require("./lease").billingUrl(),
|
|
413
|
+
query: { label: which },
|
|
414
|
+
});
|
|
415
|
+
} catch (err) {
|
|
416
|
+
throw manifestRefusal(err) || err;
|
|
417
|
+
}
|
|
418
|
+
const version = data && (data.version || data.engine_version);
|
|
419
|
+
const url = data && data.url;
|
|
420
|
+
const digest = data && (data.sha256 || data.digest);
|
|
421
|
+
if (!version || !url || !digest) {
|
|
422
|
+
throw new Error("The engine manifest came back incomplete.");
|
|
423
|
+
}
|
|
424
|
+
return { version: String(version), url: String(url), sha256: String(digest).toLowerCase() };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Take the download lock, or wait for whoever has it.
|
|
429
|
+
*
|
|
430
|
+
* A directory is the lock, because `mkdir` either creates it or fails, in one step, on every
|
|
431
|
+
* filesystem this runs on. A lock whose directory has not been touched for a while is treated
|
|
432
|
+
* as left behind by a process that died, which is the case this has to survive: a download
|
|
433
|
+
* interrupted by Ctrl-C must not stop the next one for ever.
|
|
434
|
+
*/
|
|
435
|
+
async function withLock(version, run) {
|
|
436
|
+
const lock = path.join(home(), `.lock-${version}`);
|
|
437
|
+
fs.mkdirSync(home(), { recursive: true });
|
|
438
|
+
const until = Date.now() + lockWaitMs();
|
|
439
|
+
for (;;) {
|
|
440
|
+
try {
|
|
441
|
+
fs.mkdirSync(lock);
|
|
442
|
+
break;
|
|
443
|
+
} catch (err) {
|
|
444
|
+
if (err.code !== "EEXIST") throw err;
|
|
445
|
+
let age = Infinity;
|
|
446
|
+
try {
|
|
447
|
+
age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
448
|
+
} catch {
|
|
449
|
+
continue; // it went away between the failure and the question
|
|
450
|
+
}
|
|
451
|
+
if (age > lockStaleMs()) {
|
|
452
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (Date.now() > until) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
"Another process has been downloading the local engine for too long. " +
|
|
458
|
+
`If nothing is, remove ${lock} and try again.`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
return await run();
|
|
466
|
+
} finally {
|
|
467
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Download to `into`, hashing the bytes as they arrive. Returns the digest.
|
|
473
|
+
*
|
|
474
|
+
* No credential is sent. The address came from the manifest and may be storage belonging to
|
|
475
|
+
* somebody else, so putting this machine's login token in that request would hand it over to
|
|
476
|
+
* whoever serves the file. Whatever authorises the download has to be in the address itself,
|
|
477
|
+
* which is what a signed URL is for.
|
|
478
|
+
*/
|
|
479
|
+
async function downloadTo(url, into) {
|
|
480
|
+
const answer = await fetch(url);
|
|
481
|
+
if (!answer.ok || !answer.body) {
|
|
482
|
+
throw new Error(`Downloading the local engine failed: HTTP ${answer.status}`);
|
|
483
|
+
}
|
|
484
|
+
const hash = crypto.createHash("sha256");
|
|
485
|
+
const file = fs.createWriteStream(into);
|
|
486
|
+
try {
|
|
487
|
+
for await (const chunk of answer.body) {
|
|
488
|
+
hash.update(chunk);
|
|
489
|
+
if (!file.write(chunk)) {
|
|
490
|
+
await new Promise((resolve) => file.once("drain", resolve));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} finally {
|
|
494
|
+
await new Promise((resolve) => file.end(resolve));
|
|
495
|
+
}
|
|
496
|
+
return hash.digest("hex");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The engine, downloading it first if this machine does not have it.
|
|
501
|
+
*
|
|
502
|
+
* Returns the path to the executable. The caller is whatever is about to run a build: not the
|
|
503
|
+
* watcher asking whether a folder is indexed, and not the MCP bridge answering a question from
|
|
504
|
+
* the cloud, because neither of those is a reason to pull 60 MB down somebody's connection.
|
|
505
|
+
*/
|
|
506
|
+
async function ensure({ log = console.log, which = label(), force = false, latest = false } = {}) {
|
|
507
|
+
// With an engine here this asks nobody, unless a person is upgrading (`latest`) or asked for a fresh download
|
|
508
|
+
// (`force`): the watcher comes through here.
|
|
509
|
+
const already = installed();
|
|
510
|
+
if (already && !force && !(latest && !process.env.NIRO_LOCAL_BIN)) return already;
|
|
511
|
+
|
|
512
|
+
const wanted = await manifest({ which });
|
|
513
|
+
const newestHere = newestVersion(which);
|
|
514
|
+
if (newestHere && compareVersions(newestHere, wanted.version) > 0) {
|
|
515
|
+
// Never an older version over a newer one: across a major version every start would be refused, and every
|
|
516
|
+
// install would fetch the older one again.
|
|
517
|
+
log(
|
|
518
|
+
`This machine has the local engine ${newestHere}, newer than the published ${wanted.version}, so it is kept ` +
|
|
519
|
+
`and nothing was fetched. To use ${wanted.version} instead, delete ${path.join(home(), newestHere)} and ` +
|
|
520
|
+
"run niro engine install."
|
|
521
|
+
);
|
|
522
|
+
removeOtherVersions(newestHere);
|
|
523
|
+
return installed(newestHere);
|
|
524
|
+
}
|
|
525
|
+
return withLock(wanted.version, async () => {
|
|
526
|
+
// Asked again under the lock: another process may have finished while this one waited,
|
|
527
|
+
// and unpacking the same tarball over its own folder is the one thing the rename cannot
|
|
528
|
+
// make safe.
|
|
529
|
+
const meanwhile = installed(wanted.version);
|
|
530
|
+
if (meanwhile && force && inUse(wanted.version)) {
|
|
531
|
+
// `--force` unpacks again, but never over the folder a running engine reads from (merge of the plan-page
|
|
532
|
+
// release with the one-version work: deleting it under a running bridge or watcher broke their reads).
|
|
533
|
+
log(
|
|
534
|
+
`The local engine ${wanted.version} is running (the assistant's bridge or the background watcher), so it ` +
|
|
535
|
+
"was not downloaded again. Close them and run niro engine install --force again."
|
|
536
|
+
);
|
|
537
|
+
removeOtherVersions(wanted.version);
|
|
538
|
+
return meanwhile;
|
|
539
|
+
}
|
|
540
|
+
if (meanwhile && !force) {
|
|
541
|
+
removeOtherVersions(wanted.version);
|
|
542
|
+
return meanwhile;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const target = versionDir(wanted.version, which);
|
|
546
|
+
const staging = `${target}.part-${process.pid}`;
|
|
547
|
+
const tarball = path.join(
|
|
548
|
+
home(),
|
|
549
|
+
`niro-local-${which}-${wanted.version}.part-${process.pid}`
|
|
550
|
+
);
|
|
551
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
552
|
+
log(color.gray(`Downloading the local engine ${wanted.version} for ${which}...`));
|
|
553
|
+
try {
|
|
554
|
+
const got = await downloadTo(wanted.url, tarball);
|
|
555
|
+
if (got !== wanted.sha256) {
|
|
556
|
+
// Refused before anything is unpacked, which is the whole reason the digest comes
|
|
557
|
+
// from the service rather than from the download.
|
|
558
|
+
throw new Error(
|
|
559
|
+
"The local engine that arrived is not the one Niro published, so it was not " +
|
|
560
|
+
"installed. Try again, and tell us if it keeps happening."
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
// A checksum against the service's digest, and only that: the engine is not signed, so
|
|
564
|
+
// nothing here may say it is.
|
|
565
|
+
log("Checking SHA-256… ok");
|
|
566
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
567
|
+
// Windows: the tar.exe Windows ships (10 and later), named in full. A GNU tar from Git for Windows
|
|
568
|
+
// earlier on the PATH reads "C:\..." as a remote host and fails.
|
|
569
|
+
const tar = process.platform === "win32" ? require("./windowsPaths").system32("tar.exe") : "tar";
|
|
570
|
+
const untarred = spawnSync(tar, ["-xzf", tarball, "-C", staging, "--strip-components", "1"], {
|
|
571
|
+
encoding: "utf8",
|
|
572
|
+
});
|
|
573
|
+
if (untarred.status !== 0) {
|
|
574
|
+
throw new Error(`Unpacking the local engine failed: ${unpackProblem(untarred)}`);
|
|
575
|
+
}
|
|
576
|
+
const binary = path.join(staging, EXECUTABLE);
|
|
577
|
+
if (!fs.existsSync(binary)) {
|
|
578
|
+
throw new Error(`The local engine's archive holds no ${EXECUTABLE}.`);
|
|
579
|
+
}
|
|
580
|
+
fs.chmodSync(binary, 0o755);
|
|
581
|
+
// Atomic: a reader sees the whole folder or none of it. A folder already there is one
|
|
582
|
+
// another process put down while this one worked, and is kept rather than replaced,
|
|
583
|
+
// unless this call was asked to replace it.
|
|
584
|
+
if (force) fs.rmSync(target, { recursive: true, force: true });
|
|
585
|
+
try {
|
|
586
|
+
fs.renameSync(staging, target);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
if (err.code !== "ENOTEMPTY" && err.code !== "EEXIST") throw err;
|
|
589
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
590
|
+
}
|
|
591
|
+
removeOtherVersions(wanted.version);
|
|
592
|
+
return path.join(target, EXECUTABLE);
|
|
593
|
+
} finally {
|
|
594
|
+
fs.rmSync(tarball, { force: true });
|
|
595
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
module.exports = {
|
|
601
|
+
EXECUTABLE,
|
|
602
|
+
startHeld,
|
|
603
|
+
upgradeInstalled,
|
|
604
|
+
home,
|
|
605
|
+
lockStaleMs,
|
|
606
|
+
lockWaitMs,
|
|
607
|
+
MANIFEST_PATH,
|
|
608
|
+
ensure,
|
|
609
|
+
manifestRefusal,
|
|
610
|
+
installed,
|
|
611
|
+
label,
|
|
612
|
+
onThePath,
|
|
613
|
+
unpackProblem,
|
|
614
|
+
resolve,
|
|
615
|
+
manifest,
|
|
616
|
+
manifestUrl,
|
|
617
|
+
versionDir,
|
|
618
|
+
withLock,
|
|
619
|
+
};
|
package/src/lib/folderSync.js
CHANGED
|
@@ -202,7 +202,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
202
202
|
*/
|
|
203
203
|
/** Current nesting depth as spaces. \r-repainted lines bypass withIndent's console wrappers. */
|
|
204
204
|
function nestPad() {
|
|
205
|
-
return
|
|
205
|
+
return require("./nestedOutput").currentPad();
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
function buildManifest(rootDir, opts = {}) {
|
|
@@ -573,6 +573,7 @@ module.exports = {
|
|
|
573
573
|
isConfigName,
|
|
574
574
|
chunk,
|
|
575
575
|
doInitialSync,
|
|
576
|
+
uploadBatchWithRetry,
|
|
576
577
|
aggregateGuard,
|
|
577
578
|
SOURCE_EXTENSIONS,
|
|
578
579
|
STRUCTURAL_FILENAMES,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can the local engine on this machine serve a graph?
|
|
3
|
+
*
|
|
4
|
+
* The engine keeps its graph in one SQLite file in its own home folder, so there is nothing to
|
|
5
|
+
* install or start: the question is only whether that file can be opened (a full disk, a home
|
|
6
|
+
* folder it may not write to). It is put to the engine itself, `niro-local db check`, which
|
|
7
|
+
* prints nothing when it can and says why in its own words when it cannot, so the reasons live
|
|
8
|
+
* in one place rather than in a JavaScript copy that would drift.
|
|
9
|
+
*
|
|
10
|
+
* **What was here before.** The engine's graph was a Memgraph container, many company laptops
|
|
11
|
+
* forbid a container runtime, and this file offered to index in Niro's cloud instead after
|
|
12
|
+
* asking (Niraj, 2026-10-16). With the graph in SQLite that machine no longer exists, and Niraj
|
|
13
|
+
* decided on 2026-10-31 to "drop the cloud offer altogether and remove the docker wording" (D2
|
|
14
|
+
* of niro-local's docs/usecases/sqlite-graph-store.md). A `cloudIndexingApproved` answer an
|
|
15
|
+
* older version saved in ~/.niro/config.json is simply never read again.
|
|
16
|
+
*/
|
|
17
|
+
const localEngine = require("./localEngine");
|
|
18
|
+
const engine = require("./engine");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns `{ ok: true }`, or `{ ok: false, problem }` where `problem` is the engine's own
|
|
22
|
+
* message, or `{ ok: false, missing: true, problem }` when there is no engine here to ask, which
|
|
23
|
+
* is not a problem with the graph and must not be reported as one.
|
|
24
|
+
*/
|
|
25
|
+
async function graphDatabaseUsable() {
|
|
26
|
+
if (!engine.resolve()) {
|
|
27
|
+
return { ok: false, missing: true, problem: "The local engine is not installed on this machine." };
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
await localEngine.run(["db", "check"]);
|
|
31
|
+
return { ok: true };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
// The engine's own stderr when there is any. Its `error:` prefix is for a person reading a
|
|
34
|
+
// command's output and is dropped here, since the caller prints the lines itself.
|
|
35
|
+
const words = (err && err.stderr) || (err && err.message) || String(err);
|
|
36
|
+
return { ok: false, problem: words.replace(/^error:\s*/m, "").trim() };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { graphDatabaseUsable };
|