@amodalai/amodal 0.1.6 → 0.1.9

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 (50) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/src/commands/chat.d.ts.map +1 -1
  3. package/dist/src/commands/chat.js +38 -2
  4. package/dist/src/commands/chat.js.map +1 -1
  5. package/dist/src/commands/deploy.d.ts.map +1 -1
  6. package/dist/src/commands/deploy.js +16 -1
  7. package/dist/src/commands/deploy.js.map +1 -1
  8. package/dist/src/commands/dev.d.ts +1 -0
  9. package/dist/src/commands/dev.d.ts.map +1 -1
  10. package/dist/src/commands/dev.js +33 -33
  11. package/dist/src/commands/dev.js.map +1 -1
  12. package/dist/src/commands/index.d.ts.map +1 -1
  13. package/dist/src/commands/index.js +2 -1
  14. package/dist/src/commands/index.js.map +1 -1
  15. package/dist/src/commands/login.d.ts +5 -0
  16. package/dist/src/commands/login.d.ts.map +1 -1
  17. package/dist/src/commands/login.js +70 -4
  18. package/dist/src/commands/login.js.map +1 -1
  19. package/dist/src/commands/validate.d.ts +1 -0
  20. package/dist/src/commands/validate.d.ts.map +1 -1
  21. package/dist/src/commands/validate.js +228 -4
  22. package/dist/src/commands/validate.js.map +1 -1
  23. package/dist/src/shared/connection-preflight.d.ts +52 -0
  24. package/dist/src/shared/connection-preflight.d.ts.map +1 -0
  25. package/dist/src/shared/connection-preflight.js +204 -0
  26. package/dist/src/shared/connection-preflight.js.map +1 -0
  27. package/dist/src/shared/platform-client.d.ts +14 -1
  28. package/dist/src/shared/platform-client.d.ts.map +1 -1
  29. package/dist/src/shared/platform-client.js +103 -2
  30. package/dist/src/shared/platform-client.js.map +1 -1
  31. package/dist/src/shared/tarball.d.ts +13 -0
  32. package/dist/src/shared/tarball.d.ts.map +1 -0
  33. package/dist/src/shared/tarball.js +21 -0
  34. package/dist/src/shared/tarball.js.map +1 -0
  35. package/dist/src/ui/ChatApp.d.ts.map +1 -1
  36. package/dist/src/ui/ChatApp.js +8 -7
  37. package/dist/src/ui/ChatApp.js.map +1 -1
  38. package/dist/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +4 -4
  40. package/src/commands/chat.ts +38 -2
  41. package/src/commands/deploy.ts +17 -1
  42. package/src/commands/dev.ts +35 -32
  43. package/src/commands/index.ts +2 -1
  44. package/src/commands/login.ts +76 -4
  45. package/src/commands/validate.test.ts +169 -16
  46. package/src/commands/validate.ts +272 -4
  47. package/src/shared/connection-preflight.ts +251 -0
  48. package/src/shared/platform-client.ts +115 -2
  49. package/src/shared/tarball.ts +30 -0
  50. package/src/ui/ChatApp.tsx +8 -7
@@ -4,13 +4,17 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
 
7
+ import {join} from 'node:path';
8
+ import {readFile} from 'node:fs/promises';
7
9
  import type {CommandModule} from 'yargs';
8
- import {loadRepo, readLockFile, resolveAllPackages} from '@amodalai/core';
10
+ import {loadRepo, readLockFile, resolveAllPackages, McpManager} from '@amodalai/core';
11
+ import type {ConnectionSpec} from '@amodalai/core';
9
12
  import {findRepoRoot} from '../shared/repo-discovery.js';
10
13
 
11
14
  export interface ValidateOptions {
12
15
  cwd?: string;
13
16
  packages?: boolean;
17
+ skipTest?: boolean;
14
18
  }
15
19
 
16
20
  interface ValidationIssue {
@@ -18,6 +22,213 @@ interface ValidationIssue {
18
22
  message: string;
19
23
  }
20
24
 
25
+ interface LiveTestResult {
26
+ name: string;
27
+ type: 'REST' | 'MCP';
28
+ status: 'pass' | 'fail';
29
+ detail: string;
30
+ durationMs: number;
31
+ }
32
+
33
+ /**
34
+ * Resolve an env:VAR_NAME reference using a Map of env vars.
35
+ */
36
+ function resolveEnvToken(value: string, envVars: Map<string, string>): string {
37
+ if (!value.startsWith('env:')) return value;
38
+ const varName = value.slice(4);
39
+ return envVars.get(varName) ?? process.env[varName] ?? '';
40
+ }
41
+
42
+ /**
43
+ * Build auth headers from a connection spec's auth config.
44
+ */
45
+ function buildSpecAuthHeaders(
46
+ auth: ConnectionSpec['auth'],
47
+ envVars: Map<string, string>,
48
+ ): Record<string, string> {
49
+ if (!auth) return {};
50
+
51
+ const token = auth.token ? resolveEnvToken(auth.token, envVars) : '';
52
+
53
+ if (auth.type === 'bearer') {
54
+ const header = auth.header ?? 'Authorization';
55
+ const prefix = auth.prefix ?? 'Bearer';
56
+ return token ? {[header]: `${prefix} ${token}`} : {};
57
+ }
58
+
59
+ if (auth.type === 'api_key' || auth.type === 'api-key') {
60
+ const header = auth.header ?? 'X-API-Key';
61
+ return token ? {[header]: token} : {};
62
+ }
63
+
64
+ if (auth.type === 'basic') {
65
+ return token ? {Authorization: `Basic ${token}`} : {};
66
+ }
67
+
68
+ if (auth.type === 'header') {
69
+ const header = auth.header ?? 'Authorization';
70
+ return token ? {[header]: token} : {};
71
+ }
72
+
73
+ if (auth.type === 'none') {
74
+ return {};
75
+ }
76
+
77
+ return {};
78
+ }
79
+
80
+ /**
81
+ * Test a single REST connection. Never throws.
82
+ */
83
+ async function testRestConnection(
84
+ name: string,
85
+ spec: ConnectionSpec,
86
+ envVars: Map<string, string>,
87
+ ): Promise<LiveTestResult> {
88
+ const start = Date.now();
89
+ try {
90
+ let baseUrl = spec.baseUrl;
91
+ if (baseUrl.startsWith('env:')) {
92
+ baseUrl = resolveEnvToken(baseUrl, envVars);
93
+ }
94
+ if (!baseUrl) {
95
+ return {name, type: 'REST', status: 'fail', detail: 'baseUrl not resolved', durationMs: 0};
96
+ }
97
+
98
+ const testUrl = spec.testPath ? `${baseUrl.replace(/\/$/, '')}${spec.testPath}` : baseUrl;
99
+ const headers = buildSpecAuthHeaders(spec.auth, envVars);
100
+ const response = await fetch(testUrl, {
101
+ method: 'GET',
102
+ headers,
103
+ redirect: 'follow',
104
+ signal: AbortSignal.timeout(10_000),
105
+ });
106
+
107
+ // If we got a 401 after a redirect, retry the final URL with auth headers
108
+ // (fetch strips auth headers on redirects)
109
+ if (response.status === 401 && response.redirected) {
110
+ const retryResponse = await fetch(response.url, {
111
+ method: 'GET',
112
+ headers,
113
+ signal: AbortSignal.timeout(10_000),
114
+ });
115
+ const durationMs = Date.now() - start;
116
+ if (retryResponse.status === 401 || retryResponse.status === 403) {
117
+ return {name, type: 'REST', status: 'fail', detail: `${retryResponse.status} Unauthorized`, durationMs};
118
+ }
119
+ if (retryResponse.status >= 500) {
120
+ return {name, type: 'REST', status: 'fail', detail: `${retryResponse.status} Server Error`, durationMs};
121
+ }
122
+ return {name, type: 'REST', status: 'pass', detail: `${retryResponse.status} OK (after redirect)`, durationMs};
123
+ }
124
+
125
+ const durationMs = Date.now() - start;
126
+
127
+ // Any HTTP response means the server is reachable and auth was attempted.
128
+ // 2xx/3xx/404 = pass (server is up, auth accepted or endpoint not found).
129
+ // 401/403 = fail (auth rejected).
130
+ // 5xx = fail (server error).
131
+ if (response.status === 401 || response.status === 403) {
132
+ return {name, type: 'REST', status: 'fail', detail: `${response.status} Unauthorized`, durationMs};
133
+ }
134
+ if (response.status >= 500) {
135
+ return {name, type: 'REST', status: 'fail', detail: `${response.status} Server Error`, durationMs};
136
+ }
137
+ return {name, type: 'REST', status: 'pass', detail: `${response.status} OK`, durationMs};
138
+ } catch (err) {
139
+ const durationMs = Date.now() - start;
140
+ const msg = err instanceof Error ? err.message : String(err);
141
+ return {name, type: 'REST', status: 'fail', detail: msg, durationMs};
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Test MCP server connections. Never throws.
147
+ */
148
+ async function testMcpServers(
149
+ configs: Record<string, {transport: string; url?: string; command?: string; args?: string[]; env?: Record<string, string>; headers?: Record<string, string>; trust?: boolean}>,
150
+ ): Promise<LiveTestResult[]> {
151
+ const manager = new McpManager();
152
+ const start = Date.now();
153
+
154
+ try {
155
+ // Wrap with a 30-second overall timeout
156
+ await Promise.race([
157
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
158
+ manager.startServers(configs as Record<string, Parameters<typeof manager.startServers>[0][string]>),
159
+ new Promise<void>((_, reject) => setTimeout(() => reject(new Error('MCP startup timeout')), 30_000)),
160
+ ]);
161
+ } catch {
162
+ // Timeout or other error — still collect what we can
163
+ }
164
+
165
+ const results: LiveTestResult[] = [];
166
+ for (const info of manager.getServerInfo()) {
167
+ const durationMs = Date.now() - start;
168
+ if (info.status === 'connected') {
169
+ results.push({
170
+ name: info.name,
171
+ type: 'MCP',
172
+ status: 'pass',
173
+ detail: `${info.tools.length} tools`,
174
+ durationMs,
175
+ });
176
+ } else {
177
+ results.push({
178
+ name: info.name,
179
+ type: 'MCP',
180
+ status: 'fail',
181
+ detail: info.error ?? info.status,
182
+ durationMs,
183
+ });
184
+ }
185
+ }
186
+
187
+ await manager.shutdown();
188
+ return results;
189
+ }
190
+
191
+ /**
192
+ * Load .env file and inject values into process.env.
193
+ * Handles both plain KEY=value and export KEY=value formats.
194
+ */
195
+ async function loadEnvIntoProcess(repoPath: string): Promise<Map<string, string>> {
196
+ const envPath = join(repoPath, '.env');
197
+ let content: string;
198
+ try {
199
+ content = await readFile(envPath, 'utf-8');
200
+ } catch {
201
+ return new Map();
202
+ }
203
+
204
+ // Strip 'export ' prefix before parsing
205
+ const cleaned = content
206
+ .split('\n')
207
+ .map((line) => line.replace(/^export\s+/, ''))
208
+ .join('\n');
209
+
210
+ const entries = new Map<string, string>();
211
+ for (const line of cleaned.split('\n')) {
212
+ const trimmed = line.trim();
213
+ if (!trimmed || trimmed.startsWith('#')) continue;
214
+ const eqIdx = trimmed.indexOf('=');
215
+ if (eqIdx < 0) continue;
216
+ const key = trimmed.slice(0, eqIdx).trim();
217
+ let value = trimmed.slice(eqIdx + 1).trim();
218
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
219
+ value = value.slice(1, -1);
220
+ }
221
+ if (key) {
222
+ entries.set(key, value);
223
+ // Only set if not already in process.env (don't override explicit env)
224
+ if (process.env[key] === undefined) {
225
+ process.env[key] = value;
226
+ }
227
+ }
228
+ }
229
+ return entries;
230
+ }
231
+
21
232
  /**
22
233
  * Validates the amodal project configuration by loading the full repo
23
234
  * and running cross-reference checks.
@@ -36,6 +247,12 @@ export async function runValidate(options: ValidateOptions = {}): Promise<number
36
247
  return 1;
37
248
  }
38
249
 
250
+ // Load .env into process.env before loadRepo so env: references resolve
251
+ let envVars = new Map<string, string>();
252
+ if (!options.skipTest) {
253
+ envVars = await loadEnvIntoProcess(repoPath);
254
+ }
255
+
39
256
  process.stderr.write(`[validate] Loading repo from ${repoPath}\n`);
40
257
 
41
258
  try {
@@ -67,6 +284,51 @@ export async function runValidate(options: ValidateOptions = {}): Promise<number
67
284
  }
68
285
  }
69
286
 
287
+ // Live connection tests
288
+ if (!options.skipTest) {
289
+ process.stderr.write('\n[validate] Testing live connections...\n');
290
+ const liveResults: LiveTestResult[] = [];
291
+
292
+ // Test REST connections in parallel
293
+ const restPromises = [...repo.connections.entries()].map(([name, conn]) =>
294
+ testRestConnection(name, conn.spec, envVars),
295
+ );
296
+ const restResults = await Promise.allSettled(restPromises);
297
+ for (const result of restResults) {
298
+ if (result.status === 'fulfilled') {
299
+ liveResults.push(result.value);
300
+ }
301
+ }
302
+
303
+ // Test MCP servers
304
+ if (repo.mcpServers && Object.keys(repo.mcpServers).length > 0) {
305
+ const mcpResults = await testMcpServers(repo.mcpServers);
306
+ liveResults.push(...mcpResults);
307
+ }
308
+
309
+ // Print results table
310
+ process.stderr.write('\n');
311
+ const nameWidth = Math.max(20, ...liveResults.map((r) => r.name.length + 2));
312
+ process.stderr.write(
313
+ ` ${'CONNECTION'.padEnd(nameWidth)} ${'TYPE'.padEnd(6)} ${'STATUS'.padEnd(8)} DETAILS\n`,
314
+ );
315
+ for (const r of liveResults) {
316
+ const statusStr = r.status === 'pass' ? ' OK ' : ' FAIL ';
317
+ const durationStr = r.durationMs > 0 ? ` (${r.durationMs}ms)` : '';
318
+ process.stderr.write(
319
+ ` ${r.name.padEnd(nameWidth)} ${r.type.padEnd(6)} ${statusStr.padEnd(8)} ${r.detail}${durationStr}\n`,
320
+ );
321
+ }
322
+ process.stderr.write('\n');
323
+
324
+ // Add failures to issues
325
+ for (const r of liveResults) {
326
+ if (r.status === 'fail') {
327
+ issues.push({level: 'error', message: `${r.type} connection "${r.name}" failed: ${r.detail}`});
328
+ }
329
+ }
330
+ }
331
+
70
332
  } catch (err) {
71
333
  const msg = err instanceof Error ? err.message : String(err);
72
334
  issues.push({level: 'error', message: `Failed to load repo: ${msg}`});
@@ -130,10 +392,16 @@ export const validateCommand: CommandModule = {
130
392
  command: 'validate',
131
393
  describe: 'Validate the project configuration',
132
394
  builder: (yargs) =>
133
- yargs.option('packages', {type: 'boolean', default: false, describe: 'Include package resolution validation'}),
395
+ yargs
396
+ .option('packages', {type: 'boolean', default: false, describe: 'Include package resolution validation'})
397
+ .option('skip-test', {type: 'boolean', default: false, describe: 'Skip live connection and MCP server tests'}),
134
398
  handler: async (argv) => {
135
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
136
- const code = await runValidate({packages: argv['packages'] as boolean});
399
+ const code = await runValidate({
400
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
401
+ packages: argv['packages'] as boolean,
402
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
403
+ skipTest: argv['skipTest'] as boolean,
404
+ });
137
405
  process.exit(code);
138
406
  },
139
407
  };
@@ -0,0 +1,251 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Amodal Labs, Inc.
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+
7
+ import {join} from 'node:path';
8
+ import {readFile} from 'node:fs/promises';
9
+ import {loadRepo, McpManager} from '@amodalai/core';
10
+ import type {ConnectionSpec} from '@amodalai/core';
11
+
12
+ export interface LiveTestResult {
13
+ name: string;
14
+ type: 'REST' | 'MCP';
15
+ status: 'pass' | 'fail';
16
+ detail: string;
17
+ durationMs: number;
18
+ }
19
+
20
+ export interface PreflightReport {
21
+ results: LiveTestResult[];
22
+ hasFailures: boolean;
23
+ }
24
+
25
+ /**
26
+ * Resolve an env:VAR_NAME reference using a Map of env vars.
27
+ */
28
+ function resolveEnvToken(value: string, envVars: Map<string, string>): string {
29
+ if (!value.startsWith('env:')) return value;
30
+ const varName = value.slice(4);
31
+ return envVars.get(varName) ?? process.env[varName] ?? '';
32
+ }
33
+
34
+ /**
35
+ * Build auth headers from a connection spec's auth config.
36
+ */
37
+ export function buildSpecAuthHeaders(
38
+ auth: ConnectionSpec['auth'],
39
+ envVars: Map<string, string>,
40
+ ): Record<string, string> {
41
+ if (!auth) return {};
42
+
43
+ const token = auth.token ? resolveEnvToken(auth.token, envVars) : '';
44
+
45
+ if (auth.type === 'bearer') {
46
+ const header = auth.header ?? 'Authorization';
47
+ const prefix = auth.prefix ?? 'Bearer';
48
+ return token ? {[header]: `${prefix} ${token}`} : {};
49
+ }
50
+
51
+ if (auth.type === 'api_key' || auth.type === 'api-key') {
52
+ const header = auth.header ?? 'X-API-Key';
53
+ return token ? {[header]: token} : {};
54
+ }
55
+
56
+ if (auth.type === 'basic') {
57
+ return token ? {Authorization: `Basic ${token}`} : {};
58
+ }
59
+
60
+ if (auth.type === 'header') {
61
+ const header = auth.header ?? 'Authorization';
62
+ return token ? {[header]: token} : {};
63
+ }
64
+
65
+ return {};
66
+ }
67
+
68
+ /**
69
+ * Test a single REST connection. Never throws.
70
+ */
71
+ export async function testRestConnection(
72
+ name: string,
73
+ spec: ConnectionSpec,
74
+ envVars: Map<string, string>,
75
+ ): Promise<LiveTestResult> {
76
+ const start = Date.now();
77
+ try {
78
+ let baseUrl = spec.baseUrl;
79
+ if (baseUrl.startsWith('env:')) {
80
+ baseUrl = resolveEnvToken(baseUrl, envVars);
81
+ }
82
+ if (!baseUrl) {
83
+ return {name, type: 'REST', status: 'fail', detail: 'baseUrl not resolved', durationMs: 0};
84
+ }
85
+
86
+ const testUrl = spec.testPath ? `${baseUrl.replace(/\/$/, '')}${spec.testPath}` : baseUrl;
87
+ const headers = buildSpecAuthHeaders(spec.auth, envVars);
88
+ const response = await fetch(testUrl, {
89
+ method: 'GET',
90
+ headers,
91
+ redirect: 'follow',
92
+ signal: AbortSignal.timeout(10_000),
93
+ });
94
+
95
+ // If we got a 401 after a redirect, retry the final URL with auth headers
96
+ if (response.status === 401 && response.redirected) {
97
+ const retryResponse = await fetch(response.url, {
98
+ method: 'GET',
99
+ headers,
100
+ signal: AbortSignal.timeout(10_000),
101
+ });
102
+ const durationMs = Date.now() - start;
103
+ if (retryResponse.status === 401 || retryResponse.status === 403) {
104
+ return {name, type: 'REST', status: 'fail', detail: `${retryResponse.status} Unauthorized`, durationMs};
105
+ }
106
+ if (retryResponse.status >= 500) {
107
+ return {name, type: 'REST', status: 'fail', detail: `${retryResponse.status} Server Error`, durationMs};
108
+ }
109
+ return {name, type: 'REST', status: 'pass', detail: `${retryResponse.status} OK (after redirect)`, durationMs};
110
+ }
111
+
112
+ const durationMs = Date.now() - start;
113
+
114
+ if (response.status === 401 || response.status === 403) {
115
+ return {name, type: 'REST', status: 'fail', detail: `${response.status} Unauthorized`, durationMs};
116
+ }
117
+ if (response.status >= 500) {
118
+ return {name, type: 'REST', status: 'fail', detail: `${response.status} Server Error`, durationMs};
119
+ }
120
+ return {name, type: 'REST', status: 'pass', detail: `${response.status} OK`, durationMs};
121
+ } catch (err) {
122
+ const durationMs = Date.now() - start;
123
+ const msg = err instanceof Error ? err.message : String(err);
124
+ return {name, type: 'REST', status: 'fail', detail: msg, durationMs};
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Test MCP server connections. Never throws.
130
+ */
131
+ export async function testMcpServers(
132
+ configs: Record<string, {transport: string; url?: string; command?: string; args?: string[]; env?: Record<string, string>; headers?: Record<string, string>; trust?: boolean}>,
133
+ ): Promise<LiveTestResult[]> {
134
+ const manager = new McpManager();
135
+ const start = Date.now();
136
+
137
+ try {
138
+ await Promise.race([
139
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
140
+ manager.startServers(configs as Record<string, Parameters<typeof manager.startServers>[0][string]>),
141
+ new Promise<void>((_, reject) => setTimeout(() => reject(new Error('MCP startup timeout')), 30_000)),
142
+ ]);
143
+ } catch {
144
+ // Timeout — still collect what we can
145
+ }
146
+
147
+ const results: LiveTestResult[] = [];
148
+ for (const info of manager.getServerInfo()) {
149
+ const durationMs = Date.now() - start;
150
+ if (info.status === 'connected') {
151
+ results.push({name: info.name, type: 'MCP', status: 'pass', detail: `${info.tools.length} tools`, durationMs});
152
+ } else {
153
+ results.push({name: info.name, type: 'MCP', status: 'fail', detail: info.error ?? info.status, durationMs});
154
+ }
155
+ }
156
+
157
+ await manager.shutdown();
158
+ return results;
159
+ }
160
+
161
+ /**
162
+ * Load .env file and inject values into process.env.
163
+ * Handles both plain KEY=value and export KEY=value formats.
164
+ */
165
+ export async function loadEnvIntoProcess(repoPath: string): Promise<Map<string, string>> {
166
+ const envPath = join(repoPath, '.env');
167
+ let content: string;
168
+ try {
169
+ content = await readFile(envPath, 'utf-8');
170
+ } catch {
171
+ return new Map();
172
+ }
173
+
174
+ const cleaned = content.split('\n').map((line) => line.replace(/^export\s+/, '')).join('\n');
175
+
176
+ const entries = new Map<string, string>();
177
+ for (const line of cleaned.split('\n')) {
178
+ const trimmed = line.trim();
179
+ if (!trimmed || trimmed.startsWith('#')) continue;
180
+ const eqIdx = trimmed.indexOf('=');
181
+ if (eqIdx < 0) continue;
182
+ const key = trimmed.slice(0, eqIdx).trim();
183
+ let value = trimmed.slice(eqIdx + 1).trim();
184
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
185
+ value = value.slice(1, -1);
186
+ }
187
+ if (key) {
188
+ entries.set(key, value);
189
+ if (process.env[key] === undefined) {
190
+ process.env[key] = value;
191
+ }
192
+ }
193
+ }
194
+ return entries;
195
+ }
196
+
197
+ /**
198
+ * Print the preflight results table to stderr.
199
+ */
200
+ export function printPreflightTable(results: LiveTestResult[]): void {
201
+ const nameWidth = Math.max(20, ...results.map((r) => r.name.length + 2));
202
+ process.stderr.write(
203
+ ` ${'CONNECTION'.padEnd(nameWidth)} ${'TYPE'.padEnd(6)} ${'STATUS'.padEnd(8)} DETAILS\n`,
204
+ );
205
+ for (const r of results) {
206
+ const statusStr = r.status === 'pass' ? ' OK ' : ' FAIL ';
207
+ const durationStr = r.durationMs > 0 ? ` (${r.durationMs}ms)` : '';
208
+ process.stderr.write(
209
+ ` ${r.name.padEnd(nameWidth)} ${r.type.padEnd(6)} ${statusStr.padEnd(8)} ${r.detail}${durationStr}\n`,
210
+ );
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Run preflight connection tests for a repo.
216
+ * Loads .env, loads repo, tests REST connections and MCP servers.
217
+ */
218
+ export async function runConnectionPreflight(repoPath: string): Promise<PreflightReport> {
219
+ const envVars = await loadEnvIntoProcess(repoPath);
220
+
221
+ let repo;
222
+ try {
223
+ repo = await loadRepo({localPath: repoPath});
224
+ } catch {
225
+ return {results: [], hasFailures: false};
226
+ }
227
+
228
+ const results: LiveTestResult[] = [];
229
+
230
+ // Test REST connections in parallel
231
+ const restPromises = [...repo.connections.entries()].map(([name, conn]) =>
232
+ testRestConnection(name, conn.spec, envVars),
233
+ );
234
+ const restResults = await Promise.allSettled(restPromises);
235
+ for (const result of restResults) {
236
+ if (result.status === 'fulfilled') {
237
+ results.push(result.value);
238
+ }
239
+ }
240
+
241
+ // Test MCP servers
242
+ if (repo.mcpServers && Object.keys(repo.mcpServers).length > 0) {
243
+ const mcpResults = await testMcpServers(repo.mcpServers);
244
+ results.push(...mcpResults);
245
+ }
246
+
247
+ return {
248
+ results,
249
+ hasFailures: results.some((r) => r.status === 'fail'),
250
+ };
251
+ }