@mclean-capital/neura 3.2.0 → 3.4.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.
- package/core/server.bundled.mjs +3 -1
- package/core/server.bundled.mjs.map +2 -2
- package/core/version.txt +1 -1
- package/dist/commands/backup.js +3 -3
- package/dist/commands/backup.js.map +1 -1
- package/dist/commands/install.d.ts +0 -23
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +503 -177
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/uninstall.js +3 -3
- package/dist/commands/uninstall.js.map +1 -1
- package/dist/providers.d.ts +66 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +325 -0
- package/dist/providers.js.map +1 -0
- package/dist/validate-key.d.ts +13 -0
- package/dist/validate-key.d.ts.map +1 -0
- package/dist/validate-key.js +53 -0
- package/dist/validate-key.js.map +1 -0
- package/package.json +2 -2
package/dist/commands/install.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import chalk from 'chalk';
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
3
2
|
import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
|
4
3
|
import { join } from 'path';
|
|
5
4
|
import { ensureNeuraHome, loadConfig, saveConfig, getNeuraHome, generateAuthToken, } from '../config.js';
|
|
@@ -8,24 +7,13 @@ import { getPlatformLabel } from '../service/detect.js';
|
|
|
8
7
|
import { checkHealth, waitForHealthy } from '../health.js';
|
|
9
8
|
import { hasCoreBinary, getBundledModelsDir } from '../download.js';
|
|
10
9
|
import { findFreePort } from '../port.js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
* Why "never overwrite": users who train their own classifiers with
|
|
16
|
-
* `tools/wake-word/scripts/train.sh` deploy them to the same directory
|
|
17
|
-
* via `deploy.sh`. Overwriting on every `neura install` would clobber
|
|
18
|
-
* their trained models on each upgrade. First-write-wins means the
|
|
19
|
-
* bundled defaults only fill in gaps — once a user has their own
|
|
20
|
-
* `jarvis.onnx` in place, the bundled one stops being used.
|
|
21
|
-
*
|
|
22
|
-
* Returns the list of files actually copied so the caller can print a
|
|
23
|
-
* one-line summary to the user.
|
|
24
|
-
*/
|
|
10
|
+
import { PROVIDER_PRESETS, buildRoutingFromFeatures, getVoiceOptions, } from '../providers.js';
|
|
11
|
+
import { validateProviderKey } from '../validate-key.js';
|
|
12
|
+
// ─── Bundled Model Installer ───────────────────────────────────
|
|
25
13
|
function installBundledModels(neuraHome) {
|
|
26
14
|
const src = getBundledModelsDir();
|
|
27
15
|
if (!existsSync(src))
|
|
28
|
-
return [];
|
|
16
|
+
return [];
|
|
29
17
|
const dest = join(neuraHome, 'models');
|
|
30
18
|
mkdirSync(dest, { recursive: true });
|
|
31
19
|
const copied = [];
|
|
@@ -35,241 +23,579 @@ function installBundledModels(neuraHome) {
|
|
|
35
23
|
continue;
|
|
36
24
|
const destPath = join(dest, entry);
|
|
37
25
|
if (existsSync(destPath))
|
|
38
|
-
continue;
|
|
26
|
+
continue;
|
|
39
27
|
copyFileSync(join(src, entry), destPath);
|
|
40
28
|
copied.push(entry);
|
|
41
29
|
}
|
|
42
30
|
}
|
|
43
31
|
catch {
|
|
44
|
-
// Non-fatal
|
|
45
|
-
// whole install. The core will print a clear warning at connection
|
|
46
|
-
// time if models are missing, and the user can copy them manually.
|
|
32
|
+
// Non-fatal — core warns if models are missing.
|
|
47
33
|
}
|
|
48
34
|
return copied;
|
|
49
35
|
}
|
|
36
|
+
// ─── Helpers ───────────────────────────────────────────────────
|
|
37
|
+
function cancelled() {
|
|
38
|
+
p.outro('Setup cancelled.');
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
/** Restore stdin raw mode on Windows after spinner stops (clack #408). */
|
|
42
|
+
function fixWindowsStdin() {
|
|
43
|
+
if (process.platform === 'win32' && process.stdin.isTTY) {
|
|
44
|
+
process.stdin.setRawMode(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function describeExistingFeatures(config) {
|
|
48
|
+
const features = [];
|
|
49
|
+
if (config.routing?.voice) {
|
|
50
|
+
const mode = config.routing.voice.mode === 'realtime' ? 'realtime' : 'pipeline';
|
|
51
|
+
features.push(`Voice (${mode})`);
|
|
52
|
+
}
|
|
53
|
+
if (config.routing?.vision) {
|
|
54
|
+
features.push(`Vision (${config.routing.vision.mode})`);
|
|
55
|
+
}
|
|
56
|
+
if (config.routing?.text) {
|
|
57
|
+
features.push(`Brain (${config.routing.text.provider})`);
|
|
58
|
+
}
|
|
59
|
+
if (config.routing?.embedding) {
|
|
60
|
+
features.push(`Memory (${config.routing.embedding.provider})`);
|
|
61
|
+
}
|
|
62
|
+
if (config.routing?.worker) {
|
|
63
|
+
features.push(`Agents (${config.routing.worker.provider})`);
|
|
64
|
+
}
|
|
65
|
+
return features;
|
|
66
|
+
}
|
|
67
|
+
// ─── Main Install Command ──────────────────────────────────────
|
|
50
68
|
export async function installCommand(opts = {}) {
|
|
51
69
|
const nonInteractive = !!opts.yes;
|
|
52
70
|
const home = getNeuraHome();
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
71
|
+
// ── Intro ────────────────────────────────────────────────────
|
|
72
|
+
p.intro('Neura Setup');
|
|
73
|
+
// Discover wake words
|
|
74
|
+
ensureNeuraHome();
|
|
75
|
+
const config = loadConfig();
|
|
76
|
+
const seededModels = installBundledModels(home);
|
|
77
|
+
const modelsDir = join(home, 'models');
|
|
78
|
+
const infra = new Set(['melspectrogram', 'embedding_model']);
|
|
79
|
+
let wakeWords = [];
|
|
80
|
+
try {
|
|
81
|
+
wakeWords = readdirSync(modelsDir)
|
|
82
|
+
.filter((f) => f.endsWith('.onnx'))
|
|
83
|
+
.map((f) => f.replace('.onnx', ''))
|
|
84
|
+
.filter((name) => !infra.has(name));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// models dir might not exist
|
|
88
|
+
}
|
|
89
|
+
const platformInfo = `Platform: ${getPlatformLabel()} · Home: ${home}`;
|
|
90
|
+
const wakeInfo = wakeWords.length > 0
|
|
91
|
+
? `Wake words: ${wakeWords.join(', ')} (active: ${config.assistantName ?? 'jarvis'})`
|
|
92
|
+
: '';
|
|
93
|
+
const seedInfo = seededModels.length > 0 ? `Installed ${seededModels.length} wake-word model(s)` : '';
|
|
94
|
+
p.log.info([platformInfo, wakeInfo, seedInfo].filter(Boolean).join('\n'));
|
|
95
|
+
// ── Check if already running ─────────────────────────────────
|
|
96
|
+
const existing = (config.port ?? 0) > 0 ? await checkHealth(config.port ?? 0) : null;
|
|
62
97
|
if (existing) {
|
|
63
|
-
|
|
98
|
+
p.log.success(`Core is already running on port ${existing.port}`);
|
|
64
99
|
if (!nonInteractive) {
|
|
65
|
-
const reinstall = await confirm({
|
|
66
|
-
|
|
67
|
-
|
|
100
|
+
const reinstall = await p.confirm({ message: 'Reinstall?', initialValue: false });
|
|
101
|
+
if (p.isCancel(reinstall) || !reinstall)
|
|
102
|
+
cancelled();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// ── Non-interactive mode: skip wizard, go to service registration ──
|
|
106
|
+
if (nonInteractive) {
|
|
107
|
+
await registerServiceAndFinish(config);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// ── Existing config detection ────────────────────────────────
|
|
111
|
+
const existingProviders = Object.keys(config.providers ?? {});
|
|
112
|
+
let keepExisting = false;
|
|
113
|
+
if (existingProviders.length > 0) {
|
|
114
|
+
const features = describeExistingFeatures(config);
|
|
115
|
+
if (features.length > 0) {
|
|
116
|
+
p.log.info(`Existing features: ${features.join(', ')}`);
|
|
117
|
+
}
|
|
118
|
+
const keep = await p.confirm({
|
|
119
|
+
message: "Re-use existing API keys? (you'll still pick features)",
|
|
120
|
+
initialValue: true,
|
|
121
|
+
});
|
|
122
|
+
if (p.isCancel(keep))
|
|
123
|
+
cancelled();
|
|
124
|
+
keepExisting = keep;
|
|
125
|
+
}
|
|
126
|
+
// ═══════════════════════════════════════════════════════════════
|
|
127
|
+
// PHASE 1 — Feature Selection
|
|
128
|
+
// ═══════════════════════════════════════════════════════════════
|
|
129
|
+
p.log.step("Let's configure your features.");
|
|
130
|
+
// ── Voice ────────────────────────────────────────────────────
|
|
131
|
+
const voiceChoice = await p.select({
|
|
132
|
+
message: 'Voice — How Neura speaks and listens',
|
|
133
|
+
options: [
|
|
134
|
+
{
|
|
135
|
+
value: 'realtime',
|
|
136
|
+
label: 'Realtime',
|
|
137
|
+
hint: 'natural, low-latency conversation (xAI Grok)',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
value: 'pipeline',
|
|
141
|
+
label: 'Pipeline',
|
|
142
|
+
hint: 'mix-and-match speech providers (STT → LLM → TTS)',
|
|
143
|
+
},
|
|
144
|
+
{ value: 'skip', label: 'Skip', hint: 'no voice, text-only mode' },
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
if (p.isCancel(voiceChoice))
|
|
148
|
+
cancelled();
|
|
149
|
+
const voiceMode = voiceChoice;
|
|
150
|
+
let sttProvider;
|
|
151
|
+
let ttsProvider;
|
|
152
|
+
if (voiceMode === 'pipeline') {
|
|
153
|
+
const stt = await p.select({
|
|
154
|
+
message: 'Speech-to-Text provider',
|
|
155
|
+
options: [{ value: 'deepgram', label: 'Deepgram', hint: 'recommended' }],
|
|
156
|
+
});
|
|
157
|
+
if (p.isCancel(stt))
|
|
158
|
+
cancelled();
|
|
159
|
+
sttProvider = stt;
|
|
160
|
+
const tts = await p.select({
|
|
161
|
+
message: 'Text-to-Speech provider',
|
|
162
|
+
options: [
|
|
163
|
+
{ value: 'elevenlabs', label: 'ElevenLabs', hint: 'recommended' },
|
|
164
|
+
{ value: 'openai', label: 'OpenAI' },
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
if (p.isCancel(tts))
|
|
168
|
+
cancelled();
|
|
169
|
+
ttsProvider = tts;
|
|
170
|
+
}
|
|
171
|
+
// ── Vision ───────────────────────────────────────────────────
|
|
172
|
+
const visionChoice = await p.select({
|
|
173
|
+
message: 'Vision — How Neura sees your screen and camera',
|
|
174
|
+
options: [
|
|
175
|
+
{
|
|
176
|
+
value: 'streaming',
|
|
177
|
+
label: 'Streaming',
|
|
178
|
+
hint: 'continuous awareness, ~0.5 FPS (Google Gemini)',
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
value: 'snapshot',
|
|
182
|
+
label: 'Snapshot',
|
|
183
|
+
hint: 'on-demand "what am I looking at?" (OpenAI, Anthropic)',
|
|
184
|
+
},
|
|
185
|
+
{ value: 'skip', label: 'Skip', hint: 'no visual awareness' },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
if (p.isCancel(visionChoice))
|
|
189
|
+
cancelled();
|
|
190
|
+
const visionMode = visionChoice;
|
|
191
|
+
let snapshotProvider;
|
|
192
|
+
if (visionMode === 'snapshot') {
|
|
193
|
+
const snap = await p.select({
|
|
194
|
+
message: 'Snapshot vision provider',
|
|
195
|
+
options: [
|
|
196
|
+
{ value: 'openai', label: 'OpenAI', hint: 'GPT-4.1' },
|
|
197
|
+
{ value: 'anthropic', label: 'Anthropic', hint: 'Claude' },
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
if (p.isCancel(snap))
|
|
201
|
+
cancelled();
|
|
202
|
+
snapshotProvider = snap;
|
|
203
|
+
}
|
|
204
|
+
// ── Brain (required) ─────────────────────────────────────────
|
|
205
|
+
const brainChoice = await p.select({
|
|
206
|
+
message: 'Brain — The core AI that powers thinking, tools, and conversation (required)',
|
|
207
|
+
options: [
|
|
208
|
+
{ value: 'google', label: 'Google Gemini', hint: 'recommended' },
|
|
209
|
+
{ value: 'openai', label: 'OpenAI' },
|
|
210
|
+
{ value: 'anthropic', label: 'Anthropic (Claude)' },
|
|
211
|
+
{ value: 'xai', label: 'xAI (Grok)' },
|
|
212
|
+
{ value: 'openrouter', label: 'OpenRouter', hint: 'gateway to many models' },
|
|
213
|
+
{ value: 'vercel', label: 'Vercel AI Gateway', hint: 'multi-provider gateway' },
|
|
214
|
+
{ value: 'custom', label: 'Custom', hint: 'any OpenAI-compatible endpoint' },
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
if (p.isCancel(brainChoice))
|
|
218
|
+
cancelled();
|
|
219
|
+
const brainProvider = brainChoice;
|
|
220
|
+
let customProvider;
|
|
221
|
+
if (brainProvider === 'custom') {
|
|
222
|
+
const name = await p.text({
|
|
223
|
+
message: 'Provider name (e.g. together, groq):',
|
|
224
|
+
validate: (v) => (!v || v.length === 0 ? 'Required' : undefined),
|
|
225
|
+
});
|
|
226
|
+
if (p.isCancel(name))
|
|
227
|
+
cancelled();
|
|
228
|
+
const baseUrl = await p.text({
|
|
229
|
+
message: 'Base URL (e.g. https://api.together.xyz/v1):',
|
|
230
|
+
validate: (v) => {
|
|
231
|
+
if (!v || v.length === 0)
|
|
232
|
+
return 'Required';
|
|
233
|
+
if (!v.startsWith('http'))
|
|
234
|
+
return 'Must start with http:// or https://';
|
|
235
|
+
return undefined;
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
if (p.isCancel(baseUrl))
|
|
239
|
+
cancelled();
|
|
240
|
+
const model = await p.text({
|
|
241
|
+
message: 'Default model (e.g. meta-llama/Llama-4-Scout-17B-16E):',
|
|
242
|
+
validate: (v) => (!v || v.length === 0 ? 'Required' : undefined),
|
|
243
|
+
});
|
|
244
|
+
if (p.isCancel(model))
|
|
245
|
+
cancelled();
|
|
246
|
+
customProvider = { name: name, baseUrl: baseUrl, model: model };
|
|
247
|
+
}
|
|
248
|
+
// ── Memory ───────────────────────────────────────────────────
|
|
249
|
+
const memoryChoice = await p.select({
|
|
250
|
+
message: 'Memory — Remember things across conversations',
|
|
251
|
+
options: [
|
|
252
|
+
{
|
|
253
|
+
value: 'google',
|
|
254
|
+
label: 'Google Gemini Embedding',
|
|
255
|
+
hint: 'recommended, best recall quality',
|
|
256
|
+
},
|
|
257
|
+
{ value: 'openai', label: 'OpenAI Embedding' },
|
|
258
|
+
{ value: 'vercel', label: 'Vercel AI Gateway Embedding' },
|
|
259
|
+
{ value: 'skip', label: 'Skip', hint: 'keyword search only, no semantic recall' },
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
if (p.isCancel(memoryChoice))
|
|
263
|
+
cancelled();
|
|
264
|
+
const memoryProvider = memoryChoice;
|
|
265
|
+
// ── Agents ───────────────────────────────────────────────────
|
|
266
|
+
const wantAgents = await p.confirm({
|
|
267
|
+
message: 'Agents — Delegate complex work to AI that operates independently and reports back',
|
|
268
|
+
initialValue: true,
|
|
269
|
+
});
|
|
270
|
+
if (p.isCancel(wantAgents))
|
|
271
|
+
cancelled();
|
|
272
|
+
let agentProvider = 'skip';
|
|
273
|
+
if (wantAgents) {
|
|
274
|
+
// Collect all text-capable providers the user has already selected
|
|
275
|
+
const textProviders = new Set();
|
|
276
|
+
if (voiceMode === 'realtime')
|
|
277
|
+
textProviders.add('xai');
|
|
278
|
+
if (brainProvider !== 'custom')
|
|
279
|
+
textProviders.add(brainProvider);
|
|
280
|
+
if (customProvider)
|
|
281
|
+
textProviders.add(customProvider.name);
|
|
282
|
+
// If vision streaming selected, google is available too
|
|
283
|
+
if (visionMode === 'streaming')
|
|
284
|
+
textProviders.add('google');
|
|
285
|
+
const providerOptions = [...textProviders]
|
|
286
|
+
.filter((id) => PROVIDER_PRESETS[id]?.capabilities.includes('worker') || id === customProvider?.name)
|
|
287
|
+
.map((id) => ({
|
|
288
|
+
value: id,
|
|
289
|
+
label: PROVIDER_PRESETS[id]?.label ?? id,
|
|
290
|
+
}));
|
|
291
|
+
if (providerOptions.length > 1) {
|
|
292
|
+
const agent = await p.select({
|
|
293
|
+
message: 'Agent provider',
|
|
294
|
+
options: providerOptions,
|
|
68
295
|
});
|
|
69
|
-
if (
|
|
70
|
-
|
|
296
|
+
if (p.isCancel(agent))
|
|
297
|
+
cancelled();
|
|
298
|
+
agentProvider = agent;
|
|
299
|
+
}
|
|
300
|
+
else if (providerOptions.length === 1) {
|
|
301
|
+
agentProvider = providerOptions[0].value;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
agentProvider =
|
|
305
|
+
brainProvider === 'custom' && customProvider ? customProvider.name : brainProvider;
|
|
71
306
|
}
|
|
72
|
-
// --yes: proceed without asking (this is exactly the post-update path)
|
|
73
307
|
}
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
308
|
+
// ═══════════════════════════════════════════════════════════════
|
|
309
|
+
// Build routing from feature selections
|
|
310
|
+
// ═══════════════════════════════════════════════════════════════
|
|
311
|
+
const selections = {
|
|
312
|
+
voice: voiceMode,
|
|
313
|
+
sttProvider,
|
|
314
|
+
ttsProvider,
|
|
315
|
+
vision: visionMode,
|
|
316
|
+
snapshotProvider,
|
|
317
|
+
brainProvider,
|
|
318
|
+
memoryProvider,
|
|
319
|
+
agentProvider,
|
|
320
|
+
customProvider,
|
|
321
|
+
};
|
|
322
|
+
const result = buildRoutingFromFeatures(selections);
|
|
323
|
+
// ═══════════════════════════════════════════════════════════════
|
|
324
|
+
// PHASE 2 — API Key Collection
|
|
325
|
+
// ═══════════════════════════════════════════════════════════════
|
|
326
|
+
const keyLines = [];
|
|
327
|
+
for (const [providerId, features] of result.requiredProviders) {
|
|
328
|
+
const preset = PROVIDER_PRESETS[providerId];
|
|
329
|
+
const label = preset?.label ?? providerId;
|
|
330
|
+
const url = preset?.consoleUrl ?? '';
|
|
331
|
+
keyLines.push(`${label} → ${features.join(', ')}${url ? `\n ${url}` : ''}`);
|
|
332
|
+
}
|
|
333
|
+
p.note(keyLines.join('\n\n'), `You need ${result.requiredProviders.size} API key(s)`);
|
|
334
|
+
// Collect keys
|
|
335
|
+
const collectedKeys = {};
|
|
336
|
+
// Carry over existing keys if keeping config
|
|
337
|
+
if (keepExisting && config.providers) {
|
|
338
|
+
for (const [id, creds] of Object.entries(config.providers)) {
|
|
339
|
+
collectedKeys[id] = { ...creds };
|
|
92
340
|
}
|
|
93
|
-
|
|
94
|
-
|
|
341
|
+
}
|
|
342
|
+
for (const [providerId] of result.requiredProviders) {
|
|
343
|
+
const preset = PROVIDER_PRESETS[providerId];
|
|
344
|
+
const label = preset?.label ?? providerId;
|
|
345
|
+
const existingKey = collectedKeys[providerId]?.apiKey;
|
|
346
|
+
// Custom provider — use the user-provided baseUrl
|
|
347
|
+
const isCustom = !preset && providerId === customProvider?.name;
|
|
348
|
+
const baseUrl = isCustom ? customProvider.baseUrl : undefined;
|
|
349
|
+
const hint = existingKey ? ' (press Enter to keep existing)' : '';
|
|
350
|
+
const key = await p.password({
|
|
351
|
+
message: `${label} API Key${hint}:`,
|
|
352
|
+
mask: '*',
|
|
353
|
+
});
|
|
354
|
+
if (p.isCancel(key))
|
|
355
|
+
cancelled();
|
|
356
|
+
let finalKey = key || existingKey || '';
|
|
357
|
+
if (!finalKey) {
|
|
358
|
+
p.log.warn(`No key provided for ${label} — features using this provider will be unavailable.`);
|
|
359
|
+
continue;
|
|
95
360
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
361
|
+
// Validate
|
|
362
|
+
const s = p.spinner();
|
|
363
|
+
s.start(`Validating ${label} key...`);
|
|
364
|
+
const vr = await validateProviderKey(providerId, finalKey, baseUrl);
|
|
365
|
+
fixWindowsStdin();
|
|
366
|
+
let validated = false;
|
|
367
|
+
if (vr.valid) {
|
|
368
|
+
s.stop(`${label}: Valid`);
|
|
369
|
+
validated = true;
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
s.stop(`${label}: ${vr.error ?? 'Invalid key'}`);
|
|
373
|
+
fixWindowsStdin();
|
|
374
|
+
p.log.warn(`Validation failed: ${vr.error ?? 'Invalid key'}`);
|
|
375
|
+
const retry = await p.confirm({
|
|
376
|
+
message: `Try a different ${label} key?`,
|
|
377
|
+
initialValue: true,
|
|
378
|
+
});
|
|
379
|
+
if (p.isCancel(retry))
|
|
380
|
+
cancelled();
|
|
381
|
+
if (retry) {
|
|
382
|
+
const retryKey = await p.password({
|
|
383
|
+
message: `${label} API Key:`,
|
|
384
|
+
mask: '*',
|
|
385
|
+
});
|
|
386
|
+
if (p.isCancel(retryKey))
|
|
387
|
+
cancelled();
|
|
388
|
+
if (retryKey) {
|
|
389
|
+
const vr2 = await validateProviderKey(providerId, retryKey, baseUrl);
|
|
390
|
+
fixWindowsStdin();
|
|
391
|
+
if (vr2.valid) {
|
|
392
|
+
finalKey = retryKey;
|
|
393
|
+
p.log.success(`${label}: Valid`);
|
|
394
|
+
validated = true;
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
p.log.warn(`Retry failed: ${vr2.error ?? 'Invalid key'}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
101
400
|
}
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
401
|
+
if (!validated) {
|
|
402
|
+
const continueAnyway = await p.confirm({
|
|
403
|
+
message: `Continue without a valid ${label} key?`,
|
|
404
|
+
initialValue: false,
|
|
405
|
+
});
|
|
406
|
+
if (p.isCancel(continueAnyway))
|
|
407
|
+
cancelled();
|
|
408
|
+
if (!continueAnyway)
|
|
409
|
+
cancelled();
|
|
105
410
|
}
|
|
106
411
|
}
|
|
412
|
+
if (validated) {
|
|
413
|
+
collectedKeys[providerId] = { apiKey: finalKey, baseUrl };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// ═══════════════════════════════════════════════════════════════
|
|
417
|
+
// PHASE 3 — Final Config
|
|
418
|
+
// ═══════════════════════════════════════════════════════════════
|
|
419
|
+
// Show configuration summary
|
|
420
|
+
const summaryLines = [];
|
|
421
|
+
const r = result.routing;
|
|
422
|
+
if (r.voice) {
|
|
423
|
+
if (r.voice.mode === 'realtime') {
|
|
424
|
+
summaryLines.push(`✓ Voice: Realtime (${r.voice.provider} / ${r.voice.model})`);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
summaryLines.push(`✓ Voice: Pipeline (STT: ${r.voice.stt.provider}, LLM: ${r.voice.llm.provider}, TTS: ${r.voice.tts.provider})`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
summaryLines.push('✗ Voice: Skipped');
|
|
432
|
+
}
|
|
433
|
+
if (r.vision) {
|
|
434
|
+
summaryLines.push(`✓ Vision: ${r.vision.mode === 'streaming' ? 'Streaming' : 'Snapshot'} (${r.vision.provider} / ${r.vision.model})`);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
summaryLines.push('✗ Vision: Skipped');
|
|
438
|
+
}
|
|
439
|
+
if (r.text) {
|
|
440
|
+
summaryLines.push(`✓ Brain: ${r.text.provider} / ${r.text.model}`);
|
|
441
|
+
}
|
|
442
|
+
if (r.embedding) {
|
|
443
|
+
summaryLines.push(`✓ Memory: ${r.embedding.provider} / ${r.embedding.model} (${r.embedding.dimensions}d)`);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
summaryLines.push('✗ Memory: Skipped');
|
|
447
|
+
}
|
|
448
|
+
if (r.worker) {
|
|
449
|
+
summaryLines.push(`✓ Agents: ${r.worker.provider} / ${r.worker.model}`);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
summaryLines.push('✗ Agents: Skipped');
|
|
453
|
+
}
|
|
454
|
+
if (result.warnings.length > 0) {
|
|
455
|
+
summaryLines.push('');
|
|
456
|
+
for (const w of result.warnings) {
|
|
457
|
+
summaryLines.push(`⚠ ${w}`);
|
|
458
|
+
}
|
|
107
459
|
}
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
// flow should not re-prompt for them.
|
|
111
|
-
let xaiKey = config.providers?.xai?.apiKey ?? '';
|
|
112
|
-
let googleKey = config.providers?.google?.apiKey ?? '';
|
|
113
|
-
if (!nonInteractive) {
|
|
114
|
-
console.log(chalk.dim(' API Keys'));
|
|
115
|
-
xaiKey =
|
|
116
|
-
(await password({
|
|
117
|
-
message: `XAI_API_KEY${xaiKey ? ' (press Enter to keep existing)' : ''}:`,
|
|
118
|
-
mask: '*',
|
|
119
|
-
})) || xaiKey;
|
|
120
|
-
googleKey =
|
|
121
|
-
(await password({
|
|
122
|
-
message: `GOOGLE_API_KEY${googleKey ? ' (press Enter to keep existing)' : ''}:`,
|
|
123
|
-
mask: '*',
|
|
124
|
-
})) || googleKey;
|
|
125
|
-
}
|
|
126
|
-
// Port — auto-assign unless user already has one configured
|
|
127
|
-
console.log();
|
|
128
|
-
console.log(chalk.dim(' Port'));
|
|
460
|
+
p.note(summaryLines.join('\n'), 'Configuration Summary');
|
|
461
|
+
// ── Port ─────────────────────────────────────────────────────
|
|
129
462
|
let port;
|
|
463
|
+
let portSource;
|
|
130
464
|
if ((config.port ?? 0) > 0) {
|
|
131
|
-
// User has a previously configured port — keep it
|
|
132
465
|
port = config.port;
|
|
133
|
-
|
|
466
|
+
portSource = 'configured';
|
|
134
467
|
}
|
|
135
468
|
else {
|
|
136
|
-
// Auto-assign a free port in the 18000-19000 range
|
|
137
469
|
port = await findFreePort();
|
|
138
|
-
|
|
470
|
+
portSource = 'auto-assigned';
|
|
139
471
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
472
|
+
const customPort = await p.text({
|
|
473
|
+
message: `Port: ${port} (${portSource})`,
|
|
474
|
+
placeholder: 'press Enter to accept, or type a custom port',
|
|
475
|
+
defaultValue: String(port),
|
|
476
|
+
validate: (v) => {
|
|
477
|
+
if (!v || v === '' || v === String(port))
|
|
478
|
+
return undefined;
|
|
479
|
+
if (!/^\d+$/.test(v))
|
|
480
|
+
return 'Must be a number';
|
|
481
|
+
const n = parseInt(v, 10);
|
|
482
|
+
if (n < 1 || n > 65535)
|
|
483
|
+
return 'Must be 1-65535';
|
|
484
|
+
return undefined;
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
if (p.isCancel(customPort))
|
|
488
|
+
cancelled();
|
|
489
|
+
port = parseInt(customPort, 10);
|
|
490
|
+
// ── Voice selection ──────────────────────────────────────────
|
|
491
|
+
let voice;
|
|
492
|
+
const voiceOpts = getVoiceOptions(selections);
|
|
493
|
+
if (voiceOpts) {
|
|
494
|
+
const voiceSelect = await p.select({
|
|
495
|
+
message: 'Voice',
|
|
496
|
+
options: voiceOpts.voices,
|
|
497
|
+
initialValue: voiceOpts.defaultVoice,
|
|
154
498
|
});
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
499
|
+
if (p.isCancel(voiceSelect))
|
|
500
|
+
cancelled();
|
|
501
|
+
voice = voiceSelect;
|
|
158
502
|
}
|
|
159
|
-
//
|
|
160
|
-
const currentVoice = config.routing?.voice?.mode === 'realtime'
|
|
161
|
-
? (config.routing.voice.voice ?? 'eve')
|
|
162
|
-
: 'eve';
|
|
163
|
-
const voice = nonInteractive
|
|
164
|
-
? currentVoice
|
|
165
|
-
: await input({
|
|
166
|
-
message: 'Voice:',
|
|
167
|
-
default: currentVoice,
|
|
168
|
-
});
|
|
169
|
-
// Generate auth token if not already set
|
|
503
|
+
// ── Assemble and save config ─────────────────────────────────
|
|
170
504
|
if (!config.authToken) {
|
|
171
505
|
config.authToken = generateAuthToken();
|
|
172
506
|
}
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
179
|
-
config.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
507
|
+
// Merge collected keys into providers
|
|
508
|
+
config.providers = { ...(keepExisting ? config.providers : {}), ...collectedKeys };
|
|
509
|
+
// Set routing
|
|
510
|
+
config.routing = result.routing;
|
|
511
|
+
// Apply voice to the route
|
|
512
|
+
if (voice && config.routing.voice) {
|
|
513
|
+
if (config.routing.voice.mode === 'realtime') {
|
|
514
|
+
config.routing.voice.voice = voice;
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
config.routing.voice.tts.voice = voice;
|
|
518
|
+
}
|
|
183
519
|
}
|
|
520
|
+
config.port = port;
|
|
184
521
|
saveConfig(config);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
522
|
+
p.log.info(`Config saved to ${home}/config.json`);
|
|
523
|
+
// ── Service registration & health check ──────────────────────
|
|
524
|
+
await registerServiceAndFinish(config);
|
|
525
|
+
}
|
|
526
|
+
// ─── Service Registration (shared by interactive and --yes) ────
|
|
527
|
+
async function registerServiceAndFinish(config) {
|
|
528
|
+
// Sanity check — core is bundled inside this npm package
|
|
191
529
|
if (!hasCoreBinary()) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
'
|
|
195
|
-
' npm install -g @mclean-capital/neura@latest'));
|
|
196
|
-
console.log();
|
|
530
|
+
p.log.error('Core bundle not found inside this CLI install.\n' +
|
|
531
|
+
'This indicates a broken installation. Fix with:\n' +
|
|
532
|
+
' npm install -g @mclean-capital/neura@latest');
|
|
197
533
|
return;
|
|
198
534
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
console.log(chalk.dim(' Registering service...'));
|
|
535
|
+
const s = p.spinner();
|
|
536
|
+
s.start('Registering service...');
|
|
202
537
|
let serviceRegistered = false;
|
|
203
538
|
try {
|
|
204
539
|
const svc = await getServiceManager();
|
|
205
540
|
const wasInstalled = svc.isInstalled();
|
|
206
|
-
// Always (re)write the service definition. If we only restarted when the
|
|
207
|
-
// service was already registered, an upgrade that fixes the service file
|
|
208
|
-
// (e.g. macOS plist ProgramArguments, systemd ExecStart) would never take
|
|
209
|
-
// effect — the on-disk file would stay stale. Stop the old service first
|
|
210
|
-
// so the new file gets cleanly loaded.
|
|
211
541
|
if (wasInstalled) {
|
|
212
542
|
try {
|
|
213
543
|
svc.stop();
|
|
214
544
|
}
|
|
215
545
|
catch {
|
|
216
|
-
// Service may not be running
|
|
546
|
+
// Service may not be running
|
|
217
547
|
}
|
|
218
548
|
}
|
|
219
549
|
await svc.install();
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
// used so they understand what to expect — e.g. Task Scheduler
|
|
224
|
-
// manageability vs. runs-on-next-login. Empty import on non-Windows.
|
|
550
|
+
s.stop(`Service ${wasInstalled ? 're-registered' : 'registered'} (${getPlatformLabel()})`);
|
|
551
|
+
fixWindowsStdin();
|
|
552
|
+
// Windows install mode info
|
|
225
553
|
if (process.platform === 'win32') {
|
|
226
554
|
const win = await import('../service/windows.js');
|
|
227
555
|
const mode = win.getLastInstallMode();
|
|
228
556
|
if (mode === 'startup-shim') {
|
|
229
|
-
|
|
230
|
-
'
|
|
231
|
-
' policy or corporate restrictions. The core will still run\n' +
|
|
232
|
-
' at each user login. It can be removed from\n' +
|
|
233
|
-
' %APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\.)'));
|
|
557
|
+
p.log.info('Using Startup folder shim — Scheduled Task registration was not available.\n' +
|
|
558
|
+
'Core will still run at each user login.');
|
|
234
559
|
}
|
|
235
560
|
else if (mode === 'scheduled-task') {
|
|
236
|
-
|
|
561
|
+
p.log.info('Registered in Task Scheduler under name "neura-core"');
|
|
237
562
|
}
|
|
238
563
|
}
|
|
239
564
|
svc.start();
|
|
240
565
|
serviceRegistered = true;
|
|
241
566
|
}
|
|
242
567
|
catch (err) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
568
|
+
s.stop('Service registration skipped');
|
|
569
|
+
fixWindowsStdin();
|
|
570
|
+
p.log.warn(err instanceof Error ? err.message : String(err));
|
|
571
|
+
p.log.info('Config was saved. Try again after resolving the issue.');
|
|
246
572
|
}
|
|
247
|
-
// Wait for health
|
|
573
|
+
// Wait for health
|
|
248
574
|
if (serviceRegistered) {
|
|
249
|
-
|
|
575
|
+
const hs = p.spinner();
|
|
576
|
+
hs.start('Waiting for core...');
|
|
250
577
|
const health = await waitForHealthy(config.port ?? 0);
|
|
251
578
|
if (health) {
|
|
252
|
-
|
|
253
|
-
|
|
579
|
+
hs.stop(`Core healthy on port ${health.port}`);
|
|
580
|
+
fixWindowsStdin();
|
|
254
581
|
}
|
|
255
582
|
else {
|
|
256
|
-
|
|
583
|
+
hs.stop('Core did not respond within 15s');
|
|
584
|
+
fixWindowsStdin();
|
|
585
|
+
p.log.warn('Check logs: neura logs');
|
|
257
586
|
}
|
|
258
587
|
}
|
|
259
|
-
|
|
588
|
+
// ── Done ─────────────────────────────────────────────────────
|
|
260
589
|
if (serviceRegistered) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.log(' Logs: neura logs');
|
|
590
|
+
p.note('Desktop: Open the Neura desktop app\n' +
|
|
591
|
+
'Web: neura open\n' +
|
|
592
|
+
'Status: neura status\n' +
|
|
593
|
+
'Logs: neura logs', 'Ready!');
|
|
266
594
|
}
|
|
267
595
|
else {
|
|
268
|
-
|
|
269
|
-
console.log(' neura start Start the service');
|
|
270
|
-
console.log(' neura status Check service state');
|
|
596
|
+
p.note('neura start Start the service\n' + 'neura status Check service state', 'Config saved. Service not yet running.');
|
|
271
597
|
}
|
|
272
|
-
|
|
598
|
+
p.outro('Setup complete.');
|
|
273
599
|
}
|
|
274
600
|
// Exported for tests only.
|
|
275
601
|
export const __test__ = {
|