@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
package/src/lib/api.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Thin HTTP client for ai-orchestrator REST API.
3
+ * - Injects Authorization: Bearer <jwt> when credentials are loaded.
4
+ * - Unwraps the ApiResponse<T> envelope ({ success, data, error }).
5
+ * - Never logs request/response bodies (may contain tokens or source code).
6
+ */
7
+ const credentials = require("./credentials");
8
+ const config = require("./config");
9
+
10
+ const PROD_API_URL = "https://aiorchestrator.niroai.dev";
11
+
12
+ /**
13
+ * Canonical form of a backend URL for storage/comparison: trim trailing
14
+ * slash(es) and lowercase the scheme+host (the path, if any, keeps its case).
15
+ * Used so a backend tag in watchState compares equal regardless of how the URL
16
+ * was written (env var, config, --api-url) — e.g. "http://Localhost:8095/" and
17
+ * "http://localhost:8095" are the same backend.
18
+ */
19
+ function normalizeUrl(u) {
20
+ const s = String(u || "").trim().replace(/\/+$/, "");
21
+ try {
22
+ const url = new URL(s);
23
+ url.protocol = url.protocol.toLowerCase();
24
+ url.host = url.host.toLowerCase();
25
+ return url.toString().replace(/\/+$/, "");
26
+ } catch {
27
+ return s; // not a parseable URL — compare the trimmed string as-is
28
+ }
29
+ }
30
+
31
+ // CLI (login, device, api-key, repo, build, watch) talks to ai-orchestrator.
32
+ // Resolution: env var, then ~/.niro/config.json, then the prod default.
33
+ const DEFAULT_API_URL = process.env.NIRO_API_URL || config.load().apiUrl || PROD_API_URL;
34
+
35
+ function baseUrl(creds) {
36
+ return (
37
+ (creds && creds.apiUrl) ||
38
+ process.env.NIRO_API_URL ||
39
+ config.load().apiUrl ||
40
+ PROD_API_URL
41
+ );
42
+ }
43
+
44
+ /**
45
+ * The backend the CLI is currently pointed at, normalized. This is the same
46
+ * resolution `request()` uses (credentials → env → config → prod default), so
47
+ * it is the right key for tagging/scoping per-backend local state (watchState).
48
+ */
49
+ function currentBaseUrl() {
50
+ return normalizeUrl(baseUrl(credentials.load()));
51
+ }
52
+
53
+ // De-dupe concurrent refreshes: the watch/auto-sync daemon fires many uploads at once, and when
54
+ // the short-lived JWT expires they would each independently exchange the durable token. A single
55
+ // in-flight refresh promise is shared by all callers so only one /api-key-login round-trip and one
56
+ // credentials write happen per expiry. Node's single thread makes this assignment race-free.
57
+ let inFlightRefresh = null;
58
+
59
+ /**
60
+ * Ensure we have a non-expired JWT to send.
61
+ * - If there is no JWT and no durable cliToken/niroApiKey -> ENOAUTH.
62
+ * - If the JWT is present and not expired -> return it.
63
+ * - Otherwise, exchange the durable cliToken (or legacy niroApiKey) for a
64
+ * fresh short-lived JWT via /api/public/auth/api-key-login, persist it, and
65
+ * return the new token.
66
+ * Never logs request/response bodies (they carry the token/api_key).
67
+ */
68
+ async function ensureFreshToken(opts = {}) {
69
+ const creds = credentials.load();
70
+ const durable = creds && (creds.cliToken || creds.niroApiKey);
71
+ if (!creds || (!creds.token && !durable)) {
72
+ const e = new Error("Not logged in. Run `niro login` first.");
73
+ e.code = "ENOAUTH";
74
+ throw e;
75
+ }
76
+
77
+ if (creds.token && !credentials.isExpired(creds.token)) {
78
+ return creds.token;
79
+ }
80
+
81
+ if (!durable) {
82
+ const e = new Error("Session expired. Run `niro login` to authenticate again.");
83
+ e.code = "ENOAUTH";
84
+ throw e;
85
+ }
86
+
87
+ // Share a single refresh across concurrent callers.
88
+ if (inFlightRefresh) {
89
+ return inFlightRefresh;
90
+ }
91
+ inFlightRefresh = doRefresh(creds, opts.apiUrl || baseUrl(creds)).finally(() => {
92
+ inFlightRefresh = null;
93
+ });
94
+ return inFlightRefresh;
95
+ }
96
+
97
+ async function doRefresh(creds, base) {
98
+ // Use a raw request (no recursive ensureFreshToken) — this endpoint is public
99
+ // and authenticates via the api_key body, not a Bearer header.
100
+ const durable = creds.cliToken || creds.niroApiKey;
101
+ const data = await request("POST", "/api/public/auth/api-key-login", {
102
+ body: { api_key: durable },
103
+ apiUrl: base,
104
+ skipRefresh: true,
105
+ noAuth: true,
106
+ });
107
+
108
+ const newToken = data && (data.token || data.access_token);
109
+ if (!newToken) {
110
+ const e = new Error("Token refresh failed. Run `niro login` to authenticate again.");
111
+ e.code = "ENOAUTH";
112
+ throw e;
113
+ }
114
+
115
+ // Re-load creds before saving so we merge over the freshest on-disk state, not a stale snapshot.
116
+ const latest = credentials.load() || creds;
117
+ credentials.save({
118
+ ...latest,
119
+ token: newToken,
120
+ cliToken: latest.cliToken || creds.cliToken || (data.niro_api_key || data.niroApiKey) || null,
121
+ niroApiKey: data.niro_api_key || data.niroApiKey || latest.niroApiKey || null,
122
+ email: latest.email || data.email || null,
123
+ userId: latest.userId || data.user_id || data.userId || null,
124
+ });
125
+
126
+ return newToken;
127
+ }
128
+
129
+ async function request(method, path, { body, query, apiUrl, token, expectStatus, skipRefresh, noAuth, isRetry } = {}) {
130
+ const creds = credentials.load();
131
+ const base = apiUrl || baseUrl(creds);
132
+ const url = new URL(path.startsWith("/") ? path : `/${path}`, base);
133
+ if (query) {
134
+ for (const [k, v] of Object.entries(query)) {
135
+ if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
136
+ }
137
+ }
138
+
139
+ const headers = { Accept: "application/json" };
140
+ const authToken = noAuth ? null : (token || (creds && creds.token));
141
+ if (authToken) headers.Authorization = `Bearer ${authToken}`;
142
+ if (body !== undefined) headers["Content-Type"] = "application/json";
143
+
144
+ const init = { method, headers };
145
+ if (body !== undefined) init.body = typeof body === "string" ? body : JSON.stringify(body);
146
+
147
+ let res;
148
+ try {
149
+ res = await fetch(url, init);
150
+ } catch (err) {
151
+ const e = new Error(`Network error calling ${method} ${url.pathname}: ${err.message}`);
152
+ e.cause = err;
153
+ throw e;
154
+ }
155
+
156
+ // Transparent one-time JWT refresh: on a 401 that isn't already a retry,
157
+ // refresh via the durable cliToken and replay the request exactly once.
158
+ // Guarded against infinite recursion via isRetry and skipRefresh.
159
+ if (res.status === 401 && !isRetry && !skipRefresh && !token && !noAuth) {
160
+ let fresh;
161
+ try {
162
+ fresh = await ensureFreshToken({ apiUrl: base });
163
+ } catch {
164
+ fresh = null;
165
+ }
166
+ if (fresh) {
167
+ return request(method, path, { body, query, apiUrl, token: fresh, expectStatus, isRetry: true });
168
+ }
169
+ }
170
+
171
+ const contentType = res.headers.get("content-type") || "";
172
+ const raw = await res.text();
173
+ let parsed = null;
174
+ if (contentType.includes("application/json") && raw) {
175
+ try {
176
+ parsed = JSON.parse(raw);
177
+ } catch {
178
+ // fall through — keep raw
179
+ }
180
+ }
181
+
182
+ if (expectStatus && res.status !== expectStatus) {
183
+ const msg = extractError(parsed, raw) || `HTTP ${res.status}`;
184
+ const e = new Error(`${method} ${url.pathname} failed: ${msg}`);
185
+ e.status = res.status;
186
+ e.body = parsed || raw;
187
+ throw e;
188
+ }
189
+
190
+ if (!res.ok) {
191
+ const msg = extractError(parsed, raw) || `HTTP ${res.status}`;
192
+ const e = new Error(`${method} ${url.pathname} failed: ${msg}`);
193
+ e.status = res.status;
194
+ e.body = parsed || raw;
195
+ throw e;
196
+ }
197
+
198
+ if (parsed && typeof parsed === "object" && "success" in parsed) {
199
+ if (parsed.success === false) {
200
+ const e = new Error(parsed.error || `${method} ${url.pathname} returned success=false`);
201
+ e.status = res.status;
202
+ e.body = parsed;
203
+ throw e;
204
+ }
205
+ return parsed.data !== undefined ? parsed.data : parsed;
206
+ }
207
+
208
+ return parsed !== null ? parsed : raw;
209
+ }
210
+
211
+ function extractError(parsed, raw) {
212
+ if (parsed && typeof parsed === "object") {
213
+ if (parsed.error) return typeof parsed.error === "string" ? parsed.error : JSON.stringify(parsed.error);
214
+ if (parsed.message) return parsed.message;
215
+ }
216
+ if (raw && raw.length < 400) return raw;
217
+ return null;
218
+ }
219
+
220
+ module.exports = {
221
+ get: (path, opts) => request("GET", path, opts),
222
+ post: (path, body, opts) => request("POST", path, { ...(opts || {}), body }),
223
+ put: (path, body, opts) => request("PUT", path, { ...(opts || {}), body }),
224
+ del: (path, opts) => request("DELETE", path, opts),
225
+ request,
226
+ ensureFreshToken,
227
+ DEFAULT_API_URL,
228
+ currentBaseUrl,
229
+ normalizeUrl,
230
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cross-platform browser opener. Best-effort — fails silently so the CLI
3
+ * can still print the URL for manual opening.
4
+ */
5
+ const { spawn } = require("child_process");
6
+
7
+ function open(url) {
8
+ const platform = process.platform;
9
+ let cmd, args;
10
+ if (platform === "darwin") {
11
+ cmd = "open";
12
+ args = [url];
13
+ } else if (platform === "win32") {
14
+ cmd = "cmd";
15
+ args = ["/c", "start", "", url];
16
+ } else {
17
+ cmd = "xdg-open";
18
+ args = [url];
19
+ }
20
+ try {
21
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
22
+ child.on("error", () => {});
23
+ child.unref();
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ module.exports = { open };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Zero-dep ANSI color helpers. No chalk, no picocolors — 40 lines of escape
3
+ * codes is cheaper than a dependency and has the same ergonomics at the call
4
+ * site (`color.red("boom")`).
5
+ *
6
+ * Colors are auto-disabled when:
7
+ * - `NO_COLOR` is set (https://no-color.org), or
8
+ * - `FORCE_COLOR=0`, or
9
+ * - stdout is not a TTY (piped to a file or another process).
10
+ *
11
+ * `FORCE_COLOR=1` overrides the TTY check — useful in CI logs that render ANSI
12
+ * but aren't detected as TTYs.
13
+ */
14
+
15
+ const RESET = "\x1b[0m";
16
+
17
+ const CODES = {
18
+ bold: "\x1b[1m",
19
+ dim: "\x1b[2m",
20
+ red: "\x1b[31m",
21
+ green: "\x1b[32m",
22
+ yellow: "\x1b[33m",
23
+ blue: "\x1b[34m",
24
+ magenta: "\x1b[35m",
25
+ cyan: "\x1b[36m",
26
+ gray: "\x1b[90m",
27
+ };
28
+
29
+ function enabled() {
30
+ if (process.env.NO_COLOR) return false;
31
+ if (process.env.FORCE_COLOR === "0") return false;
32
+ if (process.env.FORCE_COLOR === "1") return true;
33
+ return Boolean(process.stdout.isTTY);
34
+ }
35
+
36
+ function wrap(code) {
37
+ return (s) => (enabled() ? `${code}${s}${RESET}` : String(s));
38
+ }
39
+
40
+ const api = {};
41
+ for (const [name, code] of Object.entries(CODES)) {
42
+ api[name] = wrap(code);
43
+ }
44
+ api.enabled = enabled;
45
+
46
+ module.exports = api;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Non-secret app config at ~/.niro/config.json.
3
+ *
4
+ * This is DISTINCT from credentials.json: it holds non-sensitive defaults the
5
+ * user picked during MCP setup (e.g. which backend URLs to use). It is NOT for
6
+ * tokens or API keys — those stay in credentials.json (mode 0600).
7
+ *
8
+ * Shape: { apiUrl, mcpServerUrl }
9
+ * apiUrl — ai-orchestrator base URL used by the CLI (NIRO_API_URL).
10
+ * mcpServerUrl — ai-assistant base URL used by the MCP bridge (NIRO_MCP_SERVER_URL).
11
+ */
12
+ const fs = require("fs");
13
+ const os = require("os");
14
+ const path = require("path");
15
+
16
+ const DIR = path.join(os.homedir(), ".niro");
17
+ const FILE = path.join(DIR, "config.json");
18
+
19
+ /** Returns the parsed config object, or {} if the file is missing or invalid. */
20
+ function load() {
21
+ try {
22
+ const raw = fs.readFileSync(FILE, "utf8");
23
+ const parsed = JSON.parse(raw);
24
+ return parsed && typeof parsed === "object" ? parsed : {};
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ /** Merge `obj` over the existing config and write it back (pretty JSON + newline). */
31
+ function save(obj) {
32
+ const merged = { ...load(), ...(obj || {}) };
33
+ fs.mkdirSync(DIR, { recursive: true });
34
+ fs.writeFileSync(FILE, JSON.stringify(merged, null, 2) + "\n");
35
+ return merged;
36
+ }
37
+
38
+ module.exports = { load, save, FILE };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Credential storage at ~/.niro/credentials.json (mode 0600).
3
+ * Shape: { apiUrl, token, cliToken, niroApiKey, userId, email }
4
+ * `cliToken` is the durable per-device CLI credential (a "niro-cli-" token)
5
+ * returned once by the device-authorization flow. It is used to transparently
6
+ * refresh the short-lived JWT (`token`) without re-running `niro login`.
7
+ * Never log the contents of this file.
8
+ */
9
+ const fs = require("fs");
10
+ const os = require("os");
11
+ const path = require("path");
12
+
13
+ const DIR = path.join(os.homedir(), ".niro");
14
+ const FILE = path.join(DIR, "credentials.json");
15
+
16
+ function load() {
17
+ try {
18
+ const raw = fs.readFileSync(FILE, "utf8");
19
+ return JSON.parse(raw);
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function save(creds) {
26
+ fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
27
+ fs.writeFileSync(FILE, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
28
+ try {
29
+ fs.chmodSync(FILE, 0o600);
30
+ fs.chmodSync(DIR, 0o700);
31
+ } catch {
32
+ // Best-effort on platforms where chmod is a no-op (Windows).
33
+ }
34
+ }
35
+
36
+ function clear() {
37
+ try {
38
+ fs.unlinkSync(FILE);
39
+ return true;
40
+ } catch (err) {
41
+ if (err.code === "ENOENT") return false;
42
+ throw err;
43
+ }
44
+ }
45
+
46
+ function isExpired(token) {
47
+ try {
48
+ const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString());
49
+ return payload.exp && payload.exp * 1000 < Date.now();
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ function requireCreds() {
56
+ const c = load();
57
+ // Only block when there is no usable credential at all. If the JWT is
58
+ // expired but a durable cliToken exists, the request layer will
59
+ // transparently refresh it, so do not throw here.
60
+ if (!c || (!c.token && !c.cliToken && !c.niroApiKey)) {
61
+ const e = new Error("Not logged in. Run `niro login` first.");
62
+ e.code = "ENOAUTH";
63
+ throw e;
64
+ }
65
+ if (isExpired(c.token) && !c.cliToken && !c.niroApiKey) {
66
+ const e = new Error("Session expired. Run `niro login` to authenticate again.");
67
+ e.code = "ENOAUTH";
68
+ throw e;
69
+ }
70
+ return c;
71
+ }
72
+
73
+ module.exports = { load, save, clear, requireCreds, isExpired, FILE };