11 Commits

17 changed files with 711 additions and 134 deletions
+24 -28
View File
@@ -33,7 +33,7 @@ this will check the health, and if not setup, it will prompt you to set up the C
- `shsf get namespace <id>`: Get details of a specific namespace by its ID - `shsf get namespace <id>`: Get details of a specific namespace by its ID
- `shsf get trigger <functionId> <triggerId>`: Get details of a specific trigger from a function. - `shsf get trigger <functionId> <triggerId>`: Get details of a specific trigger from a function.
- `shsf execute --id <id> [--payload <json>] [--no-stream]`: Execute a function and stream the output (debug/internal). - `shsf function execute --id <id> [--payload <json>] [--no-stream]`: Execute a function and stream the output (debug/internal).
- `shsf storage create --name <name> --purpose <purpose>`: Create a new storage. - `shsf storage create --name <name> --purpose <purpose>`: Create a new storage.
- `shsf storage delete --name <name>`: Delete a storage. - `shsf storage delete --name <name>`: Delete a storage.
@@ -52,12 +52,35 @@ this will check the health, and if not setup, it will prompt you to set up the C
- `shsf file rename`: Rename a file in a function. (use `shsf file rename -h` first) - `shsf file rename`: Rename a file in a function. (use `shsf file rename -h` first)
- `shsf file delete`: Delete a file from a function. (use `shsf file delete -h` first) - `shsf file delete`: Delete a file from a function. (use `shsf file delete -h` first)
- `shsf env add --id <id> --name <name> --value <value>`: Adds or updates an environment variable for a function.
- `shsf env remove --id <id> --name <name>`: Removes a specific environment variable from a function.
- `shsf env list --id <id>`: Lists all environment variables for a function.
- `shsf env flush --id <id>`: Removes ALL environment variables from a function.
- `shsf remote pull --id <id> --into <path> [--force]`: Pull files from a function into a local directory. - `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. - `shsf remote push --id <id> --from <path> [--force]`: Push files from a local directory to a function.
- `shsf req add <package> --id <functionid>`: Add a package to the requirements.txt of a function. - `shsf req add <package> --id <functionid>`: Add a package to the requirements.txt of a function.
- `shsf req remove <package> --id <functionid>`: Remove a package from the requirements.txt of a function. - `shsf req remove <package> --id <functionid>`: 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 ## 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.
@@ -67,33 +90,6 @@ Get the ui url with:
shsf uiurl shsf uiurl
``` ```
## Available UI Routes
Below are the main routes you can use in the SHSF web UI. These are not clickable links, but you can navigate to them in your browser.
**General**
- `/`: Home Page
**Documentation**
- `/docs`: Documentation
**Account**
- `/account`: Account Settings
- `/login`: Login
- `/register`: Register
**Functions**
- `/functions`: Shows your functions, grouped by their respective namespaces (requires auth).
- `/functions/:id`: View details for a specific function (requires auth).
**Other**
- `/admin`: Admin dashboard (admin only, requires authentication)
- `/storage`: Storage management (requires auth)
- `/cron-jobs`: Cron job management (requires auth)
- `/access-tokens`: Access token management (requires auth)
- `/guest-users`: Guest user management (requires auth)
- `/guest-access`: Guest access portal
### Example ### Example
After creating a function and receiving an ID (for example, 81), you can share the following URL with your human so they can view the function in the UI: After creating a function and receiving an ID (for example, 81), you can share the following URL with your human so they can view the function in the UI:
``` ```
+33 -4
View File
@@ -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/) [![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/) [![TypeScript](https://img.shields.io/badge/built%20with-TypeScript-blue.svg)](https://www.typescriptlang.org/)
## 🚦 Getting Started ## 🚦 Getting Started
### 📋 Prerequisites ### 📋 Prerequisites
@@ -20,10 +19,13 @@ To install **SHSF CLI** globally on your system:
```bash ```bash
pnpm add -g shsf-cli pnpm add -g shsf-cli
``` ```
or or
```bash ```bash
npm install -g shsf-cli npm install -g shsf-cli
``` ```
--- ---
Once installed, simply type: Once installed, simply type:
@@ -31,22 +33,25 @@ Once installed, simply type:
```bash ```bash
shsf health 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 ## 🛠️ Usage & Commands
### 🩺 Health Check ### 🩺 Health Check
Quickly see if the system is up and running: Quickly see if the system is up and running:
```bash ```bash
shsf health shsf health
``` ```
### 🏗️ Local Development ### 🏗️ Local Development
If you're contributing or running from source: If you're contributing or running from source:
1. **Setup**: 1. **Setup**:
```bash ```bash
git clone https://github.com/Space-Banane/shsf-cli.git git clone https://github.com/Space-Banane/shsf-cli.git
cd shsf-cli cd shsf-cli
@@ -59,6 +64,31 @@ If you're contributing or running from source:
pnpm start [cmd] # Run a command directly 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 ## 🤝 Contributing
@@ -80,4 +110,3 @@ We love builders! To add a new command:
## 📄 License ## 📄 License
Licensed under the **MIT-0 License**. Happy coding! 🍌 Licensed under the **MIT-0 License**. Happy coding! 🍌
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "shsf-cli", "name": "shsf-cli",
"version": "2.2.2", "version": "2.2.8",
"description": "", "description": "",
"type": "module", "type": "module",
"files": [ "files": [
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import {
listFiles,
createOrUpdateFile,
deleteFile,
renameFile,
} from "../utils/fileops.js";
function makeClient(overrides: any = {}) {
return {
get: async (u: string) => ({ status: 200, data: { data: overrides.getData || [] } }),
put: async (u: string, p: any) => ({ status: overrides.putStatus || 200, data: { ok: true } }),
delete: async (u: string, opts: any) => ({ status: overrides.deleteStatus || 200, data: {} }),
patch: async (u: string, p: any) => ({ status: overrides.patchStatus || 200, data: {} }),
};
}
describe("fileops", () => {
it("lists files", async () => {
const client = makeClient({ getData: [{ id: "1", name: "a.txt" }] });
const files = await listFiles(client, "fn1");
expect(files).toHaveLength(1);
const file = files[0];
expect(file).toHaveProperty("id");
expect(file.id).toBe("1");
// Accept either `name` or `filename` depending on API shape
expect(file.name || file.filename).toBe("a.txt");
});
it("creates or updates a file", async () => {
const client = makeClient({ putStatus: 201 });
const res = await createOrUpdateFile(client, "fn1", "a.txt", "hello");
expect(res).toBeDefined();
});
it("deletes a file", async () => {
const client = makeClient({ deleteStatus: 200 });
const ok = await deleteFile(client, "fn1", "file1");
expect(ok).toBe(true);
});
it("renames a file", async () => {
const client = makeClient({ patchStatus: 200 });
const res = await renameFile(client, "fn1", "file1", "b.txt");
expect(res).toBeDefined();
});
});
+73
View File
@@ -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();
});
});
+96
View File
@@ -0,0 +1,96 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const updateFunctionDefinition = {
name: "add",
description:
"Adds or updates environment variables for a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{
name: "--name <name>",
description: "Environment variable name",
required: true,
},
{
name: "--value <value>",
description: "Environment variable value",
required: true,
},
],
action: async (options: any) => {
if (!options.id || !options.name || !options.value) {
console.error(
`${chalk.red("✗")} Error: Missing required options (--id, --name, --value)`,
);
return;
}
const client = await getApiClient();
let existingEnv: { name: string; value: string }[] = [];
try {
const response = await client.get(`/api/function/${options.id}`);
const functionData = response.data.data || response.data;
existingEnv = functionData.env || [];
if (typeof existingEnv === 'string') {
try {
existingEnv = JSON.parse(existingEnv);
} catch (e: any) {
existingEnv = [];
}
}
if (!Array.isArray(existingEnv)) {
existingEnv = [];
}
} catch (error: any) {
console.error(
`${chalk.red("✗")} Error fetching function data: ${error.message}`,
);
return;
}
const newEnv = [...existingEnv];
const existingIndex = newEnv.findIndex((env) => env.name === options.name);
const isUpdate = existingIndex !== -1;
if (isUpdate) {
newEnv[existingIndex].value = options.value;
} else {
newEnv.push({ name: options.name, value: options.value });
}
const totalCount = newEnv.length;
try {
const response = await client.patch(`/api/function/${options.id}`, {
environment: newEnv,
});
if (response.status === 200) {
const actionText = isUpdate ? "updated" : "added";
console.log(
`${chalk.green("✓")} Environment variable ${chalk.cyan(options.name)} ${actionText} successfully for function ${chalk.cyan(options.id)}.`,
);
console.log(
`${chalk.blue("")} Total environment variables: ${totalCount}`,
);
} else {
console.error(
`${chalk.red("✗")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response?.status === 404) {
console.error(
`${chalk.red("✗")} Function with ID ${chalk.yellow(options.id)} not found.`,
);
} else {
console.error(
`${chalk.red("✗")} Failed to update: ${chalk.yellow(error.response?.data?.message || error.message)}`,
);
}
}
},
};
+33
View File
@@ -0,0 +1,33 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const flushFunctionDefinition = {
name: "flush",
description: "Removes ALL environment variables from a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
],
action: async (options: any) => {
if (!options.id) {
console.error(`${chalk.red("✗")} Error: --id is required`);
return;
}
const client = await getApiClient();
try {
const response = await client.patch(`/api/function/${options.id}`, {
environment: [],
});
if (response.status === 200) {
console.log(
`${chalk.green("✓")} All environment variables flushed for function ${chalk.cyan(options.id)}.`
);
console.log(`${chalk.blue("")} Total environment variables: 0`);
}
} catch (error: any) {
console.error(`${chalk.red("✗")} Error: ${error.response?.data?.message || error.message}`);
}
},
};
+53
View File
@@ -0,0 +1,53 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const listEnvDefinition = {
name: "list",
description: "Lists all environment variables for a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
],
action: async (options: any) => {
if (!options.id) {
console.error(`${chalk.red("✗")} Error: --id is required`);
return;
}
const client = await getApiClient();
try {
const response = await client.get(`/api/function/${options.id}`);
const functionData = response.data.data || response.data;
let env = functionData.env;
if (typeof env === 'string' && env.length > 0) {
try {
env = JSON.parse(env);
} catch (e: any) {
console.error(`${chalk.red("✗")} Error parsing env string: ${e.message}`);
env = [];
}
}
const envList: { name: string; value: string }[] = Array.isArray(env) ? env : [];
if (envList.length === 0) {
console.log(`${chalk.yellow("!")} No environment variables found for function ${chalk.cyan(options.id)}.`);
return;
}
console.log(`${chalk.blue("")} Environment variables for function ${chalk.cyan(options.id)}:`);
envList.forEach((item) => {
console.log(`${chalk.green("•")} ${chalk.cyan(item.name)}=${chalk.white(item.value)}`);
});
console.log(`${chalk.blue("")} Total: ${envList.length}`);
} catch (error: any) {
if (error.response?.status === 404) {
console.error(`${chalk.red("✗")} Function with ID ${chalk.yellow(options.id)} not found.`);
} else {
console.error(`${chalk.red("✗")} Error fetching environment variables: ${chalk.yellow(error.response?.data?.message || error.message)}`);
}
}
},
};
+62
View File
@@ -0,0 +1,62 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const removeFunctionDefinition = {
name: "remove",
description: "Removes a specific environment variable from a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{
name: "--name <name>",
description: "Environment variable name to remove",
required: true,
},
],
action: async (options: any) => {
if (!options.id || !options.name) {
console.error(`${chalk.red("✗")} Error: --id and --name are required`);
return;
}
const client = await getApiClient();
try {
const response = await client.get(`/api/function/${options.id}`);
const functionData = response.data.data || response.data;
let existingEnv: { name: string; value: string }[] = functionData.env || [];
if (typeof existingEnv === 'string') {
try {
existingEnv = JSON.parse(existingEnv);
} catch (e: any) {
existingEnv = [];
}
}
if (!Array.isArray(existingEnv)) {
existingEnv = [];
}
const initialCount = existingEnv.length;
const filteredEnv = existingEnv.filter((env) => env.name !== options.name);
if (filteredEnv.length === initialCount) {
console.warn(`${chalk.yellow("!")} Variable ${chalk.cyan(options.name)} not found. No changes made.`);
return;
}
const patchResponse = await client.patch(`/api/function/${options.id}`, {
environment: filteredEnv,
});
if (patchResponse.status === 200) {
console.log(
`${chalk.green("✓")} Variable ${chalk.cyan(options.name)} removed successfully from function ${chalk.cyan(options.id)}.`
);
console.log(`${chalk.blue("")} Remaining environment variables: ${filteredEnv.length}`);
}
} catch (error: any) {
console.error(`${chalk.red("✗")} Error: ${error.response?.data?.message || error.message}`);
}
},
};
+3 -20
View File
@@ -1,6 +1,7 @@
import chalk from "chalk"; import chalk from "chalk";
import fs from "fs/promises"; import fs from "fs/promises";
import { getApiClient } from "../../api.js"; import { getApiClient } from "../../api.js";
import { createOrUpdateFile, handleAxiosError } from "../../utils/fileops.js";
export const fileCreateDefinition = { export const fileCreateDefinition = {
name: "create", name: "create",
@@ -33,31 +34,13 @@ export const fileCreateDefinition = {
} }
const client = await getApiClient(); const client = await getApiClient();
const payload = {
filename: options.filename,
code: content,
};
try { try {
const response = await client.put(`/api/function/${options.functionId}/file`, payload); await createOrUpdateFile(client, options.functionId, options.filename, content as string);
if (response.status === 200 || response.status === 201) {
console.log( console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.filename)} created/updated successfully in function ${chalk.cyan(options.functionId)}.`, `${chalk.green("✓")} File ${chalk.cyan(options.filename)} created/updated successfully in function ${chalk.cyan(options.functionId)}.`,
); );
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
}
} catch (error: any) { } catch (error: any) {
if (error.response) { handleAxiosError(error);
console.error(
`${chalk.red("✗")} Failed to create/update file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
} }
}, },
}; };
+3 -20
View File
@@ -1,5 +1,6 @@
import chalk from "chalk"; import chalk from "chalk";
import { getApiClient } from "../../api.js"; import { getApiClient } from "../../api.js";
import { deleteFile, handleAxiosError } from "../../utils/fileops.js";
export const fileDeleteDefinition = { export const fileDeleteDefinition = {
name: "delete", name: "delete",
@@ -10,31 +11,13 @@ export const fileDeleteDefinition = {
], ],
action: async (options: any) => { action: async (options: any) => {
const client = await getApiClient(); const client = await getApiClient();
try { try {
const response = await client.delete(`/api/function/${options.functionId}/file/${options.fileId}`); await deleteFile(client, options.functionId, options.fileId, undefined);
if (response.status === 200) {
console.log( console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.fileId)} deleted from function ${chalk.cyan(options.functionId)}.`, `${chalk.green("✓")} File ${chalk.cyan(options.fileId)} deleted from function ${chalk.cyan(options.functionId)}.`,
); );
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
}
} catch (error: any) { } catch (error: any) {
if (error.response) { handleAxiosError(error);
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} File or Function not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to delete file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
} }
}, },
}; };
+6 -14
View File
@@ -1,5 +1,6 @@
import chalk from "chalk"; import chalk from "chalk";
import { getApiClient } from "../../api.js"; import { getApiClient } from "../../api.js";
import { listFiles, handleAxiosError } from "../../utils/fileops.js";
export const fileListDefinition = { export const fileListDefinition = {
name: "list", name: "list",
@@ -11,10 +12,9 @@ export const fileListDefinition = {
const client = await getApiClient(); const client = await getApiClient();
try { try {
const response = await client.get(`/api/function/${options.functionId}/files`); const files = await listFiles(client, options.functionId);
if (response.status === 200 && response.data.data) { if (files) {
const files = response.data.data;
if (!Array.isArray(files) || files.length === 0) { if (!Array.isArray(files) || files.length === 0) {
console.log(`${chalk.yellow("!")} No files found.`); console.log(`${chalk.yellow("!")} No files found.`);
@@ -24,23 +24,15 @@ export const fileListDefinition = {
console.log(chalk.blue("Files:")); console.log(chalk.blue("Files:"));
console.log(chalk.gray(`${"ID".padEnd(25)} ${"Filename"}`)); console.log(chalk.gray(`${"ID".padEnd(25)} ${"Filename"}`));
files.forEach((file: any) => { files.forEach((file: any) => {
console.log(`${chalk.cyan(file.id.padEnd(25))} ${chalk.white(file.filename)}`); const id = String(file.id ?? file.name ?? "");
console.log(`${chalk.cyan(id.padEnd(25))} ${chalk.white(file.filename || file.name)}`);
}); });
console.log(`\n${chalk.green("✓")} Found ${chalk.bgGreen.black(` ${files.length} `)} files.`); console.log(`\n${chalk.green("✓")} Found ${chalk.bgGreen.black(` ${files.length} `)} files.`);
} else { } else {
console.log(`${chalk.yellow("!")} Unexpected response from server.`); console.log(`${chalk.yellow("!")} Unexpected response from server.`);
console.log(`Status Code: ${chalk.blue(response.status)}`);
} }
} catch (error: any) { } catch (error: any) {
if (error.response) { handleAxiosError(error);
console.error(
`${chalk.red("✗")} Failed to list files: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
} }
}, },
}; };
+3 -18
View File
@@ -1,5 +1,6 @@
import chalk from "chalk"; import chalk from "chalk";
import { getApiClient } from "../../api.js"; import { getApiClient } from "../../api.js";
import { renameFile, handleAxiosError } from "../../utils/fileops.js";
export const fileRenameDefinition = { export const fileRenameDefinition = {
name: "rename", name: "rename",
@@ -11,29 +12,13 @@ export const fileRenameDefinition = {
], ],
action: async (options: any) => { action: async (options: any) => {
const client = await getApiClient(); const client = await getApiClient();
try { try {
const response = await client.patch(`/api/function/${options.functionId}/file/${options.fileId}/rename`, { await renameFile(client, options.functionId, options.fileId, options.newFilename);
newFilename: options.newFilename,
});
if (response.status === 200) {
console.log( console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.fileId)} renamed to ${chalk.cyan(options.newFilename)} successfully.`, `${chalk.green("✓")} File ${chalk.cyan(options.fileId)} renamed to ${chalk.cyan(options.newFilename)} successfully.`,
); );
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
}
} catch (error: any) { } catch (error: any) {
if (error.response) { handleAxiosError(error);
console.error(
`${chalk.red("✗")} Failed to rename file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
} }
}, },
}; };
+86 -13
View File
@@ -2,12 +2,22 @@ import chalk from "chalk";
import { getApiClient } from "../../api.js"; import { getApiClient } from "../../api.js";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import {
defaultUnpushableFiles,
readIgnoreFile,
matchesAnyPattern,
readMappingFile,
} from "../../utils/push_helpers.js";
import { createHash } from "crypto"; import { createHash } from "crypto";
import type { ApiClient } from "../../types/apiClient.js";
async function deleteNonexistentFiles( async function deleteNonexistentFiles(
currentFiles: any[], currentFiles: any[],
files: any[], files: any[],
client: any, client: ApiClient,
options: any, options: any,
): Promise<{ didDeletion: boolean }> { ): Promise<{ didDeletion: boolean }> {
let didDeletion = false; let didDeletion = false;
@@ -19,7 +29,8 @@ async function deleteNonexistentFiles(
{ {
data: { filename: currentFile.name }, data: { filename: currentFile.name },
}, },
);} catch (error: any) { );
} catch (error: any) {
throw error; throw error;
} }
console.log( console.log(
@@ -35,18 +46,35 @@ export const pushDefinition = {
name: "push", name: "push",
description: "Push files to a remote function.", description: "Push files to a remote function.",
options: [ options: [
{ name: "--id <id>", description: "Function ID", required: true }, { name: "--id <id>", description: "Function ID" },
{ name: "--from <path>", description: "Source directory", required: true }, { name: "--from <path>", description: "Source directory" },
{ name: "--force", description: "Force overwrite" }, { name: "--force", description: "Force overwrite" },
], ],
action: async (options: any) => { action: async (options: any) => {
const client = await getApiClient(); 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)) { if (!fs.existsSync(options.from)) {
console.error(chalk.red(`Source ${options.from} does not exist.`)); console.error(chalk.red(`Source ${options.from} does not exist.`));
return; return;
} }
let didDeletion = false; // Track if any deletions were made let didDeletion = false; // Track if any deletions were made
const currentFilesResponse = await client.get( const currentFilesResponse = await client.get(
@@ -54,9 +82,22 @@ export const pushDefinition = {
); );
const currentFiles = currentFilesResponse.data.data; // Expecting { name: string, id: string [...] }[] const currentFiles = currentFilesResponse.data.data; // Expecting { name: string, id: string [...] }[]
const files = fs.readdirSync(options.from).map((file) => ({ // Read ignore patterns from .shsfignore (in source dir or cwd) and merge with defaults
filename: file, const customPatterns = readIgnoreFile(options.from);
content: fs.readFileSync(path.join(options.from, file), "utf-8"), const combinedPatterns = [...defaultUnpushableFiles, ...customPatterns];
// Read local files and filter to only include files (exclude directories)
const files = fs
.readdirSync(options.from)
.map((file) => {
const filePath = path.join(options.from, file);
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"),
})); }));
if (!options.force && files.length > 5) { if (!options.force && files.length > 5) {
@@ -76,9 +117,6 @@ export const pushDefinition = {
(f: any) => f.name === file.filename, (f: any) => f.name === file.filename,
); );
if (currentFile) { 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") const currentFileHash = createHash("md5")
.update(currentFile.content || "") .update(currentFile.content || "")
.digest("hex"); .digest("hex");
@@ -92,14 +130,27 @@ export const pushDefinition = {
} }
} }
const filenameLower = file.filename.toLowerCase();
// 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}`,
),
);
continue;
}
await client.put(`/api/function/${options.id}/file`, { await client.put(`/api/function/${options.id}/file`, {
filename: file.filename, filename: file.filename,
code: file.content, code: file.content,
}); });
const byteSize = Buffer.byteLength(file.content, 'utf8'); const byteSize = Buffer.byteLength(file.content, "utf8");
console.log( console.log(
chalk.green(`Pushed ${file.filename} (${byteSize} bytes) to function ${options.id}`), chalk.green(
`Pushed ${file.filename} (${byteSize} bytes) to function ${options.id}`,
),
); );
pushedFilesCount++; pushedFilesCount++;
} }
@@ -134,6 +185,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) { if (didDeletion) {
console.log(""); console.log("");
console.log( console.log(
+11
View File
@@ -0,0 +1,11 @@
// Minimal API client interface used by this CLI. Keep it small so tests can
// provide lightweight mocks without implementing the full AxiosInstance.
export interface ApiClient {
get(url: string, config?: any): Promise<{ status: number; data: any }>;
put(url: string, payload?: any, config?: any): Promise<{ status: number; data: any }>;
delete(url: string, config?: any): Promise<{ status: number; data: any }>;
patch(url: string, payload?: any, config?: any): Promise<{ status: number; data: any }>;
}
export type { ApiClient as ApiClientType };
+63
View File
@@ -0,0 +1,63 @@
import chalk from "chalk";
import type { ApiClient } from "../types/apiClient.js";
export async function listFiles(client: ApiClient, functionId: string) {
const response = await client.get(`/api/function/${functionId}/files`);
if (response.status === 200) return response.data.data || [];
throw new Error(`Unexpected status ${response.status}`);
}
export async function createOrUpdateFile(client: ApiClient, functionId: string, filename: string, code: string) {
const payload = { filename, code };
const response = await client.put(`/api/function/${functionId}/file`, payload);
if (response.status === 200 || response.status === 201) return response.data;
throw new Error(`Unexpected status ${response.status}`);
}
export async function deleteFile(client: ApiClient, functionId: string, fileId: string, filename?: string) {
// Some endpoints expect filename on body; include if provided.
const opts: any = {};
if (filename) opts.data = { filename };
const response = await client.delete(`/api/function/${functionId}/file/${fileId}`, opts);
if (response.status === 200) return true;
throw new Error(`Unexpected status ${response.status}`);
}
export async function renameFile(client: ApiClient, functionId: string, fileId: string, newFilename: string) {
const response = await client.patch(
`/api/function/${functionId}/file/${fileId}/rename`,
{ newFilename },
);
if (response.status === 200) return response.data;
throw new Error(`Unexpected status ${response.status}`);
}
export function handleAxiosError(error: any) {
if (error.response) {
// If the server signals a CLI file failure, stop further commands.
if (error.response.data?.cli_file_fail) {
console.error(`${chalk.red("✗")} ${error.response.data.message || "File operation blocked by server. (Probably because git is configured)"}`);
return { errorType: "cli_file_fail", reason: error.response.data.cli_file_fail, details: error.response };
}
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Not found.`);
return { errorType: "notfound" };
}
console.error(`${chalk.red("✗")} ${error.response.data?.message || error.response.statusText}`);
return { errorType: "response", details: error.response };
}
if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
return { errorType: "norequest" };
}
console.error(`${chalk.red("✗")} Error: ${error.message}`);
return { errorType: "other" };
}
export default {
listFiles,
createOrUpdateFile,
deleteFile,
renameFile,
handleAxiosError,
};
+98
View File
@@ -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;