@0dai-dev/cli 4.2.0 → 4.3.5
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 +98 -10
- package/bin/0dai.js +298 -60
- package/lib/commands/audit.js +13 -0
- package/lib/commands/auth.js +344 -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 +39 -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 +504 -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 +104 -7
- 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 +30 -1
- package/lib/commands/swarm.js +97 -4
- package/lib/commands/tui.js +81 -13
- package/lib/commands/upgrade.js +58 -0
- 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/utils/activation_telemetry.js +156 -0
- package/lib/utils/auth.js +1 -0
- package/lib/utils/canonical-counts.js +54 -0
- package/lib/utils/constants.js +7 -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 +47 -2
- package/lib/utils/run_cost.js +91 -0
- 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 +8 -4
- package/lib/tui/index.mjs +0 -34610
package/lib/shared.js
CHANGED
|
@@ -60,7 +60,7 @@ const DRIFT_TRACKED_CONFIGS = [
|
|
|
60
60
|
];
|
|
61
61
|
|
|
62
62
|
// --- API ---
|
|
63
|
-
function apiCall(endpoint, data) {
|
|
63
|
+
function apiCall(endpoint, data, options = {}) {
|
|
64
64
|
return new Promise((resolve) => {
|
|
65
65
|
const url = new URL(endpoint, API_URL);
|
|
66
66
|
const mod = url.protocol === "https:" ? https : http;
|
|
@@ -71,11 +71,16 @@ function apiCall(endpoint, data) {
|
|
|
71
71
|
"X-CLI-Version": VERSION,
|
|
72
72
|
"X-Client-Channel": "npm",
|
|
73
73
|
};
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
const accessToken = options && options.accessToken ? String(options.accessToken).trim() : "";
|
|
75
|
+
if (accessToken) {
|
|
76
|
+
headers["Authorization"] = `Bearer ${accessToken}`;
|
|
77
|
+
} else {
|
|
78
|
+
try {
|
|
79
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
|
|
80
|
+
const token = auth.api_key || auth.access_token || auth.token;
|
|
81
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
82
|
+
} catch {}
|
|
83
|
+
}
|
|
79
84
|
const opts = {
|
|
80
85
|
hostname: url.hostname,
|
|
81
86
|
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
@@ -116,12 +121,14 @@ function updateAuthState(patch) {
|
|
|
116
121
|
saveAuthState({ ...current, ...patch });
|
|
117
122
|
}
|
|
118
123
|
|
|
119
|
-
async function fetchAuthStatus() {
|
|
120
|
-
const
|
|
121
|
-
|
|
124
|
+
async function fetchAuthStatus(accessToken = "") {
|
|
125
|
+
const token = String(accessToken || "").trim();
|
|
126
|
+
const status = await apiCall("/v1/auth/status", null, token ? { accessToken: token } : undefined);
|
|
127
|
+
if (status && !status.error && status.email && !token) {
|
|
122
128
|
updateAuthState({
|
|
123
129
|
email: status.email, plan: status.plan || "free",
|
|
124
130
|
name: status.name || "", license: status.license || {},
|
|
131
|
+
plan_expires_at: status.plan_expires_at || "",
|
|
125
132
|
});
|
|
126
133
|
}
|
|
127
134
|
return status;
|
|
@@ -302,6 +309,8 @@ function mergeSettingsJson(existing, incoming) {
|
|
|
302
309
|
function writeFiles(target, files) {
|
|
303
310
|
let created = 0, updated = 0, unchanged = 0, merged = 0, skipped = 0;
|
|
304
311
|
const targetResolved = path.resolve(target);
|
|
312
|
+
let backupDir = null;
|
|
313
|
+
const backupConfigs = new Set(["CLAUDE.md", "AGENTS.md"]);
|
|
305
314
|
for (const [rel, content] of Object.entries(files)) {
|
|
306
315
|
if (typeof rel !== "string" || !rel || path.isAbsolute(rel) || rel.split(/[/\\]/).includes("..")) {
|
|
307
316
|
skipped++; continue;
|
|
@@ -314,11 +323,16 @@ function writeFiles(target, files) {
|
|
|
314
323
|
const existing = fs.readFileSync(p, "utf8");
|
|
315
324
|
if (existing === content) { unchanged++; continue; }
|
|
316
325
|
if (rel.endsWith("settings.json")) { finalContent = mergeSettingsJson(existing, content); merged++; }
|
|
317
|
-
else if (rel
|
|
318
|
-
if (existing.includes("managed: false")) { unchanged++; continue; }
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
326
|
+
else if (backupConfigs.has(rel)) {
|
|
327
|
+
if (rel === "AGENTS.md" && existing.includes("managed: false")) { unchanged++; continue; }
|
|
328
|
+
if (!backupDir) {
|
|
329
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
330
|
+
backupDir = path.join(targetResolved, "ai", ".backups", timestamp);
|
|
331
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
332
|
+
}
|
|
333
|
+
const backupPath = path.join(backupDir, rel);
|
|
334
|
+
fs.writeFileSync(backupPath, existing, "utf8");
|
|
335
|
+
log(`backed up existing ${rel} to ${backupPath}`);
|
|
322
336
|
updated++;
|
|
323
337
|
} else { updated++; }
|
|
324
338
|
} else { created++; }
|
|
@@ -337,6 +351,7 @@ function findRepoScript(target, scriptName) {
|
|
|
337
351
|
path.join(target, "scripts", scriptName),
|
|
338
352
|
path.join(process.cwd(), "scripts", scriptName),
|
|
339
353
|
path.join(__dirname, "..", "..", "..", "scripts", scriptName),
|
|
354
|
+
path.join(__dirname, "..", "..", "..", "..", "scripts", scriptName),
|
|
340
355
|
];
|
|
341
356
|
for (const c of candidates) { if (fs.existsSync(c)) return c; }
|
|
342
357
|
return null;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
function activationPath(target) {
|
|
7
|
+
return path.join(target, "ai", "meta", "telemetry", "activation.jsonl");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function nowIso() {
|
|
11
|
+
return new Date().toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ensureDir(filePath) {
|
|
15
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readEvents(target) {
|
|
19
|
+
const file = activationPath(target);
|
|
20
|
+
if (!fs.existsSync(file)) return [];
|
|
21
|
+
const lines = fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean);
|
|
22
|
+
const events = [];
|
|
23
|
+
for (const ln of lines) {
|
|
24
|
+
try {
|
|
25
|
+
const row = JSON.parse(ln);
|
|
26
|
+
if (row && row.event) events.push(row);
|
|
27
|
+
} catch { /* ignore malformed lines */ }
|
|
28
|
+
}
|
|
29
|
+
return events;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function appendEvent(target, entry) {
|
|
33
|
+
const file = activationPath(target);
|
|
34
|
+
try {
|
|
35
|
+
ensureDir(file);
|
|
36
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n", "utf8");
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hasEvent(events, eventName, projectId) {
|
|
44
|
+
return events.some((e) => e.event === eventName && e.project_id === projectId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findInitTs(events, projectId) {
|
|
48
|
+
const init = events.find((e) => e.event === "init" && e.project_id === projectId);
|
|
49
|
+
return init && init.ts ? init.ts : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseTs(ts) {
|
|
53
|
+
const ms = Date.parse(ts);
|
|
54
|
+
return Number.isFinite(ms) ? ms : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Idempotently append `{event:"init", ts, project_id}` on successful init.
|
|
58
|
+
function recordActivationInit(target, projectId) {
|
|
59
|
+
try {
|
|
60
|
+
if (!projectId) return { fired: false };
|
|
61
|
+
const events = readEvents(target);
|
|
62
|
+
if (hasEvent(events, "init", projectId)) return { fired: false };
|
|
63
|
+
const entry = { event: "init", ts: nowIso(), project_id: projectId };
|
|
64
|
+
return { fired: appendEvent(target, entry) };
|
|
65
|
+
} catch {
|
|
66
|
+
return { fired: false };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Idempotently append first_task with duration_ms = first_task.ts - init.ts.
|
|
71
|
+
function recordActivationFirstTask(target, projectId) {
|
|
72
|
+
try {
|
|
73
|
+
if (!projectId) return { fired: false, durationMs: null };
|
|
74
|
+
const events = readEvents(target);
|
|
75
|
+
if (hasEvent(events, "first_task", projectId)) {
|
|
76
|
+
return { fired: false, durationMs: null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const initTs = findInitTs(events, projectId);
|
|
80
|
+
const ts = nowIso();
|
|
81
|
+
let durationMs = null;
|
|
82
|
+
if (initTs) {
|
|
83
|
+
const initMs = parseTs(initTs);
|
|
84
|
+
const taskMs = parseTs(ts);
|
|
85
|
+
if (initMs != null && taskMs != null) {
|
|
86
|
+
durationMs = Math.max(0, taskMs - initMs);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const entry = {
|
|
91
|
+
event: "first_task",
|
|
92
|
+
ts,
|
|
93
|
+
project_id: projectId,
|
|
94
|
+
duration_ms: durationMs,
|
|
95
|
+
};
|
|
96
|
+
const fired = appendEvent(target, entry);
|
|
97
|
+
return { fired, durationMs };
|
|
98
|
+
} catch {
|
|
99
|
+
return { fired: false, durationMs: null };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function percentile(sorted, p) {
|
|
104
|
+
if (!sorted.length) return null;
|
|
105
|
+
if (sorted.length === 1) return sorted[0];
|
|
106
|
+
const idx = (sorted.length - 1) * p;
|
|
107
|
+
const lo = Math.floor(idx);
|
|
108
|
+
const hi = Math.ceil(idx);
|
|
109
|
+
if (lo === hi) return sorted[lo];
|
|
110
|
+
const weight = idx - lo;
|
|
111
|
+
return Math.round(sorted[lo] * (1 - weight) + sorted[hi] * weight);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getActivationDurationStats(target) {
|
|
115
|
+
const events = readEvents(target);
|
|
116
|
+
const durations = events
|
|
117
|
+
.filter((e) => e.event === "first_task"
|
|
118
|
+
&& typeof e.duration_ms === "number"
|
|
119
|
+
&& Number.isFinite(e.duration_ms))
|
|
120
|
+
.map((e) => e.duration_ms)
|
|
121
|
+
.sort((a, b) => a - b);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
count: durations.length,
|
|
125
|
+
p50_ms: percentile(durations, 0.5),
|
|
126
|
+
p90_ms: percentile(durations, 0.9),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatDurationMs(ms) {
|
|
131
|
+
if (ms == null) return "?";
|
|
132
|
+
if (ms < 1000) return `${ms}ms`;
|
|
133
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
134
|
+
return `${(ms / 60000).toFixed(1)}m`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function printActivationStats(target) {
|
|
138
|
+
const stats = getActivationDurationStats(target);
|
|
139
|
+
if (stats.count === 0) return stats;
|
|
140
|
+
console.log(
|
|
141
|
+
` activation TTFV: p50 ${formatDurationMs(stats.p50_ms)}`
|
|
142
|
+
+ ` / p90 ${formatDurationMs(stats.p90_ms)}`
|
|
143
|
+
+ ` (${stats.count} sample${stats.count === 1 ? "" : "s"})`,
|
|
144
|
+
);
|
|
145
|
+
return stats;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
activationPath,
|
|
150
|
+
readEvents,
|
|
151
|
+
recordActivationInit,
|
|
152
|
+
recordActivationFirstTask,
|
|
153
|
+
getActivationDurationStats,
|
|
154
|
+
printActivationStats,
|
|
155
|
+
formatDurationMs,
|
|
156
|
+
};
|
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 };
|
package/lib/utils/constants.js
CHANGED
|
@@ -46,6 +46,13 @@ const SUPPORTED_CLIS = [
|
|
|
46
46
|
altAuth: null,
|
|
47
47
|
agentFiles: [".qoder/settings.json"],
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
name: "cursor", bin: "cursor-agent",
|
|
51
|
+
pkg: null, pkgType: "curl",
|
|
52
|
+
install: "curl https://cursor.com/install -fsS | bash",
|
|
53
|
+
altAuth: "Cursor Pro/Business subscription",
|
|
54
|
+
agentFiles: [".cursor/mcp.json", ".cursor/hooks.json"],
|
|
55
|
+
},
|
|
49
56
|
];
|
|
50
57
|
|
|
51
58
|
const MANIFEST_FILES = [
|
|
@@ -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
|
};
|