@seanmars/tospec 0.14.1 → 0.14.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 +38 -0
- package/README.md +9 -0
- package/assets/metrics/app.js +995 -0
- package/assets/metrics/chart.umd.js +14 -0
- package/assets/metrics/index.html +14 -0
- package/assets/metrics/style.css +193 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/dashboard.d.ts +0 -6
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +7 -120
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/metrics.d.ts +145 -0
- package/dist/commands/metrics.d.ts.map +1 -0
- package/dist/commands/metrics.js +380 -0
- package/dist/commands/metrics.js.map +1 -0
- package/dist/core/codex-metrics.d.ts +95 -0
- package/dist/core/codex-metrics.d.ts.map +1 -0
- package/dist/core/codex-metrics.js +293 -0
- package/dist/core/codex-metrics.js.map +1 -0
- package/dist/core/local-server.d.ts +67 -0
- package/dist/core/local-server.d.ts.map +1 -0
- package/dist/core/local-server.js +169 -0
- package/dist/core/local-server.js.map +1 -0
- package/dist/core/skill-metrics.d.ts +185 -0
- package/dist/core/skill-metrics.d.ts.map +1 -0
- package/dist/core/skill-metrics.js +321 -0
- package/dist/core/skill-metrics.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex skill duration metrics — a second, less precise source for the same
|
|
3
|
+
* report `skill-metrics.ts` builds from Claude Code transcripts.
|
|
4
|
+
*
|
|
5
|
+
* Codex keeps one session tree shared by every project
|
|
6
|
+
* (`<CODEX_HOME or ~/.codex>/sessions/<yyyy>/<mm>/<dd>/rollout-*.jsonl`),
|
|
7
|
+
* partitioned by date rather than by project, with the working directory
|
|
8
|
+
* recorded once per session (`type: "session_meta"`, `payload.cwd`) rather than
|
|
9
|
+
* encoded into a directory name. Project scoping therefore reads that field
|
|
10
|
+
* instead of resolving a path (see codex-metrics-source spec, "Codex sessions
|
|
11
|
+
* are scoped to a project by their recorded working directory").
|
|
12
|
+
*
|
|
13
|
+
* Codex transcripts carry nothing like Claude Code's `attributionSkill`. Every
|
|
14
|
+
* session's system prompt lists all installed skills identically whether or
|
|
15
|
+
* not any of them were used, so that listing carries no signal. The one event
|
|
16
|
+
* confirmed (against this project's own real `~/.codex` history) to correlate
|
|
17
|
+
* with a skill actually being used is a tool-call command whose text reads a
|
|
18
|
+
* path ending in `<skill-name>/SKILL.md` — the moment the agent pulls in that
|
|
19
|
+
* skill's instructions. Skill attribution here is therefore a heuristic, not a
|
|
20
|
+
* recorded fact, and undercounts by construction: a skill run that never
|
|
21
|
+
* re-reads its own instructions file is invisible to it.
|
|
22
|
+
*/
|
|
23
|
+
import { promises as fs } from 'node:fs';
|
|
24
|
+
import * as os from 'node:os';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import fg from 'fast-glob';
|
|
27
|
+
import { aggregateRuns, buildRuns, normalizeSkillName, DEFAULT_IDLE_GAP_MS, DEFAULT_SPLIT_GAP_MS, } from './skill-metrics.js';
|
|
28
|
+
const CODEX_HOME_DIR_NAME = '.codex';
|
|
29
|
+
/**
|
|
30
|
+
* Locates Codex's session tree. `CODEX_HOME` is honoured the same way
|
|
31
|
+
* `CLAUDE_CONFIG_DIR` is for Claude Code; without it the default is
|
|
32
|
+
* `~/.codex`. This is the directory to search, not a per-project one — Codex
|
|
33
|
+
* has no equivalent of Claude Code's per-project transcript directory.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveCodexSessionsDir(options = {}) {
|
|
36
|
+
const env = options.env ?? process.env;
|
|
37
|
+
const home = env.CODEX_HOME
|
|
38
|
+
? path.resolve(env.CODEX_HOME)
|
|
39
|
+
: path.join(options.homedir ?? os.homedir(), CODEX_HOME_DIR_NAME);
|
|
40
|
+
return path.join(home, 'sessions');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether a session's recorded working directory names the given project.
|
|
44
|
+
* `null` (no `session_meta` seen, or no `cwd` field) never matches — a session
|
|
45
|
+
* with nothing recorded cannot be attributed to any project.
|
|
46
|
+
*/
|
|
47
|
+
export function sessionBelongsToProject(cwd, rootPath) {
|
|
48
|
+
if (cwd === null)
|
|
49
|
+
return false;
|
|
50
|
+
const a = path.resolve(cwd);
|
|
51
|
+
const b = path.resolve(rootPath);
|
|
52
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The working directory a record records, or null when it is not the kind of
|
|
56
|
+
* record that carries one. Shared by the full parse and the prefix probe so a
|
|
57
|
+
* single statement defines a session's cwd — the first `session_meta` whose
|
|
58
|
+
* `payload.cwd` is a string — and the probe can never disagree with the parse
|
|
59
|
+
* it exists to skip.
|
|
60
|
+
*/
|
|
61
|
+
function recordedCwd(record) {
|
|
62
|
+
if (record.type !== 'session_meta')
|
|
63
|
+
return null;
|
|
64
|
+
const cwd = record.payload?.cwd;
|
|
65
|
+
return typeof cwd === 'string' ? cwd : null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Matches a tool-call command's text against a path ending in
|
|
69
|
+
* `<skill-name>/SKILL.md`, on either separator. This is the one signal
|
|
70
|
+
* confirmed to correlate with a skill actually being read, as opposed to
|
|
71
|
+
* merely appearing in the session's fixed skill-listing text (see the module
|
|
72
|
+
* docstring).
|
|
73
|
+
*
|
|
74
|
+
* `function_call` arguments are JSON-encoded twice — once as the tool-call
|
|
75
|
+
* argument string itself, once more for the outer transcript line — so a
|
|
76
|
+
* Windows path's single backslash separator survives as a doubled `\\` in the
|
|
77
|
+
* text this matches against. The separator class therefore matches one or
|
|
78
|
+
* more repetitions rather than exactly one.
|
|
79
|
+
*/
|
|
80
|
+
const SKILL_MD_READ_PATTERN = /([^\\/"]+)[\\/]+SKILL\.md/i;
|
|
81
|
+
function extractSkillFromCommandText(text) {
|
|
82
|
+
const match = SKILL_MD_READ_PATTERN.exec(text);
|
|
83
|
+
return match ? match[1] : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parses one Codex rollout `.jsonl` file into the recorded project directory
|
|
87
|
+
* plus a sorted, timestamped, skill-labelled entry list — the same shape
|
|
88
|
+
* `parseTranscript` produces for Claude Code, so `buildRuns`/`aggregateRuns`
|
|
89
|
+
* apply unchanged (design.md D3). Malformed lines are skipped rather than
|
|
90
|
+
* fatal, for the same reason Claude Code transcripts are: an append-only log
|
|
91
|
+
* that can be torn at the tail while a session is live.
|
|
92
|
+
*
|
|
93
|
+
* Only `function_call`/`custom_tool_call` entries are ever inspected for a
|
|
94
|
+
* `SKILL.md` read. Every other entry — including the one carrying the fixed
|
|
95
|
+
* skill-listing text every session opens with — is folded in unattributed
|
|
96
|
+
* (`skill: null`), exactly as an unattributed Claude Code entry is: it cannot
|
|
97
|
+
* end a run, but it is not evidence a skill became active either.
|
|
98
|
+
*/
|
|
99
|
+
export function parseCodexSession(text) {
|
|
100
|
+
let cwd = null;
|
|
101
|
+
const entries = [];
|
|
102
|
+
for (const line of text.split(/\r?\n/)) {
|
|
103
|
+
if (!line.trim())
|
|
104
|
+
continue;
|
|
105
|
+
let record;
|
|
106
|
+
try {
|
|
107
|
+
record = JSON.parse(line);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (typeof record.timestamp !== 'string')
|
|
113
|
+
continue;
|
|
114
|
+
const timeMs = Date.parse(record.timestamp);
|
|
115
|
+
if (Number.isNaN(timeMs))
|
|
116
|
+
continue;
|
|
117
|
+
const payload = record.payload;
|
|
118
|
+
if (cwd === null) {
|
|
119
|
+
const found = recordedCwd(record);
|
|
120
|
+
if (found !== null) {
|
|
121
|
+
cwd = found;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let skill = null;
|
|
126
|
+
if (record.type === 'response_item' &&
|
|
127
|
+
payload &&
|
|
128
|
+
(payload.type === 'function_call' || payload.type === 'custom_tool_call')) {
|
|
129
|
+
const commandText = (typeof payload.arguments === 'string' ? payload.arguments : '') +
|
|
130
|
+
(typeof payload.input === 'string' ? payload.input : '');
|
|
131
|
+
const found = extractSkillFromCommandText(commandText);
|
|
132
|
+
if (found)
|
|
133
|
+
skill = normalizeSkillName(found);
|
|
134
|
+
}
|
|
135
|
+
entries.push({ timeMs, skill });
|
|
136
|
+
}
|
|
137
|
+
entries.sort((a, b) => a.timeMs - b.timeMs);
|
|
138
|
+
return { cwd, entries };
|
|
139
|
+
}
|
|
140
|
+
// -----------------------------------------------------------------------------
|
|
141
|
+
// IO: project scoping without reading whole sessions
|
|
142
|
+
// -----------------------------------------------------------------------------
|
|
143
|
+
/**
|
|
144
|
+
* Bytes read from a session when only its `cwd` is wanted.
|
|
145
|
+
*
|
|
146
|
+
* Codex writes `session_meta` as the very first line, but that line embeds the
|
|
147
|
+
* session's whole base-instructions text and so runs to ~20 KB. The probe has
|
|
148
|
+
* to be large enough to hold a *complete* line: a line cut by the read boundary
|
|
149
|
+
* fails `JSON.parse` and is indistinguishable from an absent one. 64 KiB clears
|
|
150
|
+
* the observed maximum several times over, and anything the probe cannot settle
|
|
151
|
+
* falls back to a full read — so an unusually long first line costs time, never
|
|
152
|
+
* correctness.
|
|
153
|
+
*/
|
|
154
|
+
const CWD_PROBE_BYTES = 64 * 1024;
|
|
155
|
+
/** First recorded cwd in already-read text, stopping at the first one found. */
|
|
156
|
+
function findRecordedCwd(text) {
|
|
157
|
+
for (const line of text.split(/\r?\n/)) {
|
|
158
|
+
if (!line.trim())
|
|
159
|
+
continue;
|
|
160
|
+
let record;
|
|
161
|
+
try {
|
|
162
|
+
record = JSON.parse(line);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const cwd = recordedCwd(record);
|
|
168
|
+
if (cwd !== null)
|
|
169
|
+
return cwd;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Reads just enough of a session to decide which project it belongs to.
|
|
175
|
+
*
|
|
176
|
+
* Every session in the shared Codex tree has to be classified, but only the
|
|
177
|
+
* matching ones are worth parsing, and on a real machine the tree is dominated
|
|
178
|
+
* by other projects: the history that motivated this held 716 MB of sessions of
|
|
179
|
+
* which one project owned 0.9 MB, and parsing all of it cost ~9 s of the
|
|
180
|
+
* command's startup. Because the deciding field sits on line 0, reading a prefix
|
|
181
|
+
* instead of the whole file makes the cost proportional to this project's share
|
|
182
|
+
* of Codex history rather than to all of it.
|
|
183
|
+
*/
|
|
184
|
+
async function probeSessionCwd(filePath) {
|
|
185
|
+
const handle = await fs.open(filePath, 'r');
|
|
186
|
+
try {
|
|
187
|
+
const buffer = Buffer.alloc(CWD_PROBE_BYTES);
|
|
188
|
+
const { bytesRead } = await handle.read(buffer, 0, CWD_PROBE_BYTES, 0);
|
|
189
|
+
const cwd = findRecordedCwd(buffer.subarray(0, bytesRead).toString('utf8'));
|
|
190
|
+
// A short read means the prefix was the entire file, so "no session_meta
|
|
191
|
+
// here" is the final answer rather than a reason to read it again.
|
|
192
|
+
return { cwd, conclusive: cwd !== null || bytesRead < CWD_PROBE_BYTES };
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
await handle.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Finds and parses every Codex session belonging to one project.
|
|
200
|
+
*
|
|
201
|
+
* Shared by `collectCodexSkillMetrics` and the metrics server, which need
|
|
202
|
+
* exactly this and previously each carried their own copy of the walk — so the
|
|
203
|
+
* probe optimisation could otherwise have been applied to one and not the other.
|
|
204
|
+
*/
|
|
205
|
+
export async function scanCodexSessions(rootPath, options = {}) {
|
|
206
|
+
const sessionsDir = resolveCodexSessionsDir(options);
|
|
207
|
+
// fast-glob returns an empty list for a missing `cwd` rather than throwing,
|
|
208
|
+
// so existence has to be checked directly — otherwise "Codex never used on
|
|
209
|
+
// this machine" (available: false) would be indistinguishable from "used,
|
|
210
|
+
// but nothing here matches this project" (available: true, sessions: 0).
|
|
211
|
+
const stat = await fs.stat(sessionsDir).catch(() => null);
|
|
212
|
+
if (!stat)
|
|
213
|
+
return { sessionsDir, available: false, sessions: [] };
|
|
214
|
+
const filePaths = await fg('**/*.jsonl', { cwd: sessionsDir, absolute: true });
|
|
215
|
+
const sessions = [];
|
|
216
|
+
for (const filePath of filePaths) {
|
|
217
|
+
try {
|
|
218
|
+
const probe = await probeSessionCwd(filePath);
|
|
219
|
+
if (probe.conclusive && !sessionBelongsToProject(probe.cwd, rootPath))
|
|
220
|
+
continue;
|
|
221
|
+
// The probe only ever skips work; the full parse stays authoritative for
|
|
222
|
+
// every session that survives it.
|
|
223
|
+
const { cwd, entries } = parseCodexSession(await fs.readFile(filePath, 'utf8'));
|
|
224
|
+
if (!sessionBelongsToProject(cwd, rootPath))
|
|
225
|
+
continue;
|
|
226
|
+
sessions.push({ session: path.basename(filePath, '.jsonl'), entries });
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// A session file can vanish between listing and read; skip it.
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { sessionsDir, available: true, sessions };
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Derives skill timings from the `SKILL.md`-read heuristic over this project's
|
|
237
|
+
* Codex sessions — the Codex-flavoured equivalent of `collectSkillMetrics`.
|
|
238
|
+
* `scanCodexSessions` does the finding and scoping.
|
|
239
|
+
*
|
|
240
|
+
* A missing sessions directory is a reportable state (`available: false`), not
|
|
241
|
+
* an error, for the same reason it is for Claude Code: the project may simply
|
|
242
|
+
* never have been opened in Codex.
|
|
243
|
+
*/
|
|
244
|
+
export async function collectCodexSkillMetrics(rootPath, options = {}) {
|
|
245
|
+
const thresholds = {
|
|
246
|
+
idleGapMs: options.idleGapMs ?? DEFAULT_IDLE_GAP_MS,
|
|
247
|
+
splitGapMs: options.splitGapMs ?? DEFAULT_SPLIT_GAP_MS,
|
|
248
|
+
};
|
|
249
|
+
const scan = await scanCodexSessions(rootPath, options);
|
|
250
|
+
if (!scan.available) {
|
|
251
|
+
return {
|
|
252
|
+
source: {
|
|
253
|
+
transcriptDir: scan.sessionsDir,
|
|
254
|
+
available: false,
|
|
255
|
+
sessions: 0,
|
|
256
|
+
attributedSessions: 0,
|
|
257
|
+
firstSeen: null,
|
|
258
|
+
lastSeen: null,
|
|
259
|
+
},
|
|
260
|
+
thresholds,
|
|
261
|
+
skills: [],
|
|
262
|
+
runs: [],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
let attributedSessions = 0;
|
|
266
|
+
const allRuns = [];
|
|
267
|
+
for (const { session, entries } of scan.sessions) {
|
|
268
|
+
const runs = buildRuns(entries, session, { ...thresholds, source: 'codex' });
|
|
269
|
+
if (runs.length > 0)
|
|
270
|
+
attributedSessions++;
|
|
271
|
+
for (const run of runs) {
|
|
272
|
+
if (options.sinceMs !== undefined && run.endMs < options.sinceMs)
|
|
273
|
+
continue;
|
|
274
|
+
allRuns.push(run);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
allRuns.sort((a, b) => a.startMs - b.startMs);
|
|
278
|
+
const source = {
|
|
279
|
+
transcriptDir: scan.sessionsDir,
|
|
280
|
+
available: true,
|
|
281
|
+
sessions: scan.sessions.length,
|
|
282
|
+
attributedSessions,
|
|
283
|
+
firstSeen: allRuns.length > 0 ? new Date(allRuns[0].startMs).toISOString() : null,
|
|
284
|
+
lastSeen: allRuns.length > 0 ? new Date(Math.max(...allRuns.map((r) => r.endMs))).toISOString() : null,
|
|
285
|
+
};
|
|
286
|
+
return {
|
|
287
|
+
source,
|
|
288
|
+
thresholds,
|
|
289
|
+
skills: aggregateRuns(allRuns),
|
|
290
|
+
runs: allRuns,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
//# sourceMappingURL=codex-metrics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codex-metrics.js","sourceRoot":"","sources":["../../src/core/codex-metrics.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plumbing shared by every local web command (`dashboard`, `metrics`).
|
|
3
|
+
*
|
|
4
|
+
* Two of these are trust boundaries, and that is the reason this module exists
|
|
5
|
+
* rather than each command carrying its own copy: `isAllowedHostHeader` is a
|
|
6
|
+
* DNS-rebinding guard and `serveAsset` is a path-escape guard. A second
|
|
7
|
+
* implementation of either is a hole that survives fixing the first one.
|
|
8
|
+
*
|
|
9
|
+
* `serveAsset` takes its root directory as an argument — the commands serve
|
|
10
|
+
* different asset trees, and parameterising the directory is what lets one
|
|
11
|
+
* implementation serve both.
|
|
12
|
+
*/
|
|
13
|
+
import * as http from 'node:http';
|
|
14
|
+
/** Hosts considered loopback for binding and Host-header checks. */
|
|
15
|
+
export declare const LOOPBACK_HOSTS: Set<string>;
|
|
16
|
+
export declare function sendJson(res: http.ServerResponse, status: number, payload: unknown): void;
|
|
17
|
+
/**
|
|
18
|
+
* Absolute path to a shipped asset tree (`assets/<name>`), resolved relative to
|
|
19
|
+
* this module's own URL.
|
|
20
|
+
*
|
|
21
|
+
* The depth is load-bearing: compiled to `dist/core/`, two levels up is the
|
|
22
|
+
* package root, which is also what `dist/commands/` yields. Assets are shipped
|
|
23
|
+
* as package `files` and never compiled, so moving this module to a different
|
|
24
|
+
* depth would silently resolve outside the package. Every caller goes through
|
|
25
|
+
* here so that constraint has exactly one home.
|
|
26
|
+
*/
|
|
27
|
+
export declare function packageAssetsDir(name: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Serves one file from `dir`. The requested name is confined to that directory:
|
|
30
|
+
* URL parsing already normalises `..`, but a resolved path outside `dir` is
|
|
31
|
+
* refused regardless, since this is the only thing standing between a crafted
|
|
32
|
+
* request and the rest of the filesystem.
|
|
33
|
+
*/
|
|
34
|
+
export declare function serveAsset(res: http.ServerResponse, dir: string, rawName: string): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* DNS-rebinding guard: only requests whose Host header names loopback (or the
|
|
37
|
+
* explicitly bound non-loopback address) are served. A malicious page can make
|
|
38
|
+
* a victim's browser resolve an attacker domain to 127.0.0.1 and read the
|
|
39
|
+
* server's endpoints; the attacker domain in the Host header is what gives that
|
|
40
|
+
* away.
|
|
41
|
+
*/
|
|
42
|
+
export declare function isAllowedHostHeader(hostHeader: string | undefined, boundHost: string): boolean;
|
|
43
|
+
/** Resolves once the server is listening, or rejects with the listen error. */
|
|
44
|
+
export declare function listenOnce(server: http.Server, port: number, host: string): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Binds `basePort`, advancing to the next port on `EADDRINUSE` until one is
|
|
47
|
+
* free, and returns the port actually bound. Every local command wants the same
|
|
48
|
+
* rule — a predictable port you can keep in your head, that still yields rather
|
|
49
|
+
* than failing when something already holds it.
|
|
50
|
+
*
|
|
51
|
+
* `port: 0` is passed straight through: the OS picks a free port itself, so
|
|
52
|
+
* there is nothing to retry and incrementing from whatever it chose would be
|
|
53
|
+
* meaningless.
|
|
54
|
+
*
|
|
55
|
+
* The attempt bound is a backstop, not a policy: without it a machine that
|
|
56
|
+
* refuses every bind for some other reason would spin through 65k ports.
|
|
57
|
+
*/
|
|
58
|
+
export declare function listenFrom(server: http.Server, basePort: number, host: string, maxAttempts?: number): Promise<number>;
|
|
59
|
+
/**
|
|
60
|
+
* Builds the platform command that opens `url` in the default browser.
|
|
61
|
+
* Pure (no side effects) so the platform branching is testable without
|
|
62
|
+
* launching anything.
|
|
63
|
+
*/
|
|
64
|
+
export declare function browserOpenCommand(url: string, platform?: NodeJS.Platform): string;
|
|
65
|
+
/** Opens `url` in a browser; a failure only warns (the URL is already printed). */
|
|
66
|
+
export declare function openBrowser(url: string): void;
|
|
67
|
+
//# sourceMappingURL=local-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-server.d.ts","sourceRoot":"","sources":["../../src/core/local-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAYlC,oEAAoE;AACpE,eAAO,MAAM,cAAc,aAA6C,CAAC;AAMzE,wBAAgB,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAGzF;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,IAAI,CAAC,cAAc,EACxB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC,CAkBf;AAMD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAQ9F;AAMD,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAczF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,IAAI,CAAC,MAAM,EACnB,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,EACZ,WAAW,SAAM,GAChB,OAAO,CAAC,MAAM,CAAC,CAiBjB;AAMD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,MAAM,CAIR;AAED,mFAAmF;AACnF,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAQ7C"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plumbing shared by every local web command (`dashboard`, `metrics`).
|
|
3
|
+
*
|
|
4
|
+
* Two of these are trust boundaries, and that is the reason this module exists
|
|
5
|
+
* rather than each command carrying its own copy: `isAllowedHostHeader` is a
|
|
6
|
+
* DNS-rebinding guard and `serveAsset` is a path-escape guard. A second
|
|
7
|
+
* implementation of either is a hole that survives fixing the first one.
|
|
8
|
+
*
|
|
9
|
+
* `serveAsset` takes its root directory as an argument — the commands serve
|
|
10
|
+
* different asset trees, and parameterising the directory is what lets one
|
|
11
|
+
* implementation serve both.
|
|
12
|
+
*/
|
|
13
|
+
import { promises as fs } from 'node:fs';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { exec } from 'node:child_process';
|
|
17
|
+
const ASSET_CONTENT_TYPES = {
|
|
18
|
+
'.html': 'text/html',
|
|
19
|
+
'.js': 'text/javascript',
|
|
20
|
+
'.css': 'text/css',
|
|
21
|
+
};
|
|
22
|
+
/** Hosts considered loopback for binding and Host-header checks. */
|
|
23
|
+
export const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
24
|
+
// -----------------------------------------------------------------------------
|
|
25
|
+
// Responses
|
|
26
|
+
// -----------------------------------------------------------------------------
|
|
27
|
+
export function sendJson(res, status, payload) {
|
|
28
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
29
|
+
res.end(JSON.stringify(payload));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Absolute path to a shipped asset tree (`assets/<name>`), resolved relative to
|
|
33
|
+
* this module's own URL.
|
|
34
|
+
*
|
|
35
|
+
* The depth is load-bearing: compiled to `dist/core/`, two levels up is the
|
|
36
|
+
* package root, which is also what `dist/commands/` yields. Assets are shipped
|
|
37
|
+
* as package `files` and never compiled, so moving this module to a different
|
|
38
|
+
* depth would silently resolve outside the package. Every caller goes through
|
|
39
|
+
* here so that constraint has exactly one home.
|
|
40
|
+
*/
|
|
41
|
+
export function packageAssetsDir(name) {
|
|
42
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
return path.join(here, '..', '..', 'assets', name);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Serves one file from `dir`. The requested name is confined to that directory:
|
|
47
|
+
* URL parsing already normalises `..`, but a resolved path outside `dir` is
|
|
48
|
+
* refused regardless, since this is the only thing standing between a crafted
|
|
49
|
+
* request and the rest of the filesystem.
|
|
50
|
+
*/
|
|
51
|
+
export async function serveAsset(res, dir, rawName) {
|
|
52
|
+
const name = decodeURIComponent(rawName);
|
|
53
|
+
const resolved = path.resolve(dir, name);
|
|
54
|
+
if (resolved !== dir && !resolved.startsWith(dir + path.sep)) {
|
|
55
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
56
|
+
}
|
|
57
|
+
const type = ASSET_CONTENT_TYPES[path.extname(resolved)];
|
|
58
|
+
if (!type) {
|
|
59
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
60
|
+
}
|
|
61
|
+
let content;
|
|
62
|
+
try {
|
|
63
|
+
content = await fs.readFile(resolved);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
67
|
+
}
|
|
68
|
+
res.writeHead(200, { 'content-type': `${type}; charset=utf-8` });
|
|
69
|
+
res.end(content);
|
|
70
|
+
}
|
|
71
|
+
// -----------------------------------------------------------------------------
|
|
72
|
+
// Trust boundary
|
|
73
|
+
// -----------------------------------------------------------------------------
|
|
74
|
+
/**
|
|
75
|
+
* DNS-rebinding guard: only requests whose Host header names loopback (or the
|
|
76
|
+
* explicitly bound non-loopback address) are served. A malicious page can make
|
|
77
|
+
* a victim's browser resolve an attacker domain to 127.0.0.1 and read the
|
|
78
|
+
* server's endpoints; the attacker domain in the Host header is what gives that
|
|
79
|
+
* away.
|
|
80
|
+
*/
|
|
81
|
+
export function isAllowedHostHeader(hostHeader, boundHost) {
|
|
82
|
+
if (!hostHeader)
|
|
83
|
+
return false;
|
|
84
|
+
// Strip the port; IPv6 hosts arrive bracketed ([::1]:5620).
|
|
85
|
+
const match = hostHeader.match(/^(\[[^\]]+\]|[^:]+)(:\d+)?$/);
|
|
86
|
+
if (!match)
|
|
87
|
+
return false;
|
|
88
|
+
const name = match[1].toLowerCase().replace(/^\[|\]$/g, '');
|
|
89
|
+
if (LOOPBACK_HOSTS.has(name))
|
|
90
|
+
return true;
|
|
91
|
+
return name === boundHost.toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
// -----------------------------------------------------------------------------
|
|
94
|
+
// Lifecycle
|
|
95
|
+
// -----------------------------------------------------------------------------
|
|
96
|
+
/** Resolves once the server is listening, or rejects with the listen error. */
|
|
97
|
+
export function listenOnce(server, port, host) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const onError = (err) => {
|
|
100
|
+
server.removeListener('listening', onListening);
|
|
101
|
+
reject(err);
|
|
102
|
+
};
|
|
103
|
+
const onListening = () => {
|
|
104
|
+
server.removeListener('error', onError);
|
|
105
|
+
resolve();
|
|
106
|
+
};
|
|
107
|
+
server.once('error', onError);
|
|
108
|
+
server.once('listening', onListening);
|
|
109
|
+
server.listen(port, host);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Binds `basePort`, advancing to the next port on `EADDRINUSE` until one is
|
|
114
|
+
* free, and returns the port actually bound. Every local command wants the same
|
|
115
|
+
* rule — a predictable port you can keep in your head, that still yields rather
|
|
116
|
+
* than failing when something already holds it.
|
|
117
|
+
*
|
|
118
|
+
* `port: 0` is passed straight through: the OS picks a free port itself, so
|
|
119
|
+
* there is nothing to retry and incrementing from whatever it chose would be
|
|
120
|
+
* meaningless.
|
|
121
|
+
*
|
|
122
|
+
* The attempt bound is a backstop, not a policy: without it a machine that
|
|
123
|
+
* refuses every bind for some other reason would spin through 65k ports.
|
|
124
|
+
*/
|
|
125
|
+
export async function listenFrom(server, basePort, host, maxAttempts = 200) {
|
|
126
|
+
let port = basePort;
|
|
127
|
+
for (let attempt = 0;; attempt++) {
|
|
128
|
+
try {
|
|
129
|
+
await listenOnce(server, port, host);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err.code === 'EADDRINUSE' && port !== 0 && attempt < maxAttempts) {
|
|
134
|
+
port++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// With `port: 0` the bound port is only knowable from the socket.
|
|
141
|
+
const address = server.address();
|
|
142
|
+
return typeof address === 'object' && address ? address.port : port;
|
|
143
|
+
}
|
|
144
|
+
// -----------------------------------------------------------------------------
|
|
145
|
+
// Browser
|
|
146
|
+
// -----------------------------------------------------------------------------
|
|
147
|
+
/**
|
|
148
|
+
* Builds the platform command that opens `url` in the default browser.
|
|
149
|
+
* Pure (no side effects) so the platform branching is testable without
|
|
150
|
+
* launching anything.
|
|
151
|
+
*/
|
|
152
|
+
export function browserOpenCommand(url, platform = process.platform) {
|
|
153
|
+
if (platform === 'win32')
|
|
154
|
+
return `start "" "${url}"`;
|
|
155
|
+
if (platform === 'darwin')
|
|
156
|
+
return `open "${url}"`;
|
|
157
|
+
return `xdg-open "${url}"`;
|
|
158
|
+
}
|
|
159
|
+
/** Opens `url` in a browser; a failure only warns (the URL is already printed). */
|
|
160
|
+
export function openBrowser(url) {
|
|
161
|
+
// `start` is a cmd builtin, so this must run through the shell (exec does).
|
|
162
|
+
// windowsHide keeps the transient cmd.exe from flashing a console window.
|
|
163
|
+
exec(browserOpenCommand(url), { windowsHide: true }, (err) => {
|
|
164
|
+
if (err) {
|
|
165
|
+
console.warn(`Could not open a browser automatically: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=local-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-server.js","sourceRoot":"","sources":["../../src/core/local-server.ts"],"names":[],"mappings":"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"}
|