@swell/cli 2.9.31 → 2.9.33

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 (57) hide show
  1. package/dist/commands/app/action.d.ts +25 -0
  2. package/dist/commands/app/action.js +233 -0
  3. package/dist/commands/app/dev.js +2 -1
  4. package/dist/commands/app/frontend/deploy.d.ts +1 -0
  5. package/dist/commands/app/frontend/deploy.js +4 -0
  6. package/dist/commands/app/frontend/dev.js +4 -2
  7. package/dist/commands/app/push.d.ts +3 -0
  8. package/dist/commands/app/push.js +100 -4
  9. package/dist/commands/app/version.js +3 -0
  10. package/dist/commands/create/function.js +14 -4
  11. package/dist/commands/create/model.js +9 -24
  12. package/dist/commands/create/webhook.js +7 -13
  13. package/dist/commands/inspect/components.d.ts +31 -0
  14. package/dist/commands/inspect/components.js +110 -0
  15. package/dist/commands/inspect/extensions.js +18 -9
  16. package/dist/commands/inspect/frontend.d.ts +37 -0
  17. package/dist/commands/inspect/frontend.js +75 -0
  18. package/dist/commands/inspect/functions.d.ts +11 -1
  19. package/dist/commands/inspect/functions.js +89 -6
  20. package/dist/commands/inspect/index.js +2 -0
  21. package/dist/commands/schema.js +5 -0
  22. package/dist/help/custom-help.js +5 -1
  23. package/dist/inspect-resource-command.d.ts +1 -1
  24. package/dist/inspect-resource-command.js +8 -2
  25. package/dist/lib/api-errors.d.ts +1 -0
  26. package/dist/lib/api-errors.js +28 -0
  27. package/dist/lib/apps/actions.d.ts +45 -0
  28. package/dist/lib/apps/actions.js +114 -0
  29. package/dist/lib/apps/components.d.ts +7 -0
  30. package/dist/lib/apps/components.js +10 -0
  31. package/dist/lib/apps/extension-manifest.d.ts +1 -0
  32. package/dist/lib/apps/extension-manifest.js +43 -0
  33. package/dist/lib/apps/index.d.ts +1 -0
  34. package/dist/lib/apps/index.js +30 -0
  35. package/dist/lib/apps/listing.d.ts +1 -0
  36. package/dist/lib/apps/listing.js +41 -0
  37. package/dist/lib/apps/paths.d.ts +8 -0
  38. package/dist/lib/apps/paths.js +34 -4
  39. package/dist/lib/bundle.js +26 -41
  40. package/dist/lib/create/function.d.ts +1 -1
  41. package/dist/lib/create/function.js +9 -5
  42. package/dist/lib/create/index.js +2 -1
  43. package/dist/lib/create/model.d.ts +7 -3
  44. package/dist/lib/create/model.js +67 -9
  45. package/dist/lib/function-declarations.d.ts +8 -0
  46. package/dist/lib/function-declarations.js +27 -0
  47. package/dist/lib/inspect/extensions.d.ts +4 -3
  48. package/dist/lib/inspect/extensions.js +36 -5
  49. package/dist/lib/swell-function-wrapper.js +13 -5
  50. package/dist/lib/swell-workflow-request.d.ts +34 -0
  51. package/dist/lib/swell-workflow-request.js +58 -0
  52. package/dist/push-app-command.d.ts +2 -1
  53. package/dist/push-app-command.js +43 -22
  54. package/dist/remote-app-command.d.ts +2 -2
  55. package/dist/remote-app-command.js +79 -51
  56. package/oclif.manifest.json +263 -10
  57. package/package.json +2 -1
@@ -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
+ }
@@ -105,7 +105,8 @@ export default class AppDev extends PushAppCommand {
105
105
  this.jsonEvents = flags['json-events'];
106
106
  this.setStoreUser(flags['store-user']);
107
107
  this.functionFilter = flags.function ?? null;
108
- if (!(await this.ensureAppExists(undefined, false))) {
108
+ // A pushing dev run updates the app from swell.json first, as a push does
109
+ if (!(await this.ensureAppExists(undefined, !noPush))) {
109
110
  return;
110
111
  }
111
112
  if (!noPush) {
@@ -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;
@@ -42,10 +42,12 @@ export default class AppFrontendDev extends PushAppCommand {
42
42
  const { port, 'proxy-port': proxyPort, 'app-dev': isAppDev } = flags;
43
43
  const noPush = flags['no-push'];
44
44
  this.setStoreUser(flags['store-user']);
45
- if (!(await this.ensureAppExists(undefined, false))) {
45
+ const isPushing = !noPush && !isAppDev;
46
+ // A pushing dev run updates the app from swell.json first, as a push does
47
+ if (!(await this.ensureAppExists(undefined, isPushing))) {
46
48
  return;
47
49
  }
48
- if (!noPush && !isAppDev) {
50
+ if (isPushing) {
49
51
  await this.pushAppConfigs();
50
52
  }
51
53
  if (this.app.type === 'storefront') {
@@ -28,5 +28,8 @@ 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;
33
+ private enableAutoDisabledFunctions;
31
34
  private exitIfFilesFailed;
32
35
  }
@@ -1,6 +1,9 @@
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, getNameFromFilePath, isPathDirectory, sameIntegrations, toPosixPath, } from '../../lib/apps/index.js';
6
+ import { isCredentialFile } from '../../lib/apps/paths.js';
4
7
  import { default as localConfig } from '../../lib/config.js';
5
8
  import style from '../../lib/style.js';
6
9
  import { PushAppCommand } from '../../push-app-command.js';
@@ -19,7 +22,7 @@ If the app does not exist, it will be created and its global ID saved to a .swel
19
22
 
20
23
  - If no file is specified, all configuration files will be pushed to the store.
21
24
  This includes the app icon (assets/icon.png), screenshots (assets/screenshots/),
22
- README.md, and swell.json.
25
+ DESCRIPTION.md, and swell.json.
23
26
  - If a file is specified, only that file will be pushed to the store.
24
27
  - If a directory is specified, only files in that directory will be pushed.
25
28
 
@@ -147,10 +150,17 @@ ${Object.values(AllConfigPaths)
147
150
  ? this.pushFilePath(relativePath, force)
148
151
  : this.pushFile(relativePath));
149
152
  // Uploading frontend sources doesn't change what the Worker serves,
150
- // so scoped pushes need the same pipeline as an unscoped one
151
- if (!noDeploy && affectsFrontendDeployment(relativePath)) {
153
+ // so scoped pushes need the same pipeline as an unscoped one. A
154
+ // credential file named on its own is refused and deploys nothing
155
+ if (!noDeploy &&
156
+ affectsFrontendDeployment(relativePath) &&
157
+ !isCredentialFile(relativePath)) {
152
158
  await this.deployAppFrontend(force);
153
159
  }
160
+ await this.checkActionFunctions(toPosixPath(relativePath));
161
+ if (toPosixPath(relativePath).split('/')[0] === 'functions') {
162
+ await this.enableAutoDisabledFunctions();
163
+ }
154
164
  this.log();
155
165
  this.logDashboardUrl(flags);
156
166
  this.exitIfFilesFailed();
@@ -158,6 +168,8 @@ ${Object.values(AllConfigPaths)
158
168
  }
159
169
  }
160
170
  await this.pushAppConfigs(force);
171
+ await this.checkActionFunctions();
172
+ await this.enableAutoDisabledFunctions();
161
173
  if (!noDeploy) {
162
174
  await this.deployAppFrontend(force);
163
175
  this.log();
@@ -165,6 +177,90 @@ ${Object.values(AllConfigPaths)
165
177
  this.logDashboardUrl(flags);
166
178
  this.exitIfFilesFailed();
167
179
  }
180
+ // The app path is the working directory unless --app-path names it, so a
181
+ // scoped push's file resolves here as in resolvePushPaths
182
+ pushesSwellJson(file) {
183
+ if (!file) {
184
+ return true;
185
+ }
186
+ const relativePath = path.relative(this.appPath, path.resolve(this.appPath, file));
187
+ return relativePath === '' || relativePath === 'swell.json';
188
+ }
189
+ // Content and settings are installed before functions, so their actions
190
+ // are checked against the app's functions once the push is done. A scoped
191
+ // push fails only for the files it pushed, and for the files whose action
192
+ // function it pushed or removed.
193
+ async checkActionFunctions(scope) {
194
+ const configs = await this.api
195
+ .getAll({ adminPath: `/apps/${this.app.id}/configs` }, { query: { type: { $in: ['function', 'content', 'setting'] } } })
196
+ .catch((error) => {
197
+ ora().warn(`Skipped checking the actions against the app functions: ${error.message}`);
198
+ return null;
199
+ });
200
+ if (!configs) {
201
+ return;
202
+ }
203
+ const { results } = configs;
204
+ const actionFunctions = new Set(results
205
+ .filter((config) => config.type === 'function' && config.values?.action)
206
+ .map((config) => functionSlug(config.name)));
207
+ const inScope = (filePath) => !scope || filePath === scope || filePath?.startsWith(`${scope}/`);
208
+ // The app's configs from before the push still name the functions it
209
+ // removed
210
+ const pushedFunctions = new Set([...results, ...(this.app.configs || [])]
211
+ .filter((config) => config.type === 'function' && inScope(config.file_path))
212
+ .map((config) => functionSlug(config.name)));
213
+ for (const config of results) {
214
+ const actions = config.type === 'content'
215
+ ? getContentActions(config.values)
216
+ : config.type === 'setting'
217
+ ? getSettingsActions(config.values, config.name)
218
+ : [];
219
+ const missing = actions.filter((action) => !actionFunctions.has(functionSlug(action.function)));
220
+ let failed = false;
221
+ for (const action of missing) {
222
+ const message = `${config.file_path}: action "${action.id}" runs function "${action.function}", which the app does not have as an action function`;
223
+ if (inScope(config.file_path) ||
224
+ pushedFunctions.has(functionSlug(action.function))) {
225
+ ora().fail(message);
226
+ failed = true;
227
+ }
228
+ else {
229
+ ora().warn(message);
230
+ }
231
+ }
232
+ if (failed && !this.failedFiles.includes(config.file_path)) {
233
+ this.failedFiles.push(config.file_path);
234
+ }
235
+ }
236
+ }
237
+ // The platform switches off a function that keeps failing, and only an
238
+ // install of its config switches it on again, which a push skips for an
239
+ // unchanged file. Switching it on also resends the events it missed.
240
+ async enableAutoDisabledFunctions() {
241
+ try {
242
+ const { results } = await this.api.getAll({ adminPath: '/data/:functions' }, {
243
+ query: {
244
+ app_id: this.app.id,
245
+ auto_disabled: true,
246
+ fields: 'id, name',
247
+ },
248
+ });
249
+ // A function whose push just failed stays off, as it would fail again
250
+ const failedNames = new Set(this.failedFiles
251
+ .filter((filePath) => path.posix.dirname(filePath) === AllConfigPaths.FUNCTION)
252
+ .map((filePath) => getNameFromFilePath(filePath)));
253
+ const disabled = (results || []).filter((fn) => !failedNames.has(fn.name));
254
+ if (disabled.length === 0) {
255
+ return;
256
+ }
257
+ await Promise.all(disabled.map((fn) => this.api.put({ adminPath: `/data/:functions/${fn.id}` }, { body: { enabled: true } })));
258
+ ora().succeed(`Re-enabled ${disabled.length} auto-disabled function${disabled.length === 1 ? '' : 's'}: ${disabled.map((fn) => fn.name).join(', ')}`);
259
+ }
260
+ catch (error) {
261
+ ora().warn(`Skipped re-enabling auto-disabled functions: ${error.message}`);
262
+ }
263
+ }
168
264
  // A skipped file is a failed push, for scripts and CI too
169
265
  exitIfFilesFailed() {
170
266
  if (this.failedFiles.length > 0) {
@@ -121,6 +121,9 @@ behavior by using the --no-git-tag option.`;
121
121
  return;
122
122
  }
123
123
  nextVersion = await this.validateAndComputeVersion(nextVersion, localVersion);
124
+ // The app takes its details from swell.json before any upload, as a push
125
+ // does, so that details the app refuses fail first
126
+ this.app = await this.getCreateUpdateApp(this.app);
124
127
  await this.pushAppConfigs();
125
128
  await this.deployAppFrontend(false, false);
126
129
  if (!message) {
@@ -4,7 +4,7 @@ import { CreateConfigCommand } from '../../create-config-command.js';
4
4
  import { ConfigType } from '../../lib/apps/index.js';
5
5
  import { getFunctionTemplate, } from '../../lib/create/function.js';
6
6
  import { toFunctionFileName } from '../../lib/create/index.js';
7
- const PERMITTED_TRIGGERS = ['cron', 'model', 'route'];
7
+ const PERMITTED_TRIGGERS = ['cron', 'model', 'route', 'action'];
8
8
  const PERMITTED_MODES = [...PERMITTED_TRIGGERS, 'workflow'];
9
9
  export default class CreateFunction extends CreateConfigCommand {
10
10
  static args = {
@@ -14,7 +14,7 @@ export default class CreateFunction extends CreateConfigCommand {
14
14
  }),
15
15
  trigger: Args.string({
16
16
  default: '',
17
- description: 'cron | model | route | workflow',
17
+ description: 'cron | model | route | action | workflow',
18
18
  options: PERMITTED_MODES,
19
19
  }),
20
20
  };
@@ -23,6 +23,7 @@ export default class CreateFunction extends CreateConfigCommand {
23
23
  '$ swell create function order-handler.ts model -e order.created,order.updated -y',
24
24
  '$ swell create function cleanup.ts cron -s "0 0 * * *" -y',
25
25
  '$ swell create function api-endpoint.ts route -r public -y',
26
+ '$ swell create function mark-shipped.ts action -y',
26
27
  '$ swell create function run-import.ts --type workflow -y',
27
28
  '$ swell create function run-import.ts workflow -y',
28
29
  ];
@@ -46,6 +47,11 @@ export default class CreateFunction extends CreateConfigCommand {
46
47
  flag: '-r, --route=<option>',
47
48
  description: 'Access: public (default) | private',
48
49
  },
50
+ {
51
+ name: 'action',
52
+ flag: '',
53
+ description: 'Runs from a dashboard action',
54
+ },
49
55
  {
50
56
  name: 'workflow',
51
57
  flag: '--type=workflow',
@@ -104,7 +110,7 @@ export default class CreateFunction extends CreateConfigCommand {
104
110
  }
105
111
  // Validate trigger argument
106
112
  if (!trigger) {
107
- this.error('Missing required argument for non-interactive mode: TRIGGER or --type workflow\n\nValid modes: cron, model, route, workflow\n\nExample: swell create function my-func.ts model -e order.created -y\nExample: swell create function run-import.ts --type workflow -y', { exit: 1 });
113
+ this.error('Missing required argument for non-interactive mode: TRIGGER or --type workflow\n\nValid modes: cron, model, route, action, workflow\n\nExample: swell create function my-func.ts model -e order.created -y\nExample: swell create function run-import.ts --type workflow -y', { exit: 1 });
108
114
  }
109
115
  // Validate filename has extension
110
116
  const language = args.name.endsWith('.ts')
@@ -212,6 +218,10 @@ export default class CreateFunction extends CreateConfigCommand {
212
218
  name: 'HTTP (route)',
213
219
  value: 'route',
214
220
  },
221
+ {
222
+ name: 'Dashboard action (action)',
223
+ value: 'action',
224
+ },
215
225
  ],
216
226
  message: 'How will your function be triggered?',
217
227
  });
@@ -290,7 +300,7 @@ export default class CreateFunction extends CreateConfigCommand {
290
300
  if (typeFlag === 'workflow') {
291
301
  if (positionalTrigger &&
292
302
  PERMITTED_TRIGGERS.includes(positionalTrigger)) {
293
- this.error('--type workflow cannot be combined with a positional cron, model, or route trigger.', { exit: 1 });
303
+ this.error('--type workflow cannot be combined with a positional cron, model, route, or action trigger.', { exit: 1 });
294
304
  }
295
305
  return 'workflow';
296
306
  }
@@ -3,7 +3,7 @@ import { Args, Flags } from '@oclif/core';
3
3
  import { CreateCollectionCommand } from '../../create-collection-command.js';
4
4
  import { ConfigType } from '../../lib/apps/index.js';
5
5
  import { toCollectionLabel, toCollectionName, toFileName, } from '../../lib/create/index.js';
6
- import { parseEvents, parseFields, } from '../../lib/create/model.js';
6
+ import { FIELD_TYPES, fieldPairProblem, parseEvents, parseFields, } from '../../lib/create/model.js';
7
7
  import { SCHEMAS } from '../../lib/create/schemas.js';
8
8
  export default class CreateModel extends CreateCollectionCommand {
9
9
  static args = {
@@ -16,7 +16,8 @@ export default class CreateModel extends CreateCollectionCommand {
16
16
  static examples = [
17
17
  '$ swell create model',
18
18
  '$ swell create model products -y',
19
- '$ swell create model visitors -f title:string,photo:string -l "Visitors" -y',
19
+ '$ swell create model visitors -f title:string,photo:file -l "Visitors" -y',
20
+ '$ swell create model reviews -f owner:link:accounts,tags:array:string -y',
20
21
  ];
21
22
  static helpMeta = {
22
23
  usageDirect: '<collection> [...] -y',
@@ -35,7 +36,7 @@ export default class CreateModel extends CreateCollectionCommand {
35
36
  fields: Flags.string({
36
37
  char: 'f',
37
38
  default: '',
38
- description: 'Fields as id:type pairs (e.g., title:string,price:currency)',
39
+ description: 'Fields as id:type pairs, id:link:<model> or id:array:<type> (e.g., title:string,owner:link:accounts)',
39
40
  }),
40
41
  events: Flags.string({
41
42
  char: 'e',
@@ -58,14 +59,6 @@ export default class CreateModel extends CreateCollectionCommand {
58
59
  const confirmYes = Boolean(flags.yes);
59
60
  // Helper validators
60
61
  const validEventValues = ['created', 'updated', 'deleted'];
61
- const validFieldTypes = [
62
- 'collection',
63
- 'currency',
64
- 'image',
65
- 'int',
66
- 'link',
67
- 'string',
68
- ];
69
62
  const validateEvents = (values) => {
70
63
  const invalid = values.filter((v) => !validEventValues.includes(v));
71
64
  if (invalid.length > 0) {
@@ -73,19 +66,11 @@ export default class CreateModel extends CreateCollectionCommand {
73
66
  }
74
67
  };
75
68
  const validateFields = (pairs) => {
76
- const invalid = [];
77
- for (const pair of pairs) {
78
- if (!pair.includes(':')) {
79
- invalid.push(pair);
80
- continue;
81
- }
82
- const type = pair.split(':')[1]?.trim();
83
- if (!validFieldTypes.includes(type)) {
84
- invalid.push(pair);
85
- }
86
- }
87
- if (invalid.length > 0) {
88
- this.error(`Invalid field(s): ${invalid.join(', ')}\n\nValid types: ${validFieldTypes.join(', ')}\n\nExample: swell create model visitors -f title:string,price:currency -y`, { exit: 1 });
69
+ const problems = pairs
70
+ .map((pair) => fieldPairProblem(pair))
71
+ .filter(Boolean);
72
+ if (problems.length > 0) {
73
+ this.error(`Invalid field(s):\n ${problems.join('\n ')}\n\nValid types: ${FIELD_TYPES.filter((type) => type !== 'collection').join(', ')}\n\nExample: swell create model visitors -f title:string,owner:link:accounts,tags:array:string -y`, { exit: 1 });
89
74
  }
90
75
  };
91
76
  if (confirmYes) {
@@ -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,