@zoer7788/mcp-nexus-node 0.1.0 → 0.1.3

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 (3) hide show
  1. package/README.md +3 -3
  2. package/cli.js +111 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,7 +14,6 @@ On `pub-118`:
14
14
 
15
15
  ```bash
16
16
  npm install -g @zoer7788/mcp-nexus-node
17
- MCP_NEXUS_ADMIN_URL=https://mcpnexus-pub118.omji.top \
18
17
  mcp-nexus pair
19
18
  ```
20
19
 
@@ -31,5 +30,6 @@ mcp-nexus serve
31
30
  mcp-nexus status
32
31
  ```
33
32
 
34
- DevSpace is installed as a package dependency. `cloudflared` must be available on
35
- the remote machine before creating public DevSpace projects.
33
+ DevSpace is installed as a package dependency. `mcp-nexus pair` auto-downloads
34
+ `cloudflared` on Linux x64/arm64 when missing. If Cloudflare is not authorized
35
+ yet, `pair` starts the login flow first.
package/cli.js CHANGED
@@ -14,6 +14,7 @@ const configPath = join(stateDir, "config.json");
14
14
  const projectsPath = join(stateDir, "projects.json");
15
15
  const tokensPath = join(stateDir, "tokens.json");
16
16
  const logDir = join(stateDir, "logs");
17
+ const binDir = join(stateDir, "bin");
17
18
  const nodeIdPath = join(stateDir, "node-id");
18
19
  const packageDir = dirname(fileURLToPath(import.meta.url));
19
20
  const require = createRequire(import.meta.url);
@@ -21,6 +22,7 @@ const require = createRequire(import.meta.url);
21
22
  function ensureState() {
22
23
  mkdirSync(stateDir, { recursive: true });
23
24
  mkdirSync(logDir, { recursive: true });
25
+ mkdirSync(binDir, { recursive: true });
24
26
  if (!existsSync(projectsPath)) saveJson(projectsPath, []);
25
27
  if (!existsSync(tokensPath)) saveJson(tokensPath, { adminToken: randomHex(32) });
26
28
  if (!existsSync(configPath)) saveJson(configPath, {
@@ -63,6 +65,11 @@ function config() {
63
65
  };
64
66
  }
65
67
 
68
+ function saveConfigPatch(patch) {
69
+ ensureState();
70
+ saveJson(configPath, { ...loadJson(configPath, {}), ...patch });
71
+ }
72
+
66
73
  function tokens() {
67
74
  ensureState();
68
75
  return loadJson(tokensPath, {});
@@ -84,7 +91,11 @@ function run(cmd, args, options = {}) {
84
91
  }
85
92
 
86
93
  function commandExists(cmd) {
87
- return spawnSync("sh", ["-lc", `command -v ${quote(cmd)}`], { encoding: "utf8" }).status === 0;
94
+ return Boolean(commandPath(cmd));
95
+ }
96
+
97
+ function commandPath(cmd) {
98
+ return spawnSync("sh", ["-lc", `command -v ${quote(cmd)}`], { encoding: "utf8" }).stdout.trim();
88
99
  }
89
100
 
90
101
  function quote(value) {
@@ -113,6 +124,43 @@ function adminUrl() {
113
124
  return cfg.adminUrl || `http://${localIp()}:${cfg.port || 9876}`;
114
125
  }
115
126
 
127
+ function defaultControlHost() {
128
+ const cfg = config();
129
+ const id = readFileSync(nodeIdPath, "utf8").trim().slice(0, 8);
130
+ return `mcpnexus-${sanitize(hostname()).slice(0, 24)}-${id}.${cfg.baseDomain || "omji.top"}`;
131
+ }
132
+
133
+ function findCloudflareTunnel(name) {
134
+ const list = spawnSync(cloudflaredCommand(), ["tunnel", "list"], { encoding: "utf8" }).stdout || "";
135
+ return list.split("\n").map((line) => line.trim().split(/\s+/)).find((cols) => cols[1] === name)?.[0] || "";
136
+ }
137
+
138
+ function cloudflaredDownloadUrl() {
139
+ if (process.platform === "linux" && process.arch === "x64") return "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64";
140
+ if (process.platform === "linux" && process.arch === "arm64") return "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64";
141
+ throw new Error(`cloudflared auto-install is only supported on Linux x64/arm64, got ${process.platform}/${process.arch}`);
142
+ }
143
+
144
+ function cloudflaredCommand() {
145
+ const installed = commandPath("cloudflared");
146
+ if (installed) return installed;
147
+ const local = join(binDir, "cloudflared");
148
+ if (existsSync(local)) return local;
149
+ ensureState();
150
+ console.log("Installing cloudflared...");
151
+ run("sh", ["-lc", `curl -L --fail -o ${quote(local)} ${quote(cloudflaredDownloadUrl())} && chmod +x ${quote(local)}`]);
152
+ return local;
153
+ }
154
+
155
+ function ensureCloudflareAuth() {
156
+ const certFile = join(home, ".cloudflared", "cert.pem");
157
+ if (existsSync(certFile)) return;
158
+ console.log("Cloudflare authorization required. Opening login flow...");
159
+ const out = spawnSync(cloudflaredCommand(), ["tunnel", "login"], { stdio: "inherit" });
160
+ if (out.status !== 0) throw new Error("cloudflared tunnel login failed");
161
+ if (!existsSync(certFile)) throw new Error(`cloudflared login did not create ${certFile}`);
162
+ }
163
+
116
164
  function json(res, status, body) {
117
165
  res.writeHead(status, {
118
166
  "content-type": "application/json; charset=utf-8",
@@ -152,7 +200,8 @@ function devspaceCommand() {
152
200
 
153
201
  function ensureTooling() {
154
202
  devspaceCommand();
155
- if (!commandExists("cloudflared")) throw new Error("cloudflared command not found");
203
+ cloudflaredCommand();
204
+ ensureCloudflareAuth();
156
205
  }
157
206
 
158
207
  function projectStatus(project) {
@@ -168,7 +217,10 @@ function setupProject(input) {
168
217
  const name = String(input.name || "").trim();
169
218
  const root = String(input.root || input.path || "").trim();
170
219
  if (!name || !root) throw new Error("name and root are required");
171
- if (!existsSync(root)) throw new Error(`root not found: ${root}`);
220
+ if (!existsSync(root)) {
221
+ if (input.createRoot) mkdirSync(root, { recursive: true });
222
+ else throw new Error(`root not found: ${root}`);
223
+ }
172
224
  const items = projects();
173
225
  if (items.some((p) => p.name === name)) throw new Error(`project already exists: ${name}`);
174
226
 
@@ -182,16 +234,15 @@ function setupProject(input) {
182
234
  const tunnelName = `devspace-${subdomain}`;
183
235
 
184
236
  let tunnelId = "";
185
- const list = spawnSync("cloudflared", ["tunnel", "list"], { encoding: "utf8" }).stdout || "";
186
- const existing = list.split("\n").map((line) => line.trim().split(/\s+/)).find((cols) => cols[1] === tunnelName);
187
- if (existing) tunnelId = existing[0];
237
+ const existing = findCloudflareTunnel(tunnelName);
238
+ if (existing) tunnelId = existing;
188
239
  else {
189
- const created = run("cloudflared", ["tunnel", "create", tunnelName]);
240
+ const created = run(cloudflaredCommand(), ["tunnel", "create", tunnelName]);
190
241
  tunnelId = (created.match(/[0-9a-f-]{36}/) || [])[0] || "";
191
242
  if (!tunnelId) throw new Error(`could not read tunnel id: ${created}`);
192
243
  }
193
244
 
194
- run("cloudflared", ["tunnel", "route", "dns", tunnelName, hostnameValue]);
245
+ run(cloudflaredCommand(), ["tunnel", "route", "dns", tunnelName, hostnameValue]);
195
246
  const cfDir = join(home, ".cloudflared");
196
247
  const cfConfig = join(cfDir, `${subdomain}-config.yml`);
197
248
  const credFile = join(cfDir, `${tunnelId}.json`);
@@ -243,10 +294,57 @@ function setupProject(input) {
243
294
  };
244
295
  items.push(project);
245
296
  saveProjects(items);
246
- startProject(name);
297
+ if (input.autostart !== false) startProject(name);
247
298
  return projectStatus(findProject(name));
248
299
  }
249
300
 
301
+ function setupControl(hostnameValue) {
302
+ ensureState();
303
+ const cfDir = join(home, ".cloudflared");
304
+ ensureCloudflareAuth();
305
+ const host = String(hostnameValue || "").trim().toLowerCase();
306
+ if (!/^[a-z0-9.-]+$/.test(host) || !host.includes(".")) throw new Error("usage: mcp-nexus setup-control <hostname>");
307
+
308
+ const cfg = config();
309
+ const tunnelName = `mcp-nexus-${sanitize(host)}-control`;
310
+ let tunnelId = findCloudflareTunnel(tunnelName);
311
+ if (!tunnelId) {
312
+ const created = run(cloudflaredCommand(), ["tunnel", "create", tunnelName]);
313
+ tunnelId = (created.match(/[0-9a-f-]{36}/) || [])[0] || "";
314
+ if (!tunnelId) throw new Error(`could not read tunnel id: ${created}`);
315
+ }
316
+ run(cloudflaredCommand(), ["tunnel", "route", "dns", tunnelName, host]);
317
+
318
+ const cfConfig = join(cfDir, `${tunnelName}.yml`);
319
+ const credFile = join(cfDir, `${tunnelId}.json`);
320
+ writeFileSync(cfConfig, [
321
+ `tunnel: ${tunnelId}`,
322
+ `credentials-file: ${credFile}`,
323
+ "",
324
+ "ingress:",
325
+ ` - hostname: ${host}`,
326
+ ` service: http://127.0.0.1:${cfg.port || 9876}`,
327
+ " - service: http_status:404",
328
+ "",
329
+ ].join("\n"));
330
+
331
+ const child = spawn(cloudflaredCommand(), ["tunnel", "--protocol", "http2", "--config", cfConfig, "run"], {
332
+ detached: true,
333
+ stdio: ["ignore", "ignore", "ignore"],
334
+ });
335
+ child.unref();
336
+ saveConfigPatch({ adminUrl: `https://${host}` });
337
+ console.log(`Admin URL: https://${host}`);
338
+ console.log(`Control tunnel: ${tunnelName}`);
339
+ console.log("Next: mcp-nexus pair");
340
+ }
341
+
342
+ function ensureControl() {
343
+ const cfg = config();
344
+ if (cfg.adminUrl || process.env.MCP_NEXUS_ADMIN_URL) return;
345
+ setupControl(defaultControlHost());
346
+ }
347
+
250
348
  function spawnLogged(project, cmd, args, env, logName) {
251
349
  const logPath = join(logDir, logName);
252
350
  const child = spawn(cmd, args, {
@@ -272,7 +370,7 @@ function startProject(name) {
272
370
  DEVSPACE_CONFIG_DIR: project.config_dir,
273
371
  }, `${name}.out`);
274
372
  if (project.cloudflare_config) {
275
- project.cloudflaredPid = spawnLogged(project, "cloudflared", ["tunnel", "--protocol", "http2", "--config", project.cloudflare_config, "run"], {}, `${name}-cloudflared.out`);
373
+ project.cloudflaredPid = spawnLogged(project, cloudflaredCommand(), ["tunnel", "--protocol", "http2", "--config", project.cloudflare_config, "run"], {}, `${name}-cloudflared.out`);
276
374
  }
277
375
  saveProjects(items);
278
376
  return projectStatus(project);
@@ -373,6 +471,7 @@ function startServeDetached() {
373
471
 
374
472
  async function pair() {
375
473
  ensureState();
474
+ ensureControl();
376
475
  const cfg = config();
377
476
  const pairCode = String(Math.floor(100000 + Math.random() * 900000));
378
477
  const pairingSecret = randomHex(16);
@@ -405,7 +504,8 @@ function status() {
405
504
  const command = process.argv[2] || "help";
406
505
  if (command === "serve") serve();
407
506
  else if (command === "pair") pair().catch((error) => { console.error(error.message); process.exit(1); });
507
+ else if (command === "setup-control") { try { setupControl(process.argv[3]); } catch (error) { console.error(error.message); process.exit(1); } }
408
508
  else if (command === "status") status();
409
509
  else {
410
- console.log("Usage: mcp-nexus <pair|serve|status>");
510
+ console.log("Usage: mcp-nexus <setup-control|pair|serve|status>");
411
511
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoer7788/mcp-nexus-node",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Remote node for MCP Nexus pairing and DevSpace project control.",
5
5
  "license": "MIT",
6
6
  "type": "module",