@coworker-jp/aidr 0.0.1 → 0.0.2

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.1",
3
+ "version": "0.0.2",
4
4
  "description": "AIDR setup CLI - installs ai-scanner hooks for 19+ AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,7 @@ export async function install(installBase, accessKey, opts = {}) {
42
42
  ];
43
43
  const settingsPath = path.join(base, "settings.json");
44
44
  const [settingsRes, ...scriptResults] = await Promise.all([
45
- writeJsonMerge(settingsPath, buildClaudeSettings(accessKey, scope), mergeClaudeSettings, { dryRun }),
45
+ writeJsonMerge(settingsPath, buildClaudeSettings(accessKey, scope), mergeClaudeSettings, { dryRun, backup: scope !== "project" }),
46
46
  ...scriptEntries.map(([p, c, mode]) => writeFileSafe(p, c, { dryRun, force: true, backup: false, mode })),
47
47
  ]);
48
48
  const scriptBackups = scriptResults.map((r) => r && r.backupPath).filter(Boolean);
@@ -59,7 +59,7 @@ export async function install(installBase, accessKey, opts = {}) {
59
59
  path.join(base, "hooks.json"),
60
60
  buildCodexHooksJson(scope, home),
61
61
  mergeCodexHooksJson,
62
- { dryRun, mode: 0o644 },
62
+ { dryRun, mode: 0o644, backup: scope !== "project" },
63
63
  );
64
64
 
65
65
  // 2) Merge config.toml to enable the feature flag. Backs up pre-merge TOML
@@ -47,7 +47,7 @@ export async function install(installBase, accessKey, opts = {}) {
47
47
  path.join(base, "hooks.json"),
48
48
  buildCursorHooksJson(scope, home),
49
49
  mergeCursorHooksJson,
50
- { dryRun, mode: 0o644 },
50
+ { dryRun, mode: 0o644, backup: scope !== "project" },
51
51
  );
52
52
 
53
53
  const entries = [
@@ -15,14 +15,14 @@ export const meta = {
15
15
  };
16
16
 
17
17
  export async function install(home, accessKey, opts = {}) {
18
- const { dryRun = false } = opts;
18
+ const { dryRun = false, scope = "project" } = opts;
19
19
  const base = meta.configDir(home);
20
20
  const hooks = path.join(base, "hooks");
21
21
  const binary = path.join(home, meta.agentDir, "bin", "ai-scanner");
22
22
  const settingsPath = path.join(base, "settings.json");
23
23
  const preHookPath = path.join(hooks, "aidr-pre.sh");
24
24
  const [settingsRes, hookRes] = await Promise.all([
25
- writeJsonMerge(settingsPath, buildGeminiSettings(accessKey, binary), mergeGeminiSettings, { dryRun }),
25
+ writeJsonMerge(settingsPath, buildGeminiSettings(accessKey, binary), mergeGeminiSettings, { dryRun, backup: scope !== "project" }),
26
26
  writeFileSafe(preHookPath, T.getGeminiPreHookSh(binary), { dryRun, force: true, mode: 0o755 }),
27
27
  ]);
28
28
  return {
@@ -44,7 +44,7 @@ export async function install(installBase, accessKey, opts = {}) {
44
44
  path.join(base, "hooks.json"),
45
45
  buildWindsurfHooksJson(scope, home),
46
46
  mergeWindsurfHooksJson,
47
- { dryRun, mode: 0o644 },
47
+ { dryRun, mode: 0o644, backup: scope !== "project" },
48
48
  );
49
49
 
50
50
  const entries = [
package/src/cli.mjs CHANGED
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import os from "node:os";
3
3
  import fs from "node:fs/promises";
4
4
  import readline from "node:readline";
5
- import { execFile } from "node:child_process";
5
+ import { execFile, spawnSync } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { Command } from "commander";
@@ -127,11 +127,12 @@ async function cmdInstall(opts) {
127
127
  opts.scheduled = true;
128
128
  }
129
129
 
130
- // --scheduled installs a cron / systemd / launchd entry that runs
131
- // `ai-scanner scan endpoint-info` periodically against an agent-independent
132
- // binary at ~/.aidr/bin/ai-scanner. Auto-add the standalone install so the
133
- // binary fetch loop drops a copy there even when the user only listed AI
134
- // agents (claude / cursor / ...) on the command line.
130
+ // --scheduled installs a system-wide cron / systemd / launchd entry that
131
+ // runs `ai-scanner scan endpoint-info` periodically as root, against an
132
+ // agent-independent binary at /opt/coworker/aidr/bin/ai-scanner. Auto-add
133
+ // the standalone install so the binary fetch loop drops a copy there
134
+ // even when the user only listed AI agents (claude / cursor / ...) on
135
+ // the command line.
135
136
  if (opts.scheduled && !agents.includes("standalone")) {
136
137
  agents.push("standalone");
137
138
  }
@@ -142,6 +143,29 @@ async function cmdInstall(opts) {
142
143
  process.exit(1);
143
144
  }
144
145
 
146
+ // --scheduled の system path 書き込みは root 必須。
147
+ // - TTY あり (対話 shell): `sudo -E` で自分自身を re-exec、password prompt が出る
148
+ // - TTY なし (CI / pipe): sudo は対話的に password を聞けないので fail-fast 案内
149
+ // smoke / unit test では `AI_SCANNER_FAKE_ROOT=1` を設定してこのチェックを skip 可能。
150
+ if (opts.scheduled && !opts.dryRun
151
+ && process.getuid && process.getuid() !== 0
152
+ && process.env.AI_SCANNER_FAKE_ROOT !== "1") {
153
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
154
+ if (interactive) {
155
+ console.error("--scheduled requires root. Re-running via sudo (you may be prompted for your password)...");
156
+ const result = spawnSync(
157
+ "sudo",
158
+ ["-E", process.execPath, ...process.argv.slice(1)],
159
+ { stdio: "inherit" }
160
+ );
161
+ process.exit(result.status ?? 1);
162
+ }
163
+ console.error("Error: --scheduled requires root privileges (writes /opt/coworker/aidr, /etc/systemd/system, /etc/aidr).");
164
+ console.error("Re-run with sudo (non-interactive shells cannot prompt for password):");
165
+ console.error(` sudo -E npx @coworker-jp/aidr install --scheduled --key <ak_xxx>`);
166
+ process.exit(2);
167
+ }
168
+
145
169
  // Confirmation for existing shared settings.json (claude / gemini).
146
170
  // --yes bypasses; --dry-run doesn't write so skip; otherwise prompt on TTY,
147
171
  // error if non-interactive (safer than silently merging in CI).
@@ -171,9 +195,16 @@ async function cmdInstall(opts) {
171
195
  }
172
196
 
173
197
  if (!opts.skipBinary && !opts.dryRun) {
198
+ const { SYSTEM_BIN_DIR } = await import("./scheduled.mjs");
174
199
  const agentBinDirs = [];
175
200
  for (const name of agents) {
176
201
  const mod = await loadAgent(name);
202
+ // --scheduled 時の standalone は system path (/opt/coworker/aidr/bin)
203
+ // に置く。それ以外は従来通り per-user の ~/.<agent>/bin。
204
+ if (name === "standalone" && opts.scheduled) {
205
+ agentBinDirs.push(SYSTEM_BIN_DIR);
206
+ continue;
207
+ }
177
208
  const agentDir = mod.meta.agentDir;
178
209
  if (!agentDir) continue;
179
210
  agentBinDirs.push(path.join(home, agentDir, "bin"));
@@ -252,16 +283,11 @@ async function cmdInstall(opts) {
252
283
 
253
284
  if (opts.scheduled && !opts.dryRun) {
254
285
  const { installScheduled } = await import("./scheduled.mjs");
255
- // Always point the cron / systemd / launchd entry at the standalone
256
- // binary (~/.aidr/bin/ai-scanner). It is agent-independent, self-updates
257
- // on every endpoint-info run, and survives an individual agent's removal.
258
- const standalone = await loadAgent("standalone");
259
- const binaryPath = path.join(home, standalone.meta.agentDir, "bin", "ai-scanner");
286
+ // Schedule entries always point at /opt/coworker/aidr/bin/ai-scanner
287
+ // (system-wide, root-owned). The path is hard-coded inside scheduled.mjs.
260
288
  try {
261
289
  await installScheduled({
262
- home,
263
290
  accessKey: opts.key,
264
- binaryPath,
265
291
  dryRun: opts.dryRun,
266
292
  interval: intervalHours,
267
293
  });
@@ -409,9 +435,31 @@ async function cmdUninstall(opts) {
409
435
  }
410
436
  }));
411
437
 
438
+ // Schedule entries live in system paths (/etc, /opt, /Library/LaunchDaemons)
439
+ // and need root to remove. Mirror cmdInstall's auto-sudo: TTY なら sudo に
440
+ // re-exec、non-TTY は guidance を出して per-user 部分だけ撤去で完了。
412
441
  try {
413
- const { uninstallScheduled } = await import("./scheduled.mjs");
414
- await uninstallScheduled({ home, dryRun: opts.dryRun });
442
+ const { uninstallScheduled, isScheduledInstalled } = await import("./scheduled.mjs");
443
+ if (await isScheduledInstalled()) {
444
+ const isRoot = process.env.AI_SCANNER_FAKE_ROOT === "1"
445
+ || (process.getuid && process.getuid() === 0);
446
+ if (!isRoot) {
447
+ const interactive = process.stdin.isTTY && process.stdout.isTTY && process.platform !== "win32";
448
+ if (interactive) {
449
+ console.error("Scheduled entry detected. Re-running via sudo to remove (you may be prompted for your password)...");
450
+ const result = spawnSync(
451
+ "sudo",
452
+ ["-E", process.execPath, ...process.argv.slice(1)],
453
+ { stdio: "inherit" }
454
+ );
455
+ process.exit(result.status ?? 0);
456
+ }
457
+ console.error("Note: scheduled entry detected but uninstall needs root. Re-run:");
458
+ console.error(` sudo -E npx @coworker-jp/aidr uninstall`);
459
+ } else {
460
+ await uninstallScheduled({ dryRun: opts.dryRun });
461
+ }
462
+ }
415
463
  } catch (e) {
416
464
  console.error(`scheduled uninstall warning: ${e.message}`);
417
465
  }
@@ -455,7 +503,7 @@ export async function run(argv) {
455
503
  program
456
504
  .name("aidr")
457
505
  .description("AIDR setup CLI — installs ai-scanner hooks for AI coding agents")
458
- .version("0.0.1");
506
+ .version("0.0.2");
459
507
 
460
508
  program
461
509
  .command("install")
package/src/fs-utils.mjs CHANGED
@@ -127,6 +127,10 @@ export async function removeIfExists(p) {
127
127
  export async function writeJsonMerge(target, ourObject, mergeFn, {
128
128
  dryRun = false,
129
129
  mode = 0o644,
130
+ // `backup: false` で書き込み前バックアップを skip。リポジトリ git 配下に
131
+ // 入る project scope では、git 自体が history を持つので `<target>.backup-<ts>`
132
+ // が単に noise を増やす。user scope (~/.<agent>/...) では既定 true のまま残す。
133
+ backup = true,
130
134
  stdout = process.stdout,
131
135
  stderr = process.stderr,
132
136
  } = {}) {
@@ -160,16 +164,23 @@ export async function writeJsonMerge(target, ourObject, mergeFn, {
160
164
  return { written: false, path: target, backupPath: null, unchanged: true };
161
165
  }
162
166
 
163
- if (stat && !dryRun) {
167
+ if (stat && backup && !dryRun) {
164
168
  backupPath = backupPathFor(target);
165
169
  await fsp.copyFile(target, backupPath);
166
170
  }
167
171
  if (stat) {
168
- stderr.write(
169
- `WARNING: ${target} already exists. ` +
170
- `${dryRun ? "(dry-run: would back up)" : `Backed up to ${backupPath}.`}\n` +
171
- `Merging ai-scanner env/hooks; your other settings are preserved.\n`
172
- );
172
+ if (backup) {
173
+ stderr.write(
174
+ `WARNING: ${target} already exists. ` +
175
+ `${dryRun ? "(dry-run: would back up)" : `Backed up to ${backupPath}.`}\n` +
176
+ `Merging ai-scanner env/hooks; your other settings are preserved.\n`
177
+ );
178
+ } else {
179
+ stderr.write(
180
+ `Updating ${target} (no backup; project scope is git-tracked).\n` +
181
+ `Merging ai-scanner env/hooks; your other settings are preserved.\n`
182
+ );
183
+ }
173
184
  }
174
185
 
175
186
  if (dryRun) {
package/src/scheduled.mjs CHANGED
@@ -1,62 +1,105 @@
1
1
  import fs from "fs/promises";
2
2
  import path from "path";
3
- import os from "os";
4
3
  import { execFile } from "child_process";
5
4
  import { promisify } from "util";
6
5
 
7
6
  const execFileP = promisify(execFile);
8
7
 
9
- function systemdDir(home) {
10
- return path.join(home, ".config", "systemd", "user");
8
+ // System-wide install paths (root-only). The schedule entry runs as root so
9
+ // endpoint-info can collect host-wide inventory (process list, SUID, /etc/*).
10
+ // All paths are owned by root:root; the access-key env file is mode 0600.
11
+ //
12
+ // `AIDR_SCHEDULED_ROOT` env (smoke / unit test only) prefixes every path so
13
+ // they land under a tmpdir like `/tmp/test123/opt/coworker/aidr/bin/...`
14
+ // without ever touching real /etc or /opt. Production should never set this.
15
+ const ROOT_PREFIX = process.env.AIDR_SCHEDULED_ROOT || "";
16
+
17
+ export const SYSTEM_BIN_DIR = ROOT_PREFIX + "/opt/coworker/aidr/bin";
18
+ export const SYSTEM_BIN = path.join(SYSTEM_BIN_DIR, "ai-scanner");
19
+ export const SYSTEM_OPENGREP = path.join(SYSTEM_BIN_DIR, "opengrep");
20
+ export const SYSTEM_USR_LOCAL_BIN = ROOT_PREFIX + "/usr/local/bin/ai-scanner";
21
+ const SYSTEM_ENV_DIR = ROOT_PREFIX + "/etc/aidr";
22
+ export const SYSTEM_ENV_FILE = path.join(SYSTEM_ENV_DIR, "aidr.env");
23
+
24
+ // Linux systemd
25
+ const SYSTEMD_DIR = ROOT_PREFIX + "/etc/systemd/system";
26
+ export const SYSTEMD_SERVICE = path.join(SYSTEMD_DIR, "aidr-scheduled.service");
27
+ export const SYSTEMD_TIMER = path.join(SYSTEMD_DIR, "aidr-scheduled.timer");
28
+
29
+ // macOS launchd (LaunchDaemons run as root, available pre-login)
30
+ export const LAUNCHD_PLIST = ROOT_PREFIX + "/Library/LaunchDaemons/jp.coworker.aidr.scheduled.plist";
31
+ export const LAUNCHD_WRAPPER = path.join(SYSTEM_BIN_DIR, "aidr-scheduled-wrapper.sh");
32
+
33
+ // Linux cron fallback (system cron, runs as root)
34
+ export const CRON_FILE = ROOT_PREFIX + "/etc/cron.d/aidr-scheduled";
35
+
36
+ function isRoot() {
37
+ if (process.env.AI_SCANNER_FAKE_ROOT === "1") return true;
38
+ return Boolean(process.getuid && process.getuid() === 0);
11
39
  }
12
- function systemdService(home) { return path.join(systemdDir(home), "aidr-scheduled.service"); }
13
- function systemdTimer(home) { return path.join(systemdDir(home), "aidr-scheduled.timer"); }
14
- function systemdEnv(home) { return path.join(systemdDir(home), "aidr-scheduled.env"); }
15
- function launchdPlist(home) { return path.join(home, "Library", "LaunchAgents", "jp.coworker.aidr.scheduled.plist"); }
16
40
 
17
41
  async function exists(p) {
18
42
  try { await fs.access(p); return true; } catch { return false; }
19
43
  }
20
44
 
21
- export async function isScheduledInstalled(home) {
45
+ export async function isScheduledInstalled() {
22
46
  return (
23
- await exists(systemdTimer(home)) ||
24
- await exists(launchdPlist(home))
47
+ await exists(SYSTEMD_TIMER) ||
48
+ await exists(LAUNCHD_PLIST) ||
49
+ await exists(CRON_FILE)
25
50
  );
26
51
  }
27
52
 
28
53
  export async function installScheduled(opts) {
29
- const { home = os.homedir(), accessKey, binaryPath,
30
- dryRun = false, interval = 4 } = opts;
54
+ const { accessKey, dryRun = false, interval = 4 } = opts;
31
55
  const noActivate = opts.noActivate || process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1";
32
56
 
33
- // Schedule entry is agent-independent (cron points at ~/.aidr/bin/ai-scanner
34
- // and the binary self-updates on each run). If a previous schedule entry
35
- // exists, replace it unconditionally — the only thing that could differ is
36
- // the binary path or interval, both of which the new install owns.
37
- if (await isScheduledInstalled(home)) {
38
- await uninstallScheduled({ home, dryRun, noActivate });
57
+ if (!isRoot()) {
58
+ throw new Error("installScheduled requires root privileges (system paths under /etc and /opt)");
39
59
  }
40
60
 
61
+ // Always use the canonical system binary path; the binary is placed there by
62
+ // the standalone agent's binary fetch step before this function runs.
63
+ const binaryPath = SYSTEM_BIN;
64
+
65
+ // Replace any prior schedule entry. Avoids duplicate ExecStart / interval drift.
66
+ if (await isScheduledInstalled()) {
67
+ await uninstallScheduled({ dryRun, noActivate });
68
+ }
69
+
70
+ // Write the shared env file first; both Linux and macOS paths reference it.
71
+ await writeEnvFile(accessKey, dryRun);
72
+
41
73
  const platform = process.platform;
42
74
  if (platform === "linux") {
43
- await installSystemd({ home, accessKey, binaryPath, dryRun, noActivate, interval });
75
+ await installSystemd({ dryRun, noActivate, interval, binaryPath });
44
76
  } else if (platform === "darwin") {
45
- await installLaunchd({ home, accessKey, binaryPath, dryRun, noActivate, interval });
77
+ await installLaunchd({ dryRun, noActivate, interval, binaryPath });
46
78
  } else {
47
- await installCrontab({ binaryPath, dryRun, interval });
79
+ await installCronDir({ binaryPath, dryRun, interval });
48
80
  }
49
81
  }
50
82
 
51
- async function installSystemd({ home, accessKey, binaryPath, dryRun, noActivate, interval = 4 }) {
52
- const dir = systemdDir(home);
83
+ async function writeEnvFile(accessKey, dryRun) {
84
+ const envContent = `AI_SCANNER_ACCESS_KEY=${accessKey}\n`;
85
+ if (dryRun) {
86
+ console.log("[dry-run] Would create:", SYSTEM_ENV_FILE, "(mode 0600)");
87
+ return;
88
+ }
89
+ await fs.mkdir(SYSTEM_ENV_DIR, { recursive: true, mode: 0o755 });
90
+ await fs.writeFile(SYSTEM_ENV_FILE, envContent, { mode: 0o600 });
91
+ }
53
92
 
93
+ async function installSystemd({ binaryPath, dryRun, noActivate, interval = 4 }) {
54
94
  const serviceContent = `[Unit]
55
95
  Description=AIDR scheduled endpoint-info scan
96
+ After=network-online.target
97
+ Wants=network-online.target
56
98
 
57
99
  [Service]
58
100
  Type=oneshot
59
- EnvironmentFile=${systemdEnv(home)}
101
+ User=root
102
+ EnvironmentFile=${SYSTEM_ENV_FILE}
60
103
  ExecStart=${binaryPath} scan endpoint-info
61
104
  `;
62
105
 
@@ -72,32 +115,40 @@ Persistent=true
72
115
  WantedBy=timers.target
73
116
  `;
74
117
 
75
- const envContent = `AI_SCANNER_ACCESS_KEY=${accessKey}\n`;
76
-
77
118
  if (dryRun) {
78
- console.log("[dry-run] Would create:", systemdService(home), systemdTimer(home), systemdEnv(home));
119
+ console.log("[dry-run] Would create:", SYSTEMD_SERVICE, SYSTEMD_TIMER);
79
120
  return;
80
121
  }
81
122
 
82
- await fs.mkdir(dir, { recursive: true });
83
- await fs.writeFile(systemdService(home), serviceContent, { mode: 0o644 });
84
- await fs.writeFile(systemdTimer(home), timerContent, { mode: 0o644 });
85
- await fs.writeFile(systemdEnv(home), envContent, { mode: 0o600 });
123
+ await fs.mkdir(path.dirname(SYSTEMD_SERVICE), { recursive: true });
124
+ await fs.writeFile(SYSTEMD_SERVICE, serviceContent, { mode: 0o644 });
125
+ await fs.writeFile(SYSTEMD_TIMER, timerContent, { mode: 0o644 });
86
126
 
87
127
  if (!noActivate) {
88
128
  try {
89
- await execFileP("systemctl", ["--user", "daemon-reload"]);
90
- await execFileP("systemctl", ["--user", "enable", "--now", "aidr-scheduled.timer"]);
91
- console.log(`Scheduled scan enabled via systemd user timer (every ${interval}h)`);
129
+ await execFileP("systemctl", ["daemon-reload"]);
130
+ await execFileP("systemctl", ["enable", "--now", "aidr-scheduled.timer"]);
131
+ console.log(`Scheduled scan enabled via system systemd timer (every ${interval}h)`);
92
132
  } catch (e) {
93
- console.warn("Warning: systemctl --user failed:", e.message);
94
- console.warn("Timer files were written. Enable manually: systemctl --user enable --now aidr-scheduled.timer");
133
+ console.warn("Warning: systemctl failed:", e.message);
134
+ console.warn("Timer files were written. Enable manually: sudo systemctl enable --now aidr-scheduled.timer");
95
135
  }
96
136
  }
97
137
  }
98
138
 
99
- async function installLaunchd({ home, accessKey, binaryPath, dryRun, noActivate, interval = 4 }) {
100
- const dir = path.join(home, "Library", "LaunchAgents");
139
+ async function installLaunchd({ binaryPath, dryRun, noActivate, interval = 4 }) {
140
+ // launchd does not support EnvironmentFile, so we use a tiny wrapper that
141
+ // sources /etc/aidr/aidr.env and execs the binary. Plist itself stays 0644
142
+ // (launchctl bootstrap requires it readable); the secret lives in the env
143
+ // file at 0600.
144
+ const wrapperContent = `#!/bin/bash
145
+ # Generated by aidr install --scheduled. Sources the system env file and
146
+ # execs ai-scanner so the access key is not embedded in the LaunchDaemon plist.
147
+ set -a
148
+ . ${SYSTEM_ENV_FILE}
149
+ set +a
150
+ exec ${binaryPath} scan endpoint-info
151
+ `;
101
152
 
102
153
  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
103
154
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -105,17 +156,12 @@ async function installLaunchd({ home, accessKey, binaryPath, dryRun, noActivate,
105
156
  <dict>
106
157
  <key>Label</key>
107
158
  <string>jp.coworker.aidr.scheduled</string>
159
+ <key>UserName</key>
160
+ <string>root</string>
108
161
  <key>ProgramArguments</key>
109
162
  <array>
110
- <string>${binaryPath}</string>
111
- <string>scan</string>
112
- <string>endpoint-info</string>
163
+ <string>${LAUNCHD_WRAPPER}</string>
113
164
  </array>
114
- <key>EnvironmentVariables</key>
115
- <dict>
116
- <key>AI_SCANNER_ACCESS_KEY</key>
117
- <string>${accessKey}</string>
118
- </dict>
119
165
  <key>StartInterval</key>
120
166
  <integer>${interval * 3600}</integer>
121
167
  <key>RunAtLoad</key>
@@ -125,95 +171,78 @@ async function installLaunchd({ home, accessKey, binaryPath, dryRun, noActivate,
125
171
  `;
126
172
 
127
173
  if (dryRun) {
128
- console.log("[dry-run] Would create:", launchdPlist(home));
174
+ console.log("[dry-run] Would create:", LAUNCHD_WRAPPER, LAUNCHD_PLIST);
129
175
  return;
130
176
  }
131
177
 
132
- await fs.mkdir(dir, { recursive: true });
133
- await fs.writeFile(launchdPlist(home), plistContent, { mode: 0o644 });
178
+ await fs.mkdir(path.dirname(LAUNCHD_WRAPPER), { recursive: true });
179
+ await fs.mkdir(path.dirname(LAUNCHD_PLIST), { recursive: true });
180
+ await fs.writeFile(LAUNCHD_WRAPPER, wrapperContent, { mode: 0o755 });
181
+ await fs.writeFile(LAUNCHD_PLIST, plistContent, { mode: 0o644 });
134
182
 
135
183
  if (!noActivate) {
136
184
  try {
137
- const uid = process.getuid?.() ?? (await execFileP("id", ["-u"])).stdout.trim();
138
- await execFileP("launchctl", ["bootstrap", `gui/${uid}`, launchdPlist(home)]);
139
- console.log(`Scheduled scan enabled via launchd (every ${interval}h)`);
185
+ await execFileP("launchctl", ["bootstrap", "system", LAUNCHD_PLIST]);
186
+ console.log(`Scheduled scan enabled via system LaunchDaemon (every ${interval}h)`);
140
187
  } catch (e) {
141
188
  console.warn("Warning: launchctl bootstrap failed:", e.message);
142
- console.warn("Plist written. Enable manually: launchctl load ~/Library/LaunchAgents/jp.coworker.aidr.scheduled.plist");
189
+ console.warn(`Plist written. Enable manually: sudo launchctl bootstrap system ${LAUNCHD_PLIST}`);
143
190
  }
144
191
  }
145
192
  }
146
193
 
147
- async function installCrontab({ binaryPath, dryRun, interval = 4 }) {
148
- const entry = `0 */${interval} * * * ${binaryPath} scan endpoint-info 2>/dev/null # aidr-scheduled\n`;
149
-
194
+ async function installCronDir({ binaryPath, dryRun, interval = 4 }) {
195
+ // /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
198
+ `;
150
199
  if (dryRun) {
151
- console.log("[dry-run] Would add crontab entry:", entry.trim());
200
+ console.log("[dry-run] Would create:", CRON_FILE);
152
201
  return;
153
202
  }
154
-
155
- let existing = "";
156
- try {
157
- const { stdout } = await execFileP("crontab", ["-l"]);
158
- existing = stdout;
159
- } catch { /* no crontab yet */ }
160
-
161
- if (existing.includes("aidr-scheduled")) return;
162
-
163
- const newCrontab = existing.endsWith("\n") || existing === ""
164
- ? existing + entry
165
- : existing + "\n" + entry;
166
-
167
- const child = execFile("crontab", ["-"]);
168
- child.stdin.write(newCrontab);
169
- child.stdin.end();
170
- await new Promise((resolve, reject) => child.on("close", c => c === 0 ? resolve() : reject(new Error(`crontab exit ${c}`))));
171
- console.log(`Scheduled scan added to user crontab (every ${interval}h)`);
203
+ await fs.mkdir(path.dirname(CRON_FILE), { recursive: true });
204
+ await fs.writeFile(CRON_FILE, entry, { mode: 0o644 });
205
+ console.log(`Scheduled scan added to ${CRON_FILE} (every ${interval}h)`);
172
206
  }
173
207
 
174
- export async function uninstallScheduled({ home = os.homedir(), dryRun = false, noActivate = false } = {}) {
208
+ export async function uninstallScheduled({ dryRun = false, noActivate = false } = {}) {
175
209
  noActivate = noActivate || process.env.AI_SCANNER_NO_SCHEDULED_ACTIVATE === "1";
176
210
 
177
- if (await exists(systemdTimer(home))) {
211
+ if (!isRoot()) {
212
+ throw new Error("uninstallScheduled requires root privileges");
213
+ }
214
+
215
+ if (await exists(SYSTEMD_TIMER)) {
178
216
  if (!dryRun && !noActivate) {
179
- try { await execFileP("systemctl", ["--user", "disable", "--now", "aidr-scheduled.timer"]); } catch { /* ignore */ }
217
+ try { await execFileP("systemctl", ["disable", "--now", "aidr-scheduled.timer"]); } catch { /* ignore */ }
180
218
  }
181
- for (const f of [systemdService(home), systemdTimer(home), systemdEnv(home)]) {
219
+ for (const f of [SYSTEMD_SERVICE, SYSTEMD_TIMER]) {
182
220
  if (!dryRun) { try { await fs.unlink(f); } catch { /* ignore */ } }
183
221
  else { console.log("[dry-run] Would remove:", f); }
184
222
  }
185
223
  }
186
224
 
187
- if (await exists(launchdPlist(home))) {
225
+ if (await exists(LAUNCHD_PLIST)) {
188
226
  if (!dryRun && !noActivate) {
189
- try {
190
- const uid = process.getuid?.() ?? (await execFileP("id", ["-u"])).stdout.trim();
191
- await execFileP("launchctl", ["bootout", `gui/${uid}`, launchdPlist(home)]);
192
- } catch { /* ignore */ }
227
+ try { await execFileP("launchctl", ["bootout", "system", LAUNCHD_PLIST]); } catch { /* ignore */ }
228
+ }
229
+ if (!dryRun) {
230
+ try { await fs.unlink(LAUNCHD_PLIST); } catch { /* ignore */ }
231
+ try { await fs.unlink(LAUNCHD_WRAPPER); } catch { /* ignore */ }
232
+ } else {
233
+ console.log("[dry-run] Would remove:", LAUNCHD_PLIST, LAUNCHD_WRAPPER);
193
234
  }
194
- if (!dryRun) { try { await fs.unlink(launchdPlist(home)); } catch { /* ignore */ } }
195
- else { console.log("[dry-run] Would remove:", launchdPlist(home)); }
196
235
  }
197
236
 
198
- try {
199
- const { stdout } = await execFileP("crontab", ["-l"]);
200
- const filtered = stdout.split("\n").filter(l => !l.includes("aidr-scheduled")).join("\n");
201
- if (filtered !== stdout) {
202
- if (!dryRun) {
203
- const child = execFile("crontab", ["-"]);
204
- child.stdin.write(filtered + (filtered.endsWith("\n") ? "" : "\n"));
205
- child.stdin.end();
206
- await new Promise(r => child.on("close", r));
207
- } else {
208
- console.log("[dry-run] Would remove aidr-scheduled from crontab");
209
- }
210
- }
211
- } catch { /* no crontab */ }
212
- }
237
+ if (await exists(CRON_FILE)) {
238
+ if (!dryRun) { try { await fs.unlink(CRON_FILE); } catch { /* ignore */ } }
239
+ else { console.log("[dry-run] Would remove:", CRON_FILE); }
240
+ }
213
241
 
214
- function promptUser(question) {
215
- return new Promise(resolve => {
216
- process.stdout.write(question);
217
- process.stdin.once("data", d => resolve(d.toString().trim()));
218
- });
242
+ // Remove the env file last so an aborted uninstall (mid-systemctl) doesn't
243
+ // strand a service file that still references a missing env file.
244
+ if (await exists(SYSTEM_ENV_FILE)) {
245
+ if (!dryRun) { try { await fs.unlink(SYSTEM_ENV_FILE); } catch { /* ignore */ } }
246
+ else { console.log("[dry-run] Would remove:", SYSTEM_ENV_FILE); }
247
+ }
219
248
  }
package/src/templates.mjs CHANGED
@@ -300,7 +300,8 @@ export AI_SCANNER_DETECTION_LOG="\${CLAUDE_PROJECT_DIR:-.}/.claude/logs/detectio
300
300
 
301
301
  ${SCAN_HELPERS}
302
302
 
303
- jq -r '.tool_response // ""' | run_scanner scan websearch`;
303
+ jq -r '.tool_response // ""' | run_scanner scan websearch
304
+ `;
304
305
 
305
306
  export const LOG_WEB_URLS_SH = `#!/bin/bash
306
307
  # Log URLs from WebSearch and WebFetch tool calls