@coworker-jp/aidr 0.0.2 → 0.0.3

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.2",
3
+ "version": "0.0.3",
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
@@ -13,6 +13,7 @@ import { verifyKey, maskKey } from "./verify.mjs";
13
13
  import { fetchBinary, fetchOpengrep } from "./binary-fetcher.mjs";
14
14
  import { detectInstalled } from "./detect.mjs";
15
15
  import { installBrowserExtension } from "./browser-extension.mjs";
16
+ import { resolveCallerHome, isRunningUnderSudo, chownToCaller } from "./sudo-user.mjs";
16
17
 
17
18
  // Package identity — read at module load from the bundled package.json.
18
19
  // The PROD publish workflow renames the package to `@coworker-jp/aidr`;
@@ -83,7 +84,7 @@ async function cmdInstall(opts) {
83
84
  });
84
85
  }
85
86
 
86
- const home = process.env.HOME || os.homedir();
87
+ const home = resolveCallerHome();
87
88
  const scope = opts.scope || "user";
88
89
  const base = scope === "user" ? home : process.cwd();
89
90
  validateKey(opts.key);
@@ -308,6 +309,19 @@ async function cmdInstall(opts) {
308
309
  }
309
310
  }
310
311
 
312
+ // Auto-sudo re-exec wrote per-agent files as root into the original user's
313
+ // home / cwd. Hand them back so the user can manage them without sudo on
314
+ // subsequent runs. System-wide writes under /opt + /etc are intentionally
315
+ // left root-owned by skipping anything outside `base`.
316
+ if (isRunningUnderSudo() && !opts.dryRun) {
317
+ for (const r of installResults) {
318
+ const mod = await loadAgent(r.name);
319
+ const agentDir = mod.meta.agentDir;
320
+ if (!agentDir) continue;
321
+ await chownToCaller(path.join(base, agentDir));
322
+ }
323
+ }
324
+
311
325
  if (restartAgents.length > 0 && !opts.dryRun) {
312
326
  const list = restartAgents.join(", ");
313
327
  console.log("");
@@ -320,7 +334,7 @@ async function cmdInstall(opts) {
320
334
  }
321
335
 
322
336
  async function cmdUninstall(opts) {
323
- const home = process.env.HOME || os.homedir();
337
+ const home = resolveCallerHome();
324
338
  const scope = opts.scope || "user";
325
339
  const base = scope === "user" ? home : process.cwd();
326
340
 
@@ -466,7 +480,7 @@ async function cmdUninstall(opts) {
466
480
  }
467
481
 
468
482
  async function cmdDoctor() {
469
- const home = process.env.HOME || os.homedir();
483
+ const home = resolveCallerHome();
470
484
  console.log(`home: ${home}`);
471
485
  console.log(`node: ${process.version}`);
472
486
  console.log(`platform: ${process.platform}/${process.arch}`);
@@ -503,7 +517,7 @@ export async function run(argv) {
503
517
  program
504
518
  .name("aidr")
505
519
  .description("AIDR setup CLI — installs ai-scanner hooks for AI coding agents")
506
- .version("0.0.2");
520
+ .version("0.0.3");
507
521
 
508
522
  program
509
523
  .command("install")
@@ -0,0 +1,111 @@
1
+ // SUDO_USER-aware HOME resolution and ownership helpers.
2
+ //
3
+ // `aidr install --scheduled` auto-escalates via `sudo -E` (cli.mjs).
4
+ // After sudo, the new node process runs as root with HOME reset to /root by
5
+ // the default `set_home` policy. Without correction, every path derived from
6
+ // HOME (e.g. ~/.claude/settings.json under `--scope user`) would land in
7
+ // /root instead of the original user's home, AND every file we write would
8
+ // be root-owned even though the user expects to manage them.
9
+ //
10
+ // We restore the caller's identity via the SUDO_USER / SUDO_UID / SUDO_GID
11
+ // vars sudo always sets, and chown the user-scope writes back to that user
12
+ // when the install completes.
13
+
14
+ import fs from "node:fs";
15
+ import fsp from "node:fs/promises";
16
+ import path from "node:path";
17
+ import os from "node:os";
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ /**
21
+ * Look up `username`'s home directory.
22
+ * 1. /etc/passwd (Linux always; macOS for system users only)
23
+ * 2. macOS dscl (regular macOS users live in Open Directory)
24
+ * Returns null if not found.
25
+ *
26
+ * Test override: AIDR_PASSWD_FILE points at an alternate passwd-format file.
27
+ */
28
+ export function lookupUserHome(username) {
29
+ if (!username) return null;
30
+ const passwdPath = process.env.AIDR_PASSWD_FILE || "/etc/passwd";
31
+ try {
32
+ const passwd = fs.readFileSync(passwdPath, "utf8");
33
+ for (const line of passwd.split("\n")) {
34
+ const f = line.split(":");
35
+ if (f[0] === username && f[5]) return f[5];
36
+ }
37
+ } catch { /* fall through */ }
38
+
39
+ if (process.platform === "darwin" && !process.env.AIDR_PASSWD_FILE) {
40
+ try {
41
+ const out = execFileSync(
42
+ "dscl", [".", "-read", `/Users/${username}`, "NFSHomeDirectory"],
43
+ { encoding: "utf8", timeout: 2000 }
44
+ );
45
+ const m = out.match(/NFSHomeDirectory:\s*(.+)/);
46
+ if (m) return m[1].trim();
47
+ } catch { /* fall through */ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Resolve the calling user's home.
54
+ * - If running as root with SUDO_USER set, use SUDO_USER's home (auto-sudo case).
55
+ * - Otherwise fall back to $HOME / os.homedir().
56
+ *
57
+ * Test override: AIDR_FORCE_HOME bypasses everything.
58
+ */
59
+ export function resolveCallerHome() {
60
+ if (process.env.AIDR_FORCE_HOME) return process.env.AIDR_FORCE_HOME;
61
+ if (isRunningUnderSudo()) {
62
+ const sudoHome = lookupUserHome(process.env.SUDO_USER);
63
+ if (sudoHome) return sudoHome;
64
+ }
65
+ return process.env.HOME || os.homedir();
66
+ }
67
+
68
+ /**
69
+ * True when we're running as root *and* SUDO_USER is set — i.e. the auto-sudo
70
+ * re-exec path. Plain `su` or login-as-root sets neither, and is treated as
71
+ * intentional root usage (no chown back).
72
+ */
73
+ export function isRunningUnderSudo() {
74
+ if (process.platform === "win32") return false;
75
+ if (!process.getuid || process.getuid() !== 0) return false;
76
+ return Boolean(process.env.SUDO_USER);
77
+ }
78
+
79
+ /**
80
+ * SUDO_UID / SUDO_GID parsed as integers, or null when not under sudo.
81
+ */
82
+ export function callerIds() {
83
+ if (!isRunningUnderSudo()) return null;
84
+ const uid = parseInt(process.env.SUDO_UID || "", 10);
85
+ const gid = parseInt(process.env.SUDO_GID || "", 10);
86
+ if (Number.isInteger(uid) && Number.isInteger(gid)) return { uid, gid };
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Recursively chown a path tree to the caller (SUDO_USER) when running under
92
+ * auto-sudo. No-op when not under sudo or on Windows. Errors per-entry are
93
+ * swallowed — worst case the user fixes ownership manually with a single
94
+ * `chown -R`. Symlinks are chowned via lchown (don't follow).
95
+ */
96
+ export async function chownToCaller(targetPath) {
97
+ const ids = callerIds();
98
+ if (!ids) return;
99
+ await chownTree(targetPath, ids.uid, ids.gid);
100
+ }
101
+
102
+ async function chownTree(p, uid, gid) {
103
+ let st;
104
+ try { st = await fsp.lstat(p); } catch { return; }
105
+ try { await fsp.lchown(p, uid, gid); } catch { /* ignore */ }
106
+ if (st.isDirectory()) {
107
+ let entries;
108
+ try { entries = await fsp.readdir(p); } catch { return; }
109
+ await Promise.all(entries.map((e) => chownTree(path.join(p, e), uid, gid)));
110
+ }
111
+ }