@vibescore/tracker 0.0.1
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/LICENSE +21 -0
- package/README.md +26 -0
- package/bin/tracker.js +23 -0
- package/package.json +42 -0
- package/src/cli.js +59 -0
- package/src/commands/diagnostics.js +39 -0
- package/src/commands/init.js +256 -0
- package/src/commands/status.js +113 -0
- package/src/commands/sync.js +187 -0
- package/src/commands/uninstall.js +52 -0
- package/src/lib/browser-auth.js +139 -0
- package/src/lib/codex-config.js +157 -0
- package/src/lib/diagnostics.js +138 -0
- package/src/lib/fs.js +62 -0
- package/src/lib/insforge-client.js +59 -0
- package/src/lib/insforge.js +17 -0
- package/src/lib/progress.js +77 -0
- package/src/lib/prompt.js +20 -0
- package/src/lib/rollout.js +263 -0
- package/src/lib/upload-throttle.js +129 -0
- package/src/lib/uploader.js +88 -0
- package/src/lib/vibescore-api.js +163 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
const { ensureDir, readJson, writeJson, openLock } = require('../lib/fs');
|
|
6
|
+
const { listRolloutFiles, parseRolloutIncremental } = require('../lib/rollout');
|
|
7
|
+
const { drainQueueToCloud } = require('../lib/uploader');
|
|
8
|
+
const { createProgress, renderBar, formatNumber, formatBytes } = require('../lib/progress');
|
|
9
|
+
const {
|
|
10
|
+
DEFAULTS: UPLOAD_DEFAULTS,
|
|
11
|
+
normalizeState: normalizeUploadState,
|
|
12
|
+
decideAutoUpload,
|
|
13
|
+
recordUploadSuccess,
|
|
14
|
+
recordUploadFailure
|
|
15
|
+
} = require('../lib/upload-throttle');
|
|
16
|
+
|
|
17
|
+
async function cmdSync(argv) {
|
|
18
|
+
const opts = parseArgs(argv);
|
|
19
|
+
const home = os.homedir();
|
|
20
|
+
const rootDir = path.join(home, '.vibescore');
|
|
21
|
+
const trackerDir = path.join(rootDir, 'tracker');
|
|
22
|
+
|
|
23
|
+
await ensureDir(trackerDir);
|
|
24
|
+
|
|
25
|
+
const lockPath = path.join(trackerDir, 'sync.lock');
|
|
26
|
+
const lock = await openLock(lockPath, { quietIfLocked: opts.auto });
|
|
27
|
+
if (!lock) return;
|
|
28
|
+
|
|
29
|
+
let progress = null;
|
|
30
|
+
try {
|
|
31
|
+
progress = !opts.auto ? createProgress({ stream: process.stdout }) : null;
|
|
32
|
+
const configPath = path.join(trackerDir, 'config.json');
|
|
33
|
+
const cursorsPath = path.join(trackerDir, 'cursors.json');
|
|
34
|
+
const queuePath = path.join(trackerDir, 'queue.jsonl');
|
|
35
|
+
const queueStatePath = path.join(trackerDir, 'queue.state.json');
|
|
36
|
+
const uploadThrottlePath = path.join(trackerDir, 'upload.throttle.json');
|
|
37
|
+
|
|
38
|
+
const config = await readJson(configPath);
|
|
39
|
+
const cursors = (await readJson(cursorsPath)) || { version: 1, files: {}, updatedAt: null };
|
|
40
|
+
const uploadThrottle = normalizeUploadState(await readJson(uploadThrottlePath));
|
|
41
|
+
|
|
42
|
+
const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
|
|
43
|
+
const sessionsDir = path.join(codexHome, 'sessions');
|
|
44
|
+
const rolloutFiles = await listRolloutFiles(sessionsDir);
|
|
45
|
+
|
|
46
|
+
if (progress?.enabled) {
|
|
47
|
+
progress.start(`Parsing ${renderBar(0)} 0/${formatNumber(rolloutFiles.length)} files | queued 0`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parseResult = await parseRolloutIncremental({
|
|
51
|
+
rolloutFiles,
|
|
52
|
+
cursors,
|
|
53
|
+
queuePath,
|
|
54
|
+
onProgress: (p) => {
|
|
55
|
+
if (!progress?.enabled) return;
|
|
56
|
+
const pct = p.total > 0 ? p.index / p.total : 1;
|
|
57
|
+
progress.update(
|
|
58
|
+
`Parsing ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} files | queued ${formatNumber(p.eventsQueued)}`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
cursors.updatedAt = new Date().toISOString();
|
|
64
|
+
await writeJson(cursorsPath, cursors);
|
|
65
|
+
|
|
66
|
+
progress?.stop();
|
|
67
|
+
|
|
68
|
+
const deviceToken = config?.deviceToken || process.env.VIBESCORE_DEVICE_TOKEN || null;
|
|
69
|
+
const baseUrl = config?.baseUrl || process.env.VIBESCORE_INSFORGE_BASE_URL || 'https://5tmappuk.us-east.insforge.app';
|
|
70
|
+
|
|
71
|
+
let uploadResult = null;
|
|
72
|
+
if (deviceToken) {
|
|
73
|
+
const beforeState = (await readJson(queueStatePath)) || { offset: 0 };
|
|
74
|
+
const queueSize = await safeStatSize(queuePath);
|
|
75
|
+
const pendingBytes = Math.max(0, queueSize - Number(beforeState.offset || 0));
|
|
76
|
+
let maxBatches = opts.auto ? 3 : opts.drain ? 10_000 : 10;
|
|
77
|
+
let batchSize = UPLOAD_DEFAULTS.batchSize;
|
|
78
|
+
let allowUpload = pendingBytes > 0;
|
|
79
|
+
|
|
80
|
+
if (opts.auto) {
|
|
81
|
+
const decision = decideAutoUpload({
|
|
82
|
+
nowMs: Date.now(),
|
|
83
|
+
pendingBytes,
|
|
84
|
+
state: uploadThrottle
|
|
85
|
+
});
|
|
86
|
+
allowUpload = allowUpload && decision.allowed;
|
|
87
|
+
maxBatches = decision.allowed ? decision.maxBatches : 0;
|
|
88
|
+
batchSize = decision.batchSize;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (progress?.enabled && pendingBytes > 0 && allowUpload) {
|
|
92
|
+
const pct = queueSize > 0 ? Number(beforeState.offset || 0) / queueSize : 0;
|
|
93
|
+
progress.start(
|
|
94
|
+
`Uploading ${renderBar(pct)} ${formatBytes(Number(beforeState.offset || 0))}/${formatBytes(queueSize)} | inserted 0 skipped 0`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (allowUpload && maxBatches > 0) {
|
|
99
|
+
try {
|
|
100
|
+
uploadResult = await drainQueueToCloud({
|
|
101
|
+
baseUrl,
|
|
102
|
+
deviceToken,
|
|
103
|
+
queuePath,
|
|
104
|
+
queueStatePath,
|
|
105
|
+
maxBatches,
|
|
106
|
+
batchSize,
|
|
107
|
+
onProgress: (u) => {
|
|
108
|
+
if (!progress?.enabled) return;
|
|
109
|
+
const pct = u.queueSize > 0 ? u.offset / u.queueSize : 1;
|
|
110
|
+
progress.update(
|
|
111
|
+
`Uploading ${renderBar(pct)} ${formatBytes(u.offset)}/${formatBytes(u.queueSize)} | inserted ${formatNumber(
|
|
112
|
+
u.inserted
|
|
113
|
+
)} skipped ${formatNumber(u.skipped)}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (uploadResult.attempted > 0) {
|
|
118
|
+
const next = recordUploadSuccess({ nowMs: Date.now(), state: uploadThrottle });
|
|
119
|
+
await writeJson(uploadThrottlePath, next);
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
const next = recordUploadFailure({ nowMs: Date.now(), state: uploadThrottle, error: e });
|
|
123
|
+
await writeJson(uploadThrottlePath, next);
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
uploadResult = { inserted: 0, skipped: 0 };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
progress?.stop();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!opts.auto) {
|
|
134
|
+
const afterState = (await readJson(queueStatePath)) || { offset: 0 };
|
|
135
|
+
const queueSize = await safeStatSize(queuePath);
|
|
136
|
+
const pendingBytes = Math.max(0, queueSize - Number(afterState.offset || 0));
|
|
137
|
+
|
|
138
|
+
process.stdout.write(
|
|
139
|
+
[
|
|
140
|
+
'Sync finished:',
|
|
141
|
+
`- Parsed files: ${parseResult.filesProcessed}`,
|
|
142
|
+
`- New events queued: ${parseResult.eventsQueued}`,
|
|
143
|
+
deviceToken
|
|
144
|
+
? `- Uploaded: ${uploadResult.inserted} inserted, ${uploadResult.skipped} skipped`
|
|
145
|
+
: '- Uploaded: skipped (no device token)',
|
|
146
|
+
deviceToken && pendingBytes > 0 && !opts.drain
|
|
147
|
+
? `- Remaining: ${formatBytes(pendingBytes)} pending (run sync again, or use --drain)`
|
|
148
|
+
: null,
|
|
149
|
+
''
|
|
150
|
+
]
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.join('\n')
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
progress?.stop();
|
|
157
|
+
await lock.release();
|
|
158
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseArgs(argv) {
|
|
163
|
+
const out = {
|
|
164
|
+
auto: false,
|
|
165
|
+
fromNotify: false,
|
|
166
|
+
drain: false
|
|
167
|
+
};
|
|
168
|
+
for (let i = 0; i < argv.length; i++) {
|
|
169
|
+
const a = argv[i];
|
|
170
|
+
if (a === '--auto') out.auto = true;
|
|
171
|
+
else if (a === '--from-notify') out.fromNotify = true;
|
|
172
|
+
else if (a === '--drain') out.drain = true;
|
|
173
|
+
else throw new Error(`Unknown option: ${a}`);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { cmdSync };
|
|
179
|
+
|
|
180
|
+
async function safeStatSize(p) {
|
|
181
|
+
try {
|
|
182
|
+
const st = await fs.stat(p);
|
|
183
|
+
return st && st.isFile() ? st.size : 0;
|
|
184
|
+
} catch (_e) {
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
const { readJson } = require('../lib/fs');
|
|
6
|
+
const { restoreCodexNotify } = require('../lib/codex-config');
|
|
7
|
+
|
|
8
|
+
async function cmdUninstall(argv) {
|
|
9
|
+
const opts = parseArgs(argv);
|
|
10
|
+
const home = os.homedir();
|
|
11
|
+
const trackerDir = path.join(home, '.vibescore', 'tracker');
|
|
12
|
+
const binDir = path.join(home, '.vibescore', 'bin');
|
|
13
|
+
const codexConfigPath = path.join(home, '.codex', 'config.toml');
|
|
14
|
+
const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
|
|
15
|
+
|
|
16
|
+
await restoreCodexNotify({
|
|
17
|
+
codexConfigPath,
|
|
18
|
+
notifyOriginalPath
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Remove installed notify handler.
|
|
22
|
+
const notifyPath = path.join(binDir, 'notify.cjs');
|
|
23
|
+
await fs.unlink(notifyPath).catch(() => {});
|
|
24
|
+
|
|
25
|
+
// Remove local app runtime (installed by init for notify-driven sync).
|
|
26
|
+
await fs.rm(path.join(trackerDir, 'app'), { recursive: true, force: true }).catch(() => {});
|
|
27
|
+
|
|
28
|
+
if (opts.purge) {
|
|
29
|
+
await fs.rm(path.join(home, '.vibescore'), { recursive: true, force: true }).catch(() => {});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
process.stdout.write(
|
|
33
|
+
[
|
|
34
|
+
'Uninstalled:',
|
|
35
|
+
`- Codex notify restored: ${codexConfigPath}`,
|
|
36
|
+
opts.purge ? `- Purged: ${path.join(home, '.vibescore')}` : '- Purge: skipped (use --purge)',
|
|
37
|
+
''
|
|
38
|
+
].join('\n')
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const out = { purge: false };
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
if (a === '--purge') out.purge = true;
|
|
47
|
+
else throw new Error(`Unknown option: ${a}`);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { cmdUninstall };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const http = require('node:http');
|
|
2
|
+
const crypto = require('node:crypto');
|
|
3
|
+
const cp = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BASE_URL = 'https://5tmappuk.us-east.insforge.app';
|
|
6
|
+
|
|
7
|
+
async function beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs, open }) {
|
|
8
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
9
|
+
const callbackPath = `/vibescore/callback/${nonce}`;
|
|
10
|
+
|
|
11
|
+
const { callbackUrl, waitForCallback } = await startLocalCallbackServer({ callbackPath, timeoutMs });
|
|
12
|
+
|
|
13
|
+
const authUrl = dashboardUrl ? new URL('/connect', dashboardUrl) : new URL('/auth/sign-up', baseUrl);
|
|
14
|
+
authUrl.searchParams.set('redirect', callbackUrl);
|
|
15
|
+
if (dashboardUrl && baseUrl && baseUrl !== DEFAULT_BASE_URL) authUrl.searchParams.set('base_url', baseUrl);
|
|
16
|
+
|
|
17
|
+
if (open !== false) openInBrowser(authUrl.toString());
|
|
18
|
+
|
|
19
|
+
return { authUrl: authUrl.toString(), waitForCallback };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function startLocalCallbackServer({ callbackPath, timeoutMs }) {
|
|
23
|
+
let resolved = false;
|
|
24
|
+
let resolveResult;
|
|
25
|
+
let rejectResult;
|
|
26
|
+
|
|
27
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
28
|
+
resolveResult = resolve;
|
|
29
|
+
rejectResult = reject;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const server = http.createServer((req, res) => {
|
|
33
|
+
if (resolved) {
|
|
34
|
+
res.writeHead(409, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
35
|
+
res.end('Already authenticated.\n');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const method = req.method || 'GET';
|
|
40
|
+
if (method !== 'GET') {
|
|
41
|
+
res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
42
|
+
res.end('Method not allowed.\n');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
47
|
+
if (url.pathname !== callbackPath) {
|
|
48
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
49
|
+
res.end('Not found.\n');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const accessToken = url.searchParams.get('access_token') || '';
|
|
54
|
+
if (!accessToken) {
|
|
55
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
56
|
+
res.end('Missing access_token.\n');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
resolved = true;
|
|
61
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
62
|
+
res.end(
|
|
63
|
+
[
|
|
64
|
+
'<!doctype html>',
|
|
65
|
+
'<html><head><meta charset="utf-8"><title>VibeScore</title></head>',
|
|
66
|
+
'<body>',
|
|
67
|
+
'<h2>Login succeeded</h2>',
|
|
68
|
+
'<p>You can close this tab and return to the CLI.</p>',
|
|
69
|
+
'</body></html>'
|
|
70
|
+
].join('')
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
resolveResult({
|
|
74
|
+
accessToken,
|
|
75
|
+
userId: url.searchParams.get('user_id') || null,
|
|
76
|
+
email: url.searchParams.get('email') || null,
|
|
77
|
+
name: url.searchParams.get('name') || null
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await new Promise((resolve, reject) => {
|
|
82
|
+
server.once('error', reject);
|
|
83
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const addr = server.address();
|
|
87
|
+
const port = typeof addr === 'object' && addr ? addr.port : null;
|
|
88
|
+
if (!port) {
|
|
89
|
+
server.close();
|
|
90
|
+
throw new Error('Failed to bind local callback server');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const callbackUrl = `http://127.0.0.1:${port}${callbackPath}`;
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
if (resolved) return;
|
|
97
|
+
resolved = true;
|
|
98
|
+
rejectResult(new Error('Authentication timed out'));
|
|
99
|
+
server.close();
|
|
100
|
+
}, timeoutMs);
|
|
101
|
+
|
|
102
|
+
async function waitForCallback() {
|
|
103
|
+
try {
|
|
104
|
+
return await resultPromise;
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
server.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { callbackUrl, waitForCallback };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function openInBrowser(url) {
|
|
115
|
+
const platform = process.platform;
|
|
116
|
+
|
|
117
|
+
let cmd = null;
|
|
118
|
+
let args = [];
|
|
119
|
+
|
|
120
|
+
if (platform === 'darwin') {
|
|
121
|
+
cmd = 'open';
|
|
122
|
+
args = [url];
|
|
123
|
+
} else if (platform === 'win32') {
|
|
124
|
+
cmd = 'cmd';
|
|
125
|
+
args = ['/c', 'start', '', url];
|
|
126
|
+
} else {
|
|
127
|
+
cmd = 'xdg-open';
|
|
128
|
+
args = [url];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const child = cp.spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
133
|
+
child.unref();
|
|
134
|
+
} catch (_e) {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
beginBrowserAuth
|
|
139
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
const { ensureDir, readJson, writeJson } = require('./fs');
|
|
5
|
+
|
|
6
|
+
async function upsertCodexNotify({ codexConfigPath, notifyCmd, notifyOriginalPath }) {
|
|
7
|
+
const originalText = await fs.readFile(codexConfigPath, 'utf8').catch(() => null);
|
|
8
|
+
if (originalText == null) {
|
|
9
|
+
throw new Error(`Codex config not found: ${codexConfigPath}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const existingNotify = extractNotify(originalText);
|
|
13
|
+
const already = arraysEqual(existingNotify, notifyCmd);
|
|
14
|
+
|
|
15
|
+
if (!already) {
|
|
16
|
+
// Persist original notify once (for uninstall + chaining).
|
|
17
|
+
if (existingNotify && existingNotify.length > 0) {
|
|
18
|
+
await ensureDir(path.dirname(notifyOriginalPath));
|
|
19
|
+
const existing = await readJson(notifyOriginalPath);
|
|
20
|
+
if (!existing) {
|
|
21
|
+
await writeJson(notifyOriginalPath, { notify: existingNotify, capturedAt: new Date().toISOString() });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const updated = setNotify(originalText, notifyCmd);
|
|
26
|
+
const backupPath = `${codexConfigPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
27
|
+
await fs.copyFile(codexConfigPath, backupPath);
|
|
28
|
+
await fs.writeFile(codexConfigPath, updated, 'utf8');
|
|
29
|
+
return { changed: true, backupPath };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { changed: false, backupPath: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function restoreCodexNotify({ codexConfigPath, notifyOriginalPath }) {
|
|
36
|
+
const text = await fs.readFile(codexConfigPath, 'utf8').catch(() => null);
|
|
37
|
+
if (text == null) return;
|
|
38
|
+
|
|
39
|
+
const original = await readJson(notifyOriginalPath);
|
|
40
|
+
const originalNotify = Array.isArray(original?.notify) ? original.notify : null;
|
|
41
|
+
|
|
42
|
+
const updated = originalNotify ? setNotify(text, originalNotify) : removeNotify(text);
|
|
43
|
+
const backupPath = `${codexConfigPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
44
|
+
await fs.copyFile(codexConfigPath, backupPath).catch(() => {});
|
|
45
|
+
await fs.writeFile(codexConfigPath, updated, 'utf8');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function loadCodexNotifyOriginal(notifyOriginalPath) {
|
|
49
|
+
const original = await readJson(notifyOriginalPath);
|
|
50
|
+
return Array.isArray(original?.notify) ? original.notify : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readCodexNotify(codexConfigPath) {
|
|
54
|
+
const text = await fs.readFile(codexConfigPath, 'utf8').catch(() => null);
|
|
55
|
+
if (text == null) return null;
|
|
56
|
+
return extractNotify(text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractNotify(text) {
|
|
60
|
+
// Heuristic parse: find a line that starts with "notify =".
|
|
61
|
+
const lines = text.split(/\r?\n/);
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const m = line.match(/^\s*notify\s*=\s*(.+)\s*$/);
|
|
64
|
+
if (m) {
|
|
65
|
+
const rhs = m[1].trim();
|
|
66
|
+
const parsed = parseTomlStringArray(rhs);
|
|
67
|
+
if (parsed) return parsed;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setNotify(text, notifyCmd) {
|
|
74
|
+
const lines = text.split(/\r?\n/);
|
|
75
|
+
const notifyLine = `notify = ${formatTomlStringArray(notifyCmd)}`;
|
|
76
|
+
|
|
77
|
+
const out = [];
|
|
78
|
+
let replaced = false;
|
|
79
|
+
for (let i = 0; i < lines.length; i++) {
|
|
80
|
+
const line = lines[i];
|
|
81
|
+
const isNotify = /^\s*notify\s*=/.test(line);
|
|
82
|
+
if (isNotify) {
|
|
83
|
+
if (!replaced) {
|
|
84
|
+
out.push(notifyLine);
|
|
85
|
+
replaced = true;
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
out.push(line);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!replaced) {
|
|
93
|
+
// Insert at top-level, before the first table header.
|
|
94
|
+
const firstTableIdx = out.findIndex((l) => /^\s*\[/.test(l));
|
|
95
|
+
const headerIdx = firstTableIdx === -1 ? out.length : firstTableIdx;
|
|
96
|
+
out.splice(headerIdx, 0, notifyLine);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return out.join('\n').replace(/\n+$/, '\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function removeNotify(text) {
|
|
103
|
+
const lines = text.split(/\r?\n/);
|
|
104
|
+
const out = lines.filter((l) => !/^\s*notify\s*=/.test(l));
|
|
105
|
+
return out.join('\n').replace(/\n+$/, '\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseTomlStringArray(rhs) {
|
|
109
|
+
// Minimal parser for ["a", "b"] string arrays.
|
|
110
|
+
// Assumes there are no escapes in strings (good enough for our usage).
|
|
111
|
+
if (!rhs.startsWith('[') || !rhs.endsWith(']')) return null;
|
|
112
|
+
const inner = rhs.slice(1, -1).trim();
|
|
113
|
+
if (!inner) return [];
|
|
114
|
+
|
|
115
|
+
const parts = [];
|
|
116
|
+
let current = '';
|
|
117
|
+
let inString = false;
|
|
118
|
+
let quote = null;
|
|
119
|
+
for (let i = 0; i < inner.length; i++) {
|
|
120
|
+
const ch = inner[i];
|
|
121
|
+
if (!inString) {
|
|
122
|
+
if (ch === '"' || ch === "'") {
|
|
123
|
+
inString = true;
|
|
124
|
+
quote = ch;
|
|
125
|
+
current = '';
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (ch === quote) {
|
|
130
|
+
parts.push(current);
|
|
131
|
+
inString = false;
|
|
132
|
+
quote = null;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
current += ch;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return parts.length > 0 ? parts : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatTomlStringArray(arr) {
|
|
142
|
+
return `[${arr.map((s) => JSON.stringify(String(s))).join(', ')}]`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function arraysEqual(a, b) {
|
|
146
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
147
|
+
if (a.length !== b.length) return false;
|
|
148
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
upsertCodexNotify,
|
|
154
|
+
restoreCodexNotify,
|
|
155
|
+
loadCodexNotifyOriginal,
|
|
156
|
+
readCodexNotify
|
|
157
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const os = require('node:os');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
|
|
5
|
+
const { readJson } = require('./fs');
|
|
6
|
+
const { readCodexNotify } = require('./codex-config');
|
|
7
|
+
const { normalizeState: normalizeUploadState } = require('./upload-throttle');
|
|
8
|
+
|
|
9
|
+
async function collectTrackerDiagnostics({
|
|
10
|
+
home = os.homedir(),
|
|
11
|
+
codexHome = process.env.CODEX_HOME || path.join(home, '.codex')
|
|
12
|
+
} = {}) {
|
|
13
|
+
const trackerDir = path.join(home, '.vibescore', 'tracker');
|
|
14
|
+
const configPath = path.join(trackerDir, 'config.json');
|
|
15
|
+
const queuePath = path.join(trackerDir, 'queue.jsonl');
|
|
16
|
+
const queueStatePath = path.join(trackerDir, 'queue.state.json');
|
|
17
|
+
const cursorsPath = path.join(trackerDir, 'cursors.json');
|
|
18
|
+
const notifySignalPath = path.join(trackerDir, 'notify.signal');
|
|
19
|
+
const throttlePath = path.join(trackerDir, 'sync.throttle');
|
|
20
|
+
const uploadThrottlePath = path.join(trackerDir, 'upload.throttle.json');
|
|
21
|
+
const codexConfigPath = path.join(codexHome, 'config.toml');
|
|
22
|
+
|
|
23
|
+
const config = await readJson(configPath);
|
|
24
|
+
const cursors = await readJson(cursorsPath);
|
|
25
|
+
const queueState = (await readJson(queueStatePath)) || { offset: 0 };
|
|
26
|
+
const uploadThrottle = normalizeUploadState(await readJson(uploadThrottlePath));
|
|
27
|
+
|
|
28
|
+
const queueSize = await safeStatSize(queuePath);
|
|
29
|
+
const offsetBytes = Number(queueState.offset || 0);
|
|
30
|
+
const pendingBytes = Math.max(0, queueSize - offsetBytes);
|
|
31
|
+
|
|
32
|
+
const lastNotify = (await safeReadText(notifySignalPath))?.trim() || null;
|
|
33
|
+
const lastNotifySpawn = parseEpochMsToIso((await safeReadText(throttlePath))?.trim() || null);
|
|
34
|
+
|
|
35
|
+
const codexNotifyRaw = await readCodexNotify(codexConfigPath);
|
|
36
|
+
const notifyConfigured = Array.isArray(codexNotifyRaw) && codexNotifyRaw.length > 0;
|
|
37
|
+
const codexNotify = notifyConfigured ? codexNotifyRaw.map((v) => redactValue(v, home)) : null;
|
|
38
|
+
|
|
39
|
+
const lastSuccessAt = uploadThrottle.lastSuccessMs ? new Date(uploadThrottle.lastSuccessMs).toISOString() : null;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
version: 1,
|
|
44
|
+
generated_at: new Date().toISOString(),
|
|
45
|
+
env: {
|
|
46
|
+
node: process.version,
|
|
47
|
+
platform: process.platform,
|
|
48
|
+
arch: process.arch
|
|
49
|
+
},
|
|
50
|
+
paths: {
|
|
51
|
+
tracker_dir: redactValue(trackerDir, home),
|
|
52
|
+
codex_home: redactValue(codexHome, home),
|
|
53
|
+
codex_config: redactValue(codexConfigPath, home)
|
|
54
|
+
},
|
|
55
|
+
config: {
|
|
56
|
+
base_url: typeof config?.baseUrl === 'string' ? config.baseUrl : null,
|
|
57
|
+
device_token: config?.deviceToken ? 'set' : 'unset',
|
|
58
|
+
device_id: maskId(config?.deviceId),
|
|
59
|
+
installed_at: typeof config?.installedAt === 'string' ? config.installedAt : null
|
|
60
|
+
},
|
|
61
|
+
parse: {
|
|
62
|
+
updated_at: typeof cursors?.updatedAt === 'string' ? cursors.updatedAt : null,
|
|
63
|
+
file_count: cursors?.files && typeof cursors.files === 'object' ? Object.keys(cursors.files).length : null
|
|
64
|
+
},
|
|
65
|
+
queue: {
|
|
66
|
+
size_bytes: queueSize,
|
|
67
|
+
offset_bytes: offsetBytes,
|
|
68
|
+
pending_bytes: pendingBytes,
|
|
69
|
+
updated_at: typeof queueState.updatedAt === 'string' ? queueState.updatedAt : null
|
|
70
|
+
},
|
|
71
|
+
notify: {
|
|
72
|
+
last_notify: lastNotify,
|
|
73
|
+
last_notify_triggered_sync: lastNotifySpawn,
|
|
74
|
+
codex_notify_configured: notifyConfigured,
|
|
75
|
+
codex_notify: codexNotify
|
|
76
|
+
},
|
|
77
|
+
upload: {
|
|
78
|
+
last_success_at: lastSuccessAt,
|
|
79
|
+
next_allowed_after: parseEpochMsToIso(uploadThrottle.nextAllowedAtMs || null),
|
|
80
|
+
backoff_until: parseEpochMsToIso(uploadThrottle.backoffUntilMs || null),
|
|
81
|
+
last_error: uploadThrottle.lastError
|
|
82
|
+
? {
|
|
83
|
+
at: uploadThrottle.lastErrorAt || null,
|
|
84
|
+
message: redactError(String(uploadThrottle.lastError), home)
|
|
85
|
+
}
|
|
86
|
+
: null
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function maskId(v) {
|
|
92
|
+
if (typeof v !== 'string') return null;
|
|
93
|
+
const s = v.trim();
|
|
94
|
+
if (s.length < 12) return null;
|
|
95
|
+
return `${s.slice(0, 8)}…${s.slice(-4)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function redactValue(value, home) {
|
|
99
|
+
if (typeof value !== 'string') return value;
|
|
100
|
+
if (typeof home !== 'string' || home.length === 0) return value;
|
|
101
|
+
const homeNorm = home.endsWith(path.sep) ? home.slice(0, -1) : home;
|
|
102
|
+
return value.startsWith(homeNorm) ? `~${value.slice(homeNorm.length)}` : value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function redactError(message, home) {
|
|
106
|
+
if (typeof message !== 'string') return message;
|
|
107
|
+
if (typeof home !== 'string' || home.length === 0) return message;
|
|
108
|
+
const homeNorm = home.endsWith(path.sep) ? home.slice(0, -1) : home;
|
|
109
|
+
return message.split(homeNorm).join('~');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function safeStatSize(p) {
|
|
113
|
+
try {
|
|
114
|
+
const st = await fs.stat(p);
|
|
115
|
+
return st && st.isFile() ? st.size : 0;
|
|
116
|
+
} catch (_e) {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function safeReadText(p) {
|
|
122
|
+
try {
|
|
123
|
+
return await fs.readFile(p, 'utf8');
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseEpochMsToIso(v) {
|
|
130
|
+
const ms = Number(v);
|
|
131
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
132
|
+
const d = new Date(ms);
|
|
133
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
134
|
+
return d.toISOString();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { collectTrackerDiagnostics };
|
|
138
|
+
|