@swell/cli 2.2.1 → 2.3.1

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 (61) 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/frontend/dev.js +1 -1
  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 +15 -5
  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 +21 -37
  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 +8 -0
  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 +4 -5
  28. package/dist/commands/create/tests.js +8 -11
  29. package/dist/commands/create/webhook.d.ts +22 -0
  30. package/dist/commands/create/webhook.js +176 -0
  31. package/dist/commands/schema.d.ts +1 -0
  32. package/dist/commands/schema.js +51 -5
  33. package/dist/commands/theme/init.d.ts +8 -4
  34. package/dist/commands/theme/init.js +21 -8
  35. package/dist/create-app-command.d.ts +1 -0
  36. package/dist/create-app-command.js +15 -10
  37. package/dist/create-config-command.js +2 -2
  38. package/dist/help/custom-help.d.ts +89 -0
  39. package/dist/help/custom-help.js +337 -0
  40. package/dist/help/types.d.ts +75 -0
  41. package/dist/help/types.js +1 -0
  42. package/dist/lib/apps/app-config.js +2 -2
  43. package/dist/lib/apps/index.d.ts +2 -1
  44. package/dist/lib/apps/index.js +21 -5
  45. package/dist/lib/apps/paths.js +7 -6
  46. package/dist/lib/create/notification.d.ts +1 -0
  47. package/dist/lib/create/schemas.d.ts +1 -0
  48. package/dist/lib/create/schemas.js +1 -0
  49. package/dist/lib/create/setting.d.ts +15 -0
  50. package/dist/lib/create/setting.js +27 -0
  51. package/dist/lib/create/tests/templates/env-dts.js +1 -0
  52. package/dist/lib/create/tests/templates/swell-client.js +2 -0
  53. package/dist/lib/create/tests/templates/tsconfig.js +2 -0
  54. package/dist/lib/create/tests/templates/vitest-config.js +1 -0
  55. package/dist/lib/create/webhook.d.ts +32 -0
  56. package/dist/lib/create/webhook.js +52 -0
  57. package/dist/lib/proxy.js +62 -5
  58. package/dist/swell-api-command.d.ts +14 -0
  59. package/dist/swell-api-command.js +113 -7
  60. package/oclif.manifest.json +609 -259
  61. package/package.json +3 -1
@@ -3,4 +3,5 @@ export const SCHEMAS = {
3
3
  MODEL: 'https://json.swell.store/model.json',
4
4
  NOTIFICATION: 'https://json.swell.store/notification.json',
5
5
  SETTING: 'https://json.swell.store/setting.json',
6
+ WEBHOOK: 'https://json.swell.store/webhook.json',
6
7
  };
@@ -0,0 +1,15 @@
1
+ interface CreateSettingFileBody {
2
+ $schema: string;
3
+ description?: string;
4
+ fields: SettingFieldProps[];
5
+ label?: string;
6
+ }
7
+ interface SettingFieldProps {
8
+ id: string;
9
+ label: string;
10
+ type?: SettingFieldType;
11
+ }
12
+ type SettingFieldType = 'asset' | 'basic_html' | 'boolean' | 'category_lookup' | 'checkbox' | 'checkboxes' | 'collection' | 'color' | 'color_scheme' | 'color_scheme_group' | 'currency' | 'customer_lookup' | 'date' | 'datetime' | 'document' | 'dropdown' | 'email' | 'field_group' | 'font_family' | 'generic_lookup' | 'html' | 'icon' | 'image' | 'long_text' | 'lookup' | 'markdown' | 'number' | 'percent' | 'phone' | 'product_lookup' | 'radio' | 'rich_html' | 'rich_text' | 'select' | 'short_text' | 'slider' | 'slug' | 'tags' | 'text' | 'textarea' | 'time' | 'toggle' | 'url' | 'variant_lookup' | 'video';
13
+ declare const parseFields: (fields: string[]) => SettingFieldProps[];
14
+ declare const toSettingLabel: (value: string) => string;
15
+ export { CreateSettingFileBody, parseFields, toSettingLabel };
@@ -0,0 +1,27 @@
1
+ import { titleize } from 'inflection';
2
+ const parseFields = (fields) => {
3
+ if (fields.length === 0) {
4
+ return [];
5
+ }
6
+ const fieldResponse = [];
7
+ for (const fieldPair of fields) {
8
+ if (fieldPair.includes(':')) {
9
+ const [id, type] = fieldPair.split(':');
10
+ fieldResponse.push({
11
+ id,
12
+ label: titleize(id),
13
+ type,
14
+ });
15
+ }
16
+ else {
17
+ fieldResponse.push({
18
+ id: fieldPair,
19
+ label: titleize(fieldPair),
20
+ });
21
+ }
22
+ }
23
+ return fieldResponse;
24
+ };
25
+ // Example: 'api-config' => 'Api Config'
26
+ const toSettingLabel = (value) => titleize(value).replaceAll('-', ' ');
27
+ export { parseFields, toSettingLabel };
@@ -6,6 +6,7 @@ declare module "cloudflare:test" {
6
6
  SWELL_SESSION_ID: string;
7
7
  SWELL_API_BASE_URL: string;
8
8
  SWELL_APP_ID: string;
9
+ SWELL_ENVIRONMENT?: string;
9
10
  }
10
11
  }
11
12
 
@@ -16,6 +16,7 @@ async function makeRequest(
16
16
  ): Promise<any> {
17
17
  const baseUrl = env.SWELL_API_BASE_URL || "https://api.swell.store";
18
18
  const sessionId = env.SWELL_SESSION_ID;
19
+ const environment = env.SWELL_ENVIRONMENT || "test";
19
20
 
20
21
  if (!sessionId) {
21
22
  throw new Error(
@@ -33,6 +34,7 @@ async function makeRequest(
33
34
  "Content-Type": "application/json;charset=UTF-8",
34
35
  "User-Agent": "swell-app-tests/1.0",
35
36
  "X-Session": sessionId,
37
+ "Swell-Env": environment,
36
38
  };
37
39
 
38
40
  const options: RequestInit = {
@@ -3,6 +3,7 @@ export function tsconfigTemplate() {
3
3
  extends: '../tsconfig.json',
4
4
  compilerOptions: {
5
5
  moduleResolution: 'bundler',
6
+ skipLibCheck: true,
6
7
  types: [
7
8
  '@cloudflare/vitest-pool-workers',
8
9
  '@swell/app-types',
@@ -10,6 +11,7 @@ export function tsconfigTemplate() {
10
11
  ],
11
12
  },
12
13
  include: ['./**/*.ts', '../functions/**/*.ts'],
14
+ exclude: ['../node_modules'],
13
15
  };
14
16
  return `${JSON.stringify(config, null, 2)}\n`;
15
17
  }
@@ -111,6 +111,7 @@ export default defineWorkersConfig({
111
111
  SWELL_SESSION_ID: sdkAuth.sessionId,
112
112
  SWELL_API_BASE_URL: sdkAuth.apiBaseUrl,
113
113
  SWELL_APP_ID: "${options.appId}",
114
+ SWELL_ENVIRONMENT: "test",
114
115
  },
115
116
  },
116
117
  },
@@ -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, };
package/dist/lib/proxy.js CHANGED
@@ -1,8 +1,62 @@
1
+ import { bin, install, Tunnel } from 'cloudflared';
1
2
  import localtunnel from 'localtunnel';
2
3
  import ngrok from 'ngrok';
4
+ import * as fs from 'node:fs';
5
+ import ora from 'ora';
3
6
  import { LOCAL_PROXY_PROVIDER } from './constants.js';
7
+ let activeTunnel = null;
8
+ let cleanupRegistered = false;
9
+ async function startCloudflaredTunnel(port) {
10
+ // Stop existing tunnel if called again
11
+ if (activeTunnel) {
12
+ activeTunnel.stop();
13
+ activeTunnel = null;
14
+ }
15
+ // Ensure cloudflared binary is installed
16
+ if (!fs.existsSync(bin)) {
17
+ const spinner = ora('Installing cloudflared tunnel (first time only)...').start();
18
+ await install(bin);
19
+ spinner.stop();
20
+ }
21
+ // Use Tunnel.quick() for quick tunnels without a Cloudflare account
22
+ const tunnel = Tunnel.quick(`http://localhost:${port}`);
23
+ activeTunnel = tunnel;
24
+ // Wait for the URL to be available
25
+ const url = await new Promise((resolve, reject) => {
26
+ const timeout = setTimeout(() => {
27
+ tunnel.stop();
28
+ activeTunnel = null;
29
+ reject(new Error('Timeout waiting for cloudflared tunnel URL. Check if cloudflared can connect to Cloudflare.'));
30
+ }, 60000);
31
+ tunnel.on('url', (tunnelUrl) => {
32
+ clearTimeout(timeout);
33
+ resolve(tunnelUrl);
34
+ });
35
+ tunnel.on('error', (error) => {
36
+ clearTimeout(timeout);
37
+ activeTunnel = null;
38
+ reject(error);
39
+ });
40
+ });
41
+ // Register cleanup handlers only once, referencing activeTunnel for current tunnel
42
+ if (!cleanupRegistered) {
43
+ cleanupRegistered = true;
44
+ process.on('exit', () => activeTunnel?.stop());
45
+ process.on('SIGINT', () => {
46
+ activeTunnel?.stop();
47
+ // eslint-disable-next-line no-process-exit
48
+ process.exit();
49
+ });
50
+ process.on('SIGTERM', () => {
51
+ activeTunnel?.stop();
52
+ // eslint-disable-next-line no-process-exit
53
+ process.exit();
54
+ });
55
+ }
56
+ return url;
57
+ }
4
58
  export async function getProxyUrl(port) {
5
- const provider = LOCAL_PROXY_PROVIDER || 'localtunnel';
59
+ const provider = LOCAL_PROXY_PROVIDER || 'cloudflared';
6
60
  try {
7
61
  switch (provider) {
8
62
  case 'local': {
@@ -14,16 +68,19 @@ export async function getProxyUrl(port) {
14
68
  region: 'us', // TODO: make it configurable
15
69
  });
16
70
  }
17
- // eslint-disable-next-line unicorn/no-useless-switch-case
18
- case 'localtunnel':
19
- default: {
71
+ case 'localtunnel': {
20
72
  const tunnel = await localtunnel({ port });
21
73
  return tunnel.url;
22
74
  }
75
+ // eslint-disable-next-line unicorn/no-useless-switch-case
76
+ case 'cloudflared':
77
+ default: {
78
+ return await startCloudflaredTunnel(port);
79
+ }
23
80
  }
24
81
  }
25
82
  catch (error) {
26
- console.log(error);
83
+ console.error(error);
27
84
  throw new Error(`Unable to start tunnel on port ${port} (${provider}): ${error.message}`);
28
85
  }
29
86
  }
@@ -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
  }