@capawesome/cli 4.26.0 → 4.27.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 +19 -0
- package/dist/commands/apps/automations/create.js +6 -9
- package/dist/commands/apps/automations/update.js +11 -26
- package/dist/commands/apps/automations/update.test.js +18 -1
- package/dist/commands/apps/builds/create.js +5 -5
- package/dist/commands/apps/builds/create.test.js +16 -0
- package/dist/commands/apps/deployments/create.js +3 -2
- package/dist/commands/apps/destinations/create.js +24 -23
- package/dist/commands/apps/destinations/create.test.js +69 -5
- package/dist/commands/apps/destinations/update.js +3 -6
- package/dist/commands/apps/destinations/update.test.js +1 -1
- package/dist/commands/apps/liveupdates/create.js +6 -4
- package/dist/commands/apps/liveupdates/generate-signing-key.js +3 -2
- package/dist/commands/apps/liveupdates/register.js +2 -1
- package/dist/commands/apps/liveupdates/upload.js +2 -1
- package/dist/commands/apps/update.js +7 -16
- package/dist/commands/apps/update.test.js +1 -1
- package/dist/utils/cli-options.js +11 -0
- package/dist/utils/cli-options.test.js +29 -1
- package/dist/utils/file.js +14 -13
- package/dist/utils/file.test.js +28 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [4.27.0](https://github.com/capawesome-team/cli/compare/v4.26.1...v4.27.0) (2026-10-09)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **apps:** add `--apple-api-key-id` to `apps:destinations:create` ([#249](https://github.com/capawesome-team/cli/issues/249)) ([f8efdd8](https://github.com/capawesome-team/cli/commit/f8efdd8ff401ab4dbb1eb954db5ca044b6819501))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **apps:** upload Apple API key file only after all destination options are validated ([#250](https://github.com/capawesome-team/cli/issues/250)) ([ad0fadf](https://github.com/capawesome-team/cli/commit/ad0fadfa9b38792764d962ad7a5803448082ff1b))
|
|
16
|
+
|
|
17
|
+
## [4.26.1](https://github.com/capawesome-team/cli/compare/v4.26.0...v4.26.1) (2026-10-07)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* **apps:liveupdates:generate-signing-key:** show actionable error message if key files cannot be written ([#246](https://github.com/capawesome-team/cli/issues/246)) ([14f477e](https://github.com/capawesome-team/cli/commit/14f477e5ac84bb3a62d94c959c30f84cbf524e84))
|
|
23
|
+
|
|
5
24
|
## [4.26.0](https://github.com/capawesome-team/cli/compare/v4.25.0...v4.26.0) (2026-10-05)
|
|
6
25
|
|
|
7
26
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import appAutomationsService from '../../../services/app-automations.js';
|
|
2
2
|
import appsService from '../../../services/apps.js';
|
|
3
3
|
import { withAuth } from '../../../utils/auth.js';
|
|
4
|
-
import { parseListOption } from '../../../utils/cli-options.js';
|
|
4
|
+
import { parseListOption, stringOption } from '../../../utils/cli-options.js';
|
|
5
5
|
import { isInteractive } from '../../../utils/environment.js';
|
|
6
6
|
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
|
|
7
7
|
import consola from 'consola';
|
|
@@ -11,18 +11,15 @@ export default defineCommand({
|
|
|
11
11
|
description: 'Create a new app automation.',
|
|
12
12
|
options: defineOptions(z.object({
|
|
13
13
|
appId: z.string().optional().describe('ID of the app.'),
|
|
14
|
-
certificate:
|
|
14
|
+
certificate: stringOption.describe('The name of the certificate to use for the build.'),
|
|
15
15
|
channel: z
|
|
16
16
|
.array(z.string())
|
|
17
17
|
.optional()
|
|
18
18
|
.describe('The name of a channel to deploy to (Web only). Can be specified multiple times or comma-separated.'),
|
|
19
|
-
commitMessagePattern:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
configuration: z.string().optional().describe('The name of the native configuration (Android/iOS only).'),
|
|
24
|
-
destination: z.string().optional().describe('The name of the destination to deploy to (Android/iOS only).'),
|
|
25
|
-
environment: z.string().optional().describe('The name of the environment to use for the build.'),
|
|
19
|
+
commitMessagePattern: stringOption.describe('Only trigger for commits whose message matches this pattern (branch triggers only).'),
|
|
20
|
+
configuration: stringOption.describe('The name of the native configuration (Android/iOS only).'),
|
|
21
|
+
destination: stringOption.describe('The name of the destination to deploy to (Android/iOS only).'),
|
|
22
|
+
environment: stringOption.describe('The name of the environment to use for the build.'),
|
|
26
23
|
json: z.boolean().optional().describe('Output in JSON format.'),
|
|
27
24
|
name: z.string().optional().describe('Name of the automation.'),
|
|
28
25
|
platform: z
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import appAutomationsService from '../../../services/app-automations.js';
|
|
2
2
|
import { withAuth } from '../../../utils/auth.js';
|
|
3
|
-
import { clearableValue, parseListOption } from '../../../utils/cli-options.js';
|
|
3
|
+
import { clearableStringOption, clearableValue, parseListOption } from '../../../utils/cli-options.js';
|
|
4
4
|
import { isInteractive } from '../../../utils/environment.js';
|
|
5
5
|
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
|
|
6
6
|
import consola from 'consola';
|
|
@@ -12,30 +12,15 @@ export default defineCommand({
|
|
|
12
12
|
options: defineOptions(z.object({
|
|
13
13
|
appId: z.string().optional().describe('ID of the app.'),
|
|
14
14
|
automationId: z.string().optional().describe('ID of the automation.'),
|
|
15
|
-
certificate:
|
|
16
|
-
.string()
|
|
17
|
-
.optional()
|
|
18
|
-
.describe('The name of the certificate to use for the build. Pass an empty string to clear it.'),
|
|
15
|
+
certificate: clearableStringOption.describe('The name of the certificate to use for the build. Pass an empty string to clear it.'),
|
|
19
16
|
channel: z
|
|
20
17
|
.array(z.string())
|
|
21
18
|
.optional()
|
|
22
19
|
.describe('The name of a channel to deploy to (Web only). Can be specified multiple times or comma-separated. Pass an empty string to clear them.'),
|
|
23
|
-
commitMessagePattern:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
configuration: z
|
|
28
|
-
.string()
|
|
29
|
-
.optional()
|
|
30
|
-
.describe('The name of the native configuration (Android/iOS only). Pass an empty string to clear it.'),
|
|
31
|
-
destination: z
|
|
32
|
-
.string()
|
|
33
|
-
.optional()
|
|
34
|
-
.describe('The name of the destination to deploy to (Android/iOS only). Pass an empty string to clear it.'),
|
|
35
|
-
environment: z
|
|
36
|
-
.string()
|
|
37
|
-
.optional()
|
|
38
|
-
.describe('The name of the environment to use for the build. Pass an empty string to clear it.'),
|
|
20
|
+
commitMessagePattern: clearableStringOption.describe('Only trigger for commits whose message matches this pattern (branch triggers only). Pass an empty string to clear it.'),
|
|
21
|
+
configuration: clearableStringOption.describe('The name of the native configuration (Android/iOS only). Pass an empty string to clear it.'),
|
|
22
|
+
destination: clearableStringOption.describe('The name of the destination to deploy to (Android/iOS only). Pass an empty string to clear it.'),
|
|
23
|
+
environment: clearableStringOption.describe('The name of the environment to use for the build. Pass an empty string to clear it.'),
|
|
39
24
|
json: z.boolean().optional().describe('Output in JSON format.'),
|
|
40
25
|
name: z.string().optional().describe('Name of the automation.'),
|
|
41
26
|
platform: z
|
|
@@ -94,16 +79,16 @@ export default defineCommand({
|
|
|
94
79
|
});
|
|
95
80
|
}
|
|
96
81
|
await appAutomationsService.update({
|
|
97
|
-
appCertificateName:
|
|
82
|
+
appCertificateName: options.certificate,
|
|
98
83
|
appChannelNames: parseListOption(options.channel),
|
|
99
|
-
appConfigurationName:
|
|
100
|
-
appDestinationName:
|
|
101
|
-
appEnvironmentName:
|
|
84
|
+
appConfigurationName: options.configuration,
|
|
85
|
+
appDestinationName: options.destination,
|
|
86
|
+
appEnvironmentName: options.environment,
|
|
102
87
|
appId,
|
|
103
88
|
automationId,
|
|
104
89
|
buildStack: clearableValue(options.stack),
|
|
105
90
|
buildType: options.type,
|
|
106
|
-
commitMessagePattern:
|
|
91
|
+
commitMessagePattern: options.commitMessagePattern,
|
|
107
92
|
name: options.name,
|
|
108
93
|
platform: options.platform,
|
|
109
94
|
triggerPatterns: clearableValues(options.triggerPattern),
|
|
@@ -59,7 +59,12 @@ describe('apps-automations-update', () => {
|
|
|
59
59
|
it('should clear a reference when an empty string is passed', async () => {
|
|
60
60
|
const appId = 'app-123';
|
|
61
61
|
const automationId = 'automation-456';
|
|
62
|
-
const options = {
|
|
62
|
+
const options = updateAutomationCommand.options.schema.parse({
|
|
63
|
+
appId,
|
|
64
|
+
automationId,
|
|
65
|
+
certificate: '',
|
|
66
|
+
triggerPattern: [''],
|
|
67
|
+
});
|
|
63
68
|
const scope = nock(DEFAULT_API_BASE_URL)
|
|
64
69
|
.patch(`/v1/apps/${appId}/automations/${automationId}`, {
|
|
65
70
|
appCertificateName: null,
|
|
@@ -71,6 +76,18 @@ describe('apps-automations-update', () => {
|
|
|
71
76
|
expect(scope.isDone()).toBe(true);
|
|
72
77
|
expect(mockConsola.success).toHaveBeenCalledWith('Automation updated successfully.');
|
|
73
78
|
});
|
|
79
|
+
it('should clear a reference when a whitespace-only string is passed', async () => {
|
|
80
|
+
const appId = 'app-123';
|
|
81
|
+
const automationId = 'automation-456';
|
|
82
|
+
const options = updateAutomationCommand.options.schema.parse({ appId, automationId, certificate: ' ' });
|
|
83
|
+
const scope = nock(DEFAULT_API_BASE_URL)
|
|
84
|
+
.patch(`/v1/apps/${appId}/automations/${automationId}`, { appCertificateName: null })
|
|
85
|
+
.matchHeader('Authorization', 'Bearer test-token')
|
|
86
|
+
.reply(204);
|
|
87
|
+
await updateAutomationCommand.action(options, undefined);
|
|
88
|
+
expect(scope.isDone()).toBe(true);
|
|
89
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Automation updated successfully.');
|
|
90
|
+
});
|
|
74
91
|
it('should send the channels as appChannelNames', async () => {
|
|
75
92
|
const appId = 'app-123';
|
|
76
93
|
const automationId = 'automation-456';
|
|
@@ -10,7 +10,7 @@ import { getAppBuildShareUrls } from '../../../utils/app-build-shares.js';
|
|
|
10
10
|
import { parseKeyValuePairs } from '../../../utils/app-environments.js';
|
|
11
11
|
import { withAuth } from '../../../utils/auth.js';
|
|
12
12
|
import { createBufferFromPath } from '../../../utils/buffer.js';
|
|
13
|
-
import { parseListOption } from '../../../utils/cli-options.js';
|
|
13
|
+
import { parseListOption, stringOption } from '../../../utils/cli-options.js';
|
|
14
14
|
import { isInteractive } from '../../../utils/environment.js';
|
|
15
15
|
import { offerJobFailureSummary } from '../../../utils/job-failure-summary.js';
|
|
16
16
|
import { isDirectory, isReadable } from '../../../utils/file.js';
|
|
@@ -46,18 +46,18 @@ export default defineCommand({
|
|
|
46
46
|
})
|
|
47
47
|
.optional()
|
|
48
48
|
.describe('App ID to create the build for.'),
|
|
49
|
-
certificate:
|
|
49
|
+
certificate: stringOption.describe('The name of the certificate to use for the build.'),
|
|
50
50
|
channel: z
|
|
51
51
|
.array(z.string())
|
|
52
52
|
.optional()
|
|
53
53
|
.describe('The name of a channel to deploy to (Web only). Can be specified multiple times or comma-separated.'),
|
|
54
|
-
configuration:
|
|
55
|
-
destination:
|
|
54
|
+
configuration: stringOption.describe('The name of the native configuration (Android/iOS only).'),
|
|
55
|
+
destination: stringOption.describe('The name of the destination to deploy to (Android/iOS only).'),
|
|
56
56
|
detached: z
|
|
57
57
|
.boolean()
|
|
58
58
|
.optional()
|
|
59
59
|
.describe('Exit immediately after creating the build without waiting for completion.'),
|
|
60
|
-
environment:
|
|
60
|
+
environment: stringOption.describe('The name of the environment to use for the build.'),
|
|
61
61
|
failureSummary: z
|
|
62
62
|
.boolean()
|
|
63
63
|
.optional()
|
|
@@ -164,6 +164,22 @@ describe('apps-builds-create', () => {
|
|
|
164
164
|
expect(buildScope.isDone()).toBe(true);
|
|
165
165
|
expect(mockConsola.error).not.toHaveBeenCalled();
|
|
166
166
|
});
|
|
167
|
+
it('should not send an empty environment', async () => {
|
|
168
|
+
const options = createCommand.options.schema.parse({
|
|
169
|
+
appId: validAppId,
|
|
170
|
+
platform: 'web',
|
|
171
|
+
gitRef: 'main',
|
|
172
|
+
environment: '',
|
|
173
|
+
detached: true,
|
|
174
|
+
});
|
|
175
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
176
|
+
.post(`/v1/apps/${validAppId}/builds`, (body) => !('appEnvironmentName' in body))
|
|
177
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
178
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
|
|
179
|
+
await createCommand.action(options, undefined);
|
|
180
|
+
expect(buildScope.isDone()).toBe(true);
|
|
181
|
+
expect(mockConsola.error).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
167
183
|
it('should accept --channel combined with --detached', async () => {
|
|
168
184
|
const options = { appId, platform: 'web', gitRef: 'main', channel: ['beta'], detached: true };
|
|
169
185
|
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
@@ -3,6 +3,7 @@ import appDeploymentsService from '../../../services/app-deployments.js';
|
|
|
3
3
|
import appDestinationsService from '../../../services/app-destinations.js';
|
|
4
4
|
import configService from '../../../services/config.js';
|
|
5
5
|
import { withAuth } from '../../../utils/auth.js';
|
|
6
|
+
import { stringOption } from '../../../utils/cli-options.js';
|
|
6
7
|
import { isInteractive } from '../../../utils/environment.js';
|
|
7
8
|
import { offerJobFailureSummary } from '../../../utils/job-failure-summary.js';
|
|
8
9
|
import { waitForJobCompletion } from '../../../utils/job.js';
|
|
@@ -27,8 +28,8 @@ export default defineCommand({
|
|
|
27
28
|
.optional()
|
|
28
29
|
.describe('Build ID to deploy.'),
|
|
29
30
|
buildNumber: z.string().optional().describe('Build number to deploy (e.g., "1", "42").'),
|
|
30
|
-
channel:
|
|
31
|
-
destination:
|
|
31
|
+
channel: stringOption.describe('The name of the channel to deploy to (Web only).'),
|
|
32
|
+
destination: stringOption.describe('The name of the destination to deploy to (Android/iOS only).'),
|
|
32
33
|
detached: z
|
|
33
34
|
.boolean()
|
|
34
35
|
.optional()
|
|
@@ -3,7 +3,7 @@ import appDestinationsService from '../../../services/app-destinations.js';
|
|
|
3
3
|
import appGoogleServiceAccountKeysService from '../../../services/app-google-service-account-keys.js';
|
|
4
4
|
import appsService from '../../../services/apps.js';
|
|
5
5
|
import { withAuth } from '../../../utils/auth.js';
|
|
6
|
-
import { parseListOption } from '../../../utils/cli-options.js';
|
|
6
|
+
import { parseListOption, stringOption } from '../../../utils/cli-options.js';
|
|
7
7
|
import { isInteractive } from '../../../utils/environment.js';
|
|
8
8
|
import { isReadable } from '../../../utils/file.js';
|
|
9
9
|
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
|
|
@@ -34,6 +34,7 @@ export default defineCommand({
|
|
|
34
34
|
appleTeamId: z.string().optional().describe('Apple Team ID for the destination.'),
|
|
35
35
|
appleAppPassword: z.string().optional().describe('Apple app-specific password for the destination.'),
|
|
36
36
|
appleApiKeyFile: z.string().optional().describe('Path to the Apple API key (.p8) file.'),
|
|
37
|
+
appleApiKeyId: z.string().optional().describe('Apple API Key ID for the destination.'),
|
|
37
38
|
appleIssuerId: z.string().optional().describe('Apple Issuer ID for the destination.'),
|
|
38
39
|
appleBetaGroup: z
|
|
39
40
|
.array(z.string())
|
|
@@ -74,18 +75,14 @@ export default defineCommand({
|
|
|
74
75
|
.string()
|
|
75
76
|
.optional()
|
|
76
77
|
.describe('Huawei AppGallery Connect API client secret for the destination.'),
|
|
77
|
-
defaultLanguage:
|
|
78
|
-
.string()
|
|
79
|
-
.optional()
|
|
80
|
-
.describe('Language of the default release notes text, e.g. `en-US` (Google Play and Huawei AppGallery only).'),
|
|
78
|
+
defaultLanguage: stringOption.describe('Language of the default release notes text, e.g. `en-US` (Google Play and Huawei AppGallery only).'),
|
|
81
79
|
})),
|
|
82
80
|
action: withAuth(async (options, args) => {
|
|
83
|
-
let { appId, json, name, platform, type, appleId, appleAppId, appleTeamId, appleAppPassword, appleApiKeyFile, appleIssuerId, appleBetaGroup, appleSubmitForReview, appleReleaseType, appleRejectIfPossible, androidPackageName, androidBuildArtifactType, androidReleaseStatus, googleServiceAccountKeyFile, googlePlayTrack, firebaseAppId, firebaseTesterGroup, huaweiAppId, huaweiClientId, huaweiClientSecret, defaultLanguage, } = options;
|
|
81
|
+
let { appId, json, name, platform, type, appleId, appleAppId, appleTeamId, appleAppPassword, appleApiKeyFile, appleApiKeyId, appleIssuerId, appleBetaGroup, appleSubmitForReview, appleReleaseType, appleRejectIfPossible, androidPackageName, androidBuildArtifactType, androidReleaseStatus, googleServiceAccountKeyFile, googlePlayTrack, firebaseAppId, firebaseTesterGroup, huaweiAppId, huaweiClientId, huaweiClientSecret, defaultLanguage, } = options;
|
|
84
82
|
if (type === 'app-store-connect') {
|
|
85
83
|
consola.warn('The destination type `app-store-connect` is deprecated. Use `apple-app-store-connect` instead.');
|
|
86
84
|
type = 'apple-app-store-connect';
|
|
87
85
|
}
|
|
88
|
-
let appleApiKeyId;
|
|
89
86
|
let appAppleApiKeyId;
|
|
90
87
|
let appGoogleServiceAccountKeyId;
|
|
91
88
|
// 1. Select organization and app
|
|
@@ -234,7 +231,7 @@ export default defineCommand({
|
|
|
234
231
|
if (type === 'apple-app-store-connect') {
|
|
235
232
|
// 9. Ask for authentication method
|
|
236
233
|
let authMethod;
|
|
237
|
-
if (appleApiKeyFile || appleIssuerId) {
|
|
234
|
+
if (appleApiKeyFile || appleApiKeyId || appleIssuerId) {
|
|
238
235
|
authMethod = 'apiKey';
|
|
239
236
|
}
|
|
240
237
|
else if (appleId || appleAppId || appleAppPassword) {
|
|
@@ -255,7 +252,7 @@ export default defineCommand({
|
|
|
255
252
|
}
|
|
256
253
|
}
|
|
257
254
|
else {
|
|
258
|
-
consola.error('You must provide authentication options when running in non-interactive environment. Either pass --apple-api-key-file and --apple-issuer-id for API Key authentication or --apple-id, --apple-app-id, and --apple-app-password for Password authentication.');
|
|
255
|
+
consola.error('You must provide authentication options when running in non-interactive environment. Either pass --apple-api-key-file, --apple-api-key-id, and --apple-issuer-id for API Key authentication or --apple-id, --apple-app-id, and --apple-app-password for Password authentication.');
|
|
259
256
|
process.exit(1);
|
|
260
257
|
}
|
|
261
258
|
if (authMethod === 'apiKey') {
|
|
@@ -273,20 +270,6 @@ export default defineCommand({
|
|
|
273
270
|
process.exit(1);
|
|
274
271
|
}
|
|
275
272
|
}
|
|
276
|
-
// Upload Apple API key file
|
|
277
|
-
const appleApiKeyFileReadable = await isReadable(appleApiKeyFile);
|
|
278
|
-
if (!appleApiKeyFileReadable) {
|
|
279
|
-
consola.error(`The Apple API key file does not exist or is not accessible: ${appleApiKeyFile}`);
|
|
280
|
-
process.exit(1);
|
|
281
|
-
}
|
|
282
|
-
const buffer = fs.readFileSync(appleApiKeyFile);
|
|
283
|
-
const fileName = path.basename(appleApiKeyFile);
|
|
284
|
-
const key = await appAppleApiKeysService.create({
|
|
285
|
-
appId,
|
|
286
|
-
buffer,
|
|
287
|
-
fileName,
|
|
288
|
-
});
|
|
289
|
-
appAppleApiKeyId = key.id;
|
|
290
273
|
// 11. Ask for key ID
|
|
291
274
|
if (!appleApiKeyId) {
|
|
292
275
|
if (!isInteractive()) {
|
|
@@ -362,6 +345,9 @@ export default defineCommand({
|
|
|
362
345
|
process.exit(1);
|
|
363
346
|
}
|
|
364
347
|
}
|
|
348
|
+
if (appleApiKeyFile) {
|
|
349
|
+
appAppleApiKeyId = await uploadAppleApiKeyFile(appId, appleApiKeyFile);
|
|
350
|
+
}
|
|
365
351
|
}
|
|
366
352
|
const response = await appDestinationsService.create({
|
|
367
353
|
appId,
|
|
@@ -494,3 +480,18 @@ const uploadGoogleServiceAccountKeyFile = async (appId, googleServiceAccountKeyF
|
|
|
494
480
|
});
|
|
495
481
|
return key.id;
|
|
496
482
|
};
|
|
483
|
+
const uploadAppleApiKeyFile = async (appId, appleApiKeyFile) => {
|
|
484
|
+
const appleApiKeyFileReadable = await isReadable(appleApiKeyFile);
|
|
485
|
+
if (!appleApiKeyFileReadable) {
|
|
486
|
+
consola.error(`The Apple API key file does not exist or is not accessible: ${appleApiKeyFile}`);
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
const buffer = fs.readFileSync(appleApiKeyFile);
|
|
490
|
+
const fileName = path.basename(appleApiKeyFile);
|
|
491
|
+
const key = await appAppleApiKeysService.create({
|
|
492
|
+
appId,
|
|
493
|
+
buffer,
|
|
494
|
+
fileName,
|
|
495
|
+
});
|
|
496
|
+
return key.id;
|
|
497
|
+
};
|
|
@@ -25,7 +25,7 @@ describe('apps-destinations-create', () => {
|
|
|
25
25
|
const appId = 'app-123';
|
|
26
26
|
const destinationId = 'destination-456';
|
|
27
27
|
const googleServiceAccountKeyId = 'key-789';
|
|
28
|
-
const
|
|
28
|
+
const appAppleApiKeyId = 'apple-key-012';
|
|
29
29
|
const testToken = 'test-token';
|
|
30
30
|
let tempDirectory;
|
|
31
31
|
let googleServiceAccountKeyFile;
|
|
@@ -100,7 +100,7 @@ describe('apps-destinations-create', () => {
|
|
|
100
100
|
const keyScope = nock(DEFAULT_API_BASE_URL)
|
|
101
101
|
.post(`/v1/apps/${appId}/apple-api-keys`)
|
|
102
102
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
103
|
-
.reply(201, { id:
|
|
103
|
+
.reply(201, { id: appAppleApiKeyId });
|
|
104
104
|
const destinationScope = nock(DEFAULT_API_BASE_URL)
|
|
105
105
|
.post(`/v1/apps/${appId}/destinations`, {
|
|
106
106
|
appId,
|
|
@@ -110,7 +110,7 @@ describe('apps-destinations-create', () => {
|
|
|
110
110
|
appleTeamId: 'team-id',
|
|
111
111
|
appleApiKeyId: 'ABC123DEFG',
|
|
112
112
|
appleIssuerId: 'issuer-id',
|
|
113
|
-
appAppleApiKeyId
|
|
113
|
+
appAppleApiKeyId,
|
|
114
114
|
appleBetaGroups: ['External Testers', 'QA'],
|
|
115
115
|
appleRejectIfPossible: false,
|
|
116
116
|
appleReleaseType: 'after-approval',
|
|
@@ -123,6 +123,70 @@ describe('apps-destinations-create', () => {
|
|
|
123
123
|
expect(destinationScope.isDone()).toBe(true);
|
|
124
124
|
expect(mockPrompt).toHaveBeenCalledTimes(1);
|
|
125
125
|
});
|
|
126
|
+
it('should create an App Store Connect destination with the Apple API key ID non-interactively', async () => {
|
|
127
|
+
const appleApiKeyFile = path.join(tempDirectory, 'AuthKey.p8');
|
|
128
|
+
await fs.writeFile(appleApiKeyFile, 'private-key');
|
|
129
|
+
const options = {
|
|
130
|
+
appId,
|
|
131
|
+
name: 'App Store',
|
|
132
|
+
platform: 'ios',
|
|
133
|
+
type: 'apple-app-store-connect',
|
|
134
|
+
appleApiKeyFile,
|
|
135
|
+
appleApiKeyId: 'ABC123DEFG',
|
|
136
|
+
appleIssuerId: 'issuer-id',
|
|
137
|
+
appleTeamId: 'team-id',
|
|
138
|
+
};
|
|
139
|
+
const keyScope = nock(DEFAULT_API_BASE_URL)
|
|
140
|
+
.post(`/v1/apps/${appId}/apple-api-keys`)
|
|
141
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
142
|
+
.reply(201, { id: appAppleApiKeyId });
|
|
143
|
+
const destinationScope = nock(DEFAULT_API_BASE_URL)
|
|
144
|
+
.post(`/v1/apps/${appId}/destinations`, {
|
|
145
|
+
appId,
|
|
146
|
+
name: 'App Store',
|
|
147
|
+
platform: 'ios',
|
|
148
|
+
type: 'apple-app-store-connect',
|
|
149
|
+
appleTeamId: 'team-id',
|
|
150
|
+
appleApiKeyId: 'ABC123DEFG',
|
|
151
|
+
appleIssuerId: 'issuer-id',
|
|
152
|
+
appAppleApiKeyId,
|
|
153
|
+
})
|
|
154
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
155
|
+
.reply(201, { id: destinationId });
|
|
156
|
+
await createDestinationCommand.action(options, undefined);
|
|
157
|
+
expect(keyScope.isDone()).toBe(true);
|
|
158
|
+
expect(destinationScope.isDone()).toBe(true);
|
|
159
|
+
expect(mockPrompt).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
it('should require the Apple API key file when only the Apple API key ID is provided non-interactively', async () => {
|
|
162
|
+
const options = {
|
|
163
|
+
appId,
|
|
164
|
+
name: 'App Store',
|
|
165
|
+
platform: 'ios',
|
|
166
|
+
type: 'apple-app-store-connect',
|
|
167
|
+
appleApiKeyId: 'ABC123DEFG',
|
|
168
|
+
};
|
|
169
|
+
await expect(createDestinationCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
170
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must provide the Apple API key file when running in non-interactive environment.');
|
|
171
|
+
});
|
|
172
|
+
it('should not upload the Apple API key file when required options are missing non-interactively', async () => {
|
|
173
|
+
const appleApiKeyFile = path.join(tempDirectory, 'AuthKey.p8');
|
|
174
|
+
await fs.writeFile(appleApiKeyFile, 'private-key');
|
|
175
|
+
const options = {
|
|
176
|
+
appId,
|
|
177
|
+
name: 'App Store',
|
|
178
|
+
platform: 'ios',
|
|
179
|
+
type: 'apple-app-store-connect',
|
|
180
|
+
appleApiKeyFile,
|
|
181
|
+
appleApiKeyId: 'ABC123DEFG',
|
|
182
|
+
appleIssuerId: 'issuer-id',
|
|
183
|
+
};
|
|
184
|
+
const keyScope = nock(DEFAULT_API_BASE_URL)
|
|
185
|
+
.post(`/v1/apps/${appId}/apple-api-keys`)
|
|
186
|
+
.reply(201, { id: appAppleApiKeyId });
|
|
187
|
+
await expect(createDestinationCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
188
|
+
expect(keyScope.isDone()).toBe(false);
|
|
189
|
+
});
|
|
126
190
|
it('should map the deprecated destination type app-store-connect to apple-app-store-connect', async () => {
|
|
127
191
|
mockIsInteractive.mockReturnValue(true);
|
|
128
192
|
mockPrompt.mockResolvedValueOnce('ABC123DEFG');
|
|
@@ -144,7 +208,7 @@ describe('apps-destinations-create', () => {
|
|
|
144
208
|
const keyScope = nock(DEFAULT_API_BASE_URL)
|
|
145
209
|
.post(`/v1/apps/${appId}/apple-api-keys`)
|
|
146
210
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
147
|
-
.reply(201, { id:
|
|
211
|
+
.reply(201, { id: appAppleApiKeyId });
|
|
148
212
|
const destinationScope = nock(DEFAULT_API_BASE_URL)
|
|
149
213
|
.post(`/v1/apps/${appId}/destinations`, {
|
|
150
214
|
appId,
|
|
@@ -154,7 +218,7 @@ describe('apps-destinations-create', () => {
|
|
|
154
218
|
appleTeamId: 'team-id',
|
|
155
219
|
appleApiKeyId: 'ABC123DEFG',
|
|
156
220
|
appleIssuerId: 'issuer-id',
|
|
157
|
-
appAppleApiKeyId
|
|
221
|
+
appAppleApiKeyId,
|
|
158
222
|
appleBetaGroups: ['External Testers', 'QA'],
|
|
159
223
|
appleRejectIfPossible: false,
|
|
160
224
|
appleReleaseType: 'after-approval',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import appDestinationsService from '../../../services/app-destinations.js';
|
|
2
2
|
import { withAuth } from '../../../utils/auth.js';
|
|
3
|
-
import {
|
|
3
|
+
import { clearableStringOption, parseListOption } from '../../../utils/cli-options.js';
|
|
4
4
|
import { isInteractive } from '../../../utils/environment.js';
|
|
5
5
|
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
|
|
6
6
|
import consola from 'consola';
|
|
@@ -61,10 +61,7 @@ export default defineCommand({
|
|
|
61
61
|
.string()
|
|
62
62
|
.optional()
|
|
63
63
|
.describe('Huawei AppGallery Connect API client secret for the destination.'),
|
|
64
|
-
defaultLanguage:
|
|
65
|
-
.string()
|
|
66
|
-
.optional()
|
|
67
|
-
.describe('Language of the default release notes text, e.g. `en-US` (Google Play and Huawei AppGallery only). Pass `--default-language=` to clear it.'),
|
|
64
|
+
defaultLanguage: clearableStringOption.describe('Language of the default release notes text, e.g. `en-US` (Google Play and Huawei AppGallery only). Pass `--default-language=` to clear it.'),
|
|
68
65
|
})),
|
|
69
66
|
action: withAuth(async (options, args) => {
|
|
70
67
|
let { appId, destinationId, name, appleId, appleAppId, appleTeamId, appleAppPassword, appleApiKeyId, appleIssuerId, appAppleApiKeyId, appleBetaGroup, appleSubmitForReview, appleReleaseType, appleRejectIfPossible, androidPackageName, androidBuildArtifactType, androidReleaseStatus, appGoogleServiceAccountKeyId, googlePlayTrack, firebaseAppId, firebaseTesterGroup, huaweiAppId, huaweiClientId, huaweiClientSecret, defaultLanguage, } = options;
|
|
@@ -108,7 +105,7 @@ export default defineCommand({
|
|
|
108
105
|
huaweiAppId,
|
|
109
106
|
huaweiClientId,
|
|
110
107
|
huaweiClientSecret,
|
|
111
|
-
defaultLanguage
|
|
108
|
+
defaultLanguage,
|
|
112
109
|
});
|
|
113
110
|
consola.success('Destination updated successfully.');
|
|
114
111
|
}),
|
|
@@ -92,7 +92,7 @@ describe('apps-destinations-update', () => {
|
|
|
92
92
|
expect(scope.isDone()).toBe(true);
|
|
93
93
|
});
|
|
94
94
|
it('should clear the default language when `--default-language=` is passed', async () => {
|
|
95
|
-
const options = { appId, destinationId, defaultLanguage: '' };
|
|
95
|
+
const options = updateDestinationCommand.options.schema.parse({ appId, destinationId, defaultLanguage: '' });
|
|
96
96
|
const scope = nock(DEFAULT_API_BASE_URL)
|
|
97
97
|
.patch(`/v1/apps/${appId}/destinations/${destinationId}`, { appId, destinationId, defaultLanguage: null })
|
|
98
98
|
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
@@ -6,6 +6,7 @@ import appEnvironmentsService from '../../../services/app-environments.js';
|
|
|
6
6
|
import configService from '../../../services/config.js';
|
|
7
7
|
import { parseKeyValuePairs } from '../../../utils/app-environments.js';
|
|
8
8
|
import { withAuth } from '../../../utils/auth.js';
|
|
9
|
+
import { parseListOption, stringOption } from '../../../utils/cli-options.js';
|
|
9
10
|
import { parseCustomProperties } from '../../../utils/custom-properties.js';
|
|
10
11
|
import { isInteractive } from '../../../utils/environment.js';
|
|
11
12
|
import { isReadable } from '../../../utils/file.js';
|
|
@@ -29,17 +30,17 @@ export default defineCommand({
|
|
|
29
30
|
})
|
|
30
31
|
.optional()
|
|
31
32
|
.describe('App ID to create the live update for.'),
|
|
32
|
-
certificate:
|
|
33
|
+
certificate: stringOption.describe('The name of the certificate to use for the build.'),
|
|
33
34
|
channel: z
|
|
34
35
|
.array(z.string())
|
|
35
36
|
.optional()
|
|
36
|
-
.describe('The name of the channel to deploy to. Can be specified multiple times.'),
|
|
37
|
+
.describe('The name of the channel to deploy to. Can be specified multiple times or comma-separated.'),
|
|
37
38
|
customProperty: z
|
|
38
39
|
.array(z.string().min(1).max(100))
|
|
39
40
|
.max(10)
|
|
40
41
|
.optional()
|
|
41
42
|
.describe('A custom property to assign to the build. Must be in the format `key=value`. Can be specified multiple times.'),
|
|
42
|
-
environment:
|
|
43
|
+
environment: stringOption.describe('The name of the environment to use for the build.'),
|
|
43
44
|
gitRef: z.string().optional().describe('The Git reference (branch, tag, or commit SHA) to build.'),
|
|
44
45
|
iosEq: z.string().optional().describe('The exact iOS CFBundleVersion for the live update.'),
|
|
45
46
|
iosMax: z.string().optional().describe('The maximum iOS CFBundleVersion for the live update.'),
|
|
@@ -71,7 +72,8 @@ export default defineCommand({
|
|
|
71
72
|
yes: z.boolean().optional().describe('Skip confirmation prompts.'),
|
|
72
73
|
}), { y: 'yes' }),
|
|
73
74
|
action: withAuth(async (options) => {
|
|
74
|
-
let { appId, certificate,
|
|
75
|
+
let { appId, certificate, gitRef, environment, json, stack, path: sourcePath, url } = options;
|
|
76
|
+
let channel = parseListOption(options.channel);
|
|
75
77
|
// Validate that path, url, and gitRef cannot be used together
|
|
76
78
|
if (sourcePath && gitRef) {
|
|
77
79
|
consola.error('The --path and --git-ref flags cannot be used together.');
|
|
@@ -3,6 +3,7 @@ import { promises as fs } from 'fs';
|
|
|
3
3
|
import pathModule from 'path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { isInteractive } from '../../../utils/environment.js';
|
|
6
|
+
import { writeFile } from '../../../utils/file.js';
|
|
6
7
|
import { prompt } from '../../../utils/prompt.js';
|
|
7
8
|
import { defineCommand, defineOptions } from 'zodline';
|
|
8
9
|
const APP_TYPES = ['capacitor', 'cordova'];
|
|
@@ -54,8 +55,8 @@ export default defineCommand({
|
|
|
54
55
|
await fs.mkdir(pathModule.dirname(absolutePublicKeyPath), { recursive: true });
|
|
55
56
|
await fs.mkdir(pathModule.dirname(absolutePrivateKeyPath), { recursive: true });
|
|
56
57
|
// Write the keys to files
|
|
57
|
-
await
|
|
58
|
-
await
|
|
58
|
+
await writeFile(absolutePublicKeyPath, publicKey);
|
|
59
|
+
await writeFile(absolutePrivateKeyPath, privateKey);
|
|
59
60
|
consola.log('');
|
|
60
61
|
consola.log('Public key saved to: ' + absolutePublicKeyPath);
|
|
61
62
|
consola.log('Private key saved to: ' + absolutePrivateKeyPath);
|
|
@@ -2,6 +2,7 @@ import appBundlesService from '../../../services/app-bundles.js';
|
|
|
2
2
|
import appsService from '../../../services/apps.js';
|
|
3
3
|
import configService from '../../../services/config.js';
|
|
4
4
|
import { withAuth } from '../../../utils/auth.js';
|
|
5
|
+
import { stringOption } from '../../../utils/cli-options.js';
|
|
5
6
|
import { parseCustomProperties } from '../../../utils/custom-properties.js';
|
|
6
7
|
import { createBufferFromPath, createBufferFromString, isPrivateKeyContent } from '../../../utils/buffer.js';
|
|
7
8
|
import { isInteractive } from '../../../utils/environment.js';
|
|
@@ -38,7 +39,7 @@ export default defineCommand({
|
|
|
38
39
|
})
|
|
39
40
|
.optional()
|
|
40
41
|
.describe('App ID to deploy to.'),
|
|
41
|
-
channel:
|
|
42
|
+
channel: stringOption.describe('Channel to associate the bundle with.'),
|
|
42
43
|
commitMessage: z
|
|
43
44
|
.string()
|
|
44
45
|
.optional()
|
|
@@ -4,6 +4,7 @@ import appBundlesService from '../../../services/app-bundles.js';
|
|
|
4
4
|
import appsService from '../../../services/apps.js';
|
|
5
5
|
import configService from '../../../services/config.js';
|
|
6
6
|
import { withAuth } from '../../../utils/auth.js';
|
|
7
|
+
import { stringOption } from '../../../utils/cli-options.js';
|
|
7
8
|
import { parseCustomProperties } from '../../../utils/custom-properties.js';
|
|
8
9
|
import { defineCommand, defineOptions } from 'zodline';
|
|
9
10
|
import { createBufferFromPath, createBufferFromReadStream, createBufferFromString, isPrivateKeyContent, } from '../../../utils/buffer.js';
|
|
@@ -50,7 +51,7 @@ export default defineCommand({
|
|
|
50
51
|
.optional()
|
|
51
52
|
.describe('The type of artifact to deploy. Must be either `manifest` or `zip`. The default is `zip`.')
|
|
52
53
|
.default('zip'),
|
|
53
|
-
channel:
|
|
54
|
+
channel: stringOption.describe('Channel to associate the bundle with.'),
|
|
54
55
|
commitMessage: z
|
|
55
56
|
.string()
|
|
56
57
|
.optional()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import appsService from '../../services/apps.js';
|
|
2
2
|
import { withAuth } from '../../utils/auth.js';
|
|
3
|
-
import { clearableValue } from '../../utils/cli-options.js';
|
|
3
|
+
import { clearableStringOption, clearableValue } from '../../utils/cli-options.js';
|
|
4
4
|
import { isInteractive } from '../../utils/environment.js';
|
|
5
5
|
import { promptAppSelection, promptOrganizationSelection } from '../../utils/prompt.js';
|
|
6
6
|
import consola from 'consola';
|
|
@@ -10,24 +10,15 @@ export default defineCommand({
|
|
|
10
10
|
description: 'Update an existing app.',
|
|
11
11
|
options: defineOptions(z.object({
|
|
12
12
|
appId: z.string().optional().describe('ID of the app.'),
|
|
13
|
-
channel:
|
|
14
|
-
.string()
|
|
15
|
-
.optional()
|
|
16
|
-
.describe('The name of the default channel for live updates. Pass an empty string to clear it.'),
|
|
13
|
+
channel: clearableStringOption.describe('The name of the default channel for live updates. Pass an empty string to clear it.'),
|
|
17
14
|
channelDiscovery: z
|
|
18
15
|
.union([z.boolean(), z.stringbool()], {
|
|
19
16
|
message: 'Channel discovery must be either `true` or `false`.',
|
|
20
17
|
})
|
|
21
18
|
.optional()
|
|
22
19
|
.describe('Enable or disable channel discovery. Supported values are `true` and `false`.'),
|
|
23
|
-
configuration:
|
|
24
|
-
|
|
25
|
-
.optional()
|
|
26
|
-
.describe('The name of the default native configuration. Pass an empty string to clear it.'),
|
|
27
|
-
environment: z
|
|
28
|
-
.string()
|
|
29
|
-
.optional()
|
|
30
|
-
.describe('The name of the default environment. Pass an empty string to clear it.'),
|
|
20
|
+
configuration: clearableStringOption.describe('The name of the default native configuration. Pass an empty string to clear it.'),
|
|
21
|
+
environment: clearableStringOption.describe('The name of the default environment. Pass an empty string to clear it.'),
|
|
31
22
|
json: z.boolean().optional().describe('Output in JSON format.'),
|
|
32
23
|
name: z.string().optional().describe('Name of the app.'),
|
|
33
24
|
nextBuildNumber: z.coerce
|
|
@@ -60,9 +51,9 @@ export default defineCommand({
|
|
|
60
51
|
}
|
|
61
52
|
const app = await appsService.update({
|
|
62
53
|
appChannelDiscoveryEnabled: options.channelDiscovery,
|
|
63
|
-
appChannelName:
|
|
64
|
-
appConfigurationName:
|
|
65
|
-
appEnvironmentName:
|
|
54
|
+
appChannelName: options.channel,
|
|
55
|
+
appConfigurationName: options.configuration,
|
|
56
|
+
appEnvironmentName: options.environment,
|
|
66
57
|
appId,
|
|
67
58
|
buildStack: clearableValue(options.stack),
|
|
68
59
|
name: options.name,
|
|
@@ -61,7 +61,7 @@ describe('apps-update', () => {
|
|
|
61
61
|
});
|
|
62
62
|
it('should clear defaults when an empty string is passed', async () => {
|
|
63
63
|
const appId = 'app-123';
|
|
64
|
-
const options = { appId, channel: '', environment: '', stack: '' };
|
|
64
|
+
const options = updateAppCommand.options.schema.parse({ appId, channel: '', environment: '', stack: '' });
|
|
65
65
|
const scope = nock(DEFAULT_API_BASE_URL)
|
|
66
66
|
.patch(`/v1/apps/${appId}`, {
|
|
67
67
|
appChannelName: null,
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const stringOption = z
|
|
3
|
+
.string()
|
|
4
|
+
.trim()
|
|
5
|
+
.transform((value) => value || undefined)
|
|
6
|
+
.optional();
|
|
7
|
+
export const clearableStringOption = z
|
|
8
|
+
.string()
|
|
9
|
+
.trim()
|
|
10
|
+
.transform((value) => value || null)
|
|
11
|
+
.optional();
|
|
1
12
|
export const clearableValue = (value) => value === '' ? null : value;
|
|
2
13
|
export const parseListOption = (values) => {
|
|
3
14
|
return values
|
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { parseListOption } from './cli-options.js';
|
|
2
|
+
import { clearableStringOption, parseListOption, stringOption } from './cli-options.js';
|
|
3
|
+
describe('stringOption', () => {
|
|
4
|
+
it('should return undefined if the option is not passed', () => {
|
|
5
|
+
expect(stringOption.parse(undefined)).toBeUndefined();
|
|
6
|
+
});
|
|
7
|
+
it('should return undefined for an empty value', () => {
|
|
8
|
+
expect(stringOption.parse('')).toBeUndefined();
|
|
9
|
+
});
|
|
10
|
+
it('should return undefined for a whitespace-only value', () => {
|
|
11
|
+
expect(stringOption.parse(' ')).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
it('should trim the value', () => {
|
|
14
|
+
expect(stringOption.parse(' production ')).toBe('production');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
describe('clearableStringOption', () => {
|
|
18
|
+
it('should return undefined if the option is not passed', () => {
|
|
19
|
+
expect(clearableStringOption.parse(undefined)).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
it('should return null for an empty value', () => {
|
|
22
|
+
expect(clearableStringOption.parse('')).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
it('should return null for a whitespace-only value', () => {
|
|
25
|
+
expect(clearableStringOption.parse(' ')).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
it('should trim the value', () => {
|
|
28
|
+
expect(clearableStringOption.parse(' production ')).toBe('production');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
3
31
|
describe('parseListOption', () => {
|
|
4
32
|
it('should return undefined if the option is not passed', () => {
|
|
5
33
|
expect(parseListOption(undefined)).toBeUndefined();
|
package/dist/utils/file.js
CHANGED
|
@@ -4,12 +4,13 @@ import pathModule from 'path';
|
|
|
4
4
|
import { createBufferFromPath } from './buffer.js';
|
|
5
5
|
import { getCodeFromUnknownError, UserError } from './error.js';
|
|
6
6
|
const concurrentModificationHint = 'Make sure that no other process (e.g. a build or file sync client) modifies the folder while the command is running.';
|
|
7
|
-
const
|
|
7
|
+
const readPermissionHint = 'Make sure that you have permission to read the file.';
|
|
8
|
+
const writePermissionHint = 'Make sure that you have permission to write to the folder or choose a different path.';
|
|
8
9
|
const unreadableFileErrorHints = {
|
|
9
|
-
EACCES:
|
|
10
|
+
EACCES: readPermissionHint,
|
|
10
11
|
EBUSY: concurrentModificationHint,
|
|
11
12
|
ENOENT: concurrentModificationHint,
|
|
12
|
-
EPERM:
|
|
13
|
+
EPERM: readPermissionHint,
|
|
13
14
|
};
|
|
14
15
|
export const getFilesInDirectoryAndSubdirectories = async (path) => {
|
|
15
16
|
const files = [];
|
|
@@ -95,14 +96,14 @@ export const isDirectory = async (path) => {
|
|
|
95
96
|
});
|
|
96
97
|
};
|
|
97
98
|
export const writeFile = async (path, data) => {
|
|
98
|
-
|
|
99
|
-
fs.writeFile(path, data
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
99
|
+
try {
|
|
100
|
+
await fs.promises.writeFile(path, data);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const code = getCodeFromUnknownError(error);
|
|
104
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
105
|
+
throw new UserError(`The file could not be written: ${path}. ${writePermissionHint}`);
|
|
106
|
+
}
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
108
109
|
};
|
package/dist/utils/file.test.js
CHANGED
|
@@ -3,7 +3,7 @@ import os from 'os';
|
|
|
3
3
|
import pathModule from 'path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { UserError } from './error.js';
|
|
6
|
-
import { readFileFromDirectory } from './file.js';
|
|
6
|
+
import { readFileFromDirectory, writeFile } from './file.js';
|
|
7
7
|
const { mockCreateBufferFromPath } = vi.hoisted(() => ({ mockCreateBufferFromPath: vi.fn() }));
|
|
8
8
|
vi.mock('./buffer.js', async (importOriginal) => {
|
|
9
9
|
const actual = await importOriginal();
|
|
@@ -44,3 +44,30 @@ describe('readFileFromDirectory', () => {
|
|
|
44
44
|
await expect(readFileFromDirectory(pathModule.join(directory, 'assets'))).rejects.toBe(error);
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
|
+
describe('writeFile', () => {
|
|
48
|
+
let directory;
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
directory = fs.mkdtempSync(pathModule.join(os.tmpdir(), 'file-'));
|
|
51
|
+
});
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.restoreAllMocks();
|
|
54
|
+
fs.rmSync(directory, { force: true, recursive: true });
|
|
55
|
+
});
|
|
56
|
+
it('should write the file', async () => {
|
|
57
|
+
const path = pathModule.join(directory, 'public.pem');
|
|
58
|
+
await writeFile(path, 'key');
|
|
59
|
+
expect(fs.readFileSync(path, 'utf8')).toBe('key');
|
|
60
|
+
});
|
|
61
|
+
it.each(['EACCES', 'EPERM'])('should throw a user error if the file is not writable (%s)', async (code) => {
|
|
62
|
+
const path = pathModule.join(directory, 'public.pem');
|
|
63
|
+
vi.spyOn(fs.promises, 'writeFile').mockRejectedValueOnce(createErrorWithCode(code));
|
|
64
|
+
const promise = writeFile(path, 'key');
|
|
65
|
+
await expect(promise).rejects.toThrow(UserError);
|
|
66
|
+
await expect(promise).rejects.toThrow(`The file could not be written: ${path}. Make sure that you have permission to write to the folder or choose a different path.`);
|
|
67
|
+
});
|
|
68
|
+
it('should rethrow errors that are not related to permissions', async () => {
|
|
69
|
+
const error = createErrorWithCode('EISDIR');
|
|
70
|
+
vi.spyOn(fs.promises, 'writeFile').mockRejectedValueOnce(error);
|
|
71
|
+
await expect(writeFile(directory, 'key')).rejects.toBe(error);
|
|
72
|
+
});
|
|
73
|
+
});
|