diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 679a824..3c85137 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -41,7 +41,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y shellcheck - name: Prepare browser checks - run: pnpm --filter @ykdz/template-builtin-source exec playwright install --with-deps chromium + run: pnpm --filter @ykdz/template-builtin-presets exec playwright install --with-deps chromium - name: Check package run: pnpm run check @@ -57,6 +57,9 @@ jobs: TEMPLATE_FIXTURE_REPLAY_CACHE_READ: "1" TEMPLATE_FIXTURE_REPLAY_CACHE_WRITE: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '1' || '0' }} + - name: Check deployment artifacts + run: pnpm run check:deployment + - name: Save fixture replay cache if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/cache/save@v6 diff --git a/.gitignore b/.gitignore index cc34750..aa7d627 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ CONTEXT.md packages/**/.turbo/ coverage target +.template-boundary-check +generated-repository diff --git a/Cargo.toml b/Cargo.toml index 45f31e8..3d57266 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ publish = false [[bin]] name = "template-cargo-dependencies" -path = "packages/builtin-source/templates/rust-bin/src/main.rs" +path = "packages/builtin-presets/templates/rust-bin/src/main.rs" [dependencies] anyhow = "1.0.100" diff --git a/package.json b/package.json index a3831d6..e3bc671 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,12 @@ "type": "module", "scripts": { "build": "turbo run build --output-logs=errors-only --log-order=grouped", - "check": "pnpm run build && pnpm run check:boundaries && turbo run format:check lint typecheck test check:generated check:templates format:check:root lint:root typecheck:root --output-logs=errors-only --log-order=grouped", + "check": "pnpm run build && pnpm run check:boundaries && turbo run format:check lint typecheck test check:generated check:templates check:templates:boundary check:templates:github-yaml format:check:root lint:root typecheck:root --output-logs=errors-only --log-order=grouped", "check:boundaries": "turbo boundaries --no-color", "check:fixtures": "turbo run check:fixtures --output-logs=errors-only --log-order=grouped", "check:format": "pnpm run format:check", "check:generated": "turbo run check:generated --output-logs=errors-only --log-order=grouped", + "check:deployment": "turbo run check:deployment --output-logs=errors-only --log-order=grouped", "check:lint": "pnpm run lint", "check:templates": "turbo run check:templates --output-logs=errors-only --log-order=grouped", "check:templates:boundary": "turbo run check:templates:boundary --output-logs=errors-only --log-order=grouped", @@ -21,20 +22,20 @@ "resolve:toolchain:update": "node packages/checks/src/update-toolchain-baseline.ts --print-workflow-outputs", "fix": "pnpm run build", "format:check": "turbo run format:check format:check:root --output-logs=errors-only --log-order=grouped", - "format:check:root": "oxfmt --list-different --config oxfmt.config.ts package.json pnpm-workspace.yaml turbo.json tsconfig.base.json tsconfig.build.json tsconfig.json vitest.config.ts oxfmt.config.ts oxlint.config.ts test packages/builtin-source/templates/*/behavior.test.ts", + "format:check:root": "oxfmt --list-different --config oxfmt.config.ts package.json pnpm-workspace.yaml turbo.json tsconfig.base.json tsconfig.build.json tsconfig.json vitest.config.ts oxfmt.config.ts oxlint.config.ts test packages/builtin-presets/src/*/behavior.test.ts", "format:write": "turbo run format:write format:write:root --output-logs=errors-only --log-order=grouped", - "format:write:root": "oxfmt --write --config oxfmt.config.ts package.json pnpm-workspace.yaml turbo.json tsconfig.base.json tsconfig.build.json tsconfig.json vitest.config.ts oxfmt.config.ts oxlint.config.ts test packages/builtin-source/templates/*/behavior.test.ts", + "format:write:root": "oxfmt --write --config oxfmt.config.ts package.json pnpm-workspace.yaml turbo.json tsconfig.base.json tsconfig.build.json tsconfig.json vitest.config.ts oxfmt.config.ts oxlint.config.ts test packages/builtin-presets/src/*/behavior.test.ts", "lint": "turbo run lint lint:root --output-logs=errors-only --log-order=grouped", "lint:fix": "turbo run lint:fix lint:fix:root --output-logs=errors-only --log-order=grouped", - "lint:fix:root": "oxlint --format=unix --config oxlint.config.ts oxlint.config.ts oxfmt.config.ts vitest.config.ts test packages/builtin-source/templates/*/behavior.test.ts --fix", - "lint:root": "oxlint --quiet --format=unix --config oxlint.config.ts oxlint.config.ts oxfmt.config.ts vitest.config.ts test packages/builtin-source/templates/*/behavior.test.ts", + "lint:fix:root": "oxlint --format=unix --config oxlint.config.ts oxlint.config.ts oxfmt.config.ts vitest.config.ts test packages/builtin-presets/src/*/behavior.test.ts --fix", + "lint:root": "oxlint --quiet --format=unix --config oxlint.config.ts oxlint.config.ts oxfmt.config.ts vitest.config.ts test packages/builtin-presets/src/*/behavior.test.ts", "test": "vitest run --reporter=agent --silent=passed-only", "typecheck": "turbo run typecheck typecheck:root --output-logs=errors-only --log-order=grouped", "typecheck:root": "tsc -p tsconfig.json --noEmit --pretty false" }, "devDependencies": { "@types/node": "catalog:", - "@ykdz/template-builtin-source": "workspace:*", + "@ykdz/template-builtin-presets": "workspace:*", "@ykdz/template-checks": "workspace:*", "@ykdz/template-core": "workspace:*", "@ykdz/template-shared": "workspace:*", @@ -52,5 +53,10 @@ "engines": { "node": ">=24.0.0" }, - "packageManager": "pnpm@11.11.0" + "packageManager": "pnpm@11.11.0", + "turbo": { + "tags": [ + "template-root" + ] + } } diff --git a/packages/builtin-presets/package.json b/packages/builtin-presets/package.json new file mode 100644 index 0000000..a67a9ce --- /dev/null +++ b/packages/builtin-presets/package.json @@ -0,0 +1,67 @@ +{ + "name": "@ykdz/template-builtin-presets", + "version": "0.0.0", + "private": true, + "files": [ + "dist", + "templates", + "!dist/src/check-*.js", + "!dist/src/check-*.d.ts", + "!dist/src/registry-checks.js", + "!dist/src/registry-checks.d.ts" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "check:templates": "pnpm run check:templates:boundary && node src/check-template-source.ts && node src/check-registry-contract.ts && node src/check-publication-source.ts", + "check:templates:boundary": "node src/check-template-boundary.ts", + "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern dist --ignore-pattern templates .", + "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts --ignore-pattern dist --ignore-pattern templates . --fix", + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" + }, + "dependencies": { + "@ykdz/template-core": "workspace:*", + "valibot": "catalog:" + }, + "devDependencies": { + "@hono/node-server": "catalog:", + "@playwright/test": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/web-bluetooth": "catalog:", + "@vikejs/hono": "catalog:", + "@vitejs/plugin-vue": "catalog:", + "@vue/tsconfig": "catalog:", + "drizzle-kit": "catalog:", + "drizzle-orm": "catalog:", + "execa": "catalog:", + "hono": "catalog:", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", + "pinia": "catalog:", + "tailwindcss": "catalog:", + "telefunc": "catalog:", + "typescript": "catalog:", + "typescript-7": "catalog:", + "vike": "catalog:", + "vike-vue": "catalog:", + "vite": "catalog:", + "vitest": "catalog:", + "vue": "catalog:", + "vue-tsc": "catalog:" + }, + "turbo": { + "tags": [ + "template-builtin-presets" + ] + } +} diff --git a/packages/builtin-presets/src/check-publication-source.ts b/packages/builtin-presets/src/check-publication-source.ts new file mode 100644 index 0000000..ee6a18b --- /dev/null +++ b/packages/builtin-presets/src/check-publication-source.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { execa } from "execa"; + +import { validatePlanPublicationSources } from "./registry-checks.ts"; + +/** Packs the Built-in Presets package and verifies plan-referenced source ships. */ +export async function checkPresetPublicationSources(): Promise { + const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ); + const destination = await mkdtemp( + path.join(tmpdir(), "template-builtin-pack-"), + ); + try { + await execa("pnpm", ["pack", "--pack-destination", destination], { + cwd: packageRoot, + }); + const archive = (await readdir(destination)).find((file) => + file.endsWith(".tgz"), + ); + if (archive === undefined) { + throw new Error("Built-in Presets pack produced no tarball"); + } + const contents = await execa("tar", [ + "-tf", + path.join(destination, archive), + ]); + validatePlanPublicationSources({ + packageRoot, + packedPaths: contents.stdout.split("\n").filter(Boolean), + }); + } finally { + await rm(destination, { recursive: true, force: true }); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await checkPresetPublicationSources(); +} diff --git a/packages/builtin-presets/src/check-registry-contract.ts b/packages/builtin-presets/src/check-registry-contract.ts new file mode 100644 index 0000000..b48ed91 --- /dev/null +++ b/packages/builtin-presets/src/check-registry-contract.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import { + deriveVerificationPlans, + discoverPresetLocalBehaviorTests, + validatePlanDependencyCatalog, + validatePlanSources, +} from "./registry-checks.ts"; + +/** Registry-only checks; Definition planners are the only catalog input. */ +export async function checkPresetRegistryContract(): Promise { + await discoverPresetLocalBehaviorTests(); + for (const { definition, plan } of deriveVerificationPlans()) { + await validatePlanSources({ definition, plan }); + validatePlanDependencyCatalog(plan); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await checkPresetRegistryContract(); +} diff --git a/packages/builtin-presets/src/check-template-boundary.ts b/packages/builtin-presets/src/check-template-boundary.ts new file mode 100644 index 0000000..d55b8d2 --- /dev/null +++ b/packages/builtin-presets/src/check-template-boundary.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { + checkTemplateSourceBoundary, + checkTemplateSourceContexts, +} from "@ykdz/template-core/template-boundary-check"; + +import { + builtInPresetTemplateSourceContexts, + type GeneratedRepositoryPlan, +} from "./foundation.ts"; +import { deriveVerificationPlans } from "./registry-checks.ts"; + +/** Checks every real registry initialization and Package Addition plan. */ +export async function checkBuiltInPresetTemplateBoundary(): Promise { + const result = await checkTemplateSourceBoundary({ + templateSourceContexts: await checkTemplateSourceContexts( + builtInPresetTemplateSourceContexts(), + ), + projections: deriveVerificationPlans().flatMap(({ definition, plan }) => { + const operationsByPlanner = new Map< + string, + GeneratedRepositoryPlan["operations"][number][] + >(); + for (const operation of plan.operations) { + const planner = + operation.provenance?.plannerSourceFile ?? plan.plannerSourceFile; + const operations = operationsByPlanner.get(planner) ?? []; + operations.push(operation); + operationsByPlanner.set(planner, operations); + } + return [...operationsByPlanner].map(([sourceFilePath, operations]) => ({ + name: `${definition.metadata.name}:${plan.planningContribution}:${sourceFilePath}`, + definitionName: definition.metadata.name, + planningContribution: plan.planningContribution, + sourceFilePath, + plan: { operations }, + })); + }), + }); + if (!result.ok) { + throw new Error( + [ + "Template Source Boundary violations:", + ...result.violations.map( + (violation) => + `- ${violation.owningFunction} generated ${violation.generatedPath} from ${violation.sourceFilePath}`, + ), + ].join("\n"), + ); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await checkBuiltInPresetTemplateBoundary(); +} diff --git a/packages/builtin-presets/src/check-template-source.ts b/packages/builtin-presets/src/check-template-source.ts new file mode 100644 index 0000000..540411a --- /dev/null +++ b/packages/builtin-presets/src/check-template-source.ts @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { renderNewProject } from "@ykdz/template-core/renderer"; +import type { TemplateSourceHandle } from "@ykdz/template-core/renderer"; +import { execa } from "execa"; + +import { + builtInPresetTemplateSourceCheckContexts, + resolveBuiltInTemplateSource, + type BuiltInPresetTemplateSourceCheckContext, +} from "./foundation.ts"; + +const formattedTemplateSourceFile = /\.(?:[cm]?[jt]sx?|vue|json|html|css)$/u; +const lintedTemplateSourceFile = /\.(?:[cm]?[jt]sx?|vue)$/u; + +function planTemplateSourceFiles( + context: BuiltInPresetTemplateSourceCheckContext, +): readonly string[] { + return context.plan.operations.flatMap((operation) => { + if ( + operation.kind !== "copyFile" && + operation.kind !== "writeTextTemplate" && + operation.kind !== "writeTextFromFragments" + ) { + return []; + } + const sourceFile = (source: TemplateSourceHandle, from: string): string => + resolveBuiltInTemplateSource(source, from); + if (operation.kind === "writeTextFromFragments") { + return operation.fragments.map((fragment) => + sourceFile(fragment.source!, fragment.from), + ); + } + return [sourceFile(operation.source!, operation.from)]; + }); +} + +async function checkContributionTemplateSource( + context: BuiltInPresetTemplateSourceCheckContext, +): Promise { + const sourceFiles = [...new Set(planTemplateSourceFiles(context))]; + for (const sourceFile of sourceFiles) { + if (!(await stat(sourceFile)).isFile()) { + throw new Error( + `${context.definition.metadata.name}: referenced Template Source is not a file: ${sourceFile}`, + ); + } + } + const formattedFiles = sourceFiles.filter((sourceFile) => + formattedTemplateSourceFile.test(sourceFile), + ); + const lintedFiles = sourceFiles.filter((sourceFile) => + lintedTemplateSourceFile.test(sourceFile), + ); + const rustSourceFiles = sourceFiles.filter((sourceFile) => + sourceFile.endsWith(".rs"), + ); + const packageDirectory = path.dirname(fileURLToPath(import.meta.url)); + const oxcConfigRoot = path.resolve(packageDirectory, "..", "..", ".."); + + if (formattedFiles.length > 0) { + await execa( + "pnpm", + [ + "exec", + "oxfmt", + "--list-different", + "--config", + path.join(oxcConfigRoot, "oxfmt.config.ts"), + ...formattedFiles, + ], + { cwd: packageDirectory }, + ); + } + if (lintedFiles.length > 0) { + await execa( + "pnpm", + [ + "exec", + "oxlint", + "--quiet", + "--format=unix", + "--config", + path.join(oxcConfigRoot, "oxlint.config.ts"), + ...lintedFiles, + ], + { cwd: packageDirectory }, + ); + } + if (rustSourceFiles.length > 0) { + await execa("rustfmt", ["--check", ...rustSourceFiles]); + } + + const checkRoot = await mkdtemp( + path.join( + tmpdir(), + `.template-${context.contribution.definition.path.replaceAll("/", "-")}-`, + ), + ); + try { + await renderNewProject({ + targetRoot: checkRoot, + operations: [...context.plan.operations], + }); + await execa("pnpm", ["install", "--ignore-scripts"], { cwd: checkRoot }); + await execa("pnpm", ["run", "typecheck:run"], { cwd: checkRoot }); + for (const definition of context.plan.blueprint.packages) { + const requiresTypecheck = context.plan.checks.some( + (check) => + check.owner.kind === "package-boundary" && + check.owner.path === definition.path && + check.kind === "typescript-typecheck", + ); + if (!requiresTypecheck) continue; + await execa( + "pnpm", + ["--filter", `./${definition.path}`, "run", "typecheck:run"], + { cwd: checkRoot }, + ); + } + } finally { + await rm(checkRoot, { recursive: true, force: true }); + } +} + +/** Checks every initial Package Contribution's directly owned Template Source. */ +export async function checkBuiltInPresetTemplateSource(): Promise { + const contexts = builtInPresetTemplateSourceCheckContexts(); + const checkedDefinitions = new Set(); + for (const context of contexts) { + if (checkedDefinitions.has(context.definition.metadata.name)) continue; + checkedDefinitions.add(context.definition.metadata.name); + await checkContributionTemplateSource(context); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await checkBuiltInPresetTemplateSource(); +} diff --git a/packages/builtin-presets/src/foundation.ts b/packages/builtin-presets/src/foundation.ts new file mode 100644 index 0000000..525eb75 --- /dev/null +++ b/packages/builtin-presets/src/foundation.ts @@ -0,0 +1,1109 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + collectGeneratedManifestCatalogReferences, + selectTemplateDependencyCatalogEntries, +} from "@ykdz/template-core/dependency-catalog"; +import { browserTestToolLayer } from "@ykdz/template-core/devcontainer"; +import { + editorCustomizationForCapabilities, + loadEditorCustomizationDeclarations, + type EditorCustomizationCapability, +} from "@ykdz/template-core/editor-customization"; +import type { + CheckComponent, + CheckEnvironmentNeed, + DeploymentCheckComponent, + FixComponent, +} from "@ykdz/template-core/module-graph"; +import { + checkComponentTaskName, + fixComponentTaskName, + playwrightBrowserAssetsEnvironmentNeed, + renderTurboRunCommand, + rustToolchainEnvironmentNeed, + shellCheckEnvironmentNeed, +} from "@ykdz/template-core/module-graph"; +import { + assertPackageContribution, + type PackageContribution, +} from "@ykdz/template-core/package-contribution"; +import type { + BuiltInPresetDefinition, + GenerationContext, +} from "@ykdz/template-core/preset-definition"; +import { + assertProjectBlueprintV2, + validateProjectBlueprintV2 as validateCoreProjectBlueprintV2, + type ProjectBlueprintV2, +} from "@ykdz/template-core/project-blueprint-v2"; +import type { + DependencyEcosystem, + DependencyMaintenancePolicy, +} from "@ykdz/template-core/project-github"; +import { + projectCheckWorkflowTemplateReplacements, + projectDependabotTemplateReplacements, +} from "@ykdz/template-core/project-github"; +import { planExplicitProjectLinks } from "@ykdz/template-core/project-linking-v2"; +import type { RenderOperation } from "@ykdz/template-core/renderer"; +import { + resolveTemplateSource, + type TemplateSourceHandle, +} from "@ykdz/template-core/renderer"; + +import { rustBinDefinition } from "./rust-bin/definition.ts"; +import { vuePnpmDependencyOverrides } from "./shared/vue.ts"; +import { templateSources } from "./template-sources.ts"; +import { tsLibDefinition } from "./ts-lib/definition.ts"; +import { vikeAppDefinition } from "./vike-app/definition.ts"; +import { vueAppDefinition } from "./vue-app/definition.ts"; +import { vueHonoAppDefinition } from "./vue-hono-app/definition.ts"; + +export type { + PackageDefinition, + PackageLinkIntent, + PackageRole, + ProjectBlueprintV2, +} from "@ykdz/template-core/project-blueprint-v2"; +export type { PackageContribution } from "@ykdz/template-core/package-contribution"; + +export type BuiltInGenerationContext = GenerationContext; +export type { BuiltInPresetDefinition } from "@ykdz/template-core/preset-definition"; + +export type NextStepInstruction = { + readonly display: string; +}; + +export type GeneratedRepositoryPlan = { + readonly definitionName: string; + readonly plannerSourceFile: string; + readonly planningContribution: "planInitialization" | "planPackageAddition"; + readonly blueprint: ProjectBlueprintV2; + readonly generationRecord: { + readonly preset: string; + readonly templateVersion: "0.0.0"; + readonly toolchain: BuiltInGenerationContext["toolchain"]; + }; + readonly operations: readonly RenderOperation[]; + readonly checks: readonly CheckComponent[]; + readonly fixes: readonly FixComponent[]; + readonly environmentNeeds: readonly CheckEnvironmentNeed[]; + readonly deploymentChecks: readonly DeploymentCheckComponent[]; + /** Structured manifests used to derive the generated Dependency Catalog. */ + readonly manifests: readonly Readonly>[]; + readonly dependencyCatalog: Readonly>; + readonly dependencyMaintenancePolicy: DependencyMaintenancePolicy; + readonly nextStepInstructions: readonly NextStepInstruction[]; +}; + +/** One independently checkable initial Package Contribution and its real plan. */ +export type BuiltInPresetTemplateSourceCheckContext = { + readonly definition: BuiltInPresetDefinition; + readonly contribution: PackageContribution; + readonly plan: GeneratedRepositoryPlan; +}; + +/** + * The Foundation persists the non-rendering half of every Package + * Contribution with the Generated Repository. Package Addition cannot infer + * check, fix, deployment, or maintenance semantics from a package name (or + * from a lossy subset of scripts), so this is the durable topology it reads. + */ +/** Resolve an owned source handle for diagnostics and source checks. */ +export function resolveBuiltInTemplateSource( + source: TemplateSourceHandle, + relativePath: string, +): string { + return resolveTemplateSource(source, relativePath); +} + +export function validateProjectBlueprintV2(value: unknown) { + return validateCoreProjectBlueprintV2(value); +} + +class PresetRegistry { + readonly #definitions: readonly BuiltInPresetDefinition[]; + constructor(definitions: readonly BuiltInPresetDefinition[]) { + const names = definitions.map((definition) => definition.metadata.name); + if ( + names.some((name) => name.length === 0) || + new Set(names).size !== names.length + ) { + throw new Error( + "Preset Registry requires unique non-empty Definition names", + ); + } + this.#definitions = [...definitions].toSorted((left, right) => + left.metadata.name.localeCompare(right.metadata.name), + ); + } + all(): readonly BuiltInPresetDefinition[] { + return this.#definitions; + } + require(name: string): BuiltInPresetDefinition { + const definition = this.#definitions.find( + (item) => item.metadata.name === name, + ); + if (!definition) throw new Error(`Unknown Built-in Preset: ${name}`); + return definition; + } +} + +export const builtInPresetRegistry = new PresetRegistry([ + tsLibDefinition, + rustBinDefinition, + vueAppDefinition, + vueHonoAppDefinition, + vikeAppDefinition, +]); + +/** Registry-derived Template Source roots checked independently of render plans. */ +export function builtInPresetTemplateSourceContexts(): readonly { + readonly name: string; + readonly root: string; +}[] { + return [ + ...builtInPresetRegistry.all().map((definition) => ({ + name: definition.metadata.name, + root: resolveBuiltInTemplateSource(definition.source, "."), + })), + { + name: "foundation", + root: resolveBuiltInTemplateSource(templateSources.foundation, "."), + }, + { + name: "shared-devcontainer", + root: resolveBuiltInTemplateSource( + templateSources.sharedDevcontainer, + ".", + ), + }, + { + name: "shared-oxc", + root: resolveBuiltInTemplateSource(templateSources.sharedOxc, "."), + }, + { + name: "shared-vue", + root: resolveBuiltInTemplateSource(templateSources.vue, "."), + }, + ]; +} + +/** + * Derives direct Template Source checks from every registered Definition's + * actual initial contributions, without maintaining a second Preset catalog. + */ +export function builtInPresetTemplateSourceCheckContexts(): readonly BuiltInPresetTemplateSourceCheckContext[] { + return builtInPresetRegistry.all().flatMap((definition) => { + const context = createGenerationContext({ + targetDir: path.join( + "generated-repository", + "template-source", + definition.metadata.name, + ), + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }); + const plan = planGeneratedRepositoryInitialization({ definition, context }); + const contributions = definition.planInitializationContributions?.( + context, + ) ?? [definition.planInitialization(context)]; + + return contributions.map((contribution) => ({ + definition, + contribution, + plan, + })); + }); +} + +export function createGenerationContext(options: { + readonly targetDir: string; + readonly scope?: string; + readonly toolchain: BuiltInGenerationContext["toolchain"]; +}): BuiltInGenerationContext { + const projectName = path.basename(path.resolve(options.targetDir)); + return { + targetDir: options.targetDir, + projectName, + scope: options.scope ?? projectName, + toolchain: options.toolchain, + }; +} + +/** + * Package Addition reconstructs generic current facts from the Blueprint + * topology and each package's real manifest/configuration. A second durable + * Contribution database would drift from the generated repository. + */ +function existingPackageContribution(options: { + readonly context: BuiltInGenerationContext; + readonly definition: ProjectBlueprintV2["packages"][number]; +}): PackageContribution { + const packageRoot = path.join( + options.context.targetDir, + options.definition.path, + ); + const manifestPath = path.join(packageRoot, "package.json"); + if (!existsSync(manifestPath)) { + throw new Error( + `Package Addition requires manifest truth for ${options.definition.path}: package.json is missing`, + ); + } + const manifest: unknown = JSON.parse(readFileSync(manifestPath, "utf8")); + if ( + typeof manifest !== "object" || + manifest === null || + Array.isArray(manifest) || + (manifest as { name?: unknown }).name !== options.definition.name + ) { + throw new Error( + `Package Addition requires manifest truth for ${options.definition.path}: expected name ${options.definition.name}`, + ); + } + const record = manifest as Record; + const scripts = + typeof record.scripts === "object" && record.scripts !== null + ? (record.scripts as Record) + : {}; + const owner = { + kind: "package-boundary" as const, + path: options.definition.path, + }; + const dependencyMaintenance = existingDependencyMaintenancePolicy( + options.context.targetDir, + ); + const rust = existsSync(path.join(packageRoot, "Cargo.toml")); + const editorRecommendationsPath = path.join( + options.context.targetDir, + ".vscode/extensions.json", + ); + const editorRecommendations = existsSync(editorRecommendationsPath) + ? ( + JSON.parse(readFileSync(editorRecommendationsPath, "utf8")) as { + recommendations?: unknown; + } + ).recommendations + : []; + const existingExtensions = Array.isArray(editorRecommendations) + ? new Set( + editorRecommendations.filter( + (item): item is string => typeof item === "string", + ), + ) + : new Set(); + const editorCapabilities: readonly EditorCustomizationCapability[] = rust + ? (["rust-tooling"] as const) + : [ + "oxc-format-lint", + ...(existingExtensions.has("Vue.volar") ? (["vue"] as const) : []), + ...(existingExtensions.has("bradlc.vscode-tailwindcss") + ? (["tailwind"] as const) + : []), + ...(existingExtensions.has("vitest.explorer") + ? (["vitest"] as const) + : []), + ]; + const checks: CheckComponent[] = rust + ? [ + { kind: "rustfmt-check", owner }, + { kind: "cargo-clippy", owner }, + { kind: "cargo-test", owner }, + ] + : [ + ...(scripts["typecheck:run"] === undefined + ? [] + : [{ kind: "typescript-typecheck" as const, owner }]), + ...(scripts["lint:run"] === undefined + ? [] + : [{ kind: "oxc-lint" as const, owner }]), + ...(scripts["format:check:run"] === undefined + ? [] + : [{ kind: "oxc-format-check" as const, owner }]), + ...(scripts["build:run"] === undefined + ? [] + : [{ kind: "build" as const, owner }]), + ...(scripts["test:run"] === undefined + ? [] + : [{ kind: "unit-test" as const, owner }]), + ...(scripts["test:e2e:run"] === undefined + ? [] + : [{ kind: "e2e-test" as const, owner }]), + ]; + return { + definition: options.definition, + manifest: record, + exposure: { + exports: + typeof record.exports === "object" && record.exports !== null + ? (record.exports as Record) + : {}, + imports: + typeof record.imports === "object" && record.imports !== null + ? (record.imports as Record) + : {}, + }, + operations: [], + checks, + fixes: rust + ? [{ kind: "rustfmt-write", owner }] + : [ + ...(scripts["format:write:run"] === undefined + ? [] + : [{ kind: "oxc-format-write" as const, owner }]), + ...(scripts["lint:fix:run"] === undefined + ? [] + : [{ kind: "oxc-lint-fix" as const, owner }]), + ], + environmentNeeds: [ + ...(rust ? [rustToolchainEnvironmentNeed(owner)] : []), + ...(scripts["test:e2e:run"] === undefined + ? [] + : [ + playwrightBrowserAssetsEnvironmentNeed({ + browser: "chromium", + owner, + }), + ]), + ...(Object.values(scripts).some( + (script) => typeof script === "string" && script.includes("shellcheck"), + ) + ? [shellCheckEnvironmentNeed(owner)] + : []), + ], + ...(scripts["check:deployment"] === undefined + ? {} + : { deploymentChecks: [{ kind: "deployment-image" as const, owner }] }), + foundation: { + toolchains: rust + ? { rust: { toolchain: "stable", components: ["rustfmt", "clippy"] } } + : {}, + editorCapabilities, + dependencyMaintenance: { + ...dependencyMaintenance, + }, + }, + }; +} + +/** + * Dependabot is the Foundation-owned durable declaration of maintenance + * coverage. Package Addition reads it back so rebuilding root policy cannot + * silently discard a base repository's non-default directories. + */ +function existingDependencyMaintenancePolicy( + targetDir: string, +): DependencyMaintenancePolicy { + const dependabotPath = path.join(targetDir, ".github/dependabot.yml"); + if (!existsSync(dependabotPath)) { + throw new Error( + "Package Addition requires maintenance truth: .github/dependabot.yml is missing", + ); + } + const ecosystems: DependencyEcosystem[] = []; + const directories: Partial> = {}; + const extraDirectories: Partial> = + {}; + const supportedEcosystems = new Set([ + "npm", + "cargo", + "github-actions", + "docker", + "rust-toolchain", + ]); + let ecosystem: DependencyEcosystem | undefined; + for (const line of readFileSync(dependabotPath, "utf8").split("\n")) { + const ecosystemMatch = /^\s*- package-ecosystem: (\S+)\s*$/u.exec(line); + if (ecosystemMatch) { + if (!supportedEcosystems.has(ecosystemMatch[1] as DependencyEcosystem)) { + throw new Error( + `Package Addition requires supported Dependabot ecosystems: ${ecosystemMatch[1]}`, + ); + } + ecosystem = ecosystemMatch[1] as DependencyEcosystem; + if (!ecosystems.includes(ecosystem)) ecosystems.push(ecosystem); + continue; + } + const directoryMatch = /^\s+directory: "?(\/[^"\s]*)"?\s*$/u.exec(line); + if (!directoryMatch || ecosystem === undefined) continue; + const directory = directoryMatch[1] as `/${string}`; + if (directories[ecosystem] === undefined) { + directories[ecosystem] = directory; + } else { + (extraDirectories[ecosystem] ??= []).push(directory); + } + } + if (ecosystems.length === 0) { + throw new Error( + "Package Addition requires maintenance truth: .github/dependabot.yml has no update entries", + ); + } + return { + ecosystems, + directories, + ...(Object.keys(extraDirectories).length === 0 ? {} : { extraDirectories }), + interval: "weekly", + }; +} + +function unique(values: readonly T[]): T[] { + return [...new Set(values)]; +} + +function rootCheckCommand(checks: readonly CheckComponent[]): string { + const rootTasks = unique( + checks + .filter((check) => check.owner.kind === "workspace-orchestration") + .map(checkComponentTaskName), + ); + const packageChecks = checks.filter( + (check) => check.owner.kind === "package-boundary", + ); + const packageTasks = unique(packageChecks.map(checkComponentTaskName)); + const packageFilters = unique( + packageChecks.map((check) => `--filter './${check.owner.path}'`), + ); + return [ + "pnpm run check:boundaries", + ...rootTasks.map((task) => `pnpm run ${task}`), + ...(packageTasks.length === 0 + ? [] + : [renderTurboRunCommand(packageTasks, packageFilters)]), + ].join(" && "); +} + +function rootFixCommand(fixes: readonly FixComponent[]): string { + const rootTasks = unique( + fixes + .filter((fix) => fix.owner.kind === "workspace-orchestration") + .map(fixComponentTaskName), + ); + const packageFixes = fixes.filter( + (fix) => fix.owner.kind === "package-boundary", + ); + const packageTasks = unique(packageFixes.map(fixComponentTaskName)); + const packageFilters = unique( + packageFixes.map((fix) => `--filter './${fix.owner.path}'`), + ); + return [ + ...rootTasks.map((task) => `pnpm run ${task}`), + ...(packageTasks.length === 0 + ? [] + : [renderTurboRunCommand(packageTasks, packageFilters)]), + ].join(" && "); +} + +function devcontainerDockerfileOperations(options: { + readonly context: BuiltInGenerationContext; + readonly environmentNeeds: readonly CheckEnvironmentNeed[]; +}): RenderOperation[] { + const hasBrowserTests = options.environmentNeeds.some( + (need) => need.kind === "playwright-browser-assets", + ); + const hasShellChecks = options.environmentNeeds.some( + (need) => need.kind === "shellcheck-command", + ); + const browserLayer = hasBrowserTests ? browserTestToolLayer() : undefined; + return [ + { + kind: "writeTextTemplate", + source: templateSources.foundation, + from: "devcontainer.json", + to: ".devcontainer/devcontainer.json", + replacements: { + PROJECT_NAME: options.context.projectName, + NODE_LTS_MAJOR: options.context.toolchain.nodeLtsMajor, + PACKAGE_MANAGER_PIN: options.context.toolchain.packageManagerPin, + PLAYWRIGHT_CLI_PACKAGE: browserLayer?.playwrightCliPackage ?? "unused", + }, + }, + { + kind: "writeTextFromFragments", + to: ".devcontainer/Dockerfile", + fragments: [ + { + source: templateSources.sharedDevcontainer, + from: "node-pnpm.Dockerfile", + }, + ...(browserLayer === undefined + ? [] + : [ + { + source: templateSources.sharedDevcontainer, + from: "browser-test.Dockerfile", + }, + ]), + ...(hasShellChecks + ? [ + { + source: templateSources.sharedDevcontainer, + from: "shellcheck.Dockerfile", + }, + ] + : []), + ], + }, + ]; +} + +function foundationPlan(options: { + readonly definition: BuiltInPresetDefinition; + readonly context: BuiltInGenerationContext; + readonly blueprint: ProjectBlueprintV2; + readonly contributions: readonly PackageContribution[]; + /** Contributions whose package-owned operations are rendered in this pass. */ + readonly renderContributions?: readonly PackageContribution[]; + readonly mode: "initialization" | "addition"; +}): GeneratedRepositoryPlan { + assertProjectBlueprintV2(options.blueprint); + const foundationChecks: CheckComponent[] = [ + { + kind: "oxc-format-check", + owner: { kind: "workspace-orchestration", path: "." }, + }, + { + kind: "oxc-lint", + owner: { kind: "workspace-orchestration", path: "." }, + }, + { + kind: "typescript-typecheck", + owner: { kind: "workspace-orchestration", path: "." }, + }, + ]; + const foundationFixes: FixComponent[] = [ + { + kind: "oxc-format-write", + owner: { kind: "workspace-orchestration", path: "." }, + }, + { + kind: "oxc-lint-fix", + owner: { kind: "workspace-orchestration", path: "." }, + }, + ]; + const checks = [ + ...foundationChecks, + ...options.contributions.flatMap((item) => item.checks), + ]; + const fixes = [ + ...foundationFixes, + ...options.contributions.flatMap((item) => item.fixes), + ]; + const environmentNeeds = options.contributions.flatMap( + (item) => item.environmentNeeds, + ); + const deploymentChecks = options.contributions.flatMap( + (item) => item.deploymentChecks ?? [], + ); + const packagePaths = options.contributions.map( + (contribution) => contribution.definition.path, + ); + if (new Set(packagePaths).size !== packagePaths.length) + throw new Error("Package Contributions must have unique Package Paths"); + const packageNames = options.contributions.map( + (contribution) => contribution.definition.name, + ); + if (new Set(packageNames).size !== packageNames.length) + throw new Error("Package Contributions must have unique package names"); + const rustToolchain = options.contributions + .map((contribution) => contribution.foundation.toolchains.rust) + .find((toolchain) => toolchain !== undefined); + if ( + options.contributions.some( + (contribution) => + contribution.foundation.toolchains.rust !== undefined && + contribution.foundation.toolchains.rust !== rustToolchain, + ) + ) { + throw new Error("Foundation requires one coordinated Rust toolchain"); + } + const workspacePackageGlobs = [ + ...new Set([ + ...options.blueprint.packages.map( + (definition) => `${definition.path.split("/")[0]}/*`, + ), + ...options.contributions.flatMap( + (contribution) => contribution.foundation.workspacePackageGlobs ?? [], + ), + ]), + ]; + const editorCustomization = editorCustomizationForCapabilities( + options.contributions.flatMap( + (contribution) => contribution.foundation.editorCapabilities, + ), + loadEditorCustomizationDeclarations( + resolveTemplateSource( + templateSources.editorCustomization, + "capabilities.json", + ), + ), + ); + const dependencyMaintenancePolicy: DependencyMaintenancePolicy = { + ecosystems: [ + ...new Set( + options.contributions.flatMap( + (contribution) => + contribution.foundation.dependencyMaintenance.ecosystems, + ), + ), + ], + directories: Object.assign( + {}, + ...options.contributions.map( + (contribution) => + contribution.foundation.dependencyMaintenance.directories ?? {}, + ), + ), + extraDirectories: Object.assign( + {}, + ...options.contributions.map( + (contribution) => + contribution.foundation.dependencyMaintenance.extraDirectories ?? {}, + ), + ), + interval: "weekly", + }; + const rootManifest = { + name: options.context.projectName, + version: "0.0.0", + private: true, + type: "module", + scripts: { + check: rootCheckCommand(checks), + "check:boundaries": "turbo boundaries --no-color", + ...(deploymentChecks.length === 0 + ? {} + : { + "check:deployment": deploymentChecks + .map( + (check) => + `pnpm --filter './${check.owner.path}' run check:deployment`, + ) + .join(" && "), + }), + fix: rootFixCommand(fixes), + "format:check:run": "oxfmt --list-different --config oxfmt.config.ts .", + "format:write:run": "oxfmt --write --config oxfmt.config.ts .", + "lint:run": "oxlint --quiet --format=unix --config oxlint.config.ts .", + "lint:fix:run": "oxlint --format=unix --config oxlint.config.ts . --fix", + typecheck: "pnpm run typecheck:run", + "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", + }, + devDependencies: { + "@types/node": "catalog:", + oxfmt: "catalog:", + oxlint: "catalog:", + "oxlint-tsgolint": "catalog:", + turbo: "catalog:", + "typescript-7": "catalog:", + }, + engines: { node: options.context.toolchain.nodeLtsMajor }, + packageManager: options.context.toolchain.packageManagerPin, + }; + const dependencyCatalog = selectTemplateDependencyCatalogEntries( + collectGeneratedManifestCatalogReferences([ + ...options.contributions.map((contribution) => contribution.manifest), + rootManifest, + ]), + ); + const dependencyOverrides = { + "valibot>typescript": "-", + ...(Object.hasOwn(dependencyCatalog, "vue") || + Object.hasOwn(dependencyCatalog, "pinia") + ? vuePnpmDependencyOverrides + : {}), + }; + const requiresCoordinatedWorkspaceRefresh = true; + const workspaceOperation: RenderOperation = + requiresCoordinatedWorkspaceRefresh + ? { + kind: "writeTextTemplate", + source: templateSources.foundation, + from: "pnpm-workspace.dynamic.txt", + to: "pnpm-workspace.yaml", + replacements: { + WORKSPACE_PACKAGE_GLOBS: workspacePackageGlobs + .map((glob) => ` - ${glob}`) + .join("\n"), + DEPENDENCY_CATALOG: Object.entries(dependencyCatalog) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map( + ([name, version]) => + ` ${JSON.stringify(name)}: ${String(version)}`, + ) + .join("\n"), + DEPENDENCY_OVERRIDES: Object.entries(dependencyOverrides) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map( + ([dependency, version]) => + ` ${JSON.stringify(dependency)}: ${JSON.stringify(version)}`, + ) + .join("\n"), + }, + ...(options.mode === "addition" ? { overwrite: true } : {}), + } + : { + kind: "copyFile", + source: templateSources.foundation, + from: "pnpm-workspace.yaml", + to: "pnpm-workspace.yaml", + }; + const workflowOperation: RenderOperation = { + kind: "writeTextTemplate", + source: templateSources.foundation, + from: ".github/workflows/check.dynamic.template", + to: ".github/workflows/check.yml", + replacements: projectCheckWorkflowTemplateReplacements({ + checkPlan: { components: checks, environmentNeeds, deploymentChecks }, + }), + ...(options.mode === "addition" ? { overwrite: true } : {}), + }; + const workflowOperations: RenderOperation[] = [ + workflowOperation, + { + kind: "writeTextTemplate" as const, + source: templateSources.foundation, + from: ".github/dependabot.dynamic.template", + to: ".github/dependabot.yml", + replacements: projectDependabotTemplateReplacements( + dependencyMaintenancePolicy, + ), + ...(options.mode === "addition" ? { overwrite: true } : {}), + }, + ]; + const projectLinkPlan = planExplicitProjectLinks({ + blueprint: options.blueprint, + contributions: options.contributions, + }); + const initializationFoundationOperations: RenderOperation[] = [ + { kind: "writeJson", to: "package.json", value: rootManifest }, + workspaceOperation, + { + kind: "copyFile", + source: templateSources.foundation, + from: "gitignore", + to: ".gitignore", + }, + { + kind: "copyFile", + source: templateSources.foundation, + from: ".pnpmfile.cts", + to: ".pnpmfile.cts", + }, + { + kind: "copyFile", + source: templateSources.foundation, + from: "turbo.json", + to: "turbo.json", + }, + { + kind: "copyFile", + source: templateSources.foundation, + from: "tsconfig.json", + to: "tsconfig.json", + }, + { + kind: "copyFile", + source: templateSources.sharedOxc, + from: "tsconfig.config.json", + to: "tsconfig.config.json", + }, + { + kind: "copyFile", + source: templateSources.sharedOxc, + from: "node/oxlint.config.ts", + to: "oxlint.config.ts", + }, + { + kind: "copyFile", + source: templateSources.sharedOxc, + from: "oxfmt.config.ts", + to: "oxfmt.config.ts", + }, + { + kind: "writeJson", + to: ".vscode/extensions.json", + value: { recommendations: editorCustomization.extensions }, + ...(editorCustomization.extensions.length > 2 + ? { multilineArrays: ["recommendations"] } + : {}), + }, + { + kind: "writeJson", + to: ".vscode/settings.json", + value: editorCustomization.settings, + }, + ...(rustToolchain === undefined + ? [ + ...devcontainerDockerfileOperations({ + context: options.context, + environmentNeeds, + }), + ] + : [ + { + kind: "writeTextTemplate" as const, + source: templateSources.foundation, + from: "rust/rust-toolchain.toml", + to: "rust-toolchain.toml", + replacements: { RUST_TOOLCHAIN: rustToolchain.toolchain }, + }, + { + kind: "writeTextTemplate" as const, + source: templateSources.foundation, + from: "rust/devcontainer/devcontainer.json", + to: ".devcontainer/devcontainer.json", + replacements: { + PROJECT_NAME: options.context.projectName, + NODE_LTS_MAJOR: options.context.toolchain.nodeLtsMajor, + PACKAGE_MANAGER_PIN: options.context.toolchain.packageManagerPin, + RUST_TOOLCHAIN: rustToolchain.toolchain, + }, + }, + { + kind: "writeTextFromFragments" as const, + to: ".devcontainer/Dockerfile", + fragments: [ + { + source: templateSources.sharedDevcontainer, + from: "node-pnpm.Dockerfile", + }, + { + source: templateSources.foundation, + from: "rust/devcontainer/rust.Dockerfile", + }, + ], + }, + ]), + ...workflowOperations, + { + kind: "writeJson", + to: ".template/blueprint.json", + value: options.blueprint, + }, + { + kind: "writeJson", + to: ".template/generation.json", + value: { + preset: options.definition.metadata.name, + templateVersion: "0.0.0", + toolchain: options.context.toolchain, + }, + }, + ]; + const refreshFoundationOperation = ( + operation: RenderOperation, + ): RenderOperation => { + switch (operation.kind) { + case "copyFile": + case "writeTextTemplate": + case "writeTextFromFragments": + case "writeJson": + return { ...operation, overwrite: true }; + case "mergeJson": + case "replaceAnchors": + case "setExecutable": + case "writeText": + return operation; + } + }; + const plannedFoundationOperations: RenderOperation[] = + options.mode === "addition" + ? initializationFoundationOperations + .filter( + (operation) => + !( + "to" in operation && + operation.to === ".template/generation.json" + ), + ) + .map(refreshFoundationOperation) + : initializationFoundationOperations; + const linkOperations: RenderOperation[] = [ + ...projectLinkPlan.manifestDependenciesByPackagePath, + ].map(([packagePath, dependencies]) => ({ + kind: "mergeJson" as const, + to: `${packagePath}/package.json`, + value: { dependencies }, + multilineArrays: ["files"], + })); + const contributionProvenance = { + definitionName: options.definition.metadata.name, + plannerSourceFile: options.definition.plannerSourceFile, + planningContribution: + options.mode === "addition" + ? "planPackageAddition" + : "planInitialization", + ownershipRule: + "Package Contribution may write only its owned Package Boundary", + } as const; + const foundationProvenance = { + definitionName: options.definition.metadata.name, + plannerSourceFile: fileURLToPath(import.meta.url), + planningContribution: "foundationPlan", + ownershipRule: "Foundation owns coordinated root outputs", + } as const; + const withProvenance = ( + operation: RenderOperation, + provenance: typeof contributionProvenance | typeof foundationProvenance, + ): RenderOperation => ({ ...operation, provenance }); + const operations: RenderOperation[] = [ + ...(options.renderContributions ?? options.contributions) + .map((item) => + assertPackageContribution(item, { + definitionName: options.definition.metadata.name, + planner: + options.mode === "addition" + ? "planPackageAddition" + : "planInitialization", + }), + ) + .flatMap((item) => + item.operations.map((operation) => + operation.kind === "writeJson" && + operation.to.endsWith("/package.json") + ? { ...operation, value: item.manifest } + : operation, + ), + ) + .map((operation) => withProvenance(operation, contributionProvenance)), + ...plannedFoundationOperations.map((operation) => + withProvenance(operation, foundationProvenance), + ), + ...linkOperations.map((operation) => + withProvenance(operation, foundationProvenance), + ), + ]; + return { + definitionName: options.definition.metadata.name, + plannerSourceFile: options.definition.plannerSourceFile, + planningContribution: + options.mode === "addition" + ? "planPackageAddition" + : "planInitialization", + blueprint: options.blueprint, + generationRecord: { + preset: options.definition.metadata.name, + templateVersion: "0.0.0", + toolchain: options.context.toolchain, + }, + operations, + checks, + fixes, + environmentNeeds, + deploymentChecks, + manifests: [ + ...options.contributions.map((item) => item.manifest), + rootManifest, + ], + dependencyCatalog, + dependencyMaintenancePolicy, + nextStepInstructions: [ + { display: "pnpm install" }, + ...environmentNeeds.map((need) => ({ display: need.nextStep.display })), + { display: "pnpm run fix" }, + { display: "pnpm run check" }, + ], + }; +} + +export function planGeneratedRepositoryInitialization(options: { + readonly definition: BuiltInPresetDefinition; + readonly context: BuiltInGenerationContext; +}): GeneratedRepositoryPlan { + const blueprint = options.definition.blueprint(options.context); + return foundationPlan({ + definition: options.definition, + context: options.context, + blueprint, + contributions: options.definition.planInitializationContributions?.( + options.context, + ) ?? [options.definition.planInitialization(options.context)], + mode: "initialization", + }); +} + +export function planGeneratedRepositoryPackageAddition(options: { + readonly definition: BuiltInPresetDefinition; + readonly context: BuiltInGenerationContext; + readonly blueprint: ProjectBlueprintV2; + readonly packageLeafName: string; + readonly packagePath?: string; + /** Existing consumers that explicitly import the newly added provider. */ + readonly linkFrom?: readonly string[]; +}): GeneratedRepositoryPlan { + assertProjectBlueprintV2(options.blueprint); + if (!options.definition.planPackageAddition) + throw new Error( + `Built-in Preset ${options.definition.metadata.name} does not support Package Addition`, + ); + const packagePath = + options.packagePath ?? + options.definition.defaultPackagePath?.({ + context: options.context, + packageLeafName: options.packageLeafName, + }); + if (packagePath === undefined) { + throw new Error( + `Built-in Preset ${options.definition.metadata.name} must own a default Package Path or receive an explicit Package Path`, + ); + } + const contribution = options.definition.planPackageAddition({ + context: options.context, + packageLeafName: options.packageLeafName, + packagePath, + }); + const conflictingPackage = options.blueprint.packages.find( + (existing) => + existing.name === contribution.definition.name || + existing.path === contribution.definition.path, + ); + if (conflictingPackage !== undefined) { + throw new Error( + `Package Addition conflicts with existing Package Definition ${conflictingPackage.name} at ${conflictingPackage.path}`, + ); + } + const blueprint: ProjectBlueprintV2 = { + ...options.blueprint, + packages: [...options.blueprint.packages, contribution.definition], + ...(options.linkFrom && options.linkFrom.length > 0 + ? { + packageLinkIntents: [ + ...(options.blueprint.packageLinkIntents ?? []), + ...[...new Set(options.linkFrom)].map((consumerPackagePath) => ({ + consumerPackagePath, + providerPackagePath: contribution.definition.path, + })), + ], + } + : {}), + }; + assertProjectBlueprintV2(blueprint); + const existingContributions = options.blueprint.packages.map((definition) => + existingPackageContribution({ context: options.context, definition }), + ); + if ( + existingContributions.length !== options.blueprint.packages.length || + existingContributions.some( + (existing) => + !options.blueprint.packages.some( + (definition) => + definition.path === existing.definition.path && + definition.name === existing.definition.name, + ), + ) + ) { + throw new Error( + "Migration Package Addition check Contributions must match the current Project Blueprint", + ); + } + return foundationPlan({ + definition: options.definition, + context: options.context, + blueprint, + contributions: [...existingContributions, contribution], + renderContributions: [contribution], + mode: "addition", + }); +} diff --git a/packages/builtin-presets/src/index.ts b/packages/builtin-presets/src/index.ts new file mode 100644 index 0000000..fd9053f --- /dev/null +++ b/packages/builtin-presets/src/index.ts @@ -0,0 +1,19 @@ +export { + builtInPresetRegistry, + builtInPresetTemplateSourceCheckContexts, + builtInPresetTemplateSourceContexts, + createGenerationContext, + planGeneratedRepositoryInitialization, + planGeneratedRepositoryPackageAddition, + resolveBuiltInTemplateSource, + validateProjectBlueprintV2, +} from "./foundation.ts"; +export { templateSources } from "./template-sources.ts"; +export type { + BuiltInPresetDefinition, + BuiltInPresetTemplateSourceCheckContext, + GeneratedRepositoryPlan, + PackageContribution, + ProjectBlueprintV2, + BuiltInGenerationContext, +} from "./foundation.ts"; diff --git a/packages/builtin-presets/src/registry-checks.ts b/packages/builtin-presets/src/registry-checks.ts new file mode 100644 index 0000000..20be1f3 --- /dev/null +++ b/packages/builtin-presets/src/registry-checks.ts @@ -0,0 +1,395 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { access, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + collectGeneratedManifestCatalogReferences, + selectTemplateDependencyCatalogEntries, +} from "@ykdz/template-core/dependency-catalog"; +import type { BuiltInPresetDefinition } from "@ykdz/template-core/preset-definition"; +import { projectDependabotConfig } from "@ykdz/template-core/project-github"; +import type { RenderOperation } from "@ykdz/template-core/renderer"; + +import { + builtInPresetRegistry, + createGenerationContext, + planGeneratedRepositoryInitialization, + planGeneratedRepositoryPackageAddition, + resolveBuiltInTemplateSource, + type GeneratedRepositoryPlan, +} from "./foundation.ts"; + +/** A real registry Definition and optional Package Addition planner scenario. */ +export type GeneratedScenario = { + readonly id: string; + readonly label: string; + readonly base: BuiltInPresetDefinition; + readonly addition?: BuiltInPresetDefinition; + readonly linkFrom?: readonly string[]; +}; + +function scenarioId(...parts: readonly string[]): string { + return parts.join("--"); +} + +/** One production-equivalent initialization per complete registered Definition. */ +export function deriveInitializationScenarios(): readonly GeneratedScenario[] { + return builtInPresetRegistry.all().map((base) => ({ + id: scenarioId("init", base.metadata.name), + label: `initialize ${base.metadata.name}`, + base, + })); +} + +/** + * Every initialization plus every registered Package Addition planner applied + * to every base Definition. No Preset identity list is maintained here. + */ +export function deriveFixtureMatrix(): readonly GeneratedScenario[] { + const definitions = builtInPresetRegistry.all(); + const addable = definitions.filter( + (definition) => definition.planPackageAddition !== undefined, + ); + + return definitions.flatMap((base) => [ + { + id: scenarioId("fixture", base.metadata.name, "init"), + label: `initialize ${base.metadata.name}`, + base, + }, + ...addable.map((addition) => ({ + id: scenarioId( + "fixture", + base.metadata.name, + "add", + addition.metadata.name, + ), + label: `initialize ${base.metadata.name}, then add ${addition.metadata.name}`, + base, + addition, + })), + ]); +} + +/** + * Focused link cases use the first real owned Package Boundary from each base + * Definition; the provider remains an optional Package Addition Definition. + * There is deliberately no hand-maintained Preset compatibility table. + */ +export function deriveFocusedProjectLinkScenarios(): readonly GeneratedScenario[] { + const definitions = builtInPresetRegistry.all(); + const addable = definitions.filter( + (definition) => definition.planPackageAddition !== undefined, + ); + return definitions.flatMap((base) => { + // Some Definitions derive their package path from the project name. Plan + // the consumer using the exact target-directory basename that the focused + // runner will use, rather than a discovery-only placeholder. + const scenarioContext = (addition: BuiltInPresetDefinition) => { + const id = scenarioId( + "focused-link", + base.metadata.name, + addition.metadata.name, + ); + return { + id, + context: createGenerationContext({ + targetDir: path.join("generated-repository", id), + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }), + }; + }; + return addable.flatMap((addition) => { + const { id, context } = scenarioContext(addition); + const contribution = + base.planInitializationContributions?.(context)[0] ?? + base.planInitialization(context); + if (contribution === undefined) return []; + const packageLeafName = `focused-${addition.metadata.name}`; + const packagePath = addition.defaultPackagePath?.({ + context, + packageLeafName, + }); + if (packagePath === undefined) return []; + const provider = addition.planPackageAddition?.({ + context, + packageLeafName, + packagePath, + }); + // Boundary policies permit generated packages to import a shared + // library, not another runtime service. Derive the focused link from + // the actual addition Contribution instead of Preset identity. + if (provider?.definition.role !== "shared-library") return []; + return [ + { + id, + label: `link ${contribution.definition.path} to added ${addition.metadata.name}`, + base, + addition, + linkFrom: [contribution.definition.path], + }, + ]; + }); + }); +} + +/** Convention-owned location for a Definition's observable behavior contract. */ +export function presetLocalBehaviorTestPath( + definition: BuiltInPresetDefinition, +): string { + // Template Source is owned at templates/; its sibling source + // directory is the convention-bearing Behavior Test location. This remains + // stable when planners are executed from compiled dist/ output. + const templateRoot = resolveBuiltInTemplateSource(definition.source, "."); + return path.resolve( + templateRoot, + "..", + "..", + "src", + path.basename(templateRoot), + "behavior.test.ts", + ); +} + +export type PresetLocalBehaviorTest = { + readonly definition: BuiltInPresetDefinition; + readonly filePath: string; +}; + +/** Discovers the behavior contract colocated with every registry Definition. */ +export async function discoverPresetLocalBehaviorTests(): Promise< + readonly PresetLocalBehaviorTest[] +> { + return await Promise.all( + builtInPresetRegistry.all().map(async (definition) => { + const filePath = presetLocalBehaviorTestPath(definition); + try { + const details = await stat(filePath); + if (!details.isFile()) { + throw new Error("path is not a file"); + } + } catch (error) { + throw new Error( + `${definition.metadata.name}: missing Preset-Local Behavior Test at ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return { definition, filePath }; + }), + ); +} + +type SourceBackedOperation = Extract< + RenderOperation, + { kind: "copyFile" | "writeTextTemplate" | "writeTextFromFragments" } +>; + +export type PlanSourceReference = { + readonly definitionName: string; + readonly plannerSourceFile: string; + readonly generatedPath: string; + readonly sourceFile: string; +}; + +function operationReferences( + operation: SourceBackedOperation, +): readonly { readonly generatedPath: string; readonly sourceFile: string }[] { + if (operation.kind === "writeTextFromFragments") { + return operation.fragments.map((fragment) => ({ + generatedPath: operation.to, + sourceFile: resolveBuiltInTemplateSource(fragment.source!, fragment.from), + })); + } + return [ + { + generatedPath: operation.to, + sourceFile: resolveBuiltInTemplateSource( + operation.source!, + operation.from, + ), + }, + ]; +} + +/** Extracts referenced Template Source exclusively from the rendered plan. */ +export function planSourceReferences(options: { + readonly definition: BuiltInPresetDefinition; + readonly plan: GeneratedRepositoryPlan; +}): readonly PlanSourceReference[] { + return options.plan.operations.flatMap((operation) => { + if ( + operation.kind !== "copyFile" && + operation.kind !== "writeTextTemplate" && + operation.kind !== "writeTextFromFragments" + ) { + return []; + } + try { + return operationReferences(operation).map((reference) => ({ + definitionName: options.definition.metadata.name, + plannerSourceFile: options.definition.plannerSourceFile, + ...reference, + })); + } catch (error) { + throw new Error( + `generated ${operation.to}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }); +} + +/** + * Fails before rendering when a real plan references missing or escaping + * Template Source. Diagnostics retain Definition, planner, and generated path. + */ +export async function validatePlanSources(options: { + readonly definition: BuiltInPresetDefinition; + readonly plan: GeneratedRepositoryPlan; +}): Promise { + let references: readonly PlanSourceReference[]; + try { + references = planSourceReferences(options); + } catch (error) { + throw new Error( + `${options.definition.metadata.name}: ${options.definition.plannerSourceFile} references undeclared or escaping Template Source for a generated output: ${error instanceof Error ? error.message : String(error)}`, + ); + } + for (const reference of references) { + try { + await access(reference.sourceFile); + } catch { + throw new Error( + `${reference.definitionName}: ${reference.plannerSourceFile} references missing Template Source ${reference.sourceFile} for generated ${reference.generatedPath}`, + ); + } + } + return references; +} + +/** Verifies the generated catalog is derived solely from structured manifests. */ +export function validatePlanDependencyCatalog( + plan: GeneratedRepositoryPlan, +): void { + const expected = selectTemplateDependencyCatalogEntries( + collectGeneratedManifestCatalogReferences(plan.manifests), + ); + if (JSON.stringify(expected) !== JSON.stringify(plan.dependencyCatalog)) { + throw new Error( + `${plan.definitionName}: ${plan.plannerSourceFile} ${plan.planningContribution} violates Dependency Catalog ownership; generated manifests reference an undeclared dependency`, + ); + } +} + +export type VerificationPlan = { + readonly definition: BuiltInPresetDefinition; + readonly plan: GeneratedRepositoryPlan; +}; + +/** + * The plan set consumed by source, dependency, boundary, and publication + * checks. Addition plans retain real base Contributions for link planning but + * render only their own operations. + */ +export function deriveVerificationPlans(): readonly VerificationPlan[] { + return deriveFixtureMatrix().flatMap((scenario) => { + const workspace = mkdtempSync( + path.join(tmpdir(), "template-verification-plan-"), + ); + try { + const context = createGenerationContext({ + targetDir: path.join(workspace, scenario.id), + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }); + const initialization = planGeneratedRepositoryInitialization({ + definition: scenario.base, + context, + }); + const plans: VerificationPlan[] = [ + { definition: scenario.base, plan: initialization }, + ]; + if (scenario.addition) { + const initialContributions = + scenario.base.planInitializationContributions?.(context) ?? [ + scenario.base.planInitialization(context), + ]; + for (const contribution of initialContributions) { + const manifestPath = path.join( + context.targetDir, + contribution.definition.path, + "package.json", + ); + mkdirSync(path.dirname(manifestPath), { recursive: true }); + writeFileSync(manifestPath, JSON.stringify(contribution.manifest)); + } + const dependabotPath = path.join( + context.targetDir, + ".github/dependabot.yml", + ); + mkdirSync(path.dirname(dependabotPath), { recursive: true }); + writeFileSync( + dependabotPath, + projectDependabotConfig(initialization.dependencyMaintenancePolicy), + ); + plans.push({ + definition: scenario.addition, + plan: planGeneratedRepositoryPackageAddition({ + definition: scenario.addition, + context, + blueprint: initialization.blueprint, + packageLeafName: `verification-${scenario.addition.metadata.name}`, + }), + }); + } + return plans; + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +} + +/** + * Verifies a packed artifact contains every Template Source file referenced by + * the complete registry plan set, rather than a parallel source inventory. + */ +export function validatePlanPublicationSources(options: { + readonly packageRoot: string; + readonly packedPaths: readonly string[]; + readonly verificationPlans?: readonly VerificationPlan[]; +}): void { + const packageRoot = path.resolve(options.packageRoot); + const packedPaths = new Set(options.packedPaths); + for (const packedPath of packedPaths) { + if ( + packedPath.startsWith("package/templates/.template-") || + /^package\/dist\/src\/.*\.test\.(?:[cm]?js|d\.ts)$/u.test(packedPath) || + /(?:^|\/)\.turbo(?:\/|$)/u.test(packedPath) || + /(?:^|\/)node_modules(?:\/|$)/u.test(packedPath) + ) { + throw new Error( + `packed Built-in Presets artifact contains generated or test artifact ${packedPath}`, + ); + } + } + const plans = options.verificationPlans ?? deriveVerificationPlans(); + for (const { definition, plan } of plans) { + for (const reference of planSourceReferences({ definition, plan })) { + const relativePath = path.relative(packageRoot, reference.sourceFile); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new Error( + `${reference.definitionName}: ${reference.plannerSourceFile} references Template Source outside the Built-in Presets package for generated ${reference.generatedPath}`, + ); + } + const packedPath = `package/${relativePath.split(path.sep).join("/")}`; + if (!packedPaths.has(packedPath)) { + throw new Error( + `${reference.definitionName}: packed Built-in Presets artifact omits ${packedPath}, referenced for generated ${reference.generatedPath}`, + ); + } + } + } +} diff --git a/packages/builtin-presets/src/rust-bin/behavior.test.ts b/packages/builtin-presets/src/rust-bin/behavior.test.ts new file mode 100644 index 0000000..9cddf00 --- /dev/null +++ b/packages/builtin-presets/src/rust-bin/behavior.test.ts @@ -0,0 +1,150 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + createGenerationContext, + planGeneratedRepositoryInitialization, +} from "@ykdz/template-builtin-presets"; +import { renderNewProject } from "@ykdz/template-core/renderer"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; + +import { rustBinDefinition } from "./definition.ts"; + +describe("rust-bin Built-in Preset Definition behavior", () => { + it("owns a native package contribution with Rust source, checks, fixes, and toolchain maintenance", () => { + const context = { + targetDir: "/tmp/Demo Rust!", + projectName: "Demo Rust!", + scope: "demo", + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }; + + const contribution = rustBinDefinition.planInitialization(context); + + expect(rustBinDefinition.metadata).toEqual({ + name: "rust-bin", + title: "Rust binary", + description: + "Rust native binary workspace with rustfmt, clippy, and cargo tests.", + }); + expect(contribution.definition).toEqual({ + name: "@demo/demo-rust-native", + path: "packages/demo-rust", + role: "native-package", + }); + expect(contribution.manifest).toMatchObject({ + name: "@demo/demo-rust-native", + scripts: { + "format:check:run": "cargo fmt --all -- --check", + "lint:run": "cargo clippy --workspace --all-targets -- -D warnings", + "test:run": "cargo test --workspace", + }, + }); + expect(contribution.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "copyFile", + from: "src/main.rs", + to: "packages/demo-rust/src/main.rs", + }), + expect.objectContaining({ + kind: "copyFile", + from: "rustfmt.toml", + to: "packages/demo-rust/rustfmt.toml", + }), + ]), + ); + expect(contribution.checks.map((check) => check.kind)).toEqual([ + "rustfmt-check", + "cargo-clippy", + "cargo-test", + ]); + expect(contribution.fixes.map((fix) => fix.kind)).toEqual([ + "rustfmt-write", + ]); + expect(contribution).not.toHaveProperty("foundationOperations"); + expect(contribution.foundation).toMatchObject({ + toolchains: { + rust: { toolchain: "stable", components: ["rustfmt", "clippy"] }, + }, + editorCapabilities: ["rust-tooling"], + dependencyMaintenance: { + ecosystems: [ + "npm", + "cargo", + "github-actions", + "docker", + "rust-toolchain", + ], + }, + }); + }); + + it("generates a Rust repository whose Root Check runs native formatting, linting, and tests", async () => { + const targetDir = path.join( + await mkdtemp(path.join(tmpdir(), "template-rust-")), + "demo-rust", + ); + const context = createGenerationContext({ + targetDir, + scope: "demo", + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }); + const plan = planGeneratedRepositoryInitialization({ + definition: rustBinDefinition, + context, + }); + + expect(plan.checks.map((check) => check.kind)).toEqual( + expect.arrayContaining([ + "oxc-format-check", + "oxc-lint", + "typescript-typecheck", + "rustfmt-check", + "cargo-clippy", + "cargo-test", + ]), + ); + expect(plan.fixes.map((fix) => fix.kind)).toEqual( + expect.arrayContaining([ + "oxc-format-write", + "oxc-lint-fix", + "rustfmt-write", + ]), + ); + + await renderNewProject({ + targetRoot: targetDir, + operations: [...plan.operations], + }); + + expect( + await readFile( + path.join(targetDir, "packages/demo-rust/Cargo.toml"), + "utf8", + ), + ).toContain('name = "demo-rust"'); + expect( + await readFile(path.join(targetDir, "rust-toolchain.toml"), "utf8"), + ).toContain('channel = "stable"'); + expect( + await readFile(path.join(targetDir, ".devcontainer/Dockerfile"), "utf8"), + ).toContain("rustup toolchain install ${RUST_TOOLCHAIN}"); + expect( + await readFile(path.join(targetDir, ".github/dependabot.yml"), "utf8"), + ).toContain('directory: "/packages/demo-rust"'); + expect( + JSON.parse(await readFile(path.join(targetDir, "package.json"), "utf8")), + ).toMatchObject({ + scripts: { + check: expect.stringContaining("test:run"), + fix: expect.stringContaining("format:write:run"), + }, + }); + + await execa("pnpm", ["install"], { cwd: targetDir }); + await execa("pnpm", ["run", "check"], { cwd: targetDir }); + }, 180_000); +}); diff --git a/packages/builtin-presets/src/rust-bin/definition.ts b/packages/builtin-presets/src/rust-bin/definition.ts new file mode 100644 index 0000000..3158d4c --- /dev/null +++ b/packages/builtin-presets/src/rust-bin/definition.ts @@ -0,0 +1,149 @@ +import { fileURLToPath } from "node:url"; + +import { rustToolchainEnvironmentNeed } from "@ykdz/template-core/module-graph"; +import type { PackageContribution } from "@ykdz/template-core/package-contribution"; +import type { + BuiltInPresetDefinition, + GenerationContext, +} from "@ykdz/template-core/preset-definition"; +import type { PackageDefinition } from "@ykdz/template-core/project-blueprint-v2"; +import type { RenderOperation } from "@ykdz/template-core/renderer"; + +import { templateSources } from "../template-sources.ts"; + +function cargoPackageName(projectName: string): string { + const slug = projectName + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return slug || "rust-bin"; +} + +function packageScripts(): Record { + return { + "format:check:run": "cargo fmt --all -- --check", + "format:write:run": "cargo fmt --all", + "lint:run": "cargo clippy --workspace --all-targets -- -D warnings", + "test:run": "cargo test --workspace", + }; +} + +function rustContribution(context: GenerationContext): PackageContribution { + const cargoName = cargoPackageName(context.projectName); + const definition: PackageDefinition = { + name: `@${context.scope}/${cargoName}-native`, + path: `packages/${cargoName}`, + role: "native-package", + }; + const operations: RenderOperation[] = [ + { kind: "writeJson", to: `${definition.path}/package.json`, value: {} }, + { + kind: "writeTextTemplate", + source: templateSources.rustBin, + from: "Cargo.toml", + to: `${definition.path}/Cargo.toml`, + replacements: { CARGO_PACKAGE_NAME: cargoName }, + }, + { + kind: "writeTextTemplate", + source: templateSources.rustBin, + from: "Cargo.lock", + to: `${definition.path}/Cargo.lock`, + replacements: { CARGO_PACKAGE_NAME: cargoName }, + }, + { + kind: "copyFile", + source: templateSources.rustBin, + from: "rustfmt.toml", + to: `${definition.path}/rustfmt.toml`, + }, + { + kind: "copyFile", + source: templateSources.rustBin, + from: "turbo.json", + to: `${definition.path}/turbo.json`, + }, + { + kind: "copyFile", + source: templateSources.rustBin, + from: "src/main.rs", + to: `${definition.path}/src/main.rs`, + }, + ]; + return { + definition, + exposure: { exports: {}, imports: {} }, + manifest: { + name: definition.name, + version: "0.0.0", + private: true, + scripts: packageScripts(), + engines: { node: context.toolchain.nodeLtsMajor }, + }, + operations, + checks: [ + { + kind: "rustfmt-check", + owner: { kind: "package-boundary", path: definition.path }, + }, + { + kind: "cargo-clippy", + owner: { kind: "package-boundary", path: definition.path }, + }, + { + kind: "cargo-test", + owner: { kind: "package-boundary", path: definition.path }, + }, + ], + fixes: [ + { + kind: "rustfmt-write", + owner: { kind: "package-boundary", path: definition.path }, + }, + ], + environmentNeeds: [ + rustToolchainEnvironmentNeed({ + kind: "package-boundary", + path: definition.path, + }), + ], + foundation: { + toolchains: { + rust: { toolchain: "stable", components: ["rustfmt", "clippy"] }, + }, + editorCapabilities: ["rust-tooling"], + dependencyMaintenance: { + ecosystems: [ + "npm", + "cargo", + "github-actions", + "docker", + "rust-toolchain", + ], + directories: { cargo: `/${definition.path}` }, + interval: "weekly", + }, + }, + }; +} + +export const rustBinDefinition: BuiltInPresetDefinition = { + metadata: { + name: "rust-bin", + title: "Rust binary", + description: + "Rust native binary workspace with rustfmt, clippy, and cargo tests.", + }, + source: templateSources.rustBin, + plannerSourceFile: fileURLToPath(import.meta.url), + blueprint(context) { + return { + schemaVersion: 2, + packages: [rustContribution(context).definition], + }; + }, + planInitialization: rustContribution, +}; diff --git a/packages/builtin-presets/src/shared/vue.ts b/packages/builtin-presets/src/shared/vue.ts new file mode 100644 index 0000000..73154cf --- /dev/null +++ b/packages/builtin-presets/src/shared/vue.ts @@ -0,0 +1,140 @@ +import { + playwrightBrowserAssetsEnvironmentNeed, + type CheckComponent, + type FixComponent, +} from "@ykdz/template-core/module-graph"; +import type { PackageContribution } from "@ykdz/template-core/package-contribution"; +import type { GenerationContext } from "@ykdz/template-core/preset-definition"; +import type { PackageDefinition } from "@ykdz/template-core/project-blueprint-v2"; +import type { RenderOperation } from "@ykdz/template-core/renderer"; + +import { templateSources } from "../template-sources.ts"; + +/** Vue's tooling peers need these repository policy overrides, never Core defaults. */ +export const vuePnpmDependencyOverrides = { + "pinia>typescript": "-", + "vue>typescript": "-", +} as const; + +export const vueApplicationExposure = { + exports: { ".": { default: "./src/main.ts", types: "./src/main.ts" } }, + imports: { "#/*": { default: "./src/*.ts", types: "./src/*.ts" } }, +} as const; + +const sharedVueSourceFiles = [ + "tsconfig.json", + "tsconfig.app.json", + "tsconfig.test.json", + "tsconfig.node.json", + "typescript/run-vue-tsc.ts", + "src/main.ts", + "src/style.css", + "src/stores/counter.ts", + "test/app.test.ts", +] as const; + +export function vueApplicationScripts(): Record { + return { + "build:run": "vite build", + dev: "vite", + "format:check:run": + "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint:run": + "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", + "lint:fix:run": + "oxlint --format=unix --config ../../oxlint.config.ts . --fix", + preview: "vite preview", + "test:run": "vitest run --reporter=agent --silent=passed-only", + "test:e2e:run": "node scripts/run-playwright.ts", + }; +} + +export function sharedVueSourceOperations( + packagePath: string, +): readonly RenderOperation[] { + return sharedVueSourceFiles.map((from) => ({ + kind: "copyFile" as const, + source: templateSources.vue, + from, + to: `${packagePath}/${from.replace("typescript/", "scripts/")}`, + })); +} + +/** Vike shares Vue's compiler runner, but owns its distinct Vike source tree. */ +export function vueTypecheckRunnerSourceOperation( + packagePath: string, +): RenderOperation { + return { + kind: "copyFile", + source: templateSources.vue, + from: "typescript/run-vue-tsc.ts", + to: `${packagePath}/scripts/run-vue-tsc.ts`, + }; +} + +export function vueApplicationChecks( + packagePath: string, +): readonly CheckComponent[] { + const owner = { kind: "package-boundary" as const, path: packagePath }; + return [ + { kind: "typescript-typecheck", owner }, + { kind: "oxc-lint", owner }, + { kind: "oxc-format-check", owner }, + { kind: "build", owner }, + { kind: "unit-test", owner }, + { kind: "e2e-test", owner }, + ]; +} + +export function vueApplicationFixes( + packagePath: string, +): readonly FixComponent[] { + const owner = { kind: "package-boundary" as const, path: packagePath }; + return [ + { kind: "oxc-format-write", owner }, + { kind: "oxc-lint-fix", owner }, + ]; +} + +export function vueApplicationEnvironmentNeeds(packagePath: string) { + return [ + playwrightBrowserAssetsEnvironmentNeed({ + browser: "chromium", + owner: { kind: "package-boundary" as const, path: packagePath }, + }), + ]; +} + +export function vueApplicationManifest(options: { + readonly context: GenerationContext; + readonly definition: PackageDefinition; + readonly scripts: Record; +}): PackageContribution["manifest"] { + return { + name: options.definition.name, + version: "0.0.0", + private: true, + type: "module", + ...vueApplicationExposure, + scripts: options.scripts, + dependencies: { pinia: "catalog:", vue: "catalog:" }, + devDependencies: { + "@playwright/test": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@types/web-bluetooth": "catalog:", + "@vitejs/plugin-vue": "catalog:", + "@vue/tsconfig": "catalog:", + oxfmt: "catalog:", + oxlint: "catalog:", + "oxlint-tsgolint": "catalog:", + tailwindcss: "catalog:", + typescript: "catalog:", + vite: "catalog:", + vitest: "catalog:", + "vue-tsc": "catalog:", + }, + engines: { node: options.context.toolchain.nodeLtsMajor }, + }; +} diff --git a/packages/builtin-presets/src/template-sources.ts b/packages/builtin-presets/src/template-sources.ts new file mode 100644 index 0000000..8f8ab58 --- /dev/null +++ b/packages/builtin-presets/src/template-sources.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + createTemplateSourceHandle, + type TemplateSourceHandle, +} from "@ykdz/template-core/renderer"; + +function templateRoot(...segments: string[]): string { + const sourceDirectory = path.dirname(fileURLToPath(import.meta.url)); + const packageRootSegments = + path.basename(path.dirname(sourceDirectory)) === "dist" + ? ["..", ".."] + : [".."]; + return path.join( + sourceDirectory, + ...packageRootSegments, + "templates", + ...segments, + ); +} + +function source(...segments: string[]): TemplateSourceHandle { + return createTemplateSourceHandle(templateRoot(...segments)); +} + +/** The sole owner of Built-in Presets Template Source references. */ +export const templateSources = { + foundation: source("foundation"), + sharedDevcontainer: source("shared", "devcontainer"), + sharedOxc: source("shared", "oxc"), + editorCustomization: source("shared", "editor-customization"), + vue: source("shared", "vue"), + tsLib: source("ts-lib"), + rustBin: source("rust-bin"), + vueApp: source("vue-app"), + vueHonoApp: source("vue-hono-app"), + vikeApp: source("vike-app"), +} as const; diff --git a/packages/builtin-presets/src/ts-lib/behavior.test.ts b/packages/builtin-presets/src/ts-lib/behavior.test.ts new file mode 100644 index 0000000..a358ff8 --- /dev/null +++ b/packages/builtin-presets/src/ts-lib/behavior.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + createGenerationContext, + planGeneratedRepositoryInitialization, + planGeneratedRepositoryPackageAddition, + resolveBuiltInTemplateSource, + templateSources, +} from "@ykdz/template-builtin-presets"; +import { + renderNewProject, + renderProjectAtomically, + type CopyFileOperation, +} from "@ykdz/template-core/renderer"; +import { describe, expect, expectTypeOf, it } from "vitest"; + +import { tsLibDefinition } from "./definition.ts"; + +describe("ts-lib Built-in Preset Definition behavior", () => { + it("owns its source, explicit exposure, catalog dependency, and package checks", () => { + const context = { + targetDir: "/tmp/demo-library", + projectName: "demo-library", + scope: "demo", + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }; + const contribution = tsLibDefinition.planInitialization(context); + + expect(resolveBuiltInTemplateSource(tsLibDefinition.source, ".")).toMatch( + /templates[\\/]ts-lib$/, + ); + expect(contribution.definition).toEqual({ + name: "@demo/demo-library", + path: "packages/demo-library", + role: "shared-library", + }); + expect(contribution.manifest).toMatchObject({ + dependencies: { valibot: "catalog:" }, + exports: { ".": { default: "./src/index.ts" } }, + imports: { "#/*": { default: "./src/*.ts" } }, + }); + expect(contribution.checks.map((check) => check.kind)).toEqual([ + "typescript-typecheck", + "oxc-lint", + "oxc-format-check", + ]); + expect(contribution.operations).toContainEqual({ + kind: "copyFile", + source: templateSources.tsLib, + from: "turbo.json", + to: "packages/demo-library/turbo.json", + }); + }); + + it("renders its owned source through opaque handles and persists durable addition facts", async () => { + expectTypeOf< + NonNullable + >().not.toEqualTypeOf(); + expect(() => + resolveBuiltInTemplateSource("vue" as never, "src/main.ts"), + ).toThrow("unknown Template Source handle"); + + const targetDir = path.join( + await mkdtemp(path.join(tmpdir(), "template-ts-lib-definition-")), + "demo-lib", + ); + const context = createGenerationContext({ + targetDir, + scope: "demo", + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }); + const initialization = planGeneratedRepositoryInitialization({ + definition: tsLibDefinition, + context, + }); + expect(initialization.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "copyFile", + source: templateSources.tsLib, + from: "src/index.ts", + }), + ]), + ); + await renderNewProject({ + targetRoot: targetDir, + operations: [...initialization.operations], + }); + expect( + await readFile( + path.join(targetDir, "packages/demo-lib/src/index.ts"), + "utf8", + ), + ).toContain("export"); + + const addition = planGeneratedRepositoryPackageAddition({ + definition: tsLibDefinition, + context, + blueprint: initialization.blueprint, + packageLeafName: "utilities", + }); + await renderProjectAtomically({ + targetRoot: targetDir, + operations: [...addition.operations], + }); + expect( + JSON.parse( + await readFile( + path.join(targetDir, "packages/utilities/package.json"), + "utf8", + ), + ), + ).toMatchObject({ name: "@demo/utilities" }); + }); +}); diff --git a/packages/builtin-presets/src/ts-lib/definition.ts b/packages/builtin-presets/src/ts-lib/definition.ts new file mode 100644 index 0000000..344ecba --- /dev/null +++ b/packages/builtin-presets/src/ts-lib/definition.ts @@ -0,0 +1,157 @@ +import { fileURLToPath } from "node:url"; + +import type { PackageContribution } from "@ykdz/template-core/package-contribution"; +import type { + BuiltInPresetDefinition, + GenerationContext, +} from "@ykdz/template-core/preset-definition"; +import type { PackageDefinition } from "@ykdz/template-core/project-blueprint-v2"; +import type { RenderOperation } from "@ykdz/template-core/renderer"; + +import { templateSources } from "../template-sources.ts"; + +function packageScripts(): Record { + return { + "format:check:run": + "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint:run": + "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern node_modules .", + "lint:fix:run": + "oxlint --format=unix --config ../../oxlint.config.ts . --fix", + "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", + }; +} + +function libraryContribution(options: { + readonly context: GenerationContext; + readonly packageLeafName: string; + readonly packagePath: string; +}): PackageContribution { + const definition: PackageDefinition = { + name: `@${options.context.scope}/${options.packageLeafName}`, + path: options.packagePath, + role: "shared-library", + }; + const exposure = { + exports: { ".": { default: "./src/index.ts", types: "./src/index.ts" } }, + imports: { "#/*": { default: "./src/*.ts", types: "./src/*.ts" } }, + }; + const operations: RenderOperation[] = [ + { kind: "writeJson", to: `${definition.path}/package.json`, value: {} }, + { + kind: "copyFile", + source: templateSources.tsLib, + from: "tsconfig.json", + to: `${definition.path}/tsconfig.json`, + }, + { + kind: "copyFile", + source: templateSources.tsLib, + from: "src/index.ts", + to: `${definition.path}/src/index.ts`, + }, + { + kind: "copyFile", + source: templateSources.tsLib, + from: "src/name-schema.ts", + to: `${definition.path}/src/name-schema.ts`, + }, + { + kind: "copyFile", + source: templateSources.tsLib, + from: "turbo.json", + to: `${definition.path}/turbo.json`, + }, + ]; + return { + definition, + exposure, + manifest: { + name: definition.name, + version: "0.0.0", + private: true, + type: "module", + ...exposure, + dependencies: { valibot: "catalog:" }, + devDependencies: { + "@types/node": "catalog:", + oxfmt: "catalog:", + oxlint: "catalog:", + "oxlint-tsgolint": "catalog:", + "typescript-7": "catalog:", + }, + engines: { node: options.context.toolchain.nodeLtsMajor }, + scripts: packageScripts(), + }, + operations, + checks: [ + { + kind: "typescript-typecheck", + owner: { kind: "package-boundary", path: definition.path }, + }, + { + kind: "oxc-lint", + owner: { kind: "package-boundary", path: definition.path }, + }, + { + kind: "oxc-format-check", + owner: { kind: "package-boundary", path: definition.path }, + }, + ], + fixes: [ + { + kind: "oxc-format-write", + owner: { kind: "package-boundary", path: definition.path }, + }, + { + kind: "oxc-lint-fix", + owner: { kind: "package-boundary", path: definition.path }, + }, + ], + environmentNeeds: [], + foundation: { + toolchains: {}, + editorCapabilities: ["oxc-format-lint"], + dependencyMaintenance: { + ecosystems: ["npm", "github-actions", "docker"], + interval: "weekly", + }, + }, + }; +} + +export const tsLibDefinition: BuiltInPresetDefinition = { + metadata: { + name: "ts-lib", + title: "TypeScript library", + description: "Strict TypeScript package with pnpm catalog tooling.", + }, + source: templateSources.tsLib, + plannerSourceFile: fileURLToPath(import.meta.url), + blueprint(context) { + return { + schemaVersion: 2, + packages: [ + libraryContribution({ + context, + packageLeafName: context.projectName, + packagePath: `packages/${context.projectName}`, + }).definition, + ], + }; + }, + planInitialization(context) { + return libraryContribution({ + context, + packageLeafName: context.projectName, + packagePath: `packages/${context.projectName}`, + }); + }, + defaultPackagePath({ packageLeafName }) { + return `packages/${packageLeafName}`; + }, + planPackageAddition({ context, packageLeafName, packagePath }) { + return libraryContribution({ context, packageLeafName, packagePath }); + }, +}; diff --git a/packages/builtin-presets/src/vike-app/behavior.test.ts b/packages/builtin-presets/src/vike-app/behavior.test.ts new file mode 100644 index 0000000..ebdcf7f --- /dev/null +++ b/packages/builtin-presets/src/vike-app/behavior.test.ts @@ -0,0 +1,252 @@ +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + builtInPresetRegistry, + createGenerationContext, + planGeneratedRepositoryInitialization, +} from "@ykdz/template-builtin-presets"; +import { renderNewProject } from "@ykdz/template-core/renderer"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; + +async function assertDockerCopyInputsExist( + repositoryRoot: string, + dockerfile: string, +): Promise { + for (const line of dockerfile.split("\n")) { + if (!line.startsWith("COPY ") || line.includes("--from=")) continue; + const arguments_ = line.slice("COPY ".length).trim().split(/\s+/u); + for (const input of arguments_.slice(0, -1)) { + await expect( + stat(path.join(repositoryRoot, input)), + ).resolves.toBeDefined(); + } + } +} + +describe("vike-app Built-in Preset Definition behavior", () => { + it("registers the complete Vike application Definition", () => { + expect(builtInPresetRegistry.require("vike-app").metadata).toMatchObject({ + name: "vike-app", + title: "Vike app", + }); + }); + + it("owns its Vike Template Source and deployment fragments through real handles", () => { + const plan = planGeneratedRepositoryInitialization({ + definition: builtInPresetRegistry.require("vike-app"), + context: createGenerationContext({ + targetDir: "/tmp/vike-template-source", + scope: "demo", + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }), + }); + expect(plan.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "writeTextTemplate", + from: "web/Dockerfile", + }), + expect.objectContaining({ + kind: "writeTextFromFragments", + to: ".devcontainer/Dockerfile", + fragments: expect.arrayContaining([ + expect.objectContaining({ from: "browser-test.Dockerfile" }), + expect.objectContaining({ from: "shellcheck.Dockerfile" }), + ]), + }), + ]), + ); + }); + + it("projects linked web, database, migration, and deployment boundaries", async () => { + const targetDir = path.join( + await mkdtemp(path.join(tmpdir(), "template-vike-")), + "demo-vike", + ); + const definition = builtInPresetRegistry.require("vike-app"); + const plan = planGeneratedRepositoryInitialization({ + definition, + context: createGenerationContext({ + targetDir, + scope: "demo", + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }), + }); + + expect(plan.blueprint).toMatchObject({ + schemaVersion: 2, + packages: [ + { name: "@demo/web", path: "apps/web", role: "runtime-service" }, + { name: "@demo/db", path: "packages/db", role: "shared-library" }, + { + name: "@demo/db-migrations", + path: "packages/db-migrations", + role: "shared-library", + }, + ], + packageLinkIntents: [ + { consumerPackagePath: "apps/web", providerPackagePath: "packages/db" }, + { + consumerPackagePath: "packages/db-migrations", + providerPackagePath: "packages/db", + }, + ], + }); + expect(plan.deploymentChecks).toEqual([ + { + kind: "deployment-image", + owner: { kind: "package-boundary", path: "apps/web" }, + }, + ]); + expect(plan.nextStepInstructions.map((step) => step.display)).toEqual( + expect.arrayContaining([ + "pnpm --filter ./apps/web exec playwright install chromium", + "sudo apt-get update && sudo apt-get install -y shellcheck", + ]), + ); + + await renderNewProject({ + targetRoot: targetDir, + operations: [...plan.operations], + }); + + expect( + await readFile(path.join(targetDir, ".pnpmfile.cts"), "utf8"), + ).toContain("readPackage"); + + expect( + JSON.parse( + await readFile(path.join(targetDir, "apps/web/package.json"), "utf8"), + ), + ).toMatchObject({ + dependencies: { "@demo/db": "workspace:*" }, + imports: { "#db/*": { default: "@demo/db/*", types: "@demo/db/*" } }, + }); + expect( + JSON.parse( + await readFile(path.join(targetDir, "apps/web/package.json"), "utf8"), + ).dependencies, + ).not.toHaveProperty("drizzle-orm"); + expect( + JSON.parse( + await readFile( + path.join(targetDir, "packages/db-migrations/package.json"), + "utf8", + ), + ).dependencies, + ).toMatchObject({ "@demo/db": "workspace:*" }); + expect( + JSON.parse( + await readFile( + path.join(targetDir, "packages/db/package.json"), + "utf8", + ), + ), + ).toMatchObject({ + exports: { "./types": { types: "./src/types.d.ts" } }, + }); + expect( + await readFile( + path.join(targetDir, "apps/web/pages/index/+Page.vue"), + "utf8", + ), + ).toContain('import type { Todo } from "#db/types";'); + expect( + await readFile( + path.join(targetDir, "apps/web/pages/index/+Page.telefunc.ts"), + "utf8", + ), + ).not.toContain("export type Todo"); + const dockerfile = await readFile( + path.join(targetDir, "apps/web/Dockerfile"), + "utf8", + ); + expect(dockerfile).toContain( + "pnpm exec turbo prune @demo/web @demo/db-migrations --docker", + ); + expect(dockerfile).toContain( + "COPY pnpm-lock.yaml pnpm-workspace.yaml .pnpmfile.cts ./", + ); + expect(dockerfile).toContain('ENV DATABASE_PACKAGE_NAME="@demo/db"'); + expect(dockerfile).toContain("for attempt in 1 2 3; do"); + expect( + await readFile( + path.join(targetDir, "apps/web/scripts/container-entrypoint.sh"), + "utf8", + ), + ).toContain("cd /migration"); + await execa("pnpm", ["install", "--lockfile-only"], { cwd: targetDir }); + await assertDockerCopyInputsExist(targetDir, dockerfile); + expect( + await readFile(path.join(targetDir, ".devcontainer/Dockerfile"), "utf8"), + ).toContain("playwright install-deps chromium"); + expect( + await readFile(path.join(targetDir, ".devcontainer/Dockerfile"), "utf8"), + ).toContain("install -y --no-install-recommends shellcheck"); + const dependabot = await readFile( + path.join(targetDir, ".github/dependabot.yml"), + "utf8", + ); + expect(dependabot).toContain("package-ecosystem: npm\n directory: /"); + expect(dependabot).toContain("directory: /.devcontainer"); + expect(dependabot).toContain("directory: /apps/web"); + expect( + await readFile(path.join(targetDir, ".gitignore"), "utf8"), + ).toContain("playwright-report"); + expect( + await readFile(path.join(targetDir, ".gitignore"), "utf8"), + ).toContain("test-results"); + expect( + await readFile(path.join(targetDir, ".gitignore"), "utf8"), + ).toContain(".template/"); + expect( + await readFile( + path.join(targetDir, ".github/workflows/check.yml"), + "utf8", + ), + ).toContain("check: [root, deployment]"); + expect( + JSON.parse(await readFile(path.join(targetDir, "package.json"), "utf8")), + ).toMatchObject({ + scripts: { + "check:deployment": "pnpm --filter './apps/web' run check:deployment", + check: expect.stringContaining("pnpm run check:boundaries"), + fix: expect.stringContaining("pnpm run format:write:run"), + }, + }); + }); + + it("passes the generated database, browser, and repository checks", async () => { + const targetDir = path.join( + await mkdtemp(path.join(tmpdir(), "template-vike-check-")), + "demo-vike", + ); + const plan = planGeneratedRepositoryInitialization({ + definition: builtInPresetRegistry.require("vike-app"), + context: createGenerationContext({ + targetDir, + scope: "demo", + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }), + }); + await renderNewProject({ + targetRoot: targetDir, + operations: [...plan.operations], + }); + + await execa("pnpm", ["install"], { cwd: targetDir }); + await execa( + "pnpm", + ["--filter", "./apps/web", "exec", "playwright", "install", "chromium"], + { cwd: targetDir }, + ); + for (const _run of [1, 2]) { + await execa("pnpm", ["run", "check"], { + cwd: targetDir, + }); + } + }, 300_000); +}); diff --git a/packages/builtin-presets/src/vike-app/definition.ts b/packages/builtin-presets/src/vike-app/definition.ts new file mode 100644 index 0000000..2b10973 --- /dev/null +++ b/packages/builtin-presets/src/vike-app/definition.ts @@ -0,0 +1,463 @@ +import { fileURLToPath } from "node:url"; + +import { + playwrightBrowserAssetsEnvironmentNeed, + shellCheckEnvironmentNeed, + type CheckComponent, + type FixComponent, +} from "@ykdz/template-core/module-graph"; +import type { PackageContribution } from "@ykdz/template-core/package-contribution"; +import type { + BuiltInPresetDefinition, + GenerationContext, +} from "@ykdz/template-core/preset-definition"; +import type { PackageDefinition } from "@ykdz/template-core/project-blueprint-v2"; +import type { RenderOperation } from "@ykdz/template-core/renderer"; + +import { vueTypecheckRunnerSourceOperation } from "../shared/vue.ts"; +import { templateSources } from "../template-sources.ts"; + +function definitions(context: GenerationContext): { + readonly web: PackageDefinition; + readonly db: PackageDefinition; + readonly migrations: PackageDefinition; +} { + return { + web: { + name: `@${context.scope}/web`, + path: "apps/web", + role: "runtime-service", + }, + db: { + name: `@${context.scope}/db`, + path: "packages/db", + role: "shared-library", + }, + migrations: { + name: `@${context.scope}/db-migrations`, + path: "packages/db-migrations", + role: "shared-library", + }, + }; +} + +function foundation(): PackageContribution["foundation"] { + return { + toolchains: {}, + editorCapabilities: ["oxc-format-lint", "vue", "tailwind", "vitest"], + dependencyMaintenance: { + ecosystems: ["npm", "github-actions", "docker"], + directories: { npm: "/", docker: "/.devcontainer" }, + extraDirectories: { docker: ["/apps/web"] }, + interval: "weekly", + }, + workspacePackageGlobs: ["apps/*", "packages/*"], + }; +} + +function checks( + packagePath: string, + options: { readonly browser?: boolean; readonly unit?: boolean } = {}, +): CheckComponent[] { + const owner = { kind: "package-boundary" as const, path: packagePath }; + return [ + { kind: "typescript-typecheck", owner }, + { kind: "oxc-lint", owner }, + { kind: "oxc-format-check", owner }, + { kind: "build", owner }, + ...((options.unit ?? true) ? [{ kind: "unit-test" as const, owner }] : []), + ...(options.browser ? [{ kind: "e2e-test" as const, owner }] : []), + ]; +} + +function fixes(packagePath: string): FixComponent[] { + const owner = { kind: "package-boundary" as const, path: packagePath }; + return [ + { kind: "oxc-format-write", owner }, + { kind: "oxc-lint-fix", owner }, + ]; +} + +function webScripts(): Record { + const prepareDatabase = + "DATABASE_FILE=../../apps/web/data/app.sqlite pnpm --dir ../../packages/db-migrations run db:prepare:dev"; + return { + "build:run": "vike build", + "check:deployment": "node scripts/check-standalone-deployment.ts", + dev: `${prepareDatabase} && vike dev`, + "format:check:run": + "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint:run": + "shellcheck scripts/container-entrypoint.sh && oxlint --quiet --format=unix --type-aware --config ../../oxlint.config.ts .", + "lint:fix:run": + "oxlint --type-aware --format=unix --config ../../oxlint.config.ts . --fix", + preview: `${prepareDatabase} && vike preview`, + start: "node ./dist/server/index.mjs", + "test:run": + "vitest run --reporter=agent --silent=passed-only --passWithNoTests", + "test:e2e:run": "node scripts/run-playwright.ts", + "typecheck:run": + "node scripts/run-vue-tsc.ts --build --noEmit --pretty false", + }; +} + +function databaseScripts(): Record { + return { + "build:run": "tsc -p tsconfig.json --noEmit", + "db:seed:example": "node scripts/seed-example.ts", + "format:check:run": + "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint:run": + "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", + "lint:fix:run": + "oxlint --format=unix --config ../../oxlint.config.ts . --fix", + "test:run": + 'DATABASE_FILE="$(pwd)/node_modules/.tmp/test.sqlite" pnpm --dir ../db-migrations run db:prepare:test && DATABASE_FILE="$(pwd)/node_modules/.tmp/test.sqlite" vitest run --reporter=agent --silent=passed-only; status=$?; rm -f ./node_modules/.tmp/test.sqlite; exit $status', + "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", + }; +} + +function migrationScripts(databasePackageName: string): Record { + const withDatabasePackage = (command: string): string => + `DATABASE_PACKAGE_NAME=${databasePackageName} ${command}`; + return { + "build:run": "tsc -p tsconfig.json --noEmit", + "db:generate": withDatabasePackage("drizzle-kit generate"), + "db:migrate": withDatabasePackage("drizzle-kit migrate"), + "db:prepare:deploy": "pnpm run db:migrate", + "db:prepare:dev": `DATABASE_PACKAGE_NAME=${databasePackageName} node scripts/prepare-database.ts dev`, + "db:prepare:test": `DATABASE_PACKAGE_NAME=${databasePackageName} node scripts/prepare-database.ts test`, + "db:push": withDatabasePackage( + "mkdir -p data node_modules/.tmp && drizzle-kit push", + ), + "db:studio": withDatabasePackage("drizzle-kit studio"), + "format:check:run": + "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint:run": + "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", + "lint:fix:run": + "oxlint --format=unix --config ../../oxlint.config.ts . --fix", + "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", + }; +} + +function copyOperations( + packagePath: string, + sourceFiles: readonly string[], +): RenderOperation[] { + return sourceFiles.map((from) => ({ + kind: "copyFile" as const, + source: templateSources.vikeApp, + from, + to: `${packagePath}/${from.replace(/^(?:web|db|db-migrations)\//, "")}`, + })); +} + +function webContribution(context: GenerationContext): PackageContribution { + const { web, db } = definitions(context); + const sourceFiles = [ + "web/+server.ts", + "web/.env.example", + "web/Dockerfile.dockerignore", + "web/assets/logo.svg", + "web/components/CounterButton.vue", + "web/components/PageShell.vue", + "web/pages/+Head.vue", + "web/pages/+Layout.vue", + "web/pages/+config.ts", + "web/pages/index/+Page.vue", + "web/pages/tailwind.css", + "web/playwright.config.ts", + "web/scripts/check-standalone-deployment.ts", + "web/scripts/container-entrypoint.sh", + "web/scripts/run-playwright.ts", + "web/server/api.ts", + "web/test/e2e/app.spec.ts", + "web/turbo.json", + "web/types/env.d.ts", + "web/vite.config.ts", + "web/vitest.config.ts", + "web/tsconfig.json", + "web/tsconfig.app.json", + "web/tsconfig.test.json", + "web/tsconfig.node.json", + ] as const; + const operations: RenderOperation[] = [ + { + kind: "writeJson", + to: `${web.path}/package.json`, + value: {}, + multilineArrays: ["files"], + }, + ...copyOperations(web.path, sourceFiles), + { + kind: "writeTextTemplate", + source: templateSources.vikeApp, + from: "web/Dockerfile", + to: `${web.path}/Dockerfile`, + replacements: { + NODE_VERSION: context.toolchain.nodeLtsMajor, + PACKAGE_MANAGER_PIN: context.toolchain.packageManagerPin, + DB_PACKAGE_NAME: definitions(context).db.name, + DB_MIGRATIONS_PACKAGE_NAME: definitions(context).migrations.name, + WEB_PACKAGE_NAME: web.name, + }, + }, + { + kind: "copyFile", + source: templateSources.vikeApp, + from: "web/pages/index/+Page.telefunc.ts", + to: `${web.path}/pages/index/+Page.telefunc.ts`, + }, + { + kind: "replaceAnchors", + path: `${web.path}/pages/index/+Page.telefunc.ts`, + language: "typescript", + replacements: { + "db-package-import": `import { createTodo, listTodos } from "${db.name}/queries/todos";`, + }, + }, + { + kind: "copyFile", + source: templateSources.vikeApp, + from: "web/server/app.ts", + to: `${web.path}/server/app.ts`, + }, + { + kind: "replaceAnchors", + path: `${web.path}/server/app.ts`, + language: "typescript", + replacements: { + "db-package-import": `import { createDatabase } from "${db.name}";\nimport { assertDatabaseReady } from "${db.name}/readiness";`, + }, + }, + { + kind: "copyFile", + source: templateSources.vikeApp, + from: "web/types/global.d.ts", + to: `${web.path}/types/global.d.ts`, + }, + { + kind: "replaceAnchors", + path: `${web.path}/types/global.d.ts`, + language: "typescript", + replacements: { + "db-package-import": `import type { Database } from "${db.name}";`, + }, + }, + vueTypecheckRunnerSourceOperation(web.path), + { + kind: "setExecutable", + path: `${web.path}/scripts/container-entrypoint.sh`, + executable: true, + }, + ]; + const owner = { kind: "package-boundary" as const, path: web.path }; + return { + definition: web, + exposure: { + exports: {}, + imports: { + "#/*": { default: "./*.ts", types: "./*.ts" }, + "#db/*": { default: `${db.name}/*`, types: `${db.name}/*` }, + }, + }, + manifest: { + name: web.name, + version: "0.0.0", + private: true, + type: "module", + files: ["dist"], + imports: { + "#/*": { default: "./*.ts", types: "./*.ts" }, + "#db/*": { default: `${db.name}/*`, types: `${db.name}/*` }, + }, + scripts: webScripts(), + dependencies: { + "@vikejs/hono": "catalog:", + hono: "catalog:", + srvx: "catalog:", + telefunc: "catalog:", + vike: "catalog:", + "vike-vue": "catalog:", + vue: "catalog:", + }, + devDependencies: { + "@playwright/test": "catalog:", + "@tailwindcss/vite": "catalog:", + "@types/node": "catalog:", + "@vitejs/plugin-vue": "catalog:", + "@vue/tsconfig": "catalog:", + oxfmt: "catalog:", + oxlint: "catalog:", + "oxlint-tsgolint": "catalog:", + tailwindcss: "catalog:", + turbo: "catalog:", + typescript: "catalog:", + vite: "catalog:", + vitest: "catalog:", + "vue-tsc": "catalog:", + }, + engines: { node: context.toolchain.nodeLtsMajor }, + packageManager: context.toolchain.packageManagerPin, + }, + operations, + checks: checks(web.path, { browser: true }), + fixes: fixes(web.path), + environmentNeeds: [ + playwrightBrowserAssetsEnvironmentNeed({ browser: "chromium", owner }), + shellCheckEnvironmentNeed(owner), + ], + deploymentChecks: [{ kind: "deployment-image", owner }], + foundation: foundation(), + }; +} + +function databaseContribution(context: GenerationContext): PackageContribution { + const { db } = definitions(context); + const sourceFiles = [ + "db/turbo.json", + "db/tsconfig.json", + "db/scripts/seed-example.ts", + "db/src/db.ts", + "db/src/index.ts", + "db/src/queries/todos.ts", + "db/src/readiness.ts", + "db/src/seed/example.ts", + "db/src/schema.ts", + "db/src/types.d.ts", + "db/test/todos.test.ts", + ] as const; + const exposure = { + exports: { + ".": { default: "./src/index.ts", types: "./src/index.ts" }, + "./schema": { default: "./src/schema.ts", types: "./src/schema.ts" }, + "./types": { default: "./src/types.d.ts", types: "./src/types.d.ts" }, + "./queries/todos": { + default: "./src/queries/todos.ts", + types: "./src/queries/todos.ts", + }, + "./readiness": { + default: "./src/readiness.ts", + types: "./src/readiness.ts", + }, + }, + imports: { "#db/*": { default: "./src/*.ts", types: "./src/*.ts" } }, + }; + return { + definition: db, + exposure, + manifest: { + name: db.name, + version: "0.0.0", + private: true, + type: "module", + ...exposure, + scripts: databaseScripts(), + dependencies: { "drizzle-orm": "catalog:" }, + devDependencies: { + "@types/node": "catalog:", + oxfmt: "catalog:", + oxlint: "catalog:", + "oxlint-tsgolint": "catalog:", + "typescript-7": "catalog:", + vitest: "catalog:", + }, + engines: { node: context.toolchain.nodeLtsMajor }, + }, + operations: [ + { kind: "writeJson", to: `${db.path}/package.json`, value: {} }, + ...copyOperations(db.path, sourceFiles), + ], + checks: checks(db.path), + fixes: fixes(db.path), + environmentNeeds: [], + foundation: foundation(), + }; +} + +function migrationsContribution( + context: GenerationContext, +): PackageContribution { + const { db, migrations } = definitions(context); + const sourceFiles = [ + "db-migrations/drizzle.config.ts", + "db-migrations/tsconfig.json", + "db-migrations/drizzle/migrations/20260709120325_old_captain_flint/migration.sql", + "db-migrations/drizzle/migrations/20260709120325_old_captain_flint/snapshot.json", + "db-migrations/scripts/prepare-database.ts", + "db-migrations/turbo.json", + ] as const; + return { + definition: migrations, + exposure: { exports: {}, imports: {} }, + manifest: { + name: migrations.name, + version: "0.0.0", + private: true, + type: "module", + files: ["drizzle.config.ts", "drizzle/migrations"], + scripts: migrationScripts(db.name), + dependencies: { "drizzle-kit": "catalog:", "drizzle-orm": "catalog:" }, + devDependencies: { + "@types/node": "catalog:", + oxfmt: "catalog:", + oxlint: "catalog:", + "oxlint-tsgolint": "catalog:", + "typescript-7": "catalog:", + }, + engines: { node: context.toolchain.nodeLtsMajor }, + }, + operations: [ + { + kind: "writeJson", + to: `${migrations.path}/package.json`, + value: {}, + multilineArrays: ["files"], + }, + ...copyOperations(migrations.path, sourceFiles), + ], + // This package has no test:run manifest script, so the durable + // contribution reconstruction must not invent a unit-test check for it. + checks: checks(migrations.path, { unit: false }), + fixes: fixes(migrations.path), + environmentNeeds: [], + foundation: foundation(), + }; +} + +export const vikeAppDefinition: BuiltInPresetDefinition = { + metadata: { + name: "vike-app", + title: "Vike app", + description: + "Vike, Hono, Telefunc, Drizzle, and Vue workspace with separate database and migration packages.", + }, + source: templateSources.vikeApp, + plannerSourceFile: fileURLToPath(import.meta.url), + blueprint(context) { + const { web, db, migrations } = definitions(context); + return { + schemaVersion: 2, + packages: [web, db, migrations], + packageLinkIntents: [ + { consumerPackagePath: web.path, providerPackagePath: db.path }, + { + consumerPackagePath: migrations.path, + providerPackagePath: db.path, + }, + ], + }; + }, + planInitialization: webContribution, + planInitializationContributions(context) { + return [ + webContribution(context), + databaseContribution(context), + migrationsContribution(context), + ]; + }, +}; diff --git a/packages/builtin-presets/src/vue-app/behavior.test.ts b/packages/builtin-presets/src/vue-app/behavior.test.ts new file mode 100644 index 0000000..bdb24f5 --- /dev/null +++ b/packages/builtin-presets/src/vue-app/behavior.test.ts @@ -0,0 +1,214 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + builtInPresetRegistry, + createGenerationContext, + planGeneratedRepositoryInitialization, + planGeneratedRepositoryPackageAddition, +} from "@ykdz/template-builtin-presets"; +import { + renderNewProject, + renderProject, + renderProjectAtomically, +} from "@ykdz/template-core/renderer"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; + +import { vueAppDefinition } from "./definition.ts"; + +describe("vue-app Built-in Preset Definition behavior", () => { + const toolchain = { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }; + + it("owns a browser application contribution with explicit exposure and preparation", () => { + expect(builtInPresetRegistry.require("vue-app").metadata).toEqual( + vueAppDefinition.metadata, + ); + const contribution = vueAppDefinition.planInitialization({ + targetDir: "/tmp/demo-vue", + projectName: "demo-vue", + scope: "demo", + toolchain, + }); + + expect(vueAppDefinition.metadata).toEqual({ + name: "vue-app", + title: "Vue app", + description: + "Vue app workspace with Vite, Tailwind, Pinia, and test tooling.", + }); + expect(contribution.definition).toEqual({ + name: "@demo/web", + path: "apps/web", + role: "runtime-service", + }); + expect(contribution.exposure).toEqual({ + exports: { ".": { default: "./src/main.ts", types: "./src/main.ts" } }, + imports: { "#/*": { default: "./src/*.ts", types: "./src/*.ts" } }, + }); + expect(contribution.checks.map((check) => check.kind)).toEqual([ + "typescript-typecheck", + "oxc-lint", + "oxc-format-check", + "build", + "unit-test", + "e2e-test", + ]); + expect(contribution.environmentNeeds).toMatchObject([ + { kind: "playwright-browser-assets", browser: "chromium" }, + ]); + expect(contribution.foundation).toMatchObject({ + workspacePackageGlobs: ["apps/*"], + }); + }); + + it("initializes and adds Vue applications at default and explicit Package Paths", async () => { + const targetDir = path.join( + await mkdtemp(path.join(tmpdir(), "template-vue-")), + "demo-vue", + ); + const context = createGenerationContext({ + targetDir, + scope: "demo", + toolchain, + }); + const initialization = planGeneratedRepositoryInitialization({ + definition: vueAppDefinition, + context, + }); + + expect( + initialization.nextStepInstructions.map((step) => step.display), + ).toContain("pnpm --filter ./apps/web exec playwright install chromium"); + expect(initialization.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "copyFile", + from: "src/App.vue", + to: "apps/web/src/App.vue", + }), + ]), + ); + + await renderNewProject({ + targetRoot: targetDir, + operations: [...initialization.operations], + }); + expect( + await readFile(path.join(targetDir, "apps/web/vite.config.ts"), "utf8"), + ).toContain("@tailwindcss/vite"); + + const defaultAddition = planGeneratedRepositoryPackageAddition({ + definition: vueAppDefinition, + context, + blueprint: initialization.blueprint, + packageLeafName: "admin", + }); + await renderProjectAtomically({ + targetRoot: targetDir, + operations: [...defaultAddition.operations], + }); + const explicitAddition = planGeneratedRepositoryPackageAddition({ + definition: vueAppDefinition, + context, + blueprint: defaultAddition.blueprint, + packageLeafName: "portal", + packagePath: "products/portal", + }); + await renderProject({ + targetRoot: targetDir, + operations: [...explicitAddition.operations], + }); + + expect(explicitAddition.blueprint.packages).toEqual( + expect.arrayContaining([ + { name: "@demo/admin", path: "apps/admin", role: "runtime-service" }, + { + name: "@demo/portal", + path: "products/portal", + role: "runtime-service", + }, + ]), + ); + expect( + JSON.parse( + await readFile(path.join(targetDir, "apps/admin/package.json"), "utf8"), + ), + ).toMatchObject({ name: "@demo/admin" }); + + await execa("pnpm", ["install"], { cwd: targetDir }); + await execa( + "pnpm", + ["--filter", "./apps/web", "exec", "playwright", "install", "chromium"], + { cwd: targetDir }, + ); + await execa("pnpm", ["run", "check"], { cwd: targetDir }); + }, 300_000); + + it("owns its default Package Path and updates an explicit Link Intent atomically", async () => { + const targetDir = path.join( + await mkdtemp(path.join(tmpdir(), "template-vue-link-")), + "demo-vue", + ); + const context = createGenerationContext({ + targetDir, + scope: "demo", + toolchain, + }); + const initialization = planGeneratedRepositoryInitialization({ + definition: vueAppDefinition, + context, + }); + await renderNewProject({ + targetRoot: targetDir, + operations: [...initialization.operations], + }); + + const addition = planGeneratedRepositoryPackageAddition({ + definition: vueAppDefinition, + context, + blueprint: initialization.blueprint, + packageLeafName: "admin", + linkFrom: ["apps/web"], + }); + + expect(addition.blueprint.packages).toContainEqual({ + name: "@demo/admin", + path: "apps/admin", + role: "runtime-service", + }); + expect(addition.blueprint.packageLinkIntents).toContainEqual({ + consumerPackagePath: "apps/web", + providerPackagePath: "apps/admin", + }); + expect(addition.operations).toContainEqual( + expect.objectContaining({ + kind: "mergeJson", + to: "apps/web/package.json", + value: { dependencies: { "@demo/admin": "workspace:*" } }, + provenance: expect.objectContaining({ + definitionName: "vue-app", + planningContribution: "foundationPlan", + }), + }), + ); + + await renderProjectAtomically({ + targetRoot: targetDir, + operations: [...addition.operations], + }); + expect( + JSON.parse( + await readFile(path.join(targetDir, "apps/web/package.json"), "utf8"), + ), + ).toMatchObject({ dependencies: { "@demo/admin": "workspace:*" } }); + await execa("pnpm", ["install"], { cwd: targetDir }); + await execa( + "pnpm", + ["--filter", "./apps/web", "exec", "playwright", "install", "chromium"], + { cwd: targetDir }, + ); + await execa("pnpm", ["run", "check"], { cwd: targetDir }); + }, 300_000); +}); diff --git a/packages/builtin-presets/src/vue-app/definition.ts b/packages/builtin-presets/src/vue-app/definition.ts new file mode 100644 index 0000000..7b09e22 --- /dev/null +++ b/packages/builtin-presets/src/vue-app/definition.ts @@ -0,0 +1,119 @@ +import type { PackageContribution } from "@ykdz/template-core/package-contribution"; +import type { + BuiltInPresetDefinition, + GenerationContext, +} from "@ykdz/template-core/preset-definition"; +import type { PackageDefinition } from "@ykdz/template-core/project-blueprint-v2"; +import type { RenderOperation } from "@ykdz/template-core/renderer"; + +import { + sharedVueSourceOperations, + vueApplicationChecks, + vueApplicationEnvironmentNeeds, + vueApplicationExposure, + vueApplicationFixes, + vueApplicationManifest, + vueApplicationScripts, +} from "../shared/vue.ts"; +import { templateSources } from "../template-sources.ts"; + +function packageScripts(): Record { + return { + ...vueApplicationScripts(), + "typecheck:run": + "node scripts/run-vue-tsc.ts --build --noEmit --pretty false", + }; +} + +function appContribution(options: { + readonly context: GenerationContext; + readonly packageLeafName: string; + readonly packagePath: string; +}): PackageContribution { + const definition: PackageDefinition = { + name: `@${options.context.scope}/${options.packageLeafName}`, + path: options.packagePath, + role: "runtime-service", + }; + const exposure = vueApplicationExposure; + const sourceFiles = [ + "env.d.ts", + "index.html", + "playwright.config.ts", + "vite.config.ts", + "vitest.config.ts", + "turbo.json", + "scripts/run-playwright.ts", + "src/App.vue", + "test/e2e/app.spec.ts", + ] as const; + const operations: RenderOperation[] = [ + { kind: "writeJson", to: `${definition.path}/package.json`, value: {} }, + ...sourceFiles.map((from) => ({ + kind: "copyFile" as const, + source: templateSources.vueApp, + from, + to: `${definition.path}/${from.replace("typescript/", "scripts/")}`, + })), + ...sharedVueSourceOperations(definition.path), + ]; + return { + definition, + exposure, + manifest: vueApplicationManifest({ + context: options.context, + definition, + scripts: packageScripts(), + }), + operations, + checks: vueApplicationChecks(definition.path), + fixes: vueApplicationFixes(definition.path), + environmentNeeds: vueApplicationEnvironmentNeeds(definition.path), + foundation: { + toolchains: {}, + editorCapabilities: ["oxc-format-lint", "vue", "tailwind", "vitest"], + dependencyMaintenance: { + ecosystems: ["npm", "github-actions", "docker"], + interval: "weekly", + }, + workspacePackageGlobs: [`${options.packagePath.split("/")[0]}/*`], + }, + }; +} + +export const vueAppDefinition: BuiltInPresetDefinition = { + metadata: { + name: "vue-app", + title: "Vue app", + description: + "Vue app workspace with Vite, Tailwind, Pinia, and test tooling.", + }, + source: templateSources.vueApp, + plannerSourceFile: fileURLToPath(import.meta.url), + blueprint(context) { + return { + schemaVersion: 2, + packages: [ + appContribution({ + context, + packageLeafName: "web", + packagePath: "apps/web", + }).definition, + ], + }; + }, + planInitialization(context) { + return appContribution({ + context, + packageLeafName: "web", + packagePath: "apps/web", + }); + }, + defaultPackagePath({ packageLeafName }) { + return `apps/${packageLeafName}`; + }, + planPackageAddition({ context, packageLeafName, packagePath }) { + return appContribution({ context, packageLeafName, packagePath }); + }, +}; +import { fileURLToPath } from "node:url"; diff --git a/packages/builtin-presets/src/vue-hono-app/behavior.test.ts b/packages/builtin-presets/src/vue-hono-app/behavior.test.ts new file mode 100644 index 0000000..fc8fb84 --- /dev/null +++ b/packages/builtin-presets/src/vue-hono-app/behavior.test.ts @@ -0,0 +1,120 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + builtInPresetRegistry, + createGenerationContext, + planGeneratedRepositoryInitialization, +} from "@ykdz/template-builtin-presets"; +import { renderNewProject } from "@ykdz/template-core/renderer"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; + +import { vueHonoAppDefinition } from "./definition.ts"; + +describe("vue-hono-app Built-in Preset Definition behavior", () => { + const toolchain = { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }; + + it("owns API and web contributions and derives their workspace link", async () => { + expect(builtInPresetRegistry.require("vue-hono-app").metadata).toEqual( + vueHonoAppDefinition.metadata, + ); + const targetDir = path.join( + await mkdtemp(path.join(tmpdir(), "template-vue-hono-")), + "demo-stack", + ); + const context = createGenerationContext({ + targetDir, + scope: "demo", + toolchain, + }); + const plan = planGeneratedRepositoryInitialization({ + definition: vueHonoAppDefinition, + context, + }); + + expect(plan.blueprint).toMatchObject({ + schemaVersion: 2, + packages: [ + { name: "@demo/api", path: "apps/api", role: "runtime-service" }, + { name: "@demo/web", path: "apps/web", role: "runtime-service" }, + ], + packageLinkIntents: [ + { + consumerPackagePath: "apps/web", + providerPackagePath: "apps/api", + }, + ], + }); + expect(plan.nextStepInstructions.map((step) => step.display)).toContain( + "pnpm --filter ./apps/web exec playwright install chromium", + ); + + await renderNewProject({ + targetRoot: targetDir, + operations: [...plan.operations], + }); + + expect( + JSON.parse( + await readFile(path.join(targetDir, "apps/api/package.json"), "utf8"), + ), + ).toMatchObject({ + name: "@demo/api", + exports: { + ".": { default: "./dist/index.js", types: "./dist/index.d.ts" }, + }, + }); + expect( + JSON.parse( + await readFile(path.join(targetDir, "apps/web/package.json"), "utf8"), + ), + ).toMatchObject({ dependencies: { "@demo/api": "workspace:*" } }); + expect( + JSON.parse(await readFile(path.join(targetDir, "turbo.json"), "utf8")), + ).toMatchObject({ + boundaries: { + tags: { + app: { dependencies: { allow: ["app", "library"] } }, + library: { dependencies: { allow: ["library"] } }, + }, + }, + tasks: { "build:run": { dependsOn: ["^build:run"] } }, + }); + expect( + await readFile(path.join(targetDir, "apps/api/src/runtime.ts"), "utf8"), + ).toContain("new Hono()"); + expect( + await readFile(path.join(targetDir, "apps/web/src/api.ts"), "utf8"), + ).toContain("/api/health"); + }); + + it("generates a checked browser-backed multi-package workspace", async () => { + const targetDir = path.join( + await mkdtemp(path.join(tmpdir(), "template-vue-hono-check-")), + "demo-stack", + ); + const context = createGenerationContext({ + targetDir, + scope: "demo", + toolchain, + }); + const plan = planGeneratedRepositoryInitialization({ + definition: vueHonoAppDefinition, + context, + }); + await renderNewProject({ + targetRoot: targetDir, + operations: [...plan.operations], + }); + + await execa("pnpm", ["install"], { cwd: targetDir }); + await execa( + "pnpm", + ["--filter", "./apps/web", "exec", "playwright", "install", "chromium"], + { cwd: targetDir }, + ); + await execa("pnpm", ["run", "check"], { cwd: targetDir }); + }, 300_000); +}); diff --git a/packages/builtin-presets/src/vue-hono-app/definition.ts b/packages/builtin-presets/src/vue-hono-app/definition.ts new file mode 100644 index 0000000..3ce459b --- /dev/null +++ b/packages/builtin-presets/src/vue-hono-app/definition.ts @@ -0,0 +1,209 @@ +import { + type CheckComponent, + type FixComponent, +} from "@ykdz/template-core/module-graph"; +import type { PackageContribution } from "@ykdz/template-core/package-contribution"; +import type { + BuiltInPresetDefinition, + GenerationContext, +} from "@ykdz/template-core/preset-definition"; +import type { PackageDefinition } from "@ykdz/template-core/project-blueprint-v2"; +import type { RenderOperation } from "@ykdz/template-core/renderer"; + +import { + sharedVueSourceOperations, + vueApplicationChecks, + vueApplicationEnvironmentNeeds, + vueApplicationExposure, + vueApplicationFixes, + vueApplicationManifest, + vueApplicationScripts, +} from "../shared/vue.ts"; +import { templateSources } from "../template-sources.ts"; + +function apiScripts(): Record { + return { + "build:run": + "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", + dev: "node --watch src/server.ts", + "format:check:run": + "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint:run": + "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", + "lint:fix:run": + "oxlint --format=unix --config ../../oxlint.config.ts . --fix", + start: "node dist/server.js", + "test:run": "vitest run --reporter=agent --silent=passed-only", + "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", + }; +} + +function webScripts(): Record { + return { + ...vueApplicationScripts(), + "typecheck:run": "node scripts/run-vue-tsc.ts --build --pretty false", + }; +} + +function packageFoundation(): PackageContribution["foundation"] { + return { + toolchains: {}, + editorCapabilities: ["oxc-format-lint", "vue", "tailwind", "vitest"], + dependencyMaintenance: { + ecosystems: ["npm", "github-actions", "docker"], + interval: "weekly", + }, + workspacePackageGlobs: ["apps/*"], + }; +} + +function apiContribution(context: GenerationContext): PackageContribution { + const definition: PackageDefinition = { + name: `@${context.scope}/api`, + path: "apps/api", + role: "runtime-service", + }; + const exposure = { + exports: { + ".": { default: "./dist/index.js", types: "./dist/index.d.ts" }, + }, + imports: { "#/*": { default: "./dist/*.js", types: "./src/*.ts" } }, + }; + const owner = { kind: "package-boundary" as const, path: definition.path }; + const checks: CheckComponent[] = [ + { kind: "typescript-typecheck", owner }, + { kind: "oxc-lint", owner }, + { kind: "oxc-format-check", owner }, + { kind: "build", owner }, + { kind: "unit-test", owner }, + ]; + const fixes: FixComponent[] = [ + { kind: "oxc-format-write", owner }, + { kind: "oxc-lint-fix", owner }, + ]; + const sourceFiles = [ + "turbo.json", + "vitest.config.ts", + "tsconfig.json", + "tsconfig.build.json", + "src/index.ts", + "src/runtime.ts", + "src/server.ts", + "test/app.test.ts", + ] as const; + const operations: RenderOperation[] = [ + { kind: "writeJson", to: `${definition.path}/package.json`, value: {} }, + ...sourceFiles.map((from) => ({ + kind: "copyFile" as const, + source: templateSources.vueHonoApp, + from: `api/${from}`, + to: `${definition.path}/${from}`, + })), + ]; + return { + definition, + exposure, + manifest: { + name: definition.name, + version: "0.0.0", + private: true, + type: "module", + ...exposure, + scripts: apiScripts(), + dependencies: { "@hono/node-server": "catalog:", hono: "catalog:" }, + devDependencies: { + "@types/node": "catalog:", + oxfmt: "catalog:", + oxlint: "catalog:", + "oxlint-tsgolint": "catalog:", + "tsc-alias": "catalog:", + "typescript-7": "catalog:", + vitest: "catalog:", + }, + engines: { node: context.toolchain.nodeLtsMajor }, + }, + operations, + checks, + fixes, + environmentNeeds: [], + foundation: packageFoundation(), + }; +} + +function webContribution(context: GenerationContext): PackageContribution { + const definition: PackageDefinition = { + name: `@${context.scope}/web`, + path: "apps/web", + role: "runtime-service", + }; + const exposure = vueApplicationExposure; + const localSourceFiles = [ + "env.d.ts", + "index.html", + "playwright.config.ts", + "vite.config.ts", + "vitest.config.ts", + "turbo.json", + "scripts/run-playwright.ts", + "src/api.ts", + "src/App.vue", + "test/e2e/app.spec.ts", + ] as const; + const operations: RenderOperation[] = [ + { kind: "writeJson", to: `${definition.path}/package.json`, value: {} }, + ...localSourceFiles.map((from) => ({ + kind: "copyFile" as const, + source: templateSources.vueHonoApp, + from: `web/${from}`, + to: `${definition.path}/${from}`, + })), + ...sharedVueSourceOperations(definition.path), + ]; + return { + definition, + exposure, + manifest: vueApplicationManifest({ + context, + definition, + scripts: webScripts(), + }), + operations, + checks: vueApplicationChecks(definition.path), + fixes: vueApplicationFixes(definition.path), + environmentNeeds: vueApplicationEnvironmentNeeds(definition.path), + foundation: packageFoundation(), + }; +} + +export const vueHonoAppDefinition: BuiltInPresetDefinition = { + metadata: { + name: "vue-hono-app", + title: "Vue Hono app", + description: + "Full-stack Vue and Hono workspace with separated app package boundaries.", + }, + source: templateSources.vueHonoApp, + plannerSourceFile: fileURLToPath(import.meta.url), + blueprint(context) { + const api = apiContribution(context); + const web = webContribution(context); + return { + schemaVersion: 2, + packages: [api.definition, web.definition], + packageLinkIntents: [ + { + consumerPackagePath: web.definition.path, + providerPackagePath: api.definition.path, + }, + ], + }; + }, + planInitialization(context) { + return apiContribution(context); + }, + planInitializationContributions(context) { + return [apiContribution(context), webContribution(context)]; + }, +}; +import { fileURLToPath } from "node:url"; diff --git a/packages/builtin-source/templates/rust-bin/.github/dependabot.yml b/packages/builtin-presets/templates/foundation/.github/dependabot-rust.yml similarity index 100% rename from packages/builtin-source/templates/rust-bin/.github/dependabot.yml rename to packages/builtin-presets/templates/foundation/.github/dependabot-rust.yml diff --git a/packages/builtin-presets/templates/foundation/.github/dependabot.dynamic.template b/packages/builtin-presets/templates/foundation/.github/dependabot.dynamic.template new file mode 100644 index 0000000..97a71f1 --- /dev/null +++ b/packages/builtin-presets/templates/foundation/.github/dependabot.dynamic.template @@ -0,0 +1,4 @@ +version: 2 + +updates: +{{DEPENDABOT_UPDATES}} diff --git a/packages/builtin-source/templates/ts-lib/.github/dependabot.yml b/packages/builtin-presets/templates/foundation/.github/dependabot.yml similarity index 100% rename from packages/builtin-source/templates/ts-lib/.github/dependabot.yml rename to packages/builtin-presets/templates/foundation/.github/dependabot.yml diff --git a/packages/builtin-source/templates/vue-app/.github/workflows/check.yml b/packages/builtin-presets/templates/foundation/.github/workflows/check-playwright.yml similarity index 100% rename from packages/builtin-source/templates/vue-app/.github/workflows/check.yml rename to packages/builtin-presets/templates/foundation/.github/workflows/check-playwright.yml diff --git a/packages/builtin-source/templates/rust-bin/.github/workflows/check.yml b/packages/builtin-presets/templates/foundation/.github/workflows/check-rust.yml similarity index 100% rename from packages/builtin-source/templates/rust-bin/.github/workflows/check.yml rename to packages/builtin-presets/templates/foundation/.github/workflows/check-rust.yml diff --git a/packages/builtin-presets/templates/foundation/.github/workflows/check.dynamic.template b/packages/builtin-presets/templates/foundation/.github/workflows/check.dynamic.template new file mode 100644 index 0000000..459a0b9 --- /dev/null +++ b/packages/builtin-presets/templates/foundation/.github/workflows/check.dynamic.template @@ -0,0 +1,19 @@ +name: Check + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest{{DEPLOYMENT_MATRIX}} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - run: corepack enable{{RUST_CI_PREPARATION}}{{DEPLOYMENT_DOCKER_PREPARATION}} + - run: pnpm install{{CHECK_ENVIRONMENT_PREPARATION}} + - run: pnpm run check{{ROOT_CHECK_CONDITION}}{{DEPLOYMENT_CHECK}} diff --git a/packages/builtin-source/templates/ts-lib/.github/workflows/check.yml b/packages/builtin-presets/templates/foundation/.github/workflows/check.yml similarity index 100% rename from packages/builtin-source/templates/ts-lib/.github/workflows/check.yml rename to packages/builtin-presets/templates/foundation/.github/workflows/check.yml diff --git a/packages/builtin-presets/templates/foundation/.pnpmfile.cts b/packages/builtin-presets/templates/foundation/.pnpmfile.cts new file mode 100644 index 0000000..b504583 --- /dev/null +++ b/packages/builtin-presets/templates/foundation/.pnpmfile.cts @@ -0,0 +1,14 @@ +const typeOnlyPeerOwners = new Set(["valibot"]); + +function readPackage(pkg: { + name?: string; + peerDependencies?: Record; + peerDependenciesMeta?: Record; +}) { + if (!pkg.name || !typeOnlyPeerOwners.has(pkg.name)) return pkg; + delete pkg.peerDependencies?.typescript; + delete pkg.peerDependenciesMeta?.typescript; + return pkg; +} + +export default { hooks: { readPackage } }; diff --git a/packages/builtin-presets/templates/foundation/TODO.md.template b/packages/builtin-presets/templates/foundation/TODO.md.template new file mode 100644 index 0000000..9b6686c --- /dev/null +++ b/packages/builtin-presets/templates/foundation/TODO.md.template @@ -0,0 +1,5 @@ +# Next steps + +Run these commands from the repository root: + +{{NEXT_STEPS}} diff --git a/packages/builtin-presets/templates/foundation/devcontainer.json b/packages/builtin-presets/templates/foundation/devcontainer.json new file mode 100644 index 0000000..4933d17 --- /dev/null +++ b/packages/builtin-presets/templates/foundation/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "{{PROJECT_NAME}}", + "build": { + "dockerfile": "Dockerfile", + "args": { + "NODE_VERSION": "{{NODE_LTS_MAJOR}}", + "PACKAGE_MANAGER_PIN": "{{PACKAGE_MANAGER_PIN}}", + "PLAYWRIGHT_CLI_PACKAGE": "{{PLAYWRIGHT_CLI_PACKAGE}}" + } + }, + "customizations": { + "vscode": { + "extensions": ["oxc.oxc-vscode"] + } + } +} diff --git a/packages/builtin-presets/templates/foundation/gitignore b/packages/builtin-presets/templates/foundation/gitignore new file mode 100644 index 0000000..41b18e0 --- /dev/null +++ b/packages/builtin-presets/templates/foundation/gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.turbo/ +coverage/ +.env +.template/ +playwright-report/ +test-results/ diff --git a/packages/builtin-presets/templates/foundation/pnpm-workspace.dynamic.txt b/packages/builtin-presets/templates/foundation/pnpm-workspace.dynamic.txt new file mode 100644 index 0000000..70e5283 --- /dev/null +++ b/packages/builtin-presets/templates/foundation/pnpm-workspace.dynamic.txt @@ -0,0 +1,23 @@ +packages: +{{WORKSPACE_PACKAGE_GLOBS}} + +nodeLinker: isolated +autoInstallPeers: false +injectWorkspacePackages: true +dedupeInjectedDeps: false + +syncInjectedDepsAfterScripts: + - build + - build:run + +minimumReleaseAge: 1440 +minimumReleaseAgeStrict: true + +overrides: +{{DEPENDENCY_OVERRIDES}} + +allowBuilds: + esbuild: true + +catalog: +{{DEPENDENCY_CATALOG}} diff --git a/packages/builtin-presets/templates/foundation/pnpm-workspace.yaml b/packages/builtin-presets/templates/foundation/pnpm-workspace.yaml new file mode 100644 index 0000000..4fe3bce --- /dev/null +++ b/packages/builtin-presets/templates/foundation/pnpm-workspace.yaml @@ -0,0 +1,29 @@ +packages: + - packages/* + +nodeLinker: isolated +autoInstallPeers: false +injectWorkspacePackages: true +dedupeInjectedDeps: false + +syncInjectedDepsAfterScripts: + - build + - build:run + +minimumReleaseAge: 1440 +minimumReleaseAgeStrict: true + +overrides: + "valibot>typescript": "-" + +allowBuilds: + esbuild: true + +catalog: + "@types/node": ^24.13.3 + oxfmt: ^0.58.0 + oxlint: ^1.73.0 + oxlint-tsgolint: ^0.24.0 + turbo: 2.10.2 + typescript-7: npm:typescript@^7.0.2 + valibot: ^1.4.2 diff --git a/packages/builtin-presets/templates/foundation/rust/.vscode/extensions.json b/packages/builtin-presets/templates/foundation/rust/.vscode/extensions.json new file mode 100644 index 0000000..201e87c --- /dev/null +++ b/packages/builtin-presets/templates/foundation/rust/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml"] +} diff --git a/packages/builtin-presets/templates/foundation/rust/.vscode/settings.json b/packages/builtin-presets/templates/foundation/rust/.vscode/settings.json new file mode 100644 index 0000000..2a0b65e --- /dev/null +++ b/packages/builtin-presets/templates/foundation/rust/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + } +} diff --git a/packages/builtin-presets/templates/foundation/rust/devcontainer/devcontainer.json b/packages/builtin-presets/templates/foundation/rust/devcontainer/devcontainer.json new file mode 100644 index 0000000..9d0e615 --- /dev/null +++ b/packages/builtin-presets/templates/foundation/rust/devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "name": "{{PROJECT_NAME}}", + "build": { + "dockerfile": "Dockerfile", + "args": { + "NODE_VERSION": "{{NODE_LTS_MAJOR}}", + "PACKAGE_MANAGER_PIN": "{{PACKAGE_MANAGER_PIN}}", + "RUST_TOOLCHAIN": "{{RUST_TOOLCHAIN}}" + } + }, + "customizations": { + "vscode": { + "extensions": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml"] + } + }, + "mounts": [ + "source=${localWorkspaceFolderBasename}-cargo-registry,target=/usr/local/cargo/registry,type=volume", + "source=${localWorkspaceFolderBasename}-cargo-git,target=/usr/local/cargo/git,type=volume", + "source=${localWorkspaceFolderBasename}-target,target=${containerWorkspaceFolder}/target,type=volume" + ] +} diff --git a/packages/builtin-source/templates/shared/devcontainer/rust.Dockerfile b/packages/builtin-presets/templates/foundation/rust/devcontainer/rust.Dockerfile similarity index 100% rename from packages/builtin-source/templates/shared/devcontainer/rust.Dockerfile rename to packages/builtin-presets/templates/foundation/rust/devcontainer/rust.Dockerfile diff --git a/packages/builtin-source/templates/rust-bin/rust-toolchain.toml b/packages/builtin-presets/templates/foundation/rust/rust-toolchain.toml similarity index 100% rename from packages/builtin-source/templates/rust-bin/rust-toolchain.toml rename to packages/builtin-presets/templates/foundation/rust/rust-toolchain.toml diff --git a/packages/builtin-presets/templates/foundation/tsconfig.json b/packages/builtin-presets/templates/foundation/tsconfig.json new file mode 100644 index 0000000..b7e62ca --- /dev/null +++ b/packages/builtin-presets/templates/foundation/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.config.json", + "files": ["oxfmt.config.ts", "oxlint.config.ts"] +} diff --git a/packages/builtin-presets/templates/foundation/turbo.json b/packages/builtin-presets/templates/foundation/turbo.json new file mode 100644 index 0000000..fdf80a2 --- /dev/null +++ b/packages/builtin-presets/templates/foundation/turbo.json @@ -0,0 +1,18 @@ +{ + "boundaries": { + "tags": { + "app": { "dependencies": { "allow": ["app", "library"] } }, + "library": { "dependencies": { "allow": ["library"] } } + } + }, + "tasks": { + "format:check:run": {}, + "lint:run": {}, + "typecheck:run": { "dependsOn": ["^typecheck:run"] }, + "build:run": { "dependsOn": ["^build:run"] }, + "test:run": { "dependsOn": ["build:run"] }, + "test:e2e:run": { "dependsOn": ["build:run"] }, + "format:write:run": { "cache": false }, + "lint:fix:run": { "cache": false } + } +} diff --git a/packages/builtin-source/templates/vike-app/.github/dependabot.yml b/packages/builtin-presets/templates/rust-bin/.github/dependabot.yml similarity index 83% rename from packages/builtin-source/templates/vike-app/.github/dependabot.yml rename to packages/builtin-presets/templates/rust-bin/.github/dependabot.yml index 19400d7..b835d37 100644 --- a/packages/builtin-source/templates/vike-app/.github/dependabot.yml +++ b/packages/builtin-presets/templates/rust-bin/.github/dependabot.yml @@ -19,6 +19,10 @@ updates: - version-update:semver-major - version-update:semver-minor - version-update:semver-patch + - package-ecosystem: cargo + directory: "{{CARGO_PACKAGE_DIRECTORY}}" + schedule: + interval: weekly - package-ecosystem: github-actions directory: / schedule: @@ -31,7 +35,7 @@ updates: - dependency-name: mcr.microsoft.com/devcontainers/typescript-node update-types: - version-update:semver-major - - package-ecosystem: docker - directory: /apps/web + - package-ecosystem: rust-toolchain + directory: / schedule: interval: weekly diff --git a/packages/builtin-presets/templates/rust-bin/.github/workflows/check.yml b/packages/builtin-presets/templates/rust-bin/.github/workflows/check.yml new file mode 100644 index 0000000..1bb3241 --- /dev/null +++ b/packages/builtin-presets/templates/rust-bin/.github/workflows/check.yml @@ -0,0 +1,23 @@ +name: Check + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - run: corepack enable + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: pnpm install + - run: pnpm run check diff --git a/packages/builtin-presets/templates/rust-bin/.vscode/extensions.json b/packages/builtin-presets/templates/rust-bin/.vscode/extensions.json new file mode 100644 index 0000000..201e87c --- /dev/null +++ b/packages/builtin-presets/templates/rust-bin/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml"] +} diff --git a/packages/builtin-presets/templates/rust-bin/.vscode/settings.json b/packages/builtin-presets/templates/rust-bin/.vscode/settings.json new file mode 100644 index 0000000..2a0b65e --- /dev/null +++ b/packages/builtin-presets/templates/rust-bin/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer" + } +} diff --git a/packages/builtin-presets/templates/rust-bin/Cargo.lock b/packages/builtin-presets/templates/rust-bin/Cargo.lock new file mode 100644 index 0000000..3955248 --- /dev/null +++ b/packages/builtin-presets/templates/rust-bin/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "{{CARGO_PACKAGE_NAME}}" +version = "0.1.0" +dependencies = [ + "anyhow", +] diff --git a/packages/builtin-presets/templates/rust-bin/Cargo.toml b/packages/builtin-presets/templates/rust-bin/Cargo.toml new file mode 100644 index 0000000..9f91e0b --- /dev/null +++ b/packages/builtin-presets/templates/rust-bin/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "{{CARGO_PACKAGE_NAME}}" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.100" + +[lints] +workspace = true + +[workspace] +members = ["."] +resolver = "3" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "deny" +pedantic = "deny" +nursery = "deny" + +[profile.release] +strip = "symbols" +lto = "thin" +codegen-units = 1 diff --git a/packages/builtin-presets/templates/rust-bin/devcontainer/devcontainer.json b/packages/builtin-presets/templates/rust-bin/devcontainer/devcontainer.json new file mode 100644 index 0000000..9d0e615 --- /dev/null +++ b/packages/builtin-presets/templates/rust-bin/devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "name": "{{PROJECT_NAME}}", + "build": { + "dockerfile": "Dockerfile", + "args": { + "NODE_VERSION": "{{NODE_LTS_MAJOR}}", + "PACKAGE_MANAGER_PIN": "{{PACKAGE_MANAGER_PIN}}", + "RUST_TOOLCHAIN": "{{RUST_TOOLCHAIN}}" + } + }, + "customizations": { + "vscode": { + "extensions": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml"] + } + }, + "mounts": [ + "source=${localWorkspaceFolderBasename}-cargo-registry,target=/usr/local/cargo/registry,type=volume", + "source=${localWorkspaceFolderBasename}-cargo-git,target=/usr/local/cargo/git,type=volume", + "source=${localWorkspaceFolderBasename}-target,target=${containerWorkspaceFolder}/target,type=volume" + ] +} diff --git a/packages/builtin-presets/templates/rust-bin/devcontainer/rust.Dockerfile b/packages/builtin-presets/templates/rust-bin/devcontainer/rust.Dockerfile new file mode 100644 index 0000000..9506d6b --- /dev/null +++ b/packages/builtin-presets/templates/rust-bin/devcontainer/rust.Dockerfile @@ -0,0 +1,16 @@ +ARG RUST_TOOLCHAIN +ENV RUSTUP_HOME=/usr/local/rustup +ENV CARGO_HOME=/usr/local/cargo +ENV PATH="/usr/local/cargo/bin:${PATH}" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gcc \ + libc6-dev \ + && rm -rf /var/lib/apt/lists/* +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --profile minimal --default-toolchain none \ + && rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal --component rustfmt --component clippy \ + && rustup default ${RUST_TOOLCHAIN} \ + && chmod -R a+w ${RUSTUP_HOME} ${CARGO_HOME} diff --git a/packages/builtin-presets/templates/rust-bin/rust-toolchain.toml b/packages/builtin-presets/templates/rust-bin/rust-toolchain.toml new file mode 100644 index 0000000..7ad5a72 --- /dev/null +++ b/packages/builtin-presets/templates/rust-bin/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "{{RUST_TOOLCHAIN}}" +components = ["rustfmt", "clippy"] diff --git a/packages/builtin-source/templates/rust-bin/rustfmt.toml b/packages/builtin-presets/templates/rust-bin/rustfmt.toml similarity index 100% rename from packages/builtin-source/templates/rust-bin/rustfmt.toml rename to packages/builtin-presets/templates/rust-bin/rustfmt.toml diff --git a/packages/builtin-source/templates/rust-bin/src/main.rs b/packages/builtin-presets/templates/rust-bin/src/main.rs similarity index 100% rename from packages/builtin-source/templates/rust-bin/src/main.rs rename to packages/builtin-presets/templates/rust-bin/src/main.rs diff --git a/packages/builtin-source/templates/rust-bin/turbo.json b/packages/builtin-presets/templates/rust-bin/turbo.json similarity index 100% rename from packages/builtin-source/templates/rust-bin/turbo.json rename to packages/builtin-presets/templates/rust-bin/turbo.json diff --git a/packages/builtin-source/templates/shared/devcontainer/browser-test.Dockerfile b/packages/builtin-presets/templates/shared/devcontainer/browser-test.Dockerfile similarity index 100% rename from packages/builtin-source/templates/shared/devcontainer/browser-test.Dockerfile rename to packages/builtin-presets/templates/shared/devcontainer/browser-test.Dockerfile diff --git a/packages/builtin-source/templates/shared/devcontainer/node-pnpm.Dockerfile b/packages/builtin-presets/templates/shared/devcontainer/node-pnpm.Dockerfile similarity index 100% rename from packages/builtin-source/templates/shared/devcontainer/node-pnpm.Dockerfile rename to packages/builtin-presets/templates/shared/devcontainer/node-pnpm.Dockerfile diff --git a/packages/builtin-source/templates/shared/devcontainer/shellcheck.Dockerfile b/packages/builtin-presets/templates/shared/devcontainer/shellcheck.Dockerfile similarity index 100% rename from packages/builtin-source/templates/shared/devcontainer/shellcheck.Dockerfile rename to packages/builtin-presets/templates/shared/devcontainer/shellcheck.Dockerfile diff --git a/packages/builtin-presets/templates/shared/editor-customization/capabilities.json b/packages/builtin-presets/templates/shared/editor-customization/capabilities.json new file mode 100644 index 0000000..e567332 --- /dev/null +++ b/packages/builtin-presets/templates/shared/editor-customization/capabilities.json @@ -0,0 +1,38 @@ +{ + "capabilityOrder": [ + "oxc-format-lint", + "vue", + "tailwind", + "rust-tooling", + "vitest" + ], + "capabilities": { + "oxc-format-lint": { + "extensions": ["oxc.oxc-vscode"], + "settings": { + "editor.codeActionsOnSave": { + "source.format.oxc": "always", + "source.fixAll.oxc": "always" + }, + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "oxc.configPath": "./oxlint.config.ts", + "oxc.enable": true, + "oxc.fmt.configPath": "./oxfmt.config.ts" + } + }, + "vue": { "extensions": ["Vue.volar"], "settings": {} }, + "tailwind": { "extensions": ["bradlc.vscode-tailwindcss"], "settings": {} }, + "rust-tooling": { + "extensions": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml"], + "settings": { + "rust-analyzer.cargo.features": "all", + "rust-analyzer.check.command": "clippy", + "rust-analyzer.procMacro.enable": true + } + }, + "vitest": { "extensions": ["vitest.explorer"], "settings": {} } + } +} diff --git a/packages/builtin-presets/templates/shared/oxc/node/oxlint.config.ts b/packages/builtin-presets/templates/shared/oxc/node/oxlint.config.ts new file mode 100644 index 0000000..0445a14 --- /dev/null +++ b/packages/builtin-presets/templates/shared/oxc/node/oxlint.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "oxlint"; + +export default defineConfig({ + options: { typeAware: true }, + categories: { correctness: "error", suspicious: "warn" }, +}); diff --git a/packages/builtin-presets/templates/shared/oxc/oxfmt.config.ts b/packages/builtin-presets/templates/shared/oxc/oxfmt.config.ts new file mode 100644 index 0000000..9cd0c95 --- /dev/null +++ b/packages/builtin-presets/templates/shared/oxc/oxfmt.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "oxfmt"; + +export default defineConfig({ printWidth: 80, sortImports: true }); diff --git a/packages/builtin-source/templates/shared/oxc/tsconfig.config.json b/packages/builtin-presets/templates/shared/oxc/tsconfig.config.json similarity index 87% rename from packages/builtin-source/templates/shared/oxc/tsconfig.config.json rename to packages/builtin-presets/templates/shared/oxc/tsconfig.config.json index fa3343d..7312866 100644 --- a/packages/builtin-source/templates/shared/oxc/tsconfig.config.json +++ b/packages/builtin-presets/templates/shared/oxc/tsconfig.config.json @@ -16,6 +16,5 @@ "target": "es2023", "types": ["node"], "verbatimModuleSyntax": true - }, - "include": ["oxlint.config.ts", "oxfmt.config.ts", "scripts/**/*.ts"] + } } diff --git a/packages/builtin-source/templates/vue-app/src/main.ts b/packages/builtin-presets/templates/shared/vue/src/main.ts similarity index 100% rename from packages/builtin-source/templates/vue-app/src/main.ts rename to packages/builtin-presets/templates/shared/vue/src/main.ts diff --git a/packages/builtin-source/templates/vue-app/src/stores/counter.ts b/packages/builtin-presets/templates/shared/vue/src/stores/counter.ts similarity index 100% rename from packages/builtin-source/templates/vue-app/src/stores/counter.ts rename to packages/builtin-presets/templates/shared/vue/src/stores/counter.ts diff --git a/packages/builtin-source/templates/vue-app/src/style.css b/packages/builtin-presets/templates/shared/vue/src/style.css similarity index 100% rename from packages/builtin-source/templates/vue-app/src/style.css rename to packages/builtin-presets/templates/shared/vue/src/style.css diff --git a/packages/builtin-source/templates/vue-app/test/app.test.ts b/packages/builtin-presets/templates/shared/vue/test/app.test.ts similarity index 100% rename from packages/builtin-source/templates/vue-app/test/app.test.ts rename to packages/builtin-presets/templates/shared/vue/test/app.test.ts diff --git a/packages/builtin-presets/templates/shared/vue/tsconfig.app.json b/packages/builtin-presets/templates/shared/vue/tsconfig.app.json new file mode 100644 index 0000000..e312400 --- /dev/null +++ b/packages/builtin-presets/templates/shared/vue/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "composite": true, + "erasableSyntaxOnly": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "esnext", + "moduleResolution": "bundler", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "rewriteRelativeImportExtensions": false, + "skipLibCheck": false, + "strict": true, + "target": "es2023", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["web-bluetooth"], + "verbatimModuleSyntax": true + }, + "include": ["env.d.ts", "src/**/*.ts", "src/**/*.vue"] +} diff --git a/packages/builtin-presets/templates/shared/vue/tsconfig.json b/packages/builtin-presets/templates/shared/vue/tsconfig.json new file mode 100644 index 0000000..4a3e749 --- /dev/null +++ b/packages/builtin-presets/templates/shared/vue/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.test.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/packages/builtin-presets/templates/shared/vue/tsconfig.node.json b/packages/builtin-presets/templates/shared/vue/tsconfig.node.json new file mode 100644 index 0000000..df386b5 --- /dev/null +++ b/packages/builtin-presets/templates/shared/vue/tsconfig.node.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "composite": true, + "erasableSyntaxOnly": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "esnext", + "moduleResolution": "bundler", + "noEmitOnError": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "rewriteRelativeImportExtensions": false, + "skipLibCheck": false, + "strict": true, + "target": "es2023", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": [ + "playwright.config.ts", + "scripts/**/*.ts", + "vite.config.ts", + "vitest.config.ts" + ] +} diff --git a/packages/builtin-presets/templates/shared/vue/tsconfig.test.json b/packages/builtin-presets/templates/shared/vue/tsconfig.test.json new file mode 100644 index 0000000..bf06668 --- /dev/null +++ b/packages/builtin-presets/templates/shared/vue/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "types": ["node", "vitest/globals", "web-bluetooth"] + }, + "include": ["env.d.ts", "src/**/*.ts", "src/**/*.vue", "test/**/*.ts"] +} diff --git a/packages/builtin-source/templates/shared/typescript/run-vue-tsc.ts b/packages/builtin-presets/templates/shared/vue/typescript/run-vue-tsc.ts similarity index 100% rename from packages/builtin-source/templates/shared/typescript/run-vue-tsc.ts rename to packages/builtin-presets/templates/shared/vue/typescript/run-vue-tsc.ts diff --git a/packages/builtin-source/templates/vue-app/.github/dependabot.yml b/packages/builtin-presets/templates/ts-lib/.github/dependabot.yml similarity index 100% rename from packages/builtin-source/templates/vue-app/.github/dependabot.yml rename to packages/builtin-presets/templates/ts-lib/.github/dependabot.yml diff --git a/packages/builtin-presets/templates/ts-lib/.github/workflows/check.yml b/packages/builtin-presets/templates/ts-lib/.github/workflows/check.yml new file mode 100644 index 0000000..3691588 --- /dev/null +++ b/packages/builtin-presets/templates/ts-lib/.github/workflows/check.yml @@ -0,0 +1,19 @@ +name: Check + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + - run: corepack enable + - run: pnpm install + - run: pnpm run check diff --git a/packages/builtin-source/templates/ts-lib/src/index.ts b/packages/builtin-presets/templates/ts-lib/src/index.ts similarity index 80% rename from packages/builtin-source/templates/ts-lib/src/index.ts rename to packages/builtin-presets/templates/ts-lib/src/index.ts index 5ed907a..20ed01c 100644 --- a/packages/builtin-source/templates/ts-lib/src/index.ts +++ b/packages/builtin-presets/templates/ts-lib/src/index.ts @@ -2,9 +2,7 @@ import * as v from "valibot"; import { nameSchema } from "#/name-schema"; -export type Greeting = { - message: string; -}; +export type Greeting = { message: string }; export function greet(name: string): Greeting { return { message: `Hello, ${v.parse(nameSchema, name)}` }; diff --git a/packages/builtin-source/templates/ts-lib/src/name-schema.ts b/packages/builtin-presets/templates/ts-lib/src/name-schema.ts similarity index 100% rename from packages/builtin-source/templates/ts-lib/src/name-schema.ts rename to packages/builtin-presets/templates/ts-lib/src/name-schema.ts diff --git a/packages/builtin-presets/templates/ts-lib/tsconfig.json b/packages/builtin-presets/templates/ts-lib/tsconfig.json new file mode 100644 index 0000000..37e01dc --- /dev/null +++ b/packages/builtin-presets/templates/ts-lib/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "target": "es2023", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/builtin-source/templates/ts-lib/turbo.json b/packages/builtin-presets/templates/ts-lib/turbo.json similarity index 100% rename from packages/builtin-source/templates/ts-lib/turbo.json rename to packages/builtin-presets/templates/ts-lib/turbo.json diff --git a/packages/builtin-source/templates/vike-app/db-migrations/drizzle.config.ts b/packages/builtin-presets/templates/vike-app/db-migrations/drizzle.config.ts similarity index 61% rename from packages/builtin-source/templates/vike-app/db-migrations/drizzle.config.ts rename to packages/builtin-presets/templates/vike-app/db-migrations/drizzle.config.ts index 8d1b3bf..e50ee02 100644 --- a/packages/builtin-source/templates/vike-app/db-migrations/drizzle.config.ts +++ b/packages/builtin-presets/templates/vike-app/db-migrations/drizzle.config.ts @@ -2,17 +2,15 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "drizzle-kit"; +const databasePackageName = process.env.DATABASE_PACKAGE_NAME ?? "@database"; const schemaFile = fileURLToPath( - new URL("../db/src/schema.ts", import.meta.url), -); -const migrationsDirectory = fileURLToPath( - new URL("./drizzle/migrations", import.meta.url), + import.meta.resolve(`${databasePackageName}/schema`), ); export default defineConfig({ dialect: "sqlite", schema: schemaFile, - out: migrationsDirectory, + out: "./drizzle/migrations", dbCredentials: { url: process.env.DATABASE_FILE ?? "./data/app.sqlite", }, diff --git a/packages/builtin-source/templates/vike-app/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/migration.sql b/packages/builtin-presets/templates/vike-app/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/migration.sql similarity index 100% rename from packages/builtin-source/templates/vike-app/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/migration.sql rename to packages/builtin-presets/templates/vike-app/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/migration.sql diff --git a/packages/builtin-source/templates/vike-app/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/snapshot.json b/packages/builtin-presets/templates/vike-app/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/snapshot.json similarity index 100% rename from packages/builtin-source/templates/vike-app/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/snapshot.json rename to packages/builtin-presets/templates/vike-app/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/snapshot.json diff --git a/packages/builtin-source/templates/vike-app/db-migrations/scripts/prepare-database.ts b/packages/builtin-presets/templates/vike-app/db-migrations/scripts/prepare-database.ts similarity index 70% rename from packages/builtin-source/templates/vike-app/db-migrations/scripts/prepare-database.ts rename to packages/builtin-presets/templates/vike-app/db-migrations/scripts/prepare-database.ts index d11f80f..3674d23 100644 --- a/packages/builtin-source/templates/vike-app/db-migrations/scripts/prepare-database.ts +++ b/packages/builtin-presets/templates/vike-app/db-migrations/scripts/prepare-database.ts @@ -27,8 +27,19 @@ execFileSync("pnpm", ["run", "db:push"], { env: environment, stdio: "inherit", }); -execFileSync("pnpm", ["--dir", "../db", "run", "db:seed:example"], { - cwd: packageDirectory, - env: environment, - stdio: "inherit", -}); +const databasePackageName = process.env.DATABASE_PACKAGE_NAME; +if (!databasePackageName) { + throw new Error( + "DATABASE_PACKAGE_NAME must name the database Package Boundary", + ); +} + +execFileSync( + "pnpm", + ["--filter", databasePackageName, "run", "db:seed:example"], + { + cwd: packageDirectory, + env: environment, + stdio: "inherit", + }, +); diff --git a/packages/builtin-source/templates/shared/oxc/tsconfig.json b/packages/builtin-presets/templates/vike-app/db-migrations/tsconfig.json similarity index 69% rename from packages/builtin-source/templates/shared/oxc/tsconfig.json rename to packages/builtin-presets/templates/vike-app/db-migrations/tsconfig.json index 5762bfd..9577b77 100644 --- a/packages/builtin-source/templates/shared/oxc/tsconfig.json +++ b/packages/builtin-presets/templates/vike-app/db-migrations/tsconfig.json @@ -4,21 +4,19 @@ "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "module": "nodenext", - "moduleResolution": "nodenext", + "module": "esnext", + "moduleResolution": "bundler", "noEmitOnError": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, "noUncheckedIndexedAccess": true, - "skipLibCheck": false, + "rootDir": ".", + "skipLibCheck": true, "strict": true, "target": "es2023", + "types": ["node"], "verbatimModuleSyntax": true }, - "include": [ - "oxfmt.config.ts", - "node/oxlint.config.ts", - "vue/oxlint.config.ts" - ] + "include": ["drizzle.config.ts", "scripts/**/*.ts"] } diff --git a/packages/builtin-source/templates/vike-app/db-migrations/turbo.json b/packages/builtin-presets/templates/vike-app/db-migrations/turbo.json similarity index 100% rename from packages/builtin-source/templates/vike-app/db-migrations/turbo.json rename to packages/builtin-presets/templates/vike-app/db-migrations/turbo.json diff --git a/packages/builtin-source/templates/vike-app/db/scripts/seed-example.ts b/packages/builtin-presets/templates/vike-app/db/scripts/seed-example.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/db/scripts/seed-example.ts rename to packages/builtin-presets/templates/vike-app/db/scripts/seed-example.ts diff --git a/packages/builtin-source/templates/vike-app/db/src/db.ts b/packages/builtin-presets/templates/vike-app/db/src/db.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/db/src/db.ts rename to packages/builtin-presets/templates/vike-app/db/src/db.ts diff --git a/packages/builtin-source/templates/vike-app/db/src/index.ts b/packages/builtin-presets/templates/vike-app/db/src/index.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/db/src/index.ts rename to packages/builtin-presets/templates/vike-app/db/src/index.ts diff --git a/packages/builtin-source/templates/vike-app/db/src/queries/todos.ts b/packages/builtin-presets/templates/vike-app/db/src/queries/todos.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/db/src/queries/todos.ts rename to packages/builtin-presets/templates/vike-app/db/src/queries/todos.ts diff --git a/packages/builtin-source/templates/vike-app/db/src/readiness.ts b/packages/builtin-presets/templates/vike-app/db/src/readiness.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/db/src/readiness.ts rename to packages/builtin-presets/templates/vike-app/db/src/readiness.ts diff --git a/packages/builtin-source/templates/vike-app/db/src/schema.ts b/packages/builtin-presets/templates/vike-app/db/src/schema.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/db/src/schema.ts rename to packages/builtin-presets/templates/vike-app/db/src/schema.ts diff --git a/packages/builtin-source/templates/vike-app/db/src/seed/example.ts b/packages/builtin-presets/templates/vike-app/db/src/seed/example.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/db/src/seed/example.ts rename to packages/builtin-presets/templates/vike-app/db/src/seed/example.ts diff --git a/packages/builtin-presets/templates/vike-app/db/src/types.d.ts b/packages/builtin-presets/templates/vike-app/db/src/types.d.ts new file mode 100644 index 0000000..cf7fe6a --- /dev/null +++ b/packages/builtin-presets/templates/vike-app/db/src/types.d.ts @@ -0,0 +1 @@ +export type Todo = typeof import("./schema.ts").todos.$inferSelect; diff --git a/packages/builtin-source/templates/vike-app/db/test/todos.test.ts b/packages/builtin-presets/templates/vike-app/db/test/todos.test.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/db/test/todos.test.ts rename to packages/builtin-presets/templates/vike-app/db/test/todos.test.ts diff --git a/packages/builtin-presets/templates/vike-app/db/tsconfig.json b/packages/builtin-presets/templates/vike-app/db/tsconfig.json new file mode 100644 index 0000000..d57119a --- /dev/null +++ b/packages/builtin-presets/templates/vike-app/db/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "erasableSyntaxOnly": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "esnext", + "moduleResolution": "bundler", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "paths": { "#db/*": ["./src/*"] }, + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "es2023", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/builtin-source/templates/vike-app/db/turbo.json b/packages/builtin-presets/templates/vike-app/db/turbo.json similarity index 100% rename from packages/builtin-source/templates/vike-app/db/turbo.json rename to packages/builtin-presets/templates/vike-app/db/turbo.json diff --git a/packages/builtin-source/templates/vike-app/web/+server.ts b/packages/builtin-presets/templates/vike-app/web/+server.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/+server.ts rename to packages/builtin-presets/templates/vike-app/web/+server.ts diff --git a/packages/builtin-source/templates/vike-app/web/.env.example b/packages/builtin-presets/templates/vike-app/web/.env.example similarity index 100% rename from packages/builtin-source/templates/vike-app/web/.env.example rename to packages/builtin-presets/templates/vike-app/web/.env.example diff --git a/packages/builtin-source/templates/vike-app/web/Dockerfile b/packages/builtin-presets/templates/vike-app/web/Dockerfile similarity index 92% rename from packages/builtin-source/templates/vike-app/web/Dockerfile rename to packages/builtin-presets/templates/vike-app/web/Dockerfile index 9a274f8..a6b375c 100644 --- a/packages/builtin-source/templates/vike-app/web/Dockerfile +++ b/packages/builtin-presets/templates/vike-app/web/Dockerfile @@ -10,7 +10,11 @@ ENV PATH="$PNPM_HOME:$PATH" WORKDIR /repo RUN install -d -m 0755 "$COREPACK_HOME" "$PNPM_HOME" \ && corepack enable --install-directory "$PNPM_HOME" \ - && corepack prepare "$PACKAGE_MANAGER_PIN" --activate \ + && for attempt in 1 2 3; do \ + if corepack prepare "$PACKAGE_MANAGER_PIN" --activate; then break; fi; \ + [ "$attempt" -lt 3 ] || exit 1; \ + sleep 2; \ + done \ && chmod -R a+rX "$COREPACK_HOME" "$PNPM_HOME" FROM base AS pruner @@ -54,6 +58,7 @@ FROM application-runtime AS standalone ARG DEPLOYMENT_BUILD_ID LABEL org.opencontainers.image.version="$DEPLOYMENT_BUILD_ID" ENV PATH="/migration/node_modules/.bin:$PATH" +ENV DATABASE_PACKAGE_NAME="{{DB_PACKAGE_NAME}}" COPY --from=build --chown=app:nodejs /migration-deploy /migration COPY --chmod=755 --from=build /repo/apps/web/scripts/container-entrypoint.sh /usr/local/bin/container-entrypoint EXPOSE 3000 diff --git a/packages/builtin-source/templates/vike-app/web/Dockerfile.dockerignore b/packages/builtin-presets/templates/vike-app/web/Dockerfile.dockerignore similarity index 100% rename from packages/builtin-source/templates/vike-app/web/Dockerfile.dockerignore rename to packages/builtin-presets/templates/vike-app/web/Dockerfile.dockerignore diff --git a/packages/builtin-source/templates/vike-app/web/assets/logo.svg b/packages/builtin-presets/templates/vike-app/web/assets/logo.svg similarity index 100% rename from packages/builtin-source/templates/vike-app/web/assets/logo.svg rename to packages/builtin-presets/templates/vike-app/web/assets/logo.svg diff --git a/packages/builtin-source/templates/vike-app/web/components/CounterButton.vue b/packages/builtin-presets/templates/vike-app/web/components/CounterButton.vue similarity index 100% rename from packages/builtin-source/templates/vike-app/web/components/CounterButton.vue rename to packages/builtin-presets/templates/vike-app/web/components/CounterButton.vue diff --git a/packages/builtin-source/templates/vike-app/web/components/PageShell.vue b/packages/builtin-presets/templates/vike-app/web/components/PageShell.vue similarity index 100% rename from packages/builtin-source/templates/vike-app/web/components/PageShell.vue rename to packages/builtin-presets/templates/vike-app/web/components/PageShell.vue diff --git a/packages/builtin-source/templates/vike-app/web/pages/+Head.vue b/packages/builtin-presets/templates/vike-app/web/pages/+Head.vue similarity index 100% rename from packages/builtin-source/templates/vike-app/web/pages/+Head.vue rename to packages/builtin-presets/templates/vike-app/web/pages/+Head.vue diff --git a/packages/builtin-source/templates/vike-app/web/pages/+Layout.vue b/packages/builtin-presets/templates/vike-app/web/pages/+Layout.vue similarity index 100% rename from packages/builtin-source/templates/vike-app/web/pages/+Layout.vue rename to packages/builtin-presets/templates/vike-app/web/pages/+Layout.vue diff --git a/packages/builtin-source/templates/vike-app/web/pages/+config.ts b/packages/builtin-presets/templates/vike-app/web/pages/+config.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/pages/+config.ts rename to packages/builtin-presets/templates/vike-app/web/pages/+config.ts diff --git a/packages/builtin-source/templates/vike-app/web/pages/index/+Page.telefunc.ts b/packages/builtin-presets/templates/vike-app/web/pages/index/+Page.telefunc.ts similarity index 80% rename from packages/builtin-source/templates/vike-app/web/pages/index/+Page.telefunc.ts rename to packages/builtin-presets/templates/vike-app/web/pages/index/+Page.telefunc.ts index d56ebf5..8fea7ac 100644 --- a/packages/builtin-source/templates/vike-app/web/pages/index/+Page.telefunc.ts +++ b/packages/builtin-presets/templates/vike-app/web/pages/index/+Page.telefunc.ts @@ -1,8 +1,6 @@ // @template-anchor db-package-import import { getContext } from "telefunc"; -export type Todo = Awaited>[number]; - export async function onLoadTodos() { const { db } = getContext(); return listTodos(db); diff --git a/packages/builtin-source/templates/vike-app/web/pages/index/+Page.vue b/packages/builtin-presets/templates/vike-app/web/pages/index/+Page.vue similarity index 95% rename from packages/builtin-source/templates/vike-app/web/pages/index/+Page.vue rename to packages/builtin-presets/templates/vike-app/web/pages/index/+Page.vue index f332003..66ea73a 100644 --- a/packages/builtin-source/templates/vike-app/web/pages/index/+Page.vue +++ b/packages/builtin-presets/templates/vike-app/web/pages/index/+Page.vue @@ -2,8 +2,9 @@ import { onMounted, ref } from "vue"; import CounterButton from "#/components/CounterButton.vue"; +import type { Todo } from "#db/types"; -import { onAddTodo, onLoadTodos, type Todo } from "./+Page.telefunc"; +import { onAddTodo, onLoadTodos } from "./+Page.telefunc"; const title = ref(""); const todos = ref([]); diff --git a/packages/builtin-source/templates/vike-app/web/pages/tailwind.css b/packages/builtin-presets/templates/vike-app/web/pages/tailwind.css similarity index 100% rename from packages/builtin-source/templates/vike-app/web/pages/tailwind.css rename to packages/builtin-presets/templates/vike-app/web/pages/tailwind.css diff --git a/packages/builtin-source/templates/vike-app/web/playwright.config.ts b/packages/builtin-presets/templates/vike-app/web/playwright.config.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/playwright.config.ts rename to packages/builtin-presets/templates/vike-app/web/playwright.config.ts diff --git a/packages/builtin-source/templates/vike-app/web/scripts/check-standalone-deployment.ts b/packages/builtin-presets/templates/vike-app/web/scripts/check-standalone-deployment.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/scripts/check-standalone-deployment.ts rename to packages/builtin-presets/templates/vike-app/web/scripts/check-standalone-deployment.ts diff --git a/packages/builtin-source/templates/vike-app/web/scripts/container-entrypoint.sh b/packages/builtin-presets/templates/vike-app/web/scripts/container-entrypoint.sh similarity index 83% rename from packages/builtin-source/templates/vike-app/web/scripts/container-entrypoint.sh rename to packages/builtin-presets/templates/vike-app/web/scripts/container-entrypoint.sh index 94b8b1e..8afc099 100644 --- a/packages/builtin-source/templates/vike-app/web/scripts/container-entrypoint.sh +++ b/packages/builtin-presets/templates/vike-app/web/scripts/container-entrypoint.sh @@ -12,7 +12,10 @@ esac prepare_database() { mkdir -p "$(dirname "$database_file")" - DATABASE_FILE="$database_file" drizzle-kit migrate --config /migration/drizzle.config.ts + ( + cd /migration + DATABASE_FILE="$database_file" drizzle-kit migrate --config drizzle.config.ts + ) } case "${1:-}" in diff --git a/packages/builtin-source/templates/vike-app/web/scripts/run-playwright.ts b/packages/builtin-presets/templates/vike-app/web/scripts/run-playwright.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/scripts/run-playwright.ts rename to packages/builtin-presets/templates/vike-app/web/scripts/run-playwright.ts diff --git a/packages/builtin-source/templates/vike-app/web/server/api.ts b/packages/builtin-presets/templates/vike-app/web/server/api.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/server/api.ts rename to packages/builtin-presets/templates/vike-app/web/server/api.ts diff --git a/packages/builtin-source/templates/vike-app/web/server/app.ts b/packages/builtin-presets/templates/vike-app/web/server/app.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/server/app.ts rename to packages/builtin-presets/templates/vike-app/web/server/app.ts diff --git a/packages/builtin-source/templates/vike-app/web/test/e2e/app.spec.ts b/packages/builtin-presets/templates/vike-app/web/test/e2e/app.spec.ts similarity index 74% rename from packages/builtin-source/templates/vike-app/web/test/e2e/app.spec.ts rename to packages/builtin-presets/templates/vike-app/web/test/e2e/app.spec.ts index e2caa8c..65730c0 100644 --- a/packages/builtin-source/templates/vike-app/web/test/e2e/app.spec.ts +++ b/packages/builtin-presets/templates/vike-app/web/test/e2e/app.spec.ts @@ -3,7 +3,9 @@ import { expect, test } from "@playwright/test"; test("renders the Vike home page", async ({ page }) => { const todoTitle = `编写生成项目端到端测试 ${Date.now()}`; - await page.goto("/", { waitUntil: "networkidle" }); + // Vike can retain background browser requests after its interactive page is + // ready; the visible UI assertions below are the actual readiness contract. + await page.goto("/", { waitUntil: "domcontentloaded" }); await expect( page.getByRole("heading", { name: "全栈 Vike 应用" }), ).toBeVisible(); diff --git a/packages/builtin-presets/templates/vike-app/web/tsconfig.app.json b/packages/builtin-presets/templates/vike-app/web/tsconfig.app.json new file mode 100644 index 0000000..ddab866 --- /dev/null +++ b/packages/builtin-presets/templates/vike-app/web/tsconfig.app.json @@ -0,0 +1,34 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "composite": true, + "erasableSyntaxOnly": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "esnext", + "moduleResolution": "bundler", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "paths": { "#/*": ["./*"] }, + "rewriteRelativeImportExtensions": false, + "skipLibCheck": true, + "strict": true, + "target": "es2023", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": [ + "+server.ts", + "assets/**/*.svg", + "components/**/*.vue", + "pages/**/*.ts", + "pages/**/*.vue", + "server/**/*.ts", + "types/**/*.d.ts" + ] +} diff --git a/packages/builtin-presets/templates/vike-app/web/tsconfig.json b/packages/builtin-presets/templates/vike-app/web/tsconfig.json new file mode 100644 index 0000000..f1cbf74 --- /dev/null +++ b/packages/builtin-presets/templates/vike-app/web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "files": [], + "references": [ + { "path": "../../packages/db" }, + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.test.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/packages/builtin-presets/templates/vike-app/web/tsconfig.node.json b/packages/builtin-presets/templates/vike-app/web/tsconfig.node.json new file mode 100644 index 0000000..3107cca --- /dev/null +++ b/packages/builtin-presets/templates/vike-app/web/tsconfig.node.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "composite": true, + "erasableSyntaxOnly": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "paths": { "#/*": ["./*"] }, + "rewriteRelativeImportExtensions": false, + "skipLibCheck": true, + "strict": true, + "target": "es2023", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": [ + "playwright.config.ts", + "scripts/**/*.ts", + "vite.config.ts", + "vitest.config.ts" + ] +} diff --git a/packages/builtin-presets/templates/vike-app/web/tsconfig.test.json b/packages/builtin-presets/templates/vike-app/web/tsconfig.test.json new file mode 100644 index 0000000..e84ac4d --- /dev/null +++ b/packages/builtin-presets/templates/vike-app/web/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "paths": { "#/*": ["./*"] }, + "skipLibCheck": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "types": ["node", "vitest/globals"] + }, + "include": ["test/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/builtin-source/templates/vue-hono-app/web/turbo.json b/packages/builtin-presets/templates/vike-app/web/turbo.json similarity index 82% rename from packages/builtin-source/templates/vue-hono-app/web/turbo.json rename to packages/builtin-presets/templates/vike-app/web/turbo.json index 5e930fa..6a62ca9 100644 --- a/packages/builtin-source/templates/vue-hono-app/web/turbo.json +++ b/packages/builtin-presets/templates/vike-app/web/turbo.json @@ -6,6 +6,7 @@ "outputs": ["dist/**"] }, "test:e2e:run": { + "dependsOn": ["build:run"], "cache": false } } diff --git a/packages/builtin-source/templates/vike-app/web/types/env.d.ts b/packages/builtin-presets/templates/vike-app/web/types/env.d.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/types/env.d.ts rename to packages/builtin-presets/templates/vike-app/web/types/env.d.ts diff --git a/packages/builtin-source/templates/vike-app/web/types/global.d.ts b/packages/builtin-presets/templates/vike-app/web/types/global.d.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/types/global.d.ts rename to packages/builtin-presets/templates/vike-app/web/types/global.d.ts diff --git a/packages/builtin-source/templates/vike-app/web/vite.config.ts b/packages/builtin-presets/templates/vike-app/web/vite.config.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/vite.config.ts rename to packages/builtin-presets/templates/vike-app/web/vite.config.ts diff --git a/packages/builtin-source/templates/vike-app/web/vitest.config.ts b/packages/builtin-presets/templates/vike-app/web/vitest.config.ts similarity index 100% rename from packages/builtin-source/templates/vike-app/web/vitest.config.ts rename to packages/builtin-presets/templates/vike-app/web/vitest.config.ts diff --git a/packages/builtin-source/templates/vue-hono-app/.github/dependabot.yml b/packages/builtin-presets/templates/vue-app/.github/dependabot.yml similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/.github/dependabot.yml rename to packages/builtin-presets/templates/vue-app/.github/dependabot.yml diff --git a/packages/builtin-source/templates/vue-hono-app/.github/workflows/check.yml b/packages/builtin-presets/templates/vue-app/.github/workflows/check.yml similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/.github/workflows/check.yml rename to packages/builtin-presets/templates/vue-app/.github/workflows/check.yml diff --git a/packages/builtin-source/templates/vue-app/env.d.ts b/packages/builtin-presets/templates/vue-app/env.d.ts similarity index 100% rename from packages/builtin-source/templates/vue-app/env.d.ts rename to packages/builtin-presets/templates/vue-app/env.d.ts diff --git a/packages/builtin-source/templates/vue-app/index.html b/packages/builtin-presets/templates/vue-app/index.html similarity index 100% rename from packages/builtin-source/templates/vue-app/index.html rename to packages/builtin-presets/templates/vue-app/index.html diff --git a/packages/builtin-source/templates/vue-app/playwright.config.ts b/packages/builtin-presets/templates/vue-app/playwright.config.ts similarity index 100% rename from packages/builtin-source/templates/vue-app/playwright.config.ts rename to packages/builtin-presets/templates/vue-app/playwright.config.ts diff --git a/packages/builtin-source/templates/vue-app/scripts/run-playwright.ts b/packages/builtin-presets/templates/vue-app/scripts/run-playwright.ts similarity index 100% rename from packages/builtin-source/templates/vue-app/scripts/run-playwright.ts rename to packages/builtin-presets/templates/vue-app/scripts/run-playwright.ts diff --git a/packages/builtin-source/templates/vue-app/src/App.vue b/packages/builtin-presets/templates/vue-app/src/App.vue similarity index 100% rename from packages/builtin-source/templates/vue-app/src/App.vue rename to packages/builtin-presets/templates/vue-app/src/App.vue diff --git a/packages/builtin-source/templates/vue-app/test/e2e/app.spec.ts b/packages/builtin-presets/templates/vue-app/test/e2e/app.spec.ts similarity index 100% rename from packages/builtin-source/templates/vue-app/test/e2e/app.spec.ts rename to packages/builtin-presets/templates/vue-app/test/e2e/app.spec.ts diff --git a/packages/builtin-source/templates/vue-app/turbo.json b/packages/builtin-presets/templates/vue-app/turbo.json similarity index 72% rename from packages/builtin-source/templates/vue-app/turbo.json rename to packages/builtin-presets/templates/vue-app/turbo.json index 5e930fa..c0a0f71 100644 --- a/packages/builtin-source/templates/vue-app/turbo.json +++ b/packages/builtin-presets/templates/vue-app/turbo.json @@ -6,7 +6,8 @@ "outputs": ["dist/**"] }, "test:e2e:run": { - "cache": false + "cache": false, + "dependsOn": ["build:run"] } } } diff --git a/packages/builtin-source/templates/shared/typescript/tsconfig.json b/packages/builtin-presets/templates/vue-app/typescript/tsconfig.json similarity index 100% rename from packages/builtin-source/templates/shared/typescript/tsconfig.json rename to packages/builtin-presets/templates/vue-app/typescript/tsconfig.json diff --git a/packages/builtin-source/templates/vue-app/vite.config.ts b/packages/builtin-presets/templates/vue-app/vite.config.ts similarity index 100% rename from packages/builtin-source/templates/vue-app/vite.config.ts rename to packages/builtin-presets/templates/vue-app/vite.config.ts diff --git a/packages/builtin-source/templates/vue-app/vitest.config.ts b/packages/builtin-presets/templates/vue-app/vitest.config.ts similarity index 100% rename from packages/builtin-source/templates/vue-app/vitest.config.ts rename to packages/builtin-presets/templates/vue-app/vitest.config.ts diff --git a/packages/builtin-source/templates/vue-hono-app/api/src/index.ts b/packages/builtin-presets/templates/vue-hono-app/api/src/index.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/api/src/index.ts rename to packages/builtin-presets/templates/vue-hono-app/api/src/index.ts diff --git a/packages/builtin-source/templates/vue-hono-app/api/src/runtime.ts b/packages/builtin-presets/templates/vue-hono-app/api/src/runtime.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/api/src/runtime.ts rename to packages/builtin-presets/templates/vue-hono-app/api/src/runtime.ts diff --git a/packages/builtin-source/templates/vue-hono-app/api/src/server.ts b/packages/builtin-presets/templates/vue-hono-app/api/src/server.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/api/src/server.ts rename to packages/builtin-presets/templates/vue-hono-app/api/src/server.ts diff --git a/packages/builtin-source/templates/vue-hono-app/api/test/app.test.ts b/packages/builtin-presets/templates/vue-hono-app/api/test/app.test.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/api/test/app.test.ts rename to packages/builtin-presets/templates/vue-hono-app/api/test/app.test.ts diff --git a/packages/builtin-presets/templates/vue-hono-app/api/tsconfig.build.json b/packages/builtin-presets/templates/vue-hono-app/api/tsconfig.build.json new file mode 100644 index 0000000..43f547a --- /dev/null +++ b/packages/builtin-presets/templates/vue-hono-app/api/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/builtin-presets/templates/vue-hono-app/api/tsconfig.json b/packages/builtin-presets/templates/vue-hono-app/api/tsconfig.json new file mode 100644 index 0000000..a6de53a --- /dev/null +++ b/packages/builtin-presets/templates/vue-hono-app/api/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "erasableSyntaxOnly": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": false, + "strict": true, + "target": "es2023", + "types": ["node", "vitest/globals"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/builtin-source/templates/vue-hono-app/api/turbo.json b/packages/builtin-presets/templates/vue-hono-app/api/turbo.json similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/api/turbo.json rename to packages/builtin-presets/templates/vue-hono-app/api/turbo.json diff --git a/packages/builtin-source/templates/vue-hono-app/api/vitest.config.ts b/packages/builtin-presets/templates/vue-hono-app/api/vitest.config.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/api/vitest.config.ts rename to packages/builtin-presets/templates/vue-hono-app/api/vitest.config.ts diff --git a/packages/builtin-source/templates/vue-hono-app/web/env.d.ts b/packages/builtin-presets/templates/vue-hono-app/web/env.d.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/web/env.d.ts rename to packages/builtin-presets/templates/vue-hono-app/web/env.d.ts diff --git a/packages/builtin-source/templates/vue-hono-app/web/index.html b/packages/builtin-presets/templates/vue-hono-app/web/index.html similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/web/index.html rename to packages/builtin-presets/templates/vue-hono-app/web/index.html diff --git a/packages/builtin-source/templates/vue-hono-app/web/playwright.config.ts b/packages/builtin-presets/templates/vue-hono-app/web/playwright.config.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/web/playwright.config.ts rename to packages/builtin-presets/templates/vue-hono-app/web/playwright.config.ts diff --git a/packages/builtin-source/templates/vue-hono-app/web/scripts/run-playwright.ts b/packages/builtin-presets/templates/vue-hono-app/web/scripts/run-playwright.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/web/scripts/run-playwright.ts rename to packages/builtin-presets/templates/vue-hono-app/web/scripts/run-playwright.ts diff --git a/packages/builtin-source/templates/vue-hono-app/web/src/App.vue b/packages/builtin-presets/templates/vue-hono-app/web/src/App.vue similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/web/src/App.vue rename to packages/builtin-presets/templates/vue-hono-app/web/src/App.vue diff --git a/packages/builtin-source/templates/vue-hono-app/web/src/api.ts b/packages/builtin-presets/templates/vue-hono-app/web/src/api.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/web/src/api.ts rename to packages/builtin-presets/templates/vue-hono-app/web/src/api.ts diff --git a/packages/builtin-source/templates/vue-hono-app/web/test/e2e/app.spec.ts b/packages/builtin-presets/templates/vue-hono-app/web/test/e2e/app.spec.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/web/test/e2e/app.spec.ts rename to packages/builtin-presets/templates/vue-hono-app/web/test/e2e/app.spec.ts diff --git a/packages/builtin-source/templates/vike-app/web/turbo.json b/packages/builtin-presets/templates/vue-hono-app/web/turbo.json similarity index 100% rename from packages/builtin-source/templates/vike-app/web/turbo.json rename to packages/builtin-presets/templates/vue-hono-app/web/turbo.json diff --git a/packages/builtin-source/templates/vue-hono-app/web/vite.config.ts b/packages/builtin-presets/templates/vue-hono-app/web/vite.config.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/web/vite.config.ts rename to packages/builtin-presets/templates/vue-hono-app/web/vite.config.ts diff --git a/packages/builtin-source/templates/vue-hono-app/web/vitest.config.ts b/packages/builtin-presets/templates/vue-hono-app/web/vitest.config.ts similarity index 100% rename from packages/builtin-source/templates/vue-hono-app/web/vitest.config.ts rename to packages/builtin-presets/templates/vue-hono-app/web/vitest.config.ts diff --git a/packages/builtin-source/tsconfig.build.json b/packages/builtin-presets/tsconfig.build.json similarity index 50% rename from packages/builtin-source/tsconfig.build.json rename to packages/builtin-presets/tsconfig.build.json index 90ac983..27ddf5f 100644 --- a/packages/builtin-source/tsconfig.build.json +++ b/packages/builtin-presets/tsconfig.build.json @@ -5,10 +5,6 @@ "declarationMap": false, "rootDir": "." }, - "include": [ - "src/**/*.ts", - "templates/*/projection.ts", - "templates/projection-plans.ts", - "templates/registry.ts" - ] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/check-*.ts", "src/registry-checks.ts"] } diff --git a/packages/builtin-presets/tsconfig.json b/packages/builtin-presets/tsconfig.json new file mode 100644 index 0000000..afc687e --- /dev/null +++ b/packages/builtin-presets/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/builtin-presets/turbo.json b/packages/builtin-presets/turbo.json new file mode 100644 index 0000000..6774711 --- /dev/null +++ b/packages/builtin-presets/turbo.json @@ -0,0 +1,4 @@ +{ + "extends": ["//"], + "tags": ["template-builtin-presets"] +} diff --git a/packages/builtin-source/package.json b/packages/builtin-source/package.json deleted file mode 100644 index 1935fa1..0000000 --- a/packages/builtin-source/package.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "name": "@ykdz/template-builtin-source", - "version": "0.0.0", - "private": true, - "files": [ - "dist", - "templates", - "!templates/*.tmp", - "!templates/registry.ts", - "!templates/projection-plans.ts", - "!templates/*/projection.ts", - "!templates/*/behavior.test.ts", - "!templates/shared/oxc/tsconfig.json", - "!templates/**/node_modules" - ], - "type": "module", - "exports": { - ".": { - "types": "./dist/src/index.d.ts", - "default": "./dist/src/index.js" - }, - "./registry": { - "types": "./dist/templates/registry.d.ts", - "default": "./dist/templates/registry.js" - }, - "./projection-plans": { - "types": "./dist/templates/projection-plans.d.ts", - "default": "./dist/templates/projection-plans.js" - }, - "./templates/*": { - "types": "./dist/templates/*.d.ts", - "default": "./dist/templates/*.js" - } - }, - "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json", - "check:templates": "pnpm run check:templates:shared-oxc && pnpm run check:templates:shared-pnpm-peer-policy && pnpm run check:templates:shared-typescript && pnpm run check:templates:toolchain-maintenance && pnpm run check:templates:static-source", - "check:templates:shared-pnpm-peer-policy": "pnpm run check:templates:shared-pnpm-peer-policy:format && pnpm run check:templates:shared-pnpm-peer-policy:lint && pnpm run check:templates:shared-pnpm-peer-policy:typecheck", - "check:templates:shared-pnpm-peer-policy:format": "oxfmt --list-different templates/shared/pnpm-peer-policy", - "check:templates:shared-pnpm-peer-policy:lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts templates/shared/pnpm-peer-policy", - "check:templates:shared-pnpm-peer-policy:typecheck": "tsc -p templates/shared/pnpm-peer-policy/tsconfig.json --noEmit --pretty false", - "check:templates:shared-oxc": "pnpm run check:templates:shared-oxc:format && pnpm run check:templates:shared-oxc:lint && pnpm run check:templates:shared-oxc:typecheck", - "check:templates:shared-oxc:format": "oxfmt --list-different templates/shared/oxc", - "check:templates:shared-oxc:lint": "oxlint --quiet --format=unix --config templates/shared/oxc/node/oxlint.config.ts templates/shared/oxc", - "check:templates:shared-oxc:typecheck": "tsc -p templates/shared/oxc/tsconfig.json --noEmit --pretty false", - "check:templates:shared-typescript": "pnpm run check:templates:shared-typescript:format && pnpm run check:templates:shared-typescript:lint && pnpm run check:templates:shared-typescript:typecheck", - "check:templates:shared-typescript:format": "oxfmt --list-different templates/shared/typescript", - "check:templates:shared-typescript:lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts templates/shared/typescript", - "check:templates:shared-typescript:typecheck": "tsc -p templates/shared/typescript/tsconfig.json --noEmit --pretty false", - "check:templates:toolchain-maintenance": "pnpm run check:templates:toolchain-maintenance:format && pnpm run check:templates:toolchain-maintenance:lint && pnpm run check:templates:toolchain-maintenance:typecheck", - "check:templates:toolchain-maintenance:format": "oxfmt --list-different templates/shared/toolchain-maintenance", - "check:templates:toolchain-maintenance:lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts templates/shared/toolchain-maintenance", - "check:templates:toolchain-maintenance:typecheck": "tsc -p templates/shared/toolchain-maintenance/tsconfig.json --noEmit --pretty false", - "check:templates:static-source": "rustup run stable rustfmt --check templates/rust-bin/src/main.rs", - "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint": "shellcheck templates/vike-app/web/scripts/container-entrypoint.sh && oxlint --quiet --format=unix --config ../../oxlint.config.ts src templates/projection-plans.ts templates/registry.ts templates/*/projection.ts templates/*/behavior.test.ts", - "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts src templates/projection-plans.ts templates/registry.ts templates/*/projection.ts templates/*/behavior.test.ts --fix", - "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" - }, - "dependencies": { - "@ykdz/template-core": "workspace:*", - "@ykdz/template-shared": "workspace:*", - "valibot": "catalog:" - }, - "devDependencies": { - "@hono/node-server": "catalog:", - "@playwright/test": "catalog:", - "@tailwindcss/vite": "catalog:", - "@types/node": "catalog:", - "@types/semver": "catalog:", - "@vikejs/hono": "catalog:", - "@vitejs/plugin-vue": "catalog:", - "drizzle-kit": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "oxfmt": "catalog:", - "oxlint": "catalog:", - "oxlint-tsgolint": "catalog:", - "pinia": "catalog:", - "semver": "catalog:", - "telefunc": "catalog:", - "typescript": "catalog:", - "typescript-7": "catalog:", - "vike": "catalog:", - "vike-vue": "catalog:", - "vite": "catalog:", - "vitest": "catalog:", - "vue": "catalog:", - "vue-tsc": "catalog:", - "yaml": "catalog:" - } -} diff --git a/packages/builtin-source/src/index.ts b/packages/builtin-source/src/index.ts deleted file mode 100644 index 7f3821b..0000000 --- a/packages/builtin-source/src/index.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import type { GenerationContext } from "@ykdz/template-core/generation-context"; -import type { PresetPackageAdditionOptions } from "@ykdz/template-core/preset-projection"; -import { - findPresetSourceManifestPreset, - validateBuiltInPresetSourceManifest, -} from "@ykdz/template-core/preset-source"; -import { - defaultPackagePathForPresetSourcePackageAddition, - planPresetSourcePackageAddition, - projectPresetSourcePreset, - type PresetProjectionSourceRoots, - type ProjectionSourcePreset, -} from "@ykdz/template-core/projection-capabilities"; -import { - validatePresetProjectionDeclaration, - type BuiltInPreset, - type PresetProjectionDeclaration, - type PresetSourceManifest, - type PresetSourceManifestPreset, -} from "@ykdz/template-shared"; - -export { - findPresetSourceManifestPreset, - loadPresetSourceManifestFile, - manifestReferencedSourceFiles, - validateBuiltInPresetSourceManifest, - validatePresetSourceManifest, -} from "@ykdz/template-core/preset-source"; -export { - presetSourceManifestJsonSchema, - type PresetSourceManifest, - type PresetSourceManifestPreset, -} from "@ykdz/template-shared"; - -export function builtInPresetSourceRoot(...segments: string[]): string { - const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - const candidates = [ - ...(process.env.TEMPLATE_REPOSITORY_ROOT - ? [ - path.join( - process.env.TEMPLATE_REPOSITORY_ROOT, - "packages", - "builtin-source", - "templates", - ...segments, - ), - ] - : []), - path.join(moduleDir, "..", "..", "templates", ...segments), - path.join(moduleDir, "..", "templates", ...segments), - path.join( - moduleDir, - "..", - "..", - "..", - "template-builtin-source", - "templates", - ...segments, - ), - ]; - - return ( - candidates.find((candidate) => readPathExists(candidate)) ?? candidates[0]! - ); -} - -function readPathExists(filePath: string): boolean { - try { - readFileSync(filePath); - return true; - } catch (error: unknown) { - if (errorCode(error) === "EISDIR") { - return true; - } - - if (errorCode(error) === "ENOENT") { - return false; - } - - return false; - } -} - -function errorCode(error: unknown): string | undefined { - if ( - error instanceof Error && - "code" in error && - typeof error.code === "string" - ) { - return error.code; - } - - return undefined; -} - -export function builtInPresetProjectionSourceRoots(): PresetProjectionSourceRoots { - const manifest = loadBuiltInPresetSourceManifest(); - const sharedResources = new Map( - manifest.sharedResources.map((resource) => [resource.id, resource.path]), - ); - - return { - preset(sourcePreset: ProjectionSourcePreset): string { - return builtInPresetSourceRoot(sourcePreset); - }, - sharedOxc(): string { - return builtInPresetSourceRoot("shared", "oxc"); - }, - sharedResource(resourceId: string): string | undefined { - const resourcePath = sharedResources.get(resourceId); - - return resourcePath === undefined - ? undefined - : builtInPresetSourceRoot(resourcePath); - }, - }; -} - -export function loadBuiltInPresetSourceManifest(): PresetSourceManifest { - const filePath = builtInPresetSourceRoot("preset-source.json"); - const result = validateBuiltInPresetSourceManifest( - JSON.parse(readFileSync(filePath, "utf8")) as unknown, - { sourceRoot: path.dirname(filePath) }, - ); - - if (!result.ok) { - throw new Error( - `Built-in Preset Source Manifest is invalid:\n${result.issues - .map((issue) => ` - ${issue.path}: ${issue.message}`) - .join("\n")}`, - ); - } - - return result.value; -} - -export const builtInPresets: readonly BuiltInPreset[] = - loadBuiltInPresetSourceManifest().presets; - -export function findBuiltInPreset(name: string): BuiltInPreset | undefined { - return builtInPresets.find((preset) => preset.name === name); -} - -export function findBuiltInPresetSourceManifestPreset( - presetName: string, -): PresetSourceManifestPreset | undefined { - return findPresetSourceManifestPreset( - loadBuiltInPresetSourceManifest(), - presetName, - ); -} - -export function loadBuiltInPresetProjectionDeclaration( - presetName: string, -): PresetProjectionDeclaration { - const preset = findBuiltInPresetSourceManifestPreset(presetName); - - if (!preset?.projection) { - throw new Error( - `Built-in Preset ${presetName} must declare a Projection Declaration`, - ); - } - - const result = validatePresetProjectionDeclaration(preset.projection); - if (!result.ok) { - throw new Error( - `Built-in Preset ${presetName} Projection Declaration is invalid:\n${result.issues - .map((issue) => ` - ${issue.path}: ${issue.message}`) - .join("\n")}`, - ); - } - - return result.value; -} - -export function projectBuiltInPresetSourcePreset(options: { - readonly preset: PresetSourceManifestPreset; - readonly context: GenerationContext; -}) { - return projectPresetSourcePreset({ - ...options, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); -} - -export function defaultPackagePathForBuiltInPackageAddition( - preset: PresetSourceManifestPreset, - packageLeafName: string, -): string { - return defaultPackagePathForPresetSourcePackageAddition( - preset, - packageLeafName, - builtInPresetProjectionSourceRoots(), - ); -} - -export function planBuiltInPackageAddition(options: { - readonly preset: PresetSourceManifestPreset; - readonly addition: PresetPackageAdditionOptions; -}) { - return planPresetSourcePackageAddition({ - ...options, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); -} diff --git a/packages/builtin-source/templates/preset-source.json b/packages/builtin-source/templates/preset-source.json deleted file mode 100644 index b457163..0000000 --- a/packages/builtin-source/templates/preset-source.json +++ /dev/null @@ -1,559 +0,0 @@ -{ - "schemaVersion": 1, - "name": "built-in", - "sharedResources": [ - { - "id": "shared-devcontainer", - "path": "shared/devcontainer" - }, - { - "id": "shared-editor-customization", - "path": "shared/editor-customization/capabilities.json" - }, - { - "id": "shared-oxc-node", - "path": "shared/oxc/node" - }, - { - "id": "shared-oxc-format", - "path": "shared/oxc/oxfmt.config.ts" - }, - { - "id": "shared-oxc-root-tsconfig", - "path": "shared/oxc/tsconfig.config.json" - }, - { - "id": "shared-oxc-vue", - "path": "shared/oxc/vue" - }, - { - "id": "shared-typescript", - "path": "shared/typescript" - }, - { - "id": "shared-toolchain-maintenance", - "path": "shared/toolchain-maintenance" - }, - { - "id": "shared-pnpm-peer-policy", - "path": "shared/pnpm-peer-policy" - } - ], - "presets": [ - { - "name": "ts-lib", - "title": "TypeScript library", - "description": "Strict TypeScript package with pnpm catalog tooling.", - "generation": "supported", - "supportedPackageManagers": ["pnpm"], - "supportedProjectKinds": ["multi-package"], - "packageAdditionSupport": "supported", - "features": [ - "pnpm-catalog", - "oxc-format-lint", - "strict-typescript", - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot" - ], - "dependencyCatalog": [ - "@types/node", - "oxfmt", - "oxlint", - "oxlint-tsgolint", - "turbo", - "typescript-7", - "valibot" - ], - "projection": { - "capabilities": [ - { - "kind": "workspace-library-package", - "workspacePackageGlob": "packages/*", - "packageRole": "shared-library", - "packageSourcePreset": "ts-lib", - "sourceFiles": ["turbo.json", "src/index.ts", "src/name-schema.ts"] - }, - { - "kind": "strict-typescript-root" - }, - { - "kind": "oxc-format-lint", - "editorCustomizationResourceId": "shared-editor-customization" - }, - { - "kind": "node-pnpm-devcontainer", - "devcontainerResourceId": "shared-devcontainer" - }, - { - "kind": "github-maintenance" - } - ] - }, - "source": { - "roots": ["ts-lib/.github", "ts-lib/src"], - "files": ["ts-lib/turbo.json"], - "sharedResources": [ - "shared-devcontainer", - "shared-editor-customization", - "shared-oxc-node", - "shared-oxc-format", - "shared-oxc-root-tsconfig", - "shared-pnpm-peer-policy", - "shared-toolchain-maintenance" - ] - } - }, - { - "name": "vue-app", - "title": "Vue app", - "description": "Vue app workspace with Vite, Tailwind, Pinia, and test tooling.", - "generation": "supported", - "supportedPackageManagers": ["pnpm"], - "supportedProjectKinds": ["multi-package"], - "packageAdditionSupport": "supported", - "features": [ - "pnpm-catalog", - "oxc-format-lint", - "strict-typescript", - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot" - ], - "dependencyCatalog": [ - "@playwright/test", - "@tailwindcss/vite", - "@types/node", - "@types/web-bluetooth", - "@vitejs/plugin-vue", - "@vue/tsconfig", - "oxfmt", - "oxlint", - "oxlint-tsgolint", - "pinia", - "tailwindcss", - "turbo", - "typescript", - "typescript-7", - "vite", - "vitest", - "vue", - "vue-tsc" - ], - "projection": { - "capabilities": [ - { - "kind": "workspace-node-packages", - "workspacePackageGlob": "apps/*", - "packages": [ - { - "kind": "vue-app", - "path": "apps/web", - "sourceFiles": [ - "turbo.json", - "env.d.ts", - "index.html", - "playwright.config.ts", - "scripts/run-playwright.ts", - "vite.config.ts", - "vitest.config.ts", - "src/App.vue", - "src/main.ts", - "src/style.css", - "src/stores/counter.ts", - "test/app.test.ts", - "test/e2e/app.spec.ts" - ] - } - ] - }, - { - "kind": "strict-typescript-root" - }, - { - "kind": "oxc-format-lint", - "editorCustomizationResourceId": "shared-editor-customization" - }, - { - "kind": "node-pnpm-devcontainer", - "devcontainerResourceId": "shared-devcontainer" - }, - { - "kind": "github-maintenance" - } - ] - }, - "source": { - "roots": ["vue-app/.github", "vue-app/src", "vue-app/test"], - "files": [ - "vue-app/env.d.ts", - "vue-app/index.html", - "vue-app/playwright.config.ts", - "vue-app/scripts/run-playwright.ts", - "vue-app/turbo.json", - "vue-app/vite.config.ts", - "vue-app/vitest.config.ts" - ], - "sharedResources": [ - "shared-devcontainer", - "shared-editor-customization", - "shared-oxc-format", - "shared-oxc-root-tsconfig", - "shared-pnpm-peer-policy", - "shared-oxc-vue", - "shared-typescript", - "shared-toolchain-maintenance" - ] - } - }, - { - "name": "vike-app", - "title": "Vike app", - "description": "Vike, Hono, Telefunc, Drizzle, and Vue workspace with separate database and migration packages.", - "generation": "supported", - "supportedPackageManagers": ["pnpm"], - "supportedProjectKinds": ["multi-package"], - "packageAdditionSupport": "unsupported", - "features": [ - "pnpm-catalog", - "oxc-format-lint", - "strict-typescript", - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot" - ], - "dependencyCatalog": [ - "@playwright/test", - "@tailwindcss/vite", - "@types/node", - "@vitejs/plugin-vue", - "@vikejs/hono", - "@vue/tsconfig", - "drizzle-kit", - "drizzle-orm", - "hono", - "oxfmt", - "oxlint", - "oxlint-tsgolint", - "srvx", - "tailwindcss", - "telefunc", - "typescript", - "typescript-7", - "vite", - "vike", - "vike-vue", - "vitest", - "vue", - "vue-tsc" - ], - "projection": { - "capabilities": [ - { - "kind": "workspace-node-packages", - "workspacePackageGlob": "apps/*", - "packageLinks": [ - { - "consumerPackagePath": "apps/web", - "providerPackagePath": "packages/db" - } - ], - "packages": [ - { - "kind": "vike-app", - "path": "apps/web", - "sourceFiles": [ - "web/+server.ts", - "web/.env.example", - "web/Dockerfile", - "web/Dockerfile.dockerignore", - "web/assets/logo.svg", - "web/components/CounterButton.vue", - "web/components/PageShell.vue", - "web/pages/+Head.vue", - "web/pages/+Layout.vue", - "web/pages/+config.ts", - "web/pages/index/+Page.vue", - "web/pages/index/+Page.telefunc.ts", - "web/pages/tailwind.css", - "web/playwright.config.ts", - "web/scripts/check-standalone-deployment.ts", - "web/scripts/container-entrypoint.sh", - "web/scripts/run-playwright.ts", - "web/turbo.json", - "web/server/api.ts", - "web/server/app.ts", - "web/test/e2e/app.spec.ts", - "web/types/env.d.ts", - "web/types/global.d.ts", - "web/vite.config.ts", - "web/vitest.config.ts" - ] - } - ] - }, - { - "kind": "strict-typescript-root" - }, - { - "kind": "oxc-format-lint", - "editorCustomizationResourceId": "shared-editor-customization" - }, - { - "kind": "node-pnpm-devcontainer", - "devcontainerResourceId": "shared-devcontainer" - }, - { - "kind": "github-maintenance" - } - ] - }, - "source": { - "roots": [ - "vike-app/.github", - "vike-app/db", - "vike-app/db-migrations", - "vike-app/web/assets", - "vike-app/web/components", - "vike-app/web/pages", - "vike-app/web/scripts", - "vike-app/web/server", - "vike-app/web/test", - "vike-app/web/types" - ], - "files": [ - "vike-app/db-migrations/drizzle.config.ts", - "vike-app/db-migrations/scripts/prepare-database.ts", - "vike-app/db-migrations/turbo.json", - "vike-app/db/turbo.json", - "vike-app/web/+server.ts", - "vike-app/web/.env.example", - "vike-app/web/Dockerfile", - "vike-app/web/Dockerfile.dockerignore", - "vike-app/web/playwright.config.ts", - "vike-app/web/scripts/check-standalone-deployment.ts", - "vike-app/web/scripts/container-entrypoint.sh", - "vike-app/web/vite.config.ts", - "vike-app/web/turbo.json", - "vike-app/web/vitest.config.ts" - ], - "sharedResources": [ - "shared-devcontainer", - "shared-editor-customization", - "shared-oxc-format", - "shared-oxc-root-tsconfig", - "shared-pnpm-peer-policy", - "shared-oxc-vue", - "shared-typescript", - "shared-toolchain-maintenance" - ] - } - }, - { - "name": "vue-hono-app", - "title": "Vue Hono app", - "description": "Full-stack Vue and Hono workspace with separated app package boundaries.", - "generation": "supported", - "supportedPackageManagers": ["pnpm"], - "supportedProjectKinds": ["multi-package"], - "packageAdditionSupport": "unsupported", - "features": [ - "pnpm-catalog", - "oxc-format-lint", - "strict-typescript", - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot" - ], - "dependencyCatalog": [ - "@hono/node-server", - "@playwright/test", - "@tailwindcss/vite", - "@types/node", - "@types/web-bluetooth", - "@vitejs/plugin-vue", - "@vue/tsconfig", - "hono", - "oxfmt", - "oxlint", - "oxlint-tsgolint", - "pinia", - "tailwindcss", - "tsc-alias", - "turbo", - "typescript", - "typescript-7", - "vite", - "vitest", - "vue", - "vue-tsc" - ], - "projection": { - "capabilities": [ - { - "kind": "workspace-node-packages", - "workspacePackageGlob": "apps/*", - "packages": [ - { - "kind": "vue-app", - "path": "apps/web", - "sourceFiles": [ - "web/turbo.json", - "web/env.d.ts", - "web/index.html", - "web/playwright.config.ts", - "web/scripts/run-playwright.ts", - "web/vite.config.ts", - "web/vitest.config.ts", - "web/src/api.ts", - "web/src/App.vue", - "web/src/main.ts", - "web/src/style.css", - "web/src/stores/counter.ts", - "web/test/app.test.ts", - "web/test/e2e/app.spec.ts" - ] - }, - { - "kind": "hono-api", - "path": "apps/api", - "sourceFiles": [ - "api/turbo.json", - "api/src/index.ts", - "api/src/runtime.ts", - "api/src/server.ts", - "api/test/app.test.ts", - "api/vitest.config.ts" - ] - } - ] - }, - { - "kind": "strict-typescript-root" - }, - { - "kind": "oxc-format-lint", - "editorCustomizationResourceId": "shared-editor-customization" - }, - { - "kind": "node-pnpm-devcontainer", - "devcontainerResourceId": "shared-devcontainer" - }, - { - "kind": "github-maintenance" - } - ] - }, - "source": { - "roots": [ - "vue-hono-app/.github", - "vue-hono-app/api/src", - "vue-hono-app/api/test", - "vue-hono-app/web/src", - "vue-hono-app/web/test" - ], - "files": [ - "vue-hono-app/api/vitest.config.ts", - "vue-hono-app/api/turbo.json", - "vue-hono-app/web/env.d.ts", - "vue-hono-app/web/index.html", - "vue-hono-app/web/playwright.config.ts", - "vue-hono-app/web/scripts/run-playwright.ts", - "vue-hono-app/web/turbo.json", - "vue-hono-app/web/vite.config.ts", - "vue-hono-app/web/vitest.config.ts" - ], - "sharedResources": [ - "shared-devcontainer", - "shared-editor-customization", - "shared-oxc-node", - "shared-oxc-format", - "shared-oxc-root-tsconfig", - "shared-pnpm-peer-policy", - "shared-oxc-vue", - "shared-typescript", - "shared-toolchain-maintenance" - ] - } - }, - { - "name": "rust-bin", - "title": "Rust binary", - "description": "Rust native binary workspace with rustfmt, clippy, and cargo tests.", - "generation": "supported", - "supportedPackageManagers": ["pnpm"], - "supportedProjectKinds": ["multi-package"], - "packageAdditionSupport": "unsupported", - "features": [ - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot", - "rustfmt-clippy", - "cargo-test" - ], - "dependencyCatalog": [ - "@types/node", - "@types/semver", - "semver", - "turbo", - "typescript-7" - ], - "projection": { - "capabilities": [ - { - "kind": "rust-binary-workspace", - "workspacePackageGlob": "packages/*", - "sourceFiles": ["turbo.json", "src/main.rs"], - "cargoDependencies": ["anyhow"], - "devcontainerResourceId": "shared-devcontainer", - "editorCustomizationResourceId": "shared-editor-customization" - } - ] - }, - "source": { - "roots": ["rust-bin/.github", "rust-bin/src"], - "files": [ - "rust-bin/rust-toolchain.toml", - "rust-bin/rustfmt.toml", - "rust-bin/turbo.json" - ], - "sharedResources": [ - "shared-devcontainer", - "shared-editor-customization", - "shared-oxc-root-tsconfig", - "shared-toolchain-maintenance" - ] - } - }, - { - "name": "ts-app", - "title": "TypeScript application", - "description": "Future application preset metadata.", - "generation": "future", - "supportedPackageManagers": ["pnpm"], - "supportedProjectKinds": ["multi-package"], - "packageAdditionSupport": "unsupported", - "features": [] - }, - { - "name": "node-cli", - "title": "Node.js CLI", - "description": "Future command-line preset metadata.", - "generation": "future", - "supportedPackageManagers": ["pnpm"], - "supportedProjectKinds": ["multi-package"], - "packageAdditionSupport": "unsupported", - "features": [] - } - ] -} diff --git a/packages/builtin-source/templates/projection-plans.ts b/packages/builtin-source/templates/projection-plans.ts deleted file mode 100644 index fd5ca7c..0000000 --- a/packages/builtin-source/templates/projection-plans.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { - CheckComponent, - CheckEnvironmentNeed, - CheckPlan, - ComponentOwner, - FixComponent, - FixPlan, -} from "@ykdz/template-core/module-graph"; -import { playwrightBrowserAssetsEnvironmentNeed } from "@ykdz/template-core/module-graph"; - -const appsWorkspaceBoundary: ComponentOwner = { - kind: "package-boundary", - path: "apps/*", -}; - -const apiPackageBoundary: ComponentOwner = { - kind: "package-boundary", - path: "apps/api", -}; - -const webPackageBoundary: ComponentOwner = { - kind: "package-boundary", - path: "apps/web", -}; - -export type NodeCheckPlanTarget = - | "hono-api" - | "vue-app" - | "vue-hono-root" - | "vue-hono-api" - | "vue-hono-web"; - -export type NodeFixPlanTarget = - | "hono-api" - | "vue-app" - | "vue-hono-root" - | "vue-hono-api" - | "vue-hono-web"; - -function honoApiCheckComponents(owner: ComponentOwner): CheckComponent[] { - return [ - { kind: "oxc-format-check", owner }, - { kind: "oxc-lint", owner }, - { kind: "typescript-typecheck", owner }, - { kind: "build", owner }, - { kind: "unit-test", owner }, - ]; -} - -function vueAppCheckComponents(owner: ComponentOwner): CheckComponent[] { - return [ - { kind: "oxc-format-check", owner }, - { kind: "oxc-lint", owner }, - { kind: "typescript-typecheck", owner }, - { kind: "build", owner }, - { kind: "unit-test", owner }, - { kind: "e2e-test", owner }, - ]; -} - -function nodeFixComponents(owner: ComponentOwner): FixComponent[] { - return [ - { kind: "oxc-format-write", owner }, - { kind: "oxc-lint-fix", owner }, - ]; -} - -export function selectNodeCheckComponents( - target: NodeCheckPlanTarget, -): CheckComponent[] { - switch (target) { - case "hono-api": - return honoApiCheckComponents(apiPackageBoundary); - case "vue-app": - return vueAppCheckComponents(webPackageBoundary); - case "vue-hono-root": - return [{ kind: "turbo-check", owner: appsWorkspaceBoundary }]; - case "vue-hono-api": - return honoApiCheckComponents(apiPackageBoundary); - case "vue-hono-web": - return vueAppCheckComponents(webPackageBoundary); - } -} - -export function selectNodeFixComponents( - target: NodeFixPlanTarget, -): FixComponent[] { - switch (target) { - case "hono-api": - return nodeFixComponents(apiPackageBoundary); - case "vue-app": - return nodeFixComponents(webPackageBoundary); - case "vue-hono-root": - return [{ kind: "turbo-fix", owner: appsWorkspaceBoundary }]; - case "vue-hono-api": - return nodeFixComponents(apiPackageBoundary); - case "vue-hono-web": - return nodeFixComponents(webPackageBoundary); - } -} - -function checkEnvironmentNeeds( - target: NodeCheckPlanTarget, -): CheckEnvironmentNeed[] { - if (target === "vue-app") { - return [ - playwrightBrowserAssetsEnvironmentNeed({ - browser: "chromium", - owner: webPackageBoundary, - id: "install-playwright-browsers", - label: "Install Playwright browser assets", - }), - ]; - } - - if (target === "vue-hono-root" || target === "vue-hono-web") { - return [ - playwrightBrowserAssetsEnvironmentNeed({ - browser: "chromium", - owner: webPackageBoundary, - id: "install-web-playwright-browsers", - label: "Install Playwright browser assets for web workspace", - }), - ]; - } - - return []; -} - -export function planNodeChecks(target: NodeCheckPlanTarget): CheckPlan { - return { - components: selectNodeCheckComponents(target), - environmentNeeds: checkEnvironmentNeeds(target), - }; -} - -export function planNodeFixes(target: NodeFixPlanTarget): FixPlan { - return { components: selectNodeFixComponents(target) }; -} diff --git a/packages/builtin-source/templates/registry.ts b/packages/builtin-source/templates/registry.ts deleted file mode 100644 index 191c9dd..0000000 --- a/packages/builtin-source/templates/registry.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { PresetProjection } from "@ykdz/template-core/preset-projection"; - -import { rustBinPresetProjection } from "./rust-bin/projection.ts"; -import { tsLibPresetProjection } from "./ts-lib/projection.ts"; -import { vikeAppPresetProjection } from "./vike-app/projection.ts"; -import { vueAppPresetProjection } from "./vue-app/projection.ts"; -import { vueHonoAppPresetProjection } from "./vue-hono-app/projection.ts"; - -const testOnlyPresetProjections: readonly PresetProjection[] = [ - tsLibPresetProjection, - vueAppPresetProjection, - vikeAppPresetProjection, - vueHonoAppPresetProjection, - rustBinPresetProjection, -]; - -export function findBuiltInPresetProjection( - name: string, -): PresetProjection | undefined { - return testOnlyPresetProjections.find( - (projection) => projection.metadata.name === name, - ); -} diff --git a/packages/builtin-source/templates/rust-bin/behavior.test.ts b/packages/builtin-source/templates/rust-bin/behavior.test.ts deleted file mode 100644 index 2dcae62..0000000 --- a/packages/builtin-source/templates/rust-bin/behavior.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { mkdtemp, readFile, readdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import * as v from "valibot"; -import { describe, expect, it } from "vitest"; - -import { rustBinPresetProjection } from "./projection.ts"; - -const packageJsonSchema = v.looseObject({ - name: v.string(), - private: v.optional(v.boolean()), - version: v.optional(v.string()), - devDependencies: v.optional(v.record(v.string(), v.string())), - engines: v.object({ node: v.string() }), - packageManager: v.optional(v.string()), - scripts: v.record(v.string(), v.string()), -}); -const devcontainerSchema = v.looseObject({ - name: v.string(), - build: v.object({ - dockerfile: v.string(), - args: v.record(v.string(), v.string()), - }), - customizations: v.object({ - vscode: v.object({ - extensions: v.array(v.string()), - settings: v.record(v.string(), v.unknown()), - }), - }), - features: v.optional(v.unknown()), - mounts: v.array(v.string()), -}); - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -async function generatedFilePaths( - root: string, - current = ".", -): Promise { - const entries = await readdir(path.join(root, current), { - withFileTypes: true, - }); - const paths = await Promise.all( - entries.map(async (entry) => { - const relativePath = path.join(current, entry.name); - if (entry.isDirectory()) { - return generatedFilePaths(root, relativePath); - } - - return [relativePath.replaceAll(path.sep, "/")]; - }), - ); - - return paths.flat().toSorted(); -} - -async function renderRustBinProject(): Promise { - const workspace = await mkdtemp(path.join(tmpdir(), "rust-bin-behavior-")); - const targetDir = path.join(workspace, "Demo Rust!"); - const blueprint = rustBinPresetProjection.blueprint({ targetDir }); - const context = assembleGenerationContext({ - blueprint, - targetDir, - toolchain: { - diagnostics: [], - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - }, - }); - - const plan = rustBinPresetProjection.project(context); - await rustBinPresetProjection.render({ plan, targetDir }); - - return targetDir; -} - -async function renderRustBinProjectAt(targetDir: string): Promise { - const blueprint = rustBinPresetProjection.blueprint({ targetDir }); - const context = assembleGenerationContext({ - blueprint, - targetDir, - toolchain: { - diagnostics: [], - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - }, - }); - - const plan = rustBinPresetProjection.project(context); - await rustBinPresetProjection.render({ plan, targetDir }); -} - -describe("rust-bin Preset Source behavior", () => { - it("projects a native Rust package with Rust toolchain infrastructure", async () => { - const targetDir = await renderRustBinProject(); - const rootPackageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const rustPackageJson = await readJsonWithSchema( - path.join(targetDir, "packages/demo-rust/package.json"), - packageJsonSchema, - ); - const devcontainer = await readJsonWithSchema( - path.join(targetDir, ".devcontainer/devcontainer.json"), - devcontainerSchema, - ); - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - const cargoToml = await readFile( - path.join(targetDir, "packages/demo-rust/Cargo.toml"), - "utf8", - ); - const checkWorkflow = await readFile( - path.join(targetDir, ".github/workflows/check.yml"), - "utf8", - ); - const dependabot = await readFile( - path.join(targetDir, ".github/dependabot.yml"), - "utf8", - ); - const files = await generatedFilePaths(targetDir); - - expect(rootPackageJson).toMatchObject({ - name: "demo-rust", - engines: { node: "24" }, - packageManager: "pnpm@11.2.3", - devDependencies: { turbo: "catalog:" }, - }); - expect(rootPackageJson.scripts).toMatchObject({ - check: - "pnpm run check:boundaries && turbo run typecheck:run format:check:run lint:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - "check:boundaries": "turbo boundaries --no-color", - fix: "turbo run format:write:run lint:fix:run fix:run --output-logs=errors-only --log-order=grouped", - }); - expect(rustPackageJson).toEqual({ - name: "demo-rust-native", - version: "0.0.0", - private: true, - scripts: { - "format:check:run": "cargo fmt --all -- --check", - "format:write:run": "cargo fmt --all", - "lint:run": "cargo clippy --workspace --all-targets -- -D warnings", - "test:run": "cargo test --workspace", - }, - engines: { node: "24" }, - }); - expect(cargoToml).toContain('name = "demo-rust"'); - expect(cargoToml).toContain('anyhow = "1.0.100"'); - expect(files).toContain("rust-toolchain.toml"); - expect(files).toContain("packages/demo-rust/src/main.rs"); - expect(files).not.toContain("behavior.test.ts"); - expect(files).not.toContain("packages/demo-rust/behavior.test.ts"); - - expect(devcontainer.name).toBe("Demo Rust!"); - expect(devcontainer.build).toEqual({ - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@11.2.3", - RUST_TOOLCHAIN: "stable", - }, - }); - expect(devcontainer).not.toHaveProperty("features"); - expect(devcontainer.mounts).toEqual( - expect.arrayContaining([ - expect.stringContaining("target=/usr/local/cargo/registry"), - expect.stringContaining("target=/usr/local/cargo/git"), - expect.stringContaining("target=${containerWorkspaceFolder}/target"), - ]), - ); - expect(devcontainer.customizations.vscode.extensions).toEqual([ - "rust-lang.rust-analyzer", - "tamasfe.even-better-toml", - ]); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain("ARG RUST_TOOLCHAIN"); - expect(dockerfile).toContain( - "rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal --component rustfmt --component clippy", - ); - expect(dockerfile).toContain("gcc"); - expect(dockerfile).toContain("libc6-dev"); - expect(dockerfile).not.toContain("typescript-node"); - expect(dockerfile).not.toContain("shellcheck"); - expect(dockerfile).not.toMatch( - /\b(build-essential|pkg-config|libssl-dev)\b/, - ); - expect(checkWorkflow).toContain("uses: dtolnay/rust-toolchain@stable"); - expect(checkWorkflow).not.toContain("shellcheck"); - expect(dependabot).toContain("package-ecosystem: cargo"); - expect(dependabot).toContain("package-ecosystem: rust-toolchain"); - }); - - it("normalizes directory names into Cargo-safe package names", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "rust-bin-name-")); - const targetDir = path.join(workspace, 'My demo.app "quoted"'); - - await renderRustBinProjectAt(targetDir); - - const cargoToml = await readFile( - path.join(targetDir, "packages/my-demo-app-quoted/Cargo.toml"), - "utf8", - ); - const cargoLock = await readFile( - path.join(targetDir, "packages/my-demo-app-quoted/Cargo.lock"), - "utf8", - ); - const devcontainer = await readJsonWithSchema( - path.join(targetDir, ".devcontainer/devcontainer.json"), - devcontainerSchema, - ); - - expect(cargoToml).toContain('name = "my-demo-app-quoted"'); - expect(cargoToml).toContain('anyhow = "1.0.100"'); - expect(cargoLock).toContain('name = "my-demo-app-quoted"'); - expect(devcontainer.name).toBe('My demo.app "quoted"'); - }); -}); diff --git a/packages/builtin-source/templates/rust-bin/projection.ts b/packages/builtin-source/templates/rust-bin/projection.ts deleted file mode 100644 index 2872ffa..0000000 --- a/packages/builtin-source/templates/rust-bin/projection.ts +++ /dev/null @@ -1,139 +0,0 @@ -import path from "node:path"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetProjectionDeclaration, -} from "@ykdz/template-builtin-source"; -import type { GenerationContext } from "@ykdz/template-core/generation-context"; -import { - type CheckPlan, - type ComponentOwner, - type FixPlan, -} from "@ykdz/template-core/module-graph"; -import type { - PresetBlueprintOptions, - PresetProjection, - PresetProjectionPlan, -} from "@ykdz/template-core/preset-projection"; -import { interpretPresetProjectionDeclaration } from "@ykdz/template-core/projection-capabilities"; -import { renderNewProject } from "@ykdz/template-core/renderer"; -import { - PackageAdditionSupport, - type BuiltInPreset, - type ProjectBlueprint, -} from "@ykdz/template-shared"; - -export const rustBinPresetMetadata: BuiltInPreset = { - name: "rust-bin", - title: "Rust binary", - description: - "Rust native binary workspace with rustfmt, clippy, and cargo tests.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Unsupported, - features: [ - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot", - "rustfmt-clippy", - "cargo-test", - ], -}; - -const rustPackageBoundary: ComponentOwner = { - kind: "package-boundary", - path: "packages/*", -}; - -function cargoPackageNameFromProjectName(projectName: string): string { - const slug = projectName - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - return slug || "rust-bin"; -} - -function projectNameFromDir(targetDir: string): string { - return path.basename(path.resolve(targetDir)); -} - -function rustWorkspacePackageName(projectName: string): string { - return `${cargoPackageNameFromProjectName(projectName)}-native`; -} - -function rustWorkspacePackagePath(projectName: string): string { - return `packages/${cargoPackageNameFromProjectName(projectName)}`; -} - -export function planRustBinChecks(): CheckPlan { - return { - components: [ - { kind: "rustfmt-check", owner: rustPackageBoundary }, - { kind: "cargo-clippy", owner: rustPackageBoundary }, - { kind: "cargo-test", owner: rustPackageBoundary }, - ], - environmentNeeds: [], - }; -} - -export function planRustBinFixes(): FixPlan { - return { - components: [{ kind: "rustfmt-write", owner: rustPackageBoundary }], - }; -} - -export function rustBinBlueprint( - options: PresetBlueprintOptions = { targetDir: process.cwd() }, -): ProjectBlueprint { - const projectName = projectNameFromDir(options.targetDir); - - return { - schemaVersion: 1, - preset: "rust-bin", - packageManager: "pnpm", - projectKind: "multi-package", - features: [...rustBinPresetMetadata.features], - packages: [ - { - name: rustWorkspacePackageName(projectName), - path: rustWorkspacePackagePath(projectName), - }, - ], - }; -} - -export function projectRustBinPackageScripts(): Record { - return { - "format:check:run": "cargo fmt --all -- --check", - "format:write:run": "cargo fmt --all", - "lint:run": "cargo clippy --workspace --all-targets -- -D warnings", - "test:run": "cargo test --workspace", - }; -} - -export const rustBinPresetProjection: PresetProjection = { - metadata: rustBinPresetMetadata, - blueprint: rustBinBlueprint, - project(context: GenerationContext): PresetProjectionPlan { - return interpretPresetProjectionDeclaration({ - preset: rustBinPresetMetadata, - declaration: loadBuiltInPresetProjectionDeclaration("rust-bin"), - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - }, - async render({ targetDir, plan }): Promise { - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - }, -}; diff --git a/packages/builtin-source/templates/shared/editor-customization/capabilities.json b/packages/builtin-source/templates/shared/editor-customization/capabilities.json deleted file mode 100644 index a205c99..0000000 --- a/packages/builtin-source/templates/shared/editor-customization/capabilities.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "capabilities": { - "oxc-format-lint": { - "extensions": ["oxc.oxc-vscode"], - "settings": { - "editor.codeActionsOnSave": { - "source.format.oxc": "always", - "source.fixAll.oxc": "always" - }, - "editor.defaultFormatter": "oxc.oxc-vscode", - "editor.formatOnPaste": true, - "editor.formatOnSave": true, - "editor.formatOnSaveMode": "file", - "oxc.enable": true, - "[javascript]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[json]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[markdown]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, - "[typescript]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - } - } - }, - "vue": { - "extensions": ["Vue.volar"], - "settings": {} - }, - "tailwind": { - "extensions": ["bradlc.vscode-tailwindcss"], - "settings": {} - }, - "rust-tooling": { - "extensions": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml"], - "settings": { - "rust-analyzer.cargo.features": "all", - "rust-analyzer.check.command": "clippy", - "rust-analyzer.procMacro.enable": true, - "[rust]": { - "editor.defaultFormatter": "rust-lang.rust-analyzer" - }, - "[toml]": { - "editor.defaultFormatter": "tamasfe.even-better-toml" - } - } - }, - "vitest": { - "extensions": ["vitest.explorer"], - "settings": {} - } - } -} diff --git a/packages/builtin-source/templates/shared/oxc/node/oxlint.config.ts b/packages/builtin-source/templates/shared/oxc/node/oxlint.config.ts deleted file mode 100644 index 3a27ac8..0000000 --- a/packages/builtin-source/templates/shared/oxc/node/oxlint.config.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { defineConfig } from "oxlint"; - -export default defineConfig({ - options: { - typeAware: true, - }, - categories: { - correctness: "error", - suspicious: "warn", - }, - plugins: [ - "eslint", - "typescript", - "unicorn", - "oxc", - "node", - "import", - "vitest", - "promise", - ], - rules: { - "typescript/consistent-return": "off", - "typescript/no-misused-promises": "error", - "typescript/no-unsafe-argument": "warn", - "typescript/no-unsafe-assignment": "warn", - "typescript/no-unsafe-call": "warn", - "typescript/no-unsafe-member-access": "warn", - "typescript/no-unsafe-return": "warn", - "typescript/switch-exhaustiveness-check": "error", - "vitest/expect-expect": "off", - "vitest/no-conditional-expect": "off", - "vitest/require-to-throw-message": "off", - }, -}); diff --git a/packages/builtin-source/templates/shared/oxc/oxfmt.config.ts b/packages/builtin-source/templates/shared/oxc/oxfmt.config.ts deleted file mode 100644 index 14798b3..0000000 --- a/packages/builtin-source/templates/shared/oxc/oxfmt.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "oxfmt"; - -export default defineConfig({ - printWidth: 80, - sortImports: true, - sortPackageJson: true, - sortTailwindcss: true, -}); diff --git a/packages/builtin-source/templates/shared/oxc/vue/oxlint.config.ts b/packages/builtin-source/templates/shared/oxc/vue/oxlint.config.ts deleted file mode 100644 index 69b3f2b..0000000 --- a/packages/builtin-source/templates/shared/oxc/vue/oxlint.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { defineConfig } from "oxlint"; - -export default defineConfig({ - options: { - typeAware: true, - }, - categories: { - correctness: "error", - suspicious: "warn", - }, - plugins: [ - "eslint", - "typescript", - "unicorn", - "oxc", - "node", - "import", - "vitest", - "promise", - "vue", - ], - rules: { - "typescript/consistent-return": "off", - "import/no-unassigned-import": ["warn", { allow: ["**/*.css"] }], - "typescript/no-misused-promises": "error", - "typescript/no-unsafe-argument": "warn", - "typescript/no-unsafe-assignment": "warn", - "typescript/no-unsafe-call": "warn", - "typescript/no-unsafe-member-access": "warn", - "typescript/no-unsafe-return": "warn", - "typescript/switch-exhaustiveness-check": "error", - "vitest/expect-expect": "off", - "vitest/no-conditional-expect": "off", - "vitest/require-to-throw-message": "off", - }, -}); diff --git a/packages/builtin-source/templates/shared/pnpm-peer-policy/.pnpmfile.cts b/packages/builtin-source/templates/shared/pnpm-peer-policy/.pnpmfile.cts deleted file mode 100644 index 3bf7f8b..0000000 --- a/packages/builtin-source/templates/shared/pnpm-peer-policy/.pnpmfile.cts +++ /dev/null @@ -1,17 +0,0 @@ -type PackageManifest = { - name?: string; - peerDependencies?: Record; - peerDependenciesMeta?: Record; -}; - -const typeOnlyPeerOwners = new Set(["pinia", "valibot", "vue"]); - -function readPackage(pkg: PackageManifest): PackageManifest { - if (!pkg.name || !typeOnlyPeerOwners.has(pkg.name)) return pkg; - - delete pkg.peerDependencies?.typescript; - delete pkg.peerDependenciesMeta?.typescript; - return pkg; -} - -module.exports = { hooks: { readPackage } }; diff --git a/packages/builtin-source/templates/shared/pnpm-peer-policy/tsconfig.json b/packages/builtin-source/templates/shared/pnpm-peer-policy/tsconfig.json deleted file mode 100644 index 7050dcf..0000000 --- a/packages/builtin-source/templates/shared/pnpm-peer-policy/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../../../tsconfig.base.json", - "compilerOptions": { - "noEmit": true, - "types": ["node"] - }, - "include": [".pnpmfile.cts"] -} diff --git a/packages/builtin-source/templates/shared/toolchain-maintenance/toolchain-baseline-update.yml b/packages/builtin-source/templates/shared/toolchain-maintenance/toolchain-baseline-update.yml deleted file mode 100644 index eb22427..0000000 --- a/packages/builtin-source/templates/shared/toolchain-maintenance/toolchain-baseline-update.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Toolchain Baseline Update - -on: - schedule: - - cron: "17 4 * * *" - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -concurrency: - group: toolchain-baseline-update - cancel-in-progress: false - -jobs: - update: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - run: corepack enable - - run: pnpm install --frozen-lockfile - - id: desired-toolchain - run: node scripts/update-toolchain-baseline.ts --print-workflow-outputs >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v6 - with: - node-version: ${{ steps.desired-toolchain.outputs.node-major }} - - run: corepack enable - - run: node scripts/update-toolchain-baseline.ts - env: - GH_TOKEN: ${{ github.token }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} diff --git a/packages/builtin-source/templates/shared/toolchain-maintenance/tsconfig.json b/packages/builtin-source/templates/shared/toolchain-maintenance/tsconfig.json deleted file mode 100644 index 923e8e0..0000000 --- a/packages/builtin-source/templates/shared/toolchain-maintenance/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../../../../tsconfig.base.json", - "compilerOptions": { - "noEmit": true, - "types": ["node"] - }, - "include": ["update-toolchain-baseline.ts"] -} diff --git a/packages/builtin-source/templates/shared/toolchain-maintenance/update-toolchain-baseline.ts b/packages/builtin-source/templates/shared/toolchain-maintenance/update-toolchain-baseline.ts deleted file mode 100644 index a7b14be..0000000 --- a/packages/builtin-source/templates/shared/toolchain-maintenance/update-toolchain-baseline.ts +++ /dev/null @@ -1,857 +0,0 @@ -#!/usr/bin/env node -import { spawn } from "node:child_process"; -import { readFile, readdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -export type Baseline = { - readonly nodeLtsMajor: string; - readonly packageManagerPin: `pnpm@${string}`; -}; - -export type Candidate = { - readonly pullRequest: - | { readonly kind: "absent" } - | { readonly kind: "open"; readonly pullRequestNumber: number }; - readonly remoteBranch: - | { readonly kind: "absent" } - | { readonly kind: "present"; readonly sha: string }; -}; - -export type Plan = - | { readonly kind: "no-drift" } - | { - readonly kind: "cleanup-stale"; - readonly pullRequestNumber?: number; - readonly deleteBranch: true; - } - | { - readonly kind: "update"; - readonly mode: "create"; - readonly desired: Baseline; - } - | { - readonly kind: "update"; - readonly mode: "replace"; - readonly pullRequestNumber: number; - readonly desired: Baseline; - }; - -type NodeRelease = { readonly version: string; readonly lts: string | boolean }; -type PnpmMetadata = { - readonly versions: Record< - string, - { readonly engines?: { readonly node?: string } } - >; - readonly time: Record; -}; - -const automationBranch = "automation/toolchain-baseline"; -const maturityMilliseconds = 24 * 60 * 60 * 1_000; -const repositoryRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); - -export type GithubEffects = { - readonly findPullRequests: (options: { - readonly owner: string; - readonly branch: string; - readonly base: string; - }) => Promise; - readonly closePullRequest: (number: number, comment: string) => Promise; - readonly createPullRequest: (options: { - readonly base: string; - readonly head: string; - readonly title: string; - readonly body: string; - }) => Promise; - readonly updatePullRequest: ( - number: number, - options: { readonly title: string; readonly body: string }, - ) => Promise; -}; - -export type MaintenanceHooks = { - readonly installPackageManager?: (baseline: Baseline) => Promise; - readonly updateLockfile?: (baseline: Baseline) => Promise; - readonly prepareChecks?: (baseline: Baseline) => Promise; - readonly runChecks?: (baseline: Baseline) => Promise; - readonly beforePush?: () => Promise; -}; - -export function planToolchainBaselineUpdate(options: { - readonly current: Baseline & { readonly materialConsistent?: boolean }; - readonly desired: Baseline; - readonly candidate: Candidate; -}): Plan { - const drift = - options.current.materialConsistent === false || - options.current.nodeLtsMajor !== options.desired.nodeLtsMajor || - options.current.packageManagerPin !== options.desired.packageManagerPin; - - if (!drift) { - return options.candidate.pullRequest.kind === "absent" && - options.candidate.remoteBranch.kind === "absent" - ? { kind: "no-drift" } - : { - kind: "cleanup-stale", - ...(options.candidate.pullRequest.kind === "open" - ? { - pullRequestNumber: - options.candidate.pullRequest.pullRequestNumber, - } - : {}), - deleteBranch: true, - }; - } - - return options.candidate.pullRequest.kind === "open" - ? { - kind: "update", - mode: "replace", - pullRequestNumber: options.candidate.pullRequest.pullRequestNumber, - desired: options.desired, - } - : { kind: "update", mode: "create", desired: options.desired }; -} - -export async function resolveToolchainBaseline( - nodeInput: unknown, - pnpmInput: unknown, - now: Date, -): Promise { - const { compare, satisfies, valid } = await import("semver"); - if (!Array.isArray(nodeInput)) { - throw new Error("Node release index was not an array"); - } - const releases = nodeInput.map((entry): NodeRelease => { - if (!isRecord(entry) || typeof entry.version !== "string") { - throw new Error("Node release entry is missing version"); - } - if (typeof entry.lts !== "string" && typeof entry.lts !== "boolean") { - throw new Error("Node release entry is missing LTS state"); - } - return { version: entry.version, lts: entry.lts }; - }); - if ( - !isRecord(pnpmInput) || - !isRecord(pnpmInput.versions) || - !isRecord(pnpmInput.time) - ) { - throw new Error("pnpm metadata is missing versions or publication times"); - } - const pnpmMetadata = pnpmInput as PnpmMetadata; - const ltsVersions = releases - .filter((release) => release.lts !== false) - .map((release) => release.version.replace(/^v/, "")) - .filter((version) => valid(version) !== null) - .toSorted(compare); - const nodeVersion = ltsVersions.at(-1); - if (nodeVersion === undefined) { - throw new Error("Node release index contains no LTS release"); - } - const nodeLtsMajor = nodeVersion.split(".")[0]!; - const maturityCutoff = now.getTime() - maturityMilliseconds; - const pnpmVersion = Object.entries(pnpmMetadata.versions) - .filter(([version, metadata]) => { - if (!/^\d+\.\d+\.\d+$/.test(version) || valid(version) === null) - return false; - const publishedAt = Date.parse(pnpmMetadata.time[version] ?? ""); - return ( - Number.isFinite(publishedAt) && - publishedAt <= maturityCutoff && - satisfies(nodeVersion, metadata.engines?.node ?? "*") - ); - }) - .map(([version]) => version) - .toSorted(compare) - .at(-1); - if (pnpmVersion === undefined) { - throw new Error("pnpm metadata contains no mature Node-compatible release"); - } - return { nodeLtsMajor, packageManagerPin: `pnpm@${pnpmVersion}` }; -} - -async function resolveOfficialBaseline(): Promise { - const [nodeResponse, pnpmResponse] = await Promise.all([ - fetch("https://nodejs.org/dist/index.json"), - fetch("https://registry.npmjs.org/pnpm"), - ]); - if (!nodeResponse.ok || !pnpmResponse.ok) { - throw new Error( - `Official toolchain metadata request failed: Node ${nodeResponse.status}, pnpm ${pnpmResponse.status}`, - ); - } - return await resolveToolchainBaseline( - await nodeResponse.json(), - await pnpmResponse.json(), - new Date(), - ); -} - -export async function readCurrentBaseline( - rootDirectory: string, -): Promise { - const manifest = JSON.parse( - await readFile(path.join(rootDirectory, "package.json"), "utf8"), - ) as { engines?: { node?: string }; packageManager?: string }; - const nodeLtsMajor = manifest.engines?.node?.match( - /^(?:>=)?(\d+)(?:\.0\.0)?$/, - )?.[1]; - if ( - !nodeLtsMajor || - !manifest.packageManager?.match(/^pnpm@\d+\.\d+\.\d+$/) - ) { - throw new Error("Repository Toolchain Baseline is malformed"); - } - let materialConsistent = true; - for (const manifestPath of await packageManifestPaths(rootDirectory)) { - const packageManifest = JSON.parse( - await readFile(manifestPath, "utf8"), - ) as { engines?: { node?: string }; packageManager?: string }; - if ( - packageManifest.engines?.node !== undefined && - packageManifest.engines.node !== nodeLtsMajor - ) { - materialConsistent = false; - } - if ( - packageManifest.packageManager !== undefined && - packageManifest.packageManager !== manifest.packageManager - ) { - materialConsistent = false; - } - } - materialConsistent &&= await jsonBaselineMatches( - rootDirectory, - ".devcontainer/devcontainer.json", - (value) => { - const root = requireRecord(value, ".devcontainer/devcontainer.json"); - const build = requireRecord( - root.build, - ".devcontainer/devcontainer.json: build", - ); - const args = requireRecord( - build.args, - ".devcontainer/devcontainer.json: build.args", - ); - return ( - args.NODE_VERSION === nodeLtsMajor && - args.PACKAGE_MANAGER_PIN === manifest.packageManager - ); - }, - ); - materialConsistent &&= await jsonBaselineMatches( - rootDirectory, - ".template/generated-by.json", - (value) => { - const root = requireRecord(value, ".template/generated-by.json"); - const toolchain = requireRecord( - root.toolchain, - ".template/generated-by.json: toolchain", - ); - return ( - toolchain.nodeLtsMajor === nodeLtsMajor && - toolchain.packageManagerPin === manifest.packageManager - ); - }, - ); - const dockerfilePath = path.join(rootDirectory, "apps/web/Dockerfile"); - try { - const dockerfile = await readFile(dockerfilePath, "utf8"); - const nodeImages = dockerfile.match(/^FROM node:[^\s]+/gm) ?? []; - const pins = dockerfile.match(/^ARG PACKAGE_MANAGER_PIN=.*$/gm) ?? []; - materialConsistent &&= - nodeImages.length > 0 && - nodeImages.every((image) => - image.startsWith(`FROM node:${nodeLtsMajor}-`), - ) && - pins.length === 1 && - pins[0] === `ARG PACKAGE_MANAGER_PIN="${manifest.packageManager}"`; - } catch (error: unknown) { - if (!isMissing(error)) throw error; - } - - return { - nodeLtsMajor, - packageManagerPin: manifest.packageManager as `pnpm@${string}`, - materialConsistent, - }; -} - -async function jsonBaselineMatches( - rootDirectory: string, - relativePath: string, - matches: (value: unknown) => boolean, -): Promise { - try { - return matches( - JSON.parse( - await readFile(path.join(rootDirectory, relativePath), "utf8"), - ) as unknown, - ); - } catch (error: unknown) { - if (isMissing(error)) return true; - throw error; - } -} - -async function command( - commandName: string, - arguments_: readonly string[], - rootDirectory = repositoryRoot, -): Promise { - return new Promise((resolve, reject) => { - const child = spawn(commandName, [...arguments_], { - cwd: rootDirectory, - stdio: ["ignore", "pipe", "inherit"], - }); - let stdout = ""; - child.stdout.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.once("error", reject); - child.once("close", (exitCode) => { - if (exitCode === 0) resolve(stdout.trim()); - else reject(new Error(`${commandName} exited with ${String(exitCode)}`)); - }); - }); -} - -async function findCandidate( - rootDirectory: string, - defaultBranch: string, - owner: string, - github: GithubEffects, -): Promise { - const pulls = await github.findPullRequests({ - owner, - branch: automationBranch, - base: defaultBranch, - }); - if (pulls.length > 1) - throw new Error("More than one toolchain candidate is open"); - const remote = await command( - "git", - ["ls-remote", "--heads", "origin", `refs/heads/${automationBranch}`], - rootDirectory, - ); - const sha = remote.split(/\s+/)[0] ?? ""; - return { - pullRequest: - pulls[0] !== undefined - ? { kind: "open", pullRequestNumber: pulls[0] } - : { kind: "absent" }, - remoteBranch: sha ? { kind: "present", sha } : { kind: "absent" }, - }; -} - -async function packageManifestPaths(root: string): Promise { - const results: string[] = []; - async function visit(directory: string): Promise { - for (const entry of await readdir(directory, { withFileTypes: true })) { - if ( - entry.isDirectory() && - [".git", "node_modules", "dist"].includes(entry.name) - ) - continue; - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory()) await visit(entryPath); - else if (entry.name === "package.json") results.push(entryPath); - } - } - await visit(root); - return results; -} - -export function checkEnvironmentNeedsForPackageManifests( - manifests: readonly unknown[], -): readonly "shellcheck"[] { - const needsShellCheck = manifests.some((value) => { - if (!isRecord(value) || !isRecord(value.scripts)) return false; - return Object.values(value.scripts).some( - (script) => - typeof script === "string" && /(^|\s|&&)shellcheck\s/u.test(script), - ); - }); - - return needsShellCheck ? ["shellcheck"] : []; -} - -async function prepareCheckEnvironment( - rootDirectory: string, - baseline: Baseline, -): Promise { - const manifestPaths = await packageManifestPaths(rootDirectory); - const manifests = await Promise.all( - manifestPaths.map(async (manifestPath) => - JSON.parse(await readFile(manifestPath, "utf8")), - ), - ); - if ( - checkEnvironmentNeedsForPackageManifests(manifests).includes("shellcheck") - ) { - await command("sudo", ["apt-get", "update"], rootDirectory); - await command( - "sudo", - ["apt-get", "install", "-y", "shellcheck"], - rootDirectory, - ); - } - - for (const [index, manifestPath] of manifestPaths.entries()) { - const manifest = manifests[index] as { - dependencies?: Record; - devDependencies?: Record; - }; - if ( - manifest.dependencies?.["@playwright/test"] === undefined && - manifest.devDependencies?.["@playwright/test"] === undefined - ) { - continue; - } - await command( - "corepack", - [ - baseline.packageManagerPin, - "--dir", - path.dirname(manifestPath), - "exec", - "playwright", - "install", - "--with-deps", - "chromium", - ], - rootDirectory, - ); - } -} - -export async function updateToolchainBaselineMaterials( - rootDirectory: string, - baseline: Baseline, -): Promise { - for (const manifestPath of await packageManifestPaths(rootDirectory)) { - const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record< - string, - unknown - > & { - engines?: Record; - packageManager?: string; - }; - if (manifest.engines !== undefined && !isRecord(manifest.engines)) { - throw new Error( - `${path.relative(rootDirectory, manifestPath)}: engines must be an object`, - ); - } - if ( - manifest.engines?.node !== undefined && - typeof manifest.engines.node !== "string" - ) { - throw new Error( - `${path.relative(rootDirectory, manifestPath)}: engines.node must be a string`, - ); - } - if ( - manifest.packageManager !== undefined && - typeof manifest.packageManager !== "string" - ) { - throw new Error( - `${path.relative(rootDirectory, manifestPath)}: packageManager must be a string`, - ); - } - if (manifest.engines?.node !== undefined) - manifest.engines.node = baseline.nodeLtsMajor; - if ( - manifestPath === path.join(rootDirectory, "package.json") || - manifest.packageManager !== undefined - ) { - manifest.packageManager = baseline.packageManagerPin; - } - await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - } - await updateJsonIfPresent( - rootDirectory, - ".devcontainer/devcontainer.json", - (value) => { - const root = requireRecord(value, ".devcontainer/devcontainer.json"); - const build = requireRecord( - root.build, - ".devcontainer/devcontainer.json: build", - ); - const args = requireRecord( - build.args, - ".devcontainer/devcontainer.json: build.args", - ); - if ( - typeof args.NODE_VERSION !== "string" || - typeof args.PACKAGE_MANAGER_PIN !== "string" - ) - throw new Error( - ".devcontainer/devcontainer.json: baseline build args are malformed", - ); - args.NODE_VERSION = baseline.nodeLtsMajor; - args.PACKAGE_MANAGER_PIN = baseline.packageManagerPin; - }, - ); - await updateJsonIfPresent( - rootDirectory, - ".template/generated-by.json", - (value) => { - const root = requireRecord(value, ".template/generated-by.json"); - const toolchain = requireRecord( - root.toolchain, - ".template/generated-by.json: toolchain", - ); - if ( - typeof toolchain.nodeLtsMajor !== "string" || - typeof toolchain.packageManagerPin !== "string" - ) - throw new Error( - ".template/generated-by.json: toolchain baseline is malformed", - ); - toolchain.nodeLtsMajor = baseline.nodeLtsMajor; - toolchain.packageManagerPin = baseline.packageManagerPin; - }, - ); - const dockerfilePath = path.join(rootDirectory, "apps/web/Dockerfile"); - try { - const dockerfile = await readFile(dockerfilePath, "utf8"); - const nodeImages = dockerfile.match(/FROM node:\d+-/g) ?? []; - const allNodeImages = dockerfile.match(/^FROM node:[^\s]+/gm) ?? []; - const packageManagerPins = - dockerfile.match(/ARG PACKAGE_MANAGER_PIN="pnpm@[^"]+"/g) ?? []; - if ( - nodeImages.length === 0 || - nodeImages.length !== allNodeImages.length || - packageManagerPins.length !== 1 - ) - throw new Error( - "apps/web/Dockerfile: expected Node image and exactly one Package Manager Pin", - ); - await writeFile( - dockerfilePath, - dockerfile - .replaceAll(/FROM node:\d+-/g, `FROM node:${baseline.nodeLtsMajor}-`) - .replace( - /ARG PACKAGE_MANAGER_PIN="pnpm@[^"]+"/, - `ARG PACKAGE_MANAGER_PIN="${baseline.packageManagerPin}"`, - ), - ); - } catch (error: unknown) { - if (!isMissing(error)) throw error; - } -} - -function requireRecord(value: unknown, label: string): Record { - if (!isRecord(value)) throw new Error(`${label} must be an object`); - return value; -} - -async function updateJsonIfPresent( - rootDirectory: string, - relativePath: string, - update: (value: unknown) => void, -): Promise { - const filePath = path.join(rootDirectory, relativePath); - try { - const value: unknown = JSON.parse(await readFile(filePath, "utf8")); - update(value); - await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); - } catch (error: unknown) { - if (!isMissing(error)) throw error; - } -} - -function isMissing(error: unknown): boolean { - return error instanceof Error && "code" in error && error.code === "ENOENT"; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -async function applyPlan( - rootDirectory: string, - plan: Plan, - candidate: Candidate, - defaultBranch: string, - owner: string, - github: GithubEffects, - hooks: MaintenanceHooks, -): Promise { - if (plan.kind === "no-drift") return; - if (plan.kind === "cleanup-stale") { - const latest = await findCandidate( - rootDirectory, - defaultBranch, - owner, - github, - ); - if (JSON.stringify(latest) !== JSON.stringify(candidate)) - throw new Error( - "Toolchain candidate changed before cleanup; refusing stale cleanup", - ); - if (candidate.remoteBranch.kind === "present") { - await command( - "git", - [ - "push", - `--force-with-lease=refs/heads/${automationBranch}:${candidate.remoteBranch.sha}`, - "origin", - `:refs/heads/${automationBranch}`, - ], - rootDirectory, - ); - } - if (plan.pullRequestNumber !== undefined) { - await github.closePullRequest( - plan.pullRequestNumber, - "Closing because the default branch no longer has Toolchain Baseline drift.", - ); - } - return; - } - await updateToolchainBaselineMaterials(rootDirectory, plan.desired); - const consistent = await readCurrentBaseline(rootDirectory); - if ( - !consistent.materialConsistent || - consistent.nodeLtsMajor !== plan.desired.nodeLtsMajor || - consistent.packageManagerPin !== plan.desired.packageManagerPin - ) - throw new Error( - "Toolchain Baseline materials remained inconsistent after update", - ); - await (hooks.installPackageManager?.(plan.desired) ?? - command( - "corepack", - ["install", "--global", plan.desired.packageManagerPin], - rootDirectory, - )); - await (hooks.updateLockfile?.(plan.desired) ?? - command( - "corepack", - [plan.desired.packageManagerPin, "install", "--lockfile-only"], - rootDirectory, - )); - await (hooks.prepareChecks?.(plan.desired) ?? - prepareCheckEnvironment(rootDirectory, plan.desired)); - await (hooks.runChecks?.(plan.desired) ?? - command( - "corepack", - [plan.desired.packageManagerPin, "run", "check"], - rootDirectory, - )); - await command( - "git", - ["config", "user.name", "github-actions[bot]"], - rootDirectory, - ); - await command( - "git", - [ - "config", - "user.email", - "41898282+github-actions[bot]@users.noreply.github.com", - ], - rootDirectory, - ); - await command("git", ["add", "-A"], rootDirectory); - await command( - "git", - ["commit", "-m", "chore: update toolchain baseline"], - rootDirectory, - ); - await hooks.beforePush?.(); - await command("git", ["fetch", "origin", defaultBranch], rootDirectory); - const latestDefaultSha = await command( - "git", - ["rev-parse", `origin/${defaultBranch}`], - rootDirectory, - ); - const baseSha = await command("git", ["rev-parse", "HEAD^"], rootDirectory); - if (latestDefaultSha !== baseSha) - throw new Error( - "Default branch advanced while preparing Toolchain Baseline candidate", - ); - const latestCandidate = await findCandidate( - rootDirectory, - defaultBranch, - owner, - github, - ); - if (JSON.stringify(latestCandidate) !== JSON.stringify(candidate)) - throw new Error( - "Toolchain candidate changed while checks ran; refusing stale push", - ); - const lease = - candidate.remoteBranch.kind === "present" ? candidate.remoteBranch.sha : ""; - await command( - "git", - [ - "push", - `--force-with-lease=refs/heads/${automationBranch}:${lease}`, - "origin", - `HEAD:refs/heads/${automationBranch}`, - ], - rootDirectory, - ); - const body = - "Updates the Node LTS and mature compatible pnpm baselines together. This automation never auto-merges."; - if (plan.mode === "create") { - await github.createPullRequest({ - base: defaultBranch, - head: automationBranch, - title: "chore: update toolchain baseline", - body, - }); - } else { - await github.updatePullRequest(plan.pullRequestNumber, { - title: "chore: update toolchain baseline", - body, - }); - } -} - -export async function runToolchainBaselineMaintenance(options: { - readonly rootDirectory: string; - readonly desired: Baseline; - readonly defaultBranch: string; - readonly owner: string; - readonly github: GithubEffects; - readonly hooks?: MaintenanceHooks; -}): Promise { - const rootDirectory = path.resolve(options.rootDirectory); - for (let attempt = 0; attempt < 2; attempt += 1) { - await command( - "git", - ["fetch", "origin", options.defaultBranch], - rootDirectory, - ); - await command( - "git", - ["checkout", "-B", automationBranch, `origin/${options.defaultBranch}`], - rootDirectory, - ); - const [current, candidate] = await Promise.all([ - readCurrentBaseline(rootDirectory), - findCandidate( - rootDirectory, - options.defaultBranch, - options.owner, - options.github, - ), - ]); - try { - await applyPlan( - rootDirectory, - planToolchainBaselineUpdate({ - current, - desired: options.desired, - candidate, - }), - candidate, - options.defaultBranch, - options.owner, - options.github, - options.hooks ?? {}, - ); - return; - } catch (error: unknown) { - if ( - !(error instanceof Error) || - error.message !== - "Default branch advanced while preparing Toolchain Baseline candidate" || - attempt === 1 - ) - throw error; - } - } -} - -const cliGithubEffects: GithubEffects = { - async findPullRequests({ owner, branch, base }) { - const pulls = JSON.parse( - await command("gh", [ - "pr", - "list", - "--state", - "open", - "--head", - `${owner}:${branch}`, - "--base", - base, - "--json", - "number", - "--limit", - "2", - ]), - ) as Array<{ number: number }>; - return pulls.map(({ number }) => number); - }, - async closePullRequest(number, comment) { - await command("gh", ["pr", "close", String(number), "--comment", comment]); - }, - async createPullRequest({ base, head, title, body }) { - await command("gh", [ - "pr", - "create", - "--base", - base, - "--head", - head, - "--title", - title, - "--body", - body, - ]); - }, - async updatePullRequest(number, { title, body }) { - await command("gh", [ - "pr", - "edit", - String(number), - "--title", - title, - "--body", - body, - ]); - }, -}; - -async function main(): Promise { - const fixtureIndex = process.argv.indexOf("--plan-fixture"); - if (fixtureIndex !== -1) { - const fixturePath = process.argv[fixtureIndex + 1]; - if (!fixturePath) throw new Error("--plan-fixture requires a JSON path"); - const fixture = JSON.parse( - await readFile(path.resolve(fixturePath), "utf8"), - ) as { - current: Baseline; - desired: Baseline; - candidate: Candidate; - }; - console.log(JSON.stringify(planToolchainBaselineUpdate(fixture))); - return; - } - const desired = await resolveOfficialBaseline(); - if (process.argv.includes("--print-workflow-outputs")) { - console.log(`node-major=${desired.nodeLtsMajor}`); - console.log(`pnpm-pin=${desired.packageManagerPin}`); - return; - } - const defaultBranch = process.env.DEFAULT_BRANCH ?? "main"; - const owner = process.env.GITHUB_REPOSITORY_OWNER ?? ""; - if (!owner) throw new Error("GITHUB_REPOSITORY_OWNER is required"); - await runToolchainBaselineMaintenance({ - rootDirectory: repositoryRoot, - desired, - defaultBranch, - owner, - github: cliGithubEffects, - }); -} - -if (import.meta.url === `file://${process.argv[1]}`) await main(); diff --git a/packages/builtin-source/templates/ts-lib/behavior.test.ts b/packages/builtin-source/templates/ts-lib/behavior.test.ts deleted file mode 100644 index 989ad52..0000000 --- a/packages/builtin-source/templates/ts-lib/behavior.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { mkdtemp, readFile, readdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import * as v from "valibot"; -import { describe, expect, it } from "vitest"; -import { parse as parseYaml } from "yaml"; - -import { tsLibPresetProjection } from "./projection.ts"; - -const packageJsonSchema = v.looseObject({ - name: v.string(), - private: v.boolean(), - type: v.optional(v.string()), - imports: v.optional(v.unknown()), - exports: v.optional(v.unknown()), - dependencies: v.optional(v.record(v.string(), v.string())), - devDependencies: v.record(v.string(), v.string()), - engines: v.object({ node: v.string() }), - packageManager: v.optional(v.string()), - scripts: v.record(v.string(), v.string()), -}); -const workspaceCatalogSchema = v.object({ - packages: v.array(v.string()), - catalog: v.record(v.string(), v.string()), -}); -const devcontainerSchema = v.looseObject({ - build: v.object({ - dockerfile: v.string(), - args: v.record(v.string(), v.string()), - }), - customizations: v.object({ - vscode: v.object({ - extensions: v.array(v.string()), - settings: v.record(v.string(), v.unknown()), - }), - }), - features: v.optional(v.unknown()), -}); - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -async function generatedFilePaths( - root: string, - current = ".", -): Promise { - const entries = await readdir(path.join(root, current), { - withFileTypes: true, - }); - const paths = await Promise.all( - entries.map(async (entry) => { - const relativePath = path.join(current, entry.name); - if (entry.isDirectory()) { - return generatedFilePaths(root, relativePath); - } - - return [relativePath.replaceAll(path.sep, "/")]; - }), - ); - - return paths.flat().toSorted(); -} - -async function renderTsLibProject(): Promise { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-ts-lib-behavior-"), - ); - const targetDir = path.join(workspace, "demo-lib"); - const blueprint = tsLibPresetProjection.blueprint({ targetDir }); - const context = assembleGenerationContext({ - blueprint, - targetDir, - toolchain: { - diagnostics: [], - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - }, - }); - - const plan = tsLibPresetProjection.project(context); - await tsLibPresetProjection.render({ plan, targetDir }); - - return targetDir; -} - -function catalogReferences(packageJson: { - dependencies?: Record | undefined; - devDependencies?: Record | undefined; -}): string[] { - return [ - ...Object.keys(packageJson.dependencies ?? {}), - ...Object.keys(packageJson.devDependencies ?? {}), - ].toSorted(); -} - -describe("ts-lib Preset Source behavior", () => { - it("projects a strict TypeScript library workspace without Preset Source Tests", async () => { - const targetDir = await renderTsLibProject(); - const rootPackageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const libraryPackageJson = await readJsonWithSchema( - path.join(targetDir, "packages/demo-lib/package.json"), - packageJsonSchema, - ); - const workspaceYaml = await readFile( - path.join(targetDir, "pnpm-workspace.yaml"), - "utf8", - ); - const workspace = v.parse( - workspaceCatalogSchema, - parseYaml(workspaceYaml) as unknown, - ); - const tsconfig = await readJsonWithSchema( - path.join(targetDir, "packages/demo-lib/tsconfig.json"), - v.object({ - compilerOptions: v.record(v.string(), v.unknown()), - include: v.array(v.string()), - }), - ); - const devcontainer = await readJsonWithSchema( - path.join(targetDir, ".devcontainer/devcontainer.json"), - devcontainerSchema, - ); - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - const files = await generatedFilePaths(targetDir); - - expect(rootPackageJson).toMatchObject({ - name: "demo-lib", - private: true, - engines: { node: "24" }, - packageManager: "pnpm@11.2.3", - }); - expect(rootPackageJson.devDependencies).toEqual({ - "@types/node": "catalog:", - "@types/semver": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - semver: "catalog:", - turbo: "catalog:", - "typescript-7": "catalog:", - }); - expect(rootPackageJson.scripts.check).toContain("turbo run"); - expect(rootPackageJson.scripts["typecheck:run"]).toBe( - "tsc -p tsconfig.config.json --noEmit --pretty false", - ); - - expect(libraryPackageJson).toMatchObject({ - name: "@demo-lib/demo-lib", - private: true, - type: "module", - engines: { node: "24" }, - }); - expect(libraryPackageJson.packageManager).toBeUndefined(); - expect(libraryPackageJson.dependencies).toEqual({ - valibot: "catalog:", - }); - expect(libraryPackageJson.devDependencies).toEqual({ - "@types/node": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - "typescript-7": "catalog:", - }); - expect(libraryPackageJson.devDependencies).not.toHaveProperty("tsc-alias"); - expect(libraryPackageJson.exports).toEqual({ - ".": { - default: "./src/index.ts", - types: "./src/index.ts", - }, - }); - expect(libraryPackageJson.imports).toEqual({ - "#/*": { - default: "./src/*.ts", - types: "./src/*.ts", - }, - }); - expect(libraryPackageJson.scripts).not.toHaveProperty("build"); - expect(libraryPackageJson.scripts["typecheck:run"]).toBe( - "tsc -p tsconfig.json --noEmit --pretty false", - ); - expect(tsconfig.compilerOptions).not.toHaveProperty("paths"); - expect(tsconfig.include).toEqual(["src/**/*.ts"]); - - expect(workspace.packages).toEqual(["packages/*"]); - expect(workspace.catalog).toMatchObject({ - "typescript-7": "npm:typescript@^7.0.2", - }); - expect(workspace.catalog).not.toHaveProperty("typescript"); - expect(Object.keys(workspace.catalog).toSorted()).toEqual( - [ - ...new Set([ - ...catalogReferences(rootPackageJson), - ...catalogReferences(libraryPackageJson), - ]), - ].toSorted(), - ); - expect(Object.values(rootPackageJson.devDependencies)).toEqual( - expect.arrayContaining(["catalog:"]), - ); - expect(Object.values(libraryPackageJson.devDependencies)).toEqual( - expect.arrayContaining(["catalog:"]), - ); - - expect(devcontainer.build).toEqual({ - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@11.2.3", - }, - }); - expect(devcontainer).not.toHaveProperty("features"); - expect(devcontainer.customizations.vscode.extensions).toContain( - "oxc.oxc-vscode", - ); - expect(devcontainer.customizations.vscode.extensions).not.toContain( - "dbaeumer.vscode-eslint", - ); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(dockerfile).not.toContain("PLAYWRIGHT_CLI_PACKAGE"); - expect(dockerfile).not.toContain("shellcheck"); - expect(dockerfile).not.toContain("typescript-node"); - expect(dockerfile).not.toMatch( - /npm install -g|pnpm add -g|corepack prepare (?!"?\$\{PACKAGE_MANAGER_PIN\}"?)/, - ); - expect(files).not.toContain("behavior.test.ts"); - expect(files).not.toContain("packages/demo-lib/behavior.test.ts"); - }); -}); diff --git a/packages/builtin-source/templates/ts-lib/projection.ts b/packages/builtin-source/templates/ts-lib/projection.ts deleted file mode 100644 index 1d41ebd..0000000 --- a/packages/builtin-source/templates/ts-lib/projection.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetProjectionDeclaration, -} from "@ykdz/template-builtin-source"; -import type { GenerationContext } from "@ykdz/template-core/generation-context"; -import { - packageManifestExposureFields, - planPackageLinks, -} from "@ykdz/template-core/package-linking"; -import type { - PresetPackageAdditionOptions, - PresetPackageAdditionPlan, - PresetBlueprintOptions, - PresetProjection, - PresetProjectionPlan, -} from "@ykdz/template-core/preset-projection"; -import { interpretPresetProjectionDeclaration } from "@ykdz/template-core/projection-capabilities"; -import { - renderNewProject, - type RenderOperation, -} from "@ykdz/template-core/renderer"; -import { - PackageAdditionSupport, - type BuiltInPreset, - type ProjectBlueprint, -} from "@ykdz/template-shared"; - -export const tsLibPresetMetadata: BuiltInPreset = { - name: "ts-lib", - title: "TypeScript library", - description: "Strict TypeScript package with pnpm catalog tooling.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Supported, - features: [ - "pnpm-catalog", - "oxc-format-lint", - "strict-typescript", - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot", - ], -}; - -function projectNameFromDir(targetDir: string): string { - return path.basename(path.resolve(targetDir)); -} - -function packageScopeFromOptions(options: PresetBlueprintOptions): string { - return options.scope ?? projectNameFromDir(options.targetDir); -} - -function scopedPackageName( - packageScope: string, - packageLeafName: string, -): string { - return `@${packageScope}/${packageLeafName}`; -} - -export function tsLibBlueprint( - options: PresetBlueprintOptions = { targetDir: process.cwd() }, -): ProjectBlueprint { - const packageScope = packageScopeFromOptions(options); - const packageLeafName = projectNameFromDir(options.targetDir); - - return { - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "multi-package", - features: [...tsLibPresetMetadata.features], - packages: [ - { - name: scopedPackageName(packageScope, packageLeafName), - path: `packages/${packageLeafName}`, - }, - ], - }; -} - -export function projectTsLibPackageScripts(): Record { - return { - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:run": - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "lint:fix:run": - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", - }; -} - -function packageAdditionOperations( - packagePath: string, - packageNameValue: string, - nodeVersion: string, -): RenderOperation[] { - const packageExposure = planPackageLinks([ - { - name: packageNameValue, - path: packagePath, - role: "shared-library", - sourcePreset: "ts-lib", - }, - ]).exposuresByPackagePath.get(packagePath); - - if (packageExposure === undefined) { - throw new Error(`Missing Package Exposure for ${packagePath}`); - } - - const exposureFields = packageManifestExposureFields(packageExposure); - - return [ - { - kind: "writeJson", - to: `${packagePath}/package.json`, - value: { - name: packageNameValue, - version: "0.0.0", - private: true, - type: "module", - imports: exposureFields.imports, - exports: exposureFields.exports, - dependencies: { - valibot: "catalog:", - }, - scripts: projectTsLibPackageScripts(), - devDependencies: { - "@types/node": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - "typescript-7": "catalog:", - }, - engines: { - node: nodeVersion, - }, - }, - multilineArrays: ["files"], - }, - { - kind: "writeJson", - to: `${packagePath}/tsconfig.json`, - value: { - compilerOptions: { - composite: true, - declaration: true, - declarationMap: true, - erasableSyntaxOnly: true, - exactOptionalPropertyTypes: true, - forceConsistentCasingInFileNames: true, - isolatedModules: true, - module: "nodenext", - moduleResolution: "nodenext", - noEmitOnError: true, - noFallthroughCasesInSwitch: true, - noImplicitOverride: true, - noImplicitReturns: true, - noUncheckedIndexedAccess: true, - rewriteRelativeImportExtensions: true, - rootDir: "src", - skipLibCheck: false, - strict: true, - target: "es2023", - types: ["node"], - verbatimModuleSyntax: true, - }, - include: ["src/**/*.ts"], - }, - }, - { - kind: "copyFile", - from: "src/index.ts", - to: `${packagePath}/src/index.ts`, - }, - { - kind: "copyFile", - from: "src/name-schema.ts", - to: `${packagePath}/src/name-schema.ts`, - }, - ]; -} - -function packageAdditionPlan( - packageNameValue: string, - packagePath: string, - nodeVersion: string, -): PresetPackageAdditionPlan { - const [workspaceCollection] = packagePath.split("/"); - - return { - packagePath, - workspacePackageGlob: `${workspaceCollection}/*`, - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceRoot: templateSourceRoot(), - sourceRoots: { sharedOxc: sharedOxcSourceRoot() }, - operations: packageAdditionOperations( - packagePath, - packageNameValue, - nodeVersion, - ), - }; -} - -function templateSourceRoot(): string { - const projectionDir = path.dirname(fileURLToPath(import.meta.url)); - const publishedTemplateRoot = path.join( - projectionDir, - "..", - "..", - "..", - "templates", - "ts-lib", - ); - - return existsSync(path.join(publishedTemplateRoot, "src", "index.ts")) - ? publishedTemplateRoot - : projectionDir; -} - -function sharedOxcSourceRoot(): string { - const projectionDir = path.dirname(fileURLToPath(import.meta.url)); - const publishedSharedRoot = path.join( - projectionDir, - "..", - "..", - "..", - "templates", - "shared", - "oxc", - ); - - return existsSync(path.join(publishedSharedRoot, "oxfmt.config.ts")) - ? publishedSharedRoot - : path.join(projectionDir, "..", "shared", "oxc"); -} - -export const tsLibPresetProjection: PresetProjection = { - metadata: tsLibPresetMetadata, - capabilities: { - packageAddition: { - planPackageAddition({ - packageName, - packagePath, - nodeVersion, - }: PresetPackageAdditionOptions) { - return packageAdditionPlan(packageName, packagePath, nodeVersion); - }, - }, - }, - blueprint: tsLibBlueprint, - project(context: GenerationContext): PresetProjectionPlan { - return interpretPresetProjectionDeclaration({ - preset: tsLibPresetMetadata, - declaration: loadBuiltInPresetProjectionDeclaration("ts-lib"), - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - }, - async render({ targetDir, plan }): Promise { - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - }, -}; diff --git a/packages/builtin-source/templates/vike-app/.github/workflows/check.yml b/packages/builtin-source/templates/vike-app/.github/workflows/check.yml deleted file mode 100644 index d3558ce..0000000 --- a/packages/builtin-source/templates/vike-app/.github/workflows/check.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Check - -on: - pull_request: - push: - branches: - - main - -jobs: - check: - runs-on: ubuntu-latest - strategy: - matrix: - check: [root, deployment] - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - run: corepack enable - - uses: docker/setup-buildx-action@v3 - if: matrix.check == 'deployment' - - run: pnpm install - - run: sudo apt-get update && sudo apt-get install -y shellcheck - if: matrix.check == 'root' - - run: pnpm --filter ./apps/web exec playwright install --with-deps chromium - - run: pnpm run check - if: matrix.check == 'root' - - run: pnpm run check:deployment - if: matrix.check == 'deployment' diff --git a/packages/builtin-source/templates/vike-app/behavior.test.ts b/packages/builtin-source/templates/vike-app/behavior.test.ts deleted file mode 100644 index 39f4b5d..0000000 --- a/packages/builtin-source/templates/vike-app/behavior.test.ts +++ /dev/null @@ -1,1701 +0,0 @@ -import { execFile, spawn } from "node:child_process"; -import { - access, - chmod, - lstat, - mkdtemp, - mkdir, - readFile, - readlink, - readdir, - symlink, - writeFile, -} from "node:fs/promises"; -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { promisify } from "node:util"; - -import { loadTemplateDependencyCatalog } from "@ykdz/template-core/dependency-catalog"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import { addPackage } from "@ykdz/template-core/package-addition"; -import * as v from "valibot"; -import { describe, expect, it } from "vitest"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetSourceManifest, -} from "../../src/index.ts"; -import { vikeAppPresetProjection } from "./projection.ts"; - -const playwrightCliPackage = `@playwright/test@${ - loadTemplateDependencyCatalog()["@playwright/test"] -}`; -const execFileAsync = promisify(execFile); -const repositoryDependencies = path.join( - process.cwd(), - "packages/builtin-source/node_modules", -); -const repositoryBin = path.join(repositoryDependencies, ".bin"); -const packageManagerPinSchema = v.custom<`pnpm@${string}`>( - (value) => typeof value === "string" && value.startsWith("pnpm@"), -); -const packageJsonSchema = v.looseObject({ - name: v.string(), - engines: v.object({ node: v.string() }), - packageManager: v.optional(v.string()), - dependencies: v.optional(v.record(v.string(), v.string())), - devDependencies: v.optional(v.record(v.string(), v.string())), - files: v.optional(v.array(v.string())), - imports: v.optional(v.record(v.string(), v.record(v.string(), v.string()))), - scripts: v.record(v.string(), v.string()), -}); -const devcontainerSchema = v.looseObject({ - build: v.object({ - dockerfile: v.string(), - args: v.record(v.string(), v.string()), - }), - customizations: v.object({ - vscode: v.object({ - extensions: v.array(v.string()), - settings: v.record(v.string(), v.unknown()), - }), - }), - features: v.optional(v.unknown()), -}); - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -async function generatedFilePaths( - root: string, - current = ".", -): Promise { - const entries = await readdir(path.join(root, current), { - withFileTypes: true, - }); - const paths = await Promise.all( - entries.map(async (entry) => { - const relativePath = path.join(current, entry.name); - if (entry.isDirectory()) { - return generatedFilePaths(root, relativePath); - } - - return [relativePath.replaceAll(path.sep, "/")]; - }), - ); - - return paths.flat().toSorted(); -} - -async function renderVikeProject( - packageManagerPin: `pnpm@${string}` = "pnpm@11.2.3", -): Promise { - const workspace = await mkdtemp(path.join(tmpdir(), "vike-behavior-")); - const targetDir = path.join(workspace, "demo-vike"); - const blueprint = vikeAppPresetProjection.blueprint({ targetDir }); - const context = assembleGenerationContext({ - blueprint, - targetDir, - toolchain: { - diagnostics: [], - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: packageManagerPin, - }, - source: "online", - }, - }); - - const plan = vikeAppPresetProjection.project(context); - await vikeAppPresetProjection.render({ plan, targetDir }); - - return targetDir; -} - -async function linkRepositoryDependencies(targetDir: string): Promise { - await symlink( - repositoryDependencies, - path.join(targetDir, "node_modules"), - "dir", - ); -} - -async function databaseReadiness(databaseFile: string): Promise { - const { stdout } = await execFileAsync( - process.execPath, - [ - "--input-type=module", - "--eval", - 'import { DatabaseSync } from "node:sqlite"; const db = new DatabaseSync(process.env.DATABASE_FILE); const table = db.prepare("select name from sqlite_master where type = \'table\' and name = \'todos\'").get()?.name; const rows = db.prepare("select count(*) as count from todos").get()?.count; console.log(`${table}:${rows}`);', - ], - { env: { ...process.env, DATABASE_FILE: databaseFile } }, - ); - - return stdout.trim(); -} - -async function availablePort(): Promise { - const server = createServer(); - - return await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const port = (server.address() as AddressInfo).port; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function waitForHttpResponse( - url: string, - server: ReturnType, -): Promise { - const timeoutAt = Date.now() + 15_000; - - while (Date.now() < timeoutAt) { - if (server.exitCode !== null) { - throw new Error( - `Server exited before becoming ready (${server.exitCode})`, - ); - } - - try { - const response = await fetch(url); - if (response.ok) return response; - } catch { - // The port is not accepting requests yet. - } - - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - throw new Error(`Timed out waiting for ${url}`); -} - -async function stopChildProcess( - child: ReturnType, -): Promise { - if (child.exitCode !== null) return; - - await new Promise((resolve) => { - const forceKill = setTimeout(() => child.kill("SIGKILL"), 5_000); - child.once("exit", () => { - clearTimeout(forceKill); - resolve(); - }); - child.kill("SIGTERM"); - }); -} - -async function repositoryTscIdentity(): Promise<{ - linkTarget: string | undefined; - modifiedAt: number; - version: string; -}> { - const tscPath = path.join(repositoryBin, "tsc"); - const metadata = await lstat(tscPath); - const [{ stdout }, linkTarget] = await Promise.all([ - execFileAsync(tscPath, ["--version"]), - metadata.isSymbolicLink() ? readlink(tscPath) : undefined, - ]); - - return { - linkTarget, - modifiedAt: metadata.mtimeMs, - version: stdout.trim(), - }; -} - -async function fakeContainerCommandEnvironment(targetDir: string): Promise<{ - databaseFile: string; - env: NodeJS.ProcessEnv; - observationFile: string; -}> { - const fakeBinDir = path.join(targetDir, "fake-container-bin"); - const observationFile = path.join(targetDir, "container-observation.txt"); - const databaseFile = path.join(targetDir, "mounted-data", "app.sqlite"); - await mkdir(fakeBinDir); - await writeFile( - path.join(fakeBinDir, "drizzle-kit"), - `#!/bin/sh -echo prepare >> "$CONTAINER_OBSERVATION_FILE" -exit "\${CONTAINER_PREPARE_EXIT_CODE:-0}" -`, - ); - await writeFile( - path.join(fakeBinDir, "node"), - `#!/bin/sh -echo start >> "$CONTAINER_OBSERVATION_FILE" -`, - ); - await chmod(path.join(fakeBinDir, "drizzle-kit"), 0o755); - await chmod(path.join(fakeBinDir, "node"), 0o755); - - return { - databaseFile, - env: { - ...process.env, - CONTAINER_OBSERVATION_FILE: observationFile, - DATABASE_FILE: databaseFile, - PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH}`, - }, - observationFile, - }; -} - -async function fakeDockerEnvironment( - targetDir: string, - port: number, - playwrightExitCode = 0, - dockerBuildOutputBytes = 0, - dockerFailurePattern = "", - unpreparedPort = 1, -): Promise<{ - dockerObservationFile: string; - env: NodeJS.ProcessEnv; - playwrightObservationFile: string; -}> { - const fakeBinDir = path.join(targetDir, "fake-docker-bin"); - const dockerObservationFile = path.join(targetDir, "docker-commands.txt"); - const playwrightObservationFile = path.join( - targetDir, - "deployment-playwright.txt", - ); - await mkdir(fakeBinDir); - await writeFile( - path.join(fakeBinDir, "docker"), - `#!/bin/sh -echo "$*" >> "$DOCKER_OBSERVATION_FILE" -if [ -n "$FAKE_DOCKER_FAILURE_PATTERN" ] && echo "$*" | grep -F -- "$FAKE_DOCKER_FAILURE_PATTERN" >/dev/null; then - echo "fake docker stdout failure for $*" - echo "fake docker stderr failure for $*" >&2 - exit 23 -fi -case "$1" in - build) - if [ ! -f apps/web/Dockerfile ]; then - echo "Docker build must run from the repository root" >&2 - exit 24 - fi - if [ "$FAKE_DOCKER_BUILD_OUTPUT_BYTES" -gt 0 ]; then - head -c "$FAKE_DOCKER_BUILD_OUTPUT_BYTES" /dev/zero | tr '\\0' x - fi - ;; - port) - case "$*" in - *unprepared*) echo "127.0.0.1:$FAKE_UNPREPARED_DOCKER_PORT" ;; - *) echo "127.0.0.1:$FAKE_DOCKER_PORT" ;; - esac - ;; - inspect) - case "$*" in - *State.Status*unprepared*) echo "exited:17" ;; - *State.Status*) echo "exited:0" ;; - *) echo true ;; - esac - ;; - logs) - case "$*" in - *unprepared*) echo "Database is not ready." ;; - *) echo "fake container stdout diagnostics" ;; - esac - echo "fake container stderr diagnostics" >&2 - ;; - volume) [ "$2" = create ] && echo fake-volume || true ;; - run) - echo fake-container - ;; -esac -`, - ); - await writeFile( - path.join(fakeBinDir, "pnpm"), - `#!/bin/sh -if [ "$1" = "--dir" ] && [ ! -f "$2/package.json" ]; then - echo "Playwright must run from the repository root" >&2 - exit 25 -fi -echo "$PLAYWRIGHT_EXTERNAL_BASE_URL" >> "$PLAYWRIGHT_OBSERVATION_FILE" -if [ "$PLAYWRIGHT_EXIT_CODE" -ne 0 ]; then - echo "fake playwright stdout failure" - echo "fake playwright stderr failure" >&2 -fi -exit "$PLAYWRIGHT_EXIT_CODE" -`, - ); - await chmod(path.join(fakeBinDir, "docker"), 0o755); - await chmod(path.join(fakeBinDir, "pnpm"), 0o755); - - return { - dockerObservationFile, - env: { - ...process.env, - DOCKER_OBSERVATION_FILE: dockerObservationFile, - FAKE_DOCKER_BUILD_OUTPUT_BYTES: String(dockerBuildOutputBytes), - FAKE_DOCKER_FAILURE_PATTERN: dockerFailurePattern, - FAKE_DOCKER_PORT: String(port), - FAKE_UNPREPARED_DOCKER_PORT: String(unpreparedPort), - PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH}`, - PLAYWRIGHT_EXIT_CODE: String(playwrightExitCode), - PLAYWRIGHT_OBSERVATION_FILE: playwrightObservationFile, - }, - playwrightObservationFile, - }; -} - -describe("vike-app Preset Source behavior", () => { - it("projects a Vike web app with a linked database workspace package", async () => { - const targetDir = await renderVikeProject(); - const rootPackageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const webPackageJson = await readJsonWithSchema( - path.join(targetDir, "apps/web/package.json"), - packageJsonSchema, - ); - const dbPackageJson = await readJsonWithSchema( - path.join(targetDir, "packages/db/package.json"), - packageJsonSchema, - ); - const migrationsPackageJson = await readJsonWithSchema( - path.join(targetDir, "packages/db-migrations/package.json"), - packageJsonSchema, - ); - const appTsconfig = await readJsonWithSchema( - path.join(targetDir, "apps/web/tsconfig.app.json"), - v.object({ include: v.array(v.string()) }), - ); - const webTsconfig = await readJsonWithSchema( - path.join(targetDir, "apps/web/tsconfig.json"), - v.object({ references: v.array(v.object({ path: v.string() })) }), - ); - const dbTsconfig = await readJsonWithSchema( - path.join(targetDir, "packages/db/tsconfig.json"), - v.object({ - compilerOptions: v.object({ - paths: v.record(v.string(), v.array(v.string())), - }), - include: v.array(v.string()), - }), - ); - const migrationsTsconfig = await readJsonWithSchema( - path.join(targetDir, "packages/db-migrations/tsconfig.json"), - v.object({ include: v.array(v.string()) }), - ); - const migrationsTurboConfig = await readJsonWithSchema( - path.join(targetDir, "packages/db-migrations/turbo.json"), - v.object({ - tasks: v.object({ - "build:run": v.object({ dependsOn: v.array(v.string()) }), - }), - }), - ); - const dbSource = await readFile( - path.join(targetDir, "packages/db/src/db.ts"), - "utf8", - ); - const todoQueriesSource = await readFile( - path.join(targetDir, "packages/db/src/queries/todos.ts"), - "utf8", - ); - const readinessSource = await readFile( - path.join(targetDir, "packages/db/src/readiness.ts"), - "utf8", - ); - const seedSource = await readFile( - path.join(targetDir, "packages/db/src/seed/example.ts"), - "utf8", - ); - const schemaSource = await readFile( - path.join(targetDir, "packages/db/src/schema.ts"), - "utf8", - ); - const serverSource = await readFile( - path.join(targetDir, "apps/web/server/app.ts"), - "utf8", - ); - const pageSource = await readFile( - path.join(targetDir, "apps/web/pages/index/+Page.vue"), - "utf8", - ); - const playwrightConfigSource = await readFile( - path.join(targetDir, "apps/web/playwright.config.ts"), - "utf8", - ); - const appDockerfile = await readFile( - path.join(targetDir, "apps/web/Dockerfile"), - "utf8", - ); - const workspaceYaml = await readFile( - path.join(targetDir, "pnpm-workspace.yaml"), - "utf8", - ); - const appDockerignore = await readFile( - path.join(targetDir, "apps/web/Dockerfile.dockerignore"), - "utf8", - ); - const playwrightRunnerSource = await readFile( - path.join(targetDir, "apps/web/scripts/run-playwright.ts"), - "utf8", - ); - const containerEntrypointSource = await readFile( - path.join(targetDir, "apps/web/scripts/container-entrypoint.sh"), - "utf8", - ); - const standaloneDeploymentRunnerSource = await readFile( - path.join(targetDir, "apps/web/scripts/check-standalone-deployment.ts"), - "utf8", - ); - const devcontainer = await readJsonWithSchema( - path.join(targetDir, ".devcontainer/devcontainer.json"), - devcontainerSchema, - ); - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - const checkWorkflow = await readFile( - path.join(targetDir, ".github/workflows/check.yml"), - "utf8", - ); - const dependabotConfig = await readFile( - path.join(targetDir, ".github/dependabot.yml"), - "utf8", - ); - const files = await generatedFilePaths(targetDir); - - expect(rootPackageJson).toMatchObject({ - name: "demo-vike", - engines: { node: "24" }, - packageManager: "pnpm@11.2.3", - }); - expect(rootPackageJson.scripts["check:deployment"]).toBe( - "pnpm --filter './apps/web' run check:deployment", - ); - expect(rootPackageJson.devDependencies).toHaveProperty( - "typescript-7", - "catalog:", - ); - expect(rootPackageJson.devDependencies).not.toHaveProperty("typescript"); - expect(webPackageJson).toMatchObject({ - name: "@demo-vike/web", - engines: { node: "24" }, - }); - expect(webPackageJson.dependencies).not.toHaveProperty("@demo-vike/db"); - expect(webPackageJson.devDependencies).toHaveProperty( - "@demo-vike/db", - "workspace:*", - ); - expect(webPackageJson.files).toEqual(["dist"]); - expect(webPackageJson.dependencies).toHaveProperty( - "drizzle-orm", - "catalog:", - ); - expect(webPackageJson.dependencies).toHaveProperty("srvx", "catalog:"); - expect(webPackageJson.scripts["lint:run"]).toBe( - "shellcheck scripts/container-entrypoint.sh && oxlint --quiet --format=unix --type-aware --config ../../oxlint.config.ts .", - ); - expect(webPackageJson.scripts["lint:fix:run"]).toBe( - "oxlint --type-aware --format=unix --config ../../oxlint.config.ts . --fix", - ); - expect(webPackageJson.scripts["check:deployment"]).toBe( - "node scripts/check-standalone-deployment.ts", - ); - expect(webPackageJson.scripts["test:e2e:run"]).toBe( - "node scripts/run-playwright.ts", - ); - expect(webPackageJson.scripts["typecheck:run"]).toBe( - "node scripts/run-vue-tsc.ts --build --noEmit --pretty false", - ); - expect(webPackageJson.devDependencies).toHaveProperty( - "typescript", - "catalog:", - ); - expect(webPackageJson.devDependencies).toHaveProperty( - "vue-tsc", - "catalog:", - ); - expect(webPackageJson.devDependencies).toHaveProperty( - "@vue/tsconfig", - "catalog:", - ); - expect(webPackageJson.devDependencies).not.toHaveProperty("typescript-7"); - expect(dbPackageJson).toMatchObject({ - name: "@demo-vike/db", - imports: { "#db/*": { default: "./src/*.ts", types: "./src/*.ts" } }, - engines: { node: "24" }, - dependencies: { "drizzle-orm": "catalog:" }, - }); - expect(dbPackageJson.imports).not.toHaveProperty("#/*"); - expect(dbPackageJson.scripts["typecheck:run"]).toBe( - "tsc -p tsconfig.json --noEmit --pretty false", - ); - expect(dbPackageJson.devDependencies).toHaveProperty( - "typescript-7", - "catalog:", - ); - expect(dbPackageJson.devDependencies).not.toHaveProperty("typescript"); - expect(dbPackageJson.scripts).toMatchObject({ - "db:seed:example": "node scripts/seed-example.ts", - "test:run": - 'DATABASE_FILE="$(pwd)/node_modules/.tmp/test.sqlite" pnpm --dir ../db-migrations run db:prepare:test && DATABASE_FILE="$(pwd)/node_modules/.tmp/test.sqlite" vitest run --reporter=agent --silent=passed-only; status=$?; rm -f ./node_modules/.tmp/test.sqlite; exit $status', - }); - expect(dbPackageJson.scripts).not.toHaveProperty("db:generate"); - expect(dbPackageJson.scripts).not.toHaveProperty("db:migrate"); - expect(dbPackageJson.scripts).not.toHaveProperty("db:push"); - expect(dbPackageJson.scripts).not.toHaveProperty("db:studio"); - expect(dbPackageJson.devDependencies).not.toHaveProperty("drizzle-kit"); - expect(migrationsPackageJson).toMatchObject({ - name: "@demo-vike/db-migrations", - private: true, - engines: { node: "24" }, - dependencies: { - "drizzle-kit": "catalog:", - "drizzle-orm": "catalog:", - }, - devDependencies: { - "@demo-vike/db": "workspace:*", - }, - }); - expect(migrationsPackageJson.files).toEqual([ - "drizzle.config.ts", - "drizzle/migrations", - ]); - expect(migrationsPackageJson.scripts).toMatchObject({ - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:prepare:deploy": "pnpm run db:migrate", - "db:prepare:dev": "node scripts/prepare-database.ts dev", - "db:prepare:test": "node scripts/prepare-database.ts test", - "db:push": "mkdir -p data node_modules/.tmp && drizzle-kit push", - "db:studio": "drizzle-kit studio", - }); - expect(migrationsPackageJson.scripts).not.toHaveProperty("db:seed:example"); - expect(migrationsPackageJson.devDependencies).toHaveProperty( - "typescript-7", - "catalog:", - ); - expect(migrationsPackageJson.devDependencies).not.toHaveProperty( - "typescript", - ); - expect(dbTsconfig.compilerOptions.paths).toEqual({ - "#db/*": ["./src/*"], - }); - expect(dbTsconfig.include).toContain("scripts/**/*.ts"); - expect(dbTsconfig.include).not.toContain("drizzle.config.ts"); - expect(migrationsTsconfig.include).toEqual([ - "drizzle.config.ts", - "scripts/**/*.ts", - ]); - expect(migrationsTurboConfig.tasks["build:run"].dependsOn).toEqual([ - "^build:run", - ]); - expect(dbSource).toContain('from "#db/schema"'); - expect(dbSource).not.toContain('from "drizzle-orm/node-sqlite/migrator"'); - expect(dbSource).not.toContain("migrate(db"); - expect(dbSource).not.toContain('from "#/'); - expect(readinessSource).toContain("assertDatabaseReady"); - expect(readinessSource).toContain("Database is not ready."); - expect(seedSource).toContain("seedExampleData"); - expect(seedSource).toContain("Read the generated TODO.md"); - expect(schemaSource).toContain("title: text().notNull().unique()"); - expect(todoQueriesSource).toContain(".prepare()"); - expect(serverSource).toContain("export function createApp()"); - expect(serverSource).toContain("assertDatabaseReady(createDatabase())"); - expect(serverSource).toContain("process.exit(1)"); - expect(pageSource).toContain("onMounted(() =>"); - expect(pageSource).toContain("void refreshTodos();"); - expect(playwrightConfigSource).toContain("db:prepare:test"); - expect(playwrightConfigSource).toContain("PLAYWRIGHT_EXTERNAL_BASE_URL"); - expect(playwrightConfigSource).toContain("...(externalServiceUrl"); - expect(playwrightConfigSource).toContain('trace: "retain-on-failure"'); - expect(playwrightRunnerSource).toContain("DATABASE_FILE"); - expect(playwrightRunnerSource).toContain("awaitExternalService"); - expect(playwrightRunnerSource).toContain("localDatabaseFile"); - expect(playwrightRunnerSource).toContain("PLAYWRIGHT_EXTERNAL_BASE_URL"); - expect(playwrightRunnerSource).toContain("must be a valid HTTP(S) URL"); - expect(appTsconfig.include).toContain("types/**/*.d.ts"); - expect(webTsconfig.references).toEqual( - expect.arrayContaining([{ path: "../../packages/db" }]), - ); - expect(files).toContain("apps/web/types/env.d.ts"); - expect(files).toContain("apps/web/types/global.d.ts"); - expect(files).toContain("apps/web/Dockerfile"); - expect(files).toContain("apps/web/Dockerfile.dockerignore"); - expect(files).toContain("apps/web/scripts/container-entrypoint.sh"); - expect(files).not.toContain("apps/web/scripts/prepare-database.sh"); - expect(files).toContain("apps/web/scripts/check-standalone-deployment.ts"); - expect(files).toContain( - "packages/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/migration.sql", - ); - expect(files).toContain( - "packages/db-migrations/drizzle/migrations/20260709120325_old_captain_flint/snapshot.json", - ); - expect(files).toContain("packages/db-migrations/drizzle.config.ts"); - expect(files).toContain("apps/web/scripts/run-vue-tsc.ts"); - expect(files.some((file) => file.startsWith("packages/vue-tooling/"))).toBe( - false, - ); - expect(files).not.toContain("packages/db/drizzle.config.ts"); - expect(files.filter((file) => file.endsWith("drizzle.config.ts"))).toEqual([ - "packages/db-migrations/drizzle.config.ts", - ]); - expect(files).toContain("packages/db/scripts/seed-example.ts"); - expect(files).toContain("packages/db/src/readiness.ts"); - expect(files).toContain("packages/db/src/seed/example.ts"); - expect(files).not.toContain("env.d.ts"); - expect(files).not.toContain("global.d.ts"); - expect(files).not.toContain("behavior.test.ts"); - expect(files).not.toContain("apps/web/behavior.test.ts"); - expect(files).not.toContain("packages/db/behavior.test.ts"); - - expect(devcontainer.build).toEqual({ - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@11.2.3", - PLAYWRIGHT_CLI_PACKAGE: playwrightCliPackage, - }, - }); - expect(devcontainer).not.toHaveProperty("features"); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain("ARG PLAYWRIGHT_CLI_PACKAGE"); - expect(dockerfile).toContain( - "apt-get install -y --no-install-recommends shellcheck", - ); - expect(checkWorkflow).toContain( - "pnpm --filter ./apps/web exec playwright install --with-deps chromium", - ); - expect(checkWorkflow).toContain( - "- run: sudo apt-get update && sudo apt-get install -y shellcheck\n if: matrix.check == 'root'", - ); - expect(checkWorkflow).toContain("docker/setup-buildx-action@v3"); - expect(checkWorkflow).toContain("check: [root, deployment]"); - expect(checkWorkflow).toContain( - "- run: pnpm run check\n if: matrix.check == 'root'", - ); - expect(checkWorkflow).toContain( - "- run: pnpm run check:deployment\n if: matrix.check == 'deployment'", - ); - expect(checkWorkflow).not.toContain("docker-image:"); - expect(checkWorkflow).not.toContain("docker buildx build"); - expect(dependabotConfig).toContain("directory: /apps/web"); - expect(appDockerfile).toContain("FROM node:24-bookworm-slim AS base"); - expect(workspaceYaml).toContain('"pinia>typescript": "-"'); - expect(workspaceYaml).toContain('"valibot>typescript": "-"'); - expect(workspaceYaml).toContain('"vue>typescript": "-"'); - expect(appDockerfile).toContain("FROM application-runtime AS runtime"); - expect(appDockerfile).toContain('ARG PACKAGE_MANAGER_PIN="pnpm@11.2.3"'); - expect(appDockerfile).toContain('ENV COREPACK_HOME="/corepack"'); - expect(appDockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(appDockerfile).toContain( - 'corepack prepare "$PACKAGE_MANAGER_PIN" --activate', - ); - expect(appDockerfile).toContain( - "COPY pnpm-lock.yaml pnpm-workspace.yaml .pnpmfile.cts ./", - ); - expect( - appDockerfile.indexOf( - "COPY pnpm-lock.yaml pnpm-workspace.yaml .pnpmfile.cts ./", - ), - ).toBeLessThan(appDockerfile.indexOf("RUN pnpm fetch")); - expect( - appDockerfile.indexOf( - "COPY --from=pruner /repo/.pnpmfile.cts ./.pnpmfile.cts", - ), - ).toBeLessThan( - appDockerfile.indexOf( - "RUN pnpm fetch", - appDockerfile.indexOf("FROM base AS build"), - ), - ); - expect(appDockerfile.indexOf("pnpm fetch")).toBeLessThan( - appDockerfile.indexOf("COPY package.json turbo.json ./"), - ); - expect(appDockerfile).toContain("pnpm fetch"); - expect(appDockerfile).toContain("pnpm install --offline --frozen-lockfile"); - expect(appDockerfile).not.toContain("packages/vue-tooling"); - expect(appDockerfile).toContain( - "pnpm exec turbo prune @demo-vike/web @demo-vike/db-migrations --docker", - ); - expect( - appDockerfile.indexOf("pnpm --filter ./packages/db run build:run"), - ).toBeLessThan( - appDockerfile.indexOf("pnpm --filter ./apps/web run build:run"), - ); - expect(appDockerfile).toContain("AS standalone"); - expect(appDockerfile).toContain('ENV DATABASE_FILE="/data/app.sqlite"'); - expect(appDockerfile).toContain( - 'ENTRYPOINT ["/usr/local/bin/container-entrypoint"]', - ); - expect(appDockerfile).toContain('CMD ["prepare-and-start"]'); - expect(appDockerfile).toContain("COPY --from=build --chown=app:nodejs"); - expect(appDockerfile).toContain("USER app"); - expect(appDockerfile).toContain( - 'CMD ["node", "/app/dist/server/index.mjs"]', - ); - expect(appDockerfile).not.toContain("CONTAINER_CAPABILITY"); - expect(appDockerfile).not.toContain("node:latest"); - expect(appDockerfile).not.toContain("npm install -g"); - expect(appDockerfile).not.toContain("pnpm dlx"); - expect(appDockerignore).toContain("**/node_modules"); - expect(appDockerignore).toContain("*.sqlite"); - expect(containerEntrypointSource).toContain("prepare-and-start"); - expect(containerEntrypointSource).toContain("prepare-only"); - expect(containerEntrypointSource).toContain( - "drizzle-kit migrate --config /migration/drizzle.config.ts", - ); - expect(containerEntrypointSource).not.toContain("pnpm"); - expect(containerEntrypointSource).toContain( - "exec node /app/dist/server/index.mjs", - ); - expect(standaloneDeploymentRunnerSource).toContain("--target"); - expect(standaloneDeploymentRunnerSource).toContain("standalone"); - expect(standaloneDeploymentRunnerSource).toContain( - "PLAYWRIGHT_EXTERNAL_BASE_URL", - ); - }); - - it("targets an externally managed Playwright service without a local web server", async () => { - const targetDir = await renderVikeProject(); - await linkRepositoryDependencies(targetDir); - - const { stdout } = await execFileAsync( - process.execPath, - [ - "--input-type=module", - "--eval", - "const { default: config } = await import('./playwright.config.ts'); console.log(JSON.stringify({ baseURL: config.use?.baseURL, webServer: config.webServer }));", - ], - { - cwd: path.join(targetDir, "apps/web"), - env: { - ...process.env, - PLAYWRIGHT_EXTERNAL_BASE_URL: "http://127.0.0.1:4173", - }, - }, - ); - - expect(JSON.parse(stdout) as unknown).toEqual({ - baseURL: "http://127.0.0.1:4173/", - }); - }); - - it("starts the generated local service from a clean checkout and removes its test database", async () => { - const repositoryPackageJson = await readJsonWithSchema( - path.join(process.cwd(), "package.json"), - v.object({ packageManager: packageManagerPinSchema }), - ); - const targetDir = await renderVikeProject( - repositoryPackageJson.packageManager, - ); - const webDir = path.join(targetDir, "apps/web"); - const databaseFile = path.join(webDir, "node_modules/.tmp/e2e.sqlite"); - await linkRepositoryDependencies(targetDir); - await mkdir(path.join(webDir, "dist/server"), { recursive: true }); - await writeFile( - path.join(webDir, "dist/server/index.mjs"), - `import { createServer } from "node:http"; - -createServer((_request, response) => { - response.writeHead(200, { "content-type": "text/html" }); - response.end("

Local service ready

"); -}).listen(Number(process.env.PORT), "127.0.0.1"); -`, - ); - await writeFile( - path.join(webDir, "test/e2e/app.spec.ts"), - `import { expect, test } from "@playwright/test"; - -test("starts the local service", async ({ page }) => { - await page.goto("/"); - await expect(page.getByRole("heading", { name: "Local service ready" })).toBeVisible(); -}); -`, - ); - const env = { - ...process.env, - CI: "1", - PATH: `${repositoryBin}${path.delimiter}${process.env.PATH}`, - }; - await execFileAsync(process.execPath, ["scripts/run-playwright.ts"], { - cwd: webDir, - env, - }); - - await expect(access(databaseFile)).rejects.toMatchObject({ - code: "ENOENT", - }); - }, 120_000); - - it("projects source accepted by the generated formatter", async () => { - const targetDir = await renderVikeProject(); - await linkRepositoryDependencies(targetDir); - - await execFileAsync( - path.join(repositoryBin, "oxfmt"), - [ - "--list-different", - "--config", - "oxfmt.config.ts", - "apps/web/pages/index/+Page.telefunc.ts", - "apps/web/scripts/check-standalone-deployment.ts", - "apps/web/server/app.ts", - ], - { cwd: targetDir, env: process.env }, - ); - }); - - it("projects exactly two final deployment images with a slim runtime", async () => { - const targetDir = await renderVikeProject(); - const dockerfile = await readFile( - path.join(targetDir, "apps/web/Dockerfile"), - "utf8", - ); - const finalTargets = [ - ...dockerfile.matchAll(/^# Final deployment target: (\S+)$/gmu), - ].map((match) => match[1]); - const runtime = dockerfile.slice( - dockerfile.indexOf("FROM application-runtime AS runtime"), - ); - const applicationRuntime = dockerfile.slice( - dockerfile.indexOf("AS application-runtime"), - dockerfile.indexOf("# Final deployment target: standalone"), - ); - - expect(finalTargets).toEqual(["standalone", "runtime"]); - expect(dockerfile).toContain( - "pnpm --filter ./apps/web deploy --prod /runtime-deploy", - ); - expect(dockerfile).toContain( - "pnpm --filter ./packages/db-migrations deploy --prod /migration-deploy", - ); - expect(dockerfile).not.toContain("--legacy"); - expect(dockerfile).not.toContain("file+packages+db"); - expect(dockerfile).not.toMatch(/\b(?:rm|find)\b/u); - expect(applicationRuntime).toContain("./node_modules"); - expect(applicationRuntime).not.toContain("packages/db"); - expect(applicationRuntime).not.toContain("drizzle-kit"); - expect(dockerfile).toContain("FROM application-runtime AS standalone"); - expect(runtime).toContain("FROM application-runtime AS runtime"); - expect(applicationRuntime).toContain( - "COPY --from=build --chown=app:nodejs", - ); - expect(applicationRuntime).toContain("--uid 1001 --gid nodejs"); - expect(applicationRuntime).toContain( - "install -d -o app -g nodejs /app /data", - ); - expect(applicationRuntime).toContain( - 'ENV DATABASE_FILE="/data/app.sqlite"', - ); - expect(runtime).not.toContain("CONTAINER_CAPABILITY"); - expect(runtime).not.toContain("ENTRYPOINT"); - expect(runtime).toContain('CMD ["node", "/app/dist/server/index.mjs"]'); - expect( - dockerfile.match(/LABEL org\.opencontainers\.image\.version/gu), - ).toHaveLength(2); - expect(runtime).not.toContain("--from=pruner"); - expect(runtime).not.toContain("--from=standalone"); - expect(runtime).not.toContain("pnpm"); - expect(runtime).not.toContain("packages/db"); - expect(runtime).not.toContain("pnpm-workspace.yaml"); - expect(runtime).not.toContain("drizzle"); - }); - - it("serves the built application after an isolated migration closure prepares its database", async () => { - const repositoryPackageJson = await readJsonWithSchema( - path.join(process.cwd(), "package.json"), - v.object({ packageManager: packageManagerPinSchema }), - ); - const targetDir = await renderVikeProject( - repositoryPackageJson.packageManager, - ); - const rootTscBefore = await repositoryTscIdentity(); - await addPackage({ - cwd: targetDir, - preset: "ts-lib", - name: "shared", - presetSourceManifest: loadBuiltInPresetSourceManifest(), - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - }); - await execFileAsync( - "pnpm", - ["install", "--ignore-scripts", "--no-frozen-lockfile"], - { - cwd: targetDir, - env: { ...process.env, CI: "1" }, - }, - ); - expect( - (await lstat(path.join(targetDir, "node_modules"))).isSymbolicLink(), - ).toBe(false); - expect(await repositoryTscIdentity()).toEqual(rootTscBefore); - await execFileAsync( - "pnpm", - ["--filter", "./apps/web", "exec", "vike", "build"], - { - cwd: targetDir, - env: process.env, - }, - ); - const deploymentRoot = await mkdtemp( - path.join(tmpdir(), "vike-application-closure-"), - ); - await execFileAsync( - "pnpm", - ["--filter", "./apps/web", "deploy", "--prod", deploymentRoot], - { cwd: targetDir, env: process.env }, - ); - expect(await repositoryTscIdentity()).toEqual(rootTscBefore); - const deployedNodeModules = path.join(deploymentRoot, "node_modules"); - const virtualStore = path.join(deployedNodeModules, ".pnpm"); - const productionEntries = await readdir(virtualStore); - expect(await generatedFilePaths(deploymentRoot)).toContain( - "pnpm-lock.yaml", - ); - await expect( - access(path.join(deployedNodeModules, "@demo-vike/db")), - ).rejects.toMatchObject({ code: "ENOENT" }); - expect( - productionEntries.some((entry) => entry.includes("file+packages+db")), - ).toBe(false); - expect( - productionEntries.some((entry) => entry.includes("drizzle-kit")), - ).toBe(false); - - const migrationRoot = await mkdtemp( - path.join(tmpdir(), "vike-migration-closure-"), - ); - await execFileAsync( - "pnpm", - [ - "--filter", - "./packages/db-migrations", - "deploy", - "--prod", - migrationRoot, - ], - { cwd: targetDir, env: process.env }, - ); - expect(await generatedFilePaths(migrationRoot)).toContain("pnpm-lock.yaml"); - - const databaseFile = path.join(deploymentRoot, "data", "app.sqlite"); - await mkdir(path.dirname(databaseFile), { recursive: true }); - await execFileAsync( - path.join(migrationRoot, "node_modules/.bin/drizzle-kit"), - ["migrate"], - { - cwd: migrationRoot, - env: { ...process.env, DATABASE_FILE: databaseFile }, - }, - ); - expect(await databaseReadiness(databaseFile)).toBe("todos:0"); - - const port = await availablePort(); - const runtimeEnvironment = { - ...process.env, - DATABASE_FILE: databaseFile, - HOST: "127.0.0.1", - PORT: String(port), - }; - const isolatedFiles = await generatedFilePaths(deploymentRoot); - expect(isolatedFiles).not.toContain("node_modules/@demo-vike/db"); - expect( - isolatedFiles.some((file) => file.includes("file+packages+db")), - ).toBe(false); - expect(isolatedFiles.some((file) => file.includes("drizzle-kit"))).toBe( - false, - ); - expect(isolatedFiles).not.toContain("scripts/prepare-database.sh"); - expect(isolatedFiles.some((file) => file.startsWith("packages/db/"))).toBe( - false, - ); - const server = spawn(process.execPath, ["dist/server/index.mjs"], { - cwd: deploymentRoot, - env: runtimeEnvironment, - stdio: ["ignore", "pipe", "pipe"], - }); - - try { - const response = await waitForHttpResponse( - `http://127.0.0.1:${port}/api/health`, - server, - ); - expect(await response.json()).toEqual({ ok: true, service: "vike-app" }); - } finally { - await stopChildProcess(server); - } - }, 600_000); - - it("prepares one seeded application database for default and caller-relative development paths", async () => { - const repositoryPackageJson = await readJsonWithSchema( - path.join(process.cwd(), "package.json"), - v.object({ packageManager: packageManagerPinSchema }), - ); - const targetDir = await renderVikeProject( - repositoryPackageJson.packageManager, - ); - await execFileAsync( - "pnpm", - ["install", "--ignore-scripts", "--no-frozen-lockfile"], - { cwd: targetDir, env: { ...process.env, CI: "1" } }, - ); - - const preparationEnvironment = { ...process.env }; - delete preparationEnvironment.DATABASE_FILE; - delete preparationEnvironment.INIT_CWD; - await execFileAsync( - "pnpm", - ["--dir", "packages/db-migrations", "run", "db:prepare:dev"], - { cwd: targetDir, env: preparationEnvironment }, - ); - - const defaultDatabaseFile = path.join( - targetDir, - "apps/web/data/app.sqlite", - ); - expect(await databaseReadiness(defaultDatabaseFile)).toBe("todos:2"); - - const relativeDatabaseFile = "caller-data/app.sqlite"; - await execFileAsync( - "pnpm", - ["--dir", "packages/db-migrations", "run", "db:prepare:dev"], - { - cwd: targetDir, - env: { ...preparationEnvironment, DATABASE_FILE: relativeDatabaseFile }, - }, - ); - - const expectedRelativeDatabaseFile = path.join( - targetDir, - relativeDatabaseFile, - ); - expect(await databaseReadiness(expectedRelativeDatabaseFile)).toBe( - "todos:2", - ); - await expect( - access( - path.join(targetDir, "packages/db-migrations", relativeDatabaseFile), - ), - ).rejects.toMatchObject({ code: "ENOENT" }); - await expect( - access(path.join(targetDir, "packages/db", relativeDatabaseFile)), - ).rejects.toMatchObject({ code: "ENOENT" }); - }, 120_000); - - it("applies SQL from a production migration closure without database schema source", async () => { - const repositoryPackageJson = await readJsonWithSchema( - path.join(process.cwd(), "package.json"), - v.object({ packageManager: packageManagerPinSchema }), - ); - const targetDir = await renderVikeProject( - repositoryPackageJson.packageManager, - ); - await execFileAsync( - "pnpm", - ["install", "--ignore-scripts", "--no-frozen-lockfile"], - { cwd: targetDir, env: { ...process.env, CI: "1" } }, - ); - - const deploymentRoot = path.join(targetDir, "migration-deploy"); - await execFileAsync( - "pnpm", - [ - "--filter", - "./packages/db-migrations", - "deploy", - "--prod", - deploymentRoot, - ], - { cwd: targetDir, env: process.env }, - ); - - const deploymentFiles = await generatedFilePaths(deploymentRoot); - expect(deploymentFiles).toContain("pnpm-lock.yaml"); - expect(deploymentFiles).toContain("drizzle.config.ts"); - expect(deploymentFiles.some((file) => file.endsWith("migration.sql"))).toBe( - true, - ); - expect(deploymentFiles.some((file) => file.includes("src/schema.ts"))).toBe( - false, - ); - expect( - deploymentFiles.some((file) => - file.includes("node_modules/@demo-vike/db"), - ), - ).toBe(false); - expect(deploymentFiles.some((file) => file.includes("seed-example"))).toBe( - false, - ); - const databaseFile = path.join(deploymentRoot, "prepared.sqlite"); - await execFileAsync( - path.join(deploymentRoot, "node_modules/.bin/drizzle-kit"), - ["migrate", "--config", path.join(deploymentRoot, "drizzle.config.ts")], - { - cwd: tmpdir(), - env: { ...process.env, DATABASE_FILE: databaseFile }, - }, - ); - const { stdout } = await execFileAsync( - process.execPath, - [ - "--input-type=module", - "--eval", - 'import { DatabaseSync } from "node:sqlite"; const db = new DatabaseSync(process.env.DATABASE_FILE); const table = db.prepare("select name from sqlite_master where type = \'table\' and name = \'todos\'").get()?.name; const rows = db.prepare("select count(*) as count from todos").get()?.count; console.log(`${table}:${rows}`);', - ], - { env: { ...process.env, DATABASE_FILE: databaseFile } }, - ); - expect(stdout.trim()).toBe("todos:0"); - }, 120_000); - - it.each([ - ["", "must be a non-empty HTTP(S) URL"], - ["ftp://example.test", "must use HTTP or HTTPS"], - ["not a URL", "must be a valid HTTP(S) URL"], - ])( - "rejects invalid external Playwright service input %#", - async (externalBaseUrl, diagnostic) => { - const targetDir = await renderVikeProject(); - await linkRepositoryDependencies(targetDir); - - await expect( - execFileAsync( - process.execPath, - [ - "--input-type=module", - "--eval", - "await import('./playwright.config.ts')", - ], - { - cwd: path.join(targetDir, "apps/web"), - env: { - ...process.env, - PLAYWRIGHT_EXTERNAL_BASE_URL: externalBaseUrl, - }, - }, - ), - ).rejects.toMatchObject({ stderr: expect.stringContaining(diagnostic) }); - }, - ); - - it("does not alter an externally managed database", async () => { - const targetDir = await renderVikeProject(); - const webDir = path.join(targetDir, "apps/web"); - const fakeBinDir = path.join(targetDir, "fake-bin"); - const databaseFile = path.join(targetDir, "external.sqlite"); - const observationFile = path.join(targetDir, "playwright-observation.json"); - await mkdir(fakeBinDir); - await writeFile(databaseFile, "externally-owned"); - const playwrightPath = path.join(fakeBinDir, "playwright"); - await writeFile( - playwrightPath, - `#!/usr/bin/env node -import { writeFileSync } from "node:fs"; -writeFileSync(process.env.PLAYWRIGHT_OBSERVATION_FILE, JSON.stringify({ - args: process.argv.slice(2), - baseUrl: process.env.PLAYWRIGHT_EXTERNAL_BASE_URL, -})); -`, - ); - await chmod(playwrightPath, 0o755); - - const server = createServer((_request, response) => { - response.writeHead(200).end("ready"); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - - try { - const address = server.address(); - if (typeof address !== "object" || address === null) { - throw new Error("Could not determine test service port"); - } - - const baseUrl = `http://127.0.0.1:${address.port}`; - await execFileAsync( - process.execPath, - ["scripts/run-playwright.ts", "--project=chromium"], - { - cwd: webDir, - env: { - ...process.env, - DATABASE_FILE: databaseFile, - PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH}`, - PLAYWRIGHT_EXTERNAL_BASE_URL: baseUrl, - PLAYWRIGHT_OBSERVATION_FILE: observationFile, - }, - }, - ); - - expect(await readFile(databaseFile, "utf8")).toBe("externally-owned"); - expect( - JSON.parse(await readFile(observationFile, "utf8")) as unknown, - ).toEqual({ - args: ["test", "--project=chromium"], - baseUrl: `${baseUrl}`, - }); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - }); - - it("rejects a relative container database path before preparation", async () => { - const targetDir = await renderVikeProject(); - - await expect( - execFileAsync("sh", ["scripts/container-entrypoint.sh", "prepare-only"], { - cwd: path.join(targetDir, "apps/web"), - env: { ...process.env, DATABASE_FILE: "data/app.sqlite" }, - }), - ).rejects.toMatchObject({ - code: 64, - stderr: expect.stringContaining("DATABASE_FILE must be an absolute path"), - }); - }); - - it("prepares an absolute database without starting the application", async () => { - const targetDir = await renderVikeProject(); - const { databaseFile, env, observationFile } = - await fakeContainerCommandEnvironment(targetDir); - - await execFileAsync( - "sh", - ["scripts/container-entrypoint.sh", "prepare-only"], - { cwd: path.join(targetDir, "apps/web"), env }, - ); - - expect(await readFile(observationFile, "utf8")).toBe("prepare\n"); - await expect(access(path.dirname(databaseFile))).resolves.toBeUndefined(); - }); - - it("starts only after successful deployment preparation", async () => { - const targetDir = await renderVikeProject(); - const { env, observationFile } = - await fakeContainerCommandEnvironment(targetDir); - - await execFileAsync( - "sh", - ["scripts/container-entrypoint.sh", "prepare-and-start"], - { cwd: path.join(targetDir, "apps/web"), env }, - ); - - expect(await readFile(observationFile, "utf8")).toBe("prepare\nstart\n"); - }); - - it("does not start when deployment preparation fails", async () => { - const targetDir = await renderVikeProject(); - const { env, observationFile } = - await fakeContainerCommandEnvironment(targetDir); - - await expect( - execFileAsync( - "sh", - ["scripts/container-entrypoint.sh", "prepare-and-start"], - { - cwd: path.join(targetDir, "apps/web"), - env: { ...env, CONTAINER_PREPARE_EXIT_CODE: "23" }, - }, - ), - ).rejects.toMatchObject({ code: 23 }); - expect(await readFile(observationFile, "utf8")).toBe("prepare\n"); - }); - - it("replaces the dispatcher so the application receives container signals", async () => { - const targetDir = await renderVikeProject(); - const { env, observationFile } = - await fakeContainerCommandEnvironment(targetDir); - const fakeBinDir = env.PATH!.split(path.delimiter)[0]!; - await writeFile( - path.join(fakeBinDir, "node"), - `#!/bin/sh -trap 'echo signal >> "$CONTAINER_OBSERVATION_FILE"; exit 0' TERM -echo "pid:$$" >> "$CONTAINER_OBSERVATION_FILE" -while :; do sleep 1; done -`, - ); - await chmod(path.join(fakeBinDir, "node"), 0o755); - - const child = spawn( - "sh", - ["scripts/container-entrypoint.sh", "prepare-and-start"], - { cwd: path.join(targetDir, "apps/web"), env }, - ); - const deadline = Date.now() + 5_000; - let observation = ""; - while (Date.now() < deadline && !observation.includes("pid:")) { - try { - observation = await readFile(observationFile, "utf8"); - } catch { - // The application has not written its PID yet. - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - expect(observation).toContain(`pid:${child.pid}`); - child.kill("SIGTERM"); - await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", () => resolve()); - }); - expect(await readFile(observationFile, "utf8")).toContain("signal\n"); - }); - - it("checks standalone, prepared runtime, and unprepared runtime lifecycles", async () => { - const targetDir = await renderVikeProject(); - const server = createServer((_request, response) => { - response.writeHead(200).end("ready"); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - - try { - const address = server.address(); - if (typeof address !== "object" || address === null) { - throw new Error("Could not determine fake deployment port"); - } - const { dockerObservationFile, env, playwrightObservationFile } = - await fakeDockerEnvironment(targetDir, address.port); - - await execFileAsync( - process.execPath, - ["scripts/check-standalone-deployment.ts"], - { cwd: path.join(targetDir, "apps/web"), env }, - ); - - const dockerCommands = await readFile(dockerObservationFile, "utf8"); - expect(dockerCommands).toContain( - "build --file apps/web/Dockerfile --target standalone --build-arg DEPLOYMENT_BUILD_ID=", - ); - expect(dockerCommands).toContain( - "build --file apps/web/Dockerfile --target runtime --build-arg DEPLOYMENT_BUILD_ID=", - ); - expect(dockerCommands.match(/volume create/gu)).toHaveLength(3); - expect(dockerCommands.match(/network create/gu)).toHaveLength(1); - expect(dockerCommands).toContain("run --detach"); - expect(dockerCommands).toContain("standalone:"); - expect(dockerCommands).toContain("prepare-only"); - expect(dockerCommands).toContain( - "inspect --format={{.State.Status}}:{{.State.ExitCode}}", - ); - expect(dockerCommands).toContain( - "port vike-deployment-check-unprepared-", - ); - expect(dockerCommands).toContain( - "logs vike-deployment-check-unprepared-", - ); - expect(dockerCommands).toContain("runtime:"); - expect(dockerCommands).toContain("unprepared"); - expect(dockerCommands).toMatch( - /unprepared[^\n]*--publish|--publish[^\n]*unprepared/u, - ); - expect(dockerCommands).toContain("exec"); - expect(dockerCommands).toContain("const expectedIdentity = 1001"); - expect(dockerCommands).toContain("process.getuid() !== expectedIdentity"); - expect(dockerCommands).toContain("process.getgid() !== expectedIdentity"); - expect(dockerCommands).toContain("statSync(process.env.DATABASE_FILE)"); - expect(dockerCommands).toContain("select count(*) as count from todos"); - expect(dockerCommands).toContain("rm --force"); - expect(dockerCommands).toContain("volume rm --force"); - expect(dockerCommands).toContain("image rm --force"); - expect(dockerCommands.match(/^rm --force/gmu)).toHaveLength(4); - expect(dockerCommands.match(/^volume rm --force/gmu)).toHaveLength(3); - expect(dockerCommands.match(/^image rm --force/gmu)).toHaveLength(2); - expect(dockerCommands.match(/^network rm/gmu)).toHaveLength(1); - expect(await readFile(playwrightObservationFile, "utf8")).toBe( - `http://127.0.0.1:${address.port}\nhttp://127.0.0.1:${address.port}\n`, - ); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - }); - - it("rejects an unprepared runtime that serves any HTTP response before exit", async () => { - const targetDir = await renderVikeProject(); - const readyServer = createServer((_request, response) => { - response.writeHead(200).end("ready"); - }); - const leakedServer = createServer((_request, response) => { - response.writeHead(503).end("not ready"); - }); - await Promise.all( - [readyServer, leakedServer].map( - (server) => - new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }), - ), - ); - - try { - const readyAddress = readyServer.address(); - const leakedAddress = leakedServer.address(); - if ( - typeof readyAddress !== "object" || - readyAddress === null || - typeof leakedAddress !== "object" || - leakedAddress === null - ) { - throw new Error("Could not determine fake deployment ports"); - } - const { env } = await fakeDockerEnvironment( - targetDir, - readyAddress.port, - 0, - 0, - "", - leakedAddress.port, - ); - - const result = await execFileAsync( - process.execPath, - ["scripts/check-standalone-deployment.ts"], - { cwd: path.join(targetDir, "apps/web"), env }, - ).catch((error: unknown) => error as { stderr?: string }); - - expect(result).toMatchObject({ - stderr: expect.stringMatching( - /Unprepared runtime served HTTP 503 before exiting/u, - ), - }); - } finally { - await Promise.all( - [readyServer, leakedServer].map( - (server) => - new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }), - ), - ); - } - }); - - it("accepts deployment command output larger than execFile's default buffer", async () => { - const targetDir = await renderVikeProject(); - const server = createServer((_request, response) => { - response.writeHead(200).end("ready"); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - - try { - const address = server.address(); - if (typeof address !== "object" || address === null) { - throw new Error("Could not determine fake deployment port"); - } - const { env } = await fakeDockerEnvironment( - targetDir, - address.port, - 0, - 1_200_000, - ); - - await execFileAsync( - process.execPath, - ["apps/web/scripts/check-standalone-deployment.ts"], - { cwd: targetDir, env }, - ); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - }); - - it("prints container logs and cleans resources when Playwright fails", async () => { - const targetDir = await renderVikeProject(); - const server = createServer((_request, response) => { - response.writeHead(200).end("ready"); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - - try { - const address = server.address(); - if (typeof address !== "object" || address === null) { - throw new Error("Could not determine fake deployment port"); - } - const { dockerObservationFile, env } = await fakeDockerEnvironment( - targetDir, - address.port, - 19, - ); - - await expect( - execFileAsync( - process.execPath, - ["apps/web/scripts/check-standalone-deployment.ts"], - { cwd: targetDir, env }, - ), - ).rejects.toMatchObject({ - code: 1, - stderr: expect.stringMatching( - /Deployment mode standalone failed during playwright[\s\S]*"pnpm" "--dir" "apps\/web" "run" "test:e2e:run"[\s\S]*fake playwright stdout failure[\s\S]*fake playwright stderr failure[\s\S]*fake container stdout diagnostics[\s\S]*fake container stderr diagnostics/u, - ), - }); - - const dockerCommands = await readFile(dockerObservationFile, "utf8"); - expect(dockerCommands).toContain("logs"); - expect(dockerCommands).toContain("rm --force"); - expect(dockerCommands).toContain("volume rm --force"); - expect(dockerCommands).toContain("image rm --force"); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - }); - - it("reports the real deployment mode, phase, command, and output for Docker failures", async () => { - const targetDir = await renderVikeProject(); - const { env } = await fakeDockerEnvironment( - targetDir, - 1, - 0, - 0, - "--target runtime", - ); - - await expect( - execFileAsync( - process.execPath, - ["apps/web/scripts/check-standalone-deployment.ts"], - { cwd: targetDir, env }, - ), - ).rejects.toMatchObject({ - code: 1, - stderr: expect.stringMatching( - /Deployment mode runtime failed during build[\s\S]*"docker" "build" "--file" "apps\/web\/Dockerfile" "--target" "runtime"[\s\S]*fake docker stdout failure[\s\S]*fake docker stderr failure/u, - ), - }); - }); - - it("prints container logs and cleans resources when readiness times out", async () => { - const targetDir = await renderVikeProject(); - const { dockerObservationFile, env } = await fakeDockerEnvironment( - targetDir, - 1, - ); - - await expect( - execFileAsync( - process.execPath, - ["apps/web/scripts/check-standalone-deployment.ts"], - { - cwd: targetDir, - env: { - ...env, - STANDALONE_DEPLOYMENT_READINESS_TIMEOUT_MS: "50", - }, - }, - ), - ).rejects.toMatchObject({ - code: 1, - stderr: expect.stringMatching( - /Standalone container was not ready within 50ms[\s\S]*fake container stdout diagnostics[\s\S]*fake container stderr diagnostics/u, - ), - }); - - const dockerCommands = await readFile(dockerObservationFile, "utf8"); - expect(dockerCommands).toContain("logs"); - expect(dockerCommands).toContain("rm --force"); - expect(dockerCommands).toContain("volume rm --force"); - expect(dockerCommands).toContain("image rm --force"); - }); - - it("cleans deployment resources when interrupted", async () => { - const targetDir = await renderVikeProject(); - const server = createServer((_request, response) => { - response.writeHead(200).end("ready"); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - - try { - const address = server.address(); - if (typeof address !== "object" || address === null) { - throw new Error("Could not determine fake deployment port"); - } - const { dockerObservationFile, env, playwrightObservationFile } = - await fakeDockerEnvironment(targetDir, address.port); - const fakeBinDir = env.PATH!.split(path.delimiter)[0]!; - await writeFile( - path.join(fakeBinDir, "pnpm"), - `#!/bin/sh -echo "$PLAYWRIGHT_EXTERNAL_BASE_URL" > "$PLAYWRIGHT_OBSERVATION_FILE" -trap 'exit 143' TERM -while :; do sleep 1; done -`, - ); - await chmod(path.join(fakeBinDir, "pnpm"), 0o755); - - const child = spawn( - process.execPath, - ["apps/web/scripts/check-standalone-deployment.ts"], - { cwd: targetDir, env }, - ); - let deploymentStderr = ""; - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk: string) => { - deploymentStderr += chunk; - }); - const deadline = Date.now() + 5_000; - while (Date.now() < deadline) { - try { - await access(playwrightObservationFile); - break; - } catch { - await new Promise((resolve) => setTimeout(resolve, 20)); - } - } - await expect(access(playwrightObservationFile)).resolves.toBeUndefined(); - child.kill("SIGTERM"); - await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", () => resolve()); - }); - - const dockerCommands = await readFile(dockerObservationFile, "utf8"); - expect(dockerCommands).toContain("rm --force"); - expect(dockerCommands).toContain("volume rm --force"); - expect(dockerCommands).toContain("image rm --force"); - expect(deploymentStderr).toMatch( - /fake container stdout diagnostics[\s\S]*fake container stderr diagnostics/u, - ); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - }); -}); diff --git a/packages/builtin-source/templates/vike-app/projection.ts b/packages/builtin-source/templates/vike-app/projection.ts deleted file mode 100644 index 1c6c88a..0000000 --- a/packages/builtin-source/templates/vike-app/projection.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetProjectionDeclaration, -} from "@ykdz/template-builtin-source"; -import type { GenerationContext } from "@ykdz/template-core/generation-context"; -import type { - PresetBlueprintOptions, - PresetProjection, - PresetProjectionPlan, -} from "@ykdz/template-core/preset-projection"; -import { interpretPresetProjectionDeclaration } from "@ykdz/template-core/projection-capabilities"; -import { renderNewProject } from "@ykdz/template-core/renderer"; -import { - PackageAdditionSupport, - type BuiltInPreset, - type ProjectBlueprint, -} from "@ykdz/template-shared"; - -export const vikeAppPresetMetadata: BuiltInPreset = { - name: "vike-app", - title: "Vike app", - description: - "Vike, Hono, Telefunc, Drizzle, and Vue workspace with separate database and migration packages.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Unsupported, - features: [ - "pnpm-catalog", - "oxc-format-lint", - "strict-typescript", - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot", - ], -}; - -function projectNameFromDir(targetDir: string): string { - return targetDir.split(/[\\/]/).filter(Boolean).at(-1) ?? "vike-app"; -} - -function scopedPackageName(packageScope: string): string { - return `@${packageScope}/web`; -} - -function scopedDbPackageName(packageScope: string): string { - return `@${packageScope}/db`; -} - -function scopedDbMigrationsPackageName(packageScope: string): string { - return `@${packageScope}/db-migrations`; -} - -export function vikeAppBlueprint( - options: PresetBlueprintOptions = { targetDir: process.cwd() }, -): ProjectBlueprint { - const projectName = projectNameFromDir(options.targetDir); - - return { - schemaVersion: 1, - preset: "vike-app", - packageManager: "pnpm", - projectKind: "multi-package", - features: [...vikeAppPresetMetadata.features], - packages: [ - { name: scopedPackageName(projectName), path: "apps/web" }, - { - name: scopedDbPackageName(projectName), - path: "packages/db", - role: "shared-library", - sourcePreset: "ts-lib", - }, - { - name: scopedDbMigrationsPackageName(projectName), - path: "packages/db-migrations", - role: "shared-library", - sourcePreset: "ts-lib", - }, - ], - }; -} - -export const vikeAppPresetProjection: PresetProjection = { - metadata: vikeAppPresetMetadata, - blueprint: vikeAppBlueprint, - project(context: GenerationContext): PresetProjectionPlan { - return interpretPresetProjectionDeclaration({ - preset: vikeAppPresetMetadata, - declaration: loadBuiltInPresetProjectionDeclaration("vike-app"), - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - }, - async render({ targetDir, plan }): Promise { - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - }, -}; diff --git a/packages/builtin-source/templates/vue-app/behavior.test.ts b/packages/builtin-source/templates/vue-app/behavior.test.ts deleted file mode 100644 index 55564d5..0000000 --- a/packages/builtin-source/templates/vue-app/behavior.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { mkdtemp, readdir, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { loadTemplateDependencyCatalog } from "@ykdz/template-core/dependency-catalog"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import * as v from "valibot"; -import { describe, expect, it } from "vitest"; - -import { vueAppPresetProjection } from "./projection.ts"; - -const playwrightCliPackage = `@playwright/test@${ - loadTemplateDependencyCatalog()["@playwright/test"] -}`; -const devcontainerSchema = v.looseObject({ - build: v.object({ - args: v.record(v.string(), v.string()), - dockerfile: v.string(), - }), - customizations: v.object({ - vscode: v.object({ - extensions: v.array(v.string()), - settings: v.record(v.string(), v.unknown()), - }), - }), - features: v.optional(v.unknown()), - name: v.string(), -}); -const packageJsonSchema = v.looseObject({ - name: v.string(), - exports: v.optional(v.unknown()), - packageManager: v.optional(v.string()), - dependencies: v.optional(v.record(v.string(), v.string())), - devDependencies: v.optional(v.record(v.string(), v.string())), - scripts: v.record(v.string(), v.string()), -}); - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -async function generatedFilePaths( - root: string, - current = ".", -): Promise { - const entries = await readdir(path.join(root, current), { - withFileTypes: true, - }); - const paths = await Promise.all( - entries.map(async (entry) => { - const relativePath = path.join(current, entry.name); - if (entry.isDirectory()) { - return generatedFilePaths(root, relativePath); - } - - return [relativePath.replaceAll(path.sep, "/")]; - }), - ); - - return paths.flat().toSorted(); -} - -describe("vue-app Preset Source behavior", () => { - async function renderVueAppProject(): Promise { - const targetDir = await mkdtemp( - path.join(tmpdir(), "template-vue-app-behavior-"), - ); - const blueprint = vueAppPresetProjection.blueprint({ targetDir }); - const context = assembleGenerationContext({ - blueprint, - targetDir, - toolchain: { - diagnostics: [], - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@11.2.3", - }, - source: "online", - }, - }); - - const plan = vueAppPresetProjection.project(context); - await vueAppPresetProjection.render({ plan, targetDir }); - - return targetDir; - } - - it("projects browser-check development container behavior without projecting Preset Source Tests", async () => { - const targetDir = await renderVueAppProject(); - const devcontainerText = await readFile( - path.join(targetDir, ".devcontainer/devcontainer.json"), - "utf8", - ); - const devcontainerRaw = JSON.parse(devcontainerText) as Record< - string, - unknown - >; - const devcontainer = await readJsonWithSchema( - path.join(targetDir, ".devcontainer/devcontainer.json"), - devcontainerSchema, - ); - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - const files = await generatedFilePaths(targetDir); - - expect(Object.keys(devcontainerRaw)).toEqual([ - "name", - "build", - "customizations", - ]); - expect(devcontainer.build).toEqual({ - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@11.2.3", - PLAYWRIGHT_CLI_PACKAGE: playwrightCliPackage, - }, - }); - expect(devcontainer).not.toHaveProperty("features"); - expect(devcontainer.customizations.vscode.extensions).toContain( - "Vue.volar", - ); - expect(devcontainer.customizations.vscode.settings).toHaveProperty( - "oxc.enable", - true, - ); - expect(devcontainerText).toMatch( - /^\{\n "name": "template-vue-app-behavior-[\w-]+",\n "build": \{/, - ); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain("ARG PLAYWRIGHT_CLI_PACKAGE"); - expect(dockerfile).toContain( - 'npx --yes --package "${PLAYWRIGHT_CLI_PACKAGE}" playwright install-deps chromium', - ); - expect(dockerfile).not.toContain( - "npx --yes playwright install-deps chromium", - ); - expect(dockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(dockerfile).not.toContain("libnss3"); - expect(dockerfile).not.toContain("libgbm1"); - expect(dockerfile).not.toContain("xvfb"); - expect(dockerfile).not.toContain("npm install -g"); - expect(dockerfile).not.toContain("shellcheck"); - expect(files).not.toContain("behavior.test.ts"); - expect(files).not.toContain("apps/web/behavior.test.ts"); - }); - - it("projects a Vue workspace app with browser test package ownership", async () => { - const targetDir = await renderVueAppProject(); - const rootPackageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const webPackageJson = await readJsonWithSchema( - path.join(targetDir, "apps/web/package.json"), - packageJsonSchema, - ); - const workspaceYaml = await readFile( - path.join(targetDir, "pnpm-workspace.yaml"), - "utf8", - ); - const rootTsconfig = await readJsonWithSchema( - path.join(targetDir, "tsconfig.json"), - v.object({ - files: v.array(v.string()), - references: v.array(v.object({ path: v.string() })), - }), - ); - const appSource = await readFile( - path.join(targetDir, "apps/web/src/App.vue"), - "utf8", - ); - const files = await generatedFilePaths(targetDir); - - expect(rootPackageJson.name).toMatch(/^template-vue-app-behavior-/); - expect(rootPackageJson).not.toHaveProperty("exports"); - expect(rootPackageJson.devDependencies).toEqual({ - "@types/node": "catalog:", - "@types/semver": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - semver: "catalog:", - turbo: "catalog:", - "typescript-7": "catalog:", - }); - expect(rootPackageJson.scripts.check).toContain("turbo run"); - expect(rootPackageJson.scripts.fix).toContain("turbo run"); - - expect(webPackageJson.name).toMatch( - /^@template-vue-app-behavior-[\w-]+\/web$/, - ); - expect(webPackageJson).not.toHaveProperty("packageManager"); - expect(webPackageJson.scripts).not.toHaveProperty("check"); - expect(webPackageJson.scripts["test:e2e:run"]).toBe( - "node scripts/run-playwright.ts", - ); - expect(webPackageJson.scripts["typecheck:run"]).toBe( - "node scripts/run-vue-tsc.ts --build --noEmit --pretty false", - ); - expect(webPackageJson.dependencies).toMatchObject({ - pinia: "catalog:", - vue: "catalog:", - }); - expect(webPackageJson.devDependencies).toMatchObject({ - "@playwright/test": "catalog:", - "@types/web-bluetooth": "catalog:", - "@vitejs/plugin-vue": "catalog:", - "@vue/tsconfig": "catalog:", - typescript: "catalog:", - vite: "catalog:", - vitest: "catalog:", - }); - expect(webPackageJson.imports).toEqual({ - "#/*": { - default: "./src/*.ts", - types: "./src/*.ts", - }, - }); - expect(webPackageJson.dependencies).not.toHaveProperty("vue-router"); - expect(webPackageJson.dependencies).not.toHaveProperty("shadcn-vue"); - expect(workspaceYaml).toContain("packages:\n - apps/*\n"); - expect(workspaceYaml).toContain("allowBuilds:\n esbuild: true\n"); - expect(workspaceYaml).toContain('"@playwright/test":'); - expect(workspaceYaml).toContain( - "typescript: npm:@typescript/typescript6@^6.0.2", - ); - expect(workspaceYaml).toContain("typescript-7: npm:typescript@^7.0.2"); - expect(workspaceYaml).toContain("vue:"); - expect(rootTsconfig.files).toEqual([]); - expect(rootTsconfig.references).toEqual([ - { path: "./apps/web/tsconfig.app.json" }, - { path: "./apps/web/tsconfig.test.json" }, - { path: "./apps/web/tsconfig.node.json" }, - ]); - expect(appSource).toContain('from "#/stores/counter"'); - expect(files).toContain("apps/web/scripts/run-vue-tsc.ts"); - }); -}); diff --git a/packages/builtin-source/templates/vue-app/projection.ts b/packages/builtin-source/templates/vue-app/projection.ts deleted file mode 100644 index 6c78821..0000000 --- a/packages/builtin-source/templates/vue-app/projection.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetProjectionDeclaration, -} from "@ykdz/template-builtin-source"; -import type { GenerationContext } from "@ykdz/template-core/generation-context"; -import type { - PresetPackageAdditionOptions, - PresetPackageAdditionPlan, - PresetBlueprintOptions, - PresetProjection, - PresetProjectionPlan, -} from "@ykdz/template-core/preset-projection"; -import { interpretPresetProjectionDeclaration } from "@ykdz/template-core/projection-capabilities"; -import { - renderNewProject, - type RenderOperation, -} from "@ykdz/template-core/renderer"; -import { - PackageAdditionSupport, - type BuiltInPreset, - type ProjectBlueprint, -} from "@ykdz/template-shared"; - -export const vueAppPresetMetadata: BuiltInPreset = { - name: "vue-app", - title: "Vue app", - description: - "Vue app workspace with Vite, Tailwind, Pinia, and test tooling.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Supported, - features: [ - "pnpm-catalog", - "oxc-format-lint", - "strict-typescript", - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot", - ], -}; - -function projectNameFromDir(targetDir: string): string { - return path.basename(path.resolve(targetDir)); -} - -function packageScopeFromOptions(options: PresetBlueprintOptions): string { - return options.scope ?? projectNameFromDir(options.targetDir); -} - -function scopedPackageName(packageScope: string): string { - return `@${packageScope}/web`; -} - -export function vueAppBlueprint( - options: PresetBlueprintOptions = { targetDir: process.cwd() }, -): ProjectBlueprint { - const packageScope = packageScopeFromOptions(options); - - return { - schemaVersion: 1, - preset: "vue-app", - packageManager: "pnpm", - projectKind: "multi-package", - features: [...vueAppPresetMetadata.features], - packages: [{ name: scopedPackageName(packageScope), path: "apps/web" }], - }; -} - -export function projectVueAppPackageScripts(): Record { - return { - "build:run": "vite build", - dev: "vite", - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:run": - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "lint:fix:run": - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - preview: "vite preview", - "test:run": "vitest run --reporter=agent --silent=passed-only", - "test:e2e:run": "node scripts/run-playwright.ts", - "typecheck:run": - "node scripts/run-vue-tsc.ts --build --noEmit --pretty false", - }; -} - -function packageAdditionOperations( - packagePath: string, - packageNameValue: string, - nodeVersion: string, -): RenderOperation[] { - return [ - { - kind: "writeJson", - to: `${packagePath}/package.json`, - value: { - name: packageNameValue, - version: "0.0.0", - private: true, - type: "module", - imports: { - "#/*": { - default: "./src/*.ts", - types: "./src/*.ts", - }, - }, - scripts: projectVueAppPackageScripts(), - dependencies: { - pinia: "catalog:", - vue: "catalog:", - }, - devDependencies: { - "@playwright/test": "catalog:", - "@tailwindcss/vite": "catalog:", - "@types/node": "catalog:", - "@types/web-bluetooth": "catalog:", - "@vitejs/plugin-vue": "catalog:", - "@vue/tsconfig": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - tailwindcss: "catalog:", - typescript: "catalog:", - vite: "catalog:", - vitest: "catalog:", - "vue-tsc": "catalog:", - }, - engines: { - node: nodeVersion, - }, - }, - }, - { - kind: "writeJson", - to: `${packagePath}/tsconfig.json`, - value: { - files: [], - references: [ - { path: "./tsconfig.app.json" }, - { path: "./tsconfig.test.json" }, - { path: "./tsconfig.node.json" }, - ], - }, - }, - { - kind: "writeJson", - to: `${packagePath}/tsconfig.app.json`, - value: { - extends: "@vue/tsconfig/tsconfig.dom.json", - compilerOptions: { - composite: true, - erasableSyntaxOnly: true, - exactOptionalPropertyTypes: true, - forceConsistentCasingInFileNames: true, - isolatedModules: true, - module: "esnext", - moduleResolution: "bundler", - noEmitOnError: true, - noFallthroughCasesInSwitch: true, - noImplicitOverride: true, - noImplicitReturns: true, - noUncheckedIndexedAccess: true, - rewriteRelativeImportExtensions: false, - skipLibCheck: false, - strict: true, - target: "es2023", - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - types: ["web-bluetooth"], - verbatimModuleSyntax: true, - }, - include: ["env.d.ts", "src/**/*.ts", "src/**/*.vue"], - }, - }, - { - kind: "writeJson", - to: `${packagePath}/tsconfig.test.json`, - value: { - extends: "./tsconfig.app.json", - compilerOptions: { - lib: ["ESNext", "DOM", "DOM.Iterable"], - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.test.tsbuildinfo", - types: ["node", "vitest/globals", "web-bluetooth"], - }, - include: ["env.d.ts", "src/**/*.ts", "src/**/*.vue", "test/**/*.ts"], - }, - }, - { - kind: "writeJson", - to: `${packagePath}/tsconfig.node.json`, - multilineArrays: ["include"], - value: { - compilerOptions: { - composite: true, - erasableSyntaxOnly: true, - exactOptionalPropertyTypes: true, - forceConsistentCasingInFileNames: true, - isolatedModules: true, - module: "esnext", - moduleResolution: "bundler", - noEmitOnError: true, - lib: ["ESNext", "DOM", "DOM.Iterable"], - noFallthroughCasesInSwitch: true, - noImplicitOverride: true, - noImplicitReturns: true, - noUncheckedIndexedAccess: true, - rewriteRelativeImportExtensions: false, - skipLibCheck: false, - strict: true, - target: "es2023", - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - types: ["node"], - verbatimModuleSyntax: true, - }, - include: [ - "playwright.config.ts", - "scripts/**/*.ts", - "vite.config.ts", - "vitest.config.ts", - ], - }, - }, - { kind: "copyFile", from: "env.d.ts", to: `${packagePath}/env.d.ts` }, - { kind: "copyFile", from: "index.html", to: `${packagePath}/index.html` }, - { - kind: "copyFile", - from: "playwright.config.ts", - to: `${packagePath}/playwright.config.ts`, - }, - { - kind: "copyFile", - from: "vite.config.ts", - to: `${packagePath}/vite.config.ts`, - }, - { - kind: "copyFile", - from: "vitest.config.ts", - to: `${packagePath}/vitest.config.ts`, - }, - { - kind: "copyFile", - from: "run-vue-tsc.ts", - to: `${packagePath}/scripts/run-vue-tsc.ts`, - sourceRoot: "sharedTypescript", - }, - { kind: "copyFile", from: "src/App.vue", to: `${packagePath}/src/App.vue` }, - { kind: "copyFile", from: "src/main.ts", to: `${packagePath}/src/main.ts` }, - { - kind: "copyFile", - from: "src/style.css", - to: `${packagePath}/src/style.css`, - }, - { - kind: "copyFile", - from: "src/stores/counter.ts", - to: `${packagePath}/src/stores/counter.ts`, - }, - { - kind: "copyFile", - from: "test/app.test.ts", - to: `${packagePath}/test/app.test.ts`, - }, - { - kind: "copyFile", - from: "test/e2e/app.spec.ts", - to: `${packagePath}/test/e2e/app.spec.ts`, - }, - ]; -} - -async function packageAdditionPlan({ - packageName: packageNameValue, - packagePath, - nodeVersion, -}: PresetPackageAdditionOptions): Promise { - const [workspaceCollection] = packagePath.split("/"); - - return { - packagePath, - workspacePackageGlob: `${workspaceCollection}/*`, - packageRole: "runtime-service", - packageSourcePreset: "vue-app", - sourceRoot: templateSourceRoot(), - sourceRoots: { - sharedOxc: sharedOxcSourceRoot(), - sharedTypescript: sharedTypeScriptSourceRoot(), - }, - operations: packageAdditionOperations( - packagePath, - packageNameValue, - nodeVersion, - ), - }; -} - -function templateSourceRoot(): string { - const projectionDir = path.dirname(fileURLToPath(import.meta.url)); - const publishedTemplateRoot = path.join( - projectionDir, - "..", - "..", - "..", - "templates", - "vue-app", - ); - - return existsSync(path.join(publishedTemplateRoot, "src", "App.vue")) - ? publishedTemplateRoot - : projectionDir; -} - -function sharedOxcSourceRoot(): string { - const projectionDir = path.dirname(fileURLToPath(import.meta.url)); - const publishedSharedRoot = path.join( - projectionDir, - "..", - "..", - "..", - "templates", - "shared", - "oxc", - ); - - return existsSync(path.join(publishedSharedRoot, "oxfmt.config.ts")) - ? publishedSharedRoot - : path.join(projectionDir, "..", "shared", "oxc"); -} - -function sharedTypeScriptSourceRoot(): string { - const projectionDir = path.dirname(fileURLToPath(import.meta.url)); - const publishedSharedRoot = path.join( - projectionDir, - "..", - "..", - "..", - "templates", - "shared", - "typescript", - ); - - return existsSync(path.join(publishedSharedRoot, "run-vue-tsc.ts")) - ? publishedSharedRoot - : path.join(projectionDir, "..", "shared", "typescript"); -} - -export const vueAppPresetProjection: PresetProjection = { - metadata: vueAppPresetMetadata, - capabilities: { - packageAddition: { - planPackageAddition: packageAdditionPlan, - }, - }, - blueprint: vueAppBlueprint, - project(context: GenerationContext): PresetProjectionPlan { - return interpretPresetProjectionDeclaration({ - preset: vueAppPresetMetadata, - declaration: loadBuiltInPresetProjectionDeclaration("vue-app"), - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - }, - async render({ targetDir, plan }): Promise { - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - }, -}; diff --git a/packages/builtin-source/templates/vue-hono-app/behavior.test.ts b/packages/builtin-source/templates/vue-hono-app/behavior.test.ts deleted file mode 100644 index e58a336..0000000 --- a/packages/builtin-source/templates/vue-hono-app/behavior.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { mkdtemp, readFile, readdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { loadTemplateDependencyCatalog } from "@ykdz/template-core/dependency-catalog"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import * as v from "valibot"; -import { describe, expect, it } from "vitest"; - -import { vueHonoAppPresetProjection } from "./projection.ts"; - -const playwrightCliPackage = `@playwright/test@${ - loadTemplateDependencyCatalog()["@playwright/test"] -}`; -const packageJsonSchema = v.looseObject({ - name: v.string(), - engines: v.object({ node: v.string() }), - packageManager: v.optional(v.string()), - dependencies: v.optional(v.record(v.string(), v.string())), - devDependencies: v.optional(v.record(v.string(), v.string())), - scripts: v.record(v.string(), v.string()), -}); -const devcontainerSchema = v.looseObject({ - build: v.object({ - dockerfile: v.string(), - args: v.record(v.string(), v.string()), - }), - customizations: v.object({ - vscode: v.object({ - extensions: v.array(v.string()), - settings: v.record(v.string(), v.unknown()), - }), - }), - features: v.optional(v.unknown()), - postCreateCommand: v.optional(v.string()), -}); - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -async function generatedFilePaths( - root: string, - current = ".", -): Promise { - const entries = await readdir(path.join(root, current), { - withFileTypes: true, - }); - const paths = await Promise.all( - entries.map(async (entry) => { - const relativePath = path.join(current, entry.name); - if (entry.isDirectory()) { - return generatedFilePaths(root, relativePath); - } - - return [relativePath.replaceAll(path.sep, "/")]; - }), - ); - - return paths.flat().toSorted(); -} - -async function renderVueHonoProject(): Promise { - const workspace = await mkdtemp(path.join(tmpdir(), "vue-hono-behavior-")); - const targetDir = path.join(workspace, "demo-stack"); - const blueprint = vueHonoAppPresetProjection.blueprint({ - targetDir, - scope: "acme", - }); - const context = assembleGenerationContext({ - blueprint, - targetDir, - toolchain: { - diagnostics: [], - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - }, - }); - - const plan = vueHonoAppPresetProjection.project(context); - await vueHonoAppPresetProjection.render({ plan, targetDir }); - - return targetDir; -} - -describe("vue-hono-app Preset Source behavior", () => { - it("projects separated API and web packages with browser-scoped checks", async () => { - const targetDir = await renderVueHonoProject(); - const rootPackageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const apiPackageJson = await readJsonWithSchema( - path.join(targetDir, "apps/api/package.json"), - packageJsonSchema, - ); - const webPackageJson = await readJsonWithSchema( - path.join(targetDir, "apps/web/package.json"), - packageJsonSchema, - ); - const devcontainerText = await readFile( - path.join(targetDir, ".devcontainer/devcontainer.json"), - "utf8", - ); - const devcontainer = v.parse( - devcontainerSchema, - JSON.parse(devcontainerText) as unknown, - ); - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - const checkWorkflow = await readFile( - path.join(targetDir, ".github/workflows/check.yml"), - "utf8", - ); - const workspaceYaml = await readFile( - path.join(targetDir, "pnpm-workspace.yaml"), - "utf8", - ); - const files = await generatedFilePaths(targetDir); - const apiServerSource = await readFile( - path.join(targetDir, "apps/api/src/server.ts"), - "utf8", - ); - const apiTsconfig = await readJsonWithSchema( - path.join(targetDir, "apps/api/tsconfig.json"), - v.object({ - compilerOptions: v.record(v.string(), v.unknown()), - include: v.array(v.string()), - }), - ); - - expect(rootPackageJson).toMatchObject({ - name: "demo-stack", - engines: { node: "24" }, - packageManager: "pnpm@11.2.3", - }); - expect(rootPackageJson.scripts).toMatchObject({ - check: - "pnpm run check:boundaries && turbo run format:check:run lint:run typecheck:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - dev: "turbo run dev --parallel", - }); - expect(rootPackageJson.devDependencies?.turbo).toBe("catalog:"); - expect(rootPackageJson.devDependencies).toHaveProperty( - "typescript-7", - "catalog:", - ); - expect(rootPackageJson.devDependencies).not.toHaveProperty("typescript"); - expect(workspaceYaml).toContain("packages:\n - apps/*\n"); - expect(workspaceYaml).toContain("allowBuilds:\n esbuild: true\n"); - expect(workspaceYaml).toContain('"@hono/node-server":'); - expect(workspaceYaml).toContain('"@playwright/test":'); - expect(workspaceYaml).toContain("vue:"); - expect(apiPackageJson).toMatchObject({ - name: "@acme/api", - engines: { node: "24" }, - }); - expect(apiPackageJson).not.toHaveProperty("packageManager"); - expect(apiPackageJson.scripts).not.toHaveProperty("check"); - expect(apiPackageJson.scripts.dev).toBe("node --watch src/server.ts"); - expect(apiPackageJson.devDependencies).toHaveProperty( - "typescript-7", - "catalog:", - ); - expect(apiPackageJson.devDependencies).not.toHaveProperty("typescript"); - expect(apiPackageJson.devDependencies).not.toHaveProperty("tsx"); - expect(apiServerSource).toContain('from "./runtime.ts"'); - expect(apiServerSource).not.toContain('from "./runtime.js"'); - expect(apiTsconfig.compilerOptions).toHaveProperty( - "rewriteRelativeImportExtensions", - true, - ); - expect(apiPackageJson.scripts["build:run"]).toBe( - "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", - ); - expect(webPackageJson).toMatchObject({ - name: "@acme/web", - engines: { node: "24" }, - }); - expect(webPackageJson).not.toHaveProperty("packageManager"); - expect(webPackageJson.scripts).not.toHaveProperty("check"); - expect(webPackageJson.scripts["test:e2e:run"]).toBe( - "node scripts/run-playwright.ts", - ); - expect(webPackageJson.scripts["typecheck:run"]).toBe( - "node scripts/run-vue-tsc.ts --build --pretty false", - ); - expect(webPackageJson.devDependencies).toHaveProperty( - "typescript", - "catalog:", - ); - expect(webPackageJson.devDependencies).not.toHaveProperty("typescript-7"); - - expect(Object.keys(devcontainer).toSorted()).toEqual([ - "build", - "customizations", - "name", - ]); - expect(devcontainer.build).toEqual({ - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@11.2.3", - PLAYWRIGHT_CLI_PACKAGE: playwrightCliPackage, - }, - }); - expect(devcontainer).not.toHaveProperty("features"); - expect(devcontainer).not.toHaveProperty("postCreateCommand"); - expect(devcontainer.customizations.vscode.extensions).toContain( - "Vue.volar", - ); - expect(devcontainer.customizations.vscode.settings).toHaveProperty( - "oxc.configPath", - "./oxlint.config.ts", - ); - expect(devcontainerText).toMatch( - /^\{\n "name": "demo-stack",\n "build": \{/, - ); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain("ARG PLAYWRIGHT_CLI_PACKAGE"); - expect(dockerfile).toContain( - 'npx --yes --package "${PLAYWRIGHT_CLI_PACKAGE}" playwright install-deps chromium', - ); - expect(dockerfile).not.toContain( - "npx --yes playwright install-deps chromium", - ); - expect(dockerfile).not.toContain("typescript-node"); - expect(dockerfile).not.toContain("shellcheck"); - expect(dockerfile).not.toMatch(/\b(?:npm|pnpm|corepack)\s+.*-g\s+turbo\b/); - expect(checkWorkflow).toContain( - "pnpm --filter ./apps/web exec playwright install --with-deps chromium", - ); - expect(checkWorkflow).not.toMatch(/\bpnpm exec playwright install\b/); - expect(checkWorkflow).not.toContain("shellcheck"); - expect(files).not.toContain("behavior.test.ts"); - expect(files).not.toContain("apps/api/behavior.test.ts"); - expect(files).not.toContain("apps/web/behavior.test.ts"); - }); -}); diff --git a/packages/builtin-source/templates/vue-hono-app/projection.ts b/packages/builtin-source/templates/vue-hono-app/projection.ts deleted file mode 100644 index f4a2c96..0000000 --- a/packages/builtin-source/templates/vue-hono-app/projection.ts +++ /dev/null @@ -1,203 +0,0 @@ -import path from "node:path"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetProjectionDeclaration, -} from "@ykdz/template-builtin-source"; -import type { GenerationContext } from "@ykdz/template-core/generation-context"; -import { - type CheckPlan, - type ComponentOwner, - type FixPlan, - playwrightBrowserAssetsEnvironmentNeed, - renderFixCommand, - renderRootCheckCommand, - renderTurboRunCommand, -} from "@ykdz/template-core/module-graph"; -import type { - PresetBlueprintOptions, - PresetProjection, - PresetProjectionPlan, -} from "@ykdz/template-core/preset-projection"; -import { interpretPresetProjectionDeclaration } from "@ykdz/template-core/projection-capabilities"; -import { renderNewProject } from "@ykdz/template-core/renderer"; -import { - PackageAdditionSupport, - type BuiltInPreset, - type ProjectBlueprint, -} from "@ykdz/template-shared"; - -export const vueHonoAppPresetMetadata: BuiltInPreset = { - name: "vue-hono-app", - title: "Vue Hono app", - description: - "Full-stack Vue and Hono workspace with separated app package boundaries.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Unsupported, - features: [ - "pnpm-catalog", - "oxc-format-lint", - "strict-typescript", - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot", - ], -}; - -const rootBoundary: ComponentOwner = { - kind: "workspace-orchestration", - path: ".", -}; - -const workspacePackageBoundary: ComponentOwner = { - kind: "package-boundary", - path: "apps/*", -}; - -const webWorkspaceBoundary: ComponentOwner = { - kind: "package-boundary", - path: "apps/web", -}; - -function planVueHonoRootChecks(): CheckPlan { - return { - components: [ - { kind: "oxc-format-check", owner: rootBoundary }, - { kind: "oxc-lint", owner: rootBoundary }, - { kind: "typescript-typecheck", owner: rootBoundary }, - { kind: "turbo-package-check", owner: workspacePackageBoundary }, - ], - environmentNeeds: [ - playwrightBrowserAssetsEnvironmentNeed({ - browser: "chromium", - owner: webWorkspaceBoundary, - id: "install-web-playwright-browsers", - label: "Install Playwright browser assets for web workspace", - }), - ], - }; -} - -function planVueHonoRootFixes(): FixPlan { - return { - components: [ - { kind: "oxc-format-write", owner: rootBoundary }, - { kind: "oxc-lint-fix", owner: rootBoundary }, - { kind: "turbo-package-fix", owner: workspacePackageBoundary }, - ], - }; -} - -function projectNameFromDir(targetDir: string): string { - return path.basename(path.resolve(targetDir)); -} - -function packageScopeFromOptions(options: PresetBlueprintOptions): string { - return options.scope ?? projectNameFromDir(options.targetDir); -} - -function scopedPackageName(packageScope: string, leaf: "api" | "web"): string { - return `@${packageScope}/${leaf}`; -} - -export function vueHonoAppBlueprint( - options: PresetBlueprintOptions, -): ProjectBlueprint { - const packageScope = packageScopeFromOptions(options); - - return { - schemaVersion: 1, - preset: "vue-hono-app", - packageManager: "pnpm", - projectKind: "multi-package", - features: [...vueHonoAppPresetMetadata.features], - packages: [ - { name: scopedPackageName(packageScope, "web"), path: "apps/web" }, - { name: scopedPackageName(packageScope, "api"), path: "apps/api" }, - ], - }; -} - -export function projectVueHonoRootPackageScripts(): Record { - return { - check: `pnpm run check:boundaries && ${renderRootCheckCommand(planVueHonoRootChecks())}`, - "check:boundaries": "turbo boundaries --no-color", - "check:run": 'node -e ""', - dev: "turbo run dev --parallel", - fix: renderFixCommand(planVueHonoRootFixes()), - "fix:run": 'node -e ""', - "format:check": renderTurboRunCommand(["format:check:run"]), - "format:check:run": - "oxfmt --list-different oxlint.config.ts oxfmt.config.ts", - "format:write": renderTurboRunCommand(["format:write:run"]), - "format:write:run": "oxfmt --write oxlint.config.ts oxfmt.config.ts", - lint: renderTurboRunCommand(["lint:run"]), - "lint:fix": renderTurboRunCommand(["lint:fix:run"]), - "lint:fix:run": - "oxlint --format=unix oxlint.config.ts oxfmt.config.ts --fix", - "lint:run": "oxlint --quiet --format=unix oxlint.config.ts oxfmt.config.ts", - typecheck: renderTurboRunCommand(["typecheck:run"]), - "typecheck:run": "tsc -p tsconfig.config.json --noEmit --pretty false", - }; -} - -export function projectVueHonoApiPackageScripts(): Record { - return { - "build:run": - "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", - dev: "node --watch src/server.ts", - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:run": - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "lint:fix:run": - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - start: "node dist/server.js", - "test:run": "vitest run --reporter=agent --silent=passed-only", - "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", - }; -} - -export function projectVueHonoWebPackageScripts(): Record { - return { - "build:run": "vite build", - dev: "vite", - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:run": - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "lint:fix:run": - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - preview: "vite preview", - "test:run": "vitest run --reporter=agent --silent=passed-only", - "test:e2e:run": "node scripts/run-playwright.ts", - "typecheck:run": "node scripts/run-vue-tsc.ts --build --pretty false", - }; -} - -export const vueHonoAppPresetProjection: PresetProjection = { - metadata: vueHonoAppPresetMetadata, - blueprint: vueHonoAppBlueprint, - project(context: GenerationContext): PresetProjectionPlan { - return interpretPresetProjectionDeclaration({ - preset: vueHonoAppPresetMetadata, - declaration: loadBuiltInPresetProjectionDeclaration("vue-hono-app"), - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - }, - async render({ targetDir, plan }): Promise { - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - }, -}; diff --git a/packages/builtin-source/templates/vue-hono-app/web/src/main.ts b/packages/builtin-source/templates/vue-hono-app/web/src/main.ts deleted file mode 100644 index 29bd250..0000000 --- a/packages/builtin-source/templates/vue-hono-app/web/src/main.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createPinia } from "pinia"; -import { createApp } from "vue"; - -import App from "./App.vue"; - -import "./style.css"; - -createApp(App).use(createPinia()).mount("#app"); diff --git a/packages/builtin-source/templates/vue-hono-app/web/src/stores/counter.ts b/packages/builtin-source/templates/vue-hono-app/web/src/stores/counter.ts deleted file mode 100644 index 39caa3b..0000000 --- a/packages/builtin-source/templates/vue-hono-app/web/src/stores/counter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineStore } from "pinia"; -import { ref } from "vue"; - -export const useCounterStore = defineStore("counter", () => { - const count = ref(0); - - function increment(): void { - count.value += 1; - } - - return { count, increment }; -}); diff --git a/packages/builtin-source/templates/vue-hono-app/web/src/style.css b/packages/builtin-source/templates/vue-hono-app/web/src/style.css deleted file mode 100644 index f1d8c73..0000000 --- a/packages/builtin-source/templates/vue-hono-app/web/src/style.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/packages/builtin-source/templates/vue-hono-app/web/test/app.test.ts b/packages/builtin-source/templates/vue-hono-app/web/test/app.test.ts deleted file mode 100644 index a347bdc..0000000 --- a/packages/builtin-source/templates/vue-hono-app/web/test/app.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createPinia, setActivePinia } from "pinia"; - -import { useCounterStore } from "#/stores/counter"; - -describe("counter store", () => { - it("increments the count", () => { - setActivePinia(createPinia()); - const counter = useCounterStore(); - - counter.increment(); - - expect(counter.count).toBe(1); - }); -}); diff --git a/packages/builtin-source/tsconfig.json b/packages/builtin-source/tsconfig.json deleted file mode 100644 index 50213ff..0000000 --- a/packages/builtin-source/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "../..", - "types": ["node"] - }, - "include": [ - "src/**/*.ts", - "templates/*/behavior.test.ts", - "templates/*/projection.ts", - "templates/projection-plans.ts", - "templates/registry.ts" - ] -} diff --git a/packages/builtin-source/turbo.json b/packages/builtin-source/turbo.json deleted file mode 100644 index 19825cc..0000000 --- a/packages/builtin-source/turbo.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": ["//"], - "tags": ["template-preset-source"] -} diff --git a/packages/checks/package.json b/packages/checks/package.json index 065efa6..c241e0c 100644 --- a/packages/checks/package.json +++ b/packages/checks/package.json @@ -10,11 +10,12 @@ }, "scripts": { "build": "rm -rf dist && tsc -p tsconfig.build.json", - "check:fixtures": "node src/check-fixtures.ts", - "check:generated": "node src/check-generated.ts", - "check:templates": "pnpm run check:templates:github-yaml && pnpm run check:templates:boundary", - "check:templates:boundary": "node src/check-template-boundary.ts", - "check:templates:github-yaml": "node src/check-template-github-yaml.ts", + "check:fixtures": "node src/check-generated-registry.ts package-addition-matrix", + "check:generated": "node src/check-generated-registry.ts init", + "check:focused": "node src/check-generated-registry.ts focused", + "check:deployment": "node src/check-generated-registry.ts deployment", + "check:templates": "pnpm --filter @ykdz/template-builtin-presets run check:templates && node src/check-legacy-architecture-removal.ts --packed", + "check:templates:github-yaml": "node src/check-builtin-preset-github-yaml.ts", "check:toolchain:online": "node src/check-online-toolchain-resolution-contract.ts", "update:toolchain": "node src/update-toolchain-baseline.ts", "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", @@ -24,9 +25,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" }, "dependencies": { - "@ykdz/template-builtin-source": "workspace:*", + "@ykdz/template-builtin-presets": "workspace:*", "@ykdz/template-core": "workspace:*", - "@ykdz/template-shared": "workspace:*", "execa": "catalog:", "yaml": "catalog:" }, @@ -35,6 +35,12 @@ "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", + "typescript": "catalog:", "typescript-7": "catalog:" + }, + "turbo": { + "tags": [ + "template-checks" + ] } } diff --git a/packages/checks/src/check-builtin-preset-github-yaml.ts b/packages/checks/src/check-builtin-preset-github-yaml.ts new file mode 100644 index 0000000..15847b1 --- /dev/null +++ b/packages/checks/src/check-builtin-preset-github-yaml.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + builtInPresetRegistry, + createGenerationContext, + planGeneratedRepositoryInitialization, + resolveBuiltInTemplateSource, + type GeneratedRepositoryPlan, +} from "@ykdz/template-builtin-presets"; +import { + projectCheckWorkflow, + projectDependabotConfig, +} from "@ykdz/template-core/project-github"; +import { parseDocument } from "yaml"; + +type GithubTemplateKind = "workflow" | "dependabot"; +type SourceBackedOperation = Extract< + GeneratedRepositoryPlan["operations"][number], + { kind: "copyFile" | "writeTextTemplate" } +>; + +function isGithubTemplateOperation( + operation: GeneratedRepositoryPlan["operations"][number], + generatedPath: string, +): operation is SourceBackedOperation { + return ( + (operation.kind === "copyFile" || operation.kind === "writeTextTemplate") && + operation.to === generatedPath + ); +} + +function sourceForGithubTemplate( + plan: GeneratedRepositoryPlan, + kind: GithubTemplateKind, +): { + readonly filePath: string; + readonly replacements: Record; +} { + const generatedPath = + kind === "workflow" + ? ".github/workflows/check.yml" + : ".github/dependabot.yml"; + const operation = plan.operations.find((candidate) => + isGithubTemplateOperation(candidate, generatedPath), + ); + + if (operation === undefined) { + throw new Error( + `${plan.definitionName}: missing Foundation-composed ${generatedPath} Template Source`, + ); + } + + return { + filePath: + operation.source === undefined + ? (() => { + throw new Error( + `${plan.definitionName}: Foundation ${generatedPath} is missing its owned Template Source handle`, + ); + })() + : resolveBuiltInTemplateSource(operation.source, operation.from), + replacements: + operation.kind === "writeTextTemplate" ? operation.replacements : {}, + }; +} + +function renderTemplate( + source: string, + replacements: Record, +): string { + const used = new Set(); + const rendered = source.replaceAll( + /\{\{([A-Za-z][A-Za-z0-9_]*)\}\}/g, + (_placeholder, name: string) => { + const replacement = replacements[name]; + if (replacement === undefined) { + throw new Error(`Missing Template Source replacement: ${name}`); + } + used.add(name); + return replacement; + }, + ); + + for (const name of Object.keys(replacements)) { + if (!used.has(name)) { + throw new Error(`Unused Template Source replacement: ${name}`); + } + } + + return rendered; +} + +function expectedGithubTemplate( + plan: GeneratedRepositoryPlan, + kind: GithubTemplateKind, +): string { + if (kind === "dependabot") { + return projectDependabotConfig(plan.dependencyMaintenancePolicy); + } + + return projectCheckWorkflow({ + checkPlan: { + components: [...plan.checks], + environmentNeeds: [...plan.environmentNeeds], + deploymentChecks: [...plan.deploymentChecks], + }, + }); +} + +export async function checkBuiltInPresetGithubYaml(): Promise { + for (const definition of builtInPresetRegistry.all()) { + const plan = planGeneratedRepositoryInitialization({ + definition, + context: createGenerationContext({ + targetDir: path.join("generated-repository", definition.metadata.name), + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }), + }); + + for (const kind of ["workflow", "dependabot"] as const) { + const source = sourceForGithubTemplate(plan, kind); + const rendered = renderTemplate( + await readFile(source.filePath, "utf8"), + source.replacements, + ); + const document = parseDocument(rendered); + if (document.errors.length > 0 || document.warnings.length > 0) { + throw new Error( + `${definition.metadata.name}: invalid ${kind} Template Source ${source.filePath}: ${[...document.errors, ...document.warnings].map((error) => error.message).join("; ")}`, + ); + } + if (rendered !== expectedGithubTemplate(plan, kind)) { + throw new Error( + `${definition.metadata.name}: ${kind} Template Source diverges from its Foundation plan\nexpected:\n${expectedGithubTemplate(plan, kind)}\nactual:\n${rendered}`, + ); + } + } + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await checkBuiltInPresetGithubYaml(); +} diff --git a/packages/checks/src/check-fixtures.ts b/packages/checks/src/check-fixtures.ts deleted file mode 100644 index 9640a15..0000000 --- a/packages/checks/src/check-fixtures.ts +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env node -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { runGeneratedScenarioSet } from "@ykdz/template-core/generated-scenarios"; - -import { fixtureReplayCacheFromEnv } from "./fixture-replay-cache.ts"; - -const repoRoot = - process.env.TEMPLATE_REPOSITORY_ROOT ?? - path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); -const cliPath = path.join(repoRoot, "packages", "cli", "src", "cli.ts"); - -async function main(): Promise { - const workspace = await mkdtemp(path.join(tmpdir(), "template-fixtures-")); - let shouldRemoveWorkspace = false; - - try { - await runGeneratedScenarioSet( - loadBuiltInPresetSourceManifest(), - "package-addition-matrix", - workspace, - { - repoRoot, - cliPath, - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - replayCache: fixtureReplayCacheFromEnv(), - }, - ); - - shouldRemoveWorkspace = true; - } finally { - if (shouldRemoveWorkspace) { - await rm(workspace, { recursive: true, force: true }); - } else { - console.error(`Fixture workspace preserved for debugging: ${workspace}`); - } - } -} - -await main(); diff --git a/packages/checks/src/check-generated-registry.ts b/packages/checks/src/check-generated-registry.ts new file mode 100644 index 0000000..1d5601f --- /dev/null +++ b/packages/checks/src/check-generated-registry.ts @@ -0,0 +1,249 @@ +#!/usr/bin/env node +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { + createGenerationContext, + planGeneratedRepositoryInitialization, + planGeneratedRepositoryPackageAddition, + type BuiltInPresetDefinition, + type GeneratedRepositoryPlan, +} from "@ykdz/template-builtin-presets"; +import { + renderNewProject, + renderProjectAtomically, +} from "@ykdz/template-core/renderer"; +import { execa } from "execa"; + +type GeneratedScenarioSet = + | "init" + | "package-addition-matrix" + | "focused" + | "deployment"; + +/** Source-only repository check API; intentionally absent from package exports. */ +type GeneratedScenario = { + readonly id: string; + readonly label: string; + readonly base: BuiltInPresetDefinition; + readonly addition?: BuiltInPresetDefinition; + readonly linkFrom?: readonly string[]; +}; + +type RegistryChecks = { + readonly deriveFixtureMatrix: () => readonly GeneratedScenario[]; + readonly deriveFocusedProjectLinkScenarios: () => readonly GeneratedScenario[]; + readonly deriveInitializationScenarios: () => readonly GeneratedScenario[]; + readonly validatePlanDependencyCatalog: ( + plan: GeneratedRepositoryPlan, + ) => void; + readonly validatePlanSources: (options: { + readonly definition: BuiltInPresetDefinition; + readonly plan: GeneratedRepositoryPlan; + }) => Promise; +}; + +async function sourceOnlyRegistryChecks(): Promise { + const sourcePath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "builtin-presets", + "src", + "registry-checks.ts", + ); + return (await import(pathToFileURL(sourcePath).href)) as RegistryChecks; +} + +type GeneratedScenarioRunOptions = { + readonly workspace: string; + readonly reporter?: { readonly info?: (message: string) => void }; +}; + +type GeneratedCommandRunner = ( + command: string, + args: readonly string[], + options: { readonly cwd: string; readonly stdio?: "inherit" }, +) => Promise; + +export async function generatedScenariosFor( + set: GeneratedScenarioSet, +): Promise { + const checks = await sourceOnlyRegistryChecks(); + switch (set) { + case "init": + return checks.deriveInitializationScenarios(); + case "package-addition-matrix": + return checks.deriveFixtureMatrix(); + case "focused": + return checks.deriveFocusedProjectLinkScenarios(); + case "deployment": + // Deployment is intentionally a separate, Docker-required deep gate. + // It covers every real addition path that can retain deployment checks. + return checks.deriveFixtureMatrix(); + } +} + +async function prepareEnvironment(options: { + readonly plan: ReturnType; + readonly projectDir: string; +}): Promise { + const seen = new Set(); + for (const need of options.plan.environmentNeeds) { + if (!need.nextStep.machineVerifiable || seen.has(need.nextStep.display)) { + continue; + } + seen.add(need.nextStep.display); + await execa(need.nextStep.command, [...need.nextStep.args], { + cwd: options.projectDir, + stdio: "inherit", + }); + } +} + +/** + * A deployment scenario is only successful after its real deployment command + * runs. Docker absence is a hard failure here, while fast scenario sets never + * call this gate and therefore make no deployment-success claim. + */ +export async function runRequiredDeploymentQualityGate(options: { + readonly plan: ReturnType; + readonly projectDir: string; + readonly run?: GeneratedCommandRunner; +}): Promise { + if (options.plan.deploymentChecks.length === 0) return; + const run = + options.run ?? + ((command, args, runOptions) => execa(command, [...args], runOptions)); + try { + await run("docker", ["version", "--format", "{{.Server.Version}}"], { + cwd: options.projectDir, + }); + } catch (error) { + throw new Error( + `Docker is required for the deployment gate (${options.plan.definitionName}); check:deployment was not executed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + await run("pnpm", ["run", "check:deployment"], { + cwd: options.projectDir, + stdio: "inherit", + }); +} + +async function requireDockerForDeploymentGate( + workspace: string, +): Promise { + try { + await execa("docker", ["version", "--format", "{{.Server.Version}}"], { + cwd: workspace, + }); + } catch (error) { + throw new Error( + `Docker is required for the deployment gate; check:deployment was not executed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function runScenario( + scenario: GeneratedScenario, + options: GeneratedScenarioRunOptions, + runDeployment: boolean, + checks: RegistryChecks, +): Promise { + const projectDir = path.join(options.workspace, scenario.id); + const context = createGenerationContext({ + targetDir: projectDir, + scope: "fixture", + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }); + const initialization = planGeneratedRepositoryInitialization({ + definition: scenario.base, + context, + }); + await checks.validatePlanSources({ + definition: scenario.base, + plan: initialization, + }); + checks.validatePlanDependencyCatalog(initialization); + await renderNewProject({ + targetRoot: projectDir, + operations: [...initialization.operations], + }); + + const finalPlan = + scenario.addition === undefined + ? initialization + : planGeneratedRepositoryPackageAddition({ + definition: scenario.addition, + context, + blueprint: initialization.blueprint, + packageLeafName: `fixture-${scenario.addition.metadata.name}`, + ...(scenario.linkFrom === undefined + ? {} + : { linkFrom: scenario.linkFrom }), + }); + if (scenario.addition !== undefined) { + await checks.validatePlanSources({ + definition: scenario.addition, + plan: finalPlan, + }); + checks.validatePlanDependencyCatalog(finalPlan); + await renderProjectAtomically({ + targetRoot: projectDir, + operations: [...finalPlan.operations], + }); + } + + options.reporter?.info?.(`Checking generated scenario ${scenario.label}`); + await execa("pnpm", ["install"], { cwd: projectDir, stdio: "inherit" }); + await prepareEnvironment({ plan: finalPlan, projectDir }); + await execa("pnpm", ["run", "check"], { cwd: projectDir, stdio: "inherit" }); + if (runDeployment) { + await runRequiredDeploymentQualityGate({ + plan: finalPlan, + projectDir, + }); + } +} + +/** Runs registry-derived generated repositories through their production plans. */ +export async function runGeneratedScenarioSet( + set: GeneratedScenarioSet, + options: GeneratedScenarioRunOptions, +): Promise { + if (set === "deployment") { + await requireDockerForDeploymentGate(options.workspace); + } + const checks = await sourceOnlyRegistryChecks(); + for (const scenario of await generatedScenariosFor(set)) { + await runScenario(scenario, options, set === "deployment", checks); + } +} + +async function main(): Promise { + const set = process.argv[2] as GeneratedScenarioSet | undefined; + if ( + set !== "init" && + set !== "package-addition-matrix" && + set !== "focused" && + set !== "deployment" + ) { + throw new Error( + "Expected generated scenario set: init, package-addition-matrix, focused, or deployment", + ); + } + const workspace = await mkdtemp( + path.join(tmpdir(), "template-generated-check-"), + ); + try { + await runGeneratedScenarioSet(set, { workspace, reporter: console }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await main(); +} diff --git a/packages/checks/src/check-generated.ts b/packages/checks/src/check-generated.ts deleted file mode 100644 index dfb4266..0000000 --- a/packages/checks/src/check-generated.ts +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { runGeneratedScenarioSet } from "@ykdz/template-core/generated-scenarios"; - -import { fixtureReplayCacheFromEnv } from "./fixture-replay-cache.ts"; - -const repoRoot = - process.env.TEMPLATE_REPOSITORY_ROOT ?? - path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); -const cliPath = path.join(repoRoot, "packages", "cli", "src", "cli.ts"); - -async function main(): Promise { - const workspace = await mkdtemp(path.join(tmpdir(), "template-generated-")); - let shouldRemoveWorkspace = false; - - try { - await runGeneratedScenarioSet( - loadBuiltInPresetSourceManifest(), - "init", - workspace, - { - repoRoot, - cliPath, - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - replayCache: fixtureReplayCacheFromEnv(), - }, - ); - - shouldRemoveWorkspace = true; - } finally { - if (shouldRemoveWorkspace) { - await rm(workspace, { recursive: true, force: true }); - } else { - console.error( - `Generated check workspace preserved for debugging: ${workspace}`, - ); - } - } -} - -await main(); diff --git a/packages/checks/src/check-legacy-architecture-removal.ts b/packages/checks/src/check-legacy-architecture-removal.ts new file mode 100644 index 0000000..3e286d8 --- /dev/null +++ b/packages/checks/src/check-legacy-architecture-removal.ts @@ -0,0 +1,808 @@ +#!/usr/bin/env node +import { + access, + mkdir, + mkdtemp, + readdir, + readFile, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { execa } from "execa"; +import ts from "typescript"; + +const defaultRepositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); + +/** One precise, independently fixable remnant of the retired architecture. */ +export type LegacyArchitectureFinding = { + readonly rule: string; + readonly file: string; + readonly detail: string; +}; + +const retiredPathParts = [ + "builtin-source", + "preset-source", + "preset-projection", + "projection-capabilities", + "package-addition-support", +] as const; + +const retiredPaths = [ + "packages/builtin-source", + "packages/core/src/preset-source.ts", + "packages/core/src/preset-projection.ts", + "packages/core/src/projection-capabilities.ts", + "packages/core/src/package-addition-support.ts", + "packages/core/src/declarations.ts", + "packages/core/src/generation-context.ts", + "packages/core/src/generated-scenarios.ts", + "packages/core/src/package-addition.ts", + "packages/core/src/package-linking.ts", + "packages/core/src/runtime-paths.ts", + "packages/checks/src/check-fixtures.ts", + "packages/checks/src/check-generated.ts", + "packages/checks/src/fixture-replay-cache.ts", +] as const; + +const retiredSymbols = new Set([ + "PresetSource", + "PresetFile", + "PresetProjection", + "ProjectionDeclaration", + "ProjectionCapability", + "PackageSourcePreset", + "GenerationState", + "SupportedChoice", + "PresetFeature", + "SupportedProjectKind", + "SupportedPackageManager", + "ProjectBlueprintV1", + "BlueprintV1", +]); + +const retiredText = [ + "@ykdz/template-builtin-source", + "Preset Source", + "Preset File", + "Projection Declaration", + "Projection Capability", + "Package Source Preset", +] as const; + +const concretePresetNames = new Set([ + "ts-lib", + "rust-bin", + "vue-app", + "vue-hono-app", + "vike-app", + "hono-api", +]); +const frameworkNames = new Set(["vue", "vike", "hono", "rust"]); +const publicCliPackageName = ["@ykdz", "template"].join("/"); + +const ignoredDirectories = new Set([ + ".git", + ".agents", + ".scratch", + ".turbo", + ".template-boundary-check", + "node_modules", +]); + +function isTextFile(relativePath: string): boolean { + if (relativePath.includes("/dist/")) return false; + return /\.(?:[cm]?[jt]sx?|json|ya?ml|toml|md)$/u.test(relativePath); +} + +function isTypeScript(relativePath: string): boolean { + return /\.[cm]?[jt]sx?$/u.test(relativePath); +} + +function isRemovalRule(relativePath: string): boolean { + return relativePath.endsWith("check-legacy-architecture-removal.ts"); +} + +function isHistoricalAdr(relativePath: string): boolean { + return relativePath.startsWith("docs/adr/"); +} + +function isGenericSurface(relativePath: string): boolean { + return ( + relativePath.startsWith("packages/core/") || + relativePath.startsWith("packages/shared/") || + relativePath.startsWith("packages/cli/") || + relativePath.startsWith("packages/checks/") || + relativePath.startsWith("test/") + ); +} + +function isBuiltInShared(relativePath: string): boolean { + return relativePath.startsWith("packages/builtin-presets/src/shared/"); +} + +async function filesUnder(root: string, relative = ""): Promise { + const directory = path.join(root, relative); + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const child = path.join(relative, entry.name); + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + files.push(...(await filesUnder(root, child))); + } + } else if (entry.isFile() && isTextFile(child)) { + files.push(child); + } + } + return files; +} + +function finding( + rule: string, + file: string, + detail: string, +): LegacyArchitectureFinding { + return { rule, file, detail }; +} + +function nodeLocation(sourceFile: ts.SourceFile, node: ts.Node): string { + const { line, character } = sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile), + ); + return `${line + 1}:${character + 1}`; +} + +function resolvedSymbol( + checker: ts.TypeChecker, + node: ts.Identifier, +): ts.Symbol | undefined { + const symbol = checker.getSymbolAtLocation(node); + if (symbol === undefined) return undefined; + return symbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(symbol) + : symbol; +} + +function evaluatedString( + checker: ts.TypeChecker, + expression: ts.Expression, + seen = new Set(), +): string | undefined { + if ( + ts.isStringLiteralLike(expression) || + ts.isNoSubstitutionTemplateLiteral(expression) + ) { + return expression.text; + } + if ( + ts.isParenthesizedExpression(expression) || + ts.isAsExpression(expression) + ) { + return evaluatedString(checker, expression.expression, seen); + } + if ( + ts.isBinaryExpression(expression) && + expression.operatorToken.kind === ts.SyntaxKind.PlusToken + ) { + const left = evaluatedString(checker, expression.left, seen); + const right = evaluatedString(checker, expression.right, seen); + return left === undefined || right === undefined ? undefined : left + right; + } + if (ts.isTemplateExpression(expression)) { + const substitutions = expression.templateSpans.map((span) => + evaluatedString(checker, span.expression, seen), + ); + if (substitutions.some((value) => value === undefined)) return undefined; + return ( + expression.head.text + + expression.templateSpans + .map((span, index) => `${substitutions[index]!}${span.literal.text}`) + .join("") + ); + } + if (ts.isIdentifier(expression)) { + const symbol = resolvedSymbol(checker, expression); + if (symbol === undefined || seen.has(symbol)) return undefined; + seen.add(symbol); + const declaration = symbol.valueDeclaration; + if ( + declaration && + ts.isVariableDeclaration(declaration) && + declaration.initializer + ) { + return evaluatedString(checker, declaration.initializer, seen); + } + } + return undefined; +} + +function containsIdentity( + checker: ts.TypeChecker, + expression: ts.Expression, +): string | undefined { + let identity: string | undefined; + const inspect = (node: ts.Node): void => { + if (identity !== undefined) return; + if (ts.isExpression(node)) { + const value = evaluatedString(checker, node); + if (value !== undefined && concretePresetNames.has(value)) + identity = value; + } + ts.forEachChild(node, inspect); + }; + inspect(expression); + return identity; +} + +function collectTypeScriptFindings( + relativePath: string, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, +): LegacyArchitectureFinding[] { + if (!isTypeScript(relativePath) || isRemovalRule(relativePath)) return []; + const findings: LegacyArchitectureFinding[] = []; + const generic = isGenericSurface(relativePath); + const shared = isBuiltInShared(relativePath); + + const inspect = (node: ts.Node): void => { + const location = nodeLocation(sourceFile, node); + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + const moduleSpecifier = node.moduleSpecifier; + if ( + moduleSpecifier !== undefined && + ts.isStringLiteral(moduleSpecifier) + ) { + const specifier = moduleSpecifier.text; + if (retiredPathParts.some((part) => specifier.includes(part))) { + findings.push( + finding( + "legacy-import-export", + relativePath, + `${location} imports or exports ${specifier}`, + ), + ); + } + if ( + shared && + /\.\.\/(?:ts-lib|rust-bin|vue-app|vue-hono-app|vike-app)\//u.test( + specifier, + ) + ) { + findings.push( + finding( + "shared-sibling-import", + relativePath, + `${location} imports outside the Built-in Presets shared area: ${specifier}`, + ), + ); + } + } + } + if (ts.isIdentifier(node) && retiredSymbols.has(node.text)) { + findings.push( + finding( + "retired-symbol", + relativePath, + `${location} references ${node.text}`, + ), + ); + } + if (generic && ts.isStringLiteral(node)) { + if (concretePresetNames.has(node.text)) { + findings.push( + finding( + "generic-preset-identity", + relativePath, + `${location} hard-codes Preset identity ${node.text}`, + ), + ); + } + if ( + frameworkNames.has(node.text) && + node.text !== "rust" && + ts.isBinaryExpression(node.parent) && + (node.parent.operatorToken.kind === + ts.SyntaxKind.EqualsEqualsEqualsToken || + node.parent.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken) + ) { + findings.push( + finding( + "generic-framework-branch", + relativePath, + `${location} hard-codes built-in framework ${node.text}`, + ), + ); + } + } + if ( + generic && + ts.isUnionTypeNode(node) && + node.types.some( + (type) => + ts.isLiteralTypeNode(type) && + ts.isStringLiteral(type.literal) && + concretePresetNames.has(type.literal.text), + ) + ) { + findings.push( + finding( + "closed-preset-union", + relativePath, + `${location} declares a string-literal union in generic code`, + ), + ); + } + if (shared && ts.isIfStatement(node)) { + const identity = containsIdentity(checker, node.expression); + if (identity !== undefined) { + findings.push( + finding( + "shared-preset-branch", + relativePath, + `${location} branches on a Preset identity`, + ), + ); + } + } + if ( + generic && + (ts.isIfStatement(node) || ts.isConditionalExpression(node)) + ) { + const condition = ts.isIfStatement(node) + ? node.expression + : node.condition; + const identity = containsIdentity(checker, condition); + if (identity !== undefined) { + findings.push( + finding( + "identity-branch", + relativePath, + `${location} branches on Preset identity ${identity}`, + ), + ); + } + } + if (generic && ts.isSwitchStatement(node)) { + const identity = containsIdentity(checker, node.expression); + const hasIdentityCase = node.caseBlock.clauses.some( + (clause) => + ts.isCaseClause(clause) && + containsIdentity(checker, clause.expression) !== undefined, + ); + if (identity !== undefined || hasIdentityCase) { + findings.push( + finding( + "identity-branch", + relativePath, + `${location} switches on a closed Preset identity branch`, + ), + ); + } + } + if ( + generic && + (ts.isArrayLiteralExpression(node) || + (ts.isNewExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "Set")) + ) { + const values = ts.isArrayLiteralExpression(node) + ? node.elements + : (node.arguments?.flatMap((argument) => + ts.isArrayLiteralExpression(argument) + ? argument.elements + : [argument], + ) ?? []); + if ( + values.some( + (value) => + ts.isExpression(value) && + containsIdentity(checker, value) !== undefined, + ) + ) { + findings.push( + finding( + "closed-identity-catalog", + relativePath, + `${location} constructs a finite catalog of Preset identities`, + ), + ); + } + } + if ( + relativePath.startsWith("packages/cli/") && + ts.isVariableDeclaration(node) && + node.initializer + ) { + const value = evaluatedString(checker, node.initializer); + if ( + value !== undefined && + /(?:schema\s+preset|preset\s+validate|preset-source)/iu.test(value) + ) { + findings.push( + finding( + "retired-cli-command", + relativePath, + `${location} composes a retired Preset protocol command`, + ), + ); + } + } + ts.forEachChild(node, inspect); + }; + inspect(sourceFile); + return findings; +} + +function collectTextFindings( + relativePath: string, + source: string, +): LegacyArchitectureFinding[] { + if (isRemovalRule(relativePath)) return []; + if (isHistoricalAdr(relativePath)) { + if (relativePath.endsWith("0093-trusted-built-in-preset-definitions.md")) { + return []; + } + if ( + retiredText.some((term) => source.includes(term)) && + !/superseded(?:\s+in\s+part)?\s+by\s+ADR-0093/iu.test(source) + ) { + return [ + finding( + "historical-adr-status", + relativePath, + "contains retired vocabulary without an explicit ADR-0093 supersession note", + ), + ]; + } + return []; + } + const findings: LegacyArchitectureFinding[] = []; + for (const term of retiredText) { + if (new RegExp(term, "iu").test(source)) { + findings.push( + finding("retired-vocabulary", relativePath, `contains ${term}`), + ); + } + } + if ( + /(?:shadow-(?:registry|runtime|check|comparison)|migration-(?:check|harness|parity))/iu.test( + source, + ) + ) { + findings.push( + finding( + "transitional-harness", + relativePath, + "contains a transitional shadow or migration harness name", + ), + ); + } + if ( + relativePath.startsWith("packages/cli/") && + /(?:schema\s+preset|preset\s+validate|preset-source)/iu.test(source) + ) { + findings.push( + finding( + "retired-cli-command", + relativePath, + "removes a retired Preset protocol command from help and dispatch", + ), + ); + } + return findings; +} + +function createAuditProgram( + repositoryRoot: string, + files: readonly string[], +): ts.Program { + return ts.createProgram({ + rootNames: files + .filter(isTypeScript) + .map((file) => path.join(repositoryRoot, file)), + options: { + allowJs: true, + checkJs: false, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + noEmit: true, + skipLibCheck: true, + target: ts.ScriptTarget.ESNext, + }, + }); +} + +async function collectPathFindings( + repositoryRoot: string, +): Promise { + const findings: LegacyArchitectureFinding[] = []; + for (const relativePath of retiredPaths) { + try { + await access(path.join(repositoryRoot, relativePath)); + findings.push( + finding( + "retired-path", + relativePath, + "remove this retired architecture path", + ), + ); + } catch { + // Absence is the required state. + } + } + return findings; +} + +function manifestFindings( + relativePath: string, + source: string, +): LegacyArchitectureFinding[] { + let manifest: Record; + try { + manifest = JSON.parse(source) as Record; + } catch { + return [finding("package-manifest", relativePath, "contains invalid JSON")]; + } + const serialized = JSON.stringify({ + exports: manifest.exports, + dependencies: manifest.dependencies, + optionalDependencies: manifest.optionalDependencies, + peerDependencies: manifest.peerDependencies, + }); + const findings: LegacyArchitectureFinding[] = []; + if (/registry-checks|(?:^|[/-])check-[\w-]*/iu.test(serialized)) { + findings.push( + finding( + "package-manifest-export", + relativePath, + "exports or depends on a repository-check-only module", + ), + ); + } + if (retiredPathParts.some((part) => serialized.includes(part))) { + findings.push( + finding( + "package-manifest-legacy-dependency", + relativePath, + "exports or depends on a retired architecture package or path", + ), + ); + } + return findings; +} + +/** Audits built JavaScript, declarations, and package manifests after build. */ +export async function findLegacyArchitectureDistributionFindings( + repositoryRoot = defaultRepositoryRoot, +): Promise { + const findings: LegacyArchitectureFinding[] = []; + const packageRoots = [ + "packages/cli", + "packages/builtin-presets", + "packages/core", + ]; + const hasDistribution = await Promise.all( + packageRoots.map(async (packageRoot) => { + try { + await access(path.join(repositoryRoot, packageRoot)); + return true; + } catch { + return false; + } + }), + ); + if (!hasDistribution.some(Boolean)) return findings; + for (const packageRoot of packageRoots) { + const manifestPath = `${packageRoot}/package.json`; + try { + findings.push( + ...manifestFindings( + manifestPath, + await readFile(path.join(repositoryRoot, manifestPath), "utf8"), + ), + ); + } catch { + findings.push(finding("package-manifest", manifestPath, "is missing")); + } + const distRoot = path.join(repositoryRoot, packageRoot, "dist"); + try { + for (const relativePath of await filesUnder(distRoot)) { + const source = await readFile( + path.join(distRoot, relativePath), + "utf8", + ); + const displayPath = `${packageRoot}/dist/${relativePath}`; + if ( + retiredPathParts.some((part) => displayPath.includes(part)) || + /(?:registry-checks|check-[\w-]+)\.(?:[cm]?js|d\.ts)$/u.test( + displayPath, + ) + ) { + findings.push( + finding( + "built-artifact", + displayPath, + "contains a retired or repository-check-only runtime module", + ), + ); + } + if (retiredText.some((term) => new RegExp(term, "iu").test(source))) { + findings.push( + finding( + "built-artifact-vocabulary", + displayPath, + "contains retired architecture vocabulary", + ), + ); + } + } + } catch { + findings.push( + finding("built-artifact", `${packageRoot}/dist`, "is missing"), + ); + } + } + return findings; +} + +/** Audit a packed public artifact without needing to unpack it. */ +export function findLegacyArchitectureTarballFindings( + packedPaths: readonly string[], +): readonly LegacyArchitectureFinding[] { + return packedPaths.flatMap((packedPath) => { + const normalized = packedPath.replace(/^package\//u, ""); + if ( + retiredPathParts.some((part) => normalized.includes(part)) || + /(?:behavior\.test|\.template-boundary-check|\.turbo)/u.test( + normalized, + ) || + /@ykdz\/template-builtin-presets\/dist\/src\/(?:check-|registry-checks)/u.test( + normalized, + ) || + normalized.startsWith("packages/") || + (normalized.startsWith("node_modules/@ykdz/template-") && + !normalized.startsWith("node_modules/@ykdz/template-core/") && + !normalized.startsWith("node_modules/@ykdz/template-builtin-presets/")) + ) { + return [ + finding( + "packed-artifact", + packedPath, + "remove retired runtime, test, or generated-check artifact from the public tarball", + ), + ]; + } + return []; + }); +} + +/** Pack the public CLI and audit its actual publication file list. */ +export async function checkPackedPublicArtifact( + repositoryRoot = defaultRepositoryRoot, +): Promise { + const destination = await mkdtemp( + path.join(tmpdir(), "template-public-artifact-"), + ); + try { + await execa( + "pnpm", + [ + "--config.node-linker=hoisted", + "--filter", + publicCliPackageName, + "pack", + "--pack-destination", + destination, + ], + { + cwd: repositoryRoot, + }, + ); + const archive = (await readdir(destination)).find((entry) => + entry.endsWith(".tgz"), + ); + if (archive === undefined) { + throw new Error("Public CLI pack produced no tarball"); + } + const archivePath = path.join(destination, archive); + const packed = await execa("tar", ["-tf", archivePath]); + const findings = findLegacyArchitectureTarballFindings( + packed.stdout.split("\n").filter(Boolean), + ); + if (findings.length > 0) { + throw new Error( + `Legacy Architecture Removal Check found packed-artifact finding(s):\n${findings + .map(({ file, detail }) => `- [packed-artifact] ${file}: ${detail}`) + .join("\n")}`, + ); + } + const manifest = await execa("tar", [ + "-xOf", + archivePath, + "package/package.json", + ]); + const manifestIssues = manifestFindings( + "package/package.json", + manifest.stdout, + ); + if (manifestIssues.length > 0) { + throw new Error( + `Legacy Architecture Removal Check found packed package-manifest finding(s):\n${manifestIssues + .map(({ rule, file, detail }) => `- [${rule}] ${file}: ${detail}`) + .join("\n")}`, + ); + } + const unpacked = path.join(destination, "unpacked"); + await mkdir(unpacked); + await execa("tar", ["-xf", archivePath, "-C", unpacked]); + const packedRoot = path.join(unpacked, "package"); + for (const args of [ + ["dist/cli.js", "--help"], + ["dist/cli.js", "presets"], + ]) { + await execa("node", args, { + cwd: packedRoot, + env: { ...process.env, TEMPLATE_REPOSITORY_ROOT: "" }, + }); + } + } finally { + await rm(destination, { recursive: true, force: true }); + } +} + +/** + * Finds every actionable remnant. This is deliberately exported so checks + * can test negative fixtures and package-packing code can audit tar listings. + */ +export async function findLegacyArchitectureFindings( + repositoryRoot = defaultRepositoryRoot, +): Promise { + const findings = await collectPathFindings(repositoryRoot); + const files = await filesUnder(repositoryRoot); + const program = createAuditProgram(repositoryRoot, files); + const checker = program.getTypeChecker(); + for (const relativePath of files) { + const source = await readFile( + path.join(repositoryRoot, relativePath), + "utf8", + ); + findings.push(...collectTextFindings(relativePath, source)); + const sourceFile = program.getSourceFile( + path.join(repositoryRoot, relativePath), + ); + if (sourceFile) { + findings.push( + ...collectTypeScriptFindings(relativePath, sourceFile, checker), + ); + } + } + findings.push( + ...(await findLegacyArchitectureDistributionFindings(repositoryRoot)), + ); + return findings; +} + +export async function checkLegacyArchitectureRemoval( + repositoryRoot = defaultRepositoryRoot, +): Promise { + const findings = await findLegacyArchitectureFindings(repositoryRoot); + if (findings.length === 0) return; + throw new Error( + `Legacy Architecture Removal Check found ${findings.length} finding(s):\n${findings + .map(({ rule, file, detail }) => `- [${rule}] ${file}: ${detail}`) + .join("\n")}`, + ); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await checkLegacyArchitectureRemoval(); + if (process.argv.includes("--packed")) await checkPackedPublicArtifact(); +} diff --git a/packages/checks/src/check-template-boundary.ts b/packages/checks/src/check-template-boundary.ts deleted file mode 100644 index 5a8e587..0000000 --- a/packages/checks/src/check-template-boundary.ts +++ /dev/null @@ -1,212 +0,0 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - findPresetSourceManifestPreset, - manifestReferencedSourceFiles, - type PresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { - builtInPresetProjectionSourceRoots, - builtInPresetSourceRoot, - loadBuiltInPresetSourceManifest, - projectBuiltInPresetSourcePreset, -} from "@ykdz/template-builtin-source"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import { - blueprintForPresetSourcePreset, - defaultPackagePathForPresetSourcePackageAddition, - planPresetSourcePackageAddition, -} from "@ykdz/template-core/projection-capabilities"; -import { - checkTemplateSourceBoundary, - templateBoundaryDebtAllowlist, - type TemplateBoundaryCheckProjection, - type TemplateBoundaryDebt, - type TemplateBoundaryViolation, -} from "@ykdz/template-core/template-boundary-check"; -import { PackageAdditionSupport } from "@ykdz/template-shared"; - -const repoRoot = - process.env.TEMPLATE_REPOSITORY_ROOT ?? - path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); - -const projectionSourceFiles: Record = { - "hono-api": "packages/core/src/projection-capabilities.ts", - "rust-bin": "packages/core/src/projection-capabilities.ts", - "ts-lib": "packages/core/src/projection-capabilities.ts", - "vike-app": "packages/core/src/projection-capabilities.ts", - "vue-app": "packages/core/src/projection-capabilities.ts", - "vue-hono-app": "packages/core/src/projection-capabilities.ts", -}; - -function projectionTargetDir(presetName: string): string { - return path.join(repoRoot, ".template-boundary-check", `demo-${presetName}`); -} - -function projectionPlanForPreset( - manifest: PresetSourceManifest, - presetName: string, -): TemplateBoundaryCheckProjection { - const preset = findPresetSourceManifestPreset(manifest, presetName); - const sourceFile = projectionSourceFiles[presetName]; - - if (!preset?.projection || !sourceFile) { - throw new Error( - `Missing Template Boundary Check projection: ${presetName}`, - ); - } - - const targetDir = projectionTargetDir(presetName); - const blueprint = blueprintForPresetSourcePreset(preset, { targetDir }); - const plan = projectBuiltInPresetSourcePreset({ - preset, - context: assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@11.2.3", - }, - source: "bundled-fallback", - diagnostics: [], - }, - }), - }); - - return { - name: presetName, - sourceFilePath: path.join(repoRoot, sourceFile), - plan, - }; -} - -async function packageAdditionPlanForPreset( - manifest: PresetSourceManifest, - presetName: string, -): Promise { - const preset = findPresetSourceManifestPreset(manifest, presetName); - const sourceFile = projectionSourceFiles[presetName]; - - if (!preset?.projection || !sourceFile) { - throw new Error( - `Missing Template Boundary Check Package Addition projection: ${presetName}`, - ); - } - - const root = path.join( - repoRoot, - ".template-boundary-check", - "package-addition-root", - ); - const packageLeafName = "template-boundary-check"; - const packagePath = defaultPackagePathForPresetSourcePackageAddition( - preset, - packageLeafName, - builtInPresetProjectionSourceRoots(), - ); - const plan = await planPresetSourcePackageAddition({ - preset, - sourceRoots: builtInPresetProjectionSourceRoots(), - addition: { - root, - blueprint: { - schemaVersion: 1, - preset: "vue-hono-app", - packageManager: "pnpm", - projectKind: "multi-package", - features: [], - packages: [{ name: "@demo/web", path: "apps/web" }], - }, - packageLeafName, - packageName: `@demo/${packageLeafName}`, - packagePath, - nodeVersion: "24", - }, - }); - - return { - name: `${presetName} package addition`, - sourceFilePath: path.join(repoRoot, sourceFile), - plan, - }; -} - -export async function builtInTemplateBoundaryProjections( - manifest: PresetSourceManifest, -): Promise { - const initProjections = manifest.presets - .filter((preset) => preset.generation === "supported") - .map((preset) => projectionPlanForPreset(manifest, preset.name)); - const packageAdditionProjections = await Promise.all( - manifest.presets - .filter( - (preset) => - preset.generation === "supported" && - preset.packageAdditionSupport === PackageAdditionSupport.Supported, - ) - .map((preset) => packageAdditionPlanForPreset(manifest, preset.name)), - ); - - return [...initProjections, ...packageAdditionProjections]; -} - -function formatDiagnostic(diagnostic: TemplateBoundaryViolation): string { - return [ - `${diagnostic.preset}: ${diagnostic.generatedPath}`, - ` owner: ${diagnostic.owningFunction}`, - ` operation: ${diagnostic.operationKind}`, - ` source: ${path.relative(repoRoot, diagnostic.sourceFilePath)}`, - ...(diagnostic.allowlistReason - ? [` allowlist: ${diagnostic.allowlistReason}`] - : []), - ].join("\n"); -} - -function formatAllowlistEntry(entry: TemplateBoundaryDebt): string { - return [ - `${entry.preset}: ${entry.generatedPath}`, - ` owner: ${entry.owningFunction}`, - ` allowlist: ${entry.reason}`, - ].join("\n"); -} - -export async function checkBuiltInTemplateBoundaries(): Promise { - const templatesRoot = builtInPresetSourceRoot(); - const manifest = loadBuiltInPresetSourceManifest(); - const result = await checkTemplateSourceBoundary({ - projections: await builtInTemplateBoundaryProjections(manifest), - manifestReferencedSourceFiles: manifestReferencedSourceFiles( - manifest, - templatesRoot, - ), - allowlist: templateBoundaryDebtAllowlist, - }); - - if (result.allowlistedDebt.length > 0) { - console.log( - [ - "Template Boundary Check allowlisted debt:", - ...result.allowlistedDebt.map(formatDiagnostic), - ].join("\n"), - ); - } - - if (!result.ok) { - throw new Error( - [ - "Template Boundary Check failed:", - ...result.violations.map(formatDiagnostic), - ...result.unusedAllowlistEntries.map( - (entry) => `Unused allowlist entry:\n${formatAllowlistEntry(entry)}`, - ), - ].join("\n"), - ); - } -} - -if (import.meta.url === `file://${process.argv[1]}`) { - await checkBuiltInTemplateBoundaries(); -} diff --git a/packages/checks/src/check-template-github-yaml.ts b/packages/checks/src/check-template-github-yaml.ts deleted file mode 100644 index 366e69d..0000000 --- a/packages/checks/src/check-template-github-yaml.ts +++ /dev/null @@ -1,490 +0,0 @@ -#!/usr/bin/env node -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - builtInPresetSourceRoot, - findBuiltInPresetSourceManifestPreset, - loadBuiltInPresetSourceManifest, - projectBuiltInPresetSourcePreset, -} from "@ykdz/template-builtin-source"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import { - projectCheckWorkflow, - projectDependabotConfig, -} from "@ykdz/template-core/project-github"; -import { blueprintForPresetSourcePreset } from "@ykdz/template-core/projection-capabilities"; -import { isMap, isSeq, parseDocument, type YAMLMap } from "yaml"; - -const defaultTemplatesRoot = builtInPresetSourceRoot(); - -type CheckTemplateGithubYamlOptions = { - templatesRoot?: string; - supportedPresetNames?: readonly string[]; -}; - -async function listGithubYamlTemplates( - templatesRoot: string, -): Promise { - const templateEntries = await readdir(templatesRoot, { withFileTypes: true }); - const templateFiles: string[] = []; - - for (const templateEntry of templateEntries) { - if (!templateEntry.isDirectory()) { - continue; - } - - const githubRoot = path.join(templatesRoot, templateEntry.name, ".github"); - templateFiles.push(...(await listYamlFiles(githubRoot))); - } - - return templateFiles.toSorted((left, right) => left.localeCompare(right)); -} - -async function listYamlFiles(directory: string): Promise { - let entries; - - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch (error) { - if (isNodeError(error) && error.code === "ENOENT") { - return []; - } - - throw error; - } - - const files: string[] = []; - - for (const entry of entries) { - const entryPath = path.join(directory, entry.name); - - if (entry.isDirectory()) { - files.push(...(await listYamlFiles(entryPath))); - continue; - } - - if (entry.isFile() && isYamlFile(entry.name)) { - files.push(entryPath); - } - } - - return files; -} - -function isYamlFile(fileName: string): boolean { - return fileName.endsWith(".yml") || fileName.endsWith(".yaml"); -} - -function isNodeError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && "code" in error; -} - -function relativeTemplateFilePath( - templatesRoot: string, - filePath: string, -): string { - return `templates/${path.relative(templatesRoot, filePath).split(path.sep).join("/")}`; -} - -async function checkYamlTemplate( - templatesRoot: string, - filePath: string, -): Promise { - const contents = await readFile(filePath, "utf8"); - const relativePath = relativeTemplateFilePath(templatesRoot, filePath); - const templateKind = githubYamlTemplateKind(relativePath); - const renderedContents = templateKind - ? renderCheckedTemplateForProjection( - presetNameFromRelativePath(relativePath) ?? "", - contents.replace(/\r\n/g, "\n"), - templateKind, - ) - : contents; - const document = parseDocument(renderedContents); - const problems = [...document.errors, ...document.warnings].map( - (error) => `${relativePath}: ${error.message}`, - ); - - if (!isMap(document.contents)) { - problems.push(`${relativePath}: expected a top-level YAML map`); - return problems; - } - - if (templateKind === "workflow") { - problems.push(...checkWorkflowTemplate(relativePath, document.contents)); - problems.push( - ...checkProjectedTemplate(relativePath, contents, "workflow"), - ); - } else if (templateKind === "dependabot") { - problems.push(...checkDependabotTemplate(relativePath, document.contents)); - problems.push( - ...checkProjectedTemplate(relativePath, contents, "dependabot"), - ); - } - - return problems; -} - -function githubYamlTemplateKind( - relativePath: string, -): "workflow" | "dependabot" | undefined { - if ( - /^templates\/[^/]+\/\.github\/workflows\/[^/]+\.ya?ml$/.test(relativePath) - ) { - return "workflow"; - } - - if (/^templates\/[^/]+\/\.github\/dependabot\.ya?ml$/.test(relativePath)) { - return "dependabot"; - } - - return undefined; -} - -function checkProjectedTemplate( - relativePath: string, - contents: string, - kind: "workflow" | "dependabot", -): string[] { - const presetName = presetNameFromRelativePath(relativePath); - - if (!presetName || !findSupportedPresetProjection(presetName)) { - return []; - } - - const renderedContents = renderCheckedTemplateForProjection( - presetName, - contents.replace(/\r\n/g, "\n"), - kind, - ); - const expected = - kind === "workflow" - ? projectGithubCheckWorkflow(presetName) - : projectDependabotTemplate(presetName); - - if (renderedContents === expected) { - return []; - } - - const projectionName = - kind === "workflow" - ? "GitHub check workflow projection" - : "Dependabot projection"; - return [ - `${relativePath}: expected checked template source to match ${projectionName}`, - ]; -} - -function renderCheckedTemplateForProjection( - presetName: string, - contents: string, - kind: "workflow" | "dependabot", -): string { - const projectionPlan = projectThroughPresetProjection(presetName); - const generatedPath = - kind === "workflow" - ? ".github/workflows/check.yml" - : ".github/dependabot.yml"; - const operation = projectionPlan?.operations.find( - (candidate) => - (candidate.kind === "copyFile" || - candidate.kind === "writeTextTemplate") && - candidate.to === generatedPath, - ); - - if (!operation || operation.kind !== "writeTextTemplate") { - return contents; - } - - return renderCheckedTextTemplate(contents, operation.replacements); -} - -function renderCheckedTextTemplate( - contents: string, - replacements: Record, -): string { - const used = new Set(); - const rendered = contents.replaceAll( - /\{\{([A-Za-z][A-Za-z0-9_]*)\}\}/g, - (_placeholder, name: string) => { - const replacement = replacements[name]; - - if (replacement === undefined) { - throw new Error(`Missing text template variable: ${name}`); - } - - used.add(name); - return replacement; - }, - ); - - for (const name of Object.keys(replacements)) { - if (!used.has(name)) { - throw new Error(`Unused text template variable: ${name}`); - } - } - - return rendered; -} - -function presetNameFromRelativePath(relativePath: string): string | undefined { - return relativePath.match(/^templates\/([^/]+)\//)?.[1]; -} - -function findSupportedPresetProjection(presetName: string) { - const preset = findBuiltInPresetSourceManifestPreset(presetName); - - return preset?.generation === "supported" && preset.projection - ? preset - : undefined; -} - -function projectGithubCheckWorkflow(presetName: string): string { - const projectionPlan = projectThroughPresetProjection(presetName); - - return projectionPlan - ? projectCheckWorkflow({ - checkPlan: projectionPlan.checkPlan, - environmentPreparation: - presetName === "rust-bin" ? { rustToolchain: true } : undefined, - }) - : missingProjectedGithubTemplate(presetName, "workflow"); -} - -function projectDependabotTemplate(presetName: string): string { - const projectionPlan = projectThroughPresetProjection(presetName); - - return projectionPlan - ? projectDependabotConfig(projectionPlan.dependencyMaintenancePolicy) - : missingProjectedGithubTemplate(presetName, "dependabot"); -} - -function missingProjectedGithubTemplate( - presetName: string, - kind: "workflow" | "dependabot", -): never { - throw new Error( - `Preset Projection ${presetName} did not project ${kind} template source`, - ); -} - -function projectThroughPresetProjection(presetName: string) { - const preset = findSupportedPresetProjection(presetName); - - if (!preset) { - return undefined; - } - - const targetDir = "generated-repository"; - const blueprint = blueprintForPresetSourcePreset(preset, { targetDir }); - - return projectBuiltInPresetSourcePreset({ - preset, - context: assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.11.0" }, - source: "bundled-fallback", - diagnostics: [], - }, - }), - }); -} - -function checkWorkflowTemplate( - relativePath: string, - document: YAMLMap, -): string[] { - const problems: string[] = []; - const jobs = getMapValue(document, "jobs"); - - if (getMapValue(document, "name") === undefined) { - problems.push(`${relativePath}: expected workflow name`); - } - - if (getMapValue(document, "on") === undefined) { - problems.push(`${relativePath}: expected workflow triggers`); - } - - if (!isMap(jobs) || jobs.items.length === 0) { - problems.push(`${relativePath}: expected at least one workflow job`); - return problems; - } - - for (const job of jobs.items) { - if (!isMap(job.value)) { - problems.push( - `${relativePath}: expected workflow job ${String(job.key)} to be a map`, - ); - } - } - - const checkJob = getMapValue(jobs, "check"); - if (checkJob === undefined) { - problems.push(`${relativePath}: expected check workflow job`); - } else if (isMap(checkJob)) { - if (typeof getMapValue(checkJob, "runs-on") !== "string") { - problems.push( - `${relativePath}: expected check workflow job to declare runs-on`, - ); - } - - const steps = getMapValue(checkJob, "steps"); - if (!isSeq(steps) || steps.items.length === 0) { - problems.push( - `${relativePath}: expected check workflow job to declare steps`, - ); - } else if (!steps.items.every(isMap)) { - problems.push( - `${relativePath}: expected every check workflow step to be a map`, - ); - } - } - - return problems; -} - -function checkDependabotTemplate( - relativePath: string, - document: YAMLMap, -): string[] { - const problems: string[] = []; - const version = getMapValue(document, "version"); - const updates = getMapValue(document, "updates"); - - if (version !== 2) { - problems.push(`${relativePath}: expected Dependabot version 2`); - } - - if (!isSeq(updates) || updates.items.length === 0) { - problems.push(`${relativePath}: expected Dependabot updates`); - return problems; - } - - const updateMaps = updates.items.filter(isMap); - const ecosystems = updateMaps - .map((update) => getMapValue(update, "package-ecosystem")) - .filter((ecosystem): ecosystem is string => typeof ecosystem === "string"); - - if (updateMaps.length !== updates.items.length) { - problems.push( - `${relativePath}: expected every Dependabot update to be a map`, - ); - } - - if (!ecosystems.includes("github-actions")) { - problems.push( - `${relativePath}: expected github-actions Dependabot coverage`, - ); - } - - if (!ecosystems.includes("npm") && !ecosystems.includes("cargo")) { - problems.push(`${relativePath}: expected npm or cargo Dependabot coverage`); - } - - for (const update of updateMaps) { - if (typeof getMapValue(update, "directory") !== "string") { - problems.push( - `${relativePath}: expected every Dependabot update to declare directory`, - ); - } - - const schedule = getMapValue(update, "schedule"); - if ( - !isMap(schedule) || - typeof getMapValue(schedule, "interval") !== "string" - ) { - problems.push( - `${relativePath}: expected every Dependabot update to declare schedule.interval`, - ); - } - } - - return problems; -} - -function getMapValue(document: YAMLMap, key: string): unknown { - return document.get(key); -} - -export async function checkTemplateGithubYaml( - options: CheckTemplateGithubYamlOptions = {}, -): Promise { - const templatesRoot = options.templatesRoot ?? defaultTemplatesRoot; - const supportedPresetNames = - options.supportedPresetNames ?? - loadBuiltInPresetSourceManifest() - .presets.filter((preset) => preset.generation === "supported") - .map((preset) => preset.name); - const templateFiles = await listGithubYamlTemplates(templatesRoot); - - if (templateFiles.length === 0) { - throw new Error( - "No checked GitHub YAML templates found in templates/*/.github", - ); - } - - const seenFiles = new Set( - templateFiles.map((filePath) => - relativeTemplateFilePath(templatesRoot, filePath), - ), - ); - const requiredFiles = supportedPresetNames.flatMap((presetName) => [ - { - displayPath: `templates/${presetName}/.github/workflows/check.yml`, - acceptedPaths: [ - `templates/${presetName}/.github/workflows/check.yml`, - `templates/${presetName}/.github/workflows/check.yaml`, - ], - }, - { - displayPath: `templates/${presetName}/.github/dependabot.yml`, - acceptedPaths: [ - `templates/${presetName}/.github/dependabot.yml`, - `templates/${presetName}/.github/dependabot.yaml`, - ], - }, - ]); - const missingFiles = requiredFiles - .filter((requiredFile) => - requiredFile.acceptedPaths.every( - (acceptedPath) => !seenFiles.has(acceptedPath), - ), - ) - .map( - (requiredFile) => - `${requiredFile.displayPath}: expected checked template source file`, - ); - const failures = ( - await Promise.all( - templateFiles.map((filePath) => - checkYamlTemplate(templatesRoot, filePath), - ), - ) - ).flat(); - failures.unshift(...missingFiles); - - if (failures.length > 0) { - throw new Error( - `Checked GitHub YAML templates are invalid:\n${failures.join("\n")}`, - ); - } - - return templateFiles.length; -} - -async function main(): Promise { - const templateFileCount = await checkTemplateGithubYaml(); - - console.log( - `Checked ${templateFileCount} GitHub workflow and Dependabot template files.`, - ); -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - await main(); -} diff --git a/packages/checks/src/fixture-replay-cache.ts b/packages/checks/src/fixture-replay-cache.ts deleted file mode 100644 index 591a74e..0000000 --- a/packages/checks/src/fixture-replay-cache.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { GeneratedScenarioReplayCache } from "@ykdz/template-core/generated-scenarios"; - -export function fixtureReplayCacheFromEnv(): - | GeneratedScenarioReplayCache - | undefined { - const directory = process.env.TEMPLATE_FIXTURE_REPLAY_CACHE_DIR; - - if (!directory) { - return undefined; - } - - return { - directory, - read: process.env.TEMPLATE_FIXTURE_REPLAY_CACHE_READ !== "0", - write: process.env.TEMPLATE_FIXTURE_REPLAY_CACHE_WRITE === "1", - }; -} diff --git a/packages/checks/tsconfig.build.json b/packages/checks/tsconfig.build.json index 833a23e..8e99cc9 100644 --- a/packages/checks/tsconfig.build.json +++ b/packages/checks/tsconfig.build.json @@ -4,5 +4,6 @@ "customConditions": [], "declarationMap": false, "rootDir": "src" - } + }, + "exclude": ["src/check-generated-registry.ts"] } diff --git a/packages/cli/package.json b/packages/cli/package.json index df82945..9b1a38b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@ykdz/template", - "version": "0.0.23", + "version": "0.0.24", "private": false, "description": "Project template generator for YKDZ starter projects.", "keywords": [ @@ -22,9 +22,8 @@ }, "files": [ "dist", - "node_modules/@ykdz/template-shared", "node_modules/@ykdz/template-core", - "node_modules/@ykdz/template-builtin-source", + "node_modules/@ykdz/template-builtin-presets", "!**/node_modules/**/node_modules" ], "type": "module", @@ -37,15 +36,15 @@ "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", "lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern dist .", "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts --ignore-pattern dist . --fix", - "prepack": "pnpm --filter @ykdz/template-shared run build && pnpm --filter @ykdz/template-core run build && pnpm --filter @ykdz/template-builtin-source run build && pnpm run build", + "prepack": "pnpm --filter @ykdz/template-core run build && pnpm --filter @ykdz/template-builtin-presets run build && pnpm run build", "pack:bundled": "pnpm --config.node-linker=hoisted pack", "publish:bundled": "pnpm --config.node-linker=hoisted publish", "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" }, "dependencies": { - "@ykdz/template-builtin-source": "workspace:*", + "@typescript/old": "npm:typescript@^6.0.0", + "@ykdz/template-builtin-presets": "workspace:*", "@ykdz/template-core": "workspace:*", - "@ykdz/template-shared": "workspace:*", "semver": "catalog:", "typescript": "catalog:", "valibot": "catalog:" @@ -58,11 +57,15 @@ "typescript-7": "catalog:" }, "bundleDependencies": [ - "@ykdz/template-builtin-source", - "@ykdz/template-core", - "@ykdz/template-shared" + "@ykdz/template-builtin-presets", + "@ykdz/template-core" ], "engines": { "node": ">=24.0.0" + }, + "turbo": { + "tags": [ + "template-cli" + ] } } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 2a8031f..e96efe2 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -3,68 +3,47 @@ import path from "node:path"; import { createInterface } from "node:readline/promises"; import { - builtInPresetProjectionSourceRoots, - builtInPresets, - findBuiltInPresetSourceManifestPreset, - loadBuiltInPresetSourceManifest, - projectBuiltInPresetSourcePreset, -} from "@ykdz/template-builtin-source"; + builtInPresetRegistry, + createGenerationContext, + planGeneratedRepositoryInitialization, + planGeneratedRepositoryPackageAddition, + templateSources, +} from "@ykdz/template-builtin-presets"; import { - assembleGenerationContext, - type GenerationContext, -} from "@ykdz/template-core/generation-context"; + assertProjectBlueprintV2, + validateProjectBlueprintV2, + type ProjectBlueprintV2, +} from "@ykdz/template-core/project-blueprint-v2"; import { - formatNextStepInstructionsForCli, - generatedFollowUpDocumentOperation, - planNextStepInstructions, - type FollowUpDocumentPlan, - type NextStepInstruction, - type NextStepInstructionPlan, -} from "@ykdz/template-core/next-step-instructions"; -import { addPackage } from "@ykdz/template-core/package-addition"; -import type { PresetProjectionPlan } from "@ykdz/template-core/preset-projection"; -import { - validateBuiltInPresetSourceManifest, - validatePresetSourceManifest, -} from "@ykdz/template-core/preset-source"; -import { blueprintForPresetSourcePreset } from "@ykdz/template-core/projection-capabilities"; -import { renderNewProject } from "@ykdz/template-core/renderer"; + renderNewProject, + renderProjectAtomically, +} from "@ykdz/template-core/renderer"; import { resolveToolchainVersions, type ResolvedToolchainVersions, type ToolchainResolutionSource, } from "@ykdz/template-core/toolchain-resolution"; -import { - blueprintJsonSchema, - presetSourceManifestJsonSchema, - presetFileJsonSchema, - validatePresetFile, - validateProjectBlueprint, - type BuiltInPreset, - type ProjectBlueprint, - type ValidationIssue, -} from "@ykdz/template-shared"; type InitOptions = { - dir: string; - preset: string; - yes: boolean; - dryRun: boolean; - json: boolean; - todo: boolean; - scope?: string | undefined; + readonly dir: string; + readonly preset: string; + readonly yes: boolean; + readonly dryRun: boolean; + readonly json: boolean; + readonly todo: boolean; + readonly scope?: string; }; type AddPackageOptions = { - preset: string; - name: string; - path?: string | undefined; - linkFrom: readonly string[]; + readonly preset: string; + readonly name: string; + readonly path?: string; + readonly linkFrom: readonly string[]; }; function usage(): string { return [ - "Project Kit template CLI", + "template CLI", "", "Usage:", " template [options]", @@ -73,11 +52,6 @@ function usage(): string { " template init --preset --yes", " template add package --preset --name [--path ] [--link-from ]...", " template presets", - " template schema preset", - " template schema preset-source", - " template schema blueprint", - " template preset validate ", - " template preset-source validate ", " template blueprint validate ", "", "Init options:", @@ -100,712 +74,378 @@ function printJson(value: unknown): void { console.log(JSON.stringify(value, null, 2)); } -async function readJsonDeclaration(filePath: string): Promise { - if (path.extname(filePath) !== ".json") { - throw new Error(`Declaration files must be JSON: ${filePath}`); - } - - try { - return JSON.parse(await readFile(filePath, "utf8")) as unknown; - } catch (error: unknown) { - if (error instanceof SyntaxError) { - throw new Error(`Invalid JSON in ${filePath}: ${error.message}`, { - cause: error, - }); - } - throw error; - } -} - -function formatValidationIssues(issues: ValidationIssue[]): string { - return issues - .map((issue) => ` - ${issue.path}: ${issue.message}`) - .join("\n"); -} - -function formatFieldRows( - rows: readonly (readonly [label: string, value: string])[], -): string[] { +function formatRows(rows: readonly (readonly [string, string])[]): string[] { const width = Math.max(...rows.map(([label]) => `${label}:`.length)); - return rows.map( ([label, value]) => ` ${`${label}:`.padEnd(width)} ${value}`, ); } -function formatPresetCatalog(): string { +function formatCatalog(): string { return [ "Built-in presets", "", - ...formatFieldRows( - builtInPresets.map((preset) => [ - preset.name, - `${preset.title} (${preset.generation}) - ${preset.description}`, - ]), + ...formatRows( + builtInPresetRegistry + .all() + .map((definition) => [ + definition.metadata.name, + `${definition.metadata.title} - ${definition.metadata.description}`, + ]), ), ].join("\n"); } -function parseInitOptions(args: string[]): InitOptions { +function normalizeNpmScope(value: string): string { + const scope = value.startsWith("@") ? value.slice(1) : value; + if (value !== value.trim() || !/^[a-z0-9][a-z0-9._-]*$/.test(scope)) { + throw new Error("--scope must be a valid npm scope without whitespace"); + } + return scope; +} + +function parseInitOptions(args: readonly string[]): InitOptions { const dir = args[1]; + if (!dir) throw new Error("init requires a target directory"); let preset = ""; let yes = false; let dryRun = false; let json = false; let todo = true; let scope: string | undefined; - for (let index = 2; index < args.length; index += 1) { const arg = args[index]; - - if (arg === "--yes" || arg === "-y") { - yes = true; - continue; - } - - if (arg === "--dry-run") { - dryRun = true; - continue; - } - - if (arg === "--json") { - json = true; - continue; - } - - if (arg === "--no-todo") { - todo = false; - continue; - } - - if (arg === "--scope") { - const value = args[index + 1]; - if (!value) { - throw new Error("--scope requires a value"); - } - scope = normalizeNpmScope(value); - index += 1; - continue; - } - - if (arg === "--preset") { + if (arg === "--yes" || arg === "-y") yes = true; + else if (arg === "--dry-run") dryRun = true; + else if (arg === "--json") json = true; + else if (arg === "--no-todo") todo = false; + else if (arg === "--preset" || arg === "--scope") { const value = args[index + 1]; - if (!value) { - throw new Error("--preset requires a value"); - } - preset = value; + if (!value) throw new Error(`${arg} requires a value`); + if (arg === "--preset") preset = value; + else scope = normalizeNpmScope(value); index += 1; - continue; - } - - throw new Error(`Unknown option: ${arg}`); - } - - if (!dir) { - throw new Error("init requires a target directory"); + } else throw new Error(`Unknown option: ${arg}`); } - - return { dir, preset, yes, dryRun, json, todo, scope }; + return { dir, preset, yes, dryRun, json, todo, ...(scope ? { scope } : {}) }; } -function normalizeNpmScope(value: string): string { - if (value !== value.trim() || /\s/.test(value)) { - throw new Error("--scope must be a valid npm scope without whitespace"); - } - - const scope = value.startsWith("@") ? value.slice(1) : value; - if (!/^[a-z0-9][a-z0-9._-]*$/.test(scope)) { - throw new Error("--scope must be a valid npm scope"); - } - - return scope; -} - -function supportedPreset(name: string): BuiltInPreset { - const preset = builtInPresets.find( - (candidate) => - candidate.name === name && candidate.generation === "supported", - ); - - if (!preset) { - throw new Error(formatSupportedPresetError()); +function parseAddPackageOptions(args: readonly string[]): AddPackageOptions { + let preset = ""; + let name = ""; + let packagePath: string | undefined; + const linkFrom: string[] = []; + for (let index = 2; index < args.length; index += 1) { + const arg = args[index]; + if (!["--preset", "--name", "--path", "--link-from"].includes(arg ?? "")) { + throw new Error(`Unknown option: ${arg}`); + } + const value = args[index + 1]; + if (!value) throw new Error(`${arg} requires a value`); + if (arg === "--preset") preset = value; + if (arg === "--name") name = value; + if (arg === "--path") packagePath = value; + if (arg === "--link-from") linkFrom.push(value); + index += 1; } - - return preset; -} - -function formatSupportedPresetError(): string { - const supportedPresetNames = builtInPresets - .filter((preset) => preset.generation === "supported") - .map((preset) => preset.name); - - return `Only the ${formatList(supportedPresetNames)} presets are supported in this version`; + if (!preset) throw new Error("add package requires --preset"); + if (!name) throw new Error("add package requires --name"); + return { + preset, + name, + ...(packagePath ? { path: packagePath } : {}), + linkFrom, + }; } -function formatList(values: readonly string[]): string { - if (values.length <= 1) { - return values[0] ?? ""; - } - - return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`; +function toolchainSourceFromEnv(): ToolchainResolutionSource | undefined { + const source = process.env.TEMPLATE_TOOLCHAIN_RESOLUTION; + return source === "online" || source === "bundled-fallback" + ? source + : undefined; } -function blueprintForInit(options: InitOptions): ProjectBlueprint { - const preset = supportedPreset(options.preset); - return blueprintForPresetSourcePreset(preset, { - targetDir: options.dir, - scope: options.scope, +async function resolveToolchain(): Promise { + return await resolveToolchainVersions({ + source: toolchainSourceFromEnv(), + nodeReleaseIndexUrl: process.env.TEMPLATE_TOOLCHAIN_NODE_RELEASE_INDEX_URL, + pnpmRegistryUrl: process.env.TEMPLATE_TOOLCHAIN_PNPM_REGISTRY_URL, }); } -function formatBlueprintSummary( - targetDir: string, - blueprint: ProjectBlueprint, -): string { - const rows: Array = [ - ["Target", targetDir], - ["Preset", blueprint.preset], - ["Project kind", blueprint.projectKind], - ]; - - if (blueprint.packageManager) { - rows.push(["Package manager", blueprint.packageManager]); - } - - return [ - "Project Blueprint", - "", - ...formatFieldRows(rows), - ...(blueprint.packages - ? [ - "", - " Packages:", - ...blueprint.packages.map((pkg) => ` - ${pkg.name} (${pkg.path})`), - ] - : []), - "", - " Features:", - ...blueprint.features.map((feature) => ` - ${feature}`), - ].join("\n"); -} - -type InitJsonOutput = { - command: "init"; - dryRun: boolean; - targetDir: string; - blueprint: ProjectBlueprint; - toolchain?: ToolchainReport; - nextSteps: readonly NextStepInstruction[]; - followUpDocument: FollowUpDocumentPlan; -}; - -type ToolchainReport = { - nodeLtsMajor: string; - packageManagerPin: string; - source: ToolchainResolutionSource; - diagnostics: string[]; -}; - -function planProjectionNextSteps( - targetDir: string, - projectionPlan: PresetProjectionPlan, -): NextStepInstructionPlan { - return planNextStepInstructions({ - targetDir, - projectionPlan, +async function planInitialization(options: InitOptions) { + const definition = builtInPresetRegistry.require(options.preset); + const toolchain = await resolveToolchain(); + const context = createGenerationContext({ + targetDir: options.dir, + ...(options.scope ? { scope: options.scope } : {}), + toolchain: { + nodeLtsMajor: toolchain.nodeLtsMajor.value, + packageManagerPin: toolchain.packageManagerPin.value, + }, }); + return { + definition, + context, + toolchain, + plan: planGeneratedRepositoryInitialization({ definition, context }), + }; } -function followUpDocumentPlan(options: InitOptions): FollowUpDocumentPlan { - return options.todo ? { enabled: true, path: "TODO.md" } : { enabled: false }; -} - -function displayPathFromCwd(filePath: string): string { - const absolutePath = path.resolve(filePath); - const relativePath = path.relative(process.cwd(), absolutePath); - - if ( - relativePath && - !relativePath.startsWith("..") && - !path.isAbsolute(relativePath) - ) { - return relativePath; - } - - return absolutePath; -} - -function followUpDocumentDisplayPath(options: InitOptions): string { - return displayPathFromCwd(path.join(options.dir, "TODO.md")); -} - -function formatFollowUpDocumentReport(options: InitOptions): string { - const rows: Array = [ - ["Enabled", options.todo ? "yes" : "no"], - ]; - - if (options.todo) { - rows.push(["Path", followUpDocumentDisplayPath(options)]); - } - - return ["Generated Follow-Up Document:", "", ...formatFieldRows(rows)].join( - "\n", - ); -} - -function toolchainReport( - toolchain: ResolvedToolchainVersions, -): ToolchainReport { +function toolchainReport(toolchain: ResolvedToolchainVersions) { return { nodeLtsMajor: toolchain.nodeLtsMajor.value, packageManagerPin: toolchain.packageManagerPin.value, source: toolchain.source, - diagnostics: [...toolchain.diagnostics], + diagnostics: toolchain.diagnostics, }; } -function formatToolchainReport(toolchain: ResolvedToolchainVersions): string { - return [ - "Toolchain Resolution:", - "", - ...formatFieldRows([ - ["Source", toolchain.source], - ["Node LTS major", toolchain.nodeLtsMajor.value], - ["Package Manager Pin", toolchain.packageManagerPin.value], - ]), - ...toolchain.diagnostics.map((diagnostic) => ` ${diagnostic}`), - ].join("\n"); -} - -function toolchainResolutionSourceFromEnv(): - | ToolchainResolutionSource - | undefined { - if ( - process.env.TEMPLATE_TOOLCHAIN_RESOLUTION === "online" || - process.env.TEMPLATE_TOOLCHAIN_RESOLUTION === "bundled-fallback" - ) { - return process.env.TEMPLATE_TOOLCHAIN_RESOLUTION; - } - - return undefined; -} - -async function generationContextForInit( +function initOutput( options: InitOptions, - blueprint: ProjectBlueprint, -): Promise { - if ( - findBuiltInPresetSourceManifestPreset(options.preset)?.projection === - undefined - ) { - return undefined; - } - - return assembleGenerationContext({ + result: Awaited>, +) { + return { + command: "init", + dryRun: options.dryRun, targetDir: options.dir, - blueprint, - toolchain: await resolveToolchainVersions({ - source: toolchainResolutionSourceFromEnv(), - nodeReleaseIndexUrl: - process.env.TEMPLATE_TOOLCHAIN_NODE_RELEASE_INDEX_URL, - pnpmRegistryUrl: process.env.TEMPLATE_TOOLCHAIN_PNPM_REGISTRY_URL, - }), - }); + blueprint: result.plan.blueprint, + generationRecord: result.plan.generationRecord, + toolchain: toolchainReport(result.toolchain), + nextSteps: result.plan.nextStepInstructions, + followUpDocument: { + enabled: options.todo, + path: options.todo ? "TODO.md" : undefined, + }, + }; } -async function generateInitProject( - options: InitOptions, - generationContext?: GenerationContext, -): Promise { - const preset = findBuiltInPresetSourceManifestPreset(options.preset); - if (!preset?.projection) { - throw new Error(formatSupportedPresetError()); - } - - if (!generationContext) { - throw new Error( - `Missing Generation Context for Preset Projection: ${options.preset}`, - ); - } - - const plan = projectBuiltInPresetSourcePreset({ - preset, - context: generationContext, - }); - const nextStepPlan = planProjectionNextSteps(options.dir, plan); - const operations = options.todo - ? [...plan.operations, generatedFollowUpDocumentOperation(nextStepPlan)] - : [...plan.operations]; +function followUpDocumentOperation( + result: Awaited>, +) { + return { + kind: "writeTextTemplate" as const, + source: templateSources.foundation, + from: "TODO.md.template", + to: "TODO.md", + replacements: { + NEXT_STEPS: result.plan.nextStepInstructions + .map((instruction, index) => `${index + 1}. \`${instruction.display}\``) + .join("\n"), + }, + }; +} - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: options.dir, - operations, - }); +function isInteractiveTerminal(): boolean { + return process.stdin.isTTY && process.stdout.isTTY; } -function printInitComplete( +async function confirmInit( options: InitOptions, - blueprint: ProjectBlueprint, - generationContext?: GenerationContext, -): void { - const preset = findBuiltInPresetSourceManifestPreset(blueprint.preset); - const projectionPlan = - generationContext && preset?.projection - ? projectBuiltInPresetSourcePreset({ preset, context: generationContext }) - : undefined; - if (!projectionPlan) { - throw new Error(`Missing Preset Projection plan: ${blueprint.preset}`); - } - const nextStepPlan = planProjectionNextSteps(options.dir, projectionPlan); - const nextSteps = nextStepPlan.steps; - const followUpDocument = followUpDocumentPlan(options); - - if (options.json) { - printJson({ - command: "init", - dryRun: false, - targetDir: options.dir, - blueprint, - ...(generationContext - ? { toolchain: toolchainReport(generationContext.toolchain) } - : {}), - nextSteps, - followUpDocument, - } satisfies InitJsonOutput); - return; - } - + blueprint: ProjectBlueprintV2, +): Promise { console.log( [ - "Initialized project", + "Planned project", "", - ...formatFieldRows([ - ["Preset", options.preset], + ...formatRows([ ["Target", options.dir], + ["Packages", String(blueprint.packages.length)], ]), ].join("\n"), ); - if (generationContext) { - console.log(""); - console.log(formatToolchainReport(generationContext.toolchain)); - } - console.log(""); - if (options.todo) { - console.log( - `Follow-up checklist written to ${followUpDocumentDisplayPath(options)}`, - ); - } else { - console.log(formatNextStepInstructionsForCli(nextStepPlan)); - } -} - -function isInteractiveTerminal(): boolean { - return process.stdin.isTTY && process.stdout.isTTY; -} - -async function confirmInit( - targetDir: string, - blueprint: ProjectBlueprint, -): Promise { - console.log(formatBlueprintSummary(targetDir, blueprint)); const readline = createInterface({ input: process.stdin, output: process.stdout, }); - try { const answer = await readline.question("Generate this project? [y/N] "); - return ( - answer.trim().toLowerCase() === "y" || - answer.trim().toLowerCase() === "yes" - ); + return ["y", "yes"].includes(answer.trim().toLowerCase()); } finally { readline.close(); } } -function parseAddPackageOptions(args: string[]): AddPackageOptions { - let preset = ""; - let name = ""; - let packagePath: string | undefined; - const linkFrom: string[] = []; - - for (let index = 2; index < args.length; index += 1) { - const arg = args[index]; - - if (arg === "--preset") { - const value = args[index + 1]; - if (!value) { - throw new Error("--preset requires a value"); - } - preset = value; - index += 1; - continue; - } - - if (arg === "--name") { - const value = args[index + 1]; - if (!value) { - throw new Error("--name requires a value"); - } - name = value; - index += 1; - continue; - } +async function readBlueprint(filePath: string): Promise { + const value: unknown = JSON.parse(await readFile(filePath, "utf8")); + if ( + typeof value === "object" && + value !== null && + "schemaVersion" in value && + value.schemaVersion === 1 + ) { + throw new Error( + "Unsupported Local Template Metadata: Blueprint version 1 is not supported", + ); + } + return assertProjectBlueprintV2(value); +} - if (arg === "--path") { - const value = args[index + 1]; - if (!value) { - throw new Error("--path requires a value"); - } - packagePath = value; - index += 1; - continue; +/** + * Package Addition has no Preset provenance to consult. The current + * Blueprint topology and the real package manifests are the durable facts; + * their shared npm scope is the context for a newly planned package. + */ +async function deriveExistingPackageScope(options: { + readonly targetDir: string; + readonly blueprint: ProjectBlueprintV2; +}): Promise { + const scopes = new Set(); + for (const definition of options.blueprint.packages) { + const match = definition.name.match(/^@([^/]+)\//); + if (!match?.[1]) { + throw new Error( + `Package Addition requires a scoped Package Definition: ${definition.name}`, + ); } - - if (arg === "--link-from") { - const value = args[index + 1]; - if (!value) { - throw new Error("--link-from requires a value"); - } - linkFrom.push(value); - index += 1; - continue; + const manifestPath = path.join( + options.targetDir, + definition.path, + "package.json", + ); + const manifest: unknown = JSON.parse(await readFile(manifestPath, "utf8")); + if ( + typeof manifest !== "object" || + manifest === null || + (manifest as { name?: unknown }).name !== definition.name + ) { + throw new Error( + `Package Addition requires manifest truth for ${definition.path}: expected name ${definition.name}`, + ); } - - throw new Error(`Unknown option: ${arg}`); - } - - if (!preset) { - throw new Error("add package requires --preset"); + scopes.add(match[1]); } - - if (!name) { - throw new Error("add package requires --name"); + if (scopes.size !== 1) { + throw new Error( + `Package Addition requires exactly one existing npm scope; found ${[...scopes].join(", ") || "none"}`, + ); } - - return { preset, name, path: packagePath, linkFrom }; + return [...scopes][0]!; } -async function main(args: string[]): Promise { +async function main(args: readonly string[]): Promise { const command = args[0]; - - if (command === "presets") { - console.log(formatPresetCatalog()); + if (command === "--help" || command === "-h") { + console.log(usage()); return; } - - if (command === "schema") { - const schemaName = args[1]; - - if (schemaName === "preset") { - printJson(presetFileJsonSchema); - return; - } - - if (schemaName === "blueprint") { - printJson(blueprintJsonSchema); - return; - } - - if (schemaName === "preset-source") { - printJson(presetSourceManifestJsonSchema); - return; - } - - throw new Error("schema requires preset, preset-source, or blueprint"); - } - - if (command === "preset" && args[1] === "validate") { - const filePath = args[2]; - if (!filePath) { - throw new Error("preset validate requires a path"); - } - - const result = validatePresetFile(await readJsonDeclaration(filePath), { - presets: builtInPresets, - }); - if (!result.ok) { - throw new Error( - `Preset file is invalid:\n${formatValidationIssues(result.issues)}`, - ); - } - - console.log(`Preset file is valid: ${result.value.name}`); + if (command === "presets") { + console.log(formatCatalog()); return; } - - if (command === "preset-source" && args[1] === "validate") { + if (command === "blueprint" && args[1] === "validate") { const filePath = args[2]; - if (!filePath) { - throw new Error("preset-source validate requires a path"); - } - - const declaration = await readJsonDeclaration(filePath); - const sourceName = - typeof declaration === "object" && - declaration !== null && - !Array.isArray(declaration) && - "name" in declaration && - typeof declaration.name === "string" - ? declaration.name - : undefined; - const result = - sourceName === "built-in" - ? validateBuiltInPresetSourceManifest(declaration, { - sourceRoot: path.dirname(filePath), - }) - : validatePresetSourceManifest(declaration, { - sourceRoot: path.dirname(filePath), - }); - if (!result.ok) { + if (!filePath) throw new Error("blueprint validate requires a path"); + const value: unknown = JSON.parse(await readFile(filePath, "utf8")); + if ( + typeof value === "object" && + value !== null && + "schemaVersion" in value && + value.schemaVersion === 1 + ) { throw new Error( - `Preset Source Manifest is invalid:\n${formatValidationIssues( - result.issues, - )}`, + "Unsupported Local Template Metadata: Blueprint version 1 is not supported", ); } - - console.log( - `Preset Source Manifest is valid: ${ - result.value.name - } (${result.value.presets.map((preset) => preset.name).join(", ")})`, - ); - return; - } - - if (command === "blueprint" && args[1] === "validate") { - const filePath = args[2]; - if (!filePath) { - throw new Error("blueprint validate requires a path"); - } - - const result = validateProjectBlueprint( - await readJsonDeclaration(filePath), - { - presets: builtInPresets, - }, - ); - if (!result.ok) { + const result = validateProjectBlueprintV2(value); + if (!result.ok) throw new Error( - `Blueprint is invalid:\n${formatValidationIssues(result.issues)}`, + result.issues + .map((issue) => `${issue.path}: ${issue.message}`) + .join("\n"), ); - } - - console.log(`Blueprint is valid: ${result.value.preset}`); + console.log("Blueprint is valid"); return; } - if (command === "init") { const options = parseInitOptions(args); - const blueprint = blueprintForInit(options); - + const result = await planInitialization(options); if (options.dryRun) { - const generationContext = await generationContextForInit( - options, - blueprint, - ); - const preset = findBuiltInPresetSourceManifestPreset(options.preset); - const projectionPlan = generationContext - ? preset?.projection - ? projectBuiltInPresetSourcePreset({ - preset, - context: generationContext, - }) - : undefined - : undefined; - if (!projectionPlan) { - throw new Error(`Missing Preset Projection plan: ${blueprint.preset}`); - } - const nextStepPlan = planProjectionNextSteps(options.dir, projectionPlan); - const nextSteps = nextStepPlan.steps; - const followUpDocument = followUpDocumentPlan(options); - - if (options.json) { - printJson({ - command: "init", - dryRun: true, - targetDir: options.dir, - blueprint, - ...(generationContext - ? { toolchain: toolchainReport(generationContext.toolchain) } - : {}), - nextSteps, - followUpDocument, - } satisfies InitJsonOutput); - return; - } - - console.log(formatBlueprintSummary(options.dir, blueprint)); - if (generationContext) { - console.log(""); - console.log(formatToolchainReport(generationContext.toolchain)); - } - console.log(""); - if (options.todo) { - console.log(formatFollowUpDocumentReport(options)); - } else { - console.log(formatNextStepInstructionsForCli(nextStepPlan)); - } + if (options.json) printJson(initOutput(options, result)); + else console.log(JSON.stringify(initOutput(options, result), null, 2)); return; } - if (!options.yes && (options.json || !isInteractiveTerminal())) { throw new Error("Non-interactive init requires --yes"); } - - if (!options.yes && !(await confirmInit(options.dir, blueprint))) { + if (!options.yes && !(await confirmInit(options, result.plan.blueprint))) throw new Error("Init cancelled"); - } - - const generationContext = await generationContextForInit( - options, - blueprint, - ); - await generateInitProject(options, generationContext); - printInitComplete(options, blueprint, generationContext); + await renderNewProject({ + targetRoot: options.dir, + operations: [ + ...result.plan.operations, + ...(options.todo ? [followUpDocumentOperation(result)] : []), + ], + }); + if (options.json) printJson(initOutput(options, result)); + else + console.log( + [ + "Initialized project", + "", + ...formatRows([ + ["Preset", result.definition.metadata.name], + ["Target", options.dir], + ]), + "", + "Next steps", + "", + ...result.plan.nextStepInstructions.map( + (instruction, index) => ` ${index + 1}. ${instruction.display}`, + ), + ].join("\n"), + ); return; } - if (command === "add" && args[1] === "package") { const options = parseAddPackageOptions(args); - await addPackage({ - cwd: process.cwd(), - preset: options.preset, - name: options.name, - path: options.path, - linkFrom: options.linkFrom, - presetSourceManifest: loadBuiltInPresetSourceManifest(), - projectionSourceRoots: builtInPresetProjectionSourceRoots(), + const blueprint = await readBlueprint( + path.join(process.cwd(), ".template/blueprint.json"), + ); + const toolchain = await resolveToolchain(); + const definition = builtInPresetRegistry.require(options.preset); + const context = createGenerationContext({ + targetDir: process.cwd(), + scope: await deriveExistingPackageScope({ + targetDir: process.cwd(), + blueprint, + }), + toolchain: { + nodeLtsMajor: toolchain.nodeLtsMajor.value, + packageManagerPin: toolchain.packageManagerPin.value, + }, + }); + const plan = planGeneratedRepositoryPackageAddition({ + definition, + context, + blueprint, + packageLeafName: options.name, + ...(options.path ? { packagePath: options.path } : {}), + ...(options.linkFrom.length > 0 ? { linkFrom: options.linkFrom } : {}), + }); + await renderProjectAtomically({ + targetRoot: process.cwd(), + operations: [...plan.operations], }); console.log( [ "Added package", "", - ...formatFieldRows([ - ["Preset", options.preset], + ...formatRows([ + ["Preset", definition.metadata.name], ["Name", options.name], - ...(options.path ? ([["Path", options.path]] as const) : []), ]), ].join("\n"), ); return; } - - if (command === "--help" || command === "-h") { - console.log(usage()); - return; - } - throw new Error(command ? `Unknown command: ${command}` : "Missing command"); } main(process.argv.slice(2)).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); - console.error(""); - console.error("Run `template --help` for usage."); + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}`, + ); + console.error("\nRun `template --help` for usage."); process.exitCode = 1; }); diff --git a/packages/core/package.json b/packages/core/package.json index 438893c..3993da2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,7 +21,6 @@ "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" }, "dependencies": { - "@ykdz/template-shared": "workspace:*", "semver": "catalog:", "typescript": "catalog:", "valibot": "catalog:" @@ -34,5 +33,10 @@ "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", "typescript-7": "catalog:" + }, + "turbo": { + "tags": [ + "template-core" + ] } } diff --git a/packages/core/src/declarations.ts b/packages/core/src/declarations.ts deleted file mode 100644 index 67f06c1..0000000 --- a/packages/core/src/declarations.ts +++ /dev/null @@ -1,21 +0,0 @@ -export { - blueprintJsonSchema, - findPreset, - presetFileJsonSchema, - presetFileSchema, - projectBlueprintSchema, - validatePresetFile, - validateProjectBlueprint, -} from "@ykdz/template-shared"; -export type { - BuiltInPreset, - FeatureName, - PackageManager, - PresetCatalogValidationOptions, - PresetFile, - ProjectBlueprint, - ProjectKind, - ProjectPackage, - ValidationIssue, - ValidationResult, -} from "@ykdz/template-shared"; diff --git a/packages/core/src/dependency-catalog.ts b/packages/core/src/dependency-catalog.ts index 9e50e99..5dd007a 100644 --- a/packages/core/src/dependency-catalog.ts +++ b/packages/core/src/dependency-catalog.ts @@ -419,9 +419,7 @@ export function renderGeneratedPnpmWorkspaceYaml( } const overrides = { - "pinia>typescript": "-", "valibot>typescript": "-", - "vue>typescript": "-", ...options.overrides, }; if (Object.keys(overrides).length > 0) { diff --git a/packages/core/src/devcontainer.ts b/packages/core/src/devcontainer.ts index 6c4803f..db7b468 100644 --- a/packages/core/src/devcontainer.ts +++ b/packages/core/src/devcontainer.ts @@ -1,5 +1,8 @@ import { loadTemplateDependencyCatalog } from "./dependency-catalog.ts"; -import type { WriteTextFromFragmentsOperation } from "./renderer.ts"; +import type { + TemplateSourceHandle, + WriteTextFromFragmentsOperation, +} from "./renderer.ts"; export type DevelopmentContainerNodePnpmLayer = { readonly kind: "node-pnpm"; @@ -27,7 +30,7 @@ type DevelopmentContainerCapabilityLayer = | DevelopmentContainerRustLayer; export type DevelopmentContainerDockerfileFragments = { - readonly sourceRoot: string; + readonly source: TemplateSourceHandle; readonly nodePnpm: { readonly from: string; readonly text: string; @@ -238,14 +241,14 @@ function checkedNodePnpmDockerfileFragments(options: { }): WriteTextFromFragmentsOperation["fragments"] { return [ { - sourceRoot: options.dockerfileFragments.sourceRoot, + source: options.dockerfileFragments.source, from: options.dockerfileFragments.nodePnpm.from, }, ...(options.additionalLayers ?? []).map((layer) => { switch (layer.kind) { case "browser-test": return { - sourceRoot: options.dockerfileFragments.sourceRoot, + source: options.dockerfileFragments.source, from: requireDockerfileFragment( options.dockerfileFragments, "browserTest", @@ -253,7 +256,7 @@ function checkedNodePnpmDockerfileFragments(options: { }; case "shellcheck": return { - sourceRoot: options.dockerfileFragments.sourceRoot, + source: options.dockerfileFragments.source, from: requireDockerfileFragment( options.dockerfileFragments, "shellCheck", @@ -261,7 +264,7 @@ function checkedNodePnpmDockerfileFragments(options: { }; case "rust": return { - sourceRoot: options.dockerfileFragments.sourceRoot, + source: options.dockerfileFragments.source, from: requireDockerfileFragment(options.dockerfileFragments, "rust") .from, }; diff --git a/packages/core/src/editor-customization.ts b/packages/core/src/editor-customization.ts index 806e750..85eeec6 100644 --- a/packages/core/src/editor-customization.ts +++ b/packages/core/src/editor-customization.ts @@ -2,64 +2,33 @@ import { readFileSync } from "node:fs"; import * as v from "valibot"; -export type EditorCustomizationCapability = - | "oxc-format-lint" - | "rust-tooling" - | "tailwind" - | "vue" - | "vitest"; +/** Capability identities are supplied by the owning Built-in Presets policy. */ +export type EditorCustomizationCapability = string; export type EditorCustomization = { readonly extensions: readonly string[]; readonly settings: Record; }; -export type EditorCustomizationOptions = { - readonly oxcConfigPaths?: - | "nested" - | { - readonly lint?: string; - readonly formatter?: string; - }; -}; - type CapabilityProjection = { readonly extensions: readonly string[]; readonly settings: Record; }; export type EditorCustomizationDeclarations = { - readonly capabilities: Record< - EditorCustomizationCapability, - CapabilityProjection + readonly capabilityOrder: readonly EditorCustomizationCapability[]; + readonly capabilities: Readonly< + Record >; }; -const capabilityOrder: readonly EditorCustomizationCapability[] = [ - "oxc-format-lint", - "vue", - "tailwind", - "rust-tooling", - "vitest", -]; - -const defaultOxcConfigPaths = { - formatter: "./oxfmt.config.ts", - lint: "./oxlint.config.ts", -}; - const capabilityProjectionSchema = v.object({ extensions: v.array(v.string()), settings: v.record(v.string(), v.unknown()), }); const editorCustomizationDeclarationsSchema = v.object({ - capabilities: v.object({ - "oxc-format-lint": capabilityProjectionSchema, - vue: capabilityProjectionSchema, - tailwind: capabilityProjectionSchema, - "rust-tooling": capabilityProjectionSchema, - vitest: capabilityProjectionSchema, - }), + capabilityOrder: v.array(v.string()), + capabilities: v.record(v.string(), capabilityProjectionSchema), }); function editorCustomizationDeclarationIssuePath( @@ -123,39 +92,6 @@ export function loadEditorCustomizationDeclarations( return result.output; } -function oxcConfigPathSettings( - configPaths: EditorCustomizationOptions["oxcConfigPaths"], -): Record { - if (configPaths === "nested") { - return {}; - } - - const paths = { - ...defaultOxcConfigPaths, - ...configPaths, - }; - - return { - "oxc.configPath": paths.lint, - "oxc.fmt.configPath": paths.formatter, - }; -} - -function oxcProjection(options: { - readonly declarations: EditorCustomizationDeclarations; - readonly editorOptions: EditorCustomizationOptions | undefined; -}): CapabilityProjection { - const baseProjection = options.declarations.capabilities["oxc-format-lint"]; - - return { - extensions: baseProjection.extensions, - settings: { - ...baseProjection.settings, - ...oxcConfigPathSettings(options.editorOptions?.oxcConfigPaths), - }, - }; -} - function mergeSettings( base: Record, patch: Record, @@ -166,21 +102,22 @@ function mergeSettings( export function editorCustomizationForCapabilities( capabilities: readonly EditorCustomizationCapability[], declarations: EditorCustomizationDeclarations, - options?: EditorCustomizationOptions, ): EditorCustomization { const selected = new Set(capabilities); const extensions = new Set(); let settings: Record = {}; - for (const capability of capabilityOrder) { + for (const capability of declarations.capabilityOrder) { if (!selected.has(capability)) { continue; } - const projection = - capability === "oxc-format-lint" - ? oxcProjection({ declarations, editorOptions: options }) - : declarations.capabilities[capability]; + const projection = declarations.capabilities[capability]; + if (projection === undefined) { + throw new Error( + `Editor customization policy orders undeclared capability ${capability}`, + ); + } for (const extension of projection.extensions) { extensions.add(extension); } diff --git a/packages/core/src/generated-scenarios.ts b/packages/core/src/generated-scenarios.ts deleted file mode 100644 index e2e21b3..0000000 --- a/packages/core/src/generated-scenarios.ts +++ /dev/null @@ -1,1254 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import { PackageAdditionSupport } from "@ykdz/template-shared"; -import { execa } from "execa"; -import * as v from "valibot"; - -import { assembleGenerationContext } from "./generation-context.ts"; -import { - type CheckEnvironmentNeed, - deploymentCheckEnvironmentNeeds, - type DeploymentCheckEnvironmentNeed, - playwrightBrowserAssetsEnvironmentNeed, -} from "./module-graph.ts"; -import { planNextStepInstructions } from "./next-step-instructions.ts"; -import { - canPlanPackageLinkIntent, - type PackageDefinition, -} from "./package-linking.ts"; -import type { - PresetSourceManifest, - PresetSourceManifestPreset, -} from "./preset-source.ts"; -import { findPresetSourceManifestPreset } from "./preset-source.ts"; -import { - blueprintForPresetSourcePreset, - projectPresetSourcePreset, - type PresetProjectionSourceRoots, -} from "./projection-capabilities.ts"; - -export type GeneratedScenarioSet = - | "init" - | "package-addition-matrix" - | "focused"; - -export type GeneratedScenario = { - readonly set: GeneratedScenarioSet; - readonly basePreset: string; - readonly addedPreset?: string; - readonly linkFrom?: readonly string[]; - readonly id: string; - readonly label: string; -}; - -export type SkippedGeneratedScenario = { - readonly set: GeneratedScenarioSet; - readonly basePreset: string; - readonly addedPreset: string; - readonly id: string; - readonly label: string; - readonly reason: string; -}; - -export type GeneratedScenarioSelection = { - readonly runnable: readonly GeneratedScenario[]; - readonly skipped: readonly SkippedGeneratedScenario[]; -}; - -export type GeneratedScenarioCommandStep = { - readonly id: string; - readonly command: string; - readonly args: readonly string[]; - readonly cwd: string; - readonly display: string; - readonly environmentNeedKind?: - | CheckEnvironmentNeed["kind"] - | DeploymentCheckEnvironmentNeed["kind"]; - readonly phase?: "deployment-preparation" | "deployment"; -}; - -export type GeneratedScenarioCommandRunner = ( - command: string, - args: readonly string[], - cwd: string, - options?: { - readonly env?: Record | undefined; - readonly logPrefix?: string | undefined; - }, -) => Promise; - -export type GeneratedScenarioReporter = { - readonly info?: (message: string) => void; - readonly error?: (message: string) => void; -}; - -export type GeneratedScenarioRunnerOptions = { - readonly repoRoot: string; - readonly cliPath: string; - readonly projectionSourceRoots: PresetProjectionSourceRoots; - readonly runCommand?: GeneratedScenarioCommandRunner; - readonly reporter?: GeneratedScenarioReporter; - readonly deterministicToolchainEnv?: Record; - readonly rootCheckLock?: ExclusiveLock; - readonly replayCache?: GeneratedScenarioReplayCache | undefined; -}; - -export type ExclusiveLock = { - readonly run: (callback: () => Promise) => Promise; -}; - -export type GeneratedScenarioReplayCache = { - readonly directory: string; - readonly read: boolean; - readonly write: boolean; -}; - -type GeneratedScenarioInstallResult = { - readonly fingerprint?: string | undefined; - readonly baseQualityGateReplayed: boolean; -}; - -const defaultDeterministicToolchainEnv = { - TEMPLATE_TOOLCHAIN_RESOLUTION: "bundled-fallback", -}; -const defaultFixtureConcurrency = 2; -const scenarioFingerprintVersion = 2; -const storedBlueprintSchema = v.object({ - packages: v.optional( - v.array( - v.object({ - name: v.string(), - path: v.string(), - }), - ), - ), -}); - -export function generatedScenarioChildProcessEnv( - env: Record = {}, -): NodeJS.ProcessEnv { - const childEnv: NodeJS.ProcessEnv = { ...process.env, ...env }; - - if (childEnv.NO_COLOR !== undefined && childEnv.FORCE_COLOR !== undefined) { - delete childEnv.FORCE_COLOR; - } - - return childEnv; -} - -export function createExclusiveLock(): ExclusiveLock { - let tail = Promise.resolve(); - - return { - async run(callback: () => Promise): Promise { - const previous = tail; - let release!: () => void; - tail = new Promise((resolve) => { - release = resolve; - }); - - await previous; - - try { - return await callback(); - } finally { - release(); - } - }, - }; -} - -export function generatedScenarioId( - scenario: Pick, -): string { - if (!scenario.addedPreset) { - return scenario.basePreset; - } - - if (scenario.linkFrom && scenario.linkFrom.length > 0) { - const linkFromId = scenario.linkFrom - .map((packagePath) => packagePath.replaceAll("/", "-")) - .join("-"); - - return `${scenario.basePreset}-add-${scenario.addedPreset}-link-from-${linkFromId}`; - } - - return `${scenario.basePreset}-add-${scenario.addedPreset}`; -} - -export function generatedScenarioLabel( - scenario: Pick, -): string { - if (!scenario.addedPreset) { - return scenario.basePreset; - } - - if (scenario.linkFrom && scenario.linkFrom.length > 0) { - return `${scenario.basePreset} + ${scenario.addedPreset} linked from ${scenario.linkFrom.join(", ")}`; - } - - return `${scenario.basePreset} + ${scenario.addedPreset}`; -} - -function createGeneratedScenario( - set: GeneratedScenarioSet, - input: Pick, -): GeneratedScenario { - return { - set, - basePreset: input.basePreset, - ...(input.addedPreset === undefined - ? {} - : { addedPreset: input.addedPreset }), - ...(input.linkFrom === undefined ? {} : { linkFrom: [...input.linkFrom] }), - id: generatedScenarioId(input), - label: generatedScenarioLabel(input), - }; -} - -export function selectGeneratedScenarios( - manifest: PresetSourceManifest, - set: GeneratedScenarioSet, -): GeneratedScenarioSelection { - const supportedInitPresets = manifest.presets.filter( - (preset) => preset.generation === "supported", - ); - const initScenarios = supportedInitPresets.map((preset) => - createGeneratedScenario("init", { basePreset: preset.name }), - ); - - if (set === "init") { - return { runnable: initScenarios, skipped: [] }; - } - - const addablePresets = manifest.presets.filter( - (preset) => - preset.packageAdditionSupport === PackageAdditionSupport.Supported, - ); - const focusedScenarios = selectFocusedGeneratedScenarios( - manifest, - addablePresets, - ); - - if (set === "focused") { - return { - runnable: focusedScenarios, - skipped: [], - }; - } - - const matrixScenarios = supportedInitPresets.flatMap((basePreset) => - addablePresets.map((addedPreset) => - createGeneratedScenario("package-addition-matrix", { - basePreset: basePreset.name, - addedPreset: addedPreset.name, - }), - ), - ); - - return { - runnable: [...matrixScenarios, ...focusedScenarios], - skipped: [], - }; -} - -function selectFocusedGeneratedScenarios( - manifest: PresetSourceManifest, - addablePresets: readonly PresetSourceManifestPreset[], -): GeneratedScenario[] { - return manifest.presets - .filter((preset) => preset.generation === "supported") - .flatMap((basePreset) => - addablePresets.flatMap((addedPreset) => { - const consumerPackagePath = focusedPackageLinkConsumerPath( - basePreset, - addedPreset, - ); - - return consumerPackagePath === undefined - ? [] - : [ - createGeneratedScenario("focused", { - basePreset: basePreset.name, - addedPreset: addedPreset.name, - linkFrom: [consumerPackagePath], - }), - ]; - }), - ); -} - -function focusedPackageLinkConsumerPath( - basePreset: PresetSourceManifestPreset, - addedPreset: PresetSourceManifestPreset, -): string | undefined { - const providers = packageLinkProviderDefinitions(addedPreset); - - return packageLinkConsumerDefinitions(basePreset).find((consumer) => - providers.some((provider) => - canPlanPackageLinkIntent({ consumer, provider }), - ), - )?.path; -} - -function packageLinkProviderDefinitions( - preset: PresetSourceManifestPreset, -): PackageDefinition[] { - return ( - preset.projection?.capabilities.flatMap((capability) => { - if (capability.kind !== "workspace-library-package") { - return []; - } - - const packageLeafName = `fixture-${preset.name}`; - - return [ - { - name: `@fixture/${packageLeafName}`, - path: `${packageCollection(capability.workspacePackageGlob)}/${packageLeafName}`, - role: capability.packageRole, - sourcePreset: capability.packageSourcePreset, - }, - ]; - }) ?? [] - ); -} - -function packageLinkConsumerDefinitions( - preset: PresetSourceManifestPreset, -): PackageDefinition[] { - return ( - preset.projection?.capabilities.flatMap((capability) => { - if (capability.kind !== "workspace-node-packages") { - return []; - } - - return capability.packages - .filter((nodePackage) => - nodePackage.sourceFiles.some((sourceFile) => - hasSourceDirectoryPath(sourceFile), - ), - ) - .map((nodePackage) => ({ - name: `@fixture/${packageLeaf(nodePackage.path)}`, - path: nodePackage.path, - role: "runtime-service" as const, - sourcePreset: nodePackage.kind, - })); - }) ?? [] - ); -} - -function packageCollection(workspacePackageGlob: string): string { - const [collection, wildcard] = workspacePackageGlob.split("/"); - if (!collection || wildcard !== "*") { - throw new Error( - `Unsupported workspace package glob: ${workspacePackageGlob}`, - ); - } - - return collection; -} - -function packageLeaf(packagePath: string): string { - return packagePath.split("/").at(-1) ?? packagePath; -} - -function hasSourceDirectoryPath(sourceFile: string): boolean { - return sourceFile.startsWith("src/") || sourceFile.includes("/src/"); -} - -function scenarioFingerprintPrefix(scenario: GeneratedScenario): string { - return JSON.stringify({ - version: scenarioFingerprintVersion, - scenario, - platform: process.platform, - arch: process.arch, - nodeMajor: process.versions.node.split(".")[0], - qualityGateProtocol: "lockfile-only-snapshot-then-fix-check", - }); -} - -async function hashGeneratedScenarioDirectory( - scenario: GeneratedScenario, - projectDir: string, -): Promise { - const hash = createHash("sha256"); - - hash.update(scenarioFingerprintPrefix(scenario)); - await hashDirectoryEntries(projectDir, projectDir, hash); - - return hash.digest("hex"); -} - -async function hashDirectoryEntries( - rootDir: string, - currentDir: string, - hash: ReturnType, -): Promise { - const entries = await readdir(currentDir, { withFileTypes: true }); - - for (const entry of entries.toSorted((left, right) => - left.name.localeCompare(right.name), - )) { - if (entry.name === "node_modules" || entry.name === ".git") { - continue; - } - - const entryPath = path.join(currentDir, entry.name); - const relativePath = path - .relative(rootDir, entryPath) - .split(path.sep) - .join("/"); - - if (entry.isDirectory()) { - hash.update(`dir\0${relativePath}\0`); - await hashDirectoryEntries(rootDir, entryPath, hash); - continue; - } - - if (!entry.isFile()) { - const fileStat = await stat(entryPath); - hash.update(`special\0${relativePath}\0${fileStat.mode.toString(8)}\0`); - continue; - } - - const fileStat = await stat(entryPath); - hash.update(`file\0${relativePath}\0${fileStat.mode.toString(8)}\0`); - hash.update(await readFile(entryPath)); - hash.update("\0"); - } -} - -async function scenarioReplayMarkerExists( - cache: GeneratedScenarioReplayCache, - fingerprint: string, -): Promise { - try { - await readFile(path.join(cache.directory, `${fingerprint}.passed`), "utf8"); - return true; - } catch (error: unknown) { - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) { - return false; - } - - throw error; - } -} - -async function writeScenarioReplayMarker( - cache: GeneratedScenarioReplayCache, - fingerprint: string, - scenario: GeneratedScenario, -): Promise { - await mkdir(cache.directory, { recursive: true }); - await writeFile( - path.join(cache.directory, `${fingerprint}.passed`), - `${JSON.stringify({ scenario, fingerprint })}\n`, - "utf8", - ); -} - -function deploymentReplayFingerprint(fingerprint: string): string { - return createHash("sha256") - .update(`${fingerprint}\0docker-engine-available`) - .digest("hex"); -} - -export function packageLeafNameForAddedPreset( - manifest: PresetSourceManifest, - presetName: string, -): string { - const preset = manifest.presets.find((entry) => entry.name === presetName); - - if (!preset) { - throw new Error(`Unknown fixture Package Addition preset: ${presetName}`); - } - - if (preset.packageAdditionSupport !== PackageAdditionSupport.Supported) { - throw new Error( - `Preset ${presetName} cannot be used for fixture Package Addition`, - ); - } - - const packageLeafName = `fixture-${presetName}`; - if (!/^[a-z0-9][a-z0-9-]*$/.test(packageLeafName)) { - throw new Error( - `Fixture Package Addition leaf name for preset ${presetName} must be a lowercase package leaf name using letters, numbers, and hyphens`, - ); - } - - return packageLeafName; -} - -export async function defaultGeneratedScenarioCommandRunner( - command: string, - args: readonly string[], - cwd: string, - options: { - readonly env?: Record | undefined; - readonly logPrefix?: string | undefined; - } = {}, -): Promise { - const prefix = options.logPrefix ? `[${options.logPrefix}] ` : ""; - - console.log(`${prefix}$ ${[command, ...args].join(" ")}`); - await execa(command, [...args], { - cwd, - env: generatedScenarioChildProcessEnv(options.env), - extendEnv: false, - stdio: "inherit", - }); -} - -async function generateScenario( - scenario: GeneratedScenario, - workspace: string, - options: RequiredRunnerOptions, -): Promise { - const projectDir = path.join(workspace, `fixture-${scenario.id}`); - - await options.runCommand( - "node", - [ - "--conditions=source", - options.cliPath, - "init", - projectDir, - "--preset", - scenario.basePreset, - "--yes", - ], - options.repoRoot, - { - env: options.deterministicToolchainEnv, - logPrefix: scenario.label, - }, - ); - - return projectDir; -} - -async function applyPackageAddition( - manifest: PresetSourceManifest, - scenario: GeneratedScenario, - projectDir: string, - options: RequiredRunnerOptions, -): Promise { - if (!scenario.addedPreset) { - return undefined; - } - - const packageLeafName = packageLeafNameForAddedPreset( - manifest, - scenario.addedPreset, - ); - const beforeConsumerSources = await linkedConsumerSourceSnapshots( - projectDir, - scenario.linkFrom ?? [], - ); - await options.runCommand( - "node", - [ - "--conditions=source", - options.cliPath, - "add", - "package", - "--preset", - scenario.addedPreset, - "--name", - packageLeafName, - ...linkFromArgs(scenario.linkFrom ?? []), - ], - projectDir, - { - logPrefix: scenario.label, - }, - ); - await assertLinkedConsumerSourcesUnchanged(projectDir, beforeConsumerSources); - - return readAddedPackagePath(projectDir, packageLeafName); -} - -function linkFromArgs(packagePaths: readonly string[]): string[] { - return packagePaths.flatMap((packagePath) => ["--link-from", packagePath]); -} - -async function linkedConsumerSourceSnapshots( - projectDir: string, - packagePaths: readonly string[], -): Promise>> { - const snapshots = new Map>(); - - for (const packagePath of packagePaths) { - snapshots.set( - packagePath, - await sourceFileSnapshot(path.join(projectDir, packagePath, "src")), - ); - } - - return snapshots; -} - -async function sourceFileSnapshot( - sourceDir: string, - currentDir = sourceDir, -): Promise> { - const snapshot: Record = {}; - const entries = await readdir(currentDir, { withFileTypes: true }); - - for (const entry of entries.toSorted((left, right) => - left.name.localeCompare(right.name), - )) { - const entryPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - Object.assign(snapshot, await sourceFileSnapshot(sourceDir, entryPath)); - continue; - } - - if (entry.isFile()) { - snapshot[path.relative(sourceDir, entryPath)] = await readFile( - entryPath, - "utf8", - ); - } - } - - return snapshot; -} - -async function assertLinkedConsumerSourcesUnchanged( - projectDir: string, - beforeSnapshots: ReadonlyMap>, -): Promise { - for (const [packagePath, beforeSnapshot] of beforeSnapshots) { - const afterSnapshot = await sourceFileSnapshot( - path.join(projectDir, packagePath, "src"), - ); - - if (JSON.stringify(afterSnapshot) !== JSON.stringify(beforeSnapshot)) { - throw new Error( - `Package Link Intent fixture modified consumer source files for ${packagePath}`, - ); - } - } -} - -async function readAddedPackagePath( - projectDir: string, - packageLeafName: string, -): Promise { - const input = JSON.parse( - await readFile( - path.join(projectDir, ".template", "blueprint.json"), - "utf8", - ), - ) as unknown; - const result = v.safeParse(storedBlueprintSchema, input); - if (!result.success) { - throw new Error( - `Generated Repository blueprint is invalid: ${result.issues - .map((issue) => issue.message) - .join("; ")}`, - ); - } - - const packageDefinition = result.output.packages?.find( - (pkg) => path.basename(pkg.path) === packageLeafName, - ); - - if (!packageDefinition) { - throw new Error( - `Package Addition fixture did not record package leaf ${packageLeafName}`, - ); - } - - return packageDefinition.path; -} - -function projectionPlanForPreset( - presetName: string, - projectDir: string, - manifest: PresetSourceManifest, - options: RequiredRunnerOptions, - packageScope?: string, -) { - const preset = findPresetSourceManifestPreset(manifest, presetName); - - if (!preset?.projection) { - throw new Error( - `Missing Preset Projection for fixture preset ${presetName}`, - ); - } - - const blueprint = blueprintForPresetSourcePreset(preset, { - targetDir: projectDir, - ...(packageScope === undefined ? {} : { scope: packageScope }), - }); - return projectPresetSourcePreset({ - preset, - sourceRoots: options.projectionSourceRoots, - context: assembleGenerationContext({ - targetDir: projectDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.11.0" }, - source: "bundled-fallback", - diagnostics: [], - }, - }), - }); -} - -function machineVerifiableNextStepsForPreset( - presetName: string, - projectDir: string, - manifest: PresetSourceManifest, - options: RequiredRunnerOptions, -): GeneratedScenarioCommandStep[] { - const projectionPlan = projectionPlanForPreset( - presetName, - projectDir, - manifest, - options, - ); - const plan = planNextStepInstructions({ - targetDir: projectDir, - projectionPlan, - }); - - return plan.steps - .filter((step) => step.machineVerifiable && step.kind === "command") - .map((step) => { - return { - id: step.id, - command: step.command, - args: step.args, - cwd: step.cwd, - display: step.display, - ...(step.environmentNeedKind === undefined - ? {} - : { environmentNeedKind: step.environmentNeedKind }), - }; - }); -} - -function deploymentStepsForPreset( - presetName: string, - scenario: GeneratedScenario, - projectDir: string, - manifest: PresetSourceManifest, - options: RequiredRunnerOptions, -): GeneratedScenarioCommandStep[] { - if (scenario.set === "init") { - return []; - } - - const deploymentChecks = - projectionPlanForPreset(presetName, projectDir, manifest, options).checkPlan - .deploymentChecks ?? []; - const needsDocker = deploymentChecks.some((check) => - deploymentCheckEnvironmentNeeds(check).some( - (need) => need.kind === "docker-engine", - ), - ); - - if (!needsDocker) { - return []; - } - - return [ - { - id: "check-docker-engine", - command: "docker", - args: ["info", "--format", "{{.ServerVersion}}"], - cwd: projectDir, - display: "docker info --format {{.ServerVersion}}", - environmentNeedKind: "docker-engine", - phase: "deployment-preparation", - }, - { - id: "run-deployment-check", - command: "pnpm", - args: ["run", "check:deployment"], - cwd: projectDir, - display: "pnpm run check:deployment", - phase: "deployment", - }, - ]; -} - -function addedPresetEnvironmentNeeds( - manifest: PresetSourceManifest, - scenario: GeneratedScenario, - projectDir: string, - addedPackagePath: string | undefined, - options: RequiredRunnerOptions, -): CheckEnvironmentNeed[] { - if (!scenario.addedPreset || !addedPackagePath) { - return []; - } - - return projectionPlanForPreset( - scenario.addedPreset, - projectDir, - manifest, - options, - path.basename(projectDir), - ).checkPlan.environmentNeeds.map((need) => { - if (need.owner.kind !== "package-boundary") { - return need; - } - - switch (need.kind) { - case "playwright-browser-assets": - return playwrightBrowserAssetsEnvironmentNeed({ - browser: need.browser, - owner: { kind: "package-boundary", path: addedPackagePath }, - machineVerifiable: need.nextStep.machineVerifiable, - }); - case "shellcheck-command": - return { - ...need, - owner: { kind: "package-boundary" as const, path: addedPackagePath }, - }; - } - }); -} - -function environmentNeedStep( - need: CheckEnvironmentNeed, - projectDir: string, -): GeneratedScenarioCommandStep { - return { - id: need.nextStep.id, - command: need.nextStep.command, - args: [...need.nextStep.args], - cwd: projectDir, - display: need.nextStep.display, - environmentNeedKind: need.kind, - }; -} - -export function generatedScenarioEnvironmentNeedSteps( - needs: readonly CheckEnvironmentNeed[], - projectDir: string, -): GeneratedScenarioCommandStep[] { - return needs - .filter((need) => need.nextStep.machineVerifiable) - .map((need) => environmentNeedStep(need, projectDir)); -} - -export function generatedScenarioRequiresSerializedRootCheck( - steps: readonly GeneratedScenarioCommandStep[], -): boolean { - return steps.some( - (step) => step.environmentNeedKind === "playwright-browser-assets", - ); -} - -export function generatedScenarioQualityGateSteps( - manifest: PresetSourceManifest, - scenario: GeneratedScenario, - projectDir: string, - addedPackagePath: string | undefined, - options: GeneratedScenarioRunnerOptions, -): GeneratedScenarioCommandStep[] { - const normalizedOptions = normalizeRunnerOptions(options); - const steps = machineVerifiableNextStepsForPreset( - scenario.basePreset, - projectDir, - manifest, - normalizedOptions, - ); - const addedEnvironmentSteps = generatedScenarioEnvironmentNeedSteps( - addedPresetEnvironmentNeeds( - manifest, - scenario, - projectDir, - addedPackagePath, - normalizedOptions, - ), - projectDir, - ); - const existingDisplays = new Set(steps.map((step) => step.display)); - const extraEnvironmentSteps = addedEnvironmentSteps.filter( - (step) => !existingDisplays.has(step.display), - ); - const deploymentSteps = deploymentStepsForPreset( - scenario.basePreset, - scenario, - projectDir, - manifest, - normalizedOptions, - ); - - if (extraEnvironmentSteps.length === 0) { - return [...steps, ...deploymentSteps]; - } - - const rootCheckIndex = steps.findIndex( - (step) => step.id === "run-root-check", - ); - if (rootCheckIndex === -1) { - return [...steps, ...extraEnvironmentSteps, ...deploymentSteps]; - } - - return [ - ...steps.slice(0, rootCheckIndex), - ...extraEnvironmentSteps, - ...steps.slice(rootCheckIndex), - ...deploymentSteps, - ]; -} - -async function runGeneratedScenarioQualityGate( - manifest: PresetSourceManifest, - scenario: GeneratedScenario, - projectDir: string, - addedPackagePath: string | undefined, - options: RequiredRunnerOptions, -): Promise { - const steps = generatedScenarioQualityGateSteps( - manifest, - scenario, - projectDir, - addedPackagePath, - options, - ); - const requiresSerializedPlaywrightRootCheck = - generatedScenarioRequiresSerializedRootCheck(steps); - let replayFingerprint: string | undefined; - let baseQualityGateReplayed = false; - let baseQualityGateMarkerWritten = false; - - for (const step of steps) { - if (step.id === "install-dependencies") { - const installResult = await runGeneratedScenarioInstallStep( - step, - scenario, - options, - ); - replayFingerprint = installResult.fingerprint; - baseQualityGateReplayed = installResult.baseQualityGateReplayed; - continue; - } - - if ( - baseQualityGateReplayed && - step.phase !== "deployment-preparation" && - step.phase !== "deployment" - ) { - continue; - } - - const env = step.id === "run-root-check" ? { CI: "1" } : undefined; - const runStep = async () => { - await options.runCommand(step.command, [...step.args], step.cwd, { - ...(env === undefined ? {} : { env }), - logPrefix: scenario.label, - }); - }; - - if (step.environmentNeedKind === "docker-engine") { - try { - await runStep(); - } catch (error: unknown) { - throw new Error( - `Deployment check requires the docker-engine Check Environment capability for ${scenario.label}; command: ${step.display}.`, - { cause: error }, - ); - } - continue; - } - - if (step.phase === "deployment") { - const deploymentFingerprint = - replayFingerprint === undefined - ? undefined - : deploymentReplayFingerprint(replayFingerprint); - if ( - deploymentFingerprint !== undefined && - options.replayCache?.read && - (await scenarioReplayMarkerExists( - options.replayCache, - deploymentFingerprint, - )) - ) { - options.reporter.info?.( - `-- Replayed passed deployment fixture ${scenario.label}: ${deploymentFingerprint}`, - ); - continue; - } - try { - await runStep(); - } catch (error: unknown) { - throw new Error( - `Generated deployment command failed for ${scenario.label}; command: ${step.display}. See the cause for the deployment mode, phase, command output, and retained container logs.`, - { cause: error }, - ); - } - if (deploymentFingerprint !== undefined && options.replayCache?.write) { - await writeScenarioReplayMarker( - options.replayCache, - deploymentFingerprint, - scenario, - ); - options.reporter.info?.( - `-- Stored passed deployment fixture replay marker for ${scenario.label}: ${deploymentFingerprint}`, - ); - } - continue; - } - - if (step.id === "run-root-check" && requiresSerializedPlaywrightRootCheck) { - await options.rootCheckLock.run(runStep); - } else { - await runStep(); - } - if ( - step.id === "run-root-check" && - options.replayCache?.write && - replayFingerprint !== undefined - ) { - await writeScenarioReplayMarker( - options.replayCache, - replayFingerprint, - scenario, - ); - baseQualityGateMarkerWritten = true; - options.reporter.info?.( - `-- Stored passed fixture replay marker for ${scenario.label}: ${replayFingerprint}`, - ); - } - } - - if ( - options.replayCache?.write && - replayFingerprint !== undefined && - !baseQualityGateMarkerWritten && - !baseQualityGateReplayed - ) { - await writeScenarioReplayMarker( - options.replayCache, - replayFingerprint, - scenario, - ); - options.reporter.info?.( - `-- Stored passed fixture replay marker for ${scenario.label}: ${replayFingerprint}`, - ); - } -} - -async function runGeneratedScenarioInstallStep( - step: GeneratedScenarioCommandStep, - scenario: GeneratedScenario, - options: RequiredRunnerOptions, -): Promise { - await options.runCommand( - step.command, - ["install", "--lockfile-only", "--prefer-offline", "--no-frozen-lockfile"], - step.cwd, - { logPrefix: scenario.label }, - ); - let replayFingerprint: string | undefined; - - if (options.replayCache) { - replayFingerprint = await hashGeneratedScenarioDirectory( - scenario, - step.cwd, - ); - - if ( - options.replayCache.read && - (await scenarioReplayMarkerExists(options.replayCache, replayFingerprint)) - ) { - options.reporter.info?.( - `-- Replayed passed base quality gate for ${scenario.label}: ${replayFingerprint}`, - ); - return { fingerprint: replayFingerprint, baseQualityGateReplayed: true }; - } - } - - await options.runCommand(step.command, ["fetch"], step.cwd, { - logPrefix: scenario.label, - }); - await options.runCommand( - step.command, - ["install", "--offline", "--frozen-lockfile"], - step.cwd, - { logPrefix: scenario.label }, - ); - - return { fingerprint: replayFingerprint, baseQualityGateReplayed: false }; -} - -async function checkGeneratedScenario( - manifest: PresetSourceManifest, - scenario: GeneratedScenario, - workspace: string, - options: RequiredRunnerOptions, -): Promise { - options.reporter.info?.(`\n== ${scenario.label} ==`); - const projectDir = await generateScenario(scenario, workspace, options); - const addedPackagePath = await applyPackageAddition( - manifest, - scenario, - projectDir, - options, - ); - await runGeneratedScenarioQualityGate( - manifest, - scenario, - projectDir, - addedPackagePath, - options, - ); -} - -export function fixtureConcurrency(scenarioCount: number): number { - const rawValue = process.env.TEMPLATE_FIXTURE_CONCURRENCY; - - if (rawValue === undefined || rawValue === "") { - return Math.min(defaultFixtureConcurrency, scenarioCount); - } - - const value = Number(rawValue); - - if (!Number.isInteger(value) || value < 1) { - throw new Error("TEMPLATE_FIXTURE_CONCURRENCY must be a positive integer"); - } - - return Math.min(value, scenarioCount); -} - -export function errorForFailedGeneratedScenario( - scenario: GeneratedScenario, - error: unknown, -): Error { - if (error instanceof Error) { - return new Error(`Fixture scenario failed: ${scenario.label}`, { - cause: error, - }); - } - - return new Error( - `Fixture scenario failed: ${scenario.label}: ${String(error)}`, - ); -} - -type RequiredRunnerOptions = Required< - Pick< - GeneratedScenarioRunnerOptions, - | "repoRoot" - | "cliPath" - | "projectionSourceRoots" - | "runCommand" - | "reporter" - | "deterministicToolchainEnv" - | "rootCheckLock" - | "replayCache" - > ->; - -function normalizeRunnerOptions( - options: GeneratedScenarioRunnerOptions, -): RequiredRunnerOptions { - return { - repoRoot: options.repoRoot, - cliPath: options.cliPath, - projectionSourceRoots: options.projectionSourceRoots, - runCommand: options.runCommand ?? defaultGeneratedScenarioCommandRunner, - reporter: options.reporter ?? { - info: console.log, - error: console.error, - }, - deterministicToolchainEnv: - options.deterministicToolchainEnv ?? defaultDeterministicToolchainEnv, - rootCheckLock: options.rootCheckLock ?? createExclusiveLock(), - replayCache: options.replayCache, - }; -} - -export async function runGeneratedScenariosConcurrently( - manifest: PresetSourceManifest, - scenarios: readonly GeneratedScenario[], - workspace: string, - concurrency: number, - options: GeneratedScenarioRunnerOptions, -): Promise { - const normalizedOptions = normalizeRunnerOptions(options); - let nextScenarioIndex = 0; - let firstFailure: Error | undefined; - - async function worker(): Promise { - while (firstFailure === undefined) { - const scenario = scenarios[nextScenarioIndex]; - nextScenarioIndex += 1; - - if (!scenario) { - return; - } - - try { - await checkGeneratedScenario( - manifest, - scenario, - workspace, - normalizedOptions, - ); - } catch (error: unknown) { - firstFailure ??= errorForFailedGeneratedScenario(scenario, error); - return; - } - } - } - - await Promise.all( - Array.from({ length: concurrency }, async () => { - await worker(); - }), - ); - - if (firstFailure) { - throw firstFailure; - } -} - -export async function runGeneratedScenarioSet( - manifest: PresetSourceManifest, - set: GeneratedScenarioSet, - workspace: string, - options: GeneratedScenarioRunnerOptions, -): Promise { - const normalizedOptions = normalizeRunnerOptions(options); - const selection = selectGeneratedScenarios(manifest, set); - const concurrency = fixtureConcurrency(selection.runnable.length); - - for (const skippedScenario of selection.skipped) { - normalizedOptions.reporter.info?.( - `-- Skipping ${skippedScenario.label}: ${skippedScenario.reason}`, - ); - } - - normalizedOptions.reporter.info?.( - `Checking ${selection.runnable.length} fixture scenarios with concurrency ${concurrency}.`, - ); - await runGeneratedScenariosConcurrently( - manifest, - selection.runnable, - workspace, - concurrency, - normalizedOptions, - ); - - return selection; -} diff --git a/packages/core/src/generation-context.ts b/packages/core/src/generation-context.ts deleted file mode 100644 index 722e4eb..0000000 --- a/packages/core/src/generation-context.ts +++ /dev/null @@ -1,51 +0,0 @@ -import path from "node:path"; - -import type { PackageManager, ProjectBlueprint } from "@ykdz/template-shared"; - -import type { ResolvedToolchainVersions } from "./toolchain-resolution.ts"; - -export type ProjectName = { - readonly kind: "ProjectName"; - readonly value: string; -}; - -export type GenerationPackageManager = { - readonly kind: "PackageManager"; - readonly value: PackageManager; -}; - -export type GenerationContext = { - readonly projectName: ProjectName; - readonly preset: string; - readonly packageManager?: GenerationPackageManager; - readonly blueprint: ProjectBlueprint; - readonly toolchain: ResolvedToolchainVersions; -}; - -export type AssembleGenerationContextOptions = { - readonly targetDir: string; - readonly blueprint: ProjectBlueprint; - readonly toolchain: ResolvedToolchainVersions; -}; - -export function assembleGenerationContext( - options: AssembleGenerationContextOptions, -): GenerationContext { - return { - projectName: { - kind: "ProjectName", - value: path.basename(path.resolve(options.targetDir)), - }, - preset: options.blueprint.preset, - ...(options.blueprint.packageManager - ? { - packageManager: { - kind: "PackageManager" as const, - value: options.blueprint.packageManager, - }, - } - : {}), - blueprint: options.blueprint, - toolchain: options.toolchain, - }; -} diff --git a/packages/core/src/module-graph.ts b/packages/core/src/module-graph.ts index b6d308d..23af5a7 100644 --- a/packages/core/src/module-graph.ts +++ b/packages/core/src/module-graph.ts @@ -61,9 +61,52 @@ export type ShellCheckEnvironmentNeed = { }; }; +/** Rust check components require the maintained toolchain in local and CI plans. */ +export type RustToolchainEnvironmentNeed = { + readonly kind: "rust-toolchain"; + readonly owner: ComponentOwner; + readonly toolchain: "stable"; + readonly nextStep: { + readonly id: "install-rust-toolchain"; + readonly label: "Install Rust toolchain"; + readonly command: "rustup"; + readonly args: readonly string[]; + readonly display: "rustup toolchain install stable --component rustfmt --component clippy"; + readonly machineVerifiable: boolean; + }; +}; + export type CheckEnvironmentNeed = | PlaywrightBrowserAssetsEnvironmentNeed - | ShellCheckEnvironmentNeed; + | ShellCheckEnvironmentNeed + | RustToolchainEnvironmentNeed; + +export function rustToolchainEnvironmentNeed( + owner: ComponentOwner, +): RustToolchainEnvironmentNeed { + return { + kind: "rust-toolchain", + owner, + toolchain: "stable", + nextStep: { + id: "install-rust-toolchain", + label: "Install Rust toolchain", + command: "rustup", + args: [ + "toolchain", + "install", + "stable", + "--component", + "rustfmt", + "--component", + "clippy", + ], + display: + "rustup toolchain install stable --component rustfmt --component clippy", + machineVerifiable: false, + }, + }; +} export type CheckComponent = { readonly kind: CheckComponentKind; diff --git a/packages/core/src/next-step-instructions.ts b/packages/core/src/next-step-instructions.ts deleted file mode 100644 index 32fff5b..0000000 --- a/packages/core/src/next-step-instructions.ts +++ /dev/null @@ -1,264 +0,0 @@ -import path from "node:path"; - -import type { CheckEnvironmentNeed } from "./module-graph.ts"; -import type { PresetProjectionPlan } from "./preset-projection.ts"; -import type { RenderOperation } from "./renderer.ts"; - -export type NextStepInstruction = { - readonly id: string; - readonly label: string; - readonly kind: "navigation" | "command"; - readonly command: string; - readonly args: readonly string[]; - readonly cwd: string; - readonly display: string; - readonly machineVerifiable: boolean; - readonly environmentNeedKind?: CheckEnvironmentNeed["kind"]; -}; - -export type NextStepInstructionPlan = { - readonly targetDir: string; - readonly steps: readonly NextStepInstruction[]; -}; - -export type PlanNextStepInstructionsOptions = { - readonly targetDir: string; - readonly projectionPlan: PresetProjectionPlan; -}; - -export type FollowUpDocumentPlan = { - readonly enabled: boolean; - readonly path?: "TODO.md"; -}; - -function rootCheckInstruction(): NextStepInstruction { - return { - id: "run-root-check", - label: "Run Root Check", - kind: "command", - command: "pnpm", - args: ["run", "check"], - cwd: "", - display: "pnpm run check", - machineVerifiable: true, - }; -} - -function optionalGitInstructions(): NextStepInstruction[] { - return [ - { - id: "optional-git-init", - label: "Optional: initialize git", - kind: "command", - command: "git", - args: ["init"], - cwd: "", - display: "git init", - machineVerifiable: false, - }, - { - id: "optional-git-add", - label: "Optional: stage files", - kind: "command", - command: "git", - args: ["add", "."], - cwd: "", - display: "git add .", - machineVerifiable: false, - }, - { - id: "optional-git-commit", - label: "Optional: create your first commit", - kind: "command", - command: "git", - args: ["commit", "-m", "Initial commit"], - cwd: "", - display: 'git commit -m "Initial commit"', - machineVerifiable: false, - }, - ]; -} - -function installDependenciesInstruction(): NextStepInstruction { - return { - id: "install-dependencies", - label: "Install dependencies", - kind: "command", - command: "pnpm", - args: ["install"], - cwd: "", - display: "pnpm install", - machineVerifiable: true, - }; -} - -function fixInstruction(): NextStepInstruction { - return { - id: "run-fix", - label: "Run Fix Command", - kind: "command", - command: "pnpm", - args: ["run", "fix"], - cwd: "", - display: "pnpm run fix", - machineVerifiable: true, - }; -} - -function checkEnvironmentInstructions( - plan: PresetProjectionPlan, -): NextStepInstruction[] { - return plan.checkPlan.environmentNeeds.map((need) => ({ - id: need.nextStep.id, - label: need.nextStep.label, - kind: "command", - command: need.nextStep.command, - args: need.nextStep.args, - cwd: "", - display: need.nextStep.display, - machineVerifiable: need.nextStep.machineVerifiable, - environmentNeedKind: need.kind, - })); -} - -function projectionInstructions( - plan: PresetProjectionPlan, -): NextStepInstruction[] { - const steps = [installDependenciesInstruction()]; - - if (plan.capabilities.fixCommand) { - steps.push(fixInstruction()); - } - - steps.push(...checkEnvironmentInstructions(plan)); - - if (plan.capabilities.rootCheck) { - steps.push(rootCheckInstruction()); - } - - steps.push(...optionalGitInstructions()); - - return steps; -} - -export function planNextStepInstructions( - options: PlanNextStepInstructionsOptions, -): NextStepInstructionPlan { - const targetDir = options.targetDir; - const commandSteps = projectionInstructions(options.projectionPlan).map( - (step) => ({ - ...step, - cwd: targetDir, - }), - ); - const navigationStep: NextStepInstruction = { - id: "enter-project", - label: "Enter Generated Repository", - kind: "navigation", - command: "cd", - args: [targetDir], - cwd: ".", - display: `cd ${targetDir}`, - machineVerifiable: false, - }; - - return Object.freeze({ - targetDir, - steps: Object.freeze( - [navigationStep, ...commandSteps].map((step) => Object.freeze(step)), - ), - }); -} - -export function formatNextStepInstructionsForCli( - plan: NextStepInstructionPlan, -): string { - return [ - "Next Step Instructions:", - "", - ...plan.steps.flatMap((step, index) => [ - ` ${index + 1}. ${step.label}`, - ` ${step.display}`, - ]), - ].join("\n"); -} - -function isOptionalGitInstruction(step: NextStepInstruction): boolean { - return step.id.startsWith("optional-git-"); -} - -function relativeCwd( - plan: NextStepInstructionPlan, - step: NextStepInstruction, -): string | undefined { - const targetDir = path.resolve(plan.targetDir); - const cwd = path.resolve(step.cwd); - - if (cwd === targetDir) { - return undefined; - } - - const relative = path.relative(targetDir, cwd); - if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { - return cwd; - } - - return relative; -} - -function taskTitle(step: NextStepInstruction): string { - const title = step.label.replace(/^Optional:\s*/, ""); - return `${title.slice(0, 1).toUpperCase()}${title.slice(1)}`; -} - -function formatTodoTask( - plan: NextStepInstructionPlan, - step: NextStepInstruction, -): string[] { - const taskLines = [`- [ ] ${taskTitle(step)}`]; - const cwd = relativeCwd(plan, step); - - if (cwd !== undefined) { - taskLines.push(` From \`${cwd}\`:`); - } - - taskLines.push(` \`${step.display}\``); - return taskLines; -} - -export function formatGeneratedFollowUpDocument( - plan: NextStepInstructionPlan, -): string { - const projectLocalSteps = plan.steps.filter( - (step) => step.kind === "command", - ); - const nextSteps = projectLocalSteps.filter( - (step) => !isOptionalGitInstruction(step), - ); - const optionalGitSteps = projectLocalSteps.filter(isOptionalGitInstruction); - - return [ - "# TODO", - "", - "Generated follow-up tasks for this repository.", - "", - "### Next Steps", - ...nextSteps.flatMap((step) => formatTodoTask(plan, step)), - "", - "### Optional Git Setup", - ...optionalGitSteps.flatMap((step) => formatTodoTask(plan, step)), - "", - "### Done ✓", - "", - ].join("\n"); -} - -export function generatedFollowUpDocumentOperation( - plan: NextStepInstructionPlan, -): RenderOperation { - return { - kind: "writeText", - to: "TODO.md", - text: formatGeneratedFollowUpDocument(plan), - }; -} diff --git a/packages/core/src/package-addition-support.ts b/packages/core/src/package-addition-support.ts deleted file mode 100644 index ac3ccd8..0000000 --- a/packages/core/src/package-addition-support.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PackageAdditionSupport as SharedPackageAdditionSupport } from "@ykdz/template-shared"; -import type { PackageAdditionSupport as SharedPackageAdditionSupportType } from "@ykdz/template-shared"; - -export const PackageAdditionSupport = SharedPackageAdditionSupport; -export type PackageAdditionSupport = SharedPackageAdditionSupportType; diff --git a/packages/core/src/package-addition.ts b/packages/core/src/package-addition.ts deleted file mode 100644 index b907ece..0000000 --- a/packages/core/src/package-addition.ts +++ /dev/null @@ -1,1233 +0,0 @@ -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import { - validateProjectBlueprint, - type ProjectBlueprint, -} from "@ykdz/template-shared"; -import { - PackageAdditionSupport, - type PackageLinkIntent, - type PackageRole, - type PackageSourcePreset, -} from "@ykdz/template-shared"; - -import { - collectGeneratedManifestCatalogReferences, - pnpmWorkspaceYamlWithCatalogDependencies, - type GeneratedPackageManifestDependencies, -} from "./dependency-catalog.ts"; -import { renderTurboRunCommand } from "./module-graph.ts"; -import { - assertTypeScriptPackageBoundaryForLinkIntent, - packageTurboConfig, - planPackageLinks, - type TurboConfig, -} from "./package-linking.ts"; -import { - findPresetSourceManifestPreset, - type PresetSourceManifest, -} from "./preset-source.ts"; -import { - defaultPackagePathForPresetSourcePackageAddition, - planPresetSourcePackageAddition, - type PresetProjectionSourceRoots, -} from "./projection-capabilities.ts"; -import { renderProject } from "./renderer.ts"; -import type { RenderOperation } from "./renderer.ts"; - -export type AddPackageOptions = { - cwd: string; - preset: string; - name: string; - path?: string | undefined; - linkFrom?: readonly string[] | undefined; - presetSourceManifest: PresetSourceManifest; - projectionSourceRoots: PresetProjectionSourceRoots; -}; - -type RootPackageJson = { - scripts: Record; - devDependencies?: Record; - type?: string; - [key: string]: unknown; -}; - -type GeneratedRepositoryPackageMetadata = { - projectName: string; - nodeVersion: string; -}; - -type RootUpdatePlan = { - blueprint: ProjectBlueprint; - rootPackageJson: RootPackageJson; - turboConfig: TurboConfig; - workspaceText: string; - consumerManifestUpdates: readonly ConsumerManifestUpdate[]; -}; - -type PackageManifestForTaskGraph = { - name?: string; - scripts?: Record; - dependencies?: Record; - exports?: unknown; -}; - -type ConsumerManifestUpdate = { - readonly packagePath: string; - readonly manifest: PackageManifestForTaskGraph & Record; -}; - -function projectNameFromBlueprint( - blueprint: ProjectBlueprint, - fallbackProjectName: string, -): string { - const firstPackage = blueprint.packages?.[0]; - const match = firstPackage?.name.match(/^@([^/]+)\//); - - if (match?.[1]) { - return match[1]; - } - - if (/^[a-z0-9][a-z0-9._-]*$/.test(fallbackProjectName)) { - return fallbackProjectName; - } - - throw new Error( - "Cannot infer workspace package scope from the stored Project Blueprint or root package.json", - ); -} - -function assertSafePackageLeaf(name: string): void { - if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) { - throw new Error( - "--name must be a lowercase package leaf name using letters, numbers, and hyphens", - ); - } -} - -const reservedPackagePathCollections = new Set([ - ".devcontainer", - ".git", - ".github", - ".template", - "build", - "coverage", - "dist", - "node_modules", - "out", - "target", -]); - -function assertSafePackagePathSegment( - packagePath: string, - segment: string, -): void { - if (!/^[a-z0-9][a-z0-9-]*$/.test(segment)) { - throw new Error( - `Package Path ${packagePath} must use safe path segments with lowercase letters, numbers, and hyphens`, - ); - } -} - -function validateExplicitPackagePath(packagePath: string): string { - if (path.isAbsolute(packagePath)) { - throw new Error(`Package Path ${packagePath} must be relative`); - } - - if (packagePath !== packagePath.trim()) { - throw new Error(`Package Path ${packagePath} must not contain whitespace`); - } - - if ( - packagePath === ".." || - packagePath.startsWith("../") || - packagePath.endsWith("/..") || - packagePath.includes("/../") - ) { - throw new Error( - `Package Path ${packagePath} must not escape the workspace`, - ); - } - - const normalized = path.posix.normalize(packagePath); - if (normalized !== packagePath || packagePath.includes("\\")) { - throw new Error( - `Package Path ${packagePath} must be exactly two safe path segments`, - ); - } - - const segments = packagePath.split("/"); - if (segments.length !== 2) { - throw new Error( - `Package Path ${packagePath} must be exactly two safe path segments`, - ); - } - - const [collection, packageDirectory] = segments; - if (!collection || !packageDirectory) { - throw new Error( - `Package Path ${packagePath} must be exactly two safe path segments`, - ); - } - - if (reservedPackagePathCollections.has(collection)) { - throw new Error( - `Package Path ${packagePath} uses reserved collection ${collection}`, - ); - } - - assertSafePackagePathSegment(packagePath, collection); - assertSafePackagePathSegment(packagePath, packageDirectory); - - if (reservedPackagePathCollections.has(packageDirectory)) { - throw new Error( - `Package Path ${packagePath} uses reserved package directory ${packageDirectory}`, - ); - } - - return packagePath; -} - -function supportedPackageAdditionPresetNames( - manifest: PresetSourceManifest, -): string[] { - return manifest.presets - .filter( - (preset) => - preset.packageAdditionSupport === PackageAdditionSupport.Supported, - ) - .map((preset) => preset.name); -} - -function formatUnsupportedPackageAdditionPresetError( - preset: string, - manifest: PresetSourceManifest, -): string { - return [ - `Preset ${preset} cannot be used for Package Addition.`, - "It can still initialize a Generated Repository, but it cannot be added to an existing one.", - `Supported Package Addition presets: ${supportedPackageAdditionPresetNames(manifest).join(", ")}`, - ].join("\n"); -} - -function assertNoPackageConflict( - blueprint: ProjectBlueprint, - packageName: string, - packagePath: string, -): void { - const existingByName = blueprint.packages?.find( - (pkg) => pkg.name === packageName, - ); - if (existingByName) { - throw new Error( - `Package Path ${packagePath} conflicts with existing package ${existingByName.name} at ${existingByName.path}`, - ); - } - - const existingByPath = blueprint.packages?.find( - (pkg) => pkg.path === packagePath, - ); - if (existingByPath) { - throw new Error( - `Package Path ${packagePath} conflicts with existing package ${existingByPath.name} at ${existingByPath.path}`, - ); - } -} - -function formatAvailablePackagePaths(blueprint: ProjectBlueprint): string { - return (blueprint.packages ?? []) - .map((projectPackage) => projectPackage.path) - .join(", "); -} - -function packageLinkIntentForSingleConsumer(options: { - readonly blueprint: ProjectBlueprint; - readonly consumerPackagePath: string; - readonly providerPackagePath: string; -}): PackageLinkIntent { - if (options.consumerPackagePath === options.providerPackagePath) { - throw new Error( - `Package Link Intent cannot link ${options.providerPackagePath} from itself`, - ); - } - - const consumer = options.blueprint.packages?.find( - (projectPackage) => projectPackage.path === options.consumerPackagePath, - ); - - if (!consumer) { - const availablePackagePaths = formatAvailablePackagePaths( - options.blueprint, - ); - throw new Error( - [ - `Unknown Package Path for --link-from: ${options.consumerPackagePath}`, - availablePackagePaths - ? `Available Package Paths: ${availablePackagePaths}` - : undefined, - ] - .filter((line): line is string => line !== undefined) - .join("\n"), - ); - } - - return { - consumerPackagePath: options.consumerPackagePath, - providerPackagePath: options.providerPackagePath, - }; -} - -function packageLinkIntentsForConsumers(options: { - readonly blueprint: ProjectBlueprint; - readonly consumerPackagePaths: readonly string[]; - readonly providerPackagePath: string; -}): PackageLinkIntent[] { - const packageLinkIntents: PackageLinkIntent[] = []; - const seenConsumerPackagePaths = new Set(); - - for (const consumerPackagePath of options.consumerPackagePaths) { - if (seenConsumerPackagePaths.has(consumerPackagePath)) { - continue; - } - - seenConsumerPackagePaths.add(consumerPackagePath); - packageLinkIntents.push( - packageLinkIntentForSingleConsumer({ - blueprint: options.blueprint, - consumerPackagePath, - providerPackagePath: options.providerPackagePath, - }), - ); - } - - return packageLinkIntents; -} - -async function assertPackageLinkIntentConsumersAreTypeScriptBoundaries(options: { - readonly root: string; - readonly blueprint: ProjectBlueprint; - readonly packageLinkIntents: readonly PackageLinkIntent[]; -}): Promise { - for (const intent of options.packageLinkIntents) { - const consumer = options.blueprint.packages?.find( - (projectPackage) => projectPackage.path === intent.consumerPackagePath, - ); - - if (!consumer) { - continue; - } - - if (consumer.role !== undefined && consumer.sourcePreset !== undefined) { - assertTypeScriptPackageBoundaryForLinkIntent(consumer, "consumer"); - continue; - } - - try { - const manifest = await readJson( - path.join(options.root, consumer.path, "package.json"), - ); - const scripts = isRecord(manifest) ? manifest.scripts : undefined; - const hasTypeScriptPackageShape = - isRecord(manifest) && - manifest.type === "module" && - isRecord(scripts) && - (typeof scripts.typecheck === "string" || - typeof scripts["typecheck:run"] === "string"); - - if (hasTypeScriptPackageShape) { - continue; - } - - assertTypeScriptPackageBoundaryForLinkIntent(consumer, "consumer"); - } catch (error: unknown) { - if (isNodeError(error) && error.code === "ENOENT") { - assertTypeScriptPackageBoundaryForLinkIntent(consumer, "consumer"); - } - - throw error; - } - } -} - -async function readJson(filePath: string): Promise { - return JSON.parse(await readFile(filePath, "utf8")) as unknown; -} - -async function writeJson(filePath: string, value: unknown): Promise { - await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -async function assertMissingPackagePath( - packagePath: string, - targetPath: string, -): Promise { - try { - await stat(targetPath); - } catch (error: unknown) { - if (isNodeError(error) && error.code === "ENOENT") { - return; - } - throw error; - } - - throw new Error( - `Package Path ${packagePath} conflicts with existing filesystem path ${targetPath}`, - ); -} - -function resolveGeneratedPath(root: string, relativePath: string): string { - if (path.isAbsolute(relativePath)) { - throw new Error(`Package Addition paths must be relative: ${relativePath}`); - } - - const resolvedRoot = path.resolve(root); - const resolvedPath = path.resolve(resolvedRoot, relativePath); - const insideRoot = - resolvedPath === resolvedRoot || - resolvedPath.startsWith(`${resolvedRoot}${path.sep}`); - - if (!insideRoot) { - throw new Error( - `Package Addition path escapes the Generated Repository: ${relativePath}`, - ); - } - - return resolvedPath; -} - -function isNodeError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && "code" in error; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function assertRootPackageJson( - input: unknown, -): asserts input is RootPackageJson { - if (!isRecord(input)) { - throw new Error( - "Cannot update root Package Addition scripts: package.json must be an object", - ); - } - - if (!isRecord(input.scripts)) { - throw new Error( - "Cannot update root Package Addition scripts: scripts must be an object", - ); - } - - for (const scriptName of ["check", "fix"] as const) { - if (typeof input.scripts[scriptName] !== "string") { - throw new Error( - `Cannot update root Package Addition scripts: scripts.${scriptName} must be a string`, - ); - } - } -} - -async function readGeneratedWorkspaceBlueprint( - root: string, -): Promise { - const blueprintPath = path.join(root, ".template/blueprint.json"); - let blueprintJson: unknown; - - try { - blueprintJson = await readJson(blueprintPath); - } catch (error: unknown) { - if ( - error instanceof SyntaxError || - (isNodeError(error) && error.code === "ENOENT") - ) { - throw new Error( - "Package Addition requires a valid .template/blueprint.json", - { cause: error }, - ); - } - - throw error; - } - - const result = validateProjectBlueprint(blueprintJson); - - if (!result.ok) { - if ( - isRecord(blueprintJson) && - blueprintJson.projectKind === "single-package" - ) { - throw new Error( - "Package Addition only supports existing workspace Generated Repositories", - ); - } - - throw new Error( - "Package Addition requires a valid .template/blueprint.json", - ); - } - - const blueprint = result.value; - - if (blueprint.projectKind !== "multi-package") { - throw new Error( - "Package Addition only supports existing workspace Generated Repositories", - ); - } - - if (blueprint.packageManager !== "pnpm") { - throw new Error( - "Package Addition only supports pnpm workspace Generated Repositories", - ); - } - - if (!blueprint.packages || blueprint.packages.length === 0) { - throw new Error( - "Package Addition requires package definitions in the stored Project Blueprint", - ); - } - - await stat(path.join(root, "turbo.json")); - await stat(path.join(root, "pnpm-workspace.yaml")); - - return blueprint; -} - -async function readGeneratedRepositoryPackageMetadata( - root: string, -): Promise { - const packageJson = await readJson(path.join(root, "package.json")); - - if (!isRecord(packageJson)) { - throw new Error( - "Package Addition requires root package.json to be an object", - ); - } - - const projectName = packageJson.name; - if (typeof projectName !== "string" || projectName.length === 0) { - throw new Error( - "Package Addition requires root package.json to declare name", - ); - } - - if (!isRecord(packageJson.engines)) { - throw new Error( - "Package Addition requires root package.json to declare engines.node", - ); - } - - const nodeVersion = packageJson.engines.node; - if (typeof nodeVersion !== "string" || nodeVersion.length === 0) { - throw new Error( - "Package Addition requires root package.json to declare engines.node", - ); - } - - return { projectName, nodeVersion }; -} - -function workspaceTextWithPackageGlob(text: string, glob: string): string { - if (text.includes(` - ${glob}`)) { - return text; - } - - const nextText = text.replace(/^packages:\n/m, `packages:\n - ${glob}\n`); - if (nextText === text) { - throw new Error( - "Cannot update pnpm workspace membership: missing packages section", - ); - } - - return nextText; -} - -function packageAdditionCatalogDependencies( - operations: readonly RenderOperation[], -): string[] { - const manifests: GeneratedPackageManifestDependencies[] = []; - - for (const operation of operations) { - if ( - operation.kind !== "writeJson" || - path.basename(operation.to) !== "package.json" - ) { - continue; - } - - if (!isRecord(operation.value)) { - throw new Error( - `Package Addition package manifest must be an object: ${operation.to}`, - ); - } - - manifests.push(operation.value); - } - - return collectGeneratedManifestCatalogReferences(manifests); -} - -function generatedPackageManifestFromOperations( - operations: readonly RenderOperation[], - packagePath: string, -): PackageManifestForTaskGraph | undefined { - for (const operation of operations) { - if ( - operation.kind !== "writeJson" || - operation.to !== `${packagePath}/package.json` - ) { - continue; - } - - if (!isRecord(operation.value)) { - throw new Error( - `Package Addition package manifest must be an object: ${operation.to}`, - ); - } - - return operation.value; - } - - return undefined; -} - -async function packageManifestsForTaskGraph(options: { - readonly root: string; - readonly blueprint: ProjectBlueprint; - readonly operations: readonly RenderOperation[]; - readonly manifestOverrides?: ReadonlyMap; -}): Promise { - const manifests: PackageManifestForTaskGraph[] = []; - - for (const packageDefinition of options.blueprint.packages ?? []) { - const manifestOverride = options.manifestOverrides?.get( - packageDefinition.path, - ); - const manifestFromOperations = generatedPackageManifestFromOperations( - options.operations, - packageDefinition.path, - ); - - if (manifestOverride !== undefined) { - manifests.push(manifestOverride); - continue; - } - - if (manifestFromOperations !== undefined) { - manifests.push(manifestFromOperations); - continue; - } - - const manifest = await readJson( - path.join(options.root, packageDefinition.path, "package.json"), - ); - assertObjectManifest(manifest, packageDefinition.path); - manifests.push(manifest); - } - - return manifests; -} - -function blueprintWithPackage( - blueprint: ProjectBlueprint, - packageName: string, - packagePath: string, - packageRole: PackageRole, - packageSourcePreset: PackageSourcePreset, - packageLinkIntents: readonly PackageLinkIntent[], -): ProjectBlueprint { - const nextBlueprint = { - ...blueprint, - packages: [ - ...(blueprint.packages ?? []), - { - name: packageName, - path: packagePath, - role: packageRole, - sourcePreset: packageSourcePreset, - }, - ], - packageLinkIntents: - packageLinkIntents.length > 0 - ? [...(blueprint.packageLinkIntents ?? []), ...packageLinkIntents] - : blueprint.packageLinkIntents, - }; - const result = validateProjectBlueprint(nextBlueprint); - - if (!result.ok) { - throw new Error( - "Package Addition would write an invalid .template/blueprint.json", - ); - } - - return result.value; -} - -function assertObjectManifest( - input: unknown, - packagePath: string, -): asserts input is PackageManifestForTaskGraph & Record { - if (!isRecord(input)) { - throw new Error( - `Cannot update Package Link Intent manifest for ${packagePath}: package.json must be an object`, - ); - } -} - -function recordSortedByKey(record: Record): Record { - return Object.fromEntries( - Object.entries(record).toSorted(([left], [right]) => - left.localeCompare(right), - ), - ); -} - -async function consumerManifestUpdatesForPackageLinkIntents(options: { - readonly root: string; - readonly manifestDependenciesByPackagePath: ReadonlyMap< - string, - Readonly> - >; -}): Promise { - const updates: ConsumerManifestUpdate[] = []; - - for (const [ - packagePath, - packageLinkDependencies, - ] of options.manifestDependenciesByPackagePath) { - const manifest = await readJson( - path.join(options.root, packagePath, "package.json"), - ); - assertObjectManifest(manifest, packagePath); - if ( - manifest.dependencies !== undefined && - !isRecord(manifest.dependencies) - ) { - throw new Error( - `Cannot update Package Link Intent manifest for ${packagePath}: dependencies must be an object`, - ); - } - - updates.push({ - packagePath, - manifest: { - ...manifest, - dependencies: recordSortedByKey({ - ...manifest.dependencies, - ...packageLinkDependencies, - }), - }, - }); - } - - return updates; -} - -function rootScriptWithTurboPackageTasks(options: { - readonly script: string; - readonly taskNames: readonly ( - | "format:check:run" - | "format:write:run" - | "lint:run" - | "lint:fix:run" - | "typecheck:run" - | "build:run" - | "test:run" - | "test:e2e:run" - | "check:run" - | "fix:run" - )[]; -}): string { - const rootCommands = options.script - .split(" && ") - .filter((command) => !command.startsWith("turbo run ")); - const turboCommand = renderTurboRunCommand(options.taskNames); - - return [...rootCommands, turboCommand].join(" && "); -} - -function rootPackageJsonWithPackageTaskFilters( - input: unknown, - blueprint: ProjectBlueprint, -): RootPackageJson { - void blueprint; - assertRootPackageJson(input); - - const checkScript = input.scripts.check; - const fixScript = input.scripts.fix; - if (checkScript === undefined || fixScript === undefined) { - throw new Error( - "Cannot update root Package Addition scripts: check and fix scripts are required", - ); - } - - return { - ...input, - scripts: { - ...input.scripts, - check: rootScriptWithTurboPackageTasks({ - script: checkScript, - taskNames: [ - "format:check:run", - "lint:run", - "typecheck:run", - "build:run", - "test:run", - "test:e2e:run", - "check:run", - ], - }), - fix: rootScriptWithTurboPackageTasks({ - script: fixScript, - taskNames: ["format:write:run", "lint:fix:run", "fix:run"], - }), - }, - }; -} - -function manifestExportUsesCompiledRuntime(value: unknown): boolean { - if (!isRecord(value)) { - return false; - } - - const rootExport = value["."]; - if (!isRecord(rootExport)) { - return false; - } - - return ( - typeof rootExport.default === "string" && - rootExport.default.startsWith("./dist/") - ); -} - -function packageManifestsNeedDependencyBuilds( - manifests: readonly PackageManifestForTaskGraph[], -): boolean { - const compiledPackageNames = new Set( - manifests - .filter((manifest) => manifestExportUsesCompiledRuntime(manifest.exports)) - .map((manifest) => manifest.name) - .filter((name): name is string => typeof name === "string"), - ); - - if (compiledPackageNames.size === 0) { - return false; - } - - return manifests.some((manifest) => - Object.entries(manifest.dependencies ?? {}).some( - ([dependencyName, specifier]) => - specifier === "workspace:*" && compiledPackageNames.has(dependencyName), - ), - ); -} - -function turboConfigForPackageManifests( - manifests: readonly PackageManifestForTaskGraph[], -): TurboConfig { - return packageTurboConfig({ - dependencyBuildsRequired: packageManifestsNeedDependencyBuilds(manifests), - }); -} - -function rootPackageJsonWithSharedOxcRuntimeDependencies( - input: RootPackageJson, - requiresSharedOxcConfiguration: boolean, -): RootPackageJson { - if (!requiresSharedOxcConfiguration) { - return input; - } - - if (input.type !== undefined && input.type !== "module") { - throw new Error( - 'Cannot update root OXC configuration: package.json type must be "module" or omitted', - ); - } - - if (input.devDependencies !== undefined && !isRecord(input.devDependencies)) { - throw new Error( - "Cannot update root OXC configuration dependencies: devDependencies must be an object", - ); - } - - return { - ...input, - type: "module", - devDependencies: { - ...input.devDependencies, - oxfmt: input.devDependencies?.oxfmt ?? "catalog:", - oxlint: input.devDependencies?.oxlint ?? "catalog:", - "oxlint-tsgolint": - input.devDependencies?.["oxlint-tsgolint"] ?? "catalog:", - }, - }; -} - -function catalogDependenciesWithSharedOxcRuntimeDependencies( - catalogDependencies: readonly string[], - requiresSharedOxcConfiguration: boolean, -): string[] { - const dependencies = [...catalogDependencies]; - - if (!requiresSharedOxcConfiguration) { - return dependencies; - } - - for (const dependency of ["oxfmt", "oxlint", "oxlint-tsgolint"]) { - if (!dependencies.includes(dependency)) { - dependencies.push(dependency); - } - } - - return dependencies; -} - -async function planRootUpdates( - root: string, - blueprint: ProjectBlueprint, - packageName: string, - packagePath: string, - packageRole: PackageRole, - packageSourcePreset: PackageSourcePreset, - packageLinkIntents: readonly PackageLinkIntent[], - workspacePackageGlob: string, - catalogDependencies: readonly string[], - requiresSharedOxcConfiguration: boolean, - operations: readonly RenderOperation[], -): Promise { - const nextBlueprint = blueprintWithPackage( - blueprint, - packageName, - packagePath, - packageRole, - packageSourcePreset, - packageLinkIntents, - ); - const consumerManifestUpdates = - packageLinkIntents.length > 0 - ? await consumerManifestUpdatesForPackageLinkIntents({ - root, - manifestDependenciesByPackagePath: planPackageLinks( - [ - { - name: packageName, - path: packagePath, - role: packageRole, - sourcePreset: packageSourcePreset, - }, - ], - packageLinkIntents, - ).manifestDependenciesByPackagePath, - }) - : []; - const manifestOverrides = new Map( - consumerManifestUpdates.map((update) => [ - update.packagePath, - update.manifest, - ]), - ); - const packageManifests = await packageManifestsForTaskGraph({ - root, - blueprint: nextBlueprint, - operations, - manifestOverrides, - }); - const workspaceText = pnpmWorkspaceYamlWithCatalogDependencies( - workspaceTextWithPackageGlob( - await readFile(path.join(root, "pnpm-workspace.yaml"), "utf8"), - workspacePackageGlob, - ), - catalogDependenciesWithSharedOxcRuntimeDependencies( - catalogDependencies, - requiresSharedOxcConfiguration, - ), - ); - const rootPackageJson = rootPackageJsonWithPackageTaskFilters( - await readJson(path.join(root, "package.json")), - nextBlueprint, - ); - - return { - blueprint: nextBlueprint, - rootPackageJson: rootPackageJsonWithSharedOxcRuntimeDependencies( - rootPackageJson, - requiresSharedOxcConfiguration, - ), - turboConfig: turboConfigForPackageManifests(packageManifests), - workspaceText, - consumerManifestUpdates, - }; -} - -async function readTextIfExists(filePath: string): Promise { - try { - return await readFile(filePath, "utf8"); - } catch (error: unknown) { - if (isNodeError(error) && error.code === "ENOENT") { - return undefined; - } - - throw error; - } -} - -async function writeIfMissing(filePath: string, text: string): Promise { - if ((await readTextIfExists(filePath)) !== undefined) { - return; - } - - await writeFile(filePath, text, "utf8"); -} - -async function ensureRootOxfmtConfig( - root: string, - sharedOxcRoot: string, -): Promise { - await writeIfMissing( - path.join(root, "oxfmt.config.ts"), - await readFile(path.join(sharedOxcRoot, "oxfmt.config.ts"), "utf8"), - ); -} - -async function ensureRootOxlintConfig(options: { - root: string; - sharedOxcRoot: string; - addedPreset: string; -}): Promise { - const nodeConfig = await readFile( - path.join(options.sharedOxcRoot, "node", "oxlint.config.ts"), - "utf8", - ); - const vueConfig = await readFile( - path.join(options.sharedOxcRoot, "vue", "oxlint.config.ts"), - "utf8", - ); - const targetPath = path.join(options.root, "oxlint.config.ts"); - const currentConfig = await readTextIfExists(targetPath); - - if (currentConfig === undefined) { - await writeFile( - targetPath, - options.addedPreset === "vue-app" ? vueConfig : nodeConfig, - "utf8", - ); - return; - } - - if (options.addedPreset !== "vue-app" || currentConfig === vueConfig) { - return; - } - - if (currentConfig === nodeConfig) { - await writeFile(targetPath, vueConfig, "utf8"); - return; - } - - throw new Error( - "Cannot update root OXC lint configuration for Vue Package Addition: oxlint.config.ts has local changes", - ); -} - -async function ensureRootOxcConfiguration(options: { - root: string; - sharedOxcRoot?: string | undefined; - addedPreset: string; -}): Promise { - if (!options.sharedOxcRoot) { - return; - } - - await ensureRootOxfmtConfig(options.root, options.sharedOxcRoot); - await ensureRootOxlintConfig({ - root: options.root, - sharedOxcRoot: options.sharedOxcRoot, - addedPreset: options.addedPreset, - }); -} - -function gitignoreContainsEntry(text: string, entry: string): boolean { - return text - .split(/\r?\n/) - .some((line) => line.trim() === entry || line.trim() === `${entry}/`); -} - -function gitignoreTextWithEntries( - text: string, - entries: readonly string[], -): string { - const missingEntries = entries.filter( - (entry) => !gitignoreContainsEntry(text, entry), - ); - - if (missingEntries.length === 0) { - return text; - } - - const prefix = text.trimEnd(); - return `${prefix}${prefix.length > 0 ? "\n" : ""}${missingEntries.join("\n")}\n`; -} - -function rootGitignoreEntriesForPackageAddition( - preset: string, - requiresSharedOxcConfiguration: boolean, -): string[] { - const entries = requiresSharedOxcConfiguration - ? ["node_modules", "dist"] - : []; - - if (preset === "vue-app") { - entries.push("playwright-report", "test-results"); - } - - return entries; -} - -async function ensureRootGitignoreEntries( - root: string, - entries: readonly string[], -): Promise { - if (entries.length === 0) { - return; - } - - const gitignorePath = path.join(root, ".gitignore"); - const currentText = (await readTextIfExists(gitignorePath)) ?? ""; - const nextText = gitignoreTextWithEntries(currentText, entries); - - if (nextText !== currentText) { - await writeFile(gitignorePath, nextText, "utf8"); - } -} - -export async function addPackage(options: AddPackageOptions): Promise { - assertSafePackageLeaf(options.name); - - const root = path.resolve(options.cwd); - const blueprint = await readGeneratedWorkspaceBlueprint(root); - const repositoryMetadata = await readGeneratedRepositoryPackageMetadata(root); - const projectName = projectNameFromBlueprint( - blueprint, - repositoryMetadata.projectName, - ); - const packageName = `@${projectName}/${options.name}`; - const preset = findPresetSourceManifestPreset( - options.presetSourceManifest, - options.preset, - ); - - if (!preset) { - throw new Error(`Unknown preset for Package Addition: ${options.preset}`); - } - - if (preset.packageAdditionSupport !== PackageAdditionSupport.Supported) { - throw new Error( - formatUnsupportedPackageAdditionPresetError( - options.preset, - options.presetSourceManifest, - ), - ); - } - - const packagePath = options.path - ? validateExplicitPackagePath(options.path) - : defaultPackagePathForPresetSourcePackageAddition( - preset, - options.name, - options.projectionSourceRoots, - ); - - const additionPlan = await planPresetSourcePackageAddition({ - preset, - sourceRoots: options.projectionSourceRoots, - addition: { - root, - blueprint, - packageLeafName: options.name, - packageName, - packagePath, - nodeVersion: repositoryMetadata.nodeVersion, - }, - }); - const requiresSharedOxcConfiguration = - additionPlan.sourceRoots?.sharedOxc !== undefined; - - assertNoPackageConflict(blueprint, packageName, additionPlan.packagePath); - const packageLinkIntents = packageLinkIntentsForConsumers({ - blueprint, - consumerPackagePaths: options.linkFrom ?? [], - providerPackagePath: additionPlan.packagePath, - }); - - await assertPackageLinkIntentConsumersAreTypeScriptBoundaries({ - root, - blueprint, - packageLinkIntents, - }); - await assertMissingPackagePath( - additionPlan.packagePath, - path.join(root, additionPlan.packagePath), - ); - const rootUpdatePlan = await planRootUpdates( - root, - blueprint, - packageName, - additionPlan.packagePath, - additionPlan.packageRole, - additionPlan.packageSourcePreset, - packageLinkIntents, - additionPlan.workspaceMembershipGlob ?? additionPlan.workspacePackageGlob, - packageAdditionCatalogDependencies(additionPlan.operations), - requiresSharedOxcConfiguration, - additionPlan.operations, - ); - - await mkdir(path.join(root, additionPlan.packagePath), { recursive: true }); - await ensureRootOxcConfiguration({ - root, - sharedOxcRoot: additionPlan.sourceRoots?.sharedOxc, - addedPreset: options.preset, - }); - await ensureRootGitignoreEntries( - root, - rootGitignoreEntriesForPackageAddition( - options.preset, - requiresSharedOxcConfiguration, - ), - ); - await renderProject({ - sourceRoot: additionPlan.sourceRoot, - sourceRoots: additionPlan.sourceRoots, - targetRoot: root, - operations: [...additionPlan.operations], - }); - - for (const textFile of additionPlan.textFiles ?? []) { - await writeFile( - resolveGeneratedPath(root, textFile.path), - textFile.text, - "utf8", - ); - } - - await writeFile( - path.join(root, "pnpm-workspace.yaml"), - rootUpdatePlan.workspaceText, - "utf8", - ); - await writeJson( - path.join(root, "package.json"), - rootUpdatePlan.rootPackageJson, - ); - await writeJson(path.join(root, "turbo.json"), rootUpdatePlan.turboConfig); - for (const update of rootUpdatePlan.consumerManifestUpdates) { - await writeJson( - path.join(root, update.packagePath, "package.json"), - update.manifest, - ); - } - await writeJson( - path.join(root, ".template/blueprint.json"), - rootUpdatePlan.blueprint, - ); -} diff --git a/packages/core/src/package-contribution.ts b/packages/core/src/package-contribution.ts new file mode 100644 index 0000000..0712713 --- /dev/null +++ b/packages/core/src/package-contribution.ts @@ -0,0 +1,83 @@ +import type { EditorCustomizationCapability } from "./editor-customization.ts"; +import type { + CheckComponent, + CheckEnvironmentNeed, + DeploymentCheckComponent, + FixComponent, +} from "./module-graph.ts"; +import type { PackageDefinition } from "./project-blueprint-v2.ts"; +import type { DependencyMaintenancePolicy } from "./project-github.ts"; +import type { RenderOperation } from "./renderer.ts"; + +export type FoundationContribution = { + /** Toolchains the Foundation must install and project into coordinated root files. */ + readonly toolchains: { + readonly rust?: { + readonly toolchain: string; + readonly components: readonly ("rustfmt" | "clippy")[]; + }; + }; + /** Editor capabilities the Foundation must project into its coordinated editor files. */ + readonly editorCapabilities: readonly EditorCustomizationCapability[]; + /** Ecosystems and paths whose maintenance belongs in the coordinated root policy. */ + readonly dependencyMaintenance: DependencyMaintenancePolicy; + /** Workspace membership patterns contributed by package boundaries. */ + readonly workspacePackageGlobs?: readonly string[]; + /** Dependency Catalog entries required by package-owned manifests. */ + readonly dependencyCatalog?: Readonly>; +}; + +/** A preset-agnostic package-sized part of a Generated Repository Plan. */ +export type PackageContribution = { + readonly definition: PackageDefinition; + readonly manifest: Readonly>; + readonly exposure: { + readonly exports: Readonly>; + readonly imports: Readonly>; + }; + readonly operations: readonly RenderOperation[]; + /** Typed requirements consumed by the Foundation for coordinated root outputs. */ + readonly foundation: FoundationContribution; + readonly checks: readonly CheckComponent[]; + readonly fixes: readonly FixComponent[]; + readonly environmentNeeds: readonly CheckEnvironmentNeed[]; + /** Deployment checks are package-owned and composed by the Foundation. */ + readonly deploymentChecks?: readonly DeploymentCheckComponent[]; +}; + +export function assertPackageContribution( + contribution: PackageContribution, + provenance: { + readonly definitionName?: string; + readonly planner?: string; + } = {}, +): PackageContribution { + if (contribution.definition.name !== contribution.manifest.name) { + throw new Error( + "Package Contribution manifest name must match its Package Definition", + ); + } + const operationPath = (operation: RenderOperation): string => { + if ("to" in operation) return operation.to; + if ("path" in operation) return operation.path; + return ""; + }; + const outsideOperation = contribution.operations.find( + (operation) => + !operationPath(operation).startsWith(`${contribution.definition.path}/`), + ); + if (outsideOperation) { + const target = operationPath(outsideOperation); + const rule = target.includes("/") + ? "Package Contribution may not write a sibling Package Boundary" + : "Package Contribution may not write a coordinated root output"; + const owner = + provenance.definitionName === undefined + ? "" + : `${provenance.definitionName}: ${provenance.planner ?? "Package Contribution"} `; + throw new Error( + `${owner}${rule}; ${contribution.definition.path} attempted ${target}`, + ); + } + return contribution; +} diff --git a/packages/core/src/package-linking.ts b/packages/core/src/package-linking.ts deleted file mode 100644 index a9cebb9..0000000 --- a/packages/core/src/package-linking.ts +++ /dev/null @@ -1,314 +0,0 @@ -import type { - PackageLinkIntent, - PackageRole, - PackageSourcePreset, -} from "@ykdz/template-shared"; - -export type { - PackageLinkIntent, - PackageRole, - PackageSourcePreset, -} from "@ykdz/template-shared"; - -export type PackageDefinition = { - readonly name: string; - readonly path: string; - readonly role?: PackageRole; - readonly sourcePreset?: PackageSourcePreset; -}; - -export type JitSourcePackageExposure = { - readonly kind: "jit-source"; - readonly entrypoint: string; - readonly packageLocalImportPattern: string; - readonly packageLocalImportTarget: string; -}; - -export type CompiledPackageExposure = { - readonly kind: "compiled"; - readonly entrypoint: string; - readonly sourceTypes: string; - readonly packageLocalImportPattern: string; - readonly packageLocalImportRuntimeTarget: string; - readonly packageLocalImportTypesTarget: string; -}; - -export type PackageExposure = - | CompiledPackageExposure - | JitSourcePackageExposure; - -export type PackageLinkPlan = { - readonly exposuresByPackagePath: ReadonlyMap; - readonly manifestDependenciesByPackagePath: ReadonlyMap< - string, - Readonly> - >; - readonly turboTasks: TurboTaskGraph; -}; - -export type PackageManifestExposureFields = { - readonly exports: Record; - readonly imports: Record; - readonly types?: string; -}; - -export type PackageLinkBoundaryDirection = "consumer" | "provider"; - -export type PackageLinkIntentCompatibility = { - readonly consumer: PackageDefinition; - readonly provider: PackageDefinition; -}; - -export type TurboTaskDefinition = { - readonly dependsOn?: readonly string[]; - readonly outputs?: readonly string[]; - readonly cache?: boolean; -}; - -export type TurboTaskGraph = Readonly>; - -export type TurboBoundaryRule = { - readonly dependencies?: { - readonly allow?: readonly string[]; - readonly deny?: readonly string[]; - }; - readonly dependents?: { - readonly allow?: readonly string[]; - readonly deny?: readonly string[]; - }; -}; - -export type TurboBoundaries = { - readonly tags: Readonly>; -}; - -export type TurboConfig = { - readonly tasks: TurboTaskGraph; - readonly boundaries: TurboBoundaries; -}; - -export type PackageTurboTaskOptions = { - readonly dependencyBuildsRequired: boolean; -}; - -export function derivePackageExposure( - definition: PackageDefinition, -): PackageExposure { - if ( - definition.role === "shared-library" && - definition.sourcePreset === "ts-lib" - ) { - return { - kind: "jit-source", - entrypoint: "./src/index.ts", - packageLocalImportPattern: "#/*", - packageLocalImportTarget: "./src/*.ts", - }; - } - - if ( - definition.role === "runtime-service" && - definition.sourcePreset === "hono-api" - ) { - return { - kind: "compiled", - entrypoint: "./dist/index.js", - sourceTypes: "./src/index.ts", - packageLocalImportPattern: "#/*", - packageLocalImportRuntimeTarget: "./dist/*.js", - packageLocalImportTypesTarget: "./src/*.ts", - }; - } - - throw new Error( - `Unsupported Package Exposure for ${definition.name} at ${definition.path}`, - ); -} - -export function assertTypeScriptPackageBoundaryForLinkIntent( - definition: PackageDefinition, - direction: PackageLinkBoundaryDirection, -): asserts definition is PackageDefinition & { - readonly role: PackageRole; - readonly sourcePreset: PackageSourcePreset; -} { - if (definition.role !== undefined && definition.sourcePreset !== undefined) { - return; - } - - const relationship = - direction === "consumer" ? "from native package" : "to native package"; - - throw new Error( - `Package Link Intent ${relationship} ${definition.path} is unsupported in V1 TypeScript-only Project Linking`, - ); -} - -export function canPlanPackageLinkIntent({ - consumer, - provider, -}: PackageLinkIntentCompatibility): boolean { - try { - assertTypeScriptPackageBoundaryForLinkIntent(consumer, "consumer"); - assertTypeScriptPackageBoundaryForLinkIntent(provider, "provider"); - derivePackageExposure(provider); - return true; - } catch { - return false; - } -} - -export function planPackageLinks( - definitions: readonly PackageDefinition[], - intents: readonly PackageLinkIntent[] = [], -): PackageLinkPlan { - const definitionsByPath = new Map( - definitions.map((definition) => [definition.path, definition]), - ); - const manifestDependenciesByPackagePath = new Map< - string, - Record - >(); - - for (const intent of intents) { - const consumer = definitionsByPath.get(intent.consumerPackagePath); - const provider = definitionsByPath.get(intent.providerPackagePath); - - if (consumer !== undefined) { - assertTypeScriptPackageBoundaryForLinkIntent(consumer, "consumer"); - } - - if (provider === undefined) { - throw new Error( - `Package Link Intent references unknown provider package at ${intent.providerPackagePath}`, - ); - } - - assertTypeScriptPackageBoundaryForLinkIntent(provider, "provider"); - } - - const exposuresByPackagePath = new Map( - definitions.map((definition) => [ - definition.path, - derivePackageExposure(definition), - ]), - ); - let dependencyBuildsRequired = false; - - for (const intent of intents) { - const provider = definitionsByPath.get(intent.providerPackagePath); - - if (provider === undefined) { - throw new Error( - `Package Link Intent references unknown provider package at ${intent.providerPackagePath}`, - ); - } - - if (exposuresByPackagePath.get(provider.path)?.kind === "compiled") { - dependencyBuildsRequired = true; - } - - const dependencies = - manifestDependenciesByPackagePath.get(intent.consumerPackagePath) ?? {}; - dependencies[provider.name] = "workspace:*"; - manifestDependenciesByPackagePath.set( - intent.consumerPackagePath, - dependencies, - ); - } - - return { - exposuresByPackagePath, - manifestDependenciesByPackagePath, - turboTasks: packageTurboTasks({ dependencyBuildsRequired }), - }; -} - -export function packageTurboTasks({ - dependencyBuildsRequired, -}: PackageTurboTaskOptions): TurboTaskGraph { - return { - "format:check:run": {}, - "format:write:run": { cache: false }, - "lint:run": {}, - "lint:fix:run": { cache: false }, - "typecheck:run": { dependsOn: ["^typecheck:run"] }, - "build:run": dependencyBuildsRequired - ? { dependsOn: ["^build:run"], outputs: ["dist/**"] } - : { outputs: ["dist/**"] }, - "test:run": { dependsOn: ["^typecheck:run"] }, - "test:e2e:run": { - dependsOn: dependencyBuildsRequired - ? ["build:run", "^build:run"] - : ["build:run"], - }, - "check:run": { cache: false }, - "fix:run": { cache: false }, - }; -} - -// Boundary violations are architecture problems. Do not loosen these rules to -// make a failing dependency graph pass; fix the dependency direction instead. -export function generatedRepositoryTurboBoundaries(): TurboBoundaries { - return { - tags: { - app: { - dependencies: { - deny: ["app"], - }, - }, - library: { - dependencies: { - deny: ["app"], - }, - }, - }, - }; -} - -export function packageTurboConfig( - options: PackageTurboTaskOptions, -): TurboConfig { - return { - tasks: packageTurboTasks(options), - boundaries: generatedRepositoryTurboBoundaries(), - }; -} - -export function packageManifestExposureFields( - exposure: PackageExposure, -): PackageManifestExposureFields { - switch (exposure.kind) { - case "jit-source": - return { - imports: { - [exposure.packageLocalImportPattern]: { - default: exposure.packageLocalImportTarget, - types: exposure.packageLocalImportTarget, - }, - }, - exports: { - ".": { - default: exposure.entrypoint, - types: exposure.entrypoint, - }, - }, - }; - case "compiled": - return { - types: exposure.sourceTypes, - imports: { - [exposure.packageLocalImportPattern]: { - default: exposure.packageLocalImportRuntimeTarget, - types: exposure.packageLocalImportTypesTarget, - }, - }, - exports: { - ".": { - default: exposure.entrypoint, - types: exposure.sourceTypes, - }, - }, - }; - } -} diff --git a/packages/core/src/preset-definition.ts b/packages/core/src/preset-definition.ts new file mode 100644 index 0000000..b406753 --- /dev/null +++ b/packages/core/src/preset-definition.ts @@ -0,0 +1,46 @@ +import type { PackageContribution } from "./package-contribution.ts"; +import type { ProjectBlueprintV2 } from "./project-blueprint-v2.ts"; +import type { TemplateSourceHandle } from "./renderer.ts"; + +/** Stable, preset-agnostic input supplied after toolchain resolution. */ +export type GenerationContext = { + readonly targetDir: string; + readonly projectName: string; + readonly scope: string; + readonly toolchain: { + readonly nodeLtsMajor: string; + readonly packageManagerPin: string; + }; +}; + +/** A side-effect-free Built-in Preset planner. */ +export type BuiltInPresetDefinition = { + readonly metadata: { + readonly name: string; + readonly title: string; + readonly description: string; + }; + /** The Definition owns its Preset-local Self-Checking Template Source. */ + readonly source: TemplateSourceHandle; + /** The owned planner source inspected by the Template Boundary Check. */ + readonly plannerSourceFile: string; + blueprint(context: GenerationContext): ProjectBlueprintV2; + planInitialization(context: GenerationContext): PackageContribution; + /** + * Multi-package Definitions expose their complete owned topology directly, + * while single-package Definitions keep the compact tracer interface. + */ + planInitializationContributions?( + context: GenerationContext, + ): readonly PackageContribution[]; + /** Package layout is Preset-owned even when callers omit --path. */ + defaultPackagePath?(options: { + readonly context: GenerationContext; + readonly packageLeafName: string; + }): string; + planPackageAddition?(options: { + readonly context: GenerationContext; + readonly packageLeafName: string; + readonly packagePath: string; + }): PackageContribution; +}; diff --git a/packages/core/src/preset-projection.ts b/packages/core/src/preset-projection.ts deleted file mode 100644 index 47e3ddd..0000000 --- a/packages/core/src/preset-projection.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { - BuiltInPreset, - PackageRole, - PackageSourcePreset, - ProjectBlueprint, -} from "@ykdz/template-shared"; - -import type { GenerationContext } from "./generation-context.ts"; -import { type CheckPlan, type FixPlan } from "./module-graph.ts"; -import { - planNextStepInstructions, - type NextStepInstruction, -} from "./next-step-instructions.ts"; -import type { DependencyMaintenancePolicy } from "./project-github.ts"; -import type { RenderOperation } from "./renderer.ts"; - -export type PresetPackageAdditionOptions = { - readonly root: string; - readonly blueprint: ProjectBlueprint; - readonly packageLeafName: string; - readonly packageName: string; - readonly packagePath: string; - readonly nodeVersion: string; -}; - -export type PresetPackageAdditionPlan = { - readonly packagePath: string; - readonly workspacePackageGlob: string; - readonly workspaceMembershipGlob?: string; - readonly packageRole: PackageRole; - readonly packageSourcePreset: PackageSourcePreset; - readonly sourceRoot: string; - readonly sourceRoots?: Record | undefined; - readonly operations: readonly RenderOperation[]; - readonly textFiles?: readonly { - readonly path: string; - readonly text: string; - }[]; -}; - -export type PresetPackageAdditionCapability = { - planPackageAddition( - options: PresetPackageAdditionOptions, - ): PresetPackageAdditionPlan | Promise; -}; - -export type PresetProjectionPlan = { - readonly sourceRoot: string; - readonly sourceRoots?: Record | undefined; - readonly operations: readonly RenderOperation[]; - readonly checkPlan: CheckPlan; - readonly fixPlan: FixPlan; - readonly dependencyMaintenancePolicy: DependencyMaintenancePolicy; - readonly packageScripts: Record; - readonly capabilities: { - readonly rootCheck: true; - readonly fixCommand: true; - readonly githubActions: true; - readonly dependabot: true; - readonly devcontainer: true; - }; -}; - -export type PresetBlueprintOptions = { - readonly targetDir: string; - readonly scope?: string | undefined; -}; - -export type RenderPresetProjectionOptions = { - readonly targetDir: string; - readonly plan: PresetProjectionPlan; -}; - -export type PresetProjection = { - readonly metadata: BuiltInPreset; - readonly capabilities?: { - readonly packageAddition?: PresetPackageAdditionCapability; - }; - blueprint(options: PresetBlueprintOptions): ProjectBlueprint; - project(context: GenerationContext): PresetProjectionPlan; - render(options: RenderPresetProjectionOptions): Promise; -}; - -export function planNextStepInstructionsForProjection(options: { - readonly targetDir: string; - readonly plan: PresetProjectionPlan; -}): readonly NextStepInstruction[] { - return planNextStepInstructions({ - targetDir: options.targetDir, - projectionPlan: options.plan, - }).steps; -} diff --git a/packages/core/src/preset-source.ts b/packages/core/src/preset-source.ts deleted file mode 100644 index cab35da..0000000 --- a/packages/core/src/preset-source.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { - existsSync, - readdirSync, - readFileSync, - realpathSync, - statSync, -} from "node:fs"; -import path from "node:path"; - -import type { - BuiltInPreset, - PresetSourceManifest, - PresetSourceManifestPreset, - PresetSourceManifestPresetSource, - PresetSourceManifestSharedResource, - ValidationIssue, - ValidationResult, -} from "@ykdz/template-shared"; -import { - presetSourceManifestJsonSchema, - validatePresetSourceManifestDeclaration, -} from "@ykdz/template-shared"; - -import { - loadTemplateDependencyCatalog, - type TemplateDependencyCatalog, -} from "./dependency-catalog.ts"; - -export { presetSourceManifestJsonSchema }; -export type { - BuiltInPreset, - PresetSourceManifest, - PresetSourceManifestPreset, - PresetSourceManifestPresetSource, - PresetSourceManifestSharedResource, -}; - -export type PresetSourceManifestValidationOptions = { - readonly sourceRoot?: string; - readonly dependencyCatalog?: TemplateDependencyCatalog; -}; - -type CoreSemanticPresetSource = { - readonly roots: readonly string[]; - readonly files: readonly string[]; - readonly sharedResources: readonly string[]; -}; - -type CoreSemanticPreset = { - readonly manifestIndex?: number; - readonly name: string; - readonly dependencyCatalog?: readonly string[]; - readonly source?: CoreSemanticPresetSource; -}; - -type CoreSemanticSharedResource = PresetSourceManifestSharedResource & { - readonly manifestIndex?: number; -}; - -type CoreSemanticManifest = { - readonly sharedResources: readonly CoreSemanticSharedResource[]; - readonly presets: readonly CoreSemanticPreset[]; -}; - -function dependencyCatalogReferenceIssues( - presets: readonly Pick< - CoreSemanticPreset, - "dependencyCatalog" | "manifestIndex" | "name" - >[], - dependencyCatalog: TemplateDependencyCatalog, -): ValidationIssue[] { - return presets.flatMap((preset, fallbackPresetIndex) => { - const presetIndex = preset.manifestIndex ?? fallbackPresetIndex; - - return (preset.dependencyCatalog ?? []) - .filter((dependency) => dependencyCatalog[dependency] === undefined) - .map((dependency) => ({ - path: `$.presets[${presetIndex}].dependencyCatalog`, - message: `Preset ${preset.name} references missing Template Dependency Catalog entry: ${dependency}`, - })); - }); -} - -function resolvePresetSourcePath( - sourceRoot: string, - referencePath: string, -): string | { issue: string } { - if (path.isAbsolute(referencePath)) { - return { issue: `Preset Source paths must be relative: ${referencePath}` }; - } - - const resolvedRoot = path.resolve(sourceRoot); - const resolvedPath = path.resolve(resolvedRoot, referencePath); - const insideRoot = - resolvedPath === resolvedRoot || - resolvedPath.startsWith(`${resolvedRoot}${path.sep}`); - - if (!insideRoot) { - return { - issue: `Preset Source path escapes its source boundary: ${referencePath}`, - }; - } - - return resolvedPath; -} - -function realPresetSourcePathIssue( - sourceRoot: string, - referencePath: string, - resolvedPath: string, -): string | undefined { - const realRoot = realpathSync(sourceRoot); - const realPath = realpathSync(resolvedPath); - const insideRoot = - realPath === realRoot || realPath.startsWith(`${realRoot}${path.sep}`); - - return insideRoot - ? undefined - : `Preset Source path escapes its source boundary: ${referencePath}`; -} - -function sharedResourcePathIssues( - manifest: Pick, - sourceRoot: string | undefined, -): ValidationIssue[] { - if (sourceRoot === undefined) { - return []; - } - - return manifest.sharedResources.flatMap((resource, fallbackResourceIndex) => { - const resourceIndex = resource.manifestIndex ?? fallbackResourceIndex; - const resolvedPath = resolvePresetSourcePath(sourceRoot, resource.path); - - if (typeof resolvedPath !== "string") { - return [ - { - path: `$.sharedResources[${resourceIndex}].path`, - message: resolvedPath.issue, - }, - ]; - } - - if (!existsSync(resolvedPath)) { - return [ - { - path: `$.sharedResources[${resourceIndex}].path`, - message: `Shared Resource ${resource.id} path does not exist: ${resource.path}`, - }, - ]; - } - - const realPathIssue = realPresetSourcePathIssue( - sourceRoot, - resource.path, - resolvedPath, - ); - if (realPathIssue) { - return [ - { - path: `$.sharedResources[${resourceIndex}].path`, - message: realPathIssue, - }, - ]; - } - - return []; - }); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isDependencySemverSpecifier(value: string): boolean { - return /^(?:[~^]|[<>=]=?)?\d+(?:\.\d+){0,2}(?:[-+][\w.-]+)?(?:\s|$|\|\|)/.test( - value, - ); -} - -function inlineDependencyCatalogSpecifierIssues( - input: unknown, -): ValidationIssue[] { - if (!isRecord(input) || !Array.isArray(input.presets)) { - return []; - } - - return input.presets.flatMap((preset, presetIndex) => { - if (!isRecord(preset)) { - return []; - } - - if (Array.isArray(preset.dependencyCatalog)) { - return preset.dependencyCatalog.flatMap((specifier, dependencyIndex) => - typeof specifier === "string" && isDependencySemverSpecifier(specifier) - ? [ - { - path: `$.presets[${presetIndex}].dependencyCatalog[${dependencyIndex}]`, - message: `Preset Source Manifests must reference Template Dependency Catalog entries by name, not inline semver specifier ${specifier}`, - }, - ] - : [], - ); - } - - if (!isRecord(preset.dependencyCatalog)) { - return []; - } - - return Object.entries(preset.dependencyCatalog) - .filter( - (entry): entry is [string, string] => - typeof entry[1] === "string" && isDependencySemverSpecifier(entry[1]), - ) - .map(([dependency, specifier]) => ({ - path: `$.presets[${presetIndex}].dependencyCatalog.${dependency}`, - message: `Preset Source Manifests must reference Template Dependency Catalog entries by name, not inline semver specifier ${specifier}`, - })); - }); -} - -function stringArray(value: unknown): string[] | undefined { - return Array.isArray(value) && - value.every((item): item is string => typeof item === "string") - ? value - : undefined; -} - -function corePresetSourceFromUnknown( - source: unknown, -): CoreSemanticPresetSource | undefined { - if (!isRecord(source)) { - return undefined; - } - - return { - roots: stringArray(source.roots) ?? [], - files: stringArray(source.files) ?? [], - sharedResources: stringArray(source.sharedResources) ?? [], - }; -} - -function coreSemanticManifestFromUnknown( - input: unknown, -): CoreSemanticManifest | undefined { - if (!isRecord(input)) { - return undefined; - } - - const sharedResources = Array.isArray(input.sharedResources) - ? input.sharedResources.flatMap((resource, manifestIndex) => - isRecord(resource) && - typeof resource.id === "string" && - typeof resource.path === "string" - ? [{ id: resource.id, path: resource.path, manifestIndex }] - : [], - ) - : []; - - const presets = Array.isArray(input.presets) - ? input.presets.flatMap((preset, manifestIndex) => { - if (!isRecord(preset) || typeof preset.name !== "string") { - return []; - } - - const source = corePresetSourceFromUnknown(preset.source); - - return [ - { - manifestIndex, - name: preset.name, - dependencyCatalog: stringArray(preset.dependencyCatalog) ?? [], - ...(source === undefined ? {} : { source }), - }, - ]; - }) - : []; - - return { sharedResources, presets }; -} - -function presetSourceReferenceIssues( - manifest: CoreSemanticManifest, - sourceRoot: string | undefined, -): ValidationIssue[] { - const sharedResourceIds = new Set( - manifest.sharedResources.map((resource) => resource.id), - ); - - return manifest.presets.flatMap((preset, fallbackPresetIndex) => { - const presetIndex = preset.manifestIndex ?? fallbackPresetIndex; - const source = preset.source; - - if (!source) { - return []; - } - - const sharedResourceIssues = source.sharedResources - .filter((resourceId) => !sharedResourceIds.has(resourceId)) - .map((resourceId) => ({ - path: `$.presets[${presetIndex}].source.sharedResources`, - message: `Preset ${preset.name} references undeclared Shared Resource: ${resourceId}`, - })); - - const pathIssues = - sourceRoot === undefined - ? [] - : [ - ...source.roots.map((sourcePath, index) => ({ - path: `$.presets[${presetIndex}].source.roots[${index}]`, - sourcePath, - kind: "source root", - })), - ...source.files.map((sourcePath, index) => ({ - path: `$.presets[${presetIndex}].source.files[${index}]`, - sourcePath, - kind: "source file", - })), - ].flatMap(({ path: issuePath, sourcePath, kind }) => { - const resolvedPath = resolvePresetSourcePath( - sourceRoot, - sourcePath, - ); - - if (typeof resolvedPath !== "string") { - return [{ path: issuePath, message: resolvedPath.issue }]; - } - - if (!existsSync(resolvedPath)) { - return [ - { - path: issuePath, - message: `Preset ${preset.name} ${kind} does not exist: ${sourcePath}`, - }, - ]; - } - - const realPathIssue = realPresetSourcePathIssue( - sourceRoot, - sourcePath, - resolvedPath, - ); - if (realPathIssue) { - return [{ path: issuePath, message: realPathIssue }]; - } - - return []; - }); - - return [...sharedResourceIssues, ...pathIssues]; - }); -} - -function coreSemanticIssues( - manifest: CoreSemanticManifest, - options: PresetSourceManifestValidationOptions, -): ValidationIssue[] { - return [ - ...dependencyCatalogReferenceIssues( - manifest.presets, - options.dependencyCatalog ?? loadTemplateDependencyCatalog(), - ), - ...sharedResourcePathIssues(manifest, options.sourceRoot), - ...presetSourceReferenceIssues(manifest, options.sourceRoot), - ]; -} - -function supportedProjectionDeclarationIssues( - presets: readonly { - readonly generation: "supported" | "future"; - readonly projection?: unknown; - readonly name: string; - }[], -): ValidationIssue[] { - return presets.flatMap((preset, index) => - preset.generation === "supported" && preset.projection === undefined - ? [ - { - path: `$.presets[${index}].projection`, - message: `Supported Preset ${preset.name} must declare a Projection Declaration`, - }, - ] - : [], - ); -} - -export function validatePresetSourceManifest( - input: unknown, - options: PresetSourceManifestValidationOptions = {}, -): ValidationResult { - const inlineDependencyIssues = inlineDependencyCatalogSpecifierIssues(input); - if (inlineDependencyIssues.length > 0) { - return { ok: false, issues: inlineDependencyIssues }; - } - - const result = validatePresetSourceManifestDeclaration(input); - const manifest = result.ok - ? result.value - : coreSemanticManifestFromUnknown(input); - const semanticIssues = manifest ? coreSemanticIssues(manifest, options) : []; - - if (!result.ok || semanticIssues.length > 0) { - return { - ok: false, - issues: [...(result.ok ? [] : result.issues), ...semanticIssues], - }; - } - - return { - ok: true, - value: result.value, - }; -} - -export function validateBuiltInPresetSourceManifest( - input: unknown, - options: PresetSourceManifestValidationOptions = {}, -): ValidationResult { - const result = validatePresetSourceManifest(input, options); - - if (!result.ok) { - return result; - } - - const projectionIssues = supportedProjectionDeclarationIssues( - result.value.presets, - ); - if (projectionIssues.length > 0) { - return { ok: false, issues: projectionIssues }; - } - - return result; -} - -function listPresetSourceFiles(sourcePath: string): string[] { - const stats = statSync(sourcePath); - - if (stats.isFile()) { - return [sourcePath]; - } - - if (!stats.isDirectory()) { - return []; - } - - return readdirSync(sourcePath, { withFileTypes: true }).flatMap((entry) => - listPresetSourceFiles(path.join(sourcePath, entry.name)), - ); -} - -export function manifestReferencedSourceFiles( - manifest: PresetSourceManifest, - sourceRoot: string, -): string[] { - const files = new Set(); - - function addReference(referencePath: string): void { - for (const file of listPresetSourceFiles( - path.resolve(sourceRoot, referencePath), - )) { - files.add(file); - } - } - - for (const resource of manifest.sharedResources) { - addReference(resource.path); - } - - for (const preset of manifest.presets) { - for (const root of preset.source?.roots ?? []) { - addReference(root); - } - - for (const file of preset.source?.files ?? []) { - addReference(file); - } - } - - return [...files].toSorted(); -} - -export function loadPresetSourceManifestFile( - filePath: string, -): PresetSourceManifest { - const result = validatePresetSourceManifest( - JSON.parse(readFileSync(filePath, "utf8")) as unknown, - { sourceRoot: path.dirname(filePath) }, - ); - - if (!result.ok) { - throw new Error( - `Preset Source Manifest is invalid:\n${result.issues - .map((issue) => ` - ${issue.path}: ${issue.message}`) - .join("\n")}`, - ); - } - - return result.value; -} - -export function findPresetSourceManifestPreset( - manifest: PresetSourceManifest, - presetName: string, -): PresetSourceManifestPreset | undefined { - return manifest.presets.find((preset) => preset.name === presetName); -} diff --git a/packages/core/src/project-blueprint-v2.ts b/packages/core/src/project-blueprint-v2.ts new file mode 100644 index 0000000..b06153b --- /dev/null +++ b/packages/core/src/project-blueprint-v2.ts @@ -0,0 +1,231 @@ +/** + * The durable, preset-agnostic topology used by local template follow-up + * operations. Preset provenance deliberately belongs in Generation Record. + */ +export type PackageRole = + | "runtime-service" + | "shared-library" + | "native-package"; + +export type PackageDefinition = { + readonly name: string; + readonly path: string; + readonly role: PackageRole; +}; + +export type PackageLinkIntent = { + readonly consumerPackagePath: string; + readonly providerPackagePath: string; +}; + +export type ProjectBlueprintV2 = { + readonly schemaVersion: 2; + readonly packages: readonly PackageDefinition[]; + readonly packageLinkIntents?: readonly PackageLinkIntent[]; +}; + +export type BlueprintV2ValidationIssue = { + readonly path: string; + readonly message: string; +}; + +export type BlueprintV2ValidationResult = + | { readonly ok: true; readonly value: ProjectBlueprintV2 } + | { + readonly ok: false; + readonly issues: readonly BlueprintV2ValidationIssue[]; + }; + +const packageName = /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/; +const packagePath = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/; +const roles = new Set([ + "runtime-service", + "shared-library", + "native-package", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function reportUnknownKeys( + value: Record, + allowed: readonly string[], + path: string, + issues: BlueprintV2ValidationIssue[], +): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) { + issues.push({ + path: `${path}.${key}`, + message: "Unknown Blueprint v2 field", + }); + } + } +} + +/** Validates persisted metadata before it is used to plan or render changes. */ +export function validateProjectBlueprintV2( + value: unknown, +): BlueprintV2ValidationResult { + const issues: BlueprintV2ValidationIssue[] = []; + if (!isRecord(value)) { + return { + ok: false, + issues: [ + { path: ".", message: "Local Template Metadata must be an object" }, + ], + }; + } + reportUnknownKeys( + value, + ["schemaVersion", "packages", "packageLinkIntents"], + ".", + issues, + ); + if (value.schemaVersion !== 2) { + issues.push({ + path: ".schemaVersion", + message: `Unsupported Local Template Metadata schema version ${String(value.schemaVersion)}; expected 2`, + }); + } + if (!Array.isArray(value.packages)) { + issues.push({ + path: ".packages", + message: "Package Definitions must be an array", + }); + } + + const definitions: PackageDefinition[] = []; + if (Array.isArray(value.packages)) { + for (const [index, item] of value.packages.entries()) { + const itemPath = `.packages[${index}]`; + if (!isRecord(item)) { + issues.push({ + path: itemPath, + message: "Package Definition must be an object", + }); + continue; + } + reportUnknownKeys(item, ["name", "path", "role"], itemPath, issues); + if (typeof item.name !== "string" || !packageName.test(item.name)) { + issues.push({ + path: `${itemPath}.name`, + message: "Package name must be a scoped lowercase package name", + }); + } + if (typeof item.path !== "string" || !packagePath.test(item.path)) { + issues.push({ + path: `${itemPath}.path`, + message: "Package Path must be exactly two safe path segments", + }); + } + if ( + typeof item.role !== "string" || + !roles.has(item.role as PackageRole) + ) { + issues.push({ + path: `${itemPath}.role`, + message: + "Package Role must be runtime-service, shared-library, or native-package", + }); + } + if ( + typeof item.name === "string" && + typeof item.path === "string" && + typeof item.role === "string" && + packageName.test(item.name) && + packagePath.test(item.path) && + roles.has(item.role as PackageRole) + ) { + definitions.push(item as PackageDefinition); + } + } + } + for (const [property, label] of [ + ["name", "Package name"], + ["path", "Package Path"], + ] as const) { + const seen = new Set(); + for (const definition of definitions) { + const member = definition[property]; + if (seen.has(member)) + issues.push({ + path: ".packages", + message: `${label} must be unique: ${member}`, + }); + seen.add(member); + } + } + + if ( + value.packageLinkIntents !== undefined && + !Array.isArray(value.packageLinkIntents) + ) { + issues.push({ + path: ".packageLinkIntents", + message: "Package Link Intents must be an array", + }); + } + const paths = new Set(definitions.map((definition) => definition.path)); + const links = new Set(); + if (Array.isArray(value.packageLinkIntents)) { + for (const [index, item] of value.packageLinkIntents.entries()) { + const itemPath = `.packageLinkIntents[${index}]`; + if (!isRecord(item)) { + issues.push({ + path: itemPath, + message: "Package Link Intent must be an object", + }); + continue; + } + reportUnknownKeys( + item, + ["consumerPackagePath", "providerPackagePath"], + itemPath, + issues, + ); + const consumer = item.consumerPackagePath; + const provider = item.providerPackagePath; + if (typeof consumer !== "string" || !paths.has(consumer)) + issues.push({ + path: `${itemPath}.consumerPackagePath`, + message: "Package Link Intent references an unknown consumer package", + }); + if (typeof provider !== "string" || !paths.has(provider)) + issues.push({ + path: `${itemPath}.providerPackagePath`, + message: "Package Link Intent references an unknown provider package", + }); + if (consumer === provider && typeof consumer === "string") + issues.push({ + path: itemPath, + message: "Package Link Intent cannot link a package to itself", + }); + if (typeof consumer === "string" && typeof provider === "string") { + const key = `${consumer}\u0000${provider}`; + if (links.has(key)) + issues.push({ + path: itemPath, + message: "Package Link Intent must be unique", + }); + links.add(key); + } + } + } + return issues.length === 0 + ? { ok: true, value: value as ProjectBlueprintV2 } + : { ok: false, issues }; +} + +export function assertProjectBlueprintV2(value: unknown): ProjectBlueprintV2 { + const result = validateProjectBlueprintV2(value); + if (!result.ok) { + throw new Error( + result.issues + .map((issue) => `${issue.path}: ${issue.message}`) + .join("\n"), + ); + } + return result.value; +} diff --git a/packages/core/src/project-github.ts b/packages/core/src/project-github.ts index 22926d8..0ac2ab1 100644 --- a/packages/core/src/project-github.ts +++ b/packages/core/src/project-github.ts @@ -71,6 +71,11 @@ export function projectCheckWorkflow( rustToolchain: false, ...options.environmentPreparation, }; + const requiresRustToolchain = + environmentPreparation.rustToolchain || + options.checkPlan.environmentNeeds.some( + (need) => need.kind === "rust-toolchain", + ); const taskLayer = options.taskLayer ?? pnpmTaskLayer; const deploymentChecks = options.checkPlan.deploymentChecks ?? []; const needsDocker = deploymentChecks.some((check) => @@ -117,22 +122,20 @@ export function projectCheckWorkflow( ); } - if (environmentPreparation.rustToolchain) { - lines.push( - " - uses: dtolnay/rust-toolchain@stable", - " with:", - " components: rustfmt, clippy", - " - uses: Swatinem/rust-cache@v2", - ); - } + if (requiresRustToolchain) lines.push(...rustCiPreparationLines()); lines.push(` - run: ${taskLayer.installCommand}`); + const checkEnvironmentLines: string[] = []; for (const need of options.checkPlan.environmentNeeds) { - lines.push(` - run: ${renderCiEnvironmentNeedCommand(need)}`); + if (need.kind === "rust-toolchain") continue; + checkEnvironmentLines.push( + ` - run: ${renderCiEnvironmentNeedCommand(need)}`, + ); if (deploymentChecks.length > 0 && need.kind === "shellcheck-command") { - lines.push(" if: matrix.check == 'root'"); + checkEnvironmentLines.push(" if: matrix.check == 'root'"); } } + lines.push(...checkEnvironmentLines); lines.push(` - run: ${taskLayer.checkCommand}`); if (deploymentChecks.length > 0) { lines.push(" if: matrix.check == 'root'"); @@ -149,6 +152,66 @@ export function projectCheckWorkflow( return lines.join("\n"); } +function rustCiPreparationLines(): string[] { + return [ + " - uses: dtolnay/rust-toolchain@stable", + " with:", + " components: rustfmt, clippy", + " - uses: Swatinem/rust-cache@v2", + ]; +} + +/** Limited substitutions for the Foundation-owned workflow Template Source. */ +export function projectCheckWorkflowTemplateReplacements(options: { + readonly checkPlan: CheckPlan; + readonly environmentPreparation?: Partial; +}): Record { + const requiresRustToolchain = + options.environmentPreparation?.rustToolchain === true || + options.checkPlan.environmentNeeds.some( + (need) => need.kind === "rust-toolchain", + ); + const deploymentChecks = options.checkPlan.deploymentChecks ?? []; + const needsDocker = deploymentChecks.some((check) => + deploymentCheckEnvironmentNeeds(check).some( + (need) => need.kind === "docker-engine", + ), + ); + const deploymentCheck = deploymentChecks[0]; + const environmentSteps = options.checkPlan.environmentNeeds + .filter((need) => need.kind !== "rust-toolchain") + .map((need) => + [ + ` - run: ${renderCiEnvironmentNeedCommand(need)}`, + ...(deploymentChecks.length > 0 && need.kind === "shellcheck-command" + ? [" if: matrix.check == 'root'"] + : []), + ].join("\n"), + ); + return { + RUST_CI_PREPARATION: requiresRustToolchain + ? `\n${rustCiPreparationLines().join("\n")}` + : "", + CHECK_ENVIRONMENT_PREPARATION: + environmentSteps.length === 0 ? "" : `\n${environmentSteps.join("\n")}`, + DEPLOYMENT_MATRIX: + deploymentChecks.length === 0 + ? "" + : "\n strategy:\n matrix:\n check: [root, deployment]", + DEPLOYMENT_DOCKER_PREPARATION: needsDocker + ? "\n - uses: docker/setup-buildx-action@v3\n if: matrix.check == 'deployment'" + : "", + ROOT_CHECK_CONDITION: + deploymentChecks.length === 0 + ? "" + : "\n if: matrix.check == 'root'", + DEPLOYMENT_CHECK: + deploymentCheck === undefined + ? "" + : `\n - run: pnpm run ${deploymentCheckTaskName(deploymentCheck)}\n if: matrix.check == 'deployment'`, + }; +} + export function projectDependabotConfig( policy: DependencyMaintenancePolicy, ): string { @@ -169,6 +232,20 @@ export function projectDependabotConfig( ].join("\n"); } +/** Limited substitution for the Foundation-owned Dependabot Template Source. */ +export function projectDependabotTemplateReplacements( + policy: DependencyMaintenancePolicy, +): Record { + const header = "version: 2\n\nupdates:\n"; + const configuration = projectDependabotConfig(policy); + if (!configuration.startsWith(header)) { + throw new Error( + "Dependabot configuration must retain its Template Source header", + ); + } + return { DEPENDABOT_UPDATES: configuration.slice(header.length).trimEnd() }; +} + function renderDependabotUpdate( ecosystem: DependencyEcosystem, directory: DependabotDirectory, @@ -231,5 +308,7 @@ function renderCiEnvironmentNeedCommand(need: CheckEnvironmentNeed): string { return renderPlaywrightBrowserInstallCommand(need, { withDeps: true }); case "shellcheck-command": return need.nextStep.display; + case "rust-toolchain": + return need.nextStep.display; } } diff --git a/packages/core/src/project-linking-v2.ts b/packages/core/src/project-linking-v2.ts new file mode 100644 index 0000000..2780b5b --- /dev/null +++ b/packages/core/src/project-linking-v2.ts @@ -0,0 +1,63 @@ +import type { PackageContribution } from "./package-contribution.ts"; +import { + assertProjectBlueprintV2, + type ProjectBlueprintV2, +} from "./project-blueprint-v2.ts"; + +export type ExplicitProjectLinkPlan = { + readonly manifestDependenciesByPackagePath: ReadonlyMap< + string, + Readonly> + >; + /** + * Build ordering follows the same explicit package relationships as manifest + * dependencies. Turbo resolves ^build:run through the derived workspace + * dependency rather than through Preset or framework vocabulary. + */ + readonly hasBuildOrdering: boolean; +}; + +/** + * Resolves durable Link Intents using the explicit exposures supplied by + * Package Contributions. No Preset identity or string resource protocol is + * involved. + */ +export function planExplicitProjectLinks(options: { + readonly blueprint: ProjectBlueprintV2; + readonly contributions: readonly PackageContribution[]; +}): ExplicitProjectLinkPlan { + const blueprint = assertProjectBlueprintV2(options.blueprint); + const contributionsByPath = new Map( + options.contributions.map((contribution) => [ + contribution.definition.path, + contribution, + ]), + ); + const dependencies = new Map>(); + for (const intent of blueprint.packageLinkIntents ?? []) { + const provider = contributionsByPath.get(intent.providerPackagePath); + const consumer = contributionsByPath.get(intent.consumerPackagePath); + if (provider === undefined || consumer === undefined) { + throw new Error( + "Project Linking requires explicit Package Contributions for every Link Intent endpoint", + ); + } + if (Object.keys(provider.exposure.exports).length === 0) { + throw new Error( + `Package Link provider ${provider.definition.path} has no Package Exposure`, + ); + } + const consumerDependencies = dependencies.get(intent.consumerPackagePath); + if (consumerDependencies === undefined) { + dependencies.set(intent.consumerPackagePath, { + [provider.definition.name]: "workspace:*", + }); + } else { + consumerDependencies[provider.definition.name] = "workspace:*"; + } + } + return { + manifestDependenciesByPackagePath: dependencies, + hasBuildOrdering: dependencies.size > 0, + }; +} diff --git a/packages/core/src/projection-capabilities.ts b/packages/core/src/projection-capabilities.ts deleted file mode 100644 index 4167ded..0000000 --- a/packages/core/src/projection-capabilities.ts +++ /dev/null @@ -1,3335 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; - -import type { - GithubMaintenanceCapabilityDeclaration, - NodePnpmDevcontainerCapabilityDeclaration, - ProjectBlueprint, - ProjectionCapabilityDeclaration, - ProjectionCapabilityKind, - OxcFormatLintCapabilityDeclaration, - PresetProjectionDeclaration, - RustBinaryWorkspaceCapabilityDeclaration, - StrictTypescriptRootCapabilityDeclaration, - WorkspaceLibraryPackageCapabilityDeclaration, - WorkspaceNodePackageDeclaration, - WorkspaceNodePackageKind, - WorkspaceNodePackagesCapabilityDeclaration, - WorkspaceNodePackagePath, -} from "@ykdz/template-shared"; -import type { PackageRole, PackageSourcePreset } from "@ykdz/template-shared"; -import { - normalizePresetProjectionDeclaration, - projectionCapabilityIssues, - validatePresetProjectionDeclaration as validateProjectionCapabilities, -} from "@ykdz/template-shared"; - -import { - collectGeneratedManifestCatalogDependencies, - collectGeneratedManifestCatalogReferences, - renderCargoLockForPackage, - renderGeneratedPnpmWorkspaceYaml, - renderCargoDependencyTomlEntries, -} from "./dependency-catalog.ts"; -import { - browserTestToolLayer, - checkedDockerfileFirstNodePnpmDevcontainer, - type DevelopmentContainerDockerfileFragments, - dockerfileFirstRustPnpmDevcontainer, - nodePnpmToolLayer, - rustToolLayer, - shellCheckToolLayer, -} from "./devcontainer.ts"; -import { - editorCustomizationForCapabilities, - loadEditorCustomizationDeclarations, - type EditorCustomizationCapability, - type EditorCustomizationDeclarations, -} from "./editor-customization.ts"; -import type { GenerationContext } from "./generation-context.ts"; -import { - type CheckPlan, - type ComponentOwner, - type DeploymentCheckComponent, - type FixPlan, - playwrightBrowserAssetsEnvironmentNeed, - renderDeploymentCheckCommand, - renderCheckLeafCommand, - renderFixCommand, - renderFixLeafCommand, - renderRootCheckCommand, - renderTurboRunCommand, - shellCheckEnvironmentNeed, -} from "./module-graph.ts"; -import { - packageManifestExposureFields, - packageTurboConfig, - planPackageLinks, -} from "./package-linking.ts"; -import type { - PresetBlueprintOptions, - PresetPackageAdditionOptions, - PresetPackageAdditionPlan, - PresetProjectionPlan, -} from "./preset-projection.ts"; -import type { PresetSourceManifestPreset } from "./preset-source.ts"; -import type { - DependencyEcosystem, - DependencyMaintenancePolicy, - DependabotDirectory, -} from "./project-github.ts"; -import type { RenderOperation } from "./renderer.ts"; - -export { - normalizePresetProjectionDeclaration, - projectionCapabilityIssues, - validateProjectionCapabilities, -}; -export type { - GithubMaintenanceCapabilityDeclaration, - NodePnpmDevcontainerCapabilityDeclaration, - OxcFormatLintCapabilityDeclaration, - PresetProjectionDeclaration, - ProjectionCapabilityDeclaration, - ProjectionCapabilityKind, - RustBinaryWorkspaceCapabilityDeclaration, - StrictTypescriptRootCapabilityDeclaration, - WorkspaceLibraryPackageCapabilityDeclaration, - WorkspaceNodePackageDeclaration, - WorkspaceNodePackageKind, - WorkspaceNodePackagesCapabilityDeclaration, - WorkspaceNodePackagePath, -}; - -export type ProjectionSourcePreset = - | "hono-api" - | "rust-bin" - | "ts-lib" - | "vike-app" - | "vue-app" - | "vue-hono-app"; - -export type PresetProjectionSourceRoots = { - readonly preset: (sourcePreset: ProjectionSourcePreset) => string; - readonly sharedOxc: () => string; - readonly sharedResource: (resourceId: string) => string | undefined; -}; - -type ProjectionPlanCapabilityFlag = keyof PresetProjectionPlan["capabilities"]; - -type ProjectionCapabilityInterpreter< - T extends ProjectionCapabilityDeclaration, -> = { - readonly kind: T["kind"]; - contribute(options: { - readonly capability: T; - readonly state: ProjectionCompositionState; - }): void; -}; - -type ProjectionOperationFactory = (options: { - readonly context: GenerationContext; - readonly state: ProjectionCompositionState; - readonly packageScripts: Record; - readonly packageScriptsByPath: ReadonlyMap>; -}) => readonly RenderOperation[]; - -type WriteJsonRenderOperation = Extract; - -type ProjectionCompositionState = { - projectionSourceRoots: PresetProjectionSourceRoots; - sourceRoot?: string; - sourceRoots: Record; - rootCheckComponents: CheckPlan["components"]; - rootCheckEnvironmentNeeds: CheckPlan["environmentNeeds"]; - deploymentCheckComponents: DeploymentCheckComponent[]; - packageCheckComponents: CheckPlan["components"]; - rootFixComponents: FixPlan["components"]; - packageFixComponents: FixPlan["components"]; - rootScriptFragments: Record; - packageScriptFragments: Record; - rootDevDependencies: Set; - packageDependencies: Set; - packageDevDependencies: Set; - dependencyMaintenanceEcosystems: DependencyMaintenancePolicy["ecosystems"]; - dependencyMaintenanceExtraDirectories: Partial< - Record - >; - package?: WorkspaceLibraryPackageCapabilityDeclaration; - nodeWorkspace?: WorkspaceNodePackagesCapabilityDeclaration; - rustWorkspace?: RustBinaryWorkspaceCapabilityDeclaration; - devcontainerResource?: { - readonly id: string; - readonly root: string; - readonly sourceRootKey: string; - }; - editorCustomizationResource?: { - readonly id: string; - readonly root: string; - readonly declarations: EditorCustomizationDeclarations; - }; - editorCustomizationCapabilities: EditorCustomizationCapability[]; - operationFactories: ProjectionOperationFactory[]; - flags: Partial>; -}; - -const strictTypescriptRootBoundary: ComponentOwner = { - kind: "workspace-orchestration", - path: ".", -}; - -const workspacePackageBoundary: ComponentOwner = { - kind: "package-boundary", - path: ".", -}; - -const requiredPlanCapabilityProviders: readonly { - readonly flag: ProjectionPlanCapabilityFlag; - readonly kind: ProjectionCapabilityKind; - readonly label: string; -}[] = [ - { - flag: "rootCheck", - kind: "strict-typescript-root", - label: "root check command", - }, - { - flag: "fixCommand", - kind: "oxc-format-lint", - label: "fix command", - }, - { - flag: "githubActions", - kind: "github-maintenance", - label: "GitHub Actions maintenance", - }, - { - flag: "dependabot", - kind: "github-maintenance", - label: "Dependabot maintenance", - }, - { - flag: "devcontainer", - kind: "node-pnpm-devcontainer", - label: "development container support", - }, -]; - -const dependencyMaintenanceEcosystems: DependencyMaintenancePolicy["ecosystems"] = - ["npm", "github-actions", "docker"]; -const sharedTypeScriptResourceId = "shared-typescript"; -const sharedTypeScriptSourceRootKey = "sharedTypescript"; -const sharedToolchainMaintenanceResourceId = "shared-toolchain-maintenance"; -const sharedToolchainMaintenanceSourceRootKey = "sharedToolchainMaintenance"; -const sharedPnpmPeerPolicyResourceId = "shared-pnpm-peer-policy"; -const sharedPnpmPeerPolicySourceRootKey = "sharedPnpmPeerPolicy"; - -function setPnpmPeerPolicyResource(state: ProjectionCompositionState): void { - const root = state.projectionSourceRoots.sharedResource( - sharedPnpmPeerPolicyResourceId, - ); - if (root === undefined) return; - state.sourceRoots[sharedPnpmPeerPolicySourceRootKey] = root; -} - -function strictTypeScriptCompilerOptions( - options: Record, -): Record { - return { - ...options, - erasableSyntaxOnly: true, - exactOptionalPropertyTypes: true, - forceConsistentCasingInFileNames: true, - isolatedModules: true, - noEmitOnError: true, - noFallthroughCasesInSwitch: true, - noImplicitOverride: true, - noImplicitReturns: true, - noUncheckedIndexedAccess: true, - rewriteRelativeImportExtensions: - options.rewriteRelativeImportExtensions ?? true, - skipLibCheck: false, - strict: true, - target: "es2023", - verbatimModuleSyntax: true, - }; -} - -function setSharedTypeScriptResource(state: ProjectionCompositionState): void { - const root = state.projectionSourceRoots.sharedResource( - sharedTypeScriptResourceId, - ); - - if (root === undefined) { - throw new Error( - `Vue TypeScript compatibility requires Shared Resource: ${sharedTypeScriptResourceId}`, - ); - } - - state.sourceRoots[sharedTypeScriptSourceRootKey] = root; -} - -function setToolchainMaintenanceResource( - state: ProjectionCompositionState, -): void { - const sourceRoot = state.projectionSourceRoots.sharedResource( - sharedToolchainMaintenanceResourceId, - ); - if (sourceRoot === undefined) { - throw new Error( - `Toolchain Baseline maintenance requires Shared Resource: ${sharedToolchainMaintenanceResourceId}`, - ); - } - state.sourceRoots[sharedToolchainMaintenanceSourceRootKey] = sourceRoot; - state.rootDevDependencies.add("@types/node"); - state.rootDevDependencies.add("@types/semver"); - state.rootDevDependencies.add("semver"); - state.rootDevDependencies.add("typescript-7"); -} - -function contributeToolchainMaintenance( - state: ProjectionCompositionState, - options: { readonly provideRootTypecheck: boolean }, -): void { - setToolchainMaintenanceResource(state); - state.operationFactories.push(toolchainMaintenanceOperations); - if (options.provideRootTypecheck) { - state.sourceRoots.sharedOxc = state.projectionSourceRoots.sharedOxc(); - state.rootCheckComponents.unshift({ - kind: "typescript-typecheck", - owner: strictTypescriptRootBoundary, - }); - state.rootScriptFragments.typecheck = - "tsc -p tsconfig.config.json --noEmit --pretty false"; - state.operationFactories.push(toolchainMaintenanceRootTypecheckOperations); - } -} - -const capabilityInterpreters = { - "workspace-library-package": { - kind: "workspace-library-package", - contribute({ capability, state }) { - setPnpmPeerPolicyResource(state); - state.package = capability; - state.sourceRoot = templateSourceRoot( - state, - capability.packageSourcePreset, - ); - state.packageDependencies.add("valibot"); - state.operationFactories.push(workspaceLibraryPackageOperations); - }, - }, - "workspace-node-packages": { - kind: "workspace-node-packages", - contribute({ capability, state }) { - setPnpmPeerPolicyResource(state); - state.nodeWorkspace = capability; - state.sourceRoot = templateSourceRootForPreset( - state, - sourceRootPreset(capability), - ); - state.operationFactories.push(workspaceNodePackagesOperations); - if (hasVikePackage(capability)) { - state.dependencyMaintenanceExtraDirectories.docker = ["/apps/web"]; - state.rootCheckEnvironmentNeeds.push( - shellCheckEnvironmentNeed({ - kind: "package-boundary", - path: "apps/web", - }), - ); - state.deploymentCheckComponents.push({ - kind: "deployment-image", - owner: { kind: "package-boundary", path: "apps/web" }, - }); - } - if (capability.packages.length > 1) { - state.rootScriptFragments.dev = "turbo run dev --parallel"; - } - const vuePackage = findVuePackage(capability); - if (vuePackage !== undefined) { - setSharedTypeScriptResource(state); - state.rootCheckEnvironmentNeeds.push( - playwrightBrowserAssetsEnvironmentNeed({ - browser: "chromium", - owner: { kind: "package-boundary", path: vuePackage.path }, - }), - ); - state.editorCustomizationCapabilities.push("vue", "tailwind"); - } - state.editorCustomizationCapabilities.push("vitest"); - }, - }, - "rust-binary-workspace": { - kind: "rust-binary-workspace", - contribute({ capability, state }) { - state.rustWorkspace = capability; - state.sourceRoot = templateSourceRootForPreset(state, "rust-bin"); - contributeToolchainMaintenance(state, { provideRootTypecheck: true }); - setDevelopmentContainerResource( - state, - capability.devcontainerResourceId, - "rust-binary-workspace", - ); - setEditorCustomizationResource( - state, - capability.editorCustomizationResourceId, - "rust-binary-workspace", - ); - state.rootCheckComponents.push({ - kind: "turbo-package-check", - owner: workspaceGlobBoundary(capability.workspacePackageGlob), - }); - state.rootFixComponents.push({ - kind: "turbo-package-fix", - owner: workspaceGlobBoundary(capability.workspacePackageGlob), - }); - state.packageCheckComponents.push( - { kind: "rustfmt-check", owner: workspacePackageBoundary }, - { kind: "cargo-clippy", owner: workspacePackageBoundary }, - { kind: "cargo-test", owner: workspacePackageBoundary }, - ); - state.packageFixComponents.push({ - kind: "rustfmt-write", - owner: workspacePackageBoundary, - }); - state.dependencyMaintenanceEcosystems.push( - "npm", - "cargo", - "github-actions", - "docker", - "rust-toolchain", - ); - state.editorCustomizationCapabilities.push("rust-tooling"); - state.flags.rootCheck = true; - state.flags.fixCommand = true; - state.flags.githubActions = true; - state.flags.dependabot = true; - state.flags.devcontainer = true; - state.operationFactories.push(rustBinaryWorkspaceOperations); - }, - }, - "strict-typescript-root": { - kind: "strict-typescript-root", - contribute({ state }) { - state.rootCheckComponents.push({ - kind: "typescript-typecheck", - owner: strictTypescriptRootBoundary, - }); - const workspaceBoundary = workspaceBoundaryForState(state); - state.rootCheckComponents.push({ - kind: "turbo-package-check", - owner: workspaceBoundary, - }); - state.packageCheckComponents.push({ - kind: "typescript-typecheck", - owner: workspacePackageBoundary, - }); - state.rootScriptFragments.typecheck = - "tsc -p tsconfig.config.json --noEmit --pretty false"; - state.packageScriptFragments.typecheck = - "tsc -p tsconfig.json --noEmit --pretty false"; - state.rootDevDependencies.add("typescript-7"); - state.packageDevDependencies.add("@types/node"); - state.packageDevDependencies.add("typescript-7"); - state.flags.rootCheck = true; - state.operationFactories.push(strictTypescriptOperations); - }, - }, - "oxc-format-lint": { - kind: "oxc-format-lint", - contribute({ capability, state }) { - state.sourceRoots.sharedOxc = state.projectionSourceRoots.sharedOxc(); - setEditorCustomizationResource( - state, - capability.editorCustomizationResourceId, - "oxc-format-lint", - ); - state.rootCheckComponents.unshift( - { - kind: "oxc-format-check", - owner: strictTypescriptRootBoundary, - }, - { - kind: "oxc-lint", - owner: strictTypescriptRootBoundary, - }, - ); - state.packageCheckComponents.push( - { kind: "oxc-lint", owner: workspacePackageBoundary }, - { kind: "oxc-format-check", owner: workspacePackageBoundary }, - ); - state.rootFixComponents.push( - { - kind: "oxc-format-write", - owner: strictTypescriptRootBoundary, - }, - { - kind: "oxc-lint-fix", - owner: strictTypescriptRootBoundary, - }, - { - kind: "turbo-package-fix", - owner: workspaceBoundaryForState(state), - }, - ); - state.packageFixComponents.push( - { kind: "oxc-format-write", owner: workspacePackageBoundary }, - { kind: "oxc-lint-fix", owner: workspacePackageBoundary }, - ); - state.rootScriptFragments["format:check"] = - "oxfmt --list-different oxlint.config.ts oxfmt.config.ts"; - state.rootScriptFragments["format:write"] = - "oxfmt --write oxlint.config.ts oxfmt.config.ts"; - state.rootScriptFragments.lint = - "oxlint --quiet --format=unix oxlint.config.ts oxfmt.config.ts"; - state.rootScriptFragments["lint:fix"] = - "oxlint --format=unix oxlint.config.ts oxfmt.config.ts --fix"; - state.packageScriptFragments["format:check"] = - "oxfmt --list-different --config ../../oxfmt.config.ts ."; - state.packageScriptFragments["format:write"] = - "oxfmt --write --config ../../oxfmt.config.ts ."; - state.packageScriptFragments.lint = - "oxlint --quiet --format=unix --config ../../oxlint.config.ts ."; - state.packageScriptFragments["lint:fix"] = - "oxlint --format=unix --config ../../oxlint.config.ts . --fix"; - state.rootDevDependencies.add("oxfmt"); - state.rootDevDependencies.add("oxlint"); - state.rootDevDependencies.add("oxlint-tsgolint"); - state.packageDevDependencies.add("oxfmt"); - state.packageDevDependencies.add("oxlint"); - state.packageDevDependencies.add("oxlint-tsgolint"); - state.editorCustomizationCapabilities.push("oxc-format-lint"); - state.flags.fixCommand = true; - state.operationFactories.push(oxcFormatLintOperations); - }, - }, - "node-pnpm-devcontainer": { - kind: "node-pnpm-devcontainer", - contribute({ capability, state }) { - setDevelopmentContainerResource( - state, - capability.devcontainerResourceId, - "node-pnpm-devcontainer", - ); - state.flags.devcontainer = true; - state.operationFactories.push(nodePnpmDevcontainerOperations); - }, - }, - "github-maintenance": { - kind: "github-maintenance", - contribute({ state }) { - contributeToolchainMaintenance(state, { provideRootTypecheck: false }); - state.dependencyMaintenanceEcosystems.push( - ...dependencyMaintenanceEcosystems, - ); - state.flags.githubActions = true; - state.flags.dependabot = true; - state.operationFactories.push(githubMaintenanceOperations); - }, - }, -} satisfies { - readonly [Kind in ProjectionCapabilityKind]: ProjectionCapabilityInterpreter< - Extract - >; -}; - -function contributeProjectionCapability( - capability: ProjectionCapabilityDeclaration, - state: ProjectionCompositionState, -): void { - switch (capability.kind) { - case "workspace-library-package": - capabilityInterpreters["workspace-library-package"].contribute({ - capability, - state, - }); - return; - case "workspace-node-packages": - capabilityInterpreters["workspace-node-packages"].contribute({ - capability, - state, - }); - return; - case "rust-binary-workspace": - capabilityInterpreters["rust-binary-workspace"].contribute({ - capability, - state, - }); - return; - case "strict-typescript-root": - capabilityInterpreters["strict-typescript-root"].contribute({ - capability, - state, - }); - return; - case "oxc-format-lint": - capabilityInterpreters["oxc-format-lint"].contribute({ - capability, - state, - }); - return; - case "node-pnpm-devcontainer": - capabilityInterpreters["node-pnpm-devcontainer"].contribute({ - capability, - state, - }); - return; - case "github-maintenance": - capabilityInterpreters["github-maintenance"].contribute({ - capability, - state, - }); - return; - } -} - -export function interpretPresetProjectionDeclaration(options: { - readonly preset: PresetSourceManifestPreset; - readonly declaration: PresetProjectionDeclaration; - readonly context: GenerationContext; - readonly sourceRoots: PresetProjectionSourceRoots; -}): PresetProjectionPlan { - const validation = validateProjectionCapabilities(options.declaration); - if (!validation.ok) { - throw new Error( - `Projection Declaration is invalid:\n${validation.issues - .map((issue) => ` - ${issue.path}: ${issue.message}`) - .join("\n")}`, - ); - } - - const state = createProjectionCompositionState(options.sourceRoots); - for (const capability of validation.value.capabilities) { - contributeProjectionCapability(capability, state); - } - - const checkPlan: CheckPlan = { - components: state.rootCheckComponents, - environmentNeeds: state.rootCheckEnvironmentNeeds, - ...(state.deploymentCheckComponents.length === 0 - ? {} - : { deploymentChecks: state.deploymentCheckComponents }), - }; - const fixPlan: FixPlan = { - components: state.rootFixComponents, - }; - const packageScripts = projectRootPackageScripts( - checkPlan, - fixPlan, - state.rootScriptFragments, - ); - const packageScriptsByPath = packageScriptsByWorkspacePath(state); - const dependencyMaintenancePolicy: DependencyMaintenancePolicy = { - ecosystems: uniqueValues(state.dependencyMaintenanceEcosystems), - extraDirectories: state.dependencyMaintenanceExtraDirectories, - ...(state.rustWorkspace === undefined - ? {} - : { - directories: { - cargo: `/${rustWorkspacePackagePath(options.context)}`, - }, - }), - interval: "weekly", - }; - - if (state.sourceRoot === undefined) { - throw new Error( - "Projection Capability composition did not provide sourceRoot", - ); - } - - return { - sourceRoot: state.sourceRoot, - sourceRoots: state.sourceRoots, - operations: state.operationFactories.flatMap((factory) => - factory({ - context: options.context, - state, - packageScripts, - packageScriptsByPath, - }), - ), - checkPlan, - fixPlan, - dependencyMaintenancePolicy, - packageScripts, - capabilities: completePlanCapabilityFlags(state.flags), - }; -} - -export function blueprintForPresetSourcePreset( - preset: PresetSourceManifestPreset, - options: PresetBlueprintOptions = { targetDir: process.cwd() }, -): ProjectBlueprint { - const packageScope = options.scope ?? projectNameFromDir(options.targetDir); - const projectName = projectNameFromDir(options.targetDir); - const packageManager = preset.supportedPackageManagers[0]; - const blueprint: ProjectBlueprint = { - schemaVersion: 1, - preset: preset.name, - ...(packageManager === undefined ? {} : { packageManager }), - projectKind: preset.supportedProjectKinds[0] ?? "multi-package", - features: [...preset.features], - }; - - if (preset.projection === undefined) { - return blueprint; - } - - const validation = validateProjectionCapabilities(preset.projection); - if (!validation.ok) { - throw new Error( - `Projection Declaration is invalid:\n${validation.issues - .map((issue) => ` - ${issue.path}: ${issue.message}`) - .join("\n")}`, - ); - } - - const packages = blueprintPackagesForCapabilities( - validation.value.capabilities, - packageScope, - projectName, - ); - - return packages.length === 0 ? blueprint : { ...blueprint, packages }; -} - -export function projectPresetSourcePreset(options: { - readonly preset: PresetSourceManifestPreset; - readonly context: GenerationContext; - readonly sourceRoots: PresetProjectionSourceRoots; -}): PresetProjectionPlan { - if (options.preset.projection === undefined) { - throw new Error( - `Preset ${options.preset.name} must declare a Projection Declaration`, - ); - } - - return interpretPresetProjectionDeclaration({ - preset: options.preset, - declaration: options.preset.projection, - context: options.context, - sourceRoots: options.sourceRoots, - }); -} - -export async function planPresetSourcePackageAddition(options: { - readonly preset: PresetSourceManifestPreset; - readonly addition: PresetPackageAdditionOptions; - readonly sourceRoots: PresetProjectionSourceRoots; -}): Promise { - if (options.preset.projection === undefined) { - throw new Error( - `Preset ${options.preset.name} declares Package Addition support but has no Projection Declaration`, - ); - } - - const state = stateForProjectionDeclaration( - options.preset.projection, - options.sourceRoots, - ); - const additionCapability = packageAdditionCapabilityForState( - options.preset.name, - state, - ); - - return { - packagePath: options.addition.packagePath, - workspacePackageGlob: additionCapability.workspacePackageGlob, - workspaceMembershipGlob: `${packageCollectionFromPackagePath( - options.addition.packagePath, - )}/*`, - packageRole: additionCapability.packageRole, - packageSourcePreset: additionCapability.packageSourcePreset, - sourceRoot: templateSourceRootForPreset( - state, - additionCapability.sourcePreset, - ), - sourceRoots: state.sourceRoots, - operations: packageAdditionOperationsForCapability({ - capability: additionCapability, - packageName: options.addition.packageName, - packagePath: options.addition.packagePath, - nodeVersion: options.addition.nodeVersion, - }), - }; -} - -export function defaultPackagePathForPresetSourcePackageAddition( - preset: PresetSourceManifestPreset, - packageLeafName: string, - sourceRoots: PresetProjectionSourceRoots, -): string { - if (preset.projection === undefined) { - throw new Error( - `Preset ${preset.name} declares Package Addition support but has no Projection Declaration`, - ); - } - - const state = stateForProjectionDeclaration(preset.projection, sourceRoots); - const additionCapability = packageAdditionCapabilityForState( - preset.name, - state, - ); - - return `${packageCollection(additionCapability.workspacePackageGlob)}/${packageLeafName}`; -} - -function createProjectionCompositionState( - sourceRoots: PresetProjectionSourceRoots, -): ProjectionCompositionState { - return { - projectionSourceRoots: sourceRoots, - sourceRoots: {}, - rootCheckComponents: [], - rootCheckEnvironmentNeeds: [], - deploymentCheckComponents: [], - packageCheckComponents: [], - rootFixComponents: [], - packageFixComponents: [], - rootScriptFragments: {}, - packageScriptFragments: {}, - rootDevDependencies: new Set(["turbo"]), - packageDependencies: new Set(), - packageDevDependencies: new Set(), - dependencyMaintenanceEcosystems: [], - dependencyMaintenanceExtraDirectories: {}, - editorCustomizationCapabilities: [], - operationFactories: [], - flags: {}, - }; -} - -function uniqueValues(values: readonly T[]): T[] { - return [...new Set(values)]; -} - -function setDevelopmentContainerResource( - state: ProjectionCompositionState, - resourceId: string, - capabilityKind: "node-pnpm-devcontainer" | "rust-binary-workspace", -): void { - const root = state.projectionSourceRoots.sharedResource(resourceId); - - if (root === undefined) { - throw new Error( - `Projection Capability ${capabilityKind} references unresolved Development Container Shared Resource: ${resourceId}`, - ); - } - - const sourceRootKey = developmentContainerResourceSourceRootKey(resourceId); - - state.devcontainerResource = { id: resourceId, root, sourceRootKey }; - state.sourceRoots[sourceRootKey] = root; -} - -function setEditorCustomizationResource( - state: ProjectionCompositionState, - resourceId: string, - capabilityKind: "oxc-format-lint" | "rust-binary-workspace", -): void { - const root = state.projectionSourceRoots.sharedResource(resourceId); - - if (root === undefined) { - throw new Error( - `Projection Capability ${capabilityKind} references unresolved Editor Customization Shared Resource: ${resourceId}`, - ); - } - - state.editorCustomizationResource = { - id: resourceId, - root, - declarations: loadEditorCustomizationDeclarations(root), - }; -} - -function developmentContainerResourceSourceRootKey(resourceId: string): string { - return `devcontainer:${resourceId}`; -} - -function editorCustomizationDeclarationsForState( - state: ProjectionCompositionState, -): EditorCustomizationDeclarations { - if (state.editorCustomizationResource === undefined) { - throw new Error( - "Projection Capability composition did not provide Editor Customization Shared Resource", - ); - } - - return state.editorCustomizationResource.declarations; -} - -function projectNameFromDir(targetDir: string): string { - return path.basename(path.resolve(targetDir)); -} - -function blueprintPackagesForCapabilities( - capabilities: readonly ProjectionCapabilityDeclaration[], - packageScope: string, - projectName: string, -): NonNullable { - return capabilities.flatMap((capability) => { - if (capability.kind === "workspace-library-package") { - return [ - { - name: `@${packageScope}/${projectName}`, - path: `${packageCollection(capability.workspacePackageGlob)}/${projectName}`, - }, - ]; - } - - if (capability.kind === "workspace-node-packages") { - return capability.packages.map((nodePackage) => { - const leaf = nodePackage.path.split("/").at(-1) ?? nodePackage.path; - return { - name: `@${packageScope}/${leaf}`, - path: nodePackage.path, - }; - }); - } - - if (capability.kind === "rust-binary-workspace") { - const rustPackageName = cargoPackageNameFromProjectName(projectName); - return [ - { - name: `${rustPackageName}-native`, - path: `packages/${rustPackageName}`, - }, - ]; - } - - return []; - }); -} - -function stateForProjectionDeclaration( - declaration: PresetProjectionDeclaration, - sourceRoots: PresetProjectionSourceRoots, -): ProjectionCompositionState { - const validation = validateProjectionCapabilities(declaration); - if (!validation.ok) { - throw new Error( - `Projection Declaration is invalid:\n${validation.issues - .map((issue) => ` - ${issue.path}: ${issue.message}`) - .join("\n")}`, - ); - } - - const state = createProjectionCompositionState(sourceRoots); - for (const capability of validation.value.capabilities) { - contributeProjectionCapability(capability, state); - } - - return state; -} - -type PackageAdditionProjectionCapability = { - readonly sourcePreset: "hono-api" | "ts-lib" | "vike-app" | "vue-app"; - readonly workspacePackageGlob: "apps/*" | "packages/*"; - readonly packageRole: PackageRole; - readonly packageSourcePreset: PackageSourcePreset; - readonly sourceFiles: readonly string[]; - readonly packageScripts: Record; - readonly nodePackage?: WorkspaceNodePackageDeclaration; - readonly nodeWorkspace?: WorkspaceNodePackagesCapabilityDeclaration; -}; - -function packageAdditionCapabilityForState( - presetName: string, - state: ProjectionCompositionState, -): PackageAdditionProjectionCapability { - if (state.package !== undefined) { - const scripts = packageScriptsByWorkspacePath(state).get( - state.package.workspacePackageGlob, - ); - if (scripts === undefined) { - throw new Error( - `Missing package scripts for ${state.package.workspacePackageGlob}`, - ); - } - - return { - sourcePreset: state.package.packageSourcePreset, - workspacePackageGlob: state.package.workspacePackageGlob, - packageRole: state.package.packageRole, - packageSourcePreset: state.package.packageSourcePreset, - sourceFiles: state.package.sourceFiles, - packageScripts: scripts, - }; - } - - if (state.nodeWorkspace !== undefined) { - if (state.nodeWorkspace.packages.length !== 1) { - throw new Error( - `Preset ${presetName} is an initialization-only workspace and cannot be used for Package Addition`, - ); - } - - const nodePackage = state.nodeWorkspace.packages[0]; - if (nodePackage === undefined) { - throw new Error( - `Preset ${presetName} must declare one package for Package Addition`, - ); - } - - return { - sourcePreset: nodePackage.kind, - workspacePackageGlob: state.nodeWorkspace.workspacePackageGlob, - packageRole: "runtime-service", - packageSourcePreset: nodePackage.kind, - sourceFiles: nodePackage.sourceFiles, - packageScripts: nodePackageScripts(nodePackage, state.nodeWorkspace), - nodePackage, - nodeWorkspace: state.nodeWorkspace, - }; - } - - throw new Error( - `Preset ${presetName} is an initialization-only workspace and cannot be used for Package Addition`, - ); -} - -function packageCollectionFromPackagePath(packagePath: string): string { - const [workspaceCollection] = packagePath.split("/"); - if (!workspaceCollection) { - throw new Error( - `Invalid Package Path for Package Addition: ${packagePath}`, - ); - } - - return workspaceCollection; -} - -function packageAdditionOperationsForCapability(options: { - readonly capability: PackageAdditionProjectionCapability; - readonly packageName: string; - readonly packagePath: string; - readonly nodeVersion: string; -}): RenderOperation[] { - switch (options.capability.packageSourcePreset) { - case "ts-lib": - return libraryPackageAdditionOperations(options); - case "hono-api": - return honoApiPackageAdditionOperations(options); - case "vike-app": - throw new Error("vike-app cannot be used for Package Addition"); - case "vue-app": - return vueAppPackageAdditionOperations(options); - } -} - -function generationContextForPackageAddition(options: { - readonly preset: string; - readonly packageName: string; - readonly packagePath: string; - readonly nodeVersion: string; -}): GenerationContext { - return { - projectName: { - kind: "ProjectName", - value: options.packageName.split("/").at(0)?.replace(/^@/, "") ?? "app", - }, - preset: options.preset, - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: { - schemaVersion: 1, - preset: options.preset, - packageManager: "pnpm", - projectKind: "multi-package", - features: [], - packages: [{ name: options.packageName, path: options.packagePath }], - }, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: options.nodeVersion }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.11.0" }, - source: "bundled-fallback", - diagnostics: [], - }, - }; -} - -function libraryPackageAdditionOperations(options: { - readonly capability: PackageAdditionProjectionCapability; - readonly packageName: string; - readonly packagePath: string; - readonly nodeVersion: string; -}): RenderOperation[] { - const packageExposure = planPackageLinks([ - { - name: options.packageName, - path: options.packagePath, - role: "shared-library", - sourcePreset: "ts-lib", - }, - ]).exposuresByPackagePath.get(options.packagePath); - - if (packageExposure === undefined) { - throw new Error(`Missing Package Exposure for ${options.packagePath}`); - } - - const exposureFields = packageManifestExposureFields(packageExposure); - - return [ - { - kind: "writeJson", - to: `${options.packagePath}/package.json`, - value: { - name: options.packageName, - version: "0.0.0", - private: true, - type: "module", - imports: exposureFields.imports, - exports: exposureFields.exports, - dependencies: { - valibot: "catalog:", - }, - scripts: options.capability.packageScripts, - devDependencies: { - "@types/node": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "typescript-7": "catalog:", - }, - engines: { - node: options.nodeVersion, - }, - }, - multilineArrays: ["files"], - }, - { - kind: "writeJson", - to: `${options.packagePath}/tsconfig.json`, - value: libraryTsconfigJson(), - }, - ...options.capability.sourceFiles.map((sourceFile) => ({ - kind: "copyFile" as const, - from: sourceFile, - to: `${options.packagePath}/${sourceFile}`, - })), - ]; -} - -function libraryTsconfigJson(): Record { - return { - compilerOptions: strictTypeScriptCompilerOptions({ - composite: true, - declaration: true, - declarationMap: true, - module: "nodenext", - moduleResolution: "nodenext", - rootDir: "src", - types: ["node"], - }), - include: ["src/**/*.ts"], - }; -} - -function honoApiPackageAdditionOperations(options: { - readonly capability: PackageAdditionProjectionCapability; - readonly packageName: string; - readonly packagePath: string; - readonly nodeVersion: string; -}): RenderOperation[] { - const packageExposure = planPackageLinks([ - { - name: options.packageName, - path: options.packagePath, - role: "runtime-service", - sourcePreset: "hono-api", - }, - ]).exposuresByPackagePath.get(options.packagePath); - - if (packageExposure === undefined) { - throw new Error(`Missing Package Exposure for ${options.packagePath}`); - } - - return [ - { - kind: "writeJson", - to: `${options.packagePath}/package.json`, - value: honoApiPackageJson( - generationContextForPackageAddition({ - preset: "hono-api", - packageName: options.packageName, - packagePath: options.packagePath, - nodeVersion: options.nodeVersion, - }), - options.packageName, - options.capability.packageScripts, - packageManifestExposureFields(packageExposure), - ), - }, - ...honoApiTsconfigOperations(options.packagePath), - ...options.capability.sourceFiles.map((sourceFile) => ({ - kind: "copyFile" as const, - from: sourceFile, - to: `${options.packagePath}/${sourceFile}`, - })), - ]; -} - -function vueAppPackageAdditionOperations(options: { - readonly capability: PackageAdditionProjectionCapability; - readonly packageName: string; - readonly packagePath: string; - readonly nodeVersion: string; -}): RenderOperation[] { - return [ - { - kind: "writeJson", - to: `${options.packagePath}/package.json`, - value: vuePackageJson( - generationContextForPackageAddition({ - preset: "vue-app", - packageName: options.packageName, - packagePath: options.packagePath, - nodeVersion: options.nodeVersion, - }), - options.packageName, - options.capability.packageScripts, - ), - }, - ...vueAppTsconfigOperations(options.packagePath), - { - kind: "copyFile", - from: "run-vue-tsc.ts", - to: `${options.packagePath}/scripts/run-vue-tsc.ts`, - sourceRoot: sharedTypeScriptSourceRootKey, - }, - ...options.capability.sourceFiles.map((sourceFile) => ({ - kind: "copyFile" as const, - from: sourceFile, - to: `${options.packagePath}/${sourceFile}`, - })), - ]; -} - -function completePlanCapabilityFlags( - flags: Partial>, -): PresetProjectionPlan["capabilities"] { - const missing = requiredPlanCapabilityProviders.filter( - (requirement) => flags[requirement.flag] !== true, - ); - - if (missing.length > 0) { - throw new Error( - `Projection Capability composition did not provide required plan capabilities: ${missing - .map((requirement) => requirement.flag) - .join(", ")}`, - ); - } - - return { - rootCheck: true, - fixCommand: true, - githubActions: true, - dependabot: true, - devcontainer: true, - }; -} - -function packageScriptsByWorkspacePath( - state: ProjectionCompositionState, -): ReadonlyMap> { - if (state.package === undefined && state.rustWorkspace === undefined) { - return new Map(); - } - - if (state.rustWorkspace !== undefined) { - return new Map([ - [ - state.rustWorkspace.workspacePackageGlob, - projectPackageScripts( - { - components: state.packageCheckComponents, - environmentNeeds: [], - }, - { - components: state.packageFixComponents, - }, - state.packageScriptFragments, - ), - ], - ]); - } - - return new Map([ - [ - state.package!.workspacePackageGlob, - projectPackageScripts( - { - components: state.packageCheckComponents, - environmentNeeds: [], - }, - { - components: state.packageFixComponents, - }, - state.packageScriptFragments, - ), - ], - ]); -} - -function workspaceBoundaryForState( - state: ProjectionCompositionState, -): ComponentOwner { - return workspaceGlobBoundary( - state.nodeWorkspace?.workspacePackageGlob ?? - state.package?.workspacePackageGlob ?? - state.rustWorkspace?.workspacePackageGlob ?? - "packages/*", - ); -} - -function workspaceGlobBoundary( - workspacePackageGlob: "apps/*" | "packages/*", -): ComponentOwner { - return { - kind: "package-boundary", - path: workspacePackageGlob, - }; -} - -function hasVuePackage( - capability: WorkspaceNodePackagesCapabilityDeclaration, -): boolean { - return findVuePackage(capability) !== undefined; -} - -function hasVikePackage( - capability: WorkspaceNodePackagesCapabilityDeclaration, -): boolean { - return capability.packages.some( - (nodePackage) => nodePackage.kind === "vike-app", - ); -} - -function findVuePackage( - capability: WorkspaceNodePackagesCapabilityDeclaration, -): WorkspaceNodePackageDeclaration | undefined { - return capability.packages.find( - (nodePackage) => - nodePackage.kind === "vue-app" || nodePackage.kind === "vike-app", - ); -} - -function sourceRootPreset( - capability: WorkspaceNodePackagesCapabilityDeclaration, -): "hono-api" | "vike-app" | "vue-app" | "vue-hono-app" { - if (capability.packages.length === 1) { - const nodePackage = capability.packages[0]; - if (nodePackage === undefined) { - throw new Error("Node package workspace must contain one package"); - } - - return nodePackage.kind; - } - - return "vue-hono-app"; -} - -function projectRootPackageScripts( - checkPlan: CheckPlan, - fixPlan: FixPlan, - fragments: Record, -): Record { - const leafFragments = leafScriptFragments(fragments); - const directFragments = directScriptFragments(fragments); - - return { - check: `pnpm run check:boundaries && ${renderRootCheckCommand(checkPlan)}`, - ...(checkPlan.deploymentChecks === undefined - ? {} - : { "check:deployment": renderDeploymentCheckCommand(checkPlan) }), - fix: renderFixCommand(fixPlan), - ...rootTaskEntrypoints(checkPlan, fixPlan), - ...directFragments, - ...leafFragments, - "check:boundaries": "turbo boundaries --no-color", - "check:run": noopTaskCommand(), - "fix:run": noopTaskCommand(), - }; -} - -function projectPackageScripts( - checkPlan: CheckPlan, - fixPlan: FixPlan, - fragments: Record, -): Record { - void checkPlan; - void fixPlan; - - return { - ...directScriptFragments(fragments), - ...leafScriptFragments(fragments), - }; -} - -const leafEntrypointNames = new Set([ - "format:check", - "format:write", - "lint", - "lint:fix", - "typecheck", - "build", - "test", - "test:e2e", -]); - -function leafScriptName(scriptName: string): string { - return leafEntrypointNames.has(scriptName) ? `${scriptName}:run` : scriptName; -} - -function leafScriptFragments( - fragments: Record, -): Record { - return Object.fromEntries( - Object.entries(fragments) - .filter(([scriptName]) => leafEntrypointNames.has(scriptName)) - .map(([scriptName, command]) => [leafScriptName(scriptName), command]), - ); -} - -function directScriptFragments( - fragments: Record, -): Record { - return Object.fromEntries( - Object.entries(fragments).filter( - ([scriptName]) => !leafEntrypointNames.has(scriptName), - ), - ); -} - -function rootTaskEntrypoints( - checkPlan: CheckPlan, - fixPlan: FixPlan, -): Record { - const taskNames = new Set([ - ...checkPlan.components.map((component) => - renderCheckLeafCommand(component).startsWith("turbo run ") - ? undefined - : leafEntrypointFromRunTask(component.kind), - ), - ...fixPlan.components.map((component) => - renderFixLeafCommand(component).startsWith("turbo run ") - ? undefined - : leafEntrypointFromRunTask(component.kind), - ), - ]); - const entries: [string, string][] = []; - - for (const taskName of taskNames) { - if (taskName === undefined) { - continue; - } - - entries.push([taskName, renderTurboRunCommand([`${taskName}:run`])]); - } - - return Object.fromEntries(entries); -} - -function leafEntrypointFromRunTask( - kind: - | CheckPlan["components"][number]["kind"] - | FixPlan["components"][number]["kind"], -): string | undefined { - switch (kind) { - case "oxc-format-check": - case "rustfmt-check": - return "format:check"; - case "oxc-format-write": - case "rustfmt-write": - return "format:write"; - case "oxc-lint": - case "cargo-clippy": - return "lint"; - case "oxc-lint-fix": - return "lint:fix"; - case "typescript-typecheck": - return "typecheck"; - case "build": - return "build"; - case "unit-test": - case "cargo-test": - return "test"; - case "e2e-test": - return "test:e2e"; - case "turbo-check": - case "turbo-package-typecheck": - case "turbo-package-build": - case "turbo-package-test": - case "turbo-package-e2e-test": - case "turbo-package-check": - case "turbo-fix": - case "turbo-package-fix": - return undefined; - } -} - -function noopTaskCommand(): string { - return 'node -e ""'; -} - -function packageCollection( - workspacePackageGlob: "apps/*" | "packages/*", -): string { - return workspacePackageGlob.slice(0, -"/*".length); -} - -function workspacePackagePath( - context: GenerationContext, - capability: WorkspaceLibraryPackageCapabilityDeclaration, -): string { - return `${packageCollection(capability.workspacePackageGlob)}/${context.projectName.value}`; -} - -function workspacePackageName( - context: GenerationContext, - capability: WorkspaceLibraryPackageCapabilityDeclaration, -): string { - const packageDefinition = context.blueprint.packages?.find( - (pkg) => pkg.path === workspacePackagePath(context, capability), - ); - - return ( - packageDefinition?.name ?? - `@${context.projectName.value}/${context.projectName.value}` - ); -} - -function cargoPackageNameFromProjectName(projectName: string): string { - const slug = projectName - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - return slug || "rust-bin"; -} - -function rustProjectName(context: GenerationContext): string { - return cargoPackageNameFromProjectName(context.projectName.value); -} - -function rustWorkspacePackagePath(context: GenerationContext): string { - return `packages/${rustProjectName(context)}`; -} - -function rustWorkspacePackageName(context: GenerationContext): string { - return `${rustProjectName(context)}-native`; -} - -function packageLinkPlanFor( - context: GenerationContext, - capability: WorkspaceLibraryPackageCapabilityDeclaration, -) { - return planPackageLinks([ - { - name: workspacePackageName(context, capability), - path: workspacePackagePath(context, capability), - role: capability.packageRole, - sourcePreset: capability.packageSourcePreset, - }, - ]); -} - -function nodePackageDefinitions( - context: GenerationContext, - capability: WorkspaceNodePackagesCapabilityDeclaration, -) { - const nodeDefinitions = capability.packages - .filter((nodePackage) => nodePackage.kind === "hono-api") - .map((nodePackage) => ({ - name: nodePackageName(context, nodePackage), - path: nodePackage.path, - role: "runtime-service" as const, - sourcePreset: "hono-api" as const, - })); - - if (hasVikePackage(capability)) { - return [ - ...nodeDefinitions, - { - name: vikeDbPackageName(context), - path: vikeDbPackagePath(), - role: "shared-library" as const, - sourcePreset: "ts-lib" as const, - }, - ]; - } - - return nodeDefinitions; -} - -function nodePackageName( - context: GenerationContext, - nodePackage: WorkspaceNodePackageDeclaration, -): string { - const packageDefinition = context.blueprint.packages?.find( - (pkg) => pkg.path === nodePackage.path, - ); - - if (packageDefinition !== undefined) { - return packageDefinition.name; - } - - const leaf = nodePackage.path.split("/").at(-1) ?? nodePackage.path; - return `@${context.projectName.value}/${leaf}`; -} - -function nodePackageLinkPlan( - context: GenerationContext, - capability: WorkspaceNodePackagesCapabilityDeclaration, -) { - return planPackageLinks( - nodePackageDefinitions(context, capability), - capability.packageLinks ?? [], - ); -} - -function vikeDbPackagePath(): "packages/db" { - return "packages/db"; -} - -function vikeDbPackageName(context: GenerationContext): string { - const packageDefinition = context.blueprint.packages?.find( - (pkg) => pkg.path === vikeDbPackagePath(), - ); - - return packageDefinition?.name ?? `@${context.projectName.value}/db`; -} - -function vikeDbMigrationsPackagePath(): "packages/db-migrations" { - return "packages/db-migrations"; -} - -function vikeDbMigrationsPackageName(context: GenerationContext): string { - const packageDefinition = context.blueprint.packages?.find( - (pkg) => pkg.path === vikeDbMigrationsPackagePath(), - ); - - return ( - packageDefinition?.name ?? `@${context.projectName.value}/db-migrations` - ); -} - -function rootPackageJson( - context: GenerationContext, - packageScripts: Record, - state: ProjectionCompositionState, -): Record { - return { - name: context.projectName.value, - version: "0.0.0", - private: true, - type: "module", - scripts: packageScripts, - devDependencies: catalogDependencies(state.rootDevDependencies), - engines: { - node: context.toolchain.nodeLtsMajor.value, - }, - packageManager: context.toolchain.packageManagerPin.value, - }; -} - -function libraryPackageJson( - context: GenerationContext, - state: ProjectionCompositionState, - capability: WorkspaceLibraryPackageCapabilityDeclaration, - scripts: Record, -): Record { - const packagePath = workspacePackagePath(context, capability); - const packageExposure = packageLinkPlanFor( - context, - capability, - ).exposuresByPackagePath.get(packagePath); - - if (packageExposure === undefined) { - throw new Error(`Missing Package Exposure for ${packagePath}`); - } - - const exposureFields = packageManifestExposureFields(packageExposure); - - return { - name: workspacePackageName(context, capability), - version: "0.0.0", - private: true, - type: "module", - imports: exposureFields.imports, - exports: exposureFields.exports, - dependencies: catalogDependencies(state.packageDependencies), - scripts, - devDependencies: catalogDependencies(state.packageDevDependencies), - engines: { - node: context.toolchain.nodeLtsMajor.value, - }, - }; -} - -function honoApiPackageJson( - context: GenerationContext, - packageName: string, - scripts: Record, - exposureFields: ReturnType | undefined, -): Record { - return { - name: packageName, - version: "0.0.0", - private: true, - type: "module", - ...(exposureFields === undefined - ? { - imports: { - "#/*": { - default: "./dist/*.js", - types: "./src/*.ts", - }, - }, - } - : { - types: exposureFields.types, - exports: exposureFields.exports, - imports: exposureFields.imports, - }), - scripts, - dependencies: { - "@hono/node-server": "catalog:", - hono: "catalog:", - }, - devDependencies: { - "@types/node": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - "tsc-alias": "catalog:", - "typescript-7": "catalog:", - vitest: "catalog:", - }, - engines: { - node: context.toolchain.nodeLtsMajor.value, - }, - }; -} - -function vuePackageJson( - context: GenerationContext, - packageName: string, - scripts: Record, - packageLinkDependencies: Readonly> = {}, -): Record { - return { - name: packageName, - version: "0.0.0", - private: true, - type: "module", - imports: { - "#/*": { - default: "./src/*.ts", - types: "./src/*.ts", - }, - }, - scripts, - dependencies: { - ...packageLinkDependencies, - ...(Object.keys(packageLinkDependencies).length === 0 - ? {} - : { hono: "catalog:" }), - pinia: "catalog:", - vue: "catalog:", - }, - devDependencies: { - "@playwright/test": "catalog:", - "@tailwindcss/vite": "catalog:", - "@types/node": "catalog:", - "@types/web-bluetooth": "catalog:", - "@vitejs/plugin-vue": "catalog:", - "@vue/tsconfig": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - tailwindcss: "catalog:", - typescript: "catalog:", - vite: "catalog:", - vitest: "catalog:", - "vue-tsc": "catalog:", - }, - engines: { - node: context.toolchain.nodeLtsMajor.value, - }, - }; -} - -function vikePackageJson( - context: GenerationContext, - packageName: string, - scripts: Record, - packageLinkDependencies: Readonly> = {}, -): Record { - return { - name: packageName, - version: "0.0.0", - private: true, - type: "module", - imports: { - "#/*": { - default: "./*.ts", - types: "./*.ts", - }, - }, - files: ["dist"], - scripts, - dependencies: { - "@vikejs/hono": "catalog:", - "drizzle-orm": "catalog:", - hono: "catalog:", - srvx: "catalog:", - telefunc: "catalog:", - vike: "catalog:", - "vike-vue": "catalog:", - vue: "catalog:", - }, - devDependencies: { - ...packageLinkDependencies, - "@playwright/test": "catalog:", - "@tailwindcss/vite": "catalog:", - "@types/node": "catalog:", - "@vitejs/plugin-vue": "catalog:", - "@vue/tsconfig": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - tailwindcss: "catalog:", - turbo: "catalog:", - typescript: "catalog:", - vite: "catalog:", - vitest: "catalog:", - "vue-tsc": "catalog:", - }, - engines: { - node: context.toolchain.nodeLtsMajor.value, - }, - packageManager: context.toolchain.packageManagerPin.value, - }; -} - -function vikeDbPackageJson( - context: GenerationContext, -): Record { - return { - name: vikeDbPackageName(context), - version: "0.0.0", - private: true, - type: "module", - imports: { - "#db/*": { - default: "./src/*.ts", - types: "./src/*.ts", - }, - }, - exports: { - ".": { - default: "./src/index.ts", - types: "./src/index.ts", - }, - "./schema": { - default: "./src/schema.ts", - types: "./src/schema.ts", - }, - "./queries/todos": { - default: "./src/queries/todos.ts", - types: "./src/queries/todos.ts", - }, - "./readiness": { - default: "./src/readiness.ts", - types: "./src/readiness.ts", - }, - }, - dependencies: { - "drizzle-orm": "catalog:", - }, - scripts: vikeDbPackageScripts(), - devDependencies: { - "@types/node": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - "typescript-7": "catalog:", - vitest: "catalog:", - }, - engines: { - node: context.toolchain.nodeLtsMajor.value, - }, - }; -} - -function vikeDbPackageScripts(): Record { - return { - "build:run": "tsc -p tsconfig.json --noEmit", - "db:seed:example": "node scripts/seed-example.ts", - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:run": - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "lint:fix:run": - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - "test:run": - 'DATABASE_FILE="$(pwd)/node_modules/.tmp/test.sqlite" pnpm --dir ../db-migrations run db:prepare:test && DATABASE_FILE="$(pwd)/node_modules/.tmp/test.sqlite" vitest run --reporter=agent --silent=passed-only; status=$?; rm -f ./node_modules/.tmp/test.sqlite; exit $status', - "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", - }; -} - -function vikeDbMigrationsPackageJson( - context: GenerationContext, -): Record { - return { - name: vikeDbMigrationsPackageName(context), - version: "0.0.0", - private: true, - type: "module", - files: ["drizzle.config.ts", "drizzle/migrations"], - dependencies: { - "drizzle-kit": "catalog:", - "drizzle-orm": "catalog:", - }, - scripts: { - "build:run": "tsc -p tsconfig.json --noEmit", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:prepare:deploy": "pnpm run db:migrate", - "db:prepare:dev": "node scripts/prepare-database.ts dev", - "db:prepare:test": "node scripts/prepare-database.ts test", - "db:push": "mkdir -p data node_modules/.tmp && drizzle-kit push", - "db:studio": "drizzle-kit studio", - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:run": - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "lint:fix:run": - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", - }, - devDependencies: { - [vikeDbPackageName(context)]: "workspace:*", - "@types/node": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "oxlint-tsgolint": "catalog:", - "typescript-7": "catalog:", - }, - engines: { - node: context.toolchain.nodeLtsMajor.value, - }, - }; -} - -function catalogDependencies( - dependencies: ReadonlySet, -): Record { - return Object.fromEntries( - [...dependencies].toSorted().map((dependency) => [dependency, "catalog:"]), - ); -} - -function generationRecord(context: GenerationContext): Record { - return { - packageName: "@ykdz/template", - version: "0.0.0", - command: `template init --preset ${context.blueprint.preset}`, - toolchain: { - nodeLtsMajor: context.toolchain.nodeLtsMajor.value, - packageManagerPin: context.toolchain.packageManagerPin.value, - source: context.toolchain.source, - }, - }; -} - -function rustCargoToml( - projectName: string, - dependencies: readonly string[] = [], -): string { - return [ - "[package]", - `name = "${projectName}"`, - 'version = "0.1.0"', - 'edition = "2024"', - "", - "[dependencies]", - ...renderCargoDependencyTomlEntries(dependencies), - "", - "[lints]", - "workspace = true", - "", - "[workspace]", - 'members = ["."]', - 'resolver = "3"', - "", - "[workspace.lints.rust]", - 'unsafe_code = "forbid"', - "", - "[workspace.lints.clippy]", - 'all = "deny"', - 'pedantic = "deny"', - 'nursery = "deny"', - "", - "[profile.release]", - 'strip = "symbols"', - 'lto = "thin"', - "codegen-units = 1", - "", - ].join("\n"); -} - -function rustCargoLock(projectName: string): string { - return renderCargoLockForPackage({ - packageName: projectName, - packageVersion: "0.1.0", - }); -} - -function rustRootPackageJson( - context: GenerationContext, - packageScripts: Record, - state: ProjectionCompositionState, -): Record { - return { - name: rustProjectName(context), - version: "0.0.0", - private: true, - scripts: packageScripts, - devDependencies: catalogDependencies(state.rootDevDependencies), - engines: { - node: context.toolchain.nodeLtsMajor.value, - }, - packageManager: context.toolchain.packageManagerPin.value, - }; -} - -function rustWorkspacePackageJson( - context: GenerationContext, -): Record { - return { - name: rustWorkspacePackageName(context), - version: "0.0.0", - private: true, - scripts: rustWorkspacePackageScripts(), - engines: { - node: context.toolchain.nodeLtsMajor.value, - }, - }; -} - -function rustWorkspacePackageScripts(): Record { - return { - "format:check:run": "cargo fmt --all -- --check", - "format:write:run": "cargo fmt --all", - "lint:run": "cargo clippy --workspace --all-targets -- -D warnings", - "test:run": "cargo test --workspace", - }; -} - -function workspaceLibraryPackageOperations({ - context, - state, - packageScripts, - packageScriptsByPath, -}: { - readonly context: GenerationContext; - readonly state: ProjectionCompositionState; - readonly packageScripts: Record; - readonly packageScriptsByPath: ReadonlyMap>; -}): RenderOperation[] { - if (state.package === undefined) { - throw new Error("workspace-library-package capability state is missing"); - } - - const capability = state.package; - const packagePath = workspacePackagePath(context, capability); - const workspacePackageScripts = packageScriptsByPath.get( - capability.workspacePackageGlob, - ); - - if (workspacePackageScripts === undefined) { - throw new Error( - `Missing package scripts for ${capability.workspacePackageGlob}`, - ); - } - - const rootManifest = rootPackageJson(context, packageScripts, state); - const packageManifest = libraryPackageJson( - context, - state, - capability, - workspacePackageScripts, - ); - - return [ - { - kind: "writeJson", - to: "package.json", - value: rootManifest, - }, - ...(state.sourceRoots[sharedPnpmPeerPolicySourceRootKey] === undefined - ? [] - : [ - { - kind: "copyFile" as const, - from: ".pnpmfile.cts", - to: ".pnpmfile.cts", - sourceRoot: sharedPnpmPeerPolicySourceRootKey, - }, - ]), - { - kind: "writeText", - to: "pnpm-workspace.yaml", - text: renderGeneratedPnpmWorkspaceYaml({ - ...(state.sourceRoots[sharedPnpmPeerPolicySourceRootKey] === undefined - ? {} - : { pnpmfile: ".pnpmfile.cts" }), - packages: [capability.workspacePackageGlob], - dependencies: collectGeneratedManifestCatalogDependencies([ - rootManifest, - packageManifest, - ]), - }), - }, - { - kind: "writeJson", - to: "turbo.json", - value: packageTurboConfig({ dependencyBuildsRequired: false }), - }, - { - kind: "writeJson", - to: `${packagePath}/package.json`, - value: packageManifest, - multilineArrays: ["files"], - }, - { - kind: "writeText", - to: ".gitignore", - text: [ - "node_modules", - "dist", - ".env", - ".template/", - ".pnpm-store/", - "", - ].join("\n"), - }, - ...capability.sourceFiles.map((sourceFile) => ({ - kind: "copyFile" as const, - from: sourceFile, - to: `${packagePath}/${sourceFile}`, - })), - { - kind: "writeJson", - to: ".template/blueprint.json", - value: context.blueprint, - }, - { - kind: "writeJson", - to: ".template/generated-by.json", - value: generationRecord(context), - }, - ]; -} - -function rustBinaryWorkspaceOperations({ - context, - state, - packageScripts, -}: { - readonly context: GenerationContext; - readonly state: ProjectionCompositionState; - readonly packageScripts: Record; - readonly packageScriptsByPath: ReadonlyMap>; -}): RenderOperation[] { - if (state.rustWorkspace === undefined) { - throw new Error("rust-binary-workspace capability state is missing"); - } - - const capability = state.rustWorkspace; - const packagePath = rustWorkspacePackagePath(context); - - const rootManifest = rustRootPackageJson(context, packageScripts, state); - const packageManifest = rustWorkspacePackageJson(context); - const rustLayer = rustToolLayer(); - const dockerfileFragments = readDevelopmentContainerDockerfileFragments( - state, - ["rust"], - ); - const editorCustomization = editorCustomizationForCapabilities( - state.editorCustomizationCapabilities, - editorCustomizationDeclarationsForState(state), - ); - const developmentContainer = dockerfileFirstRustPnpmDevcontainer({ - name: context.projectName.value, - nodeLayer: nodePnpmToolLayer({ - nodeVersion: context.toolchain.nodeLtsMajor.value, - packageManagerPin: context.toolchain.packageManagerPin.value, - }), - rustLayer, - dockerfileFragments, - extensions: editorCustomization.extensions, - settings: editorCustomization.settings, - }); - - return [ - { - kind: "writeJson", - to: "package.json", - value: rootManifest, - }, - { - kind: "writeText", - to: "pnpm-workspace.yaml", - text: renderGeneratedPnpmWorkspaceYaml({ - packages: [capability.workspacePackageGlob], - dependencies: collectGeneratedManifestCatalogDependencies([ - rootManifest, - packageManifest, - ]), - }), - }, - { - kind: "writeJson", - to: "turbo.json", - value: packageTurboConfig({ dependencyBuildsRequired: false }), - }, - { - kind: "writeJson", - to: `${packagePath}/package.json`, - value: packageManifest, - }, - { - kind: "writeText", - to: `${packagePath}/Cargo.toml`, - text: rustCargoToml( - rustProjectName(context), - capability.cargoDependencies ?? [], - ), - }, - { - kind: "writeText", - to: `${packagePath}/Cargo.lock`, - text: rustCargoLock(rustProjectName(context)), - }, - { - kind: "copyFile", - from: "rustfmt.toml", - to: `${packagePath}/rustfmt.toml`, - }, - { - kind: "writeTextTemplate", - from: "rust-toolchain.toml", - to: "rust-toolchain.toml", - replacements: { - RUST_TOOLCHAIN: rustLayer.toolchain, - }, - }, - { - kind: "writeText", - to: ".gitignore", - text: [ - "target", - "node_modules", - "dist", - ".env", - ".template/", - ".pnpm-store/", - "", - ].join("\n"), - }, - ...capability.sourceFiles.map((sourceFile) => ({ - kind: "copyFile" as const, - from: sourceFile, - to: `${packagePath}/${sourceFile}`, - })), - { - kind: "writeJson", - to: ".template/blueprint.json", - value: context.blueprint, - }, - { - kind: "writeJson", - to: ".template/generated-by.json", - value: generationRecord(context), - }, - { - kind: "writeJson", - to: ".devcontainer/devcontainer.json", - value: developmentContainer.devcontainer, - multilineArrays: ["customizations.vscode.extensions"], - }, - { - ...developmentContainer.dockerfileOperation!, - }, - { - kind: "writeJson", - to: ".vscode/extensions.json", - value: { - recommendations: editorCustomization.extensions, - }, - }, - { - kind: "writeJson", - to: ".vscode/settings.json", - value: editorCustomization.settings, - }, - { - kind: "copyFile", - from: ".github/workflows/check.yml", - to: ".github/workflows/check.yml", - }, - { - kind: "writeTextTemplate", - from: ".github/dependabot.yml", - to: ".github/dependabot.yml", - replacements: { - CARGO_PACKAGE_DIRECTORY: `/${packagePath}`, - }, - }, - ]; -} - -function workspaceNodePackagesOperations({ - context, - state, - packageScripts, -}: { - readonly context: GenerationContext; - readonly state: ProjectionCompositionState; - readonly packageScripts: Record; -}): RenderOperation[] { - if (state.nodeWorkspace === undefined) { - throw new Error("workspace-node-packages capability state is missing"); - } - - const capability = state.nodeWorkspace; - const packageLinkPlan = nodePackageLinkPlan(context, capability); - const nodePackageManifests = capability.packages.map((nodePackage) => { - const scripts = nodePackageScripts(nodePackage, capability); - const packageName = nodePackageName(context, nodePackage); - - if (nodePackage.kind === "hono-api") { - const exposure = packageLinkPlan.exposuresByPackagePath.get( - nodePackage.path, - ); - return honoApiPackageJson( - context, - packageName, - scripts, - exposure === undefined - ? undefined - : packageManifestExposureFields(exposure), - ); - } - - if (nodePackage.kind === "vike-app") { - return vikePackageJson( - context, - packageName, - scripts, - packageLinkPlan.manifestDependenciesByPackagePath.get(nodePackage.path), - ); - } - - return vuePackageJson( - context, - packageName, - scripts, - packageLinkPlan.manifestDependenciesByPackagePath.get(nodePackage.path), - ); - }); - const vikeDbManifest = hasVikePackage(capability) - ? vikeDbPackageJson(context) - : undefined; - const vikeDbMigrationsManifest = hasVikePackage(capability) - ? vikeDbMigrationsPackageJson(context) - : undefined; - const packageManifests = - vikeDbManifest === undefined || vikeDbMigrationsManifest === undefined - ? nodePackageManifests - : [...nodePackageManifests, vikeDbManifest, vikeDbMigrationsManifest]; - const rootManifest = rootPackageJson(context, packageScripts, state); - - if (rootManifest === undefined) { - throw new Error("workspace-node-packages rendered no package manifests"); - } - - return [ - { - kind: "writeJson", - to: "package.json", - value: rootManifest, - }, - ...(state.sourceRoots[sharedPnpmPeerPolicySourceRootKey] === undefined - ? [] - : [ - { - kind: "copyFile" as const, - from: ".pnpmfile.cts", - to: ".pnpmfile.cts", - sourceRoot: sharedPnpmPeerPolicySourceRootKey, - }, - ]), - { - kind: "writeText", - to: "pnpm-workspace.yaml", - text: renderGeneratedPnpmWorkspaceYaml({ - packages: workspacePackageGlobsForNodeWorkspace(capability), - ...(state.sourceRoots[sharedPnpmPeerPolicySourceRootKey] === undefined - ? {} - : { pnpmfile: ".pnpmfile.cts" }), - dependencies: - capability.packageLinks === undefined - ? collectGeneratedManifestCatalogDependencies([ - rootManifest, - ...packageManifests, - ]) - : collectGeneratedManifestCatalogReferences([ - rootManifest, - ...packageManifests, - ]), - ...(hasVuePackage(capability) - ? { - allowBuilds: { - esbuild: true, - }, - } - : {}), - ...(hasVikePackage(capability) - ? { overrides: { "vue>typescript": "-" } } - : {}), - }), - }, - { - kind: "writeJson", - to: "turbo.json", - value: { - tasks: { - ...packageLinkPlan.turboTasks, - ...(hasVuePackage(capability) - ? { - dev: { - cache: false, - persistent: true, - }, - } - : {}), - }, - boundaries: packageTurboConfig({ - dependencyBuildsRequired: false, - }).boundaries, - }, - }, - { - kind: "writeText", - to: ".gitignore", - text: gitignoreForNodeWorkspace(capability), - }, - ...capability.packages.flatMap((nodePackage, index) => [ - { - kind: "writeJson" as const, - to: `${nodePackage.path}/package.json`, - value: nodePackageManifests[index], - }, - ...nodePackageTsconfigOperations(nodePackage, capability), - ...nodePackage.sourceFiles.flatMap((sourceFile) => - vikeTemplateSourceOperation({ - context, - nodePackage, - sourceFile, - workspace: capability, - }), - ), - ]), - ...capability.packages - .filter((nodePackage) => nodePackage.kind !== "hono-api") - .map((nodePackage) => ({ - kind: "copyFile" as const, - from: "run-vue-tsc.ts", - to: `${nodePackage.path}/scripts/run-vue-tsc.ts`, - sourceRoot: sharedTypeScriptSourceRootKey, - })), - ...(vikeDbManifest === undefined ? [] : vikeDbPackageOperations(context)), - { - kind: "writeJson", - to: ".template/blueprint.json", - value: context.blueprint, - }, - { - kind: "writeJson", - to: ".template/generated-by.json", - value: generationRecord(context), - }, - ]; -} - -function nodePackageScripts( - nodePackage: WorkspaceNodePackageDeclaration, - workspace: WorkspaceNodePackagesCapabilityDeclaration, -): Record { - const oxcScripts = { - "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", - lint: "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - }; - - if (nodePackage.kind === "hono-api") { - return { - "build:run": - "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", - ...(workspace.packages.length > 1 - ? { dev: "node --watch src/server.ts" } - : {}), - ...leafScriptFragments(oxcScripts), - start: "node dist/server.js", - "test:run": "vitest run --reporter=agent --silent=passed-only", - "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", - }; - } - - if (nodePackage.kind === "vike-app") { - const dbCommand = - "DATABASE_FILE=../../apps/web/data/app.sqlite pnpm --dir ../../packages/db-migrations run db:prepare:dev"; - return { - "build:run": "vike build", - "check:deployment": "node scripts/check-standalone-deployment.ts", - dev: `${dbCommand} && vike dev`, - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:run": - "shellcheck scripts/container-entrypoint.sh && oxlint --quiet --format=unix --type-aware --config ../../oxlint.config.ts .", - "lint:fix:run": - "oxlint --type-aware --format=unix --config ../../oxlint.config.ts . --fix", - preview: `${dbCommand} && vike preview`, - start: "node ./dist/server/index.mjs", - "test:run": - "vitest run --reporter=agent --silent=passed-only --passWithNoTests", - "test:e2e:run": "node scripts/run-playwright.ts", - "typecheck:run": - "node scripts/run-vue-tsc.ts --build --noEmit --pretty false", - }; - } - - return { - "build:run": "vite build", - dev: "vite", - ...leafScriptFragments(oxcScripts), - preview: "vite preview", - "test:run": "vitest run --reporter=agent --silent=passed-only", - "test:e2e:run": "node scripts/run-playwright.ts", - "typecheck:run": `node scripts/run-vue-tsc.ts --build ${ - workspace.packages.length > 1 ? "" : "--noEmit " - }--pretty false`, - }; -} - -function nodePackageTsconfigOperations( - nodePackage: WorkspaceNodePackageDeclaration, - workspace: WorkspaceNodePackagesCapabilityDeclaration, -): WriteJsonRenderOperation[] { - if (nodePackage.kind === "vike-app") { - return [ - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.json`, - value: { - files: [], - references: [ - { path: "../../packages/db" }, - { path: "./tsconfig.app.json" }, - { path: "./tsconfig.test.json" }, - { path: "./tsconfig.node.json" }, - ], - }, - }, - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.app.json`, - value: { - extends: "@vue/tsconfig/tsconfig.dom.json", - compilerOptions: { - ...strictTypeScriptCompilerOptions({ - composite: true, - module: "esnext", - moduleResolution: "bundler", - paths: { "#/*": ["./*"] }, - rewriteRelativeImportExtensions: false, - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - types: ["node"], - }), - skipLibCheck: true, - }, - include: [ - "+server.ts", - "assets/**/*.svg", - "components/**/*.vue", - "pages/**/*.ts", - "pages/**/*.vue", - "server/**/*.ts", - "types/**/*.d.ts", - ], - }, - }, - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.test.json`, - value: { - extends: "./tsconfig.app.json", - compilerOptions: { - lib: ["ESNext", "DOM", "DOM.Iterable"], - paths: { "#/*": ["./*"] }, - skipLibCheck: true, - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.test.tsbuildinfo", - types: ["node", "vitest/globals"], - }, - include: ["test/**/*.ts", "vitest.config.ts"], - }, - }, - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.node.json`, - multilineArrays: ["include"], - value: { - compilerOptions: { - ...strictTypeScriptCompilerOptions({ - composite: true, - module: "esnext", - moduleResolution: "bundler", - lib: ["ESNext", "DOM", "DOM.Iterable"], - paths: { "#/*": ["./*"] }, - rewriteRelativeImportExtensions: false, - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - types: ["node"], - }), - skipLibCheck: true, - }, - include: [ - "playwright.config.ts", - "scripts/**/*.ts", - "vite.config.ts", - "vitest.config.ts", - ], - }, - }, - ]; - } - - if (nodePackage.kind === "hono-api") { - return [ - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.json`, - value: { - compilerOptions: strictTypeScriptCompilerOptions({ - composite: true, - ...(workspace.packages.length > 1 - ? { declaration: true, declarationMap: true } - : {}), - module: "nodenext", - moduleResolution: "nodenext", - types: ["node", "vitest/globals"], - }), - include: ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"], - }, - }, - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.build.json`, - value: { - extends: "./tsconfig.json", - compilerOptions: { - outDir: "dist", - rootDir: "src", - types: ["node"], - }, - include: ["src/**/*.ts"], - }, - }, - ]; - } - - return [ - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.json`, - value: { - files: [], - references: [ - { path: "./tsconfig.app.json" }, - { path: "./tsconfig.test.json" }, - { path: "./tsconfig.node.json" }, - ], - }, - }, - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.app.json`, - value: { - extends: "@vue/tsconfig/tsconfig.dom.json", - compilerOptions: strictTypeScriptCompilerOptions({ - composite: true, - module: "esnext", - moduleResolution: "bundler", - rewriteRelativeImportExtensions: false, - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - types: ["web-bluetooth"], - }), - include: ["env.d.ts", "src/**/*.ts", "src/**/*.vue"], - }, - }, - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.test.json`, - value: { - extends: "./tsconfig.app.json", - compilerOptions: { - lib: ["ESNext", "DOM", "DOM.Iterable"], - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.test.tsbuildinfo", - types: ["node", "vitest/globals", "web-bluetooth"], - }, - include: ["env.d.ts", "src/**/*.ts", "src/**/*.vue", "test/**/*.ts"], - }, - }, - { - kind: "writeJson", - to: `${nodePackage.path}/tsconfig.node.json`, - multilineArrays: ["include"], - value: { - compilerOptions: strictTypeScriptCompilerOptions({ - composite: true, - module: "esnext", - moduleResolution: "bundler", - lib: ["ESNext", "DOM", "DOM.Iterable"], - rewriteRelativeImportExtensions: false, - ...(workspace.packages.length > 1 - ? { outDir: "./node_modules/.tmp/tsconfig.node" } - : {}), - tsBuildInfoFile: "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - types: ["node"], - }), - include: [ - "playwright.config.ts", - "scripts/**/*.ts", - "vite.config.ts", - "vitest.config.ts", - ], - }, - }, - ]; -} - -function honoApiTsconfigOperations(packagePath: string): RenderOperation[] { - const operations = nodePackageTsconfigOperations( - { - kind: "hono-api", - path: "apps/api", - sourceFiles: [], - }, - { - kind: "workspace-node-packages", - workspacePackageGlob: "apps/*", - packages: [ - { - kind: "hono-api", - path: "apps/api", - sourceFiles: [], - }, - ], - }, - ); - - return operations.map((operation) => ({ - ...operation, - to: operation.to.replace(/^apps\/api/, packagePath), - })); -} - -function vueAppTsconfigOperations(packagePath: string): RenderOperation[] { - const operations = nodePackageTsconfigOperations( - { - kind: "vue-app", - path: "apps/web", - sourceFiles: [], - }, - { - kind: "workspace-node-packages", - workspacePackageGlob: "apps/*", - packages: [ - { - kind: "vue-app", - path: "apps/web", - sourceFiles: [], - }, - ], - }, - ); - - return operations.map((operation) => ({ - ...operation, - to: operation.to.replace(/^apps\/web/, packagePath), - })); -} - -function sourcePathWithinNodePackage( - sourceFile: string, - workspace: WorkspaceNodePackagesCapabilityDeclaration, -): string { - if (workspace.packages.length === 1 && !sourceFile.startsWith("web/")) { - return sourceFile; - } - - return sourceFile.replace(/^(api|web)\//, ""); -} - -function nodePackageTargetPath( - nodePackage: WorkspaceNodePackageDeclaration, - sourceFile: string, - workspace: WorkspaceNodePackagesCapabilityDeclaration, -): string { - const packageRelativePath = sourcePathWithinNodePackage( - sourceFile, - workspace, - ); - - return `${nodePackage.path}/${packageRelativePath}`; -} - -function vikeTemplateSourceOperation(options: { - readonly context: GenerationContext; - readonly nodePackage: WorkspaceNodePackageDeclaration; - readonly sourceFile: string; - readonly workspace: WorkspaceNodePackagesCapabilityDeclaration; -}): RenderOperation[] { - const to = nodePackageTargetPath( - options.nodePackage, - options.sourceFile, - options.workspace, - ); - const copyOperation: RenderOperation = { - kind: "copyFile", - from: options.sourceFile, - to, - }; - - if ( - options.nodePackage.kind === "vike-app" && - options.sourceFile === "web/Dockerfile" - ) { - return [ - { - kind: "writeTextTemplate", - from: options.sourceFile, - to, - replacements: { - NODE_VERSION: options.context.toolchain.nodeLtsMajor.value, - PACKAGE_MANAGER_PIN: - options.context.toolchain.packageManagerPin.value, - DB_MIGRATIONS_PACKAGE_NAME: vikeDbMigrationsPackageName( - options.context, - ), - WEB_PACKAGE_NAME: nodePackageName( - options.context, - options.nodePackage, - ), - }, - }, - ]; - } - - if ( - options.nodePackage.kind === "vike-app" && - [ - "web/pages/index/+Page.telefunc.ts", - "web/server/app.ts", - "web/types/global.d.ts", - ].includes(options.sourceFile) - ) { - const dbPackageName = vikeDbPackageName(options.context); - const replacements = { - "web/pages/index/+Page.telefunc.ts": `import { createTodo, listTodos } from "${dbPackageName}/queries/todos";`, - "web/server/app.ts": [ - `import { createDatabase } from "${dbPackageName}";`, - `import { assertDatabaseReady } from "${dbPackageName}/readiness";`, - ].join("\n"), - "web/types/global.d.ts": `import type { Database } from "${dbPackageName}";`, - } as const; - - return [ - copyOperation, - { - kind: "replaceAnchors", - path: to, - language: "typescript", - replacements: { - "db-package-import": - replacements[options.sourceFile as keyof typeof replacements], - }, - }, - ]; - } - - return [copyOperation]; -} - -const vikeDbSourceFiles = [ - "db/turbo.json", - "db/scripts/seed-example.ts", - "db/src/db.ts", - "db/src/index.ts", - "db/src/queries/todos.ts", - "db/src/readiness.ts", - "db/src/seed/example.ts", - "db/src/schema.ts", - "db/test/todos.test.ts", -] as const; - -function vikeDbPackageOperations( - context: GenerationContext, -): RenderOperation[] { - const packagePath = vikeDbPackagePath(); - - return [ - { - kind: "writeJson", - to: `${packagePath}/package.json`, - value: vikeDbPackageJson(context), - }, - { - kind: "writeJson", - to: `${packagePath}/tsconfig.json`, - value: vikeDbTsconfigJson(), - }, - ...vikeDbSourceFiles.map((sourceFile) => ({ - kind: "copyFile" as const, - from: sourceFile, - to: `${packagePath}/${sourceFile.replace(/^db\//, "")}`, - })), - ...vikeDbMigrationsPackageOperations(context), - ]; -} - -const vikeDbMigrationsSourceFiles = [ - "db-migrations/drizzle.config.ts", - "db-migrations/drizzle/migrations/20260709120325_old_captain_flint/migration.sql", - "db-migrations/drizzle/migrations/20260709120325_old_captain_flint/snapshot.json", - "db-migrations/scripts/prepare-database.ts", - "db-migrations/turbo.json", -] as const; - -function vikeDbMigrationsPackageOperations( - context: GenerationContext, -): RenderOperation[] { - const packagePath = vikeDbMigrationsPackagePath(); - - return [ - { - kind: "writeJson", - to: `${packagePath}/package.json`, - value: vikeDbMigrationsPackageJson(context), - }, - { - kind: "writeJson", - to: `${packagePath}/tsconfig.json`, - value: vikeDbMigrationsTsconfigJson(), - }, - ...vikeDbMigrationsSourceFiles.map((sourceFile) => ({ - kind: "copyFile" as const, - from: sourceFile, - to: `${packagePath}/${sourceFile.replace(/^db-migrations\//, "")}`, - })), - ]; -} - -function vikeDbTsconfigJson(): Record { - return { - compilerOptions: { - ...strictTypeScriptCompilerOptions({ - composite: true, - declaration: true, - declarationMap: true, - module: "esnext", - moduleResolution: "bundler", - paths: { "#db/*": ["./src/*"] }, - rootDir: ".", - types: ["node"], - }), - skipLibCheck: true, - }, - include: ["src/**/*.ts", "scripts/**/*.ts", "test/**/*.ts"], - }; -} - -function vikeDbMigrationsTsconfigJson(): Record { - return { - compilerOptions: { - ...strictTypeScriptCompilerOptions({ - module: "esnext", - moduleResolution: "bundler", - rootDir: ".", - types: ["node"], - }), - skipLibCheck: true, - }, - include: ["drizzle.config.ts", "scripts/**/*.ts"], - }; -} - -function workspacePackageGlobsForNodeWorkspace( - workspace: WorkspaceNodePackagesCapabilityDeclaration, -): readonly ("apps/*" | "packages/*")[] { - return hasVikePackage(workspace) - ? [workspace.workspacePackageGlob, "packages/*"] - : [workspace.workspacePackageGlob]; -} - -function gitignoreForNodeWorkspace( - workspace: WorkspaceNodePackagesCapabilityDeclaration, -): string { - return [ - "node_modules", - "dist", - ...(hasVuePackage(workspace) ? ["playwright-report", "test-results"] : []), - ".env", - ".template/", - ".pnpm-store/", - "", - ].join("\n"); -} - -function strictTypescriptOperations({ - context, - state, -}: { - readonly context: GenerationContext; - readonly state: ProjectionCompositionState; -}): RenderOperation[] { - if (state.package === undefined && state.nodeWorkspace === undefined) { - throw new Error( - "strict-typescript-root requires a workspace package layout", - ); - } - - const rootConfigTsconfigOperation = (): RenderOperation => { - if (state.sourceRoots.sharedOxc === undefined) { - throw new Error( - "strict-typescript-root requires shared OXC source when rendering tsconfig.config.json", - ); - } - - return { - kind: "copyFile", - from: "tsconfig.config.json", - to: "tsconfig.config.json", - sourceRoot: "sharedOxc", - }; - }; - - if (state.nodeWorkspace !== undefined) { - return [ - { - kind: "writeJson", - to: "tsconfig.json", - value: { - files: [], - references: rootTsconfigReferences(state.nodeWorkspace), - }, - }, - rootConfigTsconfigOperation(), - ]; - } - - const libraryPackage = state.package!; - - return [ - { - kind: "writeJson", - to: "tsconfig.json", - value: { - files: [], - }, - }, - rootConfigTsconfigOperation(), - { - kind: "writeJson", - to: `${workspacePackagePath(context, libraryPackage)}/tsconfig.json`, - value: libraryTsconfigJson(), - }, - ]; -} - -function rootTsconfigReferences( - workspace: WorkspaceNodePackagesCapabilityDeclaration, -): Array<{ path: string }> { - return workspace.packages.flatMap((nodePackage) => { - if (nodePackage.kind === "hono-api") { - return hasVuePackage(workspace) - ? [] - : [{ path: `./${nodePackage.path}/tsconfig.json` }]; - } - - if (nodePackage.kind === "vike-app") { - return [ - { path: "./packages/db" }, - { path: `./${nodePackage.path}/tsconfig.app.json` }, - { path: `./${nodePackage.path}/tsconfig.test.json` }, - { path: `./${nodePackage.path}/tsconfig.node.json` }, - ]; - } - - return [ - { path: `./${nodePackage.path}/tsconfig.app.json` }, - { path: `./${nodePackage.path}/tsconfig.test.json` }, - { path: `./${nodePackage.path}/tsconfig.node.json` }, - ]; - }); -} - -function oxcFormatLintOperations({ - state, -}: { - readonly state: ProjectionCompositionState; -}): RenderOperation[] { - const editorCustomization = editorCustomizationForCapabilities( - state.editorCustomizationCapabilities, - editorCustomizationDeclarationsForState(state), - ); - - return [ - { - kind: "copyFile", - sourceRoot: "sharedOxc", - from: - state.nodeWorkspace && hasVuePackage(state.nodeWorkspace) - ? "vue/oxlint.config.ts" - : "node/oxlint.config.ts", - to: "oxlint.config.ts", - }, - { - kind: "copyFile", - sourceRoot: "sharedOxc", - from: "oxfmt.config.ts", - to: "oxfmt.config.ts", - }, - { - kind: "writeJson", - to: ".vscode/extensions.json", - value: { - recommendations: editorCustomization.extensions, - }, - multilineArrays: ["recommendations"], - }, - { - kind: "writeJson", - to: ".vscode/settings.json", - value: editorCustomization.settings, - }, - ]; -} - -function nodePnpmDevcontainerOperations({ - context, - state, -}: { - readonly context: GenerationContext; - readonly state: ProjectionCompositionState; -}): RenderOperation[] { - const additionalLayers = [ - ...(state.nodeWorkspace && hasVuePackage(state.nodeWorkspace) - ? [browserTestToolLayer()] - : []), - ...(state.nodeWorkspace && hasVikePackage(state.nodeWorkspace) - ? [shellCheckToolLayer()] - : []), - ]; - const dockerfileFragments = readDevelopmentContainerDockerfileFragments( - state, - [ - ...(additionalLayers.some((layer) => layer.kind === "browser-test") - ? (["browserTest"] as const) - : []), - ...(additionalLayers.some((layer) => layer.kind === "shellcheck") - ? (["shellCheck"] as const) - : []), - ], - ); - const editorCustomization = editorCustomizationForCapabilities( - state.editorCustomizationCapabilities, - editorCustomizationDeclarationsForState(state), - ); - const developmentContainer = checkedDockerfileFirstNodePnpmDevcontainer({ - name: context.projectName.value, - layer: nodePnpmToolLayer({ - nodeVersion: context.toolchain.nodeLtsMajor.value, - packageManagerPin: context.toolchain.packageManagerPin.value, - }), - dockerfileFragments, - additionalLayers, - extensions: editorCustomization.extensions, - settings: editorCustomization.settings, - }); - - return [ - { - kind: "writeJson", - to: ".devcontainer/devcontainer.json", - value: developmentContainer.devcontainer, - multilineArrays: ["customizations.vscode.extensions"], - }, - { - ...developmentContainer.dockerfileOperation!, - }, - ]; -} - -type DevelopmentContainerDockerfileOptionalFragmentName = - | "browserTest" - | "shellCheck" - | "rust"; - -function readDevelopmentContainerDockerfileFragments( - state: ProjectionCompositionState, - optionalFragments: readonly DevelopmentContainerDockerfileOptionalFragmentName[], -): DevelopmentContainerDockerfileFragments { - if (state.devcontainerResource === undefined) { - throw new Error( - "Projection Capability composition did not provide a Development Container Shared Resource", - ); - } - - const fragmentNames = { - nodePnpm: "node-pnpm.Dockerfile", - browserTest: "browser-test.Dockerfile", - shellCheck: "shellcheck.Dockerfile", - rust: "rust.Dockerfile", - } as const; - - const selected = new Set(optionalFragments); - - return { - sourceRoot: state.devcontainerResource.sourceRootKey, - nodePnpm: readDevelopmentContainerDockerfileFragment( - state.devcontainerResource, - fragmentNames.nodePnpm, - ), - ...(selected.has("browserTest") - ? { - browserTest: readDevelopmentContainerDockerfileFragment( - state.devcontainerResource, - fragmentNames.browserTest, - ), - } - : {}), - ...(selected.has("shellCheck") - ? { - shellCheck: readDevelopmentContainerDockerfileFragment( - state.devcontainerResource, - fragmentNames.shellCheck, - ), - } - : {}), - ...(selected.has("rust") - ? { - rust: readDevelopmentContainerDockerfileFragment( - state.devcontainerResource, - fragmentNames.rust, - ), - } - : {}), - }; -} - -function readDevelopmentContainerDockerfileFragment( - resource: NonNullable, - fragmentName: string, -): { readonly from: string; readonly text: string } { - const filePath = path.join(resource.root, fragmentName); - - try { - return { - from: fragmentName, - text: readFileSync(filePath, "utf8"), - }; - } catch (error: unknown) { - if (isMissingDevelopmentContainerFragmentError(error)) { - throw new Error( - `Development Container Shared Resource ${resource.id} is missing Dockerfile fragment: ${fragmentName}`, - { cause: error }, - ); - } - - throw error; - } -} - -function isMissingDevelopmentContainerFragmentError(error: unknown): boolean { - return ( - error instanceof Error && - "code" in error && - (error.code === "ENOENT" || error.code === "ENOTDIR") - ); -} - -function githubMaintenanceOperations(): RenderOperation[] { - return [ - { - kind: "copyFile", - from: ".github/workflows/check.yml", - to: ".github/workflows/check.yml", - }, - { - kind: "copyFile", - from: ".github/dependabot.yml", - to: ".github/dependabot.yml", - }, - ]; -} - -function toolchainMaintenanceOperations(): RenderOperation[] { - return [ - { - kind: "copyFile", - from: "toolchain-baseline-update.yml", - to: ".github/workflows/toolchain-baseline-update.yml", - sourceRoot: sharedToolchainMaintenanceSourceRootKey, - }, - { - kind: "copyFile", - from: "update-toolchain-baseline.ts", - to: "scripts/update-toolchain-baseline.ts", - sourceRoot: sharedToolchainMaintenanceSourceRootKey, - }, - ]; -} - -function toolchainMaintenanceRootTypecheckOperations(): RenderOperation[] { - return [ - { - kind: "copyFile", - from: "tsconfig.config.json", - to: "tsconfig.config.json", - sourceRoot: "sharedOxc", - }, - ]; -} - -function templateSourceRoot( - state: ProjectionCompositionState, - sourcePreset: "hono-api" | "ts-lib" | "vike-app" | "vue-app" | "vue-hono-app", -): string { - return templateSourceRootForPreset(state, sourcePreset); -} - -function templateSourceRootForPreset( - state: ProjectionCompositionState, - sourcePreset: ProjectionSourcePreset, -): string { - return state.projectionSourceRoots.preset(sourcePreset); -} diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 0227e1e..1ba5982 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -1,6 +1,7 @@ import { constants } from "node:fs"; import { chmod, + cp, copyFile, mkdir, mkdtemp, @@ -16,11 +17,56 @@ import path from "node:path"; type RenderVariables = Record; +export type RenderOperationProvenance = { + readonly definitionName: string; + readonly plannerSourceFile: string; + readonly planningContribution: string; + readonly ownershipRule: string; +}; + +/** + * An opaque, owned Template Source reference. A renderer operation carries + * this directly instead of selecting a root from a string-keyed side map. + */ +const templateSourceHandleBrand: unique symbol = Symbol("template-source"); + +export type TemplateSourceHandle = { + readonly [templateSourceHandleBrand]: true; +}; + +const templateSourceRoots = new WeakMap(); + +export function createTemplateSourceHandle(root: string): TemplateSourceHandle { + const handle = Object.freeze({ + [templateSourceHandleBrand]: true as const, + }); + templateSourceRoots.set(handle, path.resolve(root)); + return handle; +} + +/** Resolves a handle-owned source path while enforcing containment. */ +export function resolveTemplateSource( + source: TemplateSourceHandle, + relativePath: string, +): string { + const root = + typeof source === "object" && source !== null + ? templateSourceRoots.get(source) + : undefined; + if (root === undefined) { + throw new Error("Renderer received an unknown Template Source handle"); + } + return resolveContainedPath(root, relativePath); +} + export type CopyFileOperation = { kind: "copyFile"; from: string; to: string; - sourceRoot?: string; + source: TemplateSourceHandle; + /** Foundation refreshes coordinated outputs during Package Addition. */ + overwrite?: boolean; + provenance?: RenderOperationProvenance; }; export type WriteJsonOperation = { @@ -28,18 +74,23 @@ export type WriteJsonOperation = { to: string; value: unknown; multilineArrays?: string[]; + overwrite?: boolean; + provenance?: RenderOperationProvenance; }; export type MergeJsonOperation = { kind: "mergeJson"; to: string; value: unknown; + multilineArrays?: string[]; + provenance?: RenderOperationProvenance; }; export type WriteTextOperation = { kind: "writeText"; to: string; text: string; + provenance?: RenderOperationProvenance; }; export type WriteTextFromFragmentsOperation = { @@ -47,22 +98,28 @@ export type WriteTextFromFragmentsOperation = { to: string; fragments: readonly { from: string; - sourceRoot?: string; + source: TemplateSourceHandle; }[]; + overwrite?: boolean; + provenance?: RenderOperationProvenance; }; export type WriteTextTemplateOperation = { kind: "writeTextTemplate"; from: string; to: string; - sourceRoot?: string; + source: TemplateSourceHandle; replacements: Record; + /** Follow-up plans may refresh a coordinated template-owned file. */ + overwrite?: boolean; + provenance?: RenderOperationProvenance; }; export type SetExecutableOperation = { kind: "setExecutable"; path: string; executable: boolean; + provenance?: RenderOperationProvenance; }; export type ReplaceAnchorsOperation = { @@ -70,6 +127,7 @@ export type ReplaceAnchorsOperation = { path: string; language: "typescript"; replacements: Record; + provenance?: RenderOperationProvenance; }; export type RenderOperation = @@ -83,8 +141,6 @@ export type RenderOperation = | ReplaceAnchorsOperation; export type RenderProjectOptions = { - sourceRoot: string; - sourceRoots?: Record | undefined; targetRoot: string; variables?: RenderVariables | undefined; operations: RenderOperation[]; @@ -144,17 +200,8 @@ async function renderCopyFile( options: RenderProjectOptions, ): Promise { const variables = options.variables ?? {}; - const sourceRoot = - operation.sourceRoot === undefined - ? options.sourceRoot - : options.sourceRoots?.[operation.sourceRoot]; - - if (sourceRoot === undefined) { - throw new Error(`Unknown renderer source root: ${operation.sourceRoot}`); - } - - const from = resolveContainedPath( - sourceRoot, + const from = resolveTemplateSource( + operation.source, expandTemplatePath(operation.from, variables), ); const to = resolveContainedPath( @@ -164,7 +211,7 @@ async function renderCopyFile( const sourceMode = (await stat(from)).mode; await mkdir(path.dirname(to), { recursive: true }); - await copyGeneratedFile(from, to); + await copyGeneratedFile(from, to, operation.overwrite ?? false); await chmod(to, sourceMode & 0o777); } @@ -357,6 +404,7 @@ async function renderWriteJson( expandOperationPath(operation.to, options), operation.value, operation.multilineArrays, + operation.overwrite, ); } @@ -380,7 +428,7 @@ async function renderMergeJson( options.targetRoot, toPath, mergeJsonValue(existing, operation.value), - undefined, + operation.multilineArrays, true, ); } @@ -449,6 +497,10 @@ function assertFoundationTextPath(relativePath: string): void { return; } + if (normalizedPath === ".devcontainer/devcontainer.json") { + return; + } + throw new Error( `Text output is limited to foundation files: ${relativePath}`, ); @@ -493,20 +545,11 @@ async function renderWriteTextFromFragments( const to = resolveContainedPath(options.targetRoot, toPath); const texts = await Promise.all( operation.fragments.map(async (fragment) => { - const sourceRoot = - fragment.sourceRoot === undefined - ? options.sourceRoot - : options.sourceRoots?.[fragment.sourceRoot]; - - if (sourceRoot === undefined) { - throw new Error(`Unknown renderer source root: ${fragment.sourceRoot}`); - } - const fromPath = expandTemplatePath( fragment.from, options.variables ?? {}, ); - const from = resolveContainedPath(sourceRoot, fromPath); + const from = resolveTemplateSource(fragment.source, fromPath); return readFile(from, "utf8"); }), @@ -516,6 +559,7 @@ async function renderWriteTextFromFragments( await writeGeneratedFile( to, texts.map((text) => text.trimEnd()).join("\n\n") + "\n", + operation.overwrite ?? false, ); } @@ -551,19 +595,10 @@ async function renderWriteTextTemplate( operation: WriteTextTemplateOperation, options: RenderProjectOptions, ): Promise { - const sourceRoot = - operation.sourceRoot === undefined - ? options.sourceRoot - : options.sourceRoots?.[operation.sourceRoot]; - - if (sourceRoot === undefined) { - throw new Error(`Unknown renderer source root: ${operation.sourceRoot}`); - } - const toPath = expandOperationPath(operation.to, options); assertTextTemplatePath(toPath); - const from = resolveContainedPath( - sourceRoot, + const from = resolveTemplateSource( + operation.source, expandTemplatePath(operation.from, options.variables ?? {}), ); const to = resolveContainedPath(options.targetRoot, toPath); @@ -573,6 +608,7 @@ async function renderWriteTextTemplate( await writeGeneratedFile( to, replaceTextTemplateVariables(sourceText, operation.replacements), + operation.overwrite, ); } @@ -754,9 +790,13 @@ async function targetDirectoryStatus( return "empty"; } -async function copyGeneratedFile(from: string, to: string): Promise { +async function copyGeneratedFile( + from: string, + to: string, + overwrite = false, +): Promise { try { - await copyFile(from, to, constants.COPYFILE_EXCL); + await copyFile(from, to, overwrite ? 0 : constants.COPYFILE_EXCL); } catch (error: unknown) { if (isNodeError(error) && error.code === "EEXIST") { throw new Error(`Refusing to overwrite existing file: ${to}`, { @@ -839,6 +879,92 @@ export async function renderProject( } } +/** + * Applies a follow-up plan in a sibling staging directory. In particular, + * metadata updates cannot become visible when a later source operation fails. + */ +export async function renderProjectAtomically( + options: RenderProjectOptions, +): Promise { + const targetRoot = path.resolve(options.targetRoot); + await stat(targetRoot); + const parent = path.dirname(targetRoot); + const stagingRoot = await mkdtemp( + path.join(parent, `.${path.basename(targetRoot)}.template-update-`), + ); + const backupRoot = await mkdtemp( + path.join(parent, `.${path.basename(targetRoot)}.template-backup-`), + ); + const changedEntries = changedRootEntries(options.operations); + let committed = false; + try { + await cp(targetRoot, stagingRoot, { recursive: true }); + await renderProject({ ...options, targetRoot: stagingRoot }); + + // Commit only the entries changed by the staged plan. In particular, do + // not rename the target directory: callers commonly run `template add` + // from that directory, and renaming it leaves their shell on a deleted + // inode. Each entry has a rollback copy before it is made visible. + for (const entry of changedEntries) { + const current = path.join(targetRoot, entry); + try { + await cp(current, path.join(backupRoot, entry), { recursive: true }); + } catch (error: unknown) { + if (!isNodeError(error) || error.code !== "ENOENT") throw error; + } + } + + try { + for (const entry of changedEntries) { + await cp(path.join(stagingRoot, entry), path.join(targetRoot, entry), { + recursive: true, + force: true, + }); + } + committed = true; + } catch (error) { + await rollbackStagedEntries({ targetRoot, backupRoot, changedEntries }); + throw error; + } + } finally { + if (!committed) await rm(stagingRoot, { recursive: true, force: true }); + await rm(backupRoot, { recursive: true, force: true }); + if (committed) await rm(stagingRoot, { recursive: true, force: true }); + } +} + +function changedRootEntries( + operations: readonly RenderOperation[], +): readonly string[] { + const entries = new Set(); + for (const operation of operations) { + const outputPath = + operation.kind === "setExecutable" || operation.kind === "replaceAnchors" + ? operation.path + : operation.to; + const entry = outputPath.split(/[\\/]/)[0]; + if (entry !== undefined && entry.length > 0) entries.add(entry); + } + return [...entries]; +} + +async function rollbackStagedEntries(options: { + readonly targetRoot: string; + readonly backupRoot: string; + readonly changedEntries: readonly string[]; +}): Promise { + for (const entry of options.changedEntries) { + const target = path.join(options.targetRoot, entry); + const backup = path.join(options.backupRoot, entry); + await rm(target, { recursive: true, force: true }); + try { + await cp(backup, target, { recursive: true }); + } catch (error: unknown) { + if (!isNodeError(error) || error.code !== "ENOENT") throw error; + } + } +} + async function commitStagedProject( stagingRoot: string, targetRoot: string, diff --git a/packages/core/src/runtime-paths.ts b/packages/core/src/runtime-paths.ts deleted file mode 100644 index 575f188..0000000 --- a/packages/core/src/runtime-paths.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; - -export function packageTemplateRoot( - moduleDir: string, - ...segments: string[] -): string { - const candidates = [ - path.join( - moduleDir, - "..", - "..", - "builtin-source", - "templates", - ...segments, - ), - path.join( - moduleDir, - "..", - "..", - "template-builtin-source", - "templates", - ...segments, - ), - path.join(moduleDir, "..", "..", "templates", ...segments), - path.join(moduleDir, "..", "templates", ...segments), - path.join(moduleDir, "..", "..", "..", "templates", ...segments), - ]; - - const found = candidates.find((candidate) => existsSync(candidate)); - return found ?? candidates[0]!; -} diff --git a/packages/core/src/template-boundary-check.ts b/packages/core/src/template-boundary-check.ts index 340ecce..1349d64 100644 --- a/packages/core/src/template-boundary-check.ts +++ b/packages/core/src/template-boundary-check.ts @@ -1,16 +1,9 @@ -import { readFile } from "node:fs/promises"; +import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import ts from "typescript"; -import type { RenderOperation } from "./renderer.ts"; - -export type TemplateBoundaryDebt = { - readonly preset: string; - readonly generatedPath: string; - readonly owningFunction: string; - readonly reason: string; -}; +import { resolveTemplateSource, type RenderOperation } from "./renderer.ts"; export type TemplateBoundaryViolation = { readonly preset: string; @@ -18,30 +11,39 @@ export type TemplateBoundaryViolation = { readonly owningFunction: string; readonly operationKind: RenderOperation["kind"]; readonly sourceFilePath: string; - readonly allowlistReason?: string; + /** Real registry plans retain their Definition and contribution provenance. */ + readonly definitionName?: string; + readonly planningContribution?: string; + readonly ownershipRule?: string; }; export type TemplateBoundaryCheckProjection = { readonly name: string; readonly sourceFilePath: string; + readonly definitionName?: string; + readonly planningContribution?: string; readonly plan: { - readonly sourceRoot: string; - readonly sourceRoots?: Record | undefined; readonly operations: readonly RenderOperation[]; }; }; +/** A Template Source root directly checked independently of any render plan. */ +export type TemplateSourceContext = { + readonly name: string; + readonly root: string; + /** Every readable source file discovered by a direct Template Source check. */ + readonly checkedFiles?: readonly string[]; +}; + export type TemplateBoundaryCheckResult = { readonly ok: boolean; readonly violations: readonly TemplateBoundaryViolation[]; - readonly allowlistedDebt: readonly TemplateBoundaryViolation[]; - readonly unusedAllowlistEntries: readonly TemplateBoundaryDebt[]; }; export type CheckTemplateSourceBoundaryOptions = { readonly projections: readonly TemplateBoundaryCheckProjection[]; + readonly templateSourceContexts?: readonly TemplateSourceContext[]; readonly manifestReferencedSourceFiles?: readonly string[]; - readonly allowlist?: readonly TemplateBoundaryDebt[]; }; type InlineProtectedOperation = Extract< @@ -59,8 +61,49 @@ const inlineOperationKinds = new Set([ "mergeJson", ]); -export const templateBoundaryDebtAllowlist: readonly TemplateBoundaryDebt[] = - []; +async function listTemplateSourceFiles(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...(await listTemplateSourceFiles(entryPath))); + continue; + } + if (!entry.isFile()) continue; + + await readFile(entryPath); + files.push(path.resolve(entryPath)); + } + + return files; +} + +/** + * Reads each registered Template Source context before any plan is inspected. + * The resulting file sets are an independent proof consumed by the boundary check. + */ +export async function checkTemplateSourceContexts( + contexts: readonly Omit[], +): Promise { + return await Promise.all( + contexts.map(async (context) => { + const root = path.resolve(context.root); + const rootStats = await stat(root); + if (!rootStats.isDirectory()) { + throw new Error( + `Template Source context ${context.name} root is not a directory: ${root}`, + ); + } + return { + ...context, + root, + checkedFiles: await listTemplateSourceFiles(root), + }; + }), + ); +} function operationTarget(operation: RenderOperation): string | undefined { if ("to" in operation) { @@ -770,56 +813,67 @@ function isAllowedStructuralMachineDeclaration( ); } -function allowlistKey( - preset: string, - generatedPath: string, - owningFunction: string, -): string { - return `${preset}\0${owningFunction}\0${generatedPath}`; -} - -function resolveOperationSourceRoot( - projection: TemplateBoundaryCheckProjection, - sourceRootName: string | undefined, -): string | undefined { - if (sourceRootName === undefined) { - return projection.plan.sourceRoot; - } - - return projection.plan.sourceRoots?.[sourceRootName]; -} - function sourceBackedOperationSourceFiles( projection: TemplateBoundaryCheckProjection, operation: SourceBackedOperation, ): readonly string[] { if (operation.kind === "writeTextFromFragments") { return operation.fragments.map((fragment) => { - const root = resolveOperationSourceRoot(projection, fragment.sourceRoot); - - return root === undefined - ? `missing-source-root:${fragment.sourceRoot}` - : path.resolve(root, fragment.from); + try { + return resolveTemplateSource(fragment.source, fragment.from); + } catch (error) { + return `invalid-source:${error instanceof Error ? error.message : String(error)}`; + } }); } - - const root = resolveOperationSourceRoot(projection, operation.sourceRoot); - - return [ - root === undefined - ? `missing-source-root:${operation.sourceRoot}` - : path.resolve(root, operation.from), - ]; + try { + return [resolveTemplateSource(operation.source, operation.from)]; + } catch (error) { + return [ + `invalid-source:${error instanceof Error ? error.message : String(error)}`, + ]; + } } -function operationUsesManifestReferencedSource( +async function operationUsesCheckedTemplateSource( projection: TemplateBoundaryCheckProjection, operation: SourceBackedOperation, + templateSourceContexts: readonly TemplateSourceContext[], manifestReferencedSourceFiles: ReadonlySet, -): boolean { - return sourceBackedOperationSourceFiles(projection, operation).every( - (sourceFile) => manifestReferencedSourceFiles.has(sourceFile), +): Promise { + const sourceFiles = sourceBackedOperationSourceFiles(projection, operation); + + const results = await Promise.all( + sourceFiles.map(async (sourceFile) => { + const context = templateSourceContexts.find((candidate) => { + const root = path.resolve(candidate.root); + return ( + sourceFile.startsWith(`${root}${path.sep}`) || sourceFile === root + ); + }); + + if (context === undefined) { + return manifestReferencedSourceFiles.has(sourceFile); + } + + const relativePath = path.relative( + path.resolve(context.root), + sourceFile, + ); + if ( + relativePath.length === 0 || + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + return false; + } + + return context.checkedFiles?.includes(sourceFile) ?? false; + }), ); + + return results.every(Boolean); } function pushSourceBackedOperationViolations(options: { @@ -844,31 +898,36 @@ function pushSourceBackedOperationViolations(options: { generatedPath, owningFunction, operationKind: options.operation.kind, - sourceFilePath: options.projection.sourceFilePath, + sourceFilePath: + options.operation.provenance?.plannerSourceFile ?? + options.projection.sourceFilePath, + ...(options.operation.provenance === undefined && + options.projection.definitionName === undefined + ? {} + : { + definitionName: + options.operation.provenance?.definitionName ?? + options.projection.definitionName, + planningContribution: + options.operation.provenance?.planningContribution ?? + options.projection.planningContribution, + ownershipRule: + options.operation.provenance?.ownershipRule ?? + "source-backed protected output must use declared, contained Template Source", + }), }); } } export async function checkTemplateSourceBoundary({ projections, + templateSourceContexts = [], manifestReferencedSourceFiles = [], - allowlist = [], }: CheckTemplateSourceBoundaryOptions): Promise { const manifestReferencedSourceFileSet = new Set( manifestReferencedSourceFiles.map((sourceFile) => path.resolve(sourceFile)), ); - const allowed = new Map( - allowlist.map((entry) => [ - allowlistKey(entry.preset, entry.generatedPath, entry.owningFunction), - entry.reason, - ]), - ); - const checkedPresets = new Set( - projections.map((projection) => projection.name), - ); - const usedAllowlistKeys = new Set(); const violations: TemplateBoundaryViolation[] = []; - const allowlistedDebt: TemplateBoundaryViolation[] = []; for (const projection of projections) { const sourceText = await readFile(projection.sourceFilePath, "utf8"); @@ -883,11 +942,12 @@ export async function checkTemplateSourceBoundary({ for (const operation of projection.plan.operations) { if (isProtectedSourceBackedOperation(operation)) { if ( - !operationUsesManifestReferencedSource( + !(await operationUsesCheckedTemplateSource( projection, operation, + templateSourceContexts, manifestReferencedSourceFileSet, - ) + )) ) { pushSourceBackedOperationViolations({ projection, @@ -923,40 +983,32 @@ export async function checkTemplateSourceBoundary({ generatedPath: operation.to, owningFunction, operationKind: operation.kind, - sourceFilePath: projection.sourceFilePath, + sourceFilePath: + operation.provenance?.plannerSourceFile ?? + projection.sourceFilePath, + ...(operation.provenance === undefined && + projection.definitionName === undefined + ? {} + : { + definitionName: + operation.provenance?.definitionName ?? + projection.definitionName, + planningContribution: + operation.provenance?.planningContribution ?? + projection.planningContribution, + ownershipRule: + operation.provenance?.ownershipRule ?? + "protected generated output must be Template Source-backed", + }), }; - const allowlistReason = allowed.get( - allowlistKey(projection.name, operation.to, owningFunction), - ); - - if (allowlistReason) { - usedAllowlistKeys.add( - allowlistKey(projection.name, operation.to, owningFunction), - ); - allowlistedDebt.push({ ...diagnostic, allowlistReason }); - continue; - } - violations.push(diagnostic); } } } - const unusedAllowlistEntries = allowlist.filter((entry) => { - if (!checkedPresets.has(entry.preset)) { - return false; - } - - return !usedAllowlistKeys.has( - allowlistKey(entry.preset, entry.generatedPath, entry.owningFunction), - ); - }); - return { - ok: violations.length === 0 && unusedAllowlistEntries.length === 0, + ok: violations.length === 0, violations, - allowlistedDebt, - unusedAllowlistEntries, }; } diff --git a/packages/shared/package.json b/packages/shared/package.json index 6ae0f8a..c8414f4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -29,5 +29,10 @@ "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", "typescript-7": "catalog:" + }, + "turbo": { + "tags": [ + "template-shared" + ] } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0404d76..69d9333 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2002 +1,6 @@ -import * as v from "valibot"; - -export const PackageAdditionSupport = { - Supported: "supported", - Unsupported: "unsupported", -} as const; - -export type PackageAdditionSupport = - (typeof PackageAdditionSupport)[keyof typeof PackageAdditionSupport]; - -export const packageAdditionSupportValues = [ - PackageAdditionSupport.Supported, - PackageAdditionSupport.Unsupported, -] as const satisfies readonly PackageAdditionSupport[]; - -export type PackageRole = "runtime-service" | "shared-library"; - -export type PackageSourcePreset = - | "hono-api" - | "ts-lib" - | "vike-app" - | "vue-app"; - -export type PackageLinkIntent = { - readonly consumerPackagePath: string; - readonly providerPackagePath: string; -}; - -export type PackageManager = "pnpm"; - -export type ProjectKind = "single-package" | "multi-package"; - -export type FeatureName = - | "pnpm-catalog" - | "oxc-format-lint" - | "strict-typescript" - | "root-check" - | "fix-command" - | "devcontainer" - | "github-actions" - | "dependabot" - | "rustfmt-clippy" - | "cargo-test" - | "native-binary-release"; - -export type PresetGeneration = "supported" | "future"; - -export type BuiltInPreset = { - name: string; - title: string; - description: string; - generation: PresetGeneration; - supportedPackageManagers: readonly PackageManager[]; - supportedProjectKinds: readonly ProjectKind[]; - packageAdditionSupport: PackageAdditionSupport; - features: readonly FeatureName[]; -}; - -export type PresetFile = { - schemaVersion: 1; - name: string; - title: string; - description: string; - supportedPackageManagers: PackageManager[]; - supportedProjectKinds: ProjectKind[]; - features: FeatureName[]; -}; - -export type ProjectPackage = { - name: string; - path: string; - role?: PackageRole; - sourcePreset?: PackageSourcePreset; -}; - -export type ProjectBlueprint = { - schemaVersion: 1; - preset: string; - packageManager?: PackageManager; - projectKind: ProjectKind; - features: FeatureName[]; - packages?: ProjectPackage[]; - packageLinkIntents?: PackageLinkIntent[]; -}; - -export type ValidationIssue = { - path: string; - message: string; -}; - -export type ValidationResult = - | { ok: true; value: T } - | { ok: false; issues: ValidationIssue[] }; - -export type PresetCatalogValidationOptions = { - readonly presets?: readonly BuiltInPreset[]; - readonly presetLabel?: string; -}; - -export type PresetSourceManifestPresetSource = { - roots: string[]; - files: string[]; - sharedResources: string[]; -}; - -export type ProjectionCapabilityKind = - | "workspace-library-package" - | "workspace-node-packages" - | "rust-binary-workspace" - | "strict-typescript-root" - | "oxc-format-lint" - | "node-pnpm-devcontainer" - | "github-maintenance"; - -export type WorkspaceLibraryPackageCapabilityDeclaration = { - readonly kind: "workspace-library-package"; - readonly workspacePackageGlob: "packages/*"; - readonly packageRole: "shared-library"; - readonly packageSourcePreset: "ts-lib"; - readonly sourceFiles: readonly string[]; -}; - -export type WorkspaceNodePackageKind = "hono-api" | "vike-app" | "vue-app"; -export type WorkspaceNodePackagePath = "apps/api" | "apps/web"; - -export type WorkspaceNodePackageDeclaration = { - readonly kind: WorkspaceNodePackageKind; - readonly path: WorkspaceNodePackagePath; - readonly sourceFiles: readonly string[]; -}; - -export type WorkspaceNodePackagesCapabilityDeclaration = { - readonly kind: "workspace-node-packages"; - readonly workspacePackageGlob: "apps/*"; - readonly packages: readonly WorkspaceNodePackageDeclaration[]; - readonly packageLinks?: readonly { - readonly consumerPackagePath: "apps/web"; - readonly providerPackagePath: "apps/api" | "packages/db"; - }[]; -}; - -export type RustBinaryWorkspaceCapabilityDeclaration = { - readonly kind: "rust-binary-workspace"; - readonly workspacePackageGlob: "packages/*"; - readonly sourceFiles: readonly string[]; - readonly cargoDependencies?: readonly string[]; - readonly devcontainerResourceId: string; - readonly editorCustomizationResourceId: string; -}; - -export type StrictTypescriptRootCapabilityDeclaration = { - readonly kind: "strict-typescript-root"; -}; - -export type OxcFormatLintCapabilityDeclaration = { - readonly kind: "oxc-format-lint"; - readonly editorCustomizationResourceId: string; -}; - -export type NodePnpmDevcontainerCapabilityDeclaration = { - readonly kind: "node-pnpm-devcontainer"; - readonly devcontainerResourceId: string; -}; - -export type GithubMaintenanceCapabilityDeclaration = { - readonly kind: "github-maintenance"; -}; - -export type ProjectionCapabilityDeclaration = - | WorkspaceLibraryPackageCapabilityDeclaration - | WorkspaceNodePackagesCapabilityDeclaration - | RustBinaryWorkspaceCapabilityDeclaration - | StrictTypescriptRootCapabilityDeclaration - | OxcFormatLintCapabilityDeclaration - | NodePnpmDevcontainerCapabilityDeclaration - | GithubMaintenanceCapabilityDeclaration; - -export type PresetProjectionDeclaration = { - readonly capabilities: readonly ProjectionCapabilityDeclaration[]; -}; - -export type PresetSourceManifestPreset = BuiltInPreset & { - dependencyCatalog?: string[]; - projection?: PresetProjectionDeclaration; - source?: PresetSourceManifestPresetSource; -}; - -export type PresetSourceManifestSharedResource = { - id: string; - path: string; -}; - -export type PresetSourceManifest = { - schemaVersion: 1; - name: string; - sharedResources: PresetSourceManifestSharedResource[]; - presets: PresetSourceManifestPreset[]; -}; - -export const packageManagerValues = [ - "pnpm", -] as const satisfies readonly PackageManager[]; - -export const presetGenerationValues = [ - "supported", - "future", -] as const satisfies readonly PresetGeneration[]; - -export const projectKindValues = [ - "single-package", - "multi-package", -] as const satisfies readonly ProjectKind[]; - -export const projectKindJsonSchemaEnum = [ - "multi-package", -] as const satisfies readonly ProjectKind[]; - -export const featureNameValues = [ - "pnpm-catalog", - "oxc-format-lint", - "strict-typescript", - "root-check", - "fix-command", - "devcontainer", - "github-actions", - "dependabot", - "rustfmt-clippy", - "cargo-test", - "native-binary-release", -] as const satisfies readonly FeatureName[]; - -export const packageManagerJsonSchemaEnum = packageManagerValues; - -export const presetGenerationJsonSchemaEnum = presetGenerationValues; - -export const featureNameJsonSchemaEnum = featureNameValues; - -export const projectionCapabilityKinds = [ - "workspace-library-package", - "workspace-node-packages", - "rust-binary-workspace", - "strict-typescript-root", - "oxc-format-lint", - "node-pnpm-devcontainer", - "github-maintenance", -] satisfies readonly ProjectionCapabilityKind[]; - -export const packageLeafNamePattern = "^[a-z0-9][a-z0-9-]*$"; - -export const presetSourceManifestJsonSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: "https://ykdz.dev/schemas/template/preset-source-manifest.schema.json", - title: "Preset Source Manifest", - type: "object", - additionalProperties: false, - required: ["schemaVersion", "name", "presets"], - properties: { - schemaVersion: { const: 1 }, - name: { type: "string", minLength: 1 }, - sharedResources: { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["id", "path"], - properties: { - id: { type: "string", minLength: 1 }, - path: { type: "string", minLength: 1 }, - }, - }, - }, - presets: { - type: "array", - minItems: 1, - items: { - type: "object", - additionalProperties: false, - required: [ - "name", - "title", - "description", - "generation", - "supportedPackageManagers", - "supportedProjectKinds", - "packageAdditionSupport", - "features", - ], - properties: { - name: { type: "string", minLength: 1 }, - title: { type: "string", minLength: 1 }, - description: { type: "string", minLength: 1 }, - generation: { enum: presetGenerationJsonSchemaEnum }, - supportedPackageManagers: { - type: "array", - items: { enum: packageManagerJsonSchemaEnum }, - uniqueItems: true, - }, - supportedProjectKinds: { - type: "array", - minItems: 1, - items: { enum: projectKindJsonSchemaEnum }, - uniqueItems: true, - }, - packageAdditionSupport: { enum: packageAdditionSupportValues }, - features: { - type: "array", - items: { enum: featureNameJsonSchemaEnum }, - uniqueItems: true, - }, - dependencyCatalog: { - type: "array", - items: { type: "string", minLength: 1 }, - uniqueItems: true, - }, - projection: { - type: "object", - additionalProperties: false, - required: ["capabilities"], - properties: { - capabilities: { - type: "array", - items: { - oneOf: [ - { - type: "object", - additionalProperties: false, - required: [ - "kind", - "workspacePackageGlob", - "packageRole", - "packageSourcePreset", - "sourceFiles", - ], - properties: { - kind: { const: "workspace-library-package" }, - workspacePackageGlob: { const: "packages/*" }, - packageRole: { const: "shared-library" }, - packageSourcePreset: { const: "ts-lib" }, - sourceFiles: { - type: "array", - minItems: 1, - items: { type: "string", minLength: 1 }, - }, - }, - }, - { - type: "object", - additionalProperties: false, - required: ["kind", "workspacePackageGlob", "packages"], - properties: { - kind: { const: "workspace-node-packages" }, - workspacePackageGlob: { const: "apps/*" }, - packages: { - type: "array", - minItems: 1, - items: { - type: "object", - additionalProperties: false, - required: ["kind", "path", "sourceFiles"], - properties: { - kind: { - enum: ["hono-api", "vike-app", "vue-app"], - }, - path: { enum: ["apps/api", "apps/web"] }, - sourceFiles: { - type: "array", - minItems: 1, - items: { type: "string", minLength: 1 }, - }, - }, - }, - }, - packageLinks: { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: [ - "consumerPackagePath", - "providerPackagePath", - ], - properties: { - consumerPackagePath: { const: "apps/web" }, - providerPackagePath: { - enum: ["apps/api", "packages/db"], - }, - }, - }, - }, - }, - }, - { - type: "object", - additionalProperties: false, - required: [ - "kind", - "workspacePackageGlob", - "sourceFiles", - "devcontainerResourceId", - "editorCustomizationResourceId", - ], - properties: { - kind: { const: "rust-binary-workspace" }, - workspacePackageGlob: { const: "packages/*" }, - sourceFiles: { - type: "array", - minItems: 1, - items: { type: "string", minLength: 1 }, - }, - devcontainerResourceId: { - type: "string", - minLength: 1, - }, - editorCustomizationResourceId: { - type: "string", - minLength: 1, - }, - }, - }, - { - type: "object", - additionalProperties: false, - required: ["kind"], - properties: { kind: { const: "strict-typescript-root" } }, - }, - { - type: "object", - additionalProperties: false, - required: ["kind", "editorCustomizationResourceId"], - properties: { - kind: { const: "oxc-format-lint" }, - editorCustomizationResourceId: { - type: "string", - minLength: 1, - }, - }, - }, - { - type: "object", - additionalProperties: false, - required: ["kind", "devcontainerResourceId"], - properties: { - kind: { const: "node-pnpm-devcontainer" }, - devcontainerResourceId: { - type: "string", - minLength: 1, - }, - }, - }, - { - type: "object", - additionalProperties: false, - required: ["kind"], - properties: { kind: { const: "github-maintenance" } }, - }, - ], - }, - }, - }, - }, - source: { - type: "object", - additionalProperties: false, - properties: { - roots: { - type: "array", - items: { type: "string", minLength: 1 }, - }, - files: { - type: "array", - items: { type: "string", minLength: 1 }, - }, - sharedResources: { - type: "array", - items: { type: "string", minLength: 1 }, - }, - }, - }, - }, - }, - }, - }, -} as const; - -const nonEmptyString = v.pipe(v.string(), v.minLength(1)); -export const presetGenerationSchema = v.picklist(presetGenerationValues); -export const packageManagerSchema = v.picklist(packageManagerValues); -export const projectKindSchema = v.picklist(projectKindValues); -export const featureNameSchema = v.picklist(featureNameValues); -const packageRoleSchema = v.picklist([ - "runtime-service", - "shared-library", -] as const); -const packageSourcePresetSchema = v.picklist([ - "hono-api", - "ts-lib", - "vike-app", - "vue-app", -] as const); -const presetSourceManifestPresetSourceSchema = v.strictObject({ - roots: v.optional(v.array(nonEmptyString), []), - files: v.optional(v.array(nonEmptyString), []), - sharedResources: v.optional(v.array(nonEmptyString), []), -}); -export const presetProjectionDeclarationSchema = v.strictObject({ - capabilities: v.array( - v.looseObject({ - kind: v.string(), - }), - ), -}); -export const presetSourceManifestSchema = v.strictObject({ - schemaVersion: v.literal(1), - name: nonEmptyString, - sharedResources: v.optional( - v.array( - v.strictObject({ - id: nonEmptyString, - path: nonEmptyString, - }), - ), - [], - ), - presets: v.pipe( - v.array( - v.strictObject({ - name: nonEmptyString, - title: nonEmptyString, - description: nonEmptyString, - generation: presetGenerationSchema, - supportedPackageManagers: v.array(packageManagerSchema), - supportedProjectKinds: v.pipe( - v.array(projectKindSchema), - v.minLength(1), - ), - packageAdditionSupport: v.picklist(packageAdditionSupportValues), - features: v.array(featureNameSchema), - dependencyCatalog: v.optional(v.array(nonEmptyString), []), - projection: v.optional(presetProjectionDeclarationSchema), - source: v.optional(presetSourceManifestPresetSourceSchema), - }), - ), - v.minLength(1), - ), -}); - -export const presetFileJsonSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: "https://ykdz.dev/schemas/project-kit/preset-file.schema.json", - title: "Project Kit Preset File", - type: "object", - additionalProperties: false, - required: [ - "schemaVersion", - "name", - "title", - "description", - "supportedPackageManagers", - "supportedProjectKinds", - "features", - ], - properties: { - schemaVersion: { const: 1 }, - name: { type: "string", minLength: 1 }, - title: { type: "string", minLength: 1 }, - description: { type: "string", minLength: 1 }, - supportedPackageManagers: { - type: "array", - items: { enum: packageManagerJsonSchemaEnum }, - uniqueItems: true, - }, - supportedProjectKinds: { - type: "array", - minItems: 1, - items: { enum: projectKindJsonSchemaEnum }, - uniqueItems: true, - }, - features: { - type: "array", - items: { enum: featureNameJsonSchemaEnum }, - uniqueItems: true, - }, - }, -} as const; - -export const blueprintJsonSchema = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: "https://ykdz.dev/schemas/project-kit/blueprint.schema.json", - title: "Project Kit Blueprint", - type: "object", - additionalProperties: false, - required: ["schemaVersion", "preset", "projectKind", "features"], - properties: { - schemaVersion: { const: 1 }, - preset: { type: "string", minLength: 1 }, - packageManager: { enum: packageManagerJsonSchemaEnum }, - projectKind: { enum: projectKindJsonSchemaEnum }, - features: { - type: "array", - items: { enum: featureNameJsonSchemaEnum }, - uniqueItems: true, - }, - packages: { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["name", "path"], - properties: { - name: { type: "string", minLength: 1 }, - path: { type: "string", minLength: 1 }, - role: { enum: ["runtime-service", "shared-library"] }, - sourcePreset: { enum: ["hono-api", "ts-lib", "vike-app", "vue-app"] }, - }, - }, - }, - packageLinkIntents: { - type: "array", - items: { - type: "object", - additionalProperties: false, - required: ["consumerPackagePath", "providerPackagePath"], - properties: { - consumerPackagePath: { type: "string", minLength: 1 }, - providerPackagePath: { type: "string", minLength: 1 }, - }, - }, - }, - }, -} as const; - -export const presetFileSchema = v.strictObject({ - schemaVersion: v.literal(1), - name: nonEmptyString, - title: nonEmptyString, - description: nonEmptyString, - supportedPackageManagers: v.array(packageManagerSchema), - supportedProjectKinds: v.pipe(v.array(projectKindSchema), v.minLength(1)), - features: v.array(featureNameSchema), -}); - -export const projectBlueprintSchema = v.strictObject({ - schemaVersion: v.literal(1), - preset: nonEmptyString, - packageManager: v.optional(packageManagerSchema), - projectKind: projectKindSchema, - features: v.array(featureNameSchema), - packages: v.optional( - v.array( - v.strictObject({ - name: nonEmptyString, - path: nonEmptyString, - role: v.optional(packageRoleSchema), - sourcePreset: v.optional(packageSourcePresetSchema), - }), - ), - ), - packageLinkIntents: v.optional( - v.array( - v.strictObject({ - consumerPackagePath: nonEmptyString, - providerPackagePath: nonEmptyString, - }), - ), - ), -}); - -function formatIssuePath(issue: v.BaseIssue): string { - if (!issue.path || issue.path.length === 0) { - return "$"; - } - - return issue.path.reduce((path, item) => `${path}.${String(item.key)}`, "$"); -} - -function shapeIssues(issues: v.BaseIssue[]): ValidationIssue[] { - return issues.map((issue) => ({ - path: formatIssuePath(issue), - message: issue.message, - })); -} - -function duplicateIssues(values: string[], path: string): ValidationIssue[] { - const seen = new Set(); - const duplicates = new Set(); - - for (const value of values) { - if (seen.has(value)) { - duplicates.add(value); - } - seen.add(value); - } - - return [...duplicates].map((value) => ({ - path, - message: `Duplicate value: ${value}`, - })); -} - -function generationSupportIssue( - preset: BuiltInPreset, - path: string, -): ValidationIssue | undefined { - if (preset.generation === "supported") { - return undefined; - } - - return { - path, - message: `Preset ${preset.name} is not supported for generation in this version`, - }; -} - -function unsupportedSinglePackageProjectShapeIssue( - path: string, -): ValidationIssue { - return { - path, - message: - "single-package Project Shape is unsupported in V1; use the workspace monorepo Project Shape", - }; -} - -function formatBracketIssuePath(issue: v.BaseIssue): string { - if (!issue.path || issue.path.length === 0) { - return "$"; - } - - return issue.path.reduce((issuePath, item) => { - if (typeof item.key === "number") { - return `${issuePath}[${item.key}]`; - } - - return `${issuePath}.${String(item.key)}`; - }, "$"); -} - -function presetSourceShapeIssues( - issues: v.BaseIssue[], -): ValidationIssue[] { - return issues.map((issue) => { - const validationPath = formatBracketIssuePath(issue); - const missingPresetMetadata = validationPath.match( - /^\$\.presets\[\d+\]\.(.+)$/, - ); - - if (missingPresetMetadata && issue.message.includes("received undefined")) { - return { - path: validationPath, - message: `Preset metadata is missing required field: ${missingPresetMetadata[1]}`, - }; - } - - if ( - issue.message.startsWith("Invalid key:") && - validationPath === "$.fixtureMatrix" - ) { - return { - path: validationPath, - message: - "Preset Source Manifests must contain production facts only; remove fixtureMatrix", - }; - } - - if ( - issue.message.startsWith("Invalid key:") && - /\.(body|content|text)$/.test(validationPath) - ) { - const key = validationPath.split(".").at(-1); - - return { - path: validationPath, - message: `Preset Source Manifests must reference Generated Repository file bodies by path, not inline ${key}`, - }; - } - - return { - path: validationPath, - message: validationPath.match( - /^\$\.presets\[\d+\]\.packageAdditionSupport$/, - ) - ? "Package Addition Support must be one of: supported, unsupported" - : issue.message, - }; - }); -} - -type ParsedPresetSourceManifest = v.InferOutput< - typeof presetSourceManifestSchema ->; -type ParsedPresetSourceManifestPreset = - ParsedPresetSourceManifest["presets"][number]; - -function duplicatePresetNameIssues( - presets: readonly ParsedPresetSourceManifestPreset[], -): ValidationIssue[] { - const seen = new Set(); - const duplicates = new Set(); - - for (const preset of presets) { - if (seen.has(preset.name)) { - duplicates.add(preset.name); - } - seen.add(preset.name); - } - - return [...duplicates].map((name) => ({ - path: "$.presets.name", - message: `Duplicate Preset name: ${name}`, - })); -} - -function duplicateValueIssues( - values: readonly string[], - validationPath: string, -): ValidationIssue[] { - const seen = new Set(); - const duplicates = new Set(); - - for (const value of values) { - if (seen.has(value)) { - duplicates.add(value); - } - seen.add(value); - } - - return [...duplicates].map((value) => ({ - path: validationPath, - message: `Duplicate value: ${value}`, - })); -} - -function duplicatePresetMetadataArrayIssues( - presets: readonly ParsedPresetSourceManifestPreset[], -): ValidationIssue[] { - return presets.flatMap((preset, index) => [ - ...duplicateValueIssues( - preset.supportedPackageManagers, - `$.presets[${index}].supportedPackageManagers`, - ), - ...duplicateValueIssues( - preset.supportedProjectKinds, - `$.presets[${index}].supportedProjectKinds`, - ), - ...duplicateValueIssues(preset.features, `$.presets[${index}].features`), - ...duplicateValueIssues( - preset.dependencyCatalog ?? [], - `$.presets[${index}].dependencyCatalog`, - ), - ]); -} - -function unsupportedProjectShapeIssues( - presets: readonly ParsedPresetSourceManifestPreset[], -): ValidationIssue[] { - return presets.flatMap((preset, index) => - preset.supportedProjectKinds.includes("single-package") - ? [ - { - path: `$.presets[${index}].supportedProjectKinds`, - message: - "single-package Project Shape is unsupported in V1; use the workspace monorepo Project Shape", - }, - ] - : [], - ); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function nonEmptyStringArray(value: unknown[]): string[] | undefined { - const strings: string[] = []; - - for (const entry of value) { - if (typeof entry !== "string" || entry.length === 0) { - return undefined; - } - - strings.push(entry); - } - - return strings.length === 0 ? undefined : strings; -} - -const projectionCapabilityKindSet = new Set(projectionCapabilityKinds); -const workspaceNodePackageKindSet = new Set([ - "hono-api", - "vike-app", - "vue-app", -]); -const workspaceNodePackagePathSet = new Set(["apps/api", "apps/web"]); - -function isProjectionCapabilityKind( - value: string, -): value is ProjectionCapabilityKind { - return projectionCapabilityKindSet.has(value); -} - -function isWorkspaceNodePackageKind( - value: unknown, -): value is WorkspaceNodePackageKind { - return typeof value === "string" && workspaceNodePackageKindSet.has(value); -} - -function isWorkspaceNodePackagePath( - value: unknown, -): value is WorkspaceNodePackagePath { - return typeof value === "string" && workspaceNodePackagePathSet.has(value); -} - -const exactCapabilityKeys: Record = - { - "workspace-library-package": [ - "kind", - "workspacePackageGlob", - "packageRole", - "packageSourcePreset", - "sourceFiles", - ], - "workspace-node-packages": [ - "kind", - "workspacePackageGlob", - "packages", - "packageLinks", - ], - "rust-binary-workspace": [ - "kind", - "workspacePackageGlob", - "sourceFiles", - "cargoDependencies", - "devcontainerResourceId", - "editorCustomizationResourceId", - ], - "strict-typescript-root": ["kind"], - "oxc-format-lint": ["kind", "editorCustomizationResourceId"], - "node-pnpm-devcontainer": ["kind", "devcontainerResourceId"], - "github-maintenance": ["kind"], - }; - -const requiredPlanCapabilityProviders: readonly { - readonly kind: ProjectionCapabilityKind; - readonly label: string; -}[] = [ - { kind: "strict-typescript-root", label: "root check command" }, - { kind: "oxc-format-lint", label: "fix command" }, - { kind: "github-maintenance", label: "GitHub Actions maintenance" }, - { kind: "github-maintenance", label: "Dependabot maintenance" }, - { kind: "node-pnpm-devcontainer", label: "development container support" }, -]; - -function unknownCapabilityPropertyIssues( - kind: ProjectionCapabilityKind, - capability: Record, - pathPrefix: string, -): ValidationIssue[] { - const allowedKeys = new Set(exactCapabilityKeys[kind]); - - return Object.keys(capability) - .filter((key) => !allowedKeys.has(key)) - .map((key) => ({ - path: `${pathPrefix}.${key}`, - message: `Projection Capability ${kind} does not support property: ${key}`, - })); -} - -function parseWorkspaceLibraryPackageCapability( - capability: Record, - pathPrefix: string, -): WorkspaceLibraryPackageCapabilityDeclaration | ValidationIssue[] { - const issues: ValidationIssue[] = []; - let sourceFiles: string[] = []; - - if (capability.workspacePackageGlob !== "packages/*") { - issues.push({ - path: `${pathPrefix}.workspacePackageGlob`, - message: - "workspace-library-package currently supports workspacePackageGlob: packages/*", - }); - } - - if (capability.packageRole !== "shared-library") { - issues.push({ - path: `${pathPrefix}.packageRole`, - message: - "workspace-library-package currently supports packageRole: shared-library", - }); - } - - if (capability.packageSourcePreset !== "ts-lib") { - issues.push({ - path: `${pathPrefix}.packageSourcePreset`, - message: - "workspace-library-package currently supports packageSourcePreset: ts-lib", - }); - } - - if (!Array.isArray(capability.sourceFiles)) { - issues.push({ - path: `${pathPrefix}.sourceFiles`, - message: - "workspace-library-package sourceFiles must be a non-empty array", - }); - } else { - const parsedSourceFiles = nonEmptyStringArray(capability.sourceFiles); - if (parsedSourceFiles === undefined) { - issues.push({ - path: `${pathPrefix}.sourceFiles`, - message: - "workspace-library-package sourceFiles must be a non-empty array of paths", - }); - } else { - sourceFiles = parsedSourceFiles; - } - } - - if (issues.length > 0) { - return issues; - } - - return { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles, - }; -} - -function parseWorkspaceNodePackage( - value: unknown, - pathPrefix: string, -): WorkspaceNodePackageDeclaration | ValidationIssue[] { - const issues: ValidationIssue[] = []; - - if (!isRecord(value)) { - return [ - { - path: pathPrefix, - message: "workspace-node-packages package must be an object", - }, - ]; - } - - const kind = isWorkspaceNodePackageKind(value.kind) ? value.kind : undefined; - const packagePath = isWorkspaceNodePackagePath(value.path) - ? value.path - : undefined; - let sourceFiles: string[] = []; - - if (kind === undefined) { - issues.push({ - path: `${pathPrefix}.kind`, - message: - "workspace-node-packages package kind must be hono-api, vike-app, or vue-app", - }); - } - - if (packagePath === undefined) { - issues.push({ - path: `${pathPrefix}.path`, - message: - "workspace-node-packages package path must be apps/api or apps/web", - }); - } - - if (!Array.isArray(value.sourceFiles)) { - issues.push({ - path: `${pathPrefix}.sourceFiles`, - message: - "workspace-node-packages package sourceFiles must be a non-empty array", - }); - } else { - const parsedSourceFiles = nonEmptyStringArray(value.sourceFiles); - if (parsedSourceFiles === undefined) { - issues.push({ - path: `${pathPrefix}.sourceFiles`, - message: - "workspace-node-packages package sourceFiles must be a non-empty array of paths", - }); - } else { - sourceFiles = parsedSourceFiles; - } - } - - const allowedKeys = new Set(["kind", "path", "sourceFiles"]); - for (const key of Object.keys(value)) { - if (!allowedKeys.has(key)) { - issues.push({ - path: `${pathPrefix}.${key}`, - message: `workspace-node-packages package does not support property: ${key}`, - }); - } - } - - if (issues.length > 0) { - return issues; - } - - if (kind === undefined || packagePath === undefined) { - throw new Error( - `workspace-node-packages package validation failed without diagnostics at ${pathPrefix}`, - ); - } - - return { - kind, - path: packagePath, - sourceFiles, - }; -} - -function parseWorkspaceNodePackageLinks( - value: unknown, - pathPrefix: string, - declaredPackagePaths: ReadonlySet, - issues: ValidationIssue[], -): WorkspaceNodePackagesCapabilityDeclaration["packageLinks"] | undefined { - if (!Array.isArray(value)) { - issues.push({ - path: pathPrefix, - message: "workspace-node-packages packageLinks must be an array", - }); - return undefined; - } - - return value.flatMap((link, linkIndex) => { - const linkPath = `${pathPrefix}[${linkIndex}]`; - const linkIssues: ValidationIssue[] = []; - - if (!isRecord(link)) { - issues.push({ - path: linkPath, - message: "workspace-node-packages packageLink must be an object", - }); - return []; - } - - const allowedKeys = new Set(["consumerPackagePath", "providerPackagePath"]); - for (const key of Object.keys(link)) { - if (!allowedKeys.has(key)) { - linkIssues.push({ - path: `${linkPath}.${key}`, - message: `workspace-node-packages packageLink does not support property: ${key}`, - }); - } - } - - const consumerPackagePath = - typeof link.consumerPackagePath === "string" - ? link.consumerPackagePath - : undefined; - const providerPackagePath = - typeof link.providerPackagePath === "string" - ? link.providerPackagePath - : undefined; - const isSupportedLink = - consumerPackagePath === "apps/web" && - (providerPackagePath === "apps/api" || - providerPackagePath === "packages/db"); - - if (!isSupportedLink) { - linkIssues.push({ - path: linkPath, - message: - "workspace-node-packages currently supports links from apps/web to apps/api or packages/db", - }); - } else { - if (!declaredPackagePaths.has(consumerPackagePath)) { - linkIssues.push({ - path: `${linkPath}.consumerPackagePath`, - message: - "workspace-node-packages packageLink consumerPackagePath must reference a package declared in the same packages array: apps/web", - }); - } - - if ( - providerPackagePath !== "packages/db" && - !declaredPackagePaths.has(providerPackagePath) - ) { - linkIssues.push({ - path: `${linkPath}.providerPackagePath`, - message: - "workspace-node-packages packageLink providerPackagePath must reference a package declared in the same packages array or the vike db package: apps/api, packages/db", - }); - } - } - - if (linkIssues.length > 0) { - issues.push(...linkIssues); - return []; - } - - return [ - { - consumerPackagePath: "apps/web" as const, - providerPackagePath: providerPackagePath as "apps/api" | "packages/db", - }, - ]; - }); -} - -function parseWorkspaceNodePackagesCapability( - capability: Record, - pathPrefix: string, -): WorkspaceNodePackagesCapabilityDeclaration | ValidationIssue[] { - const issues: ValidationIssue[] = []; - const workspacePackageGlob = - capability.workspacePackageGlob === "apps/*" - ? capability.workspacePackageGlob - : undefined; - - if (workspacePackageGlob === undefined) { - issues.push({ - path: `${pathPrefix}.workspacePackageGlob`, - message: - "workspace-node-packages currently supports workspacePackageGlob: apps/*", - }); - } - - if (!Array.isArray(capability.packages) || capability.packages.length === 0) { - issues.push({ - path: `${pathPrefix}.packages`, - message: "workspace-node-packages packages must be a non-empty array", - }); - } - - const packages = Array.isArray(capability.packages) - ? capability.packages.flatMap((nodePackage, packageIndex) => { - const packagePath = `${pathPrefix}.packages[${packageIndex}]`; - const parsed = parseWorkspaceNodePackage(nodePackage, packagePath); - if (Array.isArray(parsed)) { - issues.push(...parsed); - return []; - } - return [parsed]; - }) - : []; - - const packageLinks = - capability.packageLinks === undefined - ? undefined - : parseWorkspaceNodePackageLinks( - capability.packageLinks, - `${pathPrefix}.packageLinks`, - new Set(packages.map((nodePackage) => nodePackage.path)), - issues, - ); - - if (issues.length > 0) { - return issues; - } - - if (workspacePackageGlob === undefined) { - throw new Error( - `workspace-node-packages validation failed without workspacePackageGlob diagnostic at ${pathPrefix}`, - ); - } - - return { - kind: "workspace-node-packages", - workspacePackageGlob, - packages, - ...(packageLinks === undefined ? {} : { packageLinks }), - }; -} - -function parseRustBinaryWorkspaceCapability( - capability: Record, - pathPrefix: string, -): RustBinaryWorkspaceCapabilityDeclaration | ValidationIssue[] { - const issues: ValidationIssue[] = []; - let sourceFiles: string[] = []; - let cargoDependencies: string[] = []; - let devcontainerResourceId = ""; - let editorCustomizationResourceId = ""; - - if (capability.workspacePackageGlob !== "packages/*") { - issues.push({ - path: `${pathPrefix}.workspacePackageGlob`, - message: - "rust-binary-workspace currently supports workspacePackageGlob: packages/*", - }); - } - - if (!Array.isArray(capability.sourceFiles)) { - issues.push({ - path: `${pathPrefix}.sourceFiles`, - message: "rust-binary-workspace sourceFiles must be a non-empty array", - }); - } else { - const parsedSourceFiles = nonEmptyStringArray(capability.sourceFiles); - if (parsedSourceFiles === undefined) { - issues.push({ - path: `${pathPrefix}.sourceFiles`, - message: - "rust-binary-workspace sourceFiles must be a non-empty array of paths", - }); - } else { - sourceFiles = parsedSourceFiles; - } - } - - if (capability.cargoDependencies !== undefined) { - if (!Array.isArray(capability.cargoDependencies)) { - issues.push({ - path: `${pathPrefix}.cargoDependencies`, - message: "rust-binary-workspace cargoDependencies must be an array", - }); - } else { - const parsedCargoDependencies = nonEmptyStringArray( - capability.cargoDependencies, - ); - if (parsedCargoDependencies === undefined) { - issues.push({ - path: `${pathPrefix}.cargoDependencies`, - message: - "rust-binary-workspace cargoDependencies must be an array of dependency names", - }); - } else { - cargoDependencies = parsedCargoDependencies; - } - } - } - - if ( - typeof capability.devcontainerResourceId !== "string" || - capability.devcontainerResourceId.length === 0 - ) { - issues.push({ - path: `${pathPrefix}.devcontainerResourceId`, - message: - "rust-binary-workspace must declare a devcontainerResourceId Shared Resource id", - }); - } else { - devcontainerResourceId = capability.devcontainerResourceId; - } - - if ( - typeof capability.editorCustomizationResourceId !== "string" || - capability.editorCustomizationResourceId.length === 0 - ) { - issues.push({ - path: `${pathPrefix}.editorCustomizationResourceId`, - message: - "rust-binary-workspace must declare an editorCustomizationResourceId Shared Resource id", - }); - } else { - editorCustomizationResourceId = capability.editorCustomizationResourceId; - } - - if (issues.length > 0) { - return issues; - } - - return { - kind: "rust-binary-workspace", - workspacePackageGlob: "packages/*", - sourceFiles, - ...(cargoDependencies.length === 0 ? {} : { cargoDependencies }), - devcontainerResourceId, - editorCustomizationResourceId, - }; -} - -function parseOxcFormatLintCapability( - capability: Record, - pathPrefix: string, -): OxcFormatLintCapabilityDeclaration | ValidationIssue[] { - if ( - typeof capability.editorCustomizationResourceId !== "string" || - capability.editorCustomizationResourceId.length === 0 - ) { - return [ - { - path: `${pathPrefix}.editorCustomizationResourceId`, - message: - "oxc-format-lint must declare an editorCustomizationResourceId Shared Resource id", - }, - ]; - } - - return { - kind: "oxc-format-lint", - editorCustomizationResourceId: capability.editorCustomizationResourceId, - }; -} - -function parseNodePnpmDevcontainerCapability( - capability: Record, - pathPrefix: string, -): NodePnpmDevcontainerCapabilityDeclaration | ValidationIssue[] { - if ( - typeof capability.devcontainerResourceId !== "string" || - capability.devcontainerResourceId.length === 0 - ) { - return [ - { - path: `${pathPrefix}.devcontainerResourceId`, - message: - "node-pnpm-devcontainer must declare a devcontainerResourceId Shared Resource id", - }, - ]; - } - - return { - kind: "node-pnpm-devcontainer", - devcontainerResourceId: capability.devcontainerResourceId, - }; -} - -function duplicateCapabilityIssues( - capabilities: readonly ProjectionCapabilityDeclaration[], -): ValidationIssue[] { - const firstSeen = new Map(); - const issues: ValidationIssue[] = []; - - capabilities.forEach((capability, index) => { - const firstIndex = firstSeen.get(capability.kind); - if (firstIndex === undefined) { - firstSeen.set(capability.kind, index); - return; - } - - issues.push({ - path: `$.capabilities[${index}].kind`, - message: `Duplicate Projection Capability kind: ${capability.kind}`, - }); - }); - - return issues; -} - -function uniqueValues(values: readonly T[]): T[] { - return [...new Set(values)]; -} - -function capabilityCompositionIssues( - capabilities: readonly ProjectionCapabilityDeclaration[], -): ValidationIssue[] { - const kinds = new Set(capabilities.map((capability) => capability.kind)); - const issues: ValidationIssue[] = []; - const companionKinds = uniqueValues( - capabilities - .map((capability) => capability.kind) - .filter((kind) => kind !== "rust-binary-workspace"), - ); - - if (kinds.has("rust-binary-workspace") && companionKinds.length > 0) { - issues.push({ - path: "$.capabilities", - message: - "rust-binary-workspace is a complete domain capability and must be selected by itself; remove companion Projection Capabilities: " + - companionKinds.join(", "), - }); - return issues; - } - - if ( - !kinds.has("workspace-library-package") && - !kinds.has("workspace-node-packages") && - !kinds.has("rust-binary-workspace") - ) { - issues.push({ - path: "$.capabilities", - message: - "Projection Capability composition must include a workspace package layout capability", - }); - } - - if ( - kinds.has("strict-typescript-root") && - !kinds.has("workspace-library-package") && - !kinds.has("workspace-node-packages") - ) { - issues.push({ - path: "$.capabilities", - message: - "strict-typescript-root requires a workspace package layout so package typecheck tasks have a workspace target", - }); - } - - if (kinds.has("node-pnpm-devcontainer") && !kinds.has("oxc-format-lint")) { - issues.push({ - path: "$.capabilities", - message: - "node-pnpm-devcontainer requires oxc-format-lint so editor customization is derived from declared tooling", - }); - } - - for (const requirement of requiredPlanCapabilityProviders) { - if (!kinds.has(requirement.kind) && !kinds.has("rust-binary-workspace")) { - issues.push({ - path: "$.capabilities", - message: `Projection Capability composition must include ${requirement.kind} to provide ${requirement.label}`, - }); - } - } - - return issues; -} - -export function validatePresetProjectionDeclaration( - input: unknown, -): ValidationResult { - if (!isRecord(input) || !Array.isArray(input.capabilities)) { - return { - ok: false, - issues: [ - { - path: "$.capabilities", - message: "Projection Declaration must select Projection Capabilities", - }, - ], - }; - } - - const issues: ValidationIssue[] = []; - const capabilities: ProjectionCapabilityDeclaration[] = []; - - input.capabilities.forEach((capability, index) => { - const pathPrefix = `$.capabilities[${index}]`; - - if (!isRecord(capability)) { - issues.push({ - path: pathPrefix, - message: "Projection Capability must be an object", - }); - return; - } - - if (typeof capability.kind !== "string") { - issues.push({ - path: `${pathPrefix}.kind`, - message: "Projection Capability kind is required", - }); - return; - } - - if (!isProjectionCapabilityKind(capability.kind)) { - issues.push({ - path: `${pathPrefix}.kind`, - message: `Unknown Projection Capability kind: ${capability.kind}`, - }); - return; - } - - const kind = capability.kind; - issues.push( - ...unknownCapabilityPropertyIssues(kind, capability, pathPrefix), - ); - - if (kind === "workspace-library-package") { - const workspaceCapability = parseWorkspaceLibraryPackageCapability( - capability, - pathPrefix, - ); - if (Array.isArray(workspaceCapability)) { - issues.push(...workspaceCapability); - return; - } - capabilities.push(workspaceCapability); - return; - } - - if (kind === "workspace-node-packages") { - const workspaceCapability = parseWorkspaceNodePackagesCapability( - capability, - pathPrefix, - ); - if (Array.isArray(workspaceCapability)) { - issues.push(...workspaceCapability); - return; - } - capabilities.push(workspaceCapability); - return; - } - - if (kind === "rust-binary-workspace") { - const workspaceCapability = parseRustBinaryWorkspaceCapability( - capability, - pathPrefix, - ); - if (Array.isArray(workspaceCapability)) { - issues.push(...workspaceCapability); - return; - } - capabilities.push(workspaceCapability); - return; - } - - if (kind === "oxc-format-lint") { - const oxcFormatLintCapability = parseOxcFormatLintCapability( - capability, - pathPrefix, - ); - if (Array.isArray(oxcFormatLintCapability)) { - issues.push(...oxcFormatLintCapability); - return; - } - capabilities.push(oxcFormatLintCapability); - return; - } - - if (kind === "node-pnpm-devcontainer") { - const nodePnpmDevcontainerCapability = - parseNodePnpmDevcontainerCapability(capability, pathPrefix); - if (Array.isArray(nodePnpmDevcontainerCapability)) { - issues.push(...nodePnpmDevcontainerCapability); - return; - } - capabilities.push(nodePnpmDevcontainerCapability); - return; - } - - capabilities.push({ kind }); - }); - - if (issues.length === 0) { - issues.push(...duplicateCapabilityIssues(capabilities)); - issues.push(...capabilityCompositionIssues(capabilities)); - } - - if (issues.length > 0) { - return { ok: false, issues }; - } - - return { ok: true, value: { capabilities } }; -} - -export function projectionCapabilityIssues( - presets: readonly { readonly projection?: unknown }[], -): ValidationIssue[] { - return presets.flatMap((preset, presetIndex) => { - if (preset.projection === undefined) { - return []; - } - - const result = validatePresetProjectionDeclaration(preset.projection); - - return result.ok - ? [] - : result.issues.map((issue) => ({ - path: `$.presets[${presetIndex}].projection${issue.path.slice(1)}`, - message: issue.message, - })); - }); -} - -function projectionCapabilitySharedResourceIssues( - manifest: ParsedPresetSourceManifest, -): ValidationIssue[] { - const sharedResourceIds = new Set( - manifest.sharedResources.map((resource) => resource.id), - ); - const issues: ValidationIssue[] = []; - - manifest.presets.forEach((preset, presetIndex) => { - if (preset.projection === undefined) { - return; - } - - const result = validatePresetProjectionDeclaration(preset.projection); - if (!result.ok) { - return; - } - - result.value.capabilities.forEach((capability, capabilityIndex) => { - if (capability.kind === "oxc-format-lint") { - const resourceId = capability.editorCustomizationResourceId; - if (!sharedResourceIds.has(resourceId)) { - issues.push({ - path: `$.presets[${presetIndex}].projection.capabilities[${capabilityIndex}].editorCustomizationResourceId`, - message: `Projection Capability oxc-format-lint references undeclared Shared Resource: ${resourceId}`, - }); - } - } - - if (capability.kind === "node-pnpm-devcontainer") { - const resourceId = capability.devcontainerResourceId; - if (!sharedResourceIds.has(resourceId)) { - issues.push({ - path: `$.presets[${presetIndex}].projection.capabilities[${capabilityIndex}].devcontainerResourceId`, - message: `Projection Capability node-pnpm-devcontainer references undeclared Shared Resource: ${resourceId}`, - }); - } - } - - if (capability.kind === "rust-binary-workspace") { - const devcontainerResourceId = capability.devcontainerResourceId; - if (!sharedResourceIds.has(devcontainerResourceId)) { - issues.push({ - path: `$.presets[${presetIndex}].projection.capabilities[${capabilityIndex}].devcontainerResourceId`, - message: `Projection Capability rust-binary-workspace references undeclared Shared Resource: ${devcontainerResourceId}`, - }); - } - - const editorCustomizationResourceId = - capability.editorCustomizationResourceId; - if (!sharedResourceIds.has(editorCustomizationResourceId)) { - issues.push({ - path: `$.presets[${presetIndex}].projection.capabilities[${capabilityIndex}].editorCustomizationResourceId`, - message: `Projection Capability rust-binary-workspace references undeclared Shared Resource: ${editorCustomizationResourceId}`, - }); - } - } - }); - }); - - return issues; -} - -export function normalizePresetProjectionDeclaration( - declaration: unknown, -): PresetProjectionDeclaration { - const result = validatePresetProjectionDeclaration(declaration); - - if (!result.ok) { - throw new Error( - `Projection Declaration is invalid:\n${result.issues - .map((issue) => ` - ${issue.path}: ${issue.message}`) - .join("\n")}`, - ); - } - - return result.value; -} - -function normalizePresetSourceManifestOutput( - manifest: ParsedPresetSourceManifest, -): PresetSourceManifest { - return { - schemaVersion: manifest.schemaVersion, - name: manifest.name, - sharedResources: manifest.sharedResources.map((resource) => ({ - ...resource, - })), - presets: manifest.presets.map((preset) => ({ - name: preset.name, - title: preset.title, - description: preset.description, - generation: preset.generation, - packageAdditionSupport: preset.packageAdditionSupport, - supportedPackageManagers: [...preset.supportedPackageManagers], - supportedProjectKinds: [...preset.supportedProjectKinds], - features: [...preset.features], - dependencyCatalog: [...preset.dependencyCatalog], - ...(preset.projection - ? { - projection: normalizePresetProjectionDeclaration(preset.projection), - } - : {}), - ...(preset.source - ? { - source: { - roots: [...preset.source.roots], - files: [...preset.source.files], - sharedResources: [...preset.source.sharedResources], - }, - } - : {}), - })), - }; -} - -export function validatePresetSourceManifestDeclaration( - input: unknown, -): ValidationResult { - const result = v.safeParse(presetSourceManifestSchema, input); - - if (!result.success) { - return { ok: false, issues: presetSourceShapeIssues(result.issues) }; - } - - const parsedManifest = result.output; - const semanticIssues = [ - ...duplicateValueIssues( - parsedManifest.sharedResources.map((resource) => resource.id), - "$.sharedResources.id", - ), - ...duplicatePresetNameIssues(parsedManifest.presets), - ...duplicatePresetMetadataArrayIssues(parsedManifest.presets), - ...unsupportedProjectShapeIssues(parsedManifest.presets), - ...projectionCapabilityIssues(parsedManifest.presets), - ...projectionCapabilitySharedResourceIssues(parsedManifest), - ]; - - if (semanticIssues.length > 0) { - return { ok: false, issues: semanticIssues }; - } - - return { - ok: true, - value: normalizePresetSourceManifestOutput(parsedManifest), - }; -} - -export function findPreset( - presets: readonly BuiltInPreset[], - name: string, -): BuiltInPreset | undefined { - return presets.find((preset) => preset.name === name); -} - -export function validatePresetFile( - input: unknown, - options: PresetCatalogValidationOptions = {}, -): ValidationResult { - const result = v.safeParse(presetFileSchema, input); - - if (!result.success) { - return { ok: false, issues: shapeIssues(result.issues) }; - } - - const semanticIssues = [ - ...duplicateIssues( - result.output.supportedPackageManagers, - "$.supportedPackageManagers", - ), - ...duplicateIssues( - result.output.supportedProjectKinds, - "$.supportedProjectKinds", - ), - ...duplicateIssues(result.output.features, "$.features"), - ]; - - if (result.output.supportedProjectKinds.includes("single-package")) { - semanticIssues.push( - unsupportedSinglePackageProjectShapeIssue("$.supportedProjectKinds"), - ); - } - - const catalogPreset = findPreset(options.presets ?? [], result.output.name); - const unsupportedGeneration = catalogPreset - ? generationSupportIssue(catalogPreset, "$.name") - : undefined; - - if (unsupportedGeneration) { - semanticIssues.push(unsupportedGeneration); - } - - if (semanticIssues.length > 0) { - return { ok: false, issues: semanticIssues }; - } - - return { ok: true, value: result.output }; -} - -function normalizeProjectBlueprint( - blueprint: v.InferOutput, -): ProjectBlueprint { - return { - schemaVersion: blueprint.schemaVersion, - preset: blueprint.preset, - ...(blueprint.packageManager === undefined - ? {} - : { packageManager: blueprint.packageManager }), - projectKind: blueprint.projectKind, - features: [...blueprint.features], - ...(blueprint.packages === undefined - ? {} - : { - packages: blueprint.packages.map((projectPackage) => ({ - name: projectPackage.name, - path: projectPackage.path, - ...(projectPackage.role === undefined - ? {} - : { role: projectPackage.role }), - ...(projectPackage.sourcePreset === undefined - ? {} - : { sourcePreset: projectPackage.sourcePreset }), - })), - }), - ...(blueprint.packageLinkIntents === undefined - ? {} - : { - packageLinkIntents: blueprint.packageLinkIntents.map((intent) => ({ - consumerPackagePath: intent.consumerPackagePath, - providerPackagePath: intent.providerPackagePath, - })), - }), - }; -} - -function packagePathIssue( - packagePath: string, - path: string, -): ValidationIssue | undefined { - const segments = packagePath.split("/"); - if ( - segments.length !== 2 || - segments.some((segment) => segment.length === 0 || segment === ".") - ) { - return { - path, - message: - "Package Path must be exactly two non-root path segments, such as apps/web or packages/ui", - }; - } - - return undefined; -} - -export function validateProjectBlueprint( - input: unknown, - options: PresetCatalogValidationOptions = {}, -): ValidationResult { - const result = v.safeParse(projectBlueprintSchema, input); - - if (!result.success) { - return { ok: false, issues: shapeIssues(result.issues) }; - } - - const blueprint = normalizeProjectBlueprint(result.output); - const preset = findPreset(options.presets ?? [], blueprint.preset); - const semanticIssues: ValidationIssue[] = [ - ...duplicateIssues(blueprint.features, "$.features"), - ]; - - if (blueprint.projectKind === "single-package") { - semanticIssues.push( - unsupportedSinglePackageProjectShapeIssue("$.projectKind"), - ); - } - - if (options.presets && !preset) { - semanticIssues.push({ - path: "$.preset", - message: `Unknown ${options.presetLabel ?? "built-in"} preset: ${blueprint.preset}`, - }); - } else if (preset) { - const unsupportedGeneration = generationSupportIssue(preset, "$.preset"); - - if (unsupportedGeneration) { - semanticIssues.push(unsupportedGeneration); - } - - if ( - blueprint.packageManager && - !preset.supportedPackageManagers.includes(blueprint.packageManager) - ) { - semanticIssues.push({ - path: "$.packageManager", - message: `${blueprint.packageManager} is not supported by preset ${preset.name}`, - }); - } - - if ( - !blueprint.packageManager && - preset.supportedPackageManagers.length > 0 - ) { - semanticIssues.push({ - path: "$.packageManager", - message: `packageManager is required by preset ${preset.name}`, - }); - } - - if (!preset.supportedProjectKinds.includes(blueprint.projectKind)) { - semanticIssues.push({ - path: "$.projectKind", - message: `${blueprint.projectKind} is not supported by preset ${preset.name}`, - }); - } - - const supportedFeatures = new Set(preset.features); - for (const feature of blueprint.features) { - if (!supportedFeatures.has(feature)) { - semanticIssues.push({ - path: "$.features", - message: `${feature} is not supported by preset ${preset.name}`, - }); - } - } - } - - if (blueprint.packages) { - semanticIssues.push( - ...duplicateIssues( - blueprint.packages.map((projectPackage) => projectPackage.name), - "$.packages.name", - ), - ...duplicateIssues( - blueprint.packages.map((projectPackage) => projectPackage.path), - "$.packages.path", - ), - ...blueprint.packages.flatMap((projectPackage, index) => { - const issue = packagePathIssue( - projectPackage.path, - `$.packages[${index}].path`, - ); - return issue === undefined ? [] : [issue]; - }), - ); - } - - if (semanticIssues.length > 0) { - return { ok: false, issues: semanticIssues }; - } - - return { ok: true, value: blueprint }; -} +/** + * The contract library intentionally exports no external-shaped Preset + * declaration protocol. Built-in planning contracts live with the trusted + * runtime boundaries that own them. + */ +export {}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9d8c40..423c000 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,12 +22,18 @@ catalogs: '@types/semver': specifier: ^7.7.1 version: 7.7.1 + '@types/web-bluetooth': + specifier: ^0.0.21 + version: 0.0.21 '@vikejs/hono': specifier: ^0.2.1 version: 0.2.1 '@vitejs/plugin-vue': specifier: ^6.0.7 version: 6.0.7 + '@vue/tsconfig': + specifier: ^0.9.1 + version: 0.9.1 drizzle-kit: specifier: 1.0.0-rc.4 version: 1.0.0-rc.4 @@ -55,6 +61,9 @@ catalogs: semver: specifier: ^7.8.5 version: 7.8.5 + tailwindcss: + specifier: ^4.3.2 + version: 4.3.2 telefunc: specifier: ^0.2.22 version: 0.2.22 @@ -97,8 +106,6 @@ overrides: valibot>typescript: '-' vue>typescript: '-' -pnpmfileChecksum: sha256-EO7Dbruee2Wzi6F0RwQfsWc8lf+VNytY/Qb4T/HSpRU= - importers: .: @@ -106,18 +113,18 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.3 - '@ykdz/template-builtin-source': + '@ykdz/template-builtin-presets': specifier: workspace:* - version: file:packages/builtin-source + version: file:packages/builtin-presets(@typescript/typescript6@6.0.2) '@ykdz/template-checks': specifier: workspace:* - version: file:packages/checks + version: file:packages/checks(@typescript/typescript6@6.0.2) '@ykdz/template-core': specifier: workspace:* version: file:packages/core '@ykdz/template-shared': specifier: workspace:* - version: file:packages/shared + version: file:packages/shared(@typescript/typescript6@6.0.2) execa: specifier: 'catalog:' version: 9.6.1 @@ -141,7 +148,7 @@ importers: version: typescript@7.0.2 valibot: specifier: 'catalog:' - version: 1.4.2 + version: 1.4.2(@typescript/typescript6@6.0.2) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) @@ -149,17 +156,14 @@ importers: specifier: 'catalog:' version: 2.9.0 - packages/builtin-source: + packages/builtin-presets: dependencies: '@ykdz/template-core': specifier: workspace:* version: file:packages/core - '@ykdz/template-shared': - specifier: workspace:* - version: file:packages/shared valibot: specifier: 'catalog:' - version: 1.4.2 + version: 1.4.2(@typescript/typescript6@6.0.2) devDependencies: '@hono/node-server': specifier: 'catalog:' @@ -173,21 +177,27 @@ importers: '@types/node': specifier: 'catalog:' version: 24.13.3 - '@types/semver': + '@types/web-bluetooth': specifier: 'catalog:' - version: 7.7.1 + version: 0.0.21 '@vikejs/hono': specifier: 'catalog:' version: 0.2.1(hono@4.12.28)(srvx@0.11.21)(vike@0.4.260(hono@4.12.28)(srvx@0.11.21)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))) '@vitejs/plugin-vue': specifier: 'catalog:' - version: 6.0.7(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39) + version: 6.0.7(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(@typescript/typescript6@6.0.2)) + '@vue/tsconfig': + specifier: 'catalog:' + version: 0.9.1(@typescript/typescript6@6.0.2)(vue@3.5.39(@typescript/typescript6@6.0.2)) drizzle-kit: specifier: 'catalog:' version: 1.0.0-rc.4 drizzle-orm: specifier: 'catalog:' - version: 1.0.0-rc.4(valibot@1.4.2) + version: 1.0.0-rc.4(valibot@1.4.2(@typescript/typescript6@6.0.2)) + execa: + specifier: 'catalog:' + version: 9.6.1 hono: specifier: 'catalog:' version: 4.12.28 @@ -202,10 +212,10 @@ importers: version: 0.24.0 pinia: specifier: 'catalog:' - version: 3.0.4(vue@3.5.39) - semver: + version: 3.0.4(@typescript/typescript6@6.0.2)(vue@3.5.39(@typescript/typescript6@6.0.2)) + tailwindcss: specifier: 'catalog:' - version: 7.8.5 + version: 4.3.2 telefunc: specifier: 'catalog:' version: 0.2.22(@babel/core@7.29.7)(@babel/parser@7.29.7)(@babel/types@7.29.7)(srvx@0.11.21)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -220,7 +230,7 @@ importers: version: 0.4.260(hono@4.12.28)(srvx@0.11.21)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) vike-vue: specifier: 'catalog:' - version: 0.9.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vike@0.4.260(hono@4.12.28)(srvx@0.11.21)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vue@3.5.39) + version: 0.9.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vike@0.4.260(hono@4.12.28)(srvx@0.11.21)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vue@3.5.39(@typescript/typescript6@6.0.2)) vite: specifier: 'catalog:' version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) @@ -229,25 +239,19 @@ importers: version: 4.1.10(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) vue: specifier: 'catalog:' - version: 3.5.39 + version: 3.5.39(@typescript/typescript6@6.0.2) vue-tsc: specifier: 'catalog:' version: 3.3.7(@typescript/typescript6@6.0.2) - yaml: - specifier: 'catalog:' - version: 2.9.0 packages/checks: dependencies: - '@ykdz/template-builtin-source': + '@ykdz/template-builtin-presets': specifier: workspace:* - version: file:packages/builtin-source + version: file:packages/builtin-presets(@typescript/typescript6@6.0.2) '@ykdz/template-core': specifier: workspace:* version: file:packages/core - '@ykdz/template-shared': - specifier: workspace:* - version: file:packages/shared execa: specifier: 'catalog:' version: 9.6.1 @@ -267,21 +271,24 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 0.24.0 + typescript: + specifier: 'catalog:' + version: '@typescript/typescript6@6.0.2' typescript-7: specifier: 'catalog:' version: typescript@7.0.2 packages/cli: dependencies: - '@ykdz/template-builtin-source': + '@typescript/old': + specifier: npm:typescript@^6.0.0 + version: typescript@6.0.3 + '@ykdz/template-builtin-presets': specifier: workspace:* - version: file:packages/builtin-source + version: file:packages/builtin-presets(@typescript/typescript6@6.0.2) '@ykdz/template-core': specifier: workspace:* version: file:packages/core - '@ykdz/template-shared': - specifier: workspace:* - version: file:packages/shared semver: specifier: 'catalog:' version: 7.8.5 @@ -290,7 +297,7 @@ importers: version: '@typescript/typescript6@6.0.2' valibot: specifier: 'catalog:' - version: 1.4.2 + version: 1.4.2(@typescript/typescript6@6.0.2) devDependencies: '@types/node': specifier: 'catalog:' @@ -310,9 +317,6 @@ importers: packages/core: dependencies: - '@ykdz/template-shared': - specifier: workspace:* - version: file:packages/shared semver: specifier: 'catalog:' version: 7.8.5 @@ -321,7 +325,7 @@ importers: version: '@typescript/typescript6@6.0.2' valibot: specifier: 'catalog:' - version: 1.4.2 + version: 1.4.2(@typescript/typescript6@6.0.2) devDependencies: '@types/node': specifier: 'catalog:' @@ -349,7 +353,7 @@ importers: dependencies: valibot: specifier: 'catalog:' - version: 1.4.2 + version: 1.4.2(typescript@7.0.2) devDependencies: '@types/node': specifier: 'catalog:' @@ -1455,6 +1459,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1736,8 +1743,19 @@ packages: '@vue/shared@3.5.39': resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} - '@ykdz/template-builtin-source@file:packages/builtin-source': - resolution: {directory: packages/builtin-source, type: directory} + '@vue/tsconfig@0.9.1': + resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==} + peerDependencies: + typescript: '>= 5.8' + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + '@ykdz/template-builtin-presets@file:packages/builtin-presets': + resolution: {directory: packages/builtin-presets, type: directory} '@ykdz/template-checks@file:packages/checks': resolution: {directory: packages/checks, type: directory} @@ -2358,7 +2376,11 @@ packages: pinia@3.0.4: resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} peerDependencies: + typescript: '*' vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -2373,10 +2395,6 @@ packages: engines: {node: '>=18'} hasBin: true - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} @@ -2593,6 +2611,11 @@ packages: valibot@1.4.2: resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true vike-vue@0.9.13: resolution: {integrity: sha512-M7G5rBHiuLulXtPCEM25hNLoGWpO2JvsUZOMOP79DyesR8I7kw9hSXH6RBB/VrbdNCmpFQHEmjIkFlBKp+CkNQ==} @@ -2712,6 +2735,11 @@ packages: vue@3.5.39: resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -3437,6 +3465,8 @@ snapshots: '@types/semver@7.7.1': {} + '@types/web-bluetooth@0.0.21': {} + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -3606,11 +3636,11 @@ snapshots: - h3 - srvx - '@vitejs/plugin-vue@6.0.7(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39)': + '@vitejs/plugin-vue@6.0.7(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(@typescript/typescript6@6.0.2))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) - vue: 3.5.39 + vue: 3.5.39(@typescript/typescript6@6.0.2) '@vitest/expect@4.1.10': dependencies: @@ -3687,7 +3717,7 @@ snapshots: '@vue/shared': 3.5.39 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.15 + postcss: 8.5.16 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.39': @@ -3739,38 +3769,46 @@ snapshots: '@vue/shared': 3.5.39 csstype: 3.2.3 - '@vue/server-renderer@3.5.39(vue@3.5.39)': + '@vue/server-renderer@3.5.39(vue@3.5.39(@typescript/typescript6@6.0.2))': dependencies: '@vue/compiler-ssr': 3.5.39 '@vue/shared': 3.5.39 - vue: 3.5.39 + vue: 3.5.39(@typescript/typescript6@6.0.2) '@vue/shared@3.5.39': {} - '@ykdz/template-builtin-source@file:packages/builtin-source': + '@vue/tsconfig@0.9.1(@typescript/typescript6@6.0.2)(vue@3.5.39(@typescript/typescript6@6.0.2))': + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + vue: 3.5.39(@typescript/typescript6@6.0.2) + + '@ykdz/template-builtin-presets@file:packages/builtin-presets(@typescript/typescript6@6.0.2)': dependencies: '@ykdz/template-core': file:packages/core - '@ykdz/template-shared': file:packages/shared - valibot: 1.4.2 + valibot: 1.4.2(@typescript/typescript6@6.0.2) + transitivePeerDependencies: + - typescript - '@ykdz/template-checks@file:packages/checks': + '@ykdz/template-checks@file:packages/checks(@typescript/typescript6@6.0.2)': dependencies: - '@ykdz/template-builtin-source': file:packages/builtin-source + '@ykdz/template-builtin-presets': file:packages/builtin-presets(@typescript/typescript6@6.0.2) '@ykdz/template-core': file:packages/core - '@ykdz/template-shared': file:packages/shared execa: 9.6.1 yaml: 2.9.0 + transitivePeerDependencies: + - typescript '@ykdz/template-core@file:packages/core': dependencies: - '@ykdz/template-shared': file:packages/shared semver: 7.8.5 typescript: '@typescript/typescript6@6.0.2' - valibot: 1.4.2 + valibot: 1.4.2(@typescript/typescript6@6.0.2) - '@ykdz/template-shared@file:packages/shared': + '@ykdz/template-shared@file:packages/shared(@typescript/typescript6@6.0.2)': dependencies: - valibot: 1.4.2 + valibot: 1.4.2(@typescript/typescript6@6.0.2) + transitivePeerDependencies: + - typescript acorn@8.17.0: {} @@ -3846,9 +3884,9 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(valibot@1.4.2): + drizzle-orm@1.0.0-rc.4(valibot@1.4.2(@typescript/typescript6@6.0.2)): optionalDependencies: - valibot: 1.4.2 + valibot: 1.4.2(@typescript/typescript6@6.0.2) electron-to-chromium@1.5.389: {} @@ -4235,10 +4273,12 @@ snapshots: picomatch@4.0.5: {} - pinia@3.0.4(vue@3.5.39): + pinia@3.0.4(@typescript/typescript6@6.0.2)(vue@3.5.39(@typescript/typescript6@6.0.2)): dependencies: '@vue/devtools-api': 7.7.10 - vue: 3.5.39 + vue: 3.5.39(@typescript/typescript6@6.0.2) + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' pkg-types@1.3.1: dependencies: @@ -4254,12 +4294,6 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss@8.5.15: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.16: dependencies: nanoid: 3.3.15 @@ -4466,15 +4500,21 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - valibot@1.4.2: {} + valibot@1.4.2(@typescript/typescript6@6.0.2): + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + + valibot@1.4.2(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 - vike-vue@0.9.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vike@0.4.260(hono@4.12.28)(srvx@0.11.21)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vue@3.5.39): + vike-vue@0.9.13(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(vike@0.4.260(hono@4.12.28)(srvx@0.11.21)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(vue@3.5.39(@typescript/typescript6@6.0.2)): dependencies: magic-string: 0.30.21 oxc-parser: 0.104.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) oxc-walker: 0.6.0(oxc-parser@0.104.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)) vike: 0.4.260(hono@4.12.28)(srvx@0.11.21)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - vue: 3.5.39 + vue: 3.5.39(@typescript/typescript6@6.0.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -4497,7 +4537,7 @@ snapshots: esbuild: 0.28.1 json5: 2.2.3 magic-string: 0.30.21 - picomatch: 4.0.4 + picomatch: 4.0.5 semver: 7.8.5 sirv: 3.0.2 source-map-support: 0.5.21 @@ -4581,13 +4621,15 @@ snapshots: '@vue/language-core': 3.3.7 typescript: '@typescript/typescript6@6.0.2' - vue@3.5.39: + vue@3.5.39(@typescript/typescript6@6.0.2): dependencies: '@vue/compiler-dom': 3.5.39 '@vue/compiler-sfc': 3.5.39 '@vue/runtime-dom': 3.5.39 - '@vue/server-renderer': 3.5.39(vue@3.5.39) + '@vue/server-renderer': 3.5.39(vue@3.5.39(@typescript/typescript6@6.0.2)) '@vue/shared': 3.5.39 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' webpack-virtual-modules@0.6.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 535d5f9..21c48b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,8 +3,6 @@ packages: nodeLinker: isolated -pnpmfile: packages/builtin-source/templates/shared/pnpm-peer-policy/.pnpmfile.cts - autoInstallPeers: false injectWorkspacePackages: true diff --git a/test/builtin-presets-package-addition-universality.test.ts b/test/builtin-presets-package-addition-universality.test.ts new file mode 100644 index 0000000..010986f --- /dev/null +++ b/test/builtin-presets-package-addition-universality.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + builtInPresetRegistry, + createGenerationContext, + planGeneratedRepositoryInitialization, + planGeneratedRepositoryPackageAddition, +} from "@ykdz/template-builtin-presets"; +import { + renderNewProject, + renderProjectAtomically, +} from "@ykdz/template-core/renderer"; +import { describe, expect, it } from "vitest"; + +describe("Built-in Preset Package Addition universality", () => { + const toolchain = { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }; + + it("reconstructs every base contribution from durable generated state before an addition", async () => { + const definitions = builtInPresetRegistry.all(); + const addableDefinitions = definitions.filter( + (definition) => definition.planPackageAddition !== undefined, + ); + + for (const baseDefinition of definitions) { + for (const additionDefinition of addableDefinitions) { + const workspace = await mkdtemp( + path.join(tmpdir(), "template-durable-addition-"), + ); + const targetDir = path.join(workspace, baseDefinition.metadata.name); + const context = createGenerationContext({ + targetDir, + scope: "demo", + toolchain, + }); + const initialization = planGeneratedRepositoryInitialization({ + definition: baseDefinition, + context, + }); + try { + await renderNewProject({ + targetRoot: targetDir, + operations: [...initialization.operations], + }); + const addition = planGeneratedRepositoryPackageAddition({ + definition: additionDefinition, + context, + blueprint: initialization.blueprint, + packageLeafName: `durable-${additionDefinition.metadata.name}`, + }); + + expect(addition.checks).toEqual( + expect.arrayContaining([...initialization.checks]), + ); + expect(addition.fixes).toEqual( + expect.arrayContaining([...initialization.fixes]), + ); + expect(addition.environmentNeeds).toEqual( + expect.arrayContaining([...initialization.environmentNeeds]), + ); + expect(addition.deploymentChecks).toEqual( + expect.arrayContaining([...initialization.deploymentChecks]), + ); + expect(addition.dependencyMaintenancePolicy.ecosystems).toEqual( + expect.arrayContaining( + initialization.dependencyMaintenancePolicy.ecosystems, + ), + ); + expect( + addition.dependencyMaintenancePolicy.directories, + ).toMatchObject( + initialization.dependencyMaintenancePolicy.directories ?? {}, + ); + expect( + addition.operations.find( + (operation) => + operation.kind === "writeJson" && + operation.to === "package.json", + ), + ).toMatchObject({ + value: { + scripts: expect.objectContaining({ + check: expect.any(String), + ...(initialization.deploymentChecks.length === 0 + ? {} + : { "check:deployment": expect.any(String) }), + }), + }, + }); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + } + } + }); + + it("plans every supported Package Addition for every registered initialization Definition", async () => { + const definitions = builtInPresetRegistry.all(); + const addableDefinitions = definitions.filter( + (definition) => definition.planPackageAddition !== undefined, + ); + + expect(addableDefinitions).not.toHaveLength(0); + + for (const baseDefinition of definitions) { + const targetDir = path.join( + await mkdtemp(path.join(tmpdir(), "template-universality-")), + baseDefinition.metadata.name, + ); + const context = createGenerationContext({ + targetDir, + scope: "demo", + toolchain, + }); + const initialization = planGeneratedRepositoryInitialization({ + definition: baseDefinition, + context, + }); + try { + await renderNewProject({ + targetRoot: targetDir, + operations: [...initialization.operations], + }); + + let currentBlueprint = initialization.blueprint; + for (const additionDefinition of addableDefinitions) { + const packageLeafName = `${additionDefinition.metadata.name}-addition`; + const addition = planGeneratedRepositoryPackageAddition({ + definition: additionDefinition, + context, + blueprint: currentBlueprint, + packageLeafName, + }); + const addedDefinition = addition.blueprint.packages.find( + (definition) => definition.name === `@demo/${packageLeafName}`, + ); + + expect(addedDefinition).toBeDefined(); + expect(addition.operations).toContainEqual( + expect.objectContaining({ + kind: "writeJson", + to: `${addedDefinition?.path}/package.json`, + value: expect.objectContaining({ + name: `@demo/${packageLeafName}`, + }), + provenance: expect.objectContaining({ + definitionName: additionDefinition.metadata.name, + planningContribution: "planPackageAddition", + }), + }), + ); + await renderProjectAtomically({ + targetRoot: targetDir, + operations: [...addition.operations], + }); + currentBlueprint = addition.blueprint; + } + } finally { + await rm(path.dirname(targetDir), { recursive: true, force: true }); + } + } + }); +}); diff --git a/test/check-fixtures.test.ts b/test/check-fixtures.test.ts deleted file mode 100644 index f1a9a09..0000000 --- a/test/check-fixtures.test.ts +++ /dev/null @@ -1,623 +0,0 @@ -import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { - generatedScenarioId, - generatedScenarioQualityGateSteps, - selectGeneratedScenarios, - type GeneratedScenario, -} from "@ykdz/template-core/generated-scenarios"; -import { execa } from "execa"; -import * as v from "valibot"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); - -type CommandRecord = { - command: string; - args: string[]; - cwd: string; - ci: string | null; -}; - -const commandRecordSchema = v.object({ - command: v.string(), - args: v.array(v.string()), - cwd: v.string(), - ci: v.nullable(v.string()), -}); - -const fixtureScenarios = selectGeneratedScenarios( - loadBuiltInPresetSourceManifest(), - "package-addition-matrix", -).runnable; - -function fixtureScenarioFromCwd(cwd: string): GeneratedScenario | undefined { - const fixtureDirectory = path.basename(cwd).replace(/^fixture-/, ""); - - return fixtureScenarios.find((scenario) => scenario.id === fixtureDirectory); -} - -function scenarioNeedsPlaywrightEnvironment( - scenario: GeneratedScenario, -): boolean { - return generatedScenarioQualityGateSteps( - loadBuiltInPresetSourceManifest(), - scenario, - "/generated-repository", - scenario.addedPreset === undefined ? undefined : "packages/fixture-added", - { - repoRoot, - cliPath: path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - reporter: {}, - runCommand: async () => {}, - }, - ).some((step) => step.environmentNeedKind === "playwright-browser-assets"); -} - -function scenarioNeedsDeployment(scenario: GeneratedScenario): boolean { - return generatedScenarioQualityGateSteps( - loadBuiltInPresetSourceManifest(), - scenario, - "/generated-repository", - scenario.addedPreset === undefined ? undefined : "packages/fixture-added", - { - repoRoot, - cliPath: path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - reporter: {}, - runCommand: async () => {}, - }, - ).some((step) => step.id === "run-deployment-check"); -} - -async function writeExecutable( - filePath: string, - content: string, -): Promise { - await writeFile(filePath, content, "utf8"); - await chmod(filePath, 0o755); -} - -describe("fixture checks", () => { - it("runs machine-verifiable Next Step Instructions for generated repositories", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-fixture-test-"), - ); - const binDir = path.join(workspace, "bin"); - const logPath = path.join(workspace, "commands.jsonl"); - const officialFetchLogPath = path.join(workspace, "official-fetches.txt"); - const replayCachePath = path.join(workspace, "replay-cache"); - const fetchGuardPath = path.join( - workspace, - "guard-official-toolchain-fetches.mjs", - ); - const realPnpm = (await execa("which", ["pnpm"])).stdout; - - await mkdir(binDir, { recursive: true }); - await writeExecutable( - path.join(binDir, "node"), - [ - `#!${process.execPath}`, - 'import { appendFileSync } from "node:fs";', - 'import { spawnSync } from "node:child_process";', - "", - "const args = process.argv.slice(2);", - "appendFileSync(", - " process.env.FIXTURE_COMMAND_LOG,", - " JSON.stringify({", - " command: 'node',", - " args,", - " cwd: process.cwd(),", - " ci: process.env.CI ?? null", - " }) + '\\n'", - ");", - `const result = spawnSync(${JSON.stringify(process.execPath)}, args, {`, - " cwd: process.cwd(),", - " env: process.env,", - " stdio: 'inherit'", - "});", - "process.exit(result.status ?? 1);", - "", - ].join("\n"), - ); - await writeFile( - fetchGuardPath, - [ - 'import { appendFileSync } from "node:fs";', - "", - "const officialToolchainUrls = new Set([", - ' "https://nodejs.org/dist/index.json",', - ' "https://registry.npmjs.org/pnpm"', - "]);", - "const originalFetch = globalThis.fetch;", - "globalThis.fetch = async (input, init) => {", - " const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input?.url;", - " if (officialToolchainUrls.has(url)) {", - " appendFileSync(process.env.OFFICIAL_TOOLCHAIN_FETCH_LOG, `${url}\\n`);", - " return new Response('{}', {", - " status: 200,", - " headers: { 'content-type': 'application/json' }", - " });", - " }", - " return originalFetch(input, init);", - "};", - "", - ].join("\n"), - "utf8", - ); - await writeExecutable( - path.join(binDir, "pnpm"), - [ - "#!/usr/bin/env node", - 'import { appendFileSync } from "node:fs";', - 'import { spawnSync } from "node:child_process";', - "", - "const args = process.argv.slice(2);", - "appendFileSync(", - " process.env.FIXTURE_COMMAND_LOG,", - " JSON.stringify({", - " command: 'pnpm',", - " args,", - " cwd: process.cwd(),", - " ci: process.env.CI ?? null", - " }) + '\\n'", - ");", - "", - "if (", - " args[0] === 'exec' &&", - " args[1] === 'tsx' &&", - " args[2]?.endsWith('/src/cli.ts')", - ") {", - " const result = spawnSync(process.env.REAL_PNPM, args, {", - " cwd: process.cwd(),", - " env: process.env,", - " stdio: 'inherit'", - " });", - " process.exit(result.status ?? 1);", - "}", - "", - "process.exit(0);", - "", - ].join("\n"), - ); - await writeExecutable( - path.join(binDir, "corepack"), - [ - "#!/usr/bin/env node", - 'import { appendFileSync } from "node:fs";', - "appendFileSync(", - " process.env.FIXTURE_COMMAND_LOG,", - " JSON.stringify({", - " command: 'corepack',", - " args: process.argv.slice(2),", - " cwd: process.cwd(),", - " ci: process.env.CI ?? null", - " }) + '\\n'", - ");", - "", - ].join("\n"), - ); - await writeExecutable( - path.join(binDir, "cargo"), - [ - "#!/usr/bin/env node", - 'import { appendFileSync } from "node:fs";', - "appendFileSync(", - " process.env.FIXTURE_COMMAND_LOG,", - " JSON.stringify({", - " command: 'cargo',", - " args: process.argv.slice(2),", - " cwd: process.cwd(),", - " ci: process.env.CI ?? null", - " }) + '\\n'", - ");", - "", - ].join("\n"), - ); - await writeExecutable( - path.join(binDir, "docker"), - [ - "#!/usr/bin/env node", - 'import { appendFileSync } from "node:fs";', - "appendFileSync(", - " process.env.FIXTURE_COMMAND_LOG,", - " JSON.stringify({", - " command: 'docker',", - " args: process.argv.slice(2),", - " cwd: process.cwd(),", - " ci: process.env.CI ?? null", - " }) + '\\n'", - ");", - "process.exit(process.env.FIXTURE_DOCKER_AVAILABLE === '1' ? 0 : 1);", - "", - ].join("\n"), - ); - await writeExecutable( - path.join(binDir, "sh"), - [ - "#!/usr/bin/env node", - 'import { appendFileSync } from "node:fs";', - 'import { spawnSync } from "node:child_process";', - "", - "appendFileSync(", - " process.env.FIXTURE_COMMAND_LOG,", - " JSON.stringify({", - " command: 'sh',", - " args: process.argv.slice(2),", - " cwd: process.cwd(),", - " ci: process.env.CI ?? null", - " }) + '\\n'", - ");", - "", - "const result = spawnSync('/bin/sh', process.argv.slice(2), {", - " cwd: process.cwd(),", - " env: process.env,", - " stdio: 'inherit'", - "});", - "process.exit(result.status ?? 1);", - "", - ].join("\n"), - ); - - const dockerAvailableRun = await execa( - process.execPath, - ["--conditions=source", "packages/checks/src/check-fixtures.ts"], - { - cwd: repoRoot, - env: { - FIXTURE_COMMAND_LOG: logPath, - FIXTURE_DOCKER_AVAILABLE: "1", - OFFICIAL_TOOLCHAIN_FETCH_LOG: officialFetchLogPath, - TEMPLATE_FIXTURE_CONCURRENCY: "4", - TEMPLATE_FIXTURE_REPLAY_CACHE_DIR: replayCachePath, - TEMPLATE_FIXTURE_REPLAY_CACHE_READ: "0", - TEMPLATE_FIXTURE_REPLAY_CACHE_WRITE: "1", - NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${fetchGuardPath}`] - .filter(Boolean) - .join(" "), - PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, - REAL_PNPM: realPnpm, - }, - }, - ); - - expect(dockerAvailableRun.exitCode).toBe(0); - - const records = (await readFile(logPath, "utf8")) - .trim() - .split("\n") - .map( - (line): CommandRecord => - v.parse(commandRecordSchema, JSON.parse(line) as unknown), - ); - const pnpmRecords = records.filter((record) => record.command === "pnpm"); - const officialFetches = await readFile(officialFetchLogPath, "utf8").catch( - (error: unknown) => { - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) { - return ""; - } - - throw error; - }, - ); - - expect(officialFetches).toBe(""); - - expect(records).not.toContainEqual( - expect.objectContaining({ command: "corepack" }), - ); - expect(pnpmRecords).toContainEqual( - expect.objectContaining({ - args: [ - "--filter", - "./apps/web", - "exec", - "playwright", - "install", - "chromium", - ], - }), - ); - - const packageAdditionCommands = records.filter( - (record) => - record.command === "node" && - record.args[0] === "--conditions=source" && - record.args[2] === "add" && - record.args[3] === "package", - ); - const packageAdditionScenarios = fixtureScenarios.filter( - (scenario) => scenario.addedPreset, - ); - expect(packageAdditionCommands).toHaveLength( - packageAdditionScenarios.length, - ); - for (const scenario of packageAdditionScenarios) { - const addedPreset = scenario.addedPreset; - - if (!addedPreset) { - throw new Error(`Scenario ${scenario.id} must add a package.`); - } - - expect( - packageAdditionCommands.some( - (record) => - record.cwd.includes(`fixture-${generatedScenarioId(scenario)}`) && - record.args.includes("--preset") && - record.args.includes(addedPreset), - ), - ).toBe(true); - } - const linkedScenario = packageAdditionScenarios.find( - (scenario) => scenario.linkFrom && scenario.linkFrom.length > 0, - ); - expect(linkedScenario).toBeDefined(); - if (!linkedScenario) { - throw new Error("Expected a linked Package Addition scenario."); - } - expect(packageAdditionCommands).toContainEqual( - expect.objectContaining({ - cwd: expect.stringContaining( - `fixture-${generatedScenarioId(linkedScenario)}`, - ), - args: expect.arrayContaining(["--link-from", "apps/web"]), - }), - ); - - const generatedFixes = pnpmRecords.filter( - (record) => record.args[0] === "run" && record.args[1] === "fix", - ); - expect(generatedFixes).toHaveLength(fixtureScenarios.length); - for (const scenario of fixtureScenarios) { - expect( - generatedFixes.some((record) => - record.cwd.includes(`fixture-${generatedScenarioId(scenario)}`), - ), - ).toBe(true); - } - - const generatedRootChecks = pnpmRecords.filter( - (record) => record.args[0] === "run" && record.args[1] === "check", - ); - const generatedDeploymentChecks = pnpmRecords.filter( - (record) => - record.args[0] === "run" && record.args[1] === "check:deployment", - ); - const deploymentScenarios = fixtureScenarios.filter( - scenarioNeedsDeployment, - ); - expect(generatedDeploymentChecks).toHaveLength(deploymentScenarios.length); - expect( - records.filter( - (record) => record.command === "docker" && record.args[0] === "info", - ), - ).toHaveLength(deploymentScenarios.length); - expect(generatedRootChecks).toHaveLength(fixtureScenarios.length); - for (const scenario of fixtureScenarios) { - expect( - generatedRootChecks.some( - (record) => - record.ci === "1" && - record.cwd.includes(`fixture-${generatedScenarioId(scenario)}`), - ), - ).toBe(true); - } - - expect(records).not.toContainEqual( - expect.objectContaining({ command: "sh" }), - ); - - for (const generatedRootCheck of generatedRootChecks) { - const scenario = fixtureScenarioFromCwd(generatedRootCheck.cwd); - expect(scenario).toBeDefined(); - - const projectRecords = records.filter( - (record) => record.cwd === generatedRootCheck.cwd, - ); - const lockfileInstallIndex = projectRecords.findIndex( - (record) => - record.command === "pnpm" && - record.args.join(" ") === - "install --lockfile-only --prefer-offline --no-frozen-lockfile", - ); - const fetchIndex = projectRecords.findIndex( - (record) => - record.command === "pnpm" && record.args.join(" ") === "fetch", - ); - const offlineInstallIndex = projectRecords.findIndex( - (record) => - record.command === "pnpm" && - record.args.join(" ") === "install --offline --frozen-lockfile", - ); - const playwrightIndex = projectRecords.findIndex( - (record) => - record.command === "pnpm" && - record.args.includes("playwright") && - record.args.includes("install") && - record.args.includes("chromium"), - ); - const checkIndex = projectRecords.findIndex( - (record) => - record.command === "pnpm" && record.args.join(" ") === "run check", - ); - const fixIndex = projectRecords.findIndex( - (record) => - record.command === "pnpm" && record.args.join(" ") === "run fix", - ); - const dockerIndex = projectRecords.findIndex( - (record) => record.command === "docker" && record.args[0] === "info", - ); - const deploymentIndex = projectRecords.findIndex( - (record) => - record.command === "pnpm" && - record.args.join(" ") === "run check:deployment", - ); - - expect(lockfileInstallIndex).toBeGreaterThanOrEqual(0); - expect(fetchIndex).toBeGreaterThan(lockfileInstallIndex); - expect(offlineInstallIndex).toBeGreaterThan(fetchIndex); - expect(fixIndex).toBeGreaterThan(offlineInstallIndex); - const needsPlaywrightEnvironment = - scenario !== undefined && scenarioNeedsPlaywrightEnvironment(scenario); - if (needsPlaywrightEnvironment) { - expect(playwrightIndex).toBeGreaterThan(fixIndex); - expect(checkIndex).toBeGreaterThan(playwrightIndex); - } else { - expect(playwrightIndex).toBe(-1); - expect(checkIndex).toBeGreaterThan(fixIndex); - } - - if (scenario !== undefined && scenarioNeedsDeployment(scenario)) { - expect(dockerIndex).toBeGreaterThan(checkIndex); - expect(deploymentIndex).toBeGreaterThan(dockerIndex); - } else { - expect(dockerIndex).toBe(-1); - expect(deploymentIndex).toBe(-1); - } - } - - await writeFile(logPath, "", "utf8"); - const dockerUnavailableRun = await execa( - process.execPath, - ["--conditions=source", "packages/checks/src/check-fixtures.ts"], - { - cwd: repoRoot, - env: { - FIXTURE_COMMAND_LOG: logPath, - FIXTURE_DOCKER_AVAILABLE: "0", - OFFICIAL_TOOLCHAIN_FETCH_LOG: officialFetchLogPath, - TEMPLATE_FIXTURE_CONCURRENCY: "4", - TEMPLATE_FIXTURE_REPLAY_CACHE_DIR: replayCachePath, - TEMPLATE_FIXTURE_REPLAY_CACHE_READ: "1", - TEMPLATE_FIXTURE_REPLAY_CACHE_WRITE: "0", - NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${fetchGuardPath}`] - .filter(Boolean) - .join(" "), - PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, - REAL_PNPM: realPnpm, - }, - reject: false, - }, - ); - const unavailableRecords = (await readFile(logPath, "utf8")) - .trim() - .split("\n") - .map( - (line): CommandRecord => - v.parse(commandRecordSchema, JSON.parse(line) as unknown), - ); - - expect(dockerUnavailableRun.exitCode).not.toBe(0); - expect(dockerUnavailableRun.stderr).toMatch( - /Deployment check requires the docker-engine Check Environment capability/u, - ); - expect(dockerUnavailableRun.stdout).not.toMatch( - /Replayed passed deployment fixture vike-app \+ ts-lib/u, - ); - expect( - unavailableRecords.filter( - (record) => - record.command === "pnpm" && record.args.join(" ") === "run check", - ), - ).toHaveLength(0); - const unavailableInstalls = unavailableRecords.filter( - (record) => - record.command === "pnpm" && - record.args.join(" ") === - "install --lockfile-only --prefer-offline --no-frozen-lockfile", - ); - expect(unavailableInstalls.length).toBeGreaterThan(0); - expect(unavailableInstalls.length).toBeLessThanOrEqual( - fixtureScenarios.length, - ); - expect(unavailableRecords).not.toContainEqual( - expect.objectContaining({ args: ["run", "check:deployment"] }), - ); - - const reverseReplayCachePath = path.join(workspace, "reverse-replay-cache"); - const runReplayTransition = async ( - dockerAvailable: "0" | "1", - read: "0" | "1", - write: "0" | "1", - ) => { - await writeFile(logPath, "", "utf8"); - const result = await execa( - process.execPath, - ["--conditions=source", "packages/checks/src/check-fixtures.ts"], - { - cwd: repoRoot, - env: { - FIXTURE_COMMAND_LOG: logPath, - FIXTURE_DOCKER_AVAILABLE: dockerAvailable, - OFFICIAL_TOOLCHAIN_FETCH_LOG: officialFetchLogPath, - TEMPLATE_FIXTURE_CONCURRENCY: "4", - TEMPLATE_FIXTURE_REPLAY_CACHE_DIR: reverseReplayCachePath, - TEMPLATE_FIXTURE_REPLAY_CACHE_READ: read, - TEMPLATE_FIXTURE_REPLAY_CACHE_WRITE: write, - NODE_OPTIONS: [ - process.env.NODE_OPTIONS, - `--import=${fetchGuardPath}`, - ] - .filter(Boolean) - .join(" "), - PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, - REAL_PNPM: realPnpm, - }, - reject: false, - }, - ); - const transitionRecords = (await readFile(logPath, "utf8")) - .trim() - .split("\n") - .map( - (line): CommandRecord => - v.parse(commandRecordSchema, JSON.parse(line) as unknown), - ); - return { result, transitionRecords }; - }; - - const unavailableMiss = await runReplayTransition("0", "0", "1"); - expect(unavailableMiss.result.exitCode).not.toBe(0); - expect(unavailableMiss.result.stderr).toMatch( - /Deployment check requires the docker-engine Check Environment capability/u, - ); - expect(unavailableMiss.transitionRecords).not.toContainEqual( - expect.objectContaining({ args: ["run", "check:deployment"] }), - ); - - const availableAfterUnavailable = await runReplayTransition("1", "1", "1"); - expect( - availableAfterUnavailable.transitionRecords.filter( - (record) => record.args.join(" ") === "run check:deployment", - ), - ).toHaveLength(deploymentScenarios.length); - - const availableReplay = await runReplayTransition("1", "1", "0"); - expect( - availableReplay.transitionRecords.filter( - (record) => record.command === "docker" && record.args[0] === "info", - ), - ).toHaveLength(deploymentScenarios.length); - expect(availableReplay.transitionRecords).not.toContainEqual( - expect.objectContaining({ args: ["run", "check:deployment"] }), - ); - expect(availableReplay.result.stdout).toMatch( - /Replayed passed deployment fixture vike-app \+ ts-lib/u, - ); - }, 240_000); -}); diff --git a/test/cutover-cli.test.ts b/test/cutover-cli.test.ts new file mode 100644 index 0000000..6ff27a9 --- /dev/null +++ b/test/cutover-cli.test.ts @@ -0,0 +1,142 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { builtInPresetRegistry } from "@ykdz/template-builtin-presets"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; + +describe("cut-over CLI", () => { + const preset = builtInPresetRegistry.all()[0]!; + const addablePreset = builtInPresetRegistry + .all() + .find((definition) => definition.planPackageAddition !== undefined)!; + it("adds a package without replacing the caller's working-directory inode", async () => { + const workspace = await mkdtemp(path.join(tmpdir(), "template-add-cwd-")); + const target = path.join(workspace, "@acme", "project"); + const cli = path.resolve("packages/cli/src/cli.ts"); + + await execa( + "node", + [ + "--conditions=source", + cli, + "init", + target, + "--preset", + addablePreset.metadata.name, + "--scope", + "acme", + "--yes", + ], + { env: { TEMPLATE_TOOLCHAIN_RESOLUTION: "bundled-fallback" } }, + ); + + const result = await execa( + "bash", + [ + "-lc", + `cd ${JSON.stringify(target)} && node --conditions=source ${JSON.stringify(cli)} add package --preset ${addablePreset.metadata.name} --name second --path packages/second && pwd && test -f packages/second/package.json`, + ], + { env: { TEMPLATE_TOOLCHAIN_RESOLUTION: "bundled-fallback" } }, + ); + + expect(result.stdout).toContain(target); + await expect( + readFile(path.join(target, "packages/second/package.json"), "utf8"), + ).resolves.toContain('"name": "@acme/second"'); + }); + + it("exposes only the supported catalog, initialization, addition, and Blueprint workflows", async () => { + const help = await execa("node", [ + "--conditions=source", + "packages/cli/src/cli.ts", + "--help", + ]); + + expect(help.stdout).toContain("template presets"); + expect(help.stdout).toContain("template init "); + expect(help.stdout).toContain("template add package"); + expect(help.stdout).toContain("template blueprint validate "); + expect(help.stdout).not.toContain("schema preset"); + expect(help.stdout).not.toContain("schema"); + expect(help.stdout).not.toContain("preset validate"); + }); + + it("plans and persists registry-owned Blueprint v2 metadata without Preset identity", async () => { + const workspace = await mkdtemp(path.join(tmpdir(), "template-cutover-")); + const target = path.join(workspace, "library"); + const command = ["--conditions=source", "packages/cli/src/cli.ts"]; + const dryRun = await execa( + "node", + [ + ...command, + "init", + target, + "--preset", + preset.metadata.name, + "--yes", + "--dry-run", + "--json", + ], + { env: { TEMPLATE_TOOLCHAIN_RESOLUTION: "bundled-fallback" } }, + ); + const planned: unknown = JSON.parse(dryRun.stdout); + expect(planned).toMatchObject({ + blueprint: { schemaVersion: 2 }, + generationRecord: { preset: preset.metadata.name }, + }); + expect(JSON.stringify(planned)).not.toContain('"blueprint":{"preset"'); + + await execa( + "node", + [...command, "init", target, "--preset", preset.metadata.name, "--yes"], + { + env: { TEMPLATE_TOOLCHAIN_RESOLUTION: "bundled-fallback" }, + }, + ); + const blueprint = JSON.parse( + await readFile(path.join(target, ".template/blueprint.json"), "utf8"), + ) as Record; + expect(blueprint).toMatchObject({ schemaVersion: 2 }); + expect(blueprint).not.toHaveProperty("preset"); + expect(blueprint).not.toHaveProperty("features"); + }); + + it("writes Template Source-backed next-step instructions unless --no-todo is selected", async () => { + const workspace = await mkdtemp(path.join(tmpdir(), "template-todo-")); + const withTodo = path.join(workspace, "with-todo"); + const withoutTodo = path.join(workspace, "without-todo"); + const command = ["--conditions=source", "packages/cli/src/cli.ts"]; + + const withTodoResult = await execa( + "node", + [...command, "init", withTodo, "--preset", preset.metadata.name, "--yes"], + { env: { TEMPLATE_TOOLCHAIN_RESOLUTION: "bundled-fallback" } }, + ); + expect(withTodoResult.stdout).toContain("Next steps"); + expect(withTodoResult.stdout).toContain("pnpm install"); + await expect( + readFile(path.join(withTodo, "TODO.md"), "utf8"), + ).resolves.toContain("1. `pnpm install`"); + + await execa( + "node", + [ + ...command, + "init", + withoutTodo, + "--preset", + preset.metadata.name, + "--yes", + "--no-todo", + ], + { env: { TEMPLATE_TOOLCHAIN_RESOLUTION: "bundled-fallback" } }, + ); + await expect( + readFile(path.join(withoutTodo, "TODO.md"), "utf8"), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); diff --git a/test/declaration-contracts.test.ts b/test/declaration-contracts.test.ts deleted file mode 100644 index 5facba3..0000000 --- a/test/declaration-contracts.test.ts +++ /dev/null @@ -1,1287 +0,0 @@ -import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - findBuiltInPreset, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import type { - PresetSourceManifest, - PresetSourceManifestPreset, - PresetSourceManifestSharedResource, -} from "@ykdz/template-shared"; -import { - normalizePresetProjectionDeclaration, - validatePresetSourceManifestDeclaration, - validatePresetProjectionDeclaration, - validateProjectBlueprint as validateSharedProjectBlueprint, -} from "@ykdz/template-shared"; -import { execa } from "execa"; -import * as ts from "typescript"; -import * as v from "valibot"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); -const cliPath = path.join(repoRoot, "packages", "cli", "src", "cli.ts"); -const declarationConsumerRoots = [ - "packages/builtin-source/src", - "packages/builtin-source/templates", - "packages/checks/src", - "packages/cli/src", - "packages/core/src", -] as const; -const ignoredDeclarationConsumerDirectories = new Set([ - ".cache", - ".local", - ".turbo", - "dist", - "generated", - "local", - "node_modules", -]); - -function repoPath(filePath: string): string { - return filePath.split(path.sep).join(path.posix.sep); -} - -function isMaintainedTypeScriptFile(filePath: string): boolean { - const normalizedPath = repoPath(filePath); - const fileName = path.posix.basename(normalizedPath); - - return ( - normalizedPath.endsWith(".ts") && - !fileName.startsWith("generated-") && - !fileName.endsWith(".generated.ts") - ); -} - -async function discoverDeclarationConsumerFiles(): Promise { - const files: string[] = []; - - async function visit(relativeDirectory: string): Promise { - const entries = await readdir(path.join(repoRoot, relativeDirectory), { - withFileTypes: true, - }); - - for (const entry of entries.toSorted((left, right) => - left.name.localeCompare(right.name), - )) { - const relativePath = path.join(relativeDirectory, entry.name); - - if (entry.isDirectory()) { - if (!ignoredDeclarationConsumerDirectories.has(entry.name)) { - await visit(relativePath); - } - continue; - } - - if (entry.isFile() && isMaintainedTypeScriptFile(relativePath)) { - files.push(repoPath(relativePath)); - } - } - } - - for (const root of declarationConsumerRoots) { - await visit(root); - } - - return files.toSorted(); -} - -function importedDeclarationNames( - source: string, - moduleName: string, -): string[] { - const names: string[] = []; - const sourceFile = ts.createSourceFile( - "declaration-consumer.ts", - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - - for (const statement of sourceFile.statements) { - if ( - ts.isImportDeclaration(statement) && - ts.isStringLiteral(statement.moduleSpecifier) && - statement.moduleSpecifier.text === moduleName && - statement.importClause?.namedBindings && - ts.isNamedImports(statement.importClause.namedBindings) - ) { - names.push( - ...statement.importClause.namedBindings.elements.map( - (element) => element.propertyName?.text ?? element.name.text, - ), - ); - } - - if ( - ts.isExportDeclaration(statement) && - statement.moduleSpecifier && - ts.isStringLiteral(statement.moduleSpecifier) && - statement.moduleSpecifier.text === moduleName && - statement.exportClause && - ts.isNamedExports(statement.exportClause) - ) { - names.push( - ...statement.exportClause.elements.map( - (element) => element.propertyName?.text ?? element.name.text, - ), - ); - } - } - - return names; -} - -function template(args: string[]) { - return execa("node", ["--conditions=source", cliPath, ...args], { - cwd: repoRoot, - }); -} - -async function expectTemplateFailure( - args: string[], - expectedStderr: string | readonly string[], -): Promise { - try { - await template(args); - } catch (error) { - const stderr = stderrFromError(error); - const expectedMessages = - typeof expectedStderr === "string" ? [expectedStderr] : expectedStderr; - - for (const expectedMessage of expectedMessages) { - expect(stderr).toContain(expectedMessage); - } - - return; - } - - throw new Error(`Expected template command to fail: ${args.join(" ")}`); -} - -function stderrFromError(error: unknown): string { - if ( - typeof error === "object" && - error !== null && - "stderr" in error && - typeof error.stderr === "string" - ) { - return error.stderr; - } - - throw error; -} - -const stringEnumJsonSchema = v.object({ - enum: v.array(v.string()), -}); -const presetJsonSchemaOutput = v.object({ - title: v.string(), - type: v.string(), - required: v.array(v.string()), - properties: v.object({ - supportedProjectKinds: v.object({ - items: stringEnumJsonSchema, - }), - }), -}); -const blueprintJsonSchemaOutput = v.object({ - title: v.string(), - type: v.string(), - required: v.array(v.string()), - properties: v.object({ - projectKind: stringEnumJsonSchema, - }), -}); -const projectionCapabilityJsonSchema = v.looseObject({ - properties: v.looseObject({ - kind: v.object({ - const: v.string(), - }), - }), -}); -const presetSourceJsonSchemaOutput = v.object({ - title: v.string(), - type: v.string(), - required: v.array(v.string()), - properties: v.object({ - presets: v.object({ - items: v.object({ - required: v.array(v.string()), - properties: v.object({ - packageAdditionSupport: stringEnumJsonSchema, - projection: v.object({ - properties: v.object({ - capabilities: v.object({ - items: v.object({ - oneOf: v.array(projectionCapabilityJsonSchema), - }), - }), - }), - }), - }), - }), - }), - }), -}); - -function parseJsonWithSchema( - text: string, - schema: Schema, -): v.InferOutput { - return v.parse(schema, JSON.parse(text) as unknown); -} - -function validPresetSourceManifest(): PresetSourceManifest { - return { - schemaVersion: 1, - name: "custom-source", - sharedResources: [{ id: "shared-oxc-node", path: "shared/oxc/node" }], - presets: [ - { - name: "custom-lib", - title: "Custom library", - description: "A custom strict TypeScript library preset.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: "unsupported", - features: ["strict-typescript", "root-check"], - }, - ], - }; -} - -function firstPreset( - manifest: PresetSourceManifest, -): PresetSourceManifestPreset { - const preset = manifest.presets[0]; - if (!preset) { - throw new Error("Test manifest must contain a preset"); - } - - return preset; -} - -function firstSharedResource( - manifest: PresetSourceManifest, -): PresetSourceManifestSharedResource { - const resource = manifest.sharedResources[0]; - if (!resource) { - throw new Error("Test manifest must contain a shared resource"); - } - - return resource; -} - -describe("declaration contracts", () => { - it("keeps stable declaration vocabulary imported from the Template Contract Library", async () => { - const declarationConsumerFiles = await discoverDeclarationConsumerFiles(); - const forbiddenImports = [ - "@ykdz/template-core/declarations", - "@ykdz/template-core/package-addition-support", - "./declarations.js", - "./package-addition-support.js", - ]; - const offenders: string[] = []; - - for (const file of declarationConsumerFiles) { - const source = await readFile(path.join(repoRoot, file), "utf8"); - - for (const forbiddenImport of forbiddenImports) { - if (source.includes(forbiddenImport)) { - offenders.push(`${file} imports ${forbiddenImport}`); - } - } - - const presetSourceCoreNames = importedDeclarationNames( - source, - "@ykdz/template-core/preset-source", - ); - const importsPresetSourceContractFromCore = presetSourceCoreNames.some( - (name) => - [ - "PresetSourceManifest", - "PresetSourceManifestPreset", - "PresetSourceManifestSharedResource", - "presetSourceManifestJsonSchema", - ].includes(name), - ); - if (importsPresetSourceContractFromCore) { - offenders.push(`${file} imports Preset Source contracts from core`); - } - - const projectionCoreNames = importedDeclarationNames( - source, - "@ykdz/template-core/projection-capabilities", - ); - const importsProjectionContractFromCore = projectionCoreNames.some( - (name) => - [ - "PresetProjectionDeclaration", - "ProjectionCapabilityDeclaration", - "validateProjectionCapabilities", - "normalizePresetProjectionDeclaration", - ].includes(name), - ); - if (importsProjectionContractFromCore) { - offenders.push( - `${file} imports Projection Declaration contracts from core`, - ); - } - } - - expect(declarationConsumerFiles).toContain("packages/cli/src/cli.ts"); - expect(offenders).toEqual([]); - }); - - it("accepts a Project Blueprint through the Template Contract Library", () => { - expect( - validateSharedProjectBlueprint({ - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "multi-package", - features: ["pnpm-catalog"], - }), - ).toEqual({ - ok: true, - value: { - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "multi-package", - features: ["pnpm-catalog"], - }, - }); - }); - - it("validates Preset Source Manifest declarations through the Template Contract Library", () => { - expect( - validatePresetSourceManifestDeclaration(validPresetSourceManifest()), - ).toEqual({ - ok: true, - value: { - ...validPresetSourceManifest(), - presets: [ - { - ...firstPreset(validPresetSourceManifest()), - dependencyCatalog: [], - }, - ], - }, - }); - - const builtInResult = validatePresetSourceManifestDeclaration( - loadBuiltInPresetSourceManifest(), - ); - expect(builtInResult).toMatchObject({ ok: true }); - if (!builtInResult.ok) { - throw new Error("Built-in Preset Source Manifest must be valid"); - } - const builtInProviderPackagePaths = [ - ...new Set( - builtInResult.value.presets.flatMap((preset) => - (preset.projection?.capabilities ?? []).flatMap((capability) => - capability.kind === "workspace-node-packages" - ? (capability.packageLinks ?? []).map( - (link) => link.providerPackagePath, - ) - : [], - ), - ), - ), - ].toSorted(); - expect(builtInProviderPackagePaths).toContain("packages/db"); - expect(Object.hasOwn(builtInResult.value, "fixtureMatrix")).toBe(false); - - const duplicateManifest = validPresetSourceManifest(); - duplicateManifest.presets = [ - ...duplicateManifest.presets, - { ...duplicateManifest.presets[0]! }, - ]; - - expect(validatePresetSourceManifestDeclaration(duplicateManifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets.name", - message: "Duplicate Preset name: custom-lib", - }, - ], - }); - }); - - it("validates Projection Capability Shared Resource ids against Preset Source-local resources", () => { - const manifest = validPresetSourceManifest(); - manifest.sharedResources = [ - { id: "custom-editor-declarations", path: "shared/editor.json" }, - { id: "custom-devcontainer-fragments", path: "shared/devcontainer" }, - ]; - firstPreset(manifest).projection = { - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts"], - }, - { kind: "strict-typescript-root" }, - { - kind: "oxc-format-lint", - editorCustomizationResourceId: "custom-editor-declarations", - }, - { - kind: "node-pnpm-devcontainer", - devcontainerResourceId: "custom-devcontainer-fragments", - }, - { kind: "github-maintenance" }, - ], - }; - - expect(validatePresetSourceManifestDeclaration(manifest)).toEqual({ - ok: true, - value: { - ...manifest, - presets: [ - { - ...firstPreset(manifest), - dependencyCatalog: [], - }, - ], - }, - }); - - const unknownResourceManifest: PresetSourceManifest = { - ...manifest, - presets: [ - { - ...firstPreset(manifest), - projection: { - capabilities: [ - ...firstPreset(manifest).projection!.capabilities.slice(0, 2), - { - kind: "oxc-format-lint", - editorCustomizationResourceId: "missing-editor-declarations", - }, - ...firstPreset(manifest).projection!.capabilities.slice(3), - ], - }, - }, - ], - }; - - expect( - validatePresetSourceManifestDeclaration(unknownResourceManifest), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].projection.capabilities[2].editorCustomizationResourceId", - message: - "Projection Capability oxc-format-lint references undeclared Shared Resource: missing-editor-declarations", - }, - ], - }); - }); - - it("validates Projection Declarations through the Template Contract Library", () => { - const declaration = normalizePresetProjectionDeclaration({ - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts"], - }, - { kind: "strict-typescript-root" }, - { - kind: "oxc-format-lint", - editorCustomizationResourceId: "shared-editor-customization", - }, - { - kind: "node-pnpm-devcontainer", - devcontainerResourceId: "shared-devcontainer", - }, - { kind: "github-maintenance" }, - ], - }); - - expect(validatePresetProjectionDeclaration(declaration)).toEqual({ - ok: true, - value: declaration, - }); - expect( - validatePresetProjectionDeclaration({ - capabilities: [{ kind: "unknown-capability" }], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities[0].kind", - message: "Unknown Projection Capability kind: unknown-capability", - }, - ], - }); - expect( - validatePresetProjectionDeclaration({ - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts"], - }, - { kind: "strict-typescript-root" }, - { kind: "oxc-format-lint" }, - { kind: "node-pnpm-devcontainer" }, - { kind: "github-maintenance" }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities[2].editorCustomizationResourceId", - message: - "oxc-format-lint must declare an editorCustomizationResourceId Shared Resource id", - }, - { - path: "$.capabilities[3].devcontainerResourceId", - message: - "node-pnpm-devcontainer must declare a devcontainerResourceId Shared Resource id", - }, - ], - }); - expect( - validatePresetProjectionDeclaration({ - capabilities: [ - { - kind: "rust-binary-workspace", - workspacePackageGlob: "packages/*", - sourceFiles: ["src/main.rs"], - }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities[0].devcontainerResourceId", - message: - "rust-binary-workspace must declare a devcontainerResourceId Shared Resource id", - }, - { - path: "$.capabilities[0].editorCustomizationResourceId", - message: - "rust-binary-workspace must declare an editorCustomizationResourceId Shared Resource id", - }, - ], - }); - }); - - it("lists the built-in preset catalog", async () => { - const result = await template(["presets"]); - - expect(result.stdout).toContain("Built-in presets"); - expect(result.stdout).toContain("ts-lib"); - expect(result.stdout).toContain("TypeScript library"); - expect(result.stdout).toContain("vue-app"); - expect(result.stdout).toContain("Vue app"); - expect(result.stdout).toContain("vue-hono-app"); - expect(result.stdout).toContain("Vue Hono app"); - expect(result.stdout).toContain("(supported)"); - }); - - it("prints published JSON Schemas for declarations", async () => { - const presetSchema = parseJsonWithSchema( - (await template(["schema", "preset"])).stdout, - presetJsonSchemaOutput, - ); - const blueprintSchema = parseJsonWithSchema( - (await template(["schema", "blueprint"])).stdout, - blueprintJsonSchemaOutput, - ); - - expect(presetSchema).toMatchObject({ - title: "Project Kit Preset File", - type: "object", - }); - expect(presetSchema.required).toContain("name"); - expect(presetSchema.required).toContain("features"); - expect(presetSchema.properties.supportedProjectKinds.items.enum).toEqual([ - "multi-package", - ]); - - expect(blueprintSchema).toMatchObject({ - title: "Project Kit Blueprint", - type: "object", - }); - expect(blueprintSchema.required).toContain("preset"); - expect(blueprintSchema.required).not.toContain("packageManager"); - expect(blueprintSchema.properties.projectKind.enum).toEqual([ - "multi-package", - ]); - }); - - it("prints the Preset Source Manifest JSON Schema", async () => { - const presetSourceSchema = parseJsonWithSchema( - (await template(["schema", "preset-source"])).stdout, - presetSourceJsonSchemaOutput, - ); - - expect(presetSourceSchema).toMatchObject({ - title: "Preset Source Manifest", - type: "object", - }); - expect(presetSourceSchema.required).toContain("presets"); - expect(Object.hasOwn(presetSourceSchema.properties, "fixtureMatrix")).toBe( - false, - ); - expect(presetSourceSchema.properties.presets.items.required).toEqual( - expect.arrayContaining([ - "name", - "title", - "description", - "generation", - "supportedPackageManagers", - "supportedProjectKinds", - "packageAdditionSupport", - "features", - ]), - ); - expect( - presetSourceSchema.properties.presets.items.properties - .packageAdditionSupport.enum, - ).toEqual(["supported", "unsupported"]); - - const capabilitySchemas = - presetSourceSchema.properties.presets.items.properties.projection - .properties.capabilities.items.oneOf; - const nodeWorkspaceSchema = capabilitySchemas.find( - (schema) => schema.properties.kind.const === "workspace-node-packages", - ); - const rustBinaryWorkspaceSchema = capabilitySchemas.find( - (schema) => schema.properties.kind.const === "rust-binary-workspace", - ); - const oxcFormatLintSchema = capabilitySchemas.find( - (schema) => schema.properties.kind.const === "oxc-format-lint", - ); - const nodePnpmDevcontainerSchema = capabilitySchemas.find( - (schema) => schema.properties.kind.const === "node-pnpm-devcontainer", - ); - const builtInProviderPackagePaths = [ - ...new Set( - loadBuiltInPresetSourceManifest().presets.flatMap((preset) => - (preset.projection?.capabilities ?? []).flatMap((capability) => - capability.kind === "workspace-node-packages" - ? (capability.packageLinks ?? []).map( - (link) => link.providerPackagePath, - ) - : [], - ), - ), - ), - ].toSorted(); - const supportedProviderPackagePaths = ["apps/api", "packages/db"]; - - expect(builtInProviderPackagePaths).toContain("packages/db"); - expect(supportedProviderPackagePaths).toEqual( - expect.arrayContaining(builtInProviderPackagePaths), - ); - - expect(nodeWorkspaceSchema).toMatchObject({ - additionalProperties: false, - required: ["kind", "workspacePackageGlob", "packages"], - properties: { - workspacePackageGlob: { const: "apps/*" }, - packages: { - minItems: 1, - items: { - additionalProperties: false, - required: ["kind", "path", "sourceFiles"], - properties: { - kind: { enum: ["hono-api", "vike-app", "vue-app"] }, - path: { enum: ["apps/api", "apps/web"] }, - sourceFiles: { minItems: 1 }, - }, - }, - }, - packageLinks: { - items: { - additionalProperties: false, - required: ["consumerPackagePath", "providerPackagePath"], - properties: { - consumerPackagePath: { const: "apps/web" }, - providerPackagePath: { enum: supportedProviderPackagePaths }, - }, - }, - }, - }, - }); - expect(rustBinaryWorkspaceSchema).toMatchObject({ - additionalProperties: false, - required: [ - "kind", - "workspacePackageGlob", - "sourceFiles", - "devcontainerResourceId", - "editorCustomizationResourceId", - ], - properties: { - devcontainerResourceId: { type: "string", minLength: 1 }, - editorCustomizationResourceId: { type: "string", minLength: 1 }, - }, - }); - expect(oxcFormatLintSchema).toMatchObject({ - additionalProperties: false, - required: ["kind", "editorCustomizationResourceId"], - properties: { - editorCustomizationResourceId: { type: "string", minLength: 1 }, - }, - }); - expect(nodePnpmDevcontainerSchema).toMatchObject({ - additionalProperties: false, - required: ["kind", "devcontainerResourceId"], - properties: { - devcontainerResourceId: { type: "string", minLength: 1 }, - }, - }); - }); - - it("validates Preset Source Manifest references relative to the manifest file through the CLI", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "preset-source-cli-")); - const manifestPath = path.join(workspace, "preset-source.json"); - const manifest = validPresetSourceManifest(); - firstPreset(manifest).source = { - files: ["custom-lib/src/index.ts"], - roots: [], - sharedResources: [], - }; - - await mkdir(path.join(workspace, "shared/oxc/node"), { recursive: true }); - await writeFile( - manifestPath, - `${JSON.stringify(manifest, null, 2)}\n`, - "utf8", - ); - - await expectTemplateFailure( - ["preset-source", "validate", manifestPath], - "Preset custom-lib source file does not exist: custom-lib/src/index.ts", - ); - }); - - it("rejects Preset Source Manifest path escapes through the CLI", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "preset-source-cli-")); - const manifestPath = path.join(workspace, "preset-source.json"); - const manifest = validPresetSourceManifest(); - firstSharedResource(manifest).path = "../shared/oxc/node"; - - await writeFile( - manifestPath, - `${JSON.stringify(manifest, null, 2)}\n`, - "utf8", - ); - - await expectTemplateFailure( - ["preset-source", "validate", manifestPath], - "Preset Source path escapes its source boundary: ../shared/oxc/node", - ); - }); - - it("advertises pnpm support for the Rust preset task layer", () => { - expect(findBuiltInPreset("rust-bin")?.supportedPackageManagers).toEqual([ - "pnpm", - ]); - }); - - it("validates a workspace monorepo JSON preset file through the CLI", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-preset-")); - const presetPath = path.join(workspace, "preset.json"); - await writeFile( - presetPath, - `${JSON.stringify( - { - schemaVersion: 1, - name: "custom-lib", - title: "Custom library", - description: "A custom strict TypeScript library preset.", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - features: ["strict-typescript", "root-check"], - }, - null, - 2, - )}\n`, - ); - - const result = await template(["preset", "validate", presetPath]); - - expect(result.stdout).toContain("Preset file is valid"); - expect(result.stdout).toContain("custom-lib"); - }); - - it("validates a Preset Source Manifest through the CLI", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-preset-source-"), - ); - const manifestPath = path.join(workspace, "preset-source.json"); - await writeFile( - manifestPath, - `${JSON.stringify( - { - schemaVersion: 1, - name: "custom-source", - presets: [ - { - name: "custom-lib", - title: "Custom library", - description: "A custom strict TypeScript library preset.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: "unsupported", - features: ["strict-typescript", "root-check"], - }, - ], - }, - null, - 2, - )}\n`, - ); - - const result = await template(["preset-source", "validate", manifestPath]); - - expect(result.stdout).toContain("Preset Source Manifest is valid"); - expect(result.stdout).toContain("custom-source"); - expect(result.stdout).toContain("custom-lib"); - }); - - it("rejects duplicate Preset Source Manifest array values through the CLI", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-preset-source-duplicates-"), - ); - const manifestPath = path.join(workspace, "preset-source.json"); - await writeFile( - manifestPath, - `${JSON.stringify( - { - schemaVersion: 1, - name: "custom-source", - presets: [ - { - name: "custom-lib", - title: "Custom library", - description: "A custom strict TypeScript library preset.", - generation: "supported", - supportedPackageManagers: ["pnpm", "pnpm"], - supportedProjectKinds: ["multi-package", "multi-package"], - packageAdditionSupport: "unsupported", - features: ["strict-typescript", "strict-typescript"], - }, - ], - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["preset-source", "validate", manifestPath], - [ - "$.presets[0].supportedPackageManagers: Duplicate value: pnpm", - "$.presets[0].supportedProjectKinds: Duplicate value: multi-package", - "$.presets[0].features: Duplicate value: strict-typescript", - ], - ); - }); - - it("validates the built-in Preset Source Manifest through the CLI", async () => { - const result = await template([ - "preset-source", - "validate", - "packages/builtin-source/templates/preset-source.json", - ]); - - expect(result.stdout).toContain("Preset Source Manifest is valid"); - expect(result.stdout).toContain("built-in"); - expect(result.stdout).toContain("ts-lib"); - expect(result.stdout).toContain("vue-hono-app"); - expect(result.stdout).toContain("rust-bin"); - }); - - it("rejects supported built-in Preset Source Manifests without Projection Declarations through the CLI", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-preset-source-bridge-"), - ); - const manifestPath = path.join(workspace, "preset-source.json"); - await writeFile( - manifestPath, - `${JSON.stringify( - { - schemaVersion: 1, - name: "built-in", - presets: [ - { - name: "missing-supported", - title: "Missing supported preset", - description: "A supported built-in preset with no projection.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: "unsupported", - features: [], - }, - ], - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["preset-source", "validate", manifestPath], - "Supported Preset missing-supported must declare a Projection Declaration", - ); - }); - - it("rejects preset files that claim single-package support in V1", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-single-package-preset-"), - ); - const presetPath = path.join(workspace, "preset.json"); - await writeFile( - presetPath, - `${JSON.stringify( - { - schemaVersion: 1, - name: "custom-lib", - title: "Custom library", - description: "A custom strict TypeScript library preset.", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["single-package"], - features: ["strict-typescript", "root-check"], - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["preset", "validate", presetPath], - "single-package Project Shape is unsupported in V1", - ); - }); - - it("rejects future built-in preset references in preset files", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-future-preset-"), - ); - const presetPath = path.join(workspace, "preset.json"); - await writeFile( - presetPath, - `${JSON.stringify( - { - schemaVersion: 1, - name: "node-cli", - title: "Node CLI", - description: "A future built-in preset reference.", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - features: [], - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["preset", "validate", presetPath], - "Preset node-cli is not supported for generation in this version", - ); - }); - - it("rejects Post Commands in user Preset Files", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-preset-post-commands-"), - ); - const presetPath = path.join(workspace, "preset.json"); - await writeFile( - presetPath, - `${JSON.stringify( - { - schemaVersion: 1, - name: "custom-lib", - title: "Custom library", - description: "A custom strict TypeScript library preset.", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["single-package"], - features: ["strict-typescript", "root-check"], - postCommands: [ - { - command: "pnpm", - args: ["install"], - }, - ], - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["preset", "validate", presetPath], - ["Preset file is invalid", "$.postCommands"], - ); - }); - - it("validates a project blueprint against the built-in preset catalog", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-blueprint-")); - const blueprintPath = path.join(workspace, "blueprint.json"); - await writeFile( - blueprintPath, - `${JSON.stringify( - { - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "multi-package", - features: ["strict-typescript", "root-check"], - packages: [{ name: "@demo-lib/demo-lib", path: "packages/demo-lib" }], - }, - null, - 2, - )}\n`, - ); - - const result = await template(["blueprint", "validate", blueprintPath]); - - expect(result.stdout).toContain("Blueprint is valid"); - expect(result.stdout).toContain("ts-lib"); - }); - - it("validates stable Package Definition intent in project blueprints", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-package-definition-"), - ); - const blueprintPath = path.join(workspace, "blueprint.json"); - await writeFile( - blueprintPath, - `${JSON.stringify( - { - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "multi-package", - features: ["strict-typescript", "root-check"], - packages: [ - { - name: "@demo-lib/demo-lib", - path: "packages/demo-lib", - role: "shared-library", - sourcePreset: "ts-lib", - }, - ], - }, - null, - 2, - )}\n`, - ); - - const result = await template(["blueprint", "validate", blueprintPath]); - - expect(result.stdout).toContain("Blueprint is valid"); - expect(result.stdout).toContain("ts-lib"); - }); - - it("validates a multi-package vue-hono-app blueprint", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-fullstack-blueprint-"), - ); - const blueprintPath = path.join(workspace, "blueprint.json"); - await writeFile( - blueprintPath, - `${JSON.stringify( - { - schemaVersion: 1, - preset: "vue-hono-app", - packageManager: "pnpm", - projectKind: "multi-package", - features: ["strict-typescript", "root-check"], - packages: [ - { name: "@demo/web", path: "apps/web" }, - { name: "@demo/api", path: "apps/api" }, - ], - }, - null, - 2, - )}\n`, - ); - - const result = await template(["blueprint", "validate", blueprintPath]); - - expect(result.stdout).toContain("Blueprint is valid"); - expect(result.stdout).toContain("vue-hono-app"); - }); - - it("reports schema validation failures with useful paths", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-invalid-")); - const presetPath = path.join(workspace, "preset.json"); - await writeFile( - presetPath, - `${JSON.stringify( - { - schemaVersion: 2, - name: "", - title: "Broken preset", - description: "Missing required declaration fields.", - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["preset", "validate", presetPath], - ["Preset file is invalid", "$.schemaVersion"], - ); - }); - - it("reports semantic blueprint failures before generation", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-semantic-")); - const blueprintPath = path.join(workspace, "blueprint.json"); - await writeFile( - blueprintPath, - `${JSON.stringify( - { - schemaVersion: 1, - preset: "ts-app", - packageManager: "pnpm", - projectKind: "single-package", - features: ["strict-typescript"], - packages: [ - { name: "app", path: "packages/app" }, - { name: "app", path: "packages/app" }, - ], - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["blueprint", "validate", blueprintPath], - [ - "strict-typescript is not supported by preset ts-app", - "$.packages.name", - ], - ); - }); - - it("rejects single-package blueprints because V1 only supports workspace monorepos", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-single-package-"), - ); - const blueprintPath = path.join(workspace, "blueprint.json"); - await writeFile( - blueprintPath, - `${JSON.stringify( - { - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "single-package", - features: ["strict-typescript", "root-check"], - packages: [{ name: "api", path: "packages/api" }], - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["blueprint", "validate", blueprintPath], - "single-package Project Shape is unsupported in V1", - ); - }); - - it("rejects root Package Boundary paths in project blueprints", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-root-package-boundary-"), - ); - const blueprintPath = path.join(workspace, "blueprint.json"); - await writeFile( - blueprintPath, - `${JSON.stringify( - { - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "multi-package", - features: ["strict-typescript", "root-check"], - packages: [{ name: "app", path: "." }], - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["blueprint", "validate", blueprintPath], - [ - "$.packages[0].path", - "Package Path must be exactly two non-root path segments", - ], - ); - }); - - it("rejects future built-in presets in project blueprints", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-future-blueprint-"), - ); - const blueprintPath = path.join(workspace, "blueprint.json"); - await writeFile( - blueprintPath, - `${JSON.stringify( - { - schemaVersion: 1, - preset: "ts-app", - packageManager: "pnpm", - projectKind: "single-package", - features: [], - packages: [{ name: "app", path: "." }], - }, - null, - 2, - )}\n`, - ); - - await expectTemplateFailure( - ["blueprint", "validate", blueprintPath], - "Preset ts-app is not supported for generation in this version", - ); - }); - - it("accepts only JSON declaration files", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-json-only-")); - const presetPath = path.join(workspace, "preset.yaml"); - await writeFile(presetPath, "schemaVersion: 1\n"); - - await expectTemplateFailure( - ["preset", "validate", presetPath], - "Declaration files must be JSON", - ); - }); -}); diff --git a/test/dependency-catalog.test.ts b/test/dependency-catalog.test.ts deleted file mode 100644 index 316207c..0000000 --- a/test/dependency-catalog.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { loadBuiltInPresetSourceManifest } from "@ykdz/template-builtin-source"; -import { - collectGeneratedManifestCatalogDependencies, - loadTemplateCargoDependencyVersions, - loadTemplateDependencyCatalog, - renderCargoLockForPackage, - renderCargoDependencyTomlEntries, - renderGeneratedPnpmWorkspaceYaml, - selectTemplateCargoDependencyVersions, - selectTemplateDependencyCatalogEntries, -} from "@ykdz/template-core/dependency-catalog"; -import { parse as parseYaml } from "yaml"; - -describe("Template Dependency Catalog projection", () => { - it("renders the explicit Generated Repository pnpm workspace policy", () => { - const workspace = parseYaml( - renderGeneratedPnpmWorkspaceYaml({ dependencies: [] }), - ) as Record; - - expect(workspace).toMatchObject({ - nodeLinker: "isolated", - injectWorkspacePackages: true, - dedupeInjectedDeps: false, - syncInjectedDepsAfterScripts: ["build:run"], - minimumReleaseAge: 1440, - minimumReleaseAgeStrict: true, - }); - }); - - it("only weakens workspace policy through named, evidence-backed exceptions", () => { - expect(() => - renderGeneratedPnpmWorkspaceYaml({ - dependencies: [], - dependencyLinker: { kind: "hoisted", evidence: "" }, - }), - ).toThrow("Hoisted linking requires single-line compatibility evidence"); - - const workspaceYaml = renderGeneratedPnpmWorkspaceYaml({ - dependencies: [], - dependencyLinker: { - kind: "hoisted", - evidence: "upstream-tool#123 cannot resolve symlinked dependencies", - }, - minimumReleaseAgeExclude: ["urgent-security-fix"], - }); - const workspace = parseYaml(workspaceYaml) as Record; - - expect(workspaceYaml).toContain( - "# Hoisted linker compatibility evidence: upstream-tool#123 cannot resolve symlinked dependencies", - ); - expect(workspace).toMatchObject({ - nodeLinker: "hoisted", - minimumReleaseAgeExclude: ["urgent-security-fix"], - }); - }); - - it.each([ - { exclusions: [""], reason: "non-empty" }, - { exclusions: ["react", "react"], reason: "unique" }, - { exclusions: ["react@19.0.0"], reason: "exact npm package identities" }, - { exclusions: ["@scope/*"], reason: "exact npm package identities" }, - { exclusions: ["React"], reason: "exact npm package identities" }, - { exclusions: ["@scope"], reason: "exact npm package identities" }, - ])( - "rejects invalid dependency maturity exclusions: $exclusions", - ({ exclusions, reason }) => { - expect(() => - renderGeneratedPnpmWorkspaceYaml({ - dependencies: [], - minimumReleaseAgeExclude: exclusions, - }), - ).toThrow(reason); - }, - ); - - it("selects only requested dependency versions in stable dependency order", () => { - expect( - selectTemplateDependencyCatalogEntries(["typescript", "@types/node"], { - "@types/node": "^24.0.0", - hono: "^4.10.0", - typescript: "^6.0.3", - }), - ).toEqual({ - "@types/node": "^24.0.0", - typescript: "^6.0.3", - }); - }); - - it("selects Cargo dependency versions from the template Cargo manifest", () => { - expect( - selectTemplateCargoDependencyVersions(["anyhow"], { - anyhow: "1.0.100", - serde: "1.0.228", - }), - ).toEqual({ - anyhow: "1.0.100", - }); - - expect( - renderCargoDependencyTomlEntries(["anyhow"], { anyhow: "1.0.100" }), - ).toEqual(['anyhow = "1.0.100"']); - expect(loadTemplateCargoDependencyVersions()).toHaveProperty("anyhow"); - }); - - it("projects the template Cargo lockfile with the generated package identity", () => { - expect( - renderCargoLockForPackage({ - packageName: "demo-rust", - packageVersion: "0.1.0", - }), - ).toContain('name = "demo-rust"\nversion = "0.1.0"'); - }); - - it("collects generated manifest catalog dependencies and rejects inline specifiers", () => { - expect( - collectGeneratedManifestCatalogDependencies([ - { - dependencies: { - valibot: "catalog:", - }, - devDependencies: { - typescript: "catalog:", - }, - }, - { - devDependencies: { - typescript: "catalog:", - turbo: "catalog:", - }, - }, - ]), - ).toEqual(["turbo", "typescript", "valibot"]); - - expect(() => - collectGeneratedManifestCatalogDependencies([ - { - dependencies: { - valibot: "^1.4.2", - }, - }, - ]), - ).toThrow( - "Generated manifest dependency valibot must use catalog:, got ^1.4.2", - ); - }); - - it("renders Dependency Catalog versions required by built-in Preset Source declarations", () => { - const dependencies = [ - ...new Set( - loadBuiltInPresetSourceManifest().presets.flatMap( - (preset) => preset.dependencyCatalog ?? [], - ), - ), - ].toSorted(); - const templateCatalog = loadTemplateDependencyCatalog(); - const workspaceYaml = renderGeneratedPnpmWorkspaceYaml({ - dependencies, - pnpmfile: ".pnpmfile.cts", - }); - const selectedCatalog = - selectTemplateDependencyCatalogEntries(dependencies); - - expect(selectedCatalog).toMatchObject({ - typescript: "npm:@typescript/typescript6@^6.0.2", - "typescript-7": "npm:typescript@^7.0.2", - }); - expect(workspaceYaml).toContain("autoInstallPeers: false"); - expect(workspaceYaml).toContain('"pinia>typescript": "-"'); - expect(workspaceYaml).toContain('"valibot>typescript": "-"'); - expect(workspaceYaml).toContain('"vue>typescript": "-"'); - expect(workspaceYaml).toContain("pnpmfile: .pnpmfile.cts"); - expect(selectedCatalog).toEqual( - Object.fromEntries( - dependencies.map((dependency) => [ - dependency, - templateCatalog[dependency], - ]), - ), - ); - - for (const [dependency, version] of Object.entries(selectedCatalog)) { - const key = dependency.startsWith("@") - ? JSON.stringify(dependency) - : dependency; - - expect(workspaceYaml).toContain(`${key}: ${version}`); - } - }); -}); diff --git a/test/dependency-maintenance-policy.test.ts b/test/dependency-maintenance-policy.test.ts deleted file mode 100644 index 57d1857..0000000 --- a/test/dependency-maintenance-policy.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { projectDependabotConfig } from "@ykdz/template-core/project-github"; -import * as v from "valibot"; -import { parse as parseYaml } from "yaml"; - -type DependabotConfig = { - version: number; - updates: DependabotUpdate[]; -}; - -type DependabotUpdate = { - "package-ecosystem": string; - directory: string; - schedule: { interval: string }; - groups?: - | Record< - string, - { - patterns: string[]; - } - > - | undefined; - ignore?: - | { - "dependency-name": string; - "update-types": string[]; - }[] - | undefined; -}; - -const generatedRepositoryPresetNames = [ - "rust-bin", - "ts-lib", - "vue-app", - "vue-hono-app", -]; -const dependabotConfigSchema = v.object({ - version: v.number(), - updates: v.array( - v.object({ - "package-ecosystem": v.string(), - directory: v.string(), - schedule: v.object({ interval: v.string() }), - groups: v.optional( - v.record( - v.string(), - v.object({ - patterns: v.array(v.string()), - }), - ), - ), - ignore: v.optional( - v.array( - v.object({ - "dependency-name": v.string(), - "update-types": v.array(v.string()), - }), - ), - ), - }), - ), -}); - -function projectedDependabotConfig( - presetName: string, - projectName = "generated-repository", -): DependabotConfig { - const projection = findBuiltInPresetProjection(presetName); - const plan = projection?.project({ - projectName: { kind: "ProjectName", value: projectName }, - preset: presetName, - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection.blueprint({ targetDir: projectName }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@10.34.4" }, - source: "bundled-fallback", - diagnostics: [], - }, - }); - if (!plan) { - throw new Error(`${presetName} did not project .github/dependabot.yml`); - } - - return v.parse( - dependabotConfigSchema, - parseYaml( - projectDependabotConfig(plan.dependencyMaintenancePolicy), - ) as unknown, - ); -} - -function ecosystems(config: DependabotConfig): string[] { - return config.updates.map((update) => update["package-ecosystem"]); -} - -function updateFor( - config: DependabotConfig, - ecosystem: string, -): DependabotUpdate { - const update = config.updates.find( - (candidate) => candidate["package-ecosystem"] === ecosystem, - ); - - if (!update) { - throw new Error(`Missing ${ecosystem} Dependabot update`); - } - - return update; -} - -describe("Generated Repository dependency maintenance policy", () => { - it("maintains GitHub Actions for every generated repository that has real workflows", () => { - for (const presetName of generatedRepositoryPresetNames) { - const projection = findBuiltInPresetProjection(presetName); - const plan = projection?.project({ - projectName: { kind: "ProjectName", value: "generated-repository" }, - preset: presetName, - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection.blueprint({ targetDir: "generated-repository" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@10.34.4", - }, - source: "bundled-fallback", - diagnostics: [], - }, - }); - const hasWorkflow = plan?.operations.some( - (operation) => - (operation.kind === "writeText" || - operation.kind === "copyFile" || - operation.kind === "writeTextTemplate") && - operation.to.startsWith(".github/workflows/"), - ); - - expect({ hasWorkflow, presetName }).toMatchObject({ - hasWorkflow: true, - }); - expect(ecosystems(projectedDependabotConfig(presetName))).toContain( - "github-actions", - ); - expect({ - directory: updateFor( - projectedDependabotConfig(presetName), - "github-actions", - ).directory, - presetName, - }).toMatchObject({ directory: "/" }); - } - }); - - it("maintains Node repositories through npm, GitHub Actions, and Docker without letting Dependabot move the Node baseline", () => { - const dependabot = projectedDependabotConfig("ts-lib"); - - expect(ecosystems(dependabot)).toEqual(["npm", "github-actions", "docker"]); - expect(ecosystems(dependabot)).not.toContain("devcontainers"); - expect(updateFor(dependabot, "docker").directory).toBe("/.devcontainer"); - expect(updateFor(dependabot, "npm").groups).toMatchObject({ - drizzle: { - patterns: ["drizzle-*", "drizzle-orm"], - }, - }); - expect(updateFor(dependabot, "npm").ignore).toContainEqual({ - "dependency-name": "@types/node", - "update-types": ["version-update:semver-major"], - }); - expect(updateFor(dependabot, "docker").ignore).toContainEqual({ - "dependency-name": "mcr.microsoft.com/devcontainers/typescript-node", - "update-types": ["version-update:semver-major"], - }); - }); - - it("maintains Rust repositories through npm, Cargo, GitHub Actions, Docker, and rust-toolchain", () => { - const dependabot = projectedDependabotConfig("rust-bin"); - - expect(ecosystems(dependabot)).toEqual([ - "npm", - "cargo", - "github-actions", - "docker", - "rust-toolchain", - ]); - expect(ecosystems(dependabot)).not.toContain("devcontainers"); - expect(updateFor(dependabot, "docker").directory).toBe("/.devcontainer"); - expect(updateFor(dependabot, "rust-toolchain").directory).toBe("/"); - expect(updateFor(dependabot, "docker").ignore).toContainEqual({ - "dependency-name": "mcr.microsoft.com/devcontainers/typescript-node", - "update-types": ["version-update:semver-major"], - }); - }); - - it("maintains the Rust package manifest through Cargo Dependabot in its generated package boundary", () => { - const dependabot = projectedDependabotConfig("rust-bin", "My Demo App"); - - expect(updateFor(dependabot, "cargo").directory).toBe( - "/packages/my-demo-app", - ); - }); - - it("uses Dockerfile-first Dependabot policy for every supported generated repository", () => { - for (const presetName of generatedRepositoryPresetNames) { - const dependabot = projectedDependabotConfig(presetName); - - expect(ecosystems(dependabot)).toContain("docker"); - expect(ecosystems(dependabot)).not.toContain("devcontainers"); - expect({ - directory: updateFor(dependabot, "docker").directory, - presetName, - }).toMatchObject({ directory: "/.devcontainer" }); - } - }); -}); diff --git a/test/deployment-gate.test.ts b/test/deployment-gate.test.ts new file mode 100644 index 0000000..d948cd4 --- /dev/null +++ b/test/deployment-gate.test.ts @@ -0,0 +1,69 @@ +import path from "node:path"; + +import { + builtInPresetRegistry, + createGenerationContext, + planGeneratedRepositoryInitialization, +} from "@ykdz/template-builtin-presets"; +import { describe, expect, it } from "vitest"; + +import { runRequiredDeploymentQualityGate } from "../packages/checks/src/check-generated-registry.ts"; + +function deploymentPlan() { + const definition = builtInPresetRegistry.all().find( + (candidate) => + planGeneratedRepositoryInitialization({ + definition: candidate, + context: createGenerationContext({ + targetDir: path.join("generated-repository", "deployment-gate"), + toolchain: { + nodeLtsMajor: "24", + packageManagerPin: "pnpm@11.11.0", + }, + }), + }).deploymentChecks.length > 0, + ); + if (definition === undefined) { + throw new Error("A deployment Definition is required"); + } + return planGeneratedRepositoryInitialization({ + definition, + context: createGenerationContext({ + targetDir: path.join("generated-repository", "deployment-gate"), + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }), + }); +} + +describe("deployment quality gate", () => { + it("fails explicitly when Docker is unavailable instead of reporting a semantic skip", async () => { + await expect( + runRequiredDeploymentQualityGate({ + plan: deploymentPlan(), + projectDir: "/tmp/deployment-quality-gate", + run: async () => { + throw new Error("docker socket unavailable"); + }, + }), + ).rejects.toThrow(/Docker is required.*check:deployment was not executed/u); + }); + + it("runs the generated deployment gate after Docker availability is confirmed", async () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + await runRequiredDeploymentQualityGate({ + plan: deploymentPlan(), + projectDir: "/tmp/deployment-quality-gate", + run: async (command, args) => { + calls.push({ command, args }); + }, + }); + + expect(calls).toEqual([ + { + command: "docker", + args: ["version", "--format", "{{.Server.Version}}"], + }, + { command: "pnpm", args: ["run", "check:deployment"] }, + ]); + }); +}); diff --git a/test/devcontainer.test.ts b/test/devcontainer.test.ts deleted file mode 100644 index b8ca2e4..0000000 --- a/test/devcontainer.test.ts +++ /dev/null @@ -1,520 +0,0 @@ -import { readFileSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { builtInPresetSourceRoot } from "@ykdz/template-builtin-source"; -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { loadTemplateDependencyCatalog } from "@ykdz/template-core/dependency-catalog"; -import { - browserTestToolLayer, - checkedDockerfileFirstNodePnpmDevcontainer, - composeDevelopmentContainerDockerfile, - dockerfileFirstNodePnpmDevcontainer, - dockerfileFirstRustPnpmDevcontainer, - nodePnpmToolLayer, - rustToolLayer, -} from "@ykdz/template-core/devcontainer"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import { interpretPresetProjectionDeclaration } from "@ykdz/template-core/projection-capabilities"; -import * as v from "valibot"; - -const playwrightCliPackage = `@playwright/test@${ - loadTemplateDependencyCatalog()["@playwright/test"] -}`; - -const devcontainerBuildSchema = v.object({ - args: v.optional(v.record(v.string(), v.string())), -}); -const devcontainerSchema = v.looseObject({ - build: v.optional(devcontainerBuildSchema), - mounts: v.optional(v.array(v.string())), -}); -const packageJsonWithScriptsSchema = v.object({ - scripts: v.optional(v.record(v.string(), v.string())), -}); -const builtInDevcontainerFragments = { - sourceRoot: "devcontainer:shared-devcontainer", - nodePnpm: { - from: "node-pnpm.Dockerfile", - text: readFileSync( - builtInPresetSourceRoot("shared", "devcontainer", "node-pnpm.Dockerfile"), - "utf8", - ), - }, - browserTest: { - from: "browser-test.Dockerfile", - text: readFileSync( - builtInPresetSourceRoot( - "shared", - "devcontainer", - "browser-test.Dockerfile", - ), - "utf8", - ), - }, - rust: { - from: "rust.Dockerfile", - text: readFileSync( - builtInPresetSourceRoot("shared", "devcontainer", "rust.Dockerfile"), - "utf8", - ), - }, -}; - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -describe("Development Container planning", () => { - it("resolves Dockerfile fragments through a Preset Source-local Shared Resource id", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "devcontainer-resource-boundary-"), - ); - const devcontainerResource = path.join(workspace, "custom-dev-resource"); - const editorResource = path.join(workspace, "custom-editor.json"); - - await mkdir(devcontainerResource, { recursive: true }); - await writeFile( - path.join(devcontainerResource, "node-pnpm.Dockerfile"), - [ - "ARG NODE_VERSION", - "FROM node:${NODE_VERSION}-bookworm-slim", - "ARG PACKAGE_MANAGER_PIN", - "RUN corepack enable && corepack prepare ${PACKAGE_MANAGER_PIN} --activate", - "", - ].join("\n"), - ); - await writeFile( - editorResource, - `${JSON.stringify( - { - capabilities: { - "oxc-format-lint": { - extensions: ["acme.boundary-editor"], - settings: { "acme.boundary": true }, - }, - vue: { extensions: [], settings: {} }, - tailwind: { extensions: [], settings: {} }, - "rust-tooling": { extensions: [], settings: {} }, - vitest: { extensions: [], settings: {} }, - }, - }, - null, - 2, - )}\n`, - ); - - const plan = interpretPresetProjectionDeclaration({ - preset: { - name: "custom-lib", - title: "Custom library", - description: "Custom library.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: "unsupported", - features: ["strict-typescript", "oxc-format-lint", "devcontainer"], - }, - declaration: { - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts"], - }, - { kind: "strict-typescript-root" }, - { - kind: "oxc-format-lint", - editorCustomizationResourceId: "custom-editor", - }, - { - kind: "node-pnpm-devcontainer", - devcontainerResourceId: "custom-devcontainer", - }, - { kind: "github-maintenance" }, - ], - }, - context: assembleGenerationContext({ - targetDir: path.join(workspace, "generated"), - blueprint: { - schemaVersion: 1, - preset: "custom-lib", - packageManager: "pnpm", - projectKind: "multi-package", - features: [], - }, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@10.34.4", - }, - source: "bundled-fallback", - diagnostics: [], - }, - }), - sourceRoots: { - preset: () => workspace, - sharedOxc: () => workspace, - sharedResource: (resourceId) => - resourceId === "custom-devcontainer" - ? devcontainerResource - : resourceId === "custom-editor" - ? editorResource - : resourceId === "shared-toolchain-maintenance" - ? workspace - : undefined, - }, - }); - const dockerfileOperation = plan.operations.find( - (operation) => - operation.kind === "writeTextFromFragments" && - operation.to === ".devcontainer/Dockerfile", - ); - const devcontainerOperation = plan.operations.find( - (operation) => - operation.kind === "writeJson" && - operation.to === ".devcontainer/devcontainer.json", - ); - - expect(plan.sourceRoots?.["devcontainer:custom-devcontainer"]).toBe( - devcontainerResource, - ); - expect(dockerfileOperation).toEqual({ - kind: "writeTextFromFragments", - to: ".devcontainer/Dockerfile", - fragments: [ - { - sourceRoot: "devcontainer:custom-devcontainer", - from: "node-pnpm.Dockerfile", - }, - ], - }); - expect(devcontainerOperation).toMatchObject({ - value: { - customizations: { - vscode: { - extensions: ["acme.boundary-editor"], - settings: { - "acme.boundary": true, - }, - }, - }, - }, - }); - }); - - it("validates that exactly one Dockerfile base layer supplies the base image", () => { - expect(() => - composeDevelopmentContainerDockerfile({ - layers: [ - { kind: "base", name: "node", text: "FROM node:24-bookworm-slim\n" }, - { kind: "base", name: "other", text: "FROM debian:bookworm-slim\n" }, - ], - }), - ).toThrow("exactly one base layer"); - }); - - it("rejects a Dockerfile base fragment with multiple base image instructions", () => { - expect(() => - composeDevelopmentContainerDockerfile({ - layers: [ - { - kind: "base", - name: "node", - text: - "FROM node:24-bookworm-slim\n" + - "RUN corepack enable\n" + - "FROM debian:bookworm-slim\n", - }, - ], - }), - ).toThrow("exactly one base layer"); - }); - - it("rejects runtime service instructions in Dockerfile capability layers", () => { - expect(() => - composeDevelopmentContainerDockerfile({ - layers: [ - { kind: "base", name: "node", text: "FROM node:24-bookworm-slim\n" }, - { kind: "capability", name: "service", text: "EXPOSE 3000\n" }, - ], - }), - ).toThrow("service must not use runtime Dockerfile instruction EXPOSE"); - }); - - it("plans a Dockerfile-first Node pnpm tool layer from the toolchain baseline", () => { - const plan = checkedDockerfileFirstNodePnpmDevcontainer({ - name: "demo", - layer: nodePnpmToolLayer({ - nodeVersion: "24", - packageManagerPin: "pnpm@10.34.4", - }), - dockerfileFragments: builtInDevcontainerFragments, - extensions: ["oxc.oxc-vscode"], - settings: { "oxc.enable": true }, - }); - - expect(plan.devcontainer).toEqual({ - name: "demo", - build: { - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@10.34.4", - }, - }, - customizations: { - vscode: { - extensions: ["oxc.oxc-vscode"], - settings: { "oxc.enable": true }, - }, - }, - }); - expect(plan.devcontainer).not.toHaveProperty("features"); - expect(plan.dockerfile).toContain("ARG NODE_VERSION"); - expect(plan.dockerfile).toContain( - "FROM node:${NODE_VERSION}-bookworm-slim", - ); - expect(plan.dockerfile).toContain('ENV COREPACK_HOME="/corepack"'); - expect(plan.dockerfile).toContain('ENV PNPM_HOME="/pnpm"'); - expect(plan.dockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(plan.dockerfile).toContain( - 'corepack prepare "${PACKAGE_MANAGER_PIN}" --activate', - ); - expect(plan.dockerfile).not.toContain("typescript-node"); - expect(plan.dockerfile).not.toMatch( - /npm install -g|pnpm add -g|corepack prepare (?!"?\$\{PACKAGE_MANAGER_PIN\}"?)/, - ); - expect(plan.dockerfile).not.toContain("turbo"); - expect(plan.dockerfile).not.toContain("typescript"); - expect(plan.dockerfile).not.toContain("eslint"); - expect(plan.dockerfile).not.toContain("vitest"); - expect(plan.dockerfile).not.toContain("libnss3"); - expect(plan.dockerfile).not.toContain("xvfb"); - }); - - it("plans a checked browser-test Dockerfile layer through Playwright dependency installation", () => { - const plan = checkedDockerfileFirstNodePnpmDevcontainer({ - name: "demo", - layer: nodePnpmToolLayer({ - nodeVersion: "24", - packageManagerPin: "pnpm@10.34.4", - }), - dockerfileFragments: builtInDevcontainerFragments, - additionalLayers: [browserTestToolLayer()], - extensions: [], - }); - - expect(plan.dockerfile).toContain( - "FROM node:${NODE_VERSION}-bookworm-slim", - ); - expect(plan.devcontainer).toMatchObject({ - build: { - args: { - PLAYWRIGHT_CLI_PACKAGE: playwrightCliPackage, - }, - }, - }); - expect(plan.dockerfile).toContain("ARG PLAYWRIGHT_CLI_PACKAGE"); - expect(plan.dockerfile).toContain( - 'npx --yes --package "${PLAYWRIGHT_CLI_PACKAGE}" playwright install-deps chromium', - ); - expect(plan.dockerfile).not.toContain( - "npx --yes playwright install-deps chromium", - ); - expect(plan.dockerfile).not.toContain("libnss3"); - expect(plan.dockerfile).not.toContain("xvfb"); - expect(plan.dockerfileOperation).toEqual({ - kind: "writeTextFromFragments", - to: ".devcontainer/Dockerfile", - fragments: [ - { - sourceRoot: "devcontainer:shared-devcontainer", - from: "node-pnpm.Dockerfile", - }, - { - sourceRoot: "devcontainer:shared-devcontainer", - from: "browser-test.Dockerfile", - }, - ], - }); - }); - - it("keeps the legacy Node pnpm helper free of browser-test apt package ownership", () => { - const legacyCall = { - name: "demo", - layer: nodePnpmToolLayer({ - nodeVersion: "24", - packageManagerPin: "pnpm@10.34.4", - }), - additionalLayers: [browserTestToolLayer()], - extensions: [], - } as Parameters[0] & { - readonly additionalLayers: readonly ReturnType< - typeof browserTestToolLayer - >[]; - }; - const plan = dockerfileFirstNodePnpmDevcontainer(legacyCall); - - expect(plan.dockerfile).not.toContain("libnss3"); - expect(plan.dockerfile).not.toContain("libgbm1"); - expect(plan.dockerfile).not.toContain("xvfb"); - expect(plan.dockerfile).not.toContain("playwright install-deps chromium"); - }); - - it("plans a Dockerfile-first Rust tool layer with Node pnpm task tooling", () => { - const plan = dockerfileFirstRustPnpmDevcontainer({ - name: "demo", - nodeLayer: nodePnpmToolLayer({ - nodeVersion: "24", - packageManagerPin: "pnpm@10.34.4", - }), - rustLayer: rustToolLayer({ toolchain: "stable" }), - dockerfileFragments: builtInDevcontainerFragments, - extensions: ["rust-lang.rust-analyzer"], - settings: { "rust-analyzer.check.command": "clippy" }, - }); - - expect(plan.devcontainer).toEqual({ - name: "demo", - build: { - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@10.34.4", - RUST_TOOLCHAIN: "stable", - }, - }, - customizations: { - vscode: { - extensions: ["rust-lang.rust-analyzer"], - settings: { "rust-analyzer.check.command": "clippy" }, - }, - }, - mounts: [ - "source=${localWorkspaceFolderBasename}-cargo-registry,target=/usr/local/cargo/registry,type=volume", - "source=${localWorkspaceFolderBasename}-cargo-git,target=/usr/local/cargo/git,type=volume", - "source=${localWorkspaceFolderBasename}-target,target=${containerWorkspaceFolder}/target,type=volume", - ], - }); - expect(plan.devcontainer).not.toHaveProperty("features"); - expect(plan.dockerfile).toContain( - "FROM node:${NODE_VERSION}-bookworm-slim", - ); - expect(plan.dockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(plan.dockerfile).toContain("ARG RUST_TOOLCHAIN"); - expect(plan.dockerfile).toContain( - "rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal --component rustfmt --component clippy", - ); - expect(plan.dockerfile).toContain("ENV CARGO_HOME=/usr/local/cargo"); - expect(plan.dockerfile).toContain("gcc"); - expect(plan.dockerfile).toContain("libc6-dev"); - expect(plan.dockerfile).not.toContain("typescript-node"); - expect(plan.dockerfile).not.toContain("ARG PLAYWRIGHT_CLI_PACKAGE"); - expect(plan.dockerfile).not.toContain("playwright install-deps chromium"); - expect(plan.dockerfile).not.toMatch( - /\b(build-essential|pkg-config|libssl-dev)\b/, - ); - expect(plan.dockerfileOperation).toEqual({ - kind: "writeTextFromFragments", - to: ".devcontainer/Dockerfile", - fragments: [ - { - sourceRoot: "devcontainer:shared-devcontainer", - from: "node-pnpm.Dockerfile", - }, - { - sourceRoot: "devcontainer:shared-devcontainer", - from: "rust.Dockerfile", - }, - ], - }); - }); - - it("renders the rust-bin preset with the Rust layer on the shared Node pnpm base", async () => { - const targetDir = await mkdtemp(path.join(tmpdir(), "rust-bin-preset-")); - const projection = findBuiltInPresetProjection("rust-bin")!; - const blueprint = projection.blueprint({ targetDir }); - const plan = projection.project( - assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@10.34.4", - }, - source: "bundled-fallback", - diagnostics: [], - }, - }), - ); - - await projection.render({ targetDir, plan }); - - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - const devcontainer = await readJsonWithSchema( - path.join(targetDir, ".devcontainer/devcontainer.json"), - devcontainerSchema, - ); - const rootPackageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonWithScriptsSchema, - ); - const rustPackagePath = blueprint.packages?.[0]?.path; - - if (rustPackagePath === undefined) { - throw new Error("rust-bin blueprint must include a workspace package."); - } - - const rustPackageJson = await readJsonWithSchema( - path.join(targetDir, rustPackagePath, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain("ARG RUST_TOOLCHAIN"); - expect(dockerfile).toContain( - "rustup toolchain install ${RUST_TOOLCHAIN} --profile minimal --component rustfmt --component clippy", - ); - expect(dockerfile).not.toContain("typescript-node"); - expect(dockerfile).not.toContain("ARG PLAYWRIGHT_CLI_PACKAGE"); - expect(dockerfile).not.toContain("playwright install-deps chromium"); - expect(devcontainer.build?.args).toMatchObject({ - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@10.34.4", - RUST_TOOLCHAIN: "stable", - }); - expect(devcontainer.build?.args).not.toHaveProperty( - "PLAYWRIGHT_CLI_PACKAGE", - ); - expect(devcontainer.mounts).toEqual( - expect.arrayContaining([ - "source=${localWorkspaceFolderBasename}-cargo-registry,target=/usr/local/cargo/registry,type=volume", - "source=${localWorkspaceFolderBasename}-cargo-git,target=/usr/local/cargo/git,type=volume", - "source=${localWorkspaceFolderBasename}-target,target=${containerWorkspaceFolder}/target,type=volume", - ]), - ); - expect(rootPackageJson.scripts?.check).toContain("turbo run"); - expect(rootPackageJson.scripts?.check).toContain("check:run"); - expect(rustPackageJson.scripts?.["lint:run"]).toContain("cargo clippy"); - }); -}); diff --git a/test/editor-customization.test.ts b/test/editor-customization.test.ts deleted file mode 100644 index 3a96150..0000000 --- a/test/editor-customization.test.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { - editorCustomizationForCapabilities, - loadEditorCustomizationDeclarations, - type EditorCustomizationCapability, - type EditorCustomizationOptions, -} from "@ykdz/template-core/editor-customization"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import { interpretPresetProjectionDeclaration } from "@ykdz/template-core/projection-capabilities"; -import { renderProject } from "@ykdz/template-core/renderer"; -import * as v from "valibot"; - -const settingsSchema = v.record(v.string(), v.unknown()); -const devcontainerEditorCustomizationSchema = v.object({ - customizations: v.object({ - vscode: v.object({ - extensions: v.array(v.string()), - settings: settingsSchema, - }), - }), -}); -const workspaceExtensionsSchema = v.object({ - recommendations: v.array(v.string()), -}); - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -async function readDevcontainerEditorCustomization(filePath: string) { - return readJsonWithSchema(filePath, devcontainerEditorCustomizationSchema); -} - -async function readWorkspaceExtensions(filePath: string) { - return readJsonWithSchema(filePath, workspaceExtensionsSchema); -} - -async function readWorkspaceSettings(filePath: string) { - return readJsonWithSchema(filePath, settingsSchema); -} - -async function renderPresetProject(preset: string): Promise { - const projection = findBuiltInPresetProjection(preset); - if (!projection) { - throw new Error(`Unknown built-in preset: ${preset}`); - } - - const targetRoot = await mkdtemp( - path.join(tmpdir(), "editor-customization-"), - ); - const blueprint = projection.blueprint({ targetDir: targetRoot }); - const plan = projection.project( - assembleGenerationContext({ - targetDir: targetRoot, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@10.34.4" }, - source: "bundled-fallback", - diagnostics: [], - }, - }), - ); - - await renderProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot, - operations: [...plan.operations], - }); - - return targetRoot; -} - -function oxcConfigPathSettings( - settings: Record, -): { key: string; value: string }[] { - return Object.entries(settings) - .filter( - (entry): entry is [string, string] => - entry[0].startsWith("oxc.") && - entry[0].endsWith("configPath") && - typeof entry[1] === "string", - ) - .map(([key, value]) => ({ key, value })); -} - -const forbiddenOptionalExtensions = [ - "vadimcn.vscode-lldb", - "serayuzgur.crates", - "redhat.vscode-yaml", - "fill-labs.dependi", - "selemondev.shadcn-vue", -] as const; - -function builtInEditorCustomizationResourcePath(): string { - const resourcePath = builtInPresetProjectionSourceRoots().sharedResource( - "shared-editor-customization", - ); - - if (resourcePath === undefined) { - throw new Error("Missing built-in Editor Customization Shared Resource"); - } - - return resourcePath; -} - -const builtInEditorCustomizationDeclarations = - loadEditorCustomizationDeclarations(builtInEditorCustomizationResourcePath()); - -function builtInEditorCustomizationForCapabilities( - capabilities: readonly EditorCustomizationCapability[], - options?: EditorCustomizationOptions, -) { - return editorCustomizationForCapabilities( - capabilities, - builtInEditorCustomizationDeclarations, - options, - ); -} - -function expectForbiddenOptionalExtensionsAbsent( - extensions: readonly string[], -): void { - for (const extension of forbiddenOptionalExtensions) { - expect(extensions).not.toContain(extension); - } -} - -function supportedPresetNamesWithOxcFormatLint(): string[] { - return loadBuiltInPresetSourceManifest() - .presets.filter( - (preset) => - preset.generation === "supported" && - (preset.projection?.capabilities ?? []).some( - (capability) => capability.kind === "oxc-format-lint", - ), - ) - .map((preset) => preset.name); -} - -describe("editor customization", () => { - it("projects editor settings from a Preset Source-local Shared Resource id", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "editor-customization-resource-boundary-"), - ); - const devcontainerResource = path.join(workspace, "local-devcontainer"); - const editorResource = path.join(workspace, "local-capabilities.json"); - - await mkdir(devcontainerResource, { recursive: true }); - await writeFile( - path.join(devcontainerResource, "node-pnpm.Dockerfile"), - [ - "ARG NODE_VERSION", - "FROM node:${NODE_VERSION}-bookworm-slim", - "ARG PACKAGE_MANAGER_PIN", - 'corepack enable --install-directory "$PNPM_HOME"', - "", - ].join("\n"), - ); - await writeFile( - editorResource, - `${JSON.stringify( - { - capabilities: { - "oxc-format-lint": { - extensions: ["acme.local-oxc"], - settings: { "acme.localOxc": true }, - }, - vue: { extensions: [], settings: {} }, - tailwind: { extensions: [], settings: {} }, - "rust-tooling": { extensions: [], settings: {} }, - vitest: { extensions: [], settings: {} }, - }, - }, - null, - 2, - )}\n`, - ); - - const plan = interpretPresetProjectionDeclaration({ - preset: { - name: "custom-lib", - title: "Custom library", - description: "Custom library.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: "unsupported", - features: [ - "strict-typescript", - "oxc-format-lint", - "devcontainer", - "github-actions", - "dependabot", - ], - }, - declaration: { - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts"], - }, - { kind: "strict-typescript-root" }, - { - kind: "oxc-format-lint", - editorCustomizationResourceId: "local-editor-resource", - }, - { - kind: "node-pnpm-devcontainer", - devcontainerResourceId: "local-devcontainer", - }, - { kind: "github-maintenance" }, - ], - }, - context: assembleGenerationContext({ - targetDir: path.join(workspace, "generated"), - blueprint: { - schemaVersion: 1, - preset: "custom-lib", - packageManager: "pnpm", - projectKind: "multi-package", - features: [], - }, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@10.34.4", - }, - source: "bundled-fallback", - diagnostics: [], - }, - }), - sourceRoots: { - preset: () => workspace, - sharedOxc: () => workspace, - sharedResource: (resourceId) => - resourceId === "local-editor-resource" - ? editorResource - : resourceId === "local-devcontainer" - ? devcontainerResource - : resourceId === "shared-toolchain-maintenance" - ? workspace - : undefined, - }, - }); - const extensionsOperation = plan.operations.find( - (operation) => - operation.kind === "writeJson" && - operation.to === ".vscode/extensions.json", - ); - const settingsOperation = plan.operations.find( - (operation) => - operation.kind === "writeJson" && - operation.to === ".vscode/settings.json", - ); - - expect(extensionsOperation).toMatchObject({ - value: { recommendations: ["acme.local-oxc"] }, - }); - expect(settingsOperation).toMatchObject({ - value: { - "acme.localOxc": true, - }, - }); - }); - - it("reports invalid JSON in Editor Customization Shared Resource declarations", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "editor-customization-invalid-json-"), - ); - const resourcePath = path.join(workspace, "capabilities.json"); - await writeFile(resourcePath, "{"); - - expect(() => loadEditorCustomizationDeclarations(resourcePath)).toThrow( - `Editor Customization Shared Resource ${resourcePath} is invalid: invalid JSON:`, - ); - }); - - it("reports unreadable Editor Customization Shared Resource declarations", async () => { - const resourcePath = await mkdtemp( - path.join(tmpdir(), "editor-customization-directory-resource-"), - ); - - expect(() => loadEditorCustomizationDeclarations(resourcePath)).toThrow( - `Editor Customization Shared Resource ${resourcePath} is unreadable:`, - ); - }); - - it("composes OXC and Vitest VS Code configuration once in stable order", () => { - const capabilities: EditorCustomizationCapability[] = [ - "vitest", - "oxc-format-lint", - "vitest", - "oxc-format-lint", - ]; - - const customization = - builtInEditorCustomizationForCapabilities(capabilities); - - expect(customization.extensions).toEqual([ - "oxc.oxc-vscode", - "vitest.explorer", - ]); - expect(customization.settings).toMatchObject({ - "editor.codeActionsOnSave": { - "source.fixAll.oxc": "always", - "source.format.oxc": "always", - }, - "editor.defaultFormatter": "oxc.oxc-vscode", - "editor.formatOnPaste": true, - "editor.formatOnSave": true, - "editor.formatOnSaveMode": "file", - "oxc.enable": true, - "oxc.configPath": "./oxlint.config.ts", - "oxc.fmt.configPath": "./oxfmt.config.ts", - "[javascript]": { - "editor.defaultFormatter": "oxc.oxc-vscode", - }, - "[json]": { - "editor.defaultFormatter": "oxc.oxc-vscode", - }, - "[markdown]": { - "editor.defaultFormatter": "oxc.oxc-vscode", - }, - "[typescript]": { - "editor.defaultFormatter": "oxc.oxc-vscode", - }, - }); - expect(customization.settings).not.toHaveProperty("editor.fontFamily"); - expect(customization.settings).not.toHaveProperty("oxc.trace.server"); - expect(customization.settings).not.toHaveProperty("oxc.lint.configPath"); - }); - - it("composes Rust and TOML VS Code configuration from Rust capabilities", () => { - const customization = builtInEditorCustomizationForCapabilities([ - "rust-tooling", - ]); - - expect(customization.extensions).toEqual([ - "rust-lang.rust-analyzer", - "tamasfe.even-better-toml", - ]); - expect(customization.settings).toEqual({ - "rust-analyzer.cargo.features": "all", - "rust-analyzer.check.command": "clippy", - "rust-analyzer.procMacro.enable": true, - "[rust]": { - "editor.defaultFormatter": "rust-lang.rust-analyzer", - }, - "[toml]": { - "editor.defaultFormatter": "tamasfe.even-better-toml", - }, - }); - expectForbiddenOptionalExtensionsAbsent(customization.extensions); - }); - - it("composes Vue and Tailwind VS Code recommendations from web capabilities", () => { - const customization = builtInEditorCustomizationForCapabilities([ - "tailwind", - "vue", - "tailwind", - ]); - - expect(customization.extensions).toEqual([ - "Vue.volar", - "bradlc.vscode-tailwindcss", - ]); - expect(customization.settings).toEqual({}); - expectForbiddenOptionalExtensionsAbsent(customization.extensions); - }); - - it("does not recommend Vitest for generated projects without Vitest capability", async () => { - const projectDir = await renderPresetProject("ts-lib"); - const expected = builtInEditorCustomizationForCapabilities([ - "oxc-format-lint", - ]); - const devcontainer = await readDevcontainerEditorCustomization( - path.join(projectDir, ".devcontainer/devcontainer.json"), - ); - const workspaceExtensions = await readWorkspaceExtensions( - path.join(projectDir, ".vscode/extensions.json"), - ); - const workspaceSettings = await readWorkspaceSettings( - path.join(projectDir, ".vscode/settings.json"), - ); - - expect(devcontainer.customizations.vscode.extensions).toEqual( - expected.extensions, - ); - expect(devcontainer.customizations.vscode.settings).toEqual( - expected.settings, - ); - expect(workspaceExtensions.recommendations).toEqual(["oxc.oxc-vscode"]); - expect(workspaceSettings).toEqual(expected.settings); - expect(workspaceExtensions.recommendations).not.toContain( - "vitest.explorer", - ); - }); - - it("projects Rust and TOML editor customization to generated Rust repositories", async () => { - const projectDir = await renderPresetProject("rust-bin"); - const expected = builtInEditorCustomizationForCapabilities([ - "rust-tooling", - ]); - const devcontainer = await readDevcontainerEditorCustomization( - path.join(projectDir, ".devcontainer/devcontainer.json"), - ); - const workspaceExtensions = await readWorkspaceExtensions( - path.join(projectDir, ".vscode/extensions.json"), - ); - const workspaceSettings = await readWorkspaceSettings( - path.join(projectDir, ".vscode/settings.json"), - ); - - expect(devcontainer.customizations.vscode.extensions).toEqual( - expected.extensions, - ); - expect(devcontainer.customizations.vscode.settings).toEqual( - expected.settings, - ); - expect(workspaceExtensions.recommendations).toEqual(expected.extensions); - expect(workspaceSettings).toEqual(expected.settings); - expectForbiddenOptionalExtensionsAbsent( - devcontainer.customizations.vscode.extensions, - ); - expectForbiddenOptionalExtensionsAbsent( - workspaceExtensions.recommendations, - ); - }); - - it("projects Vue and Tailwind recommendations to generated Vue app repositories", async () => { - const projectDir = await renderPresetProject("vue-app"); - const expected = builtInEditorCustomizationForCapabilities([ - "oxc-format-lint", - "vue", - "tailwind", - "vitest", - ]); - const devcontainer = await readDevcontainerEditorCustomization( - path.join(projectDir, ".devcontainer/devcontainer.json"), - ); - const workspaceExtensions = await readWorkspaceExtensions( - path.join(projectDir, ".vscode/extensions.json"), - ); - const workspaceSettings = await readWorkspaceSettings( - path.join(projectDir, ".vscode/settings.json"), - ); - - expect(devcontainer.customizations.vscode.extensions).toEqual( - expected.extensions, - ); - expect(devcontainer.customizations.vscode.settings).toEqual( - expected.settings, - ); - expect(workspaceExtensions.recommendations).toEqual(expected.extensions); - expect(workspaceSettings).toEqual(expected.settings); - expectForbiddenOptionalExtensionsAbsent( - devcontainer.customizations.vscode.extensions, - ); - expectForbiddenOptionalExtensionsAbsent( - workspaceExtensions.recommendations, - ); - }); - - it.each(supportedPresetNamesWithOxcFormatLint())( - "references only generated OXC config files for the %s preset", - async (preset) => { - const projectDir = await renderPresetProject(preset); - const devcontainer = await readDevcontainerEditorCustomization( - path.join(projectDir, ".devcontainer/devcontainer.json"), - ); - const workspaceSettings = await readWorkspaceSettings( - path.join(projectDir, ".vscode/settings.json"), - ); - - for (const settings of [ - devcontainer.customizations.vscode.settings, - workspaceSettings, - ]) { - for (const { value } of oxcConfigPathSettings(settings)) { - await stat(path.resolve(projectDir, value)); - } - } - }, - ); -}); diff --git a/test/generated-scenarios.test.ts b/test/generated-scenarios.test.ts deleted file mode 100644 index ea98111..0000000 --- a/test/generated-scenarios.test.ts +++ /dev/null @@ -1,1323 +0,0 @@ -import { mkdtemp, mkdir, readdir, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import type { PresetSourceManifest } from "@ykdz/template-builtin-source"; -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { - errorForFailedGeneratedScenario, - fixtureConcurrency, - generatedScenarioChildProcessEnv, - generatedScenarioEnvironmentNeedSteps, - generatedScenarioId, - generatedScenarioQualityGateSteps, - generatedScenarioRequiresSerializedRootCheck, - packageLeafNameForAddedPreset, - runGeneratedScenarioSet, - runGeneratedScenariosConcurrently, - selectGeneratedScenarios, -} from "@ykdz/template-core/generated-scenarios"; -import { playwrightBrowserAssetsEnvironmentNeed } from "@ykdz/template-core/module-graph"; -import { PackageAdditionSupport } from "@ykdz/template-shared"; - -function matrixPairKey(input: { - readonly basePreset: string; - readonly addedPreset: string; -}): string { - return `${input.basePreset}\0${input.addedPreset}`; -} - -function minimalManifest(): PresetSourceManifest { - return { - schemaVersion: 1, - name: "test-source", - sharedResources: [], - presets: [ - { - name: "base", - title: "Base", - description: "Base preset.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Unsupported, - features: ["root-check"], - }, - { - name: "addon", - title: "Addon", - description: "Addon preset.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Supported, - features: ["root-check"], - }, - { - name: "blocked", - title: "Blocked", - description: "Blocked preset.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Unsupported, - features: ["root-check"], - }, - ], - }; -} - -function manifestWithoutFixtureMatrix(): PresetSourceManifest { - const manifest = minimalManifest(); - - return { - ...manifest, - presets: [ - ...manifest.presets, - { - name: "future", - title: "Future", - description: "Future preset.", - generation: "future", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Unsupported, - features: ["root-check"], - }, - ], - }; -} - -function manifestWithLinkableProductionFacts(): PresetSourceManifest { - return { - schemaVersion: 1, - name: "test-source", - sharedResources: [], - presets: [ - { - name: "web-workspace", - title: "Web workspace", - description: "Workspace with a web package.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Unsupported, - features: ["root-check"], - projection: { - capabilities: [ - { - kind: "workspace-node-packages", - workspacePackageGlob: "apps/*", - packages: [ - { - kind: "vue-app", - path: "apps/web", - sourceFiles: ["src/main.ts"], - }, - ], - }, - ], - }, - }, - { - name: "library", - title: "Library", - description: "Addable TypeScript library.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Supported, - features: ["root-check"], - projection: { - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts"], - }, - ], - }, - }, - ], - }; -} - -function manifestWithApiConsumerProductionFacts(): PresetSourceManifest { - return { - schemaVersion: 1, - name: "test-source", - sharedResources: [], - presets: [ - { - name: "api-workspace", - title: "API workspace", - description: "Workspace with an API package.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Unsupported, - features: ["root-check"], - projection: { - capabilities: [ - { - kind: "workspace-node-packages", - workspacePackageGlob: "apps/*", - packages: [ - { - kind: "hono-api", - path: "apps/api", - sourceFiles: ["src/server.ts"], - }, - ], - }, - ], - }, - }, - { - name: "library", - title: "Library", - description: "Addable TypeScript library.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Supported, - features: ["root-check"], - projection: { - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts"], - }, - ], - }, - }, - ], - }; -} - -describe("generated scenarios", () => { - it("does not pass conflicting color environment variables to child checks", () => { - const env = generatedScenarioChildProcessEnv({ - FORCE_COLOR: "1", - NO_COLOR: "1", - }); - - expect(env.FORCE_COLOR).toBeUndefined(); - expect(env.NO_COLOR).toBe("1"); - }); - - it("derives the init-only scenario set from supported production Presets", () => { - expect( - selectGeneratedScenarios(manifestWithoutFixtureMatrix(), "init"), - ).toEqual({ - runnable: [ - { - set: "init", - basePreset: "base", - id: "base", - label: "base", - }, - { - set: "init", - basePreset: "addon", - id: "addon", - label: "addon", - }, - { - set: "init", - basePreset: "blocked", - id: "blocked", - label: "blocked", - }, - ], - skipped: [], - }); - }); - - it("automatically gives new supported initialization Presets init scenario coverage", () => { - const manifest = minimalManifest(); - const before = selectGeneratedScenarios(manifest, "init").runnable.map( - (scenario) => scenario.id, - ); - - manifest.presets.push({ - name: "new-init-preset", - title: "New init preset", - description: "A new supported initialization preset.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Unsupported, - features: ["root-check"], - }); - - const after = selectGeneratedScenarios(manifest, "init").runnable.map( - (scenario) => scenario.id, - ); - - expect(before).not.toContain("new-init-preset"); - expect(after).toEqual([...before, "new-init-preset"]); - }); - - it("derives Package Addition matrix scenarios from supported initialization Presets crossed with addable Presets", () => { - expect( - selectGeneratedScenarios( - manifestWithoutFixtureMatrix(), - "package-addition-matrix", - ), - ).toEqual({ - runnable: [ - { - set: "package-addition-matrix", - basePreset: "base", - addedPreset: "addon", - id: "base-add-addon", - label: "base + addon", - }, - { - set: "package-addition-matrix", - basePreset: "addon", - addedPreset: "addon", - id: "addon-add-addon", - label: "addon + addon", - }, - { - set: "package-addition-matrix", - basePreset: "blocked", - addedPreset: "addon", - id: "blocked-add-addon", - label: "blocked + addon", - }, - ], - skipped: [], - }); - }); - - it("automatically gives new addable Presets Package Addition matrix coverage", () => { - const manifest = minimalManifest(); - const before = new Set( - selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ).runnable.map((scenario) => - matrixPairKey({ - basePreset: scenario.basePreset, - addedPreset: scenario.addedPreset!, - }), - ), - ); - - manifest.presets.push({ - name: "new-addable-preset", - title: "New addable preset", - description: "A new supported addable preset.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: PackageAdditionSupport.Supported, - features: ["root-check"], - }); - - const after = new Set( - selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ).runnable.map((scenario) => - matrixPairKey({ - basePreset: scenario.basePreset, - addedPreset: scenario.addedPreset!, - }), - ), - ); - - expect(before).not.toContain( - matrixPairKey({ - basePreset: "base", - addedPreset: "new-addable-preset", - }), - ); - expect(after).toEqual( - new Set([ - ...before, - matrixPairKey({ - basePreset: "base", - addedPreset: "new-addable-preset", - }), - matrixPairKey({ - basePreset: "addon", - addedPreset: "new-addable-preset", - }), - matrixPairKey({ - basePreset: "blocked", - addedPreset: "new-addable-preset", - }), - matrixPairKey({ - basePreset: "new-addable-preset", - addedPreset: "addon", - }), - matrixPairKey({ - basePreset: "new-addable-preset", - addedPreset: "new-addable-preset", - }), - ]), - ); - }); - - it("derives link-focused Package Addition scenarios from production package facts", () => { - expect( - selectGeneratedScenarios( - manifestWithLinkableProductionFacts(), - "package-addition-matrix", - ), - ).toEqual({ - runnable: expect.arrayContaining([ - { - set: "focused", - basePreset: "web-workspace", - addedPreset: "library", - linkFrom: ["apps/web"], - id: "web-workspace-add-library-link-from-apps-web", - label: "web-workspace + library linked from apps/web", - }, - ]), - skipped: [], - }); - }); - - it("derives focused Package Link Intent consumers through production link planning", () => { - expect( - selectGeneratedScenarios( - manifestWithApiConsumerProductionFacts(), - "package-addition-matrix", - ), - ).toEqual({ - runnable: expect.arrayContaining([ - { - set: "focused", - basePreset: "api-workspace", - addedPreset: "library", - linkFrom: ["apps/api"], - id: "api-workspace-add-library-link-from-apps-api", - label: "api-workspace + library linked from apps/api", - }, - ]), - skipped: [], - }); - }); - - it("does not need built-in Fixture Matrix link entries for Package Link Intent coverage", () => { - const manifest = loadBuiltInPresetSourceManifest(); - - expect(Object.hasOwn(manifest, "fixtureMatrix")).toBe(false); - expect( - selectGeneratedScenarios(manifest, "package-addition-matrix"), - ).toEqual({ - runnable: expect.arrayContaining([ - { - set: "focused", - basePreset: "vue-hono-app", - addedPreset: "ts-lib", - linkFrom: ["apps/web"], - id: "vue-hono-app-add-ts-lib-link-from-apps-web", - label: "vue-hono-app + ts-lib linked from apps/web", - }, - ]), - skipped: [], - }); - }); - - it("excludes init-only scenarios from the Package Addition matrix", () => { - const selection = selectGeneratedScenarios( - minimalManifest(), - "package-addition-matrix", - ); - - expect(selection.runnable.every((scenario) => scenario.addedPreset)).toBe( - true, - ); - expect(selection.runnable.map((scenario) => scenario.id)).not.toContain( - "base", - ); - }); - - it("derives deterministic Package Addition leaf names from the added Preset", () => { - expect( - packageLeafNameForAddedPreset(manifestWithoutFixtureMatrix(), "addon"), - ).toBe("fixture-addon"); - }); - - it("runs fixture scenarios with existing bounded parallelism by default", () => { - const previous = process.env.TEMPLATE_FIXTURE_CONCURRENCY; - - try { - delete process.env.TEMPLATE_FIXTURE_CONCURRENCY; - expect(fixtureConcurrency(20)).toBe(2); - - process.env.TEMPLATE_FIXTURE_CONCURRENCY = "8"; - expect(fixtureConcurrency(20)).toBe(8); - expect(fixtureConcurrency(3)).toBe(3); - } finally { - if (previous === undefined) { - delete process.env.TEMPLATE_FIXTURE_CONCURRENCY; - } else { - process.env.TEMPLATE_FIXTURE_CONCURRENCY = previous; - } - } - }); - - it("keeps generated scenario serialization on environment need kind metadata", () => { - expect( - generatedScenarioRequiresSerializedRootCheck([ - { - id: "prepare-browser-assets", - command: "pnpm", - args: ["exec", "playwright", "install", "chromium"], - cwd: "/project", - display: "pnpm exec playwright install chromium", - environmentNeedKind: "playwright-browser-assets", - }, - ]), - ).toBe(true); - - expect( - generatedScenarioRequiresSerializedRootCheck([ - { - id: "install-apps-web-playwright-browsers", - command: "pnpm", - args: ["run", "check"], - cwd: "/project", - display: "pnpm run check", - }, - ]), - ).toBe(false); - }); - - it("only turns machine-verifiable environment needs into generated scenario steps", () => { - const steps = generatedScenarioEnvironmentNeedSteps( - [ - playwrightBrowserAssetsEnvironmentNeed({ - browser: "chromium", - owner: { kind: "package-boundary", path: "apps/web" }, - id: "prepare-browser-assets", - label: "Prepare browser assets", - machineVerifiable: true, - }), - playwrightBrowserAssetsEnvironmentNeed({ - browser: "chromium", - owner: { kind: "package-boundary", path: "apps/admin" }, - id: "manual-browser-assets", - label: "Manually prepare browser assets", - machineVerifiable: false, - }), - ], - "/project", - ); - - expect(steps).toEqual([ - { - id: "prepare-browser-assets", - command: "pnpm", - args: [ - "--filter", - "./apps/web", - "exec", - "playwright", - "install", - "chromium", - ], - cwd: "/project", - display: "pnpm --filter ./apps/web exec playwright install chromium", - environmentNeedKind: "playwright-browser-assets", - }, - ]); - }); - - it("derives generated scenario browser preparation from generated Check Plans instead of fixture manifest fields", () => { - const manifest = loadBuiltInPresetSourceManifest(); - - expect(Object.hasOwn(manifest, "fixtureMatrix")).toBe(false); - - const selectedScenarios = selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ); - expect(selectedScenarios.runnable.length).toBeGreaterThan(0); - - const scenario = selectedScenarios.runnable.find( - (candidate) => - candidate.basePreset === "ts-lib" && - candidate.addedPreset === "vue-app", - ); - - if (!scenario) { - throw new Error("Expected ts-lib + vue-app generated scenario"); - } - - const steps = generatedScenarioQualityGateSteps( - manifest, - scenario, - "/generated-repository", - "apps/fixture-vue-app", - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - }, - ); - - expect(steps).toContainEqual( - expect.objectContaining({ - args: [ - "--filter", - "./apps/fixture-vue-app", - "exec", - "playwright", - "install", - "chromium", - ], - environmentNeedKind: "playwright-browser-assets", - }), - ); - }); - - it("does not run browser preparation for scenarios whose generated Check Plans do not require it", () => { - const manifest = loadBuiltInPresetSourceManifest(); - const scenario = selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ).runnable.find( - (candidate) => - candidate.basePreset === "ts-lib" && candidate.addedPreset === "ts-lib", - ); - - const steps = generatedScenarioQualityGateSteps( - manifest, - scenario!, - "/generated-repository", - "packages/fixture-ts-lib", - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - }, - ); - - expect( - steps.some( - (step) => step.environmentNeedKind === "playwright-browser-assets", - ), - ).toBe(false); - }); - - it("derives additive deployment checks for Vike Fixture Matrix scenarios from the generated Check Plan", () => { - const manifest = loadBuiltInPresetSourceManifest(); - const scenario = selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ).runnable.find( - (candidate) => - candidate.basePreset === "vike-app" && - candidate.addedPreset === "ts-lib" && - candidate.linkFrom === undefined, - ); - - if (!scenario) { - throw new Error("Expected vike-app + ts-lib generated scenario"); - } - - const steps = generatedScenarioQualityGateSteps( - manifest, - scenario, - "/generated-repository", - "packages/fixture-ts-lib", - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - }, - ); - const rootCheckIndex = steps.findIndex( - (step) => step.id === "run-root-check", - ); - const dockerIndex = steps.findIndex( - (step) => step.environmentNeedKind === "docker-engine", - ); - const deploymentIndex = steps.findIndex( - (step) => step.id === "run-deployment-check", - ); - - expect(rootCheckIndex).toBeGreaterThanOrEqual(0); - expect(dockerIndex).toBeGreaterThan(rootCheckIndex); - expect(deploymentIndex).toBeGreaterThan(dockerIndex); - expect(steps[deploymentIndex]).toEqual({ - id: "run-deployment-check", - command: "pnpm", - args: ["run", "check:deployment"], - cwd: "/generated-repository", - display: "pnpm run check:deployment", - phase: "deployment", - }); - }); - - it("does not add Docker work to Fixture Matrix scenarios without a deployment check", () => { - const manifest = loadBuiltInPresetSourceManifest(); - const scenario = selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ).runnable.find( - (candidate) => - candidate.basePreset === "ts-lib" && candidate.addedPreset === "ts-lib", - ); - - const steps = generatedScenarioQualityGateSteps( - manifest, - scenario!, - "/generated-repository", - "packages/fixture-ts-lib", - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - }, - ); - - expect( - steps.some( - (step) => - step.environmentNeedKind === "docker-engine" || - step.id === "run-deployment-check", - ), - ).toBe(false); - }); - - it("keeps Docker out of init-only generated checks for deployment-capable Presets", () => { - const manifest = loadBuiltInPresetSourceManifest(); - const scenario = selectGeneratedScenarios(manifest, "init").runnable.find( - (candidate) => candidate.basePreset === "vike-app", - ); - const steps = generatedScenarioQualityGateSteps( - manifest, - scenario!, - "/generated-repository", - undefined, - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - }, - ); - - expect( - steps.some( - (step) => - step.environmentNeedKind === "docker-engine" || - step.id === "run-deployment-check", - ), - ).toBe(false); - }); - - it("derives built-in Package Addition scenarios without manifest semantic skips", () => { - const selection = selectGeneratedScenarios( - loadBuiltInPresetSourceManifest(), - "package-addition-matrix", - ); - - expect(selection.runnable.every((scenario) => scenario.addedPreset)).toBe( - true, - ); - expect(selection.skipped).toEqual([]); - }); - - it("proves built-in Package Addition Universality for supported initialization Presets and addable Presets", () => { - const manifest = loadBuiltInPresetSourceManifest(); - const initSupportedPresets = manifest.presets - .filter((preset) => preset.generation === "supported") - .map((preset) => preset.name); - const packageAdditionSupportedPresets = new Set( - manifest.presets - .filter( - (preset) => - preset.packageAdditionSupport === PackageAdditionSupport.Supported, - ) - .map((preset) => preset.name), - ); - const expectedPairs = new Set( - initSupportedPresets.flatMap((basePreset) => - [...packageAdditionSupportedPresets].map((addedPreset) => - matrixPairKey({ basePreset, addedPreset }), - ), - ), - ); - const selection = selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ); - const plainMatrixScenarios = selection.runnable.filter( - (scenario) => - scenario.set === "package-addition-matrix" && - scenario.linkFrom === undefined, - ); - const runnablePairs = new Set( - plainMatrixScenarios.map((scenario) => - matrixPairKey({ - basePreset: scenario.basePreset, - addedPreset: scenario.addedPreset!, - }), - ), - ); - - expect(selection.skipped).toEqual([]); - expect(plainMatrixScenarios).toHaveLength(expectedPairs.size); - expect(runnablePairs).toEqual(expectedPairs); - for (const scenario of plainMatrixScenarios) { - expect(packageAdditionSupportedPresets.has(scenario.addedPreset!)).toBe( - true, - ); - } - }); - - it("wraps runner failures with the preset combination label", () => { - const scenario = selectGeneratedScenarios( - minimalManifest(), - "package-addition-matrix", - ).runnable.find((candidate) => candidate.addedPreset === "addon"); - - const error = errorForFailedGeneratedScenario( - scenario!, - new Error("inner"), - ); - - expect(error.message).toBe("Fixture scenario failed: base + addon"); - expect(error.cause).toBeInstanceOf(Error); - if (!(error.cause instanceof Error)) { - throw new Error("Expected generated scenario error cause."); - } - expect(error.cause.message).toBe("inner"); - }); - - it("wraps init runner failures with the initialization preset label", () => { - const scenario = selectGeneratedScenarios(minimalManifest(), "init") - .runnable[0]!; - - const error = errorForFailedGeneratedScenario(scenario, new Error("inner")); - - expect(error.message).toBe("Fixture scenario failed: base"); - expect(error.cause).toBeInstanceOf(Error); - if (!(error.cause instanceof Error)) { - throw new Error("Expected generated scenario error cause."); - } - expect(error.cause.message).toBe("inner"); - }); - - it("runs selected scenarios through the shared runner", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "generated-scenario-runner-"), - ); - const scenario = selectGeneratedScenarios( - loadBuiltInPresetSourceManifest(), - "package-addition-matrix", - ).runnable.find( - (candidate) => - candidate.basePreset === "ts-lib" && candidate.addedPreset === "ts-lib", - ); - const commands: string[] = []; - - await runGeneratedScenariosConcurrently( - loadBuiltInPresetSourceManifest(), - [scenario!], - workspace, - 1, - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - runCommand: async (command, args, cwd) => { - commands.push(`${cwd}: ${command} ${args.join(" ")}`); - - if (args[2] !== "add") { - return; - } - - await mkdir(path.join(cwd, ".template"), { recursive: true }); - await writeFile( - path.join(cwd, ".template", "blueprint.json"), - JSON.stringify({ - packages: [ - { name: "fixture-ts-lib", path: "packages/fixture-ts-lib" }, - ], - }), - "utf8", - ); - }, - reporter: {}, - }, - ); - - expect(commands).toEqual( - expect.arrayContaining([ - expect.stringContaining( - "node --conditions=source /repo/packages/cli/src/cli.ts init", - ), - expect.stringContaining( - "node --conditions=source /repo/packages/cli/src/cli.ts add package --preset ts-lib --name fixture-ts-lib", - ), - expect.stringContaining( - "pnpm install --lockfile-only --prefer-offline --no-frozen-lockfile", - ), - expect.stringContaining("pnpm fetch"), - expect.stringContaining("pnpm install --offline --frozen-lockfile"), - expect.stringContaining("pnpm run fix"), - expect.stringContaining("pnpm run check"), - ]), - ); - }); - - it("runs a generated deployment command after the Root Check when Docker is available", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const scenario = selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ).runnable.find( - (candidate) => - candidate.basePreset === "vike-app" && - candidate.addedPreset === "ts-lib" && - candidate.linkFrom === undefined, - ); - const workspace = await mkdtemp( - path.join(tmpdir(), "generated-deployment-runner-"), - ); - const commands: string[] = []; - - await runGeneratedScenariosConcurrently( - manifest, - [scenario!], - workspace, - 1, - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - runCommand: async (command, args, cwd) => { - commands.push(`${command} ${args.join(" ")}`); - - if (args[2] !== "add") { - return; - } - - await mkdir(path.join(cwd, ".template"), { recursive: true }); - await writeFile( - path.join(cwd, ".template", "blueprint.json"), - JSON.stringify({ - packages: [ - { name: "fixture-ts-lib", path: "packages/fixture-ts-lib" }, - ], - }), - "utf8", - ); - }, - reporter: {}, - }, - ); - - const rootCheckIndex = commands.indexOf("pnpm run check"); - const dockerIndex = commands.indexOf( - "docker info --format {{.ServerVersion}}", - ); - const deploymentIndex = commands.indexOf("pnpm run check:deployment"); - expect(rootCheckIndex).toBeGreaterThanOrEqual(0); - expect(dockerIndex).toBeGreaterThan(rootCheckIndex); - expect(deploymentIndex).toBeGreaterThan(dockerIndex); - }); - - it("fails a required deployment check when Docker is unavailable", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const scenario = selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ).runnable.find( - (candidate) => - candidate.basePreset === "vike-app" && - candidate.addedPreset === "ts-lib" && - candidate.linkFrom === undefined, - ); - const workspace = await mkdtemp( - path.join(tmpdir(), "generated-deployment-skip-"), - ); - const commands: string[] = []; - const messages: string[] = []; - const replayCacheDirectory = await mkdtemp( - path.join(tmpdir(), "generated-deployment-skip-cache-"), - ); - - await expect( - runGeneratedScenariosConcurrently(manifest, [scenario!], workspace, 1, { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - replayCache: { - directory: replayCacheDirectory, - read: false, - write: true, - }, - runCommand: async (command, args, cwd) => { - commands.push(`${command} ${args.join(" ")}`); - - if (command === "docker" && args[0] === "info") { - throw new Error("Docker daemon is unavailable"); - } - - if (args[2] !== "add") { - return; - } - - await mkdir(path.join(cwd, ".template"), { recursive: true }); - await writeFile( - path.join(cwd, ".template", "blueprint.json"), - JSON.stringify({ - packages: [ - { name: "fixture-ts-lib", path: "packages/fixture-ts-lib" }, - ], - }), - "utf8", - ); - }, - reporter: { info: (message) => messages.push(message) }, - }), - ).rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringMatching( - /Deployment check requires the docker-engine Check Environment capability/u, - ), - }), - }); - - expect(commands).toContain("pnpm run check"); - expect(commands).not.toContain("pnpm run check:deployment"); - expect(messages).not.toContainEqual( - expect.stringMatching(/Skipping deployment check/u), - ); - expect(await readdir(replayCacheDirectory)).toHaveLength(1); - }); - - it("partitions deployment replay from the current Docker capability", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const scenario = selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ).runnable.find( - (candidate) => - candidate.basePreset === "vike-app" && - candidate.addedPreset === "ts-lib" && - candidate.linkFrom === undefined, - ); - const cacheDirectory = await mkdtemp( - path.join(tmpdir(), "generated-deployment-replay-cache-"), - ); - - async function run( - dockerAvailable: boolean, - read: boolean, - write: boolean, - ): Promise<{ commands: string[]; messages: string[] }> { - const workspace = await mkdtemp( - path.join(tmpdir(), "generated-deployment-replay-workspace-"), - ); - const commands: string[] = []; - const messages: string[] = []; - await runGeneratedScenariosConcurrently( - manifest, - [scenario!], - workspace, - 1, - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - replayCache: { directory: cacheDirectory, read, write }, - runCommand: async (command, args, cwd) => { - commands.push(`${command} ${args.join(" ")}`); - if (command === "docker" && !dockerAvailable) { - throw new Error("Docker daemon is unavailable"); - } - if (args[2] === "add") { - await mkdir(path.join(cwd, ".template"), { recursive: true }); - await writeFile( - path.join(cwd, ".template", "blueprint.json"), - JSON.stringify({ - packages: [ - { name: "fixture-ts-lib", path: "packages/fixture-ts-lib" }, - ], - }), - "utf8", - ); - } - }, - reporter: { info: (message) => messages.push(message) }, - }, - ); - return { commands, messages }; - } - - await expect(run(false, false, true)).rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringMatching(/requires the docker-engine/u), - }), - }); - - const availableAfterUnavailable = await run(true, true, true); - expect(availableAfterUnavailable.commands).not.toContain("pnpm run check"); - expect(availableAfterUnavailable.commands).toContain( - "docker info --format {{.ServerVersion}}", - ); - expect(availableAfterUnavailable.commands).toContain( - "pnpm run check:deployment", - ); - - const availableReplay = await run(true, true, false); - expect(availableReplay.commands).toContain( - "docker info --format {{.ServerVersion}}", - ); - expect(availableReplay.commands).not.toContain("pnpm run check:deployment"); - expect(availableReplay.messages).toContainEqual( - expect.stringMatching(/Replayed passed deployment fixture/u), - ); - - await expect(run(false, true, false)).rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringMatching(/requires the docker-engine/u), - }), - }); - }); - - it("reports the generated scenario, deployment modes, command, and container logs on deployment failure", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const scenario = selectGeneratedScenarios( - manifest, - "package-addition-matrix", - ).runnable.find( - (candidate) => - candidate.basePreset === "vike-app" && - candidate.addedPreset === "ts-lib" && - candidate.linkFrom === undefined, - ); - const workspace = await mkdtemp( - path.join(tmpdir(), "generated-deployment-failure-"), - ); - let failure: unknown; - - try { - await runGeneratedScenariosConcurrently( - manifest, - [scenario!], - workspace, - 1, - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - runCommand: async (command, args, cwd) => { - if (command === "pnpm" && args[1] === "check:deployment") { - throw new Error("standalone container logs:\nstartup failed"); - } - - if (args[2] !== "add") { - return; - } - - await mkdir(path.join(cwd, ".template"), { recursive: true }); - await writeFile( - path.join(cwd, ".template", "blueprint.json"), - JSON.stringify({ - packages: [ - { - name: "fixture-ts-lib", - path: "packages/fixture-ts-lib", - }, - ], - }), - "utf8", - ); - }, - reporter: {}, - }, - ); - } catch (error: unknown) { - failure = error; - } - - expect(failure).toBeInstanceOf(Error); - expect((failure as Error).message).toBe( - "Fixture scenario failed: vike-app + ts-lib", - ); - expect((failure as Error).cause).toBeInstanceOf(Error); - const deploymentFailure = (failure as Error).cause as Error; - expect(deploymentFailure.message).toMatch( - /Generated deployment command failed.*pnpm run check:deployment.*cause.*mode.*phase.*container logs/u, - ); - expect(deploymentFailure.cause).toBeInstanceOf(Error); - expect((deploymentFailure.cause as Error).message).toContain( - "standalone container logs:\nstartup failed", - ); - }); - - it("replays a passed scenario from a generated repository fingerprint", async () => { - const cacheDir = await mkdtemp( - path.join(tmpdir(), "fixture-replay-cache-"), - ); - const scenario = selectGeneratedScenarios( - loadBuiltInPresetSourceManifest(), - "init", - ).runnable.find((candidate) => candidate.basePreset === "ts-lib"); - - if (!scenario) { - throw new Error("Expected ts-lib init scenario."); - } - const selectedScenario = scenario; - - async function runWithCache( - read: boolean, - write: boolean, - ): Promise { - const workspace = await mkdtemp( - path.join(tmpdir(), "fixture-replay-workspace-"), - ); - const commands: string[] = []; - - await runGeneratedScenariosConcurrently( - loadBuiltInPresetSourceManifest(), - [selectedScenario], - workspace, - 1, - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - replayCache: { directory: cacheDir, read, write }, - runCommand: async (command, args, cwd) => { - commands.push(`${cwd}: ${command} ${args.join(" ")}`); - - if (args[2] === "init") { - const projectDir = args[3]; - if (!projectDir) { - throw new Error("Missing generated project directory."); - } - await mkdir(projectDir, { recursive: true }); - await writeFile( - path.join(projectDir, "package.json"), - `${JSON.stringify({ name: "fixture-ts-lib" })}\n`, - "utf8", - ); - } - - if ( - command === "pnpm" && - args[0] === "install" && - args.includes("--lockfile-only") - ) { - await writeFile( - path.join(cwd, "pnpm-lock.yaml"), - "lockfileVersion: '9.0'\n", - "utf8", - ); - } - }, - reporter: {}, - }, - ); - - return commands; - } - - const missCommands = await runWithCache(false, true); - const hitCommands = await runWithCache(true, false); - - expect(missCommands).toEqual( - expect.arrayContaining([ - expect.stringContaining("pnpm fetch"), - expect.stringContaining("pnpm run fix"), - expect.stringContaining("pnpm run check"), - ]), - ); - expect(hitCommands).toEqual( - expect.arrayContaining([ - expect.stringContaining( - "pnpm install --lockfile-only --prefer-offline --no-frozen-lockfile", - ), - ]), - ); - expect(hitCommands).not.toContainEqual( - expect.stringContaining("pnpm fetch"), - ); - expect(hitCommands).not.toContainEqual( - expect.stringContaining("pnpm run fix"), - ); - expect(hitCommands).not.toContainEqual( - expect.stringContaining("pnpm run check"), - ); - }); - - it("runs initialization presets through production generation and generated Root Check", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "generated-init-scenario-runner-"), - ); - const commands: string[] = []; - const selection = await runGeneratedScenarioSet( - loadBuiltInPresetSourceManifest(), - "init", - workspace, - { - repoRoot: "/repo", - cliPath: "/repo/packages/cli/src/cli.ts", - projectionSourceRoots: builtInPresetProjectionSourceRoots(), - runCommand: async (command, args, cwd) => { - commands.push(`${cwd}: ${command} ${args.join(" ")}`); - }, - reporter: {}, - }, - ); - - const initPresetNames = selection.runnable.map( - (scenario) => scenario.basePreset, - ); - - expect(initPresetNames).toEqual( - selectGeneratedScenarios( - loadBuiltInPresetSourceManifest(), - "init", - ).runnable.map((scenario) => scenario.basePreset), - ); - for (const presetName of initPresetNames) { - expect(commands).toContainEqual( - expect.stringContaining( - `/repo: node --conditions=source /repo/packages/cli/src/cli.ts init ${path.join(workspace, `fixture-${presetName}`)} --preset ${presetName} --yes`, - ), - ); - expect(commands).toContainEqual( - expect.stringContaining( - `${path.join(workspace, `fixture-${presetName}`)}: pnpm run check`, - ), - ); - } - expect(commands).not.toContainEqual( - expect.stringContaining(" add package "), - ); - }); - - it("formats linked scenario ids without test-only helpers", () => { - expect( - generatedScenarioId({ - basePreset: "vue-hono-app", - addedPreset: "ts-lib", - linkFrom: ["apps/web"], - }), - ).toBe("vue-hono-app-add-ts-lib-link-from-apps-web"); - }); -}); diff --git a/test/generated-toolchain-maintenance.test.ts b/test/generated-toolchain-maintenance.test.ts deleted file mode 100644 index cd60444..0000000 --- a/test/generated-toolchain-maintenance.test.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import { - blueprintForPresetSourcePreset, - projectPresetSourcePreset, -} from "@ykdz/template-core/projection-capabilities"; -import { renderNewProject } from "@ykdz/template-core/renderer"; -import { execa } from "execa"; -import { parse } from "yaml"; - -import { - checkEnvironmentNeedsForPackageManifests, - type GithubEffects, - resolveToolchainBaseline, - runToolchainBaselineMaintenance, - updateToolchainBaselineMaterials, -} from "../packages/builtin-source/templates/shared/toolchain-maintenance/update-toolchain-baseline.ts"; - -describe("Generated Repository Toolchain Baseline maintenance", () => { - it("derives ShellCheck preparation only from a Package Boundary that owns shell validation", () => { - expect( - checkEnvironmentNeedsForPackageManifests([ - { - scripts: { - lint: "shellcheck scripts/container-entrypoint.sh && oxlint .", - }, - }, - ]), - ).toEqual(["shellcheck"]); - expect( - checkEnvironmentNeedsForPackageManifests([ - { scripts: { lint: "oxlint ." } }, - ]), - ).toEqual([]); - }); - - it("selects the latest official LTS with the newest compatible pnpm older than 24 hours", async () => { - const resolved = await resolveToolchainBaseline( - [ - { version: "v25.2.0", lts: false }, - { version: "v24.4.0", lts: "Krypton" }, - { version: "v22.9.0", lts: "Jod" }, - ], - { - versions: { - "11.9.0": { engines: { node: ">=20" } }, - "11.10.0": { engines: { node: ">=24" } }, - "11.11.0": { engines: { node: ">=24" } }, - "12.0.0": { engines: { node: ">=26" } }, - }, - time: { - "11.9.0": "2026-07-08T00:00:00.000Z", - "11.10.0": "2026-07-09T11:59:59.999Z", - "11.11.0": "2026-07-09T12:00:00.001Z", - "12.0.0": "2026-07-08T00:00:00.000Z", - }, - }, - new Date("2026-07-10T12:00:00.000Z"), - ); - - expect(resolved).toEqual({ - nodeLtsMajor: "24", - packageManagerPin: "pnpm@11.10.0", - }); - }); - - it("projects one offline-testable single-flight updater into every maintained Project Shape", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const maintainedPresets = manifest.presets.filter( - (preset) => - preset.generation === "supported" && - preset.features.includes("github-actions"), - ); - - expect(maintainedPresets.length).toBeGreaterThan(0); - - for (const preset of maintainedPresets) { - const targetDir = await mkdtemp( - path.join(tmpdir(), `generated-toolchain-${preset.name}-`), - ); - const blueprint = blueprintForPresetSourcePreset(preset, { targetDir }); - const context = assembleGenerationContext({ - blueprint, - targetDir, - toolchain: { - diagnostics: [], - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@11.11.0", - }, - source: "online", - }, - }); - const plan = projectPresetSourcePreset({ - preset, - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - - const workflow = parse( - await readFile( - path.join( - targetDir, - ".github/workflows/toolchain-baseline-update.yml", - ), - "utf8", - ), - ) as { - permissions: Record; - concurrency: { group: string; "cancel-in-progress": boolean }; - jobs: { update: { steps: Array<{ run?: string }> } }; - }; - expect(workflow.permissions).toEqual({ - contents: "write", - "pull-requests": "write", - }); - expect(workflow.concurrency).toEqual({ - group: "toolchain-baseline-update", - "cancel-in-progress": false, - }); - expect(JSON.stringify(workflow)).not.toContain("merge"); - - const rootPackageJson = JSON.parse( - await readFile(path.join(targetDir, "package.json"), "utf8"), - ) as { - devDependencies: Record; - }; - expect(rootPackageJson.devDependencies).toMatchObject({ - "@types/semver": "catalog:", - semver: "catalog:", - }); - expect( - await readFile(path.join(targetDir, "tsconfig.config.json"), "utf8"), - ).toContain('"scripts/**/*.ts"'); - const dependabot = await readFile( - path.join(targetDir, ".github/dependabot.yml"), - "utf8", - ); - expect(dependabot).toContain("package-ecosystem: npm"); - expect(dependabot).toContain("package-ecosystem: github-actions"); - expect(dependabot).toContain('dependency-name: "pnpm"'); - - const fixturePath = path.join(targetDir, "toolchain-fixture.json"); - await writeFile( - fixturePath, - `${JSON.stringify({ - current: { - nodeLtsMajor: "24", - packageManagerPin: "pnpm@11.11.0", - }, - desired: { - nodeLtsMajor: "26", - packageManagerPin: "pnpm@12.1.0", - }, - candidate: { - pullRequest: { kind: "absent" }, - remoteBranch: { kind: "absent" }, - }, - })}\n`, - ); - const result = await execa( - "node", - ["scripts/update-toolchain-baseline.ts", "--plan-fixture", fixturePath], - { cwd: targetDir }, - ); - expect(JSON.parse(result.stdout)).toEqual({ - kind: "update", - mode: "create", - desired: { - nodeLtsMajor: "26", - packageManagerPin: "pnpm@12.1.0", - }, - }); - - if (preset.name === "vike-app") { - await updateToolchainBaselineMaterials(targetDir, { - nodeLtsMajor: "26", - packageManagerPin: "pnpm@12.1.0", - }); - for (const manifestPath of [ - "package.json", - "apps/web/package.json", - "packages/db/package.json", - ]) { - const manifest = JSON.parse( - await readFile(path.join(targetDir, manifestPath), "utf8"), - ) as { engines: { node: string }; packageManager?: string }; - expect(manifest.engines.node).toBe("26"); - expect(manifest.packageManager).toBe( - manifestPath === "packages/db/package.json" - ? undefined - : "pnpm@12.1.0", - ); - } - const devcontainer = JSON.parse( - await readFile( - path.join(targetDir, ".devcontainer/devcontainer.json"), - "utf8", - ), - ) as { build: { args: Record } }; - expect(devcontainer.build.args).toMatchObject({ - NODE_VERSION: "26", - PACKAGE_MANAGER_PIN: "pnpm@12.1.0", - }); - const generationRecord = JSON.parse( - await readFile( - path.join(targetDir, ".template/generated-by.json"), - "utf8", - ), - ) as { toolchain: Record }; - expect(generationRecord.toolchain).toMatchObject({ - nodeLtsMajor: "26", - packageManagerPin: "pnpm@12.1.0", - }); - const dockerfile = await readFile( - path.join(targetDir, "apps/web/Dockerfile"), - "utf8", - ); - expect(dockerfile).toContain("FROM node:26-bookworm-slim"); - expect(dockerfile).toContain('ARG PACKAGE_MANAGER_PIN="pnpm@12.1.0"'); - expect(dockerfile).not.toContain("FROM node:24-"); - } - } - }); - - it("rejects a Node projection when its required maintenance source is absent", () => { - const preset = loadBuiltInPresetSourceManifest().presets.find( - (candidate) => candidate.name === "ts-lib", - )!; - const targetDir = path.join(tmpdir(), "missing-toolchain-maintenance"); - const blueprint = blueprintForPresetSourcePreset(preset, { targetDir }); - const context = assembleGenerationContext({ - blueprint, - targetDir, - toolchain: { - diagnostics: [], - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.11.0" }, - source: "online", - }, - }); - const sourceRoots = builtInPresetProjectionSourceRoots(); - expect(() => - projectPresetSourcePreset({ - preset, - context, - sourceRoots: { - ...sourceRoots, - sharedResource(resourceId) { - return resourceId === "shared-toolchain-maintenance" - ? undefined - : sourceRoots.sharedResource(resourceId); - }, - }, - }), - ).toThrow( - "Toolchain Baseline maintenance requires Shared Resource: shared-toolchain-maintenance", - ); - }); - - it("creates a checked candidate in an isolated origin without auto-merging", async () => { - const fixture = await createGitFixture(); - const github = fakeGithub(); - const sequence: string[] = []; - await runToolchainBaselineMaintenance({ - rootDirectory: fixture.checkout, - desired: desiredBaseline, - defaultBranch: "main", - owner: "example", - github: github.effects, - hooks: successfulHooks(fixture.checkout, sequence), - }); - expect( - await remoteSha(fixture.origin, "automation/toolchain-baseline"), - ).toMatch(/^[0-9a-f]{40}$/); - expect(sequence).toEqual([ - "package-manager", - "lockfile", - "prepare", - "check", - ]); - expect(github.events).toEqual(["create"]); - }); - - it("replaces an open candidate from an advanced default branch", async () => { - const fixture = await createGitFixture(); - await pushAutomationCandidate(fixture); - const github = fakeGithub([7]); - let advanced = false; - const hooks = successfulHooks(fixture.checkout, []); - await runToolchainBaselineMaintenance({ - rootDirectory: fixture.checkout, - desired: desiredBaseline, - defaultBranch: "main", - owner: "example", - github: github.effects, - hooks: { - ...hooks, - async beforePush() { - if (!advanced) { - advanced = true; - await advanceDefaultBranch(fixture); - } - }, - }, - }); - const candidate = await remoteSha( - fixture.origin, - "automation/toolchain-baseline", - ); - const parent = ( - await execa("git", [ - "--git-dir", - fixture.origin, - "rev-parse", - `${candidate}^`, - ]) - ).stdout; - expect(parent).toBe(await remoteSha(fixture.origin, "main")); - expect(github.events).toEqual(["update:7"]); - }); - - it("refuses a concurrent candidate replacement instead of overwriting it", async () => { - const fixture = await createGitFixture(); - await pushAutomationCandidate(fixture); - const github = fakeGithub([7]); - const hooks = successfulHooks(fixture.checkout, []); - await expect( - runToolchainBaselineMaintenance({ - rootDirectory: fixture.checkout, - desired: desiredBaseline, - defaultBranch: "main", - owner: "example", - github: github.effects, - hooks: { - ...hooks, - async beforePush() { - await replaceAutomationCandidate(fixture); - }, - }, - }), - ).rejects.toThrow("candidate changed while checks ran"); - expect(github.events).toEqual([]); - }); - - it("does not push or open a pull request when the full check fails", async () => { - const fixture = await createGitFixture(); - const github = fakeGithub(); - const hooks = successfulHooks(fixture.checkout, []); - await expect( - runToolchainBaselineMaintenance({ - rootDirectory: fixture.checkout, - desired: desiredBaseline, - defaultBranch: "main", - owner: "example", - github: github.effects, - hooks: { - ...hooks, - async runChecks() { - throw new Error("check failed"); - }, - }, - }), - ).rejects.toThrow("check failed"); - expect( - await remoteSha(fixture.origin, "automation/toolchain-baseline"), - ).toBe(""); - expect(github.events).toEqual([]); - }); - - it("closes and deletes stale single-flight state when drift disappears", async () => { - const fixture = await createGitFixture(); - await pushAutomationCandidate(fixture); - const github = fakeGithub([7]); - await runToolchainBaselineMaintenance({ - rootDirectory: fixture.checkout, - desired: currentBaseline, - defaultBranch: "main", - owner: "example", - github: github.effects, - }); - expect( - await remoteSha(fixture.origin, "automation/toolchain-baseline"), - ).toBe(""); - expect(github.events).toEqual(["close:7"]); - }); - - it("fails clearly when an owned material is present but malformed", async () => { - const root = await mkdtemp(path.join(tmpdir(), "malformed-toolchain-")); - await writeFile( - path.join(root, "package.json"), - `${JSON.stringify({ engines: { node: "24" }, packageManager: "pnpm@11.11.0" })}\n`, - ); - await writeFile(path.join(root, ".devcontainer.json"), "{}\n"); - await expect( - updateToolchainBaselineMaterials(root, desiredBaseline), - ).resolves.toBeUndefined(); - const devcontainer = path.join(root, ".devcontainer"); - await execa("mkdir", ["-p", devcontainer]); - await writeFile( - path.join(devcontainer, "devcontainer.json"), - `${JSON.stringify({ build: {} })}\n`, - ); - await expect( - updateToolchainBaselineMaterials(root, desiredBaseline), - ).rejects.toThrow("build.args must be an object"); - }); -}); - -const currentBaseline = { - nodeLtsMajor: "24", - packageManagerPin: "pnpm@11.11.0", -} as const; -const desiredBaseline = { - nodeLtsMajor: "26", - packageManagerPin: "pnpm@12.1.0", -} as const; - -async function createGitFixture() { - const root = await mkdtemp(path.join(tmpdir(), "toolchain-origin-")); - const origin = path.join(root, "origin.git"); - const seed = path.join(root, "seed"); - const checkout = path.join(root, "checkout"); - await execa("git", ["init", "--bare", origin]); - await execa("git", ["init", "--initial-branch=main", seed]); - await execa("git", ["config", "user.name", "Fixture"], { cwd: seed }); - await execa("git", ["config", "user.email", "fixture@example.test"], { - cwd: seed, - }); - await writeFile( - path.join(seed, "package.json"), - `${JSON.stringify({ scripts: { check: 'node -e "process.exit(0)"' }, engines: { node: "24" }, packageManager: "pnpm@11.11.0" }, null, 2)}\n`, - ); - await writeFile( - path.join(seed, "pnpm-lock.yaml"), - "lockfileVersion: '9.0'\n", - ); - await execa("git", ["add", "."], { cwd: seed }); - await execa("git", ["commit", "-m", "initial"], { cwd: seed }); - await execa("git", ["remote", "add", "origin", origin], { cwd: seed }); - await execa("git", ["push", "-u", "origin", "main"], { cwd: seed }); - await execa("git", ["clone", origin, checkout]); - return { root, origin, seed, checkout }; -} - -function fakeGithub(initialPulls: number[] = []): { - effects: GithubEffects; - events: string[]; -} { - const pulls = [...initialPulls]; - const events: string[] = []; - return { - events, - effects: { - async findPullRequests() { - return pulls; - }, - async closePullRequest(number) { - events.push(`close:${number}`); - pulls.splice(0); - }, - async createPullRequest() { - events.push("create"); - pulls.push(1); - }, - async updatePullRequest(number) { - events.push(`update:${number}`); - }, - }, - }; -} - -function successfulHooks(root: string, sequence: string[]) { - return { - async installPackageManager() { - sequence.push("package-manager"); - }, - async updateLockfile() { - sequence.push("lockfile"); - await writeFile( - path.join(root, "pnpm-lock.yaml"), - "lockfileVersion: '9.0'\n# updated\n", - ); - }, - async prepareChecks() { - sequence.push("prepare"); - }, - async runChecks() { - sequence.push("check"); - }, - }; -} - -async function remoteSha(origin: string, branch: string): Promise { - const result = await execa( - "git", - ["--git-dir", origin, "rev-parse", `refs/heads/${branch}`], - { reject: false }, - ); - return result.exitCode === 0 ? result.stdout : ""; -} - -async function pushAutomationCandidate( - fixture: Awaited>, -) { - await execa("git", ["checkout", "-B", "automation/toolchain-baseline"], { - cwd: fixture.seed, - }); - await writeFile(path.join(fixture.seed, "candidate.txt"), "old\n"); - await execa("git", ["add", "."], { cwd: fixture.seed }); - await execa("git", ["commit", "-m", "old candidate"], { cwd: fixture.seed }); - await execa("git", ["push", "origin", "HEAD:automation/toolchain-baseline"], { - cwd: fixture.seed, - }); - await execa("git", ["checkout", "main"], { cwd: fixture.seed }); -} - -async function advanceDefaultBranch( - fixture: Awaited>, -) { - await writeFile(path.join(fixture.seed, "advanced.txt"), `${Date.now()}\n`); - await execa("git", ["add", "."], { cwd: fixture.seed }); - await execa("git", ["commit", "-m", "advance default"], { - cwd: fixture.seed, - }); - await execa("git", ["push", "origin", "main"], { cwd: fixture.seed }); -} - -async function replaceAutomationCandidate( - fixture: Awaited>, -) { - await execa("git", ["checkout", "automation/toolchain-baseline"], { - cwd: fixture.seed, - }); - await writeFile(path.join(fixture.seed, "candidate.txt"), `${Date.now()}\n`); - await execa("git", ["add", "."], { cwd: fixture.seed }); - await execa("git", ["commit", "-m", "replace candidate"], { - cwd: fixture.seed, - }); - await execa( - "git", - ["push", "--force", "origin", "HEAD:automation/toolchain-baseline"], - { cwd: fixture.seed }, - ); - await execa("git", ["checkout", "main"], { cwd: fixture.seed }); -} diff --git a/test/generation-context.test.ts b/test/generation-context.test.ts deleted file mode 100644 index cca339e..0000000 --- a/test/generation-context.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { mkdtemp, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import type { ProjectBlueprint } from "@ykdz/template-shared"; -import * as v from "valibot"; - -const packageJsonSchema = v.object({ - engines: v.object({ node: v.string() }), - packageManager: v.string(), -}); -const devcontainerSchema = v.object({ - build: v.object({ - dockerfile: v.string(), - args: v.object({ - NODE_VERSION: v.string(), - PACKAGE_MANAGER_PIN: v.string(), - }), - }), -}); -const generationRecordSchema = v.object({ - toolchain: v.object({ - nodeLtsMajor: v.string(), - packageManagerPin: v.string(), - source: v.string(), - }), -}); - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -describe("generation context", () => { - it("carries resolved Node and pnpm values as named toolchain values for the ts-lib tracer preset", () => { - const blueprint: ProjectBlueprint = { - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "single-package", - features: ["pnpm-catalog"], - }; - - const context = assembleGenerationContext({ - targetDir: "/tmp/demo-lib", - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - expect(context.projectName).toEqual({ - kind: "ProjectName", - value: "demo-lib", - }); - expect(context.preset).toBe("ts-lib"); - expect(context.packageManager).toEqual({ - kind: "PackageManager", - value: "pnpm", - }); - expect(context.toolchain.nodeLtsMajor).toEqual({ - kind: "NodeLtsMajor", - value: "24", - }); - expect(context.toolchain.packageManagerPin).toEqual({ - kind: "PackageManagerPin", - value: "pnpm@11.2.3", - }); - expect(context.toolchain.source).toBe("online"); - }); - - it("projects the generation context toolchain into ts-lib package metadata and generation record", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-generation-context-"), - ); - const targetDir = path.join(workspace, "demo-lib"); - const blueprint: ProjectBlueprint = { - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "single-package", - features: ["pnpm-catalog"], - }; - const context = assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - const projection = findBuiltInPresetProjection("ts-lib"); - const plan = projection?.project(context); - expect(projection).toBeDefined(); - expect(plan).toBeDefined(); - await projection!.render({ targetDir, plan: plan! }); - - const packageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const devcontainer = await readJsonWithSchema( - path.join(targetDir, ".devcontainer/devcontainer.json"), - devcontainerSchema, - ); - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - const generationRecord = await readJsonWithSchema( - path.join(targetDir, ".template/generated-by.json"), - generationRecordSchema, - ); - - expect(packageJson.engines.node).toBe("24"); - expect(packageJson.packageManager).toBe("pnpm@11.2.3"); - expect(devcontainer.build).toEqual({ - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@11.2.3", - }, - }); - expect(dockerfile).toContain("ARG NODE_VERSION"); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(generationRecord.toolchain).toEqual({ - nodeLtsMajor: "24", - packageManagerPin: "pnpm@11.2.3", - source: "online", - }); - }); -}); diff --git a/test/legacy-architecture-removal.test.ts b/test/legacy-architecture-removal.test.ts new file mode 100644 index 0000000..68c0c69 --- /dev/null +++ b/test/legacy-architecture-removal.test.ts @@ -0,0 +1,191 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + checkLegacyArchitectureRemoval, + findLegacyArchitectureFindings, + findLegacyArchitectureDistributionFindings, + findLegacyArchitectureTarballFindings, +} from "../packages/checks/src/check-legacy-architecture-removal.ts"; + +async function fixture(): Promise { + return await mkdtemp(path.join(tmpdir(), "template-removal-audit-")); +} + +describe("Legacy Architecture Removal Check", () => { + it("reports a focused finding for every protected surface", async () => { + const root = await fixture(); + try { + await Promise.all([ + mkdir(path.join(root, "packages/builtin-source"), { + recursive: true, + }), + mkdir(path.join(root, "packages/core/src"), { recursive: true }), + mkdir(path.join(root, "packages/cli/src"), { recursive: true }), + mkdir(path.join(root, "test"), { recursive: true }), + mkdir(path.join(root, "docs"), { recursive: true }), + ]); + await Promise.all([ + writeFile( + path.join(root, "packages/core/src/old.ts"), + `import { x } from "./preset-${"source"}.ts";\nexport { x } from "./preset-${"source"}.ts";\nexport type ${"Preset"}Source = typeof x;`, + ), + writeFile( + path.join(root, "packages/cli/src/cli.ts"), + `const help = "schema preset";`, + ), + writeFile( + path.join(root, "test/identity.test.ts"), + `const selected = "ts-${"lib"}"; if (selected === "ts-${"lib"}") {}`, + ), + writeFile(path.join(root, "docs/current.md"), `${"Preset"} Source`), + ]); + + const findings = await findLegacyArchitectureFindings(root); + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule: "retired-path" }), + expect.objectContaining({ rule: "legacy-import-export" }), + expect.objectContaining({ rule: "retired-symbol" }), + expect.objectContaining({ rule: "generic-preset-identity" }), + expect.objectContaining({ rule: "retired-vocabulary" }), + expect.objectContaining({ rule: "retired-cli-command" }), + expect.objectContaining({ rule: "identity-branch" }), + ]), + ); + await expect(checkLegacyArchitectureRemoval(root)).rejects.toThrow( + /\[retired-symbol\].*old\.ts/u, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("resolves composed identities, aliases, re-exports, and non-if catalogs", async () => { + const root = await fixture(); + try { + await Promise.all([ + mkdir(path.join(root, "packages/core/src"), { recursive: true }), + mkdir(path.join(root, "packages/cli/src"), { recursive: true }), + ]); + await Promise.all([ + writeFile( + path.join(root, "packages/core/src/legacy.ts"), + `export type ${"Preset"}Source = string;`, + ), + writeFile( + path.join(root, "packages/core/src/re-export.ts"), + `export { ${"Preset"}Source as Old } from "./legacy.ts";`, + ), + writeFile( + path.join(root, "packages/core/src/catalog.ts"), + [ + `import type { Old } from "./re-export.ts";`, + `const first = "ts-" + "lib";`, + `const catalog: readonly Old[] = [first, \`rust-${"bin"}\`];`, + `switch (first) { case "ts-lib": break; }`, + `const selected = first === "ts-lib" ? "yes" : "no";`, + `void [catalog, selected];`, + ].join("\n"), + ), + writeFile( + path.join(root, "packages/cli/src/help.ts"), + `const command = "schema" + " preset"; void command;`, + ), + ]); + + const findings = await findLegacyArchitectureFindings(root); + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule: "retired-symbol" }), + expect.objectContaining({ rule: "closed-identity-catalog" }), + expect.objectContaining({ rule: "identity-branch" }), + expect.objectContaining({ rule: "retired-cli-command" }), + ]), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("permits historical ADR vocabulary only with an ADR-0093 supersession note", async () => { + const root = await fixture(); + try { + await mkdir(path.join(root, "docs/adr"), { recursive: true }); + await writeFile( + path.join(root, "docs/adr/0001-old.md"), + `${"Preset"} Source`, + ); + await expect(checkLegacyArchitectureRemoval(root)).rejects.toThrow( + /historical-adr-status/u, + ); + await writeFile( + path.join(root, "docs/adr/0001-old.md"), + `Superseded by ADR-0093.\n\n${"Preset"} Source`, + ); + await expect( + checkLegacyArchitectureRemoval(root), + ).resolves.toBeUndefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects retired runtime and test paths in a packed public artifact", () => { + expect( + findLegacyArchitectureTarballFindings([ + "package/dist/cli.js", + `package/node_modules/@ykdz/template-builtin-${"source"}/index.js`, + "package/node_modules/@ykdz/template-builtin-presets/dist/src/example/behavior.test.js", + ]), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule: "packed-artifact" }), + ]), + ); + }); + + it("audits package exports, dependencies, and built declaration or JavaScript leakage", async () => { + const root = await fixture(); + try { + await Promise.all( + ["cli", "builtin-presets", "core"].map( + async (name) => + await mkdir(path.join(root, "packages", name, "dist"), { + recursive: true, + }), + ), + ); + await Promise.all([ + writeFile( + path.join(root, "packages/cli/package.json"), + JSON.stringify({ + exports: { "./registry-checks": "./dist/check.js" }, + }), + ), + writeFile( + path.join(root, "packages/builtin-presets/package.json"), + "{}", + ), + writeFile(path.join(root, "packages/core/package.json"), "{}"), + writeFile( + path.join(root, "packages/builtin-presets/dist/registry-checks.d.ts"), + "export {};", + ), + ]); + await expect( + findLegacyArchitectureDistributionFindings(root), + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ rule: "package-manifest-export" }), + expect.objectContaining({ rule: "built-artifact" }), + ]), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/module-graph.test.ts b/test/module-graph.test.ts deleted file mode 100644 index 20a1f7d..0000000 --- a/test/module-graph.test.ts +++ /dev/null @@ -1,677 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { - planRustBinChecks, - planRustBinFixes, - projectRustBinPackageScripts, -} from "@ykdz/template-builtin-source/templates/rust-bin/projection"; -import { projectTsLibPackageScripts } from "@ykdz/template-builtin-source/templates/ts-lib/projection"; -import { projectVueAppPackageScripts } from "@ykdz/template-builtin-source/templates/vue-app/projection"; -import { - projectVueHonoApiPackageScripts, - projectVueHonoRootPackageScripts, - projectVueHonoWebPackageScripts, -} from "@ykdz/template-builtin-source/templates/vue-hono-app/projection"; -import { - deploymentCheckEnvironmentNeeds, - renderDeploymentCheckCommand, - renderPlaywrightBrowserInstallCommand, - renderRootCheckCommand, - renderFixCommand, -} from "@ykdz/template-core/module-graph"; -import { - projectCheckWorkflow, - projectDependabotConfig, -} from "@ykdz/template-core/project-github"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); - -function rootCheckWorkflowCheckoutRef(): string { - const workflow = readFileSync( - path.join(repoRoot, ".github/workflows/check.yml"), - "utf8", - ); - const match = workflow.match(/uses:\s+(actions\/checkout@v\d+)/); - - if (!match?.[1]) { - throw new Error("Root check workflow must use actions/checkout@vN"); - } - - return match[1]; -} - -describe("module graph plans", () => { - it("selects semantic Check and Fix Components for the ts-lib workspace root", () => { - const projection = findBuiltInPresetProjection("ts-lib"); - const plan = projection!.project({ - projectName: { kind: "ProjectName", value: "demo-lib" }, - preset: "ts-lib", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection!.blueprint({ targetDir: "/tmp/demo-lib" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - expect(plan.checkPlan.components).toEqual([ - { - kind: "oxc-format-check", - owner: { kind: "workspace-orchestration", path: "." }, - }, - { - kind: "oxc-lint", - owner: { kind: "workspace-orchestration", path: "." }, - }, - { - kind: "typescript-typecheck", - owner: { kind: "workspace-orchestration", path: "." }, - }, - { - kind: "turbo-package-check", - owner: { kind: "package-boundary", path: "packages/*" }, - }, - ]); - - expect(plan.fixPlan.components).toEqual([ - { - kind: "oxc-format-write", - owner: { kind: "workspace-orchestration", path: "." }, - }, - { - kind: "oxc-lint-fix", - owner: { kind: "workspace-orchestration", path: "." }, - }, - { - kind: "turbo-package-fix", - owner: { kind: "package-boundary", path: "packages/*" }, - }, - ]); - }); - - it("renders ts-lib Root Check and Fix Command strings through Turbo", () => { - const projection = findBuiltInPresetProjection("ts-lib"); - const plan = projection!.project({ - projectName: { kind: "ProjectName", value: "demo-lib" }, - preset: "ts-lib", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection!.blueprint({ targetDir: "/tmp/demo-lib" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - const checkPlan = plan.checkPlan; - const fixPlan = plan.fixPlan; - - expect(checkPlan.components.map((component) => component.kind)).toEqual([ - "oxc-format-check", - "oxc-lint", - "typescript-typecheck", - "turbo-package-check", - ]); - expect(fixPlan.components.map((component) => component.kind)).toEqual([ - "oxc-format-write", - "oxc-lint-fix", - "turbo-package-fix", - ]); - - expect(renderRootCheckCommand(checkPlan)).toBe( - "turbo run format:check:run lint:run typecheck:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - ); - expect(renderFixCommand(fixPlan)).toBe( - "turbo run format:write:run lint:fix:run fix:run --output-logs=errors-only --log-order=grouped", - ); - }); - - it("renders Turbo package filters from package boundary ownership", () => { - expect( - renderRootCheckCommand({ - components: [ - { - kind: "turbo-package-typecheck", - owner: { kind: "package-boundary", path: "apps/*" }, - }, - { - kind: "turbo-package-check", - owner: { kind: "package-boundary", path: "apps/*" }, - }, - ], - environmentNeeds: [], - }), - ).toBe( - "turbo run typecheck:run format:check:run lint:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - ); - - expect( - renderFixCommand({ - components: [ - { - kind: "turbo-package-fix", - owner: { kind: "package-boundary", path: "apps/*" }, - }, - ], - }), - ).toBe( - "turbo run format:write:run lint:fix:run fix:run --output-logs=errors-only --log-order=grouped", - ); - }); - - it("projects ts-lib root and member package scripts from Check and Fix Plans", () => { - const projection = findBuiltInPresetProjection("ts-lib"); - const plan = projection!.project({ - projectName: { kind: "ProjectName", value: "demo-lib" }, - preset: "ts-lib", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection!.blueprint({ targetDir: "/tmp/demo-lib" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - expect(plan.packageScripts).toEqual({ - check: - "pnpm run check:boundaries && turbo run format:check:run lint:run typecheck:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - "check:boundaries": "turbo boundaries --no-color", - "check:run": 'node -e ""', - fix: "turbo run format:write:run lint:fix:run fix:run --output-logs=errors-only --log-order=grouped", - "fix:run": 'node -e ""', - "format:check": - "turbo run format:check:run --output-logs=errors-only --log-order=grouped", - "format:check:run": - "oxfmt --list-different oxlint.config.ts oxfmt.config.ts", - "format:write": - "turbo run format:write:run --output-logs=errors-only --log-order=grouped", - "format:write:run": "oxfmt --write oxlint.config.ts oxfmt.config.ts", - lint: "turbo run lint:run --output-logs=errors-only --log-order=grouped", - "lint:fix": - "turbo run lint:fix:run --output-logs=errors-only --log-order=grouped", - "lint:fix:run": - "oxlint --format=unix oxlint.config.ts oxfmt.config.ts --fix", - "lint:run": - "oxlint --quiet --format=unix oxlint.config.ts oxfmt.config.ts", - typecheck: - "turbo run typecheck:run --output-logs=errors-only --log-order=grouped", - "typecheck:run": "tsc -p tsconfig.config.json --noEmit --pretty false", - }); - expect(projectTsLibPackageScripts()).toEqual({ - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:run": - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "lint:fix:run": - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", - }); - }); - - it("projects vue-app package scripts and browser environment needs from Check and Fix Plans", () => { - const projection = findBuiltInPresetProjection("vue-app"); - const plan = projection!.project({ - projectName: { kind: "ProjectName", value: "demo-vue" }, - preset: "vue-app", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection!.blueprint({ targetDir: "/tmp/demo-vue" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - expect(projectVueAppPackageScripts()).toEqual({ - "build:run": "vite build", - dev: "vite", - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:run": - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "lint:fix:run": - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - preview: "vite preview", - "test:run": "vitest run --reporter=agent --silent=passed-only", - "test:e2e:run": "node scripts/run-playwright.ts", - "typecheck:run": - "node scripts/run-vue-tsc.ts --build --noEmit --pretty false", - }); - expect(plan.checkPlan.environmentNeeds).toEqual([ - { - kind: "playwright-browser-assets", - browser: "chromium", - owner: { kind: "package-boundary", path: "apps/web" }, - nextStep: { - id: "install-apps-web-playwright-browsers", - label: "Install Playwright browser assets for apps/web package", - command: "pnpm", - args: [ - "--filter", - "./apps/web", - "exec", - "playwright", - "install", - "chromium", - ], - display: "pnpm --filter ./apps/web exec playwright install chromium", - machineVerifiable: true, - }, - }, - ]); - expect( - renderPlaywrightBrowserInstallCommand( - plan.checkPlan.environmentNeeds[0]!, - ), - ).toBe("pnpm --filter ./apps/web exec playwright install chromium"); - }); - - it("projects vue-hono workspace scripts and preserves web Playwright package filtering", () => { - const projection = findBuiltInPresetProjection("vue-hono-app"); - const plan = projection!.project({ - projectName: { kind: "ProjectName", value: "demo-stack" }, - preset: "vue-hono-app", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection!.blueprint({ targetDir: "/tmp/demo-stack" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - const rootCheckPlan = plan.checkPlan; - const rootFixPlan = plan.fixPlan; - - expect(rootFixPlan.components).toEqual([ - { - kind: "oxc-format-write", - owner: { kind: "workspace-orchestration", path: "." }, - }, - { - kind: "oxc-lint-fix", - owner: { kind: "workspace-orchestration", path: "." }, - }, - { - kind: "turbo-package-fix", - owner: { kind: "package-boundary", path: "apps/*" }, - }, - ]); - expect(renderFixCommand(rootFixPlan)).toBe( - "turbo run format:write:run lint:fix:run fix:run --output-logs=errors-only --log-order=grouped", - ); - expect(projectVueHonoRootPackageScripts()).toEqual({ - check: - "pnpm run check:boundaries && turbo run format:check:run lint:run typecheck:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - "check:boundaries": "turbo boundaries --no-color", - "check:run": 'node -e ""', - dev: "turbo run dev --parallel", - fix: renderFixCommand(rootFixPlan), - "fix:run": 'node -e ""', - "format:check": - "turbo run format:check:run --output-logs=errors-only --log-order=grouped", - "format:check:run": - "oxfmt --list-different oxlint.config.ts oxfmt.config.ts", - "format:write": - "turbo run format:write:run --output-logs=errors-only --log-order=grouped", - "format:write:run": "oxfmt --write oxlint.config.ts oxfmt.config.ts", - lint: "turbo run lint:run --output-logs=errors-only --log-order=grouped", - "lint:fix": - "turbo run lint:fix:run --output-logs=errors-only --log-order=grouped", - "lint:fix:run": - "oxlint --format=unix oxlint.config.ts oxfmt.config.ts --fix", - "lint:run": - "oxlint --quiet --format=unix oxlint.config.ts oxfmt.config.ts", - typecheck: - "turbo run typecheck:run --output-logs=errors-only --log-order=grouped", - "typecheck:run": "tsc -p tsconfig.config.json --noEmit --pretty false", - }); - expect(projectVueHonoApiPackageScripts()).not.toHaveProperty("check"); - expect(projectVueHonoWebPackageScripts()).not.toHaveProperty("check"); - expect(rootCheckPlan.environmentNeeds).toEqual([ - { - kind: "playwright-browser-assets", - browser: "chromium", - owner: { kind: "package-boundary", path: "apps/web" }, - nextStep: { - id: "install-apps-web-playwright-browsers", - label: "Install Playwright browser assets for apps/web package", - command: "pnpm", - args: [ - "--filter", - "./apps/web", - "exec", - "playwright", - "install", - "chromium", - ], - display: "pnpm --filter ./apps/web exec playwright install chromium", - machineVerifiable: true, - }, - }, - ]); - expect( - renderPlaywrightBrowserInstallCommand(rootCheckPlan.environmentNeeds[0]!), - ).toBe("pnpm --filter ./apps/web exec playwright install chromium"); - }); - - it("projects rust-bin package scripts from Rust Check and Fix Plans", () => { - const checkPlan = planRustBinChecks(); - const fixPlan = planRustBinFixes(); - - expect(checkPlan.components.map((component) => component.kind)).toEqual([ - "rustfmt-check", - "cargo-clippy", - "cargo-test", - ]); - expect(fixPlan.components.map((component) => component.kind)).toEqual([ - "rustfmt-write", - ]); - expect(renderRootCheckCommand(checkPlan)).toBe( - "turbo run format:check:run lint:run test:run check:run --output-logs=errors-only --log-order=grouped", - ); - expect(renderFixCommand(fixPlan)).toBe( - "turbo run format:write:run fix:run --output-logs=errors-only --log-order=grouped", - ); - expect(renderFixCommand(fixPlan)).not.toContain("clippy"); - expect(projectRustBinPackageScripts()).toEqual({ - "format:check:run": "cargo fmt --all -- --check", - "format:write:run": "cargo fmt --all", - "lint:run": "cargo clippy --workspace --all-targets -- -D warnings", - "test:run": "cargo test --workspace", - }); - }); - - it("projects GitHub check workflows from CI Capability, environment preparation, Check Plans, and pnpm Task Layer", () => { - const projection = findBuiltInPresetProjection("ts-lib"); - const tsLibPlan = projection!.project({ - projectName: { kind: "ProjectName", value: "demo-lib" }, - preset: "ts-lib", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection!.blueprint({ targetDir: "/tmp/demo-lib" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - const workflow = projectCheckWorkflow({ - checkPlan: tsLibPlan.checkPlan, - environmentPreparation: { rustToolchain: false }, - }); - - expect(workflow).toContain( - ` - uses: ${rootCheckWorkflowCheckoutRef()}`, - ); - expect( - workflow.replace(/actions\/checkout@v\d+/, "actions/checkout@vN"), - ).toBe( - [ - "name: Check", - "", - "on:", - " pull_request:", - " push:", - " branches:", - " - main", - "", - "jobs:", - " check:", - " runs-on: ubuntu-latest", - " steps:", - " - uses: actions/checkout@vN", - " - uses: actions/setup-node@v6", - " with:", - " node-version-file: package.json", - " - run: corepack enable", - " - run: pnpm install", - " - run: pnpm run check", - "", - ].join("\n"), - ); - - const vueProjection = findBuiltInPresetProjection("vue-app"); - const vuePlan = vueProjection!.project({ - projectName: { kind: "ProjectName", value: "demo-vue" }, - preset: "vue-app", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: vueProjection!.blueprint({ targetDir: "/tmp/demo-vue" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - expect(projectCheckWorkflow({ checkPlan: vuePlan.checkPlan })).toContain( - " - run: pnpm --filter ./apps/web exec playwright install --with-deps chromium", - ); - const rustProjection = findBuiltInPresetProjection("rust-bin"); - const rustPlan = rustProjection!.project({ - projectName: { kind: "ProjectName", value: "demo-rust" }, - preset: "rust-bin", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: rustProjection!.blueprint({ targetDir: "/tmp/demo-rust" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - const rustWorkflow = projectCheckWorkflow({ - checkPlan: rustPlan.checkPlan, - environmentPreparation: { rustToolchain: true }, - }); - - expect(rustWorkflow).toContain( - " - uses: dtolnay/rust-toolchain@stable\n with:\n components: rustfmt, clippy", - ); - expect(rustWorkflow).toContain(" - uses: Swatinem/rust-cache@v2"); - }); - - it("derives additive deployment checks and Docker preparation from Check Plan facts", () => { - const vikeProjection = findBuiltInPresetProjection("vike-app"); - const vikePlan = vikeProjection!.project({ - projectName: { kind: "ProjectName", value: "demo-vike" }, - preset: "vike-app", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: vikeProjection!.blueprint({ targetDir: "/tmp/demo-vike" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - const deploymentCheck = vikePlan.checkPlan.deploymentChecks?.[0]; - - expect(deploymentCheck).toEqual({ - kind: "deployment-image", - owner: { kind: "package-boundary", path: "apps/web" }, - }); - expect(deploymentCheckEnvironmentNeeds(deploymentCheck!)).toEqual([ - { kind: "docker-engine" }, - ]); - expect(vikePlan.packageScripts.check).not.toContain("check:deployment"); - expect(vikePlan.packageScripts["check:deployment"]).toBe( - "pnpm --filter './apps/web' run check:deployment", - ); - - const workflow = projectCheckWorkflow({ checkPlan: vikePlan.checkPlan }); - expect(workflow).toContain("matrix:\n check: [root, deployment]"); - expect(workflow).toContain("docker/setup-buildx-action@v3"); - expect(workflow).toContain( - "- run: sudo apt-get update && sudo apt-get install -y shellcheck\n if: matrix.check == 'root'", - ); - expect(workflow).toContain( - " - run: pnpm run check\n if: matrix.check == 'root'", - ); - expect(workflow).toContain( - " - run: pnpm run check:deployment\n if: matrix.check == 'deployment'", - ); - expect(workflow).not.toMatch(/docker (?:build|push|login)/u); - - const vueProjection = findBuiltInPresetProjection("vue-app"); - const vuePlan = vueProjection!.project({ - projectName: { kind: "ProjectName", value: "demo-vue" }, - preset: "vue-app", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: vueProjection!.blueprint({ targetDir: "/tmp/demo-vue" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - const vueWorkflow = projectCheckWorkflow({ checkPlan: vuePlan.checkPlan }); - - expect(vuePlan.checkPlan.deploymentChecks).toBeUndefined(); - expect(vuePlan.packageScripts).not.toHaveProperty("check:deployment"); - expect(vueWorkflow).not.toContain("docker/setup-buildx-action"); - expect(vueWorkflow).not.toContain("matrix:"); - expect(vueWorkflow).not.toContain("check:deployment"); - }); - - it("aggregates deployment check owners behind one CI task entrypoint", () => { - const deploymentChecks = [ - { - kind: "deployment-image" as const, - owner: { kind: "package-boundary" as const, path: "apps/web" }, - }, - { - kind: "deployment-image" as const, - owner: { kind: "package-boundary" as const, path: "apps/admin" }, - }, - ]; - const checkPlan = { - components: [], - deploymentChecks, - environmentNeeds: [], - }; - - expect(renderDeploymentCheckCommand(checkPlan)).toBe( - "pnpm --filter './apps/web' run check:deployment && pnpm --filter './apps/admin' run check:deployment", - ); - - const workflow = projectCheckWorkflow({ checkPlan }); - expect(workflow.match(/docker\/setup-buildx-action@v3/gu)).toHaveLength(1); - expect(workflow.match(/- run: pnpm run check:deployment/gu)).toHaveLength( - 1, - ); - }); - - it("projects Dependabot config from Dependency Maintenance Policy separately from Check Plans", () => { - const projection = findBuiltInPresetProjection("ts-lib"); - const tsLibPlan = projection!.project({ - projectName: { kind: "ProjectName", value: "demo-lib" }, - preset: "ts-lib", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection!.blueprint({ targetDir: "/tmp/demo-lib" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - expect(projectDependabotConfig(tsLibPlan.dependencyMaintenancePolicy)).toBe( - [ - "version: 2", - "", - "updates:", - " - package-ecosystem: npm", - " directory: /", - " schedule:", - " interval: weekly", - " groups:", - " drizzle:", - " patterns:", - ' - "drizzle-*"', - ' - "drizzle-orm"', - " ignore:", - ' - dependency-name: "@types/node"', - " update-types:", - " - version-update:semver-major", - ' - dependency-name: "pnpm"', - " update-types:", - " - version-update:semver-major", - " - version-update:semver-minor", - " - version-update:semver-patch", - " - package-ecosystem: github-actions", - " directory: /", - " schedule:", - " interval: weekly", - " - package-ecosystem: docker", - " directory: /.devcontainer", - " schedule:", - " interval: weekly", - " ignore:", - " - dependency-name: mcr.microsoft.com/devcontainers/typescript-node", - " update-types:", - " - version-update:semver-major", - "", - ].join("\n"), - ); - - const rustProjection = findBuiltInPresetProjection("rust-bin"); - const rustPlan = rustProjection!.project({ - projectName: { kind: "ProjectName", value: "demo-rust" }, - preset: "rust-bin", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: rustProjection!.blueprint({ targetDir: "/tmp/demo-rust" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - expect( - projectDependabotConfig(rustPlan.dependencyMaintenancePolicy), - ).toContain("package-ecosystem: cargo"); - expect( - projectDependabotConfig(rustPlan.dependencyMaintenancePolicy), - ).toContain( - 'package-ecosystem: cargo\n directory: "/packages/demo-rust"', - ); - expect( - projectDependabotConfig(rustPlan.dependencyMaintenancePolicy), - ).toContain("package-ecosystem: rust-toolchain"); - const vueHonoProjection = findBuiltInPresetProjection("vue-hono-app"); - const vueHonoPlan = vueHonoProjection!.project({ - projectName: { kind: "ProjectName", value: "demo-stack" }, - preset: "vue-hono-app", - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: vueHonoProjection!.blueprint({ targetDir: "/tmp/demo-stack" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - expect( - projectDependabotConfig(vueHonoPlan.dependencyMaintenancePolicy), - ).toContain("package-ecosystem: npm"); - }); -}); diff --git a/test/next-step-instructions.test.ts b/test/next-step-instructions.test.ts deleted file mode 100644 index 1593c93..0000000 --- a/test/next-step-instructions.test.ts +++ /dev/null @@ -1,422 +0,0 @@ -import path from "node:path"; - -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import { - formatGeneratedFollowUpDocument, - generatedFollowUpDocumentOperation, - planNextStepInstructions, -} from "@ykdz/template-core/next-step-instructions"; -import type { PresetProjectionPlan } from "@ykdz/template-core/preset-projection"; - -const optionalGitDisplays = [ - "git init", - "git add .", - 'git commit -m "Initial commit"', -]; - -function projectPresetPlan(preset: string): PresetProjectionPlan { - const projection = findBuiltInPresetProjection(preset); - - if (!projection) { - throw new Error(`Missing test Preset Projection: ${preset}`); - } - - const targetDir = path.join("/", "tmp", `generated-${preset}`); - const blueprint = projection.blueprint({ targetDir }); - - return projection.project( - assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }), - ); -} - -describe("Next Step Instructions", () => { - it.each([ - ["ts-lib", ["pnpm run fix", "pnpm run check"]], - [ - "vue-app", - [ - "pnpm run fix", - "pnpm --filter ./apps/web exec playwright install chromium", - "pnpm run check", - ], - ], - [ - "vue-hono-app", - [ - "pnpm run fix", - "pnpm --filter ./apps/web exec playwright install chromium", - "pnpm run check", - ], - ], - ] satisfies Array<[string, string[]]>)( - "plans user-run instructions for a %s Preset without executable Post Commands", - (preset, commandDisplays) => { - const targetDir = path.join("/", "tmp", "generated-repository"); - - const plan = planNextStepInstructions({ - targetDir, - projectionPlan: projectPresetPlan(preset), - }); - - expect(plan.steps.slice(0, 2)).toEqual([ - { - id: "enter-project", - label: "Enter Generated Repository", - kind: "navigation", - command: "cd", - args: [targetDir], - cwd: ".", - display: `cd ${targetDir}`, - machineVerifiable: false, - }, - { - id: "install-dependencies", - label: "Install dependencies", - kind: "command", - command: "pnpm", - args: ["install"], - cwd: targetDir, - display: "pnpm install", - machineVerifiable: true, - }, - ]); - expect( - plan.steps - .slice(2, 2 + commandDisplays.length) - .map(({ kind, cwd, display, machineVerifiable }) => ({ - kind, - cwd, - display, - machineVerifiable, - })), - ).toEqual( - commandDisplays.map((display) => ({ - kind: "command", - cwd: targetDir, - display, - machineVerifiable: true, - })), - ); - expect( - plan.steps - .slice(2 + commandDisplays.length) - .map(({ kind, cwd, display, machineVerifiable }) => ({ - kind, - cwd, - display, - machineVerifiable, - })), - ).toEqual( - optionalGitDisplays.map((display) => ({ - kind: "command", - cwd: targetDir, - display, - machineVerifiable: false, - })), - ); - expect(plan.steps.map((step) => step.command)).not.toContain("corepack"); - }, - ); - - it("plans pnpm task-layer instructions for the Rust Preset", () => { - const targetDir = path.join("/", "tmp", "rust-repository"); - - const plan = planNextStepInstructions({ - targetDir, - projectionPlan: projectPresetPlan("rust-bin"), - }); - - expect(plan.steps.map((step) => step.display)).toEqual([ - `cd ${targetDir}`, - "pnpm install", - "pnpm run fix", - "pnpm run check", - ...optionalGitDisplays, - ]); - expect(plan.steps.slice(1)).toEqual([ - { - id: "install-dependencies", - label: "Install dependencies", - kind: "command", - command: "pnpm", - args: ["install"], - cwd: targetDir, - display: "pnpm install", - machineVerifiable: true, - }, - { - id: "run-fix", - label: "Run Fix Command", - kind: "command", - command: "pnpm", - args: ["run", "fix"], - cwd: targetDir, - display: "pnpm run fix", - machineVerifiable: true, - }, - { - id: "run-root-check", - label: "Run Root Check", - kind: "command", - command: "pnpm", - args: ["run", "check"], - cwd: targetDir, - display: "pnpm run check", - machineVerifiable: true, - }, - { - id: "optional-git-init", - label: "Optional: initialize git", - kind: "command", - command: "git", - args: ["init"], - cwd: targetDir, - display: "git init", - machineVerifiable: false, - }, - { - id: "optional-git-add", - label: "Optional: stage files", - kind: "command", - command: "git", - args: ["add", "."], - cwd: targetDir, - display: "git add .", - machineVerifiable: false, - }, - { - id: "optional-git-commit", - label: "Optional: create your first commit", - kind: "command", - command: "git", - args: ["commit", "-m", "Initial commit"], - cwd: targetDir, - display: 'git commit -m "Initial commit"', - machineVerifiable: false, - }, - ]); - }); - - it("renders a TODO.md follow-up document from project-local instructions", () => { - const targetDir = path.join("/", "tmp", "generated-repository"); - const plan = planNextStepInstructions({ - targetDir, - projectionPlan: projectPresetPlan("ts-lib"), - }); - - expect(formatGeneratedFollowUpDocument(plan)).toBe( - [ - "# TODO", - "", - "Generated follow-up tasks for this repository.", - "", - "### Next Steps", - "- [ ] Install dependencies", - " `pnpm install`", - "- [ ] Run Fix Command", - " `pnpm run fix`", - "- [ ] Run Root Check", - " `pnpm run check`", - "", - "### Optional Git Setup", - "- [ ] Initialize git", - " `git init`", - "- [ ] Stage files", - " `git add .`", - "- [ ] Create your first commit", - ' `git commit -m "Initial commit"`', - "", - "### Done ✓", - "", - ].join("\n"), - ); - expect(formatGeneratedFollowUpDocument(plan)).not.toContain( - `cd ${targetDir}`, - ); - expect(generatedFollowUpDocumentOperation(plan)).toEqual({ - kind: "writeText", - to: "TODO.md", - text: formatGeneratedFollowUpDocument(plan), - }); - }); - - it("derives Playwright setup guidance from Check Plan environment needs", () => { - const targetDir = path.join("/", "tmp", "custom-repository"); - - const plan = planNextStepInstructions({ - targetDir, - projectionPlan: { - sourceRoot: targetDir, - operations: [], - checkPlan: { - components: [], - environmentNeeds: [ - { - kind: "playwright-browser-assets", - browser: "chromium", - owner: { kind: "package-boundary", path: "apps/web" }, - nextStep: { - id: "install-apps-web-playwright-browsers", - label: "Install Playwright browser assets for apps/web package", - command: "pnpm", - args: [ - "--filter", - "./apps/web", - "exec", - "playwright", - "install", - "chromium", - ], - display: - "pnpm --filter ./apps/web exec playwright install chromium", - machineVerifiable: true, - }, - }, - ], - }, - fixPlan: { components: [] }, - dependencyMaintenancePolicy: { - ecosystems: ["npm", "github-actions"], - interval: "weekly", - }, - packageScripts: {}, - capabilities: { - rootCheck: true, - fixCommand: true, - githubActions: true, - dependabot: true, - devcontainer: true, - }, - }, - }); - - expect(plan.steps.map((step) => step.display)).toContain( - "pnpm --filter ./apps/web exec playwright install chromium", - ); - }); - - it("uses explicit Check Plan metadata for package browser environment preparation", () => { - const targetDir = path.join("/", "tmp", "custom-browser-repository"); - - const plan = planNextStepInstructions({ - targetDir, - projectionPlan: { - sourceRoot: targetDir, - operations: [], - checkPlan: { - components: [], - environmentNeeds: [ - { - kind: "playwright-browser-assets", - browser: "chromium", - owner: { kind: "package-boundary", path: "packages/client" }, - nextStep: { - id: "install-client-playwright-browsers", - label: "Install Playwright browser assets for client package", - command: "pnpm", - args: [ - "--filter", - "./packages/client", - "exec", - "playwright", - "install", - "chromium", - ], - display: - "pnpm --filter ./packages/client exec playwright install chromium", - machineVerifiable: true, - }, - }, - ], - }, - fixPlan: { components: [] }, - dependencyMaintenancePolicy: { - ecosystems: ["npm", "github-actions"], - interval: "weekly", - }, - packageScripts: {}, - capabilities: { - rootCheck: true, - fixCommand: true, - githubActions: true, - dependabot: true, - devcontainer: true, - }, - }, - }); - - expect( - plan.steps.map(({ id, label, display, machineVerifiable }) => ({ - id, - label, - display, - machineVerifiable, - })), - ).toEqual([ - { - id: "enter-project", - label: "Enter Generated Repository", - display: `cd ${targetDir}`, - machineVerifiable: false, - }, - { - id: "install-dependencies", - label: "Install dependencies", - display: "pnpm install", - machineVerifiable: true, - }, - { - id: "run-fix", - label: "Run Fix Command", - display: "pnpm run fix", - machineVerifiable: true, - }, - { - id: "install-client-playwright-browsers", - label: "Install Playwright browser assets for client package", - display: - "pnpm --filter ./packages/client exec playwright install chromium", - machineVerifiable: true, - }, - { - id: "run-root-check", - label: "Run Root Check", - display: "pnpm run check", - machineVerifiable: true, - }, - ...optionalGitDisplays.map((display) => ({ - id: - display === "git init" - ? "optional-git-init" - : display === "git add ." - ? "optional-git-add" - : "optional-git-commit", - label: - display === "git init" - ? "Optional: initialize git" - : display === "git add ." - ? "Optional: stage files" - : "Optional: create your first commit", - display, - machineVerifiable: false, - })), - ]); - expect( - plan.steps.find( - (step) => step.id === "install-client-playwright-browsers", - ), - ).toMatchObject({ - environmentNeedKind: "playwright-browser-assets", - }); - }); -}); diff --git a/test/package-linking.test.ts b/test/package-linking.test.ts deleted file mode 100644 index 3aab9cf..0000000 --- a/test/package-linking.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { - packageManifestExposureFields, - planPackageLinks, -} from "@ykdz/template-core/package-linking"; - -describe("Package Link Planning", () => { - it("derives JIT source exposure and Package-Local Imports for a TypeScript shared library", () => { - const plan = planPackageLinks([ - { - name: "@demo/shared", - path: "packages/shared", - role: "shared-library", - sourcePreset: "ts-lib", - }, - ]); - const exposure = plan.exposuresByPackagePath.get("packages/shared"); - - expect(exposure).toEqual({ - kind: "jit-source", - entrypoint: "./src/index.ts", - packageLocalImportPattern: "#/*", - packageLocalImportTarget: "./src/*.ts", - }); - expect(packageManifestExposureFields(exposure!)).toEqual({ - exports: { - ".": { - default: "./src/index.ts", - types: "./src/index.ts", - }, - }, - imports: { - "#/*": { - default: "./src/*.ts", - types: "./src/*.ts", - }, - }, - }); - }); - - it("derives compiled runtime exposure with source types for a Hono API service", () => { - const plan = planPackageLinks([ - { - name: "@demo/api", - path: "apps/api", - role: "runtime-service", - sourcePreset: "hono-api", - }, - ]); - const exposure = plan.exposuresByPackagePath.get("apps/api"); - - expect(exposure).toEqual({ - kind: "compiled", - entrypoint: "./dist/index.js", - sourceTypes: "./src/index.ts", - packageLocalImportPattern: "#/*", - packageLocalImportRuntimeTarget: "./dist/*.js", - packageLocalImportTypesTarget: "./src/*.ts", - }); - expect(packageManifestExposureFields(exposure!)).toEqual({ - types: "./src/index.ts", - exports: { - ".": { - default: "./dist/index.js", - types: "./src/index.ts", - }, - }, - imports: { - "#/*": { - default: "./dist/*.js", - types: "./src/*.ts", - }, - }, - }); - }); - - it("derives a workspace dependency and provider exposure for a consumer link intent", () => { - const plan = planPackageLinks( - [ - { - name: "@demo/api", - path: "apps/api", - role: "runtime-service", - sourcePreset: "hono-api", - }, - ], - [{ consumerPackagePath: "apps/web", providerPackagePath: "apps/api" }], - ); - - expect(plan.manifestDependenciesByPackagePath.get("apps/web")).toEqual({ - "@demo/api": "workspace:*", - }); - expect(plan.exposuresByPackagePath.get("apps/api")).toEqual( - expect.objectContaining({ kind: "compiled" }), - ); - }); - - it("derives one provider exposure and idempotent workspace dependencies for repeated link intents", () => { - const plan = planPackageLinks( - [ - { - name: "@demo/shared", - path: "packages/shared", - role: "shared-library", - sourcePreset: "ts-lib", - }, - ], - [ - { - consumerPackagePath: "apps/web", - providerPackagePath: "packages/shared", - }, - { - consumerPackagePath: "apps/api", - providerPackagePath: "packages/shared", - }, - { - consumerPackagePath: "apps/web", - providerPackagePath: "packages/shared", - }, - ], - ); - - expect([...plan.exposuresByPackagePath.keys()]).toEqual([ - "packages/shared", - ]); - expect(plan.manifestDependenciesByPackagePath.get("apps/web")).toEqual({ - "@demo/shared": "workspace:*", - }); - expect(plan.manifestDependenciesByPackagePath.get("apps/api")).toEqual({ - "@demo/shared": "workspace:*", - }); - }); - - it("rejects a Package Link Intent to a native provider in V1 TypeScript-only Project Linking", () => { - expect(() => - planPackageLinks( - [ - { - name: "@demo/shared", - path: "packages/shared", - role: "shared-library", - sourcePreset: "ts-lib", - }, - { - name: "demo-native", - path: "packages/native", - }, - ], - [ - { - consumerPackagePath: "packages/shared", - providerPackagePath: "packages/native", - }, - ], - ), - ).toThrow( - "Package Link Intent to native package packages/native is unsupported in V1 TypeScript-only Project Linking", - ); - }); - - it("rejects a Package Link Intent from a native package to a native provider in V1 TypeScript-only Project Linking", () => { - expect(() => - planPackageLinks( - [ - { - name: "demo-consumer", - path: "packages/consumer", - }, - { - name: "demo-provider", - path: "packages/provider", - }, - ], - [ - { - consumerPackagePath: "packages/consumer", - providerPackagePath: "packages/provider", - }, - ], - ), - ).toThrow( - "Package Link Intent from native package packages/consumer is unsupported in V1 TypeScript-only Project Linking", - ); - }); - - it("derives Turbo task relationships for typecheck invalidation and compiled runtime artifacts", () => { - const jitPlan = planPackageLinks( - [ - { - name: "@demo/web", - path: "apps/web", - role: "shared-library", - sourcePreset: "ts-lib", - }, - { - name: "@demo/shared", - path: "packages/shared", - role: "shared-library", - sourcePreset: "ts-lib", - }, - ], - [ - { - consumerPackagePath: "apps/web", - providerPackagePath: "packages/shared", - }, - ], - ); - - expect(jitPlan.turboTasks).toEqual({ - "format:check:run": {}, - "format:write:run": { cache: false }, - "lint:run": {}, - "lint:fix:run": { cache: false }, - "typecheck:run": { dependsOn: ["^typecheck:run"] }, - "build:run": { outputs: ["dist/**"] }, - "test:run": { dependsOn: ["^typecheck:run"] }, - "test:e2e:run": { dependsOn: ["build:run"] }, - "check:run": { cache: false }, - "fix:run": { cache: false }, - }); - - const compiledPlan = planPackageLinks( - [ - { - name: "@demo/web", - path: "apps/web", - role: "shared-library", - sourcePreset: "ts-lib", - }, - { - name: "@demo/api", - path: "apps/api", - role: "runtime-service", - sourcePreset: "hono-api", - }, - ], - [{ consumerPackagePath: "apps/web", providerPackagePath: "apps/api" }], - ); - - expect(compiledPlan.turboTasks).toEqual({ - "format:check:run": {}, - "format:write:run": { cache: false }, - "lint:run": {}, - "lint:fix:run": { cache: false }, - "typecheck:run": { dependsOn: ["^typecheck:run"] }, - "build:run": { dependsOn: ["^build:run"], outputs: ["dist/**"] }, - "test:run": { dependsOn: ["^typecheck:run"] }, - "test:e2e:run": { dependsOn: ["build:run", "^build:run"] }, - "check:run": { cache: false }, - "fix:run": { cache: false }, - }); - }); -}); diff --git a/test/package-publish.test.ts b/test/package-publish.test.ts deleted file mode 100644 index bd7ae43..0000000 --- a/test/package-publish.test.ts +++ /dev/null @@ -1,820 +0,0 @@ -import { - cp, - mkdir, - mkdtemp, - readdir, - readFile, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - loadBuiltInPresetSourceManifest, - projectBuiltInPresetSourcePreset, - type PresetSourceManifestPreset, -} from "@ykdz/template-builtin-source"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import type { PresetProjectionPlan } from "@ykdz/template-core/preset-projection"; -import { blueprintForPresetSourcePreset } from "@ykdz/template-core/projection-capabilities"; -import type { CopyFileOperation } from "@ykdz/template-core/renderer"; -import { execa } from "execa"; -import * as v from "valibot"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); -const templatesRoot = path.join( - repoRoot, - "packages", - "builtin-source", - "templates", -); -const packageJsonBinSchema = v.object({ - bin: v.record(v.string(), v.string()), -}); -const cliPackageJsonSchema = v.object({ - bin: v.record(v.string(), v.string()), - bundleDependencies: v.optional(v.array(v.string())), - dependencies: v.optional(v.record(v.string(), v.string())), - devDependencies: v.optional(v.record(v.string(), v.string())), - files: v.array(v.string()), - license: v.optional(v.string()), - name: v.string(), - private: v.boolean(), - publishConfig: v.optional(v.object({ access: v.optional(v.string()) })), - repository: v.optional( - v.object({ type: v.optional(v.string()), url: v.optional(v.string()) }), - ), - scripts: v.record(v.string(), v.string()), -}); -const builtinSourcePackageJsonSchema = v.object({ - files: v.array(v.string()), - name: v.string(), - private: v.boolean(), -}); -const runtimePackageJsonSchema = v.object({ - dependencies: v.optional(v.record(v.string(), v.string())), - files: v.array(v.string()), - name: v.string(), - private: v.boolean(), -}); -const packDryRunFileSchema = v.object({ path: v.string() }); -const packDryRunSchema = v.object({ - files: v.array(packDryRunFileSchema), -}); - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -process.env.TEMPLATE_TOOLCHAIN_NODE_RELEASE_INDEX_URL ??= jsonDataUrl([ - { version: "v24.11.0", lts: "Krypton" }, - { version: "v26.1.0", lts: false }, -]); -process.env.TEMPLATE_TOOLCHAIN_PNPM_REGISTRY_URL ??= jsonDataUrl({ - time: { "10.34.4": "2025-01-01T00:00:00.000Z" }, - versions: { - "10.34.4": { engines: { node: ">=18.12" } }, - }, -}); - -function jsonDataUrl(value: unknown): string { - return `data:application/json,${encodeURIComponent(JSON.stringify(value))}`; -} - -const packageRootFiles = [ - "Cargo.lock", - "Cargo.toml", - "package.json", - "pnpm-lock.yaml", - "pnpm-workspace.yaml", - "turbo.json", - "tsconfig.base.json", - "tsconfig.json", - "packages/cli/package.json", - "packages/cli/tsconfig.build.json", - "packages/cli/tsconfig.json", - "packages/cli/turbo.json", - "packages/core/package.json", - "packages/core/tsconfig.build.json", - "packages/core/tsconfig.json", - "packages/core/turbo.json", - "packages/shared/package.json", - "packages/shared/tsconfig.build.json", - "packages/shared/tsconfig.json", - "packages/builtin-source/package.json", - "packages/builtin-source/tsconfig.build.json", - "packages/builtin-source/tsconfig.json", - "packages/builtin-source/turbo.json", - "packages/checks/package.json", - "packages/checks/tsconfig.build.json", - "packages/checks/tsconfig.json", - "packages/checks/turbo.json", -]; - -const supportedPresetSourcePresets = - loadBuiltInPresetSourceManifest().presets.filter( - (preset) => - preset.generation === "supported" && preset.projection !== undefined, - ); -const packagePublishIntegrationTimeoutMs = 180_000; -const presetSourceTestPattern = - /^packages\/builtin-source\/templates\/[^/]+\/behavior\.test\.ts$/; -const packedPresetSourceTestPattern = - /^package\/node_modules\/@ykdz\/template-builtin-source\/templates\/[^/]+\/behavior\.test\.ts$/; -const publishedPackageRuntimeEnv = { - NODE_OPTIONS: (process.env.NODE_OPTIONS ?? "") - .replaceAll(/(?:^|\s)--conditions=source(?=\s|$)/g, " ") - .trim(), -}; - -async function listFiles(root: string): Promise { - const entries = await readdir(root, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const entryPath = path.join(root, entry.name); - - if (entry.isDirectory()) { - files.push(...(await listFiles(entryPath))); - continue; - } - - if (entry.isFile()) { - files.push(entryPath); - } - } - - return files; -} - -async function runtimeSourceFiles(): Promise { - const packageSourceFiles = ( - await Promise.all([ - listFiles(path.join(repoRoot, "packages/cli/src")), - listFiles(path.join(repoRoot, "packages/checks/src")), - listFiles(path.join(repoRoot, "packages/core/src")), - listFiles(path.join(repoRoot, "packages/shared/src")), - listFiles(path.join(repoRoot, "packages/builtin-source/src")), - listFiles(path.join(repoRoot, "packages/builtin-source/templates")), - ]) - ) - .flat() - .filter((file) => file.endsWith(".ts")) - .map((file) => relativeRepoPath(file)); - - return [ - ...packageSourceFiles.filter((file) => !presetSourceTestPattern.test(file)), - "packages/builtin-source/templates/preset-source.json", - ]; -} - -function projectionPlanFor( - preset: PresetSourceManifestPreset, -): PresetProjectionPlan { - const targetDir = path.join("/tmp", `generated-${preset.name}`); - const blueprint = blueprintForPresetSourcePreset(preset, { - targetDir, - scope: "acme", - }); - - return projectBuiltInPresetSourcePreset({ - preset, - context: assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@10.34.4" }, - source: "bundled-fallback", - diagnostics: [], - }, - }), - }); -} - -function projectionTemplateSourceFiles(): string[] { - const files = new Set(); - - for (const preset of supportedPresetSourcePresets) { - const plan = projectionPlanFor(preset); - const sourceRoots: Record = { - ...plan.sourceRoots, - default: plan.sourceRoot, - }; - - for (const operation of plan.operations) { - if (operation.kind === "writeTextFromFragments") { - for (const fragment of operation.fragments) { - const root = fragment.sourceRoot - ? sourceRoots[fragment.sourceRoot] - : sourceRoots.default; - - if (!root) { - throw new Error( - `Missing sourceRoot ${fragment.sourceRoot} for ${preset.name}`, - ); - } - - files.add(relativeRepoPath(path.join(root, fragment.from))); - } - - continue; - } - - if (operation.kind === "writeTextTemplate") { - const root = operation.sourceRoot - ? sourceRoots[operation.sourceRoot] - : sourceRoots.default; - - if (!root) { - throw new Error( - `Missing sourceRoot ${operation.sourceRoot} for ${preset.name}`, - ); - } - - files.add(relativeRepoPath(path.join(root, operation.from))); - continue; - } - - if (operation.kind === "copyFile") { - const root = operation.sourceRoot - ? sourceRoots[operation.sourceRoot] - : sourceRoots.default; - - if (!root) { - throw new Error( - `Missing sourceRoot ${operation.sourceRoot} for ${preset.name}`, - ); - } - - files.add(relativeRepoPath(path.join(root, operation.from))); - } - } - } - - return [...files]; -} - -function projectionOperationSourceFiles( - preset: PresetSourceManifestPreset, -): string[] { - const files = new Set(); - const plan = projectionPlanFor(preset); - const sourceRoots: Record = { - ...plan.sourceRoots, - default: plan.sourceRoot, - }; - - function sourceFileFor(from: string, sourceRootName: string | undefined) { - const root = - sourceRootName === undefined - ? sourceRoots.default - : sourceRoots[sourceRootName]; - - if (!root) { - throw new Error( - `Missing sourceRoot ${sourceRootName} for ${preset.name}`, - ); - } - - files.add(relativeRepoPath(path.join(root, from))); - } - - for (const operation of plan.operations) { - if (operation.kind === "writeTextFromFragments") { - for (const fragment of operation.fragments) { - sourceFileFor(fragment.from, fragment.sourceRoot); - } - continue; - } - - if (operation.kind === "writeTextTemplate") { - sourceFileFor(operation.from, operation.sourceRoot); - continue; - } - - if (operation.kind === "copyFile") { - sourceFileFor(operation.from, operation.sourceRoot); - } - } - - return [...files].toSorted(); -} - -function checkedGithubTemplateFiles(): string[] { - return supportedPresetSourcePresets.flatMap((preset) => [ - `packages/builtin-source/templates/${preset.name}/.github/dependabot.yml`, - `packages/builtin-source/templates/${preset.name}/.github/workflows/check.yml`, - ]); -} - -async function manifestReferencedTemplateFiles(): Promise { - const manifest = loadBuiltInPresetSourceManifest(); - const files = new Set(); - - async function addReference(referencePath: string): Promise { - const absolutePath = path.join(templatesRoot, referencePath); - const stats = await stat(absolutePath); - - if (stats.isDirectory()) { - for (const file of await listFiles(absolutePath)) { - files.add(relativeRepoPath(file)); - } - return; - } - - if (stats.isFile()) { - files.add(relativeRepoPath(absolutePath)); - } - } - - for (const resource of manifest.sharedResources) { - await addReference(resource.path); - } - - for (const preset of manifest.presets) { - for (const root of preset.source?.roots ?? []) { - await addReference(root); - } - - for (const file of preset.source?.files ?? []) { - await addReference(file); - } - } - - return [...files]; -} - -async function packageFiles(): Promise { - return [ - ...new Set([ - ...packageRootFiles, - ...(await runtimeSourceFiles()), - ...projectionTemplateSourceFiles(), - ...checkedGithubTemplateFiles(), - ...(await manifestReferencedTemplateFiles()), - "packages/builtin-source/templates/shared/editor-customization/capabilities.json", - "packages/builtin-source/templates/shared/oxc/tsconfig.json", - ]), - ].toSorted(); -} - -function relativeRepoPath(filePath: string): string { - return path.relative(repoRoot, filePath).split(path.sep).join("/"); -} - -async function copyCleanPackage(targetDir: string): Promise { - for (const file of await packageFiles()) { - const from = path.join(repoRoot, file); - const to = path.join(targetDir, file); - - await mkdir(path.dirname(to), { recursive: true }); - await cp(from, to); - } -} - -async function checkedTemplatePackagePaths(): Promise { - return [ - ...new Set([ - ...projectionTemplateSourceFiles(), - ...checkedGithubTemplateFiles(), - ...(await manifestReferencedTemplateFiles()), - ]), - ] - .map((file) => - file.replace( - "packages/builtin-source/", - "package/node_modules/@ykdz/template-builtin-source/", - ), - ) - .toSorted(); -} - -function firstCopyOperation( - preset: PresetSourceManifestPreset, -): CopyFileOperation { - const operation = projectionPlanFor(preset).operations.find( - (candidate) => candidate.kind === "copyFile", - ); - - if (!operation) { - throw new Error( - `Preset Projection ${preset.name} does not copy template source`, - ); - } - - return operation; -} - -describe("package publishing", () => { - it("declares public npm metadata for the template CLI", async () => { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, "packages/cli/package.json"), - cliPackageJsonSchema, - ); - - expect(packageJson.name).toBe("@ykdz/template"); - expect(packageJson.private).toBe(false); - expect(packageJson.license).toBe("MIT"); - expect(packageJson.repository).toEqual({ - type: "git", - url: "git+https://github.com/YKDZ/template.git", - }); - expect(packageJson.bin.template).toBe("dist/cli.js"); - expect(packageJson.dependencies ?? {}).not.toHaveProperty("execa"); - expect(packageJson.publishConfig?.access).toBe("public"); - }); - - it("bundles private runtime workspaces required by generation", async () => { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, "packages/cli/package.json"), - cliPackageJsonSchema, - ); - const privateRuntimeWorkspaces = [ - "@ykdz/template-shared", - "@ykdz/template-core", - "@ykdz/template-builtin-source", - ]; - - expect(packageJson.dependencies).toMatchObject( - Object.fromEntries( - privateRuntimeWorkspaces.map((workspace) => [workspace, "workspace:*"]), - ), - ); - expect(packageJson.bundleDependencies).toEqual( - expect.arrayContaining(privateRuntimeWorkspaces), - ); - expect(packageJson.files).toEqual( - expect.arrayContaining([ - "node_modules/@ykdz/template-shared", - "node_modules/@ykdz/template-core", - "node_modules/@ykdz/template-builtin-source", - ]), - ); - expect(packageJson.scripts).toMatchObject({ - "pack:bundled": "pnpm --config.node-linker=hoisted pack", - "publish:bundled": "pnpm --config.node-linker=hoisted publish", - }); - }); - - it("declares a narrow package surface for bundled runtime packages", async () => { - const builtinSourcePackageJson = await readJsonWithSchema( - path.join(repoRoot, "packages/builtin-source/package.json"), - builtinSourcePackageJsonSchema, - ); - const sharedPackageJson = await readJsonWithSchema( - path.join(repoRoot, "packages/shared/package.json"), - runtimePackageJsonSchema, - ); - - expect(builtinSourcePackageJson.name).toBe("@ykdz/template-builtin-source"); - expect(builtinSourcePackageJson.private).toBe(true); - expect(builtinSourcePackageJson.files).toEqual( - expect.arrayContaining(["templates", "!templates/*/behavior.test.ts"]), - ); - expect(sharedPackageJson.name).toBe("@ykdz/template-shared"); - expect(sharedPackageJson.private).toBe(true); - expect(sharedPackageJson.files).toEqual(["dist"]); - }); - - it("carries the TypeScript 6 Compiler API across the published CLI runtime boundary", async () => { - const cliPackageJson = await readJsonWithSchema( - path.join(repoRoot, "packages/cli/package.json"), - cliPackageJsonSchema, - ); - const corePackageJson = await readJsonWithSchema( - path.join(repoRoot, "packages/core/package.json"), - runtimePackageJsonSchema, - ); - - expect(corePackageJson.dependencies).toMatchObject({ - typescript: expect.any(String), - }); - expect(cliPackageJson.dependencies).toMatchObject({ - typescript: expect.any(String), - }); - expect(cliPackageJson.devDependencies).toMatchObject({ - "typescript-7": expect.any(String), - }); - expect(cliPackageJson.bundleDependencies).not.toContain("typescript"); - }); - - it("does not treat Preset Source behavior tests as packable template source", async () => { - expect(await packageFiles()).not.toEqual( - expect.arrayContaining([expect.stringMatching(presetSourceTestPattern)]), - ); - expect(await checkedTemplatePackagePaths()).not.toEqual( - expect.arrayContaining([ - expect.stringMatching(packedPresetSourceTestPattern), - ]), - ); - - const result = await execa( - "pnpm", - [ - "--filter", - "@ykdz/template-builtin-source", - "pack", - "--dry-run", - "--json", - ], - { cwd: repoRoot }, - ); - const dryRun = v.parse( - packDryRunSchema, - JSON.parse(result.stdout) as unknown, - ); - expect(dryRun.files.map((file) => file.path)).not.toEqual( - expect.arrayContaining([ - expect.stringMatching(/^templates\/[^/]+\/behavior\.test\.ts$/), - ]), - ); - }); - - it("does not copy Preset Source behavior tests into generated repositories", () => { - for (const preset of supportedPresetSourcePresets) { - expect(projectionOperationSourceFiles(preset)).not.toEqual( - expect.arrayContaining([ - expect.stringMatching(presetSourceTestPattern), - ]), - ); - } - }); - - it( - "packs a tarball with the advertised CLI from a clean unbuilt checkout", - async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-pack-")); - const packageDir = path.join(workspace, "package"); - const packDir = path.join(workspace, "pack"); - const consumerDir = path.join(workspace, "consumer"); - - await copyCleanPackage(packageDir); - await mkdir(packDir); - await mkdir(consumerDir); - await mkdir(path.join(packageDir, "packages/shared/.turbo/logs"), { - recursive: true, - }); - await writeFile( - path.join(packageDir, "packages/shared/.turbo/logs/build.log"), - "local turbo log\n", - ); - - await execa("pnpm", ["install", "--frozen-lockfile"], { - cwd: packageDir, - }); - await execa("pnpm", ["run", "build"], { cwd: packageDir }); - const sourceCli = await execa( - process.execPath, - ["packages/cli/src/cli.ts", "--help"], - { cwd: packageDir }, - ); - expect(sourceCli.stdout).toContain("Project Kit template CLI"); - await Promise.all( - ["shared", "core", "builtin-source", "checks", "cli"].map( - (packageName) => - rm(path.join(packageDir, "packages", packageName, "dist"), { - force: true, - recursive: true, - }), - ), - ); - await execa( - "pnpm", - ["run", "pack:bundled", "--pack-destination", packDir], - { cwd: path.join(packageDir, "packages/cli") }, - ); - - const packedFiles = await readdir(packDir); - const tarball = packedFiles.find((file) => file.endsWith(".tgz")); - expect(tarball).toBeDefined(); - - const tarballPath = path.join(packDir, tarball!); - const tarballContents = await execa("tar", ["-tf", tarballPath]); - const packedPaths = tarballContents.stdout.split("\n"); - const extractedTarballDir = path.join(workspace, "extracted"); - await mkdir(extractedTarballDir); - await execa("tar", ["-xf", tarballPath, "-C", extractedTarballDir]); - const packedManifestFiles = (await listFiles(extractedTarballDir)).filter( - (file) => path.basename(file) === "package.json", - ); - const packedManifestSources = await Promise.all( - packedManifestFiles.map((file) => readFile(file, "utf8")), - ); - - expect(packedManifestSources).not.toEqual( - expect.arrayContaining([expect.stringMatching(/"link:[^"]+"/)]), - ); - expect(packedPaths).toContain("package/dist/cli.js"); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-core/dist/devcontainer.js", - ); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-shared/dist/index.js", - ); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-shared/dist/index.d.ts", - ); - expect(packedPaths).not.toEqual( - expect.arrayContaining([ - "package/node_modules/@ykdz/template-shared/src/index.ts", - "package/node_modules/@ykdz/template-shared/tsconfig.build.json", - "package/node_modules/@ykdz/template-shared/tsconfig.json", - "package/node_modules/@ykdz/template-shared/.turbo/logs/build.log", - ]), - ); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-core/dist/generation-context.js", - ); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-core/dist/module-graph.js", - ); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-core/dist/next-step-instructions.js", - ); - expect(packedPaths).not.toContain("package/dist/post-commands.js"); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-builtin-source/templates/preset-source.json", - ); - expect(packedPaths).not.toContain("package/templates/preset-source.json"); - expect(packedPaths).not.toEqual( - expect.arrayContaining([ - expect.stringMatching(/^package\/templates\/.+/), - ]), - ); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-core/dist/toolchain-resolution.js", - ); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-core/dist/Cargo.toml", - ); - expect(packedPaths).toContain( - "package/node_modules/@ykdz/template-core/dist/Cargo.lock", - ); - expect( - packedPaths.filter((packedPath) => packedPath.endsWith(".map")), - ).toEqual([]); - expect(packedPaths).not.toContain( - "package/node_modules/@ykdz/template-builtin-source/templates/registry.ts", - ); - expect(packedPaths).not.toContain( - "package/node_modules/@ykdz/template-builtin-source/templates/projection-plans.ts", - ); - expect(packedPaths).not.toEqual( - expect.arrayContaining([ - expect.stringMatching( - /^package\/node_modules\/@ykdz\/template-builtin-source\/templates\/[^/]+\/projection\.ts$/, - ), - ]), - ); - expect(packedPaths).not.toEqual( - expect.arrayContaining([ - expect.stringMatching(packedPresetSourceTestPattern), - ]), - ); - const localTemplateArtifact = path.join( - templatesRoot, - `.package-publish-artifact-${process.pid}.tmp`, - ); - await writeFile(localTemplateArtifact, "not checked template source\n"); - try { - expect(packedPaths).toEqual( - expect.arrayContaining(await checkedTemplatePackagePaths()), - ); - expect(packedPaths).toEqual( - expect.arrayContaining([ - "package/node_modules/@ykdz/template-builtin-source/templates/shared/devcontainer/browser-test.Dockerfile", - "package/node_modules/@ykdz/template-builtin-source/templates/shared/devcontainer/node-pnpm.Dockerfile", - "package/node_modules/@ykdz/template-builtin-source/templates/shared/devcontainer/rust.Dockerfile", - "package/node_modules/@ykdz/template-builtin-source/templates/rust-bin/src/main.rs", - "package/node_modules/@ykdz/template-builtin-source/templates/shared/oxc/node/oxlint.config.ts", - "package/node_modules/@ykdz/template-builtin-source/templates/shared/oxc/vue/oxlint.config.ts", - "package/node_modules/@ykdz/template-builtin-source/templates/shared/oxc/oxfmt.config.ts", - ]), - ); - expect(packedPaths).not.toContain( - `package/node_modules/@ykdz/template-builtin-source/templates/${path.basename( - localTemplateArtifact, - )}`, - ); - } finally { - await rm(localTemplateArtifact, { force: true }); - } - expect(packedPaths).not.toContain( - "package/node_modules/@ykdz/template-builtin-source/templates/shared/oxc/oxc-config-modules.d.ts", - ); - expect(packedPaths).not.toContain( - "package/node_modules/@ykdz/template-builtin-source/templates/shared/oxc/package.json", - ); - expect(packedPaths).not.toContain( - "package/node_modules/@ykdz/template-builtin-source/templates/shared/oxc/tsconfig.json", - ); - - await writeFile( - path.join(consumerDir, "package.json"), - `${JSON.stringify({ type: "module" }, null, 2)}\n`, - ); - await execa("pnpm", ["add", tarballPath], { cwd: consumerDir }); - - const result = await execa("pnpm", ["exec", "template", "--help"], { - cwd: consumerDir, - env: publishedPackageRuntimeEnv, - }); - expect(result.stdout).toContain("Usage:"); - - for (const preset of supportedPresetSourcePresets) { - const presetName = preset.name; - const generatedDir = path.join(consumerDir, `generated-${presetName}`); - const copyOperation = firstCopyOperation(preset); - - await execa( - "pnpm", - [ - "exec", - "template", - "init", - generatedDir, - "--preset", - presetName, - "--yes", - ], - { cwd: consumerDir, env: publishedPackageRuntimeEnv }, - ); - const copiedTemplateSource = await readFile( - path.join(generatedDir, copyOperation.to), - "utf8", - ); - expect(copiedTemplateSource.length).toBeGreaterThan(0); - - if (presetName === "vike-app") { - const transformedServerSource = await readFile( - path.join(generatedDir, "apps/web/server/app.ts"), - "utf8", - ); - expect(transformedServerSource).toMatch( - /import \{ createDatabase \} from "@[^"]+\/db";/, - ); - expect(transformedServerSource).not.toContain( - "@template-anchor db-package-import", - ); - } - } - - const templateBin = path.join(consumerDir, "node_modules/.bin/template"); - const generatedWorkspaceDir = path.join( - consumerDir, - "generated-workspace", - ); - await execa( - "pnpm", - [ - "exec", - "template", - "init", - generatedWorkspaceDir, - "--preset", - "vue-hono-app", - "--yes", - ], - { cwd: consumerDir, env: publishedPackageRuntimeEnv }, - ); - await execa( - templateBin, - ["add", "package", "--preset", "ts-lib", "--name", "shared"], - { - cwd: generatedWorkspaceDir, - env: publishedPackageRuntimeEnv, - }, - ); - await expect( - readFile( - path.join(generatedWorkspaceDir, "packages/shared/src/index.ts"), - "utf8", - ), - ).resolves.toContain("export function greet"); - - const packageJsonPath = path.join( - consumerDir, - "node_modules/@ykdz/template/package.json", - ); - const packageJson = await readJsonWithSchema( - packageJsonPath, - packageJsonBinSchema, - ); - expect(packageJson.bin.template).toBe("dist/cli.js"); - }, - packagePublishIntegrationTimeoutMs, - ); -}); diff --git a/test/packed-publication.test.ts b/test/packed-publication.test.ts new file mode 100644 index 0000000..4425b4b --- /dev/null +++ b/test/packed-publication.test.ts @@ -0,0 +1,139 @@ +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { builtInPresetRegistry } from "@ykdz/template-builtin-presets"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; + +const publicCliPackageName = ["@ykdz", "template"].join("/"); + +describe("packed public CLI consumer", () => { + it("runs the archive alone: import, help, every initialization, and package addition", async () => { + const workspace = await mkdtemp( + path.join(tmpdir(), "template-packed-consumer-"), + ); + try { + const archiveDirectory = path.join(workspace, "archives"); + await execa( + "pnpm", + [ + "--config.node-linker=hoisted", + "--filter", + publicCliPackageName, + "pack", + "--pack-destination", + archiveDirectory, + ], + { cwd: process.cwd() }, + ); + const archive = (await readdir(archiveDirectory)).find((file) => + file.endsWith(".tgz"), + ); + expect(archive).toBeDefined(); + const consumer = path.join(workspace, "consumer"); + const archivePath = path.join(archiveDirectory, archive!); + await mkdir(consumer, { recursive: true }); + await execa("npm", ["init", "--yes"], { cwd: consumer }); + await execa("pnpm", ["add", archivePath], { cwd: consumer }); + + const cli = path.join( + consumer, + "node_modules", + "@ykdz", + "template", + "dist/cli.js", + ); + const bundledDefinitions = path.join( + consumer, + "node_modules", + "@ykdz", + "template", + "node_modules", + "@ykdz", + "template-builtin-presets", + "dist/src/index.js", + ); + await expect( + execa( + "node", + [ + "--input-type=module", + "-e", + "await import(process.argv[1])", + bundledDefinitions, + ], + { + cwd: consumer, + }, + ), + ).resolves.toMatchObject({ exitCode: 0 }); + const help = await execa("node", [cli, "--help"], { cwd: consumer }); + expect(help.stdout).toContain("template init "); + const presets = await execa("node", [cli, "presets"], { + cwd: consumer, + }); + const definitions = presets.stdout.split("\n").flatMap((line) => { + const match = /^\s{2}([^:\s]+):/u.exec(line); + return match === null ? [] : [{ name: match[1]! }]; + }); + expect(definitions.length).toBeGreaterThan(0); + for (const definition of definitions) { + await execa( + "node", + [ + cli, + "init", + path.join(consumer, "generated", definition.name), + "--preset", + definition.name, + "--yes", + ], + { + cwd: consumer, + env: { + ...process.env, + TEMPLATE_TOOLCHAIN_RESOLUTION: "bundled-fallback", + }, + }, + ); + } + const expectedAddableDefinitions = builtInPresetRegistry + .all() + .filter((definition) => definition.planPackageAddition !== undefined) + .map((definition) => definition.metadata.name) + .toSorted(); + const completedAdditions: string[] = []; + for (const candidate of definitions) { + const result = await execa( + "node", + [ + cli, + "add", + "package", + "--preset", + candidate.name, + "--name", + "archive-addition", + "--path", + "packages/archive-addition", + ], + { + cwd: path.join(consumer, "generated", candidate.name), + env: { + ...process.env, + TEMPLATE_TOOLCHAIN_RESOLUTION: "bundled-fallback", + }, + reject: false, + }, + ); + if (result.exitCode === 0) { + completedAdditions.push(candidate.name); + } + } + expect(completedAdditions.toSorted()).toEqual(expectedAddableDefinitions); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/test/pnpm-workspace-policy.test.ts b/test/pnpm-workspace-policy.test.ts index 51e1b95..a7c7fcc 100644 --- a/test/pnpm-workspace-policy.test.ts +++ b/test/pnpm-workspace-policy.test.ts @@ -10,7 +10,9 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { builtInPresetRegistry } from "@ykdz/template-builtin-presets"; import { renderGeneratedPnpmWorkspaceYaml } from "@ykdz/template-core/dependency-catalog"; +import type { GenerationContext } from "@ykdz/template-core/preset-definition"; import { execa } from "execa"; const packageManagerPin = "pnpm@11.11.0"; @@ -19,7 +21,24 @@ const repoRoot = path.resolve( "..", ); -async function generateTsLibProject(prefix: string): Promise { +function definitionForPnpmPolicy(context: GenerationContext) { + const definition = builtInPresetRegistry.all().find((candidate) => { + const contributions = candidate.planInitializationContributions?.( + context, + ) ?? [candidate.planInitialization(context)]; + return contributions.every( + (contribution) => contribution.foundation.toolchains.rust === undefined, + ); + }); + if (definition === undefined) { + throw new Error( + "The pnpm Workspace Policy check requires a Node-only Built-in Preset Definition", + ); + } + return definition; +} + +async function generateNodeOnlyProject(prefix: string): Promise { const workspace = await mkdtemp(path.join(tmpdir(), prefix)); const projectDir = path.join(workspace, "demo-lib"); const toolchainEnvironment = { @@ -36,6 +55,12 @@ async function generateTsLibProject(prefix: string): Promise { }), )}`, }; + const context = { + targetDir: projectDir, + projectName: "demo-lib", + scope: "demo-lib", + toolchain: { nodeLtsMajor: "24", packageManagerPin }, + } satisfies GenerationContext; await execa( "node", @@ -45,7 +70,7 @@ async function generateTsLibProject(prefix: string): Promise { "init", projectDir, "--preset", - "ts-lib", + definitionForPnpmPolicy(context).metadata.name, "--yes", ], { cwd: repoRoot, env: toolchainEnvironment }, @@ -65,6 +90,25 @@ async function dockerIsAvailable(): Promise { const hasDocker = await dockerIsAvailable(); describe("pnpm Workspace Policy", () => { + it("selects a Node-only Definition by contribution semantics", () => { + const context = { + targetDir: "/tmp/pnpm-policy-definition", + projectName: "pnpm-policy-definition", + scope: "pnpm-policy-definition", + toolchain: { nodeLtsMajor: "24", packageManagerPin }, + } satisfies GenerationContext; + const definition = definitionForPnpmPolicy(context); + const contributions = definition.planInitializationContributions?.( + context, + ) ?? [definition.planInitialization(context)]; + + expect( + contributions.every( + (contribution) => contribution.foundation.toolchains.rust === undefined, + ), + ).toBe(true); + }); + it("installs an injected workspace dependency from a frozen lockfile and synchronizes its build output", async () => { const root = await mkdtemp(path.join(tmpdir(), "pnpm-workspace-policy-")); const provider = path.join(root, "packages/provider"); @@ -147,7 +191,7 @@ describe("pnpm Workspace Policy", () => { }, 30_000); it("installs a real rendered Preset from its frozen pnpm 11 lockfile", async () => { - const projectDir = await generateTsLibProject("pnpm-rendered-preset-"); + const projectDir = await generateNodeOnlyProject("pnpm-rendered-preset-"); const environment = { ...process.env, CI: "1" }; await execa( @@ -172,7 +216,7 @@ describe("pnpm Workspace Policy", () => { return; } - const projectDir = await generateTsLibProject("pnpm-corepack-users-"); + const projectDir = await generateNodeOnlyProject("pnpm-corepack-users-"); const imageIdFile = path.join(projectDir, ".devcontainer-image-id"); let imageId: string | undefined; diff --git a/test/preset-registry.test.ts b/test/preset-registry.test.ts deleted file mode 100644 index fa1cdbc..0000000 --- a/test/preset-registry.test.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { mkdtemp, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { - builtInPresetProjectionSourceRoots, - builtInPresets, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { loadTemplateDependencyCatalog } from "@ykdz/template-core/dependency-catalog"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import { - blueprintForPresetSourcePreset, - defaultPackagePathForPresetSourcePackageAddition, -} from "@ykdz/template-core/projection-capabilities"; -import { - PackageAdditionSupport, - validateProjectBlueprint, -} from "@ykdz/template-shared"; -import * as v from "valibot"; - -const playwrightCliPackage = `@playwright/test@${ - loadTemplateDependencyCatalog()["@playwright/test"] -}`; -const rootCheckScript = - "pnpm run check:boundaries && turbo run format:check:run lint:run typecheck:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped"; -const rootFixScript = - "turbo run format:write:run lint:fix:run fix:run --output-logs=errors-only --log-order=grouped"; - -const packageJsonSchema = v.object({ - name: v.optional(v.string()), - engines: v.object({ node: v.string() }), - packageManager: v.string(), - devDependencies: v.optional(v.record(v.string(), v.string())), - scripts: v.record(v.string(), v.string()), -}); -const packageJsonWithScriptsSchema = v.object({ - name: v.string(), - version: v.optional(v.string()), - private: v.optional(v.boolean()), - engines: v.optional(v.object({ node: v.string() })), - scripts: v.record(v.string(), v.string()), -}); -const generationRecordSchema = v.object({ - command: v.optional(v.string()), - toolchain: v.object({ - nodeLtsMajor: v.string(), - packageManagerPin: v.string(), - source: v.optional(v.string()), - }), -}); -const devcontainerSchema = v.looseObject({ - name: v.optional(v.string()), - build: v.optional( - v.object({ - dockerfile: v.optional(v.string()), - args: v.optional(v.record(v.string(), v.string())), - }), - ), - customizations: v.object({ - vscode: v.object({ - extensions: v.array(v.string()), - settings: v.record(v.string(), v.unknown()), - }), - }), - features: v.optional(v.unknown()), - mounts: v.optional(v.array(v.string())), -}); - -function isVueBrowserNodePackageKind(kind: string): boolean { - switch (kind) { - case "vue-app": - case "vike-app": - return true; - default: - return false; - } -} - -function supportedPresetNamesWithVueBrowserPackage(): string[] { - return loadBuiltInPresetSourceManifest() - .presets.filter( - (preset) => - preset.generation === "supported" && - (preset.projection?.capabilities ?? []).some( - (capability) => - capability.kind === "workspace-node-packages" && - capability.packages.some((nodePackage) => - isVueBrowserNodePackageKind(nodePackage.kind), - ), - ), - ) - .map((preset) => preset.name); -} - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return v.parse( - schema, - JSON.parse(await readFile(filePath, "utf8")) as unknown, - ); -} - -describe("Preset Registry", () => { - it("advertises only workspace monorepo Project Shape metadata for built-in presets", () => { - expect( - builtInPresets.map((preset) => ({ - name: preset.name, - supportedProjectKinds: preset.supportedProjectKinds, - })), - ).toEqual( - expect.arrayContaining([ - { name: "ts-lib", supportedProjectKinds: ["multi-package"] }, - { name: "vue-app", supportedProjectKinds: ["multi-package"] }, - { name: "vue-hono-app", supportedProjectKinds: ["multi-package"] }, - { name: "rust-bin", supportedProjectKinds: ["multi-package"] }, - ]), - ); - expect( - builtInPresets.flatMap((preset) => preset.supportedProjectKinds), - ).not.toContain("single-package"); - }); - - it("declares Package Addition Support consistently with Projection Declarations", () => { - const supportedPresets = loadBuiltInPresetSourceManifest().presets.filter( - (preset) => preset.generation === "supported", - ); - - for (const preset of supportedPresets) { - const projection = findBuiltInPresetProjection(preset.name); - const expectedPackageAdditionSupport = - projection?.capabilities?.packageAddition === undefined - ? PackageAdditionSupport.Unsupported - : PackageAdditionSupport.Supported; - - expect(preset.packageAdditionSupport).toBe( - expectedPackageAdditionSupport, - ); - expect(projection).toBeDefined(); - - if (projection?.capabilities?.packageAddition !== undefined) { - expect( - defaultPackagePathForPresetSourcePackageAddition( - preset, - "example", - builtInPresetProjectionSourceRoots(), - ), - ).toMatch(/^(apps|packages)\/example$/); - } - } - }); - - it("generates valid workspace monorepo Project Blueprints for supported presets", async () => { - const supportedPresets = loadBuiltInPresetSourceManifest().presets.filter( - (preset) => preset.generation === "supported", - ); - - for (const preset of supportedPresets) { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-preset-blueprint-"), - ); - const targetDir = path.join(workspace, `demo-${preset.name}`); - const blueprint = blueprintForPresetSourcePreset(preset, { - targetDir, - scope: "acme", - }); - const validation = validateProjectBlueprint(blueprint, { - presets: builtInPresets, - }); - - expect(validation).toMatchObject({ ok: true }); - expect(blueprint.projectKind).toBe("multi-package"); - } - }); - - it("projects a ts-lib Generated Repository through the Preset Projection contract", async () => { - const projection = findBuiltInPresetProjection("ts-lib"); - expect(projection?.metadata).toMatchObject({ - name: "ts-lib", - title: "TypeScript library", - generation: "supported", - }); - - const workspace = await mkdtemp( - path.join(tmpdir(), "template-preset-registry-"), - ); - const targetDir = path.join(workspace, "demo-lib"); - const blueprint = projection!.blueprint({ targetDir }); - const context = assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - const plan = projection!.project(context); - await projection!.render({ targetDir, plan }); - - expect( - plan.checkPlan.components.map((component) => component.kind), - ).toEqual([ - "oxc-format-check", - "oxc-lint", - "typescript-typecheck", - "turbo-package-check", - ]); - expect(plan.fixPlan.components.map((component) => component.kind)).toEqual([ - "oxc-format-write", - "oxc-lint-fix", - "turbo-package-fix", - ]); - expect(plan.packageScripts.check).toBe(rootCheckScript); - - const packageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const libraryPackageJson = await readJsonWithSchema( - path.join(targetDir, "packages/demo-lib/package.json"), - packageJsonWithScriptsSchema, - ); - const generationRecord = await readJsonWithSchema( - path.join(targetDir, ".template/generated-by.json"), - generationRecordSchema, - ); - - expect(packageJson.scripts).toEqual(plan.packageScripts); - expect(packageJson.engines.node).toBe("24"); - expect(packageJson.packageManager).toBe("pnpm@11.2.3"); - expect(libraryPackageJson.name).toBe("@demo-lib/demo-lib"); - expect(libraryPackageJson.scripts["typecheck:run"]).toBe( - "tsc -p tsconfig.json --noEmit --pretty false", - ); - expect(generationRecord).toMatchObject({ - command: "template init --preset ts-lib", - toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.2.3" }, - }); - }); - - it("projects vue-hono-app with browser checks but no globally installed Turbo", async () => { - const projection = findBuiltInPresetProjection("vue-hono-app"); - const workspace = await mkdtemp( - path.join(tmpdir(), "template-preset-registry-"), - ); - const targetDir = path.join(workspace, "demo-vue-hono"); - const blueprint = projection!.blueprint({ - targetDir, - scope: "acme", - }); - const context = assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - const plan = projection!.project(context); - await projection!.render({ targetDir, plan }); - - const rootPackageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const devcontainerText = await readFile( - path.join(targetDir, ".devcontainer/devcontainer.json"), - "utf8", - ); - const devcontainer = v.parse( - devcontainerSchema, - JSON.parse(devcontainerText) as unknown, - ); - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - - expect(Object.keys(devcontainer)).toEqual([ - "name", - "build", - "customizations", - ]); - expect(devcontainer.build).toEqual({ - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@11.2.3", - PLAYWRIGHT_CLI_PACKAGE: playwrightCliPackage, - }, - }); - expect(devcontainer).not.toHaveProperty("features"); - expect(devcontainer.customizations.vscode.extensions).toContain( - "Vue.volar", - ); - expect(devcontainer.customizations.vscode.settings).toHaveProperty( - "oxc.enable", - true, - ); - expect(devcontainer.customizations.vscode.settings).toHaveProperty( - "oxc.configPath", - "./oxlint.config.ts", - ); - expect(devcontainerText).toMatch( - /^\{\n "name": "demo-vue-hono",\n "build": \{/, - ); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain("ARG PLAYWRIGHT_CLI_PACKAGE"); - expect(dockerfile).toContain( - 'npx --yes --package "${PLAYWRIGHT_CLI_PACKAGE}" playwright install-deps chromium', - ); - expect(dockerfile).not.toContain( - "npx --yes playwright install-deps chromium", - ); - expect(dockerfile).not.toContain("libnss3"); - expect(dockerfile).not.toContain("libgbm1"); - expect(dockerfile).not.toContain("xvfb"); - expect(dockerfile).not.toMatch(/\b(?:npm|pnpm|corepack)\s+.*-g\s+turbo\b/); - expect(rootPackageJson.devDependencies?.turbo).toBe("catalog:"); - expect(rootPackageJson.scripts.check).toBe(rootCheckScript); - expect(rootPackageJson.scripts.dev).toBe("turbo run dev --parallel"); - }); - - it.each(supportedPresetNamesWithVueBrowserPackage())( - "projects %s package metadata from the Generation Context toolchain", - async (preset) => { - const projection = findBuiltInPresetProjection(preset); - expect(projection?.metadata).toMatchObject({ - name: preset, - generation: "supported", - }); - - const workspace = await mkdtemp( - path.join(tmpdir(), "template-preset-registry-"), - ); - const targetDir = path.join(workspace, `demo-${preset}`); - const blueprint = projection!.blueprint({ - targetDir, - scope: "acme", - }); - const context = assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@11.2.3", - }, - source: "online", - diagnostics: [], - }, - }); - - const plan = projection!.project(context); - await projection!.render({ targetDir, plan }); - - const packageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const generationRecord = await readJsonWithSchema( - path.join(targetDir, ".template/generated-by.json"), - generationRecordSchema, - ); - const devcontainerText = await readFile( - path.join(targetDir, ".devcontainer/devcontainer.json"), - "utf8", - ); - const devcontainer = v.parse( - devcontainerSchema, - JSON.parse(devcontainerText) as unknown, - ); - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - - expect(packageJson.engines.node).toBe("24"); - expect(packageJson.packageManager).toBe("pnpm@11.2.3"); - expect(generationRecord.toolchain).toEqual({ - nodeLtsMajor: "24", - packageManagerPin: "pnpm@11.2.3", - source: "online", - }); - expect(Object.keys(devcontainer)).toEqual([ - "name", - "build", - "customizations", - ]); - expect(devcontainer.build).toEqual({ - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@11.2.3", - PLAYWRIGHT_CLI_PACKAGE: playwrightCliPackage, - }, - }); - expect(devcontainer).not.toHaveProperty("features"); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(dockerfile).toContain("ARG PLAYWRIGHT_CLI_PACKAGE"); - expect(dockerfile).toContain( - 'npx --yes --package "${PLAYWRIGHT_CLI_PACKAGE}" playwright install-deps chromium', - ); - expect(dockerfile).not.toContain( - "npx --yes playwright install-deps chromium", - ); - }, - ); - - it("projects rust-bin Generated Repository behavior through the Preset Projection contract", async () => { - const projection = findBuiltInPresetProjection("rust-bin"); - expect(projection?.metadata).toMatchObject({ - name: "rust-bin", - title: "Rust binary", - generation: "supported", - }); - - const workspace = await mkdtemp( - path.join(tmpdir(), "template-preset-registry-"), - ); - const targetDir = path.join(workspace, "Demo Rust!"); - const blueprint = projection!.blueprint({ targetDir }); - const context = assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }); - - const plan = projection!.project(context); - await projection!.render({ targetDir, plan }); - - expect( - plan.checkPlan.components.map((component) => component.kind), - ).toEqual(["typescript-typecheck", "turbo-package-check"]); - expect(plan.fixPlan.components.map((component) => component.kind)).toEqual([ - "turbo-package-fix", - ]); - expect(plan.packageScripts).toEqual({ - check: - "pnpm run check:boundaries && turbo run typecheck:run format:check:run lint:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - "check:boundaries": "turbo boundaries --no-color", - "check:run": 'node -e ""', - fix: rootFixScript, - "fix:run": 'node -e ""', - typecheck: - "turbo run typecheck:run --output-logs=errors-only --log-order=grouped", - "typecheck:run": "tsc -p tsconfig.config.json --noEmit --pretty false", - }); - expect(plan.dependencyMaintenancePolicy.ecosystems).toEqual([ - "npm", - "cargo", - "github-actions", - "docker", - "rust-toolchain", - ]); - - const packageJson = await readJsonWithSchema( - path.join(targetDir, "package.json"), - packageJsonSchema, - ); - const rustPackageJson = await readJsonWithSchema( - path.join(targetDir, "packages/demo-rust/package.json"), - packageJsonWithScriptsSchema, - ); - const generationRecord = await readJsonWithSchema( - path.join(targetDir, ".template/generated-by.json"), - generationRecordSchema, - ); - const devcontainerText = await readFile( - path.join(targetDir, ".devcontainer/devcontainer.json"), - "utf8", - ); - const devcontainer = v.parse( - devcontainerSchema, - JSON.parse(devcontainerText) as unknown, - ); - const dockerfile = await readFile( - path.join(targetDir, ".devcontainer/Dockerfile"), - "utf8", - ); - const rustToolchain = await readFile( - path.join(targetDir, "rust-toolchain.toml"), - "utf8", - ); - const cargoToml = await readFile( - path.join(targetDir, "packages/demo-rust/Cargo.toml"), - "utf8", - ); - const checkWorkflow = await readFile( - path.join(targetDir, ".github/workflows/check.yml"), - "utf8", - ); - const dependabot = await readFile( - path.join(targetDir, ".github/dependabot.yml"), - "utf8", - ); - - expect(packageJson.name).toBe("demo-rust"); - expect(packageJson.scripts).toEqual(plan.packageScripts); - expect(packageJson.devDependencies).toEqual({ - "@types/node": "catalog:", - "@types/semver": "catalog:", - semver: "catalog:", - turbo: "catalog:", - "typescript-7": "catalog:", - }); - expect(packageJson.engines.node).toBe("24"); - expect(packageJson.packageManager).toBe("pnpm@11.2.3"); - expect(rustPackageJson).toEqual({ - name: "demo-rust-native", - version: "0.0.0", - private: true, - scripts: { - "format:check:run": "cargo fmt --all -- --check", - "format:write:run": "cargo fmt --all", - "lint:run": "cargo clippy --workspace --all-targets -- -D warnings", - "test:run": "cargo test --workspace", - }, - engines: { - node: "24", - }, - }); - expect(generationRecord).toMatchObject({ - command: "template init --preset rust-bin", - toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.2.3" }, - }); - expect(cargoToml).toContain('name = "demo-rust"'); - expect(cargoToml).toContain('anyhow = "1.0.100"'); - expect(Object.keys(devcontainer)).toEqual([ - "name", - "build", - "customizations", - "mounts", - ]); - expect(devcontainer.name).toBe("Demo Rust!"); - expect(devcontainer.build).toEqual({ - dockerfile: "Dockerfile", - args: { - NODE_VERSION: "24", - PACKAGE_MANAGER_PIN: "pnpm@11.2.3", - RUST_TOOLCHAIN: "stable", - }, - }); - expect(devcontainer).not.toHaveProperty("features"); - expect(devcontainerText).toMatch( - /^\{\n "name": "Demo Rust!",\n "build": \{/, - ); - expect(devcontainer.mounts).toEqual([ - "source=${localWorkspaceFolderBasename}-cargo-registry,target=/usr/local/cargo/registry,type=volume", - "source=${localWorkspaceFolderBasename}-cargo-git,target=/usr/local/cargo/git,type=volume", - "source=${localWorkspaceFolderBasename}-target,target=${containerWorkspaceFolder}/target,type=volume", - ]); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(dockerfile).toContain("ARG RUST_TOOLCHAIN"); - expect(dockerfile).toContain("rustup toolchain install"); - expect(dockerfile).toContain("rustfmt"); - expect(dockerfile).toContain("clippy"); - expect(dockerfile).toContain("gcc"); - expect(dockerfile).toContain("libc6-dev"); - expect(dockerfile).not.toContain("typescript-node"); - expect(dockerfile).not.toMatch( - /\b(build-essential|pkg-config|libssl-dev)\b/, - ); - expect(rustToolchain).toContain('[toolchain]\nchannel = "stable"\n'); - expect(rustToolchain).toContain('components = ["rustfmt", "clippy"]'); - expect(checkWorkflow).toContain("uses: dtolnay/rust-toolchain@stable"); - expect(checkWorkflow).toContain("uses: Swatinem/rust-cache@v2"); - expect(dependabot).toContain("package-ecosystem: npm"); - expect(dependabot).toContain("package-ecosystem: cargo"); - expect(dependabot).toContain( - 'package-ecosystem: cargo\n directory: "/packages/demo-rust"', - ); - expect(dependabot).toContain("package-ecosystem: github-actions"); - }); -}); diff --git a/test/preset-source.test.ts b/test/preset-source.test.ts deleted file mode 100644 index 68e5e69..0000000 --- a/test/preset-source.test.ts +++ /dev/null @@ -1,749 +0,0 @@ -import { mkdir, mkdtemp, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { - validateBuiltInPresetSourceManifest, - loadPresetSourceManifestFile, - loadBuiltInPresetSourceManifest, - validatePresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import type { - PresetSourceManifestPreset, - PresetSourceManifestSharedResource, -} from "@ykdz/template-shared"; - -type ManifestPresetInput = Omit< - PresetSourceManifestPreset, - | "dependencyCatalog" - | "features" - | "packageAdditionSupport" - | "projection" - | "source" - | "supportedPackageManagers" - | "supportedProjectKinds" -> & { - dependencyCatalog?: unknown; - features: unknown[]; - packageAdditionSupport: unknown; - projection?: unknown; - source?: unknown; - supportedPackageManagers: unknown[]; - supportedProjectKinds: unknown[]; -}; - -type SharedResourceInput = PresetSourceManifestSharedResource & - Record; - -type PresetSourceManifestInput = { - schemaVersion: 1; - name: string; - presets: ManifestPresetInput[]; - sharedResources: SharedResourceInput[]; - fixtureMatrix?: unknown; -}; - -function validManifest(): PresetSourceManifestInput { - return { - schemaVersion: 1, - name: "custom-source", - presets: [ - { - name: "custom-lib", - title: "Custom library", - description: "A custom strict TypeScript library preset.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: "unsupported", - features: ["strict-typescript", "root-check"], - }, - ], - sharedResources: [ - { - id: "shared-oxc-node", - path: "shared/oxc/node", - }, - ], - }; -} - -function firstPreset(manifest: PresetSourceManifestInput): ManifestPresetInput { - const preset = manifest.presets[0]; - if (!preset) { - throw new Error("Test manifest must contain a preset"); - } - - return preset; -} - -function firstSharedResource( - manifest: PresetSourceManifestInput, -): SharedResourceInput { - const resource = manifest.sharedResources[0]; - if (!resource) { - throw new Error("Test manifest must contain a shared resource"); - } - - return resource; -} - -describe("Preset Source Manifest validation", () => { - it("accepts reference-only Shared Resource declarations with stable identities", () => { - expect(validatePresetSourceManifest(validManifest())).toMatchObject({ - ok: true, - value: { - sharedResources: [ - { - id: "shared-oxc-node", - path: "shared/oxc/node", - }, - ], - }, - }); - }); - - it("accepts maintained Dependency Catalog entry references", () => { - const manifest = validManifest(); - firstPreset(manifest).dependencyCatalog = ["typescript", "valibot"]; - - expect(validatePresetSourceManifest(manifest)).toMatchObject({ - ok: true, - value: { - presets: [ - { - name: "custom-lib", - dependencyCatalog: ["typescript", "valibot"], - }, - ], - }, - }); - }); - - it("rejects fixture-only Fixture Matrix declarations", () => { - const manifest = validManifest(); - manifest.fixtureMatrix = { - initSupport: [{ preset: "custom-lib" }], - packageAdditionSupport: [], - supportedCombinations: [], - semanticSkips: [], - checkRequirements: ["machine-verifiable-next-steps", "root-check-ci"], - environmentPreparation: ["playwright-browser-assets"], - }; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.fixtureMatrix", - message: - "Preset Source Manifests must contain production facts only; remove fixtureMatrix", - }, - ], - }); - }); - - it("rejects unknown Projection Capability kinds with semantic diagnostics", () => { - const manifest = validManifest(); - firstPreset(manifest).projection = { - capabilities: [ - { - kind: "write-my-private-file", - }, - ], - }; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].projection.capabilities[0].kind", - message: "Unknown Projection Capability kind: write-my-private-file", - }, - ], - }); - }); - - it("reports shared declaration and core-owned semantic diagnostics together", () => { - const manifest = validManifest(); - firstPreset(manifest).dependencyCatalog = ["missing-package"]; - firstPreset(manifest).projection = { - capabilities: [ - { - kind: "write-my-private-file", - }, - ], - }; - - expect( - validatePresetSourceManifest(manifest, { dependencyCatalog: {} }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].projection.capabilities[0].kind", - message: "Unknown Projection Capability kind: write-my-private-file", - }, - { - path: "$.presets[0].dependencyCatalog", - message: - "Preset custom-lib references missing Template Dependency Catalog entry: missing-package", - }, - ], - }); - }); - - it("rejects missing Projection Capabilities with semantic diagnostics", () => { - const manifest = validManifest(); - firstPreset(manifest).projection = { - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts", "src/name-schema.ts"], - }, - { kind: "strict-typescript-root" }, - { - kind: "oxc-format-lint", - editorCustomizationResourceId: "shared-editor-customization", - }, - ], - }; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].projection.capabilities", - message: - "Projection Capability composition must include github-maintenance to provide GitHub Actions maintenance", - }, - { - path: "$.presets[0].projection.capabilities", - message: - "Projection Capability composition must include github-maintenance to provide Dependabot maintenance", - }, - { - path: "$.presets[0].projection.capabilities", - message: - "Projection Capability composition must include node-pnpm-devcontainer to provide development container support", - }, - ], - }); - }); - - it("reports missing Dependency Catalog entry references with semantic diagnostics", () => { - const manifest = validManifest(); - firstPreset(manifest).dependencyCatalog = ["missing-package"]; - - expect( - validatePresetSourceManifest(manifest, { dependencyCatalog: {} }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].dependencyCatalog", - message: - "Preset custom-lib references missing Template Dependency Catalog entry: missing-package", - }, - ], - }); - }); - - it("rejects inline Dependency Catalog semver specifiers in manifest-shaped references", () => { - const manifest = validManifest(); - firstPreset(manifest).dependencyCatalog = ["^6.0.3"]; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].dependencyCatalog[0]", - message: - "Preset Source Manifests must reference Template Dependency Catalog entries by name, not inline semver specifier ^6.0.3", - }, - ], - }); - }); - - it("rejects inline Dependency Catalog semver specifiers in object-shaped declarations", () => { - const manifest = validManifest(); - firstPreset(manifest).dependencyCatalog = { - typescript: "^6.0.3", - }; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].dependencyCatalog.typescript", - message: - "Preset Source Manifests must reference Template Dependency Catalog entries by name, not inline semver specifier ^6.0.3", - }, - ], - }); - }); - - it("rejects missing Shared Resource paths through manifest loading", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "preset-source-")); - const manifestPath = path.join(workspace, "preset-source.json"); - - await writeFile( - manifestPath, - `${JSON.stringify(validManifest(), null, 2)}\n`, - "utf8", - ); - - expect(() => loadPresetSourceManifestFile(manifestPath)).toThrow( - [ - "Preset Source Manifest is invalid:", - " - $.sharedResources[0].path: Shared Resource shared-oxc-node path does not exist: shared/oxc/node", - ].join("\n"), - ); - }); - - it("loads Preset-owned source references and Shared Resource identity references", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "preset-source-")); - await mkdir(path.join(workspace, "shared/oxc/node"), { recursive: true }); - await mkdir(path.join(workspace, "custom-lib/src"), { recursive: true }); - await writeFile( - path.join(workspace, "custom-lib/src/index.ts"), - "export function greet() {}\n", - "utf8", - ); - const manifestPath = path.join(workspace, "preset-source.json"); - const manifest = validManifest(); - firstPreset(manifest).source = { - roots: ["custom-lib/src"], - files: ["custom-lib/src/index.ts"], - sharedResources: ["shared-oxc-node"], - }; - - await writeFile( - manifestPath, - `${JSON.stringify(manifest, null, 2)}\n`, - "utf8", - ); - - expect( - loadPresetSourceManifestFile(manifestPath).presets[0]!.source, - ).toEqual({ - roots: ["custom-lib/src"], - files: ["custom-lib/src/index.ts"], - sharedResources: ["shared-oxc-node"], - }); - }); - - it("rejects missing Preset source references and undeclared Shared Resource identities", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "preset-source-")); - await mkdir(path.join(workspace, "shared/oxc/node"), { recursive: true }); - const manifestPath = path.join(workspace, "preset-source.json"); - const manifest = validManifest(); - firstPreset(manifest).source = { - files: ["custom-lib/src/index.ts"], - sharedResources: ["missing-resource"], - }; - - await writeFile( - manifestPath, - `${JSON.stringify(manifest, null, 2)}\n`, - "utf8", - ); - - expect(() => loadPresetSourceManifestFile(manifestPath)).toThrow( - [ - "Preset Source Manifest is invalid:", - " - $.presets[0].source.sharedResources: Preset custom-lib references undeclared Shared Resource: missing-resource", - " - $.presets[0].source.files[0]: Preset custom-lib source file does not exist: custom-lib/src/index.ts", - ].join("\n"), - ); - }); - - it("rejects Preset Source references that escape the source boundary", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "preset-source-")); - const manifestPath = path.join(workspace, "preset-source.json"); - const manifest = validManifest(); - firstSharedResource(manifest).path = "../shared/oxc/node"; - firstPreset(manifest).source = { - roots: ["../custom-lib/src"], - }; - - await writeFile( - manifestPath, - `${JSON.stringify(manifest, null, 2)}\n`, - "utf8", - ); - - expect(() => loadPresetSourceManifestFile(manifestPath)).toThrow( - [ - "Preset Source Manifest is invalid:", - " - $.sharedResources[0].path: Preset Source path escapes its source boundary: ../shared/oxc/node", - " - $.presets[0].source.roots[0]: Preset Source path escapes its source boundary: ../custom-lib/src", - ].join("\n"), - ); - }); - - it("rejects Preset Source symlinks that escape the source boundary", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "preset-source-")); - const outside = await mkdtemp( - path.join(tmpdir(), "preset-source-outside-"), - ); - await mkdir(path.join(outside, "shared/oxc/node"), { recursive: true }); - await symlink( - path.join(outside, "shared"), - path.join(workspace, "shared"), - "dir", - ); - const manifestPath = path.join(workspace, "preset-source.json"); - const manifest = validManifest(); - - await writeFile( - manifestPath, - `${JSON.stringify(manifest, null, 2)}\n`, - "utf8", - ); - - expect(() => loadPresetSourceManifestFile(manifestPath)).toThrow( - [ - "Preset Source Manifest is invalid:", - " - $.sharedResources[0].path: Preset Source path escapes its source boundary: shared/oxc/node", - ].join("\n"), - ); - }); - - it("rejects inline Generated Repository file bodies in manifest declarations", () => { - const manifest = validManifest(); - manifest.sharedResources[0] = { - ...firstSharedResource(manifest), - body: "version: 2\nupdates: []\n", - }; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.sharedResources[0].body", - message: - "Preset Source Manifests must reference Generated Repository file bodies by path, not inline body", - }, - ], - }); - }); - - it("loads the built-in Preset Source Manifest metadata", () => { - const manifest = loadBuiltInPresetSourceManifest(); - - expect( - manifest.presets.flatMap((preset) => [ - ...(preset.source?.roots ?? []), - ...(preset.source?.files ?? []), - ]), - ).not.toEqual( - expect.arrayContaining([expect.stringMatching(/behavior\.test\.ts$/)]), - ); - expect(manifest.sharedResources).toEqual( - expect.arrayContaining([ - { id: "shared-oxc-node", path: "shared/oxc/node" }, - { id: "shared-oxc-vue", path: "shared/oxc/vue" }, - { id: "shared-devcontainer", path: "shared/devcontainer" }, - { - id: "shared-editor-customization", - path: "shared/editor-customization/capabilities.json", - }, - ]), - ); - expect( - manifest.presets.map((preset) => ({ - name: preset.name, - generation: preset.generation, - packageAdditionSupport: preset.packageAdditionSupport, - })), - ).toEqual( - expect.arrayContaining([ - { - name: "ts-lib", - generation: "supported", - packageAdditionSupport: "supported", - }, - { - name: "vue-hono-app", - generation: "supported", - packageAdditionSupport: "unsupported", - }, - { - name: "node-cli", - generation: "future", - packageAdditionSupport: "unsupported", - }, - ]), - ); - expect( - Object.fromEntries( - manifest.presets - .filter((preset) => preset.generation === "supported") - .map((preset) => [preset.name, preset.dependencyCatalog]), - ), - ).toEqual({ - "rust-bin": [ - "@types/node", - "@types/semver", - "semver", - "turbo", - "typescript-7", - ], - "ts-lib": [ - "@types/node", - "oxfmt", - "oxlint", - "oxlint-tsgolint", - "turbo", - "typescript-7", - "valibot", - ], - "vike-app": [ - "@playwright/test", - "@tailwindcss/vite", - "@types/node", - "@vitejs/plugin-vue", - "@vikejs/hono", - "@vue/tsconfig", - "drizzle-kit", - "drizzle-orm", - "hono", - "oxfmt", - "oxlint", - "oxlint-tsgolint", - "srvx", - "tailwindcss", - "telefunc", - "typescript", - "typescript-7", - "vite", - "vike", - "vike-vue", - "vitest", - "vue", - "vue-tsc", - ], - "vue-app": [ - "@playwright/test", - "@tailwindcss/vite", - "@types/node", - "@types/web-bluetooth", - "@vitejs/plugin-vue", - "@vue/tsconfig", - "oxfmt", - "oxlint", - "oxlint-tsgolint", - "pinia", - "tailwindcss", - "turbo", - "typescript", - "typescript-7", - "vite", - "vitest", - "vue", - "vue-tsc", - ], - "vue-hono-app": [ - "@hono/node-server", - "@playwright/test", - "@tailwindcss/vite", - "@types/node", - "@types/web-bluetooth", - "@vitejs/plugin-vue", - "@vue/tsconfig", - "hono", - "oxfmt", - "oxlint", - "oxlint-tsgolint", - "pinia", - "tailwindcss", - "tsc-alias", - "turbo", - "typescript", - "typescript-7", - "vite", - "vitest", - "vue", - "vue-tsc", - ], - }); - - const rustPreset = manifest.presets.find( - (preset) => preset.name === "rust-bin", - ); - expect(rustPreset).toMatchObject({ - projection: { - capabilities: [ - { - kind: "rust-binary-workspace", - workspacePackageGlob: "packages/*", - sourceFiles: ["turbo.json", "src/main.rs"], - }, - ], - }, - source: { - roots: ["rust-bin/.github", "rust-bin/src"], - files: [ - "rust-bin/rust-toolchain.toml", - "rust-bin/rustfmt.toml", - "rust-bin/turbo.json", - ], - sharedResources: [ - "shared-devcontainer", - "shared-editor-customization", - "shared-oxc-root-tsconfig", - "shared-toolchain-maintenance", - ], - }, - }); - }); - - it("reports duplicate Preset names with an actionable diagnostic", () => { - const manifest = validManifest(); - manifest.presets.push({ - ...firstPreset(manifest), - title: "Duplicate custom library", - }); - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets.name", - message: "Duplicate Preset name: custom-lib", - }, - ], - }); - }); - - it("reports duplicate Preset metadata array values with actionable diagnostics", () => { - const manifest = validManifest(); - firstPreset(manifest).supportedPackageManagers = ["pnpm", "pnpm"]; - firstPreset(manifest).supportedProjectKinds = [ - "multi-package", - "multi-package", - ]; - firstPreset(manifest).features = ["root-check", "root-check"]; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].supportedPackageManagers", - message: "Duplicate value: pnpm", - }, - { - path: "$.presets[0].supportedProjectKinds", - message: "Duplicate value: multi-package", - }, - { - path: "$.presets[0].features", - message: "Duplicate value: root-check", - }, - ], - }); - }); - - it("reports unsupported Project Shape declarations with domain language", () => { - const manifest = validManifest(); - firstPreset(manifest).supportedProjectKinds = ["single-package"]; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].supportedProjectKinds", - message: - "single-package Project Shape is unsupported in V1; use the workspace monorepo Project Shape", - }, - ], - }); - }); - - it("reports invalid Package Addition Support values with supported values", () => { - const manifest = validManifest(); - firstPreset(manifest).packageAdditionSupport = "maybe"; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].packageAdditionSupport", - message: - "Package Addition Support must be one of: supported, unsupported", - }, - ], - }); - }); - - it("reports missing required Preset metadata with the missing field path", () => { - const manifest = validManifest(); - delete (firstPreset(manifest) as Partial).title; - - expect(validatePresetSourceManifest(manifest)).toEqual({ - ok: false, - issues: [ - { - path: "$.presets[0].title", - message: "Preset metadata is missing required field: title", - }, - ], - }); - }); - - it("rejects supported Presets without a Projection Declaration", () => { - const manifest = loadBuiltInPresetSourceManifest(); - const missingPresetIndex = manifest.presets.length; - - expect( - validateBuiltInPresetSourceManifest({ - ...manifest, - presets: [ - ...manifest.presets, - { - name: "missing-supported", - title: "Missing supported preset", - description: "A supported built-in preset with no projection.", - generation: "supported", - supportedPackageManagers: ["pnpm"], - supportedProjectKinds: ["multi-package"], - packageAdditionSupport: "unsupported", - features: [], - }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: `$.presets[${missingPresetIndex}].projection`, - message: - "Supported Preset missing-supported must declare a Projection Declaration", - }, - ], - }); - }); - - it("allows future built-in Presets to carry metadata without Projection Declarations", () => { - const manifest = loadBuiltInPresetSourceManifest(); - - const result = validateBuiltInPresetSourceManifest({ - ...manifest, - presets: manifest.presets.map((preset) => - preset.name === "ts-app" - ? { - ...preset, - projection: undefined, - source: undefined, - } - : preset, - ), - }); - - expect(result.ok).toBe(true); - }); -}); diff --git a/test/projection-capabilities.test.ts b/test/projection-capabilities.test.ts deleted file mode 100644 index e2a3c21..0000000 --- a/test/projection-capabilities.test.ts +++ /dev/null @@ -1,1212 +0,0 @@ -import { mkdir, readFile, stat, mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { - builtInPresetProjectionSourceRoots, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import { - defaultPackagePathForPresetSourcePackageAddition, - interpretPresetProjectionDeclaration, - planPresetSourcePackageAddition, - validateProjectionCapabilities, -} from "@ykdz/template-core/projection-capabilities"; -import { renderNewProject } from "@ykdz/template-core/renderer"; -import type { PresetProjectionDeclaration } from "@ykdz/template-shared"; -import * as v from "valibot"; - -const rootCheckScript = - "pnpm run check:boundaries && turbo run format:check:run lint:run typecheck:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped"; -const rootFixScript = - "turbo run format:write:run lint:fix:run fix:run --output-logs=errors-only --log-order=grouped"; - -const syntheticTsLibDeclaration: PresetProjectionDeclaration = { - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts", "src/name-schema.ts"], - }, - { kind: "strict-typescript-root" }, - { - kind: "oxc-format-lint", - editorCustomizationResourceId: "shared-editor-customization", - }, - { - kind: "node-pnpm-devcontainer", - devcontainerResourceId: "shared-devcontainer", - }, - { kind: "github-maintenance" }, - ], -}; - -async function presetContext(presetName: string) { - const legacyProjection = findBuiltInPresetProjection(presetName); - expect(legacyProjection).toBeDefined(); - - const workspace = await mkdtemp( - path.join(tmpdir(), "template-projection-capabilities-"), - ); - const targetDir = path.join(workspace, `demo-${presetName}`); - const blueprint = legacyProjection!.blueprint({ targetDir }); - - return { - legacyProjection: legacyProjection!, - targetDir, - context: assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }), - }; -} - -async function tsLibContext() { - const legacyProjection = findBuiltInPresetProjection("ts-lib"); - expect(legacyProjection).toBeDefined(); - - const workspace = await mkdtemp( - path.join(tmpdir(), "template-projection-capabilities-"), - ); - const targetDir = path.join(workspace, "demo-lib"); - const blueprint = legacyProjection!.blueprint({ targetDir }); - - return { - legacyProjection: legacyProjection!, - targetDir, - context: assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@11.2.3" }, - source: "online", - diagnostics: [], - }, - }), - }; -} - -const jsonObjectSchema = v.record(v.string(), v.unknown()); -const packageJsonSchema = v.looseObject({ - scripts: v.record(v.string(), v.string()), -}); - -function parseJsonWithSchema( - text: string, - schema: Schema, -): v.InferOutput { - return v.parse(schema, JSON.parse(text) as unknown); -} - -async function readJson(filePath: string): Promise> { - return parseJsonWithSchema( - await readFile(filePath, "utf8"), - jsonObjectSchema, - ); -} - -async function readPackageJson( - filePath: string, -): Promise> { - return parseJsonWithSchema( - await readFile(filePath, "utf8"), - packageJsonSchema, - ); -} - -async function expectFile(pathName: string): Promise { - const fileStat = await stat(pathName); - - expect(typeof fileStat.size).toBe("number"); -} - -describe("Projection Capability declarations", () => { - it("derives Package Addition defaults and workspace glob from Projection Declarations", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const tsLib = manifest.presets.find((preset) => preset.name === "ts-lib")!; - const vueApp = manifest.presets.find( - (preset) => preset.name === "vue-app", - )!; - const root = await mkdtemp( - path.join(tmpdir(), "template-projection-capabilities-"), - ); - - expect( - defaultPackagePathForPresetSourcePackageAddition( - tsLib, - "shared", - builtInPresetProjectionSourceRoots(), - ), - ).toBe("packages/shared"); - expect( - defaultPackagePathForPresetSourcePackageAddition( - vueApp, - "admin", - builtInPresetProjectionSourceRoots(), - ), - ).toBe("apps/admin"); - - const additionPlan = await planPresetSourcePackageAddition({ - preset: vueApp, - sourceRoots: builtInPresetProjectionSourceRoots(), - addition: { - root, - blueprint: { - schemaVersion: 1, - preset: "vue-hono-app", - packageManager: "pnpm", - projectKind: "multi-package", - features: [], - packages: [{ name: "@demo/web", path: "apps/web" }], - }, - packageLeafName: "admin", - packageName: "@demo/admin", - packagePath: "services/admin", - nodeVersion: "24", - }, - }); - - expect(additionPlan.workspacePackageGlob).toBe("apps/*"); - expect(additionPlan.workspaceMembershipGlob).toBe("services/*"); - expect(additionPlan.textFiles).toBeUndefined(); - expect(additionPlan.operations).toContainEqual({ - kind: "copyFile", - from: "playwright.config.ts", - to: "services/admin/playwright.config.ts", - }); - }); - - it("interprets a synthetic ts-lib declaration into Generated Repository behavior", async () => { - const { legacyProjection, context } = await tsLibContext(); - - const plan = interpretPresetProjectionDeclaration({ - preset: legacyProjection.metadata, - declaration: syntheticTsLibDeclaration, - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - - expect( - plan.checkPlan.components.map((component) => component.kind), - ).toEqual([ - "oxc-format-check", - "oxc-lint", - "typescript-typecheck", - "turbo-package-check", - ]); - expect(plan.fixPlan.components.map((component) => component.kind)).toEqual([ - "oxc-format-write", - "oxc-lint-fix", - "turbo-package-fix", - ]); - expect(plan.dependencyMaintenancePolicy.ecosystems).toEqual([ - "npm", - "github-actions", - "docker", - ]); - expect(plan.packageScripts.check).toBe(rootCheckScript); - expect(plan.capabilities).toEqual({ - rootCheck: true, - fixCommand: true, - githubActions: true, - dependabot: true, - devcontainer: true, - }); - }); - - it("renders the ts-lib built-in declaration into expected public generated files", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "ts-lib", - ); - expect(preset?.projection).toBeDefined(); - const { context, targetDir } = await tsLibContext(); - - const plan = interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: preset!.projection!, - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - - const rootPackageJson = await readPackageJson( - path.join(targetDir, "package.json"), - ); - const packageJson = await readPackageJson( - path.join(targetDir, "packages", "demo-lib", "package.json"), - ); - const workspaceYaml = await readFile( - path.join(targetDir, "pnpm-workspace.yaml"), - "utf8", - ); - const generatedByJson = await readJson( - path.join(targetDir, ".template/generated-by.json"), - ); - - expect(rootPackageJson).toMatchObject({ - name: "demo-lib", - private: true, - type: "module", - scripts: { - check: rootCheckScript, - fix: rootFixScript, - }, - devDependencies: { - oxfmt: "catalog:", - oxlint: "catalog:", - turbo: "catalog:", - "typescript-7": "catalog:", - }, - packageManager: "pnpm@11.2.3", - }); - expect(packageJson).toMatchObject({ - name: "@demo-lib/demo-lib", - dependencies: { - valibot: "catalog:", - }, - scripts: { - "format:check:run": - "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write:run": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint:fix:run": - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - "lint:run": - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - "typecheck:run": "tsc -p tsconfig.json --noEmit --pretty false", - }, - devDependencies: { - "@types/node": "catalog:", - oxfmt: "catalog:", - oxlint: "catalog:", - "typescript-7": "catalog:", - }, - }); - expect(workspaceYaml).toContain("packages/*"); - expect(workspaceYaml).toContain("valibot:"); - expect(generatedByJson).toMatchObject({ - command: "template init --preset ts-lib", - }); - await expectFile(path.join(targetDir, "packages/demo-lib/src/index.ts")); - await expectFile(path.join(targetDir, ".github/workflows/check.yml")); - await expectFile(path.join(targetDir, ".devcontainer/Dockerfile")); - }); - - it("renders only selected Development Container fragments from the declared Shared Resource id", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "ts-lib", - ); - expect(preset?.projection).toBeDefined(); - const { context, targetDir } = await tsLibContext(); - const builtInSourceRoots = builtInPresetProjectionSourceRoots(); - const resourceRoot = await mkdtemp( - path.join(tmpdir(), "template-devcontainer-resource-"), - ); - - await mkdir(resourceRoot, { recursive: true }); - await writeFile( - path.join(resourceRoot, "node-pnpm.Dockerfile"), - [ - "# custom devcontainer resource", - "ARG NODE_VERSION", - "ARG PACKAGE_MANAGER_PIN", - "FROM node:${NODE_VERSION}-bookworm-slim", - 'corepack enable --install-directory "$PNPM_HOME"', - "", - ].join("\n"), - ); - - const plan = interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: { - capabilities: preset!.projection!.capabilities.map((capability) => - capability.kind === "node-pnpm-devcontainer" - ? { - ...capability, - devcontainerResourceId: "local-devcontainer-fragments", - } - : capability, - ), - }, - context, - sourceRoots: { - ...builtInSourceRoots, - sharedResource(resourceId) { - return resourceId === "local-devcontainer-fragments" - ? resourceRoot - : builtInSourceRoots.sharedResource(resourceId); - }, - }, - }); - const dockerfileOperation = plan.operations.find( - (operation) => - operation.kind === "writeTextFromFragments" && - operation.to === ".devcontainer/Dockerfile", - ); - - expect(dockerfileOperation).toMatchObject({ - kind: "writeTextFromFragments", - fragments: [ - { - sourceRoot: "devcontainer:local-devcontainer-fragments", - from: "node-pnpm.Dockerfile", - }, - ], - }); - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - - const devcontainerDockerfile = await readFile( - path.join(targetDir, ".devcontainer", "Dockerfile"), - "utf8", - ); - - expect(devcontainerDockerfile).toContain("# custom devcontainer resource"); - }); - - it("keeps Development Container resource ids out of internal renderer source-root keys", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "ts-lib", - ); - expect(preset?.projection).toBeDefined(); - const { context, targetDir } = await tsLibContext(); - const builtInSourceRoots = builtInPresetProjectionSourceRoots(); - const resourceRoot = await mkdtemp( - path.join(tmpdir(), "template-devcontainer-resource-"), - ); - - await writeFile( - path.join(resourceRoot, "node-pnpm.Dockerfile"), - [ - "# resource id collides with sharedOxc", - "ARG NODE_VERSION", - "ARG PACKAGE_MANAGER_PIN", - "FROM node:${NODE_VERSION}-bookworm-slim", - 'corepack enable --install-directory "$PNPM_HOME"', - "", - ].join("\n"), - ); - - const plan = interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: { - capabilities: preset!.projection!.capabilities.map((capability) => - capability.kind === "node-pnpm-devcontainer" - ? { - ...capability, - devcontainerResourceId: "sharedOxc", - } - : capability, - ), - }, - context, - sourceRoots: { - ...builtInSourceRoots, - sharedResource(resourceId) { - return resourceId === "sharedOxc" - ? resourceRoot - : builtInSourceRoots.sharedResource(resourceId); - }, - }, - }); - const sourceRoots = plan.sourceRoots; - - expect(sourceRoots).toBeDefined(); - - expect(sourceRoots!.sharedOxc).toBe(builtInSourceRoots.sharedOxc()); - expect(sourceRoots!["devcontainer:sharedOxc"]).toBe(resourceRoot); - expect(sourceRoots!.sharedOxc).not.toBe(resourceRoot); - - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - - const devcontainerDockerfile = await readFile( - path.join(targetDir, ".devcontainer", "Dockerfile"), - "utf8", - ); - - expect(devcontainerDockerfile).toContain( - "# resource id collides with sharedOxc", - ); - }); - - it("resolves Editor Customization declarations by explicit Shared Resource id", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "ts-lib", - ); - expect(preset?.projection).toBeDefined(); - const { context, targetDir } = await tsLibContext(); - const builtInSourceRoots = builtInPresetProjectionSourceRoots(); - const workspace = await mkdtemp( - path.join(tmpdir(), "template-editor-customization-resource-"), - ); - const resourcePath = path.join(workspace, "editor-capabilities.json"); - - await writeFile( - resourcePath, - JSON.stringify({ - capabilities: { - "oxc-format-lint": { - extensions: ["custom.oxc-extension"], - settings: { - "custom.editorResource": true, - "editor.defaultFormatter": "custom.oxc-extension", - }, - }, - vue: { extensions: [], settings: {} }, - tailwind: { extensions: [], settings: {} }, - "rust-tooling": { extensions: [], settings: {} }, - vitest: { extensions: [], settings: {} }, - }, - }), - ); - - const plan = interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: { - capabilities: preset!.projection!.capabilities.map((capability) => - capability.kind === "oxc-format-lint" - ? { - ...capability, - editorCustomizationResourceId: "custom-editor-declarations", - } - : capability, - ), - }, - context, - sourceRoots: { - ...builtInSourceRoots, - sharedResource(resourceId) { - return resourceId === "custom-editor-declarations" - ? resourcePath - : builtInSourceRoots.sharedResource(resourceId); - }, - }, - }); - - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - - const workspaceExtensions = await readJson( - path.join(targetDir, ".vscode/extensions.json"), - ); - const workspaceSettings = await readJson( - path.join(targetDir, ".vscode/settings.json"), - ); - const devcontainer = await readJson( - path.join(targetDir, ".devcontainer/devcontainer.json"), - ); - - expect(workspaceExtensions.recommendations).toEqual([ - "custom.oxc-extension", - ]); - expect(workspaceSettings).toMatchObject({ - "custom.editorResource": true, - "editor.defaultFormatter": "custom.oxc-extension", - "oxc.configPath": "./oxlint.config.ts", - "oxc.fmt.configPath": "./oxfmt.config.ts", - }); - expect(devcontainer.customizations).toMatchObject({ - vscode: { - extensions: ["custom.oxc-extension"], - settings: workspaceSettings, - }, - }); - }); - - it("reports malformed Editor Customization Shared Resource declarations", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "ts-lib", - ); - expect(preset?.projection).toBeDefined(); - const { context } = await tsLibContext(); - const builtInSourceRoots = builtInPresetProjectionSourceRoots(); - const workspace = await mkdtemp( - path.join(tmpdir(), "template-editor-customization-resource-"), - ); - const resourcePath = path.join(workspace, "editor-capabilities.json"); - - await writeFile( - resourcePath, - JSON.stringify({ - capabilities: { - "oxc-format-lint": { - extensions: [true], - settings: {}, - }, - }, - }), - ); - - expect(() => - interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: { - capabilities: preset!.projection!.capabilities.map((capability) => - capability.kind === "oxc-format-lint" - ? { - ...capability, - editorCustomizationResourceId: "bad-editor-declarations", - } - : capability, - ), - }, - context, - sourceRoots: { - ...builtInSourceRoots, - sharedResource(resourceId) { - return resourceId === "bad-editor-declarations" - ? resourcePath - : builtInSourceRoots.sharedResource(resourceId); - }, - }, - }), - ).toThrow( - `Editor Customization Shared Resource ${resourcePath} is invalid: $.capabilities.oxc-format-lint.extensions.0: Invalid type: Expected string but received true`, - ); - }); - - it("reports a missing Development Container Shared Resource fragment", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "ts-lib", - ); - expect(preset?.projection).toBeDefined(); - const { context } = await tsLibContext(); - const builtInSourceRoots = builtInPresetProjectionSourceRoots(); - const resourceRoot = await mkdtemp( - path.join(tmpdir(), "template-devcontainer-resource-"), - ); - - expect(() => - interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: { - capabilities: preset!.projection!.capabilities.map((capability) => - capability.kind === "node-pnpm-devcontainer" - ? { - ...capability, - devcontainerResourceId: "empty-devcontainer-fragments", - } - : capability, - ), - }, - context, - sourceRoots: { - ...builtInSourceRoots, - sharedResource(resourceId) { - return resourceId === "empty-devcontainer-fragments" - ? resourceRoot - : builtInSourceRoots.sharedResource(resourceId); - }, - }, - }), - ).toThrow( - "Development Container Shared Resource empty-devcontainer-fragments is missing Dockerfile fragment: node-pnpm.Dockerfile", - ); - }); - - it("reports a malformed Development Container Shared Resource root as a semantic fragment error", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "ts-lib", - ); - expect(preset?.projection).toBeDefined(); - const { context } = await tsLibContext(); - const builtInSourceRoots = builtInPresetProjectionSourceRoots(); - const workspace = await mkdtemp( - path.join(tmpdir(), "template-devcontainer-resource-"), - ); - const resourceFile = path.join(workspace, "not-a-directory"); - - await writeFile(resourceFile, "not a directory\n"); - - expect(() => - interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: { - capabilities: preset!.projection!.capabilities.map((capability) => - capability.kind === "node-pnpm-devcontainer" - ? { - ...capability, - devcontainerResourceId: "file-devcontainer-fragments", - } - : capability, - ), - }, - context, - sourceRoots: { - ...builtInSourceRoots, - sharedResource(resourceId) { - return resourceId === "file-devcontainer-fragments" - ? resourceFile - : builtInSourceRoots.sharedResource(resourceId); - }, - }, - }), - ).toThrow( - "Development Container Shared Resource file-devcontainer-fragments is missing Dockerfile fragment: node-pnpm.Dockerfile", - ); - }); - - it("renders the vue-app built-in declaration into expected public generated files", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "vue-app", - ); - expect(preset?.projection).toBeDefined(); - const { context, targetDir } = await presetContext("vue-app"); - - const plan = interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: preset!.projection!, - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - - const rootPackageJson = await readPackageJson( - path.join(targetDir, "package.json"), - ); - const packageJson = await readPackageJson( - path.join(targetDir, "apps", "web", "package.json"), - ); - const workspaceYaml = await readFile( - path.join(targetDir, "pnpm-workspace.yaml"), - "utf8", - ); - const devcontainerDockerfile = await readFile( - path.join(targetDir, ".devcontainer", "Dockerfile"), - "utf8", - ); - - expect(rootPackageJson.scripts.check).toBe(rootCheckScript); - expect(packageJson).toMatchObject({ - name: "@demo-vue-app/web", - scripts: { - "build:run": "vite build", - dev: "vite", - preview: "vite preview", - "test:run": "vitest run --reporter=agent --silent=passed-only", - "test:e2e:run": "node scripts/run-playwright.ts", - "typecheck:run": - "node scripts/run-vue-tsc.ts --build --noEmit --pretty false", - }, - dependencies: { - pinia: "catalog:", - vue: "catalog:", - }, - devDependencies: { - "@playwright/test": "catalog:", - "@vitejs/plugin-vue": "catalog:", - "vue-tsc": "catalog:", - }, - }); - expect(workspaceYaml).toContain("allowBuilds:"); - expect(workspaceYaml).toContain("esbuild: true"); - expect(devcontainerDockerfile).toContain("PLAYWRIGHT_CLI_PACKAGE"); - await expectFile(path.join(targetDir, "apps/web/src/App.vue")); - await expectFile(path.join(targetDir, "apps/web/test/e2e/app.spec.ts")); - }); - - it("derives Vue browser environment setup from the declared package path", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "vue-app", - ); - expect(preset).toBeDefined(); - const workspace = await mkdtemp( - path.join(tmpdir(), "template-vue-package-path-"), - ); - const targetDir = path.join(workspace, "demo-vue"); - - const plan = interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: { - capabilities: [ - { - kind: "workspace-node-packages", - workspacePackageGlob: "apps/*", - packages: [ - { - kind: "vue-app", - path: "apps/api", - sourceFiles: ["src/main.ts"], - }, - ], - }, - { kind: "strict-typescript-root" }, - { - kind: "oxc-format-lint", - editorCustomizationResourceId: "shared-editor-customization", - }, - { - kind: "node-pnpm-devcontainer", - devcontainerResourceId: "shared-devcontainer", - }, - { kind: "github-maintenance" }, - ], - }, - context: assembleGenerationContext({ - targetDir, - blueprint: { - schemaVersion: 1, - preset: "vue-app", - packageManager: "pnpm", - projectKind: "multi-package", - features: [], - packages: [{ name: "@demo/api", path: "apps/api" }], - }, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@11.2.3", - }, - source: "online", - diagnostics: [], - }, - }), - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - - expect(plan.checkPlan.environmentNeeds).toEqual([ - { - kind: "playwright-browser-assets", - browser: "chromium", - owner: { kind: "package-boundary", path: "apps/api" }, - nextStep: { - id: "install-apps-api-playwright-browsers", - label: "Install Playwright browser assets for apps/api package", - command: "pnpm", - args: [ - "--filter", - "./apps/api", - "exec", - "playwright", - "install", - "chromium", - ], - display: "pnpm --filter ./apps/api exec playwright install chromium", - machineVerifiable: true, - }, - }, - ]); - }); - - it("renders the vue-hono-app built-in declaration with package linking", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "vue-hono-app", - ); - expect(preset?.projection).toBeDefined(); - const { context, targetDir } = await presetContext("vue-hono-app"); - - const plan = interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: preset!.projection!, - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - - const apiPackageJson = await readPackageJson( - path.join(targetDir, "apps", "api", "package.json"), - ); - const webPackageJson = await readPackageJson( - path.join(targetDir, "apps", "web", "package.json"), - ); - const turboConfig = await readJson(path.join(targetDir, "turbo.json")); - const webApiSource = await readFile( - path.join(targetDir, "apps", "web", "src", "api.ts"), - "utf8", - ); - - expect(apiPackageJson).toMatchObject({ - name: "@demo-vue-hono-app/api", - types: "./src/index.ts", - scripts: { - dev: "node --watch src/server.ts", - }, - devDependencies: { - "typescript-7": "catalog:", - }, - }); - expect(webPackageJson).toMatchObject({ - name: "@demo-vue-hono-app/web", - dependencies: { - pinia: "catalog:", - vue: "catalog:", - }, - scripts: { - "typecheck:run": "node scripts/run-vue-tsc.ts --build --pretty false", - }, - }); - expect(turboConfig).toMatchObject({ - tasks: { - "build:run": { - outputs: ["dist/**"], - }, - }, - }); - expect(webApiSource).toContain("export async function getHealth"); - expect(webApiSource).not.toContain("@demo-vue-hono-app/api"); - expect(webApiSource).not.toContain("hono/client"); - expect(webApiSource).not.toContain("__API_PACKAGE__"); - expect(webApiSource).not.toContain("@template-anchor"); - await expectFile(path.join(targetDir, "apps/api/src/index.ts")); - await expectFile(path.join(targetDir, "apps/web/src/api.ts")); - }); - - it("renders the rust-bin built-in declaration into expected public generated files", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const preset = manifest.presets.find( - (candidate) => candidate.name === "rust-bin", - ); - expect(preset?.projection).toBeDefined(); - const { context, targetDir } = await presetContext("rust-bin"); - - const plan = interpretPresetProjectionDeclaration({ - preset: preset!, - declaration: preset!.projection!, - context, - sourceRoots: builtInPresetProjectionSourceRoots(), - }); - await renderNewProject({ - sourceRoot: plan.sourceRoot, - sourceRoots: plan.sourceRoots, - targetRoot: targetDir, - operations: [...plan.operations], - }); - - const rootPackageJson = await readPackageJson( - path.join(targetDir, "package.json"), - ); - const packageJson = await readPackageJson( - path.join(targetDir, "packages", "demo-rust-bin", "package.json"), - ); - const cargoToml = await readFile( - path.join(targetDir, "packages", "demo-rust-bin", "Cargo.toml"), - "utf8", - ); - const cargoLock = await readFile( - path.join(targetDir, "packages", "demo-rust-bin", "Cargo.lock"), - "utf8", - ); - const dependabot = await readFile( - path.join(targetDir, ".github", "dependabot.yml"), - "utf8", - ); - const devcontainerDockerfile = await readFile( - path.join(targetDir, ".devcontainer", "Dockerfile"), - "utf8", - ); - - expect(rootPackageJson).toMatchObject({ - name: "demo-rust-bin", - scripts: { - check: - "pnpm run check:boundaries && turbo run typecheck:run format:check:run lint:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - fix: rootFixScript, - }, - devDependencies: { - turbo: "catalog:", - }, - packageManager: "pnpm@11.2.3", - }); - expect(packageJson).toMatchObject({ - name: "demo-rust-bin-native", - scripts: { - "format:check:run": "cargo fmt --all -- --check", - "format:write:run": "cargo fmt --all", - "lint:run": "cargo clippy --workspace --all-targets -- -D warnings", - "test:run": "cargo test --workspace", - }, - }); - expect(cargoToml).toContain('name = "demo-rust-bin"'); - expect(cargoToml).toContain('anyhow = "1.0.100"'); - expect(cargoToml).toContain("[workspace.lints.clippy]"); - expect(cargoLock).toContain('name = "demo-rust-bin"'); - expect(cargoLock).toContain('name = "anyhow"'); - expect(dependabot).toContain('directory: "/packages/demo-rust-bin"'); - expect(devcontainerDockerfile).toContain("RUSTUP_HOME"); - await expectFile( - path.join(targetDir, "packages/demo-rust-bin/src/main.rs"), - ); - await expectFile( - path.join(targetDir, "packages/demo-rust-bin/rustfmt.toml"), - ); - await expectFile(path.join(targetDir, "rust-toolchain.toml")); - }); - - it("rejects unknown Projection Capability kinds with semantic diagnostics", () => { - expect( - validateProjectionCapabilities({ - capabilities: [ - { - kind: "write-my-private-file", - }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities[0].kind", - message: "Unknown Projection Capability kind: write-my-private-file", - }, - ], - }); - }); - - it("rejects root Package Boundary bypasses in workspace-node-packages declarations", () => { - expect( - validateProjectionCapabilities({ - capabilities: [ - { - kind: "workspace-node-packages", - workspacePackageGlob: ".", - packages: [ - { - kind: "vike-app", - path: ".", - sourceFiles: ["+server.ts"], - }, - ], - }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities[0].workspacePackageGlob", - message: - "workspace-node-packages currently supports workspacePackageGlob: apps/*", - }, - { - path: "$.capabilities[0].packages[0].path", - message: - "workspace-node-packages package path must be apps/api or apps/web", - }, - ], - }); - }); - - it("rejects rust-binary-workspace mixed with companion capabilities", () => { - expect( - validateProjectionCapabilities({ - capabilities: [ - { - kind: "rust-binary-workspace", - workspacePackageGlob: "packages/*", - sourceFiles: ["src/main.rs"], - devcontainerResourceId: "shared-devcontainer", - editorCustomizationResourceId: "shared-editor-customization", - }, - { kind: "github-maintenance" }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities", - message: - "rust-binary-workspace is a complete domain capability and must be selected by itself; remove companion Projection Capabilities: github-maintenance", - }, - ], - }); - }); - - it("rejects packageLinks with extra fields", () => { - expect( - validateProjectionCapabilities({ - capabilities: [ - { - kind: "workspace-node-packages", - workspacePackageGlob: "apps/*", - packages: [ - { - kind: "vue-app", - path: "apps/web", - sourceFiles: ["src/main.ts"], - }, - { - kind: "hono-api", - path: "apps/api", - sourceFiles: ["src/server.ts"], - }, - ], - packageLinks: [ - { - consumerPackagePath: "apps/web", - providerPackagePath: "apps/api", - relationship: "runtime", - }, - ], - }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities[0].packageLinks[0].relationship", - message: - "workspace-node-packages packageLink does not support property: relationship", - }, - ], - }); - }); - - it("rejects packageLinks that reference packages outside the same declaration", () => { - expect( - validateProjectionCapabilities({ - capabilities: [ - { - kind: "workspace-node-packages", - workspacePackageGlob: "apps/*", - packages: [ - { - kind: "hono-api", - path: "apps/api", - sourceFiles: ["src/server.ts"], - }, - ], - packageLinks: [ - { - consumerPackagePath: "apps/web", - providerPackagePath: "apps/api", - }, - ], - }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities[0].packageLinks[0].consumerPackagePath", - message: - "workspace-node-packages packageLink consumerPackagePath must reference a package declared in the same packages array: apps/web", - }, - ], - }); - - expect( - validateProjectionCapabilities({ - capabilities: [ - { - kind: "workspace-node-packages", - workspacePackageGlob: "apps/*", - packages: [ - { - kind: "vue-app", - path: "apps/web", - sourceFiles: ["src/main.ts"], - }, - ], - packageLinks: [ - { - consumerPackagePath: "apps/web", - providerPackagePath: "apps/api", - }, - ], - }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities[0].packageLinks[0].providerPackagePath", - message: - "workspace-node-packages packageLink providerPackagePath must reference a package declared in the same packages array or the vike db package: apps/api, packages/db", - }, - ], - }); - }); - - it("rejects missing Projection Capabilities with semantic diagnostics", () => { - expect( - validateProjectionCapabilities({ - capabilities: [ - { - kind: "workspace-library-package", - workspacePackageGlob: "packages/*", - packageRole: "shared-library", - packageSourcePreset: "ts-lib", - sourceFiles: ["src/index.ts", "src/name-schema.ts"], - }, - { kind: "strict-typescript-root" }, - { - kind: "oxc-format-lint", - editorCustomizationResourceId: "shared-editor-customization", - }, - ], - }), - ).toEqual({ - ok: false, - issues: [ - { - path: "$.capabilities", - message: - "Projection Capability composition must include github-maintenance to provide GitHub Actions maintenance", - }, - { - path: "$.capabilities", - message: - "Projection Capability composition must include github-maintenance to provide Dependabot maintenance", - }, - { - path: "$.capabilities", - message: - "Projection Capability composition must include node-pnpm-devcontainer to provide development container support", - }, - ], - }); - }); -}); diff --git a/test/registry-checks.test.ts b/test/registry-checks.test.ts new file mode 100644 index 0000000..5e2f225 --- /dev/null +++ b/test/registry-checks.test.ts @@ -0,0 +1,272 @@ +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; + +import { + builtInPresetRegistry, + createGenerationContext, + planGeneratedRepositoryInitialization, + planGeneratedRepositoryPackageAddition, + resolveBuiltInTemplateSource, +} from "@ykdz/template-builtin-presets"; +import { assertPackageContribution } from "@ykdz/template-core/package-contribution"; +import { + createTemplateSourceHandle, + renderNewProject, +} from "@ykdz/template-core/renderer"; +import { describe, expect, it } from "vitest"; + +import { + deriveFixtureMatrix, + deriveFocusedProjectLinkScenarios, + deriveInitializationScenarios, + discoverPresetLocalBehaviorTests, + deriveVerificationPlans, + validatePlanDependencyCatalog, + validatePlanPublicationSources, + validatePlanSources, +} from "../packages/builtin-presets/src/registry-checks.ts"; + +describe("Preset Registry generated scenarios", () => { + it("derives one initialization scenario per Definition and the complete addition matrix", () => { + const definitions = builtInPresetRegistry.all(); + const initialization = deriveInitializationScenarios(); + const matrix = deriveFixtureMatrix(); + const addableDefinitions = definitions.filter( + (definition) => definition.planPackageAddition !== undefined, + ); + + expect( + initialization.map((scenario) => scenario.base.metadata.name), + ).toEqual(definitions.map((definition) => definition.metadata.name)); + expect(matrix).toHaveLength( + definitions.length * (addableDefinitions.length + 1), + ); + expect( + matrix.filter((scenario) => scenario.addition === undefined), + ).toHaveLength(definitions.length); + + for (const scenario of matrix) { + const context = createGenerationContext({ + targetDir: path.join("generated-repository", scenario.id), + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }); + expect(scenario.base.blueprint(context).schemaVersion).toBe(2); + } + }); + + it("derives packed-source verification from every initialization and addition plan", () => { + const verificationPlans = deriveVerificationPlans(); + expect(verificationPlans).toHaveLength( + deriveFixtureMatrix().length + + deriveFixtureMatrix().filter( + (scenario) => scenario.addition !== undefined, + ).length, + ); + expect(() => + validatePlanPublicationSources({ + packageRoot: path.resolve( + resolveBuiltInTemplateSource( + verificationPlans[0]!.definition.source, + ".", + ), + "..", + "..", + ), + packedPaths: [], + verificationPlans, + }), + ).toThrow(/packed Built-in Presets artifact omits/); + }); + + it("rejects generated debris from a packed Built-in Presets artifact", () => { + expect(() => + validatePlanPublicationSources({ + packageRoot: process.cwd(), + packedPaths: [ + `package/templates/.template-packages-rust-${"bin"}-leaked/package.json`, + `package/dist/src/rust-${"bin"}/behavior.test.js`, + ], + verificationPlans: [], + }), + ).toThrow(/generated or test artifact/); + }); + + it("derives focused Project Link scenarios from Definition contributions", async () => { + const focused = deriveFocusedProjectLinkScenarios(); + expect(focused).not.toHaveLength(0); + for (const scenario of focused) { + expect(scenario.addition?.planPackageAddition !== undefined).toBe(true); + expect(scenario.linkFrom).toHaveLength(1); + expect(scenario.id).toContain(scenario.base.metadata.name); + + const context = createGenerationContext({ + targetDir: path.join("generated-repository", scenario.id), + scope: "focused", + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }); + const initialization = planGeneratedRepositoryInitialization({ + definition: scenario.base, + context, + }); + await rm(context.targetDir, { recursive: true, force: true }); + await renderNewProject({ + targetRoot: context.targetDir, + operations: [...initialization.operations], + }); + const addition = planGeneratedRepositoryPackageAddition({ + definition: scenario.addition!, + context, + blueprint: initialization.blueprint, + packageLeafName: `focused-${scenario.addition!.metadata.name}`, + linkFrom: scenario.linkFrom!, + }); + const consumerPath = scenario.linkFrom![0]!; + const provider = addition.blueprint.packages.find( + (definition) => + definition.path === + scenario.addition!.defaultPackagePath?.({ + context, + packageLeafName: `focused-${scenario.addition!.metadata.name}`, + }), + ); + expect(provider?.role).toBe("shared-library"); + expect(addition.blueprint.packageLinkIntents).toEqual( + expect.arrayContaining([ + { + consumerPackagePath: consumerPath, + providerPackagePath: provider?.path, + }, + ]), + ); + expect(addition.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "mergeJson", + to: `${consumerPath}/package.json`, + value: { + dependencies: expect.objectContaining({ + [provider!.name]: "workspace:*", + }), + }, + }), + ]), + ); + } + }); + + it("exposes focused links and Docker-required deployment as distinct runnable check modes", async () => { + const packageJson = JSON.parse( + await readFile(path.resolve("packages/checks/package.json"), "utf8"), + ) as { scripts: Record }; + + expect(packageJson.scripts["check:focused"]).toContain("focused"); + expect(packageJson.scripts["check:deployment"]).toContain("deployment"); + }); + + it("discovers every owned behavior test and derives source and catalog checks from real plans", async () => { + const definitions = builtInPresetRegistry.all(); + await expect(discoverPresetLocalBehaviorTests()).resolves.toHaveLength( + definitions.length, + ); + + for (const definition of definitions) { + const plan = planGeneratedRepositoryInitialization({ + definition, + context: createGenerationContext({ + targetDir: path.join( + "generated-repository", + definition.metadata.name, + ), + toolchain: { + nodeLtsMajor: "24", + packageManagerPin: "pnpm@11.11.0", + }, + }), + }); + await expect(validatePlanSources({ definition, plan })).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ definitionName: definition.metadata.name }), + ]), + ); + expect(() => validatePlanDependencyCatalog(plan)).not.toThrow(); + } + }); + + it("reports Definition, planner, output, and ownership rule for invalid real-plan inputs", async () => { + const definition = builtInPresetRegistry.all()[0]!; + const context = createGenerationContext({ + targetDir: path.join("generated-repository", "provenance"), + toolchain: { nodeLtsMajor: "24", packageManagerPin: "pnpm@11.11.0" }, + }); + const plan = planGeneratedRepositoryInitialization({ definition, context }); + const source = createTemplateSourceHandle(process.cwd()); + const invalidPlan = { + ...plan, + operations: [ + ...plan.operations, + { + kind: "copyFile" as const, + source, + from: "../escape.ts", + to: "packages/demo-lib/escape.ts", + }, + ], + }; + await expect( + validatePlanSources({ definition, plan: invalidPlan }), + ).rejects.toThrow( + `${definition.metadata.name}: ${definition.plannerSourceFile} references undeclared or escaping Template Source for a generated output: generated packages/demo-lib/escape.ts`, + ); + await expect( + validatePlanSources({ + definition, + plan: { + ...plan, + operations: [ + ...plan.operations, + { + kind: "copyFile" as const, + source, + from: "definitely-missing-template-source.ts", + to: "packages/demo-lib/missing.ts", + }, + ], + }, + }), + ).rejects.toThrow( + `${definition.metadata.name}: ${definition.plannerSourceFile} references missing Template Source`, + ); + + const contribution = definition.planInitialization(context); + expect(() => + assertPackageContribution( + { + ...contribution, + operations: [ + { kind: "writeJson", to: "apps/sibling/package.json", value: {} }, + ], + }, + { + definitionName: definition.metadata.name, + planner: "planInitialization", + }, + ), + ).toThrow( + `${definition.metadata.name}: planInitialization Package Contribution may not write a sibling Package Boundary; packages/provenance attempted apps/sibling/package.json`, + ); + expect(() => + assertPackageContribution( + { + ...contribution, + operations: [{ kind: "writeJson", to: "turbo.json", value: {} }], + }, + { + definitionName: definition.metadata.name, + planner: "planPackageAddition", + }, + ), + ).toThrow( + `${definition.metadata.name}: planPackageAddition Package Contribution may not write a coordinated root output; packages/provenance attempted turbo.json`, + ); + }); +}); diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts index fe7d9a6..333972c 100644 --- a/test/release-workflow.test.ts +++ b/test/release-workflow.test.ts @@ -6,6 +6,8 @@ const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); +const publicCliPackageName = ["@ykdz", "template"].join("/"); +const publishCommand = `pnpm --filter ${publicCliPackageName} run publish:bundled --no-git-checks --access public --provenance`; function expectWorkflowUsesVersionedAction( workflow: string, @@ -24,9 +26,7 @@ describe("npm release workflow", () => { expect(workflow).toContain("id-token: write"); expect(workflow).toContain("contents: read"); expect(workflow).toContain("needs: check"); - expect(workflow).toContain( - "pnpm --filter @ykdz/template run publish:bundled --no-git-checks --access public --provenance", - ); + expect(workflow).toContain(publishCommand); expect(workflow).not.toContain("PNPM_CONFIG_NODE_LINKER"); expect(workflow).not.toContain("NPM_TOKEN"); expect(workflow).not.toContain("NODE_AUTH_TOKEN"); @@ -43,9 +43,7 @@ describe("npm release workflow", () => { expect(workflow).toContain("node-version-file: package.json"); expect(workflow).toContain("run: corepack enable"); expect(workflow).toContain("run: pnpm install --frozen-lockfile"); - expect(workflow).toContain( - "run: pnpm --filter @ykdz/template run publish:bundled --no-git-checks --access public --provenance", - ); + expect(workflow).toContain(`run: ${publishCommand}`); expect(workflow).not.toContain("node-version:"); expect(workflow).not.toContain("npm install -g"); expect(workflow).not.toMatch(/run:\s+npm publish/); diff --git a/test/renderer.test.ts b/test/renderer.test.ts deleted file mode 100644 index ed68291..0000000 --- a/test/renderer.test.ts +++ /dev/null @@ -1,698 +0,0 @@ -import { - chmod, - mkdir, - mkdtemp, - readFile, - readdir, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { renderNewProject, renderProject } from "@ykdz/template-core/renderer"; - -async function tempWorkspace(): Promise<{ - sourceRoot: string; - targetRoot: string; -}> { - const workspace = await mkdtemp(path.join(tmpdir(), "template-renderer-")); - return { - sourceRoot: path.join(workspace, "source"), - targetRoot: path.join(workspace, "target"), - }; -} - -describe("renderer", () => { - it("copies checked source files with constrained filename variables", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - const sourceFile = path.join(sourceRoot, "bin/tool.sh"); - await mkdir(path.dirname(sourceFile), { recursive: true }); - await writeFile(sourceFile, "#!/usr/bin/env sh\necho checked\n", "utf8"); - await chmod(sourceFile, 0o755); - - await renderProject({ - sourceRoot, - targetRoot, - variables: { commandName: "demo-tool" }, - operations: [ - { - kind: "copyFile", - from: "bin/tool.sh", - to: "bin/{{commandName}}", - }, - ], - }); - - const targetFile = path.join(targetRoot, "bin/demo-tool"); - await expect(readFile(targetFile, "utf8")).resolves.toBe( - "#!/usr/bin/env sh\necho checked\n", - ); - expect((await stat(targetFile)).mode & 0o111).toBe(0o111); - }); - - it("renders checked text templates without stripping surrounding quotes", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - const sourceFile = path.join(sourceRoot, "project.config"); - await mkdir(path.dirname(sourceFile), { recursive: true }); - await writeFile( - sourceFile, - [ - "[toolchain]", - 'channel = "{{RUST_TOOLCHAIN}}"', - "", - "version: 2", - "updates:", - " - package-ecosystem: cargo", - ' directory: "{{CARGO_PACKAGE_DIRECTORY}}"', - "", - ].join("\n"), - "utf8", - ); - - await renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeTextTemplate", - from: "project.config", - to: ".github/dependabot.yml", - replacements: { - CARGO_PACKAGE_DIRECTORY: "/packages/demo", - RUST_TOOLCHAIN: "stable", - }, - }, - ], - }); - - await expect( - readFile(path.join(targetRoot, ".github/dependabot.yml"), "utf8"), - ).resolves.toBe( - [ - "[toolchain]", - 'channel = "stable"', - "", - "version: 2", - "updates:", - " - package-ecosystem: cargo", - ' directory: "/packages/demo"', - "", - ].join("\n"), - ); - }); - - it("writes and merges JSON deterministically", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - - await renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeJson", - to: "package.json", - value: { - scripts: { test: "vitest" }, - name: "demo", - }, - }, - { - kind: "mergeJson", - to: "package.json", - value: { - scripts: { build: "tsc -p tsconfig.json" }, - private: true, - }, - }, - ], - }); - - await expect( - readFile(path.join(targetRoot, "package.json"), "utf8"), - ).resolves.toBe( - [ - "{", - ' "name": "demo",', - ' "private": true,', - ' "scripts": {', - ' "build": "tsc -p tsconfig.json",', - ' "test": "vitest"', - " }", - "}", - "", - ].join("\n"), - ); - }); - - it("serializes equivalent JSON object key orders canonically", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - - await renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeJson", - to: "a.json", - value: { - z: 1, - a: { - y: 2, - x: 1, - }, - list: [ - { - b: 2, - a: 1, - }, - ], - }, - }, - { - kind: "writeJson", - to: "b.json", - value: { - list: [ - { - a: 1, - b: 2, - }, - ], - a: { - x: 1, - y: 2, - }, - z: 1, - }, - }, - { - kind: "writeJson", - to: "merged-a.json", - value: { - z: true, - nested: { - b: 2, - }, - }, - }, - { - kind: "mergeJson", - to: "merged-a.json", - value: { - a: true, - nested: { - a: 1, - }, - }, - }, - { - kind: "writeJson", - to: "merged-b.json", - value: { - nested: { - b: 2, - }, - z: true, - }, - }, - { - kind: "mergeJson", - to: "merged-b.json", - value: { - nested: { - a: 1, - }, - a: true, - }, - }, - ], - }); - - const canonical = [ - "{", - ' "a": {', - ' "x": 1,', - ' "y": 2', - " },", - ' "list": [', - " {", - ' "a": 1,', - ' "b": 2', - " }", - " ],", - ' "z": 1', - "}", - "", - ].join("\n"); - await expect( - readFile(path.join(targetRoot, "a.json"), "utf8"), - ).resolves.toBe(canonical); - await expect( - readFile(path.join(targetRoot, "b.json"), "utf8"), - ).resolves.toBe(canonical); - await expect( - readFile(path.join(targetRoot, "merged-a.json"), "utf8"), - ).resolves.toBe( - await readFile(path.join(targetRoot, "merged-b.json"), "utf8"), - ); - }); - - it("writes tsconfig extends before other root keys", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - - await renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeJson", - to: "apps/api/tsconfig.build.json", - value: { - include: ["src/**/*.ts"], - compilerOptions: { - rootDir: "src", - outDir: "dist", - }, - extends: "./tsconfig.json", - }, - }, - ], - }); - - await expect( - readFile(path.join(targetRoot, "apps/api/tsconfig.build.json"), "utf8"), - ).resolves.toBe( - [ - "{", - ' "extends": "./tsconfig.json",', - ' "compilerOptions": {', - ' "outDir": "dist",', - ' "rootDir": "src"', - " },", - ' "include": ["src/**/*.ts"]', - "}", - "", - ].join("\n"), - ); - }); - - it("writes limited foundation text files", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - - await renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeText", - to: "README.md", - text: "# Demo\n\nGenerated foundation text.\n", - }, - { - kind: "writeText", - to: ".gitignore", - text: "node_modules\ndist\n", - }, - ], - }); - - await expect( - readFile(path.join(targetRoot, "README.md"), "utf8"), - ).resolves.toBe("# Demo\n\nGenerated foundation text.\n"); - await expect( - readFile(path.join(targetRoot, ".gitignore"), "utf8"), - ).resolves.toBe("node_modules\ndist\n"); - }); - - it("rejects target files that appear before a renderer write lands", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - - await expect( - renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeText", - to: "README.md", - text: "first writer\n", - }, - { - kind: "writeText", - to: "README.md", - text: "second writer\n", - }, - ], - }), - ).rejects.toThrow("Refusing to overwrite existing file"); - - await expect( - readFile(path.join(targetRoot, "README.md"), "utf8"), - ).resolves.toBe("first writer\n"); - }); - - it("leaves the target untouched when a new-project render fails", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - await mkdir(targetRoot, { recursive: true }); - - await expect( - renderNewProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeText", - to: "README.md", - text: "# Demo\n", - }, - { - kind: "writeText", - to: "src/index.ts", - text: "export const generated = true;\n", - }, - ], - }), - ).rejects.toThrow("Text output is limited to foundation files"); - - await expect(readdir(targetRoot)).resolves.toEqual([]); - }); - - it("updates executable bits without changing file contents", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - - await renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeText", - to: ".npmrc", - text: "shell-emulator=true\n", - }, - { - kind: "setExecutable", - path: ".npmrc", - executable: true, - }, - ], - }); - - const renderedFile = path.join(targetRoot, ".npmrc"); - await expect(readFile(renderedFile, "utf8")).resolves.toBe( - "shell-emulator=true\n", - ); - expect((await stat(renderedFile)).mode & 0o111).toBe(0o111); - }); - - it("resolves constrained filename variables for all target path operations", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - const targetSourceFile = path.join(targetRoot, "src/index.ts"); - await mkdir(path.dirname(targetSourceFile), { recursive: true }); - await writeFile( - targetSourceFile, - [ - "/* @template-anchor exports */", - "export const existing = true;", - "", - ].join("\n"), - "utf8", - ); - - await renderProject({ - sourceRoot, - targetRoot, - variables: { - configName: "demo", - entryName: "index", - npmrcName: ".npmrc", - readmeName: "README", - }, - operations: [ - { - kind: "writeJson", - to: ".template/{{configName}}.json", - value: { - generated: true, - }, - }, - { - kind: "mergeJson", - to: ".template/{{configName}}.json", - value: { - preset: "ts-lib", - }, - }, - { - kind: "writeText", - to: "{{readmeName}}.md", - text: "# Demo\n", - }, - { - kind: "writeText", - to: "{{npmrcName}}", - text: "shell-emulator=true\n", - }, - { - kind: "setExecutable", - path: "{{npmrcName}}", - executable: true, - }, - { - kind: "replaceAnchors", - path: "src/{{entryName}}.ts", - language: "typescript", - replacements: { - exports: "export const generated = true;", - }, - }, - ], - }); - - await expect( - readFile(path.join(targetRoot, ".template/demo.json"), "utf8"), - ).resolves.toBe( - ["{", ' "generated": true,', ' "preset": "ts-lib"', "}", ""].join("\n"), - ); - await expect( - readFile(path.join(targetRoot, "README.md"), "utf8"), - ).resolves.toBe("# Demo\n"); - expect((await stat(path.join(targetRoot, ".npmrc"))).mode & 0o111).toBe( - 0o111, - ); - await expect(readFile(targetSourceFile, "utf8")).resolves.toBe( - [ - "export const generated = true;", - "export const existing = true;", - "", - ].join("\n"), - ); - }); - - it("replaces checked transform anchors in TypeScript source", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - - await renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeText", - to: "README.md", - text: "placeholder\n", - }, - ], - }); - - const targetFile = path.join(targetRoot, "src/index.ts"); - await mkdir(path.dirname(targetFile), { recursive: true }); - await writeFile( - targetFile, - [ - "export const before = true;", - "/* @template-anchor exports */", - "export const after = true;", - "", - ].join("\n"), - "utf8", - ); - - await renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "replaceAnchors", - path: "src/index.ts", - language: "typescript", - replacements: { - exports: "export const generated = 1;", - }, - }, - ], - }); - - await expect(readFile(targetFile, "utf8")).resolves.toBe( - [ - "export const before = true;", - "export const generated = 1;", - "export const after = true;", - "", - ].join("\n"), - ); - }); - - it("preserves checked transform anchors that are not requested", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - const targetFile = path.join(targetRoot, "src/index.ts"); - await mkdir(path.dirname(targetFile), { recursive: true }); - await writeFile( - targetFile, - [ - "/* @template-anchor imports */", - 'import type { Demo } from "./demo.js";', - "/* @template-anchor exports */", - "export const existing = true;", - "", - ].join("\n"), - "utf8", - ); - - await renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "replaceAnchors", - path: "src/index.ts", - language: "typescript", - replacements: { - exports: "export const generated = true;", - }, - }, - ], - }); - - await expect(readFile(targetFile, "utf8")).resolves.toBe( - [ - "/* @template-anchor imports */", - 'import type { Demo } from "./demo.js";', - "export const generated = true;", - "export const existing = true;", - "", - ].join("\n"), - ); - }); - - it("rejects checked transform anchors for non-TypeScript languages", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - const targetFile = path.join(targetRoot, "src/index.ts"); - await mkdir(path.dirname(targetFile), { recursive: true }); - await writeFile(targetFile, "/* @template-anchor exports */\n", "utf8"); - - await expect( - renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "replaceAnchors", - path: "src/index.ts", - // @ts-expect-error This test exercises the runtime guard for unsupported languages. - language: "javascript", - replacements: { - exports: "export const generated = true;", - }, - }, - ], - }), - ).rejects.toThrow("Checked Transform Anchor only supports TypeScript"); - - await expect(readFile(targetFile, "utf8")).resolves.toBe( - "/* @template-anchor exports */\n", - ); - }); - - it("only replaces checked transform anchors attached to TypeScript syntax", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - const targetFile = path.join(targetRoot, "src/index.ts"); - await mkdir(path.dirname(targetFile), { recursive: true }); - await writeFile( - targetFile, - [ - "export const before = true;", - "/* @template-anchor exports */", - "", - ].join("\n"), - "utf8", - ); - - await expect( - renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "replaceAnchors", - path: "src/index.ts", - language: "typescript", - replacements: { - exports: "export const generated = true;", - }, - }, - ], - }), - ).rejects.toThrow("Missing Checked Transform Anchor: exports"); - }); - - it("rejects unsupported rendering operations and arbitrary source text output", async () => { - const { sourceRoot, targetRoot } = await tempWorkspace(); - - for (const operation of [ - { kind: "patchString", path: "src/index.ts", find: "x", replace: "y" }, - { - kind: "downloadRemoteTemplate", - url: "https://example.com/template.tgz", - }, - { kind: "postGenerationShellHook", command: "pnpm install" }, - { kind: "userJavaScriptTransform", code: "export default () => null" }, - ]) { - await expect( - renderProject({ - sourceRoot, - targetRoot, - // @ts-expect-error This test exercises the runtime guard for unsupported operations. - operations: [operation], - }), - ).rejects.toThrow(`Unsupported renderer operation: ${operation.kind}`); - } - - await expect( - renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeText", - to: "src/index.ts", - text: "export const arbitrary = true;\n", - }, - ], - }), - ).rejects.toThrow("Text output is limited to foundation files"); - - await expect( - renderProject({ - sourceRoot, - targetRoot, - operations: [ - { - kind: "writeText", - to: "src/README.md", - text: "# Not foundation text\n", - }, - ], - }), - ).rejects.toThrow("Text output is limited to foundation files"); - }); -}); diff --git a/test/root-check.test.ts b/test/root-check.test.ts deleted file mode 100644 index 36b553a..0000000 --- a/test/root-check.test.ts +++ /dev/null @@ -1,1027 +0,0 @@ -import { mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises"; -import { readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { checkTemplateGithubYaml } from "@ykdz/template-checks/check-template-github-yaml"; -import { - projectCheckWorkflow, - projectDependabotConfig, -} from "@ykdz/template-core/project-github"; -import { execa } from "execa"; -import ts from "typescript"; -import * as v from "valibot"; -import { parse as parseYaml } from "yaml"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); - -const packageJsonWithScriptsSchema = v.object({ - dependencies: v.optional(v.record(v.string(), v.string())), - devDependencies: v.optional(v.record(v.string(), v.string())), - scripts: v.record(v.string(), v.string()), -}); -const turboConfigSchema = v.object({ - boundaries: v.object({ - tags: v.record(v.string(), v.unknown()), - }), - tasks: v.record(v.string(), v.unknown()), -}); -const workflowWithCheckStepsSchema = v.object({ - jobs: v.object({ - check: v.object({ - steps: v.array( - v.object({ - name: v.optional(v.string()), - run: v.optional(v.string()), - }), - ), - }), - }), -}); - -function parseJsonWithSchema( - text: string, - schema: Schema, -): v.InferOutput { - return v.parse(schema, JSON.parse(text) as unknown); -} - -async function readJsonWithSchema( - filePath: string, - schema: Schema, -): Promise> { - return parseJsonWithSchema(await readFile(filePath, "utf8"), schema); -} - -function parseYamlWithSchema( - text: string, - schema: Schema, -): v.InferOutput { - return v.parse(schema, parseYaml(text) as unknown); -} - -describe("Project Kit Root Check", () => { - it("rejects production source imports from template modules", async () => { - const sourceFiles = [ - ...(await listTypeScriptSourceFiles( - path.join(repoRoot, "packages/core/src"), - )), - ...(await listTypeScriptSourceFiles( - path.join(repoRoot, "packages/cli/src"), - )), - ...(await listTypeScriptSourceFiles( - path.join(repoRoot, "packages/builtin-source/src"), - )), - ...(await listTypeScriptSourceFiles( - path.join(repoRoot, "packages/checks/src"), - )), - ]; - const violations: string[] = []; - - for (const sourceFile of sourceFiles) { - const sourceText = await readFile(sourceFile, "utf8"); - - violations.push( - ...templateModuleReferenceDiagnostics({ - sourceFilePath: sourceFile, - sourceText, - }), - ); - } - - expect(violations).toEqual([]); - }); - - it("detects import, export, and dynamic production references to template modules", () => { - expect( - templateModuleReferenceDiagnostics({ - sourceFilePath: path.join(repoRoot, "src/synthetic.ts"), - sourceText: [ - 'import { a } from "@ykdz/template-builtin-source/registry";', - 'export { b } from "@ykdz/template-builtin-source/projection-plans";', - 'const c = await import("@ykdz/template-builtin-source/templates/ts-lib/projection");', - "", - ].join("\n"), - }), - ).toEqual([ - "src/synthetic.ts imports @ykdz/template-builtin-source/registry", - "src/synthetic.ts exports from @ykdz/template-builtin-source/projection-plans", - "src/synthetic.ts dynamically imports @ykdz/template-builtin-source/templates/ts-lib/projection", - ]); - }); - - it("runs Single Preset Generated Check from the default Root Check", async () => { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(packageJson.scripts).toHaveProperty("check:generated"); - expect(packageJson.scripts["check:generated"]).toBe( - "turbo run check:generated --output-logs=errors-only --log-order=grouped", - ); - expect(packageJson.scripts.check).toContain("check:generated"); - }); - - it("keeps Fixture Matrix checks explicit and outside the default Root Check", async () => { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(packageJson.scripts).toHaveProperty("check:fixtures"); - expect(packageJson.scripts["check:fixtures"]).toBe( - "turbo run check:fixtures --output-logs=errors-only --log-order=grouped", - ); - expect(packageJson.scripts.check).not.toContain("check:fixtures"); - }); - - it("runs Fixture Matrix checks from the reusable check workflow", async () => { - const workflow = await readFile( - path.join(repoRoot, ".github/workflows/check.yml"), - "utf8", - ); - - expect(workflow).toContain("name: Check"); - expect(workflow).toContain("pull_request:"); - expect(workflow).toContain("push:"); - expect(workflow).toContain("workflow_dispatch:"); - expect(workflow).toContain("workflow_call:"); - expect(workflow).toContain("contents: read"); - expect(workflow).toContain("run: pnpm run check"); - expect(workflow).toContain("run: pnpm run check:fixtures"); - expect(workflow.indexOf("run: pnpm run check\n")).toBeLessThan( - workflow.indexOf("run: pnpm run check:fixtures"), - ); - const parsedWorkflow = parseYamlWithSchema( - workflow, - workflowWithCheckStepsSchema, - ); - const runSteps = parsedWorkflow.jobs.check.steps.filter((step) => - Boolean(step.run), - ); - const packageCheckIndex = runSteps.findIndex( - (step) => step.name === "Check package" && step.run === "pnpm run check", - ); - const browserPreparationIndex = runSteps.findIndex( - (step) => - step.name === "Prepare browser checks" && - step.run === - "pnpm --filter @ykdz/template-builtin-source exec playwright install --with-deps chromium", - ); - const fixtureMatrixCheckIndex = runSteps.findIndex( - (step) => - step.name === "Check fixture matrix" && - step.run === "pnpm run check:fixtures", - ); - - expect(browserPreparationIndex).toBeGreaterThanOrEqual(0); - expect(packageCheckIndex).toBeGreaterThan(browserPreparationIndex); - expect(packageCheckIndex).toBeGreaterThanOrEqual(0); - expect(fixtureMatrixCheckIndex).toBeGreaterThan(packageCheckIndex); - expect(workflow).not.toContain("id-token: write"); - expect(workflow).not.toContain("pnpm publish"); - }); - - it("reuses the check workflow before release publishing", async () => { - const workflow = await readFile( - path.join(repoRoot, ".github/workflows/release.yml"), - "utf8", - ); - - expect(workflow).toContain("uses: ./.github/workflows/check.yml"); - expect(workflow).toContain("needs: check"); - expect(workflow).not.toContain("run: pnpm run check\n"); - expect(workflow).not.toContain("run: pnpm run check:fixtures"); - }); - - it("keeps ordinary checks separate from npm publishing", async () => { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - const ordinaryCheckScripts = [ - packageJson.scripts.check, - packageJson.scripts["check:fixtures"], - ]; - - for (const script of ordinaryCheckScripts) { - expect(script).not.toMatch(/\bnpm\s+publish\b/); - expect(script).not.toContain("NPM_TOKEN"); - expect(script).not.toContain("NODE_AUTH_TOKEN"); - } - }); - - it("keeps the online toolchain contract check explicit and outside the default Root Check", async () => { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(packageJson.scripts).toHaveProperty("check:toolchain:online"); - expect(packageJson.scripts["check:toolchain:online"]).toBe( - "turbo run check:toolchain:online --output-logs=errors-only --log-order=grouped", - ); - expect(packageJson.scripts.check).not.toContain("check:toolchain:online"); - }); - - it("exposes the online toolchain contract check as an explicit CI workflow", async () => { - const workflow = await readFile( - path.join( - repoRoot, - ".github/workflows/toolchain-resolution-contract.yml", - ), - "utf8", - ); - - expect(workflow).toContain("name: Toolchain Resolution Contract"); - expect(workflow).toContain("workflow_dispatch:"); - expect(workflow).toContain("run: pnpm run check:toolchain:online"); - expect(workflow).not.toContain("pnpm run check\n"); - }); - - it("runs direct shared OXC template source checks from Root Check", async () => { - const rootPackageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - const packageJson = await readJsonWithSchema( - path.join(repoRoot, "packages/builtin-source/package.json"), - packageJsonWithScriptsSchema, - ); - - expect(rootPackageJson.scripts).toHaveProperty( - "check:templates:shared-oxc", - ); - expect(rootPackageJson.scripts["check:templates"]).toContain( - "turbo run check:templates --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts["check:templates:shared-oxc"]).toBe( - "turbo run check:templates:shared-oxc --output-logs=errors-only --log-order=grouped", - ); - expect(packageJson.scripts["check:templates:shared-oxc"]).toBe( - "pnpm run check:templates:shared-oxc:format && pnpm run check:templates:shared-oxc:lint && pnpm run check:templates:shared-oxc:typecheck", - ); - expect(packageJson.scripts["check:templates:shared-oxc"]).not.toContain( - "pnpm --dir templates/shared/oxc", - ); - expect(packageJson.scripts).toMatchObject({ - "check:templates:shared-oxc:format": - "oxfmt --list-different templates/shared/oxc", - "check:templates:shared-oxc:lint": - "oxlint --quiet --format=unix --config templates/shared/oxc/node/oxlint.config.ts templates/shared/oxc", - "check:templates:shared-oxc:typecheck": - "tsc -p templates/shared/oxc/tsconfig.json --noEmit --pretty false", - }); - - const workspaceYaml = await readFile( - path.join(repoRoot, "pnpm-workspace.yaml"), - "utf8", - ); - expect(workspaceYaml).not.toContain("templates/shared/oxc"); - await expect( - readFile( - path.join( - repoRoot, - "packages/builtin-source/templates/shared/oxc/package.json", - ), - "utf8", - ), - ).rejects.toMatchObject({ code: "ENOENT" }); - - await execa("pnpm", ["run", "check:templates:shared-oxc"], { - cwd: repoRoot, - }); - }); - - it("runs direct template source checks from Root Check", async () => { - const rootPackageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - const builtinSourcePackageJson = await readJsonWithSchema( - path.join(repoRoot, "packages/builtin-source/package.json"), - packageJsonWithScriptsSchema, - ); - const checksPackageJson = await readJsonWithSchema( - path.join(repoRoot, "packages/checks/package.json"), - packageJsonWithScriptsSchema, - ); - - expect(rootPackageJson.scripts).toHaveProperty("check:templates"); - expect(rootPackageJson.scripts.check).toContain("check:templates"); - expect(rootPackageJson.scripts.check).not.toContain("check:fixtures"); - expect(rootPackageJson.scripts["check:templates"]).toContain( - "turbo run check:templates --output-logs=errors-only --log-order=grouped", - ); - expect(builtinSourcePackageJson.scripts["check:templates"]).toContain( - "pnpm run check:templates:shared-oxc", - ); - expect(builtinSourcePackageJson.scripts["check:templates"]).toContain( - "pnpm run check:templates:static-source", - ); - expect(checksPackageJson.scripts["check:templates"]).toContain( - "pnpm run check:templates:github-yaml", - ); - expect(checksPackageJson.scripts["check:templates"]).toContain( - "pnpm run check:templates:boundary", - ); - }); - - it("runs whole-repository format checks from Root Check", async () => { - const rootPackageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(rootPackageJson.scripts).toHaveProperty("check:format"); - expect(rootPackageJson.scripts.check).toContain("format:check"); - expect(rootPackageJson.scripts.check).not.toContain("check:fixtures"); - expect(rootPackageJson.scripts["check:format"]).toBe( - "pnpm run format:check", - ); - expect(rootPackageJson.scripts["format:check"]).toBe( - "turbo run format:check format:check:root --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts["format:check:root"]).toBe( - "oxfmt --list-different --config oxfmt.config.ts package.json pnpm-workspace.yaml turbo.json tsconfig.base.json tsconfig.build.json tsconfig.json vitest.config.ts oxfmt.config.ts oxlint.config.ts test packages/builtin-source/templates/*/behavior.test.ts", - ); - }); - - it("runs whole-repository lint checks from the root OXC config", async () => { - const rootPackageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - const rootOxlintConfig = await readFile( - path.join(repoRoot, "oxlint.config.ts"), - "utf8", - ); - expect(rootOxlintConfig).toContain("typeAware: true"); - expect(rootOxlintConfig).toContain('correctness: "error"'); - expect(rootOxlintConfig).toContain('suspicious: "warn"'); - expect(rootOxlintConfig).not.toContain('"no-unused-vars"'); - await expect( - readFile(path.join(repoRoot, "oxfmt.config.ts"), "utf8"), - ).resolves.toContain("sortImports: true"); - - expect(rootPackageJson.scripts).toHaveProperty("check:lint"); - expect(rootPackageJson.scripts.check).toContain("lint"); - expect(rootPackageJson.scripts["check:lint"]).toBe("pnpm run lint"); - expect(rootPackageJson.scripts.lint).toBe( - "turbo run lint lint:root --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts["lint:root"]).toBe( - "oxlint --quiet --format=unix --config oxlint.config.ts oxlint.config.ts oxfmt.config.ts vitest.config.ts test packages/builtin-source/templates/*/behavior.test.ts", - ); - }); - - it("models repository checks as Turbo tasks", async () => { - const rootPackageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - const turboConfig = await readJsonWithSchema( - path.join(repoRoot, "turbo.json"), - turboConfigSchema, - ); - - expect(rootPackageJson.scripts.check).toBe( - "pnpm run build && pnpm run check:boundaries && turbo run format:check lint typecheck test check:generated check:templates format:check:root lint:root typecheck:root --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts["check:boundaries"]).toBe( - "turbo boundaries --no-color", - ); - expect(rootPackageJson.scripts.build).toBe( - "turbo run build --output-logs=errors-only --log-order=grouped", - ); - expect(turboConfig.boundaries.tags).toMatchObject({ - "template-checks": { - dependencies: { - allow: ["template-core", "template-preset-source", "template-shared"], - }, - }, - "template-cli": { - dependencies: { - allow: [ - "template-checks", - "template-core", - "template-preset-source", - "template-shared", - ], - }, - }, - "template-core": { - dependencies: { - allow: [ - "template-checks", - "template-cli", - "template-preset-source", - "template-shared", - ], - }, - }, - "template-preset-source": { - dependencies: { - allow: [ - "template-checks", - "template-cli", - "template-core", - "template-preset-source", - "template-shared", - ], - }, - }, - "template-shared": { - dependencies: { - allow: [ - "template-checks", - "template-cli", - "template-core", - "template-preset-source", - ], - }, - }, - }); - expect(turboConfig.tasks).toHaveProperty("build"); - expect(turboConfig.tasks).toHaveProperty("format:check"); - expect(turboConfig.tasks).toHaveProperty("lint"); - expect(turboConfig.tasks).toHaveProperty("typecheck"); - expect(turboConfig.tasks).toHaveProperty("//#format:check:root"); - expect(turboConfig.tasks).toHaveProperty("//#lint:root"); - expect(turboConfig.tasks).toHaveProperty("//#typecheck:root"); - }); - - it("keeps every internal package covered by format, lint, and typecheck", async () => { - await expect( - readFile(path.join(repoRoot, "oxlint.config.ts"), "utf8"), - ).resolves.toContain("typeAware: true"); - - for (const packageName of [ - "builtin-source", - "checks", - "cli", - "core", - "shared", - ]) { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, "packages", packageName, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(packageJson.scripts["format:check"]).toContain( - "oxfmt --list-different", - ); - expect(packageJson.scripts.lint).toContain("oxlint"); - expect(packageJson.scripts.typecheck).toBe( - "tsc -p tsconfig.json --noEmit --pretty false", - ); - expect(packageJson.devDependencies).toMatchObject({ - "typescript-7": "catalog:", - }); - } - }); - - it("keeps the root TypeScript command owned by the Workspace Orchestration Package", async () => { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(packageJson.scripts["typecheck:root"]).toBe( - "tsc -p tsconfig.json --noEmit --pretty false", - ); - expect(packageJson.devDependencies).toMatchObject({ - "typescript-7": "catalog:", - }); - }); - - it("keeps compiler identities within their template Repository owners", async () => { - const packageJsonFiles = [ - "package.json", - "packages/builtin-source/package.json", - "packages/checks/package.json", - "packages/cli/package.json", - "packages/core/package.json", - "packages/shared/package.json", - ]; - - for (const packageJsonFile of packageJsonFiles) { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, packageJsonFile), - packageJsonWithScriptsSchema, - ); - const allDependencies = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - }; - - if ( - packageJsonFile !== "package.json" && - packageJsonFile !== "packages/builtin-source/package.json" && - packageJsonFile !== "packages/cli/package.json" && - packageJsonFile !== "packages/core/package.json" - ) { - expect(allDependencies).not.toHaveProperty("typescript"); - } - - expect(allDependencies).not.toHaveProperty("@typescript/native"); - expect(allDependencies).not.toHaveProperty("@typescript/typescript6"); - } - }); - - it("executes TypeScript 7 through the selected native compiler", async () => { - const packageNames = [ - "@ykdz/template-repository", - "@ykdz/template-builtin-source", - "@ykdz/template-checks", - "@ykdz/template", - "@ykdz/template-core", - "@ykdz/template-shared", - ]; - - for (const packageName of packageNames) { - const result = await execa( - "pnpm", - ["--filter", packageName, "exec", "tsc", "--version"], - { cwd: repoRoot }, - ); - const versionMatch = /^Version (?\d+)\./.exec(result.stdout); - - expect({ major: versionMatch?.groups?.major, packageName }).toEqual({ - major: "7", - packageName, - }); - } - }); - - it("builds compiled Package Exposures before repository checks", async () => { - const rootPackageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - const turboConfig = await readJsonWithSchema( - path.join(repoRoot, "turbo.json"), - turboConfigSchema, - ); - - expect(rootPackageJson.scripts.check).toMatch(/^pnpm run build && /); - expect(turboConfig.tasks.build).toMatchObject({ dependsOn: ["^build"] }); - - for (const packageName of ["shared", "core", "builtin-source", "checks"]) { - const packageJsonSource = await readFile( - path.join(repoRoot, "packages", packageName, "package.json"), - "utf8", - ); - - expect(packageJsonSource).not.toContain('"source":'); - } - }); - - it("keeps repository format scripts free of non-standard oxfmt ignore files", async () => { - const packageJsonFiles = [ - "package.json", - "packages/cli/package.json", - "packages/core/package.json", - "packages/shared/package.json", - "packages/builtin-source/package.json", - "packages/checks/package.json", - ]; - - for (const packageJsonFile of packageJsonFiles) { - const packageJson = await readJsonWithSchema( - path.join(repoRoot, packageJsonFile), - packageJsonWithScriptsSchema, - ); - - expect(packageJson.scripts["format:check"]).not.toContain( - "--ignore-path", - ); - expect(packageJson.scripts["format:write"]).not.toContain( - "--ignore-path", - ); - expect(packageJson.scripts["format:check"]).not.toContain(".oxfmtignore"); - expect(packageJson.scripts["format:write"]).not.toContain(".oxfmtignore"); - } - }); - - it("runs direct Rust template source format checks from Root Check", async () => { - const rootPackageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(rootPackageJson.scripts).toHaveProperty( - "check:templates:static-source", - ); - expect(rootPackageJson.scripts["check:templates:static-source"]).toBe( - "turbo run check:templates:static-source --output-logs=errors-only --log-order=grouped", - ); - - await execa("pnpm", ["run", "check:templates:static-source"], { - cwd: repoRoot, - }); - }); - - it("runs direct GitHub YAML template source checks from Root Check", async () => { - const rootPackageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(rootPackageJson.scripts).toHaveProperty( - "check:templates:github-yaml", - ); - expect(rootPackageJson.scripts["check:templates"]).toContain( - "turbo run check:templates --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts["check:templates:github-yaml"]).toBe( - "turbo run check:templates:github-yaml --output-logs=errors-only --log-order=grouped", - ); - - await execa("pnpm", ["run", "check:templates:github-yaml"], { - cwd: repoRoot, - }); - }); - - it("directly validates workflow and Dependabot template source contracts", async () => { - const rootPackageJson = await readJsonWithSchema( - path.join(repoRoot, "package.json"), - packageJsonWithScriptsSchema, - ); - - expect(rootPackageJson.scripts["check:templates:github-yaml"]).toBe( - "turbo run check:templates:github-yaml --output-logs=errors-only --log-order=grouped", - ); - - await execa("pnpm", ["run", "check:templates:github-yaml"], { - cwd: repoRoot, - }); - }); - - it("fails when a supported preset is missing checked GitHub YAML template source", async () => { - const workspace = await mkdirTempTemplateWorkspace(); - - await writeValidGithubTemplatePair(workspace, "ts-lib"); - - await expect( - checkTemplateGithubYaml({ - templatesRoot: workspace, - supportedPresetNames: ["ts-lib", "rust-bin"], - }), - ).rejects.toThrow( - "templates/rust-bin/.github/workflows/check.yml: expected checked template source file", - ); - }); - - it("fails when workflow jobs are not maps", async () => { - const workspace = await mkdirTempTemplateWorkspace(); - - await writeTemplateFile( - workspace, - "ts-lib/.github/workflows/check.yml", - [ - "name: Check", - "on:", - " push:", - "jobs:", - " check: ubuntu-latest", - "", - ].join("\n"), - ); - await writeTemplateFile( - workspace, - "ts-lib/.github/dependabot.yml", - validDependabotTemplate("npm"), - ); - - await expect( - checkTemplateGithubYaml({ - templatesRoot: workspace, - supportedPresetNames: ["ts-lib"], - }), - ).rejects.toThrow( - "templates/ts-lib/.github/workflows/check.yml: expected workflow job check to be a map", - ); - }); - - it("fails when the check workflow job lacks a runner or steps", async () => { - const workspace = await mkdirTempTemplateWorkspace(); - - await writeTemplateFile( - workspace, - "ts-lib/.github/workflows/check.yml", - [ - "name: Check", - "on:", - " push:", - "jobs:", - " check:", - " steps: []", - "", - ].join("\n"), - ); - await writeTemplateFile( - workspace, - "ts-lib/.github/dependabot.yml", - validDependabotTemplate("npm"), - ); - - await expect( - checkTemplateGithubYaml({ - templatesRoot: workspace, - supportedPresetNames: ["ts-lib"], - }), - ).rejects.toThrow( - "templates/ts-lib/.github/workflows/check.yml: expected check workflow job to declare runs-on", - ); - }); - - it("fails when check workflow steps are not maps", async () => { - const workspace = await mkdirTempTemplateWorkspace(); - - await writeTemplateFile( - workspace, - "ts-lib/.github/workflows/check.yml", - [ - "name: Check", - "on:", - " push:", - "jobs:", - " check:", - " runs-on: ubuntu-latest", - " steps:", - " - pnpm install", - "", - ].join("\n"), - ); - await writeTemplateFile( - workspace, - "ts-lib/.github/dependabot.yml", - validDependabotTemplate("npm"), - ); - - await expect( - checkTemplateGithubYaml({ - templatesRoot: workspace, - supportedPresetNames: ["ts-lib"], - }), - ).rejects.toThrow( - "templates/ts-lib/.github/workflows/check.yml: expected every check workflow step to be a map", - ); - }); - - it("discovers checked GitHub YAML template source with .yaml extensions", async () => { - const workspace = await mkdirTempTemplateWorkspace(); - - await writeTemplateFile( - workspace, - "ts-lib/.github/workflows/check.yaml", - validWorkflowTemplate(), - ); - await writeTemplateFile( - workspace, - "ts-lib/.github/dependabot.yaml", - validDependabotTemplate("npm"), - ); - - await expect( - checkTemplateGithubYaml({ - templatesRoot: workspace, - supportedPresetNames: ["ts-lib"], - }), - ).resolves.toBe(2); - }); - - it("fails when checked GitHub YAML template source diverges from plan projections", async () => { - const workspace = await mkdirTempTemplateWorkspace(); - - await writeValidGithubTemplatePair(workspace, "ts-lib"); - await writeTemplateFile( - workspace, - "ts-lib/.github/workflows/check.yml", - validWorkflowTemplateForPreset("ts-lib").replace( - " - run: pnpm run check", - " - run: pnpm test", - ), - ); - - await expect( - checkTemplateGithubYaml({ - templatesRoot: workspace, - supportedPresetNames: ["ts-lib"], - }), - ).rejects.toThrow( - "templates/ts-lib/.github/workflows/check.yml: expected checked template source to match GitHub check workflow projection", - ); - }); -}); - -async function mkdirTempTemplateWorkspace(): Promise { - return await mkdtemp(path.join(tmpdir(), "template-root-check-")); -} - -async function writeTemplateFile( - workspace: string, - relativePath: string, - contents: string, -): Promise { - const filePath = path.join(workspace, relativePath); - await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, contents, "utf8"); -} - -async function writeValidGithubTemplatePair( - workspace: string, - presetName: "ts-lib" | "rust-bin", -): Promise { - await writeTemplateFile( - workspace, - `${presetName}/.github/workflows/check.yml`, - validWorkflowTemplateForPreset(presetName), - ); - await writeTemplateFile( - workspace, - `${presetName}/.github/dependabot.yml`, - validDependabotTemplateForPreset(presetName), - ); -} - -function validWorkflowTemplate(): string { - return validWorkflowTemplateForPreset("ts-lib"); -} - -function validDependabotTemplate(ecosystem: "npm" | "cargo"): string { - return ecosystem === "cargo" - ? validDependabotTemplateForPreset("rust-bin") - : validDependabotTemplateForPreset("ts-lib"); -} - -function validWorkflowTemplateForPreset( - presetName: "ts-lib" | "rust-bin", -): string { - const projectionPlan = projectThroughPresetProjection(presetName); - - return projectionPlan - ? projectCheckWorkflow({ - checkPlan: projectionPlan.checkPlan, - environmentPreparation: - presetName === "rust-bin" ? { rustToolchain: true } : undefined, - }) - : missingProjectedGithubTemplate(presetName, "workflow"); -} - -function validDependabotTemplateForPreset( - presetName: "ts-lib" | "rust-bin", -): string { - const projectionPlan = projectThroughPresetProjection(presetName); - - return projectionPlan - ? projectDependabotConfig(projectionPlan.dependencyMaintenancePolicy) - : missingProjectedGithubTemplate(presetName, "dependabot"); -} - -function missingProjectedGithubTemplate( - presetName: "ts-lib" | "rust-bin", - kind: "workflow" | "dependabot", -): never { - throw new Error( - `Preset Projection ${presetName} did not project ${kind} template source`, - ); -} - -function projectThroughPresetProjection(presetName: "ts-lib" | "rust-bin") { - const projection = findBuiltInPresetProjection(presetName); - - if (!projection) { - return undefined; - } - - return projection.project({ - projectName: { kind: "ProjectName", value: "generated-repository" }, - preset: presetName, - packageManager: { kind: "PackageManager", value: "pnpm" }, - blueprint: projection.blueprint({ targetDir: "generated-repository" }), - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { kind: "PackageManagerPin", value: "pnpm@10.34.4" }, - source: "bundled-fallback", - diagnostics: [], - }, - }); -} - -async function listTypeScriptSourceFiles(root: string): Promise { - const entries = await readdir(root, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const entryPath = path.join(root, entry.name); - if (entry.isDirectory()) { - files.push(...(await listTypeScriptSourceFiles(entryPath))); - continue; - } - - if (entry.isFile() && entry.name.endsWith(".ts")) { - files.push(entryPath); - } - } - - return files.toSorted(); -} - -type TemplateModuleReference = { - readonly specifier: string; - readonly verb: "imports" | "exports from" | "dynamically imports"; -}; - -function templateModuleReferenceDiagnostics(options: { - readonly sourceFilePath: string; - readonly sourceText: string; -}): string[] { - return templateModuleReferences(options.sourceText) - .filter((reference) => - resolvesToTemplateModule(options.sourceFilePath, reference.specifier), - ) - .map( - (reference) => - `${path.relative(repoRoot, options.sourceFilePath)} ${reference.verb} ${reference.specifier}`, - ); -} - -function templateModuleReferences( - sourceText: string, -): TemplateModuleReference[] { - const sourceFile = ts.createSourceFile( - "source.ts", - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const references: TemplateModuleReference[] = []; - - function addModuleSpecifier( - specifier: ts.Expression | undefined, - verb: TemplateModuleReference["verb"], - ): void { - if (specifier && ts.isStringLiteralLike(specifier)) { - references.push({ specifier: specifier.text, verb }); - } - } - - function visit(node: ts.Node): void { - if (ts.isImportDeclaration(node)) { - addModuleSpecifier(node.moduleSpecifier, "imports"); - } - - if (ts.isExportDeclaration(node)) { - addModuleSpecifier(node.moduleSpecifier, "exports from"); - } - - if ( - ts.isCallExpression(node) && - node.expression.kind === ts.SyntaxKind.ImportKeyword - ) { - addModuleSpecifier(node.arguments[0], "dynamically imports"); - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - - return references; -} - -function resolvesToTemplateModule( - sourceFilePath: string, - specifier: string, -): boolean { - if ( - specifier === "@ykdz/template-builtin-source/registry" || - specifier === "@ykdz/template-builtin-source/projection-plans" || - specifier.startsWith("@ykdz/template-builtin-source/templates/") - ) { - return true; - } - - if (!specifier.startsWith(".")) { - return false; - } - - const resolved = path.resolve(path.dirname(sourceFilePath), specifier); - const relative = path.relative(repoRoot, resolved).split(path.sep).join("/"); - - return ( - relative === "packages/builtin-source/templates" || - relative.startsWith("packages/builtin-source/templates/") - ); -} diff --git a/test/template-add-package.test.ts b/test/template-add-package.test.ts deleted file mode 100644 index cc94cbc..0000000 --- a/test/template-add-package.test.ts +++ /dev/null @@ -1,1980 +0,0 @@ -import { - mkdir, - mkdtemp, - readdir, - readFile, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { execa } from "execa"; -import * as v from "valibot"; -import { parse as parseYaml } from "yaml"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); -const cliPath = path.join(repoRoot, "packages", "cli", "src", "cli.ts"); -const nodeBin = process.execPath; -const workspaceCatalogSchema = v.object({ - catalog: v.optional(v.record(v.string(), v.string())), -}); - -async function readJson(filePath: string): Promise { - const parsed: unknown = JSON.parse(await readFile(filePath, "utf8")); - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Large generated-fixture tests still use typed JSON reads; schema-backed reads are being introduced around high-risk assertions. - return parsed as T; -} - -async function expectCommandFailure( - command: Promise, - expectedStderr: string | readonly string[], -): Promise { - try { - await command; - } catch (error) { - const stderr = commandErrorStderr(error); - const expectedMessages = - typeof expectedStderr === "string" ? [expectedStderr] : expectedStderr; - - for (const expectedMessage of expectedMessages) { - expect(stderr).toContain(expectedMessage); - } - - return; - } - - throw new Error("Expected command to fail"); -} - -function commandErrorStderr(error: unknown): string { - if ( - typeof error === "object" && - error !== null && - "stderr" in error && - typeof error.stderr === "string" - ) { - return error.stderr; - } - - throw error; -} - -async function sourceFileSnapshot( - sourceDir: string, - currentDir = sourceDir, -): Promise> { - const snapshot: Record = {}; - const entries = await readdir(currentDir, { withFileTypes: true }); - - for (const entry of entries.toSorted((left, right) => - left.name.localeCompare(right.name), - )) { - const entryPath = path.join(currentDir, entry.name); - if (entry.isDirectory()) { - Object.assign(snapshot, await sourceFileSnapshot(sourceDir, entryPath)); - continue; - } - - if (entry.isFile()) { - snapshot[path.relative(sourceDir, entryPath)] = await readFile( - entryPath, - "utf8", - ); - } - } - - return snapshot; -} - -function expectSharedRootOxcScripts(scripts: Record): void { - expect(scripts["format:check:run"]).toBe( - "oxfmt --list-different --config ../../oxfmt.config.ts .", - ); - expect(scripts["format:write:run"]).toBe( - "oxfmt --write --config ../../oxfmt.config.ts .", - ); - expect(scripts["lint:run"]).toBe( - "oxlint --quiet --format=unix --config ../../oxlint.config.ts .", - ); - expect(scripts["lint:fix:run"]).toBe( - "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - ); -} - -function catalogFromWorkspaceYaml( - workspaceYaml: string, -): Record { - const parsed = v.parse(workspaceCatalogSchema, parseYaml(workspaceYaml)); - - return parsed.catalog ?? {}; -} - -function catalogDependencyNames(manifest: { - dependencies?: Record; - devDependencies?: Record; - optionalDependencies?: Record; - peerDependencies?: Record; -}): string[] { - const dependencies = new Set(); - - for (const dependencyMap of [ - manifest.dependencies, - manifest.devDependencies, - manifest.optionalDependencies, - manifest.peerDependencies, - ]) { - for (const [dependency, specifier] of Object.entries(dependencyMap ?? {})) { - if (specifier.startsWith("catalog:")) { - dependencies.add(dependency); - } - } - } - - return [...dependencies].toSorted(); -} - -async function expectPathMissing(filePath: string): Promise { - await expect(stat(filePath)).rejects.toMatchObject({ - code: "ENOENT", - }); -} - -async function initGeneratedWorkspace( - projectDir: string, - preset = "vue-hono-app", -): Promise { - await execa( - "node", - [ - "--conditions=source", - cliPath, - "init", - projectDir, - "--preset", - preset, - "--yes", - ], - { cwd: repoRoot }, - ); -} - -describe("template add package", () => { - it("keeps the generated Dependency Catalog complete when an added package introduces catalog dependencies", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - const initialWorkspaceYaml = await readFile( - path.join(projectDir, "pnpm-workspace.yaml"), - "utf8", - ); - expect(catalogFromWorkspaceYaml(initialWorkspaceYaml)).not.toHaveProperty( - "valibot", - ); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { cwd: projectDir }, - ); - - const workspaceCatalog = catalogFromWorkspaceYaml( - await readFile(path.join(projectDir, "pnpm-workspace.yaml"), "utf8"), - ); - const templateCatalog = catalogFromWorkspaceYaml( - await readFile(path.join(repoRoot, "pnpm-workspace.yaml"), "utf8"), - ); - const packageJson = await readJson<{ - dependencies?: Record; - devDependencies?: Record; - }>(path.join(projectDir, "packages/shared/package.json")); - const addedPackageCatalogDependencies = catalogDependencyNames(packageJson); - - expect(workspaceCatalog).toHaveProperty("valibot"); - expect(workspaceCatalog.valibot).toBe(templateCatalog.valibot); - expect( - addedPackageCatalogDependencies.filter( - (dependency) => workspaceCatalog[dependency] === undefined, - ), - ).toEqual([]); - }, 120_000); - - it("adds packages while keeping the stored blueprint valid for later additions", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { cwd: projectDir }, - ); - await execa( - nodeBin, - [ - "--conditions=source", - cliPath, - "blueprint", - "validate", - ".template/blueprint.json", - ], - { - cwd: projectDir, - }, - ); - const blueprint = await readJson<{ - preset: string; - projectKind: string; - packages: Array<{ - name: string; - path: string; - role?: string; - sourcePreset?: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const workspaceYaml = await readFile( - path.join(projectDir, "pnpm-workspace.yaml"), - "utf8", - ); - const rootPackageJson = await readJson<{ - scripts: Record; - engines: { node: string }; - }>(path.join(projectDir, "package.json")); - const turboConfig = await readJson<{ - tasks: Record< - string, - { cache?: boolean; dependsOn?: string[]; outputs?: string[] } - >; - }>(path.join(projectDir, "turbo.json")); - const rootTsconfig = await readJson<{ - references?: Array<{ path: string }>; - }>(path.join(projectDir, "tsconfig.json")); - const packageJson = await readJson<{ - name: string; - scripts: Record; - devDependencies: Record; - engines: { node: string }; - packageManager?: string; - }>(path.join(projectDir, "packages/shared/package.json")); - const tsconfig = await readJson<{ - compilerOptions: Record; - include: string[]; - }>(path.join(projectDir, "packages/shared/tsconfig.json")); - - expect(blueprint).toEqual( - expect.objectContaining({ - preset: "vue-hono-app", - projectKind: "multi-package", - }), - ); - expect(blueprint.packages).toEqual([ - { name: "@demo-fullstack/web", path: "apps/web" }, - { name: "@demo-fullstack/api", path: "apps/api" }, - { - name: "@demo-fullstack/shared", - path: "packages/shared", - role: "shared-library", - sourcePreset: "ts-lib", - }, - ]); - expect(workspaceYaml).toContain(" - apps/*"); - expect(workspaceYaml).toContain(" - packages/*"); - expect(rootPackageJson.scripts.check).toBe( - "pnpm run check:boundaries && turbo run format:check:run lint:run typecheck:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts.check).not.toBe( - "turbo run typecheck:run format:check:run lint:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - ); - expect(turboConfig.tasks["typecheck:run"]!.dependsOn).toEqual([ - "^typecheck:run", - ]); - expect(turboConfig.tasks["build:run"]).toEqual({ - outputs: ["dist/**"], - }); - expect(turboConfig.tasks["test:run"]!.dependsOn).toEqual([ - "^typecheck:run", - ]); - expect(turboConfig.tasks["test:e2e:run"]!.dependsOn).toEqual(["build:run"]); - expect(turboConfig.tasks["check:run"]!.cache).toBe(false); - expect(rootPackageJson.scripts.fix).toBe( - "turbo run format:write:run lint:fix:run fix:run --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts.fix).not.toBe( - "pnpm run format:write && pnpm run lint:fix && turbo run fix --filter './apps/*'", - ); - expect(rootTsconfig.references).not.toContainEqual({ - path: "./packages/shared/tsconfig.json", - }); - expect(packageJson.name).toBe("@demo-fullstack/shared"); - expect(rootPackageJson.engines.node).toBe("24"); - expect(packageJson.engines.node).toBe(rootPackageJson.engines.node); - expect(packageJson).not.toHaveProperty("packageManager"); - expect(packageJson.scripts).not.toHaveProperty("check"); - expect(packageJson.scripts).not.toHaveProperty("build:run"); - expectSharedRootOxcScripts(packageJson.scripts); - expect(packageJson.devDependencies["typescript-7"]).toBe("catalog:"); - expect(packageJson.devDependencies).not.toHaveProperty("typescript"); - const addedPackageDependencySpecifiers = Object.values( - packageJson.devDependencies, - ); - expect( - addedPackageDependencySpecifiers.every((value) => value === "catalog:"), - ).toBe(true); - expect(tsconfig.compilerOptions.composite).toBe(true); - expect(tsconfig.compilerOptions).not.toHaveProperty("paths"); - expect(tsconfig.include).toEqual(["src/**/*.ts"]); - - await stat(path.join(projectDir, "packages/shared/src/index.ts")); - await expectPathMissing( - path.join(projectDir, "packages/shared/oxlint.config.ts"), - ); - await expectPathMissing( - path.join(projectDir, "packages/shared/oxfmt.config.ts"), - ); - await expectPathMissing( - path.join(projectDir, "apps/worker/oxlint.config.ts"), - ); - await expectPathMissing( - path.join(projectDir, "apps/worker/oxfmt.config.ts"), - ); - await expectPathMissing( - path.join(projectDir, "packages/shared/.oxlintrc.json"), - ); - await expectPathMissing( - path.join(projectDir, "packages/shared/.oxfmtrc.json"), - ); - await expectPathMissing( - path.join(projectDir, "apps/worker/.oxlintrc.json"), - ); - await expectPathMissing(path.join(projectDir, "apps/worker/.oxfmtrc.json")); - }, 120_000); - - it("adds a package at an explicit two-segment Package Path", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "vue-app", - "--name", - "worker", - "--path", - "services/worker", - ], - { cwd: projectDir }, - ); - - const blueprint = await readJson<{ - packages: Array<{ - name: string; - path: string; - role?: string; - sourcePreset?: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const workspaceYaml = await readFile( - path.join(projectDir, "pnpm-workspace.yaml"), - "utf8", - ); - const rootPackageJson = await readJson<{ - scripts: Record; - }>(path.join(projectDir, "package.json")); - const rootTsconfig = await readJson<{ - references: Array<{ path: string }>; - }>(path.join(projectDir, "tsconfig.json")); - const packageJson = await readJson<{ name: string }>( - path.join(projectDir, "services/worker/package.json"), - ); - - expect(blueprint.packages).toContainEqual( - expect.objectContaining({ - name: "@demo-fullstack/worker", - path: "services/worker", - role: "runtime-service", - sourcePreset: "vue-app", - }), - ); - expect(workspaceYaml).toContain(" - services/*"); - expect(rootPackageJson.scripts.check).not.toContain( - "turbo run typecheck:run --filter './apps/*' --filter './services/*'", - ); - expect(rootPackageJson.scripts.check).toContain( - "pnpm run check:boundaries && turbo run format:check:run lint:run typecheck:run build:run test:run test:e2e:run check:run --output-logs=errors-only --log-order=grouped", - ); - expect(rootTsconfig.references).not.toContainEqual({ - path: "./services/worker/tsconfig.json", - }); - expect(packageJson.name).toBe("@demo-fullstack/worker"); - await stat(path.join(projectDir, "services/worker/src/App.vue")); - await expectPathMissing(path.join(projectDir, "apps/worker/package.json")); - }, 120_000); - - it("links an added TypeScript library from one existing consumer without editing consumer source", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - const webSourceDir = path.join(projectDir, "apps/web/src"); - const beforeWebSource = await sourceFileSnapshot(webSourceDir); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - "--link-from", - "apps/web", - ], - { cwd: projectDir }, - ); - - await execa( - nodeBin, - [ - "--conditions=source", - cliPath, - "blueprint", - "validate", - ".template/blueprint.json", - ], - { - cwd: projectDir, - }, - ); - - const blueprint = await readJson<{ - packageLinkIntents?: Array<{ - consumerPackagePath: string; - providerPackagePath: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const webPackageJson = await readJson<{ - dependencies: Record; - }>(path.join(projectDir, "apps/web/package.json")); - const sharedPackageJson = await readJson<{ - exports: unknown; - imports: unknown; - }>(path.join(projectDir, "packages/shared/package.json")); - - expect(webPackageJson.dependencies["@demo-fullstack/shared"]).toBe( - "workspace:*", - ); - expect(sharedPackageJson.exports).toEqual({ - ".": { - default: "./src/index.ts", - types: "./src/index.ts", - }, - }); - expect(sharedPackageJson.imports).toEqual({ - "#/*": { - default: "./src/*.ts", - types: "./src/*.ts", - }, - }); - expect(blueprint.packageLinkIntents).toEqual([ - { - consumerPackagePath: "apps/web", - providerPackagePath: "packages/shared", - }, - ]); - expect(await sourceFileSnapshot(webSourceDir)).toEqual(beforeWebSource); - }, 120_000); - - it("links an added TypeScript library from multiple existing consumers", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - "--link-from", - "apps/web", - "--link-from", - "apps/api", - ], - { cwd: projectDir }, - ); - - const blueprint = await readJson<{ - packageLinkIntents?: Array<{ - consumerPackagePath: string; - providerPackagePath: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const webPackageJson = await readJson<{ - dependencies: Record; - }>(path.join(projectDir, "apps/web/package.json")); - const apiPackageJson = await readJson<{ - dependencies: Record; - }>(path.join(projectDir, "apps/api/package.json")); - const sharedPackageJson = await readJson<{ - exports: unknown; - imports: unknown; - }>(path.join(projectDir, "packages/shared/package.json")); - - expect(webPackageJson.dependencies["@demo-fullstack/shared"]).toBe( - "workspace:*", - ); - expect(apiPackageJson.dependencies["@demo-fullstack/shared"]).toBe( - "workspace:*", - ); - expect(sharedPackageJson.exports).toEqual({ - ".": { - default: "./src/index.ts", - types: "./src/index.ts", - }, - }); - expect(sharedPackageJson.imports).toEqual({ - "#/*": { - default: "./src/*.ts", - types: "./src/*.ts", - }, - }); - expect(blueprint.packageLinkIntents).toEqual([ - { - consumerPackagePath: "apps/web", - providerPackagePath: "packages/shared", - }, - { - consumerPackagePath: "apps/api", - providerPackagePath: "packages/shared", - }, - ]); - }, 120_000); - - it("deduplicates repeated Package Link Intent consumers", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - "--link-from", - "apps/web", - "--link-from", - "apps/web", - ], - { cwd: projectDir }, - ); - - const blueprint = await readJson<{ - packageLinkIntents?: Array<{ - consumerPackagePath: string; - providerPackagePath: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const webPackageJson = await readJson<{ - dependencies: Record; - }>(path.join(projectDir, "apps/web/package.json")); - - expect(blueprint.packageLinkIntents).toEqual([ - { - consumerPackagePath: "apps/web", - providerPackagePath: "packages/shared", - }, - ]); - expect( - Object.entries(webPackageJson.dependencies).filter( - ([dependencyName, specifier]) => - dependencyName === "@demo-fullstack/shared" && - specifier === "workspace:*", - ), - ).toHaveLength(1); - }, 120_000); - - it("normalizes an existing provider dependency when linking an added provider", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - const webPackageJsonPath = path.join(projectDir, "apps/web/package.json"); - const webPackageJson = await readJson<{ - dependencies?: Record; - }>(webPackageJsonPath); - await writeFile( - webPackageJsonPath, - `${JSON.stringify( - { - ...webPackageJson, - dependencies: { - ...webPackageJson.dependencies, - "@demo-fullstack/shared": "^1.0.0", - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - - await execa( - nodeBin, - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - "--link-from", - "apps/web", - ], - { cwd: projectDir }, - ); - - const updatedWebPackageJson = await readJson<{ - dependencies: Record; - }>(webPackageJsonPath); - - expect(updatedWebPackageJson.dependencies["@demo-fullstack/shared"]).toBe( - "workspace:*", - ); - expect( - Object.keys(updatedWebPackageJson.dependencies).filter( - (dependencyName) => dependencyName === "@demo-fullstack/shared", - ), - ).toHaveLength(1); - }, 120_000); - - it("rejects unknown --link-from Package Paths with available Package Paths", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - const unknownConsumerPackagePath = execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - "--link-from", - "apps/admin", - ], - { cwd: projectDir }, - ); - - await expectCommandFailure(unknownConsumerPackagePath, [ - "Unknown Package Path for --link-from: apps/admin", - "Available Package Paths: apps/web, apps/api", - ]); - await expectPathMissing(path.join(projectDir, "packages/shared")); - }, 120_000); - - it("rejects self-linking the added package from itself", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - "--link-from", - "packages/shared", - ], - { cwd: projectDir }, - ), - "Package Link Intent cannot link packages/shared from itself", - ); - await expectPathMissing(path.join(projectDir, "packages/shared")); - }, 120_000); - - it.each([ - { - preset: "vue-app", - name: "admin", - packagePath: "apps/admin", - referencePath: "./apps/admin/tsconfig.app.json", - generatedFile: "src/App.vue", - }, - { - preset: "ts-lib", - name: "ui", - packagePath: "packages/ui", - referencePath: "./packages/ui/tsconfig.json", - generatedFile: "src/index.ts", - }, - { - preset: "ts-lib", - name: "cli", - packagePath: "tools/cli", - referencePath: "./tools/cli/tsconfig.json", - generatedFile: "src/index.ts", - }, - ])( - "adds an explicit Package Path in $packagePath", - async ({ preset, name, packagePath, referencePath, generatedFile }) => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - preset, - "--name", - name, - "--path", - packagePath, - ], - { cwd: projectDir }, - ); - - const blueprint = await readJson<{ - packages: Array<{ - name: string; - path: string; - role?: string; - sourcePreset?: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const workspaceYaml = await readFile( - path.join(projectDir, "pnpm-workspace.yaml"), - "utf8", - ); - const rootTsconfig = await readJson<{ - references?: Array<{ path: string }>; - }>(path.join(projectDir, "tsconfig.json")); - - expect(blueprint.packages).toContainEqual( - expect.objectContaining({ - name: `@demo-fullstack/${name}`, - path: packagePath, - role: preset === "ts-lib" ? "shared-library" : "runtime-service", - sourcePreset: preset, - }), - ); - expect(workspaceYaml).toContain(` - ${packagePath.split("/")[0]}/*`); - expect(rootTsconfig.references).not.toContainEqual({ - path: referencePath, - }); - await stat(path.join(projectDir, packagePath, generatedFile)); - }, - ); - - it("keeps generated Root Check passing after an explicit Package Path addition", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - - await initGeneratedWorkspace(projectDir, "ts-lib"); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "cli", - "--path", - "tools/cli", - ], - { cwd: projectDir }, - ); - - await execa("pnpm", ["install", "--no-frozen-lockfile"], { - cwd: projectDir, - }); - await execa( - "pnpm", - ["--filter", "./apps/web", "exec", "playwright", "install", "chromium"], - { cwd: projectDir }, - ); - await execa("pnpm", ["run", "check"], { cwd: projectDir }); - }, 180_000); - - it("keeps generated Root Check passing after a multi-consumer Package Link Intent", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - "--link-from", - "apps/web", - "--link-from", - "apps/api", - ], - { cwd: projectDir }, - ); - - await execa("pnpm", ["install", "--no-frozen-lockfile"], { - cwd: projectDir, - }); - await execa( - "pnpm", - ["--filter", "./apps/web", "exec", "playwright", "install", "chromium"], - { cwd: projectDir }, - ); - await execa("pnpm", ["run", "check"], { cwd: projectDir }); - }, 180_000); - - it("rejects nested explicit Package Paths", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "vue-app", - "--name", - "admin", - "--path", - "apps/api/admin", - ], - { cwd: projectDir }, - ), - "Package Path apps/api/admin must be exactly two safe path segments", - ); - - await expectPathMissing(path.join(projectDir, "apps/api/admin")); - }, 120_000); - - it.each([ - { - packagePath: "admin", - diagnostic: "Package Path admin must be exactly two safe path segments", - }, - { - packagePath: "/apps/admin", - diagnostic: "Package Path /apps/admin must be relative", - }, - { - packagePath: "../admin", - diagnostic: "Package Path ../admin must not escape the workspace", - }, - { - packagePath: ".github/admin", - diagnostic: "Package Path .github/admin uses reserved collection .github", - }, - { - packagePath: "node_modules/admin", - diagnostic: - "Package Path node_modules/admin uses reserved collection node_modules", - }, - { - packagePath: "dist/admin", - diagnostic: "Package Path dist/admin uses reserved collection dist", - }, - { - packagePath: "target/admin", - diagnostic: "Package Path target/admin uses reserved collection target", - }, - ])( - "rejects invalid explicit Package Path $packagePath", - async ({ packagePath, diagnostic }) => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "vue-app", - "--name", - "admin", - "--path", - packagePath, - ], - { cwd: projectDir }, - ), - diagnostic, - ); - }, - 120_000, - ); - - it("rejects duplicate package names with a semantic Package Path diagnostic", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "web", - "--path", - "packages/web", - ], - { cwd: projectDir }, - ), - "Package Path packages/web conflicts with existing package @demo-fullstack/web", - ); - - await expectPathMissing(path.join(projectDir, "packages/web")); - }, 120_000); - - it("rejects duplicate Package Paths with a semantic Package Path diagnostic", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "admin", - "--path", - "apps/api", - ], - { cwd: projectDir }, - ), - "Package Path apps/api conflicts with existing package @demo-fullstack/api", - ); - }, 120_000); - - it("rejects existing filesystem target paths with a semantic Package Path diagnostic", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - await mkdir(path.join(projectDir, "services/cache"), { recursive: true }); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "vue-app", - "--name", - "cache", - "--path", - "services/cache", - ], - { cwd: projectDir }, - ), - "Package Path services/cache conflicts with existing filesystem path", - ); - }, 120_000); - - it("adds a package to the generated TypeScript library workspace tracer", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "init", - projectDir, - "--preset", - "ts-lib", - "--yes", - ], - { cwd: repoRoot }, - ); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { cwd: projectDir }, - ); - - const blueprint = await readJson<{ - projectKind: string; - packages: Array<{ - name: string; - path: string; - role?: string; - sourcePreset?: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const workspaceYaml = await readFile( - path.join(projectDir, "pnpm-workspace.yaml"), - "utf8", - ); - const rootTsconfig = await readJson<{ - references?: Array<{ path: string }>; - }>(path.join(projectDir, "tsconfig.json")); - const packageJson = await readJson<{ - name: string; - scripts: Record; - devDependencies: Record; - packageManager?: string; - }>(path.join(projectDir, "packages/shared/package.json")); - - expect(blueprint.projectKind).toBe("multi-package"); - expect(blueprint.packages).toEqual([ - { name: "@demo-lib/demo-lib", path: "packages/demo-lib" }, - { - name: "@demo-lib/shared", - path: "packages/shared", - role: "shared-library", - sourcePreset: "ts-lib", - }, - ]); - expect(workspaceYaml).toContain(" - packages/*"); - expect(rootTsconfig).toEqual({ files: [] }); - expect(packageJson.name).toBe("@demo-lib/shared"); - expect(packageJson).not.toHaveProperty("packageManager"); - expect(packageJson.scripts).not.toHaveProperty("check"); - expect(packageJson.scripts).not.toHaveProperty("build:run"); - expectSharedRootOxcScripts(packageJson.scripts); - expect(packageJson.devDependencies["typescript-7"]).toBe("catalog:"); - expect(packageJson.devDependencies).not.toHaveProperty("typescript"); - - await stat(path.join(projectDir, "packages/shared/src/index.ts")); - await expectPathMissing( - path.join(projectDir, "packages/shared/oxlint.config.ts"), - ); - await expectPathMissing( - path.join(projectDir, "packages/shared/oxfmt.config.ts"), - ); - }); - - it.each([{ projectName: "demo-vue", preset: "vue-app" }])( - "adds a TypeScript library package to the generated $preset workspace", - async ({ projectName, preset }) => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, projectName); - - await initGeneratedWorkspace(projectDir, preset); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { cwd: projectDir }, - ); - await execa( - nodeBin, - [ - "--conditions=source", - cliPath, - "blueprint", - "validate", - ".template/blueprint.json", - ], - { - cwd: projectDir, - }, - ); - - const blueprint = await readJson<{ - projectKind: string; - packages: Array<{ - name: string; - path: string; - role?: string; - sourcePreset?: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const workspaceYaml = await readFile( - path.join(projectDir, "pnpm-workspace.yaml"), - "utf8", - ); - const rootTsconfig = await readJson<{ - references: Array<{ path: string }>; - }>(path.join(projectDir, "tsconfig.json")); - const packageJson = await readJson<{ - name: string; - scripts: Record; - dependencies: Record; - devDependencies: Record; - engines: { node: string }; - }>(path.join(projectDir, "packages/shared/package.json")); - - expect(blueprint.projectKind).toBe("multi-package"); - expect(blueprint.packages).toContainEqual( - expect.objectContaining({ - name: `@${projectName}/shared`, - path: "packages/shared", - role: "shared-library", - sourcePreset: "ts-lib", - }), - ); - expect(workspaceYaml).toContain(" - packages/*"); - expect(rootTsconfig.references ?? []).not.toContainEqual({ - path: "./packages/shared/tsconfig.json", - }); - expect(packageJson.name).toBe(`@${projectName}/shared`); - expect(packageJson.engines.node).toBe("24"); - expect(packageJson.dependencies.valibot).toBe("catalog:"); - expect(packageJson.devDependencies["typescript-7"]).toBe("catalog:"); - expect(packageJson.devDependencies).not.toHaveProperty("typescript"); - expectSharedRootOxcScripts(packageJson.scripts); - - await stat(path.join(projectDir, "packages/shared/src/index.ts")); - await expectPathMissing( - path.join(projectDir, "packages/shared/oxlint.config.ts"), - ); - await expectPathMissing( - path.join(projectDir, "packages/shared/oxfmt.config.ts"), - ); - }, - ); - - it("fails clearly when the requested preset does not support Package Addition", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - const unsupportedPackageAddition = execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "rust-bin", - "--name", - "native", - ], - { cwd: projectDir }, - ); - - await expectCommandFailure(unsupportedPackageAddition, [ - "Preset rust-bin cannot be used for Package Addition.", - "Supported Package Addition presets: ts-lib, vue-app", - ]); - - await expect( - stat(path.join(projectDir, "packages/native")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - await expect( - stat(path.join(projectDir, "apps/native")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("fails clearly when vue-hono-app is requested for Package Addition", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - const unsupportedPackageAddition = execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "vue-hono-app", - "--name", - "fullstack", - ], - { cwd: projectDir }, - ); - - await expectCommandFailure(unsupportedPackageAddition, [ - "Preset vue-hono-app cannot be used for Package Addition.", - "Supported Package Addition presets: ts-lib, vue-app", - ]); - - await expect( - stat(path.join(projectDir, "apps/fullstack")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("fails clearly when Local Template Metadata is missing", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - await rm(path.join(projectDir, ".template"), { recursive: true }); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "worker", - ], - { cwd: projectDir }, - ), - "Package Addition requires a valid .template/blueprint.json", - ); - - await expect( - stat(path.join(projectDir, "apps/worker")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("fails clearly when Local Template Metadata is invalid", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - await writeFile( - path.join(projectDir, ".template/blueprint.json"), - "null\n", - "utf8", - ); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { cwd: projectDir }, - ), - "Package Addition requires a valid .template/blueprint.json", - ); - - await expect( - stat(path.join(projectDir, "packages/shared")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("fails clearly when Local Template Metadata declares a single-package Project Shape", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - await writeFile( - path.join(projectDir, ".template/blueprint.json"), - `${JSON.stringify( - { - schemaVersion: 1, - preset: "ts-lib", - packageManager: "pnpm", - projectKind: "single-package", - features: ["strict-typescript", "root-check"], - packages: [ - { name: "@demo-fullstack/shared", path: "packages/shared" }, - ], - }, - null, - 2, - )}\n`, - "utf8", - ); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "extra", - ], - { cwd: projectDir }, - ), - "Package Addition only supports existing workspace Generated Repositories", - ); - - await expect( - stat(path.join(projectDir, "packages/extra")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("adds packages with the existing generated repository Node engine", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - const rootPackageJsonPath = path.join(projectDir, "package.json"); - const rootPackageJson = await readJson<{ - engines: { node: string }; - [key: string]: unknown; - }>(rootPackageJsonPath); - const inheritedNodeVersion = "25"; - await writeFile( - rootPackageJsonPath, - `${JSON.stringify( - { - ...rootPackageJson, - engines: { ...rootPackageJson.engines, node: inheritedNodeVersion }, - }, - null, - 2, - )}\n`, - "utf8", - ); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { cwd: projectDir }, - ); - - const packageJson = await readJson<{ - engines: { node: string }; - }>(path.join(projectDir, "packages/shared/package.json")); - - expect(packageJson.engines.node).toBe(inheritedNodeVersion); - }); - - it("adds a Vue app package without root TypeScript project references", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "vue-app", - "--name", - "admin", - ], - { cwd: projectDir }, - ); - - const blueprint = await readJson<{ - packages: Array<{ - name: string; - path: string; - role?: string; - sourcePreset?: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const rootTsconfig = await readJson<{ - references: Array<{ path: string }>; - }>(path.join(projectDir, "tsconfig.json")); - const packageJson = await readJson<{ - name: string; - dependencies: Record; - devDependencies: Record; - scripts: Record; - engines: { node: string }; - imports: unknown; - packageManager?: string; - }>(path.join(projectDir, "apps/admin/package.json")); - const appTsconfig = await readJson<{ - compilerOptions: Record; - }>(path.join(projectDir, "apps/admin/tsconfig.app.json")); - const appSource = await readFile( - path.join(projectDir, "apps/admin/src/App.vue"), - "utf8", - ); - - expect(blueprint.packages).toContainEqual( - expect.objectContaining({ - name: "@demo-fullstack/admin", - path: "apps/admin", - role: "runtime-service", - sourcePreset: "vue-app", - }), - ); - expect(rootTsconfig.references).not.toEqual( - expect.arrayContaining([ - { path: "./apps/admin/tsconfig.app.json" }, - { path: "./apps/admin/tsconfig.test.json" }, - { path: "./apps/admin/tsconfig.node.json" }, - ]), - ); - expect(packageJson.name).toBe("@demo-fullstack/admin"); - expect(packageJson.engines.node).toBe("24"); - expect(packageJson).not.toHaveProperty("packageManager"); - expect(packageJson.dependencies.vue).toBe("catalog:"); - expect(packageJson.devDependencies.typescript).toBe("catalog:"); - expect(packageJson.devDependencies).not.toHaveProperty("typescript-7"); - expect(packageJson.imports).toEqual({ - "#/*": { - default: "./src/*.ts", - types: "./src/*.ts", - }, - }); - expect(packageJson.scripts["typecheck:run"]).toBe( - "node scripts/run-vue-tsc.ts --build --noEmit --pretty false", - ); - expectSharedRootOxcScripts(packageJson.scripts); - expect(appTsconfig.compilerOptions).not.toHaveProperty("paths"); - expect(appSource).toContain('from "#/stores/counter"'); - - await stat(path.join(projectDir, "apps/admin/src/App.vue")); - await stat(path.join(projectDir, "apps/admin/scripts/run-playwright.ts")); - await stat(path.join(projectDir, "apps/admin/scripts/run-vue-tsc.ts")); - await stat(path.join(projectDir, "apps/admin/test/e2e/app.spec.ts")); - await expectPathMissing( - path.join(projectDir, "apps/admin/oxlint.config.ts"), - ); - await expectPathMissing( - path.join(projectDir, "apps/admin/oxfmt.config.ts"), - ); - }); - - it("updates root OXC lint configuration when adding a Vue app to a Node-only base", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-api"); - - await initGeneratedWorkspace(projectDir, "ts-lib"); - - const beforeOxlintConfig = await readFile( - path.join(projectDir, "oxlint.config.ts"), - "utf8", - ); - expect(beforeOxlintConfig).toContain('"typescript"'); - expect(beforeOxlintConfig).toContain('"oxc"'); - expect(beforeOxlintConfig).not.toContain('"vue"'); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "vue-app", - "--name", - "admin", - ], - { cwd: projectDir }, - ); - - const afterOxlintConfig = await readFile( - path.join(projectDir, "oxlint.config.ts"), - "utf8", - ); - expect(afterOxlintConfig).toContain('"vue"'); - }); - - it("adds a TypeScript package to a Rust base repository", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-native"); - - await initGeneratedWorkspace(projectDir, "rust-bin"); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { cwd: projectDir }, - ); - - const blueprint = await readJson<{ - packages: Array<{ - name: string; - path: string; - role?: string; - sourcePreset?: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const workspaceYaml = await readFile( - path.join(projectDir, "pnpm-workspace.yaml"), - "utf8", - ); - const gitignore = await readFile( - path.join(projectDir, ".gitignore"), - "utf8", - ); - const rootPackageJson = await readJson<{ - type: string; - devDependencies: Record; - }>(path.join(projectDir, "package.json")); - const packageJson = await readJson<{ - name: string; - scripts: Record; - }>(path.join(projectDir, "packages/shared/package.json")); - - expect(blueprint.packages).toContainEqual( - expect.objectContaining({ - name: "@demo-native/shared", - path: "packages/shared", - role: "shared-library", - sourcePreset: "ts-lib", - }), - ); - expect(workspaceYaml).toContain(" - packages/*"); - await expectPathMissing(path.join(projectDir, "tsconfig.json")); - expect(rootPackageJson.type).toBe("module"); - expect(rootPackageJson.devDependencies.oxfmt).toBe("catalog:"); - expect(rootPackageJson.devDependencies.oxlint).toBe("catalog:"); - expect(gitignore).toContain("node_modules\n"); - expect(gitignore).toContain("dist\n"); - expect(packageJson.name).toBe("@demo-native/shared"); - expect(packageJson.scripts["typecheck:run"]).toBe( - "tsc -p tsconfig.json --noEmit --pretty false", - ); - await stat(path.join(projectDir, "oxlint.config.ts")); - await stat(path.join(projectDir, "oxfmt.config.ts")); - }); - - it("rejects linking a TypeScript package from a native package without partial writes", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-native"); - - await initGeneratedWorkspace(projectDir, "rust-bin"); - - const blueprintPath = path.join(projectDir, ".template/blueprint.json"); - const workspaceYamlPath = path.join(projectDir, "pnpm-workspace.yaml"); - const rootPackageJsonPath = path.join(projectDir, "package.json"); - const beforeBlueprint = await readFile(blueprintPath, "utf8"); - const blueprint = await readJson<{ - packages: Array<{ path: string }>; - }>(blueprintPath); - const nativePackagePath = blueprint.packages[0]?.path; - - if (!nativePackagePath) { - throw new Error("Expected rust-bin fixture to include a native package"); - } - - const beforeWorkspaceYaml = await readFile(workspaceYamlPath, "utf8"); - const beforeRootPackageJson = await readFile(rootPackageJsonPath, "utf8"); - const nativeCargoTomlPath = path.join( - projectDir, - nativePackagePath, - "Cargo.toml", - ); - const beforeNativeCargoToml = await readFile(nativeCargoTomlPath, "utf8"); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - "--link-from", - nativePackagePath, - ], - { cwd: projectDir }, - ), - `Package Link Intent from native package ${nativePackagePath} is unsupported in V1 TypeScript-only Project Linking`, - ); - - await expectPathMissing(path.join(projectDir, "packages/shared")); - expect(await readFile(blueprintPath, "utf8")).toBe(beforeBlueprint); - expect(await readFile(workspaceYamlPath, "utf8")).toBe(beforeWorkspaceYaml); - expect(await readFile(rootPackageJsonPath, "utf8")).toBe( - beforeRootPackageJson, - ); - expect(await readFile(nativeCargoTomlPath, "utf8")).toBe( - beforeNativeCargoToml, - ); - }, 120_000); - - it("adds a Vue app package with runtime-allocated e2e preview ports", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "vue-app", - "--name", - "admin", - ], - { cwd: projectDir }, - ); - - const webPlaywright = await readFile( - path.join(projectDir, "apps/web/playwright.config.ts"), - "utf8", - ); - const adminPlaywright = await readFile( - path.join(projectDir, "apps/admin/playwright.config.ts"), - "utf8", - ); - const adminPackageJson = await readJson<{ - scripts: Record; - }>(path.join(projectDir, "apps/admin/package.json")); - - expect(webPlaywright).toContain('requiredPort("PLAYWRIGHT_WEB_PORT")'); - expect(adminPlaywright).toContain('requiredPort("PLAYWRIGHT_WEB_PORT")'); - expect(webPlaywright).not.toMatch(/\b(?:--port\s+|:)(?:\d[\d_]*)/); - expect(adminPlaywright).not.toMatch(/\b(?:--port\s+|:)(?:\d[\d_]*)/); - expect(adminPackageJson.scripts).not.toHaveProperty("check"); - expect(adminPackageJson.scripts["test:e2e:run"]).toBe( - "node scripts/run-playwright.ts", - ); - }); - - it("fails clearly when the target package path already exists", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - const userFile = path.join(projectDir, "packages/shared/README.md"); - - await initGeneratedWorkspace(projectDir); - await mkdir(path.dirname(userFile), { recursive: true }); - await writeFile(userFile, "user-owned content\n", "utf8"); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { cwd: projectDir }, - ), - "Package Path packages/shared conflicts with existing filesystem path", - ); - - expect(await readFile(userFile, "utf8")).toBe("user-owned content\n"); - }); - - it("fails before writing package files when the workspace manifest cannot be updated", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - await writeFile( - path.join(projectDir, "pnpm-workspace.yaml"), - "catalog:\n vite: ^7.0.0\n", - "utf8", - ); - - await expectCommandFailure( - execa( - nodeBin, - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { - cwd: projectDir, - }, - ), - "Cannot update pnpm workspace membership", - ); - - await expect( - stat(path.join(projectDir, "packages/shared")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("adds a package without reading root TypeScript project references", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-add-package-"), - ); - const projectDir = path.join(workspace, "demo-fullstack"); - - await initGeneratedWorkspace(projectDir); - const invalidRootTsconfig = '{ "files": [], "references": '; - await writeFile( - path.join(projectDir, "tsconfig.json"), - invalidRootTsconfig, - "utf8", - ); - - await execa( - "node", - [ - "--conditions=source", - cliPath, - "add", - "package", - "--preset", - "ts-lib", - "--name", - "shared", - ], - { cwd: projectDir }, - ); - - const rootTsconfig = await readFile( - path.join(projectDir, "tsconfig.json"), - "utf8", - ); - const blueprint = await readJson<{ - packages: Array<{ - name: string; - path: string; - role?: string; - sourcePreset?: string; - }>; - }>(path.join(projectDir, ".template/blueprint.json")); - - expect(rootTsconfig).toBe(invalidRootTsconfig); - expect(blueprint.packages).toContainEqual( - expect.objectContaining({ - name: "@demo-fullstack/shared", - path: "packages/shared", - role: "shared-library", - sourcePreset: "ts-lib", - }), - ); - await stat(path.join(projectDir, "packages/shared/src/index.ts")); - }); -}); diff --git a/test/template-boundary-check.test.ts b/test/template-boundary-check.test.ts deleted file mode 100644 index 9dca9e3..0000000 --- a/test/template-boundary-check.test.ts +++ /dev/null @@ -1,1328 +0,0 @@ -import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - loadBuiltInPresetSourceManifest, - manifestReferencedSourceFiles, -} from "@ykdz/template-builtin-source"; -import { findBuiltInPresetProjection } from "@ykdz/template-builtin-source/registry"; -import { builtInTemplateBoundaryProjections } from "@ykdz/template-checks/check-template-boundary"; -import { assembleGenerationContext } from "@ykdz/template-core/generation-context"; -import type { PresetProjectionPlan } from "@ykdz/template-core/preset-projection"; -import { - checkTemplateSourceBoundary, - templateBoundaryDebtAllowlist, -} from "@ykdz/template-core/template-boundary-check"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); -const templatesRoot = path.join( - repoRoot, - "packages", - "builtin-source", - "templates", -); - -function builtInManifestReferencedSourceFiles(): string[] { - return manifestReferencedSourceFiles( - loadBuiltInPresetSourceManifest(), - templatesRoot, - ); -} - -function builtInProjectionSourceFile(): string { - return path.join(repoRoot, "packages/core/src/projection-capabilities.ts"); -} - -function minimalPlan( - operations: PresetProjectionPlan["operations"], -): PresetProjectionPlan { - return { - sourceRoot: ".", - operations, - checkPlan: { components: [], environmentNeeds: [] }, - fixPlan: { components: [] }, - dependencyMaintenancePolicy: { ecosystems: [], interval: "weekly" }, - packageScripts: {}, - capabilities: { - rootCheck: true, - fixCommand: true, - githubActions: true, - dependabot: true, - devcontainer: true, - }, - }; -} - -describe("Template Boundary Check", () => { - it("accepts all built-in init and Package Addition projections without temporary allowlisted debt", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const projections = await builtInTemplateBoundaryProjections(manifest); - - expect(projections.map((projection) => projection.name)).toEqual( - expect.arrayContaining([ - "ts-lib", - "vike-app", - "vue-app", - "vue-hono-app", - "rust-bin", - "ts-lib package addition", - "vue-app package addition", - ]), - ); - expect( - projections.find( - (projection) => projection.name === "vue-app package addition", - )?.plan.operations, - ).toContainEqual( - expect.objectContaining({ - kind: "copyFile", - from: "playwright.config.ts", - to: "apps/template-boundary-check/playwright.config.ts", - }), - ); - - const result = await checkTemplateSourceBoundary({ - projections, - manifestReferencedSourceFiles: builtInManifestReferencedSourceFiles(), - }); - - expect(result).toMatchObject({ - ok: true, - violations: [], - allowlistedDebt: [], - unusedAllowlistEntries: [], - }); - }); - - it("fails built-in Package Addition protected templates missing manifest source declarations", async () => { - const manifest = loadBuiltInPresetSourceManifest(); - const projections = await builtInTemplateBoundaryProjections(manifest); - const result = await checkTemplateSourceBoundary({ - projections, - manifestReferencedSourceFiles: - builtInManifestReferencedSourceFiles().filter( - (sourceFile) => - !sourceFile.endsWith( - path.join("templates", "vue-app", "playwright.config.ts"), - ), - ), - }); - - expect(result.ok).toBe(false); - expect(result.violations).toContainEqual( - expect.objectContaining({ - preset: "vue-app package addition", - generatedPath: "apps/template-boundary-check/playwright.config.ts", - operationKind: "copyFile", - }), - ); - }); - - it("fails unlisted protected Generated Repository outputs with path and owning function diagnostics", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectSyntheticPreset() {", - " return {", - " operations: [", - " {", - ' kind: "writeText",', - ' to: ".github/dependabot.yml",', - ' text: "version: 2\\n",', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeText", - to: ".github/dependabot.yml", - text: "version: 2\n", - }, - ]), - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toEqual([ - expect.objectContaining({ - generatedPath: ".github/dependabot.yml", - owningFunction: "projectSyntheticPreset", - }), - ]); - }); - - it("fails protected Generated Repository outputs copied from undeclared template source", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectCopiedTemplateSource() {", - " return {", - " operations: [", - " {", - ' kind: "copyFile",', - ' from: "github/dependabot.yml",', - ' to: ".github/dependabot.yml",', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "copyFile", - from: "github/dependabot.yml", - to: ".github/dependabot.yml", - }, - ]), - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toEqual([ - expect.objectContaining({ - generatedPath: ".github/dependabot.yml", - operationKind: "copyFile", - owningFunction: "projectCopiedTemplateSource", - }), - ]); - }); - - it("accepts protected Generated Repository outputs copied from manifest-declared template source", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - const referencedSourcePath = path.join(workspace, "github/dependabot.yml"); - await mkdir(path.dirname(referencedSourcePath), { recursive: true }); - await writeFile(referencedSourcePath, "version: 2\n", "utf8"); - await writeFile( - projectionPath, - [ - "function projectCopiedTemplateSource() {", - " return {", - " operations: [", - " {", - ' kind: "copyFile",', - ' from: "github/dependabot.yml",', - ' to: ".github/dependabot.yml",', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: { - ...minimalPlan([ - { - kind: "copyFile", - from: "github/dependabot.yml", - to: ".github/dependabot.yml", - }, - ]), - sourceRoot: workspace, - }, - }, - ], - manifestReferencedSourceFiles: [referencedSourcePath], - }); - - expect(result).toMatchObject({ - ok: true, - violations: [], - allowlistedDebt: [], - }); - }); - - it("accepts manifest-referenced protected source files without hiding inline protected bodies", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - const referencedSourcePath = path.join(workspace, "dependabot.yml"); - await writeFile(referencedSourcePath, "version: 2\n", "utf8"); - await writeFile( - projectionPath, - [ - "function projectManifestReferencedSource() {", - " return {", - " operations: [", - " {", - ' kind: "copyFile",', - ' from: "dependabot.yml",', - ' to: ".github/dependabot.yml",', - " },", - " {", - ' kind: "writeText",', - ' to: ".github/dependabot.yml",', - ' text: "version: 2\\nupdates: []\\n",', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: { - ...minimalPlan([ - { - kind: "copyFile", - from: "dependabot.yml", - to: ".github/dependabot.yml", - }, - { - kind: "writeText", - to: ".github/dependabot.yml", - text: "version: 2\nupdates: []\n", - }, - ]), - sourceRoot: workspace, - }, - }, - ], - manifestReferencedSourceFiles: [referencedSourcePath], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toEqual([ - expect.objectContaining({ - generatedPath: ".github/dependabot.yml", - operationKind: "writeText", - owningFunction: "projectManifestReferencedSource", - }), - ]); - }); - - it("fails unlisted inline package-local tool configuration outputs", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectRustPreset() {", - " return {", - " operations: [", - " {", - ' kind: "writeText",', - ' to: "packages/cli/rustfmt.toml",', - ' text: "edition = \\"2024\\"\\n",', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic-rust", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeText", - to: "packages/cli/rustfmt.toml", - text: 'edition = "2024"\n', - }, - ]), - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toContainEqual( - expect.objectContaining({ - generatedPath: "packages/cli/rustfmt.toml", - owningFunction: "projectRustPreset", - }), - ); - }); - - it("accepts narrow structural machine declarations at protected JSON paths", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectStructuralMachineDeclarations() {", - " const developmentContainer = planDevelopmentContainer();", - " return {", - " operations: [", - " {", - ' kind: "writeJson",', - ' to: "turbo.json",', - " value: {", - " tasks: {", - ' check: { dependsOn: ["^build"] },', - " dev: { cache: false, persistent: true },", - " },", - " },", - " },", - " {", - ' kind: "writeJson",', - ' to: "tsconfig.config.json",', - " value: {", - " compilerOptions: {", - ' module: "nodenext",', - ' moduleResolution: "nodenext",', - " noEmitOnError: true,", - " skipLibCheck: false,", - " strict: true,", - ' target: "es2023",', - " },", - ' include: ["oxlint.config.ts", "oxfmt.config.ts"],', - " },", - " },", - " {", - ' kind: "writeJson",', - ' to: ".devcontainer/devcontainer.json",', - " value: developmentContainer.devcontainer,", - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeJson", - to: "turbo.json", - value: { - tasks: { - check: { dependsOn: ["^build"] }, - dev: { cache: false, persistent: true }, - }, - }, - }, - { - kind: "writeJson", - to: "tsconfig.config.json", - value: { - compilerOptions: { - module: "nodenext", - moduleResolution: "nodenext", - noEmitOnError: true, - skipLibCheck: false, - strict: true, - target: "es2023", - }, - include: ["oxlint.config.ts", "oxfmt.config.ts"], - }, - }, - { - kind: "writeJson", - to: ".devcontainer/devcontainer.json", - value: { - name: "demo", - build: { dockerfile: "Dockerfile" }, - }, - }, - ]), - }, - ], - }); - - expect(result).toMatchObject({ - ok: true, - violations: [], - allowlistedDebt: [], - }); - }); - - it("rejects arbitrary inline bodies at structurally classified protected JSON paths", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectArbitraryProtectedJson() {", - " return {", - " operations: [", - " {", - ' kind: "writeJson",', - ' to: "turbo.json",', - ' value: { scripts: { check: "pnpm test" } },', - " },", - " {", - ' kind: "writeJson",', - ' to: ".devcontainer/devcontainer.json",', - ' value: { name: "demo" },', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeJson", - to: "turbo.json", - value: { scripts: { check: "pnpm test" } }, - }, - { - kind: "writeJson", - to: ".devcontainer/devcontainer.json", - value: { name: "demo" }, - }, - ]), - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - generatedPath: "turbo.json", - owningFunction: "projectArbitraryProtectedJson", - }), - expect.objectContaining({ - generatedPath: ".devcontainer/devcontainer.json", - owningFunction: "projectArbitraryProtectedJson", - }), - ]), - ); - }); - - it("rejects structural turbo.json declarations with extra root keys", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectTurboWithExtraRootKey() {", - " return {", - " operations: [", - " {", - ' kind: "writeJson",', - ' to: "turbo.json",', - " value: { tasks: {}, arbitrary: true },", - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeJson", - to: "turbo.json", - value: { tasks: {}, arbitrary: true }, - }, - ]), - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toContainEqual( - expect.objectContaining({ - generatedPath: "turbo.json", - owningFunction: "projectTurboWithExtraRootKey", - }), - ); - }); - - it("rejects structural tsconfig.config.json declarations with extra root keys", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectTsconfigWithExtraRootKey() {", - " return {", - " operations: [", - " {", - ' kind: "writeJson",', - ' to: "tsconfig.config.json",', - " value: {", - " compilerOptions: {", - ' module: "nodenext",', - ' moduleResolution: "nodenext",', - " noEmitOnError: true,", - " skipLibCheck: false,", - " strict: true,", - ' target: "es2023",', - " },", - ' include: ["oxlint.config.ts"],', - " arbitrary: true,", - " },", - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeJson", - to: "tsconfig.config.json", - value: { - compilerOptions: { - module: "nodenext", - moduleResolution: "nodenext", - noEmitOnError: true, - skipLibCheck: false, - strict: true, - target: "es2023", - }, - include: ["oxlint.config.ts"], - arbitrary: true, - }, - }, - ]), - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toContainEqual( - expect.objectContaining({ - generatedPath: "tsconfig.config.json", - owningFunction: "projectTsconfigWithExtraRootKey", - }), - ); - }); - - it("rejects structural tsconfig.config.json declarations with invalid compiler option values", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectTsconfigWithInvalidCompilerOptions() {", - " return {", - " operations: [", - " {", - ' kind: "writeJson",', - ' to: "tsconfig.config.json",', - " value: {", - " compilerOptions: {", - ' module: "nodenext",', - ' moduleResolution: "nodenext",', - " noEmitOnError: true,", - " skipLibCheck: false,", - ' strict: "true",', - ' target: "es2023",', - " },", - ' include: ["oxlint.config.ts"],', - " },", - " },", - " {", - ' kind: "writeJson",', - ' to: "tsconfig.config.json",', - " value: {", - " compilerOptions: {", - ' module: "CommonJS",', - ' moduleResolution: "nodenext",', - " noEmitOnError: true,", - " skipLibCheck: false,", - " strict: true,", - ' target: "es2023",', - " },", - ' include: ["oxlint.config.ts"],', - " },", - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeJson", - to: "tsconfig.config.json", - value: { - compilerOptions: { - module: "nodenext", - moduleResolution: "nodenext", - noEmitOnError: true, - skipLibCheck: false, - strict: "true", - target: "es2023", - }, - include: ["oxlint.config.ts"], - }, - }, - { - kind: "writeJson", - to: "tsconfig.config.json", - value: { - compilerOptions: { - module: "CommonJS", - moduleResolution: "nodenext", - noEmitOnError: true, - skipLibCheck: false, - strict: true, - target: "es2023", - }, - include: ["oxlint.config.ts"], - }, - }, - ]), - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - generatedPath: "tsconfig.config.json", - owningFunction: "projectTsconfigWithInvalidCompilerOptions", - }), - expect.objectContaining({ - generatedPath: "tsconfig.config.json", - owningFunction: "projectTsconfigWithInvalidCompilerOptions", - }), - ]), - ); - expect(result.violations).toHaveLength(2); - }); - - it("does not let duplicate protected paths inherit another owner's allowlist entry", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function allowedLegacyProjection() {", - " return {", - " operations: [", - " {", - ' kind: "writeText",', - ' to: ".github/dependabot.yml",', - ' text: "version: 2\\n",', - " },", - " ],", - " };", - "}", - "", - "function newUnlistedProjection() {", - " return {", - " operations: [", - " {", - ' kind: "writeText",', - ' to: ".github/dependabot.yml",', - ' text: "version: 2\\nupdates: []\\n",', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeText", - to: ".github/dependabot.yml", - text: "version: 2\nupdates: []\n", - }, - ]), - }, - ], - allowlist: [ - { - preset: "synthetic", - generatedPath: ".github/dependabot.yml", - owningFunction: "allowedLegacyProjection", - reason: "existing debt", - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toContainEqual( - expect.objectContaining({ - generatedPath: ".github/dependabot.yml", - owningFunction: "newUnlistedProjection", - }), - ); - expect(result.allowlistedDebt).toEqual([]); - }); - - it("attributes protected outputs whose generated path comes from a template literal", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectRustPreset() {", - ' const packagePath = "packages/cli";', - " return {", - " operations: [", - " {", - ' kind: "writeText",', - " to: `${packagePath}/rustfmt.toml`,", - ' text: "edition = \\"2024\\"\\n",', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic-rust", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeText", - to: "packages/cli/rustfmt.toml", - text: 'edition = "2024"\n', - }, - ]), - }, - ], - }); - - expect(result.violations).toContainEqual( - expect.objectContaining({ - generatedPath: "packages/cli/rustfmt.toml", - owningFunction: "projectRustPreset", - }), - ); - }); - - it("does not let a structured editor settings operation hide an inline settings body in the same function", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectEditorCustomization() {", - " const editorCustomization = editorCustomizationForCapabilities([]);", - " return {", - " operations: [", - " {", - ' kind: "writeJson",', - ' to: ".vscode/settings.json",', - " value: editorCustomization.settings,", - " },", - " {", - ' kind: "writeJson",', - ' to: ".vscode/settings.json",', - ' value: { "editor.formatOnSave": true },', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeJson", - to: ".vscode/settings.json", - value: { "editor.formatOnSave": true }, - }, - ]), - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toContainEqual( - expect.objectContaining({ - generatedPath: ".vscode/settings.json", - owningFunction: "projectEditorCustomization", - }), - ); - }); - - it("accepts editor extensions operations with exactly editor customization recommendations", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectEditorCustomization() {", - " const editorCustomization = editorCustomizationForCapabilities([]);", - " return {", - " operations: [", - " {", - ' kind: "writeJson",', - ' to: ".vscode/extensions.json",', - " value: {", - " recommendations: editorCustomization.extensions,", - " },", - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeJson", - to: ".vscode/extensions.json", - value: { - recommendations: [], - }, - }, - ]), - }, - ], - }); - - expect(result).toMatchObject({ - ok: true, - violations: [], - allowlistedDebt: [], - }); - }); - - it("rejects editor extensions operations with extra inline value properties", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectEditorCustomization() {", - " const editorCustomization = editorCustomizationForCapabilities([]);", - " return {", - " operations: [", - " {", - ' kind: "writeJson",', - ' to: ".vscode/extensions.json",', - " value: {", - " recommendations: editorCustomization.extensions,", - " },", - " },", - " {", - ' kind: "writeJson",', - ' to: ".vscode/extensions.json",', - " value: {", - " recommendations: editorCustomization.extensions,", - " unwanted: true,", - " },", - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "writeJson", - to: ".vscode/extensions.json", - value: { - recommendations: [], - unwanted: true, - }, - }, - ]), - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.violations).toContainEqual( - expect.objectContaining({ - generatedPath: ".vscode/extensions.json", - owningFunction: "projectEditorCustomization", - }), - ); - }); - - it("rejects unused allowlist entries for checked projections", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - await writeFile( - projectionPath, - [ - "function projectCopiedTemplateSource() {", - " return {", - " operations: [", - " {", - ' kind: "copyFile",', - ' from: "github/dependabot.yml",', - ' to: ".github/dependabot.yml",', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: minimalPlan([ - { - kind: "copyFile", - from: "github/dependabot.yml", - to: ".github/dependabot.yml", - }, - ]), - }, - ], - allowlist: [ - { - preset: "synthetic", - generatedPath: ".github/dependabot.yml", - owningFunction: "projectCopiedTemplateSource", - reason: "stale debt entry", - }, - ], - }); - - expect(result.ok).toBe(false); - expect(result.unusedAllowlistEntries).toEqual([ - { - preset: "synthetic", - generatedPath: ".github/dependabot.yml", - owningFunction: "projectCopiedTemplateSource", - reason: "stale debt entry", - }, - ]); - }); - - it("accepts allowlisted current boundary debt while reporting it explicitly", async () => { - const targetDir = path.join(tmpdir(), "demo-ts-lib"); - const projection = findBuiltInPresetProjection("ts-lib")!; - const blueprint = projection.blueprint({ targetDir }); - const plan = projection.project( - assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@11.2.3", - }, - source: "online", - diagnostics: [], - }, - }), - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "ts-lib", - sourceFilePath: builtInProjectionSourceFile(), - plan, - }, - ], - manifestReferencedSourceFiles: builtInManifestReferencedSourceFiles(), - allowlist: templateBoundaryDebtAllowlist, - }); - - expect(result.ok).toBe(true); - expect(result.violations).toEqual([]); - expect(result.allowlistedDebt).not.toContainEqual( - expect.objectContaining({ - preset: "ts-lib", - generatedPath: ".devcontainer/Dockerfile", - }), - ); - expect(result.allowlistedDebt).not.toContainEqual( - expect.objectContaining({ - preset: "ts-lib", - generatedPath: ".vscode/extensions.json", - }), - ); - expect(result.allowlistedDebt).not.toContainEqual( - expect.objectContaining({ - preset: "ts-lib", - generatedPath: ".vscode/settings.json", - }), - ); - }); - - it.each(["vue-app", "vue-hono-app"] as const)( - "accepts %s browser Dockerfile fragment composition without allowlisted debt", - async (presetName) => { - const targetDir = path.join(tmpdir(), `demo-${presetName}`); - const projection = findBuiltInPresetProjection(presetName)!; - const blueprint = projection.blueprint({ targetDir }); - const plan = projection.project( - assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@11.2.3", - }, - source: "online", - diagnostics: [], - }, - }), - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: presetName, - sourceFilePath: builtInProjectionSourceFile(), - plan, - }, - ], - manifestReferencedSourceFiles: builtInManifestReferencedSourceFiles(), - allowlist: templateBoundaryDebtAllowlist, - }); - - expect(result.ok).toBe(true); - expect(result.violations).toEqual([]); - expect(result.allowlistedDebt).not.toContainEqual( - expect.objectContaining({ - preset: presetName, - generatedPath: ".devcontainer/Dockerfile", - }), - ); - }, - ); - - it("accepts rust-bin Dockerfile fragment composition without allowlisted Dockerfile debt", async () => { - const targetDir = path.join(tmpdir(), "demo-rust-bin"); - const projection = findBuiltInPresetProjection("rust-bin")!; - const blueprint = projection.blueprint({ targetDir }); - const plan = projection.project( - assembleGenerationContext({ - targetDir, - blueprint, - toolchain: { - nodeLtsMajor: { kind: "NodeLtsMajor", value: "24" }, - packageManagerPin: { - kind: "PackageManagerPin", - value: "pnpm@11.2.3", - }, - source: "online", - diagnostics: [], - }, - }), - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "rust-bin", - sourceFilePath: builtInProjectionSourceFile(), - plan, - }, - ], - manifestReferencedSourceFiles: builtInManifestReferencedSourceFiles(), - allowlist: templateBoundaryDebtAllowlist, - }); - - expect(result.ok).toBe(true); - expect(result.violations).toEqual([]); - expect(result.allowlistedDebt).not.toContainEqual( - expect.objectContaining({ - preset: "rust-bin", - generatedPath: ".devcontainer/Dockerfile", - }), - ); - }); - - it("accepts protected Generated Repository outputs copied from manifest-declared template source", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-boundary-check-"), - ); - const projectionPath = path.join(workspace, "projection.ts"); - const referencedSourcePath = path.join(workspace, "github/dependabot.yml"); - await mkdir(path.dirname(referencedSourcePath), { recursive: true }); - await writeFile(referencedSourcePath, "version: 2\n", "utf8"); - await writeFile( - projectionPath, - [ - "function projectCopiedTemplateSource() {", - " return {", - " operations: [", - " {", - ' kind: "copyFile",', - ' from: "github/dependabot.yml",', - ' to: ".github/dependabot.yml",', - " },", - " ],", - " };", - "}", - "", - ].join("\n"), - "utf8", - ); - - const result = await checkTemplateSourceBoundary({ - projections: [ - { - name: "synthetic", - sourceFilePath: projectionPath, - plan: { - ...minimalPlan([ - { - kind: "copyFile", - from: "github/dependabot.yml", - to: ".github/dependabot.yml", - }, - ]), - sourceRoot: workspace, - }, - }, - ], - manifestReferencedSourceFiles: [referencedSourcePath], - }); - - expect(result).toMatchObject({ - ok: true, - violations: [], - allowlistedDebt: [], - }); - }); -}); diff --git a/test/template-init.test.ts b/test/template-init.test.ts deleted file mode 100644 index 7b4b357..0000000 --- a/test/template-init.test.ts +++ /dev/null @@ -1,1487 +0,0 @@ -import { - chmod, - mkdir, - mkdtemp, - readFile, - readdir, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - builtInPresets, - loadBuiltInPresetSourceManifest, -} from "@ykdz/template-builtin-source"; -import { execa } from "execa"; -import * as v from "valibot"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); -const optionalGitDisplays = [ - "git init", - "git add .", - 'git commit -m "Initial commit"', -]; - -const defaultToolchainEnv = { - TEMPLATE_TOOLCHAIN_NODE_RELEASE_INDEX_URL: jsonDataUrl([ - { version: "v24.11.0", lts: "Krypton" }, - { version: "v26.1.0", lts: false }, - ]), - TEMPLATE_TOOLCHAIN_PNPM_REGISTRY_URL: jsonDataUrl({ - time: { "11.11.0": "2025-01-01T00:00:00.000Z" }, - versions: { - "11.11.0": { engines: { node: ">=24.0.0" } }, - }, - }), -}; - -process.env.TEMPLATE_TOOLCHAIN_NODE_RELEASE_INDEX_URL ??= - defaultToolchainEnv.TEMPLATE_TOOLCHAIN_NODE_RELEASE_INDEX_URL; -process.env.TEMPLATE_TOOLCHAIN_PNPM_REGISTRY_URL ??= - defaultToolchainEnv.TEMPLATE_TOOLCHAIN_PNPM_REGISTRY_URL; - -function jsonDataUrl(value: unknown): string { - return `data:application/json,${encodeURIComponent(JSON.stringify(value))}`; -} - -const initJsonOutputSchema = v.looseObject({ - command: v.optional(v.string()), - dryRun: v.optional(v.boolean()), - targetDir: v.string(), - blueprint: v.looseObject({ - preset: v.string(), - packageManager: v.optional(v.string()), - packages: v.optional( - v.array(v.object({ name: v.string(), path: v.string() })), - ), - }), - toolchain: v.looseObject({ - nodeLtsMajor: v.string(), - packageManagerPin: v.string(), - source: v.optional(v.string()), - diagnostics: v.optional(v.array(v.string())), - }), - nextSteps: v.array( - v.looseObject({ - id: v.string(), - display: v.string(), - command: v.optional(v.string()), - args: v.optional(v.array(v.string())), - cwd: v.optional(v.string()), - machineVerifiable: v.optional(v.boolean()), - }), - ), - followUpDocument: v.looseObject({ - enabled: v.boolean(), - path: v.optional(v.string()), - }), -}); -function parseJsonWithSchema( - text: string, - schema: Schema, -): v.InferOutput { - return v.parse(schema, JSON.parse(text) as unknown); -} - -async function expectCommandFailure( - command: Promise, - expected: { readonly stderr?: string; readonly stdout?: string | RegExp }, -): Promise { - try { - await command; - } catch (error) { - if (expected.stderr !== undefined) { - expect(commandErrorOutput(error, "stderr")).toContain(expected.stderr); - } - - if (typeof expected.stdout === "string") { - expect(commandErrorOutput(error, "stdout")).toContain(expected.stdout); - } else if (expected.stdout !== undefined) { - expect(commandErrorOutput(error, "stdout")).toMatch(expected.stdout); - } - - return; - } - - throw new Error("Expected command to fail"); -} - -function commandErrorOutput( - error: unknown, - stream: "stderr" | "stdout", -): string { - if (typeof error !== "object" || error === null) { - throw error; - } - - if ( - stream === "stderr" && - "stderr" in error && - typeof error.stderr === "string" - ) { - return error.stderr; - } - - if ( - stream === "stdout" && - "stdout" in error && - typeof error.stdout === "string" - ) { - return error.stdout; - } - - throw error; -} - -function toolchainEnvWithPnpm(version: string): Record { - return { - TEMPLATE_TOOLCHAIN_NODE_RELEASE_INDEX_URL: jsonDataUrl([ - { version: "v24.11.0", lts: "Krypton" }, - { version: "v26.1.0", lts: false }, - ]), - TEMPLATE_TOOLCHAIN_PNPM_REGISTRY_URL: jsonDataUrl({ - time: { - [version]: "2025-01-01T00:00:00.000Z", - "12.0.0": "2025-01-01T00:00:00.000Z", - }, - versions: { - [version]: { engines: { node: ">=24.0.0" } }, - "12.0.0": { engines: { node: ">=26.0.0" } }, - }, - }), - }; -} - -async function readJson(filePath: string): Promise { - const parsed: unknown = JSON.parse(await readFile(filePath, "utf8")); - // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Large generated-fixture tests still use typed JSON reads; schema-backed reads are being introduced around high-risk assertions. - return parsed as T; -} - -async function writeExecutable( - filePath: string, - content: string, -): Promise { - await writeFile(filePath, content, "utf8"); - await chmod(filePath, 0o755); -} - -async function generatedFilePaths( - root: string, - current = ".", -): Promise { - const entries = await readdir(path.join(root, current), { - withFileTypes: true, - }); - const files: string[] = []; - - for (const entry of entries) { - const relativePath = path.posix.join(current, entry.name); - if (entry.isDirectory()) { - files.push(...(await generatedFilePaths(root, relativePath))); - continue; - } - - files.push(relativePath); - } - - return files.toSorted(); -} - -async function generatePresetProject(preset: string): Promise { - const workspace = await mkdtemp(path.join(tmpdir(), "template-init-")); - const projectDir = path.join(workspace, `demo-${preset}`); - - await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - preset, - "--yes", - ], - { cwd: repoRoot }, - ); - - return projectDir; -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'`; -} - -const forbiddenWorkspaceLifecycleFeatures = [ - "workspace-audit", - "workspace-diff", - "workspace-upgrade", -] as const; - -function expectNoWorkspaceLifecycleFeatures(features: readonly string[]): void { - for (const feature of forbiddenWorkspaceLifecycleFeatures) { - expect(features).not.toContain(feature); - } -} - -function supportedPresetNamesWithCapability(kind: string): string[] { - return loadBuiltInPresetSourceManifest() - .presets.filter( - (preset) => - preset.generation === "supported" && - (preset.projection?.capabilities ?? []).some( - (capability) => capability.kind === kind, - ), - ) - .map((preset) => preset.name); -} - -describe("template init", () => { - it.each(forbiddenWorkspaceLifecycleFeatures)( - "rejects the %s lifecycle feature on its own", - (feature) => { - expect(() => { - expectNoWorkspaceLifecycleFeatures(["root-check", feature]); - }).toThrow(); - }, - ); - - it("generates capability-aware infrastructure for every supported built-in preset", async () => { - const supportedPresets = builtInPresets.filter( - (preset) => preset.generation === "supported", - ); - const outOfScopePathPatterns = [ - /^(?!\.devcontainer\/Dockerfile$)(?!apps\/web\/Dockerfile$)(?:.*\/)?Dockerfile$/, - /(^|\/)\.dockerignore$/, - /(^|\/)docker-compose\.ya?ml$/, - /(^|\/)compose\.ya?ml$/, - /(^|\/)k8s\//, - /(^|\/)deploy\//, - /(^|\/)\.github\/workflows\/.*(audit|diff|upgrade|ownership|manifest|release|deploy|image|docker).*\.ya?ml$/, - ]; - - for (const preset of supportedPresets) { - const projectDir = await generatePresetProject(preset.name); - const blueprint = await readJson<{ - features: string[]; - packageManager?: string; - }>(path.join(projectDir, ".template/blueprint.json")); - const devcontainer = await readJson<{ - image?: string; - build?: { - dockerfile: string; - args?: Record; - }; - features?: Record; - postCreateCommand?: string; - mounts?: string[]; - customizations: { - vscode: { - extensions: string[]; - settings?: Record; - }; - }; - }>(path.join(projectDir, ".devcontainer/devcontainer.json")); - const packageJson = await readJson<{ - engines: { node: string }; - packageManager: string; - }>(path.join(projectDir, "package.json")); - const checkWorkflow = await readFile( - path.join(projectDir, ".github/workflows/check.yml"), - "utf8", - ); - const gitignore = await readFile( - path.join(projectDir, ".gitignore"), - "utf8", - ); - const dependabot = await readFile( - path.join(projectDir, ".github/dependabot.yml"), - "utf8", - ); - const files = await generatedFilePaths(projectDir); - const dockerfile = files.includes(".devcontainer/Dockerfile") - ? await readFile( - path.join(projectDir, ".devcontainer/Dockerfile"), - "utf8", - ) - : undefined; - - expect( - files.filter((file) => file.startsWith(".github/workflows/")), - ).toEqual([ - ".github/workflows/check.yml", - ".github/workflows/toolchain-baseline-update.yml", - ]); - expect( - files.some((file) => - outOfScopePathPatterns.some((pattern) => pattern.test(file)), - ), - ).toBe(false); - expect(files).toContain(".template/blueprint.json"); - expect(files).toContain(".template/generated-by.json"); - expect(files.some((file) => file.startsWith(".project-kit/"))).toBe( - false, - ); - expect(gitignore).toContain(".template/\n"); - expect(gitignore).toContain(".pnpm-store/\n"); - await expect( - stat(path.join(projectDir, ".project-kit")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - await expect(stat(path.join(projectDir, ".git"))).rejects.toMatchObject({ - code: "ENOENT", - }); - expect(blueprint.features).not.toContain("native-binary-release"); - expectNoWorkspaceLifecycleFeatures(blueprint.features); - - expect(checkWorkflow).toContain("pull_request:"); - expect(checkWorkflow).toContain("push:"); - expect(checkWorkflow).not.toMatch( - /\b(gh|hub)\s+(repo|api|auth|pr|release)\b/, - ); - expect(checkWorkflow).not.toMatch(/\bdocker\s+(build|push|login)\b/); - expect(checkWorkflow).not.toContain("docker/build-push-action"); - expect(checkWorkflow).not.toContain("docker/login-action"); - - expect(dependabot).toContain("package-ecosystem: github-actions"); - if (blueprint.packageManager === "pnpm") { - expect(dependabot).toContain("package-ecosystem: npm"); - expect(checkWorkflow).toContain("uses: actions/setup-node@v6"); - expect(checkWorkflow).toContain("node-version-file: package.json"); - expect(checkWorkflow).toContain("run: corepack enable"); - expect(checkWorkflow).not.toContain("uses: pnpm/action-setup"); - expect(checkWorkflow).not.toContain("node-version:"); - expect(checkWorkflow).toContain("run: pnpm install"); - expect(checkWorkflow).toContain("run: pnpm run check"); - expect(checkWorkflow).not.toContain("run: ./scripts/check"); - } - - expect(files).toContain(".devcontainer/Dockerfile"); - expect(devcontainer.build?.dockerfile).toBe("Dockerfile"); - expect(devcontainer.build?.args).toMatchObject({ - NODE_VERSION: packageJson.engines.node, - PACKAGE_MANAGER_PIN: packageJson.packageManager, - }); - expect(devcontainer).not.toHaveProperty("features"); - expect(devcontainer).not.toHaveProperty("postCreateCommand"); - if (dockerfile !== undefined) { - expect(dockerfile).toContain("ARG NODE_VERSION"); - expect(dockerfile).toContain("FROM node:${NODE_VERSION}-bookworm-slim"); - expect(dockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(dockerfile).not.toContain("typescript-node"); - expect(dockerfile).not.toMatch( - /\b(?:npm|pnpm|corepack)\s+.*-g\s+turbo\b/, - ); - } - - const workspaceExtensions = await readJson<{ - recommendations: string[]; - }>(path.join(projectDir, ".vscode/extensions.json")); - const workspaceSettings = await readJson>( - path.join(projectDir, ".vscode/settings.json"), - ); - - expect(devcontainer.customizations.vscode.extensions).toEqual( - workspaceExtensions.recommendations, - ); - expect(devcontainer.customizations.vscode.settings).toEqual( - workspaceSettings, - ); - } - }, 300_000); - - it("generates Node presets with the explicit pnpm 11 workspace contract", async () => { - const nodePresetNames = supportedPresetNamesWithCapability( - "node-pnpm-devcontainer", - ); - - for (const preset of nodePresetNames) { - const projectDir = await generatePresetProject(preset); - const packageJson = await readJson<{ - engines: { node: string }; - packageManager: string; - }>(path.join(projectDir, "package.json")); - const checkWorkflow = await readFile( - path.join(projectDir, ".github/workflows/check.yml"), - "utf8", - ); - const workspaceYaml = await readFile( - path.join(projectDir, "pnpm-workspace.yaml"), - "utf8", - ); - const dependabot = await readFile( - path.join(projectDir, ".github/dependabot.yml"), - "utf8", - ); - - expect(packageJson.engines.node).toBe("24"); - expect(packageJson.packageManager).toBe("pnpm@11.11.0"); - expect(workspaceYaml).toContain("nodeLinker: isolated"); - expect(workspaceYaml).toContain("injectWorkspacePackages: true"); - expect(workspaceYaml).toContain("dedupeInjectedDeps: false"); - expect(workspaceYaml).toContain( - "syncInjectedDepsAfterScripts:\n - build:run", - ); - expect(workspaceYaml).toContain("minimumReleaseAge: 1440"); - expect(workspaceYaml).toContain("minimumReleaseAgeStrict: true"); - expect(checkWorkflow).toContain("node-version-file: package.json"); - expect(checkWorkflow).toContain("run: corepack enable"); - expect(checkWorkflow).not.toContain("uses: pnpm/action-setup"); - expect(checkWorkflow).not.toContain(" version: 10.0.0"); - expect(dependabot).toContain("package-ecosystem: npm"); - expect(dependabot).toContain("package-ecosystem: github-actions"); - } - }, 240_000); - - it("generates Node presets with checked OXC config source", async () => { - const nodePresetNames = - supportedPresetNamesWithCapability("oxc-format-lint"); - - for (const preset of nodePresetNames) { - const projectDir = await generatePresetProject(preset); - const files = await generatedFilePaths(projectDir); - const blueprint = await readJson<{ - packages?: Array<{ path: string }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const packageDirs = blueprint.packages?.map((pkg) => pkg.path) ?? ["."]; - - expect(files).toContain("oxlint.config.ts"); - expect(files).toContain("oxfmt.config.ts"); - - const rootPackageJson = await readJson<{ - scripts: Record; - }>(path.join(projectDir, "package.json")); - const rootConfigTsconfig = await readJson<{ include: string[] }>( - path.join(projectDir, "tsconfig.config.json"), - ); - expect(rootPackageJson.scripts.typecheck).toBe( - "turbo run typecheck:run --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts["typecheck:run"]).toBe( - "tsc -p tsconfig.config.json --noEmit --pretty false", - ); - expect(rootConfigTsconfig.include).toEqual([ - "oxlint.config.ts", - "oxfmt.config.ts", - "scripts/**/*.ts", - ]); - expect(rootPackageJson.scripts["format:check"]).toBe( - "turbo run format:check:run --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts["format:check:run"]).toBe( - "oxfmt --list-different oxlint.config.ts oxfmt.config.ts", - ); - expect(rootPackageJson.scripts.lint).toBe( - "turbo run lint:run --output-logs=errors-only --log-order=grouped", - ); - expect(rootPackageJson.scripts["lint:run"]).toBe( - "oxlint --quiet --format=unix oxlint.config.ts oxfmt.config.ts", - ); - - for (const packageDir of packageDirs) { - const packageJson = await readJson<{ - scripts: Record; - }>(path.join(projectDir, packageDir, "package.json")); - expect(packageJson.scripts["format:check:run"]).toContain( - "--config ../../oxfmt.config.ts", - ); - expect(packageJson.scripts["format:write:run"]).toContain( - "--config ../../oxfmt.config.ts", - ); - expect(packageJson.scripts["lint:run"]).toContain( - "--config ../../oxlint.config.ts", - ); - expect(packageJson.scripts["lint:fix:run"]).toContain( - "--config ../../oxlint.config.ts", - ); - expect(files).not.toContain(`${packageDir}/oxlint.config.ts`); - expect(files).not.toContain(`${packageDir}/oxfmt.config.ts`); - } - - expect(files.some((file) => file.endsWith(".oxlintrc.json"))).toBe(false); - expect(files.some((file) => file.endsWith(".oxfmtrc.json"))).toBe(false); - } - }, 240_000); - - it("generates a usable ts-lib project through the CLI", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-init-")); - const projectDir = path.join(workspace, "demo-lib"); - - const result = await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - "--yes", - ], - { cwd: repoRoot }, - ); - - const gitignore = await readFile( - path.join(projectDir, ".gitignore"), - "utf8", - ); - const blueprint = await readJson<{ preset: string }>( - path.join(projectDir, ".template/blueprint.json"), - ); - const generatedBy = await readJson<{ packageName: string }>( - path.join(projectDir, ".template/generated-by.json"), - ); - const templateFiles = await readdir(path.join(projectDir, ".template")); - - expect(result.stdout).toContain("Initialized project"); - expect(result.stdout).toContain(projectDir); - expect(blueprint.preset).toBe("ts-lib"); - expect(generatedBy.packageName).toBe("@ykdz/template"); - expect(templateFiles).toEqual(["blueprint.json", "generated-by.json"]); - expect(gitignore).toContain(".template/\n"); - expect(gitignore).toContain(".pnpm-store/\n"); - - await stat(path.join(projectDir, "package.json")); - await expect( - stat(path.join(projectDir, ".project-kit")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - await expect(stat(path.join(projectDir, ".git"))).rejects.toMatchObject({ - code: "ENOENT", - }); - - await expect( - stat(path.join(projectDir, "pnpm-lock.yaml")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("prints the planned Project Blueprint during dry-run without writing files", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-dry-run-")); - const projectDir = path.join(workspace, "demo-lib"); - - const result = await execa( - process.execPath, - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - "--dry-run", - ], - { cwd: repoRoot }, - ); - - expect(result.stdout).toContain("Project Blueprint"); - expect(result.stdout).toMatch(/Preset:\s+ts-lib/); - expect(result.stdout).toContain("Target:"); - expect(result.stdout).toContain(projectDir); - expect(result.stdout).toContain("Toolchain Resolution:"); - expect(result.stdout).toContain("Node LTS major:"); - expect(result.stdout).toContain("Package Manager Pin:"); - expect(result.stdout).toContain("Generated Follow-Up Document:"); - expect(result.stdout).toMatch(/Enabled:\s+yes/); - expect(result.stdout).toContain(path.join(projectDir, "TODO.md")); - expect(result.stdout).not.toContain("Next Step Instructions:"); - expect(result.stdout).not.toContain("pnpm install"); - expect(result.stdout).not.toContain("Post Commands:"); - expect(result.stdout).not.toContain("corepack"); - - await expect(stat(projectDir)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("prints machine-readable init output with --json", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-json-")); - const projectDir = path.join(workspace, "demo-fullstack"); - - const result = await execa( - process.execPath, - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "vue-hono-app", - "--scope", - "custom-scope", - "--dry-run", - "--json", - ], - { cwd: repoRoot }, - ); - - const output = parseJsonWithSchema(result.stdout, initJsonOutputSchema); - - expect(output.command).toBe("init"); - expect(output.dryRun).toBe(true); - expect(output.targetDir).toBe(projectDir); - expect(output.blueprint.preset).toBe("vue-hono-app"); - expect(output.blueprint.packageManager).toBe("pnpm"); - expect(output.blueprint.packages).toEqual([ - { name: "@custom-scope/web", path: "apps/web" }, - { name: "@custom-scope/api", path: "apps/api" }, - ]); - expect(output.toolchain.nodeLtsMajor.length).toBeGreaterThan(0); - expect(output.toolchain.packageManagerPin).toMatch(/^pnpm@/); - expect(output.nextSteps.map((step) => step.display)).toEqual([ - `cd ${projectDir}`, - "pnpm install", - "pnpm run fix", - "pnpm --filter ./apps/web exec playwright install chromium", - "pnpm run check", - ...optionalGitDisplays, - ]); - expect(output.followUpDocument).toEqual({ - enabled: true, - path: "TODO.md", - }); - expect(output.nextSteps[2]).toEqual( - expect.objectContaining({ - id: "run-fix", - command: "pnpm", - args: ["run", "fix"], - cwd: projectDir, - }), - ); - expect(output.nextSteps[3]).toEqual( - expect.objectContaining({ - id: "install-apps-web-playwright-browsers", - command: "pnpm", - args: [ - "--filter", - "./apps/web", - "exec", - "playwright", - "install", - "chromium", - ], - cwd: projectDir, - }), - ); - expect(output).not.toHaveProperty("postCommands"); - expect(output).not.toHaveProperty("ready"); - await expect(stat(projectDir)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("prints rust-bin dry-run JSON from the Preset Projection plan", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-json-")); - const projectDir = path.join(workspace, "demo-rust"); - - const result = await execa( - process.execPath, - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "rust-bin", - "--dry-run", - "--json", - ], - { cwd: repoRoot }, - ); - - const output = parseJsonWithSchema(result.stdout, initJsonOutputSchema); - - expect(output.command).toBe("init"); - expect(output.dryRun).toBe(true); - expect(output.targetDir).toBe(projectDir); - expect(output.blueprint.preset).toBe("rust-bin"); - expect(output.blueprint.packageManager).toBe("pnpm"); - expect(output.toolchain.nodeLtsMajor.length).toBeGreaterThan(0); - expect(output.toolchain.packageManagerPin).toMatch(/^pnpm@/); - expect(output.nextSteps.map((step) => step.display)).toEqual([ - `cd ${projectDir}`, - "pnpm install", - "pnpm run fix", - "pnpm run check", - ...optionalGitDisplays, - ]); - await expect(stat(projectDir)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("prints machine-readable init output after non-dry-run generation with --json --yes", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-json-")); - const projectDir = path.join(workspace, "demo-lib"); - - const result = await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - "--json", - "--yes", - ], - { cwd: repoRoot }, - ); - - const output = parseJsonWithSchema(result.stdout, initJsonOutputSchema); - - expect(output.command).toBe("init"); - expect(output.dryRun).toBe(false); - expect(output.targetDir).toBe(projectDir); - expect(output.blueprint.preset).toBe("ts-lib"); - expect(output.blueprint.packageManager).toBe("pnpm"); - expect( - output.nextSteps.map((step) => ({ - id: step.id, - display: step.display, - command: step.command, - args: step.args, - cwd: step.cwd, - machineVerifiable: step.machineVerifiable, - })), - ).toEqual([ - { - id: "enter-project", - display: `cd ${projectDir}`, - command: "cd", - args: [projectDir], - cwd: ".", - machineVerifiable: false, - }, - { - id: "install-dependencies", - display: "pnpm install", - command: "pnpm", - args: ["install"], - cwd: projectDir, - machineVerifiable: true, - }, - { - id: "run-fix", - display: "pnpm run fix", - command: "pnpm", - args: ["run", "fix"], - cwd: projectDir, - machineVerifiable: true, - }, - { - id: "run-root-check", - display: "pnpm run check", - command: "pnpm", - args: ["run", "check"], - cwd: projectDir, - machineVerifiable: true, - }, - { - id: "optional-git-init", - display: "git init", - command: "git", - args: ["init"], - cwd: projectDir, - machineVerifiable: false, - }, - { - id: "optional-git-add", - display: "git add .", - command: "git", - args: ["add", "."], - cwd: projectDir, - machineVerifiable: false, - }, - { - id: "optional-git-commit", - display: 'git commit -m "Initial commit"', - command: "git", - args: ["commit", "-m", "Initial commit"], - cwd: projectDir, - machineVerifiable: false, - }, - ]); - expect(output.toolchain).toEqual({ - nodeLtsMajor: "24", - packageManagerPin: "pnpm@11.11.0", - source: "online", - diagnostics: [], - }); - expect(output.followUpDocument).toEqual({ - enabled: true, - path: "TODO.md", - }); - expect(output).not.toHaveProperty("postCommands"); - expect(output).not.toHaveProperty("ready"); - await stat(path.join(projectDir, "package.json")); - await expect( - readFile(path.join(projectDir, "TODO.md"), "utf8"), - ).resolves.toContain("### Next Steps"); - expect( - await readJson>( - path.join(projectDir, ".template", "generated-by.json"), - ), - ).not.toHaveProperty("nextSteps"); - await expect( - stat(path.join(projectDir, "pnpm-lock.yaml")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("keeps projection next step paths consistent with a relative target dir", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-json-")); - - const result = await execa( - process.execPath, - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - "demo-lib", - "--preset", - "ts-lib", - "--dry-run", - "--json", - ], - { cwd: workspace }, - ); - - const output = parseJsonWithSchema(result.stdout, initJsonOutputSchema); - - expect(output.targetDir).toBe("demo-lib"); - const firstNextStep = output.nextSteps[0]; - expect(firstNextStep).toBeDefined(); - expect( - firstNextStep && { - id: firstNextStep.id, - display: firstNextStep.display, - args: firstNextStep.args, - cwd: firstNextStep.cwd, - }, - ).toEqual({ - id: "enter-project", - display: "cd demo-lib", - args: ["demo-lib"], - cwd: ".", - }); - expect(output.nextSteps.slice(1).map((step) => step.cwd)).toEqual([ - "demo-lib", - "demo-lib", - "demo-lib", - "demo-lib", - "demo-lib", - "demo-lib", - ]); - }); - - it("reports bundled toolchain fallback in JSON output and the generation record", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-toolchain-fallback-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - - const result = await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - "--json", - "--yes", - ], - { - cwd: repoRoot, - env: { - TEMPLATE_TOOLCHAIN_RESOLUTION: "online", - TEMPLATE_TOOLCHAIN_NODE_RELEASE_INDEX_URL: - "http://127.0.0.1:9/index.json", - }, - }, - ); - - const output = parseJsonWithSchema(result.stdout, initJsonOutputSchema); - const generationRecord = await readJson<{ - toolchain: { - nodeLtsMajor: string; - packageManagerPin: string; - source: string; - }; - }>(path.join(projectDir, ".template/generated-by.json")); - - expect(output.toolchain).toEqual({ - nodeLtsMajor: "24", - packageManagerPin: "pnpm@11.11.0", - source: "bundled-fallback", - diagnostics: [ - expect.stringContaining("Using bundled fallback toolchain metadata"), - ], - }); - expect(generationRecord.toolchain).toEqual({ - nodeLtsMajor: "24", - packageManagerPin: "pnpm@11.11.0", - source: "bundled-fallback", - }); - }); - - it("reports bundled toolchain fallback visibly in human init output", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-toolchain-fallback-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - - const result = await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - "--yes", - ], - { - cwd: repoRoot, - env: { - TEMPLATE_TOOLCHAIN_RESOLUTION: "online", - TEMPLATE_TOOLCHAIN_NODE_RELEASE_INDEX_URL: - "http://127.0.0.1:9/index.json", - }, - }, - ); - - expect(result.stdout).toContain("Toolchain Resolution:"); - expect(result.stdout).toMatch(/Source:\s+bundled-fallback/); - expect(result.stdout).toMatch(/Node LTS major:\s+24/); - expect(result.stdout).toMatch(/Package Manager Pin:\s+pnpm@11\.11\.0/); - expect(result.stdout).toContain( - "Using bundled fallback toolchain metadata", - ); - }); - - it("rejects ready mode without executing generated project commands", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-ready-")); - const projectDir = path.join(workspace, "demo-lib"); - const fakeBin = path.join(workspace, "bin"); - await mkdir(fakeBin); - await writeExecutable( - path.join(fakeBin, "corepack"), - [ - "#!/usr/bin/env node", - 'import { readFileSync, writeFileSync } from "node:fs";', - "", - "const args = process.argv.slice(2);", - "if (args[0] === 'use') {", - " const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));", - " packageJson.packageManager = `${args[1]}+sha224.fake-corepack-hash`;", - " writeFileSync('package.json', `${JSON.stringify(packageJson, null, 2)}\\n`);", - " writeFileSync('pnpm-lock.yaml', 'lockfileVersion: 9.0\\n');", - "}", - "", - ].join("\n"), - ); - await writeExecutable( - path.join(fakeBin, "pnpm"), - [ - "#!/usr/bin/env node", - 'import { appendFileSync, writeFileSync } from "node:fs";', - "", - "const args = process.argv.slice(2);", - "appendFileSync('ready-command-log.jsonl', `${JSON.stringify(args)}\\n`);", - "if (args[0] === 'install') {", - " writeFileSync('pnpm-lock.yaml', 'lockfileVersion: 9.0\\n');", - "}", - "if (args[0] === 'run' && args[1] === 'fix') {", - " writeFileSync('READY_FIX_RAN', 'yes\\n');", - "}", - "", - ].join("\n"), - ); - - const result = await execa( - process.execPath, - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - "--ready", - "--json", - "--yes", - ], - { - cwd: repoRoot, - env: { - PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, - ...toolchainEnvWithPnpm("10.2.0"), - }, - reject: false, - }, - ); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Error: Unknown option: --ready"); - expect(result.stderr).toContain("Run `template --help` for usage."); - expect(result.stderr).not.toContain("Usage:"); - expect(result.stderr).not.toContain( - "Run template-maintained Post Commands", - ); - await expect(stat(projectDir)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("does not advertise ready mode in help", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-ready-failure-"), - ); - - const result = await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "--help", - ], - { - cwd: repoRoot, - }, - ); - - expect(result.stdout).toContain("Usage:"); - expect(result.stdout).not.toContain("--ready"); - expect(result.stdout).not.toContain("Post Commands"); - expect(result.stdout).toContain("Init options:"); - expect(result.stdout).toContain(" --yes"); - expect(result.stdout).toContain("Add package options:"); - expect(result.stdout).not.toMatch(/Add package options:[\s\S]*--yes/); - await expect(stat(workspace)).resolves.toBeDefined(); - }); - - it("fails clearly in non-interactive init without --yes", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-noninteractive-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - ], - { cwd: repoRoot, timeout: 5_000 }, - ), - { stderr: "Non-interactive init requires --yes" }, - ); - - await expect(stat(projectDir)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("allows empty target directories and rejects non-empty target directories", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-dir-safety-"), - ); - const emptyDir = path.join(workspace, "empty"); - const nonEmptyDir = path.join(workspace, "non-empty"); - await mkdir(emptyDir); - await mkdir(nonEmptyDir); - await writeFile( - path.join(nonEmptyDir, "README.md"), - "# existing\n", - "utf8", - ); - - await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - emptyDir, - "--preset", - "ts-lib", - "--yes", - ], - { cwd: repoRoot }, - ); - - await stat(path.join(emptyDir, "package.json")); - - const nonEmptyResult = await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - nonEmptyDir, - "--preset", - "ts-lib", - "--yes", - ], - { cwd: repoRoot, reject: false }, - ); - - expect(nonEmptyResult.exitCode).toBe(1); - expect(nonEmptyResult.stderr).toContain("Target directory is not empty"); - expect(nonEmptyResult.stderr).toContain("Run `template --help` for usage."); - expect(nonEmptyResult.stderr).not.toContain("Usage:"); - expect(await readFile(path.join(nonEmptyDir, "README.md"), "utf8")).toBe( - "# existing\n", - ); - }); - - it("reviews the Project Blueprint and asks for confirmation in interactive terminals", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-interactive-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - const cliPath = path.join(repoRoot, "packages", "cli", "src", "cli.ts"); - - const result = await execa( - "bash", - [ - "-lc", - [ - "printf 'y\\n' |", - "script -qfec", - shellQuote( - `node --conditions=source ${shellQuote(cliPath)} init ${shellQuote(projectDir)} --preset ts-lib`, - ), - "/dev/null", - ].join(" "), - ], - { cwd: repoRoot }, - ); - - expect(result.stdout).toContain("Project Blueprint"); - expect(result.stdout).toMatch(/Preset:\s+ts-lib/); - expect(result.stdout).toContain("Generate this project? [y/N]"); - expect(result.stdout).toContain("Initialized project"); - expect(result.stdout).toMatch(/Preset:\s+ts-lib/); - expect(result.stdout).toContain(projectDir); - await stat(path.join(projectDir, "package.json")); - }); - - it("cancels interactive init without writing files when confirmation is declined", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-interactive-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - const cliPath = path.join(repoRoot, "packages", "cli", "src", "cli.ts"); - - await expectCommandFailure( - execa( - "bash", - [ - "-lc", - [ - "printf 'n\\n' |", - "script -qfec", - shellQuote( - `node --conditions=source ${shellQuote(cliPath)} init ${shellQuote(projectDir)} --preset ts-lib`, - ), - "/dev/null", - ].join(" "), - ], - { cwd: repoRoot }, - ), - { stdout: /Generate this project\? \[y\/N\][\s\S]*Init cancelled/ }, - ); - - await expect(stat(projectDir)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("writes a follow-up TODO.md after generation without installing dependencies", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-next-steps-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - - const result = await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - "--yes", - ], - { cwd: repoRoot }, - ); - - expect(result.stdout).toContain("Initialized project"); - expect(result.stdout).toMatch(/Preset:\s+ts-lib/); - expect(result.stdout).toContain(projectDir); - expect(result.stdout).toContain( - `Follow-up checklist written to ${path.join(projectDir, "TODO.md")}`, - ); - expect(result.stdout).not.toContain("Next Step Instructions:"); - expect(result.stdout).not.toContain("pnpm install"); - await expect( - readFile(path.join(projectDir, "TODO.md"), "utf8"), - ).resolves.toBe( - [ - "# TODO", - "", - "Generated follow-up tasks for this repository.", - "", - "### Next Steps", - "- [ ] Install dependencies", - " `pnpm install`", - "- [ ] Run Fix Command", - " `pnpm run fix`", - "- [ ] Run Root Check", - " `pnpm run check`", - "", - "### Optional Git Setup", - "- [ ] Initialize git", - " `git init`", - "- [ ] Stage files", - " `git add .`", - "- [ ] Create your first commit", - ' `git commit -m "Initial commit"`', - "", - "### Done ✓", - "", - ].join("\n"), - ); - await expect( - stat(path.join(projectDir, "pnpm-lock.yaml")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("prints next steps instead of writing TODO.md when disabled", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-next-steps-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - - const result = await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - "--yes", - "--no-todo", - ], - { cwd: repoRoot }, - ); - - expect(result.stdout).toContain("Initialized project"); - expect(result.stdout).toContain("Next Step Instructions:"); - expect(result.stdout).toContain(`cd ${projectDir}`); - expect(result.stdout).toContain("pnpm install"); - expect(result.stdout).toContain("pnpm run fix"); - expect(result.stdout).toContain("pnpm run check"); - expect(result.stdout).toContain("git init"); - expect(result.stdout).not.toContain("Follow-up checklist written to"); - await expect(stat(path.join(projectDir, "TODO.md"))).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("prints fix next steps after generation without executing the fix command", async () => { - const workspace = await mkdtemp( - path.join(tmpdir(), "template-next-step-fix-"), - ); - const projectDir = path.join(workspace, "demo-lib"); - const fakeBin = path.join(workspace, "bin"); - const commandLog = path.join(workspace, "commands.jsonl"); - - await mkdir(fakeBin); - await writeExecutable( - path.join(fakeBin, "pnpm"), - [ - "#!/usr/bin/env node", - 'import { appendFileSync } from "node:fs";', - "", - "appendFileSync(", - ` ${JSON.stringify(commandLog)},`, - " JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }) + '\\n'", - ");", - "", - ].join("\n"), - ); - - const result = await execa( - process.execPath, - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "ts-lib", - "--yes", - ], - { - cwd: repoRoot, - env: { - PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, - ...toolchainEnvWithPnpm("10.2.0"), - }, - }, - ); - - expect(result.stdout).toContain( - `Follow-up checklist written to ${path.join(projectDir, "TODO.md")}`, - ); - await expect( - readFile(path.join(projectDir, "TODO.md"), "utf8"), - ).resolves.toMatch(/Run Fix Command\s+`pnpm run fix`/); - await expect(readFile(commandLog, "utf8")).rejects.toMatchObject({ - code: "ENOENT", - }); - await expect( - stat(path.join(projectDir, "pnpm-lock.yaml")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - }); - - it("uses --scope for generated workspace package names", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-scope-")); - const projectDir = path.join(workspace, "demo-fullstack"); - - await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "vue-hono-app", - "--scope", - "custom-scope", - "--yes", - ], - { cwd: repoRoot }, - ); - - const blueprint = await readJson<{ - packages: Array<{ name: string; path: string }>; - }>(path.join(projectDir, ".template/blueprint.json")); - const apiPackageJson = await readJson<{ name: string }>( - path.join(projectDir, "apps/api/package.json"), - ); - const webPackageJson = await readJson<{ - name: string; - dependencies: Record; - }>(path.join(projectDir, "apps/web/package.json")); - const webApiClient = await readFile( - path.join(projectDir, "apps/web/src/api.ts"), - "utf8", - ); - const checkWorkflow = await readFile( - path.join(projectDir, ".github/workflows/check.yml"), - "utf8", - ); - - expect(apiPackageJson.name).toBe("@custom-scope/api"); - expect(webPackageJson.name).toBe("@custom-scope/web"); - expect(webPackageJson.dependencies).not.toHaveProperty("@custom-scope/api"); - expect(webApiClient).toContain("export async function getHealth"); - expect(webApiClient).not.toContain("@custom-scope/api"); - expect(webApiClient).not.toContain("hono/client"); - expect(checkWorkflow).toContain( - "pnpm --filter ./apps/web exec playwright install --with-deps chromium", - ); - expect(blueprint.packages).toEqual([ - { name: "@custom-scope/web", path: "apps/web" }, - { name: "@custom-scope/api", path: "apps/api" }, - ]); - }, 120_000); - - it("normalizes npm scopes that include a leading at sign", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-scope-")); - const projectDir = path.join(workspace, "demo-fullstack"); - - await execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "vue-hono-app", - "--scope", - "@custom-scope", - "--yes", - ], - { cwd: repoRoot }, - ); - - const apiPackageJson = await readJson<{ name: string }>( - path.join(projectDir, "apps/api/package.json"), - ); - const webPackageJson = await readJson<{ - name: string; - dependencies: Record; - }>(path.join(projectDir, "apps/web/package.json")); - const blueprint = await readJson<{ - packages: Array<{ name: string; path: string }>; - }>(path.join(projectDir, ".template/blueprint.json")); - - expect(apiPackageJson.name).toBe("@custom-scope/api"); - expect(webPackageJson.name).toBe("@custom-scope/web"); - expect(webPackageJson.dependencies).not.toHaveProperty("@custom-scope/api"); - expect(blueprint.packages).toEqual([ - { name: "@custom-scope/web", path: "apps/web" }, - { name: "@custom-scope/api", path: "apps/api" }, - ]); - }, 120_000); - - it("rejects npm scopes with whitespace before writing files", async () => { - const workspace = await mkdtemp(path.join(tmpdir(), "template-scope-")); - const projectDir = path.join(workspace, "demo-fullstack"); - - await expectCommandFailure( - execa( - "node", - [ - "--conditions=source", - path.join(repoRoot, "packages", "cli", "src", "cli.ts"), - "init", - projectDir, - "--preset", - "vue-hono-app", - "--scope", - "custom\nscope", - "--yes", - ], - { cwd: repoRoot }, - ), - { stderr: "--scope must be a valid npm scope without whitespace" }, - ); - - await expect(stat(projectDir)).rejects.toMatchObject({ code: "ENOENT" }); - }); -}); diff --git a/test/template-repository-maintenance.test.ts b/test/template-repository-maintenance.test.ts deleted file mode 100644 index 08bcaca..0000000 --- a/test/template-repository-maintenance.test.ts +++ /dev/null @@ -1,1032 +0,0 @@ -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { loadBuiltInPresetSourceManifest } from "@ykdz/template-builtin-source"; -import { - loadTemplateCargoDependencyVersions, - loadTemplateDependencyCatalog, -} from "@ykdz/template-core/dependency-catalog"; -import { resolveToolchainVersions } from "@ykdz/template-core/toolchain-resolution"; -import { execa } from "execa"; -import * as ts from "typescript"; -import * as v from "valibot"; -import { parse as parseYaml } from "yaml"; - -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); -const stringRecordSchema = v.record(v.string(), v.string()); -const packageMetadataSchema = v.object({ - bundleDependencies: v.optional(v.array(v.string())), - dependencies: v.optional(stringRecordSchema), - devDependencies: v.optional(stringRecordSchema), - name: v.optional(v.string()), - packageManager: v.optional(v.string()), - private: v.optional(v.boolean()), - version: v.optional(v.string()), -}); -const dependabotUpdateSchema = v.object({ - "package-ecosystem": v.string(), - directory: v.string(), - schedule: v.object({ interval: v.string() }), - groups: v.optional( - v.record( - v.string(), - v.object({ - patterns: v.array(v.string()), - }), - ), - ), - ignore: v.optional( - v.array( - v.object({ - "dependency-name": v.string(), - "update-types": v.array(v.string()), - }), - ), - ), -}); -const dependabotConfigSchema = v.object({ - version: v.optional(v.number()), - updates: v.array(dependabotUpdateSchema), -}); -const devcontainerSchema = v.object({ - name: v.optional(v.string()), - build: v.optional( - v.object({ - context: v.optional(v.string()), - dockerfile: v.optional(v.string()), - }), - ), - customizations: v.optional( - v.object({ - vscode: v.optional( - v.object({ - extensions: v.optional(v.array(v.string())), - settings: v.optional(v.record(v.string(), v.unknown())), - }), - ), - }), - ), - features: v.optional(v.unknown()), - postCreateCommand: v.optional(stringRecordSchema), -}); -const workspaceExtensionsSchema = v.object({ - recommendations: v.array(v.string()), -}); -const turboPackageConfigSchema = v.object({ - tags: v.array(v.string()), -}); - -function parseJsonWithSchema( - text: string, - schema: Schema, -): v.InferOutput { - return v.parse(schema, JSON.parse(text) as unknown); -} - -function parseYamlWithSchema( - text: string, - schema: Schema, -): v.InferOutput { - return v.parse(schema, parseYaml(text) as unknown); -} - -const packageDependencyFields = new Set([ - "dependencies", - "devDependencies", - "optionalDependencies", - "peerDependencies", -]); - -const staleGeneratedCatalogLinePattern = - /["']\s{2}(?:"@?[\w./-]+"|[\w.-]+): \^\d+\.\d+\.\d+/; -const fixtureOnlyPresetManifestFields = new Set([ - "fixtureMatrix", - "initSupport", - "supportedCombinations", - "semanticSkips", - "checkRequirements", - "environmentPreparation", - "linkFrom", -]); -const packageAdditionSupportField = "packageAdditionSupport"; -const duplicateAddabilityFieldPattern = - /^(?:base.*addability|base.*additionSupport|base.*packageAdditionSupport|.*baseAddability|.*baseAdditionSupport|addability)$/i; - -function dependencyVersionGateProjectionFiles(): string[] { - return loadBuiltInPresetSourceManifest() - .presets.filter((preset) => preset.generation === "supported") - .map( - (preset) => - `packages/builtin-source/templates/${preset.name}/projection.ts`, - ) - .toSorted(); -} - -function propertyNameText( - name: ts.PropertyName, - sourceFile: ts.SourceFile, -): string { - if ( - ts.isIdentifier(name) || - ts.isPrivateIdentifier(name) || - ts.isStringLiteral(name) || - ts.isNumericLiteral(name) - ) { - return name.text; - } - - return name.getText(sourceFile); -} - -function relativeRepoPath(filePath: string): string { - return path.relative(repoRoot, filePath).split(path.sep).join("/"); -} - -function objectKeyIssues( - value: unknown, - pathLabel: string, - isIssueKey: (key: string) => boolean, -): string[] { - if (Array.isArray(value)) { - return value.flatMap((item, index) => - objectKeyIssues(item, `${pathLabel}[${index}]`, isIssueKey), - ); - } - - if (!value || typeof value !== "object") { - return []; - } - - return Object.entries(value).flatMap(([key, nestedValue]) => [ - ...(isIssueKey(key) ? [`${pathLabel}.${key}`] : []), - ...objectKeyIssues(nestedValue, `${pathLabel}.${key}`, isIssueKey), - ]); -} - -function duplicateAddabilityFieldIssues( - sourceText: string, - filePath: string, -): string[] { - const sourceFile = ts.createSourceFile( - filePath, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const issues: string[] = []; - - function visit(node: ts.Node): void { - let name: ts.PropertyName | undefined; - - if ( - ts.isPropertyAssignment(node) || - ts.isPropertySignature(node) || - ts.isMethodSignature(node) - ) { - name = node.name; - } - - if (name) { - const fieldName = propertyNameText(name, sourceFile); - - if ( - fieldName !== packageAdditionSupportField && - duplicateAddabilityFieldPattern.test(fieldName) - ) { - issues.push( - `${filePath}:${lineForPosition( - sourceFile, - name.getStart(sourceFile), - )}: use packageAdditionSupport as the only addability concept`, - ); - } - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return issues; -} - -function stringLiteralText(node: ts.Expression): string | undefined { - if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { - return node.text; - } - - return undefined; -} - -function isDependencySemverRange(value: string): boolean { - return /^(?:[~^]|[<>=]=?)?\d+(?:\.\d+){0,2}(?:[-+][\w.-]+)?(?:\s|$|\|\|)/.test( - value, - ); -} - -function inlineDependencyVersionRanges(source: string): string[] { - const sourceFile = ts.createSourceFile( - "projection.ts", - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const dependencyVersions: string[] = []; - - function visit(node: ts.Node): void { - if ( - ts.isPropertyAssignment(node) && - packageDependencyFields.has(propertyNameText(node.name, sourceFile)) && - ts.isObjectLiteralExpression(node.initializer) - ) { - for (const property of node.initializer.properties) { - if (!ts.isPropertyAssignment(property)) { - continue; - } - - const versionRange = stringLiteralText(property.initializer); - if ( - versionRange === undefined || - !isDependencySemverRange(versionRange) - ) { - continue; - } - - const packageName = propertyNameText(property.name, sourceFile); - const position = sourceFile.getLineAndCharacterOfPosition( - property.initializer.getStart(sourceFile), - ); - - dependencyVersions.push( - `${packageName}: ${versionRange} at ${position.line + 1}:${position.character + 1}`, - ); - } - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return dependencyVersions; -} - -function expectNoStaleInlineDependencyVersions(source: string): void { - expect(source).not.toMatch(staleGeneratedCatalogLinePattern); - expect(inlineDependencyVersionRanges(source)).toEqual([]); -} - -async function filesUnder(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const entryPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - files.push(...(await filesUnder(entryPath))); - continue; - } - - files.push(entryPath); - } - - return files; -} - -function isPlaywrightServerTemplate(filePath: string): boolean { - const normalized = filePath.replaceAll(path.sep, "/"); - - return ( - normalized.endsWith("/playwright.config.ts") || - normalized.endsWith("/scripts/run-playwright.ts") - ); -} - -function expectNoStaticPlaywrightServerPorts( - source: string, - filePath: string, -): void { - const labelledSource = `${filePath}\n${source}`; - - expect(labelledSource).not.toMatch(/\bworkspacePortOffset\b/); - expect(labelledSource).not.toMatch(/\bfallback\w*Port\b/); - expect(labelledSource).not.toMatch(/\b(?:--port\s+|PORT=|:)(?:\d[\d_]*)/); -} - -const reviewedRootPresetBehaviorFiles = [ - "test/template-init.test.ts", - "test/editor-customization.test.ts", - "test/preset-registry.test.ts", -] as const; - -function lineForPosition(sourceFile: ts.SourceFile, position: number): number { - return sourceFile.getLineAndCharacterOfPosition(position).line + 1; -} - -function presetLiteralFromExpression( - expression: ts.Expression, - supportedPresetNames: ReadonlySet, -): string | undefined { - return ts.isStringLiteralLike(expression) && - supportedPresetNames.has(expression.text) - ? expression.text - : undefined; -} - -function nodeContainsPresetEquality( - node: ts.Node, - supportedPresetNames: ReadonlySet, -): boolean { - let contains = false; - - function visit(current: ts.Node): void { - if (contains) { - return; - } - - if ( - ts.isBinaryExpression(current) && - (current.operatorToken.kind === - ts.SyntaxKind.ExclamationEqualsEqualsToken || - current.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken) && - (presetLiteralFromExpression(current.left, supportedPresetNames) !== - undefined || - presetLiteralFromExpression(current.right, supportedPresetNames) !== - undefined) - ) { - contains = true; - return; - } - - ts.forEachChild(current, visit); - } - - visit(node); - return contains; -} - -function objectLiteralHasProperty( - objectLiteral: ts.ObjectLiteralExpression, - propertyName: string, - sourceFile: ts.SourceFile, -): boolean { - return objectLiteral.properties.some((property) => { - if ( - ts.isPropertyAssignment(property) || - ts.isShorthandPropertyAssignment(property) || - ts.isMethodDeclaration(property) - ) { - return propertyNameText(property.name, sourceFile) === propertyName; - } - - return false; - }); -} - -function nodeContainsPresetLiteral( - node: ts.Node, - supportedPresetNames: ReadonlySet, -): boolean { - let contains = false; - - function visit(current: ts.Node): void { - if (contains) { - return; - } - - if ( - (ts.isStringLiteral(current) || - ts.isNoSubstitutionTemplateLiteral(current)) && - supportedPresetNames.has(current.text) - ) { - contains = true; - return; - } - - ts.forEachChild(current, visit); - } - - visit(node); - return contains; -} - -function rootPresetBehaviorSelectorIssues( - sourceText: string, - filePath: string, - supportedPresetNames: ReadonlySet, -): string[] { - const sourceFile = ts.createSourceFile( - filePath, - sourceText, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const issues: string[] = []; - - function visit(node: ts.Node): void { - if ( - ts.isArrayLiteralExpression(node) && - node.elements.length > 1 && - node.elements.every( - (element) => - ts.isStringLiteralLike(element) && - supportedPresetNames.has(element.text), - ) - ) { - issues.push( - `${filePath}:${lineForPosition( - sourceFile, - node.getStart(sourceFile), - )}: derive preset test matrices from manifest capability facts`, - ); - } - - if ( - ts.isArrayLiteralExpression(node) && - node.elements.length > 1 && - node.elements.every(ts.isObjectLiteralExpression) && - node.elements.every((element) => - ts.isObjectLiteralExpression(element) - ? nodeContainsPresetLiteral(element, supportedPresetNames) - : false, - ) && - node.elements.some((element) => - ts.isObjectLiteralExpression(element) - ? objectLiteralHasProperty( - element, - packageAdditionSupportField, - sourceFile, - ) - : false, - ) - ) { - issues.push( - `${filePath}:${lineForPosition( - sourceFile, - node.getStart(sourceFile), - )}: derive preset object matrices from manifest capability facts`, - ); - } - - if ( - ts.isCallExpression(node) && - ts.isPropertyAccessExpression(node.expression) && - node.expression.name.text === "filter" && - node.arguments.some((argument) => - nodeContainsPresetEquality(argument, supportedPresetNames), - ) - ) { - issues.push( - `${filePath}:${lineForPosition( - sourceFile, - node.getStart(sourceFile), - )}: derive preset exclusions from manifest capability facts`, - ); - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return issues; -} - -describe("template Repository maintenance", () => { - it("keeps internal Package Boundary edges on the workspace graph", async () => { - const expectedEdges = { - ".": [ - "@ykdz/template-builtin-source", - "@ykdz/template-checks", - "@ykdz/template-core", - "@ykdz/template-shared", - ], - "packages/builtin-source": [ - "@ykdz/template-core", - "@ykdz/template-shared", - ], - "packages/checks": [ - "@ykdz/template-builtin-source", - "@ykdz/template-core", - "@ykdz/template-shared", - ], - "packages/cli": [ - "@ykdz/template-builtin-source", - "@ykdz/template-core", - "@ykdz/template-shared", - ], - "packages/core": ["@ykdz/template-shared"], - } as const; - - for (const [packagePath, dependencies] of Object.entries(expectedEdges)) { - const packageJson = parseJsonWithSchema( - await readFile( - path.join(repoRoot, packagePath, "package.json"), - "utf8", - ), - packageMetadataSchema, - ); - const declaredDependencies = { - ...packageJson.dependencies, - ...packageJson.devDependencies, - }; - - for (const dependency of dependencies) { - expect(declaredDependencies[dependency]).toBe("workspace:*"); - } - } - }); - - it("keeps every internal Package Boundary attached to its Turbo tag", async () => { - const expectedTags = { - "packages/builtin-source": "template-preset-source", - "packages/checks": "template-checks", - "packages/cli": "template-cli", - "packages/core": "template-core", - "packages/shared": "template-shared", - } as const; - - for (const [packagePath, expectedTag] of Object.entries(expectedTags)) { - const turboConfig = parseJsonWithSchema( - await readFile(path.join(repoRoot, packagePath, "turbo.json"), "utf8"), - turboPackageConfigSchema, - ); - - expect(turboConfig.tags).toEqual([expectedTag]); - } - }); - - it("keeps the root and bundled fallback on the same explicit pnpm 11 workspace policy", async () => { - const packageJson = parseJsonWithSchema( - await readFile(path.join(repoRoot, "package.json"), "utf8"), - packageMetadataSchema, - ); - const workspace = parseYaml( - await readFile(path.join(repoRoot, "pnpm-workspace.yaml"), "utf8"), - ) as Record; - const fallback = await resolveToolchainVersions({ - source: "bundled-fallback", - }); - - expect(packageJson.packageManager).toBe("pnpm@11.11.0"); - expect(fallback.packageManagerPin.value).toBe(packageJson.packageManager); - expect(workspace).toMatchObject({ - nodeLinker: "isolated", - injectWorkspacePackages: true, - dedupeInjectedDeps: false, - syncInjectedDepsAfterScripts: ["build", "build:run"], - minimumReleaseAge: 1440, - minimumReleaseAgeStrict: true, - }); - }); - it("discovers Preset Source Tests named behavior.test.ts by convention", async () => { - const result = await execa( - "pnpm", - [ - "exec", - "vitest", - "list", - "--config", - "vitest.config.ts", - "--testNamePattern", - "vue-app Preset Source behavior", - "--no-color", - ], - { cwd: repoRoot }, - ); - - expect(result.stdout).toContain( - "packages/builtin-source/templates/vue-app/behavior.test.ts > vue-app Preset Source behavior", - ); - }); - - it("keeps reviewed root preset behavior selectors derived from manifest facts", async () => { - const supportedPresetNames = new Set( - loadBuiltInPresetSourceManifest() - .presets.filter((preset) => preset.generation === "supported") - .map((preset) => preset.name), - ); - const issues = ( - await Promise.all( - reviewedRootPresetBehaviorFiles.map(async (filePath) => - rootPresetBehaviorSelectorIssues( - await readFile(path.join(repoRoot, filePath), "utf8"), - filePath, - supportedPresetNames, - ), - ), - ) - ).flat(); - - expect(issues).toEqual([]); - }); - - it("detects root preset object matrices that duplicate manifest capability facts", () => { - expect( - rootPresetBehaviorSelectorIssues( - ` - expect(presets).toEqual([ - { name: "ts-lib", packageAdditionSupport: "supported" }, - { name: "vue-app", packageAdditionSupport: "supported" }, - ]); - `, - "test/example.test.ts", - new Set(["ts-lib", "vue-app"]), - ), - ).toEqual([ - "test/example.test.ts:2: derive preset object matrices from manifest capability facts", - ]); - }); - - it("keeps fixture-only manifest fields out of the built-in Preset Source Manifest", () => { - const issues = objectKeyIssues( - loadBuiltInPresetSourceManifest(), - "$", - (key) => fixtureOnlyPresetManifestFields.has(key), - ); - - expect(issues).toEqual([]); - }); - - it("keeps Package Addition Support as the only Preset addability field", async () => { - const manifestIssues = objectKeyIssues( - loadBuiltInPresetSourceManifest(), - "$", - (key) => - key !== packageAdditionSupportField && - duplicateAddabilityFieldPattern.test(key), - ); - const packageSourceFiles = ( - await filesUnder(path.join(repoRoot, "packages")) - ) - .filter((file) => file.endsWith(".ts")) - .filter((file) => !file.endsWith(".test.ts")) - .filter((file) => !file.endsWith("/behavior.test.ts")); - const sourceIssues = ( - await Promise.all( - packageSourceFiles.map(async (file) => - duplicateAddabilityFieldIssues( - await readFile(file, "utf8"), - relativeRepoPath(file), - ), - ), - ) - ).flat(); - - expect([...manifestIssues, ...sourceIssues]).toEqual([]); - }); - - it("detects duplicate base-addability fields in production source", () => { - expect( - duplicateAddabilityFieldIssues( - "export type Preset = { baseAddability: boolean; packageAdditionSupport: string };", - "packages/example/src/preset.ts", - ), - ).toEqual([ - "packages/example/src/preset.ts:1: use packageAdditionSupport as the only addability concept", - ]); - }); - - it("detects package metadata dependency ranges in Preset Projection source", () => { - expect(() => - expectNoStaleInlineDependencyVersions(` - const operation = { - kind: "writeJson", - to: "package.json", - value: { - name: context.projectName.value, - version: "0.0.0", - dependencies: { typescript: "^5.8.0" }, - engines: { node: "24" }, - }, - }; - `), - ).toThrow(); - }); - - it("allows non-dependency versions in Preset Projection source", () => { - expect(() => - expectNoStaleInlineDependencyVersions(` - const operation = { - kind: "writeJson", - to: "package.json", - value: { - name: context.projectName.value, - version: "0.0.0", - engines: { node: "24" }, - scripts: { dev: "vite --host 0.0.0.0 --port 5173" }, - server: { port: 4173 }, - }, - }; - `), - ).not.toThrow(); - }); - - it("covers every supported Preset Projection in the dependency version gate", () => { - expect(dependencyVersionGateProjectionFiles()).toEqual( - expect.arrayContaining([ - "packages/builtin-source/templates/rust-bin/projection.ts", - ]), - ); - }); - - it("keeps dependency version ranges out of Preset Projection source", async () => { - for (const projectionFile of dependencyVersionGateProjectionFiles()) { - const source = await readFile( - path.join(repoRoot, projectionFile), - "utf8", - ); - - expectNoStaleInlineDependencyVersions(source); - } - }); - - it("keeps Playwright server ports runtime-allocated in app templates", async () => { - const templateRoot = path.join( - repoRoot, - "packages/builtin-source/templates", - ); - const playwrightServerTemplates = (await filesUnder(templateRoot)) - .filter(isPlaywrightServerTemplate) - .toSorted(); - - expect(playwrightServerTemplates).not.toEqual([]); - for (const filePath of playwrightServerTemplates) { - const source = await readFile(filePath, "utf8"); - expectNoStaticPlaywrightServerPorts(source, filePath); - } - }); - - it("keeps Preset Source Dependency Catalog references backed by maintained versions", () => { - const templateCatalog = loadTemplateDependencyCatalog(); - const manifest = loadBuiltInPresetSourceManifest(); - const presetReferences = Object.fromEntries( - manifest.presets - .filter((preset) => preset.generation === "supported") - .map((preset) => [preset.name, preset.dependencyCatalog ?? []]), - ); - - expect(presetReferences).not.toEqual({}); - for (const [presetName, dependencies] of Object.entries(presetReferences)) { - expect(dependencies, `${presetName} declares catalog refs`).not.toEqual( - [], - ); - for (const dependency of dependencies) { - expect( - templateCatalog[dependency], - `${presetName} ${dependency}`, - ).toMatch(/^(?:npm:(?:@[^/]+\/)?[^@]+@)?\^?\d/); - } - } - }); - - it("keeps root package metadata private and catalog-backed", async () => { - const packageJson = parseJsonWithSchema( - await readFile(path.join(repoRoot, "package.json"), "utf8"), - packageMetadataSchema, - ); - - expect(packageJson.private).toBe(true); - expect([ - ...Object.values(packageJson.dependencies ?? {}), - ...Object.values(packageJson.devDependencies ?? {}), - ]).toContain("catalog:"); - }); - - it("keeps private workspace package versions out of the release train", async () => { - const packageDirs = await readdir(path.join(repoRoot, "packages"), { - withFileTypes: true, - }); - - for (const packageDir of packageDirs) { - if (!packageDir.isDirectory()) { - continue; - } - - const packageJson = parseJsonWithSchema( - await readFile( - path.join(repoRoot, "packages", packageDir.name, "package.json"), - "utf8", - ), - packageMetadataSchema, - ); - - if (packageJson.private === true) { - expect(packageJson.version).toBe( - packageJson.version === undefined ? undefined : "0.0.0", - ); - } - } - }); - - it("keeps bundled private workspace packages on sentinel versions for pnpm pack", async () => { - const cliPackageJson = parseJsonWithSchema( - await readFile(path.join(repoRoot, "packages/cli/package.json"), "utf8"), - packageMetadataSchema, - ); - const packageDirs = await readdir(path.join(repoRoot, "packages"), { - withFileTypes: true, - }); - const workspacePackages = new Map< - string, - v.InferOutput - >(); - - for (const packageDir of packageDirs) { - if (!packageDir.isDirectory()) { - continue; - } - - const packageJson = parseJsonWithSchema( - await readFile( - path.join(repoRoot, "packages", packageDir.name, "package.json"), - "utf8", - ), - packageMetadataSchema, - ); - - if (packageJson.name !== undefined) { - workspacePackages.set(packageJson.name, packageJson); - } - } - - expect(cliPackageJson.bundleDependencies).toEqual( - expect.arrayContaining([ - "@ykdz/template-builtin-source", - "@ykdz/template-core", - ]), - ); - - for (const dependency of cliPackageJson.bundleDependencies ?? []) { - const packageJson = workspacePackages.get(dependency); - - expect(packageJson?.private).toBe(true); - expect(packageJson?.version).toBe("0.0.0"); - } - }); - - it("maintains the template Repository's real GitHub Actions workflows through Dependabot", async () => { - const workflowFiles = await readdir( - path.join(repoRoot, ".github/workflows"), - ); - const dependabot = parseYamlWithSchema( - await readFile(path.join(repoRoot, ".github/dependabot.yml"), "utf8"), - dependabotConfigSchema, - ); - - expect(workflowFiles).toEqual( - expect.arrayContaining([ - "check.yml", - "release.yml", - "toolchain-resolution-contract.yml", - ]), - ); - expect(dependabot.updates).toContainEqual({ - "package-ecosystem": "github-actions", - directory: "/", - schedule: { interval: "weekly" }, - }); - }); - - it("keeps Local Template Metadata and local pnpm store paths ignored", async () => { - const gitignore = await readFile(path.join(repoRoot, ".gitignore"), "utf8"); - - expect(gitignore).toContain(".template/\n"); - expect(gitignore).toContain(".project-kit/\n"); - expect(gitignore).toContain(".fixture-replay-cache/\n"); - expect(gitignore).toContain(".pnpm-store/\n"); - expect(gitignore).not.toContain(".devcontainer/\n"); - }); - - it("keeps the root Development Container Dockerfile-first with intentional editor customizations", async () => { - const devcontainer = parseJsonWithSchema( - await readFile( - path.join(repoRoot, ".devcontainer/devcontainer.json"), - "utf8", - ), - devcontainerSchema, - ); - const workspaceExtensions = parseJsonWithSchema( - await readFile(path.join(repoRoot, ".vscode/extensions.json"), "utf8"), - workspaceExtensionsSchema, - ); - const dockerfile = await readFile( - path.join(repoRoot, ".devcontainer/Dockerfile"), - "utf8", - ); - const expectedExtensions = [ - "rust-lang.rust-analyzer", - "tamasfe.even-better-toml", - "fill-labs.dependi", - "oxc.oxc-vscode", - "vitest.explorer", - ]; - - expect(Object.keys(devcontainer).slice(0, 3)).toEqual([ - "name", - "build", - "customizations", - ]); - expect(devcontainer.build?.dockerfile).toBe("Dockerfile"); - expect(devcontainer.build?.context).toBe(".."); - expect(devcontainer).not.toHaveProperty("features"); - expect(devcontainer.customizations?.vscode?.extensions).toEqual( - expectedExtensions, - ); - expect(workspaceExtensions.recommendations).toEqual(expectedExtensions); - expect(devcontainer.customizations?.vscode?.settings).toMatchObject({ - "editor.formatOnSave": true, - "rust-analyzer.check.command": "clippy", - }); - expect(dockerfile).toContain( - "COPY package.json /tmp/template/package.json", - ); - expect(dockerfile).toContain('ENV COREPACK_HOME="/corepack"'); - expect(dockerfile).toContain( - 'corepack enable --install-directory "$PNPM_HOME"', - ); - expect(dockerfile).not.toContain("corepack prepare pnpm --activate"); - - await expect( - readFile(path.join(repoRoot, "package.json"), "utf8"), - ).resolves.toContain('"packageManager"'); - expect(devcontainer.postCreateCommand).toMatchObject({ - installNodeDependencies: "pnpm install", - }); - - await expect( - readFile(path.join(repoRoot, "Cargo.toml"), "utf8"), - ).resolves.toContain("[dependencies]"); - expect(loadTemplateCargoDependencyVersions()).toHaveProperty("anyhow"); - expect(Object.values(devcontainer.postCreateCommand ?? {})).not.toContain( - "cargo fetch", - ); - }); - - it("uses official root Dependabot config for npm, Cargo, GitHub Actions, and the Development Container Dockerfile", async () => { - const dependabot = parseYamlWithSchema( - await readFile(path.join(repoRoot, ".github/dependabot.yml"), "utf8"), - dependabotConfigSchema, - ); - - expect(dependabot).toEqual({ - version: 2, - updates: [ - { - "package-ecosystem": "npm", - directory: "/", - schedule: { interval: "weekly" }, - groups: { - drizzle: { - patterns: ["drizzle-*", "drizzle-orm"], - }, - }, - ignore: [ - { - "dependency-name": "@types/node", - "update-types": ["version-update:semver-major"], - }, - ], - }, - { - "package-ecosystem": "github-actions", - directory: "/", - schedule: { interval: "weekly" }, - }, - { - "package-ecosystem": "cargo", - directory: "/", - schedule: { interval: "weekly" }, - }, - { - "package-ecosystem": "docker", - directory: "/.devcontainer", - schedule: { interval: "weekly" }, - ignore: [ - { - "dependency-name": "node", - "update-types": ["version-update:semver-major"], - }, - ], - }, - ], - }); - }); - - it("keeps the root pnpm pin on the maintained pnpm 11 baseline", async () => { - const packageJson = parseJsonWithSchema( - await readFile(path.join(repoRoot, "package.json"), "utf8"), - packageMetadataSchema, - ); - const packageManager = packageJson.packageManager ?? ""; - const match = /^pnpm@(\d+)\.\d+\.\d+$/.exec(packageManager); - - expect(match).not.toBeNull(); - expect(Number(match?.[1])).toBe(11); - }); -}); diff --git a/test/turbo-boundaries.test.ts b/test/turbo-boundaries.test.ts new file mode 100644 index 0000000..052b748 --- /dev/null +++ b/test/turbo-boundaries.test.ts @@ -0,0 +1,168 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +type TurboBoundaries = { + readonly boundaries: { + readonly tags: Record< + string, + { readonly dependencies: { readonly allow: readonly string[] } } + >; + }; +}; + +const packages = { + shared: { + directory: "packages/shared", + name: "@ykdz/template-shared", + mayImport: [] as const, + }, + core: { + directory: "packages/core", + name: "@ykdz/template-core", + mayImport: [] as const, + }, + "builtin-presets": { + directory: "packages/builtin-presets", + name: "@ykdz/template-builtin-presets", + mayImport: ["core"] as const, + }, + cli: { + directory: "packages/cli", + name: "@ykdz/template", + mayImport: ["core", "builtin-presets"] as const, + }, + checks: { + directory: "packages/checks", + name: "@ykdz/template-checks", + mayImport: ["core", "builtin-presets"] as const, + }, +} as const; + +type PackageKey = keyof typeof packages; + +const packageByName = new Map( + Object.entries(packages).map(([key, value]) => [ + value.name, + key as PackageKey, + ]), +); + +async function typeScriptFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + return ( + await Promise.all( + entries.map(async (entry) => { + const child = path.join(directory, entry.name); + if (entry.isDirectory()) return typeScriptFiles(child); + return entry.isFile() && /\.[cm]?[jt]sx?$/u.test(entry.name) + ? [child] + : []; + }), + ) + ).flat(); +} + +function importedPackage( + sourceFile: ts.SourceFile, + moduleSpecifier: string, +): PackageKey | undefined { + const packageImport = [...packageByName.entries()].find( + ([name]) => + moduleSpecifier === name || moduleSpecifier.startsWith(`${name}/`), + ); + if (packageImport !== undefined) return packageImport[1]; + if (!moduleSpecifier.startsWith(".")) return undefined; + const target = path.resolve( + path.dirname(sourceFile.fileName), + moduleSpecifier, + ); + return ( + Object.entries(packages) as [PackageKey, (typeof packages)[PackageKey]][] + ).find(([, value]) => + target.startsWith(`${path.resolve(value.directory)}${path.sep}`), + )?.[0]; +} + +describe("Template Repository dependency DAG", () => { + it("keeps package manifests and source imports on the one-way architecture", async () => { + for (const [key, definition] of Object.entries(packages) as [ + PackageKey, + (typeof packages)[PackageKey], + ][]) { + const allowed = new Set([key, ...definition.mayImport]); + const manifest = JSON.parse( + await readFile(path.join(definition.directory, "package.json"), "utf8"), + ) as Record | undefined>; + for (const field of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ]) { + for (const dependency of Object.keys(manifest[field] ?? {})) { + const target = packageByName.get(dependency); + if (target !== undefined) + expect(allowed, `${key} ${field}`).toContain(target); + } + } + + for (const file of await typeScriptFiles( + path.join(definition.directory, "src"), + )) { + const sourceFile = ts.createSourceFile( + path.resolve(file), + await readFile(file, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const inspect = (node: ts.Node): void => { + const specifier = + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier !== undefined && + ts.isStringLiteral(node.moduleSpecifier) + ? node.moduleSpecifier.text + : ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]!) + ? node.arguments[0]!.text + : undefined; + if (specifier !== undefined) { + const target = importedPackage(sourceFile, specifier); + if (target !== undefined) { + expect( + allowed, + `${key} imports ${specifier} from ${file}`, + ).toContain(target); + } + } + ts.forEachChild(node, inspect); + }; + inspect(sourceFile); + } + } + }); + + it("keeps Turbo's target-aware compatibility rules no broader than needed", async () => { + const turbo = JSON.parse( + await readFile("turbo.json", "utf8"), + ) as TurboBoundaries; + const tags = turbo.boundaries.tags; + for (const tag of [ + "template-shared", + "template-core", + "template-builtin-presets", + ]) { + expect(tags[tag]?.dependencies.allow).not.toContain("template-cli"); + } + expect(tags["template-checks"]?.dependencies.allow).not.toContain( + "template-cli", + ); + for (const [tag, rule] of Object.entries(tags)) { + expect(rule.dependencies.allow).toContain(tag); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 8a4040e..966a1c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,10 +8,7 @@ "oxfmt.config.ts", "oxlint.config.ts", "packages/*/src/**/*.ts", - "packages/builtin-source/templates/*/behavior.test.ts", - "packages/builtin-source/templates/*/projection.ts", - "packages/builtin-source/templates/projection-plans.ts", - "packages/builtin-source/templates/registry.ts", + "packages/builtin-presets/src/*/behavior.test.ts", "test/**/*.ts", "vitest.config.ts" ] diff --git a/turbo.json b/turbo.json index 4d7e28d..ca7c705 100644 --- a/turbo.json +++ b/turbo.json @@ -5,50 +5,63 @@ "template-checks": { "dependencies": { "allow": [ + "template-checks", + "template-shared", "template-core", - "template-preset-source", - "template-shared" + "template-builtin-presets" ] } }, - "template-cli": { + "template-builtin-presets": { "dependencies": { "allow": [ - "template-checks", + "template-builtin-presets", "template-core", - "template-preset-source", - "template-shared" + "template-shared", + "template-checks" ] } }, - "template-core": { + "template-cli": { "dependencies": { "allow": [ - "template-checks", "template-cli", - "template-preset-source", - "template-shared" + "template-builtin-presets", + "template-core", + "template-shared", + "template-checks" ] } }, - "template-preset-source": { + "template-core": { "dependencies": { "allow": [ - "template-checks", - "template-cli", "template-core", - "template-preset-source", - "template-shared" + "template-shared", + "template-builtin-presets", + "template-checks" ] } }, "template-shared": { "dependencies": { "allow": [ + "template-shared", + "template-core", + "template-builtin-presets", + "template-checks" + ] + } + }, + "template-root": { + "dependencies": { + "allow": [ + "template-root", "template-checks", "template-cli", + "template-builtin-presets", "template-core", - "template-preset-source" + "template-shared" ] } } @@ -79,6 +92,10 @@ "TEMPLATE_FIXTURE_REPLAY_CACHE_WRITE" ] }, + "check:deployment": { + "dependsOn": ["transit"], + "outputs": [] + }, "check:templates": { "outputs": [], "passThroughEnv": ["CARGO_HOME", "RUSTUP_HOME", "RUSTUP_TOOLCHAIN"] diff --git a/vitest.config.ts b/vitest.config.ts index 8c727e0..f711f79 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,17 +5,17 @@ process.env.TEMPLATE_REPOSITORY_ROOT ??= process.cwd(); export default defineConfig({ ssr: { noExternal: [ - "@ykdz/template-builtin-source", + "@ykdz/template-builtin-presets", "@ykdz/template-core", "@ykdz/template-shared", - /^@ykdz\/template-builtin-source\//, + /^@ykdz\/template-builtin-presets\//, /^@ykdz\/template-core\//, ], }, test: { include: [ "test/**/*.test.ts", - "packages/builtin-source/templates/*/behavior.test.ts", + "packages/builtin-presets/src/*/behavior.test.ts", ], exclude: [ "**/node_modules/**",