@coworker-jp/aidr 0.1.2 → 0.1.4

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.1.2",
3
+ "version": "0.1.4",
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
@@ -589,7 +589,7 @@ export async function run(argv) {
589
589
  program
590
590
  .name("aidr")
591
591
  .description("AIDR setup CLI — installs ai-scanner hooks for AI coding agents")
592
- .version("0.1.2");
592
+ .version("0.1.4");
593
593
 
594
594
  program
595
595
  .command("install")
@@ -0,0 +1,63 @@
1
+ // Best-effort invoker username resolution at install time.
2
+ //
3
+ // `aidr install --scheduled` / `--with-sentinel` writes the unix username
4
+ // into `/etc/aidr/aidr.env` as `AI_SCANNER_INVOKER_USER`. The Rust daemon
5
+ // reads it back to resolve git email / Apple ID / $HOME of the actual
6
+ // installer (root daemons have no useful identity of their own).
7
+ //
8
+ // In the auto-sudo path the CLI re-execs itself via `sudo -E`, which sets
9
+ // SUDO_USER. But several real-world contexts arrive here as bare root
10
+ // without SUDO_USER:
11
+ // - `ssh root@host` then `npx ... install --scheduled` (no sudo prefix)
12
+ // - container / CI where the user is already root
13
+ // - sudo policy that strips SUDO_USER
14
+ //
15
+ // We walk a fallback chain so the resulting daemon snapshot reports the
16
+ // real user instead of `actor_id=unknown`. Each step is best-effort and
17
+ // never throws — we return `null` when no signal is available, which is
18
+ // the honest outcome for genuinely identity-less contexts (CI runners).
19
+
20
+ import { execSync } from "node:child_process";
21
+
22
+ function tryExec(cmd) {
23
+ try {
24
+ return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
25
+ } catch {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ function isUsable(name) {
31
+ return name && name !== "root";
32
+ }
33
+
34
+ export function resolveInvokerUser() {
35
+ if (process.env.SUDO_USER) return process.env.SUDO_USER;
36
+
37
+ // `who am i` reads utmp, so it returns the actual login user even after
38
+ // `sudo -i` / `su -`. Empty under non-tty / no utmp.
39
+ const whoUser = tryExec("who am i").split(/\s+/)[0];
40
+ if (isUsable(whoUser)) return whoUser;
41
+
42
+ // POSIX `logname` gives controlling-tty owner. Slightly different code
43
+ // path from `who am i` — kept as a separate fallback because they fail
44
+ // independently on minimal images.
45
+ const logname = tryExec("logname");
46
+ if (isUsable(logname)) return logname;
47
+
48
+ // systemd Linux: `loginctl list-sessions` columns are: SESSION UID USER ...
49
+ const loginctl = tryExec("loginctl list-sessions --no-legend");
50
+ for (const line of loginctl.split("\n")) {
51
+ const fields = line.trim().split(/\s+/);
52
+ if (fields.length >= 3 && isUsable(fields[2])) return fields[2];
53
+ }
54
+
55
+ // Env fallbacks (root-filtered) so a user-shell install without sudo at
56
+ // all (rare for --scheduled, possible for plain --agent without root)
57
+ // still captures the obvious answer.
58
+ for (const env of ["LOGNAME", "USER"]) {
59
+ if (isUsable(process.env[env])) return process.env[env];
60
+ }
61
+
62
+ return null;
63
+ }
package/src/scheduled.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from "fs/promises";
2
2
  import path from "path";
3
3
  import { execFile } from "child_process";
4
4
  import { promisify } from "util";
5
+ import { resolveInvokerUser } from "./invoker-resolve.mjs";
5
6
 
6
7
  const execFileP = promisify(execFile);
7
8
 
@@ -21,24 +22,19 @@ export const SYSTEM_USR_LOCAL_BIN = ROOT_PREFIX + "/usr/local/bin/ai-scanner";
21
22
  const SYSTEM_ENV_DIR = ROOT_PREFIX + "/etc/aidr";
22
23
  export const SYSTEM_ENV_FILE = path.join(SYSTEM_ENV_DIR, "aidr.env");
23
24
 
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
25
  // macOS launchd (LaunchDaemons run as root, available pre-login)
30
26
  export const LAUNCHD_PLIST = ROOT_PREFIX + "/Library/LaunchDaemons/jp.coworker.aidr.scheduled.plist";
31
27
  export const LAUNCHD_WRAPPER = path.join(SYSTEM_BIN_DIR, "aidr-scheduled-wrapper.sh");
32
28
 
33
- // Linux cron fallback (system cron, runs as root)
29
+ // Linux: cron.d entry, runs as root every N hours.
34
30
  export const CRON_FILE = ROOT_PREFIX + "/etc/cron.d/aidr-scheduled";
35
31
 
36
32
  // Shared world-readable system log directory. Both aidr-scheduled and
37
33
  // coworker-sentinel daemons write here so non-root users can `tail` errors
38
34
  // 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.
35
+ // Mode 0755 root:root, files mode 0644. launchd's `StandardOutPath` (macOS)
36
+ // redirects daemon stderr/stdout here; for Linux cron, the entry uses a
37
+ // literal `>> ... 2>&1` redirect.
42
38
  export const SYSTEM_LOG_DIR = ROOT_PREFIX + "/var/log/coworker";
43
39
  export const SCHEDULED_LOG_FILE = path.join(SYSTEM_LOG_DIR, "scheduled.log");
44
40
  export const SENTINEL_LOG_FILE = path.join(SYSTEM_LOG_DIR, "sentinel.log");
@@ -133,11 +129,7 @@ export async function removeSystemLog(logFile, { dryRun = false } = {}) {
133
129
  }
134
130
 
135
131
  export async function isScheduledInstalled() {
136
- return (
137
- await exists(SYSTEMD_TIMER) ||
138
- await exists(LAUNCHD_PLIST) ||
139
- await exists(CRON_FILE)
140
- );
132
+ return (await exists(LAUNCHD_PLIST)) || (await exists(CRON_FILE));
141
133
  }
142
134
 
143
135
  export async function installScheduled(opts) {
@@ -161,15 +153,12 @@ export async function installScheduled(opts) {
161
153
  await writeEnvFile(accessKey, dryRun);
162
154
 
163
155
  // 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.
156
+ // shared logrotate config. Both run before the launchd / cron entry so
157
+ // the daemon's first invocation has a target to append to.
166
158
  await ensureSystemLogDir(SCHEDULED_LOG_FILE, { dryRun });
167
159
  await writeLogrotateConfig({ dryRun });
168
160
 
169
- const platform = process.platform;
170
- if (platform === "linux") {
171
- await installSystemd({ dryRun, noActivate, interval, binaryPath });
172
- } else if (platform === "darwin") {
161
+ if (process.platform === "darwin") {
173
162
  await installLaunchd({ dryRun, noActivate, interval, binaryPath });
174
163
  } else {
175
164
  await installCronDir({ binaryPath, dryRun, interval });
@@ -177,14 +166,14 @@ export async function installScheduled(opts) {
177
166
  }
178
167
 
179
168
  async function writeEnvFile(accessKey, dryRun) {
180
- // Capture only the SUDO_USER (the unix username of whoever ran
181
- // `sudo aidr install ...`) the Rust binary will resolve the full
182
- // identity (Apple ID / git email / username) at scan time using
183
- // `sudo -u <invoker>` so it reads the invoker's plist / gitconfig
184
- // instead of root's empty ones. Pre-resolving here is the wrong
185
- // layer because the Apple ID needs to be re-read in the right user
186
- // context at runtime anyway (root has no MobileMeAccounts.plist).
187
- const invoker = process.env.SUDO_USER || null;
169
+ // Capture the invoker username (SUDO_USER first, then `who am i` /
170
+ // `logname` / `loginctl` / $LOGNAME / $USER fallbacks) the Rust
171
+ // binary resolves the full identity (Apple ID / git email / username)
172
+ // at scan time using `sudo -u <invoker>` so it reads the invoker's
173
+ // plist / gitconfig instead of root's empty ones. Pre-resolving here
174
+ // is the wrong layer because the Apple ID needs to be re-read in the
175
+ // right user context at runtime anyway (root has no MobileMeAccounts).
176
+ const invoker = resolveInvokerUser();
188
177
  let envContent = `AI_SCANNER_ACCESS_KEY=${accessKey}\n`;
189
178
  if (invoker) envContent += `AI_SCANNER_INVOKER_USER=${invoker}\n`;
190
179
  if (dryRun) {
@@ -196,57 +185,6 @@ async function writeEnvFile(accessKey, dryRun) {
196
185
  await fs.writeFile(SYSTEM_ENV_FILE, envContent, { mode: 0o600 });
197
186
  }
198
187
 
199
- async function installSystemd({ binaryPath, dryRun, noActivate, interval = 4 }) {
200
- // StandardOutput=append: requires systemd 240+ (released 2018). Modern
201
- // distros (RHEL 9 / Ubuntu 20.04+ / Debian 11+) all support it. Older
202
- // RHEL 8 (systemd 239) operators must redirect manually or upgrade.
203
- const serviceContent = `[Unit]
204
- Description=AIDR scheduled endpoint-info scan
205
- After=network-online.target
206
- Wants=network-online.target
207
-
208
- [Service]
209
- Type=oneshot
210
- User=root
211
- EnvironmentFile=${SYSTEM_ENV_FILE}
212
- ExecStart=${binaryPath} scan endpoint-info
213
- StandardOutput=append:${SCHEDULED_LOG_FILE}
214
- StandardError=append:${SCHEDULED_LOG_FILE}
215
- `;
216
-
217
- const timerContent = `[Unit]
218
- Description=AIDR scheduled endpoint-info scan timer
219
-
220
- [Timer]
221
- OnBootSec=5min
222
- OnUnitActiveSec=${interval}h
223
- Persistent=true
224
-
225
- [Install]
226
- WantedBy=timers.target
227
- `;
228
-
229
- if (dryRun) {
230
- console.log("[dry-run] Would create:", SYSTEMD_SERVICE, SYSTEMD_TIMER);
231
- return;
232
- }
233
-
234
- await fs.mkdir(path.dirname(SYSTEMD_SERVICE), { recursive: true });
235
- await fs.writeFile(SYSTEMD_SERVICE, serviceContent, { mode: 0o644 });
236
- await fs.writeFile(SYSTEMD_TIMER, timerContent, { mode: 0o644 });
237
-
238
- if (!noActivate) {
239
- try {
240
- await execFileP("systemctl", ["daemon-reload"]);
241
- await execFileP("systemctl", ["enable", "--now", "aidr-scheduled.timer"]);
242
- console.log(`Scheduled scan enabled via system systemd timer (every ${interval}h)`);
243
- } catch (e) {
244
- console.warn("Warning: systemctl failed:", e.message);
245
- console.warn("Timer files were written. Enable manually: sudo systemctl enable --now aidr-scheduled.timer");
246
- }
247
- }
248
- }
249
-
250
188
  export async function installLaunchd({ binaryPath, dryRun, noActivate, interval = 4 }) {
251
189
  // launchd does not support EnvironmentFile, so we use a tiny wrapper that
252
190
  // sources /etc/aidr/aidr.env and execs the binary. Plist itself stays 0644
@@ -317,7 +255,7 @@ async function installCronDir({ binaryPath, dryRun, interval = 4 }) {
317
255
  // /etc/cron.d entries run as the user named in the line. We embed the env
318
256
  // file source via a small inline script. stdout + stderr are appended to
319
257
  // the world-readable log so non-root users can read recent runs without
320
- // sudo (replaces the old `2>/dev/null` which deliberately swallowed errors).
258
+ // sudo.
321
259
  const entry = `0 */${interval} * * * root . ${SYSTEM_ENV_FILE} && ${binaryPath} scan endpoint-info >> ${SCHEDULED_LOG_FILE} 2>&1 # aidr-scheduled
322
260
  `;
323
261
  if (dryRun) {
@@ -327,6 +265,22 @@ async function installCronDir({ binaryPath, dryRun, interval = 4 }) {
327
265
  await fs.mkdir(path.dirname(CRON_FILE), { recursive: true });
328
266
  await fs.writeFile(CRON_FILE, entry, { mode: 0o644 });
329
267
  console.log(`Scheduled scan added to ${CRON_FILE} (every ${interval}h)`);
268
+
269
+ // Warn (don't fail) when no cron daemon is detected. The file is valid;
270
+ // the user just needs to install one (`apt install cron`, `dnf install
271
+ // cronie`, etc.) before the next scheduled hour for it to fire.
272
+ if (!await hasCronDaemon()) {
273
+ console.warn(
274
+ "WARNING: cron daemon not detected. Install one (`apt install cron` / `dnf install cronie` / `apk add dcron`) so /etc/cron.d/aidr-scheduled actually fires."
275
+ );
276
+ }
277
+ }
278
+
279
+ async function hasCronDaemon() {
280
+ for (const candidate of ["/usr/sbin/cron", "/usr/sbin/crond", "/sbin/cron", "/sbin/crond"]) {
281
+ if (await exists(candidate)) return true;
282
+ }
283
+ return false;
330
284
  }
331
285
 
332
286
  export async function uninstallScheduled({ dryRun = false, noActivate = false } = {}) {
@@ -336,16 +290,6 @@ export async function uninstallScheduled({ dryRun = false, noActivate = false }
336
290
  throw new Error("uninstallScheduled requires root privileges");
337
291
  }
338
292
 
339
- if (await exists(SYSTEMD_TIMER)) {
340
- if (!dryRun && !noActivate) {
341
- try { await execFileP("systemctl", ["disable", "--now", "aidr-scheduled.timer"]); } catch { /* ignore */ }
342
- }
343
- for (const f of [SYSTEMD_SERVICE, SYSTEMD_TIMER]) {
344
- if (!dryRun) { try { await fs.unlink(f); } catch { /* ignore */ } }
345
- else { console.log("[dry-run] Would remove:", f); }
346
- }
347
- }
348
-
349
293
  if (await exists(LAUNCHD_PLIST)) {
350
294
  if (!dryRun && !noActivate) {
351
295
  try { await execFileP("launchctl", ["bootout", "system", LAUNCHD_PLIST]); } catch { /* ignore */ }
@@ -363,8 +307,8 @@ export async function uninstallScheduled({ dryRun = false, noActivate = false }
363
307
  else { console.log("[dry-run] Would remove:", CRON_FILE); }
364
308
  }
365
309
 
366
- // Remove the env file last so an aborted uninstall (mid-systemctl) doesn't
367
- // strand a service file that still references a missing env file.
310
+ // Remove the env file last so an aborted uninstall doesn't strand a
311
+ // schedule entry that still references a missing env file.
368
312
  if (await exists(SYSTEM_ENV_FILE)) {
369
313
  if (!dryRun) { try { await fs.unlink(SYSTEM_ENV_FILE); } catch { /* ignore */ } }
370
314
  else { console.log("[dry-run] Would remove:", SYSTEM_ENV_FILE); }
package/src/sentinel.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  import fs from "node:fs/promises";
14
14
  import path from "node:path";
15
15
  import { execFile, spawnSync } from "node:child_process";
16
+ import { resolveInvokerUser } from "./invoker-resolve.mjs";
16
17
  import { promisify } from "node:util";
17
18
  import { fetchSentinel, detectPlatform } from "./binary-fetcher.mjs";
18
19
  import {
@@ -155,7 +156,7 @@ export async function installSentinel(opts = {}) {
155
156
  appended += `\nAI_SCANNER_ACCESS_KEY=${accessKey}\n`;
156
157
  }
157
158
  if (!content.includes("AI_SCANNER_INVOKER_USER")) {
158
- const invoker = process.env.SUDO_USER;
159
+ const invoker = resolveInvokerUser();
159
160
  if (invoker) {
160
161
  appended += `AI_SCANNER_INVOKER_USER=${invoker}\n`;
161
162
  }