@agfpd/iapeer 0.2.99 → 0.4.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/README.md CHANGED
@@ -22,19 +22,33 @@ recipient peer — asleep? the daemon wakes it and delivers on wake
22
22
 
23
23
  ## Quick start
24
24
 
25
- Requirements: **macOS**, [bun](https://bun.sh) on `PATH`, `~/.local/bin` on `PATH`, and at least one agent runtime installed — Claude Code or Codex CLI (each AI peer is a live session of one).
25
+ Requirements: **macOS** (Linux is on the [Roadmap](#roadmap)) and at least one agent runtime — Claude Code or Codex CLI (each AI peer is a live session of one). The installer handles the rest ([bun](https://bun.sh) and `~/.local/bin` on `PATH`).
26
+
27
+ **Install — one line.** Installs bun if missing, builds the `iapeer` binary, then hands off to the interactive `onboard`:
28
+
29
+ ```sh
30
+ curl -fsSL https://agfpd.github.io/iapeer/install.sh | sh
31
+ ```
32
+
33
+ **Prefer not to pipe a script into your shell?** Run the same steps by hand (needs [bun](https://bun.sh) on `PATH`):
26
34
 
27
35
  ```sh
28
- # 1. install — the iapeer binary and the ~/.iapeer layout
29
- npx @agfpd/iapeer
36
+ # install — the iapeer binary and the ~/.iapeer layout
37
+ npx @agfpd/iapeer # or: bunx @agfpd/iapeer
30
38
 
31
- # 2. set up the host — starts the router daemon, then adds scheduler peers, Telegram, and shared memory
39
+ # set up the host — router daemon, scheduler peers, Telegram, shared memory
32
40
  iapeer onboard
41
+ ```
42
+
43
+ Either path runs `onboard` interactively: it surfaces the security checks (runtime sign-in, macOS Full Disk Access) and asks before installing the memory provider — nothing is auto-approved behind your back. You can read [`install.sh`](./install.sh) before running it, or preview every step with `IAPEER_INSTALL_DRYRUN=1`.
33
44
 
34
- # 3. create your first agent
45
+ **Then create your first agent and talk to it:**
46
+
47
+ ```sh
48
+ # create your first agent
35
49
  iapeer create assistant --runtime claude
36
50
 
37
- # 4. talk to it live in your terminal — detach with Ctrl-] (or just close it; the agent keeps running)
51
+ # talk to it live in your terminal — detach with Ctrl-] (or just close it; the agent keeps running)
38
52
  iapeer attach assistant
39
53
  ```
40
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.99",
3
+ "version": "0.4.0",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -56,13 +56,17 @@
56
56
  "@modelcontextprotocol/sdk": "1.29.0",
57
57
  "@xterm/addon-serialize": "0.14.0",
58
58
  "@xterm/headless": "6.0.0",
59
+ "ink": "^7.1.0",
59
60
  "proper-lockfile": "^4.1.2",
61
+ "react": "^19.2.7",
62
+ "react-devtools-core": "^7.0.1",
60
63
  "transliteration": "^2.6.1"
61
64
  },
62
65
  "devDependencies": {
63
66
  "@types/bun": "latest",
64
67
  "@types/node": "^22.0.0",
65
68
  "@types/proper-lockfile": "^4.1.4",
69
+ "@types/react": "^19.2.17",
66
70
  "typescript": "^5.6.0"
67
71
  }
68
72
  }
package/src/cli/index.ts CHANGED
@@ -943,6 +943,7 @@ const USAGE = `usage: iapeer <verb> [args]
943
943
  install build binary + global scaffold + daemon plist (one bootstrap)
944
944
  update [version] [--force] pull latest (or exact) @agfpd/iapeer from npm + restart daemon + recycle owned infra jobs
945
945
  rollback revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs
946
+ uninstall [--dry-run] [--yes] [--remove-codesign-identity] remove THIS foundation install (binary, ~/.iapeer, daemon plist, PATH lines); refuses if a persistent-peer fleet is present
946
947
  version | --version | -v print the installed binary's version
947
948
  help | --help | -h print this usage (works appended to any verb; executes nothing)
948
949
  daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
@@ -1002,6 +1003,26 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1002
1003
  try {
1003
1004
  switch (verb) {
1004
1005
  case 'onboard': {
1006
+ // Interactive TUI wizard (Фаза TUI-редизайн) — the DEFAULT for an
1007
+ // interactive `iapeer onboard`. Routed ONLY in a real interactive terminal
1008
+ // and never for an automation path (--accept-risk / --dry-run / --no-memory
1009
+ // / --infra), which stay on the deterministic linear flow below. Escape
1010
+ // hatch: IAPEER_ONBOARD_WIZARD=0 forces the linear path (recovery / scripts
1011
+ // that want the old flow). The wizard owns its own gate screen, so we route
1012
+ // BEFORE the readline gate. It fails closed: no real TTY →
1013
+ // WIZARD_NOT_INTERACTIVE → fall through to linear.
1014
+ const wizardOptOut = /^(0|false|no)$/i.test((env.IAPEER_ONBOARD_WIZARD ?? '').trim())
1015
+ const automationFlag =
1016
+ flags['accept-risk'] === true ||
1017
+ flags['dry-run'] === true ||
1018
+ flags['no-memory'] === true ||
1019
+ typeof flags.infra === 'string'
1020
+ if (!wizardOptOut && !automationFlag && process.stdin.isTTY === true && process.stdout.isTTY === true) {
1021
+ const { runOnboardWizard, WIZARD_NOT_INTERACTIVE } = await import('../tui/onboard/run.tsx')
1022
+ const wc = await runOnboardWizard({ env })
1023
+ if (wc !== WIZARD_NOT_INTERACTIVE) return wc
1024
+ // not a real interactive TTY after all → linear path below
1025
+ }
1005
1026
  // SECURITY GATE (pre-release): the operator must consciously accept the risk of
1006
1027
  // beta infra with live agents BEFORE onboard mutates the host. --accept-risk /
1007
1028
  // IAPEER_ACCEPT_RISK accepts non-interactively (one-liner installers / CI); a
@@ -1619,6 +1640,53 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1619
1640
  ;(failed ? errOut : out)(msg)
1620
1641
  return failed ? 1 : 0
1621
1642
  }
1643
+ case 'uninstall': {
1644
+ // Symmetric foundation removal (namespace-safe; REFUSES on a foreign fleet).
1645
+ // DESTRUCTIVE — removes ~/.iapeer (all peers/memory/logs). --dry-run previews;
1646
+ // --yes skips the confirm; --remove-codesign-identity also drops the shared
1647
+ // agfpd signing identity (otherwise kept).
1648
+ const { planUninstall, executeUninstall } = await import('../uninstall/index.ts')
1649
+ const removeCodesign = flags['remove-codesign-identity'] === true
1650
+ const plan = planUninstall({ env, removeCodesignIdentity: removeCodesign })
1651
+ if (plan.refused) {
1652
+ errOut(`uninstall refused — ${plan.refused.reason}\n`)
1653
+ return 1
1654
+ }
1655
+ out('iapeer uninstall — will remove:\n')
1656
+ for (const it of plan.items) {
1657
+ const mark = it.present ? '•' : '·'
1658
+ const tail = it.detail ? ` — ${it.detail}` : it.present ? '' : ' — already gone'
1659
+ out(` ${mark} ${it.what}${it.path ? ` (${it.path})` : ''}${tail}\n`)
1660
+ }
1661
+ if (flags['dry-run'] === true) {
1662
+ out('\n(dry-run — nothing removed)\n')
1663
+ return 0
1664
+ }
1665
+ if (flags.yes !== true) {
1666
+ if (process.stdin.isTTY !== true) {
1667
+ errOut('\nuninstall is destructive (removes ~/.iapeer) — re-run with --yes in a non-interactive shell.\n')
1668
+ return 2
1669
+ }
1670
+ const { createInterface } = await import('node:readline/promises')
1671
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
1672
+ let answer = ''
1673
+ try {
1674
+ answer = (await rl.question('\nThis permanently removes iapeer and ALL its data. Continue? [y/N] ')).trim().toLowerCase()
1675
+ } finally {
1676
+ rl.close()
1677
+ }
1678
+ if (answer !== 'y' && answer !== 'yes') {
1679
+ out('uninstall aborted.\n')
1680
+ return 1
1681
+ }
1682
+ }
1683
+ const res = executeUninstall({ env, removeCodesignIdentity: removeCodesign })
1684
+ for (const r of res.removed) out(` removed: ${r}\n`)
1685
+ for (const s of res.skipped) out(` skipped (absent): ${s}\n`)
1686
+ for (const f of res.failed) errOut(` FAILED: ${f.what} — ${f.detail}\n`)
1687
+ out(res.failed.length ? '\nuninstall finished with errors (see above).\n' : '\niapeer uninstalled. (bun left in place.)\n')
1688
+ return res.failed.length ? 1 : 0
1689
+ }
1622
1690
  case 'install': {
1623
1691
  // UNIFIED foundation install (contract Установка §1 — "один npx ставит
1624
1692
  // фундамент"): ONE command does all three install-phase steps that used to be
package/src/index.ts CHANGED
@@ -23,6 +23,8 @@ export * from './init/index.ts'
23
23
  export * from './install/index.ts'
24
24
  // Update — the cloud-only deploy path (`iapeer update`: npm latest → rebuild → restart).
25
25
  export * from './update/index.ts'
26
+ // Uninstall — symmetric foundation removal (namespace-safe; refuses on a foreign fleet).
27
+ export * from './uninstall/index.ts'
26
28
  // Onboard — the host-phase (idempotent marketplace registration in claude + codex).
27
29
  export * from './onboard/index.ts'
28
30
  // Memory slot — the declarative provider slot (контракт «Слот памяти»): status read + onboard step.
@@ -25,11 +25,26 @@ export interface MemoryOnboardOptions {
25
25
  skip?: boolean
26
26
  /** --memory <pkg>: override the provider package (default @agfpd/iapeer-memory). */
27
27
  package?: string
28
+ /** Host agentic runtime to thread to the provider init (`--runtime <rt>`) — the
29
+ * onboard↔init contract: the provider provisions its role-peers (index/watcher/
30
+ * timer) on THIS runtime instead of guessing. Omitted on a no-runtime host →
31
+ * the provider degrades (base+BM25, role-peers skipped). */
32
+ runtime?: string
28
33
  dryRun?: boolean
29
34
  env?: NodeJS.ProcessEnv
35
+ /** Wall-clock backstop (ms) for the provider-init subprocess — a hung provider
36
+ * (e.g. a stalled native build) must NEVER wedge onboard (memory is optional).
37
+ * The interactive wizard passes a generous value; the linear path leaves it
38
+ * unset (no timeout). */
39
+ timeoutMs?: number
30
40
  /** Injectable runner (tests). Default: availability probe + `npx -y <pkg> init …`
31
41
  * with inherited stdio. */
32
- runInit?: (pkg: string, args: string[], env: NodeJS.ProcessEnv) => { status: number | null; unavailable: boolean }
42
+ runInit?: (
43
+ pkg: string,
44
+ args: string[],
45
+ env: NodeJS.ProcessEnv,
46
+ timeoutMs?: number,
47
+ ) => { status: number | null; unavailable: boolean }
33
48
  }
34
49
 
35
50
  export interface MemoryOnboardResult {
@@ -49,17 +64,24 @@ function defaultRunInit(
49
64
  pkg: string,
50
65
  args: string[],
51
66
  env: NodeJS.ProcessEnv,
67
+ timeoutMs?: number,
52
68
  ): { status: number | null; unavailable: boolean } {
53
69
  // Availability probe FIRST (cheap, side-effect-free): an unpublished package /
54
70
  // no network → soft-skip per the contract (the provider's release order must
55
- // never block the core's onboard).
56
- const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env })
71
+ // never block the core's onboard). Bounded so the probe itself cannot hang.
72
+ const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env, timeout: 60_000 })
57
73
  if (probe.status !== 0) return { status: probe.status, unavailable: true }
58
74
  // INHERITED stdio — the provider owns its install questions (tty interactive
59
75
  // happens here, once). npx bin-name nuance (cf. the 0.2.9 update pitfall): with
60
76
  // the provider bin already on PATH npx runs the INSTALLED one — acceptable for
61
77
  // an idempotent init (unlike the self-update case where it was a structural bug).
62
- const r = spawnSync('npx', ['-y', pkg, ...args], { stdio: 'inherit', env })
78
+ // timeout: a generous wall-clock backstop so a hung provider can't wedge onboard
79
+ // (the caller also makes Ctrl-C skip just this step).
80
+ const r = spawnSync('npx', ['-y', pkg, ...args], {
81
+ stdio: 'inherit',
82
+ env,
83
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
84
+ })
63
85
  return { status: r.status, unavailable: false }
64
86
  }
65
87
 
@@ -91,12 +113,12 @@ export async function onboardMemoryProvider(opts: MemoryOnboardOptions = {}): Pr
91
113
  detail: `slot is occupied by "${existing.provider}" (${existing.package}) — uninstall it first`,
92
114
  }
93
115
  }
94
- const args = ['init', ...coreKnownInitArgs(env)]
116
+ const args = ['init', ...coreKnownInitArgs(env), ...(opts.runtime ? ['--runtime', opts.runtime] : [])]
95
117
  if (opts.dryRun) {
96
118
  return { state: 'dry-run', provider: null, detail: `would run: npx -y ${pkg} ${args.join(' ')}` }
97
119
  }
98
120
  const run = opts.runInit ?? defaultRunInit
99
- const r = run(pkg, args, env)
121
+ const r = run(pkg, args, env, opts.timeoutMs)
100
122
  if (r.unavailable) {
101
123
  return {
102
124
  state: 'skipped-unavailable',
@@ -0,0 +1,354 @@
1
+ // OnboardApp — the interactive onboard wizard (Ink). Owns the interactive +
2
+ // live-progress part and EXITS; the inherited-stdio memory install + the final
3
+ // summary run post-Ink (see run.tsx). Drives the SAME backend functions as the
4
+ // linear non-interactive path (no logic duplication), honoring the phase
5
+ // principles: explicit prompts (what to press), visible progress on otherwise
6
+ // silent steps (daemon start), and a clear next-step instead of a cryptic code.
7
+
8
+ import React, { useEffect, useState } from 'react'
9
+ import { Box, Text, useApp, useInput, useStdin } from 'ink'
10
+ import { ONBOARD_SECURITY_WARNING } from '../../onboard/risk.ts'
11
+ import { onboardHost, runtimeAuthNote, tccFullDiskAccessNote, type OnboardRuntime } from '../../onboard/index.ts'
12
+ import { probeFullDiskAccess } from '../../status/index.ts'
13
+ import { ensureDaemonStarted } from '../../daemon/main.ts'
14
+ import { iapeerBinPath } from '../../install/index.ts'
15
+
16
+ /** What the wizard hands back to run.tsx for the post-Ink phase (memory install +
17
+ * summary). */
18
+ export interface WizardResult {
19
+ code: number
20
+ memoryConsent: boolean
21
+ advisories: string[]
22
+ summary: string[]
23
+ telegramConsent: boolean
24
+ /** Host agentic runtime detected during the run (claude if installed, else
25
+ * codex, else undefined on a no-runtime host) — threaded to the memory
26
+ * provider init as `--runtime` per the onboard↔init contract. */
27
+ hostRuntime?: OnboardRuntime
28
+ }
29
+
30
+ type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'declined' | 'finishing'
31
+ type StepStatus = 'pending' | 'running' | 'ok' | 'warn' | 'fail'
32
+ interface Step {
33
+ key: string
34
+ label: string
35
+ status: StepStatus
36
+ detail?: string
37
+ /** Honest "this can take a moment" note shown while the step is running (the
38
+ * spinner can't animate during a blocking spawnSync, so the label carries the
39
+ * expectation). */
40
+ hint?: string
41
+ }
42
+
43
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
44
+
45
+ function useSpinnerFrame(active: boolean): string {
46
+ const [i, setI] = useState(0)
47
+ useEffect(() => {
48
+ if (!active) return
49
+ const t = setInterval(() => setI(n => (n + 1) % SPINNER.length), 80)
50
+ return () => clearInterval(t)
51
+ }, [active])
52
+ return SPINNER[i]
53
+ }
54
+
55
+ function glyph(status: StepStatus, spin: string): string {
56
+ switch (status) {
57
+ case 'running':
58
+ return spin
59
+ case 'ok':
60
+ return '✓'
61
+ case 'warn':
62
+ return '!'
63
+ case 'fail':
64
+ return '✗'
65
+ default:
66
+ return '·'
67
+ }
68
+ }
69
+
70
+ function color(status: StepStatus): string {
71
+ switch (status) {
72
+ case 'ok':
73
+ return 'green'
74
+ case 'warn':
75
+ return 'yellow'
76
+ case 'fail':
77
+ return 'red'
78
+ case 'running':
79
+ return 'cyan'
80
+ default:
81
+ return 'gray'
82
+ }
83
+ }
84
+
85
+ export function OnboardApp({
86
+ env,
87
+ onResult,
88
+ }: {
89
+ env: NodeJS.ProcessEnv
90
+ onResult: (r: WizardResult) => void
91
+ }): React.ReactElement {
92
+ const { exit } = useApp()
93
+ const { isRawModeSupported } = useStdin()
94
+ const [phase, setPhase] = useState<Phase>('gate')
95
+ const [steps, setSteps] = useState<Step[]>([])
96
+ const [advisories, setAdvisories] = useState<string[]>([])
97
+ const [hostRuntime, setHostRuntime] = useState<OnboardRuntime | undefined>(undefined)
98
+ const [telegramConsent, setTelegramConsent] = useState(false)
99
+ const spin = useSpinnerFrame(phase === 'running')
100
+
101
+ const finish = (memoryConsent: boolean): void => {
102
+ const failed = steps.some(s => s.status === 'fail')
103
+ const summary = steps.map(s => {
104
+ const mark = s.status === 'ok' ? '✓' : s.status === 'warn' ? '!' : s.status === 'fail' ? '✗' : '·'
105
+ return ` ${mark} ${s.label}${s.detail ? ` — ${s.detail}` : ''}`
106
+ })
107
+ summary.unshift(failed ? 'Onboard finished with errors:' : 'Onboard complete:')
108
+ onResult({ code: failed ? 1 : 0, memoryConsent, telegramConsent, advisories, summary, hostRuntime })
109
+ setPhase('finishing')
110
+ setTimeout(() => exit(), 30)
111
+ }
112
+
113
+ // Gate input — explicit y/N.
114
+ useInput(
115
+ (input, key) => {
116
+ if (input === 'y' || input === 'Y') setPhase('running')
117
+ else if (input === 'n' || input === 'N' || key.escape) setPhase('declined')
118
+ },
119
+ { isActive: phase === 'gate' },
120
+ )
121
+
122
+ // Telegram consent — DEFAULT-YES ([Y/n]). Records the decision; the actual setup
123
+ // (install + ask name/user_id) runs post-Ink (its readline needs a cooked
124
+ // terminal). Then → memory consent.
125
+ useInput(
126
+ (input, key) => {
127
+ if (input === 'n' || input === 'N') {
128
+ setTelegramConsent(false)
129
+ setPhase('memory')
130
+ } else if (input === 'y' || input === 'Y' || key.return) {
131
+ setTelegramConsent(true)
132
+ setPhase('memory')
133
+ }
134
+ },
135
+ { isActive: phase === 'telegram' },
136
+ )
137
+
138
+ // Memory consent — DEFAULT-YES ([Y/n]: Enter or y → install).
139
+ useInput(
140
+ (input, key) => {
141
+ if (input === 'n' || input === 'N') finish(false)
142
+ else if (input === 'y' || input === 'Y' || key.return) finish(true)
143
+ },
144
+ { isActive: phase === 'memory' },
145
+ )
146
+
147
+ // Declined → exit 1 (no host mutation).
148
+ useEffect(() => {
149
+ if (phase !== 'declined') return
150
+ onResult({ code: 1, memoryConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
151
+ const t = setTimeout(() => exit(), 30)
152
+ return () => clearTimeout(t)
153
+ }, [phase, onResult, exit])
154
+
155
+ // Running phase — drive the backend steps sequentially with live status.
156
+ useEffect(() => {
157
+ if (phase !== 'running') return
158
+ let cancelled = false
159
+ const set = (k: string, patch: Partial<Step>) =>
160
+ setSteps(prev => prev.map(s => (s.key === k ? { ...s, ...patch } : s)))
161
+ setSteps([
162
+ { key: 'daemon', label: 'Start router daemon', status: 'pending', hint: 'a few seconds' },
163
+ { key: 'market', label: 'Register marketplace (claude, codex)', status: 'pending' },
164
+ { key: 'auth', label: 'Check runtime sign-in', status: 'pending' },
165
+ { key: 'fda', label: 'Check macOS Full Disk Access', status: 'pending' },
166
+ {
167
+ key: 'notifier',
168
+ label: 'Install scheduler (timer · watcher)',
169
+ status: 'pending',
170
+ hint: 'first run can take up to a minute',
171
+ },
172
+ ])
173
+
174
+ // Backend steps use synchronous spawnSync (launchctl, runtime plugin list) —
175
+ // those block the event loop, so we yield (tick) after each status change so
176
+ // Ink flushes a frame and steps paint as they progress, not all at once.
177
+ // (Intra-step spinner can't animate while a single spawnSync blocks — a known
178
+ // limitation until those backend calls go async; the step still shows.)
179
+ const tick = () => new Promise<void>(r => setTimeout(r, 0))
180
+
181
+ void (async () => {
182
+ const adv: string[] = []
183
+ set('daemon', { status: 'running' })
184
+ await tick()
185
+ try {
186
+ const d = await ensureDaemonStarted({ env })
187
+ const ok = d.state !== 'failed' && d.healthy !== false
188
+ set('daemon', { status: ok ? 'ok' : 'fail', detail: d.detail ?? d.state })
189
+ } catch (e) {
190
+ set('daemon', { status: 'fail', detail: e instanceof Error ? e.message : String(e) })
191
+ }
192
+ if (cancelled) return
193
+ await tick()
194
+
195
+ set('market', { status: 'running' })
196
+ await tick()
197
+ const r = onboardHost({ env })
198
+ const installed: OnboardRuntime[] = []
199
+ for (const m of r.marketplaces) if (m.state !== 'runtime-missing') installed.push(m.runtime)
200
+ // host-runtime for the memory init contract: claude if present (covers
201
+ // both-installed → claude default), else codex, else undefined (no runtime).
202
+ setHostRuntime(installed.includes('claude') ? 'claude' : installed[0])
203
+ const mFail = r.marketplaces.some(m => m.state === 'failed')
204
+ const allMissing = r.marketplaces.length > 0 && r.marketplaces.every(m => m.state === 'runtime-missing')
205
+ set('market', {
206
+ // runtime-missing is NOT success — show '!' (warn), never a contradictory ✓.
207
+ status: mFail ? 'fail' : allMissing ? 'warn' : 'ok',
208
+ detail: allMissing
209
+ ? 'no agent runtime installed — skipped'
210
+ : r.marketplaces
211
+ .map(m => `${m.runtime}: ${m.state === 'runtime-missing' ? 'not installed' : m.state}`)
212
+ .join(', '),
213
+ })
214
+ if (cancelled) return
215
+ await tick()
216
+
217
+ set('auth', { status: 'running' })
218
+ await tick()
219
+ if (installed.length === 0) {
220
+ set('auth', { status: 'warn', detail: 'no runtime installed' })
221
+ adv.push(
222
+ '⚠ SETUP INCOMPLETE — no agent runtime found. iapeer needs Claude Code or Codex CLI\n' +
223
+ ' to run peers. Install one, sign in, then re-run `iapeer onboard`.',
224
+ )
225
+ } else {
226
+ const notes = installed.map(rt => runtimeAuthNote(rt, env)).filter((n): n is string => n != null)
227
+ set('auth', {
228
+ status: notes.length ? 'warn' : 'ok',
229
+ detail: notes.length ? `${notes.length} runtime(s) not signed in` : installed.join(', '),
230
+ })
231
+ adv.push(...notes)
232
+ }
233
+ setAdvisories([...adv]) // surface SETUP-INCOMPLETE / auth advisories IN the wizard now
234
+ if (cancelled) return
235
+
236
+ set('fda', { status: 'running' })
237
+ await tick()
238
+ const fda = probeFullDiskAccess(env)
239
+ const tcc = tccFullDiskAccessNote({ fda, binPath: iapeerBinPath(env) })
240
+ set('fda', { status: tcc ? 'warn' : 'ok', detail: fda === true ? 'granted' : tcc ? 'not granted' : 'n/a' })
241
+ if (tcc) adv.push(tcc)
242
+ if (cancelled) return
243
+ await tick()
244
+
245
+ // notifier — the scheduler (timer · watcher) that memory's index upkeep
246
+ // rides on; a silent default. Its npx install captures stdio (no terminal
247
+ // contention), so it runs in-wizard like the other steps. Idempotent: a
248
+ // present package/peers → no-op. A failure must NOT fail onboard (degrade).
249
+ set('notifier', { status: 'running' })
250
+ await tick()
251
+ try {
252
+ // canonical backbone step (same as the linear onboard path): zero-question,
253
+ // captured-stdio, idempotent, soft-skip on unavailable.
254
+ const { onboardNotifierStep } = await import('../../onboard/steps.ts')
255
+ const ns = await onboardNotifierStep({ env, warn: () => {} })
256
+ set('notifier', {
257
+ status: ns.state === 'deployed' ? 'ok' : 'warn',
258
+ detail: ns.state === 'deployed' ? `${ns.peers.length} peer(s)` : (ns.detail ?? ns.state),
259
+ })
260
+ if (ns.state !== 'deployed') adv.push(`Scheduler (notifier): ${ns.detail ?? ns.state}`)
261
+ } catch (e) {
262
+ set('notifier', { status: 'warn', detail: (e instanceof Error ? e.message : 'failed').slice(0, 60) })
263
+ }
264
+ if (cancelled) return
265
+ await tick()
266
+
267
+ setAdvisories([...adv])
268
+ setPhase('telegram')
269
+ })()
270
+
271
+ return () => {
272
+ cancelled = true
273
+ }
274
+ }, [phase, env])
275
+
276
+ if (isRawModeSupported === false) {
277
+ return (
278
+ <Text color="yellow">
279
+ iapeer onboard needs an interactive terminal. Open a normal terminal and run: iapeer onboard
280
+ </Text>
281
+ )
282
+ }
283
+
284
+ if (phase === 'gate') {
285
+ return (
286
+ <Box flexDirection="column">
287
+ <Box borderStyle="round" borderColor="yellow" paddingX={1} flexDirection="column">
288
+ <Text color="yellow" bold>
289
+ Security warning — please read.
290
+ </Text>
291
+ <Text>{ONBOARD_SECURITY_WARNING.replace(/^Security warning — please read\.\n\n/, '')}</Text>
292
+ </Box>
293
+ <Box marginTop={1}>
294
+ <Text bold>Continue? </Text>
295
+ <Text dimColor>[y/N] </Text>
296
+ </Box>
297
+ </Box>
298
+ )
299
+ }
300
+
301
+ if (phase === 'declined') {
302
+ return <Text color="red">onboard aborted — risk not accepted.</Text>
303
+ }
304
+
305
+ // running | telegram | memory | finishing
306
+ return (
307
+ <Box flexDirection="column">
308
+ <Box marginBottom={1}>
309
+ <Text bold color="cyan">
310
+ iapeer
311
+ </Text>
312
+ <Text bold> · setting up the host</Text>
313
+ </Box>
314
+ <Box flexDirection="column">
315
+ {steps.map(s => (
316
+ <Box key={s.key}>
317
+ <Text color={color(s.status)}>{glyph(s.status, spin)} </Text>
318
+ <Text>{s.label}</Text>
319
+ {s.detail ? (
320
+ <Text dimColor> — {s.detail}</Text>
321
+ ) : s.status === 'running' && s.hint ? (
322
+ <Text dimColor> — {s.hint}…</Text>
323
+ ) : null}
324
+ </Box>
325
+ ))}
326
+ </Box>
327
+ {advisories.length > 0 ? (
328
+ <Box marginTop={1} flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
329
+ {advisories.map((a, i) => (
330
+ <Text key={i} color="yellow">
331
+ {a}
332
+ </Text>
333
+ ))}
334
+ </Box>
335
+ ) : null}
336
+ {phase === 'telegram' ? (
337
+ <Box marginTop={1} flexDirection="column">
338
+ <Text bold color="cyan">
339
+ ? Set up Telegram — talk to your agents from your phone?
340
+ </Text>
341
+ <Text dimColor> It will ask your name + Telegram user_id next. (bot token comes later) [Y/n]</Text>
342
+ </Box>
343
+ ) : null}
344
+ {phase === 'memory' ? (
345
+ <Box marginTop={1} flexDirection="column">
346
+ <Text bold color="cyan">
347
+ ? Install the shared-memory provider (@agfpd/iapeer-memory)?
348
+ </Text>
349
+ <Text dimColor> Gives your agents a shared knowledge vault. [Y/n]</Text>
350
+ </Box>
351
+ ) : null}
352
+ </Box>
353
+ )
354
+ }
@@ -0,0 +1,110 @@
1
+ // runOnboardWizard — the Ink entry point for the interactive onboard wizard.
2
+ //
3
+ // Boundary (контракт «интерактив только в реальном TTY», фаза TUI-редизайн):
4
+ // the wizard runs ONLY in a real interactive terminal. The CLI routes here only
5
+ // when stdin AND stdout are TTYs and no non-interactive flag is set; install.sh
6
+ // never launches it (it prints a next-step to open a normal terminal). As a
7
+ // belt-and-suspenders guard against the readline-class wedge (Bun raw-mode on a
8
+ // redirect-opened /dev/tty reports isTTY=true but never receives input), we also
9
+ // fail CLOSED here: no real TTY → return WIZARD_NOT_INTERACTIVE so the caller
10
+ // falls back to the linear non-interactive path instead of rendering raw and
11
+ // hanging.
12
+ //
13
+ // Structure: Ink owns the interactive + live-progress part (gate, daemon/
14
+ // marketplace/auth/FDA steps, memory consent) and EXITS. The memory provider
15
+ // install — an INHERITED-STDIO subprocess (it owns its own tty questions) — runs
16
+ // AFTER Ink has unmounted, so the two never share the terminal at once. The final
17
+ // summary is plain text post-Ink (it folds in the memory outcome).
18
+
19
+ import React from 'react'
20
+ import { render } from 'ink'
21
+ import { OnboardApp, type WizardResult } from './app.tsx'
22
+
23
+ /** Sentinel: the environment is not a real interactive TTY — caller should fall
24
+ * back to the linear non-interactive onboard path (never render raw + hang). */
25
+ export const WIZARD_NOT_INTERACTIVE = -1
26
+
27
+ export interface OnboardWizardOptions {
28
+ env?: NodeJS.ProcessEnv
29
+ }
30
+
31
+ /** Render the interactive onboard wizard. Resolves with the process exit code, or
32
+ * WIZARD_NOT_INTERACTIVE when there is no real TTY to drive it. */
33
+ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise<number> {
34
+ const env = opts.env ?? process.env
35
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
36
+ return WIZARD_NOT_INTERACTIVE
37
+ }
38
+
39
+ let result: WizardResult = { code: 0, memoryConsent: false, telegramConsent: false, advisories: [], summary: [] }
40
+ try {
41
+ const app = render(<OnboardApp env={env} onResult={r => (result = r)} />)
42
+ await app.waitUntilExit()
43
+ } catch {
44
+ return WIZARD_NOT_INTERACTIVE
45
+ }
46
+
47
+ // Post-Ink interactive provisioning — telegram THEN memory (the canonical order:
48
+ // telegram creates the human peer that memory's --human resolves from). These own
49
+ // the terminal (inherited stdio / readline), so they run AFTER Ink unmounts (no
50
+ // contention). NON-WEDGE: ignore SIGINT in the parent so Ctrl-C kills only the
51
+ // CURRENT step's child/readline and onboard continues to the summary, instead of
52
+ // the whole flow dying. The no-op handler is queued past the blocking spawnSync.
53
+ const prevInt = process.listeners('SIGINT')
54
+ process.removeAllListeners('SIGINT')
55
+ const ignore = (): void => {}
56
+ process.on('SIGINT', ignore)
57
+ try {
58
+ if (result.telegramConsent) {
59
+ const { onboardTelegramStep } = await import('../../onboard/steps.ts')
60
+ process.stdout.write('\nSetting up Telegram… (Ctrl-C to skip — add later: `iapeer create <you> --runtime telegram`)\n')
61
+ try {
62
+ const ts = await onboardTelegramStep({ env })
63
+ process.stdout.write(`telegram: ${ts.state}${ts.detail ? ` — ${ts.detail}` : ''}\n`)
64
+ } catch (e) {
65
+ process.stdout.write(`telegram: step skipped — ${e instanceof Error ? e.message : String(e)}\n`)
66
+ }
67
+ } else {
68
+ process.stdout.write('\ntelegram: skipped — add later with `iapeer create <you> --runtime telegram`\n')
69
+ }
70
+
71
+ if (result.memoryConsent) {
72
+ const { onboardMemoryProvider } = await import('../../onboard/memory.ts')
73
+ process.stdout.write(
74
+ '\nInstalling the shared-memory provider (@agfpd/iapeer-memory)…\n' +
75
+ 'This can take a few minutes — it builds a native module. Memory is OPTIONAL:\n' +
76
+ 'press Ctrl-C to skip it (set it up later with `npx @agfpd/iapeer-memory init`).\n\n',
77
+ )
78
+ try {
79
+ const mem = await onboardMemoryProvider({ env, runtime: result.hostRuntime, timeoutMs: 12 * 60_000 })
80
+ const label = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
81
+ process.stdout.write(`\nmemory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${label})\n`)
82
+ } catch (e) {
83
+ process.stdout.write(
84
+ `\nmemory: step skipped — ${e instanceof Error ? e.message : String(e)} (set up later: \`npx @agfpd/iapeer-memory init\`)\n`,
85
+ )
86
+ }
87
+ } else {
88
+ process.stdout.write('\nmemory: skipped — set it up later with `npx @agfpd/iapeer-memory init`\n')
89
+ }
90
+ } finally {
91
+ process.removeListener('SIGINT', ignore)
92
+ for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)
93
+ }
94
+
95
+ // Final summary (plain text, post-Ink).
96
+ process.stdout.write('\n')
97
+ for (const line of result.summary) process.stdout.write(line + '\n')
98
+ if (result.advisories.length > 0) {
99
+ process.stdout.write('\n')
100
+ for (const a of result.advisories) process.stdout.write(a + '\n')
101
+ }
102
+ process.stdout.write('\nNext steps:\n')
103
+ process.stdout.write(' • Create your first agent: iapeer create <name> --runtime claude\n')
104
+ if (result.telegramConsent) {
105
+ // The wizard set up the human peer (name + user_id) but NOT the bot itself —
106
+ // the bot token is a separate, explicit step (no surprise / don't-make-them-guess).
107
+ process.stdout.write(' • Activate Telegram: iapeer connect telegram (paste a bot token from @BotFather)\n')
108
+ }
109
+ return result.code
110
+ }
@@ -0,0 +1,244 @@
1
+ // uninstall — symmetric removal of THIS foundation install (the install.sh / `iapeer
2
+ // install` / onboard artifacts). NAMESPACE-SAFE: it never touches a foreign
3
+ // persistent-peer fleet. A peer plist at the shared `com.iapeer.*` namespace WITHOUT
4
+ // the ownership sentinel is foreign; if ANY is present we REFUSE (option-1) — removing
5
+ // ~/.iapeer would nuke the fleet's own state, which H4 forbids. The operator removes
6
+ // their peers first (`iapeer remove <p>`), then uninstalls.
7
+ //
8
+ // Removed: ~/.local/bin/iapeer (+ .prev) · the daemon plist (com.agfpd.iapeer,
9
+ // bootout→rm) · foundation-OWNED infra jobs (sentinel-marked com.iapeer.*, bootout→rm)
10
+ // · the ~/.iapeer scaffold · the installer's PATH lines in the shell profile.
11
+ // CONSERVATIVE (kept by default): the shared agfpd-stack codesign identity (removed
12
+ // only with removeCodesignIdentity) and bun (the user's). Plan is read-only — `--dry-run`
13
+ // renders it without touching anything. Fail-closed in the test sandbox: a real
14
+ // ~/.iapeer / ~/.local/bin path under IAPEER_TEST_SANDBOX=1 throws (tests must inject
15
+ // temp roots), so a hermetic test can never delete the real install.
16
+
17
+ import { spawnSync } from 'child_process'
18
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
19
+ import { homedir } from 'os'
20
+ import { join } from 'path'
21
+ import { DAEMON_PLIST_LABEL, IapError } from '../core/index.ts'
22
+ import { iapeerBinPath } from '../install/index.ts'
23
+ import { SIGNING_IDENTITY_CN } from '../install/signing.ts'
24
+ import { daemonPlistPath } from '../daemon/main.ts'
25
+ import { isFoundationOwnedPlist, launchAgentsDir } from '../launch/launchd.ts'
26
+ import { resolveGlobalRoot } from '../storage/index.ts'
27
+
28
+ /** The marker comment install.sh writes above its PATH line (strip both lines). */
29
+ const INSTALLER_PROFILE_MARKER = '# Added by the iapeer installer'
30
+
31
+ export type UninstallActionKind =
32
+ | 'remove-file'
33
+ | 'remove-dir'
34
+ | 'bootout-remove-plist'
35
+ | 'strip-profile-lines'
36
+ | 'remove-keychain-identity'
37
+
38
+ export interface UninstallItem {
39
+ what: string
40
+ action: UninstallActionKind
41
+ path?: string
42
+ /** True when the artifact exists / there is something to do (dry-run shows all;
43
+ * absent ones render as "already gone"). */
44
+ present: boolean
45
+ detail?: string
46
+ }
47
+
48
+ export interface UninstallPlan {
49
+ items: UninstallItem[]
50
+ /** Set when a FOREIGN persistent-peer fleet is present → the whole uninstall is
51
+ * refused (option-1, H4-safe). The caller prints the guidance and aborts. */
52
+ refused?: { reason: string; foreignLabels: string[] }
53
+ }
54
+
55
+ /** Injected launchctl/security runner (tests). Default: real spawnSync. */
56
+ export type SysRunner = (cmd: string, args: string[]) => { status: number | null; stderr: string }
57
+
58
+ export interface UninstallOptions {
59
+ env?: NodeJS.ProcessEnv
60
+ /** --remove-codesign-identity: also delete the SHARED agfpd-stack signing identity. */
61
+ removeCodesignIdentity?: boolean
62
+ /** Injected sys runner (launchctl / security) for hermetic tests. */
63
+ run?: SysRunner
64
+ }
65
+
66
+ const defaultRun: SysRunner = (cmd, args) => {
67
+ const r = spawnSync(cmd, args, { encoding: 'utf8' })
68
+ return { status: r.status, stderr: (r.stderr ?? '').trim() }
69
+ }
70
+
71
+ function home(env: NodeJS.ProcessEnv): string {
72
+ return env.HOME?.trim() || homedir()
73
+ }
74
+
75
+ /** Shell-profile files the installer may have edited (per its $SHELL branch). */
76
+ function profileFiles(env: NodeJS.ProcessEnv): string[] {
77
+ const h = home(env)
78
+ return [join(h, '.zshrc'), join(h, '.bash_profile'), join(h, '.profile')]
79
+ }
80
+
81
+ /** All com.iapeer.*.plist in LaunchAgents, split into foundation-owned (sentinel)
82
+ * vs foreign (the persistent-peer fleet). */
83
+ function scanInfraPlists(env: NodeJS.ProcessEnv): { owned: string[]; foreign: string[] } {
84
+ const dir = launchAgentsDir(env)
85
+ const owned: string[] = []
86
+ const foreign: string[] = []
87
+ let entries: string[]
88
+ try {
89
+ entries = readdirSync(dir)
90
+ } catch {
91
+ return { owned, foreign }
92
+ }
93
+ for (const e of entries) {
94
+ if (!e.startsWith('com.iapeer.') || !e.endsWith('.plist')) continue
95
+ const p = join(dir, e)
96
+ ;(isFoundationOwnedPlist(p) ? owned : foreign).push(p)
97
+ }
98
+ return { owned, foreign }
99
+ }
100
+
101
+ /** Fail-closed: under the test sandbox, refuse to operate on the REAL install paths
102
+ * (a hermetic test MUST inject temp roots). Mirrors installIapeer's guard. */
103
+ function assertSandboxSafe(env: NodeJS.ProcessEnv): void {
104
+ if (env.IAPEER_TEST_SANDBOX !== '1' && process.env.IAPEER_TEST_SANDBOX !== '1') return
105
+ const root = resolveGlobalRoot(env)
106
+ const realRoot = join(homedir(), '.iapeer')
107
+ const bin = iapeerBinPath(env)
108
+ const realBin = join(homedir(), '.local', 'bin', 'iapeer')
109
+ if (root === realRoot || bin === realBin) {
110
+ throw new IapError(
111
+ 'refusing to uninstall the REAL install under IAPEER_TEST_SANDBOX=1 — inject IAPEER_ROOT / IAPEER_BIN_DIR temp paths',
112
+ )
113
+ }
114
+ }
115
+
116
+ /** Read-only: compute what an uninstall would remove + the foreign-fleet refusal. */
117
+ export function planUninstall(opts: UninstallOptions = {}): UninstallPlan {
118
+ const env = opts.env ?? process.env
119
+ const { owned, foreign } = scanInfraPlists(env)
120
+
121
+ if (foreign.length > 0) {
122
+ const foreignLabels = foreign.map(p => p.replace(/\.plist$/, '').split('/').pop() ?? p)
123
+ return {
124
+ items: [],
125
+ refused: {
126
+ foreignLabels,
127
+ reason:
128
+ `persistent-peer fleet present (${foreignLabels.join(', ')}). Removing ~/.iapeer would ` +
129
+ `destroy their state. Remove those peers first (\`iapeer remove <peer>\`), then re-run uninstall.`,
130
+ },
131
+ }
132
+ }
133
+
134
+ const bin = iapeerBinPath(env)
135
+ const prev = `${bin}.prev`
136
+ const dPlist = daemonPlistPath(env)
137
+ const root = resolveGlobalRoot(env)
138
+ const items: UninstallItem[] = []
139
+
140
+ // launchd: daemon first, then foundation-owned infra jobs (bootout → rm).
141
+ items.push({
142
+ what: `daemon job ${DAEMON_PLIST_LABEL}`,
143
+ action: 'bootout-remove-plist',
144
+ path: dPlist,
145
+ present: existsSync(dPlist),
146
+ })
147
+ for (const p of owned) {
148
+ items.push({
149
+ what: `infra job ${p.replace(/\.plist$/, '').split('/').pop()}`,
150
+ action: 'bootout-remove-plist',
151
+ path: p,
152
+ present: true,
153
+ })
154
+ }
155
+
156
+ items.push({ what: 'binary ~/.local/bin/iapeer', action: 'remove-file', path: bin, present: existsSync(bin) })
157
+ items.push({ what: 'previous binary (.prev)', action: 'remove-file', path: prev, present: existsSync(prev) })
158
+ items.push({ what: 'state + config ~/.iapeer', action: 'remove-dir', path: root, present: existsSync(root) })
159
+
160
+ for (const pf of profileFiles(env)) {
161
+ const has = existsSync(pf) && readFileSync(pf, 'utf8').includes(INSTALLER_PROFILE_MARKER)
162
+ if (has) items.push({ what: `PATH line in ${pf}`, action: 'strip-profile-lines', path: pf, present: true })
163
+ }
164
+
165
+ items.push({
166
+ what: `codesign identity "${SIGNING_IDENTITY_CN}"`,
167
+ action: 'remove-keychain-identity',
168
+ present: opts.removeCodesignIdentity === true,
169
+ detail: opts.removeCodesignIdentity
170
+ ? 'will remove (shared agfpd-stack identity — other agfpd products re-prompt on next update)'
171
+ : 'KEPT (shared agfpd-stack identity) — pass --remove-codesign-identity to remove',
172
+ })
173
+
174
+ return { items }
175
+ }
176
+
177
+ export interface UninstallResult {
178
+ removed: string[]
179
+ skipped: string[]
180
+ failed: { what: string; detail: string }[]
181
+ refused?: { reason: string; foreignLabels: string[] }
182
+ }
183
+
184
+ /** Remove a foundation install per the plan. Refuses if a foreign fleet is present. */
185
+ export function executeUninstall(opts: UninstallOptions = {}): UninstallResult {
186
+ const env = opts.env ?? process.env
187
+ assertSandboxSafe(env)
188
+ const run = opts.run ?? defaultRun
189
+ const plan = planUninstall(opts)
190
+ const res: UninstallResult = { removed: [], skipped: [], failed: [] }
191
+ if (plan.refused) {
192
+ res.refused = plan.refused
193
+ return res
194
+ }
195
+
196
+ const uid = process.getuid?.() ?? 0
197
+ for (const item of plan.items) {
198
+ if (!item.present) {
199
+ res.skipped.push(item.what)
200
+ continue
201
+ }
202
+ try {
203
+ switch (item.action) {
204
+ case 'bootout-remove-plist': {
205
+ const label = (item.path ?? '').replace(/\.plist$/, '').split('/').pop() ?? ''
206
+ run('launchctl', ['bootout', `gui/${uid}/${label}`]) // best-effort (not-loaded → non-zero, fine)
207
+ if (item.path) rmSync(item.path, { force: true })
208
+ break
209
+ }
210
+ case 'remove-file':
211
+ if (item.path) rmSync(item.path, { force: true })
212
+ break
213
+ case 'remove-dir':
214
+ if (item.path) rmSync(item.path, { recursive: true, force: true })
215
+ break
216
+ case 'strip-profile-lines':
217
+ if (item.path) stripInstallerLines(item.path)
218
+ break
219
+ case 'remove-keychain-identity':
220
+ run('security', ['delete-identity', '-c', SIGNING_IDENTITY_CN])
221
+ break
222
+ }
223
+ res.removed.push(item.what)
224
+ } catch (e) {
225
+ res.failed.push({ what: item.what, detail: e instanceof Error ? e.message : String(e) })
226
+ }
227
+ }
228
+ return res
229
+ }
230
+
231
+ /** Remove the installer's marker comment + the export PATH line it precedes. */
232
+ function stripInstallerLines(profile: string): void {
233
+ const lines = readFileSync(profile, 'utf8').split('\n')
234
+ const out: string[] = []
235
+ for (let i = 0; i < lines.length; i++) {
236
+ if (lines[i]?.trim() === INSTALLER_PROFILE_MARKER) {
237
+ // skip the marker AND the following export PATH line the installer wrote
238
+ if (lines[i + 1]?.includes('.local/bin')) i++
239
+ continue
240
+ }
241
+ out.push(lines[i]!)
242
+ }
243
+ writeFileSync(profile, out.join('\n'), { mode: 0o644 })
244
+ }
package/tsconfig.json CHANGED
@@ -4,6 +4,8 @@
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
6
  "allowImportingTsExtensions": true,
7
+ "jsx": "react-jsx",
8
+ "jsxImportSource": "react",
7
9
  "verbatimModuleSyntax": true,
8
10
  "noEmit": true,
9
11
  "strict": true,
@@ -14,5 +16,5 @@
14
16
  "types": ["bun", "node"],
15
17
  "lib": ["ESNext"]
16
18
  },
17
- "include": ["src/**/*.ts"]
19
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
18
20
  }