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