@token-dashboard/codex-usage-uploader 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.zh-CN.md +27 -0
- package/README.md +2 -0
- package/dist/bin/codex-usage-uploader.js +5 -0
- package/dist/cli.mjs +1 -0
- package/package.json +12 -5
- package/bin/codex-usage-uploader.js +0 -9
- package/src/auth.js +0 -56
- package/src/cli.js +0 -759
- package/src/collector.js +0 -676
- package/src/constants.js +0 -44
- package/src/install.js +0 -156
- package/src/launchd.js +0 -170
- package/src/local-usage.js +0 -342
- package/src/parser.js +0 -180
- package/src/runtime-config.js +0 -182
- package/src/state-db.js +0 -325
- package/src/utils.js +0 -68
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# 版本变更记录
|
|
2
|
+
|
|
3
|
+
本文档记录 `@token-dashboard/codex-usage-uploader` 的重要变更。
|
|
4
|
+
|
|
5
|
+
## 0.1.8 - 2026-05-20
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- 后台服务新增扫描 `~/.codex/archived_sessions`,兼容新版 Codex App 将 rollout 文件移动到归档目录后导致的漏采问题。
|
|
10
|
+
- 将 rollout 相对路径统一规范为 `YYYY/MM/DD/rollout-...jsonl`,同时兼容嵌套目录和平铺归档目录两种布局。
|
|
11
|
+
- 归档文件被发现时,会复用已有的 `sessions` checkpoint 和旧版平铺 `archived_sessions` checkpoint,避免升级或文件移动后重复采集。
|
|
12
|
+
- `approval_policy` 现在支持对象格式,会在上传前稳定序列化为 JSON 字符串,兼容新版 Codex App 的字段结构。
|
|
13
|
+
- 后端也增加了 `approvalPolicy` 归一化,确保旧的待上传队列里如果已经存在对象格式 payload,也能被安全接收。
|
|
14
|
+
|
|
15
|
+
### 行为说明
|
|
16
|
+
|
|
17
|
+
- 升级后不需要手动补数据。collector 会自动发现之前未上报的归档 rollout 文件,并且只上传缺失的新增行。
|
|
18
|
+
- 补报数据会按 rollout 事件自身的时间戳归属到原始发生日期,并按 Asia/Shanghai 计算 `event_date`;不会全部计入补报当天。
|
|
19
|
+
- 上传批次的 `received_at` 和数据库行的 `created_at` 仍然表示后端实际收到数据的时间,这是接收时间,不影响用量日期归属。
|
|
20
|
+
|
|
21
|
+
### 测试
|
|
22
|
+
|
|
23
|
+
- 增加后台扫描 `archived_sessions` 的回归测试。
|
|
24
|
+
- 增加从 `sessions` 移动到 `archived_sessions` 后复用 checkpoint 的测试。
|
|
25
|
+
- 增加旧版平铺归档 checkpoint 升级兼容测试。
|
|
26
|
+
- 增加 parser、待上传队列、后端 DTO 和 service 对对象格式 `approval_policy` 的测试覆盖。
|
|
27
|
+
- 发布前已验证 collector 测试、collector 构建、后端构建、后端单元测试和后端 e2e smoke 测试。
|
package/README.md
CHANGED
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import e from"node:fs";import t from"node:path";import n from"node:os";import{createInterface as o}from"node:readline";import s from"node:readline/promises";import{stdout as i,stdin as r}from"node:process";import{Buffer as a}from"node:buffer";import{createHash as l}from"node:crypto";import{DatabaseSync as c}from"node:sqlite";import{spawnSync as u}from"node:child_process";import{fileURLToPath as d}from"node:url";function h(t){try{const n=JSON.parse(e.readFileSync(t,"utf8")),o=n?.tokens;if(!o||"object"!=typeof o)return{};const s=function(e){if(!e)return{};const t=String(e).split(".");if(t.length<2||!t[1])return{};try{const e=Buffer.from(t[1],"base64url").toString("utf8"),n=JSON.parse(e);return n&&"object"==typeof n?n:{}}catch{return{}}}(o.id_token),i={};return"string"==typeof s.email&&s.email.trim()&&(i.employeeEmail=s.email.trim()),"string"==typeof s.name&&s.name.trim()&&(i.employeeName=s.name.trim()),i}catch{return{}}}function p(e){return Boolean(e.employeeId||e.employeeEmail||e.employeeName)}async function m(e,t){const n=s.createInterface({input:r,output:i});try{const o=t?` [${t}]`:"";return(await n.question(`${e}${o}: `)).trim()||t||null}finally{n.close()}}function g(){return Date.now()/1e3}function f(e){return new Promise(t=>setTimeout(t,e))}function y(e){return JSON.stringify(E(e))}function b(e){return null==e?null:"string"==typeof e?e:"number"==typeof e||"boolean"==typeof e?String(e):y(e)}function S(e){const n=String(e??"").split(t.sep).join("/"),o=t.posix.basename(n),s=/^rollout-(\d{4})-(\d{2})-(\d{2})T.*\.jsonl$/.exec(o);if(!s)return n;const i=`${s[1]}/${s[2]}/${s[3]}/`;return n.startsWith(i)?n:`${i}${o}`}function E(e){return Array.isArray(e)?e.map(E):e&&"object"==typeof e?Object.fromEntries(Object.keys(e).sort().map(t=>[t,E(e[t])])):e}function k(e){if(!e)return null;const t=String(e).replace("Z","+00:00"),n=Date.parse(t);return Number.isNaN(n)?null:n}function _(e,n){if(e){let t=e.replace(/\/+$/,"").split("/").pop()??"";if(t.endsWith(".git")&&(t=t.slice(0,-4)),t)return t}return n&&t.basename(n)||null}function v(e,t,n){const o=a.from(`${e}|${t}|${n}`,"utf8");return l("sha1").update(o).digest("hex")}class T{constructor(e,t,n={}){this.collectorIdentity=e,this.relpath=t,this.currentSession=n.current_session??null,this.currentTurn=n.current_turn??null}exportState(){return{current_session:this.currentSession,current_turn:this.currentTurn}}normalizeSession(e){if(!e?.id)return null;const t=e.git&&"object"==typeof e.git?e.git:{},n=e.cwd??null,o=t.repository_url??null;return{sessionId:String(e.id),sessionTimestamp:k(e.timestamp),cwd:n,originator:e.originator??null,source:e.source??null,cliVersion:e.cli_version??null,modelProvider:e.model_provider??null,repoName:_(o,n),gitBranch:t.branch??null,gitCommitHash:t.commit_hash??null,repositoryUrl:o}}normalizeTurn(e,t){const n=e?.turn_id,o=this.currentSession?.sessionId;if(!n||!o)return null;const s=e.sandbox_policy&&"object"==typeof e.sandbox_policy?e.sandbox_policy:{},i=Array.isArray(s.writable_roots)?s.writable_roots:null,r=e.collaboration_mode&&"object"==typeof e.collaboration_mode?e.collaboration_mode.mode:null;return{turnId:String(n),sessionId:String(o),eventTimestamp:t,cwd:e.cwd??null,currentDate:e.current_date??null,timezone:e.timezone??null,approvalPolicy:b(e.approval_policy),sandboxPolicyType:s.type??null,sandboxNetworkAccess:s.network_access??null,sandboxWritableRootsJson:i?y(i):null,sandboxPolicyJson:Object.keys(s).length?y(s):null,model:e.model??null,personality:e.personality??null,collaborationMode:r??null,effort:e.effort??null,summary:e.summary??null,truncationPolicyJson:null!=e.truncation_policy?y(e.truncation_policy):null}}normalizeTokenEvent(e,t,n){if(!this.currentSession||null==t)return null;const o=e?.info;if(!o||"object"!=typeof o)return null;const s=o.total_token_usage&&"object"==typeof o.total_token_usage?o.total_token_usage:{},i=o.last_token_usage&&"object"==typeof o.last_token_usage?o.last_token_usage:{},r=e.rate_limits&&"object"==typeof e.rate_limits?e.rate_limits:{},a=r.primary&&"object"==typeof r.primary?r.primary:{},l=r.secondary&&"object"==typeof r.secondary?r.secondary:{},c=this.currentSession,u=this.currentTurn??{};return{eventUid:v(this.collectorIdentity.collectorId,this.relpath,n),sessionId:c.sessionId,turnId:u.turnId??null,sourceFileRelpath:this.relpath,lineNo:n,timestamp:t,model:u.model??null,cwd:u.cwd??c.cwd??null,timezone:u.timezone??null,approvalPolicy:u.approvalPolicy??null,sandboxPolicyType:u.sandboxPolicyType??null,source:c.source??null,originator:c.originator??null,cliVersion:c.cliVersion??null,repositoryUrl:c.repositoryUrl??null,repoName:c.repoName??null,gitBranch:c.gitBranch??null,gitCommitHash:c.gitCommitHash??null,totalInputTokens:s.input_tokens??null,totalCachedInputTokens:s.cached_input_tokens??null,totalOutputTokens:s.output_tokens??null,totalReasoningOutputTokens:s.reasoning_output_tokens??null,totalTokens:s.total_tokens??null,lastInputTokens:i.input_tokens??null,lastCachedInputTokens:i.cached_input_tokens??null,lastOutputTokens:i.output_tokens??null,lastReasoningOutputTokens:i.reasoning_output_tokens??null,lastTotalTokens:i.total_tokens??null,modelContextWindow:o.model_context_window??null,rateLimitPlanType:r.plan_type??null,primaryUsedPercent:a.used_percent??null,primaryWindowMinutes:a.window_minutes??null,primaryResetsAt:a.resets_at??null,secondaryUsedPercent:l.used_percent??null,secondaryWindowMinutes:l.window_minutes??null,secondaryResetsAt:l.resets_at??null,credits:P(r.credits),rawRateLimitsJson:Object.keys(r).length?y(r):null}}processLine(e,t){let n;try{n=JSON.parse(t)}catch{return{sessions:[],turns:[],events:[]}}const o=n.type,s=n.payload??{},i=k(n.timestamp),r=[],a=[],l=[];if("session_meta"===o){const e=this.normalizeSession(s);e&&(this.currentSession=e,r.push(e))}else if("turn_context"===o){const e=this.normalizeTurn(s,i);e&&(this.currentTurn=e,a.push(e))}else if("event_msg"===o&&"token_count"===s.type){const t=this.normalizeTokenEvent(s,i,e);t&&l.push(t)}return{sessions:r,turns:a,events:l}}}function P(e){if("number"==typeof e&&Number.isFinite(e))return e;if(e&&"object"==typeof e){const t=e.balance;if("number"==typeof t&&Number.isFinite(t))return t}return null}const w="Codex 用量上报",L="codex-usage-uploader",N="sessions",I="archived_sessions",$=t.join(n.homedir(),".codex","auth.json"),D=t.join(n.homedir(),".codex","sessions"),B=t.join(n.homedir(),".codex-usage-uploader"),A=t.join(B,"config.json"),C=t.join(B,"state.sqlite"),R=t.join(B,"logs"),F=t.join(R,"stdout.log"),O=t.join(R,"stderr.log"),x=t.join(B,"app");t.join(x,"current");const U=t.join(B,"bin");t.join(U,L),t.join(n.homedir(),"bin",L);const j="com.token-dashboard.codex-usage-uploader";t.join(B,"launchd",`${j}.plist`);const M=t.join(n.homedir(),"Library","LaunchAgents",`${j}.plist`),z=t.join(n.homedir(),".codex-usage-uploader-collector-id"),H="http://101.126.66.51:8086",q=1e6;class W{constructor(n){this.dbPath=n,e.mkdirSync(t.dirname(n),{recursive:!0}),this.db=new c(n,{readBigInts:!0}),this.initSchema()}close(){this.db.close()}initSchema(){this.db.exec("\n CREATE TABLE IF NOT EXISTS identity_config (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at REAL NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS pending_batches (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n batch_key TEXT NOT NULL UNIQUE,\n status TEXT NOT NULL,\n payload_json TEXT NOT NULL,\n payload_bytes INTEGER NOT NULL DEFAULT 0,\n session_count INTEGER NOT NULL DEFAULT 0,\n turn_count INTEGER NOT NULL DEFAULT 0,\n event_count INTEGER NOT NULL DEFAULT 0,\n attempt_count INTEGER NOT NULL DEFAULT 0,\n next_retry_at REAL,\n last_error TEXT,\n created_at REAL NOT NULL,\n updated_at REAL NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS upload_checkpoint (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at REAL NOT NULL\n );\n "),this.ensureIngestionFilesTable()}ensureIngestionFilesTable(){if(this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ingestion_files'").get()){if(!this.db.prepare("PRAGMA table_info(ingestion_files)").all().some(e=>"source_root"===e.name)){this.db.exec("BEGIN");try{this.db.exec("ALTER TABLE ingestion_files RENAME TO ingestion_files_legacy"),this.createIngestionFilesTable(),this.db.prepare("\n INSERT INTO ingestion_files(\n source_root, relpath, file_size, file_mtime_ns, last_line_no, state_json, updated_at\n )\n SELECT 'sessions', relpath, file_size, file_mtime_ns, last_line_no, state_json, updated_at\n FROM ingestion_files_legacy\n ").run(),this.db.exec("DROP TABLE ingestion_files_legacy"),this.db.exec("COMMIT")}catch(e){throw this.db.exec("ROLLBACK"),e}}}else this.createIngestionFilesTable()}createIngestionFilesTable(){this.db.exec("\n CREATE TABLE IF NOT EXISTS ingestion_files (\n source_root TEXT NOT NULL,\n relpath TEXT NOT NULL,\n file_size INTEGER NOT NULL,\n file_mtime_ns INTEGER NOT NULL,\n last_line_no INTEGER NOT NULL,\n state_json TEXT,\n updated_at REAL NOT NULL,\n PRIMARY KEY (source_root, relpath)\n );\n ")}getIdentity(){const e=this.db.prepare("SELECT key, value FROM identity_config").all();return Object.fromEntries(e.map(e=>[e.key,e.value]))}setIdentity(e){const t=g(),n=this.db.prepare("\n INSERT INTO identity_config(key, value, updated_at)\n VALUES (?, ?, ?)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at\n "),o=this.db.prepare("DELETE FROM identity_config WHERE key = ?");this.db.exec("BEGIN");try{for(const[s,i]of Object.entries(e))null==i?o.run(s):n.run(s,i,t);this.db.exec("COMMIT")}catch(e){throw this.db.exec("ROLLBACK"),e}}getCheckpoint(e){const t=this.db.prepare("SELECT value FROM upload_checkpoint WHERE key = ?").get(e);return t?.value??null}setCheckpoint(e,t){const n=g();this.db.prepare("\n INSERT INTO upload_checkpoint(key, value, updated_at)\n VALUES (?, ?, ?)\n ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at\n ").run(e,t,n)}getFileState(e,t){const n=this.db.prepare("SELECT * FROM ingestion_files WHERE source_root = ? AND relpath = ?");return n.setReadBigInts(!0),n.get(e,t)??null}upsertFileState(e,t,n,o,s,i){this.db.prepare("\n INSERT INTO ingestion_files(\n source_root, relpath, file_size, file_mtime_ns, last_line_no, state_json, updated_at\n )\n VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(source_root, relpath) DO UPDATE SET\n file_size = excluded.file_size,\n file_mtime_ns = excluded.file_mtime_ns,\n last_line_no = excluded.last_line_no,\n state_json = excluded.state_json,\n updated_at = excluded.updated_at\n ").run(e,t,n,o,s,y(i),g())}getBufferingBatch(){const e=this.db.prepare("\n SELECT * FROM pending_batches\n WHERE status = 'buffering'\n ORDER BY id ASC\n LIMIT 1\n ");return e.setReadBigInts(!0),e.get()??null}saveBufferingPayload(e){const t=this.getBufferingBatch(),n=y(e),o=Buffer.byteLength(n,"utf8"),s=e.sessions?.length??0,i=e.turns?.length??0,r=e.events?.length??0,a=g();return t?this.db.prepare("\n UPDATE pending_batches\n SET payload_json = ?, payload_bytes = ?, session_count = ?, turn_count = ?, event_count = ?, updated_at = ?\n WHERE id = ?\n ").run(n,o,s,i,r,a,t.id):this.db.prepare("\n INSERT INTO pending_batches(\n batch_key, status, payload_json, payload_bytes, session_count, turn_count, event_count,\n attempt_count, created_at, updated_at\n ) VALUES (?, 'buffering', ?, ?, ?, ?, ?, 0, ?, ?)\n ").run(`${Date.now().toString(16)}${Math.random().toString(16).slice(2,14)}`,n,o,s,i,r,a,a),this.getBufferingBatch()}sealStaleBatches(e=!1){const t=this.db.prepare("SELECT id, created_at FROM pending_batches WHERE status = 'buffering'");t.setReadBigInts(!0);const n=t.all();let o=0;const s=g(),i=this.db.prepare("\n UPDATE pending_batches\n SET status = 'pending', updated_at = ?\n WHERE id = ?\n ");for(const t of n)(e||s-Number(t.created_at)>=60)&&(i.run(s,t.id),o+=1);return o}sealBufferIfThresholdHit(){const e=this.getBufferingBatch();if(!e)return!1;const t=Number(e.event_count)>=200||Number(e.payload_bytes)>=q||g()-Number(e.created_at)>=60;if(t){const t=g();this.db.prepare("\n UPDATE pending_batches\n SET status = 'pending', updated_at = ?\n WHERE id = ?\n ").run(t,e.id)}return t}iterPendingBatches(){const e=this.db.prepare("\n SELECT * FROM pending_batches\n WHERE status = 'pending'\n ORDER BY created_at ASC, id ASC\n ");return e.setReadBigInts(!0),e.all()}markBatchUploaded(e){this.db.prepare("DELETE FROM pending_batches WHERE id = ?").run(e)}markBatchFailed(e,t,n){this.db.prepare("\n UPDATE pending_batches\n SET attempt_count = ?, last_error = ?, updated_at = ?\n WHERE id = ?\n ").run(t,String(n).slice(0,1e3),g(),e)}resetBackfillState(){this.db.exec("BEGIN");try{this.db.prepare("DELETE FROM ingestion_files").run(),this.db.prepare("DELETE FROM pending_batches").run(),this.db.prepare("DELETE FROM upload_checkpoint").run(),this.db.exec("COMMIT")}catch(e){throw this.db.exec("ROLLBACK"),e}}getQueueStats(){const e=this.db.prepare("\n SELECT\n COALESCE(SUM(CASE WHEN status = 'buffering' THEN 1 ELSE 0 END), 0) AS buffering_batch_count,\n COALESCE(SUM(CASE WHEN status = 'pending' AND attempt_count = 0 THEN 1 ELSE 0 END), 0) AS pending_batch_count,\n COALESCE(SUM(CASE WHEN status = 'pending' AND attempt_count > 0 THEN 1 ELSE 0 END), 0) AS retrying_batch_count,\n COALESCE(SUM(session_count), 0) AS queued_sessions,\n COALESCE(SUM(turn_count), 0) AS queued_turns,\n COALESCE(SUM(event_count), 0) AS queued_events,\n MIN(created_at) AS oldest_created_at\n FROM pending_batches\n ");e.setReadBigInts(!0);const t=e.get()??{},n=null==t.oldest_created_at?null:Number(t.oldest_created_at);return{bufferingBatchCount:Number(t.buffering_batch_count??0),pendingBatchCount:Number(t.pending_batch_count??0),retryingBatchCount:Number(t.retrying_batch_count??0),queuedSessions:Number(t.queued_sessions??0),queuedTurns:Number(t.queued_turns??0),queuedEvents:Number(t.queued_events??0),oldestPendingAgeSeconds:null==n?null:Math.max(0,Math.floor(g()-n))}}}class Q{constructor({sessionsDir:e,stateDbPath:t,backendUrl:n,intervalSeconds:o,codexAuthPath:s,persistentCollectorIdPath:i=z,scanChunkMaxEvents:r=50,scanChunkMaxBytes:a=262144}){this.sessionsDir=e,this.stateDb=new W(t),this.backendUrl=n?.replace(/\/+$/,"")||null,this.intervalSeconds=o,this.codexAuthPath=s,this.persistentCollectorIdPath=i,this.scanChunkMaxEvents=r,this.scanChunkMaxBytes=a,this.identity=this.ensureIdentity()}close(){this.stateDb.close()}ensureIdentity(){const t=this.stateDb.getIdentity();if(!t.collectorId){const n=function(t){try{return e.readFileSync(t,"utf8").trim()||null}catch{return null}}(this.persistentCollectorIdPath);t.collectorId=n??`${Date.now().toString(16)}${Math.random().toString(16).slice(2,14)}`}!function(t,n){const o=`${t}.${process.pid}.tmp`;e.writeFileSync(o,n,"utf8"),e.renameSync(o,t)}(this.persistentCollectorIdPath,t.collectorId),t.deviceId||(t.deviceId=`${n.hostname()}-${Math.random().toString(16).slice(2,14)}`),t.hostname||(t.hostname=n.hostname());const o=h(this.codexAuthPath);for(const e of["employeeEmail","employeeName"])!t[e]&&o[e]&&(t[e]=o[e]);return this.stateDb.setIdentity(t),this.stateDb.getIdentity()}resolveIdentityValues({employeeId:e,employeeEmail:t,employeeName:n,deviceId:o,hostname:s}={}){const i=this.stateDb.getIdentity(),r=h(this.codexAuthPath);return{employeeId:void 0!==e?e:i.employeeId??null,employeeEmail:void 0!==t?t:i.employeeEmail??r.employeeEmail??null,employeeName:void 0!==n?n:i.employeeName??r.employeeName??null,deviceId:void 0!==o?o:i.deviceId??null,hostname:void 0!==s?s:i.hostname??null}}configureIdentity({employeeId:e=null,employeeEmail:t=null,employeeName:n=null,deviceId:o,hostname:s}={}){const i={employeeId:e,employeeEmail:t,employeeName:n};if(!p(i))throw new Error("At least one of employeeId, employeeEmail, or employeeName must be provided.");return void 0!==o&&(i.deviceId=o),void 0!==s&&(i.hostname=s),this.stateDb.setIdentity(i),this.identity=this.ensureIdentity(),this.identity}configureIdentityWithDefaults(e={}){const t=this.resolveIdentityValues(e);return this.configureIdentity(t)}async configureIdentityInteractive(e={}){const t=this.resolveIdentityValues(e),n=h(this.codexAuthPath);return(n.employeeEmail||n.employeeName)&&console.log("Detected identity defaults from ~/.codex/auth.json. Press Enter to accept."),void 0===e.employeeEmail&&(t.employeeEmail=await m("Employee email",t.employeeEmail)),p(t)||(void 0===e.employeeName&&(t.employeeName=await m("Employee name",t.employeeName)),p(t)||void 0===e.employeeId&&(t.employeeId=await m("Employee ID",t.employeeId))),this.configureIdentity(t)}resetBackfillState(){this.stateDb.resetBackfillState()}getQueueStats(){return this.stateDb.getQueueStats()}getArchivedSessionsDir(){return t.join(t.dirname(this.sessionsDir),I)}getScanRoots({includeArchived:e=!1}={}){const t=[{sourceRoot:N,dir:this.sessionsDir}];return e&&t.push({sourceRoot:I,dir:this.getArchivedSessionsDir()}),t}iterRolloutFiles(n){if(!e.existsSync(n))return[];const o=[],s=n=>{for(const i of e.readdirSync(n,{withFileTypes:!0})){const e=t.join(n,i.name);i.isDirectory()?s(e):i.isFile()&&/^rollout-.*\.jsonl$/.test(i.name)&&o.push(e)}};return s(n),o.sort(),o}buildSnapshot({includeArchived:n=!1}={}){const o=[];for(const s of this.getScanRoots({includeArchived:n}))for(const n of this.iterRolloutFiles(s.dir)){const i=t.relative(s.dir,n).split(t.sep).join("/"),r=S(i),a=e.statSync(n,{bigint:!0}),l=this.stateDb.getFileState(s.sourceRoot,r),c=l||s.sourceRoot!==I||r===i?null:this.stateDb.getFileState(s.sourceRoot,i),u=l||s.sourceRoot!==I?null:this.stateDb.getFileState(N,r)??c;l&&l.file_size===a.size&&l.file_mtime_ns===a.mtimeNs||o.push({filePath:n,relpath:r,actualRelpath:i,sourceRoot:s.sourceRoot,progressPath:`${s.sourceRoot}/${i}`,snapshotSize:a.size,snapshotMtimeNs:a.mtimeNs,fallbackState:u})}return o}buildBackfillSnapshot(){return this.buildSnapshot({includeArchived:!0})}emptyPayload(){return{sessions:[],turns:[],events:[]}}payloadHasData(e){return Boolean(e.sessions.length||e.turns.length||e.events.length)}payloadBytes(e){return Buffer.byteLength(y(e),"utf8")}mergePayload(e,t){const n=new Map((e.sessions??[]).map(e=>[e.sessionId,e])),o=new Map((e.turns??[]).map(e=>[e.turnId,e])),s=new Map((e.events??[]).map(e=>[e.eventUid,e]));for(const e of t.sessions??[])n.set(e.sessionId,e);for(const e of t.turns??[])o.set(e.turnId,e);for(const e of t.events??[])s.set(e.eventUid,e);return{sessions:[...n.values()],turns:[...o.values()],events:[...s.values()]}}shouldFlushChunk(e){return!!this.payloadHasData(e)&&(e.events.length>=this.scanChunkMaxEvents||this.payloadBytes(e)>=this.scanChunkMaxBytes)}wouldExceedUploadThreshold(e){return!!this.payloadHasData(e)&&(e.events.length>200||this.payloadBytes(e)>q)}appendPayloadToBuffer(e){if(!this.payloadHasData(e))return 0;const t=this.stateDb.getBufferingBatch();let n=0,o=e;if(t){const s=JSON.parse(t.payload_json),i=this.mergePayload(s,e);this.wouldExceedUploadThreshold(i)?n+=this.stateDb.sealStaleBatches(!0):o=i}return this.stateDb.saveBufferingPayload(o),n+(this.stateDb.sealBufferIfThresholdHit()?1:0)}async scanSnapshotEntries(t,{onFileProcessed:n}={}){const s={filesScanned:0,sessions:0,turns:0,events:0,batchesQueued:0};for(const i of t){const{filePath:t,relpath:r,sourceRoot:a,snapshotSize:l,snapshotMtimeNs:c,fallbackState:u}=i,d=this.stateDb.getFileState(a,r),h=d??u??null;if(s.filesScanned+=1,!d&&u&&u.file_size===l&&u.file_mtime_ns===c){this.stateDb.upsertFileState(a,r,l,c,Number(u.last_line_no),JSON.parse(u.state_json||"{}")),n?.({entry:i,filesProcessed:s.filesScanned,totals:{filesScanned:s.filesScanned,sessions:s.sessions,turns:s.turns,events:s.events,batchesQueued:s.batchesQueued}});continue}let p={},m=0;h&&l>h.file_size&&(m=Number(h.last_line_no),p=JSON.parse(h.state_json||"{}"));const g=new T(this.identity,r,p);let f=this.emptyPayload(),y=0,b=0;if(Number(l)>0){const n=e.createReadStream(t,{encoding:"utf8",start:0,end:Number(l)-1}),i=o({input:n,crlfDelay:1/0});try{for await(const e of i){if(b+=1,!e)continue;if(y=b,b<=m)continue;const t=g.processLine(b,e);s.sessions+=t.sessions.length,s.turns+=t.turns.length,s.events+=t.events.length,f=this.mergePayload(f,t),this.shouldFlushChunk(f)&&(s.batchesQueued+=this.appendPayloadToBuffer(f),f=this.emptyPayload())}}finally{i.close()}}this.payloadHasData(f)&&(s.batchesQueued+=this.appendPayloadToBuffer(f)),this.stateDb.upsertFileState(a,r,l,c,y,g.exportState()),n?.({entry:i,filesProcessed:s.filesScanned,totals:{filesScanned:s.filesScanned,sessions:s.sessions,turns:s.turns,events:s.events,batchesQueued:s.batchesQueued}})}return s}async scanRollouts(){return this.scanSnapshotEntries(this.buildSnapshot({includeArchived:!0}))}collectorRequestBody(){return{collectorId:this.identity.collectorId,deviceId:this.identity.deviceId,hostname:this.identity.hostname??null,employeeId:this.identity.employeeId??null,employeeEmail:this.identity.employeeEmail??null,employeeName:this.identity.employeeName??null}}sanitizeUploadPayload(e){return{sessions:Array.isArray(e.sessions)?e.sessions:[],turns:Array.isArray(e.turns)?e.turns.map(e=>({...e,approvalPolicy:b(e?.approvalPolicy)})):[],events:Array.isArray(e.events)?e.events.map(e=>({...e,approvalPolicy:b(e?.approvalPolicy),credits:"number"==typeof e?.credits&&Number.isFinite(e.credits)?e.credits:e?.credits&&"object"==typeof e.credits&&"number"==typeof e.credits.balance&&Number.isFinite(e.credits.balance)?e.credits.balance:null})):[]}}async postJson(e,t){if(!this.backendUrl)throw new Error("backendUrl is not configured");const n=await fetch(`${this.backendUrl}${e}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok){const e=await n.text(),t=e?`: ${e.slice(0,500)}`:"";throw new Error(`HTTP ${n.status} ${n.statusText}${t}`)}const o=await n.text();return o?JSON.parse(o):{}}async ensureRemoteRegistration(){if(!this.backendUrl)return;const e=this.stateDb.getCheckpoint("last_register_at");e&&g()-Number(e)<600||(await this.postJson("/codex-usage/collectors/register",this.collectorRequestBody()),this.stateDb.setCheckpoint("last_register_at",String(g())))}async flushPendingBatches({failFast:e=!1,concurrency:t=5,onBatchUploaded:n}={}){if(!this.backendUrl)return{uploadedBatches:0,failedBatches:0,lastError:null};try{await this.ensureRemoteRegistration()}catch(t){if(e)throw new Error(`Collector registration failed: ${t instanceof Error?t.message:String(t)}`);return{uploadedBatches:0,failedBatches:1,lastError:t}}let o=0,s=0,i=null;const r=this.collectorRequestBody(),a=this.stateDb.iterPendingBatches(),l=async e=>{const t=this.sanitizeUploadPayload(JSON.parse(e.payload_json)),n={idempotencyKey:e.batch_key,collector:r,payloadSizeBytes:Number(e.payload_bytes),sessions:t.sessions??[],turns:t.turns??[],events:t.events??[]};await this.postJson("/codex-usage/upload",n)},c=new Set;let u=0;const d=()=>{for(;c.size<t&&u<a.length;){const t=a[u++],r=l(t).then(()=>{this.stateDb.markBatchUploaded(t.id),o+=1,n?.({row:t,uploadedBatches:o})}).catch(n=>{if(this.stateDb.markBatchFailed(t.id,Number(t.attempt_count)+1,n instanceof Error?n.message:String(n)),s+=1,i=n,e)throw n}).finally(()=>{c.delete(r)});c.add(r)}};for(d();c.size>0;){try{await Promise.race(c)}catch{if(e)break}d()}if(e&&i)throw new Error(`Upload batch failed: ${i instanceof Error?i.message:String(i)}`);return{uploadedBatches:o,failedBatches:s,lastError:i}}async runCycle({forceSeal:e=!1}={}){const t=await this.scanRollouts();t.batchesQueued+=this.stateDb.sealStaleBatches(e);const n=await this.flushPendingBatches();t.uploadedBatches=n.uploadedBatches,t.failedBatches=n.failedBatches;const o=this.getQueueStats();return{...t,...o,remainingQueuedBatches:o.bufferingBatchCount+o.pendingBatchCount+o.retryingBatchCount,remainingQueuedEvents:o.queuedEvents}}async runForegroundCatchUp({snapshot:e,onProgress:t}={}){const n=Date.now(),o=e??this.buildBackfillSnapshot(),s=o.length;let i;t?.({phase:"start",totalFiles:s,filesProcessed:0,eventsParsed:0,batchesQueued:0});try{i=await this.scanSnapshotEntries(o,{onFileProcessed:({entry:e,filesProcessed:n,totals:o})=>{t?.({phase:"file",file:e.progressPath??e.relpath,totalFiles:s,filesProcessed:n,eventsParsed:o.events,batchesQueued:o.batchesQueued})}})}catch(e){throw t?.({phase:"error",stage:"scan",message:e instanceof Error?e.message:String(e)}),e}i.batchesQueued+=this.stateDb.sealStaleBatches(!0),await this.ensureRemoteRegistration();const r=this.getQueueStats(),a={totalFiles:s,filesProcessed:s,eventsParsed:i.events,sessionsParsed:i.sessions,turnsParsed:i.turns,batchesQueued:i.batchesQueued,pendingBatches:r.pendingBatchCount+r.retryingBatchCount,pendingEvents:r.queuedEvents,durationMs:Date.now()-n};return t?.({phase:"done",...a}),a}async watch(){let e=!0;for(;;){const t=await this.runCycle({forceSeal:e});console.log(`[uploader] scanned_files=${t.filesScanned} sessions=${t.sessions} turns=${t.turns} events=${t.events} queued_batches=${t.batchesQueued} uploaded_batches=${t.uploadedBatches} remaining_batches=${t.remainingQueuedBatches}`),e=!1,await f(1e3*this.intervalSeconds)}}}const Y=t.basename(B);function J(e){const n=t.basename(e);if(!n.startsWith(`${Y}-`))return"";const o=n.slice(Y.length+1);return o?`-${o}`:""}function G(o=A){const s=function(e=A){const o=t.dirname(e),s=J(o),i=t.join(o,"app"),r=t.join(i,"current"),a=`${j}${s}`;return{configFile:e,installRoot:o,appRoot:i,currentAppDir:r,backendUrl:H,intervalSeconds:30,codexAuthPath:$,sessionsDir:D,stateDbPath:t.join(o,t.basename(C)),nodePath:process.execPath,entryFile:"",packageSpec:"",localBinDir:t.join(o,"bin"),localBinPath:t.join(o,"bin",L),homeBinLink:t.join(n.homedir(),"bin",`${L}${s}`),stdoutLogPath:t.join(o,"logs",t.basename(F)),stderrLogPath:t.join(o,"logs",t.basename(O)),launchdLabel:a,plistPath:t.join(o,"launchd",`${a}.plist`),launchAgentPath:t.join(t.dirname(M),`${a}.plist`),autoStartOnLogin:!0}}(o);try{const t=JSON.parse(e.readFileSync(o,"utf8"));return t&&"object"==typeof t?X({...s,...t,configFile:o}):s}catch{return s}}function X(e){const o=e.installRoot||B,s=J(o),i=e.appRoot||t.join(o,"app"),r=e.currentAppDir||t.join(i,"current"),a=e.localBinDir||t.join(o,"bin"),l=e.launchdLabel||`${j}${s}`,c=t.join(o,"launchd",`${l}.plist`),u=t.join(t.dirname(M),`${l}.plist`),d=t.join(t.dirname(M),`${l}.plist`),h=e.plistPath||c;return{...e,configFile:e.configFile||A,installRoot:o,appRoot:i,currentAppDir:r,backendUrl:e.backendUrl?.trim()?e.backendUrl.replace(/\/+$/,""):null,intervalSeconds:Number(e.intervalSeconds)||30,codexAuthPath:e.codexAuthPath||$,sessionsDir:e.sessionsDir||D,stateDbPath:e.stateDbPath||t.join(o,"state.sqlite"),nodePath:e.nodePath||process.execPath,entryFile:e.entryFile||"",packageSpec:e.packageSpec||"",localBinDir:a,localBinPath:e.localBinPath||t.join(a,L),homeBinLink:e.homeBinLink||t.join(n.homedir(),"bin",`${L}${s}`),stdoutLogPath:e.stdoutLogPath||t.join(o,"logs","stdout.log"),stderrLogPath:e.stderrLogPath||t.join(o,"logs","stderr.log"),launchdLabel:l,plistPath:h===d?c:h,launchAgentPath:e.launchAgentPath||u,autoStartOnLogin:void 0===e.autoStartOnLogin||Boolean(e.autoStartOnLogin)}}function V(n){const o=X(n);return e.mkdirSync(t.dirname(o.configFile),{recursive:!0}),e.mkdirSync(t.dirname(o.stdoutLogPath),{recursive:!0}),e.writeFileSync(o.configFile,`${y({backendUrl:o.backendUrl,intervalSeconds:o.intervalSeconds,codexAuthPath:o.codexAuthPath,sessionsDir:o.sessionsDir,stateDbPath:o.stateDbPath,installRoot:o.installRoot,appRoot:o.appRoot,currentAppDir:o.currentAppDir,nodePath:o.nodePath,entryFile:o.entryFile,packageSpec:o.packageSpec,localBinDir:o.localBinDir,localBinPath:o.localBinPath,homeBinLink:o.homeBinLink,stdoutLogPath:o.stdoutLogPath,stderrLogPath:o.stderrLogPath,launchdLabel:o.launchdLabel,plistPath:o.plistPath,launchAgentPath:o.launchAgentPath,autoStartOnLogin:o.autoStartOnLogin})}\n`),o}function K(e,t={}){return X({...G(e),...t,configFile:e})}function Z(t,n){return{configExists:e.existsSync(t.configFile),loaded:n.loaded,running:n.running,pid:n.pid,state:n.state,lastExitCode:n.lastExitCode,backendUrl:t.backendUrl,intervalSeconds:t.intervalSeconds,configFile:t.configFile,stateDbPath:t.stateDbPath,stdoutLogPath:t.stdoutLogPath,stderrLogPath:t.stderrLogPath,plistPath:t.plistPath,launchAgentPath:t.launchAgentPath,label:t.launchdLabel,autoStartOnLogin:t.autoStartOnLogin,onlineThresholdSeconds:600}}const ee="# Added by codex-usage-uploader",te=[t.join(n.homedir(),".zshrc"),t.join(n.homedir(),".bash_profile")];function ne(n=import.meta.url){let o=t.dirname(d(n));for(;;){const n=t.join(o,"package.json");if(e.existsSync(n))return o;const s=t.dirname(o);if(s===o)throw new Error("package.json not found from current runtime");o=s}}function oe(e){return String(e).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">").replaceAll('"',""").replaceAll("'","'")}class se{constructor(e){this.runtime=e}ensureMacos(){if("darwin"!==process.platform)throw new Error("launchd management is only supported on macOS.")}domainTarget(){return`gui/${process.getuid()}`}serviceTarget(){return`${this.domainTarget()}/${this.runtime.launchdLabel}`}run(e,{check:t=!0}={}){const n=u(e[0],e.slice(1),{encoding:"utf8"});if(t&&0!==n.status)throw new Error(n.stderr?.trim()||n.stdout?.trim()||`command failed: ${e.join(" ")}`);return n}ensurePlist(){if(this.ensureMacos(),!this.runtime.entryFile||!e.existsSync(this.runtime.entryFile))throw new Error(`installed runtime entry not found: ${this.runtime.entryFile||"(empty)"}`);const n=function(e){const t=[e.nodePath,e.entryFile,"--config-file",e.configFile,"run"];return`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n <dict>\n <key>Label</key>\n <string>${oe(e.launchdLabel)}</string>\n <key>ProgramArguments</key>\n <array>\n${t.map(e=>` <string>${oe(e)}</string>`).join("\n")}\n </array>\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>ProcessType</key>\n <string>Background</string>\n <key>WorkingDirectory</key>\n <string>${oe(e.installRoot)}</string>\n <key>StandardOutPath</key>\n <string>${oe(e.stdoutLogPath)}</string>\n <key>StandardErrorPath</key>\n <string>${oe(e.stderrLogPath)}</string>\n <key>EnvironmentVariables</key>\n <dict>\n <key>NODE_NO_WARNINGS</key>\n <string>1</string>\n </dict>\n </dict>\n</plist>\n`}(this.runtime);e.mkdirSync(t.dirname(this.runtime.plistPath),{recursive:!0}),e.mkdirSync(t.dirname(this.runtime.stdoutLogPath),{recursive:!0}),e.writeFileSync(this.runtime.plistPath,n),this.syncLaunchAgent(n)}syncLaunchAgent(n){const o=this.runtime.launchAgentPath;if(o)return this.runtime.autoStartOnLogin?(e.mkdirSync(t.dirname(o),{recursive:!0}),void e.writeFileSync(o,n)):void(e.existsSync(o)&&e.rmSync(o,{force:!0}))}start(){this.ensurePlist(),this.stop(),this.run(["launchctl","bootstrap",this.domainTarget(),this.runtime.plistPath])}stop(){this.ensureMacos(),this.run(["launchctl","bootout",this.serviceTarget()],{check:!1})}restart(){this.start()}status(){this.ensureMacos();const e=this.run(["launchctl","print",this.serviceTarget()],{check:!1});return 0!==e.status?{loaded:!1,running:!1,pid:null,state:null,lastExitCode:null}:function(e){const t=e.match(/^\s*state = ([^\n]+)$/m)?.[1]?.trim()??null,n=e.match(/^\s*pid = (\d+)$/m)?.[1]??null,o=e.match(/^\s*last exit code = (-?\d+)$/m)?.[1]??null;return{loaded:!0,running:"running"===t||null!=n,pid:n?Number(n):null,state:t,lastExitCode:o?Number(o):null}}(e.stdout)}uninstall(){if(this.stop(),e.existsSync(this.runtime.plistPath)&&e.unlinkSync(this.runtime.plistPath),this.runtime.launchAgentPath&&e.existsSync(this.runtime.launchAgentPath)&&e.unlinkSync(this.runtime.launchAgentPath),e.existsSync(this.runtime.homeBinLink)&&e.lstatSync(this.runtime.homeBinLink).isSymbolicLink())try{e.realpathSync.native(this.runtime.homeBinLink)===this.runtime.localBinPath&&e.unlinkSync(this.runtime.homeBinLink)}catch{e.unlinkSync(this.runtime.homeBinLink)}}}const ie=Object.freeze({collectorId:"local-usage"}),re=new Set(["today","7d","30d","all"]),ae=/^\d{4}-\d{2}-\d{2}$/;function le(){return Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"}function ce(){return{inputTokens:0,cachedInputTokens:0,outputTokens:0,reasoningOutputTokens:0,totalTokens:0}}function ue(e){return{model:e.model??"(unknown)",inputTokens:he(e.lastInputTokens),cachedInputTokens:he(e.lastCachedInputTokens),outputTokens:he(e.lastOutputTokens),reasoningOutputTokens:he(e.lastReasoningOutputTokens),totalTokens:he(e.lastTotalTokens)}}function de(e,t){e.inputTokens+=t.inputTokens,e.cachedInputTokens+=t.cachedInputTokens,e.outputTokens+=t.outputTokens,e.reasoningOutputTokens+=t.reasoningOutputTokens,e.totalTokens+=t.totalTokens}function he(e){return"number"==typeof e&&Number.isFinite(e)?e:0}function pe(e,t){const n=new Intl.DateTimeFormat("en-US",{timeZone:t,year:"numeric",month:"2-digit",day:"2-digit"}).formatToParts(new Date(e)),o=Object.fromEntries(n.map(e=>[e.type,e.value]));return`${o.year}-${o.month}-${o.day}`}function me(e,t){if(!ae.test(String(e)))throw new Error(`${t} must be in YYYY-MM-DD format`);const[n,o,s]=String(e).split("-"),i=Number(n),r=Number(o),a=Number(s),l=new Date(Date.UTC(i,r-1,a));if(Number.isNaN(l.getTime())||l.getUTCFullYear()!==i||l.getUTCMonth()!==r-1||l.getUTCDate()!==a)throw new Error(`${t} must be a valid calendar date`);return`${n}-${o}-${s}`}function ge(e,t){const[n,o,s]=e.split("-").map(Number),i=new Date(Date.UTC(n,o-1,s+t));return`${i.getUTCFullYear()}-${String(i.getUTCMonth()+1).padStart(2,"0")}-${String(i.getUTCDate()).padStart(2,"0")}`}function fe(e,t,n){return!(t&&e<t||n&&e>n)}const ye=`${w}\n\nUsage:\n ${L} init [--backend-url <url>] [--interval 30] [--yes] [--package-spec <spec>]\n ${L} bind [--email <email>] [--employee-name <name>] [--employee-id <id>] [--yes]\n ${L} clear [--yes]\n ${L} start\n ${L} stop\n ${L} restart\n ${L} status\n ${L} usage [--period <today|7d|30d|all>] [--from <date>] [--to <date>]\n ${L} logs [--lines 100]\n ${L} uninstall\n\nCommon options:\n --config-file <path> Runtime config path. Default: ${A}\n --backend-url <url> Dashboard backend URL\n --interval <seconds> Scan interval in seconds\n --codex-auth <path> Codex auth.json path\n --sessions-dir <path> Codex rollout root directory\n --state-db <path> Local SQLite state DB path\n --employee-id <id> Bind employee ID\n --email <email> Bind employee email\n --employee-name <name> Bind employee name\n --device-id <id> Override device ID\n --hostname <name> Override hostname\n --period <value> Usage range: today, 7d, 30d, or all\n --from <date> Usage range start date (YYYY-MM-DD)\n --to <date> Usage range end date (YYYY-MM-DD)\n --yes Skip interactive prompts\n --package-spec <spec> npm install spec used by init\n --lines <n> Number of log lines for service logs\n -v, --version Show version\n -h, --help Show help\n`;class be extends Error{constructor(e){super(e),this.name="CliOperationalError",this.showUsage=!1}}const Se={findPackageRoot:ne,installCurrentPackage:function(n,{packageRoot:o,packageSpec:s,nodePath:i=process.execPath}={}){const r=function(e,t){return t||`file:${e}`}(o,s),a=n.appRoot,l=t.join(a,`.staging-${Date.now()}`);e.mkdirSync(l,{recursive:!0}),e.writeFileSync(t.join(l,"package.json"),`${JSON.stringify({private:!0,name:"codex-usage-uploader-runtime"},null,2)}\n`);const c="win32"===process.platform?"npm.cmd":"npm",d=u(c,["install","--no-save","--omit=dev","--no-package-lock",r],{cwd:l,stdio:"inherit",env:{...process.env,npm_config_fund:"false",npm_config_audit:"false"}});if(d.error)throw new Error(`failed to execute ${c}: ${d.error.message}`);if(0!==d.status)throw new Error(`npm install failed for ${r}`);const h=JSON.parse(e.readFileSync(t.join(o,"package.json"),"utf8")),p=t.join(l,"node_modules",...String(h.name).split("/")),m=JSON.parse(e.readFileSync(t.join(p,"package.json"),"utf8")).bin,g="string"==typeof m?m:m?.[L]??Object.values(m??{})[0];if(!g)throw new Error("installed package does not expose the expected CLI binary");const f=n.currentAppDir;return e.rmSync(f,{recursive:!0,force:!0}),e.mkdirSync(t.dirname(f),{recursive:!0}),e.renameSync(l,f),n.nodePath=i,n.packageSpec=r,n.entryFile=t.join(f,"node_modules",...String(h.name).split("/"),g),function(t){e.mkdirSync(t.localBinDir,{recursive:!0});const n=`#!/bin/sh\nset -eu\nCONFIG_FILE="\${CODEX_USAGE_UPLOADER_CONFIG:-${t.configFile}}"\nexec "${t.nodePath}" "${t.entryFile}" --config-file "$CONFIG_FILE" "$@"\n`;e.writeFileSync(t.localBinPath,n,{mode:493}),e.chmodSync(t.localBinPath,493)}(n),function(n){e.mkdirSync(t.dirname(n.homeBinLink),{recursive:!0});try{(e.existsSync(n.homeBinLink)||e.lstatSync(n.homeBinLink).isSymbolicLink())&&e.rmSync(n.homeBinLink,{force:!0})}catch{}e.symlinkSync(n.localBinPath,n.homeBinLink)}(n),V(n),n},ensurePathInShellConfigs:function(t){const n=`export PATH="${t}:$PATH"`;for(const t of te)try{if(!e.existsSync(t))continue;const o=e.readFileSync(t,"utf8");if(o.includes(ee))continue;const s=o.length>0&&!o.endsWith("\n")?"\n":"";e.appendFileSync(t,`${s}\n${ee}\n${n}\n`)}catch{}},removePathFromShellConfigs:function(){for(const t of te)try{if(!e.existsSync(t))continue;const n=e.readFileSync(t,"utf8");if(!n.includes(ee))continue;const o=n.split("\n"),s=[];for(let e=0;e<o.length;e++)o[e]!==ee?s.push(o[e]):(e+1<o.length&&o[e+1].startsWith("export PATH=")&&(e+=1),s.length>0&&""===s[s.length-1]&&s.pop());e.writeFileSync(t,s.join("\n"))}catch{}},promptConfirm:async function(e,t=!1){const n=t?"y":"n",o=await m(`${e} (${t?"Y/n":"y/N"})`,n);return/^(y|yes)$/i.test(String(o??"").trim())},createUploader:e=>new Q({sessionsDir:e.sessionsDir,stateDbPath:e.stateDbPath,backendUrl:e.backendUrl,intervalSeconds:e.intervalSeconds,codexAuthPath:e.codexAuthPath,persistentCollectorIdPath:e.persistentCollectorIdPath}),createServiceManager:e=>new se(e)};function Ee(e){const t={configFile:A,interval:void 0,yes:!1,lines:100},n=[];for(let o=0;o<e.length;o+=1){const s=e[o];if(!s.startsWith("-")){n.push(s);continue}if("-h"===s||"--help"===s){t.help=!0;continue}if("-v"===s||"--version"===s){t.version=!0;continue}if("--yes"===s){t.yes=!0;continue}const[i,r]=s.split("=",2);if(!new Set(["--config-file","--backend-url","--interval","--codex-auth","--sessions-dir","--state-db","--employee-id","--email","--employee-name","--device-id","--hostname","--period","--from","--to","--package-spec","--lines"]).has(i))throw new Error(`Unknown argument: ${s}`);const a=r??e[++o];if(null==a||a.startsWith("-"))throw new Error(`Missing value for ${i}`);ke(t,i,a)}return{command:n[0]??null,subcommand:n[1]??null,extraPositionals:n.slice(2),options:t}}function ke(e,t,n){switch(t){case"--config-file":e.configFile=n;break;case"--backend-url":e.backendUrl=n;break;case"--interval":e.interval=_e(n,"--interval");break;case"--codex-auth":e.codexAuthPath=n;break;case"--sessions-dir":e.sessionsDir=n;break;case"--state-db":e.stateDbPath=n;break;case"--employee-id":e.employeeId=n;break;case"--email":e.employeeEmail=n;break;case"--employee-name":e.employeeName=n;break;case"--device-id":e.deviceId=n;break;case"--hostname":e.hostname=n;break;case"--period":e.period=n;break;case"--from":e.from=n;break;case"--to":e.to=n;break;case"--package-spec":e.packageSpec=n;break;case"--lines":e.lines=_e(n,"--lines");break;default:throw new Error(`Unknown argument: ${t}`)}}function _e(e,t){const n=Number.parseInt(String(e),10);if(!Number.isFinite(n)||n<=0)throw new Error(`${t} must be a positive integer`);return n}function ve(e){const t={};return void 0!==e.backendUrl&&(t.backendUrl=e.backendUrl),void 0!==e.interval&&(t.intervalSeconds=e.interval),void 0!==e.codexAuthPath&&(t.codexAuthPath=e.codexAuthPath),void 0!==e.sessionsDir&&(t.sessionsDir=e.sessionsDir),void 0!==e.stateDbPath&&(t.stateDbPath=e.stateDbPath),t}function Te(e){const t={};return void 0!==e.employeeId&&(t.employeeId=e.employeeId),void 0!==e.employeeEmail&&(t.employeeEmail=e.employeeEmail),void 0!==e.employeeName&&(t.employeeName=e.employeeName),void 0!==e.deviceId&&(t.deviceId=e.deviceId),void 0!==e.hostname&&(t.hostname=e.hostname),t}function Pe(e){if(!p(e.identity))throw new be("No employee identity is bound yet. Run `codex-usage-uploader bind` or rerun `init` with --email.")}function we(e){console.log("Identity binding:"),console.log(` collectorId: ${e.collectorId}`),console.log(` employeeEmail: ${e.employeeEmail??"-"}`),console.log(` employeeName: ${e.employeeName??"-"}`),console.log(` employeeId: ${e.employeeId??"-"}`),console.log(` deviceId: ${e.deviceId??"-"}`),console.log(` hostname: ${e.hostname??"-"}`)}function Le(t,n){return e.existsSync(t)?e.readFileSync(t,"utf8").split(/\r?\n/).filter((e,t,n)=>!(t===n.length-1&&""===e)).slice(-n):[]}async function Ne(e,t){const n=Te(t);if(t.yes){const t=e.configureIdentityWithDefaults(n);return Pe(e),t}const o=await e.configureIdentityInteractive(n);return Pe(e),o}function Ie(e){switch(e.phase){case"start":return void console.log(`[scan] start files=${e.totalFiles}`);case"file":return void console.log(`[scan] file ${e.filesProcessed}/${e.totalFiles} current=${e.file} events=${e.eventsParsed}`);case"done":return void console.log(`[scan] complete files=${e.filesProcessed}/${e.totalFiles} events=${e.eventsParsed} pending_batches=${e.pendingBatches} duration=${t=e.durationMs,`${Math.max(0,Math.round(t/1e3))}s`}`);case"error":return void console.error(`[scan] failed stage=${e.stage} message=${e.message}`)}var t}function $e(e){return Number(e??0).toLocaleString("en-US")}function De(e){const[t,n,o]=e.split("-").map(Number);return new Intl.DateTimeFormat("en-US",{timeZone:"UTC",month:"short",day:"numeric",year:"numeric"}).format(new Date(Date.UTC(t,n-1,o)))}async function Be(n){const s=K(n.configFile,ve(n));let i;console.log("Scanning local Codex usage...");try{i=await async function({sessionsDir:n,period:s,from:i,to:r,now:a=Date.now(),timeZone:l=le(),includeArchived:c=!0}={}){const u=function({period:e,from:t,to:n,now:o=Date.now(),timeZone:s=le()}={}){if(e&&(t||n))throw new Error("--period cannot be combined with --from or --to");if(e&&!re.has(e))throw new Error("--period must be one of: today, 7d, 30d, all");const i=pe(o,s);if("today"===e)return{mode:"today",from:i,to:i,trendFrom:i,trendTo:i};if("7d"===e)return{mode:"7d",from:ge(i,-6),to:i,trendFrom:ge(i,-6),trendTo:i};if("30d"===e)return{mode:"30d",from:ge(i,-29),to:i,trendFrom:ge(i,-29),trendTo:i};if("all"===e||!e&&!t&&!n)return{mode:"all",from:null,to:null,trendFrom:ge(i,-29),trendTo:i};const r=null==t?null:me(t,"--from"),a=null==n?null:me(n,"--to");if(r&&a&&r>a)throw new Error("--from cannot be later than --to");return{mode:"custom",from:r,to:a,trendFrom:r,trendTo:a}}({period:s,from:i,to:r,now:a,timeZone:l}),d=t.join(t.dirname(n),I),h=[{sourceRoot:N,dir:n}];c&&h.push({sourceRoot:I,dir:d});const p=h.flatMap(n=>function(n){if(!e.existsSync(n))return[];const o=[],s=n=>{for(const i of e.readdirSync(n,{withFileTypes:!0})){const e=t.join(n,i.name);i.isDirectory()?s(e):i.isFile()&&/^rollout-.*\.jsonl$/.test(i.name)&&o.push(e)}};return s(n),o}(n.dir).map(e=>({...n,filePath:e,relpath:t.relative(n.dir,e).split(t.sep).join("/")})));p.sort((e,t)=>e.filePath.localeCompare(t.filePath));const m={inputTokens:0,cachedInputTokens:0,outputTokens:0,reasoningOutputTokens:0,totalTokens:0},g=new Map,f=new Map,y=new Map;let b=0;for(const t of p){const n=new T(ie,`${t.sourceRoot}/${t.relpath}`),s=e.createReadStream(t.filePath,{encoding:"utf8"}),i=o({input:s,crlfDelay:1/0});let r=0;try{for await(const e of i){if(r+=1,!e)continue;const t=n.processLine(r,e);for(const e of t.events){const t=pe(e.timestamp,l);if(!fe(t,u.from,u.to))continue;const n=ue(e);b+=1,de(m,n);const o=g.get(t)??{date:t,eventCount:0,models:new Set,...ce()};o.eventCount+=1,o.models.add(n.model),de(o,n),g.set(t,o);const s=y.get(n.model)??{model:n.model,eventCount:0,...ce()};if(s.eventCount+=1,de(s,n),y.set(n.model,s),fe(t,u.trendFrom,u.trendTo)){const e=f.get(t)??{date:t,eventCount:0,...ce()};e.eventCount+=1,de(e,n),f.set(t,e)}}}}finally{i.close()}}const S=[...g.values()].sort((e,t)=>e.date.localeCompare(t.date)).map(e=>({...e,models:[...e.models].sort((e,t)=>e.localeCompare(t))})),E=function(e,t){const n=[...t.values()].sort((e,t)=>e.date.localeCompare(t.date));if(0===n.length)return[];if(!e.trendFrom||!e.trendTo||e.trendFrom>e.trendTo)return n;const o=[];for(let n=e.trendFrom;n<=e.trendTo;n=ge(n,1))o.push(t.get(n)??{date:n,eventCount:0,...ce()});return o}(u,f),k=[...y.values()].sort((e,t)=>t.totalTokens!==e.totalTokens?t.totalTokens-e.totalTokens:e.model.localeCompare(t.model));return{scope:"local-machine",timezone:l,range:u,sources:{sessionsDir:n,archivedSessionsDir:d,includeArchived:c},filesScanned:p.length,tokenEventCount:b,summary:m,days:S,trend:E,byModel:k}}({sessionsDir:s.sessionsDir,period:n.period,from:n.from,to:n.to,timeZone:le()})}catch(e){throw function(e){return new be(`Usage query failed: ${e instanceof Error?e.message:String(e)}`)}(e)}!function(e){if(0===e.tokenEventCount)return void console.log("No local Codex usage found for selected range.");const t=e.days.map(e=>[De(e.date),e.models.join(", "),$e(e.inputTokens),$e(e.outputTokens),$e(e.reasoningOutputTokens),$e(e.cachedInputTokens),$e(e.totalTokens)]);t.push(["Total","",$e(e.summary.inputTokens),$e(e.summary.outputTokens),$e(e.summary.reasoningOutputTokens),$e(e.summary.cachedInputTokens),$e(e.summary.totalTokens)]),console.log(function(e,t,n=[]){const o=e.map((e,n)=>Math.max(e.length,...t.map(e=>String(e[n]??"").length))),s=e=>e.map((e,t)=>((e,t)=>{const s=String(e??"");return"right"===n[t]?s.padStart(o[t]):s.padEnd(o[t])})(e,t)).join(" ").trimEnd();return[s(e),...t.map(e=>s(e))].join("\n")}(["Date","Models","Input","Output","Reasoning","Cache Read","Total Tokens"],t,["left","left","right","right","right","right","right"]))}(i)}function Ae(t){if(!e.existsSync(t))return{collectorId:null,bufferingBatchCount:0,pendingBatchCount:0,retryingBatchCount:0,queuedSessions:0,queuedTurns:0,queuedEvents:0,oldestPendingAgeSeconds:null};const n=new W(t);try{return{collectorId:n.getIdentity().collectorId??null,...n.getQueueStats()}}finally{n.close()}}function Ce(t){if(!e.existsSync(t.configFile)||!t.entryFile)throw new be(`Uploader is not initialized yet. Run \`${L} init\` first.`)}async function Re(n=process.argv.slice(2)){try{const{command:o,subcommand:s,extraPositionals:i,options:r}=Ee(n);if(r.version)return console.log(function(n=import.meta.url){const o=ne(n);return JSON.parse(e.readFileSync(t.join(o,"package.json"),"utf8")).version??"unknown"}()),0;if(!o||r.help)return console.log(ye),0;if(!new Set(["init","bind","clear","start","stop","restart","status","usage","logs","uninstall","run"]).has(o))throw new Error(`Unknown command: ${o}`);switch(function(e,t,n){if(t)throw new Error(`Unexpected subcommand for ${e}: ${t}`);if(n.length>0)throw new Error(`Unexpected extra arguments: ${n.join(" ")}`)}(o,s,i),o){case"init":return await async function(e){const n=ve(e);n.backendUrl||(n.backendUrl=H);let o=K(e.configFile,n);if(!o.backendUrl)throw new Error("--backend-url is required for init unless already configured in config.json");o=Se.installCurrentPackage(o,{packageRoot:Se.findPackageRoot(),packageSpec:e.packageSpec,nodePath:process.execPath});let s=Se.createServiceManager(o);s.stop();const i=Se.createUploader(o);try{let n,r;n=!Object.values(Te(e)).some(e=>void 0!==e)&&p(i.identity)?i.identity:await Ne(i,e),console.log(`${w} install is ready.`),console.log(`Backend: ${o.backendUrl}`),console.log(`Install root: ${o.installRoot}`),console.log(`Config file: ${o.configFile}`),we(n),console.log("Scanning local Codex sessions...");try{r=await i.runForegroundCatchUp({onProgress:Ie})}catch(e){throw function(e){return new be(`Foreground catch-up failed: ${e instanceof Error?e.message:String(e)}`)}(e)}o.autoStartOnLogin=await async function(e,t){return t.yes?e.autoStartOnLogin:Se.promptConfirm("Start automatically when you log in on this Mac?",e.autoStartOnLogin)}(o,e),o=V(o),s=Se.createServiceManager(o),s.start(),console.log(`${w} initialized and started.`),console.log(o.autoStartOnLogin?"Login auto-start is enabled.":"Login auto-start is disabled for future logins."),r.pendingBatches>0&&console.log(`Background service is uploading ${r.pendingBatches} batch(es) with ${r.pendingEvents} event(s).`),console.log(`Use \`${L} status\` to check the local service.`);const a=t.dirname(o.homeBinLink);(process.env.PATH||"").split(t.delimiter).includes(a)||(Se.ensurePathInShellConfigs(a),console.log(`Open a new terminal to use \`${L}\` directly.`))}finally{i.close()}}(r),0;case"bind":return await async function(t){const n=K(t.configFile,ve(t)),o=Se.createUploader(n);try{we(await Ne(o,t)),await async function(t){if("darwin"!==process.platform)return!1;if(!e.existsSync(t.configFile)||!t.entryFile)return!1;const n=Se.createServiceManager(t);let o;try{o=n.status()}catch{return!1}return!!o.running&&(n.restart(),!0)}(n)&&console.log("Background service restarted to apply the updated identity.")}finally{o.close()}}(r),0;case"clear":return await async function(e){const t=K(e.configFile,ve(e));if(Ce(t),Se.createServiceManager(t).status().running)throw new be(`Background service is still running. Stop it first with \`${L} stop\`.`);if(!e.yes&&!await Se.promptConfirm("Clear local backfill state and queued uploads?",!1))return void console.log("Clear cancelled.");const n=Se.createUploader(t);try{n.resetBackfillState()}finally{n.close()}console.log("Local backfill state has been cleared."),console.log(`Run \`${L} init\` when you want to backfill history again.`)}(r),0;case"usage":return await Be(r),0;case"run":return await async function(e){const t=K(e.configFile,ve(e)),n=Se.createUploader(t);try{Pe(n),console.log(`[run] backend=${t.backendUrl??"-"} interval=${t.intervalSeconds}s sessions_dir=${t.sessionsDir}`),await n.watch()}finally{n.close()}}(r),0;case"start":case"stop":case"restart":case"status":case"logs":case"uninstall":return function(t,n){const o=K(n.configFile,ve(n)),s=Se.createServiceManager(o);switch(t){case"start":return Ce(o),s.start(),void console.log(`Service started: ${o.launchdLabel}`);case"stop":return s.stop(),void console.log(`Service stopped: ${o.launchdLabel}`);case"restart":return Ce(o),s.restart(),void console.log(`Service restarted: ${o.launchdLabel}`);case"status":return void function(e,t){const n={...Z(e,t),...Ae(e.stateDbPath)},o=[["Config exists",n.configExists?"yes":"no"],["Loaded",n.loaded?"yes":"no"],["Running",n.running?"yes":"no"],["Auto start on login",n.autoStartOnLogin?"yes":"no"],["PID",n.pid??"-"],["State",n.state??"-"],["Last exit code",n.lastExitCode??"-"],["Collector ID",n.collectorId??"-"],["Upload URL",n.backendUrl?`${n.backendUrl}/codex-usage/upload`:"-"],["Register URL",n.backendUrl?`${n.backendUrl}/codex-usage/collectors/register`:"-"],["Interval",`${n.intervalSeconds}s`],["Buffering batches",n.bufferingBatchCount],["Pending batches",n.pendingBatchCount],["Retrying batches",n.retryingBatchCount],["Queued sessions",n.queuedSessions],["Queued turns",n.queuedTurns],["Queued events",n.queuedEvents],["Oldest pending age",null==n.oldestPendingAgeSeconds?"-":`${n.oldestPendingAgeSeconds}s`],["Config file",n.configFile],["State DB",n.stateDbPath],["Stdout log",n.stdoutLogPath],["Stderr log",n.stderrLogPath],["Service plist",n.plistPath],["LaunchAgent plist",n.launchAgentPath],["Label",n.label]];for(const[e,t]of o)console.log(`${e}: ${t}`)}(o,s.status());case"logs":{const e=Le(o.stdoutLogPath,n.lines),t=Le(o.stderrLogPath,n.lines);return console.log(`== stdout (${o.stdoutLogPath}) ==`),console.log(e.length?e.join("\n"):"(empty)"),console.log(`== stderr (${o.stderrLogPath}) ==`),void console.log(t.length?t.join("\n"):"(empty)")}case"uninstall":return s.uninstall(),Se.removePathFromShellConfigs(),e.rmSync(o.installRoot,{recursive:!0,force:!0}),void console.log(`${w} uninstalled from ${o.installRoot}`);default:throw new Error(`Unknown lifecycle command: ${t??"(empty)"}`)}}(o,r),0}}catch(e){return console.error(e instanceof Error?e.message:String(e)),!1!==e?.showUsage&&console.error(`Run \`${L} --help\` for usage.`),1}}export{Se as cliDeps,Re as main,Ee as parseCliArgs};
|
package/package.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@token-dashboard/codex-usage-uploader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Codex 用量上报 CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"codex-usage-uploader": "
|
|
7
|
+
"codex-usage-uploader": "dist/bin/codex-usage-uploader.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
10
|
+
"dist/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"CHANGELOG.zh-CN.md"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
+
"build": "node scripts/build.mjs",
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"publish:npm": "npm publish --access public --registry https://registry.npmjs.org/",
|
|
15
18
|
"init": "node ./bin/codex-usage-uploader.js --config-file $HOME/.codex-usage-uploader-dev/config.json init --backend-url http://localhost:8086",
|
|
16
19
|
"start": "node bin/codex-usage-uploader.js --config-file $HOME/.codex-usage-uploader-dev/config.json start",
|
|
17
20
|
"stop": "node bin/codex-usage-uploader.js --config-file $HOME/.codex-usage-uploader-dev/config.json stop",
|
|
@@ -24,5 +27,9 @@
|
|
|
24
27
|
},
|
|
25
28
|
"engines": {
|
|
26
29
|
"node": ">=22.13.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@rollup/plugin-terser": "^1.0.0",
|
|
33
|
+
"rollup": "^4.60.2"
|
|
27
34
|
}
|
|
28
35
|
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
if (!process.env.NODE_NO_WARNINGS) {
|
|
3
|
-
process.env.NODE_NO_WARNINGS = '1';
|
|
4
|
-
}
|
|
5
|
-
process.emitWarning = () => {};
|
|
6
|
-
|
|
7
|
-
const { main } = await import('../src/cli.js');
|
|
8
|
-
const exitCode = await main(process.argv.slice(2));
|
|
9
|
-
process.exit(exitCode);
|
package/src/auth.js
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import readline from 'node:readline/promises';
|
|
3
|
-
import { stdin as input, stdout as output } from 'node:process';
|
|
4
|
-
|
|
5
|
-
export function decodeJwtClaims(token) {
|
|
6
|
-
if (!token) return {};
|
|
7
|
-
const parts = String(token).split('.');
|
|
8
|
-
if (parts.length < 2 || !parts[1]) return {};
|
|
9
|
-
try {
|
|
10
|
-
const decoded = Buffer.from(parts[1], 'base64url').toString('utf8');
|
|
11
|
-
const data = JSON.parse(decoded);
|
|
12
|
-
return data && typeof data === 'object' ? data : {};
|
|
13
|
-
} catch {
|
|
14
|
-
return {};
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function loadCodexAuthIdentity(authPath) {
|
|
19
|
-
try {
|
|
20
|
-
const payload = JSON.parse(fs.readFileSync(authPath, 'utf8'));
|
|
21
|
-
const tokens = payload?.tokens;
|
|
22
|
-
if (!tokens || typeof tokens !== 'object') return {};
|
|
23
|
-
const claims = decodeJwtClaims(tokens.id_token);
|
|
24
|
-
const identity = {};
|
|
25
|
-
if (typeof claims.email === 'string' && claims.email.trim()) {
|
|
26
|
-
identity.employeeEmail = claims.email.trim();
|
|
27
|
-
}
|
|
28
|
-
if (typeof claims.name === 'string' && claims.name.trim()) {
|
|
29
|
-
identity.employeeName = claims.name.trim();
|
|
30
|
-
}
|
|
31
|
-
return identity;
|
|
32
|
-
} catch {
|
|
33
|
-
return {};
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function identityIsBound(values) {
|
|
38
|
-
return Boolean(values.employeeId || values.employeeEmail || values.employeeName);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export async function promptValue(label, defaultValue) {
|
|
42
|
-
const rl = readline.createInterface({ input, output });
|
|
43
|
-
try {
|
|
44
|
-
const suffix = defaultValue ? ` [${defaultValue}]` : '';
|
|
45
|
-
const answer = (await rl.question(`${label}${suffix}: `)).trim();
|
|
46
|
-
return answer || defaultValue || null;
|
|
47
|
-
} finally {
|
|
48
|
-
rl.close();
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function promptConfirm(label, defaultYes = false) {
|
|
53
|
-
const defaultValue = defaultYes ? 'y' : 'n';
|
|
54
|
-
const answer = await promptValue(`${label} (${defaultYes ? 'Y/n' : 'y/N'})`, defaultValue);
|
|
55
|
-
return /^(y|yes)$/i.test(String(answer ?? '').trim());
|
|
56
|
-
}
|