@dotsetlabs/bellwether 2.1.0 → 2.1.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/CHANGELOG.md +35 -0
- package/README.md +48 -31
- package/dist/cli/commands/check.js +49 -6
- package/dist/cli/commands/dashboard.d.ts +3 -0
- package/dist/cli/commands/dashboard.js +69 -0
- package/dist/cli/commands/discover.js +24 -2
- package/dist/cli/commands/explore.js +49 -6
- package/dist/cli/commands/watch.js +12 -1
- package/dist/cli/index.js +27 -34
- package/dist/cli/utils/headers.d.ts +12 -0
- package/dist/cli/utils/headers.js +63 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +2 -0
- package/dist/config/template.js +12 -0
- package/dist/config/validator.d.ts +38 -18
- package/dist/config/validator.js +10 -0
- package/dist/constants/core.d.ts +4 -2
- package/dist/constants/core.js +13 -2
- package/dist/dashboard/index.d.ts +3 -0
- package/dist/dashboard/index.js +6 -0
- package/dist/dashboard/runtime/artifact-index.d.ts +45 -0
- package/dist/dashboard/runtime/artifact-index.js +238 -0
- package/dist/dashboard/runtime/command-profiles.d.ts +764 -0
- package/dist/dashboard/runtime/command-profiles.js +691 -0
- package/dist/dashboard/runtime/config-service.d.ts +21 -0
- package/dist/dashboard/runtime/config-service.js +73 -0
- package/dist/dashboard/runtime/job-runner.d.ts +26 -0
- package/dist/dashboard/runtime/job-runner.js +292 -0
- package/dist/dashboard/security/input-validation.d.ts +3 -0
- package/dist/dashboard/security/input-validation.js +27 -0
- package/dist/dashboard/security/localhost-guard.d.ts +5 -0
- package/dist/dashboard/security/localhost-guard.js +52 -0
- package/dist/dashboard/server.d.ts +14 -0
- package/dist/dashboard/server.js +293 -0
- package/dist/dashboard/types.d.ts +55 -0
- package/dist/dashboard/types.js +2 -0
- package/dist/dashboard/ui.d.ts +2 -0
- package/dist/dashboard/ui.js +2264 -0
- package/dist/discovery/discovery.js +20 -1
- package/dist/discovery/types.d.ts +1 -1
- package/dist/docs/contract.js +7 -1
- package/dist/errors/retry.js +15 -1
- package/dist/errors/types.d.ts +10 -0
- package/dist/errors/types.js +28 -0
- package/dist/logging/logger.js +5 -2
- package/dist/transport/env-filter.d.ts +6 -0
- package/dist/transport/env-filter.js +76 -0
- package/dist/transport/http-transport.js +10 -0
- package/dist/transport/mcp-client.d.ts +16 -9
- package/dist/transport/mcp-client.js +119 -88
- package/dist/transport/sse-transport.js +19 -0
- package/dist/version.js +2 -2
- package/package.json +5 -15
- package/man/bellwether.1 +0 -204
- package/man/bellwether.1.md +0 -148
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
|
+
import { VERSION } from '../version.js';
|
|
5
|
+
import { enforceLocalhostRequest } from './security/localhost-guard.js';
|
|
6
|
+
import { readJsonBody } from './security/input-validation.js';
|
|
7
|
+
import { listDashboardProfiles } from './runtime/command-profiles.js';
|
|
8
|
+
import { DashboardJobRunner } from './runtime/job-runner.js';
|
|
9
|
+
import { getConfigDocument, saveConfigDocument, validateConfigSource } from './runtime/config-service.js';
|
|
10
|
+
import { getArtifactOverview, listArtifacts, readArtifact } from './runtime/artifact-index.js';
|
|
11
|
+
import { renderDashboardHtml } from './ui.js';
|
|
12
|
+
function sendJson(res, statusCode, data) {
|
|
13
|
+
res.statusCode = statusCode;
|
|
14
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
15
|
+
res.end(JSON.stringify(data));
|
|
16
|
+
}
|
|
17
|
+
function sendHtml(res, statusCode, html) {
|
|
18
|
+
res.statusCode = statusCode;
|
|
19
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
20
|
+
res.end(html);
|
|
21
|
+
}
|
|
22
|
+
function sendNotFound(res) {
|
|
23
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
24
|
+
}
|
|
25
|
+
function extractRunId(pathname, suffix) {
|
|
26
|
+
const prefix = '/api/runs/';
|
|
27
|
+
if (!pathname.startsWith(prefix) || !pathname.endsWith(suffix)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const runId = pathname.slice(prefix.length, pathname.length - suffix.length);
|
|
31
|
+
if (!runId) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return decodeURIComponent(runId);
|
|
35
|
+
}
|
|
36
|
+
function getOptionalQueryParam(requestUrl, key) {
|
|
37
|
+
const raw = requestUrl.searchParams.get(key);
|
|
38
|
+
if (!raw) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const trimmed = raw.trim();
|
|
42
|
+
if (!trimmed) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return trimmed;
|
|
46
|
+
}
|
|
47
|
+
function inferDriftSeverityFromOutput(output) {
|
|
48
|
+
const joined = output
|
|
49
|
+
.map((chunk) => chunk.text.toLowerCase())
|
|
50
|
+
.join('\n');
|
|
51
|
+
if (joined.includes('breaking changes detected')) {
|
|
52
|
+
return 'breaking';
|
|
53
|
+
}
|
|
54
|
+
if (joined.includes('warning-level changes detected')) {
|
|
55
|
+
return 'warning';
|
|
56
|
+
}
|
|
57
|
+
if (joined.includes('info-level changes detected')) {
|
|
58
|
+
return 'info';
|
|
59
|
+
}
|
|
60
|
+
if (joined.includes('no drift detected')) {
|
|
61
|
+
return 'none';
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
export class DashboardServer {
|
|
66
|
+
host;
|
|
67
|
+
port;
|
|
68
|
+
cwd;
|
|
69
|
+
runner;
|
|
70
|
+
server = null;
|
|
71
|
+
url = '';
|
|
72
|
+
constructor(options) {
|
|
73
|
+
this.host = options.host;
|
|
74
|
+
this.port = options.port;
|
|
75
|
+
this.cwd = options.cwd ?? process.cwd();
|
|
76
|
+
this.runner = new DashboardJobRunner({
|
|
77
|
+
cwd: this.cwd,
|
|
78
|
+
cliEntrypoint: options.cliEntrypoint,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async start() {
|
|
82
|
+
if (this.server) {
|
|
83
|
+
throw new Error('Dashboard server is already running.');
|
|
84
|
+
}
|
|
85
|
+
this.server = createServer((req, res) => {
|
|
86
|
+
void this.handleRequest(req, res);
|
|
87
|
+
});
|
|
88
|
+
await new Promise((resolve, reject) => {
|
|
89
|
+
this.server?.once('error', reject);
|
|
90
|
+
this.server?.listen(this.port, this.host, () => resolve());
|
|
91
|
+
});
|
|
92
|
+
const addressInfo = this.server.address();
|
|
93
|
+
if (!addressInfo || typeof addressInfo === 'string') {
|
|
94
|
+
throw new Error('Unable to determine dashboard server address.');
|
|
95
|
+
}
|
|
96
|
+
this.url = `http://${this.host}:${addressInfo.port}`;
|
|
97
|
+
return {
|
|
98
|
+
host: this.host,
|
|
99
|
+
port: addressInfo.port,
|
|
100
|
+
url: this.url,
|
|
101
|
+
stop: async () => this.stop(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async stop() {
|
|
105
|
+
await this.runner.shutdown();
|
|
106
|
+
if (!this.server) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const serverToClose = this.server;
|
|
110
|
+
this.server = null;
|
|
111
|
+
await new Promise((resolve, reject) => {
|
|
112
|
+
serverToClose.close((error) => {
|
|
113
|
+
if (error) {
|
|
114
|
+
const code = error.code;
|
|
115
|
+
if (code === 'ERR_SERVER_NOT_RUNNING') {
|
|
116
|
+
resolve();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
reject(error);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
resolve();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async handleRequest(req, res) {
|
|
127
|
+
try {
|
|
128
|
+
if (!enforceLocalhostRequest(req, res)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const method = req.method ?? 'GET';
|
|
132
|
+
const requestUrl = new URL(req.url ?? '/', this.url || `http://${this.host}:${this.port}`);
|
|
133
|
+
const pathname = requestUrl.pathname;
|
|
134
|
+
if (method === 'GET' && (pathname === '/' || pathname === '/index.html')) {
|
|
135
|
+
sendHtml(res, 200, renderDashboardHtml());
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (method === 'GET' && pathname === '/api/health') {
|
|
139
|
+
sendJson(res, 200, {
|
|
140
|
+
ok: true,
|
|
141
|
+
version: VERSION,
|
|
142
|
+
cwd: this.cwd,
|
|
143
|
+
startedAt: new Date().toISOString(),
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (method === 'GET' && pathname === '/api/profiles') {
|
|
148
|
+
sendJson(res, 200, {
|
|
149
|
+
profiles: listDashboardProfiles(),
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (method === 'GET' && pathname === '/api/overview') {
|
|
154
|
+
const configPath = getOptionalQueryParam(requestUrl, 'configPath');
|
|
155
|
+
const runs = this.runner.listRuns();
|
|
156
|
+
const artifacts = listArtifacts(this.cwd, configPath);
|
|
157
|
+
const reports = getArtifactOverview(this.cwd, configPath);
|
|
158
|
+
const lastRun = runs[0];
|
|
159
|
+
const runningCount = runs.filter((run) => run.status === 'running').length;
|
|
160
|
+
const lastCheck = runs.find((run) => run.profile === 'check');
|
|
161
|
+
const lastCheckDetails = lastCheck ? this.runner.getRun(lastCheck.runId) : null;
|
|
162
|
+
const existingArtifacts = artifacts.filter((artifact) => artifact.exists).length;
|
|
163
|
+
const lastCheckDriftSeverity = lastCheckDetails
|
|
164
|
+
? inferDriftSeverityFromOutput(lastCheckDetails.output)
|
|
165
|
+
: null;
|
|
166
|
+
sendJson(res, 200, {
|
|
167
|
+
summary: {
|
|
168
|
+
totalRuns: runs.length,
|
|
169
|
+
runningRuns: runningCount,
|
|
170
|
+
existingArtifacts,
|
|
171
|
+
lastRun: lastRun ?? null,
|
|
172
|
+
lastCheck: lastCheck ?? null,
|
|
173
|
+
lastCheckDriftSeverity,
|
|
174
|
+
},
|
|
175
|
+
artifacts,
|
|
176
|
+
reports,
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (method === 'GET' && pathname === '/api/config') {
|
|
181
|
+
const configPath = getOptionalQueryParam(requestUrl, 'path');
|
|
182
|
+
const config = getConfigDocument(this.cwd, configPath);
|
|
183
|
+
sendJson(res, 200, { config });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (method === 'PUT' && pathname === '/api/config') {
|
|
187
|
+
const body = (await readJsonBody(req));
|
|
188
|
+
if (typeof body.content !== 'string') {
|
|
189
|
+
sendJson(res, 400, { error: 'content must be a string' });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const updated = saveConfigDocument(this.cwd, body.path, body.content);
|
|
193
|
+
sendJson(res, 200, {
|
|
194
|
+
config: updated,
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (method === 'POST' && pathname === '/api/config/validate') {
|
|
199
|
+
const body = (await readJsonBody(req));
|
|
200
|
+
const validation = validateConfigSource(this.cwd, {
|
|
201
|
+
path: body.path,
|
|
202
|
+
content: body.content,
|
|
203
|
+
});
|
|
204
|
+
sendJson(res, 200, validation);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (method === 'GET' && pathname === '/api/artifacts') {
|
|
208
|
+
const configPath = getOptionalQueryParam(requestUrl, 'configPath');
|
|
209
|
+
const artifacts = listArtifacts(this.cwd, configPath);
|
|
210
|
+
sendJson(res, 200, { artifacts });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (method === 'GET' && pathname.startsWith('/api/artifacts/')) {
|
|
214
|
+
const artifactId = decodeURIComponent(pathname.slice('/api/artifacts/'.length));
|
|
215
|
+
if (!artifactId || artifactId.includes('/')) {
|
|
216
|
+
sendNotFound(res);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const configPath = getOptionalQueryParam(requestUrl, 'configPath');
|
|
220
|
+
const artifact = readArtifact(this.cwd, artifactId, configPath);
|
|
221
|
+
sendJson(res, 200, artifact);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (method === 'GET' && pathname === '/api/runs') {
|
|
225
|
+
sendJson(res, 200, { runs: this.runner.listRuns() });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (method === 'POST' && pathname === '/api/runs') {
|
|
229
|
+
const body = (await readJsonBody(req));
|
|
230
|
+
const run = this.runner.startRun(body);
|
|
231
|
+
sendJson(res, 201, {
|
|
232
|
+
runId: run.runId,
|
|
233
|
+
run,
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (method === 'GET' && pathname.startsWith('/api/runs/') && pathname.endsWith('/events')) {
|
|
238
|
+
const runId = extractRunId(pathname, '/events');
|
|
239
|
+
if (!runId) {
|
|
240
|
+
sendNotFound(res);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const attached = this.runner.openEventStream(runId, req, res);
|
|
244
|
+
if (!attached) {
|
|
245
|
+
sendNotFound(res);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (method === 'POST' && pathname.startsWith('/api/runs/') && pathname.endsWith('/cancel')) {
|
|
250
|
+
const runId = extractRunId(pathname, '/cancel');
|
|
251
|
+
if (!runId) {
|
|
252
|
+
sendNotFound(res);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const run = this.runner.cancelRun(runId);
|
|
256
|
+
if (!run) {
|
|
257
|
+
sendNotFound(res);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
sendJson(res, 200, { run });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (method === 'GET' && pathname.startsWith('/api/runs/')) {
|
|
264
|
+
const runId = pathname.slice('/api/runs/'.length);
|
|
265
|
+
if (!runId || runId.includes('/')) {
|
|
266
|
+
sendNotFound(res);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const run = this.runner.getRun(decodeURIComponent(runId));
|
|
270
|
+
if (!run) {
|
|
271
|
+
sendNotFound(res);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
sendJson(res, 200, { run });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
sendNotFound(res);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (error instanceof ZodError) {
|
|
281
|
+
sendJson(res, 400, {
|
|
282
|
+
error: 'Invalid request.',
|
|
283
|
+
issues: error.issues.map((issue) => issue.message),
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
sendJson(res, 400, {
|
|
288
|
+
error: error instanceof Error ? error.message : String(error),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type DashboardProfileId = 'check' | 'explore' | 'validate-config' | 'discover' | 'watch' | 'baseline.save' | 'baseline.compare' | 'baseline.show' | 'baseline.diff' | 'baseline.accept' | 'registry.search' | 'contract.validate' | 'contract.generate' | 'contract.show' | 'golden.save' | 'golden.compare' | 'golden.list' | 'golden.delete';
|
|
2
|
+
export type DashboardProfileCategory = 'core' | 'baseline' | 'advanced';
|
|
3
|
+
export interface DashboardProfileDefinition {
|
|
4
|
+
id: DashboardProfileId;
|
|
5
|
+
label: string;
|
|
6
|
+
description: string;
|
|
7
|
+
requiresServerCommand: boolean;
|
|
8
|
+
category: DashboardProfileCategory;
|
|
9
|
+
}
|
|
10
|
+
export interface DashboardServerCommandArgs {
|
|
11
|
+
configPath?: string;
|
|
12
|
+
serverCommand?: string;
|
|
13
|
+
serverArgs?: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface DashboardValidateConfigArgs {
|
|
16
|
+
configPath?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface DashboardStartRunRequest {
|
|
19
|
+
profile: DashboardProfileId;
|
|
20
|
+
args?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export type DashboardRunStatus = 'running' | 'completed' | 'failed' | 'cancelled';
|
|
23
|
+
export interface DashboardRunOutputChunk {
|
|
24
|
+
stream: 'stdout' | 'stderr';
|
|
25
|
+
text: string;
|
|
26
|
+
timestamp: string;
|
|
27
|
+
}
|
|
28
|
+
export interface DashboardRunSummary {
|
|
29
|
+
runId: string;
|
|
30
|
+
profile: DashboardProfileId;
|
|
31
|
+
status: DashboardRunStatus;
|
|
32
|
+
commandLine: string;
|
|
33
|
+
startedAt: string;
|
|
34
|
+
endedAt?: string;
|
|
35
|
+
durationMs?: number;
|
|
36
|
+
exitCode?: number | null;
|
|
37
|
+
pid?: number;
|
|
38
|
+
errorMessage?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface DashboardRunDetails extends DashboardRunSummary {
|
|
41
|
+
output: DashboardRunOutputChunk[];
|
|
42
|
+
}
|
|
43
|
+
export interface DashboardServerOptions {
|
|
44
|
+
host: string;
|
|
45
|
+
port: number;
|
|
46
|
+
cwd?: string;
|
|
47
|
+
cliEntrypoint?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface DashboardStartedServer {
|
|
50
|
+
host: string;
|
|
51
|
+
port: number;
|
|
52
|
+
url: string;
|
|
53
|
+
stop: () => Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=types.d.ts.map
|