nattermatic 0.0.1 → 0.2.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/src/setup.js ADDED
@@ -0,0 +1,464 @@
1
+ // One-shot machine setup: config, background service, Claude Code registration, shell alias.
2
+ import os from 'node:os'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { execFileSync, spawnSync } from 'node:child_process'
6
+ import crypto from 'node:crypto'
7
+ import net from 'node:net'
8
+ import { writeConfig, readConfigFile, loadConfig, homeDir, ensureHomeDir, normHost, machineName, detectHost, validAddr, readIdentity, writeIdentity, keyPath, certPath, rosterPath, errMsg, sleep, writeAtomic } from './config.js'
9
+ import { newIdentity, certDer, shortFp, fingerprint } from './cert.js'
10
+ import { Roster } from './roster.js'
11
+ import { peerCertificate, pinnedRequest } from './tls.js'
12
+ import { Local } from './local.js'
13
+ import { openStore } from './store.js'
14
+ import { appBundle, SEA } from './self.js'
15
+ import { codexPresent, installCodex, removeCodex, codexConfigFile } from './codex.js'
16
+ import { moveOldHome, removeOldService, removeOldApp } from './legacy.js'
17
+ import { claudeAccounts, register, unregister, writeShellBlock, removeShellBlock, primaryRc, claudeBin, selfCmd, checkAdapter, wakeSupport, WAKE_MIN_CLAUDE } from './claude.js'
18
+
19
+ /**
20
+ * Service names. The usual install (~/.nattermatic) gets the plain names; any other nattermatic home
21
+ * (NATTERMATIC_HOME, or a different $HOME such as a test sandbox) gets its own, so setting one up
22
+ * never boots out and replaces the user's real service.
23
+ */
24
+ function serviceNames() {
25
+ const dir = path.resolve(homeDir())
26
+ const usual = path.join(os.userInfo().homedir, '.nattermatic')
27
+ const suffix = dir === usual ? '' : '-' + crypto.createHash('sha256').update(dir).digest('hex').slice(0, 8)
28
+ return { label: `com.nattermatic.node${suffix}`, unit: `nattermatic${suffix}` }
29
+ }
30
+ /** The nattermatic home if it isn't this user's usual one: the Claude accounts and shell files here aren't its own. */
31
+ const homeElsewhere = () => !!process.env.NATTERMATIC_HOME && path.resolve(homeDir()) !== path.join(os.homedir(), '.nattermatic')
32
+ const uid = () => process.getuid?.() ?? 0 // launchd domain (macOS only, where getuid exists)
33
+ const sleepSync = (/** @type {number} */ ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
34
+ const ok = (/** @type {string} */ m) => console.log(` ✓ ${m}`)
35
+ const warn = (/** @type {string} */ m) => console.log(` ! ${m}`)
36
+
37
+ /**
38
+ * @param {{ host?: string, peers?: string[], noService?: boolean, noClaude?: boolean, noCodex?: boolean, noAlias?: boolean, newNetwork?: boolean,
39
+ * join?: string, pin?: string, net?: string, token?: string }} opts
40
+ */
41
+ export async function setup({ host, peers = [], noService, noClaude, noCodex, noAlias, newNetwork, join, pin, net: netFp, token }) {
42
+ noService ||= process.env.NATTERMATIC_NO_SERVICE === '1'
43
+ // what's registered with Claude Code doesn't carry a home with it: it would be this user's usual one
44
+ if (homeElsewhere() && !(noClaude && noCodex && noAlias)) {
45
+ warn(`NATTERMATIC_HOME is ${homeDir()}, so the Claude accounts, Codex and shell files of ${os.homedir()} are left alone`)
46
+ noClaude = noCodex = noAlias = true
47
+ }
48
+ if (host) host = normHost(host) // throws before anything is written
49
+ delete process.env.NATTERMATIC_TOKEN // spent by the join: not for the processes setup starts
50
+ if (join && (!pin || !token)) throw new Error('--join needs --pin and --token (use the line printed by `natter invite`)')
51
+ if (join && !validAddr(join)) throw new Error(`--join: "${join}" is not a host:port address`)
52
+ console.log(`natter setup`)
53
+ // a join first checks the machine at the invite's address presents the key the invite names (its
54
+ // invite key): before anything here is changed, so a used or expired invite leaves this machine as it was
55
+ /** @type {{ pem: string, fp: string } | undefined} */
56
+ let inviter
57
+ if (join) {
58
+ try { inviter = await peerCertificate(join) } catch (e) { throw new Error(`cannot reach ${join}: ${errMsg(e)}`) }
59
+ if (inviter.fp !== pin) throw new Error(`the machine at ${join} is not presenting this invite's key. Either the invite was already used, expired or was revoked (run \`natter invite\` on a member for a new one), or something else is answering at that address. Not joining.`)
60
+ }
61
+ // a machine set up as agentlink, before the rename: it keeps its key, network and messages
62
+ if (!homeElsewhere()) {
63
+ try {
64
+ const moved = moveOldHome(homeDir())
65
+ if (moved) (moved.includes('left alone') ? warn : ok)(moved)
66
+ if (removeOldService()) ok('removed agentlink\'s background service')
67
+ if (removeOldApp(appBundle())) ok('removed agentlink.app')
68
+ } catch (e) { throw new Error(`could not move this machine's agentlink setup to nattermatic: ${errMsg(e)}`) }
69
+ }
70
+ // an unreadable config.json stops setup: treating it as empty would change this machine's settings
71
+ const existing = readConfigFile() || {}
72
+ /** @type {Roster | null} */
73
+ let roster = null
74
+ try { roster = Roster.load(rosterPath()) } catch {}
75
+ const leaving = newNetwork || !!join // this machine's key is being replaced or checked by a join
76
+ /** @type {import('./types.js').Identity | null} */
77
+ let identity = null
78
+ /** @type {unknown} */
79
+ let unreadable = null
80
+ try { identity = readIdentity() } catch (e) { unreadable = e } // a key and certificate that don't match, or a damaged one
81
+ if (!identity && !newNetwork) identity = restoreCert(roster)
82
+ if (!identity && unreadable && !leaving) throw unreadable
83
+ if (!identity && !leaving) {
84
+ // part of a machine's identity is missing: making a new one here would silently abandon the network
85
+ if (roster || fs.existsSync(keyPath()) || fs.existsSync(certPath())) {
86
+ throw new Error(`this machine's key files are incomplete (${keyPath()}, ${certPath()}). Restore them, join again with an invite, or start a new network with --new-network`)
87
+ }
88
+ }
89
+ // the running node (if any) only accepts this machine's current key: stop it with that one
90
+ /** @type {import('./types.js').Config | null} */
91
+ let prev = null
92
+ try { prev = loadConfig() } catch {}
93
+ const member = !!(identity && roster?.members.has(identity.fp))
94
+ const memberName = member && identity ? roster?.members.get(identity.fp)?.host : undefined
95
+ const asked = host || (process.env.NATTERMATIC_HOST ? normHost(process.env.NATTERMATIC_HOST) : undefined)
96
+ // the name this machine has on its network is fixed by the roster; a new network can pick a new one
97
+ const myHost = asked || memberName || normHost(existing.host || detectHost())
98
+ // wherever it came from: the roster won't take it, and that's no state to find out in
99
+ if (!machineName(myHost)) throw new Error(`"${myHost}" can't identify a machine on the network: run natter setup --host <name>`)
100
+ /** @type {string[]} */
101
+ let allPeers = []
102
+ let banner = ''
103
+ // print an invite: only for a new network. A machine that just joined doesn't get one: an open
104
+ // invite makes the machine recognisable on its port for a day, and you add machines when you need them
105
+ let invite = false
106
+
107
+ if (join) {
108
+ // 1. checked above: the machine at the invite's address presents the key the invite names
109
+ // 2. keep this machine's key only if it is already a member of that same network (a key that
110
+ // isn't carried between networks can't be used to link them)
111
+ let same = member && !!netFp && roster?.net === netFp && !!identity
112
+ let id = same && identity ? identity : newIdentity()
113
+ // 3. join: the inviter checks the token and adds our key under our name
114
+ const ask = async () => {
115
+ try {
116
+ return await pinnedRequest({ addr: join, method: 'POST', path: '/join', body: JSON.stringify({ token, host: myHost, cert: certDer(id.cert) }), trust: [/** @type {{ pem: string, fp: string }} */ (inviter)], timeout: 20_000 })
117
+ } catch (e) { throw new Error(`could not join via ${join}: ${errMsg(e)}`) }
118
+ }
119
+ let r = await ask()
120
+ // removed while we weren't told: this key is gone for good, so come back with a new one
121
+ if (r.status === 410 && same) {
122
+ same = false
123
+ id = newIdentity()
124
+ r = await ask()
125
+ }
126
+ /** @type {any} */
127
+ let data = {}
128
+ try { data = JSON.parse(r.text) } catch {}
129
+ if (r.status !== 200) throw new Error(`could not join via ${join}: ${data.error || `HTTP ${r.status}`}`)
130
+ const joined = new Roster({ net: String(data.net), records: data.records }, rosterPath())
131
+ if (joined.members.get(id.fp)?.host !== myHost || !joined.byHost.has(String(data.host)) || (netFp && joined.net !== netFp)) throw new Error(`could not join via ${join}: it did not add this machine`)
132
+ await stopRunningNode(prev ? [prev] : [])
133
+ if (!same) leaveNetwork()
134
+ writeIdentity(id)
135
+ if (same) {
136
+ // what we know and the inviter doesn't (a removal it hasn't heard of) must survive this. Read now:
137
+ // the node may have added to it since setup started
138
+ const mine = Roster.load(rosterPath())
139
+ mine.merge(data.records)
140
+ mine.save()
141
+ } else joined.save()
142
+ allPeers = [...new Set([...(same ? existing.peers || [] : []), join, ...peers])]
143
+ banner = same ? `Already a member of this network (via ${data.host}).` : `Joined the network via ${data.host}.`
144
+ } else if (!member || newNetwork) {
145
+ // a key with no readable roster: making a new key here would silently abandon the network
146
+ if (identity && !roster && !newNetwork) {
147
+ throw new Error(`this machine has a key, but its member list (${rosterPath()}) is missing or unreadable. Restore it, join again with an invite, or start a new network with --new-network`)
148
+ }
149
+ if (identity && roster && !member && !newNetwork) {
150
+ throw new Error(roster.removed.has(identity.fp)
151
+ ? 'this machine was removed from its network. Join again with a new invite (`natter invite` on a member), or start a new network with --new-network'
152
+ : 'this machine is not a member of its network. Join with an invite (`natter invite` on a member), or start a new network with --new-network')
153
+ }
154
+ const id = newIdentity()
155
+ await stopRunningNode(prev ? [prev] : [])
156
+ leaveNetwork()
157
+ writeIdentity(id)
158
+ Roster.genesis(id, myHost, rosterPath()).save()
159
+ allPeers = [...peers]
160
+ banner = 'New network created.'
161
+ invite = true
162
+ } else {
163
+ if (asked && asked !== memberName) {
164
+ throw new Error(`this machine is on its network as ${memberName}. To rename it, run \`natter remove ${memberName}\` on a machine that joined before it, then run a new invite line with \`| sh -s -- --host ${asked}\` at the end. Or start a new network with --new-network`)
165
+ }
166
+ ok('keeping this machine\'s existing network (use --new-network to create a new one)')
167
+ await stopRunningNode(prev ? [prev] : [])
168
+ allPeers = [...new Set([...(existing.peers || []), ...peers])]
169
+ }
170
+ // a port asked for here has to last: the background service doesn't get this environment
171
+ writeConfig({ host: myHost, peers: allPeers, ...(process.env.NATTERMATIC_PORT ? { port: Number(process.env.NATTERMATIC_PORT) } : {}) })
172
+ fs.rmSync(path.join(homeDir(), 'disabled'), { force: true })
173
+ const cfg = loadConfig()
174
+ ok(`config written to ${path.join(homeDir(), 'config.json')} — this machine is ${cfg.host} (key ${shortFp(cfg.identity.fp)})`)
175
+
176
+ const local = new Local(cfg)
177
+ if (!noService) {
178
+ try {
179
+ installService()
180
+ ok('background node installed (starts at login, restarts if it dies)')
181
+ for (let i = 0; i < 50 && !await local.health(); i++) await sleep(100) // let the service win the port
182
+ } catch (e) { warn(`could not install background service (${errMsg(e)}); sessions will start the node on demand`) }
183
+ }
184
+ let running = false
185
+ try { const h = await local.ensureNode(); ok(`node running on port ${cfg.port} (pid ${'pid' in h ? h.pid : '?'})`); running = true }
186
+ catch (e) { warn(errMsg(e)) }
187
+
188
+ let broken = null
189
+ if (!noClaude) {
190
+ broken = registerClaude() || checkAdapter()
191
+ if (broken) warn(`Claude Code can't start nattermatic, so sessions won't have it until this is fixed: ${broken}`)
192
+ }
193
+ let codexNote = ''
194
+ if (!noCodex && codexPresent()) {
195
+ try {
196
+ const c = installCodex()
197
+ ok(`Codex: nattermatic added to ${codexConfigFile()}`)
198
+ if (!c.hook) codexNote = '\nCodex sessions read messages with the inbox tool. Update Codex (it needs `codex queue`) and run natter setup again, so they\'re woken too.'
199
+ else if (c.approve) codexNote = '\nCodex asks you to trust nattermatic\'s hook the next time it starts: trust it, so Codex sessions are woken by messages.'
200
+ } catch (e) { warn(`Codex: ${errMsg(e)}`) }
201
+ }
202
+ const binDir = process.env.NATTERMATIC_BIN_DIR
203
+ if (!noAlias) {
204
+ // nattermatic works without this: it mustn't stop setup before it prints the invite
205
+ try { writeShellBlock({ pathDir: binDir }) } catch (e) { warn(`could not update ${primaryRc()} (${errMsg(e)})${binDir ? `; add ${binDir} to your PATH yourself` : ''}`) }
206
+ if (binDir && path.basename(process.env.SHELL || '') === 'fish') warn(`fish doesn't read ${primaryRc()}: run fish_add_path ${binDir}`)
207
+ }
208
+ // moved to the app from an npm install: the old copy still works, but is no longer what runs
209
+ if (SEA) {
210
+ const real = (/** @type {string} */ p) => { try { return fs.realpathSync(p) } catch { return '' } }
211
+ const dirs = (process.env.NATTERMATIC_ORIG_PATH ?? process.env.PATH ?? '').split(':').filter(d => d && d !== binDir)
212
+ // (agentlink: an npm install from before the rename)
213
+ for (const [cmd, pkg] of [['natter', 'nattermatic'], ['agentlink', '@tombaldwin/agentlink']]) {
214
+ const old = dirs.map(d => path.join(d, cmd)).find(f => real(f) && real(f) !== real(process.execPath))
215
+ if (old) console.log(` (an earlier npm install is still at ${old}; new terminals use the app. Remove the old one with: npm uninstall -g ${pkg})`)
216
+ }
217
+ }
218
+ console.log(`
219
+ ${broken ? 'Done, but see the warning above.' : 'Done.'} In any Claude Code session that's already running, type /reload-plugins to join
220
+ (or start it again: \`claude --continue\` picks up where it left off). New sessions join by themselves.
221
+ Agents address each other as name@host, e.g. "send the test results to reviewer@laptop.local".
222
+ Name a session with NATTERMATIC_NAME=<name> (default: the project folder name).${codexNote}`)
223
+ if (banner) console.log(`\n${banner}`)
224
+ if (running && invite) {
225
+ try { console.log(`\n${invitePrompt(await local.req('POST', '/local/invite'))}`) }
226
+ catch (e) { warn(`could not create an invite (${errMsg(e)}); run \`natter invite\` later`) }
227
+ } else console.log(`\nTo add another machine, ask Claude to make an invite (or run \`natter invite\`${running ? '' : ' once the node is running'}).`)
228
+ }
229
+
230
+ /**
231
+ * Rebuild a lost certificate from the member list, which holds every member's. Null if it can't be.
232
+ * @param {Roster | null} roster
233
+ */
234
+ function restoreCert(roster) {
235
+ try {
236
+ const m = roster?.members.get(fingerprint(fs.readFileSync(keyPath(), 'utf8')))
237
+ if (!m) return null
238
+ writeAtomic(certPath(), m.pem, 0o644)
239
+ ok(`restored ${certPath()} from the member list`)
240
+ return readIdentity()
241
+ } catch { return null }
242
+ }
243
+
244
+ /**
245
+ * What belonged to the network this machine is leaving. Its invites must not let anyone into the new
246
+ * one, and its messages are from and for machines of the old network: another network's machine with
247
+ * the same name must not be handed them, and nobody on the new one should be shown them. This can't
248
+ * be left half done, because setup starts the node next. If the database can't be cleared (someone
249
+ * has it open, say) it goes; and if that can't be done either, setup stops here.
250
+ */
251
+ function leaveNetwork() {
252
+ fs.rmSync(path.join(homeDir(), 'invite.key'), { force: true })
253
+ // agentlink.db: the database of a machine set up as agentlink, which the node renames when it next starts
254
+ for (const name of ['nattermatic.db', 'agentlink.db']) clearDb(path.join(homeDir(), name))
255
+ }
256
+
257
+ /** @param {string} file */
258
+ function clearDb(file) {
259
+ if (!fs.existsSync(file)) return
260
+ try {
261
+ const db = openStore(file)
262
+ try {
263
+ const left = Number(/** @type {any} */ (db.prepare(`SELECT count(*) n FROM messages WHERE state IN ('pending','queued')`).get()).n)
264
+ db.exec('BEGIN IMMEDIATE; DELETE FROM messages; DELETE FROM invites; DELETE FROM peers; COMMIT')
265
+ if (left) warn(`dropped ${left} undelivered message(s) for the old network`)
266
+ } finally { db.close() }
267
+ } catch (e) {
268
+ warn(`could not clear ${file} (${errMsg(e)}): removing it, with its messages`)
269
+ for (const f of [file, `${file}-wal`, `${file}-shm`]) fs.rmSync(f, { force: true })
270
+ }
271
+ }
272
+
273
+ /** The port this machine's config says its node is on, if that's known. (A home of its own defaults to nothing: 7777 is another node's.) */
274
+ function configuredPort() {
275
+ try { return readConfigFile()?.port || (process.env.NATTERMATIC_HOME ? 0 : 7777) } catch { return 0 }
276
+ }
277
+
278
+ /**
279
+ * Stop the node on this machine (it only accepts the key it was started with). It may be on the port
280
+ * in the config while this run has been told another.
281
+ * @param {Pick<import('./types.js').Config, 'identity' | 'port' | 'dir'>[]} cfgs
282
+ */
283
+ async function stopRunningNode(cfgs) {
284
+ const list = cfgs.length ? cfgs : keyOnly()
285
+ const port0 = configuredPort()
286
+ let stopped = false
287
+ for (const c of [...list, ...list.filter(x => port0 && x.port !== port0).map(x => ({ ...x, port: port0 }))]) {
288
+ const l = new Local(c)
289
+ const h = await l.health()
290
+ if (!h || 'foreign' in h) continue
291
+ try { await l.req('POST', '/local/shutdown') } catch (e) { warn(`could not stop the running node: ${errMsg(e)}`); continue }
292
+ for (let i = 0; i < 30 && await l.health(); i++) await sleep(100)
293
+ stopped = true
294
+ }
295
+ if (stopped) return
296
+ const port = cfgs[0]?.port || Number(process.env.NATTERMATIC_PORT || readConfigFile()?.port || 7777)
297
+ if (await portInUse(port)) warn(`something else is using port ${port} (another network's node, or another program); stop it so this machine's node can start`)
298
+ }
299
+ /** What it takes to reach the node when the rest of the config won't load: only the key matters. */
300
+ function keyOnly() {
301
+ try {
302
+ const key = fs.readFileSync(keyPath(), 'utf8')
303
+ return [{ identity: { key, cert: '', fp: fingerprint(key) }, port: Number(process.env.NATTERMATIC_PORT || readConfigFile()?.port || 7777), dir: homeDir() }]
304
+ } catch { return [] }
305
+ }
306
+ /** @param {number} port */
307
+ function portInUse(port) {
308
+ return new Promise(resolve => {
309
+ const s = net.connect(port, '127.0.0.1', () => { s.destroy(); resolve(true) })
310
+ s.on('error', () => resolve(false))
311
+ s.setTimeout(1000, () => { s.destroy(); resolve(false) })
312
+ })
313
+ }
314
+
315
+ /**
316
+ * The one line to paste on another machine: fetches the installer from this machine's node over
317
+ * TLS pinned to the invite key (curl's -k only skips CA checks; --pinnedpubkey still applies).
318
+ * @param {{ token: string, fp: string, port: number }} inv @param {string} host
319
+ */
320
+ export const installLine = (inv, host) =>
321
+ `curl -fsSL -k --pinnedpubkey "sha256//${inv.fp}" "https://${host}:${inv.port}/join/install?t=${inv.token}" | sh`
322
+
323
+ /** Unused invites, for people. @param {{ created_at: number, expires_at: number }[]} invites */
324
+ export function formatInvites(invites) {
325
+ if (!invites.length) return 'No unused invites.'
326
+ const t = (/** @type {number} */ ms) => new Date(ms).toLocaleString()
327
+ return [`${invites.length} unused invite(s) (\`natter invite --revoke\` cancels them):`,
328
+ ...invites.map(i => ` made ${t(i.created_at)}, expires ${t(i.expires_at)}`)].join('\n')
329
+ }
330
+
331
+ /** Ready-to-paste instructions (for a person, or for Claude) to add another machine. @param {{ token: string, fp: string, host: string, port: number, tailscale?: string | null, ip?: string }} inv */
332
+ export function invitePrompt(inv) {
333
+ let prompt = `Join my nattermatic network: run ${installLine(inv, inv.host)}`
334
+ if (inv.tailscale) prompt += ` — if ${inv.host} can't be reached, run ${installLine(inv, inv.tailscale)} instead`
335
+ // not every machine can look up .local names (many Linux installs can't); the pinned key makes an IP just as safe
336
+ else if (inv.ip) prompt += ` — if ${inv.host} can't be found, run ${installLine(inv, inv.ip)} instead`
337
+ prompt += ` — then tell me to type /reload-plugins. (The -k is safe here: --pinnedpubkey makes curl accept only the key this invite names. If curl fails (a key mismatch or a 404), this invite was already used, expired or was revoked: ask me for a new one.)`
338
+ return `To add another machine, paste this into Claude Code on it (or just run the command):
339
+
340
+ ${prompt}
341
+
342
+ It lets one machine join and expires in 24 hours (\`natter invite --revoke\` cancels unused invites).
343
+ Once it's used, expired or revoked, curl rejects the line (a key mismatch or a 404): ask Claude for a new invite, or run \`natter invite\`.${inv.tailscale ? '' : `
344
+ (For machines off this LAN: install Tailscale on both, then run \`natter invite\` again.)`}
345
+ `
346
+ }
347
+
348
+ /** Enable nattermatic for every Claude account here. Returns what's wrong, if it couldn't be done at all. */
349
+ function registerClaude() {
350
+ if (!claudeBin()) return 'claude was not found on PATH, in ~/.local/bin or in ~/.claude/local. Run `natter setup` from a terminal where `claude` works'
351
+ /** @type {string | null} */
352
+ let failed = null
353
+ for (const acct of claudeAccounts()) {
354
+ const label = acct.dir ? `${acct.alias} (${acct.dir})` : 'default account'
355
+ try { register(acct.dir); ok(`enabled for Claude account: ${label}`) }
356
+ catch (e) { warn(`could not enable for ${label}: ${errMsg(e)}`); failed ||= `could not enable for ${label}` }
357
+ }
358
+ const { ok: wakes, version } = wakeSupport()
359
+ const then = `so sessions won't wake by themselves; they read messages with the inbox tool`
360
+ if (!wakes) warn(version ? `Claude Code ${version} is older than ${WAKE_MIN_CLAUDE}, ${then}. Update Claude Code, then run natter setup again` : `couldn't tell which version of Claude Code this is (it needs ${WAKE_MIN_CLAUDE}), ${then}`)
361
+ return failed
362
+ }
363
+
364
+ // ---------- services ----------
365
+ function installService() {
366
+ const cfgDir = ensureHomeDir()
367
+ const { label: LABEL, unit: UNIT } = serviceNames()
368
+ const logFile = path.join(cfgDir, 'node.log')
369
+ // the service starts with this user's real home, whatever this run's is
370
+ const env = { PATH: process.env.PATH, ...(path.resolve(cfgDir) !== path.join(os.userInfo().homedir, '.nattermatic') ? { NATTERMATIC_HOME: cfgDir } : {}) }
371
+ if (process.platform === 'darwin') {
372
+ const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
373
+ const args = [...selfCmd(), 'node']
374
+ fs.mkdirSync(path.dirname(plist), { recursive: true })
375
+ fs.writeFileSync(plist, `<?xml version="1.0" encoding="UTF-8"?>
376
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
377
+ <plist version="1.0"><dict>
378
+ <key>Label</key><string>${LABEL}</string>
379
+ <key>ProgramArguments</key><array>${args.map(a => `<string>${esc(a)}</string>`).join('')}</array>
380
+ <key>EnvironmentVariables</key><dict>${Object.entries(env).map(([k, v]) => `<key>${k}</key><string>${esc(v)}</string>`).join('')}</dict>
381
+ <key>RunAtLoad</key><true/>
382
+ <key>KeepAlive</key><true/>
383
+ <key>ThrottleInterval</key><integer>10</integer>
384
+ <key>StandardOutPath</key><string>${esc(logFile)}</string>
385
+ <key>StandardErrorPath</key><string>${esc(logFile)}</string>
386
+ </dict></plist>
387
+ `)
388
+ const dom = `gui/${uid()}`
389
+ // bootout returns before launchd has finished removing the job, and loading it again meanwhile
390
+ // fails ("Bootstrap failed: 5: Input/output error"): wait for it to go, then retry the load
391
+ spawnSync('launchctl', ['bootout', `${dom}/${LABEL}`], { stdio: 'ignore' })
392
+ for (let i = 0; i < 50 && spawnSync('launchctl', ['print', `${dom}/${LABEL}`], { stdio: 'ignore' }).status === 0; i++) sleepSync(100)
393
+ for (let i = 0; ; i++) {
394
+ try { execFileSync('launchctl', ['bootstrap', dom, plist], { stdio: 'pipe' }); return } catch (e) {
395
+ if (i >= 9) throw e
396
+ sleepSync(500)
397
+ }
398
+ }
399
+ }
400
+ if (process.platform === 'linux') {
401
+ const unit = path.join(os.homedir(), '.config', 'systemd', 'user', `${UNIT}.service`)
402
+ fs.mkdirSync(path.dirname(unit), { recursive: true })
403
+ fs.writeFileSync(unit, `[Unit]
404
+ Description=nattermatic agent messaging node
405
+
406
+ [Service]
407
+ ExecStart=${[...selfCmd(), 'node'].map(sdQuote).join(' ')}
408
+ ${Object.entries(env).map(([k, v]) => `Environment=${sdQuote(`${k}=${v}`)}`).join('\n')}
409
+ Restart=always
410
+ RestartSec=5
411
+ StandardOutput=append:${logFile.replace(/%/g, '%%')}
412
+ StandardError=append:${logFile.replace(/%/g, '%%')}
413
+
414
+ [Install]
415
+ WantedBy=default.target
416
+ `)
417
+ execFileSync('systemctl', ['--user', 'daemon-reload'])
418
+ execFileSync('systemctl', ['--user', 'enable', '--now', UNIT])
419
+ spawnSync('loginctl', ['enable-linger', os.userInfo().username], { stdio: 'ignore' }) // keep running when logged out
420
+ return
421
+ }
422
+ throw new Error(`unsupported platform ${process.platform}`)
423
+ }
424
+
425
+ export async function uninstall() {
426
+ const { label: LABEL, unit: UNIT } = serviceNames()
427
+ // stops open sessions from restarting the node
428
+ try { ensureHomeDir(); fs.writeFileSync(path.join(homeDir(), 'disabled'), '') } catch {}
429
+ try {
430
+ const local = new Local(loadConfig())
431
+ if (await local.health()) await local.req('POST', '/local/shutdown')
432
+ } catch {}
433
+ if (!homeElsewhere()) removeOldService()
434
+ if (process.platform === 'darwin') {
435
+ spawnSync('launchctl', ['bootout', `gui/${uid()}/${LABEL}`], { stdio: 'ignore' })
436
+ fs.rmSync(path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`), { force: true })
437
+ } else if (process.platform === 'linux') {
438
+ spawnSync('systemctl', ['--user', 'disable', '--now', UNIT], { stdio: 'ignore' })
439
+ fs.rmSync(path.join(os.homedir(), '.config', 'systemd', 'user', `${UNIT}.service`), { force: true })
440
+ }
441
+ if (homeElsewhere()) warn(`NATTERMATIC_HOME is ${homeDir()}, so the Claude accounts and shell files of ${os.homedir()} were left alone`)
442
+ else {
443
+ let unfound = false
444
+ for (const acct of claudeAccounts()) {
445
+ try { if (!unregister(acct.dir)) unfound = true } catch (e) { warn(errMsg(e)) } // one account's settings mustn't stop the rest
446
+ }
447
+ // its sessions would go on starting nattermatic's MCP server, which can only say it's uninstalled
448
+ if (unfound) warn('Claude Code was not found, so nattermatic is still registered with it: run `claude mcp remove -s user nattermatic` where claude is installed')
449
+ removeShellBlock().forEach(warn)
450
+ try { removeCodex() } catch (e) { warn(`Codex: ${errMsg(e)}`) }
451
+ }
452
+ let host = ''
453
+ try { host = loadConfig().host } catch {}
454
+ console.log(`Stopped nattermatic and removed its service, its Claude Code registrations and its Codex settings.
455
+ Its key, messages and settings remain in ${homeDir()}: \`natter setup\` turns it back on as before.
456
+ To remove it completely:
457
+ - take this machine off the network: on another member, run natter remove ${host || '<this machine>'}
458
+ - delete ${homeDir()}
459
+ - uninstall the program: ${appBundle() ? `delete ${appBundle()}` : `npm uninstall -g nattermatic (not needed if nattermatic brought its own Node.js: that lives in ${homeDir()})`}`)
460
+ }
461
+
462
+ /** systemd unit quoting: double quotes, escaped \\ and ", and % doubled (specifier escape). */
463
+ const sdQuote = (/** @type {unknown} */ v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`
464
+ const esc = (/** @type {unknown} */ s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
package/src/store.js ADDED
@@ -0,0 +1,39 @@
1
+ import { DatabaseSync } from 'node:sqlite'
2
+
3
+ /*
4
+ * messages.role:
5
+ * outbox - we are the sender's node; must hand to the recipient's node
6
+ * held - we are holding a copy for an offline machine; must hand to the recipient's node
7
+ * inbox - recipient is on this machine; must push to the local session
8
+ * messages.state:
9
+ * pending (outbox/held: still to hand over) -> forwarded
10
+ * queued (inbox: waiting for session) -> pushed (shown in a channel session; no read receipt)
11
+ * -> delivered (read with the inbox tool)
12
+ * expired | failed
13
+ */
14
+ /** @param {string} file */
15
+ export function openStore(file) {
16
+ const db = new DatabaseSync(file)
17
+ db.exec(`
18
+ PRAGMA journal_mode=WAL;
19
+ PRAGMA busy_timeout=5000;
20
+ CREATE TABLE IF NOT EXISTS messages(
21
+ id TEXT PRIMARY KEY,
22
+ from_addr TEXT NOT NULL, to_addr TEXT NOT NULL, to_host TEXT NOT NULL, to_name TEXT NOT NULL,
23
+ body TEXT NOT NULL, sent_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, sig TEXT NOT NULL,
24
+ role TEXT NOT NULL, state TEXT NOT NULL,
25
+ attempts INTEGER NOT NULL DEFAULT 0, next_attempt INTEGER NOT NULL DEFAULT 0,
26
+ replicated INTEGER NOT NULL DEFAULT 0, last_error TEXT, updated_at INTEGER NOT NULL);
27
+ CREATE INDEX IF NOT EXISTS ix_due ON messages(state, next_attempt);
28
+ CREATE INDEX IF NOT EXISTS ix_inbox ON messages(role, to_name, state);
29
+ CREATE INDEX IF NOT EXISTS ix_expiry ON messages(state, expires_at);
30
+ CREATE INDEX IF NOT EXISTS ix_waiting ON messages(to_host, to_name) WHERE state IN ('pending','queued');
31
+ CREATE TABLE IF NOT EXISTS peers(
32
+ host TEXT PRIMARY KEY, addr TEXT, last_seen INTEGER NOT NULL, configured INTEGER NOT NULL DEFAULT 0,
33
+ alias_of TEXT);
34
+ CREATE TABLE IF NOT EXISTS invites(
35
+ hash TEXT PRIMARY KEY, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER);
36
+ `)
37
+ try { db.exec(`ALTER TABLE peers ADD COLUMN alias_of TEXT`) } catch {} // older databases
38
+ return db
39
+ }