@swell/cli 2.9.31 → 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.
@@ -0,0 +1,25 @@
1
+ import { RemoteAppCommand } from '../../remote-app-command.js';
2
+ export default class AppAction extends RemoteAppCommand {
3
+ static args: {
4
+ id: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ all: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
10
+ collection: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
11
+ ids: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
+ live: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
13
+ record: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
14
+ settings: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
15
+ source: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
16
+ values: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
17
+ view: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
18
+ };
19
+ static summary: string;
20
+ run(): Promise<void>;
21
+ private findAction;
22
+ private findFunctionId;
23
+ private getActionParams;
24
+ private parseValues;
25
+ }
@@ -0,0 +1,233 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import ora from 'ora';
3
+ import { functionSlug, getContentActions, getModalFieldIds, getSettingsActions, } from '../../lib/apps/actions.js';
4
+ import style from '../../lib/style.js';
5
+ import { RemoteAppCommand } from '../../remote-app-command.js';
6
+ const UNAVAILABLE_CODES = new Set([
7
+ 'notfound',
8
+ 'connrefused',
9
+ 'proxy_notfound',
10
+ ]);
11
+ export default class AppAction extends RemoteAppCommand {
12
+ static args = {
13
+ id: Args.string({
14
+ description: 'id of the action, as declared in content or settings',
15
+ required: true,
16
+ }),
17
+ };
18
+ static description = `Runs a function action of the app as the logged-in dashboard user, the way a click in the dashboard does. The action is looked up in the app's content and settings as pushed.
19
+
20
+ A record action or action field needs ${style.command('--record')}, and a bulk action ${style.command('--ids')} or ${style.command('--all')}. Pass the values of the action's modal with ${style.command('--values')}. When the same id is declared in more than one place, pick one with ${style.command('--collection')}, ${style.command('--view')}, ${style.command('--settings')} or ${style.command('--source')}.`;
21
+ static examples = [
22
+ 'swell app action sync_now',
23
+ 'swell app action ship --record 64e8dfb633c44a0039477251',
24
+ `swell app action ship --record 64e8dfb633c44a0039477251 --values '{"carrier":"ups"}'`,
25
+ 'swell app action ship-selected --ids 64e8dfb633c44a0039477251,64e8dfb633c44a0039477252',
26
+ 'swell app action ship --collection shipments --view edit --record 64e8dfb633c44a0039477251',
27
+ 'swell app action export --source bulk --all',
28
+ ];
29
+ static flags = {
30
+ all: Flags.boolean({
31
+ description: 'run a bulk action on all records its view lists with no filters set, as selecting all in the dashboard does',
32
+ exclusive: ['ids'],
33
+ }),
34
+ collection: Flags.string({
35
+ description: "collection of the content that declares the action, or the model's name for a collection of the app",
36
+ }),
37
+ ids: Flags.string({
38
+ description: 'comma-separated ids of the records a bulk action runs on',
39
+ exclusive: ['all'],
40
+ }),
41
+ live: Flags.boolean({
42
+ default: false,
43
+ description: 'run in the live environment instead of test',
44
+ }),
45
+ record: Flags.string({
46
+ description: 'id of the record a record action or action field runs on',
47
+ }),
48
+ settings: Flags.string({
49
+ description: 'name of the settings that declare the action',
50
+ }),
51
+ source: Flags.string({
52
+ description: "where the action is declared: list or record (a view's actions), bulk (a view's bulk actions), field (an action field) or settings (a settings action)",
53
+ options: ['list', 'record', 'bulk', 'field', 'settings'],
54
+ }),
55
+ values: Flags.string({
56
+ description: "values of the action's modal fields, as JSON",
57
+ }),
58
+ view: Flags.string({
59
+ description: 'id of the content view that declares the action',
60
+ }),
61
+ };
62
+ static summary = 'Run an app action from the command line.';
63
+ async run() {
64
+ const { args, flags } = await this.parse(AppAction);
65
+ const values = flags.values === undefined ? undefined : this.parseValues(flags.values);
66
+ if (!flags.live) {
67
+ await this.api.setEnv('test');
68
+ }
69
+ this.app = await this.getAppWithConfig();
70
+ const action = await this.findAction(args.id, flags);
71
+ const $action = this.getActionParams(action, { ...flags, values });
72
+ const functionId = await this.findFunctionId(action.function);
73
+ const name = style.appConfigValue(action.id);
74
+ const spinner = ora();
75
+ spinner.start(`Running action ${name}...`);
76
+ const result = await this.handleRequestErrors(async () => this.api.put({ adminPath: `/data/:functions/${functionId}` }, { body: { $action } }), () => spinner.fail(`Action ${action.id} failed`));
77
+ if (!(result?.status >= 200 && result.status < 300)) {
78
+ spinner.fail(`Action ${name} failed`);
79
+ this.error(getErrorMessage(result, name));
80
+ }
81
+ // A workflow runs in the background, so the platform answers with the run
82
+ // it started
83
+ const run = result.response?.run;
84
+ if (result.status === 202 && run?.id && run.workflow_name) {
85
+ spinner.succeed(`Started run ${style.appConfigValue(run.id)} of workflow ${style.appConfigValue(run.workflow_name)}`);
86
+ return;
87
+ }
88
+ spinner.succeed(`Ran action ${name}`);
89
+ if (result.response !== undefined) {
90
+ this.log(JSON.stringify(result.response, null, 2));
91
+ }
92
+ }
93
+ async findAction(id, flags) {
94
+ const [contents, settings] = await Promise.all([
95
+ this.api.get({ adminPath: '/data/:content' }, { query: { app_id: this.app.id, limit: 1000 } }),
96
+ this.api.get({ adminPath: `/data/:settings/${this.app.id}` }),
97
+ ]);
98
+ const actions = [
99
+ ...(contents?.results || []).flatMap((content) => getContentActions(content).map((action) => ({
100
+ ...action,
101
+ collection: content.collection,
102
+ content_id: content.id,
103
+ }))),
104
+ ...Object.entries(settings?.content || {}).flatMap(([name, values]) => getSettingsActions(values, name)),
105
+ ];
106
+ // Content of the app's own model has the collection apps/<app id>/<model>
107
+ const appCollection = `apps/${this.app.id}/`;
108
+ const matches = actions.filter((action) => action.id === id &&
109
+ (!flags.collection ||
110
+ action.collection === flags.collection ||
111
+ action.collection === `${appCollection}${flags.collection}`) &&
112
+ (!flags.view || action.view === flags.view) &&
113
+ (!flags.settings || action.settings === flags.settings) &&
114
+ (!flags.source || action.source === flags.source));
115
+ if (matches.length === 0) {
116
+ this.error(`Action ${style.appConfigValue(id)} not found in the app's content or settings. Push the app first if it is new.`);
117
+ }
118
+ if (matches.length > 1) {
119
+ this.error(`Action ${style.appConfigValue(id)} is declared in more than one place:\n${matches
120
+ .map((action) => ` ${describePlace(action, appCollection)}`)
121
+ .join('\n')}\nPick one with --collection, --view, --settings or --source.`);
122
+ }
123
+ return matches[0];
124
+ }
125
+ async findFunctionId(name) {
126
+ const functions = await this.api.get({ adminPath: '/data/:functions' }, { query: { app_id: this.app.id, fields: 'id, name', limit: 1000 } });
127
+ const record = (functions?.results || []).find((record) => functionSlug(record.name) === functionSlug(name));
128
+ if (!record) {
129
+ this.error(`Function ${style.appConfigValue(name)} of the action is not installed. Push the app first.`);
130
+ }
131
+ return record.id;
132
+ }
133
+ getActionParams(action, flags) {
134
+ const name = style.appConfigValue(action.id);
135
+ const isBulk = action.source === 'bulk';
136
+ const needsRecord = action.source === 'record' ||
137
+ (action.source === 'field' && !action.settings);
138
+ if (needsRecord && !flags.record) {
139
+ this.error(`Action ${name} runs on a record: pass --record.`);
140
+ }
141
+ if (!needsRecord && flags.record) {
142
+ this.error(isBulk
143
+ ? `Action ${name} is a bulk action, which runs on --ids or --all: leave out --record.`
144
+ : `Action ${name} does not run on a record: leave out --record.`);
145
+ }
146
+ if (isBulk && !flags.ids && !flags.all) {
147
+ this.error(`Action ${name} is a bulk action: pass --ids or --all.`);
148
+ }
149
+ if (!isBulk && (flags.ids || flags.all)) {
150
+ this.error(`Action ${name} is not a bulk action: leave out ${flags.ids ? '--ids' : '--all'}.`);
151
+ }
152
+ // The platform passes on only the values of the modal's fields
153
+ if (flags.values) {
154
+ if (!action.modal) {
155
+ this.error(`Action ${name} has no modal: leave out --values.`);
156
+ }
157
+ const fieldIds = getModalFieldIds(action.modal);
158
+ const unknownKeys = Object.keys(flags.values).filter((key) => !fieldIds.includes(key));
159
+ if (unknownKeys.length > 0) {
160
+ this.error(`--values names fields that the modal of action ${name} does not have: ${unknownKeys.join(', ')}.`);
161
+ }
162
+ }
163
+ return {
164
+ id: action.id,
165
+ source: action.source,
166
+ ...(action.settings
167
+ ? { settings: action.settings }
168
+ : { content_id: action.content_id }),
169
+ ...(action.view ? { view: action.view } : undefined),
170
+ ...(needsRecord ? { record_id: flags.record } : undefined),
171
+ ...(isBulk
172
+ ? { selection: getSelection(flags.ids, action.query) }
173
+ : undefined),
174
+ ...(flags.values ? { values: flags.values } : undefined),
175
+ };
176
+ }
177
+ parseValues(values) {
178
+ let parsed;
179
+ try {
180
+ parsed = JSON.parse(values);
181
+ }
182
+ catch {
183
+ // ignore
184
+ }
185
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
186
+ this.error('--values must be a JSON object of the modal field values, like \'{"carrier":"ups"}\'');
187
+ }
188
+ return parsed;
189
+ }
190
+ }
191
+ // The function answers as it likes, so only a string is a message
192
+ function resultMessage(message) {
193
+ return typeof message === 'string' ? message : '';
194
+ }
195
+ // As the dashboard reads a result that did not succeed
196
+ function getErrorMessage(result, name) {
197
+ const { code, response } = result || {};
198
+ if (code === 'timeout') {
199
+ return `Action ${name} did not finish within the time limit. It may still be running; check before running it again.`;
200
+ }
201
+ if (UNAVAILABLE_CODES.has(code)) {
202
+ return 'The app function is unavailable.';
203
+ }
204
+ return (resultMessage(response?.$reject?.message) ||
205
+ resultMessage(response?.error?.message) ||
206
+ resultMessage(response?.error) ||
207
+ `Action ${name} failed`);
208
+ }
209
+ function describePlace(action, appCollection) {
210
+ const collection = action.collection?.startsWith(appCollection)
211
+ ? action.collection.slice(appCollection.length)
212
+ : action.collection;
213
+ return action.settings
214
+ ? `settings ${action.settings} (${action.source})`
215
+ : `content ${collection}${action.view ? `, view ${action.view}` : ''} (${action.source})`;
216
+ }
217
+ // As the dashboard selects records: by their ids, or all of them that the
218
+ // view lists with no filters set
219
+ function getSelection(ids, viewQuery = {}) {
220
+ if (!ids) {
221
+ return { all: true, count: null, query: viewQuery };
222
+ }
223
+ const list = ids
224
+ .split(',')
225
+ .map((id) => id.trim())
226
+ .filter(Boolean);
227
+ return {
228
+ all: false,
229
+ count: list.length,
230
+ ids: list,
231
+ query: { $and: [{ id: { $in: list } }] },
232
+ };
233
+ }
@@ -12,5 +12,6 @@ export default class AppFrontendDeploy extends PushAppCommand {
12
12
  env: string;
13
13
  };
14
14
  static summary: string;
15
+ protected pushesSwellJson(): boolean;
15
16
  run(): Promise<void>;
16
17
  }
@@ -31,6 +31,10 @@ If deployment fails, correct any reported errors and run this command again.`;
31
31
  env: 'test',
32
32
  };
33
33
  static summary = `Push frontend files to your Swell app.`;
34
+ // A deploy pushes no app files, so it applies nothing of the listing
35
+ pushesSwellJson() {
36
+ return false;
37
+ }
34
38
  async run() {
35
39
  const { flags } = await this.parse(AppFrontendDeploy);
36
40
  const { force } = flags;
@@ -28,5 +28,7 @@ export default class AppPush extends PushAppCommand {
28
28
  */
29
29
  confirmPushConflicts(conflictsFlag?: string, file?: string): Promise<boolean>;
30
30
  run(): Promise<void>;
31
+ protected pushesSwellJson(file?: string): boolean;
32
+ private checkActionFunctions;
31
33
  private exitIfFilesFailed;
32
34
  }
@@ -1,6 +1,8 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import * as path from 'node:path';
3
- import { affectsFrontendDeployment, AllConfigPaths, filePathExistsAsync, isPathDirectory, sameIntegrations, } from '../../lib/apps/index.js';
3
+ import ora from 'ora';
4
+ import { functionSlug, getContentActions, getSettingsActions, } from '../../lib/apps/actions.js';
5
+ import { affectsFrontendDeployment, AllConfigPaths, filePathExistsAsync, isPathDirectory, sameIntegrations, toPosixPath, } from '../../lib/apps/index.js';
4
6
  import { default as localConfig } from '../../lib/config.js';
5
7
  import style from '../../lib/style.js';
6
8
  import { PushAppCommand } from '../../push-app-command.js';
@@ -19,7 +21,7 @@ If the app does not exist, it will be created and its global ID saved to a .swel
19
21
 
20
22
  - If no file is specified, all configuration files will be pushed to the store.
21
23
  This includes the app icon (assets/icon.png), screenshots (assets/screenshots/),
22
- README.md, and swell.json.
24
+ DESCRIPTION.md, and swell.json.
23
25
  - If a file is specified, only that file will be pushed to the store.
24
26
  - If a directory is specified, only files in that directory will be pushed.
25
27
 
@@ -151,6 +153,7 @@ ${Object.values(AllConfigPaths)
151
153
  if (!noDeploy && affectsFrontendDeployment(relativePath)) {
152
154
  await this.deployAppFrontend(force);
153
155
  }
156
+ await this.checkActionFunctions(toPosixPath(relativePath));
154
157
  this.log();
155
158
  this.logDashboardUrl(flags);
156
159
  this.exitIfFilesFailed();
@@ -158,6 +161,7 @@ ${Object.values(AllConfigPaths)
158
161
  }
159
162
  }
160
163
  await this.pushAppConfigs(force);
164
+ await this.checkActionFunctions();
161
165
  if (!noDeploy) {
162
166
  await this.deployAppFrontend(force);
163
167
  this.log();
@@ -165,6 +169,63 @@ ${Object.values(AllConfigPaths)
165
169
  this.logDashboardUrl(flags);
166
170
  this.exitIfFilesFailed();
167
171
  }
172
+ // The app path is the working directory unless --app-path names it, so a
173
+ // scoped push's file resolves here as in resolvePushPaths
174
+ pushesSwellJson(file) {
175
+ if (!file) {
176
+ return true;
177
+ }
178
+ const relativePath = path.relative(this.appPath, path.resolve(this.appPath, file));
179
+ return relativePath === '' || relativePath === 'swell.json';
180
+ }
181
+ // Content and settings are installed before functions, so their actions
182
+ // are checked against the app's functions once the push is done. A scoped
183
+ // push fails only for the files it pushed, and for the files whose action
184
+ // function it pushed or removed.
185
+ async checkActionFunctions(scope) {
186
+ const configs = await this.api
187
+ .getAll({ adminPath: `/apps/${this.app.id}/configs` }, { query: { type: { $in: ['function', 'content', 'setting'] } } })
188
+ .catch((error) => {
189
+ ora().warn(`Skipped checking the actions against the app functions: ${error.message}`);
190
+ return null;
191
+ });
192
+ if (!configs) {
193
+ return;
194
+ }
195
+ const { results } = configs;
196
+ const actionFunctions = new Set(results
197
+ .filter((config) => config.type === 'function' && config.values?.action)
198
+ .map((config) => functionSlug(config.name)));
199
+ const inScope = (filePath) => !scope || filePath === scope || filePath?.startsWith(`${scope}/`);
200
+ // The app's configs from before the push still name the functions it
201
+ // removed
202
+ const pushedFunctions = new Set([...results, ...(this.app.configs || [])]
203
+ .filter((config) => config.type === 'function' && inScope(config.file_path))
204
+ .map((config) => functionSlug(config.name)));
205
+ for (const config of results) {
206
+ const actions = config.type === 'content'
207
+ ? getContentActions(config.values)
208
+ : config.type === 'setting'
209
+ ? getSettingsActions(config.values, config.name)
210
+ : [];
211
+ const missing = actions.filter((action) => !actionFunctions.has(functionSlug(action.function)));
212
+ let failed = false;
213
+ for (const action of missing) {
214
+ const message = `${config.file_path}: action "${action.id}" runs function "${action.function}", which the app does not have as an action function`;
215
+ if (inScope(config.file_path) ||
216
+ pushedFunctions.has(functionSlug(action.function))) {
217
+ ora().fail(message);
218
+ failed = true;
219
+ }
220
+ else {
221
+ ora().warn(message);
222
+ }
223
+ }
224
+ if (failed && !this.failedFiles.includes(config.file_path)) {
225
+ this.failedFiles.push(config.file_path);
226
+ }
227
+ }
228
+ }
168
229
  // A skipped file is a failed push, for scripts and CI too
169
230
  exitIfFilesFailed() {
170
231
  if (this.failedFiles.length > 0) {
@@ -1,4 +1,4 @@
1
- import { confirm, input } from '@inquirer/prompts';
1
+ import { input } from '@inquirer/prompts';
2
2
  import { Args, Flags } from '@oclif/core';
3
3
  import { CreateConfigCommand } from '../../create-config-command.js';
4
4
  import { ConfigType } from '../../lib/apps/index.js';
@@ -16,7 +16,7 @@ export default class CreateWebhook extends CreateConfigCommand {
16
16
  static examples = [
17
17
  '$ swell create webhook',
18
18
  '$ swell create webhook order-sync -u https://example.com/hook -e order.created -y',
19
- '$ swell create webhook payment-handler -u https://api.example.com/payments -e payment.succeeded,payment.failed --enabled -y',
19
+ '$ swell create webhook payment-handler -u https://api.example.com/payments -e payment.succeeded,payment.failed --no-enabled -y',
20
20
  ];
21
21
  static helpMeta = {
22
22
  usageDirect: '<name> -u <url> -e <events> [...] -y',
@@ -38,8 +38,9 @@ export default class CreateWebhook extends CreateConfigCommand {
38
38
  description: 'Description',
39
39
  }),
40
40
  enabled: Flags.boolean({
41
- default: false,
42
- description: 'Enable webhook (default: false)',
41
+ allowNo: true,
42
+ default: true,
43
+ description: 'Enable webhook (default: true)',
43
44
  }),
44
45
  overwrite: Flags.boolean({
45
46
  default: false,
@@ -148,21 +149,14 @@ export default class CreateWebhook extends CreateConfigCommand {
148
149
  events = parseEvents(eventsInput);
149
150
  }
150
151
  // Description prompt (optional)
151
- let { description, enabled } = flags;
152
- const { overwrite } = flags;
152
+ let { description } = flags;
153
+ const { enabled, overwrite } = flags;
153
154
  if (!description) {
154
155
  description = await input({
155
156
  default: '',
156
157
  message: 'Describe what this webhook does',
157
158
  });
158
159
  }
159
- // Enabled prompt (optional)
160
- if (!enabled) {
161
- enabled = await confirm({
162
- default: false,
163
- message: 'Enable webhook now?',
164
- });
165
- }
166
160
  const fileName = toFileName(name);
167
161
  const fileBody = {
168
162
  $schema: SCHEMAS.WEBHOOK,
@@ -6,6 +6,7 @@ import * as fs from 'node:fs/promises';
6
6
  import parseJson from 'parse-json';
7
7
  import { bundleFunction } from '../lib/bundle.js';
8
8
  import { SCHEMAS_BASE_URL } from '../lib/constants.js';
9
+ import { getFunctionDeclarations } from '../lib/function-declarations.js';
9
10
  import { analyzeFunctionSource, getFunctionTriggers, hasKindDiagnostic, workflowStaticConfigError, } from '../lib/function-source-analysis.js';
10
11
  import { SwellCommand } from '../swell-command.js';
11
12
  const SCHEMA_DEFINITIONS = Object.freeze({
@@ -164,6 +165,10 @@ export default class Schema extends SwellCommand {
164
165
  break;
165
166
  }
166
167
  case 'dts': {
168
+ if (type === 'function') {
169
+ output = await getFunctionDeclarations(process.cwd());
170
+ break;
171
+ }
167
172
  const dtsUrl = this.getTypeScriptDeclarationsUrl(type);
168
173
  const response = await fetch(dtsUrl);
169
174
  if (!response.ok) {
@@ -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 || {};
@@ -721,6 +721,126 @@
721
721
  "_uninstall.js"
722
722
  ]
723
723
  },
724
+ "app:action": {
725
+ "aliases": [],
726
+ "args": {
727
+ "id": {
728
+ "description": "id of the action, as declared in content or settings",
729
+ "name": "id",
730
+ "required": true
731
+ }
732
+ },
733
+ "description": "Runs a function action of the app as the logged-in dashboard user, the way a click in the dashboard does. The action is looked up in the app's content and settings as pushed.\n\nA record action or action field needs --record, and a bulk action --ids or --all. Pass the values of the action's modal with --values. When the same id is declared in more than one place, pick one with --collection, --view, --settings or --source.",
734
+ "examples": [
735
+ "swell app action sync_now",
736
+ "swell app action ship --record 64e8dfb633c44a0039477251",
737
+ "swell app action ship --record 64e8dfb633c44a0039477251 --values '{\"carrier\":\"ups\"}'",
738
+ "swell app action ship-selected --ids 64e8dfb633c44a0039477251,64e8dfb633c44a0039477252",
739
+ "swell app action ship --collection shipments --view edit --record 64e8dfb633c44a0039477251",
740
+ "swell app action export --source bulk --all"
741
+ ],
742
+ "flags": {
743
+ "app-path": {
744
+ "description": "Path to your app directory",
745
+ "name": "app-path",
746
+ "hasDynamicHelp": false,
747
+ "multiple": false,
748
+ "type": "option"
749
+ },
750
+ "all": {
751
+ "description": "run a bulk action on all records its view lists with no filters set, as selecting all in the dashboard does",
752
+ "exclusive": [
753
+ "ids"
754
+ ],
755
+ "name": "all",
756
+ "allowNo": false,
757
+ "type": "boolean"
758
+ },
759
+ "collection": {
760
+ "description": "collection of the content that declares the action, or the model's name for a collection of the app",
761
+ "name": "collection",
762
+ "hasDynamicHelp": false,
763
+ "multiple": false,
764
+ "type": "option"
765
+ },
766
+ "ids": {
767
+ "description": "comma-separated ids of the records a bulk action runs on",
768
+ "exclusive": [
769
+ "all"
770
+ ],
771
+ "name": "ids",
772
+ "hasDynamicHelp": false,
773
+ "multiple": false,
774
+ "type": "option"
775
+ },
776
+ "live": {
777
+ "description": "run in the live environment instead of test",
778
+ "name": "live",
779
+ "allowNo": false,
780
+ "type": "boolean"
781
+ },
782
+ "record": {
783
+ "description": "id of the record a record action or action field runs on",
784
+ "name": "record",
785
+ "hasDynamicHelp": false,
786
+ "multiple": false,
787
+ "type": "option"
788
+ },
789
+ "settings": {
790
+ "description": "name of the settings that declare the action",
791
+ "name": "settings",
792
+ "hasDynamicHelp": false,
793
+ "multiple": false,
794
+ "type": "option"
795
+ },
796
+ "source": {
797
+ "description": "where the action is declared: list or record (a view's actions), bulk (a view's bulk actions), field (an action field) or settings (a settings action)",
798
+ "name": "source",
799
+ "hasDynamicHelp": false,
800
+ "multiple": false,
801
+ "options": [
802
+ "list",
803
+ "record",
804
+ "bulk",
805
+ "field",
806
+ "settings"
807
+ ],
808
+ "type": "option"
809
+ },
810
+ "values": {
811
+ "description": "values of the action's modal fields, as JSON",
812
+ "name": "values",
813
+ "hasDynamicHelp": false,
814
+ "multiple": false,
815
+ "type": "option"
816
+ },
817
+ "view": {
818
+ "description": "id of the content view that declares the action",
819
+ "name": "view",
820
+ "hasDynamicHelp": false,
821
+ "multiple": false,
822
+ "type": "option"
823
+ }
824
+ },
825
+ "hasDynamicHelp": false,
826
+ "hiddenAliases": [],
827
+ "id": "app:action",
828
+ "pluginAlias": "@swell/cli",
829
+ "pluginName": "@swell/cli",
830
+ "pluginType": "core",
831
+ "strict": true,
832
+ "summary": "Run an app action from the command line.",
833
+ "enableJsonFlag": false,
834
+ "requiresSwellConfig": true,
835
+ "delayOrientation": false,
836
+ "isESM": true,
837
+ "relativePath": [
838
+ "dist",
839
+ "commands",
840
+ "app",
841
+ "action.js"
842
+ ]
843
+ },
724
844
  "app:delete": {
725
845
  "aliases": [],
726
846
  "args": {},
@@ -1237,7 +1357,7 @@
1237
1357
  "name": "file"
1238
1358
  }
1239
1359
  },
1240
- "description": "Push all app files, a specific file, or a specific configuration\ntype to an app in your store's test environment.\n\nIf the app does not exist, it will be created and its global ID saved to a .swellrc file.\n\n- If no file is specified, all configuration files will be pushed to the store.\n This includes the app icon (assets/icon.png), screenshots (assets/screenshots/),\n README.md, and swell.json.\n- If a file is specified, only that file will be pushed to the store.\n- If a directory is specified, only files in that directory will be pushed.\n\nPushing anything under frontend/ also builds and deploys the app frontend, so\nthe deployed frontend matches what was pushed. Use --no-deploy to skip it.\n\nApp file directories:\nassets/\ncontent/\nfrontend/\ncomponents/\nfunctions/\nmodels/\nnotifications/\nsettings/\ntheme/\nwebhooks/",
1360
+ "description": "Push all app files, a specific file, or a specific configuration\ntype to an app in your store's test environment.\n\nIf the app does not exist, it will be created and its global ID saved to a .swellrc file.\n\n- If no file is specified, all configuration files will be pushed to the store.\n This includes the app icon (assets/icon.png), screenshots (assets/screenshots/),\n DESCRIPTION.md, and swell.json.\n- If a file is specified, only that file will be pushed to the store.\n- If a directory is specified, only files in that directory will be pushed.\n\nPushing anything under frontend/ also builds and deploys the app frontend, so\nthe deployed frontend matches what was pushed. Use --no-deploy to skip it.\n\nApp file directories:\nassets/\ncontent/\nfrontend/\ncomponents/\nfunctions/\nmodels/\nnotifications/\nsettings/\ntheme/\nwebhooks/",
1241
1361
  "examples": [
1242
1362
  "swell app push",
1243
1363
  "swell app push content",
@@ -2475,7 +2595,7 @@
2475
2595
  "examples": [
2476
2596
  "$ swell create webhook",
2477
2597
  "$ swell create webhook order-sync -u https://example.com/hook -e order.created -y",
2478
- "$ swell create webhook payment-handler -u https://api.example.com/payments -e payment.succeeded,payment.failed --enabled -y"
2598
+ "$ swell create webhook payment-handler -u https://api.example.com/payments -e payment.succeeded,payment.failed --no-enabled -y"
2479
2599
  ],
2480
2600
  "flags": {
2481
2601
  "app-path": {
@@ -2513,9 +2633,9 @@
2513
2633
  "type": "option"
2514
2634
  },
2515
2635
  "enabled": {
2516
- "description": "Enable webhook (default: false)",
2636
+ "description": "Enable webhook (default: true)",
2517
2637
  "name": "enabled",
2518
- "allowNo": false,
2638
+ "allowNo": true,
2519
2639
  "type": "boolean"
2520
2640
  },
2521
2641
  "overwrite": {
@@ -3738,5 +3858,5 @@
3738
3858
  ]
3739
3859
  }
3740
3860
  },
3741
- "version": "2.9.31"
3861
+ "version": "2.9.32"
3742
3862
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swell/cli",
3
- "version": "2.9.31",
3
+ "version": "2.9.32",
4
4
  "type": "module",
5
5
  "description": "Swell's command line interface/utility",
6
6
  "keywords": [
@@ -30,6 +30,7 @@
30
30
  "@npmcli/git": "5.0.3",
31
31
  "@oclif/core": "3.7.1",
32
32
  "@oclif/plugin-help": "6.0.4",
33
+ "@swell/app-types": "1.3.0",
33
34
  "ajv": "8.20.0",
34
35
  "ajv-errors": "3.0.0",
35
36
  "ajv-formats": "3.0.1",