@ours.network/install 0.14.0 → 0.15.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/README.md +18 -8
- package/install.mjs +142 -14
- package/lib/config.mjs +65 -0
- package/lib/logic.mjs +83 -0
- package/lib/prompt.mjs +40 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @ours.network/install — `ours-install`
|
|
2
2
|
|
|
3
3
|
The **unified ours.network stack installer**. ONE guided ~3-minute flow that installs the WHOLE
|
|
4
|
-
stack for someone who already has Claude Code and/or
|
|
5
|
-
prompt
|
|
4
|
+
stack for someone who already has Claude Code, Codex, and/or Hermes, safely offers optional
|
|
5
|
+
voice-message transcription, then hands back a single copy-paste prompt for remaining setup.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -43,14 +43,20 @@ dependency on the things it installs): an ASCII banner, tasteful colour (degrade
|
|
|
43
43
|
answers `--version` promptly. A shell alias / hanging wrapper is **never called** (that would
|
|
44
44
|
hang the run) — it's reported plainly with a fix, and a manual-install path is always offered.
|
|
45
45
|
If neither harness exists it says so and exits.
|
|
46
|
-
2. **Config-first** (first install only) — the
|
|
46
|
+
2. **Config-first** (first install only) — the daemon's two base settings, up front:
|
|
47
47
|
the **broker** (end-to-end encrypted; the broker never sees message content — almost everyone
|
|
48
48
|
just presses Enter) and the **port** (probes `3050`; only asks if it's busy; never hands out
|
|
49
49
|
`3051`, reserved for the Telegram connector). Applied once, then the stack is built with it.
|
|
50
50
|
3. **Four consent gates**, each paced with a clean `✓ … No problems.` line + an explicit
|
|
51
51
|
**Continue?** — never a start-twice-then-ask, never a silent failure:
|
|
52
|
-
- **1/4 ours core (the daemon)** — write config → install/start ONCE
|
|
53
|
-
re-run it reuses the running config (no re-ask) and only updates when
|
|
52
|
+
- **1/4 ours core (the daemon)** — write config → optional voice setup → install/start ONCE
|
|
53
|
+
→ boot service. On a re-run it reuses the running config (no re-ask) and only updates when
|
|
54
|
+
you say yes. Complete voice setup is kept without prompting. Missing/incomplete setup is
|
|
55
|
+
offered before the first start or pending update restart, then delegated to the canonical
|
|
56
|
+
`ours-mcp voice-setup` provider selector and hidden API-key prompt. Accepted setup owns the
|
|
57
|
+
one restart/readiness transaction; declining or already-ready setup preserves the normal
|
|
58
|
+
core lifecycle. The secret is written atomically to mode-`0600` config; a failed daemon
|
|
59
|
+
reload rolls back.
|
|
54
60
|
- **2/4 harness plugins** — the installer **drives the plugin CLIs itself**
|
|
55
61
|
(`claude plugin marketplace add …` + `claude plugin install ours@ours.network`;
|
|
56
62
|
`codex plugin marketplace add …` + `codex plugin add ours@ours-codex-marketplace`). Choosing
|
|
@@ -64,7 +70,7 @@ dependency on the things it installs): an ASCII banner, tasteful colour (degrade
|
|
|
64
70
|
copy-paste prompt** (root identity + fleet + Telegram) with the steps for any skipped/failed
|
|
65
71
|
component dropped out. Copied to the clipboard where supported.
|
|
66
72
|
|
|
67
|
-
The
|
|
73
|
+
The human identity is created idempotently after the daemon becomes reachable. Because
|
|
68
74
|
`curl … | bash` gives the script its input over the pipe, every prompt is read from the
|
|
69
75
|
controlling terminal (`/dev/tty`), so the flow still works piped.
|
|
70
76
|
|
|
@@ -80,6 +86,10 @@ exactly the commands it *would* run (npm installs, `ours-mcp start`, plugin adds
|
|
|
80
86
|
init`, service installs) without executing them. That is the safe way to preview the flow on a
|
|
81
87
|
machine you don't want to touch, and how the integration tests drive it.
|
|
82
88
|
|
|
89
|
+
Non-interactive runs never prompt for or synthesize voice credentials. Supply a complete
|
|
90
|
+
`OURS_STT_*` environment configuration yourself, or rerun interactively later; missing setup
|
|
91
|
+
is reported and left unchanged.
|
|
92
|
+
|
|
83
93
|
| var | meaning |
|
|
84
94
|
|---|---|
|
|
85
95
|
| `OURS_ASSUME_YES` | accept every default, never prompt (implies no tty needed) |
|
|
@@ -132,5 +142,5 @@ OURS_UNINSTALL_DAEMON=yes \
|
|
|
132
142
|
published components.
|
|
133
143
|
- **Idempotent + safe to re-run.** A re-run adds a skipped piece, re-points the plugins, or (only
|
|
134
144
|
when you say yes) updates a component; an already-current daemon is left untouched, its running
|
|
135
|
-
port reused everywhere.
|
|
136
|
-
|
|
145
|
+
port and complete voice setup are reused everywhere. Bot tokens and fleet roles remain in the
|
|
146
|
+
copy-paste hand-off; provider keys never enter that prompt or agent chat.
|
package/install.mjs
CHANGED
|
@@ -6,27 +6,30 @@
|
|
|
6
6
|
// Codex / Hermes) + ours-fleet + the Telegram connector — for someone who ALREADY has Claude,
|
|
7
7
|
// Codex, and/or Hermes.
|
|
8
8
|
// Its whole job: install the stack cleanly, then hand back ONE copy-paste prompt the user drops
|
|
9
|
-
// into their agent to finish
|
|
10
|
-
//
|
|
9
|
+
// into their agent to finish remaining configuration conversationally. Voice API credentials are
|
|
10
|
+
// the one guided secret flow: interactive, masked, optional, and written atomically with mode 0600.
|
|
11
|
+
// See packages/installer/README.md and the UX spec for the full contract.
|
|
11
12
|
//
|
|
12
13
|
// Design pillars (from the spec): config FIRST then act once; consent-first (Enter = no change);
|
|
13
14
|
// slow, per-step "✓ … no problems" + Continue?; never silently broken; idempotent + safe re-run;
|
|
14
|
-
// alias-safety / never-hang; deep config deferred to the copy-paste hand-off.
|
|
15
|
+
// alias-safety / never-hang; most deep config deferred to the copy-paste hand-off.
|
|
15
16
|
//
|
|
16
17
|
// SAFETY: every side-effecting action goes through act(); with OURS_INSTALL_DRY_RUN=1 nothing is
|
|
17
18
|
// installed/started/restarted — it prints exactly what it WOULD do. That is the safe way to walk
|
|
18
19
|
// the whole flow on a machine you don't want to touch (and how the tests drive it).
|
|
19
20
|
import { spawn, spawnSync } from 'node:child_process';
|
|
20
|
-
import { readFileSync,
|
|
21
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
21
22
|
import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
|
|
22
|
-
import { join
|
|
23
|
+
import { join } from 'node:path';
|
|
23
24
|
import { banner, heading, ok, info, warn, c, box, withSpinner, openTty, makeWriter, closeSync } from './lib/ui.mjs';
|
|
24
25
|
import { askLine, askYesNo, isCancel } from './lib/prompt.mjs';
|
|
25
26
|
import {
|
|
26
27
|
suggestPort, parsePort, validateBroker, mergeConfig, parseVersion, parseStatus,
|
|
27
28
|
detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
|
|
29
|
+
voiceSetupStatus,
|
|
28
30
|
DEFAULT_PORT, resolveChannel, pkgSpec,
|
|
29
31
|
} from './lib/logic.mjs';
|
|
32
|
+
import { atomicWriteConfig } from './lib/config.mjs';
|
|
30
33
|
|
|
31
34
|
const NPM = process.env.OURS_NPM || 'npm';
|
|
32
35
|
// Release channel: OURS_CHANNEL=nightly installs @nightly for mcp/tg-connector/plugin
|
|
@@ -85,7 +88,12 @@ async function actSpin(label, desc, fn) {
|
|
|
85
88
|
// --- daemon probes (always safe to run — read-only) --------------------------------------------
|
|
86
89
|
const daemonVersionLine = () => (run('ours-mcp', ['--version'], { capture: true }).out.split('\n')[0] || '').trim();
|
|
87
90
|
const daemonStatusText = () => run('ours-mcp', ['status'], { capture: true }).out;
|
|
88
|
-
|
|
91
|
+
function daemonLifecycleState() {
|
|
92
|
+
const status = run('ours-mcp', ['status'], { capture: true });
|
|
93
|
+
if (!status.ok) return 'stopped';
|
|
94
|
+
return /^\s*pid:\s*\d+/m.test(status.out) ? 'managed' : 'external';
|
|
95
|
+
}
|
|
96
|
+
const daemonRunning = () => daemonLifecycleState() !== 'stopped';
|
|
89
97
|
const globalVersion = (pkg) => {
|
|
90
98
|
const ls = run(NPM, ['ls', '-g', pkg], { capture: true }).out;
|
|
91
99
|
const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@([0-9][0-9.]*)'));
|
|
@@ -97,11 +105,21 @@ function configPath() { return process.env.OURS_CONFIG || join(homedir(), '.ours
|
|
|
97
105
|
function readConfigObject() { try { return JSON.parse(readFileSync(configPath(), 'utf8')); } catch { return {}; } }
|
|
98
106
|
function writeConfigPatch(patch) {
|
|
99
107
|
const p = configPath();
|
|
100
|
-
|
|
101
|
-
writeFileSync(p, mergeConfig(readConfigObject(), patch));
|
|
108
|
+
atomicWriteConfig(p, mergeConfig(readConfigObject(), patch));
|
|
102
109
|
return p;
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
function daemonVoiceCapability() {
|
|
113
|
+
const r = run('ours-mcp', ['voice-status', '--json'], { capture: true, timeout: 6000 });
|
|
114
|
+
if (!r.ok) return null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(r.out.trim());
|
|
117
|
+
return typeof parsed?.ready === 'boolean' ? parsed : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
105
123
|
// Block the thread for `ms` without a subprocess — used for the brief daemon-reachability wait
|
|
106
124
|
// before creating the human identity (a freshly-started daemon needs a moment to bind its port).
|
|
107
125
|
function sleepMs(ms) {
|
|
@@ -318,8 +336,88 @@ async function main() {
|
|
|
318
336
|
const summary = [];
|
|
319
337
|
const record = (row) => summary.push(row);
|
|
320
338
|
|
|
339
|
+
// Voice setup is part of the core lifecycle, not a follow-up after it. On a
|
|
340
|
+
// fresh install this runs after the CLI is installed/configured but before
|
|
341
|
+
// the first daemon start. On an update it runs before any pending restart;
|
|
342
|
+
// a successful canonical voice-setup owns that one restart/readiness check.
|
|
343
|
+
// Declining, headless skipping, or an already-complete setup leaves the
|
|
344
|
+
// caller's normal start/restart lifecycle untouched.
|
|
345
|
+
const offerVoiceSetup = ({ readinessAfterStart = false, daemonState = 'stopped' } = {}) => {
|
|
346
|
+
line(heading('Voice messages'));
|
|
347
|
+
const cfgBefore = readConfigObject();
|
|
348
|
+
const probed = daemonVoiceCapability();
|
|
349
|
+
const localStatus = voiceSetupStatus(cfgBefore, process.env);
|
|
350
|
+
const voiceStatus = probed ?? localStatus;
|
|
351
|
+
if (voiceStatus.ready) {
|
|
352
|
+
line(ok(`Voice transcription is configured (${voiceStatus.provider}). API key: configured, never displayed.`));
|
|
353
|
+
record({ key: 'voice', label: 'Voice transcription', state: 'current', note: voiceStatus.provider });
|
|
354
|
+
cont(false);
|
|
355
|
+
return { setupRan: false, restartHandled: false };
|
|
356
|
+
}
|
|
357
|
+
if (!interactive) {
|
|
358
|
+
line(info('Voice transcription is not configured. Non-interactive mode leaves it unchanged.'));
|
|
359
|
+
line(info('Run `ours-mcp voice-setup` in a terminal, or supply OURS_STT_* environment values.'));
|
|
360
|
+
record({ key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'interactive setup available on re-run' });
|
|
361
|
+
cont(false);
|
|
362
|
+
return { setupRan: false, restartHandled: false };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
line(info('Voice notes can be transcribed by a provider you choose. Audio is sent to that'));
|
|
366
|
+
line(info('provider; use a self-hosted endpoint if it must stay local. The API key is hidden.'));
|
|
367
|
+
if (!yes(' Set up voice transcription now?', true)) {
|
|
368
|
+
line(info('skipped — this will be offered again on the next installer run.'));
|
|
369
|
+
record({ key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'declined; offered again on re-run' });
|
|
370
|
+
cont(false);
|
|
371
|
+
return { setupRan: false, restartHandled: false };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
line(info('Opening the canonical ours-mcp voice setup (same command you can re-run later).'));
|
|
375
|
+
const setupArgs = ['voice-setup', ...(DRY ? ['--dry-run'] : [])];
|
|
376
|
+
const setup = run('ours-mcp', setupArgs);
|
|
377
|
+
if (!setup.ok) {
|
|
378
|
+
line(warn('Voice setup did not complete; no installer-side credential fallback was used.'));
|
|
379
|
+
line(info('Run `ours-mcp voice-setup` directly to try again.'));
|
|
380
|
+
record({ key: 'voice', label: 'Voice transcription', state: 'failed', note: 'ours-mcp voice-setup did not complete' });
|
|
381
|
+
cont();
|
|
382
|
+
// Exit 2 is the canonical command's explicit signal that it already
|
|
383
|
+
// attempted restart/readiness and rollback. Never layer the pending
|
|
384
|
+
// installer update restart on top of that recovery transaction.
|
|
385
|
+
return { setupRan: true, restartHandled: setup.code === 2 };
|
|
386
|
+
}
|
|
387
|
+
if (DRY) {
|
|
388
|
+
line(ok('Canonical voice setup dry-run completed; no config or daemon state changed.'));
|
|
389
|
+
record({ key: 'voice', label: 'Voice transcription', state: 'current', note: 'voice-setup dry-run' });
|
|
390
|
+
cont();
|
|
391
|
+
return { setupRan: true, restartHandled: false };
|
|
392
|
+
}
|
|
393
|
+
if (readinessAfterStart) {
|
|
394
|
+
line(ok('Voice configuration saved securely; it will be readiness-checked after the first daemon start.'));
|
|
395
|
+
record({ key: 'voice', label: 'Voice transcription', state: 'installed', note: 'configured before first start' });
|
|
396
|
+
cont();
|
|
397
|
+
return { setupRan: true, restartHandled: false };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const verified = daemonVoiceCapability();
|
|
401
|
+
if (verified?.ready) {
|
|
402
|
+
line(ok(`Voice transcription is ready (${verified.provider}); API key remains hidden.`));
|
|
403
|
+
record({ key: 'voice', label: 'Voice transcription', state: 'installed', note: verified.provider });
|
|
404
|
+
} else {
|
|
405
|
+
line(warn('Voice setup returned without a ready capability; inspect `ours-mcp voice-status`.'));
|
|
406
|
+
record({ key: 'voice', label: 'Voice transcription', state: 'failed', note: 'readiness check failed' });
|
|
407
|
+
}
|
|
408
|
+
cont();
|
|
409
|
+
// Exit 0 also covers config-only success for a stopped daemon or an
|
|
410
|
+
// externally launched daemon. Only a still-managed daemon proves that
|
|
411
|
+
// canonical setup owned apply + readiness and may suppress an update
|
|
412
|
+
// restart.
|
|
413
|
+
return {
|
|
414
|
+
setupRan: true,
|
|
415
|
+
restartHandled: daemonState === 'managed' && daemonLifecycleState() === 'managed',
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
|
|
321
419
|
// ============================================================================================
|
|
322
|
-
// STEP 1 / 4 — ours core (the daemon). Config-first: write config →
|
|
420
|
+
// STEP 1 / 4 — ours core (the daemon). Config-first: write config → optional voice → start ONCE.
|
|
323
421
|
// ============================================================================================
|
|
324
422
|
line(heading('1/4 — ours core (the daemon)'));
|
|
325
423
|
line(info('This is the piece that lets your agents talk to each other securely. Everything else'));
|
|
@@ -338,25 +436,55 @@ async function main() {
|
|
|
338
436
|
const patch = { port: chosenPort };
|
|
339
437
|
if (chosenBroker) patch.brokerUrl = chosenBroker;
|
|
340
438
|
await act(`write config (${configPath()}) with port ${chosenPort}${chosenBroker ? ' + custom broker' : ''}`, async () => { writeConfigPatch(patch); return { ok: true }; });
|
|
439
|
+
const voice = offerVoiceSetup({ readinessAfterStart: true });
|
|
341
440
|
const started = await act(`ours-mcp start (port ${chosenPort})`, async () => run('ours-mcp', ['start']));
|
|
342
441
|
const svc = await act('ours-mcp install-service (survives reboot)', async () => run('ours-mcp', ['install-service']));
|
|
343
442
|
if (started.ok) line(ok(`ours core ready — running on port ${chosenPort}. No problems.`));
|
|
344
443
|
else line(warn(`could not auto-start — run '${c.cyan('ours-mcp start')}' to bring it up.`));
|
|
345
444
|
if (!svc.ok && !svc.dry) line(warn(`boot-service not installed — retry '${c.cyan('ours-mcp install-service')}' later.`));
|
|
445
|
+
if (voice.setupRan && !DRY && started.ok) {
|
|
446
|
+
const verified = daemonVoiceCapability();
|
|
447
|
+
if (verified?.ready) {
|
|
448
|
+
line(ok(`Voice transcription readiness confirmed (${verified.provider}) after the first start.`));
|
|
449
|
+
} else {
|
|
450
|
+
line(warn('Voice configuration was saved, but readiness was not confirmed after start; run `ours-mcp voice-status`.'));
|
|
451
|
+
const row = summary.find((entry) => entry.key === 'voice');
|
|
452
|
+
if (row) {
|
|
453
|
+
row.state = 'failed';
|
|
454
|
+
row.note = 'readiness check failed after first start';
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
346
458
|
record({ key: 'core', label: 'ours core (daemon)', state: started.ok ? 'installed' : 'failed', version: parseVersion(daemonVersionLine()), note: 'starts on boot' });
|
|
347
459
|
} else {
|
|
348
460
|
// Installed: offer an update; never re-ask config; reuse the running port everywhere.
|
|
349
|
-
const
|
|
461
|
+
const daemonState = daemonLifecycleState();
|
|
462
|
+
const running = daemonState !== 'stopped';
|
|
350
463
|
const upd = yes(` ours core is installed (${before || '?'}) — check for an update now?`, false);
|
|
464
|
+
let pendingUpdateRestart = false;
|
|
465
|
+
let after = before;
|
|
351
466
|
if (upd) {
|
|
352
467
|
await actSpin(`updating ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
|
|
353
|
-
|
|
354
|
-
|
|
468
|
+
after = parseVersion(daemonVersionLine());
|
|
469
|
+
pendingUpdateRestart = !!(before && after && before !== after);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const voice = offerVoiceSetup({ daemonState });
|
|
473
|
+
if (pendingUpdateRestart && !voice.restartHandled) {
|
|
474
|
+
const restartState = daemonLifecycleState();
|
|
475
|
+
if (restartState === 'external') {
|
|
476
|
+
line(warn(`ours core updated (v${before} → v${after}); restart its external launcher to load the update.`));
|
|
477
|
+
} else {
|
|
355
478
|
await act(`ours-mcp restart (now v${after})`, async () => { if (!run('ours-mcp', ['restart']).ok) run('ours-mcp', ['start']); return { ok: true }; });
|
|
356
479
|
line(ok(`ours core updated (v${before} → v${after}) and restarted. No problems.`));
|
|
357
|
-
} else {
|
|
358
|
-
line(ok(`ours core already current${after ? ` (v${after})` : ''} — nothing to change.`));
|
|
359
480
|
}
|
|
481
|
+
} else if (pendingUpdateRestart) {
|
|
482
|
+
const voiceFailed = summary.find((entry) => entry.key === 'voice')?.state === 'failed';
|
|
483
|
+
line(voiceFailed
|
|
484
|
+
? warn(`ours core updated (v${before} → v${after}); voice setup handled restart/rollback but did not change voice settings.`)
|
|
485
|
+
: ok(`ours core updated (v${before} → v${after}); voice setup performed the required restart and readiness check.`));
|
|
486
|
+
} else if (upd) {
|
|
487
|
+
line(ok(`ours core already current${after ? ` (v${after})` : ''} — nothing to change.`));
|
|
360
488
|
} else {
|
|
361
489
|
line(ok(`ours core ready — running on port ${chosenPort}${running ? '' : ' (start with ours-mcp start)'}. No problems.`));
|
|
362
490
|
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function snapshotConfig(path) {
|
|
7
|
+
if (!existsSync(path)) return { exists: false, text: '', mode: 0o600 };
|
|
8
|
+
return {
|
|
9
|
+
exists: true,
|
|
10
|
+
text: readFileSync(path, 'utf8'),
|
|
11
|
+
mode: statSync(path).mode & 0o777,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Write beside the destination and rename only after a complete write. A failure
|
|
16
|
+
// leaves the old config byte-for-byte intact. The injectable rename seam is for failure tests.
|
|
17
|
+
export function atomicWriteConfig(path, text, { rename = renameSync } = {}) {
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
19
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
22
|
+
chmodSync(tmp, 0o600);
|
|
23
|
+
rename(tmp, path);
|
|
24
|
+
chmodSync(path, 0o600);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
try { unlinkSync(tmp); } catch { /* absent or already renamed */ }
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function restoreConfig(path, snapshot) {
|
|
33
|
+
if (snapshot.exists) {
|
|
34
|
+
atomicWriteConfig(path, snapshot.text);
|
|
35
|
+
// Restore the pre-transaction permissions. Normal successful secret writes always use 0600.
|
|
36
|
+
chmodSync(path, snapshot.mode);
|
|
37
|
+
} else {
|
|
38
|
+
try { unlinkSync(path); } catch { /* already absent */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Apply a config and validate/reload it as one transaction. If the apply callback fails, restore
|
|
43
|
+
// the exact previous bytes and invoke it once more to reload the old configuration.
|
|
44
|
+
export function transactionalConfigUpdate(path, text, apply) {
|
|
45
|
+
const before = snapshotConfig(path);
|
|
46
|
+
try {
|
|
47
|
+
atomicWriteConfig(path, text);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return { ok: false, stage: 'write', rolledBack: true, error };
|
|
50
|
+
}
|
|
51
|
+
let applied;
|
|
52
|
+
try {
|
|
53
|
+
applied = apply();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
applied = { ok: false, error };
|
|
56
|
+
}
|
|
57
|
+
if (applied?.ok) return { ok: true, stage: 'apply', rolledBack: false };
|
|
58
|
+
try {
|
|
59
|
+
restoreConfig(path, before);
|
|
60
|
+
apply();
|
|
61
|
+
return { ok: false, stage: 'apply', rolledBack: true, error: applied?.error };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { ok: false, stage: 'rollback', rolledBack: false, error };
|
|
64
|
+
}
|
|
65
|
+
}
|
package/lib/logic.mjs
CHANGED
|
@@ -111,6 +111,89 @@ export function mergeConfig(existing, patch) {
|
|
|
111
111
|
return JSON.stringify(out, null, 2) + '\n';
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
export const VOICE_PROVIDERS = ['openai-compatible', 'elevenlabs', 'deepgram', 'custom'];
|
|
115
|
+
|
|
116
|
+
// Resolve only the STT fields the daemon itself accepts. Environment values override
|
|
117
|
+
// config.json field-by-field, matching packages/core/src/config.ts. The returned object
|
|
118
|
+
// may contain a secret, so callers must never print or serialize it into diagnostics.
|
|
119
|
+
export function effectiveVoiceConfig(config = {}, env = {}) {
|
|
120
|
+
const file = config?.stt && typeof config.stt === 'object' ? config.stt : {};
|
|
121
|
+
const out = { ...file };
|
|
122
|
+
const envFields = {
|
|
123
|
+
provider: env.OURS_STT_PROVIDER,
|
|
124
|
+
apiKey: env.OURS_STT_API_KEY,
|
|
125
|
+
model: env.OURS_STT_MODEL,
|
|
126
|
+
baseUrl: env.OURS_STT_BASE_URL,
|
|
127
|
+
language: env.OURS_STT_LANGUAGE,
|
|
128
|
+
};
|
|
129
|
+
for (const [key, raw] of Object.entries(envFields)) {
|
|
130
|
+
if (typeof raw === 'string' && raw.trim()) out[key] = raw.trim();
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Capability/readiness check, deliberately based on required fields rather than a package
|
|
136
|
+
// version. Reasons contain field names only — never secret values.
|
|
137
|
+
export function voiceSetupStatus(config = {}, env = {}) {
|
|
138
|
+
const stt = effectiveVoiceConfig(config, env);
|
|
139
|
+
const provider = String(stt.provider || '').trim().toLowerCase();
|
|
140
|
+
if (!provider) return { ready: false, provider: '', reason: 'no voice provider configured', missing: ['provider'] };
|
|
141
|
+
if (!VOICE_PROVIDERS.includes(provider)) {
|
|
142
|
+
return { ready: false, provider, reason: `unsupported voice provider "${provider}"`, missing: ['provider'] };
|
|
143
|
+
}
|
|
144
|
+
if (!String(stt.apiKey || '').trim()) {
|
|
145
|
+
return { ready: false, provider, reason: `voice provider "${provider}" is missing its API key`, missing: ['apiKey'] };
|
|
146
|
+
}
|
|
147
|
+
if (provider === 'openai-compatible') {
|
|
148
|
+
const missing = [];
|
|
149
|
+
if (!String(stt.baseUrl || '').trim()) missing.push('baseUrl');
|
|
150
|
+
if (!String(stt.model || '').trim()) missing.push('model');
|
|
151
|
+
if (missing.length) return { ready: false, provider, reason: `openai-compatible voice setup is missing ${missing.join(' and ')}`, missing };
|
|
152
|
+
}
|
|
153
|
+
if (provider === 'elevenlabs' && !String(stt.model || '').trim()) {
|
|
154
|
+
return { ready: false, provider, reason: 'elevenlabs voice setup is missing model', missing: ['model'] };
|
|
155
|
+
}
|
|
156
|
+
if (provider === 'custom') {
|
|
157
|
+
if (!String(stt.custom?.url || '').trim()) {
|
|
158
|
+
return { ready: false, provider, reason: 'custom voice setup is missing custom.url', missing: ['custom.url'] };
|
|
159
|
+
}
|
|
160
|
+
const wantsModel = stt.custom.url.includes('{model}')
|
|
161
|
+
|| (stt.custom.modelField !== undefined && stt.custom.modelField !== '');
|
|
162
|
+
if (wantsModel && !String(stt.model || '').trim()) {
|
|
163
|
+
return { ready: false, provider, reason: 'custom voice setup references a model but model is missing', missing: ['model'] };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
ready: true,
|
|
168
|
+
provider,
|
|
169
|
+
reason: 'voice transcription is configured',
|
|
170
|
+
missing: [],
|
|
171
|
+
keySource: typeof env.OURS_STT_API_KEY === 'string' && env.OURS_STT_API_KEY.trim() ? 'environment' : 'config',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Provider keys have different shapes, so validation is intentionally conservative: reject
|
|
176
|
+
// empty, tiny, whitespace-containing, or control-character input without assuming a vendor prefix.
|
|
177
|
+
export function validateVoiceSecret(input) {
|
|
178
|
+
const value = String(input || '').trim();
|
|
179
|
+
if (value.length < 8) return { ok: false, reason: 'API key must contain at least 8 characters' };
|
|
180
|
+
if (/[\s\x00-\x1f\x7f]/.test(value)) return { ok: false, reason: 'API key must not contain whitespace or control characters' };
|
|
181
|
+
return { ok: true, value };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Last-resort diagnostic scrubber. Code should avoid putting secrets into errors in the first
|
|
185
|
+
// place; this protects unexpected provider/tool errors before they reach a terminal or log.
|
|
186
|
+
export function redactSensitive(text, secrets = []) {
|
|
187
|
+
let out = String(text ?? '');
|
|
188
|
+
for (const raw of secrets) {
|
|
189
|
+
const secret = String(raw || '');
|
|
190
|
+
if (secret) out = out.split(secret).join('[redacted]');
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
.replace(/("(?:apiKey|apiToken|token)"\s*:\s*")[^"]*(")/gi, '$1[redacted]$2')
|
|
194
|
+
.replace(/((?:api[_ -]?key|token)\s*[=:]\s*)\S+/gi, '$1[redacted]');
|
|
195
|
+
}
|
|
196
|
+
|
|
114
197
|
// parseVersion: pull the first x.y.z out of a version string (e.g. `ours-mcp v0.9.9`), matching
|
|
115
198
|
// install.sh's `grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1`. Returns '' when none is present.
|
|
116
199
|
export function parseVersion(text) {
|
package/lib/prompt.mjs
CHANGED
|
@@ -63,6 +63,46 @@ export function askLine(write, fd, prompt, def = '') {
|
|
|
63
63
|
return ans === '' ? def : ans;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Secret variant of askLine: identical cancellation/backspace/default semantics, but typed
|
|
67
|
+
// characters are never echoed (not even as placeholder stars). This keeps provider keys out of
|
|
68
|
+
// terminal scrollback, curl|bash output, and captured CI logs.
|
|
69
|
+
export function askSecret(write, fd, prompt, def = '') {
|
|
70
|
+
if (fd == null || ASSUME_YES()) return def;
|
|
71
|
+
|
|
72
|
+
const saved = spawnSync('stty', ['-g'], { stdio: [fd, 'pipe', 'ignore'], encoding: 'utf8' });
|
|
73
|
+
const rawOk = saved.status === 0
|
|
74
|
+
&& spawnSync('stty', ['-icanon', '-echo', '-isig', 'min', '1', 'time', '0'], { stdio: [fd, 'ignore', 'ignore'] }).status === 0;
|
|
75
|
+
const restore = () => {
|
|
76
|
+
if (rawOk) spawnSync('stty', (saved.stdout || '').trim() ? [(saved.stdout || '').trim()] : ['sane'], { stdio: [fd, 'ignore', 'ignore'] });
|
|
77
|
+
};
|
|
78
|
+
if (!rawOk) {
|
|
79
|
+
// Fail closed: a cooked fallback would echo the secret. Returning null lets the caller
|
|
80
|
+
// explain that secure input is unavailable without ever reading a credential.
|
|
81
|
+
write(`${prompt}\n`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
// Disable echo BEFORE displaying the prompt. Otherwise an automated or very fast typist can
|
|
85
|
+
// submit bytes in the small prompt→stty window and have the terminal driver echo the secret.
|
|
86
|
+
write(prompt);
|
|
87
|
+
|
|
88
|
+
let s = '';
|
|
89
|
+
try {
|
|
90
|
+
for (;;) {
|
|
91
|
+
const b = readByte(fd);
|
|
92
|
+
if (b === null || b === 0x04) break;
|
|
93
|
+
if (b === 0x03) { restore(); write('^C'); throw new InstallCancelled(); }
|
|
94
|
+
if (b === 0x0a || b === 0x0d) { write('\n'); break; }
|
|
95
|
+
if (b === 0x7f || b === 0x08) { if (s.length) s = s.slice(0, -1); continue; }
|
|
96
|
+
if (b < 0x20) continue;
|
|
97
|
+
s += String.fromCharCode(b);
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
restore();
|
|
101
|
+
}
|
|
102
|
+
const ans = s.trim();
|
|
103
|
+
return ans === '' ? def : ans;
|
|
104
|
+
}
|
|
105
|
+
|
|
66
106
|
// askYesNo: y/n with a default shown in caps. Returns boolean.
|
|
67
107
|
export function askYesNo(write, fd, prompt, def = false) {
|
|
68
108
|
if (fd == null || ASSUME_YES()) return def;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The unified ours.network stack installer (ours-install): one guided ~3-minute flow for ours core (the daemon) + the harness plugins (Claude Code / Codex) + ours-fleet + the Telegram connector, then a single copy-paste hand-off prompt. Self-contained (Node built-ins only); run as `ours-install` or via curl|bash (install.sh).",
|
|
6
6
|
"type": "module",
|