@ree-auz/nook 0.1.0 → 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.
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 +84 -84
  6. package/integrations/linear/coding-tools.json +7 -7
  7. package/integrations/linear/open-issue.mjs +147 -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/prompt.mjs CHANGED
@@ -1,100 +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
- }
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 CHANGED
@@ -1,97 +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
- }
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 CHANGED
@@ -1,30 +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
- }
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
+ }
package/src/tools.mjs CHANGED
@@ -1,23 +1,23 @@
1
- import spawn from "cross-spawn";
2
- import { which } from "./agents.mjs";
3
-
4
- function run(command, args) {
5
- const result = spawn.sync(command, args, { encoding: "utf8", timeout: 15000, windowsHide: true });
6
- return { ok: !result.error && result.status === 0, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
7
- }
8
-
9
- /** gh and docker are optional helpers; this only reports their state. */
10
- export function toolStatus(env = process.env) {
11
- const status = {};
12
- const gh = which("gh", env);
13
- status.gh = !gh ? "not installed" : run(gh, ["auth", "status"]).ok ? "logged in" : "not logged in";
14
- const docker = which("docker", env);
15
- status.docker = !docker ? "not installed" : run(docker, ["info", "--format", "{{.ServerVersion}}"]).ok ? "running" : "not running";
16
- return status;
17
- }
18
-
19
- export function gitVersion() {
20
- const result = run("git", ["--version"]);
21
- const match = /(\d+)\.(\d+)/.exec(result.out);
22
- return result.ok && match ? { text: result.out, major: Number(match[1]), minor: Number(match[2]) } : null;
23
- }
1
+ import spawn from "cross-spawn";
2
+ import { which } from "./agents.mjs";
3
+
4
+ function run(command, args) {
5
+ const result = spawn.sync(command, args, { encoding: "utf8", timeout: 15000, windowsHide: true });
6
+ return { ok: !result.error && result.status === 0, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
7
+ }
8
+
9
+ /** gh and docker are optional helpers; this only reports their state. */
10
+ export function toolStatus(env = process.env) {
11
+ const status = {};
12
+ const gh = which("gh", env);
13
+ status.gh = !gh ? "not installed" : run(gh, ["auth", "status"]).ok ? "logged in" : "not logged in";
14
+ const docker = which("docker", env);
15
+ status.docker = !docker ? "not installed" : run(docker, ["info", "--format", "{{.ServerVersion}}"]).ok ? "running" : "not running";
16
+ return status;
17
+ }
18
+
19
+ export function gitVersion() {
20
+ const result = run("git", ["--version"]);
21
+ const match = /(\d+)\.(\d+)/.exec(result.out);
22
+ return result.ok && match ? { text: result.out, major: Number(match[1]), minor: Number(match[2]) } : null;
23
+ }
package/src/ui.mjs CHANGED
@@ -1,60 +1,60 @@
1
- /**
2
- * Line prefixes in the style of worktrunk: ◎ a step that runs now, ✓ done,
3
- * ▲ needs attention, ✗ failed, ○ information, ┃ an item under the line above,
4
- * ? a question.
5
- * Colors only on a terminal, and never when NO_COLOR is set (no-color.org).
6
- */
7
- const UNICODE = { step: "◎", ok: "✓", warn: "▲", fail: "✗", info: "○", item: "┃", ask: "?" };
8
- // Older Windows consoles show ✓ and ✗ as "?"; √ and × exist in their code pages
9
- // (the same fallbacks as the figures package).
10
- const FALLBACK = { ...UNICODE, ok: "√", fail: "×" };
11
- const COLORS = { step: 36, ok: 32, warn: 33, fail: 31, info: 2, item: 2, ask: 35 };
12
-
13
- /** The checks of the is-unicode-supported package, plus Warp. */
14
- export function unicodeSupported(env = process.env, platform = process.platform) {
15
- if (platform !== "win32") return env.TERM !== "linux";
16
- return (
17
- Boolean(env.WT_SESSION) ||
18
- Boolean(env.TERMINUS_SUBLIME) ||
19
- env.ConEmuTask === "{cmd::Cmder}" ||
20
- ["Terminus-Sublime", "vscode", "WarpTerminal"].includes(env.TERM_PROGRAM) ||
21
- ["xterm-256color", "alacritty", "rxvt-unicode", "rxvt-unicode-256color"].includes(env.TERM) ||
22
- env.TERMINAL_EMULATOR === "JetBrains-JediTerm"
23
- );
24
- }
25
-
26
- const SYMBOLS = unicodeSupported() ? UNICODE : FALLBACK;
27
-
28
- export function useColor(stream, env = process.env) {
29
- return Boolean(stream?.isTTY) && !("NO_COLOR" in env) && env.TERM !== "dumb";
30
- }
31
-
32
- export function line(kind, text, color = false) {
33
- const symbol = SYMBOLS[kind];
34
- const mark = color ? `\x1b[${COLORS[kind]}m${symbol}\x1b[0m` : symbol;
35
- return kind === "item" ? ` ${mark} ${text}` : `${mark} ${text}`;
36
- }
37
-
38
- /**
39
- * Indents another program's output by two columns, under the text of the line
40
- * above it. Docker Compose starts its lines with one space; that space goes.
41
- * Returns a function that takes chunks and returns them indented.
42
- */
43
- export function indenter(width = 2) {
44
- const pad = " ".repeat(width);
45
- let state = "start"; // start of a line, after one leading space, or inside it
46
- return (chunk) => {
47
- let out = "";
48
- for (const char of String(chunk)) {
49
- const newline = char === "\n" || char === "\r";
50
- if (state === "mid") out += char;
51
- else if (newline) out += char;
52
- else if (state === "start" && char === " ") {
53
- state = "space";
54
- continue;
55
- } else out += pad + (state === "space" && char === " " ? " " : char);
56
- state = newline ? "start" : "mid";
57
- }
58
- return out;
59
- };
60
- }
1
+ /**
2
+ * Line prefixes in the style of worktrunk: ◎ a step that runs now, ✓ done,
3
+ * ▲ needs attention, ✗ failed, ○ information, ┃ an item under the line above,
4
+ * ? a question.
5
+ * Colors only on a terminal, and never when NO_COLOR is set (no-color.org).
6
+ */
7
+ const UNICODE = { step: "◎", ok: "✓", warn: "▲", fail: "✗", info: "○", item: "┃", ask: "?" };
8
+ // Older Windows consoles show ✓ and ✗ as "?"; √ and × exist in their code pages
9
+ // (the same fallbacks as the figures package).
10
+ const FALLBACK = { ...UNICODE, ok: "√", fail: "×" };
11
+ const COLORS = { step: 36, ok: 32, warn: 33, fail: 31, info: 2, item: 2, ask: 35 };
12
+
13
+ /** The checks of the is-unicode-supported package, plus Warp. */
14
+ export function unicodeSupported(env = process.env, platform = process.platform) {
15
+ if (platform !== "win32") return env.TERM !== "linux";
16
+ return (
17
+ Boolean(env.WT_SESSION) ||
18
+ Boolean(env.TERMINUS_SUBLIME) ||
19
+ env.ConEmuTask === "{cmd::Cmder}" ||
20
+ ["Terminus-Sublime", "vscode", "WarpTerminal"].includes(env.TERM_PROGRAM) ||
21
+ ["xterm-256color", "alacritty", "rxvt-unicode", "rxvt-unicode-256color"].includes(env.TERM) ||
22
+ env.TERMINAL_EMULATOR === "JetBrains-JediTerm"
23
+ );
24
+ }
25
+
26
+ const SYMBOLS = unicodeSupported() ? UNICODE : FALLBACK;
27
+
28
+ export function useColor(stream, env = process.env) {
29
+ return Boolean(stream?.isTTY) && !("NO_COLOR" in env) && env.TERM !== "dumb";
30
+ }
31
+
32
+ export function line(kind, text, color = false) {
33
+ const symbol = SYMBOLS[kind];
34
+ const mark = color ? `\x1b[${COLORS[kind]}m${symbol}\x1b[0m` : symbol;
35
+ return kind === "item" ? ` ${mark} ${text}` : `${mark} ${text}`;
36
+ }
37
+
38
+ /**
39
+ * Indents another program's output by two columns, under the text of the line
40
+ * above it. Docker Compose starts its lines with one space; that space goes.
41
+ * Returns a function that takes chunks and returns them indented.
42
+ */
43
+ export function indenter(width = 2) {
44
+ const pad = " ".repeat(width);
45
+ let state = "start"; // start of a line, after one leading space, or inside it
46
+ return (chunk) => {
47
+ let out = "";
48
+ for (const char of String(chunk)) {
49
+ const newline = char === "\n" || char === "\r";
50
+ if (state === "mid") out += char;
51
+ else if (newline) out += char;
52
+ else if (state === "start" && char === " ") {
53
+ state = "space";
54
+ continue;
55
+ } else out += pad + (state === "space" && char === " " ? " " : char);
56
+ state = newline ? "start" : "mid";
57
+ }
58
+ return out;
59
+ };
60
+ }