diff --git a/cli/src/commands/cache/index.ts b/cli/src/commands/cache/index.ts index 260c7151..10b3e08b 100644 --- a/cli/src/commands/cache/index.ts +++ b/cli/src/commands/cache/index.ts @@ -71,9 +71,30 @@ export function registerCacheCommands(program: Command, ctx: CliContext) { if (typeof cacheConfig === 'object' && cacheConfig !== null) { const config = cacheConfig as Record; Console.info(chalk.bold.blue('Enabled: ') + chalk.white(config.enabled ? 'Yes' : 'No')); - Console.info(chalk.bold.blue('TTL: ') + chalk.white(config.ttl || 'N/A')); - Console.info(chalk.bold.blue('Max Size: ') + chalk.white(config.max_size || 'N/A')); - Console.info(chalk.bold.blue('Strategy: ') + chalk.white(config.strategy || 'N/A')); + Console.info(chalk.bold.blue('Table: ') + chalk.white(config.table || 'N/A')); + Console.info(chalk.bold.blue('Schema: ') + chalk.white(config.schema || 'N/A')); + Console.info(chalk.bold.blue('Schedule: ') + chalk.white(config.schedule || 'N/A')); + if (Array.isArray(config['primary-key'])) { + Console.info(chalk.bold.blue('Primary Key: ') + chalk.white(config['primary-key'].join(', '))); + } + if (config.cursor) { + Console.info( + chalk.bold.blue('Cursor: ') + + chalk.white(`${config.cursor.column || 'N/A'} (${config.cursor.type || 'unknown'})`) + ); + } + if (config['rollback-window']) { + Console.info(chalk.bold.blue('Rollback Window: ') + chalk.white(config['rollback-window'])); + } + if (config.retention) { + Console.info(chalk.bold.blue('Retention: ') + chalk.white(JSON.stringify(config.retention))); + } + if (config['delete-handling']) { + Console.info(chalk.bold.blue('Delete Handling: ') + chalk.white(config['delete-handling'])); + } + if (config['template-file']) { + Console.info(chalk.bold.blue('Template File: ') + chalk.white(config['template-file'])); + } } else { Console.info(chalk.gray('No cache configuration found')); } @@ -89,16 +110,32 @@ export function registerCacheCommands(program: Command, ctx: CliContext) { .command('update ') .description('Update cache configuration') .option('-e, --enabled ', 'Enable/disable caching (true/false)') - .option('-t, --ttl ', 'Cache TTL in seconds') - .option('-s, --max-size ', 'Maximum cache size') - .option('--strategy ', 'Cache strategy (lru, ttl, etc.)') + .option('--table ', 'Cache table name') + .option('--schema ', 'Cache schema name') + .option('--schedule ', 'Cache refresh schedule or interval string') + .option('--primary-key ', 'Primary key columns (space separated)') + .option('--cursor-column ', 'Cursor column name') + .option('--cursor-type ', 'Cursor data type') + .option('--rollback-window ', 'Rollback window (e.g. 6h)') + .option('--retention-keep ', 'Number of snapshots to keep') + .option('--retention-age ', 'Max snapshot age (e.g. 7d)') + .option('--delete-handling ', 'Delete handling strategy') + .option('--template-file ', 'Cache template SQL file') .option('-f, --file ', 'JSON file containing cache configuration') .option('--stdin', 'Read cache configuration from stdin') .action(async (path: string, options: { enabled?: string; - ttl?: string; - maxSize?: string; - strategy?: string; + table?: string; + schema?: string; + schedule?: string; + primaryKey?: string[]; + cursorColumn?: string; + cursorType?: string; + rollbackWindow?: string; + retentionKeep?: string; + retentionAge?: string; + deleteHandling?: string; + templateFile?: string; file?: string; stdin?: boolean; }) => { @@ -138,14 +175,46 @@ export function registerCacheCommands(program: Command, ctx: CliContext) { if (options.enabled !== undefined) { cacheConfig.enabled = options.enabled === 'true'; } - if (options.ttl !== undefined) { - cacheConfig.ttl = parseInt(options.ttl); + if (options.table) { + cacheConfig.table = options.table; + } + if (options.schema) { + cacheConfig.schema = options.schema; + } + if (options.schedule) { + cacheConfig.schedule = options.schedule; + } + if (options.primaryKey && options.primaryKey.length > 0) { + cacheConfig['primary-key'] = options.primaryKey; + } + if ((options.cursorColumn && !options.cursorType) || (!options.cursorColumn && options.cursorType)) { + Console.error('Both --cursor-column and --cursor-type must be provided together'); + process.exitCode = 1; + return; + } + if (options.cursorColumn && options.cursorType) { + cacheConfig.cursor = { + column: options.cursorColumn, + type: options.cursorType, + }; + } + if (options.rollbackWindow) { + cacheConfig['rollback-window'] = options.rollbackWindow; + } + if (options.retentionKeep || options.retentionAge) { + cacheConfig.retention = {}; + if (options.retentionKeep) { + cacheConfig.retention['keep-last-snapshots'] = Number(options.retentionKeep); + } + if (options.retentionAge) { + cacheConfig.retention['max-snapshot-age'] = options.retentionAge; + } } - if (options.maxSize !== undefined) { - cacheConfig.max_size = parseInt(options.maxSize); + if (options.deleteHandling) { + cacheConfig['delete-handling'] = options.deleteHandling; } - if (options.strategy !== undefined) { - cacheConfig.strategy = options.strategy; + if (options.templateFile) { + cacheConfig['template-file'] = options.templateFile; } if (Object.keys(cacheConfig).length === 0) { @@ -176,14 +245,14 @@ export function registerCacheCommands(program: Command, ctx: CliContext) { const endpointUrl = buildEndpointUrl(path, 'cache/template'); const response = await ctx.client.get(endpointUrl); spinner.succeed(chalk.green(`āœ“ Cache template for ${path} retrieved`)); - const template = response.data; + const template = typeof response.data === 'string' ? response.data : response.data?.template; if (ctx.config.output === 'json') { - renderJson(template, ctx.config.jsonStyle); + renderJson({ template: template ?? '' }, ctx.config.jsonStyle); } else { Console.info(chalk.cyan(`\nšŸ“„ Cache Template: ${path}`)); Console.info(chalk.gray('═'.repeat(60))); - Console.info(chalk.white(template)); + Console.info(chalk.white(template ?? '')); } } catch (error) { spinner.fail(chalk.red(`āœ— Failed to fetch cache template for ${path}`)); @@ -256,18 +325,11 @@ export function registerCacheCommands(program: Command, ctx: CliContext) { const result = response.data; if (ctx.config.output === 'json') { - renderJson(result, ctx.config.jsonStyle); + renderJson(result || { success: true }, ctx.config.jsonStyle); } else { Console.info(chalk.cyan(`\nšŸ”„ Cache Refresh: ${path}`)); Console.info(chalk.gray('═'.repeat(60))); Console.info(chalk.green('āœ“ Cache refreshed successfully')); - - if (result.entries_cleared !== undefined) { - Console.info(chalk.blue(`Entries cleared: ${result.entries_cleared}`)); - } - if (result.cache_size !== undefined) { - Console.info(chalk.blue(`New cache size: ${result.cache_size}`)); - } } } catch (error) { spinner.fail(chalk.red(`āœ— Failed to refresh cache for ${path}`)); @@ -275,4 +337,53 @@ export function registerCacheCommands(program: Command, ctx: CliContext) { process.exitCode = 1; } }); + + cache + .command('gc ') + .description('Run DuckLake garbage collection for endpoint cache') + .action(async (path: string) => { + const spinner = Console.spinner(`Running cache GC for ${path}...`); + try { + const endpointUrl = buildEndpointUrl(path, 'cache/gc'); + await ctx.client.post(endpointUrl); + spinner.succeed(chalk.green(`āœ“ Cache GC triggered for ${path}`)); + } catch (error) { + spinner.fail(chalk.red(`āœ— Failed to run cache GC for ${path}`)); + handleError(error, ctx.config); + process.exitCode = 1; + } + }); + + cache + .command('audit ') + .description('Show DuckLake audit log entries for an endpoint') + .action(async (path: string) => { + const spinner = Console.spinner(`Fetching cache audit for ${path}...`); + try { + const endpointUrl = buildEndpointUrl(path, 'cache/audit'); + const response = await ctx.client.get(endpointUrl); + spinner.succeed(chalk.green(`āœ“ Cache audit for ${path} retrieved`)); + renderJson(response.data, ctx.config.jsonStyle); + } catch (error) { + spinner.fail(chalk.red(`āœ— Failed to fetch cache audit for ${path}`)); + handleError(error, ctx.config); + process.exitCode = 1; + } + }); + + cache + .command('audit-all') + .description('Show DuckLake audit log across all caches') + .action(async () => { + const spinner = Console.spinner('Fetching cache audit log...'); + try { + const response = await ctx.client.get('/api/v1/_config/cache/audit'); + spinner.succeed(chalk.green('āœ“ Cache audit log retrieved')); + renderJson(response.data, ctx.config.jsonStyle); + } catch (error) { + spinner.fail(chalk.red('āœ— Failed to fetch cache audit log')); + handleError(error, ctx.config); + process.exitCode = 1; + } + }); } diff --git a/cli/src/commands/config/environment.ts b/cli/src/commands/config/environment.ts new file mode 100644 index 00000000..767c128d --- /dev/null +++ b/cli/src/commands/config/environment.ts @@ -0,0 +1,47 @@ +import type { Command } from 'commander'; +import type { CliContext } from '../../lib/types'; +import { Console } from '../../lib/console'; +import { handleError } from '../../lib/errors'; +import { renderJson } from '../../lib/render'; +import chalk from 'chalk'; + +export function registerEnvironmentCommand(config: Command, ctx: CliContext) { + config + .command('env') + .description('Show environment variables exposed by the server') + .action(async () => { + const spinner = Console.spinner('Fetching environment variables...'); + try { + const response = await ctx.client.get('/api/v1/_config/environment-variables'); + spinner.succeed(chalk.green('āœ“ Environment variables retrieved')); + const payload = response.data; + + if (ctx.config.output === 'json') { + renderJson(payload, ctx.config.jsonStyle); + return; + } + + Console.info(chalk.cyan('\n🌿 Environment Variables')); + Console.info(chalk.gray('═'.repeat(60))); + + const variables = Array.isArray(payload?.variables) ? payload.variables : []; + if (variables.length === 0) { + Console.info(chalk.gray('No environment variables are configured.')); + return; + } + + variables.forEach((variable: any) => { + const available = variable.available ? chalk.green('available') : chalk.red('missing'); + const value = variable.value ?? ''; + Console.info(`${chalk.bold(variable.name)} - ${available}`); + if (value) { + Console.info(chalk.gray(` Value: ${value}`)); + } + }); + } catch (error) { + spinner.fail(chalk.red('āœ— Failed to fetch environment variables')); + handleError(error, ctx.config); + process.exitCode = 1; + } + }); +} diff --git a/cli/src/commands/config/filesystem.ts b/cli/src/commands/config/filesystem.ts new file mode 100644 index 00000000..afb44745 --- /dev/null +++ b/cli/src/commands/config/filesystem.ts @@ -0,0 +1,75 @@ +import type { Command } from 'commander'; +import type { CliContext } from '../../lib/types'; +import { Console } from '../../lib/console'; +import { handleError } from '../../lib/errors'; +import { renderJson } from '../../lib/render'; +import chalk from 'chalk'; + +interface FilesystemNode { + name: string; + type: 'file' | 'directory'; + path: string; + children?: FilesystemNode[]; + extension?: string; + yaml_type?: string; +} + +function renderTree(nodes: FilesystemNode[], prefix = '') { + nodes.forEach((node, index) => { + const isLast = index === nodes.length - 1; + const branch = isLast ? '└─' : 'ā”œā”€'; + const nextPrefix = prefix + (isLast ? ' ' : '│ '); + + const label = node.type === 'directory' + ? chalk.blue(`[dir] ${node.name}`) + : chalk.white(node.name); + + Console.info(`${prefix}${branch} ${label}`); + + if (node.type === 'file' && node.yaml_type) { + Console.info(`${nextPrefix}${chalk.gray(`(${node.yaml_type})`)}`); + } + + if (node.children && node.children.length > 0) { + renderTree(node.children, nextPrefix); + } + }); +} + +export function registerFilesystemCommand(config: Command, ctx: CliContext) { + config + .command('filesystem') + .description('Inspect server-side filesystem tree for templates') + .action(async () => { + const spinner = Console.spinner('Fetching filesystem structure...'); + try { + const response = await ctx.client.get('/api/v1/_config/filesystem'); + spinner.succeed(chalk.green('āœ“ Filesystem data retrieved')); + const payload = response.data; + + if (ctx.config.output === 'json') { + renderJson(payload, ctx.config.jsonStyle); + return; + } + + Console.info(chalk.cyan('\nšŸ“ flapi Filesystem')); + Console.info(chalk.gray('═'.repeat(60))); + Console.info(`${chalk.bold('Base Path:')} ${payload?.base_path || 'N/A'}`); + Console.info(`${chalk.bold('Templates Path:')} ${payload?.template_path || 'N/A'}`); + if (payload?.config_file) { + Console.info(`${chalk.bold('Config File:')} ${payload.config_file} (${payload?.config_file_exists ? 'found' : 'missing'})`); + } + Console.info(''); + + if (Array.isArray(payload?.tree) && payload.tree.length > 0) { + renderTree(payload.tree); + } else { + Console.info(chalk.gray('No files discovered under the template path.')); + } + } catch (error) { + spinner.fail(chalk.red('āœ— Failed to fetch filesystem data')); + handleError(error, ctx.config); + process.exitCode = 1; + } + }); +} diff --git a/cli/src/commands/config/index.ts b/cli/src/commands/config/index.ts index be789926..2d821214 100644 --- a/cli/src/commands/config/index.ts +++ b/cli/src/commands/config/index.ts @@ -3,10 +3,13 @@ import type { CliContext } from '../../lib/types'; import { registerConfigCommand } from './show'; import { registerValidateCommand } from './validate'; import { registerLogLevelCommands } from './log-level'; +import { registerEnvironmentCommand } from './environment'; +import { registerFilesystemCommand } from './filesystem'; export function registerConfigCommands(program: Command, ctx: CliContext) { const configCmd = registerConfigCommand(program, ctx); registerValidateCommand(configCmd, ctx); registerLogLevelCommands(configCmd, ctx); + registerEnvironmentCommand(configCmd, ctx); + registerFilesystemCommand(configCmd, ctx); } - diff --git a/cli/src/commands/endpoints/index.ts b/cli/src/commands/endpoints/index.ts index e09f7854..bd732262 100644 --- a/cli/src/commands/endpoints/index.ts +++ b/cli/src/commands/endpoints/index.ts @@ -196,6 +196,42 @@ export function registerEndpointCommands(program: Command, ctx: CliContext) { } }); + endpoints + .command('parameters ') + .description('Show parameter definitions for an endpoint') + .action(async (path: string) => { + const spinner = Console.spinner(`Fetching parameters for ${path}...`); + try { + const endpointUrl = buildEndpointUrl(path, 'parameters'); + const response = await ctx.client.get(endpointUrl); + spinner.succeed(chalk.green(`āœ“ Parameters for ${path} retrieved`)); + if (ctx.config.output === 'json') { + renderJson(response.data, ctx.config.jsonStyle); + } else if (Array.isArray(response.data?.parameters)) { + Console.info(chalk.cyan(`\nšŸ“„ Parameters: ${path}`)); + Console.info(chalk.gray('═'.repeat(60))); + response.data.parameters.forEach((param: any) => { + Console.info( + `${chalk.bold(param.name)} (${param.in || param.location || 'unknown'})` + + (param.required ? chalk.red(' *') : '') + ); + if (param.description) { + Console.info(chalk.gray(` ${param.description}`)); + } + if (param.validators) { + Console.info(chalk.gray(` Validators: ${JSON.stringify(param.validators)}`)); + } + }); + } else { + Console.info(chalk.gray('No parameter metadata available.')); + } + } catch (error) { + spinner.fail(chalk.red(`āœ— Failed to fetch parameters for ${path}`)); + handleError(error, ctx.config); + process.exitCode = 1; + } + }); + // Register wizard command registerWizardCommand(endpoints, ctx); } @@ -203,4 +239,3 @@ export function registerEndpointCommands(program: Command, ctx: CliContext) { function ensurePayloadOptions() { // placeholder for shared payload validation hook if needed later } - diff --git a/cli/src/commands/templates/index.ts b/cli/src/commands/templates/index.ts index 39d2561b..8cebac94 100644 --- a/cli/src/commands/templates/index.ts +++ b/cli/src/commands/templates/index.ts @@ -63,14 +63,14 @@ export function registerTemplateCommands(program: Command, ctx: CliContext) { const endpointUrl = buildEndpointUrl(path, 'template'); const response = await ctx.client.get(endpointUrl); spinner.succeed(chalk.green(`āœ“ Template ${path} retrieved`)); - const template = response.data; + const template = typeof response.data === 'string' ? response.data : response.data?.template; if (ctx.config.output === 'json') { - renderJson(template, ctx.config.jsonStyle); + renderJson({ template: template ?? '' }, ctx.config.jsonStyle); } else { Console.info(chalk.cyan(`\nšŸ“„ Template: ${path}`)); Console.info(chalk.gray('═'.repeat(60))); - Console.info(chalk.white(template)); + Console.info(chalk.white(template ?? '')); } } catch (error) { spinner.fail(chalk.red(`āœ— Failed to fetch template ${path}`)); @@ -115,7 +115,7 @@ export function registerTemplateCommands(program: Command, ctx: CliContext) { try { const endpointUrl = buildEndpointUrl(path, 'template'); await ctx.client.put(endpointUrl, { - template: templateContent + template: templateContent, }); spinner.succeed(chalk.green(`āœ“ Template ${path} updated successfully`)); } catch (error) { @@ -157,7 +157,7 @@ export function registerTemplateCommands(program: Command, ctx: CliContext) { try { const endpointUrl = buildEndpointUrl(path, 'template/expand'); const response = await ctx.client.post(endpointUrl, { - parameters: params + parameters: params, }); spinner.succeed(chalk.green(`āœ“ Template ${path} expanded successfully`)); const result = response.data; @@ -167,7 +167,12 @@ export function registerTemplateCommands(program: Command, ctx: CliContext) { } else { Console.info(chalk.cyan(`\nšŸ“„ Expanded Template: ${path}`)); Console.info(chalk.gray('═'.repeat(60))); - Console.info(chalk.white(result.expanded || result)); + const expanded = result.expanded ?? result.expanded_sql ?? result.sql ?? ''; + Console.info(chalk.white(expanded)); + if (result.variables) { + Console.info(chalk.blue('\nVariables:')); + Console.info(chalk.white(JSON.stringify(result.variables, null, 2))); + } } } catch (error) { spinner.fail(chalk.red(`āœ— Failed to expand template ${path}`)); @@ -208,7 +213,7 @@ export function registerTemplateCommands(program: Command, ctx: CliContext) { try { const endpointUrl = buildEndpointUrl(path, 'template/test'); const response = await ctx.client.post(endpointUrl, { - parameters: params + parameters: params, }); spinner.succeed(chalk.green(`āœ“ Template ${path} test completed`)); const result = response.data; @@ -219,21 +224,24 @@ export function registerTemplateCommands(program: Command, ctx: CliContext) { Console.info(chalk.cyan(`\nšŸ” Template Test: ${path}`)); Console.info(chalk.gray('═'.repeat(60))); - if (result.valid) { - Console.success('Template syntax is valid'); - if (result.parameters) { - Console.info(chalk.blue('Required parameters:')); - Object.keys(result.parameters).forEach(param => { - Console.info(chalk.white(` - ${param}`)); - }); + if (result.success) { + Console.success('Template executed successfully'); + if (Array.isArray(result.rows)) { + Console.info(chalk.blue(`Rows returned: ${result.rows.length}`)); + if (result.rows.length > 0) { + Console.info(chalk.white(JSON.stringify(result.rows, null, 2))); + } } - } else { - Console.error('Template syntax is invalid'); - if (result.errors) { - result.errors.forEach((error: string) => { - Console.error(chalk.red(` - ${error}`)); + if (Array.isArray(result.columns)) { + Console.info(chalk.blue('Columns:')); + result.columns.forEach((column: string) => { + Console.info(chalk.white(` - ${column}`)); }); } + } else if (result.error) { + Console.error(`Template execution failed: ${result.error}`); + } else { + Console.error('Template execution failed'); } } } catch (error) { @@ -242,4 +250,36 @@ export function registerTemplateCommands(program: Command, ctx: CliContext) { process.exitCode = 1; } }); + templates + .command('find') + .description('List endpoints using a specific template file') + .requiredOption('-t, --template ', 'Template file path (relative to templates dir)') + .action(async (options: { template: string }) => { + const spinner = Console.spinner('Searching endpoints by template...'); + try { + const response = await ctx.client.post('/api/v1/_config/endpoints/by-template', { + template_path: options.template, + }); + spinner.succeed(chalk.green('āœ“ Template usage retrieved')); + if (ctx.config.output === 'json') { + renderJson(response.data, ctx.config.jsonStyle); + } else if (Array.isArray(response.data?.endpoints)) { + Console.info(chalk.cyan(`\nšŸ”Ž Endpoints referencing ${options.template}`)); + Console.info(chalk.gray('═'.repeat(60))); + response.data.endpoints.forEach((entry: any) => { + Console.info(`${chalk.bold(entry.url_path || entry.mcp_name || entry.config_file_path)} (${entry.type})`); + if (entry.config_file_path) { + Console.info(chalk.gray(` Config: ${entry.config_file_path}`)); + } + }); + Console.info(chalk.gray(`\nTotal: ${response.data.endpoints.length}`)); + } else { + Console.info(chalk.gray('No endpoints reference this template.')); + } + } catch (error) { + spinner.fail(chalk.red('āœ— Failed to search endpoints by template')); + handleError(error, ctx.config); + process.exitCode = 1; + } + }); } diff --git a/cli/src/lib/render.ts b/cli/src/lib/render.ts index cf9b86bf..09cfe948 100644 --- a/cli/src/lib/render.ts +++ b/cli/src/lib/render.ts @@ -296,11 +296,12 @@ export function renderCacheTable(caches: Record) { head: [ chalk.bold.cyan('Path'), chalk.bold.green('Enabled'), - chalk.bold.yellow('Refresh Time'), - chalk.bold.blue('Cache Table'), - chalk.bold.red('Cache Source') + chalk.bold.yellow('Schedule'), + chalk.bold.blue('Table'), + chalk.bold.magenta('Schema'), + chalk.bold.red('Template') ], - colWidths: [20, 10, 15, 20, 35], + colWidths: [20, 10, 15, 18, 18, 25], style: { head: [], border: [], @@ -315,18 +316,20 @@ export function renderCacheTable(caches: Record) { } const enabled = cache?.enabled === true ? 'āœ“' : 'āœ—'; - const refreshTime = cache?.refreshTime || 'N/A'; - const cacheTable = cache?.cacheTable || 'N/A'; - const cacheSource = cache?.cacheSource ? - (cache.cacheSource.length > 30 ? '...' + cache.cacheSource.slice(-27) : cache.cacheSource) : - 'N/A'; + const schedule = cache?.schedule || 'N/A'; + const cacheTable = cache?.table || cache?.cacheTable || 'N/A'; + const schema = cache?.schema || 'N/A'; + const templateFile = cache?.['template-file'] || cache?.templateFile || ''; + const templateDisplay = + templateFile && templateFile.length > 24 ? `...${templateFile.slice(-21)}` : templateFile || 'N/A'; table.push([ chalk.cyan(path), cache?.enabled === true ? chalk.green(enabled) : chalk.red(enabled), - chalk.yellow(String(refreshTime)), + chalk.yellow(String(schedule)), chalk.blue(String(cacheTable)), - chalk.gray(String(cacheSource)) + chalk.magenta(String(schema)), + chalk.gray(templateDisplay), ]); } @@ -436,4 +439,3 @@ export function renderTable(data: Record[]): void { Console.info(table.toString()); } - diff --git a/cli/test/setup/resetExitCode.ts b/cli/test/setup/resetExitCode.ts new file mode 100644 index 00000000..1eb3eb6d --- /dev/null +++ b/cli/test/setup/resetExitCode.ts @@ -0,0 +1,9 @@ +import { afterEach, afterAll, beforeAll } from 'vitest'; + +const reset = () => { + process.exitCode = 0; +}; + +beforeAll(reset); +afterEach(reset); +afterAll(reset); diff --git a/cli/test/unit/cache.spec.ts b/cli/test/unit/cache.spec.ts new file mode 100644 index 00000000..f39ed000 --- /dev/null +++ b/cli/test/unit/cache.spec.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Command } from 'commander'; +import { registerCacheCommands } from '../../src/commands/cache'; +import type { CliContext, FlapiiConfig } from '../../src/lib/types'; + +const spinner = { succeed: vi.fn(), fail: vi.fn(), stop: vi.fn(), text: '' }; + +vi.mock('../../src/lib/console', () => ({ + Console: { + spinner: () => spinner, + info: vi.fn(), + warn: vi.fn(), + success: vi.fn(), + color: vi.fn((_, str) => str), + }, +})); + +vi.mock('../../src/lib/render', () => ({ + renderJson: vi.fn(), + renderCacheTable: vi.fn(), +})); + +describe('cache commands', () => { + const mockClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + }; + + const config: FlapiiConfig = { + baseUrl: 'http://localhost:8080', + timeout: 10, + retries: 2, + verifyTls: true, + output: 'json', + jsonStyle: 'camel', + debugHttp: false, + quiet: false, + yes: false, + }; + + const ctx: CliContext = { + get config() { + return config; + }, + get client() { + return mockClient as any; + }, + }; + + beforeEach(() => { + mockClient.get.mockReset(); + mockClient.post.mockReset(); + mockClient.put.mockReset(); + }); + + it('retrieves cache template value from response wrapper', async () => { + mockClient.get.mockResolvedValue({ data: { template: 'select 1;' } }); + const program = new Command(); + program.exitOverride(); + registerCacheCommands(program, ctx); + + await program.parseAsync(['node', 'test', 'cache', 'template', '/foo']); + + expect(mockClient.get).toHaveBeenCalledWith('/api/v1/_config/endpoints/foo/cache/template'); + }); + + it('invokes cache gc endpoint', async () => { + mockClient.post.mockResolvedValue({ data: {} }); + const program = new Command(); + program.exitOverride(); + registerCacheCommands(program, ctx); + + await program.parseAsync(['node', 'test', 'cache', 'gc', '/foo']); + + expect(mockClient.post).toHaveBeenCalledWith('/api/v1/_config/endpoints/foo/cache/gc'); + }); + it('errors if cursor flag is incomplete', async () => { + const program = new Command(); + program.exitOverride(); + registerCacheCommands(program, ctx); + + await expect( + program.parseAsync(['node', 'test', 'cache', 'update', '/foo', '--cursor-column', 'updated_at']) + ).rejects.toThrow(); + expect(mockClient.put).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/test/unit/config-commands.spec.ts b/cli/test/unit/config-commands.spec.ts new file mode 100644 index 00000000..efabbf02 --- /dev/null +++ b/cli/test/unit/config-commands.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Command } from 'commander'; +import { registerConfigCommands } from '../../src/commands/config'; +import type { CliContext, FlapiiConfig } from '../../src/lib/types'; + +const spinner = { succeed: vi.fn(), fail: vi.fn(), stop: vi.fn(), text: '' }; + +vi.mock('../../src/lib/console', () => ({ + Console: { + spinner: () => spinner, + info: vi.fn(), + warn: vi.fn(), + color: vi.fn((_, str) => str), + }, +})); + +vi.mock('../../src/lib/render', () => ({ + renderConfig: vi.fn(), + renderJson: vi.fn(), +})); + +describe('config commands (env/filesystem)', () => { + const mockClient = { + get: vi.fn(), + put: vi.fn(), + }; + + const config: FlapiiConfig = { + baseUrl: 'http://localhost:8080', + timeout: 10, + retries: 2, + verifyTls: true, + output: 'json', + jsonStyle: 'camel', + debugHttp: false, + quiet: false, + yes: false, + }; + + const ctx: CliContext = { + get config() { + return config; + }, + get client() { + return mockClient as any; + }, + }; + + beforeEach(() => { + mockClient.get.mockReset(); + mockClient.put.mockReset(); + }); + + it('fetches environment variables via config env command', async () => { + mockClient.get.mockResolvedValue({ data: { variables: [] } }); + const program = new Command(); + program.exitOverride(); + registerConfigCommands(program, ctx); + + await program.parseAsync(['node', 'test', 'config', 'env']); + + expect(mockClient.get).toHaveBeenCalledWith('/api/v1/_config/environment-variables'); + }); + + it('fetches filesystem info via config filesystem command', async () => { + mockClient.get.mockResolvedValue({ data: { base_path: '/tmp', tree: [] } }); + const program = new Command(); + program.exitOverride(); + registerConfigCommands(program, ctx); + + await program.parseAsync(['node', 'test', 'config', 'filesystem']); + + expect(mockClient.get).toHaveBeenCalledWith('/api/v1/_config/filesystem'); + }); +}); diff --git a/cli/test/unit/endpoints.spec.ts b/cli/test/unit/endpoints.spec.ts index c2e7dff1..3472cc64 100644 --- a/cli/test/unit/endpoints.spec.ts +++ b/cli/test/unit/endpoints.spec.ts @@ -1,9 +1,8 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Command } from 'commander'; import { registerEndpointCommands } from '../../src/commands/endpoints'; -import { createApiClient } from '../../src/lib/http'; import { buildEndpointUrl } from '../../src/lib/url'; -import type { FlapiiConfig } from '../../src/lib/types'; +import type { CliContext, FlapiiConfig } from '../../src/lib/types'; const spinner = { succeed: vi.fn(), fail: vi.fn(), stop: vi.fn() }; @@ -11,6 +10,7 @@ vi.mock('../../src/lib/console', () => ({ Console: { spinner: () => spinner, info: vi.fn(), + warn: vi.fn(), color: vi.fn((_, str) => str), }, })); @@ -24,6 +24,7 @@ vi.mock('../../src/lib/render', () => ({ describe('endpoints command', () => { const mockClient = { get: vi.fn(), + post: vi.fn(), }; const config: FlapiiConfig = { @@ -38,7 +39,7 @@ describe('endpoints command', () => { yes: false, }; - const ctx = { + const ctx: CliContext = { get config() { return config; }, @@ -49,14 +50,15 @@ describe('endpoints command', () => { beforeEach(() => { mockClient.get.mockReset(); + mockClient.post.mockReset(); spinner.succeed.mockReset(); spinner.fail.mockReset(); spinner.stop.mockReset(); }); - it('registers list command that fetches endpoints', async () => { + it('fetches endpoint list', async () => { mockClient.get.mockResolvedValue({ data: {} }); - const program = new Command(); + const program = new Command().exitOverride(); registerEndpointCommands(program, ctx); await program.parseAsync(['node', 'test', 'endpoints', 'list']); @@ -64,14 +66,23 @@ describe('endpoints command', () => { expect(mockClient.get).toHaveBeenCalledWith('/api/v1/_config/endpoints'); }); - it('registers get command that fetches a single endpoint', async () => { + it('fetches single endpoint', async () => { mockClient.get.mockResolvedValue({ data: { path: '/foo' } }); - const program = new Command(); + const program = new Command().exitOverride(); registerEndpointCommands(program, ctx); await program.parseAsync(['node', 'test', 'endpoints', 'get', '/foo']); expect(mockClient.get).toHaveBeenCalledWith(buildEndpointUrl('/foo')); }); -}); + it('fetches endpoint parameters', async () => { + mockClient.get.mockResolvedValue({ data: { parameters: [] } }); + const program = new Command().exitOverride(); + registerEndpointCommands(program, ctx); + + await program.parseAsync(['node', 'test', 'endpoints', 'parameters', '/foo']); + + expect(mockClient.get).toHaveBeenCalledWith('/api/v1/_config/endpoints/foo/parameters'); + }); +}); diff --git a/cli/test/unit/templates.spec.ts b/cli/test/unit/templates.spec.ts new file mode 100644 index 00000000..210d9e38 --- /dev/null +++ b/cli/test/unit/templates.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Command } from 'commander'; +import { registerTemplateCommands } from '../../src/commands/templates'; +import type { CliContext, FlapiiConfig } from '../../src/lib/types'; + +const spinner = { succeed: vi.fn(), fail: vi.fn(), stop: vi.fn() }; + +vi.mock('../../src/lib/console', () => ({ + Console: { + spinner: () => spinner, + info: vi.fn(), + success: vi.fn(), + color: vi.fn((_, str) => str), + }, +})); + +vi.mock('../../src/lib/render', () => ({ + renderJson: vi.fn(), + renderTemplatesTable: vi.fn(), +})); + +describe('template commands', () => { + const mockClient = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + }; + + const config: FlapiiConfig = { + baseUrl: 'http://localhost:8080', + timeout: 10, + retries: 2, + verifyTls: true, + output: 'json', + jsonStyle: 'camel', + debugHttp: false, + quiet: false, + yes: false, + }; + + const ctx: CliContext = { + get config() { + return config; + }, + get client() { + return mockClient as any; + }, + }; + + beforeEach(() => { + mockClient.get.mockReset(); + mockClient.post.mockReset(); + mockClient.put.mockReset(); + }); + + it('fetches template content using template field', async () => { + mockClient.get.mockResolvedValue({ data: { template: 'select 1;' } }); + const program = new Command(); + program.exitOverride(); + registerTemplateCommands(program, ctx); + + await program.parseAsync(['node', 'test', 'templates', 'get', '/foo']); + + expect(mockClient.get).toHaveBeenCalledWith('/api/v1/_config/endpoints/foo/template'); + }); + + it('tests template execution with success payload', async () => { + mockClient.post.mockResolvedValue({ data: { success: true, rows: [{ id: 1 }], columns: ['id'] } }); + const program = new Command(); + program.exitOverride(); + registerTemplateCommands(program, ctx); + + await program.parseAsync(['node', 'test', 'templates', 'test', '/foo', '-p', '{"id":1}']); + + expect(mockClient.post).toHaveBeenCalledWith('/api/v1/_config/endpoints/foo/template/test', { + parameters: { id: 1 }, + }); + }); + + it('finds endpoints by template file', async () => { + mockClient.post.mockResolvedValue({ data: { endpoints: [] } }); + const program = new Command().exitOverride(); + registerTemplateCommands(program, ctx); + + await program.parseAsync(['node', 'test', 'templates', 'find', '--template', 'foo.sql']); + + expect(mockClient.post).toHaveBeenCalledWith('/api/v1/_config/endpoints/by-template', { + template_path: 'foo.sql', + }); + }); +}); diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index 2ed78d3c..ea937908 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -6,11 +6,14 @@ export default defineConfig({ env: { NODE_ENV: 'test', }, + setupFiles: ['test/setup/resetExitCode.ts'], coverage: { enabled: false, }, - deps: { - inline: ['cli-table3'], + server: { + deps: { + inline: ['cli-table3'], + }, }, }, resolve: { @@ -19,4 +22,3 @@ export default defineConfig({ }, }, }); - diff --git a/cli/vscode-extension/src/commands/endpointCommands.ts b/cli/vscode-extension/src/commands/endpointCommands.ts index 9d24ebae..e5cd1cf7 100644 --- a/cli/vscode-extension/src/commands/endpointCommands.ts +++ b/cli/vscode-extension/src/commands/endpointCommands.ts @@ -223,14 +223,16 @@ async function getActiveEndpointPath(): Promise { * Extracts endpoint path from a flapi:// URI */ async function getEndpointPathFromUri(uri: vscode.Uri): Promise { - // URI format: flapi://endpoint/{slug}/{component} - const pathParts = uri.path.split('/'); - if (pathParts.length < 3 || pathParts[1] !== 'endpoint') { + if (uri.scheme !== 'flapi' || uri.authority !== 'endpoint') { return undefined; } - - const slug = pathParts[2]; - // Convert slug back to path (this should use slugToPath from shared lib) + + const normalized = uri.path.replace(/^\/+/, ''); + const [slug] = normalized.split('/'); + if (!slug) { + return undefined; + } + const { slugToPath } = await import('@flapi/shared'); return slugToPath(slug); } diff --git a/cli/vscode-extension/src/explorer/FlapiExplorerProvider.ts b/cli/vscode-extension/src/explorer/FlapiExplorerProvider.ts index 23d7ce6c..503f5202 100644 --- a/cli/vscode-extension/src/explorer/FlapiExplorerProvider.ts +++ b/cli/vscode-extension/src/explorer/FlapiExplorerProvider.ts @@ -348,13 +348,13 @@ export class FlapiExplorerProvider implements vscode.TreeDataProvider ({ + path, + ...(payload as any)[path], + })); + } + + return []; } /** @@ -205,4 +216,3 @@ export class FlapiApiClient { return await response.json(); } } - diff --git a/cli/vscode-extension/src/validation/yamlValidator.ts b/cli/vscode-extension/src/validation/yamlValidator.ts index 69c80dd2..163eedd0 100644 --- a/cli/vscode-extension/src/validation/yamlValidator.ts +++ b/cli/vscode-extension/src/validation/yamlValidator.ts @@ -1,4 +1,7 @@ import * as vscode from 'vscode'; +import * as path from 'path'; +import YAML from 'yaml'; +import { pathToSlug } from '@flapi/shared'; import { FlapiApiClient } from '../shared/apiClient'; export interface ValidationError { @@ -36,7 +39,7 @@ export class YamlValidator { try { const content = document.getText(); - const slug = this.getSlugFromFilePath(document.uri.fsPath); + const slug = this.getSlugForDocument(document); if (!slug) { this.outputChannel.appendLine(`Could not determine slug for ${document.uri.fsPath}`); @@ -102,10 +105,10 @@ export class YamlValidator { * Reload endpoint configuration in backend after successful save */ async reloadEndpointConfig(document: vscode.TextDocument): Promise { - const slug = this.getSlugFromFilePath(document.uri.fsPath); - if (!slug) { - return; - } + const slug = this.getSlugForDocument(document); + if (!slug) { + return; + } try { this.outputChannel.appendLine(`Reloading endpoint configuration for ${slug}...`); @@ -196,28 +199,43 @@ export class YamlValidator { /** * Extract slug from file path */ - private getSlugFromFilePath(filePath: string): string | null { - // Get workspace folder - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - return null; + private getSlugForDocument(document: vscode.TextDocument): string | null { + try { + const parsed = YAML.parse(document.getText()); + if (parsed && typeof parsed === 'object') { + const urlPath = parsed['url-path'] || parsed['urlPath']; + if (typeof urlPath === 'string' && urlPath.length > 0) { + return pathToSlug(urlPath); + } + + const mcpToolName = parsed['mcp-tool']?.name || parsed['mcpTool']?.name; + if (typeof mcpToolName === 'string' && mcpToolName.length > 0) { + return mcpToolName; + } + + const mcpResourceName = parsed['mcp-resource']?.name || parsed['mcpResource']?.name; + if (typeof mcpResourceName === 'string' && mcpResourceName.length > 0) { + return mcpResourceName; + } + + const mcpPromptName = parsed['mcp-prompt']?.name || parsed['mcpPrompt']?.name; + if (typeof mcpPromptName === 'string' && mcpPromptName.length > 0) { + return mcpPromptName; + } + } + } catch { + // ignore YAML parse errors } - // Get relative path from workspace - const relativePath = vscode.workspace.asRelativePath(filePath); - - // Extract the endpoint name from the file - // e.g., examples/sqls/users.yaml -> users - const match = relativePath.match(/([^/]+)\.(yaml|yml)$/); - if (!match) { - return null; + const relative = vscode.workspace.asRelativePath(document.uri); + const match = relative.match(/([^/]+)\.(yaml|yml)$/i); + if (match) { + const base = match[1]; + return pathToSlug(`/${base}`); } - const endpointName = match[1]; - - // For now, use the filename as the slug - // In the future, we might want to read the url-path or mcp-tool name from the file - return endpointName; + const basename = path.basename(document.uri.fsPath, path.extname(document.uri.fsPath)); + return basename ? pathToSlug(`/${basename}`) : null; } /** @@ -234,4 +252,3 @@ export class YamlValidator { this.diagnosticCollection.dispose(); } } - diff --git a/cli/vscode-extension/src/workspace/EndpointWorkspace.ts b/cli/vscode-extension/src/workspace/EndpointWorkspace.ts index 1936da04..2dcfe23f 100644 --- a/cli/vscode-extension/src/workspace/EndpointWorkspace.ts +++ b/cli/vscode-extension/src/workspace/EndpointWorkspace.ts @@ -105,7 +105,12 @@ export class EndpointWorkspace { try { const cfg = await this.client.get(buildEndpointUrl(path)); templateSource = (cfg.data?.templateSource as string | undefined) ?? undefined; - cacheTemplateSource = (cfg.data?.cache?.templateSource as string | undefined) ?? undefined; + const cacheConfig = cfg.data?.cache ?? {}; + cacheTemplateSource = + cacheConfig?.templateFile || + cacheConfig?.['template-file'] || + cacheConfig?.templateSource || + undefined; } catch { // ignore; fall back to virtual docs }