aiakiv 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ // `aiakiv local doctor [--json] [--out <파일.md>] [--port <n>] [--offline]` — 자체 설치판(무료 5인 엔진)을
2
+ // 깔기 전에 이 컴퓨터를 점검한다(docs/211 §17.2 ~ §17.4). 엔진이 떠 있으면(그 포트의 /health 가 엔진처럼
3
+ // 응답하면) 상태, 판 이름, 판, 포트가 묶인 주소도 본다. 엔진 컨테이너가 떠 있으면 엔진의 `aiakiv-admin status` 에서
4
+ // 벡터 없는 기억(vectors 칸)도 본다(engine.vectors). 보고서는 §17.3 형식이고 tool 은 aiakiv-local-doctor.
5
+ // 호스티드 연결을 보는 `aiakiv doctor`(../doctor.ts)와는 별개다.
6
+ //
7
+ // 종료 코드: 0 fail 없음, 3 fail 있음, 4 docker 실행 파일이나 데몬에 닿지 못함(이때도 보고서는 낸다),
8
+ // 1 보고서 파일을 쓰지 못함. 인자 오류(2)는 main.ts 가 낸다.
9
+ // 비밀 값은 다루지 않는다. 프록시 주소의 사용자 이름과 비밀번호는 가린다.
10
+ import { writeFileSync } from "node:fs";
11
+ import { posix, resolve, win32 } from "node:path";
12
+ import { performance } from "node:perf_hooks";
13
+ import { failReason } from "../proc.js";
14
+ import { table } from "../render.js";
15
+ import { VERSION } from "../version.js";
16
+ import { checkCompose, checkDesktopLicense, checkDiskFree, checkDockerArch, checkDockerCpus, checkDockerMem, checkDockerOs, checkDockerVersion, checkEngineBind, checkEngineEdition, checkEngineHealth, checkEngineVectors, checkEngineVersion, checkNodeVersion, checkOs, checkProxy, checkWsl2, daemonFix, diskCandidates, dockerInstallUrl, fail, hasProxyEnv, hostAddrsForPort, info, isDockerDesktop, isEngineContainer, isPermissionDenied, looksLikeEngineHealth, NET_TARGETS, NET_TIMEOUT_MS, netVerdict, parseDfAvailable, parseDockerInfo, parseDockerPs, parsePsFree, pass, publicIPv4s, reach, skip, gbText, } from "./checks.js";
17
+ import { listContainers, parseEngineDoc, pickEngine } from "./portable.js";
18
+ export const DEFAULT_ENGINE_PORT = 7777;
19
+ export const LOCAL_DOCTOR_TOOL = "aiakiv-local-doctor";
20
+ const DOCKER_TIMEOUT_MS = 30_000;
21
+ const HEALTH_TIMEOUT_MS = 3_000;
22
+ export const STATUS_LABEL = {
23
+ pass: "통과",
24
+ warn: "주의",
25
+ fail: "실패",
26
+ skip: "건너뜀",
27
+ info: "알림",
28
+ };
29
+ /** 긴 오류 출력에서 첫 줄만, 너무 길면 자른다. */
30
+ function oneLine(s, max = 200) {
31
+ const l = s.trim().split(/\r?\n/).find((x) => x.trim()) ?? "";
32
+ return l.length > max ? `${l.slice(0, max - 1)}…` : l;
33
+ }
34
+ /** 경로의 여유 공간. statfs 가 있으면 그것, 없거나 실패하면 Windows 는 PowerShell, 그 밖은 `df -Pk`. */
35
+ async function freeBytes(ctx, host, path) {
36
+ let statfsWhy = "";
37
+ if (host.statfs) {
38
+ try {
39
+ const s = await host.statfs(path);
40
+ return { bytes: Number(s.bavail) * Number(s.bsize) };
41
+ }
42
+ catch (e) {
43
+ const err = e;
44
+ statfsWhy = `statfs ${err.code ?? err.message}; `;
45
+ }
46
+ }
47
+ if (ctx.platform === "win32") {
48
+ const drive = /^([A-Za-z]):/.exec(path)?.[1];
49
+ if (!drive)
50
+ return { bytes: null, reason: `${statfsWhy}드라이브를 알 수 없음` };
51
+ const ps = ctx.which("powershell") ?? "powershell.exe";
52
+ const r = await ctx.run(ps, ["-NoProfile", "-NonInteractive", "-Command", `(Get-PSDrive -Name ${drive} -PSProvider FileSystem).Free`], { capture: true, timeoutMs: 20_000 });
53
+ const b = r.ok ? parsePsFree(r.stdout) : null;
54
+ return b === null ? { bytes: null, reason: `${statfsWhy}PowerShell ${oneLine(failReason(r))}` } : { bytes: b };
55
+ }
56
+ const r = await ctx.run(ctx.which("df") ?? "df", ["-Pk", path], { capture: true, timeoutMs: 20_000 });
57
+ const b = r.ok ? parseDfAvailable(r.stdout) : null;
58
+ return b === null ? { bytes: null, reason: `${statfsWhy}df ${oneLine(failReason(r))}` } : { bytes: b };
59
+ }
60
+ /** 그 포트의 /health 가 AiAkiv 엔진처럼 응답하면 본문, 아니면 null. */
61
+ async function fetchHealth(doFetch, port) {
62
+ const ac = new AbortController();
63
+ const timer = setTimeout(() => ac.abort(), HEALTH_TIMEOUT_MS);
64
+ try {
65
+ const res = await doFetch(`http://127.0.0.1:${port}/health`, { signal: ac.signal });
66
+ const body = JSON.parse(await res.text());
67
+ return looksLikeEngineHealth(body) ? body : null;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+ function portFix(platform, port) {
77
+ const find = platform === "win32" ? `netstat -ano | findstr :${port}` : `lsof -nP -iTCP:${port} -sTCP:LISTEN`;
78
+ return `${find} 로 그 포트를 쓰는 프로그램을 찾아 끄거나, --port 로 다른 포트를 고르세요.`;
79
+ }
80
+ /** 점검을 모두 돌려 보고서를 만든다. dockerUnreachable 은 종료 코드 4 의 근거. */
81
+ export async function collectLocalReport(ctx, opts, host) {
82
+ const port = opts.port ?? DEFAULT_ENGINE_PORT;
83
+ const platform = ctx.platform;
84
+ const startedAt = new Date().toISOString();
85
+ const checks = [];
86
+ const push = (id, v, ms, phase = "preflight") => {
87
+ checks.push({ id, phase, status: v.status, detail: v.detail, fix: v.fix ?? null, ms: Math.round(ms) });
88
+ };
89
+ const add = async (id, fn, phase = "preflight") => {
90
+ const t0 = performance.now();
91
+ let v;
92
+ try {
93
+ v = await fn();
94
+ }
95
+ catch (e) {
96
+ v = skip(`점검 중 오류가 났습니다: ${e.message}`);
97
+ }
98
+ push(id, v, performance.now() - t0, phase);
99
+ };
100
+ // 여러 점검이 함께 쓰는 사실. 클로저 안에서 채우므로 객체로 둔다.
101
+ const st = { info: null, infoWhy: "docker 실행 파일이 없어 보지 않았습니다", compose: null, health: null, ps: null, psWhy: "" };
102
+ await add("node.version", () => checkNodeVersion(host.nodeVersion));
103
+ await add("os.supported", () => checkOs(platform, host.arch, host.osRelease));
104
+ const docker = ctx.which("docker");
105
+ await add("docker.cli", () => docker
106
+ ? pass(`docker 실행 파일 ${docker}`)
107
+ : fail("docker 실행 파일을 찾지 못했습니다", `Docker 를 설치하세요: ${dockerInstallUrl(platform)}`));
108
+ await add("docker.daemon", async () => {
109
+ if (!docker)
110
+ return skip(st.infoWhy);
111
+ const r = await ctx.run(docker, ["info", "--format", "{{json .}}"], { capture: true, timeoutMs: DOCKER_TIMEOUT_MS });
112
+ const parsed = parseDockerInfo(r.stdout);
113
+ if (r.ok && parsed.info) {
114
+ st.info = parsed.info;
115
+ return pass(`Docker 데몬에 닿았습니다(${parsed.info.operatingSystem ?? "운영 체제 알 수 없음"})`);
116
+ }
117
+ st.infoWhy = "Docker 데몬에 닿지 못해 보지 않았습니다";
118
+ const reason = parsed.errors.length ? parsed.errors.join("; ") : r.stderr.trim() || failReason(r);
119
+ return fail(`Docker 데몬에 닿지 못했습니다: ${oneLine(reason)}`, daemonFix(platform, isPermissionDenied(`${reason}\n${r.stderr}`)));
120
+ });
121
+ await add("docker.version", () => (st.info ? checkDockerVersion(st.info.serverVersion ?? "") : skip(st.infoWhy)));
122
+ await add("compose.v2", async () => {
123
+ if (!docker)
124
+ return skip("docker 실행 파일이 없어 보지 않았습니다");
125
+ const r = await ctx.run(docker, ["compose", "version", "--short"], { capture: true, timeoutMs: DOCKER_TIMEOUT_MS });
126
+ const short = r.ok ? r.stdout.trim().split(/\r?\n/)[0]?.trim() || null : null;
127
+ if (short)
128
+ st.compose = short.replace(/^v/, "");
129
+ return checkCompose(short, !!ctx.which("docker-compose"), platform);
130
+ });
131
+ await add("docker.os", () => (st.info ? checkDockerOs(st.info.osType) : skip(st.infoWhy)));
132
+ await add("docker.arch", () => (st.info ? checkDockerArch(st.info.architecture, host.arch) : skip(st.infoWhy)));
133
+ await add("windows.wsl2", () => {
134
+ if (platform !== "win32")
135
+ return skip("Windows 가 아니라 보지 않았습니다");
136
+ if (!st.info)
137
+ return skip(st.infoWhy);
138
+ if (st.info.osType === "windows")
139
+ return skip("Windows 컨테이너 모드라 보지 않았습니다(docker.os 를 먼저 고치세요)");
140
+ return checkWsl2(st.info.kernelVersion, st.info.operatingSystem);
141
+ });
142
+ await add("docker.mem", () => st.info ? checkDockerMem(st.info.memTotal, platform, isDockerDesktop(st.info)) : skip(st.infoWhy));
143
+ await add("docker.cpus", () => (st.info ? checkDockerCpus(st.info.ncpu, isDockerDesktop(st.info)) : skip(st.infoWhy)));
144
+ await add("disk.free", async () => {
145
+ const tried = [];
146
+ for (const c of diskCandidates(platform, ctx.home, ctx.env, st.info)) {
147
+ if (!host.exists(c.path)) {
148
+ tried.push(`${c.path}(없음)`);
149
+ continue;
150
+ }
151
+ const r = await freeBytes(ctx, host, c.path);
152
+ if (r.bytes !== null) {
153
+ const v = checkDiskFree(r.bytes, `${c.label} ${c.path}`);
154
+ return tried.length ? { ...v, detail: `${v.detail}(먼저 본 경로: ${tried.join(", ")})` } : v;
155
+ }
156
+ tried.push(`${c.path}(${r.reason})`);
157
+ }
158
+ return skip(`여유 공간을 확인하지 못했습니다: ${tried.join(", ")}`);
159
+ });
160
+ await add("port.free", async () => {
161
+ const b = await host.tryBind(port);
162
+ st.health = await fetchHealth(ctx.fetch, port);
163
+ if (b.ok)
164
+ return pass(`127.0.0.1:${port} 에 묶을 수 있습니다`);
165
+ if (st.health)
166
+ return info(`127.0.0.1:${port} 는 이미 AiAkiv 엔진이 쓰는 중입니다`);
167
+ return fail(`127.0.0.1:${port} 에 묶을 수 없습니다(${b.code ?? oneLine(b.message)})`, portFix(platform, port));
168
+ });
169
+ const netIds = [...NET_TARGETS.map((t) => t.id), "net.proxy"];
170
+ if (opts.offline) {
171
+ for (const id of netIds)
172
+ push(id, skip("--offline 이라 보지 않았습니다"), 0);
173
+ }
174
+ else {
175
+ const proxyEnv = hasProxyEnv(ctx.env);
176
+ const results = await Promise.all(NET_TARGETS.map(async (t) => {
177
+ const t0 = performance.now();
178
+ const r = await reach(ctx.fetch, t.url, NET_TIMEOUT_MS);
179
+ return { t, v: netVerdict(r, t.what, t.fix, proxyEnv), ms: performance.now() - t0 };
180
+ }));
181
+ for (const { t, v, ms } of results)
182
+ push(t.id, v, ms);
183
+ await add("net.proxy", () => checkProxy(ctx.env, st.info));
184
+ }
185
+ await add("docker.desktop_license", () => (st.info ? checkDesktopLicense(st.info) : skip(st.infoWhy)));
186
+ await add("install.existing", async () => {
187
+ const p = platform === "win32" ? win32 : posix;
188
+ const engineDir = p.join(ctx.home, ".aiakiv", "engine");
189
+ const hasDir = host.exists(engineDir);
190
+ if (docker && st.info) {
191
+ const r = await ctx.run(docker, ["ps", "--format", "{{.Image}}\t{{.Names}}\t{{.Ports}}"], {
192
+ capture: true,
193
+ timeoutMs: DOCKER_TIMEOUT_MS,
194
+ });
195
+ if (r.ok)
196
+ st.ps = parseDockerPs(r.stdout);
197
+ else
198
+ st.psWhy = `docker ps 실패: ${oneLine(failReason(r))}`;
199
+ }
200
+ else
201
+ st.psWhy = st.infoWhy;
202
+ const running = (st.ps ?? []).filter(isEngineContainer);
203
+ const parts = [hasDir ? `${engineDir} 가 있습니다` : `${engineDir} 가 없습니다`];
204
+ if (st.ps === null)
205
+ parts.push(`실행 중인 컨테이너는 보지 못했습니다(${st.psWhy})`);
206
+ else if (running.length)
207
+ parts.push(`실행 중인 엔진 컨테이너 ${running.map((x) => x.names).join(", ")}`);
208
+ else
209
+ parts.push("실행 중인 엔진 컨테이너가 없습니다");
210
+ if (st.health)
211
+ parts.push(`포트 ${port} 에서 엔진이 응답합니다`);
212
+ const installed = hasDir || running.length > 0 || !!st.health;
213
+ return info(parts.join(", "), installed ? "이미 설치돼 있습니다. 새 판으로 올리려면 aiakiv local upgrade 를 쓰세요." : null);
214
+ });
215
+ if (st.health) {
216
+ const h = st.health;
217
+ await add("engine.health", () => checkEngineHealth(h), "engine");
218
+ await add("engine.edition", () => checkEngineEdition(h), "engine");
219
+ await add("engine.version", () => checkEngineVersion(h), "engine");
220
+ await add("engine.bind", () => {
221
+ if (!st.info)
222
+ return skip(st.infoWhy);
223
+ if (st.ps === null)
224
+ return skip(`실행 중인 컨테이너를 보지 못했습니다(${st.psWhy})`);
225
+ const rows = st.ps.filter((r) => hostAddrsForPort(r.ports, port).length);
226
+ if (!rows.length)
227
+ return skip(`포트 ${port} 를 내보내는 컨테이너를 찾지 못했습니다(Docker 밖에서 도는 엔진일 수 있습니다)`);
228
+ const addrs = [...new Set(rows.flatMap((r) => hostAddrsForPort(r.ports, port)))];
229
+ const v = checkEngineBind(addrs, port, publicIPv4s(host.interfaces()));
230
+ return { ...v, detail: `${v.detail}(컨테이너 ${rows.map((r) => r.names).join(", ")})` };
231
+ }, "engine");
232
+ }
233
+ // 무료 5인판 엔진 컨테이너가 있으면 엔진의 status 에서 벡터 없는 기억(검색에 나오지 않음)을 본다. 컨테이너 찾기는
234
+ // local export, import 와 같다(portable.ts). 엔진 컨테이너가 하나도 없으면(설치 전) 이 줄을 내지 않는다.
235
+ // 엔진을 부르다 실패해도 주의나 알림까지만 낸다(종료 코드는 fail 만 바꾼다).
236
+ if (docker && st.info) {
237
+ const listed = await listContainers(ctx, docker);
238
+ const pick = listed.ok ? pickEngine(listed.rows) : null;
239
+ if (pick && pick.engines.length) {
240
+ await add("engine.vectors", async () => {
241
+ if (pick.running.length > 1) {
242
+ return skip(`떠 있는 엔진 컨테이너가 여럿이라 보지 않았습니다(${pick.running.map((x) => x.name).join(", ")})`);
243
+ }
244
+ if (!pick.running.length) {
245
+ const stopped = pick.engines.map((x) => `${x.name}(${x.state || "알 수 없음"})`).join(", ");
246
+ return skip(`엔진 컨테이너가 떠 있지 않아 보지 않았습니다(${stopped})`);
247
+ }
248
+ const c = pick.running[0];
249
+ // 이 보고서의 말에 맞춰 엔진 문장도 한국어로 받는다(이 호출에만, 설치 언어는 그대로).
250
+ const r = await ctx.run(docker, ["exec", "-e", "AIAKIV_LANGUAGE=ko", c.name, "aiakiv-admin", "--json", "status"], { capture: true, timeoutMs: DOCKER_TIMEOUT_MS });
251
+ // 엔진은 실패해도(DB 연결 실패 등) JSON 문서를 낸다. 문서가 없을 때만 실행 실패로 본다.
252
+ const v = checkEngineVectors(parseEngineDoc(r.stdout), oneLine(failReason(r)));
253
+ return { ...v, detail: `${v.detail}(컨테이너 ${c.name})` };
254
+ }, "engine");
255
+ }
256
+ }
257
+ const summary = { pass: 0, warn: 0, fail: 0, skip: 0, info: 0 };
258
+ for (const c of checks)
259
+ summary[c.status] += 1;
260
+ const report = {
261
+ tool: LOCAL_DOCTOR_TOOL,
262
+ tool_version: VERSION,
263
+ started_at: startedAt,
264
+ finished_at: new Date().toISOString(),
265
+ host: {
266
+ os: platform,
267
+ os_release: host.osRelease,
268
+ arch: host.arch,
269
+ cpus: host.cpus,
270
+ mem_bytes: host.memBytes,
271
+ docker_server: st.info?.serverVersion ?? null,
272
+ compose: st.compose,
273
+ docker_os: st.info?.osType ?? null,
274
+ docker_arch: st.info?.architecture ?? null,
275
+ docker_mem_bytes: st.info?.memTotal ?? null,
276
+ docker_cpus: st.info?.ncpu ?? null,
277
+ },
278
+ checks,
279
+ measurements: {},
280
+ summary,
281
+ ok: summary.fail === 0,
282
+ };
283
+ return { report, dockerUnreachable: !docker || !st.info };
284
+ }
285
+ function summaryText(s) {
286
+ return ["pass", "warn", "fail", "skip", "info"].map((k) => `${STATUS_LABEL[k]} ${s[k]}`).join(", ");
287
+ }
288
+ function verdictText(r, dockerUnreachable) {
289
+ if (r.ok)
290
+ return `준비됐습니다(${summaryText(r.summary)}).`;
291
+ const head = dockerUnreachable ? "Docker 에 닿지 못해 준비되지 않았습니다" : "준비되지 않았습니다";
292
+ return `${head}. 실패 ${r.summary.fail}건을 먼저 고치세요(${summaryText(r.summary)}).`;
293
+ }
294
+ /** 사람용 화면. */
295
+ export function renderText(r, port, dockerUnreachable) {
296
+ const lines = [];
297
+ lines.push(`aiakiv local doctor ${r.tool_version}: 자체 설치판(무료 5인 엔진) 설치 전 점검, 포트 ${port}`, "");
298
+ lines.push(...table(["상태", "점검", "내용"], r.checks.map((c) => [STATUS_LABEL[c.status], c.id, c.detail])));
299
+ lines.push("", `결과: ${verdictText(r, dockerUnreachable)}`);
300
+ const todo = r.checks.filter((c) => c.fix && c.status !== "pass");
301
+ if (todo.length) {
302
+ lines.push("", "고칠 것과 알아 둘 것:");
303
+ const order = ["fail", "warn", "info", "skip"];
304
+ todo.sort((a, b) => order.indexOf(a.status) - order.indexOf(b.status));
305
+ for (const c of todo)
306
+ lines.push(` [${STATUS_LABEL[c.status]}] ${c.id}: ${c.fix}`);
307
+ }
308
+ return lines;
309
+ }
310
+ const cell = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
311
+ /** --out 의 마크다운. 화면과 같은 내용에 컴퓨터 정보를 더한다. */
312
+ export function renderMarkdown(r, port, dockerUnreachable) {
313
+ const h = r.host;
314
+ const md = [];
315
+ md.push("# AiAkiv 무료 엔진 설치 전 점검", "");
316
+ md.push(`- 도구: ${r.tool} ${r.tool_version}`);
317
+ md.push(`- 시작: ${r.started_at}, 끝: ${r.finished_at}`);
318
+ md.push(`- 컴퓨터: ${h.os} ${h.os_release}, ${h.arch}, CPU ${h.cpus}개, 메모리 ${gbText(h.mem_bytes)}`);
319
+ md.push(h.docker_server
320
+ ? `- Docker: 서버 ${h.docker_server}, compose ${h.compose ?? "없음"}, ${h.docker_os ?? "?"}/${h.docker_arch ?? "?"}, ` +
321
+ `메모리 ${h.docker_mem_bytes !== null ? gbText(h.docker_mem_bytes) : "?"}, CPU ${h.docker_cpus ?? "?"}개`
322
+ : `- Docker: 닿지 못함(compose ${h.compose ?? "없음"})`);
323
+ md.push(`- 점검한 포트: ${port}`);
324
+ md.push(`- 결과: ${verdictText(r, dockerUnreachable)}`, "");
325
+ md.push("| 점검 | 단계 | 상태 | 내용 | 고칠 것 | 걸린 시간(ms) |", "|---|---|---|---|---|---|");
326
+ for (const c of r.checks) {
327
+ md.push(`| ${c.id} | ${c.phase} | ${STATUS_LABEL[c.status]} | ${cell(c.detail)} | ${cell(c.fix)} | ${c.ms} |`);
328
+ }
329
+ md.push("");
330
+ return md;
331
+ }
332
+ export async function runLocalDoctor(ctx, opts, host) {
333
+ const port = opts.port ?? DEFAULT_ENGINE_PORT;
334
+ const { report, dockerUnreachable } = await collectLocalReport(ctx, opts, host);
335
+ if (opts.json)
336
+ ctx.out(JSON.stringify(report));
337
+ else
338
+ for (const l of renderText(report, port, dockerUnreachable))
339
+ ctx.out(l);
340
+ if (opts.out) {
341
+ const file = resolve(ctx.cwd, opts.out);
342
+ try {
343
+ writeFileSync(file, renderMarkdown(report, port, dockerUnreachable).join("\n"), "utf8");
344
+ }
345
+ catch (e) {
346
+ ctx.err(`보고서 파일을 쓰지 못했습니다(${file}): ${e.message}`);
347
+ return 1;
348
+ }
349
+ // --json 이면 stdout 은 JSON 하나만 둔다.
350
+ (opts.json ? ctx.err : ctx.out)(`보고서 파일: ${file}`);
351
+ }
352
+ if (dockerUnreachable)
353
+ return 4;
354
+ return report.ok ? 0 : 3;
355
+ }