@worca/ui 0.8.1 → 0.9.0-rc.2
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/app/main.bundle.js +1424 -755
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +399 -23
- package/package.json +5 -4
- package/server/app.js +341 -6
- package/server/dispatch-events-aggregator.js +161 -0
- package/server/ensure-webhook.js +66 -0
- package/server/index.js +22 -0
- package/server/integrations/adapter.js +91 -0
- package/server/integrations/adapters/discord.js +109 -0
- package/server/integrations/adapters/slack.js +106 -0
- package/server/integrations/adapters/telegram.js +228 -0
- package/server/integrations/adapters/webhook_out.js +253 -0
- package/server/integrations/allowlist.js +19 -0
- package/server/integrations/chat_context.js +68 -0
- package/server/integrations/commands/control.js +120 -0
- package/server/integrations/commands/global.js +239 -0
- package/server/integrations/commands/parser.js +29 -0
- package/server/integrations/commands/project.js +394 -0
- package/server/integrations/config-loader.js +40 -0
- package/server/integrations/index.js +390 -0
- package/server/integrations/markdown.js +220 -0
- package/server/integrations/rate_limiter.js +131 -0
- package/server/integrations/renderers.js +191 -0
- package/server/integrations/rest_client.js +17 -0
- package/server/integrations/verify.js +23 -0
- package/server/process-manager.js +61 -2
- package/server/project-registry.js +37 -0
- package/server/project-routes.js +175 -6
- package/server/settings-validator.js +279 -2
- package/server/subagents-discovery.js +116 -0
- package/server/version-check.js +35 -0
- package/server/watcher.js +37 -10
- package/server/worca-setup.js +15 -1
- package/server/ws-modular.js +6 -2
package/server/project-routes.js
CHANGED
|
@@ -20,9 +20,11 @@ import { homedir } from 'node:os';
|
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
21
|
import { Router } from 'express';
|
|
22
22
|
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
23
|
+
import { ensureWebhookForUi } from './ensure-webhook.js';
|
|
23
24
|
import { readPreferences } from './preferences.js';
|
|
24
25
|
import { ProcessManager } from './process-manager.js';
|
|
25
26
|
import {
|
|
27
|
+
getMaxProjects,
|
|
26
28
|
readProjects,
|
|
27
29
|
removeProject,
|
|
28
30
|
SLUG_RE,
|
|
@@ -36,6 +38,8 @@ import {
|
|
|
36
38
|
readMergedSettings,
|
|
37
39
|
} from './settings-merge.js';
|
|
38
40
|
import { validateSettingsPayload } from './settings-validator.js';
|
|
41
|
+
import { isVersionBehind } from './version-check.js';
|
|
42
|
+
import { getVersionInfo } from './versions.js';
|
|
39
43
|
import { discoverRuns } from './watcher.js';
|
|
40
44
|
import {
|
|
41
45
|
checkWorcaInstalled,
|
|
@@ -135,7 +139,12 @@ export function projectResolver({ prefsDir, projectRoot }) {
|
|
|
135
139
|
/**
|
|
136
140
|
* Router for project CRUD: GET/POST/DELETE /api/projects[/:id]
|
|
137
141
|
*/
|
|
138
|
-
export function createProjectRoutes({
|
|
142
|
+
export function createProjectRoutes({
|
|
143
|
+
prefsDir,
|
|
144
|
+
projectRoot,
|
|
145
|
+
serverHost,
|
|
146
|
+
serverPort,
|
|
147
|
+
}) {
|
|
139
148
|
const router = Router();
|
|
140
149
|
|
|
141
150
|
// GET /api/projects — list all projects (or synthesized default)
|
|
@@ -166,6 +175,17 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
|
|
|
166
175
|
}
|
|
167
176
|
try {
|
|
168
177
|
writeProject(prefsDir, entry);
|
|
178
|
+
// Auto-configure webhook so pipeline events reach this UI server
|
|
179
|
+
if (serverHost && serverPort) {
|
|
180
|
+
try {
|
|
181
|
+
ensureWebhookForUi(entry.path, {
|
|
182
|
+
host: serverHost,
|
|
183
|
+
port: serverPort,
|
|
184
|
+
});
|
|
185
|
+
} catch {
|
|
186
|
+
/* best-effort — don't fail project creation */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
169
189
|
res.status(201).json({ ok: true, project: entry });
|
|
170
190
|
} catch (err) {
|
|
171
191
|
res.status(400).json({ ok: false, error: err.message });
|
|
@@ -182,15 +202,128 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
|
|
|
182
202
|
res.json({ ok: true, removed: id });
|
|
183
203
|
});
|
|
184
204
|
|
|
205
|
+
// POST /api/projects/batch — register multiple projects atomically
|
|
206
|
+
router.post('/batch', (req, res) => {
|
|
207
|
+
const { projects: batch } = req.body || {};
|
|
208
|
+
if (!Array.isArray(batch) || batch.length === 0) {
|
|
209
|
+
return res
|
|
210
|
+
.status(400)
|
|
211
|
+
.json({ ok: false, error: 'projects must be a non-empty array' });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Validate all entries first (all-or-nothing)
|
|
215
|
+
const failed = [];
|
|
216
|
+
for (const entry of batch) {
|
|
217
|
+
const validation = validateProjectEntry(entry);
|
|
218
|
+
if (!validation.valid) {
|
|
219
|
+
failed.push({ name: entry?.name ?? '', error: validation.error });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (!existsSync(entry.path)) {
|
|
223
|
+
failed.push({
|
|
224
|
+
name: entry.name,
|
|
225
|
+
error: `directory does not exist: ${entry.path}`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (failed.length > 0) {
|
|
230
|
+
return res.status(400).json({
|
|
231
|
+
ok: false,
|
|
232
|
+
error: `${failed.length} project${failed.length > 1 ? 's' : ''} failed validation`,
|
|
233
|
+
failed,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Check for intra-batch duplicate names
|
|
238
|
+
const batchNames = batch.map((e) => e?.name).filter(Boolean);
|
|
239
|
+
if (new Set(batchNames).size < batchNames.length) {
|
|
240
|
+
return res
|
|
241
|
+
.status(400)
|
|
242
|
+
.json({ ok: false, error: 'Duplicate names within batch' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Check for intra-batch duplicate paths
|
|
246
|
+
const batchPaths = batch.map((e) => e?.path).filter(Boolean);
|
|
247
|
+
if (new Set(batchPaths).size < batchPaths.length) {
|
|
248
|
+
return res
|
|
249
|
+
.status(400)
|
|
250
|
+
.json({ ok: false, error: 'Duplicate paths within batch' });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Check for duplicate paths against existing projects
|
|
254
|
+
const existing = readProjects(prefsDir);
|
|
255
|
+
const existingPaths = new Set(
|
|
256
|
+
existing.map((p) => p.path.replace(/\/+$/, '')),
|
|
257
|
+
);
|
|
258
|
+
const duplicates = batch.filter((entry) =>
|
|
259
|
+
existingPaths.has(entry.path.replace(/\/+$/, '')),
|
|
260
|
+
);
|
|
261
|
+
if (duplicates.length > 0) {
|
|
262
|
+
return res.status(400).json({
|
|
263
|
+
ok: false,
|
|
264
|
+
error: `${duplicates.length} project${duplicates.length > 1 ? 's' : ''} already registered`,
|
|
265
|
+
failed: duplicates.map((entry) => ({
|
|
266
|
+
name: entry.name,
|
|
267
|
+
error: `path already registered: ${entry.path}`,
|
|
268
|
+
})),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check max projects limit
|
|
273
|
+
const max = getMaxProjects(prefsDir);
|
|
274
|
+
if (existing.length + batch.length > max) {
|
|
275
|
+
return res.status(400).json({
|
|
276
|
+
ok: false,
|
|
277
|
+
error: `adding ${batch.length} project${batch.length > 1 ? 's' : ''} would exceed the limit of ${max}`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Write all projects — roll back on partial failure
|
|
282
|
+
const written = [];
|
|
283
|
+
try {
|
|
284
|
+
for (const entry of batch) {
|
|
285
|
+
writeProject(prefsDir, entry);
|
|
286
|
+
written.push(entry.name);
|
|
287
|
+
if (serverHost && serverPort) {
|
|
288
|
+
try {
|
|
289
|
+
ensureWebhookForUi(entry.path, {
|
|
290
|
+
host: serverHost,
|
|
291
|
+
port: serverPort,
|
|
292
|
+
});
|
|
293
|
+
} catch {
|
|
294
|
+
/* best-effort */
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
res.status(201).json({ ok: true, projects: batch });
|
|
299
|
+
} catch (err) {
|
|
300
|
+
for (const name of written) {
|
|
301
|
+
try {
|
|
302
|
+
removeProject(prefsDir, name);
|
|
303
|
+
} catch {
|
|
304
|
+
// ignore rollback errors
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
res.status(400).json({ ok: false, error: err.message });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
185
311
|
return router;
|
|
186
312
|
}
|
|
187
313
|
|
|
188
314
|
/**
|
|
189
315
|
* Router for project-scoped sub-routes.
|
|
190
316
|
* The projectResolver middleware must run before this to set req.project.
|
|
317
|
+
* @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
|
|
318
|
+
* worca-cc version lookup for /worca-status' `outdated` flag.
|
|
191
319
|
*/
|
|
192
|
-
export function createProjectScopedRoutes(
|
|
320
|
+
export function createProjectScopedRoutes({
|
|
321
|
+
prefsDir = null,
|
|
322
|
+
serverHost,
|
|
323
|
+
serverPort,
|
|
324
|
+
} = {}) {
|
|
193
325
|
const router = Router({ mergeParams: true });
|
|
326
|
+
const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
|
|
194
327
|
|
|
195
328
|
// Guard: run-related, cost, and pipeline routes require worcaDir
|
|
196
329
|
function requireWorcaDir(req, res, next) {
|
|
@@ -1213,10 +1346,35 @@ export function createProjectScopedRoutes() {
|
|
|
1213
1346
|
res.json({ ok: true, templates });
|
|
1214
1347
|
});
|
|
1215
1348
|
|
|
1216
|
-
// GET /api/projects/:projectId/worca-status — check worca installation state
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1349
|
+
// GET /api/projects/:projectId/worca-status — check worca installation state.
|
|
1350
|
+
// `outdated` is true when the project's installed worca-cc version is
|
|
1351
|
+
// strictly behind the active (dev-path or globally-installed) worca-cc.
|
|
1352
|
+
router.get('/worca-status', async (req, res) => {
|
|
1353
|
+
const { projectRoot } = req.project;
|
|
1354
|
+
const installed = checkWorcaInstalled(projectRoot);
|
|
1355
|
+
if (!installed) {
|
|
1356
|
+
return res.json({
|
|
1357
|
+
ok: true,
|
|
1358
|
+
installed: false,
|
|
1359
|
+
version: null,
|
|
1360
|
+
outdated: false,
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
const version = readProjectWorcaVersion(projectRoot);
|
|
1364
|
+
let outdated = false;
|
|
1365
|
+
if (version != null) {
|
|
1366
|
+
try {
|
|
1367
|
+
const versionInfo = await getVersionInfo({
|
|
1368
|
+
prefsPath,
|
|
1369
|
+
worcaVersion: req.app.locals.worcaVersion || null,
|
|
1370
|
+
});
|
|
1371
|
+
outdated = isVersionBehind(version, versionInfo.activeWorcaCc);
|
|
1372
|
+
} catch {
|
|
1373
|
+
// Best-effort — if version lookup fails, treat as not outdated
|
|
1374
|
+
outdated = false;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
res.json({ ok: true, installed: true, version, outdated });
|
|
1220
1378
|
});
|
|
1221
1379
|
|
|
1222
1380
|
// POST /api/projects/:projectId/worca-setup — install or update worca
|
|
@@ -1238,6 +1396,17 @@ export function createProjectScopedRoutes() {
|
|
|
1238
1396
|
|
|
1239
1397
|
try {
|
|
1240
1398
|
const { pid } = runWorcaSetup(projectRoot, { source });
|
|
1399
|
+
// Auto-configure webhook so pipeline events reach this UI server
|
|
1400
|
+
if (serverHost && serverPort) {
|
|
1401
|
+
try {
|
|
1402
|
+
ensureWebhookForUi(projectRoot, {
|
|
1403
|
+
host: serverHost,
|
|
1404
|
+
port: serverPort,
|
|
1405
|
+
});
|
|
1406
|
+
} catch {
|
|
1407
|
+
/* best-effort */
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1241
1410
|
res.json({ ok: true, pid });
|
|
1242
1411
|
} catch (err) {
|
|
1243
1412
|
res.status(500).json({ ok: false, error: err.message });
|
|
@@ -323,8 +323,35 @@ export function validateSettingsPayload(body) {
|
|
|
323
323
|
continue;
|
|
324
324
|
}
|
|
325
325
|
for (const v of val) {
|
|
326
|
-
if (
|
|
327
|
-
details.push(`
|
|
326
|
+
if (typeof v !== 'string') {
|
|
327
|
+
details.push(`Dispatch entry for "${key}" must be a string`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (g.subagent_dispatch !== undefined) {
|
|
334
|
+
if (
|
|
335
|
+
typeof g.subagent_dispatch !== 'object' ||
|
|
336
|
+
g.subagent_dispatch === null ||
|
|
337
|
+
Array.isArray(g.subagent_dispatch)
|
|
338
|
+
) {
|
|
339
|
+
details.push('governance.subagent_dispatch must be an object');
|
|
340
|
+
} else {
|
|
341
|
+
for (const [key, val] of Object.entries(g.subagent_dispatch)) {
|
|
342
|
+
if (!VALID_AGENTS.includes(key)) {
|
|
343
|
+
details.push(`Unknown subagent_dispatch agent: "${key}"`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (!Array.isArray(val)) {
|
|
347
|
+
details.push(`subagent_dispatch for "${key}" must be an array`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
for (const v of val) {
|
|
351
|
+
if (typeof v !== 'string') {
|
|
352
|
+
details.push(
|
|
353
|
+
`subagent_dispatch entry for "${key}" must be a string`,
|
|
354
|
+
);
|
|
328
355
|
}
|
|
329
356
|
}
|
|
330
357
|
}
|
|
@@ -505,3 +532,253 @@ export function validateSettingsPayload(body) {
|
|
|
505
532
|
|
|
506
533
|
return details.length ? { valid: false, details } : { valid: true };
|
|
507
534
|
}
|
|
535
|
+
|
|
536
|
+
const VALID_WEBHOOK_OUT_FORMATS = [
|
|
537
|
+
'generic-json',
|
|
538
|
+
'slack-compatible',
|
|
539
|
+
'discord-compatible',
|
|
540
|
+
'teams-card',
|
|
541
|
+
'ntfy',
|
|
542
|
+
'plain-text',
|
|
543
|
+
];
|
|
544
|
+
|
|
545
|
+
export function validateIntegrationsConfig(cfg) {
|
|
546
|
+
const details = [];
|
|
547
|
+
|
|
548
|
+
if (cfg.schema_version === undefined || cfg.schema_version !== 1) {
|
|
549
|
+
details.push('schema_version must be present and equal to 1');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (cfg.enabled !== undefined && typeof cfg.enabled !== 'boolean') {
|
|
553
|
+
details.push('enabled must be a boolean');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (cfg.webhook_secret_env !== undefined) {
|
|
557
|
+
if (
|
|
558
|
+
typeof cfg.webhook_secret_env !== 'string' ||
|
|
559
|
+
cfg.webhook_secret_env.length === 0
|
|
560
|
+
) {
|
|
561
|
+
details.push('webhook_secret_env must be a non-empty string');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (cfg.webhook_secrets_env !== undefined) {
|
|
566
|
+
if (
|
|
567
|
+
typeof cfg.webhook_secrets_env !== 'string' ||
|
|
568
|
+
cfg.webhook_secrets_env.length === 0
|
|
569
|
+
) {
|
|
570
|
+
details.push('webhook_secrets_env must be a non-empty string');
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (
|
|
575
|
+
cfg.strict_inbox_verification !== undefined &&
|
|
576
|
+
typeof cfg.strict_inbox_verification !== 'boolean'
|
|
577
|
+
) {
|
|
578
|
+
details.push('strict_inbox_verification must be a boolean');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// telegram
|
|
582
|
+
if (cfg.telegram !== undefined) {
|
|
583
|
+
if (
|
|
584
|
+
typeof cfg.telegram !== 'object' ||
|
|
585
|
+
cfg.telegram === null ||
|
|
586
|
+
Array.isArray(cfg.telegram)
|
|
587
|
+
) {
|
|
588
|
+
details.push('telegram must be an object');
|
|
589
|
+
} else {
|
|
590
|
+
const tg = cfg.telegram;
|
|
591
|
+
if (tg.enabled !== undefined && typeof tg.enabled !== 'boolean') {
|
|
592
|
+
details.push('telegram.enabled must be a boolean');
|
|
593
|
+
}
|
|
594
|
+
const hasTgToken =
|
|
595
|
+
(typeof tg.bot_token === 'string' && tg.bot_token.length > 0) ||
|
|
596
|
+
(typeof tg.bot_token_env === 'string' && tg.bot_token_env.length > 0);
|
|
597
|
+
if (!hasTgToken) {
|
|
598
|
+
details.push(
|
|
599
|
+
'telegram requires bot_token or bot_token_env (non-empty string)',
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
if (
|
|
603
|
+
tg.chat_id === undefined ||
|
|
604
|
+
(typeof tg.chat_id !== 'string' && typeof tg.chat_id !== 'number')
|
|
605
|
+
) {
|
|
606
|
+
details.push('telegram.chat_id must be a string or number');
|
|
607
|
+
}
|
|
608
|
+
if (tg.events === undefined || !Array.isArray(tg.events)) {
|
|
609
|
+
details.push('telegram.events must be an array');
|
|
610
|
+
} else {
|
|
611
|
+
for (let i = 0; i < tg.events.length; i++) {
|
|
612
|
+
if (typeof tg.events[i] !== 'string' || tg.events[i].length === 0) {
|
|
613
|
+
details.push(`telegram.events[${i}] must be a non-empty string`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (tg.rate_limit_per_min !== undefined) {
|
|
618
|
+
if (
|
|
619
|
+
!Number.isInteger(tg.rate_limit_per_min) ||
|
|
620
|
+
tg.rate_limit_per_min < 1
|
|
621
|
+
) {
|
|
622
|
+
details.push(
|
|
623
|
+
'telegram.rate_limit_per_min must be a positive integer',
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// discord
|
|
631
|
+
if (cfg.discord !== undefined) {
|
|
632
|
+
if (
|
|
633
|
+
typeof cfg.discord !== 'object' ||
|
|
634
|
+
cfg.discord === null ||
|
|
635
|
+
Array.isArray(cfg.discord)
|
|
636
|
+
) {
|
|
637
|
+
details.push('discord must be an object');
|
|
638
|
+
} else {
|
|
639
|
+
const dc = cfg.discord;
|
|
640
|
+
if (dc.enabled !== undefined && typeof dc.enabled !== 'boolean') {
|
|
641
|
+
details.push('discord.enabled must be a boolean');
|
|
642
|
+
}
|
|
643
|
+
const hasDcToken =
|
|
644
|
+
(typeof dc.bot_token === 'string' && dc.bot_token.length > 0) ||
|
|
645
|
+
(typeof dc.bot_token_env === 'string' && dc.bot_token_env.length > 0);
|
|
646
|
+
if (!hasDcToken) {
|
|
647
|
+
details.push(
|
|
648
|
+
'discord requires bot_token or bot_token_env (non-empty string)',
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (
|
|
652
|
+
dc.channel_id === undefined ||
|
|
653
|
+
typeof dc.channel_id !== 'string' ||
|
|
654
|
+
dc.channel_id.length === 0
|
|
655
|
+
) {
|
|
656
|
+
details.push('discord.channel_id must be a non-empty string');
|
|
657
|
+
}
|
|
658
|
+
if (dc.events !== undefined && !Array.isArray(dc.events)) {
|
|
659
|
+
details.push('discord.events must be an array');
|
|
660
|
+
} else if (Array.isArray(dc.events)) {
|
|
661
|
+
for (let i = 0; i < dc.events.length; i++) {
|
|
662
|
+
if (typeof dc.events[i] !== 'string' || dc.events[i].length === 0) {
|
|
663
|
+
details.push(`discord.events[${i}] must be a non-empty string`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// slack
|
|
671
|
+
if (cfg.slack !== undefined) {
|
|
672
|
+
if (
|
|
673
|
+
typeof cfg.slack !== 'object' ||
|
|
674
|
+
cfg.slack === null ||
|
|
675
|
+
Array.isArray(cfg.slack)
|
|
676
|
+
) {
|
|
677
|
+
details.push('slack must be an object');
|
|
678
|
+
} else {
|
|
679
|
+
const sl = cfg.slack;
|
|
680
|
+
if (sl.enabled !== undefined && typeof sl.enabled !== 'boolean') {
|
|
681
|
+
details.push('slack.enabled must be a boolean');
|
|
682
|
+
}
|
|
683
|
+
const hasSlUrl =
|
|
684
|
+
(typeof sl.webhook_url === 'string' && sl.webhook_url.length > 0) ||
|
|
685
|
+
(typeof sl.webhook_url_env === 'string' &&
|
|
686
|
+
sl.webhook_url_env.length > 0);
|
|
687
|
+
if (!hasSlUrl) {
|
|
688
|
+
details.push(
|
|
689
|
+
'slack requires webhook_url or webhook_url_env (non-empty string)',
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (sl.events !== undefined && !Array.isArray(sl.events)) {
|
|
693
|
+
details.push('slack.events must be an array');
|
|
694
|
+
} else if (Array.isArray(sl.events)) {
|
|
695
|
+
for (let i = 0; i < sl.events.length; i++) {
|
|
696
|
+
if (typeof sl.events[i] !== 'string' || sl.events[i].length === 0) {
|
|
697
|
+
details.push(`slack.events[${i}] must be a non-empty string`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// webhook_out
|
|
705
|
+
if (cfg.webhook_out !== undefined) {
|
|
706
|
+
if (
|
|
707
|
+
typeof cfg.webhook_out !== 'object' ||
|
|
708
|
+
cfg.webhook_out === null ||
|
|
709
|
+
Array.isArray(cfg.webhook_out)
|
|
710
|
+
) {
|
|
711
|
+
details.push('webhook_out must be an object');
|
|
712
|
+
} else {
|
|
713
|
+
const wo = cfg.webhook_out;
|
|
714
|
+
if (wo.enabled !== undefined && typeof wo.enabled !== 'boolean') {
|
|
715
|
+
details.push('webhook_out.enabled must be a boolean');
|
|
716
|
+
}
|
|
717
|
+
if (wo.endpoints !== undefined) {
|
|
718
|
+
if (!Array.isArray(wo.endpoints)) {
|
|
719
|
+
details.push('webhook_out.endpoints must be an array');
|
|
720
|
+
} else {
|
|
721
|
+
for (let i = 0; i < wo.endpoints.length; i++) {
|
|
722
|
+
const ep = wo.endpoints[i];
|
|
723
|
+
const pfx = `webhook_out.endpoints[${i}]`;
|
|
724
|
+
if (typeof ep !== 'object' || ep === null || Array.isArray(ep)) {
|
|
725
|
+
details.push(`${pfx} must be an object`);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (
|
|
729
|
+
ep.url === undefined ||
|
|
730
|
+
typeof ep.url !== 'string' ||
|
|
731
|
+
ep.url.trim().length === 0
|
|
732
|
+
) {
|
|
733
|
+
details.push(`${pfx}.url must be a non-empty string`);
|
|
734
|
+
} else {
|
|
735
|
+
try {
|
|
736
|
+
const parsed = new URL(ep.url);
|
|
737
|
+
if (
|
|
738
|
+
parsed.protocol !== 'http:' &&
|
|
739
|
+
parsed.protocol !== 'https:'
|
|
740
|
+
) {
|
|
741
|
+
details.push(`${pfx}.url must use http or https protocol`);
|
|
742
|
+
}
|
|
743
|
+
} catch {
|
|
744
|
+
details.push(`${pfx}.url is not a valid URL`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (ep.format !== undefined) {
|
|
748
|
+
if (!VALID_WEBHOOK_OUT_FORMATS.includes(ep.format)) {
|
|
749
|
+
details.push(
|
|
750
|
+
`${pfx}.format must be one of: ${VALID_WEBHOOK_OUT_FORMATS.join(', ')}`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (ep.headers !== undefined) {
|
|
755
|
+
if (
|
|
756
|
+
typeof ep.headers !== 'object' ||
|
|
757
|
+
ep.headers === null ||
|
|
758
|
+
Array.isArray(ep.headers)
|
|
759
|
+
) {
|
|
760
|
+
details.push(`${pfx}.headers must be an object`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (ep.events !== undefined && !Array.isArray(ep.events)) {
|
|
764
|
+
details.push(`${pfx}.events must be an array`);
|
|
765
|
+
} else if (Array.isArray(ep.events)) {
|
|
766
|
+
for (let j = 0; j < ep.events.length; j++) {
|
|
767
|
+
if (
|
|
768
|
+
typeof ep.events[j] !== 'string' ||
|
|
769
|
+
ep.events[j].length === 0
|
|
770
|
+
) {
|
|
771
|
+
details.push(
|
|
772
|
+
`${pfx}.events[${j}] must be a non-empty string`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return details.length ? { valid: false, details } : { valid: true };
|
|
784
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent discovery for the settings dispatch-rule editor.
|
|
3
|
+
*
|
|
4
|
+
* Walks three sources (built-ins, user-global, plugin cache) and returns a
|
|
5
|
+
* deduplicated list matching the shape used by `worca-ui/app/views/
|
|
6
|
+
* dispatch-tag-state.js` (`{name, label, group}`).
|
|
7
|
+
*
|
|
8
|
+
* The three sources:
|
|
9
|
+
* 1. Built-ins — hardcoded Claude Code types that are not on disk.
|
|
10
|
+
* 2. User — `<userDir>/*.md`, one file per subagent.
|
|
11
|
+
* 3. Plugins — `<pluginCacheDir>/<marketplace>/<plugin>/<version>/agents/*.md`.
|
|
12
|
+
* Deduped by the qualified name `<plugin>:<agent>` — first file wins
|
|
13
|
+
* across versions (the set of agents within a plugin is stable in
|
|
14
|
+
* practice; when two versions disagree we prefer filesystem order for
|
|
15
|
+
* determinism rather than trying to parse semver from directory names).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { basename, join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
// Built-in Claude Code subagents — shipped with a factory CC install, no
|
|
22
|
+
// plugins required. Mirror this list in worca-ui/app/views/
|
|
23
|
+
// dispatch-tag-state.js (KNOWN_TYPES) so the UI falls back to the same set
|
|
24
|
+
// when the /api/subagents fetch fails.
|
|
25
|
+
export const BUILTINS = [
|
|
26
|
+
{ name: 'Explore', label: '(built-in)', group: 'Built-in' },
|
|
27
|
+
{ name: 'general-purpose', label: '(built-in)', group: 'Built-in' },
|
|
28
|
+
{ name: 'Plan', label: '(built-in)', group: 'Built-in' },
|
|
29
|
+
{ name: 'statusline-setup', label: '(built-in)', group: 'Built-in' },
|
|
30
|
+
{ name: 'claude-code-guide', label: '(built-in)', group: 'Built-in' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function listMarkdownBasenames(dir) {
|
|
34
|
+
if (!dir || !existsSync(dir)) return [];
|
|
35
|
+
try {
|
|
36
|
+
return readdirSync(dir)
|
|
37
|
+
.filter((n) => n.endsWith('.md'))
|
|
38
|
+
.map((n) => basename(n, '.md'));
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listSubdirs(dir) {
|
|
45
|
+
if (!existsSync(dir)) return [];
|
|
46
|
+
try {
|
|
47
|
+
return readdirSync(dir).filter((n) => {
|
|
48
|
+
try {
|
|
49
|
+
return statSync(join(dir, n)).isDirectory();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Discover all subagent types reachable from the given directories.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} options
|
|
63
|
+
* @param {Array<{name:string,label:string,group:string}>} [options.builtins]
|
|
64
|
+
* @param {string} [options.userDir] e.g. ~/.claude/agents
|
|
65
|
+
* @param {string} [options.pluginCacheDir] e.g. ~/.claude/plugins/cache
|
|
66
|
+
* @param {string} [options.projectAgentsDir] e.g. <project>/.claude/agents
|
|
67
|
+
* @returns {Array<{name:string,label:string,group:string}>}
|
|
68
|
+
*/
|
|
69
|
+
export function discoverSubagents({
|
|
70
|
+
builtins = BUILTINS,
|
|
71
|
+
userDir,
|
|
72
|
+
pluginCacheDir,
|
|
73
|
+
projectAgentsDir,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const result = [...builtins];
|
|
76
|
+
const seen = new Set(result.map((t) => t.name));
|
|
77
|
+
|
|
78
|
+
for (const name of listMarkdownBasenames(userDir)) {
|
|
79
|
+
if (!seen.has(name)) {
|
|
80
|
+
seen.add(name);
|
|
81
|
+
result.push({ name, label: '(user)', group: 'User' });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (pluginCacheDir && existsSync(pluginCacheDir)) {
|
|
86
|
+
for (const marketplace of listSubdirs(pluginCacheDir)) {
|
|
87
|
+
const marketplaceDir = join(pluginCacheDir, marketplace);
|
|
88
|
+
for (const plugin of listSubdirs(marketplaceDir)) {
|
|
89
|
+
const pluginDir = join(marketplaceDir, plugin);
|
|
90
|
+
for (const version of listSubdirs(pluginDir)) {
|
|
91
|
+
const agentsDir = join(pluginDir, version, 'agents');
|
|
92
|
+
for (const agent of listMarkdownBasenames(agentsDir)) {
|
|
93
|
+
const qualified = `${plugin}:${agent}`;
|
|
94
|
+
if (!seen.has(qualified)) {
|
|
95
|
+
seen.add(qualified);
|
|
96
|
+
result.push({
|
|
97
|
+
name: qualified,
|
|
98
|
+
label: '(plugin)',
|
|
99
|
+
group: 'Plugin',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const name of listMarkdownBasenames(projectAgentsDir)) {
|
|
109
|
+
if (!seen.has(name)) {
|
|
110
|
+
seen.add(name);
|
|
111
|
+
result.push({ name, label: '(project)', group: 'Project' });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
}
|
package/server/version-check.js
CHANGED
|
@@ -40,6 +40,41 @@ export function meetsMinimum(installed, minimum) {
|
|
|
40
40
|
return true; // equal
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Parse a version string into comparable parts, tracking RC suffixes.
|
|
45
|
+
* "0.6.0rc7" → { parts: [0, 6, 0], rc: 7 }
|
|
46
|
+
* "0.6.0" → { parts: [0, 6, 0], rc: Infinity } (stable > any rc)
|
|
47
|
+
* "0.1.0-rc.5" → { parts: [0, 1, 0], rc: 5 }
|
|
48
|
+
*/
|
|
49
|
+
export function parseVersion(v) {
|
|
50
|
+
if (!v) return { parts: [], rc: Infinity };
|
|
51
|
+
const rcMatch = v.match(/^(.+?)[-.]?rc\.?(\d+)$/);
|
|
52
|
+
const base = rcMatch ? rcMatch[1] : v;
|
|
53
|
+
const rc = rcMatch ? parseInt(rcMatch[2], 10) : Infinity;
|
|
54
|
+
const parts = base.split('.').map((s) => parseInt(s, 10) || 0);
|
|
55
|
+
return { parts, rc };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns true if `project` version is strictly behind `active`.
|
|
60
|
+
* RC-aware: "0.6.0rc3" is behind "0.6.0". Returns false if either arg is falsy.
|
|
61
|
+
*/
|
|
62
|
+
export function isVersionBehind(project, active) {
|
|
63
|
+
if (!project || !active) return false;
|
|
64
|
+
const p = parseVersion(project);
|
|
65
|
+
const a = parseVersion(active);
|
|
66
|
+
const len = Math.max(p.parts.length, a.parts.length);
|
|
67
|
+
for (let i = 0; i < len; i++) {
|
|
68
|
+
const pv = p.parts[i] || 0;
|
|
69
|
+
const av = a.parts[i] || 0;
|
|
70
|
+
if (pv < av) return true;
|
|
71
|
+
if (pv > av) return false;
|
|
72
|
+
}
|
|
73
|
+
// Same base version — compare RC numbers
|
|
74
|
+
if (p.rc < a.rc) return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
/**
|
|
44
79
|
* Run `worca --version` and check compatibility.
|
|
45
80
|
* @returns {Promise<{ok: boolean, installed: string|null, minimum: string, message: string}>}
|