@ree-auz/nook 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +422 -410
  3. package/bin/nk.mjs +3 -3
  4. package/docs/setup-hooks.md +107 -107
  5. package/integrations/linear/README.md +94 -84
  6. package/integrations/linear/coding-tools.json +7 -7
  7. package/integrations/linear/open-issue.mjs +193 -147
  8. package/package.json +34 -34
  9. package/skills/nook/SKILL.md +71 -71
  10. package/src/agents.mjs +92 -92
  11. package/src/backup.mjs +74 -74
  12. package/src/ci.mjs +378 -378
  13. package/src/cli.mjs +151 -151
  14. package/src/commands/check-hooks.mjs +128 -128
  15. package/src/commands/clean.mjs +175 -175
  16. package/src/commands/doctor.mjs +100 -91
  17. package/src/commands/forget.mjs +57 -57
  18. package/src/commands/hooks.mjs +34 -27
  19. package/src/commands/init.mjs +154 -154
  20. package/src/commands/ls.mjs +107 -107
  21. package/src/commands/rm.mjs +190 -190
  22. package/src/commands/workspace.mjs +163 -163
  23. package/src/commands/write-hooks.mjs +185 -185
  24. package/src/compose-plan.mjs +228 -228
  25. package/src/compose.mjs +123 -123
  26. package/src/config.mjs +115 -115
  27. package/src/context.mjs +189 -189
  28. package/src/errors.mjs +8 -8
  29. package/src/forge.mjs +75 -75
  30. package/src/generate-hooks.mjs +367 -367
  31. package/src/git.mjs +86 -86
  32. package/src/home.mjs +48 -48
  33. package/src/hooks.mjs +135 -135
  34. package/src/names.mjs +65 -65
  35. package/src/occupancy-windows.ps1 +59 -59
  36. package/src/occupancy.mjs +131 -131
  37. package/src/ports.mjs +89 -45
  38. package/src/prompt.mjs +100 -100
  39. package/src/shared.mjs +97 -97
  40. package/src/status.mjs +30 -30
  41. package/src/tools.mjs +23 -23
  42. package/src/ui.mjs +60 -60
package/src/names.mjs CHANGED
@@ -1,65 +1,65 @@
1
- import { NookError } from "./errors.mjs";
2
-
3
- export const SUBCOMMANDS = ["init", "ls", "rm", "clean", "hooks", "forget", "doctor", "help", "version"];
4
-
5
- /**
6
- * Workspace name = branch name = folder name.
7
- * Lowercase letters, digits, "-" and "."; no "/"; not a subcommand.
8
- * Returns an error message, or null when the name is valid.
9
- */
10
- export function nameProblem(name) {
11
- if (typeof name !== "string" || name === "") return "name is empty";
12
- if (name.length > 100) return "name is longer than 100 characters";
13
- if (name.includes("/")) return `"${name}" contains "/"; use "-" instead`;
14
- if (!/^[a-z0-9.-]+$/.test(name))
15
- return `"${name}" may only use lowercase letters, digits, "-" and "."`;
16
- if (!/^[a-z0-9]/.test(name)) return `"${name}" must start with a letter or digit`;
17
- if (name.endsWith(".") || name.endsWith(".lock"))
18
- return `"${name}" cannot end with "." or ".lock"`;
19
- if (name.includes("..")) return `"${name}" cannot contain ".."`;
20
- if (SUBCOMMANDS.includes(name)) return `"${name}" is a nk command; pick another name`;
21
- return null;
22
- }
23
-
24
- export function assertName(name, what = "workspace") {
25
- const problem = nameProblem(name);
26
- if (problem) throw new NookError(`Invalid ${what} name: ${problem}`);
27
- return name;
28
- }
29
-
30
- /**
31
- * Turns any branch-like text into a valid name, so callers (trackers, scripts,
32
- * people) can pass what they have: lowercase it, turn "/", spaces, non-ASCII
33
- * and other characters into "-", and tidy the ends.
34
- * "REE-267-Nook-第-1-步:建-nook" -> "ree-267-nook-1-nook"; "feat/Login" -> "feat-login".
35
- */
36
- export function normalizeName(input) {
37
- return String(input)
38
- .toLowerCase()
39
- .replace(/[^a-z0-9.-]+/g, "-")
40
- .replace(/\.{2,}/g, ".")
41
- .replace(/-{2,}/g, "-")
42
- .replace(/^[^a-z0-9]+/, "")
43
- .replace(/(\.lock)+$/, "")
44
- .replace(/[.-]+$/, "")
45
- .slice(0, 100)
46
- .replace(/[.-]+$/, "");
47
- }
48
-
49
- /** Normalizes, then checks what normalizing cannot fix (empty, command names). */
50
- export function toName(input) {
51
- const name = normalizeName(input);
52
- const problem = nameProblem(name);
53
- if (problem) throw new NookError(`Cannot use "${input}" as a workspace name: ${problem}`);
54
- return name;
55
- }
56
-
57
- /** Turns a folder name into a repository short name suggestion. */
58
- export function suggestRepoName(folder) {
59
- const name = folder
60
- .toLowerCase()
61
- .replace(/[^a-z0-9.-]+/g, "-")
62
- .replace(/^[^a-z0-9]+/, "")
63
- .replace(/[.-]+$/, "");
64
- return nameProblem(name) ? "repo" : name;
65
- }
1
+ import { NookError } from "./errors.mjs";
2
+
3
+ export const SUBCOMMANDS = ["init", "ls", "rm", "clean", "hooks", "forget", "doctor", "help", "version"];
4
+
5
+ /**
6
+ * Workspace name = branch name = folder name.
7
+ * Lowercase letters, digits, "-" and "."; no "/"; not a subcommand.
8
+ * Returns an error message, or null when the name is valid.
9
+ */
10
+ export function nameProblem(name) {
11
+ if (typeof name !== "string" || name === "") return "name is empty";
12
+ if (name.length > 100) return "name is longer than 100 characters";
13
+ if (name.includes("/")) return `"${name}" contains "/"; use "-" instead`;
14
+ if (!/^[a-z0-9.-]+$/.test(name))
15
+ return `"${name}" may only use lowercase letters, digits, "-" and "."`;
16
+ if (!/^[a-z0-9]/.test(name)) return `"${name}" must start with a letter or digit`;
17
+ if (name.endsWith(".") || name.endsWith(".lock"))
18
+ return `"${name}" cannot end with "." or ".lock"`;
19
+ if (name.includes("..")) return `"${name}" cannot contain ".."`;
20
+ if (SUBCOMMANDS.includes(name)) return `"${name}" is a nk command; pick another name`;
21
+ return null;
22
+ }
23
+
24
+ export function assertName(name, what = "workspace") {
25
+ const problem = nameProblem(name);
26
+ if (problem) throw new NookError(`Invalid ${what} name: ${problem}`);
27
+ return name;
28
+ }
29
+
30
+ /**
31
+ * Turns any branch-like text into a valid name, so callers (trackers, scripts,
32
+ * people) can pass what they have: lowercase it, turn "/", spaces, non-ASCII
33
+ * and other characters into "-", and tidy the ends.
34
+ * "REE-267-Nook-第-1-步:建-nook" -> "ree-267-nook-1-nook"; "feat/Login" -> "feat-login".
35
+ */
36
+ export function normalizeName(input) {
37
+ return String(input)
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9.-]+/g, "-")
40
+ .replace(/\.{2,}/g, ".")
41
+ .replace(/-{2,}/g, "-")
42
+ .replace(/^[^a-z0-9]+/, "")
43
+ .replace(/(\.lock)+$/, "")
44
+ .replace(/[.-]+$/, "")
45
+ .slice(0, 100)
46
+ .replace(/[.-]+$/, "");
47
+ }
48
+
49
+ /** Normalizes, then checks what normalizing cannot fix (empty, command names). */
50
+ export function toName(input) {
51
+ const name = normalizeName(input);
52
+ const problem = nameProblem(name);
53
+ if (problem) throw new NookError(`Cannot use "${input}" as a workspace name: ${problem}`);
54
+ return name;
55
+ }
56
+
57
+ /** Turns a folder name into a repository short name suggestion. */
58
+ export function suggestRepoName(folder) {
59
+ const name = folder
60
+ .toLowerCase()
61
+ .replace(/[^a-z0-9.-]+/g, "-")
62
+ .replace(/^[^a-z0-9]+/, "")
63
+ .replace(/[.-]+$/, "");
64
+ return nameProblem(name) ? "repo" : name;
65
+ }
@@ -1,59 +1,59 @@
1
- # Lists processes whose current directory is inside -Root.
2
- # Prints JSON: { processes: [{ pid, name, reasons }], inaccessible }.
3
- param([Parameter(Mandatory = $true)][string]$Root)
4
- $ErrorActionPreference = 'Stop'
5
- if ([Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -ne 'X64') { throw 'Windows x64 is required for the process scan' }
6
- Add-Type -TypeDefinition @'
7
- using System;
8
- using System.Collections.Generic;
9
- using System.Diagnostics;
10
- using System.IO;
11
- using System.Runtime.InteropServices;
12
- namespace Nook {
13
- public class DirectoryProcess { public int pid; public string name; }
14
- public class DirectoryScan { public DirectoryProcess[] processes; public int inaccessible; }
15
- public static class DirectoryInspector {
16
- [DllImport("kernel32.dll")] static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
17
- [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
18
- [DllImport("kernel32.dll")] static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] buffer, IntPtr size, out IntPtr read);
19
- [DllImport("kernel32.dll")] static extern bool IsWow64Process(IntPtr process, out bool wow64);
20
- [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr process, int infoClass, byte[] info, int size, out int returned);
21
- static byte[] Read(IntPtr process, long address, int size) {
22
- byte[] data = new byte[size]; IntPtr read;
23
- if (!ReadProcessMemory(process, new IntPtr(address), data, new IntPtr(size), out read) || read.ToInt64() != size) throw new InvalidOperationException();
24
- return data;
25
- }
26
- public static DirectoryScan Scan(string root) {
27
- string target = Path.GetFullPath(root).TrimEnd('\\');
28
- var found = new List<DirectoryProcess>(); int unavailable = 0;
29
- foreach (Process process in Process.GetProcesses()) {
30
- using (process) {
31
- IntPtr handle = OpenProcess(0x410, false, process.Id);
32
- if (handle == IntPtr.Zero) { unavailable++; continue; }
33
- try {
34
- bool wow64;
35
- if (!IsWow64Process(handle, out wow64) || wow64) { unavailable++; continue; }
36
- byte[] info = new byte[48]; int returned;
37
- if (NtQueryInformationProcess(handle, 0, info, info.Length, out returned) != 0) { unavailable++; continue; }
38
- long peb = BitConverter.ToInt64(info, 8);
39
- long parameters = BitConverter.ToInt64(Read(handle, peb + 0x20, 8), 0);
40
- // 64-bit RTL_USER_PROCESS_PARAMETERS.CurrentDirectory.DosPath.
41
- byte[] current = Read(handle, parameters + 0x38, 16);
42
- int length = BitConverter.ToUInt16(current, 0);
43
- if (length == 0 || length > 32766 || length % 2 != 0) continue;
44
- string cwd = System.Text.Encoding.Unicode.GetString(Read(handle, BitConverter.ToInt64(current, 8), length)).TrimEnd('\\');
45
- if (cwd.StartsWith(@"\\?\", StringComparison.Ordinal)) cwd = cwd.Substring(4);
46
- if (cwd.Equals(target, StringComparison.OrdinalIgnoreCase) || cwd.StartsWith(target + "\\", StringComparison.OrdinalIgnoreCase))
47
- found.Add(new DirectoryProcess { pid = process.Id, name = process.ProcessName });
48
- } catch { unavailable++; }
49
- finally { CloseHandle(handle); }
50
- }
51
- }
52
- return new DirectoryScan { processes = found.ToArray(), inaccessible = unavailable };
53
- }
54
- }
55
- }
56
- '@
57
- $scan = [Nook.DirectoryInspector]::Scan($Root)
58
- $processes = @($scan.processes | ForEach-Object { [ordered]@{ pid = $_.pid; name = $_.name; reasons = @('working directory') } })
59
- [pscustomobject]@{ processes = $processes; inaccessible = $scan.inaccessible } | ConvertTo-Json -Depth 5 -Compress
1
+ # Lists processes whose current directory is inside -Root.
2
+ # Prints JSON: { processes: [{ pid, name, reasons }], inaccessible }.
3
+ param([Parameter(Mandatory = $true)][string]$Root)
4
+ $ErrorActionPreference = 'Stop'
5
+ if ([Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -ne 'X64') { throw 'Windows x64 is required for the process scan' }
6
+ Add-Type -TypeDefinition @'
7
+ using System;
8
+ using System.Collections.Generic;
9
+ using System.Diagnostics;
10
+ using System.IO;
11
+ using System.Runtime.InteropServices;
12
+ namespace Nook {
13
+ public class DirectoryProcess { public int pid; public string name; }
14
+ public class DirectoryScan { public DirectoryProcess[] processes; public int inaccessible; }
15
+ public static class DirectoryInspector {
16
+ [DllImport("kernel32.dll")] static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
17
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
18
+ [DllImport("kernel32.dll")] static extern bool ReadProcessMemory(IntPtr process, IntPtr address, byte[] buffer, IntPtr size, out IntPtr read);
19
+ [DllImport("kernel32.dll")] static extern bool IsWow64Process(IntPtr process, out bool wow64);
20
+ [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr process, int infoClass, byte[] info, int size, out int returned);
21
+ static byte[] Read(IntPtr process, long address, int size) {
22
+ byte[] data = new byte[size]; IntPtr read;
23
+ if (!ReadProcessMemory(process, new IntPtr(address), data, new IntPtr(size), out read) || read.ToInt64() != size) throw new InvalidOperationException();
24
+ return data;
25
+ }
26
+ public static DirectoryScan Scan(string root) {
27
+ string target = Path.GetFullPath(root).TrimEnd('\\');
28
+ var found = new List<DirectoryProcess>(); int unavailable = 0;
29
+ foreach (Process process in Process.GetProcesses()) {
30
+ using (process) {
31
+ IntPtr handle = OpenProcess(0x410, false, process.Id);
32
+ if (handle == IntPtr.Zero) { unavailable++; continue; }
33
+ try {
34
+ bool wow64;
35
+ if (!IsWow64Process(handle, out wow64) || wow64) { unavailable++; continue; }
36
+ byte[] info = new byte[48]; int returned;
37
+ if (NtQueryInformationProcess(handle, 0, info, info.Length, out returned) != 0) { unavailable++; continue; }
38
+ long peb = BitConverter.ToInt64(info, 8);
39
+ long parameters = BitConverter.ToInt64(Read(handle, peb + 0x20, 8), 0);
40
+ // 64-bit RTL_USER_PROCESS_PARAMETERS.CurrentDirectory.DosPath.
41
+ byte[] current = Read(handle, parameters + 0x38, 16);
42
+ int length = BitConverter.ToUInt16(current, 0);
43
+ if (length == 0 || length > 32766 || length % 2 != 0) continue;
44
+ string cwd = System.Text.Encoding.Unicode.GetString(Read(handle, BitConverter.ToInt64(current, 8), length)).TrimEnd('\\');
45
+ if (cwd.StartsWith(@"\\?\", StringComparison.Ordinal)) cwd = cwd.Substring(4);
46
+ if (cwd.Equals(target, StringComparison.OrdinalIgnoreCase) || cwd.StartsWith(target + "\\", StringComparison.OrdinalIgnoreCase))
47
+ found.Add(new DirectoryProcess { pid = process.Id, name = process.ProcessName });
48
+ } catch { unavailable++; }
49
+ finally { CloseHandle(handle); }
50
+ }
51
+ }
52
+ return new DirectoryScan { processes = found.ToArray(), inaccessible = unavailable };
53
+ }
54
+ }
55
+ }
56
+ '@
57
+ $scan = [Nook.DirectoryInspector]::Scan($Root)
58
+ $processes = @($scan.processes | ForEach-Object { [ordered]@{ pid = $_.pid; name = $_.name; reasons = @('working directory') } })
59
+ [pscustomobject]@{ processes = $processes; inaccessible = $scan.inaccessible } | ConvertTo-Json -Depth 5 -Compress
package/src/occupancy.mjs CHANGED
@@ -1,131 +1,131 @@
1
- import { spawnSync } from "node:child_process";
2
- import { readdirSync, readlinkSync } from "node:fs";
3
- import { fileURLToPath } from "node:url";
4
- import { which } from "./agents.mjs";
5
- import { isInside, realPath } from "./home.mjs";
6
-
7
- function add(found, pid, name, reason) {
8
- const current = found.get(pid);
9
- if (current) {
10
- if (!current.reasons.includes(reason)) current.reasons.push(reason);
11
- } else found.set(pid, { pid, name, reasons: [reason] });
12
- }
13
-
14
- function linuxScan(root) {
15
- const found = new Map();
16
- let inaccessible = 0;
17
- for (const entry of readdirSync("/proc")) {
18
- if (!/^\d+$/.test(entry)) continue;
19
- const pid = Number(entry);
20
- if (pid === process.pid) continue;
21
- let name = entry;
22
- try {
23
- name = readlinkSync(`/proc/${entry}/exe`).split("/").pop();
24
- } catch {
25
- /* Name is best effort. */
26
- }
27
- try {
28
- if (isInside(readlinkSync(`/proc/${entry}/cwd`), root, "linux")) add(found, pid, name, "working directory");
29
- } catch {
30
- inaccessible++;
31
- }
32
- }
33
- return { processes: [...found.values()], inaccessible };
34
- }
35
-
36
- /** Parses `lsof -d cwd -F pcfn` output and keeps working directories below `root`. */
37
- export function parseLsof(text, root, platform = "darwin") {
38
- const found = new Map();
39
- let pid = 0;
40
- let name = "";
41
- let fd = "";
42
- for (const line of text.split(/\r?\n/)) {
43
- const tag = line[0];
44
- const value = line.slice(1);
45
- if (tag === "p") {
46
- pid = Number(value);
47
- name = "";
48
- } else if (tag === "c") name = value;
49
- else if (tag === "f") fd = value;
50
- else if (tag === "n" && pid && pid !== process.pid && value.startsWith("/") && isInside(value, root, platform))
51
- fd === "cwd" && add(found, pid, name || String(pid), "working directory");
52
- }
53
- return [...found.values()];
54
- }
55
-
56
- function lsofScan(root, platform) {
57
- const lsof = which("lsof") ?? (["/usr/sbin/lsof", "/usr/bin/lsof"].find((p) => which(p)) || null);
58
- if (!lsof) return { processes: [], unsupported: "lsof not found" };
59
- const result = spawnSync(lsof, ["-n", "-P", "-w", "-d", "cwd", "-F", "pcfn"], { encoding: "utf8", maxBuffer: 512 * 1024 * 1024 });
60
- if (result.error) return { processes: [], unsupported: result.error.message };
61
- return { processes: parseLsof(result.stdout, root, platform) };
62
- }
63
-
64
- function windowsScan(root) {
65
- const script = fileURLToPath(new URL("./occupancy-windows.ps1", import.meta.url));
66
- const shell = which("pwsh") ?? "powershell.exe";
67
- const result = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Root", root], {
68
- encoding: "utf8",
69
- maxBuffer: 64 * 1024 * 1024,
70
- windowsHide: true,
71
- });
72
- if (result.error || result.status !== 0)
73
- return { processes: [], unsupported: (result.stderr || result.error?.message || "scan failed").trim().split(/\r?\n/)[0] };
74
- const data = JSON.parse(result.stdout);
75
- const list = Array.isArray(data.processes) ? data.processes : data.processes ? [data.processes] : [];
76
- return {
77
- processes: list.filter((item) => item.pid !== process.pid).map((item) => ({ pid: item.pid, name: item.name, reasons: [].concat(item.reasons) })),
78
- inaccessible: data.inaccessible,
79
- };
80
- }
81
-
82
- /**
83
- * Processes whose working directory is inside `root`: terminals, dev servers
84
- * and agents started there. Like worktrunk's `--reap`, open files are not
85
- * scanned: that is slow and editors usually keep their directory elsewhere.
86
- */
87
- export function findOccupants(root, platform = process.platform) {
88
- const target = realPath(root);
89
- if (platform === "linux") return linuxScan(target);
90
- if (platform === "win32") return windowsScan(target);
91
- return lsofScan(target, platform);
92
- }
93
-
94
- export function describeOccupants(processes) {
95
- return processes.map((item) => ` ${item.name} (PID ${item.pid}): ${item.reasons.join(", ")}`).join("\n");
96
- }
97
-
98
- /**
99
- * Ends the processes and their children. Windows uses taskkill /T /F; macOS and
100
- * Linux send SIGTERM, then SIGKILL to what is still running after 3 seconds.
101
- */
102
- export async function stopProcesses(processes, platform = process.platform) {
103
- if (platform === "win32") {
104
- for (const { pid } of processes) spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
105
- return;
106
- }
107
- const alive = (pid) => {
108
- try {
109
- process.kill(pid, 0);
110
- return true;
111
- } catch {
112
- return false;
113
- }
114
- };
115
- for (const { pid } of processes) {
116
- try {
117
- process.kill(pid, "SIGTERM");
118
- } catch {
119
- /* Already gone. */
120
- }
121
- }
122
- for (let waited = 0; waited < 3000 && processes.some(({ pid }) => alive(pid)); waited += 100)
123
- await new Promise((done) => setTimeout(done, 100));
124
- for (const { pid } of processes) {
125
- try {
126
- process.kill(pid, "SIGKILL");
127
- } catch {
128
- /* Already gone. */
129
- }
130
- }
131
- }
1
+ import { spawnSync } from "node:child_process";
2
+ import { readdirSync, readlinkSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { which } from "./agents.mjs";
5
+ import { isInside, realPath } from "./home.mjs";
6
+
7
+ function add(found, pid, name, reason) {
8
+ const current = found.get(pid);
9
+ if (current) {
10
+ if (!current.reasons.includes(reason)) current.reasons.push(reason);
11
+ } else found.set(pid, { pid, name, reasons: [reason] });
12
+ }
13
+
14
+ function linuxScan(root) {
15
+ const found = new Map();
16
+ let inaccessible = 0;
17
+ for (const entry of readdirSync("/proc")) {
18
+ if (!/^\d+$/.test(entry)) continue;
19
+ const pid = Number(entry);
20
+ if (pid === process.pid) continue;
21
+ let name = entry;
22
+ try {
23
+ name = readlinkSync(`/proc/${entry}/exe`).split("/").pop();
24
+ } catch {
25
+ /* Name is best effort. */
26
+ }
27
+ try {
28
+ if (isInside(readlinkSync(`/proc/${entry}/cwd`), root, "linux")) add(found, pid, name, "working directory");
29
+ } catch {
30
+ inaccessible++;
31
+ }
32
+ }
33
+ return { processes: [...found.values()], inaccessible };
34
+ }
35
+
36
+ /** Parses `lsof -d cwd -F pcfn` output and keeps working directories below `root`. */
37
+ export function parseLsof(text, root, platform = "darwin") {
38
+ const found = new Map();
39
+ let pid = 0;
40
+ let name = "";
41
+ let fd = "";
42
+ for (const line of text.split(/\r?\n/)) {
43
+ const tag = line[0];
44
+ const value = line.slice(1);
45
+ if (tag === "p") {
46
+ pid = Number(value);
47
+ name = "";
48
+ } else if (tag === "c") name = value;
49
+ else if (tag === "f") fd = value;
50
+ else if (tag === "n" && pid && pid !== process.pid && value.startsWith("/") && isInside(value, root, platform))
51
+ fd === "cwd" && add(found, pid, name || String(pid), "working directory");
52
+ }
53
+ return [...found.values()];
54
+ }
55
+
56
+ function lsofScan(root, platform) {
57
+ const lsof = which("lsof") ?? (["/usr/sbin/lsof", "/usr/bin/lsof"].find((p) => which(p)) || null);
58
+ if (!lsof) return { processes: [], unsupported: "lsof not found" };
59
+ const result = spawnSync(lsof, ["-n", "-P", "-w", "-d", "cwd", "-F", "pcfn"], { encoding: "utf8", maxBuffer: 512 * 1024 * 1024 });
60
+ if (result.error) return { processes: [], unsupported: result.error.message };
61
+ return { processes: parseLsof(result.stdout, root, platform) };
62
+ }
63
+
64
+ function windowsScan(root) {
65
+ const script = fileURLToPath(new URL("./occupancy-windows.ps1", import.meta.url));
66
+ const shell = which("pwsh") ?? "powershell.exe";
67
+ const result = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script, "-Root", root], {
68
+ encoding: "utf8",
69
+ maxBuffer: 64 * 1024 * 1024,
70
+ windowsHide: true,
71
+ });
72
+ if (result.error || result.status !== 0)
73
+ return { processes: [], unsupported: (result.stderr || result.error?.message || "scan failed").trim().split(/\r?\n/)[0] };
74
+ const data = JSON.parse(result.stdout);
75
+ const list = Array.isArray(data.processes) ? data.processes : data.processes ? [data.processes] : [];
76
+ return {
77
+ processes: list.filter((item) => item.pid !== process.pid).map((item) => ({ pid: item.pid, name: item.name, reasons: [].concat(item.reasons) })),
78
+ inaccessible: data.inaccessible,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Processes whose working directory is inside `root`: terminals, dev servers
84
+ * and agents started there. Like worktrunk's `--reap`, open files are not
85
+ * scanned: that is slow and editors usually keep their directory elsewhere.
86
+ */
87
+ export function findOccupants(root, platform = process.platform) {
88
+ const target = realPath(root);
89
+ if (platform === "linux") return linuxScan(target);
90
+ if (platform === "win32") return windowsScan(target);
91
+ return lsofScan(target, platform);
92
+ }
93
+
94
+ export function describeOccupants(processes) {
95
+ return processes.map((item) => ` ${item.name} (PID ${item.pid}): ${item.reasons.join(", ")}`).join("\n");
96
+ }
97
+
98
+ /**
99
+ * Ends the processes and their children. Windows uses taskkill /T /F; macOS and
100
+ * Linux send SIGTERM, then SIGKILL to what is still running after 3 seconds.
101
+ */
102
+ export async function stopProcesses(processes, platform = process.platform) {
103
+ if (platform === "win32") {
104
+ for (const { pid } of processes) spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
105
+ return;
106
+ }
107
+ const alive = (pid) => {
108
+ try {
109
+ process.kill(pid, 0);
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ };
115
+ for (const { pid } of processes) {
116
+ try {
117
+ process.kill(pid, "SIGTERM");
118
+ } catch {
119
+ /* Already gone. */
120
+ }
121
+ }
122
+ for (let waited = 0; waited < 3000 && processes.some(({ pid }) => alive(pid)); waited += 100)
123
+ await new Promise((done) => setTimeout(done, 100));
124
+ for (const { pid } of processes) {
125
+ try {
126
+ process.kill(pid, "SIGKILL");
127
+ } catch {
128
+ /* Already gone. */
129
+ }
130
+ }
131
+ }
package/src/ports.mjs CHANGED
@@ -1,45 +1,89 @@
1
- import { createServer } from "node:net";
2
- import { NookError } from "./errors.mjs";
3
-
4
- export const PORT_BLOCK = 10;
5
- export const DEFAULT_PORT_BASE = 20000;
6
- const MAX_PORT = 65535;
7
-
8
- /** First ports of every workspace that already has a block. */
9
- export function usedPorts(config) {
10
- const used = [];
11
- for (const repo of Object.values(config.repos ?? {}))
12
- for (const ws of Object.values(repo.workspaces ?? {}))
13
- if (Number.isInteger(ws.port)) used.push(ws.port);
14
- return used;
15
- }
16
-
17
- function overlaps(start, used) {
18
- return used.some((other) => start < other + PORT_BLOCK && other < start + PORT_BLOCK);
19
- }
20
-
21
- export function portFree(port, host = "127.0.0.1") {
22
- return new Promise((done) => {
23
- const server = createServer();
24
- server.once("error", () => done(false));
25
- server.listen({ port, host, exclusive: true }, () => server.close(() => done(true)));
26
- });
27
- }
28
-
29
- export async function blockFree(start, isFree = portFree) {
30
- for (let port = start; port < start + PORT_BLOCK; port++) if (!(await isFree(port))) return false;
31
- return true;
32
- }
33
-
34
- /**
35
- * Gives a workspace PORT_BLOCK ports in a row. Skips blocks other workspaces
36
- * own and blocks where something is already listening.
37
- */
38
- export async function allocatePort(config, { base = config.port_base ?? DEFAULT_PORT_BASE, isFree = portFree } = {}) {
39
- const used = usedPorts(config);
40
- for (let start = base; start + PORT_BLOCK - 1 <= MAX_PORT; start += PORT_BLOCK) {
41
- if (overlaps(start, used)) continue;
42
- if (await blockFree(start, isFree)) return start;
43
- }
44
- throw new NookError(`No free block of ${PORT_BLOCK} ports from ${base}`);
45
- }
1
+ import { readFileSync } from "node:fs";
2
+ import { createServer } from "node:net";
3
+ import spawn from "cross-spawn";
4
+ import { NookError } from "./errors.mjs";
5
+
6
+ export const PORT_BLOCK = 10;
7
+ export const DEFAULT_PORT_BASE = 20000;
8
+ const MAX_PORT = 65535;
9
+
10
+ /** First ports of every workspace that already has a block. */
11
+ export function usedPorts(config) {
12
+ const used = [];
13
+ for (const repo of Object.values(config.repos ?? {}))
14
+ for (const ws of Object.values(repo.workspaces ?? {}))
15
+ if (Number.isInteger(ws.port)) used.push(ws.port);
16
+ return used;
17
+ }
18
+
19
+ function overlaps(start, used) {
20
+ return used.some((other) => start < other + PORT_BLOCK && other < start + PORT_BLOCK);
21
+ }
22
+
23
+ export function portFree(port, host = "127.0.0.1") {
24
+ return new Promise((done) => {
25
+ const server = createServer();
26
+ server.once("error", () => done(false));
27
+ server.listen({ port, host, exclusive: true }, () => server.close(() => done(true)));
28
+ });
29
+ }
30
+
31
+ export async function blockFree(start, isFree = portFree) {
32
+ for (let port = start; port < start + PORT_BLOCK; port++) if (!(await isFree(port))) return false;
33
+ return true;
34
+ }
35
+
36
+ /** Reads the start and size of Windows' dynamic port range from netsh, in any language: the last two numbers. */
37
+ export function parseDynamicPorts(text) {
38
+ const numbers = String(text).match(/\d+/g)?.map(Number) ?? [];
39
+ if (numbers.length < 2) return null;
40
+ const [start, count] = numbers.slice(-2);
41
+ return start > 0 && count > 0 ? { start, end: Math.min(start + count - 1, MAX_PORT) } : null;
42
+ }
43
+
44
+ let cachedRange;
45
+ /**
46
+ * The ports the system hands out to outgoing connections (ephemeral ports), or null when unknown.
47
+ * Any program's connection can take one of them at any time, so a workspace's ports should lie outside.
48
+ */
49
+ export function ephemeralRange() {
50
+ if (cachedRange !== undefined) return cachedRange;
51
+ cachedRange = null;
52
+ try {
53
+ if (process.platform === "win32") {
54
+ const result = spawn.sync("netsh", ["int", "ipv4", "show", "dynamicport", "tcp"], { encoding: "utf8", windowsHide: true, timeout: 5000 });
55
+ if (result.status === 0) cachedRange = parseDynamicPorts(result.stdout);
56
+ } else if (process.platform === "linux") {
57
+ const [start, end] = readFileSync("/proc/sys/net/ipv4/ip_local_port_range", "utf8").trim().split(/\s+/).map(Number);
58
+ if (start > 0 && end >= start) cachedRange = { start, end };
59
+ } else if (process.platform === "darwin") {
60
+ const result = spawn.sync("sysctl", ["-n", "net.inet.ip.portrange.first", "net.inet.ip.portrange.last"], { encoding: "utf8", timeout: 5000 });
61
+ const [start, end] = (result.stdout ?? "").trim().split(/\s+/).map(Number);
62
+ if (result.status === 0 && start > 0 && end >= start) cachedRange = { start, end };
63
+ }
64
+ } catch {
65
+ /* Unknown; nothing is avoided. */
66
+ }
67
+ return cachedRange;
68
+ }
69
+
70
+ /** Whether a workspace's block of ports starting at `start` lies inside the ephemeral range. */
71
+ export function inEphemeral(start, range) {
72
+ return Boolean(range) && start <= range.end && range.start < start + PORT_BLOCK;
73
+ }
74
+
75
+ /**
76
+ * Gives a workspace PORT_BLOCK ports in a row. Skips blocks other workspaces
77
+ * own, blocks where something is already listening, and blocks the system may
78
+ * hand to outgoing connections, unless nothing else is left.
79
+ */
80
+ export async function allocatePort(config, { base = config.port_base ?? DEFAULT_PORT_BASE, isFree = portFree, ephemeral = ephemeralRange() } = {}) {
81
+ const used = usedPorts(config);
82
+ for (const avoid of [ephemeral, null]) {
83
+ for (let start = base; start + PORT_BLOCK - 1 <= MAX_PORT; start += PORT_BLOCK) {
84
+ if (overlaps(start, used) || inEphemeral(start, avoid)) continue;
85
+ if (await blockFree(start, isFree)) return start;
86
+ }
87
+ }
88
+ throw new NookError(`No free block of ${PORT_BLOCK} ports from ${base}`);
89
+ }