@capawesome/cli 4.17.2 → 4.18.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 (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/commands/apps/automations/create.js +141 -0
  3. package/dist/commands/apps/automations/create.test.js +162 -0
  4. package/dist/commands/apps/automations/delete.js +66 -0
  5. package/dist/commands/apps/automations/delete.test.js +127 -0
  6. package/dist/commands/apps/automations/get.js +62 -0
  7. package/dist/commands/apps/automations/get.test.js +118 -0
  8. package/dist/commands/apps/automations/list.js +46 -0
  9. package/dist/commands/apps/automations/list.test.js +92 -0
  10. package/dist/commands/apps/automations/update.js +118 -0
  11. package/dist/commands/apps/automations/update.test.js +124 -0
  12. package/dist/commands/apps/builds/create.js +31 -1
  13. package/dist/commands/apps/builds/create.test.js +15 -0
  14. package/dist/commands/apps/builds/run.js +265 -0
  15. package/dist/commands/apps/configurations/create.js +51 -0
  16. package/dist/commands/apps/configurations/create.test.js +120 -0
  17. package/dist/commands/apps/configurations/delete.js +61 -0
  18. package/dist/commands/apps/configurations/delete.test.js +112 -0
  19. package/dist/commands/apps/configurations/get.js +65 -0
  20. package/dist/commands/apps/configurations/get.test.js +119 -0
  21. package/dist/commands/apps/configurations/list.js +39 -0
  22. package/dist/commands/apps/configurations/list.test.js +94 -0
  23. package/dist/commands/apps/configurations/update.js +61 -0
  24. package/dist/commands/apps/configurations/update.test.js +122 -0
  25. package/dist/index.js +11 -0
  26. package/dist/services/app-automations.js +64 -0
  27. package/dist/services/app-configurations.js +77 -0
  28. package/dist/types/app-automation.js +1 -0
  29. package/dist/types/app-configuration.js +1 -0
  30. package/dist/types/index.js +1 -0
  31. package/dist/utils/android-emulator.js +170 -0
  32. package/dist/utils/ios-simulator.js +57 -0
  33. package/dist/utils/zip.js +4 -0
  34. package/package.json +2 -2
@@ -0,0 +1,265 @@
1
+ import appBuildsService from '../../../services/app-builds.js';
2
+ import { bootAndroidEmulator, findAllAndroidEmulators, installAndroidApp, launchAndroidApp, } from '../../../utils/android-emulator.js';
3
+ import { withAuth } from '../../../utils/auth.js';
4
+ import { isInteractive } from '../../../utils/environment.js';
5
+ import { bootIosSimulator, findAllIosSimulators, installIosApp, launchIosApp, } from '../../../utils/ios-simulator.js';
6
+ import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
7
+ import zip from '../../../utils/zip.js';
8
+ import { defineCommand, defineOptions } from '@robingenz/zli';
9
+ import consola from 'consola';
10
+ import fs from 'fs/promises';
11
+ import os from 'os';
12
+ import path from 'path';
13
+ import { z } from 'zod';
14
+ export default defineCommand({
15
+ description: 'Run an app build on a local emulator or simulator.',
16
+ options: defineOptions(z.object({
17
+ appId: z
18
+ .uuid({
19
+ message: 'App ID must be a UUID.',
20
+ })
21
+ .optional()
22
+ .describe('App ID the build belongs to.'),
23
+ buildId: z
24
+ .uuid({
25
+ message: 'Build ID must be a UUID.',
26
+ })
27
+ .optional()
28
+ .describe('Build ID to run.'),
29
+ buildNumber: z.string().optional().describe('Build number to run (e.g., "1", "42").'),
30
+ target: z.string().optional().describe('Run on a specific target device by its ID.'),
31
+ targetName: z
32
+ .string()
33
+ .optional()
34
+ .describe('Run on a specific target device by its name (e.g. "Pixel 8 Pro", "iPhone 17 Pro").'),
35
+ targetNameSdkVersion: z
36
+ .string()
37
+ .optional()
38
+ .describe('Run on a target device by name with a specific SDK version when using --target-name (e.g. "26.5" for iOS 26.5 or "35" for Android API 35).'),
39
+ })),
40
+ action: withAuth(async (options) => {
41
+ let { appId, buildId } = options;
42
+ const { buildNumber, target, targetName, targetNameSdkVersion } = options;
43
+ if (targetNameSdkVersion && !targetName) {
44
+ consola.error('You must provide --target-name when using --target-name-sdk-version.');
45
+ process.exit(1);
46
+ }
47
+ // Prompt for app ID if not provided
48
+ if (!appId) {
49
+ if (!isInteractive()) {
50
+ consola.error('You must provide an app ID when running in non-interactive environment.');
51
+ process.exit(1);
52
+ }
53
+ const organizationId = await promptOrganizationSelection();
54
+ appId = await promptAppSelection(organizationId);
55
+ }
56
+ // Convert build number to build ID if provided
57
+ if (!buildId && buildNumber) {
58
+ const builds = await appBuildsService.findAll({ appId, numberAsString: buildNumber });
59
+ if (builds.length === 0) {
60
+ consola.error(`Build #${buildNumber} not found.`);
61
+ process.exit(1);
62
+ }
63
+ buildId = builds[0]?.id;
64
+ }
65
+ // Prompt for build ID if not provided
66
+ if (!buildId) {
67
+ if (!isInteractive()) {
68
+ consola.error('You must provide a build ID when running in non-interactive environment.');
69
+ process.exit(1);
70
+ }
71
+ const builds = await appBuildsService.findAll({ appId });
72
+ if (builds.length === 0) {
73
+ consola.error('No builds found for this app.');
74
+ process.exit(1);
75
+ }
76
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
77
+ buildId = await prompt('Select the build you want to run:', {
78
+ type: 'select',
79
+ options: builds.map((build) => ({
80
+ label: `Build #${build.numberAsString || build.id} (${build.platform} - ${build.type})`,
81
+ value: build.id,
82
+ })),
83
+ });
84
+ if (!buildId) {
85
+ consola.error('You must select a build to run.');
86
+ process.exit(1);
87
+ }
88
+ }
89
+ const build = await appBuildsService.findOne({ appId, appBuildId: buildId, relations: 'appBuildArtifacts,job' });
90
+ const packageName = validateAppBuild(build);
91
+ const isAndroid = build.platform === 'android';
92
+ const androidTargets = isAndroid ? findAllAndroidEmulators().map(toAndroidTarget) : [];
93
+ const iosTargets = isAndroid ? [] : findAllIosSimulators().map(toIosTarget);
94
+ const targets = isAndroid ? androidTargets : iosTargets;
95
+ if (targets.length === 0) {
96
+ consola.error(isAndroid
97
+ ? 'No Android emulators found. Create one in Android Studio and try again.'
98
+ : 'No iOS simulators found. Install one via Xcode and try again.');
99
+ process.exit(1);
100
+ }
101
+ const artifactType = isAndroid ? 'apk' : 'app';
102
+ const artifact = build.appBuildArtifacts?.find((artifact) => artifact.type === artifactType && artifact.status === 'ready');
103
+ if (!artifact) {
104
+ consola.error(`No ${artifactType.toUpperCase()} artifact is available for this build.`);
105
+ process.exit(1);
106
+ }
107
+ consola.start('Downloading build...');
108
+ const artifactData = await appBuildsService.downloadArtifact({
109
+ appId,
110
+ appBuildId: buildId,
111
+ artifactId: artifact.id,
112
+ });
113
+ const temporaryDirectoryPath = await fs.mkdtemp(path.join(os.tmpdir(), 'capawesome-'));
114
+ consola.success('Build downloaded.');
115
+ const targetSelection = { target, targetName, targetNameSdkVersion };
116
+ try {
117
+ if (isAndroid) {
118
+ const apkPath = path.join(temporaryDirectoryPath, 'app.apk');
119
+ await fs.writeFile(apkPath, Buffer.from(artifactData));
120
+ await handleAndroidRun({ apkPath, packageName, targets: androidTargets, targetSelection });
121
+ }
122
+ else {
123
+ const appPath = await extractAppBundle(Buffer.from(artifactData), temporaryDirectoryPath);
124
+ await handleIosRun({ appPath, packageName, targets: iosTargets, targetSelection });
125
+ }
126
+ }
127
+ finally {
128
+ await fs.rm(temporaryDirectoryPath, { force: true, recursive: true });
129
+ }
130
+ }),
131
+ });
132
+ /**
133
+ * Ensure the build can be run locally and return its package name.
134
+ */
135
+ const validateAppBuild = (build) => {
136
+ if (build.job?.status !== 'succeeded') {
137
+ consola.error('The build has not succeeded yet. Cannot run incomplete builds.');
138
+ process.exit(1);
139
+ }
140
+ if (build.platform === 'web') {
141
+ consola.error('Web builds cannot be run on an emulator or simulator.');
142
+ process.exit(1);
143
+ }
144
+ if (build.platform === 'ios') {
145
+ if (process.platform !== 'darwin') {
146
+ consola.error('iOS builds can only be run on macOS.');
147
+ process.exit(1);
148
+ }
149
+ if (build.type !== 'simulator') {
150
+ consola.error(`Only iOS builds of the type "simulator" can be run on a simulator. This build has the type "${build.type}".`);
151
+ process.exit(1);
152
+ }
153
+ }
154
+ if (!build.packageName) {
155
+ consola.error('The package name of this build is unknown. Please create a new build and try again.');
156
+ process.exit(1);
157
+ }
158
+ return build.packageName;
159
+ };
160
+ /**
161
+ * Extract the app bundle from the downloaded artifact and return its path.
162
+ */
163
+ const extractAppBundle = async (artifactData, targetFolder) => {
164
+ await zip.unzipToFolder(artifactData, targetFolder);
165
+ const entries = await fs.readdir(targetFolder);
166
+ const appBundleName = entries.find((entry) => entry.endsWith('.app'));
167
+ if (!appBundleName) {
168
+ consola.error('The downloaded artifact does not contain an app bundle.');
169
+ process.exit(1);
170
+ }
171
+ return path.join(targetFolder, appBundleName);
172
+ };
173
+ const toAndroidTarget = (emulator) => ({
174
+ device: emulator,
175
+ id: emulator.id,
176
+ label: emulator.sdkVersion ? `${emulator.name} (API ${emulator.sdkVersion})` : emulator.name,
177
+ name: emulator.name,
178
+ running: emulator.running,
179
+ sdkVersion: emulator.sdkVersion,
180
+ });
181
+ const toIosTarget = (simulator) => ({
182
+ device: simulator,
183
+ id: simulator.id,
184
+ label: `${simulator.name} (iOS ${simulator.sdkVersion})`,
185
+ name: simulator.name,
186
+ running: simulator.running,
187
+ sdkVersion: simulator.sdkVersion,
188
+ });
189
+ /**
190
+ * Select the target device to run the build on, preferring targets that are already running.
191
+ */
192
+ const selectTarget = async (targets, selection) => {
193
+ const sortedTargets = [...targets].sort((target, otherTarget) => Number(otherTarget.running) - Number(target.running));
194
+ if (selection.target) {
195
+ const matchingTarget = sortedTargets.find((target) => target.id === selection.target);
196
+ if (!matchingTarget) {
197
+ consola.error(`No target device with the ID "${selection.target}" was found.`);
198
+ process.exit(1);
199
+ }
200
+ return matchingTarget.device;
201
+ }
202
+ if (selection.targetName) {
203
+ const matchingTargets = sortedTargets.filter((target) => target.name === selection.targetName &&
204
+ (!selection.targetNameSdkVersion || target.sdkVersion === selection.targetNameSdkVersion));
205
+ const matchingTarget = matchingTargets[0];
206
+ if (!matchingTarget) {
207
+ consola.error(`No target device named "${selection.targetName}" was found.`);
208
+ process.exit(1);
209
+ }
210
+ if (matchingTargets.length > 1) {
211
+ consola.warn(`Multiple target devices named "${selection.targetName}" were found. Using "${matchingTarget.label}". Use --target-name-sdk-version or --target to select a specific one.`);
212
+ }
213
+ return matchingTarget.device;
214
+ }
215
+ if (!isInteractive()) {
216
+ consola.error('You must provide a target device when running in non-interactive environment.');
217
+ process.exit(1);
218
+ }
219
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
220
+ const selectedIndex = await prompt('Select the target device you want to run the build on:', {
221
+ type: 'select',
222
+ options: sortedTargets.map((target, index) => ({
223
+ label: target.running ? `${target.label} (running)` : target.label,
224
+ value: `${index}`,
225
+ })),
226
+ });
227
+ const selectedTarget = sortedTargets[Number(selectedIndex)];
228
+ if (!selectedTarget) {
229
+ consola.error('You must select a target device to run the build on.');
230
+ process.exit(1);
231
+ }
232
+ return selectedTarget.device;
233
+ };
234
+ /**
235
+ * Run an Android build on a local emulator.
236
+ */
237
+ const handleAndroidRun = async (options) => {
238
+ const { apkPath, packageName, targets, targetSelection } = options;
239
+ const emulator = await selectTarget(targets, targetSelection);
240
+ consola.start(emulator.running ? `Using emulator "${emulator.name}"...` : `Starting emulator "${emulator.name}"...`);
241
+ const serial = await bootAndroidEmulator(emulator);
242
+ consola.success(`Emulator "${emulator.name}" is running.`);
243
+ consola.start('Installing app...');
244
+ installAndroidApp(serial, apkPath);
245
+ consola.success('App installed.');
246
+ consola.start('Launching app...');
247
+ launchAndroidApp(serial, packageName);
248
+ consola.success('App launched.');
249
+ };
250
+ /**
251
+ * Run an iOS build on a local simulator.
252
+ */
253
+ const handleIosRun = async (options) => {
254
+ const { appPath, packageName, targets, targetSelection } = options;
255
+ const simulator = await selectTarget(targets, targetSelection);
256
+ consola.start(simulator.running ? `Using simulator "${simulator.name}"...` : `Starting simulator "${simulator.name}"...`);
257
+ bootIosSimulator(simulator);
258
+ consola.success(`Simulator "${simulator.name}" is running.`);
259
+ consola.start('Installing app...');
260
+ installIosApp(simulator.id, appPath);
261
+ consola.success('App installed.');
262
+ consola.start('Launching app...');
263
+ launchIosApp(simulator.id, packageName);
264
+ consola.success('App launched.');
265
+ };
@@ -0,0 +1,51 @@
1
+ import appConfigurationsService from '../../../services/app-configurations.js';
2
+ import { withAuth } from '../../../utils/auth.js';
3
+ import { isInteractive } from '../../../utils/environment.js';
4
+ import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
5
+ import { defineCommand, defineOptions } from '@robingenz/zli';
6
+ import consola from 'consola';
7
+ import { z } from 'zod';
8
+ export default defineCommand({
9
+ description: 'Create a new native configuration.',
10
+ options: defineOptions(z.object({
11
+ appId: z.string().optional().describe('ID of the app.'),
12
+ displayName: z.string().optional().describe('Display name of the app.'),
13
+ json: z.boolean().optional().describe('Output in JSON format.'),
14
+ name: z.string().optional().describe('Name of the native configuration.'),
15
+ packageName: z
16
+ .string()
17
+ .optional()
18
+ .describe('Package name of the app (application ID on Android, bundle ID on iOS).'),
19
+ })),
20
+ action: withAuth(async (options, args) => {
21
+ let { appId, displayName, json, name, packageName } = options;
22
+ if (!appId) {
23
+ if (!isInteractive()) {
24
+ consola.error('You must provide an app ID when running in non-interactive environment.');
25
+ process.exit(1);
26
+ }
27
+ const organizationId = await promptOrganizationSelection({ allowCreate: true });
28
+ appId = await promptAppSelection(organizationId, { allowCreate: true });
29
+ }
30
+ if (!name) {
31
+ if (!isInteractive()) {
32
+ consola.error('You must provide the native configuration name when running in non-interactive environment.');
33
+ process.exit(1);
34
+ }
35
+ name = await prompt('Enter the name of the native configuration:', { type: 'text' });
36
+ }
37
+ const response = await appConfigurationsService.create({
38
+ appId,
39
+ displayName: displayName === '' ? null : displayName,
40
+ name,
41
+ packageName: packageName === '' ? null : packageName,
42
+ });
43
+ if (json) {
44
+ console.log(JSON.stringify({ id: response.id }, null, 2));
45
+ }
46
+ else {
47
+ consola.info(`Native configuration ID: ${response.id}`);
48
+ consola.success('Native configuration created successfully.');
49
+ }
50
+ }),
51
+ });
@@ -0,0 +1,120 @@
1
+ import { DEFAULT_API_BASE_URL } from '../../../config/consts.js';
2
+ import authorizationService from '../../../services/authorization-service.js';
3
+ import { prompt, 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 createConfigurationCommand from './create.js';
9
+ // Mock dependencies
10
+ vi.mock('@/utils/user-config.js');
11
+ vi.mock('@/utils/prompt.js');
12
+ vi.mock('@/services/authorization-service.js');
13
+ vi.mock('consola');
14
+ vi.mock('@/utils/environment.js', () => ({
15
+ isInteractive: () => true,
16
+ }));
17
+ describe('apps-configurations-create', () => {
18
+ const mockUserConfig = vi.mocked(userConfig);
19
+ const mockPrompt = vi.mocked(prompt);
20
+ const mockPromptOrganizationSelection = vi.mocked(promptOrganizationSelection);
21
+ const mockPromptAppSelection = vi.mocked(promptAppSelection);
22
+ const mockConsola = vi.mocked(consola);
23
+ const mockAuthorizationService = vi.mocked(authorizationService);
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ mockUserConfig.read.mockReturnValue({ token: 'test-token' });
27
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token');
28
+ mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
29
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
30
+ throw new Error(`Process exited with code ${code}`);
31
+ });
32
+ });
33
+ afterEach(() => {
34
+ nock.cleanAll();
35
+ vi.restoreAllMocks();
36
+ });
37
+ it('should create configuration with provided options', async () => {
38
+ const appId = 'app-123';
39
+ const configurationName = 'production';
40
+ const displayName = 'My App';
41
+ const packageName = 'io.capawesome.app';
42
+ const configurationId = 'configuration-456';
43
+ const testToken = 'test-token';
44
+ const options = { appId, displayName, name: configurationName, packageName };
45
+ const scope = nock(DEFAULT_API_BASE_URL)
46
+ .post(`/v1/apps/${appId}/configurations`, {
47
+ displayName,
48
+ name: configurationName,
49
+ packageName,
50
+ })
51
+ .matchHeader('Authorization', `Bearer ${testToken}`)
52
+ .reply(201, { id: configurationId, name: configurationName });
53
+ await createConfigurationCommand.action(options, undefined);
54
+ expect(scope.isDone()).toBe(true);
55
+ expect(mockConsola.info).toHaveBeenCalledWith(`Native configuration ID: ${configurationId}`);
56
+ expect(mockConsola.success).toHaveBeenCalledWith('Native configuration created successfully.');
57
+ });
58
+ it('should output JSON when json flag is set', async () => {
59
+ const appId = 'app-123';
60
+ const configurationName = 'production';
61
+ const configurationId = 'configuration-456';
62
+ const testToken = 'test-token';
63
+ const options = { appId, json: true, name: configurationName };
64
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
65
+ const scope = nock(DEFAULT_API_BASE_URL)
66
+ .post(`/v1/apps/${appId}/configurations`, {
67
+ name: configurationName,
68
+ })
69
+ .matchHeader('Authorization', `Bearer ${testToken}`)
70
+ .reply(201, { id: configurationId, name: configurationName });
71
+ await createConfigurationCommand.action(options, undefined);
72
+ expect(scope.isDone()).toBe(true);
73
+ expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ id: configurationId }, null, 2));
74
+ expect(mockConsola.info).not.toHaveBeenCalled();
75
+ });
76
+ it('should prompt for app when not provided', async () => {
77
+ const orgId = 'org-1';
78
+ const appId = 'app-1';
79
+ const configurationName = 'staging';
80
+ const configurationId = 'configuration-456';
81
+ const testToken = 'test-token';
82
+ const options = { name: configurationName };
83
+ const scope = nock(DEFAULT_API_BASE_URL)
84
+ .post(`/v1/apps/${appId}/configurations`, {
85
+ name: configurationName,
86
+ })
87
+ .matchHeader('Authorization', `Bearer ${testToken}`)
88
+ .reply(201, { id: configurationId, name: configurationName });
89
+ mockPromptOrganizationSelection.mockResolvedValueOnce(orgId);
90
+ mockPromptAppSelection.mockResolvedValueOnce(appId);
91
+ await createConfigurationCommand.action(options, undefined);
92
+ expect(scope.isDone()).toBe(true);
93
+ expect(mockPromptOrganizationSelection).toHaveBeenCalledWith({ allowCreate: true });
94
+ expect(mockPromptAppSelection).toHaveBeenCalledWith(orgId, { allowCreate: true });
95
+ expect(mockConsola.success).toHaveBeenCalledWith('Native configuration created successfully.');
96
+ });
97
+ it('should prompt for configuration name when not provided', async () => {
98
+ const options = { appId: 'app-123' };
99
+ const scope = nock(DEFAULT_API_BASE_URL)
100
+ .post('/v1/apps/app-123/configurations', {
101
+ name: 'development',
102
+ })
103
+ .matchHeader('Authorization', 'Bearer test-token')
104
+ .reply(201, { id: 'configuration-456', name: 'development' });
105
+ mockPrompt.mockResolvedValueOnce('development');
106
+ await createConfigurationCommand.action(options, undefined);
107
+ expect(scope.isDone()).toBe(true);
108
+ expect(mockPrompt).toHaveBeenCalledWith('Enter the name of the native configuration:', { type: 'text' });
109
+ });
110
+ it('should handle API error', async () => {
111
+ const options = { appId: 'app-123', name: 'production' };
112
+ const scope = nock(DEFAULT_API_BASE_URL)
113
+ .post('/v1/apps/app-123/configurations')
114
+ .matchHeader('Authorization', 'Bearer test-token')
115
+ .reply(400, { message: 'Configuration name already exists' });
116
+ await expect(createConfigurationCommand.action(options, undefined)).rejects.toThrow();
117
+ expect(scope.isDone()).toBe(true);
118
+ expect(mockConsola.success).not.toHaveBeenCalled();
119
+ });
120
+ });
@@ -0,0 +1,61 @@
1
+ import appConfigurationsService from '../../../services/app-configurations.js';
2
+ import { withAuth } from '../../../utils/auth.js';
3
+ import { isInteractive } from '../../../utils/environment.js';
4
+ import { prompt, promptAppSelection, promptOrganizationSelection } from '../../../utils/prompt.js';
5
+ import { defineCommand, defineOptions } from '@robingenz/zli';
6
+ import consola from 'consola';
7
+ import { z } from 'zod';
8
+ export default defineCommand({
9
+ description: 'Delete a native configuration.',
10
+ options: defineOptions(z.object({
11
+ appId: z.string().optional().describe('ID of the app.'),
12
+ configurationId: z
13
+ .string()
14
+ .optional()
15
+ .describe('ID of the native configuration. Either the ID or name must be provided.'),
16
+ name: z.string().optional().describe('Name of the native configuration. Either the ID or name must be provided.'),
17
+ yes: z.boolean().optional().describe('Skip confirmation prompt.'),
18
+ }), { y: 'yes' }),
19
+ action: withAuth(async (options, args) => {
20
+ let { appId, configurationId, name } = options;
21
+ if (!appId) {
22
+ if (!isInteractive()) {
23
+ consola.error('You must provide an app ID when running in non-interactive environment.');
24
+ process.exit(1);
25
+ }
26
+ const organizationId = await promptOrganizationSelection();
27
+ appId = await promptAppSelection(organizationId);
28
+ }
29
+ if (!configurationId && !name) {
30
+ if (!isInteractive()) {
31
+ consola.error('You must provide either the native configuration ID or name when running in non-interactive environment.');
32
+ process.exit(1);
33
+ }
34
+ const configurations = await appConfigurationsService.findAll({ appId });
35
+ if (!configurations.length) {
36
+ consola.error('No native configurations found for this app. Create one first.');
37
+ process.exit(1);
38
+ }
39
+ // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged
40
+ const selectedConfigurationId = await prompt('Select the native configuration to delete:', {
41
+ type: 'select',
42
+ options: configurations.map((configuration) => ({ label: configuration.name, value: configuration.id })),
43
+ });
44
+ configurationId = selectedConfigurationId;
45
+ }
46
+ if (!options.yes && isInteractive()) {
47
+ const confirmed = await prompt('Are you sure you want to delete this native configuration?', {
48
+ type: 'confirm',
49
+ });
50
+ if (!confirmed) {
51
+ return;
52
+ }
53
+ }
54
+ await appConfigurationsService.delete({
55
+ appId,
56
+ id: configurationId,
57
+ name,
58
+ });
59
+ consola.success('Native configuration deleted successfully.');
60
+ }),
61
+ });
@@ -0,0 +1,112 @@
1
+ import { DEFAULT_API_BASE_URL } from '../../../config/consts.js';
2
+ import authorizationService from '../../../services/authorization-service.js';
3
+ import { prompt, 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 deleteConfigurationCommand from './delete.js';
9
+ // Mock dependencies
10
+ vi.mock('@/utils/user-config.js');
11
+ vi.mock('@/utils/prompt.js');
12
+ vi.mock('@/services/authorization-service.js');
13
+ vi.mock('consola');
14
+ vi.mock('@/utils/environment.js', () => ({
15
+ isInteractive: () => true,
16
+ }));
17
+ describe('apps-configurations-delete', () => {
18
+ const mockUserConfig = vi.mocked(userConfig);
19
+ const mockPrompt = vi.mocked(prompt);
20
+ const mockPromptOrganizationSelection = vi.mocked(promptOrganizationSelection);
21
+ const mockPromptAppSelection = vi.mocked(promptAppSelection);
22
+ const mockConsola = vi.mocked(consola);
23
+ const mockAuthorizationService = vi.mocked(authorizationService);
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ mockUserConfig.read.mockReturnValue({ token: 'test-token' });
27
+ mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token');
28
+ mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true);
29
+ vi.spyOn(process, 'exit').mockImplementation((code) => {
30
+ throw new Error(`Process exited with code ${code}`);
31
+ });
32
+ });
33
+ afterEach(() => {
34
+ nock.cleanAll();
35
+ vi.restoreAllMocks();
36
+ });
37
+ it('should delete configuration by ID after confirmation', async () => {
38
+ const appId = 'app-123';
39
+ const configurationId = 'configuration-456';
40
+ const options = { appId, configurationId };
41
+ mockPrompt.mockResolvedValueOnce(true); // confirmation
42
+ const scope = nock(DEFAULT_API_BASE_URL)
43
+ .delete(`/v1/apps/${appId}/configurations/${configurationId}`)
44
+ .matchHeader('Authorization', 'Bearer test-token')
45
+ .reply(200);
46
+ await deleteConfigurationCommand.action(options, undefined);
47
+ expect(scope.isDone()).toBe(true);
48
+ expect(mockPrompt).toHaveBeenCalledWith('Are you sure you want to delete this native configuration?', {
49
+ type: 'confirm',
50
+ });
51
+ expect(mockConsola.success).toHaveBeenCalledWith('Native configuration deleted successfully.');
52
+ });
53
+ it('should delete configuration by name when yes flag is set', async () => {
54
+ const appId = 'app-123';
55
+ const configurationName = 'production';
56
+ const options = { appId, name: configurationName, yes: true };
57
+ const scope = nock(DEFAULT_API_BASE_URL)
58
+ .delete(`/v1/apps/${appId}/configurations`)
59
+ .query({ name: configurationName })
60
+ .matchHeader('Authorization', 'Bearer test-token')
61
+ .reply(200);
62
+ await deleteConfigurationCommand.action(options, undefined);
63
+ expect(scope.isDone()).toBe(true);
64
+ expect(mockPrompt).not.toHaveBeenCalled();
65
+ expect(mockConsola.success).toHaveBeenCalledWith('Native configuration deleted successfully.');
66
+ });
67
+ it('should not delete configuration when confirmation is declined', async () => {
68
+ const options = { appId: 'app-123', configurationId: 'configuration-456' };
69
+ mockPrompt.mockResolvedValueOnce(false); // declined confirmation
70
+ await deleteConfigurationCommand.action(options, undefined);
71
+ expect(mockConsola.success).not.toHaveBeenCalled();
72
+ });
73
+ it('should prompt for app and configuration when not provided', async () => {
74
+ const orgId = 'org-1';
75
+ const appId = 'app-1';
76
+ const configurationId = 'configuration-456';
77
+ const configurationName = 'development';
78
+ const options = {};
79
+ const listScope = nock(DEFAULT_API_BASE_URL)
80
+ .get(`/v1/apps/${appId}/configurations`)
81
+ .matchHeader('Authorization', 'Bearer test-token')
82
+ .reply(200, [{ id: configurationId, appId, name: configurationName }]);
83
+ const deleteScope = nock(DEFAULT_API_BASE_URL)
84
+ .delete(`/v1/apps/${appId}/configurations/${configurationId}`)
85
+ .matchHeader('Authorization', 'Bearer test-token')
86
+ .reply(200);
87
+ mockPromptOrganizationSelection.mockResolvedValueOnce(orgId);
88
+ mockPromptAppSelection.mockResolvedValueOnce(appId);
89
+ mockPrompt
90
+ .mockResolvedValueOnce(configurationId) // configuration selection
91
+ .mockResolvedValueOnce(true); // confirmation
92
+ await deleteConfigurationCommand.action(options, undefined);
93
+ expect(listScope.isDone()).toBe(true);
94
+ expect(deleteScope.isDone()).toBe(true);
95
+ expect(mockPrompt).toHaveBeenCalledWith('Select the native configuration to delete:', {
96
+ type: 'select',
97
+ options: [{ label: configurationName, value: configurationId }],
98
+ });
99
+ expect(mockConsola.success).toHaveBeenCalledWith('Native configuration deleted successfully.');
100
+ });
101
+ it('should handle API error', async () => {
102
+ const appId = 'app-123';
103
+ const configurationId = 'configuration-456';
104
+ const options = { appId, configurationId, yes: true };
105
+ const scope = nock(DEFAULT_API_BASE_URL)
106
+ .delete(`/v1/apps/${appId}/configurations/${configurationId}`)
107
+ .matchHeader('Authorization', 'Bearer test-token')
108
+ .reply(404, { message: 'Configuration not found' });
109
+ await expect(deleteConfigurationCommand.action(options, undefined)).rejects.toThrow();
110
+ expect(scope.isDone()).toBe(true);
111
+ });
112
+ });