@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 +14 -6
- package/install.mjs +145 -8
- 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,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
|
|
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
|
|
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.
|
|
136
|
-
|
|
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
|
|
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
|
-
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
|
-
|
|
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.
|
|
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",
|