diff --git a/package-lock.json b/package-lock.json index ebfa26d4..a7c26523 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^25.6.0", + "concurrently": "^10.0.3", "esbuild": "^0.28.0", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", @@ -3572,6 +3573,46 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3606,6 +3647,57 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -5482,6 +5574,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -8411,6 +8513,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -8595,6 +8707,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -9377,6 +9502,16 @@ "node": ">=20" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -10025,6 +10160,62 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/write-file-atomic": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", @@ -10055,6 +10246,16 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10095,6 +10296,59 @@ "url": "https://github.com/sponsors/ota-meshi" } }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 595b1b8b..35369bd1 100644 --- a/package.json +++ b/package.json @@ -19,14 +19,14 @@ "build": "node esbuild.config.mjs", "build:styles": "node scripts/build-styles.mjs", "build:styles:check": "node scripts/build-styles.mjs --check", - "check": "node scripts/check.mjs", - "check:ci": "node scripts/check.mjs --ci", + "check": "concurrently --group --pad-prefix --names typecheck,test,lint:ts,lint:css,lint:css-usage,lint:deps,lint:unused,format:check,build:styles:check,build \"npm run --silent typecheck\" \"npm run --silent test\" \"npm run --silent lint:ts\" \"npm run --silent lint:css\" \"npm run --silent lint:css-usage\" \"npm run --silent lint:deps\" \"npm run --silent lint:unused\" \"npm run --silent format:check\" \"npm run --silent build:styles:check\" \"npm run --silent build\"", + "check:ci": "npm run typecheck:ci && npm run test:ci && npm run lint:ci && npm run format:check:ci && npm run build:styles:check && npm run build", "format": "node scripts/format.mjs", "format:check": "node scripts/format.mjs --check --cache", "format:check:ci": "node scripts/format.mjs --check", "generate:app-server-types": "node scripts/generate-app-server-types.mjs", - "lint": "node scripts/run-parallel.mjs lint:ts lint:css lint:css-usage lint:deps lint:unused", - "lint:ci": "node scripts/run-parallel.mjs lint:ts:ci lint:css lint:css-usage lint:deps lint:unused", + "lint": "concurrently --group --pad-prefix --names lint:ts,lint:css,lint:css-usage,lint:deps,lint:unused \"npm run --silent lint:ts\" \"npm run --silent lint:css\" \"npm run --silent lint:css-usage\" \"npm run --silent lint:deps\" \"npm run --silent lint:unused\"", + "lint:ci": "npm run lint:ts:ci && npm run lint:css && npm run lint:css-usage && npm run lint:deps && npm run lint:unused", "lint:css": "stylelint \"src/**/*.css\" --max-warnings=0", "lint:css-usage": "node scripts/lint/check-css-usage.mjs --fail-on-candidates", "lint:deps": "node scripts/lint/check-import-cycles.mjs", @@ -44,6 +44,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^25.6.0", + "concurrently": "^10.0.3", "esbuild": "^0.28.0", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", diff --git a/scripts/check.mjs b/scripts/check.mjs deleted file mode 100644 index 349c37e2..00000000 --- a/scripts/check.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { spawnSync } from "node:child_process"; - -const args = new Set(process.argv.slice(2)); -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; -const checks = [ - { local: "typecheck", ci: "typecheck:ci", localPhase: "parallel" }, - { local: "test", ci: "test:ci", localPhase: "parallel" }, - { local: "lint", ci: "lint:ci", localPhase: "parallel" }, - { local: "format:check", ci: "format:check:ci", localPhase: "parallel" }, - { local: "build:styles:check", ci: "build:styles:check", localPhase: "parallel" }, - { local: "build", ci: "build", localPhase: "sequential" }, -]; - -for (const arg of args) { - if (arg !== "--ci") { - console.error("Usage: node scripts/check.mjs [--ci]"); - process.exit(1); - } -} - -if (args.has("--ci")) { - for (const check of checks) { - if (check.ci === null) continue; - run(npmCommand, ["run", check.ci]); - } -} else { - const parallelScripts = checks.filter((check) => check.localPhase === "parallel").map((check) => check.local); - run("node", ["scripts/run-parallel.mjs", ...parallelScripts]); - for (const check of checks.filter((check) => check.localPhase === "sequential")) run(npmCommand, ["run", check.local]); -} - -function run(command, args) { - const result = spawnSync(command, args, { - stdio: "inherit", - shell: false, - }); - if (result.error) { - console.error(`Failed to run ${command} ${args.join(" ")}: ${result.error.message}`); - process.exit(1); - } - if (result.status !== 0) process.exit(result.status ?? 1); -} diff --git a/scripts/run-parallel.mjs b/scripts/run-parallel.mjs deleted file mode 100644 index 3a29c0c7..00000000 --- a/scripts/run-parallel.mjs +++ /dev/null @@ -1,90 +0,0 @@ -import { Buffer } from "node:buffer"; -import { spawn } from "node:child_process"; - -const scripts = process.argv.slice(2); - -if (scripts.length === 0) { - console.error("Usage: node scripts/run-parallel.mjs [ ...]"); - process.exit(1); -} - -const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; -const longestNameLength = Math.max(...scripts.map((script) => script.length)); -const running = new Set(); - -function prefixedOutput(script, text) { - const prefix = `[${script.padEnd(longestNameLength)}] `; - return text - .trimEnd() - .split(/\r?\n/) - .map((line) => `${prefix}${line}`) - .join("\n"); -} - -function flushOutput(script, output) { - const text = Buffer.concat(output).toString(); - if (!text.trim()) return; - process.stdout.write(`${prefixedOutput(script, text)}\n`); -} - -function runScript(script) { - const startedAt = process.hrtime.bigint(); - console.log(`[${script.padEnd(longestNameLength)}] started`); - - const child = spawn(npmCommand, ["run", "--silent", script], { - stdio: ["ignore", "pipe", "pipe"], - }); - - running.add(child); - const output = []; - child.stdout.on("data", (chunk) => output.push(chunk)); - child.stderr.on("data", (chunk) => output.push(chunk)); - - return new Promise((resolve) => { - child.on("error", (error) => { - running.delete(child); - console.error(`[${script.padEnd(longestNameLength)}] failed to start: ${error.message}`); - resolve({ script, status: 1, output: "" }); - }); - - child.on("close", (code, signal) => { - running.delete(child); - flushOutput(script, output); - const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; - const elapsedSeconds = (elapsedMs / 1000).toFixed(1); - - if (code === 0) { - console.log(`[${script.padEnd(longestNameLength)}] passed in ${elapsedSeconds}s`); - resolve({ script, status: 0 }); - } else { - const detail = signal ? `signal ${signal}` : `exit ${code ?? 1}`; - console.error(`[${script.padEnd(longestNameLength)}] failed with ${detail} after ${elapsedSeconds}s`); - resolve({ script, status: code ?? 1 }); - } - }); - }); -} - -function stopRunning(signal) { - for (const child of running) { - child.kill(signal); - } -} - -process.on("SIGINT", () => { - stopRunning("SIGINT"); -}); - -process.on("SIGTERM", () => { - stopRunning("SIGTERM"); -}); - -const results = await Promise.all(scripts.map(runScript)); -const failed = results.filter((result) => result.status !== 0); - -if (failed.length > 0) { - console.error(`Failed checks: ${failed.map((result) => result.script).join(", ")}`); - process.exit(1); -} - -console.log(`All checks passed: ${scripts.join(", ")}`); diff --git a/tests/scripts/development-scripts.test.ts b/tests/scripts/development-scripts.test.ts index 782b1122..516e1fe9 100644 --- a/tests/scripts/development-scripts.test.ts +++ b/tests/scripts/development-scripts.test.ts @@ -7,20 +7,23 @@ import { describe, expect, it } from "vitest"; const repoRoot = process.cwd(); describe("development scripts", () => { - it("propagates CI check failures", async () => { + it("propagates check:ci script failures", async () => { const cwd = await tempWorkspace(); + const scripts = await readPackageScripts(); await writeFile(path.join(cwd, "fail.mjs"), "process.exit(7);\n"); await writeJson(path.join(cwd, "package.json"), { scripts: { + "check:ci": scripts["check:ci"], "typecheck:ci": "node fail.mjs", "test:ci": 'node -e "process.exit(0)"', "lint:ci": 'node -e "process.exit(0)"', "format:check:ci": 'node -e "process.exit(0)"', + "build:styles:check": 'node -e "process.exit(0)"', build: 'node -e "process.exit(0)"', }, }); - const result = runNodeScript("scripts/check.mjs", ["--ci"], cwd); + const result = runNpmScript("check:ci", cwd); expect(result.status).toBe(7); }); @@ -253,6 +256,20 @@ function runNodeScript(script: string, args: string[] = [], cwd = repoRoot, env: }); } +function runNpmScript(script: string, cwd = repoRoot) { + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + return spawnSync(npmCommand, ["run", script], { + cwd, + encoding: "utf8", + shell: false, + }); +} + +async function readPackageScripts(): Promise> { + const packageJson = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8")) as { scripts: Record }; + return packageJson.scripts; +} + async function writeJson(file: string, value: unknown): Promise { await writeFile(file, `${JSON.stringify(value, null, 2)}\n`); }