@worca/ui 0.8.1 → 0.9.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.
- package/app/main.bundle.js +1424 -755
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +399 -23
- package/package.json +1 -1
- 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/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
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
|
-
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
8
8
|
import {
|
|
9
|
+
appendFileSync,
|
|
9
10
|
closeSync,
|
|
10
11
|
existsSync,
|
|
11
12
|
mkdirSync,
|
|
@@ -22,6 +23,19 @@ import { join } from 'node:path';
|
|
|
22
23
|
/** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
|
|
23
24
|
const ARG_INLINE_LIMIT = 128 * 1024;
|
|
24
25
|
|
|
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
|
+
|
|
25
39
|
/**
|
|
26
40
|
* Write content to a temp file with restricted permissions (0o600) and return its path.
|
|
27
41
|
* Used to avoid E2BIG when passing large prompts as CLI arguments.
|
|
@@ -163,10 +177,11 @@ export class ProcessManager {
|
|
|
163
177
|
|
|
164
178
|
if (status.pipeline_status !== 'running') continue;
|
|
165
179
|
|
|
166
|
-
status.pipeline_status = 'failed';
|
|
167
180
|
if (!status.stop_reason) {
|
|
168
181
|
status.stop_reason = 'stale';
|
|
169
182
|
}
|
|
183
|
+
status.pipeline_status =
|
|
184
|
+
status.stop_reason === 'stale' ? 'interrupted' : 'failed';
|
|
170
185
|
try {
|
|
171
186
|
writeFileSync(
|
|
172
187
|
statusPath,
|
|
@@ -177,6 +192,50 @@ export class ProcessManager {
|
|
|
177
192
|
} catch {
|
|
178
193
|
/* ignore */
|
|
179
194
|
}
|
|
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
|
+
}
|
|
180
239
|
}
|
|
181
240
|
|
|
182
241
|
return fixed;
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
unlinkSync,
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from 'node:fs';
|
|
13
|
+
import { readdir } from 'node:fs/promises';
|
|
13
14
|
import { basename, isAbsolute, join } from 'node:path';
|
|
15
|
+
import { checkWorcaInstalled, readProjectWorcaVersion } from './worca-setup.js';
|
|
14
16
|
|
|
15
17
|
export const SLUG_RE = /^[a-z0-9_-]{1,64}$/i;
|
|
16
18
|
const DEFAULT_MAX_PROJECTS = 20;
|
|
@@ -24,6 +26,7 @@ export function slugify(name) {
|
|
|
24
26
|
.toLowerCase()
|
|
25
27
|
.replace(/[^a-z0-9_-]/g, '-')
|
|
26
28
|
.replace(/-{2,}/g, '-')
|
|
29
|
+
.replace(/^-+|-+$/g, '')
|
|
27
30
|
.slice(0, 64);
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -130,6 +133,40 @@ export function synthesizeDefaultProject(projectRoot) {
|
|
|
130
133
|
};
|
|
131
134
|
}
|
|
132
135
|
|
|
136
|
+
const SCAN_MAX_RESULTS = 200;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Scan a directory for immediate child folders that contain a .git subdirectory.
|
|
140
|
+
* Skips dotfiles (names starting with ".") and "node_modules".
|
|
141
|
+
* Returns entries sorted alphabetically by name, capped at SCAN_MAX_RESULTS.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} dirPath - Absolute path to the parent directory
|
|
144
|
+
* @returns {Promise<{ name: string, path: string }[]>}
|
|
145
|
+
*/
|
|
146
|
+
export async function scanDirectory(dirPath) {
|
|
147
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
148
|
+
const results = [];
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (!entry.isDirectory()) continue;
|
|
151
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
152
|
+
const childPath = join(dirPath, entry.name);
|
|
153
|
+
if (existsSync(join(childPath, '.git'))) {
|
|
154
|
+
const installed = checkWorcaInstalled(childPath);
|
|
155
|
+
const worcaVersion = installed
|
|
156
|
+
? readProjectWorcaVersion(childPath)
|
|
157
|
+
: null;
|
|
158
|
+
results.push({
|
|
159
|
+
name: entry.name,
|
|
160
|
+
path: childPath,
|
|
161
|
+
installed,
|
|
162
|
+
worcaVersion,
|
|
163
|
+
});
|
|
164
|
+
if (results.length >= SCAN_MAX_RESULTS) break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return results.sort((a, b) => a.name.localeCompare(b.name));
|
|
168
|
+
}
|
|
169
|
+
|
|
133
170
|
/**
|
|
134
171
|
* Read max projects from {prefsDir}/config.json. Defaults to 20.
|
|
135
172
|
*/
|
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
|
+
}
|