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/OpenclawSkill.md b/OpenclawSkill.md index 98527b0..8af5792 100644 --- a/OpenclawSkill.md +++ b/OpenclawSkill.md @@ -16,7 +16,6 @@ shsf health this will check the health, and if not setup, it will prompt you to set up the CLI. ## Commands - - `shsf count functions`: Count your functions. Add `--full` to list them. - `shsf count namespaces`: Count your namespaces. Add `--full` to list them. - `shsf count storages`: Count your storages. Add `--full` to list them. @@ -51,6 +50,9 @@ this will check the health, and if not setup, it will prompt you to set up the C - `shsf file delete`: Delete a file from a storage. (use `shsf file delete -h` first) - `shsf file list`: List files in a storage. (use `shsf file list -h` first) +- `shsf remote pull --id --into [--force]`: Pull files from a function into a local directory. +- `shsf remote push --id --from [--force]`: Push files from a local directory to a function. + ## Instructions Use these commands for when you need to interact with shsf from the command line. Its faster than using the ui for almost all ops. diff --git a/package.json b/package.json index 794d254..4fe6051 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shsf-cli", - "version": "2.1.6", + "version": "2.1.7", "description": "", "type": "module", "files": [ 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 new file mode 100644 index 0000000..855eedb --- /dev/null +++ b/src/commands/remote/pull.ts @@ -0,0 +1,35 @@ +import chalk from "chalk"; +import { getApiClient } from "../../api.js"; +import fs from "fs"; +import path from "path"; + +export const pullDefinition = { + name: "pull", + description: "Pull files from a remote function.", + options: [ + { name: "--id ", description: "Function ID", required: true }, + { name: "--into ", description: "Target directory", required: true }, + { name: "--force", description: "Force overwrite" }, + ], + action: async (options: any) => { + const client = await getApiClient(); + + try { + 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 }); + } else if (fs.readdirSync(options.into).length > 0 && !options.force) { + 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.name), file.content); + } + console.log(chalk.green(`Successfully pulled ${files.length} files into ${options.into}`)); + } catch (error: any) { + console.error(chalk.red(`Failed to pull files: ${error.message}`)); + } + }, +}; diff --git a/src/commands/remote/push.ts b/src/commands/remote/push.ts new file mode 100644 index 0000000..9f307cd --- /dev/null +++ b/src/commands/remote/push.ts @@ -0,0 +1,158 @@ +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", + description: "Push files to a remote function.", + options: [ + { name: "--id ", description: "Function ID", required: true }, + { name: "--from ", description: "Source directory", required: true }, + { name: "--force", description: "Force overwrite" }, + ], + action: async (options: any) => { + const client = await getApiClient(); + + if (!fs.existsSync(options.from)) { + console.error(chalk.red(`Source ${options.from} does not exist.`)); + return; + } + + 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"), + })); + + if (!options.force && files.length > 5) { + console.log( + chalk.yellow("Too many modifications. Please use --force to confirm."), + ); + return; + } + + // Push new and modified files + let pushedFilesCount = 0; + try { + 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) { + 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'], - }, + } }, });