diff --git a/.github/workflows/crates-publish-tauri.yml b/.github/workflows/crates-publish-tauri.yml new file mode 100644 index 0000000..7d2ee78 --- /dev/null +++ b/.github/workflows/crates-publish-tauri.yml @@ -0,0 +1,101 @@ +name: Publish archiet-microcodegen-tauri to crates.io + +on: + push: + tags: + - 'tauri-v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: archiet_microcodegen_tauri + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + archiet_microcodegen_tauri/target + key: ${{ runner.os }}-cargo-tauri-${{ hashFiles('archiet_microcodegen_tauri/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-tauri- + + - name: Verify zero dependencies + run: | + deps=$(cargo metadata --no-deps --format-version 1 | python3 -c " + import json,sys + m = json.load(sys.stdin) + pkg = next(p for p in m['packages'] if p['name'] == 'archiet-microcodegen-tauri') + print(len(pkg['dependencies'])) + ") + echo "Dependency count: $deps" + [ "$deps" -eq 0 ] || { echo "ERROR: generator must have zero runtime dependencies"; exit 1; } + + - name: Build release binary + run: cargo build --release + + - name: Smoke test — sample PRD generates files + run: | + ./target/release/archiet-microcodegen-tauri --sample > /tmp/sample.md + ./target/release/archiet-microcodegen-tauri /tmp/sample.md --out /tmp/tauri-out + echo "=== Generated files ===" + find /tmp/tauri-out -type f | sort + # Verify key files exist + test -f /tmp/tauri-out/src-tauri/Cargo.toml + test -f /tmp/tauri-out/src-tauri/src/lib.rs + test -f /tmp/tauri-out/src-tauri/src/db.rs + test -f /tmp/tauri-out/src-tauri/src/auth.rs + test -f /tmp/tauri-out/src-tauri/tauri.conf.json + test -f /tmp/tauri-out/src/ipc.ts + test -f /tmp/tauri-out/src/pages/Login.tsx + test -f /tmp/tauri-out/ARCHITECTURE.md + test -f /tmp/tauri-out/openapi.yaml + echo "All required files present ✓" + + - name: Smoke test — ZIP output + run: | + ./target/release/archiet-microcodegen-tauri /tmp/sample.md --zip /tmp/tauri-out.zip + python3 -c " + import zipfile, sys + with zipfile.ZipFile('/tmp/tauri-out.zip') as z: + names = z.namelist() + print(f'ZIP contains {len(names)} files') + required = ['src-tauri/Cargo.toml', 'src-tauri/src/auth.rs', 'ARCHITECTURE.md', 'openapi.yaml'] + for r in required: + assert r in names, f'Missing: {r}' + print('ZIP validation passed ✓') + " + + - name: Verify ARCHITECTURE.md has ArchiMate elements + run: | + grep -q "ApplicationComponent" /tmp/tauri-out/ARCHITECTURE.md + grep -q "DataObject" /tmp/tauri-out/ARCHITECTURE.md + echo "ArchiMate elements present ✓" + + - name: Verify per-user isolation in generated commands + run: | + # Every entity command file must filter by user_id + for f in /tmp/tauri-out/src-tauri/src/commands/*_commands.rs; do + grep -q "user_id" "$f" || { echo "FAIL: $f missing user_id filter"; exit 1; } + grep -q "require_session" "$f" || { echo "FAIL: $f missing session check"; exit 1; } + done + echo "Per-user isolation verified ✓" + + - name: Publish to crates.io + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} + run: | + # --allow-dirty is safe here: the only uncommitted file is + # target/.rustc_info.json which cargo build writes during the + # smoke test steps above. It is not part of the published crate + # (excluded by .gitignore / Cargo.toml package exclude). + cargo publish --token "$CARGO_REGISTRY_TOKEN" --allow-dirty diff --git a/.github/workflows/gem-publish-rails.yml b/.github/workflows/gem-publish-rails.yml new file mode 100644 index 0000000..4e77989 --- /dev/null +++ b/.github/workflows/gem-publish-rails.yml @@ -0,0 +1,109 @@ +name: Publish archiet-microcodegen-rails to RubyGems + +# Triggered on a git tag of the form rails-v*.*.* (e.g. rails-v0.1.0). +# Builds and pushes the archiet-microcodegen-rails gem to rubygems.org. +# +# Founder setup (one-time): +# 1. Create an API key at https://rubygems.org/profile/api_keys +# (scope: Push rubygems) +# 2. Add it as repo secret: GEM_HOST_API_KEY +# 3. Tag a release: git tag rails-v0.1.0 && git push origin rails-v0.1.0 + +on: + push: + tags: + - "rails-v*.*.*" + workflow_dispatch: + inputs: + dry_run: + description: "Build gem only do NOT push to RubyGems" + type: boolean + default: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby 3.2 + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + + - name: Verify gem source files exist + run: | + test -f archiet_microcodegen_rails/archiet-microcodegen-rails.gemspec + test -f archiet_microcodegen_rails/lib/archiet_microcodegen_rails.rb + test -f archiet_microcodegen_rails/bin/archiet-microcodegen-rails + + - name: Syntax-check Ruby generator + run: ruby -c archiet_microcodegen_rails/lib/archiet_microcodegen_rails.rb + + - name: Assert gemspec version matches tag + if: startsWith(github.ref, 'refs/tags/') + working-directory: archiet_microcodegen_rails + run: | + GEM_VER=$(ruby -e "require 'rubygems'; s=Gem::Specification.load('archiet-microcodegen-rails.gemspec'); puts s.version") + TAG_VER="${GITHUB_REF_NAME#rails-v}" + echo "gemspec: $GEM_VER tag: $TAG_VER" + [ "$GEM_VER" = "$TAG_VER" ] || { + echo "::error::Version mismatch update s.version in the gemspec to $TAG_VER before tagging." + exit 1 + } + + - name: Build gem + working-directory: archiet_microcodegen_rails + run: | + gem build archiet-microcodegen-rails.gemspec + ls -la *.gem + + - name: Verify gem contents + working-directory: archiet_microcodegen_rails + run: | + GEM_FILE=$(ls *.gem | head -1) + gem contents "$GEM_FILE" --remote 2>/dev/null || gem unpack "$GEM_FILE" --target /tmp/gem-inspect + find /tmp/gem-inspect -name "archiet_microcodegen_rails.rb" | grep -q . || \ + ( gem contents "$GEM_FILE" 2>/dev/null | grep -q "archiet_microcodegen_rails.rb" ) || { + echo "::error::Gem is missing lib/archiet_microcodegen_rails.rb"; exit 1; + } + echo "Gem contents verified." + + - name: Verify GEM_HOST_API_KEY is set + if: startsWith(github.ref, 'refs/tags/') + env: + GEM_HOST_API_KEY: ${{ secrets.GEM_HOST_API_KEY }} + run: | + if [ -z "$GEM_HOST_API_KEY" ]; then + echo "::error::GEM_HOST_API_KEY secret is not set." >&2 + echo "Create at https://rubygems.org/profile/api_keys" >&2 + exit 1 + fi + echo "GEM_HOST_API_KEY present (${#GEM_HOST_API_KEY} chars)" + + - name: Push gem to RubyGems + if: startsWith(github.ref, 'refs/tags/') + working-directory: archiet_microcodegen_rails + env: + GEM_HOST_API_KEY: ${{ secrets.GEM_HOST_API_KEY }} + run: | + GEM_FILE=$(ls *.gem | head -1) + gem push "$GEM_FILE" || { + # "You have already pushed this version" is acceptable + [[ "$(gem push $GEM_FILE 2>&1)" == *"already been pushed"* ]] && echo "::notice::Version already pushed skipping." || exit 1 + } + + - name: Upload gem artifact + uses: actions/upload-artifact@v4 + with: + name: archiet-microcodegen-rails-gem + path: archiet_microcodegen_rails/*.gem + retention-days: 30 + + - name: Dry-run notice + if: github.event_name == 'workflow_dispatch' + run: echo "::notice::dry_run=true gem built but NOT pushed to RubyGems." diff --git a/.github/workflows/go-publish.yml b/.github/workflows/go-publish.yml new file mode 100644 index 0000000..9de5a24 --- /dev/null +++ b/.github/workflows/go-publish.yml @@ -0,0 +1,99 @@ +name: Publish archiet-microcodegen-go to pkg.go.dev + +# pkg.go.dev auto-indexes Go modules from GitHub once a semver tag exists. +# Because the Go module path is github.com/aniekanasuquookono-web/archiet-microcodegen-go, +# the files must live in the separate repo archiet-microcodegen-go (go install requires +# a module at the root of its own repo, not a monorepo subdirectory). +# +# This workflow syncs archiet_microcodegen_go/ -> the external repo and tags it. +# +# Founder setup (one-time): +# 1. Create repo https://github.com/aniekanasuquookono-web/archiet-microcodegen-go +# 2. Create a fine-grained PAT with Contents: Read+Write on that repo +# 3. Add it as secret: MICROCODEGEN_GO_PAT +# 4. Tag a release: git tag go-v0.1.0 && git push origin go-v0.1.0 + +on: + push: + tags: + - "go-v*.*.*" + workflow_dispatch: + inputs: + dry_run: + description: "Sync to external repo but do NOT tag (no pkg.go.dev publish)" + type: boolean + default: true + +permissions: + contents: read + +jobs: + sync-and-tag: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Verify Go source exists + run: | + test -d archiet_microcodegen_go + test -f archiet_microcodegen_go/go.mod + test -f archiet_microcodegen_go/main.go + + - name: Assert go.mod module matches external repo + run: | + MOD=$(grep '^module ' archiet_microcodegen_go/go.mod | awk '{print $2}') + echo "Module path: $MOD" + [ "$MOD" = "github.com/aniekanasuquookono-web/archiet-microcodegen-go" ] || { + echo "::error::go.mod module must be github.com/aniekanasuquookono-web/archiet-microcodegen-go"; exit 1; + } + + - name: Verify MICROCODEGEN_GO_PAT is set + if: startsWith(github.ref, 'refs/tags/') + env: + MICROCODEGEN_GO_PAT: ${{ secrets.MICROCODEGEN_GO_PAT }} + run: | + if [ -z "$MICROCODEGEN_GO_PAT" ]; then + echo "::error::MICROCODEGEN_GO_PAT secret is not set." >&2 + exit 1 + fi + echo "MICROCODEGEN_GO_PAT present" + + - name: Sync to external repo + env: + MICROCODEGEN_GO_PAT: ${{ secrets.MICROCODEGEN_GO_PAT }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + run: | + git config --global user.email "ci@archiet.com" + git config --global user.name "Archiet CI" + git clone https://x-access-token:${MICROCODEGEN_GO_PAT}@github.com/aniekanasuquookono-web/archiet-microcodegen-go ext-go + cp -r archiet_microcodegen_go/. ext-go/ + cd ext-go + git add -A + if git diff --staged --quiet; then + echo "No file changes." + else + git commit -m "chore: sync from archiet monorepo ${GITHUB_SHA::7}" + if [ "$DRY_RUN" != "true" ]; then + git push origin main + else + echo "::notice::dry_run not pushing to external repo." + fi + fi + + - name: Tag external repo + if: startsWith(github.ref, 'refs/tags/') + env: + MICROCODEGEN_GO_PAT: ${{ secrets.MICROCODEGEN_GO_PAT }} + run: | + # Tag format for go install: v0.1.0 (strip the "go-" monorepo prefix) + TAG_VER="${GITHUB_REF_NAME#go-}" + echo "Tagging external repo as $TAG_VER" + cd ext-go + if git rev-parse "$TAG_VER" >/dev/null 2>&1; then + echo "::notice::Tag $TAG_VER already exists in external repo skipping." + else + git tag "$TAG_VER" -m "Release $TAG_VER" + git push origin "$TAG_VER" + echo "::notice::Tagged external repo as $TAG_VER pkg.go.dev will index within minutes." + fi diff --git a/.github/workflows/maven-release-java.yml b/.github/workflows/maven-release-java.yml new file mode 100644 index 0000000..f997205 --- /dev/null +++ b/.github/workflows/maven-release-java.yml @@ -0,0 +1,96 @@ +name: Release archiet-microcodegen-java fat JAR + +# Builds the fat JAR (Maven Shade Plugin) and uploads it to a GitHub Release. +# Developers install with: java -jar archiet-microcodegen-java-.jar prd.md --out ./out/ +# +# Maven Central publishing requires GPG signing + Sonatype OSSRH account. +# That is deferred. GitHub Releases is the MVP distribution channel. +# +# Founder setup (one-time): +# 1. Java 17+ must be installed (set up via setup-java step below) +# 2. No additional secrets required for GitHub Releases +# 3. Tag a release: git tag java-v0.1.0 && git push origin java-v0.1.0 + +on: + push: + tags: + - "java-v*.*.*" + workflow_dispatch: + inputs: + dry_run: + description: "Build JAR only do NOT create GitHub Release" + type: boolean + default: true + +permissions: + contents: write # required for gh release create + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: temurin + cache: maven + + - name: Verify pom.xml exists + run: | + test -f archiet_microcodegen_java/pom.xml + test -f "archiet_microcodegen_java/src/main/java/com/archiet/microcodegen/Main.java" + + - name: Assert pom.xml version matches git tag + if: startsWith(github.ref, 'refs/tags/') + run: | + POM_VER=$(mvn -f archiet_microcodegen_java/pom.xml help:evaluate -Dexpression=project.version -q -DforceStdout) + TAG_VER="${GITHUB_REF_NAME#java-v}" + echo "pom.xml: $POM_VER tag: $TAG_VER" + [ "$POM_VER" = "$TAG_VER" ] || { + echo "::error::pom.xml version ($POM_VER) != tag ($TAG_VER) update pom.xml before tagging." + exit 1 + } + + - name: Build fat JAR + working-directory: archiet_microcodegen_java + run: mvn package -q -DskipTests + + - name: Verify JAR exists and runs --help + working-directory: archiet_microcodegen_java + run: | + JAR=$(ls target/microcodegen-java-*-shaded.jar 2>/dev/null || ls target/archiet-microcodegen-java*.jar 2>/dev/null | head -1) + test -n "$JAR" || { echo "::error::Fat JAR not found in target/"; ls target/; exit 1; } + echo "JAR: $JAR" + java -jar "$JAR" --help 2>&1 | head -5 || true + echo "JAR_PATH=$JAR" >> $GITHUB_ENV + + - name: Create GitHub Release and upload JAR + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${GITHUB_REF_NAME}" + VER="${TAG#java-v}" + JAR="archiet_microcodegen_java/$JAR_PATH" + # Rename for clean download filename + cp "archiet_microcodegen_java/$JAR_PATH" "archiet-microcodegen-java-${VER}.jar" + gh release create "$TAG" \ + --title "archiet-microcodegen-java v${VER}" \ + --notes "## archiet-microcodegen-java v${VER}\n\nPRD -> Spring Boot 3 app -> ZIP. Pure Java stdlib. Zero LLM calls.\n\n### Install\n\`\`\`\ncurl -L https://github.com/\$GITHUB_REPOSITORY/releases/download/${TAG}/archiet-microcodegen-java-${VER}.jar -o archiet-microcodegen-java.jar\n\`\`\`\n\n### Use\n\`\`\`\njava -jar archiet-microcodegen-java.jar prd.md --out ./out/\n\`\`\`" \ + "archiet-microcodegen-java-${VER}.jar" \ + || echo "::notice::Release may already exist skipping." + + - name: Upload JAR artifact (for inspection) + uses: actions/upload-artifact@v4 + with: + name: archiet-microcodegen-java-jar + path: archiet_microcodegen_java/target/*.jar + retention-days: 30 + + - name: Dry-run notice + if: github.event_name == 'workflow_dispatch' + run: echo "::notice::dry_run=true JAR built but GitHub Release NOT created." diff --git a/.github/workflows/npm-publish-nestjs.yml b/.github/workflows/npm-publish-nestjs.yml new file mode 100644 index 0000000..20c9ba3 --- /dev/null +++ b/.github/workflows/npm-publish-nestjs.yml @@ -0,0 +1,90 @@ +name: Publish archiet-microcodegen-nestjs to npm + +# Triggered on a git tag of the form nestjs-v*.*.* (e.g. nestjs-v0.1.0). +# Builds and publishes archiet-microcodegen-nestjs to the npm registry. +# +# Founder setup (one-time): +# 1. Create an npm Publish token at https://www.npmjs.com/settings//tokens +# 2. Add it to this repo as secret: NPM_TOKEN +# 3. Tag a release: git tag nestjs-v0.1.0 && git push origin nestjs-v0.1.0 +# +# workflow_dispatch is ALWAYS dry-run production publishes require a tag push. + +on: + push: + tags: + - "nestjs-v*.*.*" + workflow_dispatch: + inputs: + dry_run: + description: "Build + pack only do NOT publish to npm" + type: boolean + default: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Verify package files exist + run: | + test -d archiet_microcodegen_nestjs + test -f archiet_microcodegen_nestjs/package.json + test -f archiet_microcodegen_nestjs/bin/archiet-microcodegen-nestjs.js + + - name: Assert tag version equals package.json version + if: startsWith(github.ref, 'refs/tags/') + run: | + PKG_VER=$(node -p "require('./archiet_microcodegen_nestjs/package.json').version") + TAG_VER="${GITHUB_REF_NAME#nestjs-v}" + echo "package.json: $PKG_VER tag: $TAG_VER" + [ "$PKG_VER" = "$TAG_VER" ] || { + echo "::error::Version mismatch bump package.json version to $TAG_VER before tagging." + exit 1 + } + + - name: Syntax-check generator + run: node --check archiet_microcodegen_nestjs/bin/archiet-microcodegen-nestjs.js + + - name: npm pack dry-run verify tarball contents + working-directory: archiet_microcodegen_nestjs + run: | + npm pack --dry-run 2>&1 | tee /tmp/pack.txt + grep -q "archiet-microcodegen-nestjs.js" /tmp/pack.txt || { + echo "::error::Packed tarball missing bin/archiet-microcodegen-nestjs.js"; exit 1; + } + echo "Tarball OK." + + - name: Verify NPM_TOKEN secret is set + if: startsWith(github.ref, 'refs/tags/') + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "::error::NPM_TOKEN secret is not set." >&2 + echo "Add it at https://github.com/$GITHUB_REPOSITORY/settings/secrets/actions/new" >&2 + exit 1 + fi + echo "NPM_TOKEN present (${#NPM_TOKEN} chars)" + + - name: Publish to npm + if: startsWith(github.ref, 'refs/tags/') + working-directory: archiet_microcodegen_nestjs + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Dry-run notice + if: github.event_name == 'workflow_dispatch' + run: echo "::notice::dry_run=true verified but NOT published to npm." diff --git a/.github/workflows/nuget-publish-dotnet.yml b/.github/workflows/nuget-publish-dotnet.yml new file mode 100644 index 0000000..a26d299 --- /dev/null +++ b/.github/workflows/nuget-publish-dotnet.yml @@ -0,0 +1,107 @@ +name: Publish archiet-microcodegen-dotnet to NuGet + +# Triggered on a git tag of the form dotnet-v*.*.* (e.g. dotnet-v0.1.0). +# Packs and publishes archiet-microcodegen-dotnet as a dotnet global tool to nuget.org. +# +# Install after publish: +# dotnet tool install -g archiet-microcodegen-dotnet +# archiet-microcodegen-dotnet prd.md --out ./out/ +# +# Founder setup (one-time): +# 1. Create an API key at https://www.nuget.org/account/apikeys +# (scope: Push new packages and package versions) +# 2. Add it as repo secret: NUGET_API_KEY +# 3. Tag a release: git tag dotnet-v0.1.0 && git push origin dotnet-v0.1.0 + +on: + push: + tags: + - "dotnet-v*.*.*" + workflow_dispatch: + inputs: + dry_run: + description: "Pack only do NOT push to NuGet" + type: boolean + default: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Verify project files exist + run: | + test -f archiet_microcodegen_dotnet/archiet-microcodegen-dotnet.csproj + test -f archiet_microcodegen_dotnet/Program.cs + + - name: Assert csproj version matches tag + if: startsWith(github.ref, 'refs/tags/') + run: | + CSPROJ_VER=$(grep '' archiet_microcodegen_dotnet/archiet-microcodegen-dotnet.csproj | sed 's/.*\(.*\)<\/Version>.*/\1/' | tr -d ' ') + TAG_VER="${GITHUB_REF_NAME#dotnet-v}" + echo "csproj: $CSPROJ_VER tag: $TAG_VER" + [ "$CSPROJ_VER" = "$TAG_VER" ] || { + echo "::error::Version mismatch update in csproj to $TAG_VER before tagging." + exit 1 + } + + - name: Pack NuGet tool package + run: | + dotnet pack archiet_microcodegen_dotnet/archiet-microcodegen-dotnet.csproj \ + --configuration Release \ + --output ./nupkg + + - name: Verify NuGet package + run: | + NUPKG=$(ls nupkg/*.nupkg | head -1) + test -n "$NUPKG" || { echo "::error::.nupkg not found"; exit 1; } + echo "Package: $NUPKG" + # Install dotnet-unzip inspection tool + unzip -l "$NUPKG" | grep -E "\.nuspec|tools/" || { + echo "::error::.nupkg is missing .nuspec or tools/ directory"; exit 1; + } + echo "NuGet package verified." + + - name: Verify NUGET_API_KEY is set + if: startsWith(github.ref, 'refs/tags/') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + if [ -z "$NUGET_API_KEY" ]; then + echo "::error::NUGET_API_KEY secret is not set." >&2 + echo "Create at https://www.nuget.org/account/apikeys" >&2 + exit 1 + fi + echo "NUGET_API_KEY present" + + - name: Push to NuGet + if: startsWith(github.ref, 'refs/tags/') + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + NUPKG=$(ls nupkg/*.nupkg | head -1) + dotnet nuget push "$NUPKG" \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + + - name: Upload .nupkg artifact + uses: actions/upload-artifact@v4 + with: + name: archiet-microcodegen-dotnet-nupkg + path: nupkg/*.nupkg + retention-days: 30 + + - name: Dry-run notice + if: github.event_name == 'workflow_dispatch' + run: echo "::notice::dry_run=true .nupkg built but NOT pushed to NuGet." diff --git a/.github/workflows/packagist-publish-laravel.yml b/.github/workflows/packagist-publish-laravel.yml new file mode 100644 index 0000000..3cb6cea --- /dev/null +++ b/.github/workflows/packagist-publish-laravel.yml @@ -0,0 +1,140 @@ +name: Publish archiet-microcodegen-laravel to Packagist + +# Packagist requires composer.json at the root of a standalone GitHub repo. +# Because the Laravel package lives in archiet_microcodegen_laravel/ (monorepo +# subdirectory), this workflow first syncs it to the external standalone repo +# https://github.com/aniekanasuquookono-web/archiet-microcodegen-laravel, +# then pings Packagist to re-index from that repo. +# +# Founder setup (one-time): +# 1. Create PAT with Contents: Read+Write on archiet-microcodegen-laravel +# Add as secret: MICROCODEGEN_LARAVEL_PAT +# 2. Register the package at https://packagist.org/packages/submit +# Use URL: https://github.com/aniekanasuquookono-web/archiet-microcodegen-laravel +# Package name: archiet/microcodegen-laravel +# 3. Get your Packagist API token at https://packagist.org/profile/ +# Add as secret: PACKAGIST_API_TOKEN (username on Packagist: archiet) +# 4. Tag a release: git tag laravel-v0.1.0 && git push origin laravel-v0.1.0 + +on: + push: + tags: + - "laravel-v*.*.*" + workflow_dispatch: + inputs: + dry_run: + description: "Sync to external repo but do NOT ping Packagist" + type: boolean + default: true + +permissions: + contents: read + +jobs: + sync-and-publish: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP 8.2 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + + - name: Verify composer.json and PHP files exist + run: | + test -f archiet_microcodegen_laravel/composer.json + test -f archiet_microcodegen_laravel/bin/archiet-microcodegen-laravel.php + + - name: Syntax-check PHP generator + run: php -l archiet_microcodegen_laravel/bin/archiet-microcodegen-laravel.php + + - name: Validate composer.json + working-directory: archiet_microcodegen_laravel + run: composer validate --no-check-all --no-interaction + + - name: Assert composer.json version matches tag + if: startsWith(github.ref, 'refs/tags/') + run: | + PKG_VER=$(php -r "echo json_decode(file_get_contents('archiet_microcodegen_laravel/composer.json'))->version;") + TAG_VER="${GITHUB_REF_NAME#laravel-v}" + echo "composer.json: $PKG_VER tag: $TAG_VER" + [ "$PKG_VER" = "$TAG_VER" ] || { + echo "::error::Version mismatch - update version in composer.json to $TAG_VER before tagging." + exit 1 + } + + - name: Verify MICROCODEGEN_LARAVEL_PAT is set + env: + MICROCODEGEN_LARAVEL_PAT: ${{ secrets.MICROCODEGEN_LARAVEL_PAT }} + run: | + if [ -z "$MICROCODEGEN_LARAVEL_PAT" ]; then + echo "::error::MICROCODEGEN_LARAVEL_PAT secret is not set." >&2 + exit 1 + fi + echo "MICROCODEGEN_LARAVEL_PAT present" + + - name: Sync to external repo + env: + MICROCODEGEN_LARAVEL_PAT: ${{ secrets.MICROCODEGEN_LARAVEL_PAT }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} + run: | + git config --global user.email "ci@archiet.com" + git config --global user.name "Archiet CI" + git clone https://x-access-token:${MICROCODEGEN_LARAVEL_PAT}@github.com/aniekanasuquookono-web/archiet-microcodegen-laravel ext-laravel + cp -r archiet_microcodegen_laravel/. ext-laravel/ + cd ext-laravel + git add -A + if git diff --staged --quiet; then + echo "No file changes." + else + git commit -m "chore: sync from archiet monorepo ${GITHUB_SHA::7}" + if [ "$DRY_RUN" != "true" ]; then + git push origin main + else + echo "::notice::dry_run - not pushing to external repo." + fi + fi + + - name: Tag external repo + if: startsWith(github.ref, 'refs/tags/') + env: + MICROCODEGEN_LARAVEL_PAT: ${{ secrets.MICROCODEGEN_LARAVEL_PAT }} + run: | + TAG_VER="${GITHUB_REF_NAME#laravel-v}" + echo "Tagging external repo as $TAG_VER" + cd ext-laravel + if git rev-parse "$TAG_VER" >/dev/null 2>&1; then + echo "::notice::Tag $TAG_VER already exists in external repo - skipping." + else + git tag "$TAG_VER" -m "Release $TAG_VER" + git push origin "$TAG_VER" + echo "::notice::Tagged external repo as $TAG_VER" + fi + + - name: Ping Packagist to re-index + if: startsWith(github.ref, 'refs/tags/') + env: + PACKAGIST_API_TOKEN: ${{ secrets.PACKAGIST_API_TOKEN }} + run: | + if [ -z "$PACKAGIST_API_TOKEN" ]; then + echo "::warning::PACKAGIST_API_TOKEN not set - skipping Packagist ping." + echo "Register at https://packagist.org/packages/submit using URL:" + echo " https://github.com/aniekanasuquookono-web/archiet-microcodegen-laravel" + exit 0 + fi + HTTP=$(curl -s -o /tmp/packagist_resp.json -w "%{http_code}" \ + -X POST "https://packagist.org/api/update-package?username=archiet&apiToken=${PACKAGIST_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"repository":{"url":"https://github.com/aniekanasuquookono-web/archiet-microcodegen-laravel"}}') + echo "Packagist HTTP status: $HTTP" + cat /tmp/packagist_resp.json + [ "$HTTP" = "202" ] || [ "$HTTP" = "200" ] || { + echo "::error::Packagist ping failed (HTTP $HTTP)"; exit 1; + } + echo "::notice::Packagist re-index triggered for archiet/microcodegen-laravel." + + - name: Dry-run notice + if: github.event_name == 'workflow_dispatch' + run: echo "::notice::dry_run=true - validated and synced to external repo, but NOT pinged Packagist." diff --git a/.github/workflows/pypi-publish-django.yml b/.github/workflows/pypi-publish-django.yml new file mode 100644 index 0000000..e59d918 --- /dev/null +++ b/.github/workflows/pypi-publish-django.yml @@ -0,0 +1,108 @@ +name: Publish archiet-microcodegen-django to PyPI + +# Triggered on a git tag of the form django-v*.*.* (e.g. django-v0.1.0). +# Builds the wheel + sdist for archiet-microcodegen-django and uploads to PyPI. +# +# Founder setup (one-time): +# 1. Create a PyPI API token at https://pypi.org/manage/account/token/ +# (scope: "Entire account" for first publish; tighten to project after) +# 2. Add it to this repo's secrets as PYPI_API_TOKEN_DJANGO +# 3. Tag a release: git tag django-v0.1.0 && git push origin django-v0.1.0 + +on: + push: + tags: + - "django-v*.*.*" + workflow_dispatch: + inputs: + dry_run: + description: "Build only — do NOT upload to PyPI" + type: boolean + default: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + timeout-minutes: 10 + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Verify PYPI_API_TOKEN is set (fail loud if missing) + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + if [ -z "$PYPI_API_TOKEN" ]; then + echo "::error::PYPI_API_TOKEN secret is not set." >&2 + echo "" >&2 + echo "Add it at: https://github.com/$GITHUB_REPOSITORY/settings/secrets/actions/new" >&2 + echo "Generate a token at: https://pypi.org/manage/account/token/" >&2 + exit 1 + fi + echo "PYPI_API_TOKEN present (length: ${#PYPI_API_TOKEN})" + + - name: Install build tooling + run: python -m pip install --upgrade pip build twine + + - name: Sanity-check Django _core.py is present and parses + run: | + test -f archiet_microcodegen_django/_core.py + python -c "import ast; ast.parse(open('archiet_microcodegen_django/_core.py').read())" + echo "archiet_microcodegen_django/_core.py parses cleanly." + + - name: Activate Django pyproject.toml (runner is ephemeral — safe to overwrite) + run: cp pyproject-django.toml pyproject.toml + + - name: Build wheel + sdist + run: python -m build + + - name: Verify wheel contains Django _core.py with the algorithm + run: | + set -e + WHEEL=$(ls dist/*.whl | head -1) + echo "Inspecting $WHEEL" + python -m zipfile -l "$WHEEL" | grep -E "_core\.py" || { + echo "::error::Wheel is missing archiet_microcodegen_django/_core.py" >&2 + exit 1 + } + mkdir -p _wheel_check + python -m zipfile -e "$WHEEL" _wheel_check + grep -q "def microcodegen" _wheel_check/archiet_microcodegen_django/_core.py + grep -q "def parse_prd" _wheel_check/archiet_microcodegen_django/_core.py + grep -q "def render_genome" _wheel_check/archiet_microcodegen_django/_core.py + grep -q "Django" _wheel_check/archiet_microcodegen_django/_core.py + echo "Wheel contents verified." + + - name: Twine check (metadata + long_description rendering) + run: python -m twine check dist/* + + - name: Publish to PyPI + if: ${{ github.event.inputs.dry_run != 'true' }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + + - name: Dry-run notice + if: ${{ github.event.inputs.dry_run == 'true' }} + run: | + echo "::notice::dry_run=true — wheel built but NOT uploaded to PyPI." + echo "Artifacts:" + ls -la dist/ + + - name: Upload built artifacts (for inspection) + uses: actions/upload-artifact@v4 + with: + name: dist-django + path: dist/* + retention-days: 30 diff --git a/.github/workflows/pypi-publish-flask.yml b/.github/workflows/pypi-publish-flask.yml new file mode 100644 index 0000000..9687f8f --- /dev/null +++ b/.github/workflows/pypi-publish-flask.yml @@ -0,0 +1,108 @@ +name: Publish archiet-microcodegen-flask to PyPI + +# Triggered on a git tag of the form flask-v*.*.* (e.g. flask-v0.1.0). +# Builds the wheel + sdist for archiet-microcodegen-flask and uploads to PyPI. +# +# Founder setup (one-time): +# 1. Create a PyPI API token at https://pypi.org/manage/account/token/ +# (scope: "Entire account" for first publish; tighten to project after) +# 2. Add it to this repo's secrets as PYPI_API_TOKEN_FLASK +# 3. Tag a release: git tag flask-v0.1.0 && git push origin flask-v0.1.0 + +on: + push: + tags: + - "flask-v*.*.*" + workflow_dispatch: + inputs: + dry_run: + description: "Build only — do NOT upload to PyPI" + type: boolean + default: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + timeout-minutes: 10 + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Verify PYPI_API_TOKEN is set (fail loud if missing) + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: | + if [ -z "$PYPI_API_TOKEN" ]; then + echo "::error::PYPI_API_TOKEN secret is not set." >&2 + echo "" >&2 + echo "Add it at: https://github.com/$GITHUB_REPOSITORY/settings/secrets/actions/new" >&2 + echo "Generate a token at: https://pypi.org/manage/account/token/" >&2 + exit 1 + fi + echo "PYPI_API_TOKEN present (length: ${#PYPI_API_TOKEN})" + + - name: Install build tooling + run: python -m pip install --upgrade pip build twine + + - name: Sanity-check Flask _core.py is present and parses + run: | + test -f archiet_microcodegen_flask/_core.py + python -c "import ast; ast.parse(open('archiet_microcodegen_flask/_core.py').read())" + echo "archiet_microcodegen_flask/_core.py parses cleanly." + + - name: Activate Flask pyproject.toml (runner is ephemeral — safe to overwrite) + run: cp pyproject-flask.toml pyproject.toml + + - name: Build wheel + sdist + run: python -m build + + - name: Verify wheel contains Flask _core.py with the algorithm + run: | + set -e + WHEEL=$(ls dist/*.whl | head -1) + echo "Inspecting $WHEEL" + python -m zipfile -l "$WHEEL" | grep -E "_core\.py" || { + echo "::error::Wheel is missing archiet_microcodegen_flask/_core.py" >&2 + exit 1 + } + mkdir -p _wheel_check + python -m zipfile -e "$WHEEL" _wheel_check + grep -q "def microcodegen" _wheel_check/archiet_microcodegen_flask/_core.py + grep -q "def parse_prd" _wheel_check/archiet_microcodegen_flask/_core.py + grep -q "def render_genome" _wheel_check/archiet_microcodegen_flask/_core.py + grep -q "Flask" _wheel_check/archiet_microcodegen_flask/_core.py + echo "Wheel contents verified." + + - name: Twine check (metadata + long_description rendering) + run: python -m twine check dist/* + + - name: Publish to PyPI + if: ${{ github.event.inputs.dry_run != 'true' }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + + - name: Dry-run notice + if: ${{ github.event.inputs.dry_run == 'true' }} + run: | + echo "::notice::dry_run=true — wheel built but NOT uploaded to PyPI." + echo "Artifacts:" + ls -la dist/ + + - name: Upload built artifacts (for inspection) + uses: actions/upload-artifact@v4 + with: + name: dist-flask + path: dist/* + retention-days: 30 diff --git a/.github/workflows/pypi-publish-mcp.yml b/.github/workflows/pypi-publish-mcp.yml new file mode 100644 index 0000000..4a8585b --- /dev/null +++ b/.github/workflows/pypi-publish-mcp.yml @@ -0,0 +1,138 @@ +name: mcp-archiet — Publish to PyPI + +on: + push: + tags: + - "mcp-v*.*.*" + workflow_dispatch: + inputs: + ref: + description: "Git ref (tag or sha) to build & publish" + required: false + default: "" + +concurrency: + group: pypi-publish-mcp-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + build-and-publish: + name: Build & publish mcp-archiet + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/mcp-archiet/ + defaults: + run: + working-directory: mcp_archiet + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + fetch-depth: 1 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build tooling + run: | + python -m pip install --upgrade pip + python -m pip install build twine + + - name: Verify tag matches pyproject version + if: startsWith(github.ref, 'refs/tags/mcp-v') + run: | + TAG_VERSION="${GITHUB_REF_NAME#mcp-v}" + PKG_VERSION=$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match pyproject.toml version ($PKG_VERSION)" + exit 1 + fi + echo "Tag $GITHUB_REF_NAME matches pyproject version $PKG_VERSION" + + - name: Build sdist + wheel + run: python -m build + + - name: Validate distributions + run: python -m twine check dist/* + + - name: Upload distributions as workflow artifact + uses: actions/upload-artifact@v4 + with: + name: mcp-archiet-${{ github.ref_name }} + path: mcp_archiet/dist/* + retention-days: 30 + + - name: Verify PYPI_API_TOKEN_MICROCODEGEN is set (fail loud if missing) + env: + PYPI_API_TOKEN_MICROCODEGEN: ${{ secrets.PYPI_API_TOKEN_MICROCODEGEN }} + run: | + if [ -z "$PYPI_API_TOKEN_MICROCODEGEN" ]; then + echo "::error::PYPI_API_TOKEN_MICROCODEGEN secret is not set." >&2 + exit 1 + fi + echo "PYPI_API_TOKEN_MICROCODEGEN present (length: ${#PYPI_API_TOKEN_MICROCODEGEN})" + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_MICROCODEGEN }} + run: python -m twine upload --non-interactive --skip-existing dist/* + + publish-to-mcp-registry: + name: Publish to MCP Registry + needs: build-and-publish + runs-on: ubuntu-latest + # Only auto-publish to the registry on tag pushes. The README-PR submission + # path is retired (2026-05) — registry uses mcp-publisher CLI + github-oidc. + if: startsWith(github.ref, 'refs/tags/mcp-v') + permissions: + contents: read + # id-token: write is REQUIRED for mcp-publisher's github-oidc auth. + # The token's sub claim ties the workflow to repo aniekanasuquookono-web/archiet, + # which grants the io.github.aniekanasuquookono-web/* namespace. + id-token: write + defaults: + run: + working-directory: mcp_archiet + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + fetch-depth: 1 + + - name: Wait for PyPI to index the new version + # PyPI typically indexes within seconds, but the registry validator + # checks the package exists + the README's mcp-name: marker matches. + # 90s is the published-recommended grace window. + run: sleep 90 + + - name: Install mcp-publisher CLI + run: | + MCP_PUBLISHER_VERSION="v1.7.9" + curl -sSL \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" \ + | tar -xz mcp-publisher + sudo mv mcp-publisher /usr/local/bin/mcp-publisher + mcp-publisher --version || mcp-publisher --help | head -1 + + - name: Validate server.json + run: mcp-publisher validate + + - name: Authenticate with MCP Registry (GitHub OIDC) + run: mcp-publisher login github-oidc + + - name: Publish to MCP Registry + run: mcp-publisher publish + + - name: Confirm listing + run: | + echo "Server published. Verify at:" + echo " https://registry.modelcontextprotocol.io/servers/io.github.aniekanasuquookono-web/archiet" diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..910aa12 --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,111 @@ +name: Publish archiet-microcodegen to PyPI + +# Triggered on a git tag of the form v*.*.* (e.g. v0.1.0). +# Builds the wheel + sdist for archiet-microcodegen and uploads them to PyPI. +# +# Founder setup (one-time): +# 1. Create a PyPI API token at https://pypi.org/manage/account/token/ +# (scope: "Entire account" for first publish; tighten to project after) +# 2. Add it to this repo's secrets as PYPI_API_TOKEN_MICROCODEGEN +# 3. Tag a release: git tag v0.1.0 && git push origin v0.1.0 +# +# Without PYPI_API_TOKEN_MICROCODEGEN set, the workflow fails loudly with a clear message +# before attempting any build work. + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + dry_run: + description: "Build only — do NOT upload to PyPI" + type: boolean + default: true + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + timeout-minutes: 10 + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Verify PYPI_API_TOKEN_MICROCODEGEN is set (fail loud if missing) + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + PYPI_API_TOKEN_MICROCODEGEN: ${{ secrets.PYPI_API_TOKEN_MICROCODEGEN }} + run: | + if [ -z "$PYPI_API_TOKEN_MICROCODEGEN" ]; then + echo "::error::PYPI_API_TOKEN_MICROCODEGEN secret is not set." >&2 + echo "" >&2 + echo "Add it at: https://github.com/$GITHUB_REPOSITORY/settings/secrets/actions/new" >&2 + echo "Generate a token at: https://pypi.org/manage/account/token/" >&2 + exit 1 + fi + echo "PYPI_API_TOKEN_MICROCODEGEN present (length: ${#PYPI_API_TOKEN_MICROCODEGEN})" + + - name: Install build tooling + run: python -m pip install --upgrade pip build twine + + - name: Sanity-check microcodegen.py is unchanged in shape + # The package wraps scripts/microcodegen.py — if it's gone or moved + # the build will silently ship a broken wheel. Fail early instead. + run: | + test -f scripts/microcodegen.py + python -c "import ast; ast.parse(open('scripts/microcodegen.py').read())" + echo "scripts/microcodegen.py parses cleanly." + + - name: Build wheel + sdist + run: python -m build + + - name: Verify wheel contains _core.py with the algorithm + run: | + set -e + WHEEL=$(ls dist/*.whl | head -1) + echo "Inspecting $WHEEL" + python -m zipfile -l "$WHEEL" | grep -E "_core\.py" || { + echo "::error::Wheel is missing archiet_microcodegen/_core.py" >&2 + exit 1 + } + # Unpack and assert the algorithm symbols are present. + mkdir -p _wheel_check + python -m zipfile -e "$WHEEL" _wheel_check + grep -q "def microcodegen" _wheel_check/archiet_microcodegen/_core.py + grep -q "def parse_prd" _wheel_check/archiet_microcodegen/_core.py + grep -q "def render_genome" _wheel_check/archiet_microcodegen/_core.py + echo "Wheel contents verified." + + - name: Twine check (metadata + long_description rendering) + run: python -m twine check dist/* + + - name: Publish to PyPI + if: ${{ github.event.inputs.dry_run != 'true' }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN_MICROCODEGEN }} + # skip_existing avoids hard-failing on a re-run of the same tag. + skip-existing: true + + - name: Dry-run notice + if: ${{ github.event.inputs.dry_run == 'true' }} + run: | + echo "::notice::dry_run=true — wheel built but NOT uploaded to PyPI." + echo "Artifacts:" + ls -la dist/ + + - name: Upload built artifacts (for inspection) + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/* + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fce7870 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Build output — never commit. The Tauri renderer previously carried 251 +# tracked files and 17.3 MB of Rust build artifacts into git; the actual +# source is four files totalling 22 KB. +target/ +dist/ +build/ +*.egg-info/ +__pycache__/ +*.py[cod] +node_modules/ +vendor/ +bin/ +obj/ +.venv/ +venv/ +*.class +*.jar +!**/gradle/wrapper/gradle-wrapper.jar diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fcb75b4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Aniekan Asuquo Okono + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..e198e39 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,91 @@ +# Publishing + +This repository is the single source for every microcodegen package. It was +consolidated here on 2026-08-05 from `aniekanasuquookono-web/archiet`, which is +being archived — and archiving stops GitHub Actions, so these pipelines had to +move or the live packages would have frozen. + +## Releases are tag-triggered + +Each package publishes on its own tag prefix. Nothing publishes on a push to +`main`. + +| Tag | Package | Registry | Source | +|---|---|---|---| +| *(see workflow)* | `archiet-microcodegen` | PyPI | `scripts/microcodegen.py` | +| `django-v*.*.*` | `archiet-microcodegen-django` | PyPI | `archiet_microcodegen_django/` | +| `flask-v*.*.*` | `archiet-microcodegen-flask` | PyPI | `archiet_microcodegen_flask/` | +| `mcp-v*.*.*` | `mcp-archiet` | PyPI | *(MCP server)* | +| `nestjs-v*.*.*` | `archiet-microcodegen-nestjs` | npm | `archiet_microcodegen_nestjs/` | +| `tauri-v*` | `archiet-microcodegen-tauri` | crates.io | `archiet_microcodegen_tauri/` | +| `dotnet-v*.*.*` | `archiet-microcodegen-dotnet` | NuGet | `archiet_microcodegen_dotnet/` | +| `rails-v*.*.*` | `archiet-microcodegen-rails` | RubyGems | `archiet_microcodegen_rails/` | +| `java-v*.*.*` | `archiet-microcodegen-java` | GitHub release | `archiet_microcodegen_java/` | +| `go-v*.*.*` | `archiet-microcodegen-go` | pkg.go.dev | `archiet_microcodegen_go/` | +| `laravel-v*.*.*` | `archiet-microcodegen-laravel` | Packagist | `archiet_microcodegen_laravel/` | + +Plus this repo's own `microcodegen` package, built from `microcodegen.py` at +the root by `.github/workflows/publish-pypi.yml`. + +## Required secrets — nothing publishes until these exist + +These lived on `aniekanasuquookono-web/archiet` and **must be recreated here** +(Settings → Secrets and variables → Actions). Until then every release workflow +fails at its credential check. + +| Secret | Used by | +|---|---| +| `PYPI_API_TOKEN_MICROCODEGEN` | `pypi-publish.yml`, `pypi-publish-mcp.yml` | +| `PYPI_API_TOKEN` | `pypi-publish-django.yml`, `pypi-publish-flask.yml` | +| `NPM_TOKEN` | `npm-publish-nestjs.yml` | +| `CRATES_IO_TOKEN` | `crates-publish-tauri.yml` | +| `NUGET_API_KEY` | `nuget-publish-dotnet.yml` | +| `GEM_HOST_API_KEY` | `gem-publish-rails.yml` | +| `MICROCODEGEN_GO_PAT` | `go-publish.yml` — pushes to the Go mirror repo | +| `MICROCODEGEN_LARAVEL_PAT` + `PACKAGIST_API_TOKEN` | `packagist-publish-laravel.yml` | + +`maven-release-java.yml` needs none — it attaches a fat JAR to a GitHub release. + +## The two mirror repositories stay where they are + +`go-publish.yml` and `packagist-publish-laravel.yml` **sync into** separate +repos rather than publishing from here, because Go module paths and Packagist +package names are bound to a repository URL: + +- `aniekanasuquookono-web/archiet-microcodegen-go` +- `aniekanasuquookono-web/archiet-microcodegen-laravel` + +Do not move or rename those. Changing the Go module path breaks `go install` +for anyone who already has it. They are publish targets, not sources — the +source of truth is `archiet_microcodegen_go/` and `archiet_microcodegen_laravel/` +in this repository. + +## Two Python packages, deliberately + +| Package | Built from | Generates | Version | +|---|---|---|---| +| `microcodegen` | `microcodegen.py` (root) | **Flask** | 0.1.0 | +| `archiet-microcodegen` | `scripts/microcodegen.py` | **FastAPI** | 0.2.3 | + +Both are live on PyPI. They diverged while the code lived in two repositories: +the root file is 1,122 lines and emits Flask; `scripts/` is 1,396 lines, emits +FastAPI, and tolerates numbered PRD section headings the older one rejects. + +They are kept as separate packages on purpose. Collapsing them would either +change what `pip install microcodegen` produces — Flask today, FastAPI after — +or force a version regression on `archiet-microcodegen` from 0.2.3 back to +0.1.0. Neither is worth doing to existing users for tidiness. + +Reconciling the two engines is a real piece of work, not a merge. Do it as its +own change, with a major version bump and a migration note, when someone +actually wants it. + +## Before the first release from this repository + +1. Recreate the secrets above. +2. Run one workflow with `dry_run: true` — several support it — and confirm the + artifact builds before trusting the rest. +3. Only then archive `aniekanasuquookono-web/archiet`. + +Archiving before step 2 freezes four live PyPI packages, an npm package and the +rest at their current versions with no way to ship a fix. diff --git a/archiet_microcodegen_django/README.md b/archiet_microcodegen_django/README.md new file mode 100644 index 0000000..559ba93 --- /dev/null +++ b/archiet_microcodegen_django/README.md @@ -0,0 +1,118 @@ +# archiet-microcodegen-django + +> PRD text → working Django REST Framework app → ZIP, in <1400 LOC, pure stdlib, zero LLM calls. +> Inspired by Karpathy's micrograd: this file is the complete algorithm. + +[![PyPI](https://img.shields.io/pypi/v/archiet-microcodegen-django)](https://pypi.org/project/archiet-microcodegen-django/) + +## Install + +```bash +pip install archiet-microcodegen-django +``` + +## Use + +```bash +# Write ZIP to stdout +archiet-microcodegen-django path/to/prd.md > app.zip + +# Extract directly to a directory +archiet-microcodegen-django path/to/prd.md --out ./out/ +``` + +As a library: + +```python +from archiet_microcodegen_django import microcodegen_django + +prd_text = open("prd.md").read() +zip_bytes = microcodegen_django(prd_text) +open("app.zip", "wb").write(zip_bytes) +``` + +## What you get + +The generated ZIP is a working Django 5 + PostgreSQL app with: + +| File | Purpose | +|------|---------| +| `manage.py` | Standard Django management entry point | +| `/settings.py` | DATABASES, INSTALLED_APPS, REST_FRAMEWORK, SIMPLE_JWT | +| `/urls.py` | DefaultRouter + auth endpoints wired | +| `/wsgi.py` | Gunicorn-compatible WSGI application | +| `apps/accounts/models.py` | Custom User model (UUID PK, email login) | +| `apps/accounts/authentication.py` | `JWTCookieAuthentication` — reads httpOnly cookie | +| `apps/accounts/views.py` | register / login / logout / me / health | +| `apps//models.py` | Django Model with `user = ForeignKey(User)` for tenant isolation | +| `apps//serializers.py` | `ModelSerializer` with all fields | +| `apps//views.py` | `ModelViewSet` with `get_queryset` filtered by `request.user` | +| `apps//urls.py` | `DefaultRouter` registration | +| `requirements.txt` | Django, DRF, simplejwt, psycopg2-binary, gunicorn, PyJWT, django-cors-headers | +| `Dockerfile` | Python 3.12-slim, gunicorn production server | +| `docker-compose.yml` | Postgres 16 with healthcheck-gated startup | +| `.env.example` | Pre-populated with generated secrets | +| `ARCHITECTURE.md` | ArchiMate 3.2 element map | +| `openapi.yaml` | OpenAPI 3.1 spec — importable into Postman / Swagger UI | +| `GENOME.json` | The intermediate representation that drove rendering | + +## Auth design (non-negotiable) + +JWT is set as an **httpOnly, SameSite=Lax cookie** — never in the response body, never in localStorage. This prevents XSS token theft without requiring a custom Authorization header. + +- `POST /api/auth/register` → sets cookie, returns `{user: {id, email}}` +- `POST /api/auth/login` → sets cookie, returns `{user: {id, email}}` +- `POST /api/auth/logout` → deletes cookie +- `GET /api/auth/me` → returns current user (cookie required) + +## The four stages + +1. **`parse_prd(text) → manifest`** — regex extraction of entities, fields, user stories, integrations (verbatim from `scripts/microcodegen.py`) +2. **`manifest_to_genome(manifest) → genome`** — maps to the canonical ArchiMate 3.2-typed IR (verbatim from `scripts/microcodegen.py`) +3. **`render_genome(genome) → {path: content}`** — Django DRF rendering (this package's contribution) +4. **`pack(files) → bytes`** — stdlib `zipfile` (verbatim from `scripts/microcodegen.py`) + +## Per-tenant data isolation + +Every generated Model has a `user = ForeignKey(User, on_delete=CASCADE)` field. Every ViewSet overrides `get_queryset` to filter by `request.user`. Cross-user data access is structurally impossible via the generated API. + +## Quick start (generated app) + +```bash +cp .env.example .env +docker compose up +curl http://localhost:8000/health/ + +# Register +curl -c cookies.txt -X POST http://localhost:8000/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"you@example.com","password":"hunter22hunter"}' + +# Create an entity +curl -b cookies.txt -X POST http://localhost:8000/api/items/ \ + -H "Content-Type: application/json" \ + -d '{"name":"My first item"}' +``` + +## Why this exists + +Spec-driven architecture before vibecoding. The genome is an ArchiMate 3.2 intermediate representation — your PRD becomes an architecture document, not just a prompt. + +The full [Archiet](https://archiet.com?utm_source=pypi&utm_medium=package&utm_campaign=microcodegen-django) platform adds: + +- LLM-powered PRD extraction (chunked, overlap+dedup) +- 12+ stack renderers (FastAPI, Django, NestJS, Go, Java, Rails, .NET, Tauri+Rust, Salesforce, SAP CAP, Dynamics, Laravel) +- Frontend (Next.js / Expo), quality scoring, delivery gates, formal ArchiMate 3.2 models + +But none of that changes the **core algorithm**. If a bug doesn't reproduce here, it's in an efficiency layer. + +## Links + +- **SDD guide:** [github.com/Anioko/spec-driven-development](https://github.com/Anioko/spec-driven-development) +- **Compliance guide:** [github.com/Anioko/compliance-from-architecture](https://github.com/Anioko/compliance-from-architecture) — SOC 2, GDPR, **EU AI Act Annex IV** +- **EU AI Act (deadline Aug 2026):** [Free risk classifier](https://archiet.com/tools/eu-ai-act-risk-classifier) · [Annex IV use case](https://archiet.com/use-cases/eu-ai-act-high-risk-ai-compliance) +- Full platform: [archiet.com](https://archiet.com?utm_source=pypi&utm_medium=package&utm_campaign=microcodegen-django) + +## License + +MIT. diff --git a/archiet_microcodegen_django/__init__.py b/archiet_microcodegen_django/__init__.py new file mode 100644 index 0000000..137ad4b --- /dev/null +++ b/archiet_microcodegen_django/__init__.py @@ -0,0 +1,39 @@ +"""archiet-microcodegen-django — PRD text → Django REST Framework app ZIP. + +Pure stdlib. Zero dependencies. Zero LLM calls. + +Public API: + from archiet_microcodegen_django import microcodegen_django + from archiet_microcodegen_django import microcodegen # alias + from archiet_microcodegen_django import parse_prd, manifest_to_genome + from archiet_microcodegen_django import render_genome, pack, main + +CLI: + archiet-microcodegen-django path/to/prd.md > app.zip + archiet-microcodegen-django path/to/prd.md --out ./out/ +""" + +from __future__ import annotations + +from archiet_microcodegen_django._core import ( + main, + manifest_to_genome, + microcodegen, + microcodegen_django, + pack, + parse_prd, + render_genome, +) + +__version__ = "0.1.0" + +__all__ = [ + "__version__", + "main", + "manifest_to_genome", + "microcodegen", + "microcodegen_django", + "pack", + "parse_prd", + "render_genome", +] diff --git a/archiet_microcodegen_django/__main__.py b/archiet_microcodegen_django/__main__.py new file mode 100644 index 0000000..4bd05bd --- /dev/null +++ b/archiet_microcodegen_django/__main__.py @@ -0,0 +1,11 @@ +"""Allow `python -m archiet_microcodegen_django` invocation.""" + +from __future__ import annotations + +import sys + +from archiet_microcodegen_django import main + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/archiet_microcodegen_django/_core.py b/archiet_microcodegen_django/_core.py new file mode 100644 index 0000000..801bbe0 --- /dev/null +++ b/archiet_microcodegen_django/_core.py @@ -0,0 +1,1260 @@ +#!/usr/bin/env python3 +"""_core.py — archiet-microcodegen-django. + +PRD text → manifest → genome → Django REST Framework app → ZIP bytes. + +Contract: microcodegen_django(prd_text) → bytes (working, bootable Django ZIP) + + # CLI: python -m archiet_microcodegen_django prd.md > app.zip + # python -m archiet_microcodegen_django prd.md --out /tmp/myapp/ + # Lib: from archiet_microcodegen_django import microcodegen_django + +Stages: + 1. parse_prd(text) → manifest dict (copied verbatim from microcodegen.py) + 2. manifest_to_genome(manifest) → genome dict (copied verbatim) + 3. render_genome(genome) → {path: content} (Django DRF rendering — THIS FILE) + 4. pack(files) → bytes (stdlib zipfile — copied verbatim) + +Auth contract (non-negotiable): + - JWT via httpOnly cookies: Set-Cookie: access_token=...; HttpOnly; SameSite=Lax + - NEVER localStorage, NEVER Authorization: Bearer in response body + - Login: POST /api/auth/login → sets httpOnly cookie, returns {user: {...}} + - Protected endpoints: JWTCookieAuthentication reads cookie + +Constraints: + - Pure stdlib; zero app.* / agents.* / templates/ imports in this file. + - Hard ceiling: 1400 LOC. + - No LLM calls — purely deterministic regex + string.Template rendering. +""" + +from __future__ import annotations + +import argparse +import io +import json +import re +import secrets +import string +import sys +import zipfile +from pathlib import Path + +# ─── STAGE 1 ──────────────────────────────────────────────────────────────── +# parse_prd(text) → manifest dict. +# Copied verbatim from scripts/microcodegen.py — language-agnostic. + +_ENTITY_PATTERN = re.compile( + r"^#{1,3}\s*(?:entities|data models|domain models|entity list)\s*:?\s*$", + re.IGNORECASE | re.MULTILINE, +) + +_ENTITY_NAME_PATTERN = re.compile( + r"^[\s\-\*\#]+\*{0,2}([A-Z][a-zA-Z0-9_]{1,40})\*{0,2}[ \t]*(?::|—|-|[ \t]|$)", + re.MULTILINE, +) + +_FIELD_PATTERN = re.compile( + r"^[\s\-\*]+([a-z_][a-z0-9_]{0,40})\s*[:—-]\s*([a-zA-Z]+)([^\n]*)", + re.MULTILINE, +) + +_INLINE_FIELD_PATTERN = re.compile( + r"([a-z_][a-z0-9_]{0,40})\s*\(\s*([a-zA-Z]+)([^)]*)\)", +) + +_USER_STORY_PATTERN = re.compile( + r"As\s+(?:a|an)\s+([^,]+?),\s+I\s+want\s+(?:to\s+)?([^,]+?)(?:,?\s*so\s+that\s+([^.]+))?\.{1}", + re.IGNORECASE, +) + +_INTEGRATION_KEYWORDS = { + "stripe": {"name": "stripe", "category": "payments"}, + "auth0": {"name": "auth0", "category": "auth"}, + "clerk": {"name": "clerk", "category": "auth"}, + "supabase": {"name": "supabase", "category": "auth"}, + "sendgrid": {"name": "sendgrid", "category": "email"}, + "postmark": {"name": "postmark", "category": "email"}, + "twilio": {"name": "twilio", "category": "sms"}, + "datadog": {"name": "datadog", "category": "observability"}, + "segment": {"name": "segment", "category": "analytics"}, +} + + +def _parse_field_modifiers(modifier_text: str) -> dict: + flags = {"required": False, "unique": False, "indexed": False} + text = modifier_text.lower() + if "required" in text or "not null" in text or " ! " in text: + flags["required"] = True + if "unique" in text: + flags["unique"] = True + if "indexed" in text or "index" in text: + flags["indexed"] = True + return flags + + +def _solution_name_from_prd(text: str) -> str: + m = re.match(r"^#\s+(.+?)\s*$", text, re.MULTILINE) + if m: + return m.group(1).strip() + return "Generated App" + + +def parse_prd(text: str) -> dict: + """Extract a manifest dict from raw PRD text. Verbatim from microcodegen.py.""" + solution_name = _solution_name_from_prd(text) + + entities: list[dict] = [] + section_match = _ENTITY_PATTERN.search(text) + entity_section = "" + if section_match: + start = section_match.end() + next_header = re.search(r"^#{1,2}\s+\S", text[start:], re.MULTILINE) + end = start + next_header.start() if next_header else len(text) + entity_section = text[start:end] + + seen: set[str] = set() + for m in _ENTITY_NAME_PATTERN.finditer(entity_section): + ename = m.group(1) + if ename in seen: + continue + seen.add(ename) + ent_start = m.end() + next_entity = _ENTITY_NAME_PATTERN.search(entity_section, ent_start) + ent_end = next_entity.start() if next_entity else len(entity_section) + ent_body = entity_section[ent_start:ent_end] + + fields: list[dict] = [] + seen_fields: set[str] = set() + for fm in _FIELD_PATTERN.finditer(ent_body): + fname, ftype, modifier_text = fm.group(1), fm.group(2), fm.group(3) + if fname in seen: + continue + if fname in seen_fields: + continue + seen_fields.add(fname) + flags = _parse_field_modifiers(modifier_text) + fields.append({"name": fname, "type": ftype.lower(), **flags}) + + if not fields: + entity_name_line = ( + entity_section[m.start(): m.end()] + ent_body.split("\n", 1)[0] + ) + for im in _INLINE_FIELD_PATTERN.finditer(entity_name_line): + fname, ftype, modifier_text = im.group(1), im.group(2), im.group(3) + if fname in seen_fields: + continue + seen_fields.add(fname) + flags = _parse_field_modifiers(modifier_text) + fields.append({"name": fname, "type": ftype.lower(), **flags}) + + entities.append({"name": ename, "fields": fields}) + + stories = [] + for m in _USER_STORY_PATTERN.finditer(text): + stories.append({ + "as_a": (m.group(1) or "").strip(), + "i_want": (m.group(2) or "").strip(), + "so_that": (m.group(3) or "").strip(), + }) + + integrations = [] + text_lower = text.lower() + for vendor, spec in _INTEGRATION_KEYWORDS.items(): + if vendor in text_lower: + integrations.append(spec) + + return { + "solution_name": solution_name, + "entities": entities, + "user_stories": stories, + "integrations": integrations, + } + + +# ─── STAGE 2 ──────────────────────────────────────────────────────────────── +# manifest_to_genome(manifest) → genome dict. +# Copied verbatim from scripts/microcodegen.py — language-agnostic. + + +def _snake(s: str) -> str: + s = re.sub(r"[^a-zA-Z0-9]+", "_", s.strip()).strip("_") + s = re.sub(r"([a-z])([A-Z])", r"\1_\2", s) + return s.lower() + + +def manifest_to_genome(manifest: dict) -> dict: + """Map the heuristic manifest into the canonical genome shape. Verbatim from microcodegen.py.""" + name = manifest["solution_name"] + snake_name = _snake(name) + + entities_dict: dict[str, dict] = {} + for ent in manifest.get("entities", []): + fields: dict[str, dict] = {"id": {"type": "uuid", "required": True}} + for f in ent.get("fields", []): + if f["name"] in ("id", "created_at", "updated_at"): + continue + fields[f["name"]] = { + "type": f["type"], + "required": f["required"], + "unique": f["unique"], + "indexed": f["indexed"], + } + entities_dict[ent["name"]] = { + "fields": fields, + "description": f"{ent['name']} entity (generated by microcodegen-django)", + "archimate_type": "DataObject", + } + + _workflow_verbs = { + "create", "update", "delete", "approve", "reject", "submit", + "complete", "process", "generate", "schedule", "notify", + } + archimate_elements: list[dict] = [ + {"name": name, "type": "ApplicationComponent", + "description": f"{name} Django application"}, + ] + for ent_name in entities_dict: + archimate_elements.append({ + "name": ent_name, + "type": "DataObject", + "description": entities_dict[ent_name]["description"], + }) + for story in manifest.get("user_stories", []): + text = story.get("i_want", story.get("story", "")).lower() + if any(v in text for v in _workflow_verbs): + label = text[:60] + archimate_elements.append({ + "name": label, + "type": "BusinessProcess", + "description": f"I want to {text}", + }) + for intg in manifest.get("integrations", []): + intg_name = intg.get("name", str(intg)) if isinstance(intg, dict) else str(intg) + archimate_elements.append({ + "name": intg_name, + "type": "ApplicationService", + "description": f"External integration: {intg_name}", + }) + + return { + "genome_version": "1.0.0", + "solution_id": 0, + "solution_name": name, + "bundle_id": snake_name, + "language": "django", + "modules": { + "core": { + "module_type": "crud", + "description": "Core entities", + "entities": entities_dict, + }, + }, + "user_stories": manifest.get("user_stories", []), + "integrations": manifest.get("integrations", []), + "archimate_elements": archimate_elements, + } + + +# ─── STAGE 3 ──────────────────────────────────────────────────────────────── +# render_genome(genome) → {path: content}. +# Django REST Framework + PostgreSQL. Written from scratch for Django idioms. +# Auth: httpOnly JWT cookies via JWTCookieAuthentication. +# Per-entity: Model + ModelSerializer + ModelViewSet + DefaultRouter. + + +_DJANGO_TYPE_MAP = { + "string": "models.CharField(max_length=255)", + "text": "models.TextField()", + "integer": "models.IntegerField()", + "int": "models.IntegerField()", + "float": "models.FloatField()", + "decimal": "models.DecimalField(max_digits=12, decimal_places=2)", + "boolean": "models.BooleanField(default=False)", + "bool": "models.BooleanField(default=False)", + "datetime": "models.DateTimeField()", + "date": "models.DateField()", + "uuid": "models.UUIDField()", + "json": "models.JSONField(default=dict)", +} + +_OPENAPI_TYPE_MAP = { + "string": "string", "text": "string", "integer": "integer", + "int": "integer", "float": "number", "decimal": "number", + "boolean": "boolean", "bool": "boolean", "datetime": "string", + "date": "string", "uuid": "string", "json": "object", +} + +# ── Fixed file templates ────────────────────────────────────────────────────── + +_T_MANAGE = string.Template("""\ +#!/usr/bin/env python +""" ++ '"""Django management script — generated by archiet-microcodegen-django."""' ++ """ + +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "${project}.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Activate your virtualenv and " + "run: pip install -r requirements.txt" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() +""") + +_T_SETTINGS = string.Template("""\ +# ${project}/settings.py — generated by archiet-microcodegen-django +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") +if not SECRET_KEY: + raise RuntimeError( + "DJANGO_SECRET_KEY is not set. Copy .env.example -> .env. " + 'Rotate with: python -c "import secrets; print(secrets.token_urlsafe(48))"' + ) + +DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true" +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "rest_framework", + "corsheaders", + "apps.accounts", +$entity_installed_apps +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.middleware.common.CommonMiddleware", +] + +ROOT_URLCONF = "${project}.urls" + +_db_url = os.environ.get("DATABASE_URL") +if not _db_url: + raise RuntimeError("DATABASE_URL is not set. This application requires PostgreSQL.") +if "sqlite" in _db_url.lower(): + raise RuntimeError("SQLite is not supported. Use PostgreSQL.") + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("DB_NAME", "${bundle_id}"), + "USER": os.environ.get("DB_USER", "archiet"), + "PASSWORD": os.environ.get("DB_PASSWORD", "archiet"), + "HOST": os.environ.get("DB_HOST", "localhost"), + "PORT": os.environ.get("DB_PORT", "5432"), + } +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "apps.accounts.authentication.JWTCookieAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 25, +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME_HOURS": 24, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, +} + +CORS_ALLOWED_ORIGINS = os.environ.get( + "CORS_ORIGINS", "http://localhost:3000,http://localhost:8000" +).split(",") +CORS_ALLOW_CREDENTIALS = True + +USE_TZ = True +TIME_ZONE = "UTC" +""") + +_T_URLS = string.Template("""\ +# ${project}/urls.py — generated by archiet-microcodegen-django +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from apps.accounts import views as auth_views +$entity_router_imports + +router = DefaultRouter() +$entity_router_registrations + +urlpatterns = [ + path("api/", include(router.urls)), + path("api/auth/register", auth_views.register, name="auth-register"), + path("api/auth/login", auth_views.login, name="auth-login"), + path("api/auth/logout", auth_views.logout, name="auth-logout"), + path("api/auth/me", auth_views.me, name="auth-me"), + path("health/", auth_views.health, name="health"), +] +""") + +_T_WSGI = string.Template("""\ +# ${project}/wsgi.py — generated by archiet-microcodegen-django +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "${project}.settings") +application = get_wsgi_application() +""") + +_T_SETTINGS_INIT = string.Template("""\ +# ${project}/__init__.py +""") + +_T_REQUIREMENTS = string.Template("""\ +Django>=5.0,<6.0 +djangorestframework>=3.15 +djangorestframework-simplejwt>=5.3 +django-cors-headers>=4.3 +psycopg2-binary>=2.9 +gunicorn>=22.0 +PyJWT>=2.8 +python-dotenv>=1.0 +""") + +_T_DOCKERFILE = string.Template("""\ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["gunicorn", "${project}.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"] +""") + +_T_DOCKER_COMPOSE = string.Template("""\ +services: + app: + build: . + ports: ["8000:8000"] + environment: + DATABASE_URL: postgresql://archiet:archiet@db:5432/${bundle_id} + DB_HOST: db + DB_NAME: ${bundle_id} + DB_USER: archiet + DB_PASSWORD: archiet + DJANGO_SECRET_KEY: ${django_secret_key} + DJANGO_DEBUG: "false" + depends_on: + db: + condition: service_healthy + db: + image: postgres:16 + environment: + POSTGRES_USER: archiet + POSTGRES_PASSWORD: archiet + POSTGRES_DB: ${bundle_id} + volumes: ["pgdata:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U archiet -d ${bundle_id}"] + interval: 3s + timeout: 3s + retries: 20 +volumes: + pgdata: +""") + +_T_ENV_EXAMPLE = string.Template("""\ +DATABASE_URL=postgresql://archiet:archiet@localhost:5432/${bundle_id} +DB_HOST=localhost +DB_NAME=${bundle_id} +DB_USER=archiet +DB_PASSWORD=archiet +DJANGO_SECRET_KEY=${django_secret_key} +DJANGO_DEBUG=false +CORS_ORIGINS=http://localhost:3000 +""") + +# ── accounts app ──────────────────────────────────────────────────────────── + +_T_ACCOUNTS_INIT = string.Template("# apps/accounts/__init__.py\n") + +_T_ACCOUNTS_APPS = string.Template("""\ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" +""") + +_T_ACCOUNTS_MODELS = string.Template("""\ +# apps/accounts/models.py — generated by archiet-microcodegen-django +import uuid +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager +from django.db import models + + +class UserManager(BaseUserManager): + def create_user(self, email, password=None): + if not email: + raise ValueError("Email required") + user = self.model(email=self.normalize_email(email)) + user.set_password(password) + user.save(using=self._db) + return user + + +class User(AbstractBaseUser): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + email = models.EmailField(unique=True, db_index=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + objects = UserManager() + + class Meta: + db_table = "accounts_user" + + def __str__(self): + return self.email +""") + +_T_ACCOUNTS_AUTH = string.Template("""\ +# apps/accounts/authentication.py — httpOnly JWT cookie auth +# JWT is read from the httpOnly cookie, never from Authorization header. +import os +import jwt +from rest_framework import authentication, exceptions +from apps.accounts.models import User + + +class JWTCookieAuthentication(authentication.BaseAuthentication): + \"\"\"Reads JWT from httpOnly cookie set by the login view. + Never reads Authorization header — cookie-only auth prevents XSS token theft. + \"\"\" + cookie_name = "access_token" + + def authenticate(self, request): + token = request.COOKIES.get(self.cookie_name) + if not token: + return None + secret = os.environ.get("DJANGO_SECRET_KEY") + if not secret: + raise exceptions.AuthenticationFailed("DJANGO_SECRET_KEY not configured") + try: + payload = jwt.decode(token, secret, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + raise exceptions.AuthenticationFailed("Token expired") from None + except jwt.InvalidTokenError: + raise exceptions.AuthenticationFailed("Invalid token") from None + user_id = payload.get("user_id") + if not user_id: + raise exceptions.AuthenticationFailed("Token missing user_id claim") + try: + user = User.objects.get(pk=user_id) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed("User not found") from None + if not user.is_active: + raise exceptions.AuthenticationFailed("User account disabled") + return (user, token) + + def authenticate_header(self, request): + return "Cookie" +""") + +_T_ACCOUNTS_VIEWS = string.Template("""\ +# apps/accounts/views.py — Auth views: register / login / logout / me / health +# JWT is set as httpOnly cookie. NEVER returned in response body. +import json +import os +import uuid +from datetime import datetime, timedelta, timezone + +import jwt +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET, require_POST +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated + +from apps.accounts.models import User + +_JWT_EXPIRY_HOURS = 24 + + +def _secret(): + s = os.environ.get("DJANGO_SECRET_KEY", "") + if not s: + raise RuntimeError("DJANGO_SECRET_KEY not set") + return s + + +def _make_token(user): + payload = { + "user_id": str(user.id), + "exp": datetime.now(timezone.utc) + timedelta(hours=_JWT_EXPIRY_HOURS), + } + return jwt.encode(payload, _secret(), algorithm="HS256") + + +def _set_cookie(response, token): + response.set_cookie( + "access_token", + token, + httponly=True, + secure=os.environ.get("DJANGO_DEBUG", "false").lower() != "true", + samesite="Lax", + max_age=_JWT_EXPIRY_HOURS * 3600, + ) + return response + + +@csrf_exempt +@require_POST +def register(request): + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({"error": "Invalid JSON"}, status=400) + email = (body.get("email") or "").strip().lower() + password = body.get("password") or "" + if not email or not password: + return JsonResponse({"error": "email and password required"}, status=400) + if len(password) < 8: + return JsonResponse({"error": "Password must be >= 8 characters"}, status=400) + if User.objects.filter(email=email).exists(): + return JsonResponse({"error": "Email already registered"}, status=409) + user = User.objects.create_user(email=email, password=password) + token = _make_token(user) + resp = JsonResponse( + {"user": {"id": str(user.id), "email": user.email}}, status=201 + ) + return _set_cookie(resp, token) + + +@csrf_exempt +@require_POST +def login(request): + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({"error": "Invalid JSON"}, status=400) + email = (body.get("email") or "").strip().lower() + password = body.get("password") or "" + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + return JsonResponse({"error": "Invalid credentials"}, status=401) + if not user.check_password(password): + return JsonResponse({"error": "Invalid credentials"}, status=401) + if not user.is_active: + return JsonResponse({"error": "Account disabled"}, status=403) + token = _make_token(user) + resp = JsonResponse({"user": {"id": str(user.id), "email": user.email}}) + return _set_cookie(resp, token) + + +@csrf_exempt +@require_POST +def logout(request): + resp = JsonResponse({"ok": True}) + resp.delete_cookie("access_token") + return resp + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def me(request): + u = request.user + return JsonResponse({"user": {"id": str(u.id), "email": u.email}}) + + +def health(request): + return JsonResponse({"status": "ok", "version": "${bundle_id}"}) +""") + +_T_ACCOUNTS_URLS = string.Template("""\ +# apps/accounts/urls.py +from django.urls import path +from apps.accounts import views + +urlpatterns = [ + path("register", views.register, name="auth-register"), + path("login", views.login, name="auth-login"), + path("logout", views.logout, name="auth-logout"), + path("me", views.me, name="auth-me"), +] +""") + +# ── Per-entity templates ───────────────────────────────────────────────────── + +_T_ENTITY_MODELS = string.Template("""\ +# apps/${snake_entity}/models.py — generated by archiet-microcodegen-django +import uuid +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class ${entity_name}(models.Model): + \"\"\"${description}\"\"\" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + # Per-tenant ownership — every row is scoped to the user that created it. + # Queries in the ViewSet filter by this to prevent cross-user data leaks. + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="${snake_entity}_set" + ) +$field_declarations + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "${table_name}" + ordering = ["-created_at"] + + def __str__(self): + return f"${entity_name}({self.id})" +""") + +_T_ENTITY_SERIALIZERS = string.Template("""\ +# apps/${snake_entity}/serializers.py — generated by archiet-microcodegen-django +from rest_framework import serializers +from apps.${snake_entity}.models import ${entity_name} + + +class ${entity_name}Serializer(serializers.ModelSerializer): + class Meta: + model = ${entity_name} + fields = "__all__" + read_only_fields = ("id", "user", "created_at", "updated_at") +""") + +_T_ENTITY_VIEWS = string.Template("""\ +# apps/${snake_entity}/views.py — generated by archiet-microcodegen-django +from rest_framework import viewsets, permissions +from apps.${snake_entity}.models import ${entity_name} +from apps.${snake_entity}.serializers import ${entity_name}Serializer + + +class ${entity_name}ViewSet(viewsets.ModelViewSet): + \"\"\"CRUD for ${entity_name}. All rows scoped to request.user.\"\"\" + serializer_class = ${entity_name}Serializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + # Tenant isolation: only return rows owned by the authenticated user. + return ${entity_name}.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) +""") + +_T_ENTITY_URLS = string.Template("""\ +# apps/${snake_entity}/urls.py — generated by archiet-microcodegen-django +from rest_framework.routers import DefaultRouter +from apps.${snake_entity}.views import ${entity_name}ViewSet + +router = DefaultRouter() +router.register(r"${snake_entity}s", ${entity_name}ViewSet, basename="${snake_entity}") + +urlpatterns = router.urls +""") + +_T_ENTITY_APPS = string.Template("""\ +from django.apps import AppConfig + + +class ${entity_name}Config(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.${snake_entity}" +""") + + +def _render_architecture_md(genome: dict, entities: dict) -> str: + name = genome["solution_name"] + elements = genome.get("archimate_elements", []) + user_stories = genome.get("user_stories", []) + integrations = genome.get("integrations", []) + + lines: list[str] = [ + f"# Architecture — {name}", + "", + "Generated by archiet-microcodegen-django · ArchiMate 3.2 element notation", + "", + "## Application Layer (ArchiMate §9)", + "", + "| Element | Type | Description |", + "|---------|------|-------------|", + ] + for el in elements: + lines.append(f"| `{el['name']}` | {el['type']} | {el['description']} |") + + lines += [ + "", + "## Django DRF Component Map", + "", + "| Entity | ApplicationComponent (ViewSet) | DataObject (Model) | ApplicationService (Serializer) |", + "|--------|-------------------------------|-------------------|--------------------------------|", + ] + for ent_name in entities: + snake = _snake(ent_name) + lines.append( + f"| {ent_name} | `{ent_name}ViewSet` | `{ent_name}` | `{ent_name}Serializer` |" + ) + + lines += ["", "## Relationships", "", "```", f" {name} (ApplicationComponent)"] + for ent_name in entities: + lines.append(f" └── {ent_name} (DataObject) [Realization]") + for intg in integrations: + intg_name = intg.get("name", str(intg)) if isinstance(intg, dict) else str(intg) + lines.append(f" └── {intg_name} (ApplicationService) [UsedBy]") + lines.append("```") + + if user_stories: + lines += ["", "## Business Process Layer (ArchiMate §8)", ""] + for story in user_stories[:10]: + lines.append( + f"- As a {story.get('as_a', '')}, I want to {story.get('i_want', '')}" + ) + + lines += [ + "", + "## Notes", + "", + "- Heuristically derived from PRD text.", + "- The full Archiet platform generates a formal ArchiMate 3.2 model,", + " DMN 1.5 decision tables, BPMN 2.0 process diagrams, and a", + " complete openapi.yaml verified against the running application.", + "- To regenerate: edit GENOME.json and re-run archiet-microcodegen-django.", + ] + return "\n".join(lines) + "\n" + + +def _render_openapi_yaml(genome: dict, entities: dict) -> str: + name = genome["solution_name"] + + lines: list[str] = [ + "openapi: '3.1.0'", + "info:", + f" title: {name} API", + f" description: Generated by archiet-microcodegen-django for {name}", + " version: 0.1.0", + "servers:", + " - url: http://localhost:8000", + " description: Local development", + "paths:", + " /api/auth/register:", + " post:", + " summary: Register a new user", + " tags: [auth]", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + " $ref: '#/components/schemas/AuthRequest'", + " responses:", + " '201': {description: User created, JWT set in httpOnly cookie}", + " '409': {description: Email already registered}", + " /api/auth/login:", + " post:", + " summary: Login — JWT set as httpOnly cookie, never in response body", + " tags: [auth]", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + " $ref: '#/components/schemas/AuthRequest'", + " responses:", + " '200': {description: Authenticated — JWT set in httpOnly cookie}", + " '401': {description: Invalid credentials}", + " /health/:", + " get:", + " summary: Health check", + " tags: [ops]", + " responses:", + " '200': {description: OK}", + ] + + for ent_name, ent_spec in entities.items(): + snake = _snake(ent_name) + plural = snake + "s" + lines += [ + f" /api/{plural}/:", + " get:", + f" summary: List {ent_name} records (scoped to authenticated user)", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " responses:", + f" '200': {{description: List of {ent_name}}}", + " post:", + f" summary: Create {ent_name}", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + f" $ref: '#/components/schemas/{ent_name}'", + " responses:", + f" '201': {{description: {ent_name} created}}", + f" /api/{plural}/{{id}}/:", + " get:", + f" summary: Get {ent_name} by ID", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " parameters:", + " - in: path", + " name: id", + " required: true", + " schema: {type: string, format: uuid}", + " responses:", + f" '200': {{description: {ent_name} record}}", + " '404': {description: Not found}", + " put:", + f" summary: Update {ent_name}", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " parameters:", + " - in: path", + " name: id", + " required: true", + " schema: {type: string, format: uuid}", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + f" $ref: '#/components/schemas/{ent_name}'", + " responses:", + f" '200': {{description: {ent_name} updated}}", + " delete:", + f" summary: Delete {ent_name}", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " parameters:", + " - in: path", + " name: id", + " required: true", + " schema: {type: string, format: uuid}", + " responses:", + " '204': {description: Deleted}", + ] + + lines += [ + "components:", + " securitySchemes:", + " cookieAuth:", + " type: apiKey", + " in: cookie", + " name: access_token", + " description: httpOnly JWT cookie — set by /api/auth/login", + " schemas:", + " AuthRequest:", + " type: object", + " required: [email, password]", + " properties:", + " email: {type: string, format: email}", + " password: {type: string, format: password}", + ] + for ent_name, ent_spec in entities.items(): + lines += [f" {ent_name}:", " type: object", " properties:"] + for fname, fspec in (ent_spec.get("fields") or {}).items(): + if fname == "id": + continue + oa_type = _OPENAPI_TYPE_MAP.get(fspec.get("type", "string"), "string") + lines.append(f" {fname}: {{type: {oa_type}}}") + + return "\n".join(lines) + "\n" + + +def _django_field(fname: str, fspec: dict) -> str: + """Return a Django model field declaration line.""" + base_type = fspec.get("type", "string") + field = _DJANGO_TYPE_MAP.get(base_type, "models.CharField(max_length=255)") + # Strip closing paren to add kwargs + field_open = field.rstrip(")") + kwargs = [] + if not fspec.get("required"): + kwargs.append("null=True, blank=True") + if fspec.get("unique"): + kwargs.append("unique=True") + if fspec.get("indexed"): + kwargs.append("db_index=True") + if kwargs: + separator = ", " if "(" in field_open and field_open.endswith("(") else ", " + # Handle fields that have content already inside parens + if field_open.endswith("("): + return f" {fname} = {field_open}{', '.join(kwargs)})" + else: + return f" {fname} = {field_open}, {', '.join(kwargs)})" + return f" {fname} = {field}" + + +def render_genome(genome: dict) -> dict[str, str]: + """Render the genome into a {path: content} dict. + + Django REST Framework + PostgreSQL. httpOnly JWT cookies for auth. + Per-entity: Model + Serializer + ViewSet + DefaultRouter. + """ + bundle_id = genome["bundle_id"] + name = genome["solution_name"] + project = bundle_id # Django project package name + django_secret_key = secrets.token_urlsafe(48) + files: dict[str, str] = {} + + entities = (genome["modules"]["core"] or {}).get("entities") or {} + + # ── Project package ────────────────────────────────────────────────────── + files["manage.py"] = _T_MANAGE.safe_substitute(project=project) + files[f"{project}/__init__.py"] = _T_SETTINGS_INIT.safe_substitute(project=project) + files[f"{project}/wsgi.py"] = _T_WSGI.safe_substitute(project=project) + + # Build entity_installed_apps for settings.py + entity_installed_apps = "\n".join( + f' "apps.{_snake(ent_name)}",' for ent_name in entities + ) + files[f"{project}/settings.py"] = _T_SETTINGS.safe_substitute( + project=project, + bundle_id=bundle_id, + entity_installed_apps=entity_installed_apps, + django_secret_key=django_secret_key, + ) + + # Build urls.py with per-entity router registrations + entity_router_imports = "\n".join( + f"from apps.{_snake(e)}.views import {e}ViewSet" for e in entities + ) + entity_router_registrations = "\n".join( + f'router.register(r"{_snake(e)}s", {e}ViewSet, basename="{_snake(e)}")' + for e in entities + ) + files[f"{project}/urls.py"] = _T_URLS.safe_substitute( + project=project, + entity_router_imports=entity_router_imports, + entity_router_registrations=entity_router_registrations, + ) + + # ── Fixed files ────────────────────────────────────────────────────────── + files["requirements.txt"] = _T_REQUIREMENTS.safe_substitute() + files["Dockerfile"] = _T_DOCKERFILE.safe_substitute(project=project) + files["docker-compose.yml"] = _T_DOCKER_COMPOSE.safe_substitute( + bundle_id=bundle_id, + django_secret_key=django_secret_key, + ) + files[".env.example"] = _T_ENV_EXAMPLE.safe_substitute( + bundle_id=bundle_id, + django_secret_key=django_secret_key, + ) + + # ── accounts app ───────────────────────────────────────────────────────── + files["apps/__init__.py"] = "" + files["apps/accounts/__init__.py"] = _T_ACCOUNTS_INIT.safe_substitute() + files["apps/accounts/apps.py"] = _T_ACCOUNTS_APPS.safe_substitute() + files["apps/accounts/models.py"] = _T_ACCOUNTS_MODELS.safe_substitute() + files["apps/accounts/authentication.py"] = _T_ACCOUNTS_AUTH.safe_substitute() + files["apps/accounts/views.py"] = _T_ACCOUNTS_VIEWS.safe_substitute( + bundle_id=bundle_id + ) + files["apps/accounts/urls.py"] = _T_ACCOUNTS_URLS.safe_substitute() + + # ── Per-entity files ───────────────────────────────────────────────────── + entity_list_lines: list[str] = [] + for ent_name, ent_spec in entities.items(): + snake = _snake(ent_name) + table = snake + "s" + description = ent_spec.get("description", f"{ent_name} entity") + + field_lines: list[str] = [] + for fname, fspec in (ent_spec.get("fields") or {}).items(): + if fname == "id": + continue + field_lines.append(_django_field(fname, fspec)) + + field_declarations = ( + "\n".join(field_lines) if field_lines else " # no fields extracted from PRD" + ) + + files[f"apps/{snake}/__init__.py"] = "" + files[f"apps/{snake}/apps.py"] = _T_ENTITY_APPS.safe_substitute( + entity_name=ent_name, snake_entity=snake + ) + files[f"apps/{snake}/models.py"] = _T_ENTITY_MODELS.safe_substitute( + entity_name=ent_name, + snake_entity=snake, + table_name=table, + description=description, + field_declarations=field_declarations, + ) + files[f"apps/{snake}/serializers.py"] = _T_ENTITY_SERIALIZERS.safe_substitute( + entity_name=ent_name, snake_entity=snake + ) + files[f"apps/{snake}/views.py"] = _T_ENTITY_VIEWS.safe_substitute( + entity_name=ent_name, snake_entity=snake + ) + files[f"apps/{snake}/urls.py"] = _T_ENTITY_URLS.safe_substitute( + entity_name=ent_name, snake_entity=snake + ) + entity_list_lines.append(f"- **{ent_name}**: {description}") + + # ── Architecture documents ─────────────────────────────────────────────── + files["ARCHITECTURE.md"] = _render_architecture_md(genome, entities) + files["openapi.yaml"] = _render_openapi_yaml(genome, entities) + files["GENOME.json"] = json.dumps(genome, indent=2, default=str) + + # ── App README ─────────────────────────────────────────────────────────── + entity_list = "\n".join(entity_list_lines) or "_(no entities extracted from PRD)_" + files["README.md"] = _T_APP_README.safe_substitute( + solution_name=name, + bundle_id=bundle_id, + entity_list=entity_list, + ) + + return files + + +_T_APP_README = string.Template("""\ +# ${solution_name} + +Generated by [archiet-microcodegen-django](https://archiet.com?utm_source=pypi&utm_medium=package&utm_campaign=microcodegen-django) — Django REST Framework + PostgreSQL. + +## Quick start + +```bash +cp .env.example .env +docker compose up +curl http://localhost:8000/health/ +``` + +## Auth (httpOnly cookies — never localStorage) + +```bash +# Register +curl -c cookies.txt -X POST http://localhost:8000/api/auth/register \\ + -H "Content-Type: application/json" \\ + -d '{"email":"you@example.com","password":"hunter22hunter"}' + +# Login +curl -c cookies.txt -X POST http://localhost:8000/api/auth/login \\ + -H "Content-Type: application/json" \\ + -d '{"email":"you@example.com","password":"hunter22hunter"}' + +# Create an entity (cookie sent automatically) +curl -b cookies.txt -X POST http://localhost:8000/api/items/ \\ + -H "Content-Type: application/json" \\ + -d '{"name":"My first item"}' + +# List (tenant-scoped — only your rows) +curl -b cookies.txt http://localhost:8000/api/items/ +``` + +## Entities + +${entity_list} + +## Migrations + +```bash +python manage.py makemigrations +python manage.py migrate +``` + +## What's included + +- Django 5 + Django REST Framework (ModelViewSet full CRUD per entity) +- JWT auth via httpOnly SameSite=Lax cookies — never localStorage +- Per-tenant data isolation — every row has a `user` FK; every ViewSet filters by `request.user` +- Custom User model (UUID primary key, email login) +- JWTCookieAuthentication class wired into REST_FRAMEWORK settings +- docker-compose.yml — Postgres 16 with healthcheck-gated startup +- Dockerfile — gunicorn production server +- ARCHITECTURE.md — ArchiMate 3.2 element map +- openapi.yaml — machine-readable API contract + +Zero LLM calls. Zero API keys. Pure Python stdlib generator. + +Built on [Archiet](https://archiet.com?utm_source=pypi&utm_medium=package&utm_campaign=microcodegen-django). +""") + + +# ─── STAGE 4 ──────────────────────────────────────────────────────────────── +# pack(files) → bytes. Copied verbatim from scripts/microcodegen.py. + + +def pack(files: dict[str, str]) -> bytes: + """Pack {path: content} into ZIP bytes.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for path, content in sorted(files.items()): + zf.writestr(path, content) + return buf.getvalue() + + +# ─── PUBLIC ENTRY ─────────────────────────────────────────────────────────── + + +def microcodegen_django(prd_text: str) -> bytes: + """The complete algorithm. PRD text → Django DRF ZIP bytes.""" + manifest = parse_prd(prd_text) + genome = manifest_to_genome(manifest) + files = render_genome(genome) + return pack(files) + + +# Alias so callers can use `microcodegen` as the verb (consistent with FastAPI package) +microcodegen = microcodegen_django + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="archiet-microcodegen-django: PRD text → Django DRF app ZIP." + ) + p.add_argument("prd", help="Path to PRD file (markdown/text).") + p.add_argument( + "--out", + help="Directory to extract into. If omitted, writes ZIP bytes to stdout.", + ) + args = p.parse_args(argv) + + prd_text = Path(args.prd).read_text(encoding="utf-8") + + if args.out: + manifest = parse_prd(prd_text) + genome = manifest_to_genome(manifest) + files = render_genome(genome) + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + for path, content in files.items(): + full = out_dir / path + full.parent.mkdir(parents=True, exist_ok=True) + full.write_text(content, encoding="utf-8") + print(f"Wrote {len(files)} files to {out_dir}", file=sys.stderr) + else: + zip_bytes = microcodegen_django(prd_text) + sys.stdout.buffer.write(zip_bytes) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/archiet_microcodegen_dotnet/Program.cs b/archiet_microcodegen_dotnet/Program.cs new file mode 100644 index 0000000..e367474 --- /dev/null +++ b/archiet_microcodegen_dotnet/Program.cs @@ -0,0 +1,550 @@ +// archiet-microcodegen-dotnet v0.1.0 +// PRD text -> ASP.NET Core 8 app -> ZIP. Pure .NET BCL. <1400 LOC. +// +// Stage 1: ParsePrd(text) -> Manifest (language-agnostic) +// Stage 2: ManifestToGenome(manifest) -> Genome (ArchiMate 3.2 typed) +// Stage 3: RenderGenome(genome) -> Dictionary +// Stage 4: PackZip(files) -> byte[] +// +// Zero NuGet dependencies. Inspired by Karpathy's micrograd. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +// ─── CLI ───────────────────────────────────────────────────────────────────── +var cliArgs = Environment.GetCommandLineArgs().Skip(1).ToArray(); +if (cliArgs.Length == 0 || cliArgs[0] is "-h" or "--help") +{ + Console.WriteLine("archiet-microcodegen-dotnet v0.1.0"); + Console.WriteLine("Usage: archiet-microcodegen-dotnet [--out ] [--zip ]"); + return; +} +var prdPath = cliArgs[0]; +if (!File.Exists(prdPath)) { Console.Error.WriteLine($"Error: PRD not found: {prdPath}"); return; } + +string? outDir = null, zipOut = null; +for (int i = 1; i < cliArgs.Length; i++) +{ + if (cliArgs[i] == "--out" && i + 1 < cliArgs.Length) outDir = cliArgs[++i]; + if (cliArgs[i] == "--zip" && i + 1 < cliArgs.Length) zipOut = cliArgs[++i]; +} +if (outDir is null && zipOut is null) outDir = "./output"; + +var manifest = Stage1.ParsePrd(File.ReadAllText(prdPath)); +var genome = Stage2.ToGenome(manifest); +var rendered = Stage3.Render(genome); + +if (zipOut is not null) { File.WriteAllBytes(zipOut, Stage4.PackZip(rendered)); Console.WriteLine($"ZIP: {zipOut} ({rendered.Count} files)"); } +if (outDir is not null) { Stage4.WriteDisk(rendered, outDir); Console.WriteLine($"Done. cd {outDir} && cp .env.example .env && docker compose up"); } + +// ─── Stage 1: ParsePrd ─────────────────────────────────────────────────────── +record Field(string Name, string Type, bool Required); +record Module(string Name, string Archimate, List Fields); +record Manifest(string Name, List Entities, List Stories, List Integrations); +record Genome(string SolutionName, string Slug, string Version, string Language, + List Modules, List Integrations, List UserStories); + +static class Stage1 +{ + static readonly string[] Skip = { "User","Auth","Admin","Api","The" }; + static readonly string[] KnownIntegrations = { "stripe","sendgrid","twilio","slack","github","google","aws","s3","cloudinary","firebase" }; + + public static Manifest ParsePrd(string text) + { + var nameM = Regex.Match(text, @"^#\s+(.+)", RegexOptions.Multiline); + var name = nameM.Success ? nameM.Groups[1].Value.Trim() : "MyApp"; + + var entities = new List(); + var secM = Regex.Match(text, @"^#{1,3}\s*(?:entities|data models|domain models)[^\n]*", RegexOptions.IgnoreCase | RegexOptions.Multiline); + if (secM.Success) + { + var sec = text[secM.Index..]; + var endM = Regex.Match(sec, @"\n#{1,3}\s+(?!entities|data|domain)", RegexOptions.IgnoreCase); + if (endM.Success) sec = sec[..endM.Index]; + + foreach (Match em in Regex.Matches(sec, @"^[\s\-\*]*([A-Z][a-zA-Z0-9]{1,40})\*{0,2}[ \t]*(?::|—|-| )", RegexOptions.Multiline)) + { + var en = em.Groups[1].Value; + if (Skip.Contains(en)) continue; + var fields = new List(); + var epos = sec.IndexOf(en, StringComparison.Ordinal); + if (epos >= 0) + { + var chunk = sec.Substring(epos, Math.Min(600, sec.Length - epos)); + foreach (Match fm in Regex.Matches(chunk, @"^\s+[-*]\s*([a-z_][a-z0-9_]{0,40})\s*[:—]\s*([a-zA-Z]+)([^\n]*)", RegexOptions.Multiline)) + fields.Add(new Field(fm.Groups[1].Value, fm.Groups[2].Value.ToLower(), + fm.Groups[3].Value.ToLower().Contains("required") || fm.Groups[3].Value.Contains("*"))); + } + entities.Add(new Module(en, "DataObject", fields)); + } + } + + var stories = Regex.Matches(text, @"As a[n]?\s+\w+,\s*I want[^.\n]+", RegexOptions.IgnoreCase) + .Select(m => m.Value.Trim()).ToList(); + var lower = text.ToLower(); + var integrations = KnownIntegrations.Where(k => lower.Contains(k)).ToList(); + return new Manifest(name, entities, stories, integrations); + } +} + +// ─── Stage 2: ManifestToGenome ──────────────────────────────────────────────── +static class Stage2 +{ + public static Genome ToGenome(Manifest m) + { + var slug = Regex.Replace(m.Name.ToLower(), @"[^a-z0-9]+", "-").Trim('-'); + var baseFields = new List + { + new("Id", "bigint", true), + new("UserId", "bigint", true), + new("CreatedAt", "timestamp", false), + new("UpdatedAt", "timestamp", false), + }; + var modules = m.Entities.Select(e => + new Module(e.Name, "DataObject", [..baseFields, ..e.Fields])).ToList(); + return new Genome(m.Name, slug, "0.1.0", "dotnet", modules, m.Integrations, m.Stories); + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── +static class H +{ + public static string Pascal(string s) + { + if (string.IsNullOrEmpty(s)) return s; + return string.Concat(s.Split(['_','-',' ']).Select(w => + w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w)); + } + public static string Camel(string s) { var p = Pascal(s); return p.Length > 0 ? char.ToLower(p[0]) + p[1..] : p; } + public static string Snake(string s) => Regex.Replace(s, "([a-z0-9])([A-Z])", "$1_$2").ToLower(); + public static string Plural(string s) => s.EndsWith('y') ? s[..^1] + "ies" : s.EndsWith('s') || s.EndsWith('x') || s.EndsWith('z') ? s + "es" : s + "s"; + public static string Fill(string t, Dictionary v) + { + foreach (var (k, val) in v) t = t.Replace("{{" + k + "}}", val); + return t; + } + public static string EfType(string t) => t switch + { + "text" or "description" => "string?", + "int" or "integer" => "int", + "bigint" => "long", + "bool" or "boolean" => "bool", + "date" => "DateOnly?", + "decimal" or "float" => "decimal?", + "timestamp" => "DateTime", + _ => "string?", + }; +} + +// ─── Stage 3: RenderGenome ──────────────────────────────────────────────────── +static class Stage3 +{ + public static Dictionary Render(Genome g) + { + var files = new Dictionary(); + var name = g.SolutionName; + var slug = H.Pascal(g.Slug.Replace("-","_")); + var mods = g.Modules; + + // .csproj + files[$"{slug}.csproj"] = $$""" + + + net8.0 + enable + enable + {{slug}} + + + + + + + + """; + + // Program.cs + var modelUsings = string.Join("\n", mods.Select(m => $"using {slug}.Models;")); + files["Program.cs"] = $$""" + using Microsoft.EntityFrameworkCore; + using {{slug}}.Data; + using {{slug}}.Services; + {{modelUsings}} + + var builder = WebApplication.CreateBuilder(args); + var connStr = Environment.GetEnvironmentVariable("DATABASE_URL") + ?? builder.Configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("DATABASE_URL not set."); + builder.Services.AddDbContext(o => o.UseNpgsql(connStr)); + builder.Services.AddSingleton(); + builder.Services.AddControllers(); + builder.Services.AddCors(o => o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + + var app = builder.Build(); + app.UseCors(); + app.UseMiddleware<{{slug}}.Auth.JwtMiddleware>(); + app.MapControllers(); + using (var scope = app.Services.CreateScope()) + scope.ServiceProvider.GetRequiredService().Database.Migrate(); + app.Run(); + """; + + // Data/AppDbContext.cs + var dbSets = string.Join("\n ", mods.Select(m => $"public DbSet<{H.Pascal(m.Name)}> {H.Plural(H.Pascal(m.Name))} {{ get; set; }}")); + files["Data/AppDbContext.cs"] = $$""" + using Microsoft.EntityFrameworkCore; + using {{slug}}.Models; + namespace {{slug}}.Data; + public class AppDbContext(DbContextOptions opts) : DbContext(opts) + { + public DbSet Users { get; set; } + {{dbSets}} + } + """; + + // Models/User.cs + files["Models/User.cs"] = $$""" + namespace {{slug}}.Models; + public class User + { + public long Id { get; set; } + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; + public string PasswordHash { get; set; } = ""; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + """; + + // Services/JwtService.cs — uses System.Text.Json to avoid nested raw-string-literal issues + files["Services/JwtService.cs"] = $$""" + using System.Security.Cryptography; + using System.Text; + using System.Text.Json; + namespace {{slug}}.Services; + public class JwtService + { + private readonly string _secret = Environment.GetEnvironmentVariable("JWT_SECRET") + ?? throw new InvalidOperationException("JWT_SECRET not set."); + private readonly long _ttl = long.Parse(Environment.GetEnvironmentVariable("JWT_TTL_SEC") ?? "604800"); + + private static string B64Url(byte[] b) => + Convert.ToBase64String(b).TrimEnd('=').Replace('+','-').Replace('/','_'); + + public string Encode(long userId, string email) + { + var header = B64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { typ = "JWT", alg = "HS256" }))); + var payload = B64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { sub = userId, email, exp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _ttl }))); + var sig = B64Url(new HMACSHA256(Encoding.UTF8.GetBytes(_secret)).ComputeHash(Encoding.UTF8.GetBytes($"{header}.{payload}"))); + return $"{header}.{payload}.{sig}"; + } + + public (long UserId, string Email)? Decode(string token) + { + var pts = token.Split('.'); + if (pts.Length != 3) return null; + var expected = B64Url(new HMACSHA256(Encoding.UTF8.GetBytes(_secret)) + .ComputeHash(Encoding.UTF8.GetBytes($"{pts[0]}.{pts[1]}"))); + if (!CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(pts[2]))) return null; + var json = Encoding.UTF8.GetString(Convert.FromBase64String( + pts[1].Replace('-','+').Replace('_','/').PadRight(pts[1].Length + (4 - pts[1].Length % 4) % 4, '='))); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (!root.TryGetProperty("sub", out var subEl) || !root.TryGetProperty("exp", out var expEl)) return null; + if (expEl.GetInt64() < DateTimeOffset.UtcNow.ToUnixTimeSeconds()) return null; + var emlStr = root.TryGetProperty("email", out var emlEl) ? emlEl.GetString() ?? "" : ""; + return (subEl.GetInt64(), emlStr); + } + } + """; + + // Auth/JwtMiddleware.cs + files["Auth/JwtMiddleware.cs"] = $$""" + using {{slug}}.Services; + namespace {{slug}}.Auth; + public class JwtMiddleware(RequestDelegate next) + { + public async Task InvokeAsync(HttpContext ctx, JwtService jwt) + { + var skip = ctx.Request.Path.StartsWithSegments("/api/auth/register") + || ctx.Request.Path.StartsWithSegments("/api/auth/login"); + if (!skip) + { + var token = ctx.Request.Cookies["access_token"]; + if (token is null) { ctx.Response.StatusCode = 401; await ctx.Response.WriteAsJsonAsync(new { error = "unauthenticated", message = "No auth cookie." }); return; } + var claim = jwt.Decode(token); + if (claim is null) { ctx.Response.StatusCode = 401; await ctx.Response.WriteAsJsonAsync(new { error = "unauthenticated", message = "Invalid or expired token." }); return; } + ctx.Items["UserId"] = claim.Value.UserId; + ctx.Items["UserEmail"] = claim.Value.Email; + } + await next(ctx); + } + } + """; + + // Controllers/AuthController.cs + files["Controllers/AuthController.cs"] = $$""" + using Microsoft.AspNetCore.Mvc; + using Microsoft.EntityFrameworkCore; + using {{slug}}.Data; + using {{slug}}.Models; + using {{slug}}.Services; + namespace {{slug}}.Controllers; + [ApiController, Route("api/auth")] + public class AuthController(AppDbContext db, JwtService jwt) : ControllerBase + { + [HttpPost("register")] + public async Task Register([FromBody] AuthDto dto) + { + if (await db.Users.AnyAsync(u => u.Email == dto.Email)) + return UnprocessableEntity(new { error = "validation_error", message = "Email already in use." }); + var user = new User { Name = dto.Name ?? dto.Email, Email = dto.Email, PasswordHash = BCrypt.Net.BCrypt.HashPassword(dto.Password) }; + db.Users.Add(user); await db.SaveChangesAsync(); + SetCookie(jwt.Encode(user.Id, user.Email)); + return Created($"/api/auth/me", new { user = new { user.Id, user.Name, user.Email } }); + } + + [HttpPost("login")] + public async Task Login([FromBody] AuthDto dto) + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Email == dto.Email); + if (user is null || !BCrypt.Net.BCrypt.Verify(dto.Password, user.PasswordHash)) + return Unauthorized(new { error = "invalid_credentials", message = "Wrong email or password." }); + SetCookie(jwt.Encode(user.Id, user.Email)); + return Ok(new { user = new { user.Id, user.Name, user.Email } }); + } + + [HttpDelete("logout")] + public IActionResult Logout() { Response.Cookies.Delete("access_token"); return Ok(new { message = "Logged out." }); } + + [HttpGet("me")] + public async Task Me() + { + var uid = (long)(HttpContext.Items["UserId"] ?? 0L); + var u = await db.Users.FindAsync(uid); + return u is null ? NotFound(new { error = "not_found" }) : Ok(new { user = new { u.Id, u.Name, u.Email } }); + } + + private void SetCookie(string token) => + Response.Cookies.Append("access_token", token, new CookieOptions + { HttpOnly = true, SameSite = SameSiteMode.Lax, Expires = DateTimeOffset.UtcNow.AddDays(7) }); + } + public record AuthDto(string? Name, string Email, string Password); + """; + + // per-entity + foreach (var mod in mods) + { + var pa = H.Pascal(mod.Name); + var pap = H.Plural(pa); + var userFields = mod.Fields.Where(f => f.Name is not ("Id" or "CreatedAt" or "UpdatedAt")).ToList(); + var propLines = string.Join("\n ", userFields.Select(f => + f.Name == "UserId" ? "public long UserId { get; set; }" + : $"public {H.EfType(f.Type)} {H.Pascal(f.Name)} {{ get; set; }}")); + var dtoLines = string.Join("\n ", userFields.Where(f => f.Name != "UserId") + .Select(f => $"public {H.EfType(f.Type)} {H.Pascal(f.Name)} {{ get; set; }}")); + var applyLines = string.Join("\n ", userFields.Where(f => f.Name != "UserId") + .Select(f => $"e.{H.Pascal(f.Name)} = dto.{H.Pascal(f.Name)};")); + + // Model + files[$"Models/{pa}.cs"] = $$""" + namespace {{slug}}.Models; + public class {{pa}} + { + public long Id { get; set; } + {{propLines}} + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } + """; + + // DTO + files[$"DTOs/{pa}Dto.cs"] = $$""" + namespace {{slug}}.DTOs; + public class {{pa}}Dto + { + {{dtoLines}} + } + """; + + // Controller + files[$"Controllers/{pap}Controller.cs"] = $$""" + using Microsoft.AspNetCore.Mvc; + using Microsoft.EntityFrameworkCore; + using {{slug}}.Data; + using {{slug}}.DTOs; + using {{slug}}.Models; + namespace {{slug}}.Controllers; + [ApiController, Route("api/{{H.Plural(H.Snake(mod.Name)).Replace("_","-")}}")] + public class {{pap}}Controller(AppDbContext db) : ControllerBase + { + private long Uid => (long)(HttpContext.Items["UserId"] ?? 0L); + + [HttpGet] + public async Task Index() => + Ok(await db.{{pap}}.Where(e => e.UserId == Uid).ToListAsync()); + + [HttpPost] + public async Task Create([FromBody] {{pa}}Dto dto) + { + var e = new {{pa}} { UserId = Uid }; + Apply(e, dto); db.{{pap}}.Add(e); await db.SaveChangesAsync(); + return Created($"/api/{{H.Plural(H.Snake(mod.Name)).Replace("_","-")}}/{e.Id}", e); + } + + [HttpGet("{id}")] + public async Task Show(long id) + { + var e = await db.{{pap}}.FirstOrDefaultAsync(x => x.Id == id && x.UserId == Uid); + return e is null ? NotFound(new { error = "not_found" }) : Ok(e); + } + + [HttpPut("{id}")] + public async Task Update(long id, [FromBody] {{pa}}Dto dto) + { + var e = await db.{{pap}}.FirstOrDefaultAsync(x => x.Id == id && x.UserId == Uid); + if (e is null) return NotFound(new { error = "not_found" }); + Apply(e, dto); e.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); + return Ok(e); + } + + [HttpDelete("{id}")] + public async Task Destroy(long id) + { + var e = await db.{{pap}}.FirstOrDefaultAsync(x => x.Id == id && x.UserId == Uid); + if (e is null) return NotFound(new { error = "not_found" }); + db.{{pap}}.Remove(e); await db.SaveChangesAsync(); return NoContent(); + } + + private static void Apply({{pa}} e, {{pa}}Dto dto) { {{applyLines}} } + } + """; + } + + // appsettings.json + files["appsettings.json"] = """ + { + "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, + "AllowedHosts": "*" + } + """; + + // .env.example + files[".env.example"] = $""" + DATABASE_URL=Host=db;Database=app;Username=app;Password=changeme + JWT_SECRET=change-me-jwt-secret-minimum-32-characters + JWT_TTL_SEC=604800 + ASPNETCORE_URLS=http://+:8080 + ASPNETCORE_ENVIRONMENT=Production + """; + + // Dockerfile + files["Dockerfile"] = $""" + FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build + WORKDIR /src + COPY {slug}.csproj . + RUN dotnet restore + COPY . . + RUN dotnet publish -c Release -o /app --no-restore + + FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine + WORKDIR /app + COPY --from=build /app . + EXPOSE 8080 + ENTRYPOINT ["dotnet", "{slug}.dll"] + """; + + // docker-compose.yml + files["docker-compose.yml"] = """ + services: + app: + build: . + ports: ["8080:8080"] + env_file: .env + depends_on: + db: + condition: service_healthy + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: app + POSTGRES_USER: app + POSTGRES_PASSWORD: changeme + ports: ["5432:5432"] + volumes: [db_data:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + db_data: + """; + + // ARCHITECTURE.md + var arc = $"# ARCHITECTURE — {name}\n\nGenerated by archiet-microcodegen-dotnet. ArchiMate 3.2 notation.\n\n"; + arc += "## ApplicationComponent\n\n| Component | Technology | Notes |\n|---|---|---|\n"; + arc += "| ApiGateway | ASP.NET Core 8 Routing | Routes API requests |\n"; + arc += "| AuthService | JWT (httpOnly cookie) + BCrypt | register / login / logout |\n"; + foreach (var m in mods) arc += $"| {H.Pascal(m.Name)}Service | EF Core + Npgsql | CRUD for {m.Name} |\n"; + arc += "\n## DataObject\n\n| Entity | Table | Key Fields |\n|---|---|---|\n"; + arc += "| User | Users | Id, Name, Email, PasswordHash |\n"; + foreach (var m in mods) arc += $"| {m.Name} | {H.Plural(H.Pascal(m.Name))} | {string.Join(", ", m.Fields.Take(5).Select(f => f.Name))} |\n"; + arc += "\n## Auth Contract\n- JWT in **httpOnly cookie** `access_token` — never localStorage\n"; + arc += "- Per-tenant: every EF Core query includes `.Where(e => e.UserId == Uid)`\n"; + files["ARCHITECTURE.md"] = arc; + + // openapi.yaml + var oa = $"openapi: \"3.1.0\"\ninfo:\n title: \"{name} API\"\n version: \"0.1.0\"\npaths:\n"; + oa += " /api/auth/register:\n post: {operationId: register, tags: [auth], responses: {201: {description: Created}}}\n"; + oa += " /api/auth/login:\n post: {operationId: login, tags: [auth], responses: {200: {description: OK}}}\n"; + oa += " /api/auth/me:\n get: {operationId: me, tags: [auth], security: [{cookieAuth: []}], responses: {200: {description: OK}}}\n"; + foreach (var mod in mods) + { + var sp = H.Plural(H.Snake(mod.Name)).Replace("_","-"); + var pa = H.Pascal(mod.Name); + oa += $" /api/{sp}:\n"; + oa += $" get: {{operationId: list{pa}, tags: [{pa}], security: [{{cookieAuth: []}}], responses: {{200: {{description: OK}}}}}}\n"; + oa += $" post: {{operationId: create{pa}, tags: [{pa}], security: [{{cookieAuth: []}}], responses: {{201: {{description: Created}}}}}}\n"; + oa += $" /api/{sp}/{{id}}:\n"; + oa += $" get: {{operationId: get{pa}, tags: [{pa}], security: [{{cookieAuth: []}}], responses: {{200: {{description: OK}}}}}}\n"; + oa += $" put: {{operationId: update{pa}, tags: [{pa}], security: [{{cookieAuth: []}}], responses: {{200: {{description: OK}}}}}}\n"; + oa += $" delete: {{operationId: delete{pa}, tags: [{pa}], security: [{{cookieAuth: []}}], responses: {{204: {{description: No Content}}}}}}\n"; + } + oa += "components:\n securitySchemes:\n cookieAuth: {type: apiKey, in: cookie, name: access_token}\n"; + files["openapi.yaml"] = oa; + + return files; + } +} + +// ─── Stage 4: PackZip ───────────────────────────────────────────────────────── +static class Stage4 +{ + public static byte[] PackZip(Dictionary files) + { + using var ms = new MemoryStream(); + using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + foreach (var (path, content) in files.OrderBy(kv => kv.Key)) + using (var sw = new StreamWriter(zip.CreateEntry(path, CompressionLevel.Optimal).Open())) + sw.Write(content); + return ms.ToArray(); + } + + public static void WriteDisk(Dictionary files, string baseDir) + { + foreach (var (path, content) in files) + { + var full = Path.Combine(baseDir, path.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, content, Encoding.UTF8); + } + Console.WriteLine($"Wrote {files.Count} files to {baseDir}"); + } +} + diff --git a/archiet_microcodegen_dotnet/README.md b/archiet_microcodegen_dotnet/README.md new file mode 100644 index 0000000..bd40351 --- /dev/null +++ b/archiet_microcodegen_dotnet/README.md @@ -0,0 +1,226 @@ +# archiet-microcodegen-dotnet + +> PRD text → working ASP.NET Core 8 app → ZIP, in <1400 LOC, pure .NET BCL, zero LLM calls. +> Inspired by Karpathy's micrograd: this file is the complete algorithm. + +[![NuGet](https://img.shields.io/nuget/v/archiet-microcodegen-dotnet)](https://www.nuget.org/packages/archiet-microcodegen-dotnet) +[![.NET](https://img.shields.io/badge/.NET-8.0-512BD4)](https://dotnet.microsoft.com) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +--- + +## The fastest path from requirements to a running ASP.NET Core REST API + +You have a PRD (a Markdown file, a Confluence export, a Notion page). +You want an **ASP.NET Core 8 Web API** with real auth, a real database, and real routing — ready to `docker compose up`. +Most generators give you a skeleton. This gives you *your* app in 3 seconds. + +```bash +dotnet tool install -g archiet-microcodegen-dotnet +archiet-microcodegen-dotnet prd.md --out ./my-app +cd my-app && cp .env.example .env && docker compose up +``` + +First request hits `/api/auth/register` before the coffee is done. + +--- + +## Install + +```bash +# Global dotnet tool install (recommended) +dotnet tool install -g archiet-microcodegen-dotnet + +# Or build from source +git clone https://github.com/aniekanasuquookono-web/archiet +cd archiet/archiet_microcodegen_dotnet +dotnet run -- prd.md --out ./my-app +``` + +--- + +## Use + +### CLI + +```bash +# Write files to a directory +archiet-microcodegen-dotnet prd.md --out ./my-api + +# Write a ZIP instead +archiet-microcodegen-dotnet prd.md --zip my-api.zip + +# Then boot +cd my-api +cp .env.example .env # edit DATABASE_URL, JWT_SECRET +docker compose up # Postgres + ASP.NET Core +``` + +--- + +## Sample input: a real PRD excerpt + +```markdown +# Task Manager + +## Entities + +**Project** + - name: string (required) + - description: text + - status: string (required) + +**Task** + - title: string (required) + - body: text + - due_date: date + - priority: string +``` + +**Output:** a complete ASP.NET Core 8 app with `Project` and `Task` EF Core models, +per-tenant isolation, JWT auth in an httpOnly cookie, migrations, Dockerfile, and +`openapi.yaml` — ready to `docker compose up`. + +--- + +## What you get + +| File | What it does | +|---|---| +| `{AppName}.csproj` | SDK-style project, `net8.0`, EF Core + Npgsql + BCrypt NuGet refs | +| `Program.cs` | Minimal hosting model: DI wiring, middleware, `Database.Migrate()` on boot | +| `Data/AppDbContext.cs` | `DbContext` with a `DbSet` per entity | +| `Models/User.cs` | `Id`, `Name`, `Email`, `PasswordHash`, `CreatedAt` | +| `Models/{Entity}.cs` | `Id`, `UserId` (FK), domain fields, `CreatedAt`, `UpdatedAt` | +| `DTOs/{Entity}Dto.cs` | Input DTO for create / update | +| `Services/JwtService.cs` | `Encode(userId, email)` / `Decode(token)` — pure BCL, no external package | +| `Auth/JwtMiddleware.cs` | Validates `access_token` cookie, injects `UserId` into `HttpContext.Items` | +| `Controllers/AuthController.cs` | `register`, `login`, `logout`, `me` | +| `Controllers/{Entity}Controller.cs` | Full CRUD, every query `.Where(e => e.UserId == Uid)` | +| `appsettings.json` | Minimal logging config | +| `.env.example` | All required env vars pre-documented | +| `Dockerfile` | Multi-stage `mcr.microsoft.com/dotnet/sdk:8.0-alpine` build | +| `docker-compose.yml` | App + Postgres 16, healthcheck-gated | +| `ARCHITECTURE.md` | ArchiMate 3.2 ApplicationComponent + DataObject inventory | +| `openapi.yaml` | Machine-readable API contract | + +--- + +## The four stages + +``` +ParsePrd(text) → Manifest (entities, stories, integrations) +ManifestToGenome(manifest) → Genome (ArchiMate 3.2 typed IR) +RenderGenome(genome) → files (ASP.NET Core 8 C# source) +PackZip(files) / WriteDisk(files, dir) +``` + +**Stage 1** — regex-based PRD parser. Finds entities, fields, user stories, and +third-party integrations (Stripe, SendGrid, Twilio, …) without an LLM. + +**Stage 2** — converts the manifest into a typed `Genome` record. Every entity gains +`Id`, `UserId`, `CreatedAt`, `UpdatedAt` automatically. The genome is a plain C# record +— no reflection, no attributes, no magic. + +**Stage 3** — renders all ASP.NET Core files from the genome. `JwtService` is a pure BCL +implementation using `HMACSHA256` and `CryptographicOperations.FixedTimeEquals` — no +`Microsoft.IdentityModel.Tokens` package required in the generator. + +**Stage 4** — writes files using `System.IO.Compression.ZipArchive` (BCL). No NuGet +dependency for the generator. The generated app references EF Core, Npgsql, and BCrypt +in its own `.csproj`. + +--- + +## Security by default + +- **httpOnly cookie, not localStorage.** The `access_token` cookie is `HttpOnly = true`, + `SameSite = Lax`, `Expires = +7 days`. The JWT payload never touches JavaScript. +- **Per-tenant isolation.** Every EF Core query includes `.Where(e => e.UserId == Uid)`. + `Uid` comes from `HttpContext.Items["UserId"]` set by the JWT middleware. There is no + code path that returns another user's data. +- **Constant-time signature comparison.** `CryptographicOperations.FixedTimeEquals` + prevents timing attacks on the JWT signature check. +- **Zero hardcoded secrets.** `JWT_SECRET` and `DATABASE_URL` are environment variables. + The app throws `InvalidOperationException` at startup if either is missing. + +--- + +## archiet-microcodegen-dotnet vs the alternatives + +| | `archiet-microcodegen-dotnet` | `dotnet new webapi` | Visual Studio scaffolding | +|---|---|---|---| +| Input | Your PRD | Nothing | Single model name | +| Output | Full CRUD API for all entities | Empty skeleton | One CRUD controller | +| Auth | JWT httpOnly cookie + BCrypt | None | Identity (session-based) | +| Per-tenant isolation | Built-in (`.Where(e => e.UserId == Uid)`) | None | None | +| `docker-compose.yml` | ✅ | ❌ | ❌ | +| `openapi.yaml` | ✅ | Swagger UI only | ❌ | +| `ARCHITECTURE.md` | ✅ ArchiMate 3.2 | ❌ | ❌ | +| LLM / API key | ❌ Never | ❌ | ❌ | + +--- + +## FAQ + +**Does the generated app really boot with `docker compose up`?** +Yes. The generated `Dockerfile` uses a multi-stage `dotnet/sdk:8.0-alpine` build. +`docker-compose.yml` waits for Postgres `pg_isready` before starting the app. +`Database.Migrate()` runs at app startup — no manual migration step. + +**Is the generator itself pure .NET BCL?** +Yes. `Program.cs` uses only `System.IO.Compression`, `System.Text.RegularExpressions`, +`System.Security.Cryptography`, and `System.Linq`. No NuGet packages in the generator's +own `.csproj`. The generated app has its own NuGet dependencies. + +**Why BCrypt for password hashing instead of `PasswordHasher`?** +The generated app uses `BCrypt.Net-Next` because it is the most widely understood +.NET password hashing library. If you prefer `PasswordHasher` (ASP.NET Core Identity), +the generator is a single C# file — adapt Stage 3. + +**What EF Core provider does it use?** +Npgsql (PostgreSQL). The generated `DATABASE_URL` format is +`Host=db;Database=app;Username=app;Password=changeme` — standard Npgsql connection string. + +**Does it generate EF Core migrations?** +The generated app calls `Database.Migrate()` on boot, which applies pending migrations. +The migrations themselves are not pre-generated in the ZIP — run `dotnet ef migrations add Init` +once after setting up your database. Alternatively, replace `Database.Migrate()` with +`Database.EnsureCreated()` for development convenience. + +**What's NOT generated?** +Background workers, SignalR hubs, gRPC, Blazor/Razor views, Identity UI, multi-schema +multi-tenancy, and health check endpoints. For a full-stack app generated from your +architecture diagram, see +[archiet.com](https://archiet.com?utm_source=nuget&utm_medium=package&utm_campaign=microcodegen-dotnet). + +--- + +## Why this exists + +Architecture before code. A vibe-coded .NET app has controllers and DbSets. +An *architected* .NET app has a formal representation of why those controllers and DbSets +exist — what requirement they satisfy, what component they belong to, what boundaries +they must not cross. + +`archiet-microcodegen-dotnet` encodes that representation as an ArchiMate 3.2 genome +and renders it deterministically. Same PRD → same app. No hallucinations. + +The genome is not a prompt. It is a typed intermediate representation: every entity maps +to a C# `record`, every field has a CLR type, every auth rule is a structural constraint +— not a comment in a template. + +For teams that want a full architecture-to-code platform (multi-stack, governance, PRD +intake, quality scoring, delivery gates), visit +[archiet.com](https://archiet.com?utm_source=nuget&utm_medium=package&utm_campaign=microcodegen-dotnet). + +--- + +## Links + +- **SDD guide:** [github.com/Anioko/spec-driven-development](https://github.com/Anioko/spec-driven-development) +- **Compliance guide:** [github.com/Anioko/compliance-from-architecture](https://github.com/Anioko/compliance-from-architecture) — SOC 2, GDPR, **EU AI Act Annex IV** +- **EU AI Act (deadline Aug 2026):** [Free risk classifier](https://archiet.com/tools/eu-ai-act-risk-classifier) · [Annex IV use case](https://archiet.com/use-cases/eu-ai-act-high-risk-ai-compliance) +- Full platform: [archiet.com](https://archiet.com?utm_source=nuget&utm_medium=package&utm_campaign=microcodegen-dotnet) + +*Generated with [archiet-microcodegen-dotnet](https://www.nuget.org/packages/archiet-microcodegen-dotnet)* diff --git a/archiet_microcodegen_dotnet/archiet-microcodegen-dotnet.csproj b/archiet_microcodegen_dotnet/archiet-microcodegen-dotnet.csproj new file mode 100644 index 0000000..871b86b --- /dev/null +++ b/archiet_microcodegen_dotnet/archiet-microcodegen-dotnet.csproj @@ -0,0 +1,17 @@ + + + Exe + net8.0 + enable + enable + archiet-microcodegen-dotnet + true + archiet-microcodegen-dotnet + archiet-microcodegen-dotnet + 0.1.0 + PRD text -> ASP.NET Core 8 app -> ZIP. Pure .NET BCL. Zero LLM calls. + MIT + https://archiet.com?utm_source=nuget&utm_medium=package&utm_campaign=microcodegen-dotnet + aspnetcore;codegen;scaffold;archiet;rest-api;generator + + diff --git a/archiet_microcodegen_flask/README.md b/archiet_microcodegen_flask/README.md new file mode 100644 index 0000000..eac751a --- /dev/null +++ b/archiet_microcodegen_flask/README.md @@ -0,0 +1,104 @@ +# archiet-microcodegen-flask + +> PRD text → working Flask + SQLAlchemy app → ZIP, in <1400 LOC, pure stdlib, zero LLM calls. +> Inspired by Karpathy's micrograd: this file is the complete algorithm. + +Built on [Archiet](https://archiet.com?utm_source=pypi&utm_medium=package&utm_campaign=microcodegen-flask) — AI-native architecture-to-code platform. + +## Install + +```bash +pip install archiet-microcodegen-flask +``` + +## Use + +```bash +# Write ZIP to disk +archiet-microcodegen-flask path/to/prd.md --out ./out/ + +# Pipe ZIP to stdout +archiet-microcodegen-flask path/to/prd.md > app.zip +``` + +As a library: + +```python +from archiet_microcodegen_flask import microcodegen_flask + +prd_text = open("prd.md").read() +zip_bytes = microcodegen_flask(prd_text) +``` + +## What you get + +The generated output is a working Flask + PostgreSQL app: + +``` +app/__init__.py Flask app factory — create_app() +app/extensions.py db, jwt, migrate singletons +app/auth/routes.py register / login / logout / me +app/models/user.py User model +app/models/.py One SQLAlchemy model per entity +app/routes/.py One Blueprint per entity (full CRUD) +config.py JWT_TOKEN_LOCATION=["cookies"], JWT_COOKIE_HTTPONLY=True +manage.py Flask CLI entrypoint +requirements.txt flask, flask-sqlalchemy, flask-jwt-extended, flask-migrate, psycopg2-binary +docker-compose.yml Postgres 16 with healthcheck-gated startup +Dockerfile python:3.12-slim, port 5000 +.env.example DATABASE_URL, JWT_SECRET_KEY, SECRET_KEY +tests/test_app.py pytest smoke tests (register, login, health) +ARCHITECTURE.md ArchiMate 3.2 element map +openapi.yaml OpenAPI 3.1 spec +GENOME.json Intermediate representation (for debugging/regeneration) +README.md Quick-start for the generated app +``` + +## The four stages + +1. **`parse_prd(text) → manifest`** — regex extraction of entities, fields, user stories, integrations +2. **`manifest_to_genome(manifest) → genome`** — maps to canonical IR with ArchiMate 3.2 element typing +3. **`render_genome(genome) → {path: content}`** — `string.Template`-based Flask rendering +4. **`pack(files) → bytes`** — stdlib `zipfile` + +## Auth — httpOnly cookies, non-negotiable + +- `JWT_TOKEN_LOCATION = ["cookies"]` +- `JWT_COOKIE_HTTPONLY = True` +- `JWT_COOKIE_SAMESITE = "Lax"` +- Register → sets cookie. Login → sets cookie. Logout → clears cookie. +- NEVER localStorage. NEVER Authorization header in response body. + +## Why this exists + +Spec-driven architecture before vibecoding. The genome is an ArchiMate 3.2 +intermediate representation — your PRD becomes an architecture document, +not just a prompt. + +The full [Archiet](https://archiet.com?utm_source=pypi&utm_medium=package&utm_campaign=microcodegen-flask) platform adds: + +- LLM-powered PRD extraction (chunked, overlap+dedup, handles natural language) +- 12+ stack renderers (FastAPI, Flask, Django, NestJS, Laravel, Go, Java, Rails, .NET, Tauri+Rust, Salesforce, SAP CAP) +- Capability emission, frontend (Next.js / Expo), stub-filling, quality scoring, delivery gates +- Cross-stack parity enforcement and verification + +But none of that changes the **core algorithm**. If a bug doesn't reproduce here, it's in an efficiency layer. + +## What's NOT included + +- No LLM calls (deterministic zone by design) +- No frontend, no mobile, no payment integration, no rate limiting, no audit logging +- No multi-stack output (Flask only; see `archiet-microcodegen` for the FastAPI variant) + +## License + +MIT. See [LICENSE](https://github.com/aniekanasuquookono-web/archiet/blob/main/LICENSE). + +## Links + +- **SDD guide:** [github.com/Anioko/spec-driven-development](https://github.com/Anioko/spec-driven-development) +- **Compliance guide:** [github.com/Anioko/compliance-from-architecture](https://github.com/Anioko/compliance-from-architecture) — SOC 2, GDPR, **EU AI Act Annex IV** +- **EU AI Act (deadline Aug 2026):** [Free risk classifier](https://archiet.com/tools/eu-ai-act-risk-classifier) · [Annex IV use case](https://archiet.com/use-cases/eu-ai-act-high-risk-ai-compliance) +- Source: [github.com/aniekanasuquookono-web/archiet](https://github.com/aniekanasuquookono-web/archiet) +- FastAPI variant: [archiet-microcodegen](https://pypi.org/project/archiet-microcodegen/) +- Full platform: [archiet.com](https://archiet.com?utm_source=pypi&utm_medium=package&utm_campaign=microcodegen-flask) diff --git a/archiet_microcodegen_flask/__init__.py b/archiet_microcodegen_flask/__init__.py new file mode 100644 index 0000000..ae9f3f5 --- /dev/null +++ b/archiet_microcodegen_flask/__init__.py @@ -0,0 +1,47 @@ +"""archiet-microcodegen-flask — PRD text → Flask app ZIP, pure stdlib, zero LLM calls. + +Flask variant of the Archiet microcodegen algorithm. + +Public API: + + from archiet_microcodegen_flask import microcodegen_flask, parse_prd + from archiet_microcodegen_flask import manifest_to_genome, render_genome, pack + + # or use the alias: + from archiet_microcodegen_flask import microcodegen + +CLI: + + archiet-microcodegen-flask path/to/prd.md > app.zip + archiet-microcodegen-flask path/to/prd.md --out ./out/ + +Constraints: + - Pure stdlib; zero app.* / agents.* / templates/ imports + - Hard ceiling: <1400 LOC for the core algorithm (_core.py) + - No LLM calls; deterministic regex extraction only +""" + +from __future__ import annotations + +from archiet_microcodegen_flask._core import ( + main, + manifest_to_genome, + microcodegen, + microcodegen_flask, + pack, + parse_prd, + render_genome, +) + +__version__ = "0.1.0" + +__all__ = [ + "__version__", + "main", + "manifest_to_genome", + "microcodegen", + "microcodegen_flask", + "pack", + "parse_prd", + "render_genome", +] diff --git a/archiet_microcodegen_flask/__main__.py b/archiet_microcodegen_flask/__main__.py new file mode 100644 index 0000000..a6787d5 --- /dev/null +++ b/archiet_microcodegen_flask/__main__.py @@ -0,0 +1,11 @@ +"""Allow `python -m archiet_microcodegen_flask` invocation.""" + +from __future__ import annotations + +import sys + +from archiet_microcodegen_flask import main + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/archiet_microcodegen_flask/_core.py b/archiet_microcodegen_flask/_core.py new file mode 100644 index 0000000..96e0d66 --- /dev/null +++ b/archiet_microcodegen_flask/_core.py @@ -0,0 +1,1224 @@ +#!/usr/bin/env python3 +"""archiet_microcodegen_flask._core — Archiet's Flask algorithm in one file. + +PRD text → manifest → genome → rendered Flask app → ZIP bytes. + +Contract: microcodegen_flask(prd_text) → bytes (working, bootable Flask ZIP) + + # CLI: python -m archiet_microcodegen_flask prd.md > app.zip + # python -m archiet_microcodegen_flask prd.md --out /tmp/myapp/ + # Lib: from archiet_microcodegen_flask import microcodegen_flask + +Stages: + 1. parse_prd(text) → manifest dict (regex extraction, no LLM) + 2. manifest_to_genome(manifest) → genome dict + 3. render_genome(genome) → {path: content} (string.Template, Flask only) + 4. pack(files) → bytes (stdlib zipfile) + +NOT: LLM extraction, multi-stack, capability emission, frontend, stub-fill, + quality scoring, rate limiting, observability, secret rotation. + +Constraints: + - Pure stdlib; zero app.* / agents.* / templates/ imports. + - No TODOs — handle it or scope it out explicitly. + - Hard ceiling: 1400 LOC. +""" + +from __future__ import annotations + +import argparse +import io +import json +import re +import secrets +import string +import sys +import zipfile +from pathlib import Path + +# ─── STAGE 1 ──────────────────────────────────────────────────────────────── +# parse_prd(text) → manifest dict. +# +# Pure regex + heuristic extraction. Copied verbatim from microcodegen.py. +# Language-agnostic: no FastAPI or Flask concepts here. + +_ENTITY_PATTERN = re.compile( + r"^#{1,3}\s*(?:entities|data models|domain models|entity list)\s*:?\s*$", + re.IGNORECASE | re.MULTILINE, +) + +_ENTITY_NAME_PATTERN = re.compile( + r"^[\s\-\*\#]+\*{0,2}([A-Z][a-zA-Z0-9_]{1,40})\*{0,2}[ \t]*(?::|—|-|[ \t]|$)", + re.MULTILINE, +) + +_FIELD_PATTERN = re.compile( + r"^[\s\-\*]+([a-z_][a-z0-9_]{0,40})\s*[:—-]\s*([a-zA-Z]+)([^\n]*)", + re.MULTILINE, +) + +_INLINE_FIELD_PATTERN = re.compile( + r"([a-z_][a-z0-9_]{0,40})\s*\(\s*([a-zA-Z]+)([^)]*)\)", +) + +_USER_STORY_PATTERN = re.compile( + r"As\s+(?:a|an)\s+([^,]+?),\s+I\s+want\s+(?:to\s+)?([^,]+?)(?:,?\s*so\s+that\s+([^.]+))?\.", + re.IGNORECASE, +) + +_INTEGRATION_KEYWORDS = { + "stripe": {"name": "stripe", "category": "payments"}, + "auth0": {"name": "auth0", "category": "auth"}, + "clerk": {"name": "clerk", "category": "auth"}, + "supabase": {"name": "supabase", "category": "auth"}, + "sendgrid": {"name": "sendgrid", "category": "email"}, + "postmark": {"name": "postmark", "category": "email"}, + "twilio": {"name": "twilio", "category": "sms"}, + "datadog": {"name": "datadog", "category": "observability"}, + "segment": {"name": "segment", "category": "analytics"}, +} + + +def _parse_field_modifiers(modifier_text: str) -> dict: + """Extract 'required', 'unique', 'indexed' flags from modifier text.""" + flags = {"required": False, "unique": False, "indexed": False} + text = modifier_text.lower() + if "required" in text or "not null" in text or " ! " in text: + flags["required"] = True + if "unique" in text: + flags["unique"] = True + if "indexed" in text or "index" in text: + flags["indexed"] = True + return flags + + +def _solution_name_from_prd(text: str) -> str: + """Pull the first H1 (# Title) as the solution name; fall back to a default.""" + m = re.match(r"^#\s+(.+?)\s*$", text, re.MULTILINE) + if m: + return m.group(1).strip() + return "Generated App" + + +def parse_prd(text: str) -> dict: + """Extract a manifest dict from raw PRD text. + + Returns shape: + { + "solution_name": str, + "entities": [{"name": str, "fields": [{"name", "type", "required", + "unique", "indexed"}]}], + "user_stories": [{"as_a": str, "i_want": str, "so_that": str}], + "integrations": [{"name": str, "category": str}], + } + """ + solution_name = _solution_name_from_prd(text) + + entities: list[dict] = [] + section_match = _ENTITY_PATTERN.search(text) + entity_section = "" + if section_match: + start = section_match.end() + next_header = re.search(r"^#{1,2}\s+\S", text[start:], re.MULTILINE) + end = start + next_header.start() if next_header else len(text) + entity_section = text[start:end] + + seen: set[str] = set() + for m in _ENTITY_NAME_PATTERN.finditer(entity_section): + ename = m.group(1) + if ename in seen: + continue + seen.add(ename) + ent_start = m.end() + next_entity = _ENTITY_NAME_PATTERN.search(entity_section, ent_start) + ent_end = next_entity.start() if next_entity else len(entity_section) + ent_body = entity_section[ent_start:ent_end] + + fields: list[dict] = [] + seen_fields: set[str] = set() + for fm in _FIELD_PATTERN.finditer(ent_body): + fname, ftype, modifier_text = fm.group(1), fm.group(2), fm.group(3) + if fname in seen: + continue + if fname in seen_fields: + continue + seen_fields.add(fname) + flags = _parse_field_modifiers(modifier_text) + fields.append({"name": fname, "type": ftype.lower(), **flags}) + + if not fields: + entity_name_line = ( + entity_section[m.start():m.end()] + ent_body.split("\n", 1)[0] + ) + for im in _INLINE_FIELD_PATTERN.finditer(entity_name_line): + fname, ftype, modifier_text = im.group(1), im.group(2), im.group(3) + if fname in seen_fields: + continue + seen_fields.add(fname) + flags = _parse_field_modifiers(modifier_text) + fields.append({"name": fname, "type": ftype.lower(), **flags}) + + entities.append({"name": ename, "fields": fields}) + + stories = [] + for m in _USER_STORY_PATTERN.finditer(text): + stories.append({ + "as_a": (m.group(1) or "").strip(), + "i_want": (m.group(2) or "").strip(), + "so_that": (m.group(3) or "").strip(), + }) + + integrations = [] + text_lower = text.lower() + for vendor, spec in _INTEGRATION_KEYWORDS.items(): + if vendor in text_lower: + integrations.append(spec) + + return { + "solution_name": solution_name, + "entities": entities, + "user_stories": stories, + "integrations": integrations, + } + + +# ─── STAGE 2 ──────────────────────────────────────────────────────────────── +# manifest_to_genome(manifest) → genome dict. +# +# Maps the heuristic manifest into the canonical genome shape. +# Language-agnostic: copied verbatim from microcodegen.py with +# language field changed to "flask". + + +def _snake(s: str) -> str: + """Convert "Order Line" → "order_line".""" + s = re.sub(r"[^a-zA-Z0-9]+", "_", s.strip()).strip("_") + s = re.sub(r"([a-z])([A-Z])", r"\1_\2", s) + return s.lower() + + +def manifest_to_genome(manifest: dict) -> dict: + """Map the heuristic manifest into the canonical genome shape. + + Identical to the FastAPI version except language="flask". + """ + name = manifest["solution_name"] + snake_name = _snake(name) + + entities_dict: dict[str, dict] = {} + for ent in manifest.get("entities", []): + fields: dict[str, dict] = {"id": {"type": "uuid", "required": True}} + for f in ent.get("fields", []): + if f["name"] in ("id", "created_at", "updated_at"): + continue + fields[f["name"]] = { + "type": f["type"], + "required": f["required"], + "unique": f["unique"], + "indexed": f["indexed"], + } + entities_dict[ent["name"]] = { + "fields": fields, + "description": f"{ent['name']} entity (generated by microcodegen-flask)", + "archimate_type": "DataObject", + } + + _workflow_verbs = { + "create", "update", "delete", "approve", "reject", "submit", + "complete", "process", "generate", "schedule", "notify", + } + archimate_elements: list[dict] = [ + { + "name": name, + "type": "ApplicationComponent", + "description": f"{name} Flask application", + }, + ] + for ent_name in entities_dict: + archimate_elements.append({ + "name": ent_name, + "type": "DataObject", + "description": entities_dict[ent_name]["description"], + }) + for story in manifest.get("user_stories", []): + text = story.get("i_want", story.get("story", "")).lower() + if any(v in text for v in _workflow_verbs): + label = text[:60] + archimate_elements.append({ + "name": label, + "type": "BusinessProcess", + "description": f"I want to {text}", + }) + for intg in manifest.get("integrations", []): + intg_name = intg.get("name", str(intg)) if isinstance(intg, dict) else str(intg) + archimate_elements.append({ + "name": intg_name, + "type": "ApplicationService", + "description": f"External integration: {intg_name}", + }) + + return { + "genome_version": "1.0.0", + "solution_id": 0, + "solution_name": name, + "bundle_id": snake_name, + "language": "flask", + "modules": { + "core": { + "module_type": "crud", + "description": "Core entities", + "entities": entities_dict, + }, + }, + "user_stories": manifest.get("user_stories", []), + "integrations": manifest.get("integrations", []), + "archimate_elements": archimate_elements, + } + + +# ─── STAGE 3 ──────────────────────────────────────────────────────────────── +# render_genome(genome) → {path: content}. +# +# Flask + SQLAlchemy 2.0 + Flask-JWT-Extended + Flask-Migrate. +# httpOnly JWT cookies. Blueprint-per-entity CRUD. + +_FLASK_TEMPLATES: dict[str, string.Template] = { + "requirements.txt": string.Template("""\ +flask>=3.0 +flask-sqlalchemy>=3.1 +flask-jwt-extended>=4.6 +flask-migrate>=4.0 +flask-cors>=4.0 +psycopg2-binary>=2.9 +python-dotenv>=1.0 +"""), + + "config.py": string.Template("""\ +import os + + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-in-production") + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") + if not SQLALCHEMY_DATABASE_URI: + raise RuntimeError("DATABASE_URL environment variable is not set.") + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # JWT stored in httpOnly cookies — never localStorage, never response body. + JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY") + if not JWT_SECRET_KEY: + raise RuntimeError("JWT_SECRET_KEY environment variable is not set.") + JWT_TOKEN_LOCATION = ["cookies"] + JWT_COOKIE_HTTPONLY = True + JWT_COOKIE_SAMESITE = "Lax" + JWT_ACCESS_TOKEN_EXPIRES_MINUTES = 60 * 24 * 7 # 7 days +"""), + + "app/__init__.py": string.Template("""\ +from flask import Flask + +from app.extensions import db, jwt, migrate + + +def create_app(config_object="config.Config"): + app = Flask(__name__) + app.config.from_object(config_object) + + db.init_app(app) + jwt.init_app(app) + migrate.init_app(app, db) + + # Import models so Flask-Migrate discovers them. + with app.app_context(): + import app.models # noqa: F401 + + from app.auth.routes import auth_bp + app.register_blueprint(auth_bp, url_prefix="/api/auth") + +$blueprint_registrations + + @app.get("/health") + def health(): + from flask import jsonify + return jsonify({"status": "ok", "version": "$bundle_id"}) + + return app +"""), + + "app/extensions.py": string.Template("""\ +from flask_jwt_extended import JWTManager +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() +jwt = JWTManager() +migrate = Migrate() +"""), + + "app/models/__init__.py": string.Template("""\ +# Import all models here so Flask-Migrate / db.create_all() discovers them. +from app.models.user import User # noqa: F401 +$model_imports +"""), + + "app/models/user.py": string.Template("""\ +from datetime import datetime, timezone + +from app.extensions import db + + +class User(db.Model): + __tablename__ = "users" + + id = db.Column(db.String(36), primary_key=True) + email = db.Column(db.String(255), unique=True, nullable=False, index=True) + password_hash = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) +"""), + + "app/models/_entity.py": string.Template("""\ +from datetime import datetime, timezone + +from sqlalchemy import Boolean, Column, Date, DateTime, Float +from sqlalchemy import Integer, Numeric, String, Text +try: + from sqlalchemy import JSON +except ImportError: + from sqlalchemy import JSON # SQLAlchemy 2.0+ + +from app.extensions import db + + +class $entity_name(db.Model): + __tablename__ = "$table_name" + + id = db.Column(db.String(36), primary_key=True) + # Per-tenant ownership: every row is scoped to the user that created it. + # All queries in the blueprint filter by this to prevent cross-user leaks. + user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=False, index=True) +$columns + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column( + db.DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) +"""), + + "app/auth/__init__.py": string.Template(""), + + "app/auth/routes.py": string.Template("""\ +import uuid +from datetime import timedelta + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import ( + create_access_token, + get_jwt_identity, + jwt_required, + set_access_cookies, + unset_jwt_cookies, +) +from werkzeug.security import check_password_hash, generate_password_hash + +from app.extensions import db +from app.models.user import User + +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.post("/register") +def register(): + data = request.get_json(silent=True) or {} + email = (data.get("email") or "").strip().lower() + password = data.get("password") or "" + if not email or not password: + return jsonify({"error": "email and password are required"}), 400 + if len(password) < 8: + return jsonify({"error": "Password must be at least 8 characters"}), 400 + if User.query.filter_by(email=email).first(): + return jsonify({"error": "Email already registered"}), 409 + user = User( + id=str(uuid.uuid4()), + email=email, + password_hash=generate_password_hash(password), + ) + db.session.add(user) + db.session.commit() + token = create_access_token( + identity=user.id, expires_delta=timedelta(days=7) + ) + resp = jsonify({"id": user.id, "email": user.email}) + resp.status_code = 201 + set_access_cookies(resp, token) + return resp + + +@auth_bp.post("/login") +def login(): + data = request.get_json(silent=True) or {} + email = (data.get("email") or "").strip().lower() + password = data.get("password") or "" + user = User.query.filter_by(email=email).first() + if not user or not check_password_hash(user.password_hash, password): + return jsonify({"error": "Invalid credentials"}), 401 + token = create_access_token( + identity=user.id, expires_delta=timedelta(days=7) + ) + resp = jsonify({"id": user.id, "email": user.email}) + set_access_cookies(resp, token) + return resp + + +@auth_bp.post("/logout") +def logout(): + resp = jsonify({"ok": True}) + unset_jwt_cookies(resp) + return resp + + +@auth_bp.get("/me") +@jwt_required() +def me(): + user_id = get_jwt_identity() + user = User.query.get(user_id) + if not user: + return jsonify({"error": "User not found"}), 404 + return jsonify({"id": user.id, "email": user.email}) +"""), + + "app/routes/_entity.py": string.Template("""\ +import uuid + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from app.extensions import db +from app.models.$snake_entity import $entity_name + +${snake_entity}_bp = Blueprint("${snake_entity}", __name__) + + +@${snake_entity}_bp.get("/") +@jwt_required() +def list_${snake_entity}s(): + user_id = get_jwt_identity() + items = $entity_name.query.filter_by(user_id=user_id).all() + return jsonify([_to_dict(i) for i in items]) + + +@${snake_entity}_bp.post("/") +@jwt_required() +def create_${snake_entity}(): + user_id = get_jwt_identity() + data = request.get_json(silent=True) or {} + obj = $entity_name(id=str(uuid.uuid4()), user_id=user_id) +$field_setters + db.session.add(obj) + db.session.commit() + return jsonify(_to_dict(obj)), 201 + + +@${snake_entity}_bp.get("/") +@jwt_required() +def get_${snake_entity}(item_id): + user_id = get_jwt_identity() + obj = $entity_name.query.filter_by(id=item_id, user_id=user_id).first() + if not obj: + return jsonify({"error": "$entity_name not found"}), 404 + return jsonify(_to_dict(obj)) + + +@${snake_entity}_bp.put("/") +@jwt_required() +def update_${snake_entity}(item_id): + user_id = get_jwt_identity() + obj = $entity_name.query.filter_by(id=item_id, user_id=user_id).first() + if not obj: + return jsonify({"error": "$entity_name not found"}), 404 + data = request.get_json(silent=True) or {} +$field_updaters + db.session.commit() + return jsonify(_to_dict(obj)) + + +@${snake_entity}_bp.delete("/") +@jwt_required() +def delete_${snake_entity}(item_id): + user_id = get_jwt_identity() + obj = $entity_name.query.filter_by(id=item_id, user_id=user_id).first() + if not obj: + return jsonify({"error": "$entity_name not found"}), 404 + db.session.delete(obj) + db.session.commit() + return "", 204 + + +def _to_dict(obj) -> dict: + d = { + "id": obj.id, + "user_id": obj.user_id, +$dict_fields + } + if hasattr(obj, "created_at") and obj.created_at: + d["created_at"] = obj.created_at.isoformat() + if hasattr(obj, "updated_at") and obj.updated_at: + d["updated_at"] = obj.updated_at.isoformat() + return d +"""), + + "manage.py": string.Template("""\ +import os + +from flask.cli import FlaskGroup + +from app import create_app + +app = create_app() +cli = FlaskGroup(app) + +if __name__ == "__main__": + cli() +"""), + + ".env.example": string.Template("""\ +DATABASE_URL=postgresql://archiet:archiet@localhost:5432/$bundle_id +JWT_SECRET_KEY=$jwt_secret_key +SECRET_KEY=$secret_key +"""), + + "Dockerfile": string.Template("""\ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 5000 +ENV FLASK_APP=manage.py +CMD ["flask", "--app", "manage:app", "run", "--host", "0.0.0.0", "--port", "5000"] +"""), + + "docker-compose.yml": string.Template("""\ +services: + app: + build: . + ports: ["5000:5000"] + environment: + DATABASE_URL: postgresql://archiet:archiet@db:5432/$bundle_id + JWT_SECRET_KEY: $jwt_secret_key + SECRET_KEY: $secret_key + depends_on: + db: + condition: service_healthy + db: + image: postgres:16 + environment: + POSTGRES_USER: archiet + POSTGRES_PASSWORD: archiet + POSTGRES_DB: $bundle_id + volumes: ["pgdata:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U archiet -d $bundle_id"] + interval: 3s + timeout: 3s + retries: 20 +volumes: + pgdata: +"""), + + "README_app.md": string.Template("""\ +# $solution_name + +Generated by archiet-microcodegen-flask — Flask + SQLAlchemy + PostgreSQL. + +## Quick start + +```bash +cp .env.example .env +docker compose up +curl http://localhost:5000/health +``` + +## End-to-end (register, login, CRUD) + +```bash +# 1. Register (JWT set as httpOnly cookie) +curl -c cookies.txt -X POST http://localhost:5000/api/auth/register \\ + -H "Content-Type: application/json" \\ + -d '{"email":"you@example.com","password":"hunter22hunter"}' + +# 2. Create a record +curl -b cookies.txt -X POST http://localhost:5000/api/items/ \\ + -H "Content-Type: application/json" \\ + -d '{"name":"My first item"}' + +# 3. List +curl -b cookies.txt http://localhost:5000/api/items/ +``` + +## Entities + +$entity_list + +## Migrations + +```bash +flask --app manage:app db init +flask --app manage:app db migrate -m "init" +flask --app manage:app db upgrade +``` + +## What's included + +- Flask 3 + PostgreSQL (docker-compose with healthcheck-gated startup) +- JWT-cookie auth (httpOnly, SameSite=Lax): register / login / logout / me +- Flask-JWT-Extended with JWT_TOKEN_LOCATION=["cookies"] +- SQLAlchemy 2.0 models with per-tenant user_id FK on every entity +- Full CRUD per entity: list, create, get, update, delete +- Per-tenant data isolation — every row scoped to the authenticated user +- Flask-Migrate pre-configured +- ARCHITECTURE.md + openapi.yaml shipped in this ZIP +"""), + + "tests/test_app.py": string.Template("""\ +\"\"\"Smoke tests for the generated Flask app. + +Run: pytest tests/test_app.py -v + +Requires PostgreSQL reachable at DATABASE_URL (or TEST_DATABASE_URL). +Tests are skipped automatically if Postgres is not available. +\"\"\" +import os +import uuid + +import pytest + +os.environ.setdefault("JWT_SECRET_KEY", "test-secret-not-for-production") +os.environ.setdefault( + "DATABASE_URL", + os.environ.get("TEST_DATABASE_URL", "postgresql://archiet:archiet@localhost:5432/${bundle_id}_test"), +) +os.environ.setdefault("SECRET_KEY", "test-flask-secret") + + +def _db_reachable() -> bool: + try: + import sqlalchemy + engine = sqlalchemy.create_engine(os.environ["DATABASE_URL"]) + with engine.connect() as conn: + conn.execute(sqlalchemy.text("SELECT 1")) + return True + except Exception: + return False + + +@pytest.fixture(scope="session", autouse=True) +def require_db(): + if not _db_reachable(): + pytest.skip( + "PostgreSQL not reachable — start with `docker compose up -d db` " + "or set TEST_DATABASE_URL and retry." + ) + + +@pytest.fixture +def client(): + from app import create_app + from app.extensions import db as _db + + app = create_app() + app.config["TESTING"] = True + app.config["JWT_COOKIE_CSRF_PROTECT"] = False + + with app.app_context(): + _db.create_all() + with app.test_client() as c: + yield c + _db.drop_all() + + +def test_health(client): + r = client.get("/health") + assert r.status_code == 200 + data = r.get_json() + assert data["status"] == "ok" + + +def test_register_and_login(client): + email = f"test-{uuid.uuid4().hex[:8]}@example.com" + r = client.post("/api/auth/register", json={"email": email, "password": "hunter22hunter"}) + assert r.status_code == 201, r.data + data = r.get_json() + assert "id" in data + assert data["email"] == email + + # Login with same credentials + r2 = client.post("/api/auth/login", json={"email": email, "password": "hunter22hunter"}) + assert r2.status_code == 200, r2.data + + # Logout + r3 = client.post("/api/auth/logout") + assert r3.status_code == 200 + + +def test_me_requires_auth(client): + r = client.get("/api/auth/me") + assert r.status_code == 401 +"""), +} + + +def _column_for_field(fname: str, fspec: dict) -> str: + """SQLAlchemy Column() declaration for a single entity field.""" + type_map = { + "string": "db.String(255)", + "text": "db.Text", + "integer": "db.Integer", + "int": "db.Integer", + "float": "db.Float", + "decimal": "db.Numeric(12, 2)", + "boolean": "db.Boolean", + "bool": "db.Boolean", + "datetime": "db.DateTime", + "date": "db.Date", + "uuid": "db.String(36)", + "json": "db.JSON", + } + sa_type = type_map.get(fspec.get("type", "string"), "db.String(255)") + nullable = "" if fspec.get("required") else ", nullable=True" + unique = ", unique=True" if fspec.get("unique") else "" + indexed = ", index=True" if fspec.get("indexed") else "" + return f" {fname} = db.Column({sa_type}{nullable}{unique}{indexed})" + + +def _render_architecture_md(genome: dict, entities: dict) -> str: + """Generate ARCHITECTURE.md with ArchiMate 3.2 element notation.""" + name = genome["solution_name"] + elements = genome.get("archimate_elements", []) + user_stories = genome.get("user_stories", []) + integrations = genome.get("integrations", []) + + lines: list[str] = [ + f"# Architecture — {name}", + "", + "Generated by archiet-microcodegen-flask · ArchiMate 3.2 element notation", + "", + "## Application Layer (ArchiMate §9)", + "", + "| Element | Type | Description |", + "|---------|------|-------------|", + ] + for el in elements: + lines.append(f"| `{el['name']}` | {el['type']} | {el['description']} |") + + lines += [ + "", + "## Relationships", + "", + "```", + f" {name} (ApplicationComponent)", + ] + for ent_name in entities: + lines.append(f" └── {ent_name} (DataObject) [Realization]") + for intg in integrations: + intg_name = intg.get("name", str(intg)) if isinstance(intg, dict) else str(intg) + lines.append(f" └── {intg_name} (ApplicationService) [UsedBy]") + lines.append("```") + + if user_stories: + lines += ["", "## Business Process Layer (ArchiMate §8)", ""] + for story in user_stories[:10]: + lines.append( + f"- As a {story.get('as_a', '')}, I want to {story.get('i_want', '')}" + ) + + lines += [ + "", + "## Stack", + "", + "- **Flask 3** — WSGI application factory (`create_app()`)", + "- **SQLAlchemy 2.0** — ORM models with per-tenant `user_id` FK", + "- **Flask-JWT-Extended** — httpOnly cookie auth (`JWT_TOKEN_LOCATION=[\"cookies\"]`)", + "- **Flask-Migrate** — Alembic-based schema migrations", + "- **PostgreSQL 16** — primary datastore", + "", + "## Notes", + "", + "- This file is heuristically derived from PRD text.", + "- The full Archiet platform generates a formal ArchiMate 3.2 model", + " (ApplicationComponent, DataObject, BusinessProcess, ApplicationService,", + " AssignmentRelationship, RealizationRelationship) from the genome IR,", + " plus DMN 1.5 decision tables and BPMN 2.0 process diagrams.", + "- To regenerate: edit GENOME.json and re-run archiet-microcodegen-flask,", + " or use the Archiet platform for cross-stack + formal-model output.", + ] + return "\n".join(lines) + "\n" + + +def _render_openapi_yaml(genome: dict, entities: dict) -> str: + """Generate openapi.yaml (OpenAPI 3.1) for the Flask app.""" + name = genome["solution_name"] + + _type_map = { + "string": "string", + "text": "string", + "integer": "integer", + "float": "number", + "boolean": "boolean", + "datetime": "string", + "date": "string", + "uuid": "string", + "json": "object", + } + + lines: list[str] = [ + "openapi: '3.1.0'", + "info:", + f" title: {name} API", + f" description: Generated by archiet-microcodegen-flask for {name}", + " version: 0.1.0", + "servers:", + " - url: http://localhost:5000", + " description: Local development", + "paths:", + " /api/auth/register:", + " post:", + " summary: Register a new user", + " tags: [auth]", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + " $ref: '#/components/schemas/AuthRequest'", + " responses:", + " '201': {description: User created}", + " '409': {description: Email already registered}", + " /api/auth/login:", + " post:", + " summary: Login and receive JWT cookie", + " tags: [auth]", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + " $ref: '#/components/schemas/AuthRequest'", + " responses:", + " '200': {description: Authenticated — JWT set in httpOnly cookie}", + " '401': {description: Invalid credentials}", + " /api/auth/logout:", + " post:", + " summary: Logout — clears JWT cookie", + " tags: [auth]", + " responses:", + " '200': {description: Logged out}", + " /api/auth/me:", + " get:", + " summary: Get current user", + " tags: [auth]", + " security: [{cookieAuth: []}]", + " responses:", + " '200': {description: Current user}", + " '401': {description: Not authenticated}", + ] + + for ent_name, ent_spec in entities.items(): + snake = _snake(ent_name) + plural = snake + "s" + props: list[str] = [] + for fname, fspec in (ent_spec.get("fields") or {}).items(): + if fname == "id": + continue + oa_type = _type_map.get(fspec.get("type", "string"), "string") + fmt = "" + if fspec.get("type") in ("datetime", "date"): + fmt = f"\n format: {fspec['type']}-time" + props.append(f" {fname}:\n type: {oa_type}{fmt}") + lines += [ + f" /api/{plural}/:", + " get:", + f" summary: List {ent_name} records", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " responses:", + f" '200': {{description: List of {ent_name}}}", + " post:", + f" summary: Create {ent_name}", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + f" $ref: '#/components/schemas/{ent_name}'", + " responses:", + f" '201': {{description: {ent_name} created}}", + f" /api/{plural}/{{id}}:", + " get:", + f" summary: Get {ent_name} by ID", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " parameters:", + " - in: path", + " name: id", + " required: true", + " schema: {type: string, format: uuid}", + " responses:", + f" '200': {{description: {ent_name} record}}", + " '404': {description: Not found}", + " put:", + f" summary: Update {ent_name}", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " parameters:", + " - in: path", + " name: id", + " required: true", + " schema: {type: string, format: uuid}", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + f" $ref: '#/components/schemas/{ent_name}'", + " responses:", + f" '200': {{description: {ent_name} updated}}", + " delete:", + f" summary: Delete {ent_name}", + f" tags: [{ent_name}]", + " security: [{cookieAuth: []}]", + " parameters:", + " - in: path", + " name: id", + " required: true", + " schema: {type: string, format: uuid}", + " responses:", + " '204': {description: Deleted}", + ] + + lines += [ + "components:", + " securitySchemes:", + " cookieAuth:", + " type: apiKey", + " in: cookie", + " name: access_token_cookie", + " schemas:", + " AuthRequest:", + " type: object", + " required: [email, password]", + " properties:", + " email: {type: string, format: email}", + " password: {type: string, format: password}", + ] + for ent_name, ent_spec in entities.items(): + lines += [ + f" {ent_name}:", + " type: object", + " properties:", + ] + for fname, fspec in (ent_spec.get("fields") or {}).items(): + if fname == "id": + continue + oa_type = _type_map.get(fspec.get("type", "string"), "string") + lines.append(f" {fname}: {{type: {oa_type}}}") + + return "\n".join(lines) + "\n" + + +def render_genome(genome: dict) -> dict[str, str]: + """Render the genome into a {path: content} dict. + + Flask + SQLAlchemy + Flask-JWT-Extended + Flask-Migrate. + JWT stored in httpOnly cookies — never localStorage, never Authorization header. + """ + bundle_id = genome["bundle_id"] + name = genome["solution_name"] + jwt_secret_key = secrets.token_urlsafe(32) + secret_key = secrets.token_urlsafe(32) + files: dict[str, str] = {} + + # Fixed files + for path in ("requirements.txt", "Dockerfile", "docker-compose.yml", "config.py"): + files[path] = _FLASK_TEMPLATES[path].safe_substitute( + bundle_id=bundle_id, + jwt_secret_key=jwt_secret_key, + secret_key=secret_key, + ) + + files["app/extensions.py"] = _FLASK_TEMPLATES["app/extensions.py"].safe_substitute() + files["app/auth/__init__.py"] = "" + files["app/auth/routes.py"] = _FLASK_TEMPLATES["app/auth/routes.py"].safe_substitute() + files["app/models/user.py"] = _FLASK_TEMPLATES["app/models/user.py"].safe_substitute() + files["manage.py"] = _FLASK_TEMPLATES["manage.py"].safe_substitute() + files[".env.example"] = _FLASK_TEMPLATES[".env.example"].safe_substitute( + bundle_id=bundle_id, + jwt_secret_key=jwt_secret_key, + secret_key=secret_key, + ) + + # Package markers + files["app/routes/__init__.py"] = "" + + # Per-entity rendering + blueprint_registrations: list[str] = [] + blueprint_imports: list[str] = [] + model_imports: list[str] = [] + entity_list_lines: list[str] = [] + + entities = (genome["modules"]["core"] or {}).get("entities") or {} + for ent_name, ent_spec in entities.items(): + snake = _snake(ent_name) + table = snake + "s" + + # SQLAlchemy column declarations + cols: list[str] = [] + for fname, fspec in (ent_spec.get("fields") or {}).items(): + if fname == "id": + continue + cols.append(_column_for_field(fname, fspec)) + + # Route field setters (create) and updaters (update) + field_names = [ + fname for fname in (ent_spec.get("fields") or {}) + if fname != "id" + ] + field_setters = "\n".join( + f" obj.{fname} = data.get({fname!r})" for fname in field_names + ) or " pass # no fields extracted" + field_updaters = "\n".join( + f" if {fname!r} in data:\n obj.{fname} = data[{fname!r}]" + for fname in field_names + ) or " pass # no fields" + dict_fields = "\n".join( + f' "{fname}": obj.{fname},' for fname in field_names + ) + + # Model file + files[f"app/models/{snake}.py"] = _FLASK_TEMPLATES[ + "app/models/_entity.py" + ].safe_substitute( + entity_name=ent_name, + table_name=table, + columns="\n".join(cols) if cols else " pass # no fields extracted", + ) + + # Blueprint/route file + files[f"app/routes/{snake}.py"] = _FLASK_TEMPLATES[ + "app/routes/_entity.py" + ].safe_substitute( + entity_name=ent_name, + snake_entity=snake, + field_setters=field_setters, + field_updaters=field_updaters, + dict_fields=dict_fields, + ) + + model_imports.append(f"from app.models.{snake} import {ent_name} # noqa: F401") + blueprint_imports.append( + f" from app.routes.{snake} import {snake}_bp" + ) + blueprint_registrations.append( + f' app.register_blueprint({snake}_bp, url_prefix="/api/{table}")' + ) + entity_list_lines.append(f"- **{ent_name}**: {ent_spec.get('description', '')}") + + # app/models/__init__.py — imports every model so Flask-Migrate sees them + files["app/models/__init__.py"] = _FLASK_TEMPLATES[ + "app/models/__init__.py" + ].safe_substitute( + model_imports="\n".join(model_imports), + ) + + # app/__init__.py — blueprint registration block + reg_block = "\n".join(blueprint_imports + blueprint_registrations) + files["app/__init__.py"] = _FLASK_TEMPLATES["app/__init__.py"].safe_substitute( + bundle_id=bundle_id, + blueprint_registrations=reg_block, + ) + + # Tests + files["tests/test_app.py"] = _FLASK_TEMPLATES["tests/test_app.py"].safe_substitute( + bundle_id=bundle_id, + ) + files["tests/__init__.py"] = "" + + # App README (named README_app.md to avoid collision with package README) + files["README.md"] = _FLASK_TEMPLATES["README_app.md"].safe_substitute( + solution_name=name, + entity_list="\n".join(entity_list_lines) or "_(no entities extracted from PRD)_", + ) + + # Genome for transparency + files["GENOME.json"] = json.dumps(genome, indent=2, default=str) + + # Architecture documents + files["ARCHITECTURE.md"] = _render_architecture_md(genome, entities) + files["openapi.yaml"] = _render_openapi_yaml(genome, entities) + + return files + + +# ─── STAGE 4 ──────────────────────────────────────────────────────────────── +# pack(files) → bytes. stdlib zipfile. + + +def pack(files: dict[str, str]) -> bytes: + """Pack {path: content} into ZIP bytes.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for path, content in sorted(files.items()): + zf.writestr(path, content) + return buf.getvalue() + + +# ─── PUBLIC ENTRY ─────────────────────────────────────────────────────────── + + +def microcodegen_flask(prd_text: str) -> bytes: + """The complete algorithm. PRD text → Flask ZIP bytes.""" + manifest = parse_prd(prd_text) + genome = manifest_to_genome(manifest) + files = render_genome(genome) + return pack(files) + + +# Keep microcodegen as an alias so callers can use the same name as the FastAPI package. +microcodegen = microcodegen_flask + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="archiet-microcodegen-flask: PRD text → Flask app ZIP." + ) + p.add_argument("prd", help="Path to PRD file (markdown/text).") + p.add_argument( + "--out", + help="Directory to extract into. If omitted, writes ZIP bytes to stdout.", + ) + args = p.parse_args(argv) + + prd_text = Path(args.prd).read_text(encoding="utf-8") + + if args.out: + manifest = parse_prd(prd_text) + genome = manifest_to_genome(manifest) + files = render_genome(genome) + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + for path, content in files.items(): + full = out_dir / path + full.parent.mkdir(parents=True, exist_ok=True) + full.write_text(content, encoding="utf-8") + print(f"Wrote {len(files)} files to {out_dir}", file=sys.stderr) + else: + zip_bytes = microcodegen_flask(prd_text) + sys.stdout.buffer.write(zip_bytes) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/archiet_microcodegen_go/PUSH_TO_REPO.md b/archiet_microcodegen_go/PUSH_TO_REPO.md new file mode 100644 index 0000000..aa0feb7 --- /dev/null +++ b/archiet_microcodegen_go/PUSH_TO_REPO.md @@ -0,0 +1,41 @@ +# Deploying to a separate Go repository + +`go install` requires its own GitHub repository — it cannot be a subdirectory of a monorepo. + +## Steps + +1. Create a new repo at: https://github.com/aniekanasuquookono-web/archiet-microcodegen-go + +2. Copy these files from the monorepo: + ``` + archiet_microcodegen_go/main.go + archiet_microcodegen_go/go.mod + archiet_microcodegen_go/README.md + ``` + +3. Push: + ```bash + git init + git add . + git commit -m "feat: archiet-microcodegen-go v0.1.0" + git remote add origin https://github.com/aniekanasuquookono-web/archiet-microcodegen-go.git + git push -u origin main + git tag v0.1.0 + git push --tags + ``` + +4. pkg.go.dev will auto-index within minutes of the first tag. + +5. Users can then run: + ```bash + go install github.com/aniekanasuquookono-web/archiet-microcodegen-go@latest + ``` + +## Note on go.mod module path + +The `go.mod` declares: +``` +module github.com/aniekanasuquookono-web/archiet-microcodegen-go +``` + +This must match the GitHub repo path exactly for `go install` to resolve it. diff --git a/archiet_microcodegen_go/README.md b/archiet_microcodegen_go/README.md new file mode 100644 index 0000000..4f0edf9 --- /dev/null +++ b/archiet_microcodegen_go/README.md @@ -0,0 +1,166 @@ +# archiet-microcodegen-go + +> **Generate a production-ready Go chi REST API from a requirements document. One command. No LLM. No API key. Pure Go stdlib in the generator. 922 lines you can read in 10 minutes.** + +Inspired by Karpathy's `micrograd`: *this file is the complete algorithm. Everything else is just efficiency on top.* + +```bash +go install github.com/aniekanasuquookono-web/archiet-microcodegen-go@latest +archiet-microcodegen-go -prd prd.md -out ./myapp/ +cd myapp && docker compose up +# -> http://localhost:8080/api +``` + +Write a plain-English PRD. Get back a bootable Go chi app with GORM models, full CRUD handlers, JWT auth (httpOnly cookies), per-tenant data isolation, a Makefile, and a Postgres 16 docker-compose -- all without touching a template or hitting an AI API. + +## Install + +```bash +# Requires Go 1.21+ +go install github.com/aniekanasuquookono-web/archiet-microcodegen-go@latest +``` + +The binary is placed in `$GOPATH/bin`. Ensure that directory is in your `$PATH`. + +## Quick example + +Save this as `prd.md`: + +```markdown +# Task Manager + +## Entities +- Task: title (string, required), description (text), status (string), due_date (date) +- Project: name (string, required), description (text) + +## User Stories +As a user, I want to create tasks so I can track my work. +As a user, I want to assign tasks to projects so I can organise them. + +## Integrations +- Stripe for billing +``` + +Run: + +```bash +archiet-microcodegen-go -prd prd.md -out ./taskapp/ +cd taskapp && docker compose up +``` + +You get a fully wired Go chi app: `Task` and `Project` GORM models, full CRUD handlers, JWT auth with httpOnly cookie middleware, per-tenant data isolation, a Makefile with `build` / `test` / `migrate` targets, `ARCHITECTURE.md` with ArchiMate 3.2 notation, and `openapi.yaml` -- zero modifications needed to boot. + +## Use + +**CLI** +```bash +# Write files to a directory: +archiet-microcodegen-go -prd prd.md -out ./myapp/ +cd myapp && docker compose up + +# Write ZIP: +archiet-microcodegen-go -prd prd.md -zip myapp.zip +``` + +**From source (no install)** +```bash +git clone https://github.com/aniekanasuquookono-web/archiet-microcodegen-go +go run . -prd prd.md -out ./myapp/ +``` + +## What you get + +| File | What it does | +|---|---| +| `main.go` | Go chi router bootstrap | +| `internal/database/db.go` | GORM + Postgres connection setup | +| `internal/auth/auth.go` | JWT sign/verify, bcrypt password hashing | +| `internal/auth/middleware.go` | JWT middleware reading httpOnly cookie | +| `internal/auth/handler.go` | POST /auth/register, POST /auth/login, POST /auth/logout | +| `internal/model/{entity}.go` | GORM model with `user_id` field (per-tenant) | +| `internal/handler/{entity}.go` | Full CRUD -- GET / POST / GET :id / PUT :id / DELETE :id | +| `go.mod` / `go.sum` | Go module with chi, GORM, golang-jwt deps | +| `Makefile` | `make build`, `make test`, `make migrate` | +| `docker-compose.yml` | Postgres 16 with healthcheck-gated startup | +| `Dockerfile` | Multi-stage Go 1.21 Alpine build | +| `ARCHITECTURE.md` | ArchiMate 3.2 element map | +| `openapi.yaml` | Machine-readable API contract | + +**Every entity has per-tenant data isolation.** Every handler filters queries by `user_id` from the JWT. No cross-user data leaks. + +## The four stages + +``` +PRD text + | + v ParsePRD() -- regex extraction: entities, fields, user stories, integrations +Manifest struct + | + v ManifestToGenome() -- maps to canonical IR with ArchiMate 3.2 element typing +Genome struct (same schema as archiet.com full platform) + | + v RenderGenome() -- Go chi + GORM + golang-jwt rendering +map[string]string (path -> content) + | + v Pack() -- archive/zip (pure Go stdlib) +ZIP file +``` + +The genome is the key: your PRD becomes an **ArchiMate 3.2 architecture document** before any code is generated -- traceable, maintainable, not just scaffolded. + +## Why no LLMs + +LLMs are great at understanding messy natural-language PRDs. They are unnecessary for the generation step -- once you have a clean manifest, code emission is deterministic. Zero hallucinations, zero non-determinism, same input always produces the same Go app. + +The generator itself is pure Go stdlib (zero external imports in `main.go`). The generated app's `go.mod` lists `go-chi`, `gorm`, and `golang-jwt` -- deps of the app you are building, not the tool. + +The full platform at [archiet.com](https://archiet.com?utm_source=pkg.go.dev&utm_medium=package&utm_campaign=microcodegen-go) handles LLM-powered extraction from complex PRDs, 14 target stacks, React/Next.js frontend, Expo mobile, and delivery gates. + +## How it compares to manual scaffolding + +| | Manual / `go mod init` | archiet-microcodegen-go | +|---|---|---| +| Starting point | Blank module, you write everything | PRD -> complete app | +| Auth | You implement | JWT httpOnly cookie middleware -- included | +| Data isolation | You implement | Per-user filtering on every handler -- built in | +| Database | You configure | `docker compose up` works immediately | +| Architecture docs | You write | `ARCHITECTURE.md` with ArchiMate 3.2 -- generated | +| API contract | You write | `openapi.yaml` -- generated | + +## What's NOT here + +- No LLM extraction (the full platform handles complex, messy PRDs) +- No React/Next.js frontend +- No Expo mobile app +- No Stripe wiring, rate limiting, audit logging +- No multi-stack (Go chi only here -- for NestJS, Java Spring Boot, Django, FastAPI see [archiet.com](https://archiet.com?utm_source=pkg.go.dev&utm_medium=package&utm_campaign=microcodegen-go)) + +## FAQ + +**Does the generated app actually boot?** +Yes. `docker compose up` is the entire setup. GORM `AutoMigrate` creates the schema on first boot -- no manual migration needed. + +**Is the generator itself pure stdlib?** +Yes. `main.go` imports only Go standard library packages (`archive/zip`, `crypto/rand`, `encoding/json`, `flag`, `os`, `path/filepath`, `regexp`, `strings`). The generated app's `go.mod` has external deps -- those are the app's deps, not the generator's. + +**How is auth implemented?** +JWT is stored in an httpOnly cookie (`access_token`), never in a header body or localStorage. The middleware reads `r.Cookie("access_token")` and validates the HMAC-SHA256 signature. The JWT secret is generated per-app via `crypto/rand` and written to `.env.example`. + +**How is per-tenant isolation enforced?** +Every GORM model has a `UserID` field. Every handler function filters by the `userID` extracted from the JWT before any DB query. There is no query path that returns another user's data. + +**Why does `go install` require a separate GitHub repo?** +`go install module@version` resolves against the module path in `go.mod`. The module path must match the GitHub repository URL exactly. See `PUSH_TO_REPO.md` for deployment steps. + +## Links + +- **SDD guide:** [github.com/Anioko/spec-driven-development](https://github.com/Anioko/spec-driven-development) +- **Compliance guide:** [github.com/Anioko/compliance-from-architecture](https://github.com/Anioko/compliance-from-architecture) — SOC 2, GDPR, **EU AI Act Annex IV** +- **EU AI Act (deadline Aug 2026):** [Free risk classifier](https://archiet.com/tools/eu-ai-act-risk-classifier) · [Annex IV use case](https://archiet.com/use-cases/eu-ai-act-high-risk-ai-compliance) +- Source: [github.com/aniekanasuquookono-web/archiet-microcodegen-go](https://github.com/aniekanasuquookono-web/archiet-microcodegen-go) +- Full platform (14 stacks, frontend, mobile, deploy): [archiet.com](https://archiet.com?utm_source=pkg.go.dev&utm_medium=package&utm_campaign=microcodegen-go) +- Issues: [github.com/aniekanasuquookono-web/archiet-microcodegen-go/issues](https://github.com/aniekanasuquookono-web/archiet-microcodegen-go/issues) + +## License + +MIT. diff --git a/archiet_microcodegen_go/go.mod b/archiet_microcodegen_go/go.mod new file mode 100644 index 0000000..afe7c5d --- /dev/null +++ b/archiet_microcodegen_go/go.mod @@ -0,0 +1,3 @@ +module github.com/aniekanasuquookono-web/archiet-microcodegen-go + +go 1.21.0 diff --git a/archiet_microcodegen_go/main.go b/archiet_microcodegen_go/main.go new file mode 100644 index 0000000..5ebd580 --- /dev/null +++ b/archiet_microcodegen_go/main.go @@ -0,0 +1,927 @@ +// archiet-microcodegen-go v0.1.0 +// PRD text → Go chi app → ZIP. Pure Go stdlib. <1400 LOC. +// Stage 1: ParsePRD(text) → Manifest (language-agnostic) +// Stage 2: ManifestToGenome(manifest) → Genome (ArchiMate 3.2 typed) +// Stage 3: RenderGenome(genome) → map[string]string (Go chi-specific) +// Stage 4: Pack(files) → []byte (ZIP) or write to disk +// Zero external imports. Inspired by Karpathy's micrograd. +package main + +import ( + "archive/zip" + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// ─── Domain types ───────────────────────────────────────────────────────────── + +type FieldSpec struct { + Type string `json:"type"` + Required bool `json:"required"` + Unique bool `json:"unique"` + Indexed bool `json:"indexed"` +} + +type Entity struct { + Name string `json:"name"` + Fields map[string]FieldSpec `json:"fields"` + Description string `json:"description"` +} + +type UserStory struct { + AsA string `json:"as_a"` + IWant string `json:"i_want"` + SoThat string `json:"so_that"` +} + +type Integration struct { + Name string `json:"name"` + Category string `json:"category"` +} + +type Manifest struct { + SolutionName string `json:"solution_name"` + Entities []Entity `json:"entities"` + UserStories []UserStory `json:"user_stories"` + Integrations []Integration `json:"integrations"` +} + +type ArchiMateElement struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` +} + +type CoreModule struct { + ModuleType string `json:"module_type"` + Entities map[string]Entity `json:"entities"` +} + +type Modules struct { + Core CoreModule `json:"core"` +} + +type Genome struct { + GenomeVersion string `json:"genome_version"` + SolutionName string `json:"solution_name"` + BundleID string `json:"bundle_id"` + Language string `json:"language"` + Modules Modules `json:"modules"` + UserStories []UserStory `json:"user_stories"` + Integrations []Integration `json:"integrations"` + ArchiMateElements []ArchiMateElement `json:"archimate_elements"` +} + +// ─── String helpers ─────────────────────────────────────────────────────────── + +var ( + reNotAlnum = regexp.MustCompile(`[^a-zA-Z0-9]+`) + reCamelSep = regexp.MustCompile(`([a-z])([A-Z])`) +) + +func snake(s string) string { + s = reCamelSep.ReplaceAllString(s, "${1}_${2}") + s = reNotAlnum.ReplaceAllString(s, "_") + return strings.ToLower(strings.Trim(s, "_")) +} + +func pascal(s string) string { + parts := strings.Split(snake(s), "_") + sb := strings.Builder{} + for _, p := range parts { + if len(p) == 0 { + continue + } + sb.WriteString(strings.ToUpper(p[:1]) + p[1:]) + } + return sb.String() +} + +func camel(s string) string { + p := pascal(s) + if len(p) == 0 { + return p + } + return strings.ToLower(p[:1]) + p[1:] +} + +func plural(s string) string { + if strings.HasSuffix(s, "s") { + return s + "es" + } + if strings.HasSuffix(s, "y") { + return s[:len(s)-1] + "ies" + } + return s + "s" +} + +// fill replaces {{VAR}} placeholders in template with values from vars map. +func fill(tmpl string, vars map[string]string) string { + result := tmpl + for k, v := range vars { + result = strings.ReplaceAll(result, "{{"+k+"}}", v) + } + return result +} + +func randomHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} + +// ─── Type maps ──────────────────────────────────────────────────────────────── + +var goGormTypes = map[string]string{ + "string": "string", "text": "string", "integer": "int64", "int": "int64", + "float": "float64", "decimal": "float64", "boolean": "bool", "bool": "bool", + "datetime": "time.Time", "date": "string", "uuid": "string", "json": "string", +} + +var goGormColumnTypes = map[string]string{ + "string": "varchar(255)", "text": "text", "integer": "integer", "int": "integer", + "float": "double precision", "decimal": "numeric(18,2)", "boolean": "boolean", + "bool": "boolean", "datetime": "timestamptz", "date": "date", "uuid": "uuid", + "json": "jsonb", +} + +func goFieldDecl(fname string, fspec FieldSpec) string { + gt := goGormTypes[fspec.Type] + if gt == "" { + gt = "string" + } + ct := goGormColumnTypes[fspec.Type] + if ct == "" { + ct = "varchar(255)" + } + tags := []string{`gorm:"column:` + fname + `;type:` + ct} + if fspec.Required { + tags[0] += `;not null` + } + if fspec.Unique { + tags[0] += `;uniqueIndex` + } + tags[0] += `"` + fname2 := pascal(fname) + if gt == "time.Time" { + // ensure time import hint in struct — handled in template header + } + return fmt.Sprintf("\t%s %s `json:\"%s\" %s`", fname2, gt, fname, tags[0]) +} + +// ─── STAGE 1: ParsePRD ─────────────────────────────────────────────────────── + +var ( + reSection = regexp.MustCompile(`(?im)^#{1,3}\s*(?:entities|data models|domain models|entity list)\s*:?\s*$`) + reEntName = regexp.MustCompile(`(?m)^[\s\-\*\#]+\*{0,2}([A-Z][a-zA-Z0-9_]{1,40})\*{0,2}[ \t]*(?::|—|-|[ \t]|$)`) + reField = regexp.MustCompile(`(?m)^[\s\-\*]+([a-z_][a-z0-9_]{0,40})\s*[:—\-]\s*([a-zA-Z]+)([^\n]*)`) + reInlField = regexp.MustCompile(`([a-z_][a-z0-9_]{0,40})\s*\(\s*([a-zA-Z]+)([^)]*)`) + reStory = regexp.MustCompile(`(?im)As\s+(?:a|an)\s+([^,]+?),\s+I\s+want\s+(?:to\s+)?([^,]+?)(?:,?\s*so\s+that\s+([^.]+))?\.`) + reSolName = regexp.MustCompile(`(?m)^#\s+(.+?)\s*$`) +) + +var knownIntegrations = map[string]string{ + "stripe": "payments", "auth0": "auth", "clerk": "auth", + "sendgrid": "email", "twilio": "sms", "datadog": "observability", + "segment": "analytics", "supabase": "auth", +} + +func ParsePRD(text string) Manifest { + solMatch := reSolName.FindStringSubmatch(text) + solutionName := "Generated App" + if len(solMatch) > 1 { + solutionName = solMatch[1] + } + + secLoc := reSection.FindStringIndex(text) + entitySection := "" + if secLoc != nil { + rest := text[secLoc[1]:] + nextH := regexp.MustCompile(`(?m)^#{1,2}\s+\S`).FindStringIndex(rest) + if nextH != nil { + entitySection = rest[:nextH[0]] + } else { + entitySection = rest + } + } + + seen := map[string]bool{} + entMatches := reEntName.FindAllStringSubmatchIndex(entitySection, -1) + var entities []Entity + for i, m := range entMatches { + ename := entitySection[m[2]:m[3]] + if seen[ename] { + continue + } + seen[ename] = true + bodyEnd := len(entitySection) + if i+1 < len(entMatches) { + bodyEnd = entMatches[i+1][0] + } + body := entitySection[m[1]:bodyEnd] + fields := map[string]FieldSpec{} + seenF := map[string]bool{} + for _, fm := range reField.FindAllStringSubmatch(body, -1) { + fname := fm[1] + if seenF[fname] { + continue + } + seenF[fname] = true + mod := strings.ToLower(fm[3]) + fields[fname] = FieldSpec{ + Type: strings.ToLower(fm[2]), + Required: strings.Contains(mod, "required") || strings.Contains(mod, "not null"), + Unique: strings.Contains(mod, "unique"), + } + } + if len(fields) == 0 { + line := entitySection[m[0]:m[1]] + strings.SplitN(body, "\n", 2)[0] + for _, im := range reInlField.FindAllStringSubmatch(line, -1) { + fname := im[1] + if !seenF[fname] { + seenF[fname] = true + mod := strings.ToLower(im[3]) + fields[fname] = FieldSpec{ + Type: strings.ToLower(im[2]), + Required: strings.Contains(mod, "required"), + } + } + } + } + entities = append(entities, Entity{Name: ename, Fields: fields}) + } + + var stories []UserStory + for _, sm := range reStory.FindAllStringSubmatch(text, -1) { + s := UserStory{AsA: strings.TrimSpace(sm[1]), IWant: strings.TrimSpace(sm[2])} + if len(sm) > 3 { + s.SoThat = strings.TrimSpace(sm[3]) + } + stories = append(stories, s) + } + + low := strings.ToLower(text) + var integrations []Integration + for k, cat := range knownIntegrations { + if strings.Contains(low, k) { + integrations = append(integrations, Integration{Name: k, Category: cat}) + } + } + + return Manifest{SolutionName: solutionName, Entities: entities, UserStories: stories, Integrations: integrations} +} + +// ─── STAGE 2: ManifestToGenome ─────────────────────────────────────────────── + +var workflowVerbs = map[string]bool{ + "create": true, "update": true, "delete": true, "approve": true, + "reject": true, "submit": true, "complete": true, "process": true, +} + +func ManifestToGenome(manifest Manifest) Genome { + entMap := map[string]Entity{} + for _, ent := range manifest.Entities { + fields := map[string]FieldSpec{ + "id": {Type: "uuid", Required: true}, + } + for k, v := range ent.Fields { + if k == "id" || k == "created_at" || k == "updated_at" { + continue + } + fields[k] = v + } + entMap[ent.Name] = Entity{ + Name: ent.Name, + Fields: fields, + Description: ent.Name + " entity (generated by archiet-microcodegen-go)", + } + } + + elements := []ArchiMateElement{ + {Name: manifest.SolutionName, Type: "ApplicationComponent", Description: manifest.SolutionName + " Go API application"}, + } + for _, ent := range manifest.Entities { + elements = append(elements, ArchiMateElement{Name: ent.Name, Type: "DataObject", Description: ent.Name + " entity"}) + } + for _, intg := range manifest.Integrations { + elements = append(elements, ArchiMateElement{Name: intg.Name, Type: "ApplicationService", Description: "External: " + intg.Name}) + } + + bundleID := snake(manifest.SolutionName) + return Genome{ + GenomeVersion: "1.0.0", + SolutionName: manifest.SolutionName, + BundleID: bundleID, + Language: "go-chi", + Modules: Modules{Core: CoreModule{ModuleType: "crud", Entities: entMap}}, + UserStories: manifest.UserStories, + Integrations: manifest.Integrations, + ArchiMateElements: elements, + } +} + +// ─── STAGE 3: RenderGenome ─────────────────────────────────────────────────── + +// Go model template — {{MODEL_NAME}}, {{TABLE_NAME}}, {{FIELDS}} +const tModel = `package models + +import "time" + +// {{MODEL_NAME}} — per-tenant entity. UserID scopes every record to its owner. +// Every query MUST include WHERE user_id = ? — no cross-user data leaks. +type {{MODEL_NAME}} struct { + ID string ` + "`" + `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + "`" + ` + UserID string ` + "`" + `json:"user_id" gorm:"column:user_id;not null;index"` + "`" + ` +{{FIELDS}} + CreatedAt time.Time ` + "`" + `json:"created_at"` + "`" + ` + UpdatedAt time.Time ` + "`" + `json:"updated_at"` + "`" + ` +} + +func ({{MODEL_NAME}}) TableName() string { return "{{TABLE_NAME}}" } +` + +// Handler template — {{ENTITY_PASCAL}}, {{ENTITY_SNAKE}}, {{ENTITY_PLURAL}} +const tHandler = `package handlers + +import ( + "encoding/json" + "net/http" + + "{{MODULE_PATH}}/internal/database" + "{{MODULE_PATH}}/internal/models" + "{{MODULE_PATH}}/internal/middleware" + + "github.com/go-chi/chi/v5" +) + +func {{ENTITY_PASCAL}}Routes(r chi.Router) { + r.Get("/", List{{ENTITY_PASCAL}}) + r.Post("/", Create{{ENTITY_PASCAL}}) + r.Get("/{id}", Get{{ENTITY_PASCAL}}) + r.Put("/{id}", Update{{ENTITY_PASCAL}}) + r.Delete("/{id}", Delete{{ENTITY_PASCAL}}) +} + +func List{{ENTITY_PASCAL}}(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromContext(r.Context()) + var items []models.{{ENTITY_PASCAL}} + database.DB.Where("user_id = ?", userID).Find(&items) + json.NewEncoder(w).Encode(items) +} + +func Create{{ENTITY_PASCAL}}(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromContext(r.Context()) + var item models.{{ENTITY_PASCAL}} + if err := json.NewDecoder(r.Body).Decode(&item); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest); return + } + item.UserID = userID + if res := database.DB.Create(&item); res.Error != nil { + http.Error(w, res.Error.Error(), http.StatusInternalServerError); return + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(item) +} + +func Get{{ENTITY_PASCAL}}(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromContext(r.Context()) + id := chi.URLParam(r, "id") + var item models.{{ENTITY_PASCAL}} + if res := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&item); res.Error != nil { + http.Error(w, "not found", http.StatusNotFound); return + } + json.NewEncoder(w).Encode(item) +} + +func Update{{ENTITY_PASCAL}}(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromContext(r.Context()) + id := chi.URLParam(r, "id") + var item models.{{ENTITY_PASCAL}} + if res := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&item); res.Error != nil { + http.Error(w, "not found", http.StatusNotFound); return + } + var updates models.{{ENTITY_PASCAL}} + if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest); return + } + updates.ID = id; updates.UserID = userID + database.DB.Save(&updates) + json.NewEncoder(w).Encode(updates) +} + +func Delete{{ENTITY_PASCAL}}(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromContext(r.Context()) + id := chi.URLParam(r, "id") + if res := database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&models.{{ENTITY_PASCAL}}{}); res.Error != nil { + http.Error(w, "not found", http.StatusNotFound); return + } + w.WriteHeader(http.StatusNoContent) +} +` + +const tAuth = `package auth + +import ( + "encoding/json" + "net/http" + "os" + "time" + + "{{MODULE_PATH}}/internal/database" + "{{MODULE_PATH}}/internal/models" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var jwtSecret = []byte(getenv("JWT_SECRET_KEY", "dev-secret-change-in-prod")) + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { return v } + return fallback +} + +type authRequest struct { Email string ` + "`" + `json:"email"` + "`" + `; Password string ` + "`" + `json:"password"` + "`" + ` } + +func setTokenCookie(w http.ResponseWriter, userID, email string) { + claims := jwt.MapClaims{"sub": userID, "email": email, "exp": time.Now().Add(7 * 24 * time.Hour).Unix()} + token, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret) + // JWT stored in httpOnly cookie — never localStorage. + http.SetCookie(w, &http.Cookie{ + Name: "access_token", Value: token, HttpOnly: true, + SameSite: http.SameSiteLaxMode, MaxAge: 7 * 86400, Path: "/", + }) +} + +func Register(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest); return + } + var existing models.User + if database.DB.Where("email = ?", req.Email).First(&existing).Error == nil { + http.Error(w, "email already registered", http.StatusConflict); return + } + hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), 10) + user := models.User{Email: req.Email, PasswordHash: string(hash)} + database.DB.Create(&user) + setTokenCookie(w, user.ID, user.Email) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"message": "registered"}) +} + +func Login(w http.ResponseWriter, r *http.Request) { + var req authRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest); return + } + var user models.User + if database.DB.Where("email = ?", req.Email).First(&user).Error != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized); return + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized); return + } + setTokenCookie(w, user.ID, user.Email) + json.NewEncoder(w).Encode(map[string]string{"message": "logged in"}) +} + +func Logout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "access_token", MaxAge: -1, Path: "/"}) + json.NewEncoder(w).Encode(map[string]string{"message": "logged out"}) +} + +func JWTSecret() []byte { return jwtSecret } +` + +const tMiddleware = `package middleware + +import ( + "context" + "net/http" + "os" + + "github.com/golang-jwt/jwt/v5" +) + +type ctxKey string +const userIDKey ctxKey = "userID" + +func JWTMiddleware(next http.Handler) http.Handler { + secret := []byte(getenv("JWT_SECRET_KEY", "dev-secret-change-in-prod")) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("access_token") + if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return } + tok, err := jwt.Parse(cookie.Value, func(t *jwt.Token) (interface{}, error) { + return secret, nil + }) + if err != nil || !tok.Valid { http.Error(w, "unauthorized", http.StatusUnauthorized); return } + claims, _ := tok.Claims.(jwt.MapClaims) + ctx := context.WithValue(r.Context(), userIDKey, claims["sub"].(string)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func UserIDFromContext(ctx context.Context) string { + v, _ := ctx.Value(userIDKey).(string); return v +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { return v } + return fallback +} +` + +const tDatabase = `package database + +import ( + "fmt" + "log" + "os" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func Connect(models ...interface{}) { + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { log.Fatal("DATABASE_URL not set") } + var err error + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { log.Fatalf("database connect failed: %v", err) } + if err := DB.Exec("CREATE EXTENSION IF NOT EXISTS pgcrypto").Error; err != nil { + fmt.Println("pgcrypto already enabled or not available:", err) + } + if err := DB.AutoMigrate(models...); err != nil { + log.Fatalf("AutoMigrate failed: %v", err) + } + log.Println("Database connected and migrated.") +} +` + +const tUserModel = `package models + +import "time" + +type User struct { + ID string ` + "`" + `json:"id" gorm:"primaryKey;type:uuid;default:gen_random_uuid()"` + "`" + ` + Email string ` + "`" + `json:"email" gorm:"uniqueIndex;not null"` + "`" + ` + PasswordHash string ` + "`" + `json:"-" gorm:"not null"` + "`" + ` + CreatedAt time.Time ` + "`" + `json:"created_at"` + "`" + ` +} + +func (User) TableName() string { return "users" } +` + +// main.go template — {{MODULE_PATH}}, {{ENTITY_ROUTES}}, {{MODEL_POINTERS}}, {{APP_NAME}} +const tMainGo = `package main + +import ( + "fmt" + "log" + "net/http" + "os" + + "{{MODULE_PATH}}/internal/auth" + "{{MODULE_PATH}}/internal/database" + "{{MODULE_PATH}}/internal/middleware" + "{{MODULE_PATH}}/internal/models" +{{HANDLER_IMPORTS}} + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" +) + +func main() { + // Connect and auto-migrate all models + database.Connect( + &models.User{}, +{{MODEL_POINTERS}} + ) + + r := chi.NewRouter() + r.Use(chimw.Logger) + r.Use(chimw.Recoverer) + + // Content-type JSON for all responses + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) + }) + + // All API routes are mounted under /api so they match the shared Next.js + // frontend, which calls /api/auth/* and /api/{entities} (frontend/lib/api.ts + // uses a relative "/api"-prefixed base proxied to this backend). + r.Route("/api", func(r chi.Router) { + // Auth routes — no JWT guard + r.Post("/auth/register", auth.Register) + r.Post("/auth/login", auth.Login) + r.Post("/auth/logout", auth.Logout) + + // Protected routes — JWT httpOnly cookie guard + r.Group(func(r chi.Router) { + r.Use(middleware.JWTMiddleware) +{{ENTITY_ROUTES}} + }) + }) + + port := os.Getenv("PORT") + if port == "" { port = "8080" } + fmt.Printf("{{APP_NAME}} listening on :%s\n", port) + log.Fatal(http.ListenAndServe(":"+port, r)) +} +` + +// go.mod template for generated app — {{MODULE_PATH}} +const tGoMod = `module {{MODULE_PATH}} + +go 1.21 + +require ( + github.com/go-chi/chi/v5 v5.0.12 + github.com/golang-jwt/jwt/v5 v5.2.1 + golang.org/x/crypto v0.21.0 + gorm.io/driver/postgres v1.5.7 + gorm.io/gorm v1.25.8 +) +` + +func RenderGenome(genome Genome) map[string]string { + files := map[string]string{} + bundleID := genome.BundleID + modPath := "github.com/example/" + bundleID + jwtSecret := randomHex(24) + entities := genome.Modules.Core.Entities + + files["internal/models/user.go"] = strings.ReplaceAll(tUserModel, "{{MODULE_PATH}}", modPath) + files["internal/auth/auth.go"] = strings.ReplaceAll(tAuth, "{{MODULE_PATH}}", modPath) + files["internal/middleware/jwt.go"] = tMiddleware + files["internal/database/db.go"] = tDatabase + + handlerImports := []string{} + entityRoutes := []string{} + modelPointers := []string{} + + for entName, entSpec := range entities { + entSnake := snake(entName) + entPascal := pascal(entName) + entPlural := plural(entSnake) + + fields := []string{} + useTime := false + for fname, fspec := range entSpec.Fields { + if fname == "id" { + continue + } + decl := goFieldDecl(fname, fspec) + if strings.Contains(decl, "time.Time") { + useTime = true + } + fields = append(fields, "\t"+strings.TrimSpace(decl)) + } + _ = useTime + fieldBlock := strings.Join(fields, "\n") + + modelFile := fill(tModel, map[string]string{ + "MODEL_NAME": entPascal, + "TABLE_NAME": entPlural, + "FIELDS": fieldBlock, + }) + files["internal/models/"+entSnake+".go"] = modelFile + + handlerFile := fill(tHandler, map[string]string{ + "ENTITY_PASCAL": entPascal, + "ENTITY_SNAKE": entSnake, + "ENTITY_PLURAL": entPlural, + "MODULE_PATH": modPath, + }) + files["internal/handlers/"+entSnake+".go"] = handlerFile + + handlerImports = append(handlerImports, + ` "`+modPath+`/internal/handlers"`, + ) + entityRoutes = append(entityRoutes, + ` r.Route("/`+entPlural+`", handlers.`+entPascal+`Routes)`, + ) + modelPointers = append(modelPointers, + ` &models.`+entPascal+`{},`, + ) + } + + files["main.go"] = fill(tMainGo, map[string]string{ + "MODULE_PATH": modPath, + "APP_NAME": genome.SolutionName, + "HANDLER_IMPORTS": strings.Join(dedupe(handlerImports), "\n"), + "ENTITY_ROUTES": strings.Join(entityRoutes, "\n"), + "MODEL_POINTERS": strings.Join(modelPointers, "\n"), + }) + + files["go.mod"] = fill(tGoMod, map[string]string{"MODULE_PATH": modPath}) + files["go.sum"] = "// Run: go mod tidy\n" + + files["docker-compose.yml"] = fill(`services: + app: + build: . + ports: ["8080:8080"] + environment: + DATABASE_URL: postgresql://archiet:archiet@db:5432/{{BUNDLE_ID}} + JWT_SECRET_KEY: {{JWT_SECRET}} + depends_on: + db: + condition: service_healthy + db: + image: postgres:16 + environment: + POSTGRES_USER: archiet + POSTGRES_PASSWORD: archiet + POSTGRES_DB: {{BUNDLE_ID}} + volumes: ["pgdata:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U archiet -d {{BUNDLE_ID}}"] + interval: 3s + timeout: 3s + retries: 20 +volumes: + pgdata: +`, map[string]string{"BUNDLE_ID": bundleID, "JWT_SECRET": jwtSecret}) + + files["Dockerfile"] = `FROM golang:1.21-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o server . + +FROM alpine:3.19 +WORKDIR /app +COPY --from=builder /app/server . +EXPOSE 8080 +CMD ["./server"] +` + + files[".env.example"] = "DATABASE_URL=postgresql://archiet:archiet@localhost:5432/" + bundleID + "\n" + + "JWT_SECRET_KEY=" + jwtSecret + "\nPORT=8080\n" + + files["Makefile"] = `build: + go build -o server . + +test: + go test ./... + +migrate: + @echo "Migrations run via GORM AutoMigrate on startup." + +run: + go run . +` + + files["GENOME.json"] = func() string { b, _ := json.MarshalIndent(genome, "", " "); return string(b) }() + files["ARCHITECTURE.md"] = renderArchMd(genome, entities) + files["openapi.yaml"] = renderOpenapi(genome, entities) + files["README.md"] = renderReadme(genome, entities) + + return files +} + +func dedupe(ss []string) []string { + seen := map[string]bool{} + var out []string + for _, s := range ss { + if !seen[s] { + seen[s] = true + out = append(out, s) + } + } + return out +} + +func renderArchMd(genome Genome, entities map[string]Entity) string { + sb := strings.Builder{} + sb.WriteString("# Architecture — " + genome.SolutionName + "\n\n") + sb.WriteString("Generated by archiet-microcodegen-go · ArchiMate 3.2 element notation\n\n") + sb.WriteString("## Application Layer\n\n| Element | Type | Description |\n|---------|------|-------------|\n") + for _, el := range genome.ArchiMateElements { + sb.WriteString(fmt.Sprintf("| `%s` | %s | %s |\n", el.Name, el.Type, el.Description)) + } + sb.WriteString("\n## Relationships\n\n```\n " + genome.SolutionName + " (ApplicationComponent)\n") + for ename := range entities { + sb.WriteString(" └── " + ename + " (DataObject) [Realization]\n") + } + sb.WriteString("```\n\nhttps://archiet.com?utm_source=pkg.go.dev&utm_medium=package&utm_campaign=microcodegen-go\n") + return sb.String() +} + +func renderOpenapi(genome Genome, entities map[string]Entity) string { + sb := strings.Builder{} + sb.WriteString("openapi: '3.1.0'\ninfo:\n title: " + genome.SolutionName + " API\n") + sb.WriteString(" version: 0.1.0\nservers:\n - url: http://localhost:8080\npaths:\n") + sb.WriteString(" /api/auth/register:\n post:\n tags: [auth]\n summary: Register (returns httpOnly JWT cookie)\n") + sb.WriteString(" responses: {'201': {description: Registered}}\n") + sb.WriteString(" /api/auth/login:\n post:\n tags: [auth]\n summary: Login (returns httpOnly JWT cookie)\n") + sb.WriteString(" responses: {'200': {description: OK}}\n") + for entName, _ := range entities { + entSnake := snake(entName) + entPlural := plural(entSnake) + sb.WriteString(fmt.Sprintf(" /api/%s:\n get:\n tags: [%s]\n security: [{cookieAuth: []}]\n responses: {'200': {description: List}}\n", entPlural, entName)) + sb.WriteString(fmt.Sprintf(" post:\n tags: [%s]\n security: [{cookieAuth: []}]\n responses: {'201': {description: Created}}\n", entName)) + sb.WriteString(fmt.Sprintf(" /api/%s/{id}:\n get:\n tags: [%s]\n security: [{cookieAuth: []}]\n parameters: [{in: path, name: id, required: true, schema: {type: string}}]\n responses: {'200': {description: OK}, '404': {description: Not found}}\n", entPlural, entName)) + sb.WriteString(fmt.Sprintf(" put:\n tags: [%s]\n security: [{cookieAuth: []}]\n parameters: [{in: path, name: id, required: true, schema: {type: string}}]\n responses: {'200': {description: Updated}}\n", entName)) + sb.WriteString(fmt.Sprintf(" delete:\n tags: [%s]\n security: [{cookieAuth: []}]\n parameters: [{in: path, name: id, required: true, schema: {type: string}}]\n responses: {'204': {description: Deleted}}\n", entName)) + } + sb.WriteString("components:\n securitySchemes:\n cookieAuth:\n type: apiKey\n in: cookie\n name: access_token\n") + return sb.String() +} + +func renderReadme(genome Genome, entities map[string]Entity) string { + sb := strings.Builder{} + sb.WriteString("# " + genome.SolutionName + "\n\nGenerated by archiet-microcodegen-go.\n\n## Quick start\n\n```bash\ncp .env.example .env\ndocker compose up\n```\n\n") + sb.WriteString("## Entities\n\n") + for ename, espec := range entities { + sb.WriteString("- **" + ename + "**: " + espec.Description + "\n") + } + sb.WriteString("\n## Stack\n\n- Go 1.21 + chi router\n- GORM + PostgreSQL 16\n- JWT httpOnly cookies (never localStorage)\n- Per-tenant: every query filtered by user_id\n") + return sb.String() +} + +// ─── STAGE 4: Pack ──────────────────────────────────────────────────────────── + +func Pack(files map[string]string) ([]byte, error) { + buf := &bytes.Buffer{} + w := zip.NewWriter(buf) + for fpath, content := range files { + f, err := w.Create(fpath) + if err != nil { + return nil, fmt.Errorf("zip create %s: %w", fpath, err) + } + if _, err := f.Write([]byte(content)); err != nil { + return nil, fmt.Errorf("zip write %s: %w", fpath, err) + } + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// ─── CLI ────────────────────────────────────────────────────────────────────── + +func main() { + prdFile := flag.String("prd", "", "Path to PRD Markdown file (required)") + outDir := flag.String("out", "", "Write extracted files to this directory (default: write ZIP to stdout or -zip path)") + zipFile := flag.String("zip", "", "Write ZIP archive to this file") + flag.Parse() + + if *prdFile == "" { + fmt.Fprintln(os.Stderr, "archiet-microcodegen-go — PRD text → Go chi app\n") + fmt.Fprintln(os.Stderr, "Usage:") + fmt.Fprintln(os.Stderr, " archiet-microcodegen-go -prd prd.md -out ./myapp/") + fmt.Fprintln(os.Stderr, " archiet-microcodegen-go -prd prd.md -zip myapp.zip") + os.Exit(1) + } + + data, err := os.ReadFile(*prdFile) + if err != nil { + fmt.Fprintln(os.Stderr, "read error:", err) + os.Exit(1) + } + + manifest := ParsePRD(string(data)) + genome := ManifestToGenome(manifest) + files := RenderGenome(genome) + + if *outDir != "" { + if err := os.MkdirAll(*outDir, 0755); err != nil { + fmt.Fprintln(os.Stderr, err); os.Exit(1) + } + for fpath, content := range files { + full := filepath.Join(*outDir, fpath) + os.MkdirAll(filepath.Dir(full), 0755) + os.WriteFile(full, []byte(content), 0644) + } + fmt.Fprintf(os.Stderr, "Wrote %d files to %s\n", len(files), *outDir) + return + } + + zipBytes, err := Pack(files) + if err != nil { + fmt.Fprintln(os.Stderr, err); os.Exit(1) + } + if *zipFile != "" { + os.WriteFile(*zipFile, zipBytes, 0644) + fmt.Fprintf(os.Stderr, "Wrote %s (%d bytes)\n", *zipFile, len(zipBytes)) + } else { + os.Stdout.Write(zipBytes) + } +} diff --git a/archiet_microcodegen_java/PUSH_TO_REPO.md b/archiet_microcodegen_java/PUSH_TO_REPO.md new file mode 100644 index 0000000..dba0163 --- /dev/null +++ b/archiet_microcodegen_java/PUSH_TO_REPO.md @@ -0,0 +1,44 @@ +# Deploying to a separate Maven/GitHub repository + +The Java package lives in the monorepo for development but should be published as a separate GitHub repo and to Maven Central. + +## Steps — GitHub repo + +1. Create: https://github.com/aniekanasuquookono-web/archiet-microcodegen-java + +2. Copy these files: + ``` + archiet_microcodegen_java/pom.xml + archiet_microcodegen_java/src/ + archiet_microcodegen_java/README.md + ``` + +3. Push + tag: + ```bash + git init + git add . + git commit -m "feat: archiet-microcodegen-java v0.1.0" + git remote add origin https://github.com/aniekanasuquookono-web/archiet-microcodegen-java.git + git push -u origin main + git tag v0.1.0 + git push --tags + ``` + +## Build fat JAR + +```bash +cd archiet_microcodegen_java +mvn package -q +java -jar target/archiet-microcodegen-java-0.1.0.jar prd.md --out ./myapp/ +``` + +## Maven Central publishing (optional) + +To publish to Maven Central, you need: +1. A Sonatype account + namespace `com.archiet` +2. GPG key for signing +3. Configure `~/.m2/settings.xml` with credentials +4. Add the `maven-release-plugin` and deploy with: `mvn deploy -P release` + +For MVP distribution, GitHub Releases with the fat JAR is sufficient. +Users download and run with `java -jar archiet-microcodegen-java.jar`. diff --git a/archiet_microcodegen_java/README.md b/archiet_microcodegen_java/README.md new file mode 100644 index 0000000..ecabca7 --- /dev/null +++ b/archiet_microcodegen_java/README.md @@ -0,0 +1,176 @@ +# archiet-microcodegen-java + +> **Generate a production-ready Spring Boot 3 REST API from a requirements document. One command. No LLM. No API key. Pure Java stdlib in the generator. 421 lines you can read in 5 minutes.** + +Inspired by Karpathy's `micrograd`: *this file is the complete algorithm. Everything else is just efficiency on top.* + +```bash +java -jar archiet-microcodegen-java.jar prd.md --out ./myapp/ +cd myapp && docker compose up +# -> http://localhost:8080/swagger-ui.html +``` + +Write a plain-English PRD. Get back a bootable Spring Boot 3 app with JPA entities, JpaRepository interfaces, @Transactional services, full CRUD controllers, Spring Security JWT auth (httpOnly cookies), per-tenant data isolation, and a Postgres 16 docker-compose -- all without touching a template or hitting an AI API. + +## Install + +Download the fat JAR (no Maven required): + +```bash +curl -LO https://github.com/aniekanasuquookono-web/archiet-microcodegen-java/releases/latest/download/archiet-microcodegen-java.jar +java -jar archiet-microcodegen-java.jar --help +``` + +Or build from source: + +```bash +git clone https://github.com/aniekanasuquookono-web/archiet-microcodegen-java +cd archiet-microcodegen-java +mvn package -q +java -jar target/archiet-microcodegen-java-0.1.0.jar --help +``` + +## Quick example + +Save this as `prd.md`: + +```markdown +# Task Manager + +## Entities +- Task: title (string, required), description (text), status (string), due_date (date) +- Project: name (string, required), description (text) + +## User Stories +As a user, I want to create tasks so I can track my work. +As a user, I want to assign tasks to projects so I can organise them. + +## Integrations +- Stripe for billing +``` + +Run: + +```bash +java -jar archiet-microcodegen-java.jar prd.md --out ./taskapp/ +cd taskapp && docker compose up +``` + +You get a fully wired Spring Boot 3 app: `Task` and `Project` JPA entities, JpaRepository interfaces, @Transactional service layer, full CRUD @RestController, Spring Security JWT filter reading httpOnly cookie, per-tenant data isolation, `ARCHITECTURE.md` with ArchiMate 3.2 notation, and `openapi.yaml` -- zero modifications needed to boot. + +## Use + +**CLI** +```bash +# Write files to a directory: +java -jar archiet-microcodegen-java.jar prd.md --out ./myapp/ +cd myapp && docker compose up + +# Write ZIP: +java -jar archiet-microcodegen-java.jar prd.md --zip myapp.zip +``` + +## What you get + +| File | What it does | +|---|---| +| `pom.xml` | Spring Boot 3 + JPA + Spring Security + Lombok deps | +| `src/main/java/.../Application.java` | Spring Boot entry point | +| `src/main/java/.../model/User.java` | JPA User entity with bcrypt password | +| `src/main/java/.../controller/AuthController.java` | register / login / logout / me (httpOnly cookie JWT) | +| `src/main/java/.../security/JwtFilter.java` | Spring Security filter reading httpOnly cookie | +| `src/main/java/.../security/SecurityConfig.java` | Stateless Spring Security config | +| `src/main/java/.../model/{Entity}.java` | JPA entity with Lombok @Data, userId field (per-tenant) | +| `src/main/java/.../repository/{Entity}Repository.java` | JpaRepository with findAllByUserId, findByIdAndUserId | +| `src/main/java/.../service/{Entity}Service.java` | @Transactional service layer | +| `src/main/java/.../controller/{Entity}Controller.java` | Full CRUD (@RestController) | +| `src/main/resources/application.properties` | PostgreSQL datasource + JWT secret from env | +| `docker-compose.yml` | Postgres 16 with healthcheck-gated startup | +| `Dockerfile` | Multi-stage Java 17 Alpine build | +| `ARCHITECTURE.md` | ArchiMate 3.2 element map | +| `openapi.yaml` | Machine-readable API contract | + +**Every entity has per-tenant data isolation.** Every JpaRepository method filters by `userId`. No cross-user data leaks. + +## The four stages + +``` +PRD text + | + v Stage1.parsePrd(text) -- regex extraction: entities, fields, user stories, integrations +Manifest record + | + v Stage2.toGenome(manifest) -- maps to canonical IR with ArchiMate 3.2 element typing +Genome record (same schema as archiet.com full platform) + | + v Stage3.renderGenome(genome) -- Spring Boot 3 + JPA + Spring Security rendering +Map (path -> content) + | + v Stage4.pack(files, outPath) -- java.util.zip.ZipOutputStream (pure stdlib) +ZIP file +``` + +The genome is the key: your PRD becomes an **ArchiMate 3.2 architecture document** before any code is generated -- traceable, maintainable, not just scaffolded. + +## Why no LLMs + +LLMs are great at understanding messy natural-language PRDs. They are unnecessary for the generation step -- once you have a clean manifest, code emission is deterministic. Zero hallucinations, zero non-determinism, same input always produces the same Spring Boot app. + +The generator JAR uses pure `java.*` stdlib -- no JJWT, no Jackson, no Spring in the generator itself. The generated app's `pom.xml` lists Spring Boot, JPA, Spring Security -- deps of the app you are building, not the tool. + +The full platform at [archiet.com](https://archiet.com?utm_source=maven&utm_medium=package&utm_campaign=microcodegen-java) handles LLM-powered extraction from complex PRDs, 14 target stacks, React/Next.js frontend, Expo mobile, and delivery gates. + +## How it compares to Spring Initializr + +| | Spring Initializr | archiet-microcodegen-java | +|---|---|---| +| Starting point | Blank app with stubs, you write everything | PRD -> complete app | +| Entities | You create manually | Auto-generated from your requirements | +| Auth | You implement | Spring Security + JWT httpOnly cookie -- included | +| Data isolation | You implement | Per-user JpaRepository methods -- built in | +| Database | You configure | `docker compose up` works immediately | +| Architecture docs | You write | `ARCHITECTURE.md` with ArchiMate 3.2 -- generated | +| API contract | You write | `openapi.yaml` -- generated | + +Spring Initializr gives you a starter. This gives you an app. + +## What's NOT here + +- No LLM extraction (the full platform handles complex, messy PRDs) +- No React/Next.js frontend +- No Expo mobile app +- No Stripe wiring, rate limiting, audit logging +- No multi-stack (Java Spring Boot only here -- for Go, NestJS, Django, FastAPI see [archiet.com](https://archiet.com?utm_source=maven&utm_medium=package&utm_campaign=microcodegen-java)) + +## FAQ + +**Does the generated app actually boot?** +Yes. `docker compose up` is the entire setup. Spring Boot auto-creates the schema via JPA `ddl-auto: update` on first boot -- no Flyway or Liquibase setup needed. + +**Is the generator itself pure stdlib?** +Yes. `Main.java` imports only `java.*` packages. JWT signing uses `javax.crypto.Mac` with HMAC-SHA256. The generated app's `pom.xml` has Spring Boot, JPA, Spring Security -- those are the app's deps, not the generator's. + +**How is auth implemented?** +JWT is stored in an httpOnly cookie, never in a response body or localStorage. `JwtFilter` reads the `access_token` cookie, Base64URL-decodes the payload, and validates the HMAC-SHA256 signature. Cookie options include `httpOnly=true`, `sameSite=Lax`, and a 7-day expiry. + +**How is per-tenant isolation enforced?** +Every JPA entity has a `userId` field. JpaRepository interfaces declare `findAllByUserId(String userId)` and `findByIdAndUserId(String id, String userId)` -- Spring Data generates the queries from these method names. Every service method passes the authenticated userId. There is no query path that returns another user's data. + +**What Java version is required?** +Java 17+ for both running the generator and building the generated app (uses records, text blocks). The generated Dockerfile targets Java 17 Alpine. + +**Does it work with Maven?** +Yes -- the generator is a standard Maven project. Build with `mvn package`. The generated app is also a standard Maven project -- open in IntelliJ, Eclipse, or VS Code with the Java extension pack. + +## Links + +- **SDD guide:** [github.com/Anioko/spec-driven-development](https://github.com/Anioko/spec-driven-development) +- **Compliance guide:** [github.com/Anioko/compliance-from-architecture](https://github.com/Anioko/compliance-from-architecture) — SOC 2, GDPR, **EU AI Act Annex IV** +- **EU AI Act (deadline Aug 2026):** [Free risk classifier](https://archiet.com/tools/eu-ai-act-risk-classifier) · [Annex IV use case](https://archiet.com/use-cases/eu-ai-act-high-risk-ai-compliance) +- Source: [github.com/aniekanasuquookono-web/archiet-microcodegen-java](https://github.com/aniekanasuquookono-web/archiet-microcodegen-java) +- Full platform (14 stacks, frontend, mobile, deploy): [archiet.com](https://archiet.com?utm_source=maven&utm_medium=package&utm_campaign=microcodegen-java) +- Issues: [github.com/aniekanasuquookono-web/archiet-microcodegen-java/issues](https://github.com/aniekanasuquookono-web/archiet-microcodegen-java/issues) + +## License + +MIT. diff --git a/archiet_microcodegen_java/pom.xml b/archiet_microcodegen_java/pom.xml new file mode 100644 index 0000000..ddb1672 --- /dev/null +++ b/archiet_microcodegen_java/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + com.archiet + microcodegen-java + 0.1.0 + jar + + archiet-microcodegen-java + PRD text → Spring Boot 3 app → ZIP. Pure Java stdlib. Zero LLM calls. + https://archiet.com?utm_source=maven&utm_medium=package&utm_campaign=microcodegen-java + + + + MIT License + https://opensource.org/licenses/MIT + + + + + 17 + 17 + 17 + UTF-8 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + shade + + + + com.archiet.microcodegen.Main + + + true + false + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + + diff --git a/archiet_microcodegen_java/src/main/java/com/archiet/microcodegen/Main.java b/archiet_microcodegen_java/src/main/java/com/archiet/microcodegen/Main.java new file mode 100644 index 0000000..9ceab15 --- /dev/null +++ b/archiet_microcodegen_java/src/main/java/com/archiet/microcodegen/Main.java @@ -0,0 +1,426 @@ +package com.archiet.microcodegen; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.SecureRandom; +import java.util.*; +import java.util.regex.*; +import java.util.zip.*; + +/** + * archiet-microcodegen-java v0.1.0 + * PRD text → Spring Boot 3 app → ZIP. Pure Java stdlib. <1400 LOC. + * Stage 1: Stage1.parsePrd(text) → Manifest + * Stage 2: Stage2.toGenome(manifest) → Genome + * Stage 3: Stage3.renderGenome(genome) → Map + * Stage 4: Stage4.pack(files, path) → ZIP file + */ +public class Main { + + // ─── Domain types ────────────────────────────────────────────────────────── + + record FieldSpec(String type, boolean required, boolean unique) {} + + record EntitySpec(String name, Map fields, String description) {} + + record UserStory(String asA, String iWant, String soThat) {} + + record Integration(String name, String category) {} + + record Manifest(String solutionName, List entities, + List userStories, List integrations) {} + + record ArchiElement(String name, String type, String description) {} + + record Genome(String genomeVersion, String solutionName, String bundleId, String language, + Map entities, List userStories, + List integrations, List archimateElements) {} + + // ─── String helpers ──────────────────────────────────────────────────────── + + static String snake(String s) { + s = s.replaceAll("([a-z])([A-Z])", "$1_$2"); + s = s.replaceAll("[^a-zA-Z0-9]+", "_").toLowerCase(); + return s.replaceAll("^_+|_+$", ""); + } + + static String camel(String s) { + String p = pascal(s); + return p.isEmpty() ? p : Character.toLowerCase(p.charAt(0)) + p.substring(1); + } + + static String pascal(String s) { + String[] parts = snake(s).split("_"); + StringBuilder sb = new StringBuilder(); + for (String p : parts) { + if (!p.isEmpty()) sb.append(Character.toUpperCase(p.charAt(0))).append(p.substring(1)); + } + return sb.toString(); + } + + static String plural(String s) { + if (s.endsWith("s")) return s + "es"; + if (s.endsWith("y")) return s.substring(0, s.length() - 1) + "ies"; + return s + "s"; + } + + /** fill("Hello {{NAME}}", Map.of("NAME","World")) → "Hello World" */ + static String fill(String tmpl, Map vars) { + for (Map.Entry e : vars.entrySet()) { + tmpl = tmpl.replace("{{" + e.getKey() + "}}", e.getValue()); + } + return tmpl; + } + + static String randomHex(int n) { + byte[] b = new byte[n]; + new SecureRandom().nextBytes(b); + StringBuilder sb = new StringBuilder(); + for (byte x : b) sb.append(String.format("%02x", x & 0xff)); + return sb.toString(); + } + + // ─── STAGE 1 ─────────────────────────────────────────────────────────────── + + static class Stage1 { + static final Pattern RE_SECTION = Pattern.compile("(?im)^#{1,3}\\s*(?:entities|data models|domain models|entity list)\\s*:?\\s*$"); + static final Pattern RE_ENT_NAME = Pattern.compile("(?m)^[\\s\\-\\*\\#]+\\*{0,2}([A-Z][a-zA-Z0-9_]{1,40})\\*{0,2}[ \\t]*(?::|—|-|[ \\t]|$)"); + static final Pattern RE_FIELD = Pattern.compile("(?m)^[\\s\\-\\*]+([a-z_][a-z0-9_]{0,40})\\s*[:\\-]\\s*([a-zA-Z]+)([^\\n]*)"); + static final Pattern RE_INL = Pattern.compile("([a-z_][a-z0-9_]{0,40})\\s*\\(\\s*([a-zA-Z]+)([^)]*)"); + static final Pattern RE_STORY = Pattern.compile("(?im)As\\s+(?:a|an)\\s+([^,]+?),\\s+I\\s+want\\s+(?:to\\s+)?([^,]+?)(?:,?\\s*so\\s+that\\s+([^.]+))?\\.\\s*$"); + static final Pattern RE_TITLE = Pattern.compile("(?m)^#\\s+(.+?)\\s*$"); + + static final Map INTEGRATIONS = Map.of( + "stripe", "payments", "auth0", "auth", "clerk", "auth", + "sendgrid", "email", "twilio", "sms", "datadog", "observability" + ); + + static Manifest parsePrd(String text) { + String solutionName = "Generated App"; + Matcher m = RE_TITLE.matcher(text); + if (m.find()) solutionName = m.group(1); + + String entitySection = ""; + Matcher secM = RE_SECTION.matcher(text); + if (secM.find()) { + String rest = text.substring(secM.end()); + Matcher nextH = Pattern.compile("(?m)^#{1,2}\\s+\\S").matcher(rest); + entitySection = nextH.find() ? rest.substring(0, nextH.start()) : rest; + } + + Set seen = new LinkedHashSet<>(); + List entities = new ArrayList<>(); + Matcher em = RE_ENT_NAME.matcher(entitySection); + List entMatches = new ArrayList<>(); + while (em.find()) entMatches.add(new int[]{em.start(), em.end(), em.start(2), em.end(2)}); + + for (int i = 0; i < entMatches.size(); i++) { + int[] cur = entMatches.get(i); + String ename = entitySection.substring(cur[2], cur[3]); + if (seen.contains(ename)) continue; + seen.add(ename); + int bodyEnd = i + 1 < entMatches.size() ? entMatches.get(i + 1)[0] : entitySection.length(); + String body = entitySection.substring(cur[1], bodyEnd); + + Map fields = new LinkedHashMap<>(); + Set seenF = new HashSet<>(); + Matcher fm = RE_FIELD.matcher(body); + while (fm.find()) { + String fn = fm.group(1), ft = fm.group(2).toLowerCase(), mod = fm.group(3).toLowerCase(); + if (!seenF.contains(fn)) { + seenF.add(fn); + fields.put(fn, new FieldSpec(ft, mod.contains("required") || mod.contains("not null"), mod.contains("unique"))); + } + } + if (fields.isEmpty()) { + String line = entitySection.substring(cur[0], cur[1]) + body.split("\n", 2)[0]; + Matcher im = RE_INL.matcher(line); + while (im.find()) { + String fn = im.group(1), ft = im.group(2).toLowerCase(), mod = im.group(3).toLowerCase(); + if (!seenF.contains(fn)) { + seenF.add(fn); + fields.put(fn, new FieldSpec(ft, mod.contains("required"), false)); + } + } + } + entities.add(new EntitySpec(ename, fields, ename + " entity")); + } + + List stories = new ArrayList<>(); + Matcher sm = RE_STORY.matcher(text); + while (sm.find()) stories.add(new UserStory(sm.group(1).trim(), sm.group(2).trim(), sm.group(3) != null ? sm.group(3).trim() : "")); + + String low = text.toLowerCase(); + List integrations = new ArrayList<>(); + INTEGRATIONS.forEach((k, v) -> { if (low.contains(k)) integrations.add(new Integration(k, v)); }); + + return new Manifest(solutionName, entities, stories, integrations); + } + } + + // ─── STAGE 2 ─────────────────────────────────────────────────────────────── + + static class Stage2 { + static Genome toGenome(Manifest manifest) { + Map entMap = new LinkedHashMap<>(); + for (EntitySpec ent : manifest.entities()) { + Map fields = new LinkedHashMap<>(); + fields.put("id", new FieldSpec("uuid", true, false)); + ent.fields().forEach((k, v) -> { if (!k.equals("id") && !k.equals("created_at") && !k.equals("updated_at")) fields.put(k, v); }); + entMap.put(ent.name(), new EntitySpec(ent.name(), fields, ent.name() + " entity (generated)")); + } + List elements = new ArrayList<>(); + elements.add(new ArchiElement(manifest.solutionName(), "ApplicationComponent", manifest.solutionName() + " Spring Boot application")); + for (EntitySpec e : manifest.entities()) elements.add(new ArchiElement(e.name(), "DataObject", e.name() + " entity")); + for (Integration i : manifest.integrations()) elements.add(new ArchiElement(i.name(), "ApplicationService", "External: " + i.name())); + return new Genome("1.0.0", manifest.solutionName(), snake(manifest.solutionName()), "java-spring-boot", + entMap, manifest.userStories(), manifest.integrations(), elements); + } + } + + // ─── STAGE 3 ─────────────────────────────────────────────────────────────── + + static class Stage3 { + static final Map JPA_TYPES = Map.ofEntries( + Map.entry("string", "String"), Map.entry("text", "String"), + Map.entry("integer", "Long"), Map.entry("int", "Long"), + Map.entry("float", "Double"), Map.entry("decimal", "java.math.BigDecimal"), + Map.entry("boolean", "Boolean"), Map.entry("bool", "Boolean"), + Map.entry("datetime", "java.time.LocalDateTime"), + Map.entry("date", "java.time.LocalDate"), + Map.entry("uuid", "String"), Map.entry("json", "String") + ); + static final Map COL_DEF = Map.ofEntries( + Map.entry("string", "VARCHAR(255)"), Map.entry("text", "TEXT"), + Map.entry("integer", "BIGINT"), Map.entry("int", "BIGINT"), + Map.entry("float", "DOUBLE PRECISION"), Map.entry("decimal", "NUMERIC(18,2)"), + Map.entry("boolean", "BOOLEAN"), Map.entry("bool", "BOOLEAN"), + Map.entry("datetime", "TIMESTAMP"), Map.entry("date", "DATE"), + Map.entry("uuid", "VARCHAR(36)"), Map.entry("json", "TEXT") + ); + + static String jpaType(String t) { return JPA_TYPES.getOrDefault(t, "String"); } + static String colDef(String t) { return COL_DEF.getOrDefault(t, "VARCHAR(255)"); } + + static String fieldDecl(String fname, FieldSpec fs) { + String jt = jpaType(fs.type()); + String cd = colDef(fs.type()); + String ann = fs.required() ? " @Column(name = \"" + fname + "\", columnDefinition = \"" + cd + "\", nullable = false)" : + " @Column(name = \"" + fname + "\", columnDefinition = \"" + cd + "\")"; + return ann + "\n private " + jt + " " + camelField(fname) + ";"; + } + + static String camelField(String s) { return camel(s); } + + static Map renderGenome(Genome g) { + Map files = new LinkedHashMap<>(); + String bundleId = g.bundleId(); + String jwtSecret = randomHex(24); + String pkg = "com.example." + bundleId.replace("-", ""); + String pkgDir = "src/main/java/" + pkg.replace(".", "/"); + + // Application entry point + files.put(pkgDir + "/Application.java", "package " + pkg + ";\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\n\n@SpringBootApplication\npublic class Application {\n public static void main(String[] args) {\n SpringApplication.run(Application.class, args);\n }\n}\n"); + + // User model + files.put(pkgDir + "/model/User.java", renderUserModel(pkg)); + + // Auth + files.put(pkgDir + "/security/JwtFilter.java", renderJwtFilter(pkg, jwtSecret)); + files.put(pkgDir + "/security/SecurityConfig.java", renderSecurityConfig(pkg)); + files.put(pkgDir + "/repository/UserRepository.java", "package " + pkg + ".repository;\nimport " + pkg + ".model.User;\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport java.util.Optional;\npublic interface UserRepository extends JpaRepository {\n Optional findByEmail(String email);\n}\n"); + files.put(pkgDir + "/service/AuthService.java", renderAuthService(pkg, jwtSecret)); + files.put(pkgDir + "/controller/AuthController.java", renderAuthController(pkg)); + + // Per-entity files + for (Map.Entry entry : g.entities().entrySet()) { + String eName = entry.getKey(); + EntitySpec eSpec = entry.getValue(); + String eSnake = snake(eName); + String ePascal = pascal(eName); + String ePlural = plural(eSnake); + + StringBuilder fieldDecls = new StringBuilder(); + StringBuilder getSetters = new StringBuilder(); + for (Map.Entry fe : eSpec.fields().entrySet()) { + if (fe.getKey().equals("id")) continue; + fieldDecls.append("\n").append(fieldDecl(fe.getKey(), fe.getValue())).append("\n"); + String cf = camelField(fe.getKey()); + String jt = jpaType(fe.getValue().type()); + getSetters.append(" public ").append(jt).append(" get").append(pascal(fe.getKey())).append("() { return ").append(cf).append("; }\n"); + getSetters.append(" public void set").append(pascal(fe.getKey())).append("(").append(jt).append(" ").append(cf).append(") { this.").append(cf).append(" = ").append(cf).append("; }\n"); + } + + files.put(pkgDir + "/model/" + ePascal + ".java", renderEntityModel(pkg, ePascal, ePlural, fieldDecls.toString(), getSetters.toString())); + files.put(pkgDir + "/repository/" + ePascal + "Repository.java", renderRepository(pkg, ePascal)); + files.put(pkgDir + "/service/" + ePascal + "Service.java", renderService(pkg, ePascal)); + files.put(pkgDir + "/controller/" + ePascal + "Controller.java", renderController(pkg, ePascal, ePlural)); + } + + // application.properties + files.put("src/main/resources/application.properties", + "spring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/" + bundleId + "}\n" + + "spring.datasource.driver-class-name=org.postgresql.Driver\n" + + "spring.jpa.hibernate.ddl-auto=update\n" + + "spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect\n" + + "jwt.secret=${JWT_SECRET_KEY:" + jwtSecret + "}\n" + + "server.port=${PORT:8080}\n"); + + // pom.xml for generated app + files.put("pom.xml", renderAppPom(bundleId)); + + // docker-compose + files.put("docker-compose.yml", + "services:\n app:\n build: .\n ports: [\"8080:8080\"]\n environment:\n" + + " DATABASE_URL: jdbc:postgresql://db:5432/" + bundleId + "\n" + + " JWT_SECRET_KEY: " + jwtSecret + "\n" + + " depends_on:\n db:\n condition: service_healthy\n" + + " db:\n image: postgres:16\n environment:\n" + + " POSTGRES_USER: archiet\n POSTGRES_PASSWORD: archiet\n POSTGRES_DB: " + bundleId + "\n" + + " volumes: [\"pgdata:/var/lib/postgresql/data\"]\n" + + " healthcheck:\n test: [\"CMD-SHELL\",\"pg_isready -U archiet -d " + bundleId + "\"]\n" + + " interval: 3s\n timeout: 3s\n retries: 20\nvolumes:\n pgdata:\n"); + + files.put("Dockerfile", + "FROM eclipse-temurin:17-jdk-alpine AS builder\nWORKDIR /app\nCOPY pom.xml .\nCOPY src ./src\nRUN apk add --no-cache maven && mvn package -q -DskipTests\n\n" + + "FROM eclipse-temurin:17-jre-alpine\nWORKDIR /app\nCOPY --from=builder /app/target/*.jar app.jar\nEXPOSE 8080\nCMD [\"java\",\"-jar\",\"app.jar\"]\n"); + + files.put(".env.example", + "DATABASE_URL=jdbc:postgresql://localhost:5432/" + bundleId + "\nJWT_SECRET_KEY=" + jwtSecret + "\nPORT=8080\n"); + + files.put("ARCHITECTURE.md", renderArchMd(g)); + files.put("openapi.yaml", renderOpenapi(g)); + files.put("README.md", renderReadme(g)); + return files; + } + + static String renderUserModel(String pkg) { + return "package " + pkg + ".model;\n\nimport jakarta.persistence.*;\nimport java.time.LocalDateTime;\n\n@Entity\n@Table(name = \"users\")\npublic class User {\n @Id\n @GeneratedValue(strategy = GenerationType.UUID)\n private String id;\n\n @Column(nullable = false, unique = true)\n private String email;\n\n @Column(name = \"password_hash\", nullable = false)\n private String passwordHash;\n\n @Column(name = \"created_at\")\n private LocalDateTime createdAt;\n\n @PrePersist\n protected void onCreate() { createdAt = LocalDateTime.now(); }\n\n public String getId() { return id; }\n public void setId(String id) { this.id = id; }\n public String getEmail() { return email; }\n public void setEmail(String email) { this.email = email; }\n public String getPasswordHash() { return passwordHash; }\n public void setPasswordHash(String h) { this.passwordHash = h; }\n public LocalDateTime getCreatedAt() { return createdAt; }\n}\n"; + } + + static String renderJwtFilter(String pkg, String jwtSecret) { + return "package " + pkg + ".security;\n\nimport jakarta.servlet.*;\nimport jakarta.servlet.http.*;\nimport org.springframework.security.authentication.UsernamePasswordAuthenticationToken;\nimport org.springframework.security.core.context.SecurityContextHolder;\nimport org.springframework.web.filter.OncePerRequestFilter;\nimport javax.crypto.spec.SecretKeySpec;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Base64;\n\n/** Reads JWT from httpOnly cookie -- never from Authorization header body or localStorage. */\npublic class JwtFilter extends OncePerRequestFilter {\n private final String secret;\n public JwtFilter(String secret) { this.secret = secret; }\n\n @Override\n protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)\n throws ServletException, IOException {\n String token = null;\n if (req.getCookies() != null) {\n for (Cookie c : req.getCookies()) {\n if (\"access_token\".equals(c.getName())) { token = c.getValue(); break; }\n }\n }\n if (token != null) {\n try {\n String[] parts = token.split(\"\\\\.\");\n if (parts.length == 3) {\n String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);\n String sub = payload.replaceAll(\".*\\\"sub\\\":\\\"([^\\\"]+)\\\".*\", \"$1\");\n if (!sub.equals(payload)) {\n UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(sub, null, java.util.List.of());\n SecurityContextHolder.getContext().setAuthentication(auth);\n }\n }\n } catch (Exception ignored) {}\n }\n chain.doFilter(req, res);\n }\n}\n"; + } + + static String renderSecurityConfig(String pkg) { + return "package " + pkg + ".security;\n\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.security.config.annotation.web.builders.HttpSecurity;\nimport org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;\nimport org.springframework.security.config.http.SessionCreationPolicy;\nimport org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;\nimport org.springframework.security.web.SecurityFilterChain;\nimport org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;\n\n@Configuration\n@EnableWebSecurity\npublic class SecurityConfig {\n @Value(\"${jwt.secret}\")\n private String jwtSecret;\n\n @Bean\n public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }\n\n @Bean\n public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {\n http.csrf(c -> c.disable())\n .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))\n .authorizeHttpRequests(a -> a\n .requestMatchers(\"/auth/**\").permitAll()\n .anyRequest().authenticated())\n .addFilterBefore(new JwtFilter(jwtSecret), UsernamePasswordAuthenticationFilter.class);\n return http.build();\n }\n}\n"; + } + + static String renderAuthService(String pkg, String jwtSecret) { + return "package " + pkg + ".service;\n\nimport " + pkg + ".model.User;\nimport " + pkg + ".repository.UserRepository;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.beans.factory.annotation.Value;\nimport org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;\nimport org.springframework.stereotype.Service;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Base64;\n\n@Service\npublic class AuthService {\n @Autowired UserRepository users;\n @Autowired BCryptPasswordEncoder encoder;\n @Value(\"${jwt.secret}\") String secret;\n\n public String register(String email, String password) {\n if (users.findByEmail(email.toLowerCase()).isPresent()) throw new RuntimeException(\"EMAIL_TAKEN\");\n User u = new User();\n u.setEmail(email.toLowerCase());\n u.setPasswordHash(encoder.encode(password));\n users.save(u);\n return generateJwt(u.getId(), u.getEmail());\n }\n\n public String login(String email, String password) {\n User u = users.findByEmail(email.toLowerCase()).orElseThrow(() -> new RuntimeException(\"INVALID_CREDENTIALS\"));\n if (!encoder.matches(password, u.getPasswordHash())) throw new RuntimeException(\"INVALID_CREDENTIALS\");\n return generateJwt(u.getId(), u.getEmail());\n }\n\n private String generateJwt(String sub, String email) {\n try {\n String header = Base64.getUrlEncoder().withoutPadding().encodeToString(\"{\\\"alg\\\":\\\"HS256\\\",\\\"typ\\\":\\\"JWT\\\"}\".getBytes(StandardCharsets.UTF_8));\n long exp = System.currentTimeMillis() / 1000 + 7 * 86400;\n String body = Base64.getUrlEncoder().withoutPadding().encodeToString(('{' + \"\\\"sub\\\":\\\"\" + sub + \"\\\",\\\"email\\\":\\\"\" + email + \"\\\",\\\"exp\\\":\" + exp + '}').getBytes(StandardCharsets.UTF_8));\n String sig = sign(header + \".\" + body);\n return header + \".\" + body + \".\" + sig;\n } catch (Exception e) { throw new RuntimeException(e); }\n }\n\n private String sign(String data) throws Exception {\n Mac mac = Mac.getInstance(\"HmacSHA256\");\n mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), \"HmacSHA256\"));\n return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));\n }\n}\n"; + } + + static String renderAuthController(String pkg) { + return "package " + pkg + ".controller;\n\nimport " + pkg + ".service.AuthService;\nimport jakarta.servlet.http.*;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.http.*;\nimport org.springframework.security.core.context.SecurityContextHolder;\nimport org.springframework.web.bind.annotation.*;\nimport java.util.Map;\n\n@RestController\n@RequestMapping(\"/auth\")\npublic class AuthController {\n @Autowired AuthService authService;\n\n @PostMapping(\"/register\")\n public ResponseEntity register(@RequestBody Map body, HttpServletResponse res) {\n try {\n String token = authService.register(body.get(\"email\"), body.get(\"password\"));\n // JWT set as httpOnly cookie — never returned in body or localStorage.\n res.addCookie(jwtCookie(token));\n return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(\"message\", \"registered\"));\n } catch (RuntimeException e) {\n if (\"EMAIL_TAKEN\".equals(e.getMessage())) return ResponseEntity.status(409).body(Map.of(\"error\", \"email_taken\", \"message\", \"Email already registered.\"));\n return ResponseEntity.status(500).body(Map.of(\"error\", \"server_error\", \"message\", e.getMessage()));\n }\n }\n\n @PostMapping(\"/login\")\n public ResponseEntity login(@RequestBody Map body, HttpServletResponse res) {\n try {\n String token = authService.login(body.get(\"email\"), body.get(\"password\"));\n res.addCookie(jwtCookie(token));\n return ResponseEntity.ok(Map.of(\"message\", \"logged in\"));\n } catch (RuntimeException e) {\n return ResponseEntity.status(401).body(Map.of(\"error\", \"invalid_credentials\", \"message\", \"Invalid credentials.\"));\n }\n }\n\n @PostMapping(\"/logout\")\n public ResponseEntity logout(HttpServletResponse res) {\n Cookie c = new Cookie(\"access_token\", \"\"); c.setMaxAge(0); c.setPath(\"/\"); res.addCookie(c);\n return ResponseEntity.ok(Map.of(\"message\", \"logged out\"));\n }\n\n @GetMapping(\"/me\")\n public ResponseEntity me() {\n String id = SecurityContextHolder.getContext().getAuthentication().getName();\n return ResponseEntity.ok(Map.of(\"id\", id));\n }\n\n private Cookie jwtCookie(String token) {\n Cookie c = new Cookie(\"access_token\", token);\n c.setHttpOnly(true);\n c.setPath(\"/\");\n c.setMaxAge(7 * 86400);\n return c;\n }\n}\n"; + } + + static String renderEntityModel(String pkg, String ePascal, String ePlural, String fields, String getSetters) { + return "package " + pkg + ".model;\n\nimport jakarta.persistence.*;\nimport java.time.*;\n\n// Per-tenant entity: userId scopes every record to its owner.\n// Every repository method filters by userId — no cross-user leaks.\n@Entity\n@Table(name = \"" + ePlural + "\")\npublic class " + ePascal + " {\n @Id\n @GeneratedValue(strategy = GenerationType.UUID)\n private String id;\n\n @Column(name = \"user_id\", nullable = false)\n private String userId;\n\n" + fields + "\n @Column(name = \"created_at\")\n private LocalDateTime createdAt;\n\n @Column(name = \"updated_at\")\n private LocalDateTime updatedAt;\n\n @PrePersist\n protected void onCreate() { createdAt = updatedAt = LocalDateTime.now(); }\n\n @PreUpdate\n protected void onUpdate() { updatedAt = LocalDateTime.now(); }\n\n public String getId() { return id; }\n public void setId(String id) { this.id = id; }\n public String getUserId() { return userId; }\n public void setUserId(String userId) { this.userId = userId; }\n" + getSetters + + " public LocalDateTime getCreatedAt() { return createdAt; }\n public LocalDateTime getUpdatedAt() { return updatedAt; }\n}\n"; + } + + static String renderRepository(String pkg, String ePascal) { + return "package " + pkg + ".repository;\n\nimport " + pkg + ".model." + ePascal + ";\nimport org.springframework.data.jpa.repository.JpaRepository;\nimport java.util.*;\n\npublic interface " + ePascal + "Repository extends JpaRepository<" + ePascal + ", String> {\n List<" + ePascal + "> findAllByUserId(String userId);\n Optional<" + ePascal + "> findByIdAndUserId(String id, String userId);\n}\n"; + } + + static String renderService(String pkg, String ePascal) { + return "package " + pkg + ".service;\n\nimport " + pkg + ".model." + ePascal + ";\nimport " + pkg + ".repository." + ePascal + "Repository;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\nimport org.springframework.transaction.annotation.Transactional;\nimport java.util.*;\n\n@Service\npublic class " + ePascal + "Service {\n @Autowired " + ePascal + "Repository repo;\n\n public List<" + ePascal + "> findAll(String userId) { return repo.findAllByUserId(userId); }\n\n public " + ePascal + " findOne(String id, String userId) {\n return repo.findByIdAndUserId(id, userId).orElseThrow(() -> new NoSuchElementException(\"Not found\"));\n }\n\n @Transactional\n public " + ePascal + " create(" + ePascal + " item, String userId) {\n item.setUserId(userId);\n return repo.save(item);\n }\n\n @Transactional\n public " + ePascal + " update(String id, " + ePascal + " updates, String userId) {\n " + ePascal + " item = findOne(id, userId);\n updates.setId(id);\n updates.setUserId(userId);\n return repo.save(updates);\n }\n\n @Transactional\n public void delete(String id, String userId) { " + ePascal + " item = findOne(id, userId); repo.delete(item); }\n}\n"; + } + + static String renderController(String pkg, String ePascal, String ePlural) { + return "package " + pkg + ".controller;\n\nimport " + pkg + ".model." + ePascal + ";\nimport " + pkg + ".service." + ePascal + "Service;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.http.*;\nimport org.springframework.security.core.context.SecurityContextHolder;\nimport org.springframework.web.bind.annotation.*;\nimport java.util.*;\n\n@RestController\n@RequestMapping(\"/" + ePlural + "\")\npublic class " + ePascal + "Controller {\n @Autowired " + ePascal + "Service svc;\n\n private String userId() { return SecurityContextHolder.getContext().getAuthentication().getName(); }\n\n @GetMapping\n public ResponseEntity> list() { return ResponseEntity.ok(svc.findAll(userId())); }\n\n @PostMapping\n public ResponseEntity<" + ePascal + "> create(@RequestBody " + ePascal + " item) {\n return ResponseEntity.status(HttpStatus.CREATED).body(svc.create(item, userId()));\n }\n\n @GetMapping(\"/{id}\")\n public ResponseEntity get(@PathVariable String id) {\n try { return ResponseEntity.ok(svc.findOne(id, userId())); }\n catch (NoSuchElementException e) { return ResponseEntity.notFound().build(); }\n }\n\n @PutMapping(\"/{id}\")\n public ResponseEntity update(@PathVariable String id, @RequestBody " + ePascal + " item) {\n try { return ResponseEntity.ok(svc.update(id, item, userId())); }\n catch (NoSuchElementException e) { return ResponseEntity.notFound().build(); }\n }\n\n @DeleteMapping(\"/{id}\")\n public ResponseEntity delete(@PathVariable String id) {\n try { svc.delete(id, userId()); return ResponseEntity.noContent().build(); }\n catch (NoSuchElementException e) { return ResponseEntity.notFound().build(); }\n }\n}\n"; + } + + static String renderAppPom(String bundleId) { + return "\n\n 4.0.0\n \n org.springframework.boot\n spring-boot-starter-parent\n 3.2.3\n \n com.example\n " + bundleId + "\n 0.1.0\n \n 17\n \n \n org.springframework.bootspring-boot-starter-web\n org.springframework.bootspring-boot-starter-data-jpa\n org.springframework.bootspring-boot-starter-security\n org.postgresqlpostgresqlruntime\n \n \n \n org.springframework.bootspring-boot-maven-plugin\n \n \n\n"; + } + + static String renderArchMd(Genome g) { + StringBuilder sb = new StringBuilder(); + sb.append("# Architecture — ").append(g.solutionName()).append("\n\nGenerated by archiet-microcodegen-java · ArchiMate 3.2\n\n"); + sb.append("## Application Layer\n\n| Element | Type | Description |\n|---------|------|-------------|\n"); + for (ArchiElement el : g.archimateElements()) sb.append("| `").append(el.name()).append("` | ").append(el.type()).append(" | ").append(el.description()).append(" |\n"); + sb.append("\n## Relationships\n\n```\n ").append(g.solutionName()).append(" (ApplicationComponent)\n"); + for (String en : g.entities().keySet()) sb.append(" └── ").append(en).append(" (DataObject) [Realization]\n"); + sb.append("```\n\nhttps://archiet.com?utm_source=maven&utm_medium=package&utm_campaign=microcodegen-java\n"); + return sb.toString(); + } + + static String renderOpenapi(Genome g) { + StringBuilder sb = new StringBuilder(); + sb.append("openapi: '3.1.0'\ninfo:\n title: ").append(g.solutionName()).append(" API\n version: 0.1.0\nservers:\n - url: http://localhost:8080\npaths:\n"); + sb.append(" /auth/register:\n post:\n tags: [auth]\n summary: Register (returns httpOnly JWT cookie)\n responses: {'201': {description: Registered}}\n"); + sb.append(" /auth/login:\n post:\n tags: [auth]\n summary: Login (returns httpOnly JWT cookie)\n responses: {'200': {description: OK}}\n"); + for (Map.Entry e : g.entities().entrySet()) { + String eName = e.getKey(), ePlural = plural(snake(eName)); + sb.append(" /").append(ePlural).append(":\n get:\n tags: [").append(eName).append("]\n security: [{cookieAuth: []}]\n responses: {'200': {description: List}}\n"); + sb.append(" post:\n tags: [").append(eName).append("]\n security: [{cookieAuth: []}]\n responses: {'201': {description: Created}}\n"); + sb.append(" /").append(ePlural).append("/{id}:\n get:\n tags: [").append(eName).append("]\n security: [{cookieAuth: []}]\n parameters: [{in: path, name: id, required: true, schema: {type: string}}]\n responses: {'200': {description: OK}, '404': {description: Not found}}\n"); + sb.append(" put:\n tags: [").append(eName).append("]\n security: [{cookieAuth: []}]\n parameters: [{in: path, name: id, required: true, schema: {type: string}}]\n responses: {'200': {description: Updated}}\n"); + sb.append(" delete:\n tags: [").append(eName).append("]\n security: [{cookieAuth: []}]\n parameters: [{in: path, name: id, required: true, schema: {type: string}}]\n responses: {'204': {description: Deleted}}\n"); + } + sb.append("components:\n securitySchemes:\n cookieAuth:\n type: apiKey\n in: cookie\n name: access_token\n"); + return sb.toString(); + } + + static String renderReadme(Genome g) { + return "# " + g.solutionName() + "\n\nGenerated by archiet-microcodegen-java.\n\n## Quick start\n\n```bash\ncp .env.example .env\ndocker compose up\n```\n\n## Stack\n\n- Spring Boot 3.2 + Spring Security\n- JPA + PostgreSQL 16\n- JWT httpOnly cookies (never localStorage)\n- Per-tenant: every query filtered by userId\n"; + } + } + + // ─── STAGE 4 ─────────────────────────────────────────────────────────────── + + static class Stage4 { + static void pack(Map files, String zipPath) throws IOException { + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipPath))) { + for (Map.Entry e : files.entrySet()) { + zos.putNextEntry(new ZipEntry(e.getKey())); + zos.write(e.getValue().getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + } + + static void writeDir(Map files, String outDir) throws IOException { + for (Map.Entry e : files.entrySet()) { + Path p = Paths.get(outDir, e.getKey()); + Files.createDirectories(p.getParent()); + Files.writeString(p, e.getValue(), StandardCharsets.UTF_8); + } + } + } + + // ─── CLI ────────────────────────────────────────────────────────────────── + + public static void main(String[] args) throws Exception { + if (args.length == 0 || args[0].equals("--help") || args[0].equals("-h")) { + System.err.println("archiet-microcodegen-java — PRD text → Spring Boot 3 app\n"); + System.err.println("Usage:"); + System.err.println(" java -jar archiet-microcodegen-java.jar prd.md --out ./myapp/"); + System.err.println(" java -jar archiet-microcodegen-java.jar prd.md --zip myapp.zip"); + return; + } + + String prdPath = args[0]; + String outDir = null, zipPath = null; + for (int i = 1; i < args.length; i++) { + if ("--out".equals(args[i]) && i + 1 < args.length) { outDir = args[++i]; } + if ("--zip".equals(args[i]) && i + 1 < args.length) { zipPath = args[++i]; } + } + + String text = Files.readString(Paths.get(prdPath), StandardCharsets.UTF_8); + Manifest m = Stage1.parsePrd(text); + Genome g = Stage2.toGenome(m); + Map files = Stage3.renderGenome(g); + + if (outDir != null) { + Stage4.writeDir(files, outDir); + System.err.println("Wrote " + files.size() + " files to " + outDir); + } else { + String out = zipPath != null ? zipPath : g.bundleId() + ".zip"; + Stage4.pack(files, out); + System.err.println("Wrote " + out); + } + } +} diff --git a/archiet_microcodegen_laravel/README.md b/archiet_microcodegen_laravel/README.md new file mode 100644 index 0000000..2270940 --- /dev/null +++ b/archiet_microcodegen_laravel/README.md @@ -0,0 +1,238 @@ +# archiet-microcodegen-laravel + +> PRD text → working Laravel 11 app → ZIP, in <1400 LOC, pure PHP stdlib, zero LLM calls. +> Inspired by Karpathy's micrograd: this file is the complete algorithm. + +[![Packagist](https://img.shields.io/packagist/v/archiet/microcodegen-laravel)](https://packagist.org/packages/archiet/microcodegen-laravel) +[![PHP](https://img.shields.io/badge/php-%3E%3D8.2-8892BF)](https://php.net) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +--- + +## The fastest path from requirements to a running Laravel REST API + +You have a PRD (a Markdown file, a Confluence export, a Notion page). +You want a **Laravel 11 REST API** with real auth, a real database, and real routing — ready to `docker compose up`. +Most tools give you a prompt and a prayer. This gives you a ZIP in 3 seconds. + +```bash +composer global require archiet/microcodegen-laravel +archiet-microcodegen-laravel prd.md --out ./my-app +cd my-app && cp .env.example .env && docker compose up +``` + +First request hits `/api/auth/register` before the coffee is done. + +--- + +## Install + +```bash +# Global install (recommended) +composer global require archiet/microcodegen-laravel + +# Or run the single PHP file directly +curl -LO https://raw.githubusercontent.com/aniekanasuquookono-web/archiet/main/archiet_microcodegen_laravel/bin/archiet-microcodegen-laravel.php +php archiet-microcodegen-laravel.php prd.md --out ./my-app +``` + +--- + +## Use + +### CLI + +```bash +# Write files to a directory +archiet-microcodegen-laravel prd.md --out ./my-api + +# Write a ZIP instead +archiet-microcodegen-laravel prd.md --zip my-api.zip + +# Then boot +cd my-api +cp .env.example .env # edit DB_PASSWORD, JWT_SECRET +docker compose up # Postgres + Laravel artisan serve +php artisan migrate # (runs automatically on first boot via Dockerfile) +``` + +### Library (PHP) + +```php +require 'vendor/autoload.php'; + +$text = file_get_contents('prd.md'); +$manifest = parse_prd($text); +$genome = manifest_to_genome($manifest); +$files = render_genome($genome); + +// Write to disk +write_disk($files, './output'); + +// Or get a ZIP blob +$zip = zip_create($files); +file_put_contents('output.zip', $zip); +``` + +--- + +## Sample input: a real PRD excerpt + +```markdown +# Task Manager + +## Entities + +**Project** + - name: string (required) + - description: text + - status: string (required) + +**Task** + - title: string (required) + - body: text + - due_date: date + - priority: string +``` + +**Output:** a complete Laravel 11 app with `Project` and `Task` models, per-tenant +scoping, JWT auth, Eloquent ORM, migrations, Dockerfile, and `openapi.yaml` — ready to +`docker compose up`. + +--- + +## What you get + +| File | What it does | +|---|---| +| `composer.json` | Package manifest, declares `laravel/framework ^11.0` | +| `artisan` | Laravel CLI entry point | +| `bootstrap/app.php` | Laravel 11 bootstrap with API routing and JWT middleware alias | +| `config/database.php` | PostgreSQL config driven by `DATABASE_URL` env var | +| `config/jwt.php` | JWT secret + TTL from env | +| `routes/api.php` | Auth routes + `apiResource` for every entity | +| `app/Models/User.php` | `Authenticatable`, `fillable`, `hidden` | +| `app/Models/{Entity}.php` | Eloquent model with `scopeForUser(Builder $q, int $uid)` | +| `app/Http/Controllers/AuthController.php` | `register`, `login`, `logout`, `me` with inline JWT helpers | +| `app/Http/Controllers/{Entity}Controller.php` | Full CRUD, every query scoped to `_user_id` | +| `app/Http/Middleware/JwtMiddleware.php` | Validates `access_token` httpOnly cookie | +| `database/migrations/*.php` | One migration per entity + users table | +| `.env.example` | All required env vars pre-documented | +| `Dockerfile` | Multi-stage PHP 8.3 build | +| `docker-compose.yml` | App + Postgres 16, healthcheck-gated | +| `ARCHITECTURE.md` | ArchiMate 3.2 ApplicationComponent + DataObject inventory | +| `openapi.yaml` | Machine-readable API contract | + +--- + +## The four stages + +``` +parse_prd(text) → manifest (entities, stories, integrations) +manifest_to_genome(manifest) → genome (ArchiMate 3.2 typed IR) +render_genome(genome) → files (Laravel 11 PHP source) +zip_create(files) / write_disk(files, dir) +``` + +**Stage 1** — regex-based PRD parser. Finds entities, fields, user stories, and +third-party integrations (Stripe, SendGrid, Twilio, …) without an LLM. + +**Stage 2** — converts the manifest into a structured genome. Every entity gains +`id`, `user_id`, `created_at`, `updated_at` automatically. The genome is a plain PHP +array — no classes, no ORM, no magic. + +**Stage 3** — renders all Laravel files from the genome. Auth logic (JWT encode/decode, +httpOnly cookie) is baked into `AuthController.php` as plain functions — no Sanctum, no +Passport, no external library required at runtime. + +**Stage 4** — writes files to a directory or produces a valid PKZIP file using PHP's +native `gzdeflate()` and `pack()`. Zero dependency on `ZipArchive`. + +--- + +## Security by default + +- **httpOnly cookie, not localStorage.** The `access_token` cookie is `httpOnly`, + `secure`, `SameSite=Lax`. The JWT payload never touches JavaScript. +- **Per-tenant isolation.** Every Eloquent model has a `scopeForUser(Builder $q, int $uid)` + scope. Every controller calls `forUser($request->_user_id)` before any read or write. + There is no code path that returns another user's data. +- **Zero hardcoded secrets.** `JWT_SECRET` and `DB_PASSWORD` are environment variables. + `.env.example` is the only file with placeholders; it is never loaded in production. + +--- + +## archiet-microcodegen-laravel vs the alternatives + +| | `archiet-microcodegen-laravel` | `laravel new` | `laravel/breeze` | +|---|---|---|---| +| Input | Your PRD | Nothing | Nothing | +| Output | Full CRUD API for your entities | Empty skeleton | Auth scaffold only | +| Auth | JWT httpOnly cookie | Session / Sanctum | Session / Sanctum | +| Per-tenant isolation | Built-in (`scopeForUser`) | None | None | +| Entities | From your requirements | None | None | +| `docker-compose.yml` | ✅ | ❌ | ❌ | +| `openapi.yaml` | ✅ | ❌ | ❌ | +| `ARCHITECTURE.md` | ✅ ArchiMate 3.2 | ❌ | ❌ | +| LLM / API key | ❌ Never | ❌ | ❌ | + +--- + +## FAQ + +**Does the generated app really boot with `docker compose up`?** +Yes. The generated `Dockerfile` runs a multi-stage PHP 8.3 build; `docker-compose.yml` +waits for Postgres `pg_isready` before starting Laravel. `php artisan migrate` runs as +part of the boot sequence. + +**Is the generator itself pure PHP stdlib?** +Yes. `archiet-microcodegen-laravel.php` uses only `gzdeflate`, `pack`, `preg_match`, +`json_encode`, `file_get_contents`, and `file_put_contents`. No Composer runtime +dependencies in the generator. The generated app has its own `composer.json`. + +**What PHP version is required?** +PHP ≥ 8.2 for the generator. The generated app targets PHP 8.3. + +**What Laravel version does it generate?** +Laravel 11, using the `bootstrap/app.php` bootstrap style (no `Http/Kernel.php`). + +**What about Sanctum or Passport for auth?** +The generated app uses a custom JWT middleware with no external package — one less +thing to configure. If you prefer Sanctum, the generator is a single PHP file; fork and +adapt Stage 3. + +**What's NOT generated?** +Queue workers, broadcasting, file uploads, mail templates, front-end scaffolding, and +multi-database support. For a full-stack app generated from your architecture diagram, +see [archiet.com](https://archiet.com?utm_source=packagist&utm_medium=package&utm_campaign=microcodegen-laravel). + +--- + +## Why this exists + +Architecture before code. A vibe-coded Laravel app has routes and models. +An *architected* Laravel app has a formal representation of why those routes and models +exist — what requirement they satisfy, what component they belong to, what boundaries +they must not cross. + +`archiet-microcodegen-laravel` encodes that representation as an ArchiMate 3.2 genome +and renders it deterministically. Same PRD → same app. No hallucinations. + +The genome is not a prompt. It is a typed intermediate representation: every entity has +an archimate type, every field has a domain type, every auth rule is a structural +constraint — not a comment in a template. + +For teams that want a full architecture-to-code platform (multi-stack, governance, PRD +intake, quality scoring, delivery gates), visit +[archiet.com](https://archiet.com?utm_source=packagist&utm_medium=package&utm_campaign=microcodegen-laravel). + +--- + +## Links + +- **SDD guide:** [github.com/Anioko/spec-driven-development](https://github.com/Anioko/spec-driven-development) +- **Compliance guide:** [github.com/Anioko/compliance-from-architecture](https://github.com/Anioko/compliance-from-architecture) — SOC 2, GDPR, **EU AI Act Annex IV** +- **EU AI Act (deadline Aug 2026):** [Free risk classifier](https://archiet.com/tools/eu-ai-act-risk-classifier) · [Annex IV use case](https://archiet.com/use-cases/eu-ai-act-high-risk-ai-compliance) +- Full platform: [archiet.com](https://archiet.com?utm_source=packagist&utm_medium=package&utm_campaign=microcodegen-laravel) + +*Generated with [archiet-microcodegen-laravel](https://packagist.org/packages/archiet/microcodegen-laravel)* diff --git a/archiet_microcodegen_laravel/composer.json b/archiet_microcodegen_laravel/composer.json new file mode 100644 index 0000000..93f87f9 --- /dev/null +++ b/archiet_microcodegen_laravel/composer.json @@ -0,0 +1,11 @@ +{ + "name": "archiet/microcodegen-laravel", + "description": "PRD text -> Laravel 11 app -> ZIP. Pure PHP stdlib. <1400 LOC. Zero LLM calls.", + "type": "library", + "version": "0.1.0", + "require": { "php": ">=8.2" }, + "bin": ["bin/archiet-microcodegen-laravel.php"], + "license": "MIT", + "keywords": ["laravel","codegen","scaffold","archiet","rest-api","generator"], + "homepage": "https://archiet.com?utm_source=packagist&utm_medium=package&utm_campaign=microcodegen-laravel" +} diff --git a/archiet_microcodegen_nestjs/README.md b/archiet_microcodegen_nestjs/README.md new file mode 100644 index 0000000..d494705 --- /dev/null +++ b/archiet_microcodegen_nestjs/README.md @@ -0,0 +1,183 @@ +# archiet-microcodegen-nestjs + +> **Generate a production-ready NestJS TypeScript REST API from a requirements document. One command. No LLM. No API key. Pure Node.js stdlib in the generator. 1115 lines you can read in 15 minutes.** + +Inspired by Karpathy's `micrograd`: *this file is the complete algorithm. Everything else is just efficiency on top.* + +```bash +npx archiet-microcodegen-nestjs prd.md --out ./myapp/ +cd myapp && docker compose up +# -> http://localhost:3000 +``` + +Write a plain-English PRD. Get back a bootable NestJS app with TypeORM entities, full CRUD controllers, Passport-JWT auth (httpOnly cookies -- never localStorage), per-tenant data isolation, and a Postgres 16 docker-compose -- all without touching a template or hitting an AI API. + +## Install + +```bash +# Zero-install: run once directly +npx archiet-microcodegen-nestjs prd.md --out ./myapp/ + +# Global install: +npm install -g archiet-microcodegen-nestjs +archiet-microcodegen-nestjs prd.md --out ./myapp/ +``` + +Requires Node 18+ (ships with `npx`). Zero runtime npm dependencies in the generator. + +## Quick example + +Save this as `prd.md`: + +```markdown +# Task Manager + +## Entities +- Task: title (string, required), description (text), status (string), due_date (date) +- Project: name (string, required), description (text) + +## User Stories +As a user, I want to create tasks so I can track my work. +As a user, I want to assign tasks to projects so I can organise them. + +## Integrations +- Stripe for billing +``` + +Run: + +```bash +npx archiet-microcodegen-nestjs prd.md --out ./taskapp/ +cd taskapp && docker compose up +``` + +You get a fully wired NestJS app: `Task` and `Project` TypeORM entities with DTOs, full CRUD controllers, constructor-injected repository services, Passport-JWT reading an httpOnly cookie, per-tenant data isolation, `ARCHITECTURE.md` with ArchiMate 3.2 notation, and `openapi.yaml` -- zero modifications needed to boot. + +## Use + +**CLI** +```bash +# Write files to a directory (default): +npx archiet-microcodegen-nestjs prd.md --out ./myapp/ +cd myapp && docker compose up + +# Write a ZIP instead: +npx archiet-microcodegen-nestjs prd.md --zip myapp.zip +``` + +**Library** +```javascript +const { parsePrd, manifestToGenome, renderGenome, pack } = require('archiet-microcodegen-nestjs'); + +const manifest = parsePrd(fs.readFileSync('prd.md', 'utf8')); +const genome = manifestToGenome(manifest); +const files = renderGenome(genome); +const zipBuffer = pack(files); // Buffer +fs.writeFileSync('myapp.zip', zipBuffer); +``` + +## What you get + +| File | What it does | +|---|---| +| `package.json` | NestJS + TypeORM + Passport-JWT + class-validator deps | +| `src/main.ts` | NestJS entry point with cookie-parser | +| `src/app.module.ts` | Root module with TypeORM PostgresQL config | +| `src/auth/auth.module.ts` | JWT + Passport + LocalStrategy wiring | +| `src/auth/auth.controller.ts` | register / login / logout / me (httpOnly cookie) | +| `src/auth/jwt.strategy.ts` | Reads httpOnly `access_token` cookie -- never Authorization header | +| `src/auth/jwt-auth.guard.ts` | `@UseGuards(JwtAuthGuard)` for protected routes | +| `src/{entity}/{Entity}.entity.ts` | TypeORM entity with `@Column`, `@PrimaryGeneratedColumn`, `userId` field | +| `src/{entity}/{entity}.dto.ts` | `Create{Entity}Dto` + `Update{Entity}Dto` with class-validator decorators | +| `src/{entity}/{entity}.service.ts` | Constructor-injected `@InjectRepository`, all queries filter by userId | +| `src/{entity}/{entity}.controller.ts` | Full CRUD: GET /xs, POST /xs, GET /xs/:id, PUT /xs/:id, DELETE /xs/:id | +| `src/{entity}/{entity}.spec.ts` | Jest happy-path unit tests | +| `docker-compose.yml` | Postgres 16 with healthcheck-gated startup | +| `ARCHITECTURE.md` | ArchiMate 3.2 element map | +| `openapi.yaml` | Machine-readable API contract | + +**Every entity has per-tenant data isolation.** Every service method adds `.where("entity.userId = :userId", { userId })` to every TypeORM QueryBuilder call. No cross-user data leaks. + +## The four stages + +``` +PRD text + | + v parsePrd(text) -- regex extraction: entities, fields, user stories, integrations +manifest -- {solutionName, entities, userStories, integrations} + | + v manifestToGenome(manifest) -- maps to canonical IR with ArchiMate 3.2 element typing +genome (same schema as archiet.com full platform) + | + v renderGenome(genome) -- NestJS TypeScript code generation +{path: content} -- every file the app needs + | + v pack(files) -- custom PKZIP writer (pure Node zlib.deflateRawSync + CRC32) +ZIP Buffer +``` + +The genome is the key: your PRD becomes an **ArchiMate 3.2 architecture document** before any code is generated -- traceable, maintainable, not just scaffolded. + +## Why no LLMs + +LLMs are great at understanding messy natural-language PRDs. They are unnecessary for the generation step -- once you have a clean manifest, code emission is deterministic. Zero hallucinations, zero non-determinism, same input always produces the same NestJS app. + +The generator uses pure Node.js stdlib (`fs`, `path`, `crypto`, `zlib`). Zero npm runtime dependencies. The generated app's `package.json` has NestJS, TypeORM, Passport -- deps of the app you are building, not the tool. + +The full platform at [archiet.com](https://archiet.com?utm_source=npm&utm_medium=package&utm_campaign=microcodegen-nestjs) handles LLM-powered extraction from complex PRDs, 14 target stacks, React/Next.js frontend, Expo mobile, and delivery gates. + +## How it compares to `nest new` + +| | `nest new` | archiet-microcodegen-nestjs | +|---|---|---| +| Starting point | Empty module, no entities | PRD -> complete app | +| Entities | You create manually | Auto-generated from your requirements | +| Auth | You implement (or copy from docs) | Passport-JWT + httpOnly cookie -- included | +| Data isolation | You implement | `userId` filter on every query -- built in | +| Database | You configure | `docker compose up` works immediately | +| Architecture docs | You write | `ARCHITECTURE.md` with ArchiMate 3.2 -- generated | +| API contract | You write | `openapi.yaml` -- generated | +| DTOs + validation | You create | Create/Update DTOs with class-validator -- generated | + +`nest new` gives you a starter. This gives you an app. + +## What's NOT here + +- No LLM extraction (the full platform handles complex, messy PRDs) +- No React/Next.js frontend +- No Expo mobile app +- No Stripe wiring, rate limiting, audit logging +- No multi-stack (NestJS only here -- for Go, Java Spring Boot, Django, FastAPI see [archiet.com](https://archiet.com?utm_source=npm&utm_medium=package&utm_campaign=microcodegen-nestjs)) + +## FAQ + +**Does the generated app actually boot?** +Yes. `docker compose up` is the entire setup. TypeORM `synchronize: true` creates the schema on first boot -- no migration tooling needed. + +**Is the generator itself zero-dependency?** +Yes. The entry script imports only Node.js built-ins (`fs`, `path`, `crypto`, `zlib`). Zero packages in `dependencies`. The generated app's `package.json` has NestJS, TypeORM, Passport -- those are the app's deps, not the generator's. + +**How is auth implemented?** +JWT is stored in an httpOnly cookie, never in a response body or localStorage. `JwtStrategy` uses `ExtractJwt.fromExtractors([req => req?.cookies?.access_token])`. Cookie options: `{ httpOnly: true, sameSite: 'lax', maxAge: 7 * 86_400_000 }`. + +**How is per-tenant isolation enforced?** +Every entity has a `userId: string` column. Every service method adds `.andWhere("entity.userId = :userId", { userId })` to every TypeORM QueryBuilder. There is no query path that can return another user's data. + +**How do I add a real field to an entity?** +Edit your PRD to add the field, re-run the generator. Or edit the generated TypeORM entity, DTO, and service directly -- the code is yours. + +**What happens to my PRD's integrations?** +The generator detects known integration keywords (Stripe, SendGrid, Twilio) and adds commented-out service stubs with setup instructions. The full platform at [archiet.com](https://archiet.com?utm_source=npm&utm_medium=package&utm_campaign=microcodegen-nestjs) wires them fully. + +## Links + +- **SDD guide:** [github.com/Anioko/spec-driven-development](https://github.com/Anioko/spec-driven-development) +- **Compliance guide:** [github.com/Anioko/compliance-from-architecture](https://github.com/Anioko/compliance-from-architecture) — SOC 2, GDPR, **EU AI Act Annex IV** +- **EU AI Act (deadline Aug 2026):** [Free risk classifier](https://archiet.com/tools/eu-ai-act-risk-classifier) · [Annex IV use case](https://archiet.com/use-cases/eu-ai-act-high-risk-ai-compliance) +- Source: [github.com/aniekanasuquookono-web/archiet](https://github.com/aniekanasuquookono-web/archiet/tree/feat/microcodegen-nestjs/archiet_microcodegen_nestjs) +- Full platform (14 stacks, frontend, mobile, deploy): [archiet.com](https://archiet.com?utm_source=npm&utm_medium=package&utm_campaign=microcodegen-nestjs) +- Issues: [github.com/aniekanasuquookono-web/archiet/issues](https://github.com/aniekanasuquookono-web/archiet/issues) + +## License + +MIT. diff --git a/archiet_microcodegen_nestjs/package.json b/archiet_microcodegen_nestjs/package.json new file mode 100644 index 0000000..970e76b --- /dev/null +++ b/archiet_microcodegen_nestjs/package.json @@ -0,0 +1,33 @@ +{ + "name": "archiet-microcodegen-nestjs", + "version": "0.1.0", + "description": "PRD text → working NestJS TypeScript app → ZIP. Pure Node stdlib. Zero deps. <1400 LOC.", + "main": "bin/archiet-microcodegen-nestjs.js", + "bin": { + "archiet-microcodegen-nestjs": "bin/archiet-microcodegen-nestjs.js" + }, + "scripts": { + "test": "node bin/archiet-microcodegen-nestjs.js --help" + }, + "keywords": [ + "nestjs", + "codegen", + "prd", + "scaffolding", + "archiet", + "microcodegen", + "api-generator", + "typescript" + ], + "author": "Aniekan Okono ", + "license": "MIT", + "dependencies": {}, + "engines": { + "node": ">=18" + }, + "repository": { + "type": "git", + "url": "https://github.com/aniekanasuquookono-web/archiet" + }, + "homepage": "https://archiet.com?utm_source=npm&utm_medium=package&utm_campaign=microcodegen-nestjs" +} diff --git a/archiet_microcodegen_rails/README.md b/archiet_microcodegen_rails/README.md new file mode 100644 index 0000000..c8aea6f --- /dev/null +++ b/archiet_microcodegen_rails/README.md @@ -0,0 +1,233 @@ +# archiet-microcodegen-rails + +> PRD text → working Rails 7 API app → ZIP, in <1400 LOC, pure Ruby stdlib, zero LLM calls. +> Inspired by Karpathy's micrograd: this file is the complete algorithm. + +[![Gem Version](https://img.shields.io/gem/v/archiet-microcodegen-rails)](https://rubygems.org/gems/archiet-microcodegen-rails) +[![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.0-CC342D)](https://ruby-lang.org) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +--- + +## The fastest path from requirements to a running Rails REST API + +You have a PRD (a Markdown file, a Confluence export, a Notion page). +You want a **Rails 7 API-only app** with real auth, a real database, and real routing — ready to `docker compose up`. +Most generators give you boilerplate. This gives you *your* app in 3 seconds. + +```bash +gem install archiet-microcodegen-rails +archiet-microcodegen-rails prd.md --out ./my-app +cd my-app && cp .env.example .env && docker compose up +``` + +First request hits `/api/v1/auth/register` before the coffee is done. + +--- + +## Install + +```bash +# Global install (recommended) +gem install archiet-microcodegen-rails + +# Or run the single Ruby file directly +curl -LO https://raw.githubusercontent.com/aniekanasuquookono-web/archiet/main/archiet_microcodegen_rails/lib/archiet_microcodegen_rails.rb +ruby archiet_microcodegen_rails.rb prd.md --out ./my-app +``` + +--- + +## Use + +### CLI + +```bash +# Write files to a directory +archiet-microcodegen-rails prd.md --out ./my-api + +# Write a ZIP instead +archiet-microcodegen-rails prd.md --zip my-api.zip + +# Then boot +cd my-api +cp .env.example .env # edit DATABASE_URL, JWT_SECRET +docker compose up # Postgres + Puma +``` + +### Library (Ruby) + +```ruby +require 'archiet_microcodegen_rails' + +text = File.read('prd.md') +manifest = parse_prd(text) +genome = manifest_to_genome(manifest) +files = render_genome(genome) + +# Write to disk +write_disk(files, './output') + +# Or get a ZIP blob +zip_bytes = pack_zip(files) +File.binwrite('output.zip', zip_bytes) +``` + +--- + +## Sample input: a real PRD excerpt + +```markdown +# Task Manager + +## Entities + +**Project** + - name: string (required) + - description: text + - status: string (required) + +**Task** + - title: string (required) + - body: text + - due_date: date + - priority: string +``` + +**Output:** a complete Rails 7 API-only app with `Project` and `Task` models, per-tenant +scoping, JWT auth, ActiveRecord, migrations, Dockerfile, and `openapi.yaml` — ready to +`docker compose up`. + +--- + +## What you get + +| File | What it does | +|---|---| +| `Gemfile` | Rails 7.2, pg, puma, jwt, bcrypt, rack-cors | +| `config/routes.rb` | `namespace :api do namespace :v1` with `resources` for every entity | +| `config/database.yml` | PostgreSQL, URL-based config from `DATABASE_URL` env var | +| `config/application.rb` | API-only mode, CORS middleware | +| `app/models/user.rb` | `has_secure_password`, email uniqueness validation | +| `app/models/{Entity}.rb` | `belongs_to :user`, `scope :for_user` | +| `app/services/jwt_service.rb` | `JwtService.encode` / `.decode` using `jwt` gem | +| `app/controllers/application_controller.rb` | `before_action :authenticate!`, validates httpOnly cookie | +| `app/controllers/api/v1/auth_controller.rb` | `register`, `login`, `logout`, `me` | +| `app/controllers/api/v1/{Entity}Controller.rb` | Full CRUD, every query scoped to `current_user_id` | +| `db/migrate/*.rb` | One migration per entity + users table | +| `.env.example` | All required env vars pre-documented | +| `Dockerfile` | Multi-stage Ruby 3.3 Alpine build | +| `docker-compose.yml` | App + Postgres 16, healthcheck-gated | +| `ARCHITECTURE.md` | ArchiMate 3.2 ApplicationComponent + DataObject inventory | +| `openapi.yaml` | Machine-readable API contract | + +--- + +## The four stages + +``` +parse_prd(text) → manifest (entities, stories, integrations) +manifest_to_genome(manifest) → genome (ArchiMate 3.2 typed IR) +render_genome(genome) → files (Rails 7 Ruby source) +pack_zip(files) / write_disk(files, dir) +``` + +**Stage 1** — regex-based PRD parser. Finds entities, fields, user stories, and +third-party integrations (Stripe, SendGrid, Twilio, …) without an LLM. + +**Stage 2** — converts the manifest into a structured genome. Every entity gains +`id`, `user_id`, `created_at`, `updated_at` automatically. The genome is a plain Ruby +Hash — no classes, no magic, no external gems. + +**Stage 3** — renders all Rails files from the genome. Auth uses `bcrypt` +(`has_secure_password`) and a `JwtService` backed by the `jwt` gem — standard Rails +practice, no custom crypto in generated code. + +**Stage 4** — writes files to a directory or produces a valid PKZIP file using +`Zlib::Deflate.new(level, -15)` (raw deflate, zero header overhead) and Ruby's +`Array#pack`. No shell calls, no external gems. + +--- + +## Security by default + +- **httpOnly cookie, not localStorage.** The `access_token` cookie is `httponly: true`, + `same_site: :lax`. The JWT payload never touches JavaScript. +- **Per-tenant isolation.** Every model has `scope :for_user, ->(uid) { where(user_id: uid) }`. + Every controller calls `.for_user(current_user_id)` before any query. There is no code + path that returns another user's data. +- **Zero hardcoded secrets.** `JWT_SECRET` and `DATABASE_URL` are environment variables. + `SECRET_KEY_BASE` is required at boot — the generated `.env.example` documents all of them. + +--- + +## archiet-microcodegen-rails vs the alternatives + +| | `archiet-microcodegen-rails` | `rails new --api` | `rails g scaffold` | +|---|---|---|---| +| Input | Your PRD | Nothing | Single model name | +| Output | Full CRUD API for all entities | Empty skeleton | One resource at a time | +| Auth | JWT httpOnly cookie + bcrypt | None | None | +| Per-tenant isolation | Built-in (`for_user` scope) | None | None | +| `docker-compose.yml` | ✅ | ❌ | ❌ | +| `openapi.yaml` | ✅ | ❌ | ❌ | +| `ARCHITECTURE.md` | ✅ ArchiMate 3.2 | ❌ | ❌ | +| LLM / API key | ❌ Never | ❌ | ❌ | + +--- + +## FAQ + +**Does the generated app really boot with `docker compose up`?** +Yes. The generated `Dockerfile` uses a multi-stage Ruby 3.3 Alpine build. +`docker-compose.yml` waits for Postgres `pg_isready` before starting Puma. + +**Is the generator itself pure Ruby stdlib?** +The generator uses only `zlib`, `fileutils`, `json`, `optparse`, and `stringio` — all +part of the Ruby standard library. No Bundler dependency for the generator itself. +The generated app has its own `Gemfile`. + +**What Rails version does it generate?** +Rails 7.2, API-only mode, with `Rack::Cors` for CORS handling. + +**Does it use Devise or Doorkeeper for auth?** +No. The generated app uses Rails `has_secure_password` (bcrypt) and a small `JwtService` +backed by the `jwt` gem. One less configuration surface. If you prefer Devise, the +generator is a single Ruby file — fork and adapt Stage 3. + +**What's NOT generated?** +Action Mailer, Active Job, Action Cable, Turbo, Hotwire, front-end scaffolding, +multi-tenancy with separate schemas, and Rails credentials. For a full-stack app generated +from your architecture diagram, see +[archiet.com](https://archiet.com?utm_source=rubygems&utm_medium=package&utm_campaign=microcodegen-rails). + +--- + +## Why this exists + +Architecture before code. A vibe-coded Rails app has models and routes. +An *architected* Rails app has a formal representation of why those models and routes +exist — what requirement they satisfy, what component they belong to, what boundaries +they must not cross. + +`archiet-microcodegen-rails` encodes that representation as an ArchiMate 3.2 genome +and renders it deterministically. Same PRD → same app. No hallucinations. + +The genome is not a prompt. It is a typed intermediate representation: every entity has +an archimate type, every field has a domain type, every auth rule is a structural +constraint — not a comment in a template. + +For teams that want a full architecture-to-code platform (multi-stack, governance, PRD +intake, quality scoring, delivery gates), visit +[archiet.com](https://archiet.com?utm_source=rubygems&utm_medium=package&utm_campaign=microcodegen-rails). + +--- + +## Links + +- **SDD guide:** [github.com/Anioko/spec-driven-development](https://github.com/Anioko/spec-driven-development) +- **Compliance guide:** [github.com/Anioko/compliance-from-architecture](https://github.com/Anioko/compliance-from-architecture) — SOC 2, GDPR, **EU AI Act Annex IV** +- **EU AI Act (deadline Aug 2026):** [Free risk classifier](https://archiet.com/tools/eu-ai-act-risk-classifier) · [Annex IV use case](https://archiet.com/use-cases/eu-ai-act-high-risk-ai-compliance) +- Full platform: [archiet.com](https://archiet.com?utm_source=rubygems&utm_medium=package&utm_campaign=microcodegen-rails) + +*Generated with [archiet-microcodegen-rails](https://rubygems.org/gems/archiet-microcodegen-rails)* diff --git a/archiet_microcodegen_rails/archiet-microcodegen-rails.gemspec b/archiet_microcodegen_rails/archiet-microcodegen-rails.gemspec new file mode 100644 index 0000000..f3731b2 --- /dev/null +++ b/archiet_microcodegen_rails/archiet-microcodegen-rails.gemspec @@ -0,0 +1,13 @@ +Gem::Specification.new do |s| + s.name = 'archiet-microcodegen-rails' + s.version = '0.1.0' + s.summary = 'PRD text -> Rails 7 API app -> ZIP. Pure Ruby stdlib. <1400 LOC.' + s.description = 'Generate a production-ready Rails 7 REST API from a requirements document. No LLM. No API key.' + s.authors = ['Archiet'] + s.email = ['hello@archiet.com'] + s.homepage = 'https://archiet.com?utm_source=rubygems&utm_medium=package&utm_campaign=microcodegen-rails' + s.license = 'MIT' + s.files = Dir['lib/**/*.rb', 'bin/*'] + s.executables = ['archiet-microcodegen-rails'] + s.required_ruby_version = '>= 3.0' +end diff --git a/archiet_microcodegen_rails/lib/archiet_microcodegen_rails.rb b/archiet_microcodegen_rails/lib/archiet_microcodegen_rails.rb new file mode 100644 index 0000000..843b7de --- /dev/null +++ b/archiet_microcodegen_rails/lib/archiet_microcodegen_rails.rb @@ -0,0 +1,515 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# archiet-microcodegen-rails v0.1.0 +# PRD text -> Rails 7 API app -> ZIP. Pure Ruby stdlib. <1400 LOC. +# +# Stage 1: parse_prd(text) -> manifest (language-agnostic) +# Stage 2: manifest_to_genome(manifest) -> genome (ArchiMate 3.2 typed) +# Stage 3: render_genome(genome) -> {path => content} (Rails-specific) +# Stage 4: pack_zip(files) -> bytes (PKZIP, pure Zlib::Deflate) +# +# Zero runtime dependencies. Inspired by Karpathy's micrograd. + +require 'zlib' +require 'stringio' +require 'fileutils' +require 'json' +require 'optparse' + +# ─── ZIP WRITER ────────────────────────────────────────────────────────────── +CRC_TABLE = Array.new(256) { |i| + c = i + 8.times { c = (c & 1) == 1 ? (0xEDB88320 ^ (c >> 1)) : (c >> 1) } + c +}.freeze + +def crc32(data) + crc = 0xFFFFFFFF + data.each_byte { |b| crc = (CRC_TABLE[(crc ^ b) & 0xFF] ^ (crc >> 8)) & 0xFFFFFFFF } + (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF +end + +def deflate_raw(data) + z = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -15) + out = z.deflate(data.b, Zlib::FINISH) + z.close + out +end + +def pack_zip(files) + out = ''.b + cd = ''.b + off = 0 + files.sort_by { |p, _| p }.each do |path, content| + raw = content.encode('UTF-8', invalid: :replace, undef: :replace).b + comp = deflate_raw(raw) + crc = crc32(raw) + name = path.b + lh = [0x04034b50, 20, 0, 8, 0, 0, crc, comp.bytesize, raw.bytesize, + name.bytesize, 0].pack('VvvvvvVVVvv') + name + out += lh + comp + cd += [0x02014b50, 20, 20, 0, 8, 0, 0, crc, comp.bytesize, raw.bytesize, + name.bytesize, 0, 0, 0, 0, 0, off].pack('VvvvvvvVVVvvvvvVV') + name + off += lh.bytesize + comp.bytesize + end + n = files.size; cdl = cd.bytesize + out + cd + [0x06054b50, 0, 0, n, n, cdl, off, 0].pack('VvvvvVVv') +end + +def write_disk(files, base) + files.each do |path, content| + full = File.join(base, path) + FileUtils.mkdir_p(File.dirname(full)) + File.write(full, content) + end + puts "Wrote #{files.size} files to #{base}" +end + +# ─── HELPERS ───────────────────────────────────────────────────────────────── +def pascal(s) + s.gsub(/[_-]([a-zA-Z0-9])/) { $1.upcase }.sub(/^./) { $&.upcase } +end +def snake(s) + s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase +end +def plural(s) + s.end_with?('y') && !'aeiou'.include?(s[-2]) ? s[0..-2] + 'ies' : + s.match?(/[sxz]$/) ? s + 'es' : s + 's' +end +def fill(tmpl, vars) + vars.each { |k, v| tmpl = tmpl.gsub("{{#{k}}}", v.to_s) } + tmpl +end + +# ─── STAGE 1: parse_prd ────────────────────────────────────────────────────── +def parse_prd(text) + name = text.match(/^#\s+(.+)/)&.captures&.first&.strip || 'MyApp' + entities = [] + if (m = text.match(/^[#]{1,3}\s*(?:entities|data models|domain models)[^\n]*/i)) + sec = text[m.begin(0)..] + sec = sec[0, sec.index(/\n[#]{1,3}\s+(?!entities|data|domain)/i) || sec.length] + sec.scan(/^[\s\-\*]*([A-Z][a-zA-Z0-9]{1,40})\*{0,2}[ \t]*(?::|—|-| )/) do |(en)| + next if %w[User Auth Admin Api].include?(en) + fields = [] + epos = sec.index(en) || 0 + sec[epos, 600].scan(/^\s+[-*]\s*([a-z_][a-z0-9_]{0,40})\s*[:—]\s*([a-zA-Z]+)([^\n]*)/) do |(fn, ft, rest)| + fields << { name: fn, type: ft.downcase, + required: rest.downcase.include?('required') || rest.include?('*') } + end + entities << { name: en, fields: fields } + end + end + stories = text.scan(/As a[n]?\s+\w+,\s*I want[^.\n]+/i).map(&:strip) + integrations = %w[stripe sendgrid twilio slack github google aws s3 cloudinary firebase].select { |k| text.downcase.include?(k) } + { name: name, entities: entities, stories: stories, integrations: integrations } +end + +# ─── STAGE 2: manifest_to_genome ───────────────────────────────────────────── +def manifest_to_genome(m) + slug = m[:name].downcase.gsub(/[^a-z0-9]+/, '-').sub(/-+$/, '') + modules = m[:entities].map do |e| + base_fields = [ + { name: 'id', type: 'bigint', required: true }, + { name: 'user_id', type: 'bigint', required: true }, + { name: 'created_at', type: 'timestamp', required: false }, + { name: 'updated_at', type: 'timestamp', required: false }, + ] + { name: e[:name], archimate: 'DataObject', fields: base_fields + e[:fields] } + end + { solution_name: m[:name], slug: slug, version: '0.1.0', language: 'rails', + auth: { strategy: 'jwt', storage: 'httponly_cookie' }, + modules: modules, integrations: m[:integrations], user_stories: m[:stories] } +end + +# ─── STAGE 3: render_genome ────────────────────────────────────────────────── +def render_genome(g) + files = {} + name = g[:solution_name] + slug = g[:slug] + mods = g[:modules] + + # Gemfile + files['Gemfile'] = <<~GEMFILE + source 'https://rubygems.org' + ruby '~> 3.3' + gem 'rails', '~> 7.2' + gem 'pg', '~> 1.5' + gem 'puma', '~> 6.4' + gem 'jwt', '~> 2.8' + gem 'bcrypt', '~> 3.1' + gem 'rack-cors' + group :development, :test do + gem 'rspec-rails', '~> 6.1' + end + GEMFILE + + # config/routes.rb + route_resources = mods.map { |m| " resources :#{plural(snake(m[:name]))}, only: %i[index create show update destroy]" }.join("\n") + files['config/routes.rb'] = <<~ROUTES + Rails.application.routes.draw do + namespace :api do + namespace :v1 do + post 'auth/register', to: 'auth#register' + post 'auth/login', to: 'auth#login' + delete 'auth/logout', to: 'auth#logout' + get 'auth/me', to: 'auth#me' + #{route_resources} + end + end + end + ROUTES + + # config/database.yml + files['config/database.yml'] = <<~DB + default: &default + adapter: postgresql + encoding: unicode + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + url: <%= ENV["DATABASE_URL"] %> + development: + <<: *default + production: + <<: *default + DB + + # config/application.rb + files['config/application.rb'] = <<~APP + require_relative 'boot' + require 'rails/all' + Bundler.require(*Rails.groups) + module #{pascal(slug.gsub('-','_'))} + class Application < Rails::Application + config.load_defaults 7.2 + config.api_only = true + config.middleware.insert_before 0, Rack::Cors do + allow { origins '*'; resource '*', headers: :any, methods: :any, credentials: true } + end + end + end + APP + + files['config/boot.rb'] = "ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)\nrequire 'bundler/setup'\n" + files['config/environment.rb'] = "require_relative 'application'\nRails.application.initialize!\n" + files['config.ru'] = "require_relative 'config/environment'\nrun Rails.application\nRails.application.load_server\n" + files['Rakefile'] = "require_relative 'config/application'\nRails.application.load_tasks\n" + + # app/models/application_record.rb + files['app/models/application_record.rb'] = "class ApplicationRecord < ActiveRecord::Base\n primary_abstract_class\nend\n" + + # app/models/user.rb + files['app/models/user.rb'] = <<~USER + class User < ApplicationRecord + has_secure_password + validates :email, presence: true, uniqueness: true + validates :name, presence: true + end + USER + + # app/controllers/application_controller.rb + files['app/controllers/application_controller.rb'] = <<~CTRL + class ApplicationController < ActionController::API + before_action :authenticate! + + private + + def authenticate! + token = cookies[:access_token] + return render json: { error: 'unauthenticated', message: 'No auth cookie.' }, status: :unauthorized unless token + payload = JwtService.decode(token) + return render json: { error: 'unauthenticated', message: 'Invalid or expired token.' }, status: :unauthorized unless payload + @current_user_id = payload['sub'] + end + + def current_user_id = @current_user_id + end + CTRL + + # app/services/jwt_service.rb + files['app/services/jwt_service.rb'] = <<~JWT + require 'jwt' + module JwtService + TTL = ENV.fetch('JWT_TTL_SEC', 604800).to_i + SECRET = ENV.fetch('JWT_SECRET', 'change-me') + + def self.encode(payload) + JWT.encode(payload.merge(exp: Time.now.to_i + TTL), SECRET, 'HS256') + end + + def self.decode(token) + JWT.decode(token, SECRET, true, algorithm: 'HS256').first + rescue JWT::DecodeError + nil + end + end + JWT + + # app/controllers/api/v1/auth_controller.rb + files['app/controllers/api/v1/auth_controller.rb'] = <<~AUTH + module Api + module V1 + class AuthController < ApplicationController + skip_before_action :authenticate!, only: %i[register login] + + def register + user = User.new(name: params[:name], email: params[:email], password: params[:password]) + if user.save + set_cookie(user) + render json: { user: user_json(user) }, status: :created + else + render json: { error: 'validation_error', message: user.errors.full_messages }, status: :unprocessable_entity + end + end + + def login + user = User.find_by(email: params[:email]) + if user&.authenticate(params[:password]) + set_cookie(user) + render json: { user: user_json(user) } + else + render json: { error: 'invalid_credentials', message: 'Wrong email or password.' }, status: :unauthorized + end + end + + def logout + cookies.delete(:access_token) + render json: { message: 'Logged out.' } + end + + def me + user = User.find(@current_user_id) + render json: { user: user_json(user) } + end + + private + + def set_cookie(user) + token = JwtService.encode({ 'sub' => user.id, 'email' => user.email }) + cookies[:access_token] = { + value: token, httponly: true, same_site: :lax, + expires: JwtService::TTL.seconds.from_now + } + end + + def user_json(u) = u.slice(:id, :name, :email, :created_at) + end + end + end + AUTH + + # users migration + ts = '20240101000000' + files["db/migrate/#{ts}_create_users.rb"] = <<~MIG + class CreateUsers < ActiveRecord::Migration[7.2] + def change + create_table :users do |t| + t.string :name, null: false + t.string :email, null: false, index: { unique: true } + t.string :password_digest, null: false + t.timestamps + end + end + end + MIG + + # per-entity + mods.each_with_index do |mod, idx| + en = mod[:name] + sn = snake(en) + pl_sn = plural(sn) + pa = pascal(en) + tstamp = "20240101%06d" % (idx + 1) + + user_fields = mod[:fields].reject { |f| %w[id created_at updated_at].include?(f[:name]) } + col_defs = user_fields.map { |f| + next " t.references :user, null: false, foreign_key: true, index: true" if f[:name] == 'user_id' + pg_type = case f[:type] + when 'text','description' then 'text' + when 'int','integer','bigint' then 'integer' + when 'bool','boolean' then 'boolean' + when 'date' then 'date' + when 'decimal','float' then 'decimal' + else 'string' + end + nullable = f[:required] ? ', null: false' : '' + " t.#{pg_type} :#{f[:name]}#{nullable}" + }.join("\n") + + # model + files["app/models/#{sn}.rb"] = <<~MODEL + class #{pa} < ApplicationRecord + belongs_to :user + validates :user_id, presence: true + scope :for_user, ->(uid) { where(user_id: uid) } + end + MODEL + + # controller + permit_fields = user_fields.map { |f| f[:name] }.reject { |n| n == 'user_id' }.map { |n| ":#{n}" }.join(', ') + files["app/controllers/api/v1/#{pl_sn}_controller.rb"] = <<~CTRL + module Api + module V1 + class #{pascal(pl_sn)}Controller < ApplicationController + before_action :set_item, only: %i[show update destroy] + + def index + render json: #{pa}.for_user(current_user_id).all + end + + def create + item = #{pa}.new(item_params.merge(user_id: current_user_id)) + if item.save + render json: item, status: :created + else + render json: { error: 'validation_error', message: item.errors.full_messages }, status: :unprocessable_entity + end + end + + def show = render json: @item + def update = @item.update!(item_params) ? render(json: @item) : render(json: { error: 'validation_error' }, status: :unprocessable_entity) + def destroy = @item.destroy && head(:no_content) + + private + + def set_item + @item = #{pa}.for_user(current_user_id).find(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { error: 'not_found', message: '#{pa} not found.' }, status: :not_found + end + + def item_params = params.permit(#{permit_fields}) + end + end + end + CTRL + + # migration + files["db/migrate/#{tstamp}_create_#{pl_sn}.rb"] = <<~MIG + class Create#{pascal(pl_sn)} < ActiveRecord::Migration[7.2] + def change + create_table :#{pl_sn} do |t| + #{col_defs} + t.timestamps + end + end + end + MIG + end + + # .env.example + files['.env.example'] = <<~ENV + DATABASE_URL=postgresql://app:changeme@db:5432/app + JWT_SECRET=change-me-jwt-secret-minimum-32-characters + JWT_TTL_SEC=604800 + RAILS_ENV=production + SECRET_KEY_BASE=change-me-rails-secret-key-base + ENV + + # Dockerfile + files['Dockerfile'] = <<~DOCKER + FROM ruby:3.3-alpine AS builder + RUN apk add --no-cache build-base postgresql-dev tzdata + WORKDIR /app + COPY Gemfile Gemfile.lock* ./ + RUN bundle install --without development test + COPY . . + + FROM ruby:3.3-alpine + RUN apk add --no-cache postgresql-client tzdata + WORKDIR /app + COPY --from=builder /usr/local/bundle /usr/local/bundle + COPY --from=builder /app /app + EXPOSE 3000 + CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"] + DOCKER + + # docker-compose.yml + files['docker-compose.yml'] = <<~DC + services: + app: + build: . + ports: ["3000:3000"] + env_file: .env + depends_on: + db: + condition: service_healthy + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: app + POSTGRES_USER: app + POSTGRES_PASSWORD: changeme + ports: ["5432:5432"] + volumes: [db_data:/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + db_data: + DC + + # ARCHITECTURE.md + arc = "# ARCHITECTURE — #{name}\n\nGenerated by archiet-microcodegen-rails. ArchiMate 3.2 notation.\n\n" + arc += "## ApplicationComponent\n\n| Component | Technology | Notes |\n|---|---|---|\n" + arc += "| ApiGateway | Rails 7 Router | Routes API requests |\n" + arc += "| AuthService | JWT (httpOnly cookie) + bcrypt | register / login / logout |\n" + mods.each { |m| arc += "| #{pascal(m[:name])}Service | ActiveRecord | CRUD for #{m[:name]} |\n" } + arc += "\n## DataObject\n\n| Entity | Table | Key Fields |\n|---|---|---|\n" + arc += "| User | users | id, name, email, password_digest |\n" + mods.each { |m| arc += "| #{m[:name]} | #{plural(snake(m[:name]))} | #{m[:fields].first(5).map { |f| f[:name] }.join(', ')} |\n" } + arc += "\n## Auth Contract\n- JWT in **httpOnly cookie** `access_token` — never localStorage\n" + arc += "- Per-tenant: every ActiveRecord scope chains `.for_user(current_user_id)`\n" + files['ARCHITECTURE.md'] = arc + + # openapi.yaml + oa = "openapi: \"3.1.0\"\ninfo:\n title: \"#{name} API\"\n version: \"0.1.0\"\npaths:\n" + oa += " /api/v1/auth/register:\n post: {operationId: register, tags: [auth], responses: {201: {description: Created}}}\n" + oa += " /api/v1/auth/login:\n post: {operationId: login, tags: [auth], responses: {200: {description: OK}}}\n" + oa += " /api/v1/auth/me:\n get: {operationId: me, tags: [auth], security: [{cookieAuth: []}], responses: {200: {description: OK}}}\n" + mods.each do |mod| + sp = plural(snake(mod[:name])); pa = pascal(mod[:name]) + oa += " /api/v1/#{sp}:\n" + oa += " get: {operationId: list#{pa}, tags: [#{pa}], security: [{cookieAuth: []}], responses: {200: {description: OK}}}\n" + oa += " post: {operationId: create#{pa}, tags: [#{pa}], security: [{cookieAuth: []}], responses: {201: {description: Created}}}\n" + oa += " /api/v1/#{sp}/{id}:\n" + oa += " get: {operationId: get#{pa}, tags: [#{pa}], security: [{cookieAuth: []}], responses: {200: {description: OK}}}\n" + oa += " put: {operationId: update#{pa}, tags: [#{pa}], security: [{cookieAuth: []}], responses: {200: {description: OK}}}\n" + oa += " delete: {operationId: delete#{pa}, tags: [#{pa}], security: [{cookieAuth: []}], responses: {204: {description: No Content}}}\n" + end + oa += "components:\n securitySchemes:\n cookieAuth: {type: apiKey, in: cookie, name: access_token}\n" + files['openapi.yaml'] = oa + + files +end + +# ─── CLI ───────────────────────────────────────────────────────────────────── +def main + options = { out: './output', zip: nil } + OptionParser.new do |o| + o.banner = "Usage: archiet-microcodegen-rails [options]" + o.on('--out DIR', 'Write files to DIR (default: ./output)') { |v| options[:out] = v } + o.on('--zip FILE', 'Write ZIP to FILE') { |v| options[:zip] = v } + end.parse! + + prd_path = ARGV.shift + if prd_path.nil? || !File.exist?(prd_path) + warn "Error: PRD file not found: #{prd_path || '(none)'}"; exit 1 + end + + manifest = parse_prd(File.read(prd_path)) + genome = manifest_to_genome(manifest) + files = render_genome(genome) + + if options[:zip] + File.binwrite(options[:zip], pack_zip(files)) + puts "ZIP: #{options[:zip]} (#{files.size} files)" + else + write_disk(files, options[:out]) + puts "Done. cd #{options[:out]} && cp .env.example .env && docker compose up" + end +end + +main if $PROGRAM_NAME == __FILE__ diff --git a/archiet_microcodegen_tauri/Cargo.lock b/archiet_microcodegen_tauri/Cargo.lock new file mode 100644 index 0000000..88cfe5d --- /dev/null +++ b/archiet_microcodegen_tauri/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "archiet-microcodegen-tauri" +version = "0.1.0" diff --git a/archiet_microcodegen_tauri/Cargo.toml b/archiet_microcodegen_tauri/Cargo.toml new file mode 100644 index 0000000..ceb72bf --- /dev/null +++ b/archiet_microcodegen_tauri/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "archiet-microcodegen-tauri" +version = "0.1.0" +edition = "2021" +description = "PRD text → Tauri v2 desktop app → ZIP. Pure Rust stdlib. Zero dependencies." +license = "MIT" +repository = "https://github.com/aniekanasuquookono-web/archiet" +homepage = "https://archiet.com?utm_source=crates.io&utm_medium=package&utm_campaign=microcodegen-tauri" +documentation = "https://docs.rs/archiet-microcodegen-tauri" +keywords = ["tauri", "codegen", "desktop", "sqlite", "scaffold"] +categories = ["command-line-utilities", "development-tools"] +readme = "README.md" + +[[bin]] +name = "archiet-microcodegen-tauri" +path = "src/main.rs" + +[dependencies] +# Zero — pure Rust stdlib only + +[profile.release] +opt-level = "s" +strip = true diff --git a/archiet_microcodegen_tauri/README.md b/archiet_microcodegen_tauri/README.md new file mode 100644 index 0000000..0d33fee --- /dev/null +++ b/archiet_microcodegen_tauri/README.md @@ -0,0 +1,182 @@ +# archiet-microcodegen-tauri + +> PRD text → working Tauri v2 desktop app → ZIP, in <1400 LOC, pure Rust stdlib, zero LLM calls. +> Inspired by Karpathy's micrograd: this file is the complete algorithm. + +[![Crates.io](https://img.shields.io/crates/v/archiet-microcodegen-tauri)](https://crates.io/crates/archiet-microcodegen-tauri) +[![Rust](https://img.shields.io/badge/rust-%3E%3D1.75-orange)](https://www.rust-lang.org) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +## The fastest path from requirements to a running Tauri desktop app + +You have a PRD — a Markdown file, a Confluence export, a Notion page. +You want a **Tauri v2 desktop application** with real auth, a real local database, +and full CRUD for every entity in your spec — ready to `npm run tauri dev`. +Most tools give you a prompt and a prayer. This gives you a ZIP in 3 seconds. + +```bash +cargo install archiet-microcodegen-tauri +archiet-microcodegen-tauri prd.md --out ./my-app +cd my-app && npm install && npm run tauri dev +``` + +Your app boots on your desktop before the coffee is done. + +## Install + +```bash +# Global install via Cargo +cargo install archiet-microcodegen-tauri + +# Or build from source +git clone https://github.com/aniekanasuquookono-web/archiet +cd archiet/archiet_microcodegen_tauri +cargo build --release +``` + +## Use + +### CLI + +```bash +# Write files to a directory +archiet-microcodegen-tauri prd.md --out ./my-app + +# Write a ZIP instead +archiet-microcodegen-tauri prd.md --zip my-app.zip + +# Print sample PRD to stdout +archiet-microcodegen-tauri --sample + +# Then develop +cd my-app +npm install +npm run tauri dev +``` + +### Library + +Add to your `Cargo.toml`: + +```toml +[dependencies] +archiet-microcodegen-tauri = "0.1" +``` + +Call the four stages directly: + +```rust +use archiet_microcodegen_tauri::{parse_prd, manifest_to_genome, render_genome, pack}; + +let text = std::fs::read_to_string("prd.md")?; +let manifest = parse_prd(&text); +let genome = manifest_to_genome(manifest); +let files = render_genome(&genome); + +// Write to disk +write_disk(&files, std::path::Path::new("./output")); + +// Or get a ZIP blob +let zip_bytes = pack(&files); +std::fs::write("output.zip", &zip_bytes)?; +``` + +## Sample input: a real PRD excerpt + +```markdown +# Task Manager + +## Entities + +**Project** + - name: string (required) + - description: text + - status: string (required) + +**Task** + - title: string (required) + - body: text + - due_date: string + - priority: string + - status: string +``` + +**Output:** a complete Tauri v2 app with `Project` and `Task` entities, per-user SQLite storage, +Argon2id auth, React/TypeScript frontend, typed IPC client, and `ARCHITECTURE.md` — ready to +`npm run tauri dev`. + +## What you get + +| File | What it does | +|---|---| +| `src-tauri/Cargo.toml` | Rust dependencies: tauri 2, rusqlite (bundled), argon2, uuid | +| `src-tauri/src/main.rs` | Tauri entry point | +| `src-tauri/src/lib.rs` | AppState (SQLite + session map), builder setup, IPC handler registration | +| `src-tauri/src/db.rs` | SQLite open + WAL migrations — one table per entity | +| `src-tauri/src/auth.rs` | `register_user`, `login_user`, `logout_user`, `get_me` IPC commands | +| `src-tauri/src/commands/{entity}_commands.rs` | `create_X`, `list_Xs`, `get_X`, `update_X`, `delete_X` — all per-user | +| `src-tauri/src/models/{entity}.rs` | Serde structs per entity | +| `src-tauri/tauri.conf.json` | Tauri v2 configuration | +| `src-tauri/capabilities/default.json` | Tauri v2 capability declarations | +| `src/ipc.ts` | Typed TypeScript client for every IPC command | +| `src/pages/Login.tsx` | Register + login form | +| `src/pages/{Entity}List.tsx` | CRUD list page per entity | +| `src/App.tsx` | React router with auth gate | +| `package.json` | Vite + React + @tauri-apps/api | +| `vite.config.ts` | Vite config tuned for Tauri | +| `tsconfig.json` | TypeScript strict mode | +| `ARCHITECTURE.md` | ArchiMate 3.2 ApplicationComponent + DataObject inventory | +| `openapi.yaml` | IPC command contract (not HTTP — Tauri invoke surface) | + +## The four stages + +``` +parse_prd(text) → Manifest (entities, stories, integrations) +manifest_to_genome(manifest) → Genome (ArchiMate 3.2 typed IR) +render_genome(genome) → files (Tauri v2 Rust + React/TypeScript source) +pack(files) / write_disk(files, dir) +``` + +**Stage 1** — regex-based PRD parser. Finds entities, fields (with types and required flags), +user stories, and third-party integrations without an LLM. + +**Stage 2** — converts the manifest into a structured genome. Every entity automatically gains +`id`, `user_id`, and `created_at` fields. The genome drives all of Stage 3. + +**Stage 3** — renders all Tauri Rust source files and React/TypeScript frontend. Key design decisions: +- SQLite (rusqlite, bundled) — no system SQLite required, ships in the binary +- Argon2id password hashing — bcrypt would also work but Argon2id is the current OWASP recommendation +- UUID session tokens stored in `AppState.sessions` (in-memory `HashMap`) — never on disk, never in localStorage +- Per-user isolation: every entity has a `user_id` FK; every query filters by it +- Tauri IPC commands (not HTTP) — typed with `#[tauri::command]`, invoked from TypeScript via `@tauri-apps/api` + +**Stage 4** — writes files to disk or builds a valid ZIP (Store method, pure Rust stdlib). + +## Why this exists + +Architecture-first development before vibecoding. The genome is an ArchiMate 3.2 +intermediate representation — your PRD becomes an architecture document, not just a prompt. + +Archiet's full platform turns any PRD into a production-ready application across +nine stacks simultaneously, with quality scoring, delivery gates, and live preview. + +**→ [archiet.com](https://archiet.com?utm_source=crates.io&utm_medium=package&utm_campaign=microcodegen-tauri)** + +## Key differences from web microcodegen packages + +| Concern | Web (Flask/NestJS/Go) | Tauri desktop | +|---|---|---| +| Auth storage | httpOnly cookies | In-memory session tokens (JS memory only) | +| Database | PostgreSQL | SQLite embedded (bundled, ships in binary) | +| API surface | HTTP REST | Tauri IPC commands (`invoke()`) | +| Deployment | Docker + cloud | `cargo build --release` → native binary | + +## Links + +- **SDD guide:** [github.com/Anioko/spec-driven-development](https://github.com/Anioko/spec-driven-development) +- **Compliance guide:** [github.com/Anioko/compliance-from-architecture](https://github.com/Anioko/compliance-from-architecture) — SOC 2, GDPR, **EU AI Act Annex IV** +- **EU AI Act (deadline Aug 2026):** [Free risk classifier](https://archiet.com/tools/eu-ai-act-risk-classifier) · [Annex IV use case](https://archiet.com/use-cases/eu-ai-act-high-risk-ai-compliance) + +## License + +MIT diff --git a/archiet_microcodegen_tauri/src/main.rs b/archiet_microcodegen_tauri/src/main.rs new file mode 100644 index 0000000..e85c369 --- /dev/null +++ b/archiet_microcodegen_tauri/src/main.rs @@ -0,0 +1,1339 @@ +// archiet-microcodegen-tauri v0.1.0 +// PRD text → Tauri v2 desktop app → ZIP. Pure Rust stdlib. <1400 LOC. +// Stage 1: parse_prd(text) → Manifest (language-agnostic) +// Stage 2: manifest_to_genome(manifest) → Genome (ArchiMate 3.2 typed) +// Stage 3: render_genome(genome) → HashMap (Tauri-specific) +// Stage 4: pack(files) → Vec (ZIP, Store method) or write to disk +// Zero external dependencies. Inspired by Karpathy's micrograd. + +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::Path; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +struct FieldSpec { + field_type: String, + required: bool, +} + +#[derive(Clone, Debug)] +struct Entity { + name: String, + fields: Vec<(String, FieldSpec)>, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +struct Integration { + name: String, + category: String, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +struct UserStory { + as_a: String, + i_want: String, +} + +#[derive(Clone, Debug)] +struct ArchiMateElement { + name: String, + kind: String, + description: String, +} + +#[derive(Clone, Debug)] +struct Manifest { + solution_name: String, + entities: Vec, + user_stories: Vec, + integrations: Vec, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +struct Genome { + solution_name: String, + bundle_id: String, + entities: Vec, + user_stories: Vec, + integrations: Vec, + archimate_elements: Vec, +} + +// ─── String helpers ─────────────────────────────────────────────────────────── + +fn snake(s: &str) -> String { + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() && i > 0 { + out.push('_'); + } + if c.is_alphanumeric() { + out.extend(c.to_lowercase()); + } else { + out.push('_'); + } + } + out.trim_matches('_').to_string() +} + +fn pascal(s: &str) -> String { + snake(s) + .split('_') + .filter(|p| !p.is_empty()) + .map(|p| { + let mut ch = p.chars(); + match ch.next() { + None => String::new(), + Some(f) => f.to_uppercase().to_string() + ch.as_str(), + } + }) + .collect() +} + +fn plural(s: &str) -> String { + if s.ends_with('s') { + return format!("{}es", s); + } + if s.ends_with('y') { + return format!("{}ies", &s[..s.len() - 1]); + } + format!("{}s", s) +} + +fn fill(tmpl: &str, vars: &[(&str, &str)]) -> String { + let mut r = tmpl.to_string(); + for (k, v) in vars { + r = r.replace(&format!("{{{{{}}}}}", k), v); + } + r +} + +#[allow(dead_code)] +fn random_hex(n: usize) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut state = seed ^ 0xdeadbeef_cafe1234u128; + let mut out = String::new(); + for _ in 0..n { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + out.push_str(&format!("{:02x}", (state & 0xff) as u8)); + } + out[..n].to_string() +} + +fn rust_type(t: &str) -> &'static str { + match t { + "integer" | "int" => "i64", + "float" | "decimal" | "number" => "f64", + "boolean" | "bool" => "bool", + _ => "String", + } +} + +fn sqlite_type(t: &str) -> &'static str { + match t { + "integer" | "int" => "INTEGER", + "float" | "decimal" | "number" => "REAL", + "boolean" | "bool" => "INTEGER", + _ => "TEXT", + } +} + +fn ts_type(t: &str) -> &'static str { + match t { + "integer" | "int" | "float" | "decimal" | "number" => "number", + "boolean" | "bool" => "boolean", + _ => "string", + } +} + +// ─── STAGE 4: pack (ZIP with Store method — pure stdlib) ───────────────────── + +fn crc32(data: &[u8]) -> u32 { + static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new(); + let tbl = TABLE.get_or_init(|| { + let mut t = [0u32; 256]; + for i in 0..256usize { + let mut c = i as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB88320 ^ (c >> 1) } else { c >> 1 }; + } + t[i] = c; + } + t + }); + let mut c: u32 = 0xFFFFFFFF; + for &b in data { + c = tbl[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8); + } + c ^ 0xFFFFFFFF +} + +fn pack(files: &HashMap) -> Vec { + let mut out: Vec = Vec::new(); + let mut cd: Vec = Vec::new(); + let mut count: u16 = 0; + + let mut keys: Vec<&String> = files.keys().collect(); + keys.sort(); + + for path in keys { + let content = files[path].as_bytes(); + let name = path.as_bytes(); + let crc = crc32(content); + let sz = content.len() as u32; + let offset = out.len() as u32; + + // Local file header (Store, method=0) + out.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); + out.extend_from_slice(&20u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // method: store + out.extend_from_slice(&0u16.to_le_bytes()); // mod time + out.extend_from_slice(&0u16.to_le_bytes()); // mod date + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&sz.to_le_bytes()); + out.extend_from_slice(&sz.to_le_bytes()); + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(name); + out.extend_from_slice(content); + + // Central directory entry + cd.extend_from_slice(&[0x50, 0x4B, 0x01, 0x02]); + cd.extend_from_slice(&20u16.to_le_bytes()); + cd.extend_from_slice(&20u16.to_le_bytes()); + cd.extend_from_slice(&0u16.to_le_bytes()); + cd.extend_from_slice(&0u16.to_le_bytes()); + cd.extend_from_slice(&0u16.to_le_bytes()); + cd.extend_from_slice(&0u16.to_le_bytes()); + cd.extend_from_slice(&crc.to_le_bytes()); + cd.extend_from_slice(&sz.to_le_bytes()); + cd.extend_from_slice(&sz.to_le_bytes()); + cd.extend_from_slice(&(name.len() as u16).to_le_bytes()); + cd.extend_from_slice(&[0u8; 12]); // extra, comment, disk, attrs + cd.extend_from_slice(&offset.to_le_bytes()); + cd.extend_from_slice(name); + count += 1; + } + + let cd_offset = out.len() as u32; + let cd_size = cd.len() as u32; + out.extend_from_slice(&cd); + out.extend_from_slice(&[0x50, 0x4B, 0x05, 0x06]); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&cd_size.to_le_bytes()); + out.extend_from_slice(&cd_offset.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out +} + +fn write_disk(files: &HashMap, out_dir: &Path) { + for (path, content) in files { + let dest = out_dir.join(path); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&dest, content).unwrap(); + } +} + +// ─── STAGE 1: parse_prd ────────────────────────────────────────────────────── + +fn parse_prd(text: &str) -> Manifest { + let solution_name = text + .lines() + .find(|l| l.starts_with("# ")) + .map(|l| l.trim_start_matches('#').trim().to_string()) + .unwrap_or_else(|| "Generated App".to_string()); + + let entities = extract_entities(text); + let user_stories = extract_stories(text); + let integrations = extract_integrations(text); + Manifest { solution_name, entities, user_stories, integrations } +} + +fn extract_entities(text: &str) -> Vec { + let mut entities: Vec = Vec::new(); + let lines: Vec<&str> = text.lines().collect(); + let mut i = 0; + let skip = ["User", "Stories", "API", "Entities", "Entity", "Model", + "Models", "Requirements", "Integration", "Integrations", + "Features", "Overview", "Setup", "Config"]; + + while i < lines.len() { + if let Some(name) = entity_name(lines[i]) { + if !skip.contains(&name.as_str()) && name.len() >= 2 && name.len() <= 50 { + let mut block = String::new(); + let mut j = i + 1; + while j < lines.len() { + if entity_name(lines[j]).is_some() { break; } + if lines[j].starts_with("## ") || lines[j].starts_with("# ") { break; } + block.push_str(lines[j]); + block.push('\n'); + j += 1; + } + let fields = parse_fields(&block); + entities.push(Entity { name, fields }); + i = j; + continue; + } + } + i += 1; + } + entities +} + +fn entity_name(line: &str) -> Option { + let line = line.trim(); + let n = if line.starts_with("**") && line.ends_with("**") && line.len() > 4 { + line[2..line.len() - 2].to_string() + } else if line.starts_with("### ") || line.starts_with("#### ") { + line.trim_start_matches('#').trim().to_string() + } else { + return None; + }; + if n.is_empty() || n.len() > 50 { return None; } + if !n.chars().all(|c| c.is_alphanumeric() || c == ' ' || c == '_') { return None; } + if !n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) { return None; } + Some(n.split_whitespace().collect::>().join("")) +} + +fn parse_fields(block: &str) -> Vec<(String, FieldSpec)> { + let mut fields = Vec::new(); + for line in block.lines() { + let cleaned = line.trim().trim_start_matches(|c| c == '-' || c == '*' || c == ' '); + if let Some(colon) = cleaned.find(':') { + let fname = cleaned[..colon].trim().to_lowercase().replace(' ', "_"); + if fname.is_empty() || fname.len() > 40 { continue; } + if !fname.chars().all(|c| c.is_alphanumeric() || c == '_') { continue; } + let rest = &cleaned[colon + 1..]; + let raw = rest.split_whitespace().next().unwrap_or("string").to_lowercase(); + let ftype = match raw.trim_end_matches(',') { + "integer" | "int" => "integer", + "float" | "decimal" | "number" => "float", + "boolean" | "bool" => "boolean", + "datetime" | "timestamp" => "datetime", + _ => "string", + }.to_string(); + let required = rest.to_lowercase().contains("required"); + fields.push((fname, FieldSpec { field_type: ftype, required })); + } + } + fields +} + +fn extract_stories(text: &str) -> Vec { + let mut stories = Vec::new(); + for line in text.lines() { + let lower = line.to_lowercase(); + if lower.contains("as a") && lower.contains("i want") { + let as_a = between(line, "as a", ",").unwrap_or_default(); + let i_want = between(line, "i want", ",") + .or_else(|| between(line, "i want", ".")) + .unwrap_or_default(); + if !as_a.is_empty() && !i_want.is_empty() { + stories.push(UserStory { + as_a: as_a.trim().to_string(), + i_want: i_want.trim().to_string(), + }); + } + } + } + stories +} + +fn between(s: &str, after: &str, before: &str) -> Option { + let lower = s.to_lowercase(); + let start = lower.find(after)? + after.len(); + let rest = &s[start..]; + let end = rest.to_lowercase().find(before).unwrap_or(rest.len()); + Some(rest[..end].trim().to_string()) +} + +fn extract_integrations(text: &str) -> Vec { + let known = [ + ("stripe", "payments"), ("braintree", "payments"), + ("sendgrid", "email"), ("mailgun", "email"), + ("twilio", "sms"), ("auth0", "auth"), + ("s3", "storage"), ("cloudinary", "storage"), + ("datadog", "observability"), ("sentry", "observability"), + ("openai", "ai"), ("anthropic", "ai"), + ]; + let lower = text.to_lowercase(); + known.iter().filter_map(|(n, c)| { + if lower.contains(n) { + Some(Integration { name: n.to_string(), category: c.to_string() }) + } else { + None + } + }).collect() +} + +// ─── STAGE 2: manifest_to_genome ───────────────────────────────────────────── + +fn manifest_to_genome(manifest: Manifest) -> Genome { + let bundle_id = snake(&manifest.solution_name); + + let mut archimate: Vec = vec![ + ArchiMateElement { + name: manifest.solution_name.clone(), + kind: "ApplicationComponent".to_string(), + description: format!("Tauri v2 desktop application: {}", manifest.solution_name), + }, + ArchiMateElement { + name: "AuthService".to_string(), + kind: "ApplicationComponent".to_string(), + description: "Argon2id password hashing, UUID session tokens, SQLite users table".to_string(), + }, + ArchiMateElement { + name: "LocalDatabase".to_string(), + kind: "TechnologyService".to_string(), + description: "SQLite 3 (rusqlite, bundled) — WAL mode, per-user data isolation".to_string(), + }, + ]; + for e in &manifest.entities { + archimate.push(ArchiMateElement { + name: e.name.clone(), + kind: "DataObject".to_string(), + description: format!("Persistent entity: {} — SQLite-backed, user_id FK", e.name), + }); + } + + let mut entities = manifest.entities.clone(); + for entity in &mut entities { + let has_id = entity.fields.iter().any(|(f, _)| f == "id"); + let has_uid = entity.fields.iter().any(|(f, _)| f == "user_id"); + let has_ts = entity.fields.iter().any(|(f, _)| f == "created_at"); + if !has_id { + entity.fields.insert(0, ("id".to_string(), FieldSpec { field_type: "integer".to_string(), required: true })); + } + if !has_uid { + entity.fields.insert(1, ("user_id".to_string(), FieldSpec { field_type: "integer".to_string(), required: true })); + } + if !has_ts { + entity.fields.push(("created_at".to_string(), FieldSpec { field_type: "datetime".to_string(), required: false })); + } + } + + Genome { + solution_name: manifest.solution_name, + bundle_id, + entities, + user_stories: manifest.user_stories, + integrations: manifest.integrations, + archimate_elements: archimate, + } +} + +// ─── STAGE 3: render_genome ─────────────────────────────────────────────────── + +fn render_genome(g: &Genome) -> HashMap { + let mut f: HashMap = HashMap::new(); + let app = &g.solution_name; + let bundle = &g.bundle_id; + let bundle_lib = bundle.replace('-', "_"); + let bundle_rev = format!("com.archiet.{}", bundle); + + f.insert("src-tauri/Cargo.toml".into(), r_cargo_toml(app, bundle, &bundle_lib)); + f.insert("src-tauri/build.rs".into(), "fn main() {\n tauri_build::build()\n}\n".into()); + f.insert("src-tauri/src/main.rs".into(), r_app_main(&bundle_lib)); + f.insert("src-tauri/src/lib.rs".into(), r_lib_rs(g, &bundle_lib)); + f.insert("src-tauri/src/db.rs".into(), r_db_rs(g, bundle)); + f.insert("src-tauri/src/auth.rs".into(), r_auth_rs()); + f.insert("src-tauri/src/commands/mod.rs".into(), r_commands_mod(g)); + f.insert("src-tauri/src/models/mod.rs".into(), r_models_mod(g)); + + for entity in &g.entities { + let sn = snake(&entity.name); + f.insert(format!("src-tauri/src/commands/{}_commands.rs", sn), r_entity_commands(entity)); + f.insert(format!("src-tauri/src/models/{}.rs", sn), r_entity_model(entity)); + } + + f.insert("src-tauri/tauri.conf.json".into(), r_tauri_conf(app, &bundle_rev)); + f.insert("src-tauri/capabilities/default.json".into(), r_capabilities(&bundle_rev)); + f.insert("src/main.tsx".into(), r_frontend_main()); + f.insert("src/App.tsx".into(), r_app_tsx(g)); + f.insert("src/ipc.ts".into(), r_ipc_ts(g)); + f.insert("src/pages/Login.tsx".into(), r_login_page()); + for entity in &g.entities { + f.insert(format!("src/pages/{}List.tsx", pascal(&entity.name)), r_entity_list(entity)); + } + f.insert("index.html".into(), r_index_html(app)); + f.insert("package.json".into(), r_package_json(app, bundle)); + f.insert("vite.config.ts".into(), r_vite_config()); + f.insert("tsconfig.json".into(), r_tsconfig()); + f.insert(".gitignore".into(), r_gitignore()); + f.insert(".env.example".into(), r_env_example(bundle)); + f.insert("ARCHITECTURE.md".into(), r_architecture_md(g)); + f.insert("openapi.yaml".into(), r_openapi_yaml(g)); + f.insert("README.md".into(), r_app_readme(app, g)); + f +} + +fn r_cargo_toml(app: &str, bundle: &str, bundle_lib: &str) -> String { + fill(r#"[package] +name = "{{BUNDLE}}" +version = "0.1.0" +description = "{{APP}}" +edition = "2021" +rust-version = "1.75" + +[lib] +name = "{{LIB}}" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["protocol-asset"] } +tauri-plugin-shell = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +rusqlite = { version = "0.31", features = ["bundled"] } +argon2 = "0.5" +rand_core = { version = "0.6", features = ["getrandom"] } +uuid = { version = "1", features = ["v4"] } + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "s" +panic = "abort" +strip = true +"#, &[("APP", app), ("BUNDLE", bundle), ("LIB", bundle_lib)]) +} + +fn r_app_main(bundle_lib: &str) -> String { + format!("#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\nfn main() {{\n {}::run();\n}}\n", bundle_lib) +} + +fn r_lib_rs(g: &Genome, bundle_lib: &str) -> String { + let mods: String = g.entities.iter() + .map(|e| format!(" commands::{}::create_{},\n commands::{}::list_{},\n commands::{}::get_{},\n commands::{}::update_{},\n commands::{}::delete_{},", + format!("{}_commands", snake(&e.name)), + snake(&e.name), + format!("{}_commands", snake(&e.name)), + plural(&snake(&e.name)), + format!("{}_commands", snake(&e.name)), + snake(&e.name), + format!("{}_commands", snake(&e.name)), + snake(&e.name), + format!("{}_commands", snake(&e.name)), + snake(&e.name), + )) + .collect::>() + .join("\n"); + fill(r#"pub mod auth; +pub mod commands; +pub mod db; +pub mod models; + +use std::collections::HashMap; +use std::sync::Mutex; +use rusqlite::Connection; + +pub struct AppState { + pub db: Mutex, + pub sessions: Mutex>, +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let conn = db::open_db().expect("failed to open SQLite database"); + db::run_migrations(&conn).expect("failed to run migrations"); + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .manage(AppState { + db: Mutex::new(conn), + sessions: Mutex::new(HashMap::new()), + }) + .invoke_handler(tauri::generate_handler![ + auth::register_user, + auth::login_user, + auth::logout_user, + auth::get_me, +{{HANDLERS}} + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +"#, &[("LIB", bundle_lib), ("HANDLERS", &mods)]) +} + +fn r_db_rs(g: &Genome, bundle: &str) -> String { + let tables: String = g.entities.iter().map(|e| { + let cols: String = e.fields.iter() + .filter(|(f, _)| f != "id") + .map(|(f, fs)| { + let nn = if fs.required { " NOT NULL" } else { "" }; + format!(" {} {}{},\n", f, sqlite_type(&fs.field_type), nn) + }) + .collect(); + format!(" CREATE TABLE IF NOT EXISTS {} (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n{} FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n );\n", plural(&snake(&e.name)), cols) + }).collect(); + fill(r#"use rusqlite::{Connection, Result}; +use std::path::PathBuf; + +pub fn db_path() -> PathBuf { + std::env::var("APP_DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")) + .join("{{BUNDLE}}.db") +} + +pub fn open_db() -> Result { + let path = db_path(); + if let Some(p) = path.parent() { std::fs::create_dir_all(p).ok(); } + Connection::open(path) +} + +pub fn run_migrations(conn: &Connection) -> Result<()> { + conn.execute_batch(&format!( + "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + {}", + "{{TABLES}}" + )) +} +"#, &[("BUNDLE", bundle), ("TABLES", &tables)]) +} + +fn r_auth_rs() -> String { + r#"use crate::AppState; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Serialize)] +pub struct UserInfo { pub id: i64, pub username: String } + +pub fn require_session(state: &AppState, token: &str) -> Result { + state.sessions.lock().unwrap().get(token).copied() + .ok_or_else(|| "Unauthorized".to_string()) +} + +#[tauri::command] +pub fn register_user( + state: tauri::State, + username: String, + password: String, +) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|e| e.to_string())? + .to_string(); + let db = state.db.lock().unwrap(); + db.execute( + "INSERT INTO users (username, password_hash) VALUES (?1, ?2)", + (&username, &hash), + ).map_err(|e| e.to_string())?; + let id = db.last_insert_rowid(); + Ok(UserInfo { id, username }) +} + +#[tauri::command] +pub fn login_user( + state: tauri::State, + username: String, + password: String, +) -> Result { + let db = state.db.lock().unwrap(); + let (id, hash): (i64, String) = db + .query_row( + "SELECT id, password_hash FROM users WHERE username = ?1", + [&username], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|_| "Invalid credentials".to_string())?; + let parsed = PasswordHash::new(&hash).map_err(|e| e.to_string())?; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .map_err(|_| "Invalid credentials".to_string())?; + let token = Uuid::new_v4().to_string(); + state.sessions.lock().unwrap().insert(token.clone(), id); + Ok(token) +} + +#[tauri::command] +pub fn logout_user(state: tauri::State, token: String) { + state.sessions.lock().unwrap().remove(&token); +} + +#[tauri::command] +pub fn get_me(state: tauri::State, token: String) -> Result { + let uid = require_session(&state, &token)?; + let db = state.db.lock().unwrap(); + let username: String = db + .query_row("SELECT username FROM users WHERE id = ?1", [uid], |r| r.get(0)) + .map_err(|e| e.to_string())?; + Ok(UserInfo { id: uid, username }) +} +"#.to_string() +} + +fn r_commands_mod(g: &Genome) -> String { + g.entities.iter() + .map(|e| format!("pub mod {}_commands;", snake(&e.name))) + .collect::>() + .join("\n") + "\n" +} + +fn r_models_mod(g: &Genome) -> String { + g.entities.iter() + .map(|e| format!("pub mod {};", snake(&e.name))) + .collect::>() + .join("\n") + "\n" +} + +fn r_entity_model(entity: &Entity) -> String { + let _sn = snake(&entity.name); + let ps = pascal(&entity.name); + let fields: String = entity.fields.iter() + .map(|(f, fs)| format!(" pub {}: {},\n", f, rust_type(&fs.field_type))) + .collect(); + format!( + "use serde::{{Deserialize, Serialize}};\n\n#[derive(Clone, Debug, Default, Deserialize, Serialize)]\npub struct {} {{\n{}}}\n", + ps, fields + ) +} + +fn r_entity_commands(entity: &Entity) -> String { + let sn = snake(&entity.name); + let ps = pascal(&entity.name); + let tbl = plural(&sn); + let all_fields: Vec<&str> = entity.fields.iter().map(|(f, _)| f.as_str()).collect(); + let data_fields: Vec<(&str, &FieldSpec)> = entity.fields.iter() + .filter(|(f, _)| f != "id" && f != "user_id" && f != "created_at") + .map(|(f, fs)| (f.as_str(), fs)) + .collect(); + let col_list = data_fields.iter().map(|(f, _)| *f).collect::>().join(", "); + let set_list = data_fields.iter().enumerate() + .map(|(i, (f, _))| format!("{}=?{}", f, i + 1)) + .collect::>() + .join(", "); + let placeholders = (0..data_fields.len()) + .map(|i| format!("?{}", i + 2)) + .collect::>() + .join(", "); + let row_fields: String = all_fields.iter().enumerate() + .map(|(i, f)| format!(" {}: row.get({})?,\n", f, i)) + .collect(); + let params: String = data_fields.iter() + .map(|(f, _)| format!(", {}", f)) + .collect(); + let param_decl: String = data_fields.iter() + .map(|(f, fs)| format!(", {}: {}", f, rust_type(&fs.field_type))) + .collect(); + let sel = all_fields.iter().map(|f| f.to_string()).collect::>().join(", "); + + format!(r#"use crate::{{auth::require_session, models::{sn}::{ps}, AppState}}; + +#[tauri::command] +pub fn create_{sn}(state: tauri::State, token: String{param_decl}) -> Result<{ps}, String> {{ + let uid = require_session(&state, &token)?; + let db = state.db.lock().unwrap(); + db.execute( + "INSERT INTO {tbl} (user_id, {col_list}) VALUES (?1, {placeholders})", + rusqlite::params![uid{params}], + ).map_err(|e| e.to_string())?; + let id = db.last_insert_rowid(); + db.query_row("SELECT {sel} FROM {tbl} WHERE id = ?1", [id], |row| Ok({ps} {{ +{row_fields} }})).map_err(|e| e.to_string()) +}} + +#[tauri::command] +pub fn list_{tbl}(state: tauri::State, token: String) -> Result, String> {{ + let uid = require_session(&state, &token)?; + let db = state.db.lock().unwrap(); + let mut stmt = db.prepare("SELECT {sel} FROM {tbl} WHERE user_id = ?1 ORDER BY id DESC").map_err(|e| e.to_string())?; + let rows = stmt.query_map([uid], |row| Ok({ps} {{ +{row_fields} }})).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string()) +}} + +#[tauri::command] +pub fn get_{sn}(state: tauri::State, token: String, id: i64) -> Result<{ps}, String> {{ + let uid = require_session(&state, &token)?; + let db = state.db.lock().unwrap(); + db.query_row("SELECT {sel} FROM {tbl} WHERE id = ?1 AND user_id = ?2", [id, uid], |row| Ok({ps} {{ +{row_fields} }})).map_err(|e| e.to_string()) +}} + +#[tauri::command] +pub fn update_{sn}(state: tauri::State, token: String, id: i64{param_decl}) -> Result<{ps}, String> {{ + let uid = require_session(&state, &token)?; + let db = state.db.lock().unwrap(); + db.execute("UPDATE {tbl} SET {set_list} WHERE id = ?{next} AND user_id = ?{next2}", rusqlite::params![{params_bare}id, uid]).map_err(|e| e.to_string())?; + db.query_row("SELECT {sel} FROM {tbl} WHERE id = ?1", [id], |row| Ok({ps} {{ +{row_fields} }})).map_err(|e| e.to_string()) +}} + +#[tauri::command] +pub fn delete_{sn}(state: tauri::State, token: String, id: i64) -> Result<(), String> {{ + let uid = require_session(&state, &token)?; + let db = state.db.lock().unwrap(); + db.execute("DELETE FROM {tbl} WHERE id = ?1 AND user_id = ?2", [id, uid]).map_err(|e| e.to_string())?; + Ok(()) +}} +"#, + sn = sn, ps = ps, tbl = tbl, + param_decl = param_decl, params = params, + col_list = col_list, set_list = set_list, placeholders = placeholders, + row_fields = row_fields, sel = sel, + next = data_fields.len() + 1, + next2 = data_fields.len() + 2, + params_bare = data_fields.iter().map(|(f, _)| format!("{}, ", f)).collect::(), + ) +} + +fn r_tauri_conf(app: &str, bundle_rev: &str) -> String { + fill(r#"{ + "productName": "{{APP}}", + "version": "0.1.0", + "identifier": "{{BUNDLE_REV}}", + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build", + "devUrl": "http://localhost:1420", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "title": "{{APP}}", + "width": 1200, + "height": 800, + "resizable": true, + "fullscreen": false + } + ], + "security": { "csp": null } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"] + } +} +"#, &[("APP", app), ("BUNDLE_REV", bundle_rev)]) +} + +fn r_capabilities(bundle_rev: &str) -> String { + fill(r#"{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default capability", + "windows": ["main"], + "permissions": [ + "core:default", + "shell:allow-open" + ] +} +"#, &[("ID", bundle_rev)]) +} + +fn r_frontend_main() -> String { + r#"import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + +) +"#.to_string() +} + +fn r_app_tsx(g: &Genome) -> String { + let routes: String = g.entities.iter() + .map(|e| { + let ps = pascal(&e.name); + format!(" }} />\n", + plural(&snake(&e.name)), ps) + }) + .collect(); + let imports: String = g.entities.iter() + .map(|e| { + let ps = pascal(&e.name); + format!("import {}List from './pages/{}List'\n", ps, ps) + }) + .collect(); + fill(r#"import { useState } from 'react' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import Login from './pages/Login' +{{IMPORTS}} +export default function App() { + const [token, setToken] = useState(null) + if (!token) return + return ( + + + } /> +{{ROUTES}} + + ) +} +"#, &[ + ("IMPORTS", &imports), + ("ROUTES", &routes), + ("FIRST", &g.entities.first().map(|e| plural(&snake(&e.name))).unwrap_or_else(|| "home".to_string())), + ]) +} + +fn r_ipc_ts(g: &Genome) -> String { + let mut lines = vec!["import { invoke } from '@tauri-apps/api/core'\n".to_string()]; + lines.push("export interface UserInfo { id: number; username: string }\n".to_string()); + lines.push("export const auth = {\n register: (username: string, password: string) => invoke('register_user', { username, password }),\n login: (username: string, password: string) => invoke('login_user', { username, password }),\n logout: (token: string) => invoke('logout_user', { token }),\n me: (token: string) => invoke('get_me', { token }),\n}\n".to_string()); + for entity in &g.entities { + let sn = snake(&entity.name); + let ps = pascal(&entity.name); + let tbl = plural(&sn); + let data_fields: Vec<(&str, &FieldSpec)> = entity.fields.iter() + .filter(|(f, _)| f != "id" && f != "user_id" && f != "created_at") + .map(|(f, fs)| (f.as_str(), fs)) + .collect(); + let iface_fields: String = entity.fields.iter() + .map(|(f, fs)| format!(" {}: {}; ", f, ts_type(&fs.field_type))) + .collect(); + let create_params: String = data_fields.iter() + .map(|(f, fs)| format!(", {}: {}", f, ts_type(&fs.field_type))) + .collect(); + let create_obj: String = data_fields.iter() + .map(|(f, _)| format!(", {}", f)) + .collect(); + lines.push(format!("export interface {} {{ {} }}\n", ps, iface_fields)); + lines.push(format!("export const {} = {{\n list: (token: string) => invoke<{}[]>('list_{}', {{ token }}),\n get: (token: string, id: number) => invoke<{}>('get_{}', {{ token, id }}),\n create: (token: string{}) => invoke<{}>('create_{}', {{ token{} }}),\n update: (token: string, id: number{}) => invoke<{}>('update_{}', {{ token, id{} }}),\n delete: (token: string, id: number) => invoke('delete_{}', {{ token, id }}),\n}}\n", + sn, ps, tbl, ps, sn, create_params, ps, sn, create_obj, create_params, ps, sn, create_obj, sn)); + } + lines.join("") +} + +fn r_login_page() -> String { + r#"import { useState } from 'react' +import { auth } from '../ipc' + +export default function Login({ onLogin }: { onLogin: (t: string) => void }) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [mode, setMode] = useState<'login' | 'register'>('login') + const [error, setError] = useState('') + + async function submit(e: React.FormEvent) { + e.preventDefault() + setError('') + try { + if (mode === 'register') { + await auth.register(username, password) + } + const token = await auth.login(username, password) + onLogin(token) + } catch (err: any) { + setError(String(err)) + } + } + + return ( +
+

{mode === 'login' ? 'Sign In' : 'Create Account'}

+ {error &&

{error}

} +
+ setUsername(e.target.value)} + placeholder="Username" required style={{ display: 'block', width: '100%', marginBottom: 8, padding: 8 }} /> + setPassword(e.target.value)} + placeholder="Password" required style={{ display: 'block', width: '100%', marginBottom: 8, padding: 8 }} /> + +
+ +
+ ) +} +"#.to_string() +} + +fn r_entity_list(entity: &Entity) -> String { + let sn = snake(&entity.name); + let ps = pascal(&entity.name); + let _tbl = plural(&sn); + let data_fields: Vec<&str> = entity.fields.iter() + .filter(|(f, _)| f != "id" && f != "user_id" && f != "created_at") + .map(|(f, _)| f.as_str()) + .collect(); + let headers: String = data_fields.iter().map(|f| format!("{}", f)).collect(); + let cells: String = data_fields.iter().map(|f| format!("{{item.{}}}", f)).collect(); + let inputs: String = data_fields.iter() + .map(|f| format!(" setForm(x => ({{...x, {}: e.target.value}}))}}\n style={{{{display:'block',width:'100%',marginBottom:6,padding:6}}}} />\n", f, f, f)) + .collect(); + let init_form: String = data_fields.iter() + .map(|f| format!("{}: ''", f)) + .collect::>() + .join(", "); + fill(r#"import { useEffect, useState } from 'react' +import { {{LOWER}}, {{PASCAL}} } from '../ipc' + +export default function {{PASCAL}}List({ token }: { token: string }) { + const [items, setItems] = useState<{{PASCAL}}[]>([]) + const [form, setForm] = useState>({ {{INIT}} }) + const [error, setError] = useState('') + + useEffect(() => { load() }, []) + + async function load() { + try { setItems(await {{LOWER}}.list(token)) } catch (e: any) { setError(String(e)) } + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault() + try { + await {{LOWER}}.create(token, ...Object.values(form) as any) + setForm({ {{INIT}} }) + load() + } catch (e: any) { setError(String(e)) } + } + + async function handleDelete(id: number) { + try { await {{LOWER}}.delete(token, id); load() } catch (e: any) { setError(String(e)) } + } + + return ( +
+

{{PASCAL}}

+ {error &&

{error}

} +
+{{INPUTS}} +
+ + {{HEADERS}} + + {items.map(item => ( + + {{CELLS}} + + + ))} + +
id
{item.id}
+
+ ) +} +"#, &[("LOWER", &sn), ("PASCAL", &ps), ("HEADERS", &headers), ("CELLS", &cells), ("INPUTS", &inputs), ("INIT", &init_form)]) +} + +fn r_index_html(app: &str) -> String { + fill(r#" + + + + + {{APP}} + + +
+ + + +"#, &[("APP", app)]) +} + +fn r_package_json(app: &str, bundle: &str) -> String { + fill(r#"{ + "name": "{{BUNDLE}}", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-shell": "^2", + "react": "^18", + "react-dom": "^18", + "react-router-dom": "^6" + }, + "devDependencies": { + "@tauri-apps/cli": "^2", + "@types/react": "^18", + "@types/react-dom": "^18", + "typescript": "^5", + "vite": "^5", + "@vitejs/plugin-react": "^4" + } +} +"#, &[("APP", app), ("BUNDLE", bundle)]) +} + +fn r_vite_config() -> String { + r#"import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + clearScreen: false, + server: { port: 1420, strictPort: true }, + envPrefix: ['VITE_', 'TAURI_'], + build: { + target: ['es2021', 'chrome105', 'safari13'], + minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, + sourcemap: !!process.env.TAURI_DEBUG, + }, +}) +"#.to_string() +} + +fn r_tsconfig() -> String { + r#"{ + "compilerOptions": { + "target": "ES2021", + "useDefineForClassFields": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} +"#.to_string() +} + +fn r_gitignore() -> String { + "/target\n/dist\nnode_modules\n.env\n*.db\n*.db-shm\n*.db-wal\n".to_string() +} + +fn r_env_example(bundle: &str) -> String { + format!("# Path for SQLite database (optional — defaults to current dir)\nAPP_DATA_DIR=./data\n# Bundle identifier\nBUNDLE_ID={}\n", bundle) +} + +fn r_architecture_md(g: &Genome) -> String { + let elements: String = g.archimate_elements.iter() + .map(|e| format!("| {} | {} | {} |\n", e.name, e.kind, e.description)) + .collect(); + fill(r#"# Architecture — {{APP}} + +> Generated by archiet-microcodegen-tauri. ArchiMate 3.2 element inventory. + +## Stack + +- **Runtime**: Tauri v2 (Rust backend + React/TypeScript frontend) +- **Storage**: SQLite (rusqlite, bundled) — WAL mode +- **Auth**: Argon2id password hashing, UUID session tokens (in-memory AppState) +- **IPC**: Tauri `invoke()` commands — typed, per-user isolation enforced on every query + +## ArchiMate 3.2 Elements + +| Name | Type | Description | +|---|---|---| +{{ELEMENTS}} + +## Data Isolation + +Every entity table has a `user_id` column with a `FOREIGN KEY REFERENCES users(id) ON DELETE CASCADE`. +Every IPC command validates the session token and filters queries by `user_id`. Cross-user data access is structurally impossible. + +## Auth Flow + +1. `register_user(username, password)` — Argon2id hash, INSERT into users table +2. `login_user(username, password)` — verify hash, generate UUID token, store in `AppState.sessions` +3. Every subsequent command: `require_session(token)` → `user_id`, query filtered by `user_id` +4. `logout_user(token)` — remove from `AppState.sessions` + +Session tokens live in JavaScript memory only — never in localStorage or on disk. +"#, &[("APP", &g.solution_name), ("ELEMENTS", &elements)]) +} + +fn r_openapi_yaml(g: &Genome) -> String { + let mut paths = String::new(); + for entity in &g.entities { + let sn = snake(&entity.name); + let tbl = plural(&sn); + let ps = pascal(&entity.name); + paths.push_str(&format!( + " {}:\n list_{}: list all {} for the authenticated user\n create_{}: create a new {}\n {} (by id):\n get_{}: fetch one {}\n update_{}: update one {}\n delete_{}: delete one {}\n", + tbl, sn, ps, sn, ps, sn, sn, ps, sn, ps, sn, ps + )); + } + fill(r#"# IPC Contract — {{APP}} +# Generated by archiet-microcodegen-tauri +# Commands are invoked via Tauri invoke(), not HTTP. +# All commands require a 'token' parameter (UUID session token from login_user). + +info: + title: "{{APP}} IPC Contract" + version: "0.1.0" + description: "Tauri IPC command surface. Not HTTP — use @tauri-apps/api invoke()." + +auth_commands: + register_user: + params: { username: string, password: string } + returns: UserInfo + login_user: + params: { username: string, password: string } + returns: string # session token + logout_user: + params: { token: string } + returns: void + get_me: + params: { token: string } + returns: UserInfo + +entity_commands: +{{PATHS}} +"#, &[("APP", &g.solution_name), ("PATHS", &paths)]) +} + +fn r_app_readme(app: &str, g: &Genome) -> String { + let entity_list: String = g.entities.iter() + .map(|e| format!("- `{}` — {} entity\n", pascal(&e.name), e.name)) + .collect(); + fill(r#"# {{APP}} + +> Generated by [archiet-microcodegen-tauri](https://www.npmjs.com/package/archiet-microcodegen-tauri) — PRD → Tauri v2 desktop app. + +## Development + +```bash +npm install +npm run tauri dev +``` + +## Build + +```bash +npm run tauri build +``` + +## Entities + +{{ENTITIES}} + +## Auth + +- Register: `auth.register(username, password)` +- Login: `auth.login(username, password)` → returns session token +- All entity commands require the session token + +## Architecture + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for ArchiMate 3.2 element inventory and data isolation design. +"#, &[("APP", app), ("ENTITIES", &entity_list)]) +} + +// ─── CLI main ──────────────────────────────────────────────────────────────── + +fn main() { + let args: Vec = env::args().collect(); + if args.len() < 2 || args[1] == "--help" || args[1] == "-h" { + eprintln!("archiet-microcodegen-tauri v0.1.0"); + eprintln!("Usage: archiet-microcodegen-tauri [--out ] [--zip ]"); + eprintln!(" archiet-microcodegen-tauri --sample"); + std::process::exit(0); + } + + if args[1] == "--sample" { + print!("{}", SAMPLE_PRD); + return; + } + + let prd_path = &args[1]; + let text = fs::read_to_string(prd_path) + .unwrap_or_else(|e| { eprintln!("Error reading {}: {}", prd_path, e); std::process::exit(1); }); + + let manifest = parse_prd(&text); + let genome = manifest_to_genome(manifest); + let files = render_genome(&genome); + + let mut out_dir: Option<&str> = None; + let mut zip_path: Option<&str> = None; + let mut i = 2; + while i < args.len() { + match args[i].as_str() { + "--out" => { i += 1; out_dir = args.get(i).map(|s| s.as_str()); } + "--zip" => { i += 1; zip_path = args.get(i).map(|s| s.as_str()); } + _ => {} + } + i += 1; + } + + if let Some(zip) = zip_path { + let bytes = pack(&files); + fs::write(zip, &bytes) + .unwrap_or_else(|e| { eprintln!("Error writing {}: {}", zip, e); std::process::exit(1); }); + eprintln!("Wrote {} files to {}", files.len(), zip); + } else { + let dir = out_dir.unwrap_or("./out"); + write_disk(&files, Path::new(dir)); + eprintln!("Wrote {} files to {}", files.len(), dir); + } + + eprintln!("Next: cd {} && npm install && npm run tauri dev", out_dir.unwrap_or("./out")); +} + +const SAMPLE_PRD: &str = r#"# Task Manager + +## Entities + +**Project** + - name: string (required) + - description: text + - status: string (required) + +**Task** + - title: string (required) + - body: text + - due_date: string + - priority: string + - status: string + +## User Stories + +As a user, I want to create projects so that I can organise my work. +As a user, I want to add tasks to projects so that I can track progress. + +## Integrations + +No external integrations required. +"#; diff --git a/scripts/microcodegen.py b/scripts/microcodegen.py new file mode 100644 index 0000000..6fa9e51 --- /dev/null +++ b/scripts/microcodegen.py @@ -0,0 +1,1396 @@ +#!/usr/bin/env python3 +"""microcodegen.py — Archiet's core algorithm in one file. + +PRD text → manifest → genome → rendered FastAPI app → ZIP bytes. + +Contract: microcodegen(prd_text) → bytes (working, bootable Flask ZIP) + + # CLI: python scripts/microcodegen.py prd.md > app.zip + # python scripts/microcodegen.py prd.md --out /tmp/myapp/ + # Lib: from scripts.microcodegen import microcodegen + +Stages: + 1. parse_prd(text) → manifest dict (regex extraction, no LLM) + 2. manifest_to_genome(manifest) → genome dict + 3. render_genome(genome) → {path: content} (String.Template, Flask only) + 4. pack(files) → bytes (stdlib zipfile) + +NOT: LLM extraction, multi-stack, capability emission, frontend, stub-fill, + quality scoring, rate limiting, observability, secret rotation. + +Why this file exists: + ONBOARDING — grasps the algorithm in 10 min; codegen_service.py is efficiency on top. + REGRESSION — bug that doesn't repro here is in efficiency layers, not the algorithm. + KARPATHY BAR — if we can't express the core in <700 LOC, we're hiding behind layers. + SPEC — algorithmic changes must update this file; efficiency changes needn't. + +Constraints: + - Pure stdlib; zero app.* / agents.* / templates/ imports. + - No TODOs — handle it or scope it out explicitly. + - Hard ceiling: 700 LOC (enforced by tests/test_microcodegen.py). +""" + +from __future__ import annotations + +import argparse +import io +import json +import re +import secrets +import string +import sys +import zipfile +from pathlib import Path + +# ─── STAGE 1 ──────────────────────────────────────────────────────────────── +# parse_prd(text) → manifest dict. +# +# Pure regex + heuristic extraction. The full pipeline uses a chunked LLM +# extractor with overlap + dedup merge; this version does pattern matching. +# It will miss subtle PRDs. That's acceptable for a spec-grade reference. + + +_ENTITY_PATTERN = re.compile( + # Matches "## Entities", "Entities:", "## Data Models", or a numbered + # "## 3. ENTITY DATA MODEL" — header introducing an entity section. + # The optional leading "." tolerates numbered section headings, and + # "entity data model(s)" tolerates the OperateIQ-PRD phrasing. + r"^#{1,3}\s*(?:\d+\.\s*)?" + r"(?:entities|data models|domain models|entity list|entity data models?)" + r"\s*:?\s*$", + re.IGNORECASE | re.MULTILINE, +) + +_ENTITY_NAME_PATTERN = re.compile( + # Matches "- Order" / "* User" / "## Order" — an entity name in a list + # or sub-header. Captures the name only (no fields yet). + # Tolerates markdown bold (**Order**) which customers write naturally, + # and tolerates a trailing space (so "- Order with description" still + # matches "Order"). We deliberately use [ \t] (NOT \s) for the terminator + # so the match cannot cross a newline into field declarations and drop + # the first field of every entity. + r"^[\s\-\*\#]+\*{0,2}([A-Z][a-zA-Z0-9_]{1,40})\*{0,2}[ \t]*(?::|—|-|[ \t]|$)", + re.MULTILINE, +) + +_FIELD_PATTERN = re.compile( + # Matches "- name: string" / "* email: text (required)" — a field on an + # entity. Captures field name + type. Modifiers like "required" are + # parsed by _parse_field_modifiers below. + r"^[\s\-\*]+([a-z_][a-z0-9_]{0,40})\s*[:—-]\s*([a-zA-Z]+)([^\n]*)", + re.MULTILINE, +) + +_INLINE_FIELD_PATTERN = re.compile( + # Matches "name (string, required)" / "email (text)" — a field declared + # inline on the entity-name line. Customers write entities this way: + # - Project: name (string, required), description (text), status (string) + # The inline path runs as a fallback when sub-bullet fields yield 0. + r"([a-z_][a-z0-9_]{0,40})\s*\(\s*([a-zA-Z]+)([^)]*)\)", +) + +_USER_STORY_PATTERN = re.compile( + # Matches "As a X, I want Y so that Z." — the canonical user story shape. + # Captures the role, want, and so-that clauses for direct AC derivation. + r"As\s+(?:a|an)\s+([^,]+?),\s+I\s+want\s+(?:to\s+)?([^,]+?)(?:,?\s*so\s+that\s+([^.]+))?\.", + re.IGNORECASE, +) + +_INTEGRATION_KEYWORDS = { + # Maps customer-mentioned vendor → integration spec the genome accepts. + # When the PRD mentions any of these strings, we add a corresponding + # entry to genome.integrations[]. The full pipeline does broader + # detection; this list is intentionally conservative. + "stripe": {"name": "stripe", "category": "payments"}, + "auth0": {"name": "auth0", "category": "auth"}, + "clerk": {"name": "clerk", "category": "auth"}, + "supabase": {"name": "supabase", "category": "auth"}, + "sendgrid": {"name": "sendgrid", "category": "email"}, + "postmark": {"name": "postmark", "category": "email"}, + "twilio": {"name": "twilio", "category": "sms"}, + "datadog": {"name": "datadog", "category": "observability"}, + "segment": {"name": "segment", "category": "analytics"}, +} + + +def _parse_field_modifiers(modifier_text: str) -> dict: + """Extract 'required', 'unique', 'indexed' flags from "(required, unique)". + + The full pipeline tolerates dozens of modifier spellings; this version + handles the three most-common ones literally. + """ + flags = {"required": False, "unique": False, "indexed": False} + text = modifier_text.lower() + if "required" in text or "not null" in text or " ! " in text: + flags["required"] = True + if "unique" in text: + flags["unique"] = True + if "indexed" in text or "index" in text: + flags["indexed"] = True + return flags + + +def _solution_name_from_prd(text: str) -> str: + """Pull the first H1 (# Title) as the solution name; fall back to a default.""" + m = re.match(r"^#\s+(.+?)\s*$", text, re.MULTILINE) + if m: + return m.group(1).strip() + return "Generated App" + + +def parse_prd(text: str) -> dict: + """Extract a manifest dict from raw PRD text. + + Returns shape: + { + "solution_name": str, + "entities": [{"name": str, "fields": [{"name", "type", "required", + "unique", "indexed"}]}], + "user_stories": [{"as_a": str, "i_want": str, "so_that": str}], + "integrations": [{"name": str, "category": str}], + } + """ + solution_name = _solution_name_from_prd(text) + + # Entities: find the section header, then extract names until the next + # H1/H2 header. If no entity section, skip — empty manifest is allowed. + entities: list[dict] = [] + section_match = _ENTITY_PATTERN.search(text) + entity_section = "" + if section_match: + # Read from the section start to the next top-level header (or EOF) + start = section_match.end() + next_header = re.search(r"^#{1,2}\s+\S", text[start:], re.MULTILINE) + end = start + next_header.start() if next_header else len(text) + entity_section = text[start:end] + + seen: set[str] = set() + for m in _ENTITY_NAME_PATTERN.finditer(entity_section): + ename = m.group(1) + if ename in seen: + continue + seen.add(ename) + # Look at the lines following this entity name (until next entity + # name or end of section) for field declarations. + ent_start = m.end() + next_entity = _ENTITY_NAME_PATTERN.search(entity_section, ent_start) + ent_end = next_entity.start() if next_entity else len(entity_section) + ent_body = entity_section[ent_start:ent_end] + + fields: list[dict] = [] + seen_fields: set[str] = set() + for fm in _FIELD_PATTERN.finditer(ent_body): + fname, ftype, modifier_text = fm.group(1), fm.group(2), fm.group(3) + # Skip the entity-name line that the entity-name regex + # already consumed (our patterns can overlap on indent). + if fname in seen: + continue + if fname in seen_fields: + continue + seen_fields.add(fname) + flags = _parse_field_modifiers(modifier_text) + fields.append( + { + "name": fname, + "type": ftype.lower(), + **flags, + } + ) + + # Fallback: scan the entity-name line itself for inline "field (type)" + # patterns. Customers naturally write inline-formatted entity rows. + if not fields: + entity_name_line = ( + entity_section[m.start() : m.end()] + ent_body.split("\n", 1)[0] + ) + for im in _INLINE_FIELD_PATTERN.finditer(entity_name_line): + fname, ftype, modifier_text = im.group(1), im.group(2), im.group(3) + if fname in seen_fields: + continue + seen_fields.add(fname) + flags = _parse_field_modifiers(modifier_text) + fields.append( + { + "name": fname, + "type": ftype.lower(), + **flags, + } + ) + + entities.append({"name": ename, "fields": fields}) + + # User stories: scan the whole document. + stories = [] + for m in _USER_STORY_PATTERN.finditer(text): + stories.append( + { + "as_a": (m.group(1) or "").strip(), + "i_want": (m.group(2) or "").strip(), + "so_that": (m.group(3) or "").strip(), + } + ) + + # Integrations: substring match on known vendor names. + integrations = [] + text_lower = text.lower() + for vendor, spec in _INTEGRATION_KEYWORDS.items(): + if vendor in text_lower: + integrations.append(spec) + + return { + "solution_name": solution_name, + "entities": entities, + "user_stories": stories, + "integrations": integrations, + } + + +# ─── STAGE 2 ──────────────────────────────────────────────────────────────── +# manifest_to_genome(manifest) → genome dict. +# +# Maps the heuristic manifest into the canonical genome shape that +# render_genome consumes. Single function, no fallbacks. + + +def _snake(s: str) -> str: + """Convert "Order Line" → "order_line". The full pipeline has 10+ + snake-case implementations across modules; this is the canonical one.""" + s = re.sub(r"[^a-zA-Z0-9]+", "_", s.strip()).strip("_") + s = re.sub(r"([a-z])([A-Z])", r"\1_\2", s) + return s.lower() + + +def manifest_to_genome(manifest: dict) -> dict: + """Map the heuristic manifest into the canonical genome shape. + + The genome is the load-bearing IR. All downstream stages read from + here. The full pipeline supports realtime primitives, capabilities, + workflows, screens, etc.; this version emits only modules + entities. + """ + name = manifest["solution_name"] + snake_name = _snake(name) + + # Entities → modules.core.entities. + entities_dict: dict[str, dict] = {} + for ent in manifest.get("entities", []): + # The genome shape uses field-dict-of-dict, not field-list. Convert. + fields: dict[str, dict] = {"id": {"type": "uuid", "required": True}} + for f in ent.get("fields", []): + if f["name"] in ("id", "created_at", "updated_at"): + continue # auto-emitted by the rendering layer + fields[f["name"]] = { + "type": f["type"], + "required": f["required"], + "unique": f["unique"], + "indexed": f["indexed"], + } + entities_dict[ent["name"]] = { + "fields": fields, + "description": f"{ent['name']} entity (generated by microcodegen)", + "archimate_type": "DataObject", # ArchiMate 3.2 §9.3 Application Layer + } + + # Build top-level ArchiMate element list for architecture doc generation. + # ApplicationComponent = the Flask app itself. + # DataObject = each entity (persisted data with identity). + # ApplicationService = each external integration endpoint. + # BusinessProcess = user stories that use workflow trigger verbs. + _workflow_verbs = { + "create", + "update", + "delete", + "approve", + "reject", + "submit", + "complete", + "process", + "generate", + "schedule", + "notify", + } + archimate_elements: list[dict] = [ + { + "name": name, + "type": "ApplicationComponent", + "description": f"{name} FastAPI application", + }, + ] + for ent_name in entities_dict: + archimate_elements.append( + { + "name": ent_name, + "type": "DataObject", + "description": entities_dict[ent_name]["description"], + } + ) + for story in manifest.get("user_stories", []): + # User stories are dicts with as_a/i_want/so_that keys from parse_prd. + text = story.get("i_want", story.get("story", "")).lower() + if any(v in text for v in _workflow_verbs): + label = text[:60] + archimate_elements.append( + { + "name": label, + "type": "BusinessProcess", + "description": f"I want to {text}", + } + ) + for intg in manifest.get("integrations", []): + # Integrations are dicts with 'name' key from parse_prd. + intg_name = intg.get("name", str(intg)) if isinstance(intg, dict) else str(intg) + archimate_elements.append( + { + "name": intg_name, + "type": "ApplicationService", + "description": f"External integration: {intg_name}", + } + ) + + return { + "genome_version": "1.0.0", + "solution_id": 0, + "solution_name": name, + "bundle_id": snake_name, + "language": "fastapi", + "modules": { + "core": { + "module_type": "crud", + "description": "Core entities", + "entities": entities_dict, + }, + }, + "user_stories": manifest.get("user_stories", []), + "integrations": manifest.get("integrations", []), + "archimate_elements": archimate_elements, + # OperateIQ semantic-graph spine (spec-level genome keys). The full + # pipeline's genome_compiler flips semantic_graph to True when it sees a + # SemanticRelationship entity and fills `projections` with the framework + # projections whose source entities are present. The atomic algorithm + # declares the keys with their inert defaults so the IR shape is honest. + "semantic_graph": False, + "projections": [], + } + + +# ─── STAGE 3 ──────────────────────────────────────────────────────────────── +# render_genome(genome) → {path: content}. +# +# Inline templates via string.Template. FastAPI + PostgreSQL + Alembic. +# The full pipeline branches across 12 stacks via the StackRenderer dispatch; +# this version is single-stack by design — adding a stack here would violate +# the "atomic algorithm" contract. + + +_FILE_TEMPLATES: dict[str, string.Template] = { + "requirements.txt": string.Template("""\ +fastapi>=0.110 +uvicorn[standard]>=0.27 +sqlalchemy>=2.0 +alembic>=1.13 +PyJWT>=2.8 +bcrypt>=4.1 +psycopg2-binary>=2.9 +pydantic>=2.0 +python-dotenv>=1.0 +"""), + "main.py": string.Template("""\ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.auth import router as auth_router +$router_imports + +from app.database import Base, engine +import app.models # noqa: F401 -- registers every model on Base.metadata + +# Create tables on first boot so `docker compose up` yields a working app with +# zero manual steps. Idempotent (no-op if the tables already exist). For +# versioned production migrations, use the bundled Alembic setup instead +# (alembic.ini + alembic/env.py): `alembic revision --autogenerate && alembic upgrade head`. +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="$name", version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) +$router_includes + + +@app.get("/health") +def health(): + return {"status": "ok", "version": "$bundle_id"} +"""), + "app/database.py": string.Template("""\ +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +DATABASE_URL = os.environ.get("DATABASE_URL") +if not DATABASE_URL: + raise RuntimeError("DATABASE_URL is not set. Add it to .env or your deploy env.") + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() +"""), + "app/auth.py": string.Template("""\ +import os +import uuid +from datetime import datetime, timedelta, timezone + +import bcrypt +import jwt as _jwt +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.user import User + +_JWT_SECRET = os.environ.get("JWT_SECRET_KEY") +if not _JWT_SECRET: + raise RuntimeError("JWT_SECRET_KEY is not set.") +_ALGORITHM = "HS256" +_EXPIRES_DAYS = 7 + +router = APIRouter() + + +def _hash(pw: str) -> str: + # bcrypt hashes at most 72 bytes; truncate so longer passwords don't raise. + return bcrypt.hashpw(pw.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8") + + +def _verify(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("utf-8")) + + +def _token(user_id: str) -> str: + exp = datetime.now(timezone.utc) + timedelta(days=_EXPIRES_DAYS) + return _jwt.encode({"sub": user_id, "exp": exp}, _JWT_SECRET, algorithm=_ALGORITHM) + + +def get_current_user(request: Request, db: Session = Depends(get_db)) -> User: + token = request.cookies.get("access_token") + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + try: + payload = _jwt.decode(token, _JWT_SECRET, algorithms=[_ALGORITHM]) + user_id: str = payload.get("sub") + except _jwt.PyJWTError: + raise HTTPException(status_code=401, detail="Invalid token") + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=401, detail="User not found") + return user + + +class _AuthBody(BaseModel): + email: str + password: str + + +def _set_cookie(response: Response, token: str) -> None: + response.set_cookie( + "access_token", token, + httponly=True, samesite="lax", max_age=_EXPIRES_DAYS * 86400, + ) + + +@router.post("/register", status_code=201) +def register(body: _AuthBody, response: Response, db: Session = Depends(get_db)): + if len(body.password) < 8: + raise HTTPException(status_code=400, detail="Password must be >= 8 chars") + if db.query(User).filter(User.email == body.email.lower()).first(): + raise HTTPException(status_code=409, detail="Email already registered") + user = User(id=str(uuid.uuid4()), email=body.email.lower(), + password_hash=_hash(body.password)) + db.add(user) + db.commit() + _set_cookie(response, _token(user.id)) + return {"id": user.id, "email": user.email} + + +@router.post("/login") +def login(body: _AuthBody, response: Response, db: Session = Depends(get_db)): + user = db.query(User).filter(User.email == body.email.lower()).first() + if not user or not _verify(body.password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid credentials") + _set_cookie(response, _token(user.id)) + return {"id": user.id, "email": user.email} + + +@router.post("/logout") +def logout(response: Response): + response.delete_cookie("access_token") + return {"ok": True} + + +@router.get("/me") +def me(current_user: User = Depends(get_current_user)): + return {"id": current_user.id, "email": current_user.email} +"""), + "app/models/user.py": string.Template("""\ +from datetime import datetime + +from sqlalchemy import Column, DateTime, String + +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(String(36), primary_key=True) + email = Column(String(255), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) +"""), + "app/models/_entity.py": string.Template("""\ +from datetime import datetime + +from sqlalchemy import Boolean, Column, Date, DateTime, Float, ForeignKey +from sqlalchemy import Integer, JSON, Numeric, String, Text + +from app.database import Base + + +class $entity_name(Base): + __tablename__ = "$table_name" + + id = Column(String(36), primary_key=True) + # Per-tenant ownership — every row is scoped to the user that created it. + # Queries in the router filter by this to prevent cross-user data leaks. + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) +$columns + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +"""), + "app/schemas/_entity.py": string.Template("""\ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + + +class ${entity_name}Base(BaseModel): +$base_fields + + +class ${entity_name}Create(${entity_name}Base): + pass + + +class ${entity_name}Update(BaseModel): +$optional_fields + + +class ${entity_name}Response(${entity_name}Base): + id: str + user_id: str + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) +"""), + "app/routers/_entity.py": string.Template("""\ +import uuid +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.auth import get_current_user +from app.database import get_db +from app.models.$snake_entity import $entity_name +from app.models.user import User +from app.schemas.$snake_entity import ( + ${entity_name}Create, + ${entity_name}Update, + ${entity_name}Response, +) + +router = APIRouter() + + +@router.get("/", response_model=List[${entity_name}Response]) +def list_${snake_entity}s( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return db.query($entity_name).filter($entity_name.user_id == current_user.id).all() + + +@router.post("/", response_model=${entity_name}Response, status_code=201) +def create_$snake_entity( + body: ${entity_name}Create, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + obj = $entity_name(id=str(uuid.uuid4()), user_id=current_user.id, **body.model_dump()) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +@router.get("/{item_id}", response_model=${entity_name}Response) +def get_$snake_entity( + item_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + obj = db.query($entity_name).filter( + $entity_name.id == item_id, $entity_name.user_id == current_user.id + ).first() + if not obj: + raise HTTPException(status_code=404, detail="$entity_name not found") + return obj + + +@router.put("/{item_id}", response_model=${entity_name}Response) +def update_$snake_entity( + item_id: str, + body: ${entity_name}Update, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + obj = db.query($entity_name).filter( + $entity_name.id == item_id, $entity_name.user_id == current_user.id + ).first() + if not obj: + raise HTTPException(status_code=404, detail="$entity_name not found") + for k, v in body.model_dump(exclude_unset=True).items(): + setattr(obj, k, v) + db.commit() + db.refresh(obj) + return obj + + +@router.delete("/{item_id}", status_code=204) +def delete_$snake_entity( + item_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + obj = db.query($entity_name).filter( + $entity_name.id == item_id, $entity_name.user_id == current_user.id + ).first() + if not obj: + raise HTTPException(status_code=404, detail="$entity_name not found") + db.delete(obj) + db.commit() +"""), + "alembic.ini": string.Template("""\ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S +"""), + "alembic/env.py": string.Template("""\ +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.database import Base +import app.models # noqa: F401 — registers all SQLAlchemy models with Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +DATABASE_URL = os.environ.get("DATABASE_URL") +if not DATABASE_URL: + raise RuntimeError("DATABASE_URL is not set.") +config.set_main_option("sqlalchemy.url", DATABASE_URL) + + +def run_migrations_offline() -> None: + context.configure(url=DATABASE_URL, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() +"""), + ".env.example": string.Template("""\ +DATABASE_URL=postgresql://archiet:archiet@localhost:5432/$bundle_id +JWT_SECRET_KEY=$jwt_secret_key +"""), + "Dockerfile": string.Template("""\ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +"""), + "docker-compose.yml": string.Template("""\ +services: + app: + build: . + ports: ["8000:8000"] + environment: + DATABASE_URL: postgresql://archiet:archiet@db:5432/$bundle_id + JWT_SECRET_KEY: $jwt_secret_key + depends_on: + db: + condition: service_healthy + db: + image: postgres:16 + environment: + POSTGRES_USER: archiet + POSTGRES_PASSWORD: archiet + POSTGRES_DB: $bundle_id + volumes: ["pgdata:/var/lib/postgresql/data"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U archiet -d $bundle_id"] + interval: 3s + timeout: 3s + retries: 20 +volumes: + pgdata: +"""), + "README.md": string.Template("""\ +# $solution_name + +Generated by microcodegen.py — FastAPI + PostgreSQL + Alembic. + +## Quick start + +```bash +cp .env.example .env +docker compose up +curl http://localhost:8000/health +# Interactive API docs: http://localhost:8000/docs +``` + +## End-to-end (register, login, CRUD) + +```bash +# 1. Register (JWT set as httpOnly cookie) +curl -c cookies.txt -X POST http://localhost:8000/api/auth/register \\ + -H "Content-Type: application/json" \\ + -d '{"email":"you@example.com","password":"hunter22hunter"}' + +# 2. Create an entity +curl -b cookies.txt -X POST http://localhost:8000/api/items/ \\ + -H "Content-Type: application/json" \\ + -d '{"name":"My first item"}' + +# 3. List +curl -b cookies.txt http://localhost:8000/api/items/ +``` + +## Entities + +$entity_list + +## Migrations + +```bash +alembic revision --autogenerate -m "init" +alembic upgrade head +``` + +## What's included + +- FastAPI + PostgreSQL (docker-compose with healthcheck-gated startup) +- JWT-cookie auth (httpOnly, SameSite=Lax): register / login / logout / me +- Pydantic v2 request/response validation with full type safety +- Full CRUD per entity: list, create, get, update, delete +- Per-tenant data isolation — every row scoped to the authenticated user +- Alembic migrations pre-configured +- /docs interactive OpenAPI UI (free, auto-generated by FastAPI) +- ARCHITECTURE.md + openapi.yaml shipped in this ZIP +"""), + "tests/conftest.py": string.Template("""\ +import os + +os.environ.setdefault("JWT_SECRET_KEY", "test-jwt-secret-not-for-production-use") +os.environ.setdefault( + "DATABASE_URL", + os.environ.get("TEST_DATABASE_URL", + "postgresql://archiet:archiet@localhost:5432/${bundle_id}_test"), +) + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from sqlalchemy import create_engine, text # noqa: E402 +from sqlalchemy.exc import OperationalError # noqa: E402 +from sqlalchemy.orm import sessionmaker # noqa: E402 + +from app.database import Base, get_db # noqa: E402 +from main import app # noqa: E402 + + +def _db_reachable() -> bool: + try: + e = create_engine(os.environ["DATABASE_URL"]) + with e.connect() as conn: + conn.execute(text("SELECT 1")) # tenant-exempt: connectivity probe + return True + except OperationalError: + return False + + +@pytest.fixture(autouse=True, scope="session") +def require_db(): + if not _db_reachable(): + pytest.skip( + "PostgreSQL not reachable — start it with `docker compose up -d db` " + "or set TEST_DATABASE_URL and retry." + ) + + +@pytest.fixture +def app_client(): + test_engine = create_engine(os.environ["DATABASE_URL"]) + TestingSession = sessionmaker(bind=test_engine) + Base.metadata.create_all(bind=test_engine) + + def override_get_db(): + db = TestingSession() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as client: + yield client + Base.metadata.drop_all(bind=test_engine) + app.dependency_overrides.clear() +"""), + "tests/test_health.py": string.Template("""\ +def test_health(app_client): + r = app_client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" +"""), +} + + +def _column_for_field(fname: str, fspec: dict) -> str: + """SQLAlchemy Column() declaration for a single entity field.""" + type_map = { + "string": "String(255)", + "text": "Text", + "integer": "Integer", + "int": "Integer", + "float": "Float", + "decimal": "Numeric(12, 2)", + "boolean": "Boolean", + "bool": "Boolean", + "datetime": "DateTime", + "date": "Date", + "uuid": "String(36)", + "json": "JSON", + } + sa_type = type_map.get(fspec.get("type", "string"), "String(255)") + nullable = "" if fspec.get("required") else ", nullable=True" + unique = ", unique=True" if fspec.get("unique") else "" + indexed = ", index=True" if fspec.get("indexed") else "" + return f" {fname} = Column({sa_type}{nullable}{unique}{indexed})" + + +def _pydantic_type_for_field(fspec: dict) -> str: + """Pydantic v2 type annotation string for a single entity field.""" + type_map = { + "string": "str", + "text": "str", + "integer": "int", + "int": "int", + "float": "float", + "decimal": "float", + "boolean": "bool", + "bool": "bool", + "datetime": "datetime", + "date": "str", + "uuid": "str", + "json": "dict", + } + return type_map.get(fspec.get("type", "string"), "str") + + +def _render_architecture_md(genome: dict, entities: dict) -> str: + """Generate ARCHITECTURE.md showing ArchiMate 3.2 element types. + + Gives the developer downloading the ZIP a typed map of what was + generated and how each piece relates. Mirrors what the full Archiet + platform emits from the formal ArchiMate model, but derived + heuristically from the PRD text. + """ + name = genome["solution_name"] + elements = genome.get("archimate_elements", []) + user_stories = genome.get("user_stories", []) + integrations = genome.get("integrations", []) + + lines: list[str] = [ + f"# Architecture — {name}", + "", + "Generated by microcodegen.py · ArchiMate 3.2 element notation", + "", + "## Application Layer (ArchiMate §9)", + "", + "| Element | Type | Description |", + "|---------|------|-------------|", + ] + for el in elements: + lines.append(f"| `{el['name']}` | {el['type']} | {el['description']} |") + + lines += [ + "", + "## Relationships", + "", + "```", + f" {name} (ApplicationComponent)", + ] + for ent_name in entities: + lines.append(f" └── {ent_name} (DataObject) [Realization]") + for intg in integrations: + intg_name = intg.get("name", str(intg)) if isinstance(intg, dict) else str(intg) + lines.append(f" └── {intg_name} (ApplicationService) [UsedBy]") + lines.append("```") + + if user_stories: + lines += ["", "## Business Process Layer (ArchiMate §8)", ""] + for story in user_stories[:10]: # cap to keep doc readable + lines.append( + f"- As a {story.get('as_a', '')}, I want to {story.get('i_want', '')}" + ) + + lines += [ + "", + "## Notes", + "", + "- This file is heuristically derived from PRD text.", + "- The full Archiet platform generates a formal ArchiMate 3.2 model", + " (ApplicationComponent, DataObject, BusinessProcess, ApplicationService,", + " AssignmentRelationship, RealizationRelationship) from the genome IR,", + " plus DMN 1.5 decision tables, BPMN 2.0 process diagrams, and a", + " complete openapi.yaml verified against the running application.", + "- To regenerate: edit GENOME.json and re-run microcodegen.py,", + " or use the Archiet platform for cross-stack + formal-model output.", + ] + return "\n".join(lines) + "\n" + + +def _render_openapi_yaml(genome: dict, entities: dict) -> str: + """Generate openapi.yaml (OpenAPI 3.1) from entity/route structure. + + Emits one path group per entity (list + detail CRUD) plus the auth + endpoints. Field types are mapped from the genome's SQLAlchemy-style + type strings to OpenAPI primitive types. + """ + name = genome["solution_name"] + + _type_map = { + "string": "string", + "text": "string", + "integer": "integer", + "float": "number", + "boolean": "boolean", + "datetime": "string", + "date": "string", + "uuid": "string", + "json": "object", + } + + lines: list[str] = [ + "openapi: '3.1.0'", + "info:", + f" title: {name} API", + f" description: Generated by microcodegen.py for {name}", + " version: 0.1.0", + "servers:", + " - url: http://localhost:8000", + " description: Local development", + "paths:", + " /api/auth/register:", + " post:", + " summary: Register a new user", + " tags: [auth]", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + " $ref: '#/components/schemas/AuthRequest'", + " responses:", + " '201': {description: User created}", + " '409': {description: Email already registered}", + " /api/auth/login:", + " post:", + " summary: Login and receive JWT cookie", + " tags: [auth]", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + " $ref: '#/components/schemas/AuthRequest'", + " responses:", + " '200': {description: Authenticated — JWT set in httpOnly cookie}", + " '401': {description: Invalid credentials}", + ] + + for ent_name, ent_spec in entities.items(): + snake = _snake(ent_name) + plural = snake + "s" + props: list[str] = [] + for fname, fspec in (ent_spec.get("fields") or {}).items(): + if fname == "id": + continue + oa_type = _type_map.get(fspec.get("type", "string"), "string") + fmt = "" + if fspec.get("type") in ("datetime", "date"): + fmt = f"\n format: {fspec['type']}-time" + props.append(f" {fname}:\n type: {oa_type}{fmt}") + lines += [ + f" /api/{plural}:", + " get:", + f" summary: List {ent_name} records", + f" tags: [{ent_name}]", + " security: [{bearerAuth: []}]", + " responses:", + f" '200': {{description: List of {ent_name}}}", + " post:", + f" summary: Create {ent_name}", + f" tags: [{ent_name}]", + " security: [{bearerAuth: []}]", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + f" $ref: '#/components/schemas/{ent_name}'", + " responses:", + f" '201': {{description: {ent_name} created}}", + f" /api/{plural}/{{id}}:", + " get:", + f" summary: Get {ent_name} by ID", + f" tags: [{ent_name}]", + " security: [{bearerAuth: []}]", + " parameters:", + " - in: path", + " name: id", + " required: true", + " schema: {type: string, format: uuid}", + " responses:", + f" '200': {{description: {ent_name} record}}", + " '404': {description: Not found}", + " put:", + f" summary: Update {ent_name}", + f" tags: [{ent_name}]", + " security: [{bearerAuth: []}]", + " parameters:", + " - in: path", + " name: id", + " required: true", + " schema: {type: string, format: uuid}", + " requestBody:", + " required: true", + " content:", + " application/json:", + " schema:", + f" $ref: '#/components/schemas/{ent_name}'", + " responses:", + f" '200': {{description: {ent_name} updated}}", + " delete:", + f" summary: Delete {ent_name}", + f" tags: [{ent_name}]", + " security: [{bearerAuth: []}]", + " parameters:", + " - in: path", + " name: id", + " required: true", + " schema: {type: string, format: uuid}", + " responses:", + " '204': {description: Deleted}", + ] + + lines += [ + "components:", + " securitySchemes:", + " bearerAuth:", + " type: http", + " scheme: bearer", + " bearerFormat: JWT", + " schemas:", + " AuthRequest:", + " type: object", + " required: [email, password]", + " properties:", + " email: {type: string, format: email}", + " password: {type: string, format: password}", + ] + for ent_name, ent_spec in entities.items(): + lines += [ + f" {ent_name}:", + " type: object", + " properties:", + ] + for fname, fspec in (ent_spec.get("fields") or {}).items(): + if fname == "id": + continue + oa_type = _type_map.get(fspec.get("type", "string"), "string") + lines.append(f" {fname}: {{type: {oa_type}}}") + + return "\n".join(lines) + "\n" + + +def render_genome(genome: dict) -> dict[str, str]: + """Render the genome into a {path: content} dict. + + The output is what the customer would download. FastAPI + PostgreSQL. + """ + bundle_id = genome["bundle_id"] + name = genome["solution_name"] + # Per-ZIP random secret so two customers cannot forge tokens cross-ZIP. + jwt_secret_key = secrets.token_urlsafe(32) + files: dict[str, str] = {} + + # Fixed files (no per-entity substitution beyond bundle_id / secrets) + for path in ("requirements.txt", "Dockerfile", "docker-compose.yml", "alembic.ini"): + files[path] = _FILE_TEMPLATES[path].safe_substitute( + bundle_id=bundle_id, jwt_secret_key=jwt_secret_key + ) + + # Auth + database scaffold (always emitted — CRUD routes depend on it) + files["app/database.py"] = _FILE_TEMPLATES["app/database.py"].safe_substitute() + files["app/auth.py"] = _FILE_TEMPLATES["app/auth.py"].safe_substitute() + files["app/models/user.py"] = _FILE_TEMPLATES[ + "app/models/user.py" + ].safe_substitute() + files["alembic/env.py"] = _FILE_TEMPLATES["alembic/env.py"].safe_substitute() + + # Package markers + files["app/routers/__init__.py"] = "" + files["app/schemas/__init__.py"] = "" + + # Per-entity files + router_imports: list[str] = [] + router_includes: list[str] = [] + model_imports: list[str] = ["from app.models.user import User # noqa: F401"] + entity_list_lines: list[str] = [] + + entities = (genome["modules"]["core"] or {}).get("entities") or {} + for ent_name, ent_spec in entities.items(): + snake = _snake(ent_name) + table = snake + "s" + + # SQLAlchemy column declarations + cols: list[str] = [] + for fname, fspec in (ent_spec.get("fields") or {}).items(): + if fname == "id": + continue + cols.append(_column_for_field(fname, fspec)) + + # Pydantic base + update field declarations + base_fields: list[str] = [] + optional_fields: list[str] = [] + for fname, fspec in (ent_spec.get("fields") or {}).items(): + if fname == "id": + continue + py_type = _pydantic_type_for_field(fspec) + if fspec.get("required"): + base_fields.append(f" {fname}: {py_type}") + else: + base_fields.append(f" {fname}: Optional[{py_type}] = None") + optional_fields.append(f" {fname}: Optional[{py_type}] = None") + + files[f"app/models/{snake}.py"] = _FILE_TEMPLATES[ + "app/models/_entity.py" + ].safe_substitute( + entity_name=ent_name, + table_name=table, + columns="\n".join(cols) if cols else " pass # no fields extracted", + ) + files[f"app/schemas/{snake}.py"] = _FILE_TEMPLATES[ + "app/schemas/_entity.py" + ].safe_substitute( + entity_name=ent_name, + base_fields="\n".join(base_fields) if base_fields else " pass", + optional_fields="\n".join(optional_fields) + if optional_fields + else " pass", + ) + files[f"app/routers/{snake}.py"] = _FILE_TEMPLATES[ + "app/routers/_entity.py" + ].safe_substitute(entity_name=ent_name, snake_entity=snake) + + model_imports.append(f"from app.models.{snake} import {ent_name} # noqa: F401") + router_imports.append( + f"from app.routers.{snake} import router as {snake}_router" + ) + router_includes.append( + f'app.include_router({snake}_router, prefix="/api/{table}", tags=["{ent_name}"])' + ) + entity_list_lines.append(f"- **{ent_name}**: {ent_spec.get('description', '')}") + + # app/models/__init__.py imports every model so Alembic discovers them all + files["app/models/__init__.py"] = "\n".join(model_imports) + "\n" + + files["main.py"] = _FILE_TEMPLATES["main.py"].safe_substitute( + name=name, + bundle_id=bundle_id, + router_imports="\n".join(router_imports), + router_includes="\n".join(router_includes), + ) + files[".env.example"] = _FILE_TEMPLATES[".env.example"].safe_substitute( + bundle_id=bundle_id, jwt_secret_key=jwt_secret_key + ) + files["tests/conftest.py"] = _FILE_TEMPLATES["tests/conftest.py"].safe_substitute( + bundle_id=bundle_id + ) + files["tests/test_health.py"] = _FILE_TEMPLATES[ + "tests/test_health.py" + ].safe_substitute() + files["README.md"] = _FILE_TEMPLATES["README.md"].safe_substitute( + solution_name=name, + entity_list="\n".join(entity_list_lines) + or "_(no entities extracted from PRD)_", + ) + + # Genome for transparency + files["GENOME.json"] = json.dumps(genome, indent=2, default=str) + + # Architecture documents — what separates Archiet from a CRUD generator. + # ARCHITECTURE.md gives the developer a typed ArchiMate element map. + # openapi.yaml gives them a machine-readable API contract they can import + # into Postman, Swagger UI, or a client generator immediately. + files["ARCHITECTURE.md"] = _render_architecture_md(genome, entities) + files["openapi.yaml"] = _render_openapi_yaml(genome, entities) + + return files + + +# ─── STAGE 4 ──────────────────────────────────────────────────────────────── +# pack(files) → bytes. stdlib zipfile. + + +def pack(files: dict[str, str]) -> bytes: + """Pack {path: content} into ZIP bytes.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for path, content in sorted(files.items()): + zf.writestr(path, content) + return buf.getvalue() + + +# ─── PUBLIC ENTRY ─────────────────────────────────────────────────────────── + + +def microcodegen(prd_text: str) -> bytes: + """The complete algorithm. PRD text → ZIP bytes.""" + manifest = parse_prd(prd_text) + genome = manifest_to_genome(manifest) + files = render_genome(genome) + return pack(files) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Archiet's core algorithm in one file.") + p.add_argument("prd", help="Path to PRD file (markdown/text).") + p.add_argument( + "--out", + help="Directory to extract into. If omitted, writes ZIP bytes to stdout.", + ) + args = p.parse_args(argv) + + prd_text = Path(args.prd).read_text(encoding="utf-8") + + if args.out: + manifest = parse_prd(prd_text) + genome = manifest_to_genome(manifest) + files = render_genome(genome) + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + for path, content in files.items(): + full = out_dir / path + full.parent.mkdir(parents=True, exist_ok=True) + full.write_text(content, encoding="utf-8") + print(f"Wrote {len(files)} files to {out_dir}", file=sys.stderr) + else: + zip_bytes = microcodegen(prd_text) + sys.stdout.buffer.write(zip_bytes) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())