Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
95af234
chore: check out text files with LF line endings on every platform
BrasaStudios Nov 1, 2026
bcfca48
fix(convert): init-site starts npm through a shell on Windows
BrasaStudios Nov 1, 2026
4f7e0f7
fix(convert): init-site compares template paths with forward slashes
BrasaStudios Nov 1, 2026
6588dd5
fix(capture): assets.mjs records asset paths with forward slashes on …
BrasaStudios Nov 1, 2026
7e0fed9
fix(convert): preflight finds Windows CLIs and stops a hung probe there
BrasaStudios Nov 1, 2026
a282a6b
fix(convert): review-serve --stop finds its server on Windows
BrasaStudios Nov 1, 2026
ccb0a96
test: suggest imports check modules by file URL
BrasaStudios Nov 1, 2026
c3e8169
test: link node_modules with a junction on Windows
BrasaStudios Nov 1, 2026
36bc711
test: compare paths with forward slashes on Windows
BrasaStudios Nov 1, 2026
222dd81
test: preflight fakes and the stray-process check run on Windows
BrasaStudios Nov 1, 2026
b6f144f
test: the unwritable state directory test denies writes on Windows too
BrasaStudios Nov 1, 2026
2af888f
test: the e2e runner starts npm and npx through a shell on Windows
BrasaStudios Nov 1, 2026
d997914
feat(gates): run-all --release and --with-proposals
BrasaStudios Nov 1, 2026
769675a
test: the sync test looks for node_modules with Node, not find
BrasaStudios Nov 1, 2026
be7fd0d
fix: sync-launch-skills keeps the line endings upstream committed
BrasaStudios Nov 1, 2026
8f980dd
test: time the fetchPaced delay from the first request's start
BrasaStudios Nov 1, 2026
40b1696
ci: run the suite on Windows as well
BrasaStudios Nov 1, 2026
9bcb6ea
docs: Windows is supported
BrasaStudios Nov 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion scripts/sync-launch-skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
3 changes: 2 additions & 1 deletion skills/capture-site/scripts/assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
16 changes: 11 additions & 5 deletions skills/convert-site/scripts/init-site.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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' +
Expand Down
42 changes: 30 additions & 12 deletions skills/convert-site/scripts/preflight.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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');
}
Expand Down
9 changes: 8 additions & 1 deletion skills/convert-site/scripts/review-serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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; });
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions skills/convert-site/templates/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 23 additions & 3 deletions skills/parity-gates/scripts/verify/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <gate>.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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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`);
Expand Down
3 changes: 2 additions & 1 deletion test/capture/fingerprint.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion test/convert/copy-assets.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')));
});

Expand Down
10 changes: 5 additions & 5 deletions test/convert/init-site.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)), '..', '..');
Expand Down Expand Up @@ -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('/')];
});
}

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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)));
Expand Down
Loading