vibeusage 0.2.8

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,798 @@
1
+ const os = require('node:os');
2
+ const path = require('node:path');
3
+ const fs = require('node:fs/promises');
4
+ const fssync = require('node:fs');
5
+ const cp = require('node:child_process');
6
+ const crypto = require('node:crypto');
7
+
8
+ const { ensureDir, writeFileAtomic, readJson, writeJson, chmod600IfPossible } = require('../lib/fs');
9
+ const { prompt, promptHidden } = require('../lib/prompt');
10
+ const {
11
+ upsertCodexNotify,
12
+ upsertEveryCodeNotify,
13
+ readCodexNotify,
14
+ readEveryCodeNotify
15
+ } = require('../lib/codex-config');
16
+ const { upsertClaudeHook, buildClaudeHookCommand, isClaudeHookConfigured } = require('../lib/claude-config');
17
+ const {
18
+ resolveGeminiConfigDir,
19
+ resolveGeminiSettingsPath,
20
+ buildGeminiHookCommand,
21
+ upsertGeminiHook,
22
+ isGeminiHookConfigured
23
+ } = require('../lib/gemini-config');
24
+ const { resolveOpencodeConfigDir, upsertOpencodePlugin, isOpencodePluginInstalled } = require('../lib/opencode-config');
25
+ const { beginBrowserAuth, openInBrowser } = require('../lib/browser-auth');
26
+ const {
27
+ issueDeviceTokenWithPassword,
28
+ issueDeviceTokenWithAccessToken,
29
+ issueDeviceTokenWithLinkCode
30
+ } = require('../lib/insforge');
31
+ const { resolveTrackerPaths } = require('../lib/tracker-paths');
32
+ const {
33
+ BOLD,
34
+ DIM,
35
+ CYAN,
36
+ RESET,
37
+ color,
38
+ isInteractive,
39
+ promptMenu,
40
+ createSpinner
41
+ } = require('../lib/cli-ui');
42
+ const { renderLocalReport, renderAuthTransition, renderSuccessBox } = require('../lib/init-flow');
43
+
44
+ const ASCII_LOGO = [
45
+ '██╗ ██╗██╗██████╗ ███████╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗',
46
+ '██║ ██║██║██╔══██╗██╔════╝██╔════╝██╔════╝ ██╔═══██╗██╔══██╗██╔════╝',
47
+ '██║ ██║██║██████╔╝█████╗ ███████╗██║ ██║ ██║██████╔╝█████╗',
48
+ '╚██╗ ██╔╝██║██╔══██╗██╔══╝ ╚════██║██║ ██║ ██║██╔══██╗██╔══╝',
49
+ ' ╚████╔╝ ██║██████╔╝███████╗███████║╚██████╗ ╚██████╔╝██║ ██║███████╗',
50
+ ' ╚═══╝ ╚═╝╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝'
51
+ ].join('\n');
52
+
53
+ const DIVIDER = '----------------------------------------------';
54
+
55
+ async function cmdInit(argv) {
56
+ const opts = parseArgs(argv);
57
+ const home = os.homedir();
58
+
59
+ const { rootDir, trackerDir, binDir } = await resolveTrackerPaths({ home, migrate: true });
60
+
61
+ const configPath = path.join(trackerDir, 'config.json');
62
+ const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
63
+ const linkCodeStatePath = path.join(trackerDir, 'link_code_state.json');
64
+
65
+ const baseUrl = opts.baseUrl ||
66
+ process.env.VIBEUSAGE_INSFORGE_BASE_URL ||
67
+ process.env.VIBESCORE_INSFORGE_BASE_URL ||
68
+ 'https://5tmappuk.us-east.insforge.app';
69
+ let dashboardUrl = opts.dashboardUrl ||
70
+ process.env.VIBEUSAGE_DASHBOARD_URL ||
71
+ process.env.VIBESCORE_DASHBOARD_URL ||
72
+ null;
73
+ const notifyPath = path.join(binDir, 'notify.cjs');
74
+ const appDir = path.join(trackerDir, 'app');
75
+ const trackerBinPath = path.join(appDir, 'bin', 'tracker.js');
76
+
77
+ renderWelcome();
78
+
79
+ if (opts.dryRun) {
80
+ process.stdout.write(`${color('Dry run: preview only (no changes applied).', DIM)}\n\n`);
81
+ }
82
+
83
+ if (isInteractive() && !opts.yes && !opts.dryRun) {
84
+ const choice = await promptMenu({
85
+ message: '? Proceed with installation?',
86
+ options: ['Yes, configure my environment', 'No, exit'],
87
+ defaultIndex: 0
88
+ });
89
+ const normalizedChoice = String(choice || '').trim().toLowerCase();
90
+ if (normalizedChoice.startsWith('no') || normalizedChoice.includes('exit')) {
91
+ process.stdout.write('Setup cancelled.\n');
92
+ return;
93
+ }
94
+ }
95
+
96
+ if (opts.dryRun) {
97
+ const preview = await buildDryRunSummary({
98
+ opts,
99
+ home,
100
+ trackerDir,
101
+ configPath,
102
+ notifyPath
103
+ });
104
+ renderLocalReport({ summary: preview.summary, isDryRun: true });
105
+ if (preview.pendingBrowserAuth) {
106
+ process.stdout.write('Account linking would be required for full setup.\n');
107
+ } else if (!preview.deviceToken) {
108
+ renderAccountNotLinked({ context: 'dry-run' });
109
+ }
110
+ return;
111
+ }
112
+
113
+ const spinner = createSpinner({ text: 'Analyzing and configuring local environment...' });
114
+ spinner.start();
115
+ let setup;
116
+ try {
117
+ setup = await runSetup({
118
+ opts,
119
+ home,
120
+ baseUrl,
121
+ trackerDir,
122
+ binDir,
123
+ configPath,
124
+ notifyOriginalPath,
125
+ linkCodeStatePath,
126
+ notifyPath,
127
+ appDir,
128
+ trackerBinPath
129
+ });
130
+ } catch (err) {
131
+ spinner.stop();
132
+ throw err;
133
+ }
134
+ spinner.stop();
135
+
136
+ renderLocalReport({ summary: setup.summary, isDryRun: false });
137
+
138
+ let deviceToken = setup.deviceToken;
139
+ let deviceId = setup.deviceId;
140
+
141
+ if (setup.pendingBrowserAuth) {
142
+ const deviceName = opts.deviceName || os.hostname();
143
+ if (!dashboardUrl) dashboardUrl = await detectLocalDashboardUrl();
144
+ const flow = await beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs: 10 * 60_000, open: false });
145
+ const canAutoOpen = !opts.noOpen;
146
+ renderAuthTransition({ authUrl: flow.authUrl, canAutoOpen });
147
+ if (canAutoOpen) {
148
+ if (isInteractive()) await sleep(250);
149
+ openInBrowser(flow.authUrl);
150
+ }
151
+ const callback = await flow.waitForCallback();
152
+ const issued = await issueDeviceTokenWithAccessToken({ baseUrl, accessToken: callback.accessToken, deviceName });
153
+ deviceToken = issued.token;
154
+ deviceId = issued.deviceId;
155
+ await writeJson(configPath, { baseUrl, deviceToken, deviceId, installedAt: setup.installedAt });
156
+ await chmod600IfPossible(configPath);
157
+ const resolvedDashboardUrl = dashboardUrl || null;
158
+ renderSuccessBox({ configPath, dashboardUrl: resolvedDashboardUrl });
159
+ } else if (deviceToken) {
160
+ if (!dashboardUrl) dashboardUrl = await detectLocalDashboardUrl();
161
+ const resolvedDashboardUrl = dashboardUrl || null;
162
+ renderSuccessBox({ configPath, dashboardUrl: resolvedDashboardUrl });
163
+ } else {
164
+ renderAccountNotLinked();
165
+ }
166
+
167
+ try {
168
+ spawnInitSync({ trackerBinPath, packageName: 'vibeusage' });
169
+ } catch (err) {
170
+ const msg = err && err.message ? err.message : 'unknown error';
171
+ process.stderr.write(`Initial sync spawn failed: ${msg}\n`);
172
+ }
173
+ }
174
+
175
+ function renderWelcome() {
176
+ process.stdout.write(
177
+ [
178
+ ASCII_LOGO,
179
+ '',
180
+ `${BOLD}Welcome to VibeScore CLI${RESET}`,
181
+ DIVIDER,
182
+ `${CYAN}Privacy First: Your content stays local. We only upload token counts and minimal metadata, never prompts or responses.${RESET}`,
183
+ DIVIDER,
184
+ '',
185
+ 'This tool will:',
186
+ ' - Analyze your local AI CLI configurations (Codex, Every Code, Claude, Gemini, Opencode)',
187
+ ' - Set up lightweight hooks to track your flow state',
188
+ ' - Link your device to your VibeScore account',
189
+ '',
190
+ '(Nothing will be changed until you confirm below)',
191
+ ''
192
+ ].join('\n')
193
+ );
194
+ }
195
+
196
+ function renderAccountNotLinked({ context } = {}) {
197
+ if (context === 'dry-run') {
198
+ process.stdout.write(['', 'Account not linked (dry run).', 'Run init without --dry-run to link your account.', ''].join('\n'));
199
+ return;
200
+ }
201
+ process.stdout.write(['', 'Account not linked.', 'Set VIBEUSAGE_DEVICE_TOKEN then re-run init.', ''].join('\n'));
202
+ }
203
+
204
+ function shouldUseBrowserAuth({ deviceToken, opts }) {
205
+ if (deviceToken) return false;
206
+ if (opts.noAuth) return false;
207
+ if (opts.linkCode) return false;
208
+ if (opts.email || opts.password) return false;
209
+ return true;
210
+ }
211
+
212
+ async function buildDryRunSummary({ opts, home, trackerDir, configPath, notifyPath }) {
213
+ const existingConfig = await readJson(configPath);
214
+ const deviceTokenFromEnv = process.env.VIBEUSAGE_DEVICE_TOKEN || process.env.VIBESCORE_DEVICE_TOKEN || null;
215
+ const deviceToken = deviceTokenFromEnv || existingConfig?.deviceToken || null;
216
+ const pendingBrowserAuth = shouldUseBrowserAuth({ deviceToken, opts });
217
+ const context = buildIntegrationTargets({ home, trackerDir, notifyPath });
218
+ const summary = await previewIntegrations({ context });
219
+ return { summary, pendingBrowserAuth, deviceToken };
220
+ }
221
+
222
+ async function runSetup({
223
+ opts,
224
+ home,
225
+ baseUrl,
226
+ trackerDir,
227
+ binDir,
228
+ configPath,
229
+ notifyOriginalPath,
230
+ linkCodeStatePath,
231
+ notifyPath,
232
+ appDir,
233
+ trackerBinPath
234
+ }) {
235
+ await ensureDir(trackerDir);
236
+ await ensureDir(binDir);
237
+
238
+ const existingConfig = await readJson(configPath);
239
+ const deviceTokenFromEnv = process.env.VIBEUSAGE_DEVICE_TOKEN || process.env.VIBESCORE_DEVICE_TOKEN || null;
240
+
241
+ let deviceToken = deviceTokenFromEnv || existingConfig?.deviceToken || null;
242
+ let deviceId = existingConfig?.deviceId || null;
243
+ const installedAt = existingConfig?.installedAt || new Date().toISOString();
244
+ let pendingBrowserAuth = false;
245
+
246
+ await installLocalTrackerApp({ appDir });
247
+
248
+ if (!deviceToken && opts.linkCode) {
249
+ const deviceName = opts.deviceName || os.hostname();
250
+ const platform = normalizePlatform(process.platform);
251
+ const linkCode = String(opts.linkCode);
252
+ const linkCodeHash = crypto.createHash('sha256').update(linkCode).digest('hex');
253
+ const existingLinkState = await readJson(linkCodeStatePath);
254
+ let requestId =
255
+ existingLinkState?.linkCodeHash === linkCodeHash && existingLinkState?.requestId
256
+ ? existingLinkState.requestId
257
+ : null;
258
+ if (!requestId) {
259
+ requestId = crypto.randomUUID();
260
+ await writeJson(linkCodeStatePath, {
261
+ linkCodeHash,
262
+ requestId,
263
+ createdAt: new Date().toISOString()
264
+ });
265
+ await chmod600IfPossible(linkCodeStatePath);
266
+ }
267
+ const issued = await issueDeviceTokenWithLinkCode({
268
+ baseUrl,
269
+ linkCode,
270
+ requestId,
271
+ deviceName,
272
+ platform
273
+ });
274
+ deviceToken = issued.token;
275
+ deviceId = issued.deviceId;
276
+ await fs.rm(linkCodeStatePath, { force: true });
277
+ } else if (!deviceToken && !opts.noAuth) {
278
+ const deviceName = opts.deviceName || os.hostname();
279
+
280
+ if (opts.email || opts.password) {
281
+ const email = opts.email || (await prompt('Email: '));
282
+ const password = opts.password || (await promptHidden('Password: '));
283
+ const issued = await issueDeviceTokenWithPassword({ baseUrl, email, password, deviceName });
284
+ deviceToken = issued.token;
285
+ deviceId = issued.deviceId;
286
+ } else {
287
+ pendingBrowserAuth = true;
288
+ }
289
+ }
290
+
291
+ const config = {
292
+ baseUrl,
293
+ deviceToken,
294
+ deviceId,
295
+ installedAt
296
+ };
297
+
298
+ await writeJson(configPath, config);
299
+ await chmod600IfPossible(configPath);
300
+
301
+ await writeFileAtomic(
302
+ notifyPath,
303
+ buildNotifyHandler({ trackerDir, trackerBinPath, packageName: 'vibeusage' })
304
+ );
305
+ await fs.chmod(notifyPath, 0o755).catch(() => {});
306
+
307
+ const summary = await applyIntegrationSetup({
308
+ home,
309
+ trackerDir,
310
+ notifyPath,
311
+ notifyOriginalPath
312
+ });
313
+
314
+ return {
315
+ summary,
316
+ pendingBrowserAuth,
317
+ deviceToken,
318
+ deviceId,
319
+ installedAt
320
+ };
321
+ }
322
+
323
+ function buildIntegrationTargets({ home, trackerDir, notifyPath }) {
324
+ const codexHome = process.env.CODEX_HOME || path.join(home, '.codex');
325
+ const codexConfigPath = path.join(codexHome, 'config.toml');
326
+ const codeHome = process.env.CODE_HOME || path.join(home, '.code');
327
+ const codeConfigPath = path.join(codeHome, 'config.toml');
328
+ const notifyOriginalPath = path.join(trackerDir, 'codex_notify_original.json');
329
+ const codeNotifyOriginalPath = path.join(trackerDir, 'code_notify_original.json');
330
+ const notifyCmd = ['/usr/bin/env', 'node', notifyPath];
331
+ const codeNotifyCmd = ['/usr/bin/env', 'node', notifyPath, '--source=every-code'];
332
+ const claudeDir = path.join(home, '.claude');
333
+ const claudeSettingsPath = path.join(claudeDir, 'settings.json');
334
+ const claudeHookCommand = buildClaudeHookCommand(notifyPath);
335
+ const geminiConfigDir = resolveGeminiConfigDir({ home, env: process.env });
336
+ const geminiSettingsPath = resolveGeminiSettingsPath({ configDir: geminiConfigDir });
337
+ const geminiHookCommand = buildGeminiHookCommand(notifyPath);
338
+ const opencodeConfigDir = resolveOpencodeConfigDir({ home, env: process.env });
339
+
340
+ return {
341
+ codexConfigPath,
342
+ codeConfigPath,
343
+ notifyOriginalPath,
344
+ codeNotifyOriginalPath,
345
+ notifyCmd,
346
+ codeNotifyCmd,
347
+ claudeDir,
348
+ claudeSettingsPath,
349
+ claudeHookCommand,
350
+ geminiConfigDir,
351
+ geminiSettingsPath,
352
+ geminiHookCommand,
353
+ opencodeConfigDir
354
+ };
355
+ }
356
+
357
+ async function applyIntegrationSetup({ home, trackerDir, notifyPath, notifyOriginalPath }) {
358
+ const context = buildIntegrationTargets({ home, trackerDir, notifyPath });
359
+ context.notifyOriginalPath = notifyOriginalPath;
360
+
361
+ const summary = [];
362
+
363
+ const codexProbe = await probeFile(context.codexConfigPath);
364
+ if (codexProbe.exists) {
365
+ const result = await upsertCodexNotify({
366
+ codexConfigPath: context.codexConfigPath,
367
+ notifyCmd: context.notifyCmd,
368
+ notifyOriginalPath: context.notifyOriginalPath
369
+ });
370
+ summary.push({
371
+ label: 'Codex CLI',
372
+ status: result.changed ? 'updated' : 'set',
373
+ detail: result.changed ? 'Updated config' : 'Config already set'
374
+ });
375
+ } else {
376
+ summary.push({ label: 'Codex CLI', status: 'skipped', detail: renderSkipDetail(codexProbe) });
377
+ }
378
+
379
+ const claudeDirExists = await isDir(context.claudeDir);
380
+ if (claudeDirExists) {
381
+ await upsertClaudeHook({
382
+ settingsPath: context.claudeSettingsPath,
383
+ hookCommand: context.claudeHookCommand
384
+ });
385
+ summary.push({ label: 'Claude', status: 'installed', detail: 'Hooks installed' });
386
+ } else {
387
+ summary.push({ label: 'Claude', status: 'skipped', detail: 'Config not found' });
388
+ }
389
+
390
+ const geminiConfigExists = await isDir(context.geminiConfigDir);
391
+ if (geminiConfigExists) {
392
+ await upsertGeminiHook({
393
+ settingsPath: context.geminiSettingsPath,
394
+ hookCommand: context.geminiHookCommand
395
+ });
396
+ summary.push({ label: 'Gemini', status: 'installed', detail: 'Hooks installed' });
397
+ } else {
398
+ summary.push({ label: 'Gemini', status: 'skipped', detail: 'Config not found' });
399
+ }
400
+
401
+ const opencodeResult = await upsertOpencodePlugin({
402
+ configDir: context.opencodeConfigDir,
403
+ notifyPath
404
+ });
405
+ if (opencodeResult?.skippedReason === 'config-missing') {
406
+ summary.push({ label: 'Opencode Plugin', status: 'skipped', detail: 'Config not found' });
407
+ } else {
408
+ summary.push({ label: 'Opencode Plugin', status: opencodeResult?.changed ? 'installed' : 'set', detail: 'Plugin installed' });
409
+ }
410
+
411
+ const codeProbe = await probeFile(context.codeConfigPath);
412
+ if (codeProbe.exists) {
413
+ const result = await upsertEveryCodeNotify({
414
+ codeConfigPath: context.codeConfigPath,
415
+ notifyCmd: context.codeNotifyCmd,
416
+ notifyOriginalPath: context.codeNotifyOriginalPath
417
+ });
418
+ summary.push({
419
+ label: 'Every Code',
420
+ status: result.changed ? 'updated' : 'set',
421
+ detail: result.changed ? 'Updated config' : 'Config already set'
422
+ });
423
+ } else {
424
+ summary.push({ label: 'Every Code', status: 'skipped', detail: renderSkipDetail(codeProbe) });
425
+ }
426
+
427
+ return summary;
428
+ }
429
+
430
+ async function previewIntegrations({ context }) {
431
+ const summary = [];
432
+
433
+ const codexProbe = await probeFile(context.codexConfigPath);
434
+ if (codexProbe.exists) {
435
+ const existing = await readCodexNotify(context.codexConfigPath);
436
+ const matches = arraysEqual(existing, context.notifyCmd);
437
+ summary.push({
438
+ label: 'Codex CLI',
439
+ status: matches ? 'set' : 'updated',
440
+ detail: matches ? 'Already configured' : 'Will update config'
441
+ });
442
+ } else {
443
+ summary.push({ label: 'Codex CLI', status: 'skipped', detail: renderSkipDetail(codexProbe) });
444
+ }
445
+
446
+ const claudeDirExists = await isDir(context.claudeDir);
447
+ if (claudeDirExists) {
448
+ const configured = await isClaudeHookConfigured({
449
+ settingsPath: context.claudeSettingsPath,
450
+ hookCommand: context.claudeHookCommand
451
+ });
452
+ summary.push({
453
+ label: 'Claude',
454
+ status: 'installed',
455
+ detail: configured ? 'Hooks already installed' : 'Will install hooks'
456
+ });
457
+ } else {
458
+ summary.push({ label: 'Claude', status: 'skipped', detail: 'Config not found' });
459
+ }
460
+
461
+ const geminiConfigExists = await isDir(context.geminiConfigDir);
462
+ if (geminiConfigExists) {
463
+ const configured = await isGeminiHookConfigured({
464
+ settingsPath: context.geminiSettingsPath,
465
+ hookCommand: context.geminiHookCommand
466
+ });
467
+ summary.push({
468
+ label: 'Gemini',
469
+ status: 'installed',
470
+ detail: configured ? 'Hooks already installed' : 'Will install hooks'
471
+ });
472
+ } else {
473
+ summary.push({ label: 'Gemini', status: 'skipped', detail: 'Config not found' });
474
+ }
475
+
476
+ const opencodeDirExists = await isDir(context.opencodeConfigDir);
477
+ const installed = await isOpencodePluginInstalled({ configDir: context.opencodeConfigDir });
478
+ const opencodeDetail = installed
479
+ ? 'Plugin already installed'
480
+ : opencodeDirExists
481
+ ? 'Will install plugin'
482
+ : 'Will create config and install plugin';
483
+ summary.push({
484
+ label: 'Opencode Plugin',
485
+ status: 'installed',
486
+ detail: opencodeDetail
487
+ });
488
+
489
+ const codeProbe = await probeFile(context.codeConfigPath);
490
+ if (codeProbe.exists) {
491
+ const existing = await readEveryCodeNotify(context.codeConfigPath);
492
+ const matches = arraysEqual(existing, context.codeNotifyCmd);
493
+ summary.push({
494
+ label: 'Every Code',
495
+ status: matches ? 'set' : 'updated',
496
+ detail: matches ? 'Already configured' : 'Will update config'
497
+ });
498
+ } else {
499
+ summary.push({ label: 'Every Code', status: 'skipped', detail: renderSkipDetail(codeProbe) });
500
+ }
501
+
502
+ return summary;
503
+ }
504
+
505
+ function renderSkipDetail(probe) {
506
+ if (!probe || probe.reason === 'missing') return 'Config not found';
507
+ if (probe.reason === 'permission-denied') return 'Permission denied';
508
+ if (probe.reason === 'not-file') return 'Invalid config';
509
+ return 'Unavailable';
510
+ }
511
+
512
+ function arraysEqual(a, b) {
513
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
514
+ if (a.length !== b.length) return false;
515
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
516
+ return true;
517
+ }
518
+
519
+ function parseArgs(argv) {
520
+ const out = {
521
+ baseUrl: null,
522
+ dashboardUrl: null,
523
+ email: null,
524
+ password: null,
525
+ deviceName: null,
526
+ linkCode: null,
527
+ noAuth: false,
528
+ noOpen: false,
529
+ yes: false,
530
+ dryRun: false
531
+ };
532
+
533
+ for (let i = 0; i < argv.length; i++) {
534
+ const a = argv[i];
535
+ if (a === '--base-url') out.baseUrl = argv[++i] || null;
536
+ else if (a === '--dashboard-url') out.dashboardUrl = argv[++i] || null;
537
+ else if (a === '--email') out.email = argv[++i] || null;
538
+ else if (a === '--password') out.password = argv[++i] || null;
539
+ else if (a === '--device-name') out.deviceName = argv[++i] || null;
540
+ else if (a === '--link-code') out.linkCode = argv[++i] || null;
541
+ else if (a === '--no-auth') out.noAuth = true;
542
+ else if (a === '--no-open') out.noOpen = true;
543
+ else if (a === '--yes') out.yes = true;
544
+ else if (a === '--dry-run') out.dryRun = true;
545
+ else throw new Error(`Unknown option: ${a}`);
546
+ }
547
+ return out;
548
+ }
549
+
550
+ function sleep(ms) {
551
+ if (!ms || ms <= 0) return Promise.resolve();
552
+ return new Promise((resolve) => setTimeout(resolve, ms));
553
+ }
554
+
555
+ function normalizePlatform(value) {
556
+ if (value === 'darwin') return 'macos';
557
+ if (value === 'win32') return 'windows';
558
+ if (value === 'linux') return 'linux';
559
+ return 'unknown';
560
+ }
561
+
562
+ function buildNotifyHandler({ trackerDir, packageName }) {
563
+ // Keep this file dependency-free: Node built-ins only.
564
+ // It must never block Codex; it spawns sync in the background and exits 0.
565
+ const queueSignalPath = path.join(trackerDir, 'notify.signal');
566
+ const originalPath = path.join(trackerDir, 'codex_notify_original.json');
567
+ const fallbackPkg = packageName || 'vibeusage';
568
+ const trackerBinPath = path.join(trackerDir, 'app', 'bin', 'tracker.js');
569
+
570
+ return `#!/usr/bin/env node
571
+ 'use strict';
572
+
573
+ const fs = require('node:fs');
574
+ const os = require('node:os');
575
+ const path = require('node:path');
576
+ const cp = require('node:child_process');
577
+
578
+ const rawArgs = process.argv.slice(2);
579
+ let source = 'codex';
580
+ const payloadArgs = [];
581
+ for (let i = 0; i < rawArgs.length; i++) {
582
+ const arg = rawArgs[i];
583
+ if (arg === '--source') {
584
+ source = rawArgs[i + 1] || source;
585
+ i += 1;
586
+ continue;
587
+ }
588
+ if (arg.startsWith('--source=')) {
589
+ source = arg.slice('--source='.length) || source;
590
+ continue;
591
+ }
592
+ payloadArgs.push(arg);
593
+ }
594
+
595
+ const trackerDir = ${JSON.stringify(trackerDir)};
596
+ const signalPath = ${JSON.stringify(queueSignalPath)};
597
+ const codexOriginalPath = ${JSON.stringify(originalPath)};
598
+ const codeOriginalPath = ${JSON.stringify(path.join(trackerDir, 'code_notify_original.json'))};
599
+ const trackerBinPath = ${JSON.stringify(trackerBinPath)};
600
+ const depsMarkerPath = path.join(trackerDir, 'app', 'node_modules', '@insforge', 'sdk', 'package.json');
601
+ const configPath = path.join(trackerDir, 'config.json');
602
+ const fallbackPkg = ${JSON.stringify(fallbackPkg)};
603
+ const selfPath = path.resolve(__filename);
604
+ const home = os.homedir();
605
+
606
+ try {
607
+ fs.mkdirSync(trackerDir, { recursive: true });
608
+ fs.writeFileSync(signalPath, new Date().toISOString(), { encoding: 'utf8' });
609
+ } catch (_) {}
610
+
611
+ // Throttle spawn: at most once per 20 seconds.
612
+ try {
613
+ const throttlePath = path.join(trackerDir, 'sync.throttle');
614
+ let deviceToken = process.env.VIBEUSAGE_DEVICE_TOKEN || process.env.VIBESCORE_DEVICE_TOKEN || null;
615
+ if (!deviceToken) {
616
+ try {
617
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
618
+ if (cfg && typeof cfg.deviceToken === 'string') deviceToken = cfg.deviceToken;
619
+ } catch (_) {}
620
+ }
621
+ const canSync = Boolean(deviceToken && deviceToken.length > 0);
622
+ const now = Date.now();
623
+ let last = 0;
624
+ try { last = Number(fs.readFileSync(throttlePath, 'utf8')) || 0; } catch (_) {}
625
+ if (canSync && now - last > 20_000) {
626
+ try { fs.writeFileSync(throttlePath, String(now), 'utf8'); } catch (_) {}
627
+ const hasLocalRuntime = fs.existsSync(trackerBinPath);
628
+ const hasLocalDeps = fs.existsSync(depsMarkerPath);
629
+ if (hasLocalRuntime && hasLocalDeps) {
630
+ spawnDetached([process.execPath, trackerBinPath, 'sync', '--auto', '--from-notify']);
631
+ } else {
632
+ spawnDetached(['npx', '--yes', fallbackPkg, 'sync', '--auto', '--from-notify']);
633
+ }
634
+ }
635
+ } catch (_) {}
636
+
637
+ // Chain the original notify if present (Codex/Every Code only).
638
+ try {
639
+ const originalPath =
640
+ source === 'every-code'
641
+ ? codeOriginalPath
642
+ : source === 'claude' || source === 'opencode' || source === 'gemini'
643
+ ? null
644
+ : codexOriginalPath;
645
+ if (originalPath) {
646
+ const original = JSON.parse(fs.readFileSync(originalPath, 'utf8'));
647
+ const cmd = Array.isArray(original?.notify) ? original.notify : null;
648
+ if (cmd && cmd.length > 0 && !isSelfNotify(cmd)) {
649
+ const args = cmd.slice(1);
650
+ if (payloadArgs.length > 0) args.push(...payloadArgs);
651
+ spawnDetached([cmd[0], ...args]);
652
+ }
653
+ }
654
+ } catch (_) {}
655
+
656
+ process.exit(0);
657
+
658
+ function spawnDetached(argv) {
659
+ try {
660
+ const child = cp.spawn(argv[0], argv.slice(1), {
661
+ detached: true,
662
+ stdio: 'ignore',
663
+ env: process.env
664
+ });
665
+ child.unref();
666
+ } catch (_) {}
667
+ }
668
+
669
+ function resolveMaybeHome(p) {
670
+ if (typeof p !== 'string') return null;
671
+ if (p.startsWith('~/')) return path.join(home, p.slice(2));
672
+ return path.resolve(p);
673
+ }
674
+
675
+ function isSelfNotify(cmd) {
676
+ for (const part of cmd) {
677
+ if (typeof part !== 'string') continue;
678
+ if (!part.includes('notify.cjs')) continue;
679
+ const resolved = resolveMaybeHome(part);
680
+ if (resolved && resolved === selfPath) return true;
681
+ }
682
+ return false;
683
+ }
684
+ `;
685
+ }
686
+
687
+ module.exports = { cmdInit };
688
+
689
+ async function detectLocalDashboardUrl() {
690
+ // Dev-only convenience: prefer a local dashboard (if running) so the user sees our own UI first.
691
+ // Vite defaults to 5173, but may auto-increment if the port is taken.
692
+ const hosts = ['127.0.0.1', 'localhost'];
693
+ const ports = [5173, 5174, 5175, 5176, 5177];
694
+
695
+ for (const port of ports) {
696
+ for (const host of hosts) {
697
+ const base = `http://${host}:${port}`;
698
+ const ok = await checkUrlReachable(base);
699
+ if (ok) return base;
700
+ }
701
+ }
702
+ return null;
703
+ }
704
+
705
+ async function checkUrlReachable(url) {
706
+ const timeoutMs = 250;
707
+ try {
708
+ const controller = new AbortController();
709
+ const t = setTimeout(() => controller.abort(), timeoutMs);
710
+ const res = await fetch(url, { method: 'GET', signal: controller.signal });
711
+ clearTimeout(t);
712
+ return Boolean(res && res.ok);
713
+ } catch (_e) {
714
+ return false;
715
+ }
716
+ }
717
+
718
+ async function probeFile(p) {
719
+ try {
720
+ const st = await fs.stat(p);
721
+ if (st.isFile()) return { exists: true, reason: null };
722
+ return { exists: false, reason: 'not-file' };
723
+ } catch (e) {
724
+ if (e?.code === 'ENOENT' || e?.code === 'ENOTDIR') return { exists: false, reason: 'missing' };
725
+ if (e?.code === 'EACCES' || e?.code === 'EPERM') return { exists: false, reason: 'permission-denied' };
726
+ return { exists: false, reason: 'error', code: e?.code || 'unknown' };
727
+ }
728
+ }
729
+
730
+ async function isDir(p) {
731
+ try {
732
+ const st = await fs.stat(p);
733
+ return st.isDirectory();
734
+ } catch (_e) {
735
+ return false;
736
+ }
737
+ }
738
+
739
+ async function installLocalTrackerApp({ appDir }) {
740
+ // Copy the current package's runtime (bin + src) into ~/.vibeusage so notify can run sync without npx.
741
+ const packageRoot = path.resolve(__dirname, '../..');
742
+ const srcFrom = path.join(packageRoot, 'src');
743
+ const binFrom = path.join(packageRoot, 'bin', 'tracker.js');
744
+ const nodeModulesFrom = path.join(packageRoot, 'node_modules');
745
+
746
+ const srcTo = path.join(appDir, 'src');
747
+ const binToDir = path.join(appDir, 'bin');
748
+ const binTo = path.join(binToDir, 'tracker.js');
749
+ const nodeModulesTo = path.join(appDir, 'node_modules');
750
+
751
+ await fs.rm(appDir, { recursive: true, force: true }).catch(() => {});
752
+ await ensureDir(appDir);
753
+ await fs.cp(srcFrom, srcTo, { recursive: true });
754
+ await ensureDir(binToDir);
755
+ await fs.copyFile(binFrom, binTo);
756
+ await fs.chmod(binTo, 0o755).catch(() => {});
757
+ await copyRuntimeDependencies({ from: nodeModulesFrom, to: nodeModulesTo });
758
+ }
759
+
760
+ function spawnInitSync({ trackerBinPath, packageName }) {
761
+ const fallbackPkg = packageName || 'vibeusage';
762
+ const argv = ['sync', '--drain'];
763
+ const hasLocalRuntime = typeof trackerBinPath === 'string' && fssync.existsSync(trackerBinPath);
764
+ const cmd = hasLocalRuntime
765
+ ? [process.execPath, trackerBinPath, ...argv]
766
+ : ['npx', '--yes', fallbackPkg, ...argv];
767
+ const child = cp.spawn(cmd[0], cmd.slice(1), {
768
+ detached: true,
769
+ stdio: 'ignore',
770
+ env: process.env
771
+ });
772
+ child.on('error', (err) => {
773
+ const msg = err && err.message ? err.message : 'unknown error';
774
+ const detail = isDebugEnabled() ? ` (${msg})` : '';
775
+ process.stderr.write(`Minor issue: Background sync could not start${detail}.\n`);
776
+ process.stderr.write('Run: npx --yes vibeusage sync\n');
777
+ });
778
+ child.unref();
779
+ }
780
+
781
+ async function copyRuntimeDependencies({ from, to }) {
782
+ try {
783
+ const st = await fs.stat(from);
784
+ if (!st.isDirectory()) return;
785
+ } catch (_e) {
786
+ return;
787
+ }
788
+
789
+ try {
790
+ await fs.cp(from, to, { recursive: true });
791
+ } catch (_e) {
792
+ // Best-effort: missing dependencies will fall back to npx at notify time.
793
+ }
794
+ }
795
+
796
+ function isDebugEnabled() {
797
+ return process.env.VIBEUSAGE_DEBUG === '1' || process.env.VIBESCORE_DEBUG === '1';
798
+ }