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.
- package/README.md +106 -0
- package/dist/ctx.js +2 -1
- package/dist/dashboard.js +59 -4
- package/dist/enterprise/precheck.js +656 -0
- package/dist/enterprise/reportMarkdown.js +188 -0
- package/dist/local/checks.js +529 -0
- package/dist/local/doctor.js +355 -0
- package/dist/local/portable.js +495 -0
- package/dist/main.js +227 -2
- package/dist/proc.js +51 -0
- package/dist/snippet.js +5 -9
- package/dist/version.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// `aiakiv local export` 와 `aiakiv local import` — 자체 설치판(무료 5인 엔진) 팀 기억을 파일로 내보내고 받는다
|
|
2
|
+
// (K2G docs/211 BP-172 §5.1 표, §5.2, §7). 본체는 엔진 컨테이너 안의 `aiakiv-admin export`, `aiakiv-admin import`
|
|
3
|
+
// (K2G src/k2g/selfhost/admin/exporting.py, importing.py)이고, 이 명령은 그 앞뒤를 맡는다: Docker 확인, 엔진
|
|
4
|
+
// 컨테이너 찾기, 호스트 파일을 컨테이너 안으로(또는 거꾸로) 옮기기(docker cp), docker exec 로 부르기, 옮기려고 만든
|
|
5
|
+
// 컨테이너 안 임시 폴더 지우기. 파일 내용(아카이브, 벡터)은 읽지도 바꾸지도 않는다. 벡터 처리는 엔진이 한다.
|
|
6
|
+
//
|
|
7
|
+
// 출력(§5.2): 표준 출력은 JSON 만 낸다. export 는 JSON 한 문서. import 는 엔진의 줄(진행 `{"step","done","total"}`,
|
|
8
|
+
// 마지막 줄이 결과)을 받는 대로 바꾸지 않고 낸다. 엔진이 실패를 알린 JSON(이유, 남은 수, record id)도 그대로다.
|
|
9
|
+
// 사람용 안내와 엔진의 로그는 표준 오류로 낸다.
|
|
10
|
+
//
|
|
11
|
+
// 종료 코드: 엔진의 것(0 성공, 2 입력 오류나 --yes 없이 계획만, 3 거절, 4 엔진 DB 연결 실패, 5 가져오기 검증 실패,
|
|
12
|
+
// 1 예기치 못한 오류)을 그대로 돌려준다. 이 명령의 것은 enterprise precheck 와 같은 번호로 2 인자 오류(파일 없음,
|
|
13
|
+
// 호스트에 이미 있는 파일), 4 Docker 를 쓸 수 없음이나 엔진 컨테이너가 떠 있지 않음, 1 컨테이너와 파일을 주고받지
|
|
14
|
+
// 못함이나 엔진이 결과 없이 끝남. 인자 해석의 오류(2)는 main.ts 가 낸다.
|
|
15
|
+
//
|
|
16
|
+
// 경로: `--to` 와 import 의 <파일> 은 호스트 경로이고(작업 폴더 기준, Windows 드라이브 문자와 역슬래시와 공백 그대로),
|
|
17
|
+
// 엔진에는 이 명령이 만든 컨테이너 안 경로(/data/exports/aiakiv-cli-…, /data/imports/aiakiv-cli-…)만 넘긴다.
|
|
18
|
+
// docker cp 에는 호스트 쪽을 늘 절대 경로로 준다(상대 경로의 `C:` 같은 콜론을 컨테이너 이름으로 읽지 않게).
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
21
|
+
import { posix, win32 } from "node:path";
|
|
22
|
+
import { failReason } from "../proc.js";
|
|
23
|
+
import { daemonFix, dockerInstallUrl, isPermissionDenied, parseDockerInfo } from "./checks.js";
|
|
24
|
+
/** enterprise precheck 와 같은 번호(src/enterprise/precheck.ts). */
|
|
25
|
+
export const EXIT_OK = 0;
|
|
26
|
+
export const EXIT_RUN_FAILED = 1;
|
|
27
|
+
export const EXIT_USAGE = 2;
|
|
28
|
+
export const EXIT_DOCKER = 4;
|
|
29
|
+
/** 엔진 compose 의 서비스 이름(K2G packaging/selfhost/aiakiv-compose.yml). 같은 이미지의 control 과 가른다. */
|
|
30
|
+
export const ENGINE_SERVICE = "engine";
|
|
31
|
+
/** 컨테이너 안 자리. 엔진의 데이터 볼륨(DATA_DIR=/data) 아래, K2G packaging/selfhost/README.md 3.1절과 같은 폴더. */
|
|
32
|
+
export const CONTAINER_EXPORT_DIR = "/data/exports";
|
|
33
|
+
export const CONTAINER_IMPORT_DIR = "/data/imports";
|
|
34
|
+
/** 이 명령이 컨테이너 안에 만드는 임시 폴더 이름의 머리. 이 이름의 폴더만 지운다. */
|
|
35
|
+
export const TEMP_PREFIX = "aiakiv-cli-";
|
|
36
|
+
/** 엔진 compose 파일의 기본 자리(사람에게 보이는 안내에만 쓴다). */
|
|
37
|
+
export const ENGINE_COMPOSE = "~/.aiakiv/engine/aiakiv-compose.yml";
|
|
38
|
+
const DOCKER_TIMEOUT_MS = 30_000;
|
|
39
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
40
|
+
/** 실패를 JSON 한 문서로 표준 출력에, 사람용 한 줄을 표준 오류에 낸다. */
|
|
41
|
+
function emitFail(ctx, f) {
|
|
42
|
+
ctx.out(JSON.stringify({ ok: false, error: f.error, message: f.message, fix: f.fix, ...(f.extra ?? {}) }));
|
|
43
|
+
ctx.err(`${f.message}. ${f.fix}`);
|
|
44
|
+
return f.code;
|
|
45
|
+
}
|
|
46
|
+
/** 긴 오류 출력에서 첫 줄만, 너무 길면 자른다. */
|
|
47
|
+
function oneLine(s, max = 200) {
|
|
48
|
+
const l = s.trim().split(/\r?\n/).find((x) => x.trim()) ?? "";
|
|
49
|
+
return l.length > max ? `${l.slice(0, max - 1)}…` : l;
|
|
50
|
+
}
|
|
51
|
+
/** 호스트 경로를 작업 폴더 기준 절대 경로로. Windows 는 win32 규칙(드라이브 문자, 역슬래시)으로 푼다. */
|
|
52
|
+
export function hostPath(cwd, p, platform) {
|
|
53
|
+
return (platform === "win32" ? win32 : posix).resolve(cwd, p);
|
|
54
|
+
}
|
|
55
|
+
/** 호스트 경로의 마지막 이름. Windows 는 역슬래시도 구분자로 본다. */
|
|
56
|
+
export function hostBase(p, platform) {
|
|
57
|
+
return (platform === "win32" ? win32 : posix).basename(p);
|
|
58
|
+
}
|
|
59
|
+
/** 컨테이너 안 임시 폴더. 시각과 무작위 글자로 겹치지 않게 한다(동시에 두 번 돌려도). */
|
|
60
|
+
export function tempDirName(now = new Date()) {
|
|
61
|
+
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
62
|
+
return `${TEMP_PREFIX}${stamp}-${randomBytes(3).toString("hex")}`;
|
|
63
|
+
}
|
|
64
|
+
// ---- Docker 와 엔진 컨테이너 -----------------------------------------------------------------
|
|
65
|
+
/** docker 실행 파일과 데몬. 안 되면 종료 코드 4 의 실패. */
|
|
66
|
+
async function dockerReady(ctx) {
|
|
67
|
+
const docker = ctx.which("docker");
|
|
68
|
+
if (!docker) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
fail: {
|
|
72
|
+
error: "docker_not_found",
|
|
73
|
+
message: "docker 실행 파일을 찾지 못했습니다",
|
|
74
|
+
fix: `이 명령은 엔진이 설치된 컴퓨터에서 실행하세요. Docker 가 없으면 설치하세요: ${dockerInstallUrl(ctx.platform)}`,
|
|
75
|
+
code: EXIT_DOCKER,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const r = await ctx.run(docker, ["info", "--format", "{{json .}}"], { capture: true, timeoutMs: DOCKER_TIMEOUT_MS });
|
|
80
|
+
const parsed = parseDockerInfo(r.stdout);
|
|
81
|
+
if (!r.ok || !parsed.info) {
|
|
82
|
+
const reason = parsed.errors.length ? parsed.errors.join("; ") : r.stderr.trim() || failReason(r);
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
fail: {
|
|
86
|
+
error: "docker_unreachable",
|
|
87
|
+
message: `Docker 데몬에 닿지 못했습니다: ${oneLine(reason)}`,
|
|
88
|
+
fix: daemonFix(ctx.platform, isPermissionDenied(`${reason}\n${r.stderr}`)),
|
|
89
|
+
code: EXIT_DOCKER,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, docker };
|
|
94
|
+
}
|
|
95
|
+
/** `docker ps -a` 의 `--format` 값. 칸은 탭으로 나눈다. */
|
|
96
|
+
export const PS_FORMAT = '{{.ID}}\t{{.Image}}\t{{.Names}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}';
|
|
97
|
+
export function parseContainerRows(stdout) {
|
|
98
|
+
return stdout
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.filter((l) => l.trim())
|
|
101
|
+
.map((l) => {
|
|
102
|
+
const [id = "", image = "", names = "", service = "", state = ""] = l.split("\t");
|
|
103
|
+
// 연결(link)이 있으면 Names 칸에 이름이 쉼표로 여럿 온다. 앞의 것이 컨테이너 자신의 이름이다.
|
|
104
|
+
return { id: id.trim(), image: image.trim(), name: names.split(",")[0].trim(), service: service.trim(), state: state.trim() };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/** 무료판(aiakiv/engine)과 유료 기업판(aiakiv/engine-enterprise) 엔진 이미지. 레지스트리 앞머리는 무엇이든. */
|
|
108
|
+
export function isEngineImage(image) {
|
|
109
|
+
return /(^|\/)aiakiv\/engine(-enterprise)?(:|@|$)/i.test(image);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 엔진 컨테이너. 엔진 이미지이면서 compose 서비스가 engine 이거나 compose 밖에서 띄운 것. 같은 이미지로 도는
|
|
113
|
+
* 유료 기업판 control 컨테이너는 서비스 이름으로 뺀다. local doctor 의 isEngineContainer(이미지로 찾기)와 같은
|
|
114
|
+
* 생각이고, control 을 가르려고 서비스 라벨을 더 본다.
|
|
115
|
+
*/
|
|
116
|
+
export function isEngineRow(r) {
|
|
117
|
+
return isEngineImage(r.image) && (r.service === ENGINE_SERVICE || r.service === "");
|
|
118
|
+
}
|
|
119
|
+
const upFix = () => `엔진을 켜세요: docker compose -f ${ENGINE_COMPOSE} up -d (설치 폴더가 다르면 그 폴더의 compose 파일로). ` +
|
|
120
|
+
"엔진이 다른 이름으로 떠 있으면 --container <이름> 으로 고르세요.";
|
|
121
|
+
/**
|
|
122
|
+
* 컨테이너 목록에서 엔진 컨테이너(멈춘 것 포함)와 그중 떠 있는 것. findEngine 과 local doctor 의 engine.vectors 가
|
|
123
|
+
* 같이 쓴다. 떠 있는 것이 하나면 그것이 엔진이다.
|
|
124
|
+
*/
|
|
125
|
+
export function pickEngine(rows) {
|
|
126
|
+
const engines = rows.filter(isEngineRow);
|
|
127
|
+
return { engines, running: engines.filter((x) => x.state === "running") };
|
|
128
|
+
}
|
|
129
|
+
/** `docker ps -a` 로 모든 컨테이너(멈춘 것 포함)를 읽는다. 실패하면 RunResult 를 그대로 돌려준다. */
|
|
130
|
+
export async function listContainers(ctx, docker) {
|
|
131
|
+
const r = await ctx.run(docker, ["ps", "-a", "--no-trunc", "--format", PS_FORMAT], {
|
|
132
|
+
capture: true,
|
|
133
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
134
|
+
});
|
|
135
|
+
return r.ok ? { ok: true, rows: parseContainerRows(r.stdout) } : { ok: false, run: r };
|
|
136
|
+
}
|
|
137
|
+
/** 떠 있는 엔진 컨테이너 하나. 없거나 여럿이면 실패(안내 포함). */
|
|
138
|
+
export async function findEngine(ctx, docker, want) {
|
|
139
|
+
const listed = await listContainers(ctx, docker);
|
|
140
|
+
if (!listed.ok) {
|
|
141
|
+
const r = listed.run;
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
fail: {
|
|
145
|
+
error: "docker_unreachable",
|
|
146
|
+
message: `docker ps 가 실패했습니다: ${oneLine(failReason(r))}`,
|
|
147
|
+
fix: daemonFix(ctx.platform, isPermissionDenied(r.stderr)),
|
|
148
|
+
code: EXIT_DOCKER,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const rows = listed.rows;
|
|
153
|
+
if (want) {
|
|
154
|
+
// 사람이 고른 것은 이미지를 따지지 않는다(직접 붙인 태그일 수 있다). 이름이나 id 앞부분으로 찾는다.
|
|
155
|
+
const hit = rows.find((x) => x.name === want || (want.length >= 4 && x.id.startsWith(want)));
|
|
156
|
+
if (!hit) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
fail: {
|
|
160
|
+
error: "engine_not_found",
|
|
161
|
+
message: `--container ${want} 인 컨테이너가 없습니다`,
|
|
162
|
+
fix: "docker ps 로 엔진 컨테이너 이름을 확인하고 다시 주세요.",
|
|
163
|
+
code: EXIT_DOCKER,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (hit.state !== "running") {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
fail: {
|
|
171
|
+
error: "engine_not_running",
|
|
172
|
+
message: `엔진 컨테이너 ${hit.name} 가 떠 있지 않습니다(상태 ${hit.state || "알 수 없음"})`,
|
|
173
|
+
fix: `docker start ${hit.name} 로 켜거나, ${upFix()}`,
|
|
174
|
+
code: EXIT_DOCKER,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, container: hit };
|
|
179
|
+
}
|
|
180
|
+
const pick = pickEngine(rows);
|
|
181
|
+
if (pick.running.length === 1)
|
|
182
|
+
return { ok: true, container: pick.running[0] };
|
|
183
|
+
const running = pick.running;
|
|
184
|
+
if (running.length > 1) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
fail: {
|
|
188
|
+
error: "engine_ambiguous",
|
|
189
|
+
message: `떠 있는 엔진 컨테이너가 여럿입니다: ${running.map((x) => x.name).join(", ")}`,
|
|
190
|
+
fix: "--container <이름> 으로 하나를 고르세요.",
|
|
191
|
+
code: EXIT_USAGE,
|
|
192
|
+
extra: { containers: running.map((x) => x.name) },
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const stopped = pick.engines.map((x) => `${x.name}(${x.state || "알 수 없음"})`);
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
fail: {
|
|
200
|
+
error: "engine_not_running",
|
|
201
|
+
message: stopped.length
|
|
202
|
+
? `엔진 컨테이너가 떠 있지 않습니다: ${stopped.join(", ")}`
|
|
203
|
+
: "엔진 컨테이너를 찾지 못했습니다(aiakiv/engine 이미지로 도는 컨테이너가 없습니다)",
|
|
204
|
+
fix: upFix(),
|
|
205
|
+
code: EXIT_DOCKER,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** 컨테이너 안 임시 폴더를 만든다. 엔진의 기본 사용자로 만들어 엔진이 그 안에 쓸 수 있게 한다. */
|
|
210
|
+
async function makeTempDir(ctx, docker, c, dir) {
|
|
211
|
+
const r = await ctx.run(docker, ["exec", c.name, "mkdir", "-p", dir], { capture: true, timeoutMs: DOCKER_TIMEOUT_MS });
|
|
212
|
+
if (r.ok)
|
|
213
|
+
return null;
|
|
214
|
+
return {
|
|
215
|
+
error: "container_mkdir_failed",
|
|
216
|
+
message: `엔진 컨테이너 ${c.name} 안에 ${dir} 를 만들지 못했습니다: ${oneLine(failReason(r))}`,
|
|
217
|
+
fix: "엔진이 떠 있는지(docker ps), 데이터 볼륨 /data 에 쓸 수 있는지 확인하세요.",
|
|
218
|
+
code: EXIT_RUN_FAILED,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* 이 명령이 만든 임시 폴더를 지운다. docker cp 로 넣은 파일은 root 소유라 root 로 지운다. 이 명령이 만든 이름
|
|
223
|
+
* (TEMP_PREFIX)이 아니면 지우지 않는다. 실패해도 결과는 바꾸지 않고 표준 오류에 알린다.
|
|
224
|
+
*/
|
|
225
|
+
async function removeTempDir(ctx, docker, c, dir) {
|
|
226
|
+
if (!posix.basename(dir).startsWith(TEMP_PREFIX))
|
|
227
|
+
return false;
|
|
228
|
+
const r = await ctx.run(docker, ["exec", "-u", "0", c.name, "rm", "-rf", dir], {
|
|
229
|
+
capture: true,
|
|
230
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
231
|
+
});
|
|
232
|
+
if (!r.ok) {
|
|
233
|
+
ctx.err(`엔진 컨테이너 안의 임시 폴더 ${dir} 를 지우지 못했습니다(${oneLine(failReason(r))}). ` +
|
|
234
|
+
`docker exec -u 0 ${c.name} rm -rf ${dir} 로 지우세요.`);
|
|
235
|
+
}
|
|
236
|
+
return r.ok;
|
|
237
|
+
}
|
|
238
|
+
/** 엔진의 JSON 한 문서(사람용 들여쓰기 JSON 이어도). 못 읽으면 null. */
|
|
239
|
+
export function parseEngineDoc(stdout) {
|
|
240
|
+
const t = stdout.trim();
|
|
241
|
+
const start = t.indexOf("{");
|
|
242
|
+
if (start < 0)
|
|
243
|
+
return null;
|
|
244
|
+
try {
|
|
245
|
+
const v = JSON.parse(t.slice(start));
|
|
246
|
+
return isObj(v) ? v : null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** 엔진의 종료 코드가 없거나(실행 실패) 엔진의 결과가 없을 때의 실패. */
|
|
253
|
+
function noResultFail(command, code, stderrTail) {
|
|
254
|
+
const tail = stderrTail.length ? `: ${stderrTail.join(" / ")}` : "";
|
|
255
|
+
return {
|
|
256
|
+
error: "engine_no_result",
|
|
257
|
+
message: `aiakiv-admin ${command} 가 결과 없이 끝났습니다(종료 코드 ${code ?? "없음"})${tail}`,
|
|
258
|
+
fix: "엔진 로그(docker logs <엔진 컨테이너>)를 보고 고친 뒤 다시 돌리세요. 엔진 판이 이 명령보다 오래됐으면 엔진을 새 판으로 올리세요.",
|
|
259
|
+
code: EXIT_RUN_FAILED,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// ---- export ---------------------------------------------------------------------------------
|
|
263
|
+
export async function runLocalExport(ctx, opts) {
|
|
264
|
+
const hp = ctx.platform === "win32" ? win32 : posix;
|
|
265
|
+
// 1. 호스트 쪽 자리. 이미 있는 파일은 --yes 가 있어야 덮어쓴다. 폴더(이미 있는 폴더, 또는 끝이 / 나 \ 인 경로)면
|
|
266
|
+
// 그 안에 둔다(엔진의 --to 와 같은 규칙).
|
|
267
|
+
const dest = hostPath(ctx.cwd, opts.to, ctx.platform);
|
|
268
|
+
const destIsDir = isDir(dest) || (ctx.platform === "win32" ? /[\\/]$/ : /\/$/).test(opts.to);
|
|
269
|
+
if (!destIsDir && existsSync(dest) && !opts.yes) {
|
|
270
|
+
return emitFail(ctx, {
|
|
271
|
+
error: "target_exists",
|
|
272
|
+
message: `${dest} 가 이미 있습니다`,
|
|
273
|
+
fix: "덮어쓰려면 --yes 를 붙이거나, --to 에 다른 파일 이름이나 폴더를 주세요.",
|
|
274
|
+
code: EXIT_USAGE,
|
|
275
|
+
extra: { path: dest },
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// 2. Docker 와 엔진 컨테이너.
|
|
279
|
+
const d = await dockerReady(ctx);
|
|
280
|
+
if (!d.ok)
|
|
281
|
+
return emitFail(ctx, d.fail);
|
|
282
|
+
const e = await findEngine(ctx, d.docker, opts.container);
|
|
283
|
+
if (!e.ok)
|
|
284
|
+
return emitFail(ctx, e.fail);
|
|
285
|
+
const c = e.container;
|
|
286
|
+
// 3. 컨테이너 안 임시 폴더에 내보낸다. 새로 만든 빈 폴더라 엔진에는 --yes 를 준다(폴더가 이미 있다는 거절을 피하려고).
|
|
287
|
+
// 엔진은 도메인이 하나면 그 폴더 안에 <팀>_<도메인>_<시각>.mweft.tar.gz 를, 여럿이면 그 폴더에 묶음을 쓴다.
|
|
288
|
+
const tmp = posix.join(CONTAINER_EXPORT_DIR, tempDirName());
|
|
289
|
+
const mk = await makeTempDir(ctx, d.docker, c, tmp);
|
|
290
|
+
if (mk)
|
|
291
|
+
return emitFail(ctx, mk);
|
|
292
|
+
const args = ["exec", c.name, "aiakiv-admin", "export", opts.team, "--to", tmp, "--yes", "--json"];
|
|
293
|
+
if (opts.domain !== undefined)
|
|
294
|
+
args.push("--domain", opts.domain);
|
|
295
|
+
const stdout = [];
|
|
296
|
+
const stderrTail = [];
|
|
297
|
+
const res = await ctx.runLines(d.docker, args, {
|
|
298
|
+
onStdout: (l) => stdout.push(l),
|
|
299
|
+
onStderr: (l) => {
|
|
300
|
+
ctx.err(l);
|
|
301
|
+
if (l.trim())
|
|
302
|
+
stderrTail.splice(0, stderrTail.length, ...[...stderrTail, l].slice(-5));
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
const doc = res.error ? null : parseEngineDoc(stdout.join("\n"));
|
|
306
|
+
if (!doc || typeof doc.ok !== "boolean") {
|
|
307
|
+
await removeTempDir(ctx, d.docker, c, tmp);
|
|
308
|
+
if (res.error) {
|
|
309
|
+
return emitFail(ctx, {
|
|
310
|
+
error: "docker_exec_failed",
|
|
311
|
+
message: `docker exec 를 실행하지 못했습니다: ${res.error}`,
|
|
312
|
+
fix: "Docker 가 켜져 있는지 확인하고 다시 돌리세요.",
|
|
313
|
+
code: EXIT_RUN_FAILED,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return emitFail(ctx, noResultFail("export", res.code, stderrTail));
|
|
317
|
+
}
|
|
318
|
+
if (doc.ok !== true || res.code !== EXIT_OK) {
|
|
319
|
+
// 엔진의 실패는 그 JSON 과 종료 코드 그대로.
|
|
320
|
+
await removeTempDir(ctx, d.docker, c, tmp);
|
|
321
|
+
ctx.out(JSON.stringify(doc));
|
|
322
|
+
return res.code ?? EXIT_RUN_FAILED;
|
|
323
|
+
}
|
|
324
|
+
// 4. 호스트로 꺼낸다. 하나(archive)는 파일, 묶음(bundle)은 폴더의 내용을 --to 폴더에.
|
|
325
|
+
const kind = doc.kind === "bundle" ? "bundle" : "archive";
|
|
326
|
+
const cpath = typeof doc.path === "string" && doc.path ? doc.path : tmp;
|
|
327
|
+
let target;
|
|
328
|
+
let src;
|
|
329
|
+
if (kind === "archive") {
|
|
330
|
+
target = destIsDir ? hp.join(dest, posix.basename(cpath)) : dest;
|
|
331
|
+
src = cpath;
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
// 묶음은 폴더의 내용(`<폴더>/.`)을 --to 폴더에 꺼낸다. 없는 폴더면 docker cp 가 만든다.
|
|
335
|
+
target = dest;
|
|
336
|
+
src = `${cpath.replace(/\/+$/, "")}/.`;
|
|
337
|
+
let refuse = null;
|
|
338
|
+
if (existsSync(dest) && !destIsDir) {
|
|
339
|
+
refuse = {
|
|
340
|
+
error: "target_not_directory",
|
|
341
|
+
message: `${dest} 는 파일인데, 이 팀은 도메인이 여럿이라 묶음(폴더)으로 나왔습니다`,
|
|
342
|
+
fix: "--to 에 새 폴더 경로를 주거나, --domain 으로 도메인 하나만 내보내세요.",
|
|
343
|
+
code: EXIT_USAGE,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
else if (destIsDir && existsSync(hp.join(dest, "org_manifest.json")) && !opts.yes) {
|
|
347
|
+
refuse = {
|
|
348
|
+
error: "target_exists",
|
|
349
|
+
message: `${dest} 에 이미 묶음(org_manifest.json)이 있습니다`,
|
|
350
|
+
fix: "--to 에 새 폴더 경로를 주거나, 그 폴더의 묶음을 덮어쓰려면 --yes 를 붙이세요.",
|
|
351
|
+
code: EXIT_USAGE,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (refuse) {
|
|
355
|
+
await removeTempDir(ctx, d.docker, c, tmp);
|
|
356
|
+
return emitFail(ctx, { ...refuse, extra: { path: dest, kind } });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
mkdirSync(kind === "bundle" ? dest : hp.dirname(target), { recursive: true });
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
/* 아래 docker cp 가 이유를 낸다 */
|
|
364
|
+
}
|
|
365
|
+
const cp = await ctx.run(d.docker, ["cp", `${c.name}:${src}`, target], { capture: true });
|
|
366
|
+
if (!cp.ok) {
|
|
367
|
+
// 꺼내지 못했으면 컨테이너 안 파일은 남겨 두고 손으로 꺼내는 명령을 알린다.
|
|
368
|
+
return emitFail(ctx, {
|
|
369
|
+
error: "copy_failed",
|
|
370
|
+
message: `내보낸 파일을 호스트로 꺼내지 못했습니다(docker cp): ${oneLine(failReason(cp))}`,
|
|
371
|
+
fix: `--to 의 폴더에 쓸 수 있는지 확인하세요. 파일은 엔진 컨테이너 안 ${cpath} 에 남아 있습니다: docker cp ${c.name}:${src} "${target}"`,
|
|
372
|
+
code: EXIT_RUN_FAILED,
|
|
373
|
+
extra: { container: { name: c.name, path: cpath, removed: false }, engine: doc },
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
const removed = await removeTempDir(ctx, d.docker, c, tmp);
|
|
377
|
+
ctx.out(JSON.stringify({
|
|
378
|
+
ok: true,
|
|
379
|
+
command: "local export",
|
|
380
|
+
kind,
|
|
381
|
+
path: target,
|
|
382
|
+
container: { name: c.name, path: cpath, removed },
|
|
383
|
+
engine: doc,
|
|
384
|
+
}));
|
|
385
|
+
return EXIT_OK;
|
|
386
|
+
}
|
|
387
|
+
function isDir(p) {
|
|
388
|
+
try {
|
|
389
|
+
return statSync(p).isDirectory();
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ---- import ---------------------------------------------------------------------------------
|
|
396
|
+
export async function runLocalImport(ctx, opts) {
|
|
397
|
+
// 1. 호스트 파일(아카이브 하나 또는 묶음 폴더). 내용은 엔진이 본다.
|
|
398
|
+
const src = hostPath(ctx.cwd, opts.file, ctx.platform);
|
|
399
|
+
if (!existsSync(src)) {
|
|
400
|
+
return emitFail(ctx, {
|
|
401
|
+
error: "file_not_found",
|
|
402
|
+
message: `${src} 가 없습니다`,
|
|
403
|
+
fix: "가져올 .mweft.tar.gz 파일(또는 묶음 폴더)의 경로를 확인하세요. 상대 경로는 지금 폴더 기준입니다.",
|
|
404
|
+
code: EXIT_USAGE,
|
|
405
|
+
extra: { path: src },
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
// 2. Docker 와 엔진 컨테이너.
|
|
409
|
+
const d = await dockerReady(ctx);
|
|
410
|
+
if (!d.ok)
|
|
411
|
+
return emitFail(ctx, d.fail);
|
|
412
|
+
const e = await findEngine(ctx, d.docker, opts.container);
|
|
413
|
+
if (!e.ok)
|
|
414
|
+
return emitFail(ctx, e.fail);
|
|
415
|
+
const c = e.container;
|
|
416
|
+
// 3. 컨테이너 안 임시 폴더로 옮긴다. 이름은 호스트의 것 그대로(엔진의 안내 문장에 나온다).
|
|
417
|
+
const tmp = posix.join(CONTAINER_IMPORT_DIR, tempDirName());
|
|
418
|
+
const mk = await makeTempDir(ctx, d.docker, c, tmp);
|
|
419
|
+
if (mk)
|
|
420
|
+
return emitFail(ctx, mk);
|
|
421
|
+
const cpath = posix.join(tmp, hostBase(src, ctx.platform) || "import.mweft.tar.gz");
|
|
422
|
+
const cp = await ctx.run(d.docker, ["cp", src, `${c.name}:${cpath}`], { capture: true });
|
|
423
|
+
if (!cp.ok) {
|
|
424
|
+
await removeTempDir(ctx, d.docker, c, tmp);
|
|
425
|
+
return emitFail(ctx, {
|
|
426
|
+
error: "copy_failed",
|
|
427
|
+
message: `파일을 엔진 컨테이너 안으로 옮기지 못했습니다(docker cp): ${oneLine(failReason(cp))}`,
|
|
428
|
+
fix: "파일을 읽을 수 있는지, 엔진의 데이터 볼륨에 빈 자리가 있는지 확인하세요.",
|
|
429
|
+
code: EXIT_RUN_FAILED,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
ctx.out(JSON.stringify({ step: "cli.copy", done: 1, total: 1, container: c.name, path: cpath }));
|
|
433
|
+
// 4. 엔진의 가져오기. 줄마다 받는 대로 낸다. --yes 가 없으면 엔진이 계획만 내고 종료 코드 2 로 끝난다.
|
|
434
|
+
const args = ["exec", c.name, "aiakiv-admin", "import", cpath, "--team", opts.team];
|
|
435
|
+
if (opts.as !== undefined)
|
|
436
|
+
args.push("--as", opts.as);
|
|
437
|
+
if (opts.domain !== undefined)
|
|
438
|
+
args.push("--domain", opts.domain);
|
|
439
|
+
if (opts.dryRun)
|
|
440
|
+
args.push("--dry-run");
|
|
441
|
+
if (opts.resume)
|
|
442
|
+
args.push("--resume");
|
|
443
|
+
if (opts.yes)
|
|
444
|
+
args.push("--yes");
|
|
445
|
+
args.push("--json");
|
|
446
|
+
let result = null;
|
|
447
|
+
const stderrTail = [];
|
|
448
|
+
const res = await ctx.runLines(d.docker, args, {
|
|
449
|
+
onStdout: (line) => {
|
|
450
|
+
const t = line.trim();
|
|
451
|
+
let obj = null;
|
|
452
|
+
if (t.startsWith("{")) {
|
|
453
|
+
try {
|
|
454
|
+
obj = JSON.parse(t);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
obj = null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!isObj(obj)) {
|
|
461
|
+
// 표준 출력은 JSON 만 둔다. 엔진이 JSON 이 아닌 줄을 내면 표준 오류로 옮긴다.
|
|
462
|
+
if (t)
|
|
463
|
+
ctx.err(line);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
ctx.out(line);
|
|
467
|
+
if (typeof obj.ok === "boolean")
|
|
468
|
+
result = obj;
|
|
469
|
+
},
|
|
470
|
+
onStderr: (l) => {
|
|
471
|
+
ctx.err(l);
|
|
472
|
+
if (l.trim())
|
|
473
|
+
stderrTail.splice(0, stderrTail.length, ...[...stderrTail, l].slice(-5));
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
// 5. 임시 폴더는 결과와 상관없이 지운다. --resume 은 호스트 파일을 다시 옮겨 오므로 남길 필요가 없다
|
|
477
|
+
// (엔진은 파일 이름이 아니라 내용 지문으로 가져오기 기록을 찾는다).
|
|
478
|
+
await removeTempDir(ctx, d.docker, c, tmp);
|
|
479
|
+
if (res.error) {
|
|
480
|
+
return emitFail(ctx, {
|
|
481
|
+
error: "docker_exec_failed",
|
|
482
|
+
message: `docker exec 를 실행하지 못했습니다: ${res.error}`,
|
|
483
|
+
fix: "Docker 가 켜져 있는지 확인하고 다시 돌리세요.",
|
|
484
|
+
code: EXIT_RUN_FAILED,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
const final = result;
|
|
488
|
+
if (!final)
|
|
489
|
+
return emitFail(ctx, noResultFail("import", res.code, stderrTail));
|
|
490
|
+
if (final.error === "confirmation_required") {
|
|
491
|
+
// --yes 없이 계획만 낸 경우(엔진 종료 코드 2). 사람에게 다음 걸음을 알린다.
|
|
492
|
+
ctx.err("계획만 냈습니다. 위 계획대로 가져오려면 같은 명령에 --yes 를 붙여 다시 돌리세요.");
|
|
493
|
+
}
|
|
494
|
+
return res.code ?? EXIT_RUN_FAILED;
|
|
495
|
+
}
|