@capawesome/cli 4.24.0 → 4.26.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
+ ## [4.26.0](https://github.com/capawesome-team/cli/compare/v4.25.0...v4.26.0) (2026-10-05)
6
+
7
+
8
+ ### Features
9
+
10
+ * add Electron app version constraint flags for live update bundles ([#185](https://github.com/capawesome-team/cli/issues/185)) ([b1109d6](https://github.com/capawesome-team/cli/commit/b1109d6b40c01325e35cbd9f0a2aa0d6c8123e7a))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * use the configured console URL in printed links ([#241](https://github.com/capawesome-team/cli/issues/241)) ([511d220](https://github.com/capawesome-team/cli/commit/511d2201643aed8733f36a1ca9edef67b24cd850))
16
+
17
+ ## [4.25.0](https://github.com/capawesome-team/cli/compare/v4.24.0...v4.25.0) (2026-10-04)
18
+
19
+
20
+ ### Features
21
+
22
+ * **apps:** deploy automation builds to multiple channels ([#239](https://github.com/capawesome-team/cli/issues/239)) ([4904d60](https://github.com/capawesome-team/cli/commit/4904d607ec40f976bc002d9c4a44ed8374baec71))
23
+
5
24
  ## [4.24.0](https://github.com/capawesome-team/cli/compare/v4.23.1...v4.24.0) (2026-10-01)
6
25
 
7
26
 
@@ -1,6 +1,7 @@
1
1
  import appAutomationsService from '../../../services/app-automations.js';
2
2
  import appsService from '../../../services/apps.js';
3
3
  import { withAuth } from '../../../utils/auth.js';
4
+ import { parseListOption } from '../../../utils/cli-options.js';
4
5
  import { isInteractive } from '../../../utils/environment.js';
5
6
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
6
7
  import consola from 'consola';
@@ -11,7 +12,10 @@ export default defineCommand({
11
12
  options: defineOptions(z.object({
12
13
  appId: z.string().optional().describe('ID of the app.'),
13
14
  certificate: z.string().optional().describe('The name of the certificate to use for the build.'),
14
- channel: z.string().optional().describe('The name of the channel to deploy to (Web only).'),
15
+ channel: z
16
+ .array(z.string())
17
+ .optional()
18
+ .describe('The name of a channel to deploy to (Web only). Can be specified multiple times or comma-separated.'),
15
19
  commitMessagePattern: z
16
20
  .string()
17
21
  .optional()
@@ -117,7 +121,7 @@ export default defineCommand({
117
121
  }
118
122
  const automation = await appAutomationsService.create({
119
123
  appCertificateName: options.certificate,
120
- appChannelName: options.channel,
124
+ appChannelNames: parseListOption(options.channel),
121
125
  appConfigurationName: options.configuration,
122
126
  appDestinationName: options.destination,
123
127
  appEnvironmentName: options.environment,
@@ -68,6 +68,52 @@ describe('apps-automations-create', () => {
68
68
  expect(mockConsola.info).toHaveBeenCalledWith(`Automation ID: ${automationId}`);
69
69
  expect(mockConsola.success).toHaveBeenCalledWith('Automation created successfully.');
70
70
  });
71
+ it('should send the channels as appChannelNames', async () => {
72
+ const appId = 'app-123';
73
+ const automationId = 'automation-456';
74
+ const options = {
75
+ appId,
76
+ channel: ['beta', 'alpha'],
77
+ name: 'nightly',
78
+ platform: 'web',
79
+ triggerType: 'branch',
80
+ };
81
+ const scope = nock(DEFAULT_API_BASE_URL)
82
+ .post(`/v1/apps/${appId}/automations`, {
83
+ appChannelNames: ['beta', 'alpha'],
84
+ name: 'nightly',
85
+ platform: 'web',
86
+ triggerType: 'branch',
87
+ })
88
+ .matchHeader('Authorization', 'Bearer test-token')
89
+ .reply(201, { id: automationId, name: 'nightly' });
90
+ await createAutomationCommand.action(options, undefined);
91
+ expect(scope.isDone()).toBe(true);
92
+ expect(mockConsola.success).toHaveBeenCalledWith('Automation created successfully.');
93
+ });
94
+ it('should split comma-separated channels into separate appChannelNames', async () => {
95
+ const appId = 'app-123';
96
+ const automationId = 'automation-456';
97
+ const options = {
98
+ appId,
99
+ channel: ['beta, alpha'],
100
+ name: 'nightly',
101
+ platform: 'web',
102
+ triggerType: 'branch',
103
+ };
104
+ const scope = nock(DEFAULT_API_BASE_URL)
105
+ .post(`/v1/apps/${appId}/automations`, {
106
+ appChannelNames: ['beta', 'alpha'],
107
+ name: 'nightly',
108
+ platform: 'web',
109
+ triggerType: 'branch',
110
+ })
111
+ .matchHeader('Authorization', 'Bearer test-token')
112
+ .reply(201, { id: automationId, name: 'nightly' });
113
+ await createAutomationCommand.action(options, undefined);
114
+ expect(scope.isDone()).toBe(true);
115
+ expect(mockConsola.success).toHaveBeenCalledWith('Automation created successfully.');
116
+ });
71
117
  it('should output JSON when json flag is set', async () => {
72
118
  const appId = 'app-123';
73
119
  const automationId = 'automation-456';
@@ -1,6 +1,6 @@
1
1
  import appAutomationsService from '../../../services/app-automations.js';
2
2
  import { withAuth } from '../../../utils/auth.js';
3
- import { clearableValue } from '../../../utils/cli-options.js';
3
+ import { clearableValue, parseListOption } from '../../../utils/cli-options.js';
4
4
  import { isInteractive } from '../../../utils/environment.js';
5
5
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
6
6
  import consola from 'consola';
@@ -17,9 +17,9 @@ export default defineCommand({
17
17
  .optional()
18
18
  .describe('The name of the certificate to use for the build. Pass an empty string to clear it.'),
19
19
  channel: z
20
- .string()
20
+ .array(z.string())
21
21
  .optional()
22
- .describe('The name of the channel to deploy to (Web only). Pass an empty string to clear it.'),
22
+ .describe('The name of a channel to deploy to (Web only). Can be specified multiple times or comma-separated. Pass an empty string to clear them.'),
23
23
  commitMessagePattern: z
24
24
  .string()
25
25
  .optional()
@@ -95,7 +95,7 @@ export default defineCommand({
95
95
  }
96
96
  await appAutomationsService.update({
97
97
  appCertificateName: clearableValue(options.certificate),
98
- appChannelName: clearableValue(options.channel),
98
+ appChannelNames: parseListOption(options.channel),
99
99
  appConfigurationName: clearableValue(options.configuration),
100
100
  appDestinationName: clearableValue(options.destination),
101
101
  appEnvironmentName: clearableValue(options.environment),
@@ -71,6 +71,30 @@ describe('apps-automations-update', () => {
71
71
  expect(scope.isDone()).toBe(true);
72
72
  expect(mockConsola.success).toHaveBeenCalledWith('Automation updated successfully.');
73
73
  });
74
+ it('should send the channels as appChannelNames', async () => {
75
+ const appId = 'app-123';
76
+ const automationId = 'automation-456';
77
+ const options = { appId, automationId, channel: ['beta, alpha', 'gamma'] };
78
+ const scope = nock(DEFAULT_API_BASE_URL)
79
+ .patch(`/v1/apps/${appId}/automations/${automationId}`, { appChannelNames: ['beta', 'alpha', 'gamma'] })
80
+ .matchHeader('Authorization', 'Bearer test-token')
81
+ .reply(204);
82
+ await updateAutomationCommand.action(options, undefined);
83
+ expect(scope.isDone()).toBe(true);
84
+ expect(mockConsola.success).toHaveBeenCalledWith('Automation updated successfully.');
85
+ });
86
+ it('should clear the channels when an empty string is passed', async () => {
87
+ const appId = 'app-123';
88
+ const automationId = 'automation-456';
89
+ const options = { appId, automationId, channel: [''] };
90
+ const scope = nock(DEFAULT_API_BASE_URL)
91
+ .patch(`/v1/apps/${appId}/automations/${automationId}`, { appChannelNames: [] })
92
+ .matchHeader('Authorization', 'Bearer test-token')
93
+ .reply(204);
94
+ await updateAutomationCommand.action(options, undefined);
95
+ expect(scope.isDone()).toBe(true);
96
+ expect(mockConsola.success).toHaveBeenCalledWith('Automation updated successfully.');
97
+ });
74
98
  it('should clear the build stack when an empty string is passed', async () => {
75
99
  const appId = 'app-123';
76
100
  const automationId = 'automation-456';
@@ -1,10 +1,10 @@
1
- import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
1
  import appBuildSourcesService from '../../../services/app-build-sources.js';
3
2
  import appBuildsService from '../../../services/app-builds.js';
4
3
  import appCertificatesService from '../../../services/app-certificates.js';
5
4
  import appConfigurationsService from '../../../services/app-configurations.js';
6
5
  import appEnvironmentsService from '../../../services/app-environments.js';
7
6
  import appsService from '../../../services/apps.js';
7
+ import configService from '../../../services/config.js';
8
8
  import { APP_BUILD_ARTIFACT_FORM_FACTORS, APP_BUILD_ARTIFACT_TYPES_BY_PLATFORM, handleAppBuildArtifactDownload, } from '../../../utils/app-build-artifacts.js';
9
9
  import { getAppBuildShareUrls } from '../../../utils/app-build-shares.js';
10
10
  import { parseKeyValuePairs } from '../../../utils/app-environments.js';
@@ -423,7 +423,8 @@ export default defineCommand({
423
423
  });
424
424
  consola.info(`Build ID: ${response.id}`);
425
425
  consola.info(`Build Number: ${response.numberAsString}`);
426
- consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
426
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
427
+ consola.info(`Build URL: ${consoleBaseUrl}/apps/${appId}/builds/${response.id}`);
427
428
  consola.success('Build created successfully.');
428
429
  if (channels?.length) {
429
430
  consola.info(`The build will be deployed to the following ${channels.length === 1 ? 'channel' : 'channels'} once it succeeds: ${channels.join(', ')}.`);
@@ -446,7 +447,7 @@ export default defineCommand({
446
447
  });
447
448
  consola.info(`Build ID: ${response.id}`);
448
449
  consola.info(`Build Number: ${response.numberAsString}`);
449
- consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
450
+ consola.info(`Build URL: ${consoleBaseUrl}/apps/${appId}/builds/${response.id}`);
450
451
  consola.success('Build completed successfully.');
451
452
  console.log();
452
453
  // Download artifacts if flags are set
@@ -517,7 +518,7 @@ export default defineCommand({
517
518
  else {
518
519
  consola.info(`Build ID: ${response.id}`);
519
520
  consola.info(`Build Number: ${response.numberAsString}`);
520
- consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
521
+ consola.info(`Build URL: ${consoleBaseUrl}/apps/${appId}/builds/${response.id}`);
521
522
  consola.success('Build started successfully.');
522
523
  }
523
524
  }
@@ -1,5 +1,5 @@
1
- import { DEFAULT_CONSOLE_BASE_URL } from '../../config/consts.js';
2
1
  import appsService from '../../services/apps.js';
2
+ import configService from '../../services/config.js';
3
3
  import { withAuth } from '../../utils/auth.js';
4
4
  import { isInteractive } from '../../utils/environment.js';
5
5
  import { prompt, promptOrganizationSelection } from '../../utils/prompt.js';
@@ -39,7 +39,8 @@ export default defineCommand({
39
39
  const response = await appsService.create({ name, organizationId, type });
40
40
  if (!json) {
41
41
  consola.info(`App ID: ${response.id}`);
42
- consola.info(`App URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${response.id}`);
42
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
43
+ consola.info(`App URL: ${consoleBaseUrl}/apps/${response.id}`);
43
44
  consola.success('App created successfully.');
44
45
  }
45
46
  let shouldLink = options.link ?? false;
@@ -1,7 +1,7 @@
1
- import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
1
  import appBuildsService from '../../../services/app-builds.js';
3
2
  import appDeploymentsService from '../../../services/app-deployments.js';
4
3
  import appDestinationsService from '../../../services/app-destinations.js';
4
+ import configService from '../../../services/config.js';
5
5
  import { withAuth } from '../../../utils/auth.js';
6
6
  import { isInteractive } from '../../../utils/environment.js';
7
7
  import { offerJobFailureSummary } from '../../../utils/job-failure-summary.js';
@@ -171,7 +171,8 @@ export default defineCommand({
171
171
  releaseNotes,
172
172
  });
173
173
  consola.info(`Deployment ID: ${response.id}`);
174
- consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.id}`);
174
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
175
+ consola.info(`Deployment URL: ${consoleBaseUrl}/apps/${appId}/deployments/${response.id}`);
175
176
  consola.success('Deployment created successfully.');
176
177
  // Wait for deployment job to complete by default, unless --detached flag is set
177
178
  const shouldWait = !options.detached && build.platform !== 'web';
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_API_BASE_URL } from '../../../config/consts.js';
2
2
  import authorizationService from '../../../services/authorization-service.js';
3
+ import configService from '../../../services/config.js';
3
4
  import userConfig from '../../../utils/user-config.js';
4
5
  import consola from 'consola';
5
6
  import nock from 'nock';
@@ -62,6 +63,22 @@ describe('apps-deployments-create', () => {
62
63
  expect(deploymentScope.isDone()).toBe(true);
63
64
  expect(mockConsola.success).toHaveBeenCalledWith('Deployment created successfully.');
64
65
  });
66
+ it('should print the deployment url using the configured console base url', async () => {
67
+ const consoleBaseUrl = 'https://console.example.com';
68
+ const getValueForKey = configService.getValueForKey.bind(configService);
69
+ vi.spyOn(configService, 'getValueForKey').mockImplementation((key) => key === 'CONSOLE_BASE_URL' ? Promise.resolve(consoleBaseUrl) : getValueForKey(key));
70
+ const options = { appId, buildId, destination: 'Google Play', detached: true };
71
+ nock(DEFAULT_API_BASE_URL)
72
+ .get(`/v1/apps/${appId}/builds/${buildId}`)
73
+ .matchHeader('Authorization', `Bearer ${testToken}`)
74
+ .reply(200, { id: buildId, platform: 'android' });
75
+ nock(DEFAULT_API_BASE_URL)
76
+ .post(`/v1/apps/${appId}/deployments`)
77
+ .matchHeader('Authorization', `Bearer ${testToken}`)
78
+ .reply(201, { id: deploymentId, jobId: 'job-1' });
79
+ await createCommand.action(options, undefined);
80
+ expect(mockConsola.info).toHaveBeenCalledWith(`Deployment URL: ${consoleBaseUrl}/apps/${appId}/deployments/${deploymentId}`);
81
+ });
65
82
  it('should reject translated release notes without a default text', async () => {
66
83
  const options = { appId, buildId, destination: 'Google Play', releaseNotesLocale: ['de-DE=Fehlerbehebungen'] };
67
84
  await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
@@ -1,4 +1,3 @@
1
- import { DEFAULT_CONSOLE_BASE_URL } from '../../config/consts.js';
2
1
  import appAutomationsService from '../../services/app-automations.js';
3
2
  import appCertificatesService from '../../services/app-certificates.js';
4
3
  import appChannelsService from '../../services/app-channels.js';
@@ -8,6 +7,7 @@ import appEnvironmentsService from '../../services/app-environments.js';
8
7
  import appGoogleServiceAccountKeysService from '../../services/app-google-service-account-keys.js';
9
8
  import appProvisioningProfilesService from '../../services/app-provisioning-profiles.js';
10
9
  import appsService from '../../services/apps.js';
10
+ import configService from '../../services/config.js';
11
11
  import gitConnectionsService from '../../services/git-connections.js';
12
12
  import { generateUniqueName, isNameTaken } from '../../utils/app-import.js';
13
13
  import { parseAppflowExport } from '../../utils/appflow-export.js';
@@ -103,12 +103,13 @@ export default defineCommand({
103
103
  selectedSkippedApps.push(...unresolvedApps);
104
104
  await assignUniqueAppNames(resolvedApps, organizationId);
105
105
  const gitConnectionIds = await findGitConnectionIds(resolvedApps, organizationId);
106
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
106
107
  for (const provider of getGitProviders(resolvedApps)) {
107
108
  if (gitConnectionIds.has(provider)) {
108
109
  continue;
109
110
  }
110
111
  const appCount = resolvedApps.filter((app) => app.repository?.provider === provider).length;
111
- consola.warn(`The organization has no \`${provider}\` git connection, so the repositories of ${appCount} app(s) will not be linked. Connect the git provider at ${DEFAULT_CONSOLE_BASE_URL}/organizations/${organizationId}/git before running the import to link them automatically, or link the repositories manually in the Capawesome Cloud Console afterwards.`);
112
+ consola.warn(`The organization has no \`${provider}\` git connection, so the repositories of ${appCount} app(s) will not be linked. Connect the git provider at ${consoleBaseUrl}/organizations/${organizationId}/git before running the import to link them automatically, or link the repositories manually in the Capawesome Cloud Console afterwards.`);
112
113
  }
113
114
  const outcomes = [];
114
115
  for (const app of resolvedApps) {
@@ -132,10 +133,10 @@ export default defineCommand({
132
133
  }
133
134
  const errorCount = outcomes.reduce((count, outcome) => count + outcome.errors.length, 0);
134
135
  if (json) {
135
- printJsonSummary(outcomes, selectedSkippedApps, warnings, dryRun === true);
136
+ printJsonSummary(outcomes, selectedSkippedApps, warnings, dryRun === true, consoleBaseUrl);
136
137
  }
137
138
  else {
138
- printSummary(outcomes, selectedSkippedApps, dryRun === true, file);
139
+ printSummary(outcomes, selectedSkippedApps, dryRun === true, file, consoleBaseUrl);
139
140
  }
140
141
  if (errorCount > 0) {
141
142
  process.exitCode = 1;
@@ -415,7 +416,7 @@ const importApp = async (organizationId, outcome, gitConnectionIds) => {
415
416
  buildType: automation.buildType,
416
417
  enabled: automation.enabled,
417
418
  appCertificateName: automation.appCertificateName,
418
- appChannelName: automation.appChannelName,
419
+ appChannelNames: automation.appChannelNames,
419
420
  appConfigurationName: automation.appConfigurationName,
420
421
  appDestinationName: automation.appDestinationName,
421
422
  appEnvironmentName: automation.appEnvironmentName,
@@ -444,7 +445,7 @@ const importApp = async (organizationId, outcome, gitConnectionIds) => {
444
445
  }
445
446
  }
446
447
  };
447
- const printSummary = (outcomes, skippedApps, dryRun, file) => {
448
+ const printSummary = (outcomes, skippedApps, dryRun, file, consoleBaseUrl) => {
448
449
  consola.log('');
449
450
  if (dryRun) {
450
451
  consola.info('Dry run. No resources were created.');
@@ -477,7 +478,7 @@ const printSummary = (outcomes, skippedApps, dryRun, file) => {
477
478
  }
478
479
  for (const outcome of outcomes) {
479
480
  if (outcome.id) {
480
- consola.info(`App URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${outcome.id}`);
481
+ consola.info(`App URL: ${consoleBaseUrl}/apps/${outcome.id}`);
481
482
  }
482
483
  }
483
484
  const retryLaterApps = skippedApps.filter((app) => app.retryLater);
@@ -498,7 +499,7 @@ const printSummary = (outcomes, skippedApps, dryRun, file) => {
498
499
  const formatCount = (created, total, dryRun) => {
499
500
  return dryRun ? `${total}` : `${created}/${total}`;
500
501
  };
501
- const printJsonSummary = (outcomes, skippedApps, warnings, dryRun) => {
502
+ const printJsonSummary = (outcomes, skippedApps, warnings, dryRun, consoleBaseUrl) => {
502
503
  console.log(JSON.stringify({
503
504
  dryRun,
504
505
  warnings,
@@ -507,7 +508,7 @@ const printJsonSummary = (outcomes, skippedApps, warnings, dryRun) => {
507
508
  name: outcome.app.name,
508
509
  sourceId: outcome.app.sourceId,
509
510
  sourceName: outcome.app.sourceName,
510
- webUrl: outcome.id ? `${DEFAULT_CONSOLE_BASE_URL}/apps/${outcome.id}` : null,
511
+ webUrl: outcome.id ? `${consoleBaseUrl}/apps/${outcome.id}` : null,
511
512
  created: outcome.created,
512
513
  notes: outcome.app.notes,
513
514
  renames: outcome.app.renames,
@@ -101,7 +101,18 @@ describe('apps-import', () => {
101
101
  destinationId: null,
102
102
  },
103
103
  ],
104
- 'web-build-automations.json': [],
104
+ 'web-build-automations.json': [
105
+ {
106
+ name: 'Web Prod',
107
+ gitBranch: 'main',
108
+ platform: 'web-deploy',
109
+ environmentId: null,
110
+ webhook: null,
111
+ automationEnabled: true,
112
+ channelIds: ['channel-uuid'],
113
+ webPreviewEnabled: false,
114
+ },
115
+ ],
105
116
  'signing-certificates/android/Debug-3/android-signing-certificate.json': {
106
117
  id: 3,
107
118
  name: 'Debug',
@@ -152,6 +163,12 @@ describe('apps-import', () => {
152
163
  body.appEnvironmentName === 'Production');
153
164
  })
154
165
  .reply(201, { id: 'automation-1', name: 'Android Release' })
166
+ .post(`/v1/apps/${appId}/automations`, (body) => {
167
+ return (body.name === 'Web Prod' &&
168
+ body.platform === 'web' &&
169
+ JSON.stringify(body.appChannelNames) === JSON.stringify(['Production']));
170
+ })
171
+ .reply(201, { id: 'automation-2', name: 'Web Prod' })
155
172
  .get(`/v1/organizations/${organizationId}/git-connections`)
156
173
  .query({ provider: 'github', restricted: 'false', limit: 1 })
157
174
  .reply(200, [{ id: 'git-connection-1', provider: 'github' }])
@@ -170,7 +187,7 @@ describe('apps-import', () => {
170
187
  name: 'My App',
171
188
  sourceId: '6668c18c',
172
189
  sourceName: 'My App',
173
- created: { automations: 1, certificates: 1, channels: 1, configurations: 1, destinations: 0, environments: 1 },
190
+ created: { automations: 2, certificates: 1, channels: 1, configurations: 1, destinations: 0, environments: 1 },
174
191
  errors: [],
175
192
  });
176
193
  expect(output.apps[0].webUrl).toContain(appId);
@@ -1,5 +1,5 @@
1
- import { DEFAULT_CONSOLE_BASE_URL } from '../../config/consts.js';
2
1
  import appsService from '../../services/apps.js';
2
+ import configService from '../../services/config.js';
3
3
  import gitConnectionsService from '../../services/git-connections.js';
4
4
  import { withAuth } from '../../utils/auth.js';
5
5
  import { isInteractive } from '../../utils/environment.js';
@@ -37,7 +37,8 @@ export default defineCommand({
37
37
  limit: 1,
38
38
  });
39
39
  if (!gitConnection) {
40
- consola.error(`No \`${gitRemoteInfo.provider}\` git connection found in the organization. Connect the git provider first at ${DEFAULT_CONSOLE_BASE_URL}/organizations/${app.organizationId}/git.`);
40
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
41
+ consola.error(`No \`${gitRemoteInfo.provider}\` git connection found in the organization. Connect the git provider first at ${consoleBaseUrl}/organizations/${app.organizationId}/git.`);
41
42
  process.exit(1);
42
43
  }
43
44
  gitConnectionId = gitConnection.id;
@@ -1,9 +1,9 @@
1
- import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
1
  import appBuildSourcesService from '../../../services/app-build-sources.js';
3
2
  import appBuildsService from '../../../services/app-builds.js';
4
3
  import appCertificatesService from '../../../services/app-certificates.js';
5
4
  import appDeploymentsService from '../../../services/app-deployments.js';
6
5
  import appEnvironmentsService from '../../../services/app-environments.js';
6
+ import configService from '../../../services/config.js';
7
7
  import { parseKeyValuePairs } from '../../../utils/app-environments.js';
8
8
  import { withAuth } from '../../../utils/auth.js';
9
9
  import { parseCustomProperties } from '../../../utils/custom-properties.js';
@@ -241,7 +241,8 @@ export default defineCommand({
241
241
  });
242
242
  consola.info(`Build ID: ${response.id}`);
243
243
  consola.info(`Build Number: ${response.numberAsString}`);
244
- consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
244
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
245
+ consola.info(`Build URL: ${consoleBaseUrl}/apps/${appId}/builds/${response.id}`);
245
246
  consola.success('Build created successfully.');
246
247
  // Wait for build to complete
247
248
  await waitForJobCompletion({ jobId: response.jobId });
@@ -284,7 +285,7 @@ export default defineCommand({
284
285
  });
285
286
  appDeploymentIds.push(deployment.id);
286
287
  consola.info(`Deployment ID: ${deployment.id}`);
287
- consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${deployment.id}`);
288
+ consola.info(`Deployment URL: ${consoleBaseUrl}/apps/${appId}/deployments/${deployment.id}`);
288
289
  consola.success('Deployment created successfully.');
289
290
  }
290
291
  // Output JSON if json flag is set
@@ -1,6 +1,6 @@
1
- import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
1
  import appBundlesService from '../../../services/app-bundles.js';
3
2
  import appsService from '../../../services/apps.js';
3
+ import configService from '../../../services/config.js';
4
4
  import { withAuth } from '../../../utils/auth.js';
5
5
  import { parseCustomProperties } from '../../../utils/custom-properties.js';
6
6
  import { createBufferFromPath, createBufferFromString, isPrivateKeyContent } from '../../../utils/buffer.js';
@@ -56,6 +56,18 @@ export default defineCommand({
56
56
  .max(10)
57
57
  .optional()
58
58
  .describe('A custom property to assign to the bundle. Must be in the format `key=value`. Can be specified multiple times.'),
59
+ electronMax: z
60
+ .string()
61
+ .optional()
62
+ .describe('The maximum Electron app version (`app.getVersion()`, from `electron/package.json`) that the bundle supports. Must be in the format `major[.minor[.patch]]`. Prerelease suffixes are not supported.'),
63
+ electronMin: z
64
+ .string()
65
+ .optional()
66
+ .describe('The minimum Electron app version (`app.getVersion()`, from `electron/package.json`) that the bundle supports. Must be in the format `major[.minor[.patch]]`. Prerelease suffixes are not supported.'),
67
+ electronEq: z
68
+ .string()
69
+ .optional()
70
+ .describe('The exact Electron app version (`app.getVersion()`, from `electron/package.json`) that the bundle does not support. Must be in the format `major[.minor[.patch]]`. Prerelease suffixes are not supported.'),
59
71
  expiresInDays: z.coerce
60
72
  .number({
61
73
  message: 'Expiration days must be an integer.',
@@ -104,7 +116,7 @@ export default defineCommand({
104
116
  yes: z.boolean().optional().describe('Skip confirmation prompts.'),
105
117
  }), { y: 'yes' }),
106
118
  action: withAuth(async (options, args) => {
107
- let { androidEq, androidMax, androidMin, appId, channel, commitMessage, commitRef, commitSha, customProperty, expiresInDays, gitRef, iosEq, iosMax, iosMin, json, path, privateKey, rolloutPercentage, url, } = options;
119
+ let { androidEq, androidMax, androidMin, appId, channel, commitMessage, commitRef, commitSha, customProperty, electronEq, electronMax, electronMin, expiresInDays, gitRef, iosEq, iosMax, iosMin, json, path, privateKey, rolloutPercentage, url, } = options;
108
120
  if (expiresInDays) {
109
121
  consola.warn('The `--expires-in-days` option is deprecated and will be removed in a future version. Bundle expiration is now managed by the data retention policy of your organization billing plan.');
110
122
  }
@@ -225,6 +237,7 @@ export default defineCommand({
225
237
  channelName: channel,
226
238
  checksum,
227
239
  eqAndroidAppVersionCode: androidEq,
240
+ eqElectronAppVersionCode: electronEq,
228
241
  eqIosAppVersionCode: iosEq,
229
242
  gitCommitMessage: commitMessage,
230
243
  gitCommitRef: commitRef,
@@ -233,15 +246,18 @@ export default defineCommand({
233
246
  customProperties: parseCustomProperties(customProperty),
234
247
  url,
235
248
  maxAndroidAppVersionCode: androidMax,
249
+ maxElectronAppVersionCode: electronMax,
236
250
  maxIosAppVersionCode: iosMax,
237
251
  minAndroidAppVersionCode: androidMin,
252
+ minElectronAppVersionCode: electronMin,
238
253
  minIosAppVersionCode: iosMin,
239
254
  rolloutPercentage: (rolloutPercentage ?? 100) / 100,
240
255
  signature,
241
256
  });
242
257
  consola.info(`Bundle Artifact ID: ${response.id}`);
243
258
  if (response.appDeploymentId) {
244
- consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.appDeploymentId}`);
259
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
260
+ consola.info(`Deployment URL: ${consoleBaseUrl}/apps/${appId}/deployments/${response.appDeploymentId}`);
245
261
  }
246
262
  consola.success('Live Update successfully registered.');
247
263
  if (json) {
@@ -134,6 +134,45 @@ describe('apps-liveupdates-register', () => {
134
134
  expect(mockConsola.info).toHaveBeenCalledWith(`Bundle Artifact ID: ${bundleId}`);
135
135
  expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully registered.');
136
136
  });
137
+ it('should pass Electron version constraints to API when provided', async () => {
138
+ const appId = 'app-123';
139
+ const bundleUrl = 'https://example.com/bundle.zip';
140
+ const bundleId = 'bundle-456';
141
+ const testToken = 'test-token';
142
+ const electronEq = '1.0.0';
143
+ const electronMin = '2.0.0';
144
+ const electronMax = '3.0.0';
145
+ const options = {
146
+ appId,
147
+ url: bundleUrl,
148
+ rolloutPercentage: 1,
149
+ electronEq,
150
+ electronMin,
151
+ electronMax,
152
+ yes: true,
153
+ };
154
+ const appScope = nock(DEFAULT_API_BASE_URL)
155
+ .get(`/v1/apps/${appId}`)
156
+ .matchHeader('Authorization', `Bearer ${testToken}`)
157
+ .reply(200, { id: appId, name: 'Test App' });
158
+ const bundleScope = nock(DEFAULT_API_BASE_URL)
159
+ .post(`/v1/apps/${appId}/bundles`, {
160
+ appId,
161
+ url: bundleUrl,
162
+ artifactType: 'zip',
163
+ eqElectronAppVersionCode: electronEq,
164
+ minElectronAppVersionCode: electronMin,
165
+ maxElectronAppVersionCode: electronMax,
166
+ rolloutPercentage: 0.01,
167
+ })
168
+ .matchHeader('Authorization', `Bearer ${testToken}`)
169
+ .reply(201, { id: bundleId, appBuildId: 'build-789' });
170
+ await registerCommand.action(options, undefined);
171
+ expect(appScope.isDone()).toBe(true);
172
+ expect(bundleScope.isDone()).toBe(true);
173
+ expect(mockConsola.info).toHaveBeenCalledWith(`Bundle Artifact ID: ${bundleId}`);
174
+ expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully registered.');
175
+ });
137
176
  it('should register bundle with checksum when path is provided', async () => {
138
177
  const appId = 'app-123';
139
178
  const bundleUrl = 'https://example.com/bundle.zip';
@@ -1,6 +1,6 @@
1
- import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
1
  import appChannelsService from '../../../services/app-channels.js';
3
2
  import appDeploymentsService from '../../../services/app-deployments.js';
3
+ import configService from '../../../services/config.js';
4
4
  import { withAuth } from '../../../utils/auth.js';
5
5
  import { isInteractive } from '../../../utils/environment.js';
6
6
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
@@ -130,7 +130,8 @@ export default defineCommand({
130
130
  appChannelName: channel,
131
131
  });
132
132
  consola.info(`Deployment ID: ${response.id}`);
133
- consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.id}`);
133
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
134
+ consola.info(`Deployment URL: ${consoleBaseUrl}/apps/${appId}/deployments/${response.id}`);
134
135
  consola.success(`Rolled back to Build #${selectedAppDeployment.appBuild?.numberAsString} (${selectedIndex} step${selectedIndex === 1 ? '' : 's'} back).`);
135
136
  }),
136
137
  });
@@ -1,6 +1,6 @@
1
- import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
1
  import appChannelsService from '../../../services/app-channels.js';
3
2
  import appDeploymentsService from '../../../services/app-deployments.js';
3
+ import configService from '../../../services/config.js';
4
4
  import { withAuth } from '../../../utils/auth.js';
5
5
  import { isInteractive } from '../../../utils/environment.js';
6
6
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
@@ -106,7 +106,8 @@ export default defineCommand({
106
106
  rolloutPercentage: percentage / 100,
107
107
  });
108
108
  consola.info(`Deployment ID: ${response.id}`);
109
- consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.id}`);
109
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
110
+ consola.info(`Deployment URL: ${consoleBaseUrl}/apps/${appId}/deployments/${response.id}`);
110
111
  consola.success(`Rolled out to ${percentage}%.`);
111
112
  }),
112
113
  });
@@ -1,7 +1,8 @@
1
- import { DEFAULT_CONSOLE_BASE_URL, MAX_CONCURRENT_FILE_UPLOADS } from '../../../config/index.js';
1
+ import { MAX_CONCURRENT_FILE_UPLOADS } from '../../../config/index.js';
2
2
  import appBundleFilesService from '../../../services/app-bundle-files.js';
3
3
  import appBundlesService from '../../../services/app-bundles.js';
4
4
  import appsService from '../../../services/apps.js';
5
+ import configService from '../../../services/config.js';
5
6
  import { withAuth } from '../../../utils/auth.js';
6
7
  import { parseCustomProperties } from '../../../utils/custom-properties.js';
7
8
  import { defineCommand, defineOptions } from 'zodline';
@@ -67,6 +68,18 @@ export default defineCommand({
67
68
  .max(10)
68
69
  .optional()
69
70
  .describe('A custom property to assign to the bundle. Must be in the format `key=value`. Can be specified multiple times.'),
71
+ electronMax: z
72
+ .string()
73
+ .optional()
74
+ .describe('The maximum Electron app version (`app.getVersion()`, from `electron/package.json`) that the bundle supports. Must be in the format `major[.minor[.patch]]`. Prerelease suffixes are not supported.'),
75
+ electronMin: z
76
+ .string()
77
+ .optional()
78
+ .describe('The minimum Electron app version (`app.getVersion()`, from `electron/package.json`) that the bundle supports. Must be in the format `major[.minor[.patch]]`. Prerelease suffixes are not supported.'),
79
+ electronEq: z
80
+ .string()
81
+ .optional()
82
+ .describe('The exact Electron app version (`app.getVersion()`, from `electron/package.json`) that the bundle does not support. Must be in the format `major[.minor[.patch]]`. Prerelease suffixes are not supported.'),
70
83
  expiresInDays: z.coerce
71
84
  .number({
72
85
  message: 'Expiration days must be an integer.',
@@ -117,7 +130,7 @@ export default defineCommand({
117
130
  yes: z.boolean().optional().describe('Skip confirmation prompt.'),
118
131
  }), { y: 'yes' }),
119
132
  action: withAuth(async (options, args) => {
120
- let { androidEq, androidMax, androidMin, appId, artifactType, channel, commitMessage, commitRef, commitSha, customProperty, expiresInDays, gitRef, iosEq, iosMax, iosMin, json, path, privateKey, rolloutPercentage, } = options;
133
+ let { androidEq, androidMax, androidMin, appId, artifactType, channel, commitMessage, commitRef, commitSha, customProperty, electronEq, electronMax, electronMin, expiresInDays, gitRef, iosEq, iosMax, iosMin, json, path, privateKey, rolloutPercentage, } = options;
121
134
  if (expiresInDays) {
122
135
  consola.warn('The `--expires-in-days` option is deprecated and will be removed in a future version. Bundle expiration is now managed by the data retention policy of your organization billing plan.');
123
136
  }
@@ -255,6 +268,7 @@ export default defineCommand({
255
268
  artifactType,
256
269
  channelName: channel,
257
270
  eqAndroidAppVersionCode: androidEq,
271
+ eqElectronAppVersionCode: electronEq,
258
272
  eqIosAppVersionCode: iosEq,
259
273
  gitCommitMessage: commitMessage,
260
274
  gitCommitRef: commitRef,
@@ -262,8 +276,10 @@ export default defineCommand({
262
276
  gitRef,
263
277
  customProperties: parseCustomProperties(customProperty),
264
278
  maxAndroidAppVersionCode: androidMax,
279
+ maxElectronAppVersionCode: electronMax,
265
280
  maxIosAppVersionCode: iosMax,
266
281
  minAndroidAppVersionCode: androidMin,
282
+ minElectronAppVersionCode: electronMin,
267
283
  minIosAppVersionCode: iosMin,
268
284
  // Convert percentage from 0-100 to 0-1 for API
269
285
  rolloutPercentage: (rolloutPercentage ?? 100) / 100,
@@ -287,7 +303,8 @@ export default defineCommand({
287
303
  });
288
304
  consola.info(`Build Artifact ID: ${createBundleResponse.id}`);
289
305
  if (updateBundleResponse.appDeploymentId) {
290
- consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${updateBundleResponse.appDeploymentId}`);
306
+ const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
307
+ consola.info(`Deployment URL: ${consoleBaseUrl}/apps/${appId}/deployments/${updateBundleResponse.appDeploymentId}`);
291
308
  }
292
309
  consola.success('Live Update successfully uploaded.');
293
310
  if (json) {
@@ -211,6 +211,66 @@ describe('apps-liveupdates-upload', () => {
211
211
  expect(mockConsola.info).toHaveBeenCalledWith(`Build Artifact ID: ${bundleId}`);
212
212
  expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully uploaded.');
213
213
  });
214
+ it('should pass Electron version constraints to API when provided', async () => {
215
+ const appId = 'app-123';
216
+ const bundlePath = './dist';
217
+ const bundleId = 'bundle-456';
218
+ const testToken = 'test-token';
219
+ const testBuffer = Buffer.from('test');
220
+ const electronEq = '1.0.0';
221
+ const electronMin = '2.0.0';
222
+ const electronMax = '3.0.0';
223
+ const options = {
224
+ appId,
225
+ path: bundlePath,
226
+ artifactType: 'zip',
227
+ rollout: 1,
228
+ electronEq,
229
+ electronMin,
230
+ electronMax,
231
+ };
232
+ mockIsReadable.mockResolvedValue(true);
233
+ mockIsDirectory.mockResolvedValue(true);
234
+ mockGetFilesInDirectoryAndSubdirectories.mockResolvedValue([
235
+ { href: 'index.html', mimeType: 'text/html', name: 'index.html', path: 'index.html' },
236
+ ]);
237
+ // Mock utility functions
238
+ const mockZip = await import('../../../utils/zip.js');
239
+ const mockHash = await import('../../../utils/hash.js');
240
+ vi.mocked(mockZip.default.isZipped).mockReturnValue(false);
241
+ vi.mocked(mockZip.default.zipFolder).mockResolvedValue(testBuffer);
242
+ vi.mocked(mockHash.createHash).mockResolvedValue('test-hash');
243
+ const appScope = nock(DEFAULT_API_BASE_URL)
244
+ .get(`/v1/apps/${appId}`)
245
+ .matchHeader('Authorization', `Bearer ${testToken}`)
246
+ .reply(200, { id: appId, name: 'Test App' });
247
+ const bundleScope = nock(DEFAULT_API_BASE_URL)
248
+ .post(`/v1/apps/${appId}/bundles`, {
249
+ appId,
250
+ artifactType: 'zip',
251
+ eqElectronAppVersionCode: electronEq,
252
+ minElectronAppVersionCode: electronMin,
253
+ maxElectronAppVersionCode: electronMax,
254
+ rolloutPercentage: 1,
255
+ })
256
+ .matchHeader('Authorization', `Bearer ${testToken}`)
257
+ .reply(201, { id: bundleId, appBuildId: 'build-789' });
258
+ const uploadScope = nock(DEFAULT_API_BASE_URL)
259
+ .post(`/v1/apps/${appId}/bundles/${bundleId}/files`)
260
+ .matchHeader('Authorization', `Bearer ${testToken}`)
261
+ .reply(201, { id: 'file-123' });
262
+ const updateScope = nock(DEFAULT_API_BASE_URL)
263
+ .patch(`/v1/apps/${appId}/bundles/${bundleId}`)
264
+ .matchHeader('Authorization', `Bearer ${testToken}`)
265
+ .reply(200, { id: bundleId });
266
+ await uploadCommand.action(options, undefined);
267
+ expect(appScope.isDone()).toBe(true);
268
+ expect(bundleScope.isDone()).toBe(true);
269
+ expect(uploadScope.isDone()).toBe(true);
270
+ expect(updateScope.isDone()).toBe(true);
271
+ expect(mockConsola.info).toHaveBeenCalledWith(`Build Artifact ID: ${bundleId}`);
272
+ expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully uploaded.');
273
+ });
214
274
  it('should output JSON when json flag is set', async () => {
215
275
  const appId = 'app-123';
216
276
  const bundlePath = './dist';
@@ -372,26 +372,14 @@ const parseAutomations = (appFolder, references, notes) => {
372
372
  .map((channelId) => resolveName(references.channelNamesById, channelId, automation.name, 'channel', notes))
373
373
  .filter((name) => !!name);
374
374
  const appEnvironmentName = resolveName(references.environmentNamesById, automation.environmentId, automation.name, 'environment', notes);
375
- if (channelNames.length === 0) {
376
- automations.push({
377
- name: automation.name,
378
- platform: 'web',
379
- triggerPattern: automation.gitBranch,
380
- enabled: automation.automationEnabled,
381
- appEnvironmentName,
382
- });
383
- continue;
384
- }
385
- for (const channelName of channelNames) {
386
- automations.push({
387
- name: channelNames.length > 1 ? `${automation.name} (${channelName})` : automation.name,
388
- platform: 'web',
389
- triggerPattern: automation.gitBranch,
390
- enabled: automation.automationEnabled,
391
- appChannelName: channelName,
392
- appEnvironmentName,
393
- });
394
- }
375
+ automations.push({
376
+ name: automation.name,
377
+ platform: 'web',
378
+ triggerPattern: automation.gitBranch,
379
+ enabled: automation.automationEnabled,
380
+ appChannelNames: channelNames,
381
+ appEnvironmentName,
382
+ });
395
383
  }
396
384
  return automations;
397
385
  };
@@ -190,19 +190,11 @@ describe('appflow-export', () => {
190
190
  appEnvironmentName: undefined,
191
191
  },
192
192
  {
193
- name: 'Web Prod (Staging)',
193
+ name: 'Web Prod',
194
194
  platform: 'web',
195
195
  triggerPattern: 'main',
196
196
  enabled: true,
197
- appChannelName: 'Staging',
198
- appEnvironmentName: 'Production',
199
- },
200
- {
201
- name: 'Web Prod (Production)',
202
- platform: 'web',
203
- triggerPattern: 'main',
204
- enabled: true,
205
- appChannelName: 'Production',
197
+ appChannelNames: ['Staging', 'Production'],
206
198
  appEnvironmentName: 'Production',
207
199
  },
208
200
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capawesome/cli",
3
- "version": "4.24.0",
3
+ "version": "4.26.0",
4
4
  "description": "The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.",
5
5
  "type": "module",
6
6
  "scripts": {