@capawesome/cli 4.14.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,18 @@
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
+
5
17
  ## [4.14.0](https://github.com/capawesome-team/cli/compare/v4.13.0...v4.14.0) (2026-06-22)
6
18
 
7
19
 
@@ -19,6 +19,8 @@ export default defineCommand({
19
19
  action: async (options, args) => {
20
20
  const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
21
21
  let { token: sessionIdOrToken } = options;
22
+ // The non-secret session id, only set when authenticating via the browser.
23
+ let sessionId;
22
24
  if (sessionIdOrToken === undefined) {
23
25
  if (!isInteractive()) {
24
26
  consola.error('You must provide a token when running in non-interactive environment.');
@@ -56,12 +58,13 @@ export default defineCommand({
56
58
  }
57
59
  // Wait for the user to authenticate
58
60
  consola.start('Waiting for authentication...');
59
- const sessionId = await createSession(deviceCode);
60
- if (!sessionId) {
61
+ const session = await createSession(deviceCode);
62
+ if (!session) {
61
63
  consola.error('Authentication timed out. Please try again.');
62
64
  process.exit(1);
63
65
  }
64
- sessionIdOrToken = sessionId;
66
+ sessionId = session.id;
67
+ sessionIdOrToken = session.token;
65
68
  }
66
69
  else {
67
70
  consola.info(`You can create a token at ${consoleBaseUrl}/settings/tokens.`);
@@ -84,17 +87,20 @@ export default defineCommand({
84
87
  consola.start('Signing in...');
85
88
  // Drop the previous user ID but keep other flags,
86
89
  // 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);
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 } : {}) });
89
94
  credentialStore.setToken(sessionIdOrToken);
90
95
  try {
91
96
  const user = await usersService.me();
92
- userConfig.write({ ...persistentConfig, userId: user.id });
97
+ userConfig.write({ ...persistentConfig, userId: user.id, ...(sessionId ? { sessionId } : {}) });
93
98
  consola.success(`Successfully signed in.`);
94
99
  }
95
100
  catch (error) {
96
- // Clear the credentials on failure while preserving the other flags
101
+ // Clear the credentials and session id on failure while preserving the other flags
97
102
  credentialStore.deleteToken();
103
+ userConfig.write(persistentConfig);
98
104
  if (error instanceof AxiosError && error.response?.status === 401) {
99
105
  consola.error(`Invalid token. Please provide a valid token. You can create a token at ${consoleBaseUrl}/settings/tokens.`);
100
106
  process.exit(1);
@@ -109,14 +115,13 @@ const createSession = async (deviceCode) => {
109
115
  const maxAttempts = 20;
110
116
  const interval = 3 * 1000; // 3 seconds
111
117
  let attempts = 0;
112
- let sessionId = null;
113
- while (attempts < maxAttempts && sessionId === null) {
118
+ let session = null;
119
+ while (attempts < maxAttempts && session === null) {
114
120
  try {
115
- const response = await sessionsService.create({
121
+ session = await sessionsService.create({
116
122
  code: deviceCode,
117
123
  provider: 'code',
118
124
  });
119
- sessionId = response.id;
120
125
  }
121
126
  catch (error) {
122
127
  if (error instanceof AxiosError && error.response?.status === 400) {
@@ -129,5 +134,5 @@ const createSession = async (deviceCode) => {
129
134
  }
130
135
  }
131
136
  }
132
- return sessionId;
137
+ return session;
133
138
  };
@@ -98,13 +98,13 @@ describe('login', () => {
98
98
  id: 'device-code-123',
99
99
  code: 'ABCD1234',
100
100
  });
101
- mockSessionsService.create.mockResolvedValue({ id: 'session-123' });
101
+ mockSessionsService.create.mockResolvedValue({ id: 'session-123', token: 'session-token-123' });
102
102
  // Mock credentialStore.getToken to return the session token
103
- mockCredentialStore.getToken.mockReturnValue('session-123');
103
+ mockCredentialStore.getToken.mockReturnValue('session-token-123');
104
104
  // Set up nock to intercept the /v1/users/me request
105
105
  const scope = nock(DEFAULT_API_BASE_URL)
106
106
  .get('/v1/users/me')
107
- .matchHeader('Authorization', `Bearer session-123`)
107
+ .matchHeader('Authorization', `Bearer session-token-123`)
108
108
  .reply(200, { id: 'user-123', email: 'test@example.com' });
109
109
  await loginCommand.action(options, undefined);
110
110
  expect(mockPrompt).toHaveBeenCalledWith('How would you like to authenticate Capawesome CLI?', {
@@ -117,6 +117,10 @@ describe('login', () => {
117
117
  expect(mockSessionCodesService.create).toHaveBeenCalled();
118
118
  expect(mockConsola.box).toHaveBeenCalledWith('Copy your one-time code: ABCD-1234');
119
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' });
120
124
  expect(scope.isDone()).toBe(true);
121
125
  expect(mockConsola.success).toHaveBeenCalledWith('Successfully signed in.');
122
126
  });
@@ -8,12 +8,14 @@ export default defineCommand({
8
8
  description: 'Sign out from the Capawesome Cloud Console.',
9
9
  action: async (options, args) => {
10
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();
11
13
  if (token && !token.startsWith('ca_')) {
12
- 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(() => { });
13
17
  }
14
18
  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
19
  userConfig.write(persistentConfig);
18
20
  consola.success('Successfully signed out.');
19
21
  },
@@ -36,6 +36,17 @@ describe('logout', () => {
36
36
  expect(mockCredentialStore.deleteToken).toHaveBeenCalled();
37
37
  expect(mockConsola.success).toHaveBeenCalledWith('Successfully signed out.');
38
38
  });
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
+ });
39
50
  it('should only clear credentials with API token', async () => {
40
51
  const apiToken = 'ca_abc123';
41
52
  mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(apiToken);
@@ -9,32 +9,49 @@ const ACCOUNT_NAME = 'token';
9
9
  * (e.g. headless CI environments without a keyring backend).
10
10
  */
11
11
  class CredentialStoreImpl {
12
- keyringAvailable = null;
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;
13
17
  getToken() {
14
- if (!this.isKeyringAvailable()) {
15
- return userConfig.read().token ?? null;
16
- }
17
- const token = this.createEntry().getPassword();
18
- if (token) {
19
- return token;
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
+ }
20
29
  }
21
- return this.migrateFileToken();
30
+ return userConfig.read().token ?? null;
22
31
  }
23
32
  setToken(token) {
24
- if (!this.isKeyringAvailable()) {
25
- this.writeFileToken(token);
26
- return;
33
+ if (!this.keyringDisabled) {
34
+ try {
35
+ this.createEntry().setPassword(token);
36
+ this.clearFileToken();
37
+ return;
38
+ }
39
+ catch {
40
+ this.keyringDisabled = true;
41
+ }
27
42
  }
28
- this.createEntry().setPassword(token);
29
- this.clearFileToken();
43
+ this.writeFileToken(token);
30
44
  }
31
45
  deleteToken() {
32
- if (this.isKeyringAvailable()) {
46
+ if (!this.keyringDisabled) {
33
47
  try {
34
48
  this.createEntry().deletePassword();
35
49
  }
36
50
  catch {
37
- // Ignore errors when there is no credential to delete.
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;
38
55
  }
39
56
  }
40
57
  this.clearFileToken();
@@ -42,20 +59,6 @@ class CredentialStoreImpl {
42
59
  createEntry() {
43
60
  return new Entry(SERVICE_NAME, ACCOUNT_NAME);
44
61
  }
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
62
  /**
60
63
  * Moves a token stored in the plaintext config file into the keyring and
61
64
  * removes the plaintext copy. Returns the migrated token or null.
@@ -28,7 +28,7 @@ describe('credentialStore', () => {
28
28
  vi.resetModules();
29
29
  mockRead.mockReturnValue({});
30
30
  });
31
- describe('when the keyring is available', () => {
31
+ describe('when the keyring works', () => {
32
32
  beforeEach(() => {
33
33
  mockGetPassword.mockReturnValue(null);
34
34
  });
@@ -55,30 +55,66 @@ describe('credentialStore', () => {
55
55
  expect(mockSetPassword).toHaveBeenCalledWith('new-token');
56
56
  expect(mockWrite).toHaveBeenCalledWith({ userId: 'user-1' });
57
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
+ });
58
78
  it('should delete the token from the keyring', async () => {
59
79
  const credentialStore = await loadCredentialStore();
60
80
  credentialStore.deleteToken();
61
81
  expect(mockDeletePassword).toHaveBeenCalled();
62
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
+ });
63
92
  });
64
93
  describe('when the keyring is unavailable', () => {
65
94
  beforeEach(() => {
66
- mockGetPassword.mockImplementation(() => {
95
+ const throwNoBackend = () => {
67
96
  throw new Error('no keyring backend');
68
- });
97
+ };
98
+ mockGetPassword.mockImplementation(throwNoBackend);
99
+ mockSetPassword.mockImplementation(throwNoBackend);
100
+ mockDeletePassword.mockImplementation(throwNoBackend);
69
101
  });
70
102
  it('should return the token from the config file', async () => {
71
103
  mockRead.mockReturnValue({ token: 'file-token' });
72
104
  const credentialStore = await loadCredentialStore();
73
105
  expect(credentialStore.getToken()).toBe('file-token');
74
- expect(mockSetPassword).not.toHaveBeenCalled();
75
106
  });
76
107
  it('should store the token in the config file while preserving other fields', async () => {
77
108
  mockRead.mockReturnValue({ userId: 'user-1' });
78
109
  const credentialStore = await loadCredentialStore();
79
110
  credentialStore.setToken('file-token');
80
111
  expect(mockWrite).toHaveBeenCalledWith({ userId: 'user-1', token: 'file-token' });
81
- expect(mockSetPassword).not.toHaveBeenCalled();
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' });
82
118
  });
83
119
  });
84
120
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capawesome/cli",
3
- "version": "4.14.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": {