@enke.dev/logbox 0.0.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 +140 -0
- package/dist/cli.mjs +425 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 enke.dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# logbox
|
|
2
|
+
|
|
3
|
+
Wraps a long-running command and pins a configurable info box to the bottom of the terminal. The
|
|
4
|
+
wrapped command's output scrolls above it, unaltered — colors, spinners and all.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
[dev:docs] vite v7.1.0 ready in 431 ms
|
|
8
|
+
[dev:components] build started…
|
|
9
|
+
╭─ dev servers ──────────────────────────────────────────────────────────────╮
|
|
10
|
+
│ docs..............................................https://localhost:5173 │
|
|
11
|
+
│ components (web)..................................https://localhost:5174 │
|
|
12
|
+
│ components (react)................................https://localhost:5175 │
|
|
13
|
+
╰────────────────────────────────────────────────────────────────────────────╯
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## tl;dr
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
# as a dev dependency
|
|
20
|
+
npm i -D @enke.dev/logbox
|
|
21
|
+
# or globally
|
|
22
|
+
npm i -g @enke.dev/logbox
|
|
23
|
+
|
|
24
|
+
# wrap whatever you already run
|
|
25
|
+
logbox pnpm -r --parallel --stream dev
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or without installing:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npx @enke.dev/logbox pnpm -r --parallel --stream dev
|
|
32
|
+
pnpm dlx @enke.dev/logbox pnpm -r --parallel --stream dev
|
|
33
|
+
bunx --bun @enke.dev/logbox pnpm -r --parallel --stream dev
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Typical use is a `package.json` script:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"scripts": {
|
|
41
|
+
"dev": "logbox pnpm -r --parallel --stream dev:*"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## How it works
|
|
47
|
+
|
|
48
|
+
The box lives in terminal lines that are reserved by shrinking the scroll region, so it is not part
|
|
49
|
+
of the output stream: nothing scrolls it away, and nothing is rewritten or filtered on the way
|
|
50
|
+
through. Redraws are coalesced, so a burst of output costs one render.
|
|
51
|
+
|
|
52
|
+
Without a TTY (pipes, CI logs) there is nothing to pin the box to, so the content is printed once up
|
|
53
|
+
front as a flat list and the child inherits stdio directly.
|
|
54
|
+
|
|
55
|
+
Signals (`SIGINT`, `SIGTERM`, `SIGHUP`) are handed down to the child, which decides when to go away.
|
|
56
|
+
logbox exits with the child's exit code, so it stays transparent in scripts.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Content is an **array of rows**; each row is an **array of strings**:
|
|
61
|
+
|
|
62
|
+
- the first cell is left aligned,
|
|
63
|
+
- the last cell is right aligned,
|
|
64
|
+
- a dot leader connects them,
|
|
65
|
+
- middle cells (3+ per row) get their own aligned column,
|
|
66
|
+
- a row with a single cell renders as a plain label.
|
|
67
|
+
|
|
68
|
+
### `.logbox.json`
|
|
69
|
+
|
|
70
|
+
Looked up in the current directory, then every parent up to the filesystem root — so a file in the
|
|
71
|
+
repo root is found from any nested cwd — and finally in the user home directory (`~/.logbox.json`)
|
|
72
|
+
as a global fallback. The nearest file wins.
|
|
73
|
+
|
|
74
|
+
Bare rows:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
[
|
|
78
|
+
["docs", "https://localhost:5173"],
|
|
79
|
+
["components", "https://localhost:5174"]
|
|
80
|
+
]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Or the object form, which adds a title:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"$schema": "https://enke-dev.github.io/logbox/logbox.schema.json",
|
|
88
|
+
"title": "dev servers",
|
|
89
|
+
"content": [
|
|
90
|
+
["docs", "https://localhost:5173"],
|
|
91
|
+
["components", "https://localhost:5174"]
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The JSON schema is published with every release at
|
|
97
|
+
**<https://enke-dev.github.io/logbox/logbox.schema.json>** — reference it via `$schema` for editor
|
|
98
|
+
completion and validation.
|
|
99
|
+
|
|
100
|
+
### Inline
|
|
101
|
+
|
|
102
|
+
`--content` takes the same JSON and skips the file lookup entirely:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
logbox -c '[["docs","https://localhost:5173"]]' -t 'dev servers' npm run dev
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Options
|
|
109
|
+
|
|
110
|
+
Only the options ahead of the wrapped command belong to logbox — everything from the first non-flag
|
|
111
|
+
token on is passed through verbatim, so the wrapped command keeps its own flags
|
|
112
|
+
(`logbox pnpm -r --parallel dev`). Use `--` if the command itself starts with a dash.
|
|
113
|
+
|
|
114
|
+
| Option | Description |
|
|
115
|
+
| ---------------------- | --------------------------------------------------------- |
|
|
116
|
+
| `-c, --content <json>` | Box content as inline JSON; overrides any config file |
|
|
117
|
+
| `-t, --title <text>` | Title rendered into the top border (default: `logbox`) |
|
|
118
|
+
| `--config <path>` | Read the content from this file instead of looking one up |
|
|
119
|
+
| `-h, --help` | Show help |
|
|
120
|
+
| `-v, --version` | Print the version |
|
|
121
|
+
|
|
122
|
+
With no content configured anywhere, logbox draws no box and simply runs the command.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
bun install
|
|
128
|
+
bun run dev -c '[["a","b"]]' -- sleep 5 # run from source
|
|
129
|
+
bun run check # types
|
|
130
|
+
bun run lint # eslint + prettier
|
|
131
|
+
bun run test # specs under bun and node
|
|
132
|
+
bun run build # dist/cli.mjs
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Releases are trunk based: pushing to `main` cuts the semver release from the conventional commits
|
|
136
|
+
since the last tag and republishes the schema to GitHub Pages.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import process3, { argv, stderr as stderr2, stdout as stdout2 } from "node:process";
|
|
5
|
+
// package.json
|
|
6
|
+
var package_default = {
|
|
7
|
+
name: "@enke.dev/logbox",
|
|
8
|
+
version: "0.0.1",
|
|
9
|
+
license: "MIT",
|
|
10
|
+
packageManager: "bun@1.3.14",
|
|
11
|
+
publishConfig: {
|
|
12
|
+
registry: "https://registry.npmjs.org",
|
|
13
|
+
access: "public"
|
|
14
|
+
},
|
|
15
|
+
description: "Wraps a long-running command and pins a configurable info box to the bottom of the terminal.",
|
|
16
|
+
keywords: [
|
|
17
|
+
"cli",
|
|
18
|
+
"terminal",
|
|
19
|
+
"tty",
|
|
20
|
+
"dev-server",
|
|
21
|
+
"box"
|
|
22
|
+
],
|
|
23
|
+
author: {
|
|
24
|
+
name: "David Enke",
|
|
25
|
+
email: "david@enke.dev"
|
|
26
|
+
},
|
|
27
|
+
repository: {
|
|
28
|
+
type: "git",
|
|
29
|
+
url: "git+https://github.com/enke-dev/logbox.git"
|
|
30
|
+
},
|
|
31
|
+
homepage: "https://enke-dev.github.io/logbox/",
|
|
32
|
+
type: "module",
|
|
33
|
+
bin: {
|
|
34
|
+
logbox: "./dist/cli.mjs"
|
|
35
|
+
},
|
|
36
|
+
files: [
|
|
37
|
+
"dist/cli.mjs"
|
|
38
|
+
],
|
|
39
|
+
engines: {
|
|
40
|
+
node: ">=22"
|
|
41
|
+
},
|
|
42
|
+
scripts: {
|
|
43
|
+
prepare: "bun run build",
|
|
44
|
+
check: "tsgo --noEmit",
|
|
45
|
+
lint: "run-p lint:*",
|
|
46
|
+
"lint:eslint": "bun --bun eslint --config eslint.config.ts",
|
|
47
|
+
"lint:prettier": "bun --bun prettier --config prettier.config.ts --check .",
|
|
48
|
+
format: "run-p format:*",
|
|
49
|
+
"format:eslint": "bun --bun eslint --config eslint.config.ts --fix",
|
|
50
|
+
"format:prettier": "bun --bun prettier --config prettier.config.ts --write .",
|
|
51
|
+
test: "run-p -c test:*",
|
|
52
|
+
"test:bun": "bun test --reporter=junit --reporter-outfile=reports/junit.bun.xml",
|
|
53
|
+
"test:node": 'node --import tsx --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/junit.node.xml "src/**/*.spec.ts"',
|
|
54
|
+
dev: "bun run --bun src/cli.ts",
|
|
55
|
+
build: "bun build ./src/cli.ts --target=node --outfile dist/cli.mjs"
|
|
56
|
+
},
|
|
57
|
+
devDependencies: {
|
|
58
|
+
"@enke.dev/lint": "0.13.8",
|
|
59
|
+
"@tsconfig/strictest": "2.0.8",
|
|
60
|
+
"@types/bun": "1.3.14",
|
|
61
|
+
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
62
|
+
eslint: "10.8.1",
|
|
63
|
+
jiti: "2.7.0",
|
|
64
|
+
"npm-run-all2": "9.0.3",
|
|
65
|
+
prettier: "3.9.6",
|
|
66
|
+
tsx: "4.23.12",
|
|
67
|
+
typescript: "6.0.3"
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// src/args/args.ts
|
|
72
|
+
import { parseArgs } from "node:util";
|
|
73
|
+
var OPTIONS = {
|
|
74
|
+
content: { type: "string", short: "c" },
|
|
75
|
+
title: { type: "string", short: "t" },
|
|
76
|
+
config: { type: "string" },
|
|
77
|
+
help: { type: "boolean", short: "h", default: false },
|
|
78
|
+
version: { type: "boolean", short: "v", default: false }
|
|
79
|
+
};
|
|
80
|
+
var VALUE_FLAGS = new Set(["-c", "--content", "-t", "--title", "--config"]);
|
|
81
|
+
function split(argv) {
|
|
82
|
+
let index = 0;
|
|
83
|
+
while (index < argv.length) {
|
|
84
|
+
const token = argv[index];
|
|
85
|
+
if (token === "--") {
|
|
86
|
+
return { options: argv.slice(0, index), command: argv.slice(index + 1) };
|
|
87
|
+
}
|
|
88
|
+
if (!token.startsWith("-") || token === "-") {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
index += VALUE_FLAGS.has(token) ? 2 : 1;
|
|
92
|
+
}
|
|
93
|
+
return { options: argv.slice(0, index), command: argv.slice(index) };
|
|
94
|
+
}
|
|
95
|
+
function parseCommandLine(argv) {
|
|
96
|
+
const { options, command } = split(argv);
|
|
97
|
+
const { values } = parseArgs({ args: options, options: OPTIONS });
|
|
98
|
+
return { ...values, command };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/config/config.ts
|
|
102
|
+
import { readFile } from "node:fs/promises";
|
|
103
|
+
import { homedir } from "node:os";
|
|
104
|
+
import { dirname, join, resolve } from "node:path";
|
|
105
|
+
var DEFAULT_TITLE = "logbox";
|
|
106
|
+
var CONFIG_FILE = ".logbox.json";
|
|
107
|
+
function emptyConfig() {
|
|
108
|
+
return { title: DEFAULT_TITLE, content: [], source: "none" };
|
|
109
|
+
}
|
|
110
|
+
function configCandidates(cwd, home) {
|
|
111
|
+
const climb = (directory, seen) => {
|
|
112
|
+
const parent = dirname(directory);
|
|
113
|
+
return parent === directory ? [...seen, directory] : climb(parent, [...seen, directory]);
|
|
114
|
+
};
|
|
115
|
+
const paths = climb(resolve(cwd), []).map((directory) => join(directory, CONFIG_FILE));
|
|
116
|
+
const global = join(home, CONFIG_FILE);
|
|
117
|
+
return paths.includes(global) ? paths : [...paths, global];
|
|
118
|
+
}
|
|
119
|
+
function toRows(value, source) {
|
|
120
|
+
if (!Array.isArray(value)) {
|
|
121
|
+
throw new Error(`${source}: expected an array of rows, e.g. [["docs", "https://localhost"]]`);
|
|
122
|
+
}
|
|
123
|
+
return value.map((row, index) => {
|
|
124
|
+
if (!Array.isArray(row) || row.some((cell) => typeof cell !== "string")) {
|
|
125
|
+
throw new Error(`${source}: row ${index} must be an array of strings`);
|
|
126
|
+
}
|
|
127
|
+
if (row.length === 0) {
|
|
128
|
+
throw new Error(`${source}: row ${index} must hold at least one entry`);
|
|
129
|
+
}
|
|
130
|
+
return row;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function normalize(value, source) {
|
|
134
|
+
if (Array.isArray(value)) {
|
|
135
|
+
return { title: DEFAULT_TITLE, content: toRows(value, source), source };
|
|
136
|
+
}
|
|
137
|
+
if (typeof value !== "object" || value === null) {
|
|
138
|
+
throw new Error(`${source}: expected an array of rows or an object with a "content" array`);
|
|
139
|
+
}
|
|
140
|
+
const { title, content } = value;
|
|
141
|
+
if (title !== undefined && typeof title !== "string") {
|
|
142
|
+
throw new Error(`${source}: "title" must be a string`);
|
|
143
|
+
}
|
|
144
|
+
return { title: title ?? DEFAULT_TITLE, content: toRows(content, source), source };
|
|
145
|
+
}
|
|
146
|
+
function parse(raw, source) {
|
|
147
|
+
try {
|
|
148
|
+
return normalize(JSON.parse(raw), source);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof SyntaxError) {
|
|
151
|
+
throw new Error(`${source}: invalid JSON — ${error.message}`, { cause: error });
|
|
152
|
+
}
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function readConfig(path) {
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = await readFile(path, "utf8");
|
|
160
|
+
} catch {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
return parse(raw, path);
|
|
164
|
+
}
|
|
165
|
+
async function loadConfig(options = {}) {
|
|
166
|
+
const { content, title, config, cwd = process.cwd(), home = homedir() } = options;
|
|
167
|
+
const withTitle = (resolved) => title === undefined ? resolved : { ...resolved, title };
|
|
168
|
+
if (content !== undefined) {
|
|
169
|
+
return withTitle(parse(content, "--content"));
|
|
170
|
+
}
|
|
171
|
+
if (config !== undefined) {
|
|
172
|
+
const explicit = await readConfig(resolve(cwd, config));
|
|
173
|
+
if (explicit === undefined) {
|
|
174
|
+
throw new Error(`${config}: config file not found`);
|
|
175
|
+
}
|
|
176
|
+
return withTitle(explicit);
|
|
177
|
+
}
|
|
178
|
+
const candidates = configCandidates(cwd, home);
|
|
179
|
+
const found = await candidates.reduce(async (previous, path) => await previous ?? await readConfig(path), Promise.resolve(undefined));
|
|
180
|
+
return withTitle(found ?? emptyConfig());
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/render/ansi.ts
|
|
184
|
+
var ESC = "\x1B";
|
|
185
|
+
var CSI = `${ESC}[`;
|
|
186
|
+
var RESET = `${CSI}0m`;
|
|
187
|
+
var RED = `${CSI}31m`;
|
|
188
|
+
var DIM = `${CSI}2m`;
|
|
189
|
+
var BOLD = `${CSI}1m`;
|
|
190
|
+
var CYAN = `${CSI}36m`;
|
|
191
|
+
var GREEN = `${CSI}32m`;
|
|
192
|
+
var COLOR = {
|
|
193
|
+
border: `${CSI}38;5;39m`,
|
|
194
|
+
title: `${CSI}1;38;5;45m`,
|
|
195
|
+
head: `${CSI}38;5;250m`,
|
|
196
|
+
leader: `${CSI}38;5;240m`,
|
|
197
|
+
tail: `${CSI}4;38;5;79m`
|
|
198
|
+
};
|
|
199
|
+
var SAVE_CURSOR = `${ESC}7`;
|
|
200
|
+
var RESTORE_CURSOR = `${ESC}8`;
|
|
201
|
+
var CLEAR_LINE = `${CSI}2K`;
|
|
202
|
+
var RESET_SCROLL_REGION = `${CSI}r`;
|
|
203
|
+
function cursorTo(row, column = 1) {
|
|
204
|
+
return `${CSI}${row};${column}H`;
|
|
205
|
+
}
|
|
206
|
+
function setScrollRegion(top, bottom) {
|
|
207
|
+
return `${CSI}${top};${bottom}r`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/help/help.ts
|
|
211
|
+
var SCHEMA_URL = "https://enke-dev.github.io/logbox/logbox.schema.json";
|
|
212
|
+
var SECTIONS = [
|
|
213
|
+
{
|
|
214
|
+
title: "Usage",
|
|
215
|
+
lines: ["logbox [options] <command> [...args]", "logbox [options] -- <command> [...args]"]
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
title: "Options",
|
|
219
|
+
lines: [
|
|
220
|
+
`${GREEN}-c, --content${RESET} <json> Box content as inline JSON, e.g. '[["docs","https://localhost:5173"]]'`,
|
|
221
|
+
`${GREEN}-t, --title${RESET} <text> Title rendered into the top border`,
|
|
222
|
+
`${GREEN} --config${RESET} <path> Read the content from this file instead of looking one up`,
|
|
223
|
+
`${GREEN}-h, --help${RESET} Show this help`,
|
|
224
|
+
`${GREEN}-v, --version${RESET} Print the version`
|
|
225
|
+
]
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
title: "Content",
|
|
229
|
+
lines: [
|
|
230
|
+
"An array of rows; each row is an array of strings. The first cell is left",
|
|
231
|
+
"aligned, the last one right aligned, connected by a dot leader. A row with a",
|
|
232
|
+
"single cell renders as a plain label.",
|
|
233
|
+
"",
|
|
234
|
+
'Either bare rows or an object: {"title": "dev servers", "content": [[...]]}',
|
|
235
|
+
`Schema: ${SCHEMA_URL}`
|
|
236
|
+
]
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
title: "Configuration",
|
|
240
|
+
lines: [
|
|
241
|
+
`${CONFIG_FILE} is looked up in the current directory, then every parent up to`,
|
|
242
|
+
"the filesystem root (so the repo root is found from anywhere), then the user",
|
|
243
|
+
"home directory. --content overrides the file, --title overrides its title."
|
|
244
|
+
]
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
title: "Examples",
|
|
248
|
+
lines: [
|
|
249
|
+
"logbox pnpm -r --parallel --stream dev",
|
|
250
|
+
`logbox -c '[["docs","https://localhost:5173"]]' -t 'dev servers' npm run dev`
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
];
|
|
254
|
+
function helpText() {
|
|
255
|
+
const header = `${BOLD}logbox${RESET} — pin a configurable box below a long-running command`;
|
|
256
|
+
const sections = SECTIONS.map(({ title, lines }) => `${BOLD}${CYAN}${title}${RESET}
|
|
257
|
+
${lines.map((line) => line === "" ? line : ` ${line}`).join(`
|
|
258
|
+
`)}`);
|
|
259
|
+
return `${[header, ...sections].join(`
|
|
260
|
+
|
|
261
|
+
`)}
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/run/run.ts
|
|
266
|
+
import { spawn } from "node:child_process";
|
|
267
|
+
import process2, { env, platform, stderr, stdout } from "node:process";
|
|
268
|
+
|
|
269
|
+
// src/render/box.ts
|
|
270
|
+
var GLYPH = {
|
|
271
|
+
topLeft: "╭",
|
|
272
|
+
topRight: "╮",
|
|
273
|
+
bottomLeft: "╰",
|
|
274
|
+
bottomRight: "╯",
|
|
275
|
+
horizontal: "─",
|
|
276
|
+
vertical: "│",
|
|
277
|
+
leader: "."
|
|
278
|
+
};
|
|
279
|
+
var MIN_INNER = 8;
|
|
280
|
+
function boxHeight(rows) {
|
|
281
|
+
return rows.length + 2;
|
|
282
|
+
}
|
|
283
|
+
function truncate(value, width) {
|
|
284
|
+
if (value.length <= width) {
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
return width > 0 ? `${value.slice(0, width - 1)}…` : "";
|
|
288
|
+
}
|
|
289
|
+
function headWidths(rows) {
|
|
290
|
+
return rows.reduce((widths, row) => {
|
|
291
|
+
row.slice(0, -1).forEach((cell, index) => {
|
|
292
|
+
widths[index] = Math.max(widths[index] ?? 0, cell.length);
|
|
293
|
+
});
|
|
294
|
+
return widths;
|
|
295
|
+
}, []);
|
|
296
|
+
}
|
|
297
|
+
function rowParts(row, widths, body) {
|
|
298
|
+
const cells = row.length > 1 ? row : [...row, ""];
|
|
299
|
+
const heads = cells.slice(0, -1);
|
|
300
|
+
const last = heads.length - 1;
|
|
301
|
+
const head = truncate(heads.map((cell, index) => index < last ? cell.padEnd(widths[index] ?? cell.length) : cell).join(" "), Math.max(0, body - 2));
|
|
302
|
+
const tail = truncate(cells.at(-1) ?? "", Math.max(0, body - head.length - 1));
|
|
303
|
+
const fill = Math.max(0, body - head.length - tail.length);
|
|
304
|
+
return tail === "" ? { head, leader: "", tail, pad: " ".repeat(fill) } : { head, leader: GLYPH.leader.repeat(fill), tail, pad: "" };
|
|
305
|
+
}
|
|
306
|
+
function renderBox({ title, rows, columns }) {
|
|
307
|
+
const inner = Math.max(MIN_INNER, title.length + 4, columns - 2);
|
|
308
|
+
const body = inner - 2;
|
|
309
|
+
const widths = headWidths(rows);
|
|
310
|
+
const heading = `${COLOR.title}${title}${COLOR.border}`;
|
|
311
|
+
const top = `${GLYPH.topLeft}${GLYPH.horizontal} ${heading} ` + `${GLYPH.horizontal.repeat(Math.max(0, inner - title.length - 3))}${GLYPH.topRight}`;
|
|
312
|
+
const bottom = `${GLYPH.bottomLeft}${GLYPH.horizontal.repeat(inner)}${GLYPH.bottomRight}`;
|
|
313
|
+
const lines = rows.map((row) => {
|
|
314
|
+
const { head, leader, tail, pad } = rowParts(row, widths, body);
|
|
315
|
+
return `${COLOR.border}${GLYPH.vertical}${RESET} ` + `${COLOR.head}${head}${RESET}` + `${COLOR.leader}${leader}${RESET}` + `${COLOR.tail}${tail}${RESET}${pad} ` + `${COLOR.border}${GLYPH.vertical}${RESET}`;
|
|
316
|
+
});
|
|
317
|
+
return [`${COLOR.border}${top}${RESET}`, ...lines, `${COLOR.border}${bottom}${RESET}`];
|
|
318
|
+
}
|
|
319
|
+
function renderPlain({ title, rows }) {
|
|
320
|
+
const widths = headWidths(rows);
|
|
321
|
+
const lines = rows.map((row) => row.length === 1 ? row[0] ?? "" : row.slice(0, -1).map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).concat([row.at(-1) ?? ""]).join(" "));
|
|
322
|
+
return [`${title}:`, ...lines];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/run/run.ts
|
|
326
|
+
var REDRAW_DELAY = 8;
|
|
327
|
+
var size = () => ({
|
|
328
|
+
rows: stdout.rows || 24,
|
|
329
|
+
columns: stdout.columns || 80
|
|
330
|
+
});
|
|
331
|
+
function runWithBox(command, config) {
|
|
332
|
+
const [bin, ...args] = command;
|
|
333
|
+
const { title, content: rows } = config;
|
|
334
|
+
const height = boxHeight(rows);
|
|
335
|
+
const sticky = rows.length > 0 && stdout.isTTY === true;
|
|
336
|
+
if (rows.length > 0 && !sticky) {
|
|
337
|
+
stdout.write(`${renderPlain({ title, rows }).join(`
|
|
338
|
+
`)}
|
|
339
|
+
`);
|
|
340
|
+
}
|
|
341
|
+
const scrollBottom = () => Math.max(1, size().rows - height);
|
|
342
|
+
const drawBox = () => {
|
|
343
|
+
const lines = renderBox({ title, rows, columns: size().columns });
|
|
344
|
+
const top = Math.max(1, size().rows - lines.length + 1);
|
|
345
|
+
const box = lines.map((line, index) => `${cursorTo(top + index)}${CLEAR_LINE}${line}`).join("");
|
|
346
|
+
stdout.write(`${SAVE_CURSOR}${box}${RESTORE_CURSOR}`);
|
|
347
|
+
};
|
|
348
|
+
let scheduled;
|
|
349
|
+
const scheduleBox = () => {
|
|
350
|
+
clearTimeout(scheduled);
|
|
351
|
+
scheduled = setTimeout(drawBox, REDRAW_DELAY);
|
|
352
|
+
scheduled.unref?.();
|
|
353
|
+
};
|
|
354
|
+
const child = spawn(bin, args, {
|
|
355
|
+
env: { ...env, FORCE_COLOR: env["FORCE_COLOR"] ?? "1" },
|
|
356
|
+
shell: platform === "win32",
|
|
357
|
+
stdio: sticky ? ["inherit", "pipe", "pipe"] : "inherit"
|
|
358
|
+
});
|
|
359
|
+
if (sticky) {
|
|
360
|
+
stdout.write(`
|
|
361
|
+
`.repeat(height));
|
|
362
|
+
stdout.write(`${setScrollRegion(1, scrollBottom())}${cursorTo(scrollBottom())}`);
|
|
363
|
+
drawBox();
|
|
364
|
+
child.stdout?.on("data", (chunk) => {
|
|
365
|
+
stdout.write(chunk);
|
|
366
|
+
scheduleBox();
|
|
367
|
+
});
|
|
368
|
+
child.stderr?.on("data", (chunk) => {
|
|
369
|
+
stderr.write(chunk);
|
|
370
|
+
scheduleBox();
|
|
371
|
+
});
|
|
372
|
+
stdout.on("resize", () => {
|
|
373
|
+
stdout.write(setScrollRegion(1, scrollBottom()));
|
|
374
|
+
drawBox();
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
let released = false;
|
|
378
|
+
const cleanup = () => {
|
|
379
|
+
if (released || !sticky) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
released = true;
|
|
383
|
+
clearTimeout(scheduled);
|
|
384
|
+
stdout.write(`${RESET_SCROLL_REGION}${cursorTo(size().rows)}
|
|
385
|
+
`);
|
|
386
|
+
};
|
|
387
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
388
|
+
signals.forEach((signal) => process2.on(signal, () => child.kill(signal)));
|
|
389
|
+
process2.on("exit", cleanup);
|
|
390
|
+
return new Promise((resolve2, reject) => {
|
|
391
|
+
child.on("error", (error) => {
|
|
392
|
+
cleanup();
|
|
393
|
+
reject(new Error(`${bin}: ${error.message}`));
|
|
394
|
+
});
|
|
395
|
+
child.on("close", (code, signal) => {
|
|
396
|
+
cleanup();
|
|
397
|
+
resolve2(signal === null ? code ?? 0 : 1);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/cli.ts
|
|
403
|
+
var EX_USAGE = 64;
|
|
404
|
+
async function main() {
|
|
405
|
+
const { content, title, config, help, version, command } = parseCommandLine(argv.slice(2));
|
|
406
|
+
if (version) {
|
|
407
|
+
stdout2.write(`${package_default.version}
|
|
408
|
+
`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (help || command.length === 0) {
|
|
412
|
+
(help ? stdout2 : stderr2).write(helpText());
|
|
413
|
+
if (!help) {
|
|
414
|
+
process3.exitCode = EX_USAGE;
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
process3.exitCode = await runWithBox(command, await loadConfig({ content, title, config }));
|
|
419
|
+
}
|
|
420
|
+
main().catch((error) => {
|
|
421
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
422
|
+
stderr2.write(`${RED}${message}${RESET}
|
|
423
|
+
`);
|
|
424
|
+
process3.exitCode = 1;
|
|
425
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@enke.dev/logbox",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"packageManager": "bun@1.3.14",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"registry": "https://registry.npmjs.org",
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"description": "Wraps a long-running command and pins a configurable info box to the bottom of the terminal.",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"cli",
|
|
13
|
+
"terminal",
|
|
14
|
+
"tty",
|
|
15
|
+
"dev-server",
|
|
16
|
+
"box"
|
|
17
|
+
],
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "David Enke",
|
|
20
|
+
"email": "david@enke.dev"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/enke-dev/logbox.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://enke-dev.github.io/logbox/",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"bin": {
|
|
29
|
+
"logbox": "./dist/cli.mjs"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist/cli.mjs"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"prepare": "bun run build",
|
|
39
|
+
"check": "tsgo --noEmit",
|
|
40
|
+
"lint": "run-p lint:*",
|
|
41
|
+
"lint:eslint": "bun --bun eslint --config eslint.config.ts",
|
|
42
|
+
"lint:prettier": "bun --bun prettier --config prettier.config.ts --check .",
|
|
43
|
+
"format": "run-p format:*",
|
|
44
|
+
"format:eslint": "bun --bun eslint --config eslint.config.ts --fix",
|
|
45
|
+
"format:prettier": "bun --bun prettier --config prettier.config.ts --write .",
|
|
46
|
+
"test": "run-p -c test:*",
|
|
47
|
+
"test:bun": "bun test --reporter=junit --reporter-outfile=reports/junit.bun.xml",
|
|
48
|
+
"test:node": "node --import tsx --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/junit.node.xml \"src/**/*.spec.ts\"",
|
|
49
|
+
"dev": "bun run --bun src/cli.ts",
|
|
50
|
+
"build": "bun build ./src/cli.ts --target=node --outfile dist/cli.mjs"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@enke.dev/lint": "0.13.8",
|
|
54
|
+
"@tsconfig/strictest": "2.0.8",
|
|
55
|
+
"@types/bun": "1.3.14",
|
|
56
|
+
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
57
|
+
"eslint": "10.8.1",
|
|
58
|
+
"jiti": "2.7.0",
|
|
59
|
+
"npm-run-all2": "9.0.3",
|
|
60
|
+
"prettier": "3.9.6",
|
|
61
|
+
"tsx": "4.23.12",
|
|
62
|
+
"typescript": "6.0.3"
|
|
63
|
+
}
|
|
64
|
+
}
|