@coworker-jp/aidr 0.0.5 → 0.0.7

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": "@coworker-jp/aidr",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -520,6 +520,35 @@ async function cmdUninstall(opts) {
520
520
  } catch (e) {
521
521
  console.error(`scheduled uninstall warning: ${e.message}`);
522
522
  }
523
+
524
+ // Sentinel daemon (LaunchDaemon / systemd service) — same auto-sudo
525
+ // pattern. Independent try-block so a scheduled-uninstall hiccup doesn't
526
+ // skip sentinel cleanup (and vice-versa).
527
+ try {
528
+ const { uninstallSentinel, isSentinelInstalled } = await import("./sentinel.mjs");
529
+ if (await isSentinelInstalled()) {
530
+ const isRoot = process.env.AI_SCANNER_FAKE_ROOT === "1"
531
+ || (process.getuid && process.getuid() === 0);
532
+ if (!isRoot) {
533
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
534
+ if (interactive) {
535
+ console.error("Sentinel daemon detected. Re-running via sudo to remove (you may be prompted for your password)...");
536
+ const result = spawnSync(
537
+ "sudo",
538
+ ["-E", process.execPath, ...process.argv.slice(1)],
539
+ { stdio: "inherit" }
540
+ );
541
+ process.exit(result.status ?? 0);
542
+ }
543
+ console.error("Note: sentinel daemon detected but uninstall needs root. Re-run:");
544
+ console.error(` sudo -E npx @coworker-jp/aidr uninstall`);
545
+ } else {
546
+ await uninstallSentinel({ dryRun: opts.dryRun });
547
+ }
548
+ }
549
+ } catch (e) {
550
+ console.error(`sentinel uninstall warning: ${e.message}`);
551
+ }
523
552
  }
524
553
 
525
554
  async function cmdDoctor() {
@@ -560,7 +589,7 @@ export async function run(argv) {
560
589
  program
561
590
  .name("aidr")
562
591
  .description("AIDR setup CLI — installs ai-scanner hooks for AI coding agents")
563
- .version("0.0.5");
592
+ .version("0.0.6");
564
593
 
565
594
  program
566
595
  .command("install")
package/src/scheduled.mjs CHANGED
@@ -33,6 +33,17 @@ export const LAUNCHD_WRAPPER = path.join(SYSTEM_BIN_DIR, "aidr-scheduled-wrappe
33
33
  // Linux cron fallback (system cron, runs as root)
34
34
  export const CRON_FILE = ROOT_PREFIX + "/etc/cron.d/aidr-scheduled";
35
35
 
36
+ // Shared world-readable system log directory. Both aidr-scheduled and
37
+ // coworker-sentinel daemons write here so non-root users can `tail` errors
38
+ // without sudo (memory: feedback_server_failures_never_burden_client.md).
39
+ // Mode 0755 root:root, files mode 0644. systemd's `StandardOutput=append:`
40
+ // (systemd 240+) and launchd's `StandardOutPath` redirect daemon stderr/stdout
41
+ // here; for cron, the entry uses a literal `>> ... 2>&1` redirect.
42
+ export const SYSTEM_LOG_DIR = ROOT_PREFIX + "/var/log/coworker";
43
+ export const SCHEDULED_LOG_FILE = path.join(SYSTEM_LOG_DIR, "scheduled.log");
44
+ export const SENTINEL_LOG_FILE = path.join(SYSTEM_LOG_DIR, "sentinel.log");
45
+ export const LOGROTATE_FILE = ROOT_PREFIX + "/etc/logrotate.d/coworker";
46
+
36
47
  function isRoot() {
37
48
  if (process.env.AI_SCANNER_FAKE_ROOT === "1") return true;
38
49
  return Boolean(process.getuid && process.getuid() === 0);
@@ -42,6 +53,85 @@ async function exists(p) {
42
53
  try { await fs.access(p); return true; } catch { return false; }
43
54
  }
44
55
 
56
+ /**
57
+ * Create SYSTEM_LOG_DIR (mode 0755 root:root) and touch the given daemon's
58
+ * log file (mode 0644) if it doesn't already exist. Idempotent — both
59
+ * installScheduled and installSentinel call this so the daemon's first run
60
+ * has a writable target even before any line is logged.
61
+ */
62
+ export async function ensureSystemLogDir(logFile, { dryRun = false } = {}) {
63
+ if (dryRun) {
64
+ console.log("[dry-run] Would create:", SYSTEM_LOG_DIR, "(mode 0755) and touch", logFile, "(mode 0644)");
65
+ return;
66
+ }
67
+ await fs.mkdir(SYSTEM_LOG_DIR, { recursive: true, mode: 0o755 });
68
+ if (!(await exists(logFile))) {
69
+ const fd = await fs.open(logFile, "w", 0o644);
70
+ await fd.close();
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Drop /etc/logrotate.d/coworker covering both daemons. Idempotent: same
76
+ * content overwritten on every install. The logrotate command itself may
77
+ * not be present on minimal containers; the config file is harmless when
78
+ * no logrotate is installed (it simply isn't read). Operators on systems
79
+ * without logrotate should configure their own rotation.
80
+ */
81
+ export async function writeLogrotateConfig({ dryRun = false } = {}) {
82
+ const body = `# Generated by aidr install. Covers both aidr-scheduled (weekly) and
83
+ # coworker-sentinel (daily, more chatty under load).
84
+ /var/log/coworker/scheduled.log {
85
+ weekly
86
+ rotate 4
87
+ compress
88
+ missingok
89
+ notifempty
90
+ create 0644 root root
91
+ }
92
+
93
+ /var/log/coworker/sentinel.log {
94
+ daily
95
+ rotate 7
96
+ compress
97
+ missingok
98
+ notifempty
99
+ create 0644 root root
100
+ }
101
+ `;
102
+ if (dryRun) {
103
+ console.log("[dry-run] Would create:", LOGROTATE_FILE, "(mode 0644)");
104
+ return;
105
+ }
106
+ await fs.mkdir(path.dirname(LOGROTATE_FILE), { recursive: true });
107
+ await fs.writeFile(LOGROTATE_FILE, body, { mode: 0o644 });
108
+ }
109
+
110
+ /**
111
+ * Remove the given daemon's log file. If both daemon log files are gone
112
+ * afterwards, also rmdir SYSTEM_LOG_DIR and remove the logrotate config.
113
+ * Used by uninstallScheduled and uninstallSentinel; the order doesn't
114
+ * matter (whichever daemon uninstalls last wins the cleanup).
115
+ */
116
+ export async function removeSystemLog(logFile, { dryRun = false } = {}) {
117
+ if (await exists(logFile)) {
118
+ if (dryRun) {
119
+ console.log("[dry-run] Would remove:", logFile);
120
+ } else {
121
+ try { await fs.unlink(logFile); } catch { /* ignore */ }
122
+ }
123
+ }
124
+ // dryRun does not modify state, so don't gate cleanup on the live FS.
125
+ if (dryRun) return;
126
+ const stillUsed = (await exists(SCHEDULED_LOG_FILE))
127
+ || (await exists(SENTINEL_LOG_FILE));
128
+ if (stillUsed) return;
129
+ try { await fs.rmdir(SYSTEM_LOG_DIR); } catch { /* dir not empty / not present */ }
130
+ if (await exists(LOGROTATE_FILE)) {
131
+ try { await fs.unlink(LOGROTATE_FILE); } catch { /* ignore */ }
132
+ }
133
+ }
134
+
45
135
  export async function isScheduledInstalled() {
46
136
  return (
47
137
  await exists(SYSTEMD_TIMER) ||
@@ -70,6 +160,12 @@ export async function installScheduled(opts) {
70
160
  // Write the shared env file first; both Linux and macOS paths reference it.
71
161
  await writeEnvFile(accessKey, dryRun);
72
162
 
163
+ // Create the world-readable log dir + touch scheduled.log + drop the
164
+ // shared logrotate config. Both run before the systemd / launchd / cron
165
+ // entry so the daemon's first invocation has a target to append to.
166
+ await ensureSystemLogDir(SCHEDULED_LOG_FILE, { dryRun });
167
+ await writeLogrotateConfig({ dryRun });
168
+
73
169
  const platform = process.platform;
74
170
  if (platform === "linux") {
75
171
  await installSystemd({ dryRun, noActivate, interval, binaryPath });
@@ -91,6 +187,9 @@ async function writeEnvFile(accessKey, dryRun) {
91
187
  }
92
188
 
93
189
  async function installSystemd({ binaryPath, dryRun, noActivate, interval = 4 }) {
190
+ // StandardOutput=append: requires systemd 240+ (released 2018). Modern
191
+ // distros (RHEL 9 / Ubuntu 20.04+ / Debian 11+) all support it. Older
192
+ // RHEL 8 (systemd 239) operators must redirect manually or upgrade.
94
193
  const serviceContent = `[Unit]
95
194
  Description=AIDR scheduled endpoint-info scan
96
195
  After=network-online.target
@@ -101,6 +200,8 @@ Type=oneshot
101
200
  User=root
102
201
  EnvironmentFile=${SYSTEM_ENV_FILE}
103
202
  ExecStart=${binaryPath} scan endpoint-info
203
+ StandardOutput=append:${SCHEDULED_LOG_FILE}
204
+ StandardError=append:${SCHEDULED_LOG_FILE}
104
205
  `;
105
206
 
106
207
  const timerContent = `[Unit]
@@ -166,6 +267,10 @@ exec ${binaryPath} scan endpoint-info
166
267
  <integer>${interval * 3600}</integer>
167
268
  <key>RunAtLoad</key>
168
269
  <false/>
270
+ <key>StandardOutPath</key>
271
+ <string>${SCHEDULED_LOG_FILE}</string>
272
+ <key>StandardErrorPath</key>
273
+ <string>${SCHEDULED_LOG_FILE}</string>
169
274
  </dict>
170
275
  </plist>
171
276
  `;
@@ -193,8 +298,10 @@ exec ${binaryPath} scan endpoint-info
193
298
 
194
299
  async function installCronDir({ binaryPath, dryRun, interval = 4 }) {
195
300
  // /etc/cron.d entries run as the user named in the line. We embed the env
196
- // file source via a small inline script.
197
- const entry = `0 */${interval} * * * root . ${SYSTEM_ENV_FILE} && ${binaryPath} scan endpoint-info 2>/dev/null # aidr-scheduled
301
+ // file source via a small inline script. stdout + stderr are appended to
302
+ // the world-readable log so non-root users can read recent runs without
303
+ // sudo (replaces the old `2>/dev/null` which deliberately swallowed errors).
304
+ const entry = `0 */${interval} * * * root . ${SYSTEM_ENV_FILE} && ${binaryPath} scan endpoint-info >> ${SCHEDULED_LOG_FILE} 2>&1 # aidr-scheduled
198
305
  `;
199
306
  if (dryRun) {
200
307
  console.log("[dry-run] Would create:", CRON_FILE);
@@ -245,4 +352,9 @@ export async function uninstallScheduled({ dryRun = false, noActivate = false }
245
352
  if (!dryRun) { try { await fs.unlink(SYSTEM_ENV_FILE); } catch { /* ignore */ } }
246
353
  else { console.log("[dry-run] Would remove:", SYSTEM_ENV_FILE); }
247
354
  }
355
+
356
+ // Drop the scheduled log file. removeSystemLog also rmdirs SYSTEM_LOG_DIR
357
+ // and deletes /etc/logrotate.d/coworker if no daemon log files remain
358
+ // (i.e. sentinel is also uninstalled).
359
+ await removeSystemLog(SCHEDULED_LOG_FILE, { dryRun });
248
360
  }
package/src/sentinel.mjs CHANGED
@@ -15,7 +15,14 @@ import path from "node:path";
15
15
  import { execFile, spawnSync } from "node:child_process";
16
16
  import { promisify } from "node:util";
17
17
  import { fetchSentinel, detectPlatform } from "./binary-fetcher.mjs";
18
- import { SYSTEM_BIN_DIR, SYSTEM_ENV_FILE } from "./scheduled.mjs";
18
+ import {
19
+ SYSTEM_BIN_DIR,
20
+ SYSTEM_ENV_FILE,
21
+ SENTINEL_LOG_FILE,
22
+ ensureSystemLogDir,
23
+ writeLogrotateConfig,
24
+ removeSystemLog,
25
+ } from "./scheduled.mjs";
19
26
 
20
27
  const execFileP = promisify(execFile);
21
28
 
@@ -24,7 +31,6 @@ const ROOT_PREFIX = process.env.AIDR_SCHEDULED_ROOT || "";
24
31
  export const SENTINEL_BIN = path.join(SYSTEM_BIN_DIR, "coworker-sentinel");
25
32
  export const SENTINEL_PLIST = ROOT_PREFIX + "/Library/LaunchDaemons/jp.coworker.sentinel.plist";
26
33
  export const SENTINEL_SERVICE = ROOT_PREFIX + "/etc/systemd/system/coworker-sentinel.service";
27
- export const SENTINEL_LOG_DIR = ROOT_PREFIX + "/var/log/coworker-sentinel";
28
34
 
29
35
  function isRoot() {
30
36
  if (process.env.AI_SCANNER_FAKE_ROOT === "1") return true;
@@ -54,16 +60,16 @@ function buildLaunchdPlist(envFile) {
54
60
  <array>
55
61
  <string>/bin/sh</string>
56
62
  <string>-c</string>
57
- <string>. ${envFile} &amp;&amp; exec ${SENTINEL_BIN}</string>
63
+ <string>set -a; . ${envFile}; set +a; exec ${SENTINEL_BIN}</string>
58
64
  </array>
59
65
  <key>RunAtLoad</key>
60
66
  <true/>
61
67
  <key>KeepAlive</key>
62
68
  <true/>
63
69
  <key>StandardOutPath</key>
64
- <string>${SENTINEL_LOG_DIR}/sentinel.log</string>
70
+ <string>${SENTINEL_LOG_FILE}</string>
65
71
  <key>StandardErrorPath</key>
66
- <string>${SENTINEL_LOG_DIR}/sentinel.log</string>
72
+ <string>${SENTINEL_LOG_FILE}</string>
67
73
  <key>ThrottleInterval</key>
68
74
  <integer>10</integer>
69
75
  </dict>
@@ -84,8 +90,8 @@ EnvironmentFile=${envFile}
84
90
  ExecStart=${SENTINEL_BIN}
85
91
  Restart=on-failure
86
92
  RestartSec=5
87
- StandardOutput=journal
88
- StandardError=journal
93
+ StandardOutput=append:${SENTINEL_LOG_FILE}
94
+ StandardError=append:${SENTINEL_LOG_FILE}
89
95
  SyslogIdentifier=coworker-sentinel
90
96
 
91
97
  [Install]
@@ -127,10 +133,10 @@ export async function installSentinel(opts = {}) {
127
133
  console.error(`[sentinel] DRY-RUN: would download → ${SENTINEL_BIN}`);
128
134
  }
129
135
 
130
- // 2. Create log directory
131
- if (!dryRun) {
132
- await fs.mkdir(SENTINEL_LOG_DIR, { recursive: true, mode: 0o755 });
133
- }
136
+ // 2. Create the world-readable log dir + touch sentinel.log + drop the
137
+ // shared logrotate config (covering both daemons; idempotent).
138
+ await ensureSystemLogDir(SENTINEL_LOG_FILE, { dryRun });
139
+ await writeLogrotateConfig({ dryRun });
134
140
 
135
141
  // 3. Write env file (append AI_SCANNER_ACCESS_KEY if missing)
136
142
  if (accessKey && !dryRun) {
@@ -161,13 +167,15 @@ async function _installMacos(dryRun, noActivate) {
161
167
  console.error(`[sentinel] Written: ${SENTINEL_PLIST}`);
162
168
 
163
169
  if (!noActivate && process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE !== "1") {
164
- // Unload existing (ignore errors) then load
165
- spawnSync("launchctl", ["unload", SENTINEL_PLIST], { stdio: "ignore" });
166
- const load = spawnSync("launchctl", ["load", "-w", SENTINEL_PLIST]);
167
- if (load.status !== 0) {
168
- console.warn("[sentinel] launchctl load failed — daemon may need manual start");
169
- } else {
170
+ // Modern launchctl API (bootstrap/bootout). Mirrors scheduled.mjs so
171
+ // both daemons share one mental model. Idempotent: bootout-before-
172
+ // bootstrap absorbs prior loads regardless of which API installed them.
173
+ try { await execFileP("launchctl", ["bootout", "system", SENTINEL_PLIST]); } catch { /* not loaded */ }
174
+ try {
175
+ await execFileP("launchctl", ["bootstrap", "system", SENTINEL_PLIST]);
170
176
  console.error("[sentinel] Daemon loaded via launchctl");
177
+ } catch (e) {
178
+ console.warn("[sentinel] launchctl bootstrap failed — daemon may need manual start:", e.message);
171
179
  }
172
180
  }
173
181
  } else {
@@ -206,7 +214,8 @@ export async function uninstallSentinel({ dryRun = false } = {}) {
206
214
  if (process.platform === "darwin") {
207
215
  if (await exists(SENTINEL_PLIST)) {
208
216
  if (!dryRun) {
209
- spawnSync("launchctl", ["unload", SENTINEL_PLIST], { stdio: "ignore" });
217
+ // Modern launchctl API; mirrors scheduled.mjs's uninstallScheduled.
218
+ try { await execFileP("launchctl", ["bootout", "system", SENTINEL_PLIST]); } catch { /* not loaded */ }
210
219
  await fs.unlink(SENTINEL_PLIST);
211
220
  console.error(`[sentinel] Removed: ${SENTINEL_PLIST}`);
212
221
  } else {
@@ -235,5 +244,10 @@ export async function uninstallSentinel({ dryRun = false } = {}) {
235
244
  }
236
245
  }
237
246
 
247
+ // Drop the sentinel log file. removeSystemLog also rmdirs SYSTEM_LOG_DIR
248
+ // and deletes /etc/logrotate.d/coworker if scheduled.log is also gone
249
+ // (i.e. the other daemon is uninstalled).
250
+ await removeSystemLog(SENTINEL_LOG_FILE, { dryRun });
251
+
238
252
  console.error("[sentinel] Uninstall complete.");
239
253
  }