@capawesome/cli 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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
+ ## [3.4.0](https://github.com/capawesome-team/cli/compare/v3.3.0...v3.4.0) (2025-11-19)
6
+
7
+
8
+ ### Features
9
+
10
+ * support builds and deployments ([#89](https://github.com/capawesome-team/cli/issues/89)) ([71be7b0](https://github.com/capawesome-team/cli/commit/71be7b0b69c59bd5d7e123fe37b30044a47f7afc))
11
+
5
12
  ## [3.3.0](https://github.com/capawesome-team/cli/compare/v3.2.2...v3.3.0) (2025-10-03)
6
13
 
7
14
 
@@ -0,0 +1,106 @@
1
+ import appBuildsService from '../../../services/app-builds.js';
2
+ import appsService from '../../../services/apps.js';
3
+ import authorizationService from '../../../services/authorization-service.js';
4
+ import jobsService from '../../../services/jobs.js';
5
+ import organizationsService from '../../../services/organizations.js';
6
+ import { prompt } from '../../../utils/prompt.js';
7
+ import { defineCommand, defineOptions } from '@robingenz/zli';
8
+ import consola from 'consola';
9
+ import { hasTTY } from 'std-env';
10
+ import { z } from 'zod';
11
+ export default defineCommand({
12
+ description: 'Cancel an app build.',
13
+ options: defineOptions(z.object({
14
+ appId: z
15
+ .uuid({
16
+ message: 'App ID must be a UUID.',
17
+ })
18
+ .optional()
19
+ .describe('App ID the build belongs to.'),
20
+ buildId: z
21
+ .uuid({
22
+ message: 'Build ID must be a UUID.',
23
+ })
24
+ .optional()
25
+ .describe('Build ID to cancel.'),
26
+ })),
27
+ action: async (options) => {
28
+ let { appId, buildId } = options;
29
+ // Check if the user is logged in
30
+ if (!authorizationService.hasAuthorizationToken()) {
31
+ consola.error('You must be logged in to run this command.');
32
+ process.exit(1);
33
+ }
34
+ // Prompt for app ID if not provided
35
+ if (!appId) {
36
+ if (!hasTTY) {
37
+ consola.error('You must provide an app ID when running in non-interactive environment.');
38
+ process.exit(1);
39
+ }
40
+ const organizations = await organizationsService.findAll();
41
+ if (organizations.length === 0) {
42
+ consola.error('You must create an organization before canceling a build.');
43
+ process.exit(1);
44
+ }
45
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
46
+ const organizationId = await prompt('Select the organization of the app for which you want to cancel a build.', {
47
+ type: 'select',
48
+ options: organizations.map((organization) => ({ label: organization.name, value: organization.id })),
49
+ });
50
+ if (!organizationId) {
51
+ consola.error('You must select the organization of an app for which you want to cancel a build.');
52
+ process.exit(1);
53
+ }
54
+ const apps = await appsService.findAll({
55
+ organizationId,
56
+ });
57
+ if (apps.length === 0) {
58
+ consola.error('You must create an app before canceling a build.');
59
+ process.exit(1);
60
+ }
61
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
62
+ appId = await prompt('Which app do you want to cancel a build for:', {
63
+ type: 'select',
64
+ options: apps.map((app) => ({ label: app.name, value: app.id })),
65
+ });
66
+ if (!appId) {
67
+ consola.error('You must select an app to cancel a build for.');
68
+ process.exit(1);
69
+ }
70
+ }
71
+ // Prompt for build ID if not provided
72
+ if (!buildId) {
73
+ if (!hasTTY) {
74
+ consola.error('You must provide a build ID when running in non-interactive environment.');
75
+ process.exit(1);
76
+ }
77
+ const builds = await appBuildsService.findAll({ appId });
78
+ if (builds.length === 0) {
79
+ consola.error('No builds found for this app.');
80
+ process.exit(1);
81
+ }
82
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
83
+ buildId = await prompt('Which build do you want to cancel:', {
84
+ type: 'select',
85
+ options: builds.map((build) => ({
86
+ label: `Build #${build.numberAsString || build.id} (${build.platform} - ${build.type})`,
87
+ value: build.id,
88
+ })),
89
+ });
90
+ if (!buildId) {
91
+ consola.error('You must select a build to cancel.');
92
+ process.exit(1);
93
+ }
94
+ }
95
+ // Fetch the build details to get the job ID
96
+ consola.start('Fetching build details...');
97
+ const build = await appBuildsService.findOne({ appId, appBuildId: buildId });
98
+ // Cancel the job
99
+ consola.start('Canceling build...');
100
+ await jobsService.update({
101
+ jobId: build.jobId,
102
+ dto: { status: 'canceled' },
103
+ });
104
+ consola.success('Build successfully canceled.');
105
+ },
106
+ });
@@ -0,0 +1,359 @@
1
+ import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
+ import appBuildsService from '../../../services/app-builds.js';
3
+ import appCertificatesService from '../../../services/app-certificates.js';
4
+ import appEnvironmentsService from '../../../services/app-environments.js';
5
+ import appsService from '../../../services/apps.js';
6
+ import authorizationService from '../../../services/authorization-service.js';
7
+ import organizationsService from '../../../services/organizations.js';
8
+ import { unescapeAnsi } from '../../../utils/ansi.js';
9
+ import { prompt } from '../../../utils/prompt.js';
10
+ import { wait } from '../../../utils/wait.js';
11
+ import { defineCommand, defineOptions } from '@robingenz/zli';
12
+ import consola from 'consola';
13
+ import fs from 'fs/promises';
14
+ import path from 'path';
15
+ import { hasTTY } from 'std-env';
16
+ import { z } from 'zod';
17
+ const IOS_BUILD_TYPES = ['simulator', 'development', 'ad-hoc', 'app-store', 'enterprise'];
18
+ const ANDROID_BUILD_TYPES = ['debug', 'release'];
19
+ export default defineCommand({
20
+ description: 'Create a new app build.',
21
+ options: defineOptions(z.object({
22
+ aab: z
23
+ .union([z.boolean(), z.string()])
24
+ .optional()
25
+ .describe('Download the generated AAB file (Android only). Optionally provide a file path.'),
26
+ apk: z
27
+ .union([z.boolean(), z.string()])
28
+ .optional()
29
+ .describe('Download the generated APK file (Android only). Optionally provide a file path.'),
30
+ appId: z
31
+ .uuid({
32
+ message: 'App ID must be a UUID.',
33
+ })
34
+ .optional()
35
+ .describe('App ID to create the build for.'),
36
+ certificate: z.string().optional().describe('The name of the certificate to use for the build.'),
37
+ detached: z
38
+ .boolean()
39
+ .optional()
40
+ .describe('Exit immediately after creating the build without waiting for completion.'),
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
+ ipa: z
44
+ .union([z.boolean(), z.string()])
45
+ .optional()
46
+ .describe('Download the generated IPA file (iOS only). Optionally provide a file path.'),
47
+ platform: z
48
+ .enum(['ios', 'android'], {
49
+ message: 'Platform must be either `ios` or `android`.',
50
+ })
51
+ .optional()
52
+ .describe('The platform for the build. Supported values are `ios` and `android`.'),
53
+ type: z
54
+ .string()
55
+ .optional()
56
+ .describe('The type of build. For iOS, supported values are `simulator`, `development`, `ad-hoc`, `app-store`, and `enterprise`. For Android, supported values are `debug` and `release`.'),
57
+ })),
58
+ action: async (options) => {
59
+ let { appId, platform, type, gitRef, environment, certificate } = options;
60
+ // Check if the user is logged in
61
+ if (!authorizationService.hasAuthorizationToken()) {
62
+ consola.error('You must be logged in to run this command.');
63
+ process.exit(1);
64
+ }
65
+ // Validate that detached flag cannot be used with artifact flags
66
+ if (options.detached && (options.apk || options.aab || options.ipa)) {
67
+ consola.error('The --detached flag cannot be used with --apk, --aab, or --ipa flags.');
68
+ process.exit(1);
69
+ }
70
+ // Prompt for app ID if not provided
71
+ if (!appId) {
72
+ if (!hasTTY) {
73
+ consola.error('You must provide an app ID when running in non-interactive environment.');
74
+ process.exit(1);
75
+ }
76
+ const organizations = await organizationsService.findAll();
77
+ if (organizations.length === 0) {
78
+ consola.error('You must create an organization before creating a build.');
79
+ process.exit(1);
80
+ }
81
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
82
+ const organizationId = await prompt('Select the organization of the app for which you want to create a build.', {
83
+ type: 'select',
84
+ options: organizations.map((organization) => ({ label: organization.name, value: organization.id })),
85
+ });
86
+ if (!organizationId) {
87
+ consola.error('You must select the organization of an app for which you want to create a build.');
88
+ process.exit(1);
89
+ }
90
+ const apps = await appsService.findAll({
91
+ organizationId,
92
+ });
93
+ if (apps.length === 0) {
94
+ consola.error('You must create an app before creating a build.');
95
+ process.exit(1);
96
+ }
97
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
98
+ appId = await prompt('Which app do you want to create a build for:', {
99
+ type: 'select',
100
+ options: apps.map((app) => ({ label: app.name, value: app.id })),
101
+ });
102
+ if (!appId) {
103
+ consola.error('You must select an app to create a build for.');
104
+ process.exit(1);
105
+ }
106
+ }
107
+ // Prompt for platform if not provided
108
+ if (!platform) {
109
+ if (!hasTTY) {
110
+ consola.error('You must provide a platform when running in non-interactive environment.');
111
+ process.exit(1);
112
+ }
113
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
114
+ platform = await prompt('Select the platform for the build:', {
115
+ type: 'select',
116
+ options: [
117
+ { label: 'Android', value: 'android' },
118
+ { label: 'iOS', value: 'ios' },
119
+ ],
120
+ });
121
+ if (!platform) {
122
+ consola.error('You must select a platform.');
123
+ process.exit(1);
124
+ }
125
+ }
126
+ // Prompt for git ref if not provided
127
+ if (!gitRef) {
128
+ if (!hasTTY) {
129
+ consola.error('You must provide a git ref when running in non-interactive environment.');
130
+ process.exit(1);
131
+ }
132
+ gitRef = await prompt('Enter the Git reference (branch, tag, or commit SHA):', {
133
+ type: 'text',
134
+ });
135
+ if (!gitRef) {
136
+ consola.error('You must provide a git ref.');
137
+ process.exit(1);
138
+ }
139
+ }
140
+ // Set default type based on platform if not provided
141
+ if (!type) {
142
+ type = platform === 'android' ? 'debug' : 'simulator';
143
+ }
144
+ // Validate type based on platform
145
+ if (platform === 'ios' && !IOS_BUILD_TYPES.includes(type)) {
146
+ consola.error(`Invalid build type for iOS. Supported values are: ${IOS_BUILD_TYPES.map((t) => `\`${t}\``).join(', ')}.`);
147
+ process.exit(1);
148
+ }
149
+ if (platform === 'android' && !ANDROID_BUILD_TYPES.includes(type)) {
150
+ consola.error(`Invalid build type for Android. Supported values are: ${ANDROID_BUILD_TYPES.map((t) => `\`${t}\``).join(', ')}.`);
151
+ process.exit(1);
152
+ }
153
+ // Prompt for environment if not provided
154
+ if (!environment && hasTTY) {
155
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
156
+ const selectEnvironment = await prompt('Do you want to select an environment?', {
157
+ type: 'confirm',
158
+ initial: false,
159
+ });
160
+ if (selectEnvironment) {
161
+ const environments = await appEnvironmentsService.findAll({ appId });
162
+ if (environments.length === 0) {
163
+ consola.warn('No environments found for this app.');
164
+ }
165
+ else {
166
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
167
+ environment = await prompt('Select the environment for the build:', {
168
+ type: 'select',
169
+ options: environments.map((env) => ({ label: env.name, value: env.name })),
170
+ });
171
+ }
172
+ }
173
+ }
174
+ // Prompt for certificate if not provided
175
+ if (!certificate && hasTTY) {
176
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
177
+ const selectCertificate = await prompt('Do you want to select a certificate?', {
178
+ type: 'confirm',
179
+ initial: false,
180
+ });
181
+ if (selectCertificate) {
182
+ const certificates = await appCertificatesService.findAll({ appId, platform });
183
+ if (certificates.length === 0) {
184
+ consola.warn('No certificates found for this app.');
185
+ }
186
+ else {
187
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
188
+ certificate = await prompt('Select the certificate for the build:', {
189
+ type: 'select',
190
+ options: certificates.map((cert) => ({ label: cert.name, value: cert.name })),
191
+ });
192
+ }
193
+ }
194
+ }
195
+ // Create the app build
196
+ consola.start('Creating build...');
197
+ const response = await appBuildsService.create({
198
+ appCertificateName: certificate,
199
+ appEnvironmentName: environment,
200
+ appId,
201
+ gitRef,
202
+ platform,
203
+ type,
204
+ });
205
+ consola.success(`Build created successfully.`);
206
+ consola.info(`Build Number: ${response.numberAsString}`);
207
+ consola.info(`Build ID: ${response.id}`);
208
+ consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
209
+ // Wait for build job to complete by default, unless --detached flag is set
210
+ const shouldWait = !options.detached;
211
+ if (shouldWait) {
212
+ let lastPrintedLogNumber = 0;
213
+ let isWaitingForStart = true;
214
+ // Poll build status until completion
215
+ while (true) {
216
+ try {
217
+ const build = await appBuildsService.findOne({
218
+ appId,
219
+ appBuildId: response.id,
220
+ relations: 'appBuildArtifacts,job,job.jobLogs',
221
+ });
222
+ if (!build.job) {
223
+ await wait(3000);
224
+ continue;
225
+ }
226
+ const jobStatus = build.job.status;
227
+ // Show spinner while queued or pending
228
+ if (jobStatus === 'queued' || jobStatus === 'pending') {
229
+ if (isWaitingForStart) {
230
+ consola.start(`Waiting for build to start (status: ${jobStatus})...`);
231
+ }
232
+ await wait(3000);
233
+ continue;
234
+ }
235
+ // Stop spinner when job moves to in_progress
236
+ if (isWaitingForStart && jobStatus === 'in_progress') {
237
+ isWaitingForStart = false;
238
+ consola.success('Build started...');
239
+ }
240
+ // Print new logs
241
+ if (build.job.jobLogs && build.job.jobLogs.length > 0) {
242
+ const newLogs = build.job.jobLogs
243
+ .filter((log) => log.number > lastPrintedLogNumber)
244
+ .sort((a, b) => a.number - b.number);
245
+ for (const log of newLogs) {
246
+ console.log(unescapeAnsi(log.payload));
247
+ lastPrintedLogNumber = log.number;
248
+ }
249
+ }
250
+ // Handle terminal states
251
+ if (jobStatus === 'succeeded' ||
252
+ jobStatus === 'failed' ||
253
+ jobStatus === 'canceled' ||
254
+ jobStatus === 'rejected' ||
255
+ jobStatus === 'timed_out') {
256
+ console.log(); // New line for better readability
257
+ if (jobStatus === 'succeeded') {
258
+ consola.success('Build completed successfully.');
259
+ console.log(); // New line for better readability
260
+ // Download artifacts if flags are set
261
+ if (options.apk && platform === 'android') {
262
+ await handleArtifactDownload({
263
+ appId,
264
+ buildId: response.id,
265
+ buildArtifacts: build.appBuildArtifacts,
266
+ artifactType: 'apk',
267
+ filePath: typeof options.apk === 'string' ? options.apk : undefined,
268
+ });
269
+ }
270
+ if (options.aab && platform === 'android') {
271
+ await handleArtifactDownload({
272
+ appId,
273
+ buildId: response.id,
274
+ buildArtifacts: build.appBuildArtifacts,
275
+ artifactType: 'aab',
276
+ filePath: typeof options.aab === 'string' ? options.aab : undefined,
277
+ });
278
+ }
279
+ if (options.ipa && platform === 'ios') {
280
+ await handleArtifactDownload({
281
+ appId,
282
+ buildId: response.id,
283
+ buildArtifacts: build.appBuildArtifacts,
284
+ artifactType: 'ipa',
285
+ filePath: typeof options.ipa === 'string' ? options.ipa : undefined,
286
+ });
287
+ }
288
+ process.exit(0);
289
+ }
290
+ else if (jobStatus === 'failed') {
291
+ consola.error('Build failed.');
292
+ process.exit(1);
293
+ }
294
+ else if (jobStatus === 'canceled') {
295
+ consola.warn('Build was canceled.');
296
+ process.exit(1);
297
+ }
298
+ else if (jobStatus === 'rejected') {
299
+ consola.error('Build was rejected.');
300
+ process.exit(1);
301
+ }
302
+ else if (jobStatus === 'timed_out') {
303
+ consola.error('Build timed out.');
304
+ process.exit(1);
305
+ }
306
+ }
307
+ // Wait before next poll (3 seconds)
308
+ await wait(3000);
309
+ }
310
+ catch (error) {
311
+ consola.error('Error polling build status:', error);
312
+ process.exit(1);
313
+ }
314
+ }
315
+ }
316
+ },
317
+ });
318
+ /**
319
+ * Download a build artifact (APK, AAB, or IPA).
320
+ */
321
+ const handleArtifactDownload = async (options) => {
322
+ const { appId, buildId, buildArtifacts, artifactType, filePath } = options;
323
+ try {
324
+ const artifactTypeUpper = artifactType.toUpperCase();
325
+ consola.start(`Downloading ${artifactTypeUpper}...`);
326
+ // Find the artifact
327
+ const artifact = buildArtifacts?.find((artifact) => artifact.type === artifactType);
328
+ if (!artifact) {
329
+ consola.warn(`No ${artifactTypeUpper} artifact found for this build.`);
330
+ return;
331
+ }
332
+ if (artifact.status !== 'ready') {
333
+ consola.warn(`${artifactTypeUpper} artifact is not ready (status: ${artifact.status}).`);
334
+ return;
335
+ }
336
+ // Download the artifact
337
+ const artifactData = await appBuildsService.downloadArtifact({
338
+ appId,
339
+ appBuildId: buildId,
340
+ artifactId: artifact.id,
341
+ });
342
+ // Determine the file path
343
+ let outputPath;
344
+ if (filePath) {
345
+ // Use provided path (can be relative or absolute)
346
+ outputPath = path.resolve(filePath);
347
+ }
348
+ else {
349
+ // Default to current working directory with build ID as filename
350
+ outputPath = path.resolve(`${buildId}.${artifactType}`);
351
+ }
352
+ // Save the file
353
+ await fs.writeFile(outputPath, Buffer.from(artifactData));
354
+ consola.success(`${artifactTypeUpper} downloaded successfully: ${outputPath}`);
355
+ }
356
+ catch (error) {
357
+ consola.error(`Failed to download ${artifactType.toUpperCase()}:`, error);
358
+ }
359
+ };
@@ -0,0 +1,111 @@
1
+ import appDeploymentsService from '../../../services/app-deployments.js';
2
+ import appsService from '../../../services/apps.js';
3
+ import authorizationService from '../../../services/authorization-service.js';
4
+ import jobsService from '../../../services/jobs.js';
5
+ import organizationsService from '../../../services/organizations.js';
6
+ import { prompt } from '../../../utils/prompt.js';
7
+ import { defineCommand, defineOptions } from '@robingenz/zli';
8
+ import consola from 'consola';
9
+ import { hasTTY } from 'std-env';
10
+ import { z } from 'zod';
11
+ export default defineCommand({
12
+ description: 'Cancel an ongoing app deployment.',
13
+ options: defineOptions(z.object({
14
+ appId: z
15
+ .uuid({
16
+ message: 'App ID must be a UUID.',
17
+ })
18
+ .optional()
19
+ .describe('App ID the deployment belongs to.'),
20
+ deploymentId: z
21
+ .uuid({
22
+ message: 'Deployment ID must be a UUID.',
23
+ })
24
+ .optional()
25
+ .describe('Deployment ID to cancel.'),
26
+ })),
27
+ action: async (options) => {
28
+ let { appId, deploymentId } = options;
29
+ // Check if the user is logged in
30
+ if (!authorizationService.hasAuthorizationToken()) {
31
+ consola.error('You must be logged in to run this command.');
32
+ process.exit(1);
33
+ }
34
+ // Prompt for app ID if not provided
35
+ if (!appId) {
36
+ if (!hasTTY) {
37
+ consola.error('You must provide an app ID when running in non-interactive environment.');
38
+ process.exit(1);
39
+ }
40
+ const organizations = await organizationsService.findAll();
41
+ if (organizations.length === 0) {
42
+ consola.error('You must create an organization before canceling a deployment.');
43
+ process.exit(1);
44
+ }
45
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
46
+ const organizationId = await prompt('Select the organization of the app for which you want to cancel a deployment.', {
47
+ type: 'select',
48
+ options: organizations.map((organization) => ({ label: organization.name, value: organization.id })),
49
+ });
50
+ if (!organizationId) {
51
+ consola.error('You must select the organization of an app for which you want to cancel a deployment.');
52
+ process.exit(1);
53
+ }
54
+ const apps = await appsService.findAll({
55
+ organizationId,
56
+ });
57
+ if (apps.length === 0) {
58
+ consola.error('You must create an app before canceling a deployment.');
59
+ process.exit(1);
60
+ }
61
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
62
+ appId = await prompt('Which app do you want to cancel a deployment for:', {
63
+ type: 'select',
64
+ options: apps.map((app) => ({ label: app.name, value: app.id })),
65
+ });
66
+ if (!appId) {
67
+ consola.error('You must select an app to cancel a deployment for.');
68
+ process.exit(1);
69
+ }
70
+ }
71
+ // Prompt for deployment ID if not provided
72
+ if (!deploymentId) {
73
+ if (!hasTTY) {
74
+ consola.error('You must provide a deployment ID when running in non-interactive environment.');
75
+ process.exit(1);
76
+ }
77
+ const deployments = await appDeploymentsService.findAll({ appId });
78
+ if (deployments.length === 0) {
79
+ consola.error('No deployments found for this app.');
80
+ process.exit(1);
81
+ }
82
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
83
+ deploymentId = await prompt('Which deployment do you want to cancel:', {
84
+ type: 'select',
85
+ options: deployments.map((deployment) => ({
86
+ label: `Deployment ${deployment.id}`,
87
+ value: deployment.id,
88
+ })),
89
+ });
90
+ if (!deploymentId) {
91
+ consola.error('You must select a deployment to cancel.');
92
+ process.exit(1);
93
+ }
94
+ }
95
+ // Get deployment details to retrieve the job ID
96
+ consola.start('Fetching deployment details...');
97
+ const deployment = await appDeploymentsService.findOne({
98
+ appId,
99
+ appDeploymentId: deploymentId,
100
+ });
101
+ // Cancel the job
102
+ consola.start('Canceling deployment...');
103
+ await jobsService.update({
104
+ jobId: deployment.jobId,
105
+ dto: {
106
+ status: 'canceled',
107
+ },
108
+ });
109
+ consola.success('Deployment successfully canceled.');
110
+ },
111
+ });
@@ -0,0 +1,222 @@
1
+ import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
+ import appBuildsService from '../../../services/app-builds.js';
3
+ import appDeploymentsService from '../../../services/app-deployments.js';
4
+ import appDestinationsService from '../../../services/app-destinations.js';
5
+ import appsService from '../../../services/apps.js';
6
+ import authorizationService from '../../../services/authorization-service.js';
7
+ import organizationsService from '../../../services/organizations.js';
8
+ import { unescapeAnsi } from '../../../utils/ansi.js';
9
+ import { prompt } from '../../../utils/prompt.js';
10
+ import { wait } from '../../../utils/wait.js';
11
+ import { defineCommand, defineOptions } from '@robingenz/zli';
12
+ import consola from 'consola';
13
+ import { hasTTY } from 'std-env';
14
+ import { z } from 'zod';
15
+ export default defineCommand({
16
+ description: 'Create a new app deployment.',
17
+ options: defineOptions(z.object({
18
+ appId: z
19
+ .uuid({
20
+ message: 'App ID must be a UUID.',
21
+ })
22
+ .optional()
23
+ .describe('App ID to create the deployment for.'),
24
+ buildId: z
25
+ .uuid({
26
+ message: 'Build ID must be a UUID.',
27
+ })
28
+ .optional()
29
+ .describe('Build ID to deploy.'),
30
+ destination: z.string().optional().describe('The name of the destination to deploy to.'),
31
+ detached: z
32
+ .boolean()
33
+ .optional()
34
+ .describe('Exit immediately after creating the deployment without waiting for completion.'),
35
+ })),
36
+ action: async (options) => {
37
+ let { appId, buildId, destination } = options;
38
+ // Check if the user is logged in
39
+ if (!authorizationService.hasAuthorizationToken()) {
40
+ consola.error('You must be logged in to run this command.');
41
+ process.exit(1);
42
+ }
43
+ // Prompt for app ID if not provided
44
+ if (!appId) {
45
+ if (!hasTTY) {
46
+ consola.error('You must provide an app ID when running in non-interactive environment.');
47
+ process.exit(1);
48
+ }
49
+ const organizations = await organizationsService.findAll();
50
+ if (organizations.length === 0) {
51
+ consola.error('You must create an organization before creating a deployment.');
52
+ process.exit(1);
53
+ }
54
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
55
+ const organizationId = await prompt('Select the organization of the app for which you want to create a deployment.', {
56
+ type: 'select',
57
+ options: organizations.map((organization) => ({ label: organization.name, value: organization.id })),
58
+ });
59
+ if (!organizationId) {
60
+ consola.error('You must select the organization of an app for which you want to create a deployment.');
61
+ process.exit(1);
62
+ }
63
+ const apps = await appsService.findAll({
64
+ organizationId,
65
+ });
66
+ if (apps.length === 0) {
67
+ consola.error('You must create an app before creating a deployment.');
68
+ process.exit(1);
69
+ }
70
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
71
+ appId = await prompt('Which app do you want to create a deployment for:', {
72
+ type: 'select',
73
+ options: apps.map((app) => ({ label: app.name, value: app.id })),
74
+ });
75
+ if (!appId) {
76
+ consola.error('You must select an app to create a deployment for.');
77
+ process.exit(1);
78
+ }
79
+ }
80
+ // Prompt for build ID if not provided
81
+ if (!buildId) {
82
+ if (!hasTTY) {
83
+ consola.error('You must provide a build ID when running in non-interactive environment.');
84
+ process.exit(1);
85
+ }
86
+ const builds = await appBuildsService.findAll({ appId });
87
+ if (builds.length === 0) {
88
+ consola.error('You must create a build before creating a deployment.');
89
+ process.exit(1);
90
+ }
91
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
92
+ buildId = await prompt('Which build do you want to deploy:', {
93
+ type: 'select',
94
+ options: builds.map((build) => ({
95
+ label: `Build #${build.numberAsString} (${build.platform} - ${build.type})`,
96
+ value: build.id,
97
+ })),
98
+ });
99
+ if (!buildId) {
100
+ consola.error('You must select a build to deploy.');
101
+ process.exit(1);
102
+ }
103
+ }
104
+ // Get build information to determine platform
105
+ const build = await appBuildsService.findOne({ appId, appBuildId: buildId });
106
+ // Prompt for destination if not provided
107
+ if (!destination) {
108
+ if (!hasTTY) {
109
+ consola.error('You must provide a destination when running in non-interactive environment.');
110
+ process.exit(1);
111
+ }
112
+ const destinations = await appDestinationsService.findAll({
113
+ appId,
114
+ platform: build.platform,
115
+ });
116
+ if (destinations.length === 0) {
117
+ consola.error(`You must create a destination for the ${build.platform} platform before creating a deployment.`);
118
+ process.exit(1);
119
+ }
120
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
121
+ destination = await prompt('Which destination do you want to deploy to:', {
122
+ type: 'select',
123
+ options: destinations.map((dest) => ({
124
+ label: dest.name,
125
+ value: dest.name,
126
+ })),
127
+ });
128
+ if (!destination) {
129
+ consola.error('You must select a destination to deploy to.');
130
+ process.exit(1);
131
+ }
132
+ }
133
+ // Create the deployment
134
+ consola.start('Creating deployment...');
135
+ const response = await appDeploymentsService.create({
136
+ appId,
137
+ appBuildId: buildId,
138
+ appDestinationName: destination,
139
+ });
140
+ consola.success('Deployment created successfully.');
141
+ consola.info(`Deployment ID: ${response.id}`);
142
+ consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${response.id}`);
143
+ // Wait for deployment job to complete by default, unless --detached flag is set
144
+ const shouldWait = !options.detached;
145
+ if (shouldWait) {
146
+ let lastPrintedLogNumber = 0;
147
+ let isWaitingForStart = true;
148
+ // Poll deployment status until completion
149
+ while (true) {
150
+ try {
151
+ const deployment = await appDeploymentsService.findOne({
152
+ appId,
153
+ appDeploymentId: response.id,
154
+ relations: 'job,job.jobLogs',
155
+ });
156
+ if (!deployment.job) {
157
+ await wait(3000);
158
+ continue;
159
+ }
160
+ const jobStatus = deployment.job.status;
161
+ // Show spinner while queued or pending
162
+ if (jobStatus === 'queued' || jobStatus === 'pending') {
163
+ if (isWaitingForStart) {
164
+ consola.start(`Waiting for deployment to start (status: ${jobStatus})...`);
165
+ }
166
+ await wait(3000);
167
+ continue;
168
+ }
169
+ // Stop spinner when job moves to in_progress
170
+ if (isWaitingForStart && jobStatus === 'in_progress') {
171
+ isWaitingForStart = false;
172
+ consola.success('Deployment started...');
173
+ }
174
+ // Print new logs
175
+ if (deployment.job.jobLogs && deployment.job.jobLogs.length > 0) {
176
+ const newLogs = deployment.job.jobLogs
177
+ .filter((log) => log.number > lastPrintedLogNumber)
178
+ .sort((a, b) => a.number - b.number);
179
+ for (const log of newLogs) {
180
+ console.log(unescapeAnsi(log.payload));
181
+ lastPrintedLogNumber = log.number;
182
+ }
183
+ }
184
+ // Handle terminal states
185
+ if (jobStatus === 'succeeded' ||
186
+ jobStatus === 'failed' ||
187
+ jobStatus === 'canceled' ||
188
+ jobStatus === 'rejected' ||
189
+ jobStatus === 'timed_out') {
190
+ console.log(); // New line for better readability
191
+ if (jobStatus === 'succeeded') {
192
+ consola.success('Deployment completed successfully.');
193
+ process.exit(0);
194
+ }
195
+ else if (jobStatus === 'failed') {
196
+ consola.error('Deployment failed.');
197
+ process.exit(1);
198
+ }
199
+ else if (jobStatus === 'canceled') {
200
+ consola.warn('Deployment was canceled.');
201
+ process.exit(1);
202
+ }
203
+ else if (jobStatus === 'rejected') {
204
+ consola.error('Deployment was rejected.');
205
+ process.exit(1);
206
+ }
207
+ else if (jobStatus === 'timed_out') {
208
+ consola.error('Deployment timed out.');
209
+ process.exit(1);
210
+ }
211
+ }
212
+ // Wait before next poll (3 seconds)
213
+ await wait(3000);
214
+ }
215
+ catch (error) {
216
+ consola.error('Error polling deployment status:', error);
217
+ process.exit(1);
218
+ }
219
+ }
220
+ }
221
+ },
222
+ });
package/dist/index.js CHANGED
@@ -23,6 +23,8 @@ const config = defineConfig({
23
23
  doctor: await import('./commands/doctor.js').then((mod) => mod.default),
24
24
  'apps:create': await import('./commands/apps/create.js').then((mod) => mod.default),
25
25
  'apps:delete': await import('./commands/apps/delete.js').then((mod) => mod.default),
26
+ 'apps:builds:cancel': await import('./commands/apps/builds/cancel.js').then((mod) => mod.default),
27
+ 'apps:builds:create': await import('./commands/apps/builds/create.js').then((mod) => mod.default),
26
28
  'apps:bundles:create': await import('./commands/apps/bundles/create.js').then((mod) => mod.default),
27
29
  'apps:bundles:delete': await import('./commands/apps/bundles/delete.js').then((mod) => mod.default),
28
30
  'apps:bundles:update': await import('./commands/apps/bundles/update.js').then((mod) => mod.default),
@@ -31,6 +33,8 @@ const config = defineConfig({
31
33
  'apps:channels:get': await import('./commands/apps/channels/get.js').then((mod) => mod.default),
32
34
  'apps:channels:list': await import('./commands/apps/channels/list.js').then((mod) => mod.default),
33
35
  'apps:channels:update': await import('./commands/apps/channels/update.js').then((mod) => mod.default),
36
+ 'apps:deployments:create': await import('./commands/apps/deployments/create.js').then((mod) => mod.default),
37
+ 'apps:deployments:cancel': await import('./commands/apps/deployments/cancel.js').then((mod) => mod.default),
34
38
  'apps:devices:delete': await import('./commands/apps/devices/delete.js').then((mod) => mod.default),
35
39
  'manifests:generate': await import('./commands/manifests/generate.js').then((mod) => mod.default),
36
40
  'organizations:create': await import('./commands/organizations/create.js').then((mod) => mod.default),
@@ -0,0 +1,52 @@
1
+ import authorizationService from '../services/authorization-service.js';
2
+ import httpClient from '../utils/http-client.js';
3
+ class AppBuildsServiceImpl {
4
+ httpClient;
5
+ constructor(httpClient) {
6
+ this.httpClient = httpClient;
7
+ }
8
+ async create(dto) {
9
+ const { appId, ...bodyData } = dto;
10
+ const response = await this.httpClient.post(`/v1/apps/${appId}/builds`, bodyData, {
11
+ headers: {
12
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
13
+ },
14
+ });
15
+ return response.data;
16
+ }
17
+ async findAll(dto) {
18
+ const { appId } = dto;
19
+ const response = await this.httpClient.get(`/v1/apps/${appId}/builds`, {
20
+ headers: {
21
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
22
+ },
23
+ });
24
+ return response.data;
25
+ }
26
+ async findOne(dto) {
27
+ const { appId, appBuildId, relations } = dto;
28
+ const params = {};
29
+ if (relations) {
30
+ params.relations = relations;
31
+ }
32
+ const response = await this.httpClient.get(`/v1/apps/${appId}/builds/${appBuildId}`, {
33
+ headers: {
34
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
35
+ },
36
+ params,
37
+ });
38
+ return response.data;
39
+ }
40
+ async downloadArtifact(dto) {
41
+ const { appId, appBuildId, artifactId } = dto;
42
+ const response = await this.httpClient.get(`/v1/apps/${appId}/builds/${appBuildId}/artifacts/${artifactId}/download`, {
43
+ headers: {
44
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
45
+ },
46
+ responseType: 'arraybuffer',
47
+ });
48
+ return response.data;
49
+ }
50
+ }
51
+ const appBuildsService = new AppBuildsServiceImpl(httpClient);
52
+ export default appBuildsService;
@@ -0,0 +1,24 @@
1
+ import authorizationService from '../services/authorization-service.js';
2
+ import httpClient from '../utils/http-client.js';
3
+ class AppCertificatesServiceImpl {
4
+ httpClient;
5
+ constructor(httpClient) {
6
+ this.httpClient = httpClient;
7
+ }
8
+ async findAll(dto) {
9
+ const { appId, platform } = dto;
10
+ const params = {};
11
+ if (platform) {
12
+ params.platform = platform;
13
+ }
14
+ const response = await this.httpClient.get(`/v1/apps/${appId}/certificates`, {
15
+ headers: {
16
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
17
+ },
18
+ params,
19
+ });
20
+ return response.data;
21
+ }
22
+ }
23
+ const appCertificatesService = new AppCertificatesServiceImpl(httpClient);
24
+ export default appCertificatesService;
@@ -0,0 +1,48 @@
1
+ import authorizationService from '../services/authorization-service.js';
2
+ import httpClient from '../utils/http-client.js';
3
+ class AppDeploymentsServiceImpl {
4
+ httpClient;
5
+ constructor(httpClient) {
6
+ this.httpClient = httpClient;
7
+ }
8
+ async create(dto) {
9
+ const { appId, appBuildId, appDestinationName } = dto;
10
+ const bodyData = {
11
+ appBuildId,
12
+ };
13
+ if (appDestinationName) {
14
+ bodyData.appDestinationName = appDestinationName;
15
+ }
16
+ const response = await this.httpClient.post(`/v1/apps/${appId}/deployments`, bodyData, {
17
+ headers: {
18
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
19
+ },
20
+ });
21
+ return response.data;
22
+ }
23
+ async findAll(dto) {
24
+ const { appId } = dto;
25
+ const response = await this.httpClient.get(`/v1/apps/${appId}/deployments`, {
26
+ headers: {
27
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
28
+ },
29
+ });
30
+ return response.data;
31
+ }
32
+ async findOne(dto) {
33
+ const { appId, appDeploymentId, relations } = dto;
34
+ const params = {};
35
+ if (relations) {
36
+ params.relations = relations;
37
+ }
38
+ const response = await this.httpClient.get(`/v1/apps/${appId}/deployments/${appDeploymentId}`, {
39
+ headers: {
40
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
41
+ },
42
+ params,
43
+ });
44
+ return response.data;
45
+ }
46
+ }
47
+ const appDeploymentsService = new AppDeploymentsServiceImpl(httpClient);
48
+ export default appDeploymentsService;
@@ -0,0 +1,24 @@
1
+ import authorizationService from '../services/authorization-service.js';
2
+ import httpClient from '../utils/http-client.js';
3
+ class AppDestinationsServiceImpl {
4
+ httpClient;
5
+ constructor(httpClient) {
6
+ this.httpClient = httpClient;
7
+ }
8
+ async findAll(dto) {
9
+ const { appId, platform } = dto;
10
+ const params = {};
11
+ if (platform) {
12
+ params.platform = platform;
13
+ }
14
+ const response = await this.httpClient.get(`/v1/apps/${appId}/destinations`, {
15
+ headers: {
16
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
17
+ },
18
+ params,
19
+ });
20
+ return response.data;
21
+ }
22
+ }
23
+ const appDestinationsService = new AppDestinationsServiceImpl(httpClient);
24
+ export default appDestinationsService;
@@ -0,0 +1,19 @@
1
+ import authorizationService from '../services/authorization-service.js';
2
+ import httpClient from '../utils/http-client.js';
3
+ class AppEnvironmentsServiceImpl {
4
+ httpClient;
5
+ constructor(httpClient) {
6
+ this.httpClient = httpClient;
7
+ }
8
+ async findAll(dto) {
9
+ const { appId } = dto;
10
+ const response = await this.httpClient.get(`/v1/apps/${appId}/environments`, {
11
+ headers: {
12
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
13
+ },
14
+ });
15
+ return response.data;
16
+ }
17
+ }
18
+ const appEnvironmentsService = new AppEnvironmentsServiceImpl(httpClient);
19
+ export default appEnvironmentsService;
@@ -0,0 +1,19 @@
1
+ import authorizationService from '../services/authorization-service.js';
2
+ import httpClient from '../utils/http-client.js';
3
+ class JobsServiceImpl {
4
+ httpClient;
5
+ constructor(httpClient) {
6
+ this.httpClient = httpClient;
7
+ }
8
+ async update(options) {
9
+ const { jobId, dto } = options;
10
+ const response = await this.httpClient.patch(`/v1/jobs/${jobId}`, dto, {
11
+ headers: {
12
+ Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`,
13
+ },
14
+ });
15
+ return response.data;
16
+ }
17
+ }
18
+ const jobsService = new JobsServiceImpl(httpClient);
19
+ export default jobsService;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Unescape ANSI color codes in a string.
3
+ * Converts escaped sequences like \033 or \x1b to actual escape characters.
4
+ *
5
+ * @param str - The string containing escaped ANSI codes.
6
+ * @returns The string with unescaped ANSI codes.
7
+ */
8
+ export const unescapeAnsi = (str) => {
9
+ return str
10
+ .replace(/\\033/g, '\x1b')
11
+ .replace(/\\x1b/g, '\x1b')
12
+ .replace(/\\n/g, '\n')
13
+ .replace(/\\r/g, '\r')
14
+ .replace(/\\t/g, '\t');
15
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Wait for a specified number of milliseconds.
3
+ *
4
+ * @param ms - The number of milliseconds to wait.
5
+ * @returns A promise that resolves after the specified time.
6
+ */
7
+ export const wait = (ms) => {
8
+ return new Promise((resolve) => setTimeout(resolve, ms));
9
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capawesome/cli",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "The Capawesome Cloud Command Line Interface (CLI) to manage Live Updates and more.",
5
5
  "type": "module",
6
6
  "scripts": {