5 Commits

Author SHA1 Message Date
Space e6dea43555 Add remote sync commands 2026-03-20 17:03:45 +01:00
Space-Banane 7b86fec717 That should do 2026-03-20 17:02:34 +01:00
Space-Banane 0be63e5287 Fixed spacing 2026-03-20 16:28:37 +01:00
luna 1d04649cbf Refactor remote commands into remote subdirectory 2026-03-20 16:24:09 +01:00
luna 344e10ac46 Add remote sync command and tests 2026-03-20 16:21:53 +01:00
7 changed files with 245 additions and 4 deletions
+1
View File
@@ -2,3 +2,4 @@ node_modules
dist dist
build build
pnpm-lock.yaml pnpm-lock.yaml
testing
+3 -1
View File
@@ -16,7 +16,6 @@ shsf health
this will check the health, and if not setup, it will prompt you to set up the CLI. this will check the health, and if not setup, it will prompt you to set up the CLI.
## Commands ## Commands
- `shsf count functions`: Count your functions. Add `--full` to list them. - `shsf count functions`: Count your functions. Add `--full` to list them.
- `shsf count namespaces`: Count your namespaces. 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. - `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 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 file list`: List files in a storage. (use `shsf file list -h` first)
- `shsf remote pull --id <id> --into <path> [--force]`: Pull files from a function into a local directory.
- `shsf remote push --id <id> --from <path> [--force]`: Push files from a local directory to a function.
## Instructions ## 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. 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "shsf-cli", "name": "shsf-cli",
"version": "2.1.6", "version": "2.1.7",
"description": "", "description": "",
"type": "module", "type": "module",
"files": [ "files": [
+44
View File
@@ -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);
});
});
+35
View File
@@ -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 <id>", description: "Function ID", required: true },
{ name: "--into <path>", 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}`));
}
},
};
+158
View File
@@ -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 <id>", description: "Function ID", required: true },
{ name: "--from <path>", 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}`));
}
}
},
};
+2 -1
View File
@@ -4,9 +4,10 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json', 'html'], reporter: ['text', 'json', 'html'],
}, }
}, },
}); });