@niroai/niro 0.17.0 → 1.0.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 +136 -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 +6 -2
- 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,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background code sharing: while "Help improve Niro" is on, the watcher sends the code of every
|
|
3
|
+
* folder Niro indexes on this machine, redacted here first.
|
|
4
|
+
*
|
|
5
|
+
* **Why it exists.** Until 2026-11-11 only a typed `niro audit-push-repo` sent code, so the Free
|
|
6
|
+
* plan's "no line limit while you share" shared nothing unless the user acted. Niraj, 2026-11-11:
|
|
7
|
+
* "we need the pm2 to share the code as well", then "1. this release 2. no cap but we send what is
|
|
8
|
+
* indexed with niro, okay? so the files and config files considered by niro when indexing 3. yes"
|
|
9
|
+
* (3: a user may exclude a folder while sharing stays on). Decision 01M75JHE64PYYNKR2G4JJ4JCZ3.
|
|
10
|
+
*
|
|
11
|
+
* **What decides whether anything is sent.** All of these, every round, and any doubt is a no:
|
|
12
|
+
*
|
|
13
|
+
* - the licence lease says `share_code: true` and its `expires_at` has not passed. A lease that cannot
|
|
14
|
+
* be fetched or read sends nothing, and so does an expired one (independent check of 31ffc5f,
|
|
15
|
+
* finding 2: an expired lease saying `share_code: true` sent 7 files). A clock running ahead stops
|
|
16
|
+
* sharing, which is the safe direction.
|
|
17
|
+
* Switching sharing on in the console is the consent, so nothing is asked on the machine. It is
|
|
18
|
+
* asked again before each batch (at most once a minute), so switching sharing off stops a long
|
|
19
|
+
* first upload part way, and the next round sends nothing at all;
|
|
20
|
+
* - the folder is indexed by the engine on this machine and still on disk;
|
|
21
|
+
* - the folder is not excluded (`niro share exclude`, shareExclusions.js). Also re-read before each
|
|
22
|
+
* batch. A file under an excluded folder inside an indexed folder is left out of the list
|
|
23
|
+
* (shareFiles.indexedFiles), and is not deleted on the server either: excluding only stops future
|
|
24
|
+
* uploads (Niraj, 2026-11-11, decision 01M75MFAMNACNE0W5Q4GMJF05Q, answer 2).
|
|
25
|
+
*
|
|
26
|
+
* **Which files, and how they are redacted:** shareFiles.js, the same as `niro audit-push-repo`: the
|
|
27
|
+
* engine's own list of what it indexes, masked by the engine with llm-gateway's rules, then author
|
|
28
|
+
* and company names removed by redact.js. Uploads read the masked copies in a private temporary
|
|
29
|
+
* folder, which is deleted whatever happens. No content is ever logged: counts only.
|
|
30
|
+
*
|
|
31
|
+
* **What is sent when.** The first time a folder is shared, its whole indexed file set; after that,
|
|
32
|
+
* only files whose content changed (sha256 of the file on disk), and a delete for files that left
|
|
33
|
+
* the set. No size cap: files go in batches (`NIRO_SHARE_BATCH_FILES`, 200), each its own sync
|
|
34
|
+
* session, which the server merges into the workspace (AgentSyncService.completeSyncSession). An
|
|
35
|
+
* HTTP 409 (the server already holds it) counts as sent.
|
|
36
|
+
*
|
|
37
|
+
* **The record**, `~/.niro/shared-code.json` (0600): per folder, the workspace it went to and, per
|
|
38
|
+
* file, `[sha256, size, mtimeMs]` of what was last handled (sent, or left out for a reason that only
|
|
39
|
+
* a change to the file can undo). Written after every batch with a rename, so a restart resumes where
|
|
40
|
+
* the last completed batch ended and resends nothing that batch held. Bounded: a folder's entry holds
|
|
41
|
+
* only files in its current set, and the entry of a folder gone from disk, no longer indexed, or
|
|
42
|
+
* excluded is removed (an excluded folder is sent whole again if it is included later).
|
|
43
|
+
*
|
|
44
|
+
* The workspace is the one `niro audit-push-repo` uses for the same folder (repoPush.sharedWorkspace,
|
|
45
|
+
* registered with the sharing mark), so the server deletes this code too when sharing is switched off.
|
|
46
|
+
*/
|
|
47
|
+
const crypto = require("crypto");
|
|
48
|
+
const fs = require("fs");
|
|
49
|
+
const os = require("os");
|
|
50
|
+
const path = require("path");
|
|
51
|
+
|
|
52
|
+
const shareExclusions = require("./shareExclusions");
|
|
53
|
+
const shareFiles = require("./shareFiles");
|
|
54
|
+
|
|
55
|
+
/** Files per sync session. Small enough that a restart loses little, large enough to be few requests. */
|
|
56
|
+
const DEFAULT_BATCH_FILES = 200;
|
|
57
|
+
|
|
58
|
+
/** The lease is asked again before a batch when the last answer is older than this. */
|
|
59
|
+
const RECHECK_MS = 60_000;
|
|
60
|
+
|
|
61
|
+
function batchFiles() {
|
|
62
|
+
const value = Number.parseInt(process.env.NIRO_SHARE_BATCH_FILES, 10);
|
|
63
|
+
return Number.isFinite(value) && value > 0 ? value : DEFAULT_BATCH_FILES;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function recordFile() {
|
|
67
|
+
// Read at call time so a test's scratch HOME is honoured.
|
|
68
|
+
return path.join(os.homedir(), ".niro", "shared-code.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loadRecord() {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(fs.readFileSync(recordFile(), "utf8"));
|
|
74
|
+
if (parsed && parsed.version === 1 && parsed.folders && typeof parsed.folders === "object" && !Array.isArray(parsed.folders)) {
|
|
75
|
+
return parsed;
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// None yet, or unreadable: every folder is sent whole again, which resends but loses nothing.
|
|
79
|
+
}
|
|
80
|
+
return { version: 1, folders: {} };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function saveRecord(record) {
|
|
84
|
+
const target = recordFile();
|
|
85
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
86
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
87
|
+
fs.writeFileSync(tmp, JSON.stringify(record) + "\n", { mode: 0o600 });
|
|
88
|
+
fs.renameSync(tmp, target);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Does this lease allow sending code now? `share_code` must be exactly true and `expires_at` (seconds)
|
|
93
|
+
* still ahead of `nowMs`. Anything missing or odd is no.
|
|
94
|
+
*/
|
|
95
|
+
function sharingAllowed(claims, nowMs) {
|
|
96
|
+
if (!claims || claims.share_code !== true) return false;
|
|
97
|
+
const expiresAt = Number(claims.expires_at);
|
|
98
|
+
return Number.isFinite(expiresAt) && expiresAt * 1000 > nowMs;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sha256(file) {
|
|
102
|
+
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** An HTTP 409: the server already holds what was sent. */
|
|
106
|
+
function isConflict(err) {
|
|
107
|
+
return Boolean(err) && (err.status === 409 || /\bHTTP 409\b/.test(String(err.message || "")));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A delete of something the server does not have is done. */
|
|
111
|
+
function isGone(err) {
|
|
112
|
+
return Boolean(err) && (err.status === 404 || err.status === 410);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The folders the engine indexes on this machine that are still on disk, each once. */
|
|
116
|
+
async function indexedFolders(run) {
|
|
117
|
+
const parsed = JSON.parse(await run(["projects", "--json"]));
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
for (const project of parsed.projects || []) {
|
|
120
|
+
for (const w of project.worktrees || []) {
|
|
121
|
+
if (!w || typeof w.path !== "string") continue;
|
|
122
|
+
const folder = path.resolve(w.path);
|
|
123
|
+
if (!seen.has(folder) && fs.existsSync(folder)) seen.add(folder);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...seen];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let running = false;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The masked-copy folders' prefix, in the system's temporary folder. It names this user, so on a
|
|
133
|
+
* machine where several users share `/tmp` one user's leftover is never another's to delete
|
|
134
|
+
* (independent check of 31ffc5f, finding 3).
|
|
135
|
+
*/
|
|
136
|
+
function copiesPrefix() {
|
|
137
|
+
let who;
|
|
138
|
+
try {
|
|
139
|
+
who = typeof process.getuid === "function" ? String(process.getuid()) : os.userInfo().username;
|
|
140
|
+
} catch {
|
|
141
|
+
who = "user";
|
|
142
|
+
}
|
|
143
|
+
return `niro-share-masked-${String(who).replace(/[^A-Za-z0-9_.-]/g, "_")}-`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Remove masked copies an earlier process left behind: a process stopped during an upload (a PM2
|
|
148
|
+
* restart, a reboot) never reaches the `finally` that deletes them. Only one round runs at a time
|
|
149
|
+
* on a machine (the watcher's periodic claim and `running`), so none of these is in use. A leftover
|
|
150
|
+
* that cannot be deleted is skipped with one log line for the round: it holds masked copies only,
|
|
151
|
+
* and it must not stop sharing for good (finding 3: one undeletable folder failed every round).
|
|
152
|
+
*/
|
|
153
|
+
function sweepLeftCopies(log = console.log) {
|
|
154
|
+
let names = [];
|
|
155
|
+
try {
|
|
156
|
+
names = fs.readdirSync(os.tmpdir());
|
|
157
|
+
} catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const prefix = copiesPrefix();
|
|
161
|
+
const stuck = [];
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
if (!name.startsWith(prefix)) continue;
|
|
164
|
+
try {
|
|
165
|
+
fs.rmSync(path.join(os.tmpdir(), name), { recursive: true, force: true });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
stuck.push(`${name} (${(err && err.code) || "error"})`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (stuck.length) {
|
|
171
|
+
log(` Could not delete ${stuck.length} leftover folder(s) of masked copies in ${os.tmpdir()}, skipped: ${stuck[0]}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One round: share what changed in every indexed folder. Returns `{ sent, deleted, off, busy }`.
|
|
177
|
+
* Throws, after trying every folder, when a folder could not be shared, so the watcher says so.
|
|
178
|
+
*/
|
|
179
|
+
async function shareRound({
|
|
180
|
+
currentClaims = (...a) => require("./lease").currentClaims(...a),
|
|
181
|
+
run = (...a) => require("./localEngine").run(...a),
|
|
182
|
+
redactFiles = shareFiles.engineRedact,
|
|
183
|
+
now = Date.now,
|
|
184
|
+
log = console.log,
|
|
185
|
+
} = {}) {
|
|
186
|
+
if (running) return { sent: 0, deleted: 0, busy: true };
|
|
187
|
+
running = true;
|
|
188
|
+
try {
|
|
189
|
+
// The gate. Unknown is no: a lease that cannot be fetched or read sends nothing.
|
|
190
|
+
const claims = await currentClaims().catch(() => null);
|
|
191
|
+
if (!sharingAllowed(claims, now())) return { sent: 0, deleted: 0, off: true };
|
|
192
|
+
|
|
193
|
+
const api = require("./api");
|
|
194
|
+
const agentApi = require("./agentApi");
|
|
195
|
+
const repoPush = require("./repoPush");
|
|
196
|
+
await api.ensureFreshToken();
|
|
197
|
+
|
|
198
|
+
let checkedAt = now();
|
|
199
|
+
const stillOn = async () => {
|
|
200
|
+
if (now() - checkedAt < RECHECK_MS) return true;
|
|
201
|
+
const again = await currentClaims().catch(() => null);
|
|
202
|
+
checkedAt = now();
|
|
203
|
+
return sharingAllowed(again, checkedAt);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
sweepLeftCopies(log);
|
|
207
|
+
const folders = await indexedFolders(run);
|
|
208
|
+
const record = loadRecord();
|
|
209
|
+
// Bounded: a folder gone from disk, no longer indexed, or excluded loses its entry. An exclusion
|
|
210
|
+
// list that cannot be read removes nothing here (nothing is sent either), so fixing it does not
|
|
211
|
+
// send every folder whole again.
|
|
212
|
+
const indexed = new Set(folders);
|
|
213
|
+
const excluded = shareExclusions.read();
|
|
214
|
+
for (const [key, entry] of Object.entries(record.folders)) {
|
|
215
|
+
if (
|
|
216
|
+
!entry ||
|
|
217
|
+
typeof entry.folder !== "string" ||
|
|
218
|
+
!fs.existsSync(entry.folder) ||
|
|
219
|
+
!indexed.has(path.resolve(entry.folder)) ||
|
|
220
|
+
(excluded !== null && shareExclusions.covers(excluded, shareExclusions.canonical(entry.folder)))
|
|
221
|
+
) {
|
|
222
|
+
delete record.folders[key];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const totals = { sent: 0, deleted: 0 };
|
|
227
|
+
const problems = [];
|
|
228
|
+
for (const folder of folders) {
|
|
229
|
+
if (shareExclusions.isExcluded(folder)) continue;
|
|
230
|
+
try {
|
|
231
|
+
const stopped = await shareFolder(folder, {
|
|
232
|
+
record, claims, run, redactFiles, stillOn, totals, api, agentApi, repoPush,
|
|
233
|
+
});
|
|
234
|
+
if (stopped) break;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
problems.push((err && err.message) || String(err));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
saveRecord(record);
|
|
240
|
+
if (problems.length) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`${problems.length} folder(s) could not be shared (${totals.sent} file(s) were sent): ${String(problems[0]).split("\n")[0].slice(0, 200)}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return { ...totals, off: false };
|
|
246
|
+
} finally {
|
|
247
|
+
running = false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Share one folder's changes. Returns true when sharing was switched off part way. */
|
|
252
|
+
async function shareFolder(folder, { record, claims, run, redactFiles, stillOn, totals, agentApi, repoPush }) {
|
|
253
|
+
const listing = await shareFiles.indexedFiles(folder, { run });
|
|
254
|
+
if (!listing.indexed) return false;
|
|
255
|
+
const key = repoPush.keyFor(folder);
|
|
256
|
+
const entry = record.folders[key] || { folder, gitId: null, files: {} };
|
|
257
|
+
record.folders[key] = entry;
|
|
258
|
+
entry.folder = folder;
|
|
259
|
+
|
|
260
|
+
// What is on disk now, and which of it differs from what was last handled.
|
|
261
|
+
const current = new Map();
|
|
262
|
+
let changed = [];
|
|
263
|
+
for (const file of listing.files) {
|
|
264
|
+
const absolute = path.join(folder, file.path);
|
|
265
|
+
let stat;
|
|
266
|
+
try {
|
|
267
|
+
stat = fs.statSync(absolute);
|
|
268
|
+
} catch {
|
|
269
|
+
continue; // gone since the engine listed it
|
|
270
|
+
}
|
|
271
|
+
const before = entry.files[file.path];
|
|
272
|
+
let sha;
|
|
273
|
+
if (Array.isArray(before) && before[1] === stat.size && before[2] === stat.mtimeMs) sha = before[0];
|
|
274
|
+
else sha = sha256(absolute);
|
|
275
|
+
const now = [sha, stat.size, stat.mtimeMs];
|
|
276
|
+
current.set(file.path, now);
|
|
277
|
+
if (!Array.isArray(before) || before[0] !== sha) changed.push({ path: file.path, bytes: stat.size });
|
|
278
|
+
else entry.files[file.path] = now; // same content, newer time: no resend
|
|
279
|
+
}
|
|
280
|
+
// A file under an excluded folder is not "removed": excluding stops future uploads and asks the
|
|
281
|
+
// server to delete nothing (decision 01M75MFAMNACNE0W5Q4GMJF05Q, answer 2). Its entry goes, so it is
|
|
282
|
+
// sent again if the folder is included later.
|
|
283
|
+
const held = new Set(listing.held || []);
|
|
284
|
+
for (const relative of Object.keys(entry.files)) if (held.has(relative)) delete entry.files[relative];
|
|
285
|
+
const removed = Object.keys(entry.files).filter((relative) => !current.has(relative));
|
|
286
|
+
|
|
287
|
+
if (changed.length) {
|
|
288
|
+
let workspace = await repoPush.sharedWorkspace(folder, claims.account_id, shareFiles.manifestOf(changed));
|
|
289
|
+
if (workspace.refused) throw new Error(workspace.refused);
|
|
290
|
+
if (entry.gitId && entry.gitId !== workspace.gitId) {
|
|
291
|
+
// A new workspace (the server deleted the old one, or another account): it holds nothing yet,
|
|
292
|
+
// so the whole set goes to it.
|
|
293
|
+
entry.files = {};
|
|
294
|
+
changed = [...current.keys()].map((relative) => ({ path: relative, bytes: current.get(relative)[1] }));
|
|
295
|
+
workspace = { gitId: workspace.gitId, filtered: await agentApi.filterManifest(workspace.gitId, shareFiles.manifestOf(changed)) };
|
|
296
|
+
}
|
|
297
|
+
entry.gitId = workspace.gitId;
|
|
298
|
+
const filtered = workspace.filtered || {};
|
|
299
|
+
const accepted = new Set(filtered.included_paths || filtered.includedPaths || []);
|
|
300
|
+
const cap = Number(filtered.max_file_size_bytes || filtered.maxFileSizeBytes) || require("./folderSync").PER_FILE_CAP_BYTES;
|
|
301
|
+
// A file the server will not take is handled as it is: tried again only when it changes.
|
|
302
|
+
for (const file of changed) if (!accepted.has(file.path)) entry.files[file.path] = current.get(file.path);
|
|
303
|
+
const toSend = changed.filter((file) => accepted.has(file.path)).map((file) => file.path);
|
|
304
|
+
|
|
305
|
+
const size = batchFiles();
|
|
306
|
+
const context = shareFiles.redactionContext(folder);
|
|
307
|
+
for (let start = 0; start < toSend.length; start += size) {
|
|
308
|
+
if (!(await stillOn())) return true;
|
|
309
|
+
// Exclusions re-read before each batch: one made during a long upload counts from the next batch,
|
|
310
|
+
// for the whole folder and for a folder inside it.
|
|
311
|
+
const excludedNow = shareExclusions.read();
|
|
312
|
+
if (excludedNow === null || shareExclusions.covers(excludedNow, shareExclusions.canonical(folder))) return false;
|
|
313
|
+
const base = shareExclusions.canonical(folder);
|
|
314
|
+
const batch = toSend
|
|
315
|
+
.slice(start, start + size)
|
|
316
|
+
.filter((relative) => !shareExclusions.covers(excludedNow, path.join(base, relative)));
|
|
317
|
+
if (batch.length === 0) continue;
|
|
318
|
+
totals.sent += await sendBatch(folder, entry.gitId, batch, cap, context, { redactFiles, agentApi });
|
|
319
|
+
for (const relative of batch) entry.files[relative] = current.get(relative);
|
|
320
|
+
saveRecord(record);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
for (const relative of removed) {
|
|
325
|
+
if (entry.gitId) {
|
|
326
|
+
try {
|
|
327
|
+
await agentApi.deleteFile(entry.gitId, relative);
|
|
328
|
+
totals.deleted += 1;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if (!isGone(err)) throw err;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
delete entry.files[relative];
|
|
334
|
+
}
|
|
335
|
+
if (removed.length) saveRecord(record);
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Mask, redact and upload one batch as one sync session. Returns how many files were uploaded. */
|
|
340
|
+
async function sendBatch(folder, gitId, relatives, cap, context, { redactFiles, agentApi }) {
|
|
341
|
+
const folderSync = require("./folderSync");
|
|
342
|
+
const maskedDir = fs.mkdtempSync(path.join(os.tmpdir(), copiesPrefix()));
|
|
343
|
+
try {
|
|
344
|
+
const masked = await shareFiles.maskCopies(folder, maskedDir, relatives, context, { redactFiles });
|
|
345
|
+
const sending = masked.files.filter((file) => file.bytes <= cap).map((file) => file.path);
|
|
346
|
+
if (sending.length === 0) return 0;
|
|
347
|
+
const sessionId = await agentApi.startSync(gitId);
|
|
348
|
+
for (const part of folderSync.chunk(sending, folderSync.BATCH_SIZE)) {
|
|
349
|
+
const files = part.map((relativePath) => ({ relativePath, absolutePath: path.join(maskedDir, relativePath) }));
|
|
350
|
+
try {
|
|
351
|
+
await folderSync.uploadBatchWithRetry(gitId, sessionId, files);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
if (!isConflict(err)) throw err;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
await agentApi.completeSync(gitId, sessionId);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
if (!isConflict(err)) throw err;
|
|
360
|
+
}
|
|
361
|
+
return sending.length;
|
|
362
|
+
} finally {
|
|
363
|
+
fs.rmSync(maskedDir, { recursive: true, force: true });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = { shareRound, loadRecord, recordFile, indexedFolders, isConflict, sharingAllowed, copiesPrefix, DEFAULT_BATCH_FILES };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folders this machine never shares, while "Help improve Niro" stays on.
|
|
3
|
+
*
|
|
4
|
+
* Niraj, 2026-11-11, asked whether a user may exclude a folder while sharing stays on: "yes". So
|
|
5
|
+
* `niro share exclude <folder>` writes the folder here, and both ways code leaves the machine read
|
|
6
|
+
* it: the background sharing (shareCode.js) never sends an excluded folder, and the typed
|
|
7
|
+
* `niro audit-push-repo` asks "Send it anyway?" and sends it only on a yes (repoPush.js). `niro plan` lists them.
|
|
8
|
+
*
|
|
9
|
+
* A folder covers every folder inside it, so excluding a parent excludes each checkout under it.
|
|
10
|
+
* A folder inside an indexed folder is excluded too: shareFiles.indexedFiles drops every listed file
|
|
11
|
+
* under it, so the background sharing never sends it and `niro audit-push-repo` sends it only on a yes (independent
|
|
12
|
+
* check of 31ffc5f, finding 1: `niro share exclude fixA/src` said it worked and `src/app.py` was sent).
|
|
13
|
+
* It is matched by path only: another checkout of the same repository somewhere else is not
|
|
14
|
+
* excluded, and `niro share` says so.
|
|
15
|
+
*
|
|
16
|
+
* Kept in `~/.niro/share-excluded.json` (0600), read on every use, so an exclusion made while the
|
|
17
|
+
* watcher runs counts from its next upload. A file that cannot be read is treated as holding no
|
|
18
|
+
* exclusions only when it does not exist: one that exists and will not parse makes `isExcluded`
|
|
19
|
+
* answer yes for every folder, because an unknown answer must not send code (the same rule the
|
|
20
|
+
* lease follows).
|
|
21
|
+
*/
|
|
22
|
+
const fs = require("fs");
|
|
23
|
+
const os = require("os");
|
|
24
|
+
const path = require("path");
|
|
25
|
+
|
|
26
|
+
/** A folder as the engine names it: links followed when it exists, so `/tmp/x` and its real path match. */
|
|
27
|
+
function canonical(folder) {
|
|
28
|
+
const resolved = path.resolve(folder);
|
|
29
|
+
try {
|
|
30
|
+
return fs.realpathSync(resolved);
|
|
31
|
+
} catch {
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function file() {
|
|
37
|
+
// Read at call time, not load time, so a test's scratch HOME is honoured.
|
|
38
|
+
return path.join(os.homedir(), ".niro", "share-excluded.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The excluded folders, resolved, or null when the file exists and cannot be read. */
|
|
42
|
+
function read() {
|
|
43
|
+
let text;
|
|
44
|
+
try {
|
|
45
|
+
text = fs.readFileSync(file(), "utf8");
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err && err.code === "ENOENT") return [];
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(text);
|
|
52
|
+
if (!parsed || !Array.isArray(parsed.folders)) return null;
|
|
53
|
+
return parsed.folders.filter((f) => typeof f === "string").map(canonical);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The excluded folders, for showing. An unreadable file shows as none and is said by the caller. */
|
|
60
|
+
function list() {
|
|
61
|
+
return read() || [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function write(folders) {
|
|
65
|
+
const target = file();
|
|
66
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
67
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
68
|
+
fs.writeFileSync(tmp, JSON.stringify({ folders }, null, 2) + "\n", { mode: 0o600 });
|
|
69
|
+
fs.renameSync(tmp, target);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Does one of `folders` (resolved, from `read`) hold `resolved`, or is it `resolved` itself? */
|
|
73
|
+
function covers(folders, resolved) {
|
|
74
|
+
return folders.some((f) => resolved === f || resolved.startsWith(f.endsWith(path.sep) ? f : f + path.sep));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Is `folder` excluded, itself or through a folder above it? Unknown (an unreadable file) is yes. */
|
|
78
|
+
function isExcluded(folder) {
|
|
79
|
+
const folders = read();
|
|
80
|
+
if (folders === null) return true;
|
|
81
|
+
return covers(folders, canonical(folder));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The list to change. An unreadable file is not overwritten: the folders it held would start being
|
|
86
|
+
* shared again without anybody choosing that.
|
|
87
|
+
*/
|
|
88
|
+
function readable() {
|
|
89
|
+
const folders = read();
|
|
90
|
+
if (folders === null) {
|
|
91
|
+
throw new Error(`The list of folders not shared (${file()}) cannot be read. Fix it or delete it, then try again. Until then no folder is shared.`);
|
|
92
|
+
}
|
|
93
|
+
return folders;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Add a folder. Returns false when it was already excluded by name. */
|
|
97
|
+
function exclude(folder) {
|
|
98
|
+
const resolved = canonical(folder);
|
|
99
|
+
const folders = readable();
|
|
100
|
+
if (folders.includes(resolved)) return false;
|
|
101
|
+
write([...folders, resolved].sort());
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Remove a folder. Returns false when it was not excluded by that name. */
|
|
106
|
+
function include(folder) {
|
|
107
|
+
const resolved = canonical(folder);
|
|
108
|
+
const folders = readable();
|
|
109
|
+
if (!folders.includes(resolved)) return false;
|
|
110
|
+
write(folders.filter((f) => f !== resolved));
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { list, isExcluded, covers, exclude, include, file, read, canonical };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files of a folder may be shared, and how each one is redacted before it leaves.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the two ways a folder's code leaves this machine: the background sharing in the watcher
|
|
5
|
+
* (shareCode.js) and the typed `niro audit-push-repo` (repoPush.js). One place, so the two can never
|
|
6
|
+
* send different files or redact them differently.
|
|
7
|
+
*
|
|
8
|
+
* **Which files.** Niraj, 2026-11-11: "we send what is indexed with niro, okay? so the files and
|
|
9
|
+
* config files considered by niro when indexing". The engine says which they are
|
|
10
|
+
* (`niro-local indexed-files`, niro-local `share_files.py`): the source files it parses and the
|
|
11
|
+
* config, build and text files it reads, under its own exclusions (`.gitignore`, `node_modules` and
|
|
12
|
+
* other build output, a real `.env`, keys and key stores, tool token files). niro-cli does not walk
|
|
13
|
+
* the folder by rules of its own, so the two cannot drift.
|
|
14
|
+
*
|
|
15
|
+
* **How each file is redacted.** Exactly what `niro audit-push-repo` and the audit do, nothing new:
|
|
16
|
+
* the engine writes masked copies with llm-gateway's rules (`niro-local redact-files`, constraint
|
|
17
|
+
* 01M6RXWT41PTJ9QNFQBQ80HRVB), then redact.js removes author and company names from each copy, as
|
|
18
|
+
* it does for the audit's flagged methods. A copy whose line count that would change is not sent.
|
|
19
|
+
* Everything is read from the copies in a private temporary folder, never from the repository.
|
|
20
|
+
*/
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
|
|
24
|
+
const { redactSource, linesOf } = require("./redact");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The engine's answer for one folder, less the files under a folder the user excluded:
|
|
28
|
+
* `{ folder, indexed, files: [{ path, bytes }], held: [path], heldFiles: [{ path, bytes }] }`.
|
|
29
|
+
*
|
|
30
|
+
* `held` names the files left out because an excluded folder holds them. `niro share exclude` may
|
|
31
|
+
* name a folder inside an indexed one (`fixA/src`), and only a check of each file's own path keeps
|
|
32
|
+
* that folder's code on the machine (independent check of 31ffc5f, finding 1). The list of
|
|
33
|
+
* exclusions is read once per folder. One that cannot be read holds back every file: unknown is no.
|
|
34
|
+
*
|
|
35
|
+
* `heldFiles` is the same files as `held`, with their sizes. Only the typed `niro audit-push-repo`
|
|
36
|
+
* reads it, and only after the person typing it answered yes to "Send it anyway?" (repoPush.js,
|
|
37
|
+
* Niraj, 2026-11-11). The background sharing reads `files` and `held` and never sends a held file.
|
|
38
|
+
*/
|
|
39
|
+
async function indexedFiles(folder, { run = (...a) => require("./localEngine").run(...a) } = {}) {
|
|
40
|
+
const parsed = JSON.parse(await run(["indexed-files", "--path", folder]));
|
|
41
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.files)) {
|
|
42
|
+
throw new Error("the local engine did not list the folder's files");
|
|
43
|
+
}
|
|
44
|
+
const listed = parsed.files.filter(
|
|
45
|
+
(f) => f && typeof f.path === "string" && f.path && Number.isFinite(Number(f.bytes))
|
|
46
|
+
);
|
|
47
|
+
const shareExclusions = require("./shareExclusions");
|
|
48
|
+
const excluded = shareExclusions.read();
|
|
49
|
+
const base = shareExclusions.canonical(typeof parsed.folder === "string" && parsed.folder ? parsed.folder : folder);
|
|
50
|
+
const files = [];
|
|
51
|
+
const heldFiles = [];
|
|
52
|
+
for (const file of listed) {
|
|
53
|
+
if (excluded === null || shareExclusions.covers(excluded, path.join(base, file.path))) heldFiles.push(file);
|
|
54
|
+
else files.push(file);
|
|
55
|
+
}
|
|
56
|
+
const held = heldFiles.map((file) => file.path);
|
|
57
|
+
return { folder: parsed.folder, indexed: parsed.indexed === true, files, held, heldFiles };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The upload manifest the server's file filter reads, for these files. */
|
|
61
|
+
function manifestOf(files) {
|
|
62
|
+
return files.map((file) => ({ path: file.path, size_bytes: Number(file.bytes), mime_type: "text/plain" }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ask the engine for masked copies of `relatives` (paths under `source`) in the empty folder
|
|
67
|
+
* `target`. Resolves the engine's report: `{ redacted, files: [{ path, bytes, masks }], masks,
|
|
68
|
+
* not_text, missing, refused }`. Rejects when the engine cannot do it.
|
|
69
|
+
*/
|
|
70
|
+
async function engineRedact(source, target, relatives) {
|
|
71
|
+
const out = await require("./localEngine").run(
|
|
72
|
+
["redact-files", "--from", source, "--to", target],
|
|
73
|
+
relatives.join("\n") + "\n"
|
|
74
|
+
);
|
|
75
|
+
return JSON.parse(out);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What redact.js needs to know: the signed-in email and the folder's git remote owner. */
|
|
79
|
+
function redactionContext(folder) {
|
|
80
|
+
let email = null;
|
|
81
|
+
try {
|
|
82
|
+
email = (require("./credentials").load() || {}).email || null;
|
|
83
|
+
} catch {
|
|
84
|
+
// No saved sign-in: the copyright holders are still removed.
|
|
85
|
+
}
|
|
86
|
+
let remoteOwner = null;
|
|
87
|
+
try {
|
|
88
|
+
remoteOwner = require("./auditUpload").remoteOwner(require("./git").inspect(folder).originUrl);
|
|
89
|
+
} catch {
|
|
90
|
+
// No remote: the same.
|
|
91
|
+
}
|
|
92
|
+
return { email, remoteOwner };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Masked, name-free copies of `relatives` in `target`, an empty private folder.
|
|
97
|
+
*
|
|
98
|
+
* Returns the engine's report with `files` narrowed to the copies that are fit to send, each with
|
|
99
|
+
* its size after both passes, plus `linesMoved`: the copies dropped because removing names would
|
|
100
|
+
* have changed their line count. Throws when the engine cannot mask, so nothing unmasked is sent.
|
|
101
|
+
*/
|
|
102
|
+
async function maskCopies(source, target, relatives, ctx, { redactFiles = engineRedact } = {}) {
|
|
103
|
+
const masked = await redactFiles(source, target, relatives);
|
|
104
|
+
if (!masked || !masked.redacted || !Array.isArray(masked.files)) {
|
|
105
|
+
throw new Error("the local engine did not say it masked the files");
|
|
106
|
+
}
|
|
107
|
+
const files = [];
|
|
108
|
+
let linesMoved = 0;
|
|
109
|
+
for (const file of masked.files) {
|
|
110
|
+
const copy = path.join(target, file.path);
|
|
111
|
+
if (!path.resolve(copy).startsWith(path.resolve(target) + path.sep)) continue;
|
|
112
|
+
const text = fs.readFileSync(copy, "utf8");
|
|
113
|
+
const cleaned = redactSource(text, ctx);
|
|
114
|
+
if (cleaned === text) {
|
|
115
|
+
files.push(file);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (linesOf(cleaned).length !== linesOf(text).length) {
|
|
119
|
+
fs.rmSync(copy, { force: true });
|
|
120
|
+
linesMoved += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
fs.writeFileSync(copy, cleaned);
|
|
124
|
+
files.push({ ...file, bytes: Buffer.byteLength(cleaned, "utf8") });
|
|
125
|
+
}
|
|
126
|
+
return { ...masked, files, linesMoved };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { indexedFiles, manifestOf, engineRedact, redactionContext, maskCopies };
|
package/src/lib/spinner.js
CHANGED
|
@@ -31,7 +31,7 @@ const ERASE_LINE = "\r\x1b[2K";
|
|
|
31
31
|
* of an indented step, which is where they were.
|
|
32
32
|
*/
|
|
33
33
|
function pad() {
|
|
34
|
-
return
|
|
34
|
+
return require("./nestedOutput").currentPad();
|
|
35
35
|
}
|
|
36
36
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
37
37
|
|
|
@@ -100,11 +100,15 @@ function start(label, opts = {}) {
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// On the setup rail the frame takes the rail's own column, the way a step marker does,
|
|
104
|
+
// instead of sitting after it.
|
|
105
|
+
const onRail = () => pad().startsWith("│");
|
|
103
106
|
let i = 0;
|
|
104
107
|
const render = () => {
|
|
105
|
-
const frame = FRAMES[i = (i + 1) % FRAMES.length];
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
+
const frame = color.cyan(FRAMES[i = (i + 1) % FRAMES.length]);
|
|
109
|
+
const text = `${onRail() ? label.trim() : label} ${color.dim("(" + elapsed() + "s)")}`;
|
|
110
|
+
const line = onRail() ? `${frame} ${text}` : `${pad()}${frame} ${text}`;
|
|
111
|
+
stream.write(ERASE_LINE + fit(line, (stream.columns || 80) - 1));
|
|
108
112
|
};
|
|
109
113
|
|
|
110
114
|
render();
|
|
@@ -122,7 +126,7 @@ function start(label, opts = {}) {
|
|
|
122
126
|
if (stopped) return;
|
|
123
127
|
stopped = true;
|
|
124
128
|
stream.write(ERASE_LINE);
|
|
125
|
-
if (finalLine) stream.write(pad() + finalLine + "\n");
|
|
129
|
+
if (finalLine) stream.write((onRail() ? `│ ${finalLine.trim()}` : pad() + finalLine) + "\n");
|
|
126
130
|
},
|
|
127
131
|
};
|
|
128
132
|
}
|