@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.
- package/dist/commands/app/action.d.ts +25 -0
- package/dist/commands/app/action.js +233 -0
- package/dist/commands/app/dev.js +2 -1
- package/dist/commands/app/frontend/deploy.d.ts +1 -0
- package/dist/commands/app/frontend/deploy.js +4 -0
- package/dist/commands/app/frontend/dev.js +4 -2
- package/dist/commands/app/push.d.ts +3 -0
- package/dist/commands/app/push.js +100 -4
- package/dist/commands/app/version.js +3 -0
- package/dist/commands/create/function.js +14 -4
- package/dist/commands/create/model.js +9 -24
- package/dist/commands/create/webhook.js +7 -13
- package/dist/commands/inspect/components.d.ts +31 -0
- package/dist/commands/inspect/components.js +110 -0
- package/dist/commands/inspect/extensions.js +18 -9
- package/dist/commands/inspect/frontend.d.ts +37 -0
- package/dist/commands/inspect/frontend.js +75 -0
- package/dist/commands/inspect/functions.d.ts +11 -1
- package/dist/commands/inspect/functions.js +89 -6
- package/dist/commands/inspect/index.js +2 -0
- package/dist/commands/schema.js +5 -0
- package/dist/help/custom-help.js +5 -1
- package/dist/inspect-resource-command.d.ts +1 -1
- package/dist/inspect-resource-command.js +8 -2
- package/dist/lib/api-errors.d.ts +1 -0
- package/dist/lib/api-errors.js +28 -0
- package/dist/lib/apps/actions.d.ts +45 -0
- package/dist/lib/apps/actions.js +114 -0
- package/dist/lib/apps/components.d.ts +7 -0
- package/dist/lib/apps/components.js +10 -0
- package/dist/lib/apps/extension-manifest.d.ts +1 -0
- package/dist/lib/apps/extension-manifest.js +43 -0
- package/dist/lib/apps/index.d.ts +1 -0
- package/dist/lib/apps/index.js +30 -0
- package/dist/lib/apps/listing.d.ts +1 -0
- package/dist/lib/apps/listing.js +41 -0
- package/dist/lib/apps/paths.d.ts +8 -0
- package/dist/lib/apps/paths.js +34 -4
- package/dist/lib/bundle.js +26 -41
- package/dist/lib/create/function.d.ts +1 -1
- package/dist/lib/create/function.js +9 -5
- package/dist/lib/create/index.js +2 -1
- package/dist/lib/create/model.d.ts +7 -3
- package/dist/lib/create/model.js +67 -9
- package/dist/lib/function-declarations.d.ts +8 -0
- package/dist/lib/function-declarations.js +27 -0
- package/dist/lib/inspect/extensions.d.ts +4 -3
- package/dist/lib/inspect/extensions.js +36 -5
- package/dist/lib/swell-function-wrapper.js +13 -5
- package/dist/lib/swell-workflow-request.d.ts +34 -0
- package/dist/lib/swell-workflow-request.js +58 -0
- package/dist/push-app-command.d.ts +2 -1
- package/dist/push-app-command.js +43 -22
- package/dist/remote-app-command.d.ts +2 -2
- package/dist/remote-app-command.js +79 -51
- package/oclif.manifest.json +263 -10
- 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
|
+
}
|
package/dist/commands/app/dev.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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,
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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 {
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
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,
|