beads-ui 0.1.2 → 0.3.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/CHANGES.md +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -9
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +35 -85
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +6 -9
- package/server/ws.js +466 -227
- package/docs/quickstart.md +0 -142
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Subscription Push Protocol — per‑subscription full‑issue envelopes (Breaking)
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Date: 2025-10-26
|
|
5
|
+
Status: Implemented
|
|
6
|
+
Owner: agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
This document specifies the push‑only protocol used by beads‑ui to deliver list
|
|
10
|
+
updates from the local server to the client. It replaces the legacy
|
|
11
|
+
notify‑then‑fetch model. There is no version negotiation or fallback.
|
|
12
|
+
|
|
13
|
+
## Overview
|
|
14
|
+
|
|
15
|
+
- Transport: single WebSocket connection per client
|
|
16
|
+
- Encoding: JSON text frames
|
|
17
|
+
- Subscriptions: one client‑chosen `id` per active list subscription
|
|
18
|
+
- Delivery: per‑subscription envelopes with full issue payloads
|
|
19
|
+
- Messages: `snapshot` | `upsert` | `delete`
|
|
20
|
+
- Ordering: strictly increasing `revision` per subscription key and connection
|
|
21
|
+
|
|
22
|
+
## Envelopes
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
export type SnapshotEnvelope = {
|
|
26
|
+
type: 'snapshot';
|
|
27
|
+
id: string; // client subscription id
|
|
28
|
+
revision: number; // starts at 1 and increments per envelope
|
|
29
|
+
issues: Issue[]; // full list for this subscription
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type UpsertEnvelope = {
|
|
33
|
+
type: 'upsert';
|
|
34
|
+
id: string;
|
|
35
|
+
revision: number;
|
|
36
|
+
issue: Issue; // full issue payload
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type DeleteEnvelope = {
|
|
40
|
+
type: 'delete';
|
|
41
|
+
id: string;
|
|
42
|
+
revision: number;
|
|
43
|
+
issue_id: string; // id only
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Notes
|
|
48
|
+
|
|
49
|
+
- Server serializes refresh runs per subscription key and emits envelopes in
|
|
50
|
+
`revision` order. Clients MUST ignore any envelope with `revision <=` the last
|
|
51
|
+
applied for the same `id`.
|
|
52
|
+
- Clients SHOULD treat `upsert` as idempotent and MAY additionally guard on an
|
|
53
|
+
`issue.updated_at` timestamp to ignore stale updates racing with local state.
|
|
54
|
+
|
|
55
|
+
## Handshake (subscribe‑list)
|
|
56
|
+
|
|
57
|
+
Client subscribes to a list with a chosen `id`, a `type`, and optional `params`.
|
|
58
|
+
|
|
59
|
+
Client → Server
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"id": "req-1",
|
|
64
|
+
"type": "subscribe-list",
|
|
65
|
+
"payload": { "id": "ready", "type": "ready-issues" }
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Server → Client
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"id": "req-1",
|
|
74
|
+
"ok": true,
|
|
75
|
+
"type": "subscribe-list",
|
|
76
|
+
"payload": { "id": "ready", "key": "ready-issues:{}" }
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Immediately after the ack, the server sends a `snapshot` envelope containing the
|
|
81
|
+
full list for that subscription `id` with `revision: 1`.
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"id": "evt-1730000000000",
|
|
86
|
+
"ok": true,
|
|
87
|
+
"type": "snapshot",
|
|
88
|
+
"payload": {
|
|
89
|
+
"type": "snapshot",
|
|
90
|
+
"id": "ready",
|
|
91
|
+
"revision": 1,
|
|
92
|
+
"issues": [{ "id": "UI-1", "title": "..." }]
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Updates
|
|
98
|
+
|
|
99
|
+
Subsequent refreshes emit `upsert` and `delete` envelopes as the list changes.
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"id": "evt-1730000000100",
|
|
104
|
+
"ok": true,
|
|
105
|
+
"type": "upsert",
|
|
106
|
+
"payload": {
|
|
107
|
+
"type": "upsert",
|
|
108
|
+
"id": "ready",
|
|
109
|
+
"revision": 2,
|
|
110
|
+
"issue": { "id": "UI-2", "status": "in_progress" }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"id": "evt-1730000000200",
|
|
118
|
+
"ok": true,
|
|
119
|
+
"type": "delete",
|
|
120
|
+
"payload": {
|
|
121
|
+
"type": "delete",
|
|
122
|
+
"id": "ready",
|
|
123
|
+
"revision": 3,
|
|
124
|
+
"issue_id": "UI-9"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Reconnect Behavior
|
|
130
|
+
|
|
131
|
+
- On reconnect, clients resubscribe using the same `id` values as needed. The
|
|
132
|
+
server treats this as a new connection and sends a fresh `snapshot` with
|
|
133
|
+
`revision: 1` for each active subscription.
|
|
134
|
+
|
|
135
|
+
## Diagrams
|
|
136
|
+
|
|
137
|
+
```mermaid
|
|
138
|
+
sequenceDiagram
|
|
139
|
+
participant C as Client
|
|
140
|
+
participant S as Server
|
|
141
|
+
C->>S: subscribe-list { id, type, params }
|
|
142
|
+
S-->>C: ack { id, key }
|
|
143
|
+
S-->>C: snapshot { id, schema, revision:1, issues:[...] }
|
|
144
|
+
S-->>C: upsert/delete { id, schema, revision:n, ... }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```mermaid
|
|
148
|
+
stateDiagram-v2
|
|
149
|
+
[*] --> Idle
|
|
150
|
+
Idle --> Subscribed: subscribe-list(id)
|
|
151
|
+
Subscribed --> Subscribed: snapshot(rev=1)
|
|
152
|
+
Subscribed --> Subscribed: upsert/delete(rev++)
|
|
153
|
+
Subscribed --> Idle: unsubscribe-list(id) / disconnect
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Client Responsibilities
|
|
157
|
+
|
|
158
|
+
- Maintain one `SubscriptionIssueStore` per active subscription `id`.
|
|
159
|
+
- Apply envelopes strictly in `revision` order; ignore stale revisions.
|
|
160
|
+
- Render list components from `store.snapshot()` (deterministic order).
|
|
161
|
+
- Dispose stores on route/tab changes.
|
|
162
|
+
|
|
163
|
+
Detail view
|
|
164
|
+
|
|
165
|
+
- Detail pages use the same mechanism with a single‑item subscription, e.g.
|
|
166
|
+
`{ type: 'issue-detail', params: { id: 'UI-1' } }` under a client id like
|
|
167
|
+
`detail:UI-1`. The server returns a one‑element list for `snapshot` and
|
|
168
|
+
`upsert` events.
|
|
169
|
+
|
|
170
|
+
## Rollout and Compatibility
|
|
171
|
+
|
|
172
|
+
- Breaking change: no flags and no compatibility layer with the legacy
|
|
173
|
+
notify‑then‑fetch flow. Update both client and server together.
|
|
174
|
+
|
|
175
|
+
## See Also
|
|
176
|
+
|
|
177
|
+
- ADR 002 — Per‑Subscription Stores and Full‑Issue Push
|
|
178
|
+
- `docs/data-exchange-subscription-plan.md` (server refresh and publish model)
|
|
179
|
+
- `docs/subscription-issue-store.md` (store API and usage examples)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# SubscriptionIssueStore — API and Usage Examples
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Date: 2025-10-26
|
|
5
|
+
Status: Implemented
|
|
6
|
+
Owner: agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The `SubscriptionIssueStore` is a per‑subscription in‑memory store that owns the
|
|
10
|
+
issues for a single list subscription. It applies server push envelopes
|
|
11
|
+
(`snapshot`/`upsert`/`delete`) in revision order and exposes a deterministic,
|
|
12
|
+
read‑only snapshot for rendering.
|
|
13
|
+
|
|
14
|
+
See also: `docs/protocol/issues-push-v2.md` for the wire protocol.
|
|
15
|
+
|
|
16
|
+
## API
|
|
17
|
+
|
|
18
|
+
Factory: `app/data/subscription-issue-store.js`
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { createSubscriptionIssueStore } from '../app/data/subscription-issue-store.js';
|
|
22
|
+
|
|
23
|
+
// Create at view mount (id is client-chosen)
|
|
24
|
+
const store = createSubscriptionIssueStore('ready');
|
|
25
|
+
|
|
26
|
+
// Listen for changes
|
|
27
|
+
const unsubscribe = store.subscribe(() => {
|
|
28
|
+
render(store.snapshot());
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Apply push envelopes from the WebSocket client
|
|
32
|
+
ws.on('message', (evt) => {
|
|
33
|
+
const msg = JSON.parse(evt.data);
|
|
34
|
+
if (msg && msg.ok === true && msg.payload && msg.payload.id === 'ready') {
|
|
35
|
+
// payload has { type, id, schema, revision, ... }
|
|
36
|
+
store.applyPush(msg.payload);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Read helpers
|
|
41
|
+
store.size(); // number of issues
|
|
42
|
+
store.getById('UI-1'); // lookup by id
|
|
43
|
+
|
|
44
|
+
// Dispose on unmount
|
|
45
|
+
unsubscribe();
|
|
46
|
+
store.dispose();
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Options: deterministic sort can be customized per list via the optional
|
|
50
|
+
`{ sort(a,b) }` parameter when constructing the store.
|
|
51
|
+
|
|
52
|
+
## Subscribing to a List
|
|
53
|
+
|
|
54
|
+
Pair store creation with the subscribe‑list handshake. The server will send a
|
|
55
|
+
`snapshot` immediately after the ack, followed by `upsert`/`delete`.
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
// Request a subscription
|
|
59
|
+
socket.send(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
id: 'req-1',
|
|
62
|
+
type: 'subscribe-list',
|
|
63
|
+
payload: { id: 'ready', type: 'ready-issues' }
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
socket.addEventListener('message', (ev) => {
|
|
68
|
+
const frame = JSON.parse(ev.data);
|
|
69
|
+
if (frame.ok && frame.type === 'snapshot' && frame.payload.id === 'ready') {
|
|
70
|
+
store.applyPush(frame.payload);
|
|
71
|
+
}
|
|
72
|
+
if (frame.ok && frame.type === 'upsert' && frame.payload.id === 'ready') {
|
|
73
|
+
store.applyPush(frame.payload);
|
|
74
|
+
}
|
|
75
|
+
if (frame.ok && frame.type === 'delete' && frame.payload.id === 'ready') {
|
|
76
|
+
store.applyPush(frame.payload);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Rendering Pattern (List component)
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
/** @param {{ store: ReturnType<typeof createSubscriptionIssueStore> }} props */
|
|
85
|
+
export function ListView({ store }) {
|
|
86
|
+
let items = store.snapshot();
|
|
87
|
+
|
|
88
|
+
const un = store.subscribe(() => {
|
|
89
|
+
items = store.snapshot();
|
|
90
|
+
requestRender();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// framework-specific teardown
|
|
94
|
+
onUnmount(() => un());
|
|
95
|
+
|
|
96
|
+
return html`<ul>
|
|
97
|
+
${items.map((it) => html`<li data-id=${it.id}>${it.title}</li>`)}
|
|
98
|
+
</ul>`;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Ordering and Identity
|
|
103
|
+
|
|
104
|
+
- Default sort: priority asc, then `created_at` desc, then id asc.
|
|
105
|
+
- When upserting, the store preserves object identity for existing ids by
|
|
106
|
+
mutating fields in place. This reduces unnecessary re‑renders.
|
|
107
|
+
|
|
108
|
+
## Reconnects
|
|
109
|
+
|
|
110
|
+
- On reconnect, repeat the subscribe‑list call. The server sends a fresh
|
|
111
|
+
`snapshot` with `revision: 1`. The store ignores stale envelopes using the
|
|
112
|
+
`revision` guard.
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beads-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Local‑first UI for Beads — a fast issue tracker for your coding agent.",
|
|
5
|
+
"homepage": "https://github.com/mantoni/beads-ui",
|
|
4
6
|
"type": "module",
|
|
5
7
|
"bin": {
|
|
6
8
|
"bdui": "bin/bdui.js"
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
"format": "prettier --write .",
|
|
19
21
|
"format:check": "prettier --check .",
|
|
20
22
|
"preversion": "npm run all",
|
|
21
|
-
"version": "changes",
|
|
23
|
+
"version": "changes --commits --footer",
|
|
22
24
|
"postversion": "git push --follow-tags && npm publish"
|
|
23
25
|
},
|
|
24
26
|
"devDependencies": {
|
|
@@ -40,9 +42,11 @@
|
|
|
40
42
|
"vitest": "^2.1.3"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
45
|
+
"dompurify": "^3.3.0",
|
|
43
46
|
"esbuild": "^0.25.11",
|
|
44
47
|
"express": "^5.1.0",
|
|
45
48
|
"lit-html": "^3.3.1",
|
|
49
|
+
"marked": "^16.4.1",
|
|
46
50
|
"ws": "^8.18.3"
|
|
47
51
|
},
|
|
48
52
|
"files": [
|
|
@@ -54,5 +58,9 @@
|
|
|
54
58
|
"LICENSE",
|
|
55
59
|
"README.md",
|
|
56
60
|
"!**/*.test.js"
|
|
57
|
-
]
|
|
61
|
+
],
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "https://github.com/mantoni/beads-ui.git"
|
|
65
|
+
}
|
|
58
66
|
}
|
package/server/bd.js
CHANGED
|
@@ -42,14 +42,12 @@ export function runBd(args, options = {}) {
|
|
|
42
42
|
|
|
43
43
|
if (child.stdout) {
|
|
44
44
|
child.stdout.setEncoding('utf8');
|
|
45
|
-
/** @param {string} chunk */
|
|
46
45
|
child.stdout.on('data', (chunk) => {
|
|
47
46
|
out_chunks.push(String(chunk));
|
|
48
47
|
});
|
|
49
48
|
}
|
|
50
49
|
if (child.stderr) {
|
|
51
50
|
child.stderr.setEncoding('utf8');
|
|
52
|
-
/** @param {string} chunk */
|
|
53
51
|
child.stderr.on('data', (chunk) => {
|
|
54
52
|
err_chunks.push(String(chunk));
|
|
55
53
|
});
|
package/server/cli/commands.js
CHANGED
|
@@ -19,7 +19,8 @@ import { openUrl, waitForServer } from './open.js';
|
|
|
19
19
|
* @param {{ no_open?: boolean }} [options]
|
|
20
20
|
*/
|
|
21
21
|
export async function handleStart(options) {
|
|
22
|
-
|
|
22
|
+
// Default behavior: do not open a browser unless explicitly requested.
|
|
23
|
+
const no_open = options?.no_open !== false;
|
|
23
24
|
const existing_pid = readPidFile();
|
|
24
25
|
if (existing_pid && isProcessRunning(existing_pid)) {
|
|
25
26
|
printServerUrl();
|
|
@@ -35,8 +36,7 @@ export async function handleStart(options) {
|
|
|
35
36
|
printServerUrl();
|
|
36
37
|
// Auto-open the browser once for a fresh daemon start
|
|
37
38
|
if (!no_open) {
|
|
38
|
-
const
|
|
39
|
-
const url = 'http://' + cfg.host + ':' + String(cfg.port);
|
|
39
|
+
const { url } = getConfig();
|
|
40
40
|
// Wait briefly for the server to accept connections (single retry window)
|
|
41
41
|
await waitForServer(url, 600);
|
|
42
42
|
// Best-effort open; ignore result
|
|
@@ -80,12 +80,19 @@ export async function handleStop() {
|
|
|
80
80
|
* Handle `restart` command: stop (ignore not-running) then start.
|
|
81
81
|
* @returns {Promise<number>} Exit code (0 on success)
|
|
82
82
|
*/
|
|
83
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Handle `restart` command: stop (ignore not-running) then start.
|
|
85
|
+
* Accepts the same options as `handleStart` and passes them through,
|
|
86
|
+
* so restart only opens a browser when `no_open` is explicitly false.
|
|
87
|
+
* @param {{ no_open?: boolean }} [options]
|
|
88
|
+
* @returns {Promise<number>}
|
|
89
|
+
*/
|
|
90
|
+
export async function handleRestart(options) {
|
|
84
91
|
const stop_code = await handleStop();
|
|
85
92
|
// 0 = stopped, 2 = not running; both are acceptable to proceed
|
|
86
93
|
if (stop_code !== 0 && stop_code !== 2) {
|
|
87
94
|
return 1;
|
|
88
95
|
}
|
|
89
|
-
const start_code = await handleStart();
|
|
96
|
+
const start_code = await handleStart(options);
|
|
90
97
|
return start_code === 0 ? 0 : 1;
|
|
91
98
|
}
|
package/server/cli/daemon.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { SpawnOptions } from 'node:child_process'
|
|
3
|
+
*/
|
|
1
4
|
import { spawn } from 'node:child_process';
|
|
2
5
|
import fs from 'node:fs';
|
|
3
6
|
import os from 'node:os';
|
|
4
7
|
import path from 'node:path';
|
|
5
8
|
import { fileURLToPath } from 'node:url';
|
|
6
9
|
import { getConfig } from '../config.js';
|
|
10
|
+
import { resolveDbPath } from '../db.js';
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Resolve the runtime directory used for PID and log files.
|
|
@@ -12,13 +16,11 @@ import { getConfig } from '../config.js';
|
|
|
12
16
|
* @returns {string}
|
|
13
17
|
*/
|
|
14
18
|
export function getRuntimeDir() {
|
|
15
|
-
/** @type {string | undefined} */
|
|
16
19
|
const override_dir = process.env.BDUI_RUNTIME_DIR;
|
|
17
20
|
if (override_dir && override_dir.length > 0) {
|
|
18
21
|
return ensureDir(override_dir);
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
/** @type {string | undefined} */
|
|
22
24
|
const xdg_dir = process.env.XDG_RUNTIME_DIR;
|
|
23
25
|
if (xdg_dir && xdg_dir.length > 0) {
|
|
24
26
|
return ensureDir(path.join(xdg_dir, 'beads-ui'));
|
|
@@ -148,7 +150,7 @@ export function startDaemon() {
|
|
|
148
150
|
log_fd = -1;
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
/** @type {
|
|
153
|
+
/** @type {SpawnOptions} */
|
|
152
154
|
const opts = {
|
|
153
155
|
detached: true,
|
|
154
156
|
env: { ...process.env },
|
|
@@ -233,7 +235,12 @@ function sleep(ms) {
|
|
|
233
235
|
* Print the server URL derived from current config.
|
|
234
236
|
*/
|
|
235
237
|
export function printServerUrl() {
|
|
236
|
-
const
|
|
237
|
-
const url = 'http://' + cfg.host + ':' + String(cfg.port);
|
|
238
|
+
const { url } = getConfig();
|
|
238
239
|
console.log(url);
|
|
240
|
+
|
|
241
|
+
// Resolve from the caller's working directory by default
|
|
242
|
+
const resolved_db = resolveDbPath();
|
|
243
|
+
console.log(
|
|
244
|
+
`db: ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
|
|
245
|
+
);
|
|
239
246
|
}
|
package/server/cli/index.js
CHANGED
|
@@ -17,6 +17,10 @@ export function parseArgs(args) {
|
|
|
17
17
|
flags.push('help');
|
|
18
18
|
continue;
|
|
19
19
|
}
|
|
20
|
+
if (token === '--open') {
|
|
21
|
+
flags.push('open');
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
20
24
|
if (token === '--no-open') {
|
|
21
25
|
flags.push('no-open');
|
|
22
26
|
continue;
|
|
@@ -53,19 +57,44 @@ export async function main(args) {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
if (command === 'start') {
|
|
56
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Default behavior: do NOT open a browser.
|
|
62
|
+
* `--open` explicitly opens, overriding env/config; `--no-open` forces closed.
|
|
63
|
+
*/
|
|
57
64
|
const options = {
|
|
58
|
-
no_open:
|
|
59
|
-
flags.includes('no-open') ||
|
|
60
|
-
String(process.env.BDUI_NO_OPEN || '') === '1'
|
|
65
|
+
no_open: true
|
|
61
66
|
};
|
|
67
|
+
|
|
68
|
+
const has_open = flags.includes('open');
|
|
69
|
+
const has_no_open = flags.includes('no-open');
|
|
70
|
+
const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
|
|
71
|
+
|
|
72
|
+
if (has_open) {
|
|
73
|
+
options.no_open = false;
|
|
74
|
+
} else if (has_no_open) {
|
|
75
|
+
options.no_open = true;
|
|
76
|
+
} else if (env_no_open) {
|
|
77
|
+
options.no_open = true;
|
|
78
|
+
}
|
|
62
79
|
return await handleStart(options);
|
|
63
80
|
}
|
|
64
81
|
if (command === 'stop') {
|
|
65
82
|
return await handleStop();
|
|
66
83
|
}
|
|
67
84
|
if (command === 'restart') {
|
|
68
|
-
|
|
85
|
+
const options = { no_open: true };
|
|
86
|
+
const has_open = flags.includes('open');
|
|
87
|
+
const has_no_open = flags.includes('no-open');
|
|
88
|
+
const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
|
|
89
|
+
|
|
90
|
+
if (has_open) {
|
|
91
|
+
options.no_open = false;
|
|
92
|
+
} else if (has_no_open) {
|
|
93
|
+
options.no_open = true;
|
|
94
|
+
} else if (env_no_open) {
|
|
95
|
+
options.no_open = true;
|
|
96
|
+
}
|
|
97
|
+
return await handleRestart(options);
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
// Unknown command path (should not happen due to parseArgs guard)
|
package/server/cli/usage.js
CHANGED
|
@@ -7,13 +7,13 @@ export function printUsage(out_stream) {
|
|
|
7
7
|
'Usage: bdui <command> [options]',
|
|
8
8
|
'',
|
|
9
9
|
'Commands:',
|
|
10
|
-
' start Start the UI server
|
|
10
|
+
' start Start the UI server',
|
|
11
11
|
' stop Stop the UI server',
|
|
12
12
|
' restart Restart the UI server',
|
|
13
13
|
'',
|
|
14
14
|
'Options:',
|
|
15
15
|
' -h, --help Show this help message',
|
|
16
|
-
' --
|
|
16
|
+
' --open Open the browser after start/restart',
|
|
17
17
|
''
|
|
18
18
|
];
|
|
19
19
|
for (const line of lines) {
|
package/server/config.js
CHANGED
|
@@ -3,27 +3,33 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Resolve runtime configuration for the server.
|
|
6
|
-
*
|
|
6
|
+
* Notes:
|
|
7
|
+
* - `app_dir` is resolved relative to the installed package location.
|
|
8
|
+
* - `root_dir` represents the directory where the process was invoked
|
|
9
|
+
* (i.e., the current working directory) so DB resolution follows the
|
|
10
|
+
* caller's context rather than the install location.
|
|
11
|
+
* @returns {{ host: string, port: number, env: string, app_dir: string, root_dir: string, url: string }}
|
|
7
12
|
*/
|
|
8
13
|
export function getConfig() {
|
|
9
14
|
const this_file = fileURLToPath(new URL(import.meta.url));
|
|
10
15
|
const server_dir = path.dirname(this_file);
|
|
11
|
-
const
|
|
16
|
+
const package_root = path.resolve(server_dir, '..');
|
|
17
|
+
// Always reflect the directory from which the process was started
|
|
18
|
+
const root_dir = process.cwd();
|
|
12
19
|
|
|
13
|
-
/** @type {number} */
|
|
14
20
|
let port_value = Number.parseInt(process.env.PORT || '', 10);
|
|
15
21
|
if (!Number.isFinite(port_value)) {
|
|
16
22
|
port_value = 3000;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
/** @type {string} */
|
|
20
25
|
const host_value = '127.0.0.1';
|
|
21
26
|
|
|
22
27
|
return {
|
|
23
28
|
host: host_value,
|
|
24
29
|
port: port_value,
|
|
25
30
|
env: process.env.NODE_ENV ? String(process.env.NODE_ENV) : 'development',
|
|
26
|
-
app_dir: path.resolve(
|
|
27
|
-
root_dir
|
|
31
|
+
app_dir: path.resolve(package_root, 'app'),
|
|
32
|
+
root_dir,
|
|
33
|
+
url: `http://${host_value}:${port_value}`
|
|
28
34
|
};
|
|
29
35
|
}
|
package/server/db.js
CHANGED
|
@@ -58,7 +58,6 @@ export function findNearestBeadsDb(start) {
|
|
|
58
58
|
const beadsDir = path.join(dir, '.beads');
|
|
59
59
|
try {
|
|
60
60
|
const entries = fs.readdirSync(beadsDir, { withFileTypes: true });
|
|
61
|
-
/** @type {string[]} */
|
|
62
61
|
const dbs = entries
|
|
63
62
|
.filter((e) => e.isFile() && e.name.endsWith('.db'))
|
|
64
63
|
.map((e) => e.name)
|
package/server/index.js
CHANGED
|
@@ -7,14 +7,18 @@ import { attachWsServer } from './ws.js';
|
|
|
7
7
|
const config = getConfig();
|
|
8
8
|
const app = createApp(config);
|
|
9
9
|
const server = createServer(app);
|
|
10
|
-
const {
|
|
10
|
+
const { scheduleListRefresh } = attachWsServer(server, {
|
|
11
11
|
path: '/ws',
|
|
12
|
-
heartbeat_ms: 30000
|
|
12
|
+
heartbeat_ms: 30000,
|
|
13
|
+
// Coalesce DB change bursts into one refresh run
|
|
14
|
+
refresh_debounce_ms: 75
|
|
13
15
|
});
|
|
14
16
|
|
|
15
|
-
// Watch the active beads DB and
|
|
16
|
-
watchDb(config.root_dir, (
|
|
17
|
-
|
|
17
|
+
// Watch the active beads DB and schedule subscription refresh for active lists
|
|
18
|
+
watchDb(config.root_dir, () => {
|
|
19
|
+
// Schedule subscription list refresh run for active subscriptions
|
|
20
|
+
scheduleListRefresh();
|
|
21
|
+
// v2: all updates flow via subscription push envelopes only
|
|
18
22
|
});
|
|
19
23
|
|
|
20
24
|
server.listen(config.port, config.host, () => {
|