diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea63f68..91b65d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,7 +105,7 @@ jobs: run: npm run test:templates peer-floor: - name: Packed Askr peer floor (0.4.0) + name: Packed Askr peer floor (0.5.0) runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index acef125..e864c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-10-12 + ### Added +- Generate Auth0, Microsoft, Google and Apple provider sessions with + `askr create --auth` and `askr add auth`, using explicit HTTPS origins + and typed client bindings. + - Add opt-in `askr/route-access-policy` analysis for exact protected registry file/export identities. Warn when a statically known leaf has no native `auth` requirement or non-empty `policies`, including inherited access. @@ -24,10 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Wire generated provider launchers to the configured application origin and - allowed host, serve browser assets from the production build directory while - excluding server bundles and Vite manifests, and include Node types in - provider application type checking. +- Wire generated provider servers to the configured HTTPS origin and canonical + host, include Node types in generated provider projects, and serve built assets + from the production directory while excluding server bundles and Vite manifests. - Dispose rejected or redirected OpenAPI response bodies, including header validation and redirect-limit failures. Stop streaming unused bodies after diff --git a/package-lock.json b/package-lock.json index 1a9ba8b..d3c7028 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@askrjs/cli", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/cli", - "version": "0.4.0", + "version": "0.5.0", "license": "Apache-2.0", "dependencies": { "@npmcli/config": "^11.0.1", @@ -23,13 +23,13 @@ "askr": "dist/cli.js" }, "devDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/charts": ">=0.4.0 <0.5.0", - "@askrjs/logos": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0", - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/charts": ">=0.5.0 <0.6.0", + "@askrjs/logos": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "@types/js-yaml": "^4.0.9", "@types/node": "^26.3.0", "@types/semver": "^7.8.0", @@ -43,7 +43,7 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" }, "peerDependenciesMeta": { "@askrjs/askr": { @@ -52,17 +52,17 @@ } }, "node_modules/@askrjs/askr": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@askrjs/askr/-/askr-0.4.3.tgz", - "integrity": "sha512-/kgytC8QXI7MRWo0jDWHVOugIG11WAzoYSNV29lKMRpYBuooGWrLaOx+m4TyOZO3XvRdJBB4thy4edlvgG9ecQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/askr/-/askr-0.5.0.tgz", + "integrity": "sha512-UaMytwNNi+ZPW51G033odDTAqZ02I/mmHrh424YY3lRk3DxdRRWkpMJCDvwOIGXeG4XbvLhB6PyRADmAfu5rog==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0" + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0" }, "peerDependenciesMeta": { "@askrjs/auth": { @@ -74,9 +74,9 @@ } }, "node_modules/@askrjs/auth": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/auth/-/auth-0.4.0.tgz", - "integrity": "sha512-OStU/Hdt1BShWZDOjLA37VIp09+ybHWa1IY+ZXjP4f+U7vchbhuUik1kTrMyfBHTqMcBKkkUnnikyyIeHh6j8w==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/auth/-/auth-0.5.0.tgz", + "integrity": "sha512-yfHMNt2koSIP50w9WeaFTzOYvcJcqJKMx4Gb2nDSS4FqNc2i/cpqu2YqrAXFTmOw+yY8brNl60xHf7s6fuwYgg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -89,53 +89,53 @@ } }, "node_modules/@askrjs/charts": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/charts/-/charts-0.4.0.tgz", - "integrity": "sha512-Dxqxzm3kynFVDB85VINWqMdY+BzPaL2tMi1lxOypXpYPjSpGotRMLEohr25BxPeOvpVw5eXYDby+OzsftiXQKA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/charts/-/charts-0.5.0.tgz", + "integrity": "sha512-/pwEk71WlxSGG8FZ71D71kLNDMPqA13eB5i8jxFMuzaY0o1D15nZybziJD5v1S9vlervOryH0AulCmI64Ulfwg==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" } }, "node_modules/@askrjs/logos": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/logos/-/logos-0.4.0.tgz", - "integrity": "sha512-jtmd32OD1UYNq/rMnfkuEPMXkg8ZkfmPlm9/R454Oe9M1cItmqrSvqCfPo61RC9Gqu+Eg/ap50A9PL+GgPqatQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/logos/-/logos-0.5.0.tgz", + "integrity": "sha512-Ko3Cf12+qwfoFx1QgDoEANPtFEILyGfSsA1/mwKD8AjTXH3n58cH0bXBv4s6FvfFH46FSIc9xzOr0eTC2YuLkw==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" } }, "node_modules/@askrjs/lucide": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/lucide/-/lucide-0.4.0.tgz", - "integrity": "sha512-pSBe6I3FY8nfIH0aAVL+qu8rrDGY8QR3zJyuVA9JqWflPg9xcqhJg/CsZOES2YhKhFKWYJFIDvIsDP5O0NG74w==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/lucide/-/lucide-0.5.0.tgz", + "integrity": "sha512-naFre5an65bHCVYL9lSpdk/InU2PMbfigiY+b4oafNBQpcJWfEFKtZ0EayaVLlGoxDxlg2t4O23DParn8eKQgQ==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" } }, "node_modules/@askrjs/node": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/node/-/node-0.4.0.tgz", - "integrity": "sha512-gRdFGwMDiVadJatJSveLJL8ZAmxMxFTJW+ShVT8mXZa46ZSP3p28ZKGYa2TWH8j3nlhf3H1CJW+FSUmjfL1i/w==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/node/-/node-0.5.0.tgz", + "integrity": "sha512-5j+lLlMjC13Py8soE7J226DjPTW38HI0xVGKAgsKi/somlGiMzo6AuQnMqbxk9WebZwN6modHlzgwlww25Fpwg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0", "@types/ws": "^8.18.1", "ws": "^8.21.3" }, @@ -144,9 +144,9 @@ } }, "node_modules/@askrjs/schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/schema/-/schema-0.4.0.tgz", - "integrity": "sha512-vZfUeaGuMk0uREajsYOT/5TgrOAe+YKZMjXYLCK86YeY2P7GLy6Sv76lEoVU+AZoAb7odUMaVdruU8yLxMy9oQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/schema/-/schema-0.5.0.tgz", + "integrity": "sha512-YSCBym9d92iM2JdageO9agmzAOrZj5K/uS8XE/hbq7F79no9BiSOf2ZyVAmccMZ59TfpeY85ZTIlMo0jIhsVFA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -154,20 +154,20 @@ } }, "node_modules/@askrjs/server": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@askrjs/server/-/server-0.4.0.tgz", - "integrity": "sha512-MjTomOPW/8vn8xvz1N2P/pruybCwfy94HpaavfVaBRos10ih/2I+jBKQdrt3PmwQR/iVZdqqiRgk+Chy2eaw5Q==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/server/-/server-0.5.0.tgz", + "integrity": "sha512-piD546hjlQElDs+7fNbTCBE85qdnpZRnLmcOUAoalI1l7h6+Elpp0/+nkfVVim2BMnNs8CTqW01Cwpxv/hv9pA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0" + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0" }, "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" }, "peerDependenciesMeta": { "@askrjs/askr": { @@ -176,40 +176,40 @@ } }, "node_modules/@askrjs/themes": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@askrjs/themes/-/themes-0.4.2.tgz", - "integrity": "sha512-xg+BZcpKkkYxMirBBgykDT3hTrbvUpn1nl1GcPMzDeMiI2Uow9IQKqs027ng+qTC8lHbskzhTYHws+TyqJxBlQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/themes/-/themes-0.5.0.tgz", + "integrity": "sha512-uxHT4n+ts3JWG3kfoQgVjLrcrxUVDal47+67KHKRrWVfhKDorIm4Ah3u1594xdnjTx4hI3nBASdKcS9LmLfGOQ==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" } }, "node_modules/@askrjs/ui": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@askrjs/ui/-/ui-0.4.3.tgz", - "integrity": "sha512-ADnB7CENORt7b1BLae1a6643Dh6cfc9dvpmDiS1Igu1L1PY0rvrh+tIu0OTJj0DdTypwIA/qFOviwx5VGLiBog==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/ui/-/ui-0.5.0.tgz", + "integrity": "sha512-Wzg5YShgKyYohoaxQwnJS6Jrva3lR8tLy92S4QIB0zZvFyltaTgbBGP8GPDJYb/2qrr6NZmuXrZRk82BPBB1qg==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" } }, "node_modules/@askrjs/vite": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@askrjs/vite/-/vite-0.4.1.tgz", - "integrity": "sha512-3pcvhmsRJiptJpvSmTHoIvwN6dWVkRkR05+KGeYaRqcfCh/LyWUAHmG9Agi7M7xPjkKQTLAhtpb+c53G+WqG/g==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@askrjs/vite/-/vite-0.5.0.tgz", + "integrity": "sha512-OufgXhHO9a4/IkN3KXBMnvTs8iEfUT0CMFE8v3fyRTtRahzEXU0FoFiRIEWDrmuSmGOXYTs8U7p1gLwxu9pVBQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@askrjs/node": ">=0.4.0 <0.5.0", + "@askrjs/node": ">=0.5.0 <0.6.0", "oxc-parser": "^0.147.0", "parse5": "^8.0.1" }, @@ -217,10 +217,10 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", "sharp": "^0.35.3", - "vite": "^8.2.2", - "vite-plus": "^0.3.1" + "vite": "^8.2.2 || ^0.3.1 || ^1.1.0", + "vite-plus": "^0.3.1 || ^1.1.0" }, "peerDependenciesMeta": { "sharp": { @@ -2474,9 +2474,9 @@ "license": "MIT" }, "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index af70cf2..3f7076d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/cli", - "version": "0.4.0", + "version": "0.5.0", "description": "Unified CLI for the Askr platform", "homepage": "https://github.com/askrjs/askr-cli#readme", "bugs": { @@ -73,13 +73,13 @@ "typescript": "npm:@typescript/typescript6@^6.0.2" }, "devDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/charts": ">=0.4.0 <0.5.0", - "@askrjs/logos": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0", - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/charts": ">=0.5.0 <0.6.0", + "@askrjs/logos": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "@types/js-yaml": "^4.0.9", "@types/node": "^26.3.0", "@types/semver": "^7.8.0", @@ -90,7 +90,7 @@ "vitest": "^4.1.11" }, "peerDependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0" }, "peerDependenciesMeta": { "@askrjs/askr": { @@ -100,5 +100,9 @@ "engines": { "node": ">=24.0.0" }, - "packageManager": "npm@12.0.1" + "packageManager": "npm@12.0.1", + "allowScripts": { + "esbuild@0.28.2": true, + "fsevents@2.3.3": true + } } diff --git a/src/bin/add.ts b/src/bin/add.ts index 6a675e3..ef2baa5 100644 --- a/src/bin/add.ts +++ b/src/bin/add.ts @@ -665,7 +665,7 @@ async function addDatabase( }; manifest.dependencies = { ...manifest.dependencies, - "@askrjs/orm": ">=0.4.0 <0.5.0", + "@askrjs/orm": ">=0.5.0 <0.6.0", ...(parsed.name === "postgres" ? { pg: "^8.16.0", "pg-query-stream": "^4.10.0" } : {}), }; manifest.dependencies = Object.fromEntries( diff --git a/templates/full-stack/package.json b/templates/full-stack/package.json index c73bd4f..8b7fd31 100644 --- a/templates/full-stack/package.json +++ b/templates/full-stack/package.json @@ -15,19 +15,19 @@ "check": "askr check" }, "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/i18n": ">=0.4.0 <0.5.0", - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/otel": ">=0.4.0 <0.5.0", - "@askrjs/schema": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0", + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/i18n": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/otel": ">=0.5.0 <0.6.0", + "@askrjs/schema": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0", "@opentelemetry/api": "^1.9.0" }, "devDependencies": { - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "@types/node": "^20.19.0", "jsdom": "^29.1.1", "typescript": "^7.0.2", diff --git a/templates/spa/package.json b/templates/spa/package.json index 3f93718..626a770 100644 --- a/templates/spa/package.json +++ b/templates/spa/package.json @@ -16,14 +16,14 @@ "check": "askr check" }, "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/charts": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/charts": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "@types/node": "^20.19.0", "jsdom": "^29.1.1", "typescript": "^7.0.2", diff --git a/templates/ssg/package.json b/templates/ssg/package.json index a3cd7ec..19687fd 100644 --- a/templates/ssg/package.json +++ b/templates/ssg/package.json @@ -18,13 +18,13 @@ "check": "askr check" }, "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/cli": ">=0.4.0 <0.5.0", - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/cli": ">=0.5.0 <0.6.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "@types/node": "^20.19.0", "jsdom": "^29.1.1", "typescript": "^7.0.2", diff --git a/templates/ssr/package.json b/templates/ssr/package.json index e33ac94..1185f2c 100644 --- a/templates/ssr/package.json +++ b/templates/ssr/package.json @@ -15,14 +15,14 @@ "check": "askr check" }, "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/node": ">=0.4.0 <0.5.0", - "@askrjs/server": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/node": ">=0.5.0 <0.6.0", + "@askrjs/server": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "@types/node": "^20.19.0", "jsdom": "^29.1.1", "tsx": "^4.23.1", diff --git a/templates/startkit/package.json b/templates/startkit/package.json index e7fbb95..4c50f34 100644 --- a/templates/startkit/package.json +++ b/templates/startkit/package.json @@ -17,14 +17,14 @@ "check": "askr check" }, "dependencies": { - "@askrjs/askr": ">=0.4.0 <0.5.0", - "@askrjs/auth": ">=0.4.0 <0.5.0", - "@askrjs/lucide": ">=0.4.0 <0.5.0", - "@askrjs/themes": ">=0.4.0 <0.5.0", - "@askrjs/ui": ">=0.4.0 <0.5.0" + "@askrjs/askr": ">=0.5.0 <0.6.0", + "@askrjs/auth": ">=0.5.0 <0.6.0", + "@askrjs/lucide": ">=0.5.0 <0.6.0", + "@askrjs/themes": ">=0.5.0 <0.6.0", + "@askrjs/ui": ">=0.5.0 <0.6.0" }, "devDependencies": { - "@askrjs/vite": ">=0.4.0 <0.5.0", + "@askrjs/vite": ">=0.5.0 <0.6.0", "@types/node": "^20.19.0", "jsdom": "^29.1.1", "typescript": "^7.0.2", diff --git a/tests/cli-smoke.test.ts b/tests/cli-smoke.test.ts index 6671bd8..312ec70 100644 --- a/tests/cli-smoke.test.ts +++ b/tests/cli-smoke.test.ts @@ -416,7 +416,7 @@ test("should ensure runCreateCli scaffolds SPA with the route-first themed app s expect(appLayoutFile).toMatch(/Sidebar/); expect(appLayoutFile).toMatch(/ThemeToggle/); expect(appLayoutFile).toMatch(/appNavItems/); - expect(packageJson).toMatch(/"@askrjs\/charts": ">=0\.4\.0 <0\.5\.0"/); + expect(packageJson).toMatch(/"@askrjs\/charts": ">=0\.5\.0 <0\.6\.0"/); expect(routesFile).toMatch(/registerPublicRoutes/); expect(routesFile).toMatch(/registerAuthRoutes/); expect(routesFile).toMatch(/registerAppRoutes/); @@ -487,7 +487,7 @@ test("should ensure runCreateCli scaffolds SSG with shared route registration an expect(packageJson).toMatch( /"generate": "askr ssg --config \.\/ssg\.config\.ts --output \.\/dist"/, ); - expect(packageJson).toMatch(/"@askrjs\/cli": ">=0\.4\.0 <0\.5\.0"/); + expect(packageJson).toMatch(/"@askrjs\/cli": ">=0\.5\.0 <0\.6\.0"/); expect(packageJson).toMatch(/"test": "vp test run -c \.\/vitest\.config\.ts"/); expect(packageJson).toMatch(/"fmt": "vp fmt \."/); expect(mainFile).toMatch(/registry:\s*pageRegistry/); @@ -650,10 +650,10 @@ test("should ensure runCreateCli scaffolds a function-first full-stack project", expect(packageJson).toMatch(/"@askrjs\/schema"/); expect(packageJson).toMatch(/"@askrjs\/i18n"/); expect(packageJson).toMatch(/"@askrjs\/otel"/); - expect(packageManifest.dependencies["@askrjs/askr"]).toBe(">=0.4.0 <0.5.0"); - expect(packageManifest.dependencies["@askrjs/themes"]).toBe(">=0.4.0 <0.5.0"); - expect(packageManifest.dependencies["@askrjs/ui"]).toBe(">=0.4.0 <0.5.0"); - expect(packageManifest.devDependencies["@askrjs/vite"]).toBe(">=0.4.0 <0.5.0"); + expect(packageManifest.dependencies["@askrjs/askr"]).toBe(">=0.5.0 <0.6.0"); + expect(packageManifest.dependencies["@askrjs/themes"]).toBe(">=0.5.0 <0.6.0"); + expect(packageManifest.dependencies["@askrjs/ui"]).toBe(">=0.5.0 <0.6.0"); + expect(packageManifest.devDependencies["@askrjs/vite"]).toBe(">=0.5.0 <0.6.0"); expect(indexHtml.match(//g)).toHaveLength(1); expect(indexHtml.match(//g)).toHaveLength(1); expect(gitignore).toContain("node_modules"); @@ -680,21 +680,21 @@ test("should ensure runCreateCli scaffolds a function-first full-stack project", } }); -test("should ensure templates require the 0.4 platform floor", async () => { +test("should ensure templates require the 0.5 platform floor", async () => { for (const template of ["full-stack", "spa", "ssr", "ssg", "startkit"]) { const manifest = JSON.parse( await fs.readFile(new URL(`../templates/${template}/package.json`, import.meta.url), "utf8"), ) as { dependencies: Record }; - expect(manifest.dependencies["@askrjs/askr"], template).toBe(">=0.4.0 <0.5.0"); + expect(manifest.dependencies["@askrjs/askr"], template).toBe(">=0.5.0 <0.6.0"); if (manifest.dependencies["@askrjs/themes"]) { - expect(manifest.dependencies["@askrjs/themes"], template).toBe(">=0.4.0 <0.5.0"); + expect(manifest.dependencies["@askrjs/themes"], template).toBe(">=0.5.0 <0.6.0"); } if (manifest.dependencies["@askrjs/ui"]) { - expect(manifest.dependencies["@askrjs/ui"], template).toBe(">=0.4.0 <0.5.0"); + expect(manifest.dependencies["@askrjs/ui"], template).toBe(">=0.5.0 <0.6.0"); } if (manifest.dependencies["@askrjs/auth"]) { - expect(manifest.dependencies["@askrjs/auth"], template).toBe(">=0.4.0 <0.5.0"); + expect(manifest.dependencies["@askrjs/auth"], template).toBe(">=0.5.0 <0.6.0"); } } }); @@ -834,7 +834,7 @@ test("should ensure runAddCli transactionally scaffolds both database dialects", }; expect(definition).toContain(`from '@askrjs/orm/${dialect}'`); expect(definition).toContain(`driver: ${dialect}()`); - expect(manifest.dependencies["@askrjs/orm"]).toBe(">=0.4.0 <0.5.0"); + expect(manifest.dependencies["@askrjs/orm"]).toBe(">=0.5.0 <0.6.0"); expect(manifest.dependencies.pg).toBe(dialect === "postgres" ? "^8.16.0" : undefined); await expect( fs.access(path.join(tempRoot, "src", "database", "migrations", ".gitkeep")), @@ -861,7 +861,7 @@ test("should ensure concurrent database generators report one complete winner", expect(definition.includes("sqlite()") || definition.includes("postgres()")).toBe(true); expect( JSON.parse(await fs.readFile(path.join(tempRoot, "package.json"), "utf8")).dependencies, - ).toHaveProperty("@askrjs/orm", ">=0.4.0 <0.5.0"); + ).toHaveProperty("@askrjs/orm", ">=0.5.0 <0.6.0"); } finally { await fs.rm(tempRoot, { recursive: true, force: true }); } diff --git a/tests/helpers/owned-command.ts b/tests/helpers/owned-command.ts new file mode 100644 index 0000000..30dc1ba --- /dev/null +++ b/tests/helpers/owned-command.ts @@ -0,0 +1,300 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export interface OwnedOptions { + command: string; + args: string[]; + requested?: { command: string; args: string[] }; + cwd: string; + evidence: string; + label: string; + timeoutMs: number; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; +} +export interface OwnedReceipt { + command: string; + args: string[]; + requested?: { command: string; args: string[] }; + cwd: string; + pid?: number; + startedAt: string; + elapsedMs: number; + code: number | null; + signal: NodeJS.Signals | null; + failure?: string; + childClosed: boolean; + cleanup: { + required: boolean; + treeReaped: boolean; + unprovenReason?: string; + method?: string; + terminatorCode?: number | null; + terminatorClosed?: boolean; + }; + observedBytes: { stdout: number; stderr: number }; + stdout: string; + stderr: string; +} + +// Packaging commands own their child process trees. Unix descendants share a detached +// process group. Windows taskkill must target the still-live owned wrapper; +// an earlier wrapper exit cannot prove tree cleanup and is a failed receipt. +export function startOwnedCommand(options: OwnedOptions) { + if (!path.isAbsolute(options.cwd) || !path.isAbsolute(options.evidence)) + throw new Error("Owned command paths must be absolute"); + if (!/^[a-z0-9-]+$/.test(options.label)) throw new Error("Invalid owned command label"); + if ( + !Number.isSafeInteger(options.timeoutMs) || + options.timeoutMs <= 0 || + options.timeoutMs > 600000 + ) + throw new Error("Invalid owned command deadline"); + if (options.signal?.aborted) throw new Error("Owned command case already aborted"); + const startedAt = new Date().toISOString(); + const started = performance.now(); + const child = spawn(options.command, options.args, { + cwd: options.cwd, + env: options.env ?? process.env, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + const chunks: Record<"stdout" | "stderr", Buffer[]> = { stdout: [], stderr: [] }; + const sizes = { stdout: 0, stderr: 0 }; + const reasons: string[] = []; + const cleanup: OwnedReceipt["cleanup"] = { required: false, treeReaped: false }; + let childClosed = false; + let wrapperExited = false; + let code: number | null = null; + let signal: NodeJS.Signals | null = null; + let settled = false; + let stopping = false; + let killer: ChildProcess | undefined; + let deadline: ReturnType | undefined; + let cleanupTimer: ReturnType | undefined; + let finalTimer: ReturnType | undefined; + let reapTimer: ReturnType | undefined; + let cleanupUntil: number | undefined; + let resolveClosed!: (receipt: OwnedReceipt) => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const fail = (reason: string) => { + if (!reasons.includes(reason)) reasons.push(reason); + }; + const transcript = (stream: "stdout" | "stderr") => + Buffer.concat(chunks[stream]).toString("utf8"); + const groupGone = () => { + if (!child.pid) return true; + try { + process.kill(-child.pid, 0); + return false; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return true; + fail(`Owned group observation failed: ${String(error)}`); + return false; + } + }; + const aborted = () => { + void stop("Owned packaging case deadline/cancellation"); + }; + const finish = async () => { + if (settled) return; + settled = true; + for (const timer of [deadline, cleanupTimer, finalTimer, reapTimer]) + if (timer) clearTimeout(timer); + options.signal?.removeEventListener("abort", aborted); + child.stdout?.destroy(); + child.stderr?.destroy(); + if (killer && !cleanup.terminatorClosed) { + killer.kill("SIGKILL"); + cleanup.unprovenReason ??= "Owned taskkill child close was not observed"; + fail(cleanup.unprovenReason); + } + const receipt: OwnedReceipt = { + command: options.command, + args: options.args, + requested: options.requested, + cwd: options.cwd, + pid: child.pid, + startedAt, + elapsedMs: performance.now() - started, + code, + signal, + failure: reasons.length ? reasons.join("; ") : undefined, + childClosed, + cleanup: { ...cleanup }, + observedBytes: { ...sizes }, + stdout: transcript("stdout"), + stderr: transcript("stderr"), + }; + try { + await fs.writeFile( + path.join(options.evidence, `${options.label}.stdout.log`), + Buffer.concat(chunks.stdout), + { flag: "wx" }, + ); + await fs.writeFile( + path.join(options.evidence, `${options.label}.stderr.log`), + Buffer.concat(chunks.stderr), + { flag: "wx" }, + ); + await fs.writeFile( + path.join(options.evidence, `${options.label}.json`), + `${JSON.stringify(receipt, null, 2)}\n`, + { flag: "wx" }, + ); + } catch (error) { + receipt.failure = `${receipt.failure ?? ""}; Owned transcript persistence failed: ${String(error)}`; + } + resolveClosed(receipt); + }; + const observeReap = () => { + if (settled || !childClosed || (killer && !cleanup.terminatorClosed)) return; + if (cleanupUntil !== undefined && performance.now() > cleanupUntil) + fail("Owned cleanup observation exceeded its absolute 5000ms deadline"); + if (process.platform !== "win32") cleanup.treeReaped = groupGone(); + else if (cleanup.required) + cleanup.treeReaped = + cleanup.terminatorClosed === true && + cleanup.terminatorCode === 0 && + !cleanup.unprovenReason; + // A normal Windows command close is recorded, not relabelled as an observed + // descendant-tree termination. Error/timeout/preview-stop requires taskkill. + if (!cleanup.required || cleanup.treeReaped || cleanup.unprovenReason) void finish(); + else reapTimer = setTimeout(observeReap, 10); + }; + const terminateTree = () => { + if (!child.pid) { + cleanup.treeReaped = childClosed; + observeReap(); + return; + } + if (process.platform === "win32") { + if (killer) return; + if (wrapperExited || child.exitCode !== null || child.signalCode !== null || childClosed) { + cleanup.unprovenReason = "Windows wrapper exited before owned tree termination"; + fail(cleanup.unprovenReason); + observeReap(); + return; + } + cleanup.method = "taskkill.exe /PID /T /F"; + killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + killer.once("error", (error) => { + fail(`Owned tree termination failed: ${error.message}`); + }); + killer.once("close", (status) => { + cleanup.terminatorClosed = true; + cleanup.terminatorCode = status; + if (status !== 0) { + cleanup.unprovenReason = "Owned Windows tree termination failed"; + fail(cleanup.unprovenReason); + } + observeReap(); + }); + } else { + cleanup.method = "SIGKILL owned detached process group"; + try { + process.kill(-child.pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") fail(String(error)); + } + observeReap(); + } + }; + const stop = (reason?: string) => { + if (reason) fail(reason); + if (settled || stopping) return closed; + stopping = true; + cleanup.required = true; + cleanupUntil = performance.now() + 5000; + if (deadline) clearTimeout(deadline); + cleanupTimer = setTimeout(() => { + fail("Owned command did not reap within 5000ms"); + terminateTree(); + finalTimer = setTimeout(() => { + fail("Owned command final reap not observed"); + cleanup.unprovenReason ??= "Owned command final reap not observed"; + fail(cleanup.unprovenReason); + void finish(); + }, 1000); + }, 5000); + if (reason || process.platform === "win32") terminateTree(); + else if (child.pid) { + cleanup.method = "SIGTERM owned detached process group"; + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") fail(String(error)); + } + } + observeReap(); + return closed; + }; + for (const stream of ["stdout", "stderr"] as const) { + child[stream]!.on("data", (chunk: Buffer) => { + const remaining = 20 * 1024 * 1024 - sizes[stream]; + sizes[stream] += chunk.length; + if (remaining > 0) chunks[stream].push(Buffer.from(chunk.subarray(0, remaining))); + if (sizes[stream] > 20 * 1024 * 1024) + void stop(`Owned ${stream} exceeded 20MiB capture bound`); + }); + child[stream]!.once("error", (error) => { + void stop(`Owned ${stream} failed: ${error.message}`); + }); + } + child.once("error", (error) => { + fail(error.message); + if (!child.pid) { + childClosed = true; + void finish(); + } else void stop(error.message); + }); + child.once("exit", () => { + wrapperExited = true; + }); + child.once("close", (status, exitSignal) => { + childClosed = true; + code = status; + signal = exitSignal; + if (!stopping && performance.now() - started > options.timeoutMs) + void stop("Owned command close exceeded its absolute spawn deadline"); + if (!stopping && status !== 0) void stop(`Owned command exited ${status ?? exitSignal}`); + if (!stopping && status === 0) { + if (process.platform === "win32" || groupGone()) void finish(); + else void stop("Owned command left descendants after normal exit"); + } else observeReap(); + }); + deadline = setTimeout( + () => { + void stop("Owned command exceeded its absolute spawn deadline"); + }, + Math.max(1, options.timeoutMs - (performance.now() - started)), + ); + options.signal?.addEventListener("abort", aborted, { once: true }); + if (options.signal?.aborted) aborted(); + return { + child, + closed, + stop, + stdout: () => transcript("stdout"), + stderr: () => transcript("stderr"), + }; +} + +export async function runOwnedCommand(options: OwnedOptions): Promise { + const receipt = await startOwnedCommand(options).closed; + if ( + receipt.failure || + receipt.code !== 0 || + receipt.signal || + !receipt.childClosed || + (receipt.cleanup.required && !receipt.cleanup.treeReaped) + ) + throw new Error( + `Owned command failed: ${options.label}; ${receipt.failure ?? receipt.signal ?? receipt.code}. See ${options.evidence}`, + { cause: receipt }, + ); + return receipt.stdout; +} diff --git a/tests/integration/packed-templates.test.ts b/tests/integration/packed-templates.test.ts index c9221f6..32cf858 100644 --- a/tests/integration/packed-templates.test.ts +++ b/tests/integration/packed-templates.test.ts @@ -1,42 +1,42 @@ -import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; import { expect, test } from "vite-plus/test"; -const execFileAsync = promisify(execFile); +import { runOwnedCommand } from "../helpers/owned-command"; + +interface PackagingContext { + evidence: string; + signal: AbortSignal; + nextCommand: number; +} const repository = fileURLToPath(new URL("../../", import.meta.url)); const allTemplates = ["full-stack", "spa", "ssr", "ssg", "startkit"]; -async function run(command: string, args: string[], cwd: string): Promise { - try { - const { stdout } = await execFileAsync(command, args, { - cwd, - env: { ...process.env, NO_COLOR: "1" }, - maxBuffer: 20 * 1024 * 1024, - }); - return stdout; - } catch (cause) { - // execFile only reports "Command failed: ", which hides which template - // check broke and why. Surface the child output with the failure. - const { stdout, stderr } = cause as { stdout?: string; stderr?: string }; - throw new Error( - [ - `${command} ${args.join(" ")} failed in ${cwd}`, - stdout ? `stdout:\n${stdout}` : "", - stderr ? `stderr:\n${stderr}` : "", - ] - .filter(Boolean) - .join("\n"), - { cause }, - ); - } +async function run( + command: string, + args: string[], + cwd: string, + context: PackagingContext, +): Promise { + const npmEntry = process.env.npm_execpath; + if (command === "npm" && !npmEntry) throw new Error("Run packaging tests through npm."); + return runOwnedCommand({ + command: command === "npm" ? process.execPath : command, + args: command === "npm" ? [npmEntry!, ...args] : args, + requested: { command, args }, + cwd, + evidence: context.evidence, + label: `command-${++context.nextCommand}`, + timeoutMs: 180_000, + signal: context.signal, + env: { ...process.env, NO_COLOR: "1" }, + }); } -async function checkScopedSsgJsx(project: string): Promise { +async function checkScopedSsgJsx(project: string, context: PackagingContext): Promise { await fs.copyFile( path.join(repository, "tests", "types", "ssg-scoped-jsx.tsx"), path.join(project, "src", "scoped-jsx-contract.tsx"), @@ -69,35 +69,46 @@ async function checkScopedSsgJsx(project: string): Promise { const bin = manifest.bin[compiler.major === 6 ? "tsc6" : "tsc"]; expect(bin).toBeTypeOf("string"); const entry = path.resolve(path.dirname(manifestPath), bin!); - const version = await run(process.execPath, [entry, "--version"], project); + const version = await run(process.execPath, [entry, "--version"], project, context); expect(version).toMatch(new RegExp(`^Version ${compiler.major}\\.`)); for (const jsx of ["react-jsx", "react-jsxdev"]) { await run( process.execPath, [entry, "--project", "tsconfig.scoped-jsx.json", "--jsx", jsx, "--pretty", "false"], project, + context, ); } } } -test("should ensure packed CLI passes checks for every shipped template", async () => { +test("should ensure packed CLI passes checks for every shipped template", async ({ signal }) => { const selected = process.env.ASKR_TEMPLATE ? allTemplates.filter((template) => template === process.env.ASKR_TEMPLATE) : allTemplates; expect(selected, `Unknown ASKR_TEMPLATE: ${process.env.ASKR_TEMPLATE}`).not.toHaveLength(0); const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-packed-templates-")); + const context: PackagingContext = { + evidence: await fs.mkdtemp(path.join(os.tmpdir(), "askr-packed-templates-evidence-")), + // Observed cold installs take up to 106s. Five ordinary consumer installs + // and checks get 580s, leaving 20s for owned cancellation before Vitest's bound. + signal: AbortSignal.any([signal, AbortSignal.timeout(580_000)]), + nextCommand: 0, + }; + console.info(`Packed template command evidence: ${context.evidence}`); try { - await run("npm", ["run", "build"], repository); + await run("npm", ["run", "build"], repository, context); const packed = await run( "npm", ["pack", "--ignore-scripts", "--pack-destination", root], repository, + context, ); const archive = path.join(root, packed.trim().split(/\r?\n/).at(-1)!); for (const template of selected) { + console.info(`Checking packed template: ${template}`); const fixtureRoot = path.join(root, template); const project = path.join(fixtureRoot, `fixture-${template}`); await fs.mkdir(fixtureRoot, { recursive: true }); @@ -114,6 +125,7 @@ test("should ensure packed CLI passes checks for every shipped template", async "--no-skills", ], fixtureRoot, + context, ); const manifestPath = path.join(project, "package.json"); @@ -125,11 +137,11 @@ test("should ensure packed CLI passes checks for every shipped template", async "@askrjs/cli": `file:${archive}`, }; await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - await run("npm", ["install", "--no-audit", "--no-fund"], project); - await run("npm", ["run", "check"], project); + await run("npm", ["install", "--no-audit", "--no-fund"], project, context); + await run("npm", ["run", "check"], project, context); if (template === "ssg") { - await checkScopedSsgJsx(project); + await checkScopedSsgJsx(project, context); for (const relative of ["sitemap.xml", "robots.txt", ".askr/sitemap-manifest.json"]) { await fs.access(path.join(project, "dist", relative)); } @@ -138,4 +150,4 @@ test("should ensure packed CLI passes checks for every shipped template", async } finally { await fs.rm(root, { recursive: true, force: true }); } -}, 180_000); +}, 600_000); diff --git a/tests/integration/peer-floor.test.ts b/tests/integration/peer-floor.test.ts index 858b11c..1bd31ec 100644 --- a/tests/integration/peer-floor.test.ts +++ b/tests/integration/peer-floor.test.ts @@ -51,7 +51,7 @@ test("should ensure packed CLI works with the minimum supported Askr peer", asyn private: true, type: "module", dependencies: { - "@askrjs/askr": "0.4.0", + "@askrjs/askr": "0.5.0", "@askrjs/cli": `file:${archive}`, }, }, diff --git a/tests/integration/registry-database.test.ts b/tests/integration/registry-database.test.ts index fd74689..a468c45 100644 --- a/tests/integration/registry-database.test.ts +++ b/tests/integration/registry-database.test.ts @@ -35,7 +35,7 @@ async function installRegistryArtifacts(root: string, versions: readonly string[ test("should validate a database using only public registry artifacts", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-cli-registry-database-")); const cliVersion = process.env.ASKR_CLI_REGISTRY_VERSION ?? manifest.version; - const ormVersion = process.env.ASKR_ORM_REGISTRY_VERSION ?? "0.4.0"; + const ormVersion = process.env.ASKR_ORM_REGISTRY_VERSION ?? "0.5.0"; try { await fs.writeFile( path.join(root, "package.json"), diff --git a/tests/owned-command.test.ts b/tests/owned-command.test.ts new file mode 100644 index 0000000..4713eb9 --- /dev/null +++ b/tests/owned-command.test.ts @@ -0,0 +1,192 @@ +import { EventEmitter } from "node:events"; +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import { startOwnedCommand, runOwnedCommand } from "./helpers/owned-command"; + +vi.mock("node:child_process", () => ({ spawn: vi.fn() })); +vi.mock("node:fs/promises", () => ({ default: { writeFile: vi.fn(async () => undefined) } })); +const platform = Object.getOwnPropertyDescriptor(process, "platform")!; +function child(pid: number) { + return Object.assign(new EventEmitter(), { + pid, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }); +} +function close( + value: ReturnType, + code: number | null, + signal: NodeJS.Signals | null = null, +) { + value.exitCode = code; + value.signalCode = signal; + value.emit("exit", code, signal); + value.emit("close", code, signal); +} +function setup(os: "win32" | "linux" = "linux") { + Object.defineProperty(process, "platform", { ...platform, value: os }); + const main = child(12345), + killer = child(23456); + vi.mocked(spawn) + .mockReturnValueOnce(main as unknown as ReturnType) + .mockReturnValueOnce(killer as unknown as ReturnType); + let alive = true; + vi.spyOn(process, "kill").mockImplementation(() => { + if (!alive) throw Object.assign(new Error("absent group"), { code: "ESRCH" }); + return true; + }); + const options = { + command: "owned-node", + args: ["literal argument"], + cwd: path.resolve("owned"), + evidence: path.resolve("evidence"), + label: "control", + timeoutMs: 100, + }; + return { + main, + killer, + options, + absent: () => { + alive = false; + }, + }; +} +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(spawn).mockReset(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + Object.defineProperty(process, "platform", platform); +}); + +test("owned command retains literal argv, stdout, stderr and normal exit", async () => { + const f = setup(), + running = startOwnedCommand(f.options); + f.main.stdout.write("owned stdout"); + f.main.stderr.write("owned stderr"); + f.absent(); + close(f.main, 0); + const receipt = await running.closed; + expect(receipt.failure).toBeUndefined(); + expect(receipt.code).toBe(0); + expect(receipt.childClosed).toBe(true); + expect(receipt.stdout).toBe("owned stdout"); + expect(receipt.stderr).toBe("owned stderr"); + expect(vi.mocked(spawn).mock.calls[0]).toEqual([ + "owned-node", + ["literal argument"], + expect.objectContaining({ detached: true, stdio: ["ignore", "pipe", "pipe"] }), + ]); + expect(fs.writeFile).toHaveBeenCalledTimes(3); +}); +test("absolute timeout kills and observes the Unix group but remains a failure", async () => { + const f = setup(), + running = startOwnedCommand(f.options); + await vi.advanceTimersByTimeAsync(100); + expect(process.kill).toHaveBeenCalledWith(-12345, "SIGKILL"); + f.absent(); + close(f.main, null, "SIGKILL"); + const receipt = await running.closed; + expect(receipt.failure).toContain("absolute spawn deadline"); + expect(receipt.cleanup.treeReaped).toBe(true); +}); +test("preview stop must observe the group within the unchanged five-second bound", async () => { + const f = setup(), + running = startOwnedCommand(f.options); + void running.stop(); + expect(process.kill).toHaveBeenCalledWith(-12345, "SIGTERM"); + await vi.advanceTimersByTimeAsync(5000); + expect(process.kill).toHaveBeenCalledWith(-12345, "SIGKILL"); + f.absent(); + close(f.main, null, "SIGKILL"); + expect((await running.closed).failure).toContain("did not reap within 5000ms"); +}); +test("Windows timeout taskkills the still-live owned wrapper and awaits both closes", async () => { + const f = setup("win32"), + running = startOwnedCommand(f.options); + await vi.advanceTimersByTimeAsync(100); + expect(vi.mocked(spawn).mock.calls[1]).toEqual([ + "taskkill.exe", + ["/PID", "12345", "/T", "/F"], + { stdio: "ignore" }, + ]); + close(f.main, 1); + close(f.killer, 0); + const receipt = await running.closed; + expect(receipt.failure).toContain("absolute spawn deadline"); + expect(receipt.cleanup).toMatchObject({ + treeReaped: true, + terminatorClosed: true, + terminatorCode: 0, + }); +}); +test("an exited Windows wrapper cannot be reused as tree-cleanup authority", async () => { + const f = setup("win32"), + running = startOwnedCommand(f.options); + close(f.main, 1); + const receipt = await running.closed; + expect(receipt.cleanup.treeReaped).toBe(false); + expect(receipt.failure).toContain("wrapper exited before owned tree termination"); + expect(spawn).toHaveBeenCalledTimes(1); +}); +test("case cancellation owns cleanup independently of the test runner timeout", async () => { + const f = setup(), + controller = new AbortController(); + const running = startOwnedCommand({ ...f.options, signal: controller.signal }); + controller.abort(); + expect(process.kill).toHaveBeenCalledWith(-12345, "SIGKILL"); + f.absent(); + close(f.main, null, "SIGKILL"); + expect((await running.closed).failure).toContain("case deadline/cancellation"); +}); +test("missing final close is a bounded failed receipt, never inferred cleanup", async () => { + const f = setup("win32"), + running = startOwnedCommand(f.options); + f.main.exitCode = 1; + f.main.emit("exit", 1, null); + await vi.advanceTimersByTimeAsync(6100); + const receipt = await running.closed; + expect(receipt.childClosed).toBe(false); + expect(receipt.cleanup.treeReaped).toBe(false); + expect(receipt.failure).toContain("final reap not observed"); + expect(spawn).toHaveBeenCalledTimes(1); +}); +test("run rejects a nonzero command after awaited cleanup with its raw receipt", async () => { + const f = setup(); + const result = runOwnedCommand(f.options).catch((error: Error) => error); + f.absent(); + close(f.main, 7); + const error = await result; + expect(error).toBeInstanceOf(Error); + expect((error as Error).cause).toMatchObject({ + code: 7, + childClosed: true, + cleanup: { treeReaped: true }, + }); +}); + +test("raw command logs preserve invalid UTF-8 bytes while JSON retains a decoded summary", async () => { + const f = setup(), + running = startOwnedCommand(f.options); + const bytes = Buffer.from([0xff, 0xc3, 0x28]); + f.main.stdout.write(bytes); + f.main.stderr.write(bytes); + f.absent(); + close(f.main, 0); + const receipt = await running.closed; + expect(receipt.failure).toBeUndefined(); + expect(receipt.observedBytes).toEqual({ stdout: 3, stderr: 3 }); + expect(vi.mocked(fs.writeFile).mock.calls[0]![1]).toEqual(bytes); + expect(vi.mocked(fs.writeFile).mock.calls[1]![1]).toEqual(bytes); + expect(receipt.stdout).toBe(bytes.toString("utf8")); +});