Skip to content

Commit d6adfb1

Browse files
authored
fix: Restore client parity with server (#75, #76, #77) (#78)
- fix(vscode #75): escape nested template literals in endpointTesterPanel so the webview HTML literal no longer desyncs the parser; build @flapi/shared automatically (shared prepare + extension prebuild) so EndpointConfig types resolve. Extension builds and typechecks again (broken since 2025-11-02). - feat(cli #76): add commands for server capabilities that had no CLI surface: health, config env, config filesystem, endpoints parameters, cache audit [path], cache gc. - fix(cli #77): FlapiApiClient.testEndpoint targets the real /template/test route instead of the non-existent /test (would 404). - docs: add CLIENT_PARITY_AUDIT.md documenting findings and resolution.
1 parent 5f62437 commit d6adfb1

11 files changed

Lines changed: 418 additions & 13 deletions

File tree

cli/shared/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"types": "dist/index.d.ts",
77
"scripts": {
88
"build": "tsup src/index.ts --format esm,cjs --dts",
9-
"dev": "tsup src/index.ts --format esm,cjs --dts --watch"
9+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
10+
"prepare": "npm run build"
1011
},
1112
"dependencies": {
1213
"axios": "^1.7.7",

cli/shared/src/apiClient.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,12 +257,14 @@ export class FlapiApiClient {
257257
}
258258

259259
/**
260-
* Test an endpoint with given parameters
260+
* Test an endpoint with given parameters.
261+
* Targets the server's `/template/test` route (the only test route that exists;
262+
* see src/config_service.cpp).
261263
*/
262264
async testEndpoint(pathOrName: string, parameters: Record<string, any>): Promise<any> {
263265
const slug = pathToSlug(pathOrName);
264266
const response = await this.client.post(
265-
`/api/v1/_config/endpoints/${slug}/test`,
267+
`/api/v1/_config/endpoints/${slug}/template/test`,
266268
{ parameters }
267269
);
268270
return response.data;

cli/src/commands/cache/index.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,4 +275,51 @@ export function registerCacheCommands(program: Command, ctx: CliContext) {
275275
process.exitCode = 1;
276276
}
277277
});
278+
279+
cache
280+
.command('gc <path>')
281+
.description('Run garbage collection on cache snapshots (DuckLake)')
282+
.action(async (path: string) => {
283+
const spinner = Console.spinner(`Running cache GC for ${path}...`);
284+
try {
285+
const endpointUrl = buildEndpointUrl(path, 'cache/gc');
286+
const response = await ctx.client.post(endpointUrl, {});
287+
spinner.succeed(chalk.green(`✓ Cache GC for ${path} completed`));
288+
289+
if (ctx.config.output !== 'json') {
290+
Console.info(chalk.cyan(`\n🧹 Cache GC: ${path}`));
291+
Console.info(chalk.gray('═'.repeat(60)));
292+
}
293+
renderJson(response.data, ctx.config.jsonStyle);
294+
} catch (error) {
295+
spinner.fail(chalk.red(`✗ Failed to run cache GC for ${path}`));
296+
handleError(error, ctx.config);
297+
process.exitCode = 1;
298+
}
299+
});
300+
301+
cache
302+
.command('audit [path]')
303+
.description('Get cache sync audit log (for one endpoint, or all endpoints if no path given)')
304+
.action(async (path?: string) => {
305+
const target = path ? `for ${path}` : '(all endpoints)';
306+
const spinner = Console.spinner(`Fetching cache audit log ${target}...`);
307+
try {
308+
const url = path
309+
? buildEndpointUrl(path, 'cache/audit')
310+
: '/api/v1/_config/cache/audit';
311+
const response = await ctx.client.get(url);
312+
spinner.succeed(chalk.green(`✓ Cache audit log ${target} retrieved`));
313+
314+
if (ctx.config.output !== 'json') {
315+
Console.info(chalk.cyan(`\n📋 Cache Audit ${target}`));
316+
Console.info(chalk.gray('═'.repeat(60)));
317+
}
318+
renderJson(response.data, ctx.config.jsonStyle);
319+
} catch (error) {
320+
spinner.fail(chalk.red(`✗ Failed to fetch cache audit log ${target}`));
321+
handleError(error, ctx.config);
322+
process.exitCode = 1;
323+
}
324+
});
278325
}

cli/src/commands/config/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import type { CliContext } from '../../lib/types';
33
import { registerConfigCommand } from './show';
44
import { registerValidateCommand } from './validate';
55
import { registerLogLevelCommands } from './log-level';
6+
import { registerInfoCommands } from './info';
67

78
export function registerConfigCommands(program: Command, ctx: CliContext) {
89
const configCmd = registerConfigCommand(program, ctx);
910
registerValidateCommand(configCmd, ctx);
1011
registerLogLevelCommands(configCmd, ctx);
12+
registerInfoCommands(configCmd, ctx);
1113
}
1214

cli/src/commands/config/info.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { Command } from 'commander';
2+
import type { CliContext } from '../../lib/types';
3+
import { Console } from '../../lib/console';
4+
import { handleError } from '../../lib/errors';
5+
import { renderJson } from '../../lib/render';
6+
import chalk from 'chalk';
7+
8+
export function registerInfoCommands(config: Command, ctx: CliContext) {
9+
config
10+
.command('env')
11+
.description('List whitelisted environment variables and their availability')
12+
.action(async () => {
13+
let spinner;
14+
if (ctx.config.output !== 'json') {
15+
spinner = Console.spinner('Fetching environment variables...');
16+
}
17+
try {
18+
const response = await ctx.client.get('/api/v1/_config/environment-variables');
19+
if (spinner) {
20+
spinner.succeed(chalk.green('✓ Environment variables retrieved'));
21+
}
22+
const data = response.data;
23+
24+
if (ctx.config.output === 'json') {
25+
renderJson(data, ctx.config.jsonStyle);
26+
} else {
27+
Console.info(chalk.cyan('\n🔐 Environment Variables'));
28+
Console.info(chalk.gray('═'.repeat(60)));
29+
const vars = Array.isArray(data?.variables) ? data.variables : [];
30+
if (vars.length === 0) {
31+
Console.info(chalk.gray('No environment variables configured'));
32+
} else {
33+
vars.forEach((v: any) => {
34+
const status = v.available ? chalk.green('available') : chalk.yellow('not set');
35+
Console.info(chalk.bold.blue(v.name) + ' ' + status);
36+
});
37+
}
38+
}
39+
} catch (error) {
40+
if (spinner) {
41+
spinner.fail(chalk.red('✗ Failed to fetch environment variables'));
42+
}
43+
handleError(error, ctx.config);
44+
process.exitCode = 1;
45+
}
46+
});
47+
48+
config
49+
.command('filesystem')
50+
.description('Show the project filesystem structure')
51+
.action(async () => {
52+
let spinner;
53+
if (ctx.config.output !== 'json') {
54+
spinner = Console.spinner('Fetching filesystem structure...');
55+
}
56+
try {
57+
const response = await ctx.client.get('/api/v1/_config/filesystem');
58+
if (spinner) {
59+
spinner.succeed(chalk.green('✓ Filesystem structure retrieved'));
60+
}
61+
const data = response.data;
62+
63+
if (ctx.config.output !== 'json') {
64+
Console.info(chalk.cyan('\n📁 Project Filesystem'));
65+
Console.info(chalk.gray('═'.repeat(60)));
66+
}
67+
renderJson(data, ctx.config.jsonStyle);
68+
} catch (error) {
69+
if (spinner) {
70+
spinner.fail(chalk.red('✗ Failed to fetch filesystem structure'));
71+
}
72+
handleError(error, ctx.config);
73+
process.exitCode = 1;
74+
}
75+
});
76+
}

cli/src/commands/endpoints/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,43 @@ export function registerEndpointCommands(program: Command, ctx: CliContext) {
6363
}
6464
});
6565

66+
endpoints
67+
.command('parameters <path>')
68+
.description('Get parameter definitions for an endpoint')
69+
.option('--output <format>', 'Output format: json or table')
70+
.action(async (path: string, options: { output?: 'json' | 'table' }) => {
71+
const spinner = Console.spinner(`Fetching parameters for ${path}...`);
72+
try {
73+
const endpointUrl = buildEndpointUrl(path, 'parameters');
74+
const response = await ctx.client.get(endpointUrl);
75+
spinner.succeed(chalk.green(`✓ Parameters for ${path} retrieved`));
76+
const data = response.data;
77+
const resolved = applyOutputOverride(ctx.config, options.output);
78+
if (resolved.output === 'json') {
79+
renderJson(data, resolved.jsonStyle);
80+
} else {
81+
Console.info(chalk.cyan(`\n🔧 Parameters: ${path}`));
82+
Console.info(chalk.gray('═'.repeat(60)));
83+
const params = Array.isArray(data?.parameters) ? data.parameters : [];
84+
if (params.length === 0) {
85+
Console.info(chalk.gray('No parameters defined'));
86+
} else {
87+
params.forEach((p: any) => {
88+
const req = p.required ? chalk.red('required') : chalk.gray('optional');
89+
Console.info(chalk.bold.blue(p.name) + chalk.gray(` (in: ${p.in})`) + ' ' + req);
90+
if (p.description) {
91+
Console.info(chalk.gray(` ${p.description}`));
92+
}
93+
});
94+
}
95+
}
96+
} catch (error) {
97+
spinner.fail(chalk.red(`✗ Failed to fetch parameters for ${path}`));
98+
handleError(error, ctx.config);
99+
process.exitCode = 1;
100+
}
101+
});
102+
66103
withPayloadOptions(
67104
endpoints
68105
.command('create')

cli/src/commands/health.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Command } from 'commander';
2+
import type { CliContext } from '../lib/types';
3+
import { Console } from '../lib/console';
4+
import { handleError } from '../lib/errors';
5+
import { renderJson } from '../lib/render';
6+
import chalk from 'chalk';
7+
8+
export function registerHealthCommand(program: Command, ctx: CliContext) {
9+
program
10+
.command('health')
11+
.description('Get server health (database, endpoints, Arrow IPC, VFS, credential status)')
12+
.action(async () => {
13+
let spinner;
14+
if (ctx.config.output !== 'json') {
15+
spinner = Console.spinner('Fetching server health...');
16+
}
17+
try {
18+
const response = await ctx.client.get('/api/v1/_config/health');
19+
if (spinner) {
20+
spinner.succeed(chalk.green('✓ Server health retrieved'));
21+
}
22+
const data = response.data;
23+
24+
if (ctx.config.output === 'json') {
25+
renderJson(data, ctx.config.jsonStyle);
26+
} else {
27+
Console.info(chalk.cyan('\n❤️ Server Health'));
28+
Console.info(chalk.gray('═'.repeat(60)));
29+
const status = data?.status ?? 'unknown';
30+
const healthy = ['ok', 'healthy', 'up'].includes(String(status).toLowerCase());
31+
Console.info(
32+
chalk.bold.blue('Status: ') + (healthy ? chalk.green(status) : chalk.yellow(status)),
33+
);
34+
renderJson(data, ctx.config.jsonStyle);
35+
}
36+
} catch (error) {
37+
if (spinner) {
38+
spinner.fail(chalk.red('✗ Failed to fetch server health'));
39+
}
40+
handleError(error, ctx.config);
41+
process.exitCode = 1;
42+
}
43+
});
44+
}

cli/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { loadConfig } from './lib/config';
44
import { createApiClient } from './lib/http';
55
import { handleError } from './lib/errors';
66
import { registerPingCommand } from './commands/ping';
7+
import { registerHealthCommand } from './commands/health';
78
import { registerConfigCommands } from './commands/config';
89
import { registerProjectCommands } from './commands/project';
910
import { registerEndpointCommands } from './commands/endpoints';
@@ -55,6 +56,7 @@ export async function createCli(argv = process.argv) {
5556
};
5657

5758
registerPingCommand(program, ctx);
59+
registerHealthCommand(program, ctx);
5860
registerConfigCommands(program, ctx);
5961
registerProjectCommands(program, ctx);
6062
registerEndpointCommands(program, ctx);

cli/vscode-extension/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,8 @@
367367
}
368368
},
369369
"scripts": {
370+
"build:shared": "npm --prefix ../shared run build",
371+
"prebuild": "npm run build:shared",
370372
"build": "webpack --mode production && npm run build:webview",
371373
"dev": "webpack --mode development --watch",
372374
"build:webview": "webpack --config webview/webpack.config.js --mode production",

cli/vscode-extension/src/webview/endpointTesterPanel.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1942,27 +1942,27 @@ export class EndpointTesterPanel {
19421942
let summaryHTML = '<div style="font-weight: bold; margin-bottom: 8px; color: var(--vscode-activityBarBadge-background);">📝 Write Operation Result</div>';
19431943
19441944
if (parsedData.rows_affected !== undefined) {
1945-
summaryHTML += `<div style="margin: 4px 0;"><strong>Rows Affected:</strong> <span style="color: var(--vscode-textLink-foreground);">${parsedData.rows_affected}</span></div>`;
1945+
summaryHTML += \`<div style="margin: 4px 0;"><strong>Rows Affected:</strong> <span style="color: var(--vscode-textLink-foreground);">\${parsedData.rows_affected}</span></div>\`;
19461946
}
1947-
1947+
19481948
if (parsedData.last_insert_id !== undefined) {
1949-
summaryHTML += `<div style="margin: 4px 0;"><strong>Last Insert ID:</strong> <span style="color: var(--vscode-textLink-foreground);">${parsedData.last_insert_id}</span></div>`;
1949+
summaryHTML += \`<div style="margin: 4px 0;"><strong>Last Insert ID:</strong> <span style="color: var(--vscode-textLink-foreground);">\${parsedData.last_insert_id}</span></div>\`;
19501950
}
1951-
1951+
19521952
if (parsedData.returned_data && Array.isArray(parsedData.returned_data) && parsedData.returned_data.length > 0) {
1953-
summaryHTML += `<div style="margin: 4px 0;"><strong>Returned Data:</strong> ${parsedData.returned_data.length} record(s)</div>`;
1953+
summaryHTML += \`<div style="margin: 4px 0;"><strong>Returned Data:</strong> \${parsedData.returned_data.length} record(s)</div>\`;
19541954
}
1955-
1955+
19561956
if (parsedData.errors && Array.isArray(parsedData.errors) && parsedData.errors.length > 0) {
1957-
summaryHTML += `<div style="margin: 8px 0; padding: 8px; background: var(--vscode-inputValidation-errorBackground); border-radius: 4px;">`;
1957+
summaryHTML += \`<div style="margin: 8px 0; padding: 8px; background: var(--vscode-inputValidation-errorBackground); border-radius: 4px;">\`;
19581958
summaryHTML += '<div style="font-weight: bold; color: var(--vscode-errorForeground); margin-bottom: 4px;">⚠️ Validation Errors:</div>';
19591959
parsedData.errors.forEach(error => {
1960-
summaryHTML += `<div style="margin: 2px 0; color: var(--vscode-errorForeground);"> ${error.field || 'Unknown'}: ${error.message || 'Error'}</div>`;
1960+
summaryHTML += \`<div style="margin: 2px 0; color: var(--vscode-errorForeground);">• \${error.field || 'Unknown'}: \${error.message || 'Error'}</div>\`;
19611961
});
19621962
summaryHTML += '</div>';
19631963
} else if (parsedData.error) {
1964-
summaryHTML += `<div style="margin: 8px 0; padding: 8px; background: var(--vscode-inputValidation-errorBackground); border-radius: 4px;">`;
1965-
summaryHTML += `<div style="color: var(--vscode-errorForeground);">⚠️ Error: ${parsedData.error.field || 'Unknown'}: ${parsedData.error.message || 'Error'}</div>`;
1964+
summaryHTML += \`<div style="margin: 8px 0; padding: 8px; background: var(--vscode-inputValidation-errorBackground); border-radius: 4px;">\`;
1965+
summaryHTML += \`<div style="color: var(--vscode-errorForeground);">⚠️ Error: \${parsedData.error.field || 'Unknown'}: \${parsedData.error.message || 'Error'}</div>\`;
19661966
summaryHTML += '</div>';
19671967
}
19681968

0 commit comments

Comments
 (0)