@gerbaudo/cli 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.
@@ -0,0 +1,10 @@
1
+ export interface Endpoint {
2
+ id: string;
3
+ method: string;
4
+ path: string;
5
+ params?: string;
6
+ bodySchema?: string;
7
+ responseSchema?: string;
8
+ createdAt: string;
9
+ updatedAt: string;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function createDaemonCommand(): Command;
@@ -0,0 +1,48 @@
1
+ import path from 'node:path';
2
+ import { Command } from 'commander';
3
+ import { loadConfig, findConfigPath } from '../config/config.js';
4
+ import { getDb, closeDb } from '../storage/db.js';
5
+ import { CatalogStore } from '../storage/catalog.js';
6
+ import { RecordStore } from '../storage/records.js';
7
+ import { DaemonServer } from '../daemon/server.js';
8
+ export function createDaemonCommand() {
9
+ const cmd = new Command('daemon')
10
+ .description('Start the Gerbaudo daemon server')
11
+ .option('-p, --port <number>', 'Port to listen on')
12
+ .option('--db <path>', 'Path to SQLite database file')
13
+ .action(async (opts) => {
14
+ const configPath = findConfigPath();
15
+ const config = loadConfig(configPath ?? undefined);
16
+ if (opts.port)
17
+ config.daemonPort = parseInt(opts.port, 10);
18
+ const dbPath = opts.db
19
+ ? path.resolve(opts.db)
20
+ : configPath
21
+ ? path.join(path.dirname(configPath), config.dbPath)
22
+ : config.dbPath;
23
+ const db = getDb(dbPath);
24
+ const catalogStore = new CatalogStore(db);
25
+ const recordStore = new RecordStore(db);
26
+ const server = new DaemonServer(config, catalogStore, recordStore);
27
+ try {
28
+ await server.start();
29
+ console.log(`Gerbaudo daemon listening on http://127.0.0.1:${server.getPort()}`);
30
+ }
31
+ catch (err) {
32
+ console.error('Failed to start daemon:', err);
33
+ closeDb();
34
+ process.exit(1);
35
+ }
36
+ process.on('SIGINT', async () => {
37
+ await server.stop();
38
+ closeDb();
39
+ process.exit(0);
40
+ });
41
+ process.on('SIGTERM', async () => {
42
+ await server.stop();
43
+ closeDb();
44
+ process.exit(0);
45
+ });
46
+ });
47
+ return cmd;
48
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function createEndpointsCommand(): Command;
@@ -0,0 +1,42 @@
1
+ import path from 'node:path';
2
+ import { Command } from 'commander';
3
+ import { loadConfig, findConfigPath } from '../config/config.js';
4
+ import { getDb } from '../storage/db.js';
5
+ import { CatalogStore } from '../storage/catalog.js';
6
+ export function createEndpointsCommand() {
7
+ const cmd = new Command('endpoints')
8
+ .description('List discovered API endpoints')
9
+ .option('-m, --method <method>', 'Filter by HTTP method')
10
+ .option('-p, --path <path>', 'Filter by path (partial match)')
11
+ .option('--json', 'Output as JSON')
12
+ .action((opts) => {
13
+ const configPath = findConfigPath();
14
+ const config = loadConfig(configPath ?? undefined);
15
+ const dbPath = configPath
16
+ ? path.join(path.dirname(configPath), config.dbPath)
17
+ : config.dbPath;
18
+ const db = getDb(dbPath);
19
+ const catalogStore = new CatalogStore(db);
20
+ const filter = {
21
+ method: opts.method,
22
+ path: opts.path,
23
+ };
24
+ const endpoints = catalogStore.findAll(filter);
25
+ if (opts.json) {
26
+ console.log(JSON.stringify(endpoints, null, 2));
27
+ return;
28
+ }
29
+ if (endpoints.length === 0) {
30
+ console.log('No endpoints discovered yet.');
31
+ return;
32
+ }
33
+ const rows = endpoints.map((e) => ({
34
+ Method: e.method,
35
+ Path: e.path,
36
+ 'Params': e.params ?? '-',
37
+ 'Registered': e.createdAt,
38
+ }));
39
+ console.table(rows);
40
+ });
41
+ return cmd;
42
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function createInstallCommand(): Command;
@@ -0,0 +1,112 @@
1
+ import { Command } from 'commander';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import { readFileSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { writeConfig } from '../config/config.js';
8
+ function detectExpress(dir) {
9
+ try {
10
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
11
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
12
+ return 'express' in deps;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ function getSdkPath() {
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ // Walk up looking for sdk/node/package.json marker
21
+ let dir = __dirname;
22
+ for (let i = 0; i < 10; i++) {
23
+ if (existsSync(join(dir, 'sdk', 'node', 'package.json'))) {
24
+ return join(dir, 'sdk', 'node');
25
+ }
26
+ const parent = dirname(dir);
27
+ if (parent === dir)
28
+ break;
29
+ dir = parent;
30
+ }
31
+ // Fallback: assume repo-local dev layout (tsx from cli/)
32
+ return join(__dirname, '..', '..', '..', '..', 'sdk', 'node');
33
+ }
34
+ export function createInstallCommand() {
35
+ const cmd = new Command('install')
36
+ .alias('init')
37
+ .description('Install Gerbaudo into the current project')
38
+ .option('--port <number>', 'Daemon port', '9876')
39
+ .option('--sdk', 'Auto-install the Node SDK package')
40
+ .action((opts) => {
41
+ const targetDir = process.cwd();
42
+ const configPath = join(targetDir, 'gerbaudo.json');
43
+ if (existsSync(configPath)) {
44
+ console.log('gerbaudo.json already exists. Skipping config creation.');
45
+ return;
46
+ }
47
+ const config = {
48
+ daemonPort: parseInt(opts.port, 10),
49
+ dbPath: '.gerbaudo/data.db',
50
+ };
51
+ writeConfig(config, configPath);
52
+ const dbDir = join(targetDir, '.gerbaudo');
53
+ if (!existsSync(dbDir)) {
54
+ mkdirSync(dbDir, { recursive: true });
55
+ }
56
+ console.log('Gerbaudo installed successfully.');
57
+ console.log();
58
+ console.log('Configuration written to: gerbaudo.json');
59
+ const isExpress = detectExpress(targetDir);
60
+ if (opts.sdk && isExpress) {
61
+ const sdkPath = getSdkPath();
62
+ console.log('Installing @gerbaudo/sdk-node...');
63
+ try {
64
+ execSync(`npm install "${sdkPath}"`, { cwd: targetDir, stdio: 'inherit' });
65
+ console.log('SDK installed successfully.');
66
+ console.log();
67
+ console.log('Add to your Express app:');
68
+ console.log(' import { gerbaudo } from "@gerbaudo/sdk-node"');
69
+ console.log(' app.use(gerbaudo({ app }))');
70
+ }
71
+ catch {
72
+ console.error('Failed to install SDK. Install manually:');
73
+ console.log(` npm install "${sdkPath}"`);
74
+ }
75
+ }
76
+ else if (opts.sdk && !isExpress) {
77
+ console.log('No Express project detected. Installing SDK anyway...');
78
+ const sdkPath = getSdkPath();
79
+ try {
80
+ execSync(`npm install "${sdkPath}"`, { cwd: targetDir, stdio: 'inherit' });
81
+ console.log('SDK installed successfully.');
82
+ }
83
+ catch {
84
+ console.error('Failed to install SDK.');
85
+ }
86
+ }
87
+ else {
88
+ console.log();
89
+ console.log('Next steps:');
90
+ console.log(' 1. Start the daemon:');
91
+ console.log(' npx gerbaudo daemon');
92
+ console.log();
93
+ if (isExpress) {
94
+ console.log(' 2. Install the SDK:');
95
+ console.log(' npx gerbaudo init --sdk');
96
+ console.log();
97
+ console.log(' 3. Add to your Express app:');
98
+ console.log(' import { gerbaudo } from "@gerbaudo/sdk-node"');
99
+ console.log(' app.use(gerbaudo({ app }))');
100
+ }
101
+ else {
102
+ console.log(' 2. Install the Node SDK:');
103
+ console.log(' npm install <path-to>/gerbaudo/sdk/node');
104
+ console.log();
105
+ console.log(' 3. Add to your app:');
106
+ console.log(' import { gerbaudo } from "@gerbaudo/sdk-node"');
107
+ console.log(' app.use(gerbaudo({ app }))');
108
+ }
109
+ }
110
+ });
111
+ return cmd;
112
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function createLogCommand(): Command;
@@ -0,0 +1,51 @@
1
+ import path from 'node:path';
2
+ import { Command } from 'commander';
3
+ import { loadConfig, findConfigPath } from '../config/config.js';
4
+ import { getDb } from '../storage/db.js';
5
+ import { RecordStore } from '../storage/records.js';
6
+ export function createLogCommand() {
7
+ const cmd = new Command('log')
8
+ .description('Query request history')
9
+ .argument('[endpoint]', 'Endpoint path to filter')
10
+ .option('-m, --method <method>', 'Filter by HTTP method')
11
+ .option('-s, --status <status>', 'Filter by HTTP status code')
12
+ .option('--since <date>', 'Start date (ISO format)')
13
+ .option('--until <date>', 'End date (ISO format)')
14
+ .option('-n, --limit <number>', 'Max records', '50')
15
+ .option('--json', 'Output as JSON')
16
+ .action((endpoint, opts) => {
17
+ const configPath = findConfigPath();
18
+ const config = loadConfig(configPath ?? undefined);
19
+ const dbPath = configPath
20
+ ? path.join(path.dirname(configPath), config.dbPath)
21
+ : config.dbPath;
22
+ const db = getDb(dbPath);
23
+ const recordStore = new RecordStore(db);
24
+ const records = recordStore.findAll({
25
+ path: endpoint,
26
+ method: opts.method,
27
+ status: opts.status ? parseInt(opts.status, 10) : undefined,
28
+ since: opts.since,
29
+ until: opts.until,
30
+ limit: parseInt(opts.limit, 10),
31
+ });
32
+ if (opts.json) {
33
+ console.log(JSON.stringify(records, null, 2));
34
+ return;
35
+ }
36
+ if (records.length === 0) {
37
+ console.log('No records found.');
38
+ return;
39
+ }
40
+ const rows = records.map((r) => ({
41
+ ID: r.id.slice(0, 8),
42
+ Method: r.method,
43
+ Path: r.path,
44
+ Status: r.status,
45
+ 'Duration (ms)': r.durationMs ?? '-',
46
+ 'Time': r.createdAt,
47
+ }));
48
+ console.table(rows);
49
+ });
50
+ return cmd;
51
+ }
@@ -0,0 +1,4 @@
1
+ import { Command } from 'commander';
2
+ export declare function matchRoute(pattern: string, actual: string): Record<string, string> | null;
3
+ export declare function resolvePath(pattern: string, params: Record<string, string>): string;
4
+ export declare function createRunCommand(): Command;
@@ -0,0 +1,181 @@
1
+ import path from 'node:path';
2
+ import { Command } from 'commander';
3
+ import { loadConfig, findConfigPath } from '../config/config.js';
4
+ import { getDb } from '../storage/db.js';
5
+ import { CatalogStore } from '../storage/catalog.js';
6
+ import { RecordStore } from '../storage/records.js';
7
+ export function matchRoute(pattern, actual) {
8
+ const patternParts = pattern.split('/');
9
+ const actualParts = actual.split('/');
10
+ if (patternParts.length !== actualParts.length)
11
+ return null;
12
+ const params = {};
13
+ for (let i = 0; i < patternParts.length; i++) {
14
+ if (patternParts[i].startsWith(':')) {
15
+ params[patternParts[i].slice(1)] = decodeURIComponent(actualParts[i]);
16
+ }
17
+ else if (patternParts[i] !== actualParts[i]) {
18
+ return null;
19
+ }
20
+ }
21
+ return params;
22
+ }
23
+ export function resolvePath(pattern, params) {
24
+ return pattern.replace(/:(\w+)/g, (_, name) => {
25
+ if (params[name] !== undefined)
26
+ return params[name];
27
+ return `:${name}`;
28
+ });
29
+ }
30
+ export function createRunCommand() {
31
+ const cmd = new Command('run')
32
+ .description('Execute an API endpoint')
33
+ .argument('<endpoint>', 'Endpoint path (e.g., /api/users or /api/users/123)')
34
+ .option('-X, --method <method>', 'HTTP method', 'GET')
35
+ .option('-d, --data <body>', 'Request body (JSON string)')
36
+ .option('-H, --header <headers...>', 'Request headers (Key:Value)')
37
+ .option('-p, --param <params...>', 'Path/query params (Key=Value). Fills :param in route first, remainder become query string.')
38
+ .option('--json', 'Output raw JSON response')
39
+ .action(async (endpoint, opts) => {
40
+ const configPath = findConfigPath();
41
+ if (!configPath) {
42
+ console.error('No gerbaudo.json found. Run "gerbaudo init" first.');
43
+ process.exit(1);
44
+ }
45
+ const config = loadConfig(configPath);
46
+ const dbPath = path.join(path.dirname(configPath), config.dbPath);
47
+ const db = getDb(dbPath);
48
+ const catalogStore = new CatalogStore(db);
49
+ const recordStore = new RecordStore(db);
50
+ const allByMethod = catalogStore.findAll({ method: opts.method });
51
+ let match;
52
+ let pathParams = {};
53
+ // 1) exact match
54
+ match = allByMethod.find((e) => e.path === endpoint);
55
+ if (!match) {
56
+ // 2) pattern match with :params
57
+ for (const candidate of allByMethod) {
58
+ const params = matchRoute(candidate.path, endpoint);
59
+ if (params) {
60
+ match = candidate;
61
+ pathParams = params;
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ if (!match) {
67
+ // 3) endsWith fallback (for sub-path matching)
68
+ match = allByMethod.find((e) => e.path.endsWith(endpoint));
69
+ }
70
+ if (!match) {
71
+ console.error(`Endpoint not found: ${opts.method} ${endpoint}`);
72
+ console.error();
73
+ const total = catalogStore.findAll().length;
74
+ if (total === 0) {
75
+ console.error('No endpoints registered. Make sure:');
76
+ console.error(' 1. The daemon is running: npx gerbaudo daemon');
77
+ console.error(' 2. The SDK middleware is active in your backend app');
78
+ console.error(' 3. Routes have been registered: check with "gerbaudo endpoints"');
79
+ }
80
+ else {
81
+ console.error(`Available endpoints: ${total}. Use "gerbaudo endpoints" to list them.`);
82
+ console.error('Check the method and path spelling.');
83
+ }
84
+ process.exit(1);
85
+ }
86
+ let body;
87
+ if (opts.data) {
88
+ body = opts.data;
89
+ }
90
+ const headers = {};
91
+ if (opts.header) {
92
+ for (const h of opts.header) {
93
+ const idx = h.indexOf(':');
94
+ if (idx > 0) {
95
+ headers[h.slice(0, idx).trim()] = h.slice(idx + 1).trim();
96
+ }
97
+ }
98
+ }
99
+ const allParams = {};
100
+ if (opts.param) {
101
+ for (const p of opts.param) {
102
+ const idx = p.indexOf('=');
103
+ if (idx > 0) {
104
+ allParams[p.slice(0, idx)] = p.slice(idx + 1);
105
+ }
106
+ }
107
+ }
108
+ // Extracted path params take precedence over --param
109
+ const mergedParams = { ...allParams, ...pathParams };
110
+ // Resolve :param tokens in the path
111
+ const resolvedPath = resolvePath(match.path, mergedParams);
112
+ // Remaining params become query string
113
+ const queryParams = { ...allParams };
114
+ for (const key of Object.keys(pathParams)) {
115
+ delete queryParams[key];
116
+ }
117
+ const baseUrl = `http://127.0.0.1:${config.daemonPort}`;
118
+ const url = new URL(resolvedPath, baseUrl);
119
+ for (const [k, v] of Object.entries(queryParams)) {
120
+ url.searchParams.set(k, v);
121
+ }
122
+ const start = performance.now();
123
+ try {
124
+ const response = await fetch(url.toString(), {
125
+ method: match.method,
126
+ headers: {
127
+ 'Content-Type': 'application/json',
128
+ ...headers,
129
+ },
130
+ body: body ?? undefined,
131
+ });
132
+ const durationMs = Math.round(performance.now() - start);
133
+ const responseBody = await response.text();
134
+ const record = recordStore.insert({
135
+ endpointId: match.id,
136
+ method: match.method,
137
+ path: match.path,
138
+ status: response.status,
139
+ requestHeaders: JSON.stringify(headers),
140
+ requestBody: body,
141
+ responseHeaders: JSON.stringify(Object.fromEntries(response.headers)),
142
+ responseBody,
143
+ durationMs,
144
+ });
145
+ if (opts.json) {
146
+ console.log(JSON.stringify({
147
+ status: response.status,
148
+ headers: Object.fromEntries(response.headers),
149
+ body: responseBody,
150
+ durationMs,
151
+ recordId: record.id,
152
+ }));
153
+ return;
154
+ }
155
+ console.log(`\n${response.status} ${response.statusText} (${durationMs}ms)\n`);
156
+ try {
157
+ const parsed = JSON.parse(responseBody);
158
+ console.log(JSON.stringify(parsed, null, 2));
159
+ }
160
+ catch {
161
+ console.log(responseBody);
162
+ }
163
+ }
164
+ catch (err) {
165
+ const msg = String(err);
166
+ if (msg.includes('ECONNREFUSED')) {
167
+ console.error(`Cannot connect to backend at ${baseUrl}. Is the daemon running?`);
168
+ console.error('Start it with: npx gerbaudo daemon');
169
+ }
170
+ else if (msg.includes('fetch')) {
171
+ console.error(`Request to ${match.method} ${match.path} failed.`);
172
+ console.error(`Is the backend server running at ${baseUrl}?`);
173
+ }
174
+ else {
175
+ console.error(`Request failed: ${err}`);
176
+ }
177
+ process.exit(1);
178
+ }
179
+ });
180
+ return cmd;
181
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function createStatsCommand(): Command;
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ import { Command } from 'commander';
3
+ import { loadConfig, findConfigPath } from '../config/config.js';
4
+ import { getDb } from '../storage/db.js';
5
+ import { RecordStore } from '../storage/records.js';
6
+ export function createStatsCommand() {
7
+ const cmd = new Command('stats')
8
+ .description('Show API usage statistics')
9
+ .option('--json', 'Output as JSON')
10
+ .action((opts) => {
11
+ const configPath = findConfigPath();
12
+ const config = loadConfig(configPath ?? undefined);
13
+ const dbPath = configPath
14
+ ? path.join(path.dirname(configPath), config.dbPath)
15
+ : config.dbPath;
16
+ const db = getDb(dbPath);
17
+ const recordStore = new RecordStore(db);
18
+ const stats = recordStore.getStats();
19
+ if (opts.json) {
20
+ console.log(JSON.stringify(stats, null, 2));
21
+ return;
22
+ }
23
+ console.log('=== Gerbaudo Stats ===');
24
+ console.log(`Total endpoints: ${stats.totalEndpoints}`);
25
+ console.log(`Total requests: ${stats.totalRecords}`);
26
+ console.log(`Errors (4xx+): ${stats.errorCount}`);
27
+ console.log();
28
+ if (stats.topEndpoints.length > 0) {
29
+ console.log('Top endpoints:');
30
+ console.table(stats.topEndpoints);
31
+ }
32
+ if (stats.slowestEndpoints.length > 0) {
33
+ console.log('Slowest endpoints (avg ms):');
34
+ console.table(stats.slowestEndpoints);
35
+ }
36
+ });
37
+ return cmd;
38
+ }
@@ -0,0 +1,8 @@
1
+ export interface GerbaudoConfig {
2
+ daemonPort: number;
3
+ dbPath: string;
4
+ sdkPath?: string;
5
+ }
6
+ export declare function findConfigPath(startDir?: string): string | null;
7
+ export declare function loadConfig(path?: string): GerbaudoConfig;
8
+ export declare function writeConfig(config: GerbaudoConfig, targetPath: string): void;
@@ -0,0 +1,30 @@
1
+ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ const DEFAULT_CONFIG = {
4
+ daemonPort: 9876,
5
+ dbPath: '.gerbaudo/data.db',
6
+ };
7
+ export function findConfigPath(startDir) {
8
+ let dir = startDir ?? process.cwd();
9
+ for (let i = 0; i < 10; i++) {
10
+ const candidate = join(dir, 'gerbaudo.json');
11
+ if (existsSync(candidate))
12
+ return candidate;
13
+ const parent = dirname(dir);
14
+ if (parent === dir)
15
+ break;
16
+ dir = parent;
17
+ }
18
+ return null;
19
+ }
20
+ export function loadConfig(path) {
21
+ const configPath = path ?? findConfigPath();
22
+ if (!configPath) {
23
+ return { ...DEFAULT_CONFIG };
24
+ }
25
+ const raw = readFileSync(configPath, 'utf-8');
26
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
27
+ }
28
+ export function writeConfig(config, targetPath) {
29
+ writeFileSync(targetPath, JSON.stringify(config, null, 2));
30
+ }
@@ -0,0 +1,4 @@
1
+ import { Router } from 'express';
2
+ import type { CatalogStore } from '../storage/catalog.js';
3
+ import type { RecordStore } from '../storage/records.js';
4
+ export declare function createRouter(catalogStore: CatalogStore, recordStore: RecordStore): Router;
@@ -0,0 +1,111 @@
1
+ import { Router } from 'express';
2
+ export function createRouter(catalogStore, recordStore) {
3
+ const router = Router();
4
+ router.post('/catalog/register', (req, res) => {
5
+ const { method, path, params, bodySchema, responseSchema } = req.body;
6
+ if (!method || !path) {
7
+ res.status(400).json({ error: 'method and path are required' });
8
+ return;
9
+ }
10
+ const endpoint = catalogStore.upsert({
11
+ method,
12
+ path,
13
+ params,
14
+ bodySchema,
15
+ responseSchema,
16
+ });
17
+ res.json(endpoint);
18
+ });
19
+ router.get('/catalog', (req, res) => {
20
+ const filter = {
21
+ method: req.query.method,
22
+ path: req.query.path,
23
+ };
24
+ const endpoints = catalogStore.findAll(filter);
25
+ res.json(endpoints);
26
+ });
27
+ router.post('/intercept/record', (req, res) => {
28
+ const { endpointId, method, path, status, requestHeaders, requestBody, responseHeaders, responseBody, durationMs, } = req.body;
29
+ if (!method || !path || status === undefined) {
30
+ res.status(400).json({ error: 'method, path, and status are required' });
31
+ return;
32
+ }
33
+ const record = recordStore.insert({
34
+ endpointId,
35
+ method,
36
+ path,
37
+ status,
38
+ requestHeaders,
39
+ requestBody,
40
+ responseHeaders,
41
+ responseBody,
42
+ durationMs,
43
+ });
44
+ res.json(record);
45
+ });
46
+ router.get('/records', (req, res) => {
47
+ const filter = {
48
+ endpointId: req.query.endpointId,
49
+ method: req.query.method,
50
+ status: req.query.status ? Number(req.query.status) : undefined,
51
+ since: req.query.since,
52
+ until: req.query.until,
53
+ path: req.query.path,
54
+ limit: req.query.limit ? Number(req.query.limit) : undefined,
55
+ offset: req.query.offset ? Number(req.query.offset) : undefined,
56
+ };
57
+ const records = recordStore.findAll(filter);
58
+ res.json(records);
59
+ });
60
+ router.get('/records/:id', (req, res) => {
61
+ const record = recordStore.findById(req.params.id);
62
+ if (!record) {
63
+ res.status(404).json({ error: 'record not found' });
64
+ return;
65
+ }
66
+ res.json(record);
67
+ });
68
+ router.get('/stats', (_req, res) => {
69
+ const stats = recordStore.getStats();
70
+ res.json(stats);
71
+ });
72
+ router.get('/agent/endpoints', (req, res) => {
73
+ const filter = {
74
+ method: req.query.method,
75
+ path: req.query.path,
76
+ };
77
+ const endpoints = catalogStore.findAll(filter);
78
+ res.json({
79
+ version: '0.1.0',
80
+ endpoints: endpoints.map((e) => ({
81
+ id: e.id,
82
+ method: e.method,
83
+ path: e.path,
84
+ params: e.params,
85
+ bodySchema: e.bodySchema,
86
+ responseSchema: e.responseSchema,
87
+ })),
88
+ });
89
+ });
90
+ router.post('/agent/exec', (req, res) => {
91
+ const { endpointId, body, params, headers } = req.body;
92
+ if (!endpointId) {
93
+ res.status(400).json({ error: 'endpointId is required' });
94
+ return;
95
+ }
96
+ const endpoint = catalogStore.findById(endpointId);
97
+ if (!endpoint) {
98
+ res.status(404).json({ error: 'endpoint not found' });
99
+ return;
100
+ }
101
+ res.json({
102
+ instruction: 'forward',
103
+ method: endpoint.method,
104
+ path: endpoint.path,
105
+ params,
106
+ body,
107
+ headers,
108
+ });
109
+ });
110
+ return router;
111
+ }
@@ -0,0 +1,12 @@
1
+ import type { CatalogStore } from '../storage/catalog.js';
2
+ import type { RecordStore } from '../storage/records.js';
3
+ import type { GerbaudoConfig } from '../config/config.js';
4
+ export declare class DaemonServer {
5
+ private app;
6
+ private server;
7
+ private port;
8
+ constructor(config: GerbaudoConfig, catalogStore: CatalogStore, recordStore: RecordStore);
9
+ start(): Promise<void>;
10
+ stop(): Promise<void>;
11
+ getPort(): number;
12
+ }
@@ -0,0 +1,42 @@
1
+ import express from 'express';
2
+ import { createRouter } from './router.js';
3
+ export class DaemonServer {
4
+ app;
5
+ server = null;
6
+ port;
7
+ constructor(config, catalogStore, recordStore) {
8
+ this.port = config.daemonPort;
9
+ this.app = express();
10
+ this.app.use(express.json());
11
+ this.app.use('/api', createRouter(catalogStore, recordStore));
12
+ }
13
+ start() {
14
+ return new Promise((resolve, reject) => {
15
+ this.server = this.app.listen(this.port, '127.0.0.1', () => {
16
+ const addr = this.server.address();
17
+ if (addr && typeof addr === 'object') {
18
+ this.port = addr.port;
19
+ }
20
+ resolve();
21
+ });
22
+ this.server.on('error', reject);
23
+ });
24
+ }
25
+ stop() {
26
+ return new Promise((resolve, reject) => {
27
+ if (!this.server) {
28
+ resolve();
29
+ return;
30
+ }
31
+ this.server.close((err) => {
32
+ if (err)
33
+ reject(err);
34
+ else
35
+ resolve();
36
+ });
37
+ });
38
+ }
39
+ getPort() {
40
+ return this.port;
41
+ }
42
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { createDaemonCommand } from './commands/daemon.js';
4
+ import { createInstallCommand } from './commands/install.js';
5
+ import { createEndpointsCommand } from './commands/endpoints.js';
6
+ import { createLogCommand } from './commands/log.js';
7
+ import { createStatsCommand } from './commands/stats.js';
8
+ import { createRunCommand } from './commands/run.js';
9
+ const program = new Command();
10
+ program
11
+ .name('gerbaudo')
12
+ .description('Local CLI tool for backend API instrumentation')
13
+ .version('0.1.0');
14
+ program.addCommand(createDaemonCommand());
15
+ program.addCommand(createInstallCommand());
16
+ program.addCommand(createEndpointsCommand());
17
+ program.addCommand(createLogCommand());
18
+ program.addCommand(createStatsCommand());
19
+ program.addCommand(createRunCommand());
20
+ program.parse(process.argv);
@@ -0,0 +1,13 @@
1
+ export interface InterceptRecord {
2
+ id: string;
3
+ endpointId: string;
4
+ method: string;
5
+ path: string;
6
+ status: number;
7
+ requestHeaders?: string;
8
+ requestBody?: string;
9
+ responseHeaders?: string;
10
+ responseBody?: string;
11
+ durationMs: number;
12
+ createdAt: string;
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { Endpoint } from '../catalog/endpoint.js';
3
+ export declare class CatalogStore {
4
+ private db;
5
+ constructor(db: Database.Database);
6
+ upsert(endpoint: Omit<Endpoint, 'id' | 'createdAt' | 'updatedAt'>): Endpoint;
7
+ findAll(filter?: {
8
+ method?: string;
9
+ path?: string;
10
+ }): Endpoint[];
11
+ findById(id: string): Endpoint | undefined;
12
+ findByMethodAndPath(method: string, path: string): Endpoint | undefined;
13
+ }
@@ -0,0 +1,59 @@
1
+ import { v4 as uuid } from 'uuid';
2
+ export class CatalogStore {
3
+ db;
4
+ constructor(db) {
5
+ this.db = db;
6
+ }
7
+ upsert(endpoint) {
8
+ const existing = this.db
9
+ .prepare('SELECT * FROM endpoints WHERE method = ? AND path = ?')
10
+ .get(endpoint.method, endpoint.path);
11
+ if (existing) {
12
+ this.db
13
+ .prepare('UPDATE endpoints SET params = ?, body_schema = ?, response_schema = ?, updated_at = datetime(\'now\') WHERE id = ?')
14
+ .run(endpoint.params ?? null, endpoint.bodySchema ?? null, endpoint.responseSchema ?? null, existing.id);
15
+ return {
16
+ ...existing,
17
+ params: endpoint.params ?? existing.params,
18
+ bodySchema: endpoint.bodySchema ?? existing.bodySchema,
19
+ responseSchema: endpoint.responseSchema ?? existing.responseSchema,
20
+ updatedAt: new Date().toISOString(),
21
+ };
22
+ }
23
+ const id = uuid();
24
+ const now = new Date().toISOString();
25
+ this.db
26
+ .prepare(`INSERT INTO endpoints (id, method, path, params, body_schema, response_schema, created_at, updated_at)
27
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
28
+ .run(id, endpoint.method, endpoint.path, endpoint.params ?? null, endpoint.bodySchema ?? null, endpoint.responseSchema ?? null, now, now);
29
+ return { id, ...endpoint, createdAt: now, updatedAt: now };
30
+ }
31
+ findAll(filter) {
32
+ let sql = 'SELECT * FROM endpoints';
33
+ const conditions = [];
34
+ const params = [];
35
+ if (filter?.method) {
36
+ conditions.push('method = ?');
37
+ params.push(filter.method);
38
+ }
39
+ if (filter?.path) {
40
+ conditions.push('path LIKE ?');
41
+ params.push(`%${filter.path}%`);
42
+ }
43
+ if (conditions.length > 0) {
44
+ sql += ' WHERE ' + conditions.join(' AND ');
45
+ }
46
+ sql += ' ORDER BY method, path';
47
+ return this.db.prepare(sql).all(...params);
48
+ }
49
+ findById(id) {
50
+ return this.db
51
+ .prepare('SELECT * FROM endpoints WHERE id = ?')
52
+ .get(id);
53
+ }
54
+ findByMethodAndPath(method, path) {
55
+ return this.db
56
+ .prepare('SELECT * FROM endpoints WHERE method = ? AND path = ?')
57
+ .get(method, path);
58
+ }
59
+ }
@@ -0,0 +1,3 @@
1
+ import Database from 'better-sqlite3';
2
+ export declare function getDb(dbPath: string): Database.Database;
3
+ export declare function closeDb(): void;
@@ -0,0 +1,27 @@
1
+ import Database from 'better-sqlite3';
2
+ import { mkdirp } from './mkdirp.js';
3
+ import { runMigrations } from './migrations.js';
4
+ let db = null;
5
+ export function getDb(dbPath) {
6
+ if (db) {
7
+ try {
8
+ db.prepare('SELECT 1').get();
9
+ return db;
10
+ }
11
+ catch {
12
+ db = null;
13
+ }
14
+ }
15
+ mkdirp(dbPath);
16
+ db = new Database(dbPath);
17
+ db.pragma('journal_mode = WAL');
18
+ db.pragma('foreign_keys = ON');
19
+ runMigrations(db);
20
+ return db;
21
+ }
22
+ export function closeDb() {
23
+ if (db) {
24
+ db.close();
25
+ db = null;
26
+ }
27
+ }
@@ -0,0 +1,2 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare function runMigrations(db: Database.Database): void;
@@ -0,0 +1,60 @@
1
+ const migrations = [
2
+ // 0: tracking table
3
+ `
4
+ CREATE TABLE IF NOT EXISTS _migrations (
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ applied_at TEXT DEFAULT (datetime('now'))
7
+ );
8
+ `,
9
+ // 1: endpoints
10
+ `
11
+ CREATE TABLE IF NOT EXISTS endpoints (
12
+ id TEXT PRIMARY KEY,
13
+ method TEXT NOT NULL,
14
+ path TEXT NOT NULL,
15
+ params TEXT,
16
+ body_schema TEXT,
17
+ response_schema TEXT,
18
+ created_at TEXT DEFAULT (datetime('now')),
19
+ updated_at TEXT DEFAULT (datetime('now')),
20
+ UNIQUE(method, path)
21
+ );
22
+ `,
23
+ // 2: records
24
+ `
25
+ CREATE TABLE IF NOT EXISTS records (
26
+ id TEXT PRIMARY KEY,
27
+ endpoint_id TEXT REFERENCES endpoints(id),
28
+ method TEXT NOT NULL,
29
+ path TEXT NOT NULL,
30
+ status INTEGER NOT NULL,
31
+ request_headers TEXT,
32
+ request_body TEXT,
33
+ response_headers TEXT,
34
+ response_body TEXT,
35
+ duration_ms INTEGER,
36
+ created_at TEXT DEFAULT (datetime('now'))
37
+ );
38
+ `,
39
+ // 3: indexes
40
+ `
41
+ CREATE INDEX IF NOT EXISTS idx_records_endpoint ON records(endpoint_id);
42
+ `,
43
+ `
44
+ CREATE INDEX IF NOT EXISTS idx_records_created ON records(created_at);
45
+ `,
46
+ ];
47
+ export function runMigrations(db) {
48
+ // ensure _migrations table exists first
49
+ db.exec(migrations[0]);
50
+ const row = db
51
+ .prepare('SELECT COUNT(*) as cnt FROM _migrations')
52
+ .get();
53
+ const applied = row?.cnt ?? 0;
54
+ for (let i = applied; i < migrations.length; i++) {
55
+ db.exec(migrations[i]);
56
+ if (i > 0) {
57
+ db.prepare('INSERT INTO _migrations DEFAULT VALUES').run();
58
+ }
59
+ }
60
+ }
@@ -0,0 +1 @@
1
+ export declare function mkdirp(filePath: string): void;
@@ -0,0 +1,8 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ export function mkdirp(filePath) {
4
+ const dir = dirname(filePath);
5
+ if (!existsSync(dir)) {
6
+ mkdirSync(dir, { recursive: true });
7
+ }
8
+ }
@@ -0,0 +1,34 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { InterceptRecord } from '../intercept/record.js';
3
+ export interface RecordFilter {
4
+ endpointId?: string;
5
+ method?: string;
6
+ status?: number;
7
+ since?: string;
8
+ until?: string;
9
+ path?: string;
10
+ limit?: number;
11
+ offset?: number;
12
+ }
13
+ export declare class RecordStore {
14
+ private db;
15
+ constructor(db: Database.Database);
16
+ insert(record: Omit<InterceptRecord, 'id' | 'createdAt'>): InterceptRecord;
17
+ findAll(filter?: RecordFilter): InterceptRecord[];
18
+ findById(id: string): InterceptRecord | undefined;
19
+ getStats(): {
20
+ totalEndpoints: number;
21
+ totalRecords: number;
22
+ topEndpoints: {
23
+ path: string;
24
+ method: string;
25
+ count: number;
26
+ }[];
27
+ slowestEndpoints: {
28
+ path: string;
29
+ method: string;
30
+ avgDuration: number;
31
+ }[];
32
+ errorCount: number;
33
+ };
34
+ }
@@ -0,0 +1,92 @@
1
+ import { v4 as uuid } from 'uuid';
2
+ export class RecordStore {
3
+ db;
4
+ constructor(db) {
5
+ this.db = db;
6
+ }
7
+ insert(record) {
8
+ const id = uuid();
9
+ const now = new Date().toISOString();
10
+ this.db
11
+ .prepare(`INSERT INTO records (id, endpoint_id, method, path, status, request_headers, request_body, response_headers, response_body, duration_ms, created_at)
12
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
13
+ .run(id, record.endpointId, record.method, record.path, record.status, record.requestHeaders ?? null, record.requestBody ?? null, record.responseHeaders ?? null, record.responseBody ?? null, record.durationMs, now);
14
+ return { id, ...record, createdAt: now };
15
+ }
16
+ findAll(filter) {
17
+ let sql = 'SELECT * FROM records';
18
+ const conditions = [];
19
+ const params = [];
20
+ if (filter?.endpointId) {
21
+ conditions.push('endpoint_id = ?');
22
+ params.push(filter.endpointId);
23
+ }
24
+ if (filter?.method) {
25
+ conditions.push('method = ?');
26
+ params.push(filter.method);
27
+ }
28
+ if (filter?.status) {
29
+ conditions.push('status = ?');
30
+ params.push(filter.status);
31
+ }
32
+ if (filter?.since) {
33
+ conditions.push('created_at >= ?');
34
+ params.push(filter.since);
35
+ }
36
+ if (filter?.until) {
37
+ conditions.push('created_at <= ?');
38
+ params.push(filter.until);
39
+ }
40
+ if (filter?.path) {
41
+ conditions.push('path LIKE ?');
42
+ params.push(`%${filter.path}%`);
43
+ }
44
+ if (conditions.length > 0) {
45
+ sql += ' WHERE ' + conditions.join(' AND ');
46
+ }
47
+ sql += ' ORDER BY created_at DESC';
48
+ if (filter?.limit) {
49
+ sql += ' LIMIT ?';
50
+ params.push(filter.limit);
51
+ }
52
+ if (filter?.offset) {
53
+ sql += ' OFFSET ?';
54
+ params.push(filter.offset);
55
+ }
56
+ return this.db.prepare(sql).all(...params);
57
+ }
58
+ findById(id) {
59
+ return this.db
60
+ .prepare('SELECT * FROM records WHERE id = ?')
61
+ .get(id);
62
+ }
63
+ getStats() {
64
+ const totalEndpoints = this.db.prepare('SELECT COUNT(*) as cnt FROM endpoints').get().cnt;
65
+ const totalRecords = this.db.prepare('SELECT COUNT(*) as cnt FROM records').get().cnt;
66
+ const topEndpoints = this.db
67
+ .prepare(`SELECT path, method, COUNT(*) as count
68
+ FROM records
69
+ GROUP BY method, path
70
+ ORDER BY count DESC
71
+ LIMIT 10`)
72
+ .all();
73
+ const slowestEndpoints = this.db
74
+ .prepare(`SELECT path, method, AVG(duration_ms) as avgDuration
75
+ FROM records
76
+ WHERE duration_ms IS NOT NULL
77
+ GROUP BY method, path
78
+ ORDER BY avgDuration DESC
79
+ LIMIT 10`)
80
+ .all();
81
+ const errorCount = this.db
82
+ .prepare('SELECT COUNT(*) as cnt FROM records WHERE status >= 400')
83
+ .get().cnt;
84
+ return {
85
+ totalEndpoints,
86
+ totalRecords,
87
+ topEndpoints,
88
+ slowestEndpoints,
89
+ errorCount,
90
+ };
91
+ }
92
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@gerbaudo/cli",
3
+ "version": "0.1.0",
4
+ "description": "Local CLI tool for backend API instrumentation",
5
+ "type": "module",
6
+ "bin": {
7
+ "gerbaudo": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "package.json",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "dev": "tsx src/index.ts",
17
+ "start": "node dist/index.js",
18
+ "prepare": "npm run build",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "dependencies": {
26
+ "better-sqlite3": "^11.0.0",
27
+ "commander": "^12.0.0",
28
+ "express": "^4.21.0",
29
+ "uuid": "^10.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/better-sqlite3": "^7.6.0",
33
+ "@types/express": "^4.17.0",
34
+ "@types/node": "^22.0.0",
35
+ "@types/uuid": "^10.0.0",
36
+ "tsx": "^4.0.0",
37
+ "typescript": "^5.5.0",
38
+ "vitest": "^4.1.9"
39
+ },
40
+ "license": "MIT"
41
+ }