@capawesome/cli 4.14.0 → 4.16.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 +25 -0
- package/dist/commands/apps/builds/create.js +54 -5
- package/dist/commands/apps/builds/create.test.js +121 -0
- package/dist/commands/apps/builds/share.js +105 -0
- package/dist/commands/apps/builds/share.test.js +130 -0
- package/dist/commands/login.js +17 -12
- package/dist/commands/login.test.js +7 -3
- package/dist/commands/logout.js +5 -3
- package/dist/commands/logout.test.js +11 -0
- package/dist/index.js +1 -0
- package/dist/services/app-builds.js +9 -0
- package/dist/utils/app-build-shares.js +11 -0
- package/dist/utils/credential-store.js +32 -29
- package/dist/utils/credential-store.test.js +41 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
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.16.0](https://github.com/capawesome-team/cli/compare/v4.15.0...v4.16.0) (2026-07-12)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* add `apps:builds:share` command and `--share` flag on `apps:builds:create` ([#178](https://github.com/capawesome-team/cli/issues/178)) ([05588c3](https://github.com/capawesome-team/cli/commit/05588c3db4fab2843f54fa1180de9e37c0d9b518))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* correct success message when using `--detached` ([#179](https://github.com/capawesome-team/cli/issues/179)) ([33cd33b](https://github.com/capawesome-team/cli/commit/33cd33b5266353fe62b8ba9858f58c7f13dd9e78))
|
|
16
|
+
* run deployment before `--json` output in `apps:builds:create` ([#182](https://github.com/capawesome-team/cli/issues/182)) ([1d693fb](https://github.com/capawesome-team/cli/commit/1d693fb1cafccd02237dd61c94b81e9e62b79f36))
|
|
17
|
+
|
|
18
|
+
## [4.15.0](https://github.com/capawesome-team/cli/compare/v4.14.0...v4.15.0) (2026-06-23)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
* **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))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* **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))
|
|
29
|
+
|
|
5
30
|
## [4.14.0](https://github.com/capawesome-team/cli/compare/v4.13.0...v4.14.0) (2026-06-22)
|
|
6
31
|
|
|
7
32
|
|
|
@@ -4,6 +4,7 @@ import appBuildsService from '../../../services/app-builds.js';
|
|
|
4
4
|
import appCertificatesService from '../../../services/app-certificates.js';
|
|
5
5
|
import appEnvironmentsService from '../../../services/app-environments.js';
|
|
6
6
|
import appsService from '../../../services/apps.js';
|
|
7
|
+
import { getAppBuildShareUrls } from '../../../utils/app-build-shares.js';
|
|
7
8
|
import { parseKeyValuePairs } from '../../../utils/app-environments.js';
|
|
8
9
|
import { withAuth } from '../../../utils/auth.js';
|
|
9
10
|
import { createBufferFromPath } from '../../../utils/buffer.js';
|
|
@@ -62,6 +63,19 @@ export default defineCommand({
|
|
|
62
63
|
})
|
|
63
64
|
.optional()
|
|
64
65
|
.describe('The platform for the build. Supported values are `ios`, `android`, and `web`.'),
|
|
66
|
+
share: z.boolean().optional().describe('Create a public share link for the build after it succeeds.'),
|
|
67
|
+
shareDescription: z
|
|
68
|
+
.string()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe('Additional information for testers, e.g. what to test. Requires --share.'),
|
|
71
|
+
shareExpiresInDays: z.coerce
|
|
72
|
+
.number()
|
|
73
|
+
.int()
|
|
74
|
+
.min(1, {
|
|
75
|
+
message: 'Expires in days must be a positive integer.',
|
|
76
|
+
})
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('Number of days until the share link expires. Requires --share.'),
|
|
65
79
|
stack: z
|
|
66
80
|
.enum(['macos-sequoia', 'macos-tahoe'], {
|
|
67
81
|
message: 'Build stack must be either `macos-sequoia` or `macos-tahoe`.',
|
|
@@ -104,6 +118,16 @@ export default defineCommand({
|
|
|
104
118
|
consola.error('The --detached flag cannot be used with --failure-summary.');
|
|
105
119
|
process.exit(1);
|
|
106
120
|
}
|
|
121
|
+
// Validate that detached flag cannot be used with share
|
|
122
|
+
if (options.detached && options.share) {
|
|
123
|
+
consola.error('The --detached flag cannot be used with --share. Sharing requires waiting for completion.');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
// Validate that share sub-flags cannot be used without share
|
|
127
|
+
if (!options.share && (options.shareDescription !== undefined || options.shareExpiresInDays !== undefined)) {
|
|
128
|
+
consola.error('The --share-description and --share-expires-in-days flags require --share.');
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
107
131
|
// Validate that channel and destination cannot be used together
|
|
108
132
|
if (options.channel && options.destination) {
|
|
109
133
|
consola.error('The --channel and --destination flags cannot be used together.');
|
|
@@ -393,11 +417,40 @@ export default defineCommand({
|
|
|
393
417
|
filePath: typeof options.zip === 'string' ? options.zip : undefined,
|
|
394
418
|
});
|
|
395
419
|
}
|
|
420
|
+
// Create a public share link if requested
|
|
421
|
+
let share;
|
|
422
|
+
if (options.share) {
|
|
423
|
+
const expiresAt = options.shareExpiresInDays !== undefined
|
|
424
|
+
? new Date(Date.now() + options.shareExpiresInDays * 24 * 60 * 60 * 1000).toISOString()
|
|
425
|
+
: undefined;
|
|
426
|
+
const createdShare = await appBuildsService.createShare({
|
|
427
|
+
appId,
|
|
428
|
+
appBuildId: response.id,
|
|
429
|
+
description: options.shareDescription,
|
|
430
|
+
expiresAt,
|
|
431
|
+
});
|
|
432
|
+
const shareUrls = await getAppBuildShareUrls(createdShare.id);
|
|
433
|
+
share = {
|
|
434
|
+
id: createdShare.id,
|
|
435
|
+
url: shareUrls.url,
|
|
436
|
+
qrCodeUrl: shareUrls.qrCodeUrl,
|
|
437
|
+
expiresAt: createdShare.expiresAt,
|
|
438
|
+
};
|
|
439
|
+
if (!json) {
|
|
440
|
+
consola.success('Build shared successfully.');
|
|
441
|
+
consola.info(`Share URL: ${shareUrls.url}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// Create deployment if channel or destination is set
|
|
445
|
+
if (options.channel || options.destination) {
|
|
446
|
+
await (await import('../../../commands/apps/deployments/create.js').then((mod) => mod.default)).action({ appId, buildId: response.id, channel: options.channel, destination: options.destination }, undefined);
|
|
447
|
+
}
|
|
396
448
|
// Output JSON if json flag is set
|
|
397
449
|
if (json) {
|
|
398
450
|
console.log(JSON.stringify({
|
|
399
451
|
id: response.id,
|
|
400
452
|
numberAsString: response.numberAsString,
|
|
453
|
+
...(share ? { share } : {}),
|
|
401
454
|
}, null, 2));
|
|
402
455
|
}
|
|
403
456
|
}
|
|
@@ -412,13 +465,9 @@ export default defineCommand({
|
|
|
412
465
|
consola.info(`Build ID: ${response.id}`);
|
|
413
466
|
consola.info(`Build Number: ${response.numberAsString}`);
|
|
414
467
|
consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
|
|
415
|
-
consola.success(
|
|
468
|
+
consola.success('Build started successfully.');
|
|
416
469
|
}
|
|
417
470
|
}
|
|
418
|
-
// Create deployment if channel or destination is set
|
|
419
|
-
if (options.channel || options.destination) {
|
|
420
|
-
await (await import('../../../commands/apps/deployments/create.js').then((mod) => mod.default)).action({ appId, buildId: response.id, channel: options.channel, destination: options.destination }, undefined);
|
|
421
|
-
}
|
|
422
471
|
}),
|
|
423
472
|
});
|
|
424
473
|
/**
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { DEFAULT_API_BASE_URL, DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
|
|
2
|
+
import authorizationService from '../../../services/authorization-service.js';
|
|
3
|
+
import userConfig from '../../../utils/user-config.js';
|
|
4
|
+
import consola from 'consola';
|
|
5
|
+
import nock from 'nock';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import createCommand from './create.js';
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
vi.mock('@/utils/user-config.js');
|
|
10
|
+
vi.mock('@/utils/prompt.js');
|
|
11
|
+
vi.mock('@/services/authorization-service.js');
|
|
12
|
+
vi.mock('@/utils/job.js');
|
|
13
|
+
vi.mock('consola');
|
|
14
|
+
vi.mock('@/utils/environment.js', () => ({
|
|
15
|
+
isInteractive: () => false,
|
|
16
|
+
}));
|
|
17
|
+
describe('apps-builds-create', () => {
|
|
18
|
+
const mockUserConfig = vi.mocked(userConfig);
|
|
19
|
+
const mockAuthorizationService = vi.mocked(authorizationService);
|
|
20
|
+
const mockConsola = vi.mocked(consola);
|
|
21
|
+
const testToken = 'test-token';
|
|
22
|
+
const appId = '00000000-0000-0000-0000-000000000001';
|
|
23
|
+
const buildId = '00000000-0000-0000-0000-000000000002';
|
|
24
|
+
const validAppId = '11111111-1111-4111-8111-111111111111';
|
|
25
|
+
const shareId = 'share-abc';
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
mockUserConfig.read.mockReturnValue({ token: testToken });
|
|
29
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
30
|
+
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
|
|
31
|
+
// Mock waitForJobCompletion to resolve immediately as succeeded
|
|
32
|
+
const jobUtils = await import('../../../utils/job.js');
|
|
33
|
+
vi.mocked(jobUtils.waitForJobCompletion).mockResolvedValue({
|
|
34
|
+
id: 'job-1',
|
|
35
|
+
status: 'succeeded',
|
|
36
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
37
|
+
});
|
|
38
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
39
|
+
throw new Error(`Process exited with code ${code}`);
|
|
40
|
+
});
|
|
41
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
nock.cleanAll();
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
});
|
|
47
|
+
it('should create a share and merge it into the JSON output', async () => {
|
|
48
|
+
const options = { appId, platform: 'web', gitRef: 'main', share: true, json: true };
|
|
49
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
50
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
51
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
52
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
|
|
53
|
+
const findScope = nock(DEFAULT_API_BASE_URL)
|
|
54
|
+
.get(`/v1/apps/${appId}/builds/${buildId}`)
|
|
55
|
+
.query({ relations: 'appBuildArtifacts' })
|
|
56
|
+
.reply(200, { id: buildId, appBuildArtifacts: [] });
|
|
57
|
+
const shareScope = nock(DEFAULT_API_BASE_URL)
|
|
58
|
+
.post(`/v1/apps/${appId}/builds/${buildId}/shares`, {})
|
|
59
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
60
|
+
.reply(201, {
|
|
61
|
+
id: shareId,
|
|
62
|
+
appBuildId: buildId,
|
|
63
|
+
description: null,
|
|
64
|
+
expiresAt: null,
|
|
65
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
66
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
67
|
+
});
|
|
68
|
+
await createCommand.action(options, undefined);
|
|
69
|
+
expect(buildScope.isDone()).toBe(true);
|
|
70
|
+
expect(findScope.isDone()).toBe(true);
|
|
71
|
+
expect(shareScope.isDone()).toBe(true);
|
|
72
|
+
const url = `${DEFAULT_CONSOLE_BASE_URL}/app-build-shares/${shareId}`;
|
|
73
|
+
const qrCodeUrl = `${DEFAULT_API_BASE_URL}/v1/qrcodes?content=${encodeURIComponent(url)}&format=png`;
|
|
74
|
+
expect(console.log).toHaveBeenCalledWith(JSON.stringify({
|
|
75
|
+
id: buildId,
|
|
76
|
+
numberAsString: '42',
|
|
77
|
+
share: { id: shareId, url, qrCodeUrl, expiresAt: null },
|
|
78
|
+
}, null, 2));
|
|
79
|
+
});
|
|
80
|
+
it('should reject --share combined with --detached', async () => {
|
|
81
|
+
const options = { appId, platform: 'web', gitRef: 'main', share: true, detached: true };
|
|
82
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
83
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The --detached flag cannot be used with --share. Sharing requires waiting for completion.');
|
|
84
|
+
});
|
|
85
|
+
it('should reject --share-description without --share', async () => {
|
|
86
|
+
const options = { appId, platform: 'web', gitRef: 'main', shareDescription: 'What to test' };
|
|
87
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
88
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The --share-description and --share-expires-in-days flags require --share.');
|
|
89
|
+
});
|
|
90
|
+
it('should reject --share-expires-in-days without --share', async () => {
|
|
91
|
+
const options = { appId, platform: 'web', gitRef: 'main', shareExpiresInDays: 7 };
|
|
92
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
93
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The --share-description and --share-expires-in-days flags require --share.');
|
|
94
|
+
});
|
|
95
|
+
it('should reject a non-positive shareExpiresInDays value', () => {
|
|
96
|
+
const schema = createCommand.options?.schema;
|
|
97
|
+
for (const shareExpiresInDays of [0, -1]) {
|
|
98
|
+
const result = schema?.safeParse({
|
|
99
|
+
appId: validAppId,
|
|
100
|
+
platform: 'web',
|
|
101
|
+
gitRef: 'main',
|
|
102
|
+
share: true,
|
|
103
|
+
shareExpiresInDays,
|
|
104
|
+
});
|
|
105
|
+
expect(result?.success).toBe(false);
|
|
106
|
+
expect(result?.error?.issues[0]?.message).toBe('Expires in days must be a positive integer.');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
it('should accept a positive shareExpiresInDays value', () => {
|
|
110
|
+
const schema = createCommand.options?.schema;
|
|
111
|
+
const result = schema?.safeParse({
|
|
112
|
+
appId: validAppId,
|
|
113
|
+
platform: 'web',
|
|
114
|
+
gitRef: 'main',
|
|
115
|
+
share: true,
|
|
116
|
+
shareExpiresInDays: 7,
|
|
117
|
+
});
|
|
118
|
+
expect(result?.success).toBe(true);
|
|
119
|
+
expect(result?.data?.shareExpiresInDays).toBe(7);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import appBuildsService from '../../../services/app-builds.js';
|
|
2
|
+
import { getAppBuildShareUrls } from '../../../utils/app-build-shares.js';
|
|
3
|
+
import { withAuth } from '../../../utils/auth.js';
|
|
4
|
+
import { isInteractive } from '../../../utils/environment.js';
|
|
5
|
+
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
|
|
6
|
+
import { defineCommand, defineOptions } from '@robingenz/zli';
|
|
7
|
+
import consola from 'consola';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
export default defineCommand({
|
|
10
|
+
description: 'Create a public share link for an app build.',
|
|
11
|
+
options: defineOptions(z.object({
|
|
12
|
+
appId: z
|
|
13
|
+
.uuid({
|
|
14
|
+
message: 'App ID must be a UUID.',
|
|
15
|
+
})
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('App ID the build belongs to.'),
|
|
18
|
+
buildId: z
|
|
19
|
+
.uuid({
|
|
20
|
+
message: 'Build ID must be a UUID.',
|
|
21
|
+
})
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('Build ID to share.'),
|
|
24
|
+
buildNumber: z.string().optional().describe('Build number to share (e.g., "1", "42").'),
|
|
25
|
+
description: z.string().optional().describe('Additional information for testers, e.g. what to test.'),
|
|
26
|
+
expiresInDays: z.coerce
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.min(1, {
|
|
30
|
+
message: 'Expires in days must be a positive integer.',
|
|
31
|
+
})
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('Number of days until the share link expires.'),
|
|
34
|
+
json: z.boolean().optional().describe('Output in JSON format.'),
|
|
35
|
+
})),
|
|
36
|
+
action: withAuth(async (options) => {
|
|
37
|
+
let { appId, buildId } = options;
|
|
38
|
+
const { buildNumber, description, expiresInDays, json } = options;
|
|
39
|
+
// Prompt for app ID if not provided
|
|
40
|
+
if (!appId) {
|
|
41
|
+
if (!isInteractive()) {
|
|
42
|
+
consola.error('You must provide an app ID when running in non-interactive environment.');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const organizationId = await promptOrganizationSelection();
|
|
46
|
+
appId = await promptAppSelection(organizationId);
|
|
47
|
+
}
|
|
48
|
+
// Convert build number to build ID if provided
|
|
49
|
+
if (!buildId && buildNumber) {
|
|
50
|
+
const builds = await appBuildsService.findAll({ appId, numberAsString: buildNumber });
|
|
51
|
+
if (builds.length === 0) {
|
|
52
|
+
consola.error(`Build #${buildNumber} not found.`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
buildId = builds[0]?.id;
|
|
56
|
+
}
|
|
57
|
+
// Prompt for build ID if not provided
|
|
58
|
+
if (!buildId) {
|
|
59
|
+
if (!isInteractive()) {
|
|
60
|
+
consola.error('You must provide a build ID when running in non-interactive environment.');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
const builds = await appBuildsService.findAll({ appId });
|
|
64
|
+
if (builds.length === 0) {
|
|
65
|
+
consola.error('No builds found for this app.');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
|
|
69
|
+
buildId = await prompt('Select the build you want to share:', {
|
|
70
|
+
type: 'select',
|
|
71
|
+
options: builds.map((build) => ({
|
|
72
|
+
label: `Build #${build.numberAsString || build.id} (${build.platform} - ${build.type})`,
|
|
73
|
+
value: build.id,
|
|
74
|
+
})),
|
|
75
|
+
});
|
|
76
|
+
if (!buildId) {
|
|
77
|
+
consola.error('You must select a build to share.');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Ensure the build has succeeded before creating a share
|
|
82
|
+
const build = await appBuildsService.findOne({ appId, appBuildId: buildId, relations: 'job' });
|
|
83
|
+
if (build.job?.status !== 'succeeded') {
|
|
84
|
+
consola.error('The build has not succeeded yet. Only successful builds can be shared.');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
// Compute the expiration date if provided
|
|
88
|
+
const expiresAt = expiresInDays !== undefined
|
|
89
|
+
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString()
|
|
90
|
+
: undefined;
|
|
91
|
+
const share = await appBuildsService.createShare({ appId, appBuildId: buildId, description, expiresAt });
|
|
92
|
+
const { url, qrCodeUrl } = await getAppBuildShareUrls(share.id);
|
|
93
|
+
if (json) {
|
|
94
|
+
console.log(JSON.stringify({ id: share.id, url, qrCodeUrl, expiresAt: share.expiresAt }, null, 2));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
consola.success('Build shared successfully.');
|
|
98
|
+
consola.info(`Share URL: ${url}`);
|
|
99
|
+
consola.info(`QR Code URL: ${qrCodeUrl}`);
|
|
100
|
+
if (share.expiresAt) {
|
|
101
|
+
consola.info(`Expires At: ${share.expiresAt}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { DEFAULT_API_BASE_URL, DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
|
|
2
|
+
import authorizationService from '../../../services/authorization-service.js';
|
|
3
|
+
import userConfig from '../../../utils/user-config.js';
|
|
4
|
+
import consola from 'consola';
|
|
5
|
+
import nock from 'nock';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import shareCommand from './share.js';
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
vi.mock('@/utils/user-config.js');
|
|
10
|
+
vi.mock('@/utils/prompt.js');
|
|
11
|
+
vi.mock('@/services/authorization-service.js');
|
|
12
|
+
vi.mock('consola');
|
|
13
|
+
vi.mock('@/utils/environment.js', () => ({
|
|
14
|
+
isInteractive: () => false,
|
|
15
|
+
}));
|
|
16
|
+
describe('apps-builds-share', () => {
|
|
17
|
+
const mockUserConfig = vi.mocked(userConfig);
|
|
18
|
+
const mockAuthorizationService = vi.mocked(authorizationService);
|
|
19
|
+
const mockConsola = vi.mocked(consola);
|
|
20
|
+
const testToken = 'test-token';
|
|
21
|
+
const appId = '00000000-0000-0000-0000-000000000001';
|
|
22
|
+
const buildId = '00000000-0000-0000-0000-000000000002';
|
|
23
|
+
const validAppId = '11111111-1111-4111-8111-111111111111';
|
|
24
|
+
const validBuildId = '22222222-2222-4222-8222-222222222222';
|
|
25
|
+
const shareId = 'share-abc';
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
mockUserConfig.read.mockReturnValue({ token: testToken });
|
|
29
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
30
|
+
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
|
|
31
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
32
|
+
throw new Error(`Process exited with code ${code}`);
|
|
33
|
+
});
|
|
34
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
nock.cleanAll();
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
it('should create a share and output the exact JSON shape', async () => {
|
|
41
|
+
const options = { appId, buildId, json: true };
|
|
42
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
43
|
+
.get(`/v1/apps/${appId}/builds/${buildId}`)
|
|
44
|
+
.query({ relations: 'job' })
|
|
45
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
46
|
+
.reply(200, { id: buildId, job: { id: 'job-1', status: 'succeeded' } });
|
|
47
|
+
const shareScope = nock(DEFAULT_API_BASE_URL)
|
|
48
|
+
.post(`/v1/apps/${appId}/builds/${buildId}/shares`, {})
|
|
49
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
50
|
+
.reply(201, {
|
|
51
|
+
id: shareId,
|
|
52
|
+
appBuildId: buildId,
|
|
53
|
+
description: null,
|
|
54
|
+
expiresAt: null,
|
|
55
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
56
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
57
|
+
});
|
|
58
|
+
await shareCommand.action(options, undefined);
|
|
59
|
+
expect(buildScope.isDone()).toBe(true);
|
|
60
|
+
expect(shareScope.isDone()).toBe(true);
|
|
61
|
+
const url = `${DEFAULT_CONSOLE_BASE_URL}/app-build-shares/${shareId}`;
|
|
62
|
+
const qrCodeUrl = `${DEFAULT_API_BASE_URL}/v1/qrcodes?content=${encodeURIComponent(url)}&format=png`;
|
|
63
|
+
expect(console.log).toHaveBeenCalledWith(JSON.stringify({ id: shareId, url, qrCodeUrl, expiresAt: null }, null, 2));
|
|
64
|
+
expect(mockConsola.success).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
it('should compute expiresAt from expiresInDays', async () => {
|
|
67
|
+
const expiresInDays = 7;
|
|
68
|
+
const options = { appId, buildId, expiresInDays, json: true };
|
|
69
|
+
nock(DEFAULT_API_BASE_URL)
|
|
70
|
+
.get(`/v1/apps/${appId}/builds/${buildId}`)
|
|
71
|
+
.query({ relations: 'job' })
|
|
72
|
+
.reply(200, { id: buildId, job: { id: 'job-1', status: 'succeeded' } });
|
|
73
|
+
let capturedBody = {};
|
|
74
|
+
nock(DEFAULT_API_BASE_URL)
|
|
75
|
+
.post(`/v1/apps/${appId}/builds/${buildId}/shares`, (body) => {
|
|
76
|
+
capturedBody = body;
|
|
77
|
+
return true;
|
|
78
|
+
})
|
|
79
|
+
.reply(201, {
|
|
80
|
+
id: shareId,
|
|
81
|
+
appBuildId: buildId,
|
|
82
|
+
description: null,
|
|
83
|
+
expiresAt: capturedBody.expiresAt ?? null,
|
|
84
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
85
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
86
|
+
});
|
|
87
|
+
const before = Date.now();
|
|
88
|
+
await shareCommand.action(options, undefined);
|
|
89
|
+
const after = Date.now();
|
|
90
|
+
expect(capturedBody.expiresAt).toBeDefined();
|
|
91
|
+
const expiresAtMs = new Date(capturedBody.expiresAt).getTime();
|
|
92
|
+
const millisPerDay = 24 * 60 * 60 * 1000;
|
|
93
|
+
expect(expiresAtMs).toBeGreaterThanOrEqual(before + expiresInDays * millisPerDay);
|
|
94
|
+
expect(expiresAtMs).toBeLessThanOrEqual(after + expiresInDays * millisPerDay);
|
|
95
|
+
});
|
|
96
|
+
it('should error when the build has not succeeded', async () => {
|
|
97
|
+
const options = { appId, buildId, json: true };
|
|
98
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
99
|
+
.get(`/v1/apps/${appId}/builds/${buildId}`)
|
|
100
|
+
.query({ relations: 'job' })
|
|
101
|
+
.reply(200, { id: buildId, job: { id: 'job-1', status: 'in_progress' } });
|
|
102
|
+
await expect(shareCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
103
|
+
expect(buildScope.isDone()).toBe(true);
|
|
104
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The build has not succeeded yet. Only successful builds can be shared.');
|
|
105
|
+
});
|
|
106
|
+
it('should require app ID in non-interactive mode', async () => {
|
|
107
|
+
const options = { buildId };
|
|
108
|
+
await expect(shareCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
109
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must provide an app ID when running in non-interactive environment.');
|
|
110
|
+
});
|
|
111
|
+
it('should require build ID in non-interactive mode', async () => {
|
|
112
|
+
const options = { appId };
|
|
113
|
+
await expect(shareCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
114
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must provide a build ID when running in non-interactive environment.');
|
|
115
|
+
});
|
|
116
|
+
it('should reject a non-positive expiresInDays value', () => {
|
|
117
|
+
const schema = shareCommand.options?.schema;
|
|
118
|
+
for (const expiresInDays of [0, -1]) {
|
|
119
|
+
const result = schema?.safeParse({ appId: validAppId, buildId: validBuildId, expiresInDays });
|
|
120
|
+
expect(result?.success).toBe(false);
|
|
121
|
+
expect(result?.error?.issues[0]?.message).toBe('Expires in days must be a positive integer.');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
it('should accept a positive expiresInDays value', () => {
|
|
125
|
+
const schema = shareCommand.options?.schema;
|
|
126
|
+
const result = schema?.safeParse({ appId: validAppId, buildId: validBuildId, expiresInDays: 7 });
|
|
127
|
+
expect(result?.success).toBe(true);
|
|
128
|
+
expect(result?.data?.expiresInDays).toBe(7);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
|
60
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
while (attempts < maxAttempts &&
|
|
118
|
+
let session = null;
|
|
119
|
+
while (attempts < maxAttempts && session === null) {
|
|
114
120
|
try {
|
|
115
|
-
|
|
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
|
|
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
|
});
|
package/dist/commands/logout.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ const config = defineConfig({
|
|
|
36
36
|
'apps:builds:get': await import('./commands/apps/builds/get.js').then((mod) => mod.default),
|
|
37
37
|
'apps:builds:list': await import('./commands/apps/builds/list.js').then((mod) => mod.default),
|
|
38
38
|
'apps:builds:logs': await import('./commands/apps/builds/logs.js').then((mod) => mod.default),
|
|
39
|
+
'apps:builds:share': await import('./commands/apps/builds/share.js').then((mod) => mod.default),
|
|
39
40
|
'apps:builds:download': await import('./commands/apps/builds/download.js').then((mod) => mod.default),
|
|
40
41
|
'apps:bundles:create': await import('./commands/apps/bundles/create.js').then((mod) => mod.default),
|
|
41
42
|
'apps:bundles:delete': await import('./commands/apps/bundles/delete.js').then((mod) => mod.default),
|
|
@@ -14,6 +14,15 @@ class AppBuildsServiceImpl {
|
|
|
14
14
|
});
|
|
15
15
|
return response.data;
|
|
16
16
|
}
|
|
17
|
+
async createShare(dto) {
|
|
18
|
+
const { appId, appBuildId, ...bodyData } = dto;
|
|
19
|
+
const response = await this.httpClient.post(`/v1/apps/${appId}/builds/${appBuildId}/shares`, bodyData, {
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return response.data;
|
|
25
|
+
}
|
|
17
26
|
async findAll(dto) {
|
|
18
27
|
const params = {};
|
|
19
28
|
if (dto.limit !== undefined) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import configService from '../services/config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Build the public share page URL and the corresponding QR code image URL for an app build share.
|
|
4
|
+
*/
|
|
5
|
+
export const getAppBuildShareUrls = async (shareId) => {
|
|
6
|
+
const consoleBaseUrl = await configService.getValueForKey('CONSOLE_BASE_URL');
|
|
7
|
+
const apiBaseUrl = await configService.getValueForKey('API_BASE_URL');
|
|
8
|
+
const url = `${consoleBaseUrl}/app-build-shares/${shareId}`;
|
|
9
|
+
const qrCodeUrl = `${apiBaseUrl}/v1/qrcodes?content=${encodeURIComponent(url)}&format=png`;
|
|
10
|
+
return { url, qrCodeUrl };
|
|
11
|
+
};
|
|
@@ -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
|
-
|
|
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.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
30
|
+
return userConfig.read().token ?? null;
|
|
22
31
|
}
|
|
23
32
|
setToken(token) {
|
|
24
|
-
if (!this.
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
29
|
-
this.clearFileToken();
|
|
43
|
+
this.writeFileToken(token);
|
|
30
44
|
}
|
|
31
45
|
deleteToken() {
|
|
32
|
-
if (this.
|
|
46
|
+
if (!this.keyringDisabled) {
|
|
33
47
|
try {
|
|
34
48
|
this.createEntry().deletePassword();
|
|
35
49
|
}
|
|
36
50
|
catch {
|
|
37
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|