arisa 2.0.9 → 2.1.3
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/arisa.js +221 -0
- package/package.json +2 -1
- package/src/core/index.ts +2 -2
- package/src/core/scheduler.ts +3 -2
- package/src/daemon/autofix.ts +3 -3
- package/src/daemon/bridge.ts +12 -8
- package/src/daemon/claude-login.ts +2 -0
- package/src/daemon/index.ts +5 -3
- package/src/daemon/lifecycle.ts +6 -5
- package/src/daemon/setup.ts +194 -24
- package/src/shared/config.ts +2 -0
- package/src/shared/ports.ts +21 -3
package/bin/arisa.js
CHANGED
|
@@ -403,6 +403,227 @@ function printForegroundNotice() {
|
|
|
403
403
|
process.stdout.write("Use `arisa start` to run it as a background service.\n");
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
// ── Root detection helpers ──────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
function isRoot() {
|
|
409
|
+
return process.getuid?.() === 0;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function arisaUserExists() {
|
|
413
|
+
return spawnSync("id", ["arisa"], { stdio: "ignore" }).status === 0;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isProvisioned() {
|
|
417
|
+
return arisaUserExists() && existsSync("/home/arisa/.bun/bin/bun");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function detectSudoGroup() {
|
|
421
|
+
// Debian/Ubuntu use 'sudo', RHEL/Fedora use 'wheel'
|
|
422
|
+
const sudoGroup = spawnSync("getent", ["group", "sudo"], { stdio: "ignore" });
|
|
423
|
+
if (sudoGroup.status === 0) return "sudo";
|
|
424
|
+
const wheelGroup = spawnSync("getent", ["group", "wheel"], { stdio: "ignore" });
|
|
425
|
+
if (wheelGroup.status === 0) return "wheel";
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function step(ok, msg) {
|
|
430
|
+
process.stdout.write(` ${ok ? "\u2713" : "\u2717"} ${msg}\n`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function runAs(cmd) {
|
|
434
|
+
return spawnSync("su", ["-", "arisa", "-c", cmd], {
|
|
435
|
+
stdio: "pipe",
|
|
436
|
+
timeout: 120_000,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function provisionArisaUser() {
|
|
441
|
+
process.stdout.write("Running as root \u2014 creating dedicated user 'arisa'...\n");
|
|
442
|
+
|
|
443
|
+
// 1. Create user
|
|
444
|
+
const useradd = spawnSync("useradd", ["-m", "-s", "/bin/bash", "arisa"], {
|
|
445
|
+
stdio: "pipe",
|
|
446
|
+
});
|
|
447
|
+
if (useradd.status !== 0) {
|
|
448
|
+
step(false, `Failed to create user: ${(useradd.stderr || "").toString().trim()}`);
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
step(true, "User arisa created");
|
|
452
|
+
|
|
453
|
+
// Add to sudo/wheel group if available
|
|
454
|
+
const group = detectSudoGroup();
|
|
455
|
+
if (group) {
|
|
456
|
+
spawnSync("usermod", ["-aG", group, "arisa"], { stdio: "ignore" });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 2. Install bun
|
|
460
|
+
const bunInstall = runAs("curl -fsSL https://bun.sh/install | bash");
|
|
461
|
+
if (bunInstall.status !== 0) {
|
|
462
|
+
step(false, `Failed to install bun: ${(bunInstall.stderr || "").toString().trim()}`);
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
step(true, "Bun installed for arisa");
|
|
466
|
+
|
|
467
|
+
// 3. Copy arisa source
|
|
468
|
+
const dest = "/home/arisa/arisa";
|
|
469
|
+
spawnSync("cp", ["-r", pkgRoot, dest], { stdio: "pipe" });
|
|
470
|
+
spawnSync("chown", ["-R", "arisa:arisa", dest], { stdio: "pipe" });
|
|
471
|
+
|
|
472
|
+
// Install deps + global
|
|
473
|
+
const install = runAs("cd ~/arisa && ~/.bun/bin/bun install && ~/.bun/bin/bun add -g .");
|
|
474
|
+
if (install.status !== 0) {
|
|
475
|
+
step(false, `Failed to install: ${(install.stderr || "").toString().trim()}`);
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
step(true, "Arisa installed for arisa");
|
|
479
|
+
|
|
480
|
+
// 4. Migrate data
|
|
481
|
+
const rootArisa = "/root/.arisa";
|
|
482
|
+
if (existsSync(rootArisa)) {
|
|
483
|
+
const destArisa = "/home/arisa/.arisa";
|
|
484
|
+
spawnSync("cp", ["-r", rootArisa, destArisa], { stdio: "pipe" });
|
|
485
|
+
spawnSync("chown", ["-R", "arisa:arisa", destArisa], { stdio: "pipe" });
|
|
486
|
+
step(true, "Data migrated to /home/arisa/.arisa/");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── System-level systemd (for root-provisioned installs) ────────────
|
|
491
|
+
|
|
492
|
+
const systemdSystemUnitPath = "/etc/systemd/system/arisa.service";
|
|
493
|
+
|
|
494
|
+
function writeSystemdSystemUnit() {
|
|
495
|
+
const unit = `[Unit]
|
|
496
|
+
Description=Arisa Agent Runtime
|
|
497
|
+
After=network-online.target
|
|
498
|
+
Wants=network-online.target
|
|
499
|
+
|
|
500
|
+
[Service]
|
|
501
|
+
Type=simple
|
|
502
|
+
User=arisa
|
|
503
|
+
WorkingDirectory=/home/arisa/arisa
|
|
504
|
+
ExecStart=/home/arisa/.bun/bin/bun /home/arisa/arisa/src/daemon/index.ts
|
|
505
|
+
Restart=always
|
|
506
|
+
RestartSec=5
|
|
507
|
+
Environment=ARISA_PROJECT_DIR=/home/arisa/arisa
|
|
508
|
+
Environment=BUN_INSTALL=/home/arisa/.bun
|
|
509
|
+
Environment=PATH=/home/arisa/.bun/bin:/usr/local/bin:/usr/bin:/bin
|
|
510
|
+
|
|
511
|
+
[Install]
|
|
512
|
+
WantedBy=multi-user.target
|
|
513
|
+
`;
|
|
514
|
+
writeFileSync(systemdSystemUnitPath, unit, "utf8");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function runSystemdSystem(commandArgs) {
|
|
518
|
+
const child = runCommand("systemctl", commandArgs, { stdio: "pipe" });
|
|
519
|
+
if (child.status !== 0) {
|
|
520
|
+
const stderr = child.stderr || "Unknown systemd error";
|
|
521
|
+
process.stderr.write(stderr.endsWith("\n") ? stderr : `${stderr}\n`);
|
|
522
|
+
return { ok: false, status: child.status ?? 1 };
|
|
523
|
+
}
|
|
524
|
+
return { ok: true, status: 0, stdout: child.stdout || "" };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function startSystemdSystem() {
|
|
528
|
+
const start = runSystemdSystem(["start", "arisa"]);
|
|
529
|
+
if (!start.ok) return start.status;
|
|
530
|
+
process.stdout.write("Arisa service started.\n");
|
|
531
|
+
return 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function stopSystemdSystem() {
|
|
535
|
+
const stop = runSystemdSystem(["stop", "arisa"]);
|
|
536
|
+
if (!stop.ok) return stop.status;
|
|
537
|
+
process.stdout.write("Arisa service stopped.\n");
|
|
538
|
+
return 0;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function restartSystemdSystem() {
|
|
542
|
+
const restart = runSystemdSystem(["restart", "arisa"]);
|
|
543
|
+
if (!restart.ok) return restart.status;
|
|
544
|
+
process.stdout.write("Arisa service restarted.\n");
|
|
545
|
+
return 0;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function statusSystemdSystem() {
|
|
549
|
+
const result = runCommand("systemctl", ["status", "arisa"], { stdio: "inherit" });
|
|
550
|
+
return result.status ?? 1;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function isSystemdActive() {
|
|
554
|
+
const result = runCommand("systemctl", ["is-active", "arisa"], { stdio: "pipe" });
|
|
555
|
+
return result.status === 0;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── Root guard ──────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
if (isRoot()) {
|
|
561
|
+
if (!isProvisioned()) {
|
|
562
|
+
provisionArisaUser();
|
|
563
|
+
writeSystemdSystemUnit();
|
|
564
|
+
spawnSync("systemctl", ["daemon-reload"], { stdio: "inherit" });
|
|
565
|
+
spawnSync("systemctl", ["enable", "--now", "arisa"], { stdio: "inherit" });
|
|
566
|
+
step(true, "Systemd service enabled");
|
|
567
|
+
|
|
568
|
+
process.stdout.write(`
|
|
569
|
+
Arisa is now running as a service.
|
|
570
|
+
Status: systemctl status arisa
|
|
571
|
+
Logs: journalctl -u arisa -f
|
|
572
|
+
Restart: systemctl restart arisa
|
|
573
|
+
Stop: systemctl stop arisa
|
|
574
|
+
`);
|
|
575
|
+
process.exit(0);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Already provisioned — route commands to system-level systemd
|
|
579
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
580
|
+
printHelp();
|
|
581
|
+
process.exit(0);
|
|
582
|
+
}
|
|
583
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
584
|
+
printVersion();
|
|
585
|
+
process.exit(0);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
switch (command) {
|
|
589
|
+
case "start":
|
|
590
|
+
process.exit(startSystemdSystem());
|
|
591
|
+
break;
|
|
592
|
+
case "stop":
|
|
593
|
+
process.exit(stopSystemdSystem());
|
|
594
|
+
break;
|
|
595
|
+
case "restart":
|
|
596
|
+
process.exit(restartSystemdSystem());
|
|
597
|
+
break;
|
|
598
|
+
case "status":
|
|
599
|
+
process.exit(statusSystemdSystem());
|
|
600
|
+
break;
|
|
601
|
+
case "daemon":
|
|
602
|
+
case "run": {
|
|
603
|
+
// Run as arisa user in foreground
|
|
604
|
+
const su = spawnSync("su", ["-", "arisa", "-c", "arisa"], {
|
|
605
|
+
stdio: "inherit",
|
|
606
|
+
});
|
|
607
|
+
process.exit(su.status ?? 1);
|
|
608
|
+
}
|
|
609
|
+
default: {
|
|
610
|
+
// No args or unknown — start if not active, otherwise show status
|
|
611
|
+
if (isDefaultInvocation) {
|
|
612
|
+
if (isSystemdActive()) {
|
|
613
|
+
process.exit(statusSystemdSystem());
|
|
614
|
+
} else {
|
|
615
|
+
process.exit(startSystemdSystem());
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
process.stderr.write(`Unknown command: ${command}\n\n`);
|
|
619
|
+
printHelp();
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ── Non-root flow (unchanged) ───────────────────────────────────────
|
|
626
|
+
|
|
406
627
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
407
628
|
printHelp();
|
|
408
629
|
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arisa",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.3",
|
|
4
4
|
"description": "Arisa - dynamic agent runtime with daemon/core architecture that evolves through user interaction",
|
|
5
5
|
"preferGlobal": true,
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"core": "bun src/core/index.ts"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"@inquirer/prompts": "^8.2.0",
|
|
30
31
|
"croner": "^9.0.0",
|
|
31
32
|
"crypto-js": "^4.2.0",
|
|
32
33
|
"deepbase": "^3.4.9",
|
package/src/core/index.ts
CHANGED
|
@@ -76,7 +76,7 @@ await initScheduler();
|
|
|
76
76
|
await initAttachments();
|
|
77
77
|
|
|
78
78
|
const server = await serveWithRetry({
|
|
79
|
-
|
|
79
|
+
unix: config.coreSocket,
|
|
80
80
|
async fetch(req) {
|
|
81
81
|
const url = new URL(req.url);
|
|
82
82
|
|
|
@@ -461,4 +461,4 @@ ${messageText}`;
|
|
|
461
461
|
},
|
|
462
462
|
});
|
|
463
463
|
|
|
464
|
-
log.info(`Core server listening on
|
|
464
|
+
log.info(`Core server listening on ${config.coreSocket}`);
|
package/src/core/scheduler.ts
CHANGED
|
@@ -59,14 +59,15 @@ async function executeTask(task: ScheduledTask) {
|
|
|
59
59
|
if (!tasks.includes(task) || !result) return;
|
|
60
60
|
|
|
61
61
|
// Send the processed result to Telegram via Daemon
|
|
62
|
-
const response = await fetch(
|
|
62
|
+
const response = await fetch("http://localhost/send", {
|
|
63
63
|
method: "POST",
|
|
64
64
|
headers: { "Content-Type": "application/json" },
|
|
65
65
|
body: JSON.stringify({
|
|
66
66
|
chatId: task.chatId,
|
|
67
67
|
text: result,
|
|
68
68
|
}),
|
|
69
|
-
|
|
69
|
+
unix: config.daemonSocket,
|
|
70
|
+
} as any);
|
|
70
71
|
if (!response.ok) {
|
|
71
72
|
log.error(`Daemon returned ${response.status} for task ${task.id}`);
|
|
72
73
|
}
|
package/src/daemon/autofix.ts
CHANGED
|
@@ -85,7 +85,7 @@ Rules:
|
|
|
85
85
|
} else {
|
|
86
86
|
const detail = outcome.failures.join(" | ").slice(0, 400);
|
|
87
87
|
log.error(`Auto-fix: all CLIs failed: ${detail}`);
|
|
88
|
-
await notifyFn?.("Auto-fix: Claude
|
|
88
|
+
await notifyFn?.("Auto-fix: both Claude and Codex failed. Check the logs.");
|
|
89
89
|
}
|
|
90
90
|
return false;
|
|
91
91
|
}
|
|
@@ -98,11 +98,11 @@ Rules:
|
|
|
98
98
|
log.info(`Auto-fix: ${cli} completed successfully`);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
await notifyFn?.(`Auto-fix
|
|
101
|
+
await notifyFn?.(`Auto-fix applied with ${cli}. Core restarting...\n<pre>${escapeHtml(summary)}</pre>`);
|
|
102
102
|
return true;
|
|
103
103
|
} catch (err) {
|
|
104
104
|
log.error(`Auto-fix: error: ${err}`);
|
|
105
|
-
await notifyFn?.("Auto-fix: error
|
|
105
|
+
await notifyFn?.("Auto-fix: internal error. Check the logs.");
|
|
106
106
|
return false;
|
|
107
107
|
}
|
|
108
108
|
}
|
package/src/daemon/bridge.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @module daemon/bridge
|
|
3
3
|
* @role HTTP client from Daemon to Core with smart fallback to local AI CLI.
|
|
4
4
|
* @responsibilities
|
|
5
|
-
* - POST messages to Core
|
|
5
|
+
* - POST messages to Core via Unix socket
|
|
6
6
|
* - Respect Core lifecycle state (starting/up/down)
|
|
7
7
|
* - Wait for Core during startup, fallback only when truly down
|
|
8
8
|
* - Serialize fallback calls (one CLI process at a time)
|
|
@@ -18,7 +18,7 @@ import { getCoreState, getCoreError, waitForCoreReady } from "./lifecycle";
|
|
|
18
18
|
|
|
19
19
|
const log = createLogger("daemon");
|
|
20
20
|
|
|
21
|
-
const CORE_URL =
|
|
21
|
+
const CORE_URL = "http://localhost";
|
|
22
22
|
const STARTUP_WAIT_MS = 15_000;
|
|
23
23
|
const RETRY_DELAY = 3000;
|
|
24
24
|
|
|
@@ -54,7 +54,7 @@ async function handleStarting(
|
|
|
54
54
|
onStatus?: StatusCallback,
|
|
55
55
|
): Promise<CoreResponse> {
|
|
56
56
|
log.info("Core is starting, waiting for it to be ready...");
|
|
57
|
-
await onStatus?.("Core
|
|
57
|
+
await onStatus?.("Core starting, please wait...");
|
|
58
58
|
|
|
59
59
|
const ready = await waitForCoreReady(STARTUP_WAIT_MS);
|
|
60
60
|
|
|
@@ -90,7 +90,7 @@ async function handleUp(
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
log.warn("Core unreachable, retrying in 3s...");
|
|
93
|
-
await onStatus?.("Core
|
|
93
|
+
await onStatus?.("Core not responding, retrying...");
|
|
94
94
|
await sleep(RETRY_DELAY);
|
|
95
95
|
|
|
96
96
|
try {
|
|
@@ -114,9 +114,9 @@ async function runFallback(
|
|
|
114
114
|
|
|
115
115
|
if (coreError) {
|
|
116
116
|
const preview = coreError.length > 300 ? coreError.slice(-300) : coreError;
|
|
117
|
-
await onStatus?.(`Core
|
|
117
|
+
await onStatus?.(`Core is down. Error:\n<pre>${escapeHtml(preview)}</pre>\nFalling back to direct CLI (Claude/Codex)...`);
|
|
118
118
|
} else {
|
|
119
|
-
await onStatus?.("Core
|
|
119
|
+
await onStatus?.("Core is down. Falling back to direct CLI (Claude/Codex)...");
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
const text = message.text || "[non-text message — media not available in fallback mode]";
|
|
@@ -135,7 +135,8 @@ async function postToCore(message: IncomingMessage): Promise<CoreResponse> {
|
|
|
135
135
|
headers: { "Content-Type": "application/json" },
|
|
136
136
|
body: JSON.stringify({ message }),
|
|
137
137
|
signal: AbortSignal.timeout(config.claudeTimeout + 5000),
|
|
138
|
-
|
|
138
|
+
unix: config.coreSocket,
|
|
139
|
+
} as any);
|
|
139
140
|
|
|
140
141
|
if (!response.ok) {
|
|
141
142
|
throw new Error(`Core returned ${response.status}`);
|
|
@@ -154,7 +155,10 @@ function sleep(ms: number): Promise<void> {
|
|
|
154
155
|
|
|
155
156
|
export async function isCoreHealthy(): Promise<boolean> {
|
|
156
157
|
try {
|
|
157
|
-
const response = await fetch(`${CORE_URL}/health`, {
|
|
158
|
+
const response = await fetch(`${CORE_URL}/health`, {
|
|
159
|
+
signal: AbortSignal.timeout(2000),
|
|
160
|
+
unix: config.coreSocket,
|
|
161
|
+
} as any);
|
|
158
162
|
return response.ok;
|
|
159
163
|
} catch {
|
|
160
164
|
return false;
|
|
@@ -16,6 +16,8 @@ import { buildBunWrappedAgentCliCommand } from "../shared/ai-cli";
|
|
|
16
16
|
const log = createLogger("daemon");
|
|
17
17
|
|
|
18
18
|
const AUTH_HINT_PATTERNS = [
|
|
19
|
+
/not logged in/i,
|
|
20
|
+
/please run \/login/i,
|
|
19
21
|
/invalid.*api.?key/i,
|
|
20
22
|
/authentication.*failed/i,
|
|
21
23
|
/not authenticated/i,
|
package/src/daemon/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ const { config } = await import("../shared/config");
|
|
|
23
23
|
// Initialize encrypted secrets
|
|
24
24
|
await config.secrets.initialize();
|
|
25
25
|
const { createLogger } = await import("../shared/logger");
|
|
26
|
-
const { serveWithRetry, claimProcess, releaseProcess } = await import("../shared/ports");
|
|
26
|
+
const { serveWithRetry, claimProcess, releaseProcess, cleanupSocket } = await import("../shared/ports");
|
|
27
27
|
const { TelegramChannel } = await import("./channels/telegram");
|
|
28
28
|
const { sendToCore } = await import("./bridge");
|
|
29
29
|
const { startCore, stopCore, setLifecycleNotify } = await import("./lifecycle");
|
|
@@ -175,7 +175,7 @@ telegram.onMessage(async (msg) => {
|
|
|
175
175
|
|
|
176
176
|
// --- HTTP server for Core → Daemon pushes (scheduler) ---
|
|
177
177
|
const pushServer = await serveWithRetry({
|
|
178
|
-
|
|
178
|
+
unix: config.daemonSocket,
|
|
179
179
|
async fetch(req) {
|
|
180
180
|
const url = new URL(req.url);
|
|
181
181
|
|
|
@@ -220,7 +220,7 @@ const pushServer = await serveWithRetry({
|
|
|
220
220
|
},
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
-
log.info(`Daemon push server listening on
|
|
223
|
+
log.info(`Daemon push server listening on ${config.daemonSocket}`);
|
|
224
224
|
|
|
225
225
|
// --- Auto-install missing CLIs (non-blocking) ---
|
|
226
226
|
void autoInstallMissingClis();
|
|
@@ -238,6 +238,8 @@ telegram.connect().catch((error) => {
|
|
|
238
238
|
function shutdown() {
|
|
239
239
|
log.info("Shutting down Daemon...");
|
|
240
240
|
stopCore();
|
|
241
|
+
cleanupSocket(config.daemonSocket);
|
|
242
|
+
cleanupSocket(config.coreSocket);
|
|
241
243
|
releaseProcess("daemon");
|
|
242
244
|
process.exit(0);
|
|
243
245
|
}
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -91,9 +91,10 @@ function startHealthCheck() {
|
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
93
|
try {
|
|
94
|
-
const res = await fetch(
|
|
94
|
+
const res = await fetch("http://localhost/health", {
|
|
95
95
|
signal: AbortSignal.timeout(2000),
|
|
96
|
-
|
|
96
|
+
unix: config.coreSocket,
|
|
97
|
+
} as any);
|
|
97
98
|
if (res.ok) {
|
|
98
99
|
coreState = "up";
|
|
99
100
|
log.info("Core is ready (health check passed)");
|
|
@@ -248,7 +249,7 @@ async function handleError(error: string) {
|
|
|
248
249
|
const preview = error.length > 500 ? error.slice(-500) : error;
|
|
249
250
|
log.warn("Core error detected, notifying and attempting auto-fix...");
|
|
250
251
|
await notifyFn?.(
|
|
251
|
-
`
|
|
252
|
+
`Core error detected:\n<pre>${escapeHtml(preview)}</pre>\nAttempting auto-fix...`
|
|
252
253
|
);
|
|
253
254
|
|
|
254
255
|
// 2. Run autofix
|
|
@@ -256,9 +257,9 @@ async function handleError(error: string) {
|
|
|
256
257
|
|
|
257
258
|
// 3. Notify result
|
|
258
259
|
if (fixed) {
|
|
259
|
-
await notifyFn?.("Auto-fix
|
|
260
|
+
await notifyFn?.("Auto-fix applied. Core will restart automatically.");
|
|
260
261
|
} else {
|
|
261
|
-
await notifyFn?.("Auto-fix
|
|
262
|
+
await notifyFn?.("Auto-fix could not resolve the error. Please check manually.");
|
|
262
263
|
}
|
|
263
264
|
} catch (err) {
|
|
264
265
|
log.error(`handleError threw: ${err}`);
|
package/src/daemon/setup.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module daemon/setup
|
|
3
|
-
* @role Interactive first-run setup
|
|
3
|
+
* @role Interactive first-run setup with inquirer prompts.
|
|
4
4
|
* @responsibilities
|
|
5
5
|
* - Check required config (TELEGRAM_BOT_TOKEN)
|
|
6
6
|
* - Check optional config (OPENAI_API_KEY)
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* - Detect / install missing CLIs (Claude, Codex)
|
|
8
|
+
* - Run interactive login flows for installed CLIs
|
|
9
|
+
* - Persist tokens to both .env and encrypted DB
|
|
10
|
+
* @dependencies shared/paths, shared/secrets, shared/ai-cli
|
|
11
|
+
* @effects Reads stdin, writes runtime .env, spawns install/login processes
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
13
15
|
import { dirname, join } from "path";
|
|
14
16
|
import { dataDir } from "../shared/paths";
|
|
15
17
|
import { secrets, setSecret } from "../shared/secrets";
|
|
18
|
+
import { isAgentCliInstalled, buildBunWrappedAgentCliCommand, type AgentCliName } from "../shared/ai-cli";
|
|
16
19
|
|
|
17
20
|
const ENV_PATH = join(dataDir, ".env");
|
|
18
21
|
const SETUP_DONE_KEY = "ARISA_SETUP_COMPLETE";
|
|
19
22
|
|
|
23
|
+
const CLI_PACKAGES: Record<AgentCliName, string> = {
|
|
24
|
+
claude: "@anthropic-ai/claude-code",
|
|
25
|
+
codex: "@openai/codex",
|
|
26
|
+
};
|
|
27
|
+
|
|
20
28
|
function loadExistingEnv(): Record<string, string> {
|
|
21
29
|
if (!existsSync(ENV_PATH)) return {};
|
|
22
30
|
const vars: Record<string, string> = {};
|
|
@@ -36,7 +44,8 @@ function saveEnv(vars: Record<string, string>) {
|
|
|
36
44
|
writeFileSync(ENV_PATH, content);
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
|
|
47
|
+
// Fallback readline for non-TTY environments
|
|
48
|
+
async function readLine(question: string): Promise<string> {
|
|
40
49
|
process.stdout.write(question);
|
|
41
50
|
for await (const line of console) {
|
|
42
51
|
return line.trim();
|
|
@@ -50,18 +59,44 @@ export async function runSetup(): Promise<boolean> {
|
|
|
50
59
|
const openaiSecret = await secrets.openai();
|
|
51
60
|
let changed = false;
|
|
52
61
|
const setupDone = vars[SETUP_DONE_KEY] === "1" || process.env[SETUP_DONE_KEY] === "1";
|
|
62
|
+
const isFirstRun = !setupDone;
|
|
63
|
+
|
|
64
|
+
// Try to load inquirer for interactive mode
|
|
65
|
+
let inq: typeof import("@inquirer/prompts") | null = null;
|
|
66
|
+
if (process.stdin.isTTY) {
|
|
67
|
+
try {
|
|
68
|
+
inq = await import("@inquirer/prompts");
|
|
69
|
+
} catch {
|
|
70
|
+
// Fall back to basic prompts
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Phase 1: Tokens ────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const hasTelegram = !!(vars.TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN || telegramSecret);
|
|
77
|
+
const hasOpenAI = !!(vars.OPENAI_API_KEY || process.env.OPENAI_API_KEY || openaiSecret);
|
|
78
|
+
|
|
79
|
+
if (!hasTelegram) {
|
|
80
|
+
if (isFirstRun) console.log("\n🔧 Arisa Setup\n");
|
|
53
81
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
82
|
+
let token: string;
|
|
83
|
+
if (inq) {
|
|
84
|
+
token = await inq.input({
|
|
85
|
+
message: "Telegram Bot Token (from https://t.me/BotFather):",
|
|
86
|
+
validate: (v) => (v.trim() ? true : "Token is required"),
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
console.log("Telegram Bot Token required. Get one from https://t.me/BotFather on Telegram.");
|
|
90
|
+
token = await readLine("TELEGRAM_BOT_TOKEN: ");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!token.trim()) {
|
|
60
94
|
console.log("No token provided. Cannot start without Telegram Bot Token.");
|
|
61
95
|
return false;
|
|
62
96
|
}
|
|
63
|
-
|
|
64
|
-
|
|
97
|
+
|
|
98
|
+
vars.TELEGRAM_BOT_TOKEN = token.trim();
|
|
99
|
+
await setSecret("TELEGRAM_BOT_TOKEN", token.trim()).catch((e) =>
|
|
65
100
|
console.warn(`[setup] Could not persist TELEGRAM_BOT_TOKEN to encrypted DB: ${e}`)
|
|
66
101
|
);
|
|
67
102
|
console.log("[setup] TELEGRAM_BOT_TOKEN saved to .env + encrypted DB");
|
|
@@ -71,33 +106,168 @@ export async function runSetup(): Promise<boolean> {
|
|
|
71
106
|
console.log(`[setup] TELEGRAM_BOT_TOKEN found in ${src}`);
|
|
72
107
|
}
|
|
73
108
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
109
|
+
if (!hasOpenAI && isFirstRun) {
|
|
110
|
+
let key: string;
|
|
111
|
+
if (inq) {
|
|
112
|
+
key = await inq.input({
|
|
113
|
+
message: "OpenAI API Key (optional — voice + image, enter to skip):",
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
console.log("\nOpenAI API Key (optional — enables voice transcription + image analysis).");
|
|
117
|
+
key = await readLine("OPENAI_API_KEY (enter to skip): ");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (key.trim()) {
|
|
121
|
+
vars.OPENAI_API_KEY = key.trim();
|
|
122
|
+
await setSecret("OPENAI_API_KEY", key.trim()).catch((e) =>
|
|
82
123
|
console.warn(`[setup] Could not persist OPENAI_API_KEY to encrypted DB: ${e}`)
|
|
83
124
|
);
|
|
84
125
|
console.log("[setup] OPENAI_API_KEY saved to .env + encrypted DB");
|
|
85
126
|
changed = true;
|
|
86
127
|
}
|
|
87
|
-
} else if (
|
|
128
|
+
} else if (hasOpenAI) {
|
|
88
129
|
const src = openaiSecret ? "encrypted DB" : vars.OPENAI_API_KEY ? ".env" : "env var";
|
|
89
130
|
console.log(`[setup] OPENAI_API_KEY found in ${src}`);
|
|
90
131
|
}
|
|
91
132
|
|
|
133
|
+
// Save tokens
|
|
92
134
|
if (!setupDone) {
|
|
93
135
|
vars[SETUP_DONE_KEY] = "1";
|
|
94
136
|
changed = true;
|
|
95
137
|
}
|
|
96
|
-
|
|
97
138
|
if (changed) {
|
|
98
139
|
saveEnv(vars);
|
|
99
|
-
console.log(`\nConfig saved to ${ENV_PATH}
|
|
140
|
+
console.log(`\nConfig saved to ${ENV_PATH}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Phase 2: CLI Installation (first run, interactive) ─────────
|
|
144
|
+
|
|
145
|
+
if (isFirstRun && process.stdin.isTTY) {
|
|
146
|
+
await setupClis(inq);
|
|
100
147
|
}
|
|
101
148
|
|
|
102
149
|
return true;
|
|
103
150
|
}
|
|
151
|
+
|
|
152
|
+
async function setupClis(inq: typeof import("@inquirer/prompts") | null) {
|
|
153
|
+
let claudeInstalled = isAgentCliInstalled("claude");
|
|
154
|
+
let codexInstalled = isAgentCliInstalled("codex");
|
|
155
|
+
|
|
156
|
+
console.log("\nCLI Status:");
|
|
157
|
+
console.log(` ${claudeInstalled ? "✓" : "✗"} Claude${claudeInstalled ? "" : " — not installed"}`);
|
|
158
|
+
console.log(` ${codexInstalled ? "✓" : "✗"} Codex${codexInstalled ? "" : " — not installed"}`);
|
|
159
|
+
|
|
160
|
+
// Install missing CLIs
|
|
161
|
+
const missing: AgentCliName[] = [];
|
|
162
|
+
if (!claudeInstalled) missing.push("claude");
|
|
163
|
+
if (!codexInstalled) missing.push("codex");
|
|
164
|
+
|
|
165
|
+
if (missing.length > 0) {
|
|
166
|
+
let toInstall: AgentCliName[] = [];
|
|
167
|
+
|
|
168
|
+
if (inq) {
|
|
169
|
+
toInstall = await inq.checkbox({
|
|
170
|
+
message: "Install missing CLIs? (space to select, enter to confirm)",
|
|
171
|
+
choices: missing.map((cli) => ({
|
|
172
|
+
name: `${cli === "claude" ? "Claude" : "Codex"} (${CLI_PACKAGES[cli]})`,
|
|
173
|
+
value: cli as AgentCliName,
|
|
174
|
+
checked: true,
|
|
175
|
+
})),
|
|
176
|
+
});
|
|
177
|
+
} else {
|
|
178
|
+
// Non-inquirer fallback: install all
|
|
179
|
+
const answer = await readLine("\nInstall missing CLIs? (Y/n): ");
|
|
180
|
+
if (answer.toLowerCase() !== "n") toInstall = missing;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const cli of toInstall) {
|
|
184
|
+
console.log(`\nInstalling ${cli}...`);
|
|
185
|
+
const ok = await installCli(cli);
|
|
186
|
+
console.log(ok ? ` ✓ ${cli} installed` : ` ✗ ${cli} install failed`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Refresh status
|
|
190
|
+
claudeInstalled = isAgentCliInstalled("claude");
|
|
191
|
+
codexInstalled = isAgentCliInstalled("codex");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Login CLIs
|
|
195
|
+
if (claudeInstalled) {
|
|
196
|
+
let doLogin = true;
|
|
197
|
+
if (inq) {
|
|
198
|
+
doLogin = await inq.confirm({ message: "Log in to Claude?", default: true });
|
|
199
|
+
} else {
|
|
200
|
+
const answer = await readLine("\nLog in to Claude? (Y/n): ");
|
|
201
|
+
doLogin = answer.toLowerCase() !== "n";
|
|
202
|
+
}
|
|
203
|
+
if (doLogin) {
|
|
204
|
+
console.log();
|
|
205
|
+
await runInteractiveLogin("claude");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (codexInstalled) {
|
|
210
|
+
let doLogin = true;
|
|
211
|
+
if (inq) {
|
|
212
|
+
doLogin = await inq.confirm({ message: "Log in to Codex?", default: true });
|
|
213
|
+
} else {
|
|
214
|
+
const answer = await readLine("\nLog in to Codex? (Y/n): ");
|
|
215
|
+
doLogin = answer.toLowerCase() !== "n";
|
|
216
|
+
}
|
|
217
|
+
if (doLogin) {
|
|
218
|
+
console.log();
|
|
219
|
+
await runInteractiveLogin("codex");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!claudeInstalled && !codexInstalled) {
|
|
224
|
+
console.log("\n⚠ No CLIs installed. Arisa needs at least one to work.");
|
|
225
|
+
console.log(" The daemon will auto-install them in the background.\n");
|
|
226
|
+
} else {
|
|
227
|
+
console.log("\n✓ Setup complete!\n");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function installCli(cli: AgentCliName): Promise<boolean> {
|
|
232
|
+
try {
|
|
233
|
+
const proc = Bun.spawn(["bun", "add", "-g", CLI_PACKAGES[cli]], {
|
|
234
|
+
stdout: "inherit",
|
|
235
|
+
stderr: "inherit",
|
|
236
|
+
});
|
|
237
|
+
const timeout = setTimeout(() => proc.kill(), 120_000);
|
|
238
|
+
const exitCode = await proc.exited;
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
return exitCode === 0;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
console.error(` Install error: ${e}`);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function runInteractiveLogin(cli: AgentCliName): Promise<boolean> {
|
|
248
|
+
const args = cli === "claude"
|
|
249
|
+
? ["setup-token"]
|
|
250
|
+
: ["login", "--device-auth"];
|
|
251
|
+
|
|
252
|
+
console.log(`Starting ${cli} login...`);
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const proc = Bun.spawn(buildBunWrappedAgentCliCommand(cli, args), {
|
|
256
|
+
stdin: "inherit",
|
|
257
|
+
stdout: "inherit",
|
|
258
|
+
stderr: "inherit",
|
|
259
|
+
});
|
|
260
|
+
const exitCode = await proc.exited;
|
|
261
|
+
|
|
262
|
+
if (exitCode === 0) {
|
|
263
|
+
console.log(` ✓ ${cli} login successful`);
|
|
264
|
+
return true;
|
|
265
|
+
} else {
|
|
266
|
+
console.log(` ✗ ${cli} login failed (exit ${exitCode})`);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
console.error(` Login error: ${e}`);
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
package/src/shared/config.ts
CHANGED
|
@@ -107,6 +107,8 @@ export const config = {
|
|
|
107
107
|
|
|
108
108
|
corePort: 51777,
|
|
109
109
|
daemonPort: 51778,
|
|
110
|
+
coreSocket: join(dataDir, "core.sock"),
|
|
111
|
+
daemonSocket: join(dataDir, "daemon.sock"),
|
|
110
112
|
|
|
111
113
|
// API keys - use async getters for first load
|
|
112
114
|
get telegramBotToken() { return secureConfig.telegramBotToken; },
|
package/src/shared/ports.ts
CHANGED
|
@@ -78,19 +78,37 @@ export function releaseProcess(name: string): void {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
*
|
|
81
|
+
* Remove a Unix socket file if it exists (stale leftover from crash).
|
|
82
|
+
*/
|
|
83
|
+
export function cleanupSocket(socketPath: string): void {
|
|
84
|
+
try { unlinkSync(socketPath); } catch {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Bun.serve() with retry — handles both TCP ports and Unix sockets.
|
|
89
|
+
* For Unix sockets, cleans up stale socket file before first attempt.
|
|
82
90
|
*/
|
|
83
91
|
export async function serveWithRetry(
|
|
84
92
|
options: Parameters<typeof Bun.serve>[0],
|
|
85
93
|
retries = 5,
|
|
86
94
|
): Promise<ReturnType<typeof Bun.serve>> {
|
|
95
|
+
const socketPath = (options as any).unix as string | undefined;
|
|
96
|
+
|
|
97
|
+
// Pre-clean stale Unix socket from a previous crash
|
|
98
|
+
if (socketPath) cleanupSocket(socketPath);
|
|
99
|
+
|
|
87
100
|
for (let i = 0; i < retries; i++) {
|
|
88
101
|
try {
|
|
89
102
|
return Bun.serve(options);
|
|
90
103
|
} catch (e: any) {
|
|
91
104
|
if (e?.code !== "EADDRINUSE" || i === retries - 1) throw e;
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
if (socketPath) {
|
|
106
|
+
console.log(`[ports] Socket ${socketPath} busy, cleaning up and retrying (${i + 1}/${retries})...`);
|
|
107
|
+
cleanupSocket(socketPath);
|
|
108
|
+
} else {
|
|
109
|
+
const port = (options as any).port ?? "?";
|
|
110
|
+
console.log(`[ports] Port ${port} busy, retrying (${i + 1}/${retries})...`);
|
|
111
|
+
}
|
|
94
112
|
await new Promise((r) => setTimeout(r, 1000));
|
|
95
113
|
}
|
|
96
114
|
}
|