@niroai/niro 0.3.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.
Files changed (43) hide show
  1. package/README.md +291 -0
  2. package/bin/niro.js +2 -0
  3. package/package.json +41 -0
  4. package/src/commands/_resolveProject.js +142 -0
  5. package/src/commands/add-project.js +190 -0
  6. package/src/commands/add-repo.js +270 -0
  7. package/src/commands/build.js +118 -0
  8. package/src/commands/delete.js +66 -0
  9. package/src/commands/edit.js +601 -0
  10. package/src/commands/info.js +172 -0
  11. package/src/commands/init.js +1166 -0
  12. package/src/commands/login.js +214 -0
  13. package/src/commands/logout.js +33 -0
  14. package/src/commands/mcp.js +40 -0
  15. package/src/commands/projects.js +76 -0
  16. package/src/commands/release.js +175 -0
  17. package/src/commands/remove.js +95 -0
  18. package/src/commands/status.js +75 -0
  19. package/src/commands/takeover.js +204 -0
  20. package/src/commands/watch.js +498 -0
  21. package/src/commands/whoami.js +74 -0
  22. package/src/index.js +293 -0
  23. package/src/lib/agentApi.js +276 -0
  24. package/src/lib/api.js +230 -0
  25. package/src/lib/browser.js +30 -0
  26. package/src/lib/color.js +46 -0
  27. package/src/lib/config.js +38 -0
  28. package/src/lib/credentials.js +73 -0
  29. package/src/lib/folderSync.js +503 -0
  30. package/src/lib/git.js +190 -0
  31. package/src/lib/moduleExcludeSuggestions.js +57 -0
  32. package/src/lib/niroProjectFile.js +76 -0
  33. package/src/lib/pendingRequests.js +99 -0
  34. package/src/lib/prompt.js +118 -0
  35. package/src/lib/resolveContext.js +108 -0
  36. package/src/lib/secret.js +114 -0
  37. package/src/lib/service.js +221 -0
  38. package/src/lib/spinner.js +109 -0
  39. package/src/lib/watchDaemon.js +93 -0
  40. package/src/lib/watchState.js +217 -0
  41. package/src/mcp/server.js +764 -0
  42. package/src/mcp/setup.js +1976 -0
  43. package/src/mcp/teardown.js +673 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * `niro add repo [path]` — register a local folder or remote URL and start syncing.
3
+ *
4
+ * Local folder flow:
5
+ * 1. Detect .git → offer "local changes" vs "remote commits" sync mode
6
+ * 2. Submit folder manifest → server returns include/exclude filter
7
+ * 3. Show filter to user; let them adjust patterns
8
+ * 4. Register repo on server (allocates workspace dir) → get gitId
9
+ * 5. Initial sync: walk included files → batch upload → complete
10
+ * 6. Transition into watch mode (stays in foreground)
11
+ *
12
+ * Remote URL flow:
13
+ * Delegates to existing POST /api/git/remote-repos (same as UI).
14
+ */
15
+ const path = require("path");
16
+ const fs = require("fs");
17
+ const api = require("../lib/api");
18
+ const agentApi = require("../lib/agentApi");
19
+ const watchState = require("../lib/watchState");
20
+ const credentials = require("../lib/credentials");
21
+ const prompt = require("../lib/prompt");
22
+ const git = require("../lib/git");
23
+ const folderSync = require("../lib/folderSync");
24
+ const { startWatch } = require("./watch");
25
+ const color = require("../lib/color");
26
+ const watchDaemon = require("../lib/watchDaemon");
27
+ const { login } = require("./login");
28
+
29
+ async function addRepo(pathArg, opts = {}) {
30
+ const creds = await ensureLoggedIn(opts);
31
+ if (!creds) return;
32
+
33
+ // Backend the CLI is pointed at — scopes watchState so a cached gitId from
34
+ // another stack is never reused here.
35
+ const currentApiUrl = api.currentBaseUrl();
36
+
37
+ const targetRaw = pathArg || ".";
38
+
39
+ // Remote URL?
40
+ if (targetRaw.startsWith("http://") || targetRaw.startsWith("https://") || targetRaw.startsWith("git@")) {
41
+ await addRemoteRepo(targetRaw, opts);
42
+ return;
43
+ }
44
+
45
+ const targetDir = path.isAbsolute(targetRaw) ? path.resolve(targetRaw) : path.resolve(process.cwd(), targetRaw);
46
+ if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
47
+ console.error(color.red(`Not a directory: ${targetDir}`));
48
+ process.exitCode = 1;
49
+ return;
50
+ }
51
+
52
+ // --sync-mode local|remote skips the interactive [1]/[2] sync-type choice.
53
+ // --no-confirm skips any confirmation prompt.
54
+ const syncMode = parseSyncMode(opts.syncMode);
55
+ const noConfirm = opts.confirm === false; // commander sets confirm:false for --no-confirm
56
+
57
+ // Check for existing registration on THIS backend
58
+ const existing = watchState.get(targetDir, currentApiUrl);
59
+ if (existing && existing.gitId) {
60
+ const reuse = noConfirm
61
+ ? true
62
+ : await prompt.confirm(
63
+ ` This folder is already registered (gitId: ${color.dim(existing.gitId)}). Re-sync without re-registering?`,
64
+ true
65
+ );
66
+ if (reuse) {
67
+ await startWatch(targetDir, opts);
68
+ return;
69
+ }
70
+ }
71
+
72
+ const gitInfo = git.inspect(targetDir);
73
+ let isLocalSync = true;
74
+ let branch = opts.branch || null;
75
+
76
+ if (gitInfo.repoRoot) {
77
+ console.log(`\n Detected a git repository.`);
78
+ let choice;
79
+ if (syncMode) {
80
+ choice = syncMode === "remote" ? "2" : "1";
81
+ } else if (noConfirm || prompt.isNonInteractive()) {
82
+ choice = "1"; // default to local sync without prompting
83
+ } else {
84
+ choice = await prompt.ask(
85
+ ` Sync on: [1] local file changes [2] commits to remote`,
86
+ "1"
87
+ );
88
+ }
89
+ if (choice.trim() === "2") {
90
+ // Register as remote repo using the origin URL
91
+ if (!gitInfo.originUrl) {
92
+ console.error(color.red(" No remote origin found. Cannot register as remote repo."));
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ await addRemoteRepo(gitInfo.originUrl, { ...opts, branch: branch || gitInfo.branch });
97
+ return;
98
+ }
99
+ // Local sync: auto-detect branch
100
+ if (!branch && gitInfo.branch) {
101
+ const confirmed = (noConfirm || prompt.isNonInteractive())
102
+ ? true
103
+ : await prompt.confirm(
104
+ ` Detected branch: ${color.cyan(gitInfo.branch)}. Use this?`,
105
+ true
106
+ );
107
+ branch = confirmed ? gitInfo.branch : await prompt.ask(" Branch name", "main");
108
+ }
109
+ }
110
+
111
+ if (!branch) branch = null; // no git, branch not applicable
112
+
113
+ const folderName = path.basename(targetDir);
114
+ const displayName = opts.name
115
+ || ((noConfirm || prompt.isNonInteractive())
116
+ ? folderName
117
+ : await prompt.ask(" Repo display name", folderName));
118
+
119
+ // Build manifest
120
+ process.stdout.write(`\n Scanning ${color.cyan(targetDir)}...`);
121
+ const manifest = folderSync.buildManifest(targetDir);
122
+ process.stdout.write(` ${manifest.length} files found.\n`);
123
+
124
+ // Get server filter
125
+ let gitId;
126
+ let includePatterns;
127
+ let excludePatterns;
128
+
129
+ // Register first (server allocates workspace dir) so we can call /filter
130
+ console.log(` Registering repo with server...`);
131
+ const cliModuleExcludes = Array.isArray(opts.moduleExclude) ? opts.moduleExclude : [];
132
+ const cliFileExcludes = Array.isArray(opts.fileExclude) ? opts.fileExclude : [];
133
+ gitId = await agentApi.registerRepo({
134
+ displayName,
135
+ repositoryUrl: gitInfo.originUrl || `local://${displayName}`,
136
+ branch,
137
+ moduleExcludePatterns: cliModuleExcludes,
138
+ fileExcludePatterns: cliFileExcludes,
139
+ });
140
+ console.log(` gitId: ${color.dim(gitId)}`);
141
+
142
+ // Offer backend-suggested module exclusions (skipped if --defaults or
143
+ // --module-exclude already supplied or stdin is non-interactive).
144
+ const { offerModuleExcludeSuggestions } = require("../lib/moduleExcludeSuggestions");
145
+ await offerModuleExcludeSuggestions(gitId, opts, cliModuleExcludes);
146
+
147
+ // Get filter recommendation
148
+ console.log(` Analyzing project structure...`);
149
+ const filterResult = await agentApi.filterManifest(gitId, manifest);
150
+ const includedCount = filterResult.included_count || filterResult.includedCount || 0;
151
+ const excludedCount = filterResult.excluded_count || filterResult.excludedCount || 0;
152
+
153
+ console.log(`\n ${color.green("Will sync:")} ${includedCount} files`);
154
+ console.log(` ${color.yellow("Excluded:")} ${excludedCount} files`);
155
+
156
+ const suggestedIncludes = filterResult.include_patterns || filterResult.includePatterns || [];
157
+ const suggestedExcludes = filterResult.exclude_patterns || filterResult.excludePatterns || [];
158
+
159
+ if (suggestedIncludes.length > 0) {
160
+ console.log(` Include patterns: ${color.dim(suggestedIncludes.join(", "))}`);
161
+ }
162
+ if (suggestedExcludes.length > 0) {
163
+ console.log(` Exclude patterns: ${color.dim(suggestedExcludes.join(", "))}`);
164
+ }
165
+
166
+ const adjust = (noConfirm || prompt.isNonInteractive())
167
+ ? false
168
+ : await prompt.confirm(" Adjust patterns?", false);
169
+ if (adjust) {
170
+ const addIncludes = await prompt.ask(" Add include patterns (comma-separated, e.g. src/**)", "");
171
+ const addExcludes = await prompt.ask(" Add exclude patterns (comma-separated, e.g. docs/**)", "");
172
+ if (addIncludes) suggestedIncludes.push(...addIncludes.split(",").map(s => s.trim()).filter(Boolean));
173
+ if (addExcludes) suggestedExcludes.push(...addExcludes.split(",").map(s => s.trim()).filter(Boolean));
174
+ }
175
+
176
+ includePatterns = suggestedIncludes;
177
+ excludePatterns = suggestedExcludes;
178
+
179
+ // Determine which files to actually upload
180
+ const includedPaths = (filterResult.included_paths || filterResult.includedPaths || []);
181
+
182
+ // Save state, tagged with the backend that allocated this gitId.
183
+ watchState.set(targetDir, {
184
+ gitId,
185
+ projectId: null,
186
+ branch,
187
+ includePatterns,
188
+ excludePatterns,
189
+ fileHashes: {},
190
+ lastSyncedAt: null,
191
+ }, currentApiUrl);
192
+
193
+ // Push include/exclude patterns to the server so the build pipeline respects them.
194
+ // We use module_path "" (root) since there is no build-file detected module yet.
195
+ if (includePatterns.length > 0 || excludePatterns.length > 0) {
196
+ try {
197
+ await api.put(`/api/git/${encodeURIComponent(gitId)}/module-resolution`, {
198
+ module_path: "",
199
+ include_dirs: includePatterns,
200
+ excluded: excludePatterns,
201
+ });
202
+ } catch (err) {
203
+ console.log(color.yellow(` ⚠ Could not save patterns to server: ${err.message}`));
204
+ }
205
+ }
206
+
207
+ // Initial sync (concurrent + resilient uploader lives in folderSync)
208
+ await folderSync.doInitialSync(gitId, targetDir, includedPaths);
209
+
210
+ // Install / restart the watch daemon so this repo is picked up automatically
211
+ // (shared flow — offers to install PM2 when it's missing, unless --no-confirm
212
+ // promised not to prompt).
213
+ await watchDaemon.ensureWatchDaemon({ skipPrompts: noConfirm });
214
+ }
215
+
216
+ async function addRemoteRepo(url, opts) {
217
+ const noConfirm = opts.confirm === false;
218
+ const branch = opts.branch
219
+ || ((noConfirm || prompt.isNonInteractive()) ? "main" : await prompt.ask(" Branch", "main"));
220
+ const tokenId = opts.tokenId || null;
221
+ const cliModuleExcludes = Array.isArray(opts.moduleExclude) ? opts.moduleExclude : [];
222
+ const cliFileExcludes = Array.isArray(opts.fileExclude) ? opts.fileExclude : [];
223
+
224
+ console.log(` Registering remote repo: ${color.cyan(url)} (branch: ${branch})`);
225
+ const reqBody = { repository_url: url, branch, token_id: tokenId };
226
+ if (cliModuleExcludes.length > 0) reqBody.module_exclude_patterns = cliModuleExcludes;
227
+ if (cliFileExcludes.length > 0) reqBody.file_exclude_patterns = cliFileExcludes;
228
+ const resp = await api.post("/api/git/remote-repos", [reqBody]);
229
+ const rec = Array.isArray(resp) ? resp[0] : resp;
230
+ if (!rec || !rec.gitId) {
231
+ throw new Error(`Failed to register remote repo: ${(rec && rec.status) || "unknown error"}`);
232
+ }
233
+ console.log(` ${color.green("✓")} Remote repo registered. gitId: ${color.dim(rec.gitId)}`);
234
+
235
+ const { offerModuleExcludeSuggestions } = require("../lib/moduleExcludeSuggestions");
236
+ await offerModuleExcludeSuggestions(rec.gitId, opts, cliModuleExcludes);
237
+
238
+ console.log(` Run ${color.cyan("niro add project")} to attach it to a project.`);
239
+ }
240
+
241
+ /** Parse --sync-mode local|remote. Returns the mode, or null if unset. Throws on garbage. */
242
+ function parseSyncMode(v) {
243
+ if (v === undefined || v === null || v === "") return null;
244
+ const s = String(v).trim().toLowerCase();
245
+ if (s === "local" || s === "remote") return s;
246
+ throw new Error(`Invalid --sync-mode value "${v}". Use "local" or "remote".`);
247
+ }
248
+
249
+ function isTokenExpired(token) {
250
+ try {
251
+ const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
252
+ return payload.exp && payload.exp * 1000 < Date.now();
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
257
+
258
+ async function ensureLoggedIn(opts) {
259
+ let creds = credentials.load();
260
+ const expired = creds && creds.token && isTokenExpired(creds.token);
261
+ if (!creds || !creds.token || expired) {
262
+ console.log(expired ? "Session expired — re-authenticating." : "Not logged in — starting login flow.");
263
+ await login({ apiUrl: opts.apiUrl });
264
+ creds = credentials.load();
265
+ if (!creds || !creds.token) return null;
266
+ }
267
+ return creds;
268
+ }
269
+
270
+ module.exports = { addRepo, parseSyncMode };
@@ -0,0 +1,118 @@
1
+ const api = require("../lib/api");
2
+ const credentials = require("../lib/credentials");
3
+ const spinner = require("../lib/spinner");
4
+ const color = require("../lib/color");
5
+ const { resolveProjectId } = require("./_resolveProject");
6
+
7
+ const TERMINAL = new Set(["COMPLETED", "FAILED", "CANCELLED"]);
8
+
9
+ function colorizeStatus(st) {
10
+ if (st === "COMPLETED") return color.green(st);
11
+ if (st === "FAILED" || st === "CANCELLED") return color.red(st);
12
+ if (st === "PROCESSING" || st === "RUNNING") return color.yellow(st);
13
+ if (st === "PENDING") return color.gray(st);
14
+ return st;
15
+ }
16
+
17
+ async function build(projectIdArg, opts = {}) {
18
+ credentials.requireCreds();
19
+ const projectId = await resolveProjectId(projectIdArg, { apiUrl: opts.apiUrl });
20
+ const force = opts.fullRebuild !== false;
21
+ const asJson = opts.json === true;
22
+ await api.post(`/api/graph/${encodeURIComponent(projectId)}/build`, {}, {
23
+ query: { force_full_rebuild: force ? "true" : "false" },
24
+ });
25
+ if (asJson) {
26
+ emitJson({ event: "triggered", project_id: projectId, force_full_rebuild: force });
27
+ } else {
28
+ console.log(`${color.green("✓")} Build triggered for ${color.bold(projectId)}.`);
29
+ }
30
+ if (opts.watch === false) return;
31
+ await watchStatus(projectId, { json: asJson });
32
+ }
33
+
34
+ /** Emit one JSON object per line (NDJSON) so a caller can stream-parse it. */
35
+ function emitJson(obj) {
36
+ process.stdout.write(JSON.stringify(obj) + "\n");
37
+ }
38
+
39
+ async function watchStatus(projectId, opts = {}) {
40
+ if (opts.json) return watchStatusJson(projectId);
41
+ return watchStatusHuman(projectId);
42
+ }
43
+
44
+ /**
45
+ * Machine-readable watch: one {status, log_message} line per change, then a
46
+ * final {event:"final", status, success} summary. No spinner / ANSI.
47
+ */
48
+ async function watchStatusJson(projectId) {
49
+ let lastStatus = null;
50
+ let lastLog = null;
51
+ while (true) {
52
+ let data;
53
+ try {
54
+ data = await api.get(`/api/graph/${encodeURIComponent(projectId)}/status`);
55
+ } catch (err) {
56
+ emitJson({ event: "error", project_id: projectId, message: err.message });
57
+ process.exitCode = 1;
58
+ return;
59
+ }
60
+ const st = data.status || "UNKNOWN";
61
+ const logMessage = data.log_message || data.logMessage || null;
62
+ if (st !== lastStatus || logMessage !== lastLog) {
63
+ emitJson({ event: "status", project_id: projectId, status: st, log_message: logMessage });
64
+ lastStatus = st;
65
+ lastLog = logMessage;
66
+ }
67
+ if (TERMINAL.has(st)) {
68
+ const success = st === "COMPLETED";
69
+ emitJson({ event: "final", project_id: projectId, status: st, success });
70
+ if (!success) process.exitCode = 1;
71
+ return;
72
+ }
73
+ await sleep(5000);
74
+ }
75
+ }
76
+
77
+ async function watchStatusHuman(projectId) {
78
+ const sp = spinner.start(`Waiting for build to start... [${projectId}]`);
79
+ let lastStatus = null;
80
+ let lastLog = null;
81
+ try {
82
+ while (true) {
83
+ let data;
84
+ try {
85
+ data = await api.get(`/api/graph/${encodeURIComponent(projectId)}/status`);
86
+ } catch (err) {
87
+ sp.stop(`Failed to fetch status: ${err.message}`);
88
+ process.exitCode = 1;
89
+ return;
90
+ }
91
+ const st = data.status || "UNKNOWN";
92
+ const logMessage = data.log_message || data.logMessage;
93
+ if (st !== lastStatus || logMessage !== lastLog) {
94
+ const coloredSt = colorizeStatus(st);
95
+ const label = logMessage ? `${coloredSt} — ${logMessage}` : coloredSt;
96
+ sp.update(label);
97
+ lastStatus = st;
98
+ lastLog = logMessage;
99
+ }
100
+ if (TERMINAL.has(st)) {
101
+ const mark = st === "COMPLETED" ? color.green("✓") : color.red("✗");
102
+ const tint = st === "COMPLETED" ? color.green : st === "FAILED" || st === "CANCELLED" ? color.red : (s) => s;
103
+ sp.stop(`${mark} Build ${tint(st.toLowerCase())}.`);
104
+ if (st !== "COMPLETED") process.exitCode = 1;
105
+ return;
106
+ }
107
+ await sleep(5000);
108
+ }
109
+ } finally {
110
+ sp.stop();
111
+ }
112
+ }
113
+
114
+ function sleep(ms) {
115
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
+ }
117
+
118
+ module.exports = { build, watchStatus };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `niro delete [project_id]` — remove a project from the server and (by
3
+ * default) clean up the local `.niro-project` pin so the MCP bridge no longer
4
+ * resolves to a project that is gone.
5
+ *
6
+ * Resolves the target project the same way `status` / `build` do: explicit
7
+ * arg > .niro-project walk-up from cwd. Always prompts for confirmation
8
+ * unless --yes is passed — DELETE is destructive and irreversible, and
9
+ * typing the project name is a cheap guardrail against a wrong tab.
10
+ */
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const api = require("../lib/api");
14
+ const credentials = require("../lib/credentials");
15
+ const prompt = require("../lib/prompt");
16
+ const color = require("../lib/color");
17
+ const { resolveProjectId, readNiroProjectFile } = require("./_resolveProject");
18
+
19
+ async function deleteProject(projectIdArg, opts = {}) {
20
+ credentials.requireCreds();
21
+ const projectId = await resolveProjectId(projectIdArg, { apiUrl: opts.apiUrl });
22
+
23
+ if (!opts.yes) {
24
+ console.log(`${color.yellow("⚠")} About to delete project ${color.bold(projectId)}.`);
25
+ console.log(color.dim(" This removes the project and its graph from the server. Registered repos stay."));
26
+ const typed = await prompt.ask(`Type the project name to confirm`, "");
27
+ if (typed !== projectId) {
28
+ console.log(`${color.red("✗")} Project name did not match — aborted.`);
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+ }
33
+
34
+ try {
35
+ const msg = await api.del(`/api/projects/${encodeURIComponent(projectId)}`);
36
+ console.log(`${color.green("✓")} ${typeof msg === "string" ? msg : `Deleted project ${projectId}.`}`);
37
+ } catch (err) {
38
+ console.error(`${color.red("✗")} Failed to delete: ${err.message}`);
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+
43
+ unpinIfMatches(projectId);
44
+ }
45
+
46
+ function unpinIfMatches(projectId) {
47
+ const pinned = readNiroProjectFile(process.cwd());
48
+ if (pinned !== projectId) return;
49
+ let dir = path.resolve(process.cwd());
50
+ const root = path.parse(dir).root;
51
+ while (dir !== root) {
52
+ const p = path.join(dir, ".niro-project");
53
+ if (fs.existsSync(p)) {
54
+ try {
55
+ fs.unlinkSync(p);
56
+ console.log(` Removed local pin at ${color.dim(p)}`);
57
+ } catch (err) {
58
+ console.log(color.yellow(` Could not remove ${p}: ${err.message}`));
59
+ }
60
+ return;
61
+ }
62
+ dir = path.dirname(dir);
63
+ }
64
+ }
65
+
66
+ module.exports = { deleteProject, unpinIfMatches };