@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.
- package/CHANGELOG.md +45 -0
- package/dist/src/commands/chat.d.ts.map +1 -1
- package/dist/src/commands/chat.js +38 -2
- package/dist/src/commands/chat.js.map +1 -1
- package/dist/src/commands/deploy.d.ts.map +1 -1
- package/dist/src/commands/deploy.js +16 -1
- package/dist/src/commands/deploy.js.map +1 -1
- package/dist/src/commands/dev.d.ts +1 -0
- package/dist/src/commands/dev.d.ts.map +1 -1
- package/dist/src/commands/dev.js +33 -33
- package/dist/src/commands/dev.js.map +1 -1
- package/dist/src/commands/index.d.ts.map +1 -1
- package/dist/src/commands/index.js +2 -1
- package/dist/src/commands/index.js.map +1 -1
- package/dist/src/commands/login.d.ts +5 -0
- package/dist/src/commands/login.d.ts.map +1 -1
- package/dist/src/commands/login.js +70 -4
- package/dist/src/commands/login.js.map +1 -1
- package/dist/src/commands/validate.d.ts +1 -0
- package/dist/src/commands/validate.d.ts.map +1 -1
- package/dist/src/commands/validate.js +228 -4
- package/dist/src/commands/validate.js.map +1 -1
- package/dist/src/shared/connection-preflight.d.ts +52 -0
- package/dist/src/shared/connection-preflight.d.ts.map +1 -0
- package/dist/src/shared/connection-preflight.js +204 -0
- package/dist/src/shared/connection-preflight.js.map +1 -0
- package/dist/src/shared/platform-client.d.ts +14 -1
- package/dist/src/shared/platform-client.d.ts.map +1 -1
- package/dist/src/shared/platform-client.js +103 -2
- package/dist/src/shared/platform-client.js.map +1 -1
- package/dist/src/shared/tarball.d.ts +13 -0
- package/dist/src/shared/tarball.d.ts.map +1 -0
- package/dist/src/shared/tarball.js +21 -0
- package/dist/src/shared/tarball.js.map +1 -0
- package/dist/src/ui/ChatApp.d.ts.map +1 -1
- package/dist/src/ui/ChatApp.js +8 -7
- package/dist/src/ui/ChatApp.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/commands/chat.ts +38 -2
- package/src/commands/deploy.ts +17 -1
- package/src/commands/dev.ts +35 -32
- package/src/commands/index.ts +2 -1
- package/src/commands/login.ts +76 -4
- package/src/commands/validate.test.ts +169 -16
- package/src/commands/validate.ts +272 -4
- package/src/shared/connection-preflight.ts +251 -0
- package/src/shared/platform-client.ts +115 -2
- package/src/shared/tarball.ts +30 -0
- package/src/ui/ChatApp.tsx +8 -7
package/src/commands/validate.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
136
|
-
|
|
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
|
+
}
|