47 Commits

Author SHA1 Message Date
Space-Banane 5071a6ea71 feat: add instruct command with platform reference for humans and agents
CI / CD / Test and Build (push) Successful in 25s
CI / CD / Publish (push) Successful in 19s
Adds `shsf instruct` as a first-stop command covering what SHSF is,
a CLI command group overview, quick-start steps for humans, and the full
AIDOC function authoring reference for AI agents. The program description
and help footer now prompt users to run `shsf instruct` before anything else.

Bumps version to 2.3.3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 22:55:56 +02:00
Space-Banane eeb34b7103 Bump npm package version to 2.3.2
CI / CD / Test and Build (push) Successful in 19s
CI / CD / Publish (push) Successful in 20s
2026-07-10 22:37:30 +02:00
Space-Banane 75ef33f47c feat: enhance Gitea release process with token fallback and improved artifact upload
CI / CD / Test and Build (push) Successful in 30s
CI / CD / Publish (push) Failing after 56s
2026-07-10 22:28:43 +02:00
Space-Banane 97b2089857 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.
2026-07-10 22:24:47 +02:00
Space-Banane 0434376b83 Bump version to 2.3.1 2026-05-05 12:48:35 +02:00
Space-Banane 681a3cd6df Add function exec-url command 2026-05-05 12:46:24 +02:00
Space-Banane 544f6bd714 feat(cors): implement CORS management commands and utilities 2026-04-14 22:38:27 +02:00
Space-Banane c912c791fa Added coding docs from shsf to shsf-cli 2026-04-14 18:23:11 +02:00
Space-Banane d9d6871f09 bump version 2026-04-14 18:13:35 +02:00
Space-Banane 121b0429f6 fix(cli): use PUT for trigger updates to match OpenAPI spec 2026-04-14 18:11:13 +02:00
Space-Banane 9f78e2a221 fix: update version number to 2.2.9 in package.json and correct API endpoint in createNamespaceDefinition 2026-04-14 17:55:09 +02:00
Space-Banane 1a0e811a9a fix: update version number to 2.2.8 in package.json 2026-04-05 17:50:59 +02:00
Space-Banane 57b05da8c1 feat: enhance error handling for CLI file failures in handleAxiosError function 2026-04-05 17:49:40 +02:00
Space-Banane e6e2203751 feat: refactor file operations and add utility functions for file management 2026-04-05 17:46:38 +02:00
Space-Banane 5c18138dd2 fix: update version number to 2.2.7 in package.json 2026-04-03 17:51:23 +02:00
Space-Banane 9f2d72c3a0 feat: implement file ignore and mapping functionality for remote push command 2026-04-03 17:48:44 +02:00
Space-Banane dddb352457 Added some more unpushable files (for now) 2026-04-01 17:28:42 +02:00
Space-Banane 3fe3dc96da fix: update version number to 2.2.5 in package.json and enhance file reading logic in push command 2026-04-01 17:24:42 +02:00
Space-Banane e953466786 fix: update version number to 2.2.4 in package.json 2026-04-01 13:03:58 +02:00
Space-Banane c43c37ce34 feat: add unpushable file checks to prevent uploading certain file types 2026-04-01 13:01:47 +02:00
Space-Banane 13900343a3 feat: add commands for managing environment variables in functions & removed ui docs from skill 2026-03-27 17:18:00 +01:00
Space-Banane cc54ef85e1 fix: update command syntax for function execution in documentation 2026-03-27 16:56:54 +01:00
Space-Banane 67f05af7a5 feat: add execute command for function execution with streaming options 2026-03-27 16:55:28 +01:00
Space-Banane 37f6d50034 fix: requirements.txt modifications 2026-03-27 16:50:04 +01:00
Space-Banane a1f80d38f2 update skill to reflect changes 2026-03-27 16:38:18 +01:00
Space-Banane 7b659b1ef5 version-bump: commit of shame 2026-03-27 16:36:48 +01:00
Space-Banane 3261cc1830 unslopped file operations 2026-03-27 16:35:43 +01:00
Space a76bb15fc9 feat: add req subcommands for managing requirements.txt (#5)
* feat: add req subcommands for managing requirements.txt

* bump: version 2.1.9

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

---------

Co-authored-by: Luna <clawy@reversed.dev>
2026-03-26 14:01:15 +01:00
Space 47cd88f72b feat: log byte count for push and pull 2026-03-20 18:01:29 +01:00
luna 6a98ef4bed feat: log byte count for push and pull, increment version 2026-03-20 17:59:41 +01:00
Space e6dea43555 Add remote sync commands 2026-03-20 17:03:45 +01:00
Space-Banane 7b86fec717 That should do 2026-03-20 17:02:34 +01:00
Space-Banane 0be63e5287 Fixed spacing 2026-03-20 16:28:37 +01:00
luna 1d04649cbf Refactor remote commands into remote subdirectory 2026-03-20 16:24:09 +01:00
luna 344e10ac46 Add remote sync command and tests 2026-03-20 16:21:53 +01:00
Space-Banane 6ad74bb991 feat: add testing using vitest 2026-03-20 14:56:19 +01:00
Space-Banane 1b3851d0af docs: emphasize the use of pnpm for package updates 2026-03-20 09:00:39 +01:00
luna 9887ca3dcc chore: bump version to 2.1.5 and add versioning policy 2026-03-20 08:58:03 +01:00
luna ae82ba221b fix: improve error handling for pipinstall 2026-03-20 08:55:10 +01:00
luna 8428ed56b5 feat: add pipinstall command 2026-03-20 08:51:29 +01:00
Space-Banane 20d34bf6e7 add: Implement storage management commands including create, delete, list, and item manipulation 2026-03-18 22:35:18 +01:00
Space-Banane 11a239dbf4 refactor: Remove unnecessary heading for count commands in documentation 2026-03-18 18:41:15 +01:00
Space-Banane 342b8dd61f add: Implement UI URL command and update documentation with UI links and routes 2026-03-18 18:00:35 +01:00
Space-Banane 625e3e2a3e add: Updating instructions for skill 2026-03-18 17:08:39 +01:00
Space-Banane ec06127443 Enhance CI/CD workflow by specifying paths for push and pull request triggers; update documentation with installation instructions and feature update reminders. 2026-03-18 17:05:40 +01:00
Space-Banane f8730b2044 Mass Implement of basic features 2026-03-18 17:02:30 +01:00
Space-Banane 9aec36d0e8 Implemented count feature 2026-03-18 16:33:03 +01:00
89 changed files with 4532 additions and 188 deletions
+130
View File
@@ -0,0 +1,130 @@
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 to Gitea
uses: actions/upload-artifact@v3
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 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
if [ -z "${TOKEN}" ]; then
echo "No Gitea token is configured; skipping Gitea release."
exit 0
fi
VERSION="$(node -p "require('./package.json').version")"
SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL}}"
REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY}}"
API="${SERVER_URL%/}/api/v1/repos/${REPOSITORY}/releases"
RELEASE_JSON="$(curl -sSf \
-H "Authorization: token ${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")"
RELEASE_ID="$(node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(0, 'utf8')); process.stdout.write(String(data.id));" <<< "${RELEASE_JSON}")"
curl -sSf \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@dist.zip" \
"${API}/${RELEASE_ID}/assets?name=dist.zip"
-26
View File
@@ -1,26 +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.
-86
View File
@@ -1,86 +0,0 @@
name: CI / CD
on:
push:
branches:
- '**'
pull_request:
branches:
- main
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 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: Create Release
uses: softprops/action-gh-release@v2
with:
files: dist.zip
tag_name: v${{ github.run_number }}
name: Release v${{ github.run_number }}
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -2,3 +2,5 @@ node_modules
dist
build
pnpm-lock.yaml
testing
.codex
+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.
+103 -56
View File
@@ -1,83 +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
- Node.js 22 or newer
- pnpm 10 or newer
- An SHSF access token
## 🚦 Getting Started
### 📋 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
```
---
Once installed, simply type:
## Configuration
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
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:
```bash
export SHSF_INSTANCE=https://shsf.example.com
export SHSF_TOKEN=token_xxx
shsf health
```
## Core Commands
### 🏗️ Local Development
If you're contributing or running from source:
```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
```
1. **Setup**:
```bash
git clone https://github.com/Space-Banane/shsf-cli.git
cd shsf-cli
pnpm install
```
Files can be synchronized from a local directory:
2. **Run**:
```bash
pnpm build # Build the project
pnpm start [cmd] # Run a command directly
```
```bash
shsf remote push --id 42 --from ./functions/api --force
shsf remote pull --id 42 --to ./functions/api
```
Use `.shsfignore` for gitignore-style push exclusions and `.shsf.json` for local mappings:
## 🤝 Contributing
```json
{
"default": {
"id": "42",
"from": "functions/api"
}
}
```
We love builders! To add a new command:
## Current SHSF Features
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`.
Typed commands cover the common stable workflows:
---
- `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
## 📄 License
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:
Licensed under the **MIT-0 License**. Happy coding! 🍌
```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
```
## Agent Use
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
+9 -5
View File
@@ -1,7 +1,7 @@
{
"name": "shsf-cli",
"version": "2.0.1",
"description": "",
"version": "2.3.3",
"description": "Production-ready command-line interface and agent bridge for SHSF.",
"type": "module",
"files": [
"dist"
@@ -12,18 +12,22 @@
"scripts": {
"build": "rimraf dist && tsc",
"start": "node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"keywords": [],
"author": "",
"license": "ISC",
"license": "MIT-0",
"packageManager": "pnpm@10.30.0",
"devDependencies": {
"@types/inquirer": "^9.0.9",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.0",
"rimraf": "^6.1.3",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.0"
},
"dependencies": {
"axios": "^1.13.6",
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe('Command Loading', () => {
it('all command files should export a valid definition', async () => {
const commandsDir = path.resolve(__dirname, '../commands');
function getCommandFiles(dir: string): string[] {
const files = fs.readdirSync(dir);
let commandFiles: string[] = [];
for (const file of files) {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
commandFiles = commandFiles.concat(getCommandFiles(fullPath));
} else if (file.endsWith('.ts') || file.endsWith('.js')) {
commandFiles.push(fullPath);
}
}
return commandFiles;
}
const files = getCommandFiles(commandsDir);
expect(files.length).toBeGreaterThan(0);
for (const file of files) {
// Use dynamic import to check the module
// We convert absolute path to file:// URL for ESM import on Linux/Windows
const module = await import(`file://${file}`);
const definition = module.default || Object.values(module).find((val: any) =>
val && typeof val === 'object' && val.name && typeof val.action === 'function'
);
if (!definition) {
throw new Error(`Command file ${file} does not export a valid command definition (needs name and action function)`);
}
expect(definition).toBeDefined();
expect(typeof definition.name).toBe('string');
expect(typeof definition.action).toBe('function');
console.log(`✓ Validated command: ${definition.name} (${path.relative(commandsDir, file)})`);
}
}, 20000);
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import os from 'os';
import { getConfigPath } from '../config.js';
describe('config', () => {
it('should return the correct config path', () => {
const expectedPath = path.join(os.homedir(), '.shsf_config');
expect(getConfigPath()).toBe(expectedPath);
});
});
+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();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { buildFunctionExecUrls } from "../utils/function_exec_url.js";
describe("buildFunctionExecUrls", () => {
it("builds the ID-based execution URL and trims trailing slashes", () => {
expect(
buildFunctionExecUrls("https://example.com/", {
namespaceId: 4,
executionId: "abc-123",
}),
).toEqual({
executionUrl: "https://example.com/api/exec/4/abc-123",
});
});
it("includes the alias URL when the function has an execution alias", () => {
expect(
buildFunctionExecUrls("https://example.com", {
namespaceId: 7,
executionId: "uuid-456",
executionAlias: "friendly-name",
}),
).toEqual({
executionUrl: "https://example.com/api/exec/7/uuid-456",
aliasUrl: "https://example.com/exec/friendly-name",
});
});
it("throws when required execution URL fields are missing", () => {
expect(() =>
buildFunctionExecUrls("https://example.com", {
executionAlias: "friendly-name",
}),
).toThrow("Function response is missing execution URL fields.");
});
});
+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();
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, statSync } from 'fs';
import path from 'path';
function getFilesRecursively(dir: string): string[] {
let files: string[] = [];
const items = readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
if (statSync(fullPath).isDirectory()) {
if (item !== '__tests__') {
files = files.concat(getFilesRecursively(fullPath));
}
} else if (item.endsWith('.ts')) {
files.push(fullPath);
}
}
return files;
}
describe('API URL validation', () => {
it('should ensure 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 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 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 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',
+14 -3
View File
@@ -55,10 +55,21 @@ export async function resolveCommands() {
const definition = module.default || Object.values(module).find((val: any) => val && val.name && val.action);
if (definition && definition.name && definition.action) {
currentParent
const command = currentParent
.command(definition.name)
.description(definition.description || '')
.action(definition.action);
.description(definition.description || '');
if (definition.options) {
definition.options.forEach((opt: any) => {
if (opt.required) {
command.requiredOption(opt.name, opt.description);
} else {
command.option(opt.name, opt.description);
}
});
}
command.action(definition.action);
}
}
}
+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}`);
}
}
},
};
+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");
}
},
};
+62
View File
@@ -0,0 +1,62 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const functionsCountDefinition = {
name: "functions",
description: "Counts the amount of functions the current user has.",
options: [
{ name: "--full", description: "List all functions instead of just the count", type: "boolean" },
],
action: async (options: any) => {
await countFunctions(options?.full);
},
};
async function countFunctions(full: boolean = false) {
const client = await getApiClient();
try {
const response = await client.get("/api/functions");
if (response.status === 200 && response.data.data) {
const functions = response.data.data;
const count = functions.length;
if (full && count > 0) {
console.log(chalk.blue("Functions List:"));
functions.forEach((f: any) => {
const namespaceInfo = f.namespace ? chalk.gray(` [NS: ${f.namespace.id}]`) : "";
console.log(`- ${chalk.cyan(f.name)} ${chalk.gray(`(${f.id})`)}${namespaceInfo}`);
if (f.description) {
console.log(` ${chalk.italic.gray(f.description)}`);
}
});
console.log("");
}
console.log(
`${chalk.green("✓")} You have ${chalk.bgGreen.black(` ${count} `)} functions.`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to fetch function count.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
} else {
console.error(
`${chalk.red("✗")} Local error occurred.`,
);
console.error(`Error: ${chalk.yellow(error.message)}`);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const namespacesCountDefinition = {
name: "namespaces",
description: "Counts the amount of namespaces the current user has.",
options: [
{ name: "--full", description: "List all namespaces instead of just the count", type: "boolean" },
],
action: async (options: any) => {
await countNamespaces(options?.full);
},
};
async function countNamespaces(full: boolean = false) {
const client = await getApiClient();
try {
const response = await client.get("/api/namespaces");
if (response.status === 200 && response.data.data) {
const namespaces = response.data.data;
const count = namespaces.length;
if (full && count > 0) {
console.log(chalk.blue("Namespaces List:"));
namespaces.forEach((ns: any) => {
console.log(`- ${chalk.cyan(ns.name)} ${chalk.gray(`(${ns.id})`)}`);
});
console.log("");
}
console.log(
`${chalk.green("✓")} You have ${chalk.bgGreen.black(` ${count} `)} namespaces.`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to fetch namespace count.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const storagesCountDefinition = {
name: "storages",
description: "Counts the amount of storages the current user has.",
options: [
{ name: "--full", description: "List all storages instead of just the count", type: "boolean" },
],
action: async (options: any) => {
await countStorages(options?.full);
},
};
async function countStorages(full: boolean = false) {
const client = await getApiClient();
try {
const response = await client.get("/api/storage");
if (response.status === 200 && response.data.data) {
const storages = response.data.data;
const count = storages.length;
if (full && count > 0) {
console.log(chalk.blue("Storages List:"));
storages.forEach((s: any) => {
const purposeInfo = s.purpose ? chalk.gray(` - ${s.purpose}`) : "";
console.log(`- ${chalk.cyan(s.name)}${purposeInfo}`);
});
console.log("");
}
console.log(
`${chalk.green("✓")} You have ${chalk.bgGreen.black(` ${count} `)} storages.`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to fetch storage count.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const triggersCountDefinition = {
name: "triggers",
description: "Counts the amount of triggers the current user has.",
options: [
{ name: "--full", description: "List all triggers instead of just the count", type: "boolean" },
],
action: async (options: any) => {
await countTriggers(options?.full);
},
};
async function countTriggers(full: boolean = false) {
const client = await getApiClient();
try {
const response = await client.get("/api/triggers");
if (response.status === 200 && response.data.data) {
const triggers = response.data.data;
const count = triggers.length;
if (full && count > 0) {
console.log(chalk.blue("Triggers List:"));
triggers.forEach((t: any) => {
const functionInfo = t.function ? chalk.gray(` [Func: ${t.function.name}]`) : "";
const cronInfo = t.cron ? chalk.yellow(` (Cron: ${t.cron})`) : "";
console.log(`- ${chalk.cyan(t.name)} ${chalk.gray(`(${t.id})`)}${functionInfo}${cronInfo}`);
if (t.description) {
console.log(` ${chalk.italic.gray(t.description)}`);
}
});
console.log("");
}
console.log(
`${chalk.green("✓")} You have ${chalk.bgGreen.black(` ${count} `)} triggers.`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to fetch trigger count.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+128
View File
@@ -0,0 +1,128 @@
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.",
options: [
{ 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 (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 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;
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 ?? "",
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,
ai_kicked_off: !!options.aiKickedOff,
cors_origins: options.corsOrigins,
...(Object.keys(settings).length > 0 ? { settings } : {}),
};
const client = await getApiClient();
try {
const response = await client.post("/api/function", data);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} Function ${chalk.cyan(data.name)} created successfully! ID: ${chalk.bgGreen.black(` ${response.data.data.id} `)}`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to create function: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+41
View File
@@ -0,0 +1,41 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const createNamespaceDefinition = {
name: "namespace",
description: "Create a new namespace.",
options: [
{ name: "--name <name>", description: "Namespace name", required: true },
{ name: "--description <description>", description: "Namespace description", required: true },
],
action: async (options: any) => {
const data = {
name: options.name,
description: options.description,
};
const client = await getApiClient();
try {
const response = await client.post("/api/namespace", data);
if (response.status === 200 || response.status === 201) {
console.log(
`${chalk.green("✓")} Namespace ${chalk.cyan(data.name)} created successfully! ID: ${chalk.bgGreen.black(` ${response.data.data.id} `)}`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to create namespace: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+44
View File
@@ -0,0 +1,44 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const createTriggerDefinition = {
name: "trigger",
description: "Create a new trigger for a function.",
options: [
{ name: "--function-id <id>", description: "The ID of the function to add the trigger to", required: true },
{ name: "--name <name>", description: "Trigger name", required: true },
{ name: "--description <description>", description: "Trigger description", required: true },
],
action: async (options: any) => {
const { functionId, name, description } = options;
const data = {
name,
description,
};
const client = await getApiClient();
try {
const response = await client.post(`/api/functions/${functionId}/triggers`, data);
if (response.status === 200 || response.status === 201) {
console.log(
`${chalk.green("✓")} Trigger ${chalk.cyan(name)} created successfully for function ID ${chalk.cyan(functionId)}! ID: ${chalk.bgGreen.black(` ${response.data.data.id} `)}`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to create trigger: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+47
View File
@@ -0,0 +1,47 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const deleteFunctionDefinition = {
name: "function <id>",
description: "Deletes a specific serverless function by its ID.",
action: async (id: string) => {
await deleteFunction(id);
},
};
async function deleteFunction(id: string) {
const client = await getApiClient();
try {
const response = await client.delete(`/api/function/${id}`);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} Function with ID ${chalk.cyan(id)} has been deleted.`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Function with ID ${chalk.yellow(id)} not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to delete function.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const deleteNamespaceDefinition = {
name: "namespace <id>",
description: "Deletes a namespace and all its functions by ID.",
action: async (id: string) => {
await deleteNamespace(id);
},
};
async function deleteNamespace(id: string) {
const client = await getApiClient();
try {
const response = await client.delete(`/api/namespace/${id}`);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} Namespace with ID ${chalk.cyan(id)} has been deleted.`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Namespace with ID ${chalk.yellow(id)} not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to delete namespace.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const deleteTriggerDefinition = {
name: "trigger <functionId> <triggerId>",
description: "Deletes a specific trigger from a function.",
action: async (functionId: string, triggerId: string) => {
await deleteTrigger(functionId, triggerId);
},
};
async function deleteTrigger(functionId: string, triggerId: string) {
const client = await getApiClient();
try {
const response = await client.delete(`/api/functions/${functionId}/triggers/${triggerId}`);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} Trigger with ID ${chalk.cyan(triggerId)} from function ${chalk.cyan(functionId)} has been deleted.`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Trigger or function not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to delete trigger.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+96
View File
@@ -0,0 +1,96 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const updateFunctionDefinition = {
name: "add",
description:
"Adds or updates environment variables for a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{
name: "--name <name>",
description: "Environment variable name",
required: true,
},
{
name: "--value <value>",
description: "Environment variable value",
required: true,
},
],
action: async (options: any) => {
if (!options.id || !options.name || !options.value) {
console.error(
`${chalk.red("✗")} Error: Missing required options (--id, --name, --value)`,
);
return;
}
const client = await getApiClient();
let existingEnv: { name: string; value: string }[] = [];
try {
const response = await client.get(`/api/function/${options.id}`);
const functionData = response.data.data || response.data;
existingEnv = functionData.env || [];
if (typeof existingEnv === 'string') {
try {
existingEnv = JSON.parse(existingEnv);
} catch (e: any) {
existingEnv = [];
}
}
if (!Array.isArray(existingEnv)) {
existingEnv = [];
}
} catch (error: any) {
console.error(
`${chalk.red("✗")} Error fetching function data: ${error.message}`,
);
return;
}
const newEnv = [...existingEnv];
const existingIndex = newEnv.findIndex((env) => env.name === options.name);
const isUpdate = existingIndex !== -1;
if (isUpdate) {
newEnv[existingIndex].value = options.value;
} else {
newEnv.push({ name: options.name, value: options.value });
}
const totalCount = newEnv.length;
try {
const response = await client.patch(`/api/function/${options.id}`, {
environment: newEnv,
});
if (response.status === 200) {
const actionText = isUpdate ? "updated" : "added";
console.log(
`${chalk.green("✓")} Environment variable ${chalk.cyan(options.name)} ${actionText} successfully for function ${chalk.cyan(options.id)}.`,
);
console.log(
`${chalk.blue("")} Total environment variables: ${totalCount}`,
);
} else {
console.error(
`${chalk.red("✗")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response?.status === 404) {
console.error(
`${chalk.red("✗")} Function with ID ${chalk.yellow(options.id)} not found.`,
);
} else {
console.error(
`${chalk.red("✗")} Failed to update: ${chalk.yellow(error.response?.data?.message || error.message)}`,
);
}
}
},
};
+33
View File
@@ -0,0 +1,33 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const flushFunctionDefinition = {
name: "flush",
description: "Removes ALL environment variables from a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
],
action: async (options: any) => {
if (!options.id) {
console.error(`${chalk.red("✗")} Error: --id is required`);
return;
}
const client = await getApiClient();
try {
const response = await client.patch(`/api/function/${options.id}`, {
environment: [],
});
if (response.status === 200) {
console.log(
`${chalk.green("✓")} All environment variables flushed for function ${chalk.cyan(options.id)}.`
);
console.log(`${chalk.blue("")} Total environment variables: 0`);
}
} catch (error: any) {
console.error(`${chalk.red("✗")} Error: ${error.response?.data?.message || error.message}`);
}
},
};
+53
View File
@@ -0,0 +1,53 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const listEnvDefinition = {
name: "list",
description: "Lists all environment variables for a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
],
action: async (options: any) => {
if (!options.id) {
console.error(`${chalk.red("✗")} Error: --id is required`);
return;
}
const client = await getApiClient();
try {
const response = await client.get(`/api/function/${options.id}`);
const functionData = response.data.data || response.data;
let env = functionData.env;
if (typeof env === 'string' && env.length > 0) {
try {
env = JSON.parse(env);
} catch (e: any) {
console.error(`${chalk.red("✗")} Error parsing env string: ${e.message}`);
env = [];
}
}
const envList: { name: string; value: string }[] = Array.isArray(env) ? env : [];
if (envList.length === 0) {
console.log(`${chalk.yellow("!")} No environment variables found for function ${chalk.cyan(options.id)}.`);
return;
}
console.log(`${chalk.blue("")} Environment variables for function ${chalk.cyan(options.id)}:`);
envList.forEach((item) => {
console.log(`${chalk.green("•")} ${chalk.cyan(item.name)}=${chalk.white(item.value)}`);
});
console.log(`${chalk.blue("")} Total: ${envList.length}`);
} catch (error: any) {
if (error.response?.status === 404) {
console.error(`${chalk.red("✗")} Function with ID ${chalk.yellow(options.id)} not found.`);
} else {
console.error(`${chalk.red("✗")} Error fetching environment variables: ${chalk.yellow(error.response?.data?.message || error.message)}`);
}
}
},
};
+62
View File
@@ -0,0 +1,62 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const removeFunctionDefinition = {
name: "remove",
description: "Removes a specific environment variable from a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{
name: "--name <name>",
description: "Environment variable name to remove",
required: true,
},
],
action: async (options: any) => {
if (!options.id || !options.name) {
console.error(`${chalk.red("✗")} Error: --id and --name are required`);
return;
}
const client = await getApiClient();
try {
const response = await client.get(`/api/function/${options.id}`);
const functionData = response.data.data || response.data;
let existingEnv: { name: string; value: string }[] = functionData.env || [];
if (typeof existingEnv === 'string') {
try {
existingEnv = JSON.parse(existingEnv);
} catch (e: any) {
existingEnv = [];
}
}
if (!Array.isArray(existingEnv)) {
existingEnv = [];
}
const initialCount = existingEnv.length;
const filteredEnv = existingEnv.filter((env) => env.name !== options.name);
if (filteredEnv.length === initialCount) {
console.warn(`${chalk.yellow("!")} Variable ${chalk.cyan(options.name)} not found. No changes made.`);
return;
}
const patchResponse = await client.patch(`/api/function/${options.id}`, {
environment: filteredEnv,
});
if (patchResponse.status === 200) {
console.log(
`${chalk.green("✓")} Variable ${chalk.cyan(options.name)} removed successfully from function ${chalk.cyan(options.id)}.`
);
console.log(`${chalk.blue("")} Remaining environment variables: ${filteredEnv.length}`);
}
} catch (error: any) {
console.error(`${chalk.red("✗")} Error: ${error.response?.data?.message || error.message}`);
}
},
};
+46
View File
@@ -0,0 +1,46 @@
import chalk from "chalk";
import fs from "fs/promises";
import { getApiClient } from "../../api.js";
import { createOrUpdateFile, handleAxiosError } from "../../utils/fileops.js";
export const fileCreateDefinition = {
name: "create",
description: "Create or update a file in a function.",
options: [
{ name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--filename <name>", description: "Remote filename", required: true },
{ name: "--content <content>", description: "Inline file content" },
{ name: "--source <source>", description: "Local source file path" },
],
action: async (options: any) => {
if (!options.content && !options.source) {
console.error(`${chalk.red("✗")} You must provide either --content or --source.`);
return;
}
if (options.content && options.source) {
console.error(`${chalk.red("✗")} Please provide only one of --content or --source.`);
return;
}
let content = options.content;
if (options.source) {
try {
content = await fs.readFile(options.source, "utf8");
} catch (error: any) {
console.error(`${chalk.red("✗")} Failed to read source file: ${chalk.yellow(error.message)}`);
return;
}
}
const client = await getApiClient();
try {
await createOrUpdateFile(client, options.functionId, options.filename, content as string);
console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.filename)} created/updated successfully in function ${chalk.cyan(options.functionId)}.`,
);
} catch (error: any) {
handleAxiosError(error);
}
},
};
+23
View File
@@ -0,0 +1,23 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { deleteFile, handleAxiosError } from "../../utils/fileops.js";
export const fileDeleteDefinition = {
name: "delete",
description: "Delete a file from a function.",
options: [
{ name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--file-id <id>", description: "File ID (from 'file list')", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
await deleteFile(client, options.functionId, options.fileId, undefined);
console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.fileId)} deleted from function ${chalk.cyan(options.functionId)}.`,
);
} catch (error: any) {
handleAxiosError(error);
}
},
};
+38
View File
@@ -0,0 +1,38 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { listFiles, handleAxiosError } from "../../utils/fileops.js";
export const fileListDefinition = {
name: "list",
description: "List files in a function.",
options: [
{ name: "--function-id <id>", description: "Function ID", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const files = await listFiles(client, options.functionId);
if (files) {
if (!Array.isArray(files) || files.length === 0) {
console.log(`${chalk.yellow("!")} No files found.`);
return;
}
console.log(chalk.blue("Files:"));
console.log(chalk.gray(`${"ID".padEnd(25)} ${"Filename"}`));
files.forEach((file: any) => {
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.`);
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server.`);
}
} catch (error: any) {
handleAxiosError(error);
}
},
};
+24
View File
@@ -0,0 +1,24 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { renameFile, handleAxiosError } from "../../utils/fileops.js";
export const fileRenameDefinition = {
name: "rename",
description: "Rename a file in a function.",
options: [
{ name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--file-id <id>", description: "File ID", required: true },
{ name: "--new-filename <name>", description: "New filename", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
await renameFile(client, options.functionId, options.fileId, options.newFilename);
console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.fileId)} renamed to ${chalk.cyan(options.newFilename)} successfully.`,
);
} catch (error: any) {
handleAxiosError(error);
}
},
};
+160
View File
@@ -0,0 +1,160 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { AxiosResponse } from "axios";
export const functionExecuteDefinition = {
name: "execute",
description: "Execute a function and stream the output (debug/internal).",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{ name: "--payload <json>", description: "JSON payload for execution", required: false },
{ name: "--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) => {
const client = await getApiClient();
// Explicitly check for noStream from commander
// Commander uses camelsCase for options, so --no-stream becomes stream: false
// but we use --no-stream which commander natively handles as a boolean toggle
const useStream = options.stream !== false;
let payload = {};
if (options.payload) {
try {
payload = JSON.parse(options.payload);
} catch (error: any) {
console.error(`${chalk.red("✗")} Invalid JSON payload: ${chalk.yellow(error.message)}`);
return;
}
}
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();
if (!trimmed) return;
try {
const obj = JSON.parse(trimmed);
if (obj.type === "output" && obj.content) {
process.stdout.write(obj.content);
} else if (obj.type === "end") {
if (obj.exitCode !== 0 && obj.exitCode !== undefined) {
console.error(`\n${chalk.red("✗")} Execution failed with exit code ${obj.exitCode}`);
} else {
console.log(`\n${chalk.green("✓")} Execution completed.`);
}
if (obj.result !== undefined) {
console.log(chalk.blue("Result:"), typeof obj.result === 'object' ? JSON.stringify(obj.result, null, 2) : obj.result);
}
if (obj.took) {
const total = obj.took.find((t: any) => t.description === "Total");
if (total) {
console.log(chalk.gray(`Total time: ${total.value}s`));
}
}
}
} catch (e) {
// If it's not JSON, it might be raw output from a non-stream response
// that got into the stream handler mistakenly or just raw noise
if (trimmed.length > 0) {
process.stdout.write(chunk);
}
// Not a full JSON object yet or invalid JSON
}
};
console.log(`${chalk.blue("")} Executing function ${chalk.cyan(options.id)} (stream: ${useStream})...`);
try {
const response = await client.post(`/api/function/${options.id}/execute`,
{ run: payload },
{
params: { stream: useStream ? "true" : "false" },
responseType: useStream ? "stream" : "json"
}
);
if (useStream) {
const stream = (response as AxiosResponse).data;
let buffer = "";
return new Promise<void>((resolve, reject) => {
stream.on("data", (chunk: any) => {
const str = chunk.toString();
buffer += str;
// Try to split by potential JSON boundaries
// Matches cases like ...}{...
const pattern = /\}\s*\{/g;
let lastIndex = 0;
let match;
while ((match = pattern.exec(buffer)) !== null) {
const part = buffer.slice(lastIndex, match.index + 1);
handleJsonChunk(part);
lastIndex = match.index + match[0].indexOf('{');
}
buffer = buffer.slice(lastIndex);
// Attempt to parse the remaining buffer if it looks like a complete object
try {
if (buffer.trim().startsWith('{') && buffer.trim().endsWith('}')) {
handleJsonChunk(buffer);
buffer = "";
}
} catch (e) {
// Wait for more data
}
});
stream.on("end", () => {
if (buffer.trim()) {
handleJsonChunk(buffer);
}
resolve();
});
stream.on("error", (err: Error) => {
console.error(`\n${chalk.red("✗")} Stream error: ${err.message}`);
reject(err);
});
});
} else {
// Non-stream returns just the output content or the body
const result = response.data;
if (typeof result === 'string') {
process.stdout.write(result);
} else if (result && result.data && typeof result.data === 'string') {
process.stdout.write(result.data);
} else if (result && typeof result.output === 'string') {
process.stdout.write(result.output);
} else if (result && typeof result.result === 'string') {
process.stdout.write(result.result);
} else if (result && result.data && result.data.output) {
process.stdout.write(result.data.output);
} else {
process.stdout.write(JSON.stringify(result, null, 2));
}
console.log(`\n${chalk.green("✓")} Execution completed.`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Execution failed: ${chalk.yellow(error.response.data?.message || error.response.statusText || "Unknown error")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+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}`);
}
},
};
+56
View File
@@ -0,0 +1,56 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const pipInstallDefinition = {
name: "pipinstall",
description: "Install Python dependencies for a function using requirements.txt",
options: [
{
name: "--id <id>",
description: "The ID of the function.",
required: true,
},
],
action: async (options: { id: string }) => {
await pipInstallFunction(options.id);
},
};
async function pipInstallFunction(id: string) {
const client = await getApiClient();
try {
const response = await client.post(`/api/function/${id}/pip-install`);
if (response.status === 200) {
console.log(`${chalk.green("✓")} Dependencies installed successfully for function ${chalk.yellow(id)}.`);
if (response.data && response.data.status) {
console.log(`${chalk.gray("Status:")} ${response.data.status}`);
}
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to install dependencies.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data?.message || "Unknown error from server")}`,
);
} else if (error.request) {
console.error(
`${chalk.red("✗")} Failed to install dependencies.`,
);
console.error(
`${chalk.yellow("Could not connect to the SHSF instance. Check your connection.")}`,
);
} else {
console.error(`${chalk.red("✗")} Error:`, 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}`);
}
},
};
+63
View File
@@ -0,0 +1,63 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { loadConfig } from "../../config.js";
import { resolveFunctionId } from "../../utils/function_commands.js";
import { buildFunctionExecUrls } from "../../utils/function_exec_url.js";
export const getExecUrlDefinition = {
name: "exec-url",
description: "Get a function execution URL by its ID.",
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;
await getExecUrl(functionId);
},
};
async function getExecUrl(functionId: string): Promise<void> {
const client = await getApiClient();
const config = await loadConfig();
try {
const response = await client.get(`/api/function/${functionId}`);
const functionData = response.data?.data;
if (!functionData) {
console.error(`${chalk.red("✗")} Unexpected response from server.`);
return;
}
if (!functionData.allow_http) {
console.error(`${chalk.red("✗")} HTTP execution is not allowed for function ${chalk.yellow(functionId)}.`);
return;
}
const urls = buildFunctionExecUrls(config.SHSF_INSTANCE, functionData);
console.log(`${chalk.blue("Execution URL")} for function ${chalk.cyan(functionId)}:`);
console.log(chalk.bgGreen.black(` ${urls.executionUrl} `));
if (urls.aliasUrl) {
console.log(`${chalk.blue("Alias URL")} for function ${chalk.cyan(functionId)}:`);
console.log(chalk.bgGreen.black(` ${urls.aliasUrl} `));
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Function with ID ${chalk.yellow(functionId)} not found.`);
} else {
console.error(`${chalk.red("✗")} Failed to fetch function details.`);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data?.message || "Unknown error from server")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${chalk.yellow(error.message)}`);
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const getFunctionDefinition = {
name: "function <id>",
description: "Get detailed information about a function by its ID.",
action: async (id: string) => {
await getFunction(id);
},
};
async function getFunction(id: string) {
const client = await getApiClient();
try {
const response = await client.get(`/api/function/${id}`);
if (response.status === 200 && response.data.data) {
const f = response.data.data;
console.log(chalk.blue.bold(`\nFunction Details: ${f.name} (${f.id})`));
console.log(`${chalk.gray("-------------------------------------------")}`);
console.log(`${chalk.yellow("Name:")} ${f.name}`);
console.log(`${chalk.yellow("ID:")} ${f.id}`);
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})`);
}
if (f.executionAlias) {
console.log(`${chalk.yellow("Alias:")} ${f.executionAlias}`);
}
console.log(`${chalk.gray("-------------------------------------------")}\n`);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Function with ID ${chalk.yellow(id)} not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to fetch function details.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const getNamespaceDefinition = {
name: "namespace <id>",
description: "Get detailed information about a namespace by its ID.",
action: async (id: string) => {
await getNamespace(id);
},
};
async function getNamespace(id: string) {
const client = await getApiClient();
try {
const response = await client.get(`/api/namespace/${id}`);
if (response.status === 200 && response.data.data) {
const ns = response.data.data;
console.log(chalk.blue.bold(`\nNamespace Details: ${ns.name} (${ns.id})`));
console.log(`${chalk.gray("-------------------------------------------")}`);
console.log(`${chalk.yellow("Name:")} ${ns.name}`);
console.log(`${chalk.yellow("ID:")} ${ns.id}`);
if (ns.functions && ns.functions.length > 0) {
console.log(`${chalk.yellow("Functions:")}`);
ns.functions.forEach((f: any) => {
console.log(`- ${chalk.cyan(f.name)} ${chalk.gray(`(${f.id})`)}`);
});
} else {
console.log(`${chalk.yellow("Functions:")} ${chalk.gray("None")}`);
}
console.log(`${chalk.gray("-------------------------------------------")}\n`);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Namespace with ID ${chalk.yellow(id)} not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to fetch namespace details.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const getTriggerDefinition = {
name: "trigger <functionId> <triggerId>",
description: "Get detail of a specific trigger from a function.",
action: async (functionId: string, triggerId: string) => {
await getTrigger(functionId, triggerId);
},
};
async function getTrigger(functionId: string, triggerId: string) {
const client = await getApiClient();
try {
const response = await client.get(`/api/functions/${functionId}/triggers/${triggerId}`);
if (response.status === 200 && response.data.data) {
const t = response.data.data;
console.log(chalk.blue.bold(`\nTrigger Details: ${t.name} (${t.id})`));
console.log(`${chalk.gray("-------------------------------------------")}`);
console.log(`${chalk.yellow("Name:")} ${t.name}`);
console.log(`${chalk.yellow("ID:")} ${t.id}`);
console.log(`${chalk.yellow("Description:")} ${t.description || chalk.gray("No description")}`);
console.log(`${chalk.yellow("Cron:")} ${chalk.cyan(t.cron)}`);
console.log(`${chalk.yellow("Enabled:")} ${t.enabled ? chalk.green("Yes") : chalk.red("No")}`);
console.log(`${chalk.yellow("Function ID:")} ${t.functionId}`);
if (t.nextRun) {
console.log(`${chalk.yellow("Next Run:")} ${new Date(t.nextRun).toLocaleString()}`);
}
if (t.data) {
console.log(`${chalk.yellow("Data:")} ${t.data}`);
}
console.log(`${chalk.gray("-------------------------------------------")}\n`);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Trigger or function not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to fetch trigger details.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+334
View File
@@ -0,0 +1,334 @@
import chalk from "chalk";
const AIDOC = `
## SHSF Platform Reference — read this carefully before writing any code
---
### 1. Entry-point conventions
Every function MUST expose a single entry-point that the SHSF runtime calls.
**Python** (file extension: .py)
\`\`\`python
def main(args):
# args is a dict injected by the runtime (see §2)
return {"hello": "world"} # plain dict → 200 JSON response
\`\`\`
**Go** (file extension: .go, package must be \`main\`)
\`\`\`go
package main
func main_user(args interface{}) (interface{}, error) {
return map[string]string{"hello": "world"}, nil
}
\`\`\`
• Go functions MUST use \`main_user\`, NOT \`main\`, as the user entry-point.
• Dependencies go in a \`go.mod\` file (auto-downloaded by the runtime).
• Supported Go versions: 1.20 / 1.21 / 1.22 / 1.23
**.NET / C#** (project-based runtime)
\`\`\`csharp
using SHSF;
var args = Runtime.LoadPayloadJson<Dictionary<string, JsonElement?>>();
Runtime.Return(new { hello = "world" });
\`\`\`
• .NET functions are project-based: include a runnable \`.csproj\` and C# source files.
• Do NOT use \`func.startup_file\` for .NET. The startup file should be an empty string.
• Your code can use \`Runtime.LoadPayload()\`, \`Runtime.LoadPayloadJson<T>()\`, and \`Runtime.Return(object)\` from the auto-provisioned \`SHSF.Runtime.cs\` helper.
• Supported .NET SDK images: 8.0 / 9.0 / 10.0
---
### 2. The \`args\` object
The runtime injects these fields. Always use .get() / nil-checks — never assume a field is present.
| Field | Type | Description |
|------------|---------------|--------------------------------------------------------------|
| body | string | Raw JSON string of the HTTP POST body — MUST be parsed with json.loads() before use |
| queries | dict / map | URL query parameters (?key=value) |
| route | string | Sub-path after the function URL (no leading slash). Default: "default" |
| headers | dict / map | Incoming HTTP request headers (lowercased keys) |
| raw_body | bytes/string | Raw request body bytes (for file uploads, binary data) |
| method | string | HTTP method (GET, POST, PUT, PATCH, DELETE, QUERY, …) |
> ⚠️ \`body\` is a raw JSON string — you MUST call \`json.loads(body)\` before accessing fields.
Python example:
\`\`\`python
import json
def main(args):
body = args.get("body", "{}")
body = json.loads(body) # ← required: parse the JSON string 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}
\`\`\`
---
### 3. Custom responses (SHSF v2 protocol)
Return a plain dict/map for a simple 200 JSON response.
Return the v2 envelope to control status code, headers, and body:
\`\`\`python
def main(args):
return {
"_shsf": "v2",
"_code": 201,
"_headers": {"X-My-Header": "value", "Content-Type": "application/json"},
"_res": {"created": True, "id": 42}
}
\`\`\`
v2 envelope fields:
- _shsf (required): must be "v2"
- _code (int): HTTP status code to return
- _res (any): response body (string, dict, …)
- _headers (dict): extra response headers to send
- _location (string): redirect URL — only valid when _code is 301 or 302
---
### 4. Redirects
Set _code to 301 (permanent) or 302 (temporary) AND supply _location:
\`\`\`python
def main(args):
return {"_shsf": "v2", "_code": 302, "_location": "https://example.com/target"}
\`\`\`
---
### 5. Environment variables
Define them in the SHSF dashboard — NEVER hard-code secrets in source files.
\`\`\`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}
\`\`\`
---
### 6. Persistent storage
- /app/ — files persist between invocations. Use for cached data, state files, etc.
- /tmp/ — ephemeral; wiped between container restarts.
---
### 7. SHSF Database Communication (_db_com) — Python
\`\`\`python
from _db_com import database
db = database()
def main(args):
db.create_storage("my_app", purpose="application data")
db.set("my_app", "username", "alice")
username = db.get("my_app", "username")
items = db.list_items("my_app")
db.delete_item("my_app", "username")
return {"username": username, "items": items}
\`\`\`
Go dbcom equivalent:
\`\`\`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
}
\`\`\`
---
### 8. Routing
args["route"] contains the URL sub-path after the function base URL (no leading slash).
Default when no sub-path is given: "default".
\`\`\`python
def main(args):
route = args.get("route", "default")
if route == "register":
return handle_register(args)
elif route == "login":
return handle_login(args)
else:
return {"_shsf": "v2", "_code": 404, "_res": {"error": "route not found"}}
\`\`\`
---
### 9. Serving HTML
\`\`\`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}
\`\`\`
For a fully static page: one .html file as the startup file, zero other files — SHSF serves it directly.
---
### 10. Raw body / file uploads
\`\`\`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}}
\`\`\`
---
### 11. Secure headers (x-secure-header)
When a function has secure-header enabled, SHSF validates the x-secure-header before
invoking — you do NOT need to re-validate it in your code.
---
### 12. Dependency files
| Runtime | File | How it works |
|---------|-----------------|-------------------------------------------|
| Python | requirements.txt | pip-installed before first run |
| Go | go.mod + go.sum | module dependencies, auto-downloaded |
| .NET | .csproj | NuGet restore/build handled by dotnet CLI |
---
### 13. Absolute rules — violations will cause the function to fail
- When the image is set to python, don't create go files, and vice versa.
- Only create files allowed by the runtime file policy.
- If no packages are needed, don't create a requirements.txt or go.mod file.
- FORBIDDEN filenames: _runner.py, _runner.js, init.sh (reserved by the SHSF runtime)
- Filenames must NEVER contain / or \\ (no subdirectories)
- Never write partial files or placeholder comments like "# ... rest of code"
- Never hard-code secrets — always use environment variables (§5)
- Go entry-point is main_user(), never main()
- .NET functions must include a runnable .csproj and return results via Runtime.Return(...)
- Never invent SHSF-specific APIs that are not documented in this reference
- Always \`import json\` and call \`json.loads(args.get("body", "{}"))\` in Python before accessing body fields
`;
const CLI_OVERVIEW = `
SHSF CLI — Command Overview
════════════════════════════════════════════════════════
Command groups:
api Raw REST access (api request, api openapi)
mcp MCP tools access (mcp init, mcp tools, mcp call, mcp docs)
create Create resources (function, namespace, trigger)
update Update resources (function, namespace, trigger)
delete Delete resources (function, namespace, trigger)
get Fetch details (function, namespace, trigger, exec-url)
remote Sync files (remote push, remote pull)
file Manage files (file list, file create, file delete, file rename)
env Function env vars (env add, env list, env remove, env flush)
account Account-level env vars (account env add, account env list, …)
function Function extras (function logs, function execute, function ratelimit)
storage Key-value store (storage create, storage list, storage set-item, …)
cors CORS config (cors add, cors list, cors remove, cors clear)
count Quick counts (count functions, count namespaces, …)
req Requirements (req add, req remove)
health Health check
uiurl Print the dashboard URL
Discovery commands (no auth required):
shsf api openapi — print the full OpenAPI spec
shsf mcp init — print MCP server config for this instance
shsf mcp tools — list all available MCP tools
shsf mcp docs — print the live function authoring reference
shsf mcp call <tool> — call any MCP tool by name
Authentication is read from the environment:
SHSF_INSTANCE Base URL of your SHSF instance (e.g. https://shsf.example.com)
SHSF_TOKEN API token for authenticated calls
`;
export const instructDefinition = {
name: "instruct",
description:
"IMPORTANT: Read this before using the CLI — SHSF platform overview, CLI command reference, and function authoring rules for humans and agents.",
action: () => {
// ── Human section ──────────────────────────────────────────────────────
console.log(chalk.bold.cyan("\n╔══════════════════════════════════════════════╗"));
console.log(chalk.bold.cyan("║ SHSF CLI — Getting Started ║"));
console.log(chalk.bold.cyan("╚══════════════════════════════════════════════╝\n"));
console.log(chalk.bold("What is SHSF?"));
console.log(
"SHSF is a self-hosted serverless function platform. You deploy short-lived\n" +
"functions (Python, Go, or .NET/C#) that are invoked over HTTP. The CLI lets\n" +
"you manage functions, namespaces, files, environment variables, storage, and\n" +
"more — all from the terminal or scripts.\n"
);
console.log(chalk.bold("Quick start for humans:"));
console.log(
" 1. Set SHSF_INSTANCE and SHSF_TOKEN in your environment.\n" +
" 2. Run shsf health to verify the connection.\n" +
" 3. Run shsf create function to create your first function.\n" +
" 4. Use shsf remote push to upload source files.\n" +
" 5. Use shsf get exec-url to get the callable URL.\n" +
" 6. Run shsf -h for the full command list at any time.\n"
);
console.log(CLI_OVERVIEW);
// ── Agent section ───────────────────────────────────────────────────────
console.log(chalk.bold.yellow("╔══════════════════════════════════════════════╗"));
console.log(chalk.bold.yellow("║ FOR AI AGENTS — Function Authoring ║"));
console.log(chalk.bold.yellow("╚══════════════════════════════════════════════╝\n"));
console.log(
"If you are an AI agent writing or editing SHSF functions, the section below\n" +
"is your authoritative reference. Follow every rule in §13 or the function\n" +
"will fail to run. Use shsf mcp docs to fetch the live version of this\n" +
"reference directly from the connected SHSF instance.\n"
);
console.log(AIDOC);
console.log(chalk.dim("─────────────────────────────────────────────────────"));
console.log(chalk.dim("Tip: run shsf mcp docs to fetch the live reference from your instance."));
console.log(chalk.dim("Tip: run shsf -h to see all available commands.\n"));
},
};
+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}`);
}
},
};
+37
View File
@@ -0,0 +1,37 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import fs from "fs";
import path from "path";
export const pullDefinition = {
name: "pull",
description: "Pull files from a remote function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{ name: "--into <path>", description: "Target directory", required: true },
{ name: "--force", description: "Force overwrite" },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.get(`/api/function/${options.id}/files`);
const files = response.data.data; // Expecting { name: string, content: string }[]
if (!fs.existsSync(options.into)) {
fs.mkdirSync(options.into, { recursive: true });
} else if (fs.readdirSync(options.into).length > 0 && !options.force) {
console.error(chalk.red(`Directory ${options.into} is not empty. Use --force to overwrite.`));
return;
}
for (const file of files) {
fs.writeFileSync(path.join(options.into, file.name), file.content);
const byteSize = Buffer.byteLength(file.content, 'utf8');
console.log(chalk.green(`Pulled ${file.name} (${byteSize} bytes)`));
}
console.log(chalk.green(`Successfully pulled ${files.length} files into ${options.into}`));
} catch (error: any) {
console.error(chalk.red(`Failed to pull files: ${error.message}`));
}
},
};
+232
View File
@@ -0,0 +1,232 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import fs from "fs";
import path from "path";
import {
defaultUnpushableFiles,
readIgnoreFile,
matchesAnyPattern,
readMappingFile,
} from "../../utils/push_helpers.js";
import { createHash } from "crypto";
import type { ApiClient } from "../../types/apiClient.js";
async function deleteNonexistentFiles(
currentFiles: any[],
files: any[],
client: ApiClient,
options: any,
): Promise<{ didDeletion: boolean }> {
let didDeletion = false;
for (const currentFile of currentFiles) {
if (!files.some((f: any) => f.filename === currentFile.name)) {
try {
await client.delete(
`/api/function/${options.id}/file/${currentFile.id}`,
{
data: { filename: currentFile.name },
},
);
} catch (error: any) {
throw error;
}
console.log(
chalk.green(`Deleted ${currentFile.name} from function ${options.id}`),
);
didDeletion = true;
}
}
return { didDeletion };
}
export const pushDefinition = {
name: "push",
description: "Push files to a remote function.",
options: [
{ name: "--id <id>", description: "Function ID" },
{ name: "--from <path>", description: "Source directory" },
{ name: "--force", description: "Force overwrite" },
],
action: async (options: any) => {
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)) {
console.error(chalk.red(`Source ${options.from} does not exist.`));
return;
}
let didDeletion = false; // Track if any deletions were made
const currentFilesResponse = await client.get(
`/api/function/${options.id}/files`,
);
const currentFiles = currentFilesResponse.data.data; // Expecting { name: string, id: string [...] }[]
// Read ignore patterns from .shsfignore (in source dir or cwd) and merge with defaults
const customPatterns = readIgnoreFile(options.from);
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) {
console.log(
chalk.yellow("Too many modifications. Please use --force to confirm."),
);
return;
}
// Push new and modified files
let pushedFilesCount = 0;
try {
for (const file of files) {
// Hash first, save traffic
const fileHash = createHash("md5").update(file.content).digest("hex");
const currentFile = currentFiles.find(
(f: any) => f.name === file.filename,
);
if (currentFile) {
const currentFileHash = createHash("md5")
.update(currentFile.content || "")
.digest("hex");
if (fileHash === currentFileHash) {
console.log(
chalk.yellow(
`Skipping ${file.filename}, no changes detected for function ${options.id}`,
),
);
continue;
}
}
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`, {
filename: file.filename,
code: file.content,
});
const byteSize = Buffer.byteLength(file.content, "utf8");
console.log(
chalk.green(
`Pushed ${file.filename} (${byteSize} bytes) to function ${options.id}`,
),
);
pushedFilesCount++;
}
// Delete files that no longer exist on the remote AFTER pushing new/updated files
// This avoids the "cannot delete only file" error if we are pushing at least one file.
try {
const deletionResult = await deleteNonexistentFiles(
currentFiles,
files,
client,
options,
);
if (deletionResult.didDeletion) {
didDeletion = true;
}
} catch (error: any) {
if (error.response) {
console.error(
chalk.red(
`Failed to delete files: ${error.response.data?.message || error.response.statusText}`,
),
);
} else {
console.error(chalk.red(`Error during deletion: ${error.message}`));
}
}
console.log(
chalk.green(
`Successfully pushed ${pushedFilesCount} files to function ${options.id}`,
),
);
// If 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) {
console.log("");
console.log(
chalk.blue(
`Hint: We might delete files that you renamed, but don't worry, we'll push the newly named file right after!`,
),
);
}
} catch (error: any) {
if (error.response) {
console.error(
chalk.red(
`Failed to push files: ${error.response.data?.message || error.response.statusText}`,
),
);
} else if (error.request) {
console.error(chalk.red("No response received from server."));
} else {
console.error(chalk.red(`Error: ${error.message}`));
}
}
},
};
+91
View File
@@ -0,0 +1,91 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
interface ShsfFile {
name: string;
content: string;
}
export const reqAddDefinition = {
name: "add <package>",
description: "Add a package to the requirements.txt of a function.",
options: [
{ name: "--to <id>", description: "The ID of the function (deprecated alias for --id)." },
{ name: "--id <id>", description: "The ID of the function.", required: true },
],
action: async (pkg: string, options: { to?: string; id?: string }) => {
const functionId = options.id ?? options.to;
if (!functionId) {
console.error(`${chalk.red("✗")} Error: Function ID is required. Use ${chalk.cyan("--id <id>")}.`);
return;
}
const client = await getApiClient();
try {
// 1. Get current files of the function
const response = await client.get(`/api/function/${functionId}/files`);
let files: ShsfFile[] = response.data.data;
if (!Array.isArray(files)) files = [];
let requirementsFile = files.find(f => f.name === "requirements.txt");
let content = requirementsFile ? requirementsFile.content : "";
// 2. Add the package
const lines = content.split("\n");
// Robust package name extraction
const getPkgName = (line: string) => {
const withoutComment = line.trim().split("#", 1)[0];
const basePart = withoutComment.split(";", 1)[0].trim();
if (!basePart) return null;
return basePart.split(/[!=>~<]/)[0].trim().toLowerCase();
};
const pkgToAddName = getPkgName(pkg);
if (!pkgToAddName) {
console.error(`${chalk.red("✗")} Invalid package format.`);
return;
}
const exists = lines.some(line => {
const lineName = getPkgName(line);
return lineName === pkgToAddName;
});
if (exists) {
console.log(`${chalk.yellow("!")} Package ${chalk.cyan(pkgToAddName)} is already in requirements.txt.`);
return;
}
// Preserve existing content structure
if (content && !content.endsWith("\n")) {
content += "\n";
}
content += pkg + "\n";
// 3. Overwrite/Create requirements.txt using function-based API
await client.put(`/api/function/${functionId}/file`, {
filename: "requirements.txt",
code: content,
});
console.log(`${chalk.green("✓")} Added ${chalk.cyan(pkg)} to requirements.txt for function ${chalk.yellow(functionId)}.`);
} catch (error: any) {
handleError(error, "add package");
}
},
};
function handleError(error: any, task: string) {
if (error.response) {
console.error(`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(error.response.data?.message || (error.response.data ? JSON.stringify(error.response.data) : error.message))}`);
} else if (error.request) {
console.error(
`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(
"No response received from server. Please check your network connection and try again."
)}`
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
+97
View File
@@ -0,0 +1,97 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
interface ShsfFile {
name: string;
content: string;
}
export const reqRemoveDefinition = {
name: "remove <package>",
description: "Remove a package from the requirements.txt of a function.",
options: [
{ name: "--from <id>", description: "The ID of the function (deprecated alias for --id)." },
{ name: "--id <id>", description: "The ID of the function.", required: true },
],
action: async (pkgToRemove: string, options: { from?: string; id?: string }) => {
const functionId = options.id ?? options.from;
if (!functionId) {
console.error(`${chalk.red("✗")} Error: Function ID is required. Use ${chalk.cyan("--id <id>")}.`);
return;
}
const client = await getApiClient();
try {
// 1. Get current files of the function
const response = await client.get(`/api/function/${functionId}/files`);
let files: ShsfFile[] = response.data.data;
if (!Array.isArray(files)) files = [];
const requirementsFile = files.find(f => f.name === "requirements.txt");
if (!requirementsFile) {
console.log(`${chalk.yellow("!")} No requirements.txt found for function ${chalk.yellow(functionId)}.`);
return;
}
const content = requirementsFile.content;
const lines = content.split("\n");
const targetPkgName = pkgToRemove.trim().toLowerCase();
let removed = false;
const newLines = lines.filter(line => {
const trimmedLine = line.trim();
// Preserve empty/whitespace-only lines
if (!trimmedLine) return true;
// Strip inline comments and environment markers for matching purposes
const withoutComment = trimmedLine.split("#", 1)[0];
const basePart = withoutComment.split(";", 1)[0].trim();
// If there's no actual requirement (e.g., comment-only line), keep the line
if (!basePart) return true;
// Robust extraction (handles != <= >= ~= == and < >)
const currentPkgName = basePart.split(/[!=>~<]/)[0].trim().toLowerCase();
if (currentPkgName === targetPkgName) {
removed = true;
return false;
}
return true;
});
if (!removed) {
console.log(`${chalk.yellow("!")} Package ${chalk.cyan(pkgToRemove)} was not found in requirements.txt.`);
return;
}
const newContent = newLines.join("\n");
// 2. Overwrite requirements.txt using function-based API
await client.put(`/api/function/${functionId}/file`, {
filename: "requirements.txt",
code: newContent,
});
console.log(`${chalk.green("✓")} Removed ${chalk.cyan(pkgToRemove)} from requirements.txt for function ${chalk.yellow(functionId)}.`);
} catch (error: any) {
handleError(error, "remove package");
}
},
};
function handleError(error: any, task: string) {
if (error.response) {
console.error(`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(error.response.data?.message || (error.response.data ? JSON.stringify(error.response.data) : error.message))}`);
} else if (error.request) {
console.error(
`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(
"No response received from server. Please check your network connection and try again."
)}`
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
+35
View File
@@ -0,0 +1,35 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const clearItemsDefinition = {
name: "clear-items",
description: "Clear all items from a storage.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.delete(`/api/storage/${options.name}/items`);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} All items cleared from storage ${chalk.cyan(options.name)}!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to clear items: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+41
View File
@@ -0,0 +1,41 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const createStorageDefinition = {
name: "create",
description: "Create a new storage.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
{ name: "--purpose <purpose>", description: "Storage purpose", required: true },
],
action: async (options: any) => {
const data = {
name: options.name,
purpose: options.purpose,
};
const client = await getApiClient();
try {
const response = await client.post("/api/storage", data);
if (response.status === 200 || response.status === 201) {
console.log(
`${chalk.green("✓")} Storage ${chalk.cyan(data.name)} created successfully!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to create storage: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+36
View File
@@ -0,0 +1,36 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const deleteItemDefinition = {
name: "delete-item",
description: "Delete a storage item.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
{ name: "--key <key>", description: "Item key", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.delete(`/api/storage/${options.name}/item/${options.key}`);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} Item ${chalk.cyan(options.key)} deleted from ${chalk.cyan(options.name)}!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to delete item: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+35
View File
@@ -0,0 +1,35 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const deleteStorageDefinition = {
name: "delete",
description: "Delete a storage.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.delete(`/api/storage/${options.name}`);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} Storage ${chalk.cyan(options.name)} deleted successfully!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to delete storage: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+42
View File
@@ -0,0 +1,42 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const listItemsDefinition = {
name: "get-items",
description: "List all items in a storage.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.get(`/api/storage/${options.name}/items`);
if (response.status === 200 && response.data.data) {
const items = response.data.data;
if (items.length === 0) {
console.log(`${chalk.yellow("!")} Storage ${chalk.cyan(options.name)} is empty.`);
return;
}
console.log(chalk.blue(`Items in ${chalk.cyan(options.name)}:`));
items.forEach((item: any) => {
console.log(`- ${chalk.cyan(item.key)}: ${JSON.stringify(item.value)}`);
});
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to list items: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+40
View File
@@ -0,0 +1,40 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const listStoragesDefinition = {
name: "list",
description: "List all storages.",
action: async () => {
const client = await getApiClient();
try {
const response = await client.get("/api/storage");
if (response.status === 200 && response.data.data) {
const storages = response.data.data;
if (storages.length === 0) {
console.log(`${chalk.yellow("!")} No storages found.`);
return;
}
console.log(chalk.blue("Storages:"));
storages.forEach((s: any) => {
const purposeInfo = s.purpose ? chalk.gray(` - ${s.purpose}`) : "";
console.log(`- ${chalk.cyan(s.name)}${purposeInfo}`);
});
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to list storages: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+59
View File
@@ -0,0 +1,59 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const setItemDefinition = {
name: "set-item",
description: "Set a storage item.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
{ name: "--key <key>", description: "Item key", required: true },
{ name: "--value <value>", description: "Item value (JSON string)", required: true },
{ name: "--expires <expires>", description: "Expiration (ISO string or hours)" },
],
action: async (options: any) => {
let value;
try {
value = JSON.parse(options.value);
} catch (e) {
// If not valid JSON, treat as string
value = options.value;
}
const data: any = {
key: options.key,
value: value,
};
if (options.expires) {
if (!isNaN(Number(options.expires))) {
data.expiresAt = Number(options.expires);
} else {
data.expiresAt = options.expires;
}
}
const client = await getApiClient();
try {
const response = await client.post(`/api/storage/${options.name}/item`, data);
if (response.status === 200 || response.status === 201) {
console.log(
`${chalk.green("✓")} Item ${chalk.cyan(options.key)} set successfully in ${chalk.cyan(options.name)}!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to set item: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${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! ")}`);
},
};
+52
View File
@@ -0,0 +1,52 @@
import chalk from "chalk";
import { getApiClient } from "../api.js";
export const uiUrlDefinition = {
name: "uiurl",
description: "Get the URL for the SHSF UI.",
action: async () => {
await getUiURL();
},
};
async function getUiURL(): Promise<void> {
const client = await getApiClient();
try {
const response = await client.get<{ status: string; uiUrl: string }>("/api/global/uiUrl");
if (response.status === 200 && response.data.status === "OK") {
console.log(
`${chalk.green("✓")} SHSF UI URL: ${chalk.bgGreen.black(` ${response.data.uiUrl} `)}`
);
} else {
console.log(
`${chalk.yellow("!")} SHSF Status: ${chalk.bgYellow.black(" UNEXPECTED RESPONSE ")}`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
console.log(`Response Data: ${JSON.stringify(response.data)}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} SHSF Status: ${chalk.bgRed.black(" UNHEALTHY ")}`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
} else if (error.request) {
console.error(
`${chalk.red("✗")} SHSF Status: ${chalk.bgRed.black(" DISCONNECTED ")}`,
);
console.error(
`Error: ${chalk.yellow("Could not connect to the SHSF instance. Check your connection or SHSF_INSTANCE URL.")}`,
);
} else {
console.error(
`${chalk.red("✗")} SHSF Status: ${chalk.bgRed.black(" LOCAL ERROR ")}`,
);
console.error(`Error: ${chalk.yellow(error.message)}`);
}
}
}
+148
View File
@@ -0,0 +1,148 @@
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 <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 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;
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 (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.`);
return;
}
const client = await getApiClient();
try {
const response = await client.patch(`/api/function/${id}`, data);
if (response.status === 200) {
console.log(`${chalk.green("✓")} Function with ID ${chalk.cyan(id)} updated successfully.`);
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Function with ID ${chalk.yellow(id)} not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to update function: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
}
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+48
View File
@@ -0,0 +1,48 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const updateNamespaceDefinition = {
name: "namespace <id>",
description: "Update an existing namespace.",
options: [
{ name: "--name <name>", description: "Namespace name" },
{ name: "--description <description>", description: "Namespace description" },
],
action: async (id: string, options: any) => {
const data: Record<string, any> = {};
if (options.name !== undefined) data.name = options.name;
if (options.description !== undefined) data.description = options.description;
if (Object.keys(data).length === 0) {
console.error(`${chalk.red("✗")} No update fields provided. Use --help to see available options.`);
return;
}
const client = await getApiClient();
try {
const response = await client.patch(`/api/namespace/${id}`, data);
if (response.status === 200) {
console.log(`${chalk.green("✓")} Namespace with ID ${chalk.cyan(id)} updated successfully.`);
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Namespace with ID ${chalk.yellow(id)} not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to update namespace: ${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}`);
}
}
},
};
+66
View File
@@ -0,0 +1,66 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const updateTriggerDefinition = {
name: "trigger <functionId> <triggerId>",
description: "Update an existing trigger for a function.",
options: [
{ name: "--name <name>", description: "Trigger name" },
{ name: "--description <description>", description: "Trigger description" },
{ name: "--cron <cron>", description: "Cron expression" },
{ name: "--enabled <enabled>", description: "Whether the trigger is enabled (true/false)" },
{ name: "--data <data>", description: "Optional trigger payload data" },
],
action: async (functionId: string, triggerId: string, options: any) => {
let parsedEnabled: boolean | undefined;
if (options.enabled !== undefined) {
const normalized = String(options.enabled).toLowerCase();
if (normalized !== "true" && normalized !== "false") {
console.error(`${chalk.red("✗")} Invalid value for --enabled. Use true or false.`);
return;
}
parsedEnabled = normalized === "true";
}
const data: Record<string, any> = {};
if (options.name !== undefined) data.name = options.name;
if (options.description !== undefined) data.description = options.description;
if (options.cron !== undefined) data.cron = options.cron;
if (parsedEnabled !== undefined) data.enabled = parsedEnabled;
if (options.data !== undefined) data.data = options.data;
if (Object.keys(data).length === 0) {
console.error(`${chalk.red("✗")} No update fields provided. Use --help to see available options.`);
return;
}
const client = await getApiClient();
try {
const response = await client.put(`/api/functions/${functionId}/triggers/${triggerId}`, data);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} Trigger with ID ${chalk.cyan(triggerId)} for function ${chalk.cyan(functionId)} updated successfully.`,
);
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} Trigger or function not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to update trigger: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+7
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)) {
+8 -2
View File
@@ -11,8 +11,14 @@ export const program = new Command();
program
.name('shsf')
.description('SHSF CLI tool to manage your serverless functions.')
.version(pckg.version);
.description(
'SHSF CLI tool to manage your serverless functions.\n\n' +
' NOTE: Run \'shsf instruct\' before getting started — it covers platform\n' +
' concepts, CLI command overview, and function authoring rules for both\n' +
' humans and AI agents.'
)
.version(pckg.version)
.addHelpText('afterAll', '\nTip: run \'shsf instruct\' first to get a full platform and CLI overview.');
await resolveCommands();
+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 };
}
+70
View File
@@ -0,0 +1,70 @@
import chalk from "chalk";
import { getApiClient } from "../api.js";
import { parseCorsOriginsOption } from "./cors.js";
import { resolveFunctionId } from "./function_commands.js";
export { resolveFunctionId } from "./function_commands.js";
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}`);
}
+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 }));
}
+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,
};
+19
View File
@@ -0,0 +1,19 @@
import chalk from "chalk";
import { readMappingFile } from "./push_helpers.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;
}
+35
View File
@@ -0,0 +1,35 @@
export interface FunctionExecUrlInput {
namespaceId?: number | string;
executionId?: string;
executionAlias?: string | null;
}
export interface FunctionExecUrls {
executionUrl: string;
aliasUrl?: string;
}
function normalizeBaseUrl(instanceUrl: string): string {
return instanceUrl.replace(/\/+$/, "");
}
export function buildFunctionExecUrls(
instanceUrl: string,
functionData: FunctionExecUrlInput,
): FunctionExecUrls {
const baseUrl = normalizeBaseUrl(instanceUrl);
if (functionData.namespaceId === undefined || !functionData.executionId) {
throw new Error("Function response is missing execution URL fields.");
}
const urls: FunctionExecUrls = {
executionUrl: `${baseUrl}/api/exec/${functionData.namespaceId}/${functionData.executionId}`,
};
if (functionData.executionAlias) {
urls.aliasUrl = `${baseUrl}/exec/${functionData.executionAlias}`;
}
return urls;
}
+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;
}
+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;
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
}
},
});