Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eeb34b7103 | |||
| 75ef33f47c | |||
| 97b2089857 | |||
| 0434376b83 | |||
| 681a3cd6df | |||
| 544f6bd714 | |||
| c912c791fa | |||
| d9d6871f09 | |||
| 121b0429f6 | |||
| 9f78e2a221 | |||
| 1a0e811a9a | |||
| 57b05da8c1 | |||
| e6e2203751 | |||
| 5c18138dd2 | |||
| 9f2d72c3a0 | |||
| dddb352457 | |||
| 3fe3dc96da | |||
| e953466786 | |||
| c43c37ce34 | |||
| 13900343a3 | |||
| cc54ef85e1 | |||
| 67f05af7a5 | |||
| 37f6d50034 | |||
| a1f80d38f2 | |||
| 7b659b1ef5 | |||
| 3261cc1830 | |||
| a76bb15fc9 | |||
| 47cd88f72b | |||
| 6a98ef4bed | |||
| e6dea43555 | |||
| 7b86fec717 | |||
| 0be63e5287 | |||
| 1d04649cbf | |||
| 344e10ac46 | |||
| 6ad74bb991 | |||
| 1b3851d0af | |||
| 9887ca3dcc | |||
| ae82ba221b | |||
| 8428ed56b5 |
@@ -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"
|
||||
@@ -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.
|
||||
@@ -1,98 +0,0 @@
|
||||
name: CI / CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
paths:
|
||||
- '**/*.ts'
|
||||
- '**/*.json'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '**/*.ts'
|
||||
- '**/*.json'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Test Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm build
|
||||
|
||||
- name: Zip dist directory
|
||||
run: zip -r dist.zip dist/
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-artifact
|
||||
path: dist.zip
|
||||
|
||||
publish:
|
||||
name: Publish to NPM
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-artifact
|
||||
|
||||
- run: unzip dist.zip
|
||||
|
||||
- run: pnpm publish --no-git-checks
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
needs: publish
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-artifact
|
||||
|
||||
- name: Get version from package.json
|
||||
id: package_version
|
||||
run: |
|
||||
pkg_version=$(node -p "require('./package.json').version")
|
||||
echo "VERSION=$pkg_version" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist.zip
|
||||
tag_name: v${{ steps.package_version.outputs.VERSION }}
|
||||
name: Release v${{ steps.package_version.outputs.VERSION }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -2,3 +2,5 @@ node_modules
|
||||
dist
|
||||
build
|
||||
pnpm-lock.yaml
|
||||
testing
|
||||
.codex
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
name: shsf
|
||||
description: Interact with the users shsf instance to manage serverless functions, namespaces, triggers, schedules, and more via the CLI.
|
||||
---
|
||||
|
||||
# SHSF - Selfhostable Serverless Functions; THE CLI
|
||||
The `shsf` CLI tool allows you to interact with your shsf instance to manage serverless functions, namespaces, triggers, schedules, and more. Below are the available commands and their descriptions.
|
||||
|
||||
## Setup
|
||||
To get started, you need to set up the `shsf` CLI tool. Follow the instructions below to install and configure it.
|
||||
|
||||
1. Run this
|
||||
```bash
|
||||
shsf health
|
||||
```
|
||||
this will check the health, and if not setup, it will prompt you to set up the CLI.
|
||||
|
||||
## Commands
|
||||
|
||||
- `shsf count functions`: Count your functions. Add `--full` to list them.
|
||||
- `shsf count namespaces`: Count your namespaces. Add `--full` to list them.
|
||||
- `shsf count storages`: Count your storages. Add `--full` to list them.
|
||||
- `shsf count triggers`: Count your triggers. Add `--full` to list them.
|
||||
|
||||
- `shsf create function`: Create a new function. (use `shsf create function -h` first)
|
||||
- `shsf create namespace`: Create a new namespace. (use `shsf create namespace -h` first)
|
||||
- `shsf create trigger`: Create a new trigger. (use `shsf create trigger -h` first)
|
||||
|
||||
- `shsf delete function <id>`: Deletes a specific serverless function by its ID.
|
||||
- `shsf delete namespace <id>`: Deletes a namespace and all its functions by ID.
|
||||
- `shsf delete trigger <functionId> <triggerId>`: Deletes a specific trigger from a function.
|
||||
|
||||
- `shsf get function <id>`: Get details of a specific function by its ID.
|
||||
- `shsf get namespace <id>`: Get details of a specific namespace by its ID
|
||||
- `shsf get trigger <functionId> <triggerId>`: Get details of a specific trigger from a function.
|
||||
|
||||
- `shsf storage create --name <name> --purpose <purpose>`: Create a new storage.
|
||||
- `shsf storage delete --name <name>`: Delete a storage.
|
||||
- `shsf storage list`: List all storages.
|
||||
- `shsf storage get-items --name <name>`: List all items in a storage.
|
||||
- `shsf storage set-item --name <name> --key <key> --value <value> [--expires <expires>]`: Set a storage item (value can be JSON).
|
||||
- `shsf storage delete-item --name <name> --key <key>`: Delete a storage item.
|
||||
- `shsf storage clear-items --name <name>`: Clear all items from a storage.
|
||||
|
||||
- `shsf update function <id>`: Update a specific serverless function by its ID. (use `shsf update function -h` first)
|
||||
- `shsf update namespace <id>`: Update a specific namespace by its ID. (use `shsf update namespace -h` first)
|
||||
- `shsf update trigger <functionId> <triggerId>`: Update a specific trigger from a function. (use `shsf update trigger -h` first)
|
||||
|
||||
- `shsf file create`: Create a file in a storage. (use `shsf file create -h` first)
|
||||
- `shsf file overwrite`: Overwrite an existing file in a storage. (use `shsf file overwrite -h` first)
|
||||
- `shsf file delete`: Delete a file from a storage. (use `shsf file delete -h` first)
|
||||
- `shsf file list`: List files in a storage. (use `shsf file list -h` first)
|
||||
|
||||
## Instructions
|
||||
Use these commands for when you need to interact with shsf from the command line. Its faster than using the ui for almost all ops.
|
||||
|
||||
## Ui Links
|
||||
Get the ui url with:
|
||||
```bash
|
||||
shsf uiurl
|
||||
```
|
||||
|
||||
## Available UI Routes
|
||||
|
||||
Below are the main routes you can use in the SHSF web UI. These are not clickable links, but you can navigate to them in your browser.
|
||||
|
||||
**General**
|
||||
- `/`: Home Page
|
||||
|
||||
**Documentation**
|
||||
- `/docs`: Documentation
|
||||
|
||||
**Account**
|
||||
- `/account`: Account Settings
|
||||
- `/login`: Login
|
||||
- `/register`: Register
|
||||
|
||||
**Functions**
|
||||
- `/functions`: Shows your functions, grouped by their respective namespaces (requires auth).
|
||||
- `/functions/:id`: View details for a specific function (requires auth).
|
||||
|
||||
**Other**
|
||||
- `/admin`: Admin dashboard (admin only, requires authentication)
|
||||
- `/storage`: Storage management (requires auth)
|
||||
- `/cron-jobs`: Cron job management (requires auth)
|
||||
- `/access-tokens`: Access token management (requires auth)
|
||||
- `/guest-users`: Guest user management (requires auth)
|
||||
- `/guest-access`: Guest access portal
|
||||
|
||||
### Example
|
||||
After creating a function and receiving an ID (for example, 81), you can share the following URL with your human so they can view the function in the UI:
|
||||
```
|
||||
[UI_URL]/functions/[ID]
|
||||
```
|
||||
|
||||
## Update
|
||||
Update with your package manager of choice. Preferably pnpm:
|
||||
```bash
|
||||
pnpm add -g shsf-cli
|
||||
```
|
||||
Note that new features are often released and a user might tell you to do things that are only in the latest version. So its a good idea to always keep it updated.
|
||||
|
||||
It is also good practice to update this skill every now and then, as it might get new features that are not documented here yet.
|
||||
Download the latest version with:
|
||||
```
|
||||
curl https://raw.githubusercontent.com/Space-Banane/shsf-cli/refs/heads/main/OpenclawSkill.md > /tmp/OpenclawSkill.md
|
||||
```
|
||||
and then replace the old one with the new one using `mv`.
|
||||
|
||||
## Missing a feature?
|
||||
Features might be not documented yet, as this skill document rarely updates.
|
||||
Use the shsf -h command to see options and features.
|
||||
@@ -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.
|
||||
|
||||
[](https://pnpm.io/)
|
||||
[](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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "shsf-cli",
|
||||
"version": "2.1.0",
|
||||
"description": "",
|
||||
"version": "2.3.2",
|
||||
"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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Vendored
+38
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
Vendored
+28
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
Vendored
+33
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
getFunctionCorsOrigins,
|
||||
handleCorsCommandError,
|
||||
resolveFunctionId,
|
||||
setFunctionCorsOrigins,
|
||||
validateSingleOrigin,
|
||||
} from "../../utils/cors_commands.js";
|
||||
|
||||
export const removeCorsDefinition = {
|
||||
name: "remove <origin>",
|
||||
description: "Remove a CORS allowlist origin (must start with http:// or https://).",
|
||||
options: [{ name: "--id <id>", description: "Function ID (falls back to .shsf.json default id)" }],
|
||||
action: async (origin: string, options: { id?: string }) => {
|
||||
const functionId = resolveFunctionId(options);
|
||||
if (!functionId) return;
|
||||
|
||||
const validated = validateSingleOrigin(origin);
|
||||
if (validated.error) {
|
||||
console.error(`${chalk.red("✗")} ${validated.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentOrigins = await getFunctionCorsOrigins(functionId);
|
||||
const normalizedOrigin = validated.origin as string;
|
||||
|
||||
if (!currentOrigins.includes(normalizedOrigin)) {
|
||||
console.log(`${chalk.yellow("!")} Origin ${chalk.cyan(normalizedOrigin)} is not set for function ${chalk.cyan(functionId)}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedOrigins = currentOrigins.filter((existingOrigin) => existingOrigin !== normalizedOrigin);
|
||||
await setFunctionCorsOrigins(functionId, updatedOrigins);
|
||||
|
||||
console.log(`${chalk.green("✓")} Removed CORS origin ${chalk.cyan(normalizedOrigin)} from function ${chalk.cyan(functionId)}.`);
|
||||
} catch (error: any) {
|
||||
handleCorsCommandError(error, "remove CORS origin");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,24 @@
|
||||
import chalk from "chalk";
|
||||
import { getApiClient } from "../../api.js";
|
||||
|
||||
function parseBooleanOption(value: unknown, optionName: string): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const normalized = String(value).toLowerCase();
|
||||
if (normalized !== "true" && normalized !== "false") {
|
||||
throw new Error(`Invalid value for ${optionName}. Use true or false.`);
|
||||
}
|
||||
return normalized === "true";
|
||||
}
|
||||
|
||||
function parseNumberOption(value: unknown, optionName: string): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number(value);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`Invalid value for ${optionName}. Please provide a number.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const createFunctionDefinition = {
|
||||
name: "function",
|
||||
description: "Create a new serverless function.",
|
||||
@@ -8,44 +26,79 @@ export const createFunctionDefinition = {
|
||||
{ name: "--name <name>", description: "Function name", required: true },
|
||||
{ name: "--description <description>", description: "Function description", required: true },
|
||||
{ name: "--image <image>", description: "Docker image tag", required: true },
|
||||
{ name: "--startup-file <file>", description: "Startup file name", required: true },
|
||||
{ name: "--startup-file <file>", description: "Startup file name (optional for .NET runtimes)" },
|
||||
{ name: "--namespace-id <id>", description: "Namespace ID", required: true },
|
||||
{ name: "--execution-alias <alias>", description: "Custom execution alias" },
|
||||
{ name: "--docker-mount", description: "Enable Docker mount", type: "boolean" },
|
||||
{ name: "--network-restricted", description: "Disable outbound network access", type: "boolean" },
|
||||
{ name: "--ffmpeg-install", description: "Install ffmpeg in container", type: "boolean" },
|
||||
{ name: "--opencv-install", description: "Install opencv in container", type: "boolean" },
|
||||
{ name: "--imported", description: "Marks the function as imported", type: "boolean" },
|
||||
{ name: "--ai-kicked-off", description: "Marks the function as created via AI kickoff", type: "boolean" },
|
||||
{ name: "--allow-http <enabled>", description: "Allow HTTP execution (true/false)" },
|
||||
{ name: "--secure-header <value>", description: "Require x-secure-header for HTTP execution" },
|
||||
{ name: "--max-ram <mb>", description: "Max RAM in MB" },
|
||||
{ name: "--timeout <seconds>", description: "Execution timeout in seconds" },
|
||||
{ name: "--tags <csv>", description: "Comma-separated function tags" },
|
||||
{ name: "--retry-on-failure <enabled>", description: "Retry failed executions (true/false)" },
|
||||
{ name: "--retry-count <count>", description: "Retry count" },
|
||||
{ name: "--cache-enabled <enabled>", description: "Enable response caching (true/false)" },
|
||||
{ name: "--cache-ttl <seconds>", description: "Cache TTL in seconds" },
|
||||
{ name: "--cors-origins <origins>", description: "Comma-separated allowed CORS origins" },
|
||||
],
|
||||
action: async (options: any) => {
|
||||
let parsedCacheEnabled: boolean | undefined;
|
||||
if (options.cacheEnabled !== undefined) {
|
||||
const normalized = String(options.cacheEnabled).toLowerCase();
|
||||
if (normalized !== "true" && normalized !== "false") {
|
||||
console.error(`${chalk.red("✗")} Invalid value for --cache-enabled. Use true or false.`);
|
||||
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;
|
||||
}
|
||||
parsedCacheEnabled = normalized === "true";
|
||||
}
|
||||
|
||||
const parsedCacheTtl = options.cacheTtl !== undefined ? Number(options.cacheTtl) : undefined;
|
||||
if (parsedCacheTtl !== undefined && Number.isNaN(parsedCacheTtl)) {
|
||||
console.error(`${chalk.red("✗")} Invalid value for --cache-ttl. Please provide a number.`);
|
||||
return;
|
||||
const settings: Record<string, any> = {};
|
||||
if (allowHttp !== undefined) settings.allow_http = allowHttp;
|
||||
if (options.secureHeader !== undefined) settings.secure_header = options.secureHeader;
|
||||
if (maxRam !== undefined) settings.max_ram = maxRam;
|
||||
if (timeout !== undefined) settings.timeout = timeout;
|
||||
if (options.tags !== undefined) {
|
||||
settings.tags = String(options.tags)
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (retryOnFailure !== undefined) settings.retry_on_failure = retryOnFailure;
|
||||
if (retryCount !== undefined) settings.retry_count = retryCount;
|
||||
if (cacheEnabled !== undefined) settings.cache_enabled = cacheEnabled;
|
||||
if (cacheTtl !== undefined) settings.cache_ttl = cacheTtl;
|
||||
|
||||
const data = {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
image: options.image,
|
||||
startup_file: options.startupFile,
|
||||
startup_file: options.startupFile ?? "",
|
||||
namespaceId: parseInt(options.namespaceId),
|
||||
executionAlias: options.executionAlias,
|
||||
docker_mount: !!options.dockerMount,
|
||||
network_restricted: !!options.networkRestricted,
|
||||
ffmpeg_install: !!options.ffmpegInstall,
|
||||
opencv_install: !!options.opencvInstall,
|
||||
imported: !!options.imported,
|
||||
cache_enabled: parsedCacheEnabled,
|
||||
cache_ttl: parsedCacheTtl,
|
||||
ai_kicked_off: !!options.aiKickedOff,
|
||||
cors_origins: options.corsOrigins,
|
||||
...(Object.keys(settings).length > 0 ? { settings } : {}),
|
||||
};
|
||||
|
||||
const client = await getApiClient();
|
||||
|
||||
@@ -17,7 +17,7 @@ export const createNamespaceDefinition = {
|
||||
const client = await getApiClient();
|
||||
|
||||
try {
|
||||
const response = await client.post("/api/namespaces", data);
|
||||
const response = await client.post("/api/namespace", data);
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
console.log(
|
||||
|
||||
Vendored
+96
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
Vendored
+33
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
Vendored
+53
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
Vendored
+62
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,13 +1,14 @@
|
||||
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 a file in a storage.",
|
||||
description: "Create or update a file in a function.",
|
||||
options: [
|
||||
{ name: "--storage-id <id>", description: "Storage ID", required: true },
|
||||
{ name: "--path <path>", description: "Remote file path", required: true },
|
||||
{ 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" },
|
||||
],
|
||||
@@ -33,31 +34,13 @@ export const fileCreateDefinition = {
|
||||
}
|
||||
|
||||
const client = await getApiClient();
|
||||
const payload = {
|
||||
path: options.path,
|
||||
content,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await client.post(`/api/storage/${options.storageId}/files`, payload);
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
await createOrUpdateFile(client, options.functionId, options.filename, content as string);
|
||||
console.log(
|
||||
`${chalk.green("✓")} File ${chalk.cyan(options.path)} created successfully in storage ${chalk.cyan(options.storageId)}.`,
|
||||
`${chalk.green("✓")} File ${chalk.cyan(options.filename)} created/updated successfully in function ${chalk.cyan(options.functionId)}.`,
|
||||
);
|
||||
} else {
|
||||
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
console.error(
|
||||
`${chalk.red("✗")} Failed to create file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
|
||||
);
|
||||
} else if (error.request) {
|
||||
console.error(`${chalk.red("✗")} No response received from server.`);
|
||||
} else {
|
||||
console.error(`${chalk.red("✗")} Error: ${error.message}`);
|
||||
}
|
||||
handleAxiosError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,42 +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 storage.",
|
||||
description: "Delete a file from a function.",
|
||||
options: [
|
||||
{ name: "--storage-id <id>", description: "Storage ID", required: true },
|
||||
{ name: "--path <path>", description: "Remote file path", required: true },
|
||||
{ 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 {
|
||||
const response = await client.delete(`/api/storage/${options.storageId}/files`, {
|
||||
data: { path: options.path },
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
await deleteFile(client, options.functionId, options.fileId, undefined);
|
||||
console.log(
|
||||
`${chalk.green("✓")} File ${chalk.cyan(options.path)} deleted from storage ${chalk.cyan(options.storageId)}.`,
|
||||
`${chalk.green("✓")} File ${chalk.cyan(options.fileId)} deleted from function ${chalk.cyan(options.functionId)}.`,
|
||||
);
|
||||
} else {
|
||||
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
if (error.response.status === 404) {
|
||||
console.error(`${chalk.red("✗")} File ${chalk.yellow(options.path)} not found.`);
|
||||
} else {
|
||||
console.error(
|
||||
`${chalk.red("✗")} Failed to delete file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
|
||||
);
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error(`${chalk.red("✗")} No response received from server.`);
|
||||
} else {
|
||||
console.error(`${chalk.red("✗")} Error: ${error.message}`);
|
||||
}
|
||||
handleAxiosError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
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 storage.",
|
||||
description: "List files in a function.",
|
||||
options: [
|
||||
{ name: "--storage-id <id>", description: "Storage ID", required: true },
|
||||
{ name: "--prefix <prefix>", description: "Optional path prefix filter" },
|
||||
{ name: "--function-id <id>", description: "Function ID", required: true },
|
||||
],
|
||||
action: async (options: any) => {
|
||||
const client = await getApiClient();
|
||||
|
||||
try {
|
||||
const response = await client.get(`/api/storage/${options.storageId}/files`, {
|
||||
params: options.prefix ? { prefix: options.prefix } : undefined,
|
||||
});
|
||||
const files = await listFiles(client, options.functionId);
|
||||
|
||||
if (response.status === 200 && response.data.data) {
|
||||
const files = response.data.data;
|
||||
if (files) {
|
||||
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
console.log(`${chalk.yellow("!")} No files found.`);
|
||||
@@ -25,25 +22,17 @@ export const fileListDefinition = {
|
||||
}
|
||||
|
||||
console.log(chalk.blue("Files:"));
|
||||
console.log(chalk.gray(`${"ID".padEnd(25)} ${"Filename"}`));
|
||||
files.forEach((file: any) => {
|
||||
const path = file.path || file.name || "(unknown)";
|
||||
console.log(`- ${chalk.cyan(path)}`);
|
||||
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.`);
|
||||
console.log(`Status Code: ${chalk.blue(response.status)}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
console.error(
|
||||
`${chalk.red("✗")} Failed to list files: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
|
||||
);
|
||||
} else if (error.request) {
|
||||
console.error(`${chalk.red("✗")} No response received from server.`);
|
||||
} else {
|
||||
console.error(`${chalk.red("✗")} Error: ${error.message}`);
|
||||
}
|
||||
handleAxiosError(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import chalk from "chalk";
|
||||
import fs from "fs/promises";
|
||||
import { getApiClient } from "../../api.js";
|
||||
|
||||
export const fileOverwriteDefinition = {
|
||||
name: "overwrite",
|
||||
description: "Overwrite an existing file in a storage.",
|
||||
options: [
|
||||
{ name: "--storage-id <id>", description: "Storage ID", required: true },
|
||||
{ name: "--path <path>", description: "Remote file path", required: true },
|
||||
{ name: "--content <content>", description: "Inline file content" },
|
||||
{ name: "--source <source>", description: "Local source file path" },
|
||||
],
|
||||
action: async (options: any) => {
|
||||
if (!options.content && !options.source) {
|
||||
console.error(`${chalk.red("✗")} You must provide either --content or --source.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.content && options.source) {
|
||||
console.error(`${chalk.red("✗")} Please provide only one of --content or --source.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let content = options.content;
|
||||
if (options.source) {
|
||||
try {
|
||||
content = await fs.readFile(options.source, "utf8");
|
||||
} catch (error: any) {
|
||||
console.error(`${chalk.red("✗")} Failed to read source file: ${chalk.yellow(error.message)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getApiClient();
|
||||
const payload = {
|
||||
path: options.path,
|
||||
content,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await client.put(`/api/storage/${options.storageId}/files`, payload);
|
||||
|
||||
if (response.status === 200) {
|
||||
console.log(
|
||||
`${chalk.green("✓")} File ${chalk.cyan(options.path)} overwritten successfully in storage ${chalk.cyan(options.storageId)}.`,
|
||||
);
|
||||
} else {
|
||||
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
if (error.response.status === 404) {
|
||||
console.error(`${chalk.red("✗")} File ${chalk.yellow(options.path)} not found.`);
|
||||
} else {
|
||||
console.error(
|
||||
`${chalk.red("✗")} Failed to overwrite file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
|
||||
);
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error(`${chalk.red("✗")} No response received from server.`);
|
||||
} else {
|
||||
console.error(`${chalk.red("✗")} Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,12 @@ async function getFunction(id: string) {
|
||||
console.log(`${chalk.yellow("Description:")} ${f.description || chalk.gray("No description")}`);
|
||||
console.log(`${chalk.yellow("Image:")} ${f.image}`);
|
||||
console.log(`${chalk.yellow("Startup File:")} ${f.startup_file}`);
|
||||
console.log(`${chalk.yellow("HTTP:")} ${f.allow_http ? "enabled" : "disabled"}`);
|
||||
console.log(`${chalk.yellow("Cache:")} ${f.cache_enabled ? `enabled (${f.cache_ttl}s)` : "disabled"}`);
|
||||
console.log(`${chalk.yellow("Network:")} ${f.network_restricted ? "restricted" : "allowed"}`);
|
||||
console.log(`${chalk.yellow("Max RAM:")} ${f.max_ram ?? chalk.gray("default")}`);
|
||||
console.log(`${chalk.yellow("Timeout:")} ${f.timeout ?? chalk.gray("default")}`);
|
||||
console.log(`${chalk.yellow("Tags:")} ${f.tags || chalk.gray("none")}`);
|
||||
|
||||
if (f.namespace) {
|
||||
console.log(`${chalk.yellow("Namespace:")} ${f.namespace.name} (${f.namespace.id})`);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import chalk from "chalk";
|
||||
import { getApiClient } from "../../api.js";
|
||||
|
||||
interface ShsfFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const reqRemoveDefinition = {
|
||||
name: "remove <package>",
|
||||
description: "Remove a package from the requirements.txt of a function.",
|
||||
options: [
|
||||
{ name: "--from <id>", description: "The ID of the function (deprecated alias for --id)." },
|
||||
{ name: "--id <id>", description: "The ID of the function.", required: true },
|
||||
],
|
||||
action: async (pkgToRemove: string, options: { from?: string; id?: string }) => {
|
||||
const functionId = options.id ?? options.from;
|
||||
if (!functionId) {
|
||||
console.error(`${chalk.red("✗")} Error: Function ID is required. Use ${chalk.cyan("--id <id>")}.`);
|
||||
return;
|
||||
}
|
||||
const client = await getApiClient();
|
||||
|
||||
try {
|
||||
// 1. Get current files of the function
|
||||
const response = await client.get(`/api/function/${functionId}/files`);
|
||||
let files: ShsfFile[] = response.data.data;
|
||||
if (!Array.isArray(files)) files = [];
|
||||
|
||||
const requirementsFile = files.find(f => f.name === "requirements.txt");
|
||||
if (!requirementsFile) {
|
||||
console.log(`${chalk.yellow("!")} No requirements.txt found for function ${chalk.yellow(functionId)}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = requirementsFile.content;
|
||||
const lines = content.split("\n");
|
||||
|
||||
const targetPkgName = pkgToRemove.trim().toLowerCase();
|
||||
let removed = false;
|
||||
|
||||
const newLines = lines.filter(line => {
|
||||
const trimmedLine = line.trim();
|
||||
// Preserve empty/whitespace-only lines
|
||||
if (!trimmedLine) return true;
|
||||
|
||||
// Strip inline comments and environment markers for matching purposes
|
||||
const withoutComment = trimmedLine.split("#", 1)[0];
|
||||
const basePart = withoutComment.split(";", 1)[0].trim();
|
||||
|
||||
// If there's no actual requirement (e.g., comment-only line), keep the line
|
||||
if (!basePart) return true;
|
||||
|
||||
// Robust extraction (handles != <= >= ~= == and < >)
|
||||
const currentPkgName = basePart.split(/[!=>~<]/)[0].trim().toLowerCase();
|
||||
|
||||
if (currentPkgName === targetPkgName) {
|
||||
removed = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!removed) {
|
||||
console.log(`${chalk.yellow("!")} Package ${chalk.cyan(pkgToRemove)} was not found in requirements.txt.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = newLines.join("\n");
|
||||
|
||||
// 2. Overwrite requirements.txt using function-based API
|
||||
await client.put(`/api/function/${functionId}/file`, {
|
||||
filename: "requirements.txt",
|
||||
code: newContent,
|
||||
});
|
||||
|
||||
console.log(`${chalk.green("✓")} Removed ${chalk.cyan(pkgToRemove)} from requirements.txt for function ${chalk.yellow(functionId)}.`);
|
||||
} catch (error: any) {
|
||||
handleError(error, "remove package");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function handleError(error: any, task: string) {
|
||||
if (error.response) {
|
||||
console.error(`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(error.response.data?.message || (error.response.data ? JSON.stringify(error.response.data) : error.message))}`);
|
||||
} else if (error.request) {
|
||||
console.error(
|
||||
`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(
|
||||
"No response received from server. Please check your network connection and try again."
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
console.error(`${chalk.red("✗")} Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -1,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! ")}`);
|
||||
},
|
||||
};
|
||||
@@ -1,48 +1,120 @@
|
||||
import chalk from "chalk";
|
||||
import { getApiClient } from "../../api.js";
|
||||
|
||||
function parseBooleanOption(value: unknown, optionName: string): boolean | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const normalized = String(value).toLowerCase();
|
||||
if (normalized !== "true" && normalized !== "false") {
|
||||
throw new Error(`Invalid value for ${optionName}. Use true or false.`);
|
||||
}
|
||||
return normalized === "true";
|
||||
}
|
||||
|
||||
function parseNumberOption(value: unknown, optionName: string): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = Number(value);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`Invalid value for ${optionName}. Please provide a number.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const updateFunctionDefinition = {
|
||||
name: "function <id>",
|
||||
description: "Update an existing serverless function.",
|
||||
options: [
|
||||
{ name: "--name <name>", description: "Function name" },
|
||||
{ name: "--description <description>", description: "Function description" },
|
||||
{ name: "--image <image>", description: "Runtime image tag within the existing language family" },
|
||||
{ name: "--startup-file <file>", description: "Startup file name" },
|
||||
{ name: "--namespace-id <id>", description: "Move function to namespace ID" },
|
||||
{ name: "--execution-alias <alias>", description: "Custom execution alias" },
|
||||
{ name: "--docker-mount", description: "Enable Docker mount", type: "boolean" },
|
||||
{ name: "--ffmpeg-install", description: "Install ffmpeg in container", type: "boolean" },
|
||||
{ name: "--imported", description: "Marks the function as imported", type: "boolean" },
|
||||
{ name: "--docker-mount <enabled>", description: "Enable Docker mount (true/false)" },
|
||||
{ name: "--network-restricted <enabled>", description: "Disable outbound network access (true/false)" },
|
||||
{ name: "--ffmpeg-install <enabled>", description: "Install ffmpeg in container (true/false)" },
|
||||
{ name: "--opencv-install <enabled>", description: "Install opencv in container (true/false)" },
|
||||
{ name: "--imported <enabled>", description: "Marks the function as imported (true/false)" },
|
||||
{ name: "--ai-kicked-off <enabled>", description: "Marks the function as created via AI kickoff (true/false)" },
|
||||
{ name: "--allow-http <enabled>", description: "Allow HTTP execution (true/false)" },
|
||||
{ name: "--secure-header <value>", description: "Require x-secure-header for HTTP execution" },
|
||||
{ name: "--clear-secure-header", description: "Remove the secure header requirement", type: "boolean" },
|
||||
{ name: "--max-ram <mb>", description: "Max RAM in MB" },
|
||||
{ name: "--timeout <seconds>", description: "Execution timeout in seconds" },
|
||||
{ name: "--tags <csv>", description: "Comma-separated function tags" },
|
||||
{ name: "--retry-on-failure <enabled>", description: "Retry failed executions (true/false)" },
|
||||
{ name: "--retry-count <count>", description: "Retry count" },
|
||||
{ name: "--cache-enabled <enabled>", description: "Enable response caching (true/false)" },
|
||||
{ name: "--cache-ttl <seconds>", description: "Cache TTL in seconds" },
|
||||
{ name: "--cors-origins <origins>", description: "Comma-separated allowed CORS origins" },
|
||||
],
|
||||
action: async (id: string, options: any) => {
|
||||
let parsedCacheEnabled: boolean | undefined;
|
||||
if (options.cacheEnabled !== undefined) {
|
||||
const normalized = String(options.cacheEnabled).toLowerCase();
|
||||
if (normalized !== "true" && normalized !== "false") {
|
||||
console.error(`${chalk.red("✗")} Invalid value for --cache-enabled. Use true or false.`);
|
||||
return;
|
||||
}
|
||||
parsedCacheEnabled = normalized === "true";
|
||||
}
|
||||
let dockerMount: boolean | undefined;
|
||||
let networkRestricted: boolean | undefined;
|
||||
let ffmpegInstall: boolean | undefined;
|
||||
let opencvInstall: boolean | undefined;
|
||||
let imported: boolean | undefined;
|
||||
let aiKickedOff: boolean | undefined;
|
||||
let allowHttp: boolean | undefined;
|
||||
let retryOnFailure: boolean | undefined;
|
||||
let cacheEnabled: boolean | undefined;
|
||||
let namespaceId: number | undefined;
|
||||
let maxRam: number | undefined;
|
||||
let timeout: number | undefined;
|
||||
let retryCount: number | undefined;
|
||||
let cacheTtl: number | undefined;
|
||||
|
||||
const parsedCacheTtl = options.cacheTtl !== undefined ? Number(options.cacheTtl) : undefined;
|
||||
if (parsedCacheTtl !== undefined && Number.isNaN(parsedCacheTtl)) {
|
||||
console.error(`${chalk.red("✗")} Invalid value for --cache-ttl. Please provide a number.`);
|
||||
try {
|
||||
dockerMount = parseBooleanOption(options.dockerMount, "--docker-mount");
|
||||
networkRestricted = parseBooleanOption(options.networkRestricted, "--network-restricted");
|
||||
ffmpegInstall = parseBooleanOption(options.ffmpegInstall, "--ffmpeg-install");
|
||||
opencvInstall = parseBooleanOption(options.opencvInstall, "--opencv-install");
|
||||
imported = parseBooleanOption(options.imported, "--imported");
|
||||
aiKickedOff = parseBooleanOption(options.aiKickedOff, "--ai-kicked-off");
|
||||
allowHttp = parseBooleanOption(options.allowHttp, "--allow-http");
|
||||
retryOnFailure = parseBooleanOption(options.retryOnFailure, "--retry-on-failure");
|
||||
cacheEnabled = parseBooleanOption(options.cacheEnabled, "--cache-enabled");
|
||||
namespaceId = parseNumberOption(options.namespaceId, "--namespace-id");
|
||||
maxRam = parseNumberOption(options.maxRam, "--max-ram");
|
||||
timeout = parseNumberOption(options.timeout, "--timeout");
|
||||
retryCount = parseNumberOption(options.retryCount, "--retry-count");
|
||||
cacheTtl = parseNumberOption(options.cacheTtl, "--cache-ttl");
|
||||
} catch (error: any) {
|
||||
console.error(`${chalk.red("✗")} ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
const settings: Record<string, any> = {};
|
||||
|
||||
if (options.name !== undefined) data.name = options.name;
|
||||
if (options.description !== undefined) data.description = options.description;
|
||||
if (options.image !== undefined) data.image = options.image;
|
||||
if (options.startupFile !== undefined) data.startup_file = options.startupFile;
|
||||
if (namespaceId !== undefined) data.namespaceId = namespaceId;
|
||||
if (options.executionAlias !== undefined) data.executionAlias = options.executionAlias;
|
||||
if (options.dockerMount) data.docker_mount = true;
|
||||
if (options.ffmpegInstall) data.ffmpeg_install = true;
|
||||
if (options.imported) data.imported = true;
|
||||
if (parsedCacheEnabled !== undefined) data.cache_enabled = parsedCacheEnabled;
|
||||
if (parsedCacheTtl !== undefined) data.cache_ttl = parsedCacheTtl;
|
||||
if (dockerMount !== undefined) data.docker_mount = dockerMount;
|
||||
if (networkRestricted !== undefined) data.network_restricted = networkRestricted;
|
||||
if (ffmpegInstall !== undefined) data.ffmpeg_install = ffmpegInstall;
|
||||
if (opencvInstall !== undefined) data.opencv_install = opencvInstall;
|
||||
if (imported !== undefined) data.imported = imported;
|
||||
if (aiKickedOff !== undefined) data.ai_kicked_off = aiKickedOff;
|
||||
if (options.corsOrigins !== undefined) data.cors_origins = options.corsOrigins;
|
||||
if (allowHttp !== undefined) settings.allow_http = allowHttp;
|
||||
if (options.clearSecureHeader) settings.secure_header = null;
|
||||
if (options.secureHeader !== undefined) settings.secure_header = options.secureHeader;
|
||||
if (maxRam !== undefined) settings.max_ram = maxRam;
|
||||
if (timeout !== undefined) settings.timeout = timeout;
|
||||
if (options.tags !== undefined) {
|
||||
settings.tags = String(options.tags)
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (retryOnFailure !== undefined) settings.retry_on_failure = retryOnFailure;
|
||||
if (retryCount !== undefined) settings.retry_count = retryCount;
|
||||
if (cacheEnabled !== undefined) settings.cache_enabled = cacheEnabled;
|
||||
if (cacheTtl !== undefined) settings.cache_ttl = cacheTtl;
|
||||
if (Object.keys(settings).length > 0) data.settings = settings;
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
console.error(`${chalk.red("✗")} No update fields provided. Use --help to see available options.`);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const updateTriggerDefinition = {
|
||||
const client = await getApiClient();
|
||||
|
||||
try {
|
||||
const response = await client.patch(`/api/functions/${functionId}/triggers/${triggerId}`, data);
|
||||
const response = await client.put(`/api/functions/${functionId}/triggers/${triggerId}`, data);
|
||||
|
||||
if (response.status === 200) {
|
||||
console.log(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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'],
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user