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
package/src/cli/trace.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `staysfixed trace` — "when did this stop looking right?"
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { loadProject } from '../core/config.js';
|
|
6
|
+
import { projectStatus } from '../run.js';
|
|
7
|
+
import { traceScreens } from '../marker/trace.js';
|
|
8
|
+
import { printTrace } from '../report/console.js';
|
|
9
|
+
import { say, paint } from '../core/log.js';
|
|
10
|
+
import { resultPicture } from '../core/paths.js';
|
|
11
|
+
import { sha256File } from '../core/hash.js';
|
|
12
|
+
import { EXIT } from '../core/errors.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('./index.js').CliContext} ctx
|
|
16
|
+
* @returns {Promise<number>}
|
|
17
|
+
*/
|
|
18
|
+
export async function run(ctx) {
|
|
19
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
|
|
20
|
+
|
|
21
|
+
const asked = ctx.args.filter((a) => a.trim() !== '');
|
|
22
|
+
const changed = asked.length > 0 ? [] : await changedInLastRun(project);
|
|
23
|
+
const names = asked.length > 0 ? asked : changed;
|
|
24
|
+
|
|
25
|
+
if (asked.length === 0) {
|
|
26
|
+
if (names.length === 0) {
|
|
27
|
+
say(paint.grey('Nothing is different right now, so this looks at every screen there is a record of.'));
|
|
28
|
+
} else {
|
|
29
|
+
say(paint.grey(`Tracing what the last check said had changed: ${names.join(', ')}`));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// When a screen has just gone wrong, the picture worth tracing is the one the
|
|
34
|
+
// failing run took — not the approved one, which is by definition the old, good
|
|
35
|
+
// version and would trace as "nothing has changed".
|
|
36
|
+
const current = await fingerprintsOfLastRun(project, names);
|
|
37
|
+
|
|
38
|
+
/** @type {{names?: string[], current?: Record<string,string>}} */
|
|
39
|
+
const options = { names };
|
|
40
|
+
if (Object.keys(current).length > 0) options.current = current;
|
|
41
|
+
|
|
42
|
+
const report = await traceScreens(project, options);
|
|
43
|
+
|
|
44
|
+
printTrace(report);
|
|
45
|
+
return report.findings.some((f) => f.verdict === 'changed') ? EXIT.failed : EXIT.ok;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('../types.js').Project} project
|
|
50
|
+
* @returns {Promise<string[]>}
|
|
51
|
+
*/
|
|
52
|
+
async function changedInLastRun(project) {
|
|
53
|
+
const status = /** @type {any} */ (await projectStatus(project));
|
|
54
|
+
/** @type {import('../types.js').PictureResult[]} */
|
|
55
|
+
const pictures = status?.lastRun?.pictures ?? [];
|
|
56
|
+
return pictures.filter((p) => p.status === 'changed').map((p) => p.name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fingerprints of the pictures this project's last run actually took.
|
|
61
|
+
* @param {import('../types.js').Project} project
|
|
62
|
+
* @param {string[]} names
|
|
63
|
+
* @returns {Promise<Record<string,string>>}
|
|
64
|
+
*/
|
|
65
|
+
async function fingerprintsOfLastRun(project, names) {
|
|
66
|
+
/** @type {Record<string,string>} */
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const hash = await sha256File(resultPicture(project.paths, name).png);
|
|
70
|
+
if (hash) out[name] = hash;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
package/src/cli/walk.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `staysfixed walk` — the last look before a release.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { loadProject } from '../core/config.js';
|
|
7
|
+
import { runWalk } from '../run.js';
|
|
8
|
+
import { printWalkReport } from '../report/console.js';
|
|
9
|
+
import { say, warn, paint, shortPath } from '../core/log.js';
|
|
10
|
+
import { EXIT } from '../core/errors.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('./index.js').CliContext} ctx
|
|
14
|
+
* @returns {Promise<number>}
|
|
15
|
+
*/
|
|
16
|
+
export async function run(ctx) {
|
|
17
|
+
const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
|
|
18
|
+
|
|
19
|
+
/** @type {import('../types.js').WalkReport} */
|
|
20
|
+
const report = await runWalk(project, /** @type {any} */ ({ only: ctx.list('only'), tool: ctx.version }));
|
|
21
|
+
|
|
22
|
+
printWalkReport(report);
|
|
23
|
+
|
|
24
|
+
const sheet = report.reportFile || report.dir;
|
|
25
|
+
if (ctx.bool('open')) {
|
|
26
|
+
if (sheet) openIt(sheet);
|
|
27
|
+
else warn('There is no contact sheet to open — the walk did not photograph anything.');
|
|
28
|
+
} else if (sheet) {
|
|
29
|
+
say(paint.grey(` Open it with: staysfixed walk --open, or just open ${shortPath(sheet)}`));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return report.ok ? EXIT.ok : EXIT.failed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hand the file to whatever the operating system uses to open things. It is
|
|
37
|
+
* detached and ignored on purpose: the viewer outliving this command is the point.
|
|
38
|
+
* @param {string} file
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
41
|
+
function openIt(file) {
|
|
42
|
+
/** @type {[string, string[]]} */
|
|
43
|
+
const opener =
|
|
44
|
+
process.platform === 'darwin'
|
|
45
|
+
? ['open', [file]]
|
|
46
|
+
: process.platform === 'win32'
|
|
47
|
+
? ['cmd', ['/c', 'start', '', file]]
|
|
48
|
+
: ['xdg-open', [file]];
|
|
49
|
+
const [command, args] = opener;
|
|
50
|
+
try {
|
|
51
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
52
|
+
child.on('error', () => warn(`Could not open it for you. The file is at ${shortPath(file)}`));
|
|
53
|
+
child.unref();
|
|
54
|
+
} catch {
|
|
55
|
+
warn(`Could not open it for you. The file is at ${shortPath(file)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading, defaulting and validating a project's config.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes are accepted on purpose:
|
|
5
|
+
* - staysfixed.config.js — screens can use code (`do(page) { ... }`)
|
|
6
|
+
* - staysfixed.config.json — screens use declarative steps only
|
|
7
|
+
*
|
|
8
|
+
* The JSON form exists so a Rust, Python or Go project can use this tool
|
|
9
|
+
* without anybody writing JavaScript.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fsp from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
import { StaysFixedError } from './errors.js';
|
|
16
|
+
import { findConfigFile, pathsFor, rootForConfig, DEFAULT_DIR } from './paths.js';
|
|
17
|
+
|
|
18
|
+
/** @type {Required<import('../types.js').ViewportConfig>} */
|
|
19
|
+
export const DEFAULT_VIEWPORT = {
|
|
20
|
+
width: 1440,
|
|
21
|
+
height: 900,
|
|
22
|
+
deviceScaleFactor: 2,
|
|
23
|
+
mobile: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** @type {Required<import('../types.js').SettleConfig>} */
|
|
27
|
+
export const DEFAULT_SETTLE = {
|
|
28
|
+
frames: 2,
|
|
29
|
+
intervalMs: 250,
|
|
30
|
+
timeoutMs: 10_000,
|
|
31
|
+
maxDriftPixels: 0,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** @type {Required<Omit<import('../types.js').FreezeConfig,'settle'>> & {settle: Required<import('../types.js').SettleConfig>}} */
|
|
35
|
+
export const DEFAULT_FREEZE = {
|
|
36
|
+
clock: '2026-01-01T12:00:00.000Z',
|
|
37
|
+
timezone: 'UTC',
|
|
38
|
+
locale: 'en-US',
|
|
39
|
+
motion: true,
|
|
40
|
+
random: 'seeded',
|
|
41
|
+
seed: 20260101,
|
|
42
|
+
fonts: true,
|
|
43
|
+
network: 'block-external',
|
|
44
|
+
networkAllow: [],
|
|
45
|
+
hideScrollbars: true,
|
|
46
|
+
hideCaret: true,
|
|
47
|
+
settle: DEFAULT_SETTLE,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** @type {Required<Omit<import('../types.js').ToleranceConfig,'maxPixels'>>} */
|
|
51
|
+
export const DEFAULT_TOLERANCE = {
|
|
52
|
+
// 0.05% of pixels. On a 1440x900 @2x picture that is about 1300 pixels — enough
|
|
53
|
+
// for font hinting noise, nowhere near enough to hide a missing stylesheet.
|
|
54
|
+
pixels: 0.0005,
|
|
55
|
+
threshold: 0.12,
|
|
56
|
+
antialiasing: true,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** @type {Required<import('../types.js').McpConfig>} */
|
|
60
|
+
export const DEFAULT_MCP = {
|
|
61
|
+
// An agent must never approve its own work. This default is the whole point.
|
|
62
|
+
allowApprove: false,
|
|
63
|
+
allowMark: false,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Find, import and resolve the config.
|
|
68
|
+
* @param {{cwd?: string, configFile?: string}} [opts]
|
|
69
|
+
* @returns {Promise<import('../types.js').Project>}
|
|
70
|
+
*/
|
|
71
|
+
export async function loadProject(opts = {}) {
|
|
72
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
73
|
+
const file = opts.configFile ? path.resolve(cwd, opts.configFile) : findConfigFile(cwd);
|
|
74
|
+
if (!file) {
|
|
75
|
+
throw new StaysFixedError('No Stays Fixed config found here.', {
|
|
76
|
+
hint: 'Run `staysfixed init` in your project to make one.',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const raw = await importConfig(file);
|
|
80
|
+
const root = rootForConfig(file);
|
|
81
|
+
const config = resolveConfig(raw, file);
|
|
82
|
+
const paths = pathsFor(root, file, config.dir);
|
|
83
|
+
// `guards` is the one folder a project is free to move, so the config wins over the
|
|
84
|
+
// default layout. Without this the setting silently did nothing and the tool reported
|
|
85
|
+
// "no guards yet" while staring straight at a folder full of them.
|
|
86
|
+
paths.guards = path.isAbsolute(config.guards) ? config.guards : path.join(root, config.guards);
|
|
87
|
+
return { config, paths };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} file
|
|
92
|
+
* @returns {Promise<unknown>}
|
|
93
|
+
*/
|
|
94
|
+
async function importConfig(file) {
|
|
95
|
+
if (file.endsWith('.json')) {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
98
|
+
} catch (cause) {
|
|
99
|
+
throw new StaysFixedError(`Could not read ${path.basename(file)} — it is not valid JSON.`, { cause });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
// Cache-bust so a long-lived MCP server picks up config edits without restarting.
|
|
104
|
+
const url = pathToFileURL(file).href + `?t=${(await fsp.stat(file)).mtimeMs}`;
|
|
105
|
+
const mod = await import(url);
|
|
106
|
+
return mod.default ?? mod.config ?? mod;
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
throw new StaysFixedError(`Could not load ${path.basename(file)}.`, {
|
|
109
|
+
hint: cause instanceof Error ? cause.message : undefined,
|
|
110
|
+
cause,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Fill in defaults and reject anything that would fail later in a confusing way.
|
|
117
|
+
* @param {unknown} raw
|
|
118
|
+
* @param {string} file
|
|
119
|
+
* @returns {import('../types.js').ResolvedConfig}
|
|
120
|
+
*/
|
|
121
|
+
export function resolveConfig(raw, file = '(inline)') {
|
|
122
|
+
if (!raw || typeof raw !== 'object') {
|
|
123
|
+
throw new StaysFixedError(`${path.basename(file)} did not export a config object.`, {
|
|
124
|
+
hint: 'It should `export default { app: { ... }, screens: [ ... ] }`.',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const c = /** @type {import('../types.js').StaysFixedConfig} */ (raw);
|
|
128
|
+
|
|
129
|
+
if (!c.app || typeof c.app !== 'object') {
|
|
130
|
+
throw new StaysFixedError('The config has no `app` — Stays Fixed does not know what to open.', {
|
|
131
|
+
hint: "Add `app: { kind: 'web', url: 'http://localhost:3000' }` or `app: { kind: 'electron', binary: '...' }`.",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const kind = c.app.kind;
|
|
135
|
+
if (kind !== 'web' && kind !== 'electron') {
|
|
136
|
+
throw new StaysFixedError(`app.kind must be 'web' or 'electron' (found ${JSON.stringify(kind)}).`);
|
|
137
|
+
}
|
|
138
|
+
if (kind === 'web' && !c.app.url && !c.app.attach) {
|
|
139
|
+
throw new StaysFixedError('A web app needs `app.url` — the address to open.');
|
|
140
|
+
}
|
|
141
|
+
if (kind === 'electron' && !c.app.binary && !c.app.attach) {
|
|
142
|
+
throw new StaysFixedError('An Electron app needs `app.binary` — the path to the executable.', {
|
|
143
|
+
hint: 'On macOS that is inside the bundle: /Applications/Your App.app/Contents/MacOS/Your App',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const screens = (c.screens ?? []).map((s, i) => resolveScreen(s, i));
|
|
148
|
+
const names = new Set();
|
|
149
|
+
for (const s of screens) {
|
|
150
|
+
if (names.has(s.name)) {
|
|
151
|
+
throw new StaysFixedError(`Two screens are both called "${s.name}". Screen names have to be unique.`);
|
|
152
|
+
}
|
|
153
|
+
names.add(s.name);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const freeze = {
|
|
157
|
+
...DEFAULT_FREEZE,
|
|
158
|
+
...(c.freeze ?? {}),
|
|
159
|
+
settle: { ...DEFAULT_SETTLE, ...(c.freeze?.settle ?? {}) },
|
|
160
|
+
};
|
|
161
|
+
if (freeze.clock !== false && typeof freeze.clock === 'string' && Number.isNaN(Date.parse(freeze.clock))) {
|
|
162
|
+
throw new StaysFixedError(`freeze.clock is not a time I can read: ${JSON.stringify(freeze.clock)}.`, {
|
|
163
|
+
hint: "Use an ISO timestamp like '2026-01-01T12:00:00.000Z', or false to leave the clock alone.",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (freeze.network !== 'replay' && freeze.network !== 'block-external' && freeze.network !== 'live') {
|
|
167
|
+
throw new StaysFixedError(`freeze.network must be 'replay', 'block-external' or 'live'.`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
...c,
|
|
172
|
+
app: { ...c.app, args: c.app.args ?? [], env: c.app.env ?? {} },
|
|
173
|
+
viewport: { ...DEFAULT_VIEWPORT, ...(c.viewport ?? {}) },
|
|
174
|
+
freeze,
|
|
175
|
+
tolerance: { ...DEFAULT_TOLERANCE, ...(c.tolerance ?? {}) },
|
|
176
|
+
masks: c.masks ?? [],
|
|
177
|
+
screens,
|
|
178
|
+
guards: c.guards ?? path.join(c.dir ?? DEFAULT_DIR, 'guards'),
|
|
179
|
+
walk: c.walk ?? {},
|
|
180
|
+
mcp: { ...DEFAULT_MCP, ...(c.mcp ?? {}) },
|
|
181
|
+
dir: c.dir ?? DEFAULT_DIR,
|
|
182
|
+
flakeLimit: c.flakeLimit ?? 2,
|
|
183
|
+
retries: c.retries ?? 1,
|
|
184
|
+
concurrency: Math.max(1, c.concurrency ?? 1),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {import('../types.js').ScreenConfig} s
|
|
190
|
+
* @param {number} i
|
|
191
|
+
* @returns {import('../types.js').ScreenConfig}
|
|
192
|
+
*/
|
|
193
|
+
function resolveScreen(s, i) {
|
|
194
|
+
if (!s || typeof s !== 'object') {
|
|
195
|
+
throw new StaysFixedError(`screens[${i}] is not an object.`);
|
|
196
|
+
}
|
|
197
|
+
if (!s.name || typeof s.name !== 'string') {
|
|
198
|
+
throw new StaysFixedError(`screens[${i}] has no name. Every screen needs one, e.g. name: 'sessions-empty'.`);
|
|
199
|
+
}
|
|
200
|
+
if (!s.url && !s.steps && !s.do) {
|
|
201
|
+
throw new StaysFixedError(`Screen "${s.name}" says nothing about how to get there.`, {
|
|
202
|
+
hint: "Give it a `url`, a list of `steps`, or a `do(page)` function.",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const steps = s.steps ? [...s.steps] : [];
|
|
206
|
+
if (s.url) steps.unshift({ goto: s.url });
|
|
207
|
+
return { ...s, steps };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Merge a screen's overrides on top of the project settings.
|
|
212
|
+
* @param {import('../types.js').ResolvedConfig} config
|
|
213
|
+
* @param {import('../types.js').ScreenConfig} screen
|
|
214
|
+
*/
|
|
215
|
+
export function settingsForScreen(config, screen) {
|
|
216
|
+
return {
|
|
217
|
+
viewport: { ...config.viewport, ...(screen.viewport ?? {}) },
|
|
218
|
+
tolerance: { ...config.tolerance, ...(screen.tolerance ?? {}) },
|
|
219
|
+
freeze: {
|
|
220
|
+
...config.freeze,
|
|
221
|
+
...(screen.freeze ?? {}),
|
|
222
|
+
settle: { ...config.freeze.settle, ...(screen.freeze?.settle ?? {}) },
|
|
223
|
+
},
|
|
224
|
+
masks: [...config.masks, ...(screen.masks ?? [])],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One error type, so the CLI can tell "your setup is wrong" (worth explaining)
|
|
3
|
+
* apart from "something exploded" (worth a stack trace).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class StaysFixedError extends Error {
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} message Written for a human, in plain language.
|
|
9
|
+
* @param {{hint?: string, cause?: unknown, exitCode?: number}} [opts]
|
|
10
|
+
*/
|
|
11
|
+
constructor(message, opts = {}) {
|
|
12
|
+
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
13
|
+
this.name = 'StaysFixedError';
|
|
14
|
+
/** @type {string|undefined} */
|
|
15
|
+
this.hint = opts.hint;
|
|
16
|
+
/** @type {number} */
|
|
17
|
+
this.exitCode = opts.exitCode ?? 2;
|
|
18
|
+
/** @type {true} */
|
|
19
|
+
this.expected = true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {unknown} e
|
|
25
|
+
* @returns {e is StaysFixedError}
|
|
26
|
+
*/
|
|
27
|
+
export function isExpected(e) {
|
|
28
|
+
return Boolean(e && typeof e === 'object' && /** @type {any} */ (e).expected === true);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {unknown} e
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function messageOf(e) {
|
|
36
|
+
if (e instanceof Error) return e.message;
|
|
37
|
+
return String(e);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Exit codes the CLI uses. Stable, so scripts can rely on them. */
|
|
41
|
+
export const EXIT = {
|
|
42
|
+
/** Everything the tool knows about still works. */
|
|
43
|
+
ok: 0,
|
|
44
|
+
/** Something changed or a guard failed — a human needs to look. */
|
|
45
|
+
failed: 1,
|
|
46
|
+
/** The tool could not run: bad config, no browser, app would not start. */
|
|
47
|
+
error: 2,
|
|
48
|
+
};
|
package/src/core/git.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough git to answer "which commit was this true at?".
|
|
3
|
+
*
|
|
4
|
+
* Every call is read-only and every call is allowed to fail — Stays Fixed works
|
|
5
|
+
* in a folder that is not a git repository, it just cannot trace history there.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string[]} args
|
|
15
|
+
* @param {string} cwd
|
|
16
|
+
* @returns {Promise<string|null>}
|
|
17
|
+
*/
|
|
18
|
+
async function git(args, cwd) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = await run('git', args, { cwd, timeout: 10_000, maxBuffer: 8 * 1024 * 1024 });
|
|
21
|
+
return stdout.trim();
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} cwd
|
|
29
|
+
* @returns {Promise<import('../types.js').GitInfo>}
|
|
30
|
+
*/
|
|
31
|
+
export async function gitInfo(cwd) {
|
|
32
|
+
const sha = await git(['rev-parse', 'HEAD'], cwd);
|
|
33
|
+
const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
34
|
+
const status = await git(['status', '--porcelain'], cwd);
|
|
35
|
+
const name = await git(['config', 'user.name'], cwd);
|
|
36
|
+
const email = await git(['config', 'user.email'], cwd);
|
|
37
|
+
return {
|
|
38
|
+
sha,
|
|
39
|
+
shortSha: sha ? sha.slice(0, 7) : null,
|
|
40
|
+
branch: branch === 'HEAD' ? null : branch,
|
|
41
|
+
dirty: status !== null && status.length > 0,
|
|
42
|
+
user: name ? (email ? `${name} <${email}>` : name) : null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Commits between two points, newest first. The heart of `staysfixed trace`.
|
|
48
|
+
* @param {string} cwd
|
|
49
|
+
* @param {string} from older sha
|
|
50
|
+
* @param {string} to newer sha
|
|
51
|
+
* @returns {Promise<{sha: string, shortSha: string, subject: string, author: string, date: string}[]>}
|
|
52
|
+
*/
|
|
53
|
+
export async function commitsBetween(cwd, from, to) {
|
|
54
|
+
const out = await git(['log', '--no-merges', '--format=%H%x1f%h%x1f%s%x1f%an%x1f%ad', '--date=short', `${from}..${to}`], cwd);
|
|
55
|
+
if (!out) return [];
|
|
56
|
+
return out
|
|
57
|
+
.split('\n')
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((line) => {
|
|
60
|
+
const [sha, shortSha, subject, author, date] = line.split('\x1f');
|
|
61
|
+
return { sha, shortSha, subject, author, date };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Files touched between two commits — narrows "which change did it" further.
|
|
67
|
+
* @param {string} cwd
|
|
68
|
+
* @param {string} from
|
|
69
|
+
* @param {string} to
|
|
70
|
+
* @returns {Promise<string[]>}
|
|
71
|
+
*/
|
|
72
|
+
export async function filesBetween(cwd, from, to) {
|
|
73
|
+
const out = await git(['diff', '--name-only', `${from}..${to}`], cwd);
|
|
74
|
+
return out ? out.split('\n').filter(Boolean) : [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} cwd
|
|
79
|
+
* @param {string} sha
|
|
80
|
+
*/
|
|
81
|
+
export async function commitExists(cwd, sha) {
|
|
82
|
+
return (await git(['cat-file', '-e', `${sha}^{commit}`], cwd)) !== null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} cwd
|
|
87
|
+
*/
|
|
88
|
+
export async function isRepo(cwd) {
|
|
89
|
+
return (await git(['rev-parse', '--is-inside-work-tree'], cwd)) === 'true';
|
|
90
|
+
}
|
package/src/core/hash.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Hashing. Used to fingerprint pictures so markers can spot a change without storing copies. */
|
|
2
|
+
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fsp from 'node:fs/promises';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Buffer|Uint8Array|string} data
|
|
8
|
+
* @returns {string} hex sha256
|
|
9
|
+
*/
|
|
10
|
+
export function sha256(data) {
|
|
11
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} file
|
|
16
|
+
* @returns {Promise<string|null>} hex sha256, or null when the file is not there
|
|
17
|
+
*/
|
|
18
|
+
export async function sha256File(file) {
|
|
19
|
+
try {
|
|
20
|
+
return sha256(await fsp.readFile(file));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A short, readable fingerprint for logs. Never used for comparison.
|
|
28
|
+
* @param {string} hex
|
|
29
|
+
*/
|
|
30
|
+
export function shortHash(hex) {
|
|
31
|
+
return hex.slice(0, 12);
|
|
32
|
+
}
|