staysfixed 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/staysfixed.js +18 -0
- package/examples/guards/the-sidebar-still-collapses.js +91 -0
- package/examples/staysfixed.config.electron.js +172 -0
- package/examples/staysfixed.config.web.js +277 -0
- package/package.json +61 -0
- package/src/cli/approve.js +126 -0
- package/src/cli/check.js +73 -0
- package/src/cli/doctor.js +379 -0
- package/src/cli/flake.js +61 -0
- package/src/cli/index.js +519 -0
- package/src/cli/init.js +564 -0
- package/src/cli/mark.js +69 -0
- package/src/cli/status.js +19 -0
- package/src/cli/trace.js +73 -0
- package/src/cli/walk.js +57 -0
- package/src/core/config.js +226 -0
- package/src/core/errors.js +48 -0
- package/src/core/git.js +90 -0
- package/src/core/hash.js +32 -0
- package/src/core/history.js +173 -0
- package/src/core/log.js +144 -0
- package/src/core/paths.js +135 -0
- package/src/drive/browser.js +540 -0
- package/src/drive/cdp.js +382 -0
- package/src/drive/electron.js +326 -0
- package/src/drive/find.js +331 -0
- package/src/drive/launch.js +263 -0
- package/src/drive/page.js +1042 -0
- package/src/freeze/clock.js +213 -0
- package/src/freeze/fonts.js +243 -0
- package/src/freeze/index.js +234 -0
- package/src/freeze/mask.js +187 -0
- package/src/freeze/motion.js +206 -0
- package/src/freeze/network.js +455 -0
- package/src/freeze/random.js +87 -0
- package/src/freeze/settle.js +178 -0
- package/src/guard/api.js +197 -0
- package/src/guard/load.js +324 -0
- package/src/guard/name.js +327 -0
- package/src/guard/run.js +224 -0
- package/src/index.js +61 -0
- package/src/marker/mark.js +260 -0
- package/src/marker/trace.js +293 -0
- package/src/mcp/server.js +377 -0
- package/src/mcp/tools.js +978 -0
- package/src/picture/capture.js +276 -0
- package/src/picture/compare.js +103 -0
- package/src/picture/run.js +284 -0
- package/src/picture/store.js +208 -0
- package/src/report/console.js +540 -0
- package/src/report/html.js +579 -0
- package/src/run.js +614 -0
- package/src/types.js +471 -0
- package/src/walk/run.js +541 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stays Fixed over the Model Context Protocol, hand-rolled.
|
|
3
|
+
*
|
|
4
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* STDOUT IS THE PROTOCOL. Nothing but JSON-RPC messages may EVER be written
|
|
6
|
+
* to it — one compact JSON object per line, newline-terminated. A single
|
|
7
|
+
* stray `console.log` anywhere in the process, in this tool or in somebody's
|
|
8
|
+
* guard file, corrupts the stream and the client's parser dies with an error
|
|
9
|
+
* that points nowhere near the real cause. Every human-readable word goes to
|
|
10
|
+
* stderr. `serveMcp` enforces this by swapping `process.stdout.write` for a
|
|
11
|
+
* diverter and keeping the real one to itself; do not undo that.
|
|
12
|
+
* ────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* The transport is deliberately tiny: read stdin, split on newlines, answer.
|
|
15
|
+
* That is the entire MCP stdio transport, and writing it by hand costs about a
|
|
16
|
+
* hundred lines and saves the project its only heavy dependency.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
20
|
+
import { loadProject, DEFAULT_MCP } from '../core/config.js';
|
|
21
|
+
import { setLogLevel } from '../core/log.js';
|
|
22
|
+
import { isExpected, messageOf } from '../core/errors.js';
|
|
23
|
+
import { toolDefinitions, callTool } from './tools.js';
|
|
24
|
+
|
|
25
|
+
/** Protocol revisions this server understands. Newest first. */
|
|
26
|
+
const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
27
|
+
const LATEST_PROTOCOL = SUPPORTED_PROTOCOLS[0];
|
|
28
|
+
|
|
29
|
+
/** JSON-RPC 2.0 error codes, plus the ones MCP leans on. */
|
|
30
|
+
const RPC = {
|
|
31
|
+
parseError: -32700,
|
|
32
|
+
invalidRequest: -32600,
|
|
33
|
+
methodNotFound: -32601,
|
|
34
|
+
invalidParams: -32602,
|
|
35
|
+
internalError: -32603,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A line this long without a newline is a stream that has gone wrong, not a
|
|
40
|
+
* message. Incoming requests are small; only our replies carry screenshots.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_LINE_BYTES = 64 * 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
/** How long we wait for a check that is mid-flight when the client hangs up. */
|
|
45
|
+
const SHUTDOWN_GRACE_MS = 10_000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Anything that tries to print to stdout gets pushed to stderr instead.
|
|
49
|
+
* @param {any} chunk
|
|
50
|
+
* @param {any} [encoding]
|
|
51
|
+
* @param {any} [callback]
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
function divertToStderr(chunk, encoding, callback) {
|
|
55
|
+
return process.stderr.write(chunk, encoding, callback);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Serve Stays Fixed on stdin/stdout until the client goes away.
|
|
60
|
+
*
|
|
61
|
+
* @param {{cwd?: string, configFile?: string, version?: string}} [opts]
|
|
62
|
+
* @returns {Promise<void>}
|
|
63
|
+
*/
|
|
64
|
+
export async function serveMcp(opts = {}) {
|
|
65
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
66
|
+
const configFile = opts.configFile;
|
|
67
|
+
const version = opts.version ?? '0.1.0';
|
|
68
|
+
|
|
69
|
+
// Two layers of the same guard. `quiet` stops the tool's own reporting; the
|
|
70
|
+
// diverter catches everything else, including a `console.log` left in a guard
|
|
71
|
+
// file by somebody debugging at two in the morning.
|
|
72
|
+
setLogLevel({ quiet: true });
|
|
73
|
+
const writeToClient = process.stdout.write.bind(process.stdout);
|
|
74
|
+
const realStdoutWrite = process.stdout.write;
|
|
75
|
+
/** @type {any} */ (process.stdout).write = divertToStderr;
|
|
76
|
+
|
|
77
|
+
/** @type {import('../types.js').Project|null} */
|
|
78
|
+
let project = null;
|
|
79
|
+
let configComplaint = false;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the config fresh. Called on every tool call so an agent that just
|
|
83
|
+
* edited staysfixed.config.js is answered from the file it wrote, not from a
|
|
84
|
+
* snapshot taken when its editor started this server hours ago.
|
|
85
|
+
* @returns {Promise<import('../types.js').Project>}
|
|
86
|
+
*/
|
|
87
|
+
async function reload() {
|
|
88
|
+
project = await loadProject({ cwd, configFile });
|
|
89
|
+
return project;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The config as far as we know it, for shaping `tools/list`. Never throws. */
|
|
93
|
+
async function configForListing() {
|
|
94
|
+
try {
|
|
95
|
+
const loaded = project ?? (await reload());
|
|
96
|
+
return loaded.config;
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// A broken config must not stop the server from starting or from listing
|
|
99
|
+
// its tools — the agent needs to be able to CALL one and read the real
|
|
100
|
+
// complaint, which is the only way it can fix the file.
|
|
101
|
+
if (!configComplaint) {
|
|
102
|
+
configComplaint = true;
|
|
103
|
+
log(`config not loaded yet: ${messageOf(e)}`);
|
|
104
|
+
}
|
|
105
|
+
return /** @type {any} */ ({ mcp: { ...DEFAULT_MCP } });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One JSON-RPC message out. The ONLY function allowed to touch real stdout.
|
|
111
|
+
* @param {Record<string, any>} message
|
|
112
|
+
*/
|
|
113
|
+
function send(message) {
|
|
114
|
+
try {
|
|
115
|
+
writeToClient(JSON.stringify(message) + '\n');
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// The client hung up mid-answer. There is nowhere left to report it but here.
|
|
118
|
+
log(`could not write a reply: ${messageOf(e)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string|number} id
|
|
124
|
+
* @param {any} result
|
|
125
|
+
*/
|
|
126
|
+
function reply(id, result) {
|
|
127
|
+
send({ jsonrpc: '2.0', id, result });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {string|number|null} id
|
|
132
|
+
* @param {number} code
|
|
133
|
+
* @param {string} message
|
|
134
|
+
*/
|
|
135
|
+
function replyError(id, code, message) {
|
|
136
|
+
send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {string} line */
|
|
140
|
+
function log(line) {
|
|
141
|
+
process.stderr.write(`[staysfixed] ${line}\n`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Tool calls run one at a time. Two of them at once would mean two copies of
|
|
145
|
+
// the app fighting over the same debug port, and the pictures would be
|
|
146
|
+
// nonsense — determinism is the product, so the queue is not optional.
|
|
147
|
+
/** @type {Promise<void>} */
|
|
148
|
+
let queue = Promise.resolve();
|
|
149
|
+
/** @type {Set<Promise<void>>} */
|
|
150
|
+
const inFlight = new Set();
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {() => Promise<void>} job
|
|
154
|
+
* @returns {Promise<void>}
|
|
155
|
+
*/
|
|
156
|
+
function enqueue(job) {
|
|
157
|
+
const run = queue.then(job);
|
|
158
|
+
const settled = run.then(
|
|
159
|
+
() => {},
|
|
160
|
+
() => {}
|
|
161
|
+
);
|
|
162
|
+
queue = settled;
|
|
163
|
+
inFlight.add(settled);
|
|
164
|
+
settled.then(() => inFlight.delete(settled));
|
|
165
|
+
return run;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {any} msg
|
|
170
|
+
*/
|
|
171
|
+
async function handle(msg) {
|
|
172
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
|
|
173
|
+
replyError(null, RPC.invalidRequest, 'Each line must be one JSON-RPC request object. Batched arrays are not supported.');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const method = typeof msg.method === 'string' ? msg.method : null;
|
|
178
|
+
// No `id` member means a notification, and a notification never gets an
|
|
179
|
+
// answer — replying to one is the classic way to wedge a strict client.
|
|
180
|
+
const isNotification = !('id' in msg);
|
|
181
|
+
const id = /** @type {string|number} */ (msg.id);
|
|
182
|
+
|
|
183
|
+
if (!method) {
|
|
184
|
+
if (!isNotification) replyError(id, RPC.invalidRequest, 'That message has no method name.');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (isNotification) {
|
|
189
|
+
if (method === 'notifications/cancelled') log('client cancelled a request');
|
|
190
|
+
// 'notifications/initialized' and anything else: acknowledged by silence.
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
switch (method) {
|
|
195
|
+
case 'initialize': {
|
|
196
|
+
const asked = msg.params?.protocolVersion;
|
|
197
|
+
const client = msg.params?.clientInfo?.name;
|
|
198
|
+
reply(id, {
|
|
199
|
+
// Speak the client's revision when we know it; otherwise answer with
|
|
200
|
+
// ours and let it decide whether it can live with that.
|
|
201
|
+
protocolVersion: typeof asked === 'string' && SUPPORTED_PROTOCOLS.includes(asked) ? asked : LATEST_PROTOCOL,
|
|
202
|
+
capabilities: { tools: {} },
|
|
203
|
+
serverInfo: { name: 'staysfixed', version },
|
|
204
|
+
instructions:
|
|
205
|
+
'Stays Fixed proves that what already worked still works. Call staysfixed_screens once to learn what this project protects, then call staysfixed_check after you finish editing and before you report that you are done. If a picture changed, look at the diff image and decide whether you broke it or whether it was meant to change — approving a new picture is a human decision unless this project has explicitly handed it to you.',
|
|
206
|
+
});
|
|
207
|
+
log(`connected${client ? ` to ${client}` : ''}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case 'ping':
|
|
212
|
+
reply(id, {});
|
|
213
|
+
return;
|
|
214
|
+
|
|
215
|
+
case 'tools/list': {
|
|
216
|
+
const config = await configForListing();
|
|
217
|
+
reply(id, { tools: toolDefinitions(config) });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
case 'tools/call': {
|
|
222
|
+
const name = msg.params?.name;
|
|
223
|
+
if (typeof name !== 'string' || name === '') {
|
|
224
|
+
replyError(id, RPC.invalidParams, 'A tools/call needs the name of the tool to run.');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const args = msg.params?.arguments ?? {};
|
|
228
|
+
if (Array.isArray(args)) {
|
|
229
|
+
replyError(id, RPC.invalidParams, 'Tool arguments must be an object, not a list.');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
await enqueue(async () => {
|
|
233
|
+
try {
|
|
234
|
+
// `project` may still be null on the very first call, or after a config
|
|
235
|
+
// edit broke the file. That is on purpose: callTool reloads, and a broken
|
|
236
|
+
// config then reaches the agent as words it can act on.
|
|
237
|
+
const result = await callTool(name, args, { project: /** @type {any} */ (project), reload, version });
|
|
238
|
+
reply(id, result);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
// A tool that blows up is still a RESULT, not a protocol error: the
|
|
241
|
+
// agent is meant to read what went wrong and try to fix it.
|
|
242
|
+
reply(id, {
|
|
243
|
+
content: [{ type: 'text', text: isExpected(e) ? messageOf(e) : `Stays Fixed could not finish that: ${messageOf(e)}` }],
|
|
244
|
+
isError: true,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
default:
|
|
252
|
+
replyError(id, RPC.methodNotFound, `This server does not handle "${method}".`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── the stdio loop ────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
const decoder = new StringDecoder('utf8');
|
|
259
|
+
let buffer = '';
|
|
260
|
+
let overlong = false;
|
|
261
|
+
|
|
262
|
+
/** @type {() => void} */
|
|
263
|
+
let finish = () => {};
|
|
264
|
+
/** @type {Promise<void>} */
|
|
265
|
+
const done = new Promise((resolve) => {
|
|
266
|
+
finish = resolve;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
let closing = false;
|
|
270
|
+
|
|
271
|
+
/** @param {string} why */
|
|
272
|
+
async function shutdown(why) {
|
|
273
|
+
if (closing) return;
|
|
274
|
+
closing = true;
|
|
275
|
+
log(`shutting down (${why})`);
|
|
276
|
+
// Wait for whatever is mid-flight so the app it opened gets closed properly.
|
|
277
|
+
// Past the grace period we stop waiting; a hung app must not hold the editor.
|
|
278
|
+
if (inFlight.size > 0) {
|
|
279
|
+
/** @type {NodeJS.Timeout|undefined} */
|
|
280
|
+
let timer;
|
|
281
|
+
const grace = new Promise((resolve) => {
|
|
282
|
+
timer = setTimeout(resolve, SHUTDOWN_GRACE_MS);
|
|
283
|
+
});
|
|
284
|
+
await Promise.race([Promise.all([...inFlight]), grace]);
|
|
285
|
+
if (timer) clearTimeout(timer);
|
|
286
|
+
}
|
|
287
|
+
/** @type {any} */ (process.stdout).write = realStdoutWrite;
|
|
288
|
+
setLogLevel({ quiet: false });
|
|
289
|
+
finish();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @param {string} line */
|
|
293
|
+
function onLine(line) {
|
|
294
|
+
const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
295
|
+
if (trimmed.trim() === '') return;
|
|
296
|
+
/** @type {any} */
|
|
297
|
+
let msg;
|
|
298
|
+
try {
|
|
299
|
+
msg = JSON.parse(trimmed);
|
|
300
|
+
} catch {
|
|
301
|
+
replyError(null, RPC.parseError, 'That line was not valid JSON. Each message must be one JSON object on one line.');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// Handling is async; a throw escaping it would take the server down, so it
|
|
305
|
+
// is caught here and reported as an internal error against that one message.
|
|
306
|
+
Promise.resolve()
|
|
307
|
+
.then(() => handle(msg))
|
|
308
|
+
.catch((e) => {
|
|
309
|
+
const id = msg && typeof msg === 'object' && 'id' in msg ? msg.id : null;
|
|
310
|
+
log(`internal error: ${messageOf(e)}`);
|
|
311
|
+
if (id !== null && id !== undefined) replyError(id, RPC.internalError, `Something went wrong inside Stays Fixed: ${messageOf(e)}`);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
process.stdin.on('data', (chunk) => {
|
|
316
|
+
buffer += decoder.write(/** @type {Buffer} */ (chunk));
|
|
317
|
+
|
|
318
|
+
for (;;) {
|
|
319
|
+
const nl = buffer.indexOf('\n');
|
|
320
|
+
if (nl === -1) break;
|
|
321
|
+
const line = buffer.slice(0, nl);
|
|
322
|
+
buffer = buffer.slice(nl + 1);
|
|
323
|
+
if (overlong) {
|
|
324
|
+
// We already gave up on this message; the newline ends it.
|
|
325
|
+
overlong = false;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
onLine(line);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// A line that never ends is a broken stream, not a big request. Drop what we
|
|
332
|
+
// are holding rather than growing until the process runs out of memory.
|
|
333
|
+
if (!overlong && buffer.length > MAX_LINE_BYTES) {
|
|
334
|
+
overlong = true;
|
|
335
|
+
buffer = '';
|
|
336
|
+
replyError(null, RPC.parseError, 'That message was too long to read. Each message must be one JSON object on one line.');
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
process.stdin.on('error', (e) => {
|
|
341
|
+
log(`stdin error: ${messageOf(e)}`);
|
|
342
|
+
void shutdown('stdin error');
|
|
343
|
+
});
|
|
344
|
+
process.stdin.on('end', () => void shutdown('the client closed the connection'));
|
|
345
|
+
process.stdin.on('close', () => void shutdown('the client closed the connection'));
|
|
346
|
+
|
|
347
|
+
/** @type {(() => void)[]} */
|
|
348
|
+
const signalHandlers = [];
|
|
349
|
+
for (const signal of /** @type {NodeJS.Signals[]} */ (['SIGINT', 'SIGTERM'])) {
|
|
350
|
+
const onSignal = () => void shutdown(signal);
|
|
351
|
+
process.on(signal, onSignal);
|
|
352
|
+
signalHandlers.push(() => process.removeListener(signal, onSignal));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
process.stdin.resume();
|
|
356
|
+
log(`Stays Fixed ${version} ready — talking MCP on stdin and stdout, saying everything else here.`);
|
|
357
|
+
|
|
358
|
+
await done;
|
|
359
|
+
for (const off of signalHandlers) off();
|
|
360
|
+
process.stdin.pause();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The block a person pastes into their editor's MCP settings.
|
|
365
|
+
*
|
|
366
|
+
* Printed by `staysfixed init` and quoted in the README, so it is written once,
|
|
367
|
+
* here, and never re-typed into three places that then drift apart.
|
|
368
|
+
*
|
|
369
|
+
* @param {{command: string, args?: string[], cwd?: string}} opts
|
|
370
|
+
* @returns {string}
|
|
371
|
+
*/
|
|
372
|
+
export function mcpConfigSnippet({ command, args = [], cwd }) {
|
|
373
|
+
/** @type {Record<string, any>} */
|
|
374
|
+
const server = { command, args };
|
|
375
|
+
if (cwd) server.cwd = cwd;
|
|
376
|
+
return JSON.stringify({ mcpServers: { staysfixed: server } }, null, 2);
|
|
377
|
+
}
|