charge-connect 0.1.8 → 0.2.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.
Files changed (4) hide show
  1. package/cli.js +226 -16
  2. package/collect.js +964 -97
  3. package/package.json +4 -3
  4. package/updater.js +967 -0
package/updater.js ADDED
@@ -0,0 +1,967 @@
1
+ // Charge 수집기 자동 업데이트, 서명된 릴리스 매니페스트를 확인하고 ~/.charge/app 파일을 교체한다.
2
+ // collect.js가 업로드를 마친 뒤 부른다. 설치 스크립트나 스케줄 정의는 다시 돌리지 않는다.
3
+ //
4
+ // 신뢰 사슬: 서버(charge_latest_collector)는 배포 채널일 뿐 신뢰 근거가 아니다.
5
+ // 매니페스트는 아래 공개키로 서명돼 있어야 하고, tarball 주소는 npm 레지스트리의 정해진 형식,
6
+ // 내용물은 서명에 들어간 sha512 무결성과 일치해야 한다. 셋 중 하나라도 어긋나면 아무것도 쓰지 않는다.
7
+ //
8
+ // 이 파일은 node: 내장 모듈만 불러온다. 교체가 끊겨 옛 파일과 새 파일이 섞인 앱 폴더에서도 collect.js가
9
+ // 이 파일을 불러 recoverInterruptedUpdate로 복구할 수 있어야 한다 (로컬 모듈을 require하면 그 전제가 깨진다).
10
+
11
+ const fs = require("node:fs");
12
+ const path = require("node:path");
13
+ const crypto = require("node:crypto");
14
+ const zlib = require("node:zlib");
15
+ const { execFile } = require("node:child_process");
16
+ const { promisify } = require("node:util");
17
+
18
+ const execFileAsync = promisify(execFile);
19
+ const gunzipAsync = promisify(zlib.gunzip);
20
+
21
+ const PACKAGE_NAME = "charge-connect";
22
+ // 릴리스 서명 공개키 목록 (Ed25519, SPKI DER, base64). 매니페스트의 key_id로 고르고, 목록에 없는 id는 거부한다.
23
+ // 키를 바꿀 때는 새 id를 더한 수집기를 먼저 내보낸 뒤 새 키로 서명한다. 개인키는 저장소 밖에만 있다
24
+ // (~/.charge/release-signing-key.pem).
25
+ const RELEASE_KEYS = Object.freeze({
26
+ k1: "MCowBQYDK2VwAyEAPUtk9Ynk8VdZNrEns+J9TsNmO5lUMRt7U0uslbeOp6U=",
27
+ });
28
+ // 서명 대상 문자열의 첫 줄. 형식을 바꾸면 v2로 올린다 (옛 수집기가 새 형식의 서명을 다른 뜻으로 읽지 않게).
29
+ const RELEASE_MESSAGE_PREFIX = "charge-connect-release/v1";
30
+
31
+ const CHECK_INTERVAL_MS = 12 * 3600_000;
32
+ // 같은 시각에 설치된 기기들이 한꺼번에 몰리지 않게 다음 확인 시각을 최대 60분 흩뜨린다
33
+ const CHECK_JITTER_MS = 60 * 60_000;
34
+ const MANIFEST_TIMEOUT_MS = 10_000;
35
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
36
+ const MAX_TARBALL_BYTES = 2 * 1024 * 1024;
37
+ // 압축 해제 폭탄 방지, 정상 패키지는 수백 KB다
38
+ const MAX_UNPACKED_BYTES = 16 * 1024 * 1024;
39
+ const SYNTAX_CHECK_TIMEOUT_MS = 20_000;
40
+ const SELF_TEST_TIMEOUT_MS = 20_000;
41
+ // Windows에서 백신이나 검색 인덱서가 파일을 잠깐 잡고 있으면 rename이 실패한다, 되돌리기 전에 다시 시도한다
42
+ const RENAME_RETRIES = 5;
43
+ const RENAME_RETRY_DELAY_MS = 200;
44
+ // 설치 후 자체 점검에 실패해 다시 설치하지 않을 릴리스를 몇 개까지 기억할지
45
+ const MAX_FAILED_RELEASES = 20;
46
+ // 업데이트가 도중에 죽어 잠금 파일이 남아도 영원히 막히지 않게 한다
47
+ const LOCK_STALE_MS = 10 * 60_000;
48
+ // 파일 교체가 진행 중이라는 표시 (앱 폴더 안). collect.js가 맨 앞에서 같은 이름으로 확인한다.
49
+ const UPDATE_MARKER = ".update-in-progress.json";
50
+ const WIN = process.platform === "win32";
51
+
52
+ // 릴리스 버전은 x.y.z 숫자만 받는다. 프리릴리스나 빌드 메타데이터는 자동 업데이트 대상이 아니다.
53
+ function parseReleaseVersion(value) {
54
+ const match = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/.exec(typeof value === "string" ? value : "");
55
+ return match ? match.slice(1, 4).map(Number) : null;
56
+ }
57
+
58
+ function isNewerRelease(current, candidate) {
59
+ const a = parseReleaseVersion(current);
60
+ const b = parseReleaseVersion(candidate);
61
+ if (!a || !b) return false;
62
+ for (let i = 0; i < 3; i += 1) {
63
+ if (a[i] !== b[i]) return b[i] > a[i];
64
+ }
65
+ return false;
66
+ }
67
+
68
+ function releaseTarballURL(version) {
69
+ return `https://registry.npmjs.org/${PACKAGE_NAME}/-/${PACKAGE_NAME}-${version}.tgz`;
70
+ }
71
+
72
+ // 서명 대상 문자열, 서버 테이블(charge_collector_releases)과 발행 스크립트가 같은 형식을 쓴다.
73
+ // key_id가 들어가 있어 한 키의 서명을 다른 키 id의 매니페스트로 옮겨 쓸 수 없다.
74
+ function releaseMessage({ key_id, version, integrity, tarball }) {
75
+ return `${RELEASE_MESSAGE_PREFIX}\n${key_id}\n${version}\n${integrity}\n${tarball}`;
76
+ }
77
+
78
+ // 매니페스트 필드 형식 (D14). 서명을 확인하기 전에 모든 필드를 거른다. 서버 CHECK 제약, 발행 스크립트도 같은 규칙이다.
79
+ const MANIFEST_FORMATS = Object.freeze({
80
+ key_id: /^[a-z0-9]{1,16}$/,
81
+ version: /^\d{1,6}\.\d{1,6}\.\d{1,6}$/,
82
+ integrity: /^sha512-[A-Za-z0-9+/]{86}==$/,
83
+ // Ed25519 서명 64바이트의 base64
84
+ signature: /^[A-Za-z0-9+/]{86}==$/,
85
+ });
86
+
87
+ // 반환: 형식이 맞으면 null, 아니면 이유. 이유에는 서버가 준 값을 넣지 않는다 (로그 줄 위조 방지).
88
+ function manifestFormatError(manifest) {
89
+ if (!manifest || typeof manifest !== "object") return "매니페스트 형식이 올바르지 않습니다";
90
+ for (const [field, pattern] of Object.entries(MANIFEST_FORMATS)) {
91
+ if (typeof manifest[field] !== "string" || !pattern.test(manifest[field])) return `매니페스트 ${field} 형식 불일치`;
92
+ }
93
+ // 조각마다 0 또는 앞자리 0이 없는 숫자만 (npm 버전 규칙, 서버 제약과 같다)
94
+ if (!parseReleaseVersion(manifest.version)) return "매니페스트 version 형식 불일치";
95
+ if (manifest.tarball !== releaseTarballURL(manifest.version)) return "tarball 주소 형식 불일치";
96
+ return null;
97
+ }
98
+
99
+ function publicKeyObject(publicKey) {
100
+ if (publicKey instanceof crypto.KeyObject) return publicKey;
101
+ return crypto.createPublicKey({ key: Buffer.from(publicKey, "base64"), format: "der", type: "spki" });
102
+ }
103
+
104
+ // releaseKeys는 { key_id: 공개키 } 목록이다 (테스트는 임시 키 목록을 넘긴다)
105
+ function verifyReleaseSignature(manifest, releaseKeys = RELEASE_KEYS) {
106
+ try {
107
+ if (manifestFormatError(manifest)) return false;
108
+ if (!releaseKeys || !Object.hasOwn(releaseKeys, manifest.key_id)) return false;
109
+ const key = publicKeyObject(releaseKeys[manifest.key_id]);
110
+ if (key.asymmetricKeyType !== "ed25519") return false;
111
+ const signature = Buffer.from(manifest.signature, "base64");
112
+ if (signature.length !== 64) return false;
113
+ return crypto.verify(null, Buffer.from(releaseMessage(manifest), "utf8"), key, signature);
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function sha512Integrity(buffer) {
120
+ return `sha512-${crypto.createHash("sha512").update(buffer).digest("base64")}`;
121
+ }
122
+
123
+ // 반환: { ok: true } 또는 { ok: false, reason, quiet }. quiet은 평상시 결과(이미 최신)라 로그를 남기지 않는다.
124
+ // 순서: 필드 형식, 알려진 키 id, 서명, 버전 비교.
125
+ function checkManifest(manifest, { currentVersion, releaseKeys = RELEASE_KEYS } = {}) {
126
+ const formatError = manifestFormatError(manifest);
127
+ if (formatError) return { ok: false, reason: formatError };
128
+ // key_id는 위에서 [a-z0-9]{1,16}로 걸렀으므로 로그에 넣어도 된다
129
+ if (!releaseKeys || !Object.hasOwn(releaseKeys, manifest.key_id)) {
130
+ return { ok: false, reason: `알 수 없는 서명 키 id (${manifest.key_id})` };
131
+ }
132
+ if (!verifyReleaseSignature(manifest, releaseKeys)) return { ok: false, reason: "서명 검증 실패" };
133
+ if (!parseReleaseVersion(currentVersion)) return { ok: false, reason: "현재 버전을 알 수 없습니다", quiet: true };
134
+ if (!isNewerRelease(currentVersion, manifest.version)) return { ok: false, reason: "이미 최신입니다", quiet: true };
135
+ return { ok: true };
136
+ }
137
+
138
+ // 로그로 내보내는 문자열에서 제어 문자(줄바꿈, 터미널 이스케이프, 글자 방향 전환)를 지우고 길이를 자른다
139
+ function cleanLogText(text, max = 500) {
140
+ return String(text ?? "").replace(/[\p{Cc}\p{Bidi_Control}\p{Zl}\p{Zp}]/gu, " ").slice(0, max);
141
+ }
142
+
143
+ // 응답 본문을 상한까지만 읽는다. 상한을 넘으면 그 자리에서 끊고 실패한다.
144
+ async function readCapped(response, cap) {
145
+ const declared = Number(response.headers?.get?.("content-length"));
146
+ if (Number.isFinite(declared) && declared > cap) throw new Error(`다운로드 크기 상한 초과 (${declared} bytes)`);
147
+ if (response.body && typeof response.body.getReader === "function") {
148
+ const reader = response.body.getReader();
149
+ const chunks = [];
150
+ let total = 0;
151
+ for (;;) {
152
+ const { done, value } = await reader.read();
153
+ if (done) break;
154
+ total += value.byteLength;
155
+ if (total > cap) {
156
+ await reader.cancel().catch(() => {});
157
+ throw new Error("다운로드 크기 상한 초과");
158
+ }
159
+ chunks.push(Buffer.from(value));
160
+ }
161
+ return Buffer.concat(chunks);
162
+ }
163
+ const buffer = Buffer.from(await response.arrayBuffer());
164
+ if (buffer.length > cap) throw new Error("다운로드 크기 상한 초과");
165
+ return buffer;
166
+ }
167
+
168
+ function cString(bytes) {
169
+ const end = bytes.indexOf(0);
170
+ return bytes.subarray(0, end === -1 ? bytes.length : end).toString("utf8");
171
+ }
172
+
173
+ function parseOctal(bytes) {
174
+ // base-256(최상위 비트) 인코딩은 8GB 넘는 항목에나 쓰인다, 여기서는 받을 이유가 없다
175
+ if (bytes[0] & 0x80) return null;
176
+ const text = bytes.toString("latin1").replace(/[\0 ]+$/g, "").replace(/^ +/, "");
177
+ if (!/^[0-7]*$/.test(text)) return null;
178
+ return text ? parseInt(text, 8) : 0;
179
+ }
180
+
181
+ // pax 확장 헤더: "길이 키=값\n" 레코드의 나열
182
+ function parsePax(data) {
183
+ const out = {};
184
+ let pos = 0;
185
+ while (pos < data.length) {
186
+ const space = data.indexOf(0x20, pos);
187
+ if (space === -1) throw new Error("pax 헤더가 깨졌습니다");
188
+ const length = Number(data.subarray(pos, space).toString("latin1"));
189
+ if (!Number.isSafeInteger(length) || length <= space - pos || pos + length > data.length) {
190
+ throw new Error("pax 헤더가 깨졌습니다");
191
+ }
192
+ const record = data.subarray(space + 1, pos + length).toString("utf8").replace(/\n$/, "");
193
+ const eq = record.indexOf("=");
194
+ if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
195
+ pos += length;
196
+ }
197
+ return out;
198
+ }
199
+
200
+ // 최소 tar 리더 (ustar, pax, GNU 긴 이름). 링크, 장치 파일 판정은 호출자가 한다.
201
+ function parseTar(buffer) {
202
+ const entries = [];
203
+ let offset = 0;
204
+ let pax = null;
205
+ let longName = null;
206
+ let ended = false;
207
+ while (offset + 512 <= buffer.length) {
208
+ const header = buffer.subarray(offset, offset + 512);
209
+ if (header.every((byte) => byte === 0)) {
210
+ ended = true;
211
+ break;
212
+ }
213
+ const stored = parseOctal(header.subarray(148, 156));
214
+ let sum = 0;
215
+ for (let i = 0; i < 512; i += 1) sum += i >= 148 && i < 156 ? 0x20 : header[i];
216
+ if (stored === null || stored !== sum) throw new Error("tar 헤더 체크섬 불일치");
217
+ const size = parseOctal(header.subarray(124, 136));
218
+ if (size === null) throw new Error("tar 항목 크기를 읽을 수 없습니다");
219
+ const type = header[156] === 0 ? "0" : String.fromCharCode(header[156]);
220
+ let name = cString(header.subarray(0, 100));
221
+ if (header.subarray(257, 263).toString("latin1") === "ustar\0") {
222
+ const prefix = cString(header.subarray(345, 500));
223
+ if (prefix) name = `${prefix}/${name}`;
224
+ }
225
+ const start = offset + 512;
226
+ const end = start + size;
227
+ if (end > buffer.length) throw new Error("tar가 중간에 잘렸습니다");
228
+ const data = buffer.subarray(start, end);
229
+ offset = start + Math.ceil(size / 512) * 512;
230
+
231
+ if (type === "x") {
232
+ pax = parsePax(data);
233
+ continue;
234
+ }
235
+ if (type === "g") {
236
+ // 전역 헤더가 경로를 바꾸면 이후 모든 항목 이름을 믿을 수 없다
237
+ const global = parsePax(data);
238
+ if ("path" in global || "linkpath" in global) throw new Error("전역 pax 경로 헤더는 허용하지 않습니다");
239
+ continue;
240
+ }
241
+ if (type === "L") {
242
+ longName = cString(data);
243
+ continue;
244
+ }
245
+ entries.push({
246
+ name: pax?.path ?? longName ?? name,
247
+ type,
248
+ linkname: pax?.linkpath ?? cString(header.subarray(157, 257)),
249
+ data,
250
+ });
251
+ pax = null;
252
+ longName = null;
253
+ }
254
+ if (!ended) throw new Error("tar 끝 표시가 없습니다");
255
+ return entries;
256
+ }
257
+
258
+ // tgz에서 설치할 파일만 골라낸다. 구조 위반(링크, 경로 탈출, 하위 폴더)은 업데이트 전체를 거부한다.
259
+ // 허용 목록(새 package.json의 files)에 없는 최상위 일반 파일은 설치하지 않고 건너뛴다
260
+ // (npm이 README, LICENSE를 files와 무관하게 넣기 때문이다).
261
+ async function extractRelease(tgz, expectedVersion) {
262
+ const tar = await gunzipAsync(tgz, { maxOutputLength: MAX_UNPACKED_BYTES });
263
+ const regular = new Map();
264
+ for (const entry of parseTar(tar)) {
265
+ const name = entry.name;
266
+ if (!name || name.includes("\\") || name.includes("\0")) throw new Error(`허용하지 않는 경로: ${JSON.stringify(name)}`);
267
+ if (name.startsWith("/") || /^[A-Za-z]:/.test(name)) throw new Error(`절대 경로 항목: ${name}`);
268
+ const segments = name.replace(/\/+$/, "").split("/");
269
+ if (segments.includes("..") || segments.includes(".")) throw new Error(`상위 경로 항목: ${name}`);
270
+ if (entry.type === "5") {
271
+ if (segments.length === 1 && segments[0] === "package") continue;
272
+ throw new Error(`하위 폴더 항목: ${name}`);
273
+ }
274
+ if (entry.type === "1" || entry.type === "2" || entry.type === "K") throw new Error(`링크 항목: ${name}`);
275
+ if (entry.type !== "0") throw new Error(`일반 파일이 아닌 항목(${entry.type}): ${name}`);
276
+ if (segments[0] !== "package") throw new Error(`package/ 밖의 항목: ${name}`);
277
+ if (segments.length !== 2) throw new Error(`하위 폴더 항목: ${name}`);
278
+ const file = segments[1];
279
+ if (!/^[A-Za-z0-9._-]+$/.test(file)) throw new Error(`허용하지 않는 파일 이름: ${JSON.stringify(file)}`);
280
+ if (regular.has(file)) throw new Error(`중복 항목: ${name}`);
281
+ regular.set(file, entry.data);
282
+ }
283
+
284
+ const manifestFile = regular.get("package.json");
285
+ if (!manifestFile) throw new Error("package.json이 없습니다");
286
+ let pkg;
287
+ try {
288
+ pkg = JSON.parse(manifestFile.toString("utf8"));
289
+ } catch {
290
+ throw new Error("package.json을 해석할 수 없습니다");
291
+ }
292
+ if (pkg?.name !== PACKAGE_NAME) throw new Error(`패키지 이름 불일치 (${pkg?.name})`);
293
+ if (pkg.version !== expectedVersion) throw new Error(`패키지 버전 불일치 (${pkg.version})`);
294
+ if (!Array.isArray(pkg.files)) throw new Error("package.json에 files 목록이 없습니다");
295
+ const allowed = new Set(pkg.files.filter((f) => typeof f === "string" && /^[A-Za-z0-9._-]+$/.test(f)));
296
+ allowed.add("package.json");
297
+
298
+ const files = new Map();
299
+ for (const [file, data] of regular) {
300
+ if (allowed.has(file)) files.set(file, data);
301
+ }
302
+ if (!files.has("collect.js")) throw new Error("collect.js가 없습니다");
303
+ for (const [file, data] of files) {
304
+ if (!file.endsWith(".json")) continue;
305
+ try {
306
+ JSON.parse(data.toString("utf8"));
307
+ } catch {
308
+ throw new Error(`${file}을 해석할 수 없습니다`);
309
+ }
310
+ }
311
+ return { files, pkg };
312
+ }
313
+
314
+ async function nodeSyntaxCheck(file) {
315
+ await execFileAsync(process.execPath, ["--check", file], { timeout: SYNTAX_CHECK_TIMEOUT_MS, windowsHide: true });
316
+ }
317
+
318
+ // 문법은 맞아도 불러오는 순간 죽는 릴리스(files에서 빠진 모듈, 이 Node에 없는 API)를 설치하면 다음 수집이
319
+ // 시작도 못 해 업데이트 확인까지 닿지 못하고, 모든 기기가 수동 복구를 기다리게 된다. 그래서 그 릴리스의
320
+ // collect.js를 --self-test로 별도 프로세스에서 실행한다 (D9). 자체 점검은 모든 런타임 모듈을 불러온 뒤
321
+ // "charge-connect self-test ok <버전>"을 찍고 끝나며, 네트워크, 키체인, 상태 파일에는 닿지 않는다.
322
+ // 교체 전에는 스테이징 폴더에서, 교체 후에는 앱 폴더에서 같은 점검을 돌린다. 결과 줄의 버전이 설치하려는
323
+ // 버전과 같아야 통과다. --self-test를 모르는 collect.js라면 수집을 시작할 수 있으므로 --dry-run(업로드와
324
+ // 실패 기록 없음)과 CHARGE_SKIP_UPDATE=1을 함께 주고, 결과 줄이 없거나 시간 초과면 실패로 본다.
325
+ // Windows 작업 스케줄러, systemd, cron은 collect.js에 --log를 붙여 부른다. 그 시작 코드(로그 파일, 오류 처리기)에서
326
+ // 죽는 릴리스도 걸러야 하므로 점검에도 버리는 로그 파일을 준다. 위치는 dir 옆(~/.charge, 스테이징을 만든 곳)이다.
327
+ async function nodeSelfTest(dir, expectedVersion) {
328
+ const logDir = await fs.promises.mkdtemp(path.join(path.dirname(dir), "app.self-test-"));
329
+ const logFile = path.join(logDir, "self-test.log");
330
+ const readLog = () => fs.promises.readFile(logFile, "utf8").catch(() => "");
331
+ try {
332
+ let stdout;
333
+ try {
334
+ ({ stdout } = await execFileAsync(process.execPath, [path.join(dir, "collect.js"), "--self-test", "--dry-run", "--log", logFile], {
335
+ cwd: dir,
336
+ timeout: SELF_TEST_TIMEOUT_MS,
337
+ windowsHide: true,
338
+ env: { ...process.env, CHARGE_SKIP_UPDATE: "1" },
339
+ }));
340
+ } catch (e) {
341
+ // --log를 받은 collect.js는 오류를 stderr가 아니라 로그 파일에 쓴다. 원인 줄을 고를 수 있게 줄머리([시각] 라벨: )를 떼어 붙인다
342
+ const logged = (await readLog()).replace(/^\[[^\]\n]*\] [^:\n]*: /gm, "");
343
+ if (logged.trim()) e.stderr = `${e.stderr ?? ""}\n${logged}`;
344
+ throw e;
345
+ }
346
+ const expected = `${PACKAGE_NAME} self-test ok ${expectedVersion}`;
347
+ // --log를 따르는 collect.js는 결과 줄도 로그 파일에 쓴다
348
+ if (!`${stdout}\n${await readLog()}`.split(/\r?\n/).some((line) => line.trim() === expected)) {
349
+ throw new Error(`자체 점검 결과 줄이 없습니다 (기대: ${expected})`);
350
+ }
351
+ } finally {
352
+ await fs.promises.rm(logDir, { recursive: true, force: true }).catch(() => {});
353
+ }
354
+ }
355
+
356
+ // 자체 점검 프로세스가 끝까지 돌고 0이 아닌 종료 코드를 냈는가 (execFile 오류의 code가 숫자, 시간 초과로 죽이지 않았고 신호도 없다).
357
+ // 문자열 code(ENOSPC 같은 입출력 오류), 시간 초과(killed), 신호 종료, 결과 줄 불일치는 여기에 들지 않는다.
358
+ function selfTestExitedWithError(e) {
359
+ return Number.isSafeInteger(e?.code) && e.code !== 0 && !e.killed && !e.signal;
360
+ }
361
+
362
+ // 자식 프로세스 오류에서 원인 줄(SyntaxError, TypeError 등)을 고른다. stderr 첫 줄은 보통 파일 위치뿐이다.
363
+ function failureLine(e) {
364
+ const lines = String(e?.stderr ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
365
+ return lines.find((line) => /^[A-Za-z]*Error\b/.test(line)) ?? lines[0] ?? String(e?.message ?? e).split("\n")[0];
366
+ }
367
+
368
+ // dir의 names 가운데 모든 .js를 node --check로 검사한다. 하나라도 실패하면 파일 이름과 원인 줄로 던진다.
369
+ // 스테이징(자동 업데이트)과 cli.js의 수동 설치가 교체 전에 같은 검사를 거친다.
370
+ async function checkSyntax(dir, names, { check = nodeSyntaxCheck } = {}) {
371
+ for (const file of names) {
372
+ if (!file.endsWith(".js")) continue;
373
+ try {
374
+ await check(path.join(dir, file));
375
+ } catch (e) {
376
+ throw new Error(`${file} 문법 검사 실패: ${failureLine(e)}`);
377
+ }
378
+ }
379
+ }
380
+
381
+ // 앱 폴더와 같은 볼륨(~/.charge 아래)에 새 폴더를 만들어 풀고, 모든 .js를 문법 검사한 뒤 자체 점검을 돌린다
382
+ async function stageRelease(files, chargeHome, { check = nodeSyntaxCheck, selfTest = nodeSelfTest, version } = {}) {
383
+ const dir = await fs.promises.mkdtemp(path.join(chargeHome, "app.staging-"));
384
+ try {
385
+ await fs.promises.chmod(dir, 0o700).catch(() => {});
386
+ for (const [file, data] of files) {
387
+ await fs.promises.writeFile(path.join(dir, file), data, { mode: 0o600 });
388
+ }
389
+ await checkSyntax(dir, [...files.keys()], { check });
390
+ try {
391
+ await selfTest(dir, version);
392
+ } catch (e) {
393
+ throw new Error(`자체 점검 실패: ${failureLine(e)}`);
394
+ }
395
+ return dir;
396
+ } catch (e) {
397
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
398
+ throw e;
399
+ }
400
+ }
401
+
402
+ async function fileMode(file) {
403
+ try {
404
+ return (await fs.promises.stat(file)).mode & 0o777;
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+
410
+ // Windows에서는 백신이나 검색 인덱서가 파일을 잠깐 잡고 있으면 rename이 EPERM, EBUSY, EACCES로 실패한다.
411
+ // 대부분 곧 풀리므로 RENAME_RETRY_DELAY_MS 간격으로 RENAME_RETRIES번까지 다시 시도한 뒤에야 실패로 본다 (D14).
412
+ const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
413
+ async function renameWithRetry(from, to, { retries = RENAME_RETRIES, delayMs = RENAME_RETRY_DELAY_MS } = {}) {
414
+ for (let attempt = 0; ; attempt += 1) {
415
+ try {
416
+ return await fs.promises.rename(from, to);
417
+ } catch (e) {
418
+ if (attempt >= retries || !RETRYABLE_RENAME_CODES.has(e?.code)) throw e;
419
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
420
+ }
421
+ }
422
+ }
423
+
424
+ // 파일 하나를 <file>.new로 쓰고 이름을 바꿔 덮는다. 열려 있는 파일을 지우지 않아 Windows에서도 안전하다.
425
+ async function replaceFile(source, target, name, { retryDelayMs = RENAME_RETRY_DELAY_MS } = {}) {
426
+ const existing = await fileMode(target);
427
+ let mode = existing ?? 0o600;
428
+ if (!WIN && name.endsWith(".sh")) mode |= 0o700;
429
+ const temporary = `${target}.new`;
430
+ await fs.promises.copyFile(source, temporary);
431
+ if (!WIN) await fs.promises.chmod(temporary, mode);
432
+ await renameWithRetry(temporary, target, { delayMs: retryDelayMs });
433
+ }
434
+
435
+ async function currentRuntimeFiles(appDir) {
436
+ try {
437
+ const pkg = JSON.parse(await fs.promises.readFile(path.join(appDir, "package.json"), "utf8"));
438
+ return Array.isArray(pkg.files) ? pkg.files.filter((f) => typeof f === "string" && /^[A-Za-z0-9._-]+$/.test(f)) : [];
439
+ } catch {
440
+ return [];
441
+ }
442
+ }
443
+
444
+ // replaceFile의 동기판. collect.js가 다른 모듈을 불러오기 전에 되돌릴 때는 await할 수 없다.
445
+ function replaceFileSync(source, target, name) {
446
+ let mode = 0o600;
447
+ try {
448
+ mode = fs.statSync(target).mode & 0o777;
449
+ } catch {}
450
+ if (!WIN && name.endsWith(".sh")) mode |= 0o700;
451
+ const temporary = `${target}.new`;
452
+ fs.copyFileSync(source, temporary);
453
+ if (!WIN) fs.chmodSync(temporary, mode);
454
+ fs.renameSync(temporary, target);
455
+ }
456
+
457
+ // 백업(app.prev)의 파일로 되돌리고 이번 업데이트가 새로 더한 파일은 지운다. names를 주지 않으면 백업 전체를
458
+ // 되돌린다. 실패한 파일이 있어도 끝까지 시도한 뒤 모아서 던진다 (섞인 상태를 조용히 남기지 않는다).
459
+ function restoreBackupSync({ appDir, backupDir, names = null, added = [] }) {
460
+ const restore = names ?? fs.readdirSync(backupDir).filter((name) => {
461
+ try {
462
+ return /^[A-Za-z0-9._-]+$/.test(name) && fs.statSync(path.join(backupDir, name)).isFile();
463
+ } catch {
464
+ return false;
465
+ }
466
+ });
467
+ const failures = [];
468
+ for (const name of restore) {
469
+ try {
470
+ replaceFileSync(path.join(backupDir, name), path.join(appDir, name), name);
471
+ } catch (e) {
472
+ failures.push(`${name} (${e?.code ?? e?.message ?? e})`);
473
+ }
474
+ }
475
+ for (const name of added) {
476
+ if (typeof name !== "string" || !/^[A-Za-z0-9._-]+$/.test(name) || restore.includes(name)) continue;
477
+ try {
478
+ fs.rmSync(path.join(appDir, name), { force: true });
479
+ // <파일>.new를 쓰다가(이름을 바꾸기 전에) 끊긴 교체의 흔적. 되돌린 파일의 .new는 replaceFileSync가 덮어 쓰고 옮기지만,
480
+ // 더한 파일은 지우기만 하므로 여기서 같이 지운다
481
+ fs.rmSync(`${path.join(appDir, name)}.new`, { force: true });
482
+ } catch (e) {
483
+ failures.push(`${name} (${e?.code ?? e?.message ?? e})`);
484
+ }
485
+ }
486
+ if (failures.length) throw new Error(`app.prev에서 되돌리지 못한 파일: ${failures.join(", ")}`);
487
+ }
488
+
489
+ function processAlive(pid) {
490
+ try {
491
+ process.kill(pid, 0);
492
+ return true;
493
+ } catch (e) {
494
+ // EPERM은 프로세스가 살아 있지만 신호를 보낼 권한이 없다는 뜻이다
495
+ return e?.code === "EPERM";
496
+ }
497
+ }
498
+
499
+ // 다른 프로세스의 업데이트가 지금 이 앱 폴더의 파일을 교체하는 중인가. 진행 표시에 적힌 프로세스가 살아 있고 표시를
500
+ // LOCK_STALE_MS 안에 썼을 때만 그렇다고 본다 (전원이 끊긴 뒤 같은 pid를 다른 프로세스가 받아도 그 시간이 지나면 되돌린다).
501
+ // pid나 시각이 없는 표시, 깨진 표시는 진행 중이 아니다 (되돌릴 대상이다).
502
+ function updateInProgress({ appDir, now = Date.now(), isAlive = processAlive }) {
503
+ let info;
504
+ try {
505
+ info = JSON.parse(fs.readFileSync(path.join(appDir, UPDATE_MARKER), "utf8"));
506
+ } catch {
507
+ return false;
508
+ }
509
+ const pid = info?.pid;
510
+ const startedAt = info?.startedAt;
511
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid || !Number.isFinite(startedAt)) return false;
512
+ if (Math.abs(now - startedAt) > LOCK_STALE_MS) return false;
513
+ return isAlive(pid) === true;
514
+ }
515
+
516
+ // 파일 교체 도중 끊긴 업데이트(전원 차단, 작업 시간 제한에 따른 강제 종료)를 app.prev로 되돌린다.
517
+ // 동기 함수다: collect.js가 다른 모듈을 불러오기 전에 불러, 옛 파일과 새 파일이 섞인 채 require가 엇갈려
518
+ // 죽기 전에 복구한다. 반환: 진행 표시가 없으면 false, 되돌렸으면 true. 되돌리지 못하면 표시를 남긴 채 던진다
519
+ // (다음 실행이 다시 시도하고, 그동안 installStaged는 백업을 덮지 않는다).
520
+ function recoverInterruptedUpdate({ appDir }) {
521
+ const marker = path.join(appDir, UPDATE_MARKER);
522
+ let info = null;
523
+ try {
524
+ info = JSON.parse(fs.readFileSync(marker, "utf8"));
525
+ } catch (e) {
526
+ if (e?.code === "ENOENT") return false;
527
+ }
528
+ const backupDir = typeof info?.backup === "string" && info.backup ? info.backup : path.join(path.dirname(appDir), "app.prev");
529
+ restoreBackupSync({ appDir, backupDir, added: Array.isArray(info?.added) ? info.added : [] });
530
+ fs.rmSync(marker, { force: true });
531
+ return true;
532
+ }
533
+
534
+ // 현재 앱 파일을 app.prev로 백업한 뒤 새 파일로 교체한다. package.json은 맨 마지막에 바꿔서
535
+ // 도중에 실패해도 버전 표시는 옛 값으로 남고 다음 확인 창에 다시 시도하게 한다.
536
+ // 교체 직전에 진행 표시(UPDATE_MARKER: 백업 위치, 교체할 파일 목록, 새로 더하는 파일, from/to 버전, 이 설치의 id,
537
+ // pid, 시작 시각)를 남기고, 교체와 설치 후 자체 점검(verify)이 모두 끝나야 지운다. 프로세스가 도중에 죽으면 다음 실행의
538
+ // collect.js가 이 표시를 보고 다른 모듈을 불러오기 전에 app.prev로 되돌린다 (pid가 살아 있는 동안은 건드리지 않는다).
539
+ // verify가 실패하면 여기서 바로 app.prev로 되돌리고 code "SELF_TEST_FAILED" 오류를 던진다 (호출자가 그 릴리스를
540
+ // 다시 설치하지 않도록 기억한다). 교체 도중이나 점검 뒤에 표시가 이 설치의 것이 아니게 됐거나 앱 폴더 파일이 스테이징과
541
+ // 다르면, 다른 실행이 백업으로 되돌린 것이다. 그때도 되돌리되 릴리스 탓이 아니므로 SELF_TEST_FAILED로 던지지 않는다.
542
+ async function installStaged({
543
+ stagingDir, names, appDir, backupDir,
544
+ fromVersion = null, toVersion = null, verify = null, retryDelayMs = RENAME_RETRY_DELAY_MS,
545
+ }) {
546
+ const marker = path.join(appDir, UPDATE_MARKER);
547
+ // 되돌리지 못한 교체가 남아 있으면 앱 폴더가 섞여 있을 수 있다, 그 상태로 백업을 덮으면 돌아갈 곳이 사라진다
548
+ if (fs.existsSync(marker)) {
549
+ throw new Error("이전 업데이트가 교체 도중 끊긴 뒤 아직 되돌려지지 않았습니다 (npx charge-connect@latest update로 다시 설치하세요)");
550
+ }
551
+ await fs.promises.rm(backupDir, { recursive: true, force: true });
552
+ await fs.promises.mkdir(backupDir, { recursive: true, mode: 0o700 });
553
+ const backedUp = new Set();
554
+ for (const name of new Set([...(await currentRuntimeFiles(appDir)), ...names, "package.json"])) {
555
+ try {
556
+ await fs.promises.copyFile(path.join(appDir, name), path.join(backupDir, name));
557
+ backedUp.add(name);
558
+ } catch (e) {
559
+ if (e?.code !== "ENOENT") throw e;
560
+ }
561
+ }
562
+
563
+ const ordered = [...names.filter((n) => n !== "package.json"), ...names.filter((n) => n === "package.json")];
564
+ const markerInfo = {
565
+ backup: backupDir,
566
+ files: ordered,
567
+ added: ordered.filter((name) => !backedUp.has(name)),
568
+ from: fromVersion,
569
+ to: toVersion,
570
+ id: crypto.randomUUID(),
571
+ pid: process.pid,
572
+ startedAt: Date.now(),
573
+ };
574
+ if (!writeStateAtomic(marker, markerInfo)) {
575
+ throw new Error("업데이트 진행 표시를 쓰지 못했습니다");
576
+ }
577
+ // 표시가 아직 이 설치의 것인가 (다른 실행이 되돌렸으면 지워졌다)
578
+ const ownsMarker = () => {
579
+ try {
580
+ return JSON.parse(fs.readFileSync(marker, "utf8"))?.id === markerInfo.id;
581
+ } catch {
582
+ return false;
583
+ }
584
+ };
585
+ // 자체 점검은 package.json의 버전만 찍으므로, 점검한 앱 폴더가 정말 스테이징한 파일인지 내용으로 확인한다
586
+ const installedAsStaged = () => ordered.every((name) => {
587
+ try {
588
+ return fs.readFileSync(path.join(appDir, name)).equals(fs.readFileSync(path.join(stagingDir, name)));
589
+ } catch {
590
+ return false;
591
+ }
592
+ });
593
+ const INTERRUPTED = "교체 도중 다른 실행이 앱 폴더를 app.prev로 되돌렸습니다 (다음 확인 창에 다시 설치합니다)";
594
+ // 바꿨거나 바꾸던 파일. 이름을 바꾸다 실패한 파일도 넣는다: rename이 오류를 알렸어도 디스크에는 이미 반영됐을 수 있어
595
+ // (입출력 오류, 되풀이한 뒤의 실패) 그 파일을 빼고 되돌리면 새 파일 하나가 옛 파일들 사이에 남는다.
596
+ const replaced = [];
597
+ // 바꾼 파일을 백업으로 되돌리고(새로 더한 파일은 지우고) 진행 표시를 지운다. 되돌리기마저 실패하면
598
+ // 진행 표시를 남긴 채 합친 오류를 돌려준다, 다음 실행의 collect.js가 맨 앞에서 백업 전체로 다시 되돌린다.
599
+ const rollBack = (reason) => {
600
+ try {
601
+ restoreBackupSync({
602
+ appDir,
603
+ backupDir,
604
+ names: replaced.filter((name) => backedUp.has(name)),
605
+ added: replaced.filter((name) => !backedUp.has(name)),
606
+ });
607
+ } catch (rollback) {
608
+ return new Error(`${reason}; 되돌리기도 실패해 다음 실행에서 다시 시도합니다: ${rollback.message}`);
609
+ }
610
+ fs.rmSync(marker, { force: true });
611
+ return null;
612
+ };
613
+ try {
614
+ for (const name of ordered) {
615
+ // 다른 실행이 표시를 지우고 되돌렸으면 남은 파일을 더 바꾸지 않는다 (계속 바꾸면 옛 파일과 새 파일이 섞인 폴더가 남는다)
616
+ if (!ownsMarker()) throw new Error(INTERRUPTED);
617
+ replaced.push(name);
618
+ await replaceFile(path.join(stagingDir, name), path.join(appDir, name), name, { retryDelayMs });
619
+ }
620
+ } catch (e) {
621
+ // 반쯤 바뀐 앱은 다음 수집에서 require가 엇갈려 죽을 수 있다, 바꾼 파일(바꾸던 파일까지)을 백업으로 되돌린다
622
+ const current = replaced[replaced.length - 1];
623
+ if (current) await fs.promises.rm(path.join(appDir, `${current}.new`), { force: true }).catch(() => {});
624
+ throw rollBack(e?.message ?? e) ?? e;
625
+ }
626
+ const intact = () => ownsMarker() && installedAsStaged();
627
+ if (verify) {
628
+ // 앱 폴더에서도 같은 자체 점검을 돌린다. 스테이징에서는 통과했어도 앱 폴더의 환경(남아 있던 파일,
629
+ // 권한, 경로)에서 죽는 릴리스를 그대로 두면 다음 수집부터 매번 실패한다.
630
+ try {
631
+ await verify();
632
+ } catch (e) {
633
+ const reason = `설치 후 자체 점검 실패: ${failureLine(e)}`;
634
+ // 점검 도중 다른 실행이 되돌렸다면 점검은 옛 파일을 본 것이다, 실패한 릴리스로 기억하지 않는다
635
+ if (!intact()) throw rollBack(`${INTERRUPTED}; ${reason}`) ?? new Error(`${INTERRUPTED}; ${reason}`);
636
+ const failed = rollBack(reason) ?? new Error(`${reason} (app.prev로 되돌렸습니다)`);
637
+ // 점검 프로세스가 스스로 0이 아닌 코드로 끝났을 때만 릴리스 탓으로 기억한다. 시간 초과, 신호로 죽은 경우, 점검용 임시 폴더를
638
+ // 못 만든 입출력 오류는 이 PC의 사정(깨어난 직후의 느린 시작, 새 파일을 검사하는 백신)일 수 있다. 스테이징 점검은 방금
639
+ // 통과했으므로 그때는 되돌리기만 하고 다음 확인 창에 다시 설치한다.
640
+ if (selfTestExitedWithError(e)) failed.code = "SELF_TEST_FAILED";
641
+ throw failed;
642
+ }
643
+ }
644
+ // 옛 collect.js도 새 package.json의 버전을 찍으므로 점검 통과만으로는 새 파일이 설치됐다고 볼 수 없다
645
+ if (!intact()) throw rollBack(INTERRUPTED) ?? new Error(INTERRUPTED);
646
+ fs.rmSync(marker, { force: true });
647
+ }
648
+
649
+ function writeStateAtomic(file, data) {
650
+ const temporary = `${file}.${process.pid}.tmp`;
651
+ try {
652
+ fs.writeFileSync(temporary, JSON.stringify(data), { mode: 0o600 });
653
+ fs.renameSync(temporary, file);
654
+ return true;
655
+ } catch {
656
+ return false;
657
+ } finally {
658
+ try { fs.rmSync(temporary, { force: true }); } catch {}
659
+ }
660
+ }
661
+
662
+ // 잠금 파일의 지문: 수정 시각, 크기, 내용. 낡았다고 본 잠금이 넘겨받는 사이에 다른 실행의 새 잠금으로 바뀌었는지 이것으로
663
+ // 가린다 (다른 프로세스의 잠금은 내용(pid)이 다르고, 같은 프로세스가 다시 만든 잠금도 시각이 다르다). 내용을 읽을 수 없는
664
+ // 파일(권한)은 시각과 크기로만 본다. 반환: { key, content, mtimeMs }, 파일이 없으면 null. 그 밖의 stat 오류는 던진다.
665
+ function lockFingerprint(file) {
666
+ let stat;
667
+ try {
668
+ stat = fs.statSync(file);
669
+ } catch (e) {
670
+ if (e?.code === "ENOENT") return null;
671
+ throw e;
672
+ }
673
+ let content = null;
674
+ try {
675
+ content = fs.readFileSync(file, "utf8");
676
+ } catch {}
677
+ return { key: `${stat.mtimeMs}:${stat.size}:${content ?? ""}`, content, mtimeMs: stat.mtimeMs };
678
+ }
679
+
680
+ // 낡은 잠금(만든 프로세스가 죽었거나 너무 오래된 것)을 넘겨받는다. "지우고 다시 만들기"는 원자적이지 않다: 같은 잠금을 낡았다고
681
+ // 본 두 프로세스가 rm, 생성, rm, 생성으로 엇갈리면 뒤의 rm이 앞의 새 잠금을 지워 둘 다 잡는다. 그래서 넘겨받기 자체를
682
+ // <잠금>.takeover('wx')로 직렬화하고, 그 안에서 잠금을 다시 읽어 낡았다고 본 그 파일(지문 seen)일 때만 지운 뒤 create를 부른다.
683
+ // 그사이 바뀌었으면 다른 실행이 먼저 넘겨받아 새로 만든 것이므로 지우지 않는다. 넘겨받기는 몇 밀리초면 끝나므로 staleMs보다
684
+ // 오래된 표시는 그 사이에 죽은 프로세스의 흔적으로 보고 지운다 (그 지우기에는 같은 경쟁이 남지만, 넘겨받는 도중에 죽은 실행이
685
+ // 먼저 있어야 한다). 반환: 잡았으면 true, 다른 실행이 넘겨받는 중이거나 이미 넘겨받았으면 false, 표시나 잠금 파일을 만들거나
686
+ // 지울 수 없으면 그 오류. create는 잠금을 'wx'로 만드는 함수로, 만들었으면 true, 이미 있으면 false, 그 밖에는 오류를 돌려준다.
687
+ function takeOverStaleLock(file, seen, create, staleMs) {
688
+ const takeover = `${file}.takeover`;
689
+ const claim = () => {
690
+ try {
691
+ fs.writeFileSync(takeover, String(process.pid), { flag: "wx", mode: 0o600 });
692
+ return true;
693
+ } catch (e) {
694
+ return e?.code === "EEXIST" ? false : e ?? new Error("takeover");
695
+ }
696
+ };
697
+ let claimed = claim();
698
+ if (claimed === false) {
699
+ let abandoned = false;
700
+ try {
701
+ abandoned = Date.now() - fs.statSync(takeover).mtimeMs > staleMs;
702
+ } catch (e) {
703
+ abandoned = e?.code === "ENOENT";
704
+ }
705
+ if (!abandoned) return false;
706
+ try {
707
+ fs.rmSync(takeover, { force: true });
708
+ } catch {}
709
+ claimed = claim();
710
+ }
711
+ if (claimed !== true) return claimed;
712
+ try {
713
+ const current = lockFingerprint(file);
714
+ if (current && current.key !== seen) return false;
715
+ fs.rmSync(file, { force: true });
716
+ return create();
717
+ } catch (e) {
718
+ return e ?? new Error("takeover");
719
+ } finally {
720
+ try {
721
+ fs.rmSync(takeover, { force: true });
722
+ } catch {}
723
+ }
724
+ }
725
+
726
+ // ~/.charge/update.lock: 자동 업데이트(maybeAutoUpdate)와 수동 설치(cli.js의 installRuntime, `charge-connect update`와 페어링)가
727
+ // 앱 폴더를 동시에 바꾸지 않게 하는 잠금. 'wx'로 만들며 내용은 만든 프로세스의 pid다. 그 프로세스가 죽었거나 LOCK_STALE_MS가
728
+ // 지난 잠금은 낡은 것으로 보고 넘겨받는다 (takeOverStaleLock). 반환: 잡았으면 true, 살아 있는 다른 프로세스가 잡고 있거나
729
+ // 만들 수 없으면 false.
730
+ function acquireLock(file, now = Date.now(), { isAlive = processAlive } = {}) {
731
+ const tryCreate = () => {
732
+ try {
733
+ fs.writeFileSync(file, String(process.pid), { flag: "wx", mode: 0o600 });
734
+ return true;
735
+ } catch (e) {
736
+ if (e?.code !== "EEXIST") return false;
737
+ return null;
738
+ }
739
+ };
740
+ const first = tryCreate();
741
+ if (first !== null) return first;
742
+ try {
743
+ const seen = lockFingerprint(file);
744
+ // 그사이 풀렸으면 다시 만들어 본다
745
+ if (!seen) return tryCreate() === true;
746
+ const pid = Number.parseInt(seen.content ?? "", 10);
747
+ const dead = Number.isSafeInteger(pid) && pid > 0 && !isAlive(pid);
748
+ if (dead || now - seen.mtimeMs > LOCK_STALE_MS) {
749
+ return takeOverStaleLock(file, seen.key, () => tryCreate() === true, LOCK_STALE_MS) === true;
750
+ }
751
+ } catch {}
752
+ return false;
753
+ }
754
+
755
+ // 자체 점검(app.self-test-*)과 스테이징(app.staging-*) 폴더는 만든 실행이 끝나며 지우지만, 그 도중 강제 종료되면(Ctrl+C, 전원
756
+ // 차단, 작업 시간 제한) ~/.charge에 남아 쌓인다. 둘 다 update.lock을 잡은 실행만 만들므로, 잠금을 잡은 뒤에 부르면 남아 있는
757
+ // 폴더는 모두 죽은 실행의 것이다. 지우지 못한 폴더는 그대로 둔다 (다음에 다시 시도한다).
758
+ function sweepUpdateLeftovers(chargeHome) {
759
+ let entries = [];
760
+ try {
761
+ entries = fs.readdirSync(chargeHome);
762
+ } catch {
763
+ return;
764
+ }
765
+ for (const name of entries) {
766
+ if (!/^app\.(self-test|staging)-/.test(name)) continue;
767
+ try {
768
+ fs.rmSync(path.join(chargeHome, name), { recursive: true, force: true });
769
+ } catch {}
770
+ }
771
+ }
772
+
773
+ // 이 프로세스가 만든 잠금만 지운다 (낡았다고 판정한 다른 프로세스가 그사이 새로 만든 잠금을 지우지 않게)
774
+ function releaseLock(file) {
775
+ try {
776
+ if (fs.readFileSync(file, "utf8") === String(process.pid)) fs.rmSync(file, { force: true });
777
+ } catch {}
778
+ }
779
+
780
+ async function fetchManifest(mode, fetchFn) {
781
+ const res = await fetchFn(`${mode.url}/rest/v1/rpc/charge_latest_collector`, {
782
+ method: "POST",
783
+ headers: { apikey: mode.anon, Authorization: `Bearer ${mode.anon}`, "Content-Type": "application/json" },
784
+ body: "{}",
785
+ signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS),
786
+ });
787
+ if (!res.ok) throw new Error(`매니페스트 조회 실패 (${res.status})`);
788
+ const rows = await res.json();
789
+ const row = Array.isArray(rows) ? rows[0] : rows;
790
+ return row && typeof row === "object" ? row : null;
791
+ }
792
+
793
+ function sameDirectory(a, b) {
794
+ const real = (p) => {
795
+ try {
796
+ return fs.realpathSync.native(p);
797
+ } catch {
798
+ return path.resolve(p);
799
+ }
800
+ };
801
+ const left = real(a);
802
+ const right = real(b);
803
+ return WIN ? left.toLowerCase() === right.toLowerCase() : left === right;
804
+ }
805
+
806
+ // 업데이트 확인 상태: { lastCheck, nextCheck, failed: [{ version, integrity }] }. 깨진 항목은 버린다.
807
+ // failed는 설치 후 자체 점검에 실패해 되돌린 릴리스로, 같은 버전과 무결성이면 다시 설치하지 않는다.
808
+ function readUpdateState(file) {
809
+ let saved = null;
810
+ try {
811
+ saved = JSON.parse(fs.readFileSync(file, "utf8"));
812
+ } catch {}
813
+ const failed = Array.isArray(saved?.failed)
814
+ ? saved.failed
815
+ .filter((entry) => typeof entry?.version === "string" && MANIFEST_FORMATS.version.test(entry.version)
816
+ && typeof entry?.integrity === "string" && MANIFEST_FORMATS.integrity.test(entry.integrity))
817
+ .map(({ version, integrity }) => ({ version, integrity }))
818
+ .slice(-MAX_FAILED_RELEASES)
819
+ : [];
820
+ return { lastCheck: Number(saved?.lastCheck), nextCheck: Number(saved?.nextCheck), failed };
821
+ }
822
+
823
+ function rememberFailedRelease(file, release) {
824
+ const state = readUpdateState(file);
825
+ const failed = [...state.failed.filter((f) => f.version !== release.version || f.integrity !== release.integrity), release]
826
+ .slice(-MAX_FAILED_RELEASES);
827
+ const saved = { failed };
828
+ if (Number.isFinite(state.lastCheck)) saved.lastCheck = state.lastCheck;
829
+ if (Number.isFinite(state.nextCheck)) saved.nextCheck = state.nextCheck;
830
+ return writeStateAtomic(file, saved);
831
+ }
832
+
833
+ // 반환: { updated, reason, version? }. 어떤 실패도 던지지 않는다 (수집 사이클을 깨면 안 된다).
834
+ async function maybeAutoUpdate({
835
+ mode,
836
+ appDir,
837
+ chargeHome,
838
+ currentVersion,
839
+ dryRun = false,
840
+ env = process.env,
841
+ stateFile = appDir ? path.join(appDir, ".update-check.json") : null,
842
+ fetchFn = globalThis.fetch,
843
+ releaseKeys = RELEASE_KEYS,
844
+ check = nodeSyntaxCheck,
845
+ selfTest = nodeSelfTest,
846
+ retryDelayMs = RENAME_RETRY_DELAY_MS,
847
+ now = Date.now(),
848
+ random = Math.random,
849
+ log = console.log,
850
+ logError = console.error,
851
+ } = {}) {
852
+ if (dryRun) return { updated: false, reason: "dry-run" };
853
+ if (!mode?.url || !mode?.anon) return { updated: false, reason: "unpaired" };
854
+ if (env.CHARGE_SKIP_UPDATE === "1" || mode.auto_update === false) return { updated: false, reason: "disabled" };
855
+ if (!appDir || !chargeHome || !stateFile) return { updated: false, reason: "no-app-dir" };
856
+ // 저장소 체크아웃이나 npx 캐시에서 돌 때 그 파일을 덮으면 안 된다, 설치된 런타임만 스스로 갱신한다
857
+ if (!sameDirectory(appDir, path.join(chargeHome, "app"))) return { updated: false, reason: "not-installed-runtime" };
858
+
859
+ const state = readUpdateState(stateFile);
860
+ const next = state.nextCheck;
861
+ // 시계가 크게 뒤로 갔으면(다음 확인이 창 길이보다 먼 미래) 기록을 믿지 않는다
862
+ if (Number.isFinite(next) && now < next && next - now <= CHECK_INTERVAL_MS + CHECK_JITTER_MS) {
863
+ return { updated: false, reason: "not-due" };
864
+ }
865
+ // 네트워크보다 먼저 다음 확인 시각을 적는다, 실패해도 5분마다 재시도하지 않고 다음 창을 기다린다
866
+ const nextCheck = now + CHECK_INTERVAL_MS + Math.floor(random() * CHECK_JITTER_MS);
867
+ if (!writeStateAtomic(stateFile, { lastCheck: now, nextCheck, failed: state.failed })) {
868
+ logError("자동 업데이트 확인 시각을 저장하지 못해 이번에는 건너뜁니다");
869
+ return { updated: false, reason: "state-unwritable" };
870
+ }
871
+
872
+ let lockFile = null;
873
+ let stagingDir = null;
874
+ let manifest = null;
875
+ try {
876
+ manifest = await fetchManifest(mode, fetchFn);
877
+ if (!manifest) return { updated: false, reason: "no-release" };
878
+ const verdict = checkManifest(manifest, { currentVersion, releaseKeys });
879
+ if (!verdict.ok) {
880
+ if (!verdict.quiet) logError(`자동 업데이트 건너뜀: ${cleanLogText(verdict.reason)}`);
881
+ return { updated: false, reason: verdict.reason };
882
+ }
883
+ if (state.failed.some((f) => f.version === manifest.version && f.integrity === manifest.integrity)) {
884
+ logError(`자동 업데이트 건너뜀: ${manifest.version}은 이 PC에서 설치 후 자체 점검에 실패해 되돌린 릴리스입니다 (더 높은 버전을 기다립니다)`);
885
+ return { updated: false, reason: "failed-release" };
886
+ }
887
+ lockFile = path.join(chargeHome, "update.lock");
888
+ if (!acquireLock(lockFile, now)) {
889
+ lockFile = null;
890
+ return { updated: false, reason: "locked" };
891
+ }
892
+ // 잠금을 잡았으니 남아 있는 점검, 스테이징 폴더는 죽은 실행의 것이다
893
+ sweepUpdateLeftovers(chargeHome);
894
+
895
+ // 리다이렉트는 따라가지 않는다: 서명된 주소 그대로에서만 받는다
896
+ const res = await fetchFn(manifest.tarball, {
897
+ headers: { "User-Agent": `${PACKAGE_NAME}/${currentVersion}` },
898
+ redirect: "error",
899
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
900
+ });
901
+ if (!res.ok) throw new Error(`다운로드 실패 (${res.status})`);
902
+ // 본문 전체를 상한까지 받아 sha512를 확인한 뒤에야 압축 해제, tar 해석을 한다
903
+ const tgz = await readCapped(res, MAX_TARBALL_BYTES);
904
+ if (sha512Integrity(tgz) !== manifest.integrity) throw new Error("sha512 무결성 불일치");
905
+ const { files } = await extractRelease(tgz, manifest.version);
906
+ const version = manifest.version;
907
+ stagingDir = await stageRelease(files, chargeHome, { check, selfTest, version });
908
+ await installStaged({
909
+ stagingDir,
910
+ names: [...files.keys()],
911
+ appDir,
912
+ backupDir: path.join(chargeHome, "app.prev"),
913
+ fromVersion: currentVersion,
914
+ toVersion: version,
915
+ verify: () => selfTest(appDir, version),
916
+ retryDelayMs,
917
+ });
918
+ log(`${PACKAGE_NAME} ${currentVersion} -> ${version} 업데이트 완료 (다음 수집부터 적용)`);
919
+ return { updated: true, reason: "updated", version };
920
+ } catch (e) {
921
+ if (e?.code === "SELF_TEST_FAILED" && manifest && !rememberFailedRelease(stateFile, { version: manifest.version, integrity: manifest.integrity })) {
922
+ logError("자체 점검에 실패한 릴리스를 기록하지 못했습니다 (다음 확인 창에 다시 시도할 수 있습니다)");
923
+ }
924
+ logError(`자동 업데이트 실패: ${cleanLogText(e?.message ?? e)}`);
925
+ return { updated: false, reason: e?.message ?? String(e) };
926
+ } finally {
927
+ if (stagingDir) await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => {});
928
+ if (lockFile) releaseLock(lockFile);
929
+ }
930
+ }
931
+
932
+ module.exports = {
933
+ CHECK_INTERVAL_MS,
934
+ CHECK_JITTER_MS,
935
+ MANIFEST_FORMATS,
936
+ MAX_TARBALL_BYTES,
937
+ PACKAGE_NAME,
938
+ RELEASE_KEYS,
939
+ RELEASE_MESSAGE_PREFIX,
940
+ RENAME_RETRIES,
941
+ UPDATE_MARKER,
942
+ acquireUpdateLock: acquireLock,
943
+ checkManifest,
944
+ checkSyntax,
945
+ extractRelease,
946
+ installStaged,
947
+ isNewerRelease,
948
+ lockFingerprint,
949
+ manifestFormatError,
950
+ maybeAutoUpdate,
951
+ nodeSelfTest,
952
+ nodeSyntaxCheck,
953
+ parseReleaseVersion,
954
+ parseTar,
955
+ readCapped,
956
+ readUpdateState,
957
+ recoverInterruptedUpdate,
958
+ releaseMessage,
959
+ releaseTarballURL,
960
+ releaseUpdateLock: releaseLock,
961
+ sha512Integrity,
962
+ stageRelease,
963
+ sweepUpdateLeftovers,
964
+ takeOverStaleLock,
965
+ updateInProgress,
966
+ verifyReleaseSignature,
967
+ };