feat: enhance function execution options and logging capabilities
CI / CD / Test and Build (push) Failing after 1m3s
CI / CD / Publish (push) Has been skipped

- Added `--route` and `--method` options to `function execute` command for better HTTP handling.
- Improved `get function` command to display additional function metadata including HTTP settings, cache status, network restrictions, RAM limits, timeouts, and tags.
- Removed deprecated `helloworld` command.
- Expanded `update function` command with new options for Docker mounts, network restrictions, and various installation flags, including better error handling for boolean and number options.
- Introduced environment variable management commands: `add`, `list`, and `remove` for account-wide environment variables.
- Added `export` command to export authenticated account data.
- Implemented `api openapi` and `api request` commands for direct API interaction.
- Created logging and rate limit management commands for functions.
- Added MCP tool commands for calling tools, fetching documentation, and initializing sessions.
- Introduced utility functions for JSON handling and environment variable normalization.
- Set up CI/CD pipeline with Gitea Actions for automated testing, building, and publishing.
- Updated configuration loading to support environment variables for instance and token.
This commit is contained in:
Space-Banane
2026-07-10 22:24:47 +02:00
parent 0434376b83
commit 97b2089857
33 changed files with 1037 additions and 675 deletions
+121
View File
@@ -0,0 +1,121 @@
name: CI / CD
on:
push:
branches:
- "**"
pull_request:
branches:
- main
jobs:
build:
name: Test and Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Resolve pnpm store
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Test
run: pnpm test
- name: Build
run: pnpm build
- name: Package dist
run: zip -r dist.zip dist package.json README.md
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: shsf-cli-dist
path: dist.zip
publish:
name: Publish
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Resolve pnpm store
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build
run: pnpm build
- name: Publish to npm
run: |
if [ -z "${NODE_AUTH_TOKEN}" ]; then
echo "NPM_TOKEN is not configured; skipping npm publish."
exit 0
fi
pnpm publish --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Package release archive
run: zip -r dist.zip dist package.json README.md
- name: Create Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
if [ -z "${GITEA_TOKEN}" ]; then
echo "GITEA_TOKEN is not configured; skipping Gitea release."
exit 0
fi
VERSION="$(node -p "require('./package.json').version")"
API="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}/releases"
curl -sSf \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v${VERSION}\",\"target_commitish\":\"${GITHUB_SHA}\",\"name\":\"v${VERSION}\",\"body\":\"Automated SHSF CLI release v${VERSION}\",\"draft\":false,\"prerelease\":false}" \
"$API"
-29
View File
@@ -1,29 +0,0 @@
# SHSF CLI Project Guidelines
## Architecture
- **Framework**: Built with `commander` for CLI management.
- **Dynamic Commands**: Commands are dynamically loaded from `src/commands/` using recursive directory scanning in [src/commands.ts](src/commands.ts).
- **API Client**: Centralized Axios instance in [src/api.ts](src/api.ts) with standardized headers and singleton pattern for configuration injection.
- **Configuration**: Managed via `loadConfig` in [src/config.ts](src/config.ts), typically using environment variables or local files.
## Code Style
- **TypeScript**: Strict typing is preferred.
- **ES Modules**: The project uses `"type": "module"`. Always use `.js` extensions in imports if required by the runtime/build (though TS usually handles this, be mindful of ESM requirements).
- **Output**: Use `chalk` for terminal styling. Consistent color coding:
- Green: Success/Healthy
- Red: Errors/Failures
- Yellow: Warnings/Pending
## Build and Test
- **Install**: `pnpm install`
- **Build**: `pnpm build` (runs `rimraf dist && tsc`)
- **Run**: `pnpm start [command]` or `node dist/index.js [command]`
- **Binary**: The CLI is named `shsf`.
## Conventions
- **New Commands**: To add a command, create a new file in `src/commands/` (or a subfolder). It must export a definition object (default or named) with `name`, `description`, and `action`.
- **Error Handling**: Follow the pattern in [src/commands/health.ts](src/commands/health.ts) for handling Axios errors (check for `error.response`, `error.request`, etc.).
- **No Global Scope**: Keep command logic within the `action` function or extracted to utility modules to maintain testability.
## Versioning
- **Versioning**: Always increment the version number in package.json whenever a change is merged into main. The version format is major.minor.patch.
-99
View File
@@ -1,99 +0,0 @@
name: CI / CD
on:
push:
branches:
- '**'
paths:
- '**/*.ts'
- '**/*.json'
pull_request:
branches:
- main
paths:
- '**/*.ts'
- '**/*.json'
jobs:
build:
name: Test Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: pnpm install --no-frozen-lockfile
- run: pnpm test
- run: pnpm build
- name: Zip dist directory
run: zip -r dist.zip dist/
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist-artifact
path: dist.zip
publish:
name: Publish to NPM
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist-artifact
- run: unzip dist.zip
- run: pnpm publish --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
release:
name: Create GitHub Release
needs: publish
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist-artifact
- name: Get version from package.json
id: package_version
run: |
pkg_version=$(node -p "require('./package.json').version")
echo "VERSION=$pkg_version" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: dist.zip
tag_name: v${{ steps.package_version.outputs.VERSION }}
name: Release v${{ steps.package_version.outputs.VERSION }}
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+61
View File
@@ -0,0 +1,61 @@
# Agent Notes for SHSF CLI
This repository is the TypeScript CLI for SHSF. The sibling repository `../shsf` is the authoritative source for backend routes, request bodies, MCP tools, and platform behavior.
## Before Changing Commands
1. Inspect `../shsf/Backend/src/routes` for the live REST route.
2. Inspect `../shsf/Backend/src/lib/mcp` for agent-facing MCP tool behavior.
3. Prefer updating typed command wrappers for common workflows and rely on `shsf api request` for rare or admin-only routes.
4. Run `pnpm test` and `pnpm build` before considering the change complete.
## Command Architecture
Commands are auto-loaded from `src/commands`. Directory names become command groups. Each command file exports a definition object:
```ts
export const commandDefinition = {
name: "example <arg>",
description: "Do something",
options: [{ name: "--flag <value>", description: "Example option" }],
action: async (arg: string, options: any) => {}
};
```
Use `getApiClient()` from `src/api.ts` for REST calls. It handles `SHSF_INSTANCE`, `SHSF_TOKEN`, auth headers, content type, user agent, and timeout.
## Current Backend Alignment
The live backend expects function settings under `settings` for fields such as:
- `allow_http`
- `secure_header`
- `max_ram`
- `timeout`
- `tags`
- `retry_on_failure`
- `retry_count`
- `cache_enabled`
- `cache_ttl`
Top-level function fields include runtime/container controls such as `image`, `startup_file`, `namespaceId`, `executionAlias`, `docker_mount`, `network_restricted`, `ffmpeg_install`, `opencv_install`, `imported`, `ai_kicked_off`, and `cors_origins`.
## Agent-Ready Interfaces
Keep these commands working because they let agents recover when the typed CLI surface is behind the backend:
- `shsf api openapi`
- `shsf api request <method> <path>`
- `shsf mcp info`
- `shsf mcp init`
- `shsf mcp tools`
- `shsf mcp docs`
- `shsf mcp call <tool>`
Agents writing SHSF function source should call `shsf mcp docs` first. The SHSF MCP server also instructs clients to call `get_docs` before writing function code.
## CI/CD
Use Gitea Actions only. Workflow files live in `.gitea/workflows`. Do not add GitHub Actions or Copilot instructions back unless the project owner explicitly requests that migration.
The workflow should keep pnpm-store caching, test/build gates, npm publish guarded by `NPM_TOKEN`, and Gitea release creation guarded by `GITEA_TOKEN`.
+48
View File
@@ -0,0 +1,48 @@
# Claude Notes for SHSF CLI
Work from current evidence. The sibling repo `../shsf` is the source of truth for SHSF backend routes and MCP behavior.
## Fast Orientation
- CLI source: `src`
- Command loader: `src/commands.ts`
- REST client: `src/api.ts`
- Config: `src/config.ts`
- Tests: `src/__tests__`
- Gitea CI/CD: `.gitea/workflows/ci.yml`
## Backend Checks
When updating route support, inspect these files in `../shsf`:
- `Backend/src/routes` for REST paths and request schemas
- `Backend/src/routes/mcp.ts` and `Backend/src/lib/mcp` for MCP behavior
- `Backend/src/lib/aidoc.ts` for function-authoring rules returned by `get_docs`
Do not infer backend payloads from old CLI code when the backend source is available.
## Agent Workflow
Use these commands for discovery:
```bash
shsf api openapi
shsf mcp init
shsf mcp tools
shsf mcp docs
```
Use `shsf api request <method> <path>` for live REST endpoints that do not yet have typed wrappers.
Before writing SHSF function code, call `shsf mcp docs` or the MCP `get_docs` tool and follow the returned platform rules.
## Verification
Run:
```bash
pnpm test
pnpm build
```
Keep changes scoped. Do not reintroduce removed GitHub workflow metadata, editor-specific instruction files, or skill markdown files unless explicitly requested.
-408
View File
@@ -1,408 +0,0 @@
---
name: shsf
description: Interact with the users shsf instance to manage serverless functions, namespaces, triggers, schedules, and more via the CLI.
---
# SHSF - Selfhostable Serverless Functions; THE CLI
The `shsf` CLI tool allows you to interact with your shsf instance to manage serverless functions, namespaces, triggers, schedules, and more. Below are the available commands and their descriptions.
## Setup
To get started, you need to set up the `shsf` CLI tool. Follow the instructions below to install and configure it.
1. Run this
```bash
shsf health
```
this will check the health, and if not setup, it will prompt you to set up the CLI.
## Commands
- `shsf count functions`: Count your functions. Add `--full` to list them.
- `shsf count namespaces`: Count your namespaces. Add `--full` to list them.
- `shsf count storages`: Count your storages. Add `--full` to list them.
- `shsf count triggers`: Count your triggers. Add `--full` to list them.
- `shsf create function`: Create a new function. (use `shsf create function -h` first)
- `shsf create namespace`: Create a new namespace. (use `shsf create namespace -h` first)
- `shsf create trigger`: Create a new trigger. (use `shsf create trigger -h` first)
- `shsf delete function <id>`: Deletes a specific serverless function by its ID.
- `shsf delete namespace <id>`: Deletes a namespace and all its functions by ID.
- `shsf delete trigger <functionId> <triggerId>`: Deletes a specific trigger from a function.
- `shsf get function <id>`: Get details of a specific function by its ID.
- `shsf get exec-url [--id <id>]`: Get the execution URL for a function. Falls back to `.shsf.json` for the function ID and also prints the alias URL when the function has an `executionAlias`.
- `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 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 delete --name <name>`: Delete a storage.
- `shsf storage list`: List all storages.
- `shsf storage get-items --name <name>`: List all items in a storage.
- `shsf storage set-item --name <name> --key <key> --value <value> [--expires <expires>]`: Set a storage item (value can be JSON).
- `shsf storage delete-item --name <name> --key <key>`: Delete a storage item.
- `shsf storage clear-items --name <name>`: Clear all items from a storage.
- `shsf update function <id>`: Update a specific serverless function by its ID. (use `shsf update function -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 file create`: Create/update a file in a function. (use `shsf file create -h` first)
- `shsf file list`: List files in a function. (use `shsf file list -h` first)
- `shsf file rename`: Rename a file in a function. (use `shsf file rename -h` first)
- `shsf file delete`: Delete a file from a function. (use `shsf file delete -h` first)
- `shsf 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 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`.
This mapping can also be used with `shsf get exec-url`, so from a mapped function directory you can run:
```bash
shsf get exec-url
```
## 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.
## Ui Links
Get the ui url with:
```bash
shsf uiurl
```
### 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:
```
[UI_URL]/functions/[ID]
```
To get the function execution URL from the CLI:
```bash
shsf get exec-url --id 81
```
If the function has an execution alias configured, the CLI also prints the alias form:
```text
[API_URL]/exec/[executionAlias]
```
## Update
Update with your package manager of choice. ALWAYS pnpm:
```bash
pnpm add -g shsf-cli
```
Note that new features are often released and a user might tell you to do things that are only in the latest version. So its a good idea to always keep it updated.
It is also good practice to update this skill every now and then, as it might get new features that are not documented here yet.
Download the latest version with:
```
curl https://raw.githubusercontent.com/Space-Banane/shsf-cli/refs/heads/main/OpenclawSkill.md > /tmp/OpenclawSkill.md
```
and then replace the old one with the new one using `mv`.
## Missing a feature?
Features might be not documented yet, as this skill document rarely updates.
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`
+88 -86
View File
@@ -1,128 +1,130 @@
# 🚀 SHSF CLI
# SHSF CLI
A powerful command-line interface for managing and interacting with your SHSF instance. Built with TypeScript and designed for developers, it provides a seamless experience to perform health checks, run tests, and more—all from your terminal.
Production-ready command-line tooling for SHSF instances. The CLI manages functions, namespaces, files, triggers, storage, environment variables, CORS, execution, logs, rate limits, and exposes the live SHSF REST and MCP surfaces for agents.
[![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/)
## Requirements
## 🚦 Getting Started
- Node.js 22 or newer
- pnpm 10 or newer
- An SHSF access token
### 📋 Prerequisites
- **Node.js** (v22+)
- **pnpm** (preferred)
### 📦 Installation
To install **SHSF CLI** globally on your system:
## Install
```bash
pnpm add -g shsf-cli
```
or
From source:
```bash
npm install -g shsf-cli
pnpm install
pnpm build
pnpm start -- health
```
---
## Configuration
Once installed, simply type:
The CLI reads configuration in this order:
1. `SHSF_INSTANCE` and `SHSF_TOKEN` environment variables.
2. `~/.shsf_config`, created interactively on first use.
Example non-interactive configuration:
```bash
export SHSF_INSTANCE=https://shsf.example.com
export SHSF_TOKEN=token_xxx
shsf health
```
and it will ask you for your SHSF instance URL and API token to perform a health check.
## 🛠️ Usage & Commands
### 🩺 Health Check
Quickly see if the system is up and running:
## Core Commands
```bash
shsf health
shsf uiurl
shsf create namespace --name apps
shsf create function --name api --description "API handler" --image python:3.13 --startup-file main.py --namespace-id 1 --allow-http true
shsf update function 42 --cache-enabled true --cache-ttl 60 --network-restricted true
shsf get function 42
shsf get exec-url --id 42
shsf function execute --id 42 --payload '{"name":"Ada"}' --route users/list --method POST --no-stream
```
### 🔗 Execution URL
Get a function's execution URL by ID:
Files can be synchronized from a local directory:
```bash
shsf get exec-url --id <id>
shsf remote push --id 42 --from ./functions/api --force
shsf remote pull --id 42 --to ./functions/api
```
If your repo has a `.shsf.json` mapping with an `id`, you can omit `--id`:
```bash
shsf get exec-url
```
The command prints the standard execution URL and, when available, the alias URL based on the function's `executionAlias`.
### 🏗️ Local Development
If you're contributing or running from source:
1. **Setup**:
```bash
git clone https://github.com/Space-Banane/shsf-cli.git
cd shsf-cli
pnpm install
```
2. **Run**:
```bash
pnpm build # Build the project
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:
Use `.shsfignore` for gitignore-style push exclusions and `.shsf.json` for local mappings:
```json
{
"default": {
"id": "my-function-id",
"from": "src/functions/my-fn"
"id": "42",
"from": "functions/api"
}
}
```
Command-line options always override values from `.shsf.json`.
## Current SHSF Features
## 🤝 Contributing
Typed commands cover the common stable workflows:
We love builders! To add a new command:
- `create|get|update|delete namespace`
- `create|get|update|delete function`
- function execution, dependency install, logs, logging config, rate-limit config
- file create/list/rename/delete
- trigger create/get/update/delete plus run-now
- storage create/list/delete, item get/set/delete/clear
- function and account-level environment variables
- CORS origin list/add/remove/clear
- function counts and health checks
1. Create a `.ts` file in `src/commands/`.
2. Export a definition object:
```typescript
export const myCommand = {
name: "run-test",
description: "Explanation of what it does",
action: async () => { ... }
};
```
3. Run `pnpm build` and test it with `shsf run-test`.
The live backend also exposes admin, global settings, Git source, import/replace, guest, AI, account export, OpenAPI, and MCP routes. Use `shsf api request` for any route without a specialized wrapper:
---
```bash
shsf api openapi
shsf api request GET /api/functions
shsf api request PATCH /api/function/42/logging --data '{"enabled":true,"hide_payload_headers":true}'
shsf api request POST /api/function/42/git/pull
```
## 📄 License
## Agent Use
Licensed under the **MIT-0 License**. Happy coding! 🍌
SHSF exposes an MCP server at `/mcp`. The CLI has direct wrappers for agent bootstrap and tool calls:
```bash
shsf mcp info
shsf mcp init
shsf mcp tools
shsf mcp docs
shsf mcp call list_functions --args '{"namespace":"apps"}'
shsf mcp call write_file --args '{"id":42,"filename":"main.py","content":"def main(args): return {\"ok\": True}"}'
```
Agents should call `shsf mcp docs` before writing SHSF function code. The docs include runtime entry points, `args` shape, v2 response envelope, routing, dependencies, storage, and absolute platform rules.
## CI/CD
This repository uses Gitea Actions in `.gitea/workflows/ci.yml`.
- Build job: checkout, pnpm setup, Node 24, pnpm-store cache, install, test, build, package artifact.
- Publish job on `main`: rebuilds, publishes to npm when `NPM_TOKEN` is configured, and creates a Gitea release when `GITEA_TOKEN` is configured.
GitHub Actions and Copilot-specific instructions were removed.
## Development
```bash
pnpm test
pnpm build
pnpm test:coverage
```
Add a command by creating a file under `src/commands`. Export an object with `name`, `description`, optional `options`, and `action`. Directory names become command groups automatically.
## License
MIT-0
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "shsf-cli",
"version": "2.3.1",
"description": "",
"description": "Production-ready command-line interface and agent bridge for SHSF.",
"type": "module",
"files": [
"dist"
@@ -18,7 +18,7 @@
},
"keywords": [],
"author": "",
"license": "ISC",
"license": "MIT-0",
"packageManager": "pnpm@10.30.0",
"devDependencies": {
"@types/inquirer": "^9.0.9",
+13 -6
View File
@@ -19,26 +19,33 @@ function getFilesRecursively(dir: string): string[] {
}
describe('API URL validation', () => {
it('should ensure all API client calls start with /api/', () => {
it('should ensure API client calls use known SHSF route prefixes', () => {
const tsFiles = getFilesRecursively('src');
const violations: string[] = [];
// Regex to match common patterns like client.get('/path') or client.post(`/path`)
// specifically looking for paths that start with / but NOT /api/ or /health
const urlPattern = /client\.(get|post|put|delete|patch)\(['"`]\/(?!(api|health)\/)[^'"`]+['"`]/g;
// specifically looking for paths that start with / but NOT known SHSF prefixes.
const urlPattern = /client\.(get|post|put|delete|patch)\(['"`]\/(?!(api|health|mcp)(\/|['"`]))[^'"`]+['"`]/g;
tsFiles.forEach(file => {
const content = readFileSync(file, 'utf-8');
let match;
while ((match = urlPattern.exec(content)) !== null) {
// Double check for exact /health match (without trailing slash)
if (match[0].includes("'/health'") || match[0].includes('"/health"') || match[0].includes('`/health`')) {
// Double check exact non-API endpoints without trailing slash.
if (
match[0].includes("'/health'") ||
match[0].includes('"/health"') ||
match[0].includes('`/health`') ||
match[0].includes("'/mcp'") ||
match[0].includes('"/mcp"') ||
match[0].includes('`/mcp`')
) {
continue;
}
violations.push(`${file}: ${match[0]}`);
}
});
expect(violations, `Found API calls not starting with /api/:\n${violations.join('\n')}`).toHaveLength(0);
expect(violations, `Found client calls outside known SHSF prefixes:\n${violations.join('\n')}`).toHaveLength(0);
});
});
+1
View File
@@ -14,6 +14,7 @@ export async function getApiClient(): Promise<AxiosInstance> {
apiClient = axios.create({
baseURL: config.SHSF_INSTANCE,
timeout: 120_000,
headers: {
'x-access-key': config.SHSF_TOKEN,
'Content-Type': 'application/json',
+38
View File
@@ -0,0 +1,38 @@
import chalk from "chalk";
import { getApiClient } from "../../../api.js";
import { normalizeEnvList } from "../../../utils/env_vars.js";
export const accountEnvAddDefinition = {
name: "add",
description: "Add or update an account-wide environment variable.",
options: [
{ name: "--name <name>", description: "Environment variable name", required: true },
{ name: "--value <value>", description: "Environment variable value", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const current = await client.get("/api/account/settings");
const env = normalizeEnvList(current.data?.data?.accountEnvironment);
const existingIndex = env.findIndex((item) => item.name === options.name);
const updated = existingIndex >= 0;
if (updated) {
env[existingIndex].value = options.value;
} else {
env.push({ name: options.name, value: options.value });
}
await client.patch("/api/account/settings", {
accountEnvironment: env,
});
console.log(
`${chalk.green("✓")} Account environment variable ${chalk.cyan(options.name)} ${updated ? "updated" : "added"}.`,
);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.response?.data?.message || error.message}`);
}
},
};
+28
View File
@@ -0,0 +1,28 @@
import chalk from "chalk";
import { getApiClient } from "../../../api.js";
import { normalizeEnvList } from "../../../utils/env_vars.js";
export const accountEnvListDefinition = {
name: "list",
description: "List account-wide environment variables.",
action: async () => {
const client = await getApiClient();
try {
const response = await client.get("/api/account/settings");
const env = normalizeEnvList(response.data?.data?.accountEnvironment);
if (env.length === 0) {
console.log(`${chalk.yellow("!")} No account-wide environment variables found.`);
return;
}
env.forEach((item) => {
console.log(`${chalk.green("•")} ${chalk.cyan(item.name)}=${chalk.white(item.value)}`);
});
console.log(`${chalk.blue("Total:")} ${env.length}`);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.response?.data?.message || error.message}`);
}
},
};
+33
View File
@@ -0,0 +1,33 @@
import chalk from "chalk";
import { getApiClient } from "../../../api.js";
import { normalizeEnvList } from "../../../utils/env_vars.js";
export const accountEnvRemoveDefinition = {
name: "remove",
description: "Remove an account-wide environment variable.",
options: [
{ name: "--name <name>", description: "Environment variable name", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const current = await client.get("/api/account/settings");
const env = normalizeEnvList(current.data?.data?.accountEnvironment);
const next = env.filter((item) => item.name !== options.name);
if (next.length === env.length) {
console.log(`${chalk.yellow("!")} Account environment variable ${chalk.cyan(options.name)} was not found.`);
return;
}
await client.patch("/api/account/settings", {
accountEnvironment: next,
});
console.log(`${chalk.green("✓")} Account environment variable ${chalk.cyan(options.name)} removed.`);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.response?.data?.message || error.message}`);
}
},
};
+27
View File
@@ -0,0 +1,27 @@
import chalk from "chalk";
import fs from "fs";
import { getApiClient } from "../../api.js";
import { printJson } from "../../utils/json.js";
export const accountExportDefinition = {
name: "export",
description: "Export the authenticated SHSF account data.",
options: [
{ name: "--out <path>", description: "Write export JSON to a file instead of stdout" },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.get("/api/account/export");
if (options.out) {
fs.writeFileSync(options.out, JSON.stringify(response.data, null, 2));
console.log(`${chalk.green("✓")} Account export written to ${chalk.cyan(options.out)}.`);
} else {
printJson(response.data);
}
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.response?.data?.message || error.message}`);
}
},
};
+23
View File
@@ -0,0 +1,23 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { printJson } from "../../utils/json.js";
export const apiOpenApiDefinition = {
name: "openapi",
description: "Print the live SHSF OpenAPI document.",
action: async () => {
const client = await getApiClient();
try {
const response = await client.get("/api/openapi.json");
printJson(response.data);
} catch (error: any) {
if (error.response) {
console.error(`${chalk.red("✗")} Failed to fetch OpenAPI: ${error.response.status}`);
printJson(error.response.data);
} else {
console.error(`${chalk.red("✗")} ${error.message}`);
}
}
},
};
+73
View File
@@ -0,0 +1,73 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { printJson, readJsonInput } from "../../utils/json.js";
function normalizePath(input: string): string {
return input.startsWith("/") ? input : `/${input}`;
}
function parseHeaders(rawHeaders: string | string[] | undefined): Record<string, string> {
const headers: Record<string, string> = {};
const values = Array.isArray(rawHeaders)
? rawHeaders
: rawHeaders
? [rawHeaders]
: [];
for (const header of values) {
const separator = header.indexOf(":");
if (separator === -1) {
throw new Error(`Invalid header "${header}". Use "Name: value".`);
}
const name = header.slice(0, separator).trim();
const value = header.slice(separator + 1).trim();
if (!name) throw new Error(`Invalid header "${header}". Header name is empty.`);
headers[name] = value;
}
return headers;
}
export const apiRequestDefinition = {
name: "request <method> <path>",
description: "Call any SHSF REST endpoint with the configured access token.",
options: [
{ name: "--data <json>", description: "JSON request body" },
{ name: "--data-file <path>", description: "Read JSON request body from a file" },
{ name: "--query <json>", description: "JSON query parameters" },
{ name: "--header <header...>", description: "Additional header as 'Name: value'" },
],
action: async (method: string, path: string, options: any) => {
try {
const client = await getApiClient();
const upperMethod = method.toUpperCase();
const query = options.query ? readJsonInput({ data: options.query }) : undefined;
const headers = parseHeaders(options.header);
const hasBody = !["GET", "HEAD"].includes(upperMethod);
const data = hasBody
? readJsonInput({
data: options.data,
dataFile: options.dataFile,
defaultValue: undefined,
})
: undefined;
const response = await client.request({
method: upperMethod,
url: normalizePath(path),
params: query,
headers,
data,
});
printJson(response.data);
} catch (error: any) {
if (error.response) {
console.error(`${chalk.red("✗")} ${error.response.status} ${error.response.statusText}`);
printJson(error.response.data);
} else {
console.error(`${chalk.red("✗")} ${error.message}`);
}
}
},
};
+69 -16
View File
@@ -1,6 +1,24 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
function parseBooleanOption(value: unknown, optionName: string): boolean | undefined {
if (value === undefined) return undefined;
const normalized = String(value).toLowerCase();
if (normalized !== "true" && normalized !== "false") {
throw new Error(`Invalid value for ${optionName}. Use true or false.`);
}
return normalized === "true";
}
function parseNumberOption(value: unknown, optionName: string): number | undefined {
if (value === undefined) return undefined;
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid value for ${optionName}. Please provide a number.`);
}
return parsed;
}
export const createFunctionDefinition = {
name: "function",
description: "Create a new serverless function.",
@@ -8,44 +26,79 @@ export const createFunctionDefinition = {
{ name: "--name <name>", description: "Function name", required: true },
{ name: "--description <description>", description: "Function description", required: true },
{ name: "--image <image>", description: "Docker image tag", required: true },
{ name: "--startup-file <file>", description: "Startup file name", required: true },
{ name: "--startup-file <file>", description: "Startup file name (optional for .NET runtimes)" },
{ name: "--namespace-id <id>", description: "Namespace ID", required: true },
{ name: "--execution-alias <alias>", description: "Custom execution alias" },
{ name: "--docker-mount", description: "Enable Docker mount", type: "boolean" },
{ name: "--network-restricted", description: "Disable outbound network access", type: "boolean" },
{ name: "--ffmpeg-install", description: "Install ffmpeg in container", type: "boolean" },
{ name: "--opencv-install", description: "Install opencv in container", type: "boolean" },
{ name: "--imported", description: "Marks the function as imported", type: "boolean" },
{ name: "--ai-kicked-off", description: "Marks the function as created via AI kickoff", type: "boolean" },
{ name: "--allow-http <enabled>", description: "Allow HTTP execution (true/false)" },
{ name: "--secure-header <value>", description: "Require x-secure-header for HTTP execution" },
{ name: "--max-ram <mb>", description: "Max RAM in MB" },
{ name: "--timeout <seconds>", description: "Execution timeout in seconds" },
{ name: "--tags <csv>", description: "Comma-separated function tags" },
{ name: "--retry-on-failure <enabled>", description: "Retry failed executions (true/false)" },
{ name: "--retry-count <count>", description: "Retry count" },
{ name: "--cache-enabled <enabled>", description: "Enable response caching (true/false)" },
{ name: "--cache-ttl <seconds>", description: "Cache TTL in seconds" },
{ name: "--cors-origins <origins>", description: "Comma-separated allowed CORS origins" },
],
action: async (options: any) => {
let parsedCacheEnabled: boolean | undefined;
if (options.cacheEnabled !== undefined) {
const normalized = String(options.cacheEnabled).toLowerCase();
if (normalized !== "true" && normalized !== "false") {
console.error(`${chalk.red("✗")} Invalid value for --cache-enabled. Use true or false.`);
return;
}
parsedCacheEnabled = normalized === "true";
}
let allowHttp: boolean | undefined;
let retryOnFailure: boolean | undefined;
let cacheEnabled: boolean | undefined;
let maxRam: number | undefined;
let timeout: number | undefined;
let retryCount: number | undefined;
let cacheTtl: number | undefined;
const parsedCacheTtl = options.cacheTtl !== undefined ? Number(options.cacheTtl) : undefined;
if (parsedCacheTtl !== undefined && Number.isNaN(parsedCacheTtl)) {
console.error(`${chalk.red("✗")} Invalid value for --cache-ttl. Please provide a number.`);
try {
allowHttp = parseBooleanOption(options.allowHttp, "--allow-http");
retryOnFailure = parseBooleanOption(options.retryOnFailure, "--retry-on-failure");
cacheEnabled = parseBooleanOption(options.cacheEnabled, "--cache-enabled");
maxRam = parseNumberOption(options.maxRam, "--max-ram");
timeout = parseNumberOption(options.timeout, "--timeout");
retryCount = parseNumberOption(options.retryCount, "--retry-count");
cacheTtl = parseNumberOption(options.cacheTtl, "--cache-ttl");
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.message}`);
return;
}
const settings: Record<string, any> = {};
if (allowHttp !== undefined) settings.allow_http = allowHttp;
if (options.secureHeader !== undefined) settings.secure_header = options.secureHeader;
if (maxRam !== undefined) settings.max_ram = maxRam;
if (timeout !== undefined) settings.timeout = timeout;
if (options.tags !== undefined) {
settings.tags = String(options.tags)
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
if (retryOnFailure !== undefined) settings.retry_on_failure = retryOnFailure;
if (retryCount !== undefined) settings.retry_count = retryCount;
if (cacheEnabled !== undefined) settings.cache_enabled = cacheEnabled;
if (cacheTtl !== undefined) settings.cache_ttl = cacheTtl;
const data = {
name: options.name,
description: options.description,
image: options.image,
startup_file: options.startupFile,
startup_file: options.startupFile ?? "",
namespaceId: parseInt(options.namespaceId),
executionAlias: options.executionAlias,
docker_mount: !!options.dockerMount,
network_restricted: !!options.networkRestricted,
ffmpeg_install: !!options.ffmpegInstall,
opencv_install: !!options.opencvInstall,
imported: !!options.imported,
cache_enabled: parsedCacheEnabled,
cache_ttl: parsedCacheTtl,
ai_kicked_off: !!options.aiKickedOff,
cors_origins: options.corsOrigins,
...(Object.keys(settings).length > 0 ? { settings } : {}),
};
const client = await getApiClient();
+8
View File
@@ -8,6 +8,8 @@ export const functionExecuteDefinition = {
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{ name: "--payload <json>", description: "JSON payload for execution", required: false },
{ name: "--route <path>", description: "Function sub-route to execute", required: false },
{ name: "--method <method>", description: "HTTP method forwarded to the function", required: false },
{ name: "--no-stream", description: "Disable streaming and get final result only", required: false, default: false },
],
action: async (options: any) => {
@@ -27,6 +29,12 @@ export const functionExecuteDefinition = {
return;
}
}
if (options.route) {
payload = { ...payload, route: options.route };
}
if (options.method) {
payload = { ...payload, method: String(options.method).toUpperCase() };
}
const handleJsonChunk = (chunk: string) => {
const trimmed = chunk.trim();
+49
View File
@@ -0,0 +1,49 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { printJson } from "../../utils/json.js";
function parseBoolean(value: unknown, optionName: string): boolean | undefined {
if (value === undefined) return undefined;
const normalized = String(value).toLowerCase();
if (normalized !== "true" && normalized !== "false") {
throw new Error(`Invalid value for ${optionName}. Use true or false.`);
}
return normalized === "true";
}
export const functionLoggingDefinition = {
name: "logging",
description: "Get or update logging configuration for a function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{ name: "--enabled <enabled>", description: "Enable logging (true/false)" },
{ name: "--hide-payload-headers <enabled>", description: "Hide request headers in logs (true/false)" },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const enabled = parseBoolean(options.enabled, "--enabled");
const hidePayloadHeaders = parseBoolean(
options.hidePayloadHeaders,
"--hide-payload-headers",
);
if (enabled === undefined && hidePayloadHeaders === undefined) {
const response = await client.get(`/api/function/${options.id}/logging`);
printJson(response.data);
return;
}
const response = await client.patch(`/api/function/${options.id}/logging`, {
...(enabled !== undefined ? { enabled } : {}),
...(hidePayloadHeaders !== undefined
? { hide_payload_headers: hidePayloadHeaders }
: {}),
});
printJson(response.data);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.response?.data?.message || error.message}`);
}
},
};
+32
View File
@@ -0,0 +1,32 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { printJson } from "../../utils/json.js";
export const functionLogsDefinition = {
name: "logs",
description: "Fetch or clear execution logs for a function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{ name: "--clear", description: "Delete all logs for the function", type: "boolean" },
{ name: "--log-id <id>", description: "Delete a specific log ID when used with --clear" },
],
action: async (options: any) => {
const client = await getApiClient();
try {
if (options.clear) {
const url = options.logId
? `/api/function/${options.id}/logs/${options.logId}`
: `/api/function/${options.id}/logs`;
const response = await client.delete(url);
printJson(response.data);
return;
}
const response = await client.get(`/api/function/${options.id}/logs`);
printJson(response.data);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.response?.data?.message || error.message}`);
}
},
};
+33
View File
@@ -0,0 +1,33 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { printJson, readJsonInput } from "../../utils/json.js";
export const functionRateLimitDefinition = {
name: "ratelimit",
description: "Get or update execution rate-limit configuration for a function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{ name: "--config <json>", description: "Rate-limit config JSON to PATCH" },
{ name: "--config-file <path>", description: "Read rate-limit config JSON from a file" },
],
action: async (options: any) => {
const client = await getApiClient();
try {
if (options.config !== undefined || options.configFile !== undefined) {
const config = readJsonInput({
data: options.config,
dataFile: options.configFile,
});
const response = await client.patch(`/api/function/${options.id}/ratelimit`, config);
printJson(response.data);
return;
}
const response = await client.get(`/api/function/${options.id}/ratelimit`);
printJson(response.data);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.response?.data?.message || error.message}`);
}
},
};
+6
View File
@@ -25,6 +25,12 @@ async function getFunction(id: string) {
console.log(`${chalk.yellow("Description:")} ${f.description || chalk.gray("No description")}`);
console.log(`${chalk.yellow("Image:")} ${f.image}`);
console.log(`${chalk.yellow("Startup File:")} ${f.startup_file}`);
console.log(`${chalk.yellow("HTTP:")} ${f.allow_http ? "enabled" : "disabled"}`);
console.log(`${chalk.yellow("Cache:")} ${f.cache_enabled ? `enabled (${f.cache_ttl}s)` : "disabled"}`);
console.log(`${chalk.yellow("Network:")} ${f.network_restricted ? "restricted" : "allowed"}`);
console.log(`${chalk.yellow("Max RAM:")} ${f.max_ram ?? chalk.gray("default")}`);
console.log(`${chalk.yellow("Timeout:")} ${f.timeout ?? chalk.gray("default")}`);
console.log(`${chalk.yellow("Tags:")} ${f.tags || chalk.gray("none")}`);
if (f.namespace) {
console.log(`${chalk.yellow("Namespace:")} ${f.namespace.name} (${f.namespace.id})`);
+28
View File
@@ -0,0 +1,28 @@
import chalk from "chalk";
import { callMcp } from "../../utils/mcp.js";
import { printJson, readJsonInput } from "../../utils/json.js";
export const mcpCallDefinition = {
name: "call <tool>",
description: "Call an SHSF MCP tool with JSON arguments.",
options: [
{ name: "--args <json>", description: "Tool arguments as JSON" },
{ name: "--args-file <path>", description: "Read tool arguments from a JSON file" },
],
action: async (tool: string, options: any) => {
try {
const args = readJsonInput({
data: options.args,
dataFile: options.argsFile,
defaultValue: {},
});
const result = await callMcp("tools/call", {
name: tool,
arguments: args,
});
printJson(result);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.message}`);
}
},
};
+24
View File
@@ -0,0 +1,24 @@
import chalk from "chalk";
import { callMcp } from "../../utils/mcp.js";
import { printJson } from "../../utils/json.js";
export const mcpDocsDefinition = {
name: "docs",
description: "Print the SHSF function authoring reference from the MCP get_docs tool.",
action: async () => {
try {
const result: any = await callMcp("tools/call", {
name: "get_docs",
arguments: {},
});
const text = result?.content?.find?.((item: any) => item.type === "text")?.text;
if (text) {
process.stdout.write(`${text}\n`);
} else {
printJson(result);
}
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.message}`);
}
},
};
+18
View File
@@ -0,0 +1,18 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { printJson } from "../../utils/json.js";
export const mcpInfoDefinition = {
name: "info",
description: "Print SHSF MCP server metadata.",
action: async () => {
const client = await getApiClient();
try {
const response = await client.get("/mcp");
printJson(response.data);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.response?.data?.message || error.message}`);
}
},
};
+20
View File
@@ -0,0 +1,20 @@
import chalk from "chalk";
import { callMcp } from "../../utils/mcp.js";
import { printJson } from "../../utils/json.js";
export const mcpInitDefinition = {
name: "init",
description: "Initialize an MCP session and print server instructions.",
action: async () => {
try {
const result = await callMcp("initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "shsf-cli", version: "1.0.0" },
});
printJson(result);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.message}`);
}
},
};
+16
View File
@@ -0,0 +1,16 @@
import chalk from "chalk";
import { callMcp } from "../../utils/mcp.js";
import { printJson } from "../../utils/json.js";
export const mcpToolsDefinition = {
name: "tools",
description: "List MCP tools exposed by the SHSF instance.",
action: async () => {
try {
const result = await callMcp("tools/list");
printJson(result);
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.message}`);
}
},
};
-9
View File
@@ -1,9 +0,0 @@
import chalk from "chalk";
export const helloworldDefinition = {
name: "helloworld",
description: "Prints Hello, World! to the terminal.",
action: async () => {
console.log(`${chalk.green("✓")} ${chalk.bgGreen.black(" Hello, World! ")}`);
},
};
+92 -20
View File
@@ -1,48 +1,120 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
function parseBooleanOption(value: unknown, optionName: string): boolean | undefined {
if (value === undefined) return undefined;
const normalized = String(value).toLowerCase();
if (normalized !== "true" && normalized !== "false") {
throw new Error(`Invalid value for ${optionName}. Use true or false.`);
}
return normalized === "true";
}
function parseNumberOption(value: unknown, optionName: string): number | undefined {
if (value === undefined) return undefined;
const parsed = Number(value);
if (Number.isNaN(parsed)) {
throw new Error(`Invalid value for ${optionName}. Please provide a number.`);
}
return parsed;
}
export const updateFunctionDefinition = {
name: "function <id>",
description: "Update an existing serverless function.",
options: [
{ name: "--name <name>", description: "Function name" },
{ name: "--description <description>", description: "Function description" },
{ name: "--image <image>", description: "Runtime image tag within the existing language family" },
{ name: "--startup-file <file>", description: "Startup file name" },
{ name: "--namespace-id <id>", description: "Move function to namespace ID" },
{ name: "--execution-alias <alias>", description: "Custom execution alias" },
{ name: "--docker-mount", description: "Enable Docker mount", type: "boolean" },
{ name: "--ffmpeg-install", description: "Install ffmpeg in container", type: "boolean" },
{ name: "--imported", description: "Marks the function as imported", type: "boolean" },
{ name: "--docker-mount <enabled>", description: "Enable Docker mount (true/false)" },
{ name: "--network-restricted <enabled>", description: "Disable outbound network access (true/false)" },
{ name: "--ffmpeg-install <enabled>", description: "Install ffmpeg in container (true/false)" },
{ name: "--opencv-install <enabled>", description: "Install opencv in container (true/false)" },
{ name: "--imported <enabled>", description: "Marks the function as imported (true/false)" },
{ name: "--ai-kicked-off <enabled>", description: "Marks the function as created via AI kickoff (true/false)" },
{ name: "--allow-http <enabled>", description: "Allow HTTP execution (true/false)" },
{ name: "--secure-header <value>", description: "Require x-secure-header for HTTP execution" },
{ name: "--clear-secure-header", description: "Remove the secure header requirement", type: "boolean" },
{ name: "--max-ram <mb>", description: "Max RAM in MB" },
{ name: "--timeout <seconds>", description: "Execution timeout in seconds" },
{ name: "--tags <csv>", description: "Comma-separated function tags" },
{ name: "--retry-on-failure <enabled>", description: "Retry failed executions (true/false)" },
{ name: "--retry-count <count>", description: "Retry count" },
{ name: "--cache-enabled <enabled>", description: "Enable response caching (true/false)" },
{ name: "--cache-ttl <seconds>", description: "Cache TTL in seconds" },
{ name: "--cors-origins <origins>", description: "Comma-separated allowed CORS origins" },
],
action: async (id: string, options: any) => {
let parsedCacheEnabled: boolean | undefined;
if (options.cacheEnabled !== undefined) {
const normalized = String(options.cacheEnabled).toLowerCase();
if (normalized !== "true" && normalized !== "false") {
console.error(`${chalk.red("✗")} Invalid value for --cache-enabled. Use true or false.`);
return;
}
parsedCacheEnabled = normalized === "true";
}
let dockerMount: boolean | undefined;
let networkRestricted: boolean | undefined;
let ffmpegInstall: boolean | undefined;
let opencvInstall: boolean | undefined;
let imported: boolean | undefined;
let aiKickedOff: boolean | undefined;
let allowHttp: boolean | undefined;
let retryOnFailure: boolean | undefined;
let cacheEnabled: boolean | undefined;
let namespaceId: number | undefined;
let maxRam: number | undefined;
let timeout: number | undefined;
let retryCount: number | undefined;
let cacheTtl: number | undefined;
const parsedCacheTtl = options.cacheTtl !== undefined ? Number(options.cacheTtl) : undefined;
if (parsedCacheTtl !== undefined && Number.isNaN(parsedCacheTtl)) {
console.error(`${chalk.red("✗")} Invalid value for --cache-ttl. Please provide a number.`);
try {
dockerMount = parseBooleanOption(options.dockerMount, "--docker-mount");
networkRestricted = parseBooleanOption(options.networkRestricted, "--network-restricted");
ffmpegInstall = parseBooleanOption(options.ffmpegInstall, "--ffmpeg-install");
opencvInstall = parseBooleanOption(options.opencvInstall, "--opencv-install");
imported = parseBooleanOption(options.imported, "--imported");
aiKickedOff = parseBooleanOption(options.aiKickedOff, "--ai-kicked-off");
allowHttp = parseBooleanOption(options.allowHttp, "--allow-http");
retryOnFailure = parseBooleanOption(options.retryOnFailure, "--retry-on-failure");
cacheEnabled = parseBooleanOption(options.cacheEnabled, "--cache-enabled");
namespaceId = parseNumberOption(options.namespaceId, "--namespace-id");
maxRam = parseNumberOption(options.maxRam, "--max-ram");
timeout = parseNumberOption(options.timeout, "--timeout");
retryCount = parseNumberOption(options.retryCount, "--retry-count");
cacheTtl = parseNumberOption(options.cacheTtl, "--cache-ttl");
} catch (error: any) {
console.error(`${chalk.red("✗")} ${error.message}`);
return;
}
const data: Record<string, any> = {};
const settings: Record<string, any> = {};
if (options.name !== undefined) data.name = options.name;
if (options.description !== undefined) data.description = options.description;
if (options.image !== undefined) data.image = options.image;
if (options.startupFile !== undefined) data.startup_file = options.startupFile;
if (namespaceId !== undefined) data.namespaceId = namespaceId;
if (options.executionAlias !== undefined) data.executionAlias = options.executionAlias;
if (options.dockerMount) data.docker_mount = true;
if (options.ffmpegInstall) data.ffmpeg_install = true;
if (options.imported) data.imported = true;
if (parsedCacheEnabled !== undefined) data.cache_enabled = parsedCacheEnabled;
if (parsedCacheTtl !== undefined) data.cache_ttl = parsedCacheTtl;
if (dockerMount !== undefined) data.docker_mount = dockerMount;
if (networkRestricted !== undefined) data.network_restricted = networkRestricted;
if (ffmpegInstall !== undefined) data.ffmpeg_install = ffmpegInstall;
if (opencvInstall !== undefined) data.opencv_install = opencvInstall;
if (imported !== undefined) data.imported = imported;
if (aiKickedOff !== undefined) data.ai_kicked_off = aiKickedOff;
if (options.corsOrigins !== undefined) data.cors_origins = options.corsOrigins;
if (allowHttp !== undefined) settings.allow_http = allowHttp;
if (options.clearSecureHeader) settings.secure_header = null;
if (options.secureHeader !== undefined) settings.secure_header = options.secureHeader;
if (maxRam !== undefined) settings.max_ram = maxRam;
if (timeout !== undefined) settings.timeout = timeout;
if (options.tags !== undefined) {
settings.tags = String(options.tags)
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
if (retryOnFailure !== undefined) settings.retry_on_failure = retryOnFailure;
if (retryCount !== undefined) settings.retry_count = retryCount;
if (cacheEnabled !== undefined) settings.cache_enabled = cacheEnabled;
if (cacheTtl !== undefined) settings.cache_ttl = cacheTtl;
if (Object.keys(settings).length > 0) data.settings = settings;
if (Object.keys(data).length === 0) {
console.error(`${chalk.red("✗")} No update fields provided. Use --help to see available options.`);
+7
View File
@@ -56,6 +56,13 @@ async function promptForConfig(): Promise<SHSFConfig> {
}
export async function loadConfig(): Promise<SHSFConfig> {
if (process.env.SHSF_INSTANCE && process.env.SHSF_TOKEN) {
return {
SHSF_INSTANCE: process.env.SHSF_INSTANCE,
SHSF_TOKEN: process.env.SHSF_TOKEN,
};
}
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
+23
View File
@@ -0,0 +1,23 @@
export type EnvVar = { name: string; value: string };
export function normalizeEnvList(value: unknown): EnvVar[] {
if (typeof value === "string") {
if (!value.trim()) return [];
try {
value = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(value)) return [];
return value
.filter((item): item is EnvVar =>
item &&
typeof item === "object" &&
typeof (item as EnvVar).name === "string" &&
typeof (item as EnvVar).value === "string",
)
.map((item) => ({ name: item.name, value: item.value }));
}
+36
View File
@@ -0,0 +1,36 @@
import fs from "fs";
export function parseJsonInput(input: string, label = "JSON"): unknown {
try {
return JSON.parse(input);
} catch (error: any) {
throw new Error(`Invalid ${label}: ${error.message}`);
}
}
export function readJsonInput(options: {
data?: string;
dataFile?: string;
defaultValue?: unknown;
}): unknown {
if (options.data !== undefined && options.dataFile !== undefined) {
throw new Error("Use either --data or --data-file, not both.");
}
if (options.dataFile !== undefined) {
return parseJsonInput(
fs.readFileSync(options.dataFile, "utf-8"),
`${options.dataFile} JSON`,
);
}
if (options.data !== undefined) {
return parseJsonInput(options.data, "JSON");
}
return options.defaultValue ?? {};
}
export function printJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
+20
View File
@@ -0,0 +1,20 @@
import { getApiClient } from "../api.js";
let nextJsonRpcId = 1;
export async function callMcp(method: string, params?: unknown): Promise<unknown> {
const client = await getApiClient();
const id = nextJsonRpcId++;
const response = await client.post("/mcp", {
jsonrpc: "2.0",
id,
method,
...(params !== undefined ? { params } : {}),
});
if (response.data?.error) {
throw new Error(response.data.error.message || JSON.stringify(response.data.error));
}
return response.data?.result ?? response.data;
}