@swell/cli 2.0.1-alpha.13 → 2.0.1-alpha.17

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 (91) hide show
  1. package/dist/app-command.d.ts +1 -1
  2. package/dist/app-command.js +8 -8
  3. package/dist/commands/app/dev.d.ts +18 -0
  4. package/dist/commands/app/dev.js +31 -0
  5. package/dist/commands/app/frontend/deploy.d.ts +17 -0
  6. package/dist/commands/app/frontend/deploy.js +44 -0
  7. package/dist/commands/app/frontend/dev.d.ts +21 -0
  8. package/dist/commands/app/frontend/dev.js +111 -0
  9. package/dist/commands/app/info.js +15 -8
  10. package/dist/commands/app/init.d.ts +12 -0
  11. package/dist/commands/app/init.js +34 -0
  12. package/dist/commands/app/install.d.ts +4 -4
  13. package/dist/commands/app/install.js +37 -42
  14. package/dist/commands/app/pull.d.ts +15 -0
  15. package/dist/commands/app/pull.js +50 -0
  16. package/dist/commands/app/push.d.ts +11 -14
  17. package/dist/commands/app/push.js +71 -143
  18. package/dist/commands/app/release.d.ts +13 -0
  19. package/dist/commands/app/release.js +150 -0
  20. package/dist/commands/app/version.d.ts +7 -6
  21. package/dist/commands/app/version.js +64 -43
  22. package/dist/commands/app/watch.d.ts +2 -4
  23. package/dist/commands/app/watch.js +3 -83
  24. package/dist/commands/create/app.d.ts +35 -11
  25. package/dist/commands/create/app.js +150 -144
  26. package/dist/commands/create/content.d.ts +2 -2
  27. package/dist/commands/create/content.js +10 -10
  28. package/dist/commands/create/frontend.d.ts +12 -0
  29. package/dist/commands/create/frontend.js +41 -0
  30. package/dist/commands/create/function.d.ts +3 -3
  31. package/dist/commands/create/function.js +22 -22
  32. package/dist/commands/create/model.d.ts +2 -2
  33. package/dist/commands/create/model.js +11 -11
  34. package/dist/commands/create/notification.d.ts +4 -4
  35. package/dist/commands/create/notification.js +38 -40
  36. package/dist/commands/login.d.ts +1 -1
  37. package/dist/commands/login.js +12 -9
  38. package/dist/commands/logs.js +2 -1
  39. package/dist/commands/theme/dev.d.ts +23 -0
  40. package/dist/commands/theme/dev.js +167 -0
  41. package/dist/commands/theme/init.d.ts +9 -0
  42. package/dist/commands/theme/init.js +15 -0
  43. package/dist/commands/theme/pull.d.ts +20 -0
  44. package/dist/commands/theme/pull.js +65 -0
  45. package/dist/commands/theme/push.d.ts +19 -0
  46. package/dist/commands/theme/push.js +92 -0
  47. package/dist/create-app-command.d.ts +20 -0
  48. package/dist/create-app-command.js +254 -0
  49. package/dist/create-collection-command.d.ts +9 -9
  50. package/dist/create-collection-command.js +23 -23
  51. package/dist/{create-command.d.ts → create-config-command.d.ts} +1 -1
  52. package/dist/{create-command.js → create-config-command.js} +9 -7
  53. package/dist/lib/api.d.ts +6 -4
  54. package/dist/lib/api.js +58 -29
  55. package/dist/lib/app-config.d.ts +13 -5
  56. package/dist/lib/app-config.js +60 -23
  57. package/dist/lib/apps/app-config.d.ts +22 -8
  58. package/dist/lib/apps/app-config.js +202 -68
  59. package/dist/lib/apps/index.d.ts +93 -13
  60. package/dist/lib/apps/index.js +150 -50
  61. package/dist/lib/apps/paths.d.ts +14 -7
  62. package/dist/lib/apps/paths.js +73 -28
  63. package/dist/lib/bundle.js +4 -5
  64. package/dist/lib/config.d.ts +5 -1
  65. package/dist/lib/config.js +14 -0
  66. package/dist/lib/constants.d.ts +5 -0
  67. package/dist/lib/constants.js +22 -3
  68. package/dist/lib/create/content.d.ts +2 -2
  69. package/dist/lib/create/content.js +1 -1
  70. package/dist/lib/create/function.d.ts +2 -2
  71. package/dist/lib/create/function.js +2 -2
  72. package/dist/lib/create/index.d.ts +1 -1
  73. package/dist/lib/create/index.js +11 -7
  74. package/dist/lib/create/model.d.ts +2 -2
  75. package/dist/lib/create/notification.d.ts +2 -2
  76. package/dist/lib/info.js +2 -2
  77. package/dist/lib/sessions.js +2 -2
  78. package/dist/lib/stores.d.ts +5 -2
  79. package/dist/lib/stores.js +9 -6
  80. package/dist/lib/style.d.ts +2 -0
  81. package/dist/lib/style.js +2 -0
  82. package/dist/lib/swell-function-wrapper.d.ts +2 -2
  83. package/dist/lib/swell-function-wrapper.js +2 -2
  84. package/dist/push-app-command.d.ts +73 -0
  85. package/dist/push-app-command.js +765 -0
  86. package/dist/remote-app-command.d.ts +35 -9
  87. package/dist/remote-app-command.js +436 -164
  88. package/oclif.manifest.json +841 -125
  89. package/package.json +13 -1
  90. package/dist/commands/app/_pull.d.ts +0 -8
  91. package/dist/commands/app/_pull.js +0 -45
@@ -1,14 +1,16 @@
1
1
  import { inflect } from 'inflection';
2
- import * as fs from 'node:fs';
3
2
  import * as path from 'node:path';
4
- import ora from 'ora';
3
+ import { default as ora } from 'ora';
4
+ import sortBy from 'lodash/sortBy.js';
5
+ import { confirm } from '@inquirer/prompts';
5
6
  import { AppCommand } from './app-command.js';
6
- import { appConfigFileExists, readRcFile, writeRcFile, } from './lib/app-config.js';
7
- import { FunctionProcessingError, IgnoringFileError, allConfigFilesPaths, appConfigFromFile, appLogoIcon, batchAppConfigsByType, configFilePath, isAppLogoIcon, } from './lib/apps/index.js';
7
+ import { readRcFile, writeRcFile } from './lib/app-config.js';
8
+ import { AppConfig, SwellJsonFields, FunctionProcessingError, IgnoringFileError, allConfigFilesPaths, allBaseFilesInDir, appConfigFromFile, batchAppFilesByType, writeFile, writeJsonFile, deleteFile, hashFile, } from './lib/apps/index.js';
9
+ import { default as localConfig } from './lib/config.js';
10
+ import { getLoginHost, getAppFrontendHost, getStorefrontFrontendHost, getLocalFrontendHost, } from './lib/constants.js';
8
11
  import { toAppId } from './lib/create/index.js';
9
- import { default as swellConfig } from './lib/config.js';
10
- import { getLoginHost } from './lib/constants.js';
11
12
  import style from './lib/style.js';
13
+ const PUSH_CONCURRENCY = 3;
12
14
  /**
13
15
  * Extends the AppCommand with methods that ease managing remote apps and
14
16
  * configurations.
@@ -17,44 +19,85 @@ export class RemoteAppCommand extends AppCommand {
17
19
  // allows child commands to set orientation
18
20
  // most of the times this will be the target environment
19
21
  static orientation;
20
- // an API instance of the current app definition
22
+ static delayOrientation = false;
23
+ appType = 'any';
24
+ // API instance of the current app definition
21
25
  app = {};
26
+ // Indicates the app was created during the command execution
27
+ appCreated = false;
28
+ // API instance of the target storefront, if applicable
29
+ storefront = null;
30
+ // Indicates app configs are being synced along with theme configs
31
+ themeSyncApp = false;
22
32
  dashboardUrl(flags = {}, storeId) {
23
33
  const { env } = flags;
24
34
  const envFlag = env || this.ctor.orientation?.env;
25
- const outputEnv = envFlag === 'live' ? '' : !envFlag ? '' : 'test';
35
+ const outputEnv = envFlag === 'live' ? '' : envFlag ? 'test' : '';
26
36
  return `${getLoginHost(storeId)}/admin/${outputEnv ? `${outputEnv}/` : ''}apps/${this.app.id}`;
27
37
  }
28
- async deleteConfig(configId) {
29
- if (!this.app) {
30
- this.error('App is not defined');
31
- }
32
- return this.api.delete({
33
- adminPath: `/apps/${this.app.id}/configs/${configId}`,
34
- });
38
+ appFrontendUrl(storeId, installedAppId) {
39
+ return getAppFrontendHost(storeId, installedAppId);
40
+ }
41
+ storefrontFrontendUrl(storeId, storefront, branchId, preview = true, editor = false) {
42
+ const storefrontSlug = storefront && preview
43
+ ? `${storeId}--${storefront.id.toLowerCase()}`
44
+ : storefront && editor
45
+ ? `${storeId}--${storefront.id.toLowerCase()}--dev`
46
+ : storeId;
47
+ return getStorefrontFrontendHost(storefrontSlug, branchId);
48
+ }
49
+ localFrontendUrl(storeId, sessionId) {
50
+ return getLocalFrontendHost(storeId, sessionId);
35
51
  }
36
52
  async getApp(id = '') {
37
53
  const app = await this.api.get({ adminPath: `/apps/${id}` });
38
- // Get app configs for processing
39
- app.configs = [];
40
- const configsResponse = await this.api.get({ adminPath: `/apps/${id}/configs` }, { query: { limit: 1000 } });
41
- this.debugJson('getApp: configsResponse', configsResponse);
42
- // for each config, we check if there's a local file and we add it to the
43
- // AppConfig object
44
- for (const config of configsResponse.results || []) {
45
- const localPath = configFilePath(this.appPath, config);
46
- if (appConfigFileExists(localPath)) {
47
- config.localPath = localPath;
54
+ const themeId = this.appType === 'theme' && this.storefront?.theme_id;
55
+ const canPullConfigs = themeId || app.owned;
56
+ if (canPullConfigs) {
57
+ // TODO: paginate configs to support up to 10,000 (otherwise 1,000)
58
+ // Get app configs for processing
59
+ const configs = await this.api.get({ adminPath: `/apps/${id}/configs` }, {
60
+ query: {
61
+ limit: 1000,
62
+ ...(themeId ? { theme_id: themeId } : undefined),
63
+ },
64
+ });
65
+ // Instantiate AppConfig for each object
66
+ app.configs = (configs.results || []).map((config) => AppConfig.create({
67
+ ...config,
68
+ appPath: this.appPath,
69
+ }));
70
+ // Pull app configs to compare with theme for push/pull
71
+ if (themeId && this.themeSyncApp) {
72
+ const appConfigs = await this.api.get({ adminPath: `/apps/${id}/configs` }, { query: { limit: 1000 } });
73
+ for (const appConfig of appConfigs.results || []) {
74
+ const themeConfig = app.configs.find((config) => config.filePath === appConfig.file_path);
75
+ if (themeConfig && themeConfig.fileHash !== appConfig.hash) {
76
+ themeConfig.hash = appConfig.hash;
77
+ }
78
+ else if (!themeConfig && appConfig) {
79
+ app.configs.push(AppConfig.create({
80
+ ...appConfig,
81
+ appPath: this.appPath,
82
+ appConfigId: appConfig.id,
83
+ id: undefined,
84
+ }));
85
+ }
86
+ }
87
+ for (const themeConfig of app.configs) {
88
+ const appConfig = appConfigs.results.find((config) => themeConfig.filePath === config.file_path);
89
+ if (!appConfig) {
90
+ themeConfig.hash = 'x';
91
+ }
92
+ }
48
93
  }
49
- app.configs.push(config);
50
94
  }
51
- this.debugJson('getApp: remote app', app);
52
95
  return app;
53
96
  }
54
97
  async getAppWithConfig(id = '') {
55
98
  const swellId = this.app?.id;
56
- const appConfigId = this.appConfig.get('id');
57
- if (!appConfigId) {
99
+ const swellConfigId = this.swellConfig.get('id');
100
+ if (!swellConfigId) {
58
101
  this.error(`App ${style.appConfigName('id')} is not defined in swell.json.`);
59
102
  }
60
103
  let appId = id || swellId;
@@ -63,8 +106,8 @@ export class RemoteAppCommand extends AppCommand {
63
106
  if (!privateId) {
64
107
  this.error(`App ${style.appConfigName('id')} is not defined in swell.json.`);
65
108
  }
66
- if (privateId !== appConfigId) {
67
- this.error(`App ${style.appConfigName('id')} must be declared in snake_case: ${style.appConfigValue(appConfigId)}.`);
109
+ if (privateId !== swellConfigId) {
110
+ this.error(`App ${style.appConfigName('id')} must be declared in snake_case: ${style.appConfigValue(swellConfigId)}.`);
68
111
  }
69
112
  appId = privateId;
70
113
  }
@@ -72,9 +115,9 @@ export class RemoteAppCommand extends AppCommand {
72
115
  let app = {};
73
116
  try {
74
117
  app = await this.getApp(appId);
118
+ this.app.id = app.id;
75
119
  }
76
120
  catch (error) {
77
- this.debug('getApp: app not found', error);
78
121
  let errorJson;
79
122
  try {
80
123
  errorJson = JSON.parse(error.message);
@@ -105,87 +148,126 @@ export class RemoteAppCommand extends AppCommand {
105
148
  }
106
149
  async getCreateUpdateApp(updateApp) {
107
150
  const spinner = ora();
108
- let remoteApp;
151
+ let sourceApp;
109
152
  try {
110
- remoteApp = await readRcFile(this.swellRcPath());
153
+ sourceApp = await readRcFile(this.swellRcPath());
111
154
  }
112
155
  catch (error) {
113
156
  if (error.code !== 'ENOENT') {
114
157
  throw error;
115
158
  }
116
159
  }
160
+ if (!this.swellConfig.store.id) {
161
+ throw new Error('App swell.json is missing or `id` is not defined.');
162
+ }
117
163
  /* eslint-disable camelcase */
118
164
  const body = {
119
- ...this.appConfig.store,
165
+ ...this.swellConfig.store,
120
166
  // we store locally the private id as the id, but we need to switch it
121
167
  // when posting to the API
122
168
  id: undefined,
123
- logo_icon: null,
124
- ...(this.appConfig.store.id
125
- ? { private_id: this.appConfig.store.id }
169
+ ...(this.swellConfig.store.id
170
+ ? { private_id: this.swellConfig.store.id }
126
171
  : undefined),
127
- ...(remoteApp?.id ? { source_id: remoteApp.id } : undefined),
172
+ ...(sourceApp?.id ? { source_id: sourceApp.id } : undefined),
128
173
  };
129
- const logoIcon = appLogoIcon(this.appPath);
130
- if (logoIcon) {
131
- const icon = fs.readFileSync(logoIcon, { encoding: 'base64' });
132
- body.logo_icon = icon;
133
- }
174
+ const appLabel = this.app.type === 'theme' ? 'theme' : 'app';
134
175
  /* eslint-enable camelcase */
135
176
  let app = updateApp || {};
136
- let appCreated = false;
137
177
  if (!app.id) {
138
178
  // May already have a dev instance
139
179
  let existingDevApp;
140
180
  try {
141
181
  existingDevApp = await this.api.get({
142
- adminPath: `/apps/${this.appConfig.store.id}`,
182
+ adminPath: `/apps/${this.swellConfig.store.id}`,
143
183
  });
144
184
  }
145
- catch (err) {
185
+ catch {
146
186
  // noop
147
187
  }
148
- spinner.start(`Creating app...`);
188
+ spinner.start(`Creating ${appLabel}...`);
149
189
  if (existingDevApp) {
150
- app = await this.handleRequestErrors(async () => this.api.put({ adminPath: `/apps/${existingDevApp.id}` }, { body }), () => spinner.fail('Error creating app'));
190
+ app = await this.handleRequestErrors(async () => this.api.put({ adminPath: `/apps/${existingDevApp.id}` }, { body }), () => spinner.fail('Error updating app.'));
151
191
  }
152
192
  else {
153
- app = await this.handleRequestErrors(async () => this.api.post({ adminPath: '/apps' }, { body }), () => spinner.fail('Error creating app'));
154
- appCreated = true;
193
+ app = await this.handleRequestErrors(async () => this.api.post({ adminPath: '/apps' }, { body }), () => spinner.fail('Error creating app.'));
194
+ this.appCreated = true;
155
195
  }
156
196
  }
157
197
  else if (app.id) {
158
- spinner.start(`Updating app...`);
159
- app = await this.handleRequestErrors(async () => this.api.put({ adminPath: `/apps/${app.id}` }, { body }), () => spinner.fail('Error updating app'));
198
+ spinner.start(`Updating ${appLabel}...`);
199
+ app = await this.handleRequestErrors(async () => this.api.put({ adminPath: `/apps/${app.id}` }, { body }), () => spinner.fail('Error updating app.'));
160
200
  }
161
201
  if (app?.id) {
162
- await writeRcFile(this.swellRcPath(), { id: app.id });
202
+ if (!sourceApp) {
203
+ await writeRcFile(this.swellRcPath(), { id: app.id });
204
+ }
163
205
  // if not failed, we want to display correct messaging
164
206
  if (spinner.isSpinning) {
165
- if (appCreated) {
166
- spinner.succeed('Development app created.');
167
- this.log();
207
+ if (this.appCreated) {
208
+ spinner.succeed(`Development ${appLabel} created.`);
168
209
  }
169
210
  else {
170
- spinner.stop();
171
- }
172
- this.log('App details');
173
- spinner.succeed('app details');
174
- if (logoIcon) {
175
- spinner.succeed('app logo');
211
+ spinner.succeed(`${appLabel === 'theme' ? 'Theme app' : 'App'} details up to date.`);
176
212
  }
213
+ this.log();
177
214
  }
178
- this.log();
179
- return app;
215
+ return this.getApp(app.id);
180
216
  }
181
217
  return {};
182
218
  }
219
+ async getInstalledApp() {
220
+ // check if app is already installed on store
221
+ return this.api.get({ adminPath: `/client/apps/${this.app.id}` }, {});
222
+ }
223
+ async installApp(version, spinner) {
224
+ return this.api.post({ adminPath: `/client/apps` },
225
+ // eslint-disable-next-line camelcase
226
+ {
227
+ body: { app_id: this.app.id, version },
228
+ spinner,
229
+ onAsyncGetPath: (response) => ({
230
+ adminPath: `/client/app-async-status/${response.id}`,
231
+ }),
232
+ });
233
+ }
234
+ async updateInstalledApp(version, spinner) {
235
+ await this.api.put({ adminPath: `/client/apps/${this.app.id}` }, {
236
+ body: { version },
237
+ spinner,
238
+ onAsyncGetPath: (response) => ({
239
+ adminPath: `/client/app-async-status/${response.id}`,
240
+ }),
241
+ });
242
+ }
243
+ async getVersions(version) {
244
+ const versionsResponse = await this.api.get({ adminPath: `/apps/${this.app.id}/versions` }, {
245
+ query: {
246
+ ...(version ? { version } : undefined),
247
+ },
248
+ });
249
+ return versionsResponse.results || [];
250
+ }
251
+ async getVersion(version) {
252
+ const versionsResponse = await this.api.get({ adminPath: `/apps/${this.app.id}/versions` }, {
253
+ query: {
254
+ limit: 1,
255
+ ...(version ? { version } : undefined),
256
+ },
257
+ });
258
+ return versionsResponse.results?.[0];
259
+ }
260
+ async updateVersion(version, body, spinner) {
261
+ await this.handleRequestErrors(async () => this.api.put({ adminPath: `/apps/${this.app.id}/versions` }, { body: { version, ...body } }), () => spinner?.fail('Error updating version.'));
262
+ }
183
263
  async handleRequestErrors(request, spinnerError) {
264
+ let showedErrors = false;
184
265
  try {
185
266
  const response = await request();
186
267
  if (response?.status !== 200 && response?.errors) {
187
268
  this.showErrors(response.errors, spinnerError);
188
- this.error('API request failed');
269
+ showedErrors = true;
270
+ this.error('Something went wrong.');
189
271
  }
190
272
  return response;
191
273
  }
@@ -197,13 +279,13 @@ export class RemoteAppCommand extends AppCommand {
197
279
  catch {
198
280
  // noop, likely JSON parse error
199
281
  }
200
- if (jsonError && jsonError.error) {
201
- this.showErrors(jsonError, spinnerError);
202
- this.error('API request failed');
203
- }
204
- else if (typeof error.message === 'string') {
205
- this.showErrors(error, spinnerError);
206
- this.error(error.message);
282
+ if (!showedErrors) {
283
+ if (jsonError && jsonError.error) {
284
+ this.showErrors(jsonError, spinnerError);
285
+ }
286
+ else if (typeof error.message === 'string') {
287
+ this.showErrors(error, spinnerError);
288
+ }
207
289
  }
208
290
  throw error;
209
291
  }
@@ -211,63 +293,107 @@ export class RemoteAppCommand extends AppCommand {
211
293
  async init() {
212
294
  await super.init();
213
295
  this.app = this.initApp();
296
+ this.storefront = null;
297
+ const klass = this.ctor;
298
+ if (klass.orientation && !klass.delayOrientation) {
299
+ await this.logOrientation();
300
+ }
301
+ }
302
+ async logOrientation() {
214
303
  const klass = this.ctor;
215
304
  const { flags } = await this.parse(klass);
216
305
  if (klass.orientation) {
217
306
  this.showOrientation(klass.orientation, flags);
218
307
  }
219
308
  }
220
- async postConfig(configJson) {
221
- if (!this.app) {
222
- this.error('App is not defined');
223
- }
224
- this.debug('postConfig: configJson', configJson);
225
- return this.api.post({ adminPath: `/apps/${this.app.id}/configs` }, { body: configJson });
309
+ async postConfigData(configJson) {
310
+ const themeId = this.app.type === 'theme' && this.storefront?.theme_id;
311
+ return this.api.post({ adminPath: `/apps/${this.app.id}/configs` }, {
312
+ body: {
313
+ ...configJson,
314
+ ...(themeId ? { theme_id: themeId } : undefined),
315
+ ...(this.themeSyncApp ? { $sync_app: true } : undefined),
316
+ },
317
+ });
318
+ }
319
+ async getConfigData(configId) {
320
+ const themeId = this.app.type === 'theme' && this.storefront?.theme_id;
321
+ return await this.api.get({
322
+ adminPath: `/apps/${this.app.id}/configs/${configId}/data`,
323
+ }, { query: { ...(themeId ? { theme_id: themeId } : undefined) } });
324
+ }
325
+ async deleteConfig(configId, filePath, appConfigId) {
326
+ const themeId = this.app.type === 'theme' && this.storefront?.theme_id;
327
+ return this.api.delete({
328
+ adminPath: `/apps/${this.app.id}/configs/${appConfigId || configId}`,
329
+ }, {
330
+ query: {
331
+ ...(filePath ? { file_path: filePath } : undefined),
332
+ ...(themeId ? { theme_id: themeId } : undefined),
333
+ ...(appConfigId ? { app_config_id: appConfigId } : undefined),
334
+ ...(this.themeSyncApp ? { $sync_app: true } : undefined),
335
+ },
336
+ });
226
337
  }
227
- async pushConfigFile(appConfig) {
228
- const { localPath } = appConfig;
338
+ async pushRemoteFile(config, log = true) {
229
339
  const spinner = ora();
230
- let postData;
231
- if (!localPath) {
232
- throw new Error('Missing local config path');
233
- }
234
- // we don't want to treat the app logo asset as config file
235
- // it is dealt with at the app level
236
- if (isAppLogoIcon(localPath)) {
237
- return;
238
- }
239
- const pathDesc = localPath.split('/').slice(-2).join('/');
240
- spinner.start(`Pushing ${pathDesc}`);
340
+ log && spinner.start(`Pushing ${config.filePath}`);
241
341
  try {
242
- postData = await appConfig.postData();
342
+ const postData = await config.postData();
243
343
  if (!postData) {
244
344
  spinner.stop();
245
345
  return;
246
346
  }
347
+ const result = await this.handleRequestErrors(async () => this.postConfigData(postData), () => log && spinner.fail(`Error pushing ${config.filePath}`));
348
+ log && spinner.succeed(`${this.timestampStyled()} ${config.filePath}`);
349
+ return result;
247
350
  }
248
351
  catch (error) {
249
352
  if (error instanceof IgnoringFileError) {
250
- spinner.fail(`Ignoring file: ${error.message} in ${localPath}`);
353
+ log &&
354
+ spinner.fail(`Ignoring file: ${error.message} in ${config.filePath}`);
251
355
  return;
252
356
  }
253
357
  if (error instanceof FunctionProcessingError) {
254
- this.debug(error);
255
- this.debug(error.originalError);
256
- spinner.fail(`${error.message} ${error.originalError?.message?.toLowerCase()}`);
358
+ log &&
359
+ spinner.fail(`${error.message} ${error.originalError?.message?.toLowerCase()}`);
257
360
  return;
258
361
  }
259
- spinner.fail(error.message);
362
+ log && spinner.fail(error.message);
260
363
  throw error;
261
364
  }
262
- const result = await this.handleRequestErrors(async () => this.postConfig(postData), () => spinner.fail(`Error pushing ${pathDesc}`));
263
- spinner.succeed(`Pushed ${pathDesc}`);
365
+ }
366
+ async pullRemoteFile(config, log = true) {
367
+ const spinner = ora();
368
+ log && spinner.start(`Pulling ${config.filePath}`);
369
+ try {
370
+ const result = await this.handleRequestErrors(async () => this.getConfigData(config.id), () => log && spinner.fail(`Error pulling ${config.filePath}`));
371
+ await writeFile(config.appPath, result);
372
+ log && spinner.succeed(`${this.timestampStyled()} ${config.filePath}`);
373
+ return result;
374
+ }
375
+ catch (error) {
376
+ log && spinner.fail(error.message);
377
+ throw error;
378
+ }
379
+ }
380
+ async removeRemoteFile(config, log = true) {
381
+ const spinner = ora();
382
+ log && spinner.start(`Removing ${config.filePath}`);
383
+ const result = await this.handleRequestErrors(async () => this.deleteConfig(config.id, config.filePath, config.appConfigId), () => log && spinner.fail(`Error removing ${config.filePath}`));
384
+ log &&
385
+ spinner.warn(`${this.timestampStyled()} ${style.strike(config.filePath)}`);
264
386
  return result;
265
387
  }
266
- async removeConfig(config) {
388
+ async removeLocalFile(config, log = true) {
389
+ if (!config.fileData) {
390
+ return;
391
+ }
267
392
  const spinner = ora();
268
- spinner.start(`Removing ${config.type} ${config.name}`);
269
- const result = await this.handleRequestErrors(async () => this.deleteConfig(config.id), () => spinner.fail(`Error removing ${config.type} ${config.name}`));
270
- spinner.warn(`Removed ${config.type} ${config.name}`);
393
+ log && spinner.start(`Removing ${config.filePath}`);
394
+ const result = await this.handleRequestErrors(async () => deleteFile(config.appPath), () => log && spinner.fail(`Error removing ${config.filePath}`));
395
+ log &&
396
+ spinner.warn(`${this.timestampStyled()} ${style.strike(config.filePath)}`);
271
397
  return result;
272
398
  }
273
399
  showErrors(errors, spinnerError) {
@@ -279,6 +405,8 @@ export class RemoteAppCommand extends AppCommand {
279
405
  this.logError(inflect('Error', actualErrors.length));
280
406
  }
281
407
  for (const err of actualErrors) {
408
+ if (!errors[err]?.message)
409
+ continue;
282
410
  // we need to display `id` instead of `private_id`
283
411
  // developers don't have any knowledge of private ids
284
412
  const displayErr = err === 'private_id' ? 'id' : err;
@@ -291,45 +419,205 @@ export class RemoteAppCommand extends AppCommand {
291
419
  .join(' '));
292
420
  }
293
421
  }
294
- async syncAppConfigs() {
295
- const configsToPush = [];
296
- const installed = [];
297
- const removed = [];
422
+ swellRcPath() {
423
+ return path.join(this.appPath, '.swellrc');
424
+ }
425
+ getLocalAppConfigs() {
426
+ const localConfigs = [];
298
427
  for (const { configFile, configType } of allConfigFilesPaths(this.appPath)) {
299
- // skip logo icon as it is dealt with at the app level
300
- if (isAppLogoIcon(configFile)) {
428
+ const config = appConfigFromFile(configFile, configType, this.appPath);
429
+ localConfigs.push(config);
430
+ }
431
+ for (const filePath of allBaseFilesInDir(this.appPath)) {
432
+ const config = appConfigFromFile(filePath, 'file', this.appPath);
433
+ localConfigs.push(config);
434
+ }
435
+ return localConfigs;
436
+ }
437
+ async pushAppConfigs(force) {
438
+ const pushed = [];
439
+ const removed = [];
440
+ const localConfigs = this.getLocalAppConfigs();
441
+ const remoteConfigs = await this.cleanRemoteConfigs(this.app?.configs || []);
442
+ // Do not allow more than 10K files
443
+ if (localConfigs.length > 10000) {
444
+ this.error(`Apps only support up to 10,000 files. Found ${localConfigs.length} files.`);
445
+ }
446
+ const batches = batchAppFilesByType(localConfigs);
447
+ for (const batch of batches) {
448
+ const remoteBatchConfigs = remoteConfigs.filter((config) => config.type === batch.type);
449
+ const pushFiles = batch.configs.filter((batchConfig) => {
450
+ const remoteConfig = remoteBatchConfigs.find((config) => {
451
+ return batchConfig.filePath === config.filePath;
452
+ });
453
+ return Boolean(force || !remoteConfig || remoteConfig.hash !== batchConfig.hash);
454
+ });
455
+ const filesToRemove = remoteBatchConfigs.filter((config) => {
456
+ return !batch.configs.find((batchConfig) => {
457
+ return batchConfig.filePath === config.filePath;
458
+ });
459
+ });
460
+ // Skip if there are no config changes
461
+ if (!pushFiles.length && !filesToRemove.length) {
301
462
  continue;
302
463
  }
303
- const appConfig = appConfigFromFile(configFile, configType);
304
- configsToPush.push(appConfig);
464
+ const updatedLog = pushFiles.length > 0
465
+ ? remoteBatchConfigs.length > 0
466
+ ? `(${pushFiles.length}/${batch.configs.length})`
467
+ : `(${pushFiles.length})`
468
+ : '';
469
+ if (pushFiles.length > 0 || filesToRemove.length > 0) {
470
+ this.log(`${style.appConfigName(`${batch.label} ${updatedLog}`)}`);
471
+ }
472
+ if (pushFiles.length > 0) {
473
+ const pushConcurrency = batch.type === 'ASSET' ? 1 : PUSH_CONCURRENCY;
474
+ while (pushFiles.length) {
475
+ await Promise.all(pushFiles
476
+ .splice(0, pushConcurrency)
477
+ .map(async (config) => {
478
+ const pushedConfig = await this.pushRemoteFile(config);
479
+ if (pushedConfig) {
480
+ pushed.push(pushedConfig.id);
481
+ if (config.filePath === 'swell.json') {
482
+ this.app = await this.getApp(this.app.id);
483
+ }
484
+ }
485
+ }));
486
+ }
487
+ }
488
+ // Remove any deleted files
489
+ if (filesToRemove.length > 0) {
490
+ while (filesToRemove.length) {
491
+ await Promise.all(filesToRemove.splice(0, PUSH_CONCURRENCY).map(async (config) => {
492
+ await this.removeRemoteFile(config);
493
+ removed.push(config.id);
494
+ }));
495
+ }
496
+ }
497
+ this.log();
305
498
  }
306
- const batches = batchAppConfigsByType(configsToPush);
499
+ if (pushed.length === 0 && removed.length === 0) {
500
+ this.log(`${style.success('✔')} All files up to date.\n`);
501
+ }
502
+ return { pushed, removed };
503
+ }
504
+ async pullAppConfigs(force, confirmRemove = true) {
505
+ const pulled = [];
506
+ const removed = [];
507
+ const localConfigs = this.getLocalAppConfigs();
508
+ const remoteConfigs = await this.cleanRemoteConfigs(await this.pullAppJsonAndRemoteConfigs(), false);
509
+ const batches = batchAppFilesByType(remoteConfigs);
307
510
  for (const batch of batches) {
308
- this.log(style.appConfigName(batch.label));
309
- // eslint-disable-next-line no-await-in-loop
310
- for (const appConfig of batch.configs) {
311
- const installedConfig = await this.pushConfigFile(appConfig);
312
- if (installedConfig)
313
- installed.push(installedConfig.id);
511
+ const remoteBatchConfigs = remoteConfigs.filter((config) => config.type === batch.type);
512
+ const localBatchConfigs = localConfigs.filter((config) => config.type === batch.type);
513
+ const pullFiles = batch.configs.filter((batchConfig) => {
514
+ if (force)
515
+ return true;
516
+ try {
517
+ const localConfig = localBatchConfigs.find((config) => config.filePath === batchConfig.filePath);
518
+ if (localConfig) {
519
+ const hash = hashFile(batchConfig.appPath);
520
+ if (hash === batchConfig.hash) {
521
+ return false;
522
+ }
523
+ }
524
+ }
525
+ catch { }
526
+ return true;
527
+ });
528
+ const filesToRemove = localBatchConfigs.filter((config) => {
529
+ return !batch.configs.find((batchConfig) => {
530
+ return batchConfig.filePath === config.filePath;
531
+ });
532
+ });
533
+ // Skip if there are no config changes
534
+ if (!pullFiles.length && !filesToRemove.length) {
535
+ continue;
536
+ }
537
+ const updatedLog = pullFiles.length > 0
538
+ ? `(${pullFiles.length}/${remoteBatchConfigs.length})`
539
+ : '';
540
+ if (pullFiles.length > 0 || filesToRemove.length > 0) {
541
+ this.log(`${style.appConfigName(`${batch.label} ${updatedLog}`)}`);
542
+ }
543
+ if (pullFiles.length > 0) {
544
+ while (pullFiles.length) {
545
+ await Promise.all(pullFiles
546
+ .splice(0, PUSH_CONCURRENCY)
547
+ .map(async (config) => {
548
+ await this.pullRemoteFile(config);
549
+ pulled.push(config.id);
550
+ }));
551
+ }
552
+ }
553
+ // Remove any deleted files
554
+ if (filesToRemove.length > 0) {
555
+ if (confirmRemove && !force) {
556
+ const removeList = filesToRemove
557
+ .map((config) => config.filePath)
558
+ .join('\n');
559
+ const confirmed = await confirm({
560
+ message: `The following files are not in the remote app:\n\n${removeList}\n\nDo you want to delete these files locally?`,
561
+ default: false,
562
+ });
563
+ if (!confirmed) {
564
+ filesToRemove.splice(0);
565
+ }
566
+ }
567
+ while (filesToRemove.length) {
568
+ await Promise.all(filesToRemove.splice(0, PUSH_CONCURRENCY).map(async (config) => {
569
+ await this.removeLocalFile(config);
570
+ removed.push(config.id);
571
+ }));
572
+ }
314
573
  }
315
574
  this.log();
316
575
  }
317
- // pull the app again after the latest push
318
- // this allows us to get the latest configs updates that the remote app
319
- // might have done
320
- this.app = await this.getAppWithConfig(this.app.id);
321
- this.debugJson('syncAppConfigs: app', this.app);
322
- // if the remote app has configs that are not in the local app, we want to
323
- // remove them
324
- for (const config of this.app?.configs || []) {
325
- const appConfig = config;
326
- if (!appConfig.localPath) {
327
- // eslint-disable-next-line no-await-in-loop
328
- await this.removeConfig(appConfig);
329
- removed.push(appConfig.id);
576
+ if (pulled.length === 0 && removed.length === 0) {
577
+ this.log(`${style.success('✔')} All files up to date.\n`);
578
+ }
579
+ return { pulled, removed };
580
+ }
581
+ async pullAppJsonAndRemoteConfigs() {
582
+ const configs = this.app.configs || [];
583
+ const allOtherConfigs = sortBy(configs.filter((config) => config.filePath !== 'swell.json'), 'filePath');
584
+ const swellJson = configs.find((config) => config.filePath === 'swell.json');
585
+ if (swellJson) {
586
+ await this.pullRemoteFile(swellJson, false);
587
+ }
588
+ else {
589
+ const swellConfigJson = {};
590
+ for (const key in SwellJsonFields) {
591
+ const configKey = SwellJsonFields[key];
592
+ swellConfigJson[configKey] = this.app[configKey];
330
593
  }
594
+ swellConfigJson.id = toAppId(this.app.private_id);
595
+ await writeJsonFile(path.join(this.appPath, 'swell.json'), swellConfigJson);
331
596
  }
332
- return { installed, removed };
597
+ const swellConfig = appConfigFromFile('swell.json', 'file', this.appPath);
598
+ swellConfig.id = swellJson?.id;
599
+ return [swellConfig, ...allOtherConfigs];
600
+ }
601
+ isConfigDeprecated(config) {
602
+ return !config.file_path;
603
+ }
604
+ async cleanRemoteConfigs(remoteConfigs, deleteDeprecated = true) {
605
+ // Delete remote configs that do not have file_path etc
606
+ const deprecatedConfigs = remoteConfigs.filter((config) => this.isConfigDeprecated(config));
607
+ if (deleteDeprecated && deprecatedConfigs.length > 0) {
608
+ const spinner = ora();
609
+ spinner.start('Cleaning up deprecated configurations, please wait...');
610
+ for (const config of deprecatedConfigs) {
611
+ await this.removeRemoteFile(config, false);
612
+ }
613
+ spinner.stop();
614
+ }
615
+ const validConfigs = remoteConfigs.filter((config) => !this.isConfigDeprecated(config));
616
+ return validConfigs;
617
+ }
618
+ timestampStyled() {
619
+ const date = new Date();
620
+ return style.dim(`[${date.toLocaleTimeString()}]`);
333
621
  }
334
622
  /**
335
623
  * Reads the .swellrc file and sets up the remote app configuration.
@@ -337,30 +625,28 @@ export class RemoteAppCommand extends AppCommand {
337
625
  * @returns App
338
626
  */
339
627
  initApp() {
340
- let remoteApp = {};
341
- try {
342
- this.debug('initApp: swellRcPath', this.swellRcPath());
343
- remoteApp = readRcFile(this.swellRcPath());
344
- this.debug('initApp: remoteApp', remoteApp);
345
- }
346
- catch (error) {
347
- // if the file doesn't exist, we don't want to throw an error as we might
348
- // want to create it later.
349
- if (error.code !== 'ENOENT') {
350
- throw error;
351
- }
352
- }
353
- if (this.appConfig.get('id')) {
628
+ let app = this.swellConfig.store || {};
629
+ // id refers to remote app id, so it's undefined initially
630
+ app.id = undefined;
631
+ // swell.json id refers to private id
632
+ if (this.swellConfig.get('id')) {
354
633
  // use the same formula as the backend api
355
634
  // eslint-disable-next-line camelcase
356
- remoteApp.private_id = toAppId(this.appConfig.get('id'));
635
+ app.private_id = toAppId(this.swellConfig.get('id'));
357
636
  }
358
- return remoteApp;
637
+ // Default name to private id
638
+ if (!app.name) {
639
+ app.name = app.private_id || app.id;
640
+ }
641
+ return app;
359
642
  }
360
643
  showOrientation(orientation, flags) {
361
644
  const orientationOutput = [];
362
- const currentStore = swellConfig.getDefaultStore();
645
+ const currentStore = localConfig.getDefaultStore();
363
646
  const { env, store } = flags;
647
+ orientationOutput.push(`[${this.app.type === 'theme' ? 'theme' : 'app'}] ${style.appName(this.app.name)}${this.app.version ? ` v${this.app.version}` : ''}${this.storefront && this.storefront.name !== this.app.name
648
+ ? ` [storefront] ${style.appName(this.storefront.name)}`
649
+ : ``}`);
364
650
  if (store) {
365
651
  orientationOutput.push(`[store] ${style.storeId(store)}`);
366
652
  }
@@ -375,18 +661,4 @@ export class RemoteAppCommand extends AppCommand {
375
661
  this.log(orientationOutput.join(' '));
376
662
  this.log();
377
663
  }
378
- async getVersions(version) {
379
- const versionsResponse = await this.api.get({ adminPath: `/apps/${this.app.id}/versions` }, {
380
- query: {
381
- limit: 1000,
382
- sort: 'version desc',
383
- ...(version ? { version } : undefined),
384
- },
385
- });
386
- this.debugJson('getApp: versionsResponse', versionsResponse);
387
- return versionsResponse.results || [];
388
- }
389
- swellRcPath() {
390
- return path.join(this.appPath, '.swellrc');
391
- }
392
664
  }