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/bin/bdui.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Thin CLI entry for `bdui`.
|
|
4
|
+
* Delegates to `server/cli/index.js` and sets the process exit code.
|
|
5
|
+
*/
|
|
6
|
+
import { main } from '../server/cli/index.js';
|
|
7
|
+
import { debug } from '../server/logging.js';
|
|
8
|
+
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const code = await main(argv);
|
|
13
|
+
if (Number.isFinite(code)) {
|
|
14
|
+
process.exitCode = code;
|
|
15
|
+
}
|
|
16
|
+
} catch (err) {
|
|
17
|
+
debug('cli')('fatal %o', err);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "beads-enhanced-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Enhanced local UI for Beads with improved epic rows, status labels, and UX refinements.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"issue-tracker",
|
|
8
|
+
"local-first",
|
|
9
|
+
"ai-tools"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/AlexeyPlatkovsky/beads-enhanced-ui#readme",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/AlexeyPlatkovsky/beads-enhanced-ui.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/AlexeyPlatkovsky/beads-enhanced-ui/issues"
|
|
18
|
+
},
|
|
19
|
+
"author": "Alexey Platkovsky",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"bdui": "bin/bdui.js"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"all": "npm run lint && npm run tsc && npm test && npm run test:coverage:app && npm run prettier:check",
|
|
30
|
+
"start": "node server/index.js --debug",
|
|
31
|
+
"build": "node scripts/build-frontend.js",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"test:coverage": "vitest run --coverage",
|
|
35
|
+
"test:coverage:app": "vitest run --project=jsdom --coverage",
|
|
36
|
+
"test:e2e": "playwright test",
|
|
37
|
+
"test:e2e:ui": "playwright test --ui",
|
|
38
|
+
"tsc": "tsc -p tsconfig.json --noEmit",
|
|
39
|
+
"lint": "eslint --ext .js .",
|
|
40
|
+
"prettier:write": "prettier --write .",
|
|
41
|
+
"prettier:check": "prettier --check .",
|
|
42
|
+
"preversion": "npm run all",
|
|
43
|
+
"version": "changes --commits --footer",
|
|
44
|
+
"postversion": "git push --follow-tags && npm publish",
|
|
45
|
+
"prepack": "npm run build",
|
|
46
|
+
"postpack": "rm app/main.bundle.js app/main.bundle.js.map"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"debug": "^4.4.3",
|
|
50
|
+
"dompurify": "^3.3.0",
|
|
51
|
+
"express": "^5.2.1",
|
|
52
|
+
"lit-html": "^3.3.1",
|
|
53
|
+
"marked": "^17.0.1",
|
|
54
|
+
"ws": "^8.18.3"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@eslint/js": "^9.39.1",
|
|
58
|
+
"@playwright/test": "^1.58.2",
|
|
59
|
+
"@studio/changes": "^3.0.0",
|
|
60
|
+
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
|
61
|
+
"@types/debug": "^4.1.12",
|
|
62
|
+
"@types/express": "^5.0.6",
|
|
63
|
+
"@types/node": "^22.19.1",
|
|
64
|
+
"@types/ws": "^8.18.1",
|
|
65
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
66
|
+
"esbuild": "^0.27.1",
|
|
67
|
+
"eslint": "^9.39.1",
|
|
68
|
+
"eslint-plugin-import": "^2.29.1",
|
|
69
|
+
"eslint-plugin-jsdoc": "^61.4.1",
|
|
70
|
+
"eslint-plugin-n": "^17.9.0",
|
|
71
|
+
"globals": "^16.5.0",
|
|
72
|
+
"jsdom": "^27.2.0",
|
|
73
|
+
"prettier": "^3.7.4",
|
|
74
|
+
"typescript": "^5.6.3",
|
|
75
|
+
"vitest": "^4.0.15"
|
|
76
|
+
},
|
|
77
|
+
"files": [
|
|
78
|
+
"app/index.html",
|
|
79
|
+
"app/styles.css",
|
|
80
|
+
"app/main.bundle.js",
|
|
81
|
+
"app/main.bundle.js.map",
|
|
82
|
+
"app/protocol.js",
|
|
83
|
+
"bin",
|
|
84
|
+
"server",
|
|
85
|
+
"CHANGES.md",
|
|
86
|
+
"LICENSE",
|
|
87
|
+
"README.md",
|
|
88
|
+
"!**/*.test.js"
|
|
89
|
+
]
|
|
90
|
+
}
|
package/server/app.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Express, Request, Response } from 'express'
|
|
3
|
+
*/
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { registerWorkspace } from './registry-watcher.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create and configure the Express application.
|
|
11
|
+
*
|
|
12
|
+
* @param {{ host: string, port: number, app_dir: string, root_dir: string }} config - Server configuration.
|
|
13
|
+
* @returns {Express} Configured Express app instance.
|
|
14
|
+
*/
|
|
15
|
+
export function createApp(config) {
|
|
16
|
+
const app = express();
|
|
17
|
+
|
|
18
|
+
// Basic hardening and config
|
|
19
|
+
app.disable('x-powered-by');
|
|
20
|
+
|
|
21
|
+
// Health endpoint
|
|
22
|
+
/**
|
|
23
|
+
* @param {Request} _req
|
|
24
|
+
* @param {Response} res
|
|
25
|
+
*/
|
|
26
|
+
app.get('/healthz', (_req, res) => {
|
|
27
|
+
res.type('application/json');
|
|
28
|
+
res.status(200).send({ ok: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Enable JSON body parsing for API endpoints
|
|
32
|
+
app.use(express.json());
|
|
33
|
+
|
|
34
|
+
// Register workspace endpoint - allows CLI to register workspaces dynamically
|
|
35
|
+
// when the server is already running
|
|
36
|
+
/**
|
|
37
|
+
* @param {Request} req
|
|
38
|
+
* @param {Response} res
|
|
39
|
+
*/
|
|
40
|
+
app.post('/api/register-workspace', (req, res) => {
|
|
41
|
+
const { path: workspace_path, database } = req.body || {};
|
|
42
|
+
if (!workspace_path || typeof workspace_path !== 'string') {
|
|
43
|
+
res.status(400).json({ ok: false, error: 'Missing or invalid path' });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!database || typeof database !== 'string') {
|
|
47
|
+
res.status(400).json({ ok: false, error: 'Missing or invalid database' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
registerWorkspace({ path: workspace_path, database });
|
|
51
|
+
res.status(200).json({ ok: true, registered: workspace_path });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
!fs.statSync(path.resolve(config.app_dir, 'main.bundle.js'), {
|
|
56
|
+
throwIfNoEntry: false
|
|
57
|
+
})
|
|
58
|
+
) {
|
|
59
|
+
/**
|
|
60
|
+
* On-demand bundle for the browser using esbuild.
|
|
61
|
+
*
|
|
62
|
+
* @param {Request} _req
|
|
63
|
+
* @param {Response} res
|
|
64
|
+
*/
|
|
65
|
+
app.get('/main.bundle.js', async (_req, res) => {
|
|
66
|
+
try {
|
|
67
|
+
const esbuild = await import('esbuild');
|
|
68
|
+
const entry = path.join(config.app_dir, 'main.js');
|
|
69
|
+
const result = await esbuild.build({
|
|
70
|
+
entryPoints: [entry],
|
|
71
|
+
bundle: true,
|
|
72
|
+
format: 'esm',
|
|
73
|
+
platform: 'browser',
|
|
74
|
+
target: 'es2020',
|
|
75
|
+
sourcemap: 'inline',
|
|
76
|
+
minify: false,
|
|
77
|
+
write: false
|
|
78
|
+
});
|
|
79
|
+
const out = result.outputFiles && result.outputFiles[0];
|
|
80
|
+
if (!out) {
|
|
81
|
+
res.status(500).type('text/plain').send('Bundle failed: no output');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
85
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
86
|
+
res.send(out.text);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
res
|
|
89
|
+
.status(500)
|
|
90
|
+
.type('text/plain')
|
|
91
|
+
.send('Bundle error: ' + (err && /** @type {any} */ (err).message));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Static assets from /app
|
|
97
|
+
app.use(express.static(config.app_dir));
|
|
98
|
+
|
|
99
|
+
// Root serves index.html explicitly (even if static would catch it)
|
|
100
|
+
/**
|
|
101
|
+
* @param {Request} _req
|
|
102
|
+
* @param {Response} res
|
|
103
|
+
*/
|
|
104
|
+
app.get('/', (_req, res) => {
|
|
105
|
+
const index_path = path.join(config.app_dir, 'index.html');
|
|
106
|
+
res.sendFile(index_path);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return app;
|
|
110
|
+
}
|
package/server/bd.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolveDbPath } from './db.js';
|
|
3
|
+
import { debug } from './logging.js';
|
|
4
|
+
|
|
5
|
+
const log = debug('bd');
|
|
6
|
+
/** @type {Promise<void>} */
|
|
7
|
+
let bd_run_queue = Promise.resolve();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the git user name from git config.
|
|
11
|
+
*
|
|
12
|
+
* @param {{ cwd?: string }} [options]
|
|
13
|
+
* @returns {Promise<string>}
|
|
14
|
+
*/
|
|
15
|
+
export async function getGitUserName(options = {}) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
const child = spawn('git', ['config', 'user.name'], {
|
|
18
|
+
cwd: options.cwd || process.cwd(),
|
|
19
|
+
shell: false,
|
|
20
|
+
windowsHide: true
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** @type {string[]} */
|
|
24
|
+
const chunks = [];
|
|
25
|
+
|
|
26
|
+
if (child.stdout) {
|
|
27
|
+
child.stdout.setEncoding('utf8');
|
|
28
|
+
child.stdout.on('data', (chunk) => chunks.push(String(chunk)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
child.on('error', () => resolve(''));
|
|
32
|
+
child.on('close', (code) => {
|
|
33
|
+
if (code !== 0) {
|
|
34
|
+
resolve('');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
resolve(chunks.join('').trim());
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the bd executable path.
|
|
44
|
+
*
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function getBdBin() {
|
|
48
|
+
const env_value = process.env.BD_BIN;
|
|
49
|
+
if (env_value && env_value.length > 0) {
|
|
50
|
+
return env_value;
|
|
51
|
+
}
|
|
52
|
+
return 'bd';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run the `bd` CLI with provided arguments.
|
|
57
|
+
* Shell is not used to avoid injection; args must be pre-split.
|
|
58
|
+
*
|
|
59
|
+
* @param {string[]} args - Arguments to pass (e.g., ["list", "--json"]).
|
|
60
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
|
|
61
|
+
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
|
|
62
|
+
*/
|
|
63
|
+
export function runBd(args, options = {}) {
|
|
64
|
+
return withBdRunQueue(async () => runBdUnlocked(args, options));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run the `bd` CLI with provided arguments without queueing.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} args
|
|
71
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
|
|
72
|
+
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
|
|
73
|
+
*/
|
|
74
|
+
function runBdUnlocked(args, options = {}) {
|
|
75
|
+
const bin = getBdBin();
|
|
76
|
+
|
|
77
|
+
// Set BEADS_DB only when the workspace has a local SQLite DB.
|
|
78
|
+
// Do not force BEADS_DB from global fallback paths; this can override
|
|
79
|
+
// backend autodetection in non-SQLite workspaces (for example Dolt).
|
|
80
|
+
const db_path = resolveDbPath({
|
|
81
|
+
cwd: options.cwd || process.cwd(),
|
|
82
|
+
env: options.env || process.env
|
|
83
|
+
});
|
|
84
|
+
const env_with_db = { ...(options.env || process.env) };
|
|
85
|
+
if (db_path.source === 'nearest' && db_path.exists) {
|
|
86
|
+
env_with_db.BEADS_DB = db_path.path;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const spawn_opts = {
|
|
90
|
+
cwd: options.cwd || process.cwd(),
|
|
91
|
+
env: env_with_db,
|
|
92
|
+
shell: false,
|
|
93
|
+
windowsHide: true
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** @type {string[]} */
|
|
97
|
+
const final_args = buildBdArgs(args);
|
|
98
|
+
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const child = spawn(bin, final_args, spawn_opts);
|
|
101
|
+
|
|
102
|
+
/** @type {string[]} */
|
|
103
|
+
const out_chunks = [];
|
|
104
|
+
/** @type {string[]} */
|
|
105
|
+
const err_chunks = [];
|
|
106
|
+
|
|
107
|
+
if (child.stdout) {
|
|
108
|
+
child.stdout.setEncoding('utf8');
|
|
109
|
+
child.stdout.on('data', (chunk) => {
|
|
110
|
+
out_chunks.push(String(chunk));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (child.stderr) {
|
|
114
|
+
child.stderr.setEncoding('utf8');
|
|
115
|
+
child.stderr.on('data', (chunk) => {
|
|
116
|
+
err_chunks.push(String(chunk));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
121
|
+
let timer;
|
|
122
|
+
if (options.timeout_ms && options.timeout_ms > 0) {
|
|
123
|
+
timer = setTimeout(() => {
|
|
124
|
+
child.kill('SIGKILL');
|
|
125
|
+
}, options.timeout_ms);
|
|
126
|
+
timer.unref?.();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {number | string | null} code
|
|
131
|
+
*/
|
|
132
|
+
const finish = (code) => {
|
|
133
|
+
if (timer) {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
}
|
|
136
|
+
resolve({
|
|
137
|
+
code: Number(code || 0),
|
|
138
|
+
stdout: out_chunks.join(''),
|
|
139
|
+
stderr: err_chunks.join('')
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
child.on('error', (err) => {
|
|
144
|
+
// Treat spawn error as an immediate non-zero exit; log for diagnostics.
|
|
145
|
+
log('spawn error running %s %o', bin, err);
|
|
146
|
+
finish(127);
|
|
147
|
+
});
|
|
148
|
+
child.on('close', (code) => {
|
|
149
|
+
finish(code);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build final bd CLI arguments.
|
|
156
|
+
* bdui defaults to sandbox mode to avoid sync/autopush overhead on interactive
|
|
157
|
+
* UI requests. Set `BDUI_BD_SANDBOX=0` (or "false") to opt out.
|
|
158
|
+
*
|
|
159
|
+
* @param {string[]} args
|
|
160
|
+
* @returns {string[]}
|
|
161
|
+
*/
|
|
162
|
+
function buildBdArgs(args) {
|
|
163
|
+
const arg_set = new Set(args);
|
|
164
|
+
const raw_sandbox = String(process.env.BDUI_BD_SANDBOX || '').toLowerCase();
|
|
165
|
+
const sandbox_disabled = raw_sandbox === '0' || raw_sandbox === 'false';
|
|
166
|
+
const should_prepend_sandbox = !sandbox_disabled && !arg_set.has('--sandbox');
|
|
167
|
+
|
|
168
|
+
if (!should_prepend_sandbox) {
|
|
169
|
+
return args.slice();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return ['--sandbox', ...args];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Serialize `bd` invocations.
|
|
177
|
+
* Dolt embedded mode can crash when multiple `bd` processes run concurrently
|
|
178
|
+
* against the same workspace.
|
|
179
|
+
*
|
|
180
|
+
* @template T
|
|
181
|
+
* @param {() => Promise<T>} operation
|
|
182
|
+
* @returns {Promise<T>}
|
|
183
|
+
*/
|
|
184
|
+
async function withBdRunQueue(operation) {
|
|
185
|
+
const previous = bd_run_queue;
|
|
186
|
+
/** @type {() => void} */
|
|
187
|
+
let release = () => {};
|
|
188
|
+
bd_run_queue = new Promise((resolve) => {
|
|
189
|
+
release = resolve;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await previous.catch(() => {});
|
|
193
|
+
try {
|
|
194
|
+
return await operation();
|
|
195
|
+
} finally {
|
|
196
|
+
release();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Run `bd` and parse JSON from stdout if exit code is 0.
|
|
202
|
+
*
|
|
203
|
+
* @param {string[]} args - Must include flags that cause JSON to be printed (e.g., `--json`).
|
|
204
|
+
* @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
|
|
205
|
+
* @returns {Promise<{ code: number, stdoutJson?: unknown, stderr?: string }>}
|
|
206
|
+
*/
|
|
207
|
+
export async function runBdJson(args, options = {}) {
|
|
208
|
+
const result = await runBd(args, options);
|
|
209
|
+
if (result.code !== 0) {
|
|
210
|
+
log(
|
|
211
|
+
'bd exited with code %d (args=%o) stderr=%s',
|
|
212
|
+
result.code,
|
|
213
|
+
args,
|
|
214
|
+
result.stderr
|
|
215
|
+
);
|
|
216
|
+
return { code: result.code, stderr: result.stderr };
|
|
217
|
+
}
|
|
218
|
+
/** @type {unknown} */
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(result.stdout || 'null');
|
|
222
|
+
} catch (err) {
|
|
223
|
+
log('bd returned invalid JSON (args=%o): %o', args, err);
|
|
224
|
+
return { code: 0, stderr: 'Invalid JSON from bd' };
|
|
225
|
+
}
|
|
226
|
+
return { code: 0, stdoutJson: parsed };
|
|
227
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { getConfig } from '../config.js';
|
|
2
|
+
import { resolveWorkspaceDatabase } from '../db.js';
|
|
3
|
+
import {
|
|
4
|
+
isProcessRunning,
|
|
5
|
+
printServerUrl,
|
|
6
|
+
readPidFile,
|
|
7
|
+
removePidFile,
|
|
8
|
+
startDaemon,
|
|
9
|
+
terminateProcess
|
|
10
|
+
} from './daemon.js';
|
|
11
|
+
import { openUrl, registerWorkspaceWithServer, waitForServer } from './open.js';
|
|
12
|
+
|
|
13
|
+
const STARTUP_SETTLE_MS = 200;
|
|
14
|
+
const REGISTER_RETRY_ATTEMPTS = 5;
|
|
15
|
+
const REGISTER_RETRY_DELAY_MS = 150;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle `start` command. Idempotent when already running.
|
|
19
|
+
* - Spawns a detached server process, writes PID file, returns 0.
|
|
20
|
+
* - If already running (PID file present and process alive), prints URL and returns 0.
|
|
21
|
+
*
|
|
22
|
+
* @param {{ open?: boolean, is_debug?: boolean, host?: string, port?: number }} [options]
|
|
23
|
+
* @returns {Promise<number>} Exit code (0 on success)
|
|
24
|
+
*/
|
|
25
|
+
export async function handleStart(options) {
|
|
26
|
+
// Default: do not open a browser unless explicitly requested via `open: true`.
|
|
27
|
+
const should_open = options?.open === true;
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
|
|
30
|
+
// Set env vars early so getConfig() reflects CLI overrides in ALL branches,
|
|
31
|
+
// including the "already running" path that registers workspaces via HTTP.
|
|
32
|
+
if (options?.host) {
|
|
33
|
+
process.env.HOST = options.host;
|
|
34
|
+
}
|
|
35
|
+
if (options?.port) {
|
|
36
|
+
process.env.PORT = String(options.port);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const existing_pid = readPidFile();
|
|
40
|
+
if (existing_pid && isProcessRunning(existing_pid)) {
|
|
41
|
+
// Server is already running - register this workspace dynamically
|
|
42
|
+
const { url } = getConfig();
|
|
43
|
+
const registered = await registerCurrentWorkspace(url, cwd);
|
|
44
|
+
if (registered) {
|
|
45
|
+
console.log('Workspace registered: %s', cwd);
|
|
46
|
+
}
|
|
47
|
+
console.warn('Server is already running.');
|
|
48
|
+
if (should_open) {
|
|
49
|
+
await openUrl(url);
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
if (existing_pid && !isProcessRunning(existing_pid)) {
|
|
54
|
+
// stale PID file
|
|
55
|
+
removePidFile();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { url } = getConfig();
|
|
59
|
+
|
|
60
|
+
const started = startDaemon({
|
|
61
|
+
is_debug: options?.is_debug,
|
|
62
|
+
host: options?.host,
|
|
63
|
+
port: options?.port
|
|
64
|
+
});
|
|
65
|
+
if (started && started.pid > 0) {
|
|
66
|
+
// Give the spawned daemon a brief moment to fail fast (for example EADDRINUSE).
|
|
67
|
+
await sleep(STARTUP_SETTLE_MS);
|
|
68
|
+
|
|
69
|
+
if (!isProcessRunning(started.pid)) {
|
|
70
|
+
removePidFile();
|
|
71
|
+
|
|
72
|
+
// If another server is already running at the configured URL, register this
|
|
73
|
+
// workspace there so it appears in the picker instead of silently missing.
|
|
74
|
+
const registered = await registerCurrentWorkspaceWithRetry(url, cwd);
|
|
75
|
+
if (registered) {
|
|
76
|
+
console.warn(
|
|
77
|
+
'Daemon exited early; registered workspace with existing server: %s',
|
|
78
|
+
cwd
|
|
79
|
+
);
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Register against the currently reachable server to ensure this workspace
|
|
86
|
+
// appears in the picker even when startup races with other daemons.
|
|
87
|
+
void registerCurrentWorkspaceWithRetry(url, cwd);
|
|
88
|
+
|
|
89
|
+
printServerUrl();
|
|
90
|
+
// Auto-open the browser once for a fresh daemon start
|
|
91
|
+
if (should_open) {
|
|
92
|
+
// Wait briefly for the server to accept connections (single retry window)
|
|
93
|
+
await waitForServer(url, 600);
|
|
94
|
+
// Best-effort open; ignore result
|
|
95
|
+
await openUrl(url);
|
|
96
|
+
}
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {number} ms
|
|
105
|
+
* @returns {Promise<void>}
|
|
106
|
+
*/
|
|
107
|
+
function sleep(ms) {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
setTimeout(() => {
|
|
110
|
+
resolve();
|
|
111
|
+
}, ms);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string} url
|
|
117
|
+
* @param {string} cwd
|
|
118
|
+
* @returns {Promise<boolean>}
|
|
119
|
+
*/
|
|
120
|
+
async function registerCurrentWorkspace(url, cwd) {
|
|
121
|
+
const workspace_database = resolveWorkspaceDatabase({ cwd });
|
|
122
|
+
if (
|
|
123
|
+
workspace_database.source === 'home-default' ||
|
|
124
|
+
!workspace_database.exists
|
|
125
|
+
) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return registerWorkspaceWithServer(url, {
|
|
130
|
+
path: cwd,
|
|
131
|
+
database: workspace_database.path
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} url
|
|
137
|
+
* @param {string} cwd
|
|
138
|
+
* @returns {Promise<boolean>}
|
|
139
|
+
*/
|
|
140
|
+
async function registerCurrentWorkspaceWithRetry(url, cwd) {
|
|
141
|
+
for (let i = 0; i < REGISTER_RETRY_ATTEMPTS; i++) {
|
|
142
|
+
const registered = await registerCurrentWorkspace(url, cwd);
|
|
143
|
+
if (registered) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (i < REGISTER_RETRY_ATTEMPTS - 1) {
|
|
147
|
+
await sleep(REGISTER_RETRY_DELAY_MS);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Handle `stop` command.
|
|
155
|
+
* - Sends SIGTERM and waits for exit (with SIGKILL fallback), removes PID file.
|
|
156
|
+
* - Returns 2 if not running.
|
|
157
|
+
*
|
|
158
|
+
* @returns {Promise<number>} Exit code
|
|
159
|
+
*/
|
|
160
|
+
export async function handleStop() {
|
|
161
|
+
const existing_pid = readPidFile();
|
|
162
|
+
if (!existing_pid) {
|
|
163
|
+
return 2;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!isProcessRunning(existing_pid)) {
|
|
167
|
+
// stale PID file
|
|
168
|
+
removePidFile();
|
|
169
|
+
return 2;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const terminated = await terminateProcess(existing_pid, 5000);
|
|
173
|
+
if (terminated) {
|
|
174
|
+
removePidFile();
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Not terminated within timeout
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Handle `restart` command: stop (ignore not-running) then start.
|
|
184
|
+
*
|
|
185
|
+
* @returns {Promise<number>} Exit code (0 on success)
|
|
186
|
+
*/
|
|
187
|
+
/**
|
|
188
|
+
* Handle `restart` command: stop (ignore not-running) then start.
|
|
189
|
+
* Accepts the same options as `handleStart` and passes them through,
|
|
190
|
+
* so restart only opens a browser when `open` is explicitly true.
|
|
191
|
+
*
|
|
192
|
+
* @param {{ open?: boolean }} [options]
|
|
193
|
+
* @returns {Promise<number>}
|
|
194
|
+
*/
|
|
195
|
+
export async function handleRestart(options) {
|
|
196
|
+
const stop_code = await handleStop();
|
|
197
|
+
// 0 = stopped, 2 = not running; both are acceptable to proceed
|
|
198
|
+
if (stop_code !== 0 && stop_code !== 2) {
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const start_code = await handleStart(options);
|
|
202
|
+
return start_code === 0 ? 0 : 1;
|
|
203
|
+
}
|