@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.
- package/README.md +74 -11
- package/cli.mjs +8 -11
- package/collector.mjs +261 -204
- package/credentials.mjs +75 -0
- package/install.mjs +171 -133
- package/package.json +7 -3
package/credentials.mjs
ADDED
|
@@ -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
|
|
3
|
-
import { execFileSync, spawnSync } from
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, copyFileSync } from
|
|
5
|
-
import { homedir, hostname, userInfo } from
|
|
6
|
-
import { resolve } from
|
|
7
|
-
import { fileURLToPath } from
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
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
|
-
|
|
22
|
+
const inheritedApiKey = process.env.TRAICE_API_KEY;
|
|
23
|
+
loadEnvFile(resolve("apps/web/.env"));
|
|
22
24
|
|
|
23
|
-
const outDir = resolveHome(args.get(
|
|
24
|
-
const installPath = resolve(outDir,
|
|
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(
|
|
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(
|
|
32
|
-
const workspaceName = args.get(
|
|
33
|
-
const keyName = args.get(
|
|
34
|
-
const codexHome = resolveHome(args.get(
|
|
35
|
-
const listenHost = args.get(
|
|
36
|
-
const listenPort = parsePort(args.get(
|
|
37
|
-
const patchCodexConfig = args.get(
|
|
38
|
-
const installLaunchAgent = args.get(
|
|
39
|
-
const employeeEmail =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
73
|
+
version: 2,
|
|
58
74
|
installedAt: new Date().toISOString(),
|
|
59
75
|
serverUrl,
|
|
60
|
-
|
|
76
|
+
apiKeyCredential: storedApiKey.credential,
|
|
61
77
|
apiKeyPrefix: setup.key.prefix,
|
|
62
78
|
workspaceId: setup.workspace.id,
|
|
63
79
|
workspaceSlug: setup.workspace.slug,
|
|
64
|
-
sourceKey:
|
|
65
|
-
sourceName:
|
|
66
|
-
sourceKind:
|
|
67
|
-
tool:
|
|
68
|
-
category:
|
|
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
|
|
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:
|
|
107
|
+
: { status: "skipped", path: resolve(codexHome, "config.toml") };
|
|
92
108
|
const launchAgentResult = installLaunchAgent
|
|
93
109
|
? installUserLaunchAgent({ installPath, outDir })
|
|
94
|
-
: { status:
|
|
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:
|
|
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
|
|
145
|
+
reused,
|
|
126
146
|
},
|
|
127
147
|
};
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
function readLegacyInstallIfDefaultOut() {
|
|
131
|
-
if (args.get(
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
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:
|
|
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 ===
|
|
166
|
-
await ensureDevMembership(workspace.id, args.get(
|
|
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:
|
|
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(
|
|
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:
|
|
192
|
-
update: { role:
|
|
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 =
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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:
|
|
231
|
+
where: { workspaceId_key: { workspaceId, key: "codex-local" } },
|
|
209
232
|
create: {
|
|
210
233
|
workspaceId,
|
|
211
|
-
key:
|
|
212
|
-
name:
|
|
213
|
-
kind:
|
|
234
|
+
key: "codex-local",
|
|
235
|
+
name: "Codex local collector",
|
|
236
|
+
kind: "codex_otel",
|
|
214
237
|
...(metadata ? { metadata } : {}),
|
|
215
238
|
},
|
|
216
239
|
update: {
|
|
217
|
-
name:
|
|
218
|
-
kind:
|
|
219
|
-
status:
|
|
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,
|
|
251
|
-
const current = existsSync(configPath) ? readFileSync(configPath,
|
|
273
|
+
const configPath = resolve(codexHome, "config.toml");
|
|
274
|
+
const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
252
275
|
const block = [
|
|
253
276
|
BEGIN_MARKER,
|
|
254
|
-
|
|
277
|
+
"[otel]",
|
|
255
278
|
'environment = "traice-device"',
|
|
256
|
-
|
|
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(
|
|
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?`,
|
|
290
|
+
const pattern = new RegExp(`${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}\\n?`, "m");
|
|
268
291
|
writeFileSync(configPath, current.replace(pattern, block));
|
|
269
|
-
return { status:
|
|
292
|
+
return { status: "updated", path: configPath };
|
|
270
293
|
}
|
|
271
294
|
|
|
272
295
|
if (/^\s*\[otel\]\s*$/m.test(current)) {
|
|
273
296
|
return {
|
|
274
|
-
status:
|
|
297
|
+
status: "blocked_existing_otel",
|
|
275
298
|
path: configPath,
|
|
276
|
-
message:
|
|
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:
|
|
305
|
+
return { status: "added", path: configPath };
|
|
283
306
|
}
|
|
284
307
|
|
|
285
308
|
function installUserLaunchAgent({ installPath, outDir }) {
|
|
286
|
-
const launchAgentsDir = resolveHome(
|
|
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,
|
|
292
|
-
const errPath = resolve(outDir,
|
|
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(
|
|
325
|
-
const result = spawnSync(
|
|
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:
|
|
351
|
+
status: "written_not_loaded",
|
|
329
352
|
path: plistPath,
|
|
330
|
-
error: (result.stderr || result.stdout ||
|
|
353
|
+
error: (result.stderr || result.stdout || "").trim(),
|
|
331
354
|
};
|
|
332
355
|
}
|
|
333
|
-
return { status:
|
|
356
|
+
return { status: "loaded", path: plistPath, logPath, errPath };
|
|
334
357
|
}
|
|
335
358
|
|
|
336
359
|
function copyCollectorForService(outDir) {
|
|
337
|
-
const sourcePath = fileURLToPath(new URL(
|
|
338
|
-
const targetPath = resolve(outDir,
|
|
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(
|
|
350
|
-
return readFileSync(0,
|
|
372
|
+
if (args.get("api-key-stdin") === "true") {
|
|
373
|
+
return readFileSync(0, "utf8").trim();
|
|
351
374
|
}
|
|
352
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
375
|
-
encoding:
|
|
376
|
-
stdio: [
|
|
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,
|
|
411
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
387
412
|
const trimmed = line.trim();
|
|
388
|
-
if (!trimmed || trimmed.startsWith(
|
|
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,
|
|
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 ===
|
|
422
|
-
if (value.startsWith(
|
|
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 ===
|
|
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(
|
|
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(
|
|
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,
|
|
465
|
-
.replace(/</g,
|
|
466
|
-
.replace(/>/g,
|
|
467
|
-
.replace(/"/g,
|
|
468
|
-
.replace(/'/g,
|
|
502
|
+
.replace(/&/g, "&")
|
|
503
|
+
.replace(/</g, "<")
|
|
504
|
+
.replace(/>/g, ">")
|
|
505
|
+
.replace(/"/g, """)
|
|
506
|
+
.replace(/'/g, "'");
|
|
469
507
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@traice/codex-collector",
|
|
3
|
-
"version": "0.1.
|
|
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+
|
|
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/
|
|
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
|
]
|