@yancyyu/openhermit 1.6.19 → 1.6.23
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/bin/hermit.mjs +153 -67
- package/package.json +1 -1
- package/src/main/server.ts +9 -0
package/bin/hermit.mjs
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { spawn, execSync } from 'node:child_process';
|
|
18
|
-
import crypto from 'node:crypto';
|
|
19
18
|
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
19
|
import { createRequire } from 'node:module';
|
|
20
|
+
import net from 'node:net';
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -350,10 +350,6 @@ Please install dependencies first:
|
|
|
350
350
|
// cc-connect sidecar
|
|
351
351
|
// ---------------------------------------------------------------------------
|
|
352
352
|
|
|
353
|
-
function randomToken() {
|
|
354
|
-
return crypto.randomBytes(16).toString('hex');
|
|
355
|
-
}
|
|
356
|
-
|
|
357
353
|
function escapeTomlPath(value) {
|
|
358
354
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
359
355
|
}
|
|
@@ -384,51 +380,6 @@ function isManagedBootstrapBlock(block) {
|
|
|
384
380
|
);
|
|
385
381
|
}
|
|
386
382
|
|
|
387
|
-
function buildStarterConfigToml(managementToken, bridgeToken) {
|
|
388
|
-
return `# cc-connect configuration
|
|
389
|
-
# Docs: https://github.com/chenhg5/cc-connect
|
|
390
|
-
|
|
391
|
-
data_dir = "${escapeTomlPath(path.join(hermitHome, 'cc-connect', 'data'))}"
|
|
392
|
-
language = "zh"
|
|
393
|
-
|
|
394
|
-
[management]
|
|
395
|
-
enabled = true
|
|
396
|
-
host = "127.0.0.1"
|
|
397
|
-
port = 9820
|
|
398
|
-
token = "${managementToken}"
|
|
399
|
-
|
|
400
|
-
[bridge]
|
|
401
|
-
enabled = true
|
|
402
|
-
host = "127.0.0.1"
|
|
403
|
-
port = 9810
|
|
404
|
-
token = "${bridgeToken}"
|
|
405
|
-
path = "/bridge/ws"
|
|
406
|
-
|
|
407
|
-
[log]
|
|
408
|
-
level = "info"
|
|
409
|
-
|
|
410
|
-
[[projects]]
|
|
411
|
-
name = "my-project"
|
|
412
|
-
|
|
413
|
-
[projects.agent]
|
|
414
|
-
type = "claudecode" # "claudecode", "codex", "cursor", "gemini", "qoder", "opencode", or "iflow"
|
|
415
|
-
|
|
416
|
-
[projects.agent.options]
|
|
417
|
-
work_dir = "/path/to/your/project"
|
|
418
|
-
mode = "default"
|
|
419
|
-
# model = "claude-sonnet-4-20250514"
|
|
420
|
-
|
|
421
|
-
# --- Choose at least one platform below ---
|
|
422
|
-
|
|
423
|
-
[[projects.platforms]]
|
|
424
|
-
type = "feishu"
|
|
425
|
-
|
|
426
|
-
[projects.platforms.options]
|
|
427
|
-
app_id = "your-feishu-app-id"
|
|
428
|
-
app_secret = "your-feishu-app-secret"
|
|
429
|
-
`;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
383
|
function isStarterProjectConfig(raw) {
|
|
433
384
|
const block = findProjectBlock(raw, starterProjectName);
|
|
434
385
|
if (!block) return false;
|
|
@@ -442,27 +393,61 @@ function isStarterProjectConfig(raw) {
|
|
|
442
393
|
);
|
|
443
394
|
}
|
|
444
395
|
|
|
445
|
-
function
|
|
396
|
+
function configRequiresClaudeCode(raw) {
|
|
397
|
+
return /type\s*=\s*"claudecode"/.test(raw);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function hasProjectEntries(raw) {
|
|
446
401
|
const projectPattern = /\[\[projects\]\]\nname\s*=\s*"([^"]+)"[\s\S]*?(?=\n\[\[projects\]\]|\s*$)/g;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
402
|
+
return [...raw.matchAll(projectPattern)].some((match) => !isManagedBootstrapBlock(match[0]));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function commandExists(command) {
|
|
406
|
+
try {
|
|
407
|
+
execSync(`${command} --version`, { stdio: 'ignore', shell: true });
|
|
408
|
+
return true;
|
|
409
|
+
} catch {
|
|
410
|
+
return false;
|
|
451
411
|
}
|
|
452
|
-
return false;
|
|
453
412
|
}
|
|
454
413
|
|
|
455
|
-
function
|
|
414
|
+
function ensureClaudeCodeCliIfNeeded(raw) {
|
|
415
|
+
if (!configRequiresClaudeCode(raw) || commandExists('claude')) return;
|
|
416
|
+
|
|
417
|
+
console.log('[openHermit] Claude Code CLI not found; installing @anthropic-ai/claude-code...');
|
|
418
|
+
try {
|
|
419
|
+
execSync('npm install -g @anthropic-ai/claude-code@latest --prefer-online', {
|
|
420
|
+
stdio: 'inherit',
|
|
421
|
+
shell: true,
|
|
422
|
+
});
|
|
423
|
+
} catch (err) {
|
|
424
|
+
console.error('[openHermit] Failed to install Claude Code CLI automatically.');
|
|
425
|
+
console.error('[openHermit] Please install it manually: npm install -g @anthropic-ai/claude-code@latest');
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!commandExists('claude')) {
|
|
430
|
+
throw new Error('Claude Code CLI was installed but `claude` is still not available in PATH');
|
|
431
|
+
}
|
|
432
|
+
console.log('[openHermit] Claude Code CLI installed.');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function readCcConnectConfigState() {
|
|
456
436
|
mkdirSync(path.dirname(ccConnectConfigPath), { recursive: true });
|
|
457
437
|
if (!existsSync(ccConnectConfigPath)) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
438
|
+
return {
|
|
439
|
+
configExists: false,
|
|
440
|
+
managementToken: process.env.CC_CONNECT_TOKEN || process.env.CC_CONNECT_MANAGEMENT_TOKEN || '',
|
|
441
|
+
bridgeToken: process.env.CC_CONNECT_BRIDGE_TOKEN || process.env.CC_CONNECT_TOKEN || '',
|
|
442
|
+
hasRunnableProjects: false,
|
|
443
|
+
isStarterConfig: false,
|
|
444
|
+
};
|
|
461
445
|
}
|
|
462
446
|
|
|
463
447
|
const raw = readFileSync(ccConnectConfigPath, 'utf-8');
|
|
464
448
|
|
|
465
449
|
return {
|
|
450
|
+
configExists: true,
|
|
466
451
|
managementToken:
|
|
467
452
|
process.env.CC_CONNECT_TOKEN ||
|
|
468
453
|
process.env.CC_CONNECT_MANAGEMENT_TOKEN ||
|
|
@@ -471,8 +456,9 @@ function ensureCcConnectConfig() {
|
|
|
471
456
|
process.env.CC_CONNECT_BRIDGE_TOKEN ||
|
|
472
457
|
process.env.CC_CONNECT_TOKEN ||
|
|
473
458
|
parseTomlToken(raw, 'bridge'),
|
|
474
|
-
|
|
459
|
+
hasProjects: hasProjectEntries(raw),
|
|
475
460
|
isStarterConfig: isStarterProjectConfig(raw),
|
|
461
|
+
raw,
|
|
476
462
|
};
|
|
477
463
|
}
|
|
478
464
|
|
|
@@ -554,25 +540,124 @@ function resolveAliasLoaderRegister() {
|
|
|
554
540
|
return `data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register(${JSON.stringify(aliasLoaderUrl)}, pathToFileURL("./"));`;
|
|
555
541
|
}
|
|
556
542
|
|
|
543
|
+
async function checkExistingOpenHermitServer() {
|
|
544
|
+
const url = `http://127.0.0.1:${port}`;
|
|
545
|
+
try {
|
|
546
|
+
const res = await fetch(`${url}/api/version`, { signal: AbortSignal.timeout(1000) });
|
|
547
|
+
if (res.ok) {
|
|
548
|
+
const version = (await res.text()).trim() || 'unknown';
|
|
549
|
+
return { running: true, version, url };
|
|
550
|
+
}
|
|
551
|
+
} catch {
|
|
552
|
+
// Port may be unused or owned by another process.
|
|
553
|
+
}
|
|
554
|
+
return { running: false, version: '', url };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function isTcpPortAvailable(portNumber) {
|
|
558
|
+
return new Promise((resolve) => {
|
|
559
|
+
const server = net.createServer();
|
|
560
|
+
server.once('error', () => resolve(false));
|
|
561
|
+
server.once('listening', () => {
|
|
562
|
+
server.close(() => resolve(true));
|
|
563
|
+
});
|
|
564
|
+
server.listen(portNumber, '127.0.0.1');
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function assertWebPortAvailable() {
|
|
569
|
+
const existingServer = await checkExistingOpenHermitServer();
|
|
570
|
+
if (existingServer.running) {
|
|
571
|
+
console.log(`[openHermit] Already running: ${existingServer.url}`);
|
|
572
|
+
console.log(`[openHermit] Version: ${existingServer.version}`);
|
|
573
|
+
console.log('[openHermit] Run `openhermit stop` first, or use `openhermit --port <port>` for another instance.');
|
|
574
|
+
process.exit(0);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const available = await isTcpPortAvailable(Number.parseInt(port, 10));
|
|
578
|
+
if (!available) {
|
|
579
|
+
console.error(`[openHermit] Port ${port} is already in use.`);
|
|
580
|
+
console.error('[openHermit] Stop the existing process first, or start with another port:');
|
|
581
|
+
console.error(` openhermit --port ${Number.parseInt(port, 10) + 1}`);
|
|
582
|
+
console.error('[openHermit] macOS/Linux: lsof -nP -iTCP:' + port + ' -sTCP:LISTEN');
|
|
583
|
+
console.error('[openHermit] Windows: netstat -ano | findstr :' + port);
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
557
588
|
let ccConnectProcess = null;
|
|
558
589
|
let ccTokens = {
|
|
559
590
|
managementToken: process.env.CC_CONNECT_TOKEN || process.env.CC_CONNECT_MANAGEMENT_TOKEN || '',
|
|
560
591
|
bridgeToken: process.env.CC_CONNECT_BRIDGE_TOKEN || process.env.CC_CONNECT_TOKEN || '',
|
|
561
592
|
};
|
|
593
|
+
let runtimeSetupMode = false;
|
|
594
|
+
|
|
595
|
+
await assertWebPortAvailable();
|
|
562
596
|
|
|
563
597
|
if (!skipCcConnect) {
|
|
564
|
-
|
|
598
|
+
let shouldStartRuntime = false;
|
|
599
|
+
ccTokens = readCcConnectConfigState();
|
|
565
600
|
const ccBaseUrl = process.env.CC_CONNECT_BASE_URL || 'http://127.0.0.1:9820';
|
|
566
601
|
const alreadyRunning = await waitForCcConnect(ccBaseUrl, ccTokens.managementToken, 1_000);
|
|
567
602
|
if (alreadyRunning) {
|
|
568
603
|
console.log(`[openHermit] Runtime service already running: ${ccBaseUrl}`);
|
|
569
|
-
} else if (!ccTokens.
|
|
570
|
-
console.log('[openHermit]
|
|
571
|
-
console.log(`[openHermit]
|
|
572
|
-
|
|
573
|
-
|
|
604
|
+
} else if (!ccTokens.configExists) {
|
|
605
|
+
console.log('[openHermit] Initializing runtime config with bundled runtime service...');
|
|
606
|
+
console.log(`[openHermit] Runtime config: ${ccConnectConfigPath}`);
|
|
607
|
+
const initProcess = spawn(process.execPath, [resolveCcConnectRunner(), '-config', ccConnectConfigPath], {
|
|
608
|
+
cwd: repoRoot,
|
|
609
|
+
env: {
|
|
610
|
+
...process.env,
|
|
611
|
+
CC_CONNECT_TOKEN: ccTokens.managementToken,
|
|
612
|
+
CC_CONNECT_MANAGEMENT_TOKEN: ccTokens.managementToken,
|
|
613
|
+
CC_CONNECT_BRIDGE_TOKEN: ccTokens.bridgeToken,
|
|
614
|
+
},
|
|
615
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
616
|
+
});
|
|
617
|
+
initProcess.stdout?.on('data', (chunk) => {
|
|
618
|
+
process.stdout.write(chunk);
|
|
619
|
+
appendLog(runtimeLogPath, chunk);
|
|
620
|
+
});
|
|
621
|
+
initProcess.stderr?.on('data', (chunk) => {
|
|
622
|
+
process.stderr.write(chunk);
|
|
623
|
+
appendLog(runtimeLogPath, chunk);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
const initCode = await new Promise((resolve) => {
|
|
627
|
+
initProcess.on('exit', (code) => resolve(code ?? 1));
|
|
628
|
+
initProcess.on('error', () => resolve(1));
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
ccTokens = readCcConnectConfigState();
|
|
632
|
+
if (initCode === 0 && ccTokens.configExists) {
|
|
633
|
+
console.log('[openHermit] Runtime starter config created.');
|
|
634
|
+
try {
|
|
635
|
+
ensureClaudeCodeCliIfNeeded(ccTokens.raw);
|
|
636
|
+
} catch {
|
|
637
|
+
printLogTail('Runtime', runtimeLogPath);
|
|
638
|
+
process.exit(1);
|
|
639
|
+
}
|
|
640
|
+
shouldStartRuntime = true;
|
|
641
|
+
} else {
|
|
642
|
+
console.error(`[openHermit] Runtime config initialization failed (code ${initCode}).`);
|
|
643
|
+
printLogTail('Runtime', runtimeLogPath);
|
|
644
|
+
process.exit(1);
|
|
574
645
|
}
|
|
646
|
+
} else if (ccTokens.hasProjects) {
|
|
647
|
+
try {
|
|
648
|
+
ensureClaudeCodeCliIfNeeded(ccTokens.raw);
|
|
649
|
+
} catch {
|
|
650
|
+
printLogTail('Runtime', runtimeLogPath);
|
|
651
|
+
process.exit(1);
|
|
652
|
+
}
|
|
653
|
+
shouldStartRuntime = true;
|
|
575
654
|
} else {
|
|
655
|
+
console.error('[openHermit] Runtime config has no projects. Please edit the config and try again.');
|
|
656
|
+
console.error(`[openHermit] Runtime config: ${ccConnectConfigPath}`);
|
|
657
|
+
process.exit(1);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (shouldStartRuntime) {
|
|
576
661
|
console.log('[openHermit] Starting bundled runtime service...');
|
|
577
662
|
console.log(`[openHermit] Runtime config: ${ccConnectConfigPath}`);
|
|
578
663
|
ccConnectProcess = spawn(process.execPath, [resolveCcConnectRunner(), '-config', ccConnectConfigPath], {
|
|
@@ -660,6 +745,7 @@ const serverProcess = spawn(process.execPath, ['--import', resolveAliasLoaderReg
|
|
|
660
745
|
HOST: process.env.HOST || '127.0.0.1',
|
|
661
746
|
NODE_ENV: 'production',
|
|
662
747
|
HERMIT_HOME: hermitHome,
|
|
748
|
+
HERMIT_RUNTIME_SETUP_MODE: runtimeSetupMode ? '1' : '0',
|
|
663
749
|
CC_CONNECT_TOKEN: ccTokens.managementToken,
|
|
664
750
|
CC_CONNECT_MANAGEMENT_TOKEN: ccTokens.managementToken,
|
|
665
751
|
CC_CONNECT_BRIDGE_TOKEN: ccTokens.bridgeToken,
|
package/package.json
CHANGED
package/src/main/server.ts
CHANGED
|
@@ -68,6 +68,7 @@ const HOST = process.env.HOST ?? '127.0.0.1';
|
|
|
68
68
|
const PORT = Number.parseInt(process.env.PORT ?? '5680', 10);
|
|
69
69
|
const STATIC_DIR = process.env.STATIC_DIR ?? path.resolve(REPO_ROOT, 'dist-renderer');
|
|
70
70
|
const HARNESS_BRIDGE_CONNECT_TIMEOUT_MS = 10_000;
|
|
71
|
+
const RUNTIME_SETUP_MODE = process.env.HERMIT_RUNTIME_SETUP_MODE === '1';
|
|
71
72
|
|
|
72
73
|
// ===========================================================================
|
|
73
74
|
// Hermit runtime config — ~/.hermit/config.json
|
|
@@ -2183,6 +2184,11 @@ function isCronNotFoundError(error: unknown): boolean {
|
|
|
2183
2184
|
return /(\b404\b|not found|no matching|does not exist|不存在)/i.test(message);
|
|
2184
2185
|
}
|
|
2185
2186
|
|
|
2187
|
+
function isRuntimeUnavailableError(error: unknown): boolean {
|
|
2188
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2189
|
+
return /ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|fetch failed|cc-connect 不可达/i.test(message);
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2186
2192
|
app.get('/api/schedules', async () => {
|
|
2187
2193
|
try {
|
|
2188
2194
|
const jobs = await cc.listCronJobs();
|
|
@@ -2190,6 +2196,9 @@ app.get('/api/schedules', async () => {
|
|
|
2190
2196
|
const workDirMap = await resolveTeamWorkDirs(jobs.map((job) => job.project));
|
|
2191
2197
|
return jobs.map((job) => mapCronJobToSchedule(job, workDirMap.get(job.project) ?? ''));
|
|
2192
2198
|
} catch (err) {
|
|
2199
|
+
if (RUNTIME_SETUP_MODE && isRuntimeUnavailableError(err)) {
|
|
2200
|
+
return [];
|
|
2201
|
+
}
|
|
2193
2202
|
app.log.warn({ err }, 'list schedules from cc-connect failed');
|
|
2194
2203
|
return [];
|
|
2195
2204
|
}
|