@capawesome/cli 4.6.0-dev.bda9e9d.1774345600 → 4.7.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/commands/apps/builds/create.js +98 -12
  3. package/dist/commands/apps/bundles/create.js +4 -2
  4. package/dist/commands/apps/bundles/delete.js +2 -3
  5. package/dist/commands/apps/bundles/update.js +2 -3
  6. package/dist/commands/apps/certificates/delete.js +28 -5
  7. package/dist/commands/apps/certificates/get.js +28 -5
  8. package/dist/commands/apps/deployments/create.js +5 -77
  9. package/dist/commands/apps/devices/forcechannel.js +9 -7
  10. package/dist/commands/apps/devices/unforcechannel.js +9 -7
  11. package/dist/commands/apps/link.js +34 -0
  12. package/dist/commands/apps/link.test.js +94 -0
  13. package/dist/commands/apps/liveupdates/bundle.js +7 -2
  14. package/dist/commands/apps/liveupdates/create.js +148 -44
  15. package/dist/commands/apps/liveupdates/create.test.js +300 -0
  16. package/dist/commands/apps/liveupdates/generate-manifest.js +12 -1
  17. package/dist/commands/apps/liveupdates/generate-manifest.test.js +21 -1
  18. package/dist/commands/apps/liveupdates/register.js +10 -15
  19. package/dist/commands/apps/liveupdates/upload.js +18 -16
  20. package/dist/commands/apps/transfer.js +47 -0
  21. package/dist/commands/apps/transfer.test.js +123 -0
  22. package/dist/commands/apps/unlink.js +35 -0
  23. package/dist/commands/apps/unlink.test.js +99 -0
  24. package/dist/commands/manifests/generate.js +1 -1
  25. package/dist/index.js +3 -0
  26. package/dist/services/app-build-sources.js +120 -0
  27. package/dist/services/app-devices.js +8 -0
  28. package/dist/services/apps.js +25 -0
  29. package/dist/services/authorization-service.js +5 -1
  30. package/dist/services/jobs.js +13 -0
  31. package/dist/types/app-build-source.js +1 -0
  32. package/dist/types/index.js +1 -0
  33. package/dist/utils/custom-properties.js +22 -0
  34. package/dist/utils/file.js +8 -1
  35. package/dist/utils/git.js +91 -0
  36. package/dist/utils/git.test.js +130 -0
  37. package/dist/utils/{build.js → job.js} +26 -23
  38. package/dist/utils/prompt.js +1 -1
  39. package/dist/utils/zip.js +19 -2
  40. package/package.json +2 -1
@@ -0,0 +1,34 @@
1
+ import appsService from '../../services/apps.js';
2
+ import { withAuth } from '../../utils/auth.js';
3
+ import { isInteractive } from '../../utils/environment.js';
4
+ import { getGitRemoteInfo } from '../../utils/git.js';
5
+ import { promptAppSelection, promptOrganizationSelection } from '../../utils/prompt.js';
6
+ import { defineCommand, defineOptions } from '@robingenz/zli';
7
+ import consola from 'consola';
8
+ import { z } from 'zod';
9
+ export default defineCommand({
10
+ description: 'Connect a git repository to an app.',
11
+ options: defineOptions(z.object({
12
+ appId: z.string().optional().describe('ID of the app.'),
13
+ })),
14
+ action: withAuth(async (options, args) => {
15
+ let { appId } = options;
16
+ if (!appId) {
17
+ if (!isInteractive()) {
18
+ consola.error('You must provide the app ID when running in non-interactive environment.');
19
+ process.exit(1);
20
+ }
21
+ const organizationId = await promptOrganizationSelection();
22
+ appId = await promptAppSelection(organizationId);
23
+ }
24
+ const gitRemoteInfo = getGitRemoteInfo();
25
+ await appsService.linkRepository({
26
+ appId,
27
+ ownerSlug: gitRemoteInfo.ownerSlug,
28
+ provider: gitRemoteInfo.provider,
29
+ repositorySlug: gitRemoteInfo.repositorySlug,
30
+ projectSlug: gitRemoteInfo.projectSlug,
31
+ });
32
+ consola.success('Repository connected successfully.');
33
+ }),
34
+ });
@@ -0,0 +1,94 @@
1
+ import { DEFAULT_API_BASE_URL } from '../../config/consts.js';
2
+ import authorizationService from '../../services/authorization-service.js';
3
+ import { promptAppSelection, promptOrganizationSelection } from '../../utils/prompt.js';
4
+ import userConfig from '../../utils/user-config.js';
5
+ import consola from 'consola';
6
+ import nock from 'nock';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import linkCommand from './link.js';
9
+ vi.mock('@/utils/user-config.js');
10
+ vi.mock('@/utils/prompt.js');
11
+ vi.mock('@/services/authorization-service.js');
12
+ vi.mock('consola');
13
+ vi.mock('@/utils/environment.js', () => ({
14
+ isInteractive: () => true,
15
+ }));
16
+ vi.mock('@/utils/git.js', () => ({
17
+ getGitRemoteInfo: () => ({
18
+ ownerSlug: 'capawesome-team',
19
+ provider: 'github',
20
+ repositorySlug: 'cli',
21
+ }),
22
+ }));
23
+ describe('apps-link', () => {
24
+ const mockUserConfig = vi.mocked(userConfig);
25
+ const mockPromptOrganizationSelection = vi.mocked(promptOrganizationSelection);
26
+ const mockPromptAppSelection = vi.mocked(promptAppSelection);
27
+ const mockConsola = vi.mocked(consola);
28
+ const mockAuthorizationService = vi.mocked(authorizationService);
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ mockUserConfig.read.mockReturnValue({ token: 'test-token' });
32
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token');
33
+ mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
34
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
35
+ throw new Error(`Process exited with code ${code}`);
36
+ });
37
+ });
38
+ afterEach(() => {
39
+ nock.cleanAll();
40
+ vi.restoreAllMocks();
41
+ });
42
+ it('should link repository with provided app ID', async () => {
43
+ const appId = 'app-123';
44
+ const testToken = 'test-token';
45
+ const options = { appId };
46
+ const scope = nock(DEFAULT_API_BASE_URL)
47
+ .put(`/v1/apps/${appId}/repository`, {
48
+ ownerSlug: 'capawesome-team',
49
+ provider: 'github',
50
+ repositorySlug: 'cli',
51
+ })
52
+ .matchHeader('Authorization', `Bearer ${testToken}`)
53
+ .reply(200, { id: appId, name: 'Test App' });
54
+ await linkCommand.action(options, undefined);
55
+ expect(scope.isDone()).toBe(true);
56
+ expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.');
57
+ });
58
+ it('should prompt for organization and app when app ID is not provided', async () => {
59
+ const appId = 'app-123';
60
+ const orgId = 'org-1';
61
+ const testToken = 'test-token';
62
+ const options = {};
63
+ mockPromptOrganizationSelection.mockResolvedValueOnce(orgId);
64
+ mockPromptAppSelection.mockResolvedValueOnce(appId);
65
+ const scope = nock(DEFAULT_API_BASE_URL)
66
+ .put(`/v1/apps/${appId}/repository`, {
67
+ ownerSlug: 'capawesome-team',
68
+ provider: 'github',
69
+ repositorySlug: 'cli',
70
+ })
71
+ .matchHeader('Authorization', `Bearer ${testToken}`)
72
+ .reply(200, { id: appId, name: 'Test App' });
73
+ await linkCommand.action(options, undefined);
74
+ expect(scope.isDone()).toBe(true);
75
+ expect(mockPromptOrganizationSelection).toHaveBeenCalled();
76
+ expect(mockPromptAppSelection).toHaveBeenCalledWith(orgId);
77
+ expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.');
78
+ });
79
+ it('should handle API error', async () => {
80
+ const appId = 'app-123';
81
+ const testToken = 'test-token';
82
+ const options = { appId };
83
+ const scope = nock(DEFAULT_API_BASE_URL)
84
+ .put(`/v1/apps/${appId}/repository`, {
85
+ ownerSlug: 'capawesome-team',
86
+ provider: 'github',
87
+ repositorySlug: 'cli',
88
+ })
89
+ .matchHeader('Authorization', `Bearer ${testToken}`)
90
+ .reply(400, { message: 'Git provider not connected' });
91
+ await expect(linkCommand.action(options, undefined)).rejects.toThrow();
92
+ expect(scope.isDone()).toBe(true);
93
+ });
94
+ });
@@ -1,5 +1,5 @@
1
1
  import { isInteractive } from '../../../utils/environment.js';
2
- import { directoryContainsSourceMaps, fileExistsAtPath, isDirectory } 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 zip from '../../../utils/zip.js';
@@ -9,7 +9,7 @@ import fs from 'fs';
9
9
  import pathModule from 'path';
10
10
  import { z } from 'zod';
11
11
  export default defineCommand({
12
- description: 'Generate manifest file and compress web assets into a bundle.zip file.',
12
+ description: 'Generate manifest file and compress locally built web assets into a bundle.zip file.',
13
13
  options: defineOptions(z.object({
14
14
  inputPath: z.string().optional().describe('Path to the web assets directory.'),
15
15
  outputPath: z
@@ -62,6 +62,11 @@ export default defineCommand({
62
62
  consola.error(`Directory must contain an index.html file: ${inputPath}`);
63
63
  process.exit(1);
64
64
  }
65
+ // Check for symlinks
66
+ const containsSymlinks = await directoryContainsSymlinks(inputPath);
67
+ if (containsSymlinks) {
68
+ consola.warn('Symbolic links were detected in the specified path. Symbolic links are skipped during bundling.');
69
+ }
65
70
  // Check for source maps
66
71
  const containsSourceMaps = await directoryContainsSourceMaps(inputPath);
67
72
  if (containsSourceMaps) {
@@ -1,18 +1,27 @@
1
1
  import { DEFAULT_CONSOLE_BASE_URL } from '../../../config/consts.js';
2
+ import appBuildSourcesService from '../../../services/app-build-sources.js';
2
3
  import appBuildsService from '../../../services/app-builds.js';
3
- import appDeploymentsService from '../../../services/app-deployments.js';
4
4
  import appCertificatesService from '../../../services/app-certificates.js';
5
+ import appDeploymentsService from '../../../services/app-deployments.js';
5
6
  import appEnvironmentsService from '../../../services/app-environments.js';
7
+ import { parseKeyValuePairs } from '../../../utils/app-environments.js';
6
8
  import { withAuth } from '../../../utils/auth.js';
7
- import { waitForBuildCompletion } from '../../../utils/build.js';
9
+ import { parseCustomProperties } from '../../../utils/custom-properties.js';
8
10
  import { isInteractive } from '../../../utils/environment.js';
11
+ import { waitForJobCompletion } from '../../../utils/job.js';
9
12
  import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
13
+ import zip from '../../../utils/zip.js';
10
14
  import { defineCommand, defineOptions } from '@robingenz/zli';
11
15
  import consola from 'consola';
16
+ import fs from 'fs/promises';
17
+ import path from 'path';
12
18
  import { z } from 'zod';
13
19
  export default defineCommand({
14
- description: 'Create a new live update.',
20
+ description: 'Create a new live update by building and deploying web assets using Capawesome Cloud Runners.',
15
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.'),
16
25
  appId: z
17
26
  .uuid({
18
27
  message: 'App ID must be a UUID.',
@@ -20,33 +29,81 @@ export default defineCommand({
20
29
  .optional()
21
30
  .describe('App ID to create the live update for.'),
22
31
  certificate: z.string().optional().describe('The name of the certificate to use for the build.'),
23
- channel: z.string().optional().describe('The name of the channel to deploy to.'),
24
- gitRef: z.string().optional().describe('The Git reference (branch, tag, or commit SHA) to build.'),
25
- environment: z.string().optional().describe('The name of the environment to use for the build.'),
26
- stack: z
27
- .enum(['macos-sequoia', 'macos-tahoe'], {
28
- message: 'Build stack must be either `macos-sequoia` or `macos-tahoe`.',
29
- })
32
+ channel: z
33
+ .array(z.string())
30
34
  .optional()
31
- .describe('The build stack to use for the build process.'),
32
- androidMin: z.string().optional().describe('The minimum Android versionCode for the live update.'),
33
- androidMax: z.string().optional().describe('The maximum Android versionCode for the live update.'),
34
- androidEq: z.string().optional().describe('The exact Android versionCode for the live update.'),
35
- iosMin: z.string().optional().describe('The minimum iOS CFBundleVersion for the live update.'),
36
- iosMax: z.string().optional().describe('The maximum iOS CFBundleVersion for the live update.'),
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.'),
37
43
  iosEq: z.string().optional().describe('The exact iOS CFBundleVersion for the live update.'),
38
- rolloutPercentage: z
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
39
49
  .number()
40
50
  .int()
41
51
  .min(0)
42
52
  .max(100)
43
53
  .optional()
44
54
  .describe('The rollout percentage for the deployment (0-100). Default: 100.'),
45
- json: z.boolean().optional().describe('Output in JSON format.'),
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.'),
46
70
  yes: z.boolean().optional().describe('Skip confirmation prompts.'),
47
71
  }), { y: 'yes' }),
48
72
  action: withAuth(async (options) => {
49
- let { appId, certificate, channel, gitRef, environment, json, stack } = 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
+ }
50
107
  // Prompt for app ID if not provided
51
108
  if (!appId) {
52
109
  if (!isInteractive()) {
@@ -56,10 +113,10 @@ export default defineCommand({
56
113
  const organizationId = await promptOrganizationSelection({ allowCreate: true });
57
114
  appId = await promptAppSelection(organizationId, { allowCreate: true });
58
115
  }
59
- // Prompt for git ref if not provided
60
- if (!gitRef) {
116
+ // Prompt for git ref if not provided and no path or url specified
117
+ if (!sourcePath && !url && !gitRef) {
61
118
  if (!isInteractive()) {
62
- consola.error('You must provide a git ref when running in non-interactive environment.');
119
+ consola.error('You must provide a git ref, path, or url when running in non-interactive environment.');
63
120
  process.exit(1);
64
121
  }
65
122
  gitRef = await prompt('Enter the Git reference (branch, tag, or commit SHA):', {
@@ -71,18 +128,19 @@ export default defineCommand({
71
128
  }
72
129
  }
73
130
  // Prompt for channel if not provided
74
- if (!channel) {
131
+ if (!channel || channel.length === 0) {
75
132
  if (!isInteractive()) {
76
- consola.error('You must provide a channel when running in non-interactive environment.');
133
+ consola.error('You must provide at least one channel when running in non-interactive environment.');
77
134
  process.exit(1);
78
135
  }
79
- channel = await prompt('Enter the channel name to deploy to:', {
136
+ const channelName = await prompt('Enter the channel name to deploy to:', {
80
137
  type: 'text',
81
138
  });
82
- if (!channel) {
139
+ if (!channelName) {
83
140
  consola.error('You must provide a channel.');
84
141
  process.exit(1);
85
142
  }
143
+ channel = [channelName];
86
144
  }
87
145
  // Prompt for environment if not provided
88
146
  if (!environment && !options.yes && isInteractive()) {
@@ -126,9 +184,48 @@ export default defineCommand({
126
184
  }
127
185
  }
128
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
+ }
129
224
  // Create the web build
130
225
  consola.start('Creating build...');
131
226
  const response = await appBuildsService.create({
227
+ adHocEnvironmentVariables,
228
+ appBuildSourceId,
132
229
  appCertificateName: certificate,
133
230
  appEnvironmentName: environment,
134
231
  appId,
@@ -141,21 +238,24 @@ export default defineCommand({
141
238
  consola.info(`Build URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/builds/${response.id}`);
142
239
  consola.success('Build created successfully.');
143
240
  // Wait for build to complete
144
- await waitForBuildCompletion({ appId, appBuildId: response.id });
241
+ await waitForJobCompletion({ jobId: response.jobId });
145
242
  consola.success('Build completed successfully.');
146
243
  console.log();
147
- // Update build with version constraints if any are provided
148
- const hasVersionConstraints = options.androidMin ||
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 ||
149
248
  options.androidMax ||
150
249
  options.androidEq ||
151
250
  options.iosMin ||
152
251
  options.iosMax ||
153
252
  options.iosEq;
154
- if (hasVersionConstraints) {
155
- consola.start('Updating version constraints...');
253
+ if (hasUpdateFields) {
254
+ consola.start('Updating build...');
156
255
  await appBuildsService.update({
157
256
  appId,
158
257
  appBuildId: response.id,
258
+ customProperties,
159
259
  minAndroidAppVersionCode: options.androidMin,
160
260
  maxAndroidAppVersionCode: options.androidMax,
161
261
  eqAndroidAppVersionCode: options.androidEq,
@@ -163,26 +263,30 @@ export default defineCommand({
163
263
  maxIosAppVersionCode: options.iosMax,
164
264
  eqIosAppVersionCode: options.iosEq,
165
265
  });
166
- consola.success('Version constraints updated successfully.');
266
+ consola.success('Build updated successfully.');
167
267
  }
168
- // Deploy to channel
169
- consola.start('Creating deployment...');
268
+ // Deploy to channels
170
269
  const rolloutPercentage = (options.rolloutPercentage ?? 100) / 100;
171
- const deployment = await appDeploymentsService.create({
172
- appId,
173
- appBuildId: response.id,
174
- appChannelName: channel,
175
- rolloutPercentage,
176
- });
177
- consola.info(`Deployment ID: ${deployment.id}`);
178
- consola.info(`Deployment URL: ${DEFAULT_CONSOLE_BASE_URL}/apps/${appId}/deployments/${deployment.id}`);
179
- consola.success('Deployment created successfully.');
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
+ }
180
284
  // Output JSON if json flag is set
181
285
  if (json) {
182
286
  console.log(JSON.stringify({
183
287
  buildId: response.id,
184
288
  buildNumberAsString: response.numberAsString,
185
- deploymentId: deployment.id,
289
+ deploymentIds,
186
290
  }, null, 2));
187
291
  }
188
292
  }),