@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.
- package/README.md +291 -0
- package/bin/niro.js +2 -0
- package/package.json +41 -0
- package/src/commands/_resolveProject.js +142 -0
- package/src/commands/add-project.js +190 -0
- package/src/commands/add-repo.js +270 -0
- package/src/commands/build.js +118 -0
- package/src/commands/delete.js +66 -0
- package/src/commands/edit.js +601 -0
- package/src/commands/info.js +172 -0
- package/src/commands/init.js +1166 -0
- package/src/commands/login.js +214 -0
- package/src/commands/logout.js +33 -0
- package/src/commands/mcp.js +40 -0
- package/src/commands/projects.js +76 -0
- package/src/commands/release.js +175 -0
- package/src/commands/remove.js +95 -0
- package/src/commands/status.js +75 -0
- package/src/commands/takeover.js +204 -0
- package/src/commands/watch.js +498 -0
- package/src/commands/whoami.js +74 -0
- package/src/index.js +293 -0
- package/src/lib/agentApi.js +276 -0
- package/src/lib/api.js +230 -0
- package/src/lib/browser.js +30 -0
- package/src/lib/color.js +46 -0
- package/src/lib/config.js +38 -0
- package/src/lib/credentials.js +73 -0
- package/src/lib/folderSync.js +503 -0
- package/src/lib/git.js +190 -0
- package/src/lib/moduleExcludeSuggestions.js +57 -0
- package/src/lib/niroProjectFile.js +76 -0
- package/src/lib/pendingRequests.js +99 -0
- package/src/lib/prompt.js +118 -0
- package/src/lib/resolveContext.js +108 -0
- package/src/lib/secret.js +114 -0
- package/src/lib/service.js +221 -0
- package/src/lib/spinner.js +109 -0
- package/src/lib/watchDaemon.js +93 -0
- package/src/lib/watchState.js +217 -0
- package/src/mcp/server.js +764 -0
- package/src/mcp/setup.js +1976 -0
- package/src/mcp/teardown.js +673 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro login` — acquire credentials and store at ~/.niro/credentials.json.
|
|
3
|
+
*
|
|
4
|
+
* Modes:
|
|
5
|
+
* 1. Device flow (DEFAULT): POST /api/public/auth/device-code → open browser
|
|
6
|
+
* → poll /api/public/auth/device-token. On success the returned
|
|
7
|
+
* niro_api_key is the durable per-device CLI token, stored as creds.cliToken
|
|
8
|
+
* so the short-lived JWT can be refreshed transparently.
|
|
9
|
+
* 2. --password: email + password → POST /api/auth/access-token.
|
|
10
|
+
* 3. --api-key: paste a Niro API key → POST /api/public/auth/api-key-login.
|
|
11
|
+
*
|
|
12
|
+
* The device-flow path is gated behind a capability probe (checks whether
|
|
13
|
+
* /api/public/auth/device-code returns 200 vs 404/405). When unavailable, it
|
|
14
|
+
* falls back to the API key flow.
|
|
15
|
+
*/
|
|
16
|
+
const api = require("../lib/api");
|
|
17
|
+
const credentials = require("../lib/credentials");
|
|
18
|
+
const prompt = require("../lib/prompt");
|
|
19
|
+
const browser = require("../lib/browser");
|
|
20
|
+
const spinner = require("../lib/spinner");
|
|
21
|
+
const color = require("../lib/color");
|
|
22
|
+
|
|
23
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
|
24
|
+
const DEFAULT_EXPIRY_SECONDS = 600;
|
|
25
|
+
|
|
26
|
+
async function login(opts = {}) {
|
|
27
|
+
const apiUrl = (opts.apiUrl || process.env.NIRO_API_URL || api.DEFAULT_API_URL).replace(/\/$/, "");
|
|
28
|
+
|
|
29
|
+
if (opts.password) {
|
|
30
|
+
return loginWithPassword({ apiUrl, email: opts.email });
|
|
31
|
+
}
|
|
32
|
+
if (opts.apiKey) {
|
|
33
|
+
return loginWithApiKey({ apiUrl });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const deviceFlowAvailable = await probeDeviceFlow(apiUrl);
|
|
37
|
+
if (deviceFlowAvailable) {
|
|
38
|
+
return loginWithDeviceFlow({ apiUrl });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log("Device flow not available on this server — falling back to API key login.");
|
|
42
|
+
return loginWithApiKey({ apiUrl });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loginWithApiKey({ apiUrl }) {
|
|
46
|
+
console.log(`Connecting to ${color.cyan(apiUrl)}\n`);
|
|
47
|
+
console.log(
|
|
48
|
+
`Enter your Niro API key. You can find it in the Niro dashboard under Settings → API Keys.\n`
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const apiKey = await prompt.askHidden("API key");
|
|
52
|
+
if (!apiKey) {
|
|
53
|
+
console.error("API key is required.");
|
|
54
|
+
process.exitCode = 1;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const sp = spinner.start("Authenticating...");
|
|
59
|
+
try {
|
|
60
|
+
const data = await api.post(
|
|
61
|
+
"/api/public/auth/api-key-login",
|
|
62
|
+
{ api_key: apiKey },
|
|
63
|
+
{ apiUrl, noAuth: true, skipRefresh: true }
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const token = data.token;
|
|
67
|
+
const niroApiKey = data.niro_api_key || data.niroApiKey || apiKey;
|
|
68
|
+
if (!token) {
|
|
69
|
+
sp.stop(color.red("✗") + " Server did not return a token. Check your API key.");
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
credentials.save({
|
|
75
|
+
apiUrl,
|
|
76
|
+
token,
|
|
77
|
+
niroApiKey,
|
|
78
|
+
email: data.email || null,
|
|
79
|
+
userId: data.user_id || data.userId || null,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
sp.stop(`${color.green("✓")} Logged in successfully.`);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err.status === 401) {
|
|
85
|
+
sp.stop(color.red("✗") + " Invalid API key.");
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
sp.stop(color.red("✗") + " Login failed.");
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loginWithPassword({ apiUrl, email }) {
|
|
95
|
+
const emailAnswer = email || (await prompt.ask("Email"));
|
|
96
|
+
if (!emailAnswer) {
|
|
97
|
+
console.error("Email is required.");
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const password = await prompt.askHidden("Password");
|
|
102
|
+
if (!password) {
|
|
103
|
+
console.error("Password is required.");
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const data = await api.post(
|
|
109
|
+
"/api/auth/access-token",
|
|
110
|
+
{ email_or_phone_number: emailAnswer, password },
|
|
111
|
+
{ apiUrl }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const token = data.token || data.access_token;
|
|
115
|
+
const niroApiKey = data.niro_api_key || data.niroApiKey || null;
|
|
116
|
+
if (!token) {
|
|
117
|
+
throw new Error("Server did not return a token. Check your credentials.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
credentials.save({
|
|
121
|
+
apiUrl,
|
|
122
|
+
token,
|
|
123
|
+
niroApiKey,
|
|
124
|
+
email: emailAnswer,
|
|
125
|
+
userId: data.user_id || data.userId || null,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
console.log(`${color.green("✓")} Logged in as ${color.bold(emailAnswer)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function probeDeviceFlow(apiUrl) {
|
|
132
|
+
try {
|
|
133
|
+
await api.post("/api/public/auth/device-code", {}, { apiUrl, noAuth: true, skipRefresh: true });
|
|
134
|
+
return true;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err.status === 404 || err.status === 405) return false;
|
|
137
|
+
// Other 4xx (e.g. a 429 rate limit, or a real body) mean the endpoint IS there.
|
|
138
|
+
if (err.status && err.status >= 400 && err.status < 500) return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function loginWithDeviceFlow({ apiUrl }) {
|
|
144
|
+
const resp = await api.post(
|
|
145
|
+
"/api/public/auth/device-code",
|
|
146
|
+
{ client_id: "niro-cli" },
|
|
147
|
+
{ apiUrl, noAuth: true, skipRefresh: true }
|
|
148
|
+
);
|
|
149
|
+
const userCode = resp.user_code || resp.userCode;
|
|
150
|
+
const deviceCode = resp.device_code || resp.deviceCode;
|
|
151
|
+
const verificationUri = resp.verification_uri_complete || resp.verificationUriComplete
|
|
152
|
+
|| resp.verification_uri || resp.verificationUri;
|
|
153
|
+
const interval = Number(resp.interval || DEFAULT_POLL_INTERVAL_SECONDS);
|
|
154
|
+
const expiresIn = Number(resp.expires_in || resp.expiresIn || DEFAULT_EXPIRY_SECONDS);
|
|
155
|
+
|
|
156
|
+
if (!userCode || !deviceCode || !verificationUri) {
|
|
157
|
+
throw new Error("Server did not return a complete device-code payload.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(`\n Your one-time code: ${color.bold(color.cyan(userCode))}`);
|
|
161
|
+
console.log(` Open: ${color.cyan(verificationUri)}\n`);
|
|
162
|
+
browser.open(verificationUri);
|
|
163
|
+
|
|
164
|
+
const sp = spinner.start("Waiting for browser approval...");
|
|
165
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
166
|
+
try {
|
|
167
|
+
while (Date.now() < deadline) {
|
|
168
|
+
await sleep(interval * 1000);
|
|
169
|
+
try {
|
|
170
|
+
const tokenResp = await api.post(
|
|
171
|
+
"/api/public/auth/device-token",
|
|
172
|
+
{ device_code: deviceCode },
|
|
173
|
+
{ apiUrl, noAuth: true, skipRefresh: true }
|
|
174
|
+
);
|
|
175
|
+
const token = tokenResp.token || tokenResp.access_token;
|
|
176
|
+
if (token) {
|
|
177
|
+
const email = (tokenResp.user && tokenResp.user.email) || tokenResp.email || null;
|
|
178
|
+
const cliToken = tokenResp.niro_api_key || tokenResp.niroApiKey || null;
|
|
179
|
+
sp.stop(`${color.green("✓")} Logged in${email ? ` as ${color.bold(email)}` : ""}.`);
|
|
180
|
+
credentials.save({
|
|
181
|
+
apiUrl,
|
|
182
|
+
token,
|
|
183
|
+
cliToken,
|
|
184
|
+
niroApiKey: cliToken,
|
|
185
|
+
email,
|
|
186
|
+
userId: (tokenResp.user && tokenResp.user.id) || tokenResp.user_id || tokenResp.userId || null,
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (isPending(err)) continue;
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
sp.stop(`${color.red("✗")} Login timed out. Run ${color.cyan("niro login")} to try again.`);
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
} finally {
|
|
198
|
+
sp.stop();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isPending(err) {
|
|
203
|
+
const body = err.body;
|
|
204
|
+
if (!body) return false;
|
|
205
|
+
const code = typeof body === "string" ? body : body.error || body.message;
|
|
206
|
+
if (typeof code !== "string") return false;
|
|
207
|
+
return /authorization[_-]?pending/i.test(code) || /slow[_-]?down/i.test(code);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sleep(ms) {
|
|
211
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = { login };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro logout` — revoke the durable CLI token server-side (best-effort) and
|
|
3
|
+
* remove locally stored credentials.
|
|
4
|
+
*
|
|
5
|
+
* The revoke endpoint is public and authenticates via the cli_token in the body,
|
|
6
|
+
* so it works even after the short-lived JWT has expired. Network errors are
|
|
7
|
+
* swallowed so the local credential clear always proceeds.
|
|
8
|
+
*/
|
|
9
|
+
const api = require("../lib/api");
|
|
10
|
+
const credentials = require("../lib/credentials");
|
|
11
|
+
|
|
12
|
+
async function logout() {
|
|
13
|
+
const creds = credentials.load();
|
|
14
|
+
const cliToken = creds && (creds.cliToken || creds.niroApiKey);
|
|
15
|
+
const apiUrl = (creds && creds.apiUrl) || api.DEFAULT_API_URL;
|
|
16
|
+
|
|
17
|
+
if (cliToken) {
|
|
18
|
+
try {
|
|
19
|
+
await api.post(
|
|
20
|
+
"/api/public/auth/cli-token/revoke",
|
|
21
|
+
{ cli_token: cliToken },
|
|
22
|
+
{ apiUrl, noAuth: true, skipRefresh: true }
|
|
23
|
+
);
|
|
24
|
+
} catch {
|
|
25
|
+
// Best-effort revoke: never block local logout on a network/server error.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const removed = credentials.clear();
|
|
30
|
+
console.log(removed ? "Credentials removed." : "No credentials file found.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { logout };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro mcp install` / `niro mcp uninstall` / `niro mcp serve` — drive the in-package MCP server.
|
|
3
|
+
*
|
|
4
|
+
* install(): spawn the bundled editor-config wizard as a child so it can prompt.
|
|
5
|
+
* uninstall(): spawn the teardown wizard — the exact inverse of install.
|
|
6
|
+
* serve(): run the in-package stdio bridge directly in this process so the
|
|
7
|
+
* editor's MCP stdio talks straight to it.
|
|
8
|
+
*/
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { spawn } = require("child_process");
|
|
11
|
+
|
|
12
|
+
/** Spawn a bundled wizard (setup.js / teardown.js) as a child so it can prompt on the TTY. */
|
|
13
|
+
function runWizard(scriptName, label) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const child = spawn(
|
|
16
|
+
process.execPath,
|
|
17
|
+
[path.join(__dirname, "..", "mcp", scriptName), ...process.argv.slice(4)],
|
|
18
|
+
{ stdio: "inherit" }
|
|
19
|
+
);
|
|
20
|
+
child.on("error", reject);
|
|
21
|
+
child.on("exit", (code) => {
|
|
22
|
+
if (code === 0) resolve();
|
|
23
|
+
else reject(new Error(`MCP ${label} wizard exited with code ${code}`));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function install() {
|
|
29
|
+
return runWizard("setup.js", "setup");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function uninstall() {
|
|
33
|
+
return runWizard("teardown.js", "uninstall");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function serve() {
|
|
37
|
+
require("../mcp/server");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { install, uninstall, serve };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro projects` — list the projects in the caller's account.
|
|
3
|
+
*
|
|
4
|
+
* Backed by GET /api/projects (account-scoped server-side: it returns only the
|
|
5
|
+
* authenticated user's account's projects). This is the account-wide list, NOT
|
|
6
|
+
* tied to the current folder — use `niro info` for "which project is THIS repo".
|
|
7
|
+
* Useful for the agent's "add this repo to an EXISTING project" flow, where it
|
|
8
|
+
* needs to show the user their projects to pick an alias.
|
|
9
|
+
*
|
|
10
|
+
* Never logs secrets.
|
|
11
|
+
*/
|
|
12
|
+
const api = require("../lib/api");
|
|
13
|
+
const credentials = require("../lib/credentials");
|
|
14
|
+
const color = require("../lib/color");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize the GET /api/projects payload into rows and sort them by alias
|
|
18
|
+
* (case-insensitive), falling back to project_id. Defensive about field-name
|
|
19
|
+
* shapes (snake_case from the API, camelCase just in case). Pure + testable.
|
|
20
|
+
*/
|
|
21
|
+
function projectsToRows(data) {
|
|
22
|
+
const list = Array.isArray(data) ? data : (data && Array.isArray(data.projects) ? data.projects : []);
|
|
23
|
+
return list
|
|
24
|
+
.map((p) => {
|
|
25
|
+
if (!p || typeof p !== "object") return null;
|
|
26
|
+
const projectId = p.project_id || p.projectId || p.id || null;
|
|
27
|
+
if (!projectId) return null;
|
|
28
|
+
return {
|
|
29
|
+
project_id: projectId,
|
|
30
|
+
alias: p.alias || p.name || null,
|
|
31
|
+
repo_count: p.repo_count != null ? p.repo_count : (p.repoCount != null ? p.repoCount : 0),
|
|
32
|
+
created_at: p.created_at != null ? p.created_at : (p.createdAt != null ? p.createdAt : null),
|
|
33
|
+
};
|
|
34
|
+
})
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.sort((a, b) => {
|
|
37
|
+
const ka = (a.alias || a.project_id).toLowerCase();
|
|
38
|
+
const kb = (b.alias || b.project_id).toLowerCase();
|
|
39
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Format an epoch-millis timestamp as a plain YYYY-MM-DD, or "" when absent. */
|
|
44
|
+
function fmtDate(createdAt) {
|
|
45
|
+
if (createdAt == null) return "";
|
|
46
|
+
const n = Number(createdAt);
|
|
47
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
48
|
+
return new Date(n).toISOString().slice(0, 10);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function projects(opts = {}) {
|
|
52
|
+
credentials.requireCreds();
|
|
53
|
+
const data = await api.get("/api/projects", { apiUrl: opts.apiUrl });
|
|
54
|
+
const rows = projectsToRows(data);
|
|
55
|
+
|
|
56
|
+
if (opts.json) {
|
|
57
|
+
console.log(JSON.stringify({ projects: rows }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (rows.length === 0) {
|
|
62
|
+
console.log("No projects in your account yet. Run `niro init` to create one.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(color.dim(`${rows.length} project${rows.length === 1 ? "" : "s"} in your account:`));
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
const name = color.bold(r.alias || "(no alias)");
|
|
69
|
+
const repos = `${r.repo_count} repo${r.repo_count === 1 ? "" : "s"}`;
|
|
70
|
+
const created = fmtDate(r.created_at);
|
|
71
|
+
const meta = [repos, created].filter(Boolean).join(", ");
|
|
72
|
+
console.log(` ${name} ${color.dim(r.project_id)} ${color.dim("— " + meta)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { projects, projectsToRows, fmtDate };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro discard-temp-project [path]` — delete the temporary project created by `niro new-temp-project`
|
|
3
|
+
* (ADR-013, routing per ADR-018). Run after your local branch is merged: the original project's remote
|
|
4
|
+
* poller picks up the merged commits by itself, so the temporary project has served its purpose.
|
|
5
|
+
*
|
|
6
|
+
* ADR-018: the `.niro-project` pin is normally NOT touched (new-style takeovers never changed it —
|
|
7
|
+
* server-side routing simply falls back to the source project once the temp project is gone). The ONLY
|
|
8
|
+
* pin writes left are TRANSITIONAL healing for folders taken over by a pre-ADR-018 CLI, whose pin was
|
|
9
|
+
* swapped to the temp project and would otherwise dangle at a deleted id.
|
|
10
|
+
*
|
|
11
|
+
* Discard guard (ADR-018): a temp project can cover SEVERAL repos (one per feature in flight). Deleting
|
|
12
|
+
* it kills coverage for ALL of them — so before deleting, every registered folder's CURRENT branch is
|
|
13
|
+
* compared against its recorded source branch, and still-diverged folders trigger a confirmation
|
|
14
|
+
* (or a hard refusal when non-interactive without --yes).
|
|
15
|
+
*
|
|
16
|
+
* Safety: the server refuses to release anything that is not a temporary project, so this can never delete
|
|
17
|
+
* a real project even if asked to.
|
|
18
|
+
*/
|
|
19
|
+
const api = require("../lib/api");
|
|
20
|
+
const color = require("../lib/color");
|
|
21
|
+
const git = require("../lib/git");
|
|
22
|
+
const niroProjectFile = require("../lib/niroProjectFile");
|
|
23
|
+
const prompt = require("../lib/prompt");
|
|
24
|
+
const watchState = require("../lib/watchState");
|
|
25
|
+
|
|
26
|
+
const ok = (s) => color.green(s);
|
|
27
|
+
const warn = (s) => color.yellow(s);
|
|
28
|
+
|
|
29
|
+
async function release(pathArg, opts = {}) {
|
|
30
|
+
// See takeover: --api-url isn't threaded through the release POST, so honoring it would act on
|
|
31
|
+
// the wrong stack. Fail fast rather than silently release against the default backend.
|
|
32
|
+
if (opts.apiUrl) {
|
|
33
|
+
throw new Error("`--api-url` is not supported for `niro discard-temp-project` yet. " +
|
|
34
|
+
"Select the backend with `niro login` / your config first, then run this without --api-url.");
|
|
35
|
+
}
|
|
36
|
+
const path = require("path");
|
|
37
|
+
const fs = require("fs");
|
|
38
|
+
const targetDir = path.resolve(pathArg || process.cwd());
|
|
39
|
+
git.inspect(targetDir); // validates the path; repoRoot itself is no longer needed for pinning
|
|
40
|
+
|
|
41
|
+
// Resolve WHICH temp project to discard. ADR-018: the pin no longer points at it (it stays on the
|
|
42
|
+
// source), so the primary lookup is this folder's watchState registration (written by takeover).
|
|
43
|
+
// Fallbacks: an explicit --project id (escape hatch when the local registration is lost — daemon
|
|
44
|
+
// reinstalled, second machine, backend switched; review MAJOR: without it discard dead-ended with
|
|
45
|
+
// a misleading error), then an old-style folder whose pin still holds the temp-project id.
|
|
46
|
+
const baseUrl = api.currentBaseUrl();
|
|
47
|
+
const entry = watchState.get(targetDir, baseUrl);
|
|
48
|
+
let tempProjectId = opts.project || (entry && entry.takeover ? entry.projectId : null);
|
|
49
|
+
if (!tempProjectId) {
|
|
50
|
+
const pinned = niroProjectFile.read(targetDir);
|
|
51
|
+
if (pinned) tempProjectId = pinned.id;
|
|
52
|
+
}
|
|
53
|
+
if (!tempProjectId) {
|
|
54
|
+
throw new Error("No local record of a temporary project for this folder. " +
|
|
55
|
+
"If you created one elsewhere (another machine, a reinstalled daemon), pass its id explicitly: " +
|
|
56
|
+
"`niro discard-temp-project --project <temp-project-id>`. The server refuses to delete anything " +
|
|
57
|
+
"that is not a temporary project, so this is always safe.");
|
|
58
|
+
}
|
|
59
|
+
const project = await api.get(`/api/projects/${encodeURIComponent(tempProjectId)}`);
|
|
60
|
+
const cloneOf = project.clone_of_project_id || project.cloneOfProjectId;
|
|
61
|
+
if (!cloneOf) {
|
|
62
|
+
throw new Error(`Project ${tempProjectId} is not a temporary project — refusing. ` +
|
|
63
|
+
"(discard only deletes projects created by `niro new-temp-project`)");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Every folder registered against this temp project (multi-repo takeover, ADR-016).
|
|
67
|
+
const currentDir = path.resolve(targetDir);
|
|
68
|
+
const folders = watchState.allLocal(baseUrl).filter((e) => e.projectId === tempProjectId);
|
|
69
|
+
if (!folders.some((e) => path.resolve(e.folderPath) === currentDir)) {
|
|
70
|
+
folders.push({ folderPath: targetDir, ...(entry || {}) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ADR-018 discard guard: deleting the temp project kills coverage for EVERY repo in it. Check each
|
|
74
|
+
// folder's CURRENT branch against its recorded source branch; a folder still on a feature branch is
|
|
75
|
+
// in-flight work whose coverage this discard would destroy. Old entries without sourceBranch can't be
|
|
76
|
+
// verified client-side (the server-side nudge guard still covers the agent-driven path).
|
|
77
|
+
const stillDiverged = [];
|
|
78
|
+
for (const e of folders) {
|
|
79
|
+
const sourceBranch = e.takeover && e.takeover.sourceBranch;
|
|
80
|
+
if (!sourceBranch || !fs.existsSync(e.folderPath)) continue;
|
|
81
|
+
try {
|
|
82
|
+
const cur = git.inspect(e.folderPath).branch;
|
|
83
|
+
if (cur && cur !== sourceBranch) {
|
|
84
|
+
stillDiverged.push({ folder: e.folderPath, branch: cur, sourceBranch });
|
|
85
|
+
}
|
|
86
|
+
} catch { /* can't inspect — don't block on it */ }
|
|
87
|
+
}
|
|
88
|
+
if (stillDiverged.length) {
|
|
89
|
+
console.log(warn(`\n ⚠ This temporary project still covers in-flight work:`));
|
|
90
|
+
for (const d of stillDiverged) {
|
|
91
|
+
console.log(warn(` ${d.folder} is on \`${d.branch}\` (source: \`${d.sourceBranch}\`)`));
|
|
92
|
+
}
|
|
93
|
+
console.log(warn(" Discarding deletes the WHOLE temporary project — those repos lose their coverage" +
|
|
94
|
+
" until you run `niro new-temp-project` there again (local code is untouched)."));
|
|
95
|
+
if (!opts.yes) {
|
|
96
|
+
if (!process.stdout.isTTY || prompt.isNonInteractive()) {
|
|
97
|
+
throw new Error("Refusing to discard: repos listed above are still on feature branches. " +
|
|
98
|
+
"Re-run with --yes to discard anyway, or discard after they are merged.");
|
|
99
|
+
}
|
|
100
|
+
const sure = await prompt.confirm("Discard anyway?");
|
|
101
|
+
if (!sure) {
|
|
102
|
+
console.log("Aborted.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} else if (process.stdout.isTTY && !opts.yes) {
|
|
107
|
+
const sure = await prompt.confirm(
|
|
108
|
+
`Discard temporary project ${color.bold(project.alias || tempProjectId)}?`);
|
|
109
|
+
if (!sure) {
|
|
110
|
+
console.log("Aborted.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await api.post(`/api/projects/${encodeURIComponent(tempProjectId)}/clone/release`, {});
|
|
116
|
+
|
|
117
|
+
// Restore each folder's local registration. ADR-018: the .niro-project file is left ALONE unless it
|
|
118
|
+
// still carries the OLD-STYLE swapped pin (pre-ADR-018 takeover) — then it points at a now-deleted
|
|
119
|
+
// project and must be healed: previousPin verbatim when recorded; explicit "no pin existed" → delete
|
|
120
|
+
// the file; otherwise the source project id. New-style folders (pin untouched) skip all of this.
|
|
121
|
+
const repinned = [];
|
|
122
|
+
const healed = [];
|
|
123
|
+
const failed = [];
|
|
124
|
+
for (const e of folders) {
|
|
125
|
+
try {
|
|
126
|
+
const backup = e.takeover || {};
|
|
127
|
+
const sourceProjectId = backup.sourceProjectId || cloneOf;
|
|
128
|
+
const entryDir = path.resolve(e.folderPath);
|
|
129
|
+
|
|
130
|
+
if (fs.existsSync(entryDir)) {
|
|
131
|
+
const pin = niroProjectFile.read(entryDir);
|
|
132
|
+
const pinIsSwapped = pin && pin.id === tempProjectId
|
|
133
|
+
&& path.dirname(pin.source) === entryDir; // only heal THIS folder's own pin, never a parent's
|
|
134
|
+
if (pinIsSwapped) {
|
|
135
|
+
const recordedNoPin = ("previousPin" in backup) && backup.previousPin === null;
|
|
136
|
+
if (backup.previousPin) {
|
|
137
|
+
niroProjectFile.write(entryDir, backup.previousPin, { overwrite: true });
|
|
138
|
+
healed.push({ folder: e.folderPath, to: backup.previousPin });
|
|
139
|
+
} else if (recordedNoPin) {
|
|
140
|
+
niroProjectFile.remove(entryDir);
|
|
141
|
+
healed.push({ folder: e.folderPath, to: "(no pin — removed)" });
|
|
142
|
+
} else {
|
|
143
|
+
niroProjectFile.write(entryDir, sourceProjectId, { overwrite: true });
|
|
144
|
+
healed.push({ folder: e.folderPath, to: sourceProjectId });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Watch/daemon registration goes back to the source project regardless of pin style.
|
|
150
|
+
watchState.set(e.folderPath, {
|
|
151
|
+
...e,
|
|
152
|
+
gitId: backup.previousGitId || null,
|
|
153
|
+
projectId: sourceProjectId,
|
|
154
|
+
takeover: undefined,
|
|
155
|
+
}, e.apiUrl || baseUrl);
|
|
156
|
+
repinned.push(e.folderPath);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
failed.push({ folder: e.folderPath, error: err.message });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(`\n ${ok("✓")} Temporary project discarded. Niro routes back to the original project automatically.`);
|
|
163
|
+
if (healed.length) {
|
|
164
|
+
console.log(color.dim(" Healed old-style pins:"));
|
|
165
|
+
for (const h of healed) console.log(color.dim(` ${h.folder} → ${h.to}`));
|
|
166
|
+
}
|
|
167
|
+
if (failed.length) {
|
|
168
|
+
console.log(warn(`\n ⚠ ${failed.length} folder(s) could not be restored locally (the temporary project` +
|
|
169
|
+
" is already gone server-side):"));
|
|
170
|
+
for (const f of failed) console.log(color.dim(` ${f.folder} — ${f.error}`));
|
|
171
|
+
}
|
|
172
|
+
console.log(color.dim(" The original project picks up your merged commits via its normal remote polling."));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { release };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `niro remove` — remove either a whole project or a single repo mapping.
|
|
3
|
+
*
|
|
4
|
+
* Exactly one mode is required:
|
|
5
|
+
* --project delete the WHOLE project (DESTRUCTIVE) and unpin
|
|
6
|
+
* the local .niro-project. Reuses delete.js cleanup.
|
|
7
|
+
* DELETE /api/projects/{projectId}
|
|
8
|
+
* --unmap-repo <gitId> remove THIS repo from the project (non-destructive;
|
|
9
|
+
* the repo registration itself is kept).
|
|
10
|
+
* DELETE /api/projects/{projectId}/git/{gitId}
|
|
11
|
+
*
|
|
12
|
+
* Target project resolution: --project flag is a *mode*, not the id selector.
|
|
13
|
+
* The project is resolved via resolveProjectId from --target <alias|id> or the
|
|
14
|
+
* .niro-project pin (same precedence as status/build/delete).
|
|
15
|
+
*
|
|
16
|
+
* DESTRUCTIVE BACKSTOP (required for --project): --confirm-name <value> MUST
|
|
17
|
+
* equal the project's alias OR its project_id. If it doesn't match (or is
|
|
18
|
+
* missing), we refuse and delete nothing. This is a CLI-level guard, independent
|
|
19
|
+
* of any agent-side confirmation. --unmap-repo does not need the name guard but
|
|
20
|
+
* still requires the explicit gitId flag.
|
|
21
|
+
*/
|
|
22
|
+
const api = require("../lib/api");
|
|
23
|
+
const credentials = require("../lib/credentials");
|
|
24
|
+
const color = require("../lib/color");
|
|
25
|
+
const { resolveProjectId } = require("./_resolveProject");
|
|
26
|
+
const { unpinIfMatches } = require("./delete");
|
|
27
|
+
|
|
28
|
+
async function remove(opts = {}) {
|
|
29
|
+
const wholeProject = Boolean(opts.project);
|
|
30
|
+
const unmapRepo = opts.unmapRepo || null;
|
|
31
|
+
|
|
32
|
+
// Exactly one mode.
|
|
33
|
+
const modes = [wholeProject, Boolean(unmapRepo)].filter(Boolean);
|
|
34
|
+
if (modes.length === 0) {
|
|
35
|
+
printUsage();
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (modes.length > 1) {
|
|
40
|
+
console.error(`${color.red("✗")} Choose exactly one: --project OR --unmap-repo <gitId>.`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
credentials.requireCreds();
|
|
46
|
+
const projectId = await resolveProjectId(opts.target, { apiUrl: opts.apiUrl });
|
|
47
|
+
|
|
48
|
+
if (unmapRepo) {
|
|
49
|
+
await api.del(
|
|
50
|
+
`/api/projects/${encodeURIComponent(projectId)}/git/${encodeURIComponent(unmapRepo)}`,
|
|
51
|
+
{ apiUrl: opts.apiUrl }
|
|
52
|
+
);
|
|
53
|
+
console.log(`${color.green("✓")} Unmapped repo ${color.dim(unmapRepo)} from project ${color.dim(projectId)} (repo registration kept).`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --project: destructive. Fetch the project so we can match --confirm-name
|
|
58
|
+
// against either the alias or the project_id.
|
|
59
|
+
let alias = null;
|
|
60
|
+
try {
|
|
61
|
+
const project = await api.get(`/api/projects/${encodeURIComponent(projectId)}`, { apiUrl: opts.apiUrl });
|
|
62
|
+
alias = (project && (project.alias)) || null;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// If we can't read the project, fall back to matching the id only.
|
|
65
|
+
alias = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const confirm = opts.confirmName == null ? null : String(opts.confirmName).trim();
|
|
69
|
+
const matches = confirm != null && (confirm === projectId || (alias != null && confirm === alias));
|
|
70
|
+
if (!matches) {
|
|
71
|
+
const expected = alias ? `"${alias}" or ${projectId}` : projectId;
|
|
72
|
+
console.error(`${color.red("✗")} Refusing to delete: --confirm-name must equal the project's name (${expected}).`);
|
|
73
|
+
if (confirm == null) {
|
|
74
|
+
console.error(color.dim(" Pass --confirm-name <name> to confirm this destructive delete."));
|
|
75
|
+
} else {
|
|
76
|
+
console.error(color.dim(` You passed --confirm-name "${confirm}", which does not match.`));
|
|
77
|
+
}
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await api.del(`/api/projects/${encodeURIComponent(projectId)}`, { apiUrl: opts.apiUrl });
|
|
83
|
+
console.log(`${color.green("✓")} Deleted project ${color.dim(projectId)}${alias ? ` ("${alias}")` : ""}.`);
|
|
84
|
+
unpinIfMatches(projectId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function printUsage() {
|
|
88
|
+
console.error("niro remove — remove a project or a repo mapping. Exactly one mode required:");
|
|
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
|
+
console.error("Target project: --target <alias|id>, or a .niro-project pin in this folder.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { remove };
|