@swell/cli 2.9.20 → 2.9.22
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/install.d.ts +1 -0
- package/dist/commands/app/install.js +19 -1
- package/dist/commands/app/push.d.ts +14 -0
- package/dist/commands/app/push.js +67 -2
- package/dist/lib/apps/app-config.d.ts +11 -0
- package/dist/lib/apps/app-config.js +38 -3
- package/dist/lib/apps/frontend-package.js +16 -4
- package/dist/lib/apps/index.d.ts +22 -0
- package/dist/lib/apps/index.js +14 -0
- package/dist/lib/bundle.d.ts +8 -0
- package/dist/lib/bundle.js +21 -0
- package/dist/lib/create/tests/templates/mock-request.js +9 -28
- package/dist/lib/create/tests/templates/setup-globals.js +2 -17
- package/dist/lib/create/tests/templates/unit-test.js +1 -1
- package/dist/lib/create/tests.js +4 -0
- package/dist/push-app-command.d.ts +7 -0
- package/dist/push-app-command.js +48 -1
- package/dist/remote-app-command.d.ts +42 -1
- package/dist/remote-app-command.js +129 -7
- package/dist/types/index.d.ts +1 -0
- package/oclif.manifest.json +23 -1
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ export default class AppInstall extends RemoteAppCommand {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
+
conflicts: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
6
7
|
env: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
7
8
|
store: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
8
9
|
version: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
|
|
@@ -17,6 +17,10 @@ Without a store specified, install the app in the Live environment of the curren
|
|
|
17
17
|
'swell app install -v 1.0.0 -s my-store-id',
|
|
18
18
|
];
|
|
19
19
|
static flags = {
|
|
20
|
+
conflicts: Flags.string({
|
|
21
|
+
description: 'how to handle apps and integrations already using the same services',
|
|
22
|
+
options: ['ignore', 'disable'],
|
|
23
|
+
}),
|
|
20
24
|
env: Flags.string({
|
|
21
25
|
char: 'e',
|
|
22
26
|
description: 'target environment to install the app, defaults to live',
|
|
@@ -42,7 +46,7 @@ Without a store specified, install the app in the Live environment of the curren
|
|
|
42
46
|
static summary = 'Install an existing app in another store environment.';
|
|
43
47
|
async run() {
|
|
44
48
|
const { flags } = await this.parse(AppInstall);
|
|
45
|
-
const { env, store, version } = flags;
|
|
49
|
+
const { conflicts, env, store, version } = flags;
|
|
46
50
|
this.app = await this.getAppWithConfig();
|
|
47
51
|
const versions = await this.getVersions(version);
|
|
48
52
|
if (versions.length === 0) {
|
|
@@ -102,6 +106,15 @@ Without a store specified, install the app in the Live environment of the curren
|
|
|
102
106
|
if (!shouldContinue) {
|
|
103
107
|
return;
|
|
104
108
|
}
|
|
109
|
+
const noConflicts = await this.confirmIntegrationConflicts(this.app?.integrations || [], {
|
|
110
|
+
action: 'installing',
|
|
111
|
+
conflictsFlag: conflicts,
|
|
112
|
+
deferDisable: true,
|
|
113
|
+
excludeAppId: this.app.id,
|
|
114
|
+
});
|
|
115
|
+
if (!noConflicts) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
105
118
|
const spinner = ora();
|
|
106
119
|
this.log();
|
|
107
120
|
// install or update app depending on whether it's already installed
|
|
@@ -120,6 +133,11 @@ Without a store specified, install the app in the Live environment of the curren
|
|
|
120
133
|
}
|
|
121
134
|
else {
|
|
122
135
|
spinner.succeed(`${existingInstalledApp?.id ? 'Update' : 'Install'} complete.\n`);
|
|
136
|
+
// Only once the install succeeded, so a failure leaves the store as is.
|
|
137
|
+
// After the install spinner stops, so the two don't garble each other
|
|
138
|
+
if (!(await this.disablePendingConflicts())) {
|
|
139
|
+
this.log(`${style.logStatus('!')} ${style.appConfigValue(this.app.name)} is now active alongside the conflicting apps and integrations. Disable them in your dashboard.\n`);
|
|
140
|
+
}
|
|
123
141
|
this.log(`Manage app settings in your dashboard at ${style.link(this.dashboardUrl({ flags: { env: envToInstall } }, storeToInstall))}.`);
|
|
124
142
|
}
|
|
125
143
|
}
|
|
@@ -14,5 +14,19 @@ export default class AppPush extends PushAppCommand {
|
|
|
14
14
|
filePath: string;
|
|
15
15
|
relativePath: string;
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Conflicts are only worth raising when this push is what puts the app's
|
|
19
|
+
* `integrations` on the store: the first push, or one that changes them.
|
|
20
|
+
* Re-asking on every unrelated push would train developers to skip it.
|
|
21
|
+
*
|
|
22
|
+
* Runs before the app is created or updated, so backing out leaves the
|
|
23
|
+
* store as it was. Choosing to disable is deferred until the app exists.
|
|
24
|
+
*
|
|
25
|
+
* @param conflictsFlag pre-answers the prompt for a scripted run
|
|
26
|
+
* @param file the file or directory a scoped push targets, if any
|
|
27
|
+
* @returns whether to go ahead with the push
|
|
28
|
+
*/
|
|
29
|
+
confirmPushConflicts(conflictsFlag?: string, file?: string): Promise<boolean>;
|
|
17
30
|
run(): Promise<void>;
|
|
31
|
+
private exitIfFilesFailed;
|
|
18
32
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { affectsFrontendDeployment, AllConfigPaths, filePathExistsAsync, isPathDirectory, } from '../../lib/apps/index.js';
|
|
3
|
+
import { affectsFrontendDeployment, AllConfigPaths, filePathExistsAsync, isPathDirectory, sameIntegrations, } from '../../lib/apps/index.js';
|
|
4
|
+
import { default as localConfig } from '../../lib/config.js';
|
|
4
5
|
import style from '../../lib/style.js';
|
|
5
6
|
import { PushAppCommand } from '../../push-app-command.js';
|
|
6
7
|
export default class AppPush extends PushAppCommand {
|
|
@@ -37,6 +38,10 @@ ${Object.values(AllConfigPaths)
|
|
|
37
38
|
'swell app push frontend --no-deploy',
|
|
38
39
|
];
|
|
39
40
|
static flags = {
|
|
41
|
+
conflicts: Flags.string({
|
|
42
|
+
description: 'how to handle apps and integrations already using the same services',
|
|
43
|
+
options: ['ignore', 'disable'],
|
|
44
|
+
}),
|
|
40
45
|
force: Flags.boolean({
|
|
41
46
|
default: false,
|
|
42
47
|
description: 'force push all configurations',
|
|
@@ -61,10 +66,56 @@ ${Object.values(AllConfigPaths)
|
|
|
61
66
|
}
|
|
62
67
|
return { filePath, relativePath };
|
|
63
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Conflicts are only worth raising when this push is what puts the app's
|
|
71
|
+
* `integrations` on the store: the first push, or one that changes them.
|
|
72
|
+
* Re-asking on every unrelated push would train developers to skip it.
|
|
73
|
+
*
|
|
74
|
+
* Runs before the app is created or updated, so backing out leaves the
|
|
75
|
+
* store as it was. Choosing to disable is deferred until the app exists.
|
|
76
|
+
*
|
|
77
|
+
* @param conflictsFlag pre-answers the prompt for a scripted run
|
|
78
|
+
* @param file the file or directory a scoped push targets, if any
|
|
79
|
+
* @returns whether to go ahead with the push
|
|
80
|
+
*/
|
|
81
|
+
async confirmPushConflicts(conflictsFlag, file) {
|
|
82
|
+
const integrations = this.swellConfig.store?.integrations || [];
|
|
83
|
+
const klass = this.ctor;
|
|
84
|
+
// Nothing declared locally means nothing to contend over, including the
|
|
85
|
+
// case where a push removes the field
|
|
86
|
+
if (integrations.length === 0) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
// Look up the app and its conflicts in the environment being pushed to,
|
|
90
|
+
// not whatever the API client defaults to
|
|
91
|
+
const currentStore = localConfig.getDefaultStore();
|
|
92
|
+
await this.ensureLoggedIn();
|
|
93
|
+
await this.api.setStoreEnv(currentStore, klass.orientation.env);
|
|
94
|
+
const app = await this.getAppWithConfig('').catch(() => null);
|
|
95
|
+
// An app owned by another client isn't the one being pushed to:
|
|
96
|
+
// ensureAppExists() creates a new one on this store in its place
|
|
97
|
+
const isForeignApp = Boolean(app?.client_id && app.client_id !== currentStore);
|
|
98
|
+
// A scoped push leaves the app's integrations as they are, unless it has
|
|
99
|
+
// to create the app, so there is nothing new to contend over
|
|
100
|
+
if (file && !isForeignApp) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (app &&
|
|
104
|
+
!isForeignApp &&
|
|
105
|
+
sameIntegrations(app.integrations, integrations)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return this.confirmIntegrationConflicts(integrations, {
|
|
109
|
+
action: 'pushing',
|
|
110
|
+
conflictsFlag,
|
|
111
|
+
deferDisable: true,
|
|
112
|
+
excludeAppId: isForeignApp ? undefined : app?.id,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
64
115
|
async run() {
|
|
65
116
|
const { args, flags } = await this.parse(AppPush);
|
|
66
117
|
const { file } = args;
|
|
67
|
-
const { force } = flags;
|
|
118
|
+
const { conflicts, force } = flags;
|
|
68
119
|
const noDeploy = flags['no-deploy'];
|
|
69
120
|
if (this.swellConfig.store?.type === 'theme') {
|
|
70
121
|
await this.config.runCommand('theme:push', [
|
|
@@ -75,9 +126,15 @@ ${Object.values(AllConfigPaths)
|
|
|
75
126
|
return;
|
|
76
127
|
}
|
|
77
128
|
await this.logOrientation();
|
|
129
|
+
if (!(await this.confirmPushConflicts(conflicts, file))) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
78
132
|
if (!(await this.ensureAppExists(file))) {
|
|
79
133
|
return;
|
|
80
134
|
}
|
|
135
|
+
if (!(await this.disablePendingConflicts())) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
81
138
|
if (file) {
|
|
82
139
|
const { filePath, relativePath } = this.resolvePushPaths(file, flags['app-path']);
|
|
83
140
|
// Push individual files if not referring to the whole app path
|
|
@@ -96,6 +153,7 @@ ${Object.values(AllConfigPaths)
|
|
|
96
153
|
}
|
|
97
154
|
this.log();
|
|
98
155
|
this.logDashboardUrl(flags);
|
|
156
|
+
this.exitIfFilesFailed();
|
|
99
157
|
return;
|
|
100
158
|
}
|
|
101
159
|
}
|
|
@@ -105,5 +163,12 @@ ${Object.values(AllConfigPaths)
|
|
|
105
163
|
this.log();
|
|
106
164
|
}
|
|
107
165
|
this.logDashboardUrl(flags);
|
|
166
|
+
this.exitIfFilesFailed();
|
|
167
|
+
}
|
|
168
|
+
// A skipped file is a failed push, for scripts and CI too
|
|
169
|
+
exitIfFilesFailed() {
|
|
170
|
+
if (this.failedFiles.length > 0) {
|
|
171
|
+
this.error(`${this.failedFiles.length} file(s) failed to push: ${this.failedFiles.join(', ')}`);
|
|
172
|
+
}
|
|
108
173
|
}
|
|
109
174
|
}
|
|
@@ -45,6 +45,16 @@ export declare abstract class AppConfig {
|
|
|
45
45
|
version?: string;
|
|
46
46
|
constructor(attrs?: Partial<AppConfig>);
|
|
47
47
|
load(): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* The hash push compares with the pushed config to skip unchanged files
|
|
50
|
+
* @returns {Promise<string | undefined>} the hash
|
|
51
|
+
*/
|
|
52
|
+
protected contentHash(): Promise<string | undefined>;
|
|
53
|
+
/**
|
|
54
|
+
* Full path of the app directory this config belongs to
|
|
55
|
+
* @returns {string} the path
|
|
56
|
+
*/
|
|
57
|
+
protected appRoot(): string;
|
|
48
58
|
static create(attrs?: Partial<AppConfig>): Promise<AppConfig>;
|
|
49
59
|
isRootConfig(configDir: string): boolean;
|
|
50
60
|
postData(): Promise<any>;
|
|
@@ -88,6 +98,7 @@ export declare class AppConfigFunction extends AppConfig {
|
|
|
88
98
|
hasValues: boolean;
|
|
89
99
|
type: ConfigType;
|
|
90
100
|
isRootFunction(): boolean;
|
|
101
|
+
protected contentHash(): Promise<string | undefined>;
|
|
91
102
|
preparePostData(postData: any): Promise<any>;
|
|
92
103
|
}
|
|
93
104
|
export declare class AppConfigComponent extends AppConfig {
|
|
@@ -3,9 +3,9 @@ import isEmpty from 'lodash/isEmpty.js';
|
|
|
3
3
|
import { detectFilenameMime } from 'mime-detect';
|
|
4
4
|
import * as fs from 'node:fs/promises';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
|
-
import { bundleFunction, bundleComponent, bundleWorkflow } from '../bundle.js';
|
|
6
|
+
import { bundleFunction, bundleComponent, bundleWorkflow, getLocalInputs, } from '../bundle.js';
|
|
7
7
|
import { analyzeFunctionSource, hasKindDiagnostic, workflowStaticConfigError, } from '../function-source-analysis.js';
|
|
8
|
-
import { AllConfigPaths, ConfigType, filePathExistsAsync, hashFile, } from './index.js';
|
|
8
|
+
import { AllConfigPaths, ConfigType, filePathExistsAsync, hashFile, hashString, } from './index.js';
|
|
9
9
|
export class IgnoringFileError extends Error {
|
|
10
10
|
constructor(message) {
|
|
11
11
|
super(message);
|
|
@@ -83,11 +83,25 @@ export class AppConfig {
|
|
|
83
83
|
if (await filePathExistsAsync(this.appPath)) {
|
|
84
84
|
this.fileData = await fs.readFile(this.appPath);
|
|
85
85
|
this.fileHash = hashFile(this.appPath, this.fileData, this.filePath);
|
|
86
|
-
this.hash = this.hash || this.
|
|
86
|
+
this.hash = this.hash || (await this.contentHash());
|
|
87
87
|
this.byteSize = this.fileData.length;
|
|
88
88
|
this.mbSize = this.byteSize / 1024 / 1024;
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The hash push compares with the pushed config to skip unchanged files
|
|
93
|
+
* @returns {Promise<string | undefined>} the hash
|
|
94
|
+
*/
|
|
95
|
+
async contentHash() {
|
|
96
|
+
return this.fileHash;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Full path of the app directory this config belongs to
|
|
100
|
+
* @returns {string} the path
|
|
101
|
+
*/
|
|
102
|
+
appRoot() {
|
|
103
|
+
return path.resolve(this.appPath, ...this.filePath.split('/').map(() => '..'));
|
|
104
|
+
}
|
|
91
105
|
static async create(attrs = {}) {
|
|
92
106
|
let instance;
|
|
93
107
|
switch (attrs.type) {
|
|
@@ -263,6 +277,27 @@ export class AppConfigFunction extends AppConfig {
|
|
|
263
277
|
return (this.isRootConfig('functions') &&
|
|
264
278
|
(this.filePath.endsWith('.js') || this.filePath.endsWith('.ts')));
|
|
265
279
|
}
|
|
280
|
+
// A function deploys as a bundle, so the hash includes the local files it
|
|
281
|
+
// imports: a change in shared code under functions/lib redeploys it
|
|
282
|
+
async contentHash() {
|
|
283
|
+
if (!this.isRootFunction() || !this.fileHash) {
|
|
284
|
+
return this.fileHash;
|
|
285
|
+
}
|
|
286
|
+
const appRoot = this.appRoot();
|
|
287
|
+
let inputs;
|
|
288
|
+
try {
|
|
289
|
+
inputs = await getLocalInputs(this.appPath, appRoot);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// Unknown until it compiles: push tries it and reports the error
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
const imports = inputs
|
|
296
|
+
.filter((input) => input !== this.filePath)
|
|
297
|
+
.sort()
|
|
298
|
+
.map((input) => hashFile(path.join(appRoot, input), undefined, input));
|
|
299
|
+
return hashString(this.fileHash, ...imports);
|
|
300
|
+
}
|
|
266
301
|
async preparePostData(postData) {
|
|
267
302
|
if (this.isRootFunction()) {
|
|
268
303
|
try {
|
|
@@ -3,7 +3,7 @@ import { execa } from 'execa';
|
|
|
3
3
|
import { isIgnoredByIgnoreFiles } from 'globby';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import * as fs from 'node:fs/promises';
|
|
6
|
-
import { createRequire } from 'node:module';
|
|
6
|
+
import { createRequire, isBuiltin } from 'node:module';
|
|
7
7
|
import * as os from 'node:os';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
@@ -155,13 +155,25 @@ export async function checkModuleGraph(pkg) {
|
|
|
155
155
|
name: 'retained-frontend-modules',
|
|
156
156
|
setup(builder) {
|
|
157
157
|
builder.onResolve({ filter: /.*/ }, (args) => {
|
|
158
|
-
|
|
158
|
+
// The provider validates runtime module names at upload, so a
|
|
159
|
+
// scheme is never checked against the Node version running here.
|
|
160
|
+
if (args.kind !== 'entry-point' &&
|
|
161
|
+
(/^(?:node|cloudflare):/.test(args.path) || isBuiltin(args.path)))
|
|
159
162
|
return { path: args.path, external: true };
|
|
163
|
+
// Like the runtime, resolve any other name, bare or relative,
|
|
164
|
+
// against the importer's directory.
|
|
160
165
|
const name = args.kind === 'entry-point'
|
|
161
166
|
? args.path
|
|
162
167
|
: path.posix.join(path.posix.dirname(args.importer), args.path);
|
|
163
|
-
if (!modules.has(name))
|
|
164
|
-
|
|
168
|
+
if (!modules.has(name)) {
|
|
169
|
+
const from = args.importer
|
|
170
|
+
? ` (imported as "${args.path}" from "${args.importer}")`
|
|
171
|
+
: '';
|
|
172
|
+
const hint = args.importer && /^[\w@]/.test(args.path)
|
|
173
|
+
? '. npm dependencies must be bundled.'
|
|
174
|
+
: '';
|
|
175
|
+
throw new Error(`Missing frontend Worker module: ${name}${from}${hint}`);
|
|
176
|
+
}
|
|
165
177
|
return { path: name, namespace: 'frontend' };
|
|
166
178
|
});
|
|
167
179
|
builder.onLoad({ filter: /.*/, namespace: 'frontend' }, (args) => {
|
package/dist/lib/apps/index.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ export interface App {
|
|
|
50
50
|
date_created: string;
|
|
51
51
|
id: string;
|
|
52
52
|
};
|
|
53
|
+
integrations?: string[];
|
|
53
54
|
kind?: string;
|
|
54
55
|
logo_icon?: unknown;
|
|
55
56
|
name?: string;
|
|
@@ -90,10 +91,31 @@ export interface App {
|
|
|
90
91
|
use_cases?: string[];
|
|
91
92
|
version?: string;
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Whether two `integrations` declarations name the same services. Order and
|
|
96
|
+
* duplicates do not matter — only which services are claimed.
|
|
97
|
+
*
|
|
98
|
+
* @param a one declaration
|
|
99
|
+
* @param b the other
|
|
100
|
+
* @returns whether they claim the same services
|
|
101
|
+
*/
|
|
102
|
+
export declare function sameIntegrations(a: string[] | undefined, b: string[] | undefined): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Something on a store already using a service an app declares in its
|
|
105
|
+
* swell.json `integrations` — either a Swell integration or another installed
|
|
106
|
+
* app. Reported by the admin backend's conflict check.
|
|
107
|
+
*/
|
|
108
|
+
export interface AppConflict {
|
|
109
|
+
id: string;
|
|
110
|
+
integrations?: string[];
|
|
111
|
+
name?: string;
|
|
112
|
+
type: 'app' | 'integration';
|
|
113
|
+
}
|
|
93
114
|
export declare enum SwellJsonFields {
|
|
94
115
|
DESCRIPTION = "description",
|
|
95
116
|
EXTENSIONS = "extensions",
|
|
96
117
|
ID = "id",
|
|
118
|
+
INTEGRATIONS = "integrations",
|
|
97
119
|
KIND = "kind",
|
|
98
120
|
NAME = "name",
|
|
99
121
|
PERMISSIONS = "permissions",
|
package/dist/lib/apps/index.js
CHANGED
|
@@ -7,11 +7,25 @@ import { AppConfig } from './app-config.js';
|
|
|
7
7
|
export { AppConfig, FunctionProcessingError, IgnoringFileError, } from './app-config.js';
|
|
8
8
|
export { affectsFrontendDeployment, allBaseFilesInDir, allConfigDirsPaths, allConfigFilesInDir, allConfigFilesPaths, allConfigFilesPathsByType, getAllConfigPaths, globAllFilesByPath, isPathDirectory, toPosixPath, } from './paths.js';
|
|
9
9
|
export const PUSH_CONCURRENCY = 3;
|
|
10
|
+
/**
|
|
11
|
+
* Whether two `integrations` declarations name the same services. Order and
|
|
12
|
+
* duplicates do not matter — only which services are claimed.
|
|
13
|
+
*
|
|
14
|
+
* @param a one declaration
|
|
15
|
+
* @param b the other
|
|
16
|
+
* @returns whether they claim the same services
|
|
17
|
+
*/
|
|
18
|
+
export function sameIntegrations(a, b) {
|
|
19
|
+
const left = new Set(a || []);
|
|
20
|
+
const right = new Set(b || []);
|
|
21
|
+
return (left.size === right.size && [...left].every((value) => right.has(value)));
|
|
22
|
+
}
|
|
10
23
|
export var SwellJsonFields;
|
|
11
24
|
(function (SwellJsonFields) {
|
|
12
25
|
SwellJsonFields["DESCRIPTION"] = "description";
|
|
13
26
|
SwellJsonFields["EXTENSIONS"] = "extensions";
|
|
14
27
|
SwellJsonFields["ID"] = "id";
|
|
28
|
+
SwellJsonFields["INTEGRATIONS"] = "integrations";
|
|
15
29
|
SwellJsonFields["KIND"] = "kind";
|
|
16
30
|
SwellJsonFields["NAME"] = "name";
|
|
17
31
|
SwellJsonFields["PERMISSIONS"] = "permissions";
|
package/dist/lib/bundle.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { FunctionSourceAnalysis } from './function-source-analysis.js';
|
|
2
|
+
/**
|
|
3
|
+
* Local source files an entry point imports, itself included, relative to
|
|
4
|
+
* rootDir. Packages are not followed: only the app's own files are listed.
|
|
5
|
+
* @param {string} entryPoint path of the entry point, relative to rootDir
|
|
6
|
+
* @param {string} rootDir directory the paths are relative to
|
|
7
|
+
* @returns {Promise<string[]>} paths of the imported files
|
|
8
|
+
*/
|
|
9
|
+
export declare function getLocalInputs(entryPoint: string, rootDir: string): Promise<string[]>;
|
|
2
10
|
export declare function bundleFunction(filePath: string): Promise<any>;
|
|
3
11
|
export declare function bundleWorkflow(filePath: string, analysis: FunctionSourceAnalysis): Promise<any>;
|
|
4
12
|
export declare function bundleComponent(filePath: string): Promise<any>;
|
package/dist/lib/bundle.js
CHANGED
|
@@ -2,6 +2,27 @@ import * as esbuild from 'esbuild';
|
|
|
2
2
|
import * as fs from 'node:fs/promises';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
/**
|
|
6
|
+
* Local source files an entry point imports, itself included, relative to
|
|
7
|
+
* rootDir. Packages are not followed: only the app's own files are listed.
|
|
8
|
+
* @param {string} entryPoint path of the entry point, relative to rootDir
|
|
9
|
+
* @param {string} rootDir directory the paths are relative to
|
|
10
|
+
* @returns {Promise<string[]>} paths of the imported files
|
|
11
|
+
*/
|
|
12
|
+
export async function getLocalInputs(entryPoint, rootDir) {
|
|
13
|
+
const { metafile } = await esbuild.build({
|
|
14
|
+
absWorkingDir: rootDir,
|
|
15
|
+
bundle: true,
|
|
16
|
+
entryPoints: [entryPoint],
|
|
17
|
+
external: ['cloudflare:*'],
|
|
18
|
+
logLevel: 'silent',
|
|
19
|
+
metafile: true,
|
|
20
|
+
packages: 'external',
|
|
21
|
+
platform: 'node',
|
|
22
|
+
write: false,
|
|
23
|
+
});
|
|
24
|
+
return Object.keys(metafile.inputs);
|
|
25
|
+
}
|
|
5
26
|
export async function bundleFunction(filePath) {
|
|
6
27
|
try {
|
|
7
28
|
const buildResult = await esbuild.build({
|
|
@@ -46,6 +46,7 @@ export function createMockRequest(options: MockRequestOptions = {}): SwellReques
|
|
|
46
46
|
} = options;
|
|
47
47
|
|
|
48
48
|
const requestHeaders = new Headers(headers);
|
|
49
|
+
const requestQuery = { ...Object.fromEntries(new URL(url).searchParams), ...query };
|
|
49
50
|
const originalRequest = new Request(url, {
|
|
50
51
|
method,
|
|
51
52
|
headers: requestHeaders,
|
|
@@ -88,40 +89,20 @@ export function createMockRequest(options: MockRequestOptions = {}): SwellReques
|
|
|
88
89
|
apiHost: env.SWELL_API_BASE_URL || "",
|
|
89
90
|
logParams: undefined,
|
|
90
91
|
swell: swellClient as SwellAPI,
|
|
91
|
-
|
|
92
|
-
data,
|
|
93
|
-
|
|
92
|
+
rawBody: method === "GET" ? "" : JSON.stringify(data),
|
|
93
|
+
body: method === "GET" ? "" : { ...data },
|
|
94
|
+
data: { ...(method === "GET" ? {} : data), ...requestQuery },
|
|
95
|
+
query: requestQuery,
|
|
94
96
|
initialize: async () => {},
|
|
95
|
-
parseJson: (input: string) =>
|
|
97
|
+
parseJson: (input: string) => {
|
|
98
|
+
try { return JSON.parse(input); } catch { return {}; }
|
|
99
|
+
},
|
|
96
100
|
reject: (
|
|
97
101
|
code: string,
|
|
98
102
|
message: string,
|
|
99
103
|
options: { status?: number } = {}
|
|
100
104
|
) => {
|
|
101
|
-
|
|
102
|
-
typeof options.status === "number" &&
|
|
103
|
-
options.status >= 400 &&
|
|
104
|
-
options.status < 500
|
|
105
|
-
? options.status
|
|
106
|
-
: 422;
|
|
107
|
-
const error = new Error(
|
|
108
|
-
message || "Request rejected by function"
|
|
109
|
-
) as Error & {
|
|
110
|
-
body: { $reject: { code: string; message: string; status: number } };
|
|
111
|
-
code: string;
|
|
112
|
-
status: number;
|
|
113
|
-
};
|
|
114
|
-
error.name = "SwellRejection";
|
|
115
|
-
error.code = code;
|
|
116
|
-
error.status = status;
|
|
117
|
-
error.body = {
|
|
118
|
-
$reject: {
|
|
119
|
-
code,
|
|
120
|
-
message: error.message,
|
|
121
|
-
status,
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
return error;
|
|
105
|
+
return new SwellRejection(code, message, options);
|
|
125
106
|
},
|
|
126
107
|
appValues: (idOrValues: string | SwellData, values?: SwellData) => {
|
|
127
108
|
const targetAppId =
|
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
export function setupGlobalsTemplate() {
|
|
2
2
|
return `\
|
|
3
|
-
|
|
4
|
-
status: number;
|
|
5
|
-
body?: unknown;
|
|
6
|
-
|
|
7
|
-
constructor(message: string | object, options: { status?: number } = {}) {
|
|
8
|
-
const text =
|
|
9
|
-
typeof message === "string"
|
|
10
|
-
? message
|
|
11
|
-
: JSON.stringify(message, null, 2);
|
|
12
|
-
|
|
13
|
-
super(text);
|
|
14
|
-
this.name = "SwellError";
|
|
15
|
-
this.status = options.status ?? 500;
|
|
16
|
-
this.body = typeof message === "string" ? undefined : message;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
3
|
+
import { SwellError } from "@swell/apps-sdk";
|
|
19
4
|
|
|
20
5
|
class SwellRejectionImpl extends Error {
|
|
21
6
|
status: number;
|
|
@@ -51,7 +36,7 @@ class SwellRejectionImpl extends Error {
|
|
|
51
36
|
}
|
|
52
37
|
|
|
53
38
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
-
(globalThis as any).SwellError =
|
|
39
|
+
(globalThis as any).SwellError = SwellError;
|
|
55
40
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
41
|
(globalThis as any).SwellRejection = SwellRejectionImpl;
|
|
57
42
|
|
package/dist/lib/create/tests.js
CHANGED
|
@@ -7,6 +7,7 @@ const TEST_DEV_DEPENDENCIES = {
|
|
|
7
7
|
'@cloudflare/vitest-pool-workers': '0.12.x',
|
|
8
8
|
vitest: '3.2.x',
|
|
9
9
|
'@swell/app-types': 'latest',
|
|
10
|
+
'@swell/apps-sdk': '2.0.0-alpha.0',
|
|
10
11
|
};
|
|
11
12
|
async function writeTextFile(filePath, contents, overwrite) {
|
|
12
13
|
if (!overwrite && (await filePathExistsAsync(filePath))) {
|
|
@@ -52,6 +53,9 @@ async function updatePackageJson(appPath) {
|
|
|
52
53
|
if (!devDeps['@swell/app-types'] && !deps['@swell/app-types']) {
|
|
53
54
|
devDeps['@swell/app-types'] = TEST_DEV_DEPENDENCIES['@swell/app-types'];
|
|
54
55
|
}
|
|
56
|
+
if (!devDeps['@swell/apps-sdk'] && !deps['@swell/apps-sdk']) {
|
|
57
|
+
devDeps['@swell/apps-sdk'] = TEST_DEV_DEPENDENCIES['@swell/apps-sdk'];
|
|
58
|
+
}
|
|
55
59
|
pkg.devDependencies = devDeps;
|
|
56
60
|
const scripts = (pkg.scripts || {});
|
|
57
61
|
scripts.test = scripts.test || 'vitest run';
|
|
@@ -48,6 +48,13 @@ export declare abstract class PushAppCommand extends RemoteAppCommand {
|
|
|
48
48
|
}, shouldChoose?: boolean): Promise<App | undefined>;
|
|
49
49
|
getExistingApp(appId: string): Promise<App>;
|
|
50
50
|
getFrontendDeploymentHash(): Promise<string | undefined>;
|
|
51
|
+
/**
|
|
52
|
+
* Files outside frontend/ that the worker's entry point imports, relative to
|
|
53
|
+
* the app path. Only a local entry point set by `main` in the wrangler
|
|
54
|
+
* config is followed; frameworks that build their own entry are not.
|
|
55
|
+
* @returns {Promise<string[]>} paths of the imported files
|
|
56
|
+
*/
|
|
57
|
+
getFrontendSharedInputs(): Promise<string[]>;
|
|
51
58
|
getFrontendProjectType(required?: boolean): Promise<FrontendProjectType | null>;
|
|
52
59
|
getStorefrontToPull(args?: {
|
|
53
60
|
appId?: string;
|
package/dist/push-app-command.js
CHANGED
|
@@ -8,9 +8,10 @@ import ora from 'ora';
|
|
|
8
8
|
import { default as swellConfig } from './lib/app-config.js';
|
|
9
9
|
import { buildFrontendPackage, getManagedFrontendFramework, } from './lib/apps/frontend-package.js';
|
|
10
10
|
import { warnFrontendRouteCollisions } from './lib/apps/frontend-routes.js';
|
|
11
|
-
import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExistsAsync, findAppConfig, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashString, isPathDirectory, toPosixPath, } from './lib/apps/index.js';
|
|
11
|
+
import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExistsAsync, findAppConfig, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashFile, hashString, isPathDirectory, toPosixPath, } from './lib/apps/index.js';
|
|
12
12
|
import { getGlobIgnorePathsChecker } from './lib/apps/paths.js';
|
|
13
13
|
import { slugFromApp } from './lib/apps/slug.js';
|
|
14
|
+
import { getLocalInputs } from './lib/bundle.js';
|
|
14
15
|
import { default as localConfig } from './lib/config.js';
|
|
15
16
|
import { toAppId } from './lib/create/index.js';
|
|
16
17
|
import { detectPackageManager, transformCommand, } from './lib/package-manager.js';
|
|
@@ -21,6 +22,27 @@ import { RemoteAppCommand } from './remote-app-command.js';
|
|
|
21
22
|
const PUSH_CONCURRENCY = 3;
|
|
22
23
|
const WATCH_WINDOW_MS = 100;
|
|
23
24
|
const DEV_SERVER_FALLBACK_PORT = 3000;
|
|
25
|
+
const WRANGLER_CONFIG_FILES = [
|
|
26
|
+
'wrangler.jsonc',
|
|
27
|
+
'wrangler.json',
|
|
28
|
+
'wrangler.toml',
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* The `main` entry point of a wrangler config (JSON, JSONC or TOML)
|
|
32
|
+
* @param {string} frontendPath directory of the wrangler config
|
|
33
|
+
* @returns {string | undefined} the entry point, relative to frontendPath
|
|
34
|
+
*/
|
|
35
|
+
function readWranglerMain(frontendPath) {
|
|
36
|
+
for (const name of WRANGLER_CONFIG_FILES) {
|
|
37
|
+
const configPath = path.join(frontendPath, name);
|
|
38
|
+
if (fs.existsSync(configPath)) {
|
|
39
|
+
// "main": "src/index.ts" in JSON, main = "src/index.ts" in TOML
|
|
40
|
+
return fs
|
|
41
|
+
.readFileSync(configPath, 'utf8')
|
|
42
|
+
.match(/^\s*"?main"?\s*[:=]\s*"([^"]+)"/m)?.[1];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
24
46
|
export class PushAppCommand extends RemoteAppCommand {
|
|
25
47
|
frontendPath = '';
|
|
26
48
|
hasLoggedLocalPreviewUrl = false;
|
|
@@ -466,9 +488,34 @@ export class PushAppCommand extends RemoteAppCommand {
|
|
|
466
488
|
if (frontendHashes.length > 0) {
|
|
467
489
|
const packageHash = localConfigs.find((config) => config.filePath === 'package.json')?.hash;
|
|
468
490
|
frontendHashes += `|${packageHash}`;
|
|
491
|
+
// Shared code the worker imports from outside frontend/, e.g. functions/lib
|
|
492
|
+
for (const input of await this.getFrontendSharedInputs()) {
|
|
493
|
+
frontendHashes += `|${hashFile(path.join(this.appPath, input), undefined, input)}`;
|
|
494
|
+
}
|
|
469
495
|
}
|
|
470
496
|
return frontendHashes.length > 0 ? hashString(frontendHashes) : undefined;
|
|
471
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Files outside frontend/ that the worker's entry point imports, relative to
|
|
500
|
+
* the app path. Only a local entry point set by `main` in the wrangler
|
|
501
|
+
* config is followed; frameworks that build their own entry are not.
|
|
502
|
+
* @returns {Promise<string[]>} paths of the imported files
|
|
503
|
+
*/
|
|
504
|
+
async getFrontendSharedInputs() {
|
|
505
|
+
const main = readWranglerMain(path.join(this.appPath, 'frontend'));
|
|
506
|
+
const entry = main && toPosixPath(path.join('frontend', main));
|
|
507
|
+
if (!entry || !fs.existsSync(path.join(this.appPath, entry))) {
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const inputs = await getLocalInputs(entry, this.appPath);
|
|
512
|
+
return inputs.filter((input) => !input.startsWith('frontend/')).sort();
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// Build errors surface when wrangler deploys the worker
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
}
|
|
472
519
|
async getFrontendProjectType(required = true) {
|
|
473
520
|
this.frontendPath = path.join(this.appPath, 'frontend');
|
|
474
521
|
const frontendExists = await filePathExistsAsync(this.frontendPath);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Ora } from 'ora';
|
|
2
2
|
import type { AppCommandOrientation } from './types/index.js';
|
|
3
3
|
import { AppCommand } from './app-command.js';
|
|
4
|
-
import { App, AppConfig, AppVersion } from './lib/apps/index.js';
|
|
4
|
+
import { App, AppConfig, AppConflict, AppVersion } from './lib/apps/index.js';
|
|
5
5
|
/**
|
|
6
6
|
* Extends the AppCommand with methods that ease managing remote apps and
|
|
7
7
|
* configurations.
|
|
@@ -12,6 +12,8 @@ export declare abstract class RemoteAppCommand extends AppCommand {
|
|
|
12
12
|
app: App;
|
|
13
13
|
appCreated: boolean;
|
|
14
14
|
appType: string;
|
|
15
|
+
failedFiles: string[];
|
|
16
|
+
protected pendingConflicts?: AppConflict[];
|
|
15
17
|
storefront?: any;
|
|
16
18
|
storefrontLocalUrl?: string;
|
|
17
19
|
themeSyncApp?: boolean;
|
|
@@ -26,6 +28,45 @@ export declare abstract class RemoteAppCommand extends AppCommand {
|
|
|
26
28
|
getConfigData(configId: string): Promise<any>;
|
|
27
29
|
protected getCreateUpdateApp(updateApp?: App | null, force?: boolean): Promise<App>;
|
|
28
30
|
protected getInstalledApp(): Promise<any>;
|
|
31
|
+
/**
|
|
32
|
+
* Anything on the target store already using the services named by
|
|
33
|
+
* `integrations`. Answered by the admin backend so the CLI and the dashboard
|
|
34
|
+
* agree on what counts as a conflict.
|
|
35
|
+
*
|
|
36
|
+
* @param integrations services the app declares in swell.json
|
|
37
|
+
* @param excludeAppId app that must not be reported against itself
|
|
38
|
+
* @returns the conflicts, or none when the lookup fails
|
|
39
|
+
*/
|
|
40
|
+
protected getAppConflicts(integrations: string[], excludeAppId?: string): Promise<AppConflict[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Disable what confirmIntegrationConflicts() deferred, once the command is
|
|
43
|
+
* past the points where it could still back out.
|
|
44
|
+
*
|
|
45
|
+
* @returns whether they were all disabled
|
|
46
|
+
*/
|
|
47
|
+
protected disablePendingConflicts(): Promise<boolean>;
|
|
48
|
+
/**
|
|
49
|
+
* Switch off every listed app and integration.
|
|
50
|
+
*
|
|
51
|
+
* @param conflicts what to disable
|
|
52
|
+
* @returns whether they were all disabled
|
|
53
|
+
*/
|
|
54
|
+
protected disableAppConflicts(conflicts: AppConflict[]): Promise<boolean>;
|
|
55
|
+
/**
|
|
56
|
+
* List what already uses the services `integrations` names, and settle what
|
|
57
|
+
* to do about it. `conflictsFlag` pre-answers the prompt for a scripted run;
|
|
58
|
+
* cancelling is deliberately interactive-only.
|
|
59
|
+
*
|
|
60
|
+
* @param integrations services the app declares in swell.json
|
|
61
|
+
* @param options how to ask and what to leave out
|
|
62
|
+
* @returns whether to go ahead
|
|
63
|
+
*/
|
|
64
|
+
protected confirmIntegrationConflicts(integrations: string[], { action, conflictsFlag, excludeAppId, deferDisable, }: {
|
|
65
|
+
action: string;
|
|
66
|
+
conflictsFlag?: string;
|
|
67
|
+
deferDisable?: boolean;
|
|
68
|
+
excludeAppId?: string;
|
|
69
|
+
}): Promise<boolean>;
|
|
29
70
|
protected getLocalAppConfigs(): Promise<AppConfig[]>;
|
|
30
71
|
protected getVersion(version?: null | string | undefined): Promise<AppVersion>;
|
|
31
72
|
protected getVersions(version?: null | string | undefined): Promise<AppVersion[]>;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { confirm } from '@inquirer/prompts';
|
|
1
|
+
import { confirm, select } from '@inquirer/prompts';
|
|
2
2
|
import { inflect } from 'inflection';
|
|
3
3
|
import sortBy from 'lodash/sortBy.js';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { default as ora } from 'ora';
|
|
6
6
|
import { AppCommand } from './app-command.js';
|
|
7
7
|
import { readRcFile, writeRcFile } from './lib/app-config.js';
|
|
8
|
-
import { AppConfig, ConfigType, FunctionProcessingError, IgnoringFileError, SwellJsonFields, allBaseFilesInDir, allConfigFilesPaths, appConfigFromFile, batchAppFilesByType, deleteFile,
|
|
8
|
+
import { AppConfig, ConfigType, FunctionProcessingError, IgnoringFileError, SwellJsonFields, allBaseFilesInDir, allConfigFilesPaths, appConfigFromFile, batchAppFilesByType, deleteFile, writeFile, writeJsonFile, PUSH_CONCURRENCY, } from './lib/apps/index.js';
|
|
9
9
|
import { default as localConfig } from './lib/config.js';
|
|
10
10
|
import { getAppFrontendHost, getLocalFrontendHost, getLoginHost, getStorefrontFrontendHost, } from './lib/constants.js';
|
|
11
11
|
import { toAppId } from './lib/create/index.js';
|
|
@@ -24,6 +24,10 @@ export class RemoteAppCommand extends AppCommand {
|
|
|
24
24
|
// Indicates the app was created during the command execution
|
|
25
25
|
appCreated = false;
|
|
26
26
|
appType = 'any';
|
|
27
|
+
// Files skipped because they could not be read or compiled
|
|
28
|
+
failedFiles = [];
|
|
29
|
+
// Conflicts the user chose to disable, held until the command commits
|
|
30
|
+
pendingConflicts;
|
|
27
31
|
// API instance of the target storefront, if applicable
|
|
28
32
|
storefront = null;
|
|
29
33
|
// Indicates we are syncing with a local app frontend via proxy
|
|
@@ -258,6 +262,122 @@ export class RemoteAppCommand extends AppCommand {
|
|
|
258
262
|
// check if app is already installed on store
|
|
259
263
|
return this.api.get({ adminPath: `/client/apps/${this.app.id}` }, {});
|
|
260
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Anything on the target store already using the services named by
|
|
267
|
+
* `integrations`. Answered by the admin backend so the CLI and the dashboard
|
|
268
|
+
* agree on what counts as a conflict.
|
|
269
|
+
*
|
|
270
|
+
* @param integrations services the app declares in swell.json
|
|
271
|
+
* @param excludeAppId app that must not be reported against itself
|
|
272
|
+
* @returns the conflicts, or none when the lookup fails
|
|
273
|
+
*/
|
|
274
|
+
async getAppConflicts(integrations, excludeAppId) {
|
|
275
|
+
if (integrations.length === 0) {
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const result = await this.api.post({ adminPath: '/client/conflicts' }, {
|
|
280
|
+
body: {
|
|
281
|
+
exclude_app_id: excludeAppId,
|
|
282
|
+
integrations,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
return result?.conflicts || [];
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// A conflict lookup that fails must not block the command
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Disable what confirmIntegrationConflicts() deferred, once the command is
|
|
294
|
+
* past the points where it could still back out.
|
|
295
|
+
*
|
|
296
|
+
* @returns whether they were all disabled
|
|
297
|
+
*/
|
|
298
|
+
async disablePendingConflicts() {
|
|
299
|
+
const conflicts = this.pendingConflicts;
|
|
300
|
+
this.pendingConflicts = undefined;
|
|
301
|
+
return conflicts ? this.disableAppConflicts(conflicts) : true;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Switch off every listed app and integration.
|
|
305
|
+
*
|
|
306
|
+
* @param conflicts what to disable
|
|
307
|
+
* @returns whether they were all disabled
|
|
308
|
+
*/
|
|
309
|
+
async disableAppConflicts(conflicts) {
|
|
310
|
+
const spinner = ora();
|
|
311
|
+
spinner.start('Disabling conflicting apps and integrations...');
|
|
312
|
+
try {
|
|
313
|
+
await this.api.post({ adminPath: '/client/conflicts/resolve' }, {
|
|
314
|
+
body: {
|
|
315
|
+
apps: conflicts
|
|
316
|
+
.filter(({ type }) => type === 'app')
|
|
317
|
+
.map(({ id }) => id),
|
|
318
|
+
integrations: conflicts
|
|
319
|
+
.filter(({ type }) => type === 'integration')
|
|
320
|
+
.map(({ id }) => id),
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
spinner.fail('Could not disable the conflicting apps and integrations.');
|
|
326
|
+
this.logError(error.message);
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
spinner.succeed('All conflicting apps and integrations were disabled.');
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* List what already uses the services `integrations` names, and settle what
|
|
334
|
+
* to do about it. `conflictsFlag` pre-answers the prompt for a scripted run;
|
|
335
|
+
* cancelling is deliberately interactive-only.
|
|
336
|
+
*
|
|
337
|
+
* @param integrations services the app declares in swell.json
|
|
338
|
+
* @param options how to ask and what to leave out
|
|
339
|
+
* @returns whether to go ahead
|
|
340
|
+
*/
|
|
341
|
+
async confirmIntegrationConflicts(integrations, { action, conflictsFlag, excludeAppId, deferDisable, }) {
|
|
342
|
+
const conflicts = await this.getAppConflicts(integrations, excludeAppId);
|
|
343
|
+
if (conflicts.length === 0) {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
this.log();
|
|
347
|
+
this.log(`${style.logStatus('!')} There are potential conflicts on this store:`);
|
|
348
|
+
for (const conflict of conflicts) {
|
|
349
|
+
const services = (conflict.integrations || []).join(', ');
|
|
350
|
+
this.log(` ${style.appConfigValue(conflict.name || conflict.id)} (${conflict.type})${services ? ` — ${services}` : ''}`);
|
|
351
|
+
}
|
|
352
|
+
this.log();
|
|
353
|
+
const choice = conflictsFlag ||
|
|
354
|
+
(await select({
|
|
355
|
+
choices: [
|
|
356
|
+
{ name: 'Ignore all conflicts and continue', value: 'ignore' },
|
|
357
|
+
{
|
|
358
|
+
name: 'Disable all conflicting apps and integrations, then continue',
|
|
359
|
+
value: 'disable',
|
|
360
|
+
},
|
|
361
|
+
{ name: 'Cancel', value: 'cancel' },
|
|
362
|
+
],
|
|
363
|
+
message: `How do you want to handle these before ${action}?`,
|
|
364
|
+
}));
|
|
365
|
+
if (choice === 'cancel') {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
if (choice === 'disable') {
|
|
369
|
+
// Deferred so a command that can still back out doesn't leave the store's integrations switched off
|
|
370
|
+
if (deferDisable) {
|
|
371
|
+
this.pendingConflicts = conflicts;
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
return this.disableAppConflicts(conflicts);
|
|
375
|
+
}
|
|
376
|
+
// Ignoring is a decision worth showing, especially with --conflicts=ignore
|
|
377
|
+
// where nothing else marks that the conflicts were seen and waved through
|
|
378
|
+
this.log(`${style.logStatus('!')} Conflicts were ignored.`);
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
261
381
|
async getLocalAppConfigs() {
|
|
262
382
|
const promises = [];
|
|
263
383
|
for (const { configFile, configType } of allConfigFilesPaths(this.appPath)) {
|
|
@@ -380,11 +500,11 @@ export class RemoteAppCommand extends AppCommand {
|
|
|
380
500
|
return true;
|
|
381
501
|
try {
|
|
382
502
|
const localConfig = localBatchConfigs.find((config) => config.filePath === batchConfig.filePath);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
503
|
+
// Functions pushed by earlier CLI versions carry the plain file hash
|
|
504
|
+
if (localConfig &&
|
|
505
|
+
(localConfig.hash === batchConfig.hash ||
|
|
506
|
+
localConfig.fileHash === batchConfig.hash)) {
|
|
507
|
+
return false;
|
|
388
508
|
}
|
|
389
509
|
}
|
|
390
510
|
catch {
|
|
@@ -557,11 +677,13 @@ export class RemoteAppCommand extends AppCommand {
|
|
|
557
677
|
if (error instanceof IgnoringFileError) {
|
|
558
678
|
log &&
|
|
559
679
|
spinner.fail(`Ignoring file: ${error.message} in ${config.filePath}`);
|
|
680
|
+
this.failedFiles.push(config.filePath);
|
|
560
681
|
return;
|
|
561
682
|
}
|
|
562
683
|
if (error instanceof FunctionProcessingError) {
|
|
563
684
|
log &&
|
|
564
685
|
spinner.fail(`${error.message} ${error.cause?.message?.toLowerCase()}`);
|
|
686
|
+
this.failedFiles.push(config.filePath);
|
|
565
687
|
return;
|
|
566
688
|
}
|
|
567
689
|
log && spinner.fail(error.message);
|
package/dist/types/index.d.ts
CHANGED
package/oclif.manifest.json
CHANGED
|
@@ -1056,6 +1056,17 @@
|
|
|
1056
1056
|
"multiple": false,
|
|
1057
1057
|
"type": "option"
|
|
1058
1058
|
},
|
|
1059
|
+
"conflicts": {
|
|
1060
|
+
"description": "how to handle apps and integrations already using the same services",
|
|
1061
|
+
"name": "conflicts",
|
|
1062
|
+
"hasDynamicHelp": false,
|
|
1063
|
+
"multiple": false,
|
|
1064
|
+
"options": [
|
|
1065
|
+
"ignore",
|
|
1066
|
+
"disable"
|
|
1067
|
+
],
|
|
1068
|
+
"type": "option"
|
|
1069
|
+
},
|
|
1059
1070
|
"env": {
|
|
1060
1071
|
"char": "e",
|
|
1061
1072
|
"description": "target environment to install the app, defaults to live",
|
|
@@ -1183,6 +1194,17 @@
|
|
|
1183
1194
|
"multiple": false,
|
|
1184
1195
|
"type": "option"
|
|
1185
1196
|
},
|
|
1197
|
+
"conflicts": {
|
|
1198
|
+
"description": "how to handle apps and integrations already using the same services",
|
|
1199
|
+
"name": "conflicts",
|
|
1200
|
+
"hasDynamicHelp": false,
|
|
1201
|
+
"multiple": false,
|
|
1202
|
+
"options": [
|
|
1203
|
+
"ignore",
|
|
1204
|
+
"disable"
|
|
1205
|
+
],
|
|
1206
|
+
"type": "option"
|
|
1207
|
+
},
|
|
1186
1208
|
"force": {
|
|
1187
1209
|
"description": "force push all configurations",
|
|
1188
1210
|
"name": "force",
|
|
@@ -3572,5 +3594,5 @@
|
|
|
3572
3594
|
]
|
|
3573
3595
|
}
|
|
3574
3596
|
},
|
|
3575
|
-
"version": "2.9.
|
|
3597
|
+
"version": "2.9.22"
|
|
3576
3598
|
}
|