@swell/cli 2.9.30 → 2.9.32

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.
@@ -280,7 +280,11 @@ export default class CustomHelp extends Help {
280
280
  const value = ('helpValue' in flag && flag.helpValue) ||
281
281
  ('options' in flag && flag.options ? 'option' : 'value');
282
282
  const suffix = flag.type === 'boolean' ? '' : `=<${value}>`;
283
- return [name, `${shortChar}--${name}${suffix}`];
283
+ const negatable = flag.type === 'boolean' && flag.allowNo;
284
+ return [
285
+ name,
286
+ `${shortChar}--${negatable ? '[no-]' : ''}${name}${suffix}`,
287
+ ];
284
288
  }));
285
289
  const flagWidth = Math.max(30, ...[...flagLabels.values()].map((label) => label.length));
286
290
  for (const [name, flag] of flagEntries) {
@@ -0,0 +1,45 @@
1
+ type Values = Record<string, any>;
2
+ export type ActionSource = 'bulk' | 'field' | 'list' | 'record' | 'settings';
3
+ /** An app function action, with what the dashboard sends to run it */
4
+ export interface DeclaredAction {
5
+ function: string;
6
+ id: string;
7
+ modal?: Values;
8
+ /** The query of the view whose records a bulk action runs on */
9
+ query?: Values;
10
+ /** The name of the settings config, for a settings action or action field */
11
+ settings?: string;
12
+ source: ActionSource;
13
+ view?: string;
14
+ }
15
+ /**
16
+ * The function actions a content config declares.
17
+ *
18
+ * @param values the values of the content config
19
+ * @returns the actions
20
+ */
21
+ export declare function getContentActions(values: Values): DeclaredAction[];
22
+ /**
23
+ * The function actions a settings config declares.
24
+ *
25
+ * @param values the values of the settings config
26
+ * @param settings the name of the settings config
27
+ * @returns the actions
28
+ */
29
+ export declare function getSettingsActions(values: Values, settings: string): DeclaredAction[];
30
+ /**
31
+ * The ids of the fields of an action's modal, as the platform finds them to
32
+ * pass on only their values.
33
+ *
34
+ * @param modal the modal of the action
35
+ * @returns the field ids
36
+ */
37
+ export declare function getModalFieldIds(modal: Values): string[];
38
+ /**
39
+ * A function name as the platform compares an action's `function` with it.
40
+ *
41
+ * @param name a function name
42
+ * @returns the name in the form the platform compares
43
+ */
44
+ export declare function functionSlug(name: string): string;
45
+ export {};
@@ -0,0 +1,114 @@
1
+ function isFunctionAction(item) {
2
+ return (typeof item?.id === 'string' &&
3
+ typeof item?.function === 'string');
4
+ }
5
+ // The dashboard renders the fields of rows and groups in place
6
+ function flattenFields(fields) {
7
+ if (!Array.isArray(fields)) {
8
+ return [];
9
+ }
10
+ return fields.flatMap((field) => field?.type === 'field_row' || field?.type === 'field_group'
11
+ ? flattenFields(field.fields)
12
+ : [field]);
13
+ }
14
+ function getActionFields(fields) {
15
+ return flattenFields(fields).filter((field) => field?.type === 'action');
16
+ }
17
+ /**
18
+ * The function actions a content config declares.
19
+ *
20
+ * @param values the values of the content config
21
+ * @returns the actions
22
+ */
23
+ export function getContentActions(values) {
24
+ const views = Array.isArray(values?.views) ? values.views : [];
25
+ const actions = [];
26
+ for (const view of views) {
27
+ const source = view.type || (view.id === 'list' ? 'list' : 'record');
28
+ for (const action of [
29
+ ...(view.actions || []),
30
+ ...(view.extra_actions || []),
31
+ ]) {
32
+ if (isFunctionAction(action)) {
33
+ actions.push({ ...pickAction(action), source, view: view.id });
34
+ }
35
+ }
36
+ for (const action of view.bulk_actions || []) {
37
+ if (isFunctionAction(action)) {
38
+ actions.push({
39
+ ...pickAction(action),
40
+ ...(view.query ? { query: view.query } : undefined),
41
+ source: 'bulk',
42
+ view: view.id,
43
+ });
44
+ }
45
+ }
46
+ }
47
+ const fields = [
48
+ ...(values?.fields || []),
49
+ ...views.flatMap((view) => [
50
+ ...(view.fields || []),
51
+ ...(view.tabs || []).flatMap((tab) => tab?.fields || []),
52
+ ]),
53
+ ];
54
+ for (const field of getActionFields(fields)) {
55
+ if (isFunctionAction(field)) {
56
+ actions.push({ ...pickAction(field), source: 'field' });
57
+ }
58
+ }
59
+ return actions;
60
+ }
61
+ /**
62
+ * The function actions a settings config declares.
63
+ *
64
+ * @param values the values of the settings config
65
+ * @param settings the name of the settings config
66
+ * @returns the actions
67
+ */
68
+ export function getSettingsActions(values, settings) {
69
+ const actions = [];
70
+ for (const action of values?.actions || []) {
71
+ if (isFunctionAction(action)) {
72
+ actions.push({ ...pickAction(action), settings, source: 'settings' });
73
+ }
74
+ }
75
+ for (const field of getActionFields(values?.fields)) {
76
+ if (isFunctionAction(field)) {
77
+ actions.push({ ...pickAction(field), settings, source: 'field' });
78
+ }
79
+ }
80
+ return actions;
81
+ }
82
+ /**
83
+ * The ids of the fields of an action's modal, as the platform finds them to
84
+ * pass on only their values.
85
+ *
86
+ * @param modal the modal of the action
87
+ * @returns the field ids
88
+ */
89
+ export function getModalFieldIds(modal) {
90
+ return flattenFields(modal.fields)
91
+ .map((field) => field?.id)
92
+ .filter((id) => typeof id === 'string');
93
+ }
94
+ /**
95
+ * A function name as the platform compares an action's `function` with it.
96
+ *
97
+ * @param name a function name
98
+ * @returns the name in the form the platform compares
99
+ */
100
+ export function functionSlug(name) {
101
+ return name
102
+ .replaceAll(/[^\s\w./:-]/g, '')
103
+ .replaceAll(/[\s./:_-]+/g, '-')
104
+ .replaceAll(/^-+|-+$/g, '')
105
+ .replaceAll(/([a-z])([A-Z])/g, '$1-$2')
106
+ .toLowerCase();
107
+ }
108
+ function pickAction(action) {
109
+ return {
110
+ function: action.function,
111
+ id: action.id,
112
+ ...(action.modal ? { modal: action.modal } : undefined),
113
+ };
114
+ }
@@ -0,0 +1 @@
1
+ export declare function warnListingSources(appPath: string, config: Record<string, any>, warn: (message: string) => void): void;
@@ -0,0 +1,41 @@
1
+ import { statSync } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ const LISTING_SOURCE_FIELDS = ['logo_src', 'preview_src', 'preview_mobile_src'];
4
+ const LISTING_SOURCE_LISTS = ['images', 'highlights'];
5
+ // The listing takes its full description from DESCRIPTION.md and each image
6
+ // from the asset whose path from the app root is the swell.json value as
7
+ // written, so values that point elsewhere do nothing. The platform takes an
8
+ // empty value as unset.
9
+ export function warnListingSources(appPath, config, warn) {
10
+ if (config.full_description !== undefined) {
11
+ warn('swell.json sets full_description, which push does not apply: the full description is the content of DESCRIPTION.md at the app root.');
12
+ }
13
+ const sources = [
14
+ ...LISTING_SOURCE_FIELDS.map((field) => [
15
+ field,
16
+ config[field],
17
+ ]),
18
+ ...LISTING_SOURCE_LISTS.flatMap((list) => (Array.isArray(config[list]) ? config[list] : []).map((item, index) => [
19
+ `${list}[${index}].image_src`,
20
+ item?.image_src,
21
+ ])),
22
+ ];
23
+ for (const [field, source] of sources) {
24
+ if (typeof source !== 'string' || source === '') {
25
+ continue;
26
+ }
27
+ const filePath = path.posix.normalize(source.replaceAll('\\', '/'));
28
+ const stats = statSync(path.join(appPath, filePath), {
29
+ throwIfNoEntry: false,
30
+ });
31
+ if (!stats?.isFile()) {
32
+ warn(`swell.json ${field} names ${source}, which the app does not have: the listing gets no image from it.`);
33
+ }
34
+ else if (!filePath.startsWith('assets/')) {
35
+ warn(`swell.json ${field} names ${source}, which is not in assets/: the listing gets no image from it. Move the file to assets/ and write assets/${path.posix.basename(filePath)}.`);
36
+ }
37
+ else if (filePath !== source) {
38
+ warn(`swell.json ${field} names ${source}, which is not the file's path from the app root: the listing gets no image from it. Write ${filePath}.`);
39
+ }
40
+ }
41
+ }
@@ -60,7 +60,7 @@ export async function bundleFunction(filePath) {
60
60
  }
61
61
  export async function bundleWorkflow(filePath, analysis) {
62
62
  try {
63
- const entrySource = getSwellWorkflowEntrypoint(filePath);
63
+ const entrySource = await getSwellWorkflowEntrypoint(filePath);
64
64
  const buildResult = await esbuild.build({
65
65
  bundle: true,
66
66
  stdin: {
@@ -130,13 +130,22 @@ async function getSwellFunctionWrapper() {
130
130
  const __dirname = path.dirname(__filename);
131
131
  return fs.readFile(path.join(__dirname, './swell-function-wrapper.js'), 'utf8');
132
132
  }
133
- function getSwellWorkflowEntrypoint(filePath) {
133
+ // The entry defines these functions itself, so the bundle names no file of
134
+ // the CLI and the workflow module exports only its entrypoint
135
+ async function getSwellWorkflowRequest() {
136
+ const source = await fs.readFile(path.join(path.dirname(fileURLToPath(import.meta.url)), './swell-workflow-request.js'), 'utf8');
137
+ return source.replaceAll(/^export /gm, '');
138
+ }
139
+ async function getSwellWorkflowEntrypoint(filePath) {
134
140
  const workflowImport = `./${path.basename(filePath)}`;
141
+ const workflowRequest = await getSwellWorkflowRequest();
135
142
  return `
136
143
  import { WorkflowEntrypoint } from 'cloudflare:workers';
137
144
  import { NonRetryableError } from 'cloudflare:workflows';
138
145
  import UserWorkflow from ${JSON.stringify(workflowImport)};
139
146
 
147
+ ${workflowRequest}
148
+
140
149
  const RUNTIME_PROXY_PATH = '/:workflows/runtime/request';
141
150
  const RUNTIME_TOKEN_PATTERN = /\\bwf_rt_[0-9A-Za-z]+_[0-9A-Za-z]{12,}\\b/g;
142
151
 
@@ -210,38 +219,6 @@ function getInvocation(event) {
210
219
  return payload;
211
220
  }
212
221
 
213
- function buildWorkflowRequest(invocation, proxy) {
214
- const runtime = invocation.runtime;
215
-
216
- return {
217
- id: runtime.request_id,
218
- appId: runtime.app_slug || runtime.app_id,
219
- environmentId: runtimeEnvironmentId(runtime),
220
- store: {
221
- id: runtime.store_id,
222
- },
223
- app: {
224
- admin_url: runtime.app_admin_url,
225
- },
226
- data: invocation.data,
227
- workflow: {
228
- workflow_id: runtime.workflow_id,
229
- workflow_name: runtime.workflow_name,
230
- workflow_instance_id: runtime.workflow_instance_id,
231
- trigger: runtime.trigger,
232
- request_id: runtime.request_id,
233
- },
234
- isLocalDev: false,
235
- swell: {
236
- get: (path, data) => proxy.api('get', path, data),
237
- post: (path, data) => proxy.api('post', path, data),
238
- put: (path, data) => proxy.api('put', path, data),
239
- delete: (path, data) => proxy.api('delete', path, data),
240
- settings: () => proxy.settings(),
241
- },
242
- };
243
- }
244
-
245
222
  function buildWorkflowStep(cloudflareStep, proxy) {
246
223
  return Object.freeze({
247
224
  do(name, optionsOrCallback, maybeCallback) {
@@ -361,11 +338,6 @@ function getRuntimeApiHost(runtime) {
361
338
  return apiHost.replace(/\\/$/, '');
362
339
  }
363
340
 
364
- // 'test' in the test environment, null in live
365
- function runtimeEnvironmentId(runtime) {
366
- return runtime.environment_id || null;
367
- }
368
-
369
341
  function runtimeIdentity(runtime) {
370
342
  return {
371
343
  app_id: runtime.app_id,
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The types an app's functions type-check against: the app's own
3
+ * @swell/app-types when it has the package, otherwise the CLI's.
4
+ *
5
+ * @param directory a directory of the app
6
+ * @returns the declarations, after a line naming the package version
7
+ */
8
+ export declare function getFunctionDeclarations(directory: string): Promise<string>;
@@ -0,0 +1,27 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import * as path from 'node:path';
4
+ /**
5
+ * The types an app's functions type-check against: the app's own
6
+ * @swell/app-types when it has the package, otherwise the CLI's.
7
+ *
8
+ * @param directory a directory of the app
9
+ * @returns the declarations, after a line naming the package version
10
+ */
11
+ export async function getFunctionDeclarations(directory) {
12
+ const file = resolveAppTypes(path.join(directory, 'package.json')) ??
13
+ resolveAppTypes(import.meta.url);
14
+ if (!file) {
15
+ throw new Error('@swell/app-types is not installed');
16
+ }
17
+ const { version } = JSON.parse(await fs.readFile(path.join(path.dirname(file), 'package.json'), 'utf8'));
18
+ return `// @swell/app-types ${version}\n${await fs.readFile(file, 'utf8')}`;
19
+ }
20
+ function resolveAppTypes(from) {
21
+ try {
22
+ return createRequire(from).resolve('@swell/app-types/index.d.ts');
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
@@ -30,7 +30,13 @@ async function request(originalRequest, _env, context) {
30
30
  ...(error.body ?? { error: error.message }),
31
31
  retry: error.retry === false ? false : undefined,
32
32
  };
33
- response = new SwellResponse(errorBody, { status: error.status || 500 });
33
+ const retryAfter = Number(error.retryAfter);
34
+ response = new SwellResponse(errorBody, {
35
+ status: error.status || 500,
36
+ ...(retryAfter > 0
37
+ ? { headers: { 'Retry-After': String(Math.ceil(retryAfter)) } }
38
+ : {}),
39
+ });
34
40
  }
35
41
  return SwellResponse._respond(req, response, context);
36
42
  }
@@ -447,6 +453,7 @@ class SwellError extends Error {
447
453
  this.body = body;
448
454
  this.code = options.code || body?.error?.code;
449
455
  this.retry = options.retry;
456
+ this.retryAfter = options.retryAfter;
450
457
  }
451
458
  get isRetryable() {
452
459
  return RETRYABLE_CODES.has(this.code);
@@ -0,0 +1,34 @@
1
+ export function runtimeEnvironmentId(runtime: any): any;
2
+ export function buildWorkflowRequest(invocation: any, proxy: any): {
3
+ id: any;
4
+ appId: any;
5
+ environmentId: any;
6
+ store: {
7
+ id: any;
8
+ };
9
+ app: {
10
+ admin_url: any;
11
+ frontend_url: any;
12
+ };
13
+ data: any;
14
+ workflow: {
15
+ workflow_id: any;
16
+ workflow_name: any;
17
+ workflow_instance_id: any;
18
+ trigger: any;
19
+ request_id: any;
20
+ };
21
+ isLocalDev: boolean;
22
+ swell: {
23
+ get: (path: any, data: any) => any;
24
+ post: (path: any, data: any) => any;
25
+ put: (path: any, data: any) => any;
26
+ delete: (path: any, data: any) => any;
27
+ settings: () => any;
28
+ };
29
+ appValues(idOrValues: any, values: any): {
30
+ $app: {
31
+ [x: number]: any;
32
+ };
33
+ };
34
+ };
@@ -0,0 +1,58 @@
1
+ // The request a workflow's run(req, step) gets, built from the invocation the
2
+ // platform starts the run with. Every workflow bundle includes this module.
3
+ // 'test' in the test environment, null in live
4
+ export function runtimeEnvironmentId(runtime) {
5
+ return runtime.environment_id || null;
6
+ }
7
+ export function buildWorkflowRequest(invocation, proxy) {
8
+ const { data, runtime } = invocation;
9
+ const appId = runtime.app_slug || runtime.app_id;
10
+ return {
11
+ id: runtime.request_id,
12
+ appId,
13
+ environmentId: runtimeEnvironmentId(runtime),
14
+ store: {
15
+ id: runtime.store_id,
16
+ },
17
+ app: {
18
+ admin_url: runtime.app_admin_url,
19
+ frontend_url: runtime.app_frontend_url,
20
+ },
21
+ data,
22
+ workflow: {
23
+ workflow_id: runtime.workflow_id,
24
+ workflow_name: runtime.workflow_name,
25
+ workflow_instance_id: runtime.workflow_instance_id,
26
+ trigger: runtime.trigger,
27
+ request_id: runtime.request_id,
28
+ },
29
+ isLocalDev: false,
30
+ swell: {
31
+ get: (path, data) => proxy.api('get', path, data),
32
+ post: (path, data) => proxy.api('post', path, data),
33
+ put: (path, data) => proxy.api('put', path, data),
34
+ delete: (path, data) => proxy.api('delete', path, data),
35
+ settings: () => proxy.settings(),
36
+ },
37
+ appValues(idOrValues, values) {
38
+ const targetAppId = typeof idOrValues === 'string' ? idOrValues : appId;
39
+ const appValues = typeof idOrValues === 'string' ? values : idOrValues;
40
+ if (!targetAppId) {
41
+ throw new Error('appValues: missing app id (req.appId is empty)');
42
+ }
43
+ if (!isOrdinaryObject(appValues)) {
44
+ throw new Error('appValues: values must be a plain object (arrays, class instances, null, and primitives are not allowed)');
45
+ }
46
+ return {
47
+ $app: {
48
+ [targetAppId]: appValues,
49
+ },
50
+ };
51
+ },
52
+ };
53
+ }
54
+ function isOrdinaryObject(val) {
55
+ return (typeof val === 'object' &&
56
+ val !== null &&
57
+ Object.getPrototypeOf(val) === Object.prototype);
58
+ }
@@ -21,13 +21,14 @@ export declare abstract class PushAppCommand extends RemoteAppCommand {
21
21
  watchingIgnoreFilter: GlobbyFilterFunction | null;
22
22
  watchingTimer: NodeJS.Timeout | null;
23
23
  watchListener: (eventType: fs.WatchEventType, filename: null | string) => Promise<void>;
24
+ protected pushesSwellJson(file?: string): boolean;
24
25
  protected showFrontendMigrationError(): never;
25
26
  buildFrontend(projectType?: FrontendProjectType): Promise<void>;
26
27
  chooseAppToPull(query?: any): Promise<App>;
27
28
  createAppStorefront(hasOtherStorefronts?: boolean, nonInteractive?: boolean): Promise<any>;
28
29
  deployAppFrontend(force?: boolean, log?: boolean): Promise<void>;
29
30
  deployFrontend(projectType: FrontendProjectType): Promise<string>;
30
- ensureAppExists(file?: string, shouldCreate?: boolean, force?: boolean): Promise<boolean>;
31
+ ensureAppExists(file?: string, shouldCreate?: boolean): Promise<boolean>;
31
32
  ensureLoggedIn(): Promise<void>;
32
33
  exec(command: string, cwd?: string, onOutput?: (string: string) => any | false): Promise<void>;
33
34
  execFrontend(command: string, onOutput?: (string: string) => any | false): Promise<void>;
@@ -10,6 +10,7 @@ import { componentsToRebuild } from './lib/apps/components.js';
10
10
  import { buildFrontendPackage, getManagedFrontendFramework, } from './lib/apps/frontend-package.js';
11
11
  import { warnFrontendRouteCollisions } from './lib/apps/frontend-routes.js';
12
12
  import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExistsAsync, findAppConfig, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashFile, hashString, isPathDirectory, toPosixPath, } from './lib/apps/index.js';
13
+ import { warnListingSources } from './lib/apps/listing.js';
13
14
  import { getGlobIgnorePathsChecker } from './lib/apps/paths.js';
14
15
  import { slugFromApp } from './lib/apps/slug.js';
15
16
  import { getLocalInputs } from './lib/bundle.js';
@@ -119,6 +120,10 @@ export class PushAppCommand extends RemoteAppCommand {
119
120
  }
120
121
  }
121
122
  };
123
+ // The platform applies the listing in swell.json when the file is pushed
124
+ pushesSwellJson(file) {
125
+ return !file;
126
+ }
122
127
  showFrontendMigrationError() {
123
128
  return this.error(style.funcWarn(`⚠️ Your frontend directory exists but appears to use an older structure.\n` +
124
129
  `Swell CLI v2.1.0+ requires a Workers-based frontend with package.json.\n\n` +
@@ -330,7 +335,7 @@ export class PushAppCommand extends RemoteAppCommand {
330
335
  this.error(`Error deploying frontend: ${error.message}`);
331
336
  }
332
337
  }
333
- async ensureAppExists(file, shouldCreate = true, force = false) {
338
+ async ensureAppExists(file, shouldCreate = true) {
334
339
  const klass = this.ctor;
335
340
  const currentStore = localConfig.getDefaultStore();
336
341
  await this.ensureLoggedIn();
@@ -356,10 +361,13 @@ export class PushAppCommand extends RemoteAppCommand {
356
361
  this.app = app;
357
362
  return true;
358
363
  }
364
+ if (this.pushesSwellJson(file)) {
365
+ warnListingSources(this.appPath, this.swellConfig.store, (message) => this.warn(message));
366
+ }
359
367
  // If app exists but owned by another client, create a new instance
360
368
  if (app?.client_id && app.client_id !== currentStore) {
361
369
  // Create a new app
362
- this.app = await this.getCreateUpdateApp(undefined, force);
370
+ this.app = await this.getCreateUpdateApp();
363
371
  if (!this.app) {
364
372
  return false;
365
373
  }
@@ -373,7 +381,7 @@ export class PushAppCommand extends RemoteAppCommand {
373
381
  }
374
382
  else {
375
383
  // Update existing app if not targeting a file
376
- this.app = await this.getCreateUpdateApp(app, force);
384
+ this.app = await this.getCreateUpdateApp(app);
377
385
  }
378
386
  return true;
379
387
  }
@@ -26,7 +26,7 @@ export declare abstract class RemoteAppCommand extends AppCommand {
26
26
  protected getApp(id?: string): Promise<App>;
27
27
  protected getAppWithConfig(id?: string): Promise<App>;
28
28
  getConfigData(configId: string): Promise<any>;
29
- protected getCreateUpdateApp(updateApp?: App | null, force?: boolean): Promise<App>;
29
+ protected getCreateUpdateApp(updateApp?: App | null): Promise<App>;
30
30
  protected getInstalledApp(): Promise<any>;
31
31
  /**
32
32
  * Anything on the target store already using the services named by
@@ -176,7 +176,7 @@ export class RemoteAppCommand extends AppCommand {
176
176
  adminPath: `/apps/${this.app.id}/configs/${configId}/data`,
177
177
  }, { query: { ...(themeId ? { theme_id: themeId } : undefined) } });
178
178
  }
179
- async getCreateUpdateApp(updateApp, force = false) {
179
+ async getCreateUpdateApp(updateApp) {
180
180
  const spinner = ora();
181
181
  let sourceApp;
182
182
  try {
@@ -205,9 +205,6 @@ export class RemoteAppCommand extends AppCommand {
205
205
  ? { private_id: this.swellConfig.store.id }
206
206
  : undefined),
207
207
  ...(sourceApp?.id ? { source_id: sourceApp.id } : undefined),
208
- // Overwrite name/description/logo_icon on the server
209
- // instead of only filling them in when empty
210
- ...(force ? { $force_meta: true } : undefined),
211
208
  };
212
209
  const appLabel = this.app.type === 'theme' ? 'theme' : 'app';
213
210
  let app = updateApp || {};