diff --git a/OpenclawSkill.md b/OpenclawSkill.md index 873e1bc..ef123d9 100644 --- a/OpenclawSkill.md +++ b/OpenclawSkill.md @@ -30,6 +30,7 @@ this will check the health, and if not setup, it will prompt you to set up the C - `shsf delete trigger `: Deletes a specific trigger from a function. - `shsf get function `: Get details of a specific function by its ID. +- `shsf get exec-url [--id ]`: Get the execution URL for a function. Falls back to `.shsf.json` for the function ID and also prints the alias URL when the function has an `executionAlias`. - `shsf get namespace `: Get details of a specific namespace by its ID - `shsf get trigger `: Get details of a specific trigger from a function. @@ -81,6 +82,12 @@ You can also create a `.shsf.json` mapping file in the repository root so you do Command-line flags take precedence over values in `.shsf.json`. +This mapping can also be used with `shsf get exec-url`, so from a mapped function directory you can run: + +```bash +shsf get exec-url +``` + ## Instructions Use these commands for when you need to interact with shsf from the command line. Its faster than using the ui for almost all ops. @@ -96,6 +103,18 @@ After creating a function and receiving an ID (for example, 81), you can share t [UI_URL]/functions/[ID] ``` +To get the function execution URL from the CLI: + +```bash +shsf get exec-url --id 81 +``` + +If the function has an execution alias configured, the CLI also prints the alias form: + +```text +[API_URL]/exec/[executionAlias] +``` + ## Update Update with your package manager of choice. ALWAYS pnpm: ```bash @@ -386,4 +405,4 @@ require ( ) ``` -Supported Go versions: `1.20`, `1.21`, `1.22`, `1.23` \ No newline at end of file +Supported Go versions: `1.20`, `1.21`, `1.22`, `1.23` diff --git a/README.md b/README.md index 602ffd2..c174508 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,22 @@ Quickly see if the system is up and running: shsf health ``` +### 🔗 Execution URL + +Get a function's execution URL by ID: + +```bash +shsf get exec-url --id +``` + +If your repo has a `.shsf.json` mapping with an `id`, you can omit `--id`: + +```bash +shsf get exec-url +``` + +The command prints the standard execution URL and, when available, the alias URL based on the function's `executionAlias`. + ### 🏗️ Local Development If you're contributing or running from source: diff --git a/src/__tests__/function_exec_url.test.ts b/src/__tests__/function_exec_url.test.ts new file mode 100644 index 0000000..7f7ae0b --- /dev/null +++ b/src/__tests__/function_exec_url.test.ts @@ -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."); + }); +}); diff --git a/src/commands/get/exec-url.ts b/src/commands/get/exec-url.ts new file mode 100644 index 0000000..8106c68 --- /dev/null +++ b/src/commands/get/exec-url.ts @@ -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 ", 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 { + 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)}`); + } + } +} diff --git a/src/utils/cors_commands.ts b/src/utils/cors_commands.ts index d3641e1..0bd5732 100644 --- a/src/utils/cors_commands.ts +++ b/src/utils/cors_commands.ts @@ -1,24 +1,9 @@ import chalk from "chalk"; import { getApiClient } from "../api.js"; -import { readMappingFile } from "./push_helpers.js"; import { parseCorsOriginsOption } from "./cors.js"; +import { resolveFunctionId } from "./function_commands.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 ")} or provide an ${chalk.cyan(".shsf.json")} mapping with an ${chalk.cyan("id")}.`, - ); - return null; -} +export { resolveFunctionId } from "./function_commands.js"; export function validateSingleOrigin(origin: string): { origin?: string; error?: string } { const parsed = parseCorsOriginsOption(origin); diff --git a/src/utils/function_commands.ts b/src/utils/function_commands.ts new file mode 100644 index 0000000..7edc6b4 --- /dev/null +++ b/src/utils/function_commands.ts @@ -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 ")} or provide an ${chalk.cyan(".shsf.json")} mapping with an ${chalk.cyan("id")}.`, + ); + return null; +} diff --git a/src/utils/function_exec_url.ts b/src/utils/function_exec_url.ts new file mode 100644 index 0000000..b796314 --- /dev/null +++ b/src/utils/function_exec_url.ts @@ -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; +}