@0dai-dev/cli 4.2.0 → 4.3.4
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 +30 -5
- package/bin/0dai.js +289 -60
- package/lib/commands/audit.js +13 -0
- package/lib/commands/auth.js +341 -98
- package/lib/commands/boneyard.js +44 -0
- package/lib/commands/ci.js +329 -0
- package/lib/commands/compliance.js +20 -0
- package/lib/commands/doctor.js +20 -1
- package/lib/commands/experience.js +5 -1
- package/lib/commands/feedback.js +92 -5
- package/lib/commands/gh.js +506 -0
- package/lib/commands/graph.js +78 -10
- package/lib/commands/heatmap.js +17 -0
- package/lib/commands/import_claude_code_agents.js +367 -0
- package/lib/commands/init.js +440 -28
- package/lib/commands/loop.js +108 -0
- package/lib/commands/mcp.js +410 -0
- package/lib/commands/models.js +27 -3
- package/lib/commands/paste.js +114 -0
- package/lib/commands/play.js +173 -0
- package/lib/commands/provider.js +69 -0
- package/lib/commands/quota.js +76 -0
- package/lib/commands/receipt.js +53 -0
- package/lib/commands/report.js +29 -2
- package/lib/commands/run.js +44 -4
- package/lib/commands/runner.js +527 -0
- package/lib/commands/session.js +1 -7
- package/lib/commands/standup.js +40 -0
- package/lib/commands/status.js +26 -1
- package/lib/commands/swarm.js +97 -4
- package/lib/commands/tui.js +81 -13
- package/lib/commands/usage.js +87 -0
- package/lib/commands/vault.js +246 -0
- package/lib/onboarding.js +9 -3
- package/lib/shared.js +29 -14
- package/lib/tui/index.mjs +571 -187
- package/lib/utils/auth.js +1 -0
- package/lib/utils/canonical-counts.js +54 -0
- package/lib/utils/diff-preview.js +192 -0
- package/lib/utils/identity.js +76 -18
- package/lib/utils/mcp-auth.js +607 -0
- package/lib/utils/plan.js +37 -2
- package/lib/vault/cipher.js +125 -0
- package/lib/vault/identity.js +122 -0
- package/lib/vault/index.js +184 -0
- package/lib/vault/storage.js +84 -0
- package/lib/wizard.js +19 -12
- package/package.json +2 -2
package/lib/utils/auth.js
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime reader for generated canonical product counts.
|
|
3
|
+
*/
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { SUPPORTED_CLIS } = require("./constants");
|
|
9
|
+
|
|
10
|
+
const FALLBACK = Object.freeze({
|
|
11
|
+
schema_version: 0,
|
|
12
|
+
supported_agents: SUPPORTED_CLIS.map((cli) => cli.name),
|
|
13
|
+
supported_agents_total: SUPPORTED_CLIS.length,
|
|
14
|
+
supported_runtime_agents: SUPPORTED_CLIS.map((cli) => cli.name),
|
|
15
|
+
supported_runtime_agents_total: SUPPORTED_CLIS.length,
|
|
16
|
+
supported_agent_clis: SUPPORTED_CLIS.map((cli) => cli.name),
|
|
17
|
+
supported_agent_cli_display_names: SUPPORTED_CLIS.map((cli) => cli.name),
|
|
18
|
+
agent_clis_total: SUPPORTED_CLIS.length,
|
|
19
|
+
mcp_tools_total: null,
|
|
20
|
+
free_mcp_tools_total: null,
|
|
21
|
+
pro_mcp_tools_total: null,
|
|
22
|
+
cli_commands_total: null,
|
|
23
|
+
integrations_total: null,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function candidatePaths() {
|
|
27
|
+
const envPath = process.env.ODAI_CANONICAL_COUNTS_PATH;
|
|
28
|
+
return [
|
|
29
|
+
envPath,
|
|
30
|
+
path.resolve(__dirname, "../../../../ai/manifest/canonical-counts.json"),
|
|
31
|
+
path.resolve(process.cwd(), "ai/manifest/canonical-counts.json"),
|
|
32
|
+
].filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadCanonicalCounts() {
|
|
36
|
+
for (const candidate of candidatePaths()) {
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(candidate)) continue;
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
40
|
+
if (parsed && typeof parsed === "object") {
|
|
41
|
+
return { ...FALLBACK, ...parsed };
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
return { ...FALLBACK };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mcpToolsLabel(counts = loadCanonicalCounts()) {
|
|
49
|
+
return Number.isInteger(counts.mcp_tools_total)
|
|
50
|
+
? `${counts.mcp_tools_total} MCP tools`
|
|
51
|
+
: "MCP tools";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { loadCanonicalCounts, mcpToolsLabel };
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const readline = require("readline");
|
|
5
|
+
|
|
6
|
+
function _lines(text) {
|
|
7
|
+
if (text == null) return [];
|
|
8
|
+
const s = String(text);
|
|
9
|
+
return s.length === 0 ? [] : s.split(/\r?\n/);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function unifiedDiff(relPath, before, after, context = 3) {
|
|
13
|
+
const a = _lines(before);
|
|
14
|
+
const b = _lines(after);
|
|
15
|
+
if (a.join("\n") === b.join("\n")) return "";
|
|
16
|
+
|
|
17
|
+
const n = a.length;
|
|
18
|
+
const m = b.length;
|
|
19
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
20
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
21
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
22
|
+
if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1;
|
|
23
|
+
else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ops = [];
|
|
28
|
+
let i = 0, j = 0;
|
|
29
|
+
while (i < n && j < m) {
|
|
30
|
+
if (a[i] === b[j]) { ops.push(["=", a[i]]); i++; j++; }
|
|
31
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push(["-", a[i]]); i++; }
|
|
32
|
+
else { ops.push(["+", b[j]]); j++; }
|
|
33
|
+
}
|
|
34
|
+
while (i < n) { ops.push(["-", a[i++]]); }
|
|
35
|
+
while (j < m) { ops.push(["+", b[j++]]); }
|
|
36
|
+
|
|
37
|
+
const hunks = [];
|
|
38
|
+
let k = 0;
|
|
39
|
+
while (k < ops.length) {
|
|
40
|
+
if (ops[k][0] === "=") { k++; continue; }
|
|
41
|
+
let start = Math.max(0, k - context);
|
|
42
|
+
let end = k;
|
|
43
|
+
while (end < ops.length) {
|
|
44
|
+
if (ops[end][0] !== "=") { end++; continue; }
|
|
45
|
+
let run = 0;
|
|
46
|
+
let peek = end;
|
|
47
|
+
while (peek < ops.length && ops[peek][0] === "=") { peek++; run++; }
|
|
48
|
+
if (run > context * 2 || peek === ops.length) {
|
|
49
|
+
end += Math.min(run, context);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
end = peek;
|
|
53
|
+
}
|
|
54
|
+
hunks.push([start, end]);
|
|
55
|
+
k = end;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const out = [`--- a/${relPath}`, `+++ b/${relPath}`];
|
|
59
|
+
for (const [hs, he] of hunks) {
|
|
60
|
+
let oldStart = 0, oldLen = 0, newStart = 0, newLen = 0;
|
|
61
|
+
for (let p = 0; p < hs; p++) {
|
|
62
|
+
if (ops[p][0] !== "+") oldStart++;
|
|
63
|
+
if (ops[p][0] !== "-") newStart++;
|
|
64
|
+
}
|
|
65
|
+
for (let p = hs; p < he; p++) {
|
|
66
|
+
if (ops[p][0] !== "+") oldLen++;
|
|
67
|
+
if (ops[p][0] !== "-") newLen++;
|
|
68
|
+
}
|
|
69
|
+
out.push(`@@ -${oldStart + 1},${oldLen} +${newStart + 1},${newLen} @@`);
|
|
70
|
+
for (let p = hs; p < he; p++) {
|
|
71
|
+
const [tag, line] = ops[p];
|
|
72
|
+
const prefix = tag === "=" ? " " : tag;
|
|
73
|
+
out.push(prefix + line);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isHashOnlyFile(value) {
|
|
80
|
+
return !!(
|
|
81
|
+
value &&
|
|
82
|
+
typeof value === "object" &&
|
|
83
|
+
!Array.isArray(value) &&
|
|
84
|
+
Number.isFinite(Number(value.size)) &&
|
|
85
|
+
typeof value.sha256 === "string" &&
|
|
86
|
+
/^[a-f0-9]{64}$/i.test(value.sha256)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatFileSize(size) {
|
|
91
|
+
const bytes = Number(size) || 0;
|
|
92
|
+
if (bytes < 1000) return `${bytes}B`;
|
|
93
|
+
if (bytes < 1000 * 1000) return `${Math.round(bytes / 1000)}KB`;
|
|
94
|
+
return `${Math.round(bytes / 100000) / 10}MB`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function shortSha256(sha256) {
|
|
98
|
+
const value = String(sha256 || "");
|
|
99
|
+
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hashText(text) {
|
|
103
|
+
return crypto.createHash("sha256").update(String(text), "utf8").digest("hex");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function descriptorForText(text) {
|
|
107
|
+
const value = String(text);
|
|
108
|
+
return {
|
|
109
|
+
size: Buffer.byteLength(value, "utf8"),
|
|
110
|
+
sha256: hashText(value),
|
|
111
|
+
compare: "hash-only",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatHashOnlyFile(value) {
|
|
116
|
+
return `size: ${formatFileSize(value.size)} sha256: ${shortSha256(value.sha256)} [hash-only compare]`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fileValueEqual(a, b) {
|
|
120
|
+
if (a === b) return true;
|
|
121
|
+
if (a == null || b == null) return a == null && b == null;
|
|
122
|
+
const aHashOnly = isHashOnlyFile(a);
|
|
123
|
+
const bHashOnly = isHashOnlyFile(b);
|
|
124
|
+
if (!aHashOnly && !bHashOnly) return false;
|
|
125
|
+
const aDescriptor = aHashOnly ? a : descriptorForText(a);
|
|
126
|
+
const bDescriptor = bHashOnly ? b : descriptorForText(b);
|
|
127
|
+
return Number(aDescriptor.size) === Number(bDescriptor.size) &&
|
|
128
|
+
String(aDescriptor.sha256).toLowerCase() === String(bDescriptor.sha256).toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function valueForDiff(value, peerValue) {
|
|
132
|
+
if (isHashOnlyFile(value)) return formatHashOnlyFile(value);
|
|
133
|
+
if (value == null) return "";
|
|
134
|
+
if (isHashOnlyFile(peerValue)) return formatHashOnlyFile(descriptorForText(value));
|
|
135
|
+
return String(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderFileMapDiff(changes, currentFiles = {}) {
|
|
139
|
+
const parts = [];
|
|
140
|
+
const summary = { added: [], modified: [], removed: [] };
|
|
141
|
+
for (const rel of Object.keys(changes).sort()) {
|
|
142
|
+
const hasCurrent = Object.prototype.hasOwnProperty.call(currentFiles, rel);
|
|
143
|
+
const before = hasCurrent ? currentFiles[rel] : "";
|
|
144
|
+
const after = changes[rel];
|
|
145
|
+
if (fileValueEqual(before, after)) continue;
|
|
146
|
+
if (!hasCurrent) summary.added.push(rel);
|
|
147
|
+
else if (after == null) summary.removed.push(rel);
|
|
148
|
+
else summary.modified.push(rel);
|
|
149
|
+
const d = unifiedDiff(rel, valueForDiff(before, after), valueForDiff(after, before));
|
|
150
|
+
if (d) parts.push(d);
|
|
151
|
+
}
|
|
152
|
+
return { diff: parts.join("\n"), summary };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function shouldAutoYes(args = []) {
|
|
156
|
+
if (args.includes("--yes") || args.includes("-y")) return true;
|
|
157
|
+
if (process.env.ODAI_ASSUME_YES === "1") return true;
|
|
158
|
+
if (process.env.CI === "true") return true;
|
|
159
|
+
if (!process.stdin.isTTY) return true;
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function promptConfirm(question, defaultYes = false) {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
if (!process.stdin.isTTY) { resolve(defaultYes); return; }
|
|
166
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
167
|
+
const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
|
|
168
|
+
rl.question(question + suffix, (ans) => {
|
|
169
|
+
rl.close();
|
|
170
|
+
const a = String(ans || "").trim().toLowerCase();
|
|
171
|
+
if (!a) return resolve(defaultYes);
|
|
172
|
+
resolve(a === "y" || a === "yes");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function confirmOrExit({ args = [], quiet = false, message = "Apply these changes?", defaultYes = false } = {}) {
|
|
178
|
+
if (quiet) return true;
|
|
179
|
+
if (shouldAutoYes(args)) return true;
|
|
180
|
+
const ok = await promptConfirm(message, defaultYes);
|
|
181
|
+
return ok;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
unifiedDiff,
|
|
186
|
+
renderFileMapDiff,
|
|
187
|
+
isHashOnlyFile,
|
|
188
|
+
formatHashOnlyFile,
|
|
189
|
+
shouldAutoYes,
|
|
190
|
+
promptConfirm,
|
|
191
|
+
confirmOrExit,
|
|
192
|
+
};
|
package/lib/utils/identity.js
CHANGED
|
@@ -30,31 +30,85 @@ function deviceFingerprint() {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function registerProject(projectPath, name, stack, CONFIG_DIR, PROJECTS_FILE) {
|
|
33
|
+
if (!CONFIG_DIR || !PROJECTS_FILE) {
|
|
34
|
+
throw new Error("registerProject requires CONFIG_DIR and PROJECTS_FILE");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
38
|
+
let projects = [];
|
|
39
|
+
if (fs.existsSync(PROJECTS_FILE)) {
|
|
40
|
+
const parsed = JSON.parse(fs.readFileSync(PROJECTS_FILE, "utf8"));
|
|
41
|
+
projects = Array.isArray(parsed && parsed.projects) ? parsed.projects : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const abs = path.resolve(projectPath);
|
|
45
|
+
const idx = projects.findIndex(p => p.path === abs);
|
|
46
|
+
const entry = { path: abs, name: name || path.basename(abs), stack: stack || "?", last_seen: new Date().toISOString() };
|
|
47
|
+
if (idx >= 0) projects[idx] = entry;
|
|
48
|
+
else projects.unshift(entry);
|
|
49
|
+
fs.writeFileSync(PROJECTS_FILE, JSON.stringify({ projects: projects.slice(0, 50) }, null, 2));
|
|
50
|
+
return entry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scrubRemoteUrl(url) {
|
|
54
|
+
return String(url || "").replace(/^(https?:\/\/)[^@/\s]+@/, "$1");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseScalar(value) {
|
|
58
|
+
return String(value || "").trim().replace(/^["']|["']$/g, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readProjectManifest(target) {
|
|
62
|
+
const file = path.join(target, "ai", "manifest", "project.yaml");
|
|
63
|
+
const result = {};
|
|
64
|
+
let inProject = false;
|
|
33
65
|
try {
|
|
34
|
-
fs.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
|
|
67
|
+
for (const raw of lines) {
|
|
68
|
+
const line = raw.trim();
|
|
69
|
+
if (!line || line.startsWith("#")) continue;
|
|
70
|
+
const topLevel = raw.length === raw.trimStart().length;
|
|
71
|
+
if (topLevel) inProject = false;
|
|
72
|
+
if (topLevel && line === "project:") {
|
|
73
|
+
inProject = true;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (topLevel && line.startsWith("name:")) result.name = parseScalar(line.split(":", 2)[1]);
|
|
77
|
+
else if (topLevel && line.startsWith("stack:")) result.stack = parseScalar(line.split(":", 2)[1]);
|
|
78
|
+
else if (topLevel && line.startsWith("repo:")) result.repo = scrubRemoteUrl(parseScalar(line.split(":", 2)[1]));
|
|
79
|
+
else if (inProject && line.startsWith("name:") && !result.name) result.name = parseScalar(line.split(":", 2)[1]);
|
|
80
|
+
else if (inProject && line.startsWith("stack:") && !result.stack) result.stack = parseScalar(line.split(":", 2)[1]);
|
|
81
|
+
else if (inProject && line.startsWith("type:") && !result.stack) result.stack = parseScalar(line.split(":", 2)[1]);
|
|
82
|
+
else if (inProject && line.startsWith("repo:") && !result.repo) result.repo = scrubRemoteUrl(parseScalar(line.split(":", 2)[1]));
|
|
83
|
+
}
|
|
43
84
|
} catch {}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readDiscovery(target) {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
44
94
|
}
|
|
45
95
|
|
|
46
96
|
function projectIdFor(target, projectName, remoteOrigin) {
|
|
47
97
|
const crypto = require("crypto");
|
|
48
|
-
const seed =
|
|
98
|
+
const seed = projectIdSeed(projectName, remoteOrigin || path.resolve(target));
|
|
49
99
|
return "prj_" + crypto.createHash("sha256").update(seed).digest("hex").slice(0, 16);
|
|
50
100
|
}
|
|
51
101
|
|
|
102
|
+
function projectIdSeed(projectName, origin) {
|
|
103
|
+
return `{"name": ${JSON.stringify(projectName)}, "origin": ${JSON.stringify(origin)}}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
52
106
|
function getGitRemoteOrigin(target) {
|
|
53
107
|
try {
|
|
54
108
|
const { execFileSync } = require("child_process");
|
|
55
|
-
return execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
109
|
+
return scrubRemoteUrl(execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
56
110
|
cwd: target, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000,
|
|
57
|
-
}).trim();
|
|
111
|
+
}).trim());
|
|
58
112
|
} catch { return ""; }
|
|
59
113
|
}
|
|
60
114
|
|
|
@@ -127,21 +181,25 @@ function collectMetadata(target) {
|
|
|
127
181
|
}
|
|
128
182
|
|
|
129
183
|
function buildProjectIdentity(target, metadata, detectedStack = "") {
|
|
130
|
-
const
|
|
184
|
+
const manifest = readProjectManifest(target);
|
|
185
|
+
const discovery = readDiscovery(target);
|
|
186
|
+
const projectName = manifest.name || inferProjectName(target, metadata.manifestContents);
|
|
131
187
|
const remoteOrigin = getGitRemoteOrigin(target);
|
|
188
|
+
const originValue = remoteOrigin || manifest.repo || path.resolve(target);
|
|
132
189
|
return {
|
|
133
190
|
project_name: projectName,
|
|
134
|
-
stack: detectedStack || detectStackHint(metadata.projectFiles, metadata.manifestContents),
|
|
135
|
-
project_id: projectIdFor(target, projectName,
|
|
136
|
-
origin: remoteOrigin ? "git" : "local",
|
|
137
|
-
remote_origin: remoteOrigin,
|
|
191
|
+
stack: detectedStack || manifest.stack || discovery.stack || detectStackHint(metadata.projectFiles, metadata.manifestContents),
|
|
192
|
+
project_id: projectIdFor(target, projectName, originValue),
|
|
193
|
+
origin: remoteOrigin ? "git" : (manifest.repo ? "manifest" : "local"),
|
|
194
|
+
remote_origin: remoteOrigin || manifest.repo || "",
|
|
138
195
|
binding_source: "npm-cli",
|
|
139
196
|
};
|
|
140
197
|
}
|
|
141
198
|
|
|
142
199
|
module.exports = {
|
|
143
200
|
MANIFEST_FILES, PROBE_DIRS,
|
|
144
|
-
deviceFingerprint, registerProject, projectIdFor,
|
|
201
|
+
deviceFingerprint, registerProject, projectIdFor, projectIdSeed,
|
|
202
|
+
scrubRemoteUrl, readProjectManifest, readDiscovery,
|
|
145
203
|
getGitRemoteOrigin, inferProjectName, detectStackHint,
|
|
146
204
|
collectMetadata, buildProjectIdentity,
|
|
147
205
|
};
|