taximeter 0.1.0
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.md +9 -0
- package/CODE_OF_CONDUCT.md +31 -0
- package/CONTRIBUTING.md +141 -0
- package/DECISIONS.md +54 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/SECURITY.md +69 -0
- package/SPEC-NOTES.md +369 -0
- package/SPEC.md +474 -0
- package/VERIFICATION.md +943 -0
- package/dist/chunk-I7YXGTRZ.js +1167 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +494 -0
- package/dist/index.d.ts +404 -0
- package/dist/index.js +182 -0
- package/dist/ui/assets/index-DOE5bD9P.js +101 -0
- package/dist/ui/assets/index-De4zKRDL.css +1 -0
- package/dist/ui/index.html +15 -0
- package/docs/RECORDING.md +75 -0
- package/docs/SDK.md +192 -0
- package/docs/demo.gif +0 -0
- package/docs/demo.mjs +145 -0
- package/docs/demo.tape +15 -0
- package/package.json +79 -0
- package/taximeter.config.example.json +16 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
Ledger,
|
|
4
|
+
budgetSchema,
|
|
5
|
+
closeProxy,
|
|
6
|
+
countsAsSpend,
|
|
7
|
+
createProxy,
|
|
8
|
+
diagnosticSchema,
|
|
9
|
+
expandPath,
|
|
10
|
+
formatAmount,
|
|
11
|
+
httpUrlSchema,
|
|
12
|
+
integerStringSchema,
|
|
13
|
+
labelSchema,
|
|
14
|
+
loadConfig,
|
|
15
|
+
matchesAsset,
|
|
16
|
+
paymentEventSchema,
|
|
17
|
+
portInputSchema,
|
|
18
|
+
spentForBudget,
|
|
19
|
+
toCsv,
|
|
20
|
+
toInvoice,
|
|
21
|
+
toJson,
|
|
22
|
+
totalSchema,
|
|
23
|
+
totals
|
|
24
|
+
} from "../chunk-I7YXGTRZ.js";
|
|
25
|
+
|
|
26
|
+
// src/cli/index.ts
|
|
27
|
+
import { CommanderError } from "commander";
|
|
28
|
+
|
|
29
|
+
// src/cli/program.ts
|
|
30
|
+
import { existsSync as existsSync3, renameSync, writeFileSync as writeFileSync2 } from "fs";
|
|
31
|
+
import { resolve as resolve2 } from "path";
|
|
32
|
+
import { Command } from "commander";
|
|
33
|
+
import { z as z4 } from "zod";
|
|
34
|
+
|
|
35
|
+
// src/server/lifecycle.ts
|
|
36
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
|
|
37
|
+
import { z as z3 } from "zod";
|
|
38
|
+
|
|
39
|
+
// src/server/index.ts
|
|
40
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
41
|
+
import { createServer } from "http";
|
|
42
|
+
import { dirname, extname, resolve, sep } from "path";
|
|
43
|
+
import { fileURLToPath } from "url";
|
|
44
|
+
import { z as z2 } from "zod";
|
|
45
|
+
|
|
46
|
+
// src/server/schema.ts
|
|
47
|
+
import { z } from "zod";
|
|
48
|
+
var dashboardEventSchema = paymentEventSchema.omit({ raw: true });
|
|
49
|
+
var groupSchema = totalSchema.extend({ key: z.string().nullable() });
|
|
50
|
+
var dashboardStateSchema = z.object({
|
|
51
|
+
version: z.literal("0.1.0"),
|
|
52
|
+
generatedAt: z.iso.datetime(),
|
|
53
|
+
totalEvents: z.number().int(),
|
|
54
|
+
blockedEvents: z.number().int(),
|
|
55
|
+
unknownEvents: z.number().int(),
|
|
56
|
+
totals: z.array(totalSchema),
|
|
57
|
+
events: z.array(dashboardEventSchema),
|
|
58
|
+
diagnostics: z.array(diagnosticSchema),
|
|
59
|
+
groups: z.object({
|
|
60
|
+
task: z.array(groupSchema),
|
|
61
|
+
agent: z.array(groupSchema),
|
|
62
|
+
host: z.array(groupSchema)
|
|
63
|
+
}),
|
|
64
|
+
hours: z.array(z.object({ ts: z.iso.datetime(), totals: z.array(totalSchema) })),
|
|
65
|
+
globalBudget: budgetSchema.nullable(),
|
|
66
|
+
budgets: z.array(
|
|
67
|
+
z.object({
|
|
68
|
+
network: z.string(),
|
|
69
|
+
asset: z.string(),
|
|
70
|
+
limit: integerStringSchema,
|
|
71
|
+
spent: integerStringSchema,
|
|
72
|
+
remaining: integerStringSchema,
|
|
73
|
+
window: z.string().nullable()
|
|
74
|
+
})
|
|
75
|
+
),
|
|
76
|
+
proxy: z.object({ port: z.number().int(), upstream: z.string().optional() })
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// src/server/state.ts
|
|
80
|
+
function groups(events, field) {
|
|
81
|
+
const groups2 = /* @__PURE__ */ new Map();
|
|
82
|
+
for (const event of events) {
|
|
83
|
+
const key = event[field] ?? null;
|
|
84
|
+
const members = groups2.get(key) ?? [];
|
|
85
|
+
members.push(event);
|
|
86
|
+
groups2.set(key, members);
|
|
87
|
+
}
|
|
88
|
+
return [...groups2].flatMap(
|
|
89
|
+
([key, members]) => totals(members).map((total) => ({ key, ...total }))
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
function dashboardState(ledger, config, now = Date.now()) {
|
|
93
|
+
const events = ledger.view();
|
|
94
|
+
const total = totals(events);
|
|
95
|
+
const budget = config.budgets.global;
|
|
96
|
+
const budgets = budget ? total.filter(
|
|
97
|
+
(total2) => matchesAsset(budget.asset, total2) && (!budget.network || budget.network === total2.network)
|
|
98
|
+
).map((total2) => {
|
|
99
|
+
const representative = events.find(
|
|
100
|
+
(event) => event.network === total2.network && event.asset.toLowerCase() === total2.asset
|
|
101
|
+
);
|
|
102
|
+
const spent = representative ? spentForBudget(representative, events, budget, "global", now) : "0";
|
|
103
|
+
const remaining = BigInt(budget.amount) - BigInt(spent);
|
|
104
|
+
return {
|
|
105
|
+
network: total2.network,
|
|
106
|
+
asset: total2.asset,
|
|
107
|
+
limit: budget.amount,
|
|
108
|
+
spent,
|
|
109
|
+
remaining: (remaining < 0n ? 0n : remaining).toString(),
|
|
110
|
+
window: budget.window ?? null
|
|
111
|
+
};
|
|
112
|
+
}) : [];
|
|
113
|
+
const hour = new Date(now);
|
|
114
|
+
hour.setUTCMinutes(0, 0, 0);
|
|
115
|
+
const hours = Array.from({ length: 24 }, (_, index) => {
|
|
116
|
+
const start = hour.getTime() - (23 - index) * 36e5;
|
|
117
|
+
const rows = events.filter((event) => {
|
|
118
|
+
const ts = Date.parse(event.attemptedAt ?? event.ts);
|
|
119
|
+
return ts >= start && ts < start + 36e5 && ts <= now;
|
|
120
|
+
});
|
|
121
|
+
return { ts: new Date(start).toISOString(), totals: totals(rows) };
|
|
122
|
+
});
|
|
123
|
+
return dashboardStateSchema.parse({
|
|
124
|
+
version: "0.1.0",
|
|
125
|
+
generatedAt: new Date(now).toISOString(),
|
|
126
|
+
totalEvents: events.length,
|
|
127
|
+
blockedEvents: events.filter((event) => event.status === "blocked").length,
|
|
128
|
+
unknownEvents: events.filter(
|
|
129
|
+
(event) => countsAsSpend(event) && event.settlementStatus === "unknown"
|
|
130
|
+
).length,
|
|
131
|
+
totals: total,
|
|
132
|
+
events: [...events].reverse().slice(0, 30).map(({ raw: _raw, ...event }) => event),
|
|
133
|
+
diagnostics: ledger.diagnostics().slice(-10).reverse(),
|
|
134
|
+
groups: {
|
|
135
|
+
task: groups(events, "taskId"),
|
|
136
|
+
agent: groups(events, "agentId"),
|
|
137
|
+
host: groups(events, "host")
|
|
138
|
+
},
|
|
139
|
+
hours,
|
|
140
|
+
globalBudget: budget,
|
|
141
|
+
budgets,
|
|
142
|
+
proxy: { port: config.ports.proxy, upstream: config.upstream }
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/server/index.ts
|
|
147
|
+
var querySchema = z2.strictObject({ format: z2.enum(["csv", "json", "invoice"]).optional() });
|
|
148
|
+
var types = {
|
|
149
|
+
".html": "text/html; charset=utf-8",
|
|
150
|
+
".js": "text/javascript; charset=utf-8",
|
|
151
|
+
".css": "text/css; charset=utf-8",
|
|
152
|
+
".svg": "image/svg+xml",
|
|
153
|
+
".woff2": "font/woff2"
|
|
154
|
+
};
|
|
155
|
+
function uiDirectory() {
|
|
156
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
157
|
+
return [resolve(here, "ui"), resolve(here, "../ui"), resolve(here, "../../dist/ui")].find(
|
|
158
|
+
(path) => existsSync(resolve(path, "index.html"))
|
|
159
|
+
) ?? resolve(here, "ui");
|
|
160
|
+
}
|
|
161
|
+
function json(response, status, value) {
|
|
162
|
+
response.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
163
|
+
response.end(JSON.stringify(value));
|
|
164
|
+
}
|
|
165
|
+
function createDashboard(options2) {
|
|
166
|
+
const root = resolve(options2.uiRoot ?? uiDirectory());
|
|
167
|
+
return createServer((request, response) => {
|
|
168
|
+
response.setHeader(
|
|
169
|
+
"Content-Security-Policy",
|
|
170
|
+
"default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"
|
|
171
|
+
);
|
|
172
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
173
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
174
|
+
try {
|
|
175
|
+
const input = z2.object({
|
|
176
|
+
method: z2.string(),
|
|
177
|
+
url: z2.string(),
|
|
178
|
+
headers: z2.object({
|
|
179
|
+
host: z2.string(),
|
|
180
|
+
origin: z2.string().optional(),
|
|
181
|
+
"sec-fetch-site": z2.string().optional()
|
|
182
|
+
})
|
|
183
|
+
}).parse(request);
|
|
184
|
+
const localPort = request.socket.localPort;
|
|
185
|
+
const allowed = [`127.0.0.1:${localPort}`, `localhost:${localPort}`];
|
|
186
|
+
if (!allowed.includes(input.headers.host.toLowerCase()) || input.headers.origin && !allowed.map((host) => `http://${host}`).includes(input.headers.origin) || input.headers["sec-fetch-site"] === "cross-site") {
|
|
187
|
+
json(response, 403, { error: "local_origin_required" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!["GET", "HEAD"].includes(input.method)) {
|
|
191
|
+
response.setHeader("Allow", "GET, HEAD");
|
|
192
|
+
json(response, 405, { error: "read_only" });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const url = new URL(input.url, `http://${input.headers.host}`);
|
|
196
|
+
if (url.origin !== `http://${input.headers.host}`) {
|
|
197
|
+
json(response, 400, { error: "invalid_request" });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const entries = [...url.searchParams];
|
|
201
|
+
if (new Set(entries.map(([key]) => key)).size !== entries.length) {
|
|
202
|
+
json(response, 400, { error: "invalid_request" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const query = querySchema.parse(Object.fromEntries(entries));
|
|
206
|
+
if (url.pathname === "/api/summary") {
|
|
207
|
+
json(response, 200, dashboardState(options2.ledger, options2.config));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (url.pathname === "/api/export") {
|
|
211
|
+
const format = query.format ?? "json";
|
|
212
|
+
const events = options2.ledger.view();
|
|
213
|
+
const body = format === "csv" ? toCsv(events) : format === "invoice" ? toInvoice(events) : toJson(events);
|
|
214
|
+
response.writeHead(200, {
|
|
215
|
+
"Content-Type": format === "csv" ? "text/csv; charset=utf-8" : format === "invoice" ? "text/html; charset=utf-8" : "application/json",
|
|
216
|
+
"Cache-Control": "no-store",
|
|
217
|
+
"Content-Disposition": `attachment; filename="taximeter.${format === "invoice" ? "html" : format}"`
|
|
218
|
+
});
|
|
219
|
+
response.end(body);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const decoded = decodeURIComponent(url.pathname);
|
|
223
|
+
const path = resolve(root, `.${decoded === "/" ? "/index.html" : decoded}`);
|
|
224
|
+
if (!path.startsWith(`${root}${sep}`)) {
|
|
225
|
+
json(response, 403, { error: "forbidden" });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!existsSync(path) || !statSync(path).isFile() || !types[extname(path)]) {
|
|
229
|
+
if (url.pathname === "/" && !existsSync(resolve(root, "index.html"))) {
|
|
230
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
231
|
+
response.end(
|
|
232
|
+
"<!doctype html><title>Taximeter</title><h1>Taximeter</h1><p>The local ledger is ready. Contributors: run npm run build to build the dashboard.</p>"
|
|
233
|
+
);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
json(response, 404, { error: "not_found" });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
response.writeHead(200, {
|
|
240
|
+
"Content-Type": types[extname(path)],
|
|
241
|
+
"Cache-Control": path.endsWith("index.html") ? "no-cache" : "public, max-age=31536000, immutable"
|
|
242
|
+
});
|
|
243
|
+
response.end(readFileSync(path));
|
|
244
|
+
} catch {
|
|
245
|
+
json(response, 400, { error: "invalid_request" });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/server/lifecycle.ts
|
|
251
|
+
async function listenLocal(server, port2) {
|
|
252
|
+
await new Promise((resolve3, reject) => {
|
|
253
|
+
server.once("error", reject);
|
|
254
|
+
server.listen(port2, "127.0.0.1", () => {
|
|
255
|
+
server.off("error", reject);
|
|
256
|
+
resolve3();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
return z3.object({ port: z3.number() }).parse(server.address()).port;
|
|
260
|
+
}
|
|
261
|
+
function assertStopped(db) {
|
|
262
|
+
if (db === ":memory:") return;
|
|
263
|
+
const path = `${db}.lock`;
|
|
264
|
+
if (!existsSync2(path)) return;
|
|
265
|
+
const lock = z3.object({ pid: z3.number().int().positive() }).parse(JSON.parse(readFileSync2(path, "utf8")));
|
|
266
|
+
try {
|
|
267
|
+
process.kill(lock.pid, 0);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (z3.object({ code: z3.literal("ESRCH") }).safeParse(error).success) {
|
|
270
|
+
unlinkSync(path);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
throw new Error("Cannot determine whether the ledger is in use.");
|
|
274
|
+
}
|
|
275
|
+
throw new Error("Taximeter is already running for this ledger. Stop it first.");
|
|
276
|
+
}
|
|
277
|
+
function acquireLock(db) {
|
|
278
|
+
if (db === ":memory:") return () => {
|
|
279
|
+
};
|
|
280
|
+
assertStopped(db);
|
|
281
|
+
const lock = `${db}.lock`;
|
|
282
|
+
writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: "wx", mode: 384 });
|
|
283
|
+
return () => unlinkSync(lock);
|
|
284
|
+
}
|
|
285
|
+
async function startServices(input) {
|
|
286
|
+
const config = { ...input, ports: { ...input.ports }, db: expandPath(input.db) };
|
|
287
|
+
assertStopped(config.db);
|
|
288
|
+
const ledger = new Ledger(config.db);
|
|
289
|
+
let release;
|
|
290
|
+
try {
|
|
291
|
+
release = acquireLock(config.db);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
ledger.close();
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
const proxy = createProxy({ ledger, config });
|
|
297
|
+
const dashboard = createDashboard({ ledger, config });
|
|
298
|
+
try {
|
|
299
|
+
const proxyPort = await listenLocal(proxy, config.ports.proxy);
|
|
300
|
+
config.ports.proxy = proxyPort;
|
|
301
|
+
const dashboardPort = await listenLocal(dashboard, config.ports.dashboard);
|
|
302
|
+
let closed = false;
|
|
303
|
+
return {
|
|
304
|
+
ledger,
|
|
305
|
+
proxy,
|
|
306
|
+
dashboard,
|
|
307
|
+
proxyPort,
|
|
308
|
+
dashboardPort,
|
|
309
|
+
async close() {
|
|
310
|
+
if (closed) return;
|
|
311
|
+
closed = true;
|
|
312
|
+
dashboard.closeAllConnections();
|
|
313
|
+
await Promise.all([
|
|
314
|
+
closeProxy(proxy),
|
|
315
|
+
new Promise((resolve3) => dashboard.close(() => resolve3()))
|
|
316
|
+
]);
|
|
317
|
+
ledger.close();
|
|
318
|
+
release();
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (proxy.listening) await closeProxy(proxy);
|
|
323
|
+
if (dashboard.listening) dashboard.close();
|
|
324
|
+
ledger.close();
|
|
325
|
+
release();
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/cli/program.ts
|
|
331
|
+
var common = z4.strictObject({ db: z4.string().optional(), config: z4.string().optional() });
|
|
332
|
+
var port = portInputSchema.optional();
|
|
333
|
+
var startFlags = common.extend({
|
|
334
|
+
proxyPort: port,
|
|
335
|
+
dashboardPort: port,
|
|
336
|
+
upstream: httpUrlSchema.optional()
|
|
337
|
+
});
|
|
338
|
+
var reportFlags = common.extend({
|
|
339
|
+
json: z4.boolean().optional(),
|
|
340
|
+
task: labelSchema.optional(),
|
|
341
|
+
agent: labelSchema.optional(),
|
|
342
|
+
host: labelSchema.optional()
|
|
343
|
+
});
|
|
344
|
+
var exportFlags = common.extend({
|
|
345
|
+
json: z4.string().min(1).optional(),
|
|
346
|
+
csv: z4.string().min(1).optional(),
|
|
347
|
+
invoice: z4.string().min(1).optional()
|
|
348
|
+
}).refine(
|
|
349
|
+
(flags) => [flags.json, flags.csv, flags.invoice].filter(Boolean).length <= 1,
|
|
350
|
+
"Choose one export format"
|
|
351
|
+
);
|
|
352
|
+
var resetFlags = common.extend({ yes: z4.boolean().default(false) });
|
|
353
|
+
function options(command) {
|
|
354
|
+
return command.option("--db <path>", "SQLite ledger path").option("--config <path>", "Explicit config file");
|
|
355
|
+
}
|
|
356
|
+
function configFrom(flags) {
|
|
357
|
+
return loadConfig(flags.db ? { db: flags.db } : {}, { configFile: flags.config });
|
|
358
|
+
}
|
|
359
|
+
async function runCli(input, output = (text) => process.stdout.write(text)) {
|
|
360
|
+
const argv = z4.array(z4.string()).parse(input);
|
|
361
|
+
const program = new Command().name("taximeter").description("A taximeter for your AI agents.").version("0.1.0").exitOverride();
|
|
362
|
+
program.configureOutput({ writeOut: output, writeErr: (text) => process.stderr.write(text) });
|
|
363
|
+
options(program.command("start").description("Start the local proxy and dashboard")).option("--proxy-port <port>", "Proxy port (0 selects an available port)").option("--dashboard-port <port>", "Dashboard port").option("--upstream <url>", "Forward origin-form requests to this HTTP(S) upstream").action(async (raw) => {
|
|
364
|
+
const flags = startFlags.parse(raw);
|
|
365
|
+
const config = loadConfig(
|
|
366
|
+
{
|
|
367
|
+
...flags.db ? { db: flags.db } : {},
|
|
368
|
+
...flags.upstream ? { upstream: flags.upstream } : {},
|
|
369
|
+
ports: {
|
|
370
|
+
...flags.proxyPort !== void 0 ? { proxy: flags.proxyPort } : {},
|
|
371
|
+
...flags.dashboardPort !== void 0 ? { dashboard: flags.dashboardPort } : {}
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
{ configFile: flags.config }
|
|
375
|
+
);
|
|
376
|
+
const running = await startServices(config);
|
|
377
|
+
output(
|
|
378
|
+
`Taximeter 0.1.0
|
|
379
|
+
Proxy: http://127.0.0.1:${running.proxyPort}
|
|
380
|
+
Dashboard: http://127.0.0.1:${running.dashboardPort}
|
|
381
|
+
Point an HTTP-proxy-aware agent at http://127.0.0.1:${running.proxyPort}.
|
|
382
|
+
HTTPS CONNECT is unmetered; use --upstream or withMeter for HTTPS payments.
|
|
383
|
+
`
|
|
384
|
+
);
|
|
385
|
+
const shutdown = () => {
|
|
386
|
+
process.off("SIGINT", shutdown);
|
|
387
|
+
process.off("SIGTERM", shutdown);
|
|
388
|
+
void running.close().catch(() => {
|
|
389
|
+
process.exitCode = 1;
|
|
390
|
+
});
|
|
391
|
+
};
|
|
392
|
+
process.once("SIGINT", shutdown);
|
|
393
|
+
process.once("SIGTERM", shutdown);
|
|
394
|
+
});
|
|
395
|
+
options(
|
|
396
|
+
program.command("report").description("Print exact totals, separately per network and asset")
|
|
397
|
+
).option("--json", "Machine-readable integer-string totals").option("--task <id>", "Filter by task").option("--agent <id>", "Filter by agent").option("--host <host>", "Filter by host").action((raw) => {
|
|
398
|
+
const flags = reportFlags.parse(raw);
|
|
399
|
+
const db = configFrom(flags).db;
|
|
400
|
+
const ledger = new Ledger(existsSync3(db) ? db : ":memory:");
|
|
401
|
+
try {
|
|
402
|
+
const events = ledger.view().filter(
|
|
403
|
+
(event) => (!flags.task || event.taskId === flags.task) && (!flags.agent || event.agentId === flags.agent) && (!flags.host || event.host === flags.host)
|
|
404
|
+
);
|
|
405
|
+
const total = totals(events);
|
|
406
|
+
if (flags.json) output(`${JSON.stringify(total, null, 2)}
|
|
407
|
+
`);
|
|
408
|
+
else
|
|
409
|
+
output(
|
|
410
|
+
total.length ? `${total.map((item) => `${item.decimalsKnown ? formatAmount(item.amount, item.decimals) : item.amount} ${item.assetSymbol ?? `${item.asset} atomic units`} | ${item.network} | ${item.unknownAmount} atomic units uncertain`).join("\n")}
|
|
411
|
+
` : "No payments recorded.\n"
|
|
412
|
+
);
|
|
413
|
+
} finally {
|
|
414
|
+
ledger.close();
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
options(program.command("export").description("Export the local ledger")).option("--json <file>", "Write JSON to a new file (default: stdout)").option("--csv <file>", "Write CSV with exact counted and authorized amounts").option("--invoice <file>", "Write a printable HTML payment statement").action((raw) => {
|
|
418
|
+
const flags = exportFlags.parse(raw);
|
|
419
|
+
const db = configFrom(flags).db;
|
|
420
|
+
const ledger = new Ledger(existsSync3(db) ? db : ":memory:");
|
|
421
|
+
try {
|
|
422
|
+
const events = ledger.view();
|
|
423
|
+
const data = flags.csv ? toCsv(events) : flags.invoice ? toInvoice(events) : toJson(events);
|
|
424
|
+
const file = flags.csv ?? flags.invoice ?? flags.json;
|
|
425
|
+
if (file) {
|
|
426
|
+
writeFileSync2(resolve2(file), data, { flag: "wx", mode: 384 });
|
|
427
|
+
output(`Exported ${resolve2(file)}
|
|
428
|
+
`);
|
|
429
|
+
} else output(data);
|
|
430
|
+
} finally {
|
|
431
|
+
ledger.close();
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
options(
|
|
435
|
+
program.command("reset").description("Archive the current ledger after all writers have stopped")
|
|
436
|
+
).option("--yes", "Confirm archiving the current ledger").action((raw) => {
|
|
437
|
+
const flags = resetFlags.parse(raw);
|
|
438
|
+
if (!flags.yes)
|
|
439
|
+
throw new Error(
|
|
440
|
+
"Stop all Taximeter and SDK instances, then run taximeter reset --yes to archive the ledger."
|
|
441
|
+
);
|
|
442
|
+
const { db } = configFrom(flags);
|
|
443
|
+
assertStopped(db);
|
|
444
|
+
if (!existsSync3(db)) {
|
|
445
|
+
output("No ledger to archive.\n");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const release = acquireLock(db);
|
|
449
|
+
try {
|
|
450
|
+
const ledger = new Ledger(db);
|
|
451
|
+
ledger.close();
|
|
452
|
+
const archive = `${db.endsWith(".db") ? db.slice(0, -3) : db}.archive-${Date.now()}.db`;
|
|
453
|
+
if (existsSync3(archive)) throw new Error("Archive path already exists; retry later.");
|
|
454
|
+
renameSync(db, archive);
|
|
455
|
+
for (const suffix of ["-wal", "-shm"])
|
|
456
|
+
if (existsSync3(`${db}${suffix}`)) renameSync(`${db}${suffix}`, `${archive}${suffix}`);
|
|
457
|
+
output(`Archived ${archive}
|
|
458
|
+
`);
|
|
459
|
+
} finally {
|
|
460
|
+
release();
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
options(
|
|
464
|
+
program.command("doctor").description("Check local config and SQLite without network requests")
|
|
465
|
+
).action((raw) => {
|
|
466
|
+
const config = configFrom(common.parse(raw));
|
|
467
|
+
const ledger = new Ledger(existsSync3(config.db) ? config.db : ":memory:");
|
|
468
|
+
try {
|
|
469
|
+
output(
|
|
470
|
+
`Configuration: valid
|
|
471
|
+
SQLite: ready (${ledger.events().length} events)
|
|
472
|
+
Ledger: ${config.db}
|
|
473
|
+
Listener binding: 127.0.0.1
|
|
474
|
+
HTTPS CONNECT: unmetered
|
|
475
|
+
Network checks: none
|
|
476
|
+
`
|
|
477
|
+
);
|
|
478
|
+
} finally {
|
|
479
|
+
ledger.close();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
await program.parseAsync(argv, { from: "user" });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/cli/index.ts
|
|
486
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
487
|
+
if (error instanceof CommanderError && error.exitCode === 0) return;
|
|
488
|
+
if (!(error instanceof CommanderError))
|
|
489
|
+
process.stderr.write(
|
|
490
|
+
`Taximeter: ${error instanceof Error ? error.message : "command failed"}
|
|
491
|
+
`
|
|
492
|
+
);
|
|
493
|
+
process.exitCode = 1;
|
|
494
|
+
});
|