@mmmbuto/nexuscrew 0.8.0 → 0.8.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.
- package/README.md +66 -60
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-BFyPeZsL.js +90 -0
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
- package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +314 -110
- package/lib/cli/doctor.js +1 -1
- package/lib/cli/init.js +40 -10
- package/lib/cli/pidfile.js +2 -2
- package/lib/cli/service.js +0 -5
- package/lib/decks/store.js +8 -2
- package/lib/files/routes.js +3 -1
- package/lib/fleet/builtin.js +15 -5
- package/lib/fleet/managed.js +278 -107
- package/lib/mcp/server.js +24 -5
- package/lib/nodes/commands.js +27 -12
- package/lib/nodes/peering.js +116 -0
- package/lib/nodes/store.js +91 -24
- package/lib/nodes/topology-cache.js +75 -0
- package/lib/nodes/tunnel.js +22 -7
- package/lib/proxy/federation.js +263 -0
- package/lib/proxy/node-proxy.js +21 -8
- package/lib/server.js +91 -7
- package/lib/settings/routes.js +221 -5
- package/package.json +2 -2
- package/frontend/dist/assets/index-BBOgTCoO.css +0 -32
- package/frontend/dist/assets/index-DQrDBogT.js +0 -83
package/lib/fleet/managed.js
CHANGED
|
@@ -1,53 +1,124 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Managed AI engine registry. Definitions contain adapter/provider metadata but
|
|
3
|
+
// never secret values. Commands are direct argv; no shell or chat-protocol
|
|
4
|
+
// compatibility fallback is allowed.
|
|
5
5
|
const fs = require('node:fs');
|
|
6
6
|
const path = require('node:path');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
const { execFile } = require('node:child_process');
|
|
7
9
|
|
|
8
10
|
const OLLAMA_CLOUD_MODELS = Object.freeze([
|
|
9
11
|
'glm-5.2', 'kimi-k2.7-code', 'deepseek-v4-pro', 'minimax-m3',
|
|
10
12
|
'qwen3.5:397b', 'deepseek-v4-flash', 'mistral-large-3:675b', 'gemma4:31b',
|
|
11
13
|
]);
|
|
12
14
|
const OLLAMA_CONTEXT = Object.freeze({
|
|
13
|
-
'glm-5.2': 1000000,
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'minimax-m3': 524288,
|
|
17
|
-
'qwen3.5:397b': 262144,
|
|
18
|
-
'deepseek-v4-flash': 1048576,
|
|
19
|
-
'mistral-large-3:675b': 262144,
|
|
20
|
-
'gemma4:31b': 262144,
|
|
15
|
+
'glm-5.2': 1000000, 'kimi-k2.7-code': 262144, 'deepseek-v4-pro': 524288,
|
|
16
|
+
'minimax-m3': 524288, 'qwen3.5:397b': 262144, 'deepseek-v4-flash': 1048576,
|
|
17
|
+
'mistral-large-3:675b': 262144, 'gemma4:31b': 262144,
|
|
21
18
|
});
|
|
22
19
|
|
|
20
|
+
const CUSTOM_KEYS = ['displayName', 'protocol', 'baseUrl', 'envKey', 'providerId'];
|
|
21
|
+
const MANAGED_KEYS = new Set(['client', 'provider', 'credentialProfile', 'model', 'permissionPolicy', ...CUSTOM_KEYS]);
|
|
22
|
+
const CLIENT_LABELS = Object.freeze({ claude: 'Claude Code', codex: 'Codex', 'codex-vl': 'Codex-VL', pi: 'Pi' });
|
|
23
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
24
|
+
const PROVIDER_ID_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
25
|
+
|
|
26
|
+
function validBaseUrl(value) {
|
|
27
|
+
if (typeof value !== 'string' || value.length > 512 || /\s|[\x00-\x1f\x7f]/.test(value)) return false;
|
|
28
|
+
try {
|
|
29
|
+
const parsed = new URL(value);
|
|
30
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
|
31
|
+
&& !parsed.username && !parsed.password && !parsed.hash;
|
|
32
|
+
} catch (_) { return false; }
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
const CATALOG = Object.freeze([
|
|
24
|
-
|
|
25
|
-
{ id: '
|
|
26
|
-
{ id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Claude · Ollama Cloud
|
|
27
|
-
{ id: '
|
|
28
|
-
{ id: 'claude.zai-a', client: 'claude', provider: 'zai
|
|
29
|
-
{ id: 'claude.zai-p', client: 'claude', provider: 'zai
|
|
36
|
+
// Claude Code
|
|
37
|
+
{ id: 'claude.native', client: 'claude', provider: 'native', label: 'Claude · Anthropic', auth: 'login', endpoint: 'Anthropic account', protocol: 'anthropic_messages', rc: true, default: true },
|
|
38
|
+
{ id: 'claude.ollama-cloud', client: 'claude', provider: 'ollama-cloud', label: 'Claude · Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com', protocol: 'anthropic_messages', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true },
|
|
39
|
+
{ id: 'claude.ollama', client: 'claude', provider: 'ollama', label: 'Claude · Ollama local', auth: 'none', endpoint: 'http://127.0.0.1:11434', protocol: 'anthropic_messages' },
|
|
40
|
+
{ id: 'claude.zai-a', client: 'claude', provider: 'zai', credentialProfile: 'a', label: 'Claude · Z.AI · profile A', auth: 'ZAI_API_KEY_A', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-a' },
|
|
41
|
+
{ id: 'claude.zai-p', client: 'claude', provider: 'zai', credentialProfile: 'p', label: 'Claude · Z.AI · profile P', auth: 'ZAI_API_KEY_P', endpoint: 'https://api.z.ai/api/anthropic', protocol: 'anthropic_messages', model: 'glm-5.2[1m]', models: ['glm-5.2[1m]'], legacySecrets: true, legacyProvider: 'zai-p' },
|
|
42
|
+
{ id: 'claude.custom', client: 'claude', provider: 'custom', label: 'Claude · Custom', auth: 'dynamic', protocol: 'anthropic_messages', protocols: ['anthropic_messages'], custom: true },
|
|
43
|
+
|
|
44
|
+
// Codex family. OpenAI Responses is the only remote custom wire API.
|
|
45
|
+
{ id: 'codex.native', client: 'codex', provider: 'native', label: 'Codex · OpenAI', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true },
|
|
46
|
+
{ id: 'codex-vl.native', client: 'codex-vl', provider: 'native', label: 'Codex-VL · OpenAI', auth: 'login', endpoint: 'OpenAI account', protocol: 'openai_responses', default: true },
|
|
47
|
+
{ id: 'codex.ollama', client: 'codex', provider: 'ollama', label: 'Codex · Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama' },
|
|
48
|
+
{ id: 'codex-vl.ollama', client: 'codex-vl', provider: 'ollama', label: 'Codex-VL · Ollama local', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'ollama' },
|
|
49
|
+
{ id: 'codex.lmstudio', client: 'codex', provider: 'lmstudio', label: 'Codex · LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio' },
|
|
50
|
+
{ id: 'codex-vl.lmstudio', client: 'codex-vl', provider: 'lmstudio', label: 'Codex-VL · LM Studio', auth: 'none', endpoint: 'local provider', protocol: 'openai_responses', localProvider: 'lmstudio' },
|
|
51
|
+
{ id: 'codex.ollama-cloud', client: 'codex', provider: 'ollama-cloud', label: 'Codex · Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true },
|
|
52
|
+
{ id: 'codex-vl.ollama-cloud', client: 'codex-vl', provider: 'ollama-cloud', label: 'Codex-VL · Ollama Cloud', auth: 'OLLAMA_API_KEY', endpoint: 'https://ollama.com/v1', protocol: 'openai_responses', model: 'glm-5.2', models: OLLAMA_CLOUD_MODELS, legacySecrets: true },
|
|
53
|
+
{ id: 'codex.custom', client: 'codex', provider: 'custom', label: 'Codex · Custom Responses', auth: 'dynamic', protocol: 'openai_responses', protocols: ['openai_responses'], custom: true },
|
|
54
|
+
{ id: 'codex-vl.custom', client: 'codex-vl', provider: 'custom', label: 'Codex-VL · Custom Responses', auth: 'dynamic', protocol: 'openai_responses', protocols: ['openai_responses'], custom: true },
|
|
55
|
+
|
|
56
|
+
// Pi uses its real provider IDs directly. OAuth providers do not need env keys.
|
|
57
|
+
{ id: 'pi.anthropic', client: 'pi', provider: 'anthropic', label: 'Pi · Anthropic', auth: 'ANTHROPIC_API_KEY', protocol: 'pi_native', piProvider: 'anthropic' },
|
|
58
|
+
{ id: 'pi.openai', client: 'pi', provider: 'openai', label: 'Pi · OpenAI', auth: 'OPENAI_API_KEY', protocol: 'pi_native', piProvider: 'openai' },
|
|
59
|
+
{ id: 'pi.google', client: 'pi', provider: 'google', label: 'Pi · Google Gemini', auth: 'GEMINI_API_KEY', protocol: 'pi_native', piProvider: 'google' },
|
|
60
|
+
{ id: 'pi.openrouter', client: 'pi', provider: 'openrouter', label: 'Pi · OpenRouter', auth: 'OPENROUTER_API_KEY', protocol: 'pi_native', piProvider: 'openrouter' },
|
|
61
|
+
{ id: 'pi.github-copilot', client: 'pi', provider: 'github-copilot', label: 'Pi · GitHub Copilot', auth: 'login', protocol: 'pi_native', piProvider: 'github-copilot' },
|
|
62
|
+
{ id: 'pi.fireworks', client: 'pi', provider: 'fireworks', label: 'Pi · Fireworks AI', auth: 'FIREWORKS_API_KEY', protocol: 'pi_native', piProvider: 'fireworks' },
|
|
63
|
+
{ id: 'pi.huggingface', client: 'pi', provider: 'huggingface', label: 'Pi · Hugging Face', auth: 'HF_TOKEN', protocol: 'pi_native', piProvider: 'huggingface' },
|
|
64
|
+
{ id: 'pi.minimax', client: 'pi', provider: 'minimax', label: 'Pi · MiniMax', auth: 'MINIMAX_API_KEY', protocol: 'pi_native', piProvider: 'minimax' },
|
|
65
|
+
{ id: 'pi.deepseek', client: 'pi', provider: 'deepseek', label: 'Pi · DeepSeek', auth: 'DEEPSEEK_API_KEY', protocol: 'pi_native', piProvider: 'deepseek' },
|
|
66
|
+
{ id: 'pi.kimi-coding', client: 'pi', provider: 'kimi-coding', label: 'Pi · Kimi For Coding', auth: 'KIMI_API_KEY', protocol: 'pi_native', piProvider: 'kimi-coding' },
|
|
67
|
+
{ id: 'pi.mistral', client: 'pi', provider: 'mistral', label: 'Pi · Mistral', auth: 'MISTRAL_API_KEY', protocol: 'pi_native', piProvider: 'mistral' },
|
|
68
|
+
{ id: 'pi.together', client: 'pi', provider: 'together', label: 'Pi · Together AI', auth: 'TOGETHER_API_KEY', protocol: 'pi_native', piProvider: 'together' },
|
|
69
|
+
{ id: 'pi.ollama', client: 'pi', provider: 'ollama', label: 'Pi · Ollama local', auth: 'none', protocol: 'openai-completions', piProvider: 'ollama', requiresModel: true, piExtension: { baseUrl: 'http://127.0.0.1:11434/v1', apiKey: 'ollama' } },
|
|
70
|
+
{ id: 'pi.zai', client: 'pi', provider: 'zai', label: 'Pi · Z.AI', auth: 'ZAI_API_KEY', protocol: 'pi_native', piProvider: 'zai' },
|
|
71
|
+
{ id: 'pi.custom', client: 'pi', provider: 'custom', label: 'Pi · Custom provider', auth: 'dynamic', protocol: 'openai-responses', protocols: ['openai-responses', 'anthropic-messages', 'openai-completions', 'google-generative-ai'], custom: true },
|
|
30
72
|
]);
|
|
31
73
|
|
|
32
|
-
|
|
74
|
+
function profileFor(client, provider, credentialProfile) {
|
|
75
|
+
return CATALOG.find((p) => p.client === client && p.provider === provider
|
|
76
|
+
&& (p.credentialProfile || '') === (credentialProfile || '')) || null;
|
|
77
|
+
}
|
|
33
78
|
|
|
34
79
|
function normalizeManagedSpec(value) {
|
|
35
80
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
81
|
+
if (Object.keys(value).some((k) => !MANAGED_KEYS.has(k))) return null;
|
|
82
|
+
if (!CLIENT_LABELS[value.client]) return null;
|
|
83
|
+
let provider = value.provider;
|
|
84
|
+
let credentialProfile = value.credentialProfile || '';
|
|
85
|
+
// 0.8.0 compatibility: zai-a/zai-p were encoded as providers.
|
|
86
|
+
if (value.client === 'claude' && (provider === 'zai-a' || provider === 'zai-p')) {
|
|
87
|
+
credentialProfile = provider.slice(-1);
|
|
88
|
+
provider = 'zai';
|
|
89
|
+
}
|
|
90
|
+
if (typeof provider !== 'string') return null;
|
|
91
|
+
const profile = profileFor(value.client, provider, credentialProfile);
|
|
39
92
|
if (!profile) return null;
|
|
40
93
|
const model = value.model === undefined ? (profile.model || '') : value.model;
|
|
41
|
-
if (typeof model !== 'string' || model.length > 128) return null;
|
|
42
|
-
|
|
94
|
+
if (typeof model !== 'string' || model.length > 128 || /[\x00-\x1f\x7f]/.test(model)) return null;
|
|
95
|
+
if (profile.requiresModel && !model) return null;
|
|
96
|
+
const permissionPolicy = value.permissionPolicy === undefined ? (profile.client === 'claude' ? 'unsafe' : 'standard') : value.permissionPolicy;
|
|
97
|
+
if (permissionPolicy !== 'standard' && permissionPolicy !== 'unsafe') return null;
|
|
98
|
+
if (value.client === 'pi' && permissionPolicy !== 'standard') return null;
|
|
99
|
+
const out = { client: profile.client, provider: profile.provider, model, permissionPolicy };
|
|
100
|
+
if (profile.credentialProfile) out.credentialProfile = profile.credentialProfile;
|
|
101
|
+
if (profile.custom) {
|
|
102
|
+
const displayName = typeof value.displayName === 'string' ? value.displayName.trim() : '';
|
|
103
|
+
const baseUrl = typeof value.baseUrl === 'string' ? value.baseUrl.trim() : '';
|
|
104
|
+
const envKey = typeof value.envKey === 'string' ? value.envKey.trim() : '';
|
|
105
|
+
const protocol = value.protocol || profile.protocol;
|
|
106
|
+
const providerId = typeof value.providerId === 'string' && value.providerId ? value.providerId : 'nexuscrew-custom';
|
|
107
|
+
if (!displayName || displayName.length > 64 || /[\x00-\x1f\x7f]/.test(displayName)) return null;
|
|
108
|
+
if (!validBaseUrl(baseUrl)) return null;
|
|
109
|
+
if (!ENV_KEY_RE.test(envKey) || !PROVIDER_ID_RE.test(providerId)) return null;
|
|
110
|
+
if (!model || !(profile.protocols || [profile.protocol]).includes(protocol)) return null;
|
|
111
|
+
Object.assign(out, { displayName, baseUrl, envKey, protocol, providerId });
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
43
114
|
}
|
|
44
115
|
|
|
45
116
|
function defaultDefinitions() {
|
|
46
117
|
return {
|
|
47
118
|
schemaVersion: 1,
|
|
48
119
|
engines: CATALOG.filter((p) => p.default).map((p) => ({
|
|
49
|
-
id: p.id, label: p.label, rc: p.rc,
|
|
50
|
-
managed: { client: p.client, provider: p.provider, model: p.model || '' },
|
|
120
|
+
id: p.id, label: p.label, rc: !!p.rc,
|
|
121
|
+
managed: { client: p.client, provider: p.provider, model: p.model || '', permissionPolicy: p.client === 'claude' ? 'unsafe' : 'standard' },
|
|
51
122
|
})),
|
|
52
123
|
cells: [],
|
|
53
124
|
};
|
|
@@ -64,24 +135,27 @@ function parseEnvFile(file) {
|
|
|
64
135
|
for (const line of raw.split(/\r?\n/)) {
|
|
65
136
|
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
66
137
|
if (!m) continue;
|
|
67
|
-
let
|
|
68
|
-
if ((
|
|
69
|
-
out[m[1]] =
|
|
138
|
+
let v = m[2];
|
|
139
|
+
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) v = v.slice(1, -1);
|
|
140
|
+
out[m[1]] = v;
|
|
70
141
|
}
|
|
71
142
|
return out;
|
|
72
143
|
}
|
|
73
144
|
|
|
74
145
|
function binaryCandidates(client, home) {
|
|
75
146
|
const prefix = process.env.PREFIX || '';
|
|
76
|
-
|
|
77
|
-
return [
|
|
147
|
+
const bin = client;
|
|
148
|
+
return [...new Set([
|
|
149
|
+
path.join(home, '.local', 'bin', bin), path.join(path.dirname(process.execPath), bin),
|
|
150
|
+
`/usr/local/bin/${bin}`, `/opt/homebrew/bin/${bin}`,
|
|
151
|
+
prefix && path.join(prefix, 'bin', bin),
|
|
152
|
+
].filter(Boolean))];
|
|
78
153
|
}
|
|
79
154
|
|
|
80
155
|
function findBinary(client, home) {
|
|
81
156
|
for (const candidate of binaryCandidates(client, home)) {
|
|
82
157
|
try {
|
|
83
|
-
const real = fs.realpathSync(candidate);
|
|
84
|
-
const st = fs.lstatSync(real);
|
|
158
|
+
const real = fs.realpathSync(candidate); const st = fs.lstatSync(real);
|
|
85
159
|
if (!st.isFile() || !(st.mode & 0o100) || (st.mode & 0o002)) continue;
|
|
86
160
|
if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) continue;
|
|
87
161
|
return real;
|
|
@@ -91,121 +165,218 @@ function findBinary(client, home) {
|
|
|
91
165
|
}
|
|
92
166
|
|
|
93
167
|
function secretsPath(cfg, home) {
|
|
94
|
-
return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS
|
|
95
|
-
|
|
168
|
+
return cfg.providerSecretsPath || process.env.NEXUSCREW_PROVIDER_SECRETS || path.join(home, '.nexuscrew', 'providers.env');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function credential(profile, spec, cfg, home) {
|
|
172
|
+
if (profile.auth === 'login' || profile.auth === 'none') return { envKey: profile.auth, value: '' };
|
|
173
|
+
const envKey = profile.auth === 'dynamic' ? spec.envKey : profile.auth;
|
|
174
|
+
const runtimeEnv = cfg.env || process.env;
|
|
175
|
+
let value = runtimeEnv[envKey] || '';
|
|
176
|
+
// Preserve 0.8.0 Z.AI/Ollama Cloud behavior, but new Custom never reads disk.
|
|
177
|
+
if (!value && profile.legacySecrets) value = parseEnvFile(secretsPath(cfg, home))[envKey] || '';
|
|
178
|
+
return { envKey, value };
|
|
96
179
|
}
|
|
97
180
|
|
|
98
181
|
let ollamaCache = { at: 0, models: [] };
|
|
99
182
|
async function discoverOllamaModels(opts = {}) {
|
|
100
|
-
const now = Date.now();
|
|
101
|
-
const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
|
|
183
|
+
const now = Date.now(); const ttl = opts.ttlMs === undefined ? 30000 : opts.ttlMs;
|
|
102
184
|
if (!opts.noCache && ollamaCache.models.length && now - ollamaCache.at < ttl) return [...ollamaCache.models];
|
|
103
185
|
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
104
186
|
if (typeof fetchImpl !== 'function') return [...OLLAMA_CLOUD_MODELS];
|
|
105
187
|
try {
|
|
106
188
|
const home = opts.home || require('node:os').homedir();
|
|
107
|
-
const apiKey = opts.apiKey || parseEnvFile(secretsPath(opts, home)).OLLAMA_API_KEY;
|
|
108
|
-
if (!apiKey) throw new Error('OLLAMA_API_KEY
|
|
109
|
-
const response = await fetchImpl('https://ollama.com/api/tags', {
|
|
110
|
-
headers: { authorization: `Bearer ${apiKey}` },
|
|
111
|
-
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(2500) : undefined,
|
|
112
|
-
});
|
|
189
|
+
const apiKey = opts.apiKey || (opts.env || process.env).OLLAMA_API_KEY || parseEnvFile(secretsPath(opts, home)).OLLAMA_API_KEY;
|
|
190
|
+
if (!apiKey) throw new Error('OLLAMA_API_KEY missing');
|
|
191
|
+
const response = await fetchImpl('https://ollama.com/api/tags', { headers: { authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout?.(2500) });
|
|
113
192
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
114
|
-
const body = await response.json();
|
|
115
|
-
const available = new Set();
|
|
193
|
+
const body = await response.json(); const available = new Set();
|
|
116
194
|
for (const item of Array.isArray(body.models) ? body.models : []) {
|
|
117
195
|
const name = typeof item?.name === 'string' ? item.name : '';
|
|
118
|
-
if (
|
|
119
|
-
available.add(name);
|
|
196
|
+
if (/^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)?$/.test(name) && name.length <= 128) available.add(name);
|
|
120
197
|
}
|
|
121
|
-
// La API diretta espone molti modelli, inclusi modelli in deprecazione. La UI
|
|
122
|
-
// mantiene la shortlist TOP curata, ma solo se ancora realmente disponibile.
|
|
123
198
|
const models = OLLAMA_CLOUD_MODELS.filter((name) => available.has(name));
|
|
124
|
-
if (!models.length) throw new Error('
|
|
125
|
-
ollamaCache = { at: now, models };
|
|
126
|
-
return [...models];
|
|
199
|
+
if (!models.length) throw new Error('no cloud models');
|
|
200
|
+
ollamaCache = { at: now, models }; return [...models];
|
|
127
201
|
} catch (_) {
|
|
128
|
-
ollamaCache = { at: now, models: [...OLLAMA_CLOUD_MODELS] };
|
|
129
|
-
|
|
202
|
+
ollamaCache = { at: now, models: [...OLLAMA_CLOUD_MODELS] }; return [...OLLAMA_CLOUD_MODELS];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let piCache = { at: 0, providers: {} };
|
|
207
|
+
let piInFlight = null;
|
|
208
|
+
async function discoverPiModels(opts = {}) {
|
|
209
|
+
const now = Date.now(); const ttl = opts.ttlMs === undefined ? 300000 : opts.ttlMs;
|
|
210
|
+
if (!opts.noCache && Object.keys(piCache.providers).length && now - piCache.at < ttl) {
|
|
211
|
+
return Object.fromEntries(Object.entries(piCache.providers).map(([k, v]) => [k, [...v]]));
|
|
212
|
+
}
|
|
213
|
+
const home = opts.home || require('node:os').homedir();
|
|
214
|
+
const binary = opts.binary || findBinary('pi', home);
|
|
215
|
+
if (!binary) return {};
|
|
216
|
+
if (!opts.noCache && piInFlight) return piInFlight;
|
|
217
|
+
const execFileImpl = opts.execFileImpl || execFile;
|
|
218
|
+
const load = async () => {
|
|
219
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
220
|
+
execFileImpl(binary, ['--list-models'], { encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 }, (err, out) => {
|
|
221
|
+
if (err) reject(err); else resolve(String(out || ''));
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
const providers = {};
|
|
225
|
+
for (const line of stdout.split(/\r?\n/).slice(1)) {
|
|
226
|
+
const [provider, model] = line.trim().split(/\s+/);
|
|
227
|
+
if (!PROVIDER_ID_RE.test(provider || '') || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(model || '')) continue;
|
|
228
|
+
(providers[provider] ||= []).push(model);
|
|
229
|
+
}
|
|
230
|
+
for (const key of Object.keys(providers)) providers[key] = [...new Set(providers[key])];
|
|
231
|
+
piCache = { at: now, providers };
|
|
232
|
+
return Object.fromEntries(Object.entries(providers).map(([k, v]) => [k, [...v]]));
|
|
233
|
+
};
|
|
234
|
+
if (opts.noCache) {
|
|
235
|
+
try { return await load(); } catch (_) { return {}; }
|
|
130
236
|
}
|
|
237
|
+
piInFlight = load().catch(() => ({})).finally(() => { piInFlight = null; });
|
|
238
|
+
return piInFlight;
|
|
131
239
|
}
|
|
132
240
|
|
|
133
241
|
function describeManaged(spec, cfg = {}) {
|
|
134
242
|
const normalized = normalizeManagedSpec(spec);
|
|
135
|
-
if (!normalized) return { configured: false, reason: '
|
|
243
|
+
if (!normalized) return { configured: false, reason: 'invalid managed profile' };
|
|
136
244
|
const home = cfg.home || require('node:os').homedir();
|
|
137
|
-
const profile =
|
|
245
|
+
const profile = profileFor(normalized.client, normalized.provider, normalized.credentialProfile || '');
|
|
138
246
|
const binary = findBinary(normalized.client, home);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
247
|
+
const cred = credential(profile, normalized, cfg, home);
|
|
248
|
+
// Pi can resolve credentials from its own documented /login auth store. Do
|
|
249
|
+
// not inspect or copy that store; delegate native-provider auth to Pi.
|
|
250
|
+
const delegatedPiAuth = profile.client === 'pi' && profile.provider !== 'custom';
|
|
251
|
+
const authConfigured = delegatedPiAuth || profile.auth === 'login' || profile.auth === 'none' || !!cred.value;
|
|
143
252
|
return {
|
|
144
|
-
client: profile.client,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
253
|
+
client: profile.client, clientLabel: CLIENT_LABELS[profile.client], provider: profile.provider,
|
|
254
|
+
credentialProfile: normalized.credentialProfile || '', model: normalized.model,
|
|
255
|
+
permissionPolicy: normalized.permissionPolicy, protocol: normalized.protocol || profile.protocol,
|
|
256
|
+
endpoint: normalized.baseUrl || profile.endpoint || '', auth: cred.envKey, authConfigured,
|
|
257
|
+
configured: !!binary && authConfigured, models: [...(profile.models || [])], defaultModel: profile.model || '',
|
|
258
|
+
binary: binary || '', displayName: normalized.displayName || profile.label,
|
|
259
|
+
reason: !binary ? `client ${profile.client} not found` : (!authConfigured ? `environment variable ${cred.envKey} missing` : 'ready'),
|
|
149
260
|
};
|
|
150
261
|
}
|
|
151
262
|
|
|
263
|
+
function codexProviderArgs(id, name, baseUrl, envKey) {
|
|
264
|
+
return [
|
|
265
|
+
'-c', `model_provider=${JSON.stringify(id)}`, '-c', `model_providers.${id}.name=${JSON.stringify(name)}`,
|
|
266
|
+
'-c', `model_providers.${id}.base_url=${JSON.stringify(baseUrl)}`, '-c', `model_providers.${id}.env_key=${JSON.stringify(envKey)}`,
|
|
267
|
+
'-c', `model_providers.${id}.wire_api="responses"`,
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function writePiProviderExtension(spec, home) {
|
|
272
|
+
const dir = path.join(home, '.nexuscrew', 'pi-providers');
|
|
273
|
+
try {
|
|
274
|
+
const st = fs.lstatSync(dir);
|
|
275
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('unsafe Pi provider extension directory');
|
|
276
|
+
} catch (e) {
|
|
277
|
+
if (e.code === 'ENOENT') fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
278
|
+
else throw e;
|
|
279
|
+
}
|
|
280
|
+
fs.chmodSync(dir, 0o700);
|
|
281
|
+
const target = path.join(dir, `${spec.providerId}.ts`);
|
|
282
|
+
try {
|
|
283
|
+
if (fs.lstatSync(target).isSymbolicLink()) throw new Error('refusing symlink Pi provider extension');
|
|
284
|
+
} catch (e) {
|
|
285
|
+
if (e.code !== 'ENOENT') throw e;
|
|
286
|
+
}
|
|
287
|
+
const definition = {
|
|
288
|
+
name: spec.displayName,
|
|
289
|
+
baseUrl: spec.baseUrl,
|
|
290
|
+
apiKey: spec.apiKey || `$${spec.envKey}`,
|
|
291
|
+
authHeader: true,
|
|
292
|
+
api: spec.protocol,
|
|
293
|
+
models: [{
|
|
294
|
+
id: spec.model, name: spec.model, reasoning: false, input: ['text'],
|
|
295
|
+
contextWindow: 128000, maxTokens: 16384,
|
|
296
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
297
|
+
}],
|
|
298
|
+
};
|
|
299
|
+
const source = `// Generated by NexusCrew. Contains environment references only, never secret values.\nexport default function (pi) {\n pi.registerProvider(${JSON.stringify(spec.providerId)}, ${JSON.stringify(definition, null, 2)});\n}\n`;
|
|
300
|
+
const tmp = path.join(dir, `.${spec.providerId}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
301
|
+
try {
|
|
302
|
+
fs.writeFileSync(tmp, source, { mode: 0o600 });
|
|
303
|
+
fs.chmodSync(tmp, 0o600);
|
|
304
|
+
fs.renameSync(tmp, target);
|
|
305
|
+
} catch (e) {
|
|
306
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
307
|
+
throw e;
|
|
308
|
+
}
|
|
309
|
+
return target;
|
|
310
|
+
}
|
|
311
|
+
|
|
152
312
|
function resolveManagedEngine(engine, cell, cfg = {}) {
|
|
153
|
-
const
|
|
154
|
-
|
|
313
|
+
const spec = normalizeManagedSpec(engine.managed);
|
|
314
|
+
const info = describeManaged(spec, cfg);
|
|
315
|
+
if (!spec || !info.configured) return { ok: false, reason: info.reason, info };
|
|
155
316
|
const home = cfg.home || require('node:os').homedir();
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
160
|
-
args.push('--dangerously-skip-permissions');
|
|
161
|
-
if (
|
|
317
|
+
const profile = profileFor(spec.client, spec.provider, spec.credentialProfile || '');
|
|
318
|
+
const cred = credential(profile, spec, cfg, home);
|
|
319
|
+
const env = {}; const args = []; const model = cell?.model || spec.model;
|
|
320
|
+
if (spec.permissionPolicy === 'unsafe') {
|
|
321
|
+
if (spec.client === 'claude') args.push('--dangerously-skip-permissions');
|
|
322
|
+
if (spec.client === 'codex' || spec.client === 'codex-vl') args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
323
|
+
}
|
|
324
|
+
if (spec.client === 'claude') {
|
|
325
|
+
if (spec.provider === 'native') {
|
|
162
326
|
if (engine.rc !== false) args.push('--remote-control', `Cloud_${cell.id}`);
|
|
163
|
-
} else if (info.provider === 'ollama-cloud') {
|
|
164
|
-
const secrets = parseEnvFile(secretsPath(cfg, home));
|
|
165
|
-
Object.assign(env, {
|
|
166
|
-
ANTHROPIC_BASE_URL: 'https://ollama.com', ANTHROPIC_AUTH_TOKEN: secrets.OLLAMA_API_KEY,
|
|
167
|
-
ANTHROPIC_API_KEY: '',
|
|
168
|
-
ANTHROPIC_MODEL: model, ANTHROPIC_SMALL_FAST_MODEL: model, API_TIMEOUT_MS: '3000000',
|
|
169
|
-
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(OLLAMA_CONTEXT[model] || 200000),
|
|
170
|
-
});
|
|
171
327
|
} else {
|
|
172
|
-
const
|
|
173
|
-
const
|
|
328
|
+
const endpoint = spec.baseUrl || profile.endpoint;
|
|
329
|
+
const token = profile.auth === 'none' ? 'ollama' : cred.value;
|
|
174
330
|
Object.assign(env, {
|
|
175
|
-
ANTHROPIC_BASE_URL:
|
|
176
|
-
ANTHROPIC_MODEL: model,
|
|
177
|
-
|
|
331
|
+
ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: token, ANTHROPIC_API_KEY: '',
|
|
332
|
+
ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model,
|
|
333
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: model, ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
|
334
|
+
CLAUDE_CODE_SUBAGENT_MODEL: model, API_TIMEOUT_MS: '3000000',
|
|
335
|
+
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(OLLAMA_CONTEXT[model] || (spec.provider === 'zai' ? 1000000 : 200000)),
|
|
178
336
|
});
|
|
179
337
|
}
|
|
180
338
|
if (model) args.push('--model', model);
|
|
181
|
-
} else {
|
|
182
|
-
args.push('--
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
339
|
+
} else if (spec.client === 'codex' || spec.client === 'codex-vl') {
|
|
340
|
+
if (profile.localProvider) args.push('--oss', '--local-provider', profile.localProvider);
|
|
341
|
+
else if (spec.provider === 'ollama-cloud') {
|
|
342
|
+
env.OPENAI_API_KEY = cred.value;
|
|
343
|
+
args.push(...codexProviderArgs('ollama_cloud', 'Ollama Cloud', profile.endpoint, 'OPENAI_API_KEY'));
|
|
344
|
+
args.push('-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000', '-c', `model_context_window=${OLLAMA_CONTEXT[model] || 200000}`);
|
|
187
345
|
const localCatalog = path.join(home, '.codex', 'ollama_cloud_model_catalog.json');
|
|
188
|
-
args.push(
|
|
189
|
-
'-c', 'model_provider="ollama_cloud"',
|
|
190
|
-
'-c', 'model_providers.ollama_cloud.name="Ollama Cloud"',
|
|
191
|
-
'-c', 'model_providers.ollama_cloud.base_url="https://ollama.com/v1"',
|
|
192
|
-
'-c', 'model_providers.ollama_cloud.wire_api="responses"',
|
|
193
|
-
'-c', 'model_providers.ollama_cloud.stream_idle_timeout_ms=600000',
|
|
194
|
-
'-c', `model_context_window=${OLLAMA_CONTEXT[model] || 200000}`,
|
|
195
|
-
);
|
|
196
|
-
// Codex carica il catalogo solo all'avvio. Il file e' locale e opzionale:
|
|
197
|
-
// sui device che non lo hanno resta valido il fallback context-window sopra.
|
|
198
346
|
if (fs.existsSync(localCatalog)) args.push('-c', `model_catalog_json="${localCatalog}"`);
|
|
347
|
+
} else if (spec.provider === 'custom') {
|
|
348
|
+
env[spec.envKey] = cred.value;
|
|
349
|
+
args.push(...codexProviderArgs(spec.providerId, spec.displayName, spec.baseUrl, spec.envKey));
|
|
199
350
|
}
|
|
351
|
+
if (model) args.push('-m', model);
|
|
352
|
+
} else if (spec.client === 'pi') {
|
|
353
|
+
if (profile.auth !== 'none' && profile.auth !== 'login' && cred.value) env[cred.envKey] = cred.value;
|
|
354
|
+
if (spec.provider === 'custom') args.push('--extension', writePiProviderExtension(spec, home));
|
|
355
|
+
else if (profile.piExtension) args.push('--extension', writePiProviderExtension({
|
|
356
|
+
providerId: profile.piProvider, displayName: profile.label.replace(/^Pi · /, ''),
|
|
357
|
+
baseUrl: profile.piExtension.baseUrl, apiKey: profile.piExtension.apiKey,
|
|
358
|
+
protocol: profile.protocol, model,
|
|
359
|
+
}, home));
|
|
360
|
+
args.push('--provider', spec.provider === 'custom' ? spec.providerId : profile.piProvider);
|
|
361
|
+
if (model) args.push('--model', model);
|
|
200
362
|
}
|
|
201
|
-
|
|
202
|
-
// evita di digitare nella TUI prima che sia pronta; argv e' diretto, mai shell.
|
|
203
|
-
if (cell && cell.prompt) args.push(cell.prompt);
|
|
363
|
+
if (cell?.prompt) args.push(cell.prompt);
|
|
204
364
|
return { ok: true, info, engine: { ...engine, command: info.binary, args, env, promptMode: 'managed-argv' } };
|
|
205
365
|
}
|
|
206
366
|
|
|
367
|
+
function publicCatalog() {
|
|
368
|
+
return CATALOG.map((p) => ({
|
|
369
|
+
id: p.id, client: p.client, clientLabel: CLIENT_LABELS[p.client], provider: p.provider,
|
|
370
|
+
credentialProfile: p.credentialProfile || '', label: p.label, protocol: p.protocol,
|
|
371
|
+
auth: p.auth, endpoint: p.endpoint || '', model: p.model || '', models: [...(p.models || [])],
|
|
372
|
+
protocols: [...(p.protocols || [p.protocol])], supportsUnsafe: p.client !== 'pi', requiresModel: !!p.requiresModel || !!p.custom,
|
|
373
|
+
permissionPolicyDefault: p.client === 'claude' ? 'unsafe' : 'standard',
|
|
374
|
+
rc: !!p.rc, custom: !!p.custom, default: !!p.default,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
|
|
207
378
|
module.exports = {
|
|
208
|
-
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT,
|
|
209
|
-
discoverOllamaModels,
|
|
210
|
-
|
|
379
|
+
CATALOG, OLLAMA_CLOUD_MODELS, OLLAMA_CONTEXT, CLIENT_LABELS, normalizeManagedSpec,
|
|
380
|
+
defaultDefinitions, describeManaged, discoverOllamaModels, resolveManagedEngine,
|
|
381
|
+
discoverPiModels, parseEnvFile, findBinary, publicCatalog, writePiProviderExtension,
|
|
211
382
|
};
|
package/lib/mcp/server.js
CHANGED
|
@@ -340,10 +340,28 @@ function createMcpServer(opts = {}) {
|
|
|
340
340
|
}
|
|
341
341
|
|
|
342
342
|
let rl = null;
|
|
343
|
+
const inFlight = new Set();
|
|
344
|
+
let inputClosed = false;
|
|
345
|
+
let drainResolve;
|
|
346
|
+
const drained = new Promise((resolve) => { drainResolve = resolve; });
|
|
347
|
+
const maybeDrained = () => {
|
|
348
|
+
if (inputClosed && inFlight.size === 0) drainResolve();
|
|
349
|
+
};
|
|
343
350
|
function start() {
|
|
344
351
|
rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
345
|
-
rl.on('line', (line) => {
|
|
346
|
-
|
|
352
|
+
rl.on('line', (line) => {
|
|
353
|
+
const pending = handleLine(line);
|
|
354
|
+
inFlight.add(pending);
|
|
355
|
+
pending.finally(() => {
|
|
356
|
+
inFlight.delete(pending);
|
|
357
|
+
maybeDrained();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
rl.on('close', () => {
|
|
361
|
+
inputClosed = true;
|
|
362
|
+
maybeDrained();
|
|
363
|
+
});
|
|
364
|
+
return { close: () => { try { rl.close(); } catch (_) {} }, drained };
|
|
347
365
|
}
|
|
348
366
|
|
|
349
367
|
return { start, handleLine, toolsList, ctx, cfg: { port: cfg.port, tmuxBin: cfg.tmuxBin } };
|
|
@@ -353,9 +371,10 @@ function createMcpServer(opts = {}) {
|
|
|
353
371
|
// e' aperto (il client MCP chiude la pipe per terminare il server).
|
|
354
372
|
function startMcp(opts = {}) {
|
|
355
373
|
const srv = createMcpServer(opts);
|
|
356
|
-
srv.start();
|
|
357
|
-
//
|
|
358
|
-
|
|
374
|
+
const lifecycle = srv.start();
|
|
375
|
+
// Non forzare process.exit su EOF: una tools/call asincrona puo' essere
|
|
376
|
+
// ancora in volo. Una volta drenate le richieste, Node termina naturalmente.
|
|
377
|
+
srv.drained = lifecycle.drained;
|
|
359
378
|
return srv;
|
|
360
379
|
}
|
|
361
380
|
|
package/lib/nodes/commands.js
CHANGED
|
@@ -93,19 +93,31 @@ function nodesAdd(opts) {
|
|
|
93
93
|
const { home, nodesPath } = resolveNodePaths(opts);
|
|
94
94
|
const name = opts.name;
|
|
95
95
|
const ssh = opts.ssh;
|
|
96
|
-
if (!name) { log('usage: nexuscrew nodes add <name> --ssh user@host [--remote-port N] [--key path] [--local-port N]'); return { code: 1, reason: 'name mancante' }; }
|
|
96
|
+
if (!name) { log('usage: nexuscrew nodes add <name> --ssh user@host [--ssh-port N] [--remote-port N] [--key path] [--local-port N]'); return { code: 1, reason: 'name mancante' }; }
|
|
97
97
|
if (!ssh) { log('nodes add: --ssh user@host obbligatorio'); return { code: 1, reason: 'ssh mancante' }; }
|
|
98
98
|
|
|
99
99
|
const remotePort = opts.remotePort ? Number(opts.remotePort) : DEFAULT_PORT;
|
|
100
|
+
const sshPort = opts.sshPort === undefined ? undefined : Number(opts.sshPort);
|
|
100
101
|
|
|
101
102
|
let st;
|
|
102
103
|
try { st = store.loadOrInitStore(nodesPath); }
|
|
103
104
|
catch (e) { log(`nodes add: ${e.message}`); return { code: 1, reason: 'store invalido' }; }
|
|
104
105
|
|
|
105
106
|
const localPort = opts.localPort ? Number(opts.localPort) : assignLocalPort(st);
|
|
106
|
-
const keyPath = opts.key || defaultKeyPath(home, name);
|
|
107
|
-
|
|
108
|
-
const entry = {
|
|
107
|
+
const keyPath = opts.identityFile || opts.key || (typeof opts.keygen === 'function' ? defaultKeyPath(home, name) : undefined);
|
|
108
|
+
|
|
109
|
+
const entry = {
|
|
110
|
+
name, ssh, remotePort, localPort,
|
|
111
|
+
direction: opts.direction || 'outbound',
|
|
112
|
+
transport: opts.transport || 'auto',
|
|
113
|
+
autostart: opts.autostart !== false,
|
|
114
|
+
visibility: opts.visibility || 'network',
|
|
115
|
+
roles: { client: true, node: false },
|
|
116
|
+
};
|
|
117
|
+
if (keyPath) entry.identityFile = keyPath;
|
|
118
|
+
if (opts.token) entry.token = opts.token;
|
|
119
|
+
if (opts.reversePort) entry.reversePort = Number(opts.reversePort);
|
|
120
|
+
if (sshPort !== undefined) entry.sshPort = sshPort;
|
|
109
121
|
if (opts.nodeId) entry.nodeId = opts.nodeId;
|
|
110
122
|
|
|
111
123
|
// Valida + inserisci in memoria (dup name/id, self-reference, schema) PRIMA di
|
|
@@ -115,18 +127,20 @@ function nodesAdd(opts) {
|
|
|
115
127
|
catch (e) { log(`nodes add: ${e.message}`); return { code: 1, reason: 'add rifiutato' }; }
|
|
116
128
|
|
|
117
129
|
// chiave dedicata per-tunnel (genera o riusa)
|
|
118
|
-
let pub;
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
let pub = null;
|
|
131
|
+
if (keyPath && typeof opts.keygen === 'function') {
|
|
132
|
+
try { pub = ensureKey(keyPath, name, opts); }
|
|
133
|
+
catch (e) { log(`nodes add: generazione chiave fallita (${e.message}) — nodo NON salvato`); return { code: 1, reason: 'keygen fallita' }; }
|
|
134
|
+
}
|
|
121
135
|
|
|
122
136
|
try { store.atomicWriteStore(nodesPath, next); }
|
|
123
137
|
catch (e) { log(`nodes add: scrittura nodes.json fallita: ${e.message}`); return { code: 1, reason: 'write fallita' }; }
|
|
124
138
|
|
|
125
|
-
log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh},
|
|
139
|
+
log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh}${sshPort ? `:${sshPort}` : ''}, nexus remoto ${remotePort} -> locale ${localPort})`);
|
|
126
140
|
log('Incolla nel ~/.ssh/authorized_keys del NODO (lato forward, chiave dedicata):');
|
|
127
141
|
// permitopen vincola i -L alla SOLA porta nexus remota; command=/bin/false + restrict.
|
|
128
|
-
log(`restrict,port-forwarding,permitopen="127.0.0.1:${remotePort}",command="/bin/false" ${pub}`);
|
|
129
|
-
return { code: 0, name, localPort, remotePort };
|
|
142
|
+
if (pub) log(`restrict,port-forwarding,permitopen="127.0.0.1:${remotePort}",command="/bin/false" ${pub}`);
|
|
143
|
+
return { code: 0, name, sshPort, localPort, remotePort, transport: entry.transport };
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
// --- nodes list ------------------------------------------------------------
|
|
@@ -150,7 +164,7 @@ function nodesList(opts) {
|
|
|
150
164
|
if (opts.json) { log(JSON.stringify(out, null, 2)); return { code: 0, nodes }; }
|
|
151
165
|
if (nodes.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes }; }
|
|
152
166
|
for (const n of nodes) {
|
|
153
|
-
log(`${n.name}\t${n.ssh}\tlocal:${n.localPort} ->
|
|
167
|
+
log(`${n.name}\t${n.ssh}${n.sshPort ? `:${n.sshPort}` : ''}\tlocal:${n.localPort} -> nexus:${n.remotePort}\ttunnel:${n.tunnel.status}\ttoken:${n.hasToken ? 'set' : 'unset'}`);
|
|
154
168
|
}
|
|
155
169
|
return { code: 0, nodes };
|
|
156
170
|
}
|
|
@@ -253,7 +267,8 @@ function nodesUp(opts) {
|
|
|
253
267
|
const log = opts.log || console.log;
|
|
254
268
|
const ctx = loadNodeOrFail(opts, log);
|
|
255
269
|
if (!ctx) return { code: 1 };
|
|
256
|
-
|
|
270
|
+
if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
|
|
271
|
+
const r = tunnel.startForward({ home: ctx.home, node: ctx.node, localAppPort: opts.localAppPort, spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd });
|
|
257
272
|
if (r.started) {
|
|
258
273
|
log(`nodes up [${ctx.node.name}]: tunnel avviato (pid ${r.pid}, local ${ctx.node.localPort})`);
|
|
259
274
|
return { code: 0, started: true, pid: r.pid };
|