From e312edf4257cfe4c17fb226ab27f55d4944d322d Mon Sep 17 00:00:00 2001 From: anioko1 Date: Wed, 5 Aug 2026 18:06:24 +0100 Subject: [PATCH] Consolidate all microcodegen sources and release pipelines here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aniekanasuquookono-web/archiet is being archived. It is also the build source for live packages on PyPI, npm, crates.io, NuGet, RubyGems, Packagist and pkg.go.dev — and GitHub Actions do not run on archived repositories. Left as was, archiving would have frozen every one of them with no way to ship a fix. Brought across: - scripts/microcodegen.py — the 1,396-line FastAPI engine behind PyPI archiet-microcodegen 0.2.3. Verified standalone: every non-stdlib import sits inside generated template strings, nothing reaches into the Flask app. - nine renderer directories (django, dotnet, flask, go, java, laravel, nestjs, rails, tauri) - eleven release workflows, tag triggers unchanged Excluded archiet_microcodegen_tauri/target/ — 251 tracked files and 17.3 MB of committed Rust build artifacts against 22 KB of actual source. .gitignore now covers build output across all nine ecosystems so it cannot recur. Added Apache-2.0. Every one of these repositories carried license: NONE, which means the packages people can already pip install and npm install are not legally usable. Apache-2.0 rather than MIT for the express patent grant and the contributor patent licence — the safer footing for a company-backed project whose IP strategy contemplates registered rights. Added PUBLISHING.md: the tag-to-package-to-registry map, the eight secrets that must be recreated here before anything can publish, and why the two Go and Laravel mirror repositories must stay where they are (module paths and Packagist names are bound to a repo URL; moving them breaks go install). The two Python packages are kept separate on purpose. microcodegen (root file, 1,122 lines, Flask, 0.1.0) and archiet-microcodegen (scripts/, 1,396 lines, FastAPI, 0.2.3) diverged while living in two repositories. Collapsing them would either change what pip install microcodegen produces or regress archiet-microcodegen from 0.2.3 to 0.1.0. Neither is worth doing to existing users for tidiness; reconciling the engines is its own change with a major version bump. Nothing publishes from this commit — releases are tag-triggered and the secrets do not exist here yet. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/crates-publish-tauri.yml | 101 ++ .github/workflows/gem-publish-rails.yml | 109 ++ .github/workflows/go-publish.yml | 99 ++ .github/workflows/maven-release-java.yml | 96 ++ .github/workflows/npm-publish-nestjs.yml | 90 ++ .github/workflows/nuget-publish-dotnet.yml | 107 ++ .../workflows/packagist-publish-laravel.yml | 140 ++ .github/workflows/pypi-publish-django.yml | 108 ++ .github/workflows/pypi-publish-flask.yml | 108 ++ .github/workflows/pypi-publish-mcp.yml | 138 ++ .github/workflows/pypi-publish.yml | 111 ++ .gitignore | 18 + LICENSE | 202 +++ PUBLISHING.md | 91 ++ archiet_microcodegen_django/README.md | 118 ++ archiet_microcodegen_django/__init__.py | 39 + archiet_microcodegen_django/__main__.py | 11 + archiet_microcodegen_django/_core.py | 1260 +++++++++++++++ archiet_microcodegen_dotnet/Program.cs | 550 +++++++ archiet_microcodegen_dotnet/README.md | 226 +++ .../archiet-microcodegen-dotnet.csproj | 17 + archiet_microcodegen_flask/README.md | 104 ++ archiet_microcodegen_flask/__init__.py | 47 + archiet_microcodegen_flask/__main__.py | 11 + archiet_microcodegen_flask/_core.py | 1224 +++++++++++++++ archiet_microcodegen_go/PUSH_TO_REPO.md | 41 + archiet_microcodegen_go/README.md | 166 ++ archiet_microcodegen_go/go.mod | 3 + archiet_microcodegen_go/main.go | 927 +++++++++++ archiet_microcodegen_java/PUSH_TO_REPO.md | 44 + archiet_microcodegen_java/README.md | 176 +++ archiet_microcodegen_java/pom.xml | 65 + .../java/com/archiet/microcodegen/Main.java | 426 +++++ archiet_microcodegen_laravel/README.md | 238 +++ archiet_microcodegen_laravel/composer.json | 11 + archiet_microcodegen_nestjs/README.md | 183 +++ archiet_microcodegen_nestjs/package.json | 33 + archiet_microcodegen_rails/README.md | 233 +++ .../archiet-microcodegen-rails.gemspec | 13 + .../lib/archiet_microcodegen_rails.rb | 515 ++++++ archiet_microcodegen_tauri/Cargo.lock | 7 + archiet_microcodegen_tauri/Cargo.toml | 23 + archiet_microcodegen_tauri/README.md | 182 +++ archiet_microcodegen_tauri/src/main.rs | 1339 ++++++++++++++++ scripts/microcodegen.py | 1396 +++++++++++++++++ 45 files changed, 11146 insertions(+) create mode 100644 .github/workflows/crates-publish-tauri.yml create mode 100644 .github/workflows/gem-publish-rails.yml create mode 100644 .github/workflows/go-publish.yml create mode 100644 .github/workflows/maven-release-java.yml create mode 100644 .github/workflows/npm-publish-nestjs.yml create mode 100644 .github/workflows/nuget-publish-dotnet.yml create mode 100644 .github/workflows/packagist-publish-laravel.yml create mode 100644 .github/workflows/pypi-publish-django.yml create mode 100644 .github/workflows/pypi-publish-flask.yml create mode 100644 .github/workflows/pypi-publish-mcp.yml create mode 100644 .github/workflows/pypi-publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 PUBLISHING.md create mode 100644 archiet_microcodegen_django/README.md create mode 100644 archiet_microcodegen_django/__init__.py create mode 100644 archiet_microcodegen_django/__main__.py create mode 100644 archiet_microcodegen_django/_core.py create mode 100644 archiet_microcodegen_dotnet/Program.cs create mode 100644 archiet_microcodegen_dotnet/README.md create mode 100644 archiet_microcodegen_dotnet/archiet-microcodegen-dotnet.csproj create mode 100644 archiet_microcodegen_flask/README.md create mode 100644 archiet_microcodegen_flask/__init__.py create mode 100644 archiet_microcodegen_flask/__main__.py create mode 100644 archiet_microcodegen_flask/_core.py create mode 100644 archiet_microcodegen_go/PUSH_TO_REPO.md create mode 100644 archiet_microcodegen_go/README.md create mode 100644 archiet_microcodegen_go/go.mod create mode 100644 archiet_microcodegen_go/main.go create mode 100644 archiet_microcodegen_java/PUSH_TO_REPO.md create mode 100644 archiet_microcodegen_java/README.md create mode 100644 archiet_microcodegen_java/pom.xml create mode 100644 archiet_microcodegen_java/src/main/java/com/archiet/microcodegen/Main.java create mode 100644 archiet_microcodegen_laravel/README.md create mode 100644 archiet_microcodegen_laravel/composer.json create mode 100644 archiet_microcodegen_nestjs/README.md create mode 100644 archiet_microcodegen_nestjs/package.json create mode 100644 archiet_microcodegen_rails/README.md create mode 100644 archiet_microcodegen_rails/archiet-microcodegen-rails.gemspec create mode 100644 archiet_microcodegen_rails/lib/archiet_microcodegen_rails.rb create mode 100644 archiet_microcodegen_tauri/Cargo.lock create mode 100644 archiet_microcodegen_tauri/Cargo.toml create mode 100644 archiet_microcodegen_tauri/README.md create mode 100644 archiet_microcodegen_tauri/src/main.rs create mode 100644 scripts/microcodegen.py 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())