@agfpd/iapeer 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/docs/03-peers-runtimes-identity.md +1 -1
- package/docs/06-storage.md +12 -0
- package/docs/ru/03-/320/277/320/270/321/200/321/213-/321/200/320/260/320/275/321/202/320/260/320/271/320/274/321/213-/320/270/320/264/320/265/320/275/321/202/320/270/321/207/320/275/320/276/321/201/321/202/321/214.md +1 -1
- package/docs/ru/06-/321/205/321/200/320/260/320/275/320/265/320/275/320/270/320/265.md +12 -0
- package/package.json +1 -1
- package/src/cli/index.ts +289 -53
- package/src/create/index.ts +38 -0
- package/src/daemon/index.ts +13 -4
- package/src/index.ts +2 -0
- package/src/init/index.ts +31 -0
- package/src/install/index.ts +61 -2
- package/src/launch/composeSystemPrompt.ts +8 -0
- package/src/onboard/memory.ts +28 -6
- package/src/tui/ansi.ts +71 -0
- package/src/tui/onboard/app.tsx +113 -16
- package/src/tui/onboard/run.tsx +116 -18
- package/src/uninstall/index.ts +244 -0
package/src/tui/onboard/run.tsx
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import React from 'react'
|
|
20
20
|
import { render } from 'ink'
|
|
21
21
|
import { OnboardApp, type WizardResult } from './app.tsx'
|
|
22
|
+
import { type Ansi, colorEnabled, makeAnsi } from '../ansi.ts'
|
|
22
23
|
|
|
23
24
|
/** Sentinel: the environment is not a real interactive TTY — caller should fall
|
|
24
25
|
* back to the linear non-interactive onboard path (never render raw + hang). */
|
|
@@ -28,6 +29,36 @@ export interface OnboardWizardOptions {
|
|
|
28
29
|
env?: NodeJS.ProcessEnv
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/** Style one post-Ink summary line to match the Ink wizard exactly: the headline
|
|
33
|
+
* (no leading glyph) bold — red when it announces errors; each item line keeps its
|
|
34
|
+
* glyph colored by status (✓ green / ! yellow / ✗ red / · gray) and dims the
|
|
35
|
+
* trailing " — detail", just as the in-wizard render does. */
|
|
36
|
+
function paintSummaryLine(line: string, a: Ansi): string {
|
|
37
|
+
const m = line.match(/^(\s*)([✓!✗·]) (.*)$/)
|
|
38
|
+
if (!m) return /error/i.test(line) ? a.bold(a.red(line)) : a.bold(line)
|
|
39
|
+
const [, indent, mark, rest] = m
|
|
40
|
+
const paintMark = mark === '✓' ? a.green : mark === '!' ? a.yellow : mark === '✗' ? a.red : a.gray
|
|
41
|
+
const i = rest.indexOf(' — ')
|
|
42
|
+
const label = i >= 0 ? rest.slice(0, i) : rest
|
|
43
|
+
const detail = i >= 0 ? rest.slice(i) : ''
|
|
44
|
+
return `${indent}${paintMark(mark)} ${label}${detail ? a.dim(detail) : ''}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Color a `prefix: state[ rest]` progress line by the state's semantics, matching
|
|
48
|
+
* the wizard palette + the cross-CLI convention agreed with iapeer-memory: red for
|
|
49
|
+
* failure, GRAY for a neutral skip/decline (· semantics — yellow is reserved for
|
|
50
|
+
* warn/attention, not "user chose not to"), yellow for warn, green otherwise. */
|
|
51
|
+
function paintResult(prefix: string, state: string, rest: string, a: Ansi): string {
|
|
52
|
+
const paint = /(fail|error|refus)/i.test(state)
|
|
53
|
+
? a.red
|
|
54
|
+
: /(skip|declin)/i.test(state)
|
|
55
|
+
? a.gray
|
|
56
|
+
: /(warn|missing|incomplete)/i.test(state)
|
|
57
|
+
? a.yellow
|
|
58
|
+
: a.green
|
|
59
|
+
return `${a.bold(`${prefix}:`)} ${paint(state)}${rest}`
|
|
60
|
+
}
|
|
61
|
+
|
|
31
62
|
/** Render the interactive onboard wizard. Resolves with the process exit code, or
|
|
32
63
|
* WIZARD_NOT_INTERACTIVE when there is no real TTY to drive it. */
|
|
33
64
|
export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise<number> {
|
|
@@ -35,8 +66,12 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
|
|
|
35
66
|
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
|
36
67
|
return WIZARD_NOT_INTERACTIVE
|
|
37
68
|
}
|
|
69
|
+
// Post-Ink lines are plain stdout (Ink has unmounted), so they carry their own
|
|
70
|
+
// ANSI to stay visually continuous with the wizard. Gated: real TTY here (the
|
|
71
|
+
// wizard only runs on one), off under NO_COLOR.
|
|
72
|
+
const a = makeAnsi(colorEnabled(env, process.stdout))
|
|
38
73
|
|
|
39
|
-
let result: WizardResult = { code: 0, memoryConsent: false, advisories: [], summary: [] }
|
|
74
|
+
let result: WizardResult = { code: 0, memoryConsent: false, telegramConsent: false, advisories: [], summary: [] }
|
|
40
75
|
try {
|
|
41
76
|
const app = render(<OnboardApp env={env} onResult={r => (result = r)} />)
|
|
42
77
|
await app.waitUntilExit()
|
|
@@ -44,29 +79,92 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
|
|
|
44
79
|
return WIZARD_NOT_INTERACTIVE
|
|
45
80
|
}
|
|
46
81
|
|
|
47
|
-
// Post-Ink
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
82
|
+
// Post-Ink interactive provisioning — telegram THEN memory (the canonical order:
|
|
83
|
+
// telegram creates the human peer that memory's --human resolves from). These own
|
|
84
|
+
// the terminal (inherited stdio / readline), so they run AFTER Ink unmounts (no
|
|
85
|
+
// contention). NON-WEDGE: ignore SIGINT in the parent so Ctrl-C kills only the
|
|
86
|
+
// CURRENT step's child/readline and onboard continues to the summary, instead of
|
|
87
|
+
// the whole flow dying. The no-op handler is queued past the blocking spawnSync.
|
|
88
|
+
const prevInt = process.listeners('SIGINT')
|
|
89
|
+
process.removeAllListeners('SIGINT')
|
|
90
|
+
const ignore = (): void => {}
|
|
91
|
+
process.on('SIGINT', ignore)
|
|
92
|
+
try {
|
|
93
|
+
if (result.telegramConsent) {
|
|
94
|
+
const { onboardTelegramStep } = await import('../../onboard/steps.ts')
|
|
95
|
+
process.stdout.write(
|
|
96
|
+
'\n' +
|
|
97
|
+
a.bold('Setting up Telegram…') +
|
|
98
|
+
a.dim(' (Ctrl-C to skip — add later: `iapeer create <you> --runtime telegram`)') +
|
|
99
|
+
'\n',
|
|
100
|
+
)
|
|
101
|
+
try {
|
|
102
|
+
const ts = await onboardTelegramStep({ env })
|
|
103
|
+
process.stdout.write(paintResult('telegram', ts.state, ts.detail ? ` — ${ts.detail}` : '', a) + '\n')
|
|
104
|
+
} catch (e) {
|
|
105
|
+
process.stdout.write(paintResult('telegram', 'step skipped', ` — ${e instanceof Error ? e.message : String(e)}`, a) + '\n')
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
process.stdout.write(
|
|
109
|
+
'\n' + paintResult('telegram', 'skipped', ' — add later with `iapeer create <you> --runtime telegram`', a) + '\n',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (result.memoryConsent) {
|
|
114
|
+
const { onboardMemoryProvider } = await import('../../onboard/memory.ts')
|
|
115
|
+
process.stdout.write(
|
|
116
|
+
'\n' +
|
|
117
|
+
a.bold('Installing the shared-memory provider (@agfpd/iapeer-memory)…') +
|
|
118
|
+
'\n' +
|
|
119
|
+
a.dim(
|
|
120
|
+
'This can take a few minutes — it builds a native module. Memory is OPTIONAL:\n' +
|
|
121
|
+
'press Ctrl-C to skip it (set it up later with `npx @agfpd/iapeer-memory init`).',
|
|
122
|
+
) +
|
|
123
|
+
'\n\n',
|
|
124
|
+
)
|
|
125
|
+
try {
|
|
126
|
+
const mem = await onboardMemoryProvider({ env, runtime: result.hostRuntime, timeoutMs: 12 * 60_000 })
|
|
127
|
+
const label = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
|
|
128
|
+
const rest = `${mem.detail ? ` — ${mem.detail}` : ''} ${a.dim(`(slot: ${label})`)}`
|
|
129
|
+
process.stdout.write('\n' + paintResult('memory', mem.state, rest, a) + '\n')
|
|
130
|
+
} catch (e) {
|
|
131
|
+
const rest = ` — ${e instanceof Error ? e.message : String(e)} ${a.dim('(set up later: `npx @agfpd/iapeer-memory init`)')}`
|
|
132
|
+
process.stdout.write('\n' + paintResult('memory', 'step skipped', rest, a) + '\n')
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
process.stdout.write(
|
|
136
|
+
'\n' + paintResult('memory', 'skipped', ' — set it up later with `npx @agfpd/iapeer-memory init`', a) + '\n',
|
|
137
|
+
)
|
|
58
138
|
}
|
|
59
|
-
}
|
|
60
|
-
process.
|
|
139
|
+
} finally {
|
|
140
|
+
process.removeListener('SIGINT', ignore)
|
|
141
|
+
for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)
|
|
61
142
|
}
|
|
62
143
|
|
|
63
|
-
// Final summary (
|
|
144
|
+
// Final summary (post-Ink, ANSI-styled to match the wizard's palette).
|
|
64
145
|
process.stdout.write('\n')
|
|
65
|
-
for (const line of result.summary) process.stdout.write(line + '\n')
|
|
146
|
+
for (const line of result.summary) process.stdout.write(paintSummaryLine(line, a) + '\n')
|
|
66
147
|
if (result.advisories.length > 0) {
|
|
67
148
|
process.stdout.write('\n')
|
|
68
|
-
|
|
149
|
+
// Advisories mirror the wizard's yellow box (a multi-line entry stays yellow
|
|
150
|
+
// line-to-line until the trailing reset).
|
|
151
|
+
for (const adv of result.advisories) process.stdout.write(a.yellow(adv) + '\n')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Next steps — an aligned 2-column block (label padded, command highlighted), the
|
|
155
|
+
// bot-token left as its own explicit step (no surprise / don't-make-them-guess).
|
|
156
|
+
const next: Array<{ label: string; cmd: string; note?: string }> = [
|
|
157
|
+
{ label: 'Create your first agent', cmd: 'iapeer create <name> --runtime claude' },
|
|
158
|
+
]
|
|
159
|
+
if (result.telegramConsent) {
|
|
160
|
+
next.push({ label: 'Activate Telegram', cmd: 'iapeer connect telegram', note: 'paste a bot token from @BotFather' })
|
|
161
|
+
}
|
|
162
|
+
const w = Math.max(...next.map(n => n.label.length))
|
|
163
|
+
process.stdout.write('\n' + a.bold('Next steps:') + '\n')
|
|
164
|
+
for (const n of next) {
|
|
165
|
+
process.stdout.write(
|
|
166
|
+
` ${a.cyan('•')} ${n.label.padEnd(w)} ${a.cyan(n.cmd)}${n.note ? ' ' + a.dim(`(${n.note})`) : ''}\n`,
|
|
167
|
+
)
|
|
69
168
|
}
|
|
70
|
-
process.stdout.write('\nNext: create your first agent — `iapeer create <name> --runtime claude`\n')
|
|
71
169
|
return result.code
|
|
72
170
|
}
|
|
@@ -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
|
+
}
|