@ree-auz/nook 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 +410 -3
- package/bin/nk.mjs +4 -0
- package/docs/setup-hooks.md +107 -0
- package/integrations/linear/README.md +84 -0
- package/integrations/linear/coding-tools.json +7 -0
- package/integrations/linear/open-issue.mjs +147 -0
- package/package.json +34 -6
- package/skills/nook/SKILL.md +71 -0
- package/src/agents.mjs +92 -0
- package/src/backup.mjs +74 -0
- package/src/ci.mjs +378 -0
- package/src/cli.mjs +151 -0
- package/src/commands/check-hooks.mjs +128 -0
- package/src/commands/clean.mjs +175 -0
- package/src/commands/doctor.mjs +91 -0
- package/src/commands/forget.mjs +57 -0
- package/src/commands/hooks.mjs +27 -0
- package/src/commands/init.mjs +154 -0
- package/src/commands/ls.mjs +107 -0
- package/src/commands/rm.mjs +190 -0
- package/src/commands/workspace.mjs +163 -0
- package/src/commands/write-hooks.mjs +185 -0
- package/src/compose-plan.mjs +228 -0
- package/src/compose.mjs +123 -0
- package/src/config.mjs +115 -0
- package/src/context.mjs +189 -0
- package/src/errors.mjs +8 -0
- package/src/forge.mjs +75 -0
- package/src/generate-hooks.mjs +367 -0
- package/src/git.mjs +86 -0
- package/src/home.mjs +48 -0
- package/src/hooks.mjs +135 -0
- package/src/names.mjs +65 -0
- package/src/occupancy-windows.ps1 +59 -0
- package/src/occupancy.mjs +131 -0
- package/src/ports.mjs +45 -0
- package/src/prompt.mjs +100 -0
- package/src/shared.mjs +97 -0
- package/src/status.mjs +30 -0
- package/src/tools.mjs +23 -0
- package/src/ui.mjs +60 -0
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createWriteStream, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import spawn from "cross-spawn";
|
|
5
|
+
import { which } from "./agents.mjs";
|
|
6
|
+
import { NookError } from "./errors.mjs";
|
|
7
|
+
import { line, useColor, indenter } from "./ui.mjs";
|
|
8
|
+
|
|
9
|
+
export const HOOK_EVENTS = ["create", "open", "remove"];
|
|
10
|
+
|
|
11
|
+
/** Extensions tried for a hook, in order. Platform scripts first, then portable ones. */
|
|
12
|
+
export function hookExtensions(platform = process.platform) {
|
|
13
|
+
return platform === "win32" ? [".ps1", ".cmd", ".mjs", ".py"] : [".sh", ".mjs", ".py"];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Every hook in `<dir>/.nook/` for this platform. */
|
|
17
|
+
export function findHooks(dir, platform = process.platform) {
|
|
18
|
+
return HOOK_EVENTS.map((event) => findHook(dir, event, platform)).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The hook file for `event` in `<dir>/.nook/`, or null. */
|
|
22
|
+
export function findHook(dir, event, platform = process.platform) {
|
|
23
|
+
let entries;
|
|
24
|
+
try {
|
|
25
|
+
entries = readdirSync(join(dir, ".nook"));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
for (const ext of hookExtensions(platform)) {
|
|
30
|
+
const name = event + ext;
|
|
31
|
+
if (entries.includes(name)) return { event, ext, name, file: join(dir, ".nook", name) };
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fingerprint of a hook's content. Line endings are normalized, so a Windows
|
|
38
|
+
* checkout (CRLF) and the same file with LF count as one version.
|
|
39
|
+
*/
|
|
40
|
+
export function hookHash(file) {
|
|
41
|
+
const text = readFileSync(file, "latin1").replace(/\r\n/g, "\n");
|
|
42
|
+
return createHash("sha256").update(text, "latin1").digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** How to run a hook file: { command, args }. */
|
|
46
|
+
export function hookCommand(hook, { platform = process.platform, find = which } = {}) {
|
|
47
|
+
switch (hook.ext) {
|
|
48
|
+
case ".ps1": {
|
|
49
|
+
const shell = find("pwsh") ?? (platform === "win32" ? "powershell.exe" : "pwsh");
|
|
50
|
+
return { command: shell, args: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", hook.file] };
|
|
51
|
+
}
|
|
52
|
+
case ".cmd":
|
|
53
|
+
return { command: hook.file, args: [] };
|
|
54
|
+
case ".sh":
|
|
55
|
+
return { command: find("bash") ?? "sh", args: [hook.file] };
|
|
56
|
+
case ".mjs":
|
|
57
|
+
return { command: process.execPath, args: [hook.file] };
|
|
58
|
+
case ".py": {
|
|
59
|
+
const python = find("python3") ?? find("python") ?? "python3";
|
|
60
|
+
return { command: python, args: [hook.file] };
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
throw new NookError(`Unknown hook type: ${hook.name}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** "trusted", "new" or "changed" for a hook of a repository. */
|
|
68
|
+
export function trustState(repoConfig, hook) {
|
|
69
|
+
const saved = repoConfig.hooks?.[hook.name];
|
|
70
|
+
if (!saved) return "new";
|
|
71
|
+
return saved === hookHash(hook.file) ? "trusted" : "changed";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Asks once before hooks run for the first time, and again after one changes,
|
|
76
|
+
* listing every such hook of the repository (like worktrunk's approval prompt).
|
|
77
|
+
* The file contents are not printed; the paths say where to read them.
|
|
78
|
+
* `ui` is the command context (warn, info, item, note). Calls `save` after a yes.
|
|
79
|
+
*/
|
|
80
|
+
export async function ensureTrusted({ repoName, repoConfig, hooks, prompter, save, ui }) {
|
|
81
|
+
const pending = hooks.filter((hook) => trustState(repoConfig, hook) !== "trusted");
|
|
82
|
+
if (!pending.length) return;
|
|
83
|
+
const changed = pending.some((hook) => trustState(repoConfig, hook) === "changed");
|
|
84
|
+
const count = pending.length === 1 ? "1 hook" : `${pending.length} hooks`;
|
|
85
|
+
ui.warn(`${repoName} needs approval to run ${count}${changed ? " (changed since you allowed them)" : ""}:`);
|
|
86
|
+
for (const hook of pending)
|
|
87
|
+
ui.item(`${hook.event}: .nook/${hook.name}${trustState(repoConfig, hook) === "changed" ? " (changed)" : ""}`);
|
|
88
|
+
ui.info(`Read them in ${dirname(pending[0].file)}`);
|
|
89
|
+
let ok;
|
|
90
|
+
try {
|
|
91
|
+
ok = await prompter.confirm("Allow and remember?", false);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new NookError(`Hooks of ${repoName} need your OK. Run nk in a terminal once, or nk init --allow-hooks after reading them`);
|
|
94
|
+
}
|
|
95
|
+
if (!ok) throw new NookError("Hooks were not allowed; nothing ran. Allow them by answering yes in a terminal, or with nk init --allow-hooks after reading them");
|
|
96
|
+
repoConfig.hooks ??= {};
|
|
97
|
+
for (const hook of pending) repoConfig.hooks[hook.name] = hookHash(hook.file);
|
|
98
|
+
save();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stamp(date = new Date()) {
|
|
102
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
103
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Runs a hook in the workspace. Output goes to the terminal and to a log file.
|
|
108
|
+
* Resolves to { status, log }.
|
|
109
|
+
*/
|
|
110
|
+
export function runHook(hook, { cwd, env, logsDir, label, output = process.stderr }) {
|
|
111
|
+
mkdirSync(logsDir, { recursive: true });
|
|
112
|
+
const log = join(logsDir, `${label}-${hook.event}-${stamp()}.log`);
|
|
113
|
+
const stream = createWriteStream(log);
|
|
114
|
+
const { command, args } = hookCommand(hook);
|
|
115
|
+
output.write(`${line("step", `Running ${hook.event} hook .nook/${hook.name}`, useColor(output))}\n`);
|
|
116
|
+
return new Promise((done) => {
|
|
117
|
+
const child = spawn(command, args, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true });
|
|
118
|
+
// Indented under the "Running … hook" line; the log keeps the output as it was.
|
|
119
|
+
const forward = (indent) => (chunk) => {
|
|
120
|
+
stream.write(chunk);
|
|
121
|
+
output.write(indent(chunk));
|
|
122
|
+
};
|
|
123
|
+
child.stdout.on("data", forward(indenter()));
|
|
124
|
+
child.stderr.on("data", forward(indenter()));
|
|
125
|
+
let finished = false;
|
|
126
|
+
const finish = (status, error) => {
|
|
127
|
+
if (finished) return;
|
|
128
|
+
finished = true;
|
|
129
|
+
if (error) stream.write(`\n${error.message}\n`);
|
|
130
|
+
stream.end(() => done({ status, log }));
|
|
131
|
+
};
|
|
132
|
+
child.on("error", (error) => finish(127, error));
|
|
133
|
+
child.on("close", (code, signal) => finish(code ?? (signal ? 1 : 0)));
|
|
134
|
+
});
|
|
135
|
+
}
|
package/src/names.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
|
@@ -0,0 +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
|
+
}
|
package/src/ports.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|
package/src/prompt.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { NookError } from "./errors.mjs";
|
|
3
|
+
import { line, useColor } from "./ui.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Asks questions on the terminal. With `yes`, every question takes its default.
|
|
7
|
+
* Without a terminal and without `yes`, asking is an error, so scripts never hang.
|
|
8
|
+
*/
|
|
9
|
+
export function createPrompter({ yes = false, input = process.stdin, output = process.stderr } = {}) {
|
|
10
|
+
const interactive = Boolean(input.isTTY);
|
|
11
|
+
// Questions get a mark like every other line, so their text lines up.
|
|
12
|
+
const mark = (text) => line("ask", text, useColor(output));
|
|
13
|
+
|
|
14
|
+
async function raw(question) {
|
|
15
|
+
if (!interactive)
|
|
16
|
+
throw new NookError(`Needs an answer ("${question.trim()}"); run it in a terminal`);
|
|
17
|
+
const rl = createInterface({ input, output });
|
|
18
|
+
try {
|
|
19
|
+
return (await rl.question(question)).trim();
|
|
20
|
+
} finally {
|
|
21
|
+
rl.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
yes,
|
|
27
|
+
interactive,
|
|
28
|
+
async ask(question, fallback = "") {
|
|
29
|
+
if (yes) return fallback;
|
|
30
|
+
const answer = await raw(mark(`${question}${fallback ? ` [${fallback}]` : ""}: `));
|
|
31
|
+
return answer || fallback;
|
|
32
|
+
},
|
|
33
|
+
/** Shows `initial` already typed so it can be edited; Enter keeps it. */
|
|
34
|
+
async edit(question, initial) {
|
|
35
|
+
if (yes || !interactive) return initial;
|
|
36
|
+
const rl = createInterface({ input, output });
|
|
37
|
+
try {
|
|
38
|
+
const pending = rl.question(mark(`${question}: `));
|
|
39
|
+
rl.write(initial);
|
|
40
|
+
return (await pending).trim() || initial;
|
|
41
|
+
} finally {
|
|
42
|
+
rl.close();
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
async confirm(question, fallback = false) {
|
|
46
|
+
if (yes) return fallback;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const answer = (await raw(mark(`${question} ${fallback ? "[Y/n]" : "[y/N]"} `))).toLowerCase();
|
|
49
|
+
if (!answer) return fallback;
|
|
50
|
+
if (["y", "yes"].includes(answer)) return true;
|
|
51
|
+
if (["n", "no"].includes(answer)) return false;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
/** choices: [{ key, label }]; returns the chosen key. */
|
|
55
|
+
async choose(question, choices, fallback) {
|
|
56
|
+
if (yes) return fallback;
|
|
57
|
+
output.write(`${mark(question)}\n`);
|
|
58
|
+
choices.forEach((choice, index) =>
|
|
59
|
+
output.write(` ${index + 1}. ${choice.label}${choice.key === fallback ? " (default)" : ""}\n`),
|
|
60
|
+
);
|
|
61
|
+
for (;;) {
|
|
62
|
+
const answer = await raw(mark("Choose a number: "));
|
|
63
|
+
if (!answer) return fallback;
|
|
64
|
+
const picked = choices[Number(answer) - 1];
|
|
65
|
+
if (picked) return picked.key;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Splits "--a 'b c'" into ["--a", "b c"]. Supports single and double quotes. */
|
|
72
|
+
export function splitArgs(text) {
|
|
73
|
+
const out = [];
|
|
74
|
+
let current = "";
|
|
75
|
+
let quote = null;
|
|
76
|
+
let started = false;
|
|
77
|
+
for (const char of text) {
|
|
78
|
+
if (quote) {
|
|
79
|
+
if (char === quote) quote = null;
|
|
80
|
+
else current += char;
|
|
81
|
+
} else if (char === '"' || char === "'") {
|
|
82
|
+
quote = char;
|
|
83
|
+
started = true;
|
|
84
|
+
} else if (/\s/.test(char)) {
|
|
85
|
+
if (started) out.push(current);
|
|
86
|
+
current = "";
|
|
87
|
+
started = false;
|
|
88
|
+
} else {
|
|
89
|
+
current += char;
|
|
90
|
+
started = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (quote) throw new NookError(`Unclosed quote in: ${text}`);
|
|
94
|
+
if (started) out.push(current);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function joinArgs(args) {
|
|
99
|
+
return args.map((arg) => (/[\s"']/.test(arg) || arg === "" ? JSON.stringify(arg) : arg)).join(" ");
|
|
100
|
+
}
|
package/src/shared.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
3
|
+
import { gitTry, ignoredPaths } from "./git.mjs";
|
|
4
|
+
import { exists } from "./home.mjs";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_INCLUDE = [".env*"];
|
|
7
|
+
/** Suffixes that mark a committed template: `.env.example` is the template for `.env`. */
|
|
8
|
+
export const TEMPLATE_SUFFIX = /^(.+)\.(example|sample|template|dist)$/;
|
|
9
|
+
/** Ignored secret files other tools use; init offers them one by one. */
|
|
10
|
+
export const KNOWN_SECRET_FILES = [".dev.vars", "config/master.key", ".envrc"];
|
|
11
|
+
|
|
12
|
+
/** Patterns from .worktreeinclude (gitignore syntax), or the default `.env*`. */
|
|
13
|
+
export function includePatterns(repo) {
|
|
14
|
+
const file = join(repo, ".worktreeinclude");
|
|
15
|
+
if (!exists(file)) return { patterns: DEFAULT_INCLUDE, source: "default" };
|
|
16
|
+
const patterns = readFileSync(file, "utf8")
|
|
17
|
+
.split(/\r?\n/)
|
|
18
|
+
.map((line) => line.trim())
|
|
19
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
20
|
+
return { patterns, source: ".worktreeinclude" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Files in `repo` that match the patterns and that git ignores. Paths use "/". */
|
|
24
|
+
export function sharedCandidates(repo, patterns) {
|
|
25
|
+
if (!patterns.length) return [];
|
|
26
|
+
const args = ["ls-files", "-z", "--others", "--ignored"];
|
|
27
|
+
for (const pattern of patterns) args.push(`--exclude=${pattern}`);
|
|
28
|
+
const listed = gitTry(args, { cwd: repo });
|
|
29
|
+
if (!listed.ok) return [];
|
|
30
|
+
const files = listed.out.split("\0").filter(Boolean);
|
|
31
|
+
const ignored = ignoredPaths(repo, files);
|
|
32
|
+
return files.filter((file) => ignored.has(file)).sort();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Committed templates for files git ignores, as { from, to }, in any folder:
|
|
37
|
+
* `.env.example` -> `.env`, `apps/api/.dev.vars.sample` -> `apps/api/.dev.vars`.
|
|
38
|
+
* A template whose target git does not ignore is a normal file and is left out.
|
|
39
|
+
*/
|
|
40
|
+
export function templateFiles(repo) {
|
|
41
|
+
const listed = gitTry(["ls-files", "-z"], { cwd: repo });
|
|
42
|
+
if (!listed.ok) return [];
|
|
43
|
+
const pairs = [];
|
|
44
|
+
for (const from of listed.out.split("\0").filter(Boolean)) {
|
|
45
|
+
const match = TEMPLATE_SUFFIX.exec(from);
|
|
46
|
+
// A library's own template in a committed vendor folder (showdoc's guzzle phpunit.xml.dist) is not the project's.
|
|
47
|
+
if (match && !match[1].endsWith("/") && !/(^|\/)(vendor|node_modules)\//.test(from)) pairs.push({ from, to: match[1] });
|
|
48
|
+
}
|
|
49
|
+
const ignored = ignoredPaths(repo, pairs.map((pair) => pair.to));
|
|
50
|
+
return pairs.filter((pair) => ignored.has(pair.to)).sort((a, b) => a.to.localeCompare(b.to));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function knownSecretFiles(repo, already) {
|
|
54
|
+
const present = KNOWN_SECRET_FILES.filter((file) => exists(join(repo, file)) && !already.includes(file));
|
|
55
|
+
const ignored = ignoredPaths(repo, present);
|
|
56
|
+
return present.filter((file) => ignored.has(file));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every file below `dir`, relative, with "/" separators. */
|
|
60
|
+
export function listFiles(dir) {
|
|
61
|
+
if (!exists(dir)) return [];
|
|
62
|
+
const out = [];
|
|
63
|
+
const walk = (current) => {
|
|
64
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
65
|
+
const full = join(current, entry.name);
|
|
66
|
+
if (entry.isDirectory()) walk(full);
|
|
67
|
+
else if (entry.isFile() || statSync(full).isFile()) out.push(relative(dir, full).split(sep).join("/"));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(dir);
|
|
71
|
+
return out.sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function copyFile(from, to) {
|
|
75
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
76
|
+
copyFileSync(from, to);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Copies the shared folder into a new workspace. Only files git ignores in the
|
|
81
|
+
* workspace are copied, so a shared file never overwrites a tracked one.
|
|
82
|
+
*/
|
|
83
|
+
export function copyShared(sharedDir, workspace) {
|
|
84
|
+
const files = listFiles(sharedDir);
|
|
85
|
+
const ignored = ignoredPaths(workspace, files);
|
|
86
|
+
const copied = [];
|
|
87
|
+
const skipped = [];
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
if (!ignored.has(file)) {
|
|
90
|
+
skipped.push(file);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
copyFile(join(sharedDir, file), join(workspace, file));
|
|
94
|
+
copied.push(file);
|
|
95
|
+
}
|
|
96
|
+
return { copied, skipped };
|
|
97
|
+
}
|
package/src/status.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { exists } from "./home.mjs";
|
|
2
|
+
import { gitTry, refExists } from "./git.mjs";
|
|
3
|
+
|
|
4
|
+
/** Uncommitted changes, or null when the folder is missing or not readable. */
|
|
5
|
+
export function changeCount(path) {
|
|
6
|
+
if (!exists(path)) return null;
|
|
7
|
+
const status = gitTry(["status", "--porcelain", "--untracked-files=normal"], { cwd: path });
|
|
8
|
+
if (!status.ok) return null;
|
|
9
|
+
return status.out ? status.out.split(/\r?\n/).length : 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Commits the branch has that `base` lacks (ahead), and the reverse (behind). */
|
|
13
|
+
export function aheadBehind(repo, branch, base) {
|
|
14
|
+
if (!refExists(repo, `refs/heads/${branch}`)) return null;
|
|
15
|
+
const result = gitTry(["rev-list", "--left-right", "--count", `${base}...refs/heads/${branch}`], { cwd: repo });
|
|
16
|
+
if (!result.ok) return null;
|
|
17
|
+
const [behind, ahead] = result.out.split(/\s+/).map(Number);
|
|
18
|
+
return { ahead, behind };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function branchTip(repo, branch) {
|
|
22
|
+
const result = gitTry(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: repo });
|
|
23
|
+
return result.ok ? result.out : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True when `commit` is `other` or one of its ancestors (false when either is unknown). */
|
|
27
|
+
export function contains(repo, other, commit) {
|
|
28
|
+
if (commit === other) return true;
|
|
29
|
+
return gitTry(["merge-base", "--is-ancestor", commit, other], { cwd: repo }).ok;
|
|
30
|
+
}
|