@traice/codex-collector 0.1.0

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/install.mjs ADDED
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, copyFileSync } from 'node:fs';
5
+ import { homedir, hostname, userInfo } from 'node:os';
6
+ import { resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const DEFAULT_OUT_DIR = '~/.traice/internal-spend-codex';
10
+ const LEGACY_INSTALL_CONFIG = '.traice-internal-spend/install.json';
11
+ const DEFAULT_WORKSPACE_SLUG = 'internal-spend-poc';
12
+ const DEFAULT_WORKSPACE_NAME = 'Internal Spend POC';
13
+ const DEFAULT_KEY_NAME = 'internal-spend-codex-device';
14
+ const BEGIN_MARKER = '# BEGIN trAIce Internal Spend Codex OTel';
15
+ const END_MARKER = '# END trAIce Internal Spend Codex OTel';
16
+ const LEGACY_BEGIN_MARKER = '# BEGIN trAIce Codex OTel POC';
17
+ const LEGACY_END_MARKER = '# END trAIce Codex OTel POC';
18
+ const LAUNCH_AGENT_LABEL = 'com.traice.internal-spend-codex-collector';
19
+
20
+ const args = parseArgs(process.argv.slice(2));
21
+ loadEnvFile(resolve('apps/web/.env'));
22
+
23
+ const outDir = resolveHome(args.get('out') ?? DEFAULT_OUT_DIR);
24
+ const installPath = resolve(outDir, 'install.json');
25
+ const serverUrl = normalizeUrl(
26
+ args.get('server-url') ??
27
+ process.env.TRAICE_API_URL ??
28
+ process.env.NEXT_PUBLIC_APP_URL ??
29
+ 'http://127.0.0.1:3003',
30
+ );
31
+ const workspaceSlug = args.get('workspace-slug') ?? DEFAULT_WORKSPACE_SLUG;
32
+ const workspaceName = args.get('workspace-name') ?? DEFAULT_WORKSPACE_NAME;
33
+ const keyName = args.get('key-name') ?? DEFAULT_KEY_NAME;
34
+ const codexHome = resolveHome(args.get('codex-home') ?? process.env.CODEX_HOME ?? '~/.codex');
35
+ const listenHost = args.get('listen-host') ?? '127.0.0.1';
36
+ const listenPort = parsePort(args.get('listen-port') ?? '4318');
37
+ const patchCodexConfig = args.get('patch-codex-config') !== 'false';
38
+ const installLaunchAgent = args.get('launch-agent') === 'true' || args.get('start-service') === 'true';
39
+ const employeeEmail = args.get('employee-email') ?? process.env.TRAICE_EMPLOYEE_EMAIL ?? gitConfig('user.email') ?? `${userInfo().username}@local.dev`;
40
+ const employeeName = args.get('employee-name') ?? process.env.TRAICE_EMPLOYEE_NAME ?? gitConfig('user.name') ?? userInfo().username;
41
+ const teamName = args.get('team-name') ?? process.env.TRAICE_TEAM_NAME ?? 'Engineering';
42
+ const sourcePrincipal = args.get('source-principal') ?? `${hostname()}:${userInfo().username}`;
43
+ const seatMonthlyUsd = parseOptionalMoney(args.get('seat-monthly-usd') ?? process.env.TRAICE_SEAT_MONTHLY_USD);
44
+ const providedApiKey = readApiKey();
45
+
46
+ mkdirSync(outDir, { recursive: true });
47
+
48
+ const previousInstall = readJsonIfExists(installPath) ?? readLegacyInstallIfDefaultOut();
49
+ const previousApiKey = typeof previousInstall?.apiKey === 'string' ? previousInstall.apiKey : null;
50
+
51
+ let prisma = null;
52
+
53
+ try {
54
+ const setup = providedApiKey ? providedApiKeySetup(providedApiKey) : await localDevSetup();
55
+
56
+ const installConfig = {
57
+ version: 1,
58
+ installedAt: new Date().toISOString(),
59
+ serverUrl,
60
+ apiKey: setup.key.raw,
61
+ apiKeyPrefix: setup.key.prefix,
62
+ workspaceId: setup.workspace.id,
63
+ workspaceSlug: setup.workspace.slug,
64
+ sourceKey: 'codex-local',
65
+ sourceName: 'Codex local collector',
66
+ sourceKind: 'codex_otel',
67
+ tool: 'codex',
68
+ category: 'coding_agent',
69
+ employeeEmail,
70
+ employeeName,
71
+ teamName,
72
+ sourcePrincipal,
73
+ seatMonthlyUsd,
74
+ codexHome,
75
+ listenHost,
76
+ listenPort,
77
+ pollIntervalMs: 3000,
78
+ backfillHours: 6,
79
+ includeZeroOutput: false,
80
+ };
81
+
82
+ writeFileSync(installPath, `${JSON.stringify(installConfig, null, 2)}\n`, { mode: 0o600 });
83
+ try {
84
+ chmodSync(installPath, 0o600);
85
+ } catch {
86
+ // Best effort on non-POSIX filesystems.
87
+ }
88
+
89
+ const configResult = patchCodexConfig
90
+ ? patchUserCodexConfig({ codexHome, listenHost, listenPort })
91
+ : { status: 'skipped', path: resolve(codexHome, 'config.toml') };
92
+ const launchAgentResult = installLaunchAgent
93
+ ? installUserLaunchAgent({ installPath, outDir })
94
+ : { status: 'skipped' };
95
+
96
+ console.log(
97
+ JSON.stringify(
98
+ {
99
+ ok: true,
100
+ mode: setup.mode,
101
+ workspace: setup.workspace,
102
+ apiKey: { prefix: setup.key.prefix, reused: setup.key.reused },
103
+ identity: { employeeEmail, employeeName, teamName, sourcePrincipal },
104
+ cost: { seatMonthlyUsd },
105
+ installConfig: installPath,
106
+ codexConfig: configResult,
107
+ launchAgent: launchAgentResult,
108
+ collectorCommand: `npx @traice/codex-collector@latest collect --config ${installPath}`,
109
+ },
110
+ null,
111
+ 2,
112
+ ),
113
+ );
114
+ } finally {
115
+ await prisma?.$disconnect();
116
+ }
117
+
118
+ function providedApiKeySetup(rawApiKey) {
119
+ return {
120
+ mode: 'provided_api_key',
121
+ workspace: { id: null, slug: workspaceSlug, name: workspaceName, plan: null },
122
+ key: {
123
+ raw: rawApiKey,
124
+ prefix: rawApiKey.slice(0, 12),
125
+ reused: true,
126
+ },
127
+ };
128
+ }
129
+
130
+ function readLegacyInstallIfDefaultOut() {
131
+ if (args.get('out')) return null;
132
+ return readJsonIfExists(resolve(LEGACY_INSTALL_CONFIG));
133
+ }
134
+
135
+ async function localDevSetup() {
136
+ if (!process.env.POSTGRES_PRISMA_URL || !process.env.POSTGRES_URL_NON_POOLING) {
137
+ console.error([
138
+ 'Missing API key and local database environment.',
139
+ 'For a normal install, set TRAICE_API_KEY or pass --api-key-stdin.',
140
+ 'For local repo development, check apps/web/.env for POSTGRES_PRISMA_URL and POSTGRES_URL_NON_POOLING.',
141
+ ].join('\n'));
142
+ process.exit(2);
143
+ }
144
+
145
+ const { PrismaClient } = await import('@prisma/client');
146
+ prisma = new PrismaClient();
147
+ const workspace = await prisma.workspace.upsert({
148
+ where: { slug: workspaceSlug },
149
+ create: {
150
+ name: workspaceName,
151
+ slug: workspaceSlug,
152
+ plan: 'TEAM',
153
+ captureSamplesOptIn: false,
154
+ internalSpendEnabled: true,
155
+ },
156
+ update: {
157
+ name: workspaceName,
158
+ internalSpendEnabled: true,
159
+ },
160
+ select: { id: true, slug: true, name: true, plan: true },
161
+ });
162
+
163
+ const key = await ensureApiKey(workspace.id, keyName, previousApiKey);
164
+ await ensureInternalSource(workspace.id, seatMonthlyUsd);
165
+ if (process.env.DEV_AUTH_ENABLED === 'true') {
166
+ await ensureDevMembership(workspace.id, args.get('dev-user') ?? 'admin@local.dev');
167
+ }
168
+
169
+ return {
170
+ mode: 'local_dev_bootstrap',
171
+ workspace: { id: workspace.id, slug: workspace.slug, name: workspace.name, plan: workspace.plan },
172
+ key,
173
+ };
174
+ }
175
+
176
+ async function ensureDevMembership(workspaceId, email) {
177
+ const user = await prisma.user.upsert({
178
+ where: { email },
179
+ create: {
180
+ email,
181
+ name: email.split('@')[0],
182
+ emailVerified: new Date(),
183
+ },
184
+ update: {
185
+ emailVerified: new Date(),
186
+ },
187
+ });
188
+
189
+ await prisma.workspaceUser.upsert({
190
+ where: { workspaceId_userId: { workspaceId, userId: user.id } },
191
+ create: { workspaceId, userId: user.id, role: 'OWNER' },
192
+ update: { role: 'OWNER' },
193
+ });
194
+ }
195
+
196
+ async function ensureInternalSource(workspaceId, monthlySeatUsd) {
197
+ const metadata = monthlySeatUsd > 0
198
+ ? {
199
+ subscription: {
200
+ monthlySeatUsd,
201
+ allocation: 'monthly_seat_commitment',
202
+ note: 'Subscription commitment is shown separately from per-request usage cost.',
203
+ },
204
+ }
205
+ : undefined;
206
+
207
+ await prisma.internalSource.upsert({
208
+ where: { workspaceId_key: { workspaceId, key: 'codex-local' } },
209
+ create: {
210
+ workspaceId,
211
+ key: 'codex-local',
212
+ name: 'Codex local collector',
213
+ kind: 'codex_otel',
214
+ ...(metadata ? { metadata } : {}),
215
+ },
216
+ update: {
217
+ name: 'Codex local collector',
218
+ kind: 'codex_otel',
219
+ status: 'active',
220
+ ...(metadata ? { metadata } : {}),
221
+ },
222
+ });
223
+ }
224
+
225
+ async function ensureApiKey(workspaceId, name, previousRawKey) {
226
+ if (previousRawKey) {
227
+ const existing = await prisma.apiKey.findUnique({
228
+ where: { hash: hashApiKey(previousRawKey) },
229
+ select: { id: true, prefix: true, revokedAt: true, workspaceId: true },
230
+ });
231
+ if (existing && !existing.revokedAt && existing.workspaceId === workspaceId) {
232
+ return { raw: previousRawKey, prefix: existing.prefix, reused: true };
233
+ }
234
+ }
235
+
236
+ const generated = generateApiKey();
237
+ await prisma.apiKey.create({
238
+ data: {
239
+ workspaceId,
240
+ name,
241
+ prefix: generated.prefix,
242
+ hash: generated.hash,
243
+ },
244
+ });
245
+ return { raw: generated.raw, prefix: generated.prefix, reused: false };
246
+ }
247
+
248
+ function patchUserCodexConfig({ codexHome, listenHost, listenPort }) {
249
+ mkdirSync(codexHome, { recursive: true });
250
+ const configPath = resolve(codexHome, 'config.toml');
251
+ const current = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
252
+ const block = [
253
+ BEGIN_MARKER,
254
+ '[otel]',
255
+ 'environment = "traice-device"',
256
+ 'log_user_prompt = false',
257
+ `exporter = { otlp-http = { endpoint = "http://${listenHost}:${listenPort}/v1/logs", protocol = "json" } }`,
258
+ 'trace_exporter = "none"',
259
+ 'metrics_exporter = "none"',
260
+ END_MARKER,
261
+ '',
262
+ ].join('\n');
263
+
264
+ if (current.includes(BEGIN_MARKER) || current.includes(LEGACY_BEGIN_MARKER)) {
265
+ const begin = current.includes(BEGIN_MARKER) ? BEGIN_MARKER : LEGACY_BEGIN_MARKER;
266
+ const end = current.includes(BEGIN_MARKER) ? END_MARKER : LEGACY_END_MARKER;
267
+ const pattern = new RegExp(`${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, 'm');
268
+ writeFileSync(configPath, current.replace(pattern, block));
269
+ return { status: 'updated', path: configPath };
270
+ }
271
+
272
+ if (/^\s*\[otel\]\s*$/m.test(current)) {
273
+ return {
274
+ status: 'blocked_existing_otel',
275
+ path: configPath,
276
+ message: 'Existing [otel] table found; left it unchanged.',
277
+ };
278
+ }
279
+
280
+ const next = current.trimEnd().length > 0 ? `${current.trimEnd()}\n\n${block}` : block;
281
+ writeFileSync(configPath, next);
282
+ return { status: 'added', path: configPath };
283
+ }
284
+
285
+ function installUserLaunchAgent({ installPath, outDir }) {
286
+ const launchAgentsDir = resolveHome('~/Library/LaunchAgents');
287
+ mkdirSync(launchAgentsDir, { recursive: true });
288
+ const plistPath = resolve(launchAgentsDir, `${LAUNCH_AGENT_LABEL}.plist`);
289
+ const nodePath = process.execPath;
290
+ const collectorPath = copyCollectorForService(outDir);
291
+ const logPath = resolve(outDir, 'collector.log');
292
+ const errPath = resolve(outDir, 'collector.err.log');
293
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
294
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
295
+ <plist version="1.0">
296
+ <dict>
297
+ <key>Label</key>
298
+ <string>${escapeXml(LAUNCH_AGENT_LABEL)}</string>
299
+ <key>ProgramArguments</key>
300
+ <array>
301
+ <string>${escapeXml(nodePath)}</string>
302
+ <string>${escapeXml(collectorPath)}</string>
303
+ <string>--config</string>
304
+ <string>${escapeXml(installPath)}</string>
305
+ <string>--out</string>
306
+ <string>${escapeXml(outDir)}</string>
307
+ </array>
308
+ <key>WorkingDirectory</key>
309
+ <string>${escapeXml(outDir)}</string>
310
+ <key>RunAtLoad</key>
311
+ <true/>
312
+ <key>KeepAlive</key>
313
+ <true/>
314
+ <key>StandardOutPath</key>
315
+ <string>${escapeXml(logPath)}</string>
316
+ <key>StandardErrorPath</key>
317
+ <string>${escapeXml(errPath)}</string>
318
+ </dict>
319
+ </plist>
320
+ `;
321
+ writeFileSync(plistPath, plist);
322
+
323
+ const guiTarget = `gui/${process.getuid()}`;
324
+ spawnSync('launchctl', ['bootout', guiTarget, plistPath], { stdio: 'ignore' });
325
+ const result = spawnSync('launchctl', ['bootstrap', guiTarget, plistPath], { encoding: 'utf8' });
326
+ if (result.status !== 0) {
327
+ return {
328
+ status: 'written_not_loaded',
329
+ path: plistPath,
330
+ error: (result.stderr || result.stdout || '').trim(),
331
+ };
332
+ }
333
+ return { status: 'loaded', path: plistPath, logPath, errPath };
334
+ }
335
+
336
+ function copyCollectorForService(outDir) {
337
+ const sourcePath = fileURLToPath(new URL('./collector.mjs', import.meta.url));
338
+ const targetPath = resolve(outDir, 'collector.mjs');
339
+ copyFileSync(sourcePath, targetPath);
340
+ try {
341
+ chmodSync(targetPath, 0o755);
342
+ } catch {
343
+ // Best effort on non-POSIX filesystems.
344
+ }
345
+ return targetPath;
346
+ }
347
+
348
+ function readApiKey() {
349
+ if (args.get('api-key-stdin') === 'true') {
350
+ return readFileSync(0, 'utf8').trim();
351
+ }
352
+ return args.get('api-key') ?? process.env.TRAICE_API_KEY;
353
+ }
354
+
355
+ function parseArgs(argv) {
356
+ const parsed = new Map();
357
+ for (let i = 0; i < argv.length; i += 1) {
358
+ const arg = argv[i];
359
+ if (!arg.startsWith('--')) continue;
360
+ const key = arg.slice(2);
361
+ const next = argv[i + 1];
362
+ if (next && !next.startsWith('--')) {
363
+ parsed.set(key, next);
364
+ i += 1;
365
+ } else {
366
+ parsed.set(key, 'true');
367
+ }
368
+ }
369
+ return parsed;
370
+ }
371
+
372
+ function gitConfig(key) {
373
+ try {
374
+ const value = execFileSync('git', ['config', '--global', key], {
375
+ encoding: 'utf8',
376
+ stdio: ['ignore', 'pipe', 'ignore'],
377
+ }).trim();
378
+ return value || null;
379
+ } catch {
380
+ return null;
381
+ }
382
+ }
383
+
384
+ function loadEnvFile(path) {
385
+ if (!existsSync(path)) return;
386
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
387
+ const trimmed = line.trim();
388
+ if (!trimmed || trimmed.startsWith('#')) continue;
389
+ const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
390
+ if (!match) continue;
391
+ const [, key, rawValue] = match;
392
+ if (process.env[key] != null) continue;
393
+ process.env[key] = unquoteEnvValue(rawValue.trim());
394
+ }
395
+ }
396
+
397
+ function unquoteEnvValue(value) {
398
+ if (
399
+ (value.startsWith('"') && value.endsWith('"')) ||
400
+ (value.startsWith("'") && value.endsWith("'"))
401
+ ) {
402
+ return value.slice(1, -1);
403
+ }
404
+ return value;
405
+ }
406
+
407
+ function readJsonIfExists(path) {
408
+ if (!existsSync(path)) return null;
409
+ try {
410
+ return JSON.parse(readFileSync(path, 'utf8'));
411
+ } catch {
412
+ return null;
413
+ }
414
+ }
415
+
416
+ function normalizeUrl(value) {
417
+ return String(value).replace(/\/+$/, '');
418
+ }
419
+
420
+ function resolveHome(value) {
421
+ if (value === '~') return homedir();
422
+ if (value.startsWith('~/')) return resolve(homedir(), value.slice(2));
423
+ return resolve(value);
424
+ }
425
+
426
+ function parsePort(value) {
427
+ const port = Number(value);
428
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
429
+ console.error(`Invalid listen port: ${value}`);
430
+ process.exit(2);
431
+ }
432
+ return port;
433
+ }
434
+
435
+ function parseOptionalMoney(value) {
436
+ if (value == null || value === '') return 0;
437
+ const parsed = Number(String(value).replace(/[$,]/g, ''));
438
+ if (!Number.isFinite(parsed) || parsed < 0) {
439
+ console.error(`Invalid USD amount: ${value}`);
440
+ process.exit(2);
441
+ }
442
+ return parsed;
443
+ }
444
+
445
+ function generateApiKey() {
446
+ const raw = `lm_live_${randomBytes(24).toString('hex')}`;
447
+ return {
448
+ raw,
449
+ prefix: raw.slice(0, 12),
450
+ hash: hashApiKey(raw),
451
+ };
452
+ }
453
+
454
+ function hashApiKey(raw) {
455
+ return createHash('sha256').update(raw).digest('hex');
456
+ }
457
+
458
+ function escapeRegExp(value) {
459
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
460
+ }
461
+
462
+ function escapeXml(value) {
463
+ return String(value)
464
+ .replace(/&/g, '&amp;')
465
+ .replace(/</g, '&lt;')
466
+ .replace(/>/g, '&gt;')
467
+ .replace(/"/g, '&quot;')
468
+ .replace(/'/g, '&apos;');
469
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@traice/codex-collector",
3
+ "version": "0.1.0",
4
+ "description": "Local Codex usage collector for trAIce Internal Spend.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "bin": {
8
+ "traice-codex-collector": "cli.mjs"
9
+ },
10
+ "scripts": {
11
+ "check": "node --check cli.mjs && node --check install.mjs && node --check collector.mjs",
12
+ "prepublishOnly": "npm run check && npm pack --dry-run"
13
+ },
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+ssh://git@ssh.github.com/shmulikdav/trAIce.git",
20
+ "directory": "tools/internal-spend-codex"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/shmulikdav/trAIce/issues"
24
+ },
25
+ "homepage": "https://runtraice.com",
26
+ "keywords": [
27
+ "traice",
28
+ "codex",
29
+ "otel",
30
+ "usage",
31
+ "tokens",
32
+ "llm"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "files": [
38
+ "cli.mjs",
39
+ "collector.mjs",
40
+ "install.mjs",
41
+ "README.md"
42
+ ]
43
+ }