25 Commits

Author SHA1 Message Date
Space-Banane 13900343a3 feat: add commands for managing environment variables in functions & removed ui docs from skill 2026-03-27 17:18:00 +01:00
Space-Banane cc54ef85e1 fix: update command syntax for function execution in documentation 2026-03-27 16:56:54 +01:00
Space-Banane 67f05af7a5 feat: add execute command for function execution with streaming options 2026-03-27 16:55:28 +01:00
Space-Banane 37f6d50034 fix: requirements.txt modifications 2026-03-27 16:50:04 +01:00
Space-Banane a1f80d38f2 update skill to reflect changes 2026-03-27 16:38:18 +01:00
Space-Banane 7b659b1ef5 version-bump: commit of shame 2026-03-27 16:36:48 +01:00
Space-Banane 3261cc1830 unslopped file operations 2026-03-27 16:35:43 +01:00
Space a76bb15fc9 feat: add req subcommands for managing requirements.txt (#5)
* feat: add req subcommands for managing requirements.txt

* bump: version 2.1.9

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

---------

Co-authored-by: Luna <clawy@reversed.dev>
2026-03-26 14:01:15 +01:00
Space 47cd88f72b feat: log byte count for push and pull 2026-03-20 18:01:29 +01:00
luna 6a98ef4bed feat: log byte count for push and pull, increment version 2026-03-20 17:59:41 +01:00
Space e6dea43555 Add remote sync commands 2026-03-20 17:03:45 +01:00
Space-Banane 7b86fec717 That should do 2026-03-20 17:02:34 +01:00
Space-Banane 0be63e5287 Fixed spacing 2026-03-20 16:28:37 +01:00
luna 1d04649cbf Refactor remote commands into remote subdirectory 2026-03-20 16:24:09 +01:00
luna 344e10ac46 Add remote sync command and tests 2026-03-20 16:21:53 +01:00
Space-Banane 6ad74bb991 feat: add testing using vitest 2026-03-20 14:56:19 +01:00
Space-Banane 1b3851d0af docs: emphasize the use of pnpm for package updates 2026-03-20 09:00:39 +01:00
luna 9887ca3dcc chore: bump version to 2.1.5 and add versioning policy 2026-03-20 08:58:03 +01:00
luna ae82ba221b fix: improve error handling for pipinstall 2026-03-20 08:55:10 +01:00
luna 8428ed56b5 feat: add pipinstall command 2026-03-20 08:51:29 +01:00
Space-Banane 20d34bf6e7 add: Implement storage management commands including create, delete, list, and item manipulation 2026-03-18 22:35:18 +01:00
Space-Banane 11a239dbf4 refactor: Remove unnecessary heading for count commands in documentation 2026-03-18 18:41:15 +01:00
Space-Banane 342b8dd61f add: Implement UI URL command and update documentation with UI links and routes 2026-03-18 18:00:35 +01:00
Space-Banane 625e3e2a3e add: Updating instructions for skill 2026-03-18 17:08:39 +01:00
Space-Banane ec06127443 Enhance CI/CD workflow by specifying paths for push and pull request triggers; update documentation with installation instructions and feature update reminders. 2026-03-18 17:05:40 +01:00
32 changed files with 1429 additions and 102 deletions
+3
View File
@@ -24,3 +24,6 @@
- **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`. - **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.). - **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. - **No Global Scope**: Keep command logic within the `action` function or extracted to utility modules to maintain testability.
## Versioning
- **Versioning**: Always increment the version number in package.json whenever a change is merged into main. The version format is major.minor.patch.
+7
View File
@@ -4,9 +4,15 @@ on:
push: push:
branches: branches:
- '**' - '**'
paths:
- '**/*.ts'
- '**/*.json'
pull_request: pull_request:
branches: branches:
- main - main
paths:
- '**/*.ts'
- '**/*.json'
jobs: jobs:
build: build:
@@ -22,6 +28,7 @@ jobs:
node-version: '24' node-version: '24'
- run: pnpm install --no-frozen-lockfile - run: pnpm install --no-frozen-lockfile
- run: pnpm test
- run: pnpm build - run: pnpm build
- name: Zip dist directory - name: Zip dist directory
+2 -1
View File
@@ -1,4 +1,5 @@
node_modules node_modules
dist dist
build build
pnpm-lock.yaml pnpm-lock.yaml
testing
+56 -7
View File
@@ -16,8 +16,6 @@ shsf health
this will check the health, and if not setup, it will prompt you to set up the CLI. this will check the health, and if not setup, it will prompt you to set up the CLI.
## Commands ## Commands
### Count
- `shsf count functions`: Count your functions. Add `--full` to list them. - `shsf count functions`: Count your functions. Add `--full` to list them.
- `shsf count namespaces`: Count your namespaces. 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 storages`: Count your storages. Add `--full` to list them.
@@ -35,14 +33,65 @@ this will check the health, and if not setup, it will prompt you to set up the C
- `shsf get namespace <id>`: Get details of a specific namespace by its ID - `shsf get namespace <id>`: Get details of a specific namespace by its ID
- `shsf get trigger <functionId> <triggerId>`: Get details of a specific trigger from a function. - `shsf get trigger <functionId> <triggerId>`: Get details of a specific trigger from a function.
- `shsf function execute --id <id> [--payload <json>] [--no-stream]`: Execute a function and stream the output (debug/internal).
- `shsf storage create --name <name> --purpose <purpose>`: Create a new storage.
- `shsf storage 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 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 namespace <id>`: Update a specific namespace by its ID. (use `shsf update namespace -h` first)
- `shsf update trigger <functionId> <triggerId>`: Update a specific trigger from a function. (use `shsf update trigger -h` first) - `shsf update trigger <functionId> <triggerId>`: Update a specific trigger from a function. (use `shsf update trigger -h` first)
- `shsf file create`: Create a file in a storage. (use `shsf file create -h` first) - `shsf file create`: Create/update a file in a function. (use `shsf file create -h` first)
- `shsf file overwrite`: Overwrite an existing file in a storage. (use `shsf file overwrite -h` first) - `shsf file list`: List files in a function. (use `shsf file list -h` first)
- `shsf file delete`: Delete a file from a storage. (use `shsf file delete -h` first) - `shsf file rename`: Rename a file in a function. (use `shsf file rename -h` first)
- `shsf file list`: List files in a storage. (use `shsf file list -h` first) - `shsf file delete`: Delete a file from a function. (use `shsf file delete -h` first)
- `shsf env add --id <id> --name <name> --value <value>`: Adds or updates an environment variable for a function.
- `shsf env remove --id <id> --name <name>`: Removes a specific environment variable from a function.
- `shsf env list --id <id>`: Lists all environment variables for a function.
- `shsf env flush --id <id>`: Removes ALL environment variables from a function.
- `shsf remote pull --id <id> --into <path> [--force]`: Pull files from a function into a local directory.
- `shsf remote push --id <id> --from <path> [--force]`: Push files from a local directory to a function.
- `shsf req add <package> --id <functionid>`: Add a package to the requirements.txt of a function.
- `shsf req remove <package> --id <functionid>`: Remove a package from the requirements.txt of a function.
## Instructions ## Instructions
Use these commands for when you need to interact with shsf from the command line. Its faster than using the ui for almost all ops. Use these commands for when you need to interact with shsf from the command line. Its faster than using the ui for almost all ops.
## Ui Links
Get the ui url with:
```bash
shsf uiurl
```
### Example
After creating a function and receiving an ID (for example, 81), you can share the following URL with your human so they can view the function in the UI:
```
[UI_URL]/functions/[ID]
```
## Update
Update with your package manager of choice. ALWAYS pnpm:
```bash
pnpm add -g shsf-cli
```
Note that new features are often released and a user might tell you to do things that are only in the latest version. So its a good idea to always keep it updated.
It is also good practice to update this skill every now and then, as it might get new features that are not documented here yet.
Download the latest version with:
```
curl https://raw.githubusercontent.com/Space-Banane/shsf-cli/refs/heads/main/OpenclawSkill.md > /tmp/OpenclawSkill.md
```
and then replace the old one with the new one using `mv`.
## Missing a feature?
Features might be not documented yet, as this skill document rarely updates.
Use the shsf -h command to see options and features.
+7 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "shsf-cli", "name": "shsf-cli",
"version": "2.0.3", "version": "2.2.3",
"description": "", "description": "",
"type": "module", "type": "module",
"files": [ "files": [
@@ -12,7 +12,9 @@
"scripts": { "scripts": {
"build": "rimraf dist && tsc", "build": "rimraf dist && tsc",
"start": "node dist/index.js", "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": [], "keywords": [],
"author": "", "author": "",
@@ -21,9 +23,11 @@
"devDependencies": { "devDependencies": {
"@types/inquirer": "^9.0.9", "@types/inquirer": "^9.0.9",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.0",
"rimraf": "^6.1.3", "rimraf": "^6.1.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^4.1.0"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.6", "axios": "^1.13.6",
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
describe('Command Loading', () => {
it('all command files should export a valid definition', async () => {
const commandsDir = path.resolve(__dirname, '../commands');
function getCommandFiles(dir: string): string[] {
const files = fs.readdirSync(dir);
let commandFiles: string[] = [];
for (const file of files) {
const fullPath = path.join(dir, file);
if (fs.statSync(fullPath).isDirectory()) {
commandFiles = commandFiles.concat(getCommandFiles(fullPath));
} else if (file.endsWith('.ts') || file.endsWith('.js')) {
commandFiles.push(fullPath);
}
}
return commandFiles;
}
const files = getCommandFiles(commandsDir);
expect(files.length).toBeGreaterThan(0);
for (const file of files) {
// Use dynamic import to check the module
// We convert absolute path to file:// URL for ESM import on Linux/Windows
const module = await import(`file://${file}`);
const definition = module.default || Object.values(module).find((val: any) =>
val && typeof val === 'object' && val.name && typeof val.action === 'function'
);
if (!definition) {
throw new Error(`Command file ${file} does not export a valid command definition (needs name and action function)`);
}
expect(definition).toBeDefined();
expect(typeof definition.name).toBe('string');
expect(typeof definition.action).toBe('function');
console.log(`✓ Validated command: ${definition.name} (${path.relative(commandsDir, file)})`);
}
});
});
+11
View File
@@ -0,0 +1,11 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import os from 'os';
import { getConfigPath } from '../config.js';
describe('config', () => {
it('should return the correct config path', () => {
const expectedPath = path.join(os.homedir(), '.shsf_config');
expect(getConfigPath()).toBe(expectedPath);
});
});
+44
View File
@@ -0,0 +1,44 @@
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 all API client calls start with /api/', () => {
const tsFiles = getFilesRecursively('src');
const violations: string[] = [];
// Regex to match common patterns like client.get('/path') or client.post(`/path`)
// specifically looking for paths that start with / but NOT /api/ or /health
const urlPattern = /client\.(get|post|put|delete|patch)\(['"`]\/(?!(api|health)\/)[^'"`]+['"`]/g;
tsFiles.forEach(file => {
const content = readFileSync(file, 'utf-8');
let match;
while ((match = urlPattern.exec(content)) !== null) {
// Double check for exact /health match (without trailing slash)
if (match[0].includes("'/health'") || match[0].includes('"/health"') || match[0].includes('`/health`')) {
continue;
}
violations.push(`${file}: ${match[0]}`);
}
});
expect(violations, `Found API calls not starting with /api/:\n${violations.join('\n')}`).toHaveLength(0);
});
});
+96
View File
@@ -0,0 +1,96 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const updateFunctionDefinition = {
name: "add",
description:
"Adds or updates environment variables for a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{
name: "--name <name>",
description: "Environment variable name",
required: true,
},
{
name: "--value <value>",
description: "Environment variable value",
required: true,
},
],
action: async (options: any) => {
if (!options.id || !options.name || !options.value) {
console.error(
`${chalk.red("✗")} Error: Missing required options (--id, --name, --value)`,
);
return;
}
const client = await getApiClient();
let existingEnv: { name: string; value: string }[] = [];
try {
const response = await client.get(`/api/function/${options.id}`);
const functionData = response.data.data || response.data;
existingEnv = functionData.env || [];
if (typeof existingEnv === 'string') {
try {
existingEnv = JSON.parse(existingEnv);
} catch (e: any) {
existingEnv = [];
}
}
if (!Array.isArray(existingEnv)) {
existingEnv = [];
}
} catch (error: any) {
console.error(
`${chalk.red("✗")} Error fetching function data: ${error.message}`,
);
return;
}
const newEnv = [...existingEnv];
const existingIndex = newEnv.findIndex((env) => env.name === options.name);
const isUpdate = existingIndex !== -1;
if (isUpdate) {
newEnv[existingIndex].value = options.value;
} else {
newEnv.push({ name: options.name, value: options.value });
}
const totalCount = newEnv.length;
try {
const response = await client.patch(`/api/function/${options.id}`, {
environment: newEnv,
});
if (response.status === 200) {
const actionText = isUpdate ? "updated" : "added";
console.log(
`${chalk.green("✓")} Environment variable ${chalk.cyan(options.name)} ${actionText} successfully for function ${chalk.cyan(options.id)}.`,
);
console.log(
`${chalk.blue("")} Total environment variables: ${totalCount}`,
);
} else {
console.error(
`${chalk.red("✗")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response?.status === 404) {
console.error(
`${chalk.red("✗")} Function with ID ${chalk.yellow(options.id)} not found.`,
);
} else {
console.error(
`${chalk.red("✗")} Failed to update: ${chalk.yellow(error.response?.data?.message || error.message)}`,
);
}
}
},
};
+33
View File
@@ -0,0 +1,33 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const flushFunctionDefinition = {
name: "flush",
description: "Removes ALL environment variables from a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
],
action: async (options: any) => {
if (!options.id) {
console.error(`${chalk.red("✗")} Error: --id is required`);
return;
}
const client = await getApiClient();
try {
const response = await client.patch(`/api/function/${options.id}`, {
environment: [],
});
if (response.status === 200) {
console.log(
`${chalk.green("✓")} All environment variables flushed for function ${chalk.cyan(options.id)}.`
);
console.log(`${chalk.blue("")} Total environment variables: 0`);
}
} catch (error: any) {
console.error(`${chalk.red("✗")} Error: ${error.response?.data?.message || error.message}`);
}
},
};
+53
View File
@@ -0,0 +1,53 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const listEnvDefinition = {
name: "list",
description: "Lists all environment variables for a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
],
action: async (options: any) => {
if (!options.id) {
console.error(`${chalk.red("✗")} Error: --id is required`);
return;
}
const client = await getApiClient();
try {
const response = await client.get(`/api/function/${options.id}`);
const functionData = response.data.data || response.data;
let env = functionData.env;
if (typeof env === 'string' && env.length > 0) {
try {
env = JSON.parse(env);
} catch (e: any) {
console.error(`${chalk.red("✗")} Error parsing env string: ${e.message}`);
env = [];
}
}
const envList: { name: string; value: string }[] = Array.isArray(env) ? env : [];
if (envList.length === 0) {
console.log(`${chalk.yellow("!")} No environment variables found for function ${chalk.cyan(options.id)}.`);
return;
}
console.log(`${chalk.blue("")} Environment variables for function ${chalk.cyan(options.id)}:`);
envList.forEach((item) => {
console.log(`${chalk.green("•")} ${chalk.cyan(item.name)}=${chalk.white(item.value)}`);
});
console.log(`${chalk.blue("")} Total: ${envList.length}`);
} catch (error: any) {
if (error.response?.status === 404) {
console.error(`${chalk.red("✗")} Function with ID ${chalk.yellow(options.id)} not found.`);
} else {
console.error(`${chalk.red("✗")} Error fetching environment variables: ${chalk.yellow(error.response?.data?.message || error.message)}`);
}
}
},
};
+62
View File
@@ -0,0 +1,62 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const removeFunctionDefinition = {
name: "remove",
description: "Removes a specific environment variable from a serverless function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{
name: "--name <name>",
description: "Environment variable name to remove",
required: true,
},
],
action: async (options: any) => {
if (!options.id || !options.name) {
console.error(`${chalk.red("✗")} Error: --id and --name are required`);
return;
}
const client = await getApiClient();
try {
const response = await client.get(`/api/function/${options.id}`);
const functionData = response.data.data || response.data;
let existingEnv: { name: string; value: string }[] = functionData.env || [];
if (typeof existingEnv === 'string') {
try {
existingEnv = JSON.parse(existingEnv);
} catch (e: any) {
existingEnv = [];
}
}
if (!Array.isArray(existingEnv)) {
existingEnv = [];
}
const initialCount = existingEnv.length;
const filteredEnv = existingEnv.filter((env) => env.name !== options.name);
if (filteredEnv.length === initialCount) {
console.warn(`${chalk.yellow("!")} Variable ${chalk.cyan(options.name)} not found. No changes made.`);
return;
}
const patchResponse = await client.patch(`/api/function/${options.id}`, {
environment: filteredEnv,
});
if (patchResponse.status === 200) {
console.log(
`${chalk.green("✓")} Variable ${chalk.cyan(options.name)} removed successfully from function ${chalk.cyan(options.id)}.`
);
console.log(`${chalk.blue("")} Remaining environment variables: ${filteredEnv.length}`);
}
} catch (error: any) {
console.error(`${chalk.red("✗")} Error: ${error.response?.data?.message || error.message}`);
}
},
};
+8 -8
View File
@@ -4,10 +4,10 @@ import { getApiClient } from "../../api.js";
export const fileCreateDefinition = { export const fileCreateDefinition = {
name: "create", name: "create",
description: "Create a file in a storage.", description: "Create or update a file in a function.",
options: [ options: [
{ name: "--storage-id <id>", description: "Storage ID", required: true }, { name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--path <path>", description: "Remote file path", required: true }, { name: "--filename <name>", description: "Remote filename", required: true },
{ name: "--content <content>", description: "Inline file content" }, { name: "--content <content>", description: "Inline file content" },
{ name: "--source <source>", description: "Local source file path" }, { name: "--source <source>", description: "Local source file path" },
], ],
@@ -34,16 +34,16 @@ export const fileCreateDefinition = {
const client = await getApiClient(); const client = await getApiClient();
const payload = { const payload = {
path: options.path, filename: options.filename,
content, code: content,
}; };
try { try {
const response = await client.post(`/api/storage/${options.storageId}/files`, payload); const response = await client.put(`/api/function/${options.functionId}/file`, payload);
if (response.status === 200 || response.status === 201) { if (response.status === 200 || response.status === 201) {
console.log( console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.path)} created successfully in storage ${chalk.cyan(options.storageId)}.`, `${chalk.green("✓")} File ${chalk.cyan(options.filename)} created/updated successfully in function ${chalk.cyan(options.functionId)}.`,
); );
} else { } else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`); console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
@@ -51,7 +51,7 @@ export const fileCreateDefinition = {
} catch (error: any) { } catch (error: any) {
if (error.response) { if (error.response) {
console.error( console.error(
`${chalk.red("✗")} Failed to create file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`, `${chalk.red("✗")} Failed to create/update file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
); );
} else if (error.request) { } else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`); console.error(`${chalk.red("✗")} No response received from server.`);
+6 -8
View File
@@ -3,22 +3,20 @@ import { getApiClient } from "../../api.js";
export const fileDeleteDefinition = { export const fileDeleteDefinition = {
name: "delete", name: "delete",
description: "Delete a file from a storage.", description: "Delete a file from a function.",
options: [ options: [
{ name: "--storage-id <id>", description: "Storage ID", required: true }, { name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--path <path>", description: "Remote file path", required: true }, { name: "--file-id <id>", description: "File ID (from 'file list')", required: true },
], ],
action: async (options: any) => { action: async (options: any) => {
const client = await getApiClient(); const client = await getApiClient();
try { try {
const response = await client.delete(`/api/storage/${options.storageId}/files`, { const response = await client.delete(`/api/function/${options.functionId}/file/${options.fileId}`);
data: { path: options.path },
});
if (response.status === 200) { if (response.status === 200) {
console.log( console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.path)} deleted from storage ${chalk.cyan(options.storageId)}.`, `${chalk.green("✓")} File ${chalk.cyan(options.fileId)} deleted from function ${chalk.cyan(options.functionId)}.`,
); );
} else { } else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`); console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
@@ -26,7 +24,7 @@ export const fileDeleteDefinition = {
} catch (error: any) { } catch (error: any) {
if (error.response) { if (error.response) {
if (error.response.status === 404) { if (error.response.status === 404) {
console.error(`${chalk.red("✗")} File ${chalk.yellow(options.path)} not found.`); console.error(`${chalk.red("✗")} File or Function not found.`);
} else { } else {
console.error( console.error(
`${chalk.red("✗")} Failed to delete file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`, `${chalk.red("✗")} Failed to delete file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
+5 -8
View File
@@ -3,18 +3,15 @@ import { getApiClient } from "../../api.js";
export const fileListDefinition = { export const fileListDefinition = {
name: "list", name: "list",
description: "List files in a storage.", description: "List files in a function.",
options: [ options: [
{ name: "--storage-id <id>", description: "Storage ID", required: true }, { name: "--function-id <id>", description: "Function ID", required: true },
{ name: "--prefix <prefix>", description: "Optional path prefix filter" },
], ],
action: async (options: any) => { action: async (options: any) => {
const client = await getApiClient(); const client = await getApiClient();
try { try {
const response = await client.get(`/api/storage/${options.storageId}/files`, { const response = await client.get(`/api/function/${options.functionId}/files`);
params: options.prefix ? { prefix: options.prefix } : undefined,
});
if (response.status === 200 && response.data.data) { if (response.status === 200 && response.data.data) {
const files = response.data.data; const files = response.data.data;
@@ -25,9 +22,9 @@ export const fileListDefinition = {
} }
console.log(chalk.blue("Files:")); console.log(chalk.blue("Files:"));
console.log(chalk.gray(`${"ID".padEnd(25)} ${"Filename"}`));
files.forEach((file: any) => { files.forEach((file: any) => {
const path = file.path || file.name || "(unknown)"; console.log(`${chalk.cyan(file.id.padEnd(25))} ${chalk.white(file.filename)}`);
console.log(`- ${chalk.cyan(path)}`);
}); });
console.log(`\n${chalk.green("✓")} Found ${chalk.bgGreen.black(` ${files.length} `)} files.`); console.log(`\n${chalk.green("✓")} Found ${chalk.bgGreen.black(` ${files.length} `)} files.`);
} else { } else {
-67
View File
@@ -1,67 +0,0 @@
import chalk from "chalk";
import fs from "fs/promises";
import { getApiClient } from "../../api.js";
export const fileOverwriteDefinition = {
name: "overwrite",
description: "Overwrite an existing file in a storage.",
options: [
{ name: "--storage-id <id>", description: "Storage ID", required: true },
{ name: "--path <path>", description: "Remote file path", required: true },
{ name: "--content <content>", description: "Inline file content" },
{ name: "--source <source>", description: "Local source file path" },
],
action: async (options: any) => {
if (!options.content && !options.source) {
console.error(`${chalk.red("✗")} You must provide either --content or --source.`);
return;
}
if (options.content && options.source) {
console.error(`${chalk.red("✗")} Please provide only one of --content or --source.`);
return;
}
let content = options.content;
if (options.source) {
try {
content = await fs.readFile(options.source, "utf8");
} catch (error: any) {
console.error(`${chalk.red("✗")} Failed to read source file: ${chalk.yellow(error.message)}`);
return;
}
}
const client = await getApiClient();
const payload = {
path: options.path,
content,
};
try {
const response = await client.put(`/api/storage/${options.storageId}/files`, payload);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.path)} overwritten successfully in storage ${chalk.cyan(options.storageId)}.`,
);
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
}
} catch (error: any) {
if (error.response) {
if (error.response.status === 404) {
console.error(`${chalk.red("✗")} File ${chalk.yellow(options.path)} not found.`);
} else {
console.error(
`${chalk.red("✗")} Failed to overwrite file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
);
}
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+39
View File
@@ -0,0 +1,39 @@
import chalk from "chalk";
import { getApiClient } from "../../api.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 {
const response = await client.patch(`/api/function/${options.functionId}/file/${options.fileId}/rename`, {
newFilename: options.newFilename,
});
if (response.status === 200) {
console.log(
`${chalk.green("✓")} File ${chalk.cyan(options.fileId)} renamed to ${chalk.cyan(options.newFilename)} successfully.`,
);
} else {
console.log(`${chalk.yellow("!")} Unexpected response from server: ${response.status}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to rename file: ${chalk.yellow(error.response.data?.message || "Unknown error")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+152
View File
@@ -0,0 +1,152 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import { AxiosResponse } from "axios";
export const functionExecuteDefinition = {
name: "execute",
description: "Execute a function and stream the output (debug/internal).",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{ name: "--payload <json>", description: "JSON payload for execution", required: false },
{ name: "--no-stream", description: "Disable streaming and get final result only", required: false, default: false },
],
action: async (options: any) => {
const client = await getApiClient();
// Explicitly check for noStream from commander
// Commander uses camelsCase for options, so --no-stream becomes stream: false
// but we use --no-stream which commander natively handles as a boolean toggle
const useStream = options.stream !== false;
let payload = {};
if (options.payload) {
try {
payload = JSON.parse(options.payload);
} catch (error: any) {
console.error(`${chalk.red("✗")} Invalid JSON payload: ${chalk.yellow(error.message)}`);
return;
}
}
const handleJsonChunk = (chunk: string) => {
const trimmed = chunk.trim();
if (!trimmed) return;
try {
const obj = JSON.parse(trimmed);
if (obj.type === "output" && obj.content) {
process.stdout.write(obj.content);
} else if (obj.type === "end") {
if (obj.exitCode !== 0 && obj.exitCode !== undefined) {
console.error(`\n${chalk.red("✗")} Execution failed with exit code ${obj.exitCode}`);
} else {
console.log(`\n${chalk.green("✓")} Execution completed.`);
}
if (obj.result !== undefined) {
console.log(chalk.blue("Result:"), typeof obj.result === 'object' ? JSON.stringify(obj.result, null, 2) : obj.result);
}
if (obj.took) {
const total = obj.took.find((t: any) => t.description === "Total");
if (total) {
console.log(chalk.gray(`Total time: ${total.value}s`));
}
}
}
} catch (e) {
// If it's not JSON, it might be raw output from a non-stream response
// that got into the stream handler mistakenly or just raw noise
if (trimmed.length > 0) {
process.stdout.write(chunk);
}
// Not a full JSON object yet or invalid JSON
}
};
console.log(`${chalk.blue("")} Executing function ${chalk.cyan(options.id)} (stream: ${useStream})...`);
try {
const response = await client.post(`/api/function/${options.id}/execute`,
{ run: payload },
{
params: { stream: useStream ? "true" : "false" },
responseType: useStream ? "stream" : "json"
}
);
if (useStream) {
const stream = (response as AxiosResponse).data;
let buffer = "";
return new Promise<void>((resolve, reject) => {
stream.on("data", (chunk: any) => {
const str = chunk.toString();
buffer += str;
// Try to split by potential JSON boundaries
// Matches cases like ...}{...
const pattern = /\}\s*\{/g;
let lastIndex = 0;
let match;
while ((match = pattern.exec(buffer)) !== null) {
const part = buffer.slice(lastIndex, match.index + 1);
handleJsonChunk(part);
lastIndex = match.index + match[0].indexOf('{');
}
buffer = buffer.slice(lastIndex);
// Attempt to parse the remaining buffer if it looks like a complete object
try {
if (buffer.trim().startsWith('{') && buffer.trim().endsWith('}')) {
handleJsonChunk(buffer);
buffer = "";
}
} catch (e) {
// Wait for more data
}
});
stream.on("end", () => {
if (buffer.trim()) {
handleJsonChunk(buffer);
}
resolve();
});
stream.on("error", (err: Error) => {
console.error(`\n${chalk.red("✗")} Stream error: ${err.message}`);
reject(err);
});
});
} else {
// Non-stream returns just the output content or the body
const result = response.data;
if (typeof result === 'string') {
process.stdout.write(result);
} else if (result && result.data && typeof result.data === 'string') {
process.stdout.write(result.data);
} else if (result && typeof result.output === 'string') {
process.stdout.write(result.output);
} else if (result && typeof result.result === 'string') {
process.stdout.write(result.result);
} else if (result && result.data && result.data.output) {
process.stdout.write(result.data.output);
} else {
process.stdout.write(JSON.stringify(result, null, 2));
}
console.log(`\n${chalk.green("✓")} Execution completed.`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Execution failed: ${chalk.yellow(error.response.data?.message || error.response.statusText || "Unknown error")}`,
);
} else if (error.request) {
console.error(`${chalk.red("✗")} No response received from server.`);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+56
View File
@@ -0,0 +1,56 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const pipInstallDefinition = {
name: "pipinstall",
description: "Install Python dependencies for a function using requirements.txt",
options: [
{
name: "--id <id>",
description: "The ID of the function.",
required: true,
},
],
action: async (options: { id: string }) => {
await pipInstallFunction(options.id);
},
};
async function pipInstallFunction(id: string) {
const client = await getApiClient();
try {
const response = await client.post(`/api/function/${id}/pip-install`);
if (response.status === 200) {
console.log(`${chalk.green("✓")} Dependencies installed successfully for function ${chalk.yellow(id)}.`);
if (response.data && response.data.status) {
console.log(`${chalk.gray("Status:")} ${response.data.status}`);
}
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server.`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to install dependencies.`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data?.message || "Unknown error from server")}`,
);
} else if (error.request) {
console.error(
`${chalk.red("✗")} Failed to install dependencies.`,
);
console.error(
`${chalk.yellow("Could not connect to the SHSF instance. Check your connection.")}`,
);
} else {
console.error(`${chalk.red("✗")} Error:`, error.message);
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import fs from "fs";
import path from "path";
export const pullDefinition = {
name: "pull",
description: "Pull files from a remote function.",
options: [
{ name: "--id <id>", description: "Function ID", required: true },
{ name: "--into <path>", description: "Target directory", required: true },
{ name: "--force", description: "Force overwrite" },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.get(`/api/function/${options.id}/files`);
const files = response.data.data; // Expecting { name: string, content: string }[]
if (!fs.existsSync(options.into)) {
fs.mkdirSync(options.into, { recursive: true });
} else if (fs.readdirSync(options.into).length > 0 && !options.force) {
console.error(chalk.red(`Directory ${options.into} is not empty. Use --force to overwrite.`));
return;
}
for (const file of files) {
fs.writeFileSync(path.join(options.into, file.name), file.content);
const byteSize = Buffer.byteLength(file.content, 'utf8');
console.log(chalk.green(`Pulled ${file.name} (${byteSize} bytes)`));
}
console.log(chalk.green(`Successfully pulled ${files.length} files into ${options.into}`));
} catch (error: any) {
console.error(chalk.red(`Failed to pull files: ${error.message}`));
}
},
};
+159
View File
@@ -0,0 +1,159 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
import fs from "fs";
import path from "path";
import { createHash } from "crypto";
async function deleteNonexistentFiles(
currentFiles: any[],
files: any[],
client: any,
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", required: true },
{ name: "--from <path>", description: "Source directory", required: true },
{ name: "--force", description: "Force overwrite" },
],
action: async (options: any) => {
const client = await getApiClient();
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 [...] }[]
const files = fs.readdirSync(options.from).map((file) => ({
filename: file,
content: fs.readFileSync(path.join(options.from, file), "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) {
// If the file content exists in the response, we can hash it.
// Note: currentFiles from /api/function/:id/files might not have content by default depending on API,
// but the original code assumes it does.
const currentFileHash = createHash("md5")
.update(currentFile.content || "")
.digest("hex");
if (fileHash === currentFileHash) {
console.log(
chalk.yellow(
`Skipping ${file.filename}, no changes detected 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 (didDeletion) {
console.log("");
console.log(
chalk.blue(
`Hint: We might delete files that you renamed, but don't worry, we'll push the newly named file right after!`,
),
);
}
} catch (error: any) {
if (error.response) {
console.error(
chalk.red(
`Failed to push files: ${error.response.data?.message || error.response.statusText}`,
),
);
} else if (error.request) {
console.error(chalk.red("No response received from server."));
} else {
console.error(chalk.red(`Error: ${error.message}`));
}
}
},
};
+91
View File
@@ -0,0 +1,91 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
interface ShsfFile {
name: string;
content: string;
}
export const reqAddDefinition = {
name: "add <package>",
description: "Add a package to the requirements.txt of a function.",
options: [
{ name: "--to <id>", description: "The ID of the function (deprecated alias for --id)." },
{ name: "--id <id>", description: "The ID of the function.", required: true },
],
action: async (pkg: string, options: { to?: string; id?: string }) => {
const functionId = options.id ?? options.to;
if (!functionId) {
console.error(`${chalk.red("✗")} Error: Function ID is required. Use ${chalk.cyan("--id <id>")}.`);
return;
}
const client = await getApiClient();
try {
// 1. Get current files of the function
const response = await client.get(`/api/function/${functionId}/files`);
let files: ShsfFile[] = response.data.data;
if (!Array.isArray(files)) files = [];
let requirementsFile = files.find(f => f.name === "requirements.txt");
let content = requirementsFile ? requirementsFile.content : "";
// 2. Add the package
const lines = content.split("\n");
// Robust package name extraction
const getPkgName = (line: string) => {
const withoutComment = line.trim().split("#", 1)[0];
const basePart = withoutComment.split(";", 1)[0].trim();
if (!basePart) return null;
return basePart.split(/[!=>~<]/)[0].trim().toLowerCase();
};
const pkgToAddName = getPkgName(pkg);
if (!pkgToAddName) {
console.error(`${chalk.red("✗")} Invalid package format.`);
return;
}
const exists = lines.some(line => {
const lineName = getPkgName(line);
return lineName === pkgToAddName;
});
if (exists) {
console.log(`${chalk.yellow("!")} Package ${chalk.cyan(pkgToAddName)} is already in requirements.txt.`);
return;
}
// Preserve existing content structure
if (content && !content.endsWith("\n")) {
content += "\n";
}
content += pkg + "\n";
// 3. Overwrite/Create requirements.txt using function-based API
await client.put(`/api/function/${functionId}/file`, {
filename: "requirements.txt",
code: content,
});
console.log(`${chalk.green("✓")} Added ${chalk.cyan(pkg)} to requirements.txt for function ${chalk.yellow(functionId)}.`);
} catch (error: any) {
handleError(error, "add package");
}
},
};
function handleError(error: any, task: string) {
if (error.response) {
console.error(`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(error.response.data?.message || (error.response.data ? JSON.stringify(error.response.data) : error.message))}`);
} else if (error.request) {
console.error(
`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(
"No response received from server. Please check your network connection and try again."
)}`
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
+97
View File
@@ -0,0 +1,97 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
interface ShsfFile {
name: string;
content: string;
}
export const reqRemoveDefinition = {
name: "remove <package>",
description: "Remove a package from the requirements.txt of a function.",
options: [
{ name: "--from <id>", description: "The ID of the function (deprecated alias for --id)." },
{ name: "--id <id>", description: "The ID of the function.", required: true },
],
action: async (pkgToRemove: string, options: { from?: string; id?: string }) => {
const functionId = options.id ?? options.from;
if (!functionId) {
console.error(`${chalk.red("✗")} Error: Function ID is required. Use ${chalk.cyan("--id <id>")}.`);
return;
}
const client = await getApiClient();
try {
// 1. Get current files of the function
const response = await client.get(`/api/function/${functionId}/files`);
let files: ShsfFile[] = response.data.data;
if (!Array.isArray(files)) files = [];
const requirementsFile = files.find(f => f.name === "requirements.txt");
if (!requirementsFile) {
console.log(`${chalk.yellow("!")} No requirements.txt found for function ${chalk.yellow(functionId)}.`);
return;
}
const content = requirementsFile.content;
const lines = content.split("\n");
const targetPkgName = pkgToRemove.trim().toLowerCase();
let removed = false;
const newLines = lines.filter(line => {
const trimmedLine = line.trim();
// Preserve empty/whitespace-only lines
if (!trimmedLine) return true;
// Strip inline comments and environment markers for matching purposes
const withoutComment = trimmedLine.split("#", 1)[0];
const basePart = withoutComment.split(";", 1)[0].trim();
// If there's no actual requirement (e.g., comment-only line), keep the line
if (!basePart) return true;
// Robust extraction (handles != <= >= ~= == and < >)
const currentPkgName = basePart.split(/[!=>~<]/)[0].trim().toLowerCase();
if (currentPkgName === targetPkgName) {
removed = true;
return false;
}
return true;
});
if (!removed) {
console.log(`${chalk.yellow("!")} Package ${chalk.cyan(pkgToRemove)} was not found in requirements.txt.`);
return;
}
const newContent = newLines.join("\n");
// 2. Overwrite requirements.txt using function-based API
await client.put(`/api/function/${functionId}/file`, {
filename: "requirements.txt",
code: newContent,
});
console.log(`${chalk.green("✓")} Removed ${chalk.cyan(pkgToRemove)} from requirements.txt for function ${chalk.yellow(functionId)}.`);
} catch (error: any) {
handleError(error, "remove package");
}
},
};
function handleError(error: any, task: string) {
if (error.response) {
console.error(`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(error.response.data?.message || (error.response.data ? JSON.stringify(error.response.data) : error.message))}`);
} else if (error.request) {
console.error(
`${chalk.red("✗")} Failed to ${task}: ${chalk.yellow(
"No response received from server. Please check your network connection and try again."
)}`
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
+35
View File
@@ -0,0 +1,35 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const clearItemsDefinition = {
name: "clear-items",
description: "Clear all items from a storage.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.delete(`/api/storage/${options.name}/items`);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} All items cleared from storage ${chalk.cyan(options.name)}!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to clear items: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+41
View File
@@ -0,0 +1,41 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const createStorageDefinition = {
name: "create",
description: "Create a new storage.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
{ name: "--purpose <purpose>", description: "Storage purpose", required: true },
],
action: async (options: any) => {
const data = {
name: options.name,
purpose: options.purpose,
};
const client = await getApiClient();
try {
const response = await client.post("/api/storage", data);
if (response.status === 200 || response.status === 201) {
console.log(
`${chalk.green("✓")} Storage ${chalk.cyan(data.name)} created successfully!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to create storage: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+36
View File
@@ -0,0 +1,36 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const deleteItemDefinition = {
name: "delete-item",
description: "Delete a storage item.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
{ name: "--key <key>", description: "Item key", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.delete(`/api/storage/${options.name}/item/${options.key}`);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} Item ${chalk.cyan(options.key)} deleted from ${chalk.cyan(options.name)}!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to delete item: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+35
View File
@@ -0,0 +1,35 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const deleteStorageDefinition = {
name: "delete",
description: "Delete a storage.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.delete(`/api/storage/${options.name}`);
if (response.status === 200) {
console.log(
`${chalk.green("✓")} Storage ${chalk.cyan(options.name)} deleted successfully!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to delete storage: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+42
View File
@@ -0,0 +1,42 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const listItemsDefinition = {
name: "get-items",
description: "List all items in a storage.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
],
action: async (options: any) => {
const client = await getApiClient();
try {
const response = await client.get(`/api/storage/${options.name}/items`);
if (response.status === 200 && response.data.data) {
const items = response.data.data;
if (items.length === 0) {
console.log(`${chalk.yellow("!")} Storage ${chalk.cyan(options.name)} is empty.`);
return;
}
console.log(chalk.blue(`Items in ${chalk.cyan(options.name)}:`));
items.forEach((item: any) => {
console.log(`- ${chalk.cyan(item.key)}: ${JSON.stringify(item.value)}`);
});
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to list items: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+40
View File
@@ -0,0 +1,40 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const listStoragesDefinition = {
name: "list",
description: "List all storages.",
action: async () => {
const client = await getApiClient();
try {
const response = await client.get("/api/storage");
if (response.status === 200 && response.data.data) {
const storages = response.data.data;
if (storages.length === 0) {
console.log(`${chalk.yellow("!")} No storages found.`);
return;
}
console.log(chalk.blue("Storages:"));
storages.forEach((s: any) => {
const purposeInfo = s.purpose ? chalk.gray(` - ${s.purpose}`) : "";
console.log(`- ${chalk.cyan(s.name)}${purposeInfo}`);
});
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to list storages: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+59
View File
@@ -0,0 +1,59 @@
import chalk from "chalk";
import { getApiClient } from "../../api.js";
export const setItemDefinition = {
name: "set-item",
description: "Set a storage item.",
options: [
{ name: "--name <name>", description: "Storage name", required: true },
{ name: "--key <key>", description: "Item key", required: true },
{ name: "--value <value>", description: "Item value (JSON string)", required: true },
{ name: "--expires <expires>", description: "Expiration (ISO string or hours)" },
],
action: async (options: any) => {
let value;
try {
value = JSON.parse(options.value);
} catch (e) {
// If not valid JSON, treat as string
value = options.value;
}
const data: any = {
key: options.key,
value: value,
};
if (options.expires) {
if (!isNaN(Number(options.expires))) {
data.expiresAt = Number(options.expires);
} else {
data.expiresAt = options.expires;
}
}
const client = await getApiClient();
try {
const response = await client.post(`/api/storage/${options.name}/item`, data);
if (response.status === 200 || response.status === 201) {
console.log(
`${chalk.green("✓")} Item ${chalk.cyan(options.key)} set successfully in ${chalk.cyan(options.name)}!`,
);
} else {
console.log(
`${chalk.yellow("!")} Unexpected response from server: ${response.status}`,
);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} Failed to set item: ${chalk.yellow(error.response.data.message || "Unknown error")}`,
);
} else {
console.error(`${chalk.red("✗")} Error: ${error.message}`);
}
}
},
};
+52
View File
@@ -0,0 +1,52 @@
import chalk from "chalk";
import { getApiClient } from "../api.js";
export const uiUrlDefinition = {
name: "uiurl",
description: "Get the URL for the SHSF UI.",
action: async () => {
await getUiURL();
},
};
async function getUiURL(): Promise<void> {
const client = await getApiClient();
try {
const response = await client.get<{ status: string; uiUrl: string }>("/api/global/uiUrl");
if (response.status === 200 && response.data.status === "OK") {
console.log(
`${chalk.green("✓")} SHSF UI URL: ${chalk.bgGreen.black(` ${response.data.uiUrl} `)}`
);
} else {
console.log(
`${chalk.yellow("!")} SHSF Status: ${chalk.bgYellow.black(" UNEXPECTED RESPONSE ")}`,
);
console.log(`Status Code: ${chalk.blue(response.status)}`);
console.log(`Response Data: ${JSON.stringify(response.data)}`);
}
} catch (error: any) {
if (error.response) {
console.error(
`${chalk.red("✗")} SHSF Status: ${chalk.bgRed.black(" UNHEALTHY ")}`,
);
console.error(`Status Code: ${chalk.red(error.response.status)}`);
console.error(
`Message: ${chalk.yellow(error.response.data.message || "Unknown error from server")}`,
);
} else if (error.request) {
console.error(
`${chalk.red("✗")} SHSF Status: ${chalk.bgRed.black(" DISCONNECTED ")}`,
);
console.error(
`Error: ${chalk.yellow("Could not connect to the SHSF instance. Check your connection or SHSF_INSTANCE URL.")}`,
);
} else {
console.error(
`${chalk.red("✗")} SHSF Status: ${chalk.bgRed.black(" LOCAL ERROR ")}`,
);
console.error(`Error: ${chalk.yellow(error.message)}`);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
}
},
});