@nicotinetool/o7-cli 1.1.0

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/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @nicotinetool/o7-cli
2
+
3
+ One command to set up Optimum7's AI assistant platform (OpenClaw + Mission Control).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npx @nicotinetool/o7-cli o7-setup
9
+ ```
10
+
11
+ That's it. The wizard handles everything: dependencies, AI model auth, gateway, dashboard, and device registration.
12
+
13
+ ## Usage
14
+
15
+ After setup, install globally for convenience:
16
+
17
+ ```bash
18
+ npm i -g @nicotinetool/o7-cli
19
+ ```
20
+
21
+ Then:
22
+
23
+ ```bash
24
+ o7 start # Start Gateway + Mission Control
25
+ o7 status # Show running status
26
+ o7 stop # Stop both
27
+ o7 restart # Restart both
28
+ o7 update # Pull latest, rebuild, restart
29
+ o7 doctor # Config-level health check + auto-fix
30
+ ```
31
+
32
+ ## What Gets Installed
33
+
34
+ - **OpenClaw Gateway** — AI assistant runtime (runs as macOS service)
35
+ - **Mission Control** — Dashboard UI at http://localhost:3000
36
+ - **AI Models** — Guided setup for Claude, ChatGPT, Kimi, Gemini
37
+ - **Antigravity** — Self-healing engine (auto-fixes issues every 30 min)
38
+ - **Device Registration** — Shows up in the O7 OS admin dashboard
39
+
40
+ ## Environment Variables
41
+
42
+ | Variable | Default | Description |
43
+ |----------|---------|-------------|
44
+ | `O7_MC_DIR` | `~/Projects/unified-mc` | Path to Mission Control |
package/bin/o7 ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ import { execSync, spawn, execFileSync } from 'child_process';
3
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import { homedir, hostname, platform, arch, release } from 'os';
6
+
7
+ const VERSION = '1.1.0';
8
+ const O7_ADMIN_URL = 'https://o7-os-admin-production.up.railway.app';
9
+ const MC_DIR = process.env.O7_MC_DIR || join(homedir(), 'Projects/unified-mc');
10
+ const STATE_DIR = join(homedir(), '.openclaw');
11
+ const O7OS_DIR = join(STATE_DIR, 'o7os');
12
+ const PID_FILE = join(STATE_DIR, '.mc.pid');
13
+ const DEVICE_FILE = join(O7OS_DIR, 'device.json');
14
+
15
+ mkdirSync(STATE_DIR, { recursive: true });
16
+
17
+ const cmd = process.argv[2];
18
+
19
+ // Handle flags before command dispatch
20
+ if (cmd === '--help' || cmd === '-h') { showHelp(); process.exit(0); }
21
+ if (cmd === '--version' || cmd === '-v') { console.log(VERSION); process.exit(0); }
22
+
23
+ // ── Helpers ──────────────────────────────────────────────────────────────────
24
+
25
+ function run(c, opts = {}) {
26
+ try {
27
+ return execSync(c, { stdio: 'inherit', timeout: 30000, ...opts });
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function runSilent(c, opts = {}) {
34
+ try {
35
+ return execSync(c, { encoding: 'utf8', timeout: 15000, ...opts }).trim();
36
+ } catch {
37
+ return '';
38
+ }
39
+ }
40
+
41
+ function getMcPid() {
42
+ if (!existsSync(PID_FILE)) return null;
43
+ const raw = readFileSync(PID_FILE, 'utf8').trim();
44
+ const pid = Number(raw);
45
+ if (!pid || isNaN(pid)) {
46
+ unlinkSync(PID_FILE);
47
+ return null;
48
+ }
49
+ try {
50
+ process.kill(pid, 0);
51
+ return pid;
52
+ } catch {
53
+ unlinkSync(PID_FILE);
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function getDeviceInfo() {
59
+ try {
60
+ if (existsSync(DEVICE_FILE)) return JSON.parse(readFileSync(DEVICE_FILE, 'utf8'));
61
+ } catch {}
62
+ return null;
63
+ }
64
+
65
+ // ── Commands ─────────────────────────────────────────────────────────────────
66
+
67
+ function status() {
68
+ console.log('\x1b[1m--- Gateway ---\x1b[0m');
69
+ run('openclaw gateway status');
70
+
71
+ console.log('\n\x1b[1m--- Mission Control ---\x1b[0m');
72
+ const pid = getMcPid();
73
+ if (pid) {
74
+ console.log(`Running (PID ${pid}) at http://localhost:3005`);
75
+ } else {
76
+ console.log('Not running');
77
+ }
78
+
79
+ console.log('\n\x1b[1m--- Device ---\x1b[0m');
80
+ const device = getDeviceInfo();
81
+ if (device) {
82
+ console.log(`ID: ${device.device_id || 'unregistered'}`);
83
+ console.log(`Name: ${device.device_name || hostname()}`);
84
+ console.log(`OS: ${device.os || platform()}`);
85
+ console.log(`Registered: ${device.installed_at || 'unknown'}`);
86
+ } else {
87
+ console.log('Not registered with O7 OS');
88
+ }
89
+ }
90
+
91
+ function start() {
92
+ console.log('Starting OpenClaw gateway...');
93
+ run('openclaw gateway start');
94
+
95
+ const existingPid = getMcPid();
96
+ if (existingPid) {
97
+ console.log(`Mission Control already running (PID ${existingPid})`);
98
+ return;
99
+ }
100
+
101
+ if (!existsSync(MC_DIR)) {
102
+ console.error(`Mission Control directory not found: ${MC_DIR}`);
103
+ console.error('Set O7_MC_DIR env var or install first with: npx @erenes1667/o7-cli o7-setup');
104
+ process.exit(1);
105
+ }
106
+
107
+ console.log('Starting Mission Control...');
108
+ const envPath = `${dirname(process.execPath)}:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ''}`;
109
+ const mc = spawn('pnpm', ['start'], {
110
+ cwd: MC_DIR,
111
+ detached: true,
112
+ stdio: 'ignore',
113
+ env: { ...process.env, PATH: envPath },
114
+ });
115
+ if (!mc.pid) {
116
+ console.error('Failed to start Mission Control. Is pnpm installed?');
117
+ process.exit(1);
118
+ }
119
+ mc.unref();
120
+ writeFileSync(PID_FILE, String(mc.pid));
121
+ console.log(`\n\x1b[32m✓ Mission Control started\x1b[0m (PID ${mc.pid})`);
122
+ console.log(' http://localhost:3005');
123
+
124
+ // Phone home (best-effort, non-blocking)
125
+ phoneHome().catch(() => {});
126
+ }
127
+
128
+ function stop() {
129
+ const pid = getMcPid();
130
+ if (pid) {
131
+ try {
132
+ // Kill the process group to catch child processes
133
+ process.kill(-pid, 'SIGTERM');
134
+ } catch {
135
+ try { process.kill(pid, 'SIGTERM'); } catch {}
136
+ }
137
+ console.log('Mission Control stopped');
138
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
139
+ } else {
140
+ console.log('Mission Control was not running');
141
+ }
142
+ run('openclaw gateway stop');
143
+ }
144
+
145
+ function restart() {
146
+ stop();
147
+ setTimeout(() => start(), 1000);
148
+ }
149
+
150
+ function update() {
151
+ if (!existsSync(MC_DIR)) {
152
+ console.error(`Mission Control directory not found: ${MC_DIR}`);
153
+ process.exit(1);
154
+ }
155
+ console.log('Pulling latest changes...');
156
+ run('git pull', { cwd: MC_DIR });
157
+ console.log('Installing dependencies...');
158
+ run('pnpm install', { cwd: MC_DIR });
159
+ console.log('Rebuilding...');
160
+ run('pnpm build', { cwd: MC_DIR });
161
+ restart();
162
+ }
163
+
164
+ // ── Phone Home ───────────────────────────────────────────────────────────────
165
+
166
+ async function phoneHome() {
167
+ const device = getDeviceInfo();
168
+ if (!device || !device.device_id || !device.api_key) return;
169
+
170
+ const url = `${O7_ADMIN_URL}/api/devices/${device.device_id}/heartbeat`;
171
+ const ocVer = runSilent('openclaw --version');
172
+ const body = JSON.stringify({
173
+ openclaw_version: ocVer || 'unknown',
174
+ o7_cli_version: VERSION,
175
+ });
176
+
177
+ try {
178
+ const { default: https } = await import('https');
179
+ const reqUrl = new URL(url);
180
+ const req = https.request(reqUrl, {
181
+ method: 'POST',
182
+ headers: {
183
+ 'Content-Type': 'application/json',
184
+ 'Authorization': `Bearer ${device.api_key}`,
185
+ 'Content-Length': Buffer.byteLength(body),
186
+ },
187
+ timeout: 5000,
188
+ });
189
+ req.on('error', () => {});
190
+ req.write(body);
191
+ req.end();
192
+ } catch {}
193
+ }
194
+
195
+ // ── Help ─────────────────────────────────────────────────────────────────────
196
+
197
+ function showHelp() {
198
+ console.log(`
199
+ \x1b[1mO7 - Optimum7 OpenClaw Manager\x1b[0m v${VERSION}
200
+
201
+ Usage: o7 <command>
202
+
203
+ Commands:
204
+ start Start Gateway + Mission Control
205
+ stop Stop both
206
+ restart Restart both
207
+ update Pull latest, install deps, rebuild, restart
208
+ status Show running status
209
+ doctor Config-level health check + auto-fix
210
+
211
+ Options:
212
+ --help, -h Show this help
213
+ --version, -v Show version
214
+
215
+ Environment:
216
+ O7_MC_DIR Path to Mission Control (default: ~/Projects/unified-mc)
217
+ `);
218
+ }
219
+
220
+ // ── Dispatch ─────────────────────────────────────────────────────────────────
221
+
222
+ function doctor() {
223
+ const doctorScript = join(dirname(new URL(import.meta.url).pathname), 'o7-doctor');
224
+ try {
225
+ execSync(`node "${doctorScript}" ${process.argv.slice(3).join(' ')}`, { stdio: 'inherit', timeout: 60000 });
226
+ } catch (err) {
227
+ // doctor exits 1 if issues found, that's expected
228
+ if (err.status > 1) {
229
+ console.error('Doctor script failed to run.');
230
+ process.exit(1);
231
+ }
232
+ }
233
+ }
234
+
235
+ const commands = { status, start, stop, restart, update, doctor };
236
+
237
+ if (!cmd) { showHelp(); process.exit(0); }
238
+ if (!commands[cmd]) {
239
+ console.error(`Unknown command: ${cmd}`);
240
+ showHelp();
241
+ process.exit(1);
242
+ }
243
+
244
+ commands[cmd]();
package/bin/o7-doctor ADDED
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * o7 doctor — Config-level self-healing for OpenClaw
5
+ *
6
+ * Validates and auto-fixes:
7
+ * - openclaw.json schema (valid JSON, required keys)
8
+ * - Auth profiles (tokens exist, not expired, match order list)
9
+ * - Model aliases (resolve correctly)
10
+ * - Cron jobs (valid model refs, delivery channels set)
11
+ * - Gateway process + HTTP health
12
+ * - Port conflicts
13
+ * - Stale PID files
14
+ *
15
+ * Run manually: o7 doctor
16
+ * Runs automatically via launchd every 30 min
17
+ */
18
+
19
+ import { execSync } from 'child_process';
20
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
21
+ import { join } from 'path';
22
+ import { homedir } from 'os';
23
+
24
+ const HOME = homedir();
25
+ const OC_DIR = join(HOME, '.openclaw');
26
+ const CONFIG_PATH = join(OC_DIR, 'openclaw.json');
27
+ const AUTH_PATH = join(OC_DIR, 'auth-profiles.json');
28
+ const CRON_PATH = join(OC_DIR, 'cron', 'jobs.json');
29
+ const LOG_DIR = join(OC_DIR, 'o7os', 'antigravity');
30
+ const LOG_PATH = join(LOG_DIR, 'doctor.jsonl');
31
+
32
+ mkdirSync(LOG_DIR, { recursive: true });
33
+
34
+ const VERBOSE = process.argv.includes('--verbose') || process.argv.includes('-v');
35
+ const DRY_RUN = process.argv.includes('--dry-run');
36
+ const QUIET = process.argv.includes('--quiet') || process.argv.includes('-q');
37
+
38
+ // Known model aliases (from openclaw config)
39
+ const KNOWN_ALIASES = {
40
+ 'k2.5-cloud': 'ollama/kimi-k2.5:cloud',
41
+ 'sonnet': 'anthropic/claude-sonnet-4-6',
42
+ 'opus': 'anthropic/claude-opus-4-6',
43
+ 'qwen3-coder': 'ollama/qwen3-coder:480b-cloud',
44
+ 'qwen3.5': 'ollama/qwen3.5:cloud',
45
+ };
46
+
47
+ const results = [];
48
+ let fixCount = 0;
49
+
50
+ function ok(check, detail) {
51
+ results.push({ check, status: 'ok', detail });
52
+ if (VERBOSE && !QUIET) console.log(` ✅ ${check}${detail ? ': ' + detail : ''}`);
53
+ }
54
+
55
+ function warn(check, detail) {
56
+ results.push({ check, status: 'warn', detail });
57
+ if (!QUIET) console.log(` ⚠️ ${check}: ${detail}`);
58
+ }
59
+
60
+ function fail(check, detail) {
61
+ results.push({ check, status: 'fail', detail });
62
+ if (!QUIET) console.log(` ❌ ${check}: ${detail}`);
63
+ }
64
+
65
+ function fixed(check, detail) {
66
+ fixCount++;
67
+ results.push({ check, status: 'fixed', detail });
68
+ if (!QUIET) console.log(` 🔧 ${check}: ${detail}`);
69
+ }
70
+
71
+ function readJson(p) {
72
+ try { return JSON.parse(readFileSync(p, 'utf8')); }
73
+ catch (e) { return null; }
74
+ }
75
+
76
+ function writeJson(p, data) {
77
+ if (DRY_RUN) return;
78
+ writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
79
+ }
80
+
81
+ // ── Check 1: openclaw.json valid & has required keys ─────────────────────────
82
+
83
+ function checkConfig() {
84
+ if (!existsSync(CONFIG_PATH)) {
85
+ fail('config_exists', 'openclaw.json not found');
86
+ return null;
87
+ }
88
+
89
+ let config;
90
+ try {
91
+ config = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
92
+ } catch (e) {
93
+ fail('config_json', `Invalid JSON: ${e.message.slice(0, 100)}`);
94
+
95
+ // Try to recover from trailing comma or common JSON issues
96
+ try {
97
+ const raw = readFileSync(CONFIG_PATH, 'utf8');
98
+ // Remove trailing commas before } or ]
99
+ const cleaned = raw.replace(/,\s*([\]}])/g, '$1');
100
+ config = JSON.parse(cleaned);
101
+ writeJson(CONFIG_PATH, config);
102
+ fixed('config_json', 'Fixed invalid JSON (trailing commas)');
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ // Required top-level keys
109
+ const required = ['auth', 'models', 'agents', 'gateway'];
110
+ const missing = required.filter(k => !config[k]);
111
+ if (missing.length > 0) {
112
+ warn('config_keys', `Missing top-level keys: ${missing.join(', ')}`);
113
+ } else {
114
+ ok('config_schema', 'All required keys present');
115
+ }
116
+
117
+ return config;
118
+ }
119
+
120
+ // ── Check 2: Auth profiles consistency ───────────────────────────────────────
121
+
122
+ function checkAuth(config) {
123
+ if (!config) return;
124
+
125
+ const authProfiles = readJson(AUTH_PATH);
126
+ if (!authProfiles) {
127
+ fail('auth_profiles', 'auth-profiles.json not found or invalid');
128
+ return;
129
+ }
130
+
131
+ const configProfiles = config.auth?.profiles || {};
132
+ const configOrder = config.auth?.order || {};
133
+
134
+ // Check each provider's order list
135
+ for (const [provider, order] of Object.entries(configOrder)) {
136
+ if (!Array.isArray(order)) continue;
137
+
138
+ for (const profileId of order) {
139
+ // Must exist in openclaw.json auth.profiles
140
+ if (!configProfiles[profileId]) {
141
+ warn('auth_order_ref', `${profileId} in auth.order.${provider} but not in auth.profiles`);
142
+ }
143
+
144
+ // Must exist in auth-profiles.json with a token
145
+ if (!authProfiles[profileId]) {
146
+ warn('auth_token_missing', `${profileId} in order but no token in auth-profiles.json`);
147
+ } else {
148
+ const profile = authProfiles[profileId];
149
+ const token = profile.token || profile.apiKey || profile.api_key;
150
+ if (!token) {
151
+ warn('auth_token_empty', `${profileId} has no token/apiKey`);
152
+ } else {
153
+ ok('auth_token', `${profileId} has token (${token.slice(0, 12)}...)`);
154
+ }
155
+
156
+ // Check for high error counts
157
+ if ((profile.consecutiveErrors || 0) >= 3) {
158
+ warn('auth_errors', `${profileId} has ${profile.consecutiveErrors} consecutive errors — may be dead`);
159
+
160
+ // Auto-reset if > 10 consecutive errors (probably stale state)
161
+ if (profile.consecutiveErrors >= 10) {
162
+ authProfiles[profileId].consecutiveErrors = 0;
163
+ authProfiles[profileId].errorCount = 0;
164
+ writeJson(AUTH_PATH, authProfiles);
165
+ fixed('auth_errors', `Reset error counters for ${profileId} (was ${profile.consecutiveErrors})`);
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ // Check for orphaned profiles (in auth-profiles.json but not in any order)
173
+ const allOrdered = Object.values(configOrder).flat();
174
+ for (const key of Object.keys(authProfiles)) {
175
+ if (key === 'profiles') continue; // nested profiles obj
176
+ if (!allOrdered.includes(key) && !key.includes(':')) continue; // skip non-provider keys
177
+ if (!allOrdered.includes(key) && configProfiles[key]) {
178
+ warn('auth_orphan', `${key} in auth-profiles.json but not in any auth.order list`);
179
+ }
180
+ }
181
+ }
182
+
183
+ // ── Check 3: Cron jobs ───────────────────────────────────────────────────────
184
+
185
+ function checkCrons(config) {
186
+ if (!existsSync(CRON_PATH)) {
187
+ ok('crons', 'No cron jobs configured');
188
+ return;
189
+ }
190
+
191
+ const jobs = readJson(CRON_PATH);
192
+ if (!jobs || !Array.isArray(jobs)) {
193
+ fail('crons_json', 'cron/jobs.json invalid');
194
+ return;
195
+ }
196
+
197
+ const channels = Object.keys(config?.channels || {});
198
+ const multiChannel = channels.length > 1;
199
+ let cronFixes = false;
200
+
201
+ for (const job of jobs) {
202
+ if (!job || typeof job !== 'object') continue;
203
+ if (!job.enabled) continue;
204
+
205
+ const name = job.name || job.id?.slice(0, 8) || 'unknown';
206
+
207
+ // Check model reference
208
+ const model = job.payload?.model;
209
+ if (model) {
210
+ // Check if it's a known alias
211
+ const isAlias = KNOWN_ALIASES[model];
212
+ const isFullRef = model.includes('/');
213
+
214
+ if (!isAlias && !isFullRef) {
215
+ // Might be an invalid model ref like "kimi-k2.5:cloud"
216
+ // Try to find a matching alias
217
+ const matchingAlias = Object.entries(KNOWN_ALIASES).find(([_, v]) => v.includes(model));
218
+ if (matchingAlias) {
219
+ warn('cron_model', `"${name}" uses "${model}" — did you mean "${matchingAlias[0]}"?`);
220
+ } else {
221
+ warn('cron_model', `"${name}" uses unknown model "${model}"`);
222
+ }
223
+ } else {
224
+ ok('cron_model', `"${name}" → ${model}`);
225
+ }
226
+ }
227
+
228
+ // Check delivery channel for multi-channel setups
229
+ if (multiChannel && job.delivery?.mode === 'announce') {
230
+ if (!job.delivery.channel) {
231
+ warn('cron_channel', `"${name}" has announce delivery but no channel set (${channels.length} channels configured)`);
232
+
233
+ // Auto-fix: set to telegram if it's a known channel
234
+ if (channels.includes('telegram')) {
235
+ job.delivery.channel = 'telegram';
236
+ cronFixes = true;
237
+ fixed('cron_channel', `Set delivery.channel="telegram" for "${name}"`);
238
+ }
239
+ }
240
+ }
241
+
242
+ // Check for high consecutive errors
243
+ if ((job.state?.consecutiveErrors || 0) >= 5) {
244
+ warn('cron_errors', `"${name}" has ${job.state.consecutiveErrors} consecutive errors`);
245
+ }
246
+ }
247
+
248
+ if (cronFixes) {
249
+ writeJson(CRON_PATH, jobs);
250
+ }
251
+ }
252
+
253
+ // ── Check 4: Gateway process ─────────────────────────────────────────────────
254
+
255
+ function checkGateway() {
256
+ // Process running?
257
+ let running = false;
258
+ try {
259
+ execSync('pgrep -f "openclaw.*gateway" > /dev/null 2>&1', { timeout: 5000 });
260
+ running = true;
261
+ ok('gateway_process', 'Running');
262
+ } catch {
263
+ fail('gateway_process', 'Not running');
264
+ // Auto-restart
265
+ try {
266
+ if (!DRY_RUN) execSync('openclaw gateway start', { timeout: 20000 });
267
+ fixed('gateway_process', 'Auto-restarted gateway');
268
+ running = true;
269
+ } catch (e) {
270
+ fail('gateway_restart', `Could not restart: ${e.message?.slice(0, 80)}`);
271
+ }
272
+ }
273
+
274
+ // HTTP reachable?
275
+ if (running) {
276
+ try {
277
+ const code = execSync('curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://127.0.0.1:18789/', {
278
+ encoding: 'utf8', timeout: 10000,
279
+ }).trim();
280
+ if (code === '200' || code === '101') {
281
+ ok('gateway_http', `HTTP responding (${code})`);
282
+ } else {
283
+ warn('gateway_http', `Unexpected status: ${code}`);
284
+ }
285
+ } catch {
286
+ warn('gateway_http', 'HTTP not responding (may still be starting)');
287
+ }
288
+ }
289
+ }
290
+
291
+ // ── Check 5: Port conflicts ─────────────────────────────────────────────────
292
+
293
+ function checkPorts() {
294
+ const ports = [18789, 3000, 3005]; // gateway, MC, MC alt
295
+ for (const port of ports) {
296
+ try {
297
+ const result = execSync(`lsof -i :${port} -t 2>/dev/null | head -3`, { encoding: 'utf8', timeout: 5000 }).trim();
298
+ const pids = result.split('\n').filter(Boolean);
299
+ if (pids.length > 1) {
300
+ warn('port_conflict', `Port ${port} has ${pids.length} processes: ${pids.join(', ')}`);
301
+ }
302
+ } catch {}
303
+ }
304
+ }
305
+
306
+ // ── Check 6: Stale PID files ─────────────────────────────────────────────────
307
+
308
+ function checkPidFiles() {
309
+ const pidFile = join(OC_DIR, '.mc.pid');
310
+ if (existsSync(pidFile)) {
311
+ try {
312
+ const raw = readFileSync(pidFile, 'utf8').trim();
313
+ const pid = Number(raw);
314
+ if (isNaN(pid) || pid <= 0) {
315
+ if (!DRY_RUN) try { unlinkSync(pidFile); } catch {}
316
+ fixed('pid_stale', 'Removed invalid PID file');
317
+ } else {
318
+ try {
319
+ process.kill(pid, 0);
320
+ ok('pid_file', `MC PID ${pid} is alive`);
321
+ } catch {
322
+ if (!DRY_RUN) try { unlinkSync(pidFile); } catch {}
323
+ fixed('pid_stale', `Removed stale PID file (process ${pid} dead)`);
324
+ }
325
+ }
326
+ } catch {}
327
+ }
328
+ }
329
+
330
+ // ── Check 7: Disk space ──────────────────────────────────────────────────────
331
+
332
+ function checkDisk() {
333
+ try {
334
+ const df = execSync('df -h ~ | tail -1', { encoding: 'utf8', timeout: 5000 });
335
+ const pct = parseInt(df.match(/(\d+)%/)?.[1] || '0');
336
+ if (pct >= 95) fail('disk_space', `${pct}% used — critically low`);
337
+ else if (pct >= 90) warn('disk_space', `${pct}% used — getting tight`);
338
+ else ok('disk_space', `${pct}% used`);
339
+ } catch {}
340
+ }
341
+
342
+ // ── Main ─────────────────────────────────────────────────────────────────────
343
+
344
+ if (!QUIET) console.log('\n\x1b[1mO7 Doctor\x1b[0m — Config-level health check\n');
345
+
346
+ const config = checkConfig();
347
+ checkAuth(config);
348
+ checkCrons(config);
349
+ checkGateway();
350
+ checkPorts();
351
+ checkPidFiles();
352
+ checkDisk();
353
+
354
+ // Summary
355
+ const fails = results.filter(r => r.status === 'fail');
356
+ const warns = results.filter(r => r.status === 'warn');
357
+ const fixes = results.filter(r => r.status === 'fixed');
358
+ const oks = results.filter(r => r.status === 'ok');
359
+
360
+ if (!QUIET) {
361
+ console.log('');
362
+ if (fails.length === 0 && warns.length === 0) {
363
+ console.log(`\x1b[32m✅ All clear.\x1b[0m ${oks.length} checks passed${fixCount > 0 ? `, ${fixCount} auto-fixed` : ''}.`);
364
+ } else {
365
+ console.log(`${fails.length} errors, ${warns.length} warnings, ${fixCount} auto-fixed, ${oks.length} ok.`);
366
+ }
367
+ console.log('');
368
+ }
369
+
370
+ // Log results
371
+ const logEntry = {
372
+ timestamp: new Date().toISOString(),
373
+ summary: { ok: oks.length, warn: warns.length, fail: fails.length, fixed: fixes.length },
374
+ issues: [...fails, ...warns, ...fixes],
375
+ };
376
+ try { writeFileSync(LOG_PATH, readFileSync(LOG_PATH, 'utf8').trim() + '\n' + JSON.stringify(logEntry), 'utf8'); }
377
+ catch { writeFileSync(LOG_PATH, JSON.stringify(logEntry) + '\n'); }
378
+
379
+ process.exit(fails.length > 0 ? 1 : 0);