@swell/cli 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/app-command.js +1 -1
  2. package/dist/commands/api/delete.js +4 -1
  3. package/dist/commands/api/get.js +1 -0
  4. package/dist/commands/api/index.js +4 -1
  5. package/dist/commands/api/post.js +1 -0
  6. package/dist/commands/api/put.js +1 -0
  7. package/dist/commands/app/dev.js +5 -3
  8. package/dist/commands/app/init.d.ts +8 -7
  9. package/dist/commands/app/init.js +36 -18
  10. package/dist/commands/app/pull.js +2 -2
  11. package/dist/commands/app/push.js +2 -2
  12. package/dist/commands/app/version.d.ts +1 -0
  13. package/dist/commands/app/version.js +14 -4
  14. package/dist/commands/create/app.d.ts +8 -4
  15. package/dist/commands/create/app.js +68 -51
  16. package/dist/commands/create/content.d.ts +5 -6
  17. package/dist/commands/create/content.js +22 -38
  18. package/dist/commands/create/function.d.ts +6 -7
  19. package/dist/commands/create/function.js +94 -31
  20. package/dist/commands/create/index.js +13 -1
  21. package/dist/commands/create/model.d.ts +5 -6
  22. package/dist/commands/create/model.js +21 -33
  23. package/dist/commands/create/notification.d.ts +9 -9
  24. package/dist/commands/create/notification.js +117 -95
  25. package/dist/commands/create/setting.d.ts +21 -0
  26. package/dist/commands/create/setting.js +120 -0
  27. package/dist/commands/create/tests.d.ts +14 -0
  28. package/dist/commands/create/tests.js +76 -0
  29. package/dist/commands/create/webhook.d.ts +22 -0
  30. package/dist/commands/create/webhook.js +176 -0
  31. package/dist/commands/inspect/content.js +1 -1
  32. package/dist/commands/schema.d.ts +1 -0
  33. package/dist/commands/schema.js +51 -5
  34. package/dist/commands/theme/init.d.ts +8 -4
  35. package/dist/commands/theme/init.js +21 -8
  36. package/dist/create-app-command.d.ts +1 -0
  37. package/dist/create-app-command.js +15 -10
  38. package/dist/create-config-command.js +2 -2
  39. package/dist/help/custom-help.d.ts +89 -0
  40. package/dist/help/custom-help.js +337 -0
  41. package/dist/help/types.d.ts +75 -0
  42. package/dist/help/types.js +1 -0
  43. package/dist/lib/apps/app-config.js +2 -2
  44. package/dist/lib/apps/index.d.ts +2 -1
  45. package/dist/lib/apps/index.js +21 -4
  46. package/dist/lib/apps/paths.js +7 -6
  47. package/dist/lib/create/notification.d.ts +1 -0
  48. package/dist/lib/create/schemas.d.ts +1 -0
  49. package/dist/lib/create/schemas.js +1 -0
  50. package/dist/lib/create/setting.d.ts +15 -0
  51. package/dist/lib/create/setting.js +27 -0
  52. package/dist/lib/create/tests/templates/env-dts.d.ts +1 -0
  53. package/dist/lib/create/tests/templates/env-dts.js +15 -0
  54. package/dist/lib/create/tests/templates/index.d.ts +8 -0
  55. package/dist/lib/create/tests/templates/index.js +8 -0
  56. package/dist/lib/create/tests/templates/integration-test.d.ts +1 -0
  57. package/dist/lib/create/tests/templates/integration-test.js +19 -0
  58. package/dist/lib/create/tests/templates/mock-request.d.ts +2 -0
  59. package/dist/lib/create/tests/templates/mock-request.js +112 -0
  60. package/dist/lib/create/tests/templates/setup-globals.d.ts +1 -0
  61. package/dist/lib/create/tests/templates/setup-globals.js +23 -0
  62. package/dist/lib/create/tests/templates/swell-client.d.ts +1 -0
  63. package/dist/lib/create/tests/templates/swell-client.js +128 -0
  64. package/dist/lib/create/tests/templates/tsconfig.d.ts +1 -0
  65. package/dist/lib/create/tests/templates/tsconfig.js +17 -0
  66. package/dist/lib/create/tests/templates/unit-test.d.ts +1 -0
  67. package/dist/lib/create/tests/templates/unit-test.js +42 -0
  68. package/dist/lib/create/tests/templates/vitest-config.d.ts +2 -0
  69. package/dist/lib/create/tests/templates/vitest-config.js +122 -0
  70. package/dist/lib/create/tests/types.d.ts +21 -0
  71. package/dist/lib/create/tests/types.js +1 -0
  72. package/dist/lib/create/tests.d.ts +4 -0
  73. package/dist/lib/create/tests.js +113 -0
  74. package/dist/lib/create/webhook.d.ts +32 -0
  75. package/dist/lib/create/webhook.js +52 -0
  76. package/dist/swell-api-command.d.ts +14 -0
  77. package/dist/swell-api-command.js +113 -7
  78. package/oclif.manifest.json +2879 -0
  79. package/package.json +2 -1
@@ -0,0 +1,122 @@
1
+ export function vitestConfigTemplate(options) {
2
+ return `\
3
+ import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
4
+ import { readFileSync, existsSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+
8
+ type SdkAuth = {
9
+ storeId: string;
10
+ sessionId: string;
11
+ apiBaseUrl: string;
12
+ };
13
+
14
+ const isCI = Boolean(process.env.CI || process.env.CONTINUOUS_INTEGRATION);
15
+
16
+ function loadSwellAuth(): SdkAuth {
17
+ const envStoreId = process.env.SWELL_STORE_ID;
18
+ const envSessionId = process.env.SWELL_SESSION_ID;
19
+ const envApiBaseUrl = process.env.SWELL_API_BASE_URL;
20
+
21
+ if (envStoreId && envSessionId) {
22
+ return {
23
+ storeId: envStoreId,
24
+ sessionId: envSessionId,
25
+ apiBaseUrl: envApiBaseUrl || \`https://\${envStoreId}.swell.store/admin/api\`,
26
+ };
27
+ }
28
+
29
+ if (isCI) {
30
+ throw new Error(
31
+ "CI environment detected but SWELL_STORE_ID and SWELL_SESSION_ID are not set. " +
32
+ "Add these as CI secrets/variables to run integration tests.",
33
+ );
34
+ }
35
+
36
+ const home = homedir();
37
+ const configPath = path.resolve(home, ".swell", "config.json");
38
+ if (!existsSync(configPath)) {
39
+ throw new Error(
40
+ \`Swell CLI config not found at \${configPath}. Run \\\`swell login\\\` or set SWELL_STORE_ID and SWELL_SESSION_ID.\`
41
+ );
42
+ }
43
+
44
+ const rawConfig = readFileSync(configPath, "utf-8");
45
+
46
+ interface CliConfig {
47
+ defaultStore?: string;
48
+ stores?: { storeId: string; sessionId?: string }[];
49
+ }
50
+
51
+ let configJson: CliConfig;
52
+
53
+ try {
54
+ configJson = JSON.parse(rawConfig) as CliConfig;
55
+ } catch (error) {
56
+ throw new Error(
57
+ \`Unable to parse Swell CLI config at \${configPath}: \${String(error)}\`
58
+ );
59
+ }
60
+
61
+ const defaultStore = configJson.defaultStore;
62
+ const stores = configJson.stores || [];
63
+
64
+ const store = defaultStore
65
+ ? stores.find((item) => item.storeId === defaultStore)
66
+ : undefined;
67
+
68
+ if (!defaultStore || !store?.sessionId) {
69
+ throw new Error(
70
+ "No active Swell CLI session found. Run \\\`swell login\\\` or set SWELL_STORE_ID and SWELL_SESSION_ID.",
71
+ );
72
+ }
73
+
74
+ const envPath = path.join(home, ".swell", "env.json");
75
+ let apiBaseUrl = \`https://\${defaultStore}.swell.store/admin/api\`;
76
+
77
+ if (existsSync(envPath)) {
78
+ try {
79
+ const rawEnv = readFileSync(envPath, "utf-8");
80
+ const envJson = JSON.parse(rawEnv) as { ADMIN_API_BASE_URL?: string };
81
+ if (envJson.ADMIN_API_BASE_URL) {
82
+ apiBaseUrl = envJson.ADMIN_API_BASE_URL.replace("\${STORE_ID}", defaultStore);
83
+ }
84
+ } catch {
85
+ // Ignore env parsing errors and fall back to default
86
+ }
87
+ }
88
+
89
+ return {
90
+ storeId: defaultStore,
91
+ sessionId: store.sessionId!,
92
+ apiBaseUrl,
93
+ };
94
+ }
95
+
96
+ const sdkAuth = loadSwellAuth();
97
+
98
+ export default defineWorkersConfig({
99
+ test: {
100
+ globals: true,
101
+ include: ["test/**/*.test.ts"],
102
+ exclude: ["node_modules", "docs"],
103
+ setupFiles: ["./test/setup-globals.ts"],
104
+ poolOptions: {
105
+ workers: {
106
+ singleWorker: true,
107
+ isolatedStorage: true,
108
+ miniflare: {
109
+ bindings: {
110
+ SWELL_STORE_ID: sdkAuth.storeId,
111
+ SWELL_SESSION_ID: sdkAuth.sessionId,
112
+ SWELL_API_BASE_URL: sdkAuth.apiBaseUrl,
113
+ SWELL_APP_ID: "${options.appId}",
114
+ SWELL_ENVIRONMENT: "test",
115
+ },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ });
121
+ `;
122
+ }
@@ -0,0 +1,21 @@
1
+ interface TestTemplateOptions {
2
+ /** App id from swell.json, used for SWELL_APP_ID and settings() */
3
+ appId: string;
4
+ }
5
+ interface WriteTestsOptions extends TestTemplateOptions {
6
+ /** Absolute path to the app root */
7
+ appPath: string;
8
+ /** Overwrite existing files if present */
9
+ overwrite: boolean;
10
+ }
11
+ interface ScaffoldResult {
12
+ /** Files that were created or overwritten */
13
+ createdFiles: string[];
14
+ /** Files that were skipped because they already exist */
15
+ skippedFiles: string[];
16
+ /** Whether package.json was updated */
17
+ packageJsonUpdated: boolean;
18
+ /** Warning messages to display to user */
19
+ warnings: string[];
20
+ }
21
+ export type { ScaffoldResult, TestTemplateOptions, WriteTestsOptions };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { ScaffoldResult, WriteTestsOptions } from './tests/types.js';
2
+ declare function createTestsScaffold(options: WriteTestsOptions): Promise<ScaffoldResult>;
3
+ export { createTestsScaffold };
4
+ export type { ScaffoldResult } from './tests/types.js';
@@ -0,0 +1,113 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { filePathExists, writeFile } from '../apps/index.js';
4
+ import { envDtsTemplate, integrationTestTemplate, mockRequestTemplate, setupGlobalsTemplate, swellClientTemplate, tsconfigTemplate, unitTestTemplate, vitestConfigTemplate, } from './tests/templates/index.js';
5
+ const TEST_DEV_DEPENDENCIES = {
6
+ '@cloudflare/vitest-pool-workers': 'latest',
7
+ vitest: 'latest',
8
+ '@swell/app-types': 'latest',
9
+ };
10
+ async function writeTextFile(filePath, contents, overwrite) {
11
+ if (!overwrite && filePathExists(filePath)) {
12
+ return { created: false, skipped: true };
13
+ }
14
+ await writeFile(filePath, contents);
15
+ return { created: true, skipped: false };
16
+ }
17
+ function updatePackageJson(appPath) {
18
+ const pkgPath = path.join(appPath, 'package.json');
19
+ if (!filePathExists(pkgPath)) {
20
+ return {
21
+ updated: false,
22
+ warning: 'package.json not found. Run `npm init` first.',
23
+ };
24
+ }
25
+ let pkgRaw;
26
+ try {
27
+ pkgRaw = fs.readFileSync(pkgPath, 'utf8');
28
+ }
29
+ catch (error) {
30
+ return {
31
+ updated: false,
32
+ warning: `Could not read package.json: ${error instanceof Error ? error.message : String(error)}`,
33
+ };
34
+ }
35
+ let pkg;
36
+ try {
37
+ pkg = JSON.parse(pkgRaw);
38
+ }
39
+ catch (error) {
40
+ return {
41
+ updated: false,
42
+ warning: `Could not parse package.json: ${error instanceof Error ? error.message : String(error)}`,
43
+ };
44
+ }
45
+ const devDeps = (pkg.devDependencies || {});
46
+ const deps = (pkg.dependencies || {});
47
+ devDeps['@cloudflare/vitest-pool-workers'] =
48
+ devDeps['@cloudflare/vitest-pool-workers'] ||
49
+ TEST_DEV_DEPENDENCIES['@cloudflare/vitest-pool-workers'];
50
+ devDeps.vitest = devDeps.vitest || TEST_DEV_DEPENDENCIES.vitest;
51
+ if (!devDeps['@swell/app-types'] && !deps['@swell/app-types']) {
52
+ devDeps['@swell/app-types'] = TEST_DEV_DEPENDENCIES['@swell/app-types'];
53
+ }
54
+ pkg.devDependencies = devDeps;
55
+ const scripts = (pkg.scripts || {});
56
+ scripts.test = scripts.test || 'vitest run';
57
+ pkg.scripts = scripts;
58
+ try {
59
+ const next = `${JSON.stringify(pkg, null, 2)}\n`;
60
+ fs.writeFileSync(pkgPath, next, 'utf8');
61
+ return { updated: true };
62
+ }
63
+ catch (error) {
64
+ return {
65
+ updated: false,
66
+ warning: `Could not write package.json: ${error instanceof Error ? error.message : String(error)}`,
67
+ };
68
+ }
69
+ }
70
+ function validatePrerequisites(appPath) {
71
+ const warnings = [];
72
+ const tsconfigPath = path.join(appPath, 'tsconfig.json');
73
+ if (!filePathExists(tsconfigPath)) {
74
+ warnings.push('tsconfig.json not found. test/tsconfig.json extends it; create one or tests may not compile.');
75
+ }
76
+ return warnings;
77
+ }
78
+ async function createTestsScaffold(options) {
79
+ const { appPath, appId, overwrite } = options;
80
+ const result = {
81
+ createdFiles: [],
82
+ skippedFiles: [],
83
+ packageJsonUpdated: false,
84
+ warnings: [],
85
+ };
86
+ const prereqWarnings = validatePrerequisites(appPath);
87
+ result.warnings.push(...prereqWarnings);
88
+ const trackFile = async (relativePath, contents) => {
89
+ const fullPath = path.join(appPath, relativePath);
90
+ const writeResult = await writeTextFile(fullPath, contents, overwrite);
91
+ if (writeResult.created) {
92
+ result.createdFiles.push(relativePath);
93
+ }
94
+ else if (writeResult.skipped) {
95
+ result.skippedFiles.push(relativePath);
96
+ }
97
+ };
98
+ await trackFile('vitest.config.ts', vitestConfigTemplate({ appId }));
99
+ await trackFile('test/tsconfig.json', tsconfigTemplate());
100
+ await trackFile('test/env.d.ts', envDtsTemplate());
101
+ await trackFile('test/setup-globals.ts', setupGlobalsTemplate());
102
+ await trackFile('test/helpers/swell-client.ts', swellClientTemplate());
103
+ await trackFile('test/helpers/mock-request.ts', mockRequestTemplate({ appId }));
104
+ await trackFile('test/unit/example.test.ts', unitTestTemplate());
105
+ await trackFile('test/integration/example.test.ts', integrationTestTemplate());
106
+ const pkgResult = updatePackageJson(appPath);
107
+ result.packageJsonUpdated = pkgResult.updated;
108
+ if (pkgResult.warning) {
109
+ result.warnings.push(pkgResult.warning);
110
+ }
111
+ return result;
112
+ }
113
+ export { createTestsScaffold };
@@ -0,0 +1,32 @@
1
+ interface CreateWebhookFileBody {
2
+ $schema: string;
3
+ description?: string;
4
+ enabled?: boolean;
5
+ events: string[];
6
+ url: string;
7
+ }
8
+ type ErrorHandler = (msg: string, options: {
9
+ exit: number;
10
+ }) => never;
11
+ /**
12
+ * Parse comma-separated events string to array
13
+ * Input: "payment.succeeded,payment.failed"
14
+ * Output: ["payment.succeeded", "payment.failed"]
15
+ */
16
+ declare const parseEvents: (eventsInput: string) => string[];
17
+ /**
18
+ * Validate event format (model.action pattern)
19
+ * Valid: "product.created", "payment.succeeded"
20
+ * Invalid: "product", "created", "", ".created", "product."
21
+ */
22
+ declare const validateEventFormat: (events: string[], onError: ErrorHandler) => void;
23
+ /**
24
+ * Validate URL format (basic check for protocol)
25
+ */
26
+ declare const validateUrl: (url: string, onError: ErrorHandler) => void;
27
+ /**
28
+ * Convert name to webhook label
29
+ * "payment-handler" => "Payment Handler"
30
+ */
31
+ declare const toWebhookLabel: (value: string) => string;
32
+ export { CreateWebhookFileBody, parseEvents, toWebhookLabel, validateEventFormat, validateUrl, };
@@ -0,0 +1,52 @@
1
+ import { titleize } from 'inflection';
2
+ /**
3
+ * Parse comma-separated events string to array
4
+ * Input: "payment.succeeded,payment.failed"
5
+ * Output: ["payment.succeeded", "payment.failed"]
6
+ */
7
+ const parseEvents = (eventsInput) => {
8
+ if (!eventsInput) {
9
+ return [];
10
+ }
11
+ return eventsInput
12
+ .split(',')
13
+ .map((e) => e.trim())
14
+ .filter(Boolean);
15
+ };
16
+ /**
17
+ * Validate event format (model.action pattern)
18
+ * Valid: "product.created", "payment.succeeded"
19
+ * Invalid: "product", "created", "", ".created", "product."
20
+ */
21
+ const validateEventFormat = (events, onError) => {
22
+ const invalid = [];
23
+ for (const event of events) {
24
+ const parts = event.split('.');
25
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
26
+ invalid.push(event);
27
+ }
28
+ }
29
+ if (invalid.length > 0) {
30
+ onError(`Invalid event format: ${invalid.join(', ')}\n\nEvents must follow the 'model.action' pattern (e.g., product.created, payment.succeeded).\n\nExample: swell create webhook my-hook -u https://example.com -e order.created,order.updated -y`, { exit: 1 });
31
+ }
32
+ };
33
+ /**
34
+ * Validate URL format (basic check for protocol)
35
+ */
36
+ const validateUrl = (url, onError) => {
37
+ try {
38
+ const parsed = new URL(url);
39
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
40
+ throw new Error('Invalid protocol');
41
+ }
42
+ }
43
+ catch {
44
+ onError(`Invalid URL format: ${url}\n\nURL must be a valid HTTP or HTTPS endpoint.\n\nExample: swell create webhook my-hook -u https://example.com/webhook -e order.created -y`, { exit: 1 });
45
+ }
46
+ };
47
+ /**
48
+ * Convert name to webhook label
49
+ * "payment-handler" => "Payment Handler"
50
+ */
51
+ const toWebhookLabel = (value) => titleize(value).replaceAll('-', ' ');
52
+ export { parseEvents, toWebhookLabel, validateEventFormat, validateUrl, };
@@ -5,6 +5,20 @@ export declare abstract class SwellApiCommand extends SwellCommand {
5
5
  protected request(command: typeof SwellApiCommand, requestOptions?: Api.RequestOptions): Promise<void>;
6
6
  protected catch(error: Error): Promise<any>;
7
7
  private parseCommand;
8
+ /**
9
+ * Resolve app ObjectId from a friendly slug or return as-is if already an ObjectId.
10
+ */
11
+ private resolveAppId;
12
+ /**
13
+ * Resolve function ID from app ID and function name.
14
+ */
15
+ private resolveFunctionId;
16
+ private isPlainObject;
17
+ /**
18
+ * Build a function invocation request via the admin /:functions endpoint.
19
+ */
20
+ private buildFunctionCallRequest;
21
+ private parseQueryString;
8
22
  private processBody;
9
23
  private isFilePath;
10
24
  private handleResponse;
@@ -1,11 +1,17 @@
1
1
  import * as fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { FetchError } from 'node-fetch';
4
+ import { HttpMethod } from './lib/api.js';
4
5
  import { SwellCommand } from './swell-command.js';
6
+ // Pattern to match /functions/{appId}/{functionName} with optional query string
7
+ const FUNCTION_PATH_REGEX = /^\/functions\/([^/]+)\/([^/?]+)(\?.*)?$/;
8
+ // Pattern to match /functions/{functionId} with optional query string
9
+ const FUNCTION_DIRECT_REGEX = /^\/functions\/([^/?]+)(\?.*)?$/;
5
10
  export class SwellApiCommand extends SwellCommand {
6
11
  async request(command, requestOptions = {}) {
7
- const { paths, options } = await this.parseCommand(command);
8
- const response = await this.api[this.method](paths, {
12
+ const { paths, options, methodOverride } = await this.parseCommand(command);
13
+ const method = methodOverride || this.method;
14
+ const response = await this.api[method](paths, {
9
15
  rawResponse: true,
10
16
  ...options,
11
17
  ...requestOptions,
@@ -22,22 +28,112 @@ export class SwellApiCommand extends SwellCommand {
22
28
  async parseCommand(options, argv) {
23
29
  const parsedInput = await super.parse(options, argv);
24
30
  const { args, flags } = parsedInput;
25
- const { path } = args;
31
+ const { path: requestPath } = args;
26
32
  const { live, body } = flags;
27
33
  if (!live) {
28
34
  await this.api.setEnv('test');
29
35
  }
30
- if (!path.startsWith('/')) {
36
+ if (!requestPath.startsWith('/')) {
31
37
  throw new Error('Path must start with a forward slash (/)');
32
38
  }
39
+ const processedBody = await this.processBody(body);
40
+ // Check if this is a function call by name: /functions/{appId}/{functionName}
41
+ // Must check this first (more specific pattern)
42
+ const functionMatch = requestPath.match(FUNCTION_PATH_REGEX);
43
+ if (functionMatch) {
44
+ const [, appId, functionName, queryString] = functionMatch;
45
+ const functionId = await this.resolveFunctionId(appId, functionName);
46
+ const queryParams = this.parseQueryString(queryString);
47
+ return this.buildFunctionCallRequest(parsedInput, functionId, processedBody, queryParams);
48
+ }
49
+ // Check if this is a direct function call: /functions/{functionId}
50
+ const directMatch = requestPath.match(FUNCTION_DIRECT_REGEX);
51
+ if (directMatch) {
52
+ const [, functionId, queryString] = directMatch;
53
+ const queryParams = this.parseQueryString(queryString);
54
+ return this.buildFunctionCallRequest(parsedInput, functionId, processedBody, queryParams);
55
+ }
56
+ const paths = { adminPath: `/data${requestPath}` };
33
57
  return {
34
58
  ...parsedInput,
35
- paths: { adminPath: `/data${path}` },
59
+ paths,
36
60
  options: {
37
- body: await this.processBody(body),
61
+ body: processedBody,
38
62
  },
39
63
  };
40
64
  }
65
+ /**
66
+ * Resolve app ObjectId from a friendly slug or return as-is if already an ObjectId.
67
+ */
68
+ async resolveAppId(appIdOrSlug) {
69
+ // If it looks like an ObjectId (24 hex chars), return as-is
70
+ if (/^[\da-f]{24}$/i.test(appIdOrSlug)) {
71
+ return appIdOrSlug;
72
+ }
73
+ // Fetch all installed apps and filter by public_id or private_id client-side
74
+ const installedApps = await this.api.get({ adminPath: `/client/apps` });
75
+ const app = installedApps?.results?.find((a) => a.app_public_id === appIdOrSlug ||
76
+ a.app_private_id === `_${appIdOrSlug}`);
77
+ if (!app) {
78
+ throw new Error(`App '${appIdOrSlug}' not found`);
79
+ }
80
+ return app.app_id;
81
+ }
82
+ /**
83
+ * Resolve function ID from app ID and function name.
84
+ */
85
+ async resolveFunctionId(appIdOrSlug, functionName) {
86
+ const appId = await this.resolveAppId(appIdOrSlug);
87
+ const functionRecord = await this.api.get({ adminPath: `/data/:functions` }, {
88
+ query: {
89
+ app_id: appId,
90
+ name: functionName,
91
+ limit: 1,
92
+ },
93
+ });
94
+ if (!functionRecord?.results?.length) {
95
+ throw new Error(`Function '${functionName}' not found for app '${appIdOrSlug}'`);
96
+ }
97
+ return functionRecord.results[0].id;
98
+ }
99
+ isPlainObject(value) {
100
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
101
+ }
102
+ /**
103
+ * Build a function invocation request via the admin /:functions endpoint.
104
+ */
105
+ buildFunctionCallRequest(parsedInput, functionId, bodyData, queryParams) {
106
+ // Merge query params with body data (body takes precedence)
107
+ // Only merge if bodyData is a plain object; otherwise use bodyData or query params alone
108
+ const mergedData = this.isPlainObject(bodyData)
109
+ ? { ...queryParams, ...bodyData }
110
+ : bodyData ?? queryParams;
111
+ const callBody = {
112
+ $call: {
113
+ data: mergedData,
114
+ method: this.method,
115
+ },
116
+ };
117
+ return {
118
+ ...parsedInput,
119
+ paths: { adminPath: `/data/:functions/${functionId}` },
120
+ options: {
121
+ body: callBody,
122
+ },
123
+ methodOverride: HttpMethod.PUT,
124
+ };
125
+ }
126
+ parseQueryString(queryString) {
127
+ if (!queryString) {
128
+ return {};
129
+ }
130
+ const params = new URLSearchParams(queryString.slice(1)); // Remove leading '?'
131
+ const result = {};
132
+ for (const [key, value] of params.entries()) {
133
+ result[key] = value;
134
+ }
135
+ return result;
136
+ }
41
137
  async processBody(body) {
42
138
  if (!body) {
43
139
  return;
@@ -54,10 +150,20 @@ export class SwellApiCommand extends SwellCommand {
54
150
  }
55
151
  async handleResponse(response) {
56
152
  const responseText = await response.text();
57
- if (responseText.length <= 2) {
153
+ // Handle truly empty responses (no body) - success for 2xx status codes
154
+ // This handles DELETE 204 No Content and similar cases
155
+ if (!responseText) {
156
+ if (response.ok) {
157
+ this.onSuccess({ success: true });
158
+ return;
159
+ }
58
160
  throw new Error('Not found');
59
161
  }
60
162
  const result = JSON.parse(responseText);
163
+ // API returned empty/null content in body - this is "not found" semantics
164
+ if (result === null || result === undefined || result === '') {
165
+ throw new Error('Not found');
166
+ }
61
167
  if (result.error || result.errors || !response.ok) {
62
168
  throw new Error(responseText);
63
169
  }