@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 +44 -0
- package/bin/o7 +244 -0
- package/bin/o7-doctor +379 -0
- package/bin/o7-setup +143 -0
- package/bin/o7-setup.js +143 -0
- package/bin/o7.js +244 -0
- package/installer/com.unified-mc.plist.tmpl +35 -0
- package/installer/install.sh +297 -0
- package/installer/lib/antigravity-standalone.sh +301 -0
- package/installer/lib/antigravity.sh +140 -0
- package/installer/lib/auth.sh +286 -0
- package/installer/lib/checks.sh +177 -0
- package/installer/lib/ui.sh +120 -0
- package/installer/lib/validate.sh +87 -0
- package/installer/onboard.mjs +966 -0
- package/installer/templates/agents.md.tmpl +34 -0
- package/installer/templates/heartbeat.md.tmpl +63 -0
- package/installer/templates/openclaw.json.tmpl +116 -0
- package/installer/templates/soul.md.tmpl +45 -0
- package/installer/templates/user.md.tmpl +25 -0
- package/package.json +25 -0
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);
|