@capawesome/cli 4.12.0 → 4.14.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +12 -0
  3. package/dist/commands/apps/certificates/create.js +9 -3
  4. package/dist/commands/apps/channels/create.js +9 -3
  5. package/dist/commands/apps/channels/create.test.js +20 -0
  6. package/dist/commands/apps/deployments/create.js +5 -2
  7. package/dist/commands/apps/deployments/get.js +1 -1
  8. package/dist/commands/apps/deployments/list.js +1 -1
  9. package/dist/commands/apps/destinations/create.js +9 -3
  10. package/dist/commands/apps/environments/create.js +9 -3
  11. package/dist/commands/apps/environments/get.js +11 -3
  12. package/dist/commands/apps/environments/list.js +2 -0
  13. package/dist/commands/apps/liveupdates/create.js +8 -5
  14. package/dist/commands/apps/liveupdates/create.test.js +3 -0
  15. package/dist/commands/apps/liveupdates/register.js +8 -1
  16. package/dist/commands/apps/liveupdates/register.test.js +28 -0
  17. package/dist/commands/apps/liveupdates/upload.js +8 -1
  18. package/dist/commands/apps/liveupdates/upload.test.js +47 -0
  19. package/dist/commands/login.js +10 -5
  20. package/dist/commands/login.test.js +35 -12
  21. package/dist/commands/logout.js +5 -1
  22. package/dist/commands/logout.test.js +16 -5
  23. package/dist/commands/organizations/create.js +9 -3
  24. package/dist/commands/organizations/create.test.js +15 -0
  25. package/dist/commands/whoami.js +2 -2
  26. package/dist/commands/whoami.test.js +5 -5
  27. package/dist/index.js +19 -0
  28. package/dist/services/app-environments.js +4 -0
  29. package/dist/services/authorization-service.js +6 -6
  30. package/dist/services/telemetry.js +31 -0
  31. package/dist/services/telemetry.test.js +67 -0
  32. package/dist/utils/credential-store.js +84 -0
  33. package/dist/utils/credential-store.test.js +84 -0
  34. package/dist/utils/job-failure-summary.js +1 -0
  35. package/package.json +6 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
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.14.0](https://github.com/capawesome-team/cli/compare/v4.13.0...v4.14.0) (2026-06-22)
6
+
7
+
8
+ ### Features
9
+
10
+ * hint that failure summary can take up to a minute ([9e75e4f](https://github.com/capawesome-team/cli/commit/9e75e4f211aedae075a9509059a8fd2c7fb5c1f5))
11
+ * store authentication token in OS secure storage ([#175](https://github.com/capawesome-team/cli/issues/175)) ([337213d](https://github.com/capawesome-team/cli/commit/337213d4de900fef6ac52b9c0ae0166cdeded630))
12
+ * **telemetry:** add crash report notice and opt-out ([#173](https://github.com/capawesome-team/cli/issues/173)) ([0027ff2](https://github.com/capawesome-team/cli/commit/0027ff258ef18a293646330fd3d46b29e7b0c324))
13
+ * **telemetry:** attach user ID to crash reports ([#174](https://github.com/capawesome-team/cli/issues/174)) ([eeef34a](https://github.com/capawesome-team/cli/commit/eeef34a1aadbdd2a9b8f013e765155cdb1ea9b44))
14
+
15
+ ## [4.13.0](https://github.com/capawesome-team/cli/compare/v4.12.0...v4.13.0) (2026-06-09)
16
+
17
+
18
+ ### Features
19
+
20
+ * **cli:** add `--json` output to create and register commands ([#171](https://github.com/capawesome-team/cli/issues/171)) ([aa84e81](https://github.com/capawesome-team/cli/commit/aa84e81b9dcf564f9196af3c2b2d72ab7ccd755c))
21
+ * **deployments:** include app build data in `list` and `get` JSON output ([#170](https://github.com/capawesome-team/cli/issues/170)) ([d257dd4](https://github.com/capawesome-team/cli/commit/d257dd4bf87260bd703e7d26fb33509ef71227a5))
22
+ * **environments:** include variable and secret keys in get and list output ([#172](https://github.com/capawesome-team/cli/issues/172)) ([b75098b](https://github.com/capawesome-team/cli/commit/b75098b1f206e4896be1c225b0d04551f37cd35b))
23
+ * **liveupdates:** add `--json` output to `upload` and align JSON keys in `create` ([#169](https://github.com/capawesome-team/cli/issues/169)) ([e671294](https://github.com/capawesome-team/cli/commit/e671294770495aab06792cac354663f70c92a7b9))
24
+
5
25
  ## [4.12.0](https://github.com/capawesome-team/cli/compare/v4.11.0...v4.12.0) (2026-06-08)
6
26
 
7
27
 
package/README.md CHANGED
@@ -32,6 +32,18 @@ The Capawesome Cloud CLI ships with command documentation that is accessible wit
32
32
  npx @capawesome/cli --help
33
33
  ```
34
34
 
35
+ ## Telemetry
36
+
37
+ The Capawesome Cloud CLI sends crash reports to help us identify and fix bugs. No usage analytics are collected.
38
+
39
+ To opt out, set the following environment variable:
40
+
41
+ ```bash
42
+ export CAPAWESOME_TELEMETRY_DISABLED=1
43
+ ```
44
+
45
+ Learn more in the [Telemetry documentation](https://capawesome.io/docs/cloud/cli/telemetry/).
46
+
35
47
  ## Development
36
48
 
37
49
  ### Getting Started
@@ -15,6 +15,7 @@ export default defineCommand({
15
15
  options: defineOptions(z.object({
16
16
  appId: z.string().optional().describe('ID of the app.'),
17
17
  file: z.string().optional().describe('Path to the certificate file.'),
18
+ json: z.boolean().optional().describe('Output in JSON format.'),
18
19
  keyAlias: z.string().optional().describe('Key alias for the certificate.'),
19
20
  keyPassword: z.string().optional().describe('Key password for the certificate.'),
20
21
  name: z.string().optional().describe('Name of the certificate.'),
@@ -34,7 +35,7 @@ export default defineCommand({
34
35
  yes: z.boolean().optional().describe('Skip confirmation prompts.'),
35
36
  }), { y: 'yes' }),
36
37
  action: withAuth(async (options, args) => {
37
- let { appId, file, keyAlias, keyPassword, name, password, platform, provisioningProfile, type } = options;
38
+ let { appId, file, json, keyAlias, keyPassword, name, password, platform, provisioningProfile, type } = options;
38
39
  // 1. Select organization and app
39
40
  if (!appId) {
40
41
  if (!isInteractive()) {
@@ -183,7 +184,12 @@ export default defineCommand({
183
184
  appCertificateId: certificate.id,
184
185
  });
185
186
  }
186
- consola.info(`Certificate ID: ${certificate.id}`);
187
- consola.success('Certificate created successfully.');
187
+ if (json) {
188
+ console.log(JSON.stringify({ id: certificate.id }, null, 2));
189
+ }
190
+ else {
191
+ consola.info(`Certificate ID: ${certificate.id}`);
192
+ consola.success('Certificate created successfully.');
193
+ }
188
194
  }),
189
195
  });
@@ -20,11 +20,12 @@ export default defineCommand({
20
20
  .optional()
21
21
  .describe('The number of days until the channel is automatically deleted.'),
22
22
  ignoreErrors: z.boolean().optional().describe('Whether to ignore errors or not.'),
23
+ json: z.boolean().optional().describe('Output in JSON format.'),
23
24
  name: z.string().optional().describe('Name of the channel.'),
24
25
  protected: z.boolean().optional().describe('Whether to protect the channel or not. Default is `false`.'),
25
26
  })),
26
27
  action: withAuth(async (options, args) => {
27
- let { appId, expiresInDays, ignoreErrors, name, protected: _protected } = options;
28
+ let { appId, expiresInDays, ignoreErrors, json, name, protected: _protected } = options;
28
29
  if (expiresInDays) {
29
30
  consola.warn('The `--expires-in-days` option is deprecated and will be removed in a future version. Channel expiration is now managed by the data retention policy of your organization billing plan.');
30
31
  }
@@ -51,8 +52,13 @@ export default defineCommand({
51
52
  protected: _protected,
52
53
  name,
53
54
  });
54
- consola.info(`Channel ID: ${response.id}`);
55
- consola.success('Channel created successfully.');
55
+ if (json) {
56
+ console.log(JSON.stringify({ id: response.id }, null, 2));
57
+ }
58
+ else {
59
+ consola.info(`Channel ID: ${response.id}`);
60
+ consola.success('Channel created successfully.');
61
+ }
56
62
  }
57
63
  catch (error) {
58
64
  if (ignoreErrors) {
@@ -54,6 +54,26 @@ describe('apps-channels-create', () => {
54
54
  expect(mockConsola.success).toHaveBeenCalledWith('Channel created successfully.');
55
55
  expect(mockConsola.info).toHaveBeenCalledWith(`Channel ID: ${channelId}`);
56
56
  });
57
+ it('should output JSON when json flag is set', async () => {
58
+ const appId = 'app-123';
59
+ const channelName = 'production';
60
+ const channelId = 'channel-456';
61
+ const testToken = 'test-token';
62
+ const options = { appId, json: true, name: channelName };
63
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
64
+ const scope = nock(DEFAULT_API_BASE_URL)
65
+ .post(`/v1/apps/${appId}/channels`, {
66
+ appId,
67
+ name: channelName,
68
+ protected: undefined,
69
+ })
70
+ .matchHeader('Authorization', `Bearer ${testToken}`)
71
+ .reply(201, { id: channelId, name: channelName });
72
+ await createChannelCommand.action(options, undefined);
73
+ expect(scope.isDone()).toBe(true);
74
+ expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ id: channelId }, null, 2));
75
+ expect(mockConsola.info).not.toHaveBeenCalled();
76
+ });
57
77
  it('should prompt for app when not provided', async () => {
58
78
  const channelName = 'staging';
59
79
  const orgId = 'org-1';
@@ -36,9 +36,10 @@ export default defineCommand({
36
36
  .boolean()
37
37
  .optional()
38
38
  .describe('Request an AI-powered failure summary (Capawesome Cloud Assist) if the deployment fails.'),
39
+ json: z.boolean().optional().describe('Output in JSON format.'),
39
40
  })),
40
41
  action: withAuth(async (options) => {
41
- let { appId, buildId, buildNumber, channel, destination } = options;
42
+ let { appId, buildId, buildNumber, channel, destination, json } = options;
42
43
  // Prompt for app ID if not provided
43
44
  if (!appId) {
44
45
  if (!isInteractive()) {
@@ -172,7 +173,9 @@ export default defineCommand({
172
173
  }),
173
174
  });
174
175
  consola.success('Deployment completed successfully.');
175
- process.exit(0);
176
+ }
177
+ if (json) {
178
+ console.log(JSON.stringify({ id: response.id }, null, 2));
176
179
  }
177
180
  }),
178
181
  });
@@ -23,7 +23,7 @@ export default defineCommand({
23
23
  const deployment = await appDeploymentsService.findOne({
24
24
  appId,
25
25
  appDeploymentId: deploymentId,
26
- relations: json ? 'job' : undefined,
26
+ relations: json ? 'appBuild,job' : undefined,
27
27
  });
28
28
  if (json) {
29
29
  console.log(JSON.stringify(deployment, null, 2));
@@ -33,7 +33,7 @@ export default defineCommand({
33
33
  appDestinationId: destinationId,
34
34
  limit,
35
35
  offset,
36
- relations: json ? 'job' : undefined,
36
+ relations: json ? 'appBuild,job' : undefined,
37
37
  });
38
38
  if (json) {
39
39
  console.log(JSON.stringify(foundDeployments, null, 2));
@@ -15,6 +15,7 @@ export default defineCommand({
15
15
  description: 'Create a new app destination.',
16
16
  options: defineOptions(z.object({
17
17
  appId: z.string().optional().describe('ID of the app.'),
18
+ json: z.boolean().optional().describe('Output in JSON format.'),
18
19
  name: z.string().optional().describe('Name of the destination.'),
19
20
  platform: z.enum(['android', 'ios']).optional().describe('Platform of the destination (android, ios).'),
20
21
  appleId: z.string().optional().describe('Apple ID for the destination.'),
@@ -33,7 +34,7 @@ export default defineCommand({
33
34
  googlePlayTrack: z.string().optional().describe('Google Play track for the destination.'),
34
35
  })),
35
36
  action: withAuth(async (options, args) => {
36
- let { appId, name, platform, appleId, appleAppId, appleTeamId, appleAppPassword, appleApiKeyFile, appleIssuerId, androidPackageName, androidBuildArtifactType, androidReleaseStatus, googleServiceAccountKeyFile, googlePlayTrack, } = options;
37
+ let { appId, json, name, platform, appleId, appleAppId, appleTeamId, appleAppPassword, appleApiKeyFile, appleIssuerId, androidPackageName, androidBuildArtifactType, androidReleaseStatus, googleServiceAccountKeyFile, googlePlayTrack, } = options;
37
38
  let appleApiKeyId;
38
39
  let appAppleApiKeyId;
39
40
  let appGoogleServiceAccountKeyId;
@@ -325,7 +326,12 @@ export default defineCommand({
325
326
  appGoogleServiceAccountKeyId,
326
327
  googlePlayTrack,
327
328
  });
328
- consola.info(`Destination ID: ${response.id}`);
329
- consola.success('Destination created successfully.');
329
+ if (json) {
330
+ console.log(JSON.stringify({ id: response.id }, null, 2));
331
+ }
332
+ else {
333
+ consola.info(`Destination ID: ${response.id}`);
334
+ consola.success('Destination created successfully.');
335
+ }
330
336
  }),
331
337
  });
@@ -9,10 +9,11 @@ export default defineCommand({
9
9
  description: 'Create a new environment.',
10
10
  options: defineOptions(z.object({
11
11
  appId: z.string().optional().describe('ID of the app.'),
12
+ json: z.boolean().optional().describe('Output in JSON format.'),
12
13
  name: z.string().optional().describe('Name of the environment.'),
13
14
  })),
14
15
  action: withAuth(async (options, args) => {
15
- let { appId, name } = options;
16
+ let { appId, json, name } = options;
16
17
  if (!appId) {
17
18
  if (!isInteractive()) {
18
19
  consola.error('You must provide an app ID when running in non-interactive environment.');
@@ -32,7 +33,12 @@ export default defineCommand({
32
33
  appId,
33
34
  name,
34
35
  });
35
- consola.info(`Environment ID: ${response.id}`);
36
- consola.success('Environment created successfully.');
36
+ if (json) {
37
+ console.log(JSON.stringify({ id: response.id }, null, 2));
38
+ }
39
+ else {
40
+ consola.info(`Environment ID: ${response.id}`);
41
+ consola.success('Environment created successfully.');
42
+ }
37
43
  }),
38
44
  });
@@ -39,12 +39,13 @@ export default defineCommand({
39
39
  options: environments.map((env) => ({ label: env.name, value: env.id })),
40
40
  });
41
41
  }
42
+ const relations = 'appEnvironmentVariables,appEnvironmentSecrets';
42
43
  let environment;
43
44
  if (environmentId) {
44
- environment = await appEnvironmentsService.findOneById({ appId, id: environmentId });
45
+ environment = await appEnvironmentsService.findOneById({ appId, id: environmentId, relations });
45
46
  }
46
47
  else if (name) {
47
- const environments = await appEnvironmentsService.findAll({ appId, name });
48
+ const environments = await appEnvironmentsService.findAll({ appId, name, relations });
48
49
  environment = environments[0];
49
50
  }
50
51
  if (!environment) {
@@ -55,7 +56,14 @@ export default defineCommand({
55
56
  console.log(JSON.stringify(environment, null, 2));
56
57
  }
57
58
  else {
58
- console.table(environment);
59
+ const { appEnvironmentVariables, appEnvironmentSecrets, ...rest } = environment;
60
+ console.table(rest);
61
+ if (appEnvironmentVariables?.length) {
62
+ console.table(appEnvironmentVariables.map(({ id, key, value }) => ({ id, key, value })));
63
+ }
64
+ if (appEnvironmentSecrets?.length) {
65
+ console.table(appEnvironmentSecrets.map(({ id, key }) => ({ id, key })));
66
+ }
59
67
  consola.success('Environment retrieved successfully.');
60
68
  }
61
69
  }),
@@ -25,6 +25,7 @@ export default defineCommand({
25
25
  }
26
26
  const environments = await appEnvironmentsService.findAll({
27
27
  appId,
28
+ relations: json ? 'appEnvironmentVariables,appEnvironmentSecrets' : undefined,
28
29
  limit,
29
30
  offset,
30
31
  });
@@ -33,6 +34,7 @@ export default defineCommand({
33
34
  }
34
35
  else {
35
36
  console.table(environments);
37
+ consola.info('Run with --json to include variable keys/values and secret keys.');
36
38
  consola.success('Environments retrieved successfully.');
37
39
  }
38
40
  }),
@@ -273,7 +273,7 @@ export default defineCommand({
273
273
  }
274
274
  // Deploy to channels
275
275
  const rolloutPercentage = (options.rolloutPercentage ?? 100) / 100;
276
- const deploymentIds = [];
276
+ const appDeploymentIds = [];
277
277
  for (const channelName of channel) {
278
278
  consola.start(`Creating deployment for channel "${channelName}"...`);
279
279
  const deployment = await appDeploymentsService.create({
@@ -282,7 +282,7 @@ export default defineCommand({
282
282
  appChannelName: channelName,
283
283
  rolloutPercentage,
284
284
  });
285
- deploymentIds.push(deployment.id);
285
+ appDeploymentIds.push(deployment.id);
286
286
  consola.info(`Deployment ID: ${deployment.id}`);
287
287
  consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${deployment.id}`);
288
288
  consola.success('Deployment created successfully.');
@@ -290,9 +290,12 @@ export default defineCommand({
290
290
  // Output JSON if json flag is set
291
291
  if (json) {
292
292
  console.log(JSON.stringify({
293
- buildId: response.id,
294
- buildNumberAsString: response.numberAsString,
295
- deploymentIds,
293
+ appBuildId: response.id,
294
+ appBuildNumberAsString: response.numberAsString,
295
+ appDeploymentIds,
296
+ buildId: response.id, // Deprecated, use appBuildId instead
297
+ buildNumberAsString: response.numberAsString, // Deprecated, use appBuildNumberAsString instead
298
+ deploymentIds: appDeploymentIds, // Deprecated, use appDeploymentIds instead
296
299
  }, null, 2));
297
300
  }
298
301
  }),
@@ -244,6 +244,9 @@ describe('apps-liveupdates-create', () => {
244
244
  .reply(201, { id: deploymentId });
245
245
  await createCommand.action(options, undefined);
246
246
  expect(console.log).toHaveBeenCalledWith(JSON.stringify({
247
+ appBuildId: buildId,
248
+ appBuildNumberAsString: '42',
249
+ appDeploymentIds: [deploymentId],
247
250
  buildId,
248
251
  buildNumberAsString: '42',
249
252
  deploymentIds: [deploymentId],
@@ -81,6 +81,7 @@ export default defineCommand({
81
81
  .string()
82
82
  .optional()
83
83
  .describe('The exact iOS bundle version (`CFBundleVersion`) that the bundle does not support.'),
84
+ json: z.boolean().optional().describe('Output in JSON format.'),
84
85
  path: z.string().optional().describe('Path to zip file for code signing only.'),
85
86
  privateKey: z
86
87
  .string()
@@ -103,7 +104,7 @@ export default defineCommand({
103
104
  yes: z.boolean().optional().describe('Skip confirmation prompts.'),
104
105
  }), { y: 'yes' }),
105
106
  action: withAuth(async (options, args) => {
106
- let { androidEq, androidMax, androidMin, appId, channel, commitMessage, commitRef, commitSha, customProperty, expiresInDays, gitRef, iosEq, iosMax, iosMin, path, privateKey, rolloutPercentage, url, } = options;
107
+ let { androidEq, androidMax, androidMin, appId, channel, commitMessage, commitRef, commitSha, customProperty, expiresInDays, gitRef, iosEq, iosMax, iosMin, json, path, privateKey, rolloutPercentage, url, } = options;
107
108
  if (expiresInDays) {
108
109
  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.');
109
110
  }
@@ -243,5 +244,11 @@ export default defineCommand({
243
244
  consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.appDeploymentId}`);
244
245
  }
245
246
  consola.success('Live Update successfully registered.');
247
+ if (json) {
248
+ console.log(JSON.stringify({
249
+ appBuildId: response.appBuildId,
250
+ appBuildArtifactId: response.id,
251
+ }, null, 2));
252
+ }
246
253
  }),
247
254
  });
@@ -73,6 +73,34 @@ describe('apps-liveupdates-register', () => {
73
73
  expect(mockConsola.info).toHaveBeenCalledWith(`Bundle Artifact ID: ${bundleId}`);
74
74
  expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully registered.');
75
75
  });
76
+ it('should output JSON when json flag is set', async () => {
77
+ const appId = 'app-123';
78
+ const bundleUrl = 'https://example.com/bundle.zip';
79
+ const bundleId = 'bundle-456';
80
+ const appBuildId = 'build-789';
81
+ const testToken = 'test-token';
82
+ const options = {
83
+ appId,
84
+ url: bundleUrl,
85
+ rolloutPercentage: 1,
86
+ json: true,
87
+ yes: true,
88
+ };
89
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
90
+ nock(DEFAULT_API_BASE_URL)
91
+ .get(`/v1/apps/${appId}`)
92
+ .matchHeader('Authorization', `Bearer ${testToken}`)
93
+ .reply(200, { id: appId, name: 'Test App' });
94
+ nock(DEFAULT_API_BASE_URL)
95
+ .post(`/v1/apps/${appId}/bundles`)
96
+ .matchHeader('Authorization', `Bearer ${testToken}`)
97
+ .reply(201, { id: bundleId, appBuildId });
98
+ await registerCommand.action(options, undefined);
99
+ expect(logSpy).toHaveBeenCalledWith(JSON.stringify({
100
+ appBuildId,
101
+ appBuildArtifactId: bundleId,
102
+ }, null, 2));
103
+ });
76
104
  it('should pass gitRef to API when provided', async () => {
77
105
  const appId = 'app-123';
78
106
  const bundleUrl = 'https://example.com/bundle.zip';
@@ -92,6 +92,7 @@ export default defineCommand({
92
92
  .string()
93
93
  .optional()
94
94
  .describe('The exact iOS bundle version (`CFBundleVersion`) that the bundle does not support.'),
95
+ json: z.boolean().optional().describe('Output in JSON format.'),
95
96
  path: z
96
97
  .string()
97
98
  .optional()
@@ -116,7 +117,7 @@ export default defineCommand({
116
117
  yes: z.boolean().optional().describe('Skip confirmation prompt.'),
117
118
  }), { y: 'yes' }),
118
119
  action: withAuth(async (options, args) => {
119
- let { androidEq, androidMax, androidMin, appId, artifactType, channel, commitMessage, commitRef, commitSha, customProperty, expiresInDays, gitRef, iosEq, iosMax, iosMin, path, privateKey, rolloutPercentage, } = options;
120
+ let { androidEq, androidMax, androidMin, appId, artifactType, channel, commitMessage, commitRef, commitSha, customProperty, expiresInDays, gitRef, iosEq, iosMax, iosMin, json, path, privateKey, rolloutPercentage, } = options;
120
121
  if (expiresInDays) {
121
122
  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.');
122
123
  }
@@ -289,6 +290,12 @@ export default defineCommand({
289
290
  consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${updateBundleResponse.appDeploymentId}`);
290
291
  }
291
292
  consola.success('Live Update successfully uploaded.');
293
+ if (json) {
294
+ console.log(JSON.stringify({
295
+ appBuildId: createBundleResponse.appBuildId,
296
+ appBuildArtifactId: createBundleResponse.id,
297
+ }, null, 2));
298
+ }
292
299
  }),
293
300
  });
294
301
  const uploadFile = async (options) => {
@@ -180,6 +180,53 @@ describe('apps-liveupdates-upload', () => {
180
180
  expect(mockConsola.info).toHaveBeenCalledWith(`Build Artifact ID: ${bundleId}`);
181
181
  expect(mockConsola.success).toHaveBeenCalledWith('Live Update successfully uploaded.');
182
182
  });
183
+ it('should output JSON when json flag is set', async () => {
184
+ const appId = 'app-123';
185
+ const bundlePath = './dist';
186
+ const bundleId = 'bundle-456';
187
+ const appBuildId = 'build-789';
188
+ const testToken = 'test-token';
189
+ const testBuffer = Buffer.from('test');
190
+ const options = {
191
+ appId,
192
+ path: bundlePath,
193
+ artifactType: 'zip',
194
+ rollout: 1,
195
+ json: true,
196
+ };
197
+ mockIsReadable.mockResolvedValue(true);
198
+ mockIsDirectory.mockResolvedValue(true);
199
+ mockGetFilesInDirectoryAndSubdirectories.mockResolvedValue([
200
+ { href: 'index.html', mimeType: 'text/html', name: 'index.html', path: 'index.html' },
201
+ ]);
202
+ const mockZip = await import('../../../utils/zip.js');
203
+ const mockHash = await import('../../../utils/hash.js');
204
+ vi.mocked(mockZip.default.isZipped).mockReturnValue(false);
205
+ vi.mocked(mockZip.default.zipFolder).mockResolvedValue(testBuffer);
206
+ vi.mocked(mockHash.createHash).mockResolvedValue('test-hash');
207
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
208
+ nock(DEFAULT_API_BASE_URL)
209
+ .get(`/v1/apps/${appId}`)
210
+ .matchHeader('Authorization', `Bearer ${testToken}`)
211
+ .reply(200, { id: appId, name: 'Test App' });
212
+ nock(DEFAULT_API_BASE_URL)
213
+ .post(`/v1/apps/${appId}/bundles`)
214
+ .matchHeader('Authorization', `Bearer ${testToken}`)
215
+ .reply(201, { id: bundleId, appBuildId });
216
+ nock(DEFAULT_API_BASE_URL)
217
+ .post(`/v1/apps/${appId}/bundles/${bundleId}/files`)
218
+ .matchHeader('Authorization', `Bearer ${testToken}`)
219
+ .reply(201, { id: 'file-123' });
220
+ nock(DEFAULT_API_BASE_URL)
221
+ .patch(`/v1/apps/${appId}/bundles/${bundleId}`)
222
+ .matchHeader('Authorization', `Bearer ${testToken}`)
223
+ .reply(200, { id: bundleId });
224
+ await uploadCommand.action(options, undefined);
225
+ expect(logSpy).toHaveBeenCalledWith(JSON.stringify({
226
+ appBuildId,
227
+ appBuildArtifactId: bundleId,
228
+ }, null, 2));
229
+ });
183
230
  it('should handle private key file path', async () => {
184
231
  const appId = 'app-123';
185
232
  const bundlePath = './dist';
@@ -4,6 +4,7 @@ import sessionsService from '../services/sessions.js';
4
4
  import usersService from '../services/users.js';
5
5
  import { isInteractive } from '../utils/environment.js';
6
6
  import { prompt } from '../utils/prompt.js';
7
+ import credentialStore from '../utils/credential-store.js';
7
8
  import userConfig from '../utils/user-config.js';
8
9
  import { defineCommand, defineOptions } from '@robingenz/zli';
9
10
  import { AxiosError } from 'axios';
@@ -81,15 +82,19 @@ export default defineCommand({
81
82
  }
82
83
  // Sign in with the provided token
83
84
  consola.start('Signing in...');
84
- userConfig.write({
85
- token: sessionIdOrToken,
86
- });
85
+ // Drop the previous user ID but keep other flags,
86
+ // so a crash during sign-in isn't attributed to the previous account
87
+ const { token: _previousToken, userId: _previousUserId, ...persistentConfig } = userConfig.read();
88
+ userConfig.write(persistentConfig);
89
+ credentialStore.setToken(sessionIdOrToken);
87
90
  try {
88
- await usersService.me();
91
+ const user = await usersService.me();
92
+ userConfig.write({ ...persistentConfig, userId: user.id });
89
93
  consola.success(`Successfully signed in.`);
90
94
  }
91
95
  catch (error) {
92
- userConfig.write({});
96
+ // Clear the credentials on failure while preserving the other flags
97
+ credentialStore.deleteToken();
93
98
  if (error instanceof AxiosError && error.response?.status === 401) {
94
99
  consola.error(`Invalid token. Please provide a valid token. You can create a token at ${consoleBaseUrl}/settings/tokens.`);
95
100
  process.exit(1);
@@ -2,6 +2,7 @@ import { DEFAULT_API_BASE_URL, DEFAULT_CONSOLE_BASE_URL } from '../config/consts
2
2
  import configService from '../services/config.js';
3
3
  import sessionCodesService from '../services/session-code.js';
4
4
  import sessionsService from '../services/sessions.js';
5
+ import credentialStore from '../utils/credential-store.js';
5
6
  import { prompt } from '../utils/prompt.js';
6
7
  import userConfig from '../utils/user-config.js';
7
8
  import consola from 'consola';
@@ -11,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
12
  import loginCommand from './login.js';
12
13
  // Mock dependencies
13
14
  vi.mock('@/utils/user-config.js');
15
+ vi.mock('@/utils/credential-store.js');
14
16
  vi.mock('@/services/session-code.js');
15
17
  vi.mock('@/services/sessions.js');
16
18
  vi.mock('@/services/config.js');
@@ -20,11 +22,9 @@ vi.mock('@/utils/prompt.js');
20
22
  vi.mock('@/utils/environment.js', () => ({
21
23
  isInteractive: () => true,
22
24
  }));
23
- vi.mock('@/utils/environment.js', () => ({
24
- isInteractive: () => true,
25
- }));
26
25
  describe('login', () => {
27
26
  const mockUserConfig = vi.mocked(userConfig);
27
+ const mockCredentialStore = vi.mocked(credentialStore);
28
28
  const mockSessionCodesService = vi.mocked(sessionCodesService);
29
29
  const mockSessionsService = vi.mocked(sessionsService);
30
30
  const mockConfigService = vi.mocked(configService);
@@ -35,6 +35,9 @@ describe('login', () => {
35
35
  vi.clearAllMocks();
36
36
  mockUserConfig.write.mockImplementation(() => { });
37
37
  mockUserConfig.read.mockReturnValue({});
38
+ mockCredentialStore.setToken.mockImplementation(() => { });
39
+ mockCredentialStore.deleteToken.mockImplementation(() => { });
40
+ mockCredentialStore.getToken.mockReturnValue(null);
38
41
  // Mock config service to return consistent URLs
39
42
  mockConfigService.getValueForKey.mockImplementation((key) => {
40
43
  if (key === 'CONSOLE_BASE_URL')
@@ -54,18 +57,38 @@ describe('login', () => {
54
57
  it('should use the provided token for authentication', async () => {
55
58
  const testToken = 'valid-token-123';
56
59
  const options = { token: testToken };
57
- // Mock userConfig.read to return our test token after it's written
58
- mockUserConfig.read.mockReturnValue({ token: testToken });
60
+ // Mock credentialStore.getToken to return our test token after it's written
61
+ mockCredentialStore.getToken.mockReturnValue(testToken);
59
62
  // Set up nock to intercept the /v1/users/me request
60
63
  const scope = nock(DEFAULT_API_BASE_URL)
61
64
  .get('/v1/users/me')
62
65
  .matchHeader('Authorization', `Bearer ${testToken}`)
63
66
  .reply(200, { id: 'user-123', email: 'test@example.com' });
64
67
  await loginCommand.action(options, undefined);
65
- expect(mockUserConfig.write).toHaveBeenCalledWith({ token: testToken });
68
+ expect(mockCredentialStore.setToken).toHaveBeenCalledWith(testToken);
66
69
  expect(scope.isDone()).toBe(true);
67
70
  expect(mockConsola.success).toHaveBeenCalledWith('Successfully signed in.');
68
71
  });
72
+ it('should preserve other config flags and replace the previous user ID', async () => {
73
+ const testToken = 'valid-token-123';
74
+ const options = { token: testToken };
75
+ mockCredentialStore.getToken.mockReturnValue(testToken);
76
+ mockUserConfig.read.mockReturnValue({
77
+ token: 'previous-token',
78
+ userId: 'previous-user',
79
+ telemetryNoticeShown: true,
80
+ });
81
+ const scope = nock(DEFAULT_API_BASE_URL)
82
+ .get('/v1/users/me')
83
+ .matchHeader('Authorization', `Bearer ${testToken}`)
84
+ .reply(200, { id: 'user-123', email: 'test@example.com' });
85
+ await loginCommand.action(options, undefined);
86
+ // The previous user ID is dropped before the new account is confirmed.
87
+ expect(mockUserConfig.write).toHaveBeenNthCalledWith(1, { telemetryNoticeShown: true });
88
+ // The new user ID is stored while other flags are preserved.
89
+ expect(mockUserConfig.write).toHaveBeenNthCalledWith(2, { telemetryNoticeShown: true, userId: 'user-123' });
90
+ expect(scope.isDone()).toBe(true);
91
+ });
69
92
  it('should open the browser', async () => {
70
93
  const options = {};
71
94
  mockPrompt
@@ -76,8 +99,8 @@ describe('login', () => {
76
99
  code: 'ABCD1234',
77
100
  });
78
101
  mockSessionsService.create.mockResolvedValue({ id: 'session-123' });
79
- // Mock userConfig.read to return the session token
80
- mockUserConfig.read.mockReturnValue({ token: 'session-123' });
102
+ // Mock credentialStore.getToken to return the session token
103
+ mockCredentialStore.getToken.mockReturnValue('session-123');
81
104
  // Set up nock to intercept the /v1/users/me request
82
105
  const scope = nock(DEFAULT_API_BASE_URL)
83
106
  .get('/v1/users/me')
@@ -106,16 +129,16 @@ describe('login', () => {
106
129
  it('should throw an error because the provided token is invalid', async () => {
107
130
  const invalidToken = 'invalid-token';
108
131
  const options = { token: invalidToken };
109
- // Mock userConfig.read to return our invalid token after it's written
110
- mockUserConfig.read.mockReturnValue({ token: invalidToken });
132
+ // Mock credentialStore.getToken to return our invalid token after it's written
133
+ mockCredentialStore.getToken.mockReturnValue(invalidToken);
111
134
  // Set up nock to intercept the /v1/users/me request and return 401
112
135
  const scope = nock(DEFAULT_API_BASE_URL)
113
136
  .get('/v1/users/me')
114
137
  .matchHeader('Authorization', `Bearer ${invalidToken}`)
115
138
  .reply(401, { message: 'Unauthorized' });
116
139
  await expect(loginCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
117
- expect(mockUserConfig.write).toHaveBeenCalledWith({ token: invalidToken });
118
- expect(mockUserConfig.write).toHaveBeenCalledWith({}); // Clears token on error
140
+ expect(mockCredentialStore.setToken).toHaveBeenCalledWith(invalidToken);
141
+ expect(mockCredentialStore.deleteToken).toHaveBeenCalled(); // Clears token on error
119
142
  expect(scope.isDone()).toBe(true);
120
143
  expect(mockConsola.error).toHaveBeenCalledWith(`Invalid token. Please provide a valid token. You can create a token at ${DEFAULT_CONSOLE_BASE_URL}/settings/tokens.`);
121
144
  });
@@ -2,6 +2,7 @@ import { defineCommand } from '@robingenz/zli';
2
2
  import consola from 'consola';
3
3
  import authorizationService from '../services/authorization-service.js';
4
4
  import sessionsService from '../services/sessions.js';
5
+ import credentialStore from '../utils/credential-store.js';
5
6
  import userConfig from '../utils/user-config.js';
6
7
  export default defineCommand({
7
8
  description: 'Sign out from the Capawesome Cloud Console.',
@@ -10,7 +11,10 @@ export default defineCommand({
10
11
  if (token && !token.startsWith('ca_')) {
11
12
  await sessionsService.delete({ id: token }).catch(() => { });
12
13
  }
13
- userConfig.write({});
14
+ credentialStore.deleteToken();
15
+ // Clear the user ID but keep other flags (e.g. the telemetry notice).
16
+ const { token: _token, userId: _userId, ...persistentConfig } = userConfig.read();
17
+ userConfig.write(persistentConfig);
14
18
  consola.success('Successfully signed out.');
15
19
  },
16
20
  });
@@ -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 credentialStore from '../utils/credential-store.js';
3
4
  import userConfig from '../utils/user-config.js';
4
5
  import consola from 'consola';
5
6
  import nock from 'nock';
@@ -7,41 +8,51 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
8
  import logoutCommand from './logout.js';
8
9
  // Mock dependencies
9
10
  vi.mock('@/services/authorization-service.js');
11
+ vi.mock('@/utils/credential-store.js');
10
12
  vi.mock('@/utils/user-config.js');
11
13
  vi.mock('consola');
12
14
  describe('logout', () => {
13
15
  const mockAuthorizationService = vi.mocked(authorizationService);
16
+ const mockCredentialStore = vi.mocked(credentialStore);
14
17
  const mockUserConfig = vi.mocked(userConfig);
15
18
  const mockConsola = vi.mocked(consola);
16
19
  beforeEach(() => {
17
20
  vi.clearAllMocks();
21
+ mockCredentialStore.deleteToken.mockImplementation(() => { });
18
22
  mockUserConfig.write.mockImplementation(() => { });
23
+ mockUserConfig.read.mockReturnValue({});
19
24
  });
20
25
  afterEach(() => {
21
26
  nock.cleanAll();
22
27
  vi.restoreAllMocks();
23
28
  });
24
- it('should delete session and clear user config with session token', async () => {
29
+ it('should delete session and clear credentials with session token', async () => {
25
30
  const sessionToken = 'session-123';
26
31
  mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(sessionToken);
27
32
  // Set up nock to intercept the DELETE request
28
33
  const scope = nock(DEFAULT_API_BASE_URL).delete('/v1/sessions/session-123').reply(200);
29
34
  await logoutCommand.action({}, undefined);
30
35
  expect(scope.isDone()).toBe(true);
31
- expect(mockUserConfig.write).toHaveBeenCalledWith({});
36
+ expect(mockCredentialStore.deleteToken).toHaveBeenCalled();
32
37
  expect(mockConsola.success).toHaveBeenCalledWith('Successfully signed out.');
33
38
  });
34
- it('should only clear user config with API token', async () => {
39
+ it('should only clear credentials with API token', async () => {
35
40
  const apiToken = 'ca_abc123';
36
41
  mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(apiToken);
37
42
  await logoutCommand.action({}, undefined);
38
- expect(mockUserConfig.write).toHaveBeenCalledWith({});
43
+ expect(mockCredentialStore.deleteToken).toHaveBeenCalled();
39
44
  expect(mockConsola.success).toHaveBeenCalledWith('Successfully signed out.');
40
45
  });
46
+ it('should clear the user ID but preserve other flags', async () => {
47
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('ca_abc123');
48
+ mockUserConfig.read.mockReturnValue({ token: 'ca_abc123', userId: 'user-1', telemetryNoticeShown: true });
49
+ await logoutCommand.action({}, undefined);
50
+ expect(mockUserConfig.write).toHaveBeenCalledWith({ telemetryNoticeShown: true });
51
+ });
41
52
  it('should handle no token gracefully', async () => {
42
53
  mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(null);
43
54
  await logoutCommand.action({}, undefined);
44
- expect(mockUserConfig.write).toHaveBeenCalledWith({});
55
+ expect(mockCredentialStore.deleteToken).toHaveBeenCalled();
45
56
  expect(mockConsola.success).toHaveBeenCalledWith('Successfully signed out.');
46
57
  });
47
58
  });
@@ -8,10 +8,11 @@ import { z } from 'zod';
8
8
  export default defineCommand({
9
9
  description: 'Create a new organization.',
10
10
  options: defineOptions(z.object({
11
+ json: z.boolean().optional().describe('Output in JSON format.'),
11
12
  name: z.string().optional().describe('Name of the organization.'),
12
13
  })),
13
14
  action: withAuth(async (options, args) => {
14
- let { name } = options;
15
+ let { json, name } = options;
15
16
  if (!name) {
16
17
  if (!isInteractive()) {
17
18
  consola.error('You must provide the organization name when running in non-interactive environment.');
@@ -20,7 +21,12 @@ export default defineCommand({
20
21
  name = await prompt('Enter the name of the organization:', { type: 'text' });
21
22
  }
22
23
  const response = await organizationsService.create({ name });
23
- consola.info(`Organization ID: ${response.id}`);
24
- consola.success('Organization created successfully.');
24
+ if (json) {
25
+ console.log(JSON.stringify({ id: response.id }, null, 2));
26
+ }
27
+ else {
28
+ consola.info(`Organization ID: ${response.id}`);
29
+ consola.success('Organization created successfully.');
30
+ }
25
31
  }),
26
32
  });
@@ -46,6 +46,21 @@ describe('organizations-create', () => {
46
46
  expect(mockConsola.success).toHaveBeenCalledWith('Organization created successfully.');
47
47
  expect(mockConsola.info).toHaveBeenCalledWith(`Organization ID: ${organizationId}`);
48
48
  });
49
+ it('should output JSON when json flag is set', async () => {
50
+ const organizationName = 'Test Organization';
51
+ const organizationId = 'org-456';
52
+ const testToken = 'test-token';
53
+ const options = { json: true, name: organizationName };
54
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
55
+ const scope = nock(DEFAULT_API_BASE_URL)
56
+ .post('/v1/organizations', { name: organizationName })
57
+ .matchHeader('Authorization', `Bearer ${testToken}`)
58
+ .reply(201, { id: organizationId, name: organizationName });
59
+ await createOrganizationCommand.action(options, undefined);
60
+ expect(scope.isDone()).toBe(true);
61
+ expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ id: organizationId }, null, 2));
62
+ expect(mockConsola.info).not.toHaveBeenCalled();
63
+ });
49
64
  it('should prompt for organization name when not provided', async () => {
50
65
  const promptedOrganizationName = 'Prompted Organization';
51
66
  const organizationId = 'org-456';
@@ -1,12 +1,12 @@
1
+ import authorizationService from '../services/authorization-service.js';
1
2
  import usersService from '../services/users.js';
2
- import userConfig from '../utils/user-config.js';
3
3
  import { defineCommand } from '@robingenz/zli';
4
4
  import { AxiosError } from 'axios';
5
5
  import consola from 'consola';
6
6
  export default defineCommand({
7
7
  description: 'Show current user',
8
8
  action: async (options, args) => {
9
- const { token } = userConfig.read();
9
+ const token = authorizationService.getCurrentAuthorizationToken();
10
10
  if (token) {
11
11
  try {
12
12
  const user = await usersService.me();
@@ -1,12 +1,12 @@
1
1
  import { DEFAULT_API_BASE_URL } from '../config/consts.js';
2
- import userConfig from '../utils/user-config.js';
2
+ import authorizationService from '../services/authorization-service.js';
3
3
  import nock from 'nock';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import whoamiCommand from './whoami.js';
6
6
  // Mock only the dependencies we need to control
7
- vi.mock('@/utils/user-config.js');
7
+ vi.mock('@/services/authorization-service.js');
8
8
  describe('whoami', () => {
9
- const mockUserConfig = vi.mocked(userConfig);
9
+ const mockAuthorizationService = vi.mocked(authorizationService);
10
10
  beforeEach(() => {
11
11
  vi.clearAllMocks();
12
12
  vi.spyOn(process, 'exit').mockImplementation(() => undefined);
@@ -17,8 +17,8 @@ describe('whoami', () => {
17
17
  });
18
18
  it('should send Bearer token in Authorization header when checking current user', async () => {
19
19
  const testToken = 'user-token-456';
20
- // Mock userConfig.read to return our test token
21
- mockUserConfig.read.mockReturnValue({ token: testToken });
20
+ // Mock the authorization service to return our test token
21
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
22
22
  // Set up nock to intercept the /v1/users/me request
23
23
  const scope = nock(DEFAULT_API_BASE_URL)
24
24
  .get('/v1/users/me')
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import configService from './services/config.js';
3
+ import telemetryService from './services/telemetry.js';
3
4
  import updateService from './services/update.js';
4
5
  import { getMessageFromUnknownError, UserError } from './utils/error.js';
6
+ import userConfig from './utils/user-config.js';
5
7
  import { defineConfig, processConfig, ZliError } from '@robingenz/zli';
6
8
  import * as Sentry from '@sentry/node';
7
9
  import { AxiosError } from 'axios';
@@ -103,6 +105,10 @@ const captureException = async (error) => {
103
105
  if (error instanceof ZodError) {
104
106
  return;
105
107
  }
108
+ // Respect telemetry opt-out
109
+ if (!telemetryService.isEnabled()) {
110
+ return;
111
+ }
106
112
  const environment = await configService.getValueForKey('ENVIRONMENT');
107
113
  if (environment !== 'production') {
108
114
  return;
@@ -111,6 +117,15 @@ const captureException = async (error) => {
111
117
  dsn: 'https://19f30f2ec4b91899abc33818568ceb42@o4507446340747264.ingest.de.sentry.io/4508506426966096',
112
118
  release: `capawesome-team-cli@${pkg.version}`,
113
119
  });
120
+ try {
121
+ const { userId } = userConfig.read();
122
+ if (userId) {
123
+ Sentry.setUser({ id: userId });
124
+ }
125
+ }
126
+ catch {
127
+ // Still report the crash even if the user ID can't be read.
128
+ }
114
129
  if (process.argv.slice(2).length > 0) {
115
130
  Sentry.setTag('cli_command', process.argv.slice(2)[0]);
116
131
  }
@@ -134,6 +149,8 @@ catch (error) {
134
149
  // Suggest opening an issue
135
150
  consola.log('If you think this is a bug, please open an issue at:');
136
151
  consola.log(' https://github.com/capawesome-team/cli/issues/new/choose');
152
+ // Show the telemetry notice
153
+ telemetryService.showNoticeIfNeeded();
137
154
  // Check for updates
138
155
  await updateService.checkForUpdate();
139
156
  // Exit with a non-zero code
@@ -141,6 +158,8 @@ catch (error) {
141
158
  }
142
159
  }
143
160
  finally {
161
+ // Show the telemetry notice
162
+ telemetryService.showNoticeIfNeeded();
144
163
  // Check for updates
145
164
  await updateService.checkForUpdate();
146
165
  }
@@ -37,6 +37,9 @@ class AppEnvironmentsServiceImpl {
37
37
  if (dto.name) {
38
38
  queryParams.append('name', dto.name);
39
39
  }
40
+ if (dto.relations) {
41
+ queryParams.append('relations', dto.relations);
42
+ }
40
43
  if (dto.limit) {
41
44
  queryParams.append('limit', dto.limit.toString());
42
45
  }
@@ -59,6 +62,7 @@ class AppEnvironmentsServiceImpl {
59
62
  headers: {
60
63
  Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
61
64
  },
65
+ params: dto.relations ? { relations: dto.relations } : undefined,
62
66
  });
63
67
  return response.data;
64
68
  }
@@ -1,11 +1,11 @@
1
- import userConfig from '../utils/user-config.js';
1
+ import credentialStore from '../utils/credential-store.js';
2
2
  class AuthorizationServiceImpl {
3
- userConfig;
4
- constructor(userConfig) {
5
- this.userConfig = userConfig;
3
+ credentialStore;
4
+ constructor(credentialStore) {
5
+ this.credentialStore = credentialStore;
6
6
  }
7
7
  getCurrentAuthorizationToken() {
8
- const token = this.userConfig.read().token || process.env.CAPAWESOME_CLOUD_TOKEN || process.env.CAPAWESOME_TOKEN || null;
8
+ const token = this.credentialStore.getToken() || process.env.CAPAWESOME_CLOUD_TOKEN || process.env.CAPAWESOME_TOKEN || null;
9
9
  // Trim to remove newline characters that may be included when pasting a token,
10
10
  // which would cause an invalid character error in the Authorization header.
11
11
  const trimmedToken = token?.trim();
@@ -15,5 +15,5 @@ class AuthorizationServiceImpl {
15
15
  return !!this.getCurrentAuthorizationToken();
16
16
  }
17
17
  }
18
- const authorizationService = new AuthorizationServiceImpl(userConfig);
18
+ const authorizationService = new AuthorizationServiceImpl(credentialStore);
19
19
  export default authorizationService;
@@ -0,0 +1,31 @@
1
+ import { isInteractive } from '../utils/environment.js';
2
+ import userConfig from '../utils/user-config.js';
3
+ import consola from 'consola';
4
+ const TELEMETRY_DISABLED_VALUES = ['1', 'true'];
5
+ class TelemetryServiceImpl {
6
+ isEnabled() {
7
+ const value = process.env.CAPAWESOME_TELEMETRY_DISABLED?.toLowerCase();
8
+ return !value || !TELEMETRY_DISABLED_VALUES.includes(value);
9
+ }
10
+ showNoticeIfNeeded() {
11
+ if (!this.isEnabled() || !isInteractive()) {
12
+ return;
13
+ }
14
+ try {
15
+ const config = userConfig.read();
16
+ if (config.telemetryNoticeShown) {
17
+ return;
18
+ }
19
+ console.log(''); // Add an empty line for better readability
20
+ consola.info('Capawesome CLI sends crash reports to help us fix bugs.\n' +
21
+ 'To opt out: export CAPAWESOME_TELEMETRY_DISABLED=1\n' +
22
+ 'Learn more: https://capawesome.io/docs/cloud/cli/telemetry/');
23
+ userConfig.write({ ...config, telemetryNoticeShown: true });
24
+ }
25
+ catch {
26
+ // Never let the telemetry notice break the CLI.
27
+ }
28
+ }
29
+ }
30
+ const telemetryService = new TelemetryServiceImpl();
31
+ export default telemetryService;
@@ -0,0 +1,67 @@
1
+ import consola from 'consola';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { isInteractive } from '../utils/environment.js';
4
+ import userConfig from '../utils/user-config.js';
5
+ import telemetryService from './telemetry.js';
6
+ vi.mock('@/utils/environment.js');
7
+ vi.mock('@/utils/user-config.js');
8
+ vi.mock('consola');
9
+ describe('telemetryService', () => {
10
+ const mockIsInteractive = vi.mocked(isInteractive);
11
+ const mockRead = vi.mocked(userConfig.read);
12
+ const mockWrite = vi.mocked(userConfig.write);
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ delete process.env.CAPAWESOME_TELEMETRY_DISABLED;
16
+ mockIsInteractive.mockReturnValue(true);
17
+ mockRead.mockReturnValue({});
18
+ });
19
+ afterEach(() => {
20
+ delete process.env.CAPAWESOME_TELEMETRY_DISABLED;
21
+ });
22
+ describe('isEnabled', () => {
23
+ it('should be enabled by default', () => {
24
+ expect(telemetryService.isEnabled()).toBe(true);
25
+ });
26
+ it.each(['1', 'true', 'TRUE'])('should be disabled when CAPAWESOME_TELEMETRY_DISABLED is "%s"', (value) => {
27
+ process.env.CAPAWESOME_TELEMETRY_DISABLED = value;
28
+ expect(telemetryService.isEnabled()).toBe(false);
29
+ });
30
+ it('should stay enabled for other values', () => {
31
+ process.env.CAPAWESOME_TELEMETRY_DISABLED = '0';
32
+ expect(telemetryService.isEnabled()).toBe(true);
33
+ });
34
+ });
35
+ describe('showNoticeIfNeeded', () => {
36
+ it('should show the notice once and persist the flag', () => {
37
+ mockRead.mockReturnValue({ token: 'abc' });
38
+ telemetryService.showNoticeIfNeeded();
39
+ expect(consola.info).toHaveBeenCalledOnce();
40
+ expect(mockWrite).toHaveBeenCalledWith({ token: 'abc', telemetryNoticeShown: true });
41
+ });
42
+ it('should not show the notice when it was already shown', () => {
43
+ mockRead.mockReturnValue({ telemetryNoticeShown: true });
44
+ telemetryService.showNoticeIfNeeded();
45
+ expect(consola.info).not.toHaveBeenCalled();
46
+ expect(mockWrite).not.toHaveBeenCalled();
47
+ });
48
+ it('should not show the notice when telemetry is disabled', () => {
49
+ process.env.CAPAWESOME_TELEMETRY_DISABLED = '1';
50
+ telemetryService.showNoticeIfNeeded();
51
+ expect(consola.info).not.toHaveBeenCalled();
52
+ expect(mockWrite).not.toHaveBeenCalled();
53
+ });
54
+ it('should not show the notice in non-interactive environments', () => {
55
+ mockIsInteractive.mockReturnValue(false);
56
+ telemetryService.showNoticeIfNeeded();
57
+ expect(consola.info).not.toHaveBeenCalled();
58
+ expect(mockWrite).not.toHaveBeenCalled();
59
+ });
60
+ it('should not throw when reading or writing the config fails', () => {
61
+ mockRead.mockImplementation(() => {
62
+ throw new Error('read failed');
63
+ });
64
+ expect(() => telemetryService.showNoticeIfNeeded()).not.toThrow();
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,84 @@
1
+ import userConfig from '../utils/user-config.js';
2
+ import { Entry } from '@napi-rs/keyring';
3
+ const SERVICE_NAME = 'capawesome-cli';
4
+ const ACCOUNT_NAME = 'token';
5
+ /**
6
+ * Stores the authentication token in the operating system's secure storage
7
+ * (macOS Keychain, Windows Credential Manager, Linux Secret Service) when
8
+ * available and falls back to the plaintext user config file otherwise
9
+ * (e.g. headless CI environments without a keyring backend).
10
+ */
11
+ class CredentialStoreImpl {
12
+ keyringAvailable = null;
13
+ getToken() {
14
+ if (!this.isKeyringAvailable()) {
15
+ return userConfig.read().token ?? null;
16
+ }
17
+ const token = this.createEntry().getPassword();
18
+ if (token) {
19
+ return token;
20
+ }
21
+ return this.migrateFileToken();
22
+ }
23
+ setToken(token) {
24
+ if (!this.isKeyringAvailable()) {
25
+ this.writeFileToken(token);
26
+ return;
27
+ }
28
+ this.createEntry().setPassword(token);
29
+ this.clearFileToken();
30
+ }
31
+ deleteToken() {
32
+ if (this.isKeyringAvailable()) {
33
+ try {
34
+ this.createEntry().deletePassword();
35
+ }
36
+ catch {
37
+ // Ignore errors when there is no credential to delete.
38
+ }
39
+ }
40
+ this.clearFileToken();
41
+ }
42
+ createEntry() {
43
+ return new Entry(SERVICE_NAME, ACCOUNT_NAME);
44
+ }
45
+ isKeyringAvailable() {
46
+ if (this.keyringAvailable === null) {
47
+ try {
48
+ // Probe the backend with a read. This throws if no keyring backend is
49
+ // available, but returns null for a missing credential.
50
+ this.createEntry().getPassword();
51
+ this.keyringAvailable = true;
52
+ }
53
+ catch {
54
+ this.keyringAvailable = false;
55
+ }
56
+ }
57
+ return this.keyringAvailable;
58
+ }
59
+ /**
60
+ * Moves a token stored in the plaintext config file into the keyring and
61
+ * removes the plaintext copy. Returns the migrated token or null.
62
+ */
63
+ migrateFileToken() {
64
+ const fileToken = userConfig.read().token;
65
+ if (!fileToken) {
66
+ return null;
67
+ }
68
+ this.setToken(fileToken);
69
+ return fileToken;
70
+ }
71
+ writeFileToken(token) {
72
+ const config = userConfig.read();
73
+ userConfig.write({ ...config, token });
74
+ }
75
+ clearFileToken() {
76
+ const { token, ...rest } = userConfig.read();
77
+ if (token === undefined) {
78
+ return;
79
+ }
80
+ userConfig.write(rest);
81
+ }
82
+ }
83
+ const credentialStore = new CredentialStoreImpl();
84
+ export default credentialStore;
@@ -0,0 +1,84 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const { mockGetPassword, mockSetPassword, mockDeletePassword, mockRead, mockWrite } = vi.hoisted(() => ({
3
+ mockGetPassword: vi.fn(),
4
+ mockSetPassword: vi.fn(),
5
+ mockDeletePassword: vi.fn(),
6
+ mockRead: vi.fn(),
7
+ mockWrite: vi.fn(),
8
+ }));
9
+ vi.mock('@napi-rs/keyring', () => ({
10
+ Entry: vi.fn(function () {
11
+ return {
12
+ getPassword: mockGetPassword,
13
+ setPassword: mockSetPassword,
14
+ deletePassword: mockDeletePassword,
15
+ };
16
+ }),
17
+ }));
18
+ vi.mock('@/utils/user-config.js', () => ({
19
+ default: { read: mockRead, write: mockWrite },
20
+ }));
21
+ const loadCredentialStore = async () => {
22
+ const module = await import('./credential-store.js');
23
+ return module.default;
24
+ };
25
+ describe('credentialStore', () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ vi.resetModules();
29
+ mockRead.mockReturnValue({});
30
+ });
31
+ describe('when the keyring is available', () => {
32
+ beforeEach(() => {
33
+ mockGetPassword.mockReturnValue(null);
34
+ });
35
+ it('should return the token from the keyring', async () => {
36
+ mockGetPassword.mockReturnValue('keyring-token');
37
+ const credentialStore = await loadCredentialStore();
38
+ expect(credentialStore.getToken()).toBe('keyring-token');
39
+ });
40
+ it('should return null when no token is stored', async () => {
41
+ const credentialStore = await loadCredentialStore();
42
+ expect(credentialStore.getToken()).toBeNull();
43
+ });
44
+ it('should migrate a plaintext token from the config file into the keyring', async () => {
45
+ mockRead.mockReturnValue({ token: 'file-token', userId: 'user-1' });
46
+ const credentialStore = await loadCredentialStore();
47
+ expect(credentialStore.getToken()).toBe('file-token');
48
+ expect(mockSetPassword).toHaveBeenCalledWith('file-token');
49
+ expect(mockWrite).toHaveBeenCalledWith({ userId: 'user-1' });
50
+ });
51
+ it('should store the token in the keyring and strip the plaintext copy', async () => {
52
+ mockRead.mockReturnValue({ token: 'old-token', userId: 'user-1' });
53
+ const credentialStore = await loadCredentialStore();
54
+ credentialStore.setToken('new-token');
55
+ expect(mockSetPassword).toHaveBeenCalledWith('new-token');
56
+ expect(mockWrite).toHaveBeenCalledWith({ userId: 'user-1' });
57
+ });
58
+ it('should delete the token from the keyring', async () => {
59
+ const credentialStore = await loadCredentialStore();
60
+ credentialStore.deleteToken();
61
+ expect(mockDeletePassword).toHaveBeenCalled();
62
+ });
63
+ });
64
+ describe('when the keyring is unavailable', () => {
65
+ beforeEach(() => {
66
+ mockGetPassword.mockImplementation(() => {
67
+ throw new Error('no keyring backend');
68
+ });
69
+ });
70
+ it('should return the token from the config file', async () => {
71
+ mockRead.mockReturnValue({ token: 'file-token' });
72
+ const credentialStore = await loadCredentialStore();
73
+ expect(credentialStore.getToken()).toBe('file-token');
74
+ expect(mockSetPassword).not.toHaveBeenCalled();
75
+ });
76
+ it('should store the token in the config file while preserving other fields', async () => {
77
+ mockRead.mockReturnValue({ userId: 'user-1' });
78
+ const credentialStore = await loadCredentialStore();
79
+ credentialStore.setToken('file-token');
80
+ expect(mockWrite).toHaveBeenCalledWith({ userId: 'user-1', token: 'file-token' });
81
+ expect(mockSetPassword).not.toHaveBeenCalled();
82
+ });
83
+ });
84
+ });
@@ -10,6 +10,7 @@ import consola from 'consola';
10
10
  */
11
11
  export const printJobFailureSummary = async (options) => {
12
12
  const { jobId } = options;
13
+ consola.info('Hang tight, this can take up to a minute.');
13
14
  consola.start('Generating failure summary with Capawesome Cloud Assist...');
14
15
  const { summary } = await jobsService.generateFailureSummary({ jobId });
15
16
  consola.success('Failure summary generated by Capawesome Cloud Assist:');
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@capawesome/cli",
3
- "version": "4.12.0",
3
+ "version": "4.14.0",
4
4
  "description": "The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "rimraf ./dist && tsc && tsc-alias",
8
8
  "start": "npm run build && node ./dist/index.js",
9
9
  "test": "vitest run",
10
+ "test:coverage": "vitest run --coverage",
10
11
  "test:watch": "vitest --watch",
11
12
  "test:ui": "vitest --ui",
12
13
  "lint": "npm run prettier -- --check",
@@ -53,14 +54,15 @@
53
54
  ],
54
55
  "dependencies": {
55
56
  "@clack/prompts": "0.7.0",
57
+ "@napi-rs/keyring": "1.3.0",
56
58
  "@robingenz/zli": "0.2.0",
57
- "@sentry/node": "8.55.0",
59
+ "@sentry/node": "10.58.0",
58
60
  "adm-zip": "0.5.16",
59
61
  "axios": "1.16.0",
60
62
  "axios-retry": "4.5.0",
61
63
  "c12": "3.3.3",
62
64
  "consola": "3.3.0",
63
- "form-data": "4.0.4",
65
+ "form-data": "4.0.6",
64
66
  "globby": "16.1.1",
65
67
  "http-proxy-agent": "7.0.2",
66
68
  "https-proxy-agent": "7.0.6",
@@ -78,6 +80,7 @@
78
80
  "@types/mime": "3.0.4",
79
81
  "@types/node": "24.2.1",
80
82
  "@types/semver": "7.5.8",
83
+ "@vitest/coverage-v8": "4.1.7",
81
84
  "@vitest/ui": "4.1.7",
82
85
  "commit-and-tag-version": "12.6.1",
83
86
  "nock": "14.0.10",