@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.35",
3
+ "version": "0.2.36",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
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(`installed daemon plist: ${p}\nNOT loaded — to start: launchctl bootstrap gui/$(id -u) ${p}\n`)
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({
@@ -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 path = installDaemonPlist({ env, port: 8765, throttleIntervalSecs: 10 })
79
- expect(path).toBe(daemonPlistPath(env))
80
- expect(existsSync(path)).toBe(true)
81
- expect(isFoundationOwnedPlist(path)).toBe(true)
82
- expect(plutilLint(path)).toBe(true) // live plutil: valid plist
83
- const xml = readFileSync(path, 'utf8')
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
 
@@ -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 the path. Writes the FILE only — does NOT bootstrap it (load is a
308
- * deliberate operator step). Collision guard: refuses to overwrite an existing
309
- * com.agfpd.iapeer.plist that is not foundation-owned (lacks the sentinel) — the
310
- * same ownership proof the peer-plist installer uses. com.agfpd.* is the
311
- * foundation-exclusive daemon namespace, so this never touches the com.iapeer.*
312
- * persistent-peer fleet.
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 = {}): string {
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 spec = buildDaemonPlistSpec(opts)
339
+ const rendered = renderLaunchdPlist(buildDaemonPlistSpec(opts))
324
340
  mkdirSync(launchAgentsDir(env), { recursive: true })
325
341
  mkdirSync(pluginLogsDir('iapeer', { env }), { recursive: true, mode: 0o700 })
326
- writeFileSync(path, renderLaunchdPlist(spec), { mode: 0o644 })
327
- return path
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
- `installed daemon plist: ${p}\n` +
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)