@swell/cli 2.5.7 → 2.6.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.
@@ -9,6 +9,7 @@ export default class ApiDelete extends SwellApiCommand {
9
9
  static flags: {
10
10
  live: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
11
11
  api: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
+ header: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
13
  };
13
14
  static examples: string[];
14
15
  get method(): HttpMethod;
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { HttpMethod } from '../../lib/api.js';
3
- import { SwellApiCommand } from '../../swell-api-command.js';
3
+ import { SwellApiCommand, headerFlag } from '../../swell-api-command.js';
4
4
  export default class ApiDelete extends SwellApiCommand {
5
5
  static summary = 'Send a DELETE request to the Swell API.';
6
6
  static description = `Remove existing resources from the Swell API.
@@ -21,10 +21,12 @@ export default class ApiDelete extends SwellApiCommand {
21
21
  options: ['backend', 'frontend'],
22
22
  default: 'backend',
23
23
  }),
24
+ header: headerFlag,
24
25
  };
25
26
  static examples = [
26
27
  'swell api delete /products/abc123',
27
28
  'swell api delete /functions/my-app/clear-cache',
29
+ `swell api delete /functions/my-app/clear-cache -H 'Authorization: Bearer xxx'`,
28
30
  'swell api delete /products/abc123 --api frontend',
29
31
  ];
30
32
  get method() {
@@ -9,6 +9,7 @@ export default class ApiGet extends SwellApiCommand {
9
9
  static flags: {
10
10
  live: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
11
11
  api: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
+ header: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
13
  };
13
14
  static examples: string[];
14
15
  get method(): HttpMethod;
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { HttpMethod } from '../../lib/api.js';
3
- import { SwellApiCommand } from '../../swell-api-command.js';
3
+ import { SwellApiCommand, headerFlag } from '../../swell-api-command.js';
4
4
  export default class ApiGet extends SwellApiCommand {
5
5
  static summary = 'Send a GET request to the Swell API.';
6
6
  static description = `Retrieve data from the Swell API.
@@ -22,12 +22,14 @@ export default class ApiGet extends SwellApiCommand {
22
22
  options: ['backend', 'frontend'],
23
23
  default: 'backend',
24
24
  }),
25
+ header: headerFlag,
25
26
  };
26
27
  static examples = [
27
28
  'swell api get /products',
28
29
  'swell api get "/products?limit=10&category=shoes"',
29
30
  'swell api get /products/abc123',
30
31
  'swell api get /functions/my-app/get-inventory',
32
+ `swell api get /functions/my-app/get-inventory -H 'Authorization: Bearer xxx'`,
31
33
  'swell api get /products --api frontend',
32
34
  ];
33
35
  get method() {
@@ -10,6 +10,7 @@ export default class ApiPost extends SwellApiCommand {
10
10
  body: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
11
  live: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
12
12
  api: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
+ header: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
14
  };
14
15
  static examples: string[];
15
16
  get method(): HttpMethod;
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { HttpMethod } from '../../lib/api.js';
3
- import { SwellApiCommand } from '../../swell-api-command.js';
3
+ import { SwellApiCommand, headerFlag } from '../../swell-api-command.js';
4
4
  export default class ApiPost extends SwellApiCommand {
5
5
  static summary = 'Send a POST request to the Swell API.';
6
6
  static description = `Create new resources in the Swell API.
@@ -25,11 +25,13 @@ export default class ApiPost extends SwellApiCommand {
25
25
  options: ['backend', 'frontend'],
26
26
  default: 'backend',
27
27
  }),
28
+ header: headerFlag,
28
29
  };
29
30
  static examples = [
30
31
  'swell api post /products --body \'{"name": "Product", "price": 100}\'',
31
32
  'swell api post /products --body ./product.json',
32
33
  'swell api post /functions/my-app/create-order --body \'{"items":["sku-1"]}\'',
34
+ `swell api post /functions/my-app/webhook --body @./payload.json -H 'Stripe-Signature: t=123,v1=abc'`,
33
35
  `swell api post /products --body '{"name": "Product"}' --api frontend`,
34
36
  ];
35
37
  get method() {
@@ -10,6 +10,7 @@ export default class ApiPut extends SwellApiCommand {
10
10
  body: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
11
  live: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
12
12
  api: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
+ header: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
14
  };
14
15
  static examples: string[];
15
16
  get method(): HttpMethod;
@@ -1,6 +1,6 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { HttpMethod } from '../../lib/api.js';
3
- import { SwellApiCommand } from '../../swell-api-command.js';
3
+ import { SwellApiCommand, headerFlag } from '../../swell-api-command.js';
4
4
  export default class ApiPut extends SwellApiCommand {
5
5
  static summary = 'Send a PUT request to the Swell API.';
6
6
  static description = `Update existing resources in the Swell API.
@@ -25,11 +25,13 @@ export default class ApiPut extends SwellApiCommand {
25
25
  options: ['backend', 'frontend'],
26
26
  default: 'backend',
27
27
  }),
28
+ header: headerFlag,
28
29
  };
29
30
  static examples = [
30
31
  'swell api put /products/{id} --body \'{"id": "abc123", "name": "Updated"}\'',
31
32
  'swell api put /products/abc123 --body ./product.json',
32
33
  'swell api put /functions/my-app/update-stock --body \'{"sku":"abc","qty":10}\'',
34
+ `swell api put /functions/my-app/update-stock --body '{"sku":"abc"}' -H 'X-Idempotency-Key: 42'`,
33
35
  `swell api put /products/abc123 --body '{"name": "Updated"}' --api frontend`,
34
36
  ];
35
37
  get method() {
@@ -1,3 +1,5 @@
1
+ /// <reference types="node" />
2
+ import { ChildProcess } from 'node:child_process';
1
3
  import { PushAppCommand } from '../../push-app-command.js';
2
4
  export default class AppDev extends PushAppCommand {
3
5
  static delayOrientation: boolean;
@@ -16,6 +18,7 @@ export default class AppDev extends PushAppCommand {
16
18
  static summary: string;
17
19
  functionErrors: Map<string, string>;
18
20
  functionPorts: Map<string, number>;
21
+ functionProcesses: Map<string, ChildProcess>;
19
22
  tmpDir: string;
20
23
  frontendPort: number | null;
21
24
  isCleaningUp: boolean;
@@ -50,6 +50,8 @@ export default class AppDev extends PushAppCommand {
50
50
  functionErrors = new Map();
51
51
  // All available functions
52
52
  functionPorts = new Map();
53
+ // Wrangler child processes to kill on cleanup
54
+ functionProcesses = new Map();
53
55
  // Directory for compiled function files and wrangler context
54
56
  tmpDir = '';
55
57
  // Port for frontend dev server (used for routing non-function requests)
@@ -84,6 +86,9 @@ export default class AppDev extends PushAppCommand {
84
86
  if (this.isCleaningUp)
85
87
  return;
86
88
  this.isCleaningUp = true;
89
+ for (const [, proc] of this.functionProcesses) {
90
+ proc.kill();
91
+ }
87
92
  try {
88
93
  await this.updateLocalProxy(null, this.storefront?.id);
89
94
  }
@@ -252,11 +257,8 @@ ENVIRONMENT = "development"
252
257
  .map((m) => String(m).toUpperCase())
253
258
  .join(', '))}`);
254
259
  }
255
- if (config.route.headers) {
256
- const headers = Object.entries(config.route.headers).map(([key, value]) => `${key}: ${value}`);
257
- this.log(` Headers:`);
258
- for (const header of headers)
259
- this.log(` ${style.dim(header)}`);
260
+ if (config.route.headers?.length) {
261
+ this.log(` Headers: ${style.dim(config.route.headers.join(', '))}`);
260
262
  }
261
263
  if (config.route.cache?.timeout) {
262
264
  this.log(` Cache timeout: ${style.dim(config.route.cache.timeout)}`);
@@ -296,7 +298,9 @@ ENVIRONMENT = "development"
296
298
  // Re-bundle the function
297
299
  const { code } = await bundleFunction(fullPath);
298
300
  // Update the bundled file (wrangler dev will auto-reload)
299
- const bundledPath = path.join(this.tmpDir, `${appConfig.name}.js`);
301
+ const functionDir = path.join(this.tmpDir, `${appConfig.name}`);
302
+ await fs.promises.mkdir(functionDir, { recursive: true });
303
+ const bundledPath = path.join(functionDir, `${appConfig.name}.js`);
300
304
  await fs.promises.writeFile(bundledPath, code);
301
305
  if (this.functionPorts.has(appConfig.name)) {
302
306
  this.log(`\nUpdating function ${appConfig.name}...`);
@@ -355,6 +359,7 @@ ENVIRONMENT = "development"
355
359
  }
356
360
  async startFunctionServers(functions) {
357
361
  const functionStatus = new Map();
362
+ const allocatedFunctionPorts = new Set();
358
363
  const allocatedInspectorPorts = new Set();
359
364
  /* eslint-disable no-await-in-loop */
360
365
  for (const func of functions) {
@@ -363,7 +368,11 @@ ENVIRONMENT = "development"
363
368
  // Function server already running
364
369
  continue;
365
370
  }
366
- const functionPort = await getPort({ port: portNumbers(9000, 9100) });
371
+ const functionPort = await getPort({
372
+ port: portNumbers(9000, 9100),
373
+ exclude: [...allocatedFunctionPorts],
374
+ });
375
+ allocatedFunctionPorts.add(functionPort);
367
376
  const inspectorPort = await getPort({
368
377
  port: portNumbers(9229, 9329),
369
378
  exclude: [...allocatedInspectorPorts],
@@ -373,12 +382,15 @@ ENVIRONMENT = "development"
373
382
  // Bundle the function
374
383
  const fullPath = path.join(this.appPath, func.filePath);
375
384
  const { code } = await bundleFunction(fullPath);
376
- // Write bundled function to tmp directory
377
- const bundledPath = path.join(this.tmpDir, `${functionName}.js`);
385
+ // Each function gets its own directory to avoid wrangler state conflicts
386
+ const functionDir = path.join(this.tmpDir, functionName);
387
+ await fs.promises.mkdir(functionDir, { recursive: true });
388
+ // Write bundled function to its directory
389
+ const bundledPath = path.join(functionDir, `${functionName}.js`);
378
390
  await fs.promises.writeFile(bundledPath, code);
379
391
  // Generate wrangler config
380
392
  const wranglerConfig = this.generateWranglerConfig(functionName, bundledPath);
381
- const configPath = path.join(this.tmpDir, `${functionName}.toml`);
393
+ const configPath = path.join(functionDir, `${functionName}.toml`);
382
394
  await fs.promises.writeFile(configPath, wranglerConfig);
383
395
  functionStatus.set(functionName, 'starting');
384
396
  // Start wrangler process in background using detected package manager
@@ -392,7 +404,7 @@ ENVIRONMENT = "development"
392
404
  `--port=${functionPort}`,
393
405
  `--inspector-port=${inspectorPort}`,
394
406
  ], {
395
- cwd: this.tmpDir,
407
+ cwd: functionDir,
396
408
  // stdio: 'pipe', // Capture output for debugging
397
409
  detached: false,
398
410
  });
@@ -458,7 +470,8 @@ ENVIRONMENT = "development"
458
470
  this.functionErrors.set(functionName, `Process exited with code ${code}`);
459
471
  }
460
472
  });
461
- // Store the port for routing
473
+ // Store the process and port for routing
474
+ this.functionProcesses.set(functionName, wranglerProcess);
462
475
  this.functionPorts.set(functionName, functionPort);
463
476
  }
464
477
  catch (error) {
@@ -97,7 +97,18 @@ export function createMockRequest(options: MockRequestOptions = {}): SwellReques
97
97
  const targetAppId =
98
98
  typeof idOrValues === "string" ? idOrValues : resolvedAppId;
99
99
  const appValues = typeof idOrValues === "string" ? values : idOrValues;
100
- if (!targetAppId || !appValues) return undefined;
100
+ if (!targetAppId) {
101
+ throw new Error("appValues: missing app id (req.appId is empty)");
102
+ }
103
+ if (
104
+ typeof appValues !== "object" ||
105
+ appValues === null ||
106
+ Object.getPrototypeOf(appValues) !== Object.prototype
107
+ ) {
108
+ throw new Error(
109
+ "appValues: values must be a plain object (arrays, class instances, null, and primitives are not allowed)"
110
+ );
111
+ }
101
112
  return {
102
113
  $app: {
103
114
  [targetAppId]: appValues,
@@ -2,6 +2,7 @@ export function setupGlobalsTemplate() {
2
2
  return `\
3
3
  class SwellErrorImpl extends Error {
4
4
  status: number;
5
+ body?: unknown;
5
6
 
6
7
  constructor(message: string | object, options: { status?: number } = {}) {
7
8
  const text =
@@ -12,6 +13,7 @@ class SwellErrorImpl extends Error {
12
13
  super(text);
13
14
  this.name = "SwellError";
14
15
  this.status = options.status ?? 500;
16
+ this.body = typeof message === "string" ? undefined : message;
15
17
  }
16
18
  }
17
19
 
@@ -88,6 +88,7 @@ declare class SwellRequest {
88
88
  id: any;
89
89
  isLocalDev: boolean;
90
90
  swell: SwellAPI;
91
+ rawBody: string;
91
92
  body: {};
92
93
  query: {};
93
94
  data: {};
@@ -100,14 +101,16 @@ declare class SwellRequest {
100
101
  getIngestableLogs(response: any): {
101
102
  params: any;
102
103
  } | undefined;
104
+ formatRequestData(): string;
103
105
  ingestLogs(response: any): Promise<void>;
104
106
  /**
105
107
  * Merge values into app data for the current request.
106
108
  * @param {object|string} idOrValues string to indicate app ID, or values to merge
107
109
  * @param {object|undefined} values values to merge into app data
108
- * @returns {object|undefined} existing app data merged with values if passed
110
+ * @returns {object} existing app data merged with values
111
+ * @throws {Error} if app id is missing or values is not a plain object
109
112
  */
110
- appValues(idOrValues: object | string, values?: object | undefined): object | undefined;
113
+ appValues(idOrValues: object | string, values?: object | undefined): object;
111
114
  }
112
115
  /**
113
116
  * Class representing the Swell backend API.
@@ -133,12 +136,14 @@ declare class SwellAPI {
133
136
  declare class SwellError extends Error {
134
137
  constructor(message: any, options?: {});
135
138
  status: any;
139
+ body: any;
136
140
  }
137
141
  /**
138
142
  * Class representing a Swell response.
139
143
  */
140
144
  declare class SwellResponse extends Response {
141
- static _respond(req: any, response: any, context: any): any;
145
+ static _respond(req: any, response: any, context: any): Promise<any>;
146
+ static _consumeNativeResponse(response: any): Promise<SwellResponse>;
142
147
  static _respondWithLogs(response: any, req: any): SwellResponse;
143
148
  constructor(data: any, options?: {});
144
149
  _swellData: any;
@@ -90,6 +90,8 @@ class SwellRequest {
90
90
  this.assignRequestProps(req);
91
91
  // Set environment specific variables
92
92
  this.context = context;
93
+ // Slug-form app identifier (e.g. 'klaviyo') matching keys in record.$app[...].
94
+ // Derived from the app's private_id with the leading underscore stripped.
93
95
  this.appId = req.headers.get('Swell-App-Id');
94
96
  this.storeId = req.headers.get('Swell-Store-Id');
95
97
  this.accessToken = req.headers.get('Swell-Access-Token');
@@ -105,11 +107,14 @@ class SwellRequest {
105
107
  this.swell = new SwellAPI(this, context);
106
108
  // URL of the original request
107
109
  this.url;
108
- // Original body of the request, JSON if applicable
110
+ // Raw request body text, untouched by parsing.
111
+ // Use on route triggers for HMAC/webhook signature verification.
112
+ this.rawBody = '';
113
+ // Parsed JSON body as object, or raw text string when body isn't JSON.
109
114
  this.body = {};
110
115
  // URL query parameters as an object
111
116
  this.query = {};
112
- // Combined object of body and query parameters
117
+ // Combined object of body and query parameters (query keys overwrite body keys)
113
118
  this.data = {};
114
119
  // Internal logs
115
120
  this._logs = [];
@@ -120,9 +125,10 @@ class SwellRequest {
120
125
  });
121
126
  }
122
127
  async initialize() {
123
- this.body = await this.originalRequest.text();
128
+ this.rawBody = await this.originalRequest.text();
129
+ this.body = this.rawBody;
124
130
  try {
125
- this.data = JSON.parse(this.body);
131
+ this.data = JSON.parse(this.rawBody);
126
132
  this.body = { ...this.data };
127
133
  }
128
134
  catch (err) {
@@ -170,12 +176,22 @@ class SwellRequest {
170
176
  ...this.logParams,
171
177
  message: {
172
178
  ...this.logParams?.message,
179
+ data: this.logParams?.message?.data || this.formatRequestData(),
173
180
  logs: this._logs,
174
181
  status: response.status,
175
182
  },
176
183
  },
177
184
  };
178
185
  }
186
+ formatRequestData() {
187
+ try {
188
+ const stringData = JSON.stringify(this.body ?? null);
189
+ return stringData.substring(0, 1024000);
190
+ }
191
+ catch {
192
+ return '';
193
+ }
194
+ }
179
195
  async ingestLogs(response) {
180
196
  const ingestableLogs = this.getIngestableLogs(response);
181
197
  if (!ingestableLogs) {
@@ -192,13 +208,17 @@ class SwellRequest {
192
208
  * Merge values into app data for the current request.
193
209
  * @param {object|string} idOrValues string to indicate app ID, or values to merge
194
210
  * @param {object|undefined} values values to merge into app data
195
- * @returns {object|undefined} existing app data merged with values if passed
211
+ * @returns {object} existing app data merged with values
212
+ * @throws {Error} if app id is missing or values is not a plain object
196
213
  */
197
214
  appValues(idOrValues, values = undefined) {
198
- const appId = typeof idOrValues === 'string' ? appIdOrValues : this.appId;
215
+ const appId = typeof idOrValues === 'string' ? idOrValues : this.appId;
199
216
  const appValues = typeof idOrValues === 'string' ? values : idOrValues;
200
- if (!appId || !isOrdinaryObject(appValues)) {
201
- return undefined;
217
+ if (!appId) {
218
+ throw new Error('appValues: missing app id (req.appId is empty)');
219
+ }
220
+ if (!isOrdinaryObject(appValues)) {
221
+ throw new Error('appValues: values must be a plain object (arrays, class instances, null, and primitives are not allowed)');
202
222
  }
203
223
  return {
204
224
  $app: {
@@ -306,6 +326,7 @@ class SwellAPI {
306
326
  */
307
327
  class SwellError extends Error {
308
328
  constructor(message, options = {}) {
329
+ const body = typeof message === 'string' ? undefined : message;
309
330
  let formattedMessage;
310
331
  if (typeof message === 'string') {
311
332
  formattedMessage = message;
@@ -319,6 +340,7 @@ class SwellError extends Error {
319
340
  super(formattedMessage);
320
341
  this.name = 'SwellError';
321
342
  this.status = options.status || 500;
343
+ this.body = body;
322
344
  }
323
345
  }
324
346
  /**
@@ -348,27 +370,53 @@ class SwellResponse extends Response {
348
370
  this._swellData = data;
349
371
  this._swellOptions = options || {};
350
372
  }
351
- static _respond(req, response, context) {
373
+ static async _respond(req, response, context) {
352
374
  let finalResponse = response;
353
- // Convert a plain Response instance to SwellResponse
375
+ const isHook = Boolean(req.data?.$event?.hook);
354
376
  if (finalResponse instanceof Response &&
355
377
  !(finalResponse instanceof SwellResponse)) {
356
- finalResponse = new SwellResponse(response.body, {
357
- status: response.status,
358
- headers: response.headers,
359
- });
378
+ // Non-hook responses pass through unchanged so the handler's body,
379
+ // status, and headers reach the caller intact. Hooks need the parsed
380
+ // body so $logs can be merged into the payload below.
381
+ if (isHook) {
382
+ finalResponse =
383
+ await SwellResponse._consumeNativeResponse(finalResponse);
384
+ }
360
385
  }
361
386
  else if (!(finalResponse instanceof SwellResponse)) {
362
387
  finalResponse = new SwellResponse(response);
363
388
  }
364
389
  // Send logs back with the response for event hooks
365
- if (req.data?.$event?.hook) {
390
+ if (isHook) {
366
391
  return SwellResponse._respondWithLogs(finalResponse, req);
367
392
  }
368
393
  // Ingest logs in the background
369
394
  context.waitUntil(req.ingestLogs(finalResponse));
370
395
  return finalResponse;
371
396
  }
397
+ static async _consumeNativeResponse(response) {
398
+ const headers = {};
399
+ response.headers.forEach((value, key) => {
400
+ headers[key] = value;
401
+ });
402
+ try {
403
+ const text = await response.text();
404
+ let data;
405
+ try {
406
+ data = JSON.parse(text);
407
+ }
408
+ catch {
409
+ data = text;
410
+ }
411
+ return new SwellResponse(data, {
412
+ status: response.status,
413
+ headers,
414
+ });
415
+ }
416
+ catch (err) {
417
+ return new SwellResponse({ error: `Unable to read response body: ${err.message}` }, { status: 500 });
418
+ }
419
+ }
372
420
  static _respondWithLogs(response, req) {
373
421
  const ingestableLogs = req.getIngestableLogs(response);
374
422
  // Rebuild response with logs
@@ -1,5 +1,6 @@
1
1
  import { HttpMethod } from './lib/api.js';
2
2
  import { SwellCommand } from './swell-command.js';
3
+ export declare const headerFlag: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
3
4
  export declare abstract class SwellApiCommand extends SwellCommand {
4
5
  protected abstract get method(): HttpMethod;
5
6
  protected request(command: typeof SwellApiCommand, requestOptions?: Api.RequestOptions): Promise<void>;
@@ -18,6 +19,7 @@ export declare abstract class SwellApiCommand extends SwellCommand {
18
19
  * Build a function invocation request via the admin /:functions endpoint.
19
20
  */
20
21
  private buildFunctionCallRequest;
22
+ private parseHeaders;
21
23
  private parseQueryString;
22
24
  private processBody;
23
25
  private isFilePath;
@@ -1,3 +1,4 @@
1
+ import { Flags } from '@oclif/core';
1
2
  import * as fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { FetchError } from 'node-fetch';
@@ -7,6 +8,13 @@ import { SwellCommand } from './swell-command.js';
7
8
  const FUNCTION_PATH_REGEX = /^\/functions\/([^/]+)\/([^/?]+)(\?.*)?$/;
8
9
  // Pattern to match /functions/{functionId} with optional query string
9
10
  const FUNCTION_DIRECT_REGEX = /^\/functions\/([^/?]+)(\?.*)?$/;
11
+ // Shared --header / -H flag for function-call paths. Pass once per header.
12
+ // Only forwarded as $call.headers on /functions/* paths; ignored elsewhere.
13
+ export const headerFlag = Flags.string({
14
+ char: 'H',
15
+ description: "HTTP header to forward to a function (format: 'Name: value'). Repeat for multiple. Only applies to /functions/* paths.",
16
+ multiple: true,
17
+ });
10
18
  export class SwellApiCommand extends SwellCommand {
11
19
  async request(command, requestOptions = {}) {
12
20
  const { paths, options, methodOverride } = await this.parseCommand(command);
@@ -29,7 +37,7 @@ export class SwellApiCommand extends SwellCommand {
29
37
  const parsedInput = await super.parse(options, argv);
30
38
  const { args, flags } = parsedInput;
31
39
  const { path: requestPath } = args;
32
- const { live, api, body } = flags;
40
+ const { live, api, body, header } = flags;
33
41
  const isFrontendAPI = api === 'frontend';
34
42
  if (!live) {
35
43
  await this.api.setEnv('test');
@@ -41,21 +49,30 @@ export class SwellApiCommand extends SwellCommand {
41
49
  throw new Error('Path must start with a forward slash (/)');
42
50
  }
43
51
  const processedBody = await this.processBody(body);
52
+ const callHeaders = this.parseHeaders(header);
44
53
  // Check if this is a function call by name: /functions/{appId}/{functionName}
45
54
  // Must check this first (more specific pattern)
46
55
  const functionMatch = requestPath.match(FUNCTION_PATH_REGEX);
47
56
  if (functionMatch) {
48
57
  const [, appId, functionName, queryString] = functionMatch;
49
58
  const functionId = await this.resolveFunctionId(appId, functionName);
50
- const queryParams = this.parseQueryString(queryString);
51
- return this.buildFunctionCallRequest(parsedInput, functionId, processedBody, queryParams);
59
+ return this.buildFunctionCallRequest(parsedInput, {
60
+ functionId,
61
+ body: processedBody,
62
+ query: this.parseQueryString(queryString),
63
+ headers: callHeaders,
64
+ });
52
65
  }
53
66
  // Check if this is a direct function call: /functions/{functionId}
54
67
  const directMatch = requestPath.match(FUNCTION_DIRECT_REGEX);
55
68
  if (directMatch) {
56
69
  const [, functionId, queryString] = directMatch;
57
- const queryParams = this.parseQueryString(queryString);
58
- return this.buildFunctionCallRequest(parsedInput, functionId, processedBody, queryParams);
70
+ return this.buildFunctionCallRequest(parsedInput, {
71
+ functionId,
72
+ body: processedBody,
73
+ query: this.parseQueryString(queryString),
74
+ headers: callHeaders,
75
+ });
59
76
  }
60
77
  const paths = isFrontendAPI
61
78
  ? { frontendPath: requestPath }
@@ -108,27 +125,49 @@ export class SwellApiCommand extends SwellCommand {
108
125
  /**
109
126
  * Build a function invocation request via the admin /:functions endpoint.
110
127
  */
111
- buildFunctionCallRequest(parsedInput, functionId, bodyData, queryParams) {
128
+ buildFunctionCallRequest(parsedInput, call) {
112
129
  // Merge query params with body data (body takes precedence)
113
- // Only merge if bodyData is a plain object; otherwise use bodyData or query params alone
114
- const mergedData = this.isPlainObject(bodyData)
115
- ? { ...queryParams, ...bodyData }
116
- : bodyData ?? queryParams;
117
- const callBody = {
118
- $call: {
119
- data: mergedData,
120
- method: this.method,
121
- },
130
+ // Only merge if body is a plain object; otherwise use body or query alone
131
+ const mergedData = this.isPlainObject(call.body)
132
+ ? { ...call.query, ...call.body }
133
+ : call.body ?? call.query;
134
+ const $call = {
135
+ data: mergedData,
136
+ method: this.method,
122
137
  };
138
+ if (Object.keys(call.headers).length > 0) {
139
+ $call.headers = call.headers;
140
+ }
123
141
  return {
124
142
  ...parsedInput,
125
- paths: { adminPath: `/data/:functions/${functionId}` },
143
+ paths: { adminPath: `/data/:functions/${call.functionId}` },
126
144
  options: {
127
- body: callBody,
145
+ body: { $call },
128
146
  },
129
147
  methodOverride: HttpMethod.PUT,
130
148
  };
131
149
  }
150
+ // Parse repeated `-H "Name: value"` flag entries into a map. Splits on first colon
151
+ // so values containing colons (e.g. `Authorization: Bearer x:y`) survive intact.
152
+ parseHeaders(headerArgs) {
153
+ if (!headerArgs?.length) {
154
+ return {};
155
+ }
156
+ const result = {};
157
+ for (const entry of headerArgs) {
158
+ const colonIndex = entry.indexOf(':');
159
+ if (colonIndex < 1) {
160
+ throw new Error(`Invalid header '${entry}'. Expected format: 'Name: value'.`);
161
+ }
162
+ const name = entry.slice(0, colonIndex).trim();
163
+ const value = entry.slice(colonIndex + 1).trim();
164
+ if (!name) {
165
+ throw new Error(`Invalid header '${entry}'. Header name cannot be empty.`);
166
+ }
167
+ result[name] = value;
168
+ }
169
+ return result;
170
+ }
132
171
  parseQueryString(queryString) {
133
172
  if (!queryString) {
134
173
  return {};
@@ -385,6 +385,7 @@
385
385
  "examples": [
386
386
  "swell api delete /products/abc123",
387
387
  "swell api delete /functions/my-app/clear-cache",
388
+ "swell api delete /functions/my-app/clear-cache -H 'Authorization: Bearer xxx'",
388
389
  "swell api delete /products/abc123 --api frontend"
389
390
  ],
390
391
  "flags": {
@@ -405,6 +406,14 @@
405
406
  "frontend"
406
407
  ],
407
408
  "type": "option"
409
+ },
410
+ "header": {
411
+ "char": "H",
412
+ "description": "HTTP header to forward to a function (format: 'Name: value'). Repeat for multiple. Only applies to /functions/* paths.",
413
+ "name": "header",
414
+ "hasDynamicHelp": false,
415
+ "multiple": true,
416
+ "type": "option"
408
417
  }
409
418
  },
410
419
  "hasDynamicHelp": false,
@@ -438,6 +447,7 @@
438
447
  "swell api get \"/products?limit=10&category=shoes\"",
439
448
  "swell api get /products/abc123",
440
449
  "swell api get /functions/my-app/get-inventory",
450
+ "swell api get /functions/my-app/get-inventory -H 'Authorization: Bearer xxx'",
441
451
  "swell api get /products --api frontend"
442
452
  ],
443
453
  "flags": {
@@ -458,6 +468,14 @@
458
468
  "frontend"
459
469
  ],
460
470
  "type": "option"
471
+ },
472
+ "header": {
473
+ "char": "H",
474
+ "description": "HTTP header to forward to a function (format: 'Name: value'). Repeat for multiple. Only applies to /functions/* paths.",
475
+ "name": "header",
476
+ "hasDynamicHelp": false,
477
+ "multiple": true,
478
+ "type": "option"
461
479
  }
462
480
  },
463
481
  "hasDynamicHelp": false,
@@ -521,6 +539,7 @@
521
539
  "swell api post /products --body '{\"name\": \"Product\", \"price\": 100}'",
522
540
  "swell api post /products --body ./product.json",
523
541
  "swell api post /functions/my-app/create-order --body '{\"items\":[\"sku-1\"]}'",
542
+ "swell api post /functions/my-app/webhook --body @./payload.json -H 'Stripe-Signature: t=123,v1=abc'",
524
543
  "swell api post /products --body '{\"name\": \"Product\"}' --api frontend"
525
544
  ],
526
545
  "flags": {
@@ -548,6 +567,14 @@
548
567
  "frontend"
549
568
  ],
550
569
  "type": "option"
570
+ },
571
+ "header": {
572
+ "char": "H",
573
+ "description": "HTTP header to forward to a function (format: 'Name: value'). Repeat for multiple. Only applies to /functions/* paths.",
574
+ "name": "header",
575
+ "hasDynamicHelp": false,
576
+ "multiple": true,
577
+ "type": "option"
551
578
  }
552
579
  },
553
580
  "hasDynamicHelp": false,
@@ -580,6 +607,7 @@
580
607
  "swell api put /products/{id} --body '{\"id\": \"abc123\", \"name\": \"Updated\"}'",
581
608
  "swell api put /products/abc123 --body ./product.json",
582
609
  "swell api put /functions/my-app/update-stock --body '{\"sku\":\"abc\",\"qty\":10}'",
610
+ "swell api put /functions/my-app/update-stock --body '{\"sku\":\"abc\"}' -H 'X-Idempotency-Key: 42'",
583
611
  "swell api put /products/abc123 --body '{\"name\": \"Updated\"}' --api frontend"
584
612
  ],
585
613
  "flags": {
@@ -607,6 +635,14 @@
607
635
  "frontend"
608
636
  ],
609
637
  "type": "option"
638
+ },
639
+ "header": {
640
+ "char": "H",
641
+ "description": "HTTP header to forward to a function (format: 'Name: value'). Repeat for multiple. Only applies to /functions/* paths.",
642
+ "name": "header",
643
+ "hasDynamicHelp": false,
644
+ "multiple": true,
645
+ "type": "option"
610
646
  }
611
647
  },
612
648
  "hasDynamicHelp": false,
@@ -2969,5 +3005,5 @@
2969
3005
  ]
2970
3006
  }
2971
3007
  },
2972
- "version": "2.5.7"
3008
+ "version": "2.6.0"
2973
3009
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swell/cli",
3
- "version": "2.5.7",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "description": "Swell's command line interface/utility",
6
6
  "keywords": [