@kaydash9999/banker-plugins 0.23.3 → 0.24.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +56 -0
- package/README.md +14 -6
- package/codex/manifest.json +6 -2
- package/codex/skills/teamwork/scripts/teamwork.mjs +3 -3
- package/codex/transform-matrix.md +6 -2
- package/package.json +1 -1
- package/skills/build-testbox/SKILL.md +213 -0
- package/skills/build-testbox/scripts/args.mjs +156 -0
- package/skills/build-testbox/scripts/browser-cell.mjs +181 -0
- package/skills/build-testbox/scripts/browsers.mjs +164 -0
- package/skills/build-testbox/scripts/containers.mjs +237 -0
- package/skills/build-testbox/scripts/detect.mjs +166 -0
- package/skills/build-testbox/scripts/proc.mjs +91 -0
- package/skills/build-testbox/scripts/report.mjs +109 -0
- package/skills/build-testbox/scripts/setup.mjs +144 -0
- package/skills/build-testbox/scripts/testbox.mjs +148 -0
- package/skills/git-init/SKILL.md +152 -0
- package/skills/git-init/scripts/git-init.mjs +440 -0
- package/skills/git-init/scripts/marker-block.mjs +321 -0
- package/skills/git-init/scripts/payload-scan.mjs +186 -0
- package/skills/init-dir/SKILL.md +172 -0
- package/skills/init-dir/scripts/init-dir.mjs +308 -0
- package/skills/init-dir/scripts/marker-block.mjs +321 -0
- package/skills/init-dir/scripts/templates.mjs +100 -0
- package/skills/init-dir/templates/KB/README.md +15 -0
- package/skills/init-dir/templates/docs/README.md +14 -0
- package/skills/init-dir/templates/logs/README.md +14 -0
- package/skills/init-dir/templates/logs/gitignore +3 -0
- package/skills/init-dir/templates/mockup/README.md +7 -0
- package/skills/init-dir/templates/scripts/README.md +39 -0
- package/skills/init-dir/templates/scripts/lib/service.mjs +596 -0
- package/skills/init-dir/templates/scripts/restart.cmd +13 -0
- package/skills/init-dir/templates/scripts/restart.sh +10 -0
- package/skills/init-dir/templates/scripts/service.conf +5 -0
- package/skills/init-dir/templates/scripts/start.cmd +13 -0
- package/skills/init-dir/templates/scripts/start.sh +10 -0
- package/skills/init-dir/templates/scripts/status.cmd +13 -0
- package/skills/init-dir/templates/scripts/status.sh +10 -0
- package/skills/init-dir/templates/scripts/stop.cmd +13 -0
- package/skills/init-dir/templates/scripts/stop.sh +10 -0
- package/skills/init-dir/templates/tests/README.md +15 -0
- package/skills/init-dir/templates/tests/run.cmd +13 -0
- package/skills/init-dir/templates/tests/run.mjs +160 -0
- package/skills/init-dir/templates/tests/run.sh +10 -0
- package/skills/init-dir/templates/tests/tests.conf +7 -0
- package/skills/init-dir/templates/work/README.md +8 -0
- package/skills/progress-mon/README.md +11 -8
- package/skills/progress-mon/SKILL.md +53 -16
- package/skills/progress-mon/assets/fixture/README.md +1 -1
- package/skills/progress-mon/assets/fixture/adr.json +35 -0
- package/skills/progress-mon/assets/fixture/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/flow.json +2 -2
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +1 -1
- package/skills/progress-mon/assets/fixture/variants/legacy-init/rules.json +6 -0
- package/skills/progress-mon/assets/fixture/variants/legacy-init/state.json +5 -0
- package/skills/progress-mon/references/adr.md +184 -0
- package/skills/progress-mon/references/authoring.md +13 -5
- package/skills/progress-mon/scripts/pm-adr-view.mjs +101 -0
- package/skills/progress-mon/scripts/pm-adr.mjs +515 -0
- package/skills/progress-mon/scripts/pm-check.mjs +59 -22
- package/skills/progress-mon/scripts/pm-data.mjs +1 -1
- package/skills/progress-mon/scripts/pm-html.mjs +1 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +113 -28
- package/skills/progress-mon/scripts/pm-render.mjs +6 -4
- package/skills/progress-mon/scripts/pm-server.mjs +2 -1
- package/skills/progress-mon/scripts/pm-sources.mjs +10 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +3 -2
- package/skills/tune-tmux/SKILL.md +235 -0
- package/skills/tune-tmux/scripts/app-notes.mjs +38 -0
- package/skills/tune-tmux/scripts/marker-block.mjs +321 -0
- package/skills/tune-tmux/scripts/psmux-caps.json +71 -0
- package/skills/tune-tmux/scripts/tmux-config.mjs +251 -0
- package/skills/tune-tmux/scripts/tune-tmux.mjs +284 -0
- package/skills/ultra-chain/SKILL.md +52 -22
- package/skills/ultra-chain/references/chain-ledger.mjs +93 -11
- package/skills/ultra-chain/references/chain-report.mjs +13 -2
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* marker-block: puts one marked block of lines into a text file, replaces it or takes it out, and leaves every
|
|
3
|
+
* byte outside the block as it was.
|
|
4
|
+
*
|
|
5
|
+
* The canonical copy is skills/init-dir/scripts/marker-block.mjs. tune-tmux and git-init carry byte-identical
|
|
6
|
+
* copies, since Codex installs every skill folder on its own; scripts/sync-adapter.js mirrors and checks them.
|
|
7
|
+
* Edit the canonical copy only.
|
|
8
|
+
*
|
|
9
|
+
* Policy (one place for every skill that edits a user's file):
|
|
10
|
+
* - Markers are whole lines `<open> <id>:start <close>` and `<open> <id>:end <close>`, with the comment syntax
|
|
11
|
+
* given (`#` or `<!-- -->`). A marker inside a sentence, an unpaired marker, an end before a start or a second
|
|
12
|
+
* block is refused: nothing is cut on a guess.
|
|
13
|
+
* - Bytes outside the block stay as they were. CRLF files get CRLF blocks, a byte order mark stays in front.
|
|
14
|
+
* A file that is not UTF-8 is refused.
|
|
15
|
+
* - A folder, FIFO, socket or device at the name is refused without being read.
|
|
16
|
+
* - A symbolic link is refused (refused: "symlink") unless followSymlink is set; then the file it leads to is
|
|
17
|
+
* written and the link stays a link. A link to nothing is refused either way ("dangling").
|
|
18
|
+
* - A file with two or more hard links is refused ("hardlink"): the rename below would split them.
|
|
19
|
+
* - A file owned by another user is refused ("owner"). The permission bits of the file carry over to the new
|
|
20
|
+
* file. Windows has no owner or permission bits to check.
|
|
21
|
+
* - Writes go to a temp file in the same folder and a rename. A failed write removes the temp file.
|
|
22
|
+
* - When the bytes change, the first original is kept in `<file>.<skill>.bak`; an existing backup is never
|
|
23
|
+
* overwritten.
|
|
24
|
+
*
|
|
25
|
+
* Results are { ok, changed, refused?, message, path, file }. Callers map refused to exit status 1.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
chmodSync,
|
|
29
|
+
closeSync,
|
|
30
|
+
constants,
|
|
31
|
+
fstatSync,
|
|
32
|
+
lstatSync,
|
|
33
|
+
openSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
readlinkSync,
|
|
36
|
+
realpathSync,
|
|
37
|
+
renameSync,
|
|
38
|
+
rmSync,
|
|
39
|
+
statSync,
|
|
40
|
+
writeFileSync,
|
|
41
|
+
} from "node:fs";
|
|
42
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
43
|
+
|
|
44
|
+
/** Comment syntaxes. */
|
|
45
|
+
export const HTML = Object.freeze({ open: "<!--", close: "-->" });
|
|
46
|
+
export const HASH = Object.freeze({ open: "#", close: "" });
|
|
47
|
+
|
|
48
|
+
const BOM = "\u{FEFF}";
|
|
49
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
50
|
+
const MAX_BYTES = 32 * 1024 * 1024;
|
|
51
|
+
const NEW_FILE_MODE = 0o644;
|
|
52
|
+
const BACKUP_MODE = 0o600;
|
|
53
|
+
|
|
54
|
+
/** The start and end marker lines for id in a comment syntax. */
|
|
55
|
+
export function markers(comment, id) {
|
|
56
|
+
const tail = comment.close ? ` ${comment.close}` : "";
|
|
57
|
+
return { start: `${comment.open} ${id}:start${tail}`, end: `${comment.open} ${id}:end${tail}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const refusal = (code, message, path) => ({ ok: false, changed: false, refused: code, message, path });
|
|
61
|
+
const bare = (line) => line.replace(/\r?\n$/, "").replace(/[ \t]+$/, "");
|
|
62
|
+
const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
|
|
63
|
+
const readlinkOr = (path) => {
|
|
64
|
+
try {
|
|
65
|
+
return readlinkSync(path);
|
|
66
|
+
} catch {
|
|
67
|
+
return "?";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const nativeReal = (path) => {
|
|
71
|
+
try {
|
|
72
|
+
return realpathSync.native(path);
|
|
73
|
+
} catch {
|
|
74
|
+
return resolve(path);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const realOrNull = (path) => {
|
|
78
|
+
try {
|
|
79
|
+
return realpathSync(path);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Where the bytes live: the path itself, or with followSymlink the file a link leads to.
|
|
86
|
+
function locate(path, follow) {
|
|
87
|
+
const lst = lstatSync(path, { throwIfNoEntry: false });
|
|
88
|
+
if (!lst) return { file: path, exists: false };
|
|
89
|
+
if (!lst.isSymbolicLink()) return { file: path, exists: true };
|
|
90
|
+
const target = readlinkOr(path);
|
|
91
|
+
if (!follow) return { refuse: refusal("symlink", `심볼릭 링크라 그대로 둠: ${path} -> ${target}`, path) };
|
|
92
|
+
const real = realOrNull(path);
|
|
93
|
+
if (real === null) return { refuse: refusal("dangling", `끊어진 링크라 그대로 둠: ${path} -> ${target}`, path) };
|
|
94
|
+
return { file: real, exists: true, link: path };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Why the file at file may not be read or replaced, or null.
|
|
98
|
+
function fileProblem(st, file, uid) {
|
|
99
|
+
if (!st.isFile()) return refusal("special", `일반 파일이 아니라 그대로 둠: ${file}`, file);
|
|
100
|
+
if (st.nlink > 1) return refusal("hardlink", `하드 링크가 ${st.nlink}개라 그대로 둠: ${file}`, file);
|
|
101
|
+
if (uid !== undefined && st.uid !== uid) return refusal("owner", `다른 사용자 소유라 그대로 둠: ${file}`, file);
|
|
102
|
+
if (st.size > MAX_BYTES) return refusal("too-big", `32MiB 보다 커서 그대로 둠: ${file}`, file);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits. id: the same
|
|
107
|
+
// file's bigint stat for telling files apart (an NTFS file id passes 2^53, so as a Number two files can share one).
|
|
108
|
+
function readChecked(file, uid) {
|
|
109
|
+
const first = statSync(file);
|
|
110
|
+
const problem = fileProblem(first, file, uid);
|
|
111
|
+
if (problem) return { refuse: problem };
|
|
112
|
+
const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
113
|
+
try {
|
|
114
|
+
const st = fstatSync(fd);
|
|
115
|
+
const late = fileProblem(st, file, uid);
|
|
116
|
+
return late ? { refuse: late } : { st, id: fstatSync(fd, { bigint: true }), bytes: readFileSync(fd) };
|
|
117
|
+
} finally {
|
|
118
|
+
closeSync(fd);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// What one line is: "start", "end", "inline" (a marker inside other text) or null.
|
|
123
|
+
function kindOf(line, mk) {
|
|
124
|
+
const b = bare(line);
|
|
125
|
+
if (b === mk.start) return "start";
|
|
126
|
+
if (b === mk.end) return "end";
|
|
127
|
+
return line.includes(mk.start) || line.includes(mk.end) ? "inline" : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Takes line i of kind into state; returns why the markers are broken, or null.
|
|
131
|
+
function step(state, kind, i) {
|
|
132
|
+
if (kind === "inline") return `문장 안에 표지가 있음(${i + 1}행)`;
|
|
133
|
+
if (kind === "start") {
|
|
134
|
+
if (state.open !== -1 || state.found) return `표지 짝이 맞지 않거나 블록이 둘 이상(${i + 1}행)`;
|
|
135
|
+
state.open = i;
|
|
136
|
+
} else if (kind === "end") {
|
|
137
|
+
if (state.open === -1) return `끝 표지가 시작 표지보다 먼저(${i + 1}행)`;
|
|
138
|
+
state.found = { first: state.open, last: i };
|
|
139
|
+
state.open = -1;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The block's first and last line in lines, null when there is none, or { error } when markers are broken. */
|
|
145
|
+
export function findBlock(lines, mk) {
|
|
146
|
+
const state = { open: -1, found: null };
|
|
147
|
+
for (let i = 0; i < lines.length; i++) {
|
|
148
|
+
const error = step(state, kindOf(lines[i], mk), i);
|
|
149
|
+
if (error) return { error };
|
|
150
|
+
}
|
|
151
|
+
if (state.open !== -1) return { error: `짝 없는 시작 표지(${state.open + 1}행)` };
|
|
152
|
+
return state.found;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Whether line i sits inside an open wrap region (more wrap opens than closes before it).
|
|
156
|
+
function insideWrap(lines, i, wrap) {
|
|
157
|
+
let depth = 0;
|
|
158
|
+
for (let j = 0; j < i; j++) {
|
|
159
|
+
const b = bare(lines[j]);
|
|
160
|
+
if (b === wrap.open) depth++;
|
|
161
|
+
else if (b === wrap.close && depth > 0) depth--;
|
|
162
|
+
}
|
|
163
|
+
return depth > 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The span [first, last] widened to wrap lines directly around it, and whether new text must bring its own wrap.
|
|
167
|
+
function spanOf(lines, found, wrap) {
|
|
168
|
+
if (!wrap) return { ...found, wrapNew: false };
|
|
169
|
+
const wrapped = found.first > 0 && bare(lines[found.first - 1]) === wrap.open && bare(lines[found.last + 1] ?? "") === wrap.close;
|
|
170
|
+
if (wrapped) return { first: found.first - 1, last: found.last + 1, wrapNew: true };
|
|
171
|
+
return { ...found, wrapNew: !insideWrap(lines, found.first, wrap) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const blockText = (mk, body, wrap, eol) => {
|
|
175
|
+
const inner = [mk.start, ...body.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"), mk.end];
|
|
176
|
+
return [...(wrap ? [wrap.open, ...inner, wrap.close] : inner), ""].join(eol);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// The text with the block put in, replaced or (body null) taken out. Appending puts one line break between the
|
|
180
|
+
// text and the block; taking a block out at the end of the file takes that break back, so add then remove gives
|
|
181
|
+
// the bytes from before.
|
|
182
|
+
function render(text, found, o) {
|
|
183
|
+
const lines = text.split(/(?<=\n)/);
|
|
184
|
+
const eol = eolOf(text);
|
|
185
|
+
if (!found) {
|
|
186
|
+
if (o.body === null) return text;
|
|
187
|
+
return text === "" ? blockText(o.mk, o.body, o.wrap, eol) : text + eol + blockText(o.mk, o.body, o.wrap, eol);
|
|
188
|
+
}
|
|
189
|
+
const span = spanOf(lines, found, o.wrap);
|
|
190
|
+
const before = lines.slice(0, span.first).join("");
|
|
191
|
+
const after = lines.slice(span.last + 1).join("");
|
|
192
|
+
if (o.body !== null) return before + blockText(o.mk, o.body, span.wrapNew ? o.wrap : null, eol) + after;
|
|
193
|
+
return after === "" ? before.replace(/\r?\n$/, "") : before + after;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkBody(o) {
|
|
197
|
+
if (o.body === null) return;
|
|
198
|
+
const forbidden = [o.mk.start, o.mk.end, ...(o.wrap ? [o.wrap.open, o.wrap.close] : [])];
|
|
199
|
+
if (o.body.split(/\r?\n/).some((line) => forbidden.includes(bare(line)))) throw new Error("block body holds a marker line");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The new bytes for the old ones, or { refuse } when the old ones may not be edited.
|
|
203
|
+
function nextBytes(bytes, o, file) {
|
|
204
|
+
let text;
|
|
205
|
+
try {
|
|
206
|
+
text = UTF8.decode(bytes);
|
|
207
|
+
} catch {
|
|
208
|
+
return { refuse: refusal("encoding", `UTF-8 이 아니라 그대로 둠: ${file}`, file) };
|
|
209
|
+
}
|
|
210
|
+
const bom = text.startsWith(BOM) ? BOM : "";
|
|
211
|
+
const plain = text.slice(bom.length);
|
|
212
|
+
const found = findBlock(plain.split(/(?<=\n)/), o.mk);
|
|
213
|
+
if (found?.error) return { refuse: refusal("markers", `표지 손상이라 그대로 둠: ${file}: ${found.error}`, file) };
|
|
214
|
+
return { bytes: Buffer.from(bom + render(plain, found, o), "utf8") };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const options = (opts) => ({
|
|
218
|
+
mk: markers(opts.comment, opts.id),
|
|
219
|
+
body: opts.body ?? null,
|
|
220
|
+
wrap: opts.wrap ?? null,
|
|
221
|
+
skill: opts.skill,
|
|
222
|
+
follow: opts.followSymlink === true,
|
|
223
|
+
uid: "uid" in opts ? opts.uid : process.getuid?.(),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Works out what applyBlock would write, without writing: { ok, changed, refused?, message, path, file, key,
|
|
228
|
+
* before, after, mode }. key names the file (device and inode, or the path of a file not there yet).
|
|
229
|
+
*/
|
|
230
|
+
export function planBlock(path, opts) {
|
|
231
|
+
const o = options(opts);
|
|
232
|
+
checkBody(o);
|
|
233
|
+
const at = locate(path, o.follow);
|
|
234
|
+
if (at.refuse) return at.refuse;
|
|
235
|
+
if (!at.exists) {
|
|
236
|
+
const after = o.body === null ? null : Buffer.from(blockText(o.mk, o.body, o.wrap, "\n"), "utf8");
|
|
237
|
+
return { ok: true, changed: after !== null, path, file: at.file, key: `path:${resolve(at.file)}`, before: null, after, mode: NEW_FILE_MODE };
|
|
238
|
+
}
|
|
239
|
+
const read = readChecked(at.file, o.uid);
|
|
240
|
+
if (read.refuse) return read.refuse;
|
|
241
|
+
const next = nextBytes(read.bytes, o, at.file);
|
|
242
|
+
if (next.refuse) return next.refuse;
|
|
243
|
+
const changed = !next.bytes.equals(read.bytes);
|
|
244
|
+
// ino 0 (a file system without file ids) tells nothing apart: then the path as the file system spells it is the
|
|
245
|
+
// key (realpathSync.native gives one spelling for names that differ only in case where case is ignored).
|
|
246
|
+
const key = read.id.ino === 0n ? `path:${nativeReal(at.file)}` : `ino:${read.id.dev}:${read.id.ino}`;
|
|
247
|
+
return { ok: true, changed, path, file: at.file, key, before: read.bytes, after: next.bytes, mode: read.st.mode & 0o7777 };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Writes data to a fresh temp file beside file and renames it over file.
|
|
251
|
+
function writeAtomic(file, data, mode) {
|
|
252
|
+
const tmp = join(dirname(file), `.${basename(file)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
253
|
+
let fd = openSync(tmp, "wx", mode);
|
|
254
|
+
try {
|
|
255
|
+
writeFileSync(fd, data);
|
|
256
|
+
closeSync(fd);
|
|
257
|
+
fd = null;
|
|
258
|
+
if (process.platform !== "win32") chmodSync(tmp, mode);
|
|
259
|
+
renameSync(tmp, file);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (fd !== null) closeSync(fd);
|
|
262
|
+
rmSync(tmp, { force: true });
|
|
263
|
+
throw e;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet.
|
|
268
|
+
function backup(file, skill, bytes) {
|
|
269
|
+
const bak = `${file}.${skill}.bak`;
|
|
270
|
+
if (lstatSync(bak, { throwIfNoEntry: false })) return null;
|
|
271
|
+
const fd = openSync(bak, "wx", BACKUP_MODE);
|
|
272
|
+
try {
|
|
273
|
+
writeFileSync(fd, bytes);
|
|
274
|
+
} finally {
|
|
275
|
+
closeSync(fd);
|
|
276
|
+
}
|
|
277
|
+
return bak;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Writes what planBlock worked out. A plan that changes nothing or was refused is returned as a result. */
|
|
281
|
+
export function commitBlock(plan, skill) {
|
|
282
|
+
if (!plan.ok) return plan;
|
|
283
|
+
const result = { ok: true, changed: plan.changed, path: plan.path, file: plan.file };
|
|
284
|
+
if (!plan.changed) return { ...result, message: `변경 없음: ${plan.path}` };
|
|
285
|
+
if (plan.before === null && plan.after !== null) {
|
|
286
|
+
const fd = openSync(plan.file, "wx", plan.mode);
|
|
287
|
+
try {
|
|
288
|
+
writeFileSync(fd, plan.after);
|
|
289
|
+
} finally {
|
|
290
|
+
closeSync(fd);
|
|
291
|
+
}
|
|
292
|
+
return { ...result, created: true, message: `새로 만듦: ${plan.path}` };
|
|
293
|
+
}
|
|
294
|
+
const bak = backup(plan.file, skill, plan.before);
|
|
295
|
+
writeAtomic(plan.file, plan.after, plan.mode);
|
|
296
|
+
return { ...result, backup: bak, message: `바꿈: ${plan.path}` };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Puts in, replaces or (body null) takes out the block in one file. dryRun plans only. */
|
|
300
|
+
export function applyBlock(path, opts) {
|
|
301
|
+
const plan = planBlock(path, opts);
|
|
302
|
+
if (opts.dryRun || !plan.ok) return plan;
|
|
303
|
+
return commitBlock(plan, opts.skill);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Applies blocks to several files, each entry { path, ...opts }. Every file is planned first: one refusal and
|
|
308
|
+
* none is written. Two entries naming one file (after links are followed) write it once, by the first entry.
|
|
309
|
+
*/
|
|
310
|
+
export function applyBlocks(entries, { dryRun = false } = {}) {
|
|
311
|
+
const plans = entries.map((e) => ({ entry: e, plan: planBlock(e.path, e) }));
|
|
312
|
+
const refused = plans.filter((p) => !p.plan.ok).map((p) => p.plan);
|
|
313
|
+
if (refused.length > 0) return { ok: false, refused, results: plans.map((p) => p.plan) };
|
|
314
|
+
const seen = new Map();
|
|
315
|
+
const results = plans.map(({ entry, plan }) => {
|
|
316
|
+
if (seen.has(plan.key)) return { ok: true, changed: false, path: plan.path, file: plan.file, sameAs: seen.get(plan.key), message: `같은 파일이라 한 번만 씀: ${plan.path}` };
|
|
317
|
+
seen.set(plan.key, plan.path);
|
|
318
|
+
return dryRun ? plan : commitBlock(plan, entry.skill);
|
|
319
|
+
});
|
|
320
|
+
return { ok: true, refused: [], results };
|
|
321
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"psmux": "3.3.8",
|
|
3
|
+
"probed": "2026-11-01, Windows 10 22H2 test box, psmux 3.3.8 (winget marlocarlo.psmux)",
|
|
4
|
+
"rule": "A line goes into the block only when verified is true and default is not true (psmux already has it).",
|
|
5
|
+
"behavior": {
|
|
6
|
+
"default": ["마우스(mouse on)", "set-clipboard on", "휠로 copy-mode 진입(scroll-enter-copy-mode on)"],
|
|
7
|
+
"unverified": [
|
|
8
|
+
"드래그한 글이 Windows 클립보드로 가는지(실제 마우스와 데스크톱이 필요해 미실측)",
|
|
9
|
+
"전체 화면 앱(Claude Code, Codex) 안의 PageUp/PageDown 을 앱이 받는지(미실측)"
|
|
10
|
+
],
|
|
11
|
+
"broken": [
|
|
12
|
+
"앱 밖(셸) PageUp 은 prefix 뒤에만 copy-mode 진입. 루트 PageUp 바인딩은 넣지 않음",
|
|
13
|
+
"PageDown 이 바닥에서 copy-mode 를 빠지지 않음(-e 미지원, Escape 로 빠짐)",
|
|
14
|
+
"#{alternate_on} 이 늘 0 이라 전체 화면 앱을 구분하지 못함(앱 구분 바인딩 불가)"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"options": [
|
|
18
|
+
{
|
|
19
|
+
"id": "mouse",
|
|
20
|
+
"line": "set -g mouse on",
|
|
21
|
+
"verified": true,
|
|
22
|
+
"default": true,
|
|
23
|
+
"note": "psmux 기본값이 on. 블록에 넣지 않음"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "set-clipboard",
|
|
27
|
+
"line": "set -s set-clipboard on",
|
|
28
|
+
"verified": true,
|
|
29
|
+
"default": true,
|
|
30
|
+
"note": "psmux 기본값이 on. show -s 는 show -g 와 같음"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "scroll-enter-copy-mode",
|
|
34
|
+
"line": "set -g scroll-enter-copy-mode on",
|
|
35
|
+
"verified": true,
|
|
36
|
+
"default": true,
|
|
37
|
+
"note": "psmux 기본값이 on"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "page-up",
|
|
41
|
+
"line": "bind -n PPage if -F \"#{||:#{alternate_on},#{pane_in_mode}}\" \"send-keys PPage\" \"copy-mode -e -u\"",
|
|
42
|
+
"verified": false,
|
|
43
|
+
"note": "등록, copy-mode 진입, 스크롤은 됨. 그러나 Windows 10 에서 #{alternate_on} 이 늘 0 이라 전체 화면 앱의 PageUp 을 빼앗음. 바닥에서 빠지는 -e 미지원"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "page-up-joined-flags",
|
|
47
|
+
"line": "bind -n PPage copy-mode -eu",
|
|
48
|
+
"verified": false,
|
|
49
|
+
"note": "붙인 짧은 플래그를 풀지 못해 -eu 가 사라짐"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "copy-command",
|
|
53
|
+
"line": "set -s copy-command 'clip.exe'",
|
|
54
|
+
"verified": false,
|
|
55
|
+
"note": "값 저장만 확인. 드래그 복사에 쓰는지 미확인"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "copy-pipe",
|
|
59
|
+
"line": "bind -T copy-mode MouseDragEnd1Pane send -X copy-pipe-and-cancel 'clip.exe'",
|
|
60
|
+
"verified": false,
|
|
61
|
+
"note": "경고 없이 버려짐"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"id": "force-drag",
|
|
65
|
+
"line": "bind -n MouseDrag1Pane if -F \"#{pane_in_mode}\" \"send-keys -M\" \"copy-mode -M\"",
|
|
66
|
+
"verified": false,
|
|
67
|
+
"forceDrag": true,
|
|
68
|
+
"note": "경고 없이 버려짐"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux-config: what tune-tmux knows about tmux and psmux. Tool and version detection, the clipboard command,
|
|
3
|
+
* which config files are read, the block lines, conflicting lines in other files and the commands that take
|
|
4
|
+
* block lines back out of a running server.
|
|
5
|
+
*
|
|
6
|
+
* Every function that runs a program takes `exec(cmd, args)` returning { status, stdout, stderr, error }, so the
|
|
7
|
+
* tests can stand in for tmux on every OS. No network is used.
|
|
8
|
+
*/
|
|
9
|
+
import { accessSync, constants, lstatSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
10
|
+
import { devNull, homedir } from "node:os";
|
|
11
|
+
import { basename, delimiter, join, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
/** The tool and version in `tmux -V` output. psmux says "tmux 3.3.8" on line 1 and "psmux" on line 2. */
|
|
14
|
+
export function parseVersion(text) {
|
|
15
|
+
const out = String(text ?? "");
|
|
16
|
+
const first = out.split(/\r?\n/)[0].trim();
|
|
17
|
+
if (!/^(tmux|psmux)\s/i.test(first)) return null;
|
|
18
|
+
const kind = /psmux/i.test(out) ? "psmux" : "tmux";
|
|
19
|
+
const m = /(\d+)\.(\d+)/.exec(first);
|
|
20
|
+
return m ? { kind, major: Number(m[1]), minor: Number(m[2]), raw: first } : { kind, major: 99, minor: 0, raw: first };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether version v is at least major.minor. */
|
|
24
|
+
export const atLeast = (v, major, minor) => v.major > major || (v.major === major && v.minor >= minor);
|
|
25
|
+
|
|
26
|
+
/** The tool behind bin, or null when it does not run. */
|
|
27
|
+
export function toolVersion(exec, bin) {
|
|
28
|
+
const r = exec(bin, ["-V"]);
|
|
29
|
+
if (r.error || r.status !== 0) return null;
|
|
30
|
+
const v = parseVersion(`${r.stdout ?? ""}\n${r.stderr ?? ""}`);
|
|
31
|
+
return v ? { ...v, bin } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The home folder psmux and tmux use: USERPROFILE first on Windows, HOME elsewhere. */
|
|
35
|
+
export function homeOf(env, platform) {
|
|
36
|
+
const first = platform === "win32" ? env.USERPROFILE || env.HOME : env.HOME;
|
|
37
|
+
return first || homedir();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Whether p is a file this user may run (Windows: any file).
|
|
41
|
+
function runnable(p, platform) {
|
|
42
|
+
try {
|
|
43
|
+
if (!statSync(p).isFile()) return false;
|
|
44
|
+
if (platform !== "win32") accessSync(p, constants.X_OK);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The path of an executable name in env.PATH, or null. */
|
|
52
|
+
export function which(name, env, platform = process.platform) {
|
|
53
|
+
const exts = platform === "win32" ? ["", ...String(env.PATHEXT || ".EXE;.CMD;.BAT").split(";")] : [""];
|
|
54
|
+
for (const dir of String(env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean)) {
|
|
55
|
+
for (const ext of exts) {
|
|
56
|
+
const p = join(dir, name + ext);
|
|
57
|
+
if (runnable(p, platform)) return p;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CLIPBOARDS = [
|
|
64
|
+
{ tool: "pbcopy", cmd: "pbcopy", when: (o) => o.platform === "darwin" },
|
|
65
|
+
{ tool: "wl-copy", cmd: "wl-copy", when: (o) => Boolean(o.env.WAYLAND_DISPLAY) },
|
|
66
|
+
{ tool: "xclip", cmd: "xclip -selection clipboard -in", when: (o) => Boolean(o.env.DISPLAY) },
|
|
67
|
+
{ tool: "xsel", cmd: "xsel -ib", when: (o) => Boolean(o.env.DISPLAY) },
|
|
68
|
+
{ tool: "clip.exe", cmd: "clip.exe", when: (o) => Boolean(o.env.WSL_DISTRO_NAME || o.env.WSL_INTEROP) },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** The OS clipboard command: macOS pbcopy, Wayland wl-copy, X11 xclip then xsel, WSL clip.exe; or null. */
|
|
72
|
+
export function pickClipboard({ platform, env, has }) {
|
|
73
|
+
const hit = CLIPBOARDS.find((c) => c.when({ platform, env }) && has(c.tool));
|
|
74
|
+
return hit ? { tool: hit.tool, cmd: hit.cmd } : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const exists = (p) => lstatSync(p, { throwIfNoEntry: false }) !== undefined;
|
|
78
|
+
|
|
79
|
+
/** The config files tmux reads by itself, in order. Measured: 3.6a reads every one there; 2.7 only ~/.tmux.conf. */
|
|
80
|
+
export function tmuxReads(v, env, home) {
|
|
81
|
+
const files = [join(home, ".tmux.conf")];
|
|
82
|
+
if (v && !atLeast(v, 3, 1)) return files;
|
|
83
|
+
if (env.XDG_CONFIG_HOME) files.push(join(env.XDG_CONFIG_HOME, "tmux", "tmux.conf"));
|
|
84
|
+
files.push(join(home, ".config", "tmux", "tmux.conf"));
|
|
85
|
+
return [...new Set(files.map((f) => resolve(f)))];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The tmux file for the block: --conf, else the first of tmuxReads that is there, else ~/.tmux.conf. */
|
|
89
|
+
export function chooseTmuxConf(v, env, home, conf) {
|
|
90
|
+
if (conf) return resolve(conf);
|
|
91
|
+
return tmuxReads(v, env, home).find(exists) ?? resolve(join(home, ".tmux.conf"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** psmux reads one file: -f, PSMUX_CONFIG_FILE, then the first there of these four (measured on psmux 3.3.8). */
|
|
95
|
+
function psmuxCandidates(home) {
|
|
96
|
+
return [join(home, ".psmux.conf"), join(home, ".psmuxrc"), join(home, ".tmux.conf"), join(home, ".config", "psmux", "psmux.conf")];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function choosePsmuxConf(env, home, conf) {
|
|
100
|
+
if (conf) return resolve(conf);
|
|
101
|
+
if (env.PSMUX_CONFIG_FILE) return resolve(env.PSMUX_CONFIG_FILE);
|
|
102
|
+
return resolve(psmuxCandidates(home).find(exists) ?? join(home, ".psmux.conf"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const PAGE_UP = 'bind -n PPage if -F "#{||:#{alternate_on},#{pane_in_mode}}" "send-keys PPage" "copy-mode -eu"';
|
|
106
|
+
export const FORCE_DRAG = 'bind -n MouseDrag1Pane if -F "#{pane_in_mode}" "send-keys -M" "copy-mode -M"';
|
|
107
|
+
export const HEADER = "# banker tune-tmux: mouse drag copy and PageUp scroll. Remove with: tune-tmux off";
|
|
108
|
+
|
|
109
|
+
// The lines that send a finished mouse selection to the OS clipboard, for this version.
|
|
110
|
+
function copyLines(v, clipboard) {
|
|
111
|
+
if (!clipboard) return { lines: [], omitted: [{ what: "copy-command", reason: "외부 클립보드 도구 없음, 터미널의 OSC 52 허용 필요" }] };
|
|
112
|
+
if (atLeast(v, 3, 2)) return { lines: [`set -s copy-command '${clipboard.cmd}'`], omitted: [] };
|
|
113
|
+
if (atLeast(v, 2, 4)) {
|
|
114
|
+
const pipe = (t) => `bind -T ${t} MouseDragEnd1Pane send -X copy-pipe-and-cancel '${clipboard.cmd}'`;
|
|
115
|
+
return { lines: [pipe("copy-mode"), pipe("copy-mode-vi")], omitted: [] };
|
|
116
|
+
}
|
|
117
|
+
return { lines: [], omitted: [{ what: "copy-pipe", reason: `tmux ${v.major}.${v.minor} 는 send -X 가 없음(2.4 이상 필요)` }] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The tmux block: { lines, omitted }. conditional is whether #{||:} works (probeConditional). */
|
|
121
|
+
export function tmuxBody({ version, clipboard, conditional, forceDrag }) {
|
|
122
|
+
const copy = copyLines(version, clipboard);
|
|
123
|
+
const lines = [HEADER, "set -g mouse on", "set -s set-clipboard on", ...copy.lines];
|
|
124
|
+
const omitted = [...copy.omitted];
|
|
125
|
+
if (conditional) lines.push(PAGE_UP);
|
|
126
|
+
else omitted.push({ what: "PageUp", reason: "이 tmux 는 #{||:} 조건식을 읽지 못함. PageUp 줄을 넣으면 전체 화면 앱의 PageUp 을 빼앗으므로 넣지 않음" });
|
|
127
|
+
if (forceDrag) lines.push(FORCE_DRAG);
|
|
128
|
+
return { lines, omitted };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The psmux block from the caps table: verified lines psmux does not already have. Empty means no block. */
|
|
132
|
+
export function psmuxBody(caps, forceDrag) {
|
|
133
|
+
const wanted = caps.options.filter((o) => !o.forceDrag || forceDrag);
|
|
134
|
+
const lines = wanted.filter((o) => o.verified === true && o.default !== true).map((o) => o.line);
|
|
135
|
+
const omitted = wanted
|
|
136
|
+
.filter((o) => !(o.verified === true && o.default !== true))
|
|
137
|
+
.map((o) => ({ what: o.id, reason: o.default === true ? `psmux 기본값과 같음: ${o.note}` : `psmux ${caps.psmux} 에서 확인 안 됨: ${o.note}` }));
|
|
138
|
+
return { lines: lines.length > 0 ? [HEADER, ...lines] : [], omitted, behavior: psmuxBehavior(caps) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What psmux does with its defaults: { default, unverified, broken } lists of text (empty when caps has none). */
|
|
142
|
+
function psmuxBehavior(caps) {
|
|
143
|
+
const b = caps.behavior ?? {};
|
|
144
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
145
|
+
return { default: list(b.default), unverified: list(b.unverified), broken: list(b.broken) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The socket file of a private server, removed after kill-server (tmux leaves it behind; measured 2.7 and 3.6a).
|
|
149
|
+
function removeSocket(path, sock) {
|
|
150
|
+
if (!path || basename(path) !== sock) return;
|
|
151
|
+
if (lstatSync(path, { throwIfNoEntry: false })?.isSocket()) rmSync(path, { force: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Runs fn(sock) against a private server started with no config, then kills it: { value } or { error }. */
|
|
155
|
+
function withPrivateServer(exec, bin, fn) {
|
|
156
|
+
const sock = `banker-tune-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
157
|
+
const up = exec(bin, ["-L", sock, "-f", devNull, "new-session", "-d", "-s", "probe", "-x", "80", "-y", "24"]);
|
|
158
|
+
if (up.error || up.status !== 0) return { error: `사설 서버를 띄우지 못함: ${firstLine(up)}` };
|
|
159
|
+
const path = String(exec(bin, ["-L", sock, "display-message", "-p", "-t", "probe", "#{socket_path}"]).stdout ?? "").trim();
|
|
160
|
+
try {
|
|
161
|
+
return { value: fn(sock) };
|
|
162
|
+
} finally {
|
|
163
|
+
exec(bin, ["-L", sock, "kill-server"]);
|
|
164
|
+
removeSocket(path, sock);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Whether #{||:} works, asked of a private server: { ok, reason? }. */
|
|
169
|
+
export function probeConditional(exec, bin) {
|
|
170
|
+
const r = withPrivateServer(exec, bin, (sock) => exec(bin, ["-L", sock, "display-message", "-p", "-t", "probe", "#{||:0,1}#{||:0,0}"]));
|
|
171
|
+
if (r.error) return { ok: false, reason: r.error };
|
|
172
|
+
return { ok: r.value.status === 0 && String(r.value.stdout).trim() === "10" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const firstLine = (r) => String(r.error?.message || r.stderr || r.stdout || "").trim().split(/\r?\n/)[0];
|
|
176
|
+
|
|
177
|
+
const CONFLICTS = [
|
|
178
|
+
{ what: "mouse off", test: (t) => isSet(t, "mouse", "off") },
|
|
179
|
+
{ what: "set-clipboard off", test: (t) => isSet(t, "set-clipboard", "off") },
|
|
180
|
+
{ what: "루트 PPage 바인딩", test: (t) => isRootBind(t, "PPage") },
|
|
181
|
+
];
|
|
182
|
+
const isSet = (t, name, value) => ["set", "set-option"].includes(t[0]) && t.indexOf(name) > 0 && t[t.indexOf(name) + 1] === value;
|
|
183
|
+
const isRootBind = (t, key) => ["bind", "bind-key"].includes(t[0]) && (t.includes("-n") || t[t.indexOf("-T") + 1] === "root") && t.includes(key);
|
|
184
|
+
|
|
185
|
+
/** Warnings for lines in other files tmux reads that undo the block (mouse off, set-clipboard off, PPage). */
|
|
186
|
+
export function conflictsIn(files) {
|
|
187
|
+
const warnings = [];
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
let text;
|
|
190
|
+
try {
|
|
191
|
+
if (!statSync(file).isFile() || statSync(file).size > 1024 * 1024) continue;
|
|
192
|
+
text = readFileSync(file, "utf8");
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
197
|
+
const t = line.trim().split(/\s+/);
|
|
198
|
+
for (const c of CONFLICTS) if (c.test(t)) warnings.push(`경고: ${file}:${i + 1} 에 ${c.what}. tmux 가 이 파일도 읽으므로 블록보다 나중에 읽히면 블록 설정을 덮음`);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return warnings;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The body lines of the block in text (marker lines excluded), or [] when there is none. */
|
|
205
|
+
export function blockLinesOf(text, mk) {
|
|
206
|
+
const lines = String(text).replace(/^\u{FEFF}/u, "").split(/\r?\n/);
|
|
207
|
+
const first = lines.findIndex((l) => l.trimEnd() === mk.start);
|
|
208
|
+
const last = lines.findIndex((l) => l.trimEnd() === mk.end);
|
|
209
|
+
return first !== -1 && last > first ? lines.slice(first + 1, last) : [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// What a block line changes: an option ({ scope, name }) or a key ({ table, key }), or null for a comment.
|
|
213
|
+
function effectOf(line) {
|
|
214
|
+
const set = /^set\s+-([gs])\s+(\S+)\s/.exec(line);
|
|
215
|
+
if (set) return { id: `opt:${set[1]}:${set[2]}`, undo: `set -${set[1]}u ${set[2]}` };
|
|
216
|
+
const bind = /^bind\s+(?:-n|-T\s+(\S+))\s+(\S+)\s/.exec(line);
|
|
217
|
+
if (bind) return { id: `key:${bind[1] ?? "root"}:${bind[2]}`, table: bind[1] ?? "root", key: bind[2] };
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The list-keys lines of the keys asked, from tables of a private server.
|
|
222
|
+
function readKeys(exec, bin, sock, keys) {
|
|
223
|
+
const found = new Map(keys.map((k) => [`${k.table} ${k.key}`, null]));
|
|
224
|
+
for (const table of new Set(keys.map((k) => k.table))) {
|
|
225
|
+
for (const line of String(exec(bin, ["-L", sock, "list-keys", "-T", table]).stdout ?? "").split(/\r?\n/)) {
|
|
226
|
+
const m = /^bind-key\s+(?:-r\s+)?-T\s+(\S+)\s+(\S+)\s/.exec(line);
|
|
227
|
+
if (m && found.has(`${m[1]} ${m[2]}`)) found.set(`${m[1]} ${m[2]}`, line.trim());
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return found;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The default bindings of a fresh private server for the keys asked: Map "table key" -> list-keys line or null. */
|
|
234
|
+
export function defaultKeys(exec, bin, keys) {
|
|
235
|
+
if (keys.length === 0) return new Map();
|
|
236
|
+
const r = withPrivateServer(exec, bin, (sock) => readKeys(exec, bin, sock, keys));
|
|
237
|
+
return r.value ?? new Map();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The commands that take the effect of oldLines out of a server when newLines replace them: options are unset,
|
|
242
|
+
* keys get their default binding back (from defaults) or are unbound. Lines also in newLines are left alone.
|
|
243
|
+
*/
|
|
244
|
+
export function undoCommands(oldLines, newLines, defaults) {
|
|
245
|
+
const kept = new Set(newLines.map(effectOf).filter(Boolean).map((e) => e.id));
|
|
246
|
+
const gone = oldLines.map(effectOf).filter((e) => e && !kept.has(e.id));
|
|
247
|
+
return gone.map((e) => e.undo ?? defaults.get(`${e.table} ${e.key}`) ?? `unbind -T ${e.table} ${e.key}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The keys among block lines, for defaultKeys. */
|
|
251
|
+
export const keysOf = (lines) => lines.map(effectOf).filter((e) => e?.key);
|