@agfpd/voicetalk-runtime 0.1.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/LICENSE +202 -0
- package/README.md +81 -0
- package/bin/voicetalk-runtime +12 -0
- package/docs/README.md +31 -0
- package/docs/ru/README.md +30 -0
- package/package.json +29 -0
- package/src/cli.ts +1076 -0
- package/src/constants.ts +35 -0
- package/src/fsAtomic.ts +17 -0
- package/src/hostDocs.ts +87 -0
- package/src/manifest.ts +109 -0
- package/src/selfConfig.ts +129 -0
- package/src/selfInstall.ts +172 -0
- package/tsconfig.json +15 -0
package/src/constants.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Shared constants for the voicetalk runtime PACKAGE-FACING contract surface
|
|
2
|
+
// (manifest / self-install / self-config — landing in Фаза 2). Kept tiny and
|
|
3
|
+
// dependency-free so the contract modules can import it without pulling in the
|
|
4
|
+
// audio run-loop.
|
|
5
|
+
|
|
6
|
+
import pkg from '../package.json'
|
|
7
|
+
|
|
8
|
+
/** The runtime id this package implements — the namespace folder under
|
|
9
|
+
* ~/.iapeer/runtimes/<runtime>/ AND the manifest's `runtime` field. Matches
|
|
10
|
+
* the foundation's NATURAL_RUNTIMES / INFRA_RUNTIMES entry (Фаза 1). */
|
|
11
|
+
export const RUNTIME = 'voicetalk'
|
|
12
|
+
|
|
13
|
+
/** The package version — single source of truth is package.json. Stamped into
|
|
14
|
+
* the runtime manifest (foundation reads it for `iapeer status` and
|
|
15
|
+
* `update-runtime` version-gating vs npm-latest). Bumping package.json is
|
|
16
|
+
* enough; nothing here needs hand-editing. */
|
|
17
|
+
export const VERSION: string = pkg.version
|
|
18
|
+
|
|
19
|
+
/** The launcher binary name placed on PATH — `<runtime>-runtime`. Mirrors the
|
|
20
|
+
* foundation's INFRA_RUNTIME_DEFAULT_BIN.voicetalk and the voicetalk adapter's
|
|
21
|
+
* buildArgv fallback (`voicetalk-runtime`). */
|
|
22
|
+
export const BIN_NAME = `${RUNTIME}-runtime`
|
|
23
|
+
|
|
24
|
+
/** The unscoped npm package name — the per-package subdir id for the on-host
|
|
25
|
+
* docs convention (`<IAPEER_ROOT>/docs/<PACKAGE_NAME>/`). The scope is stripped
|
|
26
|
+
* (`@agfpd/voicetalk-runtime` → `voicetalk-runtime`). Single source of truth is
|
|
27
|
+
* package.json `name`. */
|
|
28
|
+
export const PACKAGE_NAME: string = pkg.name.split('/').pop() ?? pkg.name
|
|
29
|
+
|
|
30
|
+
/** Peer-name grammar — identical to the IAP/foundation ecosystem
|
|
31
|
+
* (/^[a-z][a-z0-9-]{0,31}$/). */
|
|
32
|
+
export const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/
|
|
33
|
+
|
|
34
|
+
export const IAPEER_DIR = '.iapeer'
|
|
35
|
+
export const PEER_PROFILE_FILE = 'peer-profile.json'
|
package/src/fsAtomic.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Atomic JSON write shared across the package (cli.ts + selfConfig.ts). Write to a
|
|
2
|
+
// unique sibling tmp in the same directory, then rename over the target — rename is
|
|
3
|
+
// atomic WITHIN one filesystem, so a concurrent reader never observes a half-written
|
|
4
|
+
// file and a repeat write overwrites in place (idempotent). Default mode 0o600: the
|
|
5
|
+
// files this writes (peer profiles, operator state) are private. mkdir -p the parent
|
|
6
|
+
// (0o700) first so a first write into a fresh tree succeeds.
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, renameSync, writeFileSync } from 'fs'
|
|
9
|
+
import { randomUUID } from 'crypto'
|
|
10
|
+
import { dirname } from 'path'
|
|
11
|
+
|
|
12
|
+
export function writeJsonAtomic(path: string, value: unknown, mode = 0o600): void {
|
|
13
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
14
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
15
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode })
|
|
16
|
+
renameSync(tmp, path)
|
|
17
|
+
}
|
package/src/hostDocs.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// On-host docs per-package convention (`<IAPEER_ROOT>/docs/<package>/`).
|
|
2
|
+
//
|
|
3
|
+
// A faithful mirror of the foundation's scaffoldHostDocs (iapeer src/install/index.ts):
|
|
4
|
+
// on EVERY install + update, copy this package's PUBLIC docs to a stable on-host path so
|
|
5
|
+
// an agent (or a human) reads the docs that ship WITH the installed version, without
|
|
6
|
+
// needing the source tree. Versioned-as-artifact: each install/update refreshes the copy
|
|
7
|
+
// (the atomic temp-swap prunes docs removed in a newer version). We own ONLY our own
|
|
8
|
+
// `<root>/docs/voicetalk-runtime/` subdir; the foundation owns the `<root>/docs/` parent
|
|
9
|
+
// and the system-prompt pointer to it.
|
|
10
|
+
//
|
|
11
|
+
// EXCLUDES the `internals/` subtree (internals are local-only, never published). The npm
|
|
12
|
+
// `files` allowlist already keeps internals out of the tarball (docs/*.md + docs/ru/,
|
|
13
|
+
// not docs/) — the filter here is defense-in-depth for a dev source tree that still
|
|
14
|
+
// carries docs/internals/.
|
|
15
|
+
//
|
|
16
|
+
// BEST-EFFORT: a missing docs source or any copy error returns a soft verdict and never
|
|
17
|
+
// throws — the install MUST NOT fail because docs could not be staged (the caller in
|
|
18
|
+
// selfInstall additionally wraps the call). The ONE hard failure is the fail-closed
|
|
19
|
+
// sandbox guard: a misconfigured test that would otherwise write the REAL ~/.iapeer.
|
|
20
|
+
|
|
21
|
+
import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from 'fs'
|
|
22
|
+
import { homedir } from 'os'
|
|
23
|
+
import { basename, join, relative, sep } from 'path'
|
|
24
|
+
import { resolveIapeerRoot } from './manifest.ts'
|
|
25
|
+
|
|
26
|
+
export interface HostDocsResult {
|
|
27
|
+
copied: boolean
|
|
28
|
+
/** The per-package destination — `<root>/docs/<pkg>/`. Always set (even on skip). */
|
|
29
|
+
dest: string
|
|
30
|
+
/** Why nothing was copied (missing source / copy error). Absent on success. */
|
|
31
|
+
reason?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Copy `docsSource` to `<IAPEER_ROOT or ~/.iapeer>/docs/<pkg>/`, atomically and
|
|
36
|
+
* excluding the `internals/` subtree. Idempotent: re-running replaces the package's
|
|
37
|
+
* docs cleanly (the atomic copy→tmp→rm-dest→rename drops the previous tree, so a doc
|
|
38
|
+
* removed in a newer version is pruned, and a reader never sees a half-written tree).
|
|
39
|
+
*
|
|
40
|
+
* Returns what it did. Throws ONLY on the fail-closed sandbox guard; for a missing
|
|
41
|
+
* source or any copy error it returns `{ copied: false, reason }`.
|
|
42
|
+
*/
|
|
43
|
+
export function scaffoldHostDocs(
|
|
44
|
+
pkg: string,
|
|
45
|
+
docsSource: string,
|
|
46
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
47
|
+
): HostDocsResult {
|
|
48
|
+
const root = resolveIapeerRoot(env)
|
|
49
|
+
const dest = join(root, 'docs', pkg)
|
|
50
|
+
// Fail-closed sandbox guard (mirrors the foundation): never write the REAL
|
|
51
|
+
// ~/.iapeer/docs under a sandboxed test that forgot to set IAPEER_ROOT. Compared
|
|
52
|
+
// against the ACTUAL OS home (homedir()), not env.HOME — the root IS the isolation,
|
|
53
|
+
// so an env.HOME-based check would false-trip when a test legitimately points
|
|
54
|
+
// IAPEER_ROOT at <tmp>/.iapeer (with env.HOME also <tmp>).
|
|
55
|
+
if (env.IAPEER_TEST_SANDBOX === '1' && root === join(homedir(), '.iapeer')) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`refusing to scaffold docs into the REAL ${join(root, 'docs')} under IAPEER_TEST_SANDBOX=1 — set IAPEER_ROOT`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
if (!existsSync(docsSource)) return { copied: false, dest, reason: `docs source not found: ${docsSource}` }
|
|
61
|
+
const tmp = `${dest}.tmp-${process.pid}`
|
|
62
|
+
try {
|
|
63
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
64
|
+
mkdirSync(join(dest, '..'), { recursive: true })
|
|
65
|
+
cpSync(docsSource, tmp, {
|
|
66
|
+
recursive: true,
|
|
67
|
+
// Returning false for a directory skips its whole subtree.
|
|
68
|
+
filter: src => {
|
|
69
|
+
// macOS clutter: a dev/host docs tree carries .DS_Store; skip it everywhere so
|
|
70
|
+
// it never leaks into <root>/docs/<pkg>/ (matches the foundation's filter).
|
|
71
|
+
if (basename(src) === '.DS_Store') return false
|
|
72
|
+
const rel = relative(docsSource, src)
|
|
73
|
+
return rel !== 'internals' && !rel.startsWith(`internals${sep}`)
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
rmSync(dest, { recursive: true, force: true })
|
|
77
|
+
renameSync(tmp, dest)
|
|
78
|
+
return { copied: true, dest }
|
|
79
|
+
} catch (e) {
|
|
80
|
+
try {
|
|
81
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
82
|
+
} catch {
|
|
83
|
+
/* best-effort cleanup */
|
|
84
|
+
}
|
|
85
|
+
return { copied: false, dest, reason: e instanceof Error ? e.message : String(e) }
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Runtime MANIFEST — the package-facing contract surface (layer 2 of the presence-
|
|
2
|
+
// runtime contract, iapeer-memory «Контракт presence-рантайма в iapeer 0.4.x — два
|
|
3
|
+
// слоя, router-модель» § «Слой 2 — package-facing манифест»). The package WRITES this
|
|
4
|
+
// at npx-install (self-deploy); the foundation only READS it (readRuntimeManifest →
|
|
5
|
+
// createPeer/deployRuntime). The path MUST match byte-for-byte where the foundation
|
|
6
|
+
// looks: <IAPEER_ROOT or ~/.iapeer>/runtimes/voicetalk/runtime.json
|
|
7
|
+
//
|
|
8
|
+
// OPERATOR-ADD mode (mode b): voicetalk peers are the human OWNER of a local voice
|
|
9
|
+
// endpoint — the package cannot know them ahead of time, so the manifest declares NO
|
|
10
|
+
// `peers[]`. Humans are added one at a time with `iapeer create <owner> --runtime
|
|
11
|
+
// voicetalk`. (Contrast the notifier pilot, declared-set mode a, which lists
|
|
12
|
+
// timer+watcher.) The shared per-peer self-config hook is the same; only the
|
|
13
|
+
// enumeration differs.
|
|
14
|
+
//
|
|
15
|
+
// IAPEER_ROOT-aware: we re-implement the foundation's resolveGlobalRoot contract HERE
|
|
16
|
+
// (we cannot import from the foundation repo — it is a separate package). The rule is
|
|
17
|
+
// the SAME: IAPEER_ROOT env wins, else $HOME/.iapeer. A manifest written under the
|
|
18
|
+
// wrong root (npx not inheriting IAPEER_ROOT) is the most likely first failure — so we
|
|
19
|
+
// always resolve it from the passed env, never hard-code ~/.iapeer.
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
|
22
|
+
import { randomUUID } from 'crypto'
|
|
23
|
+
import { homedir } from 'os'
|
|
24
|
+
import { dirname, join } from 'path'
|
|
25
|
+
import { RUNTIME, VERSION } from './constants.ts'
|
|
26
|
+
|
|
27
|
+
/** A self-config hook descriptor (mirror of the foundation's SelfConfigDescriptor):
|
|
28
|
+
* a bare command (PATH-resolvable / absolute) or {command, args}. We always emit the
|
|
29
|
+
* OBJECT form — a string descriptor carries no args, but the hook needs the
|
|
30
|
+
* `self-config` subcommand, and we pin the ABSOLUTE installed bin so the hook is
|
|
31
|
+
* PATH-independent (the foundation runs it with cwd=peer.cwd + namespaced env). */
|
|
32
|
+
export interface SelfConfigDescriptor {
|
|
33
|
+
command: string
|
|
34
|
+
args?: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One declared peer in a package's FIXED set (mode a). voicetalk (operator-add, mode
|
|
38
|
+
* b) emits NO peers[], so this is only here for round-trip type fidelity with the
|
|
39
|
+
* frozen manifest schema. */
|
|
40
|
+
export interface RuntimePeerDecl {
|
|
41
|
+
personality: string
|
|
42
|
+
intelligence?: 'absent' | 'natural' | 'artificial'
|
|
43
|
+
description?: string
|
|
44
|
+
path?: string
|
|
45
|
+
runtimeBin?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RuntimeManifest {
|
|
49
|
+
runtime: string
|
|
50
|
+
/** Package version, stamped from package.json (see VERSION). The foundation schema
|
|
51
|
+
* (runtime/index.ts) declares it; it powers `iapeer status` version reporting and
|
|
52
|
+
* `update-runtime` gating against npm-latest. Optional in the type for round-trip
|
|
53
|
+
* fidelity with legacy manifests written before it was stamped. */
|
|
54
|
+
version?: string
|
|
55
|
+
selfConfig?: string | SelfConfigDescriptor
|
|
56
|
+
peers?: RuntimePeerDecl[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Mirror of the foundation's resolveGlobalRoot: IAPEER_ROOT wins, else $HOME/.iapeer.
|
|
60
|
+
* Re-implemented (not imported) because the foundation is a separate package; kept in
|
|
61
|
+
* lockstep with its contract on purpose. */
|
|
62
|
+
export function resolveIapeerRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
63
|
+
const override = env.IAPEER_ROOT?.trim()
|
|
64
|
+
if (override) return override
|
|
65
|
+
const home = env.HOME?.trim() || homedir()
|
|
66
|
+
if (!home) throw new Error('cannot resolve home directory for ~/.iapeer')
|
|
67
|
+
return join(home, '.iapeer')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** <root>/runtimes/voicetalk/runtime.json (IAPEER_ROOT-aware). */
|
|
71
|
+
export function runtimeManifestPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
72
|
+
return join(resolveIapeerRoot(env), 'runtimes', RUNTIME, 'runtime.json')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the voicetalk runtime manifest. `binPath` is the ABSOLUTE path the self-
|
|
77
|
+
* installer placed the launcher at — pinned into the self-config descriptor so the
|
|
78
|
+
* hook resolves without any PATH dependency. OPERATOR-ADD: NO `peers[]` (the package
|
|
79
|
+
* does not know the owners ahead of time; they arrive via `iapeer create`).
|
|
80
|
+
*/
|
|
81
|
+
export function buildManifest(binPath: string): RuntimeManifest {
|
|
82
|
+
return {
|
|
83
|
+
runtime: RUNTIME,
|
|
84
|
+
version: VERSION,
|
|
85
|
+
selfConfig: { command: binPath, args: ['self-config'] },
|
|
86
|
+
// NO peers[] — operator-add. Omitted on purpose (foundation reads its absence as
|
|
87
|
+
// "operator-add only"; deployRuntime.operatorAddOnly === true).
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Write the manifest atomically (tmp+rename) under <root>/runtimes/voicetalk/. mkdir
|
|
92
|
+
* -p the runtime dir first. Returns the written path. Idempotent: a repeat write
|
|
93
|
+
* produces byte-identical content (stable key order, sorted-by-construction). */
|
|
94
|
+
export function writeManifestAtomic(manifest: RuntimeManifest, env: NodeJS.ProcessEnv = process.env): string {
|
|
95
|
+
const path = runtimeManifestPath(env)
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o755 })
|
|
97
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
98
|
+
writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 })
|
|
99
|
+
renameSync(tmp, path)
|
|
100
|
+
return path
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read the manifest, or null when absent. Throws on present-but-malformed JSON (it
|
|
104
|
+
* is our declared contract — a corruption should surface, not silently degrade). */
|
|
105
|
+
export function readManifest(env: NodeJS.ProcessEnv = process.env): RuntimeManifest | null {
|
|
106
|
+
const path = runtimeManifestPath(env)
|
|
107
|
+
if (!existsSync(path)) return null
|
|
108
|
+
return JSON.parse(readFileSync(path, 'utf8')) as RuntimeManifest
|
|
109
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Per-peer self-config hook — the shared contract BOTH provision modes call (layer 2 of
|
|
2
|
+
// the presence-runtime contract, § «Per-peer self-config-хук»). The foundation invokes
|
|
3
|
+
// it per peer as `voicetalk-runtime self-config` with cwd = the peer's cwd and the peer
|
|
4
|
+
// context in NAMESPACED env (IAPEER_PEER_PERSONALITY/_CWD/_RUNTIME/_INTELLIGENCE +
|
|
5
|
+
// IAPEER_ROOT — NOT the bare PEER_* the identity gate keys on). Our job is the RICH,
|
|
6
|
+
// voicetalk-specific state: this owner's voice BINDING (wake_word + optional audio
|
|
7
|
+
// device → interfaces.voicetalk).
|
|
8
|
+
//
|
|
9
|
+
// NARROWED schema (design dialog 21.06): interfaces.voicetalk = { wake_word, device? }.
|
|
10
|
+
// STT/TTS/voice config is DELIBERATELY NOT here — it lives in the shared voice-connect
|
|
11
|
+
// service (one place for voicetalk + Telegram voice + Peer-Voice). NO sender-guard
|
|
12
|
+
// (unlike telegram's bot_username): the speaker is a single local endpoint of the owner,
|
|
13
|
+
// any fleet peer may answer by voice, so there is no separate "voicetalk-face" sender to
|
|
14
|
+
// record (decision: «Sender-guard: NO for the MVP»).
|
|
15
|
+
//
|
|
16
|
+
// IDEMPOTENT ("ensure runtime state for peer X"): read-merge-write, byte-stable. exit 0
|
|
17
|
+
// = configured, ≠0 = failed (the foundation is fail-closed — a failed hook means the
|
|
18
|
+
// plist is written but NOT bootstrapped, so a misconfigured always-on voicetalk session
|
|
19
|
+
// never crash-loops).
|
|
20
|
+
//
|
|
21
|
+
// IDENTITY IS THE FOUNDATION'S DOMAIN — and voicetalk is intelligence-GATED (the launch
|
|
22
|
+
// primitive requires `natural`; the foundation provisions it). So the hook does a RAW
|
|
23
|
+
// read-merge-write that PRESERVES every field the foundation provisioned (especially
|
|
24
|
+
// `intelligence`=natural and `personality`). It deliberately does NOT use a typed reader
|
|
25
|
+
// that would coerce the contract `natural` back to a legacy value and clobber it
|
|
26
|
+
// (the pilot-notifier lesson: preserve, don't re-derive identity).
|
|
27
|
+
//
|
|
28
|
+
// OPERATOR INPUTS come via env at `iapeer create` time (the foundation forwards the whole
|
|
29
|
+
// env into the hook): VOICETALK_WAKE_WORD → interfaces.voicetalk.wake_word;
|
|
30
|
+
// VOICETALK_DEVICE → interfaces.voicetalk.device. Absent inputs → the hook is still
|
|
31
|
+
// `configured` (a no-op that confirms state); the operator can complete the binding later
|
|
32
|
+
// via the `interface` verb.
|
|
33
|
+
|
|
34
|
+
import { existsSync, readFileSync } from 'fs'
|
|
35
|
+
import { basename, join } from 'path'
|
|
36
|
+
import { IAPEER_DIR, NAME_RE, PEER_PROFILE_FILE } from './constants.ts'
|
|
37
|
+
import { writeJsonAtomic } from './fsAtomic.ts'
|
|
38
|
+
|
|
39
|
+
export interface SelfConfigOptions {
|
|
40
|
+
env?: NodeJS.ProcessEnv
|
|
41
|
+
cwd?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SelfConfigOutcome {
|
|
45
|
+
personality: string
|
|
46
|
+
profilePath: string
|
|
47
|
+
/** What was written into interfaces.voicetalk this run (for the CLI summary/log). */
|
|
48
|
+
wakeWord?: string
|
|
49
|
+
device?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function trimmed(value: string | undefined): string | undefined {
|
|
53
|
+
const v = value?.trim()
|
|
54
|
+
return v && v.length > 0 ? v : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve the peer's personality for this hook invocation: prefer the NAMESPACED
|
|
58
|
+
* IAPEER_PEER_PERSONALITY (the contract env), then the bare PEER_PERSONALITY, then the
|
|
59
|
+
* cwd basename (so a manual `voicetalk-runtime self-config` in a peer cwd still works). */
|
|
60
|
+
function resolvePersonality(env: NodeJS.ProcessEnv, cwd: string): string {
|
|
61
|
+
const candidate =
|
|
62
|
+
trimmed(env.IAPEER_PEER_PERSONALITY) ?? trimmed(env.PEER_PERSONALITY) ?? basename(cwd).toLowerCase()
|
|
63
|
+
if (!NAME_RE.test(candidate)) {
|
|
64
|
+
throw new Error(`self-config: resolved personality "${candidate}" must match /^[a-z][a-z0-9-]{0,31}$/`)
|
|
65
|
+
}
|
|
66
|
+
return candidate
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function peerProfilePath(cwd: string): string {
|
|
70
|
+
return join(cwd, IAPEER_DIR, PEER_PROFILE_FILE)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read the peer profile as a RAW object (no typed coercion) so every foundation-
|
|
74
|
+
* provisioned field — especially `intelligence` — round-trips untouched. Returns {}
|
|
75
|
+
* when the file is absent or malformed (nothing to preserve in the malformed case). */
|
|
76
|
+
function readRawProfile(path: string): Record<string, unknown> {
|
|
77
|
+
if (!existsSync(path)) return {}
|
|
78
|
+
try {
|
|
79
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
80
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>
|
|
81
|
+
} catch {
|
|
82
|
+
// malformed → start clean (nothing valid to preserve)
|
|
83
|
+
}
|
|
84
|
+
return {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Configure runtime state for ONE voicetalk peer (idempotent). Merges this owner's voice
|
|
89
|
+
* binding (wake_word + optional device, from env) into <cwd>/.iapeer/peer-profile.json
|
|
90
|
+
* `interfaces.voicetalk`, PRESERVING every other field — most importantly the
|
|
91
|
+
* foundation-provisioned `intelligence` (natural). Atomic tmp+rename. Returns what was
|
|
92
|
+
* configured.
|
|
93
|
+
*/
|
|
94
|
+
export function runSelfConfig(opts: SelfConfigOptions = {}): SelfConfigOutcome {
|
|
95
|
+
const env = opts.env ?? process.env
|
|
96
|
+
const cwd = opts.cwd ?? trimmed(env.IAPEER_PEER_CWD) ?? process.cwd()
|
|
97
|
+
const personality = resolvePersonality(env, cwd)
|
|
98
|
+
const profilePath = peerProfilePath(cwd)
|
|
99
|
+
|
|
100
|
+
const profile = readRawProfile(profilePath)
|
|
101
|
+
|
|
102
|
+
// Operator-supplied voice binding (env, forwarded by the foundation).
|
|
103
|
+
const wakeWord = trimmed(env.VOICETALK_WAKE_WORD)
|
|
104
|
+
const device = trimmed(env.VOICETALK_DEVICE)
|
|
105
|
+
|
|
106
|
+
// Merge interfaces.voicetalk (preserve any existing voicetalk fields). Only set
|
|
107
|
+
// wake_word/device when provided — never blank out a value the operator set earlier.
|
|
108
|
+
const interfaces =
|
|
109
|
+
profile.interfaces && typeof profile.interfaces === 'object' && !Array.isArray(profile.interfaces)
|
|
110
|
+
? { ...(profile.interfaces as Record<string, unknown>) }
|
|
111
|
+
: {}
|
|
112
|
+
const voicetalk =
|
|
113
|
+
(interfaces as { voicetalk?: unknown }).voicetalk &&
|
|
114
|
+
typeof (interfaces as { voicetalk?: unknown }).voicetalk === 'object'
|
|
115
|
+
? { ...((interfaces as { voicetalk: Record<string, unknown> }).voicetalk) }
|
|
116
|
+
: {}
|
|
117
|
+
if (wakeWord) voicetalk.wake_word = wakeWord
|
|
118
|
+
if (device) voicetalk.device = device
|
|
119
|
+
;(interfaces as Record<string, unknown>).voicetalk = voicetalk
|
|
120
|
+
|
|
121
|
+
// Identity stays the foundation's domain — spread the raw profile FIRST so every
|
|
122
|
+
// provisioned field (intelligence=natural, personality, runtimes, description, and any
|
|
123
|
+
// unknown adjacent-contract fields) is preserved; only `interfaces` is replaced.
|
|
124
|
+
const merged: Record<string, unknown> = { ...profile, interfaces }
|
|
125
|
+
|
|
126
|
+
writeJsonAtomic(profilePath, merged)
|
|
127
|
+
|
|
128
|
+
return { personality, profilePath, wakeWord, device }
|
|
129
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// npx-self-install — the package's self-deploy (layer 2 of the presence-runtime
|
|
2
|
+
// contract). `npx @agfpd/voicetalk-runtime` (a bare, no-subcommand invocation — the
|
|
3
|
+
// foundation's defaultNpxRunner runs `npx -y <pkg>` with NO args) IDEMPOTENTLY:
|
|
4
|
+
// (a) puts an executable `voicetalk-runtime` on PATH (a self-contained `bun build
|
|
5
|
+
// --compile` snapshot — decoupled from the npm-cache source dir, which GC's),
|
|
6
|
+
// (b) writes the runtime manifest at <IAPEER_ROOT>/runtimes/voicetalk/runtime.json.
|
|
7
|
+
//
|
|
8
|
+
// This is the npx↔foundation seam: after this, `iapeer create <owner> --runtime
|
|
9
|
+
// voicetalk` resolves the launcher on PATH (provisionPeer refuses a crash-looper if it
|
|
10
|
+
// is missing), bakes its abs path into the always-on plist (VOICETALK_RUNTIME_BIN), and
|
|
11
|
+
// runs the per-peer self-config hook the manifest declares. Two ordering invariants:
|
|
12
|
+
// the manifest lands under the RIGHT root (manifest.ts resolves IAPEER_ROOT from env),
|
|
13
|
+
// and the bin is on PATH BEFORE provision (this runs first).
|
|
14
|
+
//
|
|
15
|
+
// Bin placement: IAPEER_BIN_DIR override → else ~/.local/bin (the standard user-bin,
|
|
16
|
+
// same home as the foundation's `iapeer`, and on the launchd-minimal PATH the plist
|
|
17
|
+
// bakes — `~/.bun/bin:~/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`). Overwriting a
|
|
18
|
+
// legacy ~/.local/bin/voicetalk-runtime symlink with a real compiled bin is INTENDED
|
|
19
|
+
// (the package now owns this path); a sandbox proof sets IAPEER_BIN_DIR so a test never
|
|
20
|
+
// touches the real ~/.local/bin.
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'child_process'
|
|
23
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'
|
|
24
|
+
import { homedir } from 'os'
|
|
25
|
+
import { dirname, join } from 'path'
|
|
26
|
+
import { BIN_NAME, PACKAGE_NAME } from './constants.ts'
|
|
27
|
+
import { scaffoldHostDocs, type HostDocsResult } from './hostDocs.ts'
|
|
28
|
+
import { buildManifest, resolveIapeerRoot, writeManifestAtomic } from './manifest.ts'
|
|
29
|
+
|
|
30
|
+
/** The launcher binary name (`voicetalk-runtime`). Re-exported so callers/tests have a
|
|
31
|
+
* single import surface for the self-install contract. */
|
|
32
|
+
export { BIN_NAME }
|
|
33
|
+
|
|
34
|
+
export interface SelfInstallOptions {
|
|
35
|
+
env?: NodeJS.ProcessEnv
|
|
36
|
+
/** The cli.ts source entry to compile (default: this module's sibling cli.ts). When
|
|
37
|
+
* it is not a real .ts file on disk (running FROM a compiled bin), self-install
|
|
38
|
+
* falls back to copying the running executable instead of recompiling. */
|
|
39
|
+
sourceEntry?: string
|
|
40
|
+
/** The running interpreter / compiled bin (default process.execPath). When the
|
|
41
|
+
* source path is real and this is `bun`, it is the compiler we invoke. */
|
|
42
|
+
execPath?: string
|
|
43
|
+
/** On-host docs source dir. Default: `<package-root>/docs` derived from `sourceEntry`
|
|
44
|
+
* (`<pkg>/src/cli.ts` → `<pkg>/docs`). Missing (e.g. running from a compiled bin with
|
|
45
|
+
* no source tree) → soft-skip, never fails the install. */
|
|
46
|
+
docsSource?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SelfInstallBinMode = 'compiled' | 'copied-self'
|
|
50
|
+
|
|
51
|
+
export interface SelfInstallResult {
|
|
52
|
+
binPath: string
|
|
53
|
+
manifestPath: string
|
|
54
|
+
binMode: SelfInstallBinMode
|
|
55
|
+
root: string
|
|
56
|
+
binDir: string
|
|
57
|
+
/** On-host docs scaffolding verdict (best-effort; never blocks the install). */
|
|
58
|
+
docs: HostDocsResult
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve the bin dir: IAPEER_BIN_DIR override → else ~/.local/bin. */
|
|
62
|
+
export function resolveBinDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
63
|
+
const override = env.IAPEER_BIN_DIR?.trim()
|
|
64
|
+
if (override) return override
|
|
65
|
+
const home = env.HOME?.trim() || homedir()
|
|
66
|
+
if (!home) throw new Error('cannot resolve home directory for the bin dir')
|
|
67
|
+
return join(home, '.local', 'bin')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when `path` is a real .ts source file we can `bun build --compile`. */
|
|
71
|
+
function isCompilableSource(path: string | undefined): path is string {
|
|
72
|
+
if (!path || !path.endsWith('.ts')) return false
|
|
73
|
+
try {
|
|
74
|
+
return statSync(path).isFile()
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Is `execPath` a bun interpreter (so `<execPath> build` works)? When running
|
|
81
|
+
* `bun src/cli.ts`, process.execPath IS the bun binary (basename `bun`). A compiled
|
|
82
|
+
* voicetalk-runtime has basename `voicetalk-runtime` → NOT bun → we must not call
|
|
83
|
+
* `<it> build` (it would be `voicetalk-runtime build …`), so we fall back to copy-self. */
|
|
84
|
+
function isBunInterpreter(execPath: string): boolean {
|
|
85
|
+
const base = execPath.split('/').pop() ?? execPath
|
|
86
|
+
return base === 'bun' || base === 'bun-debug'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cleanup(path: string): void {
|
|
90
|
+
try {
|
|
91
|
+
if (existsSync(path)) renameSync(path, `${path}.dead`)
|
|
92
|
+
} catch {
|
|
93
|
+
// best-effort
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Tiny deterministic string hash for a unique-ish tmp suffix (Math.random is avoided
|
|
98
|
+
* to keep the tmp name reproducible within a run). */
|
|
99
|
+
function hashStr(s: string): number {
|
|
100
|
+
let h = 0
|
|
101
|
+
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
|
102
|
+
return h
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Self-install: place the launcher on PATH + write the manifest. IDEMPOTENT — a
|
|
107
|
+
* repeat run produces the same on-disk bin and a byte-identical manifest (atomic
|
|
108
|
+
* overwrite-in-place; no duplicated or divergent state). Returns what it did.
|
|
109
|
+
*/
|
|
110
|
+
export function selfInstall(opts: SelfInstallOptions = {}): SelfInstallResult {
|
|
111
|
+
const env = opts.env ?? process.env
|
|
112
|
+
const sourceEntry = opts.sourceEntry ?? join(import.meta.dir, 'cli.ts')
|
|
113
|
+
// `<pkg>/docs` from the source entry's package root (`<pkg>/src/cli.ts` → `<pkg>/docs`).
|
|
114
|
+
const docsSource = opts.docsSource ?? join(dirname(sourceEntry), '..', 'docs')
|
|
115
|
+
const execPath = opts.execPath ?? process.execPath
|
|
116
|
+
const binDir = resolveBinDir(env)
|
|
117
|
+
const binPath = join(binDir, BIN_NAME)
|
|
118
|
+
|
|
119
|
+
mkdirSync(binDir, { recursive: true, mode: 0o755 })
|
|
120
|
+
|
|
121
|
+
// Build to a sibling tmp in the SAME dir (rename is atomic only within one FS), then
|
|
122
|
+
// chmod +x and rename over the target. A reader of `voicetalk-runtime` never sees a
|
|
123
|
+
// half-written file, and a legacy symlink at binPath is replaced atomically.
|
|
124
|
+
const tmp = `${binPath}.tmp.${process.pid}.${Math.abs(hashStr(binPath + execPath))}`
|
|
125
|
+
let binMode: SelfInstallBinMode
|
|
126
|
+
|
|
127
|
+
if (isCompilableSource(sourceEntry) && isBunInterpreter(execPath)) {
|
|
128
|
+
// PRIMARY path: running under bun from the package source (npx → bin → bun cli.ts).
|
|
129
|
+
// Compile a self-contained snapshot so the installed bin does not depend on the npm
|
|
130
|
+
// cache (GC'd) or the source tree.
|
|
131
|
+
const r = spawnSync(execPath, ['build', '--compile', '--outfile', tmp, sourceEntry], {
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
env: env as Record<string, string>,
|
|
134
|
+
})
|
|
135
|
+
if (r.error || (r.status ?? 1) !== 0) {
|
|
136
|
+
cleanup(tmp)
|
|
137
|
+
throw new Error(
|
|
138
|
+
`self-install: bun build --compile failed: ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim()}`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
binMode = 'compiled'
|
|
142
|
+
} else {
|
|
143
|
+
// FALLBACK: running from an already-compiled bin (no .ts source / execPath is not
|
|
144
|
+
// bun). Re-assert the install by copying the running executable into binDir.
|
|
145
|
+
copyFileSync(execPath, tmp)
|
|
146
|
+
binMode = 'copied-self'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
chmodSync(tmp, 0o755)
|
|
150
|
+
renameSync(tmp, binPath)
|
|
151
|
+
|
|
152
|
+
// Manifest pins the ABSOLUTE installed bin into the self-config descriptor.
|
|
153
|
+
const manifest = buildManifest(binPath)
|
|
154
|
+
const manifestPath = writeManifestAtomic(manifest, env)
|
|
155
|
+
|
|
156
|
+
// On-host docs — stage this version's public docs to <root>/docs/voicetalk-runtime/.
|
|
157
|
+
// Best-effort: scaffoldHostDocs already soft-skips missing source / copy errors; the
|
|
158
|
+
// try/catch additionally swallows the fail-closed sandbox guard so a docs problem can
|
|
159
|
+
// NEVER fail the install (bin + manifest are the install contract, docs are a bonus).
|
|
160
|
+
let docs: HostDocsResult
|
|
161
|
+
try {
|
|
162
|
+
docs = scaffoldHostDocs(PACKAGE_NAME, docsSource, env)
|
|
163
|
+
} catch (e) {
|
|
164
|
+
docs = {
|
|
165
|
+
copied: false,
|
|
166
|
+
dest: join(resolveIapeerRoot(env), 'docs', PACKAGE_NAME),
|
|
167
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { binPath, manifestPath, binMode, root: resolveIapeerRoot(env), binDir, docs }
|
|
172
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"resolveJsonModule": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"types": ["bun-types"],
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
|
15
|
+
}
|