@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amodalai/amodal",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "Amodal CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,11 +30,11 @@
30
30
  "semver": "^7.6.0",
31
31
  "yargs": "^17.7.2",
32
32
  "zod": "^4.3.6",
33
- "@amodalai/core": "0.1.6",
34
- "@amodalai/runtime": "0.1.6"
33
+ "@amodalai/runtime": "0.1.9",
34
+ "@amodalai/core": "0.1.9"
35
35
  },
36
36
  "peerDependencies": {
37
- "@amodalai/runtime-app": "0.1.6"
37
+ "@amodalai/runtime-app": "0.1.9"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@amodalai/runtime-app": {
@@ -10,6 +10,7 @@ import {render} from 'ink';
10
10
  import type {CommandModule} from 'yargs';
11
11
  import {createLocalServer, createSnapshotServer} from '@amodalai/runtime';
12
12
  import {findRepoRoot} from '../shared/repo-discovery.js';
13
+ import {runConnectionPreflight, printPreflightTable} from '../shared/connection-preflight.js';
13
14
  import {ChatApp} from '../ui/ChatApp.js';
14
15
 
15
16
  export interface ChatOptions {
@@ -53,6 +54,7 @@ export async function runChat(options: ChatOptions): Promise<void> {
53
54
  // Mode 2 & 3: Boot a local server
54
55
  const port = options.port ?? 0;
55
56
  let serverInstance: {app: unknown; start: () => Promise<unknown>; stop: () => Promise<void>};
57
+ let repoPath: string | undefined;
56
58
 
57
59
  if (options.config) {
58
60
  process.stderr.write(`[chat] Loading snapshot from ${options.config}\n`);
@@ -62,7 +64,6 @@ export async function runChat(options: ChatOptions): Promise<void> {
62
64
  host: '127.0.0.1',
63
65
  });
64
66
  } else {
65
- let repoPath: string;
66
67
  try {
67
68
  repoPath = findRepoRoot(options.cwd);
68
69
  } catch (err) {
@@ -86,11 +87,46 @@ export async function runChat(options: ChatOptions): Promise<void> {
86
87
  const actualPort = typeof addr === 'object' && addr !== null ? addr.port : port;
87
88
  const baseUrl = `http://127.0.0.1:${actualPort}`;
88
89
 
90
+ // Preflight connection check (non-blocking)
91
+ if (repoPath) {
92
+ const preflight = await runConnectionPreflight(repoPath);
93
+ if (preflight.results.length > 0) {
94
+ process.stderr.write('\n');
95
+ printPreflightTable(preflight.results);
96
+ if (preflight.hasFailures) {
97
+ process.stderr.write('\n WARNING: Some connections failed. The agent may not work correctly.\n');
98
+ }
99
+ process.stderr.write('\n');
100
+ }
101
+ }
102
+
103
+ // Resolve "latest" to an actual session ID
104
+ let resumeId = options.resume;
105
+ if (resumeId === 'latest') {
106
+ try {
107
+ const res = await fetch(`${baseUrl}/sessions`);
108
+ if (res.ok) {
109
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
110
+ const body = await res.json() as {sessions: Array<{id: string}>};
111
+ if (body.sessions.length > 0) {
112
+ resumeId = body.sessions[0].id;
113
+ process.stderr.write(`[chat] Resuming session ${resumeId}\n`);
114
+ } else {
115
+ process.stderr.write('[chat] No previous sessions found, starting fresh\n');
116
+ resumeId = undefined;
117
+ }
118
+ }
119
+ } catch {
120
+ process.stderr.write('[chat] Could not fetch sessions, starting fresh\n');
121
+ resumeId = undefined;
122
+ }
123
+ }
124
+
89
125
  const {waitUntilExit} = render(
90
126
  createElement(ChatApp, {
91
127
  baseUrl,
92
128
  tenantId,
93
- resumeSessionId: options.resume,
129
+ resumeSessionId: resumeId,
94
130
  fullscreen: options.fullscreen,
95
131
  }),
96
132
  );
@@ -5,11 +5,14 @@
5
5
  */
6
6
 
7
7
  import {execSync} from 'node:child_process';
8
+ import {createReadStream} from 'node:fs';
8
9
  import type {CommandModule} from 'yargs';
9
10
  import {loadRepo, buildSnapshot, serializeSnapshot, snapshotSizeBytes} from '@amodalai/core';
10
11
  import {findRepoRoot} from '../shared/repo-discovery.js';
11
12
  import {PlatformClient} from '../shared/platform-client.js';
12
13
  import {runValidate} from './validate.js';
14
+ import {createRepoTarball} from '../shared/tarball.js';
15
+ import {readProjectLink} from './link.js';
13
16
 
14
17
  export interface DeployOptions {
15
18
  cwd?: string;
@@ -107,11 +110,24 @@ export async function runDeploy(options: DeployOptions = {}): Promise<number> {
107
110
  }
108
111
 
109
112
  try {
110
- const result = await client.uploadSnapshot(snapshot, {environment});
113
+ // Create tarball of the repo for server-side runtime-app build
114
+ process.stderr.write('[deploy] Packaging repo...\n');
115
+ const tarballPath = await createRepoTarball(repoPath);
116
+
117
+ // Read appId from project link
118
+ const projectLink = await readProjectLink();
119
+ const appId = projectLink?.appId;
120
+
121
+ const result = await client.deployWithRepo(snapshot, createReadStream(tarballPath), {environment, appId});
111
122
  process.stderr.write(`[deploy] Deployed ${result.id} to ${result.environment}\n`);
112
123
  if (result.message) {
113
124
  process.stderr.write(`[deploy] Message: ${result.message}\n`);
114
125
  }
126
+
127
+ // Cleanup tarball
128
+ const {unlinkSync} = await import('node:fs');
129
+ try { unlinkSync(tarballPath); } catch { /* best-effort */ }
130
+
115
131
  return 0;
116
132
  } catch (err) {
117
133
  const msg = err instanceof Error ? err.message : String(err);
@@ -10,20 +10,13 @@ import path from 'node:path';
10
10
  import {fileURLToPath} from 'node:url';
11
11
  import {createLocalServer} from '@amodalai/runtime';
12
12
  import {findRepoRoot} from '../shared/repo-discovery.js';
13
-
14
- async function loadRuntimeApp(): Promise<typeof import('@amodalai/runtime-app/dev') | null> {
15
- try {
16
- return await import('@amodalai/runtime-app/dev');
17
- } catch {
18
- // Runtime app is optional — server-only mode without the frontend
19
- return null;
20
- }
21
- }
13
+ import {runConnectionPreflight, printPreflightTable} from '../shared/connection-preflight.js';
22
14
 
23
15
  export interface DevOptions {
24
16
  cwd?: string;
25
17
  port?: number;
26
18
  host?: string;
19
+ resume?: string;
27
20
  }
28
21
 
29
22
  const DEFAULT_PORT = 3847;
@@ -47,29 +40,22 @@ export async function runDev(options: DevOptions = {}): Promise<void> {
47
40
  process.stderr.write(`[dev] Starting dev server for ${repoPath}\n`);
48
41
 
49
42
  try {
50
- // Try to load the runtime app for the dev UI
51
- let appMiddleware: ((req: unknown, res: unknown, next: unknown) => void) | undefined;
52
43
  let staticAppDir: string | undefined;
53
44
 
54
- const runtimeApp = await loadRuntimeApp();
55
- if (runtimeApp) {
56
- process.stderr.write('[dev] Loading runtime app (Vite dev server)...\n');
57
- appMiddleware = await runtimeApp.createDevMiddleware(repoPath);
58
- } else {
59
- // Fall back to pre-built static assets (bundled mode or global install)
60
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
61
- const candidates = [
62
- // esbuild bundle: bundle/app/
63
- path.resolve(scriptDir, 'app'),
64
- // global/local install: <pkg root>/node_modules/@amodalai/runtime-app/dist/
65
- path.resolve(scriptDir, '..', '..', '..', 'node_modules', '@amodal', 'runtime-app', 'dist'),
66
- ];
67
- for (const dir of candidates) {
68
- if (existsSync(path.join(dir, 'index.html'))) {
69
- process.stderr.write('[dev] Serving pre-built runtime app\n');
70
- staticAppDir = dir;
71
- break;
72
- }
45
+ // Use pre-built static assets for the SPA for the SPA.
46
+ // Vite dev middleware is only used inside the monorepo with `pnpm dev`.
47
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
48
+ const candidates = [
49
+ // esbuild bundle: bundle/app/
50
+ path.resolve(scriptDir, 'app'),
51
+ // global/local install: <pkg root>/node_modules/@amodalai/runtime-app/dist/
52
+ path.resolve(scriptDir, '..', '..', '..', 'node_modules', '@amodalai', 'runtime-app', 'dist'),
53
+ ];
54
+ for (const dir of candidates) {
55
+ if (existsSync(path.join(dir, 'index.html'))) {
56
+ process.stderr.write('[dev] Serving pre-built runtime app\n');
57
+ staticAppDir = dir;
58
+ break;
73
59
  }
74
60
  }
75
61
 
@@ -79,12 +65,23 @@ export async function runDev(options: DevOptions = {}): Promise<void> {
79
65
  host,
80
66
  hotReload: true,
81
67
  corsOrigin: '*',
82
- appMiddleware,
83
68
  staticAppDir,
69
+ resumeSessionId: options.resume,
84
70
  });
85
71
 
86
72
  await server.start();
87
73
 
74
+ // Preflight connection check (non-blocking)
75
+ const preflight = await runConnectionPreflight(repoPath);
76
+ if (preflight.results.length > 0) {
77
+ process.stderr.write('\n');
78
+ printPreflightTable(preflight.results);
79
+ if (preflight.hasFailures) {
80
+ process.stderr.write('\n WARNING: Some connections failed. The agent may not work correctly.\n');
81
+ }
82
+ process.stderr.write('\n');
83
+ }
84
+
88
85
  // Graceful shutdown
89
86
  const shutdown = async (signal: string): Promise<void> => {
90
87
  process.stderr.write(`\n[dev] Received ${signal}, shutting down...\n`);
@@ -113,12 +110,18 @@ export const devCommand: CommandModule = {
113
110
  type: 'string',
114
111
  describe: 'Host to bind to',
115
112
  },
113
+ resume: {
114
+ type: 'string',
115
+ describe: 'Resume a previous session by ID or "latest"',
116
+ },
116
117
  },
117
118
  handler: async (argv) => {
118
119
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
119
120
  const port = argv['port'] as number | undefined;
120
121
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
121
122
  const host = argv['host'] as string | undefined;
122
- await runDev({port, host});
123
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
124
+ const resume = argv['resume'] as string | undefined;
125
+ await runDev({port, host, resume});
123
126
  },
124
127
  };
@@ -16,7 +16,7 @@ import {updateCommand} from './update.js';
16
16
  import {diffCommand} from './diff.js';
17
17
  import {searchCommand} from './search.js';
18
18
  import {publishCommand} from './publish.js';
19
- import {loginCommand} from './login.js';
19
+ import {loginCommand, logoutCommand} from './login.js';
20
20
  import {linkCommand} from './link.js';
21
21
  import {syncCommand} from './sync.js';
22
22
  import {secretsCommand} from './secrets.js';
@@ -54,6 +54,7 @@ export const amodalCommands = [
54
54
  searchCommand,
55
55
  publishCommand,
56
56
  loginCommand,
57
+ logoutCommand,
57
58
  linkCommand,
58
59
  syncCommand,
59
60
  // Platform
@@ -143,12 +143,34 @@ export async function runLogin(options: LoginOptions = {}): Promise<number> {
143
143
  const platformUrl = options.platformUrl ?? process.env['PLATFORM_API_URL'] ?? DEFAULT_PLATFORM_URL;
144
144
  const adminUrl = options.adminUrl ?? process.env['ADMIN_UI_URL'] ?? DEFAULT_ADMIN_UI_URL;
145
145
 
146
- // Check if already logged in
146
+ // Check if already logged in — try refreshing the token first
147
147
  const rc = await readRcFile();
148
148
  if (rc.platform?.url === platformUrl && rc.platform.token) {
149
- process.stderr.write(`[login] Already logged in to ${platformUrl}\n`);
150
- process.stderr.write('[login] Run `amodal logout` first, or use --platform-url for a different instance.\n');
151
- return 0;
149
+ // Try the existing token
150
+ const meRes = await fetch(`${platformUrl}/api/me`, {
151
+ headers: {Authorization: `Bearer ${rc.platform.token}`},
152
+ }).catch(() => null);
153
+
154
+ if (meRes?.ok) {
155
+ process.stderr.write(`[login] Already logged in to ${platformUrl}\n`);
156
+ return 0;
157
+ }
158
+
159
+ // Token expired — try refresh
160
+ if (rc.platform.refreshToken) {
161
+ process.stderr.write('[login] Token expired, refreshing...\n');
162
+ const refreshed = await refreshAccessToken(platformUrl, rc.platform.refreshToken);
163
+ if (refreshed) {
164
+ rc.platform.token = refreshed.token;
165
+ rc.platform.refreshToken = refreshed.refreshToken;
166
+ await writeRcFile(rc);
167
+ process.stderr.write('[login] Token refreshed successfully.\n');
168
+ return 0;
169
+ }
170
+ }
171
+
172
+ // Refresh failed — proceed to re-login
173
+ process.stderr.write('[login] Session expired. Re-authenticating...\n');
152
174
  }
153
175
 
154
176
  // Start local callback server
@@ -234,6 +256,56 @@ export async function runLogin(options: LoginOptions = {}): Promise<number> {
234
256
  }
235
257
  }
236
258
 
259
+ /**
260
+ * Refresh an expired Supabase access token using the refresh token.
261
+ */
262
+ async function refreshAccessToken(
263
+ platformUrl: string,
264
+ refreshToken: string,
265
+ ): Promise<{token: string; refreshToken: string} | null> {
266
+ try {
267
+ const res = await fetch(`${platformUrl}/api/auth/refresh`, {
268
+ method: 'POST',
269
+ headers: {'Content-Type': 'application/json'},
270
+ body: JSON.stringify({refresh_token: refreshToken}),
271
+ });
272
+ if (!res.ok) return null;
273
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
274
+ const data = (await res.json()) as {access_token?: string; refresh_token?: string};
275
+ if (!data.access_token) return null;
276
+ return {
277
+ token: data.access_token,
278
+ refreshToken: data.refresh_token ?? refreshToken,
279
+ };
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Log out by removing saved credentials.
287
+ */
288
+ export async function runLogout(): Promise<number> {
289
+ const rc = await readRcFile();
290
+ if (!rc.platform) {
291
+ process.stderr.write('[logout] Not logged in.\n');
292
+ return 0;
293
+ }
294
+ delete rc.platform;
295
+ await writeRcFile(rc);
296
+ process.stderr.write('[logout] Logged out.\n');
297
+ return 0;
298
+ }
299
+
300
+ export const logoutCommand: CommandModule = {
301
+ command: 'logout',
302
+ describe: 'Log out from the amodal platform',
303
+ handler: async () => {
304
+ const code = await runLogout();
305
+ process.exit(code);
306
+ },
307
+ };
308
+
237
309
  export const loginCommand: CommandModule = {
238
310
  command: 'login',
239
311
  describe: 'Log in to the amodal platform',
@@ -15,10 +15,17 @@ vi.mock('../shared/repo-discovery.js', () => ({
15
15
  findRepoRoot: mockFindRepoRoot,
16
16
  }));
17
17
 
18
+ const mockMcpManager = {
19
+ startServers: vi.fn().mockResolvedValue(undefined),
20
+ getServerInfo: vi.fn().mockReturnValue([]),
21
+ shutdown: vi.fn().mockResolvedValue(undefined),
22
+ };
23
+
18
24
  vi.mock('@amodalai/core', () => ({
19
25
  loadRepo: mockLoadRepo,
20
26
  readLockFile: mockReadLockFile,
21
27
  resolveAllPackages: mockResolveAllPackages,
28
+ McpManager: vi.fn(() => mockMcpManager),
22
29
  }));
23
30
 
24
31
  describe('runValidate', () => {
@@ -38,7 +45,7 @@ describe('runValidate', () => {
38
45
  });
39
46
 
40
47
  const {runValidate} = await import('./validate.js');
41
- const result = await runValidate();
48
+ const result = await runValidate({skipTest: true});
42
49
  expect(result).toBe(0);
43
50
  });
44
51
 
@@ -50,7 +57,7 @@ describe('runValidate', () => {
50
57
  });
51
58
 
52
59
  const {runValidate} = await import('./validate.js');
53
- const result = await runValidate();
60
+ const result = await runValidate({skipTest: true});
54
61
  expect(result).toBe(0); // warnings don't cause failure
55
62
  });
56
63
 
@@ -62,7 +69,7 @@ describe('runValidate', () => {
62
69
  });
63
70
 
64
71
  const {runValidate} = await import('./validate.js');
65
- const result = await runValidate();
72
+ const result = await runValidate({skipTest: true});
66
73
  expect(result).toBe(0);
67
74
  });
68
75
 
@@ -74,7 +81,7 @@ describe('runValidate', () => {
74
81
  });
75
82
 
76
83
  const {runValidate} = await import('./validate.js');
77
- const result = await runValidate();
84
+ const result = await runValidate({skipTest: true});
78
85
  expect(result).toBe(1);
79
86
  });
80
87
 
@@ -86,7 +93,7 @@ describe('runValidate', () => {
86
93
  });
87
94
 
88
95
  const {runValidate} = await import('./validate.js');
89
- const result = await runValidate();
96
+ const result = await runValidate({skipTest: true});
90
97
  expect(result).toBe(0);
91
98
  });
92
99
 
@@ -94,7 +101,7 @@ describe('runValidate', () => {
94
101
  mockLoadRepo.mockRejectedValue(new Error('Config parse failed'));
95
102
 
96
103
  const {runValidate} = await import('./validate.js');
97
- const result = await runValidate();
104
+ const result = await runValidate({skipTest: true});
98
105
  expect(result).toBe(1);
99
106
  });
100
107
 
@@ -104,7 +111,7 @@ describe('runValidate', () => {
104
111
  });
105
112
 
106
113
  const {runValidate} = await import('./validate.js');
107
- const result = await runValidate();
114
+ const result = await runValidate({skipTest: true});
108
115
  expect(result).toBe(1);
109
116
  });
110
117
 
@@ -119,7 +126,7 @@ describe('runValidate', () => {
119
126
  });
120
127
 
121
128
  const {runValidate} = await import('./validate.js');
122
- const result = await runValidate();
129
+ const result = await runValidate({skipTest: true});
123
130
  expect(result).toBe(2);
124
131
  });
125
132
 
@@ -131,7 +138,7 @@ describe('runValidate', () => {
131
138
  });
132
139
 
133
140
  const {runValidate} = await import('./validate.js');
134
- const result = await runValidate();
141
+ const result = await runValidate({skipTest: true});
135
142
  expect(result).toBe(1); // 1 error
136
143
  });
137
144
 
@@ -152,7 +159,7 @@ describe('runValidate', () => {
152
159
  });
153
160
 
154
161
  const {runValidate} = await import('./validate.js');
155
- const result = await runValidate({packages: true});
162
+ const result = await runValidate({packages: true, skipTest: true});
156
163
  expect(result).toBe(0);
157
164
  expect(mockResolveAllPackages).toHaveBeenCalled();
158
165
  });
@@ -173,7 +180,7 @@ describe('runValidate', () => {
173
180
  });
174
181
 
175
182
  const {runValidate} = await import('./validate.js');
176
- const result = await runValidate({packages: true});
183
+ const result = await runValidate({packages: true, skipTest: true});
177
184
  expect(result).toBe(0); // warnings only
178
185
  });
179
186
 
@@ -193,7 +200,7 @@ describe('runValidate', () => {
193
200
  });
194
201
 
195
202
  const {runValidate} = await import('./validate.js');
196
- const result = await runValidate({packages: true});
203
+ const result = await runValidate({packages: true, skipTest: true});
197
204
  expect(result).toBe(1);
198
205
  });
199
206
 
@@ -206,7 +213,7 @@ describe('runValidate', () => {
206
213
  mockReadLockFile.mockResolvedValue(null);
207
214
 
208
215
  const {runValidate} = await import('./validate.js');
209
- const result = await runValidate({packages: true});
216
+ const result = await runValidate({packages: true, skipTest: true});
210
217
  expect(result).toBe(0);
211
218
  expect(mockResolveAllPackages).not.toHaveBeenCalled();
212
219
  });
@@ -219,7 +226,7 @@ describe('runValidate', () => {
219
226
  });
220
227
 
221
228
  const {runValidate} = await import('./validate.js');
222
- const result = await runValidate();
229
+ const result = await runValidate({skipTest: true});
223
230
  expect(result).toBe(0);
224
231
  expect(mockReadLockFile).not.toHaveBeenCalled();
225
232
  });
@@ -234,7 +241,7 @@ describe('runValidate', () => {
234
241
  mockResolveAllPackages.mockRejectedValue(new Error('Disk error'));
235
242
 
236
243
  const {runValidate} = await import('./validate.js');
237
- const result = await runValidate({packages: true});
244
+ const result = await runValidate({packages: true, skipTest: true});
238
245
  expect(result).toBe(1);
239
246
  });
240
247
 
@@ -254,7 +261,153 @@ describe('runValidate', () => {
254
261
  });
255
262
 
256
263
  const {runValidate} = await import('./validate.js');
257
- const result = await runValidate({packages: true});
264
+ const result = await runValidate({packages: true, skipTest: true});
258
265
  expect(result).toBe(0); // warning only
259
266
  });
267
+
268
+ // Live connection testing
269
+ describe('live connection tests', () => {
270
+ const mockFetch = vi.fn();
271
+
272
+ beforeEach(() => {
273
+ vi.stubGlobal('fetch', mockFetch);
274
+ mockFetch.mockReset();
275
+ });
276
+
277
+ it('should test REST connections and report pass on 200', async () => {
278
+ mockLoadRepo.mockResolvedValue({
279
+ connections: new Map([['myapi', {
280
+ surface: [{method: 'GET', path: '/test'}],
281
+ access: {},
282
+ spec: {baseUrl: 'https://api.example.com', auth: {type: 'none'}},
283
+ }]]),
284
+ skills: [],
285
+ automations: [],
286
+ });
287
+ mockFetch.mockResolvedValue({status: 200, redirected: false});
288
+
289
+ const {runValidate} = await import('./validate.js');
290
+ const result = await runValidate({skipTest: false});
291
+ expect(result).toBe(0);
292
+ expect(mockFetch).toHaveBeenCalledWith(
293
+ 'https://api.example.com',
294
+ expect.objectContaining({method: 'GET'}),
295
+ );
296
+ });
297
+
298
+ it('should report fail on 401', async () => {
299
+ mockLoadRepo.mockResolvedValue({
300
+ connections: new Map([['myapi', {
301
+ surface: [{method: 'GET', path: '/test'}],
302
+ access: {},
303
+ spec: {baseUrl: 'https://api.example.com', auth: {type: 'bearer', token: 'env:BAD_KEY'}},
304
+ }]]),
305
+ skills: [],
306
+ automations: [],
307
+ });
308
+ mockFetch.mockResolvedValue({status: 401, redirected: false});
309
+
310
+ const {runValidate} = await import('./validate.js');
311
+ const result = await runValidate({skipTest: false});
312
+ expect(result).toBe(1);
313
+ });
314
+
315
+ it('should use testPath when available', async () => {
316
+ mockLoadRepo.mockResolvedValue({
317
+ connections: new Map([['myapi', {
318
+ surface: [{method: 'GET', path: '/me'}],
319
+ access: {},
320
+ spec: {baseUrl: 'https://api.example.com/v2', testPath: '/me', auth: {type: 'none'}},
321
+ }]]),
322
+ skills: [],
323
+ automations: [],
324
+ });
325
+ mockFetch.mockResolvedValue({status: 200, redirected: false});
326
+
327
+ const {runValidate} = await import('./validate.js');
328
+ const result = await runValidate({skipTest: false});
329
+ expect(result).toBe(0);
330
+ expect(mockFetch).toHaveBeenCalledWith(
331
+ 'https://api.example.com/v2/me',
332
+ expect.objectContaining({method: 'GET'}),
333
+ );
334
+ });
335
+
336
+ it('should report network errors as fail', async () => {
337
+ mockLoadRepo.mockResolvedValue({
338
+ connections: new Map([['myapi', {
339
+ surface: [{method: 'GET', path: '/test'}],
340
+ access: {},
341
+ spec: {baseUrl: 'https://unreachable.example.com', auth: {type: 'none'}},
342
+ }]]),
343
+ skills: [],
344
+ automations: [],
345
+ });
346
+ mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
347
+
348
+ const {runValidate} = await import('./validate.js');
349
+ const result = await runValidate({skipTest: false});
350
+ expect(result).toBe(1);
351
+ });
352
+
353
+ it('should test MCP servers and report pass on connected', async () => {
354
+ mockMcpManager.getServerInfo.mockReturnValue([
355
+ {name: 'xpoz', status: 'connected', tools: ['search', 'get'], error: undefined},
356
+ ]);
357
+
358
+ mockLoadRepo.mockResolvedValue({
359
+ connections: new Map(),
360
+ skills: [],
361
+ automations: [],
362
+ mcpServers: {xpoz: {transport: 'http', url: 'https://mcp.xpoz.ai/mcp'}},
363
+ });
364
+
365
+ const {runValidate} = await import('./validate.js');
366
+ const result = await runValidate({skipTest: false});
367
+ expect(result).toBe(0);
368
+ expect(mockMcpManager.startServers).toHaveBeenCalled();
369
+ expect(mockMcpManager.shutdown).toHaveBeenCalled();
370
+ });
371
+
372
+ it('should report MCP server failure', async () => {
373
+ mockMcpManager.getServerInfo.mockReturnValue([
374
+ {name: 'broken', status: 'error', tools: [], error: 'Connection refused'},
375
+ ]);
376
+
377
+ mockLoadRepo.mockResolvedValue({
378
+ connections: new Map(),
379
+ skills: [],
380
+ automations: [],
381
+ mcpServers: {broken: {transport: 'http', url: 'https://broken.example.com/mcp'}},
382
+ });
383
+
384
+ const {runValidate} = await import('./validate.js');
385
+ const result = await runValidate({skipTest: false});
386
+ expect(result).toBe(1);
387
+ });
388
+
389
+ it('should handle redirect + retry for auth stripping', async () => {
390
+ mockLoadRepo.mockResolvedValue({
391
+ connections: new Map([['myapi', {
392
+ surface: [{method: 'GET', path: '/test'}],
393
+ access: {},
394
+ spec: {baseUrl: 'https://api.example.com/v2', auth: {type: 'bearer', token: 'env:MY_TOKEN'}},
395
+ }]]),
396
+ skills: [],
397
+ automations: [],
398
+ });
399
+ // First call: redirected, lost auth → 401
400
+ // Second call: retry with auth → 200
401
+ mockFetch
402
+ .mockResolvedValueOnce({status: 401, redirected: true, url: 'https://api.example.com/v2/'})
403
+ .mockResolvedValueOnce({status: 200, redirected: false});
404
+
405
+ process.env['MY_TOKEN'] = 'test-token';
406
+ const {runValidate} = await import('./validate.js');
407
+ const result = await runValidate({skipTest: false});
408
+ expect(result).toBe(0);
409
+ expect(mockFetch).toHaveBeenCalledTimes(2);
410
+ delete process.env['MY_TOKEN'];
411
+ });
412
+ });
260
413
  });