@swell/cli 2.9.32 → 2.9.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/commands/app/dev.js +2 -1
  2. package/dist/commands/app/frontend/dev.js +4 -2
  3. package/dist/commands/app/push.d.ts +1 -0
  4. package/dist/commands/app/push.js +38 -3
  5. package/dist/commands/app/version.js +3 -0
  6. package/dist/commands/create/function.js +14 -4
  7. package/dist/commands/create/model.js +9 -24
  8. package/dist/commands/inspect/components.d.ts +31 -0
  9. package/dist/commands/inspect/components.js +110 -0
  10. package/dist/commands/inspect/extensions.js +18 -9
  11. package/dist/commands/inspect/frontend.d.ts +37 -0
  12. package/dist/commands/inspect/frontend.js +75 -0
  13. package/dist/commands/inspect/functions.d.ts +11 -1
  14. package/dist/commands/inspect/functions.js +89 -6
  15. package/dist/commands/inspect/index.js +2 -0
  16. package/dist/inspect-resource-command.d.ts +1 -1
  17. package/dist/inspect-resource-command.js +8 -2
  18. package/dist/lib/api-errors.d.ts +1 -0
  19. package/dist/lib/api-errors.js +28 -0
  20. package/dist/lib/app-config.js +1 -0
  21. package/dist/lib/apps/components.d.ts +7 -0
  22. package/dist/lib/apps/components.js +10 -0
  23. package/dist/lib/apps/extension-manifest.d.ts +1 -0
  24. package/dist/lib/apps/extension-manifest.js +43 -0
  25. package/dist/lib/apps/index.d.ts +5 -0
  26. package/dist/lib/apps/index.js +31 -0
  27. package/dist/lib/apps/paths.d.ts +8 -0
  28. package/dist/lib/apps/paths.js +34 -4
  29. package/dist/lib/bundle.js +15 -2
  30. package/dist/lib/create/function.d.ts +1 -1
  31. package/dist/lib/create/function.js +9 -5
  32. package/dist/lib/create/index.js +2 -1
  33. package/dist/lib/create/model.d.ts +7 -3
  34. package/dist/lib/create/model.js +67 -9
  35. package/dist/lib/create/tests.js +4 -2
  36. package/dist/lib/inspect/extensions.d.ts +4 -3
  37. package/dist/lib/inspect/extensions.js +36 -5
  38. package/dist/lib/swell-function-wrapper.js +5 -4
  39. package/dist/push-app-command.js +32 -19
  40. package/dist/remote-app-command.d.ts +1 -1
  41. package/dist/remote-app-command.js +78 -47
  42. package/oclif.manifest.json +139 -6
  43. package/package.json +1 -1
@@ -4,9 +4,11 @@ import { filePathExistsAsync, writeFile } from '../apps/index.js';
4
4
  import { APPS_SDK_VERSION } from './component.js';
5
5
  import { mockRequestTemplate, setupGlobalsTemplate, tsconfigTemplate, unitTestTemplate, vitestConfigTemplate, } from './tests/templates/index.js';
6
6
  const TEST_DEV_DEPENDENCIES = {
7
- // npm 10 cannot resolve vitest 4.1 next to the plugin; vitest 5 needs Node 22.12 or later
7
+ // npm 10 cannot resolve vitest 4.1 next to the plugin; vitest 5 needs Node 22.12 or later.
8
+ // npm 10 also crashes ("reading 'edgesOut'") on vitest 5.0 since 5.1.0 came out: vitest's
9
+ // optional peers (@vitest/browser-*) resolve to 5.1, which wants vitest 5.1
8
10
  '@cloudflare/vitest-plugin': '1.4.x',
9
- vitest: '5.0.x',
11
+ vitest: '5.1.x',
10
12
  '@swell/app-types': 'latest',
11
13
  '@swell/apps-sdk': APPS_SDK_VERSION,
12
14
  };
@@ -24,7 +24,7 @@ export interface ManifestEntry {
24
24
  }
25
25
  /** CLI-resolved type. Diverges from raw manifest `type` for payment (card vs alt). */
26
26
  export type ExtensionType = 'card' | 'alt' | 'shipping' | 'tax';
27
- export type ExtensionStatus = 'not deployed' | 'not activated' | 'app id mismatch' | 'id mismatch' | 'not selected' | 'gateway missing' | 'not enabled' | 'no handler' | 'handler mismatch' | 'activated';
27
+ export type ExtensionStatus = 'not deployed' | 'settings unavailable' | 'not activated' | 'app id mismatch' | 'id mismatch' | 'not selected' | 'gateway missing' | 'not enabled' | 'no handler' | 'handler mismatch' | 'activated';
28
28
  export type ActionOwner = 'dev' | 'merchant' | null;
29
29
  export interface FunctionRecord {
30
30
  id?: string;
@@ -163,8 +163,6 @@ export declare function formatExtensionKey(slug: string, extId: string): string;
163
163
  * resolves to `card` even with no explicit `method`.
164
164
  */
165
165
  export declare function resolveExtensionType(entry: Pick<ManifestEntry, 'type' | 'method' | 'id'>): ExtensionType;
166
- /** Apply the platform's `if(method, method, id)` formula. */
167
- export declare function paymentMethodIdFor(entry: Pick<ManifestEntry, 'method' | 'id'>): string;
168
166
  /** Shipping records use `app_<appId>_<extId>` as both the row id and the carrier id. */
169
167
  export declare function shippingBindId(appId: string, extId: string): string;
170
168
  /** Card-gateway records use the same id form as shipping rows. */
@@ -229,12 +227,15 @@ export interface BuildDetailInput {
229
227
  payments?: PaymentSettings | null;
230
228
  shipments?: ShippingSettings | null;
231
229
  taxes?: TaxSettingsRecord | null;
230
+ /** Why each settings record could not be read, if it could not. */
231
+ unavailableSettings?: UnavailableSettings;
232
232
  /** Functions in the same app whose top-level `extension` matches this extId. */
233
233
  functions: FunctionRecord[];
234
234
  /** Components in the same app whose `values.extension` matches this extId. */
235
235
  components: ComponentRecord[];
236
236
  localDiff?: LocalDiff | null;
237
237
  }
238
+ export type UnavailableSettings = Partial<Record<'payments' | 'shipments' | 'taxes', string>>;
238
239
  export interface BuildOrphanInput {
239
240
  appId: string;
240
241
  appSlug: string;
@@ -42,10 +42,6 @@ export function resolveExtensionType(entry) {
42
42
  const method = entry.method ?? entry.id;
43
43
  return method === 'card' ? 'card' : 'alt';
44
44
  }
45
- /** Apply the platform's `if(method, method, id)` formula. */
46
- export function paymentMethodIdFor(entry) {
47
- return entry.method ?? entry.id;
48
- }
49
45
  /** Shipping records use `app_<appId>_<extId>` as both the row id and the carrier id. */
50
46
  export function shippingBindId(appId, extId) {
51
47
  return `app_${appId}_${extId}`;
@@ -200,7 +196,9 @@ function findShippingCarrier(shipments, carrierId) {
200
196
  * gate on `enabled`) is intentional.
201
197
  */
202
198
  function deriveAltDispatch(ctx) {
203
- const methodId = paymentMethodIdFor(ctx.manifest);
199
+ // The dashboard keys an extension's method by the extension's id, whatever
200
+ // its `method`, and checkout dispatches on that id
201
+ const methodId = ctx.extId;
204
202
  const expectedGatewayId = gatewayBindId(ctx.appId, ctx.extId);
205
203
  const method = findPaymentMethod(ctx.payments, methodId);
206
204
  const gateway = findPaymentGateway(ctx.payments, expectedGatewayId);
@@ -254,8 +252,11 @@ function deriveCardDispatch(ctx) {
254
252
  const expectedGatewayId = gatewayBindId(ctx.appId, ctx.extId);
255
253
  const cardMethod = findPaymentMethod(ctx.payments, 'card');
256
254
  const gateway = findPaymentGateway(ctx.payments, expectedGatewayId);
255
+ // Charges and refunds go to the app and extension the card method names
257
256
  const methodChecks = {
258
257
  gateway: cardMethod?.gateway === expectedGatewayId,
258
+ extension_app_id: cardMethod?.extension_app_id === ctx.appId,
259
+ extension_config_id: cardMethod?.extension_config_id === ctx.extId,
259
260
  };
260
261
  const gatewayChecks = {
261
262
  extension_app_id: gateway?.extension_app_id === ctx.appId,
@@ -285,6 +286,12 @@ function deriveCardDispatch(ctx) {
285
286
  if (cardMethod?.gateway !== expectedGatewayId) {
286
287
  return { status: 'not activated', native_bindings };
287
288
  }
289
+ if (cardMethod.extension_app_id !== ctx.appId) {
290
+ return { status: 'app id mismatch', native_bindings };
291
+ }
292
+ if (cardMethod.extension_config_id !== ctx.extId) {
293
+ return { status: 'id mismatch', native_bindings };
294
+ }
288
295
  return { status: 'activated', native_bindings };
289
296
  }
290
297
  /**
@@ -401,6 +408,26 @@ export function buildExtensionDetail(input) {
401
408
  throw new Error('buildExtensionDetail: missing manifest for a deployed extension');
402
409
  }
403
410
  const type = resolveExtensionType(input.manifest);
411
+ // Without the settings the activation cannot be told
412
+ const settingsRecord = type === 'shipping' ? 'shipments' : type === 'tax' ? 'taxes' : 'payments';
413
+ const unavailable = input.unavailableSettings?.[settingsRecord];
414
+ if (unavailable) {
415
+ return {
416
+ status: 'settings unavailable',
417
+ action_owner: 'dev',
418
+ action: `Could not read /settings/${settingsRecord}: ${unavailable}`,
419
+ type,
420
+ manifest: input.manifest,
421
+ native_bindings: [],
422
+ bound: {
423
+ functions: input.functions.map((f) => liftBoundFunction(f)),
424
+ components: input.components.map((c) => liftBoundComponent(c)),
425
+ },
426
+ required_events: requiredEventsFor(type),
427
+ missing_required_events: missingRequiredEvents(type, input.functions),
428
+ local_diff: input.localDiff ?? null,
429
+ };
430
+ }
404
431
  const dispatch = deriveDispatch(type, {
405
432
  appId: input.appId,
406
433
  extId: input.extId,
@@ -487,6 +514,10 @@ function describeAction(status, ctx) {
487
514
  case 'not deployed': {
488
515
  return { action: 'swell app push', action_owner: 'dev' };
489
516
  }
517
+ // buildExtensionDetail names the settings it could not read
518
+ case 'settings unavailable': {
519
+ return { action: null, action_owner: 'dev' };
520
+ }
490
521
  case 'not activated': {
491
522
  return {
492
523
  action: `Open Settings → ${section} → ${label} → Save`,
@@ -124,9 +124,9 @@ class SwellRequest {
124
124
  this.swell = new SwellAPI(this, context);
125
125
  // URL of the original request
126
126
  this.url;
127
- // Request body text. On a route called through the storefront gateway with
128
- // a JSON body, this is the exact text the caller sent, so verify HMAC/webhook
129
- // signatures against it. Other bodies arrive re-serialized as JSON.
127
+ // Request body text. On a route called through the storefront gateway, this
128
+ // is the exact body the caller sent, so verify HMAC/webhook signatures
129
+ // against it. Form-encoded bodies arrive re-serialized as JSON.
130
130
  this.rawBody = '';
131
131
  // Parsed JSON body as object, or raw text string when body isn't JSON.
132
132
  this.body = {};
@@ -508,7 +508,8 @@ class SwellResponse extends Response {
508
508
  }
509
509
  static async _respond(req, response, context) {
510
510
  let finalResponse = response;
511
- const isHook = Boolean(req.data?.$event?.hook);
511
+ // Only the platform marks a hook's call: a route's data comes from its caller
512
+ const isHook = Boolean(req.headers.get('Swell-Event-Hook'));
512
513
  if (finalResponse instanceof Response &&
513
514
  !(finalResponse instanceof SwellResponse)) {
514
515
  // Non-hook responses pass through unchanged so the handler's body,
@@ -7,11 +7,12 @@ import Stream from 'node:stream';
7
7
  import ora from 'ora';
8
8
  import { default as swellConfig } from './lib/app-config.js';
9
9
  import { componentsToRebuild } from './lib/apps/components.js';
10
+ import { extensionManifestWarnings } from './lib/apps/extension-manifest.js';
10
11
  import { buildFrontendPackage, getManagedFrontendFramework, } from './lib/apps/frontend-package.js';
11
12
  import { warnFrontendRouteCollisions } from './lib/apps/frontend-routes.js';
12
13
  import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExistsAsync, findAppConfig, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashFile, hashString, isPathDirectory, toPosixPath, } from './lib/apps/index.js';
13
14
  import { warnListingSources } from './lib/apps/listing.js';
14
- import { getGlobIgnorePathsChecker } from './lib/apps/paths.js';
15
+ import { getGlobIgnorePathsChecker, globFrontendEnvFiles, isCredentialFile, } from './lib/apps/paths.js';
15
16
  import { slugFromApp } from './lib/apps/slug.js';
16
17
  import { getLocalInputs } from './lib/bundle.js';
17
18
  import { default as localConfig } from './lib/config.js';
@@ -363,6 +364,9 @@ export class PushAppCommand extends RemoteAppCommand {
363
364
  }
364
365
  if (this.pushesSwellJson(file)) {
365
366
  warnListingSources(this.appPath, this.swellConfig.store, (message) => this.warn(message));
367
+ for (const message of extensionManifestWarnings(this.swellConfig.get('extensions'))) {
368
+ this.warn(message);
369
+ }
366
370
  }
367
371
  // If app exists but owned by another client, create a new instance
368
372
  if (app?.client_id && app.client_id !== currentStore) {
@@ -372,7 +376,7 @@ export class PushAppCommand extends RemoteAppCommand {
372
376
  return false;
373
377
  }
374
378
  }
375
- else if (file) {
379
+ else if (file && !this.pushesSwellJson(file)) {
376
380
  // Targeting a file: don't update the app from swell.json, but keep the
377
381
  // remote one to diff config hashes and the frontend deployment hash
378
382
  if (app) {
@@ -380,7 +384,8 @@ export class PushAppCommand extends RemoteAppCommand {
380
384
  }
381
385
  }
382
386
  else {
383
- // Update existing app if not targeting a file
387
+ // The app takes its details from swell.json before any upload, so that
388
+ // details the app refuses fail the push first
384
389
  this.app = await this.getCreateUpdateApp(app);
385
390
  }
386
391
  return true;
@@ -501,8 +506,12 @@ export class PushAppCommand extends RemoteAppCommand {
501
506
  if (frontendHashes.length > 0) {
502
507
  const packageHash = localConfigs.find((config) => config.filePath === 'package.json')?.hash;
503
508
  frontendHashes += `|${packageHash}`;
504
- // Shared code the worker imports from outside frontend/, e.g. functions/lib
505
- for (const input of await this.getFrontendSharedInputs()) {
509
+ // Shared code the worker imports from outside frontend/, e.g.
510
+ // functions/lib, and the .env files its build reads
511
+ for (const input of [
512
+ ...(await this.getFrontendSharedInputs()),
513
+ ...(await globFrontendEnvFiles(this.appPath)),
514
+ ]) {
506
515
  frontendHashes += `|${hashFile(path.join(this.appPath, input), undefined, input)}`;
507
516
  }
508
517
  }
@@ -795,6 +804,11 @@ export class PushAppCommand extends RemoteAppCommand {
795
804
  }
796
805
  async pushFile(relativePath) {
797
806
  const relativePathPosix = toPosixPath(relativePath);
807
+ if (isCredentialFile(relativePathPosix)) {
808
+ ora().fail(`${relativePathPosix} is never pushed: .env and .dev.vars files can hold credentials`);
809
+ this.failedFiles.push(relativePathPosix);
810
+ return;
811
+ }
798
812
  const basePath = relativePathPosix.split('/')[0];
799
813
  const configType = getConfigTypeFromPath(basePath) || ConfigType.FILE;
800
814
  const config = await appConfigFromFile(relativePathPosix, configType, this.appPath);
@@ -825,7 +839,7 @@ export class PushAppCommand extends RemoteAppCommand {
825
839
  const pushConcurrency = configTypeKey === 'ASSET' ? 1 : PUSH_CONCURRENCY;
826
840
  while (pushFiles.length > 0) {
827
841
  // eslint-disable-next-line no-await-in-loop
828
- await Promise.all(pushFiles
842
+ await this.runTogether(pushFiles
829
843
  .splice(0, pushConcurrency)
830
844
  .map((appConfig) => this.pushRemoteFile(appConfig)));
831
845
  }
@@ -834,7 +848,7 @@ export class PushAppCommand extends RemoteAppCommand {
834
848
  if (filesToRemove.length > 0) {
835
849
  while (filesToRemove.length > 0) {
836
850
  // eslint-disable-next-line no-await-in-loop
837
- await Promise.all(filesToRemove
851
+ await this.runTogether(filesToRemove
838
852
  .splice(0, PUSH_CONCURRENCY)
839
853
  .map((appConfig) => this.removeRemoteFile(appConfig)));
840
854
  }
@@ -870,18 +884,17 @@ export class PushAppCommand extends RemoteAppCommand {
870
884
  // Process queue in batches with concurrency control
871
885
  /* eslint-disable no-await-in-loop */
872
886
  while (queue.length > 0) {
873
- try {
874
- await Promise.all(queue
875
- .splice(0, PUSH_CONCURRENCY)
876
- .map(async ({ action, appConfig }) => {
877
- const result = await (action === 'remove'
878
- ? this.removeRemoteFile(appConfig, this.logWatchChanges)
879
- : this.pushRemoteFile(appConfig, this.logWatchChanges));
880
- this.onWatchChange?.(appConfig, action, result);
881
- }));
882
- }
883
- catch {
884
- // TODO: Ignore only some errors
887
+ const results = await Promise.allSettled(queue.splice(0, PUSH_CONCURRENCY).map(async ({ action, appConfig }) => {
888
+ const result = await (action === 'remove'
889
+ ? this.removeRemoteFile(appConfig, this.logWatchChanges)
890
+ : this.pushRemoteFile(appConfig, this.logWatchChanges));
891
+ this.onWatchChange?.(appConfig, action, result);
892
+ }));
893
+ // A failed change ends no watch: its reasons show, and watching goes on
894
+ for (const result of results) {
895
+ if (result.status === 'rejected') {
896
+ this.logError(result.reason?.message ?? String(result.reason));
897
+ }
885
898
  }
886
899
  }
887
900
  /* eslint-enable no-await-in-loop */
@@ -88,9 +88,9 @@ export declare abstract class RemoteAppCommand extends AppCommand {
88
88
  removed: string[];
89
89
  }>;
90
90
  protected pushRemoteFile(config: AppConfig, log?: boolean): Promise<any>;
91
+ protected runTogether(tasks: Promise<unknown>[]): Promise<void>;
91
92
  protected removeLocalFile(config: AppConfig, log?: boolean): Promise<any>;
92
93
  protected removeRemoteFile(config: AppConfig, log?: boolean): Promise<any>;
93
- protected showErrors(errors: any, spinnerError?: () => void): void;
94
94
  storefrontFrontendUrl(storeId: string, storefront?: any, branchId?: string, preview?: boolean): string;
95
95
  protected swellRcPath(): string;
96
96
  protected timestampStyled(): string;
@@ -1,11 +1,13 @@
1
1
  import { confirm, select } from '@inquirer/prompts';
2
- import { inflect } from 'inflection';
3
2
  import sortBy from 'lodash/sortBy.js';
4
3
  import * as path from 'node:path';
5
4
  import { default as ora } from 'ora';
6
5
  import { AppCommand } from './app-command.js';
6
+ import { errorReasons } from './lib/api-errors.js';
7
7
  import { readRcFile, writeRcFile } from './lib/app-config.js';
8
- import { AppConfig, ConfigType, FunctionProcessingError, IgnoringFileError, SwellJsonFields, allBaseFilesInDir, allConfigFilesPaths, appConfigFromFile, batchAppFilesByType, deleteFile, writeFile, writeJsonFile, PUSH_CONCURRENCY, } from './lib/apps/index.js';
8
+ import { isUnloadableComponentFile } from './lib/apps/components.js';
9
+ import { AppConfig, ConfigType, FunctionProcessingError, IgnoringFileError, SwellJsonFields, allBaseFilesInDir, allConfigFilesPaths, appConfigFromFile, batchAppFilesByType, deleteFile, kebabCaseFilePath, writeFile, writeJsonFile, PUSH_CONCURRENCY, } from './lib/apps/index.js';
10
+ import { isCredentialFile } from './lib/apps/paths.js';
9
11
  import { default as localConfig } from './lib/config.js';
10
12
  import { getAppFrontendHost, getLocalFrontendHost, getLoginHost, getStorefrontFrontendHost, } from './lib/constants.js';
11
13
  import { toAppId } from './lib/create/index.js';
@@ -402,38 +404,37 @@ export class RemoteAppCommand extends AppCommand {
402
404
  });
403
405
  return versionsResponse.results || [];
404
406
  }
407
+ // A refused request fails with every reason the API gives, so that they
408
+ // reach stderr together with the failure
405
409
  async handleRequestErrors(request, spinnerError) {
406
- let showedErrors = false;
410
+ let response;
407
411
  try {
408
- const response = await request();
409
- if (response?.status !== 200 && response?.errors) {
410
- this.showErrors(response.errors, spinnerError);
411
- showedErrors = true;
412
- this.error('Something went wrong.');
413
- }
414
- return response;
412
+ response = await request();
415
413
  }
416
414
  catch (error) {
417
415
  let jsonError;
418
416
  try {
419
417
  jsonError = JSON.parse(error.message);
420
418
  }
421
- catch (error) {
419
+ catch (parseError) {
422
420
  // Skip JSON parse error
423
- if (!(error instanceof SyntaxError)) {
424
- throw error;
421
+ if (!(parseError instanceof SyntaxError)) {
422
+ throw parseError;
425
423
  }
426
424
  }
427
- if (!showedErrors) {
428
- if (jsonError && jsonError.error) {
429
- this.showErrors(jsonError, spinnerError);
430
- }
431
- else if (typeof error.message === 'string') {
432
- this.showErrors(error, spinnerError);
433
- }
425
+ spinnerError?.();
426
+ const reasons = jsonError?.error ? errorReasons(jsonError) : [];
427
+ if (reasons.length > 0) {
428
+ this.error(reasons.join('\n'));
434
429
  }
435
430
  throw error;
436
431
  }
432
+ if (response?.status !== 200 && response?.errors) {
433
+ spinnerError?.();
434
+ const reasons = errorReasons(response.errors);
435
+ this.error(reasons.length > 0 ? reasons.join('\n') : 'Something went wrong.');
436
+ }
437
+ return response;
437
438
  }
438
439
  async init() {
439
440
  await super.init();
@@ -486,7 +487,11 @@ export class RemoteAppCommand extends AppCommand {
486
487
  const removed = [];
487
488
  const [localConfigs, remoteConfigs] = await Promise.all([
488
489
  this.getLocalAppConfigs(),
489
- this.pullAppJsonAndRemoteConfigs().then((remoteConfigs) => this.cleanRemoteConfigs(remoteConfigs, false)),
490
+ this.pullAppJsonAndRemoteConfigs()
491
+ .then((remoteConfigs) => this.cleanRemoteConfigs(remoteConfigs, false))
492
+ // An older push could send .env and .dev.vars files, which must not
493
+ // replace the local ones
494
+ .then((remoteConfigs) => remoteConfigs.filter((config) => !isCredentialFile(config.filePath))),
490
495
  ]);
491
496
  const batches = batchAppFilesByType(remoteConfigs);
492
497
  for (const batch of batches) {
@@ -629,7 +634,7 @@ export class RemoteAppCommand extends AppCommand {
629
634
  if (pushFiles.length > 0) {
630
635
  while (pushFiles.length > 0) {
631
636
  // eslint-disable-next-line no-await-in-loop
632
- await Promise.all(pushFiles.splice(0, batch.concurrency).map(async (config) => {
637
+ await this.runTogether(pushFiles.splice(0, batch.concurrency).map(async (config) => {
633
638
  const pushedConfig = await this.pushRemoteFile(config);
634
639
  if (pushedConfig) {
635
640
  pushed.push(pushedConfig.id);
@@ -644,7 +649,7 @@ export class RemoteAppCommand extends AppCommand {
644
649
  if (filesToRemove.length > 0) {
645
650
  while (filesToRemove.length > 0) {
646
651
  // eslint-disable-next-line no-await-in-loop
647
- await Promise.all(filesToRemove.splice(0, batch.concurrency).map(async (config) => {
652
+ await this.runTogether(filesToRemove.splice(0, batch.concurrency).map(async (config) => {
648
653
  await this.removeRemoteFile(config);
649
654
  removed.push(config.id);
650
655
  }));
@@ -659,15 +664,43 @@ export class RemoteAppCommand extends AppCommand {
659
664
  }
660
665
  async pushRemoteFile(config, log = true) {
661
666
  const spinner = ora();
667
+ // A file the app already has keeps deploying under its name
668
+ const unloadable = isUnloadableComponentFile(config.filePath);
669
+ const pushedBefore = this.app?.configs?.some((remote) => remote.filePath === config.filePath);
670
+ if (unloadable && !pushedBefore) {
671
+ log &&
672
+ spinner.fail(`${config.filePath} would never load: name a component with letters, digits and dashes only`);
673
+ this.failedFiles.push(config.filePath);
674
+ return;
675
+ }
662
676
  log && spinner.start(`Pushing ${config.filePath}`);
677
+ // A refused push has failed the spinner, and its reasons end the command
678
+ let isRefused = false;
663
679
  try {
664
680
  const postData = await config.postData();
665
681
  if (!postData) {
666
682
  spinner.stop();
667
683
  return;
668
684
  }
669
- const result = await this.handleRequestErrors(async () => this.postConfigData(postData), () => log && spinner.fail(`Error pushing ${config.filePath}`));
685
+ const result = await this.handleRequestErrors(async () => this.postConfigData(postData), () => {
686
+ isRefused = true;
687
+ log && spinner.fail(`Error pushing ${config.filePath}`);
688
+ });
670
689
  log && spinner.succeed(`${this.timestampStyled()} ${config.filePath}`);
690
+ // A config named in its values deploys under that name instead of its
691
+ // file's, and a model as its collection, whatever name it gives
692
+ const deployName = config.type === ConfigType.MODEL
693
+ ? postData.values?.collection
694
+ : postData.name;
695
+ const kebabPath = deployName === config.name
696
+ ? kebabCaseFilePath(config.filePath)
697
+ : undefined;
698
+ if (log && kebabPath) {
699
+ ora().warn(`${config.filePath} deploys as '${config.name}': use kebab-case: ${kebabPath}`);
700
+ }
701
+ if (log && unloadable) {
702
+ ora().warn(`${config.filePath} never loads: name a component with letters, digits and dashes only`);
703
+ }
671
704
  return result;
672
705
  }
673
706
  catch (error) {
@@ -683,10 +716,31 @@ export class RemoteAppCommand extends AppCommand {
683
716
  this.failedFiles.push(config.filePath);
684
717
  return;
685
718
  }
719
+ if (isRefused) {
720
+ // Each reason names its file, as a batch fails with all of them
721
+ this.error(error.message
722
+ .split('\n')
723
+ .map((reason) => `${config.filePath}: ${reason}`)
724
+ .join('\n'));
725
+ }
686
726
  log && spinner.fail(error.message);
687
727
  throw error;
688
728
  }
689
729
  }
730
+ // Runs pushes or removals together and, when some fail, fails once with
731
+ // the reasons of each of them, not only the first
732
+ async runTogether(tasks) {
733
+ const results = await Promise.allSettled(tasks);
734
+ const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
735
+ if (failures.length === 1) {
736
+ throw failures[0];
737
+ }
738
+ if (failures.length > 1) {
739
+ this.error(failures
740
+ .map((failure) => failure?.message ?? String(failure))
741
+ .join('\n'));
742
+ }
743
+ }
690
744
  async removeLocalFile(config, log = true) {
691
745
  if (!config.fileData) {
692
746
  return;
@@ -706,29 +760,6 @@ export class RemoteAppCommand extends AppCommand {
706
760
  spinner.warn(`${this.timestampStyled()} ${style.strike(config.filePath)}`);
707
761
  return result;
708
762
  }
709
- showErrors(errors, spinnerError) {
710
- const actualErrors = Object.keys(errors).filter((err) => Object.prototype.hasOwnProperty.call(errors, err));
711
- if (spinnerError) {
712
- spinnerError();
713
- }
714
- else {
715
- this.logError(inflect('Error', actualErrors.length));
716
- }
717
- for (const err of actualErrors) {
718
- if (!errors[err]?.message)
719
- continue;
720
- // we need to display `id` instead of `private_id`
721
- // developers don't have any knowledge of private ids
722
- const displayErr = err === 'private_id' ? 'id' : err;
723
- this.logError([
724
- ' -',
725
- `${displayErr}:`.toLowerCase().replace(/^error:/, ''),
726
- errors[err].message,
727
- ]
728
- .filter(Boolean)
729
- .join(' '));
730
- }
731
- }
732
763
  storefrontFrontendUrl(storeId, storefront, branchId, preview = true) {
733
764
  const storefrontSlug = storefront && preview
734
765
  ? `${storeId}--${storefront.id.toLowerCase()}--preview`