@apdesign/cursor-roi-tracker 0.5.2

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.
@@ -0,0 +1,336 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { spawnSync } = require("child_process");
4
+ const { loadConfig } = require("./config");
5
+ const { getGitDir, runGit } = require("./git");
6
+
7
+ const EVENTS_EXCLUDE_ENTRY = ".cursor/animus-ai-events.jsonl";
8
+ const EXTENSION_VSIX_RELATIVE_PATH = path.join("extension", "animus-cursor-roi-tracker.vsix");
9
+ const ANSI_YELLOW = "\x1b[33m";
10
+ const ANSI_RED = "\x1b[31m";
11
+ const ANSI_RESET = "\x1b[0m";
12
+
13
+ async function runInstallHooks() {
14
+ const isAutoMode = process.argv.includes("--auto");
15
+ const hostProjectRoot = resolveHostProjectRoot();
16
+ const packageRoot = path.resolve(__dirname, "..");
17
+ try {
18
+ const repoRoot = resolveTargetRepoRoot(hostProjectRoot);
19
+ await trySilentInstallEditorExtension({ packageRoot, isAutoMode });
20
+
21
+ if (!repoRoot) {
22
+ if (!isAutoMode) {
23
+ process.stderr.write("cursor-roi-tracker: no git repo found, skip hook install.\n");
24
+ }
25
+ return;
26
+ }
27
+
28
+ const hooksDir = path.join(getGitDir(repoRoot), "hooks");
29
+ const preCommitPath = path.join(hooksDir, "pre-commit");
30
+ const postCommitPath = path.join(hooksDir, "post-commit");
31
+
32
+ fs.mkdirSync(hooksDir, { recursive: true });
33
+
34
+ const preBlock = buildManagedBlock({
35
+ packageRoot,
36
+ script: "bin/pre-commit.js",
37
+ marker: "cursor-roi-tracker pre-commit",
38
+ });
39
+ const postBlock = buildManagedBlock({
40
+ packageRoot,
41
+ script: "bin/post-commit.js",
42
+ marker: "cursor-roi-tracker post-commit",
43
+ });
44
+
45
+ writeHook(preCommitPath, preBlock);
46
+ writeHook(postCommitPath, postBlock);
47
+ ensureDefaultConfig(repoRoot);
48
+ ensureEventsFileExcluded(repoRoot);
49
+ await exchangeSilentToken(repoRoot, isAutoMode);
50
+
51
+ if (!isAutoMode) {
52
+ process.stdout.write("cursor-roi-tracker hooks installed.\n");
53
+ }
54
+ } catch (error) {
55
+ if (!isAutoMode) {
56
+ process.stderr.write(`cursor-roi-tracker hook install failed: ${error.message}\n`);
57
+ }
58
+ }
59
+ }
60
+
61
+ async function exchangeSilentToken(repoRoot, isAutoMode) {
62
+ const config = loadConfig(repoRoot);
63
+ const serverBaseUrl = resolveServerBaseUrl(config);
64
+ if (!serverBaseUrl) {
65
+ return;
66
+ }
67
+
68
+ const email = resolveEmailWithFallback(repoRoot);
69
+ if (!email) {
70
+ warn(
71
+ `cursor-roi-tracker: missing email; run git config --global cursor.email "你的邮箱".`,
72
+ isAutoMode
73
+ );
74
+ return;
75
+ }
76
+
77
+ try {
78
+ const endpoint = new URL(config.silentTokenPath || "/api/cursor-board/auth/silent-token", serverBaseUrl);
79
+ const payload = await requestJson(endpoint.toString(), {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({ email }),
83
+ }, config.requestTimeoutMs);
84
+
85
+ const accessToken = String(payload?.accessToken || "").trim();
86
+ if (!accessToken) {
87
+ throw new Error("silent token response missing accessToken");
88
+ }
89
+
90
+ const gitDir = getGitDir(repoRoot);
91
+ writeLocalToken(gitDir, accessToken);
92
+ } catch (error) {
93
+ warn(`cursor-roi-tracker: silent token exchange failed: ${error.message}`, isAutoMode);
94
+ }
95
+ }
96
+
97
+ function buildManagedBlock({ packageRoot, script, marker }) {
98
+ return `
99
+ # >>> cursor-roi-tracker begin
100
+ # ${marker}
101
+ PACKAGE_ROOT="${packageRoot}"
102
+ if [ -f "$PACKAGE_ROOT/${script}" ]; then
103
+ node "$PACKAGE_ROOT/${script}" || true
104
+ fi
105
+ # <<< cursor-roi-tracker end
106
+ `;
107
+ }
108
+
109
+ function writeHook(hookPath, managedBlock) {
110
+ const shebang = "#!/usr/bin/env sh";
111
+ const current = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, "utf8") : "";
112
+
113
+ let body = current.trim() ? current : shebang;
114
+ if (!body.startsWith("#!")) {
115
+ body = `${shebang}\n${body}`;
116
+ }
117
+
118
+ body = body.replace(
119
+ /\n?# >>> cursor-roi-tracker begin[\s\S]*?# <<< cursor-roi-tracker end\n?/g,
120
+ "\n"
121
+ );
122
+ const nextBody = `${body.trimEnd()}\n${managedBlock.trim()}\n`;
123
+ fs.writeFileSync(hookPath, nextBody, { mode: 0o755 });
124
+ }
125
+
126
+ function resolveTargetRepoRoot(hostProjectRoot) {
127
+ const candidateCwds = [hostProjectRoot, process.cwd(), process.env.INIT_CWD].filter(Boolean);
128
+
129
+ for (const cwd of candidateCwds) {
130
+ const repoRoot = findGitRoot(cwd);
131
+ if (repoRoot) {
132
+ return repoRoot;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function findGitRoot(startDir) {
139
+ if (!startDir) {
140
+ return null;
141
+ }
142
+ const resolved = path.resolve(startDir);
143
+ if (fs.existsSync(path.join(resolved, ".git"))) {
144
+ return resolved;
145
+ }
146
+ try {
147
+ return runGit(resolved, ["rev-parse", "--show-toplevel"]).trim();
148
+ } catch (_error) {
149
+ const parent = path.dirname(resolved);
150
+ if (parent === resolved) {
151
+ return null;
152
+ }
153
+ return findGitRoot(parent);
154
+ }
155
+ }
156
+
157
+ function ensureDefaultConfig(repoRoot) {
158
+ const configPath = path.join(repoRoot, ".cursor-roi-tracker.json");
159
+ if (fs.existsSync(configPath)) {
160
+ return;
161
+ }
162
+ const payload = {
163
+ highThreshold: 0.85,
164
+ sourceExtensions: [".ts", ".tsx", ".js", ".jsx", ".vue", ".css", ".scss", ".html"],
165
+ excludeRegexes: [
166
+ "(^|/)dist/",
167
+ "(^|/)build/",
168
+ "\\.min\\.",
169
+ "(^|/)package-lock\\.json$",
170
+ "(^|/)pnpm-lock\\.yaml$",
171
+ "(^|/)yarn\\.lock$",
172
+ ],
173
+ eventsDirectory: [".cursor/animus-ai-events.jsonl", ".cursor/local-ai-events"],
174
+ membersFile: ".cursor/enginuity_yunxiao_members.names.json",
175
+ serverBaseUrl: "",
176
+ silentTokenPath: "/api/cursor-board/auth/silent-token",
177
+ commitReportPath: "/api/cursor-board/commit-reports",
178
+ requestTimeoutMs: 5000,
179
+ };
180
+ fs.writeFileSync(configPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
181
+ }
182
+
183
+ function ensureEventsFileExcluded(repoRoot) {
184
+ const dotGitPath = path.join(repoRoot, ".git");
185
+ if (!fs.existsSync(dotGitPath)) {
186
+ return;
187
+ }
188
+ try {
189
+ const gitDir = getGitDir(repoRoot);
190
+ appendExcludeEntry(gitDir, EVENTS_EXCLUDE_ENTRY);
191
+ } catch (_error) {
192
+ // never block install on local exclude setup
193
+ }
194
+ }
195
+
196
+ function resolveEmailWithFallback(repoRoot) {
197
+ const candidates = [
198
+ ["config", "cursor.email"],
199
+ ["config", "user.email"],
200
+ ];
201
+ for (const args of candidates) {
202
+ try {
203
+ const value = runGit(repoRoot, args).trim();
204
+ if (value) {
205
+ return value;
206
+ }
207
+ } catch (_error) {
208
+ // ignore and fallback
209
+ }
210
+ }
211
+ return "";
212
+ }
213
+
214
+ function resolveServerBaseUrl(config) {
215
+ const fromEnv = String(process.env.CURSOR_ROI_SERVER_URL || "").trim();
216
+ const fromConfig = String(config?.serverBaseUrl || "").trim();
217
+ const value = fromEnv || fromConfig;
218
+ if (!value) {
219
+ return "";
220
+ }
221
+ return value.endsWith("/") ? value : `${value}/`;
222
+ }
223
+
224
+ function writeLocalToken(gitDir, accessToken) {
225
+ const tokenPath = path.join(gitDir, ".local-token");
226
+ fs.writeFileSync(tokenPath, `${accessToken}\n`, "utf8");
227
+
228
+ appendExcludeEntry(gitDir, ".local-token");
229
+ }
230
+
231
+ function appendExcludeEntry(gitDir, line) {
232
+ const infoDir = path.join(gitDir, "info");
233
+ fs.mkdirSync(infoDir, { recursive: true });
234
+ const excludePath = path.join(infoDir, "exclude");
235
+ const existing = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
236
+ const lines = new Set(existing.split(/\r?\n/).map((item) => item.trim()).filter(Boolean));
237
+ if (lines.has(line)) {
238
+ return;
239
+ }
240
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
241
+ fs.appendFileSync(excludePath, `${prefix}${line}\n`, "utf8");
242
+ }
243
+
244
+ function resolveHostProjectRoot() {
245
+ const initCwd = String(process.env.INIT_CWD || "").trim();
246
+ if (initCwd) {
247
+ return path.resolve(initCwd);
248
+ }
249
+ return path.resolve(process.cwd());
250
+ }
251
+
252
+ async function trySilentInstallEditorExtension({ packageRoot, isAutoMode }) {
253
+ const vsixPath = path.join(packageRoot, EXTENSION_VSIX_RELATIVE_PATH);
254
+ const availableCli = detectAvailableEditorCli();
255
+ if (!availableCli.length) {
256
+ warnInstallFailure(
257
+ "no cursor/code CLI found in PATH",
258
+ vsixPath,
259
+ isAutoMode
260
+ );
261
+ return;
262
+ }
263
+ if (!fs.existsSync(vsixPath)) {
264
+ warnInstallFailure("local VSIX not found", vsixPath, isAutoMode);
265
+ return;
266
+ }
267
+
268
+ for (const cli of availableCli) {
269
+ const result = spawnSync(cli, ["--install-extension", vsixPath, "--force"], {
270
+ encoding: "utf8",
271
+ timeout: 15000,
272
+ });
273
+ if (result.status === 0) {
274
+ return;
275
+ }
276
+ }
277
+
278
+ warnInstallFailure("CLI install command failed", vsixPath, isAutoMode);
279
+ }
280
+
281
+ function detectAvailableEditorCli() {
282
+ const candidates = process.platform === "win32"
283
+ ? ["cursor.cmd", "cursor", "code.cmd", "code"]
284
+ : ["cursor", "code"];
285
+ const available = [];
286
+ for (const command of candidates) {
287
+ const probe = spawnSync(command, ["--version"], { encoding: "utf8", timeout: 5000 });
288
+ if (probe.status === 0) {
289
+ available.push(command);
290
+ }
291
+ }
292
+ return available;
293
+ }
294
+
295
+ function warnInstallFailure(reason, vsixPath, isAutoMode) {
296
+ const header = `${ANSI_YELLOW}cursor-roi-tracker warning:${ANSI_RESET} ${reason}`;
297
+ const detail = `${ANSI_RED}Manual install required${ANSI_RESET}: install VSIX from ${vsixPath}`;
298
+ process.stderr.write(`${header}\n`);
299
+ process.stderr.write(`${detail}\n`);
300
+ process.stderr.write('Steps: open Extensions panel -> "Install from VSIX..." -> choose the path above.\n');
301
+ if (!isAutoMode) {
302
+ process.stderr.write("cursor-roi-tracker: continuing without blocking npm install.\n");
303
+ }
304
+ }
305
+
306
+ async function requestJson(url, init, timeoutMs = 5000) {
307
+ if (typeof fetch !== "function") {
308
+ throw new Error("fetch is not available on this Node runtime");
309
+ }
310
+ const controller = new AbortController();
311
+ const timer = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 5000));
312
+ try {
313
+ const response = await fetch(url, { ...init, signal: controller.signal });
314
+ const text = await response.text();
315
+ let payload = {};
316
+ if (text) {
317
+ payload = JSON.parse(text);
318
+ }
319
+ if (!response.ok) {
320
+ throw new Error(`HTTP ${response.status}`);
321
+ }
322
+ return payload;
323
+ } finally {
324
+ clearTimeout(timer);
325
+ }
326
+ }
327
+
328
+ function warn(message, isAutoMode) {
329
+ if (!isAutoMode) {
330
+ process.stderr.write(`${message}\n`);
331
+ }
332
+ }
333
+
334
+ module.exports = {
335
+ runInstallHooks,
336
+ };
@@ -0,0 +1,176 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { loadConfig } = require("./config");
4
+ const { FINAL_REPORT_PATH } = require("./constants");
5
+ const { getRepoRoot, getGitDir, getHeadSha, getHeadCommitTime } = require("./git");
6
+ const { readPendingReport, writeFinalReport, writeErrorStatus } = require("./report");
7
+ const { compactConsumedEvents } = require("./event-store");
8
+
9
+ async function runPostCommit() {
10
+ let repoRoot = process.cwd();
11
+ try {
12
+ repoRoot = getRepoRoot(process.cwd());
13
+ const pending = readPendingReport(repoRoot);
14
+ if (!pending) {
15
+ return;
16
+ }
17
+
18
+ const sha = getHeadSha(repoRoot);
19
+ const commitTime = getHeadCommitTime(repoRoot);
20
+ pending.commitSha = sha;
21
+ pending.commitTime = commitTime;
22
+ pending.generatedAt = Date.now();
23
+
24
+ if (Array.isArray(pending.aiBlocks)) {
25
+ pending.aiBlocks = pending.aiBlocks.map((item) => ({
26
+ ...item,
27
+ originalCommitSha: sha,
28
+ }));
29
+ }
30
+
31
+ writeFinalReport(repoRoot, pending);
32
+ await compactEventsAfterCommit(repoRoot, pending);
33
+ await uploadFinalReport(repoRoot);
34
+ } catch (error) {
35
+ writeErrorStatus(
36
+ repoRoot,
37
+ {
38
+ code: "POST_COMMIT_BIND_ERROR",
39
+ message: error?.message || "unknown error",
40
+ },
41
+ { stage: "post-commit" }
42
+ );
43
+ } finally {
44
+ process.exit(0);
45
+ }
46
+ }
47
+
48
+ async function compactEventsAfterCommit(repoRoot, pendingReport) {
49
+ try {
50
+ const cleanupPlan = Array.isArray(pendingReport?.cleanupPlan) ? pendingReport.cleanupPlan : [];
51
+ if (!cleanupPlan.length) {
52
+ return;
53
+ }
54
+ await compactConsumedEvents(repoRoot, cleanupPlan);
55
+ } catch (error) {
56
+ writeErrorStatus(
57
+ repoRoot,
58
+ {
59
+ code: "POST_COMMIT_EVENT_COMPACT_ERROR",
60
+ message: error?.message || "unknown error",
61
+ },
62
+ { stage: "post-commit-compact" }
63
+ );
64
+ }
65
+ }
66
+
67
+ async function uploadFinalReport(repoRoot) {
68
+ const config = loadConfig(repoRoot);
69
+ const baseUrl = resolveServerBaseUrl(config);
70
+ if (!baseUrl) {
71
+ return;
72
+ }
73
+
74
+ const report = readFinalReport(repoRoot);
75
+ if (!report) {
76
+ return;
77
+ }
78
+ if (!report.commitSha || !report.repoName || !report.cliVersion) {
79
+ writeErrorStatus(repoRoot, {
80
+ code: "POST_COMMIT_UPLOAD_INVALID_REPORT",
81
+ message: "final report missing commitSha/repoName/cliVersion",
82
+ }, { stage: "post-commit-upload" });
83
+ return;
84
+ }
85
+
86
+ const token = readLocalToken(repoRoot);
87
+ if (!token) {
88
+ writeErrorStatus(repoRoot, {
89
+ code: "POST_COMMIT_UPLOAD_MISSING_TOKEN",
90
+ message: "missing local token file",
91
+ }, { stage: "post-commit-upload" });
92
+ return;
93
+ }
94
+
95
+ const endpoint = new URL(config.commitReportPath || "/api/cursor-board/commit-reports", baseUrl);
96
+ try {
97
+ await requestJson(
98
+ endpoint.toString(),
99
+ {
100
+ method: "POST",
101
+ headers: {
102
+ "Content-Type": "application/json",
103
+ Authorization: `Bearer ${token}`,
104
+ },
105
+ body: JSON.stringify(report),
106
+ },
107
+ config.requestTimeoutMs
108
+ );
109
+ } catch (error) {
110
+ writeErrorStatus(repoRoot, {
111
+ code: "POST_COMMIT_UPLOAD_ERROR",
112
+ message: error?.message || "unknown error",
113
+ }, { stage: "post-commit-upload", endpoint: endpoint.toString() });
114
+ }
115
+ }
116
+
117
+ function readFinalReport(repoRoot) {
118
+ const candidates = [
119
+ path.join(repoRoot, FINAL_REPORT_PATH),
120
+ path.join(repoRoot, FINAL_REPORT_PATH.replace(/^\.cursor\//, ".cursor-roi/")),
121
+ ];
122
+ const filePath = candidates.find((item) => fs.existsSync(item));
123
+ if (!filePath) {
124
+ return null;
125
+ }
126
+ try {
127
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
128
+ } catch (_error) {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function readLocalToken(repoRoot) {
134
+ try {
135
+ const gitDir = getGitDir(repoRoot);
136
+ const tokenPath = path.join(gitDir, ".local-token");
137
+ if (!fs.existsSync(tokenPath)) {
138
+ return "";
139
+ }
140
+ return fs.readFileSync(tokenPath, "utf8").trim();
141
+ } catch (_error) {
142
+ return "";
143
+ }
144
+ }
145
+
146
+ function resolveServerBaseUrl(config) {
147
+ const fromEnv = String(process.env.CURSOR_ROI_SERVER_URL || "").trim();
148
+ const fromConfig = String(config?.serverBaseUrl || "").trim();
149
+ const value = fromEnv || fromConfig;
150
+ if (!value) {
151
+ return "";
152
+ }
153
+ return value.endsWith("/") ? value : `${value}/`;
154
+ }
155
+
156
+ async function requestJson(url, init, timeoutMs = 5000) {
157
+ if (typeof fetch !== "function") {
158
+ throw new Error("fetch is not available on this Node runtime");
159
+ }
160
+ const controller = new AbortController();
161
+ const timer = setTimeout(() => controller.abort(), Math.max(1, Number(timeoutMs) || 5000));
162
+ try {
163
+ const response = await fetch(url, { ...init, signal: controller.signal });
164
+ const text = await response.text();
165
+ if (!response.ok) {
166
+ throw new Error(`HTTP ${response.status}`);
167
+ }
168
+ return text ? JSON.parse(text) : {};
169
+ } finally {
170
+ clearTimeout(timer);
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ runPostCommit,
176
+ };