@swell/cli 2.9.30 → 2.9.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/app/action.d.ts +25 -0
- package/dist/commands/app/action.js +233 -0
- package/dist/commands/app/delete.d.ts +33 -0
- package/dist/commands/app/delete.js +179 -0
- package/dist/commands/app/frontend/deploy.d.ts +1 -0
- package/dist/commands/app/frontend/deploy.js +4 -0
- package/dist/commands/app/push.d.ts +2 -0
- package/dist/commands/app/push.js +63 -2
- package/dist/commands/create/webhook.js +7 -13
- package/dist/commands/schema.js +5 -0
- package/dist/help/custom-help.js +5 -1
- package/dist/lib/apps/actions.d.ts +45 -0
- package/dist/lib/apps/actions.js +114 -0
- package/dist/lib/apps/listing.d.ts +1 -0
- package/dist/lib/apps/listing.js +41 -0
- package/dist/lib/bundle.js +11 -39
- package/dist/lib/function-declarations.d.ts +8 -0
- package/dist/lib/function-declarations.js +27 -0
- package/dist/lib/swell-function-wrapper.js +8 -1
- 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 +11 -3
- package/dist/remote-app-command.d.ts +1 -1
- package/dist/remote-app-command.js +1 -4
- package/oclif.manifest.json +178 -5
- 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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { RemoteAppCommand } from '../../remote-app-command.js';
|
|
2
|
+
export default class AppDelete extends RemoteAppCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static flags: {
|
|
6
|
+
confirm: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
7
|
+
yes: import("@oclif/core/lib/interfaces/parser.js").BooleanFlag<boolean>;
|
|
8
|
+
};
|
|
9
|
+
static orientation: {
|
|
10
|
+
env: string;
|
|
11
|
+
};
|
|
12
|
+
static summary: string;
|
|
13
|
+
run(): Promise<void>;
|
|
14
|
+
private deleteRequest;
|
|
15
|
+
private ensureDeleted;
|
|
16
|
+
private errorTheme;
|
|
17
|
+
/**
|
|
18
|
+
* Remove .swellrc when it holds the deleted app's id, so the next push
|
|
19
|
+
* creates a new app. Another id is left alone.
|
|
20
|
+
* @param {string} appId the deleted app
|
|
21
|
+
* @returns {Promise<boolean>} false when .swellrc names another app
|
|
22
|
+
*/
|
|
23
|
+
private removeSwellRc;
|
|
24
|
+
private errorBlocked;
|
|
25
|
+
/**
|
|
26
|
+
* The app named by swell.json, with the reasons it can't be deleted.
|
|
27
|
+
* Only the store that owns it may delete it.
|
|
28
|
+
* @param {string} privateId app id from swell.json
|
|
29
|
+
* @param {string} storeId the logged in store
|
|
30
|
+
* @returns {Promise<any>} the app
|
|
31
|
+
*/
|
|
32
|
+
private getOwnedApp;
|
|
33
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Flags } from '@oclif/core';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import { readRcFile } from '../../lib/app-config.js';
|
|
5
|
+
import config from '../../lib/config.js';
|
|
6
|
+
import style from '../../lib/style.js';
|
|
7
|
+
import { RemoteAppCommand } from '../../remote-app-command.js';
|
|
8
|
+
export default class AppDelete extends RemoteAppCommand {
|
|
9
|
+
static description = `Deletes the app from the store that owns it, to free a slot under the store's app limit.
|
|
10
|
+
|
|
11
|
+
The app's versions, configs, functions and its development install in the test environment are removed. Records the app created in the store are not deleted.
|
|
12
|
+
|
|
13
|
+
An app can't be deleted while it is installed anywhere else, if it is linked to a partner, or if it was ever published. The command lists what blocks it.
|
|
14
|
+
|
|
15
|
+
After deleting, .swellrc is removed, so the next \`swell app push\` creates a new app.`;
|
|
16
|
+
static examples = [
|
|
17
|
+
'swell app delete',
|
|
18
|
+
'swell app delete --confirm',
|
|
19
|
+
'swell app delete --confirm -y',
|
|
20
|
+
];
|
|
21
|
+
static flags = {
|
|
22
|
+
confirm: Flags.boolean({
|
|
23
|
+
description: 'confirm deleting the app, instead of being asked',
|
|
24
|
+
}),
|
|
25
|
+
yes: Flags.boolean({
|
|
26
|
+
char: 'y',
|
|
27
|
+
description: 'skip all prompts; requires --confirm',
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
static orientation = {
|
|
31
|
+
env: 'test',
|
|
32
|
+
};
|
|
33
|
+
static summary = 'Delete an app your store owns.';
|
|
34
|
+
async run() {
|
|
35
|
+
const { flags } = await this.parse(AppDelete);
|
|
36
|
+
const { confirm: confirmed, yes } = flags;
|
|
37
|
+
if (yes && !confirmed) {
|
|
38
|
+
this.error(`Missing --confirm for non-interactive mode.
|
|
39
|
+
|
|
40
|
+
Deleting an app can't be undone.
|
|
41
|
+
|
|
42
|
+
Example: ${style.command('swell app delete --confirm -y')}`, { exit: 1 });
|
|
43
|
+
}
|
|
44
|
+
if (!confirmed && !process.stdin.isTTY) {
|
|
45
|
+
this.error(`Missing --confirm: there is no terminal to ask for confirmation.
|
|
46
|
+
|
|
47
|
+
Example: ${style.command('swell app delete --confirm')}`, { exit: 1 });
|
|
48
|
+
}
|
|
49
|
+
if (this.swellConfig.get('type') === 'theme') {
|
|
50
|
+
this.errorTheme();
|
|
51
|
+
}
|
|
52
|
+
const privateId = this.app.private_id;
|
|
53
|
+
if (!privateId) {
|
|
54
|
+
this.error(`App ${style.appConfigName('id')} is not defined in swell.json.`, { exit: 1 });
|
|
55
|
+
}
|
|
56
|
+
const storeId = config.getDefaultStore();
|
|
57
|
+
config.ensureLoggedIn(storeId);
|
|
58
|
+
const app = await this.getOwnedApp(privateId, storeId);
|
|
59
|
+
const appLabel = `${style.appName(app.name)} (${app.id})`;
|
|
60
|
+
if (app.type === 'theme') {
|
|
61
|
+
this.errorTheme();
|
|
62
|
+
}
|
|
63
|
+
const blockers = app.delete_blockers || [];
|
|
64
|
+
if (blockers.length > 0) {
|
|
65
|
+
this.errorBlocked(appLabel, blockers);
|
|
66
|
+
}
|
|
67
|
+
if (!confirmed) {
|
|
68
|
+
const continueDelete = await confirm({
|
|
69
|
+
default: false,
|
|
70
|
+
message: `Delete ${app.name} from store ${storeId}? This can't be undone.`,
|
|
71
|
+
});
|
|
72
|
+
if (!continueDelete) {
|
|
73
|
+
this.log('Delete cancelled.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// The BFF hands out a key, and deletes only when it is sent back
|
|
78
|
+
const challenge = await this.deleteRequest(app.id);
|
|
79
|
+
this.ensureDeleted(appLabel, challenge);
|
|
80
|
+
if (!challenge?.confirm_delete_key) {
|
|
81
|
+
this.error(`Unable to delete app ${appLabel}: no confirmation key.`);
|
|
82
|
+
}
|
|
83
|
+
const result = await this.deleteRequest(app.id, challenge.confirm_delete_key);
|
|
84
|
+
this.ensureDeleted(appLabel, result);
|
|
85
|
+
const swellRcRemoved = await this.removeSwellRc(app.id);
|
|
86
|
+
this.log(`✓ Deleted app ${appLabel}`);
|
|
87
|
+
this.log(` Store: ${style.storeId(storeId)}`);
|
|
88
|
+
if (!swellRcRemoved) {
|
|
89
|
+
this.log(` .swellrc kept: it names another app`);
|
|
90
|
+
}
|
|
91
|
+
this.log();
|
|
92
|
+
this.log(`Run ${style.command('swell app push')} to create it again as a new app.`);
|
|
93
|
+
}
|
|
94
|
+
async deleteRequest(id, confirmDeleteKey) {
|
|
95
|
+
try {
|
|
96
|
+
return await this.api.delete({ adminPath: `/apps/${id}` }, confirmDeleteKey
|
|
97
|
+
? { body: { confirm_delete_key: confirmDeleteKey } }
|
|
98
|
+
: {});
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return this.error(`Unable to delete app: ${errorMessage(error)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
ensureDeleted(appLabel, response) {
|
|
105
|
+
const error = response?.errors?.app;
|
|
106
|
+
if (error?.blockers?.length > 0) {
|
|
107
|
+
this.errorBlocked(appLabel, error.blockers);
|
|
108
|
+
}
|
|
109
|
+
if (response?.errors) {
|
|
110
|
+
const messages = Object.values(response.errors).map((err) => err?.message || String(err));
|
|
111
|
+
this.error(`Unable to delete app ${appLabel}: ${messages.join(' ')}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
errorTheme() {
|
|
115
|
+
this.error(`Themes can't be deleted with ${style.command('swell app delete')}. Delete an uploaded theme from the store's storefronts in the dashboard.`, { exit: 1 });
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Remove .swellrc when it holds the deleted app's id, so the next push
|
|
119
|
+
* creates a new app. Another id is left alone.
|
|
120
|
+
* @param {string} appId the deleted app
|
|
121
|
+
* @returns {Promise<boolean>} false when .swellrc names another app
|
|
122
|
+
*/
|
|
123
|
+
async removeSwellRc(appId) {
|
|
124
|
+
let rc;
|
|
125
|
+
try {
|
|
126
|
+
rc = await readRcFile(this.swellRcPath());
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error.code === 'ENOENT') {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (rc.id && rc.id !== appId) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
await fs.rm(this.swellRcPath(), { force: true });
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
errorBlocked(appLabel, blockers) {
|
|
141
|
+
this.error([
|
|
142
|
+
`App ${appLabel} can't be deleted:`,
|
|
143
|
+
...blockers.map((blocker) => ` - ${blocker.message}`),
|
|
144
|
+
].join('\n'), { exit: 1 });
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The app named by swell.json, with the reasons it can't be deleted.
|
|
148
|
+
* Only the store that owns it may delete it.
|
|
149
|
+
* @param {string} privateId app id from swell.json
|
|
150
|
+
* @param {string} storeId the logged in store
|
|
151
|
+
* @returns {Promise<any>} the app
|
|
152
|
+
*/
|
|
153
|
+
async getOwnedApp(privateId, storeId) {
|
|
154
|
+
let app;
|
|
155
|
+
try {
|
|
156
|
+
app = await this.api.get({ adminPath: `/apps/${privateId}` }, { query: { delete_blockers: true } });
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
const message = errorMessage(error);
|
|
160
|
+
if (message === 'App not found') {
|
|
161
|
+
this.error(`App ${style.appConfigValue(privateId)} was not found on store ${style.storeId(storeId)}. Nothing to delete.`, { exit: 1 });
|
|
162
|
+
}
|
|
163
|
+
this.error(`Unable to get app: ${message}`);
|
|
164
|
+
}
|
|
165
|
+
if (!app?.id || app.client_id !== storeId) {
|
|
166
|
+
this.error(`App ${style.appConfigValue(privateId)} is not owned by store ${style.storeId(storeId)}. Only the store that owns an app can delete it.`, { exit: 1 });
|
|
167
|
+
}
|
|
168
|
+
return app;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function errorMessage(error) {
|
|
172
|
+
try {
|
|
173
|
+
const json = JSON.parse(error.message);
|
|
174
|
+
return typeof json.error === 'string' ? json.error : json.error.message;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return error.message;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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,
|
package/dist/commands/schema.js
CHANGED
|
@@ -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) {
|