chatgpt-local-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,677 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AI PC MCP Connector — Cross-platform launcher
4
+ *
5
+ * Works on Windows, Linux (including GitHub Codespaces), and macOS.
6
+ *
7
+ * What it does:
8
+ * 1. Copies the protected runtime to ~/.ai-pc-mcp
9
+ * 2. npm-installs dependencies there (once)
10
+ * 3. Runs a watchdog that auto-restarts the MCP server on crash
11
+ * 4. GitHub Codespaces → makes port public via `gh` CLI
12
+ * 5. Everywhere else → auto-downloads cloudflared, starts a free tunnel,
13
+ * reads the public URL from cloudflared output
14
+ * 6. Prints the public MCP URL to paste into ChatGPT (No Auth)
15
+ *
16
+ * Usage:
17
+ * npm start (all platforms)
18
+ * node scripts/start.js (direct)
19
+ */
20
+
21
+ import { spawn } from "child_process";
22
+ import { promisify } from "util";
23
+ import { execFile } from "child_process";
24
+ import fs from "fs/promises";
25
+ import fsSync from "fs";
26
+ import path from "path";
27
+ import os from "os";
28
+ import https from "https";
29
+ import http from "http";
30
+ import { fileURLToPath } from "url";
31
+
32
+ const execFileAsync = promisify(execFile);
33
+ const __filename = fileURLToPath(import.meta.url);
34
+ const __dirname = path.dirname(__filename);
35
+
36
+ // ─── constants ───────────────────────────────────────────────────────────────
37
+
38
+ const IS_WINDOWS = process.platform === "win32";
39
+ const IS_MACOS = process.platform === "darwin";
40
+ const WORKSPACE_DIR = path.resolve(__dirname, "..");
41
+ const PORT = Number(process.env.AI_PC_MCP_PORT || 3001);
42
+ const HOST = process.env.AI_PC_MCP_HOST || "0.0.0.0";
43
+ const INSTALL_DIR = process.env.CHATGPT_LOCAL_MCP_HOME || process.env.AI_PC_MCP_HOME || path.join(os.homedir(), ".chatgpt-local-mcp");
44
+ const LOG_DIR = path.join(INSTALL_DIR, "logs");
45
+ const BIN_DIR = path.join(INSTALL_DIR, "bin");
46
+ const SERVER_FILE = path.join(INSTALL_DIR, "src", "server.js");
47
+ const ENV_FILE = path.join(INSTALL_DIR, ".env");
48
+ const SERVER_LOG = path.join(LOG_DIR, "server.log");
49
+ const WATCHDOG_LOG = path.join(LOG_DIR, "watchdog.log");
50
+ const CF_LOG = path.join(LOG_DIR, "cloudflared.log");
51
+
52
+ let shutdownRequested = false;
53
+ let serverChild = null;
54
+
55
+ // ─── colours (Windows 10+ supports ANSI in cmd / PowerShell) ─────────────────
56
+
57
+ const cyan = (s) => `\x1b[1;36m${s}\x1b[0m`;
58
+ const green = (s) => `\x1b[1;32m${s}\x1b[0m`;
59
+ const yellow = (s) => `\x1b[1;33m${s}\x1b[0m`;
60
+ const red = (s) => `\x1b[1;31m${s}\x1b[0m`;
61
+
62
+ // ─── log mode ────────────────────────────────────────────────────────────────
63
+ // Enable with: npm run start:log OR npm start -- --log
64
+
65
+ const LOG_MODE = process.argv.includes("--log") || process.argv.includes("log");
66
+
67
+ // ANSI colour palette
68
+ const C = {
69
+ reset: "\x1b[0m",
70
+ dim: "\x1b[2m",
71
+ bold: "\x1b[1m",
72
+ red: "\x1b[31m",
73
+ yellow: "\x1b[33m",
74
+ green: "\x1b[32m",
75
+ cyan: "\x1b[36m",
76
+ magenta: "\x1b[35m",
77
+ bCyan: "\x1b[1;36m",
78
+ bGreen: "\x1b[1;32m",
79
+ bYellow: "\x1b[1;33m",
80
+ };
81
+
82
+ // Keep legacy aliases used by printHeader / cleanup / etc.
83
+ const DIM = C.dim;
84
+ const RESET = C.reset;
85
+
86
+ // Per-source style: short label + colour
87
+ const SRC_STYLES = {
88
+ SERVER: { label: "SRV", color: C.bCyan },
89
+ TUNNEL: { label: "TUN", color: C.bYellow },
90
+ WATCHDOG: { label: "WDG", color: C.magenta },
91
+ };
92
+ // Keep SRC_COLORS for any legacy references
93
+ const SRC_COLORS = {
94
+ SERVER: C.bCyan, TUNNEL: C.bYellow, WATCHDOG: C.magenta,
95
+ };
96
+
97
+ // ── Cloudflared log parsing ───────────────────────────────────────────────────
98
+ // cloudflared emits: "2026-05-30T10:12:02Z INF message…"
99
+ const CF_LINE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\s+(INF|ERR|WRN|DBG)\s*/;
100
+
101
+ // Lines we silently drop — they're noise or already shown elsewhere
102
+ const CF_FILTER_RE = [
103
+ /^Thank you for trying Cloudflare Tunnel/, // long ToS paragraph
104
+ /^precheck component=/, // per-check detail rows (shown in table above)
105
+ /^precheck complete\s/, // precheck footer
106
+ /^Cannot determine default configuration/, // missing config-file notice
107
+ /^cloudflared will not automatically update/, // Windows update notice
108
+ /^GOOS:/, // build metadata
109
+ /^Version \d{4}/, // build version string
110
+ /^Settings:/, // internal settings dump
111
+ /^Tunnel connection curve preferences/, // TLS crypto detail
112
+ /^ICMP proxy will use/, // repeated ICMP setup (appears 4x)
113
+ /^Generated Connector ID:/, // internal UUID
114
+ ];
115
+
116
+ // Determines display level for a cleaned tunnel message
117
+ const CF_GOOD_RE = /Registered tunnel connection|SUMMARY.*healthy|Your quick Tunnel|trycloudflare\.com/i;
118
+ const CF_WARN_RE = /does not support|certificate|WRN/;
119
+
120
+ function parseCfLine(raw) {
121
+ const m = raw.match(CF_LINE_RE);
122
+ if (!m) return { text: raw, level: "info", skip: false };
123
+ const cfLevel = m[1];
124
+ const text = raw.slice(m[0].length).trimEnd();
125
+ if (CF_FILTER_RE.some((re) => re.test(text))) return { text, level: "info", skip: true };
126
+ let level = "info";
127
+ if (cfLevel === "ERR") level = "error";
128
+ else if (cfLevel === "WRN" || CF_WARN_RE.test(text)) level = "warn";
129
+ else if (CF_GOOD_RE.test(text)) level = "success";
130
+ return { text, level, skip: false };
131
+ }
132
+
133
+ // ── Log rendering ─────────────────────────────────────────────────────────────
134
+ const LEVEL_ICON = { info: "·", warn: "⚠", error: "✗", success: "✓" };
135
+ const LEVEL_COLOR = {
136
+ info: C.reset,
137
+ warn: C.yellow,
138
+ error: C.red,
139
+ success: C.bGreen,
140
+ };
141
+
142
+ // Buffer lines that arrive before the live banner is shown (avoids interleaving)
143
+ const startupLogBuffer = [];
144
+ let logBannerShown = false;
145
+
146
+ function logLine(source, rawText, _level = "info") {
147
+ if (!rawText.trim() || !LOG_MODE) return;
148
+
149
+ let text = rawText;
150
+ let level = _level;
151
+
152
+ if (source === "TUNNEL") {
153
+ const parsed = parseCfLine(rawText);
154
+ if (parsed.skip) return;
155
+ text = parsed.text;
156
+ level = parsed.level;
157
+ }
158
+
159
+ const now = new Date();
160
+ const hms = now.toTimeString().slice(0, 8);
161
+ const ms = String(now.getMilliseconds()).padStart(3, "0");
162
+ const ts = `${hms}.${ms}`;
163
+
164
+ const { label, color } = SRC_STYLES[source] || { label: source.slice(0, 3).toUpperCase(), color: C.reset };
165
+ const icon = LEVEL_ICON[level] || "·";
166
+ const msgColor = LEVEL_COLOR[level] || C.reset;
167
+
168
+ const line =
169
+ `${C.dim}${ts}${C.reset}` +
170
+ ` ${color}[${label}]${C.reset}` +
171
+ ` ${msgColor}${icon} ${text}${C.reset}`;
172
+
173
+ if (logBannerShown) {
174
+ process.stdout.write(line + "\n");
175
+ } else {
176
+ startupLogBuffer.push(line);
177
+ }
178
+ }
179
+
180
+ // Dump buffered startup logs with a clear visual separator
181
+ function flushStartupLogs() {
182
+ if (!LOG_MODE || !startupLogBuffer.length) return;
183
+ const bar = `${C.dim}${"-".repeat(64)}${C.reset}`;
184
+ process.stdout.write(`\n${bar}\n`);
185
+ process.stdout.write(`${C.dim} startup logs (${startupLogBuffer.length} lines captured before tunnel was ready)${C.reset}\n`);
186
+ process.stdout.write(`${bar}\n`);
187
+ for (const line of startupLogBuffer) process.stdout.write(line + "\n");
188
+ startupLogBuffer.length = 0;
189
+ process.stdout.write(`${bar}\n\n`);
190
+ }
191
+
192
+ // ─── helpers ──────────────────────────────────────────────────────────────────
193
+
194
+ function printHeader() {
195
+ // Skip when launched via `chatgpt-local-mcp` CLI — it already showed the scope banner.
196
+ if (process.env.CHATGPT_LOCAL_MCP_CLI) return;
197
+ console.log();
198
+ console.log(cyan("╔══════════════════════════════════════════════════════════════╗"));
199
+ console.log(cyan("║ ChatGPT Local MCP — Folder AI Bridge ║"));
200
+ console.log(cyan("╚══════════════════════════════════════════════════════════════╝"));
201
+ console.log();
202
+ }
203
+
204
+ async function installRuntime() {
205
+ console.log(`📦 Installing protected runtime in: ${INSTALL_DIR}`);
206
+ await fs.mkdir(path.join(INSTALL_DIR, "src"), { recursive: true });
207
+ await fs.mkdir(LOG_DIR, { recursive: true });
208
+ await fs.mkdir(BIN_DIR, { recursive: true });
209
+
210
+ await fs.copyFile(
211
+ path.join(WORKSPACE_DIR, "src", "server.js"),
212
+ SERVER_FILE
213
+ );
214
+ await fs.copyFile(
215
+ path.join(WORKSPACE_DIR, "package.json"),
216
+ path.join(INSTALL_DIR, "package.json")
217
+ );
218
+ }
219
+
220
+ async function npmInstall() {
221
+ const nodeModules = path.join(INSTALL_DIR, "node_modules");
222
+ if (fsSync.existsSync(nodeModules)) {
223
+ console.log("✅ Dependencies already installed.");
224
+ return;
225
+ }
226
+ console.log("📥 Installing npm dependencies in protected runtime...");
227
+ await new Promise((resolve, reject) => {
228
+ // On Windows the npm script is npm.cmd; shell:true covers both platforms.
229
+ const child = spawn("npm", [
230
+ "install", "--omit=dev", "--no-audit", "--no-fund",
231
+ ], {
232
+ cwd: INSTALL_DIR,
233
+ stdio: "inherit",
234
+ shell: true, // needed on Windows so npm.cmd is found
235
+ });
236
+ child.on("close", (code) => {
237
+ if (code === 0) resolve();
238
+ else reject(new Error(`npm install failed (exit ${code})`));
239
+ });
240
+ child.on("error", reject);
241
+ });
242
+ }
243
+
244
+ // ─── env file ─────────────────────────────────────────────────────────────────
245
+ // Use | as path separator — safe on all OS (| is illegal in file paths).
246
+
247
+ async function writeEnvFile(publicUrl) {
248
+ // ACCESS_ROOT: use what cli.js set (folder-scoped cwd or bypass), falling back to drive root.
249
+ const accessRoot = process.env.AI_PC_MCP_ROOT || (IS_WINDOWS
250
+ ? (process.env.SystemDrive || "C:") + "\\"
251
+ : "/");
252
+
253
+ // DEFAULT_CWD: same — use cli.js value if present, otherwise the workspace dir.
254
+ const defaultCwd = process.env.AI_PC_MCP_DEFAULT_CWD || WORKSPACE_DIR;
255
+
256
+ // Build protected paths list, separated by | (safe on Windows and Linux).
257
+ const protectedPaths = [
258
+ INSTALL_DIR,
259
+ SERVER_FILE,
260
+ path.join(WORKSPACE_DIR, "scripts", "start.js"),
261
+ path.join(WORKSPACE_DIR, "package.json"),
262
+ ].join("|");
263
+
264
+ const lines = [
265
+ `AI_PC_MCP_HOME=${INSTALL_DIR}`,
266
+ `AI_PC_MCP_ENV_FILE=${ENV_FILE}`,
267
+ `AI_PC_MCP_PORT=${PORT}`,
268
+ `AI_PC_MCP_HOST=${HOST}`,
269
+ `AI_PC_MCP_ROOT=${accessRoot}`,
270
+ `AI_PC_MCP_DEFAULT_CWD=${defaultCwd}`,
271
+ `AI_PC_MCP_BYPASS=${process.env.AI_PC_MCP_BYPASS || "false"}`,
272
+ `CHATGPT_LOCAL_MCP_HOME=${INSTALL_DIR}`,
273
+ publicUrl ? `AI_PC_MCP_PUBLIC_URL=${publicUrl}` : null,
274
+ `AI_PC_MCP_PROTECTED_PATHS=${protectedPaths}`,
275
+ `AI_PC_MCP_ALLOW_NO_AUTH=true`,
276
+ `AI_PC_MCP_COMMAND_TIMEOUT_MS=${process.env.AI_PC_MCP_COMMAND_TIMEOUT_MS || 30000}`,
277
+ `AI_PC_MCP_MAX_READ_BYTES=${process.env.AI_PC_MCP_MAX_READ_BYTES || 4194304}`,
278
+ `AI_PC_MCP_MAX_OUTPUT_BYTES=${process.env.AI_PC_MCP_MAX_OUTPUT_BYTES || 2097152}`,
279
+ ].filter(Boolean);
280
+
281
+ await fs.writeFile(ENV_FILE, lines.join("\n") + "\n", "utf8");
282
+ }
283
+
284
+ // ─── health check ─────────────────────────────────────────────────────────────
285
+
286
+ function healthCheck() {
287
+ return new Promise((resolve) => {
288
+ const req = http.get(
289
+ { hostname: "127.0.0.1", port: PORT, path: "/health", timeout: 2000 },
290
+ (res) => { res.resume(); resolve(res.statusCode === 200); }
291
+ );
292
+ req.on("error", () => resolve(false));
293
+ req.on("timeout", () => { req.destroy(); resolve(false); });
294
+ });
295
+ }
296
+
297
+ async function waitForServer(maxSeconds = 30) {
298
+ console.log("Waiting for server...");
299
+ for (let i = 0; i < maxSeconds; i++) {
300
+ await new Promise((r) => setTimeout(r, 1000));
301
+ if (await healthCheck()) {
302
+ console.log("Server is healthy.");
303
+ return true;
304
+ }
305
+ }
306
+ return false;
307
+ }
308
+
309
+ // ─── watchdog ─────────────────────────────────────────────────────────────────
310
+
311
+ function launchServer() {
312
+ if (shutdownRequested) return;
313
+
314
+ const logStream = fsSync.createWriteStream(SERVER_LOG, { flags: "a" });
315
+
316
+ serverChild = spawn(process.execPath, [SERVER_FILE], {
317
+ cwd: INSTALL_DIR,
318
+ env: {
319
+ ...process.env,
320
+ AI_PC_MCP_HOME: INSTALL_DIR,
321
+ AI_PC_MCP_ENV_FILE: ENV_FILE,
322
+ AI_PC_MCP_PORT: String(PORT),
323
+ AI_PC_MCP_HOST: HOST,
324
+ AI_PC_MCP_ALLOW_NO_AUTH: "true",
325
+ },
326
+ stdio: ["ignore", "pipe", "pipe"],
327
+ });
328
+
329
+ // Write each chunk to the log file AND, in --log mode, also to the terminal.
330
+ const handleServerChunk = (chunk, level) => {
331
+ logStream.write(chunk);
332
+ if (LOG_MODE) {
333
+ chunk.toString().split(/\r?\n/).forEach((line) => logLine("SERVER", line, level));
334
+ }
335
+ };
336
+ serverChild.stdout.on("data", (chunk) => handleServerChunk(chunk, "info"));
337
+ serverChild.stderr.on("data", (chunk) => handleServerChunk(chunk, "error"));
338
+
339
+ const pid = serverChild.pid;
340
+ const ts = () => new Date().toISOString();
341
+ const wdLog = (msg) => {
342
+ fsSync.appendFileSync(WATCHDOG_LOG, `[${ts()}] ${msg}\n`);
343
+ if (LOG_MODE) logLine("WATCHDOG", msg, "info");
344
+ };
345
+
346
+ wdLog(`started server (pid=${pid})`);
347
+
348
+ serverChild.on("exit", (code, signal) => {
349
+ logStream.end();
350
+ if (shutdownRequested) return;
351
+ wdLog(`server exited (code=${code}, signal=${signal}); restarting in 2s`);
352
+ setTimeout(launchServer, 2000);
353
+ });
354
+
355
+ serverChild.on("error", (err) => {
356
+ wdLog(`server error: ${err.message}`);
357
+ });
358
+ }
359
+
360
+ // ─── cloudflared tunnel ───────────────────────────────────────────────────────
361
+
362
+ function cfBinaryUrl() {
363
+ const arch = process.arch === "arm64" ? "arm64" : "amd64";
364
+ const base = "https://github.com/cloudflare/cloudflared/releases/latest/download";
365
+ if (IS_WINDOWS) return `${base}/cloudflared-windows-${arch}.exe`;
366
+ if (IS_MACOS) return `${base}/cloudflared-darwin-${arch}`;
367
+ return `${base}/cloudflared-linux-${arch}`;
368
+ }
369
+
370
+ function cfBinaryPath() {
371
+ return path.join(BIN_DIR, IS_WINDOWS ? "cloudflared.exe" : "cloudflared");
372
+ }
373
+
374
+ function downloadFile(url, dest, hops = 0) {
375
+ return new Promise((resolve, reject) => {
376
+ if (hops > 12) return reject(new Error("Too many redirects"));
377
+
378
+ const out = fsSync.createWriteStream(dest);
379
+
380
+ const cleanup = (err) => {
381
+ out.close();
382
+ fs.unlink(dest).catch(() => {});
383
+ reject(err);
384
+ };
385
+
386
+ https.get(url, { timeout: 120_000 }, (res) => {
387
+ const { statusCode, headers } = res;
388
+
389
+ if ([301, 302, 307, 308].includes(statusCode) && headers.location) {
390
+ out.close();
391
+ fs.unlink(dest).catch(() => {});
392
+ return downloadFile(headers.location, dest, hops + 1).then(resolve, reject);
393
+ }
394
+
395
+ if (statusCode !== 200) {
396
+ res.resume();
397
+ return cleanup(new Error(`HTTP ${statusCode} for ${url}`));
398
+ }
399
+
400
+ res.pipe(out);
401
+ out.on("finish", () => out.close(resolve));
402
+ out.on("error", cleanup);
403
+ res.on("error", cleanup);
404
+ }).on("error", cleanup);
405
+ });
406
+ }
407
+
408
+ async function ensureCloudflared() {
409
+ const cfPath = cfBinaryPath();
410
+ if (fsSync.existsSync(cfPath)) return cfPath;
411
+
412
+ console.log("📥 Downloading cloudflared tunnel binary...");
413
+ try {
414
+ await downloadFile(cfBinaryUrl(), cfPath);
415
+ if (!IS_WINDOWS) await fs.chmod(cfPath, 0o755);
416
+ console.log(green("✅ cloudflared downloaded."));
417
+ return cfPath;
418
+ } catch (err) {
419
+ console.log(yellow(`⚠️ Could not download cloudflared: ${err.message}`));
420
+ return null;
421
+ }
422
+ }
423
+
424
+ async function startCloudflaredTunnel() {
425
+ const cfPath = await ensureCloudflared();
426
+ if (!cfPath) return null;
427
+
428
+ return new Promise((resolve) => {
429
+ const logStream = fsSync.createWriteStream(CF_LOG, { flags: "a" });
430
+
431
+ const child = spawn(cfPath, ["tunnel", "--url", `http://localhost:${PORT}`], {
432
+ stdio: ["ignore", "pipe", "pipe"],
433
+ // Don't inherit parent env completely; give cloudflared a clean slate.
434
+ env: {
435
+ HOME: os.homedir(),
436
+ USERPROFILE: os.homedir(),
437
+ PATH: process.env.PATH || "",
438
+ ...(IS_WINDOWS ? { SystemRoot: process.env.SystemRoot || "C:\\Windows" } : {}),
439
+ },
440
+ });
441
+
442
+ let done = false;
443
+ const urlRe = /https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/;
444
+
445
+ const finish = (result) => {
446
+ if (done) return;
447
+ done = true;
448
+ resolve(result);
449
+ };
450
+
451
+ const handleData = (chunk) => {
452
+ const text = chunk.toString();
453
+ logStream.write(text);
454
+ // Stream tunnel output to terminal in --log mode.
455
+ if (LOG_MODE) {
456
+ text.split(/\r?\n/).forEach((line) => logLine("TUNNEL", line, "info"));
457
+ }
458
+ if (!done) {
459
+ const m = text.match(urlRe);
460
+ if (m) finish({ url: m[0], process: child });
461
+ }
462
+ };
463
+
464
+ child.stdout.on("data", handleData);
465
+ child.stderr.on("data", handleData);
466
+ child.on("exit", () => finish(null));
467
+ child.on("error", (err) => {
468
+ logStream.write(`cloudflared error: ${err.message}\n`);
469
+ finish(null);
470
+ });
471
+
472
+ // Give cloudflared 40 s to establish and print the URL.
473
+ setTimeout(() => finish(null), 40_000);
474
+ });
475
+ }
476
+
477
+ // ─── Codespaces helpers ───────────────────────────────────────────────────────
478
+
479
+ function getCodespacesUrl() {
480
+ if (process.env.AI_PC_MCP_PUBLIC_URL)
481
+ return process.env.AI_PC_MCP_PUBLIC_URL.replace(/\/$/, "");
482
+
483
+ if (process.env.CODESPACE_NAME && process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN)
484
+ return `https://${process.env.CODESPACE_NAME}-${PORT}.${process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}`;
485
+
486
+ return null;
487
+ }
488
+
489
+ async function makeCodespacesPortPublic() {
490
+ if (!process.env.CODESPACE_NAME) return;
491
+ console.log(`🌐 Making Codespaces port ${PORT} public...`);
492
+ try {
493
+ await execFileAsync(
494
+ "gh",
495
+ ["codespace", "ports", "visibility", `${PORT}:public`, "-c", process.env.CODESPACE_NAME],
496
+ { timeout: 15_000 }
497
+ );
498
+ console.log(green(`✅ Port ${PORT} is public.`));
499
+ } catch {
500
+ console.log(yellow(
501
+ `⚠️ Could not auto-public port ${PORT}. Open VS Code Ports tab → set to Public.`
502
+ ));
503
+ }
504
+ }
505
+
506
+ // ─── connection info ──────────────────────────────────────────────────────────
507
+
508
+ function printConnectionInfo(publicUrl) {
509
+ const mcpUrl = `${publicUrl}/mcp`;
510
+ const bypass = process.env.AI_PC_MCP_BYPASS === "true";
511
+ const scopeLabel = bypass
512
+ ? "Full filesystem (bypass mode)"
513
+ : (process.env.AI_PC_MCP_ROOT || "folder-scoped");
514
+
515
+ // ── separator lines ────────────────────────────────────────────────────────
516
+ const HEAVY = C.bGreen + "═".repeat(64) + C.reset;
517
+ const LIGHT = C.dim + "─".repeat(64) + C.reset;
518
+
519
+ // ── URL box: │ <url> │ (2-space padding each side) ────────────────────
520
+ const urlInner = mcpUrl.length + 4; // 2 left + 2 right padding
521
+ const urlHRule = "─".repeat(urlInner);
522
+ const arrow = `${C.cyan}›${C.reset}`;
523
+
524
+ console.log();
525
+
526
+ // ── "Ready" title ──────────────────────────────────────────────────────────
527
+ console.log(HEAVY);
528
+ console.log(` ${C.bGreen}✓${C.reset} ${C.bold}ChatGPT Local MCP${C.reset} — Ready`);
529
+ console.log(HEAVY);
530
+ console.log();
531
+
532
+ // ── info rows (label column = 9 chars, aligned) ───────────────────────────
533
+ console.log(` ${C.dim}Dashboard${C.reset} ${arrow} ${publicUrl}/`);
534
+ console.log(` ${C.dim}Stats ${C.reset} ${arrow} ${publicUrl}/stats`);
535
+ console.log(` ${C.dim}Scope ${C.reset} ${arrow} ${scopeLabel}`);
536
+ console.log();
537
+
538
+ // ── connect instructions ───────────────────────────────────────────────────
539
+ console.log(LIGHT);
540
+ console.log(` ${C.bold}📋 ChatGPT › Settings › Connectors › Add connector › MCP${C.reset}`);
541
+ console.log(` ${C.dim} Authentication: No Auth${C.reset}`);
542
+ console.log(LIGHT);
543
+ console.log();
544
+
545
+ // ── MCP URL in its own prominent box ─────────────────────────────────────
546
+ console.log(` ${C.bCyan}┌${urlHRule}┐${C.reset}`);
547
+ console.log(` ${C.bCyan}│${C.reset} ${C.bGreen}${mcpUrl}${C.reset} ${C.bCyan}│${C.reset}`);
548
+ console.log(` ${C.bCyan}└${urlHRule}┘${C.reset}`);
549
+ console.log();
550
+
551
+ // ── runtime paths ─────────────────────────────────────────────────────────
552
+ console.log(` ${C.dim}Runtime › ${INSTALL_DIR}${C.reset}`);
553
+ console.log(` ${C.dim}Logs › ${SERVER_LOG}${C.reset}`);
554
+ if (fsSync.existsSync(CF_LOG)) console.log(` ${C.dim} › ${CF_LOG}${C.reset}`);
555
+
556
+ if (!LOG_MODE) {
557
+ console.log();
558
+ console.log(` ${C.dim}💡 Tip: run with --log to stream live tool-call logs.${C.reset}`);
559
+ }
560
+
561
+ console.log();
562
+ console.log(C.dim + "─".repeat(64) + C.reset);
563
+ console.log();
564
+ }
565
+
566
+ // ─── shutdown ─────────────────────────────────────────────────────────────────
567
+
568
+ function cleanup(cfProcess) {
569
+ shutdownRequested = true;
570
+ console.log("\nShutting down…");
571
+ if (serverChild) { try { serverChild.kill(); } catch {} }
572
+ if (cfProcess) { try { cfProcess.kill(); } catch {} }
573
+ process.exit(0);
574
+ }
575
+
576
+ // ─── main ─────────────────────────────────────────────────────────────────────
577
+
578
+ async function main() {
579
+ printHeader();
580
+
581
+ // Node.js version gate
582
+ const [major] = process.version.replace("v", "").split(".").map(Number);
583
+ if (major < 18) {
584
+ console.error(red("❌ Node.js 18 or higher is required. Please upgrade Node.js."));
585
+ process.exit(1);
586
+ }
587
+
588
+ await installRuntime();
589
+ await npmInstall();
590
+
591
+ // Determine public URL before server starts (placeholder if cloudflare needed).
592
+ const codespaceUrl = getCodespacesUrl();
593
+ await writeEnvFile(codespaceUrl || "");
594
+
595
+ // ── Start watchdog ──────────────────────────────────────────────────────────
596
+ console.log("🛡️ Starting watchdog. It restarts the MCP server if it exits.");
597
+ launchServer();
598
+
599
+ // Wait for server to respond to /health.
600
+ const healthy = await waitForServer(30);
601
+ if (!healthy) {
602
+ console.error(red("❌ Server did not become healthy."));
603
+ try {
604
+ const tail = fsSync.readFileSync(SERVER_LOG, "utf8").split("\n").slice(-25).join("\n");
605
+ console.error(tail);
606
+ } catch {}
607
+ cleanup(null);
608
+ }
609
+
610
+ // ── Public URL ──────────────────────────────────────────────────────────────
611
+ let publicUrl = `http://localhost:${PORT}`;
612
+ let cfProcess = null;
613
+
614
+ if (codespaceUrl) {
615
+ // ─── GitHub Codespaces ───────────────────────────────────────────────────
616
+ publicUrl = codespaceUrl;
617
+ await makeCodespacesPortPublic();
618
+ } else if (process.env.AI_PC_MCP_NO_TUNNEL === "true") {
619
+ // ─── No tunnel requested (--no-tunnel flag) ──────────────────────────────
620
+ console.log(yellow("🔌 Tunnel disabled. MCP server is available locally only."));
621
+ console.log(yellow(` Add this to ChatGPT only if it can reach your machine directly.`));
622
+ } else {
623
+ // ─── Local / Windows: use cloudflared free tunnel ────────────────────────
624
+ console.log("🌐 Starting cloudflared tunnel...");
625
+ const result = await startCloudflaredTunnel();
626
+
627
+ if (result && result.url) {
628
+ publicUrl = result.url;
629
+ cfProcess = result.process;
630
+ console.log(green(`✅ Tunnel established: ${publicUrl}`));
631
+ // Update env file so the server knows its public URL.
632
+ await writeEnvFile(publicUrl);
633
+ } else {
634
+ console.log(yellow(`⚠️ cloudflared tunnel unavailable. Using local URL: http://localhost:${PORT}`));
635
+ console.log(yellow(" The MCP server is running locally. Expose it with ngrok or another tunnel."));
636
+ }
637
+ }
638
+
639
+ printConnectionInfo(publicUrl);
640
+
641
+ // ── Live log banner ─────────────────────────────────────────────────────────
642
+ if (LOG_MODE) {
643
+ // Flush anything captured before the tunnel was ready, clearly separated.
644
+ flushStartupLogs();
645
+
646
+ // Now switch to live streaming mode.
647
+ logBannerShown = true;
648
+
649
+ const bar = `${C.bCyan}${"━".repeat(64)}${C.reset}`;
650
+ console.log(bar);
651
+ console.log(`${C.bCyan} 📋 LIVE LOGS${C.reset} ${C.dim}— new entries stream below in real-time (Ctrl+C to stop)${C.reset}`);
652
+ console.log(
653
+ ` ${C.dim}HH:MM:SS.mmm${C.reset}` +
654
+ ` ${SRC_STYLES.SERVER.color}[SRV]${C.reset} server` +
655
+ ` ${SRC_STYLES.TUNNEL.color}[TUN]${C.reset} tunnel` +
656
+ ` ${SRC_STYLES.WATCHDOG.color}[WDG]${C.reset} watchdog` +
657
+ ` ${C.bGreen}✓${C.reset} ok ${C.yellow}⚠${C.reset} warn ${C.red}✗${C.reset} error`
658
+ );
659
+ console.log(bar);
660
+ console.log();
661
+ }
662
+
663
+ // ── Keep alive + graceful shutdown ─────────────────────────────────────────
664
+ const onSignal = () => cleanup(cfProcess);
665
+ process.on("SIGINT", onSignal);
666
+ process.on("SIGTERM", onSignal);
667
+ process.on("SIGHUP", onSignal); // Handles terminal close on Linux/macOS
668
+
669
+ // On Windows, Ctrl+C sends SIGINT — but keep an interval to prevent the
670
+ // event loop from draining while child processes remain alive.
671
+ setInterval(() => {}, 60_000);
672
+ }
673
+
674
+ main().catch((err) => {
675
+ console.error(red(`❌ Fatal error: ${err.message}`));
676
+ process.exit(1);
677
+ });