@worca/ui 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { createServer } from 'node:net';
12
+ import { homedir } from 'node:os';
13
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import {
16
+ readProjects,
17
+ removeProject,
18
+ slugify,
19
+ validateProjectEntry,
20
+ writeProject,
21
+ } from '../server/project-registry.js';
22
+
23
+ function findProjectRoot(startDir) {
24
+ let dir = startDir;
25
+ while (dir !== dirname(dir)) {
26
+ if (existsSync(join(dir, '.claude', 'settings.json'))) return dir;
27
+ dir = dirname(dir);
28
+ }
29
+ return startDir;
30
+ }
31
+
32
+ const PREFS_DIR = join(homedir(), '.worca');
33
+ const SERVER_SCRIPT = join(
34
+ dirname(fileURLToPath(import.meta.url)),
35
+ '..',
36
+ 'server',
37
+ 'index.js',
38
+ );
39
+
40
+ /** Exported for testing */
41
+ export function parseArgs(argv) {
42
+ const args = {
43
+ command: 'start',
44
+ port: 3400,
45
+ host: '127.0.0.1',
46
+ open: false,
47
+ global: false,
48
+ // projects sub-command
49
+ subAction: null, // 'list' | 'add' | 'remove'
50
+ projectPath: null,
51
+ projectName: null,
52
+ // migrate sub-command
53
+ scanDir: null,
54
+ dryRun: false,
55
+ migrateAdd: null,
56
+ migrateStatus: false,
57
+ };
58
+ for (let i = 2; i < argv.length; i++) {
59
+ const arg = argv[i];
60
+ if (
61
+ ['start', 'stop', 'restart', 'status', 'projects', 'migrate'].includes(
62
+ arg,
63
+ )
64
+ ) {
65
+ args.command = arg;
66
+ // Parse projects sub-actions
67
+ if (arg === 'projects' && argv[i + 1]) {
68
+ const sub = argv[i + 1];
69
+ if (['list', 'add', 'remove'].includes(sub)) {
70
+ args.subAction = sub;
71
+ i++;
72
+ if (
73
+ (sub === 'add' || sub === 'remove') &&
74
+ argv[i + 1] &&
75
+ !argv[i + 1].startsWith('-')
76
+ ) {
77
+ args.projectPath = argv[++i];
78
+ }
79
+ }
80
+ }
81
+ } else if (arg === '--port' && argv[i + 1]) {
82
+ args.port = parseInt(argv[++i], 10);
83
+ } else if (arg === '--host' && argv[i + 1]) {
84
+ args.host = argv[++i];
85
+ } else if (arg === '--open') {
86
+ args.open = true;
87
+ } else if (arg === '--global') {
88
+ args.global = true;
89
+ } else if (arg === '--scan' && argv[i + 1]) {
90
+ args.scanDir = argv[++i];
91
+ } else if (arg === '--dry-run') {
92
+ args.dryRun = true;
93
+ } else if (arg === '--add' && argv[i + 1]) {
94
+ args.migrateAdd = argv[++i];
95
+ } else if (arg === '--status') {
96
+ args.migrateStatus = true;
97
+ } else if (arg === '--name' && argv[i + 1]) {
98
+ args.projectName = argv[++i];
99
+ }
100
+ }
101
+ return args;
102
+ }
103
+
104
+ /** Resolve PID file path and dir based on mode. */
105
+ function resolvePidPaths(isGlobal) {
106
+ if (isGlobal) {
107
+ return {
108
+ pidDir: PREFS_DIR,
109
+ pidFile: join(PREFS_DIR, 'worca-ui-global.pid'),
110
+ };
111
+ }
112
+ const projectRoot = findProjectRoot(process.cwd());
113
+ return {
114
+ pidDir: join(projectRoot, '.worca'),
115
+ pidFile: join(projectRoot, '.worca', 'worca-ui.pid'),
116
+ };
117
+ }
118
+
119
+ function readPidFile(pidFile) {
120
+ try {
121
+ return JSON.parse(readFileSync(pidFile, 'utf8'));
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function writePidFile(pidFile, pidDir, info) {
128
+ mkdirSync(pidDir, { recursive: true });
129
+ writeFileSync(pidFile, `${JSON.stringify(info, null, 2)}\n`);
130
+ }
131
+
132
+ function removePidFile(pidFile) {
133
+ try {
134
+ unlinkSync(pidFile);
135
+ } catch {
136
+ /* ignore */
137
+ }
138
+ }
139
+
140
+ function isRunning(pid) {
141
+ try {
142
+ process.kill(pid, 0);
143
+ return true;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ function isPortAvailable(port, host) {
150
+ return new Promise((resolve) => {
151
+ const srv = createServer();
152
+ srv.once('error', () => resolve(false));
153
+ srv.listen(port, host, () => {
154
+ srv.close(() => resolve(true));
155
+ });
156
+ });
157
+ }
158
+
159
+ async function findAvailablePort(startPort, host, maxAttempts = 10) {
160
+ for (let i = 0; i < maxAttempts; i++) {
161
+ const p = startPort + i;
162
+ if (await isPortAvailable(p, host)) return p;
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /** Try to read an existing PID file for port-conflict diagnostics. */
168
+ function describePortOccupant(port) {
169
+ // Check global PID file
170
+ const globalPid = join(PREFS_DIR, 'worca-ui-global.pid');
171
+ const globalInfo = readPidFile(globalPid);
172
+ if (globalInfo && globalInfo.port === port && isRunning(globalInfo.pid)) {
173
+ return `Global worca-ui is running on port ${port} (PID ${globalInfo.pid}, started ${globalInfo.started_at})`;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ async function start({ port, host, open, global: isGlobal }) {
179
+ const { pidDir, pidFile } = resolvePidPaths(isGlobal);
180
+
181
+ const existing = readPidFile(pidFile);
182
+ if (existing && isRunning(existing.pid)) {
183
+ console.log(
184
+ `worca-ui already running (PID ${existing.pid}) at http://${existing.host}:${existing.port}`,
185
+ );
186
+ return;
187
+ }
188
+
189
+ let availablePort;
190
+ if (isGlobal) {
191
+ // Global mode: claim port exclusively, no auto-increment
192
+ if (await isPortAvailable(port, host)) {
193
+ availablePort = port;
194
+ } else {
195
+ const occupant = describePortOccupant(port);
196
+ if (occupant) {
197
+ console.error(`Port ${port} is occupied: ${occupant}`);
198
+ } else {
199
+ console.error(
200
+ `Port ${port} is already in use. Cannot start global server.`,
201
+ );
202
+ }
203
+ process.exit(1);
204
+ }
205
+ } else {
206
+ // Per-project mode: auto-find available port
207
+ availablePort = await findAvailablePort(port, host);
208
+ if (availablePort === null) {
209
+ console.error(`No available port found (tried ${port}-${port + 9})`);
210
+ process.exit(1);
211
+ }
212
+ if (availablePort !== port) {
213
+ console.log(`Port ${port} in use, using ${availablePort}`);
214
+ }
215
+ }
216
+
217
+ const spawnArgs = [
218
+ SERVER_SCRIPT,
219
+ '--port',
220
+ String(availablePort),
221
+ '--host',
222
+ host,
223
+ ];
224
+ if (isGlobal) {
225
+ spawnArgs.push('--global');
226
+ }
227
+
228
+ const child = spawn(process.execPath, spawnArgs, {
229
+ detached: true,
230
+ stdio: 'ignore',
231
+ cwd: process.cwd(),
232
+ });
233
+ child.unref();
234
+
235
+ const info = {
236
+ pid: child.pid,
237
+ port: availablePort,
238
+ host,
239
+ started_at: new Date().toISOString(),
240
+ mode: isGlobal ? 'global' : 'per-project',
241
+ projectPath: isGlobal ? null : findProjectRoot(process.cwd()),
242
+ };
243
+ writePidFile(pidFile, pidDir, info);
244
+ const url = `http://${host}:${availablePort}`;
245
+ console.log(
246
+ `worca-ui ${isGlobal ? '(global) ' : ''}started (PID ${child.pid}) at ${url}`,
247
+ );
248
+
249
+ // Hint: if global mode, empty projects.d/, and cwd has .worca/
250
+ if (isGlobal) {
251
+ const projects = readProjects(PREFS_DIR);
252
+ if (projects.length === 0 && existsSync(join(process.cwd(), '.worca'))) {
253
+ console.log(
254
+ '\nTip: No projects registered. Run:\n' +
255
+ ` worca-ui migrate --add ${process.cwd()}\n`,
256
+ );
257
+ }
258
+ }
259
+
260
+ if (open) {
261
+ spawn('open', [url], { detached: true, stdio: 'ignore' }).unref();
262
+ }
263
+ }
264
+
265
+ function stop({ global: isGlobal }) {
266
+ const { pidFile } = resolvePidPaths(isGlobal);
267
+ const info = readPidFile(pidFile);
268
+ if (!info) {
269
+ console.log('worca-ui is not running');
270
+ return;
271
+ }
272
+ if (isRunning(info.pid)) {
273
+ try {
274
+ process.kill(info.pid, 'SIGTERM');
275
+ console.log(`worca-ui stopped (PID ${info.pid})`);
276
+ } catch (e) {
277
+ console.error(`Failed to stop PID ${info.pid}: ${e.message}`);
278
+ }
279
+ } else {
280
+ console.log('worca-ui was not running (stale PID file)');
281
+ }
282
+ removePidFile(pidFile);
283
+ }
284
+
285
+ async function restart(opts) {
286
+ stop(opts);
287
+ await new Promise((r) => setTimeout(r, 500));
288
+ await start(opts);
289
+ }
290
+
291
+ function status({ global: isGlobal }) {
292
+ const { pidFile } = resolvePidPaths(isGlobal);
293
+ const info = readPidFile(pidFile);
294
+ if (!info) {
295
+ console.log('worca-ui is not running');
296
+ return;
297
+ }
298
+ if (isRunning(info.pid)) {
299
+ const modeLabel = info.mode === 'global' ? ' (global)' : '';
300
+ console.log(
301
+ `worca-ui${modeLabel} is running (PID ${info.pid}) at http://${info.host}:${info.port}`,
302
+ );
303
+ console.log(`Started: ${info.started_at}`);
304
+ if (info.projectPath) {
305
+ console.log(`Project: ${info.projectPath}`);
306
+ }
307
+ } else {
308
+ console.log('worca-ui is not running (stale PID file)');
309
+ removePidFile(pidFile);
310
+ }
311
+ }
312
+
313
+ // --- projects subcommand ---
314
+
315
+ function projectsList() {
316
+ const projects = readProjects(PREFS_DIR);
317
+ if (projects.length === 0) {
318
+ console.log(
319
+ 'No projects registered. Use: worca-ui projects add /path/to/project',
320
+ );
321
+ return;
322
+ }
323
+ console.log(`${'NAME'.padEnd(30)} ${'PATH'.padEnd(50)} .worca`);
324
+ console.log(`${'─'.repeat(30)} ${'─'.repeat(50)} ${'─'.repeat(6)}`);
325
+ for (const p of projects) {
326
+ const hasWorca = existsSync(join(p.path, '.worca')) ? 'yes' : 'no';
327
+ console.log(`${p.name.padEnd(30)} ${p.path.padEnd(50)} ${hasWorca}`);
328
+ }
329
+ }
330
+
331
+ function projectsAdd(pathArg, nameArg) {
332
+ if (!pathArg) {
333
+ console.error(
334
+ 'Usage: worca-ui projects add /path/to/project [--name slug]',
335
+ );
336
+ process.exit(1);
337
+ }
338
+ const absPath = isAbsolute(pathArg) ? pathArg : resolve(pathArg);
339
+ if (!existsSync(absPath)) {
340
+ console.error(`Path does not exist: ${absPath}`);
341
+ process.exit(1);
342
+ }
343
+ const name = nameArg || slugify(basename(absPath));
344
+ const entry = {
345
+ name,
346
+ path: absPath,
347
+ worcaDir: join(absPath, '.worca'),
348
+ settingsPath: join(absPath, '.claude', 'settings.json'),
349
+ };
350
+ const validation = validateProjectEntry(entry);
351
+ if (!validation.valid) {
352
+ console.error(`Invalid project entry: ${validation.error}`);
353
+ process.exit(1);
354
+ }
355
+ try {
356
+ writeProject(PREFS_DIR, entry);
357
+ console.log(`Added project "${name}" at ${absPath}`);
358
+ } catch (e) {
359
+ console.error(`Failed to add project: ${e.message}`);
360
+ process.exit(1);
361
+ }
362
+ }
363
+
364
+ function projectsRemove(nameArg) {
365
+ if (!nameArg) {
366
+ console.error('Usage: worca-ui projects remove <project-name>');
367
+ process.exit(1);
368
+ }
369
+ removeProject(PREFS_DIR, nameArg);
370
+ console.log(`Removed project "${nameArg}"`);
371
+ }
372
+
373
+ // --- migrate subcommand ---
374
+
375
+ function migrateScan(scanDir, dryRun) {
376
+ if (!scanDir) {
377
+ console.error('Usage: worca-ui migrate --scan <dir> [--dry-run]');
378
+ process.exit(1);
379
+ }
380
+ const absDir = isAbsolute(scanDir) ? scanDir : resolve(scanDir);
381
+ if (!existsSync(absDir)) {
382
+ console.error(`Directory does not exist: ${absDir}`);
383
+ process.exit(1);
384
+ }
385
+
386
+ const found = [];
387
+ // Walk depth 2 to find directories containing .worca/
388
+ try {
389
+ for (const d1 of readdirSync(absDir, { withFileTypes: true })) {
390
+ if (!d1.isDirectory() || d1.name.startsWith('.')) continue;
391
+ const p1 = join(absDir, d1.name);
392
+ if (existsSync(join(p1, '.worca'))) {
393
+ found.push(p1);
394
+ continue;
395
+ }
396
+ try {
397
+ for (const d2 of readdirSync(p1, { withFileTypes: true })) {
398
+ if (!d2.isDirectory() || d2.name.startsWith('.')) continue;
399
+ const p2 = join(p1, d2.name);
400
+ if (existsSync(join(p2, '.worca'))) {
401
+ found.push(p2);
402
+ }
403
+ }
404
+ } catch {
405
+ /* skip unreadable */
406
+ }
407
+ }
408
+ } catch (e) {
409
+ console.error(`Failed to scan: ${e.message}`);
410
+ process.exit(1);
411
+ }
412
+
413
+ if (found.length === 0) {
414
+ console.log('No projects with .worca/ found.');
415
+ return;
416
+ }
417
+
418
+ const existing = readProjects(PREFS_DIR);
419
+ const existingPaths = new Set(existing.map((p) => p.path));
420
+
421
+ console.log(`Found ${found.length} project(s):\n`);
422
+ console.log(`${'NAME'.padEnd(30)} ${'PATH'.padEnd(50)} STATUS`);
423
+ console.log(`${'─'.repeat(30)} ${'─'.repeat(50)} ${'─'.repeat(15)}`);
424
+
425
+ let registered = 0;
426
+ for (const p of found) {
427
+ const name = slugify(basename(p));
428
+ const isExisting = existingPaths.has(p);
429
+ const status = isExisting
430
+ ? 'already registered'
431
+ : dryRun
432
+ ? 'would register'
433
+ : 'registered';
434
+ console.log(`${name.padEnd(30)} ${p.padEnd(50)} ${status}`);
435
+ if (!isExisting && !dryRun) {
436
+ try {
437
+ writeProject(PREFS_DIR, {
438
+ name,
439
+ path: p,
440
+ worcaDir: join(p, '.worca'),
441
+ settingsPath: join(p, '.claude', 'settings.json'),
442
+ });
443
+ registered++;
444
+ } catch (e) {
445
+ console.error(` Failed: ${e.message}`);
446
+ }
447
+ }
448
+ }
449
+ if (!dryRun && registered > 0) {
450
+ console.log(`\nRegistered ${registered} new project(s).`);
451
+ }
452
+ }
453
+
454
+ function migrateAdd(pathArg) {
455
+ if (!pathArg) {
456
+ console.error('Usage: worca-ui migrate --add /path/to/project');
457
+ process.exit(1);
458
+ }
459
+ const absPath = isAbsolute(pathArg) ? pathArg : resolve(pathArg);
460
+ if (absPath === '.') {
461
+ return projectsAdd(process.cwd());
462
+ }
463
+ projectsAdd(absPath);
464
+ }
465
+
466
+ function migrateStatus() {
467
+ const projects = readProjects(PREFS_DIR);
468
+ if (projects.length === 0) {
469
+ console.log('No projects registered.');
470
+ return;
471
+ }
472
+
473
+ console.log(
474
+ `${'NAME'.padEnd(30)} ${'PATH EXISTS'.padEnd(12)} ${'.worca'.padEnd(8)} ${'settings.json'.padEnd(15)}`,
475
+ );
476
+ console.log(
477
+ `${'─'.repeat(30)} ${'─'.repeat(12)} ${'─'.repeat(8)} ${'─'.repeat(15)}`,
478
+ );
479
+ for (const p of projects) {
480
+ const pathExists = existsSync(p.path) ? 'yes' : 'NO';
481
+ const hasWorca = existsSync(join(p.path, '.worca')) ? 'yes' : 'NO';
482
+ const hasSettings = existsSync(join(p.path, '.claude', 'settings.json'))
483
+ ? 'yes'
484
+ : 'NO';
485
+ console.log(
486
+ `${p.name.padEnd(30)} ${pathExists.padEnd(12)} ${hasWorca.padEnd(8)} ${hasSettings.padEnd(15)}`,
487
+ );
488
+ }
489
+ }
490
+
491
+ const args = parseArgs(process.argv);
492
+ switch (args.command) {
493
+ case 'start':
494
+ start(args);
495
+ break;
496
+ case 'stop':
497
+ stop(args);
498
+ break;
499
+ case 'restart':
500
+ restart(args);
501
+ break;
502
+ case 'status':
503
+ status(args);
504
+ break;
505
+ case 'projects':
506
+ switch (args.subAction) {
507
+ case 'list':
508
+ projectsList();
509
+ break;
510
+ case 'add':
511
+ projectsAdd(args.projectPath, args.projectName);
512
+ break;
513
+ case 'remove':
514
+ projectsRemove(args.projectPath);
515
+ break;
516
+ default:
517
+ console.log('Usage: worca-ui projects [list|add|remove]');
518
+ }
519
+ break;
520
+ case 'migrate':
521
+ if (args.scanDir) {
522
+ migrateScan(args.scanDir, args.dryRun);
523
+ } else if (args.migrateAdd) {
524
+ migrateAdd(args.migrateAdd);
525
+ } else if (args.migrateStatus) {
526
+ migrateStatus();
527
+ } else {
528
+ console.log(
529
+ 'Usage:\n' +
530
+ ' worca-ui migrate --scan <dir> [--dry-run]\n' +
531
+ ' worca-ui migrate --add /path/to/project\n' +
532
+ ' worca-ui migrate --status',
533
+ );
534
+ }
535
+ break;
536
+ default:
537
+ console.log(
538
+ 'Usage: worca-ui [start|stop|restart|status|projects|migrate] [--port N] [--host H] [--open] [--global]',
539
+ );
540
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@worca/ui",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "Pipeline monitoring UI for worca-cc",
5
+ "license": "MIT",
6
+ "author": "Sinisha Djukic",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/SinishaDjukic/worca-cc.git",
10
+ "directory": "worca-ui"
11
+ },
12
+ "keywords": [
13
+ "worca",
14
+ "pipeline",
15
+ "monitoring",
16
+ "dashboard",
17
+ "ai-agents",
18
+ "autonomous-coding"
19
+ ],
20
+ "type": "module",
21
+ "bin": {
22
+ "worca-ui": "./bin/worca-ui.js"
23
+ },
24
+ "files": [
25
+ "bin/worca-ui.js",
26
+ "server/*.js",
27
+ "!server/*.test.js",
28
+ "!server/test/",
29
+ "app/index.html",
30
+ "app/main.bundle.js",
31
+ "app/main.bundle.js.map",
32
+ "app/styles.css",
33
+ "app/vendor/",
34
+ "scripts/build-frontend.js"
35
+ ],
36
+ "engines": {
37
+ "node": ">=22"
38
+ },
39
+ "scripts": {
40
+ "start": "node bin/worca-ui.js start",
41
+ "stop": "node bin/worca-ui.js stop",
42
+ "restart": "node bin/worca-ui.js restart",
43
+ "build": "node scripts/build-frontend.js",
44
+ "prepublishOnly": "npm run build && npm test",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest",
47
+ "test:browser": "playwright test",
48
+ "test:browser:ui": "playwright test --ui",
49
+ "lint": "biome check",
50
+ "lint:fix": "biome check --write"
51
+ },
52
+ "dependencies": {
53
+ "@shoelace-style/shoelace": "^2.20.1",
54
+ "@xterm/addon-fit": "^0.11.0",
55
+ "@xterm/addon-search": "^0.16.0",
56
+ "@xterm/xterm": "^6.0.0",
57
+ "better-sqlite3": "^12.6.2",
58
+ "express": "^5.2.1",
59
+ "lit-html": "^3.3.1",
60
+ "lucide": "^0.577.0",
61
+ "marked": "^17.0.1",
62
+ "ws": "^8.18.3"
63
+ },
64
+ "devDependencies": {
65
+ "@biomejs/biome": "^2.2.4",
66
+ "@playwright/test": "^1.58.2",
67
+ "esbuild": "^0.27.1",
68
+ "jsdom": "^29.0.1",
69
+ "vitest": "^4.0.15"
70
+ }
71
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import { copyFileSync, mkdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ async function run() {
7
+ const thisFile = fileURLToPath(new URL(import.meta.url));
8
+ const repoRoot = path.resolve(path.dirname(thisFile), '..');
9
+ const appDir = path.join(repoRoot, 'app');
10
+ const entry = path.join(appDir, 'main.js');
11
+ const outfile = path.join(appDir, 'main.bundle.js');
12
+ const vendorDir = path.join(appDir, 'vendor');
13
+
14
+ mkdirSync(appDir, { recursive: true });
15
+ mkdirSync(vendorDir, { recursive: true });
16
+
17
+ // Copy vendor CSS assets
18
+ const vendorAssets = [
19
+ ['@shoelace-style/shoelace/dist/themes/light.css', 'shoelace-light.css'],
20
+ ['@shoelace-style/shoelace/dist/themes/dark.css', 'shoelace-dark.css'],
21
+ ['@xterm/xterm/css/xterm.css', 'xterm.css'],
22
+ ];
23
+ for (const [src, dest] of vendorAssets) {
24
+ const srcPath = path.join(repoRoot, 'node_modules', src);
25
+ copyFileSync(srcPath, path.join(vendorDir, dest));
26
+ console.log('copied', dest);
27
+ }
28
+
29
+ try {
30
+ const esbuild = await import('esbuild');
31
+ await esbuild.build({
32
+ entryPoints: [entry],
33
+ bundle: true,
34
+ format: 'esm',
35
+ platform: 'browser',
36
+ target: 'es2020',
37
+ outfile,
38
+ sourcemap: true,
39
+ minify: true,
40
+ legalComments: 'none',
41
+ });
42
+ console.log('built', path.relative(repoRoot, outfile));
43
+ } catch (err) {
44
+ console.error('bundle error', err);
45
+ process.exitCode = 1;
46
+ }
47
+ }
48
+
49
+ run();