aiakiv 0.2.2 → 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,188 @@
1
+ // 점검 보고서 파일(report.json, report.md). 점검 본체의 `--out` 이 쓰는 것과 같은 이름과 형식을 이 CLI 가 쓴다.
2
+ // 컨테이너에 --out 을 넘기지 않는 까닭: Linux 에서는 점검 컨테이너가 사용자 10001 로 돌아 붙인 폴더에 쓰지 못할 수
3
+ // 있다(K2G packaging/enterprise/precheck/README.md §5 Q14). 그래서 --json 의 마지막 줄(보고서)을 받아 여기서 쓴다.
4
+ //
5
+ // 형식은 K2G src/k2g/selfhost/precheck/runner.py `_write_outputs` 와 report.py `to_markdown` 을 옮긴 것이다.
6
+ // - report.json: json.dumps(보고서, ensure_ascii=False, indent=2) + "\n"
7
+ // - report.md: to_markdown(보고서). 말은 설치 언어(en 기본, ko)를 따른다. 저쪽 문장을 바꾸면 여기도 함께 바꾼다.
8
+ // 값을 글자로 바꾸는 str() 은 Python 과 같게 흉내 낸다(True, False, 목록과 사전의 repr). 깊은 중첩의 따옴표
9
+ // 고르기 같은 세부는 다를 수 있다.
10
+ import { parse as parseToml } from "smol-toml";
11
+ const T = {
12
+ "status.pass": ["PASS", "통과"],
13
+ "status.warn": ["WARN", "주의"],
14
+ "status.fail": ["FAIL", "실패"],
15
+ "status.skip": ["SKIP", "건너뜀"],
16
+ "status.info": ["INFO", "정보"],
17
+ seconds: ["{s}s", "{s}초"],
18
+ minutes: ["{m}m {s}s", "{m}분 {s}초"],
19
+ "md.title": ["# AiAkiv enterprise pre-install check report", "# AiAkiv 유료 기업판 설치 전 점검 보고서"],
20
+ "md.tool": ["- Tool: {tool} {version}, engine {engine}", "- 도구: {tool} {version}, 엔진 {engine}"],
21
+ "md.time": ["- Started: {started}, finished: {finished}", "- 시작: {started}, 끝: {finished}"],
22
+ "md.result": ["- Result: {verdict} {counts}", "- 결과: {verdict}{counts}"],
23
+ "verdict.ok": ["no failures", "실패 없음"],
24
+ "verdict.fail": ["has failures", "실패 있음"],
25
+ counts: ["(pass {p}, warn {w}, fail {f}, skip {s}, info {i})", "(통과 {p}, 주의 {w}, 실패 {f}, 건너뜀 {s}, 정보 {i})"],
26
+ "md.mode": [
27
+ "- Login mode: {mode}, meets the quote and license conditions: {eligible}",
28
+ "- 로그인 방식: {mode}, 견적과 발급 조건 충족: {eligible}",
29
+ ],
30
+ yes: ["yes", "예"],
31
+ no: ["no", "아니오"],
32
+ "md.failed_required": ["- Required checks not met: {ids}", "- 충족하지 못한 필수 점검: {ids}"],
33
+ "md.sha": ["- Settings file sha256: {sha}", "- 설정 파일 sha256: {sha}"],
34
+ "md.warnings": ["## Settings warnings", "## 설정 경고"],
35
+ "md.target": ["## Target", "## 대상"],
36
+ "md.host": ["## Host", "## 호스트"],
37
+ "md.measurements": ["## Measurements", "## 측정값"],
38
+ "md.checks": ["## Checks", "## 점검"],
39
+ "md.header": ["| id | Result | Details | How to fix | Time |", "| id | 결과 | 내용 | 고칠 방법 | 시간 |"],
40
+ "md.details": ["## Check details", "## 점검 세부 값"],
41
+ };
42
+ function t(lang, key, args = {}) {
43
+ const pair = T[key];
44
+ const text = pair[lang === "ko" ? 1 : 0];
45
+ return text.replace(/\{(\w+)\}/g, (m, k) => (k in args ? String(args[k]) : m));
46
+ }
47
+ /**
48
+ * 점검의 설치 언어. 점검 본체(K2G k2g.selfhost.i18n.current_language)와 같은 순서로 이 CLI 가 볼 수 있는 것만 본다:
49
+ * 이 CLI 의 환경 변수 AIAKIV_LANGUAGE, 설정 파일 맨 위의 language, 기본 en. 작업 폴더 .env 의 AIAKIV_LANGUAGE 는
50
+ * 읽지 않는다(.env 는 열지 않는다).
51
+ */
52
+ export function reportLanguage(env, configText) {
53
+ const norm = (v) => {
54
+ const s = typeof v === "string" ? v.trim().toLowerCase() : "";
55
+ return s === "en" || s === "ko" ? s : null;
56
+ };
57
+ const fromEnv = env.AIAKIV_LANGUAGE;
58
+ if (fromEnv !== undefined && fromEnv.trim())
59
+ return norm(fromEnv) ?? "en";
60
+ if (configText) {
61
+ try {
62
+ const v = parseToml(configText).language;
63
+ if (v !== undefined)
64
+ return norm(v) ?? "en";
65
+ }
66
+ catch {
67
+ /* 설정 파일을 못 읽으면 점검도 설정 오류로 끝난다. 말은 기본 */
68
+ }
69
+ }
70
+ return "en";
71
+ }
72
+ /** Python repr 흉내(목록과 사전 안의 값). */
73
+ function pyRepr(v) {
74
+ if (v === null || v === undefined)
75
+ return "None";
76
+ if (typeof v === "boolean")
77
+ return v ? "True" : "False";
78
+ if (typeof v === "number")
79
+ return pyNumber(v);
80
+ if (typeof v === "string") {
81
+ const q = v.includes("'") && !v.includes('"') ? '"' : "'";
82
+ const body = v.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
83
+ return q + (q === "'" ? body.replace(/'/g, "\\'") : body) + q;
84
+ }
85
+ if (Array.isArray(v))
86
+ return `[${v.map(pyRepr).join(", ")}]`;
87
+ if (typeof v === "object") {
88
+ return `{${Object.entries(v).map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(", ")}}`;
89
+ }
90
+ return String(v);
91
+ }
92
+ function pyNumber(n) {
93
+ if (!Number.isFinite(n))
94
+ return n > 0 ? "inf" : n < 0 ? "-inf" : "nan";
95
+ return String(n);
96
+ }
97
+ /** Python str() 흉내. */
98
+ function pyStr(v) {
99
+ if (typeof v === "string")
100
+ return v;
101
+ return pyRepr(v);
102
+ }
103
+ /** report.py `_cell`: None 은 빈 칸, | 는 \|, 줄바꿈은 빈칸. */
104
+ function cell(v) {
105
+ return (v === null || v === undefined ? "" : pyStr(v)).replace(/\|/g, "\\|").replace(/\r/g, " ").replace(/\n/g, " ");
106
+ }
107
+ /** Python round() 의 짝수 쪽 반올림. */
108
+ function roundHalfEven(x) {
109
+ const f = Math.floor(x);
110
+ const d = x - f;
111
+ if (d > 0.5)
112
+ return f + 1;
113
+ if (d < 0.5)
114
+ return f;
115
+ return f % 2 === 0 ? f : f + 1;
116
+ }
117
+ /**
118
+ * Python f"{ms/1000:.1f}". 정수 ms 에서 반 올림 경계가 정확히 걸리는 것은 ms % 500 == 250(1250 은 1.25 가 이진수로
119
+ * 정확하다)뿐이고, Python 은 그때 짝수 쪽(1.2), JS toFixed 는 큰 쪽(1.3)으로 간다. 그 경우만 따로 맞춘다.
120
+ */
121
+ function oneDecimal(ms) {
122
+ if (Number.isInteger(ms) && ms % 500 === 250) {
123
+ const tenths = Math.floor(ms / 100);
124
+ return ((tenths % 2 === 0 ? tenths : tenths + 1) / 10).toFixed(1);
125
+ }
126
+ return (ms / 1000).toFixed(1);
127
+ }
128
+ /** report.py `fmt_ms`. */
129
+ export function fmtMs(lang, ms) {
130
+ if (ms === null || ms === undefined)
131
+ return "-";
132
+ if (ms < 1000)
133
+ return `${Math.trunc(ms)}ms`;
134
+ const s = ms / 1000;
135
+ if (s < 60)
136
+ return t(lang, "seconds", { s: oneDecimal(ms) });
137
+ const m = Math.floor(s / 60);
138
+ return t(lang, "minutes", { m, s: roundHalfEven(s - m * 60) });
139
+ }
140
+ const STATUSES = ["pass", "warn", "fail", "skip", "info"];
141
+ const statusLabel = (lang, s) => typeof s === "string" && STATUSES.includes(s) ? t(lang, `status.${s}`) : String(s);
142
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
143
+ const entries = (v) => (isObj(v) ? Object.entries(v) : []);
144
+ /** report.json 의 본문. */
145
+ export function reportJsonText(report) {
146
+ return `${JSON.stringify(report, null, 2)}\n`;
147
+ }
148
+ /** report.md 의 본문(report.py `to_markdown`). */
149
+ export function toMarkdown(r, lang) {
150
+ const q = isObj(r.quote) ? r.quote : {};
151
+ const s = isObj(r.summary) ? r.summary : {};
152
+ const n = (k) => s[k] ?? 0;
153
+ const verdict = t(lang, r.ok ? "verdict.ok" : "verdict.fail");
154
+ const counts = t(lang, "counts", { p: n("pass"), w: n("warn"), f: n("fail"), s: n("skip"), i: n("info") });
155
+ const lines = [
156
+ t(lang, "md.title"),
157
+ "",
158
+ t(lang, "md.tool", { tool: pyStr(r.tool), version: pyStr(r.tool_version), engine: pyStr(q.engine_version ?? "-") }),
159
+ t(lang, "md.time", { started: pyStr(r.started_at), finished: r.finished_at ? pyStr(r.finished_at) : "-" }),
160
+ t(lang, "md.result", { verdict, counts }),
161
+ t(lang, "md.mode", { mode: pyStr(q.mode ?? "-"), eligible: t(lang, q.eligible ? "yes" : "no") }),
162
+ ];
163
+ if (Array.isArray(q.failed_required) && q.failed_required.length) {
164
+ lines.push(t(lang, "md.failed_required", { ids: q.failed_required.map(pyStr).join(", ") }));
165
+ }
166
+ lines.push(t(lang, "md.sha", { sha: pyStr(q.config_sha256 ?? "-") }));
167
+ if (Array.isArray(r.warnings) && r.warnings.length) {
168
+ lines.push("", t(lang, "md.warnings"), "", ...r.warnings.map((w) => `- ${pyStr(w)}`));
169
+ }
170
+ lines.push("", t(lang, "md.target"), "", ...entries(r.target).map(([k, v]) => `- ${k}: ${cell(v)}`));
171
+ lines.push("", t(lang, "md.host"), "", ...entries(r.host).map(([k, v]) => `- ${k}: ${cell(v)}`));
172
+ lines.push("", t(lang, "md.measurements"), "", ...entries(r.measurements).map(([k, v]) => `- ${k}: ${cell(v)}`));
173
+ lines.push("", t(lang, "md.checks"), "", t(lang, "md.header"), "|---|---|---|---|---|");
174
+ const checks = Array.isArray(r.checks) ? r.checks.filter(isObj) : [];
175
+ for (const c of checks) {
176
+ const ms = typeof c.ms === "number" ? fmtMs(lang, c.ms) : "";
177
+ lines.push(`| ${cell(c.id)} | ${statusLabel(lang, c.status)} | ${cell(c.detail)} | ${cell(c.fix)} | ${ms} |`);
178
+ }
179
+ const withData = checks.filter((c) => c.data !== undefined && c.data !== null);
180
+ if (withData.length) {
181
+ lines.push("", t(lang, "md.details"));
182
+ for (const c of withData) {
183
+ lines.push("", `### ${pyStr(c.id)}`, "", "```json", JSON.stringify(c.data, null, 2), "```");
184
+ }
185
+ }
186
+ lines.push("", "## quote", "", "```json", JSON.stringify(q, null, 2), "```", "");
187
+ return lines.join("\n");
188
+ }
@@ -309,6 +309,40 @@ export function checkEngineVersion(h) {
309
309
  const v = h.engine_version;
310
310
  return info(`엔진 판 ${typeof v === "string" && v ? v : "알 수 없음"}`);
311
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
+ }
312
346
  /** `docker ps --format "{{.Image}}\t{{.Names}}\t{{.Ports}}"` 출력. */
313
347
  export function parseDockerPs(stdout) {
314
348
  return stdout
@@ -1,6 +1,7 @@
1
1
  // `aiakiv local doctor [--json] [--out <파일.md>] [--port <n>] [--offline]` — 자체 설치판(무료 5인 엔진)을
2
2
  // 깔기 전에 이 컴퓨터를 점검한다(docs/211 §17.2 ~ §17.4). 엔진이 떠 있으면(그 포트의 /health 가 엔진처럼
3
- // 응답하면) 상태, 판 이름, 판, 포트가 묶인 주소도 본다. 보고서는 §17.3 형식이고 tool 은 aiakiv-local-doctor.
3
+ // 응답하면) 상태, 판 이름, 판, 포트가 묶인 주소도 본다. 엔진 컨테이너가 떠 있으면 엔진의 `aiakiv-admin status` 에서
4
+ // 벡터 없는 기억(vectors 칸)도 본다(engine.vectors). 보고서는 §17.3 형식이고 tool 은 aiakiv-local-doctor.
4
5
  // 호스티드 연결을 보는 `aiakiv doctor`(../doctor.ts)와는 별개다.
5
6
  //
6
7
  // 종료 코드: 0 fail 없음, 3 fail 있음, 4 docker 실행 파일이나 데몬에 닿지 못함(이때도 보고서는 낸다),
@@ -12,7 +13,8 @@ import { performance } from "node:perf_hooks";
12
13
  import { failReason } from "../proc.js";
13
14
  import { table } from "../render.js";
14
15
  import { VERSION } from "../version.js";
15
- import { checkCompose, checkDesktopLicense, checkDiskFree, checkDockerArch, checkDockerCpus, checkDockerMem, checkDockerOs, checkDockerVersion, checkEngineBind, checkEngineEdition, checkEngineHealth, 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";
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";
16
18
  export const DEFAULT_ENGINE_PORT = 7777;
17
19
  export const LOCAL_DOCTOR_TOOL = "aiakiv-local-doctor";
18
20
  const DOCKER_TIMEOUT_MS = 30_000;
@@ -228,6 +230,30 @@ export async function collectLocalReport(ctx, opts, host) {
228
230
  return { ...v, detail: `${v.detail}(컨테이너 ${rows.map((r) => r.names).join(", ")})` };
229
231
  }, "engine");
230
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
+ }
231
257
  const summary = { pass: 0, warn: 0, fail: 0, skip: 0, info: 0 };
232
258
  for (const c of checks)
233
259
  summary[c.status] += 1;