@traice/codex-collector 0.1.0 → 0.1.3

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