@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 +1 -1
- package/src/agents/claude.mjs +1 -1
- package/src/agents/codex.mjs +1 -1
- package/src/agents/cursor.mjs +1 -1
- package/src/agents/gemini.mjs +2 -2
- package/src/agents/windsurf.mjs +1 -1
- package/src/cli.mjs +64 -16
- package/src/fs-utils.mjs +17 -6
- package/src/scheduled.mjs +135 -106
- package/src/templates.mjs +2 -1
package/package.json
CHANGED
package/src/agents/claude.mjs
CHANGED
|
@@ -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);
|
package/src/agents/codex.mjs
CHANGED
|
@@ -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
|
package/src/agents/cursor.mjs
CHANGED
|
@@ -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 = [
|
package/src/agents/gemini.mjs
CHANGED
|
@@ -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 {
|
package/src/agents/windsurf.mjs
CHANGED
|
@@ -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
|
|
131
|
-
// `ai-scanner scan endpoint-info` periodically against an
|
|
132
|
-
// binary at
|
|
133
|
-
// binary fetch loop drops a copy there
|
|
134
|
-
// agents (claude / cursor / ...) on
|
|
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
|
-
//
|
|
256
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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(
|
|
45
|
+
export async function isScheduledInstalled() {
|
|
22
46
|
return (
|
|
23
|
-
await exists(
|
|
24
|
-
await exists(
|
|
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 {
|
|
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
|
-
|
|
34
|
-
|
|
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({
|
|
75
|
+
await installSystemd({ dryRun, noActivate, interval, binaryPath });
|
|
44
76
|
} else if (platform === "darwin") {
|
|
45
|
-
await installLaunchd({
|
|
77
|
+
await installLaunchd({ dryRun, noActivate, interval, binaryPath });
|
|
46
78
|
} else {
|
|
47
|
-
await
|
|
79
|
+
await installCronDir({ binaryPath, dryRun, interval });
|
|
48
80
|
}
|
|
49
81
|
}
|
|
50
82
|
|
|
51
|
-
async function
|
|
52
|
-
const
|
|
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
|
-
|
|
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:",
|
|
119
|
+
console.log("[dry-run] Would create:", SYSTEMD_SERVICE, SYSTEMD_TIMER);
|
|
79
120
|
return;
|
|
80
121
|
}
|
|
81
122
|
|
|
82
|
-
await fs.mkdir(
|
|
83
|
-
await fs.writeFile(
|
|
84
|
-
await fs.writeFile(
|
|
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", ["
|
|
90
|
-
await execFileP("systemctl", ["
|
|
91
|
-
console.log(`Scheduled scan enabled via systemd
|
|
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
|
|
94
|
-
console.warn("Timer files were written. Enable manually: systemctl
|
|
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({
|
|
100
|
-
|
|
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>${
|
|
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:",
|
|
174
|
+
console.log("[dry-run] Would create:", LAUNCHD_WRAPPER, LAUNCHD_PLIST);
|
|
129
175
|
return;
|
|
130
176
|
}
|
|
131
177
|
|
|
132
|
-
await fs.mkdir(
|
|
133
|
-
await fs.
|
|
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
|
-
|
|
138
|
-
|
|
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(
|
|
189
|
+
console.warn(`Plist written. Enable manually: sudo launchctl bootstrap system ${LAUNCHD_PLIST}`);
|
|
143
190
|
}
|
|
144
191
|
}
|
|
145
192
|
}
|
|
146
193
|
|
|
147
|
-
async function
|
|
148
|
-
|
|
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
|
|
200
|
+
console.log("[dry-run] Would create:", CRON_FILE);
|
|
152
201
|
return;
|
|
153
202
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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({
|
|
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 (
|
|
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", ["
|
|
217
|
+
try { await execFileP("systemctl", ["disable", "--now", "aidr-scheduled.timer"]); } catch { /* ignore */ }
|
|
180
218
|
}
|
|
181
|
-
for (const f of [
|
|
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(
|
|
225
|
+
if (await exists(LAUNCHD_PLIST)) {
|
|
188
226
|
if (!dryRun && !noActivate) {
|
|
189
|
-
try {
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|