beads-enhanced-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/LICENSE +22 -0
- package/README.md +95 -0
- package/app/index.html +49 -0
- package/app/main.bundle.js +999 -0
- package/app/main.bundle.js.map +7 -0
- package/app/protocol.js +216 -0
- package/app/styles.css +2342 -0
- package/bin/bdui.js +19 -0
- package/package.json +90 -0
- package/server/app.js +110 -0
- package/server/bd.js +227 -0
- package/server/cli/commands.js +203 -0
- package/server/cli/daemon.js +271 -0
- package/server/cli/index.js +135 -0
- package/server/cli/open.js +139 -0
- package/server/cli/usage.js +27 -0
- package/server/config.js +36 -0
- package/server/db.js +154 -0
- package/server/index.js +76 -0
- package/server/list-adapters.js +264 -0
- package/server/logging.js +23 -0
- package/server/registry-watcher.js +200 -0
- package/server/subscriptions.js +299 -0
- package/server/validators.js +113 -0
- package/server/watcher.js +139 -0
- package/server/ws.js +1363 -0
package/server/db.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the SQLite DB path used by beads according to precedence:
|
|
7
|
+
* 1) explicit --db flag (provided via options.explicit_db)
|
|
8
|
+
* 2) BEADS_DB environment variable
|
|
9
|
+
* 3) nearest ".beads/*.db" by walking up from cwd (excluding
|
|
10
|
+
* "~/.beads/default.db", which is reserved for fallback)
|
|
11
|
+
* 4) "~/.beads/default.db" fallback
|
|
12
|
+
*
|
|
13
|
+
* Returns a normalized absolute path and a `source` indicator. Existence is
|
|
14
|
+
* returned via the `exists` boolean.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
|
|
17
|
+
* @returns {{ path: string, source: 'flag'|'env'|'nearest'|'home-default', exists: boolean }}
|
|
18
|
+
*/
|
|
19
|
+
export function resolveDbPath(options = {}) {
|
|
20
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
21
|
+
const env = options.env || process.env;
|
|
22
|
+
const home_default = path.join(os.homedir(), '.beads', 'default.db');
|
|
23
|
+
|
|
24
|
+
// 1) explicit flag
|
|
25
|
+
if (options.explicit_db && options.explicit_db.length > 0) {
|
|
26
|
+
const p = absFrom(options.explicit_db, cwd);
|
|
27
|
+
return { path: p, source: 'flag', exists: fileExists(p) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 2) BEADS_DB env
|
|
31
|
+
if (env.BEADS_DB && String(env.BEADS_DB).length > 0) {
|
|
32
|
+
const p = absFrom(String(env.BEADS_DB), cwd);
|
|
33
|
+
return { path: p, source: 'env', exists: fileExists(p) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 3) nearest .beads/*.db walking up
|
|
37
|
+
const nearest = findNearestBeadsDb(cwd);
|
|
38
|
+
if (nearest && path.normalize(nearest) !== path.normalize(home_default)) {
|
|
39
|
+
return { path: nearest, source: 'nearest', exists: fileExists(nearest) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 4) ~/.beads/default.db
|
|
43
|
+
return {
|
|
44
|
+
path: home_default,
|
|
45
|
+
source: 'home-default',
|
|
46
|
+
exists: fileExists(home_default)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the workspace database location used by the UI/server.
|
|
52
|
+
*
|
|
53
|
+
* For non-SQLite backends (for example Dolt), this returns the nearest
|
|
54
|
+
* workspace `.beads` directory when metadata exists. This avoids collapsing
|
|
55
|
+
* all such workspaces onto the `~/.beads/default.db` fallback.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
|
|
58
|
+
* @returns {{ path: string, source: 'flag'|'env'|'nearest'|'metadata'|'home-default', exists: boolean }}
|
|
59
|
+
*/
|
|
60
|
+
export function resolveWorkspaceDatabase(options = {}) {
|
|
61
|
+
const sqlite_db = resolveDbPath(options);
|
|
62
|
+
if (sqlite_db.source !== 'home-default') {
|
|
63
|
+
return sqlite_db;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
|
|
67
|
+
const metadata_path = findNearestBeadsMetadata(cwd);
|
|
68
|
+
if (metadata_path) {
|
|
69
|
+
return {
|
|
70
|
+
path: path.dirname(metadata_path),
|
|
71
|
+
source: 'metadata',
|
|
72
|
+
exists: true
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return sqlite_db;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Find nearest `.beads/metadata.json` by walking up from start.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} start
|
|
83
|
+
* @returns {string | null}
|
|
84
|
+
*/
|
|
85
|
+
export function findNearestBeadsMetadata(start) {
|
|
86
|
+
let dir = path.resolve(start);
|
|
87
|
+
for (let i = 0; i < 100; i++) {
|
|
88
|
+
const metadata_path = path.join(dir, '.beads', 'metadata.json');
|
|
89
|
+
if (fileExists(metadata_path)) {
|
|
90
|
+
return metadata_path;
|
|
91
|
+
}
|
|
92
|
+
const parent = path.dirname(dir);
|
|
93
|
+
if (parent === dir) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
dir = parent;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Find nearest .beads/*.db by walking up from start.
|
|
103
|
+
* First alphabetical .db.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} start
|
|
106
|
+
* @returns {string | null}
|
|
107
|
+
*/
|
|
108
|
+
export function findNearestBeadsDb(start) {
|
|
109
|
+
let dir = path.resolve(start);
|
|
110
|
+
// Cap iterations to avoid infinite loop in degenerate cases
|
|
111
|
+
for (let i = 0; i < 100; i++) {
|
|
112
|
+
const beads_dir = path.join(dir, '.beads');
|
|
113
|
+
try {
|
|
114
|
+
const entries = fs.readdirSync(beads_dir, { withFileTypes: true });
|
|
115
|
+
const dbs = entries
|
|
116
|
+
.filter((e) => e.isFile() && e.name.endsWith('.db'))
|
|
117
|
+
.map((e) => e.name)
|
|
118
|
+
.sort();
|
|
119
|
+
if (dbs.length > 0) {
|
|
120
|
+
return path.join(beads_dir, dbs[0]);
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore and walk up
|
|
124
|
+
}
|
|
125
|
+
const parent = path.dirname(dir);
|
|
126
|
+
if (parent === dir) {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
dir = parent;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve possibly relative `p` against `cwd` to an absolute filesystem path.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} p
|
|
138
|
+
* @param {string} cwd
|
|
139
|
+
*/
|
|
140
|
+
function absFrom(p, cwd) {
|
|
141
|
+
return path.isAbsolute(p) ? path.normalize(p) : path.join(cwd, p);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} p
|
|
146
|
+
*/
|
|
147
|
+
function fileExists(p) {
|
|
148
|
+
try {
|
|
149
|
+
fs.accessSync(p, fs.constants.F_OK);
|
|
150
|
+
return true;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createApp } from './app.js';
|
|
3
|
+
import { printServerUrl } from './cli/daemon.js';
|
|
4
|
+
import { getConfig } from './config.js';
|
|
5
|
+
import { resolveWorkspaceDatabase } from './db.js';
|
|
6
|
+
import { debug, enableAllDebug } from './logging.js';
|
|
7
|
+
import { registerWorkspace, watchRegistry } from './registry-watcher.js';
|
|
8
|
+
import { watchDb } from './watcher.js';
|
|
9
|
+
import { attachWsServer } from './ws.js';
|
|
10
|
+
|
|
11
|
+
if (process.argv.includes('--debug') || process.argv.includes('-d')) {
|
|
12
|
+
enableAllDebug();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Parse --host and --port from argv and set env vars before getConfig()
|
|
16
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
17
|
+
if (process.argv[i] === '--host' && process.argv[i + 1]) {
|
|
18
|
+
process.env.HOST = process.argv[++i];
|
|
19
|
+
}
|
|
20
|
+
if (process.argv[i] === '--port' && process.argv[i + 1]) {
|
|
21
|
+
process.env.PORT = process.argv[++i];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const config = getConfig();
|
|
26
|
+
const app = createApp(config);
|
|
27
|
+
const server = createServer(app);
|
|
28
|
+
const log = debug('server');
|
|
29
|
+
|
|
30
|
+
// Register the initial workspace (from cwd) so it appears in the workspace picker
|
|
31
|
+
// even without the beads daemon running
|
|
32
|
+
const workspace_database = resolveWorkspaceDatabase({ cwd: config.root_dir });
|
|
33
|
+
if (workspace_database.source !== 'home-default' && workspace_database.exists) {
|
|
34
|
+
registerWorkspace({
|
|
35
|
+
path: config.root_dir,
|
|
36
|
+
database: workspace_database.path
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Watch the active beads DB and schedule subscription refresh for active lists
|
|
41
|
+
const db_watcher = watchDb(config.root_dir, () => {
|
|
42
|
+
// Schedule subscription list refresh run for active subscriptions
|
|
43
|
+
log('db change detected → schedule refresh');
|
|
44
|
+
scheduleListRefresh();
|
|
45
|
+
// v2: all updates flow via subscription push envelopes only
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const { scheduleListRefresh } = attachWsServer(server, {
|
|
49
|
+
path: '/ws',
|
|
50
|
+
heartbeat_ms: 30000,
|
|
51
|
+
// Coalesce DB change bursts into one refresh run
|
|
52
|
+
refresh_debounce_ms: 75,
|
|
53
|
+
root_dir: config.root_dir,
|
|
54
|
+
watcher: db_watcher
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Watch the global registry for workspace changes (e.g., when user starts
|
|
58
|
+
// bd daemon in a different project). This enables automatic workspace switching.
|
|
59
|
+
watchRegistry(
|
|
60
|
+
(entries) => {
|
|
61
|
+
log('registry changed: %d entries', entries.length);
|
|
62
|
+
// Find if there's a newer workspace that matches our initial root
|
|
63
|
+
// For now, we just log the change - users can switch via set-workspace
|
|
64
|
+
// Future: could auto-switch if a workspace was started in a parent/child dir
|
|
65
|
+
},
|
|
66
|
+
{ debounce_ms: 500 }
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
server.listen(config.port, config.host, () => {
|
|
70
|
+
printServerUrl();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
server.on('error', (err) => {
|
|
74
|
+
log('server error %o', err);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { runBdJson } from './bd.js';
|
|
2
|
+
import { debug } from './logging.js';
|
|
3
|
+
|
|
4
|
+
const log = debug('list-adapters');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build concrete `bd` CLI args for a subscription type + params.
|
|
8
|
+
* Always includes `--json` for parseable output.
|
|
9
|
+
*
|
|
10
|
+
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
11
|
+
* @returns {string[]}
|
|
12
|
+
*/
|
|
13
|
+
export function mapSubscriptionToBdArgs(spec) {
|
|
14
|
+
const t = String(spec.type);
|
|
15
|
+
switch (t) {
|
|
16
|
+
case 'all-issues': {
|
|
17
|
+
return ['list', '--json', '--tree=false'];
|
|
18
|
+
}
|
|
19
|
+
case 'epics': {
|
|
20
|
+
return ['epic', 'status', '--json'];
|
|
21
|
+
}
|
|
22
|
+
case 'blocked-issues': {
|
|
23
|
+
return ['blocked', '--json'];
|
|
24
|
+
}
|
|
25
|
+
case 'ready-issues': {
|
|
26
|
+
return ['ready', '--limit', '1000', '--json'];
|
|
27
|
+
}
|
|
28
|
+
case 'in-progress-issues': {
|
|
29
|
+
return ['list', '--json', '--tree=false', '--status', 'in_progress'];
|
|
30
|
+
}
|
|
31
|
+
case 'closed-issues': {
|
|
32
|
+
return [
|
|
33
|
+
'list',
|
|
34
|
+
'--json',
|
|
35
|
+
'--tree=false',
|
|
36
|
+
'--status',
|
|
37
|
+
'closed',
|
|
38
|
+
'--limit',
|
|
39
|
+
'1000'
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
case 'issue-detail': {
|
|
43
|
+
const p = spec.params || {};
|
|
44
|
+
const id = String(p.id || '').trim();
|
|
45
|
+
if (id.length === 0) {
|
|
46
|
+
throw badRequest('Missing param: params.id');
|
|
47
|
+
}
|
|
48
|
+
return ['show', id, '--json'];
|
|
49
|
+
}
|
|
50
|
+
default: {
|
|
51
|
+
throw badRequest(`Unknown subscription type: ${t}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize bd list output to minimal Issue shape used by the registry.
|
|
58
|
+
* - Ensures `id` is a string.
|
|
59
|
+
* - Coerces timestamps to numbers.
|
|
60
|
+
* - `closed_at` defaults to null when missing or invalid.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} value
|
|
63
|
+
* @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
|
|
64
|
+
*/
|
|
65
|
+
export function normalizeIssueList(value) {
|
|
66
|
+
if (!Array.isArray(value)) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
/** @type {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>} */
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const it of value) {
|
|
72
|
+
const id = String(it.id ?? '');
|
|
73
|
+
if (id.length === 0) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const created_at = parseTimestamp(/** @type {any} */ (it).created_at);
|
|
77
|
+
const updated_at = parseTimestamp(it.updated_at);
|
|
78
|
+
const closed_raw = it.closed_at;
|
|
79
|
+
/** @type {number | null} */
|
|
80
|
+
let closed_at = null;
|
|
81
|
+
if (closed_raw !== undefined && closed_raw !== null) {
|
|
82
|
+
const n = parseTimestamp(closed_raw);
|
|
83
|
+
closed_at = Number.isFinite(n) ? n : null;
|
|
84
|
+
}
|
|
85
|
+
out.push({
|
|
86
|
+
...it,
|
|
87
|
+
id,
|
|
88
|
+
created_at: Number.isFinite(created_at) ? created_at : 0,
|
|
89
|
+
updated_at: Number.isFinite(updated_at) ? updated_at : 0,
|
|
90
|
+
closed_at
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @typedef {Object} FetchListResultSuccess
|
|
98
|
+
* @property {true} ok
|
|
99
|
+
* @property {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {Object} FetchListResultFailure
|
|
104
|
+
* @property {false} ok
|
|
105
|
+
* @property {{ code: string, message: string, details?: Record<string, unknown> }} error
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Execute the mapped `bd` command for a subscription spec and return normalized items.
|
|
110
|
+
* Errors do not throw; they are surfaced as a structured object.
|
|
111
|
+
*
|
|
112
|
+
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
113
|
+
* @param {{ cwd?: string }} [options] - Optional working directory for bd command
|
|
114
|
+
* @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
|
|
115
|
+
*/
|
|
116
|
+
export async function fetchListForSubscription(spec, options = {}) {
|
|
117
|
+
/** @type {string[]} */
|
|
118
|
+
let args;
|
|
119
|
+
try {
|
|
120
|
+
args = mapSubscriptionToBdArgs(spec);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Surface bad requests (e.g., missing params)
|
|
123
|
+
log('mapSubscriptionToBdArgs failed for %o: %o', spec, err);
|
|
124
|
+
const e = toErrorObject(err);
|
|
125
|
+
return { ok: false, error: e };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const res = await runBdJson(args, { cwd: options.cwd });
|
|
130
|
+
if (!res || res.code !== 0 || !('stdoutJson' in res)) {
|
|
131
|
+
log(
|
|
132
|
+
'bd failed for %o (args=%o) code=%s stderr=%s',
|
|
133
|
+
spec,
|
|
134
|
+
args,
|
|
135
|
+
res?.code,
|
|
136
|
+
res?.stderr || ''
|
|
137
|
+
);
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
error: {
|
|
141
|
+
code: 'bd_error',
|
|
142
|
+
message: String(res?.stderr || 'bd failed'),
|
|
143
|
+
details: { exit_code: res?.code ?? -1 }
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// bd show may return a single object; normalize to an array first
|
|
148
|
+
let raw = Array.isArray(res.stdoutJson)
|
|
149
|
+
? res.stdoutJson
|
|
150
|
+
: res.stdoutJson && typeof res.stdoutJson === 'object'
|
|
151
|
+
? [res.stdoutJson]
|
|
152
|
+
: [];
|
|
153
|
+
|
|
154
|
+
// Special-case mapping for `epics`: current bd output nests the epic under
|
|
155
|
+
// an `epic` key and exposes counters at the top level. Flatten so that
|
|
156
|
+
// each entry has a top-level `id` and core fields expected by the registry.
|
|
157
|
+
if (String(spec.type) === 'epics') {
|
|
158
|
+
raw = raw.map((it) => {
|
|
159
|
+
if (it && typeof it === 'object' && 'epic' in it) {
|
|
160
|
+
const e = /** @type {any} */ (it).epic || {};
|
|
161
|
+
/** @type {Record<string, unknown>} */
|
|
162
|
+
const flat = {
|
|
163
|
+
// Required minimal fields for registry + client rendering
|
|
164
|
+
id: String(e.id ?? ''),
|
|
165
|
+
title: e.title,
|
|
166
|
+
status: e.status,
|
|
167
|
+
issue_type: e.issue_type || 'epic',
|
|
168
|
+
created_at: e.created_at,
|
|
169
|
+
updated_at: e.updated_at,
|
|
170
|
+
closed_at: e.closed_at ?? null,
|
|
171
|
+
deleted_at: e.deleted_at ?? null,
|
|
172
|
+
// Preserve useful counters from bd output
|
|
173
|
+
total_children: /** @type {any} */ (it).total_children,
|
|
174
|
+
closed_children: /** @type {any} */ (it).closed_children,
|
|
175
|
+
eligible_for_close: /** @type {any} */ (it).eligible_for_close
|
|
176
|
+
};
|
|
177
|
+
return flat;
|
|
178
|
+
}
|
|
179
|
+
return it;
|
|
180
|
+
});
|
|
181
|
+
raw = raw.filter((it) => {
|
|
182
|
+
if (!it || typeof it !== 'object') {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
const status =
|
|
186
|
+
typeof (/** @type {any} */ (it).status) === 'string'
|
|
187
|
+
? /** @type {any} */ (it).status
|
|
188
|
+
: '';
|
|
189
|
+
if (status === 'tombstone') {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
const deleted_at = /** @type {any} */ (it).deleted_at;
|
|
193
|
+
if (deleted_at !== undefined && deleted_at !== null) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const items = normalizeIssueList(raw);
|
|
201
|
+
return { ok: true, items };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
log('bd invocation failed for %o (args=%o): %o', spec, args, err);
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: {
|
|
207
|
+
code: 'bd_error',
|
|
208
|
+
message:
|
|
209
|
+
(err && /** @type {any} */ (err).message) || 'bd invocation failed'
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Create a `bad_request` error object.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} message
|
|
219
|
+
*/
|
|
220
|
+
function badRequest(message) {
|
|
221
|
+
const e = new Error(message);
|
|
222
|
+
// @ts-expect-error add code
|
|
223
|
+
e.code = 'bad_request';
|
|
224
|
+
return e;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Normalize arbitrary thrown values to a structured error object.
|
|
229
|
+
*
|
|
230
|
+
* @param {unknown} err
|
|
231
|
+
* @returns {FetchListResultFailure['error']}
|
|
232
|
+
*/
|
|
233
|
+
function toErrorObject(err) {
|
|
234
|
+
if (err && typeof err === 'object') {
|
|
235
|
+
const any = /** @type {{ code?: unknown, message?: unknown }} */ (err);
|
|
236
|
+
const code = typeof any.code === 'string' ? any.code : 'bad_request';
|
|
237
|
+
const message =
|
|
238
|
+
typeof any.message === 'string' ? any.message : 'Request error';
|
|
239
|
+
return { code, message };
|
|
240
|
+
}
|
|
241
|
+
return { code: 'bad_request', message: 'Request error' };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parse a bd timestamp string to epoch ms using Date.parse.
|
|
246
|
+
* Falls back to numeric coercion when parsing fails.
|
|
247
|
+
*
|
|
248
|
+
* @param {unknown} v
|
|
249
|
+
* @returns {number}
|
|
250
|
+
*/
|
|
251
|
+
function parseTimestamp(v) {
|
|
252
|
+
if (typeof v === 'string') {
|
|
253
|
+
const ms = Date.parse(v);
|
|
254
|
+
if (Number.isFinite(ms)) {
|
|
255
|
+
return ms;
|
|
256
|
+
}
|
|
257
|
+
const n = Number(v);
|
|
258
|
+
return Number.isFinite(n) ? n : 0;
|
|
259
|
+
}
|
|
260
|
+
if (typeof v === 'number') {
|
|
261
|
+
return Number.isFinite(v) ? v : 0;
|
|
262
|
+
}
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger helper for Node server/CLI.
|
|
3
|
+
*/
|
|
4
|
+
import createDebug from 'debug';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a namespaced logger for Node runtime.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} ns - Module namespace suffix (e.g., 'ws', 'watcher').
|
|
10
|
+
*/
|
|
11
|
+
export function debug(ns) {
|
|
12
|
+
return createDebug(`beads-ui:${ns}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Enable all `beads-ui:*` debug logs at runtime for Node/CLI.
|
|
17
|
+
* Safe to call multiple times.
|
|
18
|
+
*/
|
|
19
|
+
export function enableAllDebug() {
|
|
20
|
+
// `debug` exposes a global enable/disable API.
|
|
21
|
+
// Enabling after loggers are created updates their `.enabled` state.
|
|
22
|
+
createDebug.enable(process.env.DEBUG || 'beads-ui:*');
|
|
23
|
+
}
|