@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,73 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, relative, resolve } from 'path';
|
|
3
|
+
import { PATHS } from '../../constants.js';
|
|
4
|
+
import { getConfigWarnings, validateConfig } from '../../config/validator.js';
|
|
5
|
+
import { parseYamlSecure } from '../../utils/yaml-parser.js';
|
|
6
|
+
function normalizeWorkspace(path) {
|
|
7
|
+
return resolve(path);
|
|
8
|
+
}
|
|
9
|
+
function assertWithinWorkspace(workspaceRoot, absolutePath) {
|
|
10
|
+
const rel = relative(workspaceRoot, absolutePath);
|
|
11
|
+
if (rel.startsWith('..') || rel.includes('/../') || rel.includes('\\..\\')) {
|
|
12
|
+
throw new Error('Path is outside the current workspace.');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function resolveWorkspacePath(cwd, targetPath) {
|
|
16
|
+
const workspaceRoot = normalizeWorkspace(cwd);
|
|
17
|
+
const resolvedPath = resolve(workspaceRoot, targetPath);
|
|
18
|
+
assertWithinWorkspace(workspaceRoot, resolvedPath);
|
|
19
|
+
return resolvedPath;
|
|
20
|
+
}
|
|
21
|
+
export function resolveConfigPath(cwd, explicitPath) {
|
|
22
|
+
const workspaceRoot = normalizeWorkspace(cwd);
|
|
23
|
+
if (explicitPath && explicitPath.trim()) {
|
|
24
|
+
return resolveWorkspacePath(workspaceRoot, explicitPath.trim());
|
|
25
|
+
}
|
|
26
|
+
for (const filename of PATHS.CONFIG_FILENAMES) {
|
|
27
|
+
const candidate = resolve(workspaceRoot, filename);
|
|
28
|
+
if (existsSync(candidate)) {
|
|
29
|
+
return candidate;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return resolve(workspaceRoot, PATHS.DEFAULT_CONFIG_FILENAME);
|
|
33
|
+
}
|
|
34
|
+
export function getConfigDocument(cwd, explicitPath) {
|
|
35
|
+
const absolutePath = resolveConfigPath(cwd, explicitPath);
|
|
36
|
+
const workspacePath = relative(normalizeWorkspace(cwd), absolutePath) || PATHS.DEFAULT_CONFIG_FILENAME;
|
|
37
|
+
const exists = existsSync(absolutePath);
|
|
38
|
+
return {
|
|
39
|
+
absolutePath,
|
|
40
|
+
workspacePath,
|
|
41
|
+
exists,
|
|
42
|
+
content: exists ? readFileSync(absolutePath, 'utf-8') : '',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function saveConfigDocument(cwd, explicitPath, content) {
|
|
46
|
+
const absolutePath = resolveConfigPath(cwd, explicitPath);
|
|
47
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
48
|
+
writeFileSync(absolutePath, content);
|
|
49
|
+
return getConfigDocument(cwd, explicitPath);
|
|
50
|
+
}
|
|
51
|
+
function parseConfigContent(content) {
|
|
52
|
+
const parsed = parseYamlSecure(content);
|
|
53
|
+
if (parsed === null || parsed === undefined) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
export function validateConfigSource(cwd, options) {
|
|
59
|
+
const sourceContent = options.content !== undefined
|
|
60
|
+
? options.content
|
|
61
|
+
: getConfigDocument(cwd, options.path).content;
|
|
62
|
+
if (!sourceContent || sourceContent.trim().length === 0) {
|
|
63
|
+
throw new Error('Config content is empty.');
|
|
64
|
+
}
|
|
65
|
+
const parsed = parseConfigContent(sourceContent);
|
|
66
|
+
const config = validateConfig(parsed, options.path);
|
|
67
|
+
return {
|
|
68
|
+
valid: true,
|
|
69
|
+
warnings: getConfigWarnings(config),
|
|
70
|
+
config,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=config-service.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
2
|
+
import type { DashboardRunDetails, DashboardRunSummary, DashboardStartRunRequest } from '../types.js';
|
|
3
|
+
interface DashboardJobRunnerOptions {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
cliEntrypoint?: string;
|
|
6
|
+
maxStoredRuns?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare class DashboardJobRunner {
|
|
9
|
+
private readonly runs;
|
|
10
|
+
private readonly cwd;
|
|
11
|
+
private readonly cliEntrypoint;
|
|
12
|
+
private readonly maxStoredRuns;
|
|
13
|
+
constructor(options?: DashboardJobRunnerOptions);
|
|
14
|
+
listRuns(): DashboardRunSummary[];
|
|
15
|
+
getRun(runId: string): DashboardRunDetails | null;
|
|
16
|
+
startRun(request: DashboardStartRunRequest): DashboardRunSummary;
|
|
17
|
+
cancelRun(runId: string): DashboardRunSummary | null;
|
|
18
|
+
openEventStream(runId: string, req: IncomingMessage, res: ServerResponse): boolean;
|
|
19
|
+
shutdown(): Promise<void>;
|
|
20
|
+
private appendOutput;
|
|
21
|
+
private finalizeRun;
|
|
22
|
+
private emit;
|
|
23
|
+
private pruneFinishedRuns;
|
|
24
|
+
}
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=job-runner.d.ts.map
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { extname, join } from 'path';
|
|
5
|
+
import { TIMEOUTS } from '../../constants.js';
|
|
6
|
+
import { buildBellwetherArgs, parseDashboardRunRequest } from './command-profiles.js';
|
|
7
|
+
const OUTPUT_CHUNK_LIMIT = 2000;
|
|
8
|
+
function formatCommandLine(command, args) {
|
|
9
|
+
return [command, ...args]
|
|
10
|
+
.map((part) => {
|
|
11
|
+
if (part.includes(' ')) {
|
|
12
|
+
return `"${part}"`;
|
|
13
|
+
}
|
|
14
|
+
return part;
|
|
15
|
+
})
|
|
16
|
+
.join(' ');
|
|
17
|
+
}
|
|
18
|
+
function resolveCliLaunchPlan(cwd, cliEntrypoint) {
|
|
19
|
+
const normalizedEntrypoint = cliEntrypoint?.trim();
|
|
20
|
+
if (normalizedEntrypoint && existsSync(normalizedEntrypoint)) {
|
|
21
|
+
const extension = extname(normalizedEntrypoint).toLowerCase();
|
|
22
|
+
if (extension === '.js' || extension === '.mjs' || extension === '.cjs') {
|
|
23
|
+
return {
|
|
24
|
+
command: process.execPath,
|
|
25
|
+
prefixArgs: [normalizedEntrypoint],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (extension === '.ts' || extension === '.mts' || extension === '.cts') {
|
|
29
|
+
const localTsx = join(cwd, 'node_modules', '.bin', process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
|
|
30
|
+
return {
|
|
31
|
+
command: existsSync(localTsx) ? localTsx : 'tsx',
|
|
32
|
+
prefixArgs: [normalizedEntrypoint],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
command: process.execPath,
|
|
37
|
+
prefixArgs: [normalizedEntrypoint],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
command: process.platform === 'win32' ? 'bellwether.cmd' : 'bellwether',
|
|
42
|
+
prefixArgs: [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function toSummary(run) {
|
|
46
|
+
return {
|
|
47
|
+
runId: run.runId,
|
|
48
|
+
profile: run.profile,
|
|
49
|
+
status: run.status,
|
|
50
|
+
commandLine: run.commandLine,
|
|
51
|
+
startedAt: run.startedAt,
|
|
52
|
+
endedAt: run.endedAt,
|
|
53
|
+
durationMs: run.durationMs,
|
|
54
|
+
exitCode: run.exitCode,
|
|
55
|
+
pid: run.pid,
|
|
56
|
+
errorMessage: run.errorMessage,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function toDetails(run) {
|
|
60
|
+
return {
|
|
61
|
+
...toSummary(run),
|
|
62
|
+
output: [...run.output],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function writeSseEvent(res, event, payload) {
|
|
66
|
+
res.write(`event: ${event}\n`);
|
|
67
|
+
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
68
|
+
}
|
|
69
|
+
export class DashboardJobRunner {
|
|
70
|
+
runs = new Map();
|
|
71
|
+
cwd;
|
|
72
|
+
cliEntrypoint;
|
|
73
|
+
maxStoredRuns;
|
|
74
|
+
constructor(options = {}) {
|
|
75
|
+
this.cwd = options.cwd ?? process.cwd();
|
|
76
|
+
this.cliEntrypoint = options.cliEntrypoint ?? process.argv[1];
|
|
77
|
+
this.maxStoredRuns = options.maxStoredRuns ?? 50;
|
|
78
|
+
}
|
|
79
|
+
listRuns() {
|
|
80
|
+
const summaries = [...this.runs.values()].map((run) => toSummary(run));
|
|
81
|
+
summaries.sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt));
|
|
82
|
+
return summaries;
|
|
83
|
+
}
|
|
84
|
+
getRun(runId) {
|
|
85
|
+
const run = this.runs.get(runId);
|
|
86
|
+
if (!run) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return toDetails(run);
|
|
90
|
+
}
|
|
91
|
+
startRun(request) {
|
|
92
|
+
const parsedRequest = parseDashboardRunRequest(request);
|
|
93
|
+
const profileArgs = buildBellwetherArgs(parsedRequest);
|
|
94
|
+
const launchPlan = resolveCliLaunchPlan(this.cwd, this.cliEntrypoint);
|
|
95
|
+
const childArgs = [...launchPlan.prefixArgs, ...profileArgs];
|
|
96
|
+
const child = spawn(launchPlan.command, childArgs, {
|
|
97
|
+
cwd: this.cwd,
|
|
98
|
+
env: process.env,
|
|
99
|
+
stdio: 'pipe',
|
|
100
|
+
});
|
|
101
|
+
const runId = randomUUID();
|
|
102
|
+
const run = {
|
|
103
|
+
runId,
|
|
104
|
+
profile: parsedRequest.profile,
|
|
105
|
+
status: 'running',
|
|
106
|
+
commandLine: formatCommandLine(launchPlan.command, childArgs),
|
|
107
|
+
startedAt: new Date().toISOString(),
|
|
108
|
+
output: [],
|
|
109
|
+
process: child,
|
|
110
|
+
subscribers: new Set(),
|
|
111
|
+
cancelRequested: false,
|
|
112
|
+
finalized: false,
|
|
113
|
+
forceKillTimer: null,
|
|
114
|
+
pid: child.pid,
|
|
115
|
+
};
|
|
116
|
+
this.runs.set(runId, run);
|
|
117
|
+
this.pruneFinishedRuns();
|
|
118
|
+
this.emit(run, 'run.start', { run: toSummary(run) });
|
|
119
|
+
child.stdout.on('data', (chunk) => {
|
|
120
|
+
this.appendOutput(run, 'stdout', chunk.toString('utf-8'));
|
|
121
|
+
});
|
|
122
|
+
child.stderr.on('data', (chunk) => {
|
|
123
|
+
this.appendOutput(run, 'stderr', chunk.toString('utf-8'));
|
|
124
|
+
});
|
|
125
|
+
child.on('error', (error) => {
|
|
126
|
+
this.appendOutput(run, 'stderr', `${error.message}\n`);
|
|
127
|
+
this.finalizeRun(run, 1, undefined, error.message);
|
|
128
|
+
});
|
|
129
|
+
child.on('close', (exitCode, signal) => {
|
|
130
|
+
let errorMessage = run.errorMessage;
|
|
131
|
+
if (!run.cancelRequested && signal && !errorMessage) {
|
|
132
|
+
errorMessage = `Process terminated by signal ${signal}`;
|
|
133
|
+
}
|
|
134
|
+
this.finalizeRun(run, exitCode, signal, errorMessage);
|
|
135
|
+
});
|
|
136
|
+
return toSummary(run);
|
|
137
|
+
}
|
|
138
|
+
cancelRun(runId) {
|
|
139
|
+
const run = this.runs.get(runId);
|
|
140
|
+
if (!run) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
if (run.status !== 'running' || !run.process) {
|
|
144
|
+
return toSummary(run);
|
|
145
|
+
}
|
|
146
|
+
run.cancelRequested = true;
|
|
147
|
+
run.errorMessage = 'Run cancelled by user.';
|
|
148
|
+
run.process.kill('SIGTERM');
|
|
149
|
+
run.forceKillTimer = setTimeout(() => {
|
|
150
|
+
if (!run.finalized && run.process) {
|
|
151
|
+
run.process.kill('SIGKILL');
|
|
152
|
+
}
|
|
153
|
+
}, TIMEOUTS.SHUTDOWN_KILL);
|
|
154
|
+
this.emit(run, 'run.state', {
|
|
155
|
+
run: toSummary(run),
|
|
156
|
+
message: 'Cancellation requested.',
|
|
157
|
+
});
|
|
158
|
+
return toSummary(run);
|
|
159
|
+
}
|
|
160
|
+
openEventStream(runId, req, res) {
|
|
161
|
+
const run = this.runs.get(runId);
|
|
162
|
+
if (!run) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
res.writeHead(200, {
|
|
166
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
167
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
168
|
+
Connection: 'keep-alive',
|
|
169
|
+
'X-Accel-Buffering': 'no',
|
|
170
|
+
});
|
|
171
|
+
res.write('\n');
|
|
172
|
+
writeSseEvent(res, 'run.snapshot', { run: toDetails(run) });
|
|
173
|
+
if (run.finalized) {
|
|
174
|
+
writeSseEvent(res, 'run.exit', { run: toSummary(run) });
|
|
175
|
+
res.end();
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
run.subscribers.add(res);
|
|
179
|
+
req.on('close', () => {
|
|
180
|
+
run.subscribers.delete(res);
|
|
181
|
+
});
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
async shutdown() {
|
|
185
|
+
const shutdownPromises = [];
|
|
186
|
+
for (const run of this.runs.values()) {
|
|
187
|
+
if (run.status === 'running' && run.process) {
|
|
188
|
+
run.cancelRequested = true;
|
|
189
|
+
run.process.kill('SIGTERM');
|
|
190
|
+
shutdownPromises.push(new Promise((resolve) => {
|
|
191
|
+
const timeout = setTimeout(() => {
|
|
192
|
+
if (!run.finalized && run.process) {
|
|
193
|
+
run.process.kill('SIGKILL');
|
|
194
|
+
}
|
|
195
|
+
resolve();
|
|
196
|
+
}, TIMEOUTS.SHUTDOWN_KILL);
|
|
197
|
+
run.process?.once('close', () => {
|
|
198
|
+
clearTimeout(timeout);
|
|
199
|
+
resolve();
|
|
200
|
+
});
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await Promise.allSettled(shutdownPromises);
|
|
205
|
+
for (const run of this.runs.values()) {
|
|
206
|
+
for (const subscriber of run.subscribers) {
|
|
207
|
+
subscriber.end();
|
|
208
|
+
}
|
|
209
|
+
run.subscribers.clear();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
appendOutput(run, stream, text) {
|
|
213
|
+
if (!text) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const chunk = {
|
|
217
|
+
stream,
|
|
218
|
+
text,
|
|
219
|
+
timestamp: new Date().toISOString(),
|
|
220
|
+
};
|
|
221
|
+
run.output.push(chunk);
|
|
222
|
+
if (run.output.length > OUTPUT_CHUNK_LIMIT) {
|
|
223
|
+
run.output.shift();
|
|
224
|
+
}
|
|
225
|
+
this.emit(run, 'run.output', {
|
|
226
|
+
runId: run.runId,
|
|
227
|
+
stream,
|
|
228
|
+
text,
|
|
229
|
+
timestamp: chunk.timestamp,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
finalizeRun(run, exitCode, signal, errorMessage) {
|
|
233
|
+
if (run.finalized) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
run.finalized = true;
|
|
237
|
+
run.process = null;
|
|
238
|
+
if (run.forceKillTimer) {
|
|
239
|
+
clearTimeout(run.forceKillTimer);
|
|
240
|
+
run.forceKillTimer = null;
|
|
241
|
+
}
|
|
242
|
+
run.exitCode = exitCode ?? null;
|
|
243
|
+
run.endedAt = new Date().toISOString();
|
|
244
|
+
run.durationMs = Date.parse(run.endedAt) - Date.parse(run.startedAt);
|
|
245
|
+
if (run.cancelRequested) {
|
|
246
|
+
run.status = 'cancelled';
|
|
247
|
+
}
|
|
248
|
+
else if (exitCode === 0) {
|
|
249
|
+
run.status = 'completed';
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
run.status = 'failed';
|
|
253
|
+
}
|
|
254
|
+
if (errorMessage) {
|
|
255
|
+
run.errorMessage = errorMessage;
|
|
256
|
+
}
|
|
257
|
+
else if (run.status === 'failed' && signal) {
|
|
258
|
+
run.errorMessage = `Run terminated by signal ${signal}`;
|
|
259
|
+
}
|
|
260
|
+
this.emit(run, 'run.exit', { run: toSummary(run) });
|
|
261
|
+
for (const subscriber of run.subscribers) {
|
|
262
|
+
subscriber.end();
|
|
263
|
+
}
|
|
264
|
+
run.subscribers.clear();
|
|
265
|
+
}
|
|
266
|
+
emit(run, event, payload) {
|
|
267
|
+
for (const subscriber of run.subscribers) {
|
|
268
|
+
try {
|
|
269
|
+
writeSseEvent(subscriber, event, payload);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
subscriber.end();
|
|
273
|
+
run.subscribers.delete(subscriber);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
pruneFinishedRuns() {
|
|
278
|
+
if (this.runs.size <= this.maxStoredRuns) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const removable = [...this.runs.values()]
|
|
282
|
+
.filter((run) => run.status !== 'running')
|
|
283
|
+
.sort((a, b) => Date.parse(a.startedAt) - Date.parse(b.startedAt));
|
|
284
|
+
while (this.runs.size > this.maxStoredRuns && removable.length > 0) {
|
|
285
|
+
const oldest = removable.shift();
|
|
286
|
+
if (oldest) {
|
|
287
|
+
this.runs.delete(oldest.runId);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
//# sourceMappingURL=job-runner.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
2
|
+
export async function readJsonBody(req, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
|
|
3
|
+
const chunks = [];
|
|
4
|
+
let totalBytes = 0;
|
|
5
|
+
for await (const chunk of req) {
|
|
6
|
+
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
7
|
+
totalBytes += bufferChunk.length;
|
|
8
|
+
if (totalBytes > maxBodyBytes) {
|
|
9
|
+
throw new Error('Request body is too large.');
|
|
10
|
+
}
|
|
11
|
+
chunks.push(bufferChunk);
|
|
12
|
+
}
|
|
13
|
+
if (chunks.length === 0) {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
const raw = Buffer.concat(chunks).toString('utf-8').trim();
|
|
17
|
+
if (raw.length === 0) {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error('Request body must be valid JSON.');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=input-validation.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
2
|
+
export declare function isLocalAddress(address: string | undefined): boolean;
|
|
3
|
+
export declare function isAllowedHostHeader(hostHeader: string | undefined): boolean;
|
|
4
|
+
export declare function enforceLocalhostRequest(req: IncomingMessage, res: ServerResponse): boolean;
|
|
5
|
+
//# sourceMappingURL=localhost-guard.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const localHosts = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
2
|
+
const localAddresses = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
3
|
+
function extractHostname(hostHeader) {
|
|
4
|
+
if (!hostHeader) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
const host = hostHeader.trim().toLowerCase();
|
|
8
|
+
if (host.length === 0) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if (host.startsWith('[')) {
|
|
12
|
+
const closeIndex = host.indexOf(']');
|
|
13
|
+
if (closeIndex !== -1) {
|
|
14
|
+
return host.slice(0, closeIndex + 1);
|
|
15
|
+
}
|
|
16
|
+
return host;
|
|
17
|
+
}
|
|
18
|
+
const colonIndex = host.indexOf(':');
|
|
19
|
+
if (colonIndex === -1) {
|
|
20
|
+
return host;
|
|
21
|
+
}
|
|
22
|
+
return host.slice(0, colonIndex);
|
|
23
|
+
}
|
|
24
|
+
export function isLocalAddress(address) {
|
|
25
|
+
if (!address) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return localAddresses.has(address);
|
|
29
|
+
}
|
|
30
|
+
export function isAllowedHostHeader(hostHeader) {
|
|
31
|
+
const hostname = extractHostname(hostHeader);
|
|
32
|
+
if (!hostname) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return localHosts.has(hostname);
|
|
36
|
+
}
|
|
37
|
+
export function enforceLocalhostRequest(req, res) {
|
|
38
|
+
if (!isLocalAddress(req.socket.remoteAddress)) {
|
|
39
|
+
res.statusCode = 403;
|
|
40
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
41
|
+
res.end(JSON.stringify({ error: 'Dashboard only accepts localhost requests.' }));
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (!isAllowedHostHeader(req.headers.host)) {
|
|
45
|
+
res.statusCode = 403;
|
|
46
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
47
|
+
res.end(JSON.stringify({ error: 'Invalid host header.' }));
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=localhost-guard.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DashboardServerOptions, DashboardStartedServer } from './types.js';
|
|
2
|
+
export declare class DashboardServer {
|
|
3
|
+
private readonly host;
|
|
4
|
+
private readonly port;
|
|
5
|
+
private readonly cwd;
|
|
6
|
+
private readonly runner;
|
|
7
|
+
private server;
|
|
8
|
+
private url;
|
|
9
|
+
constructor(options: DashboardServerOptions);
|
|
10
|
+
start(): Promise<DashboardStartedServer>;
|
|
11
|
+
stop(): Promise<void>;
|
|
12
|
+
private handleRequest;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=server.d.ts.map
|