@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.
@@ -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'
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }