arisa 4.3.5 → 5.1.2
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/AGENTS.md +21 -19
- package/README.md +30 -9
- package/package.json +6 -2
- package/pnpm-workspace.yaml +1 -0
- package/src/core/agent/agent-manager.js +288 -29
- package/src/core/agent/auth-flow.js +12 -8
- package/src/core/agent/model-selection.js +54 -14
- package/src/core/agent/model-speed.js +59 -0
- package/src/core/config/config-defaults.js +56 -4
- package/src/core/config/config-store.js +5 -1
- package/src/core/conversation/conversation-history-store.js +142 -0
- package/src/core/tasks/task-store.js +16 -0
- package/src/core/tools/daemon-health.js +11 -2
- package/src/core/tools/daemon-processes.js +92 -2
- package/src/core/tools/daemon-runtime.js +4 -2
- package/src/core/tools/ipc-client.js +15 -3
- package/src/core/tools/tool-registry.js +42 -2
- package/src/core/tools/tool-usage-store.js +59 -0
- package/src/index.js +61 -6
- package/src/runtime/arisa-capabilities.js +45 -1
- package/src/runtime/bootstrap.js +3 -2
- package/src/runtime/create-app.js +49 -11
- package/src/runtime/doctor.js +365 -0
- package/src/runtime/log-viewer.js +165 -0
- package/src/runtime/paths.js +8 -1
- package/src/runtime/report-format.js +51 -0
- package/src/runtime/service-manager.js +106 -8
- package/src/runtime/tool-process-supervisor.js +107 -10
- package/src/runtime/tool-usage-report.js +10 -0
- package/src/runtime/update-manager.js +206 -0
- package/src/transport/telegram/bot.js +633 -91
- package/src/transport/telegram/model-picker.js +28 -2
- package/test/agent-tool-policy.test.js +26 -1
- package/test/auth-flow.test.js +28 -2
- package/test/capabilities-security.test.js +37 -0
- package/test/context-and-task-bounds.test.js +280 -0
- package/test/daemon-runtime.test.js +130 -2
- package/test/dependency-warnings.test.js +17 -0
- package/test/doctor.test.js +111 -0
- package/test/log-viewer.test.js +90 -0
- package/test/model-selection.test.js +125 -2
- package/test/paths.test.js +16 -0
- package/test/pi-compaction.test.js +43 -0
- package/test/service-manager.test.js +237 -0
- package/test/task-store.test.js +31 -0
- package/test/telegram-text-artifact.test.js +36 -1
- package/test/tool-registry-run.test.js +21 -0
- package/test/tool-usage.test.js +37 -0
- package/test/update-manager.test.js +87 -0
|
@@ -4,14 +4,24 @@ import process from "node:process";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { ensureArisaHome, serviceLogFile, servicePidFile } from "./paths.js";
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
export const serviceEntryFile = fileURLToPath(new URL("../index.js", import.meta.url));
|
|
8
8
|
|
|
9
9
|
function isProcessRunning(pid) {
|
|
10
10
|
try {
|
|
11
11
|
process.kill(pid, 0);
|
|
12
12
|
return true;
|
|
13
|
-
} catch {
|
|
14
|
-
return
|
|
13
|
+
} catch (error) {
|
|
14
|
+
return error?.code !== "ESRCH";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sleep(ms) {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requirePositiveTiming(value, name) {
|
|
23
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
24
|
+
throw new Error(`Service restart requires a positive ${name}`);
|
|
15
25
|
}
|
|
16
26
|
}
|
|
17
27
|
|
|
@@ -19,7 +29,7 @@ async function readPid() {
|
|
|
19
29
|
try {
|
|
20
30
|
const raw = await readFile(servicePidFile, "utf8");
|
|
21
31
|
const pid = Number.parseInt(raw.trim(), 10);
|
|
22
|
-
return Number.
|
|
32
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
23
33
|
} catch {
|
|
24
34
|
return null;
|
|
25
35
|
}
|
|
@@ -44,7 +54,7 @@ export async function startService({ verbose = true, cliArgs = [] } = {}) {
|
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
const logHandle = await open(serviceLogFile, "a");
|
|
47
|
-
const args = [
|
|
57
|
+
const args = [serviceEntryFile, "--service-runner", ...cliArgs];
|
|
48
58
|
if (!verbose) args.push("--silent");
|
|
49
59
|
|
|
50
60
|
const child = spawn(process.execPath, args, {
|
|
@@ -58,6 +68,36 @@ export async function startService({ verbose = true, cliArgs = [] } = {}) {
|
|
|
58
68
|
return { ok: true, pid: child.pid, logFile: serviceLogFile };
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
export async function handoffServiceRestart({ verbose = true, cliArgs = [] } = {}, {
|
|
72
|
+
ensureHome = ensureArisaHome,
|
|
73
|
+
getStatus = getServiceStatus,
|
|
74
|
+
openLog = open,
|
|
75
|
+
spawnProcess = spawn,
|
|
76
|
+
environment = process.env,
|
|
77
|
+
currentPid = process.pid
|
|
78
|
+
} = {}) {
|
|
79
|
+
await ensureHome();
|
|
80
|
+
const status = await getStatus();
|
|
81
|
+
if (!status.running || status.pid !== currentPid) {
|
|
82
|
+
throw new Error("Service restart handoff requires the active background service process");
|
|
83
|
+
}
|
|
84
|
+
const logHandle = await openLog(serviceLogFile, "a");
|
|
85
|
+
const args = [serviceEntryFile, "restart", ...cliArgs];
|
|
86
|
+
if (!verbose) args.push("--silent");
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const child = spawnProcess(process.execPath, args, {
|
|
90
|
+
detached: true,
|
|
91
|
+
stdio: ["ignore", logHandle.fd, logHandle.fd],
|
|
92
|
+
env: environment
|
|
93
|
+
});
|
|
94
|
+
child.unref();
|
|
95
|
+
return { ok: true, pid: child.pid, logFile: serviceLogFile };
|
|
96
|
+
} finally {
|
|
97
|
+
await logHandle.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
61
101
|
export async function stopService() {
|
|
62
102
|
const status = await getServiceStatus();
|
|
63
103
|
if (!status.running) {
|
|
@@ -66,14 +106,72 @@ export async function stopService() {
|
|
|
66
106
|
|
|
67
107
|
try {
|
|
68
108
|
process.kill(status.pid, "SIGTERM");
|
|
69
|
-
} catch {
|
|
70
|
-
|
|
71
|
-
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error?.code === "ESRCH") {
|
|
111
|
+
await rm(servicePidFile, { force: true }).catch(() => {});
|
|
112
|
+
return { ok: false, reason: "not-running", pid: status.pid };
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
72
115
|
}
|
|
73
116
|
|
|
74
117
|
return { ok: true, pid: status.pid };
|
|
75
118
|
}
|
|
76
119
|
|
|
120
|
+
export async function waitForServiceStop({
|
|
121
|
+
pid,
|
|
122
|
+
timeoutMs,
|
|
123
|
+
pollIntervalMs,
|
|
124
|
+
getStatus = getServiceStatus,
|
|
125
|
+
wait = sleep
|
|
126
|
+
}) {
|
|
127
|
+
requirePositiveTiming(timeoutMs, "shutdownTimeoutMs");
|
|
128
|
+
requirePositiveTiming(pollIntervalMs, "shutdownPollIntervalMs");
|
|
129
|
+
|
|
130
|
+
const startedAt = Date.now();
|
|
131
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
132
|
+
const status = await getStatus();
|
|
133
|
+
if (!status.running || status.pid !== pid) return;
|
|
134
|
+
await wait(pollIntervalMs);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
throw new Error(`Arisa process ${pid} did not stop within ${timeoutMs}ms`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function restartService({
|
|
141
|
+
verbose = true,
|
|
142
|
+
cliArgs = [],
|
|
143
|
+
shutdownTimeoutMs,
|
|
144
|
+
shutdownPollIntervalMs
|
|
145
|
+
} = {}, {
|
|
146
|
+
stop = stopService,
|
|
147
|
+
getStatus = getServiceStatus,
|
|
148
|
+
start = startService,
|
|
149
|
+
sleep: wait = sleep
|
|
150
|
+
} = {}) {
|
|
151
|
+
requirePositiveTiming(shutdownTimeoutMs, "shutdownTimeoutMs");
|
|
152
|
+
requirePositiveTiming(shutdownPollIntervalMs, "shutdownPollIntervalMs");
|
|
153
|
+
|
|
154
|
+
const stopped = await stop();
|
|
155
|
+
if (!stopped.ok && stopped.reason !== "not-running") return stopped;
|
|
156
|
+
|
|
157
|
+
if (stopped.ok) {
|
|
158
|
+
await waitForServiceStop({
|
|
159
|
+
pid: stopped.pid,
|
|
160
|
+
timeoutMs: shutdownTimeoutMs,
|
|
161
|
+
pollIntervalMs: shutdownPollIntervalMs,
|
|
162
|
+
getStatus,
|
|
163
|
+
wait
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const started = await start({ verbose, cliArgs });
|
|
168
|
+
return {
|
|
169
|
+
...started,
|
|
170
|
+
previousPid: stopped.ok ? stopped.pid : null,
|
|
171
|
+
wasRunning: stopped.ok
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
77
175
|
export async function unregisterServiceProcess() {
|
|
78
176
|
await rm(servicePidFile, { force: true }).catch(() => {});
|
|
79
177
|
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { access } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { superviseDaemon } from "../core/tools/daemon-health.js";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
daemonPaths,
|
|
6
|
+
listRegisteredDaemons,
|
|
7
|
+
readDaemonDiagnostic,
|
|
8
|
+
writeDaemonStatus
|
|
9
|
+
} from "../core/tools/daemon-processes.js";
|
|
4
10
|
import { loadDaemonPolicy } from "../core/tools/daemon-policy.js";
|
|
5
11
|
import { ensureArisaHome } from "./paths.js";
|
|
6
12
|
|
|
@@ -13,41 +19,127 @@ async function fileExists(file) {
|
|
|
13
19
|
}
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
|
|
22
|
+
function oneLine(value) {
|
|
23
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatDaemonOutcome(record, outcome, diagnostic, policy) {
|
|
27
|
+
const details = [`${record.toolName} (${record.instanceId || "global"}): ${outcome}`];
|
|
28
|
+
if (diagnostic.lastError?.message) {
|
|
29
|
+
details.push(`error[${diagnostic.lastError.phase || "unknown"}]=${oneLine(diagnostic.lastError.message)}`);
|
|
30
|
+
if (diagnostic.lastError.code) details.push(`code=${diagnostic.lastError.code}`);
|
|
31
|
+
} else if (diagnostic.message) {
|
|
32
|
+
details.push(`message=${oneLine(diagnostic.message)}`);
|
|
33
|
+
}
|
|
34
|
+
if (diagnostic.restart.attempts > 0) {
|
|
35
|
+
details.push(`restarts=${diagnostic.restart.attempts}/${policy.restartLimit}`);
|
|
36
|
+
}
|
|
37
|
+
if (diagnostic.restart.nextAt) details.push(`next=${diagnostic.restart.nextAt}`);
|
|
38
|
+
details.push(`action=${diagnostic.disposition}`);
|
|
39
|
+
if (diagnostic.disposition === "requires-attention") details.push(`log=${diagnostic.logFile}`);
|
|
40
|
+
return details.join(" | ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateRegistration(record, toolRegistry) {
|
|
44
|
+
if (!toolRegistry) return { valid: true, record };
|
|
45
|
+
const tool = toolRegistry.get(record.toolName);
|
|
46
|
+
if (!tool) return { valid: false, reason: "tool is no longer installed" };
|
|
47
|
+
if (!tool.daemon) return { valid: false, reason: "tool no longer declares a daemon" };
|
|
48
|
+
const expectedScope = tool.daemon.scope === "chat" ? "chat" : "global";
|
|
49
|
+
if (record.scope?.type !== expectedScope) {
|
|
50
|
+
return { valid: false, reason: `registered ${record.scope?.type || "unknown"} scope does not match manifest ${expectedScope} scope` };
|
|
51
|
+
}
|
|
52
|
+
if (path.resolve(record.entryPath) !== path.resolve(tool.entry)) {
|
|
53
|
+
return { valid: false, reason: "registered entry does not match the installed tool" };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
valid: true,
|
|
57
|
+
record: { ...record, autoStart: Boolean(tool.daemon.autoStart) }
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createToolProcessSupervisor({ logger, policy, toolRegistry } = {}) {
|
|
17
62
|
let running = false;
|
|
18
63
|
let timer = null;
|
|
19
64
|
let reconciliation = null;
|
|
20
65
|
let daemonPolicy = policy;
|
|
66
|
+
const reportedDiagnostics = new Map();
|
|
21
67
|
|
|
22
68
|
function reportLoopError(error) {
|
|
23
69
|
logger?.error?.("tools", `daemon supervisor loop failed: ${error?.message || error}`);
|
|
24
70
|
}
|
|
25
71
|
|
|
26
|
-
async function reconcileDaemons() {
|
|
72
|
+
async function reconcileDaemons({ repairFailed = false } = {}) {
|
|
27
73
|
await ensureArisaHome();
|
|
28
|
-
|
|
74
|
+
const results = [];
|
|
75
|
+
for (const registeredRecord of await listRegisteredDaemons()) {
|
|
76
|
+
const validation = validateRegistration(registeredRecord, toolRegistry);
|
|
77
|
+
if (!validation.valid) {
|
|
78
|
+
logger?.log("tools", `skipping stale daemon registration ${registeredRecord.toolName} (${registeredRecord.instanceId || "global"}): ${validation.reason}`);
|
|
79
|
+
results.push({
|
|
80
|
+
record: registeredRecord,
|
|
81
|
+
outcome: "stale-registration",
|
|
82
|
+
reason: validation.reason,
|
|
83
|
+
diagnostic: await readDaemonDiagnostic(registeredRecord)
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const record = validation.record;
|
|
29
88
|
if (!(await fileExists(record.entryPath))) {
|
|
30
89
|
logger?.log("tools", `skipping daemon ${record.toolName}: missing entry ${record.entryPath}`);
|
|
90
|
+
results.push({ record, outcome: "missing-entry", diagnostic: await readDaemonDiagnostic(record) });
|
|
31
91
|
continue;
|
|
32
92
|
}
|
|
33
93
|
try {
|
|
94
|
+
const current = await readDaemonDiagnostic(record);
|
|
95
|
+
if (repairFailed && current.state === "failed") {
|
|
96
|
+
await writeDaemonStatus(daemonPaths(record), {
|
|
97
|
+
state: "restarting",
|
|
98
|
+
pid: null,
|
|
99
|
+
heartbeatAt: null,
|
|
100
|
+
restartAttempts: 0,
|
|
101
|
+
restartRequested: true,
|
|
102
|
+
nextRestartAt: null,
|
|
103
|
+
message: "Manual repair requested by /doctor"
|
|
104
|
+
});
|
|
105
|
+
}
|
|
34
106
|
const outcome = await superviseDaemon(record, daemonPolicy);
|
|
35
|
-
|
|
36
|
-
|
|
107
|
+
const key = `${record.toolName}:${record.instanceId || "global"}`;
|
|
108
|
+
if (outcome === "healthy") {
|
|
109
|
+
reportedDiagnostics.delete(key);
|
|
110
|
+
results.push({ record, outcome, diagnostic: await readDaemonDiagnostic(record) });
|
|
111
|
+
continue;
|
|
37
112
|
}
|
|
113
|
+
const diagnostic = await readDaemonDiagnostic(record);
|
|
114
|
+
results.push({ record, outcome, diagnostic });
|
|
115
|
+
const message = formatDaemonOutcome(record, outcome, diagnostic, daemonPolicy);
|
|
116
|
+
if (reportedDiagnostics.get(key) === message) continue;
|
|
117
|
+
reportedDiagnostics.set(key, message);
|
|
118
|
+
logger?.log("tools", message);
|
|
38
119
|
} catch (error) {
|
|
39
120
|
logger?.error?.("tools", `daemon supervision failed for ${record.toolName}: ${error?.message || error}`);
|
|
121
|
+
results.push({ record, outcome: "error", error: error?.message || String(error) });
|
|
40
122
|
}
|
|
41
123
|
}
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function runReconciliation(options) {
|
|
128
|
+
while (reconciliation) await reconciliation.catch(() => {});
|
|
129
|
+
const current = reconcileDaemons(options);
|
|
130
|
+
reconciliation = current;
|
|
131
|
+
try {
|
|
132
|
+
return await current;
|
|
133
|
+
} finally {
|
|
134
|
+
if (reconciliation === current) reconciliation = null;
|
|
135
|
+
}
|
|
42
136
|
}
|
|
43
137
|
|
|
44
138
|
async function runLoop() {
|
|
45
|
-
if (!running
|
|
46
|
-
reconciliation = reconcileDaemons();
|
|
139
|
+
if (!running) return;
|
|
47
140
|
try {
|
|
48
|
-
await
|
|
141
|
+
await runReconciliation();
|
|
49
142
|
} finally {
|
|
50
|
-
reconciliation = null;
|
|
51
143
|
if (running) {
|
|
52
144
|
timer = setTimeout(() => {
|
|
53
145
|
runLoop().catch(reportLoopError);
|
|
@@ -70,6 +162,11 @@ export function createToolProcessSupervisor({ logger, policy } = {}) {
|
|
|
70
162
|
clearTimeout(timer);
|
|
71
163
|
timer = null;
|
|
72
164
|
await reconciliation?.catch(() => {});
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async repair() {
|
|
168
|
+
daemonPolicy ||= await loadDaemonPolicy();
|
|
169
|
+
return runReconciliation({ repairFailed: true });
|
|
73
170
|
}
|
|
74
171
|
};
|
|
75
172
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { renderTextReport, reportRow } from "./report-format.js";
|
|
2
|
+
|
|
3
|
+
export function formatToolUsageReport(tools) {
|
|
4
|
+
const lines = ["Arisa tools", "===========", "Usage count"];
|
|
5
|
+
if (!tools.length) lines.push(" (none installed)");
|
|
6
|
+
for (const tool of tools) {
|
|
7
|
+
lines.push(...reportRow(tool.name, tool.count, { labelWidth: 24 }));
|
|
8
|
+
}
|
|
9
|
+
return renderTextReport(lines);
|
|
10
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, cp, mkdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { arisaPackageDir, getToolDir } from "./paths.js";
|
|
6
|
+
import { renderTextReport, reportRow, wrapReportText } from "./report-format.js";
|
|
7
|
+
|
|
8
|
+
const defaultRepoUrl = "https://github.com/clasen/Arisa.git";
|
|
9
|
+
const defaultBranch = "main";
|
|
10
|
+
const bootstrapToolNames = ["trash", "official-tool-sync"];
|
|
11
|
+
|
|
12
|
+
function exists(target) {
|
|
13
|
+
return access(target).then(() => true, () => false);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function runCommand(command, args, { cwd, timeoutMs = 300_000, env = process.env } = {}) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
19
|
+
let stdout = "";
|
|
20
|
+
let stderr = "";
|
|
21
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
22
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); });
|
|
23
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
|
|
24
|
+
child.once("error", (error) => { clearTimeout(timer); reject(error); });
|
|
25
|
+
child.once("close", (code, signal) => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
if (code === 0) resolve({ stdout, stderr });
|
|
28
|
+
else reject(new Error(`${command} ${args.join(" ")} failed (${signal || code}): ${(stderr || stdout).trim().slice(-2000)}`));
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseSemver(value) {
|
|
34
|
+
const match = String(value || "").trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
|
|
35
|
+
return match ? match.slice(1).map(Number) : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function compareVersions(left, right) {
|
|
39
|
+
const a = parseSemver(left);
|
|
40
|
+
const b = parseSemver(right);
|
|
41
|
+
if (!a || !b) return null;
|
|
42
|
+
for (let index = 0; index < 3; index += 1) {
|
|
43
|
+
if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readCurrentVersion() {
|
|
49
|
+
const packageJson = JSON.parse(await readFile(path.join(arisaPackageDir, "package.json"), "utf8"));
|
|
50
|
+
return packageJson.version;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fetchLatestVersion() {
|
|
54
|
+
const { stdout } = await runCommand("npm", ["view", "arisa", "version", "--json"], { timeoutMs: 60_000 });
|
|
55
|
+
const parsed = JSON.parse(stdout);
|
|
56
|
+
const version = Array.isArray(parsed) ? parsed.at(-1) : parsed;
|
|
57
|
+
if (!parseSemver(version)) throw new Error(`npm returned an invalid Arisa version: ${version}`);
|
|
58
|
+
return version;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function cloneCatalog(scratchRoot) {
|
|
62
|
+
const repoDir = path.join(scratchRoot, "repo");
|
|
63
|
+
await runCommand("git", ["clone", "--depth", "1", "--branch", defaultBranch, "--", defaultRepoUrl, repoDir], { cwd: scratchRoot, timeoutMs: 180_000 });
|
|
64
|
+
return repoDir;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function installDependencies(toolDir) {
|
|
68
|
+
if (!(await exists(path.join(toolDir, "package.json")))) return null;
|
|
69
|
+
try {
|
|
70
|
+
await runCommand("pnpm", ["install", "--lockfile=false"], { cwd: toolDir, timeoutMs: 300_000 });
|
|
71
|
+
return "pnpm";
|
|
72
|
+
} catch {
|
|
73
|
+
await runCommand("npm", ["install", "--no-package-lock"], { cwd: toolDir, timeoutMs: 300_000 });
|
|
74
|
+
return "npm";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function validateTool(toolDir, expectedName) {
|
|
79
|
+
const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
|
|
80
|
+
if (manifest.name !== expectedName) throw new Error(`Bootstrap manifest mismatch for ${expectedName}`);
|
|
81
|
+
const entry = manifest.entry || "index.js";
|
|
82
|
+
const env = { ...process.env, ARISA_PACKAGE_DIR: arisaPackageDir };
|
|
83
|
+
await runCommand(process.execPath, ["--check", entry], { cwd: toolDir, timeoutMs: 30_000, env });
|
|
84
|
+
await runCommand(process.execPath, [entry, "--help"], { cwd: toolDir, timeoutMs: 30_000, env });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function stageBootstrapTool(repoDir, scratchRoot, name) {
|
|
88
|
+
const sourceDir = path.join(repoDir, "tools", name);
|
|
89
|
+
const stageDir = path.join(scratchRoot, `stage-${name}`);
|
|
90
|
+
if (!(await exists(path.join(sourceDir, "tool.manifest.json")))) throw new Error(`Official catalog is missing required bootstrap tool: ${name}`);
|
|
91
|
+
await cp(sourceDir, stageDir, { recursive: true });
|
|
92
|
+
await installDependencies(stageDir);
|
|
93
|
+
await validateTool(stageDir, name);
|
|
94
|
+
return { name, stageDir, destination: getToolDir(name) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function ensureOfficialUpdateTools({ toolRegistry }) {
|
|
98
|
+
const missing = bootstrapToolNames.filter((name) => !toolRegistry.get(name));
|
|
99
|
+
if (!missing.length) return { installed: [] };
|
|
100
|
+
const scratchRoot = path.join(os.tmpdir(), `arisa-update-${process.pid}-${Date.now()}`);
|
|
101
|
+
await mkdir(scratchRoot, { recursive: true });
|
|
102
|
+
const deployed = [];
|
|
103
|
+
try {
|
|
104
|
+
const repoDir = await cloneCatalog(scratchRoot);
|
|
105
|
+
const staged = [];
|
|
106
|
+
for (const name of missing) staged.push(await stageBootstrapTool(repoDir, scratchRoot, name));
|
|
107
|
+
for (const item of staged) {
|
|
108
|
+
await mkdir(path.dirname(item.destination), { recursive: true });
|
|
109
|
+
await cp(item.stageDir, item.destination, { recursive: true, errorOnExist: true, force: false });
|
|
110
|
+
deployed.push(item.destination);
|
|
111
|
+
}
|
|
112
|
+
await toolRegistry.load();
|
|
113
|
+
return { installed: staged.map((item) => item.name) };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
for (const destination of deployed.reverse()) await rm(destination, { recursive: true, force: true }).catch(() => {});
|
|
116
|
+
await toolRegistry.load().catch(() => {});
|
|
117
|
+
throw error;
|
|
118
|
+
} finally {
|
|
119
|
+
await rm(scratchRoot, { recursive: true, force: true }).catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseToolSyncOutput(result) {
|
|
124
|
+
if (!result?.ok) throw new Error(result?.error || "official-tool-sync failed");
|
|
125
|
+
const text = result.output?.text;
|
|
126
|
+
if (typeof text !== "string") return result.output?.json || {};
|
|
127
|
+
return JSON.parse(text);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function summarizeTools(sync, installedTools) {
|
|
131
|
+
const tools = sync.tools || [];
|
|
132
|
+
const officialNames = new Set(tools.map((tool) => tool.name));
|
|
133
|
+
const counts = {};
|
|
134
|
+
for (const tool of tools) counts[tool.status] = (counts[tool.status] || 0) + 1;
|
|
135
|
+
const updateable = tools.filter((tool) => tool.safeToUpdate && !["up-to-date", "baseline-refresh"].includes(tool.status));
|
|
136
|
+
const blocked = tools.filter((tool) => !tool.safeToUpdate && !["up-to-date", "baseline-refresh"].includes(tool.status));
|
|
137
|
+
return {
|
|
138
|
+
installedOfficial: sync.installedOfficialCount || tools.length,
|
|
139
|
+
official: tools.map(({ name, status }) => ({ name, status })).sort((left, right) => left.name.localeCompare(right.name)),
|
|
140
|
+
nonOfficial: installedTools.map((tool) => tool.name).filter((name) => !officialNames.has(name)).sort(),
|
|
141
|
+
counts,
|
|
142
|
+
updateable: updateable.map((tool) => tool.name),
|
|
143
|
+
blocked: blocked.map((tool) => ({ name: tool.name, status: tool.status }))
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function checkForUpdates({ chatId, toolRegistry }) {
|
|
148
|
+
const [currentVersion, latestVersion] = await Promise.all([readCurrentVersion(), fetchLatestVersion()]);
|
|
149
|
+
const bootstrapped = await ensureOfficialUpdateTools({ toolRegistry });
|
|
150
|
+
const result = await toolRegistry.run({ name: "official-tool-sync", chatId, request: { args: { action: "check" } } });
|
|
151
|
+
const sync = parseToolSyncOutput(result);
|
|
152
|
+
return {
|
|
153
|
+
core: { currentVersion, latestVersion, updateAvailable: compareVersions(currentVersion, latestVersion) === -1 },
|
|
154
|
+
bootstrapInstalled: bootstrapped.installed,
|
|
155
|
+
tools: summarizeTools(sync, toolRegistry.list())
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function shortToolStatus(status) {
|
|
160
|
+
return ({
|
|
161
|
+
"locally-modified": "local",
|
|
162
|
+
"untracked-difference": "untracked",
|
|
163
|
+
"update-available": "update",
|
|
164
|
+
"baseline-refresh": "refresh",
|
|
165
|
+
"up-to-date": "current"
|
|
166
|
+
})[status] || status;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function formatUpdateReport(report) {
|
|
170
|
+
const lines = ["Arisa update", "============", "Core"];
|
|
171
|
+
lines.push(...reportRow("Current", report.core.currentVersion));
|
|
172
|
+
lines.push(...reportRow("Latest", report.core.latestVersion));
|
|
173
|
+
lines.push(...reportRow("Status", report.core.updateAvailable ? "update available" : "up to date"));
|
|
174
|
+
lines.push("", "Official tools");
|
|
175
|
+
lines.push(...reportRow("Installed", report.tools.installedOfficial));
|
|
176
|
+
for (const [status, count] of Object.entries(report.tools.counts)) {
|
|
177
|
+
lines.push(...reportRow(status, count, { labelWidth: 20 }));
|
|
178
|
+
}
|
|
179
|
+
lines.push("", `Official (${report.tools.official.length})`);
|
|
180
|
+
for (const item of report.tools.official) {
|
|
181
|
+
const status = shortToolStatus(item.status);
|
|
182
|
+
const suffix = status === "current" ? "" : ` [${status}]`;
|
|
183
|
+
lines.push(...wrapReportText(`${item.name}${suffix}`, { firstPrefix: " - ", nextPrefix: " " }));
|
|
184
|
+
}
|
|
185
|
+
lines.push("", `Non-official (${report.tools.nonOfficial.length})`);
|
|
186
|
+
if (!report.tools.nonOfficial.length) lines.push(" (none)");
|
|
187
|
+
for (const name of report.tools.nonOfficial) {
|
|
188
|
+
lines.push(...wrapReportText(name, { firstPrefix: " - ", nextPrefix: " " }));
|
|
189
|
+
}
|
|
190
|
+
if (report.tools.updateable.length) {
|
|
191
|
+
lines.push("", "Safe updates");
|
|
192
|
+
for (const name of report.tools.updateable) lines.push(...wrapReportText(name, { firstPrefix: " - ", nextPrefix: " " }));
|
|
193
|
+
}
|
|
194
|
+
if (report.tools.blocked.length) {
|
|
195
|
+
lines.push("", "Needs review");
|
|
196
|
+
for (const item of report.tools.blocked) {
|
|
197
|
+
lines.push(...wrapReportText(item.name, { firstPrefix: " - ", nextPrefix: " " }));
|
|
198
|
+
lines.push(...wrapReportText(`[${shortToolStatus(item.status)}]`, { firstPrefix: " ", nextPrefix: " " }));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (report.bootstrapInstalled.length) {
|
|
202
|
+
lines.push("", "Update support installed");
|
|
203
|
+
for (const name of report.bootstrapInstalled) lines.push(...wrapReportText(name, { firstPrefix: " - ", nextPrefix: " " }));
|
|
204
|
+
}
|
|
205
|
+
return renderTextReport(lines);
|
|
206
|
+
}
|