@capawesome/cli 4.6.0 → 4.8.0-dev.efa0850.1775645973
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 +35 -0
- package/dist/commands/apps/builds/create.js +160 -128
- package/dist/commands/apps/bundles/create.js +4 -2
- package/dist/commands/apps/bundles/delete.js +2 -3
- package/dist/commands/apps/bundles/update.js +2 -3
- package/dist/commands/apps/certificates/create.js +3 -19
- package/dist/commands/apps/certificates/delete.js +28 -5
- package/dist/commands/apps/certificates/get.js +28 -5
- package/dist/commands/apps/certificates/update.js +3 -1
- package/dist/commands/apps/create.js +23 -4
- package/dist/commands/apps/deployments/create.js +5 -77
- package/dist/commands/apps/devices/forcechannel.js +9 -7
- package/dist/commands/apps/devices/unforcechannel.js +9 -7
- package/dist/commands/apps/link.js +34 -0
- package/dist/commands/apps/link.test.js +94 -0
- package/dist/commands/apps/liveupdates/bundle.js +12 -2
- package/dist/commands/apps/liveupdates/create.js +293 -0
- package/dist/commands/apps/liveupdates/create.test.js +300 -0
- package/dist/commands/apps/liveupdates/generate-manifest.js +17 -1
- package/dist/commands/apps/liveupdates/generate-manifest.test.js +21 -1
- package/dist/commands/apps/liveupdates/register.js +10 -15
- package/dist/commands/apps/liveupdates/upload.js +25 -16
- package/dist/commands/apps/transfer.js +47 -0
- package/dist/commands/apps/transfer.test.js +123 -0
- package/dist/commands/apps/unlink.js +35 -0
- package/dist/commands/apps/unlink.test.js +99 -0
- package/dist/commands/manifests/generate.js +1 -1
- package/dist/index.js +13 -5
- package/dist/services/app-build-sources.js +120 -0
- package/dist/services/app-certificates.js +0 -1
- package/dist/services/app-devices.js +8 -0
- package/dist/services/apps.js +25 -0
- package/dist/services/authorization-service.js +5 -1
- package/dist/services/jobs.js +13 -0
- package/dist/types/app-build-source.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/custom-properties.js +22 -0
- package/dist/utils/error.js +6 -0
- package/dist/utils/file.js +12 -1
- package/dist/utils/git.js +92 -0
- package/dist/utils/git.test.js +130 -0
- package/dist/utils/job.js +77 -0
- package/dist/utils/prompt.js +1 -1
- package/dist/utils/zip.js +19 -2
- package/package.json +2 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
|
|
2
|
+
import appBuildSourcesService from '../../../services/app-build-sources.js';
|
|
3
|
+
import appBuildsService from '../../../services/app-builds.js';
|
|
4
|
+
import appCertificatesService from '../../../services/app-certificates.js';
|
|
5
|
+
import appDeploymentsService from '../../../services/app-deployments.js';
|
|
6
|
+
import appEnvironmentsService from '../../../services/app-environments.js';
|
|
7
|
+
import { parseKeyValuePairs } from '../../../utils/app-environments.js';
|
|
8
|
+
import { withAuth } from '../../../utils/auth.js';
|
|
9
|
+
import { parseCustomProperties } from '../../../utils/custom-properties.js';
|
|
10
|
+
import { isInteractive } from '../../../utils/environment.js';
|
|
11
|
+
import { waitForJobCompletion } from '../../../utils/job.js';
|
|
12
|
+
import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
|
|
13
|
+
import zip from '../../../utils/zip.js';
|
|
14
|
+
import { defineCommand, defineOptions } from '@robingenz/zli';
|
|
15
|
+
import consola from 'consola';
|
|
16
|
+
import fs from 'fs/promises';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
export default defineCommand({
|
|
20
|
+
description: 'Create a new live update by building and deploying web assets using Capawesome Cloud Runners.',
|
|
21
|
+
options: defineOptions(z.object({
|
|
22
|
+
androidEq: z.string().optional().describe('The exact Android versionCode for the live update.'),
|
|
23
|
+
androidMax: z.string().optional().describe('The maximum Android versionCode for the live update.'),
|
|
24
|
+
androidMin: z.string().optional().describe('The minimum Android versionCode for the live update.'),
|
|
25
|
+
appId: z
|
|
26
|
+
.uuid({
|
|
27
|
+
message: 'App ID must be a UUID.',
|
|
28
|
+
})
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('App ID to create the live update for.'),
|
|
31
|
+
certificate: z.string().optional().describe('The name of the certificate to use for the build.'),
|
|
32
|
+
channel: z
|
|
33
|
+
.array(z.string())
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('The name of the channel to deploy to. Can be specified multiple times.'),
|
|
36
|
+
customProperty: z
|
|
37
|
+
.array(z.string().min(1).max(100))
|
|
38
|
+
.max(10)
|
|
39
|
+
.optional()
|
|
40
|
+
.describe('A custom property to assign to the build. Must be in the format `key=value`. Can be specified multiple times.'),
|
|
41
|
+
environment: z.string().optional().describe('The name of the environment to use for the build.'),
|
|
42
|
+
gitRef: z.string().optional().describe('The Git reference (branch, tag, or commit SHA) to build.'),
|
|
43
|
+
iosEq: z.string().optional().describe('The exact iOS CFBundleVersion for the live update.'),
|
|
44
|
+
iosMax: z.string().optional().describe('The maximum iOS CFBundleVersion for the live update.'),
|
|
45
|
+
iosMin: z.string().optional().describe('The minimum iOS CFBundleVersion for the live update.'),
|
|
46
|
+
json: z.boolean().optional().describe('Output in JSON format.'),
|
|
47
|
+
path: z.string().optional().describe('Path to local source files to upload.'),
|
|
48
|
+
rolloutPercentage: z.coerce
|
|
49
|
+
.number()
|
|
50
|
+
.int()
|
|
51
|
+
.min(0)
|
|
52
|
+
.max(100)
|
|
53
|
+
.optional()
|
|
54
|
+
.describe('The rollout percentage for the deployment (0-100). Default: 100.'),
|
|
55
|
+
stack: z
|
|
56
|
+
.enum(['macos-sequoia', 'macos-tahoe'], {
|
|
57
|
+
message: 'Build stack must be either `macos-sequoia` or `macos-tahoe`.',
|
|
58
|
+
})
|
|
59
|
+
.optional()
|
|
60
|
+
.describe('The build stack to use for the build process.'),
|
|
61
|
+
url: z.string().optional().describe('URL to a zip file to use as build source.'),
|
|
62
|
+
variable: z
|
|
63
|
+
.array(z.string())
|
|
64
|
+
.optional()
|
|
65
|
+
.describe('Ad hoc environment variable in key=value format. Can be specified multiple times.'),
|
|
66
|
+
variableFile: z
|
|
67
|
+
.string()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('Path to a file containing ad hoc environment variables in .env format.'),
|
|
70
|
+
yes: z.boolean().optional().describe('Skip confirmation prompts.'),
|
|
71
|
+
}), { y: 'yes' }),
|
|
72
|
+
action: withAuth(async (options) => {
|
|
73
|
+
let { appId, certificate, channel, gitRef, environment, json, stack, path: sourcePath, url } = options;
|
|
74
|
+
// Validate that path, url, and gitRef cannot be used together
|
|
75
|
+
if (sourcePath && gitRef) {
|
|
76
|
+
consola.error('The --path and --git-ref flags cannot be used together.');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
if (url && gitRef) {
|
|
80
|
+
consola.error('The --url and --git-ref flags cannot be used together.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
if (url && sourcePath) {
|
|
84
|
+
consola.error('The --url and --path flags cannot be used together.');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
// Validate url if provided
|
|
88
|
+
if (url) {
|
|
89
|
+
consola.warn('The --url option is experimental and may change in the future.');
|
|
90
|
+
}
|
|
91
|
+
// Validate path if provided
|
|
92
|
+
if (sourcePath) {
|
|
93
|
+
consola.warn('The --path option is experimental and may change in the future.');
|
|
94
|
+
const resolvedPath = path.resolve(sourcePath);
|
|
95
|
+
const stat = await fs.stat(resolvedPath).catch(() => null);
|
|
96
|
+
if (!stat || !stat.isDirectory()) {
|
|
97
|
+
consola.error('The --path must point to an existing directory.');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const packageJsonPath = path.join(resolvedPath, 'package.json');
|
|
101
|
+
const packageJsonStat = await fs.stat(packageJsonPath).catch(() => null);
|
|
102
|
+
if (!packageJsonStat || !packageJsonStat.isFile()) {
|
|
103
|
+
consola.error('The directory specified by --path must contain a package.json file.');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Prompt for app ID if not provided
|
|
108
|
+
if (!appId) {
|
|
109
|
+
if (!isInteractive()) {
|
|
110
|
+
consola.error('You must provide an app ID when running in non-interactive environment.');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const organizationId = await promptOrganizationSelection({ allowCreate: true });
|
|
114
|
+
appId = await promptAppSelection(organizationId, { allowCreate: true });
|
|
115
|
+
}
|
|
116
|
+
// Prompt for git ref if not provided and no path or url specified
|
|
117
|
+
if (!sourcePath && !url && !gitRef) {
|
|
118
|
+
if (!isInteractive()) {
|
|
119
|
+
consola.error('You must provide a git ref, path, or url when running in non-interactive environment.');
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
gitRef = await prompt('Enter the Git reference (branch, tag, or commit SHA):', {
|
|
123
|
+
type: 'text',
|
|
124
|
+
});
|
|
125
|
+
if (!gitRef) {
|
|
126
|
+
consola.error('You must provide a git ref.');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Prompt for channel if not provided
|
|
131
|
+
if (!channel || channel.length === 0) {
|
|
132
|
+
if (!isInteractive()) {
|
|
133
|
+
consola.error('You must provide at least one channel when running in non-interactive environment.');
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
const channelName = await prompt('Enter the channel name to deploy to:', {
|
|
137
|
+
type: 'text',
|
|
138
|
+
});
|
|
139
|
+
if (!channelName) {
|
|
140
|
+
consola.error('You must provide a channel.');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
channel = [channelName];
|
|
144
|
+
}
|
|
145
|
+
// Prompt for environment if not provided
|
|
146
|
+
if (!environment && !options.yes && isInteractive()) {
|
|
147
|
+
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
|
|
148
|
+
const selectEnvironment = await prompt('Do you want to select an environment?', {
|
|
149
|
+
type: 'confirm',
|
|
150
|
+
initial: false,
|
|
151
|
+
});
|
|
152
|
+
if (selectEnvironment) {
|
|
153
|
+
const environments = await appEnvironmentsService.findAll({ appId });
|
|
154
|
+
if (environments.length === 0) {
|
|
155
|
+
consola.warn('No environments found for this app.');
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
|
|
159
|
+
environment = await prompt('Select the environment for the build:', {
|
|
160
|
+
type: 'select',
|
|
161
|
+
options: environments.map((env) => ({ label: env.name, value: env.name })),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Prompt for certificate if not provided
|
|
167
|
+
if (!certificate && !options.yes && isInteractive()) {
|
|
168
|
+
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
|
|
169
|
+
const selectCertificate = await prompt('Do you want to select a certificate?', {
|
|
170
|
+
type: 'confirm',
|
|
171
|
+
initial: false,
|
|
172
|
+
});
|
|
173
|
+
if (selectCertificate) {
|
|
174
|
+
const certificates = await appCertificatesService.findAll({ appId, platform: 'web' });
|
|
175
|
+
if (certificates.length === 0) {
|
|
176
|
+
consola.warn('No certificates found for this app.');
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
|
|
180
|
+
certificate = await prompt('Select the certificate for the build:', {
|
|
181
|
+
type: 'select',
|
|
182
|
+
options: certificates.map((cert) => ({ label: cert.name, value: cert.name })),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Parse ad hoc environment variables from inline and file
|
|
188
|
+
const variablesMap = new Map();
|
|
189
|
+
if (options.variableFile) {
|
|
190
|
+
const fileContent = await fs.readFile(options.variableFile, 'utf-8');
|
|
191
|
+
const fileVariables = parseKeyValuePairs(fileContent);
|
|
192
|
+
fileVariables.forEach((v) => variablesMap.set(v.key, v.value));
|
|
193
|
+
}
|
|
194
|
+
if (options.variable) {
|
|
195
|
+
const inlineVariables = parseKeyValuePairs(options.variable.join('\n'));
|
|
196
|
+
inlineVariables.forEach((v) => variablesMap.set(v.key, v.value));
|
|
197
|
+
}
|
|
198
|
+
const adHocEnvironmentVariables = variablesMap.size > 0 ? Object.fromEntries(variablesMap) : undefined;
|
|
199
|
+
// Create build source from URL if provided
|
|
200
|
+
let appBuildSourceId;
|
|
201
|
+
if (url) {
|
|
202
|
+
consola.start('Creating build source from URL...');
|
|
203
|
+
const appBuildSource = await appBuildSourcesService.createFromUrl({ appId, fileUrl: url });
|
|
204
|
+
appBuildSourceId = appBuildSource.id;
|
|
205
|
+
consola.success('Build source created successfully.');
|
|
206
|
+
}
|
|
207
|
+
// Upload source files if path is provided
|
|
208
|
+
if (sourcePath) {
|
|
209
|
+
const resolvedPath = path.resolve(sourcePath);
|
|
210
|
+
consola.start('Zipping source files...');
|
|
211
|
+
const buffer = await zip.zipFolderWithGitignore(resolvedPath);
|
|
212
|
+
consola.start('Uploading source files...');
|
|
213
|
+
const appBuildSource = await appBuildSourcesService.createFromFile({
|
|
214
|
+
appId,
|
|
215
|
+
fileSizeInBytes: buffer.byteLength,
|
|
216
|
+
buffer,
|
|
217
|
+
name: 'source.zip',
|
|
218
|
+
}, (currentPart, totalParts) => {
|
|
219
|
+
consola.start(`Uploading source files (${currentPart}/${totalParts})...`);
|
|
220
|
+
});
|
|
221
|
+
appBuildSourceId = appBuildSource.id;
|
|
222
|
+
consola.success('Source files uploaded successfully.');
|
|
223
|
+
}
|
|
224
|
+
// Create the web build
|
|
225
|
+
consola.start('Creating build...');
|
|
226
|
+
const response = await appBuildsService.create({
|
|
227
|
+
adHocEnvironmentVariables,
|
|
228
|
+
appBuildSourceId,
|
|
229
|
+
appCertificateName: certificate,
|
|
230
|
+
appEnvironmentName: environment,
|
|
231
|
+
appId,
|
|
232
|
+
stack,
|
|
233
|
+
gitRef,
|
|
234
|
+
platform: 'web',
|
|
235
|
+
});
|
|
236
|
+
consola.info(`Build ID: ${response.id}`);
|
|
237
|
+
consola.info(`Build Number: ${response.numberAsString}`);
|
|
238
|
+
consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
|
|
239
|
+
consola.success('Build created successfully.');
|
|
240
|
+
// Wait for build to complete
|
|
241
|
+
await waitForJobCompletion({ jobId: response.jobId });
|
|
242
|
+
consola.success('Build completed successfully.');
|
|
243
|
+
console.log();
|
|
244
|
+
// Update build with custom properties and version constraints if any are provided
|
|
245
|
+
const customProperties = parseCustomProperties(options.customProperty);
|
|
246
|
+
const hasUpdateFields = customProperties ||
|
|
247
|
+
options.androidMin ||
|
|
248
|
+
options.androidMax ||
|
|
249
|
+
options.androidEq ||
|
|
250
|
+
options.iosMin ||
|
|
251
|
+
options.iosMax ||
|
|
252
|
+
options.iosEq;
|
|
253
|
+
if (hasUpdateFields) {
|
|
254
|
+
consola.start('Updating build...');
|
|
255
|
+
await appBuildsService.update({
|
|
256
|
+
appId,
|
|
257
|
+
appBuildId: response.id,
|
|
258
|
+
customProperties,
|
|
259
|
+
minAndroidAppVersionCode: options.androidMin,
|
|
260
|
+
maxAndroidAppVersionCode: options.androidMax,
|
|
261
|
+
eqAndroidAppVersionCode: options.androidEq,
|
|
262
|
+
minIosAppVersionCode: options.iosMin,
|
|
263
|
+
maxIosAppVersionCode: options.iosMax,
|
|
264
|
+
eqIosAppVersionCode: options.iosEq,
|
|
265
|
+
});
|
|
266
|
+
consola.success('Build updated successfully.');
|
|
267
|
+
}
|
|
268
|
+
// Deploy to channels
|
|
269
|
+
const rolloutPercentage = (options.rolloutPercentage ?? 100) / 100;
|
|
270
|
+
const deploymentIds = [];
|
|
271
|
+
for (const channelName of channel) {
|
|
272
|
+
consola.start(`Creating deployment for channel "${channelName}"...`);
|
|
273
|
+
const deployment = await appDeploymentsService.create({
|
|
274
|
+
appId,
|
|
275
|
+
appBuildId: response.id,
|
|
276
|
+
appChannelName: channelName,
|
|
277
|
+
rolloutPercentage,
|
|
278
|
+
});
|
|
279
|
+
deploymentIds.push(deployment.id);
|
|
280
|
+
consola.info(`Deployment ID: ${deployment.id}`);
|
|
281
|
+
consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${deployment.id}`);
|
|
282
|
+
consola.success('Deployment created successfully.');
|
|
283
|
+
}
|
|
284
|
+
// Output JSON if json flag is set
|
|
285
|
+
if (json) {
|
|
286
|
+
console.log(JSON.stringify({
|
|
287
|
+
buildId: response.id,
|
|
288
|
+
buildNumberAsString: response.numberAsString,
|
|
289
|
+
deploymentIds,
|
|
290
|
+
}, null, 2));
|
|
291
|
+
}
|
|
292
|
+
}),
|
|
293
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
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-liveupdates-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 deploymentId = '00000000-0000-0000-0000-000000000003';
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
mockUserConfig.read.mockReturnValue({ token: testToken });
|
|
28
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
|
|
29
|
+
mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue(testToken);
|
|
30
|
+
// Mock waitForJobCompletion to resolve immediately
|
|
31
|
+
const jobUtils = await import('../../../utils/job.js');
|
|
32
|
+
vi.mocked(jobUtils.waitForJobCompletion).mockResolvedValue({
|
|
33
|
+
id: 'job-1',
|
|
34
|
+
status: 'succeeded',
|
|
35
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
36
|
+
});
|
|
37
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
38
|
+
throw new Error(`Process exited with code ${code}`);
|
|
39
|
+
});
|
|
40
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
nock.cleanAll();
|
|
44
|
+
vi.restoreAllMocks();
|
|
45
|
+
});
|
|
46
|
+
it('should require authentication', async () => {
|
|
47
|
+
mockAuthorizationService.hasAuthorizationToken.mockReturnValue(false);
|
|
48
|
+
const options = { appId, gitRef: 'main', channel: ['production'] };
|
|
49
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
50
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must be logged in to run this command. Set the `CAPAWESOME_TOKEN` environment variable or use the `--token` option.');
|
|
51
|
+
});
|
|
52
|
+
it('should create a live update with build and deployment', async () => {
|
|
53
|
+
const options = {
|
|
54
|
+
appId,
|
|
55
|
+
gitRef: 'main',
|
|
56
|
+
channel: ['production'],
|
|
57
|
+
yes: true,
|
|
58
|
+
};
|
|
59
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
60
|
+
.post(`/v1/apps/${appId}/builds`, {
|
|
61
|
+
gitRef: 'main',
|
|
62
|
+
platform: 'web',
|
|
63
|
+
})
|
|
64
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
65
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
66
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
67
|
+
.post(`/v1/apps/${appId}/deployments`, {
|
|
68
|
+
appId,
|
|
69
|
+
appBuildId: buildId,
|
|
70
|
+
appChannelName: 'production',
|
|
71
|
+
rolloutPercentage: 1,
|
|
72
|
+
})
|
|
73
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
74
|
+
.reply(201, { id: deploymentId });
|
|
75
|
+
await createCommand.action(options, undefined);
|
|
76
|
+
expect(buildScope.isDone()).toBe(true);
|
|
77
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
78
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Build created successfully.');
|
|
79
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Build completed successfully.');
|
|
80
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Deployment created successfully.');
|
|
81
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Build ID: ${buildId}`);
|
|
82
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Deployment ID: ${deploymentId}`);
|
|
83
|
+
});
|
|
84
|
+
it('should pass environment and certificate to build', async () => {
|
|
85
|
+
const options = {
|
|
86
|
+
appId,
|
|
87
|
+
gitRef: 'v1.0.0',
|
|
88
|
+
channel: ['production'],
|
|
89
|
+
environment: 'staging',
|
|
90
|
+
certificate: 'my-cert',
|
|
91
|
+
yes: true,
|
|
92
|
+
};
|
|
93
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
94
|
+
.post(`/v1/apps/${appId}/builds`, {
|
|
95
|
+
gitRef: 'v1.0.0',
|
|
96
|
+
platform: 'web',
|
|
97
|
+
appEnvironmentName: 'staging',
|
|
98
|
+
appCertificateName: 'my-cert',
|
|
99
|
+
})
|
|
100
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
101
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
102
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
103
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
104
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
105
|
+
.reply(201, { id: deploymentId });
|
|
106
|
+
await createCommand.action(options, undefined);
|
|
107
|
+
expect(buildScope.isDone()).toBe(true);
|
|
108
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
it('should pass stack to build', async () => {
|
|
111
|
+
const options = {
|
|
112
|
+
appId,
|
|
113
|
+
gitRef: 'main',
|
|
114
|
+
channel: ['production'],
|
|
115
|
+
stack: 'macos-tahoe',
|
|
116
|
+
yes: true,
|
|
117
|
+
};
|
|
118
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
119
|
+
.post(`/v1/apps/${appId}/builds`, {
|
|
120
|
+
gitRef: 'main',
|
|
121
|
+
platform: 'web',
|
|
122
|
+
stack: 'macos-tahoe',
|
|
123
|
+
})
|
|
124
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
125
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
126
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
127
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
128
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
129
|
+
.reply(201, { id: deploymentId });
|
|
130
|
+
await createCommand.action(options, undefined);
|
|
131
|
+
expect(buildScope.isDone()).toBe(true);
|
|
132
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
it('should update version constraints when provided', async () => {
|
|
135
|
+
const options = {
|
|
136
|
+
appId,
|
|
137
|
+
gitRef: 'main',
|
|
138
|
+
channel: ['production'],
|
|
139
|
+
androidMin: '10',
|
|
140
|
+
androidMax: '50',
|
|
141
|
+
iosEq: '42',
|
|
142
|
+
yes: true,
|
|
143
|
+
};
|
|
144
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
145
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
146
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
147
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
148
|
+
const updateScope = nock(DEFAULT_API_BASE_URL)
|
|
149
|
+
.patch(`/v1/apps/${appId}/builds/${buildId}`, {
|
|
150
|
+
minAndroidAppVersionCode: '10',
|
|
151
|
+
maxAndroidAppVersionCode: '50',
|
|
152
|
+
eqIosAppVersionCode: '42',
|
|
153
|
+
})
|
|
154
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
155
|
+
.reply(200, { id: buildId });
|
|
156
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
157
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
158
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
159
|
+
.reply(201, { id: deploymentId });
|
|
160
|
+
await createCommand.action(options, undefined);
|
|
161
|
+
expect(buildScope.isDone()).toBe(true);
|
|
162
|
+
expect(updateScope.isDone()).toBe(true);
|
|
163
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
164
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Build updated successfully.');
|
|
165
|
+
});
|
|
166
|
+
it('should convert rollout percentage to decimal', async () => {
|
|
167
|
+
const options = {
|
|
168
|
+
appId,
|
|
169
|
+
gitRef: 'main',
|
|
170
|
+
channel: ['production'],
|
|
171
|
+
rolloutPercentage: 50,
|
|
172
|
+
yes: true,
|
|
173
|
+
};
|
|
174
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
175
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
176
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
177
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
178
|
+
const deploymentScope = nock(DEFAULT_API_BASE_URL)
|
|
179
|
+
.post(`/v1/apps/${appId}/deployments`, {
|
|
180
|
+
appId,
|
|
181
|
+
appBuildId: buildId,
|
|
182
|
+
appChannelName: 'production',
|
|
183
|
+
rolloutPercentage: 0.5,
|
|
184
|
+
})
|
|
185
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
186
|
+
.reply(201, { id: deploymentId });
|
|
187
|
+
await createCommand.action(options, undefined);
|
|
188
|
+
expect(buildScope.isDone()).toBe(true);
|
|
189
|
+
expect(deploymentScope.isDone()).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
it('should create multiple deployments for multiple channels', async () => {
|
|
192
|
+
const deploymentId2 = '00000000-0000-0000-0000-000000000004';
|
|
193
|
+
const options = {
|
|
194
|
+
appId,
|
|
195
|
+
gitRef: 'main',
|
|
196
|
+
channel: ['production', 'staging'],
|
|
197
|
+
yes: true,
|
|
198
|
+
};
|
|
199
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
200
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
201
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
202
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
203
|
+
const deploymentScope1 = nock(DEFAULT_API_BASE_URL)
|
|
204
|
+
.post(`/v1/apps/${appId}/deployments`, {
|
|
205
|
+
appId,
|
|
206
|
+
appBuildId: buildId,
|
|
207
|
+
appChannelName: 'production',
|
|
208
|
+
rolloutPercentage: 1,
|
|
209
|
+
})
|
|
210
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
211
|
+
.reply(201, { id: deploymentId });
|
|
212
|
+
const deploymentScope2 = nock(DEFAULT_API_BASE_URL)
|
|
213
|
+
.post(`/v1/apps/${appId}/deployments`, {
|
|
214
|
+
appId,
|
|
215
|
+
appBuildId: buildId,
|
|
216
|
+
appChannelName: 'staging',
|
|
217
|
+
rolloutPercentage: 1,
|
|
218
|
+
})
|
|
219
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
220
|
+
.reply(201, { id: deploymentId2 });
|
|
221
|
+
await createCommand.action(options, undefined);
|
|
222
|
+
expect(buildScope.isDone()).toBe(true);
|
|
223
|
+
expect(deploymentScope1.isDone()).toBe(true);
|
|
224
|
+
expect(deploymentScope2.isDone()).toBe(true);
|
|
225
|
+
expect(mockConsola.success).toHaveBeenCalledWith('Deployment created successfully.');
|
|
226
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Deployment ID: ${deploymentId}`);
|
|
227
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Deployment ID: ${deploymentId2}`);
|
|
228
|
+
});
|
|
229
|
+
it('should output JSON when json flag is set', async () => {
|
|
230
|
+
const options = {
|
|
231
|
+
appId,
|
|
232
|
+
gitRef: 'main',
|
|
233
|
+
channel: ['production'],
|
|
234
|
+
json: true,
|
|
235
|
+
yes: true,
|
|
236
|
+
};
|
|
237
|
+
nock(DEFAULT_API_BASE_URL)
|
|
238
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
239
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
240
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '42' });
|
|
241
|
+
nock(DEFAULT_API_BASE_URL)
|
|
242
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
243
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
244
|
+
.reply(201, { id: deploymentId });
|
|
245
|
+
await createCommand.action(options, undefined);
|
|
246
|
+
expect(console.log).toHaveBeenCalledWith(JSON.stringify({
|
|
247
|
+
buildId,
|
|
248
|
+
buildNumberAsString: '42',
|
|
249
|
+
deploymentIds: [deploymentId],
|
|
250
|
+
}, null, 2));
|
|
251
|
+
});
|
|
252
|
+
it('should require app ID in non-interactive mode', async () => {
|
|
253
|
+
const options = { gitRef: 'main', channel: ['production'] };
|
|
254
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
255
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must provide an app ID when running in non-interactive environment.');
|
|
256
|
+
});
|
|
257
|
+
it('should require git ref in non-interactive mode', async () => {
|
|
258
|
+
const options = { appId, channel: ['production'] };
|
|
259
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
260
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must provide a git ref, path, or url when running in non-interactive environment.');
|
|
261
|
+
});
|
|
262
|
+
it('should require channel in non-interactive mode', async () => {
|
|
263
|
+
const options = { appId, gitRef: 'main' };
|
|
264
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
265
|
+
expect(mockConsola.error).toHaveBeenCalledWith('You must provide at least one channel when running in non-interactive environment.');
|
|
266
|
+
});
|
|
267
|
+
it('should handle build creation API error', async () => {
|
|
268
|
+
const options = {
|
|
269
|
+
appId,
|
|
270
|
+
gitRef: 'main',
|
|
271
|
+
channel: ['production'],
|
|
272
|
+
yes: true,
|
|
273
|
+
};
|
|
274
|
+
const buildScope = nock(DEFAULT_API_BASE_URL)
|
|
275
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
276
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
277
|
+
.reply(400, { message: 'Invalid build data' });
|
|
278
|
+
await expect(createCommand.action(options, undefined)).rejects.toThrow();
|
|
279
|
+
expect(buildScope.isDone()).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
it('should include build URL in output', async () => {
|
|
282
|
+
const options = {
|
|
283
|
+
appId,
|
|
284
|
+
gitRef: 'main',
|
|
285
|
+
channel: ['production'],
|
|
286
|
+
yes: true,
|
|
287
|
+
};
|
|
288
|
+
nock(DEFAULT_API_BASE_URL)
|
|
289
|
+
.post(`/v1/apps/${appId}/builds`)
|
|
290
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
291
|
+
.reply(201, { id: buildId, jobId: 'job-1', numberAsString: '1' });
|
|
292
|
+
nock(DEFAULT_API_BASE_URL)
|
|
293
|
+
.post(`/v1/apps/${appId}/deployments`)
|
|
294
|
+
.matchHeader('Authorization', `Bearer ${testToken}`)
|
|
295
|
+
.reply(201, { id: deploymentId });
|
|
296
|
+
await createCommand.action(options, undefined);
|
|
297
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${buildId}`);
|
|
298
|
+
expect(mockConsola.info).toHaveBeenCalledWith(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${deploymentId}`);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isInteractive } from '../../../utils/environment.js';
|
|
2
|
-
import { fileExistsAtPath } from '../../../utils/file.js';
|
|
2
|
+
import { directoryContainsSourceMaps, directoryContainsSymlinks, fileExistsAtPath, isDirectory } from '../../../utils/file.js';
|
|
3
3
|
import { generateManifestJson } from '../../../utils/manifest.js';
|
|
4
4
|
import { prompt } from '../../../utils/prompt.js';
|
|
5
5
|
import { defineCommand, defineOptions } from '@robingenz/zli';
|
|
@@ -32,6 +32,22 @@ export default defineCommand({
|
|
|
32
32
|
consola.error(`The path does not exist.`);
|
|
33
33
|
process.exit(1);
|
|
34
34
|
}
|
|
35
|
+
// Check if the path is a directory
|
|
36
|
+
const pathIsDirectory = await isDirectory(path);
|
|
37
|
+
if (!pathIsDirectory) {
|
|
38
|
+
consola.error(`The path is not a directory.`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
// Check for source maps
|
|
42
|
+
const containsSourceMaps = await directoryContainsSourceMaps(path);
|
|
43
|
+
if (containsSourceMaps) {
|
|
44
|
+
consola.warn('Source map files were detected in the specified path. Source maps should not be distributed to end users as they expose your original source code and increase the download size. Consider excluding source map files from your build output.');
|
|
45
|
+
}
|
|
46
|
+
// Check for symlinks
|
|
47
|
+
const containsSymlinks = await directoryContainsSymlinks(path);
|
|
48
|
+
if (containsSymlinks) {
|
|
49
|
+
consola.warn('Symbolic links were detected in the specified path. Symbolic links are skipped during manifest generation.');
|
|
50
|
+
}
|
|
35
51
|
// Generate the manifest file
|
|
36
52
|
await generateManifestJson(path);
|
|
37
53
|
consola.success('Manifest file generated.');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fileExistsAtPath } from '../../../utils/file.js';
|
|
1
|
+
import { directoryContainsSymlinks, fileExistsAtPath, isDirectory } from '../../../utils/file.js';
|
|
2
2
|
import { generateManifestJson } from '../../../utils/manifest.js';
|
|
3
3
|
import { prompt } from '../../../utils/prompt.js';
|
|
4
4
|
import consola from 'consola';
|
|
@@ -14,6 +14,8 @@ vi.mock('@/utils/environment.js', () => ({
|
|
|
14
14
|
}));
|
|
15
15
|
describe('apps-liveupdates-generatemanifest', () => {
|
|
16
16
|
const mockFileExistsAtPath = vi.mocked(fileExistsAtPath);
|
|
17
|
+
const mockIsDirectory = vi.mocked(isDirectory);
|
|
18
|
+
const mockDirectoryContainsSymlinks = vi.mocked(directoryContainsSymlinks);
|
|
17
19
|
const mockGenerateManifestJson = vi.mocked(generateManifestJson);
|
|
18
20
|
const mockPrompt = vi.mocked(prompt);
|
|
19
21
|
const mockConsola = vi.mocked(consola);
|
|
@@ -29,6 +31,7 @@ describe('apps-liveupdates-generatemanifest', () => {
|
|
|
29
31
|
it('should generate manifest with provided path', async () => {
|
|
30
32
|
const options = { path: './dist' };
|
|
31
33
|
mockFileExistsAtPath.mockResolvedValue(true);
|
|
34
|
+
mockIsDirectory.mockResolvedValue(true);
|
|
32
35
|
mockGenerateManifestJson.mockResolvedValue(undefined);
|
|
33
36
|
await generateManifestCommand.action(options, undefined);
|
|
34
37
|
expect(mockFileExistsAtPath).toHaveBeenCalledWith('./dist');
|
|
@@ -39,6 +42,7 @@ describe('apps-liveupdates-generatemanifest', () => {
|
|
|
39
42
|
const options = {};
|
|
40
43
|
mockPrompt.mockResolvedValueOnce('./www');
|
|
41
44
|
mockFileExistsAtPath.mockResolvedValue(true);
|
|
45
|
+
mockIsDirectory.mockResolvedValue(true);
|
|
42
46
|
mockGenerateManifestJson.mockResolvedValue(undefined);
|
|
43
47
|
await generateManifestCommand.action(options, undefined);
|
|
44
48
|
expect(mockPrompt).toHaveBeenCalledWith('Enter the path to the web assets folder (e.g., `dist` or `www`):', {
|
|
@@ -60,4 +64,20 @@ describe('apps-liveupdates-generatemanifest', () => {
|
|
|
60
64
|
await expect(generateManifestCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
61
65
|
expect(mockConsola.error).toHaveBeenCalledWith('The path does not exist.');
|
|
62
66
|
});
|
|
67
|
+
it('should handle non-directory path', async () => {
|
|
68
|
+
const options = { path: './file.txt' };
|
|
69
|
+
mockFileExistsAtPath.mockResolvedValue(true);
|
|
70
|
+
mockIsDirectory.mockResolvedValue(false);
|
|
71
|
+
await expect(generateManifestCommand.action(options, undefined)).rejects.toThrow('Process exited with code 1');
|
|
72
|
+
expect(mockConsola.error).toHaveBeenCalledWith('The path is not a directory.');
|
|
73
|
+
});
|
|
74
|
+
it('should warn when symlinks are detected', async () => {
|
|
75
|
+
const options = { path: './dist' };
|
|
76
|
+
mockFileExistsAtPath.mockResolvedValue(true);
|
|
77
|
+
mockIsDirectory.mockResolvedValue(true);
|
|
78
|
+
mockDirectoryContainsSymlinks.mockResolvedValue(true);
|
|
79
|
+
mockGenerateManifestJson.mockResolvedValue(undefined);
|
|
80
|
+
await generateManifestCommand.action(options, undefined);
|
|
81
|
+
expect(mockConsola.warn).toHaveBeenCalledWith('Symbolic links were detected in the specified path. Symbolic links are skipped during manifest generation.');
|
|
82
|
+
});
|
|
63
83
|
});
|