@vizzly-testing/cli 0.19.2 → 0.20.1-beta.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/dist/api/client.js +134 -0
- package/dist/api/core.js +341 -0
- package/dist/api/endpoints.js +314 -0
- package/dist/api/index.js +19 -0
- package/dist/auth/client.js +91 -0
- package/dist/auth/core.js +176 -0
- package/dist/auth/index.js +30 -0
- package/dist/auth/operations.js +148 -0
- package/dist/cli.js +1 -1
- package/dist/client/index.js +0 -1
- package/dist/commands/doctor.js +3 -3
- package/dist/commands/finalize.js +41 -15
- package/dist/commands/login.js +7 -6
- package/dist/commands/logout.js +4 -4
- package/dist/commands/project.js +5 -4
- package/dist/commands/run.js +158 -90
- package/dist/commands/status.js +22 -18
- package/dist/commands/tdd.js +105 -78
- package/dist/commands/upload.js +61 -26
- package/dist/commands/whoami.js +4 -4
- package/dist/config/core.js +438 -0
- package/dist/config/index.js +13 -0
- package/dist/config/operations.js +327 -0
- package/dist/index.js +1 -1
- package/dist/project/core.js +295 -0
- package/dist/project/index.js +13 -0
- package/dist/project/operations.js +393 -0
- package/dist/report-generator/core.js +315 -0
- package/dist/report-generator/index.js +8 -0
- package/dist/report-generator/operations.js +196 -0
- package/dist/reporter/reporter-bundle.iife.js +16 -16
- package/dist/screenshot-server/core.js +157 -0
- package/dist/screenshot-server/index.js +11 -0
- package/dist/screenshot-server/operations.js +183 -0
- package/dist/sdk/index.js +3 -2
- package/dist/server/handlers/api-handler.js +14 -5
- package/dist/server/handlers/tdd-handler.js +80 -48
- package/dist/server-manager/core.js +183 -0
- package/dist/server-manager/index.js +81 -0
- package/dist/server-manager/operations.js +208 -0
- package/dist/services/build-manager.js +2 -69
- package/dist/services/index.js +21 -48
- package/dist/services/screenshot-server.js +40 -74
- package/dist/services/server-manager.js +45 -80
- package/dist/services/static-report-generator.js +21 -163
- package/dist/services/test-runner.js +90 -249
- package/dist/services/uploader.js +56 -358
- package/dist/tdd/core/hotspot-coverage.js +112 -0
- package/dist/tdd/core/signature.js +101 -0
- package/dist/tdd/index.js +19 -0
- package/dist/tdd/metadata/baseline-metadata.js +103 -0
- package/dist/tdd/metadata/hotspot-metadata.js +93 -0
- package/dist/tdd/services/baseline-downloader.js +151 -0
- package/dist/tdd/services/baseline-manager.js +166 -0
- package/dist/tdd/services/comparison-service.js +230 -0
- package/dist/tdd/services/hotspot-service.js +71 -0
- package/dist/tdd/services/result-service.js +123 -0
- package/dist/tdd/tdd-service.js +1081 -0
- package/dist/test-runner/core.js +255 -0
- package/dist/test-runner/index.js +13 -0
- package/dist/test-runner/operations.js +483 -0
- package/dist/types/client.d.ts +4 -2
- package/dist/types/index.d.ts +5 -0
- package/dist/uploader/core.js +396 -0
- package/dist/uploader/index.js +11 -0
- package/dist/uploader/operations.js +412 -0
- package/dist/utils/config-schema.js +8 -3
- package/package.json +7 -12
- package/dist/services/api-service.js +0 -412
- package/dist/services/auth-service.js +0 -226
- package/dist/services/config-service.js +0 -369
- package/dist/services/html-report-generator.js +0 -455
- package/dist/services/project-service.js +0 -326
- package/dist/services/report-generator/report.css +0 -411
- package/dist/services/report-generator/viewer.js +0 -102
- package/dist/services/tdd-service.js +0 -1429
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Operations - Authentication operations with dependency injection
|
|
3
|
+
*
|
|
4
|
+
* Each operation takes its dependencies as parameters:
|
|
5
|
+
* - httpClient: for making HTTP requests
|
|
6
|
+
* - tokenStore: for reading/writing auth tokens
|
|
7
|
+
*
|
|
8
|
+
* This makes them trivially testable without mocking modules.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { buildDevicePollPayload, buildLogoutPayload, buildRefreshPayload, buildTokenData, validateTokens } from './core.js';
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Device Flow Operations
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initiate OAuth device flow
|
|
19
|
+
* @param {Object} httpClient - HTTP client with request method
|
|
20
|
+
* @returns {Promise<Object>} Device code, user code, verification URL
|
|
21
|
+
*/
|
|
22
|
+
export async function initiateDeviceFlow(httpClient) {
|
|
23
|
+
return httpClient.request('/api/auth/cli/device/initiate', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json'
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Poll for device authorization
|
|
33
|
+
* @param {Object} httpClient - HTTP client
|
|
34
|
+
* @param {string} deviceCode - Device code from initiate
|
|
35
|
+
* @returns {Promise<Object>} Token data or pending status
|
|
36
|
+
*/
|
|
37
|
+
export async function pollDeviceAuthorization(httpClient, deviceCode) {
|
|
38
|
+
return httpClient.request('/api/auth/cli/device/poll', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json'
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify(buildDevicePollPayload(deviceCode))
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Complete device flow and save tokens
|
|
49
|
+
* @param {Object} tokenStore - Token storage with saveTokens method
|
|
50
|
+
* @param {Object} tokenData - Token response from poll
|
|
51
|
+
* @returns {Promise<Object>} Token data
|
|
52
|
+
*/
|
|
53
|
+
export async function completeDeviceFlow(tokenStore, tokenData) {
|
|
54
|
+
await tokenStore.saveTokens(buildTokenData(tokenData));
|
|
55
|
+
return tokenData;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Token Operations
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Refresh access token using refresh token
|
|
64
|
+
* @param {Object} httpClient - HTTP client
|
|
65
|
+
* @param {Object} tokenStore - Token storage
|
|
66
|
+
* @returns {Promise<Object>} New tokens
|
|
67
|
+
*/
|
|
68
|
+
export async function refresh(httpClient, tokenStore) {
|
|
69
|
+
let auth = await tokenStore.getTokens();
|
|
70
|
+
let validation = validateTokens(auth, 'refreshToken');
|
|
71
|
+
if (!validation.valid) {
|
|
72
|
+
throw validation.error;
|
|
73
|
+
}
|
|
74
|
+
let response = await httpClient.request('/api/auth/cli/refresh', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
'Content-Type': 'application/json'
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(buildRefreshPayload(auth.refreshToken))
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Preserve existing user data when refreshing
|
|
83
|
+
await tokenStore.saveTokens(buildTokenData(response, auth.user));
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ============================================================================
|
|
88
|
+
// Logout Operations
|
|
89
|
+
// ============================================================================
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Logout and revoke tokens
|
|
93
|
+
* @param {Object} httpClient - HTTP client
|
|
94
|
+
* @param {Object} tokenStore - Token storage
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
97
|
+
export async function logout(httpClient, tokenStore) {
|
|
98
|
+
let auth = await tokenStore.getTokens();
|
|
99
|
+
if (auth?.refreshToken) {
|
|
100
|
+
try {
|
|
101
|
+
await httpClient.request('/api/auth/cli/logout', {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: {
|
|
104
|
+
'Content-Type': 'application/json'
|
|
105
|
+
},
|
|
106
|
+
body: JSON.stringify(buildLogoutPayload(auth.refreshToken))
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// If server request fails, still clear local tokens
|
|
110
|
+
console.warn('Warning: Failed to revoke tokens on server:', error.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await tokenStore.clearTokens();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ============================================================================
|
|
117
|
+
// User Operations
|
|
118
|
+
// ============================================================================
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get current user information
|
|
122
|
+
* @param {Object} httpClient - HTTP client
|
|
123
|
+
* @param {Object} tokenStore - Token storage
|
|
124
|
+
* @returns {Promise<Object>} User and organization data
|
|
125
|
+
*/
|
|
126
|
+
export async function whoami(httpClient, tokenStore) {
|
|
127
|
+
let auth = await tokenStore.getTokens();
|
|
128
|
+
let validation = validateTokens(auth, 'accessToken');
|
|
129
|
+
if (!validation.valid) {
|
|
130
|
+
throw validation.error;
|
|
131
|
+
}
|
|
132
|
+
return httpClient.authenticatedRequest('/api/auth/cli/whoami', auth.accessToken);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check if user is authenticated
|
|
137
|
+
* @param {Object} httpClient - HTTP client
|
|
138
|
+
* @param {Object} tokenStore - Token storage
|
|
139
|
+
* @returns {Promise<boolean>} True if authenticated
|
|
140
|
+
*/
|
|
141
|
+
export async function isAuthenticated(httpClient, tokenStore) {
|
|
142
|
+
try {
|
|
143
|
+
await whoami(httpClient, tokenStore);
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -13,8 +13,8 @@ import { tddCommand, validateTddOptions } from './commands/tdd.js';
|
|
|
13
13
|
import { runDaemonChild, tddStartCommand, tddStatusCommand, tddStopCommand } from './commands/tdd-daemon.js';
|
|
14
14
|
import { uploadCommand, validateUploadOptions } from './commands/upload.js';
|
|
15
15
|
import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js';
|
|
16
|
-
import { loadPlugins } from './plugin-loader.js';
|
|
17
16
|
import { createPluginServices } from './plugin-api.js';
|
|
17
|
+
import { loadPlugins } from './plugin-loader.js';
|
|
18
18
|
import { createServices } from './services/index.js';
|
|
19
19
|
import { loadConfig } from './utils/config-loader.js';
|
|
20
20
|
import * as output from './utils/output.js';
|
package/dist/client/index.js
CHANGED
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { URL } from 'node:url';
|
|
2
|
+
import { createApiClient, getBuilds } from '../api/index.js';
|
|
2
3
|
import { ConfigError } from '../errors/vizzly-error.js';
|
|
3
|
-
import { ApiService } from '../services/api-service.js';
|
|
4
4
|
import { loadConfig } from '../utils/config-loader.js';
|
|
5
5
|
import { getApiToken } from '../utils/environment-config.js';
|
|
6
6
|
import * as output from '../utils/output.js';
|
|
@@ -102,13 +102,13 @@ export async function doctorCommand(options = {}, globalOptions = {}) {
|
|
|
102
102
|
} else {
|
|
103
103
|
output.progress('Checking API connectivity...');
|
|
104
104
|
try {
|
|
105
|
-
|
|
105
|
+
let client = createApiClient({
|
|
106
106
|
baseUrl: config.apiUrl,
|
|
107
107
|
token: config.apiKey,
|
|
108
108
|
command: 'doctor'
|
|
109
109
|
});
|
|
110
110
|
// Minimal, read-only call
|
|
111
|
-
await
|
|
111
|
+
await getBuilds(client, {
|
|
112
112
|
limit: 1
|
|
113
113
|
});
|
|
114
114
|
diagnostics.connectivity.ok = true;
|
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Finalize command implementation
|
|
3
|
+
* Uses functional API operations directly
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createApiClient as defaultCreateApiClient, finalizeParallelBuild as defaultFinalizeParallelBuild } from '../api/index.js';
|
|
7
|
+
import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
|
|
8
|
+
import * as defaultOutput from '../utils/output.js';
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Finalize command implementation
|
|
7
12
|
* @param {string} parallelId - Parallel ID to finalize
|
|
8
13
|
* @param {Object} options - Command options
|
|
9
14
|
* @param {Object} globalOptions - Global CLI options
|
|
15
|
+
* @param {Object} deps - Dependencies for testing
|
|
10
16
|
*/
|
|
11
|
-
export async function finalizeCommand(parallelId, options = {}, globalOptions = {}) {
|
|
17
|
+
export async function finalizeCommand(parallelId, options = {}, globalOptions = {}, deps = {}) {
|
|
18
|
+
let {
|
|
19
|
+
loadConfig = defaultLoadConfig,
|
|
20
|
+
createApiClient = defaultCreateApiClient,
|
|
21
|
+
finalizeParallelBuild = defaultFinalizeParallelBuild,
|
|
22
|
+
output = defaultOutput,
|
|
23
|
+
exit = code => process.exit(code)
|
|
24
|
+
} = deps;
|
|
12
25
|
output.configure({
|
|
13
26
|
json: globalOptions.json,
|
|
14
27
|
verbose: globalOptions.verbose,
|
|
@@ -16,16 +29,20 @@ export async function finalizeCommand(parallelId, options = {}, globalOptions =
|
|
|
16
29
|
});
|
|
17
30
|
try {
|
|
18
31
|
// Load configuration with CLI overrides
|
|
19
|
-
|
|
32
|
+
let allOptions = {
|
|
20
33
|
...globalOptions,
|
|
21
34
|
...options
|
|
22
35
|
};
|
|
23
|
-
|
|
36
|
+
let config = await loadConfig(globalOptions.config, allOptions);
|
|
24
37
|
|
|
25
38
|
// Validate API token
|
|
26
39
|
if (!config.apiKey) {
|
|
27
40
|
output.error('API token required. Use --token or set VIZZLY_TOKEN environment variable');
|
|
28
|
-
|
|
41
|
+
exit(1);
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
reason: 'no-api-key'
|
|
45
|
+
};
|
|
29
46
|
}
|
|
30
47
|
if (globalOptions.verbose) {
|
|
31
48
|
output.info('Configuration loaded');
|
|
@@ -35,14 +52,15 @@ export async function finalizeCommand(parallelId, options = {}, globalOptions =
|
|
|
35
52
|
});
|
|
36
53
|
}
|
|
37
54
|
|
|
38
|
-
//
|
|
55
|
+
// Call finalize endpoint via functional API
|
|
39
56
|
output.startSpinner('Finalizing parallel build...');
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
let client = createApiClient({
|
|
58
|
+
baseUrl: config.apiUrl,
|
|
59
|
+
token: config.apiKey,
|
|
60
|
+
command: 'finalize'
|
|
61
|
+
});
|
|
62
|
+
let result = await finalizeParallelBuild(client, parallelId);
|
|
42
63
|
output.stopSpinner();
|
|
43
|
-
|
|
44
|
-
// Call finalize endpoint
|
|
45
|
-
const result = await apiService.finalizeParallelBuild(parallelId);
|
|
46
64
|
if (globalOptions.json) {
|
|
47
65
|
output.data(result);
|
|
48
66
|
} else {
|
|
@@ -50,10 +68,18 @@ export async function finalizeCommand(parallelId, options = {}, globalOptions =
|
|
|
50
68
|
output.info(`Status: ${result.build.status}`);
|
|
51
69
|
output.info(`Parallel ID: ${result.build.parallel_id}`);
|
|
52
70
|
}
|
|
71
|
+
return {
|
|
72
|
+
success: true,
|
|
73
|
+
result
|
|
74
|
+
};
|
|
53
75
|
} catch (error) {
|
|
54
76
|
output.stopSpinner();
|
|
55
77
|
output.error('Failed to finalize parallel build', error);
|
|
56
|
-
|
|
78
|
+
exit(1);
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
error
|
|
82
|
+
};
|
|
57
83
|
} finally {
|
|
58
84
|
output.cleanup();
|
|
59
85
|
}
|
|
@@ -65,7 +91,7 @@ export async function finalizeCommand(parallelId, options = {}, globalOptions =
|
|
|
65
91
|
* @param {Object} options - Command options
|
|
66
92
|
*/
|
|
67
93
|
export function validateFinalizeOptions(parallelId, _options) {
|
|
68
|
-
|
|
94
|
+
let errors = [];
|
|
69
95
|
if (!parallelId || parallelId.trim() === '') {
|
|
70
96
|
errors.push('Parallel ID is required');
|
|
71
97
|
}
|
package/dist/commands/login.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Authenticates user via OAuth device flow
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { completeDeviceFlow, createAuthClient, createTokenStore, initiateDeviceFlow, pollDeviceAuthorization } from '../auth/index.js';
|
|
7
7
|
import { openBrowser } from '../utils/browser.js';
|
|
8
8
|
import { getApiUrl } from '../utils/environment-config.js';
|
|
9
9
|
import * as output from '../utils/output.js';
|
|
@@ -24,14 +24,15 @@ export async function loginCommand(options = {}, globalOptions = {}) {
|
|
|
24
24
|
output.info('Starting Vizzly authentication...');
|
|
25
25
|
output.blank();
|
|
26
26
|
|
|
27
|
-
// Create auth
|
|
28
|
-
|
|
27
|
+
// Create auth client and token store
|
|
28
|
+
let client = createAuthClient({
|
|
29
29
|
baseUrl: options.apiUrl || getApiUrl()
|
|
30
30
|
});
|
|
31
|
+
let tokenStore = createTokenStore();
|
|
31
32
|
|
|
32
33
|
// Initiate device flow
|
|
33
34
|
output.startSpinner('Connecting to Vizzly...');
|
|
34
|
-
|
|
35
|
+
let deviceFlow = await initiateDeviceFlow(client);
|
|
35
36
|
output.stopSpinner();
|
|
36
37
|
|
|
37
38
|
// Handle both snake_case and camelCase field names
|
|
@@ -83,7 +84,7 @@ export async function loginCommand(options = {}, globalOptions = {}) {
|
|
|
83
84
|
|
|
84
85
|
// Check authorization status
|
|
85
86
|
output.startSpinner('Checking authorization status...');
|
|
86
|
-
|
|
87
|
+
let pollResponse = await pollDeviceAuthorization(client, deviceCode);
|
|
87
88
|
output.stopSpinner();
|
|
88
89
|
let tokenData = null;
|
|
89
90
|
|
|
@@ -113,7 +114,7 @@ export async function loginCommand(options = {}, globalOptions = {}) {
|
|
|
113
114
|
user: tokenData.user,
|
|
114
115
|
organizations: tokenData.organizations
|
|
115
116
|
};
|
|
116
|
-
await
|
|
117
|
+
await completeDeviceFlow(tokenStore, tokens);
|
|
117
118
|
|
|
118
119
|
// Display success message
|
|
119
120
|
output.success('Successfully authenticated!');
|
package/dist/commands/logout.js
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
* Clears stored authentication tokens
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { createAuthClient, createTokenStore, getAuthTokens, logout } from '../auth/index.js';
|
|
7
7
|
import { getApiUrl } from '../utils/environment-config.js';
|
|
8
|
-
import { getAuthTokens } from '../utils/global-config.js';
|
|
9
8
|
import * as output from '../utils/output.js';
|
|
10
9
|
|
|
11
10
|
/**
|
|
@@ -30,10 +29,11 @@ export async function logoutCommand(options = {}, globalOptions = {}) {
|
|
|
30
29
|
|
|
31
30
|
// Logout
|
|
32
31
|
output.startSpinner('Logging out...');
|
|
33
|
-
|
|
32
|
+
let client = createAuthClient({
|
|
34
33
|
baseUrl: options.apiUrl || getApiUrl()
|
|
35
34
|
});
|
|
36
|
-
|
|
35
|
+
let tokenStore = createTokenStore();
|
|
36
|
+
await logout(client, tokenStore);
|
|
37
37
|
output.stopSpinner();
|
|
38
38
|
output.success('Successfully logged out');
|
|
39
39
|
if (globalOptions.json) {
|
package/dist/commands/project.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { resolve } from 'node:path';
|
|
7
7
|
import readline from 'node:readline';
|
|
8
|
-
import {
|
|
8
|
+
import { createAuthClient, createTokenStore, getAuthTokens, whoami } from '../auth/index.js';
|
|
9
9
|
import { getApiUrl } from '../utils/environment-config.js';
|
|
10
|
-
import { deleteProjectMapping,
|
|
10
|
+
import { deleteProjectMapping, getProjectMapping, getProjectMappings, saveProjectMapping } from '../utils/global-config.js';
|
|
11
11
|
import * as output from '../utils/output.js';
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -30,13 +30,14 @@ export async function projectSelectCommand(options = {}, globalOptions = {}) {
|
|
|
30
30
|
output.info('Run "vizzly login" to authenticate first');
|
|
31
31
|
process.exit(1);
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
let client = createAuthClient({
|
|
34
34
|
baseUrl: options.apiUrl || getApiUrl()
|
|
35
35
|
});
|
|
36
|
+
let tokenStore = createTokenStore();
|
|
36
37
|
|
|
37
38
|
// Get user info to show organizations
|
|
38
39
|
output.startSpinner('Fetching organizations...');
|
|
39
|
-
|
|
40
|
+
let userInfo = await whoami(client, tokenStore);
|
|
40
41
|
output.stopSpinner();
|
|
41
42
|
if (!userInfo.organizations || userInfo.organizations.length === 0) {
|
|
42
43
|
output.error('No organizations found');
|