@tenyokjs/devdevian-cli 0.2.0-alpha.1 → 0.3.0-alpha.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.
@@ -0,0 +1,386 @@
1
+ import { constants } from "node:fs";
2
+ import { readdir, realpath, lstat, open } from "node:fs/promises";
3
+ import { resolve, relative, join, isAbsolute, sep } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ const skipped = new Set([
7
+ ".git",
8
+ "node_modules",
9
+ "dist",
10
+ "build",
11
+ "coverage",
12
+ ".next",
13
+ ".nuxt",
14
+ ".cache",
15
+ ".turbo",
16
+ "target",
17
+ "vendor",
18
+ ]);
19
+ export const sensitivePath = (path) =>
20
+ path
21
+ .split(/[\\/]/)
22
+ .some(
23
+ (name) =>
24
+ [".git", ".ssh", ".aws", ".gnupg", ".envrc"].includes(name.toLowerCase()) ||
25
+ /^\.env(?:\.|$)/i.test(name) ||
26
+ /\.(?:pem|key|p12|pfx|keystore)$/i.test(name) ||
27
+ /^(?:id_(?:rsa|ed25519|ecdsa|dsa)(?:\.pub)?|credentials(?:\.json)?|\.npmrc|\.netrc|\.git-credentials)$/i.test(
28
+ name,
29
+ ),
30
+ );
31
+ const hash = (value) => createHash("sha256").update(value).digest("hex");
32
+ export async function workspaceRoot(path = process.cwd()) {
33
+ const root = await realpath(resolve(path));
34
+ if (!(await lstat(root)).isDirectory())
35
+ throw Error("Choose a project directory.");
36
+ return root;
37
+ }
38
+ export async function workspacePath(root, path = ".") {
39
+ const candidate = resolve(root, path),
40
+ rel = relative(root, candidate);
41
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel))
42
+ throw Error("Stay inside the selected project directory.");
43
+ // Refuse symlinks at every segment, including links pointing back into the project.
44
+ let current = root;
45
+ for (const part of rel.split(sep).filter(Boolean)) {
46
+ current = join(current, part);
47
+ if ((await lstat(current)).isSymbolicLink())
48
+ throw Error("Symbolic links are not opened by the file picker.");
49
+ }
50
+ if (sensitivePath(rel))
51
+ throw Error(
52
+ "Environment files and credential/key files are excluded from code selection.",
53
+ );
54
+ return candidate;
55
+ }
56
+ async function git(root, args, { input, signal, max = 1024 * 1024 } = {}) {
57
+ return new Promise((resolvePromise, reject) => {
58
+ const child = spawn(
59
+ "git",
60
+ [
61
+ "--no-pager",
62
+ ...(args[0] === "diff" ? ["--literal-pathspecs"] : []),
63
+ "-c",
64
+ "core.fsmonitor=false",
65
+ "-c",
66
+ "core.quotepath=false",
67
+ "-C",
68
+ root,
69
+ ...args,
70
+ ],
71
+ {
72
+ shell: false,
73
+ stdio: ["pipe", "pipe", "pipe"],
74
+ env: {
75
+ ...process.env,
76
+ GIT_TERMINAL_PROMPT: "0",
77
+ GIT_OPTIONAL_LOCKS: "0",
78
+ GIT_PAGER: "cat",
79
+ },
80
+ },
81
+ );
82
+ const chunks = [];
83
+ let length = 0,
84
+ error = "",
85
+ failure;
86
+ const stop = (reason) => {
87
+ failure = reason;
88
+ child.kill();
89
+ };
90
+ const timer = setTimeout(
91
+ () => stop(Error("Git took too long. Try a smaller project.")),
92
+ 8000,
93
+ );
94
+ const abort = () => stop(Error("Git selection cancelled."));
95
+ signal?.addEventListener("abort", abort, { once: true });
96
+ if (signal?.aborted) abort();
97
+ child.stdout.on("data", (chunk) => {
98
+ length += chunk.length;
99
+ if (length > max)
100
+ stop(
101
+ Error(
102
+ "Git output is too large. Narrow the changes before selecting them.",
103
+ ),
104
+ );
105
+ else chunks.push(chunk);
106
+ });
107
+ child.stderr.on("data", (chunk) => {
108
+ if (error.length < 1000) error += chunk.toString();
109
+ });
110
+ child.stdin.on("error", () => {});
111
+ child.stdin.end(input);
112
+ child.once("error", (e) => {
113
+ clearTimeout(timer);
114
+ signal?.removeEventListener("abort", abort);
115
+ if (e.code === "ENOENT") {
116
+ const missing = Error("Install Git to select project changes.");
117
+ missing.gitMissing = true;
118
+ reject(missing);
119
+ } else reject(e);
120
+ });
121
+ child.once("close", (code) => {
122
+ clearTimeout(timer);
123
+ signal?.removeEventListener("abort", abort);
124
+ if (failure) reject(failure);
125
+ else if (code !== 0) {
126
+ const e = Error(
127
+ /not a git repository/i.test(error)
128
+ ? "This project is not inside a Git repository."
129
+ : "Git could not read these changes.",
130
+ );
131
+ e.gitCode = code;
132
+ reject(e);
133
+ } else resolvePromise(Buffer.concat(chunks).toString("utf8"));
134
+ });
135
+ });
136
+ }
137
+ export async function listFiles(
138
+ root,
139
+ directory = ".",
140
+ { hidden = false, query = "", signal } = {},
141
+ ) {
142
+ const path = await workspacePath(root, directory),
143
+ entries = await readdir(path, { withFileTypes: true });
144
+ if (entries.length > 5000)
145
+ throw Error(
146
+ "This directory has too many entries. Choose a smaller project directory.",
147
+ );
148
+ const candidates = entries.filter(
149
+ (e) =>
150
+ !e.isSymbolicLink() &&
151
+ (e.isDirectory() || e.isFile()) &&
152
+ !skipped.has(e.name) &&
153
+ (!e.name.startsWith(".") || hidden) &&
154
+ !sensitivePath(join(directory, e.name)),
155
+ );
156
+ let ignored = new Set();
157
+ if (candidates.length) {
158
+ try {
159
+ const output = await git(
160
+ root,
161
+ ["check-ignore", "--no-index", "-z", "--stdin"],
162
+ {
163
+ input:
164
+ candidates
165
+ .map(
166
+ (e) =>
167
+ "./" +
168
+ relative(root, join(path, e.name)).split(sep).join("/") +
169
+ (e.isDirectory() ? "/" : ""),
170
+ )
171
+ .join("\0") + "\0",
172
+ signal,
173
+ },
174
+ );
175
+ ignored = new Set(
176
+ output.split("\0").map((path) => path.replace(/^\.\//, "")),
177
+ );
178
+ } catch (e) {
179
+ if (signal?.aborted) throw e;
180
+ if (
181
+ !e.gitMissing &&
182
+ e.gitCode !== 1 &&
183
+ e.message !== "This project is not inside a Git repository."
184
+ )
185
+ throw e;
186
+ }
187
+ }
188
+ return candidates
189
+ .filter(
190
+ (e) =>
191
+ !ignored.has(
192
+ relative(root, join(path, e.name)).split(sep).join("/") +
193
+ (e.isDirectory() ? "/" : ""),
194
+ ) && e.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
195
+ )
196
+ .sort(
197
+ (a, b) =>
198
+ Number(b.isDirectory()) - Number(a.isDirectory()) ||
199
+ a.name.localeCompare(b.name),
200
+ )
201
+ .map((e) => ({
202
+ name: e.name,
203
+ path: relative(root, join(path, e.name)),
204
+ directory: e.isDirectory(),
205
+ }));
206
+ }
207
+ export async function readCodeFile(root, path) {
208
+ const filename = await workspacePath(root, path);
209
+ if (!(await lstat(filename)).isFile())
210
+ throw Error("Choose a regular text file.");
211
+ const file = await open(
212
+ filename,
213
+ constants.O_RDONLY |
214
+ (constants.O_NOFOLLOW || 0) |
215
+ (constants.O_NONBLOCK || 0),
216
+ );
217
+ try {
218
+ const info = await file.stat();
219
+ if (!info.isFile() || info.size > 512 * 1024)
220
+ throw Error(
221
+ "Choose a text file up to 512 KiB; publish up to 20,000 characters.",
222
+ );
223
+ // Bound allocation even if a generator appends to this file during selection.
224
+ const buffer = Buffer.alloc(512 * 1024 + 1);
225
+ let length = 0;
226
+ while (length < buffer.length) {
227
+ const { bytesRead } = await file.read(
228
+ buffer,
229
+ length,
230
+ buffer.length - length,
231
+ length,
232
+ );
233
+ if (!bytesRead) break;
234
+ length += bytesRead;
235
+ }
236
+ if (length > 512 * 1024)
237
+ throw Error("The selected file grew beyond the preview limit.");
238
+ const code = new TextDecoder("utf-8", { fatal: true }).decode(
239
+ buffer.subarray(0, length),
240
+ );
241
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(code))
242
+ throw Error("Binary/control data cannot be selected as code.");
243
+ return {
244
+ code: code.replace(/\r\n/g, "\n"),
245
+ path: relative(root, filename),
246
+ };
247
+ } catch (e) {
248
+ if (e instanceof TypeError) throw Error("Choose a UTF-8 text file.");
249
+ throw e;
250
+ } finally {
251
+ await file.close();
252
+ }
253
+ }
254
+ export function selectLines(code, start = 1, end = code.split("\n").length) {
255
+ const lines = code.split("\n");
256
+ if (
257
+ !Number.isSafeInteger(start) ||
258
+ !Number.isSafeInteger(end) ||
259
+ start < 1 ||
260
+ end < start ||
261
+ end > lines.length
262
+ )
263
+ throw Error("Choose an existing line range.");
264
+ const selected = lines.slice(start - 1, end).join("\n");
265
+ if (selected.trim().length < 10 || selected.length > 20000)
266
+ throw Error("Select between 10 and 20,000 characters of code.");
267
+ return selected;
268
+ }
269
+ const diffArgs = (staged) => [
270
+ "diff",
271
+ "--no-ext-diff",
272
+ "--no-textconv",
273
+ "--no-color",
274
+ "--no-renames",
275
+ "--ignore-submodules=all",
276
+ "--relative",
277
+ ...(staged ? ["--cached"] : []),
278
+ ];
279
+ export async function listChanges(
280
+ root,
281
+ { staged = false, query = "", signal } = {},
282
+ ) {
283
+ const output = await git(
284
+ root,
285
+ [...diffArgs(staged), "--numstat", "-z", "--", "."],
286
+ { signal },
287
+ );
288
+ const entries = output
289
+ .split("\0")
290
+ .filter(Boolean)
291
+ .map((line) => {
292
+ const first = line.indexOf("\t"),
293
+ second = line.indexOf("\t", first + 1);
294
+ return {
295
+ path: line.slice(second + 1),
296
+ added: line.slice(0, first),
297
+ removed: line.slice(first + 1, second),
298
+ };
299
+ });
300
+ const safe = [];
301
+ for (const entry of entries) {
302
+ if (
303
+ entry.added === "-" ||
304
+ (entry.added === "0" && entry.removed === "0") ||
305
+ sensitivePath(entry.path) ||
306
+ entry.path.split("/").some((p) => skipped.has(p))
307
+ )
308
+ continue;
309
+ try {
310
+ await workspacePath(root, entry.path);
311
+ } catch (e) {
312
+ if (e.code !== "ENOENT")
313
+ continue; /* Deleted files still have reviewable patches. */
314
+ }
315
+ if (entry.path.toLocaleLowerCase().includes(query.toLocaleLowerCase()))
316
+ safe.push({ ...entry, name: entry.path, directory: false });
317
+ }
318
+ return safe;
319
+ }
320
+ export function splitPatch(patch) {
321
+ const lines = patch.split("\n"),
322
+ starts = [];
323
+ for (let i = 0; i < lines.length; i++)
324
+ if (/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(lines[i])) starts.push(i);
325
+ if (!starts.length)
326
+ throw Error(
327
+ "No text changes to review. Binary files and submodules are not supported.",
328
+ );
329
+ const header = lines.slice(0, starts[0]);
330
+ return starts.map((start, index) => {
331
+ const text = [
332
+ ...header,
333
+ ...lines.slice(start, starts[index + 1] ?? lines.length),
334
+ ].join("\n");
335
+ return { index, label: lines[start], patch: text, hash: hash(text) };
336
+ });
337
+ }
338
+ export async function readDiff(root, path, { staged = false, signal } = {}) {
339
+ if (sensitivePath(path)) throw Error("Credential files cannot be selected.");
340
+ // Deleted paths cannot be resolved, but their existing parents must still stay in the workspace.
341
+ const candidate = resolve(root, path),
342
+ rel = relative(root, candidate);
343
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel))
344
+ throw Error("Stay inside the selected project directory.");
345
+ try {
346
+ await workspacePath(root, path);
347
+ } catch (e) {
348
+ if (e.code !== "ENOENT") throw e;
349
+ }
350
+ const patch = await git(
351
+ root,
352
+ [...diffArgs(staged), "--unified=3", "--", path],
353
+ { signal },
354
+ );
355
+ return { path, staged, patch, hash: hash(patch), hunks: splitPatch(patch) };
356
+ }
357
+ export async function reviewSource(root, source, { signal } = {}) {
358
+ if (source.kind === "diff") {
359
+ const diff = await readDiff(root, source.path, {
360
+ staged: source.staged,
361
+ signal,
362
+ }),
363
+ part = source.hunk === null ? diff : diff.hunks[source.hunk];
364
+ if (!part || part.hash !== source.hash)
365
+ throw Error(
366
+ "These Git changes have changed since selection. Open the picker and select them again.",
367
+ );
368
+ const code = selectLines(part.patch);
369
+ return {
370
+ code,
371
+ label: `Git diff · ${source.staged ? "staged" : "unstaged"} · ${source.path}${source.hunk === null ? "" : " · " + part.label}`,
372
+ kind: "diff",
373
+ };
374
+ }
375
+ const file = await readCodeFile(root, source.path),
376
+ code = selectLines(
377
+ file.code,
378
+ source.start || 1,
379
+ source.end || file.code.split("\n").length,
380
+ );
381
+ return {
382
+ code,
383
+ label: `File · ${file.path}${source.start ? " · lines " + source.start + "–" + source.end : ""}`,
384
+ kind: "file",
385
+ };
386
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenyokjs/devdevian-cli",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.3.0-alpha.1",
4
4
  "description": "Devdevian in your terminal: discussions, questions and human code reviews.",
5
5
  "type": "module",
6
6
  "bin": {