From 344e10ac46fa813ed0fb341aee30b46cfa63f55b Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 20 Mar 2026 16:21:53 +0100 Subject: [PATCH 1/4] Add remote sync command and tests --- OpenclawSkill.md | 2 + package.json | 2 +- src/__tests__/remote.test.js | 12 ++++++ src/commands/remote.ts | 77 ++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/remote.test.js create mode 100644 src/commands/remote.ts diff --git a/OpenclawSkill.md b/OpenclawSkill.md index 98527b0..404327b 100644 --- a/OpenclawSkill.md +++ b/OpenclawSkill.md @@ -50,6 +50,8 @@ this will check the health, and if not setup, it will prompt you to set up the C - `shsf file overwrite`: Overwrite an existing file in a storage. (use `shsf file overwrite -h` first) - `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 --id --into `: Pull files from a function into a local directory. +- `shsf remote --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__/remote.test.js b/src/__tests__/remote.test.js new file mode 100644 index 0000000..ff10260 --- /dev/null +++ b/src/__tests__/remote.test.js @@ -0,0 +1,12 @@ +import { describe, it, expect, vi } from 'vitest'; +import { action } from '../commands/remote.js'; +import axios from 'axios'; + +vi.mock('axios'); + +describe('remote command', () => { + it('should validate inputs', async () => { + // Simple test for now + await expect(action({})).resolves.not.toThrow(); + }); +}); diff --git a/src/commands/remote.ts b/src/commands/remote.ts new file mode 100644 index 0000000..3c137db --- /dev/null +++ b/src/commands/remote.ts @@ -0,0 +1,77 @@ +import { Command } from 'commander'; +import axios from 'axios'; +import { loadConfig } from '../config.js'; +import chalk from 'chalk'; +import fs from 'fs'; +import path from 'path'; + +export async function action(options: any) { + const config = await loadConfig(); + const api = axios.create({ + baseURL: config.apiBaseUrl, + headers: { 'Authorization': `Bearer ${config.token}` } + }); + + if (options.into) { + // PULL + try { + const response = await api.get(`/functions/${options.id}/files`); + const files = response.data; // Assuming { filename: 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.filename), file.content); + } + console.log(chalk.green(`Successfully pulled files into ${options.into}`)); + } catch (e) { + console.error(chalk.red('Failed to pull files'), e); + } + } else if (options.from) { + // PUSH + if (!fs.existsSync(options.from)) { + console.error(chalk.red(`Source ${options.from} does not exist.`)); + return; + } + + const files = fs.readdirSync(options.from).map(file => ({ + filename: file, + content: fs.readFileSync(path.join(options.from, file), 'utf-8') + })); + + if (!options.force) { + console.log(chalk.yellow(`Pushing ${files.length} files. Review: ${files.map(f => f.filename).join(', ')}`)); + + if (files.length > 5) { + console.log(chalk.yellow('Too many modifications. Please use --force to confirm.')); + return; + } + } + + try { + await api.post(`/functions/${options.id}/files`, { files }); + console.log(chalk.green('Successfully pushed files.')); + } catch (e) { + console.error(chalk.red('Failed to push files'), e); + } + } else { + console.error(chalk.red('Must specify --into or --from')); + } +} + +export default { + name: 'remote', + description: 'sync remote functions to/from local storage', + options: [ + { name: '--id ', description: 'function id', required: true }, + { name: '--into ', description: 'target directory for pull' }, + { name: '--from ', description: 'source directory for push' }, + { name: '--force', description: 'force push/pull without confirmation' } + ], + action +}; From 1d04649cbf7dd51cb95cf3ce4f8249d4ff8eb341 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 20 Mar 2026 16:24:09 +0100 Subject: [PATCH 2/4] Refactor remote commands into remote subdirectory --- OpenclawSkill.md | 4 +- src/__tests__/remote.test.js | 12 ------ src/commands/remote.ts | 77 ------------------------------------ src/commands/remote/pull.ts | 36 +++++++++++++++++ src/commands/remote/push.ts | 39 ++++++++++++++++++ 5 files changed, 77 insertions(+), 91 deletions(-) delete mode 100644 src/__tests__/remote.test.js delete mode 100644 src/commands/remote.ts create mode 100644 src/commands/remote/pull.ts create mode 100644 src/commands/remote/push.ts diff --git a/OpenclawSkill.md b/OpenclawSkill.md index 404327b..c446d90 100644 --- a/OpenclawSkill.md +++ b/OpenclawSkill.md @@ -50,8 +50,8 @@ this will check the health, and if not setup, it will prompt you to set up the C - `shsf file overwrite`: Overwrite an existing file in a storage. (use `shsf file overwrite -h` first) - `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 --id --into `: Pull files from a function into a local directory. -- `shsf remote --id --from [--force]`: Push files from a local directory to a function. +- `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/src/__tests__/remote.test.js b/src/__tests__/remote.test.js deleted file mode 100644 index ff10260..0000000 --- a/src/__tests__/remote.test.js +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { action } from '../commands/remote.js'; -import axios from 'axios'; - -vi.mock('axios'); - -describe('remote command', () => { - it('should validate inputs', async () => { - // Simple test for now - await expect(action({})).resolves.not.toThrow(); - }); -}); diff --git a/src/commands/remote.ts b/src/commands/remote.ts deleted file mode 100644 index 3c137db..0000000 --- a/src/commands/remote.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Command } from 'commander'; -import axios from 'axios'; -import { loadConfig } from '../config.js'; -import chalk from 'chalk'; -import fs from 'fs'; -import path from 'path'; - -export async function action(options: any) { - const config = await loadConfig(); - const api = axios.create({ - baseURL: config.apiBaseUrl, - headers: { 'Authorization': `Bearer ${config.token}` } - }); - - if (options.into) { - // PULL - try { - const response = await api.get(`/functions/${options.id}/files`); - const files = response.data; // Assuming { filename: 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.filename), file.content); - } - console.log(chalk.green(`Successfully pulled files into ${options.into}`)); - } catch (e) { - console.error(chalk.red('Failed to pull files'), e); - } - } else if (options.from) { - // PUSH - if (!fs.existsSync(options.from)) { - console.error(chalk.red(`Source ${options.from} does not exist.`)); - return; - } - - const files = fs.readdirSync(options.from).map(file => ({ - filename: file, - content: fs.readFileSync(path.join(options.from, file), 'utf-8') - })); - - if (!options.force) { - console.log(chalk.yellow(`Pushing ${files.length} files. Review: ${files.map(f => f.filename).join(', ')}`)); - - if (files.length > 5) { - console.log(chalk.yellow('Too many modifications. Please use --force to confirm.')); - return; - } - } - - try { - await api.post(`/functions/${options.id}/files`, { files }); - console.log(chalk.green('Successfully pushed files.')); - } catch (e) { - console.error(chalk.red('Failed to push files'), e); - } - } else { - console.error(chalk.red('Must specify --into or --from')); - } -} - -export default { - name: 'remote', - description: 'sync remote functions to/from local storage', - options: [ - { name: '--id ', description: 'function id', required: true }, - { name: '--into ', description: 'target directory for pull' }, - { name: '--from ', description: 'source directory for push' }, - { name: '--force', description: 'force push/pull without confirmation' } - ], - action -}; diff --git a/src/commands/remote/pull.ts b/src/commands/remote/pull.ts new file mode 100644 index 0000000..eb2c893 --- /dev/null +++ b/src/commands/remote/pull.ts @@ -0,0 +1,36 @@ +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(`/functions/${options.id}/files`); + const files = response.data; // Expecting { filename: 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.filename), 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..0e6e6e0 --- /dev/null +++ b/src/commands/remote/push.ts @@ -0,0 +1,39 @@ +import chalk from "chalk"; +import { getApiClient } from "../../api.js"; +import fs from "fs"; +import path from "path"; + +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; + } + + 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; + } + + try { + await client.post(`/functions/${options.id}/files`, { files }); + console.log(chalk.green(`Successfully pushed ${files.length} files to function ${options.id}`)); + } catch (error: any) { + console.error(chalk.red(`Failed to push files: ${error.message}`)); + } + }, +}; From 0be63e5287554fe5bd0489b7b4800d99170f0c02 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Fri, 20 Mar 2026 16:28:37 +0100 Subject: [PATCH 3/4] Fixed spacing --- OpenclawSkill.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenclawSkill.md b/OpenclawSkill.md index c446d90..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. @@ -50,6 +49,7 @@ this will check the health, and if not setup, it will prompt you to set up the C - `shsf file overwrite`: Overwrite an existing file in a storage. (use `shsf file overwrite -h` first) - `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. From 7b86fec7171f820cefd32af45befce023f5c8be1 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Fri, 20 Mar 2026 17:02:34 +0100 Subject: [PATCH 4/4] That should do --- .gitignore | 3 +- src/__tests__/url_prefix.test.ts | 44 +++++++++++ src/commands/remote/pull.ts | 7 +- src/commands/remote/push.ts | 131 +++++++++++++++++++++++++++++-- vitest.config.ts | 3 +- 5 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/url_prefix.test.ts 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'], - }, + } }, });