14 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
24 changed files with 1037 additions and 111 deletions
+1
View File
@@ -3,3 +3,4 @@ dist
build build
pnpm-lock.yaml pnpm-lock.yaml
testing testing
.codex
+292
View File
@@ -63,6 +63,24 @@ this will check the health, and if not setup, it will prompt you to set up the C
- `shsf req add <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.
@@ -95,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.2.3", "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(
+6 -23
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);
console.log(
if (response.status === 200 || response.status === 201) { `${chalk.green("✓")} File ${chalk.cyan(options.filename)} created/updated successfully in function ${chalk.cyan(options.functionId)}.`,
console.log( );
`${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}`);
}
} }
}, },
}; };
+6 -23
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);
console.log(
if (response.status === 200) { `${chalk.green("✓")} File ${chalk.cyan(options.fileId)} deleted from function ${chalk.cyan(options.functionId)}.`,
console.log( );
`${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}`);
}
} }
}, },
}; };
+6 -21
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, console.log(
}); `${chalk.green("✓")} File ${chalk.cyan(options.fileId)} renamed to ${chalk.cyan(options.newFilename)} successfully.`,
);
if (response.status === 200) {
console.log(
`${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}`);
}
} }
}, },
}; };
+92 -19
View File
@@ -2,24 +2,35 @@ 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;
for (const currentFile of currentFiles) { for (const currentFile of currentFiles) {
if (!files.some((f: any) => f.filename === currentFile.name)) { if (!files.some((f: any) => f.filename === currentFile.name)) {
try { try {
await client.delete( await client.delete(
`/api/function/${options.id}/file/${currentFile.id}`, `/api/function/${options.id}/file/${currentFile.id}`,
{ {
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,10 +82,23 @@ 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) {
console.log( console.log(
@@ -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(
+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;