biucingcli 0.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- biucingcli/__init__.py +5 -0
- biucingcli/cli.py +849 -0
- biucingcli/template_data/android/template/.editorconfig +9 -0
- biucingcli/template_data/android/template/.gitignore +26 -0
- biucingcli/template_data/android/template/.mise.toml +3 -0
- biucingcli/template_data/android/template/Brewfile +6 -0
- biucingcli/template_data/android/template/Makefile +103 -0
- biucingcli/template_data/android/template/README.md +163 -0
- biucingcli/template_data/android/template/app/build.gradle.kts +178 -0
- biucingcli/template_data/android/template/app/proguard-rules.pro +1 -0
- biucingcli/template_data/android/template/app/src/androidTest/java/app/HomeRouteUiTest.kt +21 -0
- biucingcli/template_data/android/template/app/src/main/AndroidManifest.xml +18 -0
- biucingcli/template_data/android/template/app/src/main/java/app/MainActivity.kt +36 -0
- biucingcli/template_data/android/template/app/src/main/res/values/strings.xml +4 -0
- biucingcli/template_data/android/template/app/src/main/res/values/themes.xml +8 -0
- biucingcli/template_data/android/template/app/src/test/java/app/AppSmokeTest.kt +19 -0
- biucingcli/template_data/android/template/build.gradle.kts +27 -0
- biucingcli/template_data/android/template/core/designsystem/build.gradle.kts +33 -0
- biucingcli/template_data/android/template/core/designsystem/src/main/java/designsystem/Components.kt +75 -0
- biucingcli/template_data/android/template/core/designsystem/src/main/java/designsystem/DesignTokens.kt +23 -0
- biucingcli/template_data/android/template/core/designsystem/src/main/java/designsystem/Theme.kt +110 -0
- biucingcli/template_data/android/template/core/model/build.gradle.kts +26 -0
- biucingcli/template_data/android/template/core/model/src/main/java/model/Greeting.kt +6 -0
- biucingcli/template_data/android/template/core/model/src/test/java/model/GreetingTest.kt +16 -0
- biucingcli/template_data/android/template/core/network/build.gradle.kts +26 -0
- biucingcli/template_data/android/template/core/network/src/main/java/network/AppEnvironment.kt +17 -0
- biucingcli/template_data/android/template/core/testing/build.gradle.kts +27 -0
- biucingcli/template_data/android/template/core/testing/src/main/java/testing/FakeAppEnvironmentProvider.kt +13 -0
- biucingcli/template_data/android/template/docs/release-delivery.md +68 -0
- biucingcli/template_data/android/template/docs/release-signing.properties.example +20 -0
- biucingcli/template_data/android/template/fastlane/.env.example +22 -0
- biucingcli/template_data/android/template/fastlane/Appfile +1 -0
- biucingcli/template_data/android/template/fastlane/Fastfile +121 -0
- biucingcli/template_data/android/template/feature/home/build.gradle.kts +35 -0
- biucingcli/template_data/android/template/feature/home/src/main/java/home/HomeRoute.kt +38 -0
- biucingcli/template_data/android/template/feature/settings/build.gradle.kts +34 -0
- biucingcli/template_data/android/template/feature/settings/src/main/java/settings/SettingsRoute.kt +35 -0
- biucingcli/template_data/android/template/gradle/libs.versions.toml +35 -0
- biucingcli/template_data/android/template/gradle/verification-metadata.xml +3462 -0
- biucingcli/template_data/android/template/gradle/wrapper/gradle-wrapper.jar +0 -0
- biucingcli/template_data/android/template/gradle/wrapper/gradle-wrapper.jar.sha256 +1 -0
- biucingcli/template_data/android/template/gradle/wrapper/gradle-wrapper.properties +10 -0
- biucingcli/template_data/android/template/gradle.properties +4 -0
- biucingcli/template_data/android/template/gradlew +252 -0
- biucingcli/template_data/android/template/gradlew.bat +94 -0
- biucingcli/template_data/android/template/scripts/artifact-info +11 -0
- biucingcli/template_data/android/template/scripts/bootstrap +17 -0
- biucingcli/template_data/android/template/scripts/doctor +133 -0
- biucingcli/template_data/android/template/scripts/setup-android-sdk +23 -0
- biucingcli/template_data/android/template/scripts/sync-gradle-wrapper +19 -0
- biucingcli/template_data/android/template/scripts/verify-gradle-supply-chain +61 -0
- biucingcli/template_data/android/template/scripts/verify-release-artifact +104 -0
- biucingcli/template_data/android/template/settings.gradle.kts +25 -0
- biucingcli/template_data/android/template.json +146 -0
- biucingcli/template_data/apple/template/.gitignore +28 -0
- biucingcli/template_data/apple/template/.mise.toml +4 -0
- biucingcli/template_data/apple/template/.swiftformat +10 -0
- biucingcli/template_data/apple/template/.swiftlint.yml +33 -0
- biucingcli/template_data/apple/template/App/Config/ProjectDescriptionHelpers/Module.swift +6 -0
- biucingcli/template_data/apple/template/App/Config/XCConfig/Base.xcconfig +4 -0
- biucingcli/template_data/apple/template/App/Project.swift +66 -0
- biucingcli/template_data/apple/template/App/Targets/App/Resources/.gitkeep +1 -0
- biucingcli/template_data/apple/template/App/Targets/App/Sources/AppEntry.swift +9 -0
- biucingcli/template_data/apple/template/App/Targets/App/Sources/HomeView.swift +18 -0
- biucingcli/template_data/apple/template/App/Targets/App/Sources/HomeViewModel.swift +38 -0
- biucingcli/template_data/apple/template/App/Targets/AppTests/Sources/AppTests.swift +59 -0
- biucingcli/template_data/apple/template/Brewfile +8 -0
- biucingcli/template_data/apple/template/Makefile +128 -0
- biucingcli/template_data/apple/template/Packages/AppServices/Package.swift +20 -0
- biucingcli/template_data/apple/template/Packages/AppServices/Sources/AppServices/StarterMetadata.swift +52 -0
- biucingcli/template_data/apple/template/Packages/DesignSystem/Package.swift +20 -0
- biucingcli/template_data/apple/template/Packages/DesignSystem/Sources/DesignSystem/Theme.swift +8 -0
- biucingcli/template_data/apple/template/README.md +133 -0
- biucingcli/template_data/apple/template/Tuist.swift +8 -0
- biucingcli/template_data/apple/template/Workspace.swift +11 -0
- biucingcli/template_data/apple/template/docs/release-delivery.md +55 -0
- biucingcli/template_data/apple/template/fastlane/.env.example +20 -0
- biucingcli/template_data/apple/template/fastlane/Appfile +3 -0
- biucingcli/template_data/apple/template/fastlane/Fastfile +126 -0
- biucingcli/template_data/apple/template/fastlane/Matchfile +7 -0
- biucingcli/template_data/apple/template/scripts/bootstrap +9 -0
- biucingcli/template_data/apple/template/scripts/doctor +163 -0
- biucingcli/template_data/apple/template/scripts/setup-xcode +6 -0
- biucingcli/template_data/apple/template/scripts/test-destination +42 -0
- biucingcli/template_data/apple/template/scripts/verify-release-identity +88 -0
- biucingcli/template_data/apple/template.json +110 -0
- biucingcli/template_data/frontend/template/.dockerignore +8 -0
- biucingcli/template_data/frontend/template/.env.example +1 -0
- biucingcli/template_data/frontend/template/.gitignore +7 -0
- biucingcli/template_data/frontend/template/.npmrc +1 -0
- biucingcli/template_data/frontend/template/.prettierignore +2 -0
- biucingcli/template_data/frontend/template/.prettierrc.json +4 -0
- biucingcli/template_data/frontend/template/Dockerfile +25 -0
- biucingcli/template_data/frontend/template/Dockerfile.dev +35 -0
- biucingcli/template_data/frontend/template/Dockerfile.dev.full +40 -0
- biucingcli/template_data/frontend/template/Makefile +137 -0
- biucingcli/template_data/frontend/template/README.md +146 -0
- biucingcli/template_data/frontend/template/compose.dev.yaml +29 -0
- biucingcli/template_data/frontend/template/eslint.config.js +27 -0
- biucingcli/template_data/frontend/template/index.html +12 -0
- biucingcli/template_data/frontend/template/nginx.conf +11 -0
- biucingcli/template_data/frontend/template/package.json +50 -0
- biucingcli/template_data/frontend/template/playwright.production.config.ts +21 -0
- biucingcli/template_data/frontend/template/playwright.smoke.config.ts +19 -0
- biucingcli/template_data/frontend/template/pnpm-lock.yaml +4054 -0
- biucingcli/template_data/frontend/template/public/.gitkeep +1 -0
- biucingcli/template_data/frontend/template/scripts/browser-smoke-production +55 -0
- biucingcli/template_data/frontend/template/scripts/doctor +46 -0
- biucingcli/template_data/frontend/template/src/App.test.tsx +18 -0
- biucingcli/template_data/frontend/template/src/App.tsx +5 -0
- biucingcli/template_data/frontend/template/src/components/MetricCard.tsx +14 -0
- biucingcli/template_data/frontend/template/src/components/TaskPanel.tsx +32 -0
- biucingcli/template_data/frontend/template/src/config/env.ts +3 -0
- biucingcli/template_data/frontend/template/src/hooks/useProjectOverview.ts +24 -0
- biucingcli/template_data/frontend/template/src/index.css +208 -0
- biucingcli/template_data/frontend/template/src/main.tsx +11 -0
- biucingcli/template_data/frontend/template/src/pages/HomePage.tsx +60 -0
- biucingcli/template_data/frontend/template/src/router/AppRouter.tsx +14 -0
- biucingcli/template_data/frontend/template/src/services/api/client.ts +33 -0
- biucingcli/template_data/frontend/template/src/services/projectOverview.ts +39 -0
- biucingcli/template_data/frontend/template/src/test/setup.ts +1 -0
- biucingcli/template_data/frontend/template/src/types/projectOverview.ts +22 -0
- biucingcli/template_data/frontend/template/src/vite-env.d.ts +9 -0
- biucingcli/template_data/frontend/template/tests/browser-smoke.spec.ts +16 -0
- biucingcli/template_data/frontend/template/tests/production-browser-smoke.spec.ts +22 -0
- biucingcli/template_data/frontend/template/tsconfig.json +21 -0
- biucingcli/template_data/frontend/template/vite.config.ts +12 -0
- biucingcli/template_data/frontend/template.json +81 -0
- biucingcli/template_data/harmonyos/template/.gitignore +21 -0
- biucingcli/template_data/harmonyos/template/.mise.toml +2 -0
- biucingcli/template_data/harmonyos/template/AppScope/app.json5 +10 -0
- biucingcli/template_data/harmonyos/template/AppScope/resources/base/element/string.json +8 -0
- biucingcli/template_data/harmonyos/template/AppScope/resources/base/media/background.svg +3 -0
- biucingcli/template_data/harmonyos/template/AppScope/resources/base/media/foreground.svg +4 -0
- biucingcli/template_data/harmonyos/template/AppScope/resources/base/media/layered_image.json +6 -0
- biucingcli/template_data/harmonyos/template/Makefile +106 -0
- biucingcli/template_data/harmonyos/template/README.md +152 -0
- biucingcli/template_data/harmonyos/template/build-profile.json5 +36 -0
- biucingcli/template_data/harmonyos/template/code-linter.json5 +12 -0
- biucingcli/template_data/harmonyos/template/docs/release-signing.local.properties.example +14 -0
- biucingcli/template_data/harmonyos/template/entry/build-profile.json5 +9 -0
- biucingcli/template_data/harmonyos/template/entry/hvigorfile.ts +6 -0
- biucingcli/template_data/harmonyos/template/entry/oh-package.json5 +9 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/ets/core/config/AppConfig.ets +10 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/ets/core/designsystem/Tokens.ets +28 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/ets/entryability/EntryAbility.ets +29 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/ets/pages/Index.ets +47 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/ets/pages/Settings.ets +96 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/module.json5 +34 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/resources/base/element/color.json +24 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/resources/base/element/string.json +12 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/resources/base/media/app_icon.svg +5 -0
- biucingcli/template_data/harmonyos/template/entry/src/main/resources/base/profile/main_pages.json +6 -0
- biucingcli/template_data/harmonyos/template/entry/src/test/List.test.ets +11 -0
- biucingcli/template_data/harmonyos/template/hvigor/hvigor-config.json5 +8 -0
- biucingcli/template_data/harmonyos/template/hvigorfile.ts +6 -0
- biucingcli/template_data/harmonyos/template/oh-package-lock.json5 +19 -0
- biucingcli/template_data/harmonyos/template/oh-package.json5 +13 -0
- biucingcli/template_data/harmonyos/template/scripts/artifact-info +31 -0
- biucingcli/template_data/harmonyos/template/scripts/bootstrap +38 -0
- biucingcli/template_data/harmonyos/template/scripts/doctor +147 -0
- biucingcli/template_data/harmonyos/template/scripts/lint +105 -0
- biucingcli/template_data/harmonyos/template/scripts/release-build +199 -0
- biucingcli/template_data/harmonyos/template/scripts/release-preflight +109 -0
- biucingcli/template_data/harmonyos/template/scripts/test +28 -0
- biucingcli/template_data/harmonyos/template/scripts/verify-release-artifact +173 -0
- biucingcli/template_data/harmonyos/template/scripts/verify-supply-chain +70 -0
- biucingcli/template_data/harmonyos/template.json +134 -0
- biucingcli/template_data/microservice/template/.air.toml +11 -0
- biucingcli/template_data/microservice/template/.dockerignore +5 -0
- biucingcli/template_data/microservice/template/.gitignore +4 -0
- biucingcli/template_data/microservice/template/.mise.toml +3 -0
- biucingcli/template_data/microservice/template/Brewfile +7 -0
- biucingcli/template_data/microservice/template/Dockerfile +44 -0
- biucingcli/template_data/microservice/template/Dockerfile.dev +50 -0
- biucingcli/template_data/microservice/template/Makefile +145 -0
- biucingcli/template_data/microservice/template/README.md +191 -0
- biucingcli/template_data/microservice/template/api/buf.gen.yaml +11 -0
- biucingcli/template_data/microservice/template/api/buf.yaml +11 -0
- biucingcli/template_data/microservice/template/api/proto/service/v1/service.proto +18 -0
- biucingcli/template_data/microservice/template/cmd/server/main.go +81 -0
- biucingcli/template_data/microservice/template/compose.dev.yaml +49 -0
- biucingcli/template_data/microservice/template/configs/config.yaml +18 -0
- biucingcli/template_data/microservice/template/deploy/compose.yaml +35 -0
- biucingcli/template_data/microservice/template/deploy/otel-collector.yaml +15 -0
- biucingcli/template_data/microservice/template/go.mod +53 -0
- biucingcli/template_data/microservice/template/go.sum +138 -0
- biucingcli/template_data/microservice/template/internal/config/config.go +112 -0
- biucingcli/template_data/microservice/template/internal/config/config_test.go +92 -0
- biucingcli/template_data/microservice/template/internal/handler/health.go +25 -0
- biucingcli/template_data/microservice/template/internal/handler/ping.go +25 -0
- biucingcli/template_data/microservice/template/internal/model/health.go +8 -0
- biucingcli/template_data/microservice/template/internal/model/ping.go +7 -0
- biucingcli/template_data/microservice/template/internal/repository/.gitkeep +1 -0
- biucingcli/template_data/microservice/template/internal/router/router.go +26 -0
- biucingcli/template_data/microservice/template/internal/runtime/server.go +116 -0
- biucingcli/template_data/microservice/template/internal/runtime/server_test.go +161 -0
- biucingcli/template_data/microservice/template/internal/service/health.go +26 -0
- biucingcli/template_data/microservice/template/internal/service/ping.go +23 -0
- biucingcli/template_data/microservice/template/internal/telemetry/telemetry.go +56 -0
- biucingcli/template_data/microservice/template/internal/transport/grpc.go +51 -0
- biucingcli/template_data/microservice/template/internal/transport/ping.go +28 -0
- biucingcli/template_data/microservice/template/scripts/bootstrap +29 -0
- biucingcli/template_data/microservice/template/scripts/doctor +115 -0
- biucingcli/template_data/microservice/template/tests/server_test.go +153 -0
- biucingcli/template_data/microservice/template.json +114 -0
- biucingcli/template_data/web-service/template/.air.toml +11 -0
- biucingcli/template_data/web-service/template/.dockerignore +5 -0
- biucingcli/template_data/web-service/template/.gitignore +5 -0
- biucingcli/template_data/web-service/template/.mise.toml +2 -0
- biucingcli/template_data/web-service/template/Brewfile +5 -0
- biucingcli/template_data/web-service/template/Dockerfile +41 -0
- biucingcli/template_data/web-service/template/Dockerfile.dev +45 -0
- biucingcli/template_data/web-service/template/Makefile +113 -0
- biucingcli/template_data/web-service/template/README.md +164 -0
- biucingcli/template_data/web-service/template/cmd/server/main.go +50 -0
- biucingcli/template_data/web-service/template/compose.dev.yaml +28 -0
- biucingcli/template_data/web-service/template/compose.yaml +14 -0
- biucingcli/template_data/web-service/template/configs/config.yaml +10 -0
- biucingcli/template_data/web-service/template/go.mod +36 -0
- biucingcli/template_data/web-service/template/go.sum +89 -0
- biucingcli/template_data/web-service/template/internal/config/config.go +84 -0
- biucingcli/template_data/web-service/template/internal/config/config_test.go +88 -0
- biucingcli/template_data/web-service/template/internal/handler/health.go +25 -0
- biucingcli/template_data/web-service/template/internal/handler/ping.go +25 -0
- biucingcli/template_data/web-service/template/internal/handler/user.go +40 -0
- biucingcli/template_data/web-service/template/internal/model/health.go +6 -0
- biucingcli/template_data/web-service/template/internal/model/ping.go +7 -0
- biucingcli/template_data/web-service/template/internal/model/user.go +7 -0
- biucingcli/template_data/web-service/template/internal/repository/.gitkeep +1 -0
- biucingcli/template_data/web-service/template/internal/repository/user.go +37 -0
- biucingcli/template_data/web-service/template/internal/router/router.go +28 -0
- biucingcli/template_data/web-service/template/internal/runtime/server.go +76 -0
- biucingcli/template_data/web-service/template/internal/runtime/server_test.go +139 -0
- biucingcli/template_data/web-service/template/internal/service/health.go +22 -0
- biucingcli/template_data/web-service/template/internal/service/ping.go +23 -0
- biucingcli/template_data/web-service/template/internal/service/user.go +27 -0
- biucingcli/template_data/web-service/template/scripts/bootstrap +23 -0
- biucingcli/template_data/web-service/template/scripts/doctor +83 -0
- biucingcli/template_data/web-service/template/tests/server_test.go +160 -0
- biucingcli/template_data/web-service/template.json +88 -0
- biucingcli/template_data/worker/template/.dockerignore +3 -0
- biucingcli/template_data/worker/template/.gitignore +2 -0
- biucingcli/template_data/worker/template/.mise.toml +2 -0
- biucingcli/template_data/worker/template/Brewfile +1 -0
- biucingcli/template_data/worker/template/Dockerfile +19 -0
- biucingcli/template_data/worker/template/Dockerfile.dev +9 -0
- biucingcli/template_data/worker/template/Makefile +105 -0
- biucingcli/template_data/worker/template/README.md +164 -0
- biucingcli/template_data/worker/template/cmd/worker/main.go +41 -0
- biucingcli/template_data/worker/template/compose.dev.yaml +29 -0
- biucingcli/template_data/worker/template/configs/config.json +11 -0
- biucingcli/template_data/worker/template/go.mod +3 -0
- biucingcli/template_data/worker/template/go.sum +1 -0
- biucingcli/template_data/worker/template/internal/config/config.go +108 -0
- biucingcli/template_data/worker/template/internal/config/config_test.go +86 -0
- biucingcli/template_data/worker/template/internal/runtime/runner.go +199 -0
- biucingcli/template_data/worker/template/internal/runtime/runner_test.go +215 -0
- biucingcli/template_data/worker/template/internal/task/heartbeat.go +19 -0
- biucingcli/template_data/worker/template/scripts/bootstrap +6 -0
- biucingcli/template_data/worker/template/scripts/doctor +41 -0
- biucingcli/template_data/worker/template/tests/worker_test.go +44 -0
- biucingcli/template_data/worker/template.json +100 -0
- biucingcli/templates.py +902 -0
- biucingcli-0.9.1.dist-info/METADATA +204 -0
- biucingcli-0.9.1.dist-info/RECORD +269 -0
- biucingcli-0.9.1.dist-info/WHEEL +5 -0
- biucingcli-0.9.1.dist-info/entry_points.txt +2 -0
- biucingcli-0.9.1.dist-info/top_level.txt +1 -0
biucingcli/templates.py
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
"""Template loading and rendering for BiucingCLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BiucingError(Exception):
|
|
17
|
+
"""Base class for expected, user-facing CLI failures."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnknownTemplateError(BiucingError):
|
|
21
|
+
"""Raised when a requested template does not exist."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidTemplateError(BiucingError):
|
|
25
|
+
"""Raised when bundled template metadata cannot be loaded."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GenerationError(BiucingError):
|
|
29
|
+
"""Raised when a project cannot be generated safely."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GenerationConflictError(GenerationError):
|
|
33
|
+
"""Raised when generation would overwrite an existing path."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class TemplateVariable:
|
|
38
|
+
"""A declared template variable."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
required: bool = False
|
|
42
|
+
default: str | None = None
|
|
43
|
+
default_from: str | None = None
|
|
44
|
+
prompt: str | None = None
|
|
45
|
+
validator: str = "text"
|
|
46
|
+
choices: list[str] = field(default_factory=list)
|
|
47
|
+
minimum: int | None = None
|
|
48
|
+
maximum: int | None = None
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, object]:
|
|
51
|
+
"""Return a JSON-serializable representation."""
|
|
52
|
+
return {
|
|
53
|
+
"name": self.name,
|
|
54
|
+
"required": self.required,
|
|
55
|
+
"default": self.default,
|
|
56
|
+
"default_from": self.default_from,
|
|
57
|
+
"prompt": self.prompt,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ResolvedVariable:
|
|
63
|
+
"""A resolved template variable plus its source."""
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
value: str
|
|
67
|
+
source: str
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, str]:
|
|
70
|
+
"""Return a JSON-serializable representation."""
|
|
71
|
+
return asdict(self)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class TemplateMaturity:
|
|
76
|
+
"""User-facing maturity metadata for a template."""
|
|
77
|
+
|
|
78
|
+
level: str
|
|
79
|
+
summary: str
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict[str, object]:
|
|
82
|
+
"""Return a JSON-serializable representation."""
|
|
83
|
+
return asdict(self)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class TemplateValidation:
|
|
88
|
+
"""User-facing validation metadata for a template."""
|
|
89
|
+
|
|
90
|
+
status: str
|
|
91
|
+
verification_tier: str
|
|
92
|
+
evidence: list[str]
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict[str, object]:
|
|
95
|
+
"""Return a JSON-serializable representation."""
|
|
96
|
+
return asdict(self)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class TemplateWorktree:
|
|
101
|
+
"""Worktree isolation metadata for a template."""
|
|
102
|
+
|
|
103
|
+
support_level: str = ""
|
|
104
|
+
isolation_dimensions: list[str] = field(default_factory=list)
|
|
105
|
+
diagnostics: list[str] = field(default_factory=list)
|
|
106
|
+
cleanup: list[str] = field(default_factory=list)
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, object]:
|
|
109
|
+
"""Return a JSON-serializable representation."""
|
|
110
|
+
return asdict(self)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class TemplateDefinition:
|
|
115
|
+
"""Template metadata and file locations."""
|
|
116
|
+
|
|
117
|
+
name: str
|
|
118
|
+
description: str
|
|
119
|
+
stack: list[str]
|
|
120
|
+
category: str
|
|
121
|
+
tags: list[str]
|
|
122
|
+
platforms: list[str]
|
|
123
|
+
maturity: TemplateMaturity
|
|
124
|
+
validation: TemplateValidation
|
|
125
|
+
worktree: TemplateWorktree
|
|
126
|
+
operating_assumptions: list[str]
|
|
127
|
+
workflow_labels: list[str]
|
|
128
|
+
commands: dict[str, str]
|
|
129
|
+
variables: list[TemplateVariable]
|
|
130
|
+
next_steps: list[str]
|
|
131
|
+
template_dir: Path
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> dict[str, object]:
|
|
134
|
+
"""Return a JSON-serializable representation."""
|
|
135
|
+
return {
|
|
136
|
+
"name": self.name,
|
|
137
|
+
"description": self.description,
|
|
138
|
+
"stack": self.stack,
|
|
139
|
+
"category": self.category,
|
|
140
|
+
"tags": self.tags,
|
|
141
|
+
"platforms": self.platforms,
|
|
142
|
+
"maturity": self.maturity.to_dict(),
|
|
143
|
+
"validation": self.validation.to_dict(),
|
|
144
|
+
"worktree": self.worktree.to_dict(),
|
|
145
|
+
"operating_assumptions": self.operating_assumptions,
|
|
146
|
+
"workflow_labels": self.workflow_labels,
|
|
147
|
+
"variables": [variable.to_dict() for variable in self.variables],
|
|
148
|
+
"next_steps": self.next_steps,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class VariableResolutionResult:
|
|
154
|
+
"""Resolved template variables and missing required inputs."""
|
|
155
|
+
|
|
156
|
+
values: dict[str, str]
|
|
157
|
+
resolved_variables: list[ResolvedVariable]
|
|
158
|
+
missing_required: list[str]
|
|
159
|
+
|
|
160
|
+
def to_dict(self) -> dict[str, object]:
|
|
161
|
+
"""Return a JSON-serializable representation."""
|
|
162
|
+
return {
|
|
163
|
+
"values": self.values,
|
|
164
|
+
"resolved_variables": [item.to_dict() for item in self.resolved_variables],
|
|
165
|
+
"missing_required": self.missing_required,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
PLACEHOLDER_PATTERN = re.compile(r"\{\{[A-Z0-9_]+\}\}")
|
|
170
|
+
ALLOWED_VERIFICATION_TIERS = {
|
|
171
|
+
"generated-project",
|
|
172
|
+
"real-build",
|
|
173
|
+
}
|
|
174
|
+
ALLOWED_WORKFLOW_LABELS = {
|
|
175
|
+
"bootstrap",
|
|
176
|
+
"doctor",
|
|
177
|
+
"dev",
|
|
178
|
+
"test",
|
|
179
|
+
"verify",
|
|
180
|
+
"build",
|
|
181
|
+
"runtime",
|
|
182
|
+
"generate",
|
|
183
|
+
"format",
|
|
184
|
+
"release",
|
|
185
|
+
"ui-test",
|
|
186
|
+
"open",
|
|
187
|
+
"lint",
|
|
188
|
+
}
|
|
189
|
+
ALLOWED_WORKTREE_SUPPORT_LEVELS = {
|
|
190
|
+
"planned",
|
|
191
|
+
"partial",
|
|
192
|
+
"worktree-ready",
|
|
193
|
+
}
|
|
194
|
+
ALLOWED_WORKTREE_ISOLATION_DIMENSIONS = {
|
|
195
|
+
"runtime-names",
|
|
196
|
+
"ports",
|
|
197
|
+
"dependency-stores",
|
|
198
|
+
"caches",
|
|
199
|
+
"generated-output",
|
|
200
|
+
"local-config",
|
|
201
|
+
"installed-app-identity",
|
|
202
|
+
"cleanup",
|
|
203
|
+
"diagnostics",
|
|
204
|
+
}
|
|
205
|
+
ALLOWED_VARIABLE_VALIDATORS = {
|
|
206
|
+
"apple-version",
|
|
207
|
+
"bundle-identifier",
|
|
208
|
+
"choice",
|
|
209
|
+
"display-name",
|
|
210
|
+
"go-module",
|
|
211
|
+
"harmony-sdk-version",
|
|
212
|
+
"identifier",
|
|
213
|
+
"java-package",
|
|
214
|
+
"npm-package",
|
|
215
|
+
"port",
|
|
216
|
+
"positive-integer",
|
|
217
|
+
"project-name",
|
|
218
|
+
"protobuf-package",
|
|
219
|
+
"semantic-version",
|
|
220
|
+
"slug",
|
|
221
|
+
"team-id",
|
|
222
|
+
"text",
|
|
223
|
+
"url",
|
|
224
|
+
}
|
|
225
|
+
REQUIRED_COMMAND_CONTRACT = (
|
|
226
|
+
"bootstrap",
|
|
227
|
+
"doctor",
|
|
228
|
+
"lint",
|
|
229
|
+
"test",
|
|
230
|
+
"verify",
|
|
231
|
+
"build",
|
|
232
|
+
"clean",
|
|
233
|
+
"help",
|
|
234
|
+
)
|
|
235
|
+
MAKE_TARGET_PATTERN = re.compile(
|
|
236
|
+
r"^([A-Za-z0-9_.-]+(?:[ \t]+[A-Za-z0-9_.-]+)*):(?:[ \t]|$)",
|
|
237
|
+
re.MULTILINE,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def templates_root() -> Path:
|
|
242
|
+
"""Return the templates bundled inside the installed package."""
|
|
243
|
+
return Path(__file__).resolve().parent / "template_data"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def load_template(name: str) -> TemplateDefinition:
|
|
247
|
+
"""Load one template definition by name."""
|
|
248
|
+
metadata_path = templates_root() / name / "template.json"
|
|
249
|
+
if not metadata_path.exists():
|
|
250
|
+
raise UnknownTemplateError(f"unknown template '{name}'")
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
with metadata_path.open("r", encoding="utf-8") as handle:
|
|
254
|
+
data = json.load(handle)
|
|
255
|
+
|
|
256
|
+
variables = [TemplateVariable(**variable) for variable in data["variables"]]
|
|
257
|
+
maturity = TemplateMaturity(**data["maturity"])
|
|
258
|
+
validation = TemplateValidation(**data["validation"])
|
|
259
|
+
worktree = TemplateWorktree(**data.get("worktree", {}))
|
|
260
|
+
return TemplateDefinition(
|
|
261
|
+
name=data["name"],
|
|
262
|
+
description=data["description"],
|
|
263
|
+
stack=data["stack"],
|
|
264
|
+
category=data["category"],
|
|
265
|
+
tags=data["tags"],
|
|
266
|
+
platforms=data["platforms"],
|
|
267
|
+
maturity=maturity,
|
|
268
|
+
validation=validation,
|
|
269
|
+
worktree=worktree,
|
|
270
|
+
operating_assumptions=data["operating_assumptions"],
|
|
271
|
+
workflow_labels=data["workflow_labels"],
|
|
272
|
+
commands=data.get("commands", {}),
|
|
273
|
+
variables=variables,
|
|
274
|
+
next_steps=data["next_steps"],
|
|
275
|
+
template_dir=metadata_path.parent / "template",
|
|
276
|
+
)
|
|
277
|
+
except (OSError, UnicodeError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
278
|
+
raise InvalidTemplateError(
|
|
279
|
+
f"invalid metadata for template '{name}': {exc}"
|
|
280
|
+
) from exc
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def load_templates() -> list[TemplateDefinition]:
|
|
284
|
+
"""Load every available template."""
|
|
285
|
+
definitions: list[TemplateDefinition] = []
|
|
286
|
+
for metadata_path in sorted(templates_root().glob("*/template.json")):
|
|
287
|
+
definitions.append(load_template(metadata_path.parent.name))
|
|
288
|
+
return definitions
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def supported_placeholders() -> set[str]:
|
|
292
|
+
"""Return every placeholder the renderer knows how to replace."""
|
|
293
|
+
return set(placeholder_map({}).keys())
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def variable_validation_error(variable: TemplateVariable, value: str) -> str | None:
|
|
297
|
+
"""Return a concise validation error for one resolved template value."""
|
|
298
|
+
if value != value.strip():
|
|
299
|
+
return "must not start or end with whitespace"
|
|
300
|
+
if not value:
|
|
301
|
+
return "must not be empty"
|
|
302
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
|
303
|
+
return "must not contain control characters"
|
|
304
|
+
if variable.choices and value not in variable.choices:
|
|
305
|
+
return f"must be one of: {', '.join(variable.choices)}"
|
|
306
|
+
|
|
307
|
+
validator = variable.validator
|
|
308
|
+
patterns = {
|
|
309
|
+
"apple-version": (
|
|
310
|
+
r"^\d+(?:\.\d+){1,2}$",
|
|
311
|
+
"must be a numeric Apple OS version such as 17.0",
|
|
312
|
+
),
|
|
313
|
+
"bundle-identifier": (
|
|
314
|
+
r"^[A-Za-z0-9][A-Za-z0-9-]*(?:\.[A-Za-z0-9][A-Za-z0-9-]*)+$",
|
|
315
|
+
"must be a reverse-DNS identifier such as com.example.app",
|
|
316
|
+
),
|
|
317
|
+
"go-module": (
|
|
318
|
+
r"^[A-Za-z0-9][A-Za-z0-9.+~-]*(?:[./][A-Za-z0-9][A-Za-z0-9._+~-]*)+$",
|
|
319
|
+
"must be a Go module path such as github.com/example/service",
|
|
320
|
+
),
|
|
321
|
+
"harmony-sdk-version": (
|
|
322
|
+
r"^\d+\.\d+\.\d+\(\d+\)$",
|
|
323
|
+
"must use HarmonyOS SDK notation such as 5.0.0(12)",
|
|
324
|
+
),
|
|
325
|
+
"identifier": (
|
|
326
|
+
r"^[A-Za-z_][A-Za-z0-9_]*$",
|
|
327
|
+
"must be a valid language identifier",
|
|
328
|
+
),
|
|
329
|
+
"java-package": (
|
|
330
|
+
r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+$",
|
|
331
|
+
"must be a dotted Java package such as com.example.app",
|
|
332
|
+
),
|
|
333
|
+
"npm-package": (
|
|
334
|
+
r"^(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$",
|
|
335
|
+
"must be a lowercase npm package name",
|
|
336
|
+
),
|
|
337
|
+
"protobuf-package": (
|
|
338
|
+
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$",
|
|
339
|
+
"must be a dotted lowercase Protobuf package such as service.v1",
|
|
340
|
+
),
|
|
341
|
+
"semantic-version": (
|
|
342
|
+
r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$",
|
|
343
|
+
"must be a semantic version such as 1.2.3",
|
|
344
|
+
),
|
|
345
|
+
"slug": (
|
|
346
|
+
r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
347
|
+
"must be a lowercase hyphenated slug",
|
|
348
|
+
),
|
|
349
|
+
"team-id": (
|
|
350
|
+
r"^(?:[A-Z0-9]{10}|DEVELOPMENT_TEAM_ID)$",
|
|
351
|
+
"must be a 10-character Apple team ID or DEVELOPMENT_TEAM_ID",
|
|
352
|
+
),
|
|
353
|
+
}
|
|
354
|
+
if validator in patterns:
|
|
355
|
+
pattern, message = patterns[validator]
|
|
356
|
+
if re.fullmatch(pattern, value) is None:
|
|
357
|
+
return message
|
|
358
|
+
|
|
359
|
+
if validator == "project-name":
|
|
360
|
+
if value in {".", ".."} or len(value) > 80 or re.fullmatch(
|
|
361
|
+
r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?", value
|
|
362
|
+
) is None:
|
|
363
|
+
return (
|
|
364
|
+
"must be a safe directory name using letters, numbers, dots, "
|
|
365
|
+
"underscores, or hyphens"
|
|
366
|
+
)
|
|
367
|
+
elif validator == "display-name" and len(value) > 120:
|
|
368
|
+
return "must contain at most 120 characters"
|
|
369
|
+
elif validator == "npm-package" and len(value) > 214:
|
|
370
|
+
return "must contain at most 214 characters"
|
|
371
|
+
elif validator == "slug" and len(value) > 63:
|
|
372
|
+
return "must contain at most 63 characters"
|
|
373
|
+
elif validator in {"port", "positive-integer"}:
|
|
374
|
+
if re.fullmatch(r"\d+", value) is None:
|
|
375
|
+
return "must be an integer"
|
|
376
|
+
numeric_value = int(value)
|
|
377
|
+
minimum = variable.minimum if variable.minimum is not None else 1
|
|
378
|
+
maximum = variable.maximum
|
|
379
|
+
if validator == "port" and maximum is None:
|
|
380
|
+
maximum = 65535
|
|
381
|
+
if numeric_value < minimum or (maximum is not None and numeric_value > maximum):
|
|
382
|
+
upper = str(maximum) if maximum is not None else "unbounded"
|
|
383
|
+
return f"must be between {minimum} and {upper}"
|
|
384
|
+
elif validator == "url":
|
|
385
|
+
parsed = urlparse(value)
|
|
386
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
387
|
+
return "must be an absolute http or https URL"
|
|
388
|
+
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def validate_resolved_variables(
|
|
393
|
+
definition: TemplateDefinition, values: dict[str, str]
|
|
394
|
+
) -> list[str]:
|
|
395
|
+
"""Validate every resolved input according to its template metadata."""
|
|
396
|
+
errors: list[str] = []
|
|
397
|
+
for variable in definition.variables:
|
|
398
|
+
value = values.get(variable.name)
|
|
399
|
+
if value is None:
|
|
400
|
+
continue
|
|
401
|
+
reason = variable_validation_error(variable, value)
|
|
402
|
+
if reason:
|
|
403
|
+
errors.append(f"{variable.name}: {reason} (received {value!r})")
|
|
404
|
+
return errors
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def validate_template_definition(definition: TemplateDefinition) -> list[str]:
|
|
408
|
+
"""Return validation errors for one template definition."""
|
|
409
|
+
errors: list[str] = []
|
|
410
|
+
|
|
411
|
+
if definition.template_dir.name != "template":
|
|
412
|
+
errors.append(f"{definition.name}: template_dir must end in /template")
|
|
413
|
+
|
|
414
|
+
if not definition.template_dir.exists():
|
|
415
|
+
errors.append(f"{definition.name}: template directory is missing")
|
|
416
|
+
|
|
417
|
+
if not definition.category:
|
|
418
|
+
errors.append(f"{definition.name}: category must not be empty")
|
|
419
|
+
|
|
420
|
+
if not definition.stack:
|
|
421
|
+
errors.append(f"{definition.name}: stack must contain at least one entry")
|
|
422
|
+
|
|
423
|
+
if not definition.tags:
|
|
424
|
+
errors.append(f"{definition.name}: tags must contain at least one entry")
|
|
425
|
+
|
|
426
|
+
if not definition.platforms:
|
|
427
|
+
errors.append(f"{definition.name}: platforms must contain at least one entry")
|
|
428
|
+
|
|
429
|
+
if not definition.next_steps:
|
|
430
|
+
errors.append(f"{definition.name}: next_steps must contain at least one entry")
|
|
431
|
+
if not definition.operating_assumptions:
|
|
432
|
+
errors.append(f"{definition.name}: operating_assumptions must contain at least one entry")
|
|
433
|
+
if not definition.workflow_labels:
|
|
434
|
+
errors.append(f"{definition.name}: workflow_labels must contain at least one entry")
|
|
435
|
+
|
|
436
|
+
if not definition.maturity.level or not definition.maturity.summary:
|
|
437
|
+
errors.append(f"{definition.name}: maturity.level and maturity.summary must be set")
|
|
438
|
+
|
|
439
|
+
if not definition.validation.status:
|
|
440
|
+
errors.append(f"{definition.name}: validation.status must not be empty")
|
|
441
|
+
if definition.validation.verification_tier not in ALLOWED_VERIFICATION_TIERS:
|
|
442
|
+
allowed_tiers = ", ".join(sorted(ALLOWED_VERIFICATION_TIERS))
|
|
443
|
+
errors.append(
|
|
444
|
+
f"{definition.name}: validation.verification_tier must be one of: {allowed_tiers}"
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
if not definition.validation.evidence:
|
|
448
|
+
errors.append(f"{definition.name}: validation.evidence must contain at least one entry")
|
|
449
|
+
|
|
450
|
+
if definition.worktree.support_level not in ALLOWED_WORKTREE_SUPPORT_LEVELS:
|
|
451
|
+
allowed_levels = ", ".join(sorted(ALLOWED_WORKTREE_SUPPORT_LEVELS))
|
|
452
|
+
errors.append(
|
|
453
|
+
f"{definition.name}: worktree.support_level must be one of: {allowed_levels}"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
if not definition.worktree.isolation_dimensions:
|
|
457
|
+
errors.append(
|
|
458
|
+
f"{definition.name}: worktree.isolation_dimensions must contain at least one entry"
|
|
459
|
+
)
|
|
460
|
+
if not definition.worktree.diagnostics:
|
|
461
|
+
errors.append(f"{definition.name}: worktree.diagnostics must contain at least one entry")
|
|
462
|
+
if not definition.worktree.cleanup:
|
|
463
|
+
errors.append(f"{definition.name}: worktree.cleanup must contain at least one entry")
|
|
464
|
+
|
|
465
|
+
invalid_worktree_dimensions = sorted(
|
|
466
|
+
dimension
|
|
467
|
+
for dimension in definition.worktree.isolation_dimensions
|
|
468
|
+
if dimension not in ALLOWED_WORKTREE_ISOLATION_DIMENSIONS
|
|
469
|
+
)
|
|
470
|
+
if invalid_worktree_dimensions:
|
|
471
|
+
allowed_dimensions = ", ".join(sorted(ALLOWED_WORKTREE_ISOLATION_DIMENSIONS))
|
|
472
|
+
invalid_dimensions = ", ".join(invalid_worktree_dimensions)
|
|
473
|
+
errors.append(
|
|
474
|
+
f"{definition.name}: worktree.isolation_dimensions contain unsupported values: "
|
|
475
|
+
f"{invalid_dimensions}; expected one of: {allowed_dimensions}"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
if len(definition.worktree.isolation_dimensions) != len(
|
|
479
|
+
set(definition.worktree.isolation_dimensions)
|
|
480
|
+
):
|
|
481
|
+
errors.append(
|
|
482
|
+
f"{definition.name}: worktree.isolation_dimensions must not contain duplicates"
|
|
483
|
+
)
|
|
484
|
+
if len(definition.worktree.diagnostics) != len(set(definition.worktree.diagnostics)):
|
|
485
|
+
errors.append(f"{definition.name}: worktree.diagnostics must not contain duplicates")
|
|
486
|
+
if len(definition.worktree.cleanup) != len(set(definition.worktree.cleanup)):
|
|
487
|
+
errors.append(f"{definition.name}: worktree.cleanup must not contain duplicates")
|
|
488
|
+
|
|
489
|
+
invalid_workflow_labels = sorted(
|
|
490
|
+
label for label in definition.workflow_labels if label not in ALLOWED_WORKFLOW_LABELS
|
|
491
|
+
)
|
|
492
|
+
if invalid_workflow_labels:
|
|
493
|
+
errors.append(
|
|
494
|
+
f"{definition.name}: workflow_labels contain unsupported values: {', '.join(invalid_workflow_labels)}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if len(definition.workflow_labels) != len(set(definition.workflow_labels)):
|
|
498
|
+
errors.append(f"{definition.name}: workflow_labels must not contain duplicates")
|
|
499
|
+
|
|
500
|
+
missing_commands = sorted(set(REQUIRED_COMMAND_CONTRACT) - set(definition.commands))
|
|
501
|
+
if missing_commands:
|
|
502
|
+
errors.append(
|
|
503
|
+
f"{definition.name}: commands missing required entries: {', '.join(missing_commands)}"
|
|
504
|
+
)
|
|
505
|
+
for command_name, command in sorted(definition.commands.items()):
|
|
506
|
+
if command != f"make {command_name}":
|
|
507
|
+
errors.append(
|
|
508
|
+
f"{definition.name}: command '{command_name}' must be exactly 'make {command_name}'"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
variable_names = [variable.name for variable in definition.variables]
|
|
512
|
+
duplicate_names = sorted({name for name in variable_names if variable_names.count(name) > 1})
|
|
513
|
+
if duplicate_names:
|
|
514
|
+
errors.append(
|
|
515
|
+
f"{definition.name}: duplicate variable names: {', '.join(duplicate_names)}"
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
supported = supported_placeholders()
|
|
519
|
+
for variable in definition.variables:
|
|
520
|
+
placeholder = "{{" + variable.name.upper() + "}}"
|
|
521
|
+
if placeholder not in supported:
|
|
522
|
+
errors.append(
|
|
523
|
+
f"{definition.name}: variable '{variable.name}' has no supported placeholder mapping"
|
|
524
|
+
)
|
|
525
|
+
if variable.default_from and variable.default_from not in variable_names:
|
|
526
|
+
errors.append(
|
|
527
|
+
f"{definition.name}: variable '{variable.name}' default_from unknown variable '{variable.default_from}'"
|
|
528
|
+
)
|
|
529
|
+
if variable.validator not in ALLOWED_VARIABLE_VALIDATORS:
|
|
530
|
+
errors.append(
|
|
531
|
+
f"{definition.name}: variable '{variable.name}' uses unsupported validator '{variable.validator}'"
|
|
532
|
+
)
|
|
533
|
+
if variable.validator == "choice" and not variable.choices:
|
|
534
|
+
errors.append(
|
|
535
|
+
f"{definition.name}: variable '{variable.name}' choice validator requires choices"
|
|
536
|
+
)
|
|
537
|
+
if variable.minimum is not None and variable.maximum is not None:
|
|
538
|
+
if variable.minimum > variable.maximum:
|
|
539
|
+
errors.append(
|
|
540
|
+
f"{definition.name}: variable '{variable.name}' minimum must not exceed maximum"
|
|
541
|
+
)
|
|
542
|
+
if variable.default is not None:
|
|
543
|
+
reason = variable_validation_error(variable, variable.default)
|
|
544
|
+
if reason:
|
|
545
|
+
errors.append(
|
|
546
|
+
f"{definition.name}: default for variable '{variable.name}' {reason}"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
return errors
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def validate_template_command_contract(definition: TemplateDefinition) -> list[str]:
|
|
553
|
+
"""Validate metadata commands against concrete phony Make targets."""
|
|
554
|
+
makefile_path = definition.template_dir / "Makefile"
|
|
555
|
+
if not makefile_path.is_file():
|
|
556
|
+
return [f"{definition.name}: Makefile is missing for command contract validation"]
|
|
557
|
+
|
|
558
|
+
content = makefile_path.read_text(encoding="utf-8")
|
|
559
|
+
targets: set[str] = set()
|
|
560
|
+
phony_targets: set[str] = set()
|
|
561
|
+
for match in MAKE_TARGET_PATTERN.finditer(content):
|
|
562
|
+
names = match.group(1).split()
|
|
563
|
+
if names == [".PHONY"]:
|
|
564
|
+
line_end = content.find("\n", match.end())
|
|
565
|
+
if line_end < 0:
|
|
566
|
+
line_end = len(content)
|
|
567
|
+
phony_targets.update(content[match.end():line_end].split())
|
|
568
|
+
else:
|
|
569
|
+
targets.update(names)
|
|
570
|
+
|
|
571
|
+
errors: list[str] = []
|
|
572
|
+
for command_name in sorted(definition.commands):
|
|
573
|
+
if command_name not in targets:
|
|
574
|
+
errors.append(
|
|
575
|
+
f"{definition.name}: command target '{command_name}' is missing from Makefile"
|
|
576
|
+
)
|
|
577
|
+
elif command_name not in phony_targets:
|
|
578
|
+
errors.append(
|
|
579
|
+
f"{definition.name}: command target '{command_name}' must be declared .PHONY"
|
|
580
|
+
)
|
|
581
|
+
return errors
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def validate_template_required_files(definition: TemplateDefinition) -> list[str]:
|
|
585
|
+
"""Return family-level required file errors for a template."""
|
|
586
|
+
errors: list[str] = []
|
|
587
|
+
|
|
588
|
+
if not definition.template_dir.exists():
|
|
589
|
+
return [f"{definition.name}: template directory is missing"]
|
|
590
|
+
|
|
591
|
+
relative_entries = {
|
|
592
|
+
path.relative_to(definition.template_dir).as_posix()
|
|
593
|
+
for path in definition.template_dir.rglob("*")
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
required_entries = {"README.md", "Makefile", ".gitignore", "scripts/doctor"}
|
|
597
|
+
if "docker" in definition.tags:
|
|
598
|
+
required_entries.update({".dockerignore", "compose.dev.yaml"})
|
|
599
|
+
if definition.category == "backend":
|
|
600
|
+
required_entries.update({"go.mod", "go.sum", "cmd", "internal", "configs", "scripts"})
|
|
601
|
+
if definition.category == "native":
|
|
602
|
+
required_entries.update({".mise.toml", "scripts"})
|
|
603
|
+
if definition.name == "apple":
|
|
604
|
+
required_entries.update(
|
|
605
|
+
{
|
|
606
|
+
"Tuist.swift",
|
|
607
|
+
"Workspace.swift",
|
|
608
|
+
"fastlane/Fastfile",
|
|
609
|
+
"scripts/verify-release-identity",
|
|
610
|
+
}
|
|
611
|
+
)
|
|
612
|
+
if definition.name == "android":
|
|
613
|
+
required_entries.update({"gradlew", "gradlew.bat", "scripts"})
|
|
614
|
+
if definition.name == "microservice":
|
|
615
|
+
required_entries.update(
|
|
616
|
+
{
|
|
617
|
+
"api/buf.gen.yaml",
|
|
618
|
+
"api/buf.yaml",
|
|
619
|
+
"api/proto/service/v1/service.proto",
|
|
620
|
+
"internal/transport/grpc.go",
|
|
621
|
+
"internal/transport/ping.go",
|
|
622
|
+
"internal/runtime/server.go",
|
|
623
|
+
"internal/runtime/server_test.go",
|
|
624
|
+
"tests/server_test.go",
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
if definition.name == "web-service":
|
|
628
|
+
required_entries.update(
|
|
629
|
+
{
|
|
630
|
+
"internal/runtime/server.go",
|
|
631
|
+
"internal/runtime/server_test.go",
|
|
632
|
+
}
|
|
633
|
+
)
|
|
634
|
+
if definition.name == "worker":
|
|
635
|
+
required_entries.update(
|
|
636
|
+
{
|
|
637
|
+
"internal/runtime/runner.go",
|
|
638
|
+
"internal/runtime/runner_test.go",
|
|
639
|
+
}
|
|
640
|
+
)
|
|
641
|
+
if definition.name == "frontend":
|
|
642
|
+
required_entries.update(
|
|
643
|
+
{
|
|
644
|
+
"pnpm-lock.yaml",
|
|
645
|
+
"playwright.production.config.ts",
|
|
646
|
+
"scripts/browser-smoke-production",
|
|
647
|
+
"tests/production-browser-smoke.spec.ts",
|
|
648
|
+
}
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
missing_entries = sorted(entry for entry in required_entries if entry not in relative_entries)
|
|
652
|
+
if missing_entries:
|
|
653
|
+
errors.append(
|
|
654
|
+
f"{definition.name}: missing required starter entries: {', '.join(missing_entries)}"
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
return errors
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def validate_template_placeholders(definition: TemplateDefinition) -> list[str]:
|
|
661
|
+
"""Return placeholder validation errors for a template directory."""
|
|
662
|
+
errors: list[str] = []
|
|
663
|
+
supported = supported_placeholders()
|
|
664
|
+
|
|
665
|
+
if not definition.template_dir.exists():
|
|
666
|
+
return [f"{definition.name}: template directory is missing"]
|
|
667
|
+
|
|
668
|
+
for path in definition.template_dir.rglob("*"):
|
|
669
|
+
if not path.is_file():
|
|
670
|
+
continue
|
|
671
|
+
|
|
672
|
+
try:
|
|
673
|
+
content = path.read_text(encoding="utf-8")
|
|
674
|
+
except UnicodeDecodeError:
|
|
675
|
+
continue
|
|
676
|
+
|
|
677
|
+
placeholders = sorted(set(PLACEHOLDER_PATTERN.findall(content)))
|
|
678
|
+
unsupported = [placeholder for placeholder in placeholders if placeholder not in supported]
|
|
679
|
+
if unsupported:
|
|
680
|
+
relative_path = path.relative_to(templates_root()).as_posix()
|
|
681
|
+
errors.append(
|
|
682
|
+
f"{definition.name}: unsupported placeholder(s) in {relative_path}: {', '.join(unsupported)}"
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
for step in definition.next_steps:
|
|
686
|
+
placeholders = sorted(set(PLACEHOLDER_PATTERN.findall(step)))
|
|
687
|
+
unsupported = [placeholder for placeholder in placeholders if placeholder not in supported]
|
|
688
|
+
if unsupported:
|
|
689
|
+
errors.append(
|
|
690
|
+
f"{definition.name}: unsupported placeholder(s) in next_steps: {', '.join(unsupported)}"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
return errors
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def validate_templates() -> list[str]:
|
|
697
|
+
"""Return every template validation error in the repository."""
|
|
698
|
+
errors: list[str] = []
|
|
699
|
+
definitions = load_templates()
|
|
700
|
+
known_names = {definition.name for definition in definitions}
|
|
701
|
+
|
|
702
|
+
for definition in definitions:
|
|
703
|
+
errors.extend(validate_template_definition(definition))
|
|
704
|
+
errors.extend(validate_template_placeholders(definition))
|
|
705
|
+
errors.extend(validate_template_required_files(definition))
|
|
706
|
+
errors.extend(validate_template_command_contract(definition))
|
|
707
|
+
|
|
708
|
+
for metadata_path in sorted(templates_root().glob("*/template.json")):
|
|
709
|
+
folder_name = metadata_path.parent.name
|
|
710
|
+
definition_name = json.loads(metadata_path.read_text(encoding="utf-8"))["name"]
|
|
711
|
+
if definition_name != folder_name:
|
|
712
|
+
errors.append(
|
|
713
|
+
f"{folder_name}: template name '{definition_name}' must match folder name"
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
if not known_names:
|
|
717
|
+
errors.append("no templates found under templates/")
|
|
718
|
+
|
|
719
|
+
return errors
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def resolve_variables(
|
|
723
|
+
definition: TemplateDefinition,
|
|
724
|
+
provided: dict[str, str | None],
|
|
725
|
+
interactive: bool = True,
|
|
726
|
+
) -> dict[str, str]:
|
|
727
|
+
"""Resolve final template variables from provided values and defaults."""
|
|
728
|
+
return resolve_variables_detailed(definition, provided, interactive=interactive).values
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def resolve_variables_detailed(
|
|
732
|
+
definition: TemplateDefinition,
|
|
733
|
+
provided: dict[str, str | None],
|
|
734
|
+
interactive: bool = True,
|
|
735
|
+
) -> VariableResolutionResult:
|
|
736
|
+
"""Resolve final template variables along with source metadata."""
|
|
737
|
+
resolved: dict[str, str] = {}
|
|
738
|
+
resolution_sources: dict[str, str] = {}
|
|
739
|
+
missing_required: list[str] = []
|
|
740
|
+
for variable in definition.variables:
|
|
741
|
+
value = provided.get(variable.name)
|
|
742
|
+
normalized_value = value.strip() if value is not None else ""
|
|
743
|
+
if normalized_value:
|
|
744
|
+
resolved[variable.name] = normalized_value
|
|
745
|
+
resolution_sources[variable.name] = "provided"
|
|
746
|
+
continue
|
|
747
|
+
|
|
748
|
+
if variable.default is not None:
|
|
749
|
+
resolved[variable.name] = variable.default
|
|
750
|
+
resolution_sources[variable.name] = "default"
|
|
751
|
+
continue
|
|
752
|
+
|
|
753
|
+
if variable.default_from is not None and variable.default_from in resolved:
|
|
754
|
+
resolved[variable.name] = resolved[variable.default_from]
|
|
755
|
+
resolution_sources[variable.name] = f"default_from:{variable.default_from}"
|
|
756
|
+
continue
|
|
757
|
+
|
|
758
|
+
if variable.required:
|
|
759
|
+
if not interactive:
|
|
760
|
+
missing_required.append(variable.name)
|
|
761
|
+
continue
|
|
762
|
+
prompt = variable.prompt or f"{variable.name}: "
|
|
763
|
+
answer = input(prompt).strip()
|
|
764
|
+
if not answer:
|
|
765
|
+
raise ValueError(f"Missing required value for {variable.name}")
|
|
766
|
+
resolved[variable.name] = answer
|
|
767
|
+
resolution_sources[variable.name] = "prompted"
|
|
768
|
+
|
|
769
|
+
if missing_required:
|
|
770
|
+
missing_list = ", ".join(missing_required)
|
|
771
|
+
raise ValueError(
|
|
772
|
+
f"Missing required values in non-interactive mode: {missing_list}"
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
resolved_variables = [
|
|
776
|
+
ResolvedVariable(
|
|
777
|
+
name=variable.name,
|
|
778
|
+
value=resolved[variable.name],
|
|
779
|
+
source=resolution_sources[variable.name],
|
|
780
|
+
)
|
|
781
|
+
for variable in definition.variables
|
|
782
|
+
if variable.name in resolved and variable.name in resolution_sources
|
|
783
|
+
]
|
|
784
|
+
return VariableResolutionResult(
|
|
785
|
+
values=resolved,
|
|
786
|
+
resolved_variables=resolved_variables,
|
|
787
|
+
missing_required=missing_required,
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def placeholder_map(values: dict[str, str]) -> dict[str, str]:
|
|
792
|
+
"""Map internal variable names to template placeholders."""
|
|
793
|
+
return {
|
|
794
|
+
"{{PROJECT_NAME}}": values.get("project_name", ""),
|
|
795
|
+
"{{DISPLAY_NAME}}": values.get("display_name", ""),
|
|
796
|
+
"{{PACKAGE_NAME}}": values.get("package_name", ""),
|
|
797
|
+
"{{MODULE_NAME}}": values.get("module_name", ""),
|
|
798
|
+
"{{SERVICE_NAME}}": values.get("service_name", ""),
|
|
799
|
+
"{{WORKER_NAME}}": values.get("worker_name", ""),
|
|
800
|
+
"{{RUN_MODE}}": values.get("run_mode", ""),
|
|
801
|
+
"{{TICK_INTERVAL_SECONDS}}": values.get("tick_interval_seconds", ""),
|
|
802
|
+
"{{SHUTDOWN_TIMEOUT_SECONDS}}": values.get("shutdown_timeout_seconds", ""),
|
|
803
|
+
"{{SERVICE_TYPE_NAME}}": values.get("service_type_name", ""),
|
|
804
|
+
"{{HTTP_PORT}}": values.get("http_port", ""),
|
|
805
|
+
"{{GRPC_PORT}}": values.get("grpc_port", ""),
|
|
806
|
+
"{{PROTO_PACKAGE}}": values.get("proto_package", ""),
|
|
807
|
+
"{{DEPENDENCY_STORE}}": values.get("dependency_store", ""),
|
|
808
|
+
"{{DEPENDENCY_STORE_IMAGE}}": values.get("dependency_store_image", ""),
|
|
809
|
+
"{{DEPENDENCY_STORE_PORT}}": values.get("dependency_store_port", ""),
|
|
810
|
+
"{{DEPENDENCY_STORE_DSN}}": values.get("dependency_store_dsn", ""),
|
|
811
|
+
"{{DEPENDENCY_STORE_CONTAINER_DSN}}": values.get("dependency_store_container_dsn", ""),
|
|
812
|
+
"{{DEPENDENCY_STORE_ENV_BLOCK}}": values.get("dependency_store_env_block", ""),
|
|
813
|
+
"{{OTEL_EXPORTER_ENDPOINT}}": values.get("otel_exporter_endpoint", ""),
|
|
814
|
+
"{{APPLICATION_ID}}": values.get("application_id", ""),
|
|
815
|
+
"{{ANDROID_NAMESPACE}}": values.get("android_namespace", ""),
|
|
816
|
+
"{{COMPILE_SDK}}": values.get("compile_sdk", ""),
|
|
817
|
+
"{{MIN_SDK}}": values.get("min_sdk", ""),
|
|
818
|
+
"{{TARGET_SDK}}": values.get("target_sdk", ""),
|
|
819
|
+
"{{VERSION_CODE}}": values.get("version_code", ""),
|
|
820
|
+
"{{VERSION_NAME}}": values.get("version_name", ""),
|
|
821
|
+
"{{JAVA_VERSION}}": values.get("java_version", ""),
|
|
822
|
+
"{{KOTLIN_MODULE_NAME}}": values.get("kotlin_module_name", ""),
|
|
823
|
+
"{{BUNDLE_NAME}}": values.get("bundle_name", ""),
|
|
824
|
+
"{{HARMONY_MODULE_NAME}}": values.get("harmony_module_name", ""),
|
|
825
|
+
"{{ABILITY_NAME}}": values.get("ability_name", ""),
|
|
826
|
+
"{{COMPATIBLE_SDK_VERSION}}": values.get("compatible_sdk_version", ""),
|
|
827
|
+
"{{TARGET_SDK_VERSION}}": values.get("target_sdk_version", ""),
|
|
828
|
+
"{{MIN_API_VERSION}}": values.get("min_api_version", ""),
|
|
829
|
+
"{{HARMONY_VERSION_CODE}}": values.get("harmony_version_code", ""),
|
|
830
|
+
"{{HARMONY_VERSION_NAME}}": values.get("harmony_version_name", ""),
|
|
831
|
+
"{{APPLE_PLATFORM}}": values.get("apple_platform", ""),
|
|
832
|
+
"{{APPLE_PLATFORM_NAME}}": values.get("apple_platform_name", ""),
|
|
833
|
+
"{{FASTLANE_PLATFORM}}": values.get("fastlane_platform", ""),
|
|
834
|
+
"{{APP_STORE_PLATFORM}}": values.get("app_store_platform", ""),
|
|
835
|
+
"{{BUNDLE_IDENTIFIER}}": values.get("bundle_identifier", ""),
|
|
836
|
+
"{{MINIMUM_OS_VERSION}}": values.get("minimum_os_version", ""),
|
|
837
|
+
"{{DEVELOPMENT_TEAM}}": values.get("development_team", ""),
|
|
838
|
+
"{{ORGANIZATION_NAME}}": values.get("organization_name", ""),
|
|
839
|
+
"{{SWIFT_MODULE_NAME}}": values.get("swift_module_name", ""),
|
|
840
|
+
"{{TUIST_DESTINATIONS}}": values.get("tuist_destinations", ""),
|
|
841
|
+
"{{TUIST_DEPLOYMENT_TARGETS}}": values.get("tuist_deployment_targets", ""),
|
|
842
|
+
"{{XCODEBUILD_DESTINATION}}": values.get("xcodebuild_destination", ""),
|
|
843
|
+
"{{SWIFTPM_SUPPORTED_PLATFORM}}": values.get("swiftpm_supported_platform", ""),
|
|
844
|
+
"{{APPLE_SCENE_BODY}}": values.get("apple_scene_body", ""),
|
|
845
|
+
"{{APPLE_HOME_BODY}}": values.get("apple_home_body", ""),
|
|
846
|
+
"{{APPLE_PLATFORM_OUTPUT_NOTE}}": values.get("apple_platform_output_note", ""),
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def render_text(text: str, values: dict[str, str]) -> str:
|
|
851
|
+
"""Replace placeholders in a text snippet."""
|
|
852
|
+
content = text
|
|
853
|
+
for placeholder, value in placeholder_map(values).items():
|
|
854
|
+
content = content.replace(placeholder, value)
|
|
855
|
+
return content
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def render_template(
|
|
859
|
+
definition: TemplateDefinition,
|
|
860
|
+
values: dict[str, str],
|
|
861
|
+
target_dir: Path,
|
|
862
|
+
) -> None:
|
|
863
|
+
"""Copy and render a template into the target directory."""
|
|
864
|
+
if target_dir.exists():
|
|
865
|
+
raise GenerationConflictError(f"target directory already exists: {target_dir}")
|
|
866
|
+
if not target_dir.parent.exists():
|
|
867
|
+
raise GenerationError(f"output directory does not exist: {target_dir.parent}")
|
|
868
|
+
if not target_dir.parent.is_dir():
|
|
869
|
+
raise GenerationError(f"output path is not a directory: {target_dir.parent}")
|
|
870
|
+
|
|
871
|
+
staging_root: Path | None = None
|
|
872
|
+
try:
|
|
873
|
+
staging_root = Path(
|
|
874
|
+
tempfile.mkdtemp(prefix=f".{target_dir.name}.biucing-", dir=target_dir.parent)
|
|
875
|
+
)
|
|
876
|
+
rendered_dir = staging_root / "project"
|
|
877
|
+
shutil.copytree(definition.template_dir, rendered_dir)
|
|
878
|
+
|
|
879
|
+
for path in rendered_dir.rglob("*"):
|
|
880
|
+
if not path.is_file():
|
|
881
|
+
continue
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
content = path.read_text(encoding="utf-8")
|
|
885
|
+
except UnicodeDecodeError:
|
|
886
|
+
continue
|
|
887
|
+
|
|
888
|
+
path.write_text(render_text(content, values), encoding="utf-8")
|
|
889
|
+
if path.name == "gradlew" or "scripts" in path.parts:
|
|
890
|
+
current_mode = path.stat().st_mode
|
|
891
|
+
path.chmod(current_mode | stat.S_IXUSR)
|
|
892
|
+
|
|
893
|
+
if target_dir.exists():
|
|
894
|
+
raise GenerationConflictError(f"target directory already exists: {target_dir}")
|
|
895
|
+
os.replace(rendered_dir, target_dir)
|
|
896
|
+
except BiucingError:
|
|
897
|
+
raise
|
|
898
|
+
except OSError as exc:
|
|
899
|
+
raise GenerationError(f"could not generate project at {target_dir}: {exc}") from exc
|
|
900
|
+
finally:
|
|
901
|
+
if staging_root is not None:
|
|
902
|
+
shutil.rmtree(staging_root, ignore_errors=True)
|