aiakiv 0.2.0 → 0.2.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/dist/bind.js +3 -1
- package/dist/dashboard.js +59 -4
- package/dist/local/checks.js +495 -0
- package/dist/local/doctor.js +329 -0
- package/dist/main.js +51 -1
- package/dist/setup.js +6 -4
- package/dist/snippet.js +5 -9
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/bind.js
CHANGED
|
@@ -93,7 +93,9 @@ async function bindOAuth(ctx, client, project, dir, opts) {
|
|
|
93
93
|
ctx.out(` - /mcp 에서 ${name} 을 골라 브라우저로 로그인하세요.`);
|
|
94
94
|
}
|
|
95
95
|
else {
|
|
96
|
-
ctx.out(` - Cursor 로 그 폴더를 열고 MCP
|
|
96
|
+
ctx.out(` - Cursor 로 그 폴더를 열고 설정(Ctrl+Shift+J)의 Tools & MCP 에서 ${name} 을 켠 뒤 안내에 따라 로그인하세요. ` +
|
|
97
|
+
"Cursor 는 폴더 서버를 켜기 전에는 연결하지 않습니다. 전역 AiAkiv 연결도 있으면 채팅에서 " +
|
|
98
|
+
`${name} 이라고 이름을 지목하세요.`);
|
|
97
99
|
}
|
|
98
100
|
ctx.out(" - 클라이언트 안에서 get_save_target 을 불러 binding 이 folder 인지 확인하세요.");
|
|
99
101
|
return 0;
|
package/dist/dashboard.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// `aiakiv dashboard [--json] [--dir <폴더>]`(BP-171 §5.2) — 한 화면 요약.
|
|
2
2
|
//
|
|
3
|
-
// 읽기 액션
|
|
4
|
-
// list_target_history limit 5)과 MCP get_save_target 한 번. 계정 키(.aiakiv/console.json)는 MCP 가
|
|
3
|
+
// 읽기 액션 일곱(list_teams, list_projects, list_permissions, list_links, list_cards,
|
|
4
|
+
// list_target_history limit 5, get_usage 인자 없음)과 MCP get_save_target 한 번. 계정 키(.aiakiv/console.json)는 MCP 가
|
|
5
5
|
// 거절하므로 그 키로는 get_save_target 을 부르지 않는다(BP-171 §4.3). 그 폴더에 프로젝트 키(bind 가 쓴
|
|
6
6
|
// codex, gemini 설정)가 따로 있으면 그 키로 부르고, 없으면 저장 대상 칸을 건너뛴다. 환경 변수 키는
|
|
7
7
|
// 종류를 알 수 없어 그대로 부른다(계정 키면 그 칸에 MCP 의 거절 이유가 적힌다). 한 칸이 실패해도(권한 꺼짐, 카드 앱
|
|
8
8
|
// 미등록, 403 등) 그 칸에 이유를 적고 나머지는 그린다. 401(키 문제), 404(입구 꺼짐), 연결 실패는
|
|
9
|
-
// 다른 칸도 같은 결과이므로 거기서 멈춘다(
|
|
9
|
+
// 다른 칸도 같은 결과이므로 거기서 멈춘다(일곱 번을 다 두드리지 않는다).
|
|
10
|
+
//
|
|
11
|
+
// 사용량 칸(BP-171 §5.5, P2c): get_usage 가 없는 옛 서버는 200 unknown_action 을 돌려준다. 그 칸에만
|
|
12
|
+
// "서버가 아직 지원하지 않음" 을 적는다. usage_unavailable(서버가 상태를 읽지 못함)도 그 칸에서 끝난다.
|
|
10
13
|
//
|
|
11
14
|
// 개수: 응답에 전체 수(total)가 있으면 그것을 쓴다. 팀, 프로젝트, 연결, 권한 목록은 서버가 페이지를
|
|
12
15
|
// 나누지 않고 전부 주므로(control list_orgs, list_projects, list_links, 권한 카탈로그) 받은 개수가
|
|
@@ -34,6 +37,45 @@ function countText(body, list, complete) {
|
|
|
34
37
|
return `${n}개`;
|
|
35
38
|
return `받은 ${n}개(더 있을 수 있음)`;
|
|
36
39
|
}
|
|
40
|
+
/** 정수부에 세 자리마다 쉼표(1000 → 1,000). 숫자가 아니면 undefined. */
|
|
41
|
+
function num(v) {
|
|
42
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
43
|
+
return undefined;
|
|
44
|
+
const [int, frac] = String(v).split(".");
|
|
45
|
+
return `${int.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${frac ? `.${frac}` : ""}`;
|
|
46
|
+
}
|
|
47
|
+
/** ISO 시각을 이 컴퓨터의 시각대로 `YYYY-MM-DD HH:MM`. 읽을 수 없으면 받은 글자 그대로. */
|
|
48
|
+
function localTime(iso) {
|
|
49
|
+
const d = new Date(iso);
|
|
50
|
+
if (Number.isNaN(d.getTime()))
|
|
51
|
+
return iso;
|
|
52
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
53
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
54
|
+
}
|
|
55
|
+
/** `used/limit`, 한도가 없으면 `used`. */
|
|
56
|
+
const usedOf = (o) => `${num(o.used) ?? "?"}${num(o.limit) !== undefined ? `/${num(o.limit)}` : ""}`;
|
|
57
|
+
/** get_usage 응답(BP-171 §5.5) → 한 줄. off 는 숫자가 null 이다. total 이 null 이면 총량을 뺀다. */
|
|
58
|
+
function usageLines(b) {
|
|
59
|
+
if (b.mode === "off")
|
|
60
|
+
return ["한도 없음"];
|
|
61
|
+
const parts = [];
|
|
62
|
+
if (isObj(b.daily)) {
|
|
63
|
+
const d = b.daily;
|
|
64
|
+
const extra = [
|
|
65
|
+
num(d.remaining) !== undefined ? `남은 ${num(d.remaining)}` : "",
|
|
66
|
+
typeof d.resets_at === "string" && d.resets_at ? `초기화 ${localTime(d.resets_at)}` : "",
|
|
67
|
+
].filter(Boolean);
|
|
68
|
+
parts.push(`오늘 ${usedOf(d)} 저장${extra.length ? `(${extra.join(", ")})` : ""}`);
|
|
69
|
+
}
|
|
70
|
+
if (isObj(b.total))
|
|
71
|
+
parts.push(`총량 ${usedOf(b.total)}`);
|
|
72
|
+
let line = parts.join(", ") || "응답에 사용량 숫자가 없습니다";
|
|
73
|
+
if (b.mode === "shadow")
|
|
74
|
+
line += " (한도를 적용하지 않는 관찰 모드)";
|
|
75
|
+
else if (b.mode !== "enforce")
|
|
76
|
+
line += ` (모드 ${s(b.mode) || "?"})`;
|
|
77
|
+
return [line];
|
|
78
|
+
}
|
|
37
79
|
const CELLS = [
|
|
38
80
|
{
|
|
39
81
|
field: "teams", title: "팀", action: "list_teams", data: {},
|
|
@@ -101,6 +143,19 @@ const CELLS = [
|
|
|
101
143
|
return lines;
|
|
102
144
|
},
|
|
103
145
|
},
|
|
146
|
+
{
|
|
147
|
+
field: "usage", title: "사용량", action: "get_usage", data: {},
|
|
148
|
+
render: usageLines,
|
|
149
|
+
failure: (o) => {
|
|
150
|
+
if (o.exit !== EXIT.APP_ERROR)
|
|
151
|
+
return undefined;
|
|
152
|
+
if (o.error?.code === "unknown_action")
|
|
153
|
+
return ["서버가 아직 지원하지 않음"];
|
|
154
|
+
if (o.error?.code === "usage_unavailable")
|
|
155
|
+
return ["지금 사용량을 읽을 수 없습니다(usage_unavailable). 잠시 뒤 다시 보세요."];
|
|
156
|
+
return undefined;
|
|
157
|
+
},
|
|
158
|
+
},
|
|
104
159
|
];
|
|
105
160
|
/** 일찍 멈출 결과: 키 문제(401), 입구 꺼짐(404), 연결 실패. 다른 칸도 같다. */
|
|
106
161
|
function stopsAll(r, o) {
|
|
@@ -188,7 +243,7 @@ export async function runDashboard(ctx, opts) {
|
|
|
188
243
|
if (o.exit === EXIT.OK && isObj(r.body))
|
|
189
244
|
row(cell.title, cell.render(r.body));
|
|
190
245
|
else
|
|
191
|
-
row(cell.title, cellFailure(o));
|
|
246
|
+
row(cell.title, cell.failure?.(o) ?? cellFailure(o));
|
|
192
247
|
}
|
|
193
248
|
if (!anyOk) {
|
|
194
249
|
ctx.err("모든 칸이 실패했습니다.");
|
|
@@ -0,0 +1,495 @@
|
|
|
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
|
+
/** `docker ps --format "{{.Image}}\t{{.Names}}\t{{.Ports}}"` 출력. */
|
|
313
|
+
export function parseDockerPs(stdout) {
|
|
314
|
+
return stdout
|
|
315
|
+
.split(/\r?\n/)
|
|
316
|
+
.filter((l) => l.trim())
|
|
317
|
+
.map((l) => {
|
|
318
|
+
const [image = "", names = "", ports = ""] = l.split("\t");
|
|
319
|
+
return { image: image.trim(), names: names.trim(), ports: ports.trim() };
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
export function isEngineContainer(r) {
|
|
323
|
+
return /(^|\/)aiakiv\/engine(:|@|$)/i.test(r.image);
|
|
324
|
+
}
|
|
325
|
+
/** Ports 칸(`127.0.0.1:7777->7777/tcp, [::]:7777->7777/tcp`)에서 그 호스트 포트가 묶인 주소들. */
|
|
326
|
+
export function hostAddrsForPort(ports, port) {
|
|
327
|
+
const out = [];
|
|
328
|
+
for (const piece of ports.split(",").map((p) => p.trim()).filter(Boolean)) {
|
|
329
|
+
const m = /^(.*):(\d+)(?:-(\d+))?->/.exec(piece);
|
|
330
|
+
if (!m)
|
|
331
|
+
continue;
|
|
332
|
+
const lo = Number(m[2]);
|
|
333
|
+
const hi = m[3] ? Number(m[3]) : lo;
|
|
334
|
+
if (port < lo || port > hi)
|
|
335
|
+
continue;
|
|
336
|
+
out.push(m[1].replace(/^\[|\]$/g, "") || "::");
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
/** 사설 또는 공인 인터넷에서 닿지 않는 IPv4 대역(사설, 루프백, 링크 로컬, 통신사 공유 대역). */
|
|
341
|
+
export function isPrivateV4(a) {
|
|
342
|
+
const p = a.split(".").map(Number);
|
|
343
|
+
if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
344
|
+
return false;
|
|
345
|
+
const [x, y] = p;
|
|
346
|
+
return (x === 10 || x === 127 || x === 0 ||
|
|
347
|
+
(x === 172 && y >= 16 && y <= 31) ||
|
|
348
|
+
(x === 192 && y === 168) ||
|
|
349
|
+
(x === 169 && y === 254) ||
|
|
350
|
+
(x === 100 && y >= 64 && y <= 127));
|
|
351
|
+
}
|
|
352
|
+
const isV4 = (a) => /^\d+\.\d+\.\d+\.\d+$/.test(a);
|
|
353
|
+
const isLoopback = (a) => a === "::1" || a === "localhost" || (isV4(a) && a.startsWith("127."));
|
|
354
|
+
/** 이 컴퓨터의 공인 IPv4(내부가 아니고 사설 대역 밖). */
|
|
355
|
+
export function publicIPv4s(ifaces) {
|
|
356
|
+
const out = [];
|
|
357
|
+
for (const list of Object.values(ifaces)) {
|
|
358
|
+
for (const a of list ?? []) {
|
|
359
|
+
const v4 = a.family === "IPv4" || a.family === 4;
|
|
360
|
+
if (v4 && !a.internal && !isPrivateV4(a.address) && !out.includes(a.address))
|
|
361
|
+
out.push(a.address);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
export function checkEngineBind(addrs, port, publicV4) {
|
|
367
|
+
const fixClose = "다른 컴퓨터에서 쓰지 않는다면 ~/.aiakiv/engine/.env 의 AIAKIV_BIND 를 127.0.0.1 로 바꾸고 " +
|
|
368
|
+
"그 폴더에서 docker compose -f aiakiv-compose.yml up -d 로 다시 올리세요.";
|
|
369
|
+
if (addrs.every(isLoopback))
|
|
370
|
+
return pass(`${addrs.join(", ")}:${port} 에만 묶여 있습니다`);
|
|
371
|
+
const exposed = [];
|
|
372
|
+
let worst = "warn";
|
|
373
|
+
for (const a of addrs) {
|
|
374
|
+
if (isLoopback(a))
|
|
375
|
+
continue;
|
|
376
|
+
if (a === "0.0.0.0" || a === "::") {
|
|
377
|
+
if (publicV4.length) {
|
|
378
|
+
worst = "fail";
|
|
379
|
+
exposed.push(`${a}(모든 주소, 이 컴퓨터의 공인 주소 ${publicV4.join(", ")} 포함)`);
|
|
380
|
+
}
|
|
381
|
+
else
|
|
382
|
+
exposed.push(`${a}(모든 주소, 공인 주소는 없음)`);
|
|
383
|
+
}
|
|
384
|
+
else if (isV4(a) && !isPrivateV4(a)) {
|
|
385
|
+
worst = "fail";
|
|
386
|
+
exposed.push(`${a}(공인 주소)`);
|
|
387
|
+
}
|
|
388
|
+
else
|
|
389
|
+
exposed.push(`${a}(사설 대역)`);
|
|
390
|
+
}
|
|
391
|
+
if (worst === "fail") {
|
|
392
|
+
return fail(`포트 ${port} 가 공인 주소에 열려 있습니다: ${exposed.join(", ")}`, fixClose);
|
|
393
|
+
}
|
|
394
|
+
return warn(`포트 ${port} 가 이 컴퓨터 밖(사설망)에 열려 있습니다: ${exposed.join(", ")}`, fixClose);
|
|
395
|
+
}
|
|
396
|
+
// ---- 네트워크 -------------------------------------------------------------------------------
|
|
397
|
+
const PROXY_VARS = ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"];
|
|
398
|
+
/** 주소의 사용자 이름과 비밀번호를 가린다. */
|
|
399
|
+
export function maskProxy(v) {
|
|
400
|
+
return v.replace(/\/\/[^@/\s]*@/, "//…@");
|
|
401
|
+
}
|
|
402
|
+
/** 설정된 프록시 환경 변수(대소문자 둘 다). NO_PROXY 는 있는지만 알 때 쓰도록 따로 표시한다. */
|
|
403
|
+
export function envProxies(env) {
|
|
404
|
+
const out = [];
|
|
405
|
+
for (const base of PROXY_VARS) {
|
|
406
|
+
for (const name of [base, base.toLowerCase()]) {
|
|
407
|
+
const v = env[name];
|
|
408
|
+
if (v && !out.some((o) => o.name.toUpperCase() === base))
|
|
409
|
+
out.push({ name, value: maskProxy(v) });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
export function hasProxyEnv(env) {
|
|
415
|
+
return envProxies(env).some((p) => p.name.toUpperCase() !== "NO_PROXY");
|
|
416
|
+
}
|
|
417
|
+
/** Docker Desktop 이 늘 알리는 안쪽 중계(http.docker.internal)는 회사 프록시가 아니다. */
|
|
418
|
+
export function isDesktopInternalProxy(v) {
|
|
419
|
+
return /(^|\/\/)http\.docker\.internal(:\d+)?\/?$/i.test(v.trim());
|
|
420
|
+
}
|
|
421
|
+
export function checkProxy(env, d) {
|
|
422
|
+
const envList = envProxies(env);
|
|
423
|
+
const dockerList = [
|
|
424
|
+
["HttpProxy", d?.httpProxy],
|
|
425
|
+
["HttpsProxy", d?.httpsProxy],
|
|
426
|
+
["NoProxy", d?.noProxy],
|
|
427
|
+
].filter((x) => !!x[1]);
|
|
428
|
+
const dockerReal = dockerList.filter(([k, v]) => k === "NoProxy" || !isDesktopInternalProxy(v));
|
|
429
|
+
const internal = dockerList.length > 0 && dockerList.some(([k, v]) => k !== "NoProxy" && isDesktopInternalProxy(v));
|
|
430
|
+
const parts = [];
|
|
431
|
+
parts.push(envList.length ? `환경 변수 ${envList.map((p) => `${p.name}=${p.value}`).join(", ")}` : "프록시 환경 변수 없음");
|
|
432
|
+
if (!d)
|
|
433
|
+
parts.push("Docker 설정은 보지 못함");
|
|
434
|
+
else if (dockerReal.some(([k]) => k !== "NoProxy")) {
|
|
435
|
+
parts.push(`Docker ${dockerReal.map(([k, v]) => `${k}=${maskProxy(v)}`).join(", ")}`);
|
|
436
|
+
}
|
|
437
|
+
else
|
|
438
|
+
parts.push(internal ? "Docker 는 Docker Desktop 안쪽 중계(http.docker.internal)만 씀" : "Docker 프록시 없음");
|
|
439
|
+
const anyProxy = hasProxyEnv(env) || dockerReal.some(([k]) => k !== "NoProxy");
|
|
440
|
+
return info(parts.join("; "), anyProxy
|
|
441
|
+
? "회사 프록시를 쓰면 Docker 에도 같은 프록시를 설정하고, TLS 를 검사하는 프록시면 회사 인증서를 Docker 에 등록하세요."
|
|
442
|
+
: null);
|
|
443
|
+
}
|
|
444
|
+
export const NET_TARGETS = [
|
|
445
|
+
{
|
|
446
|
+
id: "net.docker_registry",
|
|
447
|
+
url: "https://registry-1.docker.io/v2/",
|
|
448
|
+
what: "Docker Hub",
|
|
449
|
+
fix: "Postgres 이미지를 받을 수 없으니 다른 컴퓨터에서 받아 옮기는 오프라인 설치(--image-tar)를 쓰세요.",
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: "net.aiakiv",
|
|
453
|
+
url: "https://www.aiakiv.com/",
|
|
454
|
+
what: "www.aiakiv.com",
|
|
455
|
+
fix: "로그인과 엔진 내려받기가 안 되니 인터넷이 되는 다른 컴퓨터에서 받아 옮기세요.",
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
id: "net.npm",
|
|
459
|
+
url: "https://registry.npmjs.org/",
|
|
460
|
+
what: "npm 저장소(registry.npmjs.org)",
|
|
461
|
+
fix: "npm 패키지를 받을 수 없으니 다른 컴퓨터에서 패키지 tgz 를 받아 옮기세요.",
|
|
462
|
+
},
|
|
463
|
+
];
|
|
464
|
+
export const NET_TIMEOUT_MS = 10000;
|
|
465
|
+
/** 응답이 오면(상태 코드와 관계없이) 닿은 것. 시간 초과나 연결 실패면 이유 한 줄. */
|
|
466
|
+
export async function reach(doFetch, url, timeoutMs) {
|
|
467
|
+
const ac = new AbortController();
|
|
468
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
469
|
+
try {
|
|
470
|
+
const res = await doFetch(url, { method: "GET", redirect: "manual", signal: ac.signal });
|
|
471
|
+
try {
|
|
472
|
+
await res.body?.cancel();
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
/* 본문은 읽지 않는다 */
|
|
476
|
+
}
|
|
477
|
+
return { ok: true, status: res.status };
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
const err = e;
|
|
481
|
+
if (err.name === "AbortError" || ac.signal.aborted) {
|
|
482
|
+
return { ok: false, reason: `${Math.round(timeoutMs / 1000)}초 안에 응답이 없음` };
|
|
483
|
+
}
|
|
484
|
+
return { ok: false, reason: err.cause?.code ?? err.cause?.message ?? err.message };
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
clearTimeout(timer);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
export function netVerdict(r, what, fix, proxyEnv) {
|
|
491
|
+
const note = proxyEnv ? " (프록시 환경 변수가 있지만 직접 연결로만 시험함)" : "";
|
|
492
|
+
if (r.ok)
|
|
493
|
+
return pass(`${what} 에 닿음(HTTP ${r.status})${note}`);
|
|
494
|
+
return warn(`${what} 에 닿지 않음: ${r.reason}${note}`, fix);
|
|
495
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// `aiakiv local doctor [--json] [--out <파일.md>] [--port <n>] [--offline]` — 자체 설치판(무료 5인 엔진)을
|
|
2
|
+
// 깔기 전에 이 컴퓨터를 점검한다(docs/211 §17.2 ~ §17.4). 엔진이 떠 있으면(그 포트의 /health 가 엔진처럼
|
|
3
|
+
// 응답하면) 상태, 판 이름, 판, 포트가 묶인 주소도 본다. 보고서는 §17.3 형식이고 tool 은 aiakiv-local-doctor.
|
|
4
|
+
// 호스티드 연결을 보는 `aiakiv doctor`(../doctor.ts)와는 별개다.
|
|
5
|
+
//
|
|
6
|
+
// 종료 코드: 0 fail 없음, 3 fail 있음, 4 docker 실행 파일이나 데몬에 닿지 못함(이때도 보고서는 낸다),
|
|
7
|
+
// 1 보고서 파일을 쓰지 못함. 인자 오류(2)는 main.ts 가 낸다.
|
|
8
|
+
// 비밀 값은 다루지 않는다. 프록시 주소의 사용자 이름과 비밀번호는 가린다.
|
|
9
|
+
import { writeFileSync } from "node:fs";
|
|
10
|
+
import { posix, resolve, win32 } from "node:path";
|
|
11
|
+
import { performance } from "node:perf_hooks";
|
|
12
|
+
import { failReason } from "../proc.js";
|
|
13
|
+
import { table } from "../render.js";
|
|
14
|
+
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
|
+
export const DEFAULT_ENGINE_PORT = 7777;
|
|
17
|
+
export const LOCAL_DOCTOR_TOOL = "aiakiv-local-doctor";
|
|
18
|
+
const DOCKER_TIMEOUT_MS = 30_000;
|
|
19
|
+
const HEALTH_TIMEOUT_MS = 3_000;
|
|
20
|
+
export const STATUS_LABEL = {
|
|
21
|
+
pass: "통과",
|
|
22
|
+
warn: "주의",
|
|
23
|
+
fail: "실패",
|
|
24
|
+
skip: "건너뜀",
|
|
25
|
+
info: "알림",
|
|
26
|
+
};
|
|
27
|
+
/** 긴 오류 출력에서 첫 줄만, 너무 길면 자른다. */
|
|
28
|
+
function oneLine(s, max = 200) {
|
|
29
|
+
const l = s.trim().split(/\r?\n/).find((x) => x.trim()) ?? "";
|
|
30
|
+
return l.length > max ? `${l.slice(0, max - 1)}…` : l;
|
|
31
|
+
}
|
|
32
|
+
/** 경로의 여유 공간. statfs 가 있으면 그것, 없거나 실패하면 Windows 는 PowerShell, 그 밖은 `df -Pk`. */
|
|
33
|
+
async function freeBytes(ctx, host, path) {
|
|
34
|
+
let statfsWhy = "";
|
|
35
|
+
if (host.statfs) {
|
|
36
|
+
try {
|
|
37
|
+
const s = await host.statfs(path);
|
|
38
|
+
return { bytes: Number(s.bavail) * Number(s.bsize) };
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
const err = e;
|
|
42
|
+
statfsWhy = `statfs ${err.code ?? err.message}; `;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (ctx.platform === "win32") {
|
|
46
|
+
const drive = /^([A-Za-z]):/.exec(path)?.[1];
|
|
47
|
+
if (!drive)
|
|
48
|
+
return { bytes: null, reason: `${statfsWhy}드라이브를 알 수 없음` };
|
|
49
|
+
const ps = ctx.which("powershell") ?? "powershell.exe";
|
|
50
|
+
const r = await ctx.run(ps, ["-NoProfile", "-NonInteractive", "-Command", `(Get-PSDrive -Name ${drive} -PSProvider FileSystem).Free`], { capture: true, timeoutMs: 20_000 });
|
|
51
|
+
const b = r.ok ? parsePsFree(r.stdout) : null;
|
|
52
|
+
return b === null ? { bytes: null, reason: `${statfsWhy}PowerShell ${oneLine(failReason(r))}` } : { bytes: b };
|
|
53
|
+
}
|
|
54
|
+
const r = await ctx.run(ctx.which("df") ?? "df", ["-Pk", path], { capture: true, timeoutMs: 20_000 });
|
|
55
|
+
const b = r.ok ? parseDfAvailable(r.stdout) : null;
|
|
56
|
+
return b === null ? { bytes: null, reason: `${statfsWhy}df ${oneLine(failReason(r))}` } : { bytes: b };
|
|
57
|
+
}
|
|
58
|
+
/** 그 포트의 /health 가 AiAkiv 엔진처럼 응답하면 본문, 아니면 null. */
|
|
59
|
+
async function fetchHealth(doFetch, port) {
|
|
60
|
+
const ac = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => ac.abort(), HEALTH_TIMEOUT_MS);
|
|
62
|
+
try {
|
|
63
|
+
const res = await doFetch(`http://127.0.0.1:${port}/health`, { signal: ac.signal });
|
|
64
|
+
const body = JSON.parse(await res.text());
|
|
65
|
+
return looksLikeEngineHealth(body) ? body : null;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function portFix(platform, port) {
|
|
75
|
+
const find = platform === "win32" ? `netstat -ano | findstr :${port}` : `lsof -nP -iTCP:${port} -sTCP:LISTEN`;
|
|
76
|
+
return `${find} 로 그 포트를 쓰는 프로그램을 찾아 끄거나, --port 로 다른 포트를 고르세요.`;
|
|
77
|
+
}
|
|
78
|
+
/** 점검을 모두 돌려 보고서를 만든다. dockerUnreachable 은 종료 코드 4 의 근거. */
|
|
79
|
+
export async function collectLocalReport(ctx, opts, host) {
|
|
80
|
+
const port = opts.port ?? DEFAULT_ENGINE_PORT;
|
|
81
|
+
const platform = ctx.platform;
|
|
82
|
+
const startedAt = new Date().toISOString();
|
|
83
|
+
const checks = [];
|
|
84
|
+
const push = (id, v, ms, phase = "preflight") => {
|
|
85
|
+
checks.push({ id, phase, status: v.status, detail: v.detail, fix: v.fix ?? null, ms: Math.round(ms) });
|
|
86
|
+
};
|
|
87
|
+
const add = async (id, fn, phase = "preflight") => {
|
|
88
|
+
const t0 = performance.now();
|
|
89
|
+
let v;
|
|
90
|
+
try {
|
|
91
|
+
v = await fn();
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
v = skip(`점검 중 오류가 났습니다: ${e.message}`);
|
|
95
|
+
}
|
|
96
|
+
push(id, v, performance.now() - t0, phase);
|
|
97
|
+
};
|
|
98
|
+
// 여러 점검이 함께 쓰는 사실. 클로저 안에서 채우므로 객체로 둔다.
|
|
99
|
+
const st = { info: null, infoWhy: "docker 실행 파일이 없어 보지 않았습니다", compose: null, health: null, ps: null, psWhy: "" };
|
|
100
|
+
await add("node.version", () => checkNodeVersion(host.nodeVersion));
|
|
101
|
+
await add("os.supported", () => checkOs(platform, host.arch, host.osRelease));
|
|
102
|
+
const docker = ctx.which("docker");
|
|
103
|
+
await add("docker.cli", () => docker
|
|
104
|
+
? pass(`docker 실행 파일 ${docker}`)
|
|
105
|
+
: fail("docker 실행 파일을 찾지 못했습니다", `Docker 를 설치하세요: ${dockerInstallUrl(platform)}`));
|
|
106
|
+
await add("docker.daemon", async () => {
|
|
107
|
+
if (!docker)
|
|
108
|
+
return skip(st.infoWhy);
|
|
109
|
+
const r = await ctx.run(docker, ["info", "--format", "{{json .}}"], { capture: true, timeoutMs: DOCKER_TIMEOUT_MS });
|
|
110
|
+
const parsed = parseDockerInfo(r.stdout);
|
|
111
|
+
if (r.ok && parsed.info) {
|
|
112
|
+
st.info = parsed.info;
|
|
113
|
+
return pass(`Docker 데몬에 닿았습니다(${parsed.info.operatingSystem ?? "운영 체제 알 수 없음"})`);
|
|
114
|
+
}
|
|
115
|
+
st.infoWhy = "Docker 데몬에 닿지 못해 보지 않았습니다";
|
|
116
|
+
const reason = parsed.errors.length ? parsed.errors.join("; ") : r.stderr.trim() || failReason(r);
|
|
117
|
+
return fail(`Docker 데몬에 닿지 못했습니다: ${oneLine(reason)}`, daemonFix(platform, isPermissionDenied(`${reason}\n${r.stderr}`)));
|
|
118
|
+
});
|
|
119
|
+
await add("docker.version", () => (st.info ? checkDockerVersion(st.info.serverVersion ?? "") : skip(st.infoWhy)));
|
|
120
|
+
await add("compose.v2", async () => {
|
|
121
|
+
if (!docker)
|
|
122
|
+
return skip("docker 실행 파일이 없어 보지 않았습니다");
|
|
123
|
+
const r = await ctx.run(docker, ["compose", "version", "--short"], { capture: true, timeoutMs: DOCKER_TIMEOUT_MS });
|
|
124
|
+
const short = r.ok ? r.stdout.trim().split(/\r?\n/)[0]?.trim() || null : null;
|
|
125
|
+
if (short)
|
|
126
|
+
st.compose = short.replace(/^v/, "");
|
|
127
|
+
return checkCompose(short, !!ctx.which("docker-compose"), platform);
|
|
128
|
+
});
|
|
129
|
+
await add("docker.os", () => (st.info ? checkDockerOs(st.info.osType) : skip(st.infoWhy)));
|
|
130
|
+
await add("docker.arch", () => (st.info ? checkDockerArch(st.info.architecture, host.arch) : skip(st.infoWhy)));
|
|
131
|
+
await add("windows.wsl2", () => {
|
|
132
|
+
if (platform !== "win32")
|
|
133
|
+
return skip("Windows 가 아니라 보지 않았습니다");
|
|
134
|
+
if (!st.info)
|
|
135
|
+
return skip(st.infoWhy);
|
|
136
|
+
if (st.info.osType === "windows")
|
|
137
|
+
return skip("Windows 컨테이너 모드라 보지 않았습니다(docker.os 를 먼저 고치세요)");
|
|
138
|
+
return checkWsl2(st.info.kernelVersion, st.info.operatingSystem);
|
|
139
|
+
});
|
|
140
|
+
await add("docker.mem", () => st.info ? checkDockerMem(st.info.memTotal, platform, isDockerDesktop(st.info)) : skip(st.infoWhy));
|
|
141
|
+
await add("docker.cpus", () => (st.info ? checkDockerCpus(st.info.ncpu, isDockerDesktop(st.info)) : skip(st.infoWhy)));
|
|
142
|
+
await add("disk.free", async () => {
|
|
143
|
+
const tried = [];
|
|
144
|
+
for (const c of diskCandidates(platform, ctx.home, ctx.env, st.info)) {
|
|
145
|
+
if (!host.exists(c.path)) {
|
|
146
|
+
tried.push(`${c.path}(없음)`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const r = await freeBytes(ctx, host, c.path);
|
|
150
|
+
if (r.bytes !== null) {
|
|
151
|
+
const v = checkDiskFree(r.bytes, `${c.label} ${c.path}`);
|
|
152
|
+
return tried.length ? { ...v, detail: `${v.detail}(먼저 본 경로: ${tried.join(", ")})` } : v;
|
|
153
|
+
}
|
|
154
|
+
tried.push(`${c.path}(${r.reason})`);
|
|
155
|
+
}
|
|
156
|
+
return skip(`여유 공간을 확인하지 못했습니다: ${tried.join(", ")}`);
|
|
157
|
+
});
|
|
158
|
+
await add("port.free", async () => {
|
|
159
|
+
const b = await host.tryBind(port);
|
|
160
|
+
st.health = await fetchHealth(ctx.fetch, port);
|
|
161
|
+
if (b.ok)
|
|
162
|
+
return pass(`127.0.0.1:${port} 에 묶을 수 있습니다`);
|
|
163
|
+
if (st.health)
|
|
164
|
+
return info(`127.0.0.1:${port} 는 이미 AiAkiv 엔진이 쓰는 중입니다`);
|
|
165
|
+
return fail(`127.0.0.1:${port} 에 묶을 수 없습니다(${b.code ?? oneLine(b.message)})`, portFix(platform, port));
|
|
166
|
+
});
|
|
167
|
+
const netIds = [...NET_TARGETS.map((t) => t.id), "net.proxy"];
|
|
168
|
+
if (opts.offline) {
|
|
169
|
+
for (const id of netIds)
|
|
170
|
+
push(id, skip("--offline 이라 보지 않았습니다"), 0);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const proxyEnv = hasProxyEnv(ctx.env);
|
|
174
|
+
const results = await Promise.all(NET_TARGETS.map(async (t) => {
|
|
175
|
+
const t0 = performance.now();
|
|
176
|
+
const r = await reach(ctx.fetch, t.url, NET_TIMEOUT_MS);
|
|
177
|
+
return { t, v: netVerdict(r, t.what, t.fix, proxyEnv), ms: performance.now() - t0 };
|
|
178
|
+
}));
|
|
179
|
+
for (const { t, v, ms } of results)
|
|
180
|
+
push(t.id, v, ms);
|
|
181
|
+
await add("net.proxy", () => checkProxy(ctx.env, st.info));
|
|
182
|
+
}
|
|
183
|
+
await add("docker.desktop_license", () => (st.info ? checkDesktopLicense(st.info) : skip(st.infoWhy)));
|
|
184
|
+
await add("install.existing", async () => {
|
|
185
|
+
const p = platform === "win32" ? win32 : posix;
|
|
186
|
+
const engineDir = p.join(ctx.home, ".aiakiv", "engine");
|
|
187
|
+
const hasDir = host.exists(engineDir);
|
|
188
|
+
if (docker && st.info) {
|
|
189
|
+
const r = await ctx.run(docker, ["ps", "--format", "{{.Image}}\t{{.Names}}\t{{.Ports}}"], {
|
|
190
|
+
capture: true,
|
|
191
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
192
|
+
});
|
|
193
|
+
if (r.ok)
|
|
194
|
+
st.ps = parseDockerPs(r.stdout);
|
|
195
|
+
else
|
|
196
|
+
st.psWhy = `docker ps 실패: ${oneLine(failReason(r))}`;
|
|
197
|
+
}
|
|
198
|
+
else
|
|
199
|
+
st.psWhy = st.infoWhy;
|
|
200
|
+
const running = (st.ps ?? []).filter(isEngineContainer);
|
|
201
|
+
const parts = [hasDir ? `${engineDir} 가 있습니다` : `${engineDir} 가 없습니다`];
|
|
202
|
+
if (st.ps === null)
|
|
203
|
+
parts.push(`실행 중인 컨테이너는 보지 못했습니다(${st.psWhy})`);
|
|
204
|
+
else if (running.length)
|
|
205
|
+
parts.push(`실행 중인 엔진 컨테이너 ${running.map((x) => x.names).join(", ")}`);
|
|
206
|
+
else
|
|
207
|
+
parts.push("실행 중인 엔진 컨테이너가 없습니다");
|
|
208
|
+
if (st.health)
|
|
209
|
+
parts.push(`포트 ${port} 에서 엔진이 응답합니다`);
|
|
210
|
+
const installed = hasDir || running.length > 0 || !!st.health;
|
|
211
|
+
return info(parts.join(", "), installed ? "이미 설치돼 있습니다. 새 판으로 올리려면 aiakiv local upgrade 를 쓰세요." : null);
|
|
212
|
+
});
|
|
213
|
+
if (st.health) {
|
|
214
|
+
const h = st.health;
|
|
215
|
+
await add("engine.health", () => checkEngineHealth(h), "engine");
|
|
216
|
+
await add("engine.edition", () => checkEngineEdition(h), "engine");
|
|
217
|
+
await add("engine.version", () => checkEngineVersion(h), "engine");
|
|
218
|
+
await add("engine.bind", () => {
|
|
219
|
+
if (!st.info)
|
|
220
|
+
return skip(st.infoWhy);
|
|
221
|
+
if (st.ps === null)
|
|
222
|
+
return skip(`실행 중인 컨테이너를 보지 못했습니다(${st.psWhy})`);
|
|
223
|
+
const rows = st.ps.filter((r) => hostAddrsForPort(r.ports, port).length);
|
|
224
|
+
if (!rows.length)
|
|
225
|
+
return skip(`포트 ${port} 를 내보내는 컨테이너를 찾지 못했습니다(Docker 밖에서 도는 엔진일 수 있습니다)`);
|
|
226
|
+
const addrs = [...new Set(rows.flatMap((r) => hostAddrsForPort(r.ports, port)))];
|
|
227
|
+
const v = checkEngineBind(addrs, port, publicIPv4s(host.interfaces()));
|
|
228
|
+
return { ...v, detail: `${v.detail}(컨테이너 ${rows.map((r) => r.names).join(", ")})` };
|
|
229
|
+
}, "engine");
|
|
230
|
+
}
|
|
231
|
+
const summary = { pass: 0, warn: 0, fail: 0, skip: 0, info: 0 };
|
|
232
|
+
for (const c of checks)
|
|
233
|
+
summary[c.status] += 1;
|
|
234
|
+
const report = {
|
|
235
|
+
tool: LOCAL_DOCTOR_TOOL,
|
|
236
|
+
tool_version: VERSION,
|
|
237
|
+
started_at: startedAt,
|
|
238
|
+
finished_at: new Date().toISOString(),
|
|
239
|
+
host: {
|
|
240
|
+
os: platform,
|
|
241
|
+
os_release: host.osRelease,
|
|
242
|
+
arch: host.arch,
|
|
243
|
+
cpus: host.cpus,
|
|
244
|
+
mem_bytes: host.memBytes,
|
|
245
|
+
docker_server: st.info?.serverVersion ?? null,
|
|
246
|
+
compose: st.compose,
|
|
247
|
+
docker_os: st.info?.osType ?? null,
|
|
248
|
+
docker_arch: st.info?.architecture ?? null,
|
|
249
|
+
docker_mem_bytes: st.info?.memTotal ?? null,
|
|
250
|
+
docker_cpus: st.info?.ncpu ?? null,
|
|
251
|
+
},
|
|
252
|
+
checks,
|
|
253
|
+
measurements: {},
|
|
254
|
+
summary,
|
|
255
|
+
ok: summary.fail === 0,
|
|
256
|
+
};
|
|
257
|
+
return { report, dockerUnreachable: !docker || !st.info };
|
|
258
|
+
}
|
|
259
|
+
function summaryText(s) {
|
|
260
|
+
return ["pass", "warn", "fail", "skip", "info"].map((k) => `${STATUS_LABEL[k]} ${s[k]}`).join(", ");
|
|
261
|
+
}
|
|
262
|
+
function verdictText(r, dockerUnreachable) {
|
|
263
|
+
if (r.ok)
|
|
264
|
+
return `준비됐습니다(${summaryText(r.summary)}).`;
|
|
265
|
+
const head = dockerUnreachable ? "Docker 에 닿지 못해 준비되지 않았습니다" : "준비되지 않았습니다";
|
|
266
|
+
return `${head}. 실패 ${r.summary.fail}건을 먼저 고치세요(${summaryText(r.summary)}).`;
|
|
267
|
+
}
|
|
268
|
+
/** 사람용 화면. */
|
|
269
|
+
export function renderText(r, port, dockerUnreachable) {
|
|
270
|
+
const lines = [];
|
|
271
|
+
lines.push(`aiakiv local doctor ${r.tool_version}: 자체 설치판(무료 5인 엔진) 설치 전 점검, 포트 ${port}`, "");
|
|
272
|
+
lines.push(...table(["상태", "점검", "내용"], r.checks.map((c) => [STATUS_LABEL[c.status], c.id, c.detail])));
|
|
273
|
+
lines.push("", `결과: ${verdictText(r, dockerUnreachable)}`);
|
|
274
|
+
const todo = r.checks.filter((c) => c.fix && c.status !== "pass");
|
|
275
|
+
if (todo.length) {
|
|
276
|
+
lines.push("", "고칠 것과 알아 둘 것:");
|
|
277
|
+
const order = ["fail", "warn", "info", "skip"];
|
|
278
|
+
todo.sort((a, b) => order.indexOf(a.status) - order.indexOf(b.status));
|
|
279
|
+
for (const c of todo)
|
|
280
|
+
lines.push(` [${STATUS_LABEL[c.status]}] ${c.id}: ${c.fix}`);
|
|
281
|
+
}
|
|
282
|
+
return lines;
|
|
283
|
+
}
|
|
284
|
+
const cell = (s) => (s ?? "").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
285
|
+
/** --out 의 마크다운. 화면과 같은 내용에 컴퓨터 정보를 더한다. */
|
|
286
|
+
export function renderMarkdown(r, port, dockerUnreachable) {
|
|
287
|
+
const h = r.host;
|
|
288
|
+
const md = [];
|
|
289
|
+
md.push("# AiAkiv 무료 엔진 설치 전 점검", "");
|
|
290
|
+
md.push(`- 도구: ${r.tool} ${r.tool_version}`);
|
|
291
|
+
md.push(`- 시작: ${r.started_at}, 끝: ${r.finished_at}`);
|
|
292
|
+
md.push(`- 컴퓨터: ${h.os} ${h.os_release}, ${h.arch}, CPU ${h.cpus}개, 메모리 ${gbText(h.mem_bytes)}`);
|
|
293
|
+
md.push(h.docker_server
|
|
294
|
+
? `- Docker: 서버 ${h.docker_server}, compose ${h.compose ?? "없음"}, ${h.docker_os ?? "?"}/${h.docker_arch ?? "?"}, ` +
|
|
295
|
+
`메모리 ${h.docker_mem_bytes !== null ? gbText(h.docker_mem_bytes) : "?"}, CPU ${h.docker_cpus ?? "?"}개`
|
|
296
|
+
: `- Docker: 닿지 못함(compose ${h.compose ?? "없음"})`);
|
|
297
|
+
md.push(`- 점검한 포트: ${port}`);
|
|
298
|
+
md.push(`- 결과: ${verdictText(r, dockerUnreachable)}`, "");
|
|
299
|
+
md.push("| 점검 | 단계 | 상태 | 내용 | 고칠 것 | 걸린 시간(ms) |", "|---|---|---|---|---|---|");
|
|
300
|
+
for (const c of r.checks) {
|
|
301
|
+
md.push(`| ${c.id} | ${c.phase} | ${STATUS_LABEL[c.status]} | ${cell(c.detail)} | ${cell(c.fix)} | ${c.ms} |`);
|
|
302
|
+
}
|
|
303
|
+
md.push("");
|
|
304
|
+
return md;
|
|
305
|
+
}
|
|
306
|
+
export async function runLocalDoctor(ctx, opts, host) {
|
|
307
|
+
const port = opts.port ?? DEFAULT_ENGINE_PORT;
|
|
308
|
+
const { report, dockerUnreachable } = await collectLocalReport(ctx, opts, host);
|
|
309
|
+
if (opts.json)
|
|
310
|
+
ctx.out(JSON.stringify(report));
|
|
311
|
+
else
|
|
312
|
+
for (const l of renderText(report, port, dockerUnreachable))
|
|
313
|
+
ctx.out(l);
|
|
314
|
+
if (opts.out) {
|
|
315
|
+
const file = resolve(ctx.cwd, opts.out);
|
|
316
|
+
try {
|
|
317
|
+
writeFileSync(file, renderMarkdown(report, port, dockerUnreachable).join("\n"), "utf8");
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
ctx.err(`보고서 파일을 쓰지 못했습니다(${file}): ${e.message}`);
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
// --json 이면 stdout 은 JSON 하나만 둔다.
|
|
324
|
+
(opts.json ? ctx.err : ctx.out)(`보고서 파일: ${file}`);
|
|
325
|
+
}
|
|
326
|
+
if (dockerUnreachable)
|
|
327
|
+
return 4;
|
|
328
|
+
return report.ok ? 0 : 3;
|
|
329
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -6,6 +6,8 @@ import { runBind } from "./bind.js";
|
|
|
6
6
|
import { ConsoleUsageError, runConsole, runConsoleKey } from "./console.js";
|
|
7
7
|
import { runDashboard } from "./dashboard.js";
|
|
8
8
|
import { DEFAULT_REPORT, DOCTOR_CLIENT_IDS, runDoctor } from "./doctor.js";
|
|
9
|
+
import { realHost } from "./local/checks.js";
|
|
10
|
+
import { DEFAULT_ENGINE_PORT, runLocalDoctor } from "./local/doctor.js";
|
|
9
11
|
import { runSetup } from "./setup.js";
|
|
10
12
|
import { DEFAULT_SKILLS_TARBALL_URL } from "./skills.js";
|
|
11
13
|
import { DEFAULT_CONTROL_URL, DEFAULT_MCP_URL } from "./urls.js";
|
|
@@ -21,6 +23,7 @@ export const HELP = `aiakiv ${VERSION}: 폴더에 AiAkiv 프로젝트를 고정
|
|
|
21
23
|
npx aiakiv console describe [--json] [--dir <폴더>]
|
|
22
24
|
npx aiakiv console key [--expires-days N] [--dir <폴더>] [--no-browser]
|
|
23
25
|
npx aiakiv dashboard [--json] [--dir <폴더>]
|
|
26
|
+
npx aiakiv local doctor [--json] [--out <파일.md>] [--port <n>] [--offline]
|
|
24
27
|
|
|
25
28
|
bind 폴더에 프로젝트를 고정합니다. 웹 콘솔의 "폴더 ZIP 받기" 와 같은 한 벌을 그 폴더에 직접 씁니다.
|
|
26
29
|
claude, cursor 는 ?project= 주소(OAuth)를 쓰고, codex, gemini 는 브라우저에서 확인 버튼을 눌러
|
|
@@ -51,9 +54,18 @@ console key
|
|
|
51
54
|
읽거나 쓸 수 없고 관리 작업만 합니다. 기본 만료 90일. 프로젝트나 팀을 고르지 않고, 속한
|
|
52
55
|
모든 팀을 다룹니다(.gitignore 에 /.aiakiv/ 를 더합니다). 다시 받으면 앞선 계정 키는 폐기됩니다.
|
|
53
56
|
dashboard
|
|
54
|
-
팀, 프로젝트, 저장 대상, 켜진 권한, 연결, 카드, 최근 저장 대상
|
|
57
|
+
팀, 프로젝트, 저장 대상, 켜진 권한, 연결, 카드, 최근 저장 대상 이력, 사용량(오늘 저장 수와
|
|
58
|
+
한도)을 한 화면에 보입니다.
|
|
55
59
|
한 칸이 실패해도 나머지는 그립니다. 계정 키로는 저장 대상을 읽지 않고, 그 폴더에 프로젝트
|
|
56
60
|
키가 있으면 그 키로 확인합니다.
|
|
61
|
+
local doctor
|
|
62
|
+
자체 설치판(무료 5인 엔진)을 깔기 전에 이 컴퓨터가 준비됐는지 점검합니다. Node, OS, Docker(판,
|
|
63
|
+
compose v2, 리눅스 컨테이너 모드, 아키텍처, 메모리, CPU), 디스크 여유 공간, 포트(기본 ${DEFAULT_ENGINE_PORT}),
|
|
64
|
+
인터넷 연결(Docker Hub, www.aiakiv.com, npm 저장소), 프록시 설정, 이미 깔린 엔진을 봅니다. 그 포트에
|
|
65
|
+
엔진이 떠 있으면 엔진 상태, 판 이름, 판, 포트가 묶인 주소도 봅니다. --json 은 보고서 JSON 하나를 내고,
|
|
66
|
+
--out 은 같은 내용을 마크다운 파일로 씁니다. --port 는 볼 포트를 바꾸고, --offline 이면 인터넷 연결은
|
|
67
|
+
보지 않습니다. 종료 코드: 0 실패 없음, 3 실패 있음, 2 인자 오류, 4 Docker 를 찾지 못했거나 Docker 에
|
|
68
|
+
닿지 못함(이때도 보고서는 냅니다).
|
|
57
69
|
|
|
58
70
|
console 과 dashboard 가 키를 찾는 순서: 환경 변수 AIAKIV_API_KEY, <폴더>/.aiakiv/console.json(계정 키),
|
|
59
71
|
<폴더>의 .codex/config.toml 과 .gemini/settings.json(프로젝트 키). 키 평문은 화면에 내지 않고 키 id 와
|
|
@@ -91,6 +103,15 @@ function parseExpiresDays(v) {
|
|
|
91
103
|
throw new UsageError("--expires-days 는 1 이상의 정수입니다.");
|
|
92
104
|
return n;
|
|
93
105
|
}
|
|
106
|
+
function parsePort(v) {
|
|
107
|
+
if (v === undefined)
|
|
108
|
+
return undefined;
|
|
109
|
+
const n = Number(v);
|
|
110
|
+
if (!/^\d+$/.test(v.trim()) || !Number.isInteger(n) || n < 1 || n > 65535) {
|
|
111
|
+
throw new UsageError(`--port 는 1 에서 65535 사이의 정수입니다(받은 값: ${v}).`);
|
|
112
|
+
}
|
|
113
|
+
return n;
|
|
114
|
+
}
|
|
94
115
|
function parseClient(v, required) {
|
|
95
116
|
if (v === undefined) {
|
|
96
117
|
if (required)
|
|
@@ -281,6 +302,35 @@ export async function main(argv, ctx) {
|
|
|
281
302
|
action: positionals[0], data: values.data, json: values.json, dir: values.dir, yes: values.yes,
|
|
282
303
|
});
|
|
283
304
|
}
|
|
305
|
+
if (cmd === "local") {
|
|
306
|
+
// 자체 설치판(무료 5인 엔진) 명령. 지금은 doctor 하나(docs/211 §5.1, §17). install, status 등이 같은 아래에 붙는다.
|
|
307
|
+
const [sub, ...subRest] = rest;
|
|
308
|
+
if (sub === "-h" || sub === "--help" || sub === "help")
|
|
309
|
+
return (ctx.out(HELP), 0);
|
|
310
|
+
if (!sub)
|
|
311
|
+
throw new UsageError("local 뒤에 하위 명령이 필요합니다(지금은 doctor 하나입니다).");
|
|
312
|
+
if (sub !== "doctor")
|
|
313
|
+
throw new UsageError(`local 의 모르는 하위 명령입니다: ${sub}. 지금은 doctor 하나입니다.`);
|
|
314
|
+
const { values, positionals } = parseArgs({
|
|
315
|
+
args: subRest,
|
|
316
|
+
options: {
|
|
317
|
+
json: { type: "boolean", default: false },
|
|
318
|
+
out: { type: "string" },
|
|
319
|
+
port: { type: "string" },
|
|
320
|
+
offline: { type: "boolean", default: false },
|
|
321
|
+
help: { type: "boolean", short: "h" },
|
|
322
|
+
},
|
|
323
|
+
strict: true,
|
|
324
|
+
allowPositionals: true,
|
|
325
|
+
});
|
|
326
|
+
if (values.help)
|
|
327
|
+
return (ctx.out(HELP), 0);
|
|
328
|
+
if (positionals.length)
|
|
329
|
+
throw new UsageError(`local doctor 는 위치 인자를 받지 않습니다: ${positionals.join(" ")}`);
|
|
330
|
+
if (values.out !== undefined && !values.out.trim())
|
|
331
|
+
throw new UsageError("--out 에 파일 이름이 필요합니다.");
|
|
332
|
+
return await runLocalDoctor(ctx, { json: values.json, out: values.out, port: parsePort(values.port), offline: values.offline }, realHost());
|
|
333
|
+
}
|
|
284
334
|
if (cmd === "dashboard") {
|
|
285
335
|
const { values } = parseArgs({
|
|
286
336
|
args: rest,
|
package/dist/setup.js
CHANGED
|
@@ -221,12 +221,14 @@ async function setup(ctx, opts) {
|
|
|
221
221
|
ctx.out("전역 스킬: --no-skills 라 건너뜁니다.");
|
|
222
222
|
ctx.out("다음 할 일(사람):");
|
|
223
223
|
if (opts.clients.includes("gemini")) {
|
|
224
|
-
ctx.out(` - Gemini CLI: gemini 를 실행한 뒤 /mcp 에서 ${GLOBAL_SERVER_NAME} 가 연결됐는지 보고, ` +
|
|
225
|
-
"\"저장 대상 확인해 줘\" 라고 해서 binding 이 main 인지 확인하세요."
|
|
224
|
+
ctx.out(` - Gemini CLI: 신뢰한 작업 폴더에서 gemini 를 실행한 뒤 /mcp 에서 ${GLOBAL_SERVER_NAME} 가 연결됐는지 보고, ` +
|
|
225
|
+
"\"저장 대상 확인해 줘\" 라고 해서 binding 이 main 인지 확인하세요. " +
|
|
226
|
+
"신뢰하지 않은 폴더에서는 Gemini CLI 가 전역 서버도 꺼진 것으로 보여 줍니다.");
|
|
226
227
|
}
|
|
227
228
|
if (opts.clients.includes("antigravity")) {
|
|
228
|
-
ctx.out(
|
|
229
|
-
"
|
|
229
|
+
ctx.out(" - Antigravity: 앱이 켜져 있었다면 설정 파일을 바꿨으니 앱을 다시 시작하세요. " +
|
|
230
|
+
"새 대화에서 \"저장 대상 확인해 줘\" 라고 해서 binding 이 main 인지 확인하세요. " +
|
|
231
|
+
`서버 목록은 사이드바의 Skills & Customizations 에서 ${GLOBAL_SERVER_NAME} 로 볼 수 있습니다.`);
|
|
230
232
|
}
|
|
231
233
|
ctx.out(` - 확인: npx aiakiv doctor --client ${opts.clients.join(",")}`);
|
|
232
234
|
// 홈 폴더 파일이라 .gitignore 로 빼 줄 수 없다. 마지막 줄로 알린다.
|
package/dist/snippet.js
CHANGED
|
@@ -27,17 +27,13 @@ export function mcpUrl(base, project) {
|
|
|
27
27
|
const url = `${base}/mcp`;
|
|
28
28
|
return project === undefined ? url : `${url}?project=${encodeURIComponent(project)}`;
|
|
29
29
|
}
|
|
30
|
-
/** 프로젝트를 지정하지 않는 OAuth 연결(전역 설치)의 주소 `{base}/mcp?apps=all`.
|
|
31
|
-
* 표시 없는 `{base}/mcp` 에 OAuth 로 붙고 `?project=` 도 없으면 서버가 앱 실행 도구
|
|
32
|
-
* (`run_aiakiv_app_action`)를 뺀 표면만 준다. `?apps=all` 이 전체 도구를 고른다.
|
|
33
|
-
* `?project=` 주소(mcpUrl 의 project)와 함께 쓰지 않는다 — 그 연결은 이미 전체를 받는다. */
|
|
34
|
-
export function mcpUrlAllApps(base) {
|
|
35
|
-
return `${mcpUrl(base)}?apps=all`;
|
|
36
|
-
}
|
|
37
30
|
/** Cursor 원클릭 설치 딥링크 — 전역 설치(머리글 없음, Main 을 따라간다).
|
|
38
|
-
* config = base64(JSON { url }). 주소는
|
|
31
|
+
* config = base64(JSON { url }). 주소는 표시 없는 `{base}/mcp`(BP-171 §7.1, 공개 주소에서
|
|
32
|
+
* 앱 전체 표시를 뺐다). 이 연결은 기억 도구와 카드 도구를 받고 앱 실행 도구
|
|
33
|
+
* (`run_aiakiv_app_action`)는 받지 않는다. 관리는 `npx aiakiv console`, 대화 안 앱 기능은
|
|
34
|
+
* 폴더 연결(`?project=`)이나 API 키 연결로 한다. */
|
|
39
35
|
export function cursorInstallLink(base) {
|
|
40
|
-
const config = btoa(JSON.stringify({ url:
|
|
36
|
+
const config = btoa(JSON.stringify({ url: mcpUrl(base) }));
|
|
41
37
|
return `cursor://anysphere.cursor-deeplink/mcp/install?name=AiAkiv&config=${config}`;
|
|
42
38
|
}
|
|
43
39
|
/** 내 프로젝트의 연결 주소와 머리글 — 복사 스니펫과 ZIP 이 같이 쓴다.
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// package.json 의 version 과 같아야 한다(tests/cli.test.mjs 가 확인).
|
|
2
|
-
export const VERSION = "0.2.
|
|
2
|
+
export const VERSION = "0.2.2";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiakiv",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "AiAkiv CLI: pin a folder to an AiAkiv project (connection, folder skills, settings), check it, and run console actions and a dashboard from the shell.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|