diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..356e1f2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Git for Windows turns on core.autocrlf system-wide; without this rule a clone there checks out +# CRLF line endings, which the fixtures and the tests that read them do not expect. +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbb7e8d..9fdb2c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,11 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: [ubuntu-latest, windows-latest] node: ['22.23.4', '24.22.1'] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index a3813e7..d6cd428 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,8 @@ a baseline set shipped in `vercel.json` or `_headers`. More in [docs/security.md - git, and a GitHub account with `gh` logged in if you want the repo and PR created for you. - A Vercel or Cloudflare account for staging and production. Optional for the conversion itself. - Playwright's Chromium (`npx playwright install chromium`), for screenshots and visual review. -- macOS or Linux. Windows is not supported in this version. +- macOS, Linux or Windows. On Windows the agent runs the skills' shell commands in Git Bash; see + the [FAQ](docs/faq.md#can-i-run-it-on-windows). - A coding agent that can run shell commands. If your agent has its own browser or can run subagents, the skills use them; without them every step still runs. diff --git a/docs/faq.md b/docs/faq.md index 89e910b..0685333 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -117,4 +117,8 @@ domains and the old host are changed by you, following the runbook. ## Can I run it on Windows? -Not in this version. Use macOS or Linux. WSL may work but is not tested. +Yes. CI runs the whole suite on Windows, and the site's npm scripts run in any shell. The commands +in the skills' references are POSIX shell, such as `PUBLIC_INDEXABLE=true npm run build`, so on +Windows the agent runs them in Git Bash, which comes with Git for Windows. Set `MSYS_NO_PATHCONV=1` +there: without it Git Bash rewrites an argument such as `/thank-you/` into a Windows path before +Node sees it. diff --git a/scripts/sync-launch-skills.mjs b/scripts/sync-launch-skills.mjs index 0900c83..80b83c9 100644 --- a/scripts/sync-launch-skills.mjs +++ b/scripts/sync-launch-skills.mjs @@ -80,7 +80,8 @@ function assertClean() { function checkout(repo, commit, dir) { git(dir, ['init', '-q']); git(dir, ['fetch', '-q', '--depth', '1', repo, commit]); - git(dir, ['-c', 'advice.detachedHead=false', 'checkout', '-q', 'FETCH_HEAD']); + // core.autocrlf=false keeps the line endings upstream committed (Git for Windows turns it on). + git(dir, ['-c', 'advice.detachedHead=false', '-c', 'core.autocrlf=false', 'checkout', '-q', 'FETCH_HEAD']); const head = git(dir, ['rev-parse', 'HEAD']); if (head !== commit) throw new UsageError(`Fetched ${head}, expected ${commit}`); } diff --git a/skills/capture-site/scripts/assets.mjs b/skills/capture-site/scripts/assets.mjs index d2127da..943f705 100644 --- a/skills/capture-site/scripts/assets.mjs +++ b/skills/capture-site/scripts/assets.mjs @@ -303,7 +303,8 @@ async function main() { } const file = known ?? join(out, `${stem}${extensionFor(url, res.headers['content-type'])}`); if (file !== download) renameSync(download, file); - entries.push({ kind, url, file: `assets/${file.slice(out.length + 1)}`, status: 200, note: null }); + const relativeFile = file.slice(out.length + 1); + entries.push({ kind, url, file: `assets/${process.platform === 'win32' ? relativeFile.replaceAll('\\', '/') : relativeFile}`, status: 200, note: null }); }; const absent = (kind, note) => entries.push({ kind, url: null, file: null, status: null, note }); diff --git a/skills/convert-site/scripts/init-site.mjs b/skills/convert-site/scripts/init-site.mjs index df49f89..cc07a34 100644 --- a/skills/convert-site/scripts/init-site.mjs +++ b/skills/convert-site/scripts/init-site.mjs @@ -1,11 +1,11 @@ #!/usr/bin/env node // Scaffold a new Astro site repository from the kit's templates and copy in the kit's scripts. // Exit 0 success, 2 usage or environment error (including a non-empty target directory). -import { execFileSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from 'node:fs'; -import { dirname, join, relative, resolve } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { assertNodeVersion } from './lib/node-version.mjs'; @@ -121,9 +121,15 @@ function existingEntries(dir) { return readdirSync(dir); } +// npm is npm.cmd on Windows, which Node starts only through a shell. These arguments are fixed +// tokens, so on Windows they are joined into one command line. +function npm(args, options) { + return process.platform === 'win32' ? execSync(['npm', ...args].join(' '), options) : execFileSync('npm', args, options); +} + function latestAstro7() { try { - const out = execFileSync('npm', ['view', 'astro@7', 'version', '--json'], { + const out = npm(['view', 'astro@7', 'version', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000, @@ -150,7 +156,7 @@ function substitute(text, values) { function copyTemplates(templateDir, dir, values, skip) { for (const source of walk(templateDir)) { - const rel = relative(templateDir, source); + const rel = relative(templateDir, source).split(sep).join('/'); if (skip.includes(rel)) continue; const target = join(dir, rel); const text = substitute(readFileSync(source, 'utf8'), values); @@ -256,7 +262,7 @@ function commitAndBranch(dir) { function npmInstall(dir) { try { - execFileSync('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'inherit' }); + npm(['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'inherit' }); } catch { throw new UsageError( 'npm install failed. The scaffold is written but not committed; do not run init-site again.\n' + diff --git a/skills/convert-site/scripts/preflight.mjs b/skills/convert-site/scripts/preflight.mjs index 43ab6ed..64b7638 100644 --- a/skills/convert-site/scripts/preflight.mjs +++ b/skills/convert-site/scripts/preflight.mjs @@ -2,7 +2,7 @@ // Preflight: what this machine has (Node, git, gh, vercel, wrangler, Playwright chromium) // and a rough read of how experienced the person running the conversion is. // Login probes report a boolean only: their output is inspected, never printed or stored. -import { spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { accessSync, constants, existsSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { delimiter, dirname, join } from 'node:path'; @@ -78,13 +78,16 @@ function parseArgs(argv) { function findBinary(name, pathEnv) { for (const dir of pathEnv.split(delimiter)) { if (!dir) continue; - const file = join(dir, name); - try { - if (!statSync(file).isFile()) continue; - accessSync(file, constants.X_OK); - return file; - } catch { - // not here + const extensions = process.platform === 'win32' ? ['.exe', '.com', '.cmd', '.bat'] : ['']; + for (const extension of extensions) { + const file = join(dir, name + extension); + try { + if (!statSync(file).isFile()) continue; + accessSync(file, constants.X_OK); + return file; + } catch { + // not here + } } } return null; @@ -104,11 +107,20 @@ function probe(file, args, pathEnv) { }; let child; try { - child = spawn(file, args, { + const options = { env: { ...process.env, PATH: pathEnv, NO_COLOR: '1', GH_PROMPT_DISABLED: '1', CI: '1' }, stdio: ['ignore', 'pipe', 'pipe'], - detached: true, - }); + detached: process.platform !== 'win32', + windowsHide: true, + }; + if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(file)) { + // A .cmd or .bat file starts only through a shell. Probe arguments are flags, CLI names or + // team slugs, never shell syntax, so they are checked and joined into one command line. + if (/["%]/.test(file) || args.some((arg) => !/^[\w@./:=+-]+$/.test(arg))) throw new Error('unsafe CLI argument'); + child = spawn(`"${file}" ${args.join(' ')}`, { ...options, shell: true }); + } else { + child = spawn(file, args, options); + } } catch { resolve({ code: null, output: '' }); return; @@ -122,7 +134,13 @@ function probe(file, args, pathEnv) { child.on('close', (code) => finish(code)); const timer = setTimeout(() => { try { - process.kill(-child.pid, 'SIGKILL'); + if (process.platform === 'win32') { + execFileSync(join(process.env.SystemRoot, 'System32', 'taskkill.exe'), ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', windowsHide: true, timeout: 5000, + }); + } else { + process.kill(-child.pid, 'SIGKILL'); + } } catch { child.kill('SIGKILL'); } diff --git a/skills/convert-site/scripts/review-serve.mjs b/skills/convert-site/scripts/review-serve.mjs index 5e24ec6..556166e 100644 --- a/skills/convert-site/scripts/review-serve.mjs +++ b/skills/convert-site/scripts/review-serve.mjs @@ -292,7 +292,7 @@ async function startBackground({ dir, port }) { const log = openSync(logPath, 'w'); const self = fileURLToPath(import.meta.url); const child = spawn(process.execPath, [self, '--dir', dir, '--port', String(port)], { - detached: true, stdio: ['ignore', log, log], + detached: true, stdio: ['ignore', log, log], windowsHide: true, }); let exited = null; child.once('exit', (code) => { exited = code ?? 1; }); @@ -317,6 +317,13 @@ async function startBackground({ dir, port }) { function commandOf(pid) { if (!Number.isInteger(pid) || pid <= 0) return null; try { + if (process.platform === 'win32') { + // Windows has no ps; WMI has the command line. + return execFileSync(join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [ + '-NoProfile', '-NonInteractive', '-Command', + `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`, + ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 }).trim() || null; + } return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null; } catch { return null; diff --git a/skills/convert-site/templates/site/package.json b/skills/convert-site/templates/site/package.json index 269f9c7..3972bb3 100644 --- a/skills/convert-site/templates/site/package.json +++ b/skills/convert-site/templates/site/package.json @@ -16,8 +16,8 @@ "assets:copy": "node scripts/copy-assets.mjs --capture capture --site .", "links:exceptions": "node scripts/link-exceptions.mjs --baseline baseline --capture capture --out exceptions.json", "verify": "node scripts/verify/run-all.mjs", - "verify:proposals": "PROPOSALS=on node scripts/verify/run-all.mjs", - "verify:release": "PUBLIC_INDEXABLE=true node scripts/verify/run-all.mjs", + "verify:proposals": "node scripts/verify/run-all.mjs --with-proposals", + "verify:release": "node scripts/verify/run-all.mjs --release", "redirects:build": "node scripts/build-redirects.mjs --csv redirects.csv --host {{HOSTING}} --out {{REDIRECTS_OUT}} --trailing-slash {{TRAILING}} --routes baseline/routes.json", "verify:live": "node scripts/verify-live.mjs", "review:shoot": "node scripts/review/shoot.mjs", diff --git a/skills/parity-gates/scripts/verify/run-all.mjs b/skills/parity-gates/scripts/verify/run-all.mjs index 0a19f9f..f65852c 100644 --- a/skills/parity-gates/scripts/verify/run-all.mjs +++ b/skills/parity-gates/scripts/verify/run-all.mjs @@ -17,6 +17,8 @@ Runs every gate in ${HERE} in alphabetical order, passing each one the options i stops at the first gate that fails. Every option of every gate is accepted here; see each gate's --help (node .mjs --help). + --release run gates with PUBLIC_INDEXABLE=true + --with-proposals run gates with PROPOSALS=on --help show this help Exit codes: 0 every gate passed, otherwise the exit code of the first gate that did not pass @@ -58,7 +60,18 @@ export function splitArgs(argv, known) { function main() { assertNodeVersion(); - const argv = process.argv.slice(2); + const env = { ...process.env }; + const argv = process.argv.slice(2).filter((arg) => { + if (arg === '--release') { + env.PUBLIC_INDEXABLE = 'true'; + return false; + } + if (arg === '--with-proposals') { + env.PROPOSALS = 'on'; + return false; + } + return true; + }); if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(USAGE); return; @@ -69,7 +82,14 @@ function main() { process.stderr.write(`run-all: ${file} --help exited ${help.status}\n${help.stderr ?? ''}`); process.exit(2); } - return { file, options: optionsFromHelp(help.stdout) }; + const options = optionsFromHelp(help.stdout); + for (const flag of ['release', 'with-proposals']) { + if (options.has(flag)) { + process.stderr.write(`run-all: ${file} lists reserved option --${flag}\n`); + process.exit(2); + } + } + return { file, options }; }); const known = new Map(gates.flatMap((g) => [...g.options])); let parsed; @@ -81,7 +101,7 @@ function main() { } for (const gate of gates) { const args = parsed.filter((p) => gate.options.has(p.flag)).flatMap((p) => p.tokens); - const result = spawnSync(process.execPath, [join(HERE, gate.file), ...args], { stdio: 'inherit' }); + const result = spawnSync(process.execPath, [join(HERE, gate.file), ...args], { stdio: 'inherit', env }); if (result.status !== 0) process.exit(result.status ?? 2); } process.stdout.write(`All ${gates.length} gates passed\n`); diff --git a/test/capture/fingerprint.test.mjs b/test/capture/fingerprint.test.mjs index 1a9ba24..38036aa 100644 --- a/test/capture/fingerprint.test.mjs +++ b/test/capture/fingerprint.test.mjs @@ -274,8 +274,9 @@ test('fingerprint stops after the home page when it is blocked or unreachable', test('fetchPaced waits delayMs between requests to the same host', async () => { const stub = await startStub({ '/': { body: 'x' } }); try { - await fetchPaced(`${stub.origin}/`, { delayMs: 0, trusted: [stub.origin] }); + // The delay runs from the first request's start, so the clock starts before that request. const started = Date.now(); + await fetchPaced(`${stub.origin}/`, { delayMs: 0, trusted: [stub.origin] }); await fetchPaced(`${stub.origin}/`, { delayMs: 150, trusted: [stub.origin] }); assert.ok(Date.now() - started >= 140); } finally { diff --git a/test/convert/copy-assets.test.mjs b/test/convert/copy-assets.test.mjs index 56dbe2d..d81438b 100644 --- a/test/convert/copy-assets.test.mjs +++ b/test/convert/copy-assets.test.mjs @@ -124,7 +124,7 @@ test('a listed file missing from the capture exits 1 and names it; the rest is s rmSync(join(dir, 'capture', 'js', reveal.file)); const result = await copy(dir); assert.equal(result.code, 1); - assert.match(result.stderr, new RegExp(`missing: .*js/${reveal.file.replace('.', '\\.')}`)); + assert.match(process.platform === 'win32' ? result.stderr.replaceAll('\\', '/') : result.stderr, new RegExp(`missing: .*js/${reveal.file.replace('.', '\\.')}`)); assert.ok(existsSync(join(dir, 'public', 'wp-content/themes/fixture/js/view.js'))); }); diff --git a/test/convert/init-site.test.mjs b/test/convert/init-site.test.mjs index be7f8b9..0bac18a 100644 --- a/test/convert/init-site.test.mjs +++ b/test/convert/init-site.test.mjs @@ -5,7 +5,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, cpSync, existsSync, statSync, symlinkSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, dirname, relative } from 'node:path'; +import { join, dirname, relative, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); @@ -94,7 +94,7 @@ function walk(dir, base = dir) { return readdirSync(dir).flatMap((name) => { if (name === '.git') return []; const full = join(dir, name); - return statSync(full).isDirectory() ? walk(full, base) : [relative(base, full)]; + return statSync(full).isDirectory() ? walk(full, base) : [relative(base, full).split(sep).join('/')]; }); } @@ -184,8 +184,8 @@ test('init scaffolds templates, copies scripts, commits on main and switches to const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); assert.equal(pkg.name, 'fixture-roofing-co'); assert.equal(pkg.scripts.verify, 'node scripts/verify/run-all.mjs'); - assert.equal(pkg.scripts['verify:proposals'], 'PROPOSALS=on node scripts/verify/run-all.mjs'); - assert.equal(pkg.scripts['verify:release'], 'PUBLIC_INDEXABLE=true node scripts/verify/run-all.mjs'); + assert.equal(pkg.scripts['verify:proposals'], 'node scripts/verify/run-all.mjs --with-proposals'); + assert.equal(pkg.scripts['verify:release'], 'node scripts/verify/run-all.mjs --release'); assert.equal(pkg.scripts.baseline, 'node scripts/build-baseline.mjs --capture capture --out baseline'); assert.equal(pkg.scripts['redirects:build'], 'node scripts/build-redirects.mjs --csv redirects.csv --host vercel --out vercel.json --trailing-slash always --routes baseline/routes.json'); @@ -241,7 +241,7 @@ test('the real copied scripts answer --help from inside the scaffolded site', (t const result = run(baseArgs(dir, realKit(t))); assert.equal(result.status, 0, result.stdout + result.stderr); // Stands in for npm install: the site resolves the pinned packages from its own node_modules. - symlinkSync(join(ROOT, 'node_modules'), join(dir, 'node_modules'), 'dir'); + symlinkSync(join(ROOT, 'node_modules'), join(dir, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir'); const scripts = ['scripts/review/shoot.mjs', 'scripts/state.mjs', 'scripts/media-upload.mjs', 'scripts/review-serve.mjs', 'scripts/verify-live.mjs', 'scripts/copy-assets.mjs', 'scripts/link-exceptions.mjs'] .filter((script) => script !== 'scripts/verify-live.mjs' || existsSync(join(dir, script))); diff --git a/test/convert/preflight.test.mjs b/test/convert/preflight.test.mjs index e261820..c853a9a 100644 --- a/test/convert/preflight.test.mjs +++ b/test/convert/preflight.test.mjs @@ -16,7 +16,14 @@ const SLEEP_MARK = 7357; // True while a process matching pattern is alive; retries briefly so a just-killed one can be reaped. function strayProcess(pattern) { for (let attempt = 0; attempt < 10; attempt += 1) { - if (spawnSync('pgrep', ['-f', pattern]).status !== 0) return false; + if (process.platform === 'win32') { + const result = spawnSync(join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), [ + '-NoProfile', '-NonInteractive', '-Command', + `@(Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" | Where-Object { $_.CommandLine -like '*${pattern}*' }).Count`, + ], { encoding: 'utf8', windowsHide: true }); + assert.equal(result.status, 0, result.stderr); + if (Number(result.stdout.trim()) === 0) return false; + } else if (spawnSync('pgrep', ['-f', pattern]).status !== 0) return false; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); } return true; @@ -28,8 +35,19 @@ function tempDir(t, prefix) { return dir; } -function fakeBin(dir, name, script) { +function fakeBin(dir, name, script, windowsScript) { const file = join(dir, name); + if (process.platform === 'win32') { + assert.equal(typeof windowsScript, 'string'); + writeFileSync(`${file}.cjs`, `const args = process.argv.slice(2); +const reply = (text, code = 0) => { require('node:fs').writeSync(1, text + '\\n'); process.exit(code); }; +const log = (file, text = args.join(' ')) => require('node:fs').appendFileSync(file, text + '\\n'); +const sleep = () => require('node:child_process').spawn(process.execPath, + ['-e', 'setTimeout(() => {}, ${SLEEP_MARK * 1000})', 'sleep ${SLEEP_MARK}'], { stdio: 'inherit' }); +${windowsScript}\n`); + writeFileSync(`${file}.cmd`, `@echo off\r\n"${process.execPath}" "%~dp0${name}.cjs" %*\r\n`); + return; + } writeFileSync(file, `#!/bin/sh\n${script}\n`); chmodSync(file, 0o755); } @@ -40,7 +58,10 @@ function fakeGh(dir) { 'if [ "$1" = "--version" ]; then echo "gh version 2.70.0 (2026-01-01)"; exit 0; fi', `if [ "$1" = "auth" ]; then echo "Logged in to github.com account test (${SECRET})"; exit 0; fi`, 'exit 1', - ].join('\n')); + ].join('\n'), ` +if (args[0] === '--version') reply('gh version 2.70.0 (2026-01-01)'); +if (args[0] === 'auth') reply('Logged in to github.com account test (${SECRET})'); +process.exit(1);`); } function run(args, env = {}) { @@ -101,11 +122,15 @@ test('a CLI that says it is not authenticated is not logged in', (t) => { fakeBin(bin, 'wrangler', [ 'if [ "$1" = "--version" ]; then echo "4.20.1"; exit 0; fi', 'echo "You are not authenticated. Please run wrangler login."; exit 0', - ].join('\n')); + ].join('\n'), ` +if (args[0] === '--version') reply('4.20.1'); +reply('You are not authenticated. Please run wrangler login.');`); fakeBin(bin, 'vercel', [ 'if [ "$1" = "--version" ]; then echo "Vercel CLI 44.2.0"; exit 0; fi', 'echo "Error: not logged in" >&2; exit 1', - ].join('\n')); + ].join('\n'), ` +if (args[0] === '--version') reply('Vercel CLI 44.2.0'); +console.error('Error: not logged in'); process.exit(1);`); const report = runJson(['--home', home, '--path-env', bin]); assert.deepEqual(report.tools.wrangler, { present: true, version: '4.20.1', loggedIn: false, via: 'path' }); assert.deepEqual(report.tools.vercel, { present: true, version: '44.2.0', loggedIn: false, via: 'path', scope: null }); @@ -120,7 +145,12 @@ function fakeNpx(dir, log) { 'if [ "$2" = "vercel" ] && [ "$3" = "--version" ]; then echo "Vercel CLI 50.1.0"; exit 0; fi', 'if [ "$2" = "vercel" ] && [ "$3" = "whoami" ]; then echo "fixture-user"; exit 0; fi', 'echo "npm error could not determine executable to run" >&2; exit 1', - ].join('\n')); + ].join('\n'), ` +log(${JSON.stringify(log)}); +if (args[0] !== '--no-install') reply('would install', 9); +if (args[1] === 'vercel' && args[2] === '--version') reply('Vercel CLI 50.1.0'); +if (args[1] === 'vercel' && args[2] === 'whoami') reply('fixture-user'); +console.error('npm error could not determine executable to run'); process.exit(1);`); } test('vercel and wrangler are found through npx --no-install, then on PATH', (t) => { @@ -131,7 +161,9 @@ test('vercel and wrangler are found through npx --no-install, then on PATH', (t) fakeBin(bin, 'wrangler', [ 'if [ "$1" = "--version" ]; then echo "4.20.1"; exit 0; fi', 'echo "you@example.com"; exit 0', - ].join('\n')); + ].join('\n'), ` +if (args[0] === '--version') reply('4.20.1'); +reply('you@example.com');`); const report = runJson(['--home', home, '--path-env', bin]); assert.deepEqual(report.tools.vercel, { present: true, version: '50.1.0', loggedIn: true, via: 'npx', scope: null }); assert.deepEqual(report.tools.wrangler, { present: true, version: '4.20.1', loggedIn: true, via: 'path' }); @@ -160,7 +192,17 @@ function fakeTeamsNpx(dir, log, { current = false, teams = 'ok' } = {}) { 'fi', `if [ "$2" = "vercel" ] && [ "$3" = "api" ]; then echo '{"status":"action_required","reason":"missing_scope"}'; exit 1; fi`, 'echo "npm error could not determine executable to run" >&2; exit 1', - ].join('\n')); + ].join('\n'), ` +log(${JSON.stringify(log)}); +if (args[1] !== 'vercel') reply('npm error could not determine executable to run', 1); +if (args[2] === '--version') reply('Vercel CLI 65.1.0'); +if (args[2] === 'whoami') reply('fixture-user'); +if (args[2] === 'teams' && ${JSON.stringify(teams)} !== 'ok') reply('Error: offline', 1); +if (args[2] === 'teams' && args[3] === 'ls' && args[4] === '--json') reply(${JSON.stringify(list)}); +if (args[2] === 'api' && args[3] === '/v2/user' && args[4] === '--scope') + reply('{"user":{"id":"u1","username":"fixture-user","email":"user@example.com","defaultTeamId":"team_personal"}}'); +if (args[2] === 'api') reply('{"status":"action_required","reason":"missing_scope"}', 1); +process.exit(1);`); } test('vercel.scope is the default team slug: from teams ls when one is current, else the user\'s defaultTeamId', (t) => { @@ -207,7 +249,15 @@ function fakeLoggedOutNpx(dir, log, exitCode) { ` exit ${exitCode}`, 'fi', 'echo "npm error could not determine executable to run" >&2; exit 1', - ].join('\n')); + ].join('\n'), ` +log(${JSON.stringify(log)}, args.join(' ') + ' HOME=' + (process.env.HOME ?? '')); +if (args[1] === 'vercel' && args[2] === '--version') reply('Vercel CLI 65.1.0'); +if (args[1] === 'vercel' && args[2] === 'whoami') { + const answer = require('node:fs').readFileSync(0, 'utf8'); + if (answer) console.log('read stdin: ' + answer.trim()); + reply('> Logged out.', ${exitCode}); +} +console.error('npm error could not determine executable to run'); process.exit(1);`); } test('a logged-out vercel (exit 1, "> Logged out.") is present and not logged in, probed with the real HOME', (t) => { @@ -235,7 +285,8 @@ test('a hanging login probe times out instead of blocking', { timeout: 30000 }, const home = tempDir(t, 'preflight-home-'); // Not exec: the sleep is a grandchild holding the output pipe, so the whole group must die. // The odd duration lets pgrep find exactly this sleep afterwards. - fakeBin(bin, 'vercel', `if [ "$1" = "--version" ]; then echo "44.2.0"; exit 0; fi\n/bin/sleep ${SLEEP_MARK}\nexit 0`); + fakeBin(bin, 'vercel', `if [ "$1" = "--version" ]; then echo "44.2.0"; exit 0; fi\n/bin/sleep ${SLEEP_MARK}\nexit 0`, + `if (args[0] === '--version') reply('44.2.0'); sleep();`); const started = Date.now(); const report = runJson(['--home', home, '--path-env', bin], { PREFLIGHT_PROBE_TIMEOUT_MS: '1000' }); const elapsed = Date.now() - started; @@ -249,7 +300,8 @@ test('a hanging login probe times out instead of blocking', { timeout: 30000 }, test('a login probe with no answer is reported as timed out, not logged out, with the command to run', { timeout: 30000 }, (t) => { const bin = tempDir(t, 'preflight-bin-'); const home = tempDir(t, 'preflight-home-'); - fakeBin(bin, 'vercel', `if [ "$1" = "--version" ]; then echo "44.2.0"; exit 0; fi\n/bin/sleep ${SLEEP_MARK}\nexit 0`); + fakeBin(bin, 'vercel', `if [ "$1" = "--version" ]; then echo "44.2.0"; exit 0; fi\n/bin/sleep ${SLEEP_MARK}\nexit 0`, + `if (args[0] === '--version') reply('44.2.0'); sleep();`); const env = { PREFLIGHT_PROBE_TIMEOUT_MS: '1000' }; const report = runJson(['--home', home, '--path-env', bin], env); assert.deepEqual(report.tools.vercel, { present: true, version: '44.2.0', loggedIn: false, timedOut: true, via: 'path', scope: null }); @@ -264,7 +316,8 @@ test('a login probe with no answer is reported as timed out, not logged out, wit test('a cold npx that does not answer in time is "unknown", never "missing"', { timeout: 30000 }, (t) => { const bin = tempDir(t, 'preflight-bin-'); const home = tempDir(t, 'preflight-home-'); - fakeBin(bin, 'npx', `if [ "$2" = "vercel" ]; then /bin/sleep ${SLEEP_MARK}; fi\nexit 1`); + fakeBin(bin, 'npx', `if [ "$2" = "vercel" ]; then /bin/sleep ${SLEEP_MARK}; fi\nexit 1`, + `if (args[1] === 'vercel') sleep(); else process.exit(1);`); const env = { PREFLIGHT_PROBE_TIMEOUT_MS: '1000' }; assert.deepEqual(runJson(['--home', home, '--path-env', bin], env).tools.vercel, { present: false, version: null, loggedIn: false, timedOut: true, via: null, scope: null }); const text = run(['--home', home, '--path-env', bin], env); @@ -276,7 +329,8 @@ test('a cold npx that does not answer in time is "unknown", never "missing"', { test('a CLI found on PATH (gh) is named without npx in the no-answer line', { timeout: 30000 }, (t) => { const bin = tempDir(t, 'preflight-bin-'); const home = tempDir(t, 'preflight-home-'); - fakeBin(bin, 'gh', `if [ "$1" = "--version" ]; then echo "gh version 2.70.0"; exit 0; fi\n/bin/sleep ${SLEEP_MARK}\nexit 0`); + fakeBin(bin, 'gh', `if [ "$1" = "--version" ]; then echo "gh version 2.70.0"; exit 0; fi\n/bin/sleep ${SLEEP_MARK}\nexit 0`, + `if (args[0] === '--version') reply('gh version 2.70.0'); sleep();`); const text = run(['--home', home, '--path-env', bin], { PREFLIGHT_PROBE_TIMEOUT_MS: '1000' }); assert.match(text.stdout, /^gh: no answer in 1 s; run `gh auth status` yourself$/m); assert.equal(strayProcess(`sleep ${SLEEP_MARK}`), false); diff --git a/test/convert/state.test.mjs b/test/convert/state.test.mjs index 192d7da..6cd55ea 100644 --- a/test/convert/state.test.mjs +++ b/test/convert/state.test.mjs @@ -140,14 +140,21 @@ test('a state file holding null or an array exits 2 without a crash', (t) => { test('an unwritable state directory exits 2 with a message, not a stack trace', (t) => { const dir = site(t); mkdirSync(join(dir, '.conversion')); - chmodSync(join(dir, '.conversion'), 0o500); + const account = `${process.env.USERDOMAIN}\\${process.env.USERNAME}`; + const permissions = (args) => { + const result = spawnSync('icacls', [join(dir, '.conversion'), ...args], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stdout + result.stderr); + }; + if (process.platform === 'win32') permissions(['/deny', `${account}:(W)`]); + else chmodSync(join(dir, '.conversion'), 0o500); let result; try { result = state(dir, 'set', 'hosting', '"vercel"'); } finally { - chmodSync(join(dir, '.conversion'), 0o700); + if (process.platform === 'win32') permissions(['/remove:d', account]); + else chmodSync(join(dir, '.conversion'), 0o700); } assert.equal(result.status, 2); - assert.match(result.stderr, /^state: .*EACCES/); + assert.match(result.stderr, process.platform === 'win32' ? /^state: .*EPERM/ : /^state: .*EACCES/); assert.doesNotMatch(result.stderr, /\n\s+at /); }); diff --git a/test/e2e/lib/run.mjs b/test/e2e/lib/run.mjs index f832c68..3f6471f 100644 --- a/test/e2e/lib/run.mjs +++ b/test/e2e/lib/run.mjs @@ -3,7 +3,7 @@ // commands from SITE and returning what its proof needs. The test plays the agent; where a phase // needs an account (a GitHub repo, a Vercel deploy) the stand-ins are named at the function. // Scripts run as child processes so the in-process fixture keeps answering. -import { execFile } from 'node:child_process'; +import { exec, execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createServer } from 'node:http'; import { createServer as createNetServer } from 'node:net'; @@ -48,12 +48,22 @@ function baseEnv() { return env; } -// Resolves { code, stdout, stderr, command } whatever the exit code. +// Resolves { code, stdout, stderr, command } whatever the exit code. On Windows npm and npx are +// .cmd files, which start only through a shell, so their arguments (script names, flags, local +// paths and URLs) are checked and joined into one command line. export function sh(command, args, { cwd, env = {}, timeout = 300_000 } = {}) { + const options = { cwd, env: { ...baseEnv(), ...env }, timeout, maxBuffer: 64 * 1024 * 1024 }; + const viaShell = process.platform === 'win32' && (command === 'npm' || command === 'npx'); + if (viaShell) { + const unsafe = args.find((arg) => !/^[\w@./:=,+-]+$/.test(arg)); + if (unsafe !== undefined) throw new Error(`sh: ${command} argument ${JSON.stringify(unsafe)} needs quoting on Windows`); + } return new Promise((done) => { - execFile(command, args, { cwd, env: { ...baseEnv(), ...env }, timeout, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { + const finish = (error, stdout, stderr) => { done({ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout, stderr, command: [command, ...args].join(' ') }); - }); + }; + if (viaShell) exec([command, ...args].join(' '), options, finish); + else execFile(command, args, options, finish); }); } @@ -274,7 +284,7 @@ export async function scaffold({ site, live, trailingSlash }) { rmSync(join(site, 'node_modules'), { recursive: true, force: true }); } } - symlinkSync(cache, join(site, 'node_modules'), 'dir'); + symlinkSync(cache, join(site, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir'); const decisions = state(site).decisions; const table = ['| Id | Date | Topic | Choice | Default | Reason | Cost if wrong |', '| --- | --- | --- | --- | --- | --- | --- |', diff --git a/test/gates/build-baseline.test.mjs b/test/gates/build-baseline.test.mjs index d8edd18..c580f7f 100644 --- a/test/gates/build-baseline.test.mjs +++ b/test/gates/build-baseline.test.mjs @@ -366,7 +366,7 @@ test('a capture without feeds or scripts warns and still builds; without wp-rest for (const p of ['feeds', 'scripts.json', 'wp-rest']) rmSync(join(copy, p), { recursive: true }); const result = await build(['--capture', copy, '--out', out]); assert.equal(result.code, 0, result.stderr); - const warnings = result.stderr.trim().split('\n'); + const warnings = (process.platform === 'win32' ? result.stderr.replaceAll('\\', '/') : result.stderr).trim().split('\n'); assert.equal(warnings.length, 2); assert.match(warnings[0], /warning: no feeds\/index\.json/); assert.match(warnings[1], /warning: no scripts\.json/); diff --git a/test/gates/run-all.test.mjs b/test/gates/run-all.test.mjs index c0cfb48..d389308 100644 --- a/test/gates/run-all.test.mjs +++ b/test/gates/run-all.test.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { buildFixtureBaseline } from './fixtures/baseline.mjs'; import { DIST_ORIGIN, makeDist } from './fixtures/make-dist.mjs'; import { gateScript, runGate } from './fixtures/run-gate.mjs'; +import { gateFiles, optionsFromHelp } from '../../skills/parity-gates/scripts/verify/run-all.mjs'; const GATES = readdirSync(dirname(gateScript('run-all'))) .filter((f) => f.endsWith('.mjs') && f !== 'run-all.mjs') @@ -47,6 +48,17 @@ test('runs every gate in order and prints All N gates passed', async () => { assert.match(result.stdout, new RegExp(`^All ${GATES.length} gates passed$`, 'm')); }); +// A release build compares URLs exactly, so these runs name the build's origin. +test('--release enables release checks, alone or with --with-proposals', async () => { + const staging = await runAll(good, ['--origin', DIST_ORIGIN]); + assert.equal(staging.code, 0, staging.stdout + staging.stderr); + for (const args of [['--release'], ['--release', '--with-proposals']]) { + const release = await runAll(good, [...args, '--origin', DIST_ORIGIN]); + assert.equal(release.code, 1, release.stdout + release.stderr); + assert.match(release.stdout + release.stderr, /PUBLIC_INDEXABLE=true/); + } +}); + test('stops at the first failing gate with its exit code', async () => { const bad = build('dist-bad', { bad: ['changed-title'] }); const result = await runAll(bad, ['--origin', DIST_ORIGIN]); @@ -80,9 +92,44 @@ test('verify:proposals passes with a markup proposal, which no gate compares; an assert.match(unknown.stdout + unknown.stderr, /field "html" is not one of .*\|markup/); }); +test('--with-proposals loads markup proposals and rejects unknown fields only when enabled', async (t) => { + const proposals = join(root, 'proposals.json'); + t.after(() => rmSync(proposals, { force: true })); + const markup = { + id: 'relative-links', path: '/about/', field: 'markup', from: '', + to: '', reason: 'links stay on the preview host', approvedBy: 'proposed', date: '2026-10-23', + }; + writeFileSync(proposals, JSON.stringify([markup])); + const on = await runAll(good, ['--with-proposals', '--origin', DIST_ORIGIN]); + assert.equal(on.code, 0, on.stdout + on.stderr); + assert.match(on.stdout, new RegExp(`^All ${GATES.length} gates passed$`, 'm')); + assert.doesNotMatch(on.stdout, /exception/); + + writeFileSync(proposals, JSON.stringify([{ ...markup, field: 'html' }])); + const unknown = await runAll(good, ['--with-proposals', '--origin', DIST_ORIGIN]); + assert.equal(unknown.code, 2, unknown.stdout + unknown.stderr); + assert.match(unknown.stdout + unknown.stderr, /field "html" is not one of .*\|markup/); + + const off = await runAll(good, ['--origin', DIST_ORIGIN]); + assert.equal(off.code, 0, off.stdout + off.stderr); +}); + +test('no gate lists a reserved run-all option', async () => { + for (const file of gateFiles()) { + const help = await runGate(file.slice(0, -4), ['--help']); + assert.equal(help.code, 0, file + ': ' + help.stderr); + const options = optionsFromHelp(help.stdout); + for (const flag of ['release', 'with-proposals']) { + assert.equal(options.has(flag), false, `${file} lists --${flag}`); + } + } +}); + test('an option no gate accepts exits 2; --help exits 0', async () => { assert.equal((await runAll(good, ['--bogus'])).code, 2); const help = await runGate('run-all', ['--help']); assert.equal(help.code, 0); assert.match(help.stdout, /Usage: run-all/); + assert.match(help.stdout, /^ --release\s+run gates with PUBLIC_INDEXABLE=true$/m); + assert.match(help.stdout, /^ --with-proposals\s+run gates with PROPOSALS=on$/m); }); diff --git a/test/node-version-copies.test.mjs b/test/node-version-copies.test.mjs index 9c40501..304b036 100644 --- a/test/node-version-copies.test.mjs +++ b/test/node-version-copies.test.mjs @@ -53,7 +53,7 @@ test('every top-level skill script answers --help from a copy of the skills plac for (const skill of SKILLS) { cpSync(join(ROOT, 'skills', skill), join(elsewhere, 'skills', skill), { recursive: true }); } - symlinkSync(join(ROOT, 'node_modules'), join(elsewhere, 'node_modules'), 'dir'); + symlinkSync(join(ROOT, 'node_modules'), join(elsewhere, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir'); for (const skill of SKILLS) { const dir = join(elsewhere, 'skills', skill, 'scripts'); if (!existsSync(dir)) continue; diff --git a/test/suggest/suggest.test.mjs b/test/suggest/suggest.test.mjs index d5f8eef..5582398 100644 --- a/test/suggest/suggest.test.mjs +++ b/test/suggest/suggest.test.mjs @@ -5,7 +5,7 @@ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, stat import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { PNG } from 'pngjs'; import { startFixture } from '../fixture/serve.mjs'; @@ -24,7 +24,7 @@ async function run(path, args) { } const suggest = (args) => run('suggest-improvements/scripts/suggest.mjs', args); const readJson = (file) => JSON.parse(readFileSync(file, 'utf8')); -const check = async (id) => (await import(join(CHECKS_DIR, `${id}.mjs`))).default; +const check = async (id) => (await import(pathToFileURL(join(CHECKS_DIR, `${id}.mjs`)).href)).default; const MINIMUM = [ 'missing-h1', 'multiple-h1', 'duplicate-titles', 'missing-descriptions', 'long-titles', 'thin-pages', @@ -157,7 +157,7 @@ test('every check file exports id, title, category and run, and the catalogue li const files = readdirSync(CHECKS_DIR).filter((n) => n.endsWith('.mjs')); const ids = []; for (const file of files) { - const c = (await import(join(CHECKS_DIR, file))).default; + const c = (await import(pathToFileURL(join(CHECKS_DIR, file)).href)).default; assert.equal(`${c.id}.mjs`, file); assert.equal(typeof c.title, 'string'); assert.ok(CATEGORIES.includes(c.category), `${c.id} category`); @@ -334,7 +334,7 @@ test('site-wide problems are one item across pages, with the page count in the e }); test('a check that throws is isolated and listed as not covered; missing inputs are noted', async () => { - const { runChecks } = await import(skills('suggest-improvements/scripts/suggest.mjs')); + const { runChecks } = await import(pathToFileURL(skills('suggest-improvements/scripts/suggest.mjs')).href); const boom = { id: 'boom', title: 'Boom', category: 'seo', run: () => { throw new Error('bad input'); } }; const fine = await check('missing-h1'); const noWebp = await check('no-webp'); @@ -424,7 +424,7 @@ test('a check returning a malformed item is not covered and the run still exits const copy = join(root, 'skills-copy'); t.after(() => rmSync(copy, { recursive: true, force: true })); cpSync(skills('suggest-improvements'), join(copy, 'suggest-improvements'), { recursive: true }); - symlinkSync(fileURLToPath(new URL('../../node_modules', import.meta.url)), join(copy, 'node_modules'), 'dir'); + symlinkSync(fileURLToPath(new URL('../../node_modules', import.meta.url)), join(copy, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir'); writeFileSync(join(copy, 'suggest-improvements', 'scripts', 'checks', 'bad-shape.mjs'), [ "import { defineCheck } from '../lib/item.mjs';", "export default defineCheck({ id: 'bad-shape', title: 'Bad shape', category: 'seo', why: 'w', effort: 'XL', risk: 'low', applyable: false,", @@ -448,7 +448,7 @@ test('a check returning a malformed item is not covered and the run still exits }); test('a partial() that throws keeps the check items', async () => { - const { runChecks } = await import(skills('suggest-improvements/scripts/suggest.mjs')); + const { runChecks } = await import(pathToFileURL(skills('suggest-improvements/scripts/suggest.mjs')).href); const fine = await check('missing-h1'); const flaky = { ...fine, partial: () => { throw new Error('odd'); } }; const [result] = runChecks([flaky], ctx({ inventory: [page({ h1: [] })] })); diff --git a/test/sync-launch-skills.test.mjs b/test/sync-launch-skills.test.mjs index e62a7a7..02faf4e 100644 --- a/test/sync-launch-skills.test.mjs +++ b/test/sync-launch-skills.test.mjs @@ -1,9 +1,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync, execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, copyFileSync, readFileSync, appendFileSync, existsSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, copyFileSync, readFileSync, readdirSync, appendFileSync, existsSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, dirname } from 'node:path'; +import { join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; const KIT = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -19,8 +19,9 @@ function run(dir, script, ...args) { return spawnSync(process.execPath, [join(dir, script), ...args], { cwd: dir, encoding: 'utf8' }); } +// Every node_modules folder under dir, one per line ('' when there is none). function findNodeModules(dir) { - return execFileSync('find', [dir, '-name', 'node_modules'], { encoding: 'utf8' }).trim(); + return readdirSync(dir, { recursive: true }).filter((path) => basename(path) === 'node_modules').join('\n'); } test('sync-launch-skills vendors the three launch skills at the locked sha', async (t) => { @@ -42,8 +43,12 @@ test('sync-launch-skills vendors the three launch skills at the locked sha', asy git(dir, 'commit', '-q', '-m', 'chore: kit'); const lock = JSON.parse(readFileSync(join(dir, 'launch-skills.lock.json'), 'utf8')); - // One real sync; every assertion below reads its result. - const sync = run(dir, 'scripts/sync-launch-skills.mjs'); + // One real sync; every assertion below reads its result. It runs with core.autocrlf=true, which Git + // for Windows sets system-wide, so the line-ending check below holds on every platform. + const sync = spawnSync(process.execPath, [join(dir, 'scripts/sync-launch-skills.mjs')], { + cwd: dir, encoding: 'utf8', + env: { ...process.env, GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.autocrlf', GIT_CONFIG_VALUE_0: 'true' }, + }); await t.test('the lock names the upstream repo and a full sha', () => { assert.equal(lock.repo, 'https://github.com/ethanplusai/website-launch-skills'); @@ -67,6 +72,13 @@ test('sync-launch-skills vendors the three launch skills at the locked sha', asy assert.equal(findNodeModules(join(dir, 'skills')), ''); }); + await t.test('the copies keep the line endings upstream committed, whatever core.autocrlf says', () => { + for (const skill of SKILLS) { + const text = readFileSync(join(dir, 'skills', skill, 'SKILL.md'), 'utf8'); + assert.ok(!text.includes('\r\n'), `${skill}/SKILL.md came out with CRLF line endings`); + } + }); + await t.test('pre-launch runs through the kit path', () => { const help = run(dir, 'skills/pre-launch/scripts/pre-launch.mjs', '--help'); assert.equal(help.status, 0, help.stderr);