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,529 @@
1
+ // `aiakiv local doctor` 의 점검 판정(docs/211 §17.4). 판정 함수는 받은 값만 보고 결과를 낸다.
2
+ // 바깥 세계(실행기, fetch, 환경 변수)는 Ctx 로, Node 판과 OS, 디스크, 포트 묶기는 LocalHost 로만 만난다.
3
+ // 시험은 둘 다 가짜로 넣는다.
4
+ //
5
+ // 문턱값의 GB 는 10^9 바이트다. Docker 가 알리는 MemTotal 은 커널이 잡아 둔 몫을 빼고 나와서, 설정에서
6
+ // 4GB 를 준 컴퓨터가 2^30 기준으로는 4GB 미만으로 보이기 때문이다.
7
+ import { existsSync, promises as fsp } from "node:fs";
8
+ import { createServer } from "node:net";
9
+ import { cpus, networkInterfaces, release, totalmem } from "node:os";
10
+ import { posix, win32 } from "node:path";
11
+ export const GB = 1e9;
12
+ export const pass = (detail) => ({ status: "pass", detail });
13
+ export const warn = (detail, fix = null) => ({ status: "warn", detail, fix });
14
+ export const fail = (detail, fix = null) => ({ status: "fail", detail, fix });
15
+ export const skip = (detail) => ({ status: "skip", detail });
16
+ export const info = (detail, fix = null) => ({ status: "info", detail, fix });
17
+ function bindOnce(port) {
18
+ return new Promise((resolve) => {
19
+ const server = createServer();
20
+ server.once("error", (e) => resolve({ ok: false, code: e.code, message: e.message }));
21
+ server.listen(port, "127.0.0.1", () => server.close(() => resolve({ ok: true })));
22
+ });
23
+ }
24
+ export function realHost() {
25
+ const statfs = fsp.statfs;
26
+ return {
27
+ nodeVersion: process.versions.node,
28
+ arch: process.arch,
29
+ osRelease: release(),
30
+ cpus: cpus().length,
31
+ memBytes: totalmem(),
32
+ interfaces: () => networkInterfaces(),
33
+ statfs: typeof statfs === "function" ? (p) => statfs(p) : null,
34
+ exists: (p) => existsSync(p),
35
+ tryBind: bindOnce,
36
+ };
37
+ }
38
+ /** "27.3.1", "v2.29.1-desktop.1", "20.10.24+dfsg1" 에서 [주, 부]. 못 읽으면 null. */
39
+ export function majorMinor(v) {
40
+ const m = /(\d+)\.(\d+)/.exec(v ?? "");
41
+ return m ? [Number(m[1]), Number(m[2])] : null;
42
+ }
43
+ const below = (v, major, minor) => v[0] < major || (v[0] === major && v[1] < minor);
44
+ export const gbText = (bytes) => `${(bytes / GB).toFixed(1)}GB`;
45
+ // ---- Node, OS -------------------------------------------------------------------------------
46
+ export function checkNodeVersion(version) {
47
+ const v = majorMinor(version);
48
+ if (!v)
49
+ return fail(`Node 판을 읽지 못했습니다(${version})`, "Node.js LTS 판을 설치하세요(https://nodejs.org).");
50
+ if (below(v, 18, 3))
51
+ return fail(`Node ${version} 은 18.3 보다 낮습니다`, "Node.js LTS 판을 설치하세요(https://nodejs.org).");
52
+ return pass(`Node ${version}`);
53
+ }
54
+ const WINDOWS_22H2_BUILD = 19045;
55
+ const WINDOWS_11_BUILD = 22000;
56
+ export function checkOs(platform, hostArch, osRelease) {
57
+ const archOk = hostArch === "x64" || hostArch === "arm64";
58
+ const unsupported = "Linux, macOS, Windows 10 22H2 이상 또는 Windows 11 의 x64, arm64 컴퓨터에서 설치하세요.";
59
+ if (platform === "linux" || platform === "darwin") {
60
+ const name = platform === "linux" ? "Linux" : "macOS";
61
+ if (!archOk)
62
+ return fail(`${name} ${hostArch} 는 지원하지 않습니다(x64, arm64 만)`, unsupported);
63
+ return pass(`${name} ${osRelease} ${hostArch}`);
64
+ }
65
+ if (platform === "win32") {
66
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(osRelease);
67
+ if (!m || Number(m[1]) < 10)
68
+ return fail(`Windows ${osRelease} 는 지원하지 않습니다`, unsupported);
69
+ if (!archOk)
70
+ return fail(`Windows ${hostArch} 는 지원하지 않습니다(x64, arm64 만)`, unsupported);
71
+ const build = Number(m[3]);
72
+ if (build >= WINDOWS_11_BUILD)
73
+ return pass(`Windows 11(빌드 ${build}) ${hostArch}`);
74
+ if (build >= WINDOWS_22H2_BUILD)
75
+ return pass(`Windows 10 22H2(빌드 ${build}) ${hostArch}`);
76
+ return warn(`Windows 10 빌드 ${build} 는 22H2(빌드 ${WINDOWS_22H2_BUILD}) 보다 오래됐습니다`, "Windows 업데이트로 Windows 10 22H2 이상으로 올리세요.");
77
+ }
78
+ return fail(`${platform} 는 지원하지 않습니다`, unsupported);
79
+ }
80
+ // ---- Docker ---------------------------------------------------------------------------------
81
+ export function dockerInstallUrl(platform) {
82
+ if (platform === "win32")
83
+ return "https://docs.docker.com/desktop/setup/install/windows-install/";
84
+ if (platform === "darwin")
85
+ return "https://docs.docker.com/desktop/setup/install/mac-install/";
86
+ return "https://docs.docker.com/engine/install/";
87
+ }
88
+ const str = (v) => (typeof v === "string" && v ? v : undefined);
89
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
90
+ /** `docker info --format "{{json .}}"` 의 출력. 데몬에 닿지 못하면 info 없이 errors 만(ServerErrors 나 JSON 아님). */
91
+ export function parseDockerInfo(stdout) {
92
+ const line = stdout.split(/\r?\n/).map((l) => l.trim()).find((l) => l.startsWith("{"));
93
+ if (!line)
94
+ return { info: null, errors: [] };
95
+ let raw;
96
+ try {
97
+ raw = JSON.parse(line);
98
+ }
99
+ catch {
100
+ return { info: null, errors: [] };
101
+ }
102
+ const errors = Array.isArray(raw.ServerErrors) ? raw.ServerErrors.map(String).filter(Boolean) : [];
103
+ const serverVersion = str(raw.ServerVersion);
104
+ if (!serverVersion || errors.length)
105
+ return { info: null, errors };
106
+ return {
107
+ info: {
108
+ serverVersion,
109
+ osType: str(raw.OSType),
110
+ architecture: str(raw.Architecture),
111
+ memTotal: num(raw.MemTotal),
112
+ ncpu: num(raw.NCPU),
113
+ operatingSystem: str(raw.OperatingSystem),
114
+ kernelVersion: str(raw.KernelVersion),
115
+ dockerRootDir: str(raw.DockerRootDir),
116
+ httpProxy: str(raw.HttpProxy),
117
+ httpsProxy: str(raw.HttpsProxy),
118
+ noProxy: str(raw.NoProxy),
119
+ },
120
+ errors,
121
+ };
122
+ }
123
+ export function isPermissionDenied(text) {
124
+ return /permission denied/i.test(text);
125
+ }
126
+ export function daemonFix(platform, permissionDenied) {
127
+ if (platform === "linux" && permissionDenied) {
128
+ return "지금 사용자를 docker 그룹에 넣고(sudo usermod -aG docker $USER) 로그아웃했다가 다시 로그인하세요.";
129
+ }
130
+ if (platform === "linux")
131
+ return "Docker 를 켜세요(sudo systemctl start docker).";
132
+ return "Docker Desktop 을 켜고, 고래 아이콘이 움직임을 멈출 때까지 기다린 뒤 다시 점검하세요.";
133
+ }
134
+ export function checkDockerVersion(v) {
135
+ const mm = majorMinor(v);
136
+ const fix = "Docker 를 새 판으로 올리세요(Docker Engine 24 이상 또는 최신 Docker Desktop).";
137
+ if (!mm)
138
+ return warn(`Docker 서버 판을 읽지 못했습니다(${v})`, fix);
139
+ if (below(mm, 20, 10))
140
+ return fail(`Docker 서버 ${v} 는 20.10 보다 낮습니다`, fix);
141
+ if (mm[0] < 24)
142
+ return warn(`Docker 서버 ${v} 는 24 보다 낮습니다`, fix);
143
+ return pass(`Docker 서버 ${v}`);
144
+ }
145
+ /** `docker compose version --short` 결과(실패면 null)와 옛 docker-compose 실행 파일이 있는지. */
146
+ export function checkCompose(short, hasV1, platform) {
147
+ const fix = platform === "linux"
148
+ ? "compose 플러그인을 설치하세요(배포판 꾸러미 docker-compose-v2 또는 docker-compose-plugin)."
149
+ : "Docker Desktop 을 새 판으로 올리세요(compose v2 가 함께 들어 있습니다).";
150
+ if (short === null) {
151
+ if (hasV1)
152
+ return fail("옛 docker-compose(v1) 만 있고 docker compose(v2) 가 없습니다", fix);
153
+ return fail("docker compose(v2) 가 없습니다", fix);
154
+ }
155
+ const v = short.trim().replace(/^v/, "");
156
+ const mm = majorMinor(v);
157
+ if (!mm)
158
+ return warn(`compose 판을 읽지 못했습니다(${short.trim()})`, fix);
159
+ if (mm[0] < 2)
160
+ return fail(`compose ${v} 는 v2 가 아닙니다`, fix);
161
+ if (below(mm, 2, 20))
162
+ return warn(`compose ${v} 는 2.20 보다 낮습니다`, fix);
163
+ return pass(`compose ${v}`);
164
+ }
165
+ export function checkDockerOs(osType) {
166
+ if (osType === "linux")
167
+ return pass("리눅스 컨테이너 모드");
168
+ if (osType === "windows") {
169
+ return fail("Docker 가 Windows 컨테이너 모드입니다", "작업 표시줄의 Docker 아이콘을 오른쪽 클릭해 Switch to Linux containers 를 고르세요.");
170
+ }
171
+ return fail(`Docker 컨테이너 종류를 알 수 없습니다(${osType ?? "없음"})`, "리눅스 컨테이너 모드로 바꾸세요.");
172
+ }
173
+ /** Docker 의 Architecture 와 process.arch 를 같은 이름(amd64, arm64)으로. 모르면 null. */
174
+ export function normArch(a) {
175
+ const v = (a ?? "").toLowerCase();
176
+ if (v === "x86_64" || v === "amd64" || v === "x64")
177
+ return "amd64";
178
+ if (v === "aarch64" || v === "arm64")
179
+ return "arm64";
180
+ return null;
181
+ }
182
+ export function checkDockerArch(dockerArch, hostArch) {
183
+ const d = normArch(dockerArch);
184
+ if (!d)
185
+ return fail(`Docker 아키텍처 ${dockerArch ?? "없음"} 는 지원하지 않습니다(amd64, arm64 만)`, "amd64 또는 arm64 컴퓨터에서 설치하세요.");
186
+ const h = normArch(hostArch);
187
+ if (h && h !== d) {
188
+ return warn(`Docker 는 ${d}, 이 컴퓨터는 ${h} 입니다(에뮬레이션이라 임베딩이 느립니다)`, "이 컴퓨터의 아키텍처와 같은 Docker 를 설치하세요.");
189
+ }
190
+ return pass(`Docker ${dockerArch}(${d})`);
191
+ }
192
+ export function checkWsl2(kernelVersion, operatingSystem) {
193
+ const k = kernelVersion ?? "";
194
+ if (/wsl2/i.test(k))
195
+ return pass(`WSL2 백엔드(커널 ${k})`);
196
+ return warn(`WSL2 백엔드가 아닌 것으로 보입니다(커널 ${k || "알 수 없음"}${operatingSystem ? `, ${operatingSystem}` : ""})`, "Docker Desktop 의 Settings > General 에서 Use the WSL 2 based engine 을 켜세요.");
197
+ }
198
+ export function checkDockerMem(memTotal, platform, isDesktop) {
199
+ if (memTotal === undefined)
200
+ return warn("Docker 가 쓸 수 있는 메모리를 알 수 없습니다");
201
+ let fix;
202
+ if (platform === "win32") {
203
+ fix =
204
+ "Docker Desktop 의 Settings > Resources 에서 메모리를 6GB 이상으로 올리세요. WSL2 백엔드면 " +
205
+ "%UserProfile%\\.wslconfig 의 [wsl2] 아래 memory=6GB 로 적고 wsl --shutdown 뒤 Docker Desktop 을 다시 켜세요.";
206
+ }
207
+ else if (isDesktop || platform === "darwin") {
208
+ fix = "Docker Desktop 의 Settings > Resources > Memory 를 6GB 이상으로 올리세요.";
209
+ }
210
+ else {
211
+ fix = "메모리가 더 많은 컴퓨터를 쓰세요(Docker Engine 은 컴퓨터 메모리를 그대로 씁니다).";
212
+ }
213
+ if (memTotal < 4 * GB)
214
+ return fail(`Docker 메모리 ${gbText(memTotal)} 는 4GB 보다 적습니다`, fix);
215
+ if (memTotal < 6 * GB)
216
+ return warn(`Docker 메모리 ${gbText(memTotal)} 는 6GB 보다 적습니다`, fix);
217
+ return pass(`Docker 메모리 ${gbText(memTotal)}`);
218
+ }
219
+ export function checkDockerCpus(ncpu, isDesktop) {
220
+ if (ncpu === undefined)
221
+ return warn("Docker 가 쓸 수 있는 CPU 수를 알 수 없습니다");
222
+ const fix = isDesktop
223
+ ? "Docker Desktop 의 Settings > Resources 에서 CPU 를 4개 이상으로 올리세요."
224
+ : "CPU 가 더 많은 컴퓨터를 쓰세요.";
225
+ if (ncpu < 2)
226
+ return fail(`Docker CPU ${ncpu}개는 2개보다 적습니다`, fix);
227
+ if (ncpu < 4)
228
+ return warn(`Docker CPU ${ncpu}개는 4개보다 적습니다`, fix);
229
+ return pass(`Docker CPU ${ncpu}개`);
230
+ }
231
+ export function isDockerDesktop(info) {
232
+ return /docker desktop/i.test(info?.operatingSystem ?? "");
233
+ }
234
+ export function checkDesktopLicense(d) {
235
+ if (isDockerDesktop(d)) {
236
+ return info(`Docker Desktop 입니다(${d.operatingSystem})`, "직원 250명 이상이거나 연매출 1천만 달러 이상인 회사는 Docker 유료 구독이 필요합니다.");
237
+ }
238
+ return info(`Docker Desktop 이 아닙니다(${d.operatingSystem ?? "알 수 없음"}). 구독 조건과 관계없습니다`);
239
+ }
240
+ /** 여유 공간을 볼 경로 후보. 앞에서부터 있는 것을 쓴다. 마지막은 늘 홈 폴더(또는 그 드라이브). */
241
+ export function diskCandidates(platform, home, env, info) {
242
+ if (platform === "win32") {
243
+ const out = [];
244
+ const lad = env.LOCALAPPDATA ?? env.LocalAppData;
245
+ if (lad) {
246
+ out.push({ path: win32.join(lad, "Docker", "wsl"), label: "Docker Desktop 데이터 폴더" });
247
+ const drive = /^[A-Za-z]:/.exec(lad)?.[0];
248
+ if (drive)
249
+ out.push({ path: `${drive}\\`, label: `Docker Desktop 데이터가 있는 ${drive} 드라이브` });
250
+ }
251
+ out.push({ path: home, label: "홈 폴더" });
252
+ return out;
253
+ }
254
+ if (platform === "darwin") {
255
+ return [
256
+ { path: posix.join(home, "Library", "Containers", "com.docker.docker", "Data"), label: "Docker Desktop 데이터 폴더" },
257
+ { path: posix.join(home, ".orbstack"), label: "OrbStack 데이터 폴더" },
258
+ { path: home, label: "홈 폴더" },
259
+ ];
260
+ }
261
+ const root = info?.dockerRootDir ?? "/var/lib/docker";
262
+ return [
263
+ { path: root, label: info?.dockerRootDir ? "Docker 데이터 폴더(DockerRootDir)" : "Docker 기본 데이터 폴더" },
264
+ { path: home, label: "홈 폴더" },
265
+ ];
266
+ }
267
+ /** `df -Pk <경로>` 출력에서 쓸 수 있는 바이트(Available 칸, 1K 블록). 못 읽으면 null. */
268
+ export function parseDfAvailable(stdout) {
269
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
270
+ for (const l of lines.slice(1).reverse()) {
271
+ const m = /\s(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+\S.*$/.exec(` ${l}`);
272
+ if (m)
273
+ return Number(m[3]) * 1024;
274
+ }
275
+ return null;
276
+ }
277
+ /** PowerShell `(Get-PSDrive …).Free` 의 출력(바이트 숫자 한 줄). */
278
+ export function parsePsFree(stdout) {
279
+ const m = /^\s*(\d+)\s*$/m.exec(stdout);
280
+ return m ? Number(m[1]) : null;
281
+ }
282
+ export function checkDiskFree(bytes, where) {
283
+ const fix = "디스크 공간을 비우거나 Docker 데이터 위치를 여유가 있는 디스크로 옮기세요.";
284
+ if (bytes < 10 * GB)
285
+ return fail(`${where} 의 여유 공간 ${gbText(bytes)} 는 10GB 보다 적습니다`, fix);
286
+ if (bytes < 20 * GB)
287
+ return warn(`${where} 의 여유 공간 ${gbText(bytes)} 는 20GB 보다 적습니다`, fix);
288
+ return pass(`${where} 의 여유 공간 ${gbText(bytes)}`);
289
+ }
290
+ /** AiAkiv 엔진(k2g-mcp)의 /health 로 보이는가: status 가 글자이고 엔진만 내는 칸이 하나 이상 있다. */
291
+ export function looksLikeEngineHealth(body) {
292
+ if (!body || typeof body !== "object" || Array.isArray(body))
293
+ return false;
294
+ const b = body;
295
+ return typeof b.status === "string" && ("edition" in b || "engine_version" in b || "first_bootstrap" in b);
296
+ }
297
+ export const FREE_EDITION = "self-hosted-free";
298
+ export function checkEngineHealth(h) {
299
+ if (h.status === "ok")
300
+ return pass("엔진 /health 의 status 가 ok 입니다");
301
+ return fail(`엔진 /health 의 status 가 ${String(h.status)} 입니다`, "엔진 로그를 보세요(~/.aiakiv/engine 에서 docker compose -f aiakiv-compose.yml logs engine).");
302
+ }
303
+ export function checkEngineEdition(h) {
304
+ if (h.edition === FREE_EDITION)
305
+ return pass(`판 이름 ${FREE_EDITION}`);
306
+ return fail(`이 포트의 엔진은 무료 엔진이 아닙니다(edition ${h.edition === undefined ? "없음" : String(h.edition)})`, "이 포트를 쓰는 프로그램을 확인하거나 --port 로 무료 엔진의 포트를 주세요.");
307
+ }
308
+ export function checkEngineVersion(h) {
309
+ const v = h.engine_version;
310
+ return info(`엔진 판 ${typeof v === "string" && v ? v : "알 수 없음"}`);
311
+ }
312
+ // ---- 엔진 status 의 vectors 칸 --------------------------------------------------------------
313
+ /**
314
+ * `aiakiv-admin --json status` 의 결과 문서(K2G src/k2g/selfhost/admin/status.py)에서 `vectors` 칸
315
+ * (vector_check.status_block: status 는 ok, info, warn, skip, 그리고 detail, fix)을 점검 한 줄로 옮긴다.
316
+ * 엔진의 detail 과 fix 는 그대로 싣는다. doc 이 null 이면 결과를 읽지 못한 것이고 why 가 이유다.
317
+ * 이 칸이 없는 옛 엔진 판은 알림으로 둔다.
318
+ */
319
+ export function checkEngineVectors(doc, why = "") {
320
+ const logsFix = "엔진 로그(docker logs <엔진 컨테이너>)를 보세요.";
321
+ if (!doc)
322
+ return warn(`엔진 상태(aiakiv-admin status)를 읽지 못했습니다${why ? `: ${why}` : ""}`, logsFix);
323
+ if (doc.ok !== true) {
324
+ const msg = typeof doc.message === "string" && doc.message ? doc.message : String(doc.error ?? "이유 없음");
325
+ return warn(`엔진 상태(aiakiv-admin status)가 실패했습니다: ${msg}`, logsFix);
326
+ }
327
+ const v = doc.vectors;
328
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
329
+ const ver = typeof doc.engine_version === "string" && doc.engine_version ? `, 엔진 판 ${doc.engine_version}` : "";
330
+ return info(`엔진 판이 벡터 없는 기억 점검을 지원하지 않아 보지 않았습니다(status 에 vectors 칸 없음${ver})`);
331
+ }
332
+ const b = v;
333
+ const detail = typeof b.detail === "string" && b.detail ? b.detail : "벡터 상태 내용 없음";
334
+ const fix = typeof b.fix === "string" && b.fix ? b.fix : null;
335
+ switch (b.status) {
336
+ case "ok":
337
+ return pass(detail);
338
+ case "warn":
339
+ return warn(detail, fix);
340
+ case "skip":
341
+ return skip(detail);
342
+ default:
343
+ return info(detail, fix);
344
+ }
345
+ }
346
+ /** `docker ps --format "{{.Image}}\t{{.Names}}\t{{.Ports}}"` 출력. */
347
+ export function parseDockerPs(stdout) {
348
+ return stdout
349
+ .split(/\r?\n/)
350
+ .filter((l) => l.trim())
351
+ .map((l) => {
352
+ const [image = "", names = "", ports = ""] = l.split("\t");
353
+ return { image: image.trim(), names: names.trim(), ports: ports.trim() };
354
+ });
355
+ }
356
+ export function isEngineContainer(r) {
357
+ return /(^|\/)aiakiv\/engine(:|@|$)/i.test(r.image);
358
+ }
359
+ /** Ports 칸(`127.0.0.1:7777->7777/tcp, [::]:7777->7777/tcp`)에서 그 호스트 포트가 묶인 주소들. */
360
+ export function hostAddrsForPort(ports, port) {
361
+ const out = [];
362
+ for (const piece of ports.split(",").map((p) => p.trim()).filter(Boolean)) {
363
+ const m = /^(.*):(\d+)(?:-(\d+))?->/.exec(piece);
364
+ if (!m)
365
+ continue;
366
+ const lo = Number(m[2]);
367
+ const hi = m[3] ? Number(m[3]) : lo;
368
+ if (port < lo || port > hi)
369
+ continue;
370
+ out.push(m[1].replace(/^\[|\]$/g, "") || "::");
371
+ }
372
+ return out;
373
+ }
374
+ /** 사설 또는 공인 인터넷에서 닿지 않는 IPv4 대역(사설, 루프백, 링크 로컬, 통신사 공유 대역). */
375
+ export function isPrivateV4(a) {
376
+ const p = a.split(".").map(Number);
377
+ if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
378
+ return false;
379
+ const [x, y] = p;
380
+ return (x === 10 || x === 127 || x === 0 ||
381
+ (x === 172 && y >= 16 && y <= 31) ||
382
+ (x === 192 && y === 168) ||
383
+ (x === 169 && y === 254) ||
384
+ (x === 100 && y >= 64 && y <= 127));
385
+ }
386
+ const isV4 = (a) => /^\d+\.\d+\.\d+\.\d+$/.test(a);
387
+ const isLoopback = (a) => a === "::1" || a === "localhost" || (isV4(a) && a.startsWith("127."));
388
+ /** 이 컴퓨터의 공인 IPv4(내부가 아니고 사설 대역 밖). */
389
+ export function publicIPv4s(ifaces) {
390
+ const out = [];
391
+ for (const list of Object.values(ifaces)) {
392
+ for (const a of list ?? []) {
393
+ const v4 = a.family === "IPv4" || a.family === 4;
394
+ if (v4 && !a.internal && !isPrivateV4(a.address) && !out.includes(a.address))
395
+ out.push(a.address);
396
+ }
397
+ }
398
+ return out;
399
+ }
400
+ export function checkEngineBind(addrs, port, publicV4) {
401
+ const fixClose = "다른 컴퓨터에서 쓰지 않는다면 ~/.aiakiv/engine/.env 의 AIAKIV_BIND 를 127.0.0.1 로 바꾸고 " +
402
+ "그 폴더에서 docker compose -f aiakiv-compose.yml up -d 로 다시 올리세요.";
403
+ if (addrs.every(isLoopback))
404
+ return pass(`${addrs.join(", ")}:${port} 에만 묶여 있습니다`);
405
+ const exposed = [];
406
+ let worst = "warn";
407
+ for (const a of addrs) {
408
+ if (isLoopback(a))
409
+ continue;
410
+ if (a === "0.0.0.0" || a === "::") {
411
+ if (publicV4.length) {
412
+ worst = "fail";
413
+ exposed.push(`${a}(모든 주소, 이 컴퓨터의 공인 주소 ${publicV4.join(", ")} 포함)`);
414
+ }
415
+ else
416
+ exposed.push(`${a}(모든 주소, 공인 주소는 없음)`);
417
+ }
418
+ else if (isV4(a) && !isPrivateV4(a)) {
419
+ worst = "fail";
420
+ exposed.push(`${a}(공인 주소)`);
421
+ }
422
+ else
423
+ exposed.push(`${a}(사설 대역)`);
424
+ }
425
+ if (worst === "fail") {
426
+ return fail(`포트 ${port} 가 공인 주소에 열려 있습니다: ${exposed.join(", ")}`, fixClose);
427
+ }
428
+ return warn(`포트 ${port} 가 이 컴퓨터 밖(사설망)에 열려 있습니다: ${exposed.join(", ")}`, fixClose);
429
+ }
430
+ // ---- 네트워크 -------------------------------------------------------------------------------
431
+ const PROXY_VARS = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"];
432
+ /** 주소의 사용자 이름과 비밀번호를 가린다. */
433
+ export function maskProxy(v) {
434
+ return v.replace(/\/\/[^@/\s]*@/, "//…@");
435
+ }
436
+ /** 설정된 프록시 환경 변수(대소문자 둘 다). NO_PROXY 는 있는지만 알 때 쓰도록 따로 표시한다. */
437
+ export function envProxies(env) {
438
+ const out = [];
439
+ for (const base of PROXY_VARS) {
440
+ for (const name of [base, base.toLowerCase()]) {
441
+ const v = env[name];
442
+ if (v && !out.some((o) => o.name.toUpperCase() === base))
443
+ out.push({ name, value: maskProxy(v) });
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+ export function hasProxyEnv(env) {
449
+ return envProxies(env).some((p) => p.name.toUpperCase() !== "NO_PROXY");
450
+ }
451
+ /** Docker Desktop 이 늘 알리는 안쪽 중계(http.docker.internal)는 회사 프록시가 아니다. */
452
+ export function isDesktopInternalProxy(v) {
453
+ return /(^|\/\/)http\.docker\.internal(:\d+)?\/?$/i.test(v.trim());
454
+ }
455
+ export function checkProxy(env, d) {
456
+ const envList = envProxies(env);
457
+ const dockerList = [
458
+ ["HttpProxy", d?.httpProxy],
459
+ ["HttpsProxy", d?.httpsProxy],
460
+ ["NoProxy", d?.noProxy],
461
+ ].filter((x) => !!x[1]);
462
+ const dockerReal = dockerList.filter(([k, v]) => k === "NoProxy" || !isDesktopInternalProxy(v));
463
+ const internal = dockerList.length > 0 && dockerList.some(([k, v]) => k !== "NoProxy" && isDesktopInternalProxy(v));
464
+ const parts = [];
465
+ parts.push(envList.length ? `환경 변수 ${envList.map((p) => `${p.name}=${p.value}`).join(", ")}` : "프록시 환경 변수 없음");
466
+ if (!d)
467
+ parts.push("Docker 설정은 보지 못함");
468
+ else if (dockerReal.some(([k]) => k !== "NoProxy")) {
469
+ parts.push(`Docker ${dockerReal.map(([k, v]) => `${k}=${maskProxy(v)}`).join(", ")}`);
470
+ }
471
+ else
472
+ parts.push(internal ? "Docker 는 Docker Desktop 안쪽 중계(http.docker.internal)만 씀" : "Docker 프록시 없음");
473
+ const anyProxy = hasProxyEnv(env) || dockerReal.some(([k]) => k !== "NoProxy");
474
+ return info(parts.join("; "), anyProxy
475
+ ? "회사 프록시를 쓰면 Docker 에도 같은 프록시를 설정하고, TLS 를 검사하는 프록시면 회사 인증서를 Docker 에 등록하세요."
476
+ : null);
477
+ }
478
+ export const NET_TARGETS = [
479
+ {
480
+ id: "net.docker_registry",
481
+ url: "https://registry-1.docker.io/v2/",
482
+ what: "Docker Hub",
483
+ fix: "Postgres 이미지를 받을 수 없으니 다른 컴퓨터에서 받아 옮기는 오프라인 설치(--image-tar)를 쓰세요.",
484
+ },
485
+ {
486
+ id: "net.aiakiv",
487
+ url: "https://www.aiakiv.com/",
488
+ what: "www.aiakiv.com",
489
+ fix: "로그인과 엔진 내려받기가 안 되니 인터넷이 되는 다른 컴퓨터에서 받아 옮기세요.",
490
+ },
491
+ {
492
+ id: "net.npm",
493
+ url: "https://registry.npmjs.org/",
494
+ what: "npm 저장소(registry.npmjs.org)",
495
+ fix: "npm 패키지를 받을 수 없으니 다른 컴퓨터에서 패키지 tgz 를 받아 옮기세요.",
496
+ },
497
+ ];
498
+ export const NET_TIMEOUT_MS = 10000;
499
+ /** 응답이 오면(상태 코드와 관계없이) 닿은 것. 시간 초과나 연결 실패면 이유 한 줄. */
500
+ export async function reach(doFetch, url, timeoutMs) {
501
+ const ac = new AbortController();
502
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
503
+ try {
504
+ const res = await doFetch(url, { method: "GET", redirect: "manual", signal: ac.signal });
505
+ try {
506
+ await res.body?.cancel();
507
+ }
508
+ catch {
509
+ /* 본문은 읽지 않는다 */
510
+ }
511
+ return { ok: true, status: res.status };
512
+ }
513
+ catch (e) {
514
+ const err = e;
515
+ if (err.name === "AbortError" || ac.signal.aborted) {
516
+ return { ok: false, reason: `${Math.round(timeoutMs / 1000)}초 안에 응답이 없음` };
517
+ }
518
+ return { ok: false, reason: err.cause?.code ?? err.cause?.message ?? err.message };
519
+ }
520
+ finally {
521
+ clearTimeout(timer);
522
+ }
523
+ }
524
+ export function netVerdict(r, what, fix, proxyEnv) {
525
+ const note = proxyEnv ? " (프록시 환경 변수가 있지만 직접 연결로만 시험함)" : "";
526
+ if (r.ok)
527
+ return pass(`${what} 에 닿음(HTTP ${r.status})${note}`);
528
+ return warn(`${what} 에 닿지 않음: ${r.reason}${note}`, fix);
529
+ }