@profoundlogic/coderflow-cli 0.2.1

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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Command: coder test - Run ephemeral test containers with local state
3
+ */
4
+
5
+ import { request } from '../http-client.js';
6
+ import { getDefaultEnvironment, getServerUrl, getApiKey } from '../config.js';
7
+ import { captureAllRepos, validateState } from '../state-capture.js';
8
+
9
+ /**
10
+ * Load test definitions from server for the given environment
11
+ */
12
+ async function loadTestDefinitions(environmentName) {
13
+ try {
14
+ const envConfig = await request(`/environments/${environmentName}`);
15
+ return envConfig.tests || {};
16
+ } catch (error) {
17
+ throw new Error(`Failed to load test definitions from server: ${error.message}`);
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Parse command line arguments
23
+ */
24
+ function parseTestArgs(args) {
25
+ let testName = null;
26
+ let environment = null;
27
+ let customCommand = null;
28
+ let noLocalState = false;
29
+ let listTests = false;
30
+
31
+ for (let index = 0; index < args.length; index += 1) {
32
+ const arg = args[index];
33
+
34
+ if (arg === '--list') {
35
+ listTests = true;
36
+ continue;
37
+ }
38
+
39
+ if (arg === '--no-local-state') {
40
+ noLocalState = true;
41
+ continue;
42
+ }
43
+
44
+ if (arg === '--environment' || arg === '--env') {
45
+ const next = args[index + 1];
46
+ if (!next) {
47
+ console.error('Error: --environment requires a value');
48
+ process.exit(1);
49
+ }
50
+ environment = next;
51
+ index += 1;
52
+ continue;
53
+ }
54
+
55
+ if (arg.startsWith('--environment=')) {
56
+ environment = arg.substring('--environment='.length);
57
+ continue;
58
+ }
59
+
60
+ if (arg.startsWith('--env=')) {
61
+ environment = arg.substring('--env='.length);
62
+ continue;
63
+ }
64
+
65
+ if (arg === '--cmd') {
66
+ const next = args[index + 1];
67
+ if (!next) {
68
+ console.error('Error: --cmd requires a command string');
69
+ process.exit(1);
70
+ }
71
+ customCommand = next;
72
+ index += 1;
73
+ continue;
74
+ }
75
+
76
+ if (arg.startsWith('--cmd=')) {
77
+ customCommand = arg.substring('--cmd='.length);
78
+ continue;
79
+ }
80
+
81
+ if (arg.startsWith('--')) {
82
+ console.error(`Error: Unrecognized option: ${arg}`);
83
+ process.exit(1);
84
+ }
85
+
86
+ // First non-flag argument is the test name
87
+ if (!testName) {
88
+ testName = arg;
89
+ continue;
90
+ }
91
+
92
+ console.error(`Error: Unexpected argument: ${arg}`);
93
+ process.exit(1);
94
+ }
95
+
96
+ return { testName, environment, customCommand, noLocalState, listTests };
97
+ }
98
+
99
+ /**
100
+ * List available tests for environment
101
+ */
102
+ async function listAvailableTests(environmentName) {
103
+ const tests = await loadTestDefinitions(environmentName);
104
+ const testNames = Object.keys(tests);
105
+
106
+ if (testNames.length === 0) {
107
+ console.log(`No tests defined for environment: ${environmentName}`);
108
+ console.log(`\nCreate tests.json in: environments/${environmentName}/tests.json`);
109
+ return;
110
+ }
111
+
112
+ console.log(`Available tests for ${environmentName}:\n`);
113
+ for (const name of testNames) {
114
+ const test = tests[name];
115
+ console.log(` ${name}`);
116
+ if (test.description) {
117
+ console.log(` ${test.description}`);
118
+ }
119
+ // Show command or commands
120
+ if (test.commands && Array.isArray(test.commands)) {
121
+ console.log(` Commands: ${test.commands.length} steps`);
122
+ } else {
123
+ console.log(` Command: ${test.command}`);
124
+ }
125
+ if (Array.isArray(test.repos)) {
126
+ if (test.repos.length === 0) {
127
+ console.log(` Repos: none (skip local state)`);
128
+ } else {
129
+ console.log(` Repos: ${test.repos.join(', ')}`);
130
+ }
131
+ } else {
132
+ console.log(` Repos: all`);
133
+ }
134
+ console.log('');
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Run a test in an ephemeral container
140
+ */
141
+ export async function runTest(args = []) {
142
+ const { testName, environment, customCommand, noLocalState, listTests } = parseTestArgs(args);
143
+
144
+ // Resolve environment
145
+ let resolvedEnvironment = environment;
146
+ if (!resolvedEnvironment) {
147
+ resolvedEnvironment = await getDefaultEnvironment();
148
+ }
149
+
150
+ if (!resolvedEnvironment) {
151
+ console.error('Error: No environment specified and no default environment configured');
152
+ console.error('Use: coder test <test-name> --env=<environment>');
153
+ process.exit(1);
154
+ }
155
+
156
+ // Handle --list
157
+ if (listTests) {
158
+ await listAvailableTests(resolvedEnvironment);
159
+ return;
160
+ }
161
+
162
+ // Load test definitions
163
+ const tests = await loadTestDefinitions(resolvedEnvironment);
164
+
165
+ // Determine command to run
166
+ let command;
167
+ let description;
168
+ let reposFilter; // Optional array of repo names to include
169
+
170
+ if (customCommand) {
171
+ // Custom command
172
+ command = customCommand;
173
+ description = 'Custom test command';
174
+ } else if (testName) {
175
+ // Named test
176
+ const test = tests[testName];
177
+ if (!test) {
178
+ console.error(`Error: Test "${testName}" not found in environment "${resolvedEnvironment}"`);
179
+ console.error(`\nAvailable tests: ${Object.keys(tests).join(', ') || 'none'}`);
180
+ console.error(`\nOr use: coder test --cmd="your command"`);
181
+ process.exit(1);
182
+ }
183
+
184
+ // Support both 'command' (string) and 'commands' (array)
185
+ if (test.commands && Array.isArray(test.commands)) {
186
+ command = test.commands.join(' && ');
187
+ } else {
188
+ command = test.command;
189
+ }
190
+
191
+ description = test.description || testName;
192
+ reposFilter = test.repos; // undefined, array, or empty array
193
+ } else {
194
+ console.error('Error: No test name or --cmd specified');
195
+ console.error('Usage: coder test <test-name> [options]');
196
+ console.error(' or: coder test --cmd="command" [options]');
197
+ console.error(' or: coder test --list');
198
+ process.exit(1);
199
+ }
200
+
201
+ // Show what we're running
202
+ console.log(`Running test in environment: ${resolvedEnvironment}`);
203
+ if (testName) {
204
+ console.log(`Test: ${testName}`);
205
+ }
206
+ if (description) {
207
+ console.log(`Description: ${description}`);
208
+ }
209
+ console.log(`Command: ${command}`);
210
+
211
+ // Prepare request body
212
+ const requestBody = {
213
+ environment: resolvedEnvironment,
214
+ test_command: command
215
+ };
216
+
217
+ // Apply local state by default (unless --no-local-state)
218
+ // Also check if test specifies repos: [] (empty array = skip local state)
219
+ const shouldCaptureState = !noLocalState && !(Array.isArray(reposFilter) && reposFilter.length === 0);
220
+
221
+ if (shouldCaptureState) {
222
+ console.log('\n🔍 Capturing local repository state...');
223
+
224
+ try {
225
+ // Fetch environment config to get repo configurations
226
+ const envConfig = await request(`/environments/${resolvedEnvironment}`);
227
+ let repoConfigs = envConfig.repos || [];
228
+
229
+ // Filter repos if test specifies which ones to include
230
+ if (Array.isArray(reposFilter) && reposFilter.length > 0) {
231
+ const originalCount = repoConfigs.length;
232
+ repoConfigs = repoConfigs.filter(r => reposFilter.includes(r.name));
233
+ console.log(` Filtering to ${repoConfigs.length}/${originalCount} repositories: ${reposFilter.join(', ')}`);
234
+ }
235
+
236
+ if (repoConfigs.length === 0) {
237
+ console.warn('Warning: No repositories configured for this environment');
238
+ } else {
239
+ // Capture state locally on the client side
240
+ const localState = await captureAllRepos(process.cwd(), repoConfigs);
241
+
242
+ // Validate the captured state
243
+ const validation = validateState(localState);
244
+ if (!validation.valid) {
245
+ console.error('Error: Invalid local state captured');
246
+ validation.errors.forEach(err => console.error(` - ${err}`));
247
+ process.exit(1);
248
+ }
249
+
250
+ if (validation.warnings && validation.warnings.length > 0) {
251
+ validation.warnings.forEach(warn => console.warn(` Warning: ${warn}`));
252
+ }
253
+
254
+ // Send the captured state JSON to the server
255
+ requestBody.local_state = localState;
256
+ }
257
+ } catch (error) {
258
+ console.error(`Error capturing local state: ${error.message}`);
259
+ process.exit(1);
260
+ }
261
+ } else if (Array.isArray(reposFilter) && reposFilter.length === 0) {
262
+ console.log('\n⏭️ Skipping local state (test configured with empty repos array)');
263
+ }
264
+
265
+ // Create test container
266
+ const data = await request('/test', {
267
+ method: 'POST',
268
+ body: JSON.stringify(requestBody)
269
+ });
270
+
271
+ console.log(`\n✓ Test container created`);
272
+ console.log(` Container ID: ${data.containerId}`);
273
+
274
+ // Display local state info if captured
275
+ if (data.localState && data.localState.repos_found && data.localState.repos_found.length > 0) {
276
+ console.log('\n📦 Local State Applied:');
277
+ console.log(` Repositories: ${data.localState.repos_found.join(', ')}`);
278
+ if (data.localState.repos_missing && data.localState.repos_missing.length > 0) {
279
+ console.log(` Missing: ${data.localState.repos_missing.join(', ')} (using defaults)`);
280
+ }
281
+ }
282
+
283
+ console.log('\n\n' + '='.repeat(60));
284
+ console.log('Test Output:');
285
+ console.log('='.repeat(60) + '\n');
286
+
287
+ // Stream logs from container
288
+ const serverUrl = await getServerUrl();
289
+ const apiKey = await getApiKey();
290
+
291
+ // Use fetch to stream logs
292
+ const logsUrl = `${serverUrl}/test/${data.containerId}/logs`;
293
+ const headers = {
294
+ 'Authorization': `Bearer ${apiKey}`
295
+ };
296
+
297
+ try {
298
+ const response = await fetch(logsUrl, { headers });
299
+
300
+ if (!response.ok) {
301
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
302
+ }
303
+
304
+ // Stream response body
305
+ const reader = response.body.getReader();
306
+ const decoder = new TextDecoder();
307
+
308
+ while (true) {
309
+ const { done, value } = await reader.read();
310
+ if (done) break;
311
+
312
+ const text = decoder.decode(value, { stream: true });
313
+ process.stdout.write(text);
314
+ }
315
+
316
+ console.log('='.repeat(60));
317
+
318
+ // Check final status and exit with proper code
319
+ const statusCheck = await request(`/test/${data.containerId}/status`).catch(() => null);
320
+
321
+ if (statusCheck) {
322
+ const exitCode = statusCheck.exitCode || 0;
323
+ console.log(`Exit code: ${exitCode}`);
324
+ process.exit(exitCode);
325
+ } else {
326
+ // Container already removed, assume success
327
+ console.log('Exit code: 0');
328
+ process.exit(0);
329
+ }
330
+
331
+ } catch (error) {
332
+ console.error(`\n✗ Error streaming test output: ${error.message}`);
333
+ process.exit(1);
334
+ }
335
+ }
package/lib/config.js ADDED
@@ -0,0 +1,378 @@
1
+ import { promises as fs } from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import {
5
+ getEffectiveConfig,
6
+ getActiveProfileName,
7
+ loadProfile,
8
+ getMainConfigPath,
9
+ loadMainConfig,
10
+ saveMainConfig
11
+ } from './profile.js';
12
+
13
+ let cachedConfig = null;
14
+ let configLoaded = false;
15
+
16
+ // CLI profile override (set via --profile flag)
17
+ let cliProfileOverride = null;
18
+
19
+ function getDefaultConfigPath() {
20
+ return path.join(os.homedir(), '.coder', 'config.json');
21
+ }
22
+
23
+ function getConfigPath() {
24
+ return process.env.CODER_CONFIG_PATH || getDefaultConfigPath();
25
+ }
26
+
27
+ /**
28
+ * Set the CLI profile override (from --profile flag)
29
+ * This takes precedence over the activeProfile in config.json
30
+ */
31
+ export function setCliProfileOverride(profileName) {
32
+ cliProfileOverride = profileName;
33
+ invalidateConfigCache();
34
+ }
35
+
36
+ /**
37
+ * Get the current CLI profile override
38
+ */
39
+ export function getCliProfileOverride() {
40
+ return cliProfileOverride;
41
+ }
42
+
43
+ /**
44
+ * Invalidate the config cache (used after external writes)
45
+ */
46
+ export function invalidateConfigCache() {
47
+ cachedConfig = null;
48
+ configLoaded = false;
49
+ }
50
+
51
+ function sanitizeServerUrl(url) {
52
+ if (!url) {
53
+ return url;
54
+ }
55
+ return url.endsWith('/') ? url.slice(0, -1) : url;
56
+ }
57
+
58
+ /**
59
+ * Load client config with profile support
60
+ * Returns the merged configuration from profile + legacy config
61
+ */
62
+ export async function loadClientConfig() {
63
+ if (configLoaded) {
64
+ return cachedConfig;
65
+ }
66
+
67
+ try {
68
+ // Use the new profile-aware config loading
69
+ cachedConfig = await getEffectiveConfig(cliProfileOverride);
70
+ } catch (error) {
71
+ // Fall back to legacy behavior if profile system fails
72
+ const configPath = getConfigPath();
73
+ try {
74
+ const content = await fs.readFile(configPath, 'utf-8');
75
+ cachedConfig = JSON.parse(content);
76
+ } catch (readError) {
77
+ if (readError.code === 'ENOENT') {
78
+ cachedConfig = null;
79
+ } else {
80
+ throw new Error(`Failed to read client config at ${configPath}: ${readError.message}`);
81
+ }
82
+ }
83
+ }
84
+
85
+ configLoaded = true;
86
+ return cachedConfig;
87
+ }
88
+
89
+ /**
90
+ * Load raw client config (legacy config.json only, no profile merging)
91
+ * Used for config management commands
92
+ */
93
+ export async function loadRawClientConfig() {
94
+ const configPath = getConfigPath();
95
+ try {
96
+ const content = await fs.readFile(configPath, 'utf-8');
97
+ return JSON.parse(content);
98
+ } catch (error) {
99
+ if (error.code === 'ENOENT') {
100
+ return null;
101
+ }
102
+ throw new Error(`Failed to read client config at ${configPath}: ${error.message}`);
103
+ }
104
+ }
105
+
106
+ export async function getServerUrl() {
107
+ if (process.env.CODER_SERVER_URL) {
108
+ return sanitizeServerUrl(process.env.CODER_SERVER_URL);
109
+ }
110
+
111
+ const config = await loadClientConfig();
112
+ if (config?.server) {
113
+ return sanitizeServerUrl(config.server);
114
+ }
115
+
116
+ return 'http://localhost:3000';
117
+ }
118
+
119
+ export async function getDefaultEnvironment() {
120
+ // Check client config first
121
+ const config = await loadClientConfig();
122
+ if (config?.default_environment) {
123
+ return config.default_environment;
124
+ }
125
+
126
+ // Fall back to fetching from server
127
+ try {
128
+ const { request } = await import('./http-client.js');
129
+ const environmentsData = await request('/environments');
130
+ return environmentsData?.default_environment || null;
131
+ } catch (error) {
132
+ // If server call fails, fall back to local setup.json if available
133
+ const setupPath = await getCoderSetupPath();
134
+ if (setupPath) {
135
+ try {
136
+ const setupJsonPath = path.join(setupPath, 'setup.json');
137
+ const setupContent = await fs.readFile(setupJsonPath, 'utf-8');
138
+ const setupJson = JSON.parse(setupContent);
139
+ return setupJson?.default_environment || null;
140
+ } catch (error) {
141
+ return null;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ }
147
+
148
+ export function getConfigPathForDisplay() {
149
+ return getConfigPath();
150
+ }
151
+
152
+ /**
153
+ * Get the path where credentials will be saved (profile or legacy config)
154
+ */
155
+ export async function getCredentialsPathForDisplay() {
156
+ const activeProfileName = cliProfileOverride || await getActiveProfileName();
157
+ if (activeProfileName) {
158
+ const { getProfilePath } = await import('./profile.js');
159
+ return getProfilePath(activeProfileName);
160
+ }
161
+ return getConfigPath();
162
+ }
163
+
164
+ /**
165
+ * Save API key to config file or active profile
166
+ */
167
+ export async function saveApiKey(apiKey) {
168
+ // Check if there's an active profile (including CLI override)
169
+ const activeProfileName = cliProfileOverride || await getActiveProfileName();
170
+
171
+ if (activeProfileName) {
172
+ // Save to active profile
173
+ const { loadProfile, saveProfile } = await import('./profile.js');
174
+ const profile = await loadProfile(activeProfileName) || { name: activeProfileName };
175
+ profile.apiKey = apiKey;
176
+ await saveProfile(activeProfileName, profile);
177
+
178
+ // Invalidate cache so next read gets fresh data
179
+ invalidateConfigCache();
180
+ return;
181
+ }
182
+
183
+ // Fall back to legacy config
184
+ const configPath = getConfigPath();
185
+ const configDir = path.dirname(configPath);
186
+
187
+ // Ensure .coder directory exists
188
+ await fs.mkdir(configDir, { recursive: true });
189
+
190
+ // Load existing config or create new one
191
+ let config = await loadRawClientConfig() || {};
192
+
193
+ // Update API key
194
+ config.apiKey = apiKey;
195
+
196
+ // Write config file
197
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
198
+
199
+ // Update cache
200
+ invalidateConfigCache();
201
+ }
202
+
203
+ /**
204
+ * Get API key from config or environment variable
205
+ */
206
+ export async function getApiKey() {
207
+ // Check environment variable first
208
+ if (process.env.CODER_API_KEY) {
209
+ return process.env.CODER_API_KEY;
210
+ }
211
+
212
+ // Check config file
213
+ const config = await loadClientConfig();
214
+ return config?.apiKey || null;
215
+ }
216
+
217
+ /**
218
+ * Clear API key from config file (logout)
219
+ */
220
+ export async function clearApiKey() {
221
+ const configPath = getConfigPath();
222
+
223
+ let config = await loadRawClientConfig();
224
+ if (!config) {
225
+ return; // No config file, nothing to clear
226
+ }
227
+
228
+ // Remove API key
229
+ delete config.apiKey;
230
+
231
+ // Write updated config
232
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
233
+
234
+ // Invalidate cache
235
+ invalidateConfigCache();
236
+ }
237
+
238
+ /**
239
+ * Save coder setup path to config file
240
+ */
241
+ export async function saveCoderSetupPath(setupPath) {
242
+ const configPath = getConfigPath();
243
+ const configDir = path.dirname(configPath);
244
+
245
+ // Ensure .coder directory exists
246
+ await fs.mkdir(configDir, { recursive: true });
247
+
248
+ // Load existing config or create new one (use raw to preserve activeProfile)
249
+ let config = await loadRawClientConfig() || {};
250
+
251
+ // Update coder setup path
252
+ config.coder_setup_path = setupPath;
253
+
254
+ // Write config file
255
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
256
+
257
+ // Invalidate cache
258
+ invalidateConfigCache();
259
+ }
260
+
261
+ /**
262
+ * Get coder setup path from config or environment variable
263
+ */
264
+ export async function getCoderSetupPath() {
265
+ // Check environment variable first
266
+ if (process.env.CODER_SETUP_PATH) {
267
+ return process.env.CODER_SETUP_PATH;
268
+ }
269
+
270
+ // Check config file
271
+ const config = await loadClientConfig();
272
+ return config?.coder_setup_path || null;
273
+ }
274
+
275
+ /**
276
+ * Save server port to config file
277
+ */
278
+ export async function saveServerPort(port) {
279
+ const configPath = getConfigPath();
280
+ const configDir = path.dirname(configPath);
281
+
282
+ // Ensure .coder directory exists
283
+ await fs.mkdir(configDir, { recursive: true });
284
+
285
+ // Load existing config or create new one (use raw to preserve activeProfile)
286
+ let config = await loadRawClientConfig() || {};
287
+
288
+ // Update server port
289
+ config.server_port = port;
290
+
291
+ // Write config file
292
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
293
+
294
+ // Invalidate cache
295
+ invalidateConfigCache();
296
+ }
297
+
298
+ /**
299
+ * Get server port from config or environment variable
300
+ */
301
+ export async function getServerPort() {
302
+ // Check environment variable first
303
+ if (process.env.PORT) {
304
+ return parseInt(process.env.PORT, 10);
305
+ }
306
+
307
+ // Check config file
308
+ const config = await loadClientConfig();
309
+ return config?.server_port || 3000;
310
+ }
311
+
312
+ /**
313
+ * Save profound coder path to config file
314
+ */
315
+ export async function saveProfoundCoderPath(profoundCoderPath) {
316
+ const configPath = getConfigPath();
317
+ const configDir = path.dirname(configPath);
318
+
319
+ // Ensure .coder directory exists
320
+ await fs.mkdir(configDir, { recursive: true });
321
+
322
+ // Load existing config or create new one (use raw to preserve activeProfile)
323
+ let config = await loadRawClientConfig() || {};
324
+
325
+ // Update profound coder path
326
+ config.profound_coder_path = profoundCoderPath;
327
+
328
+ // Write config file
329
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
330
+
331
+ // Invalidate cache
332
+ invalidateConfigCache();
333
+ }
334
+
335
+ /**
336
+ * Get profound coder path from config or environment variable
337
+ */
338
+ export async function getProfoundCoderPath() {
339
+ // Check environment variable first
340
+ if (process.env.PROFOUND_CODER_PATH) {
341
+ return process.env.PROFOUND_CODER_PATH;
342
+ }
343
+
344
+ // Check config file
345
+ const config = await loadClientConfig();
346
+ return config?.profound_coder_path || null;
347
+ }
348
+
349
+ /**
350
+ * Save last container ID to config file
351
+ */
352
+ export async function saveLastContainerId(containerId) {
353
+ const configPath = getConfigPath();
354
+ const configDir = path.dirname(configPath);
355
+
356
+ // Ensure .coder directory exists
357
+ await fs.mkdir(configDir, { recursive: true });
358
+
359
+ // Load existing config or create new one (use raw to preserve activeProfile)
360
+ let config = await loadRawClientConfig() || {};
361
+
362
+ // Update last container ID
363
+ config.last_container_id = containerId;
364
+
365
+ // Write config file
366
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
367
+
368
+ // Invalidate cache
369
+ invalidateConfigCache();
370
+ }
371
+
372
+ /**
373
+ * Get last container ID from config
374
+ */
375
+ export async function getLastContainerId() {
376
+ const config = await loadClientConfig();
377
+ return config?.last_container_id || null;
378
+ }