From 9f2d72c3a05667fa8392b8002c56eb0bed0492f0 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Fri, 3 Apr 2026 17:48:44 +0200 Subject: [PATCH] feat: implement file ignore and mapping functionality for remote push command --- OpenclawSkill.md | 18 ++++++ README.md | 39 ++++++++++-- src/__tests__/push_helpers.test.ts | 73 ++++++++++++++++++++++ src/commands/remote/push.ts | 95 +++++++++++++++++------------ src/utils/push_helpers.ts | 98 ++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 44 deletions(-) create mode 100644 src/__tests__/push_helpers.test.ts create mode 100644 src/utils/push_helpers.ts diff --git a/OpenclawSkill.md b/OpenclawSkill.md index dad04a8..4bd5fe5 100644 --- a/OpenclawSkill.md +++ b/OpenclawSkill.md @@ -63,6 +63,24 @@ this will check the health, and if not setup, it will prompt you to set up the C - `shsf req add --id `: Add a package to the requirements.txt of a function. - `shsf req remove --id `: Remove a package from the requirements.txt of a function. + +### Ignore & Mapping files + +`shsf remote push` supports a `.shsfignore` file (gitignore-like) placed in the source directory or repository root. Lines are treated like gitignore patterns and will prevent matching files from being pushed. + +You can also create a `.shsf.json` mapping file in the repository root so you don't need to pass `--id` and `--from` every time. Example `.shsf.json`: + +```json +{ + "default": { + "id": "my-function-id", + "from": "src/functions/my-fn" + } +} +``` + +Command-line flags take precedence over values in `.shsf.json`. + ## 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/README.md b/README.md index 3dee218..602ffd2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ A powerful command-line interface for managing and interacting with your SHSF in [![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc3534.svg)](https://pnpm.io/) [![TypeScript](https://img.shields.io/badge/built%20with-TypeScript-blue.svg)](https://www.typescriptlang.org/) - ## 🚦 Getting Started ### 📋 Prerequisites @@ -20,10 +19,13 @@ To install **SHSF CLI** globally on your system: ```bash pnpm add -g shsf-cli ``` -or + +or + ```bash npm install -g shsf-cli ``` + --- Once installed, simply type: @@ -31,22 +33,25 @@ Once installed, simply type: ```bash shsf health ``` -and it will ask you for your SHSF instance URL and API token to perform a health check. +and it will ask you for your SHSF instance URL and API token to perform a health check. ## 🛠️ Usage & Commands ### 🩺 Health Check + Quickly see if the system is up and running: + ```bash shsf health ``` - ### 🏗️ Local Development + If you're contributing or running from source: 1. **Setup**: + ```bash git clone https://github.com/Space-Banane/shsf-cli.git cd shsf-cli @@ -59,6 +64,31 @@ If you're contributing or running from source: pnpm start [cmd] # Run a command directly ``` +## Ignore & Mapping files + +You can control what files `shsf remote push` ignores using a `.shsfignore` file (gitignore-style). + +Place `.shsfignore` in the source directory you're pushing or in the repository root. Examples: + +``` +# ignore logs and secrets +*.log +secret.txt +node_modules +``` + +You can also create a `.shsf.json` mapping file at the repository root to avoid passing `--id` and `--from` every time. Example: + +```json +{ + "default": { + "id": "my-function-id", + "from": "src/functions/my-fn" + } +} +``` + +Command-line options always override values from `.shsf.json`. ## 🤝 Contributing @@ -80,4 +110,3 @@ We love builders! To add a new command: ## 📄 License Licensed under the **MIT-0 License**. Happy coding! 🍌 - diff --git a/src/__tests__/push_helpers.test.ts b/src/__tests__/push_helpers.test.ts new file mode 100644 index 0000000..15b5f2b --- /dev/null +++ b/src/__tests__/push_helpers.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { readIgnoreFile, matchesAnyPattern, readMappingFile, defaultUnpushableFiles } from '../utils/push_helpers.js'; + +const tmpDirs: string[] = []; + +afterEach(() => { + for (const d of tmpDirs) { + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch (_e) { + // ignore cleanup errors + } + } + tmpDirs.length = 0; +}); + +describe('push_helpers', () => { + it('readIgnoreFile should read patterns from provided dir', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shsf-test-')); + tmpDirs.push(tmp); + const ignorePath = path.join(tmp, '.shsfignore'); + fs.writeFileSync(ignorePath, '# comment\nsecret.txt\n*.log\n'); + + const patterns = readIgnoreFile(tmp); + expect(patterns).toContain('secret.txt'); + expect(patterns).toContain('*.log'); + }); + + it('matchesAnyPattern should match suffix and glob patterns', () => { + const patterns = ['.secret', '*.log', 'dockerfile']; + expect(matchesAnyPattern('foo.secret', patterns)).toBe(true); + expect(matchesAnyPattern('bar.log', patterns)).toBe(true); + expect(matchesAnyPattern('Dockerfile', patterns)).toBe(true); + expect(matchesAnyPattern('notmatched.txt', patterns)).toBe(false); + }); + + it('defaultUnpushableFiles contains common suffix', () => { + expect(defaultUnpushableFiles).toContain('.png'); + }); + + it('readMappingFile should read mapping from cwd when present', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shsf-map-')); + tmpDirs.push(tmp); + const mapPath = path.join(tmp, '.shsf.json'); + fs.writeFileSync(mapPath, JSON.stringify({ default: { id: 'abc', from: 'src' } })); + + const mapping = readMappingFile(tmp); + expect(mapping).toBeTruthy(); + expect(mapping?.id).toBe('abc'); + expect(mapping?.from).toBe('src'); + }); + + it('readMappingFile should return null when file is missing', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shsf-map-missing-')); + tmpDirs.push(tmp); + + const mapping = readMappingFile(tmp); + expect(mapping).toBeNull(); + }); + + it('readMappingFile should return null for malformed json', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'shsf-map-bad-')); + tmpDirs.push(tmp); + const mapPath = path.join(tmp, '.shsf.json'); + fs.writeFileSync(mapPath, '{ this is : not valid json }'); + + const mapping = readMappingFile(tmp); + expect(mapping).toBeNull(); + }); +}); diff --git a/src/commands/remote/push.ts b/src/commands/remote/push.ts index 0f46888..20cdc78 100644 --- a/src/commands/remote/push.ts +++ b/src/commands/remote/push.ts @@ -2,42 +2,15 @@ import chalk from "chalk"; import { getApiClient } from "../../api.js"; import fs from "fs"; import path from "path"; +import { + defaultUnpushableFiles, + readIgnoreFile, + matchesAnyPattern, + readMappingFile, +} from "../../utils/push_helpers.js"; import { createHash } from "crypto"; -const unpushableFiles = [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".bmp", - ".ico", - ".zip", - ".tar", - ".gz", - ".7z", - ".pdf", - ".doc", - ".docx", - ".xls", - ".xlsx", - ".ppt", - ".pptx", - ".exe", - ".dll", - ".so", - ".dylib", - "docker-compose.yml", - "docker-compose.yaml", - "compose.yaml", - "compose.yml", - "dockerfile", - "dockerfile.dev", - "dockerfile.prod", - ".gitignore", - ".env", - ".gitkeep", - ".md" -]; + async function deleteNonexistentFiles( currentFiles: any[], @@ -71,18 +44,35 @@ 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: "--id ", description: "Function ID" }, + { name: "--from ", description: "Source directory" }, { name: "--force", description: "Force overwrite" }, ], action: async (options: any) => { const client = await getApiClient(); + // Load mapping if options are missing + const mapping = readMappingFile(); + if (!options.id && mapping?.id) { + options.id = mapping.id; + console.log(chalk.blue(`Using mapped id ${options.id} from .shsf.json`)); + } + if (!options.from && mapping?.from) { + options.from = mapping.from; + console.log(chalk.blue(`Using mapped from ${options.from} from .shsf.json`)); + } + + const hadMapping = !!mapping; + + if (!options.from) { + console.error(chalk.red("Source path not provided.")); + return; + } + 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( @@ -90,6 +80,10 @@ export const pushDefinition = { ); const currentFiles = currentFilesResponse.data.data; // Expecting { name: string, id: string [...] }[] + // Read ignore patterns from .shsfignore (in source dir or cwd) and merge with defaults + const customPatterns = readIgnoreFile(options.from); + const combinedPatterns = [...defaultUnpushableFiles, ...customPatterns]; + // Read local files and filter to only include files (exclude directories) const files = fs .readdirSync(options.from) @@ -98,6 +92,7 @@ export const pushDefinition = { return { filename: file, filePath }; }) .filter(({ filePath }) => fs.statSync(filePath).isFile()) + .filter(({ filename }) => !matchesAnyPattern(filename, combinedPatterns)) .map(({ filename, filePath }) => ({ filename, content: fs.readFileSync(filePath, "utf-8"), @@ -134,8 +129,8 @@ export const pushDefinition = { } const filenameLower = file.filename.toLowerCase(); - // Unpushable file check - if (unpushableFiles.some((suffix) => filenameLower.endsWith(suffix))) { + // Unpushable file check (also check custom patterns) + if (matchesAnyPattern(filenameLower, combinedPatterns)) { console.log( chalk.yellow( `Skipping ${file.filename}, it matches unpushable patterns for function ${options.id}`, @@ -188,6 +183,28 @@ export const pushDefinition = { ), ); + // If there was no mapping file and the user provided --id and --from, create a .shsf.json + if (!hadMapping && options.id && options.from) { + try { + const mapPath = path.join(process.cwd(), ".shsf.json"); + if (!fs.existsSync(mapPath)) { + const data = { default: { id: options.id, from: options.from } }; + fs.writeFileSync(mapPath, JSON.stringify(data, null, 2), { encoding: "utf-8" }); + console.log(""); + console.log( + chalk.green(`Wrote .shsf.json mapping to ${mapPath}`), + ); + console.log( + chalk.blue( + `Tip: Next time you can omit --id and --from — the CLI will use values from .shsf.json`, + ), + ); + } + } catch (err: any) { + console.error(chalk.yellow(`Could not write .shsf.json: ${err?.message || err}`)); + } + } + if (didDeletion) { console.log(""); console.log( diff --git a/src/utils/push_helpers.ts b/src/utils/push_helpers.ts new file mode 100644 index 0000000..4db2c80 --- /dev/null +++ b/src/utils/push_helpers.ts @@ -0,0 +1,98 @@ +import fs from "fs"; +import path from "path"; + +export const defaultUnpushableFiles = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".ico", + ".zip", + ".tar", + ".gz", + ".7z", + ".pdf", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".ppt", + ".pptx", + ".exe", + ".dll", + ".so", + ".dylib", + "docker-compose.yml", + "docker-compose.yaml", + "compose.yaml", + "compose.yml", + "dockerfile", + "dockerfile.dev", + "dockerfile.prod", + ".gitignore", + ".env", + ".gitkeep", + ".md", +]; + +export function readIgnoreFile(dir: string): string[] { + const candidates = [path.join(dir, ".shsfignore"), path.join(process.cwd(), ".shsfignore")]; + for (const p of candidates) { + if (fs.existsSync(p)) { + try { + return fs + .readFileSync(p, "utf-8") + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")); + } catch (_err) { + return []; + } + } + } + return []; +} + +function globToRegExp(pattern: string): RegExp { + let p = pattern.trim(); + if (!p.includes("*")) { + if (!p.startsWith("/")) { + p = `*${p}`; + } + } + // Escape regex special chars, then replace * with .* + const escaped = p.replace(/[-\/\\^$+?.()|[\]{}]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`, "i"); +} + +export function matchesAnyPattern(filename: string, patterns: string[]): boolean { + const name = filename.replace(/\\/g, "/"); + for (const pat of patterns) { + try { + const re = globToRegExp(pat); + if (re.test(name)) return true; + } catch (_e) { + if (name.toLowerCase().endsWith(pat.toLowerCase())) return true; + } + } + return false; +} + +export function readMappingFile(cwd?: string): { id?: string; from?: string } | null { + const base = cwd || process.cwd(); + const p = path.join(base, ".shsf.json"); + if (!fs.existsSync(p)) return null; + try { + const raw = fs.readFileSync(p, "utf-8"); + const json = JSON.parse(raw); + if (json && typeof json === "object") { + return (json.default || json) as { id?: string; from?: string }; + } + } catch (_e) { + // ignore + } + return null; +} + +export default null;