@capawesome/cli 4.13.0 → 4.15.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,28 @@
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.15.0](https://github.com/capawesome-team/cli/compare/v4.14.0...v4.15.0) (2026-06-23)
6
+
7
+
8
+ ### Features
9
+
10
+ * **login:** support the new session token field ([#176](https://github.com/capawesome-team/cli/issues/176)) ([195922d](https://github.com/capawesome-team/cli/commit/195922d60e9a035143f236aeaaeea6e9aa842c52))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **login:** fall back to file storage when keyring write fails ([#177](https://github.com/capawesome-team/cli/issues/177)) ([ec6019d](https://github.com/capawesome-team/cli/commit/ec6019d58476abda2c12646fd434b77636095355))
16
+
17
+ ## [4.14.0](https://github.com/capawesome-team/cli/compare/v4.13.0...v4.14.0) (2026-06-22)
18
+
19
+
20
+ ### Features
21
+
22
+ * hint that failure summary can take up to a minute ([9e75e4f](https://github.com/capawesome-team/cli/commit/9e75e4f211aedae075a9509059a8fd2c7fb5c1f5))
23
+ * 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))
24
+ * **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))
25
+ * **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))
26
+
5
27
  ## [4.13.0](https://github.com/capawesome-team/cli/compare/v4.12.0...v4.13.0) (2026-06-09)
6
28
 
7
29
 
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
@@ -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';
@@ -18,6 +19,8 @@ export default defineCommand({
18
19
  action: async (options, args) => {
19
20
  const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
20
21
  let { token: sessionIdOrToken } = options;
22
+ // The non-secret session id, only set when authenticating via the browser.
23
+ let sessionId;
21
24
  if (sessionIdOrToken === undefined) {
22
25
  if (!isInteractive()) {
23
26
  consola.error('You must provide a token when running in non-interactive environment.');
@@ -55,12 +58,13 @@ export default defineCommand({
55
58
  }
56
59
  // Wait for the user to authenticate
57
60
  consola.start('Waiting for authentication...');
58
- const sessionId = await createSession(deviceCode);
59
- if (!sessionId) {
61
+ const session = await createSession(deviceCode);
62
+ if (!session) {
60
63
  consola.error('Authentication timed out. Please try again.');
61
64
  process.exit(1);
62
65
  }
63
- sessionIdOrToken = sessionId;
66
+ sessionId = session.id;
67
+ sessionIdOrToken = session.token;
64
68
  }
65
69
  else {
66
70
  consola.info(`You can create a token at ${consoleBaseUrl}/settings/tokens.`);
@@ -81,15 +85,22 @@ export default defineCommand({
81
85
  }
82
86
  // Sign in with the provided token
83
87
  consola.start('Signing in...');
84
- userConfig.write({
85
- token: sessionIdOrToken,
86
- });
88
+ // Drop the previous user ID but keep other flags,
89
+ // so a crash during sign-in isn't attributed to the previous account
90
+ const { sessionId: _previousSessionId, token: _previousToken, userId: _previousUserId, ...persistentConfig } = userConfig.read();
91
+ // Persist the non-secret session id right away, so a crash during sign-in
92
+ // still lets logout delete the correct server session.
93
+ userConfig.write({ ...persistentConfig, ...(sessionId ? { sessionId } : {}) });
94
+ credentialStore.setToken(sessionIdOrToken);
87
95
  try {
88
- await usersService.me();
96
+ const user = await usersService.me();
97
+ userConfig.write({ ...persistentConfig, userId: user.id, ...(sessionId ? { sessionId } : {}) });
89
98
  consola.success(`Successfully signed in.`);
90
99
  }
91
100
  catch (error) {
92
- userConfig.write({});
101
+ // Clear the credentials and session id on failure while preserving the other flags
102
+ credentialStore.deleteToken();
103
+ userConfig.write(persistentConfig);
93
104
  if (error instanceof AxiosError && error.response?.status === 401) {
94
105
  consola.error(`Invalid token. Please provide a valid token. You can create a token at ${consoleBaseUrl}/settings/tokens.`);
95
106
  process.exit(1);
@@ -104,14 +115,13 @@ const createSession = async (deviceCode) => {
104
115
  const maxAttempts = 20;
105
116
  const interval = 3 * 1000; // 3 seconds
106
117
  let attempts = 0;
107
- let sessionId = null;
108
- while (attempts < maxAttempts && sessionId === null) {
118
+ let session = null;
119
+ while (attempts < maxAttempts && session === null) {
109
120
  try {
110
- const response = await sessionsService.create({
121
+ session = await sessionsService.create({
111
122
  code: deviceCode,
112
123
  provider: 'code',
113
124
  });
114
- sessionId = response.id;
115
125
  }
116
126
  catch (error) {
117
127
  if (error instanceof AxiosError && error.response?.status === 400) {
@@ -124,5 +134,5 @@ const createSession = async (deviceCode) => {
124
134
  }
125
135
  }
126
136
  }
127
- return sessionId;
137
+ return session;
128
138
  };
@@ -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
@@ -75,13 +98,13 @@ describe('login', () => {
75
98
  id: 'device-code-123',
76
99
  code: 'ABCD1234',
77
100
  });
78
- mockSessionsService.create.mockResolvedValue({ id: 'session-123' });
79
- // Mock userConfig.read to return the session token
80
- mockUserConfig.read.mockReturnValue({ token: 'session-123' });
101
+ mockSessionsService.create.mockResolvedValue({ id: 'session-123', token: 'session-token-123' });
102
+ // Mock credentialStore.getToken to return the session token
103
+ mockCredentialStore.getToken.mockReturnValue('session-token-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')
84
- .matchHeader('Authorization', `Bearer session-123`)
107
+ .matchHeader('Authorization', `Bearer session-token-123`)
85
108
  .reply(200, { id: 'user-123', email: 'test@example.com' });
86
109
  await loginCommand.action(options, undefined);
87
110
  expect(mockPrompt).toHaveBeenCalledWith('How would you like to authenticate Capawesome CLI?', {
@@ -94,6 +117,10 @@ describe('login', () => {
94
117
  expect(mockSessionCodesService.create).toHaveBeenCalled();
95
118
  expect(mockConsola.box).toHaveBeenCalledWith('Copy your one-time code: ABCD-1234');
96
119
  expect(mockOpen).toHaveBeenCalledWith(`${DEFAULT_CONSOLE_BASE_URL}/login/device`);
120
+ // The token (not the id) must be stored as the credential
121
+ expect(mockCredentialStore.setToken).toHaveBeenCalledWith('session-token-123');
122
+ // The non-secret session id must be persisted so logout can delete the session
123
+ expect(mockUserConfig.write).toHaveBeenLastCalledWith({ userId: 'user-123', sessionId: 'session-123' });
97
124
  expect(scope.isDone()).toBe(true);
98
125
  expect(mockConsola.success).toHaveBeenCalledWith('Successfully signed in.');
99
126
  });
@@ -106,16 +133,16 @@ describe('login', () => {
106
133
  it('should throw an error because the provided token is invalid', async () => {
107
134
  const invalidToken = 'invalid-token';
108
135
  const options = { token: invalidToken };
109
- // Mock userConfig.read to return our invalid token after it's written
110
- mockUserConfig.read.mockReturnValue({ token: invalidToken });
136
+ // Mock credentialStore.getToken to return our invalid token after it's written
137
+ mockCredentialStore.getToken.mockReturnValue(invalidToken);
111
138
  // Set up nock to intercept the /v1/users/me request and return 401
112
139
  const scope = nock(DEFAULT_API_BASE_URL)
113
140
  .get('/v1/users/me')
114
141
  .matchHeader('Authorization', `Bearer ${invalidToken}`)
115
142
  .reply(401, { message: 'Unauthorized' });
116
143
  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
144
+ expect(mockCredentialStore.setToken).toHaveBeenCalledWith(invalidToken);
145
+ expect(mockCredentialStore.deleteToken).toHaveBeenCalled(); // Clears token on error
119
146
  expect(scope.isDone()).toBe(true);
120
147
  expect(mockConsola.error).toHaveBeenCalledWith(`Invalid token. Please provide a valid token. You can create a token at ${DEFAULT_CONSOLE_BASE_URL}/settings/tokens.`);
121
148
  });
@@ -2,15 +2,21 @@ 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.',
8
9
  action: async (options, args) => {
9
10
  const token = authorizationService.getCurrentAuthorizationToken();
11
+ // Clear the session id and user ID but keep other flags (e.g. the telemetry notice).
12
+ const { sessionId, token: _token, userId: _userId, ...persistentConfig } = userConfig.read();
10
13
  if (token && !token.startsWith('ca_')) {
11
- await sessionsService.delete({ id: token }).catch(() => { });
14
+ // Delete by the stored session id. Fall back to the token for sessions
15
+ // created before the token/id split, whose token equals the id.
16
+ await sessionsService.delete({ id: sessionId ?? token }).catch(() => { });
12
17
  }
13
- userConfig.write({});
18
+ credentialStore.deleteToken();
19
+ userConfig.write(persistentConfig);
14
20
  consola.success('Successfully signed out.');
15
21
  },
16
22
  });
@@ -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,62 @@ 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 delete the session by its stored id when available', async () => {
40
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('session-token-xyz');
41
+ mockUserConfig.read.mockReturnValue({ sessionId: 'session-id-abc', token: 'session-token-xyz', userId: 'user-1' });
42
+ // The session must be deleted by its id, not by the token
43
+ const scope = nock(DEFAULT_API_BASE_URL).delete('/v1/sessions/session-id-abc').reply(200);
44
+ await logoutCommand.action({}, undefined);
45
+ expect(scope.isDone()).toBe(true);
46
+ expect(mockCredentialStore.deleteToken).toHaveBeenCalled();
47
+ // The session id and other sensitive fields must be cleared from the config
48
+ expect(mockUserConfig.write).toHaveBeenCalledWith({});
49
+ });
50
+ it('should only clear credentials with API token', async () => {
35
51
  const apiToken = 'ca_abc123';
36
52
  mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(apiToken);
37
53
  await logoutCommand.action({}, undefined);
38
- expect(mockUserConfig.write).toHaveBeenCalledWith({});
54
+ expect(mockCredentialStore.deleteToken).toHaveBeenCalled();
39
55
  expect(mockConsola.success).toHaveBeenCalledWith('Successfully signed out.');
40
56
  });
57
+ it('should clear the user ID but preserve other flags', async () => {
58
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('ca_abc123');
59
+ mockUserConfig.read.mockReturnValue({ token: 'ca_abc123', userId: 'user-1', telemetryNoticeShown: true });
60
+ await logoutCommand.action({}, undefined);
61
+ expect(mockUserConfig.write).toHaveBeenCalledWith({ telemetryNoticeShown: true });
62
+ });
41
63
  it('should handle no token gracefully', async () => {
42
64
  mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(null);
43
65
  await logoutCommand.action({}, undefined);
44
- expect(mockUserConfig.write).toHaveBeenCalledWith({});
66
+ expect(mockCredentialStore.deleteToken).toHaveBeenCalled();
45
67
  expect(mockConsola.success).toHaveBeenCalledWith('Successfully signed out.');
46
68
  });
47
69
  });
@@ -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
  }
@@ -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,87 @@
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
+ // Set once any keyring operation fails (e.g. a headless CI environment with a
13
+ // present but unusable Secret Service backend). From then on the file token is
14
+ // the single source of truth for the rest of the process, so reads don't
15
+ // return a stale keyring token after a failed write.
16
+ keyringDisabled = false;
17
+ getToken() {
18
+ if (!this.keyringDisabled) {
19
+ try {
20
+ const token = this.createEntry().getPassword();
21
+ if (token) {
22
+ return token;
23
+ }
24
+ return this.migrateFileToken();
25
+ }
26
+ catch {
27
+ this.keyringDisabled = true;
28
+ }
29
+ }
30
+ return userConfig.read().token ?? null;
31
+ }
32
+ setToken(token) {
33
+ if (!this.keyringDisabled) {
34
+ try {
35
+ this.createEntry().setPassword(token);
36
+ this.clearFileToken();
37
+ return;
38
+ }
39
+ catch {
40
+ this.keyringDisabled = true;
41
+ }
42
+ }
43
+ this.writeFileToken(token);
44
+ }
45
+ deleteToken() {
46
+ if (!this.keyringDisabled) {
47
+ try {
48
+ this.createEntry().deletePassword();
49
+ }
50
+ catch {
51
+ // The deletion throws when there is no credential to delete, but also
52
+ // when the keyring cannot be mutated. Disable the keyring so a stale
53
+ // token can't be read back after the file token has been cleared.
54
+ this.keyringDisabled = true;
55
+ }
56
+ }
57
+ this.clearFileToken();
58
+ }
59
+ createEntry() {
60
+ return new Entry(SERVICE_NAME, ACCOUNT_NAME);
61
+ }
62
+ /**
63
+ * Moves a token stored in the plaintext config file into the keyring and
64
+ * removes the plaintext copy. Returns the migrated token or null.
65
+ */
66
+ migrateFileToken() {
67
+ const fileToken = userConfig.read().token;
68
+ if (!fileToken) {
69
+ return null;
70
+ }
71
+ this.setToken(fileToken);
72
+ return fileToken;
73
+ }
74
+ writeFileToken(token) {
75
+ const config = userConfig.read();
76
+ userConfig.write({ ...config, token });
77
+ }
78
+ clearFileToken() {
79
+ const { token, ...rest } = userConfig.read();
80
+ if (token === undefined) {
81
+ return;
82
+ }
83
+ userConfig.write(rest);
84
+ }
85
+ }
86
+ const credentialStore = new CredentialStoreImpl();
87
+ export default credentialStore;
@@ -0,0 +1,120 @@
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 works', () => {
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 fall back to the config file when the keyring write fails', async () => {
59
+ mockRead.mockReturnValue({ userId: 'user-1' });
60
+ mockSetPassword.mockImplementation(() => {
61
+ throw new Error('Platform failure: Unknown(38)');
62
+ });
63
+ const credentialStore = await loadCredentialStore();
64
+ credentialStore.setToken('new-token');
65
+ expect(mockSetPassword).toHaveBeenCalledWith('new-token');
66
+ expect(mockWrite).toHaveBeenCalledWith({ userId: 'user-1', token: 'new-token' });
67
+ });
68
+ it('should read the file token after a keyring write failure even when a stale keyring token exists', async () => {
69
+ mockGetPassword.mockReturnValue('stale-keyring-token');
70
+ mockSetPassword.mockImplementation(() => {
71
+ throw new Error('Platform failure: Unknown(38)');
72
+ });
73
+ mockRead.mockReturnValue({ token: 'new-token' });
74
+ const credentialStore = await loadCredentialStore();
75
+ credentialStore.setToken('new-token');
76
+ expect(credentialStore.getToken()).toBe('new-token');
77
+ });
78
+ it('should delete the token from the keyring', async () => {
79
+ const credentialStore = await loadCredentialStore();
80
+ credentialStore.deleteToken();
81
+ expect(mockDeletePassword).toHaveBeenCalled();
82
+ });
83
+ it('should not read a stale keyring token after a keyring delete failure', async () => {
84
+ mockGetPassword.mockReturnValue('stale-keyring-token');
85
+ mockDeletePassword.mockImplementation(() => {
86
+ throw new Error('Platform failure: Unknown(38)');
87
+ });
88
+ const credentialStore = await loadCredentialStore();
89
+ credentialStore.deleteToken();
90
+ expect(credentialStore.getToken()).toBeNull();
91
+ });
92
+ });
93
+ describe('when the keyring is unavailable', () => {
94
+ beforeEach(() => {
95
+ const throwNoBackend = () => {
96
+ throw new Error('no keyring backend');
97
+ };
98
+ mockGetPassword.mockImplementation(throwNoBackend);
99
+ mockSetPassword.mockImplementation(throwNoBackend);
100
+ mockDeletePassword.mockImplementation(throwNoBackend);
101
+ });
102
+ it('should return the token from the config file', async () => {
103
+ mockRead.mockReturnValue({ token: 'file-token' });
104
+ const credentialStore = await loadCredentialStore();
105
+ expect(credentialStore.getToken()).toBe('file-token');
106
+ });
107
+ it('should store the token in the config file while preserving other fields', async () => {
108
+ mockRead.mockReturnValue({ userId: 'user-1' });
109
+ const credentialStore = await loadCredentialStore();
110
+ credentialStore.setToken('file-token');
111
+ expect(mockWrite).toHaveBeenCalledWith({ userId: 'user-1', token: 'file-token' });
112
+ });
113
+ it('should clear the file token without throwing when deleting', async () => {
114
+ mockRead.mockReturnValue({ token: 'file-token', userId: 'user-1' });
115
+ const credentialStore = await loadCredentialStore();
116
+ expect(() => credentialStore.deleteToken()).not.toThrow();
117
+ expect(mockWrite).toHaveBeenCalledWith({ userId: 'user-1' });
118
+ });
119
+ });
120
+ });
@@ -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.13.0",
3
+ "version": "4.15.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",