@swell/cli 2.9.19 → 2.9.21

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.
@@ -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,18 @@ 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>;
18
31
  }
@@ -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
@@ -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",
@@ -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";
@@ -74,7 +74,7 @@ export function createMockRequest(options: MockRequestOptions = {}): SwellReques
74
74
  const req: Partial<SwellRequest> & { appId: string; storeId: string } = {
75
75
  originalRequest,
76
76
  context,
77
- url,
77
+ url: new URL(url),
78
78
  method,
79
79
  headers: requestHeaders,
80
80
  referrer: originalRequest.referrer,
@@ -3,7 +3,8 @@ import * as path from 'node:path';
3
3
  import { filePathExistsAsync, writeFile } from '../apps/index.js';
4
4
  import { envDtsTemplate, integrationTestTemplate, mockRequestTemplate, setupGlobalsTemplate, swellClientTemplate, tsconfigTemplate, unitTestTemplate, vitestConfigTemplate, } from './tests/templates/index.js';
5
5
  const TEST_DEV_DEPENDENCIES = {
6
- '@cloudflare/vitest-pool-workers': 'latest',
6
+ // 0.13+ requires vitest 4 and drops the /config entry the vitest template imports
7
+ '@cloudflare/vitest-pool-workers': '0.12.x',
7
8
  vitest: '3.2.x',
8
9
  '@swell/app-types': 'latest',
9
10
  };
@@ -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,7 @@ export declare abstract class RemoteAppCommand extends AppCommand {
12
12
  app: App;
13
13
  appCreated: boolean;
14
14
  appType: string;
15
+ protected pendingConflicts?: AppConflict[];
15
16
  storefront?: any;
16
17
  storefrontLocalUrl?: string;
17
18
  themeSyncApp?: boolean;
@@ -26,6 +27,45 @@ export declare abstract class RemoteAppCommand extends AppCommand {
26
27
  getConfigData(configId: string): Promise<any>;
27
28
  protected getCreateUpdateApp(updateApp?: App | null, force?: boolean): Promise<App>;
28
29
  protected getInstalledApp(): Promise<any>;
30
+ /**
31
+ * Anything on the target store already using the services named by
32
+ * `integrations`. Answered by the admin backend so the CLI and the dashboard
33
+ * agree on what counts as a conflict.
34
+ *
35
+ * @param integrations services the app declares in swell.json
36
+ * @param excludeAppId app that must not be reported against itself
37
+ * @returns the conflicts, or none when the lookup fails
38
+ */
39
+ protected getAppConflicts(integrations: string[], excludeAppId?: string): Promise<AppConflict[]>;
40
+ /**
41
+ * Disable what confirmIntegrationConflicts() deferred, once the command is
42
+ * past the points where it could still back out.
43
+ *
44
+ * @returns whether they were all disabled
45
+ */
46
+ protected disablePendingConflicts(): Promise<boolean>;
47
+ /**
48
+ * Switch off every listed app and integration.
49
+ *
50
+ * @param conflicts what to disable
51
+ * @returns whether they were all disabled
52
+ */
53
+ protected disableAppConflicts(conflicts: AppConflict[]): Promise<boolean>;
54
+ /**
55
+ * List what already uses the services `integrations` names, and settle what
56
+ * to do about it. `conflictsFlag` pre-answers the prompt for a scripted run;
57
+ * cancelling is deliberately interactive-only.
58
+ *
59
+ * @param integrations services the app declares in swell.json
60
+ * @param options how to ask and what to leave out
61
+ * @returns whether to go ahead
62
+ */
63
+ protected confirmIntegrationConflicts(integrations: string[], { action, conflictsFlag, excludeAppId, deferDisable, }: {
64
+ action: string;
65
+ conflictsFlag?: string;
66
+ deferDisable?: boolean;
67
+ excludeAppId?: string;
68
+ }): Promise<boolean>;
29
69
  protected getLocalAppConfigs(): Promise<AppConfig[]>;
30
70
  protected getVersion(version?: null | string | undefined): Promise<AppVersion>;
31
71
  protected getVersions(version?: null | string | undefined): Promise<AppVersion[]>;
@@ -1,4 +1,4 @@
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';
@@ -24,6 +24,8 @@ 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
+ // Conflicts the user chose to disable, held until the command commits
28
+ pendingConflicts;
27
29
  // API instance of the target storefront, if applicable
28
30
  storefront = null;
29
31
  // Indicates we are syncing with a local app frontend via proxy
@@ -258,6 +260,122 @@ export class RemoteAppCommand extends AppCommand {
258
260
  // check if app is already installed on store
259
261
  return this.api.get({ adminPath: `/client/apps/${this.app.id}` }, {});
260
262
  }
263
+ /**
264
+ * Anything on the target store already using the services named by
265
+ * `integrations`. Answered by the admin backend so the CLI and the dashboard
266
+ * agree on what counts as a conflict.
267
+ *
268
+ * @param integrations services the app declares in swell.json
269
+ * @param excludeAppId app that must not be reported against itself
270
+ * @returns the conflicts, or none when the lookup fails
271
+ */
272
+ async getAppConflicts(integrations, excludeAppId) {
273
+ if (integrations.length === 0) {
274
+ return [];
275
+ }
276
+ try {
277
+ const result = await this.api.post({ adminPath: '/client/conflicts' }, {
278
+ body: {
279
+ exclude_app_id: excludeAppId,
280
+ integrations,
281
+ },
282
+ });
283
+ return result?.conflicts || [];
284
+ }
285
+ catch {
286
+ // A conflict lookup that fails must not block the command
287
+ return [];
288
+ }
289
+ }
290
+ /**
291
+ * Disable what confirmIntegrationConflicts() deferred, once the command is
292
+ * past the points where it could still back out.
293
+ *
294
+ * @returns whether they were all disabled
295
+ */
296
+ async disablePendingConflicts() {
297
+ const conflicts = this.pendingConflicts;
298
+ this.pendingConflicts = undefined;
299
+ return conflicts ? this.disableAppConflicts(conflicts) : true;
300
+ }
301
+ /**
302
+ * Switch off every listed app and integration.
303
+ *
304
+ * @param conflicts what to disable
305
+ * @returns whether they were all disabled
306
+ */
307
+ async disableAppConflicts(conflicts) {
308
+ const spinner = ora();
309
+ spinner.start('Disabling conflicting apps and integrations...');
310
+ try {
311
+ await this.api.post({ adminPath: '/client/conflicts/resolve' }, {
312
+ body: {
313
+ apps: conflicts
314
+ .filter(({ type }) => type === 'app')
315
+ .map(({ id }) => id),
316
+ integrations: conflicts
317
+ .filter(({ type }) => type === 'integration')
318
+ .map(({ id }) => id),
319
+ },
320
+ });
321
+ }
322
+ catch (error) {
323
+ spinner.fail('Could not disable the conflicting apps and integrations.');
324
+ this.logError(error.message);
325
+ return false;
326
+ }
327
+ spinner.succeed('All conflicting apps and integrations were disabled.');
328
+ return true;
329
+ }
330
+ /**
331
+ * List what already uses the services `integrations` names, and settle what
332
+ * to do about it. `conflictsFlag` pre-answers the prompt for a scripted run;
333
+ * cancelling is deliberately interactive-only.
334
+ *
335
+ * @param integrations services the app declares in swell.json
336
+ * @param options how to ask and what to leave out
337
+ * @returns whether to go ahead
338
+ */
339
+ async confirmIntegrationConflicts(integrations, { action, conflictsFlag, excludeAppId, deferDisable, }) {
340
+ const conflicts = await this.getAppConflicts(integrations, excludeAppId);
341
+ if (conflicts.length === 0) {
342
+ return true;
343
+ }
344
+ this.log();
345
+ this.log(`${style.logStatus('!')} There are potential conflicts on this store:`);
346
+ for (const conflict of conflicts) {
347
+ const services = (conflict.integrations || []).join(', ');
348
+ this.log(` ${style.appConfigValue(conflict.name || conflict.id)} (${conflict.type})${services ? ` — ${services}` : ''}`);
349
+ }
350
+ this.log();
351
+ const choice = conflictsFlag ||
352
+ (await select({
353
+ choices: [
354
+ { name: 'Ignore all conflicts and continue', value: 'ignore' },
355
+ {
356
+ name: 'Disable all conflicting apps and integrations, then continue',
357
+ value: 'disable',
358
+ },
359
+ { name: 'Cancel', value: 'cancel' },
360
+ ],
361
+ message: `How do you want to handle these before ${action}?`,
362
+ }));
363
+ if (choice === 'cancel') {
364
+ return false;
365
+ }
366
+ if (choice === 'disable') {
367
+ // Deferred so a command that can still back out doesn't leave the store's integrations switched off
368
+ if (deferDisable) {
369
+ this.pendingConflicts = conflicts;
370
+ return true;
371
+ }
372
+ return this.disableAppConflicts(conflicts);
373
+ }
374
+ // Ignoring is a decision worth showing, especially with --conflicts=ignore
375
+ // where nothing else marks that the conflicts were seen and waved through
376
+ this.log(`${style.logStatus('!')} Conflicts were ignored.`);
377
+ return true;
378
+ }
261
379
  async getLocalAppConfigs() {
262
380
  const promises = [];
263
381
  for (const { configFile, configType } of allConfigFilesPaths(this.appPath)) {
@@ -85,6 +85,7 @@ export interface App {
85
85
  type: string;
86
86
  private_id: string;
87
87
  public_id?: string;
88
+ integrations?: string[];
88
89
  }
89
90
  export interface Model {
90
91
  name: string;
@@ -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.19"
3597
+ "version": "2.9.21"
3576
3598
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swell/cli",
3
- "version": "2.9.19",
3
+ "version": "2.9.21",
4
4
  "type": "module",
5
5
  "description": "Swell's command line interface/utility",
6
6
  "keywords": [