24 Commits

Author SHA1 Message Date
Space-Banane 544f6bd714 feat(cors): implement CORS management commands and utilities 2026-04-14 22:38:27 +02:00
Space-Banane c912c791fa Added coding docs from shsf to shsf-cli 2026-04-14 18:23:11 +02:00
Space-Banane d9d6871f09 bump version 2026-04-14 18:13:35 +02:00
Space-Banane 121b0429f6 fix(cli): use PUT for trigger updates to match OpenAPI spec 2026-04-14 18:11:13 +02:00
Space-Banane 9f78e2a221 fix: update version number to 2.2.9 in package.json and correct API endpoint in createNamespaceDefinition 2026-04-14 17:55:09 +02:00
Space-Banane 1a0e811a9a fix: update version number to 2.2.8 in package.json 2026-04-05 17:50:59 +02:00
Space-Banane 57b05da8c1 feat: enhance error handling for CLI file failures in handleAxiosError function 2026-04-05 17:49:40 +02:00
Space-Banane e6e2203751 feat: refactor file operations and add utility functions for file management 2026-04-05 17:46:38 +02:00
Space-Banane 5c18138dd2 fix: update version number to 2.2.7 in package.json 2026-04-03 17:51:23 +02:00
Space-Banane 9f2d72c3a0 feat: implement file ignore and mapping functionality for remote push command 2026-04-03 17:48:44 +02:00
Space-Banane dddb352457 Added some more unpushable files (for now) 2026-04-01 17:28:42 +02:00
Space-Banane 3fe3dc96da fix: update version number to 2.2.5 in package.json and enhance file reading logic in push command 2026-04-01 17:24:42 +02:00
Space-Banane e953466786 fix: update version number to 2.2.4 in package.json 2026-04-01 13:03:58 +02:00
Space-Banane c43c37ce34 feat: add unpushable file checks to prevent uploading certain file types 2026-04-01 13:01:47 +02:00
Space-Banane 13900343a3 feat: add commands for managing environment variables in functions & removed ui docs from skill 2026-03-27 17:18:00 +01:00
Space-Banane cc54ef85e1 fix: update command syntax for function execution in documentation 2026-03-27 16:56:54 +01:00
Space-Banane 67f05af7a5 feat: add execute command for function execution with streaming options 2026-03-27 16:55:28 +01:00
Space-Banane 37f6d50034 fix: requirements.txt modifications 2026-03-27 16:50:04 +01:00
Space-Banane a1f80d38f2 update skill to reflect changes 2026-03-27 16:38:18 +01:00
Space-Banane 7b659b1ef5 version-bump: commit of shame 2026-03-27 16:36:48 +01:00
Space-Banane 3261cc1830 unslopped file operations 2026-03-27 16:35:43 +01:00
Space a76bb15fc9 feat: add req subcommands for managing requirements.txt (#5)
* feat: add req subcommands for managing requirements.txt

* bump: version 2.1.9

* fix: address codex review comments (flag consistency, regex-based pkg parsing, error handling)

---------

Co-authored-by: Luna <clawy@reversed.dev>
2026-03-26 14:01:15 +01:00
Space 47cd88f72b feat: log byte count for push and pull 2026-03-20 18:01:29 +01:00
luna 6a98ef4bed feat: log byte count for push and pull, increment version 2026-03-20 17:59:41 +01:00
33 changed files with 1664 additions and 201 deletions
+1
View File
@@ -3,3 +3,4 @@ dist
build build
pnpm-lock.yaml pnpm-lock.yaml
testing testing
.codex
+306 -31
View File
@@ -33,6 +33,8 @@ 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 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.
- `shsf storage list`: List all storages. - `shsf storage list`: List all storages.
@@ -45,14 +47,40 @@ this will check the health, and if not setup, it will prompt you to set up the C
- `shsf update namespace <id>`: Update a specific namespace by its ID. (use `shsf update namespace -h` first) - `shsf update namespace <id>`: Update a specific namespace by its ID. (use `shsf update namespace -h` first)
- `shsf update trigger <functionId> <triggerId>`: Update a specific trigger from a function. (use `shsf update trigger -h` first) - `shsf update trigger <functionId> <triggerId>`: Update a specific trigger from a function. (use `shsf update trigger -h` first)
- `shsf file create`: Create a file in a storage. (use `shsf file create -h` first) - `shsf file create`: Create/update a file in a function. (use `shsf file create -h` first)
- `shsf file overwrite`: Overwrite an existing file in a storage. (use `shsf file overwrite -h` first) - `shsf file list`: List files in a function. (use `shsf file list -h` first)
- `shsf file delete`: Delete a file from a storage. (use `shsf file delete -h` first) - `shsf file rename`: Rename a file in a function. (use `shsf file rename -h` first)
- `shsf file list`: List files in a storage. (use `shsf file list -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 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.
@@ -62,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:
``` ```
@@ -112,3 +113,277 @@ and then replace the old one with the new one using `mv`.
## Missing a feature? ## Missing a feature?
Features might be not documented yet, as this skill document rarely updates. Features might be not documented yet, as this skill document rarely updates.
Use the shsf -h command to see options and features. Use the shsf -h command to see options and features.
CODING DOC:
# SHSF Platform Reference (Agent-Optimized)
## Critical Rules (check before writing any code)
- Python files use `.py`, Go files use `.go` — never mix runtimes
- Go package must be `main`; entry-point must be `main_user()`, never `main()`
- Python entry-point must be `def main(args):`
- **Always** `import json` and call `json.loads(args.get("body", "{}"))` before accessing body fields in Python
- Forbidden filenames: `_runner.py`, `_runner.js`, `init.sh`
- Filenames must never contain `/` or `\` — no subdirectories
- Never hard-code secrets — use environment variables via `os.getenv()`
- Never invent SHSF APIs not documented here
- Never write partial files or placeholder comments
- Only create `requirements.txt` / `go.mod` if dependencies are actually needed
---
## Entry Points
### Python
```python
def main(args):
return {"hello": "world"} # plain dict → 200 JSON
```
### Go
```go
package main
func main_user(args interface{}) (interface{}, error) {
return map[string]string{"hello": "world"}, nil
}
```
---
## The `args` Object
| Field | Type | Notes |
|------------|------------|-----------------------------------------------------------------------|
| `body` | string | Raw JSON string — **must** be parsed with `json.loads()` before use |
| `queries` | dict / map | URL query parameters |
| `route` | string | Sub-path segment after function URL. Default: `"default"` |
| `headers` | dict / map | Lowercased HTTP request headers |
| `raw_body` | bytes | Raw request body (file uploads, binary data) |
| `method` | string | HTTP method (GET, POST, …) |
Always use `.get()` / nil-checks — never assume a field is present.
### Python args example
```python
import json
def main(args):
body = json.loads(args.get("body", "{}")) # always parse first
queries = args.get("queries", {})
route = args.get("route", "default")
name = body.get("name", "stranger")
page = queries.get("page", "1")
return {"greeting": f"Hello {name}", "page": page, "route": route}
```
---
## Response Formats
### Simple 200 JSON (plain dict)
```python
return {"key": "value"}
```
### v2 Envelope (control status, headers, body)
```python
return {
"_shsf": "v2",
"_code": 201, # HTTP status code
"_headers": {"Content-Type": "application/json"}, # optional
"_res": {"created": True, "id": 42} # response body
}
```
### Error response
```python
return {"_shsf": "v2", "_code": 400, "_res": {"error": "missing field 'name'"}}
```
### Redirect (301 / 302)
```python
return {"_shsf": "v2", "_code": 302, "_location": "https://example.com/target"}
```
### HTML response
```python
def main(args):
with open("index.html", "r") as f:
html = f.read()
return {"_shsf": "v2", "_code": 200, "_headers": {"Content-Type": "text/html"}, "_res": html}
```
> **Static HTML shortcut**: if the only file is a single `.html` set as the startup file, SHSF serves it directly without spinning up a runtime.
---
## Routing
`args["route"]` holds the single URL segment after the function base URL (no leading slash, default `"default"`). Only **one** segment is supported.
```python
def main(args):
route = args.get("route", "default")
if route == "register": return handle_register(args)
elif route == "login": return handle_login(args)
elif route == "status": return {"status": "ok"}
else: return {"_shsf": "v2", "_code": 404, "_res": {"error": "route not found"}}
```
---
## Environment Variables
Never hard-code secrets. Define them in the SHSF dashboard.
```python
import os
def main(args):
api_key = os.getenv("MY_API_KEY", "")
if not api_key:
return {"_shsf": "v2", "_code": 500, "_res": {"error": "MY_API_KEY not set"}}
return {"ok": True}
```
Go: `apiKey := os.Getenv("MY_API_KEY")`
---
## Persistent Storage
| Path | Persistence | Use for |
|---------|--------------------------|---------------------------------|
| `/app/` | Persists across calls | Cache, state files |
| `/tmp/` | Wiped on container restart | Truly temporary scratch work |
**WARNING**: SHSF may restart or update containers, which recreates all of it, even the `/app/` directory. For critical data, use `_db_com` instead.
```python
import json, os
CACHE = "/app/cache.json"
def main(args):
data = json.load(open(CACHE)) if os.path.exists(CACHE) else {}
data["hits"] = data.get("hits", 0) + 1
json.dump(data, open(CACHE, "w"))
return {"hits": data["hits"]}
```
### Redis (shared key-value, fast)
```python
import redis
r = redis.Redis(host="localhost", port=6379, db=0)
def main(args):
r.incr("counter")
return {"counter": int(r.get("counter"))}
```
---
## Database (`_db_com`) — Python
`_db_com.py` is auto-provisioned. Add `requests` to `requirements.txt`.
```python
from _db_com import database
from datetime import datetime, timedelta
db = database()
def main(args):
db.create_storage("my_app", purpose="application data") # idempotent, safe every call
db.set("my_app", "username", "alice") # write
expires = (datetime.utcnow() + timedelta(hours=1)).isoformat()
db.set("my_app", "session", "tok_abc", expires_at=expires) # write with TTL
username = db.get("my_app", "username") # read (None if missing)
exists = db.exists("my_app", "username") # existence check
items = db.list_items("my_app") # list all keys
db.delete_item("my_app", "username") # delete
return {"username": username, "items": items}
```
### Go `dbcom`
```go
package main
import "myfunction/dbcom"
func main_user(args interface{}) (interface{}, error) {
db := dbcom.New()
if _, err := db.Set("my-storage", "key", "value", nil); err != nil {
return nil, err
}
value, err := db.Get("my-storage", "key")
if err != nil {
return nil, err
}
return map[string]interface{}{"value": value}, nil
}
```
---
## File Uploads / Raw Body
```python
def main(args):
raw = args.get("raw_body")
if raw is None:
return {"_shsf": "v2", "_code": 400, "_res": {"error": "no body provided"}}
if isinstance(raw, str):
raw = raw.encode("latin-1")
with open("/app/upload.bin", "wb") as f:
f.write(raw)
return {"_shsf": "v2", "_code": 200, "_res": {"saved": True}}
```
---
## Secure Header (`x-secure-header`)
When the secure-header feature is enabled, SHSF validates the token **before** invoking your function — no need to re-validate. Read it only for logging:
```python
def main(args):
token = args.get("headers", {}).get("x-secure-header", "")
return {"authenticated": True, "token_preview": token[:4] + ""}
```
---
## Dependency Files
| Runtime | File | Notes |
|---------|-------------------|------------------------------------------------|
| Python | `requirements.txt`| pip-installed before first run |
| Go | `go.mod`+`go.sum` | Module deps, auto-downloaded |
Only create these files if you have actual dependencies.
### Python `requirements.txt`
```
requests==2.31.0
beautifulsoup4==4.12.2
```
### Go `go.mod`
```
module myfunction
go 1.23
require (
github.com/google/uuid v1.3.0
)
```
Supported Go versions: `1.20`, `1.21`, `1.22`, `1.23`
+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.1.7", "version": "2.3.0",
"description": "", "description": "",
"type": "module", "type": "module",
"files": [ "files": [
+1 -1
View File
@@ -48,5 +48,5 @@ describe('Command Loading', () => {
console.log(`✓ Validated command: ${definition.name} (${path.relative(commandsDir, file)})`); console.log(`✓ Validated command: ${definition.name} (${path.relative(commandsDir, file)})`);
} }
}); }, 20000);
}); });
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { parseCorsOriginsOption } from "../utils/cors.js";
describe("parseCorsOriginsOption", () => {
it("returns undefined when no value is provided", () => {
expect(parseCorsOriginsOption(undefined)).toEqual({});
});
it("parses, normalizes, and deduplicates URLs", () => {
const parsed = parseCorsOriginsOption([
"https://example.com",
"https://example.com/path",
"http://localhost:3000,http://localhost:3000/",
]);
expect(parsed).toEqual({
corsOrigins: ["https://example.com", "http://localhost:3000"],
});
});
it("returns an error for invalid URLs", () => {
const parsed = parseCorsOriginsOption(["not-a-url"]);
expect(parsed.error).toMatch("Invalid CORS origin URL");
});
it("returns an error for unsupported protocols", () => {
const parsed = parseCorsOriginsOption(["ftp://example.com"]);
expect(parsed.error).toMatch("Only http and https are allowed");
});
});
+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();
});
});
+41
View File
@@ -0,0 +1,41 @@
import chalk from "chalk";
import {
getFunctionCorsOrigins,
handleCorsCommandError,
resolveFunctionId,
setFunctionCorsOrigins,
validateSingleOrigin,
} from "../../utils/cors_commands.js";
export const addCorsDefinition = {
name: "add <origin>",
description: "Add a CORS allowlist origin (must start with http:// or https://).",
options: [{ name: "--id <id>", description: "Function ID (falls back to .shsf.json default id)" }],
action: async (origin: string, options: { id?: string }) => {
const functionId = resolveFunctionId(options);
if (!functionId) return;
const validated = validateSingleOrigin(origin);
if (validated.error) {
console.error(`${chalk.red("✗")} ${validated.error}`);
return;
}
try {
const currentOrigins = await getFunctionCorsOrigins(functionId);
const normalizedOrigin = validated.origin as string;
if (currentOrigins.includes(normalizedOrigin)) {
console.log(`${chalk.yellow("!")} Origin ${chalk.cyan(normalizedOrigin)} already exists for function ${chalk.cyan(functionId)}.`);
return;
}
const updatedOrigins = [...currentOrigins, normalizedOrigin];
await setFunctionCorsOrigins(functionId, updatedOrigins);
console.log(`${chalk.green("✓")} Added CORS origin ${chalk.cyan(normalizedOrigin)} to function ${chalk.cyan(functionId)}.`);
} catch (error: any) {
handleCorsCommandError(error, "add CORS origin");
}
},
};
+30
View File
@@ -0,0 +1,30 @@
import chalk from "chalk";
import {
getFunctionCorsOrigins,
handleCorsCommandError,
resolveFunctionId,
setFunctionCorsOrigins,
} from "../../utils/cors_commands.js";
export const clearCorsDefinition = {
name: "clear",
description: "Clear all CORS allowlist origins for a function.",
options: [{ name: "--id <id>", description: "Function ID (falls back to .shsf.json default id)" }],
action: async (options: { id?: string }) => {
const functionId = resolveFunctionId(options);
if (!functionId) return;
try {
const currentOrigins = await getFunctionCorsOrigins(functionId);
if (currentOrigins.length === 0) {
console.log(`${chalk.yellow("!")} CORS allowlist is already empty for function ${chalk.cyan(functionId)}.`);
return;
}
await setFunctionCorsOrigins(functionId, []);
console.log(`${chalk.green("✓")} Cleared all CORS origins for function ${chalk.cyan(functionId)}.`);
} catch (error: any) {
handleCorsCommandError(error, "clear CORS origins");
}
},
};
+27
View File
@@ -0,0 +1,27 @@
import chalk from "chalk";
import { getFunctionCorsOrigins, handleCorsCommandError, resolveFunctionId } from "../../utils/cors_commands.js";
export const listCorsDefinition = {
name: "list",
description: "List CORS allowlist origins for a function.",
options: [{ name: "--id <id>", description: "Function ID (falls back to .shsf.json default id)" }],
action: async (options: { id?: string }) => {
const functionId = resolveFunctionId(options);
if (!functionId) return;
try {
const origins = await getFunctionCorsOrigins(functionId);
if (origins.length === 0) {
console.log(`${chalk.yellow("!")} No CORS allowlist origins configured for function ${chalk.cyan(functionId)}.`);
return;
}
console.log(`${chalk.blue("CORS Origins")} for function ${chalk.cyan(functionId)}:`);
origins.forEach((origin) => {
console.log(`- ${chalk.cyan(origin)}`);
});
} catch (error: any) {
handleCorsCommandError(error, "list CORS origins");
}
},
};
+41
View File
@@ -0,0 +1,41 @@
import chalk from "chalk";
import {
getFunctionCorsOrigins,
handleCorsCommandError,
resolveFunctionId,
setFunctionCorsOrigins,
validateSingleOrigin,
} from "../../utils/cors_commands.js";
export const removeCorsDefinition = {
name: "remove <origin>",
description: "Remove a CORS allowlist origin (must start with http:// or https://).",
options: [{ name: "--id <id>", description: "Function ID (falls back to .shsf.json default id)" }],
action: async (origin: string, options: { id?: string }) => {
const functionId = resolveFunctionId(options);
if (!functionId) return;
const validated = validateSingleOrigin(origin);
if (validated.error) {
console.error(`${chalk.red("✗")} ${validated.error}`);
return;
}
try {
const currentOrigins = await getFunctionCorsOrigins(functionId);
const normalizedOrigin = validated.origin as string;
if (!currentOrigins.includes(normalizedOrigin)) {
console.log(`${chalk.yellow("!")} Origin ${chalk.cyan(normalizedOrigin)} is not set for function ${chalk.cyan(functionId)}.`);
return;
}
const updatedOrigins = currentOrigins.filter((existingOrigin) => existingOrigin !== normalizedOrigin);
await setFunctionCorsOrigins(functionId, updatedOrigins);
console.log(`${chalk.green("✓")} Removed CORS origin ${chalk.cyan(normalizedOrigin)} from function ${chalk.cyan(functionId)}.`);
} catch (error: any) {
handleCorsCommandError(error, "remove CORS origin");
}
},
};
+1 -1
View File
@@ -17,7 +17,7 @@ export const createNamespaceDefinition = {
const client = await getApiClient(); const client = await getApiClient();
try { try {
const response = await client.post("/api/namespaces", data); const response = await client.post("/api/namespace", data);
if (response.status === 200 || response.status === 201) { if (response.status === 200 || response.status === 201) {
console.log( console.log(
+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}`);
}
},
};
+7 -24
View File
@@ -1,13 +1,14 @@
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",
description: "Create a file in a storage.", description: "Create or update a file in a function.",
options: [ options: [
{ name: "--storage-id <id>", description: "Storage ID", required: true }, { name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--path <path>", description: "Remote file path", required: true }, { name: "--filename <name>", description: "Remote filename", required: true },
{ name: "--content <content>", description: "Inline file content" }, { name: "--content <content>", description: "Inline file content" },
{ name: "--source <source>", description: "Local source file path" }, { name: "--source <source>", description: "Local source file path" },
], ],
@@ -33,31 +34,13 @@ export const fileCreateDefinition = {
} }
const client = await getApiClient(); const client = await getApiClient();
const payload = {
path: options.path,
content,
};
try { try {
const response = await client.post(`/api/storage/${options.storageId}/files`, 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.path)} created successfully in storage ${chalk.cyan(options.storageId)}.`, `${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 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}`);
}
} }
}, },
}; };
+7 -26
View File
@@ -1,42 +1,23 @@
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",
description: "Delete a file from a storage.", description: "Delete a file from a function.",
options: [ options: [
{ name: "--storage-id <id>", description: "Storage ID", required: true }, { name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--path <path>", description: "Remote file path", required: true }, { name: "--file-id <id>", description: "File ID (from 'file list')", required: true },
], ],
action: async (options: any) => { action: async (options: any) => {
const client = await getApiClient(); const client = await getApiClient();
try { try {
const response = await client.delete(`/api/storage/${options.storageId}/files`, { await deleteFile(client, options.functionId, options.fileId, undefined);
data: { path: options.path },
});
if (response.status === 200) {
console.log( console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.path)} deleted from storage ${chalk.cyan(options.storageId)}.`, `${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 ${chalk.yellow(options.path)} 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}`);
}
} }
}, },
}; };
+9 -20
View File
@@ -1,23 +1,20 @@
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",
description: "List files in a storage.", description: "List files in a function.",
options: [ options: [
{ name: "--storage-id <id>", description: "Storage ID", required: true }, { name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--prefix <prefix>", description: "Optional path prefix filter" },
], ],
action: async (options: any) => { action: async (options: any) => {
const client = await getApiClient(); const client = await getApiClient();
try { try {
const response = await client.get(`/api/storage/${options.storageId}/files`, { const files = await listFiles(client, options.functionId);
params: options.prefix ? { prefix: options.prefix } : undefined,
});
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.`);
@@ -25,25 +22,17 @@ export const fileListDefinition = {
} }
console.log(chalk.blue("Files:")); console.log(chalk.blue("Files:"));
console.log(chalk.gray(`${"ID".padEnd(25)} ${"Filename"}`));
files.forEach((file: any) => { files.forEach((file: any) => {
const path = file.path || file.name || "(unknown)"; const id = String(file.id ?? file.name ?? "");
console.log(`- ${chalk.cyan(path)}`); 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}`);
}
} }
}, },
}; };
-67
View File
@@ -1,67 +0,0 @@
import chalk from "chalk";
import fs from "fs/promises";
import { getApiClient } from "../../api.js";
export const fileOverwriteDefinition = {
name: "overwrite",
description: "Overwrite an existing file in a storage.",
options: [
{ name: "--storage-id <id>", description: "Storage ID", required: true },
{ name: "--path <path>", description: "Remote file path", required: true },
{ name: "--content <content>", description: "Inline file content" },
{ name: "--source <source>", description: "Local source file path" },
],
action: async (options: any) => {
if (!options.content && !options.source) {
console.error(`${chalk.red("✗")} You must provide either --content or --source.`);
return;
}
if (options.content && options.source) {
console.error(`${chalk.red("✗")} Please provide only one of --content or --source.`);
return;
}
let content = options.content;
if (options.source) {
try {
content = await fs.readFile(options.source, "utf8");
} catch (error: any) {
console.error(`${chalk.red("✗")} Failed to read source file: ${chalk.yellow(error.message)}`);
return;
}
}
const client = await getApiClient();
const payload = {
path: options.path,
content,
};
try {
const response = await client.put(`/api/storage/${options.storageId}/files`, payload);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.path)} overwritten successfully in storage ${chalk.cyan(options.storageId)}.`,
);
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} File ${chalk.yellow(options.path)} not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to overwrite 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}`);
}
}
},
};
+24
View File
@@ -0,0 +1,24 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { renameFile, handleAxiosError } from "../../utils/fileops.js";
export const fileRenameDefinition = {
name: "rename",
description: "Rename a file in a function.",
options: [
{ name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--file-id <id>", description: "File ID", required: true },
{ name: "--new-filename <name>", description: "New filename", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
await renameFile(client, options.functionId, options.fileId, options.newFilename);
console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.fileId)} renamed to ${chalk.cyan(options.newFilename)} successfully.`,
);
} catch (error: any) {
handleAxiosError(error);
}
},
};
+152
View File
@@ -0,0 +1,152 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { AxiosResponse } from "axios";
export const functionExecuteDefinition = {
name: "execute",
description: "Execute a function and stream the output (debug/internal).",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{ name: "--payload <json>", description: "JSON payload for execution", required: false },
{ name: "--no-stream", description: "Disable streaming and get final result only", required: false, default: false },
],
action: async (options: any) => {
const client = await getApiClient();
// Explicitly check for noStream from commander
// Commander uses camelsCase for options, so --no-stream becomes stream: false
// but we use --no-stream which commander natively handles as a boolean toggle
const useStream = options.stream !== false;
let payload = {};
if (options.payload) {
try {
payload = JSON.parse(options.payload);
} catch (error: any) {
console.error(`${chalk.red("✗")} Invalid JSON payload: ${chalk.yellow(error.message)}`);
return;
}
}
const handleJsonChunk = (chunk: string) => {
const trimmed = chunk.trim();
if (!trimmed) return;
try {
const obj = JSON.parse(trimmed);
if (obj.type === "output" && obj.content) {
process.stdout.write(obj.content);
} else if (obj.type === "end") {
if (obj.exitCode !== 0 && obj.exitCode !== undefined) {
console.error(`\n${chalk.red("✗")} Execution failed with exit code ${obj.exitCode}`);
} else {
console.log(`\n${chalk.green("✓")} Execution completed.`);
}
if (obj.result !== undefined) {
console.log(chalk.blue("Result:"), typeof obj.result === 'object' ? JSON.stringify(obj.result, null, 2) : obj.result);
}
if (obj.took) {
const total = obj.took.find((t: any) => t.description === "Total");
if (total) {
console.log(chalk.gray(`Total time: ${total.value}s`));
}
}
}
} catch (e) {
// If it's not JSON, it might be raw output from a non-stream response
// that got into the stream handler mistakenly or just raw noise
if (trimmed.length > 0) {
process.stdout.write(chunk);
}
// Not a full JSON object yet or invalid JSON
}
};
console.log(`${chalk.blue("")} Executing function ${chalk.cyan(options.id)} (stream: ${useStream})...`);
try {
const response = await client.post(`/api/function/${options.id}/execute`,
{ run: payload },
{
params: { stream: useStream ? "true" : "false" },
responseType: useStream ? "stream" : "json"
}
);
if (useStream) {
const stream = (response as AxiosResponse).data;
let buffer = "";
return new Promise<void>((resolve, reject) => {
stream.on("data", (chunk: any) => {
const str = chunk.toString();
buffer += str;
// Try to split by potential JSON boundaries
// Matches cases like ...}{...
const pattern = /\}\s*\{/g;
let lastIndex = 0;
let match;
while ((match = pattern.exec(buffer)) !== null) {
const part = buffer.slice(lastIndex, match.index + 1);
handleJsonChunk(part);
lastIndex = match.index + match[0].indexOf('{');
}
buffer = buffer.slice(lastIndex);
// Attempt to parse the remaining buffer if it looks like a complete object
try {
if (buffer.trim().startsWith('{') && buffer.trim().endsWith('}')) {
handleJsonChunk(buffer);
buffer = "";
}
} catch (e) {
// Wait for more data
}
});
stream.on("end", () => {
if (buffer.trim()) {
handleJsonChunk(buffer);
}
resolve();
});
stream.on("error", (err: Error) => {
console.error(`\n${chalk.red("✗")} Stream error: ${err.message}`);
reject(err);
});
});
} else {
// Non-stream returns just the output content or the body
const result = response.data;
if (typeof result === 'string') {
process.stdout.write(result);
} else if (result && result.data && typeof result.data === 'string') {
process.stdout.write(result.data);
} else if (result && typeof result.output === 'string') {
process.stdout.write(result.output);
} else if (result && typeof result.result === 'string') {
process.stdout.write(result.result);
} else if (result && result.data && result.data.output) {
process.stdout.write(result.data.output);
} else {
process.stdout.write(JSON.stringify(result, null, 2));
}
console.log(`\n${chalk.green("✓")} Execution completed.`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Execution failed: ${chalk.yellow(error.response.data?.message || error.response.statusText || "Unknown error")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+2
View File
@@ -26,6 +26,8 @@ export const pullDefinition = {
} }
for (const file of files) { for (const file of files) {
fs.writeFileSync(path.join(options.into, file.name), file.content); fs.writeFileSync(path.join(options.into, file.name), file.content);
const byteSize = Buffer.byteLength(file.content, 'utf8');
console.log(chalk.green(`Pulled ${file.name} (${byteSize} bytes)`));
} }
console.log(chalk.green(`Successfully pulled ${files.length} files into ${options.into}`)); console.log(chalk.green(`Successfully pulled ${files.length} files into ${options.into}`));
} catch (error: any) { } catch (error: any) {
+86 -12
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,13 +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");
console.log( console.log(
chalk.green(`Pushed ${file.filename} to function ${options.id}`), chalk.green(
`Pushed ${file.filename} (${byteSize} bytes) to function ${options.id}`,
),
); );
pushedFilesCount++; pushedFilesCount++;
} }
@@ -133,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(
+91
View File
@@ -0,0 +1,91 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
interface ShsfFile {
name: string;
content: string;
}
export const reqAddDefinition = {
name: "add <package>",
description: "Add a package to the requirements.txt of a function.",
options: [
{ name: "--to <id>", description: "The ID of the function (deprecated alias for --id)." },
{ name: "--id <id>", description: "The ID of the function.", required: true },
],
action: async (pkg: string, options: { to?: string; id?: string }) => {
const functionId = options.id ?? options.to;
if (!functionId) {
console.error(`${chalk.red("✗")} Error: Function ID is required. Use ${chalk.cyan("--id <id>")}.`);
return;
}
const client = await getApiClient();
try {
// 1. Get current files of the function
const response = await client.get(`/api/function/${functionId}/files`);
let files: ShsfFile[] = response.data.data;
if (!Array.isArray(files)) files = [];
let requirementsFile = files.find(f => f.name === "requirements.txt");
let content = requirementsFile ? requirementsFile.content : "";
// 2. Add the package
const lines = content.split("\n");
// Robust package name extraction
const getPkgName = (line: string) => {
const withoutComment = line.trim().split("#", 1)[0];
const basePart = withoutComment.split(";", 1)[0].trim();
if (!basePart) return null;
return basePart.split(/[!=>~<]/)[0].trim().toLowerCase();
};
const pkgToAddName = getPkgName(pkg);
if (!pkgToAddName) {
console.error(`${chalk.red("✗")} Invalid package format.`);
return;
}
const exists = lines.some(line => {
const lineName = getPkgName(line);
return lineName === pkgToAddName;
});
if (exists) {
console.log(`${chalk.yellow("!")} Package ${chalk.cyan(pkgToAddName)} is already in requirements.txt.`);
return;
}
// Preserve existing content structure
if (content && !content.endsWith("\n")) {
content += "\n";
}
content += pkg + "\n";
// 3. Overwrite/Create requirements.txt using function-based API
await client.put(`/api/function/${functionId}/file`, {
filename: "requirements.txt",
code: content,
});
console.log(`${chalk.green("✓")} Added ${chalk.cyan(pkg)} to requirements.txt for function ${chalk.yellow(functionId)}.`);
} catch (error: any) {
handleError(error, "add package");
}
},
};
function handleError(error: any, task: string) {
if (error.response) {
console.error(`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(error.response.data?.message || (error.response.data ? JSON.stringify(error.response.data) : error.message))}`);
} else if (error.request) {
console.error(
`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(
"No response received from server. Please check your network connection and try again."
)}`
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
+97
View File
@@ -0,0 +1,97 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
interface ShsfFile {
name: string;
content: string;
}
export const reqRemoveDefinition = {
name: "remove <package>",
description: "Remove a package from the requirements.txt of a function.",
options: [
{ name: "--from <id>", description: "The ID of the function (deprecated alias for --id)." },
{ name: "--id <id>", description: "The ID of the function.", required: true },
],
action: async (pkgToRemove: string, options: { from?: string; id?: string }) => {
const functionId = options.id ?? options.from;
if (!functionId) {
console.error(`${chalk.red("✗")} Error: Function ID is required. Use ${chalk.cyan("--id <id>")}.`);
return;
}
const client = await getApiClient();
try {
// 1. Get current files of the function
const response = await client.get(`/api/function/${functionId}/files`);
let files: ShsfFile[] = response.data.data;
if (!Array.isArray(files)) files = [];
const requirementsFile = files.find(f => f.name === "requirements.txt");
if (!requirementsFile) {
console.log(`${chalk.yellow("!")} No requirements.txt found for function ${chalk.yellow(functionId)}.`);
return;
}
const content = requirementsFile.content;
const lines = content.split("\n");
const targetPkgName = pkgToRemove.trim().toLowerCase();
let removed = false;
const newLines = lines.filter(line => {
const trimmedLine = line.trim();
// Preserve empty/whitespace-only lines
if (!trimmedLine) return true;
// Strip inline comments and environment markers for matching purposes
const withoutComment = trimmedLine.split("#", 1)[0];
const basePart = withoutComment.split(";", 1)[0].trim();
// If there's no actual requirement (e.g., comment-only line), keep the line
if (!basePart) return true;
// Robust extraction (handles != <= >= ~= == and < >)
const currentPkgName = basePart.split(/[!=>~<]/)[0].trim().toLowerCase();
if (currentPkgName === targetPkgName) {
removed = true;
return false;
}
return true;
});
if (!removed) {
console.log(`${chalk.yellow("!")} Package ${chalk.cyan(pkgToRemove)} was not found in requirements.txt.`);
return;
}
const newContent = newLines.join("\n");
// 2. Overwrite requirements.txt using function-based API
await client.put(`/api/function/${functionId}/file`, {
filename: "requirements.txt",
code: newContent,
});
console.log(`${chalk.green("✓")} Removed ${chalk.cyan(pkgToRemove)} from requirements.txt for function ${chalk.yellow(functionId)}.`);
} catch (error: any) {
handleError(error, "remove package");
}
},
};
function handleError(error: any, task: string) {
if (error.response) {
console.error(`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(error.response.data?.message || (error.response.data ? JSON.stringify(error.response.data) : error.message))}`);
} else if (error.request) {
console.error(
`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(
"No response received from server. Please check your network connection and try again."
)}`
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ export const updateTriggerDefinition = {
const client = await getApiClient(); const client = await getApiClient();
try { try {
const response = await client.patch(`/api/functions/${functionId}/triggers/${triggerId}`, data); const response = await client.put(`/api/functions/${functionId}/triggers/${triggerId}`, data);
if (response.status === 200) { if (response.status === 200) {
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 };
+42
View File
@@ -0,0 +1,42 @@
export function parseCorsOriginsOption(rawValue: unknown): { corsOrigins?: string[]; error?: string } {
if (rawValue === undefined || rawValue === null) {
return {};
}
const tokens = Array.isArray(rawValue) ? rawValue : [rawValue];
const splitValues = tokens
.map((value) => String(value).trim())
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean);
if (splitValues.length === 0) {
return { error: "No valid values were provided for --cors-origins." };
}
const uniqueOrigins: string[] = [];
const seen = new Set<string>();
for (const origin of splitValues) {
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
return { error: `Invalid CORS origin URL: ${origin}` };
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return { error: `Invalid CORS origin protocol for ${origin}. Only http and https are allowed.` };
}
const normalizedOrigin = parsed.origin;
if (!seen.has(normalizedOrigin)) {
seen.add(normalizedOrigin);
uniqueOrigins.push(normalizedOrigin);
}
}
return { corsOrigins: uniqueOrigins };
}
+85
View File
@@ -0,0 +1,85 @@
import chalk from "chalk";
import { getApiClient } from "../api.js";
import { readMappingFile } from "./push_helpers.js";
import { parseCorsOriginsOption } from "./cors.js";
export function resolveFunctionId(options: { id?: string }): string | null {
if (options.id) {
return options.id;
}
const mapping = readMappingFile();
if (mapping?.id) {
console.log(chalk.blue(`Using mapped id ${mapping.id} from .shsf.json`));
return mapping.id;
}
console.error(
`${chalk.red("✗")} Function ID is required. Use ${chalk.cyan("--id <id>")} or provide an ${chalk.cyan(".shsf.json")} mapping with an ${chalk.cyan("id")}.`,
);
return null;
}
export function validateSingleOrigin(origin: string): { origin?: string; error?: string } {
const parsed = parseCorsOriginsOption(origin);
if (parsed.error) return { error: parsed.error };
const normalized = parsed.corsOrigins?.[0];
if (!normalized) {
return { error: "No valid origin provided." };
}
return { origin: normalized };
}
export function parseCorsOriginsString(corsOriginsRaw: unknown): string[] {
if (typeof corsOriginsRaw !== "string") {
return [];
}
return corsOriginsRaw
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
export async function getFunctionCorsOrigins(functionId: string): Promise<string[]> {
const client = await getApiClient();
const response = await client.get(`/api/function/${functionId}/cors-origins`);
const rawValue =
response.data?.cors_origins ??
response.data?.data?.cors_origins ??
response.data?.data ??
"";
return parseCorsOriginsString(rawValue);
}
export async function setFunctionCorsOrigins(functionId: string, origins: string[]): Promise<void> {
const client = await getApiClient();
await client.patch(`/api/function/${functionId}/cors-origins`, {
cors_origins: origins.join(","),
});
}
export function handleCorsCommandError(error: any, action: string) {
if (error.response?.status === 404) {
console.error(`${chalk.red("✗")} Function not found.`);
return;
}
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to ${action}: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
);
return;
}
if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
return;
}
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
+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;