@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 +1 -1
- package/src/cli.mjs +30 -1
- package/src/scheduled.mjs +114 -2
- package/src/sentinel.mjs +32 -18
package/package.json
CHANGED
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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>${
|
|
70
|
+
<string>${SENTINEL_LOG_FILE}</string>
|
|
65
71
|
<key>StandardErrorPath</key>
|
|
66
|
-
<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=
|
|
88
|
-
StandardError=
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
}
|