@ree-auz/nook 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +421 -2
- 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 +32 -4
- 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 +100 -0
- package/src/commands/forget.mjs +57 -0
- package/src/commands/hooks.mjs +34 -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 +89 -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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Opens a Linear issue in a Nook workspace, in a new terminal tab.
|
|
3
|
+
// Linear runs this file through ~/.linear/coding-tools.json
|
|
4
|
+
// (Work on issue -> Custom script). See README.md next to it.
|
|
5
|
+
//
|
|
6
|
+
// node open-issue.mjs [agent] started by Linear (no window)
|
|
7
|
+
// node open-issue.mjs --run <file> ... inside the new tab
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { normalizeName } from "../../src/names.mjs";
|
|
14
|
+
|
|
15
|
+
const self = fileURLToPath(import.meta.url);
|
|
16
|
+
const nk = fileURLToPath(new URL("../../bin/nk.mjs", import.meta.url));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Puts this Node's folder first on PATH. With a version manager such as fnm
|
|
20
|
+
* or nvm, Linear starts without the shell setup that adds it, and global npm
|
|
21
|
+
* commands (the agent, pnpm for hooks) live next to node.exe.
|
|
22
|
+
*/
|
|
23
|
+
function withNodeOnPath(env = process.env) {
|
|
24
|
+
const key = Object.keys(env).find((name) => name.toUpperCase() === "PATH") ?? "PATH";
|
|
25
|
+
return { ...env, [key]: [dirname(process.execPath), env[key]].filter(Boolean).join(delimiter) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Runs nk with this Node, so the prompt never passes through cmd.exe. */
|
|
29
|
+
function runNk(args, opts = {}) {
|
|
30
|
+
return spawnSync(process.execPath, [nk, ...args], { stdio: "inherit", env: withNodeOnPath(), ...opts });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Creates the issue's workspace and starts the agent, or reopens it. */
|
|
34
|
+
function startWork({ branch, agent, prompt }) {
|
|
35
|
+
const listed = spawnSync(process.execPath, [nk, "ls", "--json"], { encoding: "utf8", env: withNodeOnPath() });
|
|
36
|
+
const existing = listed.status === 0 ? JSON.parse(listed.stdout).find((ws) => !ws.main && (ws.branch === branch || ws.name === normalizeName(branch))) : null;
|
|
37
|
+
// Clicked again for the same issue: reopen the workspace without restarting the task.
|
|
38
|
+
// -y: the branch name Linear suggests is converted without asking.
|
|
39
|
+
const result = existing ? runNk([existing.name, agent]) : runNk(["-b", branch, agent, "-y", "-p", prompt]);
|
|
40
|
+
process.exitCode = result.status ?? 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Inside a tab another terminal opened: the prompt comes in a file. */
|
|
44
|
+
export function insideTab([file, branch, agent, ...more]) {
|
|
45
|
+
const prompt = readFileSync(file, "utf8");
|
|
46
|
+
rmSync(file, { force: true });
|
|
47
|
+
// A Warp tab config written only for this launch.
|
|
48
|
+
if (more[0] === "--cleanup" && more[1]) rmSync(more[1], { force: true });
|
|
49
|
+
startWork({ branch, agent, prompt });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Windows Terminal: -w 0 opens a tab in the most recent window (or a new window). */
|
|
53
|
+
function openInWindowsTerminal({ dir, id, inner }) {
|
|
54
|
+
spawn("wt.exe", ["-w", "0", "new-tab", "-d", dir, "--title", id, ...inner], { detached: true, stdio: "ignore" }).unref();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A PowerShell argument in single quotes. */
|
|
58
|
+
const psQuote = (text) => `'${String(text).replaceAll("'", "''")}'`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Warp: its URI scheme cannot run a command, so write a tab config that
|
|
62
|
+
* does and open it with warp://tab_config/<name>, as Warp's docs advise.
|
|
63
|
+
* The tab deletes the config once it starts.
|
|
64
|
+
*/
|
|
65
|
+
export function openInWarp({ dir, id, inner, env = process.env, start = openUri }) {
|
|
66
|
+
const folder = join(env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "warp", "Warp", "data", "tab_configs");
|
|
67
|
+
mkdirSync(folder, { recursive: true });
|
|
68
|
+
const name = `nk-${id.toLowerCase().replace(/[^a-z0-9-]/g, "-")}-${process.pid}`;
|
|
69
|
+
const config = join(folder, `${name}.toml`);
|
|
70
|
+
const [node, ...args] = [...inner, "--cleanup", config];
|
|
71
|
+
const shell = which("pwsh") ? "pwsh" : "powershell";
|
|
72
|
+
const command = `& ${[node, ...args].map(psQuote).join(" ")}`;
|
|
73
|
+
writeFileSync(
|
|
74
|
+
config,
|
|
75
|
+
[`name = ${JSON.stringify(name)}`, `title = ${JSON.stringify(id)}`, "", "[[panes]]", 'id = "main"', 'type = "terminal"', `shell = ${JSON.stringify(shell)}`, `directory = ${JSON.stringify(dir)}`, `commands = [${JSON.stringify(command)}]`, ""].join("\n"),
|
|
76
|
+
);
|
|
77
|
+
start(`warp://tab_config/${name}`);
|
|
78
|
+
return config;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Opens a URI with its registered app, like PowerShell's Start-Process. */
|
|
82
|
+
function openUri(uri) {
|
|
83
|
+
spawn("cmd.exe", ["/d", "/c", "start", '""', uri], { detached: true, stdio: "ignore", windowsVerbatimArguments: true }).unref();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A command on PATH, without running it. */
|
|
87
|
+
function which(command) {
|
|
88
|
+
const result = spawnSync("where.exe", [command], { encoding: "utf8", windowsHide: true });
|
|
89
|
+
return result.status === 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const TERMINALS = { wt: openInWindowsTerminal, warp: openInWarp };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Closes the terminal window Linear opened for this script, once the work
|
|
96
|
+
* runs in another terminal: its shell is this process's parent.
|
|
97
|
+
*/
|
|
98
|
+
function closeLinearWindow() {
|
|
99
|
+
const parent = spawnSync("tasklist.exe", ["/FI", `PID eq ${process.ppid}`, "/FO", "CSV", "/NH"], { encoding: "utf8", windowsHide: true });
|
|
100
|
+
if (/^"(powershell|pwsh|cmd)\.exe"/i.test(parent.stdout ?? "")) process.kill(process.ppid);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The agent named by a label in Linear's prompt, which lists the issue's
|
|
105
|
+
* labels as <label>Group › Name</label>: with group "Agent", an issue
|
|
106
|
+
* labelled "Agent › Claude" gets `claude`.
|
|
107
|
+
*/
|
|
108
|
+
export function agentFromLabels(prompt, group) {
|
|
109
|
+
if (!group) return null;
|
|
110
|
+
for (const [, text] of prompt.matchAll(/<label>([^<]*)<\/label>/g)) {
|
|
111
|
+
const [labelGroup, name] = text.split("›").map((part) => part.trim());
|
|
112
|
+
if (name && labelGroup === group) return name.toLowerCase().replace(/\s+/g, "-");
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `--name value` from the arguments Linear passes. */
|
|
118
|
+
function option(argv, name) {
|
|
119
|
+
const at = argv.indexOf(name);
|
|
120
|
+
return at >= 0 ? argv[at + 1] : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fromLinear(argv) {
|
|
124
|
+
const terminal = option(argv, "--terminal");
|
|
125
|
+
const agent = agentFromLabels(process.env.LINEAR_PROMPT ?? "", option(argv, "--agent-label")) ?? option(argv, "--agent") ?? "codex";
|
|
126
|
+
const { LINEAR_ISSUE_BRANCH_NAME: branch, LINEAR_WORK_DIR: dir, LINEAR_ISSUE_IDENTIFIER: id = "issue", LINEAR_PROMPT: prompt = "" } = process.env;
|
|
127
|
+
if (!branch || !dir) throw new Error("LINEAR_ISSUE_BRANCH_NAME and LINEAR_WORK_DIR are missing; list them under env in coding-tools.json");
|
|
128
|
+
// Default: work in the terminal Linear opened for the script.
|
|
129
|
+
if (!terminal) {
|
|
130
|
+
process.chdir(dir);
|
|
131
|
+
return startWork({ branch, agent, prompt });
|
|
132
|
+
}
|
|
133
|
+
const open = TERMINALS[terminal];
|
|
134
|
+
if (!open) throw new Error(`Unknown terminal "${terminal}"; use ${Object.keys(TERMINALS).join(" or ")}, or leave --terminal out`);
|
|
135
|
+
if (process.platform !== "win32") throw new Error("--terminal only works on Windows so far");
|
|
136
|
+
// The prompt is long Markdown with quotes and semicolons, so it travels in a file.
|
|
137
|
+
const file = join(tmpdir(), `nk-linear-${id}-${process.pid}.md`);
|
|
138
|
+
writeFileSync(file, prompt);
|
|
139
|
+
open({ dir, id, inner: [process.execPath, self, "--run", file, branch, agent] });
|
|
140
|
+
closeLinearWindow();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (process.argv[1] && resolve(process.argv[1]) === self) {
|
|
144
|
+
const [first, ...rest] = process.argv.slice(2);
|
|
145
|
+
if (first === "--run") insideTab(rest);
|
|
146
|
+
else fromLinear(process.argv.slice(2));
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ree-auz/nook",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "One home folder for your git worktrees: create, open and remove workspaces with shared files, ports and hooks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"nk": "bin/nk.mjs",
|
|
9
|
+
"nook": "bin/nk.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"docs",
|
|
14
|
+
"integrations",
|
|
15
|
+
"skills",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20.12"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test --test-concurrency=1",
|
|
23
|
+
"corpus": "node scripts/corpus.mjs"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"cross-spawn": "7.0.6",
|
|
27
|
+
"smol-toml": "1.9.1",
|
|
28
|
+
"yaml": "2.9.1"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/ree-auz/nook.git"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nook
|
|
3
|
+
description: Use when working in a git worktree managed by nk (Nook), when asked to create, list, open, remove or clean up workspaces with nk, or to set up a repository's .nook hooks.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Working with Nook (`nk`)
|
|
7
|
+
|
|
8
|
+
Nook keeps one git worktree per task under `~/nook/workspace/<repo>/<name>`.
|
|
9
|
+
The workspace name is also the branch name and the folder name.
|
|
10
|
+
|
|
11
|
+
## Am I in a workspace?
|
|
12
|
+
|
|
13
|
+
`NOOK_WORKSPACE` is set when nk started you. These variables are also set:
|
|
14
|
+
|
|
15
|
+
| Variable | Meaning |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `NOOK_WORKSPACE` | workspace and branch name |
|
|
18
|
+
| `NOOK_PATH` | this workspace's folder |
|
|
19
|
+
| `NOOK_REPO` | the main checkout of the repository |
|
|
20
|
+
| `NOOK_SHARED` | the repository's shared folder (holds files such as `.env`) |
|
|
21
|
+
| `NOOK_PORT` | first of ten ports reserved for this workspace (`NOOK_PORT` to `NOOK_PORT + 9`) |
|
|
22
|
+
|
|
23
|
+
Without the variables, `nk ls --json` lists every workspace with its `path`;
|
|
24
|
+
you are in one when your working directory is inside that path.
|
|
25
|
+
|
|
26
|
+
## Rules
|
|
27
|
+
|
|
28
|
+
- Work and commit only inside your own workspace folder. Do not edit the main
|
|
29
|
+
checkout (`NOOK_REPO`) or another workspace.
|
|
30
|
+
- Use ports from `NOOK_PORT` to `NOOK_PORT + 9` for servers you start, so
|
|
31
|
+
workspaces do not collide.
|
|
32
|
+
- Files copied from the shared folder (such as `.env`) are local settings.
|
|
33
|
+
Do not commit them and do not print their contents.
|
|
34
|
+
- Do not run `nk rm` or `nk clean` on your own workspace: nk refuses while
|
|
35
|
+
you are inside it. Leave removal to the person or the tool that started you.
|
|
36
|
+
- Hooks in `.nook/` run with the person's approval. Do not edit them to get
|
|
37
|
+
past a failure; report the failure and the log path nk printed. Never pass
|
|
38
|
+
`--allow-hooks` yourself: the person reads and allows the hooks.
|
|
39
|
+
|
|
40
|
+
## Setting up hooks
|
|
41
|
+
|
|
42
|
+
When asked to set up Nook hooks: if you were started by `nk hooks write
|
|
43
|
+
<agent>`, nk has already generated hooks from CI and the Compose file; finish what its prompt lists
|
|
44
|
+
as not translated. Otherwise run `nk hooks write` first. Follow
|
|
45
|
+
[docs/setup-hooks.md](https://github.com/ree-auz/nook/blob/main/docs/setup-hooks.md),
|
|
46
|
+
run `nk hooks test` in the folder you work in and fix the hooks until it
|
|
47
|
+
passes. Delete the first line of a generated hook you edit. Do not commit or
|
|
48
|
+
push unless asked.
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
| Command | Use |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `nk -b <name> [agent] [-p <prompt>]` | Create a workspace from the default branch (or an existing branch of that name) |
|
|
55
|
+
| `nk <name>` | Print a workspace's path; with an agent, start it there |
|
|
56
|
+
| `nk ls --json` | Every workspace of this repository: `name`, `repo`, `path`, `port`, `changes`, `ahead`, `behind`, `pr` (`number`, `state`, `ci`) |
|
|
57
|
+
| `nk rm <name>... -n` | Show what removing would back up and delete, change nothing |
|
|
58
|
+
| `nk clean -n` | Show which workspaces are merged and would be removed |
|
|
59
|
+
| `nk hooks test` | Run this folder's hooks in a temporary workspace; fails on leftovers |
|
|
60
|
+
| `nk hooks` | The repository's hooks: allowed, tested |
|
|
61
|
+
| `nk doctor` | Check nk's tools; exit 1 when something is broken |
|
|
62
|
+
|
|
63
|
+
Pass `-y` to `nk rm` or `nk clean` only when the person asked for the
|
|
64
|
+
removal. Both back up uncommitted changes and unpushed commits to
|
|
65
|
+
`~/nook/backup/<repo>/` first.
|
|
66
|
+
|
|
67
|
+
## Reading results
|
|
68
|
+
|
|
69
|
+
Exit code 0 means success. On failure, stderr starts with `✗ what failed`,
|
|
70
|
+
followed by `○` lines saying why and what to do next. Follow those lines
|
|
71
|
+
instead of guessing.
|
package/src/agents.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { delimiter, dirname, extname, isAbsolute, join } from "node:path";
|
|
3
|
+
import { NookError } from "./errors.mjs";
|
|
4
|
+
import spawn from "cross-spawn";
|
|
5
|
+
|
|
6
|
+
/** Command-line coding agents init looks for. Any other command works too. */
|
|
7
|
+
export const KNOWN_AGENTS = ["claude", "codex", "gemini", "grok", "opencode", "pi", "aider", "amp", "cursor-agent", "qwen"];
|
|
8
|
+
|
|
9
|
+
function isFile(path) {
|
|
10
|
+
try {
|
|
11
|
+
return statSync(path).isFile();
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Finds a command on PATH, like `which` / `where`. */
|
|
18
|
+
export function which(command, env = process.env, platform = process.platform) {
|
|
19
|
+
const extensions =
|
|
20
|
+
platform === "win32" ? ["", ...(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((ext) => ext.toLowerCase())] : [""];
|
|
21
|
+
if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
|
|
22
|
+
for (const ext of extensions) if (isFile(command + ext)) return command + ext;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const dirs = (env.PATH || env.Path || "").split(platform === "win32" ? ";" : delimiter).filter(Boolean);
|
|
26
|
+
for (const dir of dirs)
|
|
27
|
+
for (const ext of extensions) {
|
|
28
|
+
if (platform === "win32" && ext === "" && !extname(command)) continue;
|
|
29
|
+
const full = join(dir, command + ext);
|
|
30
|
+
if (isFile(full)) return full;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function commandVersion(path) {
|
|
36
|
+
const result = spawn.sync(path, ["--version"], { encoding: "utf8", timeout: 10000, windowsHide: true });
|
|
37
|
+
if (result.status !== 0 || !result.stdout) return "";
|
|
38
|
+
return result.stdout.trim().split(/\r?\n/)[0].slice(0, 80);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function detectAgents(env = process.env, names = KNOWN_AGENTS) {
|
|
42
|
+
const found = [];
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
const path = which(name, env);
|
|
45
|
+
if (path) found.push({ name, path, version: commandVersion(path) });
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Command for `nk <name> <agent> [-p prompt] [-- extra]`. */
|
|
51
|
+
export function agentCommand(config, agent, { prompt, extra = [] } = {}, env = process.env) {
|
|
52
|
+
const entry = config.agents?.[agent];
|
|
53
|
+
const command = entry?.path && isFile(entry.path) ? entry.path : which(agent, env) ?? agent;
|
|
54
|
+
const args = [...(entry?.args ?? []), ...extra];
|
|
55
|
+
if (prompt) args.push(prompt);
|
|
56
|
+
return { command, args };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The program behind an npm .cmd shim on Windows, read the way npm's own
|
|
61
|
+
* read-cmd-shim does: the quoted path after %dp0% (or %~dp0) followed by %*.
|
|
62
|
+
*/
|
|
63
|
+
export function cmdShimTarget(shimPath) {
|
|
64
|
+
let text;
|
|
65
|
+
try {
|
|
66
|
+
text = readFileSync(shimPath, "utf8");
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const match = /"%(?:~dp0|dp0%)\\?([^"]+?)"\s+%\*/.exec(text);
|
|
71
|
+
return match ? join(dirname(shimPath), ...match[1].split(/[\\/]/)) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* cmd.exe ends a command at the first line break, so a .cmd agent would get
|
|
76
|
+
* only the first line of a multi-line prompt. Run the program behind an npm
|
|
77
|
+
* shim directly instead.
|
|
78
|
+
*/
|
|
79
|
+
export function windowsDirect({ command, args }, platform = process.platform) {
|
|
80
|
+
if (platform !== "win32" || !/\.(cmd|bat)$/i.test(command) || !args.some((arg) => /[\r\n]/.test(arg))) return { command, args };
|
|
81
|
+
const target = cmdShimTarget(command);
|
|
82
|
+
if (target && /\.(c|m)?js$/i.test(target) && isFile(target)) return { command: process.execPath, args: [target, ...args] };
|
|
83
|
+
if (target && /\.exe$/i.test(target) && isFile(target)) return { command: target, args };
|
|
84
|
+
throw new NookError(`${command} runs through cmd.exe, which cannot pass a prompt with line breaks; put the prompt on one line`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function launch(spec, { cwd, env }) {
|
|
88
|
+
const { command, args } = windowsDirect(spec);
|
|
89
|
+
const result = spawn.sync(command, args, { cwd, env, stdio: "inherit" });
|
|
90
|
+
if (result.error) throw result.error;
|
|
91
|
+
return result.status ?? 1;
|
|
92
|
+
}
|
package/src/backup.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { exists } from "./home.mjs";
|
|
4
|
+
import { git, gitTry, refExists, statusLines } from "./git.mjs";
|
|
5
|
+
import { copyFile } from "./shared.mjs";
|
|
6
|
+
|
|
7
|
+
/** Commits on `branch` that are on no remote and not in the base branch. */
|
|
8
|
+
export function unpushedCommits(repo, branch, base) {
|
|
9
|
+
if (!refExists(repo, `refs/heads/${branch}`)) return [];
|
|
10
|
+
const args = ["rev-list", `refs/heads/${branch}`, "--not", "--remotes"];
|
|
11
|
+
if (base && base !== branch) args.push(base);
|
|
12
|
+
const result = gitTry(args, { cwd: repo });
|
|
13
|
+
return result.ok && result.out ? result.out.split(/\r?\n/) : [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isMerged(repo, branch, base) {
|
|
17
|
+
if (!refExists(repo, `refs/heads/${branch}`)) return true;
|
|
18
|
+
return gitTry(["merge-base", "--is-ancestor", `refs/heads/${branch}`, base], { cwd: repo }).ok;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a removal would lose: uncommitted changes, unpushed commits, unmerged branch. */
|
|
22
|
+
export function inspectWorkspace({ repo, path, branch, base }) {
|
|
23
|
+
const present = exists(path);
|
|
24
|
+
return {
|
|
25
|
+
present,
|
|
26
|
+
changes: present ? statusLines(path) : [],
|
|
27
|
+
unpushed: unpushedCommits(repo, branch, base),
|
|
28
|
+
merged: isMerged(repo, branch, base),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function needsBackup(state) {
|
|
33
|
+
return state.changes.length > 0 || state.unpushed.length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stamp(date = new Date()) {
|
|
37
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
38
|
+
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function backupDir(backupRoot, name, date = new Date()) {
|
|
42
|
+
let dir = join(backupRoot, `${name}-${stamp(date)}`);
|
|
43
|
+
for (let n = 2; exists(dir); n++) dir = join(backupRoot, `${name}-${stamp(date)}-${n}`);
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Saves what removal would lose:
|
|
49
|
+
* changes.patch tracked changes (staged and unstaged), apply with `git apply`
|
|
50
|
+
* untracked/ new files git does not ignore
|
|
51
|
+
* commits.bundle unpushed commits, restore with `git fetch <bundle> <branch>`
|
|
52
|
+
* info.txt workspace, branch, HEAD, time
|
|
53
|
+
*/
|
|
54
|
+
export function createBackup({ repo, path, branch, base, state, dir }) {
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
const info = [`workspace: ${branch}`, `path: ${path}`, `time: ${new Date().toISOString()}`];
|
|
57
|
+
if (state.present) {
|
|
58
|
+
const head = gitTry(["rev-parse", "HEAD"], { cwd: path });
|
|
59
|
+
if (head.ok) info.push(`head: ${head.out}`);
|
|
60
|
+
const diff = gitTry(["diff", "--binary", "HEAD"], { cwd: path });
|
|
61
|
+
if (diff.ok && diff.out) writeFileSync(join(dir, "changes.patch"), diff.out + "\n");
|
|
62
|
+
const untracked = git(["ls-files", "-z", "--others", "--exclude-standard"], { cwd: path }).split("\0").filter(Boolean);
|
|
63
|
+
for (const file of untracked) copyFile(join(path, file), join(dir, "untracked", file));
|
|
64
|
+
if (untracked.length) info.push(`untracked files: ${untracked.length}`);
|
|
65
|
+
}
|
|
66
|
+
if (state.unpushed.length) {
|
|
67
|
+
const args = ["bundle", "create", join(dir, "commits.bundle"), `refs/heads/${branch}`, "--not", "--remotes"];
|
|
68
|
+
if (base && base !== branch) args.push(base);
|
|
69
|
+
git(args, { cwd: repo });
|
|
70
|
+
info.push(`unpushed commits: ${state.unpushed.length}`);
|
|
71
|
+
}
|
|
72
|
+
writeFileSync(join(dir, "info.txt"), info.join("\n") + "\n");
|
|
73
|
+
return dir;
|
|
74
|
+
}
|