arisa 2.3.16 → 2.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -7
- package/scripts/test-secrets.ts +22 -0
- package/src/core/attachments.ts +104 -0
- package/src/core/auth.ts +58 -0
- package/src/core/context.ts +30 -0
- package/src/core/file-detector.ts +39 -0
- package/src/core/format.ts +159 -0
- package/src/core/history.ts +193 -0
- package/src/core/index.ts +464 -0
- package/src/core/intent.ts +119 -0
- package/src/core/media.ts +144 -0
- package/src/core/onboarding.ts +102 -0
- package/src/core/processor.ts +309 -0
- package/src/core/router.ts +64 -0
- package/src/core/scheduler.ts +193 -0
- package/src/daemon/agent-cli.ts +129 -0
- package/src/daemon/auto-install.ts +148 -0
- package/src/daemon/autofix.ts +116 -0
- package/src/daemon/bridge.ts +166 -0
- package/src/daemon/channels/base.ts +10 -0
- package/src/daemon/channels/telegram.ts +306 -0
- package/src/daemon/claude-login.ts +215 -0
- package/src/daemon/codex-login.ts +172 -0
- package/src/daemon/fallback.ts +49 -0
- package/src/daemon/index.ts +262 -0
- package/src/daemon/lifecycle.ts +289 -0
- package/src/daemon/setup.ts +381 -0
- package/src/shared/ai-cli.ts +115 -0
- package/src/shared/config.ts +137 -0
- package/src/shared/db.ts +304 -0
- package/src/shared/deepbase-secure.ts +39 -0
- package/src/shared/ink-shim.js +7 -0
- package/src/shared/logger.ts +42 -0
- package/src/shared/paths.ts +90 -0
- package/src/shared/ports.ts +116 -0
- package/src/shared/secrets.ts +136 -0
- package/src/shared/types.ts +103 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module daemon/lifecycle
|
|
3
|
+
* @role Spawn and manage the Core process with --watch for hot reload.
|
|
4
|
+
* @responsibilities
|
|
5
|
+
* - Start Core as a child process with `bun --watch`
|
|
6
|
+
* - Capture stdout+stderr, detect errors in real-time
|
|
7
|
+
* - When errors detected: notify via Telegram, trigger autofix
|
|
8
|
+
* - Track Core state: starting → up → down
|
|
9
|
+
* - Health-check loop to detect when Core is ready
|
|
10
|
+
* @dependencies shared/config, daemon/autofix
|
|
11
|
+
* @effects Spawns child process, manages process lifecycle
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { config } from "../shared/config";
|
|
15
|
+
import { createLogger } from "../shared/logger";
|
|
16
|
+
import { attemptAutoFix } from "./autofix";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
|
|
19
|
+
const log = createLogger("daemon");
|
|
20
|
+
|
|
21
|
+
export type CoreState = "starting" | "up" | "down";
|
|
22
|
+
|
|
23
|
+
let coreProcess: ReturnType<typeof Bun.spawn> | null = null;
|
|
24
|
+
let shouldRun = true;
|
|
25
|
+
let coreState: CoreState = "down";
|
|
26
|
+
let lastError: string | null = null;
|
|
27
|
+
let crashCount = 0;
|
|
28
|
+
let lastCrashAt = 0;
|
|
29
|
+
let healthCheckTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
+
let autofixInProgress = false;
|
|
31
|
+
|
|
32
|
+
const BUF_MAX = 2000;
|
|
33
|
+
const HEALTH_CHECK_INTERVAL = 1000;
|
|
34
|
+
|
|
35
|
+
// Patterns that indicate real errors in Core STDERR output
|
|
36
|
+
const ERROR_PATTERNS = [
|
|
37
|
+
/error:/i,
|
|
38
|
+
/SyntaxError/,
|
|
39
|
+
/TypeError/,
|
|
40
|
+
/ReferenceError/,
|
|
41
|
+
/ENOENT/,
|
|
42
|
+
/EACCES/,
|
|
43
|
+
/JSON Parse error/,
|
|
44
|
+
/Cannot find module/,
|
|
45
|
+
/Module not found/,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// --- Notification callback (set by index.ts) ---
|
|
49
|
+
type NotifyFn = (text: string) => Promise<void>;
|
|
50
|
+
let notifyFn: NotifyFn | null = null;
|
|
51
|
+
|
|
52
|
+
export function setLifecycleNotify(fn: NotifyFn) {
|
|
53
|
+
notifyFn = fn;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- State getters ---
|
|
57
|
+
|
|
58
|
+
export function getCoreState(): CoreState {
|
|
59
|
+
return coreState;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getCoreError(): string | null {
|
|
63
|
+
return lastError;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function waitForCoreReady(timeoutMs: number): Promise<boolean> {
|
|
67
|
+
if (coreState === "up") return Promise.resolve(true);
|
|
68
|
+
if (coreState === "down") return Promise.resolve(false);
|
|
69
|
+
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const start = Date.now();
|
|
72
|
+
const check = setInterval(() => {
|
|
73
|
+
if (coreState === "up") {
|
|
74
|
+
clearInterval(check);
|
|
75
|
+
resolve(true);
|
|
76
|
+
} else if (coreState === "down" || Date.now() - start > timeoutMs) {
|
|
77
|
+
clearInterval(check);
|
|
78
|
+
resolve(false);
|
|
79
|
+
}
|
|
80
|
+
}, 500);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- Health check ---
|
|
85
|
+
|
|
86
|
+
function startHealthCheck() {
|
|
87
|
+
stopHealthCheck();
|
|
88
|
+
healthCheckTimer = setInterval(async () => {
|
|
89
|
+
if (coreState !== "starting") {
|
|
90
|
+
stopHealthCheck();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch("http://localhost/health", {
|
|
95
|
+
signal: AbortSignal.timeout(2000),
|
|
96
|
+
unix: config.coreSocket,
|
|
97
|
+
} as any);
|
|
98
|
+
if (res.ok) {
|
|
99
|
+
coreState = "up";
|
|
100
|
+
log.info("Core is ready (health check passed)");
|
|
101
|
+
stopHealthCheck();
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// Still starting
|
|
105
|
+
}
|
|
106
|
+
}, HEALTH_CHECK_INTERVAL);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stopHealthCheck() {
|
|
110
|
+
if (healthCheckTimer) {
|
|
111
|
+
clearInterval(healthCheckTimer);
|
|
112
|
+
healthCheckTimer = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Core process management ---
|
|
117
|
+
|
|
118
|
+
export function startCore() {
|
|
119
|
+
if (!shouldRun) return;
|
|
120
|
+
|
|
121
|
+
const coreEntry = join(config.projectDir, "src", "core", "index.ts");
|
|
122
|
+
log.info(`Starting Core: bun --watch ${coreEntry}`);
|
|
123
|
+
|
|
124
|
+
if (crashCount > 3) {
|
|
125
|
+
coreState = "down";
|
|
126
|
+
} else {
|
|
127
|
+
coreState = "starting";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Output buffers
|
|
131
|
+
const stdoutBuf = { data: "" };
|
|
132
|
+
const stderrBuf = { data: "" };
|
|
133
|
+
|
|
134
|
+
// Error detection state (per spawn — resets each time Core restarts)
|
|
135
|
+
let errorHandled = false;
|
|
136
|
+
let errorDebounce: ReturnType<typeof setTimeout> | null = null;
|
|
137
|
+
|
|
138
|
+
// Called when an error pattern is detected in the output stream
|
|
139
|
+
function onErrorDetected() {
|
|
140
|
+
if (errorHandled || autofixInProgress || !shouldRun) return;
|
|
141
|
+
errorHandled = true;
|
|
142
|
+
|
|
143
|
+
// Wait 3s for full stack trace to accumulate, then act
|
|
144
|
+
if (errorDebounce) clearTimeout(errorDebounce);
|
|
145
|
+
errorDebounce = setTimeout(() => {
|
|
146
|
+
const combined = (stderrBuf.data + "\n" + stdoutBuf.data).trim();
|
|
147
|
+
lastError = combined.slice(-BUF_MAX);
|
|
148
|
+
handleError(lastError);
|
|
149
|
+
}, 3000);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
coreProcess = Bun.spawn(["bun", "--watch", coreEntry], {
|
|
153
|
+
cwd: config.projectDir,
|
|
154
|
+
stdout: "pipe",
|
|
155
|
+
stderr: "pipe",
|
|
156
|
+
env: { ...process.env },
|
|
157
|
+
onExit(proc, exitCode, signalCode) {
|
|
158
|
+
log.warn(`Core exited (code=${exitCode}, signal=${signalCode})`);
|
|
159
|
+
coreProcess = null;
|
|
160
|
+
coreState = "down";
|
|
161
|
+
stopHealthCheck();
|
|
162
|
+
if (errorDebounce) clearTimeout(errorDebounce);
|
|
163
|
+
|
|
164
|
+
// Save last error
|
|
165
|
+
const combined = (stderrBuf.data + "\n" + stdoutBuf.data).trim();
|
|
166
|
+
if (combined) lastError = combined.slice(-BUF_MAX);
|
|
167
|
+
|
|
168
|
+
const now = Date.now();
|
|
169
|
+
if (now - lastCrashAt < 10_000) {
|
|
170
|
+
crashCount++;
|
|
171
|
+
} else {
|
|
172
|
+
crashCount = 1;
|
|
173
|
+
}
|
|
174
|
+
lastCrashAt = now;
|
|
175
|
+
|
|
176
|
+
if (!shouldRun) return;
|
|
177
|
+
|
|
178
|
+
// On 2nd+ rapid crash and error not yet handled: autofix
|
|
179
|
+
if (crashCount >= 2 && !autofixInProgress && !errorHandled) {
|
|
180
|
+
log.error(`Core crash loop (${crashCount}x). Triggering auto-fix...`);
|
|
181
|
+
errorHandled = true;
|
|
182
|
+
handleError(lastError || `Core crashed with exit code ${exitCode}`);
|
|
183
|
+
} else if (!autofixInProgress) {
|
|
184
|
+
log.info("Restarting Core in 2s...");
|
|
185
|
+
setTimeout(() => startCore(), 2000);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Capture streams: print to console + accumulate + detect errors
|
|
191
|
+
if (coreProcess.stdout && typeof coreProcess.stdout !== "number") {
|
|
192
|
+
pipeAndWatch(coreProcess.stdout, process.stdout, stdoutBuf, onErrorDetected, false);
|
|
193
|
+
}
|
|
194
|
+
if (coreProcess.stderr && typeof coreProcess.stderr !== "number") {
|
|
195
|
+
pipeAndWatch(coreProcess.stderr, process.stderr, stderrBuf, onErrorDetected, true);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (coreState === "starting") {
|
|
199
|
+
startHealthCheck();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
log.info(`Core spawned (pid=${coreProcess.pid})`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Pipe a stream to a target, accumulate into buffer, call onError when error patterns detected.
|
|
207
|
+
*/
|
|
208
|
+
function pipeAndWatch(
|
|
209
|
+
stream: ReadableStream<Uint8Array>,
|
|
210
|
+
target: NodeJS.WriteStream,
|
|
211
|
+
buf: { data: string },
|
|
212
|
+
onError: () => void,
|
|
213
|
+
watchErrors: boolean,
|
|
214
|
+
) {
|
|
215
|
+
const reader = stream.getReader();
|
|
216
|
+
const decoder = new TextDecoder();
|
|
217
|
+
|
|
218
|
+
(async () => {
|
|
219
|
+
try {
|
|
220
|
+
while (true) {
|
|
221
|
+
const { done, value } = await reader.read();
|
|
222
|
+
if (done) break;
|
|
223
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
224
|
+
target.write(chunk);
|
|
225
|
+
buf.data += chunk;
|
|
226
|
+
if (buf.data.length > BUF_MAX) {
|
|
227
|
+
buf.data = buf.data.slice(-BUF_MAX);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check for fatal/runtime-like patterns only when explicitly watching this stream.
|
|
231
|
+
if (watchErrors && ERROR_PATTERNS.some((p) => p.test(chunk))) {
|
|
232
|
+
onError();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// stream closed
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Central error handler: notify user via Telegram, then try autofix.
|
|
243
|
+
*/
|
|
244
|
+
async function handleError(error: string) {
|
|
245
|
+
autofixInProgress = true;
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
// 1. Notify immediately
|
|
249
|
+
const preview = error.length > 500 ? error.slice(-500) : error;
|
|
250
|
+
log.warn("Core error detected, notifying and attempting auto-fix...");
|
|
251
|
+
await notifyFn?.(
|
|
252
|
+
`Core error detected:\n<pre>${escapeHtml(preview)}</pre>\nAttempting auto-fix...`
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// 2. Run autofix
|
|
256
|
+
const fixed = await attemptAutoFix(error);
|
|
257
|
+
|
|
258
|
+
// 3. Notify result
|
|
259
|
+
if (fixed) {
|
|
260
|
+
await notifyFn?.("Auto-fix applied. Core will restart automatically.");
|
|
261
|
+
} else {
|
|
262
|
+
await notifyFn?.("Auto-fix could not resolve the error. Please check manually.");
|
|
263
|
+
}
|
|
264
|
+
} catch (err) {
|
|
265
|
+
log.error(`handleError threw: ${err}`);
|
|
266
|
+
} finally {
|
|
267
|
+
autofixInProgress = false;
|
|
268
|
+
// If Core exited while we were fixing, restart it
|
|
269
|
+
if (shouldRun && coreProcess === null) {
|
|
270
|
+
log.info("Restarting Core after auto-fix...");
|
|
271
|
+
startCore();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function escapeHtml(s: string): string {
|
|
277
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function stopCore() {
|
|
281
|
+
shouldRun = false;
|
|
282
|
+
stopHealthCheck();
|
|
283
|
+
if (coreProcess) {
|
|
284
|
+
log.info("Stopping Core...");
|
|
285
|
+
coreProcess.kill();
|
|
286
|
+
coreProcess = null;
|
|
287
|
+
}
|
|
288
|
+
coreState = "down";
|
|
289
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module daemon/setup
|
|
3
|
+
* @role Interactive first-run setup with inquirer prompts.
|
|
4
|
+
* @responsibilities
|
|
5
|
+
* - Check required config (TELEGRAM_BOT_TOKEN)
|
|
6
|
+
* - Check optional config (OPENAI_API_KEY)
|
|
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
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
15
|
+
import { dirname, join } from "path";
|
|
16
|
+
import { dataDir } from "../shared/paths";
|
|
17
|
+
import { secrets, setSecret } from "../shared/secrets";
|
|
18
|
+
import { isAgentCliInstalled, buildBunWrappedAgentCliCommand, isRunningAsRoot, type AgentCliName } from "../shared/ai-cli";
|
|
19
|
+
|
|
20
|
+
const ENV_PATH = join(dataDir, ".env");
|
|
21
|
+
const SETUP_DONE_KEY = "ARISA_SETUP_COMPLETE";
|
|
22
|
+
|
|
23
|
+
const CLI_PACKAGES: Record<AgentCliName, string> = {
|
|
24
|
+
claude: "@anthropic-ai/claude-code",
|
|
25
|
+
codex: "@openai/codex",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function loadExistingEnv(): Record<string, string> {
|
|
29
|
+
if (!existsSync(ENV_PATH)) return {};
|
|
30
|
+
const vars: Record<string, string> = {};
|
|
31
|
+
for (const line of readFileSync(ENV_PATH, "utf8").split("\n")) {
|
|
32
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.+)$/);
|
|
33
|
+
if (match) vars[match[1]] = match[2].trim();
|
|
34
|
+
}
|
|
35
|
+
return vars;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saveEnv(vars: Record<string, string>) {
|
|
39
|
+
const dir = dirname(ENV_PATH);
|
|
40
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
41
|
+
const content = Object.entries(vars)
|
|
42
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
43
|
+
.join("\n") + "\n";
|
|
44
|
+
writeFileSync(ENV_PATH, content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fallback readline for non-TTY environments
|
|
48
|
+
async function readLine(question: string): Promise<string> {
|
|
49
|
+
process.stdout.write(question);
|
|
50
|
+
for await (const line of console) {
|
|
51
|
+
return line.trim();
|
|
52
|
+
}
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function runSetup(): Promise<boolean> {
|
|
57
|
+
const vars = loadExistingEnv();
|
|
58
|
+
const telegramSecret = await secrets.telegram();
|
|
59
|
+
const openaiSecret = await secrets.openai();
|
|
60
|
+
let changed = false;
|
|
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");
|
|
81
|
+
|
|
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()) {
|
|
94
|
+
console.log("No token provided. Cannot start without Telegram Bot Token.");
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
vars.TELEGRAM_BOT_TOKEN = token.trim();
|
|
99
|
+
await setSecret("TELEGRAM_BOT_TOKEN", token.trim()).catch((e) =>
|
|
100
|
+
console.warn(`[setup] Could not persist TELEGRAM_BOT_TOKEN to encrypted DB: ${e}`)
|
|
101
|
+
);
|
|
102
|
+
console.log("[setup] TELEGRAM_BOT_TOKEN saved to .env + encrypted DB");
|
|
103
|
+
changed = true;
|
|
104
|
+
} else {
|
|
105
|
+
const src = telegramSecret ? "encrypted DB" : vars.TELEGRAM_BOT_TOKEN ? ".env" : "env var";
|
|
106
|
+
console.log(`[setup] TELEGRAM_BOT_TOKEN found in ${src}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
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) =>
|
|
123
|
+
console.warn(`[setup] Could not persist OPENAI_API_KEY to encrypted DB: ${e}`)
|
|
124
|
+
);
|
|
125
|
+
console.log("[setup] OPENAI_API_KEY saved to .env + encrypted DB");
|
|
126
|
+
changed = true;
|
|
127
|
+
}
|
|
128
|
+
} else if (hasOpenAI) {
|
|
129
|
+
const src = openaiSecret ? "encrypted DB" : vars.OPENAI_API_KEY ? ".env" : "env var";
|
|
130
|
+
console.log(`[setup] OPENAI_API_KEY found in ${src}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Save tokens
|
|
134
|
+
if (!setupDone) {
|
|
135
|
+
vars[SETUP_DONE_KEY] = "1";
|
|
136
|
+
changed = true;
|
|
137
|
+
}
|
|
138
|
+
if (changed) {
|
|
139
|
+
saveEnv(vars);
|
|
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, vars);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function setupClis(inq: typeof import("@inquirer/prompts") | null, vars: Record<string, string>) {
|
|
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", vars);
|
|
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", vars);
|
|
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 cmd = isRunningAsRoot()
|
|
234
|
+
? ["su", "-", "arisa", "-c", `export BUN_INSTALL=/home/arisa/.bun && export PATH=/home/arisa/.bun/bin:$PATH && bun add -g ${CLI_PACKAGES[cli]}`]
|
|
235
|
+
: ["bun", "add", "-g", CLI_PACKAGES[cli]];
|
|
236
|
+
const proc = Bun.spawn(cmd, {
|
|
237
|
+
stdout: "inherit",
|
|
238
|
+
stderr: "inherit",
|
|
239
|
+
});
|
|
240
|
+
const timeout = setTimeout(() => proc.kill(), 180_000);
|
|
241
|
+
const exitCode = await proc.exited;
|
|
242
|
+
clearTimeout(timeout);
|
|
243
|
+
return exitCode === 0;
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.error(` Install error: ${e}`);
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runInteractiveLogin(cli: AgentCliName, vars: Record<string, string>): Promise<boolean> {
|
|
251
|
+
const args = cli === "claude"
|
|
252
|
+
? ["setup-token"]
|
|
253
|
+
: ["login", "--device-auth"];
|
|
254
|
+
|
|
255
|
+
console.log(`Starting ${cli} login...`);
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
// For claude: capture stdout to extract OAuth token while still showing output
|
|
259
|
+
if (cli === "claude") {
|
|
260
|
+
const proc = Bun.spawn(buildBunWrappedAgentCliCommand(cli, args), {
|
|
261
|
+
stdin: "inherit",
|
|
262
|
+
stdout: "pipe",
|
|
263
|
+
stderr: "inherit",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
let output = "";
|
|
267
|
+
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
268
|
+
const decoder = new TextDecoder();
|
|
269
|
+
while (true) {
|
|
270
|
+
const { done, value } = await reader.read();
|
|
271
|
+
if (done) break;
|
|
272
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
273
|
+
process.stdout.write(chunk);
|
|
274
|
+
output += chunk;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const exitCode = await proc.exited;
|
|
278
|
+
if (exitCode === 0) {
|
|
279
|
+
// Strip ANSI with a state machine (regex can't handle all Ink sequences)
|
|
280
|
+
function stripAnsi(s: string): string {
|
|
281
|
+
let out = "";
|
|
282
|
+
for (let i = 0; i < s.length; i++) {
|
|
283
|
+
if (s.charCodeAt(i) === 0x1b) {
|
|
284
|
+
i++;
|
|
285
|
+
if (i >= s.length) break;
|
|
286
|
+
if (s[i] === "[") {
|
|
287
|
+
// CSI: ESC [ <params 0x20-0x3F>* <final 0x40-0x7E>
|
|
288
|
+
i++;
|
|
289
|
+
while (i < s.length && s.charCodeAt(i) < 0x40) i++;
|
|
290
|
+
// i now on final byte, loop will i++
|
|
291
|
+
} else if (s[i] === "]") {
|
|
292
|
+
// OSC: ESC ] ... BEL(0x07) or ST(ESC \)
|
|
293
|
+
i++;
|
|
294
|
+
while (i < s.length && s.charCodeAt(i) !== 0x07 && s[i] !== "\x1b") i++;
|
|
295
|
+
} else if (s[i] === "(" || s[i] === ")" || s[i] === "#") {
|
|
296
|
+
i++; // skip designator byte
|
|
297
|
+
}
|
|
298
|
+
// else: 2-byte Fe sequence, already skipped
|
|
299
|
+
} else if (s.charCodeAt(i) < 0x20 && s[i] !== "\n" && s[i] !== "\r") {
|
|
300
|
+
// skip control chars
|
|
301
|
+
} else {
|
|
302
|
+
out += s[i];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const clean = stripAnsi(output);
|
|
309
|
+
const startIdx = clean.indexOf("sk-ant-");
|
|
310
|
+
let token = "";
|
|
311
|
+
|
|
312
|
+
if (startIdx >= 0) {
|
|
313
|
+
let endIdx = clean.indexOf("Store", startIdx);
|
|
314
|
+
if (endIdx < 0) endIdx = clean.indexOf("Use this", startIdx);
|
|
315
|
+
if (endIdx < 0) endIdx = startIdx + 200;
|
|
316
|
+
|
|
317
|
+
const tokenArea = clean.substring(startIdx, endIdx);
|
|
318
|
+
token = tokenArea.replace(/[^A-Za-z0-9_-]/g, "");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (token && token.startsWith("sk-ant-") && token.length > 50 && token.length < 150) {
|
|
322
|
+
console.log(` [token] ${token.slice(0, 20)}...${token.slice(-6)} (${token.length} chars)`);
|
|
323
|
+
vars.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
324
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
325
|
+
saveEnv(vars);
|
|
326
|
+
console.log(" ✓ claude token saved to .env");
|
|
327
|
+
|
|
328
|
+
// Also write credentials file for arisa user (belt + suspenders)
|
|
329
|
+
const claudeDir = isRunningAsRoot() ? "/home/arisa/.claude" : join(process.env.HOME || "~", ".claude");
|
|
330
|
+
try {
|
|
331
|
+
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
|
332
|
+
const credsPath = join(claudeDir, ".credentials.json");
|
|
333
|
+
const creds = {
|
|
334
|
+
claudeAiOauth: {
|
|
335
|
+
accessToken: token,
|
|
336
|
+
expiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
337
|
+
scopes: ["user:inference", "user:profile"],
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
writeFileSync(credsPath, JSON.stringify(creds, null, 2) + "\n");
|
|
341
|
+
if (isRunningAsRoot()) {
|
|
342
|
+
Bun.spawnSync(["chown", "-R", "arisa:arisa", claudeDir]);
|
|
343
|
+
}
|
|
344
|
+
console.log(` ✓ credentials written to ${credsPath}`);
|
|
345
|
+
} catch (e) {
|
|
346
|
+
console.log(` ⚠ could not write credentials file: ${e}`);
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
console.log(` ⚠ token extraction failed (indexOf=${startIdx}, len=${token.length})`);
|
|
350
|
+
if (startIdx >= 0) {
|
|
351
|
+
console.log(` [clean] ${clean.substring(startIdx, startIdx + 150).replace(/\n/g, "\\n")}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
console.log(` ✓ claude login successful`);
|
|
355
|
+
return true;
|
|
356
|
+
} else {
|
|
357
|
+
console.log(` ✗ claude login failed (exit ${exitCode})`);
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// For codex and others: inherit all stdio
|
|
363
|
+
const proc = Bun.spawn(buildBunWrappedAgentCliCommand(cli, args), {
|
|
364
|
+
stdin: "inherit",
|
|
365
|
+
stdout: "inherit",
|
|
366
|
+
stderr: "inherit",
|
|
367
|
+
});
|
|
368
|
+
const exitCode = await proc.exited;
|
|
369
|
+
|
|
370
|
+
if (exitCode === 0) {
|
|
371
|
+
console.log(` ✓ ${cli} login successful`);
|
|
372
|
+
return true;
|
|
373
|
+
} else {
|
|
374
|
+
console.log(` ✗ ${cli} login failed (exit ${exitCode})`);
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
} catch (e) {
|
|
378
|
+
console.error(` Login error: ${e}`);
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|