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