second-read 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +212 -2
- package/dist/checks.js +325 -0
- package/dist/claude.js +422 -0
- package/dist/cli.js +51 -0
- package/dist/config.js +193 -0
- package/dist/gh.js +179 -0
- package/dist/git.js +531 -0
- package/dist/main.js +433 -0
- package/dist/normalize.js +44 -0
- package/dist/shell.js +396 -0
- package/dist/toml.js +170 -0
- package/package.json +32 -4
package/dist/claude.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// What a Claude Code tool call would post: the text of gh and git commands run through Bash, and the text of a
|
|
2
|
+
// GitHub tool from an MCP server.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { basename, resolve } from "node:path";
|
|
5
|
+
import { KIND_NAMES } from "./checks.js";
|
|
6
|
+
import { parseGh, postTexts } from "./gh.js";
|
|
7
|
+
import { output, parseShell, UNKNOWN, UNKNOWN_END, unknown } from "./shell.js";
|
|
8
|
+
// The texts each command in a Bash tool call would post, read against the folder each one runs in.
|
|
9
|
+
export function bashPosts(command, cwd, home) {
|
|
10
|
+
return scriptPosts(command, cwd, home, new Map());
|
|
11
|
+
}
|
|
12
|
+
function scriptPosts(script, cwd, home, written) {
|
|
13
|
+
const posts = [];
|
|
14
|
+
let dir = cwd;
|
|
15
|
+
for (const command of parseShell(script)) {
|
|
16
|
+
const words = unwrap(command.words);
|
|
17
|
+
const name = basename(words[0] ?? "").replace(/\.exe$/i, "");
|
|
18
|
+
const args = words.slice(1);
|
|
19
|
+
const { stdin } = command;
|
|
20
|
+
if (name === "cd" || name === "pushd") {
|
|
21
|
+
const target = args.find((a) => !a.startsWith("-") || a === "-");
|
|
22
|
+
if (target === undefined)
|
|
23
|
+
dir = home;
|
|
24
|
+
else if (target !== "-" && !target.includes(UNKNOWN))
|
|
25
|
+
dir = resolve(dir, target.replace(/^~(?=\/|$)/, home));
|
|
26
|
+
}
|
|
27
|
+
else if (/^(ba|z|da|k)?sh$/.test(name) && args[0] === "-c" && args[1] !== undefined) {
|
|
28
|
+
if (!args[1].includes(UNKNOWN))
|
|
29
|
+
posts.push(...scriptPosts(args[1], dir, home, written));
|
|
30
|
+
}
|
|
31
|
+
else if (name === "eval") {
|
|
32
|
+
const script = args.join(" ");
|
|
33
|
+
if (!script.includes(UNKNOWN))
|
|
34
|
+
posts.push(...scriptPosts(script, dir, home, written));
|
|
35
|
+
}
|
|
36
|
+
else if (name === "gh") {
|
|
37
|
+
posts.push(...ghPosts(args, stdin, dir, written));
|
|
38
|
+
}
|
|
39
|
+
else if (name === "git") {
|
|
40
|
+
posts.push(...gitPosts(args, stdin, dir, written));
|
|
41
|
+
}
|
|
42
|
+
const printed = output({ ...command, words });
|
|
43
|
+
const files = (command.writes ?? []).map((w) => ({ ...w, by: "this command" }));
|
|
44
|
+
if (name === "tee") {
|
|
45
|
+
const append = args.some((a) => /^-[^-]*a/.test(a) || a === "--append");
|
|
46
|
+
for (const file of args.filter((a) => !a.startsWith("-")))
|
|
47
|
+
files.push({ file, append, by: "tee" });
|
|
48
|
+
}
|
|
49
|
+
for (const { file, append } of files) {
|
|
50
|
+
if (file.includes(UNKNOWN))
|
|
51
|
+
continue;
|
|
52
|
+
const path = resolve(dir, file);
|
|
53
|
+
written.set(path, printed !== undefined && !append ? printed : unknown(`what ${words.join(" ")} writes to ${file}`));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return posts;
|
|
57
|
+
}
|
|
58
|
+
// The command itself, past variable assignments and commands that run the one after them.
|
|
59
|
+
function unwrap(words) {
|
|
60
|
+
let i = 0;
|
|
61
|
+
for (;;) {
|
|
62
|
+
while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))
|
|
63
|
+
i++;
|
|
64
|
+
const name = words[i];
|
|
65
|
+
if (name === "env") {
|
|
66
|
+
for (i++; i < words.length && (words[i].startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])); i++) {
|
|
67
|
+
if (/^(-u|--unset|-C|--chdir)$/.test(words[i]))
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else if (name === "command" || name === "exec" || name === "nohup" || name === "time" || name === "builtin") {
|
|
72
|
+
i++;
|
|
73
|
+
while (i < words.length && words[i].startsWith("-"))
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
else if (name === "sudo") {
|
|
77
|
+
i = pastOptions(words, i + 1, "ugChpUTrtDR");
|
|
78
|
+
}
|
|
79
|
+
else if (name === "xargs") {
|
|
80
|
+
// xargs adds arguments from its input, so the command's text is filled in only when it runs.
|
|
81
|
+
let replace;
|
|
82
|
+
i = pastOptions(words, i + 1, "adEILnPs", "eil", (flag, value) => {
|
|
83
|
+
if (flag === "I")
|
|
84
|
+
replace = value;
|
|
85
|
+
if (flag === "i" || flag === "--replace")
|
|
86
|
+
replace = value || "{}";
|
|
87
|
+
});
|
|
88
|
+
const added = unknown("the arguments xargs adds");
|
|
89
|
+
const rest = words.slice(i);
|
|
90
|
+
if (replace === undefined)
|
|
91
|
+
return [...rest, added];
|
|
92
|
+
return rest.map((w) => (w.includes(replace) ? w.split(replace).join(added) : w));
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
return words.slice(i);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// The index past a command's options, as in `sudo -u someone`. `valued` names the short options that take a value,
|
|
100
|
+
// and `joined` those that take one only when it's joined on, as in xargs -i{}.
|
|
101
|
+
function pastOptions(words, start, valued, joined = "", seen = () => { }) {
|
|
102
|
+
let i = start;
|
|
103
|
+
for (; i < words.length && words[i].startsWith("-"); i++) {
|
|
104
|
+
const word = words[i];
|
|
105
|
+
if (word === "--")
|
|
106
|
+
return i + 1;
|
|
107
|
+
if (word.startsWith("--")) {
|
|
108
|
+
const eq = word.indexOf("=");
|
|
109
|
+
seen(eq < 0 ? word : word.slice(0, eq), eq < 0 ? "" : word.slice(eq + 1));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
for (let j = 1; j < word.length; j++) {
|
|
113
|
+
const flag = word[j];
|
|
114
|
+
if (!valued.includes(flag)) {
|
|
115
|
+
if (joined.includes(flag) && j + 1 < word.length) {
|
|
116
|
+
seen(flag, word.slice(j + 1));
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
seen(flag, "");
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const value = j + 1 < word.length ? word.slice(j + 1) : (words[++i] ?? "");
|
|
123
|
+
seen(flag, value);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return i;
|
|
128
|
+
}
|
|
129
|
+
// A file's text, or stdin's for "-". A name the shell fills in is kept as the text, so it's refused unread.
|
|
130
|
+
function reader(stdin, dir, written) {
|
|
131
|
+
const load = (file) => {
|
|
132
|
+
if (file.includes(UNKNOWN))
|
|
133
|
+
return file;
|
|
134
|
+
const path = resolve(dir, file);
|
|
135
|
+
return written.get(path) ?? readFileSync(path, "utf8");
|
|
136
|
+
};
|
|
137
|
+
return (file) => {
|
|
138
|
+
if (file !== "-")
|
|
139
|
+
return load(file);
|
|
140
|
+
if (stdin === undefined)
|
|
141
|
+
return "";
|
|
142
|
+
return "text" in stdin ? stdin.text : load(stdin.file);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function ghPosts(args, stdin, dir, written) {
|
|
146
|
+
if (args[0] === "api")
|
|
147
|
+
return ghApiPosts(args.slice(1), reader(stdin, dir, written), dir);
|
|
148
|
+
const post = parseGh(args);
|
|
149
|
+
if (!post)
|
|
150
|
+
return [];
|
|
151
|
+
return postTexts(post, reader(stdin, dir, written)).map(([where, text]) => ({ where, kind: post.kind, text, cwd: dir }));
|
|
152
|
+
}
|
|
153
|
+
const GH_API_VALUE_FLAGS = new Set(["-X", "--method", "-f", "--raw-field", "-F", "--field", "-H", "--header", "-q",
|
|
154
|
+
"--jq", "-t", "--template", "-p", "--preview", "--hostname", "--cache", "--input"]);
|
|
155
|
+
// gh api sends its fields, or the --input file, and switches to POST when it has either. A GraphQL query that
|
|
156
|
+
// isn't a mutation only reads.
|
|
157
|
+
function ghApiPosts(args, read, dir) {
|
|
158
|
+
let endpoint;
|
|
159
|
+
let method;
|
|
160
|
+
let input;
|
|
161
|
+
const fields = [];
|
|
162
|
+
for (let i = 0; i < args.length; i++) {
|
|
163
|
+
const arg = args[i];
|
|
164
|
+
let name;
|
|
165
|
+
let value;
|
|
166
|
+
if (arg.startsWith("--")) {
|
|
167
|
+
const eq = arg.indexOf("=");
|
|
168
|
+
name = eq < 0 ? arg : arg.slice(0, eq);
|
|
169
|
+
if (eq >= 0)
|
|
170
|
+
value = arg.slice(eq + 1);
|
|
171
|
+
}
|
|
172
|
+
else if (arg.startsWith("-") && arg.length > 1) {
|
|
173
|
+
name = arg.slice(0, 2);
|
|
174
|
+
if (arg.length > 2)
|
|
175
|
+
value = arg.slice(arg[2] === "=" ? 3 : 2);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
endpoint ??= arg;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!GH_API_VALUE_FLAGS.has(name))
|
|
182
|
+
continue;
|
|
183
|
+
if (value === undefined)
|
|
184
|
+
value = args[++i];
|
|
185
|
+
if (value === undefined)
|
|
186
|
+
continue;
|
|
187
|
+
if (name === "-X" || name === "--method")
|
|
188
|
+
method = value.toUpperCase();
|
|
189
|
+
else if (name === "--input")
|
|
190
|
+
input = value;
|
|
191
|
+
else if (name === "-f" || name === "--raw-field")
|
|
192
|
+
fields.push({ field: value, typed: false });
|
|
193
|
+
else if (name === "-F" || name === "--field")
|
|
194
|
+
fields.push({ field: value, typed: true });
|
|
195
|
+
}
|
|
196
|
+
method ??= fields.length > 0 || input !== undefined ? "POST" : "GET";
|
|
197
|
+
if (method === "GET" || method === "HEAD")
|
|
198
|
+
return [];
|
|
199
|
+
const target = `gh api ${endpoint ?? ""}`.trim();
|
|
200
|
+
const kind = apiKind(endpoint ?? "");
|
|
201
|
+
const texts = [];
|
|
202
|
+
for (const { field, typed } of fields) {
|
|
203
|
+
const eq = field.indexOf("=");
|
|
204
|
+
const key = eq < 0 ? field : field.slice(0, eq);
|
|
205
|
+
if (eq < 0 || DESTINATION_KEYS.has(key))
|
|
206
|
+
continue;
|
|
207
|
+
const value = field.slice(eq + 1);
|
|
208
|
+
texts.push([`the ${key} field of ${target}`, typed && value.startsWith("@") ? read(value.slice(1)) : value]);
|
|
209
|
+
}
|
|
210
|
+
if (endpoint === "graphql") {
|
|
211
|
+
const query = texts.find(([where]) => where.startsWith("the query field"))?.[1];
|
|
212
|
+
if (query !== undefined && !query.includes(UNKNOWN) && !/\bmutation\b/.test(query))
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
if (input !== undefined) {
|
|
216
|
+
const text = read(input);
|
|
217
|
+
const where = input === "-" ? `the body of ${target}` : `the body of ${target}, from ${input}`;
|
|
218
|
+
let json;
|
|
219
|
+
try {
|
|
220
|
+
json = JSON.parse(text);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
texts.push([where, text]);
|
|
224
|
+
}
|
|
225
|
+
if (json !== undefined)
|
|
226
|
+
for (const [path, leaf] of leaves(json))
|
|
227
|
+
texts.push([`${path} in ${where}`, leaf]);
|
|
228
|
+
}
|
|
229
|
+
return texts.map(([where, text]) => ({ where, kind, text, cwd: dir }));
|
|
230
|
+
}
|
|
231
|
+
function apiKind(endpoint) {
|
|
232
|
+
if (/comments/.test(endpoint))
|
|
233
|
+
return "comment";
|
|
234
|
+
if (/reviews/.test(endpoint))
|
|
235
|
+
return "review";
|
|
236
|
+
if (/pulls/.test(endpoint))
|
|
237
|
+
return "pr";
|
|
238
|
+
if (/issues/.test(endpoint))
|
|
239
|
+
return "issue";
|
|
240
|
+
if (/commits|git\/|contents/.test(endpoint))
|
|
241
|
+
return "commit";
|
|
242
|
+
return "comment";
|
|
243
|
+
}
|
|
244
|
+
const COMMIT = {
|
|
245
|
+
where: KIND_NAMES.commit,
|
|
246
|
+
short: "mFCct",
|
|
247
|
+
joined: "Su",
|
|
248
|
+
long: {
|
|
249
|
+
"--message": "message",
|
|
250
|
+
"--file": "file",
|
|
251
|
+
"--trailer": "trailer",
|
|
252
|
+
"--author": "value",
|
|
253
|
+
"--date": "value",
|
|
254
|
+
"--reuse-message": "value",
|
|
255
|
+
"--reedit-message": "value",
|
|
256
|
+
"--fixup": "value",
|
|
257
|
+
"--squash": "value",
|
|
258
|
+
"--template": "value",
|
|
259
|
+
"--cleanup": "value",
|
|
260
|
+
"--pathspec-from-file": "value",
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
const TAG = {
|
|
264
|
+
where: "this tag message",
|
|
265
|
+
short: "mFu",
|
|
266
|
+
joined: "n",
|
|
267
|
+
long: {
|
|
268
|
+
"--message": "message",
|
|
269
|
+
"--file": "file",
|
|
270
|
+
"--trailer": "trailer",
|
|
271
|
+
"--local-user": "value",
|
|
272
|
+
"--cleanup": "value",
|
|
273
|
+
"--sort": "value",
|
|
274
|
+
"--format": "value",
|
|
275
|
+
"--contains": "value",
|
|
276
|
+
"--no-contains": "value",
|
|
277
|
+
"--merged": "value",
|
|
278
|
+
"--no-merged": "value",
|
|
279
|
+
"--points-at": "value",
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
const GIT_VALUE_OPTIONS = new Set(["-c", "--git-dir", "--work-tree", "--namespace", "--config-env", "--super-prefix"]);
|
|
283
|
+
// The messages git commit and git tag are given. A push is the pre-push hook's to check, since only git knows
|
|
284
|
+
// what it sends.
|
|
285
|
+
function gitPosts(args, stdin, cwd, written) {
|
|
286
|
+
let dir = cwd;
|
|
287
|
+
let i = 0;
|
|
288
|
+
for (; i < args.length; i++) {
|
|
289
|
+
const arg = args[i];
|
|
290
|
+
if (arg === "-C") {
|
|
291
|
+
const to = args[++i];
|
|
292
|
+
if (to !== undefined && !to.includes(UNKNOWN))
|
|
293
|
+
dir = resolve(dir, to);
|
|
294
|
+
}
|
|
295
|
+
else if (GIT_VALUE_OPTIONS.has(arg))
|
|
296
|
+
i++;
|
|
297
|
+
else if (!arg.startsWith("-"))
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
const command = args[i];
|
|
301
|
+
const options = command === "commit" ? COMMIT : command === "tag" ? TAG : undefined;
|
|
302
|
+
if (options === undefined)
|
|
303
|
+
return [];
|
|
304
|
+
return messages(args.slice(i + 1), options, reader(stdin, dir, written)).map(([where, text]) => ({
|
|
305
|
+
where,
|
|
306
|
+
kind: "commit",
|
|
307
|
+
text,
|
|
308
|
+
cwd: dir,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
// Each -m is a paragraph of the one message.
|
|
312
|
+
function messages(args, options, read) {
|
|
313
|
+
const paragraphs = [];
|
|
314
|
+
const texts = [];
|
|
315
|
+
const take = (kind, value) => {
|
|
316
|
+
if (kind === "message")
|
|
317
|
+
paragraphs.push(value);
|
|
318
|
+
else if (kind === "trailer")
|
|
319
|
+
texts.push([`a trailer of ${options.where}`, value]);
|
|
320
|
+
else if (kind === "file")
|
|
321
|
+
texts.push([value === "-" ? options.where : `${options.where}, from ${value}`, read(value)]);
|
|
322
|
+
};
|
|
323
|
+
for (let i = 0; i < args.length; i++) {
|
|
324
|
+
const arg = args[i];
|
|
325
|
+
if (arg === "--")
|
|
326
|
+
break;
|
|
327
|
+
if (arg.startsWith("--")) {
|
|
328
|
+
const eq = arg.indexOf("=");
|
|
329
|
+
const kind = options.long[eq < 0 ? arg : arg.slice(0, eq)];
|
|
330
|
+
if (kind === undefined)
|
|
331
|
+
continue;
|
|
332
|
+
const value = eq < 0 ? args[++i] : arg.slice(eq + 1);
|
|
333
|
+
if (value !== undefined)
|
|
334
|
+
take(kind, value);
|
|
335
|
+
}
|
|
336
|
+
else if (arg.startsWith("-")) {
|
|
337
|
+
// Short options cluster, as in -am "message".
|
|
338
|
+
for (let j = 1; j < arg.length; j++) {
|
|
339
|
+
const flag = arg[j];
|
|
340
|
+
if (options.joined.includes(flag))
|
|
341
|
+
break;
|
|
342
|
+
if (!options.short.includes(flag))
|
|
343
|
+
continue;
|
|
344
|
+
const value = j + 1 < arg.length ? arg.slice(j + 1) : args[++i];
|
|
345
|
+
if (value !== undefined)
|
|
346
|
+
take(flag === "m" ? "message" : flag === "F" ? "file" : "value", value);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (paragraphs.length > 0)
|
|
352
|
+
texts.unshift([options.where, paragraphs.join("\n\n")]);
|
|
353
|
+
return texts;
|
|
354
|
+
}
|
|
355
|
+
// Keys that name where a post goes, such as the account a repository belongs to, not what it says.
|
|
356
|
+
const DESTINATION_KEYS = new Set(["owner", "repo"]);
|
|
357
|
+
// An MCP tool whose server or name says GitHub, in any case, including one a plugin bundles.
|
|
358
|
+
export function isGithubTool(name) {
|
|
359
|
+
return name.startsWith("mcp__") && /github/i.test(name);
|
|
360
|
+
}
|
|
361
|
+
// Every string a GitHub tool is given, but for tools that only read. A file's contents are checked as the lines a
|
|
362
|
+
// push would add to it.
|
|
363
|
+
export function mcpPosts(toolName, input, cwd) {
|
|
364
|
+
const tool = toolName.slice(toolName.lastIndexOf("__") + 2);
|
|
365
|
+
if (/^(get|list|search|read)_|_read$/.test(tool))
|
|
366
|
+
return [];
|
|
367
|
+
const kind = toolKind(tool);
|
|
368
|
+
const posts = [];
|
|
369
|
+
const walk = (value, path) => {
|
|
370
|
+
if (typeof value === "string") {
|
|
371
|
+
posts.push({ where: `${path || "the input"} of ${tool}`, kind, text: value, cwd });
|
|
372
|
+
}
|
|
373
|
+
else if (Array.isArray(value)) {
|
|
374
|
+
value.forEach((v, i) => walk(v, `${path}[${i}]`));
|
|
375
|
+
}
|
|
376
|
+
else if (value !== null && typeof value === "object") {
|
|
377
|
+
const entries = Object.entries(value);
|
|
378
|
+
const record = value;
|
|
379
|
+
const isFile = typeof record.path === "string" && typeof record.content === "string";
|
|
380
|
+
if (isFile) {
|
|
381
|
+
const file = record.path;
|
|
382
|
+
posts.push({ where: `${file}, as ${tool} writes it`, kind: "commit", text: record.content, cwd, file });
|
|
383
|
+
}
|
|
384
|
+
for (const [key, v] of entries) {
|
|
385
|
+
if (DESTINATION_KEYS.has(key) || (isFile && key === "content"))
|
|
386
|
+
continue;
|
|
387
|
+
walk(v, path ? `${path}.${key}` : key);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
walk(input, "");
|
|
392
|
+
return posts;
|
|
393
|
+
}
|
|
394
|
+
function toolKind(tool) {
|
|
395
|
+
if (/comment/.test(tool))
|
|
396
|
+
return "comment";
|
|
397
|
+
if (/review/.test(tool))
|
|
398
|
+
return "review";
|
|
399
|
+
if (/pull/.test(tool))
|
|
400
|
+
return "pr";
|
|
401
|
+
if (/issue/.test(tool))
|
|
402
|
+
return "issue";
|
|
403
|
+
if (/commit|file|push|branch|tag/.test(tool))
|
|
404
|
+
return "commit";
|
|
405
|
+
return "comment";
|
|
406
|
+
}
|
|
407
|
+
// The JSON string values in a request body, each with its path.
|
|
408
|
+
function leaves(value, path = "") {
|
|
409
|
+
if (typeof value === "string")
|
|
410
|
+
return [[path || "the text", value]];
|
|
411
|
+
if (Array.isArray(value))
|
|
412
|
+
return value.flatMap((v, i) => leaves(v, `${path}[${i}]`));
|
|
413
|
+
if (value === null || typeof value !== "object")
|
|
414
|
+
return [];
|
|
415
|
+
return Object.entries(value).flatMap(([key, v]) => DESTINATION_KEYS.has(key) ? [] : leaves(v, path ? `${path}.${key}` : key));
|
|
416
|
+
}
|
|
417
|
+
// The shell source of the first thing in a text that the shell fills in.
|
|
418
|
+
export function firstUnknown(text) {
|
|
419
|
+
const start = text.indexOf(UNKNOWN);
|
|
420
|
+
const end = text.indexOf(UNKNOWN_END, start);
|
|
421
|
+
return text.slice(start + 1, end < 0 ? undefined : end);
|
|
422
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { homedir, hostname, tmpdir, userInfo } from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { main } from "./main.js";
|
|
7
|
+
function systemIdentity() {
|
|
8
|
+
let user;
|
|
9
|
+
try {
|
|
10
|
+
user = userInfo().username;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
user = process.env.USER ?? process.env.USERNAME ?? "";
|
|
14
|
+
}
|
|
15
|
+
const tempDirs = [tmpdir()];
|
|
16
|
+
try {
|
|
17
|
+
tempDirs.push(realpathSync(tempDirs[0]));
|
|
18
|
+
}
|
|
19
|
+
catch { }
|
|
20
|
+
return { user, home: homedir(), host: hostname(), tempDirs, emails: gitEmails() };
|
|
21
|
+
}
|
|
22
|
+
// Every user.email git uses here, from each config file it reads: one set in a repository doesn't hide the global
|
|
23
|
+
// one. An address an includeIf sets for another folder isn't read.
|
|
24
|
+
// No git, or no address, means none to check.
|
|
25
|
+
function gitEmails() {
|
|
26
|
+
const result = spawnSync("git", ["config", "--get-all", "user.email"], { encoding: "utf8" });
|
|
27
|
+
if (result.error?.code === "ENOENT" || result.status === 1)
|
|
28
|
+
return [];
|
|
29
|
+
if (result.error)
|
|
30
|
+
throw new Error(`can't run git: ${result.error.message}`);
|
|
31
|
+
if (result.status !== 0)
|
|
32
|
+
throw new Error(`git config failed: ${result.stderr.trim()}`);
|
|
33
|
+
return result.stdout.split("\n").filter((e) => e.trim() !== "");
|
|
34
|
+
}
|
|
35
|
+
async function readStdin() {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
for await (const chunk of process.stdin)
|
|
38
|
+
chunks.push(chunk);
|
|
39
|
+
return Buffer.concat(chunks);
|
|
40
|
+
}
|
|
41
|
+
process.exitCode = await main({
|
|
42
|
+
argv: process.argv.slice(2),
|
|
43
|
+
env: process.env,
|
|
44
|
+
platform: process.platform,
|
|
45
|
+
cwd: process.cwd(),
|
|
46
|
+
identity: systemIdentity,
|
|
47
|
+
command: [process.execPath, fileURLToPath(import.meta.url)],
|
|
48
|
+
readStdin,
|
|
49
|
+
out: (text) => process.stdout.write(text),
|
|
50
|
+
err: (text) => process.stderr.write(text),
|
|
51
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { isBlanketEmail, isBlanketPath } from "./checks.js";
|
|
4
|
+
import { escapeRegExp, fold, termPattern } from "./normalize.js";
|
|
5
|
+
import { parseToml } from "./toml.js";
|
|
6
|
+
export const USER_FILE = "config.toml";
|
|
7
|
+
export const REPO_FILE = ".second-read.toml";
|
|
8
|
+
export class ConfigError extends Error {
|
|
9
|
+
}
|
|
10
|
+
const ALLOW_KEYS = { allowed_words: "allowedWords", allowed_emails: "allowedEmails", allowed_paths: "allowedPaths" };
|
|
11
|
+
// The platform's config folder. SECOND_READ_CONFIG_DIR overrides it, which is how tests keep away from the real one.
|
|
12
|
+
export function configDir(env, platform, home) {
|
|
13
|
+
if (env.SECOND_READ_CONFIG_DIR)
|
|
14
|
+
return env.SECOND_READ_CONFIG_DIR;
|
|
15
|
+
if (platform === "win32")
|
|
16
|
+
return join(env.APPDATA || join(home, "AppData", "Roaming"), "second-read");
|
|
17
|
+
return join(env.XDG_CONFIG_HOME || join(home, ".config"), "second-read");
|
|
18
|
+
}
|
|
19
|
+
// A missing user file means no private terms yet, not a failure: the other checks still run.
|
|
20
|
+
export function loadUserConfig(path) {
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return { terms: [], allowedWords: [], allowedEmails: [], allowedPaths: [], allowedNames: [] };
|
|
23
|
+
const values = parseFile(path);
|
|
24
|
+
return {
|
|
25
|
+
terms: stringList(values, "terms", path),
|
|
26
|
+
...allowLists(values, path, ["terms", "allowed_names"]),
|
|
27
|
+
allowedNames: stringList(values, "allowed_names", path),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// A repository's file holds only an allowlist. It's checked in, so a private term in it would be posted.
|
|
31
|
+
export function loadRepoConfig(path, terms) {
|
|
32
|
+
const source = readText(path);
|
|
33
|
+
const values = parseText(source, path);
|
|
34
|
+
if (Object.hasOwn(values, "terms")) {
|
|
35
|
+
throw new ConfigError(`${path}: private terms belong in your own config file, never in a repository`);
|
|
36
|
+
}
|
|
37
|
+
if (Object.hasOwn(values, "allowed_names")) {
|
|
38
|
+
throw new ConfigError(`${path}: allowed_names belongs in your own config file, since naming your account or machine in a ` +
|
|
39
|
+
`repository posts it`);
|
|
40
|
+
}
|
|
41
|
+
const folded = fold(source).text;
|
|
42
|
+
for (const term of terms) {
|
|
43
|
+
const match = termPattern(term)?.exec(folded);
|
|
44
|
+
if (match) {
|
|
45
|
+
const line = folded.slice(0, match.index).split("\n").length;
|
|
46
|
+
throw new ConfigError(`${path}: line ${line} names one of your private terms; take it out of the repository`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Anyone who can change the repository can change this file, so it can't switch a whole check off.
|
|
50
|
+
// The user's own file can, since it never leaves the machine.
|
|
51
|
+
const lists = allowLists(values, path, ["exempt_files"]);
|
|
52
|
+
for (const entry of lists.allowedPaths) {
|
|
53
|
+
if (isBlanketPath(entry)) {
|
|
54
|
+
throw new ConfigError(`${path}: allowed_paths entry ${JSON.stringify(entry)} takes in the whole home folder or a temp folder; ` +
|
|
55
|
+
`a repository can allow only narrower paths`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const entry of lists.allowedEmails) {
|
|
59
|
+
if (isBlanketEmail(entry)) {
|
|
60
|
+
throw new ConfigError(`${path}: allowed_emails entry ${JSON.stringify(entry)} has no fixed domain; ` +
|
|
61
|
+
`a repository has to name one, as in "*@example.com"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const exemptFiles = stringList(values, "exempt_files", path);
|
|
65
|
+
for (const entry of exemptFiles) {
|
|
66
|
+
if (isBlanketGlob(entry)) {
|
|
67
|
+
throw new ConfigError(`${path}: exempt_files entry ${JSON.stringify(entry)} is made only of wildcards, so it takes in nearly ` +
|
|
68
|
+
`every file; a repository can exempt only narrower globs, as in "src/**/*.test.ts"`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { ...lists, exemptFiles };
|
|
72
|
+
}
|
|
73
|
+
// A glob over paths from the top of the repository. "*" and "?" stay within one folder, "**" takes in any number of
|
|
74
|
+
// folders, and a glob ending in "/" takes in everything under that folder. Case counts, as it does in git.
|
|
75
|
+
export function exemptMatcher(globs) {
|
|
76
|
+
if (globs.length === 0)
|
|
77
|
+
return () => false;
|
|
78
|
+
const patterns = globs.map((glob) => {
|
|
79
|
+
const segments = globSegments(glob);
|
|
80
|
+
let source = "";
|
|
81
|
+
segments.forEach((segment, i) => {
|
|
82
|
+
const last = i === segments.length - 1;
|
|
83
|
+
if (segment === "**")
|
|
84
|
+
source += last ? ".*" : "(?:[^/]*/)*";
|
|
85
|
+
else {
|
|
86
|
+
source += segment.split("").map((c) => (c === "*" ? "[^/]*" : c === "?" ? "[^/]" : escapeRegExp(c))).join("");
|
|
87
|
+
if (!last)
|
|
88
|
+
source += "/";
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return new RegExp(`^${source}$`, "u");
|
|
92
|
+
});
|
|
93
|
+
return (file) => patterns.some((re) => re.test(file));
|
|
94
|
+
}
|
|
95
|
+
// A glob made only of wildcards and dots, such as "**", "**/?*" or "**/*.*", which leaves pre-push next to no added
|
|
96
|
+
// lines to check. A folder of only "?" takes in names of that length, and one that starts with a dot only dotfiles,
|
|
97
|
+
// so those are narrower.
|
|
98
|
+
export function isBlanketGlob(glob) {
|
|
99
|
+
return globSegments(glob).every((segment) => /^[*?][*?.]*$/.test(segment) && segment.includes("*"));
|
|
100
|
+
}
|
|
101
|
+
function globSegments(glob) {
|
|
102
|
+
const trimmed = glob.replace(/^(?:\.?\/)+/, "");
|
|
103
|
+
return (trimmed === "" || trimmed.endsWith("/") ? trimmed + "**" : trimmed).split("/");
|
|
104
|
+
}
|
|
105
|
+
// The nearest .second-read.toml at or above `cwd`, up to the top of the git repository. Outside a repository
|
|
106
|
+
// there is none, so a stray file in a parent folder is never picked up.
|
|
107
|
+
export function findRepoConfig(cwd) {
|
|
108
|
+
const found = [];
|
|
109
|
+
let dir = cwd;
|
|
110
|
+
for (;;) {
|
|
111
|
+
const candidate = join(dir, REPO_FILE);
|
|
112
|
+
if (existsSync(candidate))
|
|
113
|
+
found.push(candidate);
|
|
114
|
+
if (existsSync(join(dir, ".git")))
|
|
115
|
+
return found[0];
|
|
116
|
+
const parent = dirname(dir);
|
|
117
|
+
if (parent === dir)
|
|
118
|
+
return undefined;
|
|
119
|
+
dir = parent;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function mergeRules(user, repo) {
|
|
123
|
+
return {
|
|
124
|
+
terms: user.terms,
|
|
125
|
+
allowedWords: [...user.allowedWords, ...(repo?.allowedWords ?? [])],
|
|
126
|
+
allowedEmails: [...user.allowedEmails, ...(repo?.allowedEmails ?? [])],
|
|
127
|
+
allowedPaths: [...user.allowedPaths, ...(repo?.allowedPaths ?? [])],
|
|
128
|
+
allowedNames: user.allowedNames,
|
|
129
|
+
userAllowedEmails: user.allowedEmails,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export const USER_FILE_TEMPLATE = `# second-read: your private terms and allowlists.
|
|
133
|
+
# This file stays on this machine. Never check it in, and never copy its terms into a repository.
|
|
134
|
+
|
|
135
|
+
# Private terms: project, folder, character or client names that must never be posted.
|
|
136
|
+
# Each is matched in any case, at word boundaries, with its words joined by spaces, hyphens or nothing:
|
|
137
|
+
# "Example Project" also catches "example-project" and "ExampleProject".
|
|
138
|
+
terms = [
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
# Money and account words that are fine to post everywhere, as a report names them (such as "billing").
|
|
142
|
+
allowed_words = []
|
|
143
|
+
|
|
144
|
+
# Your account or machine name, when it's an ordinary word that's fine to post, so it isn't flagged anywhere.
|
|
145
|
+
# Common default machine names, such as "MacBook-Pro", are never flagged. Paths with your account name still are.
|
|
146
|
+
allowed_names = []
|
|
147
|
+
|
|
148
|
+
# Email addresses that are fine to post. "*" matches any run of characters, as in "*@example.com".
|
|
149
|
+
# Your own address, git's user.email, is allowed only when it's written out here in full.
|
|
150
|
+
allowed_emails = []
|
|
151
|
+
|
|
152
|
+
# Paths that are fine to post, such as "~/.config/some-tool".
|
|
153
|
+
allowed_paths = []
|
|
154
|
+
`;
|
|
155
|
+
function allowLists(values, path, extra) {
|
|
156
|
+
for (const key of Object.keys(values)) {
|
|
157
|
+
if (!Object.hasOwn(ALLOW_KEYS, key) && !extra.includes(key)) {
|
|
158
|
+
throw new ConfigError(`${path}: unknown key "${key}"`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
allowedWords: stringList(values, "allowed_words", path),
|
|
163
|
+
allowedEmails: stringList(values, "allowed_emails", path),
|
|
164
|
+
allowedPaths: stringList(values, "allowed_paths", path),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function stringList(values, key, path) {
|
|
168
|
+
const value = values[key];
|
|
169
|
+
if (value === undefined)
|
|
170
|
+
return [];
|
|
171
|
+
if (!Array.isArray(value))
|
|
172
|
+
throw new ConfigError(`${path}: "${key}" must be an array of strings`);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
function parseFile(path) {
|
|
176
|
+
return parseText(readText(path), path);
|
|
177
|
+
}
|
|
178
|
+
function parseText(source, path) {
|
|
179
|
+
try {
|
|
180
|
+
return parseToml(source);
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
throw new ConfigError(`${path}: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function readText(path) {
|
|
187
|
+
try {
|
|
188
|
+
return readFileSync(path, "utf8");
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
throw new ConfigError(`can't read ${path}: ${e.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|