@swell/cli 2.5.8 → 2.7.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 (55) hide show
  1. package/dist/commands/api/delete.d.ts +1 -0
  2. package/dist/commands/api/delete.js +3 -1
  3. package/dist/commands/api/get.d.ts +1 -0
  4. package/dist/commands/api/get.js +3 -1
  5. package/dist/commands/api/post.d.ts +1 -0
  6. package/dist/commands/api/post.js +3 -1
  7. package/dist/commands/api/put.d.ts +1 -0
  8. package/dist/commands/api/put.js +3 -1
  9. package/dist/commands/app/dev.js +2 -5
  10. package/dist/commands/app/frontend/dev.js +18 -0
  11. package/dist/commands/inspect/content.d.ts +25 -14
  12. package/dist/commands/inspect/content.js +34 -144
  13. package/dist/commands/inspect/functions.d.ts +48 -0
  14. package/dist/commands/inspect/functions.js +83 -0
  15. package/dist/commands/inspect/index.js +8 -7
  16. package/dist/commands/inspect/models.d.ts +17 -2
  17. package/dist/commands/inspect/models.js +114 -47
  18. package/dist/commands/inspect/notifications.d.ts +31 -0
  19. package/dist/commands/inspect/notifications.js +125 -0
  20. package/dist/commands/inspect/settings.d.ts +28 -0
  21. package/dist/commands/inspect/settings.js +82 -0
  22. package/dist/commands/inspect/webhooks.d.ts +34 -0
  23. package/dist/commands/inspect/webhooks.js +61 -0
  24. package/dist/create-app-command.d.ts +7 -0
  25. package/dist/create-app-command.js +66 -3
  26. package/dist/inspect-resource-command.d.ts +91 -0
  27. package/dist/inspect-resource-command.js +232 -0
  28. package/dist/lib/apps/index.d.ts +2 -1
  29. package/dist/lib/apps/index.js +43 -6
  30. package/dist/lib/apps/inspect-scope.d.ts +58 -0
  31. package/dist/lib/apps/inspect-scope.js +60 -0
  32. package/dist/lib/apps/object-id.d.ts +7 -0
  33. package/dist/lib/apps/object-id.js +9 -0
  34. package/dist/lib/apps/paths.js +8 -1
  35. package/dist/lib/apps/resolve.d.ts +16 -0
  36. package/dist/lib/apps/resolve.js +39 -0
  37. package/dist/lib/apps/slug.d.ts +29 -0
  38. package/dist/lib/apps/slug.js +12 -0
  39. package/dist/lib/create/tests/templates/mock-request.js +12 -1
  40. package/dist/lib/create/tests/templates/setup-globals.js +2 -0
  41. package/dist/lib/inspect/content.d.ts +39 -0
  42. package/dist/lib/inspect/content.js +76 -0
  43. package/dist/lib/inspect/notifications.d.ts +115 -0
  44. package/dist/lib/inspect/notifications.js +173 -0
  45. package/dist/lib/inspect/settings.d.ts +61 -0
  46. package/dist/lib/inspect/settings.js +56 -0
  47. package/dist/lib/inspect/table.d.ts +29 -0
  48. package/dist/lib/inspect/table.js +61 -0
  49. package/dist/lib/swell-function-wrapper.d.ts +7 -3
  50. package/dist/lib/swell-function-wrapper.js +53 -15
  51. package/dist/push-app-command.js +3 -2
  52. package/dist/swell-api-command.d.ts +2 -4
  53. package/dist/swell-api-command.js +58 -35
  54. package/oclif.manifest.json +364 -35
  55. package/package.json +1 -1
@@ -0,0 +1,61 @@
1
+ import { GroupInfo } from './table.js';
2
+ export interface SettingRecord {
3
+ api?: string;
4
+ app_id?: string | null;
5
+ deprecated?: boolean | null;
6
+ fields?: Record<string, unknown>;
7
+ id?: string;
8
+ label?: string;
9
+ name?: string;
10
+ }
11
+ /**
12
+ * Settings identifier grammar (calibrated against `/data/:settings`).
13
+ *
14
+ * `app.<slug-or-hex>` → kind 'app' — slug must be translated to hex before
15
+ * a path GET; hex passes through.
16
+ * anything else valid → kind 'path' — direct path GET. The endpoint resolves
17
+ * bare system names (`taxes`), bare app
18
+ * hexes, and full dotted ids
19
+ * (`com.taxes`) on the path. No `name`
20
+ * query needed.
21
+ * anything else → kind 'invalid'
22
+ *
23
+ * The 3-segment `app.<slug>.<name>` form is rejected: settings collapse to one
24
+ * record per app at push time, so there is no sub-section identity the API can
25
+ * resolve back. Users wanting a single section run `--json | jq`.
26
+ */
27
+ export type ParsedSettingsKey = {
28
+ kind: 'app';
29
+ ref: string;
30
+ } | {
31
+ kind: 'path';
32
+ segment: string;
33
+ } | {
34
+ input: string;
35
+ kind: 'invalid';
36
+ };
37
+ export declare function parseSettingsKey(input: string): ParsedSettingsKey;
38
+ /**
39
+ * Produce the column-1 paste-back key for a settings record.
40
+ *
41
+ * System (no app_id): `record.name` (e.g. `taxes`).
42
+ * App with slug: `app.<slug>`.
43
+ * App without slug: `app.<hex>` (still pasteable — `app.<hex>` resolves).
44
+ *
45
+ * App records carry `name === app_id` (the hex), which is meaningless to a
46
+ * human; we never surface it. Ultimate fallback is `record.id` for degenerate
47
+ * records that have neither `app_id` nor `name`.
48
+ */
49
+ export declare function formatSettingsKey(record: SettingRecord, appSlugById: Record<string, string>): string;
50
+ /**
51
+ * Group a settings record. System rows (`app_id` null/undefined) belong to the
52
+ * platform and render under a `system` divider at the top, mirroring the
53
+ * notifications shape.
54
+ */
55
+ export declare function groupSettingsRecord(record: SettingRecord, appSlugById: Record<string, string>): GroupInfo;
56
+ /**
57
+ * `general` and `admin` are flagged `deprecated: true` in the base schema and
58
+ * still come back from the default list (no server-side filter equivalent —
59
+ * `deprecated[$ne]=true` doesn't take). Filter them out client-side.
60
+ */
61
+ export declare function isDeprecatedRecord(record: SettingRecord): boolean;
@@ -0,0 +1,56 @@
1
+ const PATH_SEGMENT = /^[\w.-]+$/;
2
+ const APP_PREFIX = /^app\.([^.]+)$/;
3
+ export function parseSettingsKey(input) {
4
+ const appMatch = input.match(APP_PREFIX);
5
+ if (appMatch) {
6
+ return { kind: 'app', ref: appMatch[1] };
7
+ }
8
+ if (input.startsWith('app.')) {
9
+ return { kind: 'invalid', input };
10
+ }
11
+ if (PATH_SEGMENT.test(input)) {
12
+ return { kind: 'path', segment: input };
13
+ }
14
+ return { kind: 'invalid', input };
15
+ }
16
+ /**
17
+ * Produce the column-1 paste-back key for a settings record.
18
+ *
19
+ * System (no app_id): `record.name` (e.g. `taxes`).
20
+ * App with slug: `app.<slug>`.
21
+ * App without slug: `app.<hex>` (still pasteable — `app.<hex>` resolves).
22
+ *
23
+ * App records carry `name === app_id` (the hex), which is meaningless to a
24
+ * human; we never surface it. Ultimate fallback is `record.id` for degenerate
25
+ * records that have neither `app_id` nor `name`.
26
+ */
27
+ export function formatSettingsKey(record, appSlugById) {
28
+ if (!record.app_id) {
29
+ return record.name ?? record.id ?? '-';
30
+ }
31
+ const slug = appSlugById[record.app_id] ?? record.app_id;
32
+ return `app.${slug}`;
33
+ }
34
+ /**
35
+ * Group a settings record. System rows (`app_id` null/undefined) belong to the
36
+ * platform and render under a `system` divider at the top, mirroring the
37
+ * notifications shape.
38
+ */
39
+ export function groupSettingsRecord(record, appSlugById) {
40
+ if (!record.app_id) {
41
+ return { slug: '<system>', label: 'system', order: 0 };
42
+ }
43
+ const resolved = appSlugById[record.app_id];
44
+ if (!resolved) {
45
+ return { slug: '<not resolved>', order: 2 };
46
+ }
47
+ return { slug: resolved };
48
+ }
49
+ /**
50
+ * `general` and `admin` are flagged `deprecated: true` in the base schema and
51
+ * still come back from the default list (no server-side filter equivalent —
52
+ * `deprecated[$ne]=true` doesn't take). Filter them out client-side.
53
+ */
54
+ export function isDeprecatedRecord(record) {
55
+ return record.deprecated === true;
56
+ }
@@ -0,0 +1,29 @@
1
+ export interface GroupInfo {
2
+ /** Grouping identity. Rows with the same slug land in the same section. */
3
+ slug: string;
4
+ /** Divider text. Defaults to `slug` when omitted. */
5
+ label?: string;
6
+ /** Sort priority; lower renders first. Ties preserve insertion order. Default 1. */
7
+ order?: number;
8
+ }
9
+ export interface KeyMetaRow {
10
+ /** Paste-back identifier rendered as column 1. */
11
+ key: string;
12
+ /** Optional compact status string. Empty or undefined renders the key alone. */
13
+ meta?: string;
14
+ /** Optional group; when some rows carry one, multiple groups produce dividers. */
15
+ group?: GroupInfo;
16
+ }
17
+ /**
18
+ * Render a two-column list: paste-back key + optional compact meta string.
19
+ *
20
+ * - Rows with meta render as ` <key-padded> <meta>`.
21
+ * - Rows without meta render as ` <key>` with no trailing whitespace.
22
+ * - Key column width is computed globally across all rows so meta columns
23
+ * align vertically.
24
+ * - Rows with a `group` are collected into sections; sections are separated
25
+ * by a blank line and a `── <label>` divider. Sections sort by `order`
26
+ * (lower first), ties preserve insertion order. A single group collapses
27
+ * to a flat listing.
28
+ */
29
+ export declare function renderKeyMetaTable(rows: KeyMetaRow[]): string[];
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Render a two-column list: paste-back key + optional compact meta string.
3
+ *
4
+ * - Rows with meta render as ` <key-padded> <meta>`.
5
+ * - Rows without meta render as ` <key>` with no trailing whitespace.
6
+ * - Key column width is computed globally across all rows so meta columns
7
+ * align vertically.
8
+ * - Rows with a `group` are collected into sections; sections are separated
9
+ * by a blank line and a `── <label>` divider. Sections sort by `order`
10
+ * (lower first), ties preserve insertion order. A single group collapses
11
+ * to a flat listing.
12
+ */
13
+ export function renderKeyMetaTable(rows) {
14
+ if (rows.length === 0) {
15
+ return [];
16
+ }
17
+ const keyWidth = Math.max(...rows.map((r) => r.key.length));
18
+ const formatRow = (row) => {
19
+ if (row.meta && row.meta.length > 0) {
20
+ return ` ${row.key.padEnd(keyWidth)} ${row.meta}`;
21
+ }
22
+ return ` ${row.key}`;
23
+ };
24
+ const hasGroups = rows.some((r) => r.group !== undefined);
25
+ if (!hasGroups) {
26
+ return rows.map((row) => formatRow(row));
27
+ }
28
+ const groupMap = new Map();
29
+ for (const row of rows) {
30
+ const info = row.group;
31
+ if (!info)
32
+ continue;
33
+ const existing = groupMap.get(info.slug);
34
+ if (existing) {
35
+ existing.rows.push(row);
36
+ }
37
+ else {
38
+ groupMap.set(info.slug, {
39
+ slug: info.slug,
40
+ label: info.label ?? info.slug,
41
+ order: info.order ?? 1,
42
+ rows: [row],
43
+ });
44
+ }
45
+ }
46
+ if (groupMap.size === 1) {
47
+ return rows.map((row) => formatRow(row));
48
+ }
49
+ const ordered = [...groupMap.values()].sort((a, b) => a.order - b.order);
50
+ const lines = [];
51
+ for (const group of ordered) {
52
+ if (lines.length > 0) {
53
+ lines.push('');
54
+ }
55
+ lines.push(`── ${group.label}`);
56
+ for (const row of group.rows) {
57
+ lines.push(formatRow(row));
58
+ }
59
+ }
60
+ return lines;
61
+ }
@@ -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: {};
@@ -106,9 +107,10 @@ declare class SwellRequest {
106
107
  * Merge values into app data for the current request.
107
108
  * @param {object|string} idOrValues string to indicate app ID, or values to merge
108
109
  * @param {object|undefined} values values to merge into app data
109
- * @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
110
112
  */
111
- appValues(idOrValues: object | string, values?: object | undefined): object | undefined;
113
+ appValues(idOrValues: object | string, values?: object | undefined): object;
112
114
  }
113
115
  /**
114
116
  * Class representing the Swell backend API.
@@ -134,12 +136,14 @@ declare class SwellAPI {
134
136
  declare class SwellError extends Error {
135
137
  constructor(message: any, options?: {});
136
138
  status: any;
139
+ body: any;
137
140
  }
138
141
  /**
139
142
  * Class representing a Swell response.
140
143
  */
141
144
  declare class SwellResponse extends Response {
142
- 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>;
143
147
  static _respondWithLogs(response: any, req: any): SwellResponse;
144
148
  constructor(data: any, options?: {});
145
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) {
@@ -202,13 +208,17 @@ class SwellRequest {
202
208
  * Merge values into app data for the current request.
203
209
  * @param {object|string} idOrValues string to indicate app ID, or values to merge
204
210
  * @param {object|undefined} values values to merge into app data
205
- * @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
206
213
  */
207
214
  appValues(idOrValues, values = undefined) {
208
- const appId = typeof idOrValues === 'string' ? appIdOrValues : this.appId;
215
+ const appId = typeof idOrValues === 'string' ? idOrValues : this.appId;
209
216
  const appValues = typeof idOrValues === 'string' ? values : idOrValues;
210
- if (!appId || !isOrdinaryObject(appValues)) {
211
- 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)');
212
222
  }
213
223
  return {
214
224
  $app: {
@@ -316,6 +326,7 @@ class SwellAPI {
316
326
  */
317
327
  class SwellError extends Error {
318
328
  constructor(message, options = {}) {
329
+ const body = typeof message === 'string' ? undefined : message;
319
330
  let formattedMessage;
320
331
  if (typeof message === 'string') {
321
332
  formattedMessage = message;
@@ -329,6 +340,7 @@ class SwellError extends Error {
329
340
  super(formattedMessage);
330
341
  this.name = 'SwellError';
331
342
  this.status = options.status || 500;
343
+ this.body = body;
332
344
  }
333
345
  }
334
346
  /**
@@ -358,27 +370,53 @@ class SwellResponse extends Response {
358
370
  this._swellData = data;
359
371
  this._swellOptions = options || {};
360
372
  }
361
- static _respond(req, response, context) {
373
+ static async _respond(req, response, context) {
362
374
  let finalResponse = response;
363
- // Convert a plain Response instance to SwellResponse
375
+ const isHook = Boolean(req.data?.$event?.hook);
364
376
  if (finalResponse instanceof Response &&
365
377
  !(finalResponse instanceof SwellResponse)) {
366
- finalResponse = new SwellResponse(response.body, {
367
- status: response.status,
368
- headers: response.headers,
369
- });
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
+ }
370
385
  }
371
386
  else if (!(finalResponse instanceof SwellResponse)) {
372
387
  finalResponse = new SwellResponse(response);
373
388
  }
374
389
  // Send logs back with the response for event hooks
375
- if (req.data?.$event?.hook) {
390
+ if (isHook) {
376
391
  return SwellResponse._respondWithLogs(finalResponse, req);
377
392
  }
378
393
  // Ingest logs in the background
379
394
  context.waitUntil(req.ingestLogs(finalResponse));
380
395
  return finalResponse;
381
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
+ }
382
420
  static _respondWithLogs(response, req) {
383
421
  const ingestableLogs = req.getIngestableLogs(response);
384
422
  // Rebuild response with logs
@@ -6,8 +6,9 @@ import * as path from 'node:path';
6
6
  import Stream from 'node:stream';
7
7
  import ora from 'ora';
8
8
  import { default as swellConfig } from './lib/app-config.js';
9
- import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExists, filePathExistsAsync, findAppConfig, getAppSlugId, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashString, isPathDirectory, } from './lib/apps/index.js';
9
+ import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExists, filePathExistsAsync, findAppConfig, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashString, isPathDirectory, } from './lib/apps/index.js';
10
10
  import { getGlobIgnorePathsChecker } from './lib/apps/paths.js';
11
+ import { slugFromApp } from './lib/apps/slug.js';
11
12
  import { default as localConfig } from './lib/config.js';
12
13
  import { toAppId } from './lib/create/index.js';
13
14
  import { detectPackageManager, transformCommand, } from './lib/package-manager.js';
@@ -139,7 +140,7 @@ export class PushAppCommand extends RemoteAppCommand {
139
140
  }
140
141
  const appSlugId = (await select({
141
142
  choices: apps.results.map((app) => ({
142
- name: `${style.appConfigValue(app.name)} (${getAppSlugId(app)})`,
143
+ name: `${style.appConfigValue(app.name)} (${slugFromApp(app) ?? app.id})`,
143
144
  value: app.private_id,
144
145
  })),
145
146
  message: `Choose ${typeLabelPrefixed} to pull`,
@@ -1,14 +1,11 @@
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>;
6
7
  protected catch(error: Error): Promise<any>;
7
8
  private parseCommand;
8
- /**
9
- * Resolve app ObjectId from a friendly slug or return as-is if already an ObjectId.
10
- */
11
- private resolveAppId;
12
9
  /**
13
10
  * Resolve function ID from app ID and function name.
14
11
  */
@@ -18,6 +15,7 @@ export declare abstract class SwellApiCommand extends SwellCommand {
18
15
  * Build a function invocation request via the admin /:functions endpoint.
19
16
  */
20
17
  private buildFunctionCallRequest;
18
+ private parseHeaders;
21
19
  private parseQueryString;
22
20
  private processBody;
23
21
  private isFilePath;
@@ -1,12 +1,21 @@
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';
4
5
  import { HttpMethod } from './lib/api.js';
6
+ import { resolveAppId } from './lib/apps/resolve.js';
5
7
  import { SwellCommand } from './swell-command.js';
6
8
  // Pattern to match /functions/{appId}/{functionName} with optional query string
7
9
  const FUNCTION_PATH_REGEX = /^\/functions\/([^/]+)\/([^/?]+)(\?.*)?$/;
8
10
  // Pattern to match /functions/{functionId} with optional query string
9
11
  const FUNCTION_DIRECT_REGEX = /^\/functions\/([^/?]+)(\?.*)?$/;
12
+ // Shared --header / -H flag for function-call paths. Pass once per header.
13
+ // Only forwarded as $call.headers on /functions/* paths; ignored elsewhere.
14
+ export const headerFlag = Flags.string({
15
+ char: 'H',
16
+ description: "HTTP header to forward to a function (format: 'Name: value'). Repeat for multiple. Only applies to /functions/* paths.",
17
+ multiple: true,
18
+ });
10
19
  export class SwellApiCommand extends SwellCommand {
11
20
  async request(command, requestOptions = {}) {
12
21
  const { paths, options, methodOverride } = await this.parseCommand(command);
@@ -29,7 +38,7 @@ export class SwellApiCommand extends SwellCommand {
29
38
  const parsedInput = await super.parse(options, argv);
30
39
  const { args, flags } = parsedInput;
31
40
  const { path: requestPath } = args;
32
- const { live, api, body } = flags;
41
+ const { live, api, body, header } = flags;
33
42
  const isFrontendAPI = api === 'frontend';
34
43
  if (!live) {
35
44
  await this.api.setEnv('test');
@@ -41,21 +50,30 @@ export class SwellApiCommand extends SwellCommand {
41
50
  throw new Error('Path must start with a forward slash (/)');
42
51
  }
43
52
  const processedBody = await this.processBody(body);
53
+ const callHeaders = this.parseHeaders(header);
44
54
  // Check if this is a function call by name: /functions/{appId}/{functionName}
45
55
  // Must check this first (more specific pattern)
46
56
  const functionMatch = requestPath.match(FUNCTION_PATH_REGEX);
47
57
  if (functionMatch) {
48
58
  const [, appId, functionName, queryString] = functionMatch;
49
59
  const functionId = await this.resolveFunctionId(appId, functionName);
50
- const queryParams = this.parseQueryString(queryString);
51
- return this.buildFunctionCallRequest(parsedInput, functionId, processedBody, queryParams);
60
+ return this.buildFunctionCallRequest(parsedInput, {
61
+ functionId,
62
+ body: processedBody,
63
+ query: this.parseQueryString(queryString),
64
+ headers: callHeaders,
65
+ });
52
66
  }
53
67
  // Check if this is a direct function call: /functions/{functionId}
54
68
  const directMatch = requestPath.match(FUNCTION_DIRECT_REGEX);
55
69
  if (directMatch) {
56
70
  const [, functionId, queryString] = directMatch;
57
- const queryParams = this.parseQueryString(queryString);
58
- return this.buildFunctionCallRequest(parsedInput, functionId, processedBody, queryParams);
71
+ return this.buildFunctionCallRequest(parsedInput, {
72
+ functionId,
73
+ body: processedBody,
74
+ query: this.parseQueryString(queryString),
75
+ headers: callHeaders,
76
+ });
59
77
  }
60
78
  const paths = isFrontendAPI
61
79
  ? { frontendPath: requestPath }
@@ -68,28 +86,11 @@ export class SwellApiCommand extends SwellCommand {
68
86
  },
69
87
  };
70
88
  }
71
- /**
72
- * Resolve app ObjectId from a friendly slug or return as-is if already an ObjectId.
73
- */
74
- async resolveAppId(appIdOrSlug) {
75
- // If it looks like an ObjectId (24 hex chars), return as-is
76
- if (/^[\da-f]{24}$/i.test(appIdOrSlug)) {
77
- return appIdOrSlug;
78
- }
79
- // Fetch all installed apps and filter by public_id or private_id client-side
80
- const installedApps = await this.api.get({ adminPath: `/client/apps` });
81
- const app = installedApps?.results?.find((a) => a.app_public_id === appIdOrSlug ||
82
- a.app_private_id === `_${appIdOrSlug}`);
83
- if (!app) {
84
- throw new Error(`App '${appIdOrSlug}' not found`);
85
- }
86
- return app.app_id;
87
- }
88
89
  /**
89
90
  * Resolve function ID from app ID and function name.
90
91
  */
91
92
  async resolveFunctionId(appIdOrSlug, functionName) {
92
- const appId = await this.resolveAppId(appIdOrSlug);
93
+ const appId = await resolveAppId(this.api, appIdOrSlug);
93
94
  const functionRecord = await this.api.get({ adminPath: `/data/:functions` }, {
94
95
  query: {
95
96
  app_id: appId,
@@ -108,27 +109,49 @@ export class SwellApiCommand extends SwellCommand {
108
109
  /**
109
110
  * Build a function invocation request via the admin /:functions endpoint.
110
111
  */
111
- buildFunctionCallRequest(parsedInput, functionId, bodyData, queryParams) {
112
+ buildFunctionCallRequest(parsedInput, call) {
112
113
  // 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
- },
114
+ // Only merge if body is a plain object; otherwise use body or query alone
115
+ const mergedData = this.isPlainObject(call.body)
116
+ ? { ...call.query, ...call.body }
117
+ : call.body ?? call.query;
118
+ const $call = {
119
+ data: mergedData,
120
+ method: this.method,
122
121
  };
122
+ if (Object.keys(call.headers).length > 0) {
123
+ $call.headers = call.headers;
124
+ }
123
125
  return {
124
126
  ...parsedInput,
125
- paths: { adminPath: `/data/:functions/${functionId}` },
127
+ paths: { adminPath: `/data/:functions/${call.functionId}` },
126
128
  options: {
127
- body: callBody,
129
+ body: { $call },
128
130
  },
129
131
  methodOverride: HttpMethod.PUT,
130
132
  };
131
133
  }
134
+ // Parse repeated `-H "Name: value"` flag entries into a map. Splits on first colon
135
+ // so values containing colons (e.g. `Authorization: Bearer x:y`) survive intact.
136
+ parseHeaders(headerArgs) {
137
+ if (!headerArgs?.length) {
138
+ return {};
139
+ }
140
+ const result = {};
141
+ for (const entry of headerArgs) {
142
+ const colonIndex = entry.indexOf(':');
143
+ if (colonIndex < 1) {
144
+ throw new Error(`Invalid header '${entry}'. Expected format: 'Name: value'.`);
145
+ }
146
+ const name = entry.slice(0, colonIndex).trim();
147
+ const value = entry.slice(colonIndex + 1).trim();
148
+ if (!name) {
149
+ throw new Error(`Invalid header '${entry}'. Header name cannot be empty.`);
150
+ }
151
+ result[name] = value;
152
+ }
153
+ return result;
154
+ }
132
155
  parseQueryString(queryString) {
133
156
  if (!queryString) {
134
157
  return {};