@agfpd/iapeer 0.2.35 → 0.2.36
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/package.json +1 -1
- package/src/cli/index.ts +4 -4
- package/src/daemon/main.test.ts +31 -6
- package/src/daemon/main.ts +38 -13
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -1129,7 +1129,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1129
1129
|
const { installIapeer } = await import('../install/index.ts')
|
|
1130
1130
|
ensureGlobalIapScaffold({ env })
|
|
1131
1131
|
const r = installIapeer(fileURLToPath(import.meta.url), env)
|
|
1132
|
-
const plist = installDaemonPlist({ env })
|
|
1132
|
+
const { path: plist, changed: plistChanged } = installDaemonPlist({ env })
|
|
1133
1133
|
const signingLine =
|
|
1134
1134
|
r.signing == null
|
|
1135
1135
|
? ''
|
|
@@ -1142,7 +1142,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1142
1142
|
`${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
|
|
1143
1143
|
signingLine +
|
|
1144
1144
|
` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
|
|
1145
|
-
` daemon plist written: ${plist}\n` +
|
|
1145
|
+
` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
|
|
1146
1146
|
` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
|
|
1147
1147
|
)
|
|
1148
1148
|
return 0
|
|
@@ -1151,8 +1151,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1151
1151
|
// Ф-F: the prod daemon entrypoint. The launchd plist runs `iapeer daemon`
|
|
1152
1152
|
// (the INSTALLED binary), decoupling prod from the mutable src tree.
|
|
1153
1153
|
if (flags['install-plist'] === true) {
|
|
1154
|
-
const p = installDaemonPlist({ env })
|
|
1155
|
-
out(`
|
|
1154
|
+
const { path: p, changed } = installDaemonPlist({ env })
|
|
1155
|
+
out(`daemon plist ${changed ? 'written' : 'unchanged'}: ${p}\nNOT loaded — to start: launchctl bootstrap gui/$(id -u) ${p}\n`)
|
|
1156
1156
|
return 0
|
|
1157
1157
|
}
|
|
1158
1158
|
const handle = await startConfiguredDaemon({
|
package/src/daemon/main.test.ts
CHANGED
|
@@ -75,17 +75,40 @@ describe('buildDaemonPlistSpec / installDaemonPlist (com.agfpd.iapeer)', () => {
|
|
|
75
75
|
HOME: root,
|
|
76
76
|
PATH: '/usr/bin:/bin',
|
|
77
77
|
} as NodeJS.ProcessEnv
|
|
78
|
-
const
|
|
79
|
-
expect(path).toBe(daemonPlistPath(env))
|
|
80
|
-
expect(
|
|
81
|
-
expect(
|
|
82
|
-
expect(
|
|
83
|
-
|
|
78
|
+
const r = installDaemonPlist({ env, port: 8765, throttleIntervalSecs: 10 })
|
|
79
|
+
expect(r.path).toBe(daemonPlistPath(env))
|
|
80
|
+
expect(r.changed).toBe(true) // first install on a clean host writes
|
|
81
|
+
expect(existsSync(r.path)).toBe(true)
|
|
82
|
+
expect(isFoundationOwnedPlist(r.path)).toBe(true)
|
|
83
|
+
expect(plutilLint(r.path)).toBe(true) // live plutil: valid plist
|
|
84
|
+
const xml = readFileSync(r.path, 'utf8')
|
|
84
85
|
expect(xml).toContain(`<string>${DAEMON_PLIST_LABEL}</string>`)
|
|
85
86
|
expect(xml).toContain('<key>RunAtLoad</key>')
|
|
86
87
|
expect(xml).toContain('<key>KeepAlive</key>')
|
|
87
88
|
})
|
|
88
89
|
|
|
90
|
+
test('idempotent by content: a re-install of the SAME plist does NOT rewrite the file (changed:false, mtime stable) — no BTM notification spam (owner-caught 11.06)', () => {
|
|
91
|
+
const root = mkTmp()
|
|
92
|
+
const env = {
|
|
93
|
+
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
|
|
94
|
+
IAPEER_ROOT: join(root, 'iapeer'),
|
|
95
|
+
HOME: root,
|
|
96
|
+
PATH: '/usr/bin:/bin',
|
|
97
|
+
} as NodeJS.ProcessEnv
|
|
98
|
+
const opts = { env, port: 8765, throttleIntervalSecs: 10 }
|
|
99
|
+
const first = installDaemonPlist(opts)
|
|
100
|
+
expect(first.changed).toBe(true)
|
|
101
|
+
const mtime1 = statSync(first.path).mtimeMs
|
|
102
|
+
// second identical install — must be a pure no-op (no write → BTM not tripped)
|
|
103
|
+
const second = installDaemonPlist(opts)
|
|
104
|
+
expect(second.changed).toBe(false)
|
|
105
|
+
expect(second.path).toBe(first.path)
|
|
106
|
+
expect(statSync(second.path).mtimeMs).toBe(mtime1) // file untouched, not just byte-equal
|
|
107
|
+
// a CHANGED spec (different port → different rendered plist) DOES rewrite
|
|
108
|
+
const third = installDaemonPlist({ env, port: 9999, throttleIntervalSecs: 10 })
|
|
109
|
+
expect(third.changed).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
|
|
89
112
|
test('REFUSES to overwrite a foreign com.agfpd.iapeer.plist (collision guard)', () => {
|
|
90
113
|
const root = mkTmp()
|
|
91
114
|
const laDir = join(root, 'LaunchAgents')
|
|
@@ -96,6 +119,8 @@ describe('buildDaemonPlistSpec / installDaemonPlist (com.agfpd.iapeer)', () => {
|
|
|
96
119
|
writeFileSync(path, foreign)
|
|
97
120
|
expect(() => installDaemonPlist({ env })).toThrow(/foundation-managed|refus/i)
|
|
98
121
|
expect(readFileSync(path, 'utf8')).toBe(foreign) // untouched
|
|
122
|
+
// (the throw must fire BEFORE the write-if-changed read, so the foreign file
|
|
123
|
+
// is never even compared — the guard is the first gate.)
|
|
99
124
|
})
|
|
100
125
|
})
|
|
101
126
|
|
package/src/daemon/main.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// action (`--install-plist`); it writes the file but does NOT load it — a live
|
|
16
16
|
// `launchctl bootstrap` stays a deliberate operator step.
|
|
17
17
|
|
|
18
|
-
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
19
19
|
import { homedir } from 'os'
|
|
20
20
|
import { join } from 'path'
|
|
21
21
|
import { fileURLToPath } from 'url'
|
|
@@ -301,17 +301,33 @@ export function buildDaemonPlistSpec(opts: InstallDaemonPlistOptions = {}): Laun
|
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
export interface InstallDaemonPlistResult {
|
|
305
|
+
/** The plist path (always returned, written or not). */
|
|
306
|
+
path: string
|
|
307
|
+
/** false when the on-disk plist already matched byte-for-byte → NO write was
|
|
308
|
+
* done. The deploy path calls this on EVERY `iapeer update`; a byte-identical
|
|
309
|
+
* rewrite serves nothing (the daemon never re-reads the file — launchd holds
|
|
310
|
+
* its loaded definition; a changed file takes effect only on an explicit
|
|
311
|
+
* bootout+bootstrap) yet each write into ~/Library/LaunchAgents trips macOS
|
|
312
|
+
* Background Task Management → a "background activity" notification to the
|
|
313
|
+
* owner on every deploy. Skipping the no-op write suppresses that noise. */
|
|
314
|
+
changed: boolean
|
|
315
|
+
}
|
|
316
|
+
|
|
304
317
|
/**
|
|
305
318
|
* Generate + install the daemon's always-on launchd plist at
|
|
306
319
|
* ~/Library/LaunchAgents/com.agfpd.iapeer.plist (or IAPEER_LAUNCHAGENTS_DIR),
|
|
307
|
-
* returning
|
|
308
|
-
* deliberate operator step).
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
320
|
+
* returning {path, changed}. Writes the FILE only — does NOT bootstrap it (load
|
|
321
|
+
* is a deliberate operator step). IDEMPOTENT BY CONTENT: writes only when the
|
|
322
|
+
* rendered plist differs from what is already on disk (owner-caught 11.06: an
|
|
323
|
+
* unconditional rewrite on every deploy spams the owner's Background Task
|
|
324
|
+
* Management notification for zero benefit). Collision guard: refuses to
|
|
325
|
+
* overwrite an existing com.agfpd.iapeer.plist that is not foundation-owned
|
|
326
|
+
* (lacks the sentinel) — the same ownership proof the peer-plist installer uses.
|
|
327
|
+
* com.agfpd.* is the foundation-exclusive daemon namespace, so this never
|
|
328
|
+
* touches the com.iapeer.* persistent-peer fleet.
|
|
313
329
|
*/
|
|
314
|
-
export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}):
|
|
330
|
+
export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): InstallDaemonPlistResult {
|
|
315
331
|
const env = opts.env ?? process.env
|
|
316
332
|
const path = daemonPlistPath(env)
|
|
317
333
|
if (existsSync(path) && !isFoundationOwnedPlist(path)) {
|
|
@@ -320,11 +336,20 @@ export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): string
|
|
|
320
336
|
`(no ownership sentinel) — another manager owns it`,
|
|
321
337
|
)
|
|
322
338
|
}
|
|
323
|
-
const
|
|
339
|
+
const rendered = renderLaunchdPlist(buildDaemonPlistSpec(opts))
|
|
324
340
|
mkdirSync(launchAgentsDir(env), { recursive: true })
|
|
325
341
|
mkdirSync(pluginLogsDir('iapeer', { env }), { recursive: true, mode: 0o700 })
|
|
326
|
-
|
|
327
|
-
|
|
342
|
+
// Write-if-changed: a byte-identical rewrite is a pure no-op for the daemon but
|
|
343
|
+
// trips the owner's BTM notification — skip it (see InstallDaemonPlistResult).
|
|
344
|
+
let existing: string | null = null
|
|
345
|
+
try {
|
|
346
|
+
existing = readFileSync(path, 'utf8')
|
|
347
|
+
} catch {
|
|
348
|
+
existing = null
|
|
349
|
+
}
|
|
350
|
+
if (existing === rendered) return { path, changed: false }
|
|
351
|
+
writeFileSync(path, rendered, { mode: 0o644 })
|
|
352
|
+
return { path, changed: true }
|
|
328
353
|
}
|
|
329
354
|
|
|
330
355
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -337,9 +362,9 @@ if (import.meta.main) {
|
|
|
337
362
|
process.stdout.write(renderLaunchdPlist(buildDaemonPlistSpec()))
|
|
338
363
|
process.exit(0)
|
|
339
364
|
} else if (arg === '--install-plist') {
|
|
340
|
-
const p = installDaemonPlist()
|
|
365
|
+
const { path: p, changed } = installDaemonPlist()
|
|
341
366
|
process.stdout.write(
|
|
342
|
-
`
|
|
367
|
+
`daemon plist ${changed ? 'written' : 'unchanged'}: ${p}\n` +
|
|
343
368
|
`NOT loaded. To start it live: launchctl bootstrap gui/$(id -u) ${p}\n`,
|
|
344
369
|
)
|
|
345
370
|
process.exit(0)
|