deel-local-cli 2.0.1 → 2.1.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.ko.md +34 -7
- package/README.md +35 -7
- package/bin/deel.js +34 -1
- package/package.json +1 -1
- package/src/acp/serve.js +83 -17
- package/src/agent/commit.js +2 -2
- package/src/agent/compact.js +4 -4
- package/src/agent/donecheck.js +97 -0
- package/src/agent/grade.js +1 -1
- package/src/agent/loop.js +176 -13
- package/src/agent/memory.js +1 -1
- package/src/agent/models.js +1 -1
- package/src/agent/modes.js +3 -0
- package/src/agent/outschema.js +1 -1
- package/src/agent/pins.js +7 -2
- package/src/agent/project.js +1 -1
- package/src/agent/recall.js +1 -1
- package/src/agent/session.js +15 -0
- package/src/agent/store.js +13 -26
- package/src/backend/ctxsize.js +2 -2
- package/src/backend/http.js +1 -1
- package/src/backend/learn.js +27 -1
- package/src/backend/mcp.js +97 -16
- package/src/backend/proxy.js +1 -1
- package/src/backend/quota.js +2 -2
- package/src/commands.js +24 -2
- package/src/completion.js +16 -3
- package/src/config.js +33 -11
- package/src/configexplain.js +1 -1
- package/src/doctor.js +11 -1
- package/src/eval/run.js +432 -0
- package/src/eval/starter.js +95 -0
- package/src/i18n/en.js +20 -0
- package/src/i18n/ja.js +20 -0
- package/src/i18n/ko.js +20 -0
- package/src/i18n/zh.js +20 -0
- package/src/jsonl.js +42 -0
- package/src/lsp/client.js +6 -46
- package/src/oneshot.js +85 -3
- package/src/pack/selfpack.js +2 -2
- package/src/pack/zip.js +18 -1
- package/src/plugins/manage.js +81 -14
- package/src/reap.js +70 -0
- package/src/repl.js +85 -11
- package/src/report.js +1 -1
- package/src/safety/audit.js +4 -19
- package/src/safety/authcmd.js +1 -1
- package/src/safety/guard.js +56 -6
- package/src/safety/hooks.js +1 -1
- package/src/safety/keystore.js +18 -3
- package/src/safety/policy.js +38 -0
- package/src/safety/shellenv.js +1 -1
- package/src/safety/undo.js +44 -20
- package/src/skills/discover.js +1 -1
- package/src/tools/docs.js +3 -3
- package/src/tools/edit-match.js +1 -1
- package/src/tools/encoding.js +1 -1
- package/src/tools/excel-com.js +56 -30
- package/src/tools/fastgrep.js +1 -1
- package/src/tools/fsutil.js +8 -4
- package/src/tools/ignore.js +21 -1
- package/src/tools/index.js +122 -11
- package/src/tools/jobs.js +23 -4
- package/src/tools/shell.js +27 -0
- package/src/tools/todo.js +1 -1
- package/src/tools/webfetch.js +1 -1
- package/src/tools/xlsx.js +4 -4
- package/src/ui/approve.js +57 -3
- package/src/ui/banner.js +1 -1
- package/src/ui/inputbox.js +1 -1
- package/src/ui/md.js +1 -1
- package/src/ui/motion.js +1 -1
package/src/reap.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 신호로 끝날 때 아이들을 거두는 **하나뿐인** 손 (2.0.2 · M3).
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 한 자리로 모았나 ──────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 'exit' 그물(MCP 서버 · 일감 · 언어 서버가 저마다 건 것)은 **신호로 끝나면 안 돈다.**
|
|
7
|
+
* Node 는 SIGINT·SIGTERM 에 손이 없으면 그 자리에서 죽고, 그 길에는 'exit' 이 없다.
|
|
8
|
+
*
|
|
9
|
+
* 신호 손은 언어 서버(lsp/client.js)에만 있었고, 그 손은 **제 아이만** 거둔 뒤 같은 신호를
|
|
10
|
+
* 다시 보내 죽었다. 그래서 `kill <pid>` 한 번에 언어 서버는 거둬지고 MCP 서버와 일감
|
|
11
|
+
* (`npm run dev` 같은 것)은 남았다 — 입력이 닫히면 스스로 끝나는 서버만 운 좋게 사라졌다.
|
|
12
|
+
* 저마다 손을 따로 달면 더 나쁘다. 손이 둘이면 서로 「남이 맡고 있다」 고 보고 아무도 안
|
|
13
|
+
* 죽어서, deel 이 SIGTERM 을 **삼키는** 프로그램이 된다(아래 다시보낼까).
|
|
14
|
+
*
|
|
15
|
+
* 그래서 손은 여기 하나만 달고, 거둘 것은 각자 `신호에거두기()` 로 적는다.
|
|
16
|
+
*
|
|
17
|
+
* ── 치우고 나면 **원래대로 죽어야 한다** ────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* Node 는 SIGINT·SIGTERM 에 손이 하나라도 달려 있으면 **기본 동작(끝내기)을 안 한다.**
|
|
20
|
+
* 치우기만 하고 말면 `kill <pid>` 에 아이들은 죽고 deel 은 그대로 산다 — 잡 관리자·컨테이너
|
|
21
|
+
* 종료·systemd 가 전부 이 신호로 정리하는데, 그것들이 하나같이 시간을 다 기다린 뒤 SIGKILL 로
|
|
22
|
+
* 때려잡게 된다. 그래서 치운 다음 우리 손을 떼고 같은 신호를 자신에게 다시 보낸다. 그러면
|
|
23
|
+
* 기본 동작이 돌아 128+n 로 끝난다 — 셸이 기대하는 그 값이다.
|
|
24
|
+
*
|
|
25
|
+
* 다만 **남이 그 신호를 맡고 있으면 안 건드린다.** 한 방 실행은 SIGINT 로 턴만 끊고 살아
|
|
26
|
+
* 있어야 하고(oneshot.js), 대화 화면의 Ctrl+C 는 readline 이 따로 받는다. 우리 것 말고 다른
|
|
27
|
+
* 손이 달려 있으면 그쪽 뜻이 먼저다 — 남이 맡은 신호로 결국 끝나면 그때는 'exit' 그물이 거둔다.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** 신호로 끝날 때 부를 것들. 저마다 제 'exit' 그물과 같은 함수를 적는다. */
|
|
31
|
+
const 거둘것 = new Set();
|
|
32
|
+
|
|
33
|
+
/** 신호로 끝날 때 이것도 부른다. 같은 함수를 두 번 적어도 한 번만 돈다. */
|
|
34
|
+
export function 신호에거두기(손) { 거둘것.add(손); return 손; }
|
|
35
|
+
|
|
36
|
+
/** 검사가 본다 — 이 손이 적혀 있나. */
|
|
37
|
+
export function 거둘것에있나(손) { return 거둘것.has(손); }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 치운 다음 그 신호로 진짜 죽어야 하나.
|
|
41
|
+
*
|
|
42
|
+
* 갈림을 함수로 빼 둔 까닭: 윈도우에서는 이 길을 통째로 밟아 볼 수가 없다
|
|
43
|
+
* (SIGTERM 을 보내면 손이 돌기도 전에 Node 가 죽인다). 그런데 갈림 자체가
|
|
44
|
+
* 틀리면 유닉스에서 한 방 실행의 Ctrl+C 가 턴만 끊는 대신 프로그램을 죽인다 —
|
|
45
|
+
* 어느 판에서든 재어 볼 수 있어야 하는 자리다.
|
|
46
|
+
*
|
|
47
|
+
* @param 셈 이 신호에 달린 손의 수. 우리 것 하나만이면 우리가 마지막이다.
|
|
48
|
+
*/
|
|
49
|
+
export function 다시보낼까(신호, 셈 = process.listenerCount(신호)) {
|
|
50
|
+
return Number(셈) <= 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 적힌 것을 다 부른다. 하나가 던져도 나머지는 부른다 — 끝나는 길이다. */
|
|
54
|
+
function 모두거두기() {
|
|
55
|
+
for (const 손 of 거둘것) { try { 손(); } catch { /* 끝나는 중이라 할 수 있는 게 없다 */ } }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const 신호 of ['SIGINT', 'SIGTERM']) {
|
|
59
|
+
const 손 = () => {
|
|
60
|
+
/*
|
|
61
|
+
* 갈림을 **거두기보다 먼저** 본다. 거두고 나서 갈랐었다 — 그래서 대화 화면의 Ctrl+C 가
|
|
62
|
+
* 턴만 끊고 세션은 사는데 언어 서버는 전부 죽고 풀도 비었다(2.0.0 6회차 사냥).
|
|
63
|
+
*/
|
|
64
|
+
if (!다시보낼까(신호)) return; // 남이 맡고 있다 — 그쪽 뜻이 먼저다
|
|
65
|
+
모두거두기();
|
|
66
|
+
process.removeListener(신호, 손);
|
|
67
|
+
try { process.kill(process.pid, 신호); } catch { process.exit(신호 === 'SIGINT' ? 130 : 143); }
|
|
68
|
+
};
|
|
69
|
+
process.on(신호, 손);
|
|
70
|
+
}
|
package/src/repl.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
|
|
2
2
|
import { createInterface, emitKeypressEvents } from 'node:readline';
|
|
3
|
-
import { 규칙모으기,
|
|
3
|
+
import { 규칙모으기, 정책읽기, 승인바닥 } from './safety/policy.js';
|
|
4
|
+
import { 검사설정 } from './agent/donecheck.js';
|
|
5
|
+
import { 자율대기 } from './ui/approve.js';
|
|
4
6
|
import { 훅읽기 } from './safety/hooks.js';
|
|
5
7
|
import { 에이전트읽기 } from './agent/agents.js';
|
|
6
8
|
import { 받기설정 } from './safety/authcmd.js';
|
|
@@ -41,7 +43,7 @@ import { 배움 } from './agent/evolve.js';
|
|
|
41
43
|
import { 마크다운 } from './ui/md.js';
|
|
42
44
|
import { askHidden, 가림중 } from './ui/prompt.js';
|
|
43
45
|
import { explain, shows as levelShows } from './ui/level.js';
|
|
44
|
-
import { 고르기 as 승인고르기, 다음 as
|
|
46
|
+
import { 고르기 as 승인고르기, 다음 as 승인다음, 미리보기줄들 } from './ui/approve.js';
|
|
45
47
|
import { 추천, 채울글, 붙임탭완성기 } from './ui/complete.js';
|
|
46
48
|
import { 접어쓰기 } from './ui/wrap.js';
|
|
47
49
|
import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들, 남길글, 안쪽최대 as 붙임안쪽최대 } from './ui/pastechip.js';
|
|
@@ -166,6 +168,8 @@ export const 이어쓰기표시 = (l) =>
|
|
|
166
168
|
|
|
167
169
|
export async function chatLoop(opts = {}) {
|
|
168
170
|
const cfg = load();
|
|
171
|
+
// 자율(auto) 모드에서 되묻기·계획 승인을 몇 초 기다리나 (ui/approve.js 의 자율대기). 설정의 askWait.
|
|
172
|
+
const 자율대기초 = 자율대기(cfg);
|
|
169
173
|
// 모아 둔 소식을 비운다 (아래 「한 자리에서 비운다」 머리말).
|
|
170
174
|
const 소식쓰기 = () => { for (const 줄 of 소식줄들(cfg)) 바로쓰기(줄); };
|
|
171
175
|
const prof = activeProfile(cfg);
|
|
@@ -1042,11 +1046,14 @@ export async function chatLoop(opts = {}) {
|
|
|
1042
1046
|
// Shift+Tab — 승인 방식 (자동 → 위험만 → 모두)
|
|
1043
1047
|
if (key && key.name === 'tab' && key.shift) {
|
|
1044
1048
|
const 앞 = 승인고르기(session.mode);
|
|
1045
|
-
|
|
1049
|
+
// 관리 정책의 바닥 위에서만 돈다 (ui/approve.js 의 다음).
|
|
1050
|
+
const 바닥 = 승인바닥();
|
|
1051
|
+
session.mode = 승인다음(session.mode, 바닥.바닥);
|
|
1046
1052
|
const 뒤 = 승인고르기(session.mode);
|
|
1047
1053
|
화면.입력지움();
|
|
1048
1054
|
say(` ${뒤.색(뒤.글자)} ${c.bold(뒤.색(뒤.이름))} ${c.gray(뒤.한줄)}`);
|
|
1049
1055
|
say(` ${c.gray(`${앞.이름} → ${뒤.이름} · Shift+Tab 으로 계속 바꿉니다`)}`);
|
|
1056
|
+
if (바닥.바닥 !== 'auto') say(` ${c.gray(옮긴말('approve.locked', { 바닥: 승인고르기(바닥.바닥).이름, 곳: 바닥.곳 ?? '' }))}`);
|
|
1050
1057
|
prompt();
|
|
1051
1058
|
return;
|
|
1052
1059
|
}
|
|
@@ -1264,9 +1271,29 @@ export async function chatLoop(opts = {}) {
|
|
|
1264
1271
|
* 있을 때만 「먼저 친 줄은 답이 아니다」 가 참이다.
|
|
1265
1272
|
*/
|
|
1266
1273
|
const 미리쳐둔 = process.stdin.isTTY ? queue.splice(0) : [];
|
|
1267
|
-
|
|
1274
|
+
/*
|
|
1275
|
+
* 시한 (자율 모드 · ui/approve.js 의 자율대기). 주면 그 초가 지나 손을 뗀다 — ESC 와 같은 길이되,
|
|
1276
|
+
* 무엇으로 답한 셈 칠지는 따로 받는다(`시간끝`). ESC 는 여전히 ESC 로 읽힌다.
|
|
1277
|
+
*/
|
|
1278
|
+
const 턴멈춤 = o.signal ?? turn?.signal ?? null;
|
|
1279
|
+
let 시간지남 = false;
|
|
1280
|
+
let 시계 = null;
|
|
1281
|
+
let 멈춤 = 턴멈춤;
|
|
1282
|
+
const 이어멈춤 = () => 시계줄?.abort();
|
|
1283
|
+
const 시계줄 = o.기다림초 > 0 ? new AbortController() : null;
|
|
1284
|
+
if (시계줄) {
|
|
1285
|
+
멈춤 = 시계줄.signal;
|
|
1286
|
+
if (턴멈춤?.aborted) 시계줄.abort();
|
|
1287
|
+
else 턴멈춤?.addEventListener('abort', 이어멈춤, { once: true });
|
|
1288
|
+
시계 = setTimeout(() => { 시간지남 = true; 시계줄.abort(); }, o.기다림초 * 1000);
|
|
1289
|
+
}
|
|
1268
1290
|
try {
|
|
1269
1291
|
const a = await nextLine(멈춤);
|
|
1292
|
+
if (시간지남) {
|
|
1293
|
+
say('');
|
|
1294
|
+
say(` ${c.gray(o.시간끝말 ?? 옮긴말('ask.timedOut', { 초: o.기다림초 }))}`);
|
|
1295
|
+
return o.시간끝 ?? o.멈추면 ?? '';
|
|
1296
|
+
}
|
|
1270
1297
|
if (멈춤?.aborted) {
|
|
1271
1298
|
// 물음이 화면에 걸린 채로 남으면 안 된다. 줄을 끊고 멈췄다고 적는다.
|
|
1272
1299
|
say('');
|
|
@@ -1284,6 +1311,8 @@ export async function chatLoop(opts = {}) {
|
|
|
1284
1311
|
if (a === null) return o.끝나면 ?? o.def ?? '';
|
|
1285
1312
|
return a.trim() || o.def || '';
|
|
1286
1313
|
} finally {
|
|
1314
|
+
clearTimeout(시계);
|
|
1315
|
+
턴멈춤?.removeEventListener?.('abort', 이어멈춤);
|
|
1287
1316
|
묻는중 = null;
|
|
1288
1317
|
// 빼 뒀던 것을 앞에 되돌린다. 차례가 바뀌면 안 된다.
|
|
1289
1318
|
if (미리쳐둔.length) queue.unshift(...미리쳐둔);
|
|
@@ -1369,6 +1398,8 @@ export async function chatLoop(opts = {}) {
|
|
|
1369
1398
|
get 눈있나() { return !!conn.vision; },
|
|
1370
1399
|
// 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
|
|
1371
1400
|
규칙들: 규칙모으기(cfg),
|
|
1401
|
+
// 모델이 끝내려는 자리에서 돌릴 검사 (agent/donecheck.js). 설정에 check 가 없으면 null — 안 돈다.
|
|
1402
|
+
완료검사: 검사설정(cfg),
|
|
1372
1403
|
// 사람이 적어 둔 훅 (safety/hooks.js). 프로젝트 파일은 믿는 폴더에서만 읽는다.
|
|
1373
1404
|
훅들: 훅정보.훅들,
|
|
1374
1405
|
// 이름 붙인 하위 작업. 목록은 Task 스키마에, 지침은 고른 뒤에만 실린다.
|
|
@@ -1506,6 +1537,16 @@ export async function chatLoop(opts = {}) {
|
|
|
1506
1537
|
*/
|
|
1507
1538
|
ask물음: async (물음, 고를것 = [], 이해 = '') => {
|
|
1508
1539
|
if (closed) return null;
|
|
1540
|
+
/*
|
|
1541
|
+
* 자율(auto) 모드는 맡긴 모드다 — 사람 답을 끝없이 기다리지 않는다 (ui/approve.js 의 자율대기).
|
|
1542
|
+
* 0 이면 묻지 않고 넘긴다. 도구는 null 을 받으면 「스스로 판단해 이어가라」 고 모델에게 말한다.
|
|
1543
|
+
*/
|
|
1544
|
+
const 대기 = session.mode === 'auto' ? 자율대기초 : 0;
|
|
1545
|
+
if (session.mode === 'auto' && 대기 === 0) {
|
|
1546
|
+
say('');
|
|
1547
|
+
say(` ${c.gray(옮긴말('ask.autoSkip', { 물음: clip(String(물음 ?? ''), 80) }))}`);
|
|
1548
|
+
return null;
|
|
1549
|
+
}
|
|
1509
1550
|
const 폭 = Math.max(40, Math.min(88, (process.stdout.columns || 80) - 6)) - 4;
|
|
1510
1551
|
say('');
|
|
1511
1552
|
say(` ${c.hcyan('┌')} ${c.bold(옮긴말('ask.title'))}`);
|
|
@@ -1543,8 +1584,10 @@ export async function chatLoop(opts = {}) {
|
|
|
1543
1584
|
* 아무도 안 고른 판에서 1번을 「사람이 골랐다」 로 모델에게 실어 보낸다.
|
|
1544
1585
|
*/
|
|
1545
1586
|
const 답 = String(await ask(
|
|
1546
|
-
고를것.length ? 옮긴말('ask.pickPrompt', { 끝: 고를것.length }) : 옮긴말('ask.freePrompt')
|
|
1547
|
-
|
|
1587
|
+
(고를것.length ? 옮긴말('ask.pickPrompt', { 끝: 고를것.length }) : 옮긴말('ask.freePrompt'))
|
|
1588
|
+
+ (대기 ? ` ${c.gray(`(${옮긴말('ask.autoWait', { 초: 대기 })})`)}` : ''),
|
|
1589
|
+
// 시한이 지나면 안 고른 것이다 — def 인 '1' 로 떨어지면 아무도 안 고른 1번이 사람의 답이 된다.
|
|
1590
|
+
{ def: 고를것.length ? '1' : '', 끝나면: '', 기다림초: 대기, 시간끝: '' },
|
|
1548
1591
|
)).trim();
|
|
1549
1592
|
창제목(제목글('도는중', { 폴더: 알림.폴더 }));
|
|
1550
1593
|
|
|
@@ -1568,9 +1611,11 @@ export async function chatLoop(opts = {}) {
|
|
|
1568
1611
|
say('');
|
|
1569
1612
|
return 고른것;
|
|
1570
1613
|
},
|
|
1571
|
-
confirm: async (name, args) => {
|
|
1614
|
+
confirm: async (name, args, { 미리보기 } = {}) => {
|
|
1572
1615
|
say('');
|
|
1573
1616
|
say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
|
|
1617
|
+
// 무엇이 바뀌는지 먼저 보여 주고 묻는다 (ui/approve.js 의 미리보기줄들).
|
|
1618
|
+
for (const l of 미리보기줄들(미리보기, { 줄수: DIFF_LINES[session.level] ?? 40 })) say(l);
|
|
1574
1619
|
/*
|
|
1575
1620
|
* 여기서 종을 울린다. 끝난 것은 늦게 알아도 되지만 **막혀 있는 것은**
|
|
1576
1621
|
* 기다린 만큼 그대로 손해다 — 다른 창에 가 있는 사이 3분째 이 줄에서
|
|
@@ -3006,6 +3051,26 @@ export async function chatLoop(opts = {}) {
|
|
|
3006
3051
|
: ` ${c.gray('↺ 읽기만 하고 끝내려고 해서 한 번 되밀었습니다')}`);
|
|
3007
3052
|
break;
|
|
3008
3053
|
|
|
3054
|
+
// 완료 검사 (agent/donecheck.js) — 모델이 끝내려 하자 deel 이 정해 둔 검사를 돌린다.
|
|
3055
|
+
case 'check_start':
|
|
3056
|
+
clearThinking();
|
|
3057
|
+
if (streamed) { 답비우기(); say(''); streamed = false; }
|
|
3058
|
+
say('');
|
|
3059
|
+
say(` ${c.cyan('⧗')} ${c.gray(옮긴말('check.start', { 판: ev.판, 최대: ev.최대, 명령: ev.명령 }))}`);
|
|
3060
|
+
break;
|
|
3061
|
+
|
|
3062
|
+
case 'check':
|
|
3063
|
+
clearThinking();
|
|
3064
|
+
if (ev.ok === true) say(` ${mark.ok} ${c.gray(옮긴말('check.pass', { 명령: ev.명령 }))}`);
|
|
3065
|
+
else if (ev.ok === false) {
|
|
3066
|
+
say(` ${c.red('✗')} ${c.white(옮긴말('check.fail', { 판: ev.판, 최대: ev.최대, 요약: ev.요약 }))}`);
|
|
3067
|
+
for (const 한줄 of String(ev.꼬리 ?? '').trim().split('\n').filter((l) => l.trim()).slice(-6)) {
|
|
3068
|
+
say(` ${c.gray(clip(한줄, 100))}`);
|
|
3069
|
+
}
|
|
3070
|
+
say(` ${c.gray(ev.판 < ev.최대 ? 옮긴말('check.retry') : 옮긴말('check.gaveUp', { 판: ev.판 }))}`);
|
|
3071
|
+
} else say(` ${c.yellow('⊘')} ${c.gray(옮긴말('check.skipped', { 까닭: ev.까닭 }))}`);
|
|
3072
|
+
break;
|
|
3073
|
+
|
|
3009
3074
|
/*
|
|
3010
3075
|
* 모델이 안 하겠다고 했다 (agent/loop.js 의 거절).
|
|
3011
3076
|
*
|
|
@@ -3231,10 +3296,19 @@ export async function chatLoop(opts = {}) {
|
|
|
3231
3296
|
* 승인받는 자리는 막아 놓고 계획 **전체**를 승인하는 자리가 열려 있었다.
|
|
3232
3297
|
* 재 보니 아무도 답하지 않은 판에서 계획이 승인되고 파일이 만들어졌다.
|
|
3233
3298
|
*/
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3299
|
+
/*
|
|
3300
|
+
* 자율(auto) 모드면 시한을 둔다 (ui/approve.js 의 자율대기). 지나면 **그대로 진행**이다 —
|
|
3301
|
+
* 이 상자는 안전 관문이 아니라 「계획부터 보여 준다」 는 자리이고, auto 를 고른 사람은
|
|
3302
|
+
* 맡긴 사람이다. 입력이 끝난 것(파이프·Ctrl+D)은 여전히 승인이 아니다(`끝나면: 'n'`).
|
|
3303
|
+
*/
|
|
3304
|
+
const 계획대기 = session.mode === 'auto' ? 자율대기초 : 0;
|
|
3305
|
+
const 답 = session.mode === 'auto' && 계획대기 === 0
|
|
3306
|
+
? (say(` ${c.gray(옮긴말('plan.autoGo'))}`), 'y')
|
|
3307
|
+
: String(await ask(
|
|
3308
|
+
`${옮긴말('plan.ask')} ${c.gray(옮긴말('plan.hint'))}`
|
|
3309
|
+
+ (계획대기 ? ` ${c.gray(`(${옮긴말('ask.autoWait', { 초: 계획대기 })})`)}` : ''),
|
|
3310
|
+
{ def: 'y', 끝나면: 'n', 멈추면: 'n', 기다림초: 계획대기, 시간끝: 'y', 시간끝말: 옮긴말('plan.timedOut', { 초: 계획대기 }) },
|
|
3311
|
+
)).trim();
|
|
3238
3312
|
/*
|
|
3239
3313
|
* ── 골라진 것 + 하고 싶은 말 ────────────────────────────────────
|
|
3240
3314
|
*
|
package/src/report.js
CHANGED
package/src/safety/audit.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// 감사 로그. 무엇을 언제 어떻게 했는지 전부 남긴다.
|
|
2
2
|
// 자율 실행을 사내에 설득할 때 이 파일이 근거가 된다.
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { appendFileSync, mkdirSync, existsSync, readFileSync
|
|
4
|
+
import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { 줄로끝나나, 본인만잠그기 } from '../jsonl.js';
|
|
5
6
|
import { 가리기 } from './secrets.js';
|
|
6
7
|
import { 첫이름 } from '../tools/label.js';
|
|
7
8
|
|
|
@@ -126,25 +127,13 @@ export class Audit {
|
|
|
126
127
|
* 개행이 아니면 개행부터 붙인다. 줄마다 재지 않는다 — 그 뒤로는 우리가 적은 줄이다.
|
|
127
128
|
*/
|
|
128
129
|
#줄끝봤나 = false;
|
|
129
|
-
#줄로끝나나() {
|
|
130
|
-
let fd = null;
|
|
131
|
-
try {
|
|
132
|
-
const 크기 = statSync(this.file).size;
|
|
133
|
-
if (!크기) return true;
|
|
134
|
-
fd = openSync(this.file, 'r');
|
|
135
|
-
const 한바이트 = Buffer.alloc(1);
|
|
136
|
-
readSync(fd, 한바이트, 0, 1, 크기 - 1);
|
|
137
|
-
return 한바이트[0] === 0x0a;
|
|
138
|
-
} catch { return true; } // 없거나 못 읽으면 붙일 반쪽도 없다
|
|
139
|
-
finally { if (fd != null) { try { closeSync(fd); } catch { /* 닫다 터져도 적기는 한다 */ } } }
|
|
140
|
-
}
|
|
141
130
|
|
|
142
131
|
write(kind, data) {
|
|
143
132
|
const rec = { at: new Date().toISOString(), session: this.session, kind, ...data };
|
|
144
133
|
try {
|
|
145
134
|
let 줄 = JSON.stringify(rec) + '\n';
|
|
146
135
|
if (!this.#줄끝봤나) {
|
|
147
|
-
if (
|
|
136
|
+
if (!줄로끝나나(this.file)) 줄 = '\n' + 줄;
|
|
148
137
|
this.#줄끝봤나 = true;
|
|
149
138
|
}
|
|
150
139
|
appendFileSync(this.file, 줄, 'utf8');
|
|
@@ -180,11 +169,7 @@ export class Audit {
|
|
|
180
169
|
#잠그기() {
|
|
181
170
|
if (this.#잠갔나) return;
|
|
182
171
|
this.#잠갔나 = true;
|
|
183
|
-
|
|
184
|
-
// 「잠근 척」 이다 — agent/store.js 와 같이 안 걸었다고 적는다.
|
|
185
|
-
if (process.platform === 'win32') { this.잠금 = { 못함: 'windows' }; return; }
|
|
186
|
-
try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
|
|
187
|
-
catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
|
|
172
|
+
this.잠금 = 본인만잠그기(this.file);
|
|
188
173
|
}
|
|
189
174
|
|
|
190
175
|
/** 기록이 새고 있나. 새고 있으면 `{수, 까닭}`, 멀쩡하면 null. */
|
package/src/safety/authcmd.js
CHANGED
|
@@ -49,7 +49,7 @@ import { isOffline } from './network.js';
|
|
|
49
49
|
import { 가리기 } from './secrets.js';
|
|
50
50
|
|
|
51
51
|
/** 기다려 주는 시간. 사내 로그인은 브라우저를 열고 사람을 기다린다. */
|
|
52
|
-
|
|
52
|
+
const 기본기다림 = 180000;
|
|
53
53
|
|
|
54
54
|
/** 수명을 안 알려 줄 때 몇 초짜리로 볼까. 짧게 잡는다 — 늦게 아는 것보다 낫다. */
|
|
55
55
|
export const 기본수명 = 3300;
|
package/src/safety/guard.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// 자율 실행의 울타리.
|
|
2
2
|
// 승인 프롬프트를 안 쓰는 대신 (1) 작업 범위 밖은 못 건드리고
|
|
3
3
|
// (2) 되돌릴 수 없는 명령만 막는다. 나머지는 전부 통과시킨다.
|
|
4
|
-
import { resolve, relative, isAbsolute, sep, dirname, basename } from 'node:path';
|
|
5
|
-
import { realpathSync, existsSync } from 'node:fs';
|
|
6
|
-
import { homedir } from 'node:os';
|
|
4
|
+
import { resolve, relative, isAbsolute, sep, dirname, basename, win32, posix } from 'node:path';
|
|
5
|
+
import { realpathSync, existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { homedir, userInfo } from 'node:os';
|
|
7
7
|
import { 내부살림 } from '../tools/fsutil.js';
|
|
8
8
|
|
|
9
9
|
export class ScopeError extends Error {}
|
|
@@ -110,8 +110,28 @@ export function makeScope(root) {
|
|
|
110
110
|
// Git Bash 꼴 절대 경로(/c/Users/…)는 윈도우에서 C:\Users\… 다. 안 풀면 지금
|
|
111
111
|
// 드라이브 밑의 \c\Users\… 로 읽혀, 안에 있는 파일이 '범위 밖' 으로 막힌다.
|
|
112
112
|
const 경로 = MSYS풀기(p);
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
let abs = isAbsolute(경로) ? resolve(경로) : resolve(base, 경로);
|
|
114
|
+
/*
|
|
115
|
+
* ── 글자로는 밖인데 **같은 자리를 다르게 적은 것** (2.0.2 · A5) ──────────────
|
|
116
|
+
*
|
|
117
|
+
* 윈도우는 한 폴더를 두 이름으로 적는다 — `C:\Users\RUNNER~1\…`(8.3) 와
|
|
118
|
+
* `C:\Users\runneradmin\…`. 뿌리를 짧은 이름으로 들고 있는데 모델이 긴 이름
|
|
119
|
+
* 절대경로를 주면(셸의 pwd · 오류 글 · 딴 도구의 답에서 베낀 것), 여기가 글자로만
|
|
120
|
+
* 견줘 **같은 폴더의 파일을 「작업 범위 밖」 으로** 막았다. 재 봤다 —
|
|
121
|
+
* `DEEL_A~1` 뿌리에서 `deel_a5_longfoldername_…\b.txt` 쓰기가 막혔다.
|
|
122
|
+
*
|
|
123
|
+
* 그래서 글자로 밖이면 한 번 더 **실제 자리끼리** 견준다. 실제로도 밖이면 그대로
|
|
124
|
+
* 막는다. 실제로는 안이면 받아 들이되, 돌려주는 경로는 **뿌리가 적힌 꼴로** 다시
|
|
125
|
+
* 붙인다 — 되돌리기 기록(rel)·화면 경로(show)·커밋이 다 뿌리의 꼴로 견주기 때문이다.
|
|
126
|
+
* 긴 이름 그대로 넘기면 여기만 지나고 아래에서 같은 병이 다시 난다.
|
|
127
|
+
*/
|
|
128
|
+
if (밖(base, abs)) {
|
|
129
|
+
const 실제 = 진짜자리(abs);
|
|
130
|
+
const 뿌리실제 = 진짜뿌리();
|
|
131
|
+
if (밖(뿌리실제, 실제)) throw new ScopeError(`작업 범위 밖입니다: ${p}\n 범위: ${base}`);
|
|
132
|
+
const 안쪽 = relative(같은꼴(뿌리실제), 같은꼴(실제));
|
|
133
|
+
abs = 안쪽 ? resolve(base, 안쪽) : base;
|
|
134
|
+
}
|
|
115
135
|
// 글자로는 안인데 링크를 따라가면 밖인 자리. 정션 하나로 울타리가 열린다.
|
|
116
136
|
//
|
|
117
137
|
// 여기 견주기도 한 모양으로 맞춘다. 맥에서 realpathSync 는 디스크에 있는
|
|
@@ -560,6 +580,36 @@ export function checkPaths(cmd, scope) {
|
|
|
560
580
|
return true;
|
|
561
581
|
}
|
|
562
582
|
|
|
583
|
+
/*
|
|
584
|
+
* ── `~이름` 이 가리키는 집 (2.0.2 · 388) ────────────────────────────────────
|
|
585
|
+
*
|
|
586
|
+
* 「내 집 옆자리」 로만 풀었다. 그런데 root 로 돌면 내 집이 `/root` 라 옆자리가 **뿌리**다 —
|
|
587
|
+
* `~admin` 이 `/admin` 이 됐다. 셸은 passwd 의 `/home/admin` 으로 가는데. 작업 폴더가 마침
|
|
588
|
+
* `/admin` 이면 남의 집을 「범위 안」 으로 읽는다.
|
|
589
|
+
*
|
|
590
|
+
* 유닉스에서는 passwd 에 적힌 집을 먼저 본다(셸이 보는 그 자리). 없으면(맥의 디렉터리 서비스 ·
|
|
591
|
+
* 윈도) 예전처럼 내 집 옆자리로 풀되, 그 옆자리가 뿌리면 관례 자리(`/home`)로 간다. 내 이름이면
|
|
592
|
+
* 내 집이다.
|
|
593
|
+
*/
|
|
594
|
+
export function 남의집(이름, { 집 = homedir(), 나 = null, passwd = null, platform = process.platform } = {}) {
|
|
595
|
+
let 내이름 = 나;
|
|
596
|
+
if (내이름 == null) { try { 내이름 = userInfo().username; } catch { 내이름 = ''; } }
|
|
597
|
+
if (이름 === 내이름) return 집;
|
|
598
|
+
if (platform !== 'win32') {
|
|
599
|
+
let 글 = passwd;
|
|
600
|
+
if (글 == null) { try { 글 = readFileSync('/etc/passwd', 'utf8'); } catch { 글 = ''; } }
|
|
601
|
+
for (const 줄 of String(글).split('\n')) {
|
|
602
|
+
const 칸 = 줄.split(':');
|
|
603
|
+
if (칸[0] === 이름 && 칸[5]) return 칸[5];
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// 판을 넘겨받았으면 경로 셈도 그 판으로 한다. 리눅스에서 윈도 판을 물으면 `C:\Users\me` 가
|
|
607
|
+
// 상대경로로 읽혀 작업 폴더 밑이 됐다(검사만 그 판을 만든다 — 실제로는 늘 이 PC 의 판이다).
|
|
608
|
+
const 길 = platform === 'win32' ? win32 : posix;
|
|
609
|
+
const 옆 = 길.dirname(집);
|
|
610
|
+
return 길.resolve(옆 === 길.parse(옆).root ? 길.join(옆, 'home') : 옆, 이름);
|
|
611
|
+
}
|
|
612
|
+
|
|
563
613
|
// 셸이 알아서 풀어 주는 자리표. 우리도 같이 풀어야 실제로 닿는 곳을 본다.
|
|
564
614
|
function 자리표풀기(s) {
|
|
565
615
|
return s
|
|
@@ -582,7 +632,7 @@ function 자리표풀기(s) {
|
|
|
582
632
|
* 남의 집은 어느 쪽으로 풀든 내 작업 폴더 밖이다. 이름은 글자·밑줄로 시작할 때만 받는다
|
|
583
633
|
* (`~1` · `~+` 은 셸의 다른 뜻). 가운데 물결(`./~backup` · `PROGRA~1`)은 `^` 에 안 닿는다.
|
|
584
634
|
*/
|
|
585
|
-
.replace(/^~([A-Za-z_][\w.-]*)(?=[/\\]|$)/, (전체, 이름) =>
|
|
635
|
+
.replace(/^~([A-Za-z_][\w.-]*)(?=[/\\]|$)/, (전체, 이름) => 남의집(이름))
|
|
586
636
|
/*
|
|
587
637
|
* 파워셸은 `$env:이름` 으로 적는다.
|
|
588
638
|
*
|
package/src/safety/hooks.js
CHANGED
package/src/safety/keystore.js
CHANGED
|
@@ -222,7 +222,7 @@ export function 잠그기(글, { 프로필id = null } = {}) {
|
|
|
222
222
|
|
|
223
223
|
if (process.platform === 'win32') {
|
|
224
224
|
const r = 파워셸실행(잠그는스크립트, Buffer.from(값, 'utf8').toString('base64'));
|
|
225
|
-
if (r.ok && r.out) return `dpapi:${r.out}
|
|
225
|
+
if (r.ok && r.out) return 됐다(`dpapi:${r.out}`);
|
|
226
226
|
못잠근까닭 = r.err || '파워셸이 잠그기를 못 마쳤습니다';
|
|
227
227
|
해보고졌나 = true;
|
|
228
228
|
return null;
|
|
@@ -230,7 +230,7 @@ export function 잠그기(글, { 프로필id = null } = {}) {
|
|
|
230
230
|
if (process.platform === 'darwin') {
|
|
231
231
|
const 이름 = 키체인이름(프로필id);
|
|
232
232
|
const r = 키체인넣기(값, 이름);
|
|
233
|
-
if (r.ok) return `keychain:${이름}
|
|
233
|
+
if (r.ok) return 됐다(`keychain:${이름}`);
|
|
234
234
|
못잠근까닭 = r.err || 'security 가 열쇠를 못 넣었습니다';
|
|
235
235
|
해보고졌나 = true;
|
|
236
236
|
return null;
|
|
@@ -282,7 +282,22 @@ let 못잠근까닭 = null;
|
|
|
282
282
|
*/
|
|
283
283
|
let 해보고졌나 = false;
|
|
284
284
|
|
|
285
|
-
|
|
285
|
+
/*
|
|
286
|
+
* ── 되고 나면 옛 실패를 **내려놓는다** (2.0.2 · 530) ──────────────────────
|
|
287
|
+
*
|
|
288
|
+
* 잠그기가 한 번 지면 그 까닭이 프로세스 내내 남았다. 뒤에 같은 판에서 잠그기가 되어도
|
|
289
|
+
* (파워셸이 잠깐 막혔다 풀림 · 키체인이 잠겼다 열림) 보관방식() 은 평문 값을 보면 여전히
|
|
290
|
+
* 「잠그려다 실패했습니다」 라고 적었다 — 지금은 되는 장치를 안 된다고 말하는 것이다.
|
|
291
|
+
* 되면 지운다. config.js 가 「몇 개를 왜 못 잠갔나」 에 쓰는 까닭은 그쪽이 **진 순간에**
|
|
292
|
+
* 받아 둔다(잠금옮기기).
|
|
293
|
+
*/
|
|
294
|
+
function 됐다(값) {
|
|
295
|
+
못잠근까닭 = null;
|
|
296
|
+
해보고졌나 = false;
|
|
297
|
+
return 값;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 마지막으로 잠그다 실패한 까닭. 한 번도 실패한 적이 없으면(또는 그 뒤로 됐으면) null. */
|
|
286
301
|
export function 잠그다실패한까닭() { return 못잠근까닭; }
|
|
287
302
|
|
|
288
303
|
/** 사람이 스스로 껐나. 껐으면 이 판에서는 **아무것도 안 해 본다.** */
|
package/src/safety/policy.js
CHANGED
|
@@ -90,6 +90,44 @@ export function 정책읽기({ env = process.env, platform = process.platform,
|
|
|
90
90
|
/** 검사가 원래대로 돌려놓을 때. */
|
|
91
91
|
export function 정책잊기() { 읽은정책 = null; }
|
|
92
92
|
|
|
93
|
+
/*
|
|
94
|
+
* ── 승인 바닥 (2.1.0) ──────────────────────────────────────────────────
|
|
95
|
+
*
|
|
96
|
+
* 사내 검토에서 나온 물음: 「파일을 고치거나 지우기 전에 미리보기를 보여 주고
|
|
97
|
+
* 사람이 한 번 확인하는 절차가 **기본으로** 도는가.」
|
|
98
|
+
*
|
|
99
|
+
* 승인 모드는 쓰는 사람이 고른다(`/mode` · Shift+Tab). 기본은 auto 다 — 이 도구의
|
|
100
|
+
* 속도가 거기서 나온다. 그런데 관리자가 「이 PC 에서는 바꾸기 전에 늘 묻는다」 를
|
|
101
|
+
* 정할 자리가 없었다. 정책은 주소·봉인·금지만 받았다.
|
|
102
|
+
*
|
|
103
|
+
* { "approval": "strict" }
|
|
104
|
+
*
|
|
105
|
+
* 정책의 approval 은 **바닥**이다. 사람은 그보다 더 조일 수는 있어도 아래로는 못
|
|
106
|
+
* 내린다 — 정책이 넓히지 못하는 것과 같은 자세다. 모르는 값이면 제일 좁은
|
|
107
|
+
* strict 로 친다. 관리자가 오타를 냈다고 바닥이 통째로 빠지면, 관리자는 걸린 줄
|
|
108
|
+
* 알고 사람은 안 걸린 채로 쓴다.
|
|
109
|
+
*/
|
|
110
|
+
export const 승인차례 = ['auto', 'confirm', 'strict'];
|
|
111
|
+
|
|
112
|
+
export function 승인바닥(읽은 = 정책읽기()) {
|
|
113
|
+
/*
|
|
114
|
+
* 파일이 있는데 **못 읽었으면**(쉼표 하나 틀린 JSON) 바닥도 strict 다. 위의 「모르는 값」 과
|
|
115
|
+
* 같은 까닭이다 — 관리자는 `"approval": "strict",` 를 적어 두었고 걸린 줄 안다. 못 읽었다고
|
|
116
|
+
* auto 로 풀면 오타 하나로 바닥이 통째로 빠진다 (2차 눈 판정). 파일이 없는 PC 는 그대로 auto.
|
|
117
|
+
*/
|
|
118
|
+
if (읽은?.탈) return { 바닥: 'strict', 곳: 읽은.곳 ?? null, 모름: null, 탈: 읽은.탈 };
|
|
119
|
+
const v = 읽은?.값?.approval;
|
|
120
|
+
if (v === undefined || v === null || v === '') return { 바닥: 'auto', 곳: null, 모름: null };
|
|
121
|
+
const s = String(v).trim().toLowerCase();
|
|
122
|
+
if (승인차례.includes(s)) return { 바닥: s, 곳: 읽은.곳, 모름: null };
|
|
123
|
+
return { 바닥: 'strict', 곳: 읽은.곳, 모름: String(v) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 둘 중 더 많이 묻는 쪽. 모르는 이름은 auto 로 친다 — 바닥 쪽이 따로 막는다. */
|
|
127
|
+
export function 더센승인(a, b) {
|
|
128
|
+
return 승인차례[Math.max(0, 승인차례.indexOf(a), 승인차례.indexOf(b))];
|
|
129
|
+
}
|
|
130
|
+
|
|
93
131
|
/*
|
|
94
132
|
* `Tool(무늬)` 를 읽는다.
|
|
95
133
|
*
|
package/src/safety/shellenv.js
CHANGED
|
@@ -116,7 +116,7 @@ function 이름마디들(이름) {
|
|
|
116
116
|
const 자격든주소 = /^[a-z][a-z0-9+.-]*:\/\/[^\s/?#@:]*:[^\s/?#@]+@/i;
|
|
117
117
|
|
|
118
118
|
/** 이 값이 자격이 든 주소인가. 이름으로는 못 거르는 DATABASE_URL 같은 것을 거른다. */
|
|
119
|
-
|
|
119
|
+
function 비밀값인가(값) {
|
|
120
120
|
return typeof 값 === 'string' && 자격든주소.test(값.trim());
|
|
121
121
|
}
|
|
122
122
|
|
package/src/safety/undo.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
|
|
2
2
|
// 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
|
|
3
3
|
import { join, dirname, basename, resolve, relative, isAbsolute, sep } from 'node:path';
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, statSync,
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, rmdirSync, appendFileSync, statSync, renameSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { 줄로끝나나, 본인만잠그기 } from '../jsonl.js';
|
|
5
6
|
import { looksBinary } from '../tools/encoding.js';
|
|
6
7
|
import { 살림폴더만들기 } from './audit.js';
|
|
7
8
|
import { 말 } from '../i18n/index.js';
|
|
@@ -86,6 +87,19 @@ export class History {
|
|
|
86
87
|
this.잠금 = null;
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/** 이 경로의 조상 가운데 **아직 없는** 폴더들(뿌리 기준 상대 경로, 깊은 것부터). 뿌리 밖으로는 안 올라간다. */
|
|
91
|
+
#없는폴더들(abs) {
|
|
92
|
+
const 들 = [];
|
|
93
|
+
let d = dirname(resolve(String(abs)));
|
|
94
|
+
for (let i = 0; i < 64 && !existsSync(d); i++) {
|
|
95
|
+
const 상대 = this.#상대(d);
|
|
96
|
+
if (상대 === null) break;
|
|
97
|
+
들.push(상대);
|
|
98
|
+
d = dirname(d);
|
|
99
|
+
}
|
|
100
|
+
return 들;
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
/** 새 기록에 적을 상대 경로. 뿌리 밖이면 null — 그런 기록은 되돌릴 때 안 받는다. */
|
|
90
104
|
#상대(abs) {
|
|
91
105
|
const a = resolve(String(abs));
|
|
@@ -120,23 +134,11 @@ export class History {
|
|
|
120
134
|
* 개행으로 끝내므로 한 번 본 것이 계속 맞다.
|
|
121
135
|
*/
|
|
122
136
|
#줄끝봤나 = false;
|
|
123
|
-
#줄로끝나나() {
|
|
124
|
-
let fd = null;
|
|
125
|
-
try {
|
|
126
|
-
const 크기 = statSync(this.file).size;
|
|
127
|
-
if (!크기) return true;
|
|
128
|
-
fd = openSync(this.file, 'r');
|
|
129
|
-
const 한바이트 = Buffer.alloc(1);
|
|
130
|
-
readSync(fd, 한바이트, 0, 1, 크기 - 1);
|
|
131
|
-
return 한바이트[0] === 0x0a;
|
|
132
|
-
} catch { return true; } // 없거나 못 읽으면 붙일 반쪽도 없다
|
|
133
|
-
finally { if (fd != null) { try { closeSync(fd); } catch { /* 닫다 터져도 적기는 한다 */ } } }
|
|
134
|
-
}
|
|
135
137
|
|
|
136
138
|
#덧붙이기(rec) {
|
|
137
139
|
let 줄 = 적을줄(rec) + '\n';
|
|
138
140
|
if (!this.#줄끝봤나) {
|
|
139
|
-
if (
|
|
141
|
+
if (!줄로끝나나(this.file)) 줄 = '\n' + 줄;
|
|
140
142
|
this.#줄끝봤나 = true;
|
|
141
143
|
}
|
|
142
144
|
appendFileSync(this.file, 줄, 'utf8');
|
|
@@ -166,11 +168,7 @@ export class History {
|
|
|
166
168
|
#잠그기() {
|
|
167
169
|
if (this.#잠갔나) return;
|
|
168
170
|
this.#잠갔나 = true;
|
|
169
|
-
|
|
170
|
-
// 「잠근 척」 이다 — agent/store.js 와 같이 안 걸었다고 적는다.
|
|
171
|
-
if (process.platform === 'win32') { this.잠금 = { 못함: 'windows' }; return; }
|
|
172
|
-
try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
|
|
173
|
-
catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
|
|
171
|
+
this.잠금 = 본인만잠그기(this.file);
|
|
174
172
|
}
|
|
175
173
|
|
|
176
174
|
/**
|
|
@@ -236,6 +234,22 @@ export class History {
|
|
|
236
234
|
* 그림·문서가 /undo 뒤에도 남았다. 이 표가 있으면 정말 없던 자리라 바이너리여도 지운다.
|
|
237
235
|
*/
|
|
238
236
|
if (없던) rec.없던 = true;
|
|
237
|
+
/*
|
|
238
|
+
* ── 이 파일과 **같이 생길 폴더**도 적는다 (2.0.2 · B3) ─────────────────────
|
|
239
|
+
*
|
|
240
|
+
* 폴더를 통째로 옮긴 판(`dirA` → `dirB`)을 되돌리면 파일은 `dirA` 로 돌아오고
|
|
241
|
+
* `dirB/f.txt` 는 「원래 없던 파일」 로 지워지는데, **빈 `dirB` 가 남았다.** 되돌린
|
|
242
|
+
* 뒤의 폴더가 되돌리기 전과 다르다. 그 폴더가 이번에 생겼다는 것을 아무 데도
|
|
243
|
+
* 안 적어서 지울 근거가 없었다.
|
|
244
|
+
*
|
|
245
|
+
* 원래 있던 빈 폴더에 파일을 만든 판과 가르려면 **뜰 때** 봐야 한다 — 되돌릴 때는
|
|
246
|
+
* 둘 다 「빈 폴더」 라 구별이 안 된다. 그래서 여기서 아직 없는 조상 폴더를 깊은
|
|
247
|
+
* 것부터 적어 두고, 되돌릴 때 그 폴더가 **비었을 때만** 지운다.
|
|
248
|
+
*/
|
|
249
|
+
if (없던) {
|
|
250
|
+
const 새폴더 = this.#없는폴더들(absPath);
|
|
251
|
+
if (새폴더.length) rec.새폴더 = 새폴더;
|
|
252
|
+
}
|
|
239
253
|
// 작업 폴더 기준 자리. 폴더를 옮기거나 복사해도 이 이력은 **그 폴더**를 되돌린다 (위 머리말).
|
|
240
254
|
const 상대 = this.#상대(absPath);
|
|
241
255
|
if (상대 !== null) rec.rel = 상대;
|
|
@@ -618,6 +632,16 @@ export class History {
|
|
|
618
632
|
}
|
|
619
633
|
rmSync(path, { force: true });
|
|
620
634
|
restored.push({ path, how: 말('undo.wayDeleted'), ok: true });
|
|
635
|
+
// 그 파일과 같이 생긴 폴더가 이제 비었으면 거둔다 (snapshot 의 새폴더 머리말). rmdirSync 는 **빈
|
|
636
|
+
// 폴더만** 지운다 — 그 뒤에 누가 넣은 것이 있으면 ENOTEMPTY 로 멈추고, 폴더가 아니면 ENOTDIR 다.
|
|
637
|
+
// 이력은 저장소에 딸려 올 수 있으니(파일 머리말) 폴더 자리도 밖·링크 너머면 안 건드린다.
|
|
638
|
+
// 파일이 아니라서 되돌린 수에는 안 센다.
|
|
639
|
+
for (const 상대 of rec.새폴더 ?? []) {
|
|
640
|
+
const d = this.#자리풀기(null, 상대);
|
|
641
|
+
if (!d || 링크밖(d)) break;
|
|
642
|
+
try { rmdirSync(d); } catch { break; }
|
|
643
|
+
restored.push({ path: d, how: '빈 폴더 지움 (이번에 새로 생긴 폴더)', ok: true, 폴더: true });
|
|
644
|
+
}
|
|
621
645
|
} else {
|
|
622
646
|
/*
|
|
623
647
|
* 담고 있던 폴더가 없어졌을 수 있다 — Move 로 폴더째 옮긴 경우다.
|
|
@@ -710,7 +734,7 @@ export class History {
|
|
|
710
734
|
// 이력에서 못 읽은 줄 수. 그만큼은 되돌릴 길이 애초에 없었다.
|
|
711
735
|
깨진줄: this.깨진줄,
|
|
712
736
|
// 화면·감사기록이 쓰는 수. **진짜로 되돌아간 것만** 센다.
|
|
713
|
-
되돌린수: restored.filter((x) => x.ok === true).length,
|
|
737
|
+
되돌린수: restored.filter((x) => x.ok === true && !x.폴더).length,
|
|
714
738
|
못한것: restored.filter((x) => x.ok === false),
|
|
715
739
|
turns: turns.length,
|
|
716
740
|
turnIds: turns.slice(),
|
package/src/skills/discover.js
CHANGED
|
@@ -38,7 +38,7 @@ export const 내장자리 = join(dirname(fileURLToPath(import.meta.url)), 'built
|
|
|
38
38
|
* 앞머리가 몇 백 줄일 까닭은 없어서 찾는 줄 수에도 상한을 둔다. 같은 열쇠가 두 번 오면
|
|
39
39
|
* 첫 것을 둔다 — 뒤엣것은 대개 본문이 새어 들어온 것이다.
|
|
40
40
|
*/
|
|
41
|
-
|
|
41
|
+
const 앞머리최대줄 = 200;
|
|
42
42
|
const 금인가 = (줄) => /^---[ \t]*\r?$/.test(줄);
|
|
43
43
|
// YAML 앞머리에 설 수 있는 줄 꼴 — 빈 줄 · 들여 쓴 줄 · 주석 · 목록 · `열쇠:` 줄.
|
|
44
44
|
const 앞머리줄인가 = (줄) => {
|