design-spine 0.1.0 → 0.2.1
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/README.md +30 -10
- package/bin/resolve-bun.d.ts +23 -0
- package/bin/resolve-bun.js +66 -0
- package/bin/spine.js +18 -6
- package/package.json +19 -6
- package/src/assets.d.ts +10 -0
- package/src/cli.ts +98 -33
- package/src/lib/capture.ts +63 -10
- package/src/lib/docker.ts +196 -0
- package/src/lib/redact.ts +26 -0
- package/src/lib/report.ts +4 -0
- package/src/lib/routes.ts +2 -0
- package/src/lib/runs.ts +9 -3
- package/src/lib/server.ts +14 -0
- package/src/lib/summary.ts +70 -0
- package/src/lib/types.ts +9 -0
- package/src/mcp.ts +117 -0
- package/src/ui/index.html +3 -710
- package/src/ui/review.css +1313 -0
- package/src/ui/review.js +1592 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
2
|
+
import { realpathSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
// Matches package.json engines.bun and the pin in action.yml; a test keeps them in step.
|
|
8
|
+
export const DOCKER_BUN_VERSION = '1.3.13'
|
|
9
|
+
// GitHub's ubuntu-latest is x64, so captures must be too, even on Apple silicon.
|
|
10
|
+
export const DOCKER_PLATFORM = 'linux/amd64'
|
|
11
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
12
|
+
|
|
13
|
+
export const imageTag = (playwrightVersion: string, bunVersion = DOCKER_BUN_VERSION): string =>
|
|
14
|
+
`design-spine-capture:${playwrightVersion}-bun${bunVersion}`
|
|
15
|
+
|
|
16
|
+
export const dockerfileText = (
|
|
17
|
+
playwrightVersion: string,
|
|
18
|
+
bunVersion = DOCKER_BUN_VERSION,
|
|
19
|
+
): string =>
|
|
20
|
+
`FROM mcr.microsoft.com/playwright:v${playwrightVersion}-noble
|
|
21
|
+
COPY --from=oven/bun:${bunVersion} /usr/local/bin/bun /usr/local/bin/bun
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
/** Loopback hosts mean the host machine, which a container reaches as host.docker.internal. */
|
|
25
|
+
export function rewriteBaseForDocker(base: string): string {
|
|
26
|
+
const url = new URL(base)
|
|
27
|
+
if (!LOOPBACK.has(url.hostname)) return base
|
|
28
|
+
url.hostname = 'host.docker.internal'
|
|
29
|
+
const out = url.href
|
|
30
|
+
const bare = url.pathname === '/' && !url.search && !url.hash
|
|
31
|
+
return bare && !base.endsWith('/') ? out.slice(0, -1) : out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function installedPlaywrightVersion(): string {
|
|
35
|
+
const pkg = createRequire(import.meta.url)('playwright/package.json') as { version: string }
|
|
36
|
+
return pkg.version
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CliLocation {
|
|
40
|
+
/** Extra `-v` mount for a CLI that lives outside the project root. */
|
|
41
|
+
mounts: string[]
|
|
42
|
+
/** CLI path as seen inside the container. */
|
|
43
|
+
path: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function locateCli(root: string, cliFile: string, packageDir: string): CliLocation {
|
|
47
|
+
const rel = relative(root, cliFile)
|
|
48
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel))
|
|
49
|
+
return { mounts: [], path: `/work/${rel.split(sep).join('/')}` }
|
|
50
|
+
return {
|
|
51
|
+
mounts: ['-v', `${packageDir}:/spine:ro`],
|
|
52
|
+
path: `/spine/${relative(packageDir, cliFile).split(sep).join('/')}`,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface InnerCapture {
|
|
57
|
+
manifest: string
|
|
58
|
+
base: string
|
|
59
|
+
routes?: string
|
|
60
|
+
concurrency: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function innerCaptureArgs(o: InnerCapture): string[] {
|
|
64
|
+
return [
|
|
65
|
+
'capture',
|
|
66
|
+
'--manifest',
|
|
67
|
+
o.manifest,
|
|
68
|
+
'--base',
|
|
69
|
+
o.base,
|
|
70
|
+
...(o.routes ? ['--routes', o.routes] : []),
|
|
71
|
+
'--concurrency',
|
|
72
|
+
o.concurrency,
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RunArgvInput {
|
|
77
|
+
image: string
|
|
78
|
+
root: string
|
|
79
|
+
cli: CliLocation
|
|
80
|
+
recordedBase: string
|
|
81
|
+
inner: string[]
|
|
82
|
+
/** Set on Linux hosts so files written into the project stay owned by the caller. */
|
|
83
|
+
user?: { uid: number; gid: number }
|
|
84
|
+
/**
|
|
85
|
+
* Linux hosts share the host network, so an app bound only to localhost (the usual CI
|
|
86
|
+
* setup) is reachable without rewriting the URL. Docker Desktop and OrbStack use
|
|
87
|
+
* host.docker.internal instead.
|
|
88
|
+
*/
|
|
89
|
+
hostNetwork?: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function dockerRunArgv(i: RunArgvInput): string[] {
|
|
93
|
+
return [
|
|
94
|
+
'run',
|
|
95
|
+
'--rm',
|
|
96
|
+
'--platform',
|
|
97
|
+
DOCKER_PLATFORM,
|
|
98
|
+
'--init',
|
|
99
|
+
'--ipc=host',
|
|
100
|
+
...(i.user ? ['--user', `${i.user.uid}:${i.user.gid}`, '-e', 'HOME=/tmp'] : []),
|
|
101
|
+
'-v',
|
|
102
|
+
`${i.root}:/work`,
|
|
103
|
+
...i.cli.mounts,
|
|
104
|
+
'-w',
|
|
105
|
+
'/work',
|
|
106
|
+
...(i.hostNetwork ? ['--network', 'host'] : ['--add-host=host.docker.internal:host-gateway']),
|
|
107
|
+
'-e',
|
|
108
|
+
'PLAYWRIGHT_BROWSERS_PATH=/ms-playwright',
|
|
109
|
+
'-e',
|
|
110
|
+
'SPINE_CWD=/work',
|
|
111
|
+
// The run keeps the URL the host used, so review and reports read the same as a host capture.
|
|
112
|
+
'-e',
|
|
113
|
+
`SPINE_RECORDED_BASE_URL=${i.recordedBase}`,
|
|
114
|
+
'-e',
|
|
115
|
+
`SPINE_HOST_ROOT=${i.root}`,
|
|
116
|
+
i.image,
|
|
117
|
+
'bun',
|
|
118
|
+
i.cli.path,
|
|
119
|
+
...i.inner,
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function docker(args: string[], opts: { input?: string; quiet?: boolean } = {}) {
|
|
124
|
+
return spawnSync('docker', args, {
|
|
125
|
+
input: opts.input,
|
|
126
|
+
stdio: [
|
|
127
|
+
opts.input === undefined ? 'ignore' : 'pipe',
|
|
128
|
+
opts.quiet ? 'ignore' : 'inherit',
|
|
129
|
+
'inherit',
|
|
130
|
+
],
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function assertDockerReady(): void {
|
|
135
|
+
const info = spawnSync('docker', ['info'], { stdio: 'ignore' })
|
|
136
|
+
if ((info.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT')
|
|
137
|
+
throw new Error('--docker needs the docker CLI, but `docker` is not on PATH.')
|
|
138
|
+
if (info.status !== 0)
|
|
139
|
+
throw new Error(
|
|
140
|
+
'--docker needs a running Docker daemon, but `docker info` failed. Start Docker or OrbStack.',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function ensureImage(playwrightVersion: string, log: (s: string) => void): string {
|
|
145
|
+
const tag = imageTag(playwrightVersion)
|
|
146
|
+
if (docker(['image', 'inspect', tag], { quiet: true }).status === 0) return tag
|
|
147
|
+
log(`Building ${tag} (${DOCKER_PLATFORM}), first run only...`)
|
|
148
|
+
const built = docker(['build', '--platform', DOCKER_PLATFORM, '-t', tag, '-'], {
|
|
149
|
+
input: dockerfileText(playwrightVersion),
|
|
150
|
+
})
|
|
151
|
+
if (built.status !== 0) throw new Error(`docker build failed for ${tag}.`)
|
|
152
|
+
return tag
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface DockerCapture extends InnerCapture {
|
|
156
|
+
root: string
|
|
157
|
+
/** Base URL as the host sees it, before the loopback rewrite. */
|
|
158
|
+
hostBase: string
|
|
159
|
+
log: (s: string) => void
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Runs `spine capture` inside the pinned Playwright image and resolves to its exit code. */
|
|
163
|
+
export async function captureInDocker(o: DockerCapture): Promise<number> {
|
|
164
|
+
assertDockerReady()
|
|
165
|
+
const image = ensureImage(installedPlaywrightVersion(), o.log)
|
|
166
|
+
const cliFile = realpathSync(fileURLToPath(new URL('../cli.ts', import.meta.url)))
|
|
167
|
+
const root = realpathSync(o.root)
|
|
168
|
+
const cli = locateCli(root, cliFile, dirname(dirname(cliFile)))
|
|
169
|
+
const getuid = process.getuid?.()
|
|
170
|
+
const linux = process.platform === 'linux'
|
|
171
|
+
const argv = dockerRunArgv({
|
|
172
|
+
image,
|
|
173
|
+
root,
|
|
174
|
+
cli,
|
|
175
|
+
recordedBase: o.hostBase,
|
|
176
|
+
inner: innerCaptureArgs({ ...o, base: linux ? o.base : rewriteBaseForDocker(o.base) }),
|
|
177
|
+
hostNetwork: linux,
|
|
178
|
+
user:
|
|
179
|
+
linux && getuid !== undefined
|
|
180
|
+
? { uid: getuid, gid: process.getgid?.() ?? getuid }
|
|
181
|
+
: undefined,
|
|
182
|
+
})
|
|
183
|
+
return await new Promise<number>((done, fail) => {
|
|
184
|
+
const child = spawn('docker', argv, { stdio: 'inherit' })
|
|
185
|
+
child.on('error', fail)
|
|
186
|
+
child.on('close', (code) => done(code ?? 1))
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Manifest path relative to the project root, since the container sees the root as /work. */
|
|
191
|
+
export function manifestInRoot(root: string, manifest: string): string {
|
|
192
|
+
const rel = relative(root, resolve(root, manifest))
|
|
193
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel))
|
|
194
|
+
throw new Error('--docker needs the manifest inside the project root.')
|
|
195
|
+
return rel.split(sep).join('/')
|
|
196
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Captured text lands in run.json, the HTML report and CI artifacts, so app-generated URLs keep
|
|
2
|
+
// only origin and path: query strings, fragments and userinfo often carry tokens or signatures.
|
|
3
|
+
|
|
4
|
+
/** One URL reduced to origin + path; schemes other than HTTP and WebSocket keep only the scheme. */
|
|
5
|
+
export function safeUrl(raw: string): string {
|
|
6
|
+
let url: URL
|
|
7
|
+
try {
|
|
8
|
+
url = new URL(raw)
|
|
9
|
+
} catch {
|
|
10
|
+
return '[unparseable url]'
|
|
11
|
+
}
|
|
12
|
+
return ['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol)
|
|
13
|
+
? url.origin + url.pathname
|
|
14
|
+
: `${url.protocol}[redacted]`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const URL_IN_TEXT = /\b(?:https?|wss?):\/\/[^\s"'<>`]+/g
|
|
18
|
+
const TRAILING = /[.,;:!?)\]]+$/
|
|
19
|
+
|
|
20
|
+
/** Every http(s) and ws(s) URL inside free text (console messages, error strings) passed through safeUrl. */
|
|
21
|
+
export function redactUrls(text: string): string {
|
|
22
|
+
return text.replace(URL_IN_TEXT, (match) => {
|
|
23
|
+
const tail = match.match(TRAILING)?.[0] ?? ''
|
|
24
|
+
return safeUrl(match.slice(0, match.length - tail.length)) + tail
|
|
25
|
+
})
|
|
26
|
+
}
|
package/src/lib/report.ts
CHANGED
|
@@ -30,6 +30,10 @@ export function buildReport(dir: string, run: RunRecord, review: ReviewFile): st
|
|
|
30
30
|
...(tile.warnings ?? []),
|
|
31
31
|
...(tile.consoleErrors ?? []).map((message) => `Console: ${message}`),
|
|
32
32
|
...(tile.pageErrors ?? []).map((message) => `Page error: ${message}`),
|
|
33
|
+
...(tile.requestErrors ?? []).map(
|
|
34
|
+
(error) =>
|
|
35
|
+
`Request: ${error.status !== undefined ? `HTTP ${error.status}` : error.failure} · ${error.resourceType} · +${error.elapsedMs} ms · ${error.url}`,
|
|
36
|
+
),
|
|
33
37
|
...(tile.brokenImages ?? []).map((source) => `Broken image: ${source}`),
|
|
34
38
|
...(tile.textClipped ?? []).map((item) => `Possible clipped text: ${item.selector}`),
|
|
35
39
|
...(tile.overflowX ? ['Horizontal overflow'] : []),
|
package/src/lib/routes.ts
CHANGED
|
@@ -170,6 +170,8 @@ export function draftManifest(
|
|
|
170
170
|
baseUrl,
|
|
171
171
|
viewports: [390, 1280],
|
|
172
172
|
themes: { light: 'light', dark: 'dark', method: 'localStorage', key: 'theme' },
|
|
173
|
+
// Motion and unsettled pages are the main source of false diffs; start stable.
|
|
174
|
+
capture: { animations: 'disabled', stabilityAttempts: 3 },
|
|
173
175
|
initScripts: [],
|
|
174
176
|
routes,
|
|
175
177
|
}
|
package/src/lib/runs.ts
CHANGED
|
@@ -105,9 +105,14 @@ export function setLatest(root: string, runId: string): void {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export const loadRun = (dir: string): RunRecord => {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
const path = join(dir, 'run.json')
|
|
109
|
+
const r = readStateJson<RunRecord>(path)
|
|
110
|
+
if (r === null && !existsSync(path)) throw new Error(`Missing run.json in ${dir}`)
|
|
111
|
+
// Callers index r.tiles directly, so a parseable file with the wrong shape is corruption too.
|
|
112
|
+
if (!isPlainObject(r) || (r.tiles !== undefined && !Array.isArray(r.tiles)))
|
|
113
|
+
throw new Error(`Invalid ${path}. Preserve the file and repair or remove it, then retry.`)
|
|
114
|
+
r.tiles ??= []
|
|
115
|
+
for (const tile of r.tiles) {
|
|
111
116
|
// Tile files are joined onto run and baseline dirs; only plain capture names are allowed.
|
|
112
117
|
if (typeof tile.file !== 'string' || !/^[A-Za-z0-9[\]._-]+\.png$/.test(tile.file))
|
|
113
118
|
throw new Error(`Invalid tile file in ${join(dir, 'run.json')}: ${String(tile.file)}`)
|
|
@@ -160,4 +165,5 @@ export const isFlagged = (t: Tile): boolean =>
|
|
|
160
165
|
t.brokenImages.length > 0 ||
|
|
161
166
|
t.consoleErrors.length > 0 ||
|
|
162
167
|
t.pageErrors.length > 0 ||
|
|
168
|
+
!!t.requestErrors?.length ||
|
|
163
169
|
!!t.redirectedTo
|
package/src/lib/server.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { readFileSync, realpathSync } from 'node:fs'
|
|
|
3
3
|
import { basename, join, resolve, sep } from 'node:path'
|
|
4
4
|
import { PNG } from 'pngjs'
|
|
5
5
|
import UI_PATH from '../ui/index.html' with { type: 'file' }
|
|
6
|
+
import UI_CSS from '../ui/review.css' with { type: 'file' }
|
|
7
|
+
import UI_JS from '../ui/review.js' with { type: 'file' }
|
|
6
8
|
import { buildComparisonAsync } from './comparison.ts'
|
|
7
9
|
import { listRuns, selectRun } from './history.ts'
|
|
8
10
|
import { buildReport } from './report.ts'
|
|
@@ -30,6 +32,18 @@ export function startReviewServer(initialRunDir: string, port: number) {
|
|
|
30
32
|
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
31
33
|
})
|
|
32
34
|
}
|
|
35
|
+
if (
|
|
36
|
+
req.method === 'GET' &&
|
|
37
|
+
['/assets/review.css', '/assets/review.js'].includes(url.pathname)
|
|
38
|
+
) {
|
|
39
|
+
const css = url.pathname.endsWith('.css')
|
|
40
|
+
return new Response(Bun.file(String(css ? UI_CSS : UI_JS)), {
|
|
41
|
+
headers: {
|
|
42
|
+
'content-type': css ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
|
|
43
|
+
'cache-control': 'no-cache',
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
}
|
|
33
47
|
if (req.method === 'GET' && url.pathname === '/api/report') {
|
|
34
48
|
try {
|
|
35
49
|
return new Response(buildReport(runDir, loadRun(runDir), loadReview(runDir)), {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const SUMMARY_MARKER = '<!-- design-spine -->'
|
|
2
|
+
export const SUMMARY_ROW_CAP = 50
|
|
3
|
+
|
|
4
|
+
export interface SummaryInput {
|
|
5
|
+
rows: { id: string; result: string; ratio: string }[]
|
|
6
|
+
threshold: number
|
|
7
|
+
policy: string
|
|
8
|
+
gateReasons: string[]
|
|
9
|
+
/** Number of failures the CLI counted; it decides pass or fail. */
|
|
10
|
+
failed: number
|
|
11
|
+
/** Where reviewers find the full report. */
|
|
12
|
+
reportArtifact?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RESULTS = [
|
|
16
|
+
'ok',
|
|
17
|
+
'CHANGED',
|
|
18
|
+
'new',
|
|
19
|
+
'size-changed',
|
|
20
|
+
'no-capture',
|
|
21
|
+
'modified-capture',
|
|
22
|
+
'modified-baseline',
|
|
23
|
+
'unreadable',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
/** Ids and reasons come from user manifests, so neutralise Markdown and HTML. */
|
|
27
|
+
export function escapeMarkdown(text: string): string {
|
|
28
|
+
return text
|
|
29
|
+
.replace(/[\r\n]+/g, ' ')
|
|
30
|
+
.replace(/[&\\|<>`]/g, (c) =>
|
|
31
|
+
c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : `\\${c}`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildSummary(input: SummaryInput): string {
|
|
36
|
+
const { rows, failed } = input
|
|
37
|
+
const counts = new Map<string, number>()
|
|
38
|
+
for (const r of rows) counts.set(r.result, (counts.get(r.result) ?? 0) + 1)
|
|
39
|
+
const kinds = [...RESULTS, ...[...counts.keys()].filter((k) => !RESULTS.includes(k))]
|
|
40
|
+
const parts = kinds
|
|
41
|
+
.filter((k) => (counts.get(k) ?? 0) > 0 || k === 'ok')
|
|
42
|
+
.map((k) => `${counts.get(k) ?? 0} ${escapeMarkdown(k)}`)
|
|
43
|
+
const lines = [
|
|
44
|
+
SUMMARY_MARKER,
|
|
45
|
+
`## Design Spine: ${failed ? 'failed' : 'passed'}`,
|
|
46
|
+
'',
|
|
47
|
+
`${rows.length} tiles (${parts.join(', ')}). Policy: ${escapeMarkdown(input.policy)}, threshold ${input.threshold}.`,
|
|
48
|
+
'',
|
|
49
|
+
]
|
|
50
|
+
const shown = rows.filter((r) => r.result !== 'ok')
|
|
51
|
+
if (shown.length) {
|
|
52
|
+
lines.push('| Tile | Result | Ratio |', '| --- | --- | --- |')
|
|
53
|
+
for (const r of shown.slice(0, SUMMARY_ROW_CAP))
|
|
54
|
+
lines.push(
|
|
55
|
+
`| ${escapeMarkdown(r.id)} | ${escapeMarkdown(r.result)} | ${escapeMarkdown(r.ratio)} |`,
|
|
56
|
+
)
|
|
57
|
+
if (shown.length > SUMMARY_ROW_CAP) lines.push('', `and ${shown.length - SUMMARY_ROW_CAP} more`)
|
|
58
|
+
lines.push('')
|
|
59
|
+
}
|
|
60
|
+
if (input.gateReasons.length) {
|
|
61
|
+
lines.push('Strict gate reasons:', '')
|
|
62
|
+
for (const reason of input.gateReasons) lines.push(`- ${escapeMarkdown(reason)}`)
|
|
63
|
+
lines.push('')
|
|
64
|
+
}
|
|
65
|
+
lines.push(
|
|
66
|
+
`Full report with screenshots: download the \`${input.reportArtifact ?? 'design-spine-report'}\` artifact from this run.`,
|
|
67
|
+
'',
|
|
68
|
+
)
|
|
69
|
+
return lines.join('\n')
|
|
70
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -66,6 +66,15 @@ export interface Tile {
|
|
|
66
66
|
status: number
|
|
67
67
|
consoleErrors: string[]
|
|
68
68
|
pageErrors: string[]
|
|
69
|
+
/** First 20 failed requests observed during capture; URL credentials/query/fragment removed. */
|
|
70
|
+
requestErrors?: {
|
|
71
|
+
url: string
|
|
72
|
+
resourceType: string
|
|
73
|
+
status?: number
|
|
74
|
+
failure?: string
|
|
75
|
+
/** Milliseconds from the start of navigation until the error was observed. */
|
|
76
|
+
elapsedMs: number
|
|
77
|
+
}[]
|
|
69
78
|
overflowX: boolean
|
|
70
79
|
blankRegions: { count: number; boxes: Box[] }
|
|
71
80
|
brokenImages: string[]
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
5
|
+
import { isFlagged, loadReview, loadRun, resolveRunDir, runIdOf } from './lib/runs.ts'
|
|
6
|
+
import type { ReviewFile, RunRecord } from './lib/types.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Read-only MCP server. It only calls loaders that read (resolveRunDir, loadRun,
|
|
10
|
+
* loadReview) and registers no tool that writes, so the process cannot change a
|
|
11
|
+
* review decision. Do not import writeJson, mergeReview or setLatest here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const STATUSES = ['approved', 'rejected', 'pending'] as const
|
|
15
|
+
type TileStatus = (typeof STATUSES)[number]
|
|
16
|
+
|
|
17
|
+
const statusOf = (review: ReviewFile, id: string): TileStatus => {
|
|
18
|
+
const d = review.tiles[id]?.decision
|
|
19
|
+
return d === 'approve' ? 'approved' : d === 'reject' ? 'rejected' : 'pending'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function latest(root: string): { dir: string; run: RunRecord; review: ReviewFile } {
|
|
23
|
+
const dir = resolveRunDir(root)
|
|
24
|
+
return { dir, run: loadRun(dir), review: loadReview(dir) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function spineStatus(root: string) {
|
|
28
|
+
const { dir, run, review } = latest(root)
|
|
29
|
+
const counts = { approved: 0, rejected: 0, pending: 0 }
|
|
30
|
+
for (const t of run.tiles) counts[statusOf(review, t.id)]++
|
|
31
|
+
return {
|
|
32
|
+
runId: run.id,
|
|
33
|
+
startedAt: run.startedAt,
|
|
34
|
+
finishedAt: run.finishedAt,
|
|
35
|
+
tiles: run.tiles.length,
|
|
36
|
+
...counts,
|
|
37
|
+
flagged: run.tiles.filter(isFlagged).length,
|
|
38
|
+
runDir: dir,
|
|
39
|
+
reviewCommand: `spine review --run ${runIdOf(dir)}`,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function spineListTiles(root: string, status?: TileStatus) {
|
|
44
|
+
const { dir, run, review } = latest(root)
|
|
45
|
+
return run.tiles
|
|
46
|
+
.map((t) => ({
|
|
47
|
+
id: t.id,
|
|
48
|
+
route: t.route,
|
|
49
|
+
state: t.state,
|
|
50
|
+
viewport: t.viewport,
|
|
51
|
+
theme: t.theme,
|
|
52
|
+
imagePath: join(dir, t.file),
|
|
53
|
+
imageHash: t.imageHash ?? null,
|
|
54
|
+
status: statusOf(review, t.id),
|
|
55
|
+
}))
|
|
56
|
+
.filter((row) => !status || row.status === status)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const TOOLS = [
|
|
60
|
+
{
|
|
61
|
+
name: 'spine_status',
|
|
62
|
+
description:
|
|
63
|
+
'Summary of the latest design-spine run: run id and times, tile count, approved/rejected/pending counts, flagged count, run directory and the command that opens the contact sheet. Read-only.',
|
|
64
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
65
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'spine_list_tiles',
|
|
69
|
+
description:
|
|
70
|
+
'List tiles of the latest run with route, state, viewport, theme, image path, image hash (null if not recorded) and review status. Optional status filter. Read-only.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: { status: { type: 'string', enum: [...STATUSES] } },
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
const ok = (data: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(data) }] })
|
|
81
|
+
const err = (message: string) => ({
|
|
82
|
+
isError: true,
|
|
83
|
+
content: [{ type: 'text' as const, text: message }],
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export function createSpineMcpServer(root: string): Server {
|
|
87
|
+
const server = new Server(
|
|
88
|
+
{ name: 'design-spine', version: '0.2.1' },
|
|
89
|
+
{ capabilities: { tools: {} } },
|
|
90
|
+
)
|
|
91
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
92
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
93
|
+
try {
|
|
94
|
+
const args = (req.params.arguments ?? {}) as Record<string, unknown>
|
|
95
|
+
switch (req.params.name) {
|
|
96
|
+
case 'spine_status':
|
|
97
|
+
return ok(spineStatus(root))
|
|
98
|
+
case 'spine_list_tiles': {
|
|
99
|
+
const status = args.status
|
|
100
|
+
if (status !== undefined && !STATUSES.includes(status as TileStatus))
|
|
101
|
+
return err(`status must be one of: ${STATUSES.join(', ')}`)
|
|
102
|
+
return ok(spineListTiles(root, status as TileStatus | undefined))
|
|
103
|
+
}
|
|
104
|
+
default:
|
|
105
|
+
return err(`Unknown tool: ${req.params.name}`)
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return err((e as Error).message)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
return server
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Serve over stdio. stdout carries the protocol, so nothing else may print to it. */
|
|
115
|
+
export async function runMcp(root: string): Promise<void> {
|
|
116
|
+
await createSpineMcpServer(root).connect(new StdioServerTransport())
|
|
117
|
+
}
|