@worca/ui 0.9.0-rc.1 → 0.9.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/app/main.bundle.js +570 -678
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +4 -205
- package/package.json +1 -1
- package/server/app.js +6 -285
- package/server/index.js +0 -22
- package/server/process-manager.js +2 -61
- package/server/project-routes.js +2 -44
- package/server/settings-validator.js +0 -250
- package/server/ensure-webhook.js +0 -66
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
|
-
import { randomBytes
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
8
|
import {
|
|
9
|
-
appendFileSync,
|
|
10
9
|
closeSync,
|
|
11
10
|
existsSync,
|
|
12
11
|
mkdirSync,
|
|
@@ -23,19 +22,6 @@ import { join } from 'node:path';
|
|
|
23
22
|
/** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
|
|
24
23
|
const ARG_INLINE_LIMIT = 128 * 1024;
|
|
25
24
|
|
|
26
|
-
const TERMINAL_EVENTS = [
|
|
27
|
-
'pipeline.run.interrupted',
|
|
28
|
-
'pipeline.run.failed',
|
|
29
|
-
'pipeline.run.completed',
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
function elapsedMsSince(startedAtIso) {
|
|
33
|
-
if (!startedAtIso) return 0;
|
|
34
|
-
const started = Date.parse(startedAtIso);
|
|
35
|
-
if (Number.isNaN(started)) return 0;
|
|
36
|
-
return Math.max(0, Date.now() - started);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
25
|
/**
|
|
40
26
|
* Write content to a temp file with restricted permissions (0o600) and return its path.
|
|
41
27
|
* Used to avoid E2BIG when passing large prompts as CLI arguments.
|
|
@@ -177,11 +163,10 @@ export class ProcessManager {
|
|
|
177
163
|
|
|
178
164
|
if (status.pipeline_status !== 'running') continue;
|
|
179
165
|
|
|
166
|
+
status.pipeline_status = 'failed';
|
|
180
167
|
if (!status.stop_reason) {
|
|
181
168
|
status.stop_reason = 'stale';
|
|
182
169
|
}
|
|
183
|
-
status.pipeline_status =
|
|
184
|
-
status.stop_reason === 'stale' ? 'interrupted' : 'failed';
|
|
185
170
|
try {
|
|
186
171
|
writeFileSync(
|
|
187
172
|
statusPath,
|
|
@@ -192,50 +177,6 @@ export class ProcessManager {
|
|
|
192
177
|
} catch {
|
|
193
178
|
/* ignore */
|
|
194
179
|
}
|
|
195
|
-
|
|
196
|
-
// Append synthetic interrupted event if no terminal event exists yet
|
|
197
|
-
const eventsPath = join(this.worcaDir, 'runs', runId, 'events.jsonl');
|
|
198
|
-
let hasTerminalEvent = false;
|
|
199
|
-
if (existsSync(eventsPath)) {
|
|
200
|
-
try {
|
|
201
|
-
const lines = readFileSync(eventsPath, 'utf8')
|
|
202
|
-
.split('\n')
|
|
203
|
-
.filter(Boolean);
|
|
204
|
-
hasTerminalEvent = lines.some((line) => {
|
|
205
|
-
try {
|
|
206
|
-
const evt = JSON.parse(line);
|
|
207
|
-
return TERMINAL_EVENTS.includes(evt.event_type);
|
|
208
|
-
} catch {
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
211
|
-
});
|
|
212
|
-
} catch {
|
|
213
|
-
/* ignore */
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
if (!hasTerminalEvent) {
|
|
217
|
-
try {
|
|
218
|
-
const evt = {
|
|
219
|
-
schema_version: '1',
|
|
220
|
-
event_id: randomUUID(),
|
|
221
|
-
event_type: 'pipeline.run.interrupted',
|
|
222
|
-
timestamp: new Date().toISOString(),
|
|
223
|
-
run_id: status.run_id ?? runId,
|
|
224
|
-
pipeline: {
|
|
225
|
-
branch: status.branch ?? null,
|
|
226
|
-
work_request: status.work_request ?? null,
|
|
227
|
-
},
|
|
228
|
-
payload: {
|
|
229
|
-
interrupted_stage: status.current_stage ?? 'unknown',
|
|
230
|
-
elapsed_ms: elapsedMsSince(status.started_at),
|
|
231
|
-
source: 'reconcile',
|
|
232
|
-
},
|
|
233
|
-
};
|
|
234
|
-
appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
|
|
235
|
-
} catch {
|
|
236
|
-
/* ignore */
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
180
|
}
|
|
240
181
|
|
|
241
182
|
return fixed;
|
package/server/project-routes.js
CHANGED
|
@@ -20,7 +20,6 @@ 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';
|
|
24
23
|
import { readPreferences } from './preferences.js';
|
|
25
24
|
import { ProcessManager } from './process-manager.js';
|
|
26
25
|
import {
|
|
@@ -139,12 +138,7 @@ export function projectResolver({ prefsDir, projectRoot }) {
|
|
|
139
138
|
/**
|
|
140
139
|
* Router for project CRUD: GET/POST/DELETE /api/projects[/:id]
|
|
141
140
|
*/
|
|
142
|
-
export function createProjectRoutes({
|
|
143
|
-
prefsDir,
|
|
144
|
-
projectRoot,
|
|
145
|
-
serverHost,
|
|
146
|
-
serverPort,
|
|
147
|
-
}) {
|
|
141
|
+
export function createProjectRoutes({ prefsDir, projectRoot }) {
|
|
148
142
|
const router = Router();
|
|
149
143
|
|
|
150
144
|
// GET /api/projects — list all projects (or synthesized default)
|
|
@@ -175,17 +169,6 @@ export function createProjectRoutes({
|
|
|
175
169
|
}
|
|
176
170
|
try {
|
|
177
171
|
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
|
-
}
|
|
189
172
|
res.status(201).json({ ok: true, project: entry });
|
|
190
173
|
} catch (err) {
|
|
191
174
|
res.status(400).json({ ok: false, error: err.message });
|
|
@@ -284,16 +267,6 @@ export function createProjectRoutes({
|
|
|
284
267
|
for (const entry of batch) {
|
|
285
268
|
writeProject(prefsDir, entry);
|
|
286
269
|
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
270
|
}
|
|
298
271
|
res.status(201).json({ ok: true, projects: batch });
|
|
299
272
|
} catch (err) {
|
|
@@ -317,11 +290,7 @@ export function createProjectRoutes({
|
|
|
317
290
|
* @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
|
|
318
291
|
* worca-cc version lookup for /worca-status' `outdated` flag.
|
|
319
292
|
*/
|
|
320
|
-
export function createProjectScopedRoutes({
|
|
321
|
-
prefsDir = null,
|
|
322
|
-
serverHost,
|
|
323
|
-
serverPort,
|
|
324
|
-
} = {}) {
|
|
293
|
+
export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
325
294
|
const router = Router({ mergeParams: true });
|
|
326
295
|
const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
|
|
327
296
|
|
|
@@ -1396,17 +1365,6 @@ export function createProjectScopedRoutes({
|
|
|
1396
1365
|
|
|
1397
1366
|
try {
|
|
1398
1367
|
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
|
-
}
|
|
1410
1368
|
res.json({ ok: true, pid });
|
|
1411
1369
|
} catch (err) {
|
|
1412
1370
|
res.status(500).json({ ok: false, error: err.message });
|
|
@@ -532,253 +532,3 @@ export function validateSettingsPayload(body) {
|
|
|
532
532
|
|
|
533
533
|
return details.length ? { valid: false, details } : { valid: true };
|
|
534
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
|
-
}
|
package/server/ensure-webhook.js
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// ensure-webhook.js — auto-configure a webhook pointing to this worca-ui instance
|
|
2
|
-
// in a project's settings.local.json so the pipeline sends events to the UI.
|
|
3
|
-
|
|
4
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { localPathFor } from './settings-merge.js';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Ensure a webhook entry exists in the project's settings.local.json
|
|
10
|
-
* pointing to the worca-ui inbox at the given host:port.
|
|
11
|
-
*
|
|
12
|
-
* Skips if a webhook for this host:port already exists.
|
|
13
|
-
* Creates settings.local.json if it doesn't exist.
|
|
14
|
-
*
|
|
15
|
-
* @param {string} projectPath — absolute path to the project root
|
|
16
|
-
* @param {{ host: string, port: number }} server — worca-ui server address
|
|
17
|
-
*/
|
|
18
|
-
export function ensureWebhookForUi(projectPath, { host, port }) {
|
|
19
|
-
const settingsPath = join(projectPath, '.claude', 'settings.json');
|
|
20
|
-
const localPath = localPathFor(settingsPath);
|
|
21
|
-
// Use localhost instead of 127.0.0.1 — the pipeline validator only allows
|
|
22
|
-
// https:// or http://localhost for security.
|
|
23
|
-
const displayHost =
|
|
24
|
-
host === '127.0.0.1' || host === '::1' ? 'localhost' : host;
|
|
25
|
-
const inboxUrl = `http://${displayHost}:${port}/api/webhooks/inbox`;
|
|
26
|
-
|
|
27
|
-
// Read existing local settings (or start fresh)
|
|
28
|
-
let local = {};
|
|
29
|
-
if (existsSync(localPath)) {
|
|
30
|
-
try {
|
|
31
|
-
local = JSON.parse(readFileSync(localPath, 'utf8'));
|
|
32
|
-
} catch {
|
|
33
|
-
local = {};
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (!local.worca) local.worca = {};
|
|
38
|
-
if (!Array.isArray(local.worca.webhooks)) local.worca.webhooks = [];
|
|
39
|
-
|
|
40
|
-
// Check if a webhook for this URL already exists
|
|
41
|
-
const exists = local.worca.webhooks.some((wh) => wh.url === inboxUrl);
|
|
42
|
-
if (exists) return false;
|
|
43
|
-
|
|
44
|
-
// Also check base settings.json (in case it was manually configured there)
|
|
45
|
-
try {
|
|
46
|
-
const base = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
47
|
-
const baseWebhooks = base?.worca?.webhooks || [];
|
|
48
|
-
if (baseWebhooks.some((wh) => wh.url === inboxUrl)) return false;
|
|
49
|
-
} catch {
|
|
50
|
-
// no base settings — proceed
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
local.worca.webhooks.push({
|
|
54
|
-
url: inboxUrl,
|
|
55
|
-
events: ['pipeline.*'],
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// Ensure events are enabled
|
|
59
|
-
if (!local.worca.events) local.worca.events = {};
|
|
60
|
-
if (local.worca.events.enabled === undefined) {
|
|
61
|
-
local.worca.events.enabled = true;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
writeFileSync(localPath, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
|
|
65
|
-
return true;
|
|
66
|
-
}
|