scripts/ensure-playwright.mjs
A supporting file of the design-critique skill, shown as source.
#!/usr/bin/env node
/**
* ensure-playwright.mjs — resolve Playwright ON DEMAND, never vendored in the app.
*
* Resolution order:
* 1. a normally-resolvable `playwright` (globally / already on the path)
* 2. a cached copy under ~/.cache/kirocrew-design-critique (override: $DC_PW_DIR)
* 3. install it into that cache dir on first use, then load it
*
* Falls back to null so callers can use headless Chrome instead. Uses your
* installed Chrome via channel:'chrome', so no browser download is needed.
*
* import { getPlaywright } from './ensure-playwright.mjs'
* const pw = await getPlaywright() // { chromium, ... } or null
* const pw = await getPlaywright({ autoInstall: false }) // never install
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
const PW_VERSION = '1.58.2' // matches website/package.json; must be >= 1.48 for routeWebSocket (ssrf-guard.mjs refuses without it) and needs a modern Node (repo docs say >= 22)
const CACHE = process.env.DC_PW_DIR || join(homedir(), '.cache', 'kirocrew-design-critique')
const PW_ENTRY = join(CACHE, 'node_modules', 'playwright', 'index.js')
// ssrf-guard.mjs refuses to run without routeWebSocket (Playwright >= 1.48), so
// resolving an older build would only trade a silent fail-open for a permanent
// refusal. Gate every resolution path on the version actually found: too old
// (or unreadable) falls through to the pinned install instead of returning a
// build the guard must reject. A host that cached the old 1.47.2 pin therefore
// self-heals -- the install step below upgrades the cache in place.
const MIN_PW = [1, 48]
function pwVersionUsable(pkgJsonPath) {
try {
const v = JSON.parse(readFileSync(pkgJsonPath, 'utf8')).version || ''
const m = /^(\d+)\.(\d+)/.exec(v)
if (!m) return false
const maj = Number(m[1]), min = Number(m[2])
return maj > MIN_PW[0] || (maj === MIN_PW[0] && min >= MIN_PW[1])
} catch {
return false
}
}
// Playwright is CJS; a dynamic import may put the API on `.default`.
function normalize(mod) {
if (!mod) return null
if (mod.chromium) return mod
if (mod.default && mod.default.chromium) return mod.default
return null
}
export async function getPlaywright({ autoInstall = true } = {}) {
// 1. resolvable from the normal module paths (only when it can provide the
// routeWebSocket the SSRF guard requires)?
try {
const pkg = createRequire(import.meta.url).resolve('playwright/package.json')
if (pwVersionUsable(pkg)) { const m = normalize(await import('playwright')); if (m) return m }
} catch { /* not here */ }
// 2. present in the cache dir? An old cached pin (e.g. the former 1.47.2)
// fails the version gate and falls through, so the install below
// replaces it rather than leaving capture permanently refused.
if (existsSync(PW_ENTRY) && pwVersionUsable(join(CACHE, 'node_modules', 'playwright', 'package.json'))) {
try { const m = normalize(await import(pathToFileURL(PW_ENTRY).href)); if (m) return m } catch { /* fallthrough */ }
}
if (!autoInstall) return null
// 3. install into the cache dir on demand (one-time)
console.error(`ensure-playwright: installing playwright@${PW_VERSION} into ${CACHE} (one-time, needs network)…`)
try { mkdirSync(CACHE, { recursive: true }) } catch { /* ignore */ }
const r = spawnSync('npm', ['install', '--prefix', CACHE, `playwright@${PW_VERSION}`, '--no-audit', '--no-fund', '--silent'],
{ stdio: 'inherit', timeout: 240000 })
if (r.status !== 0 || !existsSync(PW_ENTRY)) {
console.error('ensure-playwright: install failed — falling back to headless Chrome.')
return null
}
// The npm package ships no browser binaries, so installing it alone leaves
// every launch rejecting on a machine with no Chrome of its own (a bare Linux
// box is the normal case). Download the Chromium build too. The env is
// inherited untouched so it lands in Playwright's own home-directory cache —
// the same place the launch side will look, and never inside the project.
const cli = join(CACHE, 'node_modules', 'playwright', 'cli.js')
if (existsSync(cli)) {
console.error('ensure-playwright: downloading the Chromium build (one-time)…')
const b = spawnSync(process.execPath, [cli, 'install', 'chromium'],
{ stdio: 'inherit', timeout: 600000 })
if (b.status !== 0) {
// Not fatal: a machine that already has Chrome installed still works via
// the channel fallback, so report and continue rather than refusing.
console.error('ensure-playwright: Chromium download failed — will try a system browser.')
}
}
try { return normalize(await import(pathToFileURL(PW_ENTRY).href)) } catch { return null }
}