@ours.network/install 0.14.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
@@ -97,11 +100,21 @@ function configPath() { return process.env.OURS_CONFIG || join(homedir(), '.ours
97
100
  function readConfigObject() { try { return JSON.parse(readFileSync(configPath(), 'utf8')); } catch { return {}; } }
98
101
  function writeConfigPatch(patch) {
99
102
  const p = configPath();
100
- mkdirSync(dirname(p), { recursive: true });
101
- writeFileSync(p, mergeConfig(readConfigObject(), patch));
103
+ atomicWriteConfig(p, mergeConfig(readConfigObject(), patch));
102
104
  return p;
103
105
  }
104
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
+
105
118
  // Block the thread for `ms` without a subprocess — used for the brief daemon-reachability wait
106
119
  // before creating the human identity (a freshly-started daemon needs a moment to bind its port).
107
120
  function sleepMs(ms) {
@@ -364,6 +377,130 @@ async function main() {
364
377
  }
365
378
  cont();
366
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
+
367
504
  // ============================================================================================
368
505
  // Human identity — created DURING install, right after the daemon is confirmed reachable (the
369
506
  // owner change that supersedes "defer to the hand-off"). `ours-mcp create-root` is the internal
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.14.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",