diff --git a/.gitignore b/.gitignore index cf14aed..7e93b2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules dist build -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +testing \ No newline at end of file diff --git a/src/__tests__/url_prefix.test.ts b/src/__tests__/url_prefix.test.ts new file mode 100644 index 0000000..03f7d97 --- /dev/null +++ b/src/__tests__/url_prefix.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'fs'; +import path from 'path'; + +function getFilesRecursively(dir: string): string[] { + let files: string[] = []; + const items = readdirSync(dir); + for (const item of items) { + const fullPath = path.join(dir, item); + if (statSync(fullPath).isDirectory()) { + if (item !== '__tests__') { + files = files.concat(getFilesRecursively(fullPath)); + } + } else if (item.endsWith('.ts')) { + files.push(fullPath); + } + } + return files; +} + +describe('API URL validation', () => { + it('should ensure all API client calls start with /api/', () => { + const tsFiles = getFilesRecursively('src'); + const violations: string[] = []; + + // Regex to match common patterns like client.get('/path') or client.post(`/path`) + // specifically looking for paths that start with / but NOT /api/ or /health + const urlPattern = /client\.(get|post|put|delete|patch)\(['"`]\/(?!(api|health)\/)[^'"`]+['"`]/g; + + tsFiles.forEach(file => { + const content = readFileSync(file, 'utf-8'); + let match; + while ((match = urlPattern.exec(content)) !== null) { + // Double check for exact /health match (without trailing slash) + if (match[0].includes("'/health'") || match[0].includes('"/health"') || match[0].includes('`/health`')) { + continue; + } + violations.push(`${file}: ${match[0]}`); + } + }); + + expect(violations, `Found API calls not starting with /api/:\n${violations.join('\n')}`).toHaveLength(0); + }); +}); diff --git a/src/commands/remote/pull.ts b/src/commands/remote/pull.ts index eb2c893..855eedb 100644 --- a/src/commands/remote/pull.ts +++ b/src/commands/remote/pull.ts @@ -15,8 +15,8 @@ export const pullDefinition = { const client = await getApiClient(); try { - const response = await client.get(`/functions/${options.id}/files`); - const files = response.data; // Expecting { filename: string, content: string }[] + const response = await client.get(`/api/function/${options.id}/files`); + const files = response.data.data; // Expecting { name: string, content: string }[] if (!fs.existsSync(options.into)) { fs.mkdirSync(options.into, { recursive: true }); @@ -24,9 +24,8 @@ export const pullDefinition = { console.error(chalk.red(`Directory ${options.into} is not empty. Use --force to overwrite.`)); return; } - for (const file of files) { - fs.writeFileSync(path.join(options.into, file.filename), file.content); + fs.writeFileSync(path.join(options.into, file.name), file.content); } console.log(chalk.green(`Successfully pulled ${files.length} files into ${options.into}`)); } catch (error: any) { diff --git a/src/commands/remote/push.ts b/src/commands/remote/push.ts index 0e6e6e0..9f307cd 100644 --- a/src/commands/remote/push.ts +++ b/src/commands/remote/push.ts @@ -2,6 +2,34 @@ import chalk from "chalk"; import { getApiClient } from "../../api.js"; import fs from "fs"; import path from "path"; +import { createHash } from "crypto"; + +async function deleteNonexistentFiles( + currentFiles: any[], + files: any[], + client: any, + options: any, +): Promise<{ didDeletion: boolean }> { + let didDeletion = false; + for (const currentFile of currentFiles) { + if (!files.some((f: any) => f.filename === currentFile.name)) { + try { + await client.delete( + `/api/function/${options.id}/file/${currentFile.id}`, + { + data: { filename: currentFile.name }, + }, + );} catch (error: any) { + throw error; + } + console.log( + chalk.green(`Deleted ${currentFile.name} from function ${options.id}`), + ); + didDeletion = true; + } + } + return { didDeletion }; +} export const pushDefinition = { name: "push", @@ -19,21 +47,112 @@ export const pushDefinition = { return; } - const files = fs.readdirSync(options.from).map(file => ({ + let didDeletion = false; // Track if any deletions were made + + const currentFilesResponse = await client.get( + `/api/function/${options.id}/files`, + ); + const currentFiles = currentFilesResponse.data.data; // Expecting { name: string, id: string [...] }[] + + const files = fs.readdirSync(options.from).map((file) => ({ filename: file, - content: fs.readFileSync(path.join(options.from, file), 'utf-8') + content: fs.readFileSync(path.join(options.from, file), "utf-8"), })); if (!options.force && files.length > 5) { - console.log(chalk.yellow('Too many modifications. Please use --force to confirm.')); + console.log( + chalk.yellow("Too many modifications. Please use --force to confirm."), + ); return; } + // Push new and modified files + let pushedFilesCount = 0; try { - await client.post(`/functions/${options.id}/files`, { files }); - console.log(chalk.green(`Successfully pushed ${files.length} files to function ${options.id}`)); + for (const file of files) { + // Hash first, save traffic + const fileHash = createHash("md5").update(file.content).digest("hex"); + const currentFile = currentFiles.find( + (f: any) => f.name === file.filename, + ); + if (currentFile) { + // If the file content exists in the response, we can hash it. + // Note: currentFiles from /api/function/:id/files might not have content by default depending on API, + // but the original code assumes it does. + const currentFileHash = createHash("md5") + .update(currentFile.content || "") + .digest("hex"); + if (fileHash === currentFileHash) { + console.log( + chalk.yellow( + `Skipping ${file.filename}, no changes detected for function ${options.id}`, + ), + ); + continue; + } + } + + await client.put(`/api/function/${options.id}/file`, { + filename: file.filename, + code: file.content, + }); + + console.log( + chalk.green(`Pushed ${file.filename} to function ${options.id}`), + ); + pushedFilesCount++; + } + + // Delete files that no longer exist on the remote AFTER pushing new/updated files + // This avoids the "cannot delete only file" error if we are pushing at least one file. + try { + const deletionResult = await deleteNonexistentFiles( + currentFiles, + files, + client, + options, + ); + if (deletionResult.didDeletion) { + didDeletion = true; + } + } catch (error: any) { + if (error.response) { + console.error( + chalk.red( + `Failed to delete files: ${error.response.data?.message || error.response.statusText}`, + ), + ); + } else { + console.error(chalk.red(`Error during deletion: ${error.message}`)); + } + } + + console.log( + chalk.green( + `Successfully pushed ${pushedFilesCount} files to function ${options.id}`, + ), + ); + + if (didDeletion) { + console.log(""); + console.log( + chalk.blue( + `Hint: We might delete files that you renamed, but don't worry, we'll push the newly named file right after!`, + ), + ); + } } catch (error: any) { - console.error(chalk.red(`Failed to push files: ${error.message}`)); + if (error.response) { + console.error( + chalk.red( + `Failed to push files: ${error.response.data?.message || error.response.statusText}`, + ), + ); + } else if (error.request) { + console.error(chalk.red("No response received from server.")); + } else { + console.error(chalk.red(`Error: ${error.message}`)); + } } }, }; diff --git a/vitest.config.ts b/vitest.config.ts index c634ddb..e7960bb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,9 +4,10 @@ export default defineConfig({ test: { globals: true, environment: 'node', + exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], - }, + } }, });