@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,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-register helper: fetch module exclude suggestions from the backend
|
|
3
|
+
* and offer them to the user (one prompt per suggestion). On confirmation
|
|
4
|
+
* each accepted pattern is appended and PUT back to the backend.
|
|
5
|
+
*
|
|
6
|
+
* Skips silently if --defaults is set, the user supplied --module-exclude
|
|
7
|
+
* flags already, or the backend returns no suggestions.
|
|
8
|
+
*/
|
|
9
|
+
const agentApi = require("./agentApi");
|
|
10
|
+
const prompt = require("./prompt");
|
|
11
|
+
const color = require("./color");
|
|
12
|
+
|
|
13
|
+
async function offerModuleExcludeSuggestions(gitId, opts = {}, alreadySupplied = []) {
|
|
14
|
+
if (opts.defaults) return;
|
|
15
|
+
if (alreadySupplied && alreadySupplied.length > 0) return;
|
|
16
|
+
if (!process.stdin.isTTY) return;
|
|
17
|
+
|
|
18
|
+
let suggestions;
|
|
19
|
+
try {
|
|
20
|
+
suggestions = await agentApi.getModuleExcludeSuggestions(gitId);
|
|
21
|
+
} catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(suggestions) || suggestions.length === 0) return;
|
|
25
|
+
|
|
26
|
+
console.log(`\n ${color.cyan("Suggested module exclusions")} (modules that are usually noise):`);
|
|
27
|
+
const accepted = [];
|
|
28
|
+
for (const s of suggestions) {
|
|
29
|
+
const count = s.module_count || s.moduleCount || 0;
|
|
30
|
+
const label = s.label || "";
|
|
31
|
+
const samples = (s.sample_modules || s.sampleModules || []).slice(0, 3);
|
|
32
|
+
console.log(
|
|
33
|
+
` ${color.dim(s.pattern.padEnd(28))} ${String(count).padStart(3)} module${count === 1 ? "" : "s"} ${color.dim(label)}`
|
|
34
|
+
);
|
|
35
|
+
if (samples.length > 0) {
|
|
36
|
+
console.log(` ${color.dim("e.g. " + samples.join(", "))}`);
|
|
37
|
+
}
|
|
38
|
+
const yes = await prompt.confirm(` Apply ${color.cyan(s.pattern)}?`, true);
|
|
39
|
+
if (yes) accepted.push(s.pattern);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (accepted.length === 0) {
|
|
43
|
+
console.log(` ${color.dim("No exclusions applied.")}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await agentApi.updateModuleExcludePatterns(gitId, accepted);
|
|
49
|
+
console.log(
|
|
50
|
+
` ${color.green("✓")} Applied ${accepted.length} exclusion${accepted.length === 1 ? "" : "s"}.`
|
|
51
|
+
);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.log(` ${color.yellow("Warning:")} failed to save exclusions: ${err.message || err}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { offerModuleExcludeSuggestions };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The .niro-project file consumed by @niroai/mcp-server and the CLI. A small text
|
|
3
|
+
* file at a repo root whose first non-comment, non-blank line is the project id.
|
|
4
|
+
* Lines starting with # are comments. This module is the SINGLE source of truth for
|
|
5
|
+
* that format — both the writer and every reader (the MCP bridge in src/mcp/server.js
|
|
6
|
+
* and the CLI's _resolveProject.js) go through here so the read/write contract can
|
|
7
|
+
* never drift again. (It drifted once: the bridge's hand-rolled reader only looked at
|
|
8
|
+
* line 0 and bailed on the comment header the writer always emits, so it skipped a
|
|
9
|
+
* valid pin and walked up to the wrong parent .niro-project.)
|
|
10
|
+
*/
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
|
|
14
|
+
function write(repoRoot, projectId, { overwrite = true } = {}) {
|
|
15
|
+
const target = path.join(repoRoot, ".niro-project");
|
|
16
|
+
if (!overwrite && fs.existsSync(target)) return { written: false, path: target };
|
|
17
|
+
const contents = `# Niro project binding — consumed by @niroai/mcp-server\n${projectId}\n`;
|
|
18
|
+
fs.writeFileSync(target, contents);
|
|
19
|
+
return { written: true, path: target };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse the project id out of .niro-project contents: the first non-comment,
|
|
24
|
+
* non-blank line, trimmed. Returns null if there is no such line (e.g. a
|
|
25
|
+
* comment-only file). Mirror of what write() emits.
|
|
26
|
+
*/
|
|
27
|
+
function parse(contents) {
|
|
28
|
+
const line = String(contents)
|
|
29
|
+
.split(/\r?\n/)
|
|
30
|
+
.find((l) => l.trim() && !l.trim().startsWith("#"));
|
|
31
|
+
return line ? line.trim() : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Walk up from startDir looking for a .niro-project that yields a project id.
|
|
36
|
+
* The nearest file with a parseable id wins; a comment-only file is skipped and
|
|
37
|
+
* the walk continues upward. Returns { id, source } (source = absolute file path)
|
|
38
|
+
* or null if none is found up to the filesystem root.
|
|
39
|
+
*/
|
|
40
|
+
function read(startDir) {
|
|
41
|
+
let dir = path.resolve(startDir);
|
|
42
|
+
const root = path.parse(dir).root;
|
|
43
|
+
while (true) {
|
|
44
|
+
const p = path.join(dir, ".niro-project");
|
|
45
|
+
try {
|
|
46
|
+
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
|
|
47
|
+
const id = parse(fs.readFileSync(p, "utf8"));
|
|
48
|
+
if (id) return { id, source: p };
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Unreadable file — ignore and keep walking up.
|
|
52
|
+
}
|
|
53
|
+
if (dir === root) return null;
|
|
54
|
+
dir = path.dirname(dir);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Delete the .niro-project file at repoRoot, if present. Used by `niro discard-temp-project` to restore a
|
|
60
|
+
* folder that had NO pin before takeover — writing the source id back would leave a pin file the folder
|
|
61
|
+
* never had. Best-effort: returns { removed } and never throws on a missing file.
|
|
62
|
+
*/
|
|
63
|
+
function remove(repoRoot) {
|
|
64
|
+
const target = path.join(repoRoot, ".niro-project");
|
|
65
|
+
try {
|
|
66
|
+
if (fs.existsSync(target)) {
|
|
67
|
+
fs.unlinkSync(target);
|
|
68
|
+
return { removed: true, path: target };
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Best-effort — a locked/unreadable file is left as-is.
|
|
72
|
+
}
|
|
73
|
+
return { removed: false, path: target };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { write, parse, read, remove };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request client-side deadline tracker for the MCP stdio↔SSE bridge.
|
|
3
|
+
*
|
|
4
|
+
* The bridge POSTs each JSON-RPC request to the server fire-and-forget and receives the
|
|
5
|
+
* result LATER as an SSE 'message' event. Without a deadline, a request the server never
|
|
6
|
+
* answers (a wedged dependency, a dropped result event, a silently half-open stream) leaves
|
|
7
|
+
* the MCP host waiting forever — the exact "hangs indefinitely" failure the upload path
|
|
8
|
+
* already guards against with an AbortController (agentApi.js). This tracks in-flight
|
|
9
|
+
* REQUESTS by JSON-RPC id; if no response arrives within timeoutMs it hands a synthesized
|
|
10
|
+
* JSON-RPC error to onExpire so the host fails cleanly instead of hanging.
|
|
11
|
+
*
|
|
12
|
+
* NOTE: this module is mirrored inline in the standalone npm bridge
|
|
13
|
+
* (niro-mcp-server-npm/index.js), which ships a `files` whitelist and cannot require it.
|
|
14
|
+
* Keep the two in sync.
|
|
15
|
+
*
|
|
16
|
+
* TRADEOFF: after a request's deadline fires, a genuinely-late server response for the SAME id that
|
|
17
|
+
* still arrives over SSE is forwarded to stdout as usual — so the host could see two messages for one
|
|
18
|
+
* id (it ignores the second, already-settled one). This CANNOT happen under defaults: the server-side
|
|
19
|
+
* deadline (MCP_TOOL_DEADLINE_MS, default 90s) is shorter than this client default (120s), so the
|
|
20
|
+
* server always answers first and cancels the timer. It only occurs if NIRO_TOOL_TIMEOUT_MS is set
|
|
21
|
+
* BELOW the server deadline. Keep NIRO_TOOL_TIMEOUT_MS >= the server deadline to avoid it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TOOL_TIMEOUT_MS = 120000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A client→server JSON-RPC REQUEST (one that expects a response) has BOTH a method and an id.
|
|
28
|
+
* A response (id + result/error, no method) and a notification (method, no id) are NOT tracked
|
|
29
|
+
* — tracking a response would arm a timer that never gets cancelled.
|
|
30
|
+
*/
|
|
31
|
+
function isTrackableRequest(message) {
|
|
32
|
+
return !!message
|
|
33
|
+
&& typeof message.method === "string"
|
|
34
|
+
&& message.id !== undefined && message.id !== null
|
|
35
|
+
&& message.result === undefined && message.error === undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {number} opts.timeoutMs deadline per request (ms)
|
|
41
|
+
* @param {(errObj:object)=>void} opts.onExpire invoked with a JSON-RPC error object on timeout
|
|
42
|
+
* @param {(msg:string)=>void} [opts.onLog] optional diagnostic sink (stderr)
|
|
43
|
+
*/
|
|
44
|
+
function createPendingTracker({ timeoutMs, onExpire, onLog }) {
|
|
45
|
+
const pending = new Map(); // JSON-RPC id -> timeout handle
|
|
46
|
+
|
|
47
|
+
function clear(id) {
|
|
48
|
+
const handle = pending.get(id);
|
|
49
|
+
if (handle !== undefined) {
|
|
50
|
+
clearTimeout(handle);
|
|
51
|
+
pending.delete(id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function arm(id) {
|
|
56
|
+
clear(id); // defensive: never leak a prior timer for a reused id
|
|
57
|
+
const handle = setTimeout(() => {
|
|
58
|
+
pending.delete(id);
|
|
59
|
+
if (onLog) onLog(`request ${id} timed out after ${timeoutMs}ms; returned JSON-RPC error to client`);
|
|
60
|
+
onExpire({
|
|
61
|
+
jsonrpc: "2.0",
|
|
62
|
+
id,
|
|
63
|
+
error: {
|
|
64
|
+
code: -32001,
|
|
65
|
+
message:
|
|
66
|
+
`Niro MCP request timed out after ${timeoutMs}ms with no response from the server. ` +
|
|
67
|
+
`It may be busy indexing/enriching — retry shortly, or use your native tools. ` +
|
|
68
|
+
`(Adjust the deadline with NIRO_TOOL_TIMEOUT_MS.)`,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
if (typeof handle.unref === "function") handle.unref(); // don't keep the process alive for the timer
|
|
73
|
+
pending.set(id, handle);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Cancel the deadline when a RESPONSE (id + result/error) arrives for a tracked request. */
|
|
77
|
+
function onIncoming(message) {
|
|
78
|
+
if (message
|
|
79
|
+
&& message.id !== undefined && message.id !== null
|
|
80
|
+
&& (message.result !== undefined || message.error !== undefined)) {
|
|
81
|
+
clear(message.id);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { arm, clear, onIncoming, size: () => pending.size };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resolve the per-request deadline from env, falling back to the default. */
|
|
89
|
+
function resolveTimeoutMs(env) {
|
|
90
|
+
const raw = parseInt((env || {}).NIRO_TOOL_TIMEOUT_MS, 10);
|
|
91
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TOOL_TIMEOUT_MS;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
createPendingTracker,
|
|
96
|
+
isTrackableRequest,
|
|
97
|
+
resolveTimeoutMs,
|
|
98
|
+
DEFAULT_TOOL_TIMEOUT_MS,
|
|
99
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readline helpers for the CLI wizard flow. `ask` uses node's built-in
|
|
3
|
+
* readline. `askHidden` bypasses readline entirely and puts the TTY into
|
|
4
|
+
* raw mode to mask password input — mixing readline with a `data` listener
|
|
5
|
+
* for masking is unreliable across platforms.
|
|
6
|
+
*/
|
|
7
|
+
const readline = require("readline");
|
|
8
|
+
const color = require("./color");
|
|
9
|
+
|
|
10
|
+
function rl() {
|
|
11
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Single place that decides how a prompt's default value is displayed. A bare
|
|
16
|
+
* "[value]" reads as noise to first-time users, so the default is labelled
|
|
17
|
+
* explicitly: `Question (default: value):` — pressing Enter accepts it.
|
|
18
|
+
* Also shared by the MCP setup/teardown wizards' own ask() helpers.
|
|
19
|
+
*/
|
|
20
|
+
function formatPrompt(question, defaultValue) {
|
|
21
|
+
return defaultValue != null && defaultValue !== ""
|
|
22
|
+
? `${question} ${color.dim(`(default: ${defaultValue})`)}: `
|
|
23
|
+
: `${question}: `;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ask(question, defaultValue) {
|
|
27
|
+
const prompt = formatPrompt(question, defaultValue);
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const r = rl();
|
|
30
|
+
r.question(prompt, (answer) => {
|
|
31
|
+
r.close();
|
|
32
|
+
resolve(answer.trim() || defaultValue || "");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function askHidden(question) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const stdin = process.stdin;
|
|
40
|
+
const stdout = process.stdout;
|
|
41
|
+
|
|
42
|
+
stdout.write(`${question}: `);
|
|
43
|
+
|
|
44
|
+
if (!stdin.isTTY) {
|
|
45
|
+
// Piped / non-TTY — read one line in flowing mode without masking.
|
|
46
|
+
let buf = "";
|
|
47
|
+
stdin.setEncoding("utf8");
|
|
48
|
+
const onData = (chunk) => {
|
|
49
|
+
buf += chunk;
|
|
50
|
+
const nl = buf.indexOf("\n");
|
|
51
|
+
if (nl >= 0) {
|
|
52
|
+
stdin.removeListener("data", onData);
|
|
53
|
+
stdin.pause();
|
|
54
|
+
resolve(buf.slice(0, nl).replace(/\r$/, ""));
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
stdin.resume();
|
|
58
|
+
stdin.on("data", onData);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const wasRaw = stdin.isRaw === true;
|
|
63
|
+
stdin.setRawMode(true);
|
|
64
|
+
stdin.resume();
|
|
65
|
+
stdin.setEncoding("utf8");
|
|
66
|
+
|
|
67
|
+
let buffer = "";
|
|
68
|
+
const onData = (ch) => {
|
|
69
|
+
if (ch === "\r" || ch === "\n" || ch === "\u0004") {
|
|
70
|
+
stdin.removeListener("data", onData);
|
|
71
|
+
if (!wasRaw) stdin.setRawMode(false);
|
|
72
|
+
stdin.pause();
|
|
73
|
+
stdout.write("\n");
|
|
74
|
+
resolve(buffer);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (ch === "\u0003") {
|
|
78
|
+
// Ctrl-C
|
|
79
|
+
stdin.removeListener("data", onData);
|
|
80
|
+
if (!wasRaw) stdin.setRawMode(false);
|
|
81
|
+
stdout.write("\n");
|
|
82
|
+
process.exit(130);
|
|
83
|
+
}
|
|
84
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
85
|
+
// Backspace / DEL
|
|
86
|
+
if (buffer.length > 0) {
|
|
87
|
+
buffer = buffer.slice(0, -1);
|
|
88
|
+
stdout.write("\b \b");
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Handle paste or multi-byte chunks by iterating characters.
|
|
93
|
+
for (const c of ch) {
|
|
94
|
+
if (c === "\r" || c === "\n") continue;
|
|
95
|
+
buffer += c;
|
|
96
|
+
stdout.write("*");
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
stdin.on("data", onData);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function confirm(question, defaultYes = true) {
|
|
105
|
+
// The capitalised letter in the hint is the default marker; don't pass the
|
|
106
|
+
// default to ask() or it would redundantly render "(default: y)" as well.
|
|
107
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
108
|
+
const raw = await ask(`${question} (${hint})`);
|
|
109
|
+
const ans = (raw || (defaultYes ? "y" : "n")).toLowerCase();
|
|
110
|
+
return ans === "y" || ans === "yes";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True when there is no interactive terminal to read input from. */
|
|
114
|
+
function isNonInteractive() {
|
|
115
|
+
return !process.stdin.isTTY;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { ask, askHidden, confirm, formatPrompt, isNonInteractive };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the Niro "context" for a working directory: inspect the local git
|
|
3
|
+
* repo, then ask the server which Niro project(s) map to that git URL/branch.
|
|
4
|
+
*
|
|
5
|
+
* This is shared plumbing for the agent commands (info/init/edit/build/remove).
|
|
6
|
+
* It reuses src/lib/git.js for repo inspection (which walks up to the git root)
|
|
7
|
+
* and src/lib/api.js for the server call. The server owns URL normalization, so
|
|
8
|
+
* the raw origin URL is sent as-is — we never normalize it client-side.
|
|
9
|
+
*
|
|
10
|
+
* Returned shape:
|
|
11
|
+
* {
|
|
12
|
+
* gitRoot: string|null, // repo root, or null when not a git repo
|
|
13
|
+
* repoUrl: string|null, // origin URL, or null when no origin
|
|
14
|
+
* branch: string|null, // current branch, or null on detached HEAD
|
|
15
|
+
* state: string, // one of: "not_a_git_repo" | "no_origin" |
|
|
16
|
+
* // "resolved" | "unresolved" | "error"
|
|
17
|
+
* projects: [{ project_id, alias, source_type, git_id }]
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* source_type is the matched repo's registration kind (REMOTE_GIT / LOCAL_PATH /
|
|
21
|
+
* AGENT_UPLOAD) when the server supplies it, else null. Agents use it to warn on
|
|
22
|
+
* remote-git projects (which auto-build only on commits).
|
|
23
|
+
*
|
|
24
|
+
* git_id is the specific repo (gitId) the server matched this folder's git
|
|
25
|
+
* url+branch to within the project, else null. Lets per-repo commands target the
|
|
26
|
+
* folder's repo on a multi-repo project without prompting (null on an alias match).
|
|
27
|
+
*
|
|
28
|
+
* Never logs secrets. Resolution failures degrade gracefully (state: "error")
|
|
29
|
+
* rather than throwing, so callers/agents can branch on `state`.
|
|
30
|
+
*/
|
|
31
|
+
const git = require("./git");
|
|
32
|
+
const api = require("./api");
|
|
33
|
+
|
|
34
|
+
async function resolveContext(startPath = process.cwd(), opts = {}) {
|
|
35
|
+
const { repoRoot, originUrl, branch } = git.inspect(startPath);
|
|
36
|
+
|
|
37
|
+
if (!repoRoot) {
|
|
38
|
+
return { gitRoot: null, repoUrl: null, branch: null, state: "not_a_git_repo", projects: [] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!originUrl) {
|
|
42
|
+
return { gitRoot: repoRoot, repoUrl: null, branch: branch || null, state: "no_origin", projects: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Omit branch when null (detached HEAD) -> URL-only resolve. Do NOT normalize
|
|
46
|
+
// the URL here; the server does.
|
|
47
|
+
const query = { git_url: originUrl };
|
|
48
|
+
if (branch) query.branch = branch;
|
|
49
|
+
|
|
50
|
+
let data;
|
|
51
|
+
try {
|
|
52
|
+
data = await api.get("/api/projects/resolve", { query, apiUrl: opts.apiUrl });
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return {
|
|
55
|
+
gitRoot: repoRoot,
|
|
56
|
+
repoUrl: originUrl,
|
|
57
|
+
branch: branch || null,
|
|
58
|
+
state: "error",
|
|
59
|
+
projects: [],
|
|
60
|
+
error: err && err.message ? err.message : String(err),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const projects = normalizeProjects(data);
|
|
65
|
+
return {
|
|
66
|
+
gitRoot: repoRoot,
|
|
67
|
+
repoUrl: originUrl,
|
|
68
|
+
branch: branch || null,
|
|
69
|
+
state: projects.length ? "resolved" : "unresolved",
|
|
70
|
+
projects,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalize the server payload into [{ project_id, alias, source_type, git_id }].
|
|
76
|
+
* Accepts a few shapes defensively: an array of projects, or an object with a
|
|
77
|
+
* `projects` array, or a single project object. source_type is the matched repo's
|
|
78
|
+
* registration kind (e.g. REMOTE_GIT), or null when the server doesn't supply it.
|
|
79
|
+
* git_id is the matched repo's gitId for this folder, or null when not supplied.
|
|
80
|
+
*/
|
|
81
|
+
function normalizeProjects(data) {
|
|
82
|
+
if (!data) return [];
|
|
83
|
+
let list = data;
|
|
84
|
+
if (!Array.isArray(data)) {
|
|
85
|
+
if (Array.isArray(data.projects)) list = data.projects;
|
|
86
|
+
else if (data.project_id || data.projectId || data.id) list = [data];
|
|
87
|
+
else return [];
|
|
88
|
+
}
|
|
89
|
+
return list
|
|
90
|
+
.map((p) => {
|
|
91
|
+
if (!p || typeof p !== "object") return null;
|
|
92
|
+
const projectId = p.project_id || p.projectId || p.id || null;
|
|
93
|
+
if (!projectId) return null;
|
|
94
|
+
const alias = p.alias || p.name || null;
|
|
95
|
+
const sourceType = p.source_type || p.sourceType || null;
|
|
96
|
+
const gitId = p.git_id || p.gitId || null;
|
|
97
|
+
// Identity = url+branch (ADR-017): the matched repo's INDEXED branch, and whether the caller is
|
|
98
|
+
// ON it. Default branch_match=true when a server predates this field, so old servers never nag.
|
|
99
|
+
const indexedBranch = p.branch || p.indexedBranch || null;
|
|
100
|
+
const rawMatch = p.branch_match !== undefined ? p.branch_match : p.branchMatch;
|
|
101
|
+
const branchMatch = rawMatch === undefined ? true : !!rawMatch;
|
|
102
|
+
return { project_id: projectId, alias, source_type: sourceType, git_id: gitId,
|
|
103
|
+
indexed_branch: indexedBranch, branch_match: branchMatch };
|
|
104
|
+
})
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = { resolveContext, normalizeProjects };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers to keep secret values out of any --json or log output.
|
|
3
|
+
*
|
|
4
|
+
* The agent commands emit machine-readable JSON; this module makes sure env-var
|
|
5
|
+
* VALUES, API keys, tokens and passwords are masked or omitted before printing.
|
|
6
|
+
* Nothing here ever prints — it only transforms data the caller is about to emit.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Field-name patterns that indicate a secret. Matched case-insensitively against
|
|
10
|
+
// object keys when stripping. Kept deliberately broad: env var values, tokens,
|
|
11
|
+
// keys, passwords, secrets, credentials.
|
|
12
|
+
const SECRET_KEY_PATTERN =
|
|
13
|
+
/(secret|password|passwd|pwd|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|credential|authorization|auth[_-]?token|bearer|jwt|client[_-]?secret)/i;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Mask a single secret value so its presence is visible but the value is not.
|
|
17
|
+
* - null/undefined -> returned as-is (nothing to leak).
|
|
18
|
+
* - empty string -> "" (nothing to leak).
|
|
19
|
+
* - short values -> fully masked.
|
|
20
|
+
* - longer values -> keep last 4 chars for correlation, mask the rest.
|
|
21
|
+
*/
|
|
22
|
+
function maskSecret(value) {
|
|
23
|
+
if (value === null || value === undefined) return value;
|
|
24
|
+
const s = String(value);
|
|
25
|
+
if (s.length === 0) return s;
|
|
26
|
+
// Only reveal the last 4 chars for clearly-long tokens; short secrets (which include
|
|
27
|
+
// many API keys) are fully masked so we never leak a meaningful fraction.
|
|
28
|
+
if (s.length < 16) return "****";
|
|
29
|
+
return "****" + s.slice(-4);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A VALUE that embeds credentials regardless of its key name: a connection
|
|
33
|
+
// string / URL with userinfo (scheme://user[:pass]@host — covers DATABASE_URL,
|
|
34
|
+
// MONGODB_URI, REDIS_URL, SENTRY_DSN, spring.datasource.url, ...), or a PEM
|
|
35
|
+
// private-key/cert block. The `@` must precede the first `/`, so an `@` in a URL
|
|
36
|
+
// path does not trigger, and a plain URL with no userinfo (a behavioural base
|
|
37
|
+
// URL the user WANTS to see) is not matched.
|
|
38
|
+
const SECRET_VALUE_URLINFO = /:\/\/[^\s/@]+@/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True when a VALUE looks like it carries a secret even if its key name doesn't
|
|
42
|
+
* (a credential-bearing connection string, or a PEM key block). Used to decide
|
|
43
|
+
* masking of env-var values so a password embedded in DATABASE_URL never prints.
|
|
44
|
+
*/
|
|
45
|
+
function looksSecretValue(value) {
|
|
46
|
+
if (value === null || value === undefined) return false;
|
|
47
|
+
const s = String(value);
|
|
48
|
+
if (!s) return false;
|
|
49
|
+
if (SECRET_VALUE_URLINFO.test(s)) return true;
|
|
50
|
+
if (s.includes("-----BEGIN ")) return true; // PEM private key / certificate
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Key names that denote an env-var COLLECTION (name -> value map) whose VALUES are secret. */
|
|
55
|
+
const ENV_COLLECTION_PATTERN = /^(env|envs|env_vars|environment|variables|vars)$/i;
|
|
56
|
+
|
|
57
|
+
/** True if a key name looks like it holds a secret. */
|
|
58
|
+
function isSecretKey(key) {
|
|
59
|
+
return typeof key === "string" && SECRET_KEY_PATTERN.test(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True if a key names an env-var collection whose member values must all be masked. */
|
|
63
|
+
function isEnvCollectionKey(key) {
|
|
64
|
+
return typeof key === "string" && ENV_COLLECTION_PATTERN.test(key);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Return a deep copy of `obj` with any secret-looking field masked. Use this
|
|
69
|
+
* before printing an object (e.g. as JSON) so values like api_key or env-var
|
|
70
|
+
* values never reach stdout/logs in plaintext.
|
|
71
|
+
*
|
|
72
|
+
* - Recurses into nested objects and arrays.
|
|
73
|
+
* - By default secret fields are masked (value replaced with a redacted form).
|
|
74
|
+
* - Pass { omit: true } to drop secret fields entirely instead of masking.
|
|
75
|
+
* - Env-var collections (objects of name -> value) are handled too: when the
|
|
76
|
+
* key itself isn't obviously a secret but the surrounding key marks an env
|
|
77
|
+
* map, callers should pass maskValues:true to mask every value.
|
|
78
|
+
*/
|
|
79
|
+
function stripSecrets(obj, opts = {}) {
|
|
80
|
+
const { omit = false, maskValues = false } = opts;
|
|
81
|
+
return _strip(obj, omit, maskValues);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _strip(value, omit, maskValues) {
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
return value.map((v) => _strip(v, omit, maskValues));
|
|
87
|
+
}
|
|
88
|
+
if (value && typeof value === "object") {
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const [k, v] of Object.entries(value)) {
|
|
91
|
+
if (isSecretKey(k)) {
|
|
92
|
+
if (omit) continue;
|
|
93
|
+
out[k] = v && typeof v === "object" ? _strip(v, omit, maskValues) : maskSecret(v);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// An env-var collection (e.g. env_vars: {DATABASE_URL: "postgres://u:p@h/db"}): the key
|
|
97
|
+
// names aren't secret-looking but every VALUE is. Force value-masking for this subtree so
|
|
98
|
+
// callers can't forget — a connection string with an embedded password won't leak by default.
|
|
99
|
+
if (isEnvCollectionKey(k) && v && typeof v === "object") {
|
|
100
|
+
out[k] = _strip(v, omit, true);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (maskValues && (v === null || typeof v !== "object")) {
|
|
104
|
+
out[k] = maskSecret(v);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
out[k] = _strip(v, omit, maskValues);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { maskSecret, isSecretKey, looksSecretValue, stripSecrets };
|