beads-ui 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/.beads/issues.jsonl +107 -0
- package/.editorconfig +10 -0
- package/.eslintrc.json +36 -0
- package/.github/workflows/ci.yml +38 -0
- package/.prettierignore +5 -0
- package/AGENTS.md +85 -0
- package/CHANGES.md +5 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/app/data/providers.js +178 -0
- package/app/data/providers.test.js +126 -0
- package/app/index.html +29 -0
- package/app/main.board-switch.test.js +94 -0
- package/app/main.deep-link.test.js +64 -0
- package/app/main.js +280 -0
- package/app/main.live-updates.test.js +229 -0
- package/app/main.test.js +17 -0
- package/app/main.theme.test.js +41 -0
- package/app/main.view-sync.test.js +54 -0
- package/app/protocol.js +200 -0
- package/app/protocol.md +64 -0
- package/app/protocol.test.js +57 -0
- package/app/router.js +78 -0
- package/app/router.test.js +34 -0
- package/app/state.js +87 -0
- package/app/state.test.js +21 -0
- package/app/styles.css +1343 -0
- package/app/utils/issue-id.js +10 -0
- package/app/utils/issue-type.js +27 -0
- package/app/utils/markdown.js +201 -0
- package/app/utils/markdown.test.js +103 -0
- package/app/utils/priority-badge.js +49 -0
- package/app/utils/priority.js +1 -0
- package/app/utils/status-badge.js +33 -0
- package/app/utils/status.js +23 -0
- package/app/utils/type-badge.js +36 -0
- package/app/utils/type-badge.test.js +30 -0
- package/app/views/board.js +183 -0
- package/app/views/board.test.js +184 -0
- package/app/views/detail.acceptance-notes.test.js +67 -0
- package/app/views/detail.assignee.test.js +161 -0
- package/app/views/detail.deps.test.js +97 -0
- package/app/views/detail.edits.test.js +146 -0
- package/app/views/detail.js +1039 -0
- package/app/views/detail.labels.test.js +73 -0
- package/app/views/detail.priority.test.js +86 -0
- package/app/views/detail.test.js +188 -0
- package/app/views/detail.ui47.test.js +78 -0
- package/app/views/epics.js +228 -0
- package/app/views/epics.test.js +283 -0
- package/app/views/issue-row.js +191 -0
- package/app/views/list.inline-edits.test.js +84 -0
- package/app/views/list.js +393 -0
- package/app/views/list.test.js +479 -0
- package/app/views/nav.js +67 -0
- package/app/views/nav.test.js +43 -0
- package/app/ws.js +252 -0
- package/app/ws.test.js +168 -0
- package/bin/bdui.js +18 -0
- package/docs/architecture.md +244 -0
- package/docs/db-watching.md +29 -0
- package/docs/quickstart.md +142 -0
- package/eslint.config.js +59 -0
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/package.json +48 -0
- package/prettier.config.js +13 -0
- package/server/app.js +80 -0
- package/server/app.test.js +29 -0
- package/server/bd.js +125 -0
- package/server/bd.test.js +93 -0
- package/server/cli/cli.test.js +109 -0
- package/server/cli/commands.integration.test.js +155 -0
- package/server/cli/commands.js +91 -0
- package/server/cli/commands.unit.test.js +94 -0
- package/server/cli/daemon.js +239 -0
- package/server/cli/index.js +74 -0
- package/server/cli/open.js +96 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +22 -0
- package/server/config.js +29 -0
- package/server/db.js +100 -0
- package/server/db.test.js +70 -0
- package/server/index.js +29 -0
- package/server/protocol.js +3 -0
- package/server/protocol.test.js +87 -0
- package/server/watcher.js +107 -0
- package/server/watcher.test.js +100 -0
- package/server/ws.handlers.test.js +174 -0
- package/server/ws.js +784 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.mutations.test.js +261 -0
- package/server/ws.subscriptions.test.js +116 -0
- package/server/ws.test.js +52 -0
- package/test/setup-vitest.js +12 -0
- package/tsconfig.json +23 -0
- package/vitest.config.mjs +14 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# beads-ui Quickstart
|
|
2
|
+
|
|
3
|
+
This project provides a local-first SPA for the `bd` (beads) CLI. It runs a
|
|
4
|
+
local HTTP + WebSocket server that serves the UI and proxies edits to the `bd`
|
|
5
|
+
CLI. Changes to the active beads database are pushed live to the browser.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- Node.js >= 22
|
|
10
|
+
- The `bd` CLI on your PATH (or set `BD_BIN=/path/to/bd`)
|
|
11
|
+
- An initialized beads database (see below)
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Run
|
|
20
|
+
|
|
21
|
+
Use the CLI to daemonize the server and open your browser:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bdui start
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or run in the foreground for quick debugging:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npm start
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- Server binds to `127.0.0.1:3000` by default.
|
|
34
|
+
- Open http://127.0.0.1:3000 in your browser.
|
|
35
|
+
|
|
36
|
+
Environment knobs:
|
|
37
|
+
|
|
38
|
+
- `PORT` to change the listen port (default: `3000`). The server always binds to
|
|
39
|
+
`127.0.0.1` for local‑only access.
|
|
40
|
+
- `BD_BIN` to point at a non-default `bd` binary.
|
|
41
|
+
|
|
42
|
+
## Database Resolution and Watching
|
|
43
|
+
|
|
44
|
+
The server and watcher resolve the active beads database in this order:
|
|
45
|
+
|
|
46
|
+
1. `--db <path>` injected by the server when invoking `bd` (derived from
|
|
47
|
+
resolution below)
|
|
48
|
+
2. `BEADS_DB` environment variable, if set
|
|
49
|
+
3. Nearest `.beads/*.db` by walking up from the server `root_dir`
|
|
50
|
+
4. `~/.beads/default.db`
|
|
51
|
+
|
|
52
|
+
The watcher listens for changes to the resolved SQLite DB and broadcasts an
|
|
53
|
+
`issues-changed` event to all connected clients. See `docs/db-watching.md` for
|
|
54
|
+
details.
|
|
55
|
+
|
|
56
|
+
## Initialize a Workspace (if needed)
|
|
57
|
+
|
|
58
|
+
From your project root:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
# create a workspace DB
|
|
62
|
+
bd init
|
|
63
|
+
|
|
64
|
+
# create a few issues
|
|
65
|
+
bd create "First issue" -t task -p 2 -d "Initial work"
|
|
66
|
+
bd create "Bug: wrong color" -t bug -p 1
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The UI should list these after startup. Edits in the UI map to `bd update`
|
|
70
|
+
commands executed by the server.
|
|
71
|
+
|
|
72
|
+
## Development Workflow
|
|
73
|
+
|
|
74
|
+
- Type check: `npm run typecheck`
|
|
75
|
+
- Tests: `npm test`
|
|
76
|
+
- Lint: `npm run lint`
|
|
77
|
+
- Format: `npm run format`
|
|
78
|
+
|
|
79
|
+
Tests cover protocol handlers, WebSocket client/server behavior, and core UI
|
|
80
|
+
flows (list and detail views, edits, and dependency management).
|
|
81
|
+
|
|
82
|
+
## CLI (`bdui`) Local Link
|
|
83
|
+
|
|
84
|
+
The `bdui` CLI is exposed via npm’s `bin` field for local development. To make
|
|
85
|
+
it available on your PATH:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
npm link
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Common commands:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
bdui start # daemonize the server and open the browser
|
|
95
|
+
bdui start --no-open # start without opening a browser (or set BDUI_NO_OPEN=1)
|
|
96
|
+
bdui stop # stop the daemon (exit code 2 if not running)
|
|
97
|
+
bdui restart # stop then start
|
|
98
|
+
bdui --help # usage
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Runtime directory and logs:
|
|
102
|
+
|
|
103
|
+
- PID and log files live under `$XDG_RUNTIME_DIR/beads-ui` or the system temp
|
|
104
|
+
directory. Override with `BDUI_RUNTIME_DIR=/path`.
|
|
105
|
+
|
|
106
|
+
Environment knobs also used by `bdui`:
|
|
107
|
+
|
|
108
|
+
- `PORT` to change the listen port (default: `3000`)
|
|
109
|
+
- `BDUI_NO_OPEN=1` to disable auto-opening the browser on `start`
|
|
110
|
+
- `BDUI_RUNTIME_DIR` to set a custom runtime directory
|
|
111
|
+
|
|
112
|
+
## Protocol
|
|
113
|
+
|
|
114
|
+
The WebSocket protocol is documented in `app/protocol.md` and shared by server
|
|
115
|
+
and client via `server/protocol.js` re-exports.
|
|
116
|
+
|
|
117
|
+
## Troubleshooting
|
|
118
|
+
|
|
119
|
+
- If the UI shows no issues, verify a beads DB exists or run `bd init` in your
|
|
120
|
+
workspace.
|
|
121
|
+
- To target a specific DB, set `BEADS_DB=/path/to/file.db` before `npm start`.
|
|
122
|
+
- If `bd` isn’t on your PATH, set `BD_BIN` to the full path.
|
|
123
|
+
|
|
124
|
+
### `bdui` specific
|
|
125
|
+
|
|
126
|
+
- Logs and PID: check the runtime dir for `daemon.log` and `server.pid`.
|
|
127
|
+
- Default: `$XDG_RUNTIME_DIR/beads-ui` (Linux), otherwise your system temp
|
|
128
|
+
directory (see `os.tmpdir()`).
|
|
129
|
+
- Override: set `BDUI_RUNTIME_DIR=/path`.
|
|
130
|
+
- Stale process: if `bdui stop` reports exit code `2` but `server.pid` exists,
|
|
131
|
+
remove the PID file and try again:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
rm "$(bdui --help >/dev/null 2>&1; echo ${BDUI_RUNTIME_DIR:-$(echo ${XDG_RUNTIME_DIR:-/tmp})/beads-ui})/server.pid" 2>/dev/null || true
|
|
135
|
+
bdui stop
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- Port in use: set a different port and restart:
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
PORT=4000 bdui restart
|
|
142
|
+
```
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import js from '@eslint/js';
|
|
2
|
+
import plugin_jsdoc from 'eslint-plugin-jsdoc';
|
|
3
|
+
import plugin_n from 'eslint-plugin-n';
|
|
4
|
+
import { defineConfig } from 'eslint/config';
|
|
5
|
+
import globals from 'globals';
|
|
6
|
+
|
|
7
|
+
export default defineConfig([
|
|
8
|
+
{
|
|
9
|
+
ignores: ['node_modules', 'coverage', 'dist', '.beads']
|
|
10
|
+
},
|
|
11
|
+
js.configs.recommended,
|
|
12
|
+
plugin_jsdoc.configs['flat/recommended'],
|
|
13
|
+
{
|
|
14
|
+
settings: {
|
|
15
|
+
jsdoc: {
|
|
16
|
+
mode: 'typescript',
|
|
17
|
+
preferredTypes: {
|
|
18
|
+
object: 'Object'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
rules: {
|
|
23
|
+
'jsdoc/require-jsdoc': 'off',
|
|
24
|
+
'jsdoc/require-param-description': 'off',
|
|
25
|
+
'jsdoc/require-returns-description': 'off',
|
|
26
|
+
'jsdoc/require-property-description': 'off',
|
|
27
|
+
'jsdoc/reject-any-type': 'off',
|
|
28
|
+
'jsdoc/require-returns': 'off'
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
files: ['**/*.test.js'],
|
|
33
|
+
languageOptions: {
|
|
34
|
+
globals: globals.vitest
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
files: ['server/**/*.js'],
|
|
39
|
+
...plugin_n.configs['flat/recommended'],
|
|
40
|
+
languageOptions: {
|
|
41
|
+
globals: globals.node
|
|
42
|
+
},
|
|
43
|
+
rules: {
|
|
44
|
+
'n/no-unpublished-import': 'off'
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
files: ['bin/**/*.js'],
|
|
49
|
+
languageOptions: {
|
|
50
|
+
globals: globals.node
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
files: ['app/**/*.js'],
|
|
55
|
+
languageOptions: {
|
|
56
|
+
globals: globals.browser
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
]);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "beads-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"bdui": "bin/bdui.js"
|
|
7
|
+
},
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"all": "npm run lint && npm run typecheck && npm test && npm run format:check",
|
|
13
|
+
"start": "node server/index.js",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest",
|
|
16
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
17
|
+
"lint": "eslint --ext .js .",
|
|
18
|
+
"format": "prettier --write .",
|
|
19
|
+
"format:check": "prettier --check .",
|
|
20
|
+
"preversion": "npm run all",
|
|
21
|
+
"version": "changes",
|
|
22
|
+
"postversion": "git push --follow-tags && npm publish"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@eslint/js": "^9.38.0",
|
|
26
|
+
"@studio/changes": "^3.0.0",
|
|
27
|
+
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
|
28
|
+
"@types/express": "^5.0.3",
|
|
29
|
+
"@types/node": "^22.7.4",
|
|
30
|
+
"@types/ws": "^8.18.1",
|
|
31
|
+
"eslint": "^9.11.0",
|
|
32
|
+
"eslint-plugin-import": "^2.29.1",
|
|
33
|
+
"eslint-plugin-jsdoc": "^48.10.2",
|
|
34
|
+
"eslint-plugin-n": "^17.9.0",
|
|
35
|
+
"eslint-plugin-promise": "^6.1.1",
|
|
36
|
+
"globals": "^16.4.0",
|
|
37
|
+
"jsdom": "^27.0.1",
|
|
38
|
+
"prettier": "^3.3.3",
|
|
39
|
+
"typescript": "^5.6.3",
|
|
40
|
+
"vitest": "^2.1.3"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"esbuild": "^0.25.11",
|
|
44
|
+
"express": "^5.1.0",
|
|
45
|
+
"lit-html": "^3.3.1",
|
|
46
|
+
"ws": "^8.18.3"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Config } from 'prettier'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @type {Config} */
|
|
6
|
+
export default {
|
|
7
|
+
singleQuote: true,
|
|
8
|
+
trailingComma: 'none',
|
|
9
|
+
proseWrap: 'always',
|
|
10
|
+
plugins: ['@trivago/prettier-plugin-sort-imports'],
|
|
11
|
+
importOrder: ['^@(.*)$', '<THIRD_PARTY_MODULES>', '^[./]'],
|
|
12
|
+
importOrderSortSpecifiers: true
|
|
13
|
+
};
|
package/server/app.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Express, Request, Response } from 'express'
|
|
3
|
+
*/
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create and configure the Express application.
|
|
9
|
+
* @param {{ host: string, port: number, env: string, app_dir: string, root_dir: string }} config - Server configuration.
|
|
10
|
+
* @returns {Express} Configured Express app instance.
|
|
11
|
+
*/
|
|
12
|
+
export function createApp(config) {
|
|
13
|
+
const app = express();
|
|
14
|
+
|
|
15
|
+
// Basic hardening and config
|
|
16
|
+
app.disable('x-powered-by');
|
|
17
|
+
|
|
18
|
+
// Health endpoint
|
|
19
|
+
/**
|
|
20
|
+
* @param {Request} _req
|
|
21
|
+
* @param {Response} res
|
|
22
|
+
*/
|
|
23
|
+
app.get('/healthz', (_req, res) => {
|
|
24
|
+
res.type('application/json');
|
|
25
|
+
res.status(200).send({ ok: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* On-demand bundle for the browser using esbuild.
|
|
30
|
+
* Note: esbuild is loaded lazily so tests don't require it to be installed.
|
|
31
|
+
* @param {Request} _req
|
|
32
|
+
* @param {Response} res
|
|
33
|
+
*/
|
|
34
|
+
app.get('/main.bundle.js', async (_req, res) => {
|
|
35
|
+
try {
|
|
36
|
+
const esbuild = await import('esbuild');
|
|
37
|
+
const entry = path.join(config.app_dir, 'main.js');
|
|
38
|
+
const result = await esbuild.build({
|
|
39
|
+
entryPoints: [entry],
|
|
40
|
+
bundle: true,
|
|
41
|
+
format: 'esm',
|
|
42
|
+
platform: 'browser',
|
|
43
|
+
target: 'es2020',
|
|
44
|
+
sourcemap: config.env === 'production' ? false : 'inline',
|
|
45
|
+
minify: config.env === 'production',
|
|
46
|
+
write: false
|
|
47
|
+
});
|
|
48
|
+
const out = result.outputFiles && result.outputFiles[0];
|
|
49
|
+
if (!out) {
|
|
50
|
+
res.status(500).type('text/plain').send('Bundle failed: no output');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
54
|
+
if (config.env !== 'production') {
|
|
55
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
56
|
+
}
|
|
57
|
+
res.send(out.text);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
res
|
|
60
|
+
.status(500)
|
|
61
|
+
.type('text/plain')
|
|
62
|
+
.send('Bundle error: ' + (err && /** @type {any} */ (err).message));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Static assets from /app
|
|
67
|
+
app.use(express.static(config.app_dir));
|
|
68
|
+
|
|
69
|
+
// Root serves index.html explicitly (even if static would catch it)
|
|
70
|
+
/**
|
|
71
|
+
* @param {Request} _req
|
|
72
|
+
* @param {Response} res
|
|
73
|
+
*/
|
|
74
|
+
app.get('/', (_req, res) => {
|
|
75
|
+
const index_path = path.join(config.app_dir, 'index.html');
|
|
76
|
+
res.sendFile(index_path);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return app;
|
|
80
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { createApp } from './app.js';
|
|
5
|
+
import { getConfig } from './config.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Narrow to function type for basic checks.
|
|
9
|
+
* @param {unknown} value
|
|
10
|
+
* @returns {value is Function}
|
|
11
|
+
*/
|
|
12
|
+
function isFunction(value) {
|
|
13
|
+
return typeof value === 'function';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('server app wiring (no listen)', () => {
|
|
17
|
+
test('createApp returns an express-like app', () => {
|
|
18
|
+
const config = getConfig();
|
|
19
|
+
const app = createApp(config);
|
|
20
|
+
expect(isFunction(app.get)).toBe(true);
|
|
21
|
+
expect(isFunction(app.use)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('index.html exists in configured app_dir', () => {
|
|
25
|
+
const config = getConfig();
|
|
26
|
+
const index_path = path.join(config.app_dir, 'index.html');
|
|
27
|
+
expect(fs.existsSync(index_path)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
package/server/bd.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolveDbPath } from './db.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the bd executable path.
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function getBdBin() {
|
|
9
|
+
const env_value = process.env.BD_BIN;
|
|
10
|
+
if (env_value && env_value.length > 0) {
|
|
11
|
+
return env_value;
|
|
12
|
+
}
|
|
13
|
+
return 'bd';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run the `bd` CLI with provided arguments.
|
|
18
|
+
* Shell is not used to avoid injection; args must be pre-split.
|
|
19
|
+
* @param {string[]} args - Arguments to pass (e.g., ["list", "--json"]).
|
|
20
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
|
|
21
|
+
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
|
|
22
|
+
*/
|
|
23
|
+
export function runBd(args, options = {}) {
|
|
24
|
+
const bin = getBdBin();
|
|
25
|
+
const spawn_opts = {
|
|
26
|
+
cwd: options.cwd || process.cwd(),
|
|
27
|
+
env: options.env ? options.env : process.env,
|
|
28
|
+
shell: false
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Ensure a consistent DB by injecting --db if missing, following beads precedence.
|
|
32
|
+
/** @type {string[]} */
|
|
33
|
+
const finalArgs = withDbArg(args, spawn_opts.cwd, spawn_opts.env);
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const child = spawn(bin, finalArgs, spawn_opts);
|
|
37
|
+
|
|
38
|
+
/** @type {string[]} */
|
|
39
|
+
const out_chunks = [];
|
|
40
|
+
/** @type {string[]} */
|
|
41
|
+
const err_chunks = [];
|
|
42
|
+
|
|
43
|
+
if (child.stdout) {
|
|
44
|
+
child.stdout.setEncoding('utf8');
|
|
45
|
+
/** @param {string} chunk */
|
|
46
|
+
child.stdout.on('data', (chunk) => {
|
|
47
|
+
out_chunks.push(String(chunk));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (child.stderr) {
|
|
51
|
+
child.stderr.setEncoding('utf8');
|
|
52
|
+
/** @param {string} chunk */
|
|
53
|
+
child.stderr.on('data', (chunk) => {
|
|
54
|
+
err_chunks.push(String(chunk));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
59
|
+
let timer;
|
|
60
|
+
if (options.timeout_ms && options.timeout_ms > 0) {
|
|
61
|
+
timer = setTimeout(() => {
|
|
62
|
+
child.kill('SIGKILL');
|
|
63
|
+
}, options.timeout_ms);
|
|
64
|
+
timer.unref?.();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {number | string | null} code
|
|
69
|
+
*/
|
|
70
|
+
const finish = (code) => {
|
|
71
|
+
if (timer) {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
resolve({
|
|
75
|
+
code: Number(code || 0),
|
|
76
|
+
stdout: out_chunks.join(''),
|
|
77
|
+
stderr: err_chunks.join('')
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
child.on('error', () => {
|
|
82
|
+
// Treat spawn error as an immediate non-zero exit with captured stderr message.
|
|
83
|
+
finish(127);
|
|
84
|
+
});
|
|
85
|
+
child.on('close', (code) => {
|
|
86
|
+
finish(code);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run `bd` and parse JSON from stdout if exit code is 0.
|
|
93
|
+
* @param {string[]} args - Must include flags that cause JSON to be printed (e.g., `--json`).
|
|
94
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
|
|
95
|
+
* @returns {Promise<{ code: number, stdoutJson?: unknown, stderr?: string }>}
|
|
96
|
+
*/
|
|
97
|
+
export async function runBdJson(args, options = {}) {
|
|
98
|
+
const result = await runBd(args, options);
|
|
99
|
+
if (result.code !== 0) {
|
|
100
|
+
return { code: result.code, stderr: result.stderr };
|
|
101
|
+
}
|
|
102
|
+
/** @type {unknown} */
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(result.stdout || 'null');
|
|
106
|
+
} catch {
|
|
107
|
+
return { code: 0, stderr: 'Invalid JSON from bd' };
|
|
108
|
+
}
|
|
109
|
+
return { code: 0, stdoutJson: parsed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Add a resolved "--db <path>" pair to args when none present.
|
|
114
|
+
* @param {string[]} args
|
|
115
|
+
* @param {string} cwd
|
|
116
|
+
* @param {Record<string, string | undefined>} env
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
function withDbArg(args, cwd, env) {
|
|
120
|
+
if (args.includes('--db')) {
|
|
121
|
+
return args.slice();
|
|
122
|
+
}
|
|
123
|
+
const resolved = resolveDbPath({ cwd, env });
|
|
124
|
+
return ['--db', resolved.path, ...args];
|
|
125
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawn as spawnMock } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { PassThrough } from 'node:stream';
|
|
4
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
5
|
+
import { getBdBin, runBd, runBdJson } from './bd.js';
|
|
6
|
+
|
|
7
|
+
// Mock child_process.spawn before importing the module under test
|
|
8
|
+
vi.mock('node:child_process', () => ({ spawn: vi.fn() }));
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} stdoutText
|
|
12
|
+
* @param {string} stderrText
|
|
13
|
+
* @param {number} code
|
|
14
|
+
*/
|
|
15
|
+
function makeFakeProc(stdoutText, stderrText, code) {
|
|
16
|
+
const cp = /** @type {any} */ (new EventEmitter());
|
|
17
|
+
const out = new PassThrough();
|
|
18
|
+
const err = new PassThrough();
|
|
19
|
+
cp.stdout = out;
|
|
20
|
+
cp.stderr = err;
|
|
21
|
+
// Simulate async emission
|
|
22
|
+
queueMicrotask(() => {
|
|
23
|
+
if (stdoutText) {
|
|
24
|
+
out.write(stdoutText);
|
|
25
|
+
}
|
|
26
|
+
out.end();
|
|
27
|
+
if (stderrText) {
|
|
28
|
+
err.write(stderrText);
|
|
29
|
+
}
|
|
30
|
+
err.end();
|
|
31
|
+
cp.emit('close', code);
|
|
32
|
+
});
|
|
33
|
+
return cp;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mockedSpawn = /** @type {import('vitest').Mock} */ (spawnMock);
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
mockedSpawn.mockReset();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('getBdBin', () => {
|
|
43
|
+
test('returns env BD_BIN when set', () => {
|
|
44
|
+
const prev = process.env.BD_BIN;
|
|
45
|
+
process.env.BD_BIN = '/custom/bd';
|
|
46
|
+
expect(getBdBin()).toBe('/custom/bd');
|
|
47
|
+
if (prev) {
|
|
48
|
+
process.env.BD_BIN = prev;
|
|
49
|
+
} else {
|
|
50
|
+
delete process.env.BD_BIN;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('runBd', () => {
|
|
56
|
+
test('returns stdout/stderr and exit code', async () => {
|
|
57
|
+
mockedSpawn.mockReturnValueOnce(makeFakeProc('ok', '', 0));
|
|
58
|
+
const res = await runBd(['--version']);
|
|
59
|
+
expect(res.code).toBe(0);
|
|
60
|
+
expect(res.stdout).toContain('ok');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('non-zero exit propagates code and stderr', async () => {
|
|
64
|
+
mockedSpawn.mockReturnValueOnce(makeFakeProc('', 'boom', 1));
|
|
65
|
+
const res = await runBd(['list']);
|
|
66
|
+
expect(res.code).toBe(1);
|
|
67
|
+
expect(res.stderr).toContain('boom');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('runBdJson', () => {
|
|
72
|
+
test('parses valid JSON output', async () => {
|
|
73
|
+
const json = JSON.stringify([{ id: 'UI-1' }]);
|
|
74
|
+
mockedSpawn.mockReturnValueOnce(makeFakeProc(json, '', 0));
|
|
75
|
+
const res = await runBdJson(['list', '--json']);
|
|
76
|
+
expect(res.code).toBe(0);
|
|
77
|
+
expect(Array.isArray(res.stdoutJson)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('invalid JSON yields stderr message with code 0', async () => {
|
|
81
|
+
mockedSpawn.mockReturnValueOnce(makeFakeProc('not-json', '', 0));
|
|
82
|
+
const res = await runBdJson(['list', '--json']);
|
|
83
|
+
expect(res.code).toBe(0);
|
|
84
|
+
expect(res.stderr).toContain('Invalid JSON');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('non-zero exit returns code and stderr', async () => {
|
|
88
|
+
mockedSpawn.mockReturnValueOnce(makeFakeProc('', 'oops', 2));
|
|
89
|
+
const res = await runBdJson(['list', '--json']);
|
|
90
|
+
expect(res.code).toBe(2);
|
|
91
|
+
expect(res.stderr).toContain('oops');
|
|
92
|
+
});
|
|
93
|
+
});
|