@ours.network/install 0.13.0 → 0.14.1

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 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 Codex, then hands back a single copy-paste
5
- prompt to finish setup conversationally.
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,7 +43,7 @@ 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 only two settings the user ever types, up front:
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.
@@ -51,6 +51,10 @@ dependency on the things it installs): an ASCII banner, tasteful colour (degrade
51
51
  **Continue?** — never a start-twice-then-ask, never a silent failure:
52
52
  - **1/4 ours core (the daemon)** — write config → install/start ONCE → boot service. On a
53
53
  re-run it reuses the running config (no re-ask) and only updates when you say yes.
54
+ After core readiness, the installer checks `ours-mcp voice-status --json`. Complete
55
+ voice setup is kept without prompting. Missing/incomplete setup is offered on every
56
+ interactive rerun: provider, model/endpoint where required, and a hidden API-key prompt.
57
+ The secret is written atomically to mode-`0600` config; a failed daemon reload rolls back.
54
58
  - **2/4 harness plugins** — the installer **drives the plugin CLIs itself**
55
59
  (`claude plugin marketplace add …` + `claude plugin install ours@ours.network`;
56
60
  `codex plugin marketplace add …` + `codex plugin add ours@ours-codex-marketplace`). Choosing
@@ -64,7 +68,7 @@ dependency on the things it installs): an ASCII banner, tasteful colour (degrade
64
68
  copy-paste prompt** (root identity + fleet + Telegram) with the steps for any skipped/failed
65
69
  component dropped out. Copied to the clipboard where supported.
66
70
 
67
- The root identity is **deferred to the hand-off** — zero identity typing during install. Because
71
+ The human identity is created idempotently after the daemon becomes reachable. Because
68
72
  `curl … | bash` gives the script its input over the pipe, every prompt is read from the
69
73
  controlling terminal (`/dev/tty`), so the flow still works piped.
70
74
 
@@ -80,6 +84,10 @@ exactly the commands it *would* run (npm installs, `ours-mcp start`, plugin adds
80
84
  init`, service installs) without executing them. That is the safe way to preview the flow on a
81
85
  machine you don't want to touch, and how the integration tests drive it.
82
86
 
87
+ Non-interactive runs never prompt for or synthesize voice credentials. Supply a complete
88
+ `OURS_STT_*` environment configuration yourself, or rerun interactively later; missing setup
89
+ is reported and left unchanged.
90
+
83
91
  | var | meaning |
84
92
  |---|---|
85
93
  | `OURS_ASSUME_YES` | accept every default, never prompt (implies no tty needed) |
@@ -132,5 +140,5 @@ OURS_UNINSTALL_DAEMON=yes \
132
140
  published components.
133
141
  - **Idempotent + safe to re-run.** A re-run adds a skipped piece, re-points the plugins, or (only
134
142
  when you say yes) updates a component; an already-current daemon is left untouched, its running
135
- port reused everywhere. Deep configuration (identities, bot tokens, fleet roles) is intentionally
136
- **not** done here — it's the copy-paste hand-off's job.
143
+ port and complete voice setup are reused everywhere. Bot tokens and fleet roles remain in the
144
+ 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 all real configuration conversationally. No tokens, no port editing,
10
- // no config files. See packages/installer/README.md and the UX spec for the full contract.
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, writeFileSync, mkdirSync, existsSync } from 'node:fs';
21
+ import { readFileSync, existsSync } from 'node:fs';
21
22
  import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
22
- import { join, dirname } from 'node:path';
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
- import { askLine, askYesNo, isCancel } from './lib/prompt.mjs';
25
+ import { askLine, askSecret, askYesNo, isCancel } from './lib/prompt.mjs';
25
26
  import {
26
27
  suggestPort, parsePort, validateBroker, mergeConfig, parseVersion, parseStatus,
27
28
  detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
29
+ voiceSetupStatus, validateVoiceSecret, redactSensitive, VOICE_PROVIDERS,
28
30
  DEFAULT_PORT, resolveChannel, pkgSpec,
29
31
  } from './lib/logic.mjs';
32
+ import { atomicWriteConfig, transactionalConfigUpdate } 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
@@ -37,12 +40,6 @@ let DRY = !!process.env.OURS_INSTALL_DRY_RUN;
37
40
  const SELFHOST_URL = 'ours.network';
38
41
  const CLAUDE_MARKET = 'adapt-toolkit/ours-claude-marketplace';
39
42
  const CODEX_MARKET = 'adapt-toolkit/ours-codex-marketplace';
40
- // The ours-fleet HARNESS PLUGINS (separate from the `ours` plugin) — these are what actually give
41
- // Claude Code / Codex the fleet skill. Claude: plugin "fleet" in the ours.network marketplace
42
- // (@ours.network/fleet-claude-code). Codex: plugin "ours-fleet" in the ours-codex-marketplace
43
- // (@ours.network/fleet-codex).
44
- const CLAUDE_FLEET_PLUGIN = 'fleet@ours.network';
45
- const CODEX_FLEET_PLUGIN = 'ours-fleet@ours-codex-marketplace';
46
43
 
47
44
  const sink = (s) => process.stdout.write(s);
48
45
  const line = (s = '') => sink(`${s}\n`);
@@ -103,11 +100,21 @@ function configPath() { return process.env.OURS_CONFIG || join(homedir(), '.ours
103
100
  function readConfigObject() { try { return JSON.parse(readFileSync(configPath(), 'utf8')); } catch { return {}; } }
104
101
  function writeConfigPatch(patch) {
105
102
  const p = configPath();
106
- mkdirSync(dirname(p), { recursive: true });
107
- writeFileSync(p, mergeConfig(readConfigObject(), patch));
103
+ atomicWriteConfig(p, mergeConfig(readConfigObject(), patch));
108
104
  return p;
109
105
  }
110
106
 
107
+ function daemonVoiceCapability() {
108
+ const r = run('ours-mcp', ['voice-status', '--json'], { capture: true, timeout: 6000 });
109
+ if (!r.ok) return null;
110
+ try {
111
+ const parsed = JSON.parse(r.out.trim());
112
+ return typeof parsed?.ready === 'boolean' ? parsed : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
111
118
  // Block the thread for `ms` without a subprocess — used for the brief daemon-reachability wait
112
119
  // before creating the human identity (a freshly-started daemon needs a moment to bind its port).
113
120
  function sleepMs(ms) {
@@ -370,6 +377,130 @@ async function main() {
370
377
  }
371
378
  cont();
372
379
 
380
+ // ============================================================================================
381
+ // Voice-message transcription — capability-based and safe to re-run. A complete setup is kept
382
+ // without prompting. An incomplete setup is offered every interactive run, including updates.
383
+ // Headless/CI never blocks or invents a provider/key. Secrets are read without echo and the
384
+ // config transaction is atomic + 0600; a failed daemon restart restores the previous file.
385
+ // ============================================================================================
386
+ if (summary.some((r) => r.key === 'core' && (r.state === 'installed' || r.state === 'current'))) {
387
+ line(heading('Voice messages'));
388
+ const cfgBefore = readConfigObject();
389
+ const probed = daemonVoiceCapability();
390
+ const localStatus = voiceSetupStatus(cfgBefore, process.env);
391
+ const voiceStatus = probed ?? localStatus;
392
+ if (voiceStatus.ready) {
393
+ line(ok(`Voice transcription is configured (${voiceStatus.provider}). API key: configured, never displayed.`));
394
+ record({ key: 'voice', label: 'Voice transcription', state: 'current', note: voiceStatus.provider });
395
+ cont(false);
396
+ } else if (!interactive) {
397
+ line(info('Voice transcription is not configured. Non-interactive mode leaves it unchanged.'));
398
+ line(info('Run ours-install in a terminal to enter the provider key with masked input.'));
399
+ record({ key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'interactive setup available on re-run' });
400
+ cont(false);
401
+ } else {
402
+ line(info('Voice notes can be transcribed by a provider you choose. Audio is sent to that'));
403
+ line(info('provider; use a self-hosted endpoint if it must stay local. The API key is masked.'));
404
+ const configure = yes(' Set up voice transcription now?', true);
405
+ if (!configure) {
406
+ line(info('skipped — this will be offered again on the next installer run.'));
407
+ record({ key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'declined; offered again on re-run' });
408
+ cont(false);
409
+ } else {
410
+ const fileVoice = cfgBefore?.stt && typeof cfgBefore.stt === 'object' ? cfgBefore.stt : {};
411
+ const providerDefault = String(process.env.OURS_STT_PROVIDER || fileVoice.provider || '').trim().toLowerCase();
412
+ const provider = ask(
413
+ ` Provider (${VOICE_PROVIDERS.join(' / ')})${providerDefault ? ` [${providerDefault}]` : ''}: `,
414
+ providerDefault,
415
+ ).trim().toLowerCase();
416
+ let setupError = '';
417
+ if (!VOICE_PROVIDERS.includes(provider)) setupError = `choose one of: ${VOICE_PROVIDERS.join(', ')}`;
418
+
419
+ let model = String(process.env.OURS_STT_MODEL || fileVoice.model || '').trim();
420
+ let baseUrl = String(process.env.OURS_STT_BASE_URL || fileVoice.baseUrl || '').trim();
421
+ let customUrl = String(fileVoice.custom?.url || '').trim();
422
+ if (!setupError && provider === 'openai-compatible') {
423
+ baseUrl = ask(` Provider /v1 base URL${baseUrl ? ` [${baseUrl}]` : ''}: `, baseUrl).trim();
424
+ model = ask(` Model name (sent verbatim)${model ? ` [${model}]` : ''}: `, model).trim();
425
+ } else if (!setupError && provider === 'elevenlabs') {
426
+ model = ask(` ElevenLabs model id${model ? ` [${model}]` : ''}: `, model).trim();
427
+ baseUrl = ask(` Custom base URL (Enter for provider default)${baseUrl ? ` [${baseUrl}]` : ''}: `, baseUrl).trim();
428
+ } else if (!setupError && provider === 'deepgram') {
429
+ model = ask(` Model (optional; Enter for provider default)${model ? ` [${model}]` : ''}: `, model).trim();
430
+ baseUrl = ask(` Custom base URL (optional)${baseUrl ? ` [${baseUrl}]` : ''}: `, baseUrl).trim();
431
+ } else if (!setupError && provider === 'custom') {
432
+ customUrl = ask(` Full transcription endpoint URL${customUrl ? ` [${customUrl}]` : ''}: `, customUrl).trim();
433
+ model = ask(` Model (optional unless URL contains {model})${model ? ` [${model}]` : ''}: `, model).trim();
434
+ }
435
+
436
+ const envHasKey = !!process.env.OURS_STT_API_KEY?.trim();
437
+ let keyToPersist = String(fileVoice.apiKey || '').trim();
438
+ if (!setupError && !envHasKey) {
439
+ const keyPrompt = keyToPersist
440
+ ? ' Provider API key [configured; Enter keeps it]: '
441
+ : ' Provider API key (input hidden): ';
442
+ const entered = askSecret(write, ttyFd, keyPrompt, keyToPersist);
443
+ if (entered === null) setupError = 'secure hidden input is unavailable on this terminal';
444
+ else {
445
+ const valid = validateVoiceSecret(entered);
446
+ if (!valid.ok) setupError = valid.reason;
447
+ else keyToPersist = valid.value;
448
+ }
449
+ }
450
+
451
+ const nextStt = {
452
+ ...fileVoice,
453
+ provider,
454
+ ...(keyToPersist ? { apiKey: keyToPersist } : {}),
455
+ ...(model ? { model } : {}),
456
+ ...(baseUrl ? { baseUrl } : {}),
457
+ ...(provider === 'custom' && customUrl
458
+ ? { custom: { ...(fileVoice.custom ?? {}), url: customUrl } }
459
+ : {}),
460
+ };
461
+ const intended = voiceSetupStatus({ ...cfgBefore, stt: nextStt }, process.env);
462
+ if (!setupError && !intended.ready) setupError = intended.reason;
463
+
464
+ if (setupError) {
465
+ line(warn(`Voice setup was not saved: ${redactSensitive(setupError, [keyToPersist])}.`));
466
+ line(info('No existing configuration was changed; re-run ours-install to try again.'));
467
+ record({ key: 'voice', label: 'Voice transcription', state: 'failed', note: 'incomplete setup; config unchanged' });
468
+ cont();
469
+ } else if (DRY) {
470
+ line(' ' + c.dim(`[dry-run] would: atomically update ${configPath()} (mode 0600) and restart ours-mcp`));
471
+ line(ok(`Voice transcription would be configured (${provider}); API key stays hidden.`));
472
+ record({ key: 'voice', label: 'Voice transcription', state: 'installed', note: `${provider} (dry-run)` });
473
+ cont();
474
+ } else {
475
+ const updated = transactionalConfigUpdate(
476
+ configPath(),
477
+ mergeConfig(cfgBefore, { stt: nextStt }),
478
+ () => {
479
+ const r = run('ours-mcp', ['restart'], { capture: true });
480
+ if (!r.ok) return { ok: false, error: r.err || r.out };
481
+ const verified = daemonVoiceCapability();
482
+ return verified && !verified.ready
483
+ ? { ok: false, error: verified.reason || 'voice capability remained incomplete after restart' }
484
+ : { ok: true };
485
+ },
486
+ );
487
+ if (!updated.ok && updated.stage === 'write') {
488
+ line(warn(`Could not save voice setup: ${redactSensitive(updated.error instanceof Error ? updated.error.message : String(updated.error), [keyToPersist])}.`));
489
+ line(info('The prior config is intact; no restart was attempted.'));
490
+ record({ key: 'voice', label: 'Voice transcription', state: 'failed', note: 'config write failed; prior config intact' });
491
+ } else if (!updated.ok) {
492
+ line(warn(`Daemon restart failed; ${updated.rolledBack ? 'restored the prior config' : 'automatic rollback also failed — inspect the config before restarting'}.`));
493
+ record({ key: 'voice', label: 'Voice transcription', state: 'failed', note: updated.rolledBack ? 'restart failed; rolled back' : 'restart and rollback failed' });
494
+ } else {
495
+ line(ok(`Voice transcription configured (${provider}) and daemon restarted. API key saved in mode-0600 config.`));
496
+ record({ key: 'voice', label: 'Voice transcription', state: 'installed', note: provider });
497
+ }
498
+ cont();
499
+ }
500
+ }
501
+ }
502
+ }
503
+
373
504
  // ============================================================================================
374
505
  // Human identity — created DURING install, right after the daemon is confirmed reachable (the
375
506
  // owner change that supersedes "defer to the hand-off"). `ours-mcp create-root` is the internal
@@ -501,34 +632,16 @@ async function main() {
501
632
  const init = await act('ours-fleet init (one-time host setup: units, dirs, linger)', async () => run('ours-fleet', ['init']));
502
633
  if (!init.ok) line(warn(`ours-fleet host setup didn't finish — retry '${c.cyan('ours-fleet init')}'.`));
503
634
 
504
- // Install the fleet HARNESS PLUGIN into each real+safe harness — the piece that actually gives
505
- // Claude Code / Codex the fleet skill. Same drive-the-CLI, alias-safe, loud-on-failure pattern
506
- // as Step 2; never dead-ends. (The `ours` plugin does NOT bundle this — it's a separate plugin.)
507
- const fleetIn = [];
508
- for (const h of harnesses) {
509
- if (h.status === 'absent') continue;
510
- if (h.name === 'claude') {
511
- if (h.status !== 'ok') { manualClaudeFleet(h); continue; }
512
- await act(`claude plugin marketplace add ${CLAUDE_MARKET}`, async () => run('claude', ['plugin', 'marketplace', 'add', CLAUDE_MARKET], { capture: true }));
513
- const r = await act(`claude plugin install ${CLAUDE_FLEET_PLUGIN}`, async () => run('claude', ['plugin', 'install', CLAUDE_FLEET_PLUGIN], { capture: true }));
514
- if (r.ok) { line(ok('Claude Code fleet plugin installed — you can spawn agents from Claude Code. No problems.')); fleetIn.push('Claude Code'); }
515
- else failClaudeFleet();
516
- } else if (h.name === 'codex') {
517
- if (h.status !== 'ok') { manualCodexFleet(h); continue; }
518
- await act(`codex plugin marketplace add ${CODEX_MARKET}`, async () => run('codex', ['plugin', 'marketplace', 'add', CODEX_MARKET], { capture: true }));
519
- const r = await act(`codex plugin add ${CODEX_FLEET_PLUGIN}`, async () => run('codex', ['plugin', 'add', CODEX_FLEET_PLUGIN], { capture: true }));
520
- if (r.ok) { line(ok('Codex fleet plugin installed — you can spawn agents from Codex. No problems.')); fleetIn.push('Codex'); }
521
- else failCodexFleet();
522
- }
635
+ if (init.ok) {
636
+ line(ok('ours-fleet ready — the core ours plugin discovers every option through `ours-fleet docs`. No problems.'));
523
637
  }
524
- // Only claim the skill is present where the fleet plugin actually installed.
525
- if (init.ok && fleetIn.length) line(ok(`ours-fleet ready — ${fleetIn.join(' + ')} now know the fleet skill. No problems.`));
526
- else if (init.ok) line(info('ours-fleet CLI is installed; add the fleet plugin to your harness with the commands above.'));
527
- // Fleet plugins target Claude Code + Codex only (Hermes has none) — so "no fleet-capable harness
528
- // to install into" means every claude/codex is absent, NOT every harness (Hermes doesn't count).
529
- const fleetCapableAbsent = harnesses.filter((h) => h.name === 'claude' || h.name === 'codex').every((h) => h.status === 'absent');
530
- const fleetOk = init.ok && (fleetIn.length > 0 || fleetCapableAbsent);
531
- record({ key: 'fleet', label: 'ours-fleet', state: fleetOk ? 'installed' : 'failed', version: globalVersion('@ours.network/fleet'), note: fleetIn.length ? fleetIn.join(' + ') : (init.ok ? 'CLI only — add plugin manually' : 'ours-fleet init failed') });
638
+ record({
639
+ key: 'fleet',
640
+ label: 'ours-fleet',
641
+ state: init.ok ? 'installed' : 'failed',
642
+ version: globalVersion('@ours.network/fleet'),
643
+ note: init.ok ? 'CLI + core-plugin discovery' : 'ours-fleet init failed',
644
+ });
532
645
  } else {
533
646
  line(info('skipped cleanly — re-run ours-install any time to add it.'));
534
647
  record({ key: 'fleet', label: 'ours-fleet', state: 'skipped' });
@@ -611,38 +724,6 @@ function failHermes() {
611
724
  line(' ' + c.cyan('ours-hermes-install'));
612
725
  line(info('Your daemon and other steps are intact. Continuing.'));
613
726
  }
614
- // --- fleet HARNESS PLUGIN never-dead-end messaging ---------------------------------------------
615
- function manualClaudeFleet(h) {
616
- line(warn(h.status === 'alias'
617
- ? '"claude" is installed as an alias, so I can\'t add the fleet plugin for you.'
618
- : 'Couldn\'t safely drive "claude" to add the fleet plugin.'));
619
- line(info('Add it yourself — inside Claude Code, run these two:'));
620
- line(' ' + c.cyan(`/plugin marketplace add ${CLAUDE_MARKET}`));
621
- line(' ' + c.cyan('/plugin install fleet'));
622
- }
623
- function failClaudeFleet() {
624
- line(warn('Couldn\'t install the Claude Code fleet plugin automatically.'));
625
- line(info('Install it by hand — inside Claude Code, run these two, then re-run ours-install:'));
626
- line(' ' + c.cyan(`/plugin marketplace add ${CLAUDE_MARKET}`));
627
- line(' ' + c.cyan('/plugin install fleet'));
628
- line(info('Your daemon and other steps are intact. Continuing.'));
629
- }
630
- function manualCodexFleet(h) {
631
- line(warn(h.status === 'alias'
632
- ? '"codex" is installed as an alias, so I can\'t add the fleet plugin for you.'
633
- : 'Couldn\'t safely drive "codex" to add the fleet plugin.'));
634
- line(info('Add it yourself — run these two in your terminal:'));
635
- line(' ' + c.cyan(`codex plugin marketplace add ${CODEX_MARKET}`));
636
- line(' ' + c.cyan(`codex plugin add ${CODEX_FLEET_PLUGIN}`));
637
- }
638
- function failCodexFleet() {
639
- line(warn('Couldn\'t install the Codex fleet plugin automatically.'));
640
- line(info('Install it by hand — run these two, then re-run ours-install:'));
641
- line(' ' + c.cyan(`codex plugin marketplace add ${CODEX_MARKET}`));
642
- line(' ' + c.cyan(`codex plugin add ${CODEX_FLEET_PLUGIN}`));
643
- line(info('Your daemon and other steps are intact. Continuing.'));
644
- }
645
-
646
727
  // --- final summary + copy-paste hand-off -------------------------------------------------------
647
728
  function endScreen({ ttyFd, summary, chosenPort, chosenBroker }) {
648
729
  line('');
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.13.0",
3
+ "version": "0.14.1",
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",