@getjack/jack 0.1.7 → 0.1.8
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/package.json +5 -2
- package/src/commands/down.ts +20 -3
- package/src/commands/mcp.ts +17 -1
- package/src/lib/agent-files.ts +0 -2
- package/src/lib/build-helper.ts +6 -6
- package/src/lib/config-generator.ts +13 -0
- package/src/lib/config.ts +2 -1
- package/src/lib/mcp-config.ts +2 -1
- package/src/lib/output.ts +21 -1
- package/src/lib/project-detection.ts +19 -0
- package/src/lib/project-operations.ts +20 -18
- package/src/lib/project-resolver.ts +4 -1
- package/src/lib/secrets.ts +1 -2
- package/src/lib/telemetry-config.ts +3 -3
- package/src/mcp/test-utils.ts +112 -0
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getjack/jack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Ship before you forget why you started. The vibecoder's deployment CLI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"jack": "./src/index.ts"
|
|
9
9
|
},
|
|
10
|
-
"files": [
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"templates"
|
|
13
|
+
],
|
|
11
14
|
"engines": {
|
|
12
15
|
"bun": ">=1.0.0"
|
|
13
16
|
},
|
package/src/commands/down.ts
CHANGED
|
@@ -62,6 +62,7 @@ export interface DownFlags {
|
|
|
62
62
|
export default async function down(projectName?: string, flags: DownFlags = {}): Promise<void> {
|
|
63
63
|
try {
|
|
64
64
|
// Get project name
|
|
65
|
+
const hasExplicitName = Boolean(projectName);
|
|
65
66
|
let name = projectName;
|
|
66
67
|
if (!name) {
|
|
67
68
|
try {
|
|
@@ -74,10 +75,12 @@ export default async function down(projectName?: string, flags: DownFlags = {}):
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
// Resolve project from all sources (local link + control plane)
|
|
77
|
-
const resolved = await resolveProject(name
|
|
78
|
+
const resolved = await resolveProject(name, {
|
|
79
|
+
preferLocalLink: !hasExplicitName,
|
|
80
|
+
});
|
|
78
81
|
|
|
79
|
-
// Read local project link
|
|
80
|
-
const link = await readProjectLink(process.cwd());
|
|
82
|
+
// Read local project link (only when no explicit name provided)
|
|
83
|
+
const link = hasExplicitName ? null : await readProjectLink(process.cwd());
|
|
81
84
|
|
|
82
85
|
// Check if found only on control plane (orphaned managed project)
|
|
83
86
|
if (resolved?.sources.controlPlane && !resolved.sources.filesystem) {
|
|
@@ -85,6 +88,20 @@ export default async function down(projectName?: string, flags: DownFlags = {}):
|
|
|
85
88
|
info(`Found "${name}" on jack cloud, linking locally...`);
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
|
|
92
|
+
// Guard against mismatched resolutions when an explicit name is provided
|
|
93
|
+
if (hasExplicitName && resolved) {
|
|
94
|
+
const matches =
|
|
95
|
+
name === resolved.slug ||
|
|
96
|
+
name === resolved.name ||
|
|
97
|
+
name === resolved.remote?.projectId;
|
|
98
|
+
if (!matches) {
|
|
99
|
+
error(`Refusing to undeploy '${name}' because it resolves to '${resolved.slug}'.`);
|
|
100
|
+
info("Use the exact slug/name shown by 'jack info' and try again.");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
88
105
|
if (!resolved && !link) {
|
|
89
106
|
// Not found anywhere
|
|
90
107
|
warn(`Project '${name}' not found`);
|
package/src/commands/mcp.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { rm, mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
2
6
|
import { error, info, success } from "../lib/output.ts";
|
|
3
7
|
import { startMcpServer } from "../mcp/server.ts";
|
|
4
8
|
|
|
9
|
+
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
10
|
+
|
|
5
11
|
interface McpOptions {
|
|
6
12
|
project?: string;
|
|
7
13
|
debug?: boolean;
|
|
@@ -32,10 +38,19 @@ export default async function mcp(subcommand?: string, options: McpOptions = {})
|
|
|
32
38
|
* Test MCP server by spawning it and sending test requests
|
|
33
39
|
*/
|
|
34
40
|
async function testMcpServer(): Promise<void> {
|
|
41
|
+
const configDir = await mkdtemp(join(tmpdir(), "jack-config-"));
|
|
42
|
+
|
|
35
43
|
info("Testing MCP server...\n");
|
|
36
44
|
|
|
37
|
-
const proc = spawn("
|
|
45
|
+
const proc = spawn("bun", ["run", "src/index.ts", "mcp", "serve"], {
|
|
38
46
|
stdio: ["pipe", "pipe", "pipe"],
|
|
47
|
+
cwd: cliRoot,
|
|
48
|
+
env: {
|
|
49
|
+
...process.env,
|
|
50
|
+
CI: "1",
|
|
51
|
+
JACK_TELEMETRY_DISABLED: "1",
|
|
52
|
+
JACK_CONFIG_DIR: configDir,
|
|
53
|
+
},
|
|
39
54
|
});
|
|
40
55
|
|
|
41
56
|
const results: { test: string; passed: boolean; error?: string }[] = [];
|
|
@@ -126,6 +141,7 @@ async function testMcpServer(): Promise<void> {
|
|
|
126
141
|
error(` ✗ Error: ${errorMsg}`);
|
|
127
142
|
} finally {
|
|
128
143
|
proc.kill();
|
|
144
|
+
await rm(configDir, { recursive: true, force: true });
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
// Summary
|
package/src/lib/agent-files.ts
CHANGED
|
@@ -21,7 +21,6 @@ This project is deployed to Cloudflare Workers using jack:
|
|
|
21
21
|
\`\`\`bash
|
|
22
22
|
jack ship # Deploy to Cloudflare Workers
|
|
23
23
|
jack logs # Stream production logs
|
|
24
|
-
jack dev # Start local development server
|
|
25
24
|
\`\`\`
|
|
26
25
|
|
|
27
26
|
All deployment is handled by jack. Never run \`wrangler\` commands directly.
|
|
@@ -42,7 +41,6 @@ See [AGENTS.md](./AGENTS.md) for complete project context and deployment instruc
|
|
|
42
41
|
|
|
43
42
|
- **Deploy**: \`jack ship\` - Deploy to Cloudflare Workers
|
|
44
43
|
- **Logs**: \`jack logs\` - Stream production logs
|
|
45
|
-
- **Dev**: \`jack dev\` - Start local development server
|
|
46
44
|
|
|
47
45
|
## Important
|
|
48
46
|
|
package/src/lib/build-helper.ts
CHANGED
|
@@ -172,19 +172,19 @@ export async function buildProject(options: BuildOptions): Promise<BuildOutput>
|
|
|
172
172
|
// Check if OpenNext build is needed (Next.js + Cloudflare)
|
|
173
173
|
const hasOpenNext = await needsOpenNextBuild(projectPath);
|
|
174
174
|
if (hasOpenNext) {
|
|
175
|
-
reporter?.start("Building...");
|
|
175
|
+
reporter?.start("Building assets...");
|
|
176
176
|
await runOpenNextBuild(projectPath);
|
|
177
177
|
reporter?.stop();
|
|
178
|
-
reporter?.success("Built");
|
|
178
|
+
reporter?.success("Built assets");
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
// Check if Vite build is needed and run it (skip if OpenNext already built)
|
|
182
182
|
const hasVite = await needsViteBuild(projectPath);
|
|
183
183
|
if (hasVite && !hasOpenNext) {
|
|
184
|
-
reporter?.start("Building...");
|
|
184
|
+
reporter?.start("Building assets...");
|
|
185
185
|
await runViteBuild(projectPath);
|
|
186
186
|
reporter?.stop();
|
|
187
|
-
reporter?.success("Built");
|
|
187
|
+
reporter?.success("Built assets");
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
// Create unique temp directory for build output
|
|
@@ -193,7 +193,7 @@ export async function buildProject(options: BuildOptions): Promise<BuildOutput>
|
|
|
193
193
|
await mkdir(outDir, { recursive: true });
|
|
194
194
|
|
|
195
195
|
// Run wrangler dry-run to build without deploying
|
|
196
|
-
reporter?.start("
|
|
196
|
+
reporter?.start("Bundling runtime...");
|
|
197
197
|
|
|
198
198
|
const dryRunResult = await $`wrangler deploy --dry-run --outdir=${outDir}`
|
|
199
199
|
.cwd(projectPath)
|
|
@@ -215,7 +215,7 @@ export async function buildProject(options: BuildOptions): Promise<BuildOutput>
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
reporter?.stop();
|
|
218
|
-
reporter?.success("
|
|
218
|
+
reporter?.success("Bundled runtime");
|
|
219
219
|
|
|
220
220
|
const entrypoint = await resolveEntrypoint(outDir, config.main);
|
|
221
221
|
|
|
@@ -33,6 +33,19 @@ export function generateWranglerConfig(
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
case "vite":
|
|
36
|
+
// Check if this is a Vite + Worker hybrid (has entryPoint)
|
|
37
|
+
if (entryPoint) {
|
|
38
|
+
// Hybrid mode: Vite frontend + custom Worker backend
|
|
39
|
+
return {
|
|
40
|
+
name: projectName,
|
|
41
|
+
main: entryPoint,
|
|
42
|
+
compatibility_date: COMPATIBILITY_DATE,
|
|
43
|
+
assets: {
|
|
44
|
+
directory: "./dist",
|
|
45
|
+
binding: "ASSETS",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
36
49
|
// Pure Vite SPAs use assets-only mode (no worker entry)
|
|
37
50
|
// Cloudflare auto-generates a worker that serves static files
|
|
38
51
|
return {
|
package/src/lib/config.ts
CHANGED
|
@@ -40,7 +40,8 @@ export interface JackConfig {
|
|
|
40
40
|
sync?: SyncConfig;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
const DEFAULT_CONFIG_DIR = join(homedir(), ".config", "jack");
|
|
44
|
+
export const CONFIG_DIR = process.env.JACK_CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
|
|
44
45
|
export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
45
46
|
|
|
46
47
|
/**
|
package/src/lib/mcp-config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir } from "node:fs/promises";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
+
import { CONFIG_DIR } from "./config.ts";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* MCP server configuration structure
|
|
@@ -44,7 +45,7 @@ export const APP_MCP_CONFIGS: Record<string, AppMcpConfig> = {
|
|
|
44
45
|
/**
|
|
45
46
|
* Jack MCP configuration storage path
|
|
46
47
|
*/
|
|
47
|
-
const JACK_MCP_CONFIG_DIR = join(
|
|
48
|
+
const JACK_MCP_CONFIG_DIR = join(CONFIG_DIR, "mcp");
|
|
48
49
|
const JACK_MCP_CONFIG_PATH = join(JACK_MCP_CONFIG_DIR, "config.json");
|
|
49
50
|
|
|
50
51
|
/**
|
package/src/lib/output.ts
CHANGED
|
@@ -32,7 +32,27 @@ export const output = {
|
|
|
32
32
|
* Create a spinner for long-running operations
|
|
33
33
|
*/
|
|
34
34
|
export function spinner(text: string) {
|
|
35
|
-
|
|
35
|
+
const spin = yoctoSpinner({ text }).start();
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
success(message: string) {
|
|
39
|
+
spin.stop();
|
|
40
|
+
success(message);
|
|
41
|
+
},
|
|
42
|
+
error(message: string) {
|
|
43
|
+
spin.stop();
|
|
44
|
+
error(message);
|
|
45
|
+
},
|
|
46
|
+
stop() {
|
|
47
|
+
spin.stop();
|
|
48
|
+
},
|
|
49
|
+
get text() {
|
|
50
|
+
return spin.text;
|
|
51
|
+
},
|
|
52
|
+
set text(value: string | undefined) {
|
|
53
|
+
spin.text = value ?? "";
|
|
54
|
+
},
|
|
55
|
+
};
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
/**
|
|
@@ -297,9 +297,28 @@ export function detectProjectType(projectPath: string): DetectionResult {
|
|
|
297
297
|
if (viteConfig && hasDep(pkg, "vite")) {
|
|
298
298
|
configFiles.push(viteConfig);
|
|
299
299
|
detectedDeps.push("vite");
|
|
300
|
+
|
|
301
|
+
// Check for Vite + Worker hybrid pattern
|
|
302
|
+
// This is indicated by @cloudflare/vite-plugin AND a worker entry file
|
|
303
|
+
const hasCloudflarePlugin = hasDep(pkg, "@cloudflare/vite-plugin");
|
|
304
|
+
let workerEntry: string | null = null;
|
|
305
|
+
|
|
306
|
+
if (hasCloudflarePlugin) {
|
|
307
|
+
detectedDeps.push("@cloudflare/vite-plugin");
|
|
308
|
+
// Check common worker entry locations
|
|
309
|
+
const workerCandidates = ["src/worker.ts", "src/worker.js", "worker.ts", "worker.js"];
|
|
310
|
+
for (const candidate of workerCandidates) {
|
|
311
|
+
if (existsSync(join(projectPath, candidate))) {
|
|
312
|
+
workerEntry = candidate;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
300
318
|
return {
|
|
301
319
|
type: "vite",
|
|
302
320
|
configFile: viteConfig,
|
|
321
|
+
entryPoint: workerEntry || undefined,
|
|
303
322
|
detectedDeps,
|
|
304
323
|
configFiles,
|
|
305
324
|
};
|
|
@@ -1047,22 +1047,22 @@ export async function createProject(
|
|
|
1047
1047
|
|
|
1048
1048
|
// Build first if needed (wrangler needs built assets)
|
|
1049
1049
|
if (await needsOpenNextBuild(targetDir)) {
|
|
1050
|
-
reporter.start("Building...");
|
|
1050
|
+
reporter.start("Building assets...");
|
|
1051
1051
|
try {
|
|
1052
1052
|
await runOpenNextBuild(targetDir);
|
|
1053
1053
|
reporter.stop();
|
|
1054
|
-
reporter.success("Built");
|
|
1054
|
+
reporter.success("Built assets");
|
|
1055
1055
|
} catch (err) {
|
|
1056
1056
|
reporter.stop();
|
|
1057
1057
|
reporter.error("Build failed");
|
|
1058
1058
|
throw err;
|
|
1059
1059
|
}
|
|
1060
1060
|
} else if (await needsViteBuild(targetDir)) {
|
|
1061
|
-
reporter.start("Building...");
|
|
1061
|
+
reporter.start("Building assets...");
|
|
1062
1062
|
try {
|
|
1063
1063
|
await runViteBuild(targetDir);
|
|
1064
1064
|
reporter.stop();
|
|
1065
|
-
reporter.success("Built");
|
|
1065
|
+
reporter.success("Built assets");
|
|
1066
1066
|
} catch (err) {
|
|
1067
1067
|
reporter.stop();
|
|
1068
1068
|
reporter.error("Build failed");
|
|
@@ -1237,9 +1237,11 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
|
|
|
1237
1237
|
}
|
|
1238
1238
|
|
|
1239
1239
|
// Validate mode availability
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1240
|
+
if (!dryRun) {
|
|
1241
|
+
const modeError = await validateModeAvailability(deployMode);
|
|
1242
|
+
if (modeError) {
|
|
1243
|
+
throw new JackError(JackErrorCode.VALIDATION_ERROR, modeError);
|
|
1244
|
+
}
|
|
1243
1245
|
}
|
|
1244
1246
|
|
|
1245
1247
|
let workerUrl: string | null = null;
|
|
@@ -1264,19 +1266,19 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
|
|
|
1264
1266
|
// (deployToManagedProject handles its own build, so only build here for dry-run)
|
|
1265
1267
|
if (dryRun) {
|
|
1266
1268
|
if (await needsOpenNextBuild(projectPath)) {
|
|
1267
|
-
const buildSpin = reporter.spinner("Building...");
|
|
1269
|
+
const buildSpin = reporter.spinner("Building assets...");
|
|
1268
1270
|
try {
|
|
1269
1271
|
await runOpenNextBuild(projectPath);
|
|
1270
|
-
buildSpin.success("Built");
|
|
1272
|
+
buildSpin.success("Built assets");
|
|
1271
1273
|
} catch (err) {
|
|
1272
1274
|
buildSpin.error("Build failed");
|
|
1273
1275
|
throw err;
|
|
1274
1276
|
}
|
|
1275
1277
|
} else if (await needsViteBuild(projectPath)) {
|
|
1276
|
-
const buildSpin = reporter.spinner("Building...");
|
|
1278
|
+
const buildSpin = reporter.spinner("Building assets...");
|
|
1277
1279
|
try {
|
|
1278
1280
|
await runViteBuild(projectPath);
|
|
1279
|
-
buildSpin.success("Built");
|
|
1281
|
+
buildSpin.success("Built assets");
|
|
1280
1282
|
} catch (err) {
|
|
1281
1283
|
buildSpin.error("Build failed");
|
|
1282
1284
|
throw err;
|
|
@@ -1302,19 +1304,19 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
|
|
|
1302
1304
|
|
|
1303
1305
|
// Build first if needed (wrangler needs built assets)
|
|
1304
1306
|
if (await needsOpenNextBuild(projectPath)) {
|
|
1305
|
-
const buildSpin = reporter.spinner("Building...");
|
|
1307
|
+
const buildSpin = reporter.spinner("Building assets...");
|
|
1306
1308
|
try {
|
|
1307
1309
|
await runOpenNextBuild(projectPath);
|
|
1308
|
-
buildSpin.success("Built");
|
|
1310
|
+
buildSpin.success("Built assets");
|
|
1309
1311
|
} catch (err) {
|
|
1310
1312
|
buildSpin.error("Build failed");
|
|
1311
1313
|
throw err;
|
|
1312
1314
|
}
|
|
1313
1315
|
} else if (await needsViteBuild(projectPath)) {
|
|
1314
|
-
const buildSpin = reporter.spinner("Building...");
|
|
1316
|
+
const buildSpin = reporter.spinner("Building assets...");
|
|
1315
1317
|
try {
|
|
1316
1318
|
await runViteBuild(projectPath);
|
|
1317
|
-
buildSpin.success("Built");
|
|
1319
|
+
buildSpin.success("Built assets");
|
|
1318
1320
|
} catch (err) {
|
|
1319
1321
|
buildSpin.error("Build failed");
|
|
1320
1322
|
throw err;
|
|
@@ -1396,16 +1398,16 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
|
|
|
1396
1398
|
}
|
|
1397
1399
|
}
|
|
1398
1400
|
|
|
1399
|
-
if (includeSync) {
|
|
1401
|
+
if (includeSync && deployMode !== "byo") {
|
|
1400
1402
|
const syncConfig = await getSyncConfig();
|
|
1401
1403
|
if (syncConfig.enabled && syncConfig.autoSync) {
|
|
1402
|
-
const syncSpin = reporter.spinner("Syncing source to
|
|
1404
|
+
const syncSpin = reporter.spinner("Syncing source to jack storage...");
|
|
1403
1405
|
try {
|
|
1404
1406
|
const syncResult = await syncToCloud(projectPath);
|
|
1405
1407
|
if (syncResult.success) {
|
|
1406
1408
|
if (syncResult.filesUploaded > 0 || syncResult.filesDeleted > 0) {
|
|
1407
1409
|
syncSpin.success(
|
|
1408
|
-
`
|
|
1410
|
+
`Synced source to jack storage (${syncResult.filesUploaded} uploaded, ${syncResult.filesDeleted} removed)`,
|
|
1409
1411
|
);
|
|
1410
1412
|
} else {
|
|
1411
1413
|
syncSpin.success("Source already synced");
|
|
@@ -179,6 +179,8 @@ export interface ResolveProjectOptions {
|
|
|
179
179
|
projectPath?: string;
|
|
180
180
|
/** Allow fallback lookup by managed project name when slug lookup fails */
|
|
181
181
|
matchByName?: boolean;
|
|
182
|
+
/** Prefer local .jack/project.json when resolving (default true) */
|
|
183
|
+
preferLocalLink?: boolean;
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
/**
|
|
@@ -225,9 +227,10 @@ export async function resolveProject(
|
|
|
225
227
|
let resolved: ResolvedProject | null = null;
|
|
226
228
|
const matchByName = options?.matchByName !== false;
|
|
227
229
|
const projectPath = options?.projectPath || process.cwd();
|
|
230
|
+
const preferLocalLink = options?.preferLocalLink ?? true;
|
|
228
231
|
|
|
229
232
|
// First, check if we're resolving from a local path with .jack/project.json
|
|
230
|
-
const link = await readProjectLink(projectPath);
|
|
233
|
+
const link = preferLocalLink ? await readProjectLink(projectPath) : null;
|
|
231
234
|
|
|
232
235
|
if (link) {
|
|
233
236
|
// We have a local link - start with that
|
package/src/lib/secrets.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { chmod, mkdir, stat } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
4
|
+
import { CONFIG_DIR } from "./config.ts";
|
|
5
5
|
|
|
6
6
|
export interface SecretEntry {
|
|
7
7
|
value: string;
|
|
@@ -14,7 +14,6 @@ export interface SecretsFile {
|
|
|
14
14
|
secrets: Record<string, SecretEntry>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const CONFIG_DIR = join(homedir(), ".config", "jack");
|
|
18
17
|
const SECRETS_PATH = join(CONFIG_DIR, "secrets.json");
|
|
19
18
|
|
|
20
19
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
4
|
+
import { CONFIG_DIR } from "./config.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Telemetry configuration structure
|
|
@@ -12,8 +12,8 @@ export interface TelemetryConfig {
|
|
|
12
12
|
version: number; // config schema version (start at 1)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export const TELEMETRY_CONFIG_DIR =
|
|
16
|
-
export const TELEMETRY_CONFIG_PATH = join(
|
|
15
|
+
export const TELEMETRY_CONFIG_DIR = CONFIG_DIR;
|
|
16
|
+
export const TELEMETRY_CONFIG_PATH = join(CONFIG_DIR, "telemetry.json");
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Cached telemetry config for memoization
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
|
|
4
|
+
export interface McpClientOptions {
|
|
5
|
+
command: string;
|
|
6
|
+
args?: string[];
|
|
7
|
+
cwd?: string;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
clientName?: string;
|
|
10
|
+
clientVersion?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface McpTestClient {
|
|
14
|
+
client: Client;
|
|
15
|
+
transport: StdioClientTransport;
|
|
16
|
+
getStderr(): string;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function openMcpTestClient(options: McpClientOptions): Promise<McpTestClient> {
|
|
21
|
+
const transport = new StdioClientTransport({
|
|
22
|
+
command: options.command,
|
|
23
|
+
args: options.args ?? [],
|
|
24
|
+
cwd: options.cwd,
|
|
25
|
+
env: options.env,
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let stderr = "";
|
|
30
|
+
transport.stderr?.on("data", (chunk) => {
|
|
31
|
+
stderr += chunk.toString();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const client = new Client({
|
|
35
|
+
name: options.clientName ?? "jack-mcp-test",
|
|
36
|
+
version: options.clientVersion ?? "0.1.0",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await client.connect(transport);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
await transport.close();
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
client,
|
|
48
|
+
transport,
|
|
49
|
+
getStderr: () => stderr,
|
|
50
|
+
close: () => transport.close(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseMcpToolResult(toolResult: {
|
|
55
|
+
content?: Array<{ type: string; text?: string }>;
|
|
56
|
+
}): unknown {
|
|
57
|
+
const toolText = toolResult.content?.[0]?.type === "text" ? toolResult.content[0].text : null;
|
|
58
|
+
if (!toolText) {
|
|
59
|
+
throw new Error("MCP tool response missing text content");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parsed = JSON.parse(toolText);
|
|
63
|
+
if (!parsed.success) {
|
|
64
|
+
const message = parsed.error?.message ?? "unknown error";
|
|
65
|
+
throw new Error(`MCP tool failed: ${message}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return parsed.data;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function verifyMcpToolsAndResources(client: Client): Promise<void> {
|
|
72
|
+
const tools = await client.listTools();
|
|
73
|
+
if (!tools.tools?.length) {
|
|
74
|
+
throw new Error("MCP server reported no tools");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resources = await client.listResources();
|
|
78
|
+
if (!resources.resources?.length) {
|
|
79
|
+
throw new Error("MCP server reported no resources");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await client.readResource({ uri: "agents://context" });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function callMcpListProjects(
|
|
86
|
+
client: Client,
|
|
87
|
+
filter?: "all" | "local" | "deployed" | "cloud",
|
|
88
|
+
): Promise<unknown[]> {
|
|
89
|
+
const response = await client.callTool({
|
|
90
|
+
name: "list_projects",
|
|
91
|
+
arguments: filter ? { filter } : {},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const data = parseMcpToolResult(response);
|
|
95
|
+
if (!Array.isArray(data)) {
|
|
96
|
+
throw new Error("MCP list_projects returned unexpected data");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function callMcpGetProjectStatus(
|
|
103
|
+
client: Client,
|
|
104
|
+
args: { name?: string; project_path?: string },
|
|
105
|
+
): Promise<unknown> {
|
|
106
|
+
const response = await client.callTool({
|
|
107
|
+
name: "get_project_status",
|
|
108
|
+
arguments: args,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return parseMcpToolResult(response);
|
|
112
|
+
}
|