@niroai/niro 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +131 -211
- package/package.json +1 -1
- package/src/commands/ab.js +198 -0
- package/src/commands/add-project.js +11 -11
- package/src/commands/add-repo.js +23 -23
- package/src/commands/build.js +5 -5
- package/src/commands/delete.js +3 -3
- package/src/commands/edit.js +11 -11
- package/src/commands/info.js +2 -2
- package/src/commands/init.js +108 -87
- package/src/commands/login.js +25 -5
- package/src/commands/projects.js +1 -1
- package/src/commands/release.js +7 -7
- package/src/commands/remove.js +5 -5
- package/src/commands/start.js +567 -0
- package/src/commands/takeover.js +19 -19
- package/src/commands/watch.js +35 -35
- package/src/index.js +57 -3
- package/src/lib/folderSync.js +13 -8
- package/src/lib/moduleExcludeSuggestions.js +5 -5
- package/src/lib/nestedOutput.js +49 -0
- package/src/lib/onboardTarget.js +5 -5
- package/src/lib/prompt.js +3 -0
- package/src/lib/service.js +20 -12
- package/src/lib/spinner.js +28 -7
- package/src/lib/watchDaemon.js +9 -9
- package/src/mcp/setup.js +198 -160
- package/src/mcp/teardown.js +53 -53
package/src/commands/release.js
CHANGED
|
@@ -141,11 +141,11 @@ async function release(pathArg, opts = {}) {
|
|
|
141
141
|
} catch { /* can't inspect — don't block on it */ }
|
|
142
142
|
}
|
|
143
143
|
if (stillDiverged.length) {
|
|
144
|
-
console.log(warn(`\n
|
|
144
|
+
console.log(warn(`\n ⚠ This temporary project still covers in-flight work:`));
|
|
145
145
|
for (const d of stillDiverged) {
|
|
146
146
|
console.log(warn(` ${d.folder} is on \`${d.branch}\` (source: \`${d.sourceBranch}\`)`));
|
|
147
147
|
}
|
|
148
|
-
console.log(warn("
|
|
148
|
+
console.log(warn(" Discarding deletes the WHOLE temporary project — those repos lose their coverage" +
|
|
149
149
|
" until you run `niro new-temp-project` there again (local code is untouched)."));
|
|
150
150
|
if (!opts.yes) {
|
|
151
151
|
if (!process.stdout.isTTY || prompt.isNonInteractive()) {
|
|
@@ -233,25 +233,25 @@ async function release(pathArg, opts = {}) {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
if (alreadyGone) {
|
|
236
|
-
console.log(`\n
|
|
236
|
+
console.log(`\n ${ok("✓")} Temporary project ${color.bold(tempProjectId)} was already gone from the server` +
|
|
237
237
|
color.dim(" (deleted from the UI, or reaped after sitting idle)."));
|
|
238
238
|
console.log(repinned.length
|
|
239
239
|
? ` ${ok("✓")} Cleaned up ${repinned.length} local registration(s) that still pointed at it. ` +
|
|
240
240
|
"Niro routes back to the original project automatically."
|
|
241
241
|
: ` ${ok("✓")} Nothing left to clean up locally.`);
|
|
242
242
|
} else {
|
|
243
|
-
console.log(`\n
|
|
243
|
+
console.log(`\n ${ok("✓")} Temporary project discarded. Niro routes back to the original project automatically.`);
|
|
244
244
|
}
|
|
245
245
|
if (healed.length) {
|
|
246
|
-
console.log(color.dim("
|
|
246
|
+
console.log(color.dim(" Healed old-style pins:"));
|
|
247
247
|
for (const h of healed) console.log(color.dim(` ${h.folder} → ${h.to}`));
|
|
248
248
|
}
|
|
249
249
|
if (failed.length) {
|
|
250
|
-
console.log(warn(`\n
|
|
250
|
+
console.log(warn(`\n ⚠ ${failed.length} folder(s) could not be restored locally (the temporary project` +
|
|
251
251
|
" is already gone server-side):"));
|
|
252
252
|
for (const f of failed) console.log(color.dim(` ${f.folder} — ${f.error}`));
|
|
253
253
|
}
|
|
254
|
-
console.log(color.dim("
|
|
254
|
+
console.log(color.dim(" The original project picks up your merged commits via its normal remote polling."));
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
module.exports = { release };
|
package/src/commands/remove.js
CHANGED
|
@@ -71,9 +71,9 @@ async function remove(opts = {}) {
|
|
|
71
71
|
const expected = alias ? `"${alias}" or ${projectId}` : projectId;
|
|
72
72
|
console.error(`${color.red("✗")} Refusing to delete: --confirm-name must equal the project's name (${expected}).`);
|
|
73
73
|
if (confirm == null) {
|
|
74
|
-
console.error(color.dim("
|
|
74
|
+
console.error(color.dim(" Pass --confirm-name <name> to confirm this destructive delete."));
|
|
75
75
|
} else {
|
|
76
|
-
console.error(color.dim(`
|
|
76
|
+
console.error(color.dim(` You passed --confirm-name "${confirm}", which does not match.`));
|
|
77
77
|
}
|
|
78
78
|
process.exitCode = 1;
|
|
79
79
|
return;
|
|
@@ -86,9 +86,9 @@ async function remove(opts = {}) {
|
|
|
86
86
|
|
|
87
87
|
function printUsage() {
|
|
88
88
|
console.error("niro remove — remove a project or a repo mapping. Exactly one mode required:");
|
|
89
|
-
console.error("
|
|
90
|
-
console.error("
|
|
91
|
-
console.error("
|
|
89
|
+
console.error(" --project delete the whole project (DESTRUCTIVE)");
|
|
90
|
+
console.error(" requires --confirm-name <project alias or id>");
|
|
91
|
+
console.error(" --unmap-repo <gitId> remove one repo from the project (non-destructive)");
|
|
92
92
|
console.error("Target project: --target <alias|id>, or a .niro-project pin in this folder.");
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro start` — the one command a new user runs.
|
|
3
|
+
*
|
|
4
|
+
* A thin ORCHESTRATOR over the commands that already exist. It deliberately owns no
|
|
5
|
+
* onboarding logic of its own: a second implementation of project creation or env
|
|
6
|
+
* scanning would drift from init.js within a release. Every step below either calls an
|
|
7
|
+
* existing command module or asks a question.
|
|
8
|
+
*
|
|
9
|
+
* 0. Beta gate — access code, asked BEFORE anything is installed or contacted.
|
|
10
|
+
* 1. Bootstrap — install globally and re-exec when running from the npx cache.
|
|
11
|
+
* 2. Sign in — login.js (device flow), skipped when credentials exist.
|
|
12
|
+
* 3. Choose folder — show what was detected, confirm, re-ask on a refusal.
|
|
13
|
+
* 4. Onboard — init.js, unchanged.
|
|
14
|
+
* 5. Connect — mcp.js install(), skipped when this machine already did it.
|
|
15
|
+
*
|
|
16
|
+
* Why step 0 precedes step 1: the beta needs an access code and a work email, so
|
|
17
|
+
* someone without a code cannot get an account. Asking first costs one prompt and
|
|
18
|
+
* saves them installing software they cannot use. Nothing touches their machine
|
|
19
|
+
* before they answer.
|
|
20
|
+
*
|
|
21
|
+
* Why step 1 exists at all: service.js registers the watch daemon with PM2 using
|
|
22
|
+
* `process.argv[1]`. Under npx that path lives in ~/.npm/_npx/<hash>/, which npm
|
|
23
|
+
* garbage-collects on its own schedule, so a watcher registered from an npx run would
|
|
24
|
+
* look healthy and then stop syncing days later with no error anywhere. npx is the
|
|
25
|
+
* bootstrapper, not the way to run Niro.
|
|
26
|
+
*/
|
|
27
|
+
const path = require("path");
|
|
28
|
+
const fs = require("fs");
|
|
29
|
+
// Required as a namespace, not destructured, so tests can substitute spawnSync
|
|
30
|
+
// without shelling out to npm or re-execing a real binary.
|
|
31
|
+
const childProcess = require("child_process");
|
|
32
|
+
|
|
33
|
+
const color = require("../lib/color");
|
|
34
|
+
const prompt = require("../lib/prompt");
|
|
35
|
+
const credentials = require("../lib/credentials");
|
|
36
|
+
const config = require("../lib/config");
|
|
37
|
+
const git = require("../lib/git");
|
|
38
|
+
const onboardTarget = require("../lib/onboardTarget");
|
|
39
|
+
|
|
40
|
+
const step = (n) => color.bold(color.cyan(n));
|
|
41
|
+
const ok = (s) => color.green(s);
|
|
42
|
+
const warn = (s) => color.yellow(s);
|
|
43
|
+
const fail = (s) => color.red(s);
|
|
44
|
+
|
|
45
|
+
const TOTAL_STEPS = 5;
|
|
46
|
+
const PACKAGE_NAME = "@niroai/niro";
|
|
47
|
+
|
|
48
|
+
/** Offered in the welcome screen and again at the end. Not shown to returning users. */
|
|
49
|
+
const BOOK_A_CALL = "https://tidycal.com/nirajaswani/45-minutes-meeting-mpy606v";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Private-beta copy. There is no endpoint that reports whether the gate is on, and
|
|
53
|
+
* adding one to serve a first prompt is not worth the round-trip, so this is a local
|
|
54
|
+
* constant. DELETE THIS BLOCK (and betaGate below) when the beta ends — see
|
|
55
|
+
* docs/usecases/first-run-wizard.md.
|
|
56
|
+
*/
|
|
57
|
+
const BETA = {
|
|
58
|
+
on: true,
|
|
59
|
+
// The marketing site, NOT the app: niro.ai, not the niroai.dev hosts everything else in
|
|
60
|
+
// this CLI talks to. Confirmed by the user 2026-08-27 — it cannot be derived from anything
|
|
61
|
+
// in this repo, so it must not be guessed at from the backend hostnames.
|
|
62
|
+
requestUrl: "https://www.niro.ai",
|
|
63
|
+
requestAction: '"Request Access"', // the button on that home page
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Set on the re-exec so the installed copy can never bootstrap a second time. */
|
|
67
|
+
const BOOTSTRAP_ENV = "NIRO_START_BOOTSTRAPPED";
|
|
68
|
+
|
|
69
|
+
async function start(pathArg, opts = {}) {
|
|
70
|
+
if (prompt.isNonInteractive()) {
|
|
71
|
+
console.error(fail("`niro start` is interactive and there is no terminal to prompt on."));
|
|
72
|
+
console.error(" For scripts and agents use the individual commands, which take flags:");
|
|
73
|
+
console.error(` ${color.cyan("niro login --api-key")}`);
|
|
74
|
+
console.error(` ${color.cyan("niro init --defaults")}`);
|
|
75
|
+
console.error(` ${color.cyan("niro mcp install --client <id>")}`);
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// One decision, read once and used everywhere the wizard treats a newcomer differently:
|
|
81
|
+
// the banner, the welcome screen, the what-is-installed note, the beta gate and the
|
|
82
|
+
// booking offer. Separate checks would eventually disagree with each other.
|
|
83
|
+
const firstRun = !hasCredentials();
|
|
84
|
+
|
|
85
|
+
// True in the process step 1 handed over to. That process has no credentials yet, so it
|
|
86
|
+
// looks like a first run and would replay the welcome screen and ask for the access code a
|
|
87
|
+
// SECOND time. The parent already showed both and already got an answer; repeating them
|
|
88
|
+
// makes the install look like a restart. Observed live: the whole opening printed twice.
|
|
89
|
+
const handedOver = !!process.env[BOOTSTRAP_ENV];
|
|
90
|
+
|
|
91
|
+
console.log(`\n ${color.bold("Niro")} ${color.dim("·")} ${firstRun ? "first-time setup" : "set up another repo"}\n`);
|
|
92
|
+
|
|
93
|
+
if (firstRun && !handedOver) printWelcome();
|
|
94
|
+
|
|
95
|
+
// 0. Beta gate. Skipped for anyone already holding credentials: they plainly have an
|
|
96
|
+
// account, and asking again would be theatre. Also skipped after a hand-over, where it
|
|
97
|
+
// was answered a moment ago in the parent.
|
|
98
|
+
if (!handedOver && !(await betaGate())) return;
|
|
99
|
+
|
|
100
|
+
// 1. Bootstrap. Returns true when it re-execed, meaning this process is done.
|
|
101
|
+
if (await bootstrapIfNpx(pathArg, firstRun)) return;
|
|
102
|
+
|
|
103
|
+
// 2. Sign in.
|
|
104
|
+
if (!(await ensureSignedIn())) return;
|
|
105
|
+
|
|
106
|
+
// 3. Choose the folder.
|
|
107
|
+
const target = await chooseFolder(pathArg);
|
|
108
|
+
if (!target) return;
|
|
109
|
+
|
|
110
|
+
// 4. Onboard. init re-validates the folder itself; it stays the authority.
|
|
111
|
+
console.log(`\n ${step(`4/${TOTAL_STEPS}`)} Onboarding and building the index\n`);
|
|
112
|
+
const initCmd = require("./init");
|
|
113
|
+
// Everything init prints belongs under this step, including what its own helpers print.
|
|
114
|
+
const { withIndent } = require("../lib/nestedOutput");
|
|
115
|
+
await withIndent(4, () => initCmd.init(target, { nested: true }));
|
|
116
|
+
if (process.exitCode) return; // init already explained why
|
|
117
|
+
|
|
118
|
+
// 5. Connect an assistant.
|
|
119
|
+
await connectAssistant();
|
|
120
|
+
|
|
121
|
+
if (firstRun) await offerMeasurement();
|
|
122
|
+
|
|
123
|
+
printNextSteps(firstRun);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The opening screen: what this thing is for, before asking anyone to install it.
|
|
128
|
+
*
|
|
129
|
+
* Four lines, not a tour. Someone who has just typed an unfamiliar command deserves to know
|
|
130
|
+
* what they are about to spend five minutes on, and someone evaluating deserves to know a
|
|
131
|
+
* person is available before they invest that time — which is why the call is offered here
|
|
132
|
+
* and not only at the end.
|
|
133
|
+
*/
|
|
134
|
+
function printWelcome() {
|
|
135
|
+
console.log(" Niro maps how your code actually fits together, so your AI");
|
|
136
|
+
console.log(" assistant answers from that map instead of guessing.\n");
|
|
137
|
+
// "in", not "with": the point is that there is no new tool and no new window, you ask
|
|
138
|
+
// inside the thing already open. "it" is dropped because it read ambiguously against
|
|
139
|
+
// "the assistant" — and without it the line fits on one.
|
|
140
|
+
console.log(` ${color.cyan("·")} Ask anything about your code, in the assistant you already use`);
|
|
141
|
+
console.log(` ${color.cyan("·")} See what breaks before you change it, across services`);
|
|
142
|
+
console.log(` ${color.cyan("·")} Get a standing review of the issues already in your code`);
|
|
143
|
+
console.log(` ${color.cyan("·")} Find bugs' root causes from your alerts`);
|
|
144
|
+
console.log(` ${color.cyan("·")} Measure it: run half your sessions without Niro and compare\n`);
|
|
145
|
+
console.log(" Setting up takes about five minutes.");
|
|
146
|
+
console.log(" Prefer a walkthrough first? 45 minutes with us:");
|
|
147
|
+
console.log(` ${color.cyan(BOOK_A_CALL)}\n`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Offer the A/B measurement window, once, at the end.
|
|
152
|
+
*
|
|
153
|
+
* Not in the opening, even though the capability is claimed there: enabling needs a signed-in
|
|
154
|
+
* account, and asking someone to measure a thing they have not yet seen work is the wrong
|
|
155
|
+
* order. First run only, and it defaults to NO — this is a measurement option, not something
|
|
156
|
+
* to be nudged into. A returning user finds it in the next-steps list instead.
|
|
157
|
+
*
|
|
158
|
+
* Never fails the wizard. The repo IS indexed by this point; a measurement window is a nicety
|
|
159
|
+
* and a network hiccup here must not read as a broken setup.
|
|
160
|
+
*/
|
|
161
|
+
async function offerMeasurement() {
|
|
162
|
+
const abCmd = require("./ab");
|
|
163
|
+
console.log("\n One more thing, entirely optional.\n");
|
|
164
|
+
console.log(` Niro can run ${color.bold("off")} for half your sessions for the next`);
|
|
165
|
+
console.log(` ${abCmd.DEFAULT_WINDOW_DAYS} days, so you can see the difference rather than take`);
|
|
166
|
+
console.log(" our word for it. It applies to your whole account, and");
|
|
167
|
+
console.log(` ${color.cyan("niro ab disable")} stops it at any time.\n`);
|
|
168
|
+
|
|
169
|
+
if (!(await prompt.confirm(" Turn on A/B measurement?", false))) {
|
|
170
|
+
console.log(`\n Left off. Turn it on later with ${color.cyan("niro ab enable")}.`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await abCmd.enable({ yes: true });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.log(`\n ${warn("⚠")} Could not start the measurement window.`);
|
|
177
|
+
console.log(` Your repo is indexed and working. Try ${color.cyan("niro ab enable")} later.`);
|
|
178
|
+
console.log(` (${err && err.message ? err.message : err})`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* What to do now that a repo is indexed.
|
|
184
|
+
*
|
|
185
|
+
* Ordered by when they will need it, not by how impressive it is: one question they can paste
|
|
186
|
+
* straight away, then the three things they will reach for over the next few days. The web app
|
|
187
|
+
* line is where code review and the graph views get named, because those are things you look
|
|
188
|
+
* at rather than ask for.
|
|
189
|
+
*
|
|
190
|
+
* The web address is whatever the server told us at login, never a constant: a self-hosted
|
|
191
|
+
* install has its own, and a wrong link here is worse than none. Omitted when unknown, which
|
|
192
|
+
* is the case for anyone who logged in before the CLI started recording it.
|
|
193
|
+
*/
|
|
194
|
+
function printNextSteps(firstRun) {
|
|
195
|
+
const appUrl = config.load().appUrl;
|
|
196
|
+
|
|
197
|
+
console.log(`\n ${ok("✓ You are set up.")}`);
|
|
198
|
+
console.log("\n Ask this in the assistant you just connected, from this folder:\n");
|
|
199
|
+
console.log(` ${color.bold('"What are the entry points into this repo, and what does each one do?"')}`);
|
|
200
|
+
|
|
201
|
+
console.log("\n When you need them:\n");
|
|
202
|
+
if (appUrl) {
|
|
203
|
+
console.log(` ${color.cyan(appUrl)}`);
|
|
204
|
+
console.log(" the graph, impact views and your code review, in the browser");
|
|
205
|
+
}
|
|
206
|
+
console.log(` ${color.cyan("niro start")} in another repo`);
|
|
207
|
+
console.log(" cross-service answers need more than one repo indexed");
|
|
208
|
+
console.log(` ${color.cyan("niro new-temp-project")}`);
|
|
209
|
+
console.log(" run it once on a branch Niro has not indexed, so answers match your work");
|
|
210
|
+
console.log(` ${color.cyan("niro status")}`);
|
|
211
|
+
console.log(" check the index is current");
|
|
212
|
+
console.log(` ${color.cyan("niro ab")}`);
|
|
213
|
+
console.log(" measure what Niro changes, by running it off half the time");
|
|
214
|
+
|
|
215
|
+
if (firstRun) {
|
|
216
|
+
console.log(`\n Questions, or want us to walk through your codebase with you?`);
|
|
217
|
+
console.log(` 45 minutes with us: ${color.cyan(BOOK_A_CALL)}`);
|
|
218
|
+
}
|
|
219
|
+
console.log("");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* What one `npm install` actually puts on the machine.
|
|
224
|
+
*
|
|
225
|
+
* Printed at the install step, because that is the moment software lands and the honest time
|
|
226
|
+
* to say what it is. Three things arrive from one package and nothing else in the flow says
|
|
227
|
+
* so: the CLI is visible (they just typed it), but the MCP server and the skills are not, and
|
|
228
|
+
* a developer who does not know the skills exist will never think to ask for a rebuild in
|
|
229
|
+
* plain English.
|
|
230
|
+
*
|
|
231
|
+
* First run only, like the welcome screen. Someone setting up their fifth repo knows.
|
|
232
|
+
*/
|
|
233
|
+
function printWhatIsInstalled() {
|
|
234
|
+
console.log(" One package, three parts:\n");
|
|
235
|
+
console.log(` ${color.bold("niro")} the command you are running now: onboard a repo,`);
|
|
236
|
+
console.log(" check status, rebuild, manage projects");
|
|
237
|
+
console.log(` ${color.bold("MCP server")} how your AI assistant reaches Niro's map of your`);
|
|
238
|
+
console.log(" code, so it answers from that instead of guessing");
|
|
239
|
+
console.log(` ${color.bold("two skills")} so you can say \"index this repo\" or \"what breaks`);
|
|
240
|
+
console.log(" if I change this\" instead of remembering commands");
|
|
241
|
+
console.log("");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Step 0. Returns false when the user has no access code, having changed nothing.
|
|
246
|
+
*
|
|
247
|
+
* Deliberately NOT a server capability probe: see BETA above.
|
|
248
|
+
*/
|
|
249
|
+
async function betaGate() {
|
|
250
|
+
if (!BETA.on) return true;
|
|
251
|
+
if (hasCredentials()) return true; // already has an account on this machine
|
|
252
|
+
|
|
253
|
+
console.log(" Niro is in private beta. To create an account you need:");
|
|
254
|
+
console.log(" · an access code (the browser signup form asks for it by that name)");
|
|
255
|
+
console.log(" · a work email address (personal addresses are not accepted yet)\n");
|
|
256
|
+
console.log(` No access code? Click ${BETA.requestAction} at ${color.cyan(BETA.requestUrl)}\n`);
|
|
257
|
+
|
|
258
|
+
const hasCode = await prompt.confirm(" Do you have an access code?", true);
|
|
259
|
+
if (hasCode) return true;
|
|
260
|
+
|
|
261
|
+
console.log(`\n No problem. Open ${color.cyan(BETA.requestUrl)} and click ${BETA.requestAction},`);
|
|
262
|
+
console.log(" then run this again when your code arrives:\n");
|
|
263
|
+
console.log(` ${color.cyan(`npx ${PACKAGE_NAME} start`)}\n`);
|
|
264
|
+
console.log(" Nothing was installed or changed on this machine.\n");
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The same condition credentials.requireCreds() enforces, and deliberately not a second
|
|
270
|
+
* definition of it: any usable credential counts, because the request layer refreshes an
|
|
271
|
+
* expired JWT from the durable cliToken on its own.
|
|
272
|
+
*/
|
|
273
|
+
function hasCredentials() {
|
|
274
|
+
const c = credentials.load();
|
|
275
|
+
return !!(c && (c.token || c.cliToken || c.niroApiKey));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** True when this process is running out of npm's throwaway npx cache. */
|
|
279
|
+
function runningFromNpxCache(argv1 = process.argv[1]) {
|
|
280
|
+
return /[\\/]_npx[\\/]/.test(argv1 || "");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The absolute path of a PERMANENTLY installed `niro`, or null.
|
|
285
|
+
*
|
|
286
|
+
* Deliberately not "is niro on PATH". Under npx, PATH begins with npx's own throwaway cache,
|
|
287
|
+
* so a PATH probe finds THIS process's binary and concludes a global install already exists:
|
|
288
|
+
* the install is skipped, and the hand-over re-execs the same temporary copy. Which is the
|
|
289
|
+
* whole bug step 1 exists to prevent, plus a wizard that restarts from its first screen.
|
|
290
|
+
* Observed for real: `command -v niro` inside npx returns
|
|
291
|
+
* ~/.npm/_npx/<hash>/node_modules/.bin/niro.
|
|
292
|
+
*
|
|
293
|
+
* npm's own global prefix is the only answer that cannot be the cache.
|
|
294
|
+
*/
|
|
295
|
+
function globalNiroPath() {
|
|
296
|
+
const r = childProcess.spawnSync("npm", ["prefix", "-g"], { shell: true, encoding: "utf8" });
|
|
297
|
+
if (r.status !== 0) return null;
|
|
298
|
+
const prefix = String(r.stdout || "").trim();
|
|
299
|
+
if (!prefix) return null;
|
|
300
|
+
const bin = process.platform === "win32"
|
|
301
|
+
? path.join(prefix, "niro.cmd")
|
|
302
|
+
: path.join(prefix, "bin", "niro");
|
|
303
|
+
return fs.existsSync(bin) ? bin : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** True when the installed copy at `bin` is at least as new as this one. */
|
|
307
|
+
function isCurrent(bin) {
|
|
308
|
+
const r = childProcess.spawnSync(bin, ["--version"], { encoding: "utf8" });
|
|
309
|
+
if (r.status !== 0) return false;
|
|
310
|
+
return !isOlder(String(r.stdout || "").trim(), require("../../package.json").version);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** True when semver string `a` is strictly older than `b`. Unparseable counts as older. */
|
|
314
|
+
function isOlder(a, b) {
|
|
315
|
+
const parts = (v) => {
|
|
316
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(v || "");
|
|
317
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
318
|
+
};
|
|
319
|
+
const x = parts(a);
|
|
320
|
+
const y = parts(b);
|
|
321
|
+
if (!x || !y) return true;
|
|
322
|
+
for (let i = 0; i < 3; i++) {
|
|
323
|
+
if (x[i] !== y[i]) return x[i] < y[i];
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Step 1. Install globally and hand over, when and only when this copy came from npx.
|
|
330
|
+
*
|
|
331
|
+
* Returns true if it re-execed (the caller must stop). The global install is NOT
|
|
332
|
+
* prompted for: the alternative is a question whose wrong answer produces a silently
|
|
333
|
+
* dead watch daemon days later.
|
|
334
|
+
*/
|
|
335
|
+
async function bootstrapIfNpx(pathArg, firstRun = false) {
|
|
336
|
+
const handedOver = !!process.env[BOOTSTRAP_ENV];
|
|
337
|
+
if (handedOver || !runningFromNpxCache()) {
|
|
338
|
+
// Nothing to do: either this IS the handed-over child, or niro was installed
|
|
339
|
+
// properly in the first place. Still announced, so the wizard shows all five steps
|
|
340
|
+
// and reads as a finite process rather than an open-ended one.
|
|
341
|
+
console.log(` ${step(`1/${TOTAL_STEPS}`)} ${ok("✓")} niro is installed.\n`);
|
|
342
|
+
if (firstRun && !handedOver) printWhatIsInstalled();
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
console.log(` ${step(`1/${TOTAL_STEPS}`)} Installing niro\n`);
|
|
347
|
+
if (firstRun) printWhatIsInstalled();
|
|
348
|
+
console.log(" Background sync has to outlive npx's temporary cache, so Niro");
|
|
349
|
+
console.log(" installs itself properly first.\n");
|
|
350
|
+
|
|
351
|
+
let bin = globalNiroPath();
|
|
352
|
+
if (bin && isCurrent(bin)) {
|
|
353
|
+
console.log(` ${ok("✓")} Already installed at ${bin}.\n`);
|
|
354
|
+
} else {
|
|
355
|
+
const r = childProcess.spawnSync("npm", ["install", "-g", PACKAGE_NAME], { shell: true, stdio: "inherit" });
|
|
356
|
+
bin = r.status === 0 ? globalNiroPath() : null;
|
|
357
|
+
// Only claim success once the binary is actually THERE. The old wording announced an
|
|
358
|
+
// install that had been skipped, which is how an empty global folder came to look fine.
|
|
359
|
+
if (!bin) {
|
|
360
|
+
console.error(fail("\n ✗ Global install failed."));
|
|
361
|
+
console.error(` Install it yourself, then run ${color.cyan("niro start")}:`);
|
|
362
|
+
console.error(` ${color.cyan(`npm install -g ${PACKAGE_NAME}`)}`);
|
|
363
|
+
process.exitCode = 1;
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
console.log(` ${ok("✓")} ${PACKAGE_NAME} installed at ${bin}.\n`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const args = ["start"];
|
|
370
|
+
if (pathArg) args.push(pathArg);
|
|
371
|
+
// The resolved absolute path, never the bare name: under npx the name resolves back to the
|
|
372
|
+
// cache we are trying to escape.
|
|
373
|
+
const handover = childProcess.spawnSync(bin, args, {
|
|
374
|
+
stdio: "inherit",
|
|
375
|
+
env: { ...process.env, [BOOTSTRAP_ENV]: "1" },
|
|
376
|
+
});
|
|
377
|
+
process.exitCode = handover.status === null ? 1 : handover.status;
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Step 2. Returns false only when login failed. */
|
|
382
|
+
async function ensureSignedIn() {
|
|
383
|
+
if (hasCredentials()) {
|
|
384
|
+
const creds = credentials.load();
|
|
385
|
+
console.log(` ${step(`2/${TOTAL_STEPS}`)} ${ok("✓")} Signed in${creds.email ? ` as ${color.bold(creds.email)}` : ""}.\n`);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
console.log(` ${step(`2/${TOTAL_STEPS}`)} Sign in\n`);
|
|
390
|
+
console.log(" Opening your browser. Sign in there, or create your account");
|
|
391
|
+
console.log(" with your access code.\n");
|
|
392
|
+
console.log(" Creating an account? You will get a verification email. Take");
|
|
393
|
+
console.log(" your time. This keeps waiting.\n");
|
|
394
|
+
|
|
395
|
+
const loginCmd = require("./login");
|
|
396
|
+
await loginCmd.login({});
|
|
397
|
+
if (!hasCredentials()) {
|
|
398
|
+
console.error(fail("\n ✗ Not signed in, so there is nothing to onboard to."));
|
|
399
|
+
console.error(` Run ${color.cyan("niro start")} again when you are ready.`);
|
|
400
|
+
process.exitCode = 1;
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Step 3. Confirm what is about to be uploaded, and keep asking on a refusal.
|
|
408
|
+
*
|
|
409
|
+
* The refusal RULES are not re-implemented here: protectedRootReason and
|
|
410
|
+
* findProjectIndicators are the same functions init.js validates with, so the preview
|
|
411
|
+
* can never disagree with the gate. init.js re-runs the authoritative check.
|
|
412
|
+
*
|
|
413
|
+
* Returns the chosen absolute path, or null if the user gave up.
|
|
414
|
+
*/
|
|
415
|
+
async function chooseFolder(pathArg) {
|
|
416
|
+
let candidate = path.resolve(pathArg || process.cwd());
|
|
417
|
+
|
|
418
|
+
for (;;) {
|
|
419
|
+
console.log(` ${step(`3/${TOTAL_STEPS}`)} Choose what to index\n`);
|
|
420
|
+
|
|
421
|
+
const problem = describeProblem(candidate);
|
|
422
|
+
if (problem) {
|
|
423
|
+
console.log(` ${fail("✗")} ${problem.headline}\n`);
|
|
424
|
+
for (const line of problem.detail) console.log(` ${line}`);
|
|
425
|
+
console.log("");
|
|
426
|
+
const answer = await prompt.ask(" Which folder do you want to index? (blank to give up)");
|
|
427
|
+
if (!answer || !answer.trim()) {
|
|
428
|
+
console.log(`\n Stopped. Nothing was changed. Run ${color.cyan("niro start")} from a project folder.\n`);
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
candidate = path.resolve(expandHome(answer.trim()));
|
|
432
|
+
console.log("");
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
printPreview(candidate);
|
|
437
|
+
if (await prompt.confirm(" Index this folder?", true)) return candidate;
|
|
438
|
+
|
|
439
|
+
const answer = await prompt.ask(" Which folder instead? (blank to give up)");
|
|
440
|
+
if (!answer || !answer.trim()) {
|
|
441
|
+
console.log(`\n Stopped. Nothing was changed.\n`);
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
candidate = path.resolve(expandHome(answer.trim()));
|
|
445
|
+
console.log("");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** A leading ~ is expanded here because a prompt answer never goes through a shell. */
|
|
450
|
+
function expandHome(p) {
|
|
451
|
+
if (p === "~") return require("os").homedir();
|
|
452
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(require("os").homedir(), p.slice(2));
|
|
453
|
+
return p;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Why this folder cannot be onboarded, in the wizard's voice, or null if it can.
|
|
458
|
+
* Mirrors ensureOnboardable's two rules by calling the same functions it calls.
|
|
459
|
+
*/
|
|
460
|
+
function describeProblem(dir) {
|
|
461
|
+
if (!fs.existsSync(dir)) {
|
|
462
|
+
return { headline: `${dir} does not exist.`, detail: ["Check the path and try again."] };
|
|
463
|
+
}
|
|
464
|
+
if (!fs.statSync(dir).isDirectory()) {
|
|
465
|
+
return { headline: `${dir} is a file, not a folder.`, detail: [] };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const reason = onboardTarget.protectedRootReason(dir);
|
|
469
|
+
if (reason) {
|
|
470
|
+
return {
|
|
471
|
+
headline: `${dir} is ${reason}. Niro will not index it.`,
|
|
472
|
+
// Says only what is true. folderSync never descends into a dot directory
|
|
473
|
+
// (folderSync.js walkDir) and only uploads source, build and committed config files
|
|
474
|
+
// (isUploadableName), so keys, documents and photos are never eligible and claiming
|
|
475
|
+
// otherwise is scaremongering. What IS lost is .gitignore: it is only honoured when
|
|
476
|
+
// the onboarded folder is itself a git work tree, and a home folder is not one, so
|
|
477
|
+
// each nested repo's ignored files stop being protected.
|
|
478
|
+
detail: [
|
|
479
|
+
"It would upload source from every repository under it, and lose the",
|
|
480
|
+
".gitignore protection each one has, because the folder you point at",
|
|
481
|
+
"has to be the git repository for that to apply.",
|
|
482
|
+
],
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const { indicators } = onboardTarget.findProjectIndicators(dir);
|
|
487
|
+
if (indicators.size === 0) {
|
|
488
|
+
return {
|
|
489
|
+
headline: `No project found in ${dir}.`,
|
|
490
|
+
detail: [
|
|
491
|
+
"Niro looks for a build file (package.json, pom.xml, go.mod, ...) in",
|
|
492
|
+
"this folder or just below it. None found.",
|
|
493
|
+
`If you really mean this folder, use ${color.cyan("niro init --force")} instead.`,
|
|
494
|
+
],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The screen that prevents the accident: what is about to be uploaded, before it is.
|
|
502
|
+
*
|
|
503
|
+
* Deliberately cheap — no full tree walk. The project scan has already run for the
|
|
504
|
+
* refusal check and git.inspect reads two small files, so this adds nothing the wizard
|
|
505
|
+
* was not doing anyway. init.js does the real manifest walk a moment later.
|
|
506
|
+
*/
|
|
507
|
+
function printPreview(dir) {
|
|
508
|
+
const { roots, indicators } = onboardTarget.findProjectIndicators(dir);
|
|
509
|
+
const info = git.inspect(dir);
|
|
510
|
+
|
|
511
|
+
console.log(` Folder: ${color.bold(dir)}`);
|
|
512
|
+
console.log(` Detected: ${onboardTarget.describeIndicators(indicators)}`);
|
|
513
|
+
console.log(` Projects: ${roots} module${roots === 1 ? "" : "s"}`);
|
|
514
|
+
if (info.repoRoot) {
|
|
515
|
+
console.log(` Git: branch ${info.branch || "(detached)"}${info.originUrl ? `, ${info.originUrl}` : ""}`);
|
|
516
|
+
} else {
|
|
517
|
+
console.log(` Git: ${color.dim("not a git repo, which is fine; it uploads as-is")}`);
|
|
518
|
+
}
|
|
519
|
+
console.log("");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Step 5. `mcp install` prompts for the client itself and resolves the key from the
|
|
524
|
+
* login session, so there is nothing to pass it.
|
|
525
|
+
*
|
|
526
|
+
* "Already connected" is a wizard-local flag rather than a probe of 14 clients' config
|
|
527
|
+
* files: those paths live inside src/mcp/setup.js and are not exported, and a wrong
|
|
528
|
+
* guess here would silently skip the step that makes Niro usable. The flag only ever
|
|
529
|
+
* records what THIS wizard did, and re-running mcp install is harmless anyway.
|
|
530
|
+
*/
|
|
531
|
+
async function connectAssistant() {
|
|
532
|
+
const saved = config.load();
|
|
533
|
+
if (saved.mcpConnectedAt) {
|
|
534
|
+
console.log(`\n ${step(`5/${TOTAL_STEPS}`)} ${ok("✓")} Assistant already connected on this machine.`);
|
|
535
|
+
console.log(` Connect another with ${color.cyan("niro mcp install")}.`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
console.log(`\n ${step(`5/${TOTAL_STEPS}`)} Connect your AI assistant\n`);
|
|
540
|
+
const mcpCmd = require("./mcp");
|
|
541
|
+
try {
|
|
542
|
+
await mcpCmd.install();
|
|
543
|
+
config.save({ mcpConnectedAt: new Date().toISOString() });
|
|
544
|
+
} catch (err) {
|
|
545
|
+
// Never fail the whole wizard here: the repo IS indexed by this point, and the
|
|
546
|
+
// user can retry a single command.
|
|
547
|
+
console.log(`\n ${warn("⚠")} Could not configure an assistant automatically.`);
|
|
548
|
+
console.log(` Run ${color.cyan("niro mcp install")} when you are ready.`);
|
|
549
|
+
console.log(` (${err && err.message ? err.message : err})`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
module.exports = {
|
|
554
|
+
start,
|
|
555
|
+
// exported for tests
|
|
556
|
+
hasCredentials,
|
|
557
|
+
printWelcome,
|
|
558
|
+
printWhatIsInstalled,
|
|
559
|
+
offerMeasurement,
|
|
560
|
+
printNextSteps,
|
|
561
|
+
BOOK_A_CALL,
|
|
562
|
+
runningFromNpxCache,
|
|
563
|
+
isOlder,
|
|
564
|
+
describeProblem,
|
|
565
|
+
expandHome,
|
|
566
|
+
BOOTSTRAP_ENV,
|
|
567
|
+
};
|