From 9d363290883a183291e53a2cfe60339c391e658f Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Mon, 12 Oct 2026 21:58:30 -0400 Subject: [PATCH 1/2] chore(release): prepare CLI 0.5.0 candidate graph --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 11 + docs/provider-auth.md | 5 + package-lock.json | 126 ++++---- package.json | 24 +- src/bin/add.ts | 2 +- src/generate/auth.ts | 24 +- templates/full-stack/package.json | 20 +- templates/full-stack/server.ts | 12 +- templates/spa/package.json | 12 +- templates/ssg/package.json | 10 +- templates/ssr/README.md | 4 + templates/ssr/package.json | 12 +- templates/ssr/server.ts | 15 +- templates/startkit/package.json | 12 +- tests/cli-smoke.test.ts | 26 +- tests/helpers/owned-command.ts | 300 ++++++++++++++++++++ tests/integration/packed-templates.test.ts | 80 +++--- tests/integration/peer-floor.test.ts | 2 +- tests/integration/registry-database.test.ts | 2 +- tests/owned-command.test.ts | 192 +++++++++++++ tests/provider-auth-generator.test.ts | 76 +++++ 22 files changed, 809 insertions(+), 160 deletions(-) create mode 100644 tests/helpers/owned-command.ts create mode 100644 tests/owned-command.test.ts 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 a3d4b90..d2d50c4 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,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Preserve the configured HTTPS origin and canonical host in generated + provider servers, and include Node types when provider setup adds server code. +- Resolve production assets from the built server location while excluding + server bundles and Vite manifests from public asset responses. + - Dispose rejected or redirected OpenAPI response bodies, including header validation and redirect-limit failures. Stop streaming unused bodies after reporting an error. Keep normal complete responses and load limits unchanged. diff --git a/docs/provider-auth.md b/docs/provider-auth.md index d283862..b499343 100644 --- a/docs/provider-auth.md +++ b/docs/provider-auth.md @@ -87,3 +87,8 @@ explicit operations, subject to the provider's capabilities. After filling server configuration, run the generated Vite+ type, lint, test, and build commands. Release qualification uses simulated signed providers; live provider-account compatibility remains a separate check. + +The production launcher passes `APP_ORIGIN` to the Node transport as its canonical +base URL and allowed host, including an explicit port. Set it to the same HTTPS +origin used by the provider session configuration. Generated provider projects +include Node types for their server code. diff --git a/package-lock.json b/package-lock.json index 1a9ba8b..6395f53 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-VaMVh23Qj/pik7j1y57t0JzKpKhYq8zO+0UmVfzleDVNme73pI/C0iNOYtJuaYYaAV5+fsSCUpvX7TPgeCuYSA==", "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-2quLsDz8mH/6eyKSdcKLJcJQFPcNU2P5H8lS56/IzMzPOt3eihO17e1W5DDnVNMYbNaGVc5FJwWj9Snz0++ygw==", "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-HnsI+53V/JPWiEuPk954VTLhHHf+jK0in8Mvq2FKu+fBsaUhJENRiray8BC9LMex5Lvt+czX1XvAF/f4RdBpag==", "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-JMciK1KmJJ7Jy/GY+ph3ibFf/75qFA1HXMuC0tI/QMxnqpVTbIY694AXk9KDODrY1qm8WaF9ktpjKlY7ciENgA==", "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-qqR1q8cdl0w8ayXRh45WvEmCgk3WPAcOCeNcOGPm9ymCY7iO5aNjrwxz/hJ9fdrAB1vAGb3FQF5K/uddciZphw==", "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-Q3yvvcah8rp9iWEq7H0uQHU4to4uLx9lT5B8GpXz43qh2i9UVtxjrpJ5rdPWfNiIPCYEkqjDnhpT3JOlMnMOsg==", "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-7V7rS+oF8so2O0Kjr3CQUKtYUJIogP5taogHJTu9kqFrjfuBNpI9aMZkQcS1FwsQnJ9hHOVK1OE0JyDmZ6A92g==", "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/src/generate/auth.ts b/src/generate/auth.ts index eaf2bdf..b1dbef4 100644 --- a/src/generate/auth.ts +++ b/src/generate/auth.ts @@ -173,6 +173,17 @@ function providerFile(provider: Provider): string { ].join("\n"); } +function withProviderOrigin(content: string): string { + if (content.split("const port =").length !== 2 || content.split(" port,").length !== 2) + throw new Error("The owned server template requires explicit provider origin wiring."); + return ( + 'import { requiredEnv } from "./src/auth/env";\n' + + content + .replace("const port =", 'const origin = requiredEnv("APP_ORIGIN");\nconst port =') + .replace(" port,", " port,\n baseUrl: origin,\n allowedHosts: [new URL(origin).host],") + ); +} + function generated(state: AuthState): Record { return { ...Object.fromEntries( @@ -458,7 +469,9 @@ export async function planProviderAuth( ); await append( "server.ts", - await fs.readFile(path.join(await findTemplateDir("ssr"), "server.ts"), "utf8"), + withProviderOrigin( + await fs.readFile(path.join(await findTemplateDir("ssr"), "server.ts"), "utf8"), + ), ); await replace("src/main.tsx", (content) => content @@ -487,6 +500,15 @@ export async function planProviderAuth( .replace("build: {", "build: {\n manifest: true,"), ); } + if (template === "ssr" || template === "full-stack") { + await replace("server.ts", withProviderOrigin); + } + await replace("tsconfig.json", (content) => { + const config = JSON.parse(content); + if (config.compilerOptions.types.includes("node")) return content; + config.compilerOptions.types.push("node"); + return `${JSON.stringify(config, null, 2)}\n`; + }); await replace("package.json", (content) => { const manifest = JSON.parse(content); const range = manifest.dependencies["@askrjs/askr"]; 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/full-stack/server.ts b/templates/full-stack/server.ts index 389a04f..d786aaf 100644 --- a/templates/full-stack/server.ts +++ b/templates/full-stack/server.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url"; +import { posix } from "node:path"; import { serve } from "@askrjs/node"; import app from "virtual:askr-server"; @@ -5,5 +7,13 @@ const port = Number(process.env.PORT ?? 3000); await serve(app, { host: process.env.HOST ?? "127.0.0.1", port, - assets: { root: new URL("./dist", import.meta.url).pathname }, + assets: { + root: fileURLToPath(new URL("../", import.meta.url)), + exclude: (pathname) => { + const normalized = posix.normalize(pathname.replaceAll("\\", "/")).toLowerCase(); + return ["/server", "/.vite"].some( + (directory) => normalized === directory || normalized.startsWith(`${directory}/`), + ); + }, + }, }); 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/README.md b/templates/ssr/README.md index 64b3215..432dbdc 100644 --- a/templates/ssr/README.md +++ b/templates/ssr/README.md @@ -36,3 +36,7 @@ src/ ``` The application source does not construct document markup or expose a `render(url)` callback. Document ownership stays in `index.html` and `@askrjs/vite/server`. + +The production launcher runs from `dist/server` and serves browser assets from +`dist`. Server bundles under `dist/server` and Vite manifests under `dist/.vite` +are excluded from public asset responses. 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/ssr/server.ts b/templates/ssr/server.ts index a78535c..4d0be7c 100644 --- a/templates/ssr/server.ts +++ b/templates/ssr/server.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'node:url'; +import { posix } from 'node:path'; import { serve } from '@askrjs/node'; import app from 'virtual:askr-server'; @@ -5,6 +7,17 @@ const port = Number(process.env.PORT ?? 3000); const running = await serve(app, { host: process.env.HOST ?? '127.0.0.1', port, - assets: { root: new URL('./dist', import.meta.url).pathname }, + assets: { + root: fileURLToPath(new URL('../', import.meta.url)), + exclude: (pathname) => { + const normalized = posix + .normalize(pathname.replaceAll('\\', '/')) + .toLowerCase(); + return ['/server', '/.vite'].some( + (directory) => + normalized === directory || normalized.startsWith(`${directory}/`) + ); + }, + }, }); console.log(`Server started at ${running.url}`); 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")); +}); diff --git a/tests/provider-auth-generator.test.ts b/tests/provider-auth-generator.test.ts index 3b5d2bd..49c0491 100644 --- a/tests/provider-auth-generator.test.ts +++ b/tests/provider-auth-generator.test.ts @@ -1,12 +1,15 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import ts from "typescript"; import { afterEach, describe, expect, test } from "vite-plus/test"; import { runAddCli } from "../src/bin/add"; import { runCreateCli } from "../src/bin/create"; import { writeFileChanges } from "../src/file-changes"; +const execFileAsync = promisify(execFile); const roots: string[] = []; const providers = ["auth0", "microsoft", "google", "apple"] as const; const names = { @@ -330,3 +333,76 @@ test("refuses symlinked output ownership without touching the outside target", a expect(await fs.readdir(outside)).toEqual(["sentinel"]); expect(await fs.readFile(path.join(outside, "sentinel"), "utf8")).toBe("preserve outside bytes"); }); + +test.each(["ssr", "full-stack", "spa", "startkit"])( + "generated %s provider launcher preserves the canonical origin and built assets", + async (template) => { + const root = await scaffold(template); + expect( + await runAddCli( + ["auth", "google", "--cwd", root, "--origin", "https://app.example:8443"], + capture().io, + ), + ).toBe(0); + const files = await contents(root); + const config = JSON.parse(files["tsconfig.json"]); + expect(config.compilerOptions.types).toContain("node"); + expect(config.compilerOptions.types.filter((name: string) => name === "node")).toHaveLength(1); + + // Execute the generated launcher from its production build location. Only + // the external transport and Vite virtual app are replaced by fixture modules. + const output = path.join(root, "dist", "server"); + await fs.mkdir(output, { recursive: true }); + const privatePaths = [ + "/server", + "/server/server.js", + "/.vite/manifest.json", + "/SERVER/secret.js", + "/assets/../server/secret.js", + "\\server\\secret.js", + ]; + const publicPaths = [ + "/assets/main.js", + "/favicon.svg", + "/server-status", + "/.vite-public/logo.svg", + ]; + await fs.writeFile( + path.join(output, "transport.mjs"), + `import { writeFile } from "node:fs/promises"; +export async function serve(app, options) { + await writeFile(new URL("./result.json", import.meta.url), JSON.stringify({ + baseUrl: options.baseUrl, + allowedHosts: options.allowedHosts, + assetsRoot: options.assets.root, + exclusions: Object.fromEntries(${JSON.stringify([...privatePaths, ...publicPaths])}.map(pathname => [pathname, options.assets.exclude?.(pathname)])), + })); + return { url: "http://127.0.0.1" }; +}`, + ); + await fs.writeFile(path.join(output, "app.mjs"), "export default {};\n"); + const transpile = (source: string) => + ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, + }).outputText; + await fs.writeFile(path.join(output, "env.mjs"), transpile(files["src/auth/env.ts"])); + const source = files["server.ts"] + .replace(/(["'])@askrjs\/node\1/, '"./transport.mjs"') + .replace(/(["'])virtual:askr-server\1/, '"./app.mjs"') + .replace('"./src/auth/env"', '"./env.mjs"'); + await fs.writeFile(path.join(output, "server.mjs"), transpile(source)); + await execFileAsync(process.execPath, [path.join(output, "server.mjs")], { + env: { ...process.env, APP_ORIGIN: "https://app.example:8443", PORT: "0" }, + }); + const options = JSON.parse(await fs.readFile(path.join(output, "result.json"), "utf8")); + expect(options.baseUrl).toBe("https://app.example:8443"); + expect(options.allowedHosts).toEqual(["app.example:8443"]); + expect(await fs.realpath(options.assetsRoot)).toBe(await fs.realpath(path.join(root, "dist"))); + for (const pathname of privatePaths) { + expect(options.exclusions[pathname], pathname).toBe(true); + } + for (const pathname of publicPaths) { + expect(options.exclusions[pathname], pathname).toBe(false); + } + }, +); From ad8448bd70c4402ca3565332dbc0de4fa7109aab Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Wed, 14 Oct 2026 10:07:17 -0400 Subject: [PATCH 2/2] chore(release): refresh CLI 0.5.0 registry lockfile --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6395f53..d3c7028 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "node_modules/@askrjs/askr": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@askrjs/askr/-/askr-0.5.0.tgz", - "integrity": "sha512-VaMVh23Qj/pik7j1y57t0JzKpKhYq8zO+0UmVfzleDVNme73pI/C0iNOYtJuaYYaAV5+fsSCUpvX7TPgeCuYSA==", + "integrity": "sha512-UaMytwNNi+ZPW51G033odDTAqZ02I/mmHrh424YY3lRk3DxdRRWkpMJCDvwOIGXeG4XbvLhB6PyRADmAfu5rog==", "dev": true, "license": "Apache-2.0", "engines": { @@ -76,7 +76,7 @@ "node_modules/@askrjs/auth": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@askrjs/auth/-/auth-0.5.0.tgz", - "integrity": "sha512-2quLsDz8mH/6eyKSdcKLJcJQFPcNU2P5H8lS56/IzMzPOt3eihO17e1W5DDnVNMYbNaGVc5FJwWj9Snz0++ygw==", + "integrity": "sha512-yfHMNt2koSIP50w9WeaFTzOYvcJcqJKMx4Gb2nDSS4FqNc2i/cpqu2YqrAXFTmOw+yY8brNl60xHf7s6fuwYgg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -130,7 +130,7 @@ "node_modules/@askrjs/node": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@askrjs/node/-/node-0.5.0.tgz", - "integrity": "sha512-HnsI+53V/JPWiEuPk954VTLhHHf+jK0in8Mvq2FKu+fBsaUhJENRiray8BC9LMex5Lvt+czX1XvAF/f4RdBpag==", + "integrity": "sha512-5j+lLlMjC13Py8soE7J226DjPTW38HI0xVGKAgsKi/somlGiMzo6AuQnMqbxk9WebZwN6modHlzgwlww25Fpwg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -146,7 +146,7 @@ "node_modules/@askrjs/schema": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@askrjs/schema/-/schema-0.5.0.tgz", - "integrity": "sha512-JMciK1KmJJ7Jy/GY+ph3ibFf/75qFA1HXMuC0tI/QMxnqpVTbIY694AXk9KDODrY1qm8WaF9ktpjKlY7ciENgA==", + "integrity": "sha512-YSCBym9d92iM2JdageO9agmzAOrZj5K/uS8XE/hbq7F79no9BiSOf2ZyVAmccMZ59TfpeY85ZTIlMo0jIhsVFA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -178,7 +178,7 @@ "node_modules/@askrjs/themes": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@askrjs/themes/-/themes-0.5.0.tgz", - "integrity": "sha512-qqR1q8cdl0w8ayXRh45WvEmCgk3WPAcOCeNcOGPm9ymCY7iO5aNjrwxz/hJ9fdrAB1vAGb3FQF5K/uddciZphw==", + "integrity": "sha512-uxHT4n+ts3JWG3kfoQgVjLrcrxUVDal47+67KHKRrWVfhKDorIm4Ah3u1594xdnjTx4hI3nBASdKcS9LmLfGOQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -192,7 +192,7 @@ "node_modules/@askrjs/ui": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@askrjs/ui/-/ui-0.5.0.tgz", - "integrity": "sha512-Q3yvvcah8rp9iWEq7H0uQHU4to4uLx9lT5B8GpXz43qh2i9UVtxjrpJ5rdPWfNiIPCYEkqjDnhpT3JOlMnMOsg==", + "integrity": "sha512-Wzg5YShgKyYohoaxQwnJS6Jrva3lR8tLy92S4QIB0zZvFyltaTgbBGP8GPDJYb/2qrr6NZmuXrZRk82BPBB1qg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -205,7 +205,7 @@ "node_modules/@askrjs/vite": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@askrjs/vite/-/vite-0.5.0.tgz", - "integrity": "sha512-7V7rS+oF8so2O0Kjr3CQUKtYUJIogP5taogHJTu9kqFrjfuBNpI9aMZkQcS1FwsQnJ9hHOVK1OE0JyDmZ6A92g==", + "integrity": "sha512-OufgXhHO9a4/IkN3KXBMnvTs8iEfUT0CMFE8v3fyRTtRahzEXU0FoFiRIEWDrmuSmGOXYTs8U7p1gLwxu9pVBQ==", "dev": true, "license": "Apache-2.0", "dependencies": {