@subvalue/cli 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/PRE_RELEASE.md +6 -6
  2. package/README.md +5 -3
  3. package/dist/PRICING.md +15 -16
  4. package/dist/packages/cli/src/index.js +109 -20
  5. package/dist/packages/cli/src/protocol.js +13 -0
  6. package/dist/packages/cli/src/runtime.js +44 -14
  7. package/dist/packages/cli/src/server.js +306 -62
  8. package/dist/packages/core/src/calendar.js +41 -18
  9. package/dist/packages/core/src/coverage.js +12 -0
  10. package/dist/packages/core/src/demo.js +61 -9
  11. package/dist/packages/core/src/metadata.js +52 -9
  12. package/dist/packages/core/src/pricing/current.js +346 -297
  13. package/dist/packages/core/src/pricing/index.js +373 -73
  14. package/dist/packages/core/src/providers/claude/index.js +93 -21
  15. package/dist/packages/core/src/providers/codex/index.js +213 -46
  16. package/dist/packages/core/src/scanner.js +226 -46
  17. package/dist/packages/core/src/security.js +127 -34
  18. package/dist/packages/core/src/storage.js +215 -47
  19. package/dist/packages/core/src/subscriptions.js +146 -21
  20. package/dist/packages/core/src/summary.js +408 -59
  21. package/dist/packages/core/src/types.js +71 -17
  22. package/dist/packages/core/src/usage-json.js +166 -47
  23. package/dist/packages/receipt/src/index.js +386 -82
  24. package/dist/packages/ui/src/brand.js +42 -27
  25. package/dist/public/app.css +2280 -61
  26. package/dist/public/apps/web/src/landing.js +200 -8
  27. package/dist/public/apps/web/src/main.js +1143 -98
  28. package/dist/public/index.html +24 -1
  29. package/dist/public/landing.css +3235 -80
  30. package/dist/public/landing.html +21 -1
  31. package/dist/public/packages/core/src/calendar.js +41 -18
  32. package/dist/public/packages/core/src/coverage.js +12 -0
  33. package/dist/public/packages/core/src/subscriptions.js +146 -21
  34. package/dist/public/packages/receipt/src/index.js +386 -82
  35. package/dist/public/packages/ui/src/brand.js +42 -27
  36. package/dist/public/privacy.css +197 -9
  37. package/package.json +1 -1
@@ -1,76 +1,320 @@
1
1
  import http from 'node:http';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import {randomBytes,timingSafeEqual} from 'node:crypto';
5
-
6
- import {scan, } from '../../core/src/scanner.js';
7
- import {dateRange,summarize} from '../../core/src/summary.js';
8
- import {demoData} from '../../core/src/demo.js';
9
-
4
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
5
+
6
+ import { scan, } from '../../core/src/scanner.js';
7
+ import { dateRange, summarize } from '../../core/src/summary.js';
8
+ import { demoData } from '../../core/src/demo.js';
9
+ import {
10
+ defaultSettings,
11
+
12
+
13
+
14
+ } from '../../core/src/types.js';
10
15
 
11
16
 
12
- import {safeDirectory} from '../../core/src/security.js';
13
- import {subscriptionPlan} from '../../core/src/subscriptions.js';
14
- const mime ={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.svg':'image/svg+xml','.woff2':'font/woff2','.txt':'text/plain; charset=utf-8'};
15
- export function validateSettings(value ) {
16
- if(!value||value.currency!=='USD'||typeof value.onboarded!=='boolean')throw new Error('Invalid settings');
17
- const s ={onboarded:value.onboarded,currency:'USD',billing:{} ,include:{} };
18
- if(value.receiptTheme!==undefined){if(!['dark','light'].includes(value.receiptTheme))throw new Error('Invalid receipt theme');s.receiptTheme=value.receiptTheme;}
19
- for(const provider of ['codex','claude'] ){const b=value.billing?.[provider];if(!b||!['UNKNOWN','SUBSCRIPTION','API','MIXED'].includes(b.mode)||typeof value.include?.[provider]!=='boolean')throw new Error('Invalid billing settings');if(b.monthly!==null&&(typeof b.monthly!=='number'||!Number.isFinite(b.monthly)||b.monthly<0||b.monthly>1000000))throw new Error('Invalid subscription amount');
20
- const plan=b.planId==null?undefined:subscriptionPlan(provider,b.planId);if(b.planId!=null&&!plan)throw new Error('Invalid subscription plan');
21
- if(b.mode==='SUBSCRIPTION'&&plan&&(b.monthly===null||Math.abs(b.monthly-plan.monthly)>.005))throw new Error('Subscription amount does not match the selected plan');
22
- s.billing[provider]={mode:b.mode,monthly:b.mode==='SUBSCRIPTION'?(plan?.monthly??b.monthly):null};if(b.mode==='SUBSCRIPTION'&&b.planId!==undefined)s.billing[provider].planId=plan?.id??null;s.include[provider]=value.include[provider];}return s;
17
+ import { isRecord } from '../../core/src/metadata.js';
18
+ import { safeDirectory } from '../../core/src/security.js';
19
+ import { subscriptionPlan } from '../../core/src/subscriptions.js';
20
+ const mime = {
21
+ '.html': 'text/html; charset=utf-8',
22
+ '.js': 'text/javascript; charset=utf-8',
23
+ '.css': 'text/css; charset=utf-8',
24
+ '.svg': 'image/svg+xml',
25
+ '.woff2': 'font/woff2',
26
+ '.txt': 'text/plain; charset=utf-8',
27
+ };
28
+ const billingModes = ['UNKNOWN', 'SUBSCRIPTION', 'API', 'MIXED'] ;
29
+ const isBillingMode = (value ) =>
30
+ billingModes.some((mode) => mode === value);
31
+
32
+ export function validateSettings(value ) {
33
+ if (!isRecord(value) || value.currency !== 'USD' || typeof value.onboarded !== 'boolean')
34
+ throw new Error('Invalid settings');
35
+ if (!isRecord(value.billing) || !isRecord(value.include))
36
+ throw new Error('Invalid billing settings');
37
+ const settings = defaultSettings();
38
+ settings.onboarded = value.onboarded;
39
+ if (value.receiptTheme !== undefined) {
40
+ if (value.receiptTheme !== 'dark' && value.receiptTheme !== 'light')
41
+ throw new Error('Invalid receipt theme');
42
+ settings.receiptTheme = value.receiptTheme;
43
+ }
44
+ for (const provider of ['codex', 'claude'] ) {
45
+ const billing = value.billing[provider];
46
+ const included = value.include[provider];
47
+ if (!isRecord(billing) || !isBillingMode(billing.mode) || typeof included !== 'boolean')
48
+ throw new Error('Invalid billing settings');
49
+ const monthly = billing.monthly;
50
+ if (
51
+ monthly !== null &&
52
+ (typeof monthly !== 'number' || !Number.isFinite(monthly) || monthly < 0 || monthly > 1000000)
53
+ )
54
+ throw new Error('Invalid subscription amount');
55
+ const planId = billing.planId;
56
+ if (planId != null && typeof planId !== 'string') throw new Error('Invalid subscription plan');
57
+ const plan = planId == null ? undefined : subscriptionPlan(provider, planId);
58
+ if (planId != null && !plan) throw new Error('Invalid subscription plan');
59
+ if (
60
+ billing.mode === 'SUBSCRIPTION' &&
61
+ plan &&
62
+ (monthly === null || Math.abs(monthly - plan.monthly) > 0.005)
63
+ )
64
+ throw new Error('Subscription amount does not match the selected plan');
65
+ settings.billing[provider] = {
66
+ mode: billing.mode,
67
+ monthly: billing.mode === 'SUBSCRIPTION' ? (plan?.monthly ?? monthly) : null,
68
+ };
69
+ if (billing.mode === 'SUBSCRIPTION' && planId !== undefined)
70
+ settings.billing[provider].planId = plan?.id ?? null;
71
+ settings.include[provider] = included;
72
+ }
73
+ return settings;
23
74
  }
24
- export async function startServer(options ){
25
- const secret=randomBytes(32).toString('hex');const demo=options.demo?demoData():null;let demoSettings=demo?.settings;let scanning=false;let progress =null;let sessionOrigin='';
26
- let revision='',history =null;const summaries=new Map ();
27
- const invalidate=()=>{revision='';history=null;summaries.clear();};
28
- const server=http.createServer(async(req,res)=>{
29
- const headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer','Cross-Origin-Resource-Policy':'same-origin','X-Frame-Options':'DENY','Content-Security-Policy':"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"};
30
- const json=(status ,data )=>{res.writeHead(status,{...headers,'Content-Type':'application/json'});res.end(JSON.stringify(data));};
31
- const expectedHost=new URL(sessionOrigin).host;
32
- if(req.headers.host!==expectedHost){json(403,{error:'Host denied'});return;}
33
- if(req.headers.origin&&req.headers.origin!==sessionOrigin){json(403,{error:'Origin denied'});return;}
34
- if(req.headers['sec-fetch-site']==='cross-site'){json(403,{error:'Cross-site access denied'});return;}
35
- const url=new URL(req.url??'/',sessionOrigin);
36
- try{
37
- if(url.pathname.startsWith('/api/')){
75
+ export async function startServer(options
76
+
77
+
78
+
79
+
80
+
81
+ ) {
82
+ const secret = randomBytes(32).toString('hex');
83
+ const demo = options.demo ? demoData() : null;
84
+ let demoSettings = demo?.settings;
85
+ let scanning = false;
86
+ let progress = null;
87
+ let sessionOrigin = '';
88
+ let scanCompletion = null;
89
+ let revision = '',
90
+ history = null;
91
+ const summaries = new Map ();
92
+ const invalidate = () => {
93
+ revision = '';
94
+ history = null;
95
+ summaries.clear();
96
+ };
97
+ const server = http.createServer(async (req, res) => {
98
+ const headers = {
99
+ 'Cache-Control': 'no-store',
100
+ 'X-Content-Type-Options': 'nosniff',
101
+ 'Referrer-Policy': 'no-referrer',
102
+ 'Cross-Origin-Resource-Policy': 'same-origin',
103
+ 'X-Frame-Options': 'DENY',
104
+ 'Content-Security-Policy':
105
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
106
+ };
107
+ const json = (status , data ) => {
108
+ res.writeHead(status, { ...headers, 'Content-Type': 'application/json' });
109
+ res.end(JSON.stringify(data));
110
+ };
111
+ const expectedHost = new URL(sessionOrigin).host;
112
+ if (req.headers.host !== expectedHost) {
113
+ json(403, { error: 'Host denied' });
114
+ return;
115
+ }
116
+ if (req.headers.origin && req.headers.origin !== sessionOrigin) {
117
+ json(403, { error: 'Origin denied' });
118
+ return;
119
+ }
120
+ if (req.headers['sec-fetch-site'] === 'cross-site') {
121
+ json(403, { error: 'Cross-site access denied' });
122
+ return;
123
+ }
124
+ const url = new URL(req.url ?? '/', sessionOrigin);
125
+ try {
126
+ if (url.pathname.startsWith('/api/')) {
38
127
  // The bootstrap is same-origin only. Mutations require its per-launch token.
39
- if(url.pathname==='/api/bootstrap'&&req.method==='GET'){json(200,{token:secret,settings:demoSettings??options.store .settings(),sources:demo?.statuses??options.store .statuses(),demo:!!demo,lastScan:options.store?.get('lastScan')??null,scanning,progress});return;}
40
- if(url.pathname==='/api/summary'&&req.method==='GET'){
41
- if(scanning){json(503,{error:'Scan in progress'});return;}
42
- const range=dateRange(url.searchParams.get('period')??'30d',new Date(),url.searchParams.get('from')??undefined,url.searchParams.get('to')??undefined,url.searchParams.get('month')??undefined,url.searchParams.get('anchor')??undefined);
128
+ if (url.pathname === '/api/bootstrap' && req.method === 'GET') {
129
+ json(200, {
130
+ token: secret,
131
+ settings: demoSettings ?? options.store .settings(),
132
+ sources: demo?.statuses ?? options.store .statuses(),
133
+ demo: !!demo,
134
+ lastScan: options.store?.get ('lastScan') ?? null,
135
+ scanning,
136
+ progress,
137
+ } );
138
+ return;
139
+ }
140
+ if (url.pathname === '/api/summary' && req.method === 'GET') {
141
+ if (scanning) {
142
+ json(503, { error: 'Scan in progress' });
143
+ return;
144
+ }
145
+ const range = dateRange(
146
+ url.searchParams.get('period') ?? '30d',
147
+ new Date(),
148
+ url.searchParams.get('from') ?? undefined,
149
+ url.searchParams.get('to') ?? undefined,
150
+ url.searchParams.get('month') ?? undefined,
151
+ url.searchParams.get('anchor') ?? undefined,
152
+ );
43
153
  // History bounds use all normalized metadata, never original transcripts.
44
- const currentRevision=demo?'demo':options.store .revision();if(currentRevision!==revision){invalidate();revision=currentRevision;}
45
- const key=JSON.stringify(range);let result=summaries.get(key);
46
- if(!result){history??=demo?.records??options.store .records();result=summarize(history,demo?.statuses??options.store .statuses(),demoSettings??options.store .settings(),range,demo?.catalog,!!demo);if(summaries.size>=16)summaries.delete(summaries.keys().next().value );summaries.set(key,result);}
47
- json(200,result);return;
154
+ const currentRevision = demo ? 'demo' : options.store .revision();
155
+ if (currentRevision !== revision) {
156
+ invalidate();
157
+ revision = currentRevision;
158
+ }
159
+ const key = JSON.stringify(range);
160
+ let result = summaries.get(key);
161
+ if (!result) {
162
+ history ??= demo?.records ?? options.store .records();
163
+ result = summarize(
164
+ history,
165
+ demo?.statuses ?? options.store .statuses(),
166
+ demoSettings ?? options.store .settings(),
167
+ range,
168
+ demo?.catalog,
169
+ !!demo,
170
+ );
171
+ if (summaries.size >= 16) summaries.delete(summaries.keys().next().value );
172
+ summaries.set(key, result);
173
+ }
174
+ json(200, result);
175
+ return;
176
+ }
177
+ const given = req.headers['x-subvalue-token'];
178
+ if (
179
+ typeof given !== 'string' ||
180
+ given.length !== secret.length ||
181
+ !timingSafeEqual(Buffer.from(given), Buffer.from(secret))
182
+ ) {
183
+ json(403, { error: 'Session token required' });
184
+ return;
48
185
  }
49
- const given=req.headers['x-subvalue-token'];if(typeof given!=='string'||given.length!==secret.length||!timingSafeEqual(Buffer.from(given),Buffer.from(secret))){json(403,{error:'Session token required'});return;}
50
- if(url.pathname==='/api/settings'&&req.method==='POST'){
51
- let body='';for await(const chunk of req){body+=chunk;if(body.length>16384){json(413,{error:'Request too large'});return;}}
52
- const settings=validateSettings(JSON.parse(body));if(demo)demoSettings=settings;else options.store .saveSettings(settings);invalidate();json(200,{saved:true});return;
186
+ if (url.pathname === '/api/settings' && req.method === 'POST') {
187
+ let body = '';
188
+ for await (const chunk of req) {
189
+ body += chunk;
190
+ if (body.length > 16384) {
191
+ json(413, { error: 'Request too large' });
192
+ return;
193
+ }
194
+ }
195
+ const settings = validateSettings(JSON.parse(body));
196
+ // A rescan holds a transaction on this connection while streaming files.
197
+ // Save independently after it ends, so a later rollback cannot erase
198
+ // settings that the browser has already been told were persisted.
199
+ while (scanCompletion) await scanCompletion;
200
+ if (demo) demoSettings = settings;
201
+ else options.store .saveSettings(settings);
202
+ invalidate();
203
+ json(200, { saved: true });
204
+ return;
53
205
  }
54
- if(url.pathname==='/api/rescan'&&req.method==='POST'){
55
- if(demo){json(200,{scanning:false});return;}if(scanning){json(409,{error:'Scan already running'});return;}scanning=true;json(202,{scanning:true});
56
- void scan(options.store ,{home:options.home,onProgress:p=>{progress=p;}}).catch(()=>{progress=null;}).finally(()=>{invalidate();scanning=false;});return;
206
+ if (url.pathname === '/api/rescan' && req.method === 'POST') {
207
+ if (demo) {
208
+ json(200, { scanning: false });
209
+ return;
210
+ }
211
+ if (scanning) {
212
+ json(409, { error: 'Scan already running' });
213
+ return;
214
+ }
215
+ scanning = true;
216
+ json(202, { scanning: true });
217
+ scanCompletion = scan(options.store , {
218
+ home: options.home,
219
+ onProgress: (p) => {
220
+ progress = p;
221
+ },
222
+ })
223
+ .then(() => {})
224
+ .catch(() => {
225
+ progress = null;
226
+ })
227
+ .finally(() => {
228
+ invalidate();
229
+ scanning = false;
230
+ scanCompletion = null;
231
+ });
232
+ return;
57
233
  }
58
- json(404,{error:'Not found'});return;
234
+ json(404, { error: 'Not found' });
235
+ return;
236
+ }
237
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
238
+ json(405, { error: 'Method not allowed' });
239
+ return;
240
+ }
241
+ const name =
242
+ url.pathname === '/' || ['/overview', '/receipt', '/settings'].includes(url.pathname)
243
+ ? 'index.html'
244
+ : url.pathname === '/landing'
245
+ ? 'landing.html'
246
+ : url.pathname.slice(1);
247
+ if (!/^[a-zA-Z0-9_./-]+$/.test(name) || name.includes('..') || !mime[path.extname(name)]) {
248
+ json(404, { error: 'Not found' });
249
+ return;
250
+ }
251
+ const base = path.resolve(options.publicDirectory),
252
+ target = path.resolve(base, name);
253
+ if (
254
+ !target.startsWith(base + path.sep) ||
255
+ !safeDirectory(path.dirname(target)) ||
256
+ !fs.existsSync(target)
257
+ ) {
258
+ json(404, { error: 'Not found' });
259
+ return;
260
+ }
261
+ const before = fs.lstatSync(target);
262
+ if (
263
+ !before.isFile() ||
264
+ before.isSymbolicLink() ||
265
+ !fs.realpathSync(target).startsWith(fs.realpathSync(base) + path.sep)
266
+ ) {
267
+ json(404, { error: 'Not found' });
268
+ return;
269
+ }
270
+ if (req.method === 'HEAD') {
271
+ res.writeHead(200, { ...headers, 'Content-Type': mime[path.extname(target)] });
272
+ res.end();
273
+ return;
274
+ }
275
+ const fd = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)),
276
+ after = fs.fstatSync(fd);
277
+ if (before.ino !== after.ino || before.dev !== after.dev) {
278
+ fs.closeSync(fd);
279
+ json(404, { error: 'Not found' });
280
+ return;
59
281
  }
60
- if(req.method!=='GET'&&req.method!=='HEAD'){json(405,{error:'Method not allowed'});return;}
61
- const name=url.pathname==='/'||['/overview','/receipt','/settings'].includes(url.pathname)?'index.html':url.pathname==='/landing'?'landing.html':url.pathname.slice(1);
62
- if(!/^[a-zA-Z0-9_./-]+$/.test(name)||name.includes('..')||!mime[path.extname(name)]){json(404,{error:'Not found'});return;}
63
- const base=path.resolve(options.publicDirectory),target=path.resolve(base,name);
64
- if(!target.startsWith(base+path.sep)||!safeDirectory(path.dirname(target))||!fs.existsSync(target)){json(404,{error:'Not found'});return;}
65
- const before=fs.lstatSync(target);if(!before.isFile()||before.isSymbolicLink()||!fs.realpathSync(target).startsWith(fs.realpathSync(base)+path.sep)){json(404,{error:'Not found'});return;}
66
- if(req.method==='HEAD'){res.writeHead(200,{...headers,'Content-Type':mime[path.extname(target)]});res.end();return;}
67
- const fd=fs.openSync(target,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW??0)),after=fs.fstatSync(fd);
68
- if(before.ino!==after.ino||before.dev!==after.dev){fs.closeSync(fd);json(404,{error:'Not found'});return;}
69
- res.writeHead(200,{...headers,'Content-Type':mime[path.extname(target)]});
70
- const stream=fs.createReadStream(target,{fd,autoClose:true});stream.on('error',()=>res.destroy());res.once('close',()=>stream.destroy());stream.pipe(res);
71
- }catch{json(400,{error:'Unable to complete request'});}
282
+ res.writeHead(200, { ...headers, 'Content-Type': mime[path.extname(target)] });
283
+ const stream = fs.createReadStream(target, { fd, autoClose: true });
284
+ stream.on('error', () => res.destroy());
285
+ res.once('close', () => stream.destroy());
286
+ stream.pipe(res);
287
+ } catch {
288
+ json(400, { error: 'Unable to complete request' });
289
+ }
72
290
  });
73
- const listen=(port )=>new Promise ((resolve,reject)=>{const error=(e )=>reject(e);server.once('error',error);server.listen(port,'127.0.0.1',()=>{server.removeListener('error',error);const address=server.address();if(!address||typeof address==='string')return reject(new Error('No listening address'));sessionOrigin=`http://127.0.0.1:${address.port}`;resolve();});});
74
- try{await listen(options.port);}catch(e){if(options.port===0||!['EADDRINUSE','EACCES'].includes((e ).code??''))throw e;await listen(0);}
75
- return {server,url:sessionOrigin,close:()=>new Promise ((resolve,reject)=>server.close(e=>e?reject(e):resolve()))};
291
+ const listen = (port ) =>
292
+ new Promise ((resolve, reject) => {
293
+ const error = (e ) => reject(e);
294
+ server.once('error', error);
295
+ server.listen(port, '127.0.0.1', () => {
296
+ server.removeListener('error', error);
297
+ const address = server.address();
298
+ if (!address || typeof address === 'string')
299
+ return reject(new Error('No listening address'));
300
+ sessionOrigin = `http://127.0.0.1:${address.port}`;
301
+ resolve();
302
+ });
303
+ });
304
+ try {
305
+ await listen(options.port);
306
+ } catch (e) {
307
+ if (
308
+ options.port === 0 ||
309
+ !['EADDRINUSE', 'EACCES'].includes((e ).code ?? '')
310
+ )
311
+ throw e;
312
+ await listen(0);
313
+ }
314
+ return {
315
+ server,
316
+ url: sessionOrigin,
317
+ close: () =>
318
+ new Promise ((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))),
319
+ };
76
320
  }
@@ -1,24 +1,47 @@
1
1
  /** Count whole local calendar months; rolling presets are never subscription comparisons. */
2
- export function fullCalendarMonths(range ) {
3
- if(range.preset==='7d'||range.preset==='30d')return null;
2
+ export function fullCalendarMonths(range
3
+
4
+
5
+
6
+
7
+ ) {
8
+ if (range.preset === '7d' || range.preset === '30d') return null;
4
9
  // Browser timezone overrides must not change the server's calendar selection.
5
- if(range.calendarMonths!==undefined)return Number.isInteger(range.calendarMonths)&&range.calendarMonths >0&&range.calendarMonths <=1200?range.calendarMonths:null;
6
- const start=new Date(range.from),end=new Date(range.until);
7
- const boundary=(date )=>Number.isFinite(+date)&&date.getDate()===1&&date.getHours()===0&&date.getMinutes()===0&&date.getSeconds()===0&&date.getMilliseconds()===0;
8
- if(!boundary(start)||!boundary(end))return null;
9
- const months=(end.getFullYear()-start.getFullYear())*12+end.getMonth()-start.getMonth();
10
- return months>0&&months<=1200?months:null;
10
+ if (range.calendarMonths !== undefined)
11
+ return Number.isInteger(range.calendarMonths) &&
12
+ range.calendarMonths > 0 &&
13
+ range.calendarMonths <= 1200
14
+ ? range.calendarMonths
15
+ : null;
16
+ const start = new Date(range.from),
17
+ end = new Date(range.until);
18
+ const boundary = (date ) =>
19
+ Number.isFinite(+date) &&
20
+ date.getDate() === 1 &&
21
+ date.getHours() === 0 &&
22
+ date.getMinutes() === 0 &&
23
+ date.getSeconds() === 0 &&
24
+ date.getMilliseconds() === 0;
25
+ if (!boundary(start) || !boundary(end)) return null;
26
+ const months = (end.getFullYear() - start.getFullYear()) * 12 + end.getMonth() - start.getMonth();
27
+ return months > 0 && months <= 1200 ? months : null;
11
28
  }
12
29
  /** Civil-date movement uses UTC arithmetic so DST never drops or repeats a day. */
13
- export function shiftCalendarDays(day ,days ) {
14
- if(!/^\d{4}-\d{2}-\d{2}$/.test(day)||!Number.isInteger(days)||Math.abs(days)>36600)return null;
15
- const date=new Date(day+'T12:00:00.000Z');
16
- if(!Number.isFinite(+date)||date.toISOString().slice(0,10)!==day)return null;
17
- date.setUTCDate(date.getUTCDate()+days);
18
- const result=date.toISOString().slice(0,10);return /^\d{4}-\d{2}-\d{2}$/.test(result)?result:null;
30
+ export function shiftCalendarDays(day , days ) {
31
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isInteger(days) || Math.abs(days) > 36600)
32
+ return null;
33
+ const date = new Date(day + 'T12:00:00.000Z');
34
+ if (!Number.isFinite(+date) || date.toISOString().slice(0, 10) !== day) return null;
35
+ date.setUTCDate(date.getUTCDate() + days);
36
+ const result = date.toISOString().slice(0, 10);
37
+ return /^\d{4}-\d{2}-\d{2}$/.test(result) ? result : null;
19
38
  }
20
- export function shiftCalendarMonth(month ,months ) {
21
- if(!/^(?:19|20|21)\d{2}-(?:0[1-9]|1[0-2])$/.test(month)||!Number.isInteger(months))return null;
22
- const ordinal=Number(month.slice(0,4))*12+Number(month.slice(5))-1+months,year=Math.floor(ordinal/12);
23
- return year>=1900&&year<=2199?`${year}-${String(ordinal%12+1).padStart(2,'0')}`:null;
39
+ export function shiftCalendarMonth(month , months ) {
40
+ if (!/^(?:19|20|21)\d{2}-(?:0[1-9]|1[0-2])$/.test(month) || !Number.isInteger(months))
41
+ return null;
42
+ const ordinal = Number(month.slice(0, 4)) * 12 + Number(month.slice(5)) - 1 + months,
43
+ year = Math.floor(ordinal / 12);
44
+ return year >= 1900 && year <= 2199
45
+ ? `${year}-${String((ordinal % 12) + 1).padStart(2, '0')}`
46
+ : null;
24
47
  }
@@ -0,0 +1,12 @@
1
+ /** Reserve 100% for fully priced usage; missing coverage never becomes zero. */
2
+ export function formatPricingCoverage(coverage ) {
3
+ if (coverage === null || !Number.isFinite(coverage) || coverage < 0 || coverage > 1) {
4
+ return 'Unknown';
5
+ }
6
+ if (coverage === 1) return '100%';
7
+ if (coverage === 0) return '0%';
8
+ const percent = Math.round(coverage * 100);
9
+ if (percent === 100) return '<100%';
10
+ if (percent === 0) return '<1%';
11
+ return `${percent}%`;
12
+ }
@@ -1,10 +1,62 @@
1
- import {baseRecord} from './metadata.js';
2
- import {catalog, } from './pricing/index.js';
3
- import {defaultSettings,emptyDiagnostics, } from './types.js';
4
- export function demoData(now=new Date()) {
5
- const records =[];const monthDays=now.getDate();const weights=Array.from({length:monthDays},(_,i)=>[4,6,5,8,7,10,6,8,11,9][i%10]);const sum=weights.reduce((a,b)=>a+b,0);let assigned=0;
6
- for(let i=0;i<monthDays;i++){const r=baseRecord('codex','fixture-only',`fixture-${i}`);const output=i===monthDays-1?20530000-assigned:Math.floor(20530000*weights[i]/sum);assigned+=output;r.timestamp=new Date(now.getFullYear(),now.getMonth(),i+1,12).toISOString();r.model_raw='gpt-5.3-codex';r.session_id='fixture-session';r.input_tokens=0;r.cached_input_tokens=0;r.cache_creation_tokens=0;r.output_tokens=output;r.reasoning_tokens=0;r.total_tokens=output;r.source_schema='DEMO/fixture';r.notes=['demo-fixture'];records.push(r);}
7
- const settings=defaultSettings();settings.onboarded=true;settings.billing.codex={mode:'SUBSCRIPTION',monthly:200};
8
- const statuses=(['codex','claude'] ).map(provider=>({provider,detected:true,files:provider==='codex'?1:0,scanned:0,cached:0,errors:0,skippedLinks:0,missingFiles:0,diagnostics:emptyDiagnostics()}));
9
- const demoCatalog ={...catalog,version:'DEMO — fixture prices',prices:catalog.prices.map(p=>({...p,effective_from:'2000-01-01T00:00:00.000Z'}))};return {records,settings,statuses,catalog:demoCatalog};
1
+ import { baseRecord } from './metadata.js';
2
+ import { catalog, } from './pricing/index.js';
3
+ import {
4
+ defaultSettings,
5
+ emptyDiagnostics,
6
+
7
+
8
+
9
+ } from './types.js';
10
+ export function demoData(now = new Date())
11
+
12
+
13
+
14
+
15
+ {
16
+ const records = [];
17
+ const monthDays = now.getDate();
18
+ const weights = Array.from(
19
+ { length: monthDays },
20
+ (_, i) => [4, 6, 5, 8, 7, 10, 6, 8, 11, 9][i % 10],
21
+ );
22
+ const sum = weights.reduce((a, b) => a + b, 0);
23
+ let assigned = 0;
24
+ for (let i = 0; i < monthDays; i++) {
25
+ const r = baseRecord('codex', 'fixture-only', `fixture-${i}`);
26
+ const output =
27
+ i === monthDays - 1 ? 20530000 - assigned : Math.floor((20530000 * weights[i]) / sum);
28
+ assigned += output;
29
+ r.timestamp = new Date(now.getFullYear(), now.getMonth(), i + 1, 12).toISOString();
30
+ r.model_raw = 'gpt-5.3-codex';
31
+ r.session_id = 'fixture-session';
32
+ r.input_tokens = 0;
33
+ r.cached_input_tokens = 0;
34
+ r.cache_creation_tokens = 0;
35
+ r.output_tokens = output;
36
+ r.reasoning_tokens = 0;
37
+ r.total_tokens = output;
38
+ r.source_schema = 'DEMO/fixture';
39
+ r.notes = ['demo-fixture'];
40
+ records.push(r);
41
+ }
42
+ const settings = defaultSettings();
43
+ settings.onboarded = true;
44
+ settings.billing.codex = { mode: 'SUBSCRIPTION', monthly: 200 };
45
+ const statuses = (['codex', 'claude'] ).map((provider) => ({
46
+ provider,
47
+ detected: true,
48
+ files: provider === 'codex' ? 1 : 0,
49
+ scanned: 0,
50
+ cached: 0,
51
+ errors: 0,
52
+ skippedLinks: 0,
53
+ missingFiles: 0,
54
+ diagnostics: emptyDiagnostics(),
55
+ }));
56
+ const demoCatalog = {
57
+ ...catalog,
58
+ version: 'DEMO — fixture prices',
59
+ prices: catalog.prices.map((p) => ({ ...p, effective_from: '2000-01-01T00:00:00.000Z' })),
60
+ };
61
+ return { records, settings, statuses, catalog: demoCatalog };
10
62
  }
@@ -1,14 +1,57 @@
1
- import {createHash} from 'node:crypto';
2
-
3
- export const hash = (...parts ) => createHash('sha256').update(JSON.stringify(parts)).digest('hex');
4
- export const token = (v ) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : null;
5
- export const identifier = (v ) => typeof v === 'string' && /^[a-zA-Z0-9._:@/-]{1,160}$/.test(v) ? v : null;
6
- export const modelId = (v ) => typeof v === 'string' && /^[a-zA-Z0-9._:-]{1,100}$/.test(v) ? v : null;
7
- export const timestamp = (v ) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v) && Number.isFinite(Date.parse(v)) ? new Date(v).toISOString() : null;
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const isObject = (value ) =>
4
+ value !== null && typeof value === 'object';
5
+ export const isRecord = (value ) =>
6
+ isObject(value) && !Array.isArray(value);
7
+ export const hash = (...parts ) =>
8
+ createHash('sha256').update(JSON.stringify(parts)).digest('hex');
9
+ export const token = (v ) =>
10
+ typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : null;
11
+ export const identifier = (v ) =>
12
+ typeof v === 'string' && /^[a-zA-Z0-9._:@/-]{1,160}$/.test(v) ? v : null;
13
+ export const modelId = (v ) =>
14
+ typeof v === 'string' && /^[a-zA-Z0-9._:-]{1,100}$/.test(v) ? v : null;
15
+ export const timestamp = (v ) =>
16
+ typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v) && Number.isFinite(Date.parse(v))
17
+ ? new Date(v).toISOString()
18
+ : null;
8
19
  export function baseRecord(provider , ref , event ) {
9
- return {id:hash(provider,event),provider,source_event_id:event,source_file_reference:ref,timestamp:null,session_id:null,request_id:null,thread_id:null,model_raw:null,model_canonical:null,billing_mode:'UNKNOWN',input_tokens:null,cached_input_tokens:null,cache_creation_tokens:null,cache_creation_5m_tokens:null,cache_creation_1h_tokens:null,output_tokens:null,reasoning_tokens:null,unallocated_total_tokens:null,total_tokens:null,project_identifier_hash:null,quality:'HIGH',source_schema:'unknown',notes:[],service_tier:null,speed:null,web_search_requests:null,web_fetch_requests:null};
20
+ return {
21
+ id: hash(provider, event),
22
+ provider,
23
+ source_event_id: event,
24
+ source_file_reference: ref,
25
+ timestamp: null,
26
+ session_id: null,
27
+ request_id: null,
28
+ thread_id: null,
29
+ model_raw: null,
30
+ model_canonical: null,
31
+ billing_mode: 'UNKNOWN',
32
+ input_tokens: null,
33
+ cached_input_tokens: null,
34
+ cache_creation_tokens: null,
35
+ cache_creation_5m_tokens: null,
36
+ cache_creation_1h_tokens: null,
37
+ output_tokens: null,
38
+ reasoning_tokens: null,
39
+ unallocated_total_tokens: null,
40
+ total_tokens: null,
41
+ project_identifier_hash: null,
42
+ quality: 'HIGH',
43
+ source_schema: 'unknown',
44
+ notes: [],
45
+ service_tier: null,
46
+ speed: null,
47
+ web_search_requests: null,
48
+ web_fetch_requests: null,
49
+ };
10
50
  }
11
51
  export function track(d , r ) {
12
52
  d.records++;
13
- if(r.timestamp){d.first = !d.first || r.timestamp < d.first ? r.timestamp : d.first;d.last = !d.last || r.timestamp > d.last ? r.timestamp : d.last;}
53
+ if (r.timestamp) {
54
+ d.first = !d.first || r.timestamp < d.first ? r.timestamp : d.first;
55
+ d.last = !d.last || r.timestamp > d.last ? r.timestamp : d.last;
56
+ }
14
57
  }