@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/index.js ADDED
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ const { Command } = require("commander");
3
+
4
+ const pkg = require("../package.json");
5
+
6
+ function main() {
7
+ const program = new Command();
8
+ program
9
+ .name("niro")
10
+ .description("Niro CLI — onboard repos, build indexes, manage projects from the terminal.")
11
+ .version(pkg.version)
12
+ .option("--api-url <url>", "override API base URL (default: $NIRO_API_URL or https://aiorchestrator.niroai.dev)");
13
+
14
+ program
15
+ .command("login")
16
+ .description("Log in to Niro. Default: device authorization in your browser. (--api-key or --password for alternatives.)")
17
+ .option("--api-key", "paste a Niro API key instead of the browser device flow")
18
+ .option("--password", "use email + password instead of the browser device flow")
19
+ .option("--email <email>", "email address (password flow only)")
20
+ .action((opts) => run(async () => {
21
+ const loginCmd = require("./commands/login");
22
+ await loginCmd.login({ ...opts, apiUrl: program.opts().apiUrl });
23
+ }));
24
+
25
+ program
26
+ .command("logout")
27
+ .description("Remove locally stored credentials.")
28
+ .action(() => run(async () => {
29
+ const logoutCmd = require("./commands/logout");
30
+ await logoutCmd.logout();
31
+ }));
32
+
33
+ program
34
+ .command("whoami")
35
+ .description("Print the account currently logged in.")
36
+ .option("--json", "emit machine-readable JSON (includes authed:true|false)")
37
+ .action((opts) => run(async () => {
38
+ const whoami = require("./commands/whoami");
39
+ await whoami.whoami(opts);
40
+ }));
41
+
42
+ program
43
+ .command("init [path]")
44
+ .description("Onboard a local repo end-to-end: create project, register repo, scan env vars, build index.")
45
+ .option("--project-name <name>", "project name for a NEW project (default: derived from repo)")
46
+ .option("--add-to <alias|id>", "add this folder to an EXISTING project instead of creating a new one")
47
+ .option("--auto-build <on|off>", "enable/disable auto-build on file changes without prompting")
48
+ .option("--quiet-period <seconds>", "debounce seconds before an auto-build (used with --auto-build on)")
49
+ .option("--confirm-build <yes|no>", "whether to trigger the initial index build now without prompting")
50
+ .option("--defaults", "accept default values for all env vars without prompting")
51
+ .option("--force", "proceed past cross-project/overlap warnings without confirming")
52
+ .option("--no-watch", "return immediately after triggering the build instead of watching")
53
+ .option(
54
+ "-X, --module-exclude <pattern>",
55
+ "exclude modules matching glob (repeatable, e.g. **/qa/**)",
56
+ collectOption,
57
+ []
58
+ )
59
+ .option(
60
+ "-F, --file-exclude <pattern>",
61
+ "exclude files matching glob across all modules (repeatable, e.g. **/src/test/**)",
62
+ collectOption,
63
+ []
64
+ )
65
+ .action((pathArg, opts) => run(async () => {
66
+ const initCmd = require("./commands/init");
67
+ await initCmd.init(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
68
+ }));
69
+
70
+ // `niro add` subcommand group
71
+ const add = program
72
+ .command("add")
73
+ .description("Add a repo or project.");
74
+
75
+ add
76
+ .command("repo [path]")
77
+ .description("Register a local folder or remote URL and start syncing.")
78
+ .option("--branch <branch>", "branch name (remote repos; local repos auto-detect from .git)")
79
+ .option("--name <name>", "display name for this repo")
80
+ .option("--sync-mode <local|remote>", "sync on local file changes or remote commits (skips the interactive choice)")
81
+ .option("--no-confirm", "skip confirmation prompts (non-interactive)")
82
+ .option("--token-id <id>", "git token ID for private repos")
83
+ .option(
84
+ "-X, --module-exclude <pattern>",
85
+ "exclude modules matching glob (repeatable, e.g. **/qa/**)",
86
+ collectOption,
87
+ []
88
+ )
89
+ .option(
90
+ "-F, --file-exclude <pattern>",
91
+ "exclude files matching glob across all modules (repeatable, e.g. **/src/test/**)",
92
+ collectOption,
93
+ []
94
+ )
95
+ .action((pathArg, opts) => run(async () => {
96
+ const cmd = require("./commands/add-repo");
97
+ await cmd.addRepo(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
98
+ }));
99
+
100
+ add
101
+ .command("project")
102
+ .description("Create a project and map registered repos to it.")
103
+ .option("--no-watch", "return immediately after triggering the build")
104
+ .action((opts) => run(async () => {
105
+ const cmd = require("./commands/add-project");
106
+ await cmd.addProject({ ...opts, apiUrl: program.opts().apiUrl });
107
+ }));
108
+
109
+ program
110
+ .command("info [path]")
111
+ .description("Show what Niro knows about this folder: git info + which project(s) it maps to and their status.")
112
+ .option("--json", "emit machine-readable JSON")
113
+ .action((pathArg, opts) => run(async () => {
114
+ const infoCmd = require("./commands/info");
115
+ await infoCmd.info(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
116
+ }));
117
+
118
+ program
119
+ .command("edit")
120
+ .description("Change an existing project: map/unmap repos, set/list/rescan env vars (incl. choosing which file drives a var or which property files to consider), or rename.")
121
+ .option("--project <alias|id>", "target project (default: .niro-project in this folder)")
122
+ .option("--repo <gitId>", "repo to target for env actions (default: the project's only repo)")
123
+ .option("--add-repo <gitId>", "map a registered repo to the project")
124
+ .option("--remove-repo <gitId>", "unmap a repo from the project (non-destructive)")
125
+ .option("--set-env <KEY=VALUE>", "set an env var (repeatable; value is never printed)", collectOption, [])
126
+ .option("--set-env-from <KEY=FILE>", "adopt the value FILE carries for KEY (repeatable; value is never printed)", collectOption, [])
127
+ .option("--active-files <files>", "comma-separated property files to keep active for env-var defaults; the rest are ignored (default: all active; at least one required)")
128
+ .option("--list-env", "list env var keys + per-file candidates (secret values masked)")
129
+ .option("--rescan-env", "rescan the repo for env vars")
130
+ .option("--alias <newAlias>", "rename the project")
131
+ .option("--json", "emit machine-readable JSON where supported (set-env, list-env)")
132
+ .action((opts) => run(async () => {
133
+ const editCmd = require("./commands/edit");
134
+ await editCmd.edit({ ...opts, apiUrl: program.opts().apiUrl });
135
+ }));
136
+
137
+ program
138
+ .command("remove")
139
+ .description("Remove a whole project (destructive) or unmap a single repo from it.")
140
+ .option("--target <alias|id>", "target project (default: .niro-project in this folder)")
141
+ .option("--project", "delete the WHOLE project (destructive; requires --confirm-name)")
142
+ .option("--unmap-repo <gitId>", "remove one repo from the project (non-destructive)")
143
+ .option("--confirm-name <value>", "required for --project: must equal the project's alias or id")
144
+ .action((opts) => run(async () => {
145
+ const removeCmd = require("./commands/remove");
146
+ await removeCmd.remove({ ...opts, apiUrl: program.opts().apiUrl });
147
+ }));
148
+
149
+ program
150
+ .command("watch [path]")
151
+ .description("Resume watching an already-registered local repo (use after restart).")
152
+ .action((pathArg) => run(async () => {
153
+ const cmd = require("./commands/watch");
154
+ await cmd.watch(pathArg, { apiUrl: program.opts().apiUrl });
155
+ }));
156
+
157
+ program
158
+ .command("new-temp-project [path]")
159
+ .aliases(["sandbox", "takeover"])
160
+ .description("Create a temporary private project for this folder that reflects your branch + local edits, so Niro's answers match your work (teammates unaffected). Remove it with `niro discard-temp-project`.")
161
+ .option("--project <id_or_alias>", "which project to base the temporary project on when the repo maps to several")
162
+ .action((pathArg, opts) => run(async () => {
163
+ const cmd = require("./commands/takeover");
164
+ await cmd.takeover(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
165
+ }));
166
+
167
+ program
168
+ .command("discard-temp-project [path]")
169
+ .aliases(["discard", "release"])
170
+ .description("Discard the temporary project created by `niro new-temp-project`; Niro routes back to the original project automatically.")
171
+ .option("-y, --yes", "skip the confirmation prompt (still prints the in-flight-work warning)")
172
+ .option("--project <temp_project_id>", "the temporary project to discard, when this machine has no local record of it (e.g. created elsewhere)")
173
+ .action((pathArg, opts) => run(async () => {
174
+ const cmd = require("./commands/release");
175
+ await cmd.release(pathArg, { ...opts, apiUrl: program.opts().apiUrl });
176
+ }));
177
+
178
+ program
179
+ .command("status [project_id]")
180
+ .description("Show the current build status for a project.")
181
+ .option("--json", "emit machine-readable JSON")
182
+ .action((projectId, opts) => run(async () => {
183
+ const statusCmd = require("./commands/status");
184
+ await statusCmd.status(projectId, { ...opts, apiUrl: program.opts().apiUrl });
185
+ }));
186
+
187
+ program
188
+ .command("projects")
189
+ .alias("ls")
190
+ .description("List the projects in your account (account-wide; use `niro info` for the current folder).")
191
+ .option("--json", "emit machine-readable JSON")
192
+ .action((opts) => run(async () => {
193
+ const projectsCmd = require("./commands/projects");
194
+ await projectsCmd.projects({ ...opts, apiUrl: program.opts().apiUrl });
195
+ }));
196
+
197
+ program
198
+ .command("build [project_id]")
199
+ .description("Trigger a rebuild and watch it to completion.")
200
+ .option("--no-full-rebuild", "incremental build (default: force full rebuild)")
201
+ .option("--no-watch", "return immediately instead of streaming progress")
202
+ .option("--json", "emit machine-readable JSON status (NDJSON: one object per status change + a final summary)")
203
+ .action((projectId, opts) => run(async () => {
204
+ const buildCmd = require("./commands/build");
205
+ await buildCmd.build(projectId, { ...opts, apiUrl: program.opts().apiUrl });
206
+ }));
207
+
208
+ program
209
+ .command("delete [project_id]")
210
+ .description("Delete a project from the server (destructive). Uses .niro-project if no id is given.")
211
+ .option("-y, --yes", "skip the confirmation prompt")
212
+ .action((projectId, opts) => run(async () => {
213
+ const deleteCmd = require("./commands/delete");
214
+ await deleteCmd.deleteProject(projectId, { ...opts, apiUrl: program.opts().apiUrl });
215
+ }));
216
+
217
+ const svc = program
218
+ .command("service")
219
+ .description("Manage the niro-watch background daemon (auto-sync on login).");
220
+ svc
221
+ .command("install")
222
+ .description("Install niro-watch as a system service that starts automatically on login.")
223
+ .action(() => run(async () => {
224
+ // Shared flow: offers to install PM2 when it's missing (interactive runs).
225
+ const { ensureWatchDaemon } = require("./lib/watchDaemon");
226
+ const result = await ensureWatchDaemon();
227
+ if (!result.ok) process.exitCode = 1;
228
+ }));
229
+ svc
230
+ .command("uninstall")
231
+ .description("Stop niro-watch and remove it from system startup.")
232
+ .action(() => run(async () => {
233
+ const service = require("./lib/service");
234
+ service.uninstall();
235
+ console.log("✓ niro-watch removed.");
236
+ }));
237
+ svc
238
+ .command("status")
239
+ .description("Show niro-watch daemon status.")
240
+ .action(() => run(async () => {
241
+ const service = require("./lib/service");
242
+ service.status();
243
+ }));
244
+
245
+ const mcp = program
246
+ .command("mcp")
247
+ .description("MCP server helpers.");
248
+ mcp
249
+ .command("install")
250
+ .description("Configure your editor(s) to use the Niro MCP server (no API key needed after `niro login`). Flags: --client <id> | --url <mcp-server-url>.")
251
+ .allowUnknownOption(true)
252
+ .action(() => run(async () => {
253
+ const mcpCmd = require("./commands/mcp");
254
+ await mcpCmd.install();
255
+ }));
256
+ mcp
257
+ .command("uninstall")
258
+ .description("Remove the Niro MCP server config install added (reverses `mcp install`). Flags: --client <id>[,<id>...] | --all [-y].")
259
+ .allowUnknownOption(true)
260
+ .action(() => run(async () => {
261
+ const mcpCmd = require("./commands/mcp");
262
+ await mcpCmd.uninstall();
263
+ }));
264
+ mcp
265
+ .command("serve")
266
+ .description("Run the Niro MCP server over stdio (used by your editor's MCP config).")
267
+ .action(() => { require("./commands/mcp").serve(); });
268
+
269
+ program.parseAsync(process.argv).catch((err) => {
270
+ console.error(err && err.message ? err.message : err);
271
+ process.exit(1);
272
+ });
273
+ }
274
+
275
+ function collectOption(value, previous) {
276
+ return Array.isArray(previous) ? [...previous, value] : [value];
277
+ }
278
+
279
+ function run(fn) {
280
+ return Promise.resolve()
281
+ .then(fn)
282
+ .catch((err) => {
283
+ if (err && (err.code === "ENOAUTH" || err.code === "ENOPROJECT" || err.code === "EAMBIGUOUS")) {
284
+ console.error(err.message);
285
+ process.exit(1);
286
+ }
287
+ console.error(err && err.stack ? err.stack.split("\n")[0] : String(err));
288
+ if (err && err.status) console.error(` (HTTP ${err.status})`);
289
+ process.exit(1);
290
+ });
291
+ }
292
+
293
+ main();
@@ -0,0 +1,276 @@
1
+ /**
2
+ * HTTP calls to the AgentSyncController endpoints.
3
+ * All requests use the JWT Bearer token (same as all other CLI commands).
4
+ * File content is never logged.
5
+ */
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const api = require("./api");
9
+
10
+ const DEFAULT_UPLOAD_TIMEOUT_MS = 60000;
11
+
12
+ /** Per-call upload timeout (ms). Overridable via NIRO_UPLOAD_TIMEOUT_MS. */
13
+ function uploadTimeoutMs() {
14
+ const v = Number.parseInt(process.env.NIRO_UPLOAD_TIMEOUT_MS, 10);
15
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_UPLOAD_TIMEOUT_MS;
16
+ }
17
+
18
+ /**
19
+ * fetch() wrapped in an AbortController + timeout so a stalled upload aborts
20
+ * instead of hanging forever. On timeout the abort surfaces as an AbortError;
21
+ * callers translate that into a clear "timed out" message so the caller's retry
22
+ * logic counts it as a failed attempt. Each call gets a fresh controller, so a
23
+ * 401-retry re-arms the timeout.
24
+ *
25
+ * Returns { res, clear }. The timer stays ARMED until the caller invokes
26
+ * clear() — call it only AFTER reading the body (res.json()/res.text()), so a
27
+ * server that returns headers and then stalls the body stream also aborts. On a
28
+ * fetch-level failure the timer is cleared before throwing.
29
+ */
30
+ async function fetchWithTimeout(url, options) {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), uploadTimeoutMs());
33
+ try {
34
+ const res = await fetch(url, { ...options, signal: controller.signal });
35
+ return { res, clear: () => clearTimeout(timer) };
36
+ } catch (err) {
37
+ clearTimeout(timer);
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ function isAbort(err) {
43
+ return err && (err.name === "AbortError" || err.code === "ABORT_ERR");
44
+ }
45
+
46
+ /**
47
+ * Register a new CLI-synced repo. Returns gitId.
48
+ */
49
+ async function registerRepo({ displayName, repositoryUrl, branch, moduleExcludePatterns, fileExcludePatterns }) {
50
+ const body = { display_name: displayName };
51
+ if (repositoryUrl) body.repository_url = repositoryUrl;
52
+ if (branch) body.branch = branch;
53
+ if (moduleExcludePatterns && moduleExcludePatterns.length > 0) {
54
+ body.module_exclude_patterns = moduleExcludePatterns;
55
+ }
56
+ if (fileExcludePatterns && fileExcludePatterns.length > 0) {
57
+ body.file_exclude_patterns = fileExcludePatterns;
58
+ }
59
+ const data = await api.post("/api/agent/repos", body);
60
+ return data.git_id;
61
+ }
62
+
63
+ /**
64
+ * Fetch backend-suggested module exclude patterns for a repo.
65
+ * Returns an array of { pattern, label, module_count, sample_modules }.
66
+ */
67
+ async function getModuleExcludeSuggestions(gitId) {
68
+ return api.get(`/api/git/${encodeURIComponent(gitId)}/module-exclude-suggestions`);
69
+ }
70
+
71
+ /**
72
+ * Replace the module exclude patterns for a repo. The backend re-resolves
73
+ * project structure with the new patterns applied.
74
+ */
75
+ async function updateModuleExcludePatterns(gitId, patterns) {
76
+ return api.put(`/api/git/${encodeURIComponent(gitId)}/module-exclude-patterns`, {
77
+ module_exclude_patterns: patterns,
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Submit a manifest of { path, size_bytes, mime_type } entries.
83
+ * Returns { includedPaths, excludedPaths, includePatterns, excludePatterns }.
84
+ */
85
+ async function filterManifest(gitId, manifest) {
86
+ return api.post(`/api/agent/repos/${encodeURIComponent(gitId)}/filter`, manifest);
87
+ }
88
+
89
+ /**
90
+ * Start a sync session. Returns sessionId.
91
+ */
92
+ async function startSync(gitId) {
93
+ const data = await api.post(`/api/agent/repos/${encodeURIComponent(gitId)}/sync/start`, {});
94
+ return data.session_id;
95
+ }
96
+
97
+ /**
98
+ * Upload a batch of files for the given session.
99
+ * Each file: { relativePath: string, absolutePath: string }
100
+ */
101
+ async function uploadFileBatch(gitId, sessionId, fileBatch) {
102
+ const { FormData, Blob } = await getFormDataImpl();
103
+ const form = new FormData();
104
+
105
+ for (const { relativePath, absolutePath } of fileBatch) {
106
+ const content = fs.readFileSync(absolutePath);
107
+ const blob = new Blob([content]);
108
+ // Set the filename as the relative path so the server can write to the right location
109
+ form.append("files", blob, relativePath);
110
+ }
111
+
112
+ const creds = require("./credentials").load();
113
+ const baseUrl = (creds && creds.apiUrl) || api.DEFAULT_API_URL;
114
+ const url = `${baseUrl}/api/agent/repos/${encodeURIComponent(gitId)}/sync/${encodeURIComponent(sessionId)}/files`;
115
+
116
+ // Ensure a current JWT before building the request (the daemon may have run
117
+ // long enough for the short-lived JWT to expire). Retry once on 401. Each
118
+ // attempt is bounded by a timeout so a stalled connection aborts instead of
119
+ // hanging the upload pool.
120
+ let handle;
121
+ try {
122
+ let authToken = await api.ensureFreshToken();
123
+ handle = await fetchWithTimeout(url, {
124
+ method: "POST",
125
+ headers: {
126
+ Authorization: `Bearer ${authToken}`,
127
+ // Content-Type is set automatically by fetch for FormData
128
+ },
129
+ body: form,
130
+ });
131
+ if (handle.res.status === 401) {
132
+ handle.clear();
133
+ authToken = await api.ensureFreshToken();
134
+ handle = await fetchWithTimeout(url, {
135
+ method: "POST",
136
+ headers: { Authorization: `Bearer ${authToken}` },
137
+ body: form,
138
+ });
139
+ }
140
+ const res = handle.res;
141
+ if (!res.ok) {
142
+ const text = await res.text();
143
+ throw new Error(`Upload batch failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
144
+ }
145
+ const json = await res.json();
146
+ return json.data;
147
+ } catch (err) {
148
+ if (isAbort(err)) {
149
+ throw new Error(`Upload batch timed out after ${uploadTimeoutMs()}ms`);
150
+ }
151
+ throw err;
152
+ } finally {
153
+ if (handle) handle.clear();
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Complete a sync session, triggering atomic move and staleness.
159
+ */
160
+ async function completeSync(gitId, sessionId) {
161
+ return api.post(`/api/agent/repos/${encodeURIComponent(gitId)}/sync/${encodeURIComponent(sessionId)}/complete`, {});
162
+ }
163
+
164
+ /**
165
+ * Incremental single-file upsert.
166
+ */
167
+ async function upsertFile(gitId, relativePath, absolutePath) {
168
+ const { FormData, Blob } = await getFormDataImpl();
169
+ const form = new FormData();
170
+ form.append("path", relativePath);
171
+ const content = fs.readFileSync(absolutePath);
172
+ form.append("file", new Blob([content]), path.basename(relativePath));
173
+
174
+ const creds = require("./credentials").load();
175
+ const baseUrl = (creds && creds.apiUrl) || api.DEFAULT_API_URL;
176
+ const url = `${baseUrl}/api/agent/repos/${encodeURIComponent(gitId)}/files`;
177
+
178
+ // Ensure a current JWT before building the request; retry once on 401 so the
179
+ // watch/auto-sync daemon survives short-lived JWT expiry. Each attempt is
180
+ // bounded by the same upload timeout as batch uploads (a stalled single-file
181
+ // upsert previously had no timeout — a latent hang on the watch path).
182
+ let handle;
183
+ try {
184
+ let authToken = await api.ensureFreshToken();
185
+ handle = await fetchWithTimeout(url, {
186
+ method: "PUT",
187
+ headers: { Authorization: `Bearer ${authToken}` },
188
+ body: form,
189
+ });
190
+ if (handle.res.status === 401) {
191
+ handle.clear();
192
+ authToken = await api.ensureFreshToken();
193
+ handle = await fetchWithTimeout(url, {
194
+ method: "PUT",
195
+ headers: { Authorization: `Bearer ${authToken}` },
196
+ body: form,
197
+ });
198
+ }
199
+ const res = handle.res;
200
+ if (!res.ok) {
201
+ const text = await res.text();
202
+ throw new Error(`Upsert file failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
203
+ }
204
+ } catch (err) {
205
+ if (isAbort(err)) {
206
+ throw new Error(`Upsert file timed out after ${uploadTimeoutMs()}ms`);
207
+ }
208
+ throw err;
209
+ } finally {
210
+ if (handle) handle.clear();
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Incremental single-file delete.
216
+ */
217
+ async function deleteFile(gitId, relativePath) {
218
+ return api.del(`/api/agent/repos/${encodeURIComponent(gitId)}/files`, {
219
+ body: { path: relativePath },
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Send heartbeat.
225
+ */
226
+ async function heartbeat(gitId) {
227
+ return api.post(`/api/agent/repos/${encodeURIComponent(gitId)}/heartbeat`, {});
228
+ }
229
+
230
+ /**
231
+ * Get sync status for a repo.
232
+ */
233
+ async function getStatus(gitId) {
234
+ return api.get(`/api/agent/repos/${encodeURIComponent(gitId)}/status`);
235
+ }
236
+
237
+ /**
238
+ * True when an error from an /api/agent/repos/{gitId}/* call is a DEFINITIVE
239
+ * "this repo does not exist on this backend" — a 404, or the 400 the
240
+ * AgentSyncController returns ("Repository not found: <gitId>"). Anything else
241
+ * (network failure, 401, 500) is NOT a not-found and must not be treated as one.
242
+ */
243
+ function isRepoNotFound(err) {
244
+ if (!err) return false;
245
+ if (err.status === 404) return true;
246
+ const text = `${err.message || ""} ${typeof err.body === "string" ? err.body : ""}`;
247
+ return err.status === 400 && /repository not found/i.test(text);
248
+ }
249
+
250
+ /**
251
+ * Does `gitId` still exist on the CURRENT backend? Returns false ONLY on a
252
+ * definitive not-found (see isRepoNotFound); any other error propagates so the
253
+ * caller fails loudly instead of silently re-registering (which would duplicate
254
+ * the repo and re-upload). Used by `niro init` to self-heal a stale cached gitId
255
+ * left behind by a backend switch or a deleted repo.
256
+ */
257
+ async function repoExists(gitId) {
258
+ try {
259
+ await getStatus(gitId);
260
+ return true;
261
+ } catch (err) {
262
+ if (isRepoNotFound(err)) return false;
263
+ throw err;
264
+ }
265
+ }
266
+
267
+ // Node 18+ has FormData and Blob built-in (no-import needed in ESM, but CJS needs this)
268
+ async function getFormDataImpl() {
269
+ // Node 18+ has FormData globally; use it if available
270
+ if (typeof FormData !== "undefined" && typeof Blob !== "undefined") {
271
+ return { FormData, Blob };
272
+ }
273
+ throw new Error("FormData/Blob not available. Node 18+ is required.");
274
+ }
275
+
276
+ module.exports = { registerRepo, filterManifest, startSync, uploadFileBatch, completeSync, upsertFile, deleteFile, heartbeat, getStatus, repoExists, isRepoNotFound, getModuleExcludeSuggestions, updateModuleExcludePatterns };