From 95af234f9e4ec34d337a729285f7d9cd7bd4a07d Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:01:02 -0500 Subject: [PATCH 01/18] chore: check out text files with LF line endings on every platform Git for Windows turns on core.autocrlf system-wide, so a plain clone on Windows checked out CRLF files and tests that read the fixtures failed before any Windows-specific code ran. text=auto leaves binary files as they are. --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes 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 From bcfca488b49075db5fb190734e9881108fb0b2d3 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:01:04 -0500 Subject: [PATCH 02/18] fix(convert): init-site starts npm through a shell on Windows npm is npm.cmd on Windows, and since Node fixed CVE-2024-27980 (April 2024) a .cmd file starts only through a shell, so the scaffold stopped at npm install. The arguments are fixed tokens, so on Windows they are joined into one command line; that also avoids the DEP0190 warning Node 24 prints when an argument list is passed with shell: true. Linux and macOS keep execFileSync. --- skills/convert-site/scripts/init-site.mjs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/skills/convert-site/scripts/init-site.mjs b/skills/convert-site/scripts/init-site.mjs index df49f89..d74a969 100644 --- a/skills/convert-site/scripts/init-site.mjs +++ b/skills/convert-site/scripts/init-site.mjs @@ -1,7 +1,7 @@ #!/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'; @@ -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, @@ -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' + From 4f7e0f7c8f9a6fb536d022b8f5d56eeec1a82429 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:01:43 -0500 Subject: [PATCH 03/18] fix(convert): init-site compares template paths with forward slashes copyTemplates compared each template path, which holds backslashes on Windows, with skip names written with forward slashes, so a file meant to be skipped was copied anyway. path.sep is / on Linux and macOS, so nothing changes there. --- skills/convert-site/scripts/init-site.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/convert-site/scripts/init-site.mjs b/skills/convert-site/scripts/init-site.mjs index d74a969..cc07a34 100644 --- a/skills/convert-site/scripts/init-site.mjs +++ b/skills/convert-site/scripts/init-site.mjs @@ -5,7 +5,7 @@ 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'; @@ -156,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); From 6588dd54780deeec1312116cf1958c6c11b567e9 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:01:44 -0500 Subject: [PATCH 04/18] fix(capture): assets.mjs records asset paths with forward slashes on Windows The file field is a site path such as assets/js/view.js that later steps join and compare, but on Windows it was cut from a native path and kept its backslashes. --- skills/capture-site/scripts/assets.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 }); From 7e0fed944759e23509cdd431231c2193919a243a Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:01:45 -0500 Subject: [PATCH 05/18] fix(convert): preflight finds Windows CLIs and stops a hung probe there findBinary looked only for the bare name, so gh.exe and npx.cmd were never found on Windows. A .cmd or .bat CLI starts only through a shell, so its probe arguments are checked and joined into one command line. A probe that times out is ended with taskkill /t, because a negative pid (the process group) means nothing on Windows. Linux and macOS behave as before. --- skills/convert-site/scripts/preflight.mjs | 42 ++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) 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'); } From a282a6bf9128b5d8811aa923cf397713c250f58c Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:01:58 -0500 Subject: [PATCH 06/18] fix(convert): review-serve --stop finds its server on Windows --stop reads the command line of the pid in the pid file from ps. Windows has no ps, so --stop reported a running server as already exited and left it running. There the command line comes from WMI, and the background server starts without opening a console window. --- skills/convert-site/scripts/review-serve.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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; From ccb0a9601c70c1045843cfff330b283ca53c9b1d Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:01:59 -0500 Subject: [PATCH 07/18] test: suggest imports check modules by file URL A plain absolute path is not a valid import specifier on Windows (C:\ reads as a URL scheme). pathToFileURL, which the production loader already uses, gives the same module on every platform. --- test/suggest/suggest.test.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/suggest/suggest.test.mjs b/test/suggest/suggest.test.mjs index d5f8eef..0c05a35 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'); @@ -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: [] })] })); From c3e8169df400ad5a15c01bac51ab2d8d3a861143 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:02:21 -0500 Subject: [PATCH 08/18] test: link node_modules with a junction on Windows A directory symlink needs administrator rights or Developer Mode on Windows; a junction needs neither and serves these links the same way. Three tests and the e2e scaffold make one. --- test/convert/init-site.test.mjs | 2 +- test/e2e/lib/run.mjs | 2 +- test/node-version-copies.test.mjs | 2 +- test/suggest/suggest.test.mjs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/convert/init-site.test.mjs b/test/convert/init-site.test.mjs index be7f8b9..42de8d2 100644 --- a/test/convert/init-site.test.mjs +++ b/test/convert/init-site.test.mjs @@ -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/e2e/lib/run.mjs b/test/e2e/lib/run.mjs index f832c68..976a0a1 100644 --- a/test/e2e/lib/run.mjs +++ b/test/e2e/lib/run.mjs @@ -274,7 +274,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/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 0c05a35..5582398 100644 --- a/test/suggest/suggest.test.mjs +++ b/test/suggest/suggest.test.mjs @@ -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,", From 36bc71176dd59bb64c65a117f2f09a933aa6587d Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:02:55 -0500 Subject: [PATCH 09/18] test: compare paths with forward slashes on Windows Three tests compared native paths, or messages that print them, with names written with forward slashes. On Linux and macOS path.sep is already /, so the comparisons there are unchanged. --- test/convert/copy-assets.test.mjs | 2 +- test/convert/init-site.test.mjs | 4 ++-- test/gates/build-baseline.test.mjs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 42de8d2..7f42721 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('/')]; }); } 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/); From 222dd8180c38cbf4bfe79c009617dc5d86fc11ec Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:02:56 -0500 Subject: [PATCH 10/18] test: preflight fakes and the stray-process check run on Windows The fake CLIs were shell scripts, which Windows cannot run, and the stray-process check used pgrep. On Windows each fake is a .cmd that runs a small Node script, and the check asks WMI. The shell fakes stay for Linux and macOS. --- test/convert/preflight.test.mjs | 80 +++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 13 deletions(-) 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); From b6f144f4e34b98c30b3472069020bedfc39bcca5 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:02:58 -0500 Subject: [PATCH 11/18] test: the unwritable state directory test denies writes on Windows too chmod 0o500 does not stop writes to a directory on Windows. There the test denies the current account write access with icacls and expects EPERM, the error Windows reports. --- test/convert/state.test.mjs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 /); }); From 2af888fa032526c076403729e642ca576aecbc08 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:02:59 -0500 Subject: [PATCH 12/18] test: the e2e runner starts npm and npx through a shell on Windows Like init-site, the runner could not start npm.cmd or npx.cmd without a shell. Its arguments are script names, flags, local paths and URLs, so on Windows they are checked and joined into one command line, and an argument that would need quoting fails the run loudly. --- test/e2e/lib/run.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/e2e/lib/run.mjs b/test/e2e/lib/run.mjs index 976a0a1..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); }); } From d9979143396664471702f6f20908d1b59b8d9a64 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:21:34 -0500 Subject: [PATCH 13/18] feat(gates): run-all --release and --with-proposals The site's verify:release and verify:proposals scripts set PUBLIC_INDEXABLE and PROPOSALS with POSIX shell syntax, which cmd.exe, npm's script shell on Windows, cannot run. run-all now takes two switches and sets the variables for every gate, so the scripts are plain node commands in any shell. The second is --with-proposals because every gate already has --proposals for the proposals file. A site scaffolded before this, which sets the variables itself, keeps working. --- .../convert-site/templates/site/package.json | 4 +- .../parity-gates/scripts/verify/run-all.mjs | 26 ++++++++-- test/convert/init-site.test.mjs | 4 +- test/gates/run-all.test.mjs | 47 +++++++++++++++++++ 4 files changed, 74 insertions(+), 7 deletions(-) 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/convert/init-site.test.mjs b/test/convert/init-site.test.mjs index 7f42721..0bac18a 100644 --- a/test/convert/init-site.test.mjs +++ b/test/convert/init-site.test.mjs @@ -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'); 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); }); From 769675ae1378de51bd01522dce5e5885b232d63d Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:25:54 -0500 Subject: [PATCH 14/18] test: the sync test looks for node_modules with Node, not find On Windows find is the text-search command, so the check failed wherever the Unix tools from Git Bash were not first on PATH. A recursive readdirSync gives the same answer on every platform. --- test/sync-launch-skills.test.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/sync-launch-skills.test.mjs b/test/sync-launch-skills.test.mjs index e62a7a7..5595e32 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) => { From be7fd0d0799556d04e2eefc3fb8c0567460e81fd Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:26:34 -0500 Subject: [PATCH 15/18] fix: sync-launch-skills keeps the line endings upstream committed The sync checks the skills out in a scratch repository, which inherits core.autocrlf. Git for Windows turns that on system-wide, so every copied file came out with CRLF line endings. The checkout now passes -c core.autocrlf=false; the test runs its sync with core.autocrlf=true forced, so the new check fails without the fix on every platform. --- scripts/sync-launch-skills.mjs | 3 ++- test/sync-launch-skills.test.mjs | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) 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/test/sync-launch-skills.test.mjs b/test/sync-launch-skills.test.mjs index 5595e32..02faf4e 100644 --- a/test/sync-launch-skills.test.mjs +++ b/test/sync-launch-skills.test.mjs @@ -43,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'); @@ -68,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); From 8f980dd68915c0049315c2372db7f153bf61a348 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:36:41 -0500 Subject: [PATCH 16/18] test: time the fetchPaced delay from the first request's start fetchPaced paces request starts, so once the first request had finished, the second waited delayMs minus that request's duration. A first local request on Windows often takes more than 10 ms, and the test failed 5 runs in 8 there. Starting the clock before the first request measures what the pacer promises on every platform; a pacer that did not wait still fails it. --- test/capture/fingerprint.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 { From 40b1696e1055861a508a08c429818b7735f38d75 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:20:41 -0500 Subject: [PATCH 17/18] ci: run the suite on Windows as well windows-latest joins the matrix, so both Node lines run there too and Windows support cannot quietly break later. The e2e cache key already includes runner.os. --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 9bcb6ea01ef12c944c6d06e632978693cd640b21 Mon Sep 17 00:00:00 2001 From: Brasa Studios <317737961+BrasaStudios@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:21:36 -0500 Subject: [PATCH 18/18] docs: Windows is supported The README requirement and the FAQ answer. The skills' references keep their POSIX shell commands, so the FAQ says to run them in Git Bash, with MSYS_NO_PATHCONV=1 so that Git Bash leaves arguments such as /thank-you/ alone. --- README.md | 3 ++- docs/faq.md | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) 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.