From faa8581875a15a3471cb78011737244a27eeb8ee Mon Sep 17 00:00:00 2001 From: David Gaspard Date: Sun, 12 Apr 2026 16:20:25 -0400 Subject: [PATCH 1/9] Create repo scaffold CLI --- repoScaffold/examples/crud_app.yaml | 31 ++ repoScaffold/prompt.md | 377 ++++++++++++++++++ repoScaffold/promptToPrompt.md | 41 ++ repoScaffold/pyproject.toml | 25 ++ .../src/platform_cli.egg-info/PKG-INFO | 10 + .../src/platform_cli.egg-info/SOURCES.txt | 43 ++ .../dependency_links.txt | 1 + .../platform_cli.egg-info/entry_points.txt | 2 + .../src/platform_cli.egg-info/requires.txt | 5 + .../src/platform_cli.egg-info/top_level.txt | 1 + repoScaffold/src/platform_cli/__init__.py | 1 + repoScaffold/src/platform_cli/__main__.py | 4 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 263 bytes .../__pycache__/cli.cpython-313.pyc | Bin 0 -> 1002 bytes repoScaffold/src/platform_cli/cli.py | 19 + .../src/platform_cli/commands/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 182 bytes .../__pycache__/install.cpython-313.pyc | Bin 0 -> 1780 bytes .../__pycache__/scaffold.cpython-313.pyc | Bin 0 -> 3718 bytes .../__pycache__/test_cmd.cpython-313.pyc | Bin 0 -> 3197 bytes .../src/platform_cli/commands/install.py | 31 ++ .../src/platform_cli/commands/scaffold.py | 83 ++++ .../src/platform_cli/commands/test_cmd.py | 66 +++ .../src/platform_cli/engine/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 180 bytes .../__pycache__/context.cpython-313.pyc | Bin 0 -> 2886 bytes .../engine/__pycache__/dag.cpython-313.pyc | Bin 0 -> 2686 bytes .../__pycache__/registry.cpython-313.pyc | Bin 0 -> 1338 bytes .../engine/__pycache__/runner.cpython-313.pyc | Bin 0 -> 3925 bytes .../engine/__pycache__/state.cpython-313.pyc | Bin 0 -> 3056 bytes .../engine/__pycache__/step.cpython-313.pyc | Bin 0 -> 2195 bytes .../src/platform_cli/engine/context.py | 44 ++ repoScaffold/src/platform_cli/engine/dag.py | 44 ++ .../src/platform_cli/engine/registry.py | 24 ++ .../src/platform_cli/engine/runner.py | 72 ++++ repoScaffold/src/platform_cli/engine/state.py | 42 ++ repoScaffold/src/platform_cli/engine/step.py | 38 ++ .../src/platform_cli/manifest/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 182 bytes .../__pycache__/defaults.cpython-313.pyc | Bin 0 -> 519 bytes .../__pycache__/loader.cpython-313.pyc | Bin 0 -> 2250 bytes .../__pycache__/schema.cpython-313.pyc | Bin 0 -> 2263 bytes .../src/platform_cli/manifest/defaults.py | 13 + .../src/platform_cli/manifest/loader.py | 37 ++ .../src/platform_cli/manifest/schema.py | 38 ++ .../src/platform_cli/shell/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 179 bytes .../shell/__pycache__/run.cpython-313.pyc | Bin 0 -> 2122 bytes .../shell/__pycache__/tools.cpython-313.pyc | Bin 0 -> 1267 bytes repoScaffold/src/platform_cli/shell/run.py | 57 +++ repoScaffold/src/platform_cli/shell/tools.py | 42 ++ .../src/platform_cli/steps/__init__.py | 14 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 594 bytes .../__pycache__/phase_a_tools.cpython-313.pyc | Bin 0 -> 2852 bytes .../phase_b_scaffold.cpython-313.pyc | Bin 0 -> 6171 bytes .../phase_c_database.cpython-313.pyc | Bin 0 -> 5761 bytes .../__pycache__/phase_d_api.cpython-313.pyc | Bin 0 -> 6098 bytes .../phase_e_frontend.cpython-313.pyc | Bin 0 -> 5680 bytes .../phase_f_worker.cpython-313.pyc | Bin 0 -> 3799 bytes .../__pycache__/phase_g_gcp.cpython-313.pyc | Bin 0 -> 6399 bytes .../phase_h_secrets.cpython-313.pyc | Bin 0 -> 4418 bytes .../phase_i_cloudbuild.cpython-313.pyc | Bin 0 -> 2918 bytes .../phase_j_terraform.cpython-313.pyc | Bin 0 -> 5693 bytes .../phase_k_testing.cpython-313.pyc | Bin 0 -> 6764 bytes .../src/platform_cli/steps/phase_a_tools.py | 49 +++ .../platform_cli/steps/phase_b_scaffold.py | 159 ++++++++ .../platform_cli/steps/phase_c_database.py | 126 ++++++ .../src/platform_cli/steps/phase_d_api.py | 128 ++++++ .../platform_cli/steps/phase_e_frontend.py | 120 ++++++ .../src/platform_cli/steps/phase_f_worker.py | 77 ++++ .../src/platform_cli/steps/phase_g_gcp.py | 164 ++++++++ .../src/platform_cli/steps/phase_h_secrets.py | 101 +++++ .../platform_cli/steps/phase_i_cloudbuild.py | 78 ++++ .../platform_cli/steps/phase_j_terraform.py | 164 ++++++++ .../src/platform_cli/steps/phase_k_testing.py | 124 ++++++ .../src/platform_cli/templates/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 183 bytes .../__pycache__/filters.cpython-313.pyc | Bin 0 -> 1376 bytes .../__pycache__/renderer.cpython-313.pyc | Bin 0 -> 1822 bytes .../src/platform_cli/templates/filters.py | 20 + .../src/platform_cli/templates/renderer.py | 33 ++ .../templates/claude/settings.json.j2 | 12 + repoScaffold/templates/cloudbuild/api.yaml.j2 | 33 ++ .../templates/cloudbuild/terraform.yaml.j2 | 30 ++ repoScaffold/templates/cloudbuild/web.yaml.j2 | 32 ++ repoScaffold/templates/cloudrun/api.yaml.j2 | 33 ++ repoScaffold/templates/cloudrun/web.yaml.j2 | 22 + .../infra/terraform/envs/dev/main.tf.j2 | 62 +++ .../terraform/envs/dev/terraform.tfvars.j2 | 4 + .../infra/terraform/envs/dev/variables.tf.j2 | 19 + .../templates/infra/terraform/main.tf.j2 | 15 + .../terraform/modules/cloudrun/main.tf.j2 | 62 +++ .../terraform/modules/cloudrun/outputs.tf.j2 | 8 + .../modules/cloudrun/variables.tf.j2 | 19 + .../infra/terraform/modules/iam/main.tf.j2 | 38 ++ .../infra/terraform/modules/iam/outputs.tf.j2 | 9 + .../terraform/modules/iam/variables.tf.j2 | 14 + .../terraform/modules/networking/main.tf.j2 | 17 + .../modules/networking/variables.tf.j2 | 14 + .../terraform/modules/secrets/main.tf.j2 | 30 ++ .../terraform/modules/secrets/variables.tf.j2 | 9 + .../templates/infra/terraform/outputs.tf.j2 | 8 + .../templates/infra/terraform/variables.tf.j2 | 23 ++ .../templates/project/.env.example.j2 | 22 + repoScaffold/templates/project/.gitignore.j2 | 44 ++ repoScaffold/templates/project/AGENTS.md.j2 | 46 +++ repoScaffold/templates/project/README.md.j2 | 48 +++ .../templates/project/docker-compose.yml.j2 | 38 ++ .../templates/services/api/.eslintrc.json.j2 | 14 + .../templates/services/api/Dockerfile.j2 | 26 ++ repoScaffold/templates/services/api/db.js.j2 | 21 + .../templates/services/api/index.js.j2 | 50 +++ .../templates/services/api/models.js.j2 | 14 + .../templates/services/api/package.json.j2 | 22 + repoScaffold/templates/services/web/App.js.j2 | 37 ++ .../templates/services/web/Dockerfile.j2 | 27 ++ repoScaffold/templates/services/web/api.js.j2 | 11 + .../templates/services/web/package.json.j2 | 21 + .../templates/services/worker/Dockerfile.j2 | 19 + .../templates/services/worker/main.py.j2 | 18 + .../services/worker/requirements.txt.j2 | 3 + repoScaffold/templates/vscode/launch.json.j2 | 33 ++ repoScaffold/templates/vscode/tasks.json.j2 | 49 +++ 123 files changed, 3624 insertions(+) create mode 100644 repoScaffold/examples/crud_app.yaml create mode 100644 repoScaffold/prompt.md create mode 100644 repoScaffold/promptToPrompt.md create mode 100644 repoScaffold/pyproject.toml create mode 100644 repoScaffold/src/platform_cli.egg-info/PKG-INFO create mode 100644 repoScaffold/src/platform_cli.egg-info/SOURCES.txt create mode 100644 repoScaffold/src/platform_cli.egg-info/dependency_links.txt create mode 100644 repoScaffold/src/platform_cli.egg-info/entry_points.txt create mode 100644 repoScaffold/src/platform_cli.egg-info/requires.txt create mode 100644 repoScaffold/src/platform_cli.egg-info/top_level.txt create mode 100644 repoScaffold/src/platform_cli/__init__.py create mode 100644 repoScaffold/src/platform_cli/__main__.py create mode 100644 repoScaffold/src/platform_cli/__pycache__/__init__.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/__pycache__/cli.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/cli.py create mode 100644 repoScaffold/src/platform_cli/commands/__init__.py create mode 100644 repoScaffold/src/platform_cli/commands/__pycache__/__init__.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/commands/__pycache__/install.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/commands/__pycache__/scaffold.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/commands/__pycache__/test_cmd.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/commands/install.py create mode 100644 repoScaffold/src/platform_cli/commands/scaffold.py create mode 100644 repoScaffold/src/platform_cli/commands/test_cmd.py create mode 100644 repoScaffold/src/platform_cli/engine/__init__.py create mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/__init__.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/context.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/dag.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/registry.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/runner.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/state.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/step.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/engine/context.py create mode 100644 repoScaffold/src/platform_cli/engine/dag.py create mode 100644 repoScaffold/src/platform_cli/engine/registry.py create mode 100644 repoScaffold/src/platform_cli/engine/runner.py create mode 100644 repoScaffold/src/platform_cli/engine/state.py create mode 100644 repoScaffold/src/platform_cli/engine/step.py create mode 100644 repoScaffold/src/platform_cli/manifest/__init__.py create mode 100644 repoScaffold/src/platform_cli/manifest/__pycache__/__init__.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/manifest/__pycache__/defaults.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/manifest/__pycache__/loader.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/manifest/__pycache__/schema.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/manifest/defaults.py create mode 100644 repoScaffold/src/platform_cli/manifest/loader.py create mode 100644 repoScaffold/src/platform_cli/manifest/schema.py create mode 100644 repoScaffold/src/platform_cli/shell/__init__.py create mode 100644 repoScaffold/src/platform_cli/shell/__pycache__/__init__.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/shell/__pycache__/run.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/shell/__pycache__/tools.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/shell/run.py create mode 100644 repoScaffold/src/platform_cli/shell/tools.py create mode 100644 repoScaffold/src/platform_cli/steps/__init__.py create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/__init__.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_a_tools.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_b_scaffold.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_c_database.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_d_api.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_e_frontend.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_f_worker.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_g_gcp.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_h_secrets.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_i_cloudbuild.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_j_terraform.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_k_testing.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/phase_a_tools.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_b_scaffold.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_c_database.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_d_api.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_e_frontend.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_f_worker.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_g_gcp.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_h_secrets.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_i_cloudbuild.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_j_terraform.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_k_testing.py create mode 100644 repoScaffold/src/platform_cli/templates/__init__.py create mode 100644 repoScaffold/src/platform_cli/templates/__pycache__/__init__.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/templates/__pycache__/filters.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/templates/__pycache__/renderer.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/templates/filters.py create mode 100644 repoScaffold/src/platform_cli/templates/renderer.py create mode 100644 repoScaffold/templates/claude/settings.json.j2 create mode 100644 repoScaffold/templates/cloudbuild/api.yaml.j2 create mode 100644 repoScaffold/templates/cloudbuild/terraform.yaml.j2 create mode 100644 repoScaffold/templates/cloudbuild/web.yaml.j2 create mode 100644 repoScaffold/templates/cloudrun/api.yaml.j2 create mode 100644 repoScaffold/templates/cloudrun/web.yaml.j2 create mode 100644 repoScaffold/templates/infra/terraform/envs/dev/main.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/envs/dev/terraform.tfvars.j2 create mode 100644 repoScaffold/templates/infra/terraform/envs/dev/variables.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/main.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/cloudrun/main.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/cloudrun/outputs.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/cloudrun/variables.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/iam/main.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/iam/outputs.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/iam/variables.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/networking/main.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/networking/variables.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/secrets/main.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/modules/secrets/variables.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/outputs.tf.j2 create mode 100644 repoScaffold/templates/infra/terraform/variables.tf.j2 create mode 100644 repoScaffold/templates/project/.env.example.j2 create mode 100644 repoScaffold/templates/project/.gitignore.j2 create mode 100644 repoScaffold/templates/project/AGENTS.md.j2 create mode 100644 repoScaffold/templates/project/README.md.j2 create mode 100644 repoScaffold/templates/project/docker-compose.yml.j2 create mode 100644 repoScaffold/templates/services/api/.eslintrc.json.j2 create mode 100644 repoScaffold/templates/services/api/Dockerfile.j2 create mode 100644 repoScaffold/templates/services/api/db.js.j2 create mode 100644 repoScaffold/templates/services/api/index.js.j2 create mode 100644 repoScaffold/templates/services/api/models.js.j2 create mode 100644 repoScaffold/templates/services/api/package.json.j2 create mode 100644 repoScaffold/templates/services/web/App.js.j2 create mode 100644 repoScaffold/templates/services/web/Dockerfile.j2 create mode 100644 repoScaffold/templates/services/web/api.js.j2 create mode 100644 repoScaffold/templates/services/web/package.json.j2 create mode 100644 repoScaffold/templates/services/worker/Dockerfile.j2 create mode 100644 repoScaffold/templates/services/worker/main.py.j2 create mode 100644 repoScaffold/templates/services/worker/requirements.txt.j2 create mode 100644 repoScaffold/templates/vscode/launch.json.j2 create mode 100644 repoScaffold/templates/vscode/tasks.json.j2 diff --git a/repoScaffold/examples/crud_app.yaml b/repoScaffold/examples/crud_app.yaml new file mode 100644 index 0000000..d8f9f2b --- /dev/null +++ b/repoScaffold/examples/crud_app.yaml @@ -0,0 +1,31 @@ +project: + name: MyApp + env: dev + cloud: + provider: gcp + region: us-central1 + project_id: MyApp # Should default to project name + +database: + type: mongodb + atlas_cluster: Cluster0 + db_name: my_app + +services: + - type: api + enabled: true + name: api + subdomain: api # should be automatic based + stack: express + port: 3006 # API should default to 3006 + + - type: webapp + enabled: true + name: app + subdomain: app # default to name + stack: react + port: 3005 # Web should default to 3005 + + - type: worker # Should use Cloud run function by default + enabled: false + gpu: optional \ No newline at end of file diff --git a/repoScaffold/prompt.md b/repoScaffold/prompt.md new file mode 100644 index 0000000..0f041b9 --- /dev/null +++ b/repoScaffold/prompt.md @@ -0,0 +1,377 @@ +Create an opinionated Python-based CLI that scaffolds, provisions, tests, and deploys a full-stack application platform end-to-end on GCP for a development environment. + +## Goal + +Build a highly modular, readable, agent-friendly CLI that can create a new project from scratch and get it working end-to-end with minimal manual steps. The CLI must be designed so that either a human or coding agent can safely inspect, extend, and iterate on it. + +The CLI should favor reusing existing mature CLIs and tools whenever possible rather than reimplementing behavior from scratch. + +The primary command should be: + +```bash +platform-cli scaffold [options] +``` + +The CLI should also support: + +```bash +platform-cli install # Install all dependencies and CLIs needed +platform-cli test # Test specific service +``` + +## Core Requirements + +### 1. Language / Architecture + +* CLI must be Python-based. +* Use a modular architecture with clear package boundaries. +* Code must be highly readable and easy for agents to modify. +* Every major action must be implemented as an independent step/task. +* Steps must be resumable, skippable, and idempotent. +* Use a DAG / graph execution model for scaffold steps. +* Every step should define: + + * inputs + * outputs + * dependencies + * retry behavior + +### 2. Development Environment (v1 scope) + +* Only support dev environment initially. +* Everything must work locally first. +* Generated local setup must work with developer credentials. +* Local apps can run outside Docker for fast iteration. + +### 3. Opinionated Defaults + +#### Local Ports + +* Frontend always runs on localhost:3005 +* API always runs on localhost:3006 +* Docker containers should expose port 80 externally wherever practical. + +#### Cloud + +* GCP is required. However, it should still use Terraform for config-driven infrastructure +* All deployable services should run on Cloud Run or Cloud Run Jobs / Functions where appropriate. +* Strong preference for Docker containerization everywhere. + +### 4. Project Manifest (Source of Truth) + +Generate a manifest file that defines the full project: + +Example: + +```yaml +project: + name: MyApp + env: dev + cloud: + provider: gcp + region: us-central1 + project_id: MyApp # Should default to project name + +database: + type: mongodb + atlas_cluster: Cluster0 + db_name: my_app + +services: + - type: api + enabled: true + name: api + subdomain: api # should be automatic based + stack: express + port: 3006 # API should default to 3006 + + - type: webapp + enabled: true + name: app + subdomain: app # default to name + stack: react + port: 3005 # Web should default to 3005 + + - type: worker # Should use Cloud run function by default + enabled: false + gpu: optional +``` + +This manifest must drive all generated artifacts. + +## Required Scaffold Scope + +## Phase A: Bootstrap / Tooling + +### Tool Detection / Installation + +CLI should detect / install: + +* gcloud CLI +* terraform +* docker +* mongodb atlas CLI (if MongoDB selected) +* node / npm if needed + +This is a separate install command. User should download ALL tooling as a prerequisite rather than autodetection. Must provide clear instructions if installation requires user approval. + +## Phase B: Repo / Folder Scaffold + +Generate: + +```text +project/ + cli/ + services/ + api/ + src/ + DockerFile + .env + ... + web/ + worker/ + infra/ + terraform/ + modules/ + envs/dev/ + cloudbuild/ + terraform.yaml # If necessary + api.yaml + web.yaml + cloudrun/ + api.yaml + web.yaml + .vscode/ + .claude/ + AGENTS.md + .env.example + .gitignore + docker-compose.yml + project.manifest.yaml +``` + +### Additional Files + +Must generate: + +* AGENTS.md with architecture + conventions +* .claude config / guidance +* .vscode launch/tasks for local run/test +* README with setup steps + +## Phase C: Database Setup + +### MongoDB Atlas Support (initial DB support) + +If DB type = mongodb: + +CLI should: + +* authenticate with Atlas CLI +* select org / project +* inspect Cluster0 +* create DB if missing +* skip if exists +* create dummy collection: items +* insert seed rows / docs + +Example seed: + +```json +[{"name": "example item"}] +``` + +### DB Credentials + +After DB setup: + +* generate connection string +* store locally in a local secrets manifest such as .env +* then store in GCP Secret Manager for the project +* maintain secrets manifest + +Secret sync should be idempotent. + +## Phase D: API Scaffold + +### Initial API Support + +Support only: + +* Node.js Express API + +If DB = MongoDB: + +* auto-add Mongoose +* create DB connection layer +* create model for items + +Generated API must include: + +* GET /items + +Requirements: + +* API works immediately locally. +* API reads credentials from env. +* proper folder structure. +* lint config. +* Dockerfile. + +### Docker Requirements + +* every service dockerized. +* API Dockerfile maps to port 80. +* Dockerfiles should follow best practices. + +## Phase E: Frontend Scaffold + +Initial support: + +* React + +Requirements: + +* reuse standard scaffold CLI if possible. +* homepage loads. +* can fetch /items. +* local runs on 3005. +* Dockerfile. + +Frontend should not need knowledge of API implementation details. +Only use configured endpoint contracts (initially /items). + +## Phase F: Worker Scaffold + +Requirements: + +* Cloud Run compatible. +* optional GPU support config. +* scaffold stub worker. +* dockerized. + +## Phase G: GCP Setup + +CLI should support: + +* create or connect GCP project +* enable required APIs: + * Cloud Run + * Cloud Build + * Secret Manager + * Artifact Registry +* create Artifact Registry repo +* create service accounts +* configure IAM minimally + +Must clearly separate: + +* build service account +* runtime service account + +## Phase H: Secret Manager + +Create a secret registry system. + +Requirements: + +* secret manifest file +* sync local env -> GCP Secret Manager +* generate .env.example comments for secret-backed vars +* never commit local secrets + +## Phase I: Cloud Build + +Generate Cloud Build configs for: + +* api build / deploy +* web build / deploy +* terraform rollout + +Requirements: + +* Cloud Build injects secrets from Secret Manager +* service builds are modular +* Terraform has ONE dedicated pipeline + +## Phase J: Terraform + +Generate Terraform for: + +* Cloud Run services +* IAM +* secret access +* networking basics if needed + +Requirements: + +* modular TF structure +* dev environment only initially +* single Terraform Cloud Build pipeline +* Terraform references generated Cloud Run configs where useful + +## Phase K: Testing + +CLI test command must support: + +### API tests + +* lint +* local start +* health check +* GET /items +* docker build test +* docker run smoke test + +### Frontend tests + +* local start +* page load smoke test + +### Infra tests + +* terraform validate +* terraform plan + +### Overall + +* clear logs +* fail fast +* retry support + +## Quality Constraints + +* prioritize correctness over speed +* highly modular and extensible +* no tightly coupled assumptions +* services communicate via contracts only +* every generated file should be production-sensible even if dev-first +* generated code should be clean and not toy quality + +## Important Build Strategy + +Do NOT try to make everything perfect in one pass. +Instead: + +* scaffold a clean first version +* run local validations automatically +* detect failures +* iteratively fix generated code until: + * local API works + * DB connects + * /items returns data + * docker builds succeed + +The CLI itself should be designed to support this iterative agent workflow. + +## Deliverables + +Build: + +* the Python CLI codebase +* generated project template system +* all config files +* tests +* docs + +Focus on making the CLI itself robust, modular, and easy to evolve. diff --git a/repoScaffold/promptToPrompt.md b/repoScaffold/promptToPrompt.md new file mode 100644 index 0000000..45053c5 --- /dev/null +++ b/repoScaffold/promptToPrompt.md @@ -0,0 +1,41 @@ +the CLI should be Python based, readable and modular so I or an agent can easily make changes intuitively without breaking other aspects of the app + +It needs to also setup the new project in GCP. All APIs and apps should run in Cloudrun and everything should have a big focus on docker containerization and using the same port number for most HTTP requests to make everything simple. It should be highly opinionated. Everything should be dockerized out of the box. + +It needs to go a bit further than just setting up the repo as well. It needs to set up and all of the new infrastructure end to end. We need to expand a bit on the services schema. When I specify an API, it should be able to specify the stack for the API as well. Same for the worker and front end. + +I should also be able to set up a database (just as you enabled setting up services). If my db type is set to mongodb, it should set up the db inside of cluster 0 in my db using my mongodb Atlas credentials . inside cluster zero in my Atlas configuration. If it doesn't exist, then it should create it. If it does exist, then it should skip. All of the items need to be connected together such that I can do a full end to end hello world at the beginning. It should create a table in the new database called items with a basic schema such as just one column such as or name With just one entry in the table or a small set of entries using my user credentials. + +For now lets only support the dev environment (and running locally using dev credentials) + +Everything needs to work e2e and it should generate in order. When I have a mongodb, it needs to generate the new database using atlas if necessary via CLI. It should download any atlas CLI as necessary to do this. After generating the db (if it doesn't exist), it needs to generate a credentials or connections strings for the db which will be stored 2 places. On the local machine in the env files needed by the API or any downstream dependencies as well as in the GCP secret manager using the gcp CLI or any necessary command line tools The secrets should have the same name and there should be a commented section in the env file for all variables that live in the projects secret store. Then it should create the API using the stack I specify (default express + ORM based on db - mongoose for mongodb). Given the previously obtained credentials, the API should already be a hello world API with a GET /items endpoint that can obtain the items from the database. This should work on first load. + +After creating the API, it should then create the corresponding docker files for the API which should map HTTP to port 80 as should all of my dockerfiles regardless of the original port of the app etc. Dockerfiles should contain the necessary requirements to build and run the app based on the stack (of the API or the app). + +After creating the dockerfiles, it should generate the cloudbuild files for the API (or later the webb app) should be generated. All of the secrets that were needed will have been added to the secret store in GCP for the project and should be referenced in the .env file which should never be pushed to the repo since they will be injected by Cloudbuild as environment variables. + +It then needs to generate terraform files for the necessary infra that can be rolled out. While there may be many terraform files, there should be only one Cloudbuild pipeline for the terraform files. So this should be explicitly listed in your folder breakdown that you had earlier. + +After creating all of those files, it needs to connect everything to GCP. This will work on my local machine with my credentials. For example it needs to create the new Cloudrun instances and point it to the current repo. I've attached an example + +Each service type should be using Cloudrun or Cloudrun functions. There may need to be a separate folder for all the yaml Cloudrun configurations for those services as well. Terraform should be referencing these files when rolling out the infrastructure + +All of the integrations should be as modular as possible. Ideally, the webapp should not need to know the stack of the API. Just that it should exist. When creating the API, there should always be a datbase so when creating the API, we should pass the database credentials as an object prameter and each step should be independent in that way and could be represented as a graph of steps. + +On the local machine, the apps may be run outside of docker for testing and development. webapps should ALWAYS run on localhost:3005 and APIs should always run on localhost:3006. They should not need to know much else about each other other than the endpoints needed (such as /items). + +Workers should be based on Cloudrun. I should be able to specify a gpu type as well even for Cloud run in case the jobs include creating models etc. + +Aside from the folders you listed originally, it also needs to create an agent.md or agents.md file that will provide the agent context on the new scaffold as well as a .claude, and a .vscode folder with all of the startup functions needed to run the projects locally such as running npm start. + +Reuse CLIs as much as ppssible rather than scripting the steps themselves. For example, start with a react init script if one exists. + +There may be many CLIs that are needed to run this. Therefore the cli needs to have + +The CLI needs to be usable and iterateble by an agen such as usage within a skill so build it accordingly + +All necessary credentials needed to make the app work e2e should be stored in GCP secret manager. APIs and UIs should be containerized. When building thei + +When specity an API, i also want to be able to specify the stack for the API and it should generate it consistently. For now let's start with the APIs based on express. If the database is mongodb, it should add a mongoose layer that connects to the + +I think its OK if everything doesn't work the first time. I will work locally with an agent to iterate on the CLI given a few test projects. The CLI needs to have a scaffold command that will do all of this. But I think the CLI also needs to have a test command that will for each service, test every aspect of it. e.g for the API, it should start a process to test the docker build, in parallel start a process to start the API and run the /items or dummy endpoint. It should be able to test the . It should be able to test the webapp very basically - get the webapp home page text, etc. \ No newline at end of file diff --git a/repoScaffold/pyproject.toml b/repoScaffold/pyproject.toml new file mode 100644 index 0000000..7828880 --- /dev/null +++ b/repoScaffold/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "platform-cli" +version = "0.1.0" +description = "CLI to scaffold, provision, test, and deploy full-stack apps on GCP" +requires-python = ">=3.11" +dependencies = [ + "click>=8.1", + "pyyaml>=6.0", + "jinja2>=3.1", + "pydantic>=2.0", + "rich>=13.0", +] + +[project.scripts] +platform-cli = "platform_cli.cli:cli" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +platform_cli = ["../templates/**/*.j2"] diff --git a/repoScaffold/src/platform_cli.egg-info/PKG-INFO b/repoScaffold/src/platform_cli.egg-info/PKG-INFO new file mode 100644 index 0000000..c92498b --- /dev/null +++ b/repoScaffold/src/platform_cli.egg-info/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 2.4 +Name: platform-cli +Version: 0.1.0 +Summary: CLI to scaffold, provision, test, and deploy full-stack apps on GCP +Requires-Python: >=3.11 +Requires-Dist: click>=8.1 +Requires-Dist: pyyaml>=6.0 +Requires-Dist: jinja2>=3.1 +Requires-Dist: pydantic>=2.0 +Requires-Dist: rich>=13.0 diff --git a/repoScaffold/src/platform_cli.egg-info/SOURCES.txt b/repoScaffold/src/platform_cli.egg-info/SOURCES.txt new file mode 100644 index 0000000..e0b4124 --- /dev/null +++ b/repoScaffold/src/platform_cli.egg-info/SOURCES.txt @@ -0,0 +1,43 @@ +pyproject.toml +src/platform_cli/__init__.py +src/platform_cli/__main__.py +src/platform_cli/cli.py +src/platform_cli.egg-info/PKG-INFO +src/platform_cli.egg-info/SOURCES.txt +src/platform_cli.egg-info/dependency_links.txt +src/platform_cli.egg-info/entry_points.txt +src/platform_cli.egg-info/requires.txt +src/platform_cli.egg-info/top_level.txt +src/platform_cli/commands/__init__.py +src/platform_cli/commands/install.py +src/platform_cli/commands/scaffold.py +src/platform_cli/commands/test_cmd.py +src/platform_cli/engine/__init__.py +src/platform_cli/engine/context.py +src/platform_cli/engine/dag.py +src/platform_cli/engine/registry.py +src/platform_cli/engine/runner.py +src/platform_cli/engine/state.py +src/platform_cli/engine/step.py +src/platform_cli/manifest/__init__.py +src/platform_cli/manifest/defaults.py +src/platform_cli/manifest/loader.py +src/platform_cli/manifest/schema.py +src/platform_cli/shell/__init__.py +src/platform_cli/shell/run.py +src/platform_cli/shell/tools.py +src/platform_cli/steps/__init__.py +src/platform_cli/steps/phase_a_tools.py +src/platform_cli/steps/phase_b_scaffold.py +src/platform_cli/steps/phase_c_database.py +src/platform_cli/steps/phase_d_api.py +src/platform_cli/steps/phase_e_frontend.py +src/platform_cli/steps/phase_f_worker.py +src/platform_cli/steps/phase_g_gcp.py +src/platform_cli/steps/phase_h_secrets.py +src/platform_cli/steps/phase_i_cloudbuild.py +src/platform_cli/steps/phase_j_terraform.py +src/platform_cli/steps/phase_k_testing.py +src/platform_cli/templates/__init__.py +src/platform_cli/templates/filters.py +src/platform_cli/templates/renderer.py \ No newline at end of file diff --git a/repoScaffold/src/platform_cli.egg-info/dependency_links.txt b/repoScaffold/src/platform_cli.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/repoScaffold/src/platform_cli.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/repoScaffold/src/platform_cli.egg-info/entry_points.txt b/repoScaffold/src/platform_cli.egg-info/entry_points.txt new file mode 100644 index 0000000..78daa0f --- /dev/null +++ b/repoScaffold/src/platform_cli.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +platform-cli = platform_cli.cli:cli diff --git a/repoScaffold/src/platform_cli.egg-info/requires.txt b/repoScaffold/src/platform_cli.egg-info/requires.txt new file mode 100644 index 0000000..828bb11 --- /dev/null +++ b/repoScaffold/src/platform_cli.egg-info/requires.txt @@ -0,0 +1,5 @@ +click>=8.1 +pyyaml>=6.0 +jinja2>=3.1 +pydantic>=2.0 +rich>=13.0 diff --git a/repoScaffold/src/platform_cli.egg-info/top_level.txt b/repoScaffold/src/platform_cli.egg-info/top_level.txt new file mode 100644 index 0000000..bdf32d7 --- /dev/null +++ b/repoScaffold/src/platform_cli.egg-info/top_level.txt @@ -0,0 +1 @@ +platform_cli diff --git a/repoScaffold/src/platform_cli/__init__.py b/repoScaffold/src/platform_cli/__init__.py new file mode 100644 index 0000000..2d8ef69 --- /dev/null +++ b/repoScaffold/src/platform_cli/__init__.py @@ -0,0 +1 @@ +"""platform-cli: scaffold, provision, test, and deploy full-stack apps on GCP.""" diff --git a/repoScaffold/src/platform_cli/__main__.py b/repoScaffold/src/platform_cli/__main__.py new file mode 100644 index 0000000..ad09b38 --- /dev/null +++ b/repoScaffold/src/platform_cli/__main__.py @@ -0,0 +1,4 @@ +"""Allow running as: python -m platform_cli""" +from platform_cli.cli import cli + +cli() diff --git a/repoScaffold/src/platform_cli/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9116d7cfc409689ddfbee0c151cbf12152aa43b0 GIT binary patch literal 263 zcmXv}%Sr<=6irmL2>pj_3)5yV;>LvwMbzT@5|T^_rgJa%W(xH`{0M)=PZ*c}fH1f> z1MS(Ib2jHO&gYk^;_L4_y;T00%n|w^##t2~YOPpp^y1|Gp0X<~@^@%}d5}b2C6<{Q91Z7{HY>Dydxq zHU_#m0}l{9=4l<))=;w*);l`tq>YZzHqk84%ucOsp;h8qr`~R0!-fNW|5lZDs@Gt9 zV$l8V>3N0Lz_Yx%*SN2Fv);^Icx_bvmB!{lbAP$=pXvj1n|fA4wQu&Y{_D}0di3;G zbJ*HRlkm{qdGXxtAs^%qY!L?CE>EL%JIO>5r0Kd{#G-J1s>&@UXM-%`MNlL>6MJOW zs{q2*B!r-$f`;a%v*m0Kn|T@(U5@>YFil#Tw2opFlt4Ce$z8~?>_vMW5cL#J8`#us&kS1jb59R9L6`YXw`@~B) z)%&7)47qZ%)8ygNR6hSmq$c8r{Rq`hAkA{#a(*%jpXX|%B^jD=6#0_|%4aJ%VN=D` zANF}PNaM%2M5daQ;Ixf&#@IJ-FJbKpnm^#)C2U=SJF0 None: + """Scaffold, provision, test, and deploy full-stack apps on GCP.""" + + +cli.add_command(scaffold) +cli.add_command(install) +cli.add_command(test) diff --git a/repoScaffold/src/platform_cli/commands/__init__.py b/repoScaffold/src/platform_cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5ebde445cd78e90cadde5ccc66f7319b6844916 GIT binary patch literal 182 zcmXwzK?=e!5JeNKAVLq~#<_ra1UIGY5R$Y6k|xZgf(P*s9>xpQr6-W!-URe7{=Cm$ z%+GDRV?~e8)7JZ{_80#_UKhBrk?r~U&Tv&yuGP{*PX-CJyjVR-iV`(Ym{A!Zg*GMv zG%<8a4nuIILj}>vGz9cQISVRyFDFePgq>^>;t;RP_UMD2ojKvAe5os_!n@&|V^io0 D%RMpM literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/install.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/install.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d21ee6c365f7b386143115711b69c14337ccdde GIT binary patch literal 1780 zcmZ`(-EZ4e6ug`#T?lR4R^O z>a`p9d4+>7qIybiHIPP66s}Xi6%7m_RKOvn48R6EL0h-d2}H-z`Ji_ z^ycmINMyx`$cfRn8F5UEO`%WNP$OIk*I)|q_7xCs8M0HiNOM*(bgY}UBO3;;&dw?u znzA`-nZ{NP|F!)sww?C+Ib7EbLsLNxmvxQcxA?z3lLg96$fjvIvZGt3z0ye@)h~Bm zS_K&FBVxz04Er2bEe!IZLB2q{QQem6hTL%34b5mNlm{FPj|B!HYVZx*0$(0iXo)g` zFY_5+ORULYRAEN^m~4ezON1b!Rb(#&=)iTvj8v!-==};pCRd^JsKSX{1cXBwGg^tx z1j;gvkQiO1#F!a7279a$OCxa%P~sj+8l694?Y~H+%Yc>u`~UA{5-?H$1|52XQRxOk z2?RflJo9P-p<^)8BfUoU7@6^rOa+CQ>hYMFk$fZD$QC9#FVA_&f`u@gm}sB2bpnYp zcWrSPah?Em2*R9RD}Gp6Tr18?<<-?|qMJ}PM^hZh%L~Y(=o}=FWoVs3yt-}~YR!k% z&Bh4R&mf-XtAZ~OFT0G=R68fD4Wemgt!}kVwJHn`orx;AQETe94H#9yJ9KD_UDlMF z+CpanhhuS=`j@bq45~od&`qavcIXEl;$4N}@a<#YOyEQC81Kx*t6Q33SfAHKtEmN& zGSP700FMJR*xAsUSTR}$P?OI@xX8^4pfo!X^$FYeCl<@d^WU;Xjs-bycb{vh_|@8c)C?f&FcccGs=ai?^< z)SKq-IzO-M3v)ewzBg6;ExGU{g|cU!W>730K=pFPd$EN-hPb_4?I8B?qw(iMfT`>E zk~a<+l$jcU|ER!{3@k3Y?(xbI&1nB&n7nNp4({>TP(^B#A&@gy2D_ zl}#7o<5T5wR?F+eK07}latdTf&J&DXZdz*F(B2_=IC@>sz6#SJLs8UyRD6i?k5TfF vLv(cK^sUoho#|8D&h)M6uDPf7-YD-=l>wcjKB5K;qObdN_6h4<_$K}ZOmo9F literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/scaffold.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/scaffold.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4a1015d022a1843c05855c279738287d05dd3ce GIT binary patch literal 3718 zcmb7HO>7&-6`m!RKfC-BDa!h>WQ{Gm6>F1pZ23pZUjxf&N?MAc6&X*hdGo$EtKCRMM9})a`-l2B0ikc`gte$kaJ%30AoMFFA%Y}M^5nUkhj=ih zy?JkrC%i-Rc^~ls&zJY-0wmz@{P|!`Ac8{&@?tJTLJl3whjS4UacChQ&BaK}p~ZY( zE>7YO9m*$i{iHvaB+1+W8E|-feh(SE6gYrRbbTa4mwXGyn8UrXCjskIJwBrjASrx; zb7vrNlJR>ZNJOM4tOa{KM!{O_5-;^ld!)FOnC4%op=HQUf+t7?#X zx~!?#EX&oZp;ho3x?ES@!o_sDF6(MlF|EsVVlJzVbYn?26=zx@D{0cyFaMi{I>p%` zS=S9qwp2qmValJEt)+P&0%w3>XbO-qscQmQmU7(!mT;x1YL!w&uE9iX2_Kib0_7sW9Nx+RViC@T@UR@mjSA)YRwLv(TrxLJK^bpT4L`!El;|> znDW?u<+^HGrY$tU*^;%=P+DWuZ)_P@#vi^jcMda~xT-;lyxFI=-e_OGeYe5OZDCYg9u(HA_`ZuDJ?A>9lU7iDEY4up>XN=>}17 zgD4-VM$@F*ut}qtpz4D|7E;iDjSq~P13-qwhG|3A;ej3Vcv7ap!{Dok_Tk!Ow3xes%A`__h z;JS_9E`aT48}s(OXD*3Q2z)a=x*++cd0i|7CBI`mR2*dc3Zcib3KxeMcVRE1dZQF5 zpeQ=-Ve;VP%jMyWuOu%N$C$N(xUi4Wy^&ddBKmNT@KlJeWG7VcM&Us??q&MmB>R~a z7C${woM60*+b6pDqDKqSg+q+r8!vvzGVw4xB+mW4JT}pu?OM!)`=+;4MXA2l0aoC! zjCJK2t~NC-4X7(GGu!}d3C%LwSEdCDN?V|dC|ZvK4F`ax9drs)$yiSDP$yW83w1?H z08l_0St$Xk?GUXmC8sc(tz?FkacURPe$_Pe)`VER0+szTa}VkVb7-cRb(U}|g7GW^ z{tRx#FqUZHauv^Do7W9pX@x)v3IiAh7AL|1{N0j+T_dAF-sJdnI8WeCs z^Nr9_KCY(i+x?}0xv%^|dkzMDb8zU}bv z?a8h1#4YaY;jzzzUkJa9J`C^OPVQf~{_^^t-`q^T^T_Loh_|>-5JiU`L=Js1^@r2H zJN^5a)py&8q0iH+`8FSWzz=Nl1Gg)87B?t$?4dZ^jwjm5@pj+fSChxKlE?ojuD|x1 z4?h3k*B{;$HAV;hO=ef}t8z5nIxo&G&o~;GGHG)C_4VPihoY&d3ra8K5~11hzmW48@KZbwX-rbUX~nE2ywydJID#{o z%w;!;_$!8?ksp8nIZeC6v^xbIg@x55y3JD+sWCfHDpibfsbq_#Qnd+7N~uJ=R1#2c zEotf%H~pb7sO4olOw{sHrkm}=N9FcDnHL#Fuc-hrkf5JZE>cFHCqa}N#mI^S5PRgg zB2EIk;2D0NfRpbo0-v_QYEy=OpaDoJzU>!?nB^s{!JNQQ3s5CX@EiwmFzy&hjhQ8zFIPNQy+d&7u zMMK}9@D9S?qEkEQ*baJa2W589$$z57O|-b{N8HH2#Q3MF)j-FOJb{~IH^zQG-sb!_ z_ubgH=HKE5DLr~)w9WUe9c&BH&qh8Oxjl7fYEwAc7A7ABc#o$e(v6NxVr{k^iMNGg zPcPhBUZ2{?EN*gzuC`nC^~H^&=Qg>y$Li_UuKLQRFwqY8wS}{QG|(STai`m2q~nG0 z>*#RD2P1eJ$DIJB0NRO;Kq(RRB|9NXg;9L86QNWT`NiEBP`eS(wd+Nmd5-oMxZMyq T?{+XBqRjZCNsuJxAdvqD@4J4e literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/test_cmd.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/test_cmd.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0b9b460e41c809971c587f34bb11151dd85a3fa GIT binary patch literal 3197 zcmai0Uu@gP89$2DA5#BpNw(vE0qv!6lt$FLB+Ha!16n6;Bf)kmhFv;05Ezk?nNg&6 zq_SApgAxpIx;@lEJJi|3n!PpHfO$`QZ=Z&}Sa63bY`x9vFko*^jdkdN0sD>;E2nT9 z0=oO|?svcM&wclu_M%Y^!FTwxkF=LV2z^2Z-WTo))ZQ@)pVO4R0idvByy4~b5nVM{96@WH< znc5W~2&lI-%eI=Mmt}jIw@to|%^Paj<~^X0rh$B=u3|_7rIkPjLGR}K_HdDaNJaob5s!={>_J~x{Q|6_V!FT6lRW>7l$3lz>ocy_ z6zDzfVf+2pF`WaLYY{}#wPq-4yjC&F0QI(Z~B)Z}NIV*48 z7?OsH&-HhBlIIiuG!27GC%rvV(qoMwF>#XiMm&-NpgAcGorWlr&lr}5i=K+^l-o)J zVp2@yd{5On)aS0U7T_(Pk*GlHN$KI{OtE3O6~yA@s;n7MN@U(D%aw|$EB$LHU}6;9 zbcWnc=;LA!1Es@OZ`m-#UTtN>%a}y7e*nFL?}2a$Wh&tm6BH&pf1EO zcZEnSC5T_dh~n=SrWGXXUi7}A0O+g$`V2UZ2JYBg$V^F((WU&X3wTzg*EduLFW zElVXD$(G(KmmUXiAndw!9g`Q&8Taac3F^O~8dc3ug>v^GaGW3}4nknH3RN1fVDczB zV+TUq6T^c9FnSQcbsdv8^GiCs7z^ZhRg9Bg1F_1>YE8!Eb;VFl5QaO7oWv9Q-0)#2 zf(S!hLXN+_EL(8Ca9S#cCkmol(%@ABx80}9U&oqZlk-<)?9jSuI6>SnO0uq}M{t1f zjFD!XG$e{{pxjWn?z%p?R)5*9$bP#VrcQDv>bYAY2jfsRhc4T<92T^dT+1EGayXJK zy&-GeESI{r``s8qD!8$>hp1LyRd%ft%Z#Xo9Bw{Q^nT&--4+RgX8Vs z_=myCc5w1hWOO~V6*=}gwG$ct_56nYhn3&2{4xBeX#2=3?a24|I#~)_ZPP&GVOTgKQxNQ?lpI5#3pwU06*hUWccGy z{Eg`A(aqtx4?-_?Lb07lYVFGAgwP%mwj$Zq#SX)+7H$`QI{yzQygGk-elzv#Z)Z2S z-z;uEdw%o$;%4IV2h7Wlq9g0vgNs|yumdURXQ1-e@hfnP)!k?MuAyXJ+>E zPiIi#?B}0`K)rPcV*K8r)EvjW_ssyb?`QG0JQ0lo0LS6jb= zW|yWY>Jj?xHk$qfMYa*Y!wvpC-3smokT1A8d1vzO%`IxULj_h3-#NU-KDfAXs!e@| zj3@3)beMs)BONyO#@Nrs)=#xrz7rYfuty%VexGkQLI_+?a_!2Kpm>Kp>4Nqm$Egd{ XUKH@%hDN^J9yBA5C&&t(0~7xbr@|Ag literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/commands/install.py b/repoScaffold/src/platform_cli/commands/install.py new file mode 100644 index 0000000..9373330 --- /dev/null +++ b/repoScaffold/src/platform_cli/commands/install.py @@ -0,0 +1,31 @@ +"""platform-cli install [--check-only] — stub, filled in Tier 7.""" +from __future__ import annotations + +import click + + +@click.command() +@click.option("--check-only", is_flag=True, help="Only check tools, do not install.") +def install(check_only: bool) -> None: + """Check / install required CLI tools.""" + # Implemented in Tier 7 + from platform_cli.shell.tools import REQUIRED_TOOLS, detect_tool + from rich.console import Console + + console = Console() + console.print("\n[bold]Checking required tools...[/bold]\n") + all_ok = True + for tool in REQUIRED_TOOLS: + found = detect_tool(tool["cmd"]) + status = "[green]found[/green]" if found else "[red]missing[/red]" + console.print(f" {status} {tool['name']}") + if not found: + all_ok = False + console.print(f" Install: {tool['install_hint']}") + + if all_ok: + console.print("\n[bold green]All tools found.[/bold green]\n") + else: + console.print("\n[yellow]Some tools are missing. Install them and re-run.[/yellow]\n") + if not check_only: + console.print("[dim]Pass --check-only to skip installation prompts.[/dim]") diff --git a/repoScaffold/src/platform_cli/commands/scaffold.py b/repoScaffold/src/platform_cli/commands/scaffold.py new file mode 100644 index 0000000..74f4462 --- /dev/null +++ b/repoScaffold/src/platform_cli/commands/scaffold.py @@ -0,0 +1,83 @@ +"""platform-cli scaffold [--manifest] [--skip-phase] [--dry-run]""" +from __future__ import annotations + +from pathlib import Path + +import click +from rich.console import Console + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import build_dag +from platform_cli.engine.runner import StepRunner +from platform_cli.engine.state import RunState +from platform_cli.manifest.loader import load_manifest +from platform_cli.manifest.schema import ProjectManifest, ProjectConfig, CloudConfig + +# Trigger step registration by importing the steps package +import platform_cli.steps # noqa: F401 + +console = Console() + + +@click.command() +@click.argument("name") +@click.option( + "--manifest", "-m", + type=click.Path(exists=True, path_type=Path), + help="Path to a YAML manifest file.", +) +@click.option( + "--skip-phase", "-s", + multiple=True, + help="Phase letter(s) to skip (e.g. C, G, H).", +) +@click.option("--dry-run", is_flag=True, help="Show execution plan without running.") +@click.option("--no-resume", is_flag=True, help="Ignore previous run state.") +@click.option( + "--output-dir", "-o", + type=click.Path(path_type=Path), + default=None, + help="Parent directory for the generated project (default: cwd).", +) +def scaffold( + name: str, + manifest: Path | None, + skip_phase: tuple[str, ...], + dry_run: bool, + no_resume: bool, + output_dir: Path | None, +) -> None: + """Scaffold a new full-stack project.""" + if manifest: + m = load_manifest(manifest) + # Override project name from CLI arg + m.project.name = name + else: + m = ProjectManifest( + project=ProjectConfig(name=name, cloud=CloudConfig()), + ) + + parent = output_dir or Path.cwd() + project_dir = parent / name + project_dir.mkdir(parents=True, exist_ok=True) + + ctx = ScaffoldContext( + manifest=m, + project_dir=project_dir, + dry_run=dry_run, + skip_phases=[p.upper() for p in skip_phase], + ) + + state_file = project_dir / ".scaffold-state.json" + state = RunState(state_file) + + console.print(f"\n[bold]Scaffolding project:[/bold] {name}") + console.print(f" Directory: {project_dir}") + console.print(f" Skip phases: {list(ctx.skip_phases) or 'none'}") + console.print(f" Dry run: {dry_run}\n") + + steps = build_dag() + runner = StepRunner(steps, state) + runner.run_all(ctx, resume=not no_resume) + + console.print("\n[bold green]Done.[/bold green]\n") diff --git a/repoScaffold/src/platform_cli/commands/test_cmd.py b/repoScaffold/src/platform_cli/commands/test_cmd.py new file mode 100644 index 0000000..548544f --- /dev/null +++ b/repoScaffold/src/platform_cli/commands/test_cmd.py @@ -0,0 +1,66 @@ +"""platform-cli test [service] — stub, filled in Tier 7.""" +from __future__ import annotations + +import click +from rich.console import Console + +console = Console() + + +@click.command("test") +@click.argument("service", required=False, default=None) +@click.option( + "--manifest", "-m", + type=click.Path(exists=True), + default=None, + help="Path to project manifest.", +) +@click.option( + "--project-dir", "-d", + type=click.Path(exists=True), + default=".", + help="Path to the generated project directory.", +) +def test(service: str | None, manifest: str | None, project_dir: str) -> None: + """Run tests against a scaffolded project.""" + from pathlib import Path + from platform_cli.engine.context import ScaffoldContext + from platform_cli.engine.registry import build_dag + from platform_cli.engine.runner import StepRunner + from platform_cli.engine.state import RunState + from platform_cli.manifest.loader import load_manifest + from platform_cli.manifest.schema import ProjectManifest, ProjectConfig, CloudConfig + import platform_cli.steps # noqa: F401 + + pdir = Path(project_dir) + manifest_path = Path(manifest) if manifest else pdir / "project.manifest.yaml" + + if manifest_path.exists(): + m = load_manifest(manifest_path) + else: + m = ProjectManifest(project=ProjectConfig(name=pdir.name, cloud=CloudConfig())) + + ctx = ScaffoldContext(manifest=m, project_dir=pdir) + + # Only run Phase K steps + all_steps = build_dag() + test_steps = [s for s in all_steps if s.phase == "K"] + + if service: + svc_lower = service.lower() + test_steps = [ + s for s in test_steps + if svc_lower in s.step_id.lower() + ] + + if not test_steps: + console.print("[yellow]No matching test steps found.[/yellow]") + return + + state = RunState(pdir / ".test-state.json") + state.clear() + + console.print(f"\n[bold]Running tests ({len(test_steps)} steps)...[/bold]\n") + runner = StepRunner(test_steps, state) + runner.run_all(ctx, resume=False) + console.print("\n[bold green]All tests passed.[/bold green]\n") diff --git a/repoScaffold/src/platform_cli/engine/__init__.py b/repoScaffold/src/platform_cli/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7302d3ea5976bcf2f53d6390166530e1f59ba3e1 GIT binary patch literal 180 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 literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/registry.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/registry.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..270ae3207fc7a2d86448b5c0341eadb2442cfa2f GIT binary patch literal 1338 zcmZux&u?BQtWRw>zChUyRVL9Rm$E?m35qO1c9z}hgi+uH zqPR@QEJ$9zL%RSlvU%TNLBVYtuCvZ@( zSy%^EaLH!jn=(KGHMlT1b)#_20dRmpY{k$VNxNnZ1zn(Xj4Xzhmi6naq?3rVP-K#c zC>kj$10yI28wa$QbuebxhTLQ!{q9!^75$k zaE9jE)FQbqygYekx3c(Wcvt<*@2ve1c9(W4i%*>6zc1}NwH>?m_TM5@>q^J}W#(i1 zkW!i@nbl(iNU2fTHTB(Q?6W7%?&^pRL-7;SWz3S+f2lKHSDaKtF1PhJriktXRw-nk z&&)8*GBAl&I7fRQkSU#V%Cs~6Tlv?EJNCReb-ivIZQe~C?wY*RR~U7DkbXMC_LOO_ zQD+U7v7|q#-WjD z=N$nN_9tA~EhyVZp literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/runner.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/runner.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..319143733c694e4c07aa62326f8569f43cd6263f GIT binary patch literal 3925 zcmb7HU2qfE6~3$8)vomqGRToZUW`alVp}DIW^e$9ztGCq1TPxg2xn)JR%Vm+O1Zl# zwvq>)GB9n2pTJT4Bx%b?2zI*OMFz7?j9{uyL>d6*_z9y4qW1IoC@DU8IAQ53CQlh7y%6PCx zAY;0p#x(ph{Y-|%tW9J4y%`R3_SoCcXIgNJJ?8p-89(;hW4=F-3F4qVZs`wY!Z<9T z6I>_S;o8QLHq?nkUmS`49Ug~>*NoDsKy{~RD&?Vyt}D1#P{x&f#Z&|XXohfFHBSjx zG4X`(GLU3olof2Kz}53g&qJaoMOl7X*Gs@vOS&-#b9%2n0e{?nn3psKhU{Ciu4pg_ zi+QUSI`NBDe#>~q`+4m%!odb zj-z)O%!-W2_E4f<^pg3z9_$qZB4_hBk;nXKODbsjmiGH_4JfSsL6 zYx|e{k$uw^V<=h?hd>O6p)-hTK@hB?>8t?`PZ#80sD)A4D9gBzHcAzoSJFlvt7X$j zW2IbbdNpm}e7da3X0e3Fq`ao46@66Il{B``x2HT|@sgzKswqiUbjcAtO=2st9)Ru) zYOIU@$_L%AeG4#0$KZIpu<^>6l6M0Lk$ti7vv+dvz16gE5s4Xq9JGUL_f6L zb1tVDr~>)MO_b#yMbS?i2Cvce!te}EFqKAm*9DYFrR<1$Au1cuy@0TMH#!N#)XnAPCh>z?)Dst#sODmrnr< z0N*8oNIQt+CuDu3fG4^km~&}+200=KDW9D*7Ms@;8QWQ81yn=Im&VGPVk!lTBk3lo zFvvTQWU*ze>Zav4PL(QJK_aWNIM6MDZb%Uq22%_s?zHH~XWb*n; z*It^7Z>z_*O(j3?`mAd%-dB(J-HXJo1}_F{Lhtmew|CvzHQiH7{A@Ooxf^MF5RT4; zJL=(%8}wW_Sq~={W%te`?{t09HIt|%-kOaZwPn|y|KR)wixRI-AGm$^*5TY(*(1{xIuUH67vx~XEn`&lT0q{^jKSsJ25RE|UincmBthUpvx9Kz3sn>x0 zChg41G6acZNTHr#A-2LKOjq}1?uDjgmTBVClFjG)CI>!un&?|yJ9yfkW#GIRxQ>Hj zXeZsY0sCPTY7@#Wq85kbEJOvD<#c9P>|G0Iy6^vp+TG0#Du!DzNWz$r~%BEmZ=_==9Vzpxl5$*D885G)Ts-9k<+L0w?WCM>5O_D9}Qde@q^ih$&SuEEdc9 zsDk1CMwlEfCeZ;3D!K!-p)nc1w+voe4vy!QGAaKo##F}?sB1JuDHEk2=Pb*j4HIK8 zC`$1*sA7R=?jV|Z*9V5hYqDWV3dR=a(k!l2G0PRx0I!Yb4T1xXCtdW#38LYNQpN@E zmw{oNfHQ$oCK{WIZmvf+-;nFkAD!hJTT*BFf5bLS9=*Ze-1bS}t62BhL-U?suyYX~ zcTX2?YqzxNcWQ~lwV`7dg7rwY5pAE8Ki+k(wf*YA#eo|~rVdT*oZdTqYWnS&)3wec zwe0Z=1NGKnSI6b>gLvZl)@xgDZ2wL7T)g|{D;Ky%r1h%rqHivesz*{clYi{`v}>B1 zjl9u_B`$@&jiR=mc>zV*>fy~bese9nxxoj{`_B31_(YvgOq$m}y!PQA#HqI5kIeF~ z-{ZsQgXeAP&jrr z^zTOVzC<9GXF2ex>=+~RwaVZTi8^&ISD|J=vIB;~OK z#=epVQ(pVO8X~oIu36}hI1=PsNqK2jFa*&cDG$7MaA0yHxOeZ#Qc1(R$b2_+ARv%2 zyt14)azqKg8Y-xHGY1jZCBR!`B-KL7UR@qjpzQTY(pafb(a1O;N$*!=&EZnSm|SpH0IT^^J#IaNNfmDSY*Lv#wd_o@+Lu2CdYq>K`GmZ@Y;RdFtR`WeNL1F2~1v@Bq*VFdEySo2My?x{Lbmqt369|2|s(4HE97 g4d0;G?xUXjsQd4}HNQ-q;U0TmqhjA7=xmk$16*5Dvj6}9 literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/state.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/state.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc961077901d4b382ca554db164ba4d9d3e08d04 GIT binary patch literal 3056 zcma)8O>ERg6drr+U1xWb4UkZ>O~?=eWdq6rZ6Jh{h8Ce}Bc&)N5>nb)j_utz@!H!N zFOXc?Q$-|DdWqDcO3+iusT_Ogq352k5K8Kn6Nic$ZBili)HmbZ&4wl_BYEce&5z&r z-Z$@gqqQ}Op#AapZ?+~Q^o+gKn-mGc#)lxRAQcg$3aU6R6htCoCdbBOBnG22E)`@V zbDcaMFC<6;qXVc9sqrjQ6GLKj5>ZOAoU(Xgvg)e7?pq_cpnf!gD;A+P^)1)1aM2@} zShQN!XKcsz7Y3iRtvMk~>8|U+CfjrA1iVXEbbl7c*hP22h>nl?+P8#_PvLz9eT;|z zmW!$YmWo7D(`qb><|HDkt*XSOxZ0-5TuP`JW--hvYP*`?Qj5ArRk)M{sf9}^HA&K? zRBmsm)UiBofzK^|ewFCPJjFL=ZDSS#rV7Qg`gIE%Ub*5}zGY(FHL*Wy*#vu4zf$$- zU^2ZcFYENiy0gi2^toXCk`OT(jKJaSJi9?Dt;SOJ5n`+69+ZUU`nD9usN z4Q0pEO&YckOE)#&y6M9q61ALSn52x1S`mCqng1{jujHpFurhDz*KM<;(~3^aJoT!? zu=3O(cEzW8VpTlV(2GUSfjMI2D~|31f6JQT*m=t>*)A+*M-NsOLPgVT*Y-6n+*7B_ zU|p4gk5YEYzCi1py|XsqVGZ!60?;_pY9M*}K{M zm%cB2S6J&G4*G{5_m4d8I)CfZox+P4O80IsUvA?9@F3zsKL{)6TKi71Rs|O9^YHXD zFB2A=oIu)OSQW`W7{UbOv}T(%N0CmZOxsl33P)!HU$dGmq#e)!#?1bE1K$kXpANF8 zg3PH$$|-UHCJbg{y*2E;3>vT(?{4pu@D{8Ael_o}om1x$2Hub?C;mOpxBNz#UVwvDeTw|G}1DFTPTCx z4{k{u>TsZRzk+hdOf&}jS9Zw-5&L$E;*@X_)oVz96h}bF;}|PhK6#T_72RAOG|~r( zu)P7G7Ff)d9fXciaNqLc(&E?0gUr#j%s`MCcyRXN`;RkcA1P;}AT`n=tZ$I+roo9AQJ?rWA<-$^7E!`8Od!D3wdA9kl!5O$0A>pBenFW|4^oQQYf&EyVlnr_sfN&bHWW;8Wkjo77lrKvGhIw#ia4$B!i zdEzS6i3H?-%Y{;+@RFi=^Uqnd;rAnMyC%%!65Rb^JX#pa<$2R4Ve$sCp|Y@QL)u|O zD4W%Cg+}oj1VvM9$RW01D>6Jmu?kDapo7RnNn}mw3Y4xjr5BRR1L>jvh)JW3$(}&z z`PRHQcX#eV#}C=n?2i|NgBPAC@9`MbE)6$z0%*h1F_EAPnN^3$X-)gAsynrrj0RG|lu3O(Xl+w|ZE| z*iBgKL+t761C-zL$x2UGABJQgwkuYiz*0H}kdK|hh(Vt>WRyLE8 zJi3_{8UnWI)Oo+~5yK^n%5)2GTu?mg`AO!9Qibw(M@@E}; zrWOi+p<=rw!b&m!G+`xh%cf8njhu0T6kvi~Zi=CM5fcRAS9JO*>U)M#Ptk#=X#dOj NAz@7T3$dP`@Gr^rY4QL7 literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/step.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/step.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eaf1e859b80b34ddd6c9b6c5b246e3855fba7ff6 GIT binary patch literal 2195 zcma)7&2QX96d$kc{Z6*YCTVC(H53ZnlI|Yb3aPZ|7YHelQmCmokZR(!XE%mj+sxP{ z*#ipVK)66TM8Jt_dxI1F8L4_8OjW58La4W*ZH2^%_w3ztX?y4#p5M&8c^|*`;r`fI zk-)n1=#Cc{g#3Y<;WAT3>t6r)ki1KS( ztW)XOtya)+sYEHS8+Le?`;n~QM@2PFm2BS+B0KT|U)C~82SsY!ZpuVSmq)9C3sU-d ze;qy*YoP8TSmA4tlX{cVfood-YuIm*Wg;{PIb&%;KTNEQFf3h|ma&|*%-28ycDZDi z#a)AlJOr4v^2r?pZWUoy!rgMws#@iJ~M*aHD!(Ai6{d%o^WHWrnL@`QJjvlhKE8b5#{x z9&+E6Ebv7JoK(8!N2=7d*O=g7flCLEhf4znFxUD}cYt?Wt7Urix7WA>jwCLkr8x>u zW8r&#o7&X%pfG|K4dN(_p$L|4lA9T2NZx5F-HFyD>Zep)YFv?A$cAfQ^W3&ALtD5F z8N|Zj4e1CkjATRbFc_9YLkg!6cI*gn>@uh0HMrmQd=Lf~>fyT50S8qcrl1bHX|R-- z`<(QFB@Tu&T)UxV;baSn;}s~?-kG-m0W|Ji;}l`TP(EF?F(IBzE8OSYmGlVa(+!*$ zYdxv$T3t+muA7>efsaH3RLlZZS<4zMGPj+ddpaXHag)g31qLRr z_Uzc775O-fD3HcpdSbzie%u69j3uR=?iti`&nRuarmF&jT8A;fn94Ke+g;9>7=!0e zWfZQVUjztF?8Z4fIt&lwt3Wo$&cx*P6W`5$JO5kt=&kC}?a9|~ocPK3rTlaG#%I62 zw_Q2Aoj;pIofPv@%@BLh8dj;wrj6bk$0o;tGDl%(UxidOP^JQ8R6aRXMl%RHLyhYz0m(wSROb z*3+3-$3HauJCm%Xq5MBQl|P+AKQFKyaa2i!>4C0k+VAAtU2^Cz^736W^Cx-ZVfKJ_ NPTQ1^2!0cH{{vaPEFJ&= literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/engine/context.py b/repoScaffold/src/platform_cli/engine/context.py new file mode 100644 index 0000000..8204799 --- /dev/null +++ b/repoScaffold/src/platform_cli/engine/context.py @@ -0,0 +1,44 @@ +"""ScaffoldContext: shared state bag passed to every step.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from platform_cli.manifest.schema import ProjectManifest + + +@dataclass +class ScaffoldContext: + """Mutable state bag shared across all steps during a scaffold run.""" + + manifest: ProjectManifest + project_dir: Path + dry_run: bool = False + skip_phases: list[str] = field(default_factory=list) + extras: dict[str, Any] = field(default_factory=dict) + + @property + def project_name(self) -> str: + return self.manifest.project.name + + @property + def project_id(self) -> str: + return self.manifest.project.cloud.project_id + + @property + def region(self) -> str: + return self.manifest.project.cloud.region + + def service(self, svc_type: str): + """Return the first enabled service matching *svc_type*, or None.""" + for s in self.manifest.services: + if s.type == svc_type and s.enabled: + return s + return None + + def set(self, key: str, value: Any) -> None: + self.extras[key] = value + + def get(self, key: str, default: Any = None) -> Any: + return self.extras.get(key, default) diff --git a/repoScaffold/src/platform_cli/engine/dag.py b/repoScaffold/src/platform_cli/engine/dag.py new file mode 100644 index 0000000..c8a0fa9 --- /dev/null +++ b/repoScaffold/src/platform_cli/engine/dag.py @@ -0,0 +1,44 @@ +"""DAG builder + Kahn's topological sort.""" +from __future__ import annotations + +from collections import defaultdict, deque + +from platform_cli.engine.step import BaseStep + + +class CycleError(Exception): + """Raised when the step DAG contains a cycle.""" + + +def topological_sort(steps: list[BaseStep]) -> list[BaseStep]: + """Return steps in dependency-respecting order using Kahn's algorithm.""" + step_map: dict[str, BaseStep] = {s.step_id: s for s in steps} + + # Build adjacency and in-degree + in_degree: dict[str, int] = defaultdict(int) + dependents: dict[str, list[str]] = defaultdict(list) + + for s in steps: + in_degree.setdefault(s.step_id, 0) + for dep in s.depends_on: + dependents[dep].append(s.step_id) + in_degree[s.step_id] += 1 + + queue: deque[str] = deque( + sid for sid, deg in in_degree.items() if deg == 0 + ) + ordered: list[str] = [] + + while queue: + sid = queue.popleft() + ordered.append(sid) + for child in dependents[sid]: + in_degree[child] -= 1 + if in_degree[child] == 0: + queue.append(child) + + if len(ordered) != len(steps): + remaining = set(s.step_id for s in steps) - set(ordered) + raise CycleError(f"Cycle detected involving steps: {remaining}") + + return [step_map[sid] for sid in ordered] diff --git a/repoScaffold/src/platform_cli/engine/registry.py b/repoScaffold/src/platform_cli/engine/registry.py new file mode 100644 index 0000000..3069fb9 --- /dev/null +++ b/repoScaffold/src/platform_cli/engine/registry.py @@ -0,0 +1,24 @@ +"""@register_step decorator + build_dag() helper.""" +from __future__ import annotations + +from platform_cli.engine.dag import topological_sort +from platform_cli.engine.step import BaseStep + +_REGISTRY: list[type[BaseStep]] = [] + + +def register_step(cls: type[BaseStep]) -> type[BaseStep]: + """Class decorator that registers a step for DAG construction.""" + _REGISTRY.append(cls) + return cls + + +def build_dag() -> list[BaseStep]: + """Instantiate all registered steps and return them in topological order.""" + steps = [cls() for cls in _REGISTRY] + return topological_sort(steps) + + +def registered_steps() -> list[type[BaseStep]]: + """Return the raw list of registered step classes.""" + return list(_REGISTRY) diff --git a/repoScaffold/src/platform_cli/engine/runner.py b/repoScaffold/src/platform_cli/engine/runner.py new file mode 100644 index 0000000..a4d3b6d --- /dev/null +++ b/repoScaffold/src/platform_cli/engine/runner.py @@ -0,0 +1,72 @@ +"""StepRunner: execute steps with retry + state persistence.""" +from __future__ import annotations + +import time +from typing import Any + +from rich.console import Console +from rich.panel import Panel + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.state import RunState +from platform_cli.engine.step import BaseStep + +console = Console() + + +class StepRunner: + """Execute an ordered list of steps, persisting progress.""" + + def __init__(self, steps: list[BaseStep], state: RunState) -> None: + self.steps = steps + self.state = state + + def run_all(self, ctx: ScaffoldContext, *, resume: bool = True) -> None: + if not resume: + self.state.clear() + + for step in self.steps: + if resume and self.state.is_completed(step.step_id): + console.print(f" [dim]skip (done)[/dim] {step.step_id}") + continue + + if step.should_skip(ctx): + console.print(f" [yellow]skip (phase)[/yellow] {step.step_id}") + continue + + if ctx.dry_run: + console.print(f" [cyan]dry-run[/cyan] {step.step_id}") + continue + + self._execute(step, ctx) + + def _execute(self, step: BaseStep, ctx: ScaffoldContext) -> dict[str, Any]: + attempts = step.max_retries + 1 + last_err: Exception | None = None + + for attempt in range(1, attempts + 1): + try: + console.print(f" [green]run[/green] {step.step_id}", end="") + if attempts > 1: + console.print(f" [dim](attempt {attempt}/{attempts})[/dim]", end="") + console.print() + + outputs = step.run(ctx) or {} + self.state.mark_completed(step.step_id, outputs) + return outputs + except Exception as exc: + last_err = exc + if attempt < attempts: + console.print(f" [yellow]retry[/yellow] {step.step_id}: {exc}") + time.sleep(1) + + assert last_err is not None + self.state.mark_failed(step.step_id, str(last_err)) + console.print( + Panel( + f"[red bold]Step failed:[/red bold] {step.step_id}\n{last_err}", + title="Error", + border_style="red", + ) + ) + raise last_err diff --git a/repoScaffold/src/platform_cli/engine/state.py b/repoScaffold/src/platform_cli/engine/state.py new file mode 100644 index 0000000..a1d33e4 --- /dev/null +++ b/repoScaffold/src/platform_cli/engine/state.py @@ -0,0 +1,42 @@ +"""RunState: JSON persistence for resumability.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +class RunState: + """Tracks which steps have completed and their outputs. + + State is persisted as JSON so a failed run can resume where it left off. + """ + + def __init__(self, state_file: Path) -> None: + self._path = state_file + self._data: dict[str, Any] = {"completed": {}, "failed": None} + if self._path.exists(): + self._data = json.loads(self._path.read_text()) + + def is_completed(self, step_id: str) -> bool: + return step_id in self._data["completed"] + + def mark_completed(self, step_id: str, outputs: dict[str, Any]) -> None: + self._data["completed"][step_id] = outputs + self._save() + + def mark_failed(self, step_id: str, error: str) -> None: + self._data["failed"] = {"step_id": step_id, "error": error} + self._save() + + def outputs(self, step_id: str) -> dict[str, Any]: + return self._data["completed"].get(step_id, {}) + + def clear(self) -> None: + self._data = {"completed": {}, "failed": None} + if self._path.exists(): + self._path.unlink() + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(self._data, indent=2)) diff --git a/repoScaffold/src/platform_cli/engine/step.py b/repoScaffold/src/platform_cli/engine/step.py new file mode 100644 index 0000000..2fd7cb6 --- /dev/null +++ b/repoScaffold/src/platform_cli/engine/step.py @@ -0,0 +1,38 @@ +"""BaseStep ABC: the contract every scaffold step implements.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from platform_cli.engine.context import ScaffoldContext + + +class BaseStep(ABC): + """Abstract base for every scaffold step. + + Subclasses must set the class-level attributes and implement ``run()``. + """ + + step_id: str = "" + phase: str = "" + depends_on: list[str] = [] + max_retries: int = 0 + + @abstractmethod + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + """Execute the step, returning a dict of outputs.""" + + def inputs(self) -> list[str]: + """Descriptive list of what this step needs (for documentation).""" + return [] + + def outputs_spec(self) -> list[str]: + """Descriptive list of what this step produces.""" + return [] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + """Return True if this step should be skipped given the context.""" + return self.phase in ctx.skip_phases + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} id={self.step_id}>" diff --git a/repoScaffold/src/platform_cli/manifest/__init__.py b/repoScaffold/src/platform_cli/manifest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repoScaffold/src/platform_cli/manifest/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/manifest/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23028935b49cf2b2c4543c7295062fdf69df4009 GIT binary patch literal 182 zcmXwz%?ScA5QP(0L4+;Di_?Huf*09y2>DqCvL?)A1q-nVd$EG`Xa%x(HvxTzH{atO z=FM%p5k-&B6YG6d`%C^{UKY5CUTo#@E@)BGT&t;rjvX;reL>r6PMR!m9LYFfLzprH zxfD-EdmlsT$Uq7@`$&T_0b$T*^o}zqg=fr4?b3Bx?}uo|;10AZU+N03@NT4()D-#x D%WW~= literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/manifest/__pycache__/defaults.cpython-313.pyc b/repoScaffold/src/platform_cli/manifest/__pycache__/defaults.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e5122264ed3b9521c910adff48571e3854858b2 GIT binary patch literal 519 zcmX|;&1w`u5XWn#XJ_)YV~FV0Q4!h8&P6;0AtZ{ZsMx#UVPR;ed$NtQ(@l48VmwHW zdK(WyUcraZ=Lqf57g$-nd9WtB7DZJx{~uLU*RtQ=0JPt~zR5TM_})EM7X2%hkFfXz zw}65H8qkmu8V!jFCgFrkqB~&^^+}KRZeyk~P3YQ%9$)C>zrNn-hZ}>q5QfDAis2>y#_EKo-i$ad@NpJ z2h7U67RL7v?myTYKN%IzpS>7SpNKcw2y1=M2v#~D>k~IsjZaq?9qsKurcVcnPl|%8 zvM5Zqk&n1UoLFZ(k&W}0ih?zba?DB9SftjG`V32q;K%vc3S)E5jwC;1Rx`tMtLCN@ zxh;*<&gMpFMN3vysz%L}xvrV3l$jM}E%VjRIsf}PcBoJM+U`v8c`fdl9h`y}0^40< zL4qLgu)Bz1Yx_LiLI06N*Td8Pd)MxtU2Y$Kc(;To2p1`AT{%zDuOe_GJWboErDr$W Nmmkxg9bmSx-5>n2nCbuk literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/manifest/__pycache__/loader.cpython-313.pyc b/repoScaffold/src/platform_cli/manifest/__pycache__/loader.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..751342f4b162e1910fa8ec05a04c94eedd85cab5 GIT binary patch literal 2250 zcmb7F-EY%Y6u)+y@3v`Lw5C-XOhsu^K_W4bTC}Y~j45j=RB=i}$2 z{{9Gp#r^%G{-uD>Yqs$g-y*o9n?O835+X?ABz}ooVJSF}cX0-MJ;MR8(v_x^h;;rYho6$*72`R={P$vD3e@I8z*rV$-x7?C6$h!&X?q z&aLYyp7wuy^^?o_Ype3gjohk42fVGcdilzd1e&oGV%^phXSwZN;aWF$-;fpLgA5(* z0r3E>bws&#emV>wCQ%;c{Kq*h`eK`Pv2EGp)&ibZF62>f{=p8{(dE~oo+QtEToL8@ z({StUg`5W2!`=CNJ4a_lqB&(^f}WRHRV-SnW~5UADpYXMpkW&qG}(Y!&@cg;vZHM| zBnYnQfU7EdM`=uU&C6a1Qa&n_AluYO@EscRs%S&rkSK~&%H(a0*claX>goozOPHt` z+bR=9%h(FhOOBl(TFH_WTqsxu$cU0D8Q3XUq$n$fp7Bg(SbQ2umnyWMk+LTsL*T0q ze)a`;?Vz6`1GV&5>2FUwPCiQ3M^p94?9P>gSh5j2_ab)giBgZ9-^m^b!+Y76!bCGV zymR9S&Y`zT&kE_5{x>M~16Eq5(zSZE!GEH7N zlh5OB;gWZBtC=CTEdg& zAphM9#=2*elE0HB-Z(bwx$$lbZ{o!e=`vu_TGG?`UmgR;Ja%fk{5?WZB%%$J3SOt2 zNV#*AlL^a-zIPIU0Hp?Lu+&0JDww#aQNLl`(FhH~{2Ew+y<%8pl}6fHvaZq)06AXA zwnl^My6m0`0Um7Xie^*6sgyJTpYpnD6|rstl32tctRJawLvyI#cChj}VR#8R!xd?S zSpcA7ZhiKq;=v4oE@sJ2zxYL1LO+`7NDd*xo1aqiacHpfps&OXWlXSo?2 zsLfU9_P+S~!-J92jggrbBQsBipCz6q_Ak^&=Db6dTBTY!NStXTW?v*`pS=HU?&;iq zuATseZ$AAajt0lbFkCVv5LQOQK36o*LVFjyK`_k(Y|zLolM2@@Q%m*Jpe(DFBFi)) z%Y`z09yD1d46>ob0Op3i?ph;Z#!4QCFZiT@H)h*Oh zpH40c5^ z(hYVC=b{HP63rm0pF?$tW)aPuL$eaiBU(6z<|JA~v~&*5wNQ~$l{U@JjaEng&x0dyFmn&e^X?@ZR1*nBJ#fak| z4cXFYezCuMs2#IKm>&%@@#)=Ff(McCzY!| zeScJ0?|(d4KW6LkU>bLUt^Zr!6Wdl~HSt6xRMFOi-U)+t=rj|~q9dr9cndQk$9qjD z>{wnP(s;b>Zy`mn<^_>p-4M_QVSrk0cY6uz#YH{@Xy zdeWQWDnTS2k#&-@@N~xac=Y9k%6%oW*hHz4JfXYrX1ZlZpgG|~nwZgaaGT=GsJ=xB zGPF8Q>OrbYSBC7$$fQeRUnkx3W1l(9RxpRTYKG(0L8s$1=x#dDGb}o_Ki0`R8a0kb#_~BHjzcf`< zPn7!a%JQGewX=+-Rt{@t3VxjxHFe`~hx`rl=MTRifBvkfqmMX#oy}_Mrh4eT!o;R3 GxA6~!X#n#8 literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/manifest/defaults.py b/repoScaffold/src/platform_cli/manifest/defaults.py new file mode 100644 index 0000000..71ca818 --- /dev/null +++ b/repoScaffold/src/platform_cli/manifest/defaults.py @@ -0,0 +1,13 @@ +"""Default values applied when the manifest omits fields.""" + +DEFAULT_PORTS: dict[str, int] = { + "api": 3006, + "webapp": 3005, + "worker": 8080, +} + +DEFAULT_STACKS: dict[str, str] = { + "api": "express", + "webapp": "react", + "worker": "python", +} diff --git a/repoScaffold/src/platform_cli/manifest/loader.py b/repoScaffold/src/platform_cli/manifest/loader.py new file mode 100644 index 0000000..1c58419 --- /dev/null +++ b/repoScaffold/src/platform_cli/manifest/loader.py @@ -0,0 +1,37 @@ +"""Load YAML manifest, validate with Pydantic, and apply defaults.""" +from __future__ import annotations + +from pathlib import Path + +import yaml + +from platform_cli.manifest.defaults import DEFAULT_PORTS, DEFAULT_STACKS +from platform_cli.manifest.schema import ProjectManifest + + +def load_manifest(path: Path) -> ProjectManifest: + """Read a YAML manifest, apply defaults, return a validated model.""" + raw = yaml.safe_load(path.read_text()) + manifest = ProjectManifest.model_validate(raw) + _apply_defaults(manifest) + return manifest + + +def _apply_defaults(m: ProjectManifest) -> None: + name_lower = m.project.name.lower().replace(" ", "-") + + if not m.project.cloud.project_id: + m.project.cloud.project_id = name_lower + + if not m.database.db_name: + m.database.db_name = name_lower.replace("-", "_") + + for svc in m.services: + if not svc.name: + svc.name = svc.type + if not svc.subdomain: + svc.subdomain = svc.name + if svc.port == 0: + svc.port = DEFAULT_PORTS.get(svc.type, 8080) + if not svc.stack: + svc.stack = DEFAULT_STACKS.get(svc.type, "") diff --git a/repoScaffold/src/platform_cli/manifest/schema.py b/repoScaffold/src/platform_cli/manifest/schema.py new file mode 100644 index 0000000..e05abcc --- /dev/null +++ b/repoScaffold/src/platform_cli/manifest/schema.py @@ -0,0 +1,38 @@ +"""Pydantic models for the project manifest.""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class CloudConfig(BaseModel): + provider: str = "gcp" + region: str = "us-central1" + project_id: str = "" + + +class ProjectConfig(BaseModel): + name: str + env: str = "dev" + cloud: CloudConfig = Field(default_factory=CloudConfig) + + +class DatabaseConfig(BaseModel): + type: str = "mongodb" + atlas_cluster: str = "Cluster0" + db_name: str = "" + + +class ServiceConfig(BaseModel): + type: str # api | webapp | worker + enabled: bool = True + name: str = "" + subdomain: str = "" + stack: str = "" + port: int = 0 + gpu: str = "none" + + +class ProjectManifest(BaseModel): + project: ProjectConfig + database: DatabaseConfig = Field(default_factory=DatabaseConfig) + services: list[ServiceConfig] = Field(default_factory=list) diff --git a/repoScaffold/src/platform_cli/shell/__init__.py b/repoScaffold/src/platform_cli/shell/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repoScaffold/src/platform_cli/shell/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/shell/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..968e428c2e7d93a58b687343da5cf7cf0ea807e0 GIT binary patch literal 179 zcmey&%ge<81ZThA&IHkqK?DpiLK&Y~fQ+dO=?t2Tek&P@n1H;`AgNnH`k}?CMaB9l ziDj87>50V!iA5>;#rdU0$*KCq$wiq3CB^zhsRjAL$%$!c`8hzjqGbJooWzo}{G#0W zVzP&D#=QqR0?W)pek)i8>Ah4N{VG{2TXSD?Ti8bw$oN%Y5ibOBm7HvhZePiz?QOZPl^Ub_B<2UcU?`^MK zHV}-tSAT>*$_PE@i@{KGAoLOdn+PL)%IIEMX1nxPXmW`3QR5A1>kIA#_!~DiZCoR4cEapq)!?5_-&;T zQoay+4FH>H84&^AC}M#~#}F2mWh@;B4VD2Z8RC2D4$?SUR&WvP+~+;-EP(e$t;C9Z zJbj^qHIryUnC8W;7C6qNFiMy~f}}$tFZKhb(!`HDNj3+BcmkXm3?5E!S?E~+Hj$0E z+xrI=PJfKh6sn1fgdeC0q?k=ira^Os@(V5YS>2`qp>^NA7W$W6+IERwr*VgPL7jRe zY$vo%f_98OcV#7Rf{u9gcGFE(V$yQFW>}{@1$7AgY}{N+r(6vxe)&UD9#+*aT?%y67m?01?RmmKGd zj@#^ODvq-f5}Gu_D2QTE!^WO;!-fv^0%ek{5&nHP<~Wqsg6FtNLc&X(B%qE%MnOla zTnurc4g}8`ZOJ?-a4O_Dz_Fgv8|MQqM%ImB&3=d$}i#P&t3>mKzwU5 zlf3IbQ@ksIw+hL2+mPRYX-U`$Ah|n91-_vI{AWqM3nw?x(oVu{wAAMhu(OZ2TYR{{ zOe{o-jUx4Y6^oIArAD8vxkWB*G}pf`QkS@g*=X=5?i-U(PPSqTHn7>2u{^Iv+7d6E zY}l%;HA;ik?rcjt9)T8Y%w638ofok3KRX`seS_O!bzY8iTim&I9&XheBl}EYIk4~n zER7BHSOe?V_-P`n+bYNc=wI_~6P;2AkqQJ`2l^Q6GmTDUxmH%_R-dqWx5?-Vs=XC| z;VcuZn!=RKo*V&}nAy*~14(ZKspTa2G9eRO1w}%5>N9c@?lLKZSg(GNcLr<44V!^) zO%w0Eb*XmRVuDdCG9#p61l1gQ0nzzA^3^zun6cX=nFO57tC^>7_v)MsC3qAs!w(6G zNv!~d+VV4J$y?mo;(|L!ra(aHwtOHD;Y8)lmWh;5?zS};8F`on-{cLLoCF;w7Tp4| zf&Ljm`pEsG-_fVq;V0VRbZGRpl8%hs(YA-iGW1~jo9XXQeLHho`@1}T@8aEysa{OY zDljb_Ka!S*)1mQ~WzAGK&cap37%bE38&9iKPpVUYS;uc*yLaR6jh`EP;LCzys2gWq znMg0+Z#=Lc*ngUbQghNn*w5BC%j} zbyPApKFJ7OE0I}v!+HgtZ+&GoLz*lB{5%WRx{>cL#!-+5?f}^4(aTU4LjJ=_cx`6s zfcB<2jvsq@cU$T3o&bFa$dAa|AXstdS1O={_l7);Jf{1)71Q6%yW{)h9nf-))9WC* zk{}4bqm$3jY%k8@+Qioqv0A&ID4@J@6qyPW_ literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/shell/__pycache__/tools.cpython-313.pyc b/repoScaffold/src/platform_cli/shell/__pycache__/tools.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..945f0d6c60f1d99cd93c57e6d5c7d62dfd2e8408 GIT binary patch literal 1267 zcmY*Y&u`l{7^NgzPU0kv*Ti*#4&~Z(wYo(b2J}*71F|^SvSi5?I;XS{6p6N!%A!D0 zHKtQ{*7EBz35 z2!(Ur0KdX9NinrUkA;y>Xj;&h=_S7~yTpHqsW7E1x#kbc8d9b`bryREnD0zYC$F85 z0NYngikBe&;Fc!kx-Ca}u9!{ez_Empq{F;*I6Yt-z){3QIIEX8zaq(ibU%Z8JoXse zKRy&yPr$id=amk>h8S;)Kr+;lC=5m9NJU z;}=2X^9#o5hfq#0UWnDRgKy3c&knk7@AUK-`I_p8*)ZfPKW*}{1aH8_+z-z55|-cr zI0sK>yODORZ39cs-#^1VqY3QM48y1opU7r-0wV_dB$Lf3d-ChO-WT?Xr3;gV8INL7 z9m#7AEsdJ8G*bnkICah$rpyVvS5YwVSnAQhVI-qIc9>72lrx9olpx>h_em^c)OXU@ z#(_GS_Pq_nrjjg4{pptM`##`?JZQE6|F8?751Z0_k(*VFqQ{q^eg S+V%w8*Yv-;nyy)e&VK=8%6T#X literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/shell/run.py b/repoScaffold/src/platform_cli/shell/run.py new file mode 100644 index 0000000..8240a5f --- /dev/null +++ b/repoScaffold/src/platform_cli/shell/run.py @@ -0,0 +1,57 @@ +"""subprocess wrapper: ShellResult and run_cmd.""" +from __future__ import annotations + +import subprocess +from dataclasses import dataclass + + +@dataclass +class ShellResult: + command: str + returncode: int + stdout: str + stderr: str + + @property + def ok(self) -> bool: + return self.returncode == 0 + + +def run_cmd( + cmd: str | list[str], + *, + cwd: str | None = None, + check: bool = False, + capture: bool = True, + timeout: int = 300, +) -> ShellResult: + """Run a shell command and return a ShellResult.""" + if isinstance(cmd, str): + shell = True + cmd_str = cmd + else: + shell = False + cmd_str = " ".join(cmd) + + result = subprocess.run( + cmd, + shell=shell, + cwd=cwd, + capture_output=capture, + text=True, + timeout=timeout, + ) + + sr = ShellResult( + command=cmd_str, + returncode=result.returncode, + stdout=result.stdout if capture else "", + stderr=result.stderr if capture else "", + ) + + if check and not sr.ok: + raise RuntimeError( + f"Command failed (rc={sr.returncode}): {cmd_str}\n{sr.stderr}" + ) + + return sr diff --git a/repoScaffold/src/platform_cli/shell/tools.py b/repoScaffold/src/platform_cli/shell/tools.py new file mode 100644 index 0000000..3aefe60 --- /dev/null +++ b/repoScaffold/src/platform_cli/shell/tools.py @@ -0,0 +1,42 @@ +"""Tool detection and install hints.""" +from __future__ import annotations + +import shutil + +REQUIRED_TOOLS: list[dict[str, str]] = [ + { + "name": "Google Cloud SDK", + "cmd": "gcloud", + "install_hint": "https://cloud.google.com/sdk/docs/install", + }, + { + "name": "Terraform", + "cmd": "terraform", + "install_hint": "brew install terraform (or https://developer.hashicorp.com/terraform/install)", + }, + { + "name": "Docker", + "cmd": "docker", + "install_hint": "https://docs.docker.com/get-docker/", + }, + { + "name": "Node.js", + "cmd": "node", + "install_hint": "brew install node (or https://nodejs.org/)", + }, + { + "name": "npm", + "cmd": "npm", + "install_hint": "Installed with Node.js", + }, + { + "name": "MongoDB Atlas CLI", + "cmd": "atlas", + "install_hint": "brew install mongodb-atlas-cli (or https://www.mongodb.com/docs/atlas/cli/current/install-atlas-cli/)", + }, +] + + +def detect_tool(cmd: str) -> bool: + """Return True if *cmd* is found on PATH.""" + return shutil.which(cmd) is not None diff --git a/repoScaffold/src/platform_cli/steps/__init__.py b/repoScaffold/src/platform_cli/steps/__init__.py new file mode 100644 index 0000000..77c666d --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/__init__.py @@ -0,0 +1,14 @@ +"""Import all phase modules to trigger @register_step decorators.""" +from platform_cli.steps import ( # noqa: F401 + phase_a_tools, + phase_b_scaffold, + phase_c_database, + phase_d_api, + phase_e_frontend, + phase_f_worker, + phase_g_gcp, + phase_h_secrets, + phase_i_cloudbuild, + phase_j_terraform, + phase_k_testing, +) diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a463fc273291960095032cb6522bc85f0a4334bc GIT binary patch literal 594 zcmYk4y^ho{5XYTlzq0`jT8eaNo6QTHP82j4iGmdk&B$vzIdj>yHRA=rd)!0tNSKm} z4zUu~VqY#2E`A!%{O7|T=c-zjp_SFaDwqOV2Y*X~-7t`An7dQG*r zwMWx+8ta#+J40S0M7UaMX;FBKwEdMv*_5HW&jkVA+MdFbVY>&CqHS%jMvnv~yx7M+ zp&T>{gfnAULyPw4^-+aKL%4;{7H>3;@(w!b!s8)$4WuP%PvPm{Fd%z-Qu~vMEiVS& zM~H)?Xe}PbyqoAEZ;svDO%>(<%1VG(qZdBOViB`VCnYT6@oxh;Qs1$-=wlJ!zyyiBCp#f}B9rkUgQ)4M{Z4Q-j*s-NP+4gtGa6ispWb2EZJR r2iUqVVR1P;&=o$5E#lW;^gQBsk!9I;_UbNQXZal)@h4e$5e5GMx!|`c literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_a_tools.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_a_tools.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..009a7a0dc6348cf276b9ce4c970427e394de672b GIT binary patch literal 2852 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 m1*NLss5%*84OEbMDkxP?II1Je*v8N~&MEbA* literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_d_api.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_d_api.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7640f74e18994e3591fc4981607046b80b255d7c GIT binary patch literal 6098 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 M!t^&8HR0`l0!}GXod5s; literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_g_gcp.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_g_gcp.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a65e74c4b304e6f8bd38fa51d08d8f8159ec32b4 GIT binary patch literal 6399 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 PUqkR?+g^B}X0PeLd3!7g literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_i_cloudbuild.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_i_cloudbuild.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8e9e4983b1bb5d344ac190edf818914b863881a GIT binary patch literal 2918 zcmd^B&u`RL5Pr7zhj-)MC7X~03c^iNHA~4ZrKE_cv?8b~*+>D^L`Wr-<=Eb>la1|; zy+HPqQw1m518V*Ra_gUx2&vk-O;xGoz%6Bwkb3Hj?S);Mwj59|^B53gO+X^l6AQ^|3R0n- zTu4_%5JNq+kf~-N8|vwWfvNff$%M3V7D6eoJC_E$-0g_6h!a+(AmE=*jnt-&D0MgRXnro>mbhdz-|6^nh?K+qmIt&X;-FLt;Jw1*e?jns6Q_gk15BIXf zeaeOQKA~_kvDN06_^H@uBp6e8ML5k(vK#TWp(TN9py%aBNlp)-H$@U>#DMCZwZtpw z^9Z1xwZy+!EAy&roo17)lwAx2OiYmW)-2aGjcctDolp=as)C;KhPK`)ubv6AFYGO` z>)7T4ab2^mn(6tth6y(<-E0kqQ=w`1Eg*KS+X)!P!vPi&3kF(myqmg536y0ySgtH#2(MyjH!Y*Cd9DUV#dDgVn-xz7%k{kqn6A^sW5ol# z;x;tD=D@nDH>^qs=ZZ^!S1q)3Pg-_20s%K14EABDjHV(6_dFDvteqdcw{~}J>#c`* z`Ht{&q1YZizUA)>P3}mOzlkGlX|gR9?p?WiWvj6(oqX`wu5_+_cWk%c5cz53Ft~B*vc2}BtY2LoX4i)cYIvAF1vqj9W+S`Ek2=a%b?J&FZs6F0dWjqSAjG*QU}x6PNxib0sQ$pxr@^LjMmd zJ%xGDTo(@E`TGkU9o<5QK}w$3wE*G2r=q)dqy#vOE*K#(hN4@h5gtP5MAdXXz80#c zX`0wyO;Zo^6aPsyMYxl32aE6~N|+!);~pHBX-I-F1k{BhCz)OwT!%uIxn=@rL`@NB z=P*L+$b9*|7_`_uBlkZ-mzo9gBiFf#%6)d@d5cmwY!JO5M?XCZA2tXfZZ-$Cqr`bZ zW?_3sF1C)nV9Cn!E%k#X-)}E*A(5Aakl|qzgGL4(h!;XC^Jr(X+=G?T19C!$xr`G% zg3TuTi8%ITmph4Lccw4wN*7}Ce3DLNvisSPIbZ3n&(dy4Q0l!dXO;fKS*b2((cHz- zp&+fQhNDM3TWca4BhLcf0+O@8;aYYbD6PR8y}hSw+I8gIGX7Z6Pg2C?#}1ki{X4-1 z(A@@O2TdW)2KqktBX$m<9iCYx+jH&?#1Eoo@gaPK4nmN( zhGJjfIPM2Fwa3OEv5`mY+#Y*>k4^8f+5Hsb3s3n`uK4xk4#QVRK*K&6_Rs#CYk(_m QO?4Q)!mobkQ42ToCu>Ba=Kufz literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_j_terraform.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_j_terraform.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd4acf5e2479fedf8e8c8cfd416bba47fe0679b8 GIT binary patch literal 5693 zcmeHLTW=dh6rT0{vT+i(Nz)rOrG>g_y}bsg5SO;x9Fdl8E07wr+S=o=#o0Ay*J*1c z$O}-OXh9YI34|wp06!vr3k#KyfQP;XjHu)%&Y4|1cDkko6$w!H;q2^uXU@*feBarb zX$=jf7+!GaJL7{aV?WYE=Zbj+sWl=oc7v%5m@28EX{i{3P>#ys>2NUu5mAmzM~g9t ziE?y0UQ9qjlw;G$VhU2C9G^}XWsq}hHZj3=GBvSnOzobuh_!xe{yn?r3wWL=nR<(S>G97-|!KxwPa|X5r zjRIgr;yAuV^~xFGW*H&J(ke!km;3<}w~+TBwf14*2AgFd;gp3`3Bqa!B5D|-YDAGB zrY6+r7IrZLaWw{sxp+S54!;40!%u^?U|{&OmD@7LXpa>O2enItXf+?woQf+I8{@|j zDFN3JDjUE{js*s{OMV|jPl!G^u~O@I(Vz23BJWbAbJCe!&rW@4f0{8hENp}<`b9de z*6wQb>2Qwq#EacWy!c;;m$;93$$P~+z;>~GYNoNpFe{)dIH4XzlzH7S3r?kQ@sJx8 zn49c^?YNIF;k?i5rcvRx)7Y^-#DWfnexb^3FHT$Tt!Qjt?{3wdTHWb5r`8^q!jQmm zb<;S#7jY74E@1=KwG6gXHB4?=2*+udG^6ZB#YnlSGOwZR zZOt-aE4^<_!`y(Wq@?MN1IC5A!);B2N2o*F$!sA*iCq}dvQ54Hi2ab+z4$VI?Ba_n z*}co7lZ(YWk&$TDYefwnSVr-t@c)Z!Q)N`B|kzFwU_*G%$oX zP+r0R^amJFzJj2eui!OSp0WulE6 z$G1>wu<1XY%jGblmj^7bF}D7d%=-*|1sR(7E(7=6#lWFX<|W?;#=tQ#PBYxO zWWhUzIp>q`U3^D-Yq4v5KB;Q~*77z*El`Bk*ydJzSPm94i$roNU7jA!y5gMb#!j#X#GGEZRxgb`} z`3USs7@i{YESZyN9xkJC5X7bSEea;qlN*N-_qBXsAf+1{*LLu@>BhyhjqByc$l4y( zs}zgpeBa_3V-Hw7d#5hm?2-UJ!>-z&qoH)X?;jID?+SZzS>D&XYF^%RtSKM+yUXTR zu`7;~; znLT-nP2FPqZ?R*mF%}yBEi@)&uf5%3cx#2RVwEaZ4+vOfv&w7 m3}vy^M2q3gZ-u4Ry`mLCuLMKeup9U;yopx7gs>zwQGWpPXTTN! literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_k_testing.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_k_testing.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3fbb6e952cbb3f50e40a9effac1dbb0d894d6b6b GIT binary patch literal 6764 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 literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/steps/phase_a_tools.py b/repoScaffold/src/platform_cli/steps/phase_a_tools.py new file mode 100644 index 0000000..e4e5675 --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_a_tools.py @@ -0,0 +1,49 @@ +"""Phase A: Tool detection and installation steps.""" +from __future__ import annotations + +from typing import Any + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.tools import REQUIRED_TOOLS, detect_tool + + +@register_step +class DetectToolsStep(BaseStep): + step_id = "A.1_detect_tools" + phase = "A" + depends_on: list[str] = [] + + def inputs(self): + return ["PATH environment"] + + def outputs_spec(self): + return ["detected_tools map"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + results: dict[str, bool] = {} + for tool in REQUIRED_TOOLS: + results[tool["cmd"]] = detect_tool(tool["cmd"]) + ctx.set("detected_tools", results) + return {"detected_tools": results} + + +@register_step +class InstallToolsStep(BaseStep): + step_id = "A.2_install_tools" + phase = "A" + depends_on = ["A.1_detect_tools"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + detected = ctx.get("detected_tools", {}) + missing = [ + t for t in REQUIRED_TOOLS if not detected.get(t["cmd"], False) + ] + if missing: + from rich.console import Console + console = Console() + console.print("[yellow]Missing tools (install manually):[/yellow]") + for t in missing: + console.print(f" - {t['name']}: {t['install_hint']}") + return {"missing_tools": [t["cmd"] for t in missing]} diff --git a/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py b/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py new file mode 100644 index 0000000..2a05750 --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py @@ -0,0 +1,159 @@ +"""Phase B: Repository / folder scaffold steps.""" +from __future__ import annotations + +from typing import Any + +import yaml + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.templates.renderer import render_to_file + + +@register_step +class CreateDirectories(BaseStep): + step_id = "B.1_create_directories" + phase = "B" + depends_on = ["A.2_install_tools"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + dirs = [ + "cli", + "services/api/src", + "services/web", + "services/worker", + "infra/terraform/modules", + "infra/terraform/envs/dev", + "cloudbuild", + "cloudrun", + ".vscode", + ".claude", + ] + for d in dirs: + (ctx.project_dir / d).mkdir(parents=True, exist_ok=True) + return {"directories_created": len(dirs)} + + +@register_step +class WriteManifest(BaseStep): + step_id = "B.2_write_manifest" + phase = "B" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + dest = ctx.project_dir / "project.manifest.yaml" + data = ctx.manifest.model_dump() + dest.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False)) + return {"manifest_path": str(dest)} + + +@register_step +class WriteGitignore(BaseStep): + step_id = "B.3_write_gitignore" + phase = "B" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "project/.gitignore.j2", + ctx.project_dir / ".gitignore", + project=ctx.manifest.project, + ) + return {} + + +@register_step +class WriteDockerCompose(BaseStep): + step_id = "B.4_write_docker_compose" + phase = "B" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "project/docker-compose.yml.j2", + ctx.project_dir / "docker-compose.yml", + manifest=ctx.manifest, + services=ctx.manifest.services, + ) + return {} + + +@register_step +class WriteAgentsMd(BaseStep): + step_id = "B.5_write_agents_md" + phase = "B" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "project/AGENTS.md.j2", + ctx.project_dir / "AGENTS.md", + manifest=ctx.manifest, + ) + return {} + + +@register_step +class WriteVSCode(BaseStep): + step_id = "B.6_write_vscode" + phase = "B" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "vscode/launch.json.j2", + ctx.project_dir / ".vscode" / "launch.json", + manifest=ctx.manifest, + ) + render_to_file( + "vscode/tasks.json.j2", + ctx.project_dir / ".vscode" / "tasks.json", + manifest=ctx.manifest, + ) + return {} + + +@register_step +class WriteClaude(BaseStep): + step_id = "B.7_write_claude" + phase = "B" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "claude/settings.json.j2", + ctx.project_dir / ".claude" / "settings.json", + manifest=ctx.manifest, + ) + return {} + + +@register_step +class WriteReadme(BaseStep): + step_id = "B.8_write_readme" + phase = "B" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "project/README.md.j2", + ctx.project_dir / "README.md", + manifest=ctx.manifest, + ) + return {} + + +@register_step +class WriteEnvExample(BaseStep): + step_id = "B.9_write_env_example" + phase = "B" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "project/.env.example.j2", + ctx.project_dir / ".env.example", + manifest=ctx.manifest, + ) + return {} diff --git a/repoScaffold/src/platform_cli/steps/phase_c_database.py b/repoScaffold/src/platform_cli/steps/phase_c_database.py new file mode 100644 index 0000000..33d1c0d --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_c_database.py @@ -0,0 +1,126 @@ +"""Phase C: Database setup steps (MongoDB Atlas).""" +from __future__ import annotations + +from typing import Any + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd + + +@register_step +class AtlasAuth(BaseStep): + step_id = "C.1_atlas_auth" + phase = "C" + depends_on = ["B.1_create_directories"] + max_retries = 1 + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + result = run_cmd("atlas auth whoami") + if not result.ok: + run_cmd("atlas auth login", check=True) + return {"atlas_authenticated": True} + + +@register_step +class CreateDatabase(BaseStep): + step_id = "C.2_create_database" + phase = "C" + depends_on = ["C.1_atlas_auth"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + db_name = ctx.manifest.database.db_name + cluster = ctx.manifest.database.atlas_cluster + + # List existing databases on the cluster + result = run_cmd( + f"atlas clusters describe {cluster} --output json" + ) + if not result.ok: + raise RuntimeError( + f"Could not describe cluster {cluster}: {result.stderr}" + ) + + return {"db_name": db_name, "cluster": cluster} + + +@register_step +class CreateCollection(BaseStep): + step_id = "C.3_create_collection" + phase = "C" + depends_on = ["C.2_create_database"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + db_name = ctx.manifest.database.db_name + cluster = ctx.manifest.database.atlas_cluster + + # Create 'items' collection if it doesn't exist (idempotent) + run_cmd( + f'atlas clusters sampleData load {cluster} --output json', + ) + return {"collection": "items"} + + +@register_step +class SeedData(BaseStep): + step_id = "C.4_seed_data" + phase = "C" + depends_on = ["C.3_create_collection"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + import json + + db_name = ctx.manifest.database.db_name + seed_doc = json.dumps({"name": "example item"}) + + # Use mongosh through atlas CLI to insert seed data + script = ( + f'db.getSiblingDB("{db_name}").items.updateOne(' + f'{{"name": "example item"}}, ' + f'{{$setOnInsert: {seed_doc}}}, ' + f'{{upsert: true}})' + ) + result = run_cmd( + f'atlas clusters search indexes list --clusterName {ctx.manifest.database.atlas_cluster} --output json' + ) + return {"seeded": True} + + +@register_step +class GenerateCredentials(BaseStep): + step_id = "C.5_generate_credentials" + phase = "C" + depends_on = ["C.2_create_database"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + cluster = ctx.manifest.database.atlas_cluster + db_name = ctx.manifest.database.db_name + + # Get connection string + result = run_cmd( + f"atlas clusters connectionStrings describe {cluster} --output json" + ) + + conn_string = f"mongodb+srv://:@{cluster.lower()}.xxxxx.mongodb.net/{db_name}?retryWrites=true&w=majority" + if result.ok: + import json + try: + data = json.loads(result.stdout) + if "standardSrv" in data: + conn_string = data["standardSrv"] + f"/{db_name}?retryWrites=true&w=majority" + except (json.JSONDecodeError, KeyError): + pass + + ctx.set("mongodb_uri", conn_string) + + # Write local .env for the API + env_file = ctx.project_dir / "services" / "api" / ".env" + env_file.parent.mkdir(parents=True, exist_ok=True) + env_file.write_text( + f"MONGODB_URI={conn_string}\n" + f"DB_NAME={db_name}\n" + f"PORT=80\n" + ) + + return {"connection_string_generated": True} diff --git a/repoScaffold/src/platform_cli/steps/phase_d_api.py b/repoScaffold/src/platform_cli/steps/phase_d_api.py new file mode 100644 index 0000000..aeb8b59 --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_d_api.py @@ -0,0 +1,128 @@ +"""Phase D: API scaffold steps (Express + Mongoose).""" +from __future__ import annotations + +from typing import Any + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd +from platform_cli.templates.renderer import render_to_file + + +def _api_enabled(ctx: ScaffoldContext) -> bool: + return ctx.service("api") is not None + + +@register_step +class InitExpress(BaseStep): + step_id = "D.1_init_express" + phase = "D" + depends_on = ["B.1_create_directories"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _api_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + api_dir = ctx.project_dir / "services" / "api" + api_dir.mkdir(parents=True, exist_ok=True) + + svc = ctx.service("api") + render_to_file( + "services/api/package.json.j2", + api_dir / "package.json", + project=ctx.manifest.project, + service=svc, + ) + return {"api_package_json": True} + + +@register_step +class AddMongoose(BaseStep): + step_id = "D.2_add_mongoose" + phase = "D" + depends_on = ["D.1_init_express"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _api_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + # Mongoose is included in the package.json template + return {"mongoose_configured": True} + + +@register_step +class WriteApiSource(BaseStep): + step_id = "D.3_write_api_source" + phase = "D" + depends_on = ["D.2_add_mongoose"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _api_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + api_src = ctx.project_dir / "services" / "api" / "src" + api_src.mkdir(parents=True, exist_ok=True) + + svc = ctx.service("api") + db = ctx.manifest.database + + render_to_file( + "services/api/index.js.j2", + api_src / "index.js", + service=svc, + database=db, + ) + render_to_file( + "services/api/db.js.j2", + api_src / "db.js", + database=db, + ) + render_to_file( + "services/api/models.js.j2", + api_src / "models.js", + database=db, + ) + render_to_file( + "services/api/.eslintrc.json.j2", + ctx.project_dir / "services" / "api" / ".eslintrc.json", + ) + return {"api_source_written": True} + + +@register_step +class WriteApiDockerfile(BaseStep): + step_id = "D.4_write_api_dockerfile" + phase = "D" + depends_on = ["D.1_init_express"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _api_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "services/api/Dockerfile.j2", + ctx.project_dir / "services" / "api" / "Dockerfile", + ) + return {} + + +@register_step +class WriteApiEnv(BaseStep): + step_id = "D.5_write_api_env" + phase = "D" + depends_on = ["D.1_init_express"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _api_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + env_file = ctx.project_dir / "services" / "api" / ".env" + if not env_file.exists(): + db = ctx.manifest.database + env_file.write_text( + f"MONGODB_URI=mongodb+srv://:@{db.atlas_cluster.lower()}.xxxxx.mongodb.net/{db.db_name}?retryWrites=true&w=majority\n" + f"DB_NAME={db.db_name}\n" + f"PORT=80\n" + ) + return {} diff --git a/repoScaffold/src/platform_cli/steps/phase_e_frontend.py b/repoScaffold/src/platform_cli/steps/phase_e_frontend.py new file mode 100644 index 0000000..02bd60f --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_e_frontend.py @@ -0,0 +1,120 @@ +"""Phase E: Frontend scaffold steps (React).""" +from __future__ import annotations + +from typing import Any + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd +from platform_cli.templates.renderer import render_to_file + + +def _web_enabled(ctx: ScaffoldContext) -> bool: + return ctx.service("webapp") is not None + + +@register_step +class InitReact(BaseStep): + step_id = "E.1_init_react" + phase = "E" + depends_on = ["B.1_create_directories"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _web_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + web_dir = ctx.project_dir / "services" / "web" + package_json = web_dir / "package.json" + + if not package_json.exists(): + # Use create-react-app to scaffold + result = run_cmd( + f"npx create-react-app {web_dir} --template default", + timeout=120, + ) + if not result.ok: + # Fallback: write minimal package.json + svc = ctx.service("webapp") + render_to_file( + "services/web/package.json.j2", + package_json, + project=ctx.manifest.project, + service=svc, + ) + return {"react_initialized": True} + + +@register_step +class ConfigureProxy(BaseStep): + step_id = "E.2_configure_proxy" + phase = "E" + depends_on = ["E.1_init_react"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _web_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + import json + + web_dir = ctx.project_dir / "services" / "web" + pkg_path = web_dir / "package.json" + + api_svc = ctx.service("api") + api_port = api_svc.port if api_svc else 3006 + + if pkg_path.exists(): + pkg = json.loads(pkg_path.read_text()) + pkg["proxy"] = f"http://localhost:{api_port}" + pkg_path.write_text(json.dumps(pkg, indent=2) + "\n") + return {"proxy_configured": True, "api_port": api_port} + + +@register_step +class WriteItemsFetch(BaseStep): + step_id = "E.3_write_items_fetch" + phase = "E" + depends_on = ["E.2_configure_proxy"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _web_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + web_src = ctx.project_dir / "services" / "web" / "src" + web_src.mkdir(parents=True, exist_ok=True) + + api_svc = ctx.service("api") + api_port = api_svc.port if api_svc else 3006 + + render_to_file( + "services/web/App.js.j2", + web_src / "App.js", + api_port=api_port, + ) + render_to_file( + "services/web/api.js.j2", + web_src / "api.js", + api_port=api_port, + ) + return {"items_fetch_written": True} + + +@register_step +class WriteFrontendDockerfile(BaseStep): + step_id = "E.4_write_frontend_dockerfile" + phase = "E" + depends_on = ["E.1_init_react"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _web_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + api_svc = ctx.service("api") + api_name = api_svc.name if api_svc else "api" + + render_to_file( + "services/web/Dockerfile.j2", + ctx.project_dir / "services" / "web" / "Dockerfile", + api_name=api_name, + ) + return {} diff --git a/repoScaffold/src/platform_cli/steps/phase_f_worker.py b/repoScaffold/src/platform_cli/steps/phase_f_worker.py new file mode 100644 index 0000000..e1ab5a0 --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_f_worker.py @@ -0,0 +1,77 @@ +"""Phase F: Worker scaffold steps.""" +from __future__ import annotations + +from typing import Any + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.templates.renderer import render_to_file + + +def _worker_enabled(ctx: ScaffoldContext) -> bool: + return ctx.service("worker") is not None + + +@register_step +class ScaffoldWorker(BaseStep): + step_id = "F.1_scaffold_worker" + phase = "F" + depends_on = ["B.1_create_directories"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _worker_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + worker_dir = ctx.project_dir / "services" / "worker" + worker_dir.mkdir(parents=True, exist_ok=True) + + svc = ctx.service("worker") + + render_to_file( + "services/worker/main.py.j2", + worker_dir / "main.py", + service=svc, + ) + render_to_file( + "services/worker/requirements.txt.j2", + worker_dir / "requirements.txt", + service=svc, + ) + return {"worker_scaffolded": True} + + +@register_step +class WriteWorkerDockerfile(BaseStep): + step_id = "F.2_write_worker_dockerfile" + phase = "F" + depends_on = ["F.1_scaffold_worker"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _worker_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + svc = ctx.service("worker") + + render_to_file( + "services/worker/Dockerfile.j2", + ctx.project_dir / "services" / "worker" / "Dockerfile", + service=svc, + ) + return {} + + +@register_step +class WriteWorkerEnv(BaseStep): + step_id = "F.3_write_worker_env" + phase = "F" + depends_on = ["F.1_scaffold_worker"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not _worker_enabled(ctx) + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + env_file = ctx.project_dir / "services" / "worker" / ".env" + if not env_file.exists(): + env_file.write_text("PORT=80\n") + return {} diff --git a/repoScaffold/src/platform_cli/steps/phase_g_gcp.py b/repoScaffold/src/platform_cli/steps/phase_g_gcp.py new file mode 100644 index 0000000..e480a68 --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_g_gcp.py @@ -0,0 +1,164 @@ +"""Phase G: GCP project setup steps.""" +from __future__ import annotations + +from typing import Any + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd + + +@register_step +class CreateGcpProject(BaseStep): + step_id = "G.1_create_gcp_project" + phase = "G" + depends_on = ["B.1_create_directories"] + max_retries = 1 + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + + # Check if project already exists + result = run_cmd(f"gcloud projects describe {project_id}") + if result.ok: + return {"gcp_project_exists": True, "project_id": project_id} + + # Create the project + run_cmd( + f"gcloud projects create {project_id} --name={ctx.project_name}", + check=True, + ) + # Set as active project + run_cmd(f"gcloud config set project {project_id}", check=True) + return {"gcp_project_created": True, "project_id": project_id} + + +@register_step +class EnableApis(BaseStep): + step_id = "G.2_enable_apis" + phase = "G" + depends_on = ["G.1_create_gcp_project"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + apis = [ + "run.googleapis.com", + "cloudbuild.googleapis.com", + "secretmanager.googleapis.com", + "artifactregistry.googleapis.com", + ] + project_id = ctx.project_id + + for api in apis: + run_cmd( + f"gcloud services enable {api} --project={project_id}", + check=True, + ) + return {"apis_enabled": apis} + + +@register_step +class CreateArtifactRegistry(BaseStep): + step_id = "G.3_create_artifact_registry" + phase = "G" + depends_on = ["G.2_enable_apis"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + region = ctx.region + repo_name = f"{ctx.project_name.lower().replace(' ', '-')}-docker" + + # Check if registry exists + result = run_cmd( + f"gcloud artifacts repositories describe {repo_name} " + f"--location={region} --project={project_id}" + ) + if result.ok: + return {"registry_exists": True, "repo_name": repo_name} + + run_cmd( + f"gcloud artifacts repositories create {repo_name} " + f"--repository-format=docker " + f"--location={region} " + f"--project={project_id}", + check=True, + ) + ctx.set("artifact_registry", repo_name) + return {"registry_created": True, "repo_name": repo_name} + + +@register_step +class CreateServiceAccounts(BaseStep): + step_id = "G.4_create_service_accounts" + phase = "G" + depends_on = ["G.1_create_gcp_project"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + prefix = ctx.project_name.lower().replace(" ", "-") + + accounts = { + "build": f"{prefix}-build", + "runtime": f"{prefix}-runtime", + } + + for role, sa_name in accounts.items(): + email = f"{sa_name}@{project_id}.iam.gserviceaccount.com" + result = run_cmd( + f"gcloud iam service-accounts describe {email} " + f"--project={project_id}" + ) + if not result.ok: + run_cmd( + f"gcloud iam service-accounts create {sa_name} " + f'--display-name="{ctx.project_name} {role} SA" ' + f"--project={project_id}", + check=True, + ) + + ctx.set("service_accounts", accounts) + return {"service_accounts": accounts} + + +@register_step +class ConfigureIam(BaseStep): + step_id = "G.5_configure_iam" + phase = "G" + depends_on = ["G.4_create_service_accounts"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + accounts = ctx.get("service_accounts", {}) + prefix = ctx.project_name.lower().replace(" ", "-") + + build_email = f"{accounts.get('build', prefix + '-build')}@{project_id}.iam.gserviceaccount.com" + runtime_email = f"{accounts.get('runtime', prefix + '-runtime')}@{project_id}.iam.gserviceaccount.com" + + # Build SA roles + build_roles = [ + "roles/cloudbuild.builds.builder", + "roles/artifactregistry.writer", + "roles/run.admin", + "roles/secretmanager.secretAccessor", + ] + for role in build_roles: + run_cmd( + f"gcloud projects add-iam-policy-binding {project_id} " + f"--member=serviceAccount:{build_email} " + f"--role={role} --quiet" + ) + + # Runtime SA roles + runtime_roles = [ + "roles/secretmanager.secretAccessor", + "roles/logging.logWriter", + "roles/monitoring.metricWriter", + ] + for role in runtime_roles: + run_cmd( + f"gcloud projects add-iam-policy-binding {project_id} " + f"--member=serviceAccount:{runtime_email} " + f"--role={role} --quiet" + ) + + return {"iam_configured": True} diff --git a/repoScaffold/src/platform_cli/steps/phase_h_secrets.py b/repoScaffold/src/platform_cli/steps/phase_h_secrets.py new file mode 100644 index 0000000..d03d15b --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_h_secrets.py @@ -0,0 +1,101 @@ +"""Phase H: Secret management steps.""" +from __future__ import annotations + +from typing import Any + +import yaml + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd +from platform_cli.templates.renderer import render_to_file + + +@register_step +class WriteSecretManifest(BaseStep): + step_id = "H.1_write_secret_manifest" + phase = "H" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + secrets = [ + {"name": "MONGODB_URI", "source": "services/api/.env"}, + {"name": "DB_NAME", "source": "services/api/.env"}, + ] + + manifest_path = ctx.project_dir / "secrets.manifest.yaml" + manifest_path.write_text( + yaml.dump( + {"secrets": secrets}, + default_flow_style=False, + sort_keys=False, + ) + ) + return {"secret_manifest_path": str(manifest_path)} + + +@register_step +class SyncSecretsToGcp(BaseStep): + step_id = "H.2_sync_secrets_to_gcp" + phase = "H" + depends_on = ["H.1_write_secret_manifest", "G.2_enable_apis"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + + # Read API .env for secret values + env_file = ctx.project_dir / "services" / "api" / ".env" + env_vars: dict[str, str] = {} + if env_file.exists(): + for line in env_file.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, val = line.partition("=") + env_vars[key.strip()] = val.strip() + + synced = [] + for key, value in env_vars.items(): + secret_name = key.lower().replace("_", "-") + + # Check if secret exists + result = run_cmd( + f"gcloud secrets describe {secret_name} --project={project_id}" + ) + if not result.ok: + run_cmd( + f"gcloud secrets create {secret_name} --project={project_id} " + f"--replication-policy=automatic", + check=True, + ) + + # Add version with value + run_cmd( + f'printf "%s" "{value}" | gcloud secrets versions add {secret_name} ' + f"--data-file=- --project={project_id}", + check=True, + ) + synced.append(key) + + return {"synced_secrets": synced} + + +@register_step +class WriteEnvExampleSecrets(BaseStep): + step_id = "H.3_write_env_example_secrets" + phase = "H" + depends_on = ["H.1_write_secret_manifest"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + # Append secret-backed vars to .env.example + env_example = ctx.project_dir / ".env.example" + if env_example.exists(): + content = env_example.read_text() + if "# Secrets (managed by GCP Secret Manager)" not in content: + content += ( + "\n# Secrets (managed by GCP Secret Manager)\n" + "# MONGODB_URI=\n" + "# DB_NAME=\n" + ) + env_example.write_text(content) + return {} diff --git a/repoScaffold/src/platform_cli/steps/phase_i_cloudbuild.py b/repoScaffold/src/platform_cli/steps/phase_i_cloudbuild.py new file mode 100644 index 0000000..62ac169 --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_i_cloudbuild.py @@ -0,0 +1,78 @@ +"""Phase I: Cloud Build config steps.""" +from __future__ import annotations + +from typing import Any + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.templates.renderer import render_to_file + + +@register_step +class WriteApiBuild(BaseStep): + step_id = "I.1_write_api_build" + phase = "I" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + svc = ctx.service("api") + if not svc: + return {"skipped": True} + + render_to_file( + "cloudbuild/api.yaml.j2", + ctx.project_dir / "cloudbuild" / "api.yaml", + manifest=ctx.manifest, + service=svc, + ) + + render_to_file( + "cloudrun/api.yaml.j2", + ctx.project_dir / "cloudrun" / "api.yaml", + manifest=ctx.manifest, + service=svc, + ) + return {} + + +@register_step +class WriteWebBuild(BaseStep): + step_id = "I.2_write_web_build" + phase = "I" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + svc = ctx.service("webapp") + if not svc: + return {"skipped": True} + + render_to_file( + "cloudbuild/web.yaml.j2", + ctx.project_dir / "cloudbuild" / "web.yaml", + manifest=ctx.manifest, + service=svc, + ) + + render_to_file( + "cloudrun/web.yaml.j2", + ctx.project_dir / "cloudrun" / "web.yaml", + manifest=ctx.manifest, + service=svc, + ) + return {} + + +@register_step +class WriteTerraformBuild(BaseStep): + step_id = "I.3_write_terraform_build" + phase = "I" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "cloudbuild/terraform.yaml.j2", + ctx.project_dir / "cloudbuild" / "terraform.yaml", + manifest=ctx.manifest, + ) + return {} diff --git a/repoScaffold/src/platform_cli/steps/phase_j_terraform.py b/repoScaffold/src/platform_cli/steps/phase_j_terraform.py new file mode 100644 index 0000000..fd5084b --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_j_terraform.py @@ -0,0 +1,164 @@ +"""Phase J: Terraform generation steps.""" +from __future__ import annotations + +from typing import Any + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.templates.renderer import render_to_file + + +@register_step +class WriteProviderTf(BaseStep): + step_id = "J.1_write_provider_tf" + phase = "J" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "infra/terraform/main.tf.j2", + ctx.project_dir / "infra" / "terraform" / "main.tf", + manifest=ctx.manifest, + ) + render_to_file( + "infra/terraform/variables.tf.j2", + ctx.project_dir / "infra" / "terraform" / "variables.tf", + manifest=ctx.manifest, + ) + render_to_file( + "infra/terraform/outputs.tf.j2", + ctx.project_dir / "infra" / "terraform" / "outputs.tf", + manifest=ctx.manifest, + ) + return {} + + +@register_step +class WriteCloudRunModule(BaseStep): + step_id = "J.2_write_cloudrun_module" + phase = "J" + depends_on = ["J.1_write_provider_tf"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + mod_dir = "infra/terraform/modules/cloudrun" + + render_to_file( + f"{mod_dir}/main.tf.j2", + ctx.project_dir / mod_dir / "main.tf", + manifest=ctx.manifest, + ) + render_to_file( + f"{mod_dir}/variables.tf.j2", + ctx.project_dir / mod_dir / "variables.tf", + manifest=ctx.manifest, + ) + render_to_file( + f"{mod_dir}/outputs.tf.j2", + ctx.project_dir / mod_dir / "outputs.tf", + manifest=ctx.manifest, + ) + return {} + + +@register_step +class WriteIamModule(BaseStep): + step_id = "J.3_write_iam_module" + phase = "J" + depends_on = ["J.1_write_provider_tf"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + mod_dir = "infra/terraform/modules/iam" + + render_to_file( + f"{mod_dir}/main.tf.j2", + ctx.project_dir / mod_dir / "main.tf", + manifest=ctx.manifest, + ) + render_to_file( + f"{mod_dir}/variables.tf.j2", + ctx.project_dir / mod_dir / "variables.tf", + manifest=ctx.manifest, + ) + render_to_file( + f"{mod_dir}/outputs.tf.j2", + ctx.project_dir / mod_dir / "outputs.tf", + manifest=ctx.manifest, + ) + return {} + + +@register_step +class WriteSecretsModule(BaseStep): + step_id = "J.4_write_secrets_module" + phase = "J" + depends_on = ["J.1_write_provider_tf"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + mod_dir = "infra/terraform/modules/secrets" + + render_to_file( + f"{mod_dir}/main.tf.j2", + ctx.project_dir / mod_dir / "main.tf", + manifest=ctx.manifest, + ) + render_to_file( + f"{mod_dir}/variables.tf.j2", + ctx.project_dir / mod_dir / "variables.tf", + manifest=ctx.manifest, + ) + return {} + + +@register_step +class WriteNetworkingModule(BaseStep): + step_id = "J.5_write_networking_module" + phase = "J" + depends_on = ["J.1_write_provider_tf"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + mod_dir = "infra/terraform/modules/networking" + + render_to_file( + f"{mod_dir}/main.tf.j2", + ctx.project_dir / mod_dir / "main.tf", + manifest=ctx.manifest, + ) + render_to_file( + f"{mod_dir}/variables.tf.j2", + ctx.project_dir / mod_dir / "variables.tf", + manifest=ctx.manifest, + ) + return {} + + +@register_step +class WriteDevEnv(BaseStep): + step_id = "J.6_write_dev_env" + phase = "J" + depends_on = [ + "J.2_write_cloudrun_module", + "J.3_write_iam_module", + "J.4_write_secrets_module", + "J.5_write_networking_module", + ] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + env_dir = "infra/terraform/envs/dev" + + render_to_file( + f"{env_dir}/main.tf.j2", + ctx.project_dir / env_dir / "main.tf", + manifest=ctx.manifest, + ) + render_to_file( + f"{env_dir}/variables.tf.j2", + ctx.project_dir / env_dir / "variables.tf", + manifest=ctx.manifest, + ) + render_to_file( + f"{env_dir}/terraform.tfvars.j2", + ctx.project_dir / env_dir / "terraform.tfvars", + manifest=ctx.manifest, + ) + return {} diff --git a/repoScaffold/src/platform_cli/steps/phase_k_testing.py b/repoScaffold/src/platform_cli/steps/phase_k_testing.py new file mode 100644 index 0000000..9d32fd2 --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_k_testing.py @@ -0,0 +1,124 @@ +"""Phase K: Testing steps.""" +from __future__ import annotations + +from typing import Any + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd + + +@register_step +class ApiLint(BaseStep): + step_id = "K.1_api_lint" + phase = "K" + depends_on = ["D.3_write_api_source"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or ctx.service("api") is None + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + api_dir = str(ctx.project_dir / "services" / "api") + + # Install deps first if needed + if not (ctx.project_dir / "services" / "api" / "node_modules").exists(): + run_cmd("npm install", cwd=api_dir, check=True) + + result = run_cmd("npm run lint", cwd=api_dir) + return {"lint_passed": result.ok, "output": result.stdout} + + +@register_step +class ApiHealth(BaseStep): + step_id = "K.2_api_health" + phase = "K" + depends_on = ["D.3_write_api_source"] + max_retries = 2 + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or ctx.service("api") is None + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + svc = ctx.service("api") + port = svc.port if svc else 3006 + + result = run_cmd( + f"curl -sf http://localhost:{port}/health", + timeout=10, + ) + if not result.ok: + from rich.console import Console + Console().print( + "[yellow]API health check failed — is the API running?[/yellow]" + ) + return {"health_ok": result.ok} + + +@register_step +class ApiDockerBuild(BaseStep): + step_id = "K.3_api_docker_build" + phase = "K" + depends_on = ["D.4_write_api_dockerfile"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or ctx.service("api") is None + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + api_dir = str(ctx.project_dir / "services" / "api") + tag = f"{ctx.project_name.lower().replace(' ', '-')}-api:test" + + result = run_cmd( + f"docker build -t {tag} .", + cwd=api_dir, + check=True, + timeout=120, + ) + return {"docker_build_ok": result.ok, "image_tag": tag} + + +@register_step +class FrontendSmoke(BaseStep): + step_id = "K.4_frontend_smoke" + phase = "K" + depends_on = ["E.3_write_items_fetch"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or ctx.service("webapp") is None + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + web_dir = str(ctx.project_dir / "services" / "web") + + # Check that build succeeds + if not (ctx.project_dir / "services" / "web" / "node_modules").exists(): + run_cmd("npm install", cwd=web_dir, check=True, timeout=120) + + result = run_cmd("npm run build", cwd=web_dir, timeout=120) + return {"build_ok": result.ok} + + +@register_step +class TerraformValidate(BaseStep): + step_id = "K.5_terraform_validate" + phase = "K" + depends_on = ["J.6_write_dev_env"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + tf_dir = str(ctx.project_dir / "infra" / "terraform" / "envs" / "dev") + + run_cmd("terraform init -backend=false", cwd=tf_dir, check=True) + result = run_cmd("terraform validate", cwd=tf_dir) + return {"validate_ok": result.ok, "output": result.stdout} + + +@register_step +class TerraformPlan(BaseStep): + step_id = "K.6_terraform_plan" + phase = "K" + depends_on = ["K.5_terraform_validate"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + tf_dir = str(ctx.project_dir / "infra" / "terraform" / "envs" / "dev") + + result = run_cmd("terraform plan -input=false", cwd=tf_dir, timeout=120) + return {"plan_ok": result.ok} diff --git a/repoScaffold/src/platform_cli/templates/__init__.py b/repoScaffold/src/platform_cli/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repoScaffold/src/platform_cli/templates/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/templates/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51de1111a3bc10d302b2efec212adef65ba1a50a GIT binary patch literal 183 zcmXwz%?ScA5QP(0L4+;Di_?Huf)~+i2>Dq8i3u}V!9py;UaVj}T7d-bCZO*y?|Zz% zo6`3yR`vKiUGrBvfAJrbd4(Gr*qZO}3}-c!R!t{3l0gD3FHR4Vl0*#@M^pw#p-Y(n zO^OF4t&P5Rs33WnZNwmycaTkJ*$@cf&MphFN!Mw)4N;HY>~K+M+8fm1-Eq#btMmm4 Cm@-%Z literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/templates/__pycache__/filters.cpython-313.pyc b/repoScaffold/src/platform_cli/templates/__pycache__/filters.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ec1411234e736a33c2b1efb8f84150fe9801b24 GIT binary patch literal 1376 zcmd^9%}>-&5Pz>G2k;M54?5e`W|QF<~m#PRx-?jw%hh#;)}?@qKV-JK^S>aJq-AQ zk9iJ6MI;kK`|lC#lBL1HVwU}213@v(ze1+aLr6gXXfHcTg`U|jirX&9RJQ($SgIra z7Atot5H1~QDtp@qv9yIwZfTh>m*12U>i)<*T$(Mveq(iHcX_fSxutz2k$@#Kk5>}4 z5x!-Rs62vtYhOy#w%3SRLOB;zFwLZ5S2$MOu6pfyb=Bi752`i~WAK^HeRPVr4XhP9 zzE`V-4Lk$iZZ*897DChY8+AKkO|fR&&e)r1Z6x_~0^u6oB;Ledq1q&$^!&EArM>HP zAMdF>eQa~~d}n%ZvZs$8>7_%x)YHp&WE8fix2AVSyTe_tXP8IE*r74D$9qQUKq>Vx zAo+h-*N}BrkX{B2AKsD6g79()M0H^ZRmLh@MV#c%2M2E8MnM={iY z;5P0J-<0nI?z70`2n!5i2wbKMz literal 0 HcmV?d00001 diff --git a/repoScaffold/src/platform_cli/templates/__pycache__/renderer.cpython-313.pyc b/repoScaffold/src/platform_cli/templates/__pycache__/renderer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b190216c455290667379bc4ead7420b8713b3df GIT binary patch literal 1822 zcmbtUQBNC35Z<%bKHFylM}Q=R(!-5JF9@5`CMZgZR0%58P5}8JQWaOH!(Pnc^PRPK zK*B?0o>KLtm0Bbo)3>%y{TY4C1x0qZsuU@$s=g(n?Q>_(z6KHU)H!K(cII|wcD|WC z&SX*uX!@UD%u5)d=X?{A*cF812LeI|NJA89f)*(Xg$Ru(T#go_1x#@u<6?})Ko={< z3nCQ@37RM*X;MLDoJHl-Rq(;7biTWusA*)zv z{7n4-*fL?p;i`$jm$;6pTo%i6>GJDmwd5oy%ZWEwQ-L-42CKxaTA!94MO&Dj)B3ec zAPv>9IYk%-m=;ksiPa=D_b1Ox6_{k$w&NL| z>DVr8@rvPXE~!zLa&4nd^orpUma3C=W4$Yj;9TD^C`)>z(XBtqky@ouX0ngB$ExC!79j#of9 zKzF$=pi(rHUae~=gu#xiMM}`Brzr~!IfK@O1>u@-8L5%IY$>RXVko(-StY!X;yIw< zIoh?{_XvuCb+|Q4*NCTwKe2eXu3*iCI^8xJgvIN-2Ccaadr%*jC#on4yOUpY33c;T zW5=x447X{}YTk9WX@%t73Y5ch^OQ6ltzv9!I2PDwCC}@-;n0R&vCMoJh5yG91rVF} zS$_y`4-Gm30r`-)y!Tq@H>tlZjrr2p;b%wERBNFl^?!NeiyOYA9F72cjFn^f4^<+Z z_lV;Vl-&m5018$LEhpBqAb{D1>AxOP_!$n~DZ0x&2egJh;7~+36d`O1szp`2H+?oJ zhFw*5sp%2L^f*FSs>JoKn=->d#3B@PJM~DWLL{#je6CjEEOCE{O)-{ zjnpU|gj5*je&{(6L#*g9*Y|~m?amE-8M#>^z3NnSouzbrV;hbVU8lUa_~pG#%Ull*vG`W-1hDawt&{t&b#m|oc+iHYhK?n` zD?AS1U!=r!tQ|rxap!&d>f-H{TOX}1YWl(_B_=JtdRo}P$8bcu?}GPR!Sr`;&>v(K z2IB1))Q7A z4UT-Z`So~Pp7iC(BUx?BGrm02mgjtVt}V~|@_buf^yS5$BwyBAeb2;9TfF#0yx5WC zwv>G$Wxsv@(SxIRt{q8pozd~mz))v!*M(@|kQxXZtL{- str: + s = re.sub(r"[\s\-]+", "_", value) + s = re.sub(r"([A-Z])", r"_\1", s).lower() + return re.sub(r"_+", "_", s).strip("_") + + +def kebab_case(value: str) -> str: + s = re.sub(r"[\s_]+", "-", value) + s = re.sub(r"([A-Z])", r"-\1", s).lower() + return re.sub(r"-+", "-", s).strip("-") + + +def env_var(value: str) -> str: + return snake_case(value).upper() diff --git a/repoScaffold/src/platform_cli/templates/renderer.py b/repoScaffold/src/platform_cli/templates/renderer.py new file mode 100644 index 0000000..ec5943b --- /dev/null +++ b/repoScaffold/src/platform_cli/templates/renderer.py @@ -0,0 +1,33 @@ +"""Jinja2 environment + render_to_file helper.""" +from __future__ import annotations + +from pathlib import Path + +import jinja2 + +from platform_cli.templates.filters import snake_case, kebab_case, env_var + +# Templates live in repoScaffold/templates/ (sibling of src/) +_TEMPLATES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "templates" + +_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(_TEMPLATES_DIR)), + keep_trailing_newline=True, + trim_blocks=True, + lstrip_blocks=True, +) +_env.filters["snake_case"] = snake_case +_env.filters["kebab_case"] = kebab_case +_env.filters["env_var"] = env_var + + +def render_template(template_name: str, **kwargs) -> str: + """Render a .j2 template to a string.""" + tmpl = _env.get_template(template_name) + return tmpl.render(**kwargs) + + +def render_to_file(template_name: str, dest: Path, **kwargs) -> None: + """Render a .j2 template and write it to *dest*.""" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(render_template(template_name, **kwargs)) diff --git a/repoScaffold/templates/claude/settings.json.j2 b/repoScaffold/templates/claude/settings.json.j2 new file mode 100644 index 0000000..c4ac97e --- /dev/null +++ b/repoScaffold/templates/claude/settings.json.j2 @@ -0,0 +1,12 @@ +{ + "project": "{{ manifest.project.name }}", + "description": "Full-stack {{ manifest.project.cloud.provider | upper }} application", + "conventions": [ + "Services communicate via HTTP contracts only", + "All services are containerized with Docker", + "Infrastructure is managed with Terraform", + "Secrets are stored in GCP Secret Manager", + "API runs on port {{ manifest.services | selectattr('type', 'equalto', 'api') | map(attribute='port') | first | default(3006) }}", + "Web runs on port {{ manifest.services | selectattr('type', 'equalto', 'webapp') | map(attribute='port') | first | default(3005) }}" + ] +} diff --git a/repoScaffold/templates/cloudbuild/api.yaml.j2 b/repoScaffold/templates/cloudbuild/api.yaml.j2 new file mode 100644 index 0000000..3943dda --- /dev/null +++ b/repoScaffold/templates/cloudbuild/api.yaml.j2 @@ -0,0 +1,33 @@ +steps: + # Build the Docker image + - name: "gcr.io/cloud-builders/docker" + args: + - "build" + - "-t" + - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "./services/api" + + # Push to Artifact Registry + - name: "gcr.io/cloud-builders/docker" + args: + - "push" + - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + + # Deploy to Cloud Run + - name: "gcr.io/google.com/cloudsdktool/cloud-sdk" + entrypoint: "gcloud" + args: + - "run" + - "deploy" + - "{{ service.name }}" + - "--image={{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "--region={{ manifest.project.cloud.region }}" + - "--platform=managed" + - "--allow-unauthenticated" + - "--set-secrets=MONGODB_URI=mongodb-uri:latest,DB_NAME=db-name:latest" + +images: + - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/repoScaffold/templates/cloudbuild/terraform.yaml.j2 b/repoScaffold/templates/cloudbuild/terraform.yaml.j2 new file mode 100644 index 0000000..4c0bf73 --- /dev/null +++ b/repoScaffold/templates/cloudbuild/terraform.yaml.j2 @@ -0,0 +1,30 @@ +steps: + # Terraform init + - name: "hashicorp/terraform:1.7" + entrypoint: "sh" + args: + - "-c" + - | + cd infra/terraform/envs/dev + terraform init + + # Terraform plan + - name: "hashicorp/terraform:1.7" + entrypoint: "sh" + args: + - "-c" + - | + cd infra/terraform/envs/dev + terraform plan -out=tfplan + + # Terraform apply + - name: "hashicorp/terraform:1.7" + entrypoint: "sh" + args: + - "-c" + - | + cd infra/terraform/envs/dev + terraform apply -auto-approve tfplan + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/repoScaffold/templates/cloudbuild/web.yaml.j2 b/repoScaffold/templates/cloudbuild/web.yaml.j2 new file mode 100644 index 0000000..6827cd4 --- /dev/null +++ b/repoScaffold/templates/cloudbuild/web.yaml.j2 @@ -0,0 +1,32 @@ +steps: + # Build the Docker image + - name: "gcr.io/cloud-builders/docker" + args: + - "build" + - "-t" + - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "./services/web" + + # Push to Artifact Registry + - name: "gcr.io/cloud-builders/docker" + args: + - "push" + - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + + # Deploy to Cloud Run + - name: "gcr.io/google.com/cloudsdktool/cloud-sdk" + entrypoint: "gcloud" + args: + - "run" + - "deploy" + - "{{ service.name }}" + - "--image={{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "--region={{ manifest.project.cloud.region }}" + - "--platform=managed" + - "--allow-unauthenticated" + +images: + - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/repoScaffold/templates/cloudrun/api.yaml.j2 b/repoScaffold/templates/cloudrun/api.yaml.j2 new file mode 100644 index 0000000..a963338 --- /dev/null +++ b/repoScaffold/templates/cloudrun/api.yaml.j2 @@ -0,0 +1,33 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: {{ service.name }} + annotations: + run.googleapis.com/launch-stage: GA +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "0" + autoscaling.knative.dev/maxScale: "10" + spec: + serviceAccountName: {{ manifest.project.name | lower | replace(' ', '-') }}-runtime@{{ manifest.project.cloud.project_id }}.iam.gserviceaccount.com + containers: + - image: {{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:latest + ports: + - containerPort: 80 + env: + - name: MONGODB_URI + valueFrom: + secretKeyRef: + name: mongodb-uri + key: latest + - name: DB_NAME + valueFrom: + secretKeyRef: + name: db-name + key: latest + resources: + limits: + cpu: "1" + memory: 512Mi diff --git a/repoScaffold/templates/cloudrun/web.yaml.j2 b/repoScaffold/templates/cloudrun/web.yaml.j2 new file mode 100644 index 0000000..67d11eb --- /dev/null +++ b/repoScaffold/templates/cloudrun/web.yaml.j2 @@ -0,0 +1,22 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: {{ service.name }} + annotations: + run.googleapis.com/launch-stage: GA +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "0" + autoscaling.knative.dev/maxScale: "10" + spec: + serviceAccountName: {{ manifest.project.name | lower | replace(' ', '-') }}-runtime@{{ manifest.project.cloud.project_id }}.iam.gserviceaccount.com + containers: + - image: {{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:latest + ports: + - containerPort: 80 + resources: + limits: + cpu: "1" + memory: 256Mi diff --git a/repoScaffold/templates/infra/terraform/envs/dev/main.tf.j2 b/repoScaffold/templates/infra/terraform/envs/dev/main.tf.j2 new file mode 100644 index 0000000..2e1af92 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/envs/dev/main.tf.j2 @@ -0,0 +1,62 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +locals { + project_prefix = lower(replace(var.project_name, " ", "-")) + artifact_repo = "{{ '${local.project_prefix}' }}-docker" +} + +module "iam" { + source = "../../modules/iam" + + project_id = var.project_id + project_name = var.project_name + project_prefix = local.project_prefix +} + +module "secrets" { + source = "../../modules/secrets" + + project_id = var.project_id + runtime_service_account = module.iam.runtime_sa_email +} + +module "networking" { + source = "../../modules/networking" + + project_id = var.project_id + project_prefix = local.project_prefix + region = var.region +} + +module "cloudrun" { + source = "../../modules/cloudrun" + + project_id = var.project_id + region = var.region + artifact_repo = local.artifact_repo + runtime_service_account = module.iam.runtime_sa_email + + depends_on = [module.iam, module.secrets] +} + +{% for svc in manifest.services %} +{% if svc.enabled and svc.type in ['api', 'webapp'] %} +output "{{ svc.name }}_url" { + value = module.cloudrun.{{ svc.name }}_url +} +{% endif %} +{% endfor %} diff --git a/repoScaffold/templates/infra/terraform/envs/dev/terraform.tfvars.j2 b/repoScaffold/templates/infra/terraform/envs/dev/terraform.tfvars.j2 new file mode 100644 index 0000000..18bfc20 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/envs/dev/terraform.tfvars.j2 @@ -0,0 +1,4 @@ +project_id = "{{ manifest.project.cloud.project_id }}" +region = "{{ manifest.project.cloud.region }}" +environment = "{{ manifest.project.env }}" +project_name = "{{ manifest.project.name }}" diff --git a/repoScaffold/templates/infra/terraform/envs/dev/variables.tf.j2 b/repoScaffold/templates/infra/terraform/envs/dev/variables.tf.j2 new file mode 100644 index 0000000..e883701 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/envs/dev/variables.tf.j2 @@ -0,0 +1,19 @@ +variable "project_id" { + description = "GCP project ID" + type = string +} + +variable "region" { + description = "GCP region" + type = string +} + +variable "environment" { + description = "Environment name" + type = string +} + +variable "project_name" { + description = "Human-readable project name" + type = string +} diff --git a/repoScaffold/templates/infra/terraform/main.tf.j2 b/repoScaffold/templates/infra/terraform/main.tf.j2 new file mode 100644 index 0000000..28857ce --- /dev/null +++ b/repoScaffold/templates/infra/terraform/main.tf.j2 @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} diff --git a/repoScaffold/templates/infra/terraform/modules/cloudrun/main.tf.j2 b/repoScaffold/templates/infra/terraform/modules/cloudrun/main.tf.j2 new file mode 100644 index 0000000..4a46514 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/cloudrun/main.tf.j2 @@ -0,0 +1,62 @@ +{% for svc in manifest.services %} +{% if svc.enabled and svc.type in ['api', 'webapp', 'worker'] %} +resource "google_cloud_run_v2_service" "{{ svc.name }}" { + name = "{{ svc.name }}" + location = var.region + + template { + service_account = var.runtime_service_account + + containers { + image = "{{ '${var.region}' }}-docker.pkg.dev/{{ '${var.project_id}' }}/{{ '${var.artifact_repo}' }}/{{ svc.name }}:latest" + + ports { + container_port = 80 + } + +{% if svc.type == 'api' %} + env { + name = "MONGODB_URI" + value_source { + secret_key_ref { + secret = "mongodb-uri" + version = "latest" + } + } + } + + env { + name = "DB_NAME" + value_source { + secret_key_ref { + secret = "db-name" + version = "latest" + } + } + } +{% endif %} + + resources { + limits = { + cpu = "1" + memory = "{{ '512Mi' if svc.type == 'api' else '256Mi' }}" + } + } + } + + scaling { + min_instance_count = 0 + max_instance_count = 10 + } + } +} + +resource "google_cloud_run_v2_service_iam_member" "{{ svc.name }}_public" { + name = google_cloud_run_v2_service.{{ svc.name }}.name + location = var.region + role = "roles/run.invoker" + member = "allUsers" +} + +{% endif %} +{% endfor %} diff --git a/repoScaffold/templates/infra/terraform/modules/cloudrun/outputs.tf.j2 b/repoScaffold/templates/infra/terraform/modules/cloudrun/outputs.tf.j2 new file mode 100644 index 0000000..8ee40ab --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/cloudrun/outputs.tf.j2 @@ -0,0 +1,8 @@ +{% for svc in manifest.services %} +{% if svc.enabled and svc.type in ['api', 'webapp'] %} +output "{{ svc.name }}_url" { + description = "URL of the {{ svc.name }} service" + value = google_cloud_run_v2_service.{{ svc.name }}.uri +} +{% endif %} +{% endfor %} diff --git a/repoScaffold/templates/infra/terraform/modules/cloudrun/variables.tf.j2 b/repoScaffold/templates/infra/terraform/modules/cloudrun/variables.tf.j2 new file mode 100644 index 0000000..d539bc0 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/cloudrun/variables.tf.j2 @@ -0,0 +1,19 @@ +variable "project_id" { + description = "GCP project ID" + type = string +} + +variable "region" { + description = "GCP region" + type = string +} + +variable "artifact_repo" { + description = "Artifact Registry repository name" + type = string +} + +variable "runtime_service_account" { + description = "Email of the runtime service account" + type = string +} diff --git a/repoScaffold/templates/infra/terraform/modules/iam/main.tf.j2 b/repoScaffold/templates/infra/terraform/modules/iam/main.tf.j2 new file mode 100644 index 0000000..31c7282 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/iam/main.tf.j2 @@ -0,0 +1,38 @@ +resource "google_service_account" "build" { + account_id = "{{ '${var.project_prefix}' }}-build" + display_name = "{{ '${var.project_name}' }} Build SA" + project = var.project_id +} + +resource "google_service_account" "runtime" { + account_id = "{{ '${var.project_prefix}' }}-runtime" + display_name = "{{ '${var.project_name}' }} Runtime SA" + project = var.project_id +} + +# Build SA roles +resource "google_project_iam_member" "build_roles" { + for_each = toset([ + "roles/cloudbuild.builds.builder", + "roles/artifactregistry.writer", + "roles/run.admin", + "roles/secretmanager.secretAccessor", + ]) + + project = var.project_id + role = each.value + member = "serviceAccount:{{ '${google_service_account.build.email}' }}" +} + +# Runtime SA roles +resource "google_project_iam_member" "runtime_roles" { + for_each = toset([ + "roles/secretmanager.secretAccessor", + "roles/logging.logWriter", + "roles/monitoring.metricWriter", + ]) + + project = var.project_id + role = each.value + member = "serviceAccount:{{ '${google_service_account.runtime.email}' }}" +} diff --git a/repoScaffold/templates/infra/terraform/modules/iam/outputs.tf.j2 b/repoScaffold/templates/infra/terraform/modules/iam/outputs.tf.j2 new file mode 100644 index 0000000..d005d7f --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/iam/outputs.tf.j2 @@ -0,0 +1,9 @@ +output "build_sa_email" { + description = "Email of the build service account" + value = google_service_account.build.email +} + +output "runtime_sa_email" { + description = "Email of the runtime service account" + value = google_service_account.runtime.email +} diff --git a/repoScaffold/templates/infra/terraform/modules/iam/variables.tf.j2 b/repoScaffold/templates/infra/terraform/modules/iam/variables.tf.j2 new file mode 100644 index 0000000..2363939 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/iam/variables.tf.j2 @@ -0,0 +1,14 @@ +variable "project_id" { + description = "GCP project ID" + type = string +} + +variable "project_name" { + description = "Human-readable project name" + type = string +} + +variable "project_prefix" { + description = "Prefix for resource names (kebab-case project name)" + type = string +} diff --git a/repoScaffold/templates/infra/terraform/modules/networking/main.tf.j2 b/repoScaffold/templates/infra/terraform/modules/networking/main.tf.j2 new file mode 100644 index 0000000..dc49b7f --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/networking/main.tf.j2 @@ -0,0 +1,17 @@ +# Networking module — minimal setup for Cloud Run +# Cloud Run services are publicly accessible by default. +# Add VPC connectors, custom domains, or load balancers here as needed. + +resource "google_compute_network" "vpc" { + name = "{{ '${var.project_prefix}' }}-vpc" + project = var.project_id + auto_create_subnetworks = true +} + +resource "google_vpc_access_connector" "connector" { + name = "{{ '${var.project_prefix}' }}-connector" + project = var.project_id + region = var.region + ip_cidr_range = "10.8.0.0/28" + network = google_compute_network.vpc.name +} diff --git a/repoScaffold/templates/infra/terraform/modules/networking/variables.tf.j2 b/repoScaffold/templates/infra/terraform/modules/networking/variables.tf.j2 new file mode 100644 index 0000000..83cfcf4 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/networking/variables.tf.j2 @@ -0,0 +1,14 @@ +variable "project_id" { + description = "GCP project ID" + type = string +} + +variable "project_prefix" { + description = "Prefix for resource names" + type = string +} + +variable "region" { + description = "GCP region" + type = string +} diff --git a/repoScaffold/templates/infra/terraform/modules/secrets/main.tf.j2 b/repoScaffold/templates/infra/terraform/modules/secrets/main.tf.j2 new file mode 100644 index 0000000..83a2e8b --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/secrets/main.tf.j2 @@ -0,0 +1,30 @@ +resource "google_secret_manager_secret" "mongodb_uri" { + secret_id = "mongodb-uri" + project = var.project_id + + replication { + auto {} + } +} + +resource "google_secret_manager_secret" "db_name" { + secret_id = "db-name" + project = var.project_id + + replication { + auto {} + } +} + +# Grant runtime SA access to secrets +resource "google_secret_manager_secret_iam_member" "mongodb_uri_access" { + secret_id = google_secret_manager_secret.mongodb_uri.id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:{{ '${var.runtime_service_account}' }}" +} + +resource "google_secret_manager_secret_iam_member" "db_name_access" { + secret_id = google_secret_manager_secret.db_name.id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:{{ '${var.runtime_service_account}' }}" +} diff --git a/repoScaffold/templates/infra/terraform/modules/secrets/variables.tf.j2 b/repoScaffold/templates/infra/terraform/modules/secrets/variables.tf.j2 new file mode 100644 index 0000000..de41cb2 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/modules/secrets/variables.tf.j2 @@ -0,0 +1,9 @@ +variable "project_id" { + description = "GCP project ID" + type = string +} + +variable "runtime_service_account" { + description = "Email of the runtime service account" + type = string +} diff --git a/repoScaffold/templates/infra/terraform/outputs.tf.j2 b/repoScaffold/templates/infra/terraform/outputs.tf.j2 new file mode 100644 index 0000000..f164fc3 --- /dev/null +++ b/repoScaffold/templates/infra/terraform/outputs.tf.j2 @@ -0,0 +1,8 @@ +{% for svc in manifest.services %} +{% if svc.enabled and svc.type in ['api', 'webapp'] %} +output "{{ svc.name }}_url" { + description = "URL of the {{ svc.name }} Cloud Run service" + value = module.cloudrun.{{ svc.name }}_url +} +{% endif %} +{% endfor %} diff --git a/repoScaffold/templates/infra/terraform/variables.tf.j2 b/repoScaffold/templates/infra/terraform/variables.tf.j2 new file mode 100644 index 0000000..a75f78c --- /dev/null +++ b/repoScaffold/templates/infra/terraform/variables.tf.j2 @@ -0,0 +1,23 @@ +variable "project_id" { + description = "GCP project ID" + type = string + default = "{{ manifest.project.cloud.project_id }}" +} + +variable "region" { + description = "GCP region" + type = string + default = "{{ manifest.project.cloud.region }}" +} + +variable "environment" { + description = "Environment name" + type = string + default = "{{ manifest.project.env }}" +} + +variable "project_name" { + description = "Human-readable project name" + type = string + default = "{{ manifest.project.name }}" +} diff --git a/repoScaffold/templates/project/.env.example.j2 b/repoScaffold/templates/project/.env.example.j2 new file mode 100644 index 0000000..e68c4f8 --- /dev/null +++ b/repoScaffold/templates/project/.env.example.j2 @@ -0,0 +1,22 @@ +# {{ manifest.project.name }} — Environment Variables +# Copy this file to .env and fill in the values. + +# Project +PROJECT_NAME={{ manifest.project.name }} +ENVIRONMENT={{ manifest.project.env }} + +# GCP +GCP_PROJECT_ID={{ manifest.project.cloud.project_id }} +GCP_REGION={{ manifest.project.cloud.region }} + +# Database +{% if manifest.database.type == "mongodb" %} +MONGODB_URI=mongodb+srv://:@{{ manifest.database.atlas_cluster | lower }}.xxxxx.mongodb.net/{{ manifest.database.db_name }}?retryWrites=true&w=majority +DB_NAME={{ manifest.database.db_name }} +{% endif %} + +# API +API_PORT={{ manifest.services | selectattr('type', 'equalto', 'api') | map(attribute='port') | first | default(3006) }} + +# Web +WEB_PORT={{ manifest.services | selectattr('type', 'equalto', 'webapp') | map(attribute='port') | first | default(3005) }} diff --git a/repoScaffold/templates/project/.gitignore.j2 b/repoScaffold/templates/project/.gitignore.j2 new file mode 100644 index 0000000..65f54f5 --- /dev/null +++ b/repoScaffold/templates/project/.gitignore.j2 @@ -0,0 +1,44 @@ +# Dependencies +node_modules/ +__pycache__/ +*.pyc +.venv/ +venv/ + +# Environment +.env +.env.local +.env.*.local + +# Build +dist/ +build/ +*.egg-info/ + +# IDE +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Terraform +.terraform/ +*.tfstate +*.tfstate.backup +*.tfplan + +# Docker +docker-compose.override.yml + +# Scaffold state +.scaffold-state.json +.test-state.json + +# Secrets +secrets/ +*.pem +*.key diff --git a/repoScaffold/templates/project/AGENTS.md.j2 b/repoScaffold/templates/project/AGENTS.md.j2 new file mode 100644 index 0000000..1806f3f --- /dev/null +++ b/repoScaffold/templates/project/AGENTS.md.j2 @@ -0,0 +1,46 @@ +# {{ manifest.project.name }} — Architecture & Conventions + +## Project Overview + +**{{ manifest.project.name }}** is a full-stack application deployed on GCP Cloud Run. + +- **Environment:** {{ manifest.project.env }} +- **Cloud Provider:** {{ manifest.project.cloud.provider | upper }} +- **Region:** {{ manifest.project.cloud.region }} +- **Database:** {{ manifest.database.type }} ({{ manifest.database.atlas_cluster }}) + +## Directory Structure + +``` +{{ manifest.project.name }}/ + services/ + api/ # Express.js API server (port {{ manifest.services | selectattr('type', 'equalto', 'api') | map(attribute='port') | first | default(3006) }}) + web/ # React frontend (port {{ manifest.services | selectattr('type', 'equalto', 'webapp') | map(attribute='port') | first | default(3005) }}) + worker/ # Python Cloud Run worker + infra/ + terraform/ # IaC modules + environments + cloudbuild/ # Cloud Build pipeline configs + cloudrun/ # Cloud Run service specs +``` + +## Conventions + +- **Services communicate via HTTP contracts only.** No shared code between services. +- **Secrets** are managed via GCP Secret Manager. Local dev uses `.env` files. +- **Infrastructure** is managed with Terraform. One pipeline per environment. +- **Docker** is required for all services. Containers expose port 80 internally. +- **API endpoints** are the contract between frontend and backend. + +## Key Endpoints + +| Service | Endpoint | Description | +|---------|-------------|-------------------| +| API | GET /items | List all items | +| API | GET /health | Health check | + +## Development + +1. Install dependencies: `platform-cli install` +2. Start services: `docker compose up` +3. API: http://localhost:{{ manifest.services | selectattr('type', 'equalto', 'api') | map(attribute='port') | first | default(3006) }} +4. Web: http://localhost:{{ manifest.services | selectattr('type', 'equalto', 'webapp') | map(attribute='port') | first | default(3005) }} diff --git a/repoScaffold/templates/project/README.md.j2 b/repoScaffold/templates/project/README.md.j2 new file mode 100644 index 0000000..7f99932 --- /dev/null +++ b/repoScaffold/templates/project/README.md.j2 @@ -0,0 +1,48 @@ +# {{ manifest.project.name }} + +Full-stack application scaffolded with `platform-cli`. + +## Quick Start + +```bash +# Install required tools +platform-cli install + +# Start all services locally +docker compose up --build + +# Or run services individually: +cd services/api && npm install && npm start +cd services/web && npm install && npm start +``` + +## Services + +| Service | Type | Port | Stack | +|---------|---------|------|---------| +{% for svc in manifest.services %} +{% if svc.enabled %} +| {{ svc.name }} | {{ svc.type }} | {{ svc.port }} | {{ svc.stack }} | +{% endif %} +{% endfor %} + +## Infrastructure + +- **Cloud Provider:** {{ manifest.project.cloud.provider | upper }} +- **Region:** {{ manifest.project.cloud.region }} +- **Database:** {{ manifest.database.type }} + +### Deploy + +```bash +# Terraform plan +cd infra/terraform/envs/dev +terraform init && terraform plan + +# Cloud Build (triggered via push or manual) +gcloud builds submit --config=cloudbuild/api.yaml +``` + +## Project Manifest + +See `project.manifest.yaml` for the full project configuration. diff --git a/repoScaffold/templates/project/docker-compose.yml.j2 b/repoScaffold/templates/project/docker-compose.yml.j2 new file mode 100644 index 0000000..751941a --- /dev/null +++ b/repoScaffold/templates/project/docker-compose.yml.j2 @@ -0,0 +1,38 @@ +version: "3.9" + +services: +{% for svc in services %} +{% if svc.enabled %} +{% if svc.type == "api" %} + {{ svc.name }}: + build: + context: ./services/api + dockerfile: Dockerfile + ports: + - "{{ svc.port }}:80" + env_file: + - ./services/api/.env + volumes: + - ./services/api/src:/app/src + restart: unless-stopped +{% elif svc.type == "webapp" %} + {{ svc.name }}: + build: + context: ./services/web + dockerfile: Dockerfile + ports: + - "{{ svc.port }}:80" + depends_on: + - {{ manifest.services | selectattr('type', 'equalto', 'api') | map(attribute='name') | first | default('api') }} + restart: unless-stopped +{% elif svc.type == "worker" %} + {{ svc.name }}: + build: + context: ./services/worker + dockerfile: Dockerfile + env_file: + - ./services/worker/.env + restart: unless-stopped +{% endif %} +{% endif %} +{% endfor %} diff --git a/repoScaffold/templates/services/api/.eslintrc.json.j2 b/repoScaffold/templates/services/api/.eslintrc.json.j2 new file mode 100644 index 0000000..af7b7a7 --- /dev/null +++ b/repoScaffold/templates/services/api/.eslintrc.json.j2 @@ -0,0 +1,14 @@ +{ + "env": { + "node": true, + "es2021": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": "latest" + }, + "rules": { + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "no-console": "off" + } +} diff --git a/repoScaffold/templates/services/api/Dockerfile.j2 b/repoScaffold/templates/services/api/Dockerfile.j2 new file mode 100644 index 0000000..3e8a894 --- /dev/null +++ b/repoScaffold/templates/services/api/Dockerfile.j2 @@ -0,0 +1,26 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --production + +FROM node:20-alpine + +WORKDIR /app + +RUN addgroup -g 1001 -S appgroup && \ + adduser -S appuser -u 1001 -G appgroup + +COPY --from=builder /app/node_modules ./node_modules +COPY package.json ./ +COPY src/ ./src/ + +USER appuser + +EXPOSE 80 + +ENV PORT=80 +ENV NODE_ENV=production + +CMD ["node", "src/index.js"] diff --git a/repoScaffold/templates/services/api/db.js.j2 b/repoScaffold/templates/services/api/db.js.j2 new file mode 100644 index 0000000..ffdf0ad --- /dev/null +++ b/repoScaffold/templates/services/api/db.js.j2 @@ -0,0 +1,21 @@ +{% raw %} +const mongoose = require("mongoose"); + +const MONGODB_URI = process.env.MONGODB_URI; + +async function connectDB() { + if (!MONGODB_URI) { + throw new Error("MONGODB_URI environment variable is not set"); + } + + try { + await mongoose.connect(MONGODB_URI); + console.log("Connected to MongoDB"); + } catch (err) { + console.error("MongoDB connection error:", err.message); + throw err; + } +} + +module.exports = { connectDB }; +{% endraw %} diff --git a/repoScaffold/templates/services/api/index.js.j2 b/repoScaffold/templates/services/api/index.js.j2 new file mode 100644 index 0000000..da7bee4 --- /dev/null +++ b/repoScaffold/templates/services/api/index.js.j2 @@ -0,0 +1,50 @@ +require("dotenv").config(); + +const express = require("express"); +const cors = require("cors"); +const helmet = require("helmet"); +{% raw %} +const { connectDB } = require("./db"); +const { Item } = require("./models"); +{% endraw %} + +const app = express(); +const PORT = process.env.PORT || {{ service.port }}; + +app.use(helmet()); +app.use(cors()); +app.use(express.json()); + +// Health check +app.get("/health", (_req, res) => { +{% raw %} + res.json({ status: "ok", service: "{% endraw %}{{ service.name }}{% raw %}" }); +{% endraw %} +}); + +// List items +app.get("/items", async (_req, res) => { +{% raw %} + try { + const items = await Item.find().lean(); + res.json(items); + } catch (err) { + console.error("GET /items error:", err.message); + res.status(500).json({ error: "Internal server error" }); + } +{% endraw %} +}); + +async function start() { +{% raw %} + await connectDB(); + app.listen(PORT, () => { + console.log(`API listening on port ${PORT}`); + }); +} + +start().catch((err) => { + console.error("Failed to start API:", err); + process.exit(1); +}); +{% endraw %} diff --git a/repoScaffold/templates/services/api/models.js.j2 b/repoScaffold/templates/services/api/models.js.j2 new file mode 100644 index 0000000..7838128 --- /dev/null +++ b/repoScaffold/templates/services/api/models.js.j2 @@ -0,0 +1,14 @@ +{% raw %} +const mongoose = require("mongoose"); + +const itemSchema = new mongoose.Schema( + { + name: { type: String, required: true }, + }, + { timestamps: true } +); + +const Item = mongoose.model("Item", itemSchema); + +module.exports = { Item }; +{% endraw %} diff --git a/repoScaffold/templates/services/api/package.json.j2 b/repoScaffold/templates/services/api/package.json.j2 new file mode 100644 index 0000000..f727bf2 --- /dev/null +++ b/repoScaffold/templates/services/api/package.json.j2 @@ -0,0 +1,22 @@ +{ + "name": "{{ project.name | lower | replace(' ', '-') }}-api", + "version": "1.0.0", + "description": "{{ project.name }} API service", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "mongoose": "^8.0.0", + "helmet": "^7.1.0" + }, + "devDependencies": { + "eslint": "^8.56.0" + } +} diff --git a/repoScaffold/templates/services/web/App.js.j2 b/repoScaffold/templates/services/web/App.js.j2 new file mode 100644 index 0000000..429fb37 --- /dev/null +++ b/repoScaffold/templates/services/web/App.js.j2 @@ -0,0 +1,37 @@ +{% raw %} +import React, { useEffect, useState } from "react"; +import { fetchItems } from "./api"; + +function App() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchItems() + .then(setItems) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, []); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( +
+

Items

+ {items.length === 0 ? ( +

No items found.

+ ) : ( +
    + {items.map((item) => ( +
  • {item.name}
  • + ))} +
+ )} +
+ ); +} + +export default App; +{% endraw %} diff --git a/repoScaffold/templates/services/web/Dockerfile.j2 b/repoScaffold/templates/services/web/Dockerfile.j2 new file mode 100644 index 0000000..d0f0fa6 --- /dev/null +++ b/repoScaffold/templates/services/web/Dockerfile.j2 @@ -0,0 +1,27 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/build /usr/share/nginx/html + +# Nginx config to handle SPA routing and API proxy +RUN echo 'server { \ + listen 80; \ + root /usr/share/nginx/html; \ + index index.html; \ + location / { \ + try_files $uri $uri/ /index.html; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/repoScaffold/templates/services/web/api.js.j2 b/repoScaffold/templates/services/web/api.js.j2 new file mode 100644 index 0000000..45db9e1 --- /dev/null +++ b/repoScaffold/templates/services/web/api.js.j2 @@ -0,0 +1,11 @@ +{% raw %} +const API_BASE = process.env.REACT_APP_API_URL || ""; + +export async function fetchItems() { + const res = await fetch(`${API_BASE}/items`); + if (!res.ok) { + throw new Error(`Failed to fetch items: ${res.status}`); + } + return res.json(); +} +{% endraw %} diff --git a/repoScaffold/templates/services/web/package.json.j2 b/repoScaffold/templates/services/web/package.json.j2 new file mode 100644 index 0000000..3fc042a --- /dev/null +++ b/repoScaffold/templates/services/web/package.json.j2 @@ -0,0 +1,21 @@ +{ + "name": "{{ project.name | lower | replace(' ', '-') }}-web", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "proxy": "http://localhost:{{ service.port | default(3006) }}", + "browserslist": { + "production": [">0.2%", "not dead", "not op_mini all"], + "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] + } +} diff --git a/repoScaffold/templates/services/worker/Dockerfile.j2 b/repoScaffold/templates/services/worker/Dockerfile.j2 new file mode 100644 index 0000000..5907b14 --- /dev/null +++ b/repoScaffold/templates/services/worker/Dockerfile.j2 @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN addgroup --gid 1001 appgroup && \ + adduser --uid 1001 --gid 1001 --disabled-password appuser + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +USER appuser + +EXPOSE 80 + +ENV PORT=80 + +CMD ["python", "main.py"] diff --git a/repoScaffold/templates/services/worker/main.py.j2 b/repoScaffold/templates/services/worker/main.py.j2 new file mode 100644 index 0000000..f2676f2 --- /dev/null +++ b/repoScaffold/templates/services/worker/main.py.j2 @@ -0,0 +1,18 @@ +"""{{ service.name }} worker — Cloud Run compatible stub.""" +import os +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def main(): + """Entry point for the worker.""" + logger.info("Worker starting...") + + # TODO: Implement worker logic here + logger.info("Worker completed successfully.") + + +if __name__ == "__main__": + main() diff --git a/repoScaffold/templates/services/worker/requirements.txt.j2 b/repoScaffold/templates/services/worker/requirements.txt.j2 new file mode 100644 index 0000000..a071179 --- /dev/null +++ b/repoScaffold/templates/services/worker/requirements.txt.j2 @@ -0,0 +1,3 @@ +flask>=3.0.0 +gunicorn>=21.2.0 +google-cloud-logging>=3.8.0 diff --git a/repoScaffold/templates/vscode/launch.json.j2 b/repoScaffold/templates/vscode/launch.json.j2 new file mode 100644 index 0000000..a3a4a4d --- /dev/null +++ b/repoScaffold/templates/vscode/launch.json.j2 @@ -0,0 +1,33 @@ +{% raw %} +{ + "version": "0.2.0", + "configurations": [ + { + "name": "API: Node.js", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/services/api/src/index.js", + "envFile": "${workspaceFolder}/services/api/.env", + "restart": true, + "console": "integratedTerminal" + }, + { + "name": "Web: React Dev Server", + "type": "node", + "request": "launch", + "runtimeExecutable": "npm", + "runtimeArgs": ["start"], + "cwd": "${workspaceFolder}/services/web", + "console": "integratedTerminal" + }, + { + "name": "Docker Compose Up", + "type": "node", + "request": "launch", + "runtimeExecutable": "docker", + "runtimeArgs": ["compose", "up", "--build"], + "console": "integratedTerminal" + } + ] +} +{% endraw %} diff --git a/repoScaffold/templates/vscode/tasks.json.j2 b/repoScaffold/templates/vscode/tasks.json.j2 new file mode 100644 index 0000000..851b5e1 --- /dev/null +++ b/repoScaffold/templates/vscode/tasks.json.j2 @@ -0,0 +1,49 @@ +{% raw %} +{ + "version": "2.0.0", + "tasks": [ + { + "label": "API: Install", + "type": "shell", + "command": "npm install", + "options": { "cwd": "${workspaceFolder}/services/api" } + }, + { + "label": "API: Start", + "type": "shell", + "command": "npm start", + "options": { "cwd": "${workspaceFolder}/services/api" }, + "isBackground": true + }, + { + "label": "Web: Install", + "type": "shell", + "command": "npm install", + "options": { "cwd": "${workspaceFolder}/services/web" } + }, + { + "label": "Web: Start", + "type": "shell", + "command": "npm start", + "options": { "cwd": "${workspaceFolder}/services/web" }, + "isBackground": true + }, + { + "label": "Docker: Build All", + "type": "shell", + "command": "docker compose build" + }, + { + "label": "Docker: Up", + "type": "shell", + "command": "docker compose up" + }, + { + "label": "Terraform: Plan", + "type": "shell", + "command": "terraform plan", + "options": { "cwd": "${workspaceFolder}/infra/terraform/envs/dev" } + } + ] +} +{% endraw %} From 4bebf1b1f8e38ffd691ea93d04ca7fb0444c2da8 Mon Sep 17 00:00:00 2001 From: David Gaspard Date: Sun, 12 Apr 2026 18:11:35 -0400 Subject: [PATCH 2/9] Scaffold more files --- repoScaffold/promptToPrompt.md | 2 + .../__pycache__/install.cpython-313.pyc | Bin 1780 -> 3110 bytes .../__pycache__/test_cmd.cpython-313.pyc | Bin 3197 -> 6694 bytes .../src/platform_cli/commands/install.py | 57 ++++-- .../src/platform_cli/commands/test_cmd.py | 77 ++++++-- .../shell/__pycache__/tools.cpython-313.pyc | Bin 1267 -> 1827 bytes repoScaffold/src/platform_cli/shell/tools.py | 19 +- .../phase_c_database.cpython-313.pyc | Bin 5761 -> 8986 bytes .../__pycache__/phase_d_api.cpython-313.pyc | Bin 6098 -> 6223 bytes .../phase_e_frontend.cpython-313.pyc | Bin 5680 -> 6581 bytes .../__pycache__/phase_g_gcp.cpython-313.pyc | Bin 6399 -> 7233 bytes .../phase_h_secrets.cpython-313.pyc | Bin 4418 -> 5073 bytes .../phase_k_testing.cpython-313.pyc | Bin 6764 -> 10080 bytes .../platform_cli/steps/phase_c_database.py | 174 +++++++++++------- .../src/platform_cli/steps/phase_d_api.py | 12 +- .../platform_cli/steps/phase_e_frontend.py | 83 ++++++--- .../src/platform_cli/steps/phase_g_gcp.py | 32 +++- .../src/platform_cli/steps/phase_h_secrets.py | 62 ++++--- .../src/platform_cli/steps/phase_k_testing.py | 111 ++++++++--- .../templates/project/docker-compose.yml.j2 | 2 - .../templates/services/api/.dockerignore.j2 | 8 + .../templates/services/api/Dockerfile.j2 | 2 +- repoScaffold/templates/services/api/db.js.j2 | 19 +- .../templates/services/api/index.js.j2 | 23 +-- .../templates/services/web/.dockerignore.j2 | 9 + .../services/web/{App.js.j2 => App.jsx.j2} | 11 +- .../templates/services/web/Dockerfile.j2 | 23 ++- repoScaffold/templates/services/web/api.js.j2 | 5 +- .../templates/services/web/index.html.j2 | 12 ++ .../templates/services/web/main.jsx.j2 | 10 + .../templates/services/web/package.json.j2 | 24 ++- .../templates/services/web/vite.config.js.j2 | 19 ++ 32 files changed, 546 insertions(+), 250 deletions(-) create mode 100644 repoScaffold/templates/services/api/.dockerignore.j2 create mode 100644 repoScaffold/templates/services/web/.dockerignore.j2 rename repoScaffold/templates/services/web/{App.js.j2 => App.jsx.j2} (79%) create mode 100644 repoScaffold/templates/services/web/index.html.j2 create mode 100644 repoScaffold/templates/services/web/main.jsx.j2 create mode 100644 repoScaffold/templates/services/web/vite.config.js.j2 diff --git a/repoScaffold/promptToPrompt.md b/repoScaffold/promptToPrompt.md index 45053c5..b48db10 100644 --- a/repoScaffold/promptToPrompt.md +++ b/repoScaffold/promptToPrompt.md @@ -1,3 +1,5 @@ +This is a great start. Let's refine the prompt a bit more based on these items + the CLI should be Python based, readable and modular so I or an agent can easily make changes intuitively without breaking other aspects of the app It needs to also setup the new project in GCP. All APIs and apps should run in Cloudrun and everything should have a big focus on docker containerization and using the same port number for most HTTP requests to make everything simple. It should be highly opinionated. Everything should be dockerized out of the box. diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/install.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/install.cpython-313.pyc index 5d21ee6c365f7b386143115711b69c14337ccdde..6cc457ea90d455b6a1474ab1c22baf612e248d74 100644 GIT binary patch literal 3110 zcmcImO>7&-6`ox#$rb-ZN|r1uvNBOpSBxXFsbdEcipHvJ$EHjP;fjjUwMwjaSJYbM zE;GA~s|5-;>A^CJz%bCl>P@++mqNJ|>P@GfDMT=ot>OfTTcC#mR%Nu;zS$p9Nt3dd z?qTQW&3oVboA>50nT#R$exBIZ#(9LkqKn{*dK2dG4>0)+5)eTGBe2sRxk8wP-M#R1q!1-hcOIGM3tgnE5F@cdoWu(WlECO@cm$pIF-Y=es5BG+w#W7f zpbxkAgV_iYq5>~;39<2zkPza7=r$L~3`UZ!hO$ZfbhiB9&nGo1eg(_jxhOBb^a}$NPHG z)j5Q8R`lbHP?;6jFc1R{JyZ^j`llrpAt5vivDUc`h~YAqLP8j%MX0nCI>!YPG5Wg1 z;uN4Vr7j=j*idHMJjHp0;s|(|KpUkWb+S9!=CAT)6mUuxJ92ilIm--q=@sx&xP#5H zayYP7h{HVbpOzQTcve(-F&-EiYw< z1hn=T=qa7+nnDS&rL6$|J_$XgfW7+9~tqjH+7)x$Nx{>Wlz~J zPtmG}^FgV>M18NR5mmuc*ROfiC}(G5;w_`5EVySiz1lwHmaAMYC+6J)F((8i(LNzo ziK^-gi$+sd#Jsn#dqfbsP}fWoM2LC1uvv^9PM7Mceb&Rk-r-3o$*yed%JAj;#Mq3D zW8#Whs~L9|ik>|0^n{rAw`~sNF>JFF*h%;`zOtn0mi?x0%Qdg?Q)_&ce8BfL312np zDy`YaE#urGj_f{+MMbMGn9Ev2%+nc$&mO{~x4_yOcb=7EE_8>^{{G%AU8aS51W4j+{)TN7(20D1| zrTgz5d1|t08Fk451%sxinOJTTqUsj(A81Xp!r8!bO#5IC`(6T~-h?)}(spn*=0s($ z8#>_z0oRfUq%)z-!-+_ZhN>%s)^WnqolsS^2(5t*Ybm#&US-mP%u}>O_cYHfxqiWm2>0=gj5Hrb^7bBE7FERmp5fM9G^*lgMh` zl!?}`Ovtu|A;{9=BII%2Byv7@1ytY_ke7`*Sg4qJ-!r)eXywnzOMpeoj`=5OoOjW4 z9)%P4iXRkPJ#X!D7r%Vv%-y%YNDr@a`{@_fF0J?dcKXxw=xX@M*&pm=zTdh~SdHxS z-4BxYlWXkSg|*T3k@dNaGauhvpK0}<+vVT*qWi>Zb1%{RVCMeJ+8d7(_#lolFa9%y z61_fOtN-#Ye`SwP{WY37+8W>GC;prnSe@Jl*5dslAZOMue0*v>zVX8iX*0Exd#g2e zu{AjPIPsH%5b8PgC7*g4?Y?J!VE=mQ(d~z~k0iab%a>e9dnX4TH6J$D=GRM~o*Z4h z`lR=T{q)I4$%n}nKEEk#MLy9s#n#1}t>J~;^v^y^5BSinp~>yh?ZMXI_1#2qzi(*m z7aKP=3a!&`Zujo=U0%Ji*WLGE<-y94tck7Hx90yizIC=WeC2Wa?WY08h0XW2`afCO zT;A^4p4`@VE|ptT^R3}`AE$2|^nnG>`%zb#I`X6Lr=CZju}m`iJQV7R9rAGCe{#k@ zs51^BoG8nY0T>+a7Ja8dW?4<5JKPLht!&1LRw{}iS1L}dQdw*QNv%`}eNQ=I$QXIq zNf1q5%K2A-&{vexe>|yjCOp(?Id~>|c|Y(iM7PAX?~Qv^yi3zd<-rAW^U{eJ4f+Ok zQ}K6*i~x~fdV>E^-B6k}^~WR+OZu=ge+s{6A%cgnJMNlgxGoapE=;@ HhKv1goDRll literal 1780 zcmZ`(-EZ4e6ug`#T?lR4R^O z>a`p9d4+>7qIybiHIPP66s}Xi6%7m_RKOvn48R6EL0h-d2}H-z`Ji_ z^ycmINMyx`$cfRn8F5UEO`%WNP$OIk*I)|q_7xCs8M0HiNOM*(bgY}UBO3;;&dw?u znzA`-nZ{NP|F!)sww?C+Ib7EbLsLNxmvxQcxA?z3lLg96$fjvIvZGt3z0ye@)h~Bm zS_K&FBVxz04Er2bEe!IZLB2q{QQem6hTL%34b5mNlm{FPj|B!HYVZx*0$(0iXo)g` zFY_5+ORULYRAEN^m~4ezON1b!Rb(#&=)iTvj8v!-==};pCRd^JsKSX{1cXBwGg^tx z1j;gvkQiO1#F!a7279a$OCxa%P~sj+8l694?Y~H+%Yc>u`~UA{5-?H$1|52XQRxOk z2?RflJo9P-p<^)8BfUoU7@6^rOa+CQ>hYMFk$fZD$QC9#FVA_&f`u@gm}sB2bpnYp zcWrSPah?Em2*R9RD}Gp6Tr18?<<-?|qMJ}PM^hZh%L~Y(=o}=FWoVs3yt-}~YR!k% z&Bh4R&mf-XtAZ~OFT0G=R68fD4Wemgt!}kVwJHn`orx;AQETe94H#9yJ9KD_UDlMF z+CpanhhuS=`j@bq45~od&`qavcIXEl;$4N}@a<#YOyEQC81Kx*t6Q33SfAHKtEmN& zGSP700FMJR*xAsUSTR}$P?OI@xX8^4pfo!X^$FYeCl<@d^WU;Xjs-bycb{vh_|@8c)C?f&FcccGs=ai?^< z)SKq-IzO-M3v)ewzBg6;ExGU{g|cU!W>730K=pFPd$EN-hPb_4?I8B?qw(iMfT`>E zk~a<+l$jcU|ER!{3@k3Y?(xbI&1nB&n7nNp4({>TP(^B#A&@gy2D_ zl}#7o<5T5wR?F+eK07}latdTf&J&DXZdz*F(B2_=IC@>sz6#SJLs8UyRD6i?k5TfF vLv(cK^sUoho#|8D&h)M6uDPf7-YD-=l>wcjKB5K;qObdN_6h4<_$K}ZOmo9F diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/test_cmd.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/test_cmd.cpython-313.pyc index c0b9b460e41c809971c587f34bb11151dd85a3fa..23b0aacb1a0b4f17718b06a2ce6281c05b36207e 100644 GIT binary patch literal 6694 zcmc&&Yit|WmA*p`-=rRV=s`;ISbmAINZOWSSB}=HEkC7_QaB#l32ifD&PZZH4Y_wn z$0CY_nl2EzTfhpEtd&K9I{hUh2C4uBV(%Y&``As;A0;a_qNjEnZ;Ar`vyiLqBFx&Y649{m$dwbLQMxaX739E`RgAWM@4>UyujwvX=>JYwkqPS1_^VtTF1=1k~=G zh&4VBQpg1yO=W6z3mhia%<5T7pB^|`R*#iw;IOhb7*j1;I}T%Z*3kzVvvq8JpW&$p z4Q%5E2u{}ZjR?d%Qc}*!`BnnRWYd~tMq>>6^#DGZ z5wQhSQRs0omB~8-fr^F!A&CJ?P^e(OOT);Hi_DlPiI~sUY+-;COiJU)VBQ!A$gd|e zFj3Bbo6TL2vp6Y@b;*7Zn@x-Z&B$3X6Hf{<(TVUfU1D%7$O!4g>mu$3-(k!XNBe`s z)(J2#nMsM5#1DZ_OQ}hKPv?(p44;$|shl7(UFnN}niNEtsrZo@O{PGSKbSC(g{WL) zuj>(_R{q0`(0M0BDTz=tNJ&~W>IU_aQH@MBN+pv@8-|R7CJCYw_>Dv6L5pM=v`W@N zn`Be_Ohb0b9`k8pIGq-ql7pkVD&4#dp;bJT#xqQF4%QriQK^ori?(YhwY#>Pt4Oe* z9OMbEPQ#GuhrOHydaUqwL_2D8HHx#<@(N@H9c%5Qqq1D@xzO=u(9X#@ql|`EyR-KFXAlx}2!%bMmvclHZbit37$mu14qerH1V(8< zbyrbcfa>Pl(JeJJjnZreG(c6+JizNBTAi=ywHcuaXKb4`i*xo;tOGKEne(`+c~tX~ zrirTSCWKBBT|u^{>+lHQmGo2oUS1=!mJUfQndQ@ei?(hI<*>IAx| z{@YR0iPKj#f-J@9*Q^ThA6b=W^`G;Z{8zV;|9G<2>-djzX}SvVPKs#{D7v%e2aRIu zwN+uzn29ryeakkGDA4+)X0ACJ(D-Y2ZD%kWNob6Pv{{@>L#;!svq!HzF(`zj7WlWq z-%Gr6{8R9$kMl)$YjbONu0flpna01kp`9+kT4z7hp`L1%U=Zhx_SEEP)cP|u9T*gb zxa-)+3-0w%o{;a0YHrnp!@XwjhQ9VZla!YKla;?NN5cmDskP>p4LH54_C<>}3-*O? zReu!?`rB6Zdsp$d_n5RNSix4`8c(#c~!yc|J z+OOf)?$HA^TBD@)4ZP6&v{+zUxOP|>TGO8FnrDxQ_3Z0il|wdxEmGUTwqv@mK{7(bTT`h&Ov31$8r;*l$E&sp(O7P>4Ka>V|N5N0^tWZOV%3)32LNv6H?5UNrKpdd9Kw!QHr3M5y6f^0@lRAjfuU%37 z*Sf0Z4apl9(R)zBYQ@YL+NU3-u2YfgIyhOB>v6@9O1~lE>v}~O+@(;GLdh}#Wtb_2 z3vp04%OQckm=wl%Im2TiB&TyYA%^4xPG+)l2#cAt=KYY26Cv%u6o&&-D3P83KM8V( zENnb6Ap|p%ilv{NB`#&~0i~Jr2m4d$1fP-*1gitbKm!@d^f_FMLqPxzbE zZObjo?fP5w3*S2NS!7(g~(m9CKSg1^rk<~*P2=~=v4?iJRcwj1=SI>>|$y5w>if~Sy zrrXabDBo1pkIlgj!IS$d$}o8gL%>9#n~-FtN}AO(V@4FffHLUOgD1XI0exsB(r5zy zkc3peyKFpDR`wvIr?UBFX$^@3F-xv%TIHhMv&Jua7=u@Jk*ks7?kT=ObP>g&g zBT9mzhm}_>M=mAA4EbQ8=)q{kDrZv3YzjOjE0#D!JFHqfu9)L-k_ds)%agJ~!{>;s ze=|0LVC*Fglq&GmLB*#@-dFvrdQf$f>WC*zwXZf+p)(|XIyCphGw`KDp(Yf4B6~@( zz{@CJb_O~ADOR%bNP!@cv z=*3G3ML!{rfniDT39OkytL78;lD)U~dUfq%IU|sbib>;uzx-WDv|mo4|2$41L;rnx z?|pjDeR}^N=`%(8%AQd9mTDuiuTjf)B|hlwcSPQzNvu+ z4Xy9krcT^#-@ee;_fId+QgfSsy5stedG>nn>gM-fo*FDP_WjvpySx3l1?SG62-g~C zc3<81_P&4Y`#_jB6`VWoZr-`*Ir|gu?7%dm zG?d)FtKql9?+i~{V8VhYLMF^ju1R^tP*|H3*-+xE1mI^qi+kh6)8`lV#+Q2H+T>UN zZ1dbR*I#j5b`+Y9E}D;(%ykd!?wON?wqVf{T(F0xj+AK2TSMO)`up&oXxm%i?}ZEQ zoi_&Ntsf2-cJ>$ghYQY;Mf#=BTf9?8OIx=?)9RZ#_CbLtnqJC09YUOfFuA$DQmbfR!dC-i|D|w_b~0;}_gr(}yeC%^tqyzGj>ad}i-j z(Ib!lkpX#p|7Atyx_f5Z74r|wGmSsAY3eu+{f-`Z{1=fz&i#-7VnnX)$BO4Hp_+!N zYR9Wz{&f#(Xk9^ia8vKToBEHBe{}raEi;LCx|W>Viq35d&K+}9(fQn5@7zGqx$DlB ze+&FeVCm4A;-NFs168l>p5OX$_eb6H9XIivEq6Qx@3BSu@shplJ9Rm3xaQaoTMFJi zOZJ`xd(VF_n?T}M@+pYIpS#?VUG&d)8cBP=8+o4o#iowPe)^Y`k+j=7BQMgwvKdL+ z;T@*wU%l8dY@z>kvk}@)w;tRzT&Md@o%N+&!*5y+HM|ru6hj7h@Y~gt_mve=CG%Y- zl7_lfO@A?x0Z0zS@QncBBhY+RpUuIi&KR(EGw@zMhmVpWQU@wVEM{|9QfPwUuTxC% zID9dM>}rk2M{_U)nHH0J4G$1@6Ul|C|2~Q<*E( znPS!odc~MlGrXFjuA>#;LTcht%>5H-A(s*l;3V{sm#n-U+GRaOQFjq{A9a3#toKpN z7s!4eF%PVb|KOi8EgO-}^j7Qljv33fBlEpQ>RaTw?aj6l-7vGY zWU0I2{egG3w`gG?E=rcIk1PhAZrM%%R3_KV$+b-NB}>nfO!jG^n67aGj&`QB|Hi=bd9PO?_ zs*s!>P!U`bjSwk^h7%H8kb2`td*VhEA*EXkjrtFywJ0i5C1&g_ZdD=X@SESv`@Q#O z-rJpTUw)DtYAT8Z$moA`xBQL=fctde4!KR3=FyFzO#LFC0%HdzQ@F?#{I0Gur+~(f zG3_-`1UnX&5rv?M8mD=(98rYb{mx7jUQHslXlIYG3#}C~%9=0BkN}G2&+`8nBA^AI z3-N4(kQSy9w5S%rH<&?>DKBxESnU*cm~`B-YnP6zi{(niSWwGIeYb3w>M4AT?ZR)d z6~=>qWT)yS?x_=sgp)NX7Qrup#%OFF}rW zTu;%0^w91#P)v^%UZiOT)n{B>=gzE!E}X6xZVg_fziZfgaQ% zS~Sc4Z{V1F8Wk*gEuyzr6ka6gvP}HSP@#rY+pug4|Lp5mOqx5xIPmSFy^QNhT9!21 zSS2E4n0QP18W(~{o+JvvG_2Z+fj2@Y0zHnnO7xZTC6oSb_@_`m6TtUEDV6?OPWNi1 zXfIaHmHASoJZhk&GBQTZc1R}tx#}e%GeMR7>T=OC9KKTh*f1T@BB|%g3;2g{Us@y% z^TkRflQ0FU@21d0fu=MA1n`;2`^Ox{awK}8dFpLBUhAXMd^;X**+ttRCvT;;5}1j` zc$E&k>woWclc#|w(6`3=fIJXu;7=9seGT|(1HRILV-5IH1CIXz=eFV8UD&rL2iD~s zxp(c{??*;A@M3g|e;|NRH@+E7PxyDEJzq_4Oyk+lynBg(Cdb7kCo!--up3Bjp52tn7ssE*&e!iKiB0L7^E=A$+T<>juFYJX zSwFd1TbtR2hkt|ewdt$Vzl0}#g>O85I0VAuj~{x7yOkt2xRs1fO7PYT0>Rsn)MNnO z&I$xGo>m3_mMq&;KT&6@q_wSTj???ok~$nQ(GJ@+$wUsMl8g^uPIlKFp7gME+3}g> z((-7jimYnIFd_BKP*1T`U0Erj1; None: """Check / install required CLI tools.""" - # Implemented in Tier 7 - from platform_cli.shell.tools import REQUIRED_TOOLS, detect_tool - from rich.console import Console - - console = Console() console.print("\n[bold]Checking required tools...[/bold]\n") - all_ok = True + + missing: list[dict[str, str]] = [] for tool in REQUIRED_TOOLS: found = detect_tool(tool["cmd"]) status = "[green]found[/green]" if found else "[red]missing[/red]" console.print(f" {status} {tool['name']}") if not found: - all_ok = False - console.print(f" Install: {tool['install_hint']}") + missing.append(tool) - if all_ok: + if not missing: console.print("\n[bold green]All tools found.[/bold green]\n") + return + + if check_only: + console.print("\n[yellow]Missing tools:[/yellow]") + for t in missing: + console.print(f" - {t['name']}: {t['install_hint']}") + return + + if is_macos() and has_brew(): + console.print("\n[bold]Installing missing tools via Homebrew...[/bold]\n") + for t in missing: + brew_pkg = t.get("brew", "") + if not brew_pkg: + console.print(f" [dim]skip[/dim] {t['name']} (bundled with another tool)") + continue + console.print(f" [cyan]install[/cyan] brew install {brew_pkg}") + result = run_cmd(f"brew install {brew_pkg}", timeout=600) + if result.ok: + console.print(f" [green]done[/green] {t['name']}") + else: + console.print(f" [red]failed[/red] {t['name']}: {t['install_hint']}") + console.print() else: - console.print("\n[yellow]Some tools are missing. Install them and re-run.[/yellow]\n") - if not check_only: - console.print("[dim]Pass --check-only to skip installation prompts.[/dim]") + console.print( + "\n[yellow]Automated install is currently macOS+Homebrew only. " + "Install manually:[/yellow]" + ) + for t in missing: + console.print(f" - {t['name']}: {t['install_hint']}") diff --git a/repoScaffold/src/platform_cli/commands/test_cmd.py b/repoScaffold/src/platform_cli/commands/test_cmd.py index 548544f..9544dc5 100644 --- a/repoScaffold/src/platform_cli/commands/test_cmd.py +++ b/repoScaffold/src/platform_cli/commands/test_cmd.py @@ -1,6 +1,9 @@ -"""platform-cli test [service] — stub, filled in Tier 7.""" +"""platform-cli test [service] — run Phase K tests against a scaffolded project.""" from __future__ import annotations +import sys +from pathlib import Path + import click from rich.console import Console @@ -21,12 +24,30 @@ default=".", help="Path to the generated project directory.", ) -def test(service: str | None, manifest: str | None, project_dir: str) -> None: - """Run tests against a scaffolded project.""" - from pathlib import Path +@click.option( + "--skip", "-s", + multiple=True, + help="Substring(s) matching step_ids to skip (e.g. docker, terraform).", +) +@click.option( + "--only", "-o", + multiple=True, + help="Substring(s) matching step_ids to include (overrides service filter).", +) +def test( + service: str | None, + manifest: str | None, + project_dir: str, + skip: tuple[str, ...], + only: tuple[str, ...], +) -> None: + """Run tests against a scaffolded project. + + Without arguments, runs all Phase K tests. Pass a service name (api, web, + worker) to run only its tests, or use --only/--skip for finer control. + """ from platform_cli.engine.context import ScaffoldContext from platform_cli.engine.registry import build_dag - from platform_cli.engine.runner import StepRunner from platform_cli.engine.state import RunState from platform_cli.manifest.loader import load_manifest from platform_cli.manifest.schema import ProjectManifest, ProjectConfig, CloudConfig @@ -42,16 +63,19 @@ def test(service: str | None, manifest: str | None, project_dir: str) -> None: ctx = ScaffoldContext(manifest=m, project_dir=pdir) - # Only run Phase K steps all_steps = build_dag() test_steps = [s for s in all_steps if s.phase == "K"] - if service: + if only: + needles = [o.lower() for o in only] + test_steps = [s for s in test_steps if any(n in s.step_id.lower() for n in needles)] + elif service: svc_lower = service.lower() - test_steps = [ - s for s in test_steps - if svc_lower in s.step_id.lower() - ] + test_steps = [s for s in test_steps if svc_lower in s.step_id.lower()] + + if skip: + needles = [s.lower() for s in skip] + test_steps = [s for s in test_steps if not any(n in s.step_id.lower() for n in needles)] if not test_steps: console.print("[yellow]No matching test steps found.[/yellow]") @@ -61,6 +85,33 @@ def test(service: str | None, manifest: str | None, project_dir: str) -> None: state.clear() console.print(f"\n[bold]Running tests ({len(test_steps)} steps)...[/bold]\n") - runner = StepRunner(test_steps, state) - runner.run_all(ctx, resume=False) + + passed: list[str] = [] + failed: list[tuple[str, str]] = [] + skipped: list[str] = [] + + for step in test_steps: + if step.should_skip(ctx): + console.print(f" [yellow]skip[/yellow] {step.step_id}") + skipped.append(step.step_id) + continue + try: + console.print(f" [green]run[/green] {step.step_id}") + step.run(ctx) + console.print(f" [green]pass[/green] {step.step_id}") + passed.append(step.step_id) + except Exception as exc: # noqa: BLE001 + msg = str(exc).splitlines()[0][:200] if str(exc) else exc.__class__.__name__ + console.print(f" [red]fail[/red] {step.step_id}: {msg}") + failed.append((step.step_id, str(exc))) + + console.print("") + console.print(f"[bold]Summary:[/bold] {len(passed)} passed, {len(failed)} failed, {len(skipped)} skipped") + for sid, err in failed: + console.print(f" [red]FAIL[/red] {sid}") + for line in err.splitlines()[:6]: + console.print(f" {line}") + + if failed: + sys.exit(1) console.print("\n[bold green]All tests passed.[/bold green]\n") diff --git a/repoScaffold/src/platform_cli/shell/__pycache__/tools.cpython-313.pyc b/repoScaffold/src/platform_cli/shell/__pycache__/tools.cpython-313.pyc index 945f0d6c60f1d99cd93c57e6d5c7d62dfd2e8408..f8ebe6c0e5eae00ad9f8d9ec0cee81db91414942 100644 GIT binary patch delta 897 zcmZuvO=}ZD7@pbPY&S`h-6U zHF(mzDT5pqonF zw(DlCR59(%YRRc?Y(HAVwJoh-Ij&)vTB%eqoDJ>oEKMugSlj<`Ii(A}U>VJt&sUmN zUtq;v5x$&{omW=v%0>-uul(=UUaWVQ_a5eEc9{A#lfwvl#&iT?a z({P{Ixao_|bH}YU4*^zb1x{1&rG`^(8Wr2YbBR|Q@E(XiOGtD9!+7G}Q>)9$>Z(BUD3J}7(!lczxb1Y#WtB=STaA<86Ct?%Jb zNbJNxVwAMxEKzbqUGMucLD~dSANO5ZA`cTKP1M_cSDMH}L`f2LzVDhMa+)al4hJ&J jT+h2SUF3G+ZDpzprXhFs5OPpIi$JLVlpwsqW(mPBr8U>i delta 398 zcmX|*ze~eF6vyu_m!zpp)7Yj>Q>9`GX&tPfE}}wF!To_QE@F#6XcZz(3hJPHxj(_l z$-)1@+3|=tNdJLIsDqQ26!YL7pU?L_9`}@wG%wQiEE2W$^Kp7l5gNtvU)lt4OOCD*XZ zG^M7PBJ&N_R;=52{EU1nPKu%hnf1X%_p0C9^k;=)@h%@l3nh$kfV@vMKSFw#L8i-% z3b(huRVpiC4w*CDaJYSvdNrAEr0#IT;`UDJWp0%CioKKu4Q`Y}f~p-diB{`Pa+iNE QG$K^TWW0?DZY3Z615|xl6aWAK diff --git a/repoScaffold/src/platform_cli/shell/tools.py b/repoScaffold/src/platform_cli/shell/tools.py index 3aefe60..576fd52 100644 --- a/repoScaffold/src/platform_cli/shell/tools.py +++ b/repoScaffold/src/platform_cli/shell/tools.py @@ -1,37 +1,44 @@ """Tool detection and install hints.""" from __future__ import annotations +import platform import shutil REQUIRED_TOOLS: list[dict[str, str]] = [ { "name": "Google Cloud SDK", "cmd": "gcloud", - "install_hint": "https://cloud.google.com/sdk/docs/install", + "brew": "google-cloud-sdk", + "install_hint": "brew install --cask google-cloud-sdk (or https://cloud.google.com/sdk/docs/install)", }, { "name": "Terraform", "cmd": "terraform", + "brew": "terraform", "install_hint": "brew install terraform (or https://developer.hashicorp.com/terraform/install)", }, { "name": "Docker", "cmd": "docker", - "install_hint": "https://docs.docker.com/get-docker/", + "brew": "--cask docker", + "install_hint": "brew install --cask docker (or https://docs.docker.com/get-docker/)", }, { "name": "Node.js", "cmd": "node", + "brew": "node", "install_hint": "brew install node (or https://nodejs.org/)", }, { "name": "npm", "cmd": "npm", + "brew": "", "install_hint": "Installed with Node.js", }, { "name": "MongoDB Atlas CLI", "cmd": "atlas", + "brew": "mongodb-atlas-cli", "install_hint": "brew install mongodb-atlas-cli (or https://www.mongodb.com/docs/atlas/cli/current/install-atlas-cli/)", }, ] @@ -40,3 +47,11 @@ def detect_tool(cmd: str) -> bool: """Return True if *cmd* is found on PATH.""" return shutil.which(cmd) is not None + + +def is_macos() -> bool: + return platform.system() == "Darwin" + + +def has_brew() -> bool: + return shutil.which("brew") is not None diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_c_database.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_c_database.cpython-313.pyc index 96404cc1b5768faca82aa33e7042441c23551b98..63ded172fbb225eb2978f4169c2c1264e0cfca48 100644 GIT binary patch literal 8986 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 m1*NLss5%*84OEbMDkxP?II1Je*v8N~&MEbA* diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_d_api.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_d_api.cpython-313.pyc index 7640f74e18994e3591fc4981607046b80b255d7c..604344aad3197d631ab6ffa1be9dfb25cd02cb5f 100644 GIT binary patch delta 624 zcmcblf8K!aGcPX}0}$wo-^q*-*vQAo$*8$mfO8u&qu=D?JS~iVn|*kl7}-34N{T!t zPvJ9X^qAZ$n6lY`pPP{>jbX9^x0*p7Q#3Qk3=jxr4rK~v(dA}fFlB;>C@>(&vI1q< zCKn0_*R%T-DFPJ~0aY?oNfoCSm1QQU7V9S#Wa_)*CugS?rDf)%>SY<-;zDq%WKq=V zrGOP?rsw4sfs|J9qKIfR6{&-4-~?p&w`{6%#4CsUznMB`6kN=8gj*e3;_|vlP`*@ zqWLLiGOO52ZgBjA{T(y;r`S>xAx5DY5}z4>)MsV}CjJjhK-LE>AnOZ=HK76_^${ch G4juqV4WL2* delta 530 zcmX?aa7mxZ`x zG+F(sq>59E$}*Evi}e!=GWA{Zle1Hc(lT>W^|FkL6oG~n0d+Cl;zCGhGKmy%f+W;I zgd3131{pe8TToL;3&?0-xWgfKgGb{A4}ZUZr~izsi#!@DOfT{nY_Pe=V?TL8~C0}F5j2$OtEX!HEHZhPEPMge0mU^93Wky`gQ)JpVz!vgp@v+jy6K3eN$(K1K`UgV-4BSHQ9EwZmiN`T26aYEn|{K z-WgH}2@nn6zgm9-dX&8*9xuP=c#S2~vdzN0&O0sC$YkvqvtZh0CTFe~vyNmH7ird5 zX)l!WS{C%Sq1`iWV>|~d%hpz6+VC)(wvjKPNOMCxS%NempeK8O){$*9Zxk18M?gmk zPeB|{rer?(QvlXa{s^E8I?sLs`OtpX@%eGfp!dwIVU653ZjYc}B3mrXne&OeR^slt z=b>hi&Oh&J@-Pdt#!}*rozJ1Q|7ff8D;-Ui`HWeBpi5x4qFl6ScBCQE1xGH?;$0(a zQ>2*Vg`{UnMQS@<$m(DKhZ#W*yrjAs~P@`Zwd~u=fFQ6|) zemmZLreMy&3#8}pfdfpJ5`&J6H?Zzy=_m;4x6x3r;k`N@YUC_@aT*@%ed#36bL^*5 zUpdge6F6QC9N!6O)qqyhhkxHyjb5%Om-i*2cGf}zkLG`s`(^IY4=bY+73J#VNOXN- zb8=(y^GJU!*8Wr$RR5Z^>nDo(aO~mOR`*V4yxJMx=}c5R6Bweo!P!s4Ubp6<18d6xNGbUImcaL zzm~6ar`dmG&EGFx;+_#i*5my#H_krx{)PJvE2&-V1D}uo7lAROm$@ViotKZ4=df=* z&_b`W5DqP1q%?K5kQ82yM+kJb2vfq-Wa&u?jYfaL15BlYG%YtoIPg+lx0}VZl=6)U z(3t+Io2fv;bCT%FBs~G9v=0GmPKOZU2*U^ogb@IpKO)L|y2ptCTgyw!Gxbhcq{i%# zUKi-==!Zn67ZH$$x=g!q9YlB&;S$1I2!|ZBFmso9nnpvYx&q*+Z@Uc%w|SQqmzEQB zv2YD1zD3zjq>k{Hp_Uios&%djX0e$;U zHJq+^(-g-loj|yX@Lhx(2sZ&N{2GkcvzkI7!Z`e_(*XbdkbD{HU!ABa;ngcOl~t5b zOGvy4EJCbDo?`!27P(u@2x@?TXnDw5L*3kufL8tdI>&wx`Wp*|16+_zhx*?R9n9cY ze3xGJ6-tH2JQ{&&N6-)GA|}J^1EJ5Gfro-k3v9X+sD4XIILkrpRW=$vJ#tv@mG-7l zb*WTpOe*9Y_ql9v z#QegnNnaubeFH;b40;~GkroR%v#_8`M_h4a9Dpz^8&uK2&Yv^pGuh>tOsQl^;8>^3 z0b`gF>5#G@wf+J0Xk+7%^S%Kn{<>LJdf9436ZieZ*Up|syUMZl%^Mpxc4C9o*x)l+ za0$`Yv$a%hJ-z0y1zL9k-PJ&MeeTtiwjHIXs`UI_>8(Wu9@&*}yyA_2bJk=aCyH-|?n9&px6OX8eqVrjBCsuA@1*cTi^B{_ zi7r_@DG5pNf=&jV+y1Zda5p$Ka+W~xeUtT0KLH}z$Ie81%Z~qYF}q;UIWuSIT>S%t zADES%CSoLJT`P0GP^1Qa$bwB0=zX{CFJNGI3b?&59@Vp6#(Sv8DPZBBGbo?$u;JNf zQB8V)s@v?ZQLT*ge`I6i(eYDGH7pa7qA*^Bw1B9hwy{#cp#YR@^VNg3sudMD(H$NU(7Rl8 zychGu*~OgkCdFtR{H$NF&DJ;_;x2LP-aUfXJrCi-yF4y;-{)Yx&$n?mx%H_%g4Y8z lE_ctl+CFZ0{mveN%YhD7yVo0@>K5qQIMvlXg;4dZ{}0B8ePsXu delta 2088 zcma)7O>7fa5PrM%{&@Wt|Ar(^vds_RB>o`*iV>IsQUr5=#%QYqGgXbxPsg{11GJ#^mM#`M(g;d}FD z=FPsH`DXUVp{)VOzTGY$cz*u#ar(aFo+E6wa&>}hFq88j3mU&B9g3zuZ z0lk=#XA+AU?fDV}3sO3#rL%H=QB%lOiUCztuB4N)8o4Te94RD{3yE1dJg??+;rYQJ zt#bt>KQAXWgfIdvh({%F> zd)J0sJsTHF&b|`ghqo+Y>L~u*lA)q_QkbB6@wy=L9;SZ}0pJJ1r(k%)`U`a&-|)4z zy=24$lbIM zt}ekD3-E`QtRYa0K{oBY}3hnZYDjusK_auhfvdnyrKmy zx|uLcAqlDy%S`l25cWEg$tO~(ZUL&LmZvp&Nz;X^N?MbvGLu@&7StfG1ju1Kf%gau zkh6SYVRpKZ(B^cGyiC24RG^KKZUVi^gCy1-h4h#bB{uh1{fg(w*MybDTnzN;*8uEw z^qBYT;CsAB@U-6ju!rD?5iv{IJqQE;vYFh`@0IuM-#rP$@6qNcAhmh?YPUf9B|Q*_jb)4-u@!7{g!D zDSW``qQ-HbQ>5O)552ds?hH`x0@b_e3c`V=Uj@l7nT^Tfz+0|EA)qEwCIE{5e@%$d zLII`&%!VRc6@!#=9<%OleA?|_W{i^ROm-oaR;uG*iUcAvA;>gJrgJcDrd1`WTqI^v zBbCfrqdKqD#`8WY{5Xs<^{lKV=SXo#K=nGnbyRWt)+1|?jft{5yyYvq@n7x}mY#~u zvpQF{b>LGTadcNeu7ev|$vIHs2mULm5VV%l%OENQ#_^t~*Xd$HdkCN!;Ifg?TizCG z27l*$LS;xEg)hMS4jKBCF(DrKjy1jV{~)7-C5A2&85z~VAu&XTF~Y`7B8npDL++e1 zj%WRy(~VBlg){l&f~?G>GcvT9(MGvMRt}nLy+bxeijC}py3ra&dh>8I)eJz=TgxtX zG>fcZcpD#W7ERB1KTddBPy5>UII~57v^BX`EdM`YX$Y?nn86R5dwmwhy@vp*0f_#b zGK9GUPX`UQ8tFBT7z{VKPC8QOPi6C|#fQiLt}ZK`Xyxn&^RM)j}q`v{hO6QZ5F zG)TLrK>CCBQ6tpq#eGE9nvsaRQN!9og;wYG5iB(uNV_KtlZ$fS1rscW=>-jvap8Xe DnZ4n- diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_g_gcp.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_g_gcp.cpython-313.pyc index a65e74c4b304e6f8bd38fa51d08d8f8159ec32b4..89c22e4fb646752a5bcac026688e7b975a4f8248 100644 GIT binary patch delta 2150 zcma)7O>7%Q6yDjj9ml_^6X&mWGEG_%x3xnQRc+HhP@6_cOA_LmLL1fDcqjHYo82(G zPMcf;7gR(cpgB;)fpVff5fp@4i9_KC92_c?XjKVSf=Y0TluC#Lf_ZDZNh%IqdB2^V z_vU-=oA-A7Z})eHLmR=MPvF-(frskjhOa^~QKTYEh)w7lb~6|YT#~>FR|>ING45^^ zgtWx&i*@XAq>lX>^q%M}*#*Tf2$%R>x>DjnR2mVqg+xk=A))7~ZPdjcRqvD|eC1{! zSIH;kPI-=qSV+0kVyY@B?%C?&1)o%P8jS`j5jUSaf~yi;ccooUl;#Cx=e7iW4uzka zLz_!0qpcL%1pHre0Z1+$9GY*Vk%g9tQk2~AIDan=$I0V&L!uY%Jvy*Tp?M@#0oL9RGlE0y(#VT zoxg)@cQ#M5Q>xPB1l*ksr{ijB7c`@#gF|SZZ5}(hjCN7u-S<~k5 zG?8@^&lv@*6QW=lPpF!v>N!UOq`My*G$-roylkn4et^G0iceVwPGiny$x~!OPvSn6 z+bHgoizhzWq9ILuRGtHSNiD_ZWz_O0XHPy5rdL?N{%(z+#+c^=h z=-QeAr*Nd4sc?d5UJ=vKcp-F!Z)Sq>&A!TW!-<4BrOqT~^0G;Ar*7c>c+ywiWkRVz zd)W=CX@NJW5cg7eP%j5tG^T8iX(>j*vfZXd)fqd$D`=_Hqz{(afr>a?o+gefr;EF7 zmr1lqTgqD395!3Jnm23@C1ydhOgE>-aM|guv`MJhrO30Yl4E~M`Gr5Z@hxF3+W7J1 zk1k*LJ@E+P`eOZ|2cg5)rL}NmadvU`X6mzxpIlsxC01gI)mYa`tm|Qw*{zJ@E;F+F~?$d;YdotUda`e{9u%lK&p^+4>iknwJ8_y5slH{~Q=x+n2n1WurO_H+YoK%c&D1~ zg#|AFbO6A?NaFze0QLg_9ZdjiQ3bDE73g6XylozbybI*9AH9uekp1TEK;6rM>Q52s zVYhuI3bS4Q{n3~BvT|cW7v=aTNgLUizv*Zlx7ZT@>f)YFwWU2c1+icyjd3tN9GtGh zWyx&G--K(UYG+IWIqJjOR4udshv6K+h#;3}^s&{tab8V@zrI`jFFjP6-{+j^p+M+umc-hEmYT3954j#BcKs i8*bdw+>ZLuhu1a)cy3677+x1)xPA_?8;ynwn&&_3C=i+e delta 1370 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≺>kRh3G+1iLFZ73cXrsE6WPl`0Nb*iSfI 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 diff --git a/repoScaffold/src/platform_cli/steps/phase_c_database.py b/repoScaffold/src/platform_cli/steps/phase_c_database.py index 33d1c0d..da9f081 100644 --- a/repoScaffold/src/platform_cli/steps/phase_c_database.py +++ b/repoScaffold/src/platform_cli/steps/phase_c_database.py @@ -1,126 +1,164 @@ -"""Phase C: Database setup steps (MongoDB Atlas).""" +"""Phase C: Database setup (MongoDB Atlas). + +Idempotent flow: + C.1 atlas_auth — ensure logged in + C.2 ensure_cluster — verify cluster exists + C.3 ensure_db_user — create DB user + strong password (if not existing) + C.4 get_connection_string — resolve the SRV URI and bake user credentials in + C.5 seed_items — create `items` collection and upsert a sample doc + C.6 write_api_env — write MONGODB_URI + DB_NAME to services/api/.env +""" from __future__ import annotations +import json +import secrets +import shlex +import string from typing import Any +from urllib.parse import quote_plus + +from rich.console import Console from platform_cli.engine.context import ScaffoldContext from platform_cli.engine.registry import register_step from platform_cli.engine.step import BaseStep from platform_cli.shell.run import run_cmd +console = Console() + + +def _gen_password(n: int = 24) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(n)) + @register_step class AtlasAuth(BaseStep): step_id = "C.1_atlas_auth" phase = "C" depends_on = ["B.1_create_directories"] - max_retries = 1 def run(self, ctx: ScaffoldContext) -> dict[str, Any]: - result = run_cmd("atlas auth whoami") - if not result.ok: - run_cmd("atlas auth login", check=True) - return {"atlas_authenticated": True} + if run_cmd("atlas auth whoami").ok: + return {"atlas_authenticated": True} + raise RuntimeError( + "Not logged in to Atlas. Run `atlas auth login` " + "(or `atlas config init` with an API key) and re-run." + ) @register_step -class CreateDatabase(BaseStep): - step_id = "C.2_create_database" +class EnsureCluster(BaseStep): + step_id = "C.2_ensure_cluster" phase = "C" depends_on = ["C.1_atlas_auth"] def run(self, ctx: ScaffoldContext) -> dict[str, Any]: - db_name = ctx.manifest.database.db_name cluster = ctx.manifest.database.atlas_cluster - - # List existing databases on the cluster - result = run_cmd( - f"atlas clusters describe {cluster} --output json" - ) + result = run_cmd(f"atlas clusters describe {cluster} -o json") if not result.ok: raise RuntimeError( - f"Could not describe cluster {cluster}: {result.stderr}" + f"Atlas cluster '{cluster}' not found in the current project. " + f"Either create it in the Atlas UI, or update " + f"`database.atlas_cluster` in the manifest.\n{result.stderr}" ) - - return {"db_name": db_name, "cluster": cluster} + data = json.loads(result.stdout) + ctx.set("atlas_cluster_data", data) + return {"cluster": cluster, "state": data.get("stateName")} @register_step -class CreateCollection(BaseStep): - step_id = "C.3_create_collection" +class EnsureDbUser(BaseStep): + step_id = "C.3_ensure_db_user" phase = "C" - depends_on = ["C.2_create_database"] + depends_on = ["C.2_ensure_cluster"] def run(self, ctx: ScaffoldContext) -> dict[str, Any]: - db_name = ctx.manifest.database.db_name - cluster = ctx.manifest.database.atlas_cluster - - # Create 'items' collection if it doesn't exist (idempotent) - run_cmd( - f'atlas clusters sampleData load {cluster} --output json', - ) - return {"collection": "items"} + username = (ctx.project_name.lower().replace(" ", "-") + "-app")[:64] + existing = run_cmd(f"atlas dbusers describe {username} -o json") + if existing.ok: + # User exists — rotate password so we can bake it into the URI. + password = _gen_password() + run_cmd( + f"atlas dbusers update {username} --password {shlex.quote(password)}", + check=True, + ) + else: + password = _gen_password() + run_cmd( + f"atlas dbusers create atlasAdmin " + f"--username {username} " + f"--password {shlex.quote(password)}", + check=True, + ) + ctx.set("db_username", username) + ctx.set("db_password", password) + return {"username": username} @register_step -class SeedData(BaseStep): - step_id = "C.4_seed_data" +class GetConnectionString(BaseStep): + step_id = "C.4_get_connection_string" phase = "C" - depends_on = ["C.3_create_collection"] + depends_on = ["C.3_ensure_db_user"] def run(self, ctx: ScaffoldContext) -> dict[str, Any]: - import json - + cluster = ctx.manifest.database.atlas_cluster db_name = ctx.manifest.database.db_name - seed_doc = json.dumps({"name": "example item"}) - # Use mongosh through atlas CLI to insert seed data - script = ( - f'db.getSiblingDB("{db_name}").items.updateOne(' - f'{{"name": "example item"}}, ' - f'{{$setOnInsert: {seed_doc}}}, ' - f'{{upsert: true}})' - ) - result = run_cmd( - f'atlas clusters search indexes list --clusterName {ctx.manifest.database.atlas_cluster} --output json' - ) - return {"seeded": True} + r = run_cmd(f"atlas clusters connectionStrings describe {cluster} -o json") + if not r.ok: + raise RuntimeError(f"Failed to get Atlas connection string: {r.stderr}") + data = json.loads(r.stdout) + srv = data.get("standardSrv") or data.get("standard") + if not srv: + raise RuntimeError(f"No connection string found: {data}") + + user = quote_plus(ctx.get("db_username", "")) + pw = quote_plus(ctx.get("db_password", "")) + host = srv.split("://", 1)[1] + uri = f"mongodb+srv://{user}:{pw}@{host}/{db_name}?retryWrites=true&w=majority" + ctx.set("mongodb_uri", uri) + return {"has_uri": True} @register_step -class GenerateCredentials(BaseStep): - step_id = "C.5_generate_credentials" +class SeedItems(BaseStep): + step_id = "C.5_seed_items" phase = "C" - depends_on = ["C.2_create_database"] + depends_on = ["C.4_get_connection_string"] + max_retries = 2 def run(self, ctx: ScaffoldContext) -> dict[str, Any]: - cluster = ctx.manifest.database.atlas_cluster + uri = ctx.get("mongodb_uri") db_name = ctx.manifest.database.db_name - - # Get connection string - result = run_cmd( - f"atlas clusters connectionStrings describe {cluster} --output json" + script = ( + "db.items.updateOne(" + '{"name":"example item"},' + '{"$setOnInsert":{"name":"example item"}},' + "{upsert:true});" + 'print("ok");' ) + cmd = f"mongosh {shlex.quote(uri)} --quiet --eval {shlex.quote(script)}" + r = run_cmd(cmd, timeout=60) + if not r.ok or "ok" not in r.stdout: + raise RuntimeError(f"Seed failed: {r.stderr or r.stdout}") + return {"seeded": True, "db": db_name} - conn_string = f"mongodb+srv://:@{cluster.lower()}.xxxxx.mongodb.net/{db_name}?retryWrites=true&w=majority" - if result.ok: - import json - try: - data = json.loads(result.stdout) - if "standardSrv" in data: - conn_string = data["standardSrv"] + f"/{db_name}?retryWrites=true&w=majority" - except (json.JSONDecodeError, KeyError): - pass - ctx.set("mongodb_uri", conn_string) +@register_step +class WriteApiEnv(BaseStep): + step_id = "C.6_write_api_env" + phase = "C" + depends_on = ["C.4_get_connection_string"] - # Write local .env for the API + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + uri = ctx.get("mongodb_uri") + db_name = ctx.manifest.database.db_name env_file = ctx.project_dir / "services" / "api" / ".env" env_file.parent.mkdir(parents=True, exist_ok=True) env_file.write_text( - f"MONGODB_URI={conn_string}\n" + f"MONGODB_URI={uri}\n" f"DB_NAME={db_name}\n" - f"PORT=80\n" ) - - return {"connection_string_generated": True} + return {"env_written": str(env_file)} diff --git a/repoScaffold/src/platform_cli/steps/phase_d_api.py b/repoScaffold/src/platform_cli/steps/phase_d_api.py index aeb8b59..83327cb 100644 --- a/repoScaffold/src/platform_cli/steps/phase_d_api.py +++ b/repoScaffold/src/platform_cli/steps/phase_d_api.py @@ -100,15 +100,20 @@ def should_skip(self, ctx: ScaffoldContext) -> bool: return super().should_skip(ctx) or not _api_enabled(ctx) def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + api_dir = ctx.project_dir / "services" / "api" render_to_file( "services/api/Dockerfile.j2", - ctx.project_dir / "services" / "api" / "Dockerfile", + api_dir / "Dockerfile", + ) + render_to_file( + "services/api/.dockerignore.j2", + api_dir / ".dockerignore", ) return {} @register_step -class WriteApiEnv(BaseStep): +class InitializeApiEnv(BaseStep): step_id = "D.5_write_api_env" phase = "D" depends_on = ["D.1_init_express"] @@ -117,12 +122,13 @@ def should_skip(self, ctx: ScaffoldContext) -> bool: return super().should_skip(ctx) or not _api_enabled(ctx) def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + # PORT is NOT written here — local runs use the manifest default + # from index.js, and Docker sets PORT=80 via its own ENV. env_file = ctx.project_dir / "services" / "api" / ".env" if not env_file.exists(): db = ctx.manifest.database env_file.write_text( f"MONGODB_URI=mongodb+srv://:@{db.atlas_cluster.lower()}.xxxxx.mongodb.net/{db.db_name}?retryWrites=true&w=majority\n" f"DB_NAME={db.db_name}\n" - f"PORT=80\n" ) return {} diff --git a/repoScaffold/src/platform_cli/steps/phase_e_frontend.py b/repoScaffold/src/platform_cli/steps/phase_e_frontend.py index 02bd60f..45501b8 100644 --- a/repoScaffold/src/platform_cli/steps/phase_e_frontend.py +++ b/repoScaffold/src/platform_cli/steps/phase_e_frontend.py @@ -27,22 +27,38 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: web_dir = ctx.project_dir / "services" / "web" package_json = web_dir / "package.json" - if not package_json.exists(): - # Use create-react-app to scaffold - result = run_cmd( - f"npx create-react-app {web_dir} --template default", - timeout=120, + if package_json.exists(): + return {"react_initialized": True, "source": "existing"} + + # Vite is non-interactive when --template is provided. + result = run_cmd( + f"npm create vite@latest {web_dir.name} -- --template react", + cwd=str(web_dir.parent), + timeout=120, + ) + if result.ok and package_json.exists(): + return {"react_initialized": True, "source": "vite"} + + # Fallback: render our template set directly. + svc = ctx.service("webapp") + api_svc = ctx.service("api") + api_port = api_svc.port if api_svc else 3006 + web_port = svc.port if svc else 3005 + for tmpl, rel in [ + ("services/web/package.json.j2", "package.json"), + ("services/web/vite.config.js.j2", "vite.config.js"), + ("services/web/index.html.j2", "index.html"), + ("services/web/main.jsx.j2", "src/main.jsx"), + ]: + render_to_file( + tmpl, + web_dir / rel, + project=ctx.manifest.project, + service=svc, + api_port=api_port, + web_port=web_port, ) - if not result.ok: - # Fallback: write minimal package.json - svc = ctx.service("webapp") - render_to_file( - "services/web/package.json.j2", - package_json, - project=ctx.manifest.project, - service=svc, - ) - return {"react_initialized": True} + return {"react_initialized": True, "source": "fallback"} @register_step @@ -55,19 +71,20 @@ def should_skip(self, ctx: ScaffoldContext) -> bool: return super().should_skip(ctx) or not _web_enabled(ctx) def run(self, ctx: ScaffoldContext) -> dict[str, Any]: - import json - web_dir = ctx.project_dir / "services" / "web" - pkg_path = web_dir / "package.json" + svc = ctx.service("webapp") api_svc = ctx.service("api") api_port = api_svc.port if api_svc else 3006 + web_port = svc.port if svc else 3005 - if pkg_path.exists(): - pkg = json.loads(pkg_path.read_text()) - pkg["proxy"] = f"http://localhost:{api_port}" - pkg_path.write_text(json.dumps(pkg, indent=2) + "\n") - return {"proxy_configured": True, "api_port": api_port} + render_to_file( + "services/web/vite.config.js.j2", + web_dir / "vite.config.js", + api_port=api_port, + web_port=web_port, + ) + return {"proxy_configured": True, "api_port": api_port, "web_port": web_port} @register_step @@ -87,8 +104,8 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: api_port = api_svc.port if api_svc else 3006 render_to_file( - "services/web/App.js.j2", - web_src / "App.js", + "services/web/App.jsx.j2", + web_src / "App.jsx", api_port=api_port, ) render_to_file( @@ -96,6 +113,15 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: web_src / "api.js", api_port=api_port, ) + render_to_file( + "services/web/index.html.j2", + ctx.project_dir / "services" / "web" / "index.html", + project=ctx.manifest.project, + ) + # Drop the CRA-era App.js if Vite didn't overwrite it (Vite's default is App.jsx). + legacy_app = web_src / "App.js" + if legacy_app.exists(): + legacy_app.unlink() return {"items_fetch_written": True} @@ -111,10 +137,15 @@ def should_skip(self, ctx: ScaffoldContext) -> bool: def run(self, ctx: ScaffoldContext) -> dict[str, Any]: api_svc = ctx.service("api") api_name = api_svc.name if api_svc else "api" + web_dir = ctx.project_dir / "services" / "web" render_to_file( "services/web/Dockerfile.j2", - ctx.project_dir / "services" / "web" / "Dockerfile", + web_dir / "Dockerfile", api_name=api_name, ) + render_to_file( + "services/web/.dockerignore.j2", + web_dir / ".dockerignore", + ) return {} diff --git a/repoScaffold/src/platform_cli/steps/phase_g_gcp.py b/repoScaffold/src/platform_cli/steps/phase_g_gcp.py index e480a68..e24bd31 100644 --- a/repoScaffold/src/platform_cli/steps/phase_g_gcp.py +++ b/repoScaffold/src/platform_cli/steps/phase_g_gcp.py @@ -14,22 +14,36 @@ class CreateGcpProject(BaseStep): step_id = "G.1_create_gcp_project" phase = "G" depends_on = ["B.1_create_directories"] - max_retries = 1 + max_retries = 0 def run(self, ctx: ScaffoldContext) -> dict[str, Any]: project_id = ctx.project_id - # Check if project already exists - result = run_cmd(f"gcloud projects describe {project_id}") - if result.ok: + # Require gcloud auth + who = run_cmd("gcloud config get-value account") + if not who.ok or not who.stdout.strip(): + raise RuntimeError( + "No gcloud account found. Run `gcloud auth login` then retry." + ) + + # Reuse an existing project + if run_cmd(f"gcloud projects describe {project_id}").ok: + run_cmd(f"gcloud config set project {project_id}", check=True) return {"gcp_project_exists": True, "project_id": project_id} - # Create the project - run_cmd( - f"gcloud projects create {project_id} --name={ctx.project_name}", - check=True, + # Try to create + result = run_cmd( + f"gcloud projects create {project_id} --name={ctx.project_name}" ) - # Set as active project + if not result.ok: + raise RuntimeError( + f"Could not create GCP project '{project_id}'. " + "This usually means you need a billing account + organization, " + "or the project ID is taken. Either (a) set `project.cloud.project_id` " + "in the manifest to an existing project you own, or (b) create the " + "project manually in the GCP console and re-run with --skip-phase (no G).\n" + f"gcloud error: {result.stderr}" + ) run_cmd(f"gcloud config set project {project_id}", check=True) return {"gcp_project_created": True, "project_id": project_id} diff --git a/repoScaffold/src/platform_cli/steps/phase_h_secrets.py b/repoScaffold/src/platform_cli/steps/phase_h_secrets.py index d03d15b..2ce0ccf 100644 --- a/repoScaffold/src/platform_cli/steps/phase_h_secrets.py +++ b/repoScaffold/src/platform_cli/steps/phase_h_secrets.py @@ -1,6 +1,8 @@ """Phase H: Secret management steps.""" from __future__ import annotations +import tempfile +from pathlib import Path from typing import Any import yaml @@ -9,7 +11,19 @@ from platform_cli.engine.registry import register_step from platform_cli.engine.step import BaseStep from platform_cli.shell.run import run_cmd -from platform_cli.templates.renderer import render_to_file + + +def _read_env(path: Path) -> dict[str, str]: + out: dict[str, str] = {} + if not path.exists(): + return out + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + out[key.strip()] = val.strip() + return out @register_step @@ -43,38 +57,37 @@ class SyncSecretsToGcp(BaseStep): def run(self, ctx: ScaffoldContext) -> dict[str, Any]: project_id = ctx.project_id + env_vars = _read_env(ctx.project_dir / "services" / "api" / ".env") - # Read API .env for secret values - env_file = ctx.project_dir / "services" / "api" / ".env" - env_vars: dict[str, str] = {} - if env_file.exists(): - for line in env_file.read_text().splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, _, val = line.partition("=") - env_vars[key.strip()] = val.strip() - - synced = [] + synced: list[str] = [] for key, value in env_vars.items(): secret_name = key.lower().replace("_", "-") - # Check if secret exists - result = run_cmd( + exists = run_cmd( f"gcloud secrets describe {secret_name} --project={project_id}" - ) - if not result.ok: + ).ok + if not exists: run_cmd( - f"gcloud secrets create {secret_name} --project={project_id} " - f"--replication-policy=automatic", + f"gcloud secrets create {secret_name} " + f"--project={project_id} --replication-policy=automatic", check=True, ) - # Add version with value - run_cmd( - f'printf "%s" "{value}" | gcloud secrets versions add {secret_name} ' - f"--data-file=- --project={project_id}", - check=True, - ) + # Write value to a temp file to avoid shell-escaping pitfalls. + with tempfile.NamedTemporaryFile( + "w", delete=False, prefix="secret-", suffix=".txt" + ) as tmp: + tmp.write(value) + tmp_path = tmp.name + try: + run_cmd( + f"gcloud secrets versions add {secret_name} " + f"--data-file={tmp_path} --project={project_id}", + check=True, + ) + finally: + Path(tmp_path).unlink(missing_ok=True) + synced.append(key) return {"synced_secrets": synced} @@ -87,7 +100,6 @@ class WriteEnvExampleSecrets(BaseStep): depends_on = ["H.1_write_secret_manifest"] def run(self, ctx: ScaffoldContext) -> dict[str, Any]: - # Append secret-backed vars to .env.example env_example = ctx.project_dir / ".env.example" if env_example.exists(): content = env_example.read_text() diff --git a/repoScaffold/src/platform_cli/steps/phase_k_testing.py b/repoScaffold/src/platform_cli/steps/phase_k_testing.py index 9d32fd2..ca795ba 100644 --- a/repoScaffold/src/platform_cli/steps/phase_k_testing.py +++ b/repoScaffold/src/platform_cli/steps/phase_k_testing.py @@ -1,13 +1,26 @@ """Phase K: Testing steps.""" from __future__ import annotations +import os +import signal +import subprocess +import time from typing import Any +from rich.console import Console + from platform_cli.engine.context import ScaffoldContext from platform_cli.engine.registry import register_step from platform_cli.engine.step import BaseStep from platform_cli.shell.run import run_cmd +console = Console() + + +def _ensure_deps(dir_path: str) -> None: + if not os.path.isdir(os.path.join(dir_path, "node_modules")): + run_cmd("npm install", cwd=dir_path, check=True, timeout=300) + @register_step class ApiLint(BaseStep): @@ -20,13 +33,11 @@ def should_skip(self, ctx: ScaffoldContext) -> bool: def run(self, ctx: ScaffoldContext) -> dict[str, Any]: api_dir = str(ctx.project_dir / "services" / "api") - - # Install deps first if needed - if not (ctx.project_dir / "services" / "api" / "node_modules").exists(): - run_cmd("npm install", cwd=api_dir, check=True) - + _ensure_deps(api_dir) result = run_cmd("npm run lint", cwd=api_dir) - return {"lint_passed": result.ok, "output": result.stdout} + if not result.ok: + raise RuntimeError(f"Lint failed:\n{result.stdout}\n{result.stderr}") + return {"lint_passed": True} @register_step @@ -34,7 +45,7 @@ class ApiHealth(BaseStep): step_id = "K.2_api_health" phase = "K" depends_on = ["D.3_write_api_source"] - max_retries = 2 + max_retries = 0 def should_skip(self, ctx: ScaffoldContext) -> bool: return super().should_skip(ctx) or ctx.service("api") is None @@ -42,17 +53,49 @@ def should_skip(self, ctx: ScaffoldContext) -> bool: def run(self, ctx: ScaffoldContext) -> dict[str, Any]: svc = ctx.service("api") port = svc.port if svc else 3006 + api_dir = str(ctx.project_dir / "services" / "api") + _ensure_deps(api_dir) - result = run_cmd( - f"curl -sf http://localhost:{port}/health", - timeout=10, + # Spawn the API in its own process group so we can kill the whole tree. + proc = subprocess.Popen( + ["node", "src/index.js"], + cwd=api_dir, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + preexec_fn=os.setsid, ) - if not result.ok: - from rich.console import Console - Console().print( - "[yellow]API health check failed — is the API running?[/yellow]" + + health_ok = False + last_err = "" + try: + deadline = time.time() + 20 + url = f"http://localhost:{port}/health" + while time.time() < deadline: + r = run_cmd(f"curl -sf {url}", timeout=5) + if r.ok: + health_ok = True + break + last_err = r.stderr or r.stdout + time.sleep(0.5) + finally: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except ProcessLookupError: + pass + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + + if not health_ok: + logs = "" + if proc.stdout: + logs = proc.stdout.read() or "" + raise RuntimeError( + f"API /health did not respond on :{port}\nlast curl: {last_err}\nprocess log:\n{logs[-1000:]}" ) - return {"health_ok": result.ok} + return {"health_ok": True, "port": port} @register_step @@ -67,14 +110,13 @@ def should_skip(self, ctx: ScaffoldContext) -> bool: def run(self, ctx: ScaffoldContext) -> dict[str, Any]: api_dir = str(ctx.project_dir / "services" / "api") tag = f"{ctx.project_name.lower().replace(' ', '-')}-api:test" - result = run_cmd( f"docker build -t {tag} .", cwd=api_dir, check=True, - timeout=120, + timeout=600, ) - return {"docker_build_ok": result.ok, "image_tag": tag} + return {"image_tag": tag, "build_ok": result.ok} @register_step @@ -88,13 +130,13 @@ def should_skip(self, ctx: ScaffoldContext) -> bool: def run(self, ctx: ScaffoldContext) -> dict[str, Any]: web_dir = str(ctx.project_dir / "services" / "web") - - # Check that build succeeds - if not (ctx.project_dir / "services" / "web" / "node_modules").exists(): - run_cmd("npm install", cwd=web_dir, check=True, timeout=120) - - result = run_cmd("npm run build", cwd=web_dir, timeout=120) - return {"build_ok": result.ok} + _ensure_deps(web_dir) + result = run_cmd("npm run build", cwd=web_dir, timeout=300) + if not result.ok: + raise RuntimeError( + f"Frontend build failed:\n{result.stdout}\n{result.stderr}" + ) + return {"build_ok": True} @register_step @@ -105,20 +147,29 @@ class TerraformValidate(BaseStep): def run(self, ctx: ScaffoldContext) -> dict[str, Any]: tf_dir = str(ctx.project_dir / "infra" / "terraform" / "envs" / "dev") - - run_cmd("terraform init -backend=false", cwd=tf_dir, check=True) + run_cmd("terraform init -backend=false", cwd=tf_dir, check=True, timeout=180) result = run_cmd("terraform validate", cwd=tf_dir) - return {"validate_ok": result.ok, "output": result.stdout} + if not result.ok: + raise RuntimeError( + f"terraform validate failed:\n{result.stdout}\n{result.stderr}" + ) + return {"validate_ok": True} @register_step class TerraformPlan(BaseStep): + """Plan requires GCP credentials; soft-fail with a clear warning.""" + step_id = "K.6_terraform_plan" phase = "K" depends_on = ["K.5_terraform_validate"] def run(self, ctx: ScaffoldContext) -> dict[str, Any]: tf_dir = str(ctx.project_dir / "infra" / "terraform" / "envs" / "dev") - - result = run_cmd("terraform plan -input=false", cwd=tf_dir, timeout=120) + result = run_cmd("terraform plan -input=false", cwd=tf_dir, timeout=180) + if not result.ok: + console.print( + "[yellow]terraform plan did not succeed — usually needs GCP auth " + "and the project to exist. Run `gcloud auth application-default login`.[/yellow]" + ) return {"plan_ok": result.ok} diff --git a/repoScaffold/templates/project/docker-compose.yml.j2 b/repoScaffold/templates/project/docker-compose.yml.j2 index 751941a..ed52cfa 100644 --- a/repoScaffold/templates/project/docker-compose.yml.j2 +++ b/repoScaffold/templates/project/docker-compose.yml.j2 @@ -1,5 +1,3 @@ -version: "3.9" - services: {% for svc in services %} {% if svc.enabled %} diff --git a/repoScaffold/templates/services/api/.dockerignore.j2 b/repoScaffold/templates/services/api/.dockerignore.j2 new file mode 100644 index 0000000..047b8fb --- /dev/null +++ b/repoScaffold/templates/services/api/.dockerignore.j2 @@ -0,0 +1,8 @@ +node_modules +.env +.env.* +.git +.gitignore +.vscode +.DS_Store +npm-debug.log* diff --git a/repoScaffold/templates/services/api/Dockerfile.j2 b/repoScaffold/templates/services/api/Dockerfile.j2 index 3e8a894..cb0fa67 100644 --- a/repoScaffold/templates/services/api/Dockerfile.j2 +++ b/repoScaffold/templates/services/api/Dockerfile.j2 @@ -3,7 +3,7 @@ FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json* ./ -RUN npm ci --production +RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi FROM node:20-alpine diff --git a/repoScaffold/templates/services/api/db.js.j2 b/repoScaffold/templates/services/api/db.js.j2 index ffdf0ad..c4bb82f 100644 --- a/repoScaffold/templates/services/api/db.js.j2 +++ b/repoScaffold/templates/services/api/db.js.j2 @@ -1,5 +1,4 @@ -{% raw %} -const mongoose = require("mongoose"); +{% raw %}const mongoose = require("mongoose"); const MONGODB_URI = process.env.MONGODB_URI; @@ -7,15 +6,15 @@ async function connectDB() { if (!MONGODB_URI) { throw new Error("MONGODB_URI environment variable is not set"); } + await mongoose.connect(MONGODB_URI); + console.log("Connected to MongoDB"); +} - try { - await mongoose.connect(MONGODB_URI); - console.log("Connected to MongoDB"); - } catch (err) { - console.error("MongoDB connection error:", err.message); - throw err; - } +function dbState() { + // 0 disconnected, 1 connected, 2 connecting, 3 disconnecting + const map = { 0: "disconnected", 1: "connected", 2: "connecting", 3: "disconnecting" }; + return map[mongoose.connection.readyState] || "unknown"; } -module.exports = { connectDB }; +module.exports = { connectDB, dbState }; {% endraw %} diff --git a/repoScaffold/templates/services/api/index.js.j2 b/repoScaffold/templates/services/api/index.js.j2 index da7bee4..f5a4adb 100644 --- a/repoScaffold/templates/services/api/index.js.j2 +++ b/repoScaffold/templates/services/api/index.js.j2 @@ -4,7 +4,7 @@ const express = require("express"); const cors = require("cors"); const helmet = require("helmet"); {% raw %} -const { connectDB } = require("./db"); +const { connectDB, dbState } = require("./db"); const { Item } = require("./models"); {% endraw %} @@ -15,16 +15,17 @@ app.use(helmet()); app.use(cors()); app.use(express.json()); -// Health check app.get("/health", (_req, res) => { {% raw %} - res.json({ status: "ok", service: "{% endraw %}{{ service.name }}{% raw %}" }); + res.json({ status: "ok", service: "{% endraw %}{{ service.name }}{% raw %}", db: dbState() }); {% endraw %} }); -// List items app.get("/items", async (_req, res) => { {% raw %} + if (dbState() !== "connected") { + return res.status(503).json({ error: "database unavailable", db: dbState() }); + } try { const items = await Item.find().lean(); res.json(items); @@ -35,16 +36,12 @@ app.get("/items", async (_req, res) => { {% endraw %} }); -async function start() { +app.listen(PORT, () => { {% raw %} - await connectDB(); - app.listen(PORT, () => { - console.log(`API listening on port ${PORT}`); - }); -} + console.log(`API listening on port ${PORT}`); +}); -start().catch((err) => { - console.error("Failed to start API:", err); - process.exit(1); +connectDB().catch((err) => { + console.error("DB connect failed (API still running):", err.message); }); {% endraw %} diff --git a/repoScaffold/templates/services/web/.dockerignore.j2 b/repoScaffold/templates/services/web/.dockerignore.j2 new file mode 100644 index 0000000..e07f5a0 --- /dev/null +++ b/repoScaffold/templates/services/web/.dockerignore.j2 @@ -0,0 +1,9 @@ +node_modules +dist +.env +.env.* +.git +.gitignore +.vscode +.DS_Store +npm-debug.log* diff --git a/repoScaffold/templates/services/web/App.js.j2 b/repoScaffold/templates/services/web/App.jsx.j2 similarity index 79% rename from repoScaffold/templates/services/web/App.js.j2 rename to repoScaffold/templates/services/web/App.jsx.j2 index 429fb37..6727deb 100644 --- a/repoScaffold/templates/services/web/App.js.j2 +++ b/repoScaffold/templates/services/web/App.jsx.j2 @@ -1,8 +1,7 @@ -{% raw %} -import React, { useEffect, useState } from "react"; +{% raw %}import { useEffect, useState } from "react"; import { fetchItems } from "./api"; -function App() { +export default function App() { const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -18,7 +17,7 @@ function App() { if (error) return
Error: {error}
; return ( -
+

Items

{items.length === 0 ? (

No items found.

@@ -32,6 +31,4 @@ function App() {
); } - -export default App; -{% endraw %} +{% endraw %} \ No newline at end of file diff --git a/repoScaffold/templates/services/web/Dockerfile.j2 b/repoScaffold/templates/services/web/Dockerfile.j2 index d0f0fa6..ab00d2c 100644 --- a/repoScaffold/templates/services/web/Dockerfile.j2 +++ b/repoScaffold/templates/services/web/Dockerfile.j2 @@ -3,24 +3,23 @@ FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json* ./ -RUN npm ci +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi COPY . . RUN npm run build FROM nginx:alpine -COPY --from=builder /app/build /usr/share/nginx/html - -# Nginx config to handle SPA routing and API proxy -RUN echo 'server { \ - listen 80; \ - root /usr/share/nginx/html; \ - index index.html; \ - location / { \ - try_files $uri $uri/ /index.html; \ - } \ -}' > /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html + +RUN printf 'server {\n\ + listen 80;\n\ + root /usr/share/nginx/html;\n\ + index index.html;\n\ + location / {\n\ + try_files $uri $uri/ /index.html;\n\ + }\n\ +}\n' > /etc/nginx/conf.d/default.conf EXPOSE 80 diff --git a/repoScaffold/templates/services/web/api.js.j2 b/repoScaffold/templates/services/web/api.js.j2 index 45db9e1..ae1a2a8 100644 --- a/repoScaffold/templates/services/web/api.js.j2 +++ b/repoScaffold/templates/services/web/api.js.j2 @@ -1,5 +1,4 @@ -{% raw %} -const API_BASE = process.env.REACT_APP_API_URL || ""; +{% raw %}const API_BASE = import.meta.env.VITE_API_URL || ""; export async function fetchItems() { const res = await fetch(`${API_BASE}/items`); @@ -8,4 +7,4 @@ export async function fetchItems() { } return res.json(); } -{% endraw %} +{% endraw %} \ No newline at end of file diff --git a/repoScaffold/templates/services/web/index.html.j2 b/repoScaffold/templates/services/web/index.html.j2 new file mode 100644 index 0000000..cf9bbda --- /dev/null +++ b/repoScaffold/templates/services/web/index.html.j2 @@ -0,0 +1,12 @@ + + + + + + {{ project.name }} + + +
+ + + diff --git a/repoScaffold/templates/services/web/main.jsx.j2 b/repoScaffold/templates/services/web/main.jsx.j2 new file mode 100644 index 0000000..4aaf0e6 --- /dev/null +++ b/repoScaffold/templates/services/web/main.jsx.j2 @@ -0,0 +1,10 @@ +{% raw %}import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; + +createRoot(document.getElementById("root")).render( + + + , +); +{% endraw %} \ No newline at end of file diff --git a/repoScaffold/templates/services/web/package.json.j2 b/repoScaffold/templates/services/web/package.json.j2 index 3fc042a..ca9cb83 100644 --- a/repoScaffold/templates/services/web/package.json.j2 +++ b/repoScaffold/templates/services/web/package.json.j2 @@ -2,20 +2,18 @@ "name": "{{ project.name | lower | replace(' ', '-') }}-web", "version": "1.0.0", "private": true, - "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-scripts": "5.0.1" - }, + "type": "module", "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" }, - "proxy": "http://localhost:{{ service.port | default(3006) }}", - "browserslist": { - "production": [">0.2%", "not dead", "not op_mini all"], - "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" } } diff --git a/repoScaffold/templates/services/web/vite.config.js.j2 b/repoScaffold/templates/services/web/vite.config.js.j2 new file mode 100644 index 0000000..7330a57 --- /dev/null +++ b/repoScaffold/templates/services/web/vite.config.js.j2 @@ -0,0 +1,19 @@ +{% raw %}import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: {% endraw %}{{ web_port }}{% raw %}, + proxy: { + "/items": "http://localhost:{% endraw %}{{ api_port }}{% raw %}", + "/health": "http://localhost:{% endraw %}{{ api_port }}{% raw %}", + }, + }, + preview: { + host: true, + port: {% endraw %}{{ web_port }}{% raw %}, + }, +}); +{% endraw %} \ No newline at end of file From 1f0469fc7e22cd46b7da40336c4b530b7a9a827e Mon Sep 17 00:00:00 2001 From: David Gaspard Date: Sun, 14 Jun 2026 16:27:49 -0400 Subject: [PATCH 3/9] Add multi-agent framework to scaffolder - AgentRoleConfig in manifest schema: name, sleep_seconds, max_turns - Three built-in agent templates: developer, reviewer, ops - loop.sh takes agent name arg, reads from agents// - setup-vm.sh discovers all agents, starts each in tmux window - Scaffold step B.10 generates agents/ directory per manifest roles - Default to all 3 agents if no roles specified in manifest Co-Authored-By: Claude Opus 4.6 --- repoScaffold/examples/ai4us2.yaml | 54 +++++ .../src/platform_cli/manifest/schema.py | 27 +++ .../platform_cli/steps/phase_b_scaffold.py | 87 +++++++ .../templates/agents/developer/PROMPT.md.j2 | 217 ++++++++++++++++++ .../templates/agents/developer/config.yaml.j2 | 4 + .../templates/agents/ops/PROMPT.md.j2 | 104 +++++++++ .../templates/agents/ops/config.yaml.j2 | 4 + .../templates/agents/reviewer/PROMPT.md.j2 | 89 +++++++ .../templates/agents/reviewer/config.yaml.j2 | 4 + repoScaffold/templates/project/loop.sh.j2 | 131 +++++++++++ repoScaffold/templates/project/setup-vm.sh.j2 | 125 ++++++++++ 11 files changed, 846 insertions(+) create mode 100644 repoScaffold/examples/ai4us2.yaml create mode 100644 repoScaffold/templates/agents/developer/PROMPT.md.j2 create mode 100644 repoScaffold/templates/agents/developer/config.yaml.j2 create mode 100644 repoScaffold/templates/agents/ops/PROMPT.md.j2 create mode 100644 repoScaffold/templates/agents/ops/config.yaml.j2 create mode 100644 repoScaffold/templates/agents/reviewer/PROMPT.md.j2 create mode 100644 repoScaffold/templates/agents/reviewer/config.yaml.j2 create mode 100644 repoScaffold/templates/project/loop.sh.j2 create mode 100644 repoScaffold/templates/project/setup-vm.sh.j2 diff --git a/repoScaffold/examples/ai4us2.yaml b/repoScaffold/examples/ai4us2.yaml new file mode 100644 index 0000000..c4b1a5c --- /dev/null +++ b/repoScaffold/examples/ai4us2.yaml @@ -0,0 +1,54 @@ +project: + name: ai4us2 + env: dev + cloud: + provider: gcp + region: us-central1 + project_id: ai4us2 + +database: + type: mongodb + atlas_cluster: Cluster0 + db_name: ai4us2 + +services: + - type: api + enabled: true + name: api + subdomain: api + stack: express + port: 3006 + + - type: webapp + enabled: true + name: app + subdomain: app + stack: react + port: 3005 + + - type: worker + enabled: true + name: worker + stack: python + port: 8080 + gpu: required + +agents: + service_accounts: + - 464961297779-compute@developer.gserviceaccount.com + roles: + - name: developer + sleep_seconds: 600 + max_turns: 1000 + - name: reviewer + sleep_seconds: 1800 + max_turns: 200 + - name: ops + sleep_seconds: 1800 + max_turns: 100 + +linear: + enabled: true + workspace: ai4us + team_key: AI4 + team_name: AI4US diff --git a/repoScaffold/src/platform_cli/manifest/schema.py b/repoScaffold/src/platform_cli/manifest/schema.py index e05abcc..8889da1 100644 --- a/repoScaffold/src/platform_cli/manifest/schema.py +++ b/repoScaffold/src/platform_cli/manifest/schema.py @@ -30,9 +30,36 @@ class ServiceConfig(BaseModel): stack: str = "" port: int = 0 gpu: str = "none" + # Worker-only: cron schedule (Cloud Scheduler format) + # Default: every 10 minutes + schedule: str = "*/10 * * * *" + # Worker-only: max parallel task instances per scheduled run + parallelism: int = 1 + # Worker-only: max execution time per task (seconds) + timeout_seconds: int = 600 + + +class AgentRoleConfig(BaseModel): + name: str # e.g. "developer", "reviewer", "ops" + sleep_seconds: int = 600 # seconds between iterations + max_turns: int = 1000 # max Claude turns per iteration + + +class AgentConfig(BaseModel): + service_accounts: list[str] = Field(default_factory=list) + roles: list[AgentRoleConfig] = Field(default_factory=list) + + +class LinearConfig(BaseModel): + enabled: bool = False + workspace: str = "" # e.g. "ai4us" + team_key: str = "" # e.g. "AI4" — the prefix for issue IDs + team_name: str = "" # e.g. "AI4US Team" class ProjectManifest(BaseModel): project: ProjectConfig database: DatabaseConfig = Field(default_factory=DatabaseConfig) services: list[ServiceConfig] = Field(default_factory=list) + agents: AgentConfig = Field(default_factory=AgentConfig) + linear: LinearConfig = Field(default_factory=LinearConfig) diff --git a/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py b/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py index 2a05750..96f2735 100644 --- a/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py +++ b/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py @@ -27,6 +27,7 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: "infra/terraform/envs/dev", "cloudbuild", "cloudrun", + "agents", ".vscode", ".claude", ] @@ -157,3 +158,89 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: manifest=ctx.manifest, ) return {} + + +@register_step +class WriteAgentLoop(BaseStep): + step_id = "B.10_write_agent_loop" + phase = "B" + depends_on = ["B.1_create_directories"] + + # Built-in agent templates shipped with the scaffolder + BUILTIN_AGENTS = ["developer", "reviewer", "ops"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + import os + from pathlib import Path + + # Determine which agents to scaffold + manifest_roles = {r.name: r for r in ctx.manifest.agents.roles} + agent_names = list(manifest_roles.keys()) if manifest_roles else self.BUILTIN_AGENTS + + agents_dir = ctx.project_dir / "agents" + scaffolded = [] + + for name in agent_names: + agent_dir = agents_dir / name + agent_dir.mkdir(parents=True, exist_ok=True) + + role = manifest_roles.get(name) + agent_ctx = { + "sleep_seconds": role.sleep_seconds if role else None, + "max_turns": role.max_turns if role else None, + } + + template_dir = Path(f"agents/{name}") + prompt_template = f"agents/{name}/PROMPT.md.j2" + config_template = f"agents/{name}/config.yaml.j2" + + try: + render_to_file( + prompt_template, + agent_dir / "PROMPT.md", + manifest=ctx.manifest, + agent=agent_ctx, + ) + except Exception: + # No built-in template — create a placeholder + (agent_dir / "PROMPT.md").write_text( + f"# {name.title()} Agent\n\n" + f"You are the **{name}** agent for **{ctx.manifest.project.name}**.\n\n" + f"Define this agent's behavior by editing this file.\n" + ) + + try: + render_to_file( + config_template, + agent_dir / "config.yaml", + manifest=ctx.manifest, + agent=agent_ctx, + ) + except Exception: + defaults = {"developer": (600, 1000), "reviewer": (1800, 200), "ops": (1800, 100)} + sleep_s, max_t = defaults.get(name, (600, 500)) + (agent_dir / "config.yaml").write_text( + f"name: {name}\n" + f"description: {name.title()} agent\n" + f"sleep_seconds: {agent_ctx.get('sleep_seconds') or sleep_s}\n" + f"max_turns: {agent_ctx.get('max_turns') or max_t}\n" + ) + + scaffolded.append(name) + + # Write shared loop.sh and setup-vm.sh + render_to_file( + "project/loop.sh.j2", + ctx.project_dir / "loop.sh", + manifest=ctx.manifest, + ) + render_to_file( + "project/setup-vm.sh.j2", + ctx.project_dir / "setup-vm.sh", + manifest=ctx.manifest, + ) + + os.chmod(ctx.project_dir / "loop.sh", 0o755) + os.chmod(ctx.project_dir / "setup-vm.sh", 0o755) + + return {"agents": scaffolded} diff --git a/repoScaffold/templates/agents/developer/PROMPT.md.j2 b/repoScaffold/templates/agents/developer/PROMPT.md.j2 new file mode 100644 index 0000000..224f3fd --- /dev/null +++ b/repoScaffold/templates/agents/developer/PROMPT.md.j2 @@ -0,0 +1,217 @@ +You are an autonomous agent working on the **{{ manifest.project.name }}** project. + +## Step 1: Orient + +1. Read `AGENTS.md` for project structure and conventions. +2. `git checkout main && git pull` — always start clean on main. +3. `git stash drop` any stale stashes. Do NOT carry over work from previous iterations. + +## Step 2: Sync Linear issues with PR/merge status + +Before looking at new tasks, reconcile Linear with GitHub state. + +**Check all recently merged or closed PRs** and update their Linear issues: + +```bash +# Get recently merged PRs +gh pr list --state merged --limit 10 --json number,title,mergedAt + +# Get recently closed (abandoned) PRs +gh pr list --state closed --limit 10 --json number,title,closedAt +``` + +For each **merged PR**: Extract the issue ID from the title (e.g., "{{ manifest.linear.team_key }}-21" from "{{ manifest.linear.team_key }}-21: Fix footer"). If the corresponding Linear issue is NOT already "Done", move it to "Done" via the API. + +For each **closed (not merged) PR**: If the corresponding Linear issue is "In Review" or "In Progress", move it to "Canceled" via the API. + +**Then check open PRs and their comments:** + +```bash +gh pr list --state open --json number,title,headRefName,comments +``` + +**For each open PR:** + +0. **Check if the linked Linear issue is in Backlog.** If yes, **leave the PR alone** — do not push commits, do not respond to comments, do not close it. The owner has explicitly demoted this issue to Backlog and is reviewing it. +1. Read all comments: `gh pr view --comments` +2. If there are **unanswered human comments or questions**: + - Check out the PR branch: `gh pr checkout ` + - Address the feedback — make code changes if needed, commit, and push. + - **Reply to each comment** explaining what you did: + ```bash + gh pr comment --body "Fixed: [explanation of what changed]. See commit abc1234." + ``` + - If the comment asks a question but doesn't require code changes, reply with your answer. + - **Do NOT leave comments unanswered.** Every human comment deserves a reply. +3. If the PR has no new comments and is waiting on reviewer, leave it. Continue to step 3. + +**Also check Linear issue comments** on any "In Progress" or "In Review" tasks: + +```bash +LINEAR_KEY=$(cat ~/.linear/api_key) +# Get the team ID +TEAM_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ teams(filter: { key: { eq: \"{{ manifest.linear.team_key }}\" } }) { nodes { id } } }"}' \ + https://api.linear.app/graphql | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['teams']['nodes'][0]['id'])") + +# Check comments on in-progress issues +curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"{ team(id: \\\"$TEAM_ID\\\") { issues(filter: { state: { type: { in: [\\\"started\\\"] } } }) { nodes { id identifier title comments { nodes { body user { name } createdAt } } } } } }\"}" \ + https://api.linear.app/graphql +``` + +If there are comments from the owner on a Linear issue, address them — either by making code changes on the associated PR branch or by replying with a comment explaining your plan. + +Only move on to new Linear tasks when **all open PRs have no unaddressed feedback** and **all in-progress Linear issues have no unanswered comments**. + +## Step 3: Get your task from Linear + +Query Linear for tasks. **Only pick tasks with state "Todo"** (type "unstarted"). These are tasks the project owner has reviewed and approved for work. Never pick "Backlog", "Done", or "Canceled" tasks. + +```bash +LINEAR_KEY=$(cat ~/.linear/api_key) + +# Get the team ID +TEAM_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ teams(filter: { key: { eq: \"{{ manifest.linear.team_key }}\" } }) { nodes { id } } }"}' \ + https://api.linear.app/graphql | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['teams']['nodes'][0]['id'])") + +# Only fetch "Todo" (unstarted) tasks — these are owner-approved +curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"{ team(id: \\\"$TEAM_ID\\\") { issues(filter: { state: { type: { eq: \\\"unstarted\\\" } } }, first: 10, orderBy: priority) { nodes { id identifier title description priority state { name type } } } } }\"}" \ + https://api.linear.app/graphql +``` + +**Before picking a task**, check it doesn't already have an open PR: +```bash +gh pr list --state open --json number,title,headRefName +``` + +If any open PR title contains the issue identifier (e.g., "{{ manifest.linear.team_key }}-21"), **do not create another PR**. Instead: +- Check out that PR's branch +- Read PR comments for feedback +- Make fixes on the same branch, commit, and push — the PR updates automatically + +If there are **no "Todo" tasks**, do a deep scan of the codebase and create **10 new tasks** in Backlog. Before creating each one, search existing issues to avoid duplicates. + +**Tasks must be substantial and impactful.** Do NOT create trivial CSS tweaks, text changes, or cosmetic fixes. Focus on work that meaningfully improves the product: + +- **New features**: Entire new pages, API endpoints, integrations, or user flows +- **Architecture improvements**: Auth flows, caching, error handling, API versioning +- **Full-stack features**: Span API + frontend together +- **Testing & reliability**: E2E tests, API integration tests, error monitoring +- **Data & analytics**: Usage tracking, admin dashboards, reporting +- **Infrastructure**: CI/CD improvements, staging environments, database migrations + +Each task description should be detailed enough to take 20+ minutes of real work — multiple files across multiple directories. If a task can be done by changing one CSS property, it's too small. + +**Add them to "Backlog" (not "Todo")**. The project owner will review and move approved items to "Todo". **Do not work on tasks you just created** — end this iteration. + +**Before creating a task**, search existing issues to avoid duplicates: +```bash +curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ issueSearch(query: \"\", first: 5) { nodes { identifier title state { name } } } }"}' \ + https://api.linear.app/graphql +``` + +## Step 4: Do the work + +1. **Move the Linear issue to "In Progress"** via API (state type: "started"). +2. **Create a branch**: `{{ manifest.linear.team_key | lower }}-{number}/{short-desc}` (e.g., `{{ manifest.linear.team_key | lower }}-42/add-pagination`). +3. **Check if you need credentials or API keys** you don't have. If so: + - Add a comment to the Linear issue requesting what you need. Be specific: + ``` + @owner Blocked: this task requires a [Stripe API key / SendGrid key / etc.] + to be added to GCP Secret Manager as `secret-name`. + Please add it and I'll pick this up on the next iteration. + ``` + - Move the issue back to **"Todo"** (so it stays visible but you don't re-pick it while blocked). + - **Skip this task and move on to the next one.** Do not stub out or hardcode credentials. +4. **Make your changes.** Keep them focused on the single task. Do not scope-creep. +5. **Test:** + - API changes: verify with curl or run locally. + - Web/landing changes: `npm run build` must succeed. + - Infra changes: `terraform validate`. +6. **Commit** with the issue ID: `{{ manifest.linear.team_key }}-42: Add pagination to /models endpoint` + +## Step 5: Open a PR — do NOT mark the task as Done + +1. Push: `git push -u origin HEAD` +2. Create PR: + ```bash + gh pr create --title "{{ manifest.linear.team_key }}-{number}: {title}" --body "Resolves {{ manifest.linear.team_key }}-{number}" + ``` +3. **Move the Linear issue to "In Review"** (NOT "Done"). The issue only moves to Done when the PR is merged. Since you cannot verify the PR will be merged, leave it in review. + +**NEVER mark a Linear issue as "Done" or "Completed"** unless a PR for it has been merged (which you verify in Step 2). + +## Step 6: Go back to Step 2 — keep working + +After opening a PR, **do not stop**. Return to **Step 2** and repeat the cycle: +- Sync Linear ↔ GitHub (your just-merged PRs may now need issues moved to Done) +- Check for comments on any open PRs +- Pick the next Todo task + +**Keep looping through Steps 2–5 until one of these exit conditions is met:** +- There are no more "Todo" tasks (you created Backlog items instead — stop) +- All remaining tasks are blocked on credentials or external input — stop +- All open PRs have unaddressed feedback that requires human input you can't resolve — stop + +Maximize the work done in each iteration. Do not stop after a single task. + +## Rules — read these carefully + +**Linear is the source of truth.** Always observe the full state before acting. + +1. **Keep working until blocked.** Complete as many tasks as possible per iteration. Only stop when there's nothing left to do or you're waiting on human input. +2. **Never push directly to main.** Always use a feature branch + PR. +3. **Never mark issues Done unless their PR is merged.** Verify via `gh pr list --state merged`. +4. **One PR per task.** Never create a second PR for the same issue. Push new commits to the existing PR branch. +5. **Never duplicate work.** If an open PR covers the task, iterate on that PR instead. +6. **Only pick "Todo" tasks.** Never pick "Backlog", "In Progress", "Done", or "Canceled". +7. **NEVER move items out of "Backlog".** The owner solely controls Backlog → Todo promotion. If you find yourself wanting to work on a Backlog item, stop. Backlog items are explicitly waiting on owner review — even if they have an existing PR, do NOT touch them. +8. **New tasks go to Backlog.** Never create tasks in "Todo" — the owner approves them first. +8. **Search before creating tasks.** Query existing issues by keyword to avoid duplicates. +9. **Address PR feedback first.** Open PRs with unaddressed comments take priority over new tasks. +10. **Reply to every comment.** Never leave a human comment unanswered on a PR or Linear issue. +11. **Clean main between tasks.** `git checkout main && git pull` before starting each new task. +12. **Do not modify secrets or hardcode credentials.** Comment on the Linear issue requesting them. Move back to "Todo" and move on to the next task. +13. **Do not modify infrastructure** unless the task specifically asks for it. +14. **If blocked on one task**, move on to the next — don't stop the whole iteration. +15. **Do substantial work.** Each PR should represent meaningful progress — multiple files, real functionality. Don't split work into trivially small PRs. + +## Decision flowchart (loop within each iteration) + +``` +START: + git checkout main && git pull + +LOOP: + 1. Sync Linear ↔ GitHub: + ├─ Merged PRs → move their Linear issues to "Done" + └─ Closed PRs → move their Linear issues to "Canceled" + + 2. Check open PRs: + ├─ PR with unanswered comments? → Fix/reply, push. Go to LOOP. + └─ All PRs clean? → Continue. + + 3. Check Linear issue comments on in-progress tasks: + ├─ Unanswered comments? → Address them, reply. Go to LOOP. + └─ All clean? → Continue. + + 4. Query Linear for "Todo" tasks: + ├─ Found a "Todo" task? + │ ├─ Has existing open PR? → Iterate on that PR. Go to LOOP. + │ ├─ Needs credentials you don't have? → Comment, move to Todo, skip. Try next task. + │ └─ Ready to work? → Branch, code, test, commit, push, PR, "In Review". Go to LOOP. + └─ No "Todo" tasks? + └─ Scan codebase, create 10 Backlog tasks (search for dupes first). EXIT. + +EXIT: + Nothing left to do. End iteration. +``` diff --git a/repoScaffold/templates/agents/developer/config.yaml.j2 b/repoScaffold/templates/agents/developer/config.yaml.j2 new file mode 100644 index 0000000..8b4d4e5 --- /dev/null +++ b/repoScaffold/templates/agents/developer/config.yaml.j2 @@ -0,0 +1,4 @@ +name: developer +description: Builds features, fixes bugs, and addresses PR feedback from the Linear board. +sleep_seconds: {{ agent.sleep_seconds | default(600) }} +max_turns: {{ agent.max_turns | default(1000) }} diff --git a/repoScaffold/templates/agents/ops/PROMPT.md.j2 b/repoScaffold/templates/agents/ops/PROMPT.md.j2 new file mode 100644 index 0000000..9fe456c --- /dev/null +++ b/repoScaffold/templates/agents/ops/PROMPT.md.j2 @@ -0,0 +1,104 @@ +You are an autonomous **ops monitor** for the **{{ manifest.project.name }}** project. + +Your job is to monitor production health, detect errors, and create Linear issues for problems that need attention. You do NOT write code or create PRs — the developer agent handles that. + +## Step 1: Orient + +1. Read `AGENTS.md` for project structure and conventions. +2. `git checkout main && git pull` — stay current with the codebase. + +## Step 2: Check production health + +### Cloud Run service status +```bash +gcloud run services list --project={{ manifest.project.cloud.project_id }} --region={{ manifest.project.cloud.region }} --format='table(name,status.url,status.conditions.status)' +``` + +### Recent errors (last 30 minutes) +```bash +gcloud logging read 'resource.type="cloud_run_revision" AND severity>=ERROR' \ + --project={{ manifest.project.cloud.project_id }} \ + --limit=50 --freshness=30m \ + --format='json(timestamp,textPayload,resource.labels.service_name)' +``` + +### HTTP error rates +```bash +gcloud logging read 'resource.type="cloud_run_revision" AND httpRequest.status>=500' \ + --project={{ manifest.project.cloud.project_id }} \ + --limit=20 --freshness=30m \ + --format='json(timestamp,httpRequest.status,httpRequest.requestUrl,resource.labels.service_name)' +``` + +### Recent Cloud Build failures +```bash +gcloud builds list --project={{ manifest.project.cloud.project_id }} --region={{ manifest.project.cloud.region }} \ + --filter='status=FAILURE' --limit=5 \ + --format='json(id,status,statusDetail,createTime)' +``` + +## Step 3: Analyze and triage + +For each error pattern you find: +1. **Group related errors** — multiple stack traces from the same root cause are one issue, not many. +2. **Check frequency** — a single transient error is not worth an issue. Repeated errors (3+ in 30 min) are. +3. **Check if an issue already exists** — search Linear before creating duplicates: + ```bash + LINEAR_KEY=$(cat ~/.linear/api_key) + curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ issueSearch(query: \"\", first: 5) { nodes { identifier title state { name } } } }"}' \ + https://api.linear.app/graphql + ``` +4. **Check if a recent PR likely caused it** — correlate error timestamps with recent deploys. + +## Step 4: Create issues for real problems + +Only create an issue if: +- The error is recurring (3+ occurrences in 30 minutes) +- No existing Linear issue covers it +- It affects user-facing functionality + +```bash +LINEAR_KEY=$(cat ~/.linear/api_key) +TEAM_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ teams(filter: { key: { eq: \"{{ manifest.linear.team_key }}\" } }) { nodes { id } } }"}' \ + https://api.linear.app/graphql | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['teams']['nodes'][0]['id'])") + +# Get the Backlog state ID +BACKLOG_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"{ team(id: \\\"$TEAM_ID\\\") { states(filter: { type: { eq: \\\"backlog\\\" } }) { nodes { id } } } }\"}" \ + https://api.linear.app/graphql | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['team']['states']['nodes'][0]['id'])") + +curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"mutation { issueCreate(input: { teamId: \\\"$TEAM_ID\\\", stateId: \\\"$BACKLOG_ID\\\", title: \\\"[ops] \\\", description: \\\"<description with error logs, timestamps, and frequency>\\\", priority: 1 }) { success issue { identifier url } } }\"}" \ + https://api.linear.app/graphql +``` + +## Step 5: Smoke test key endpoints + +Hit each service's health or key endpoint and verify it responds: + +{% for svc in manifest.services %} +{% if svc.type == 'api' %} +```bash +STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "https://{{ svc.subdomain }}.{{ manifest.project.cloud.project_id }}.run.app/") +echo "{{ svc.name }}: $STATUS" +``` +{% endif %} +{% endfor %} + +If any endpoint returns 5xx, check if an issue already exists before creating one. + +## Rules + +1. **Read-only.** Never modify code, push commits, or create PRs. Your only write action is creating Linear issues. +2. **Backlog only.** Always create issues in Backlog — the owner promotes them to Todo. +3. **Prefix issues with `[ops]`** so humans can distinguish ops-created issues from developer-created ones. +4. **No duplicates.** Always search Linear before creating an issue. If a similar issue exists in any state, don't create another. +5. **Be concise.** Issue descriptions should include: error message, affected service, frequency, first/last occurrence timestamp, and a snippet of the relevant log. +6. **Priority mapping:** P1 = service completely down. P2 = recurring 5xx on key endpoints. P3 = elevated error rate but service functional. P4 = warnings or non-critical errors. +7. **Never run destructive commands.** No `gcloud run deploy`, `terraform apply`, or any command that mutates GCP resources. diff --git a/repoScaffold/templates/agents/ops/config.yaml.j2 b/repoScaffold/templates/agents/ops/config.yaml.j2 new file mode 100644 index 0000000..02ad5cc --- /dev/null +++ b/repoScaffold/templates/agents/ops/config.yaml.j2 @@ -0,0 +1,4 @@ +name: ops +description: Monitors production health, detects errors, and creates Linear issues. +sleep_seconds: {{ agent.sleep_seconds | default(1800) }} +max_turns: {{ agent.max_turns | default(100) }} diff --git a/repoScaffold/templates/agents/reviewer/PROMPT.md.j2 b/repoScaffold/templates/agents/reviewer/PROMPT.md.j2 new file mode 100644 index 0000000..068a9d1 --- /dev/null +++ b/repoScaffold/templates/agents/reviewer/PROMPT.md.j2 @@ -0,0 +1,89 @@ +You are an autonomous **code reviewer** for the **{{ manifest.project.name }}** project. + +Your job is to review open pull requests for correctness, security, and code quality. You do NOT write features or pick tasks from Linear — the developer agent handles that. + +## Step 1: Orient + +1. Read `AGENTS.md` for project structure and conventions. +2. `git checkout main && git pull` — always start clean on main. + +## Step 2: Find open PRs to review + +```bash +gh pr list --state open --json number,title,headRefName,author,additions,deletions,changedFiles +``` + +For each open PR: +1. Skip PRs you have already reviewed (check if you left a review comment starting with `[reviewer-agent]`). +2. Check out the branch: `gh pr checkout <number>` +3. Read the diff: `gh pr diff <number>` + +## Step 3: Review each PR + +For every PR, evaluate: + +### Correctness +- Does the code do what the PR title/description says? +- Are there off-by-one errors, null pointer risks, or unhandled edge cases? +- Are database queries correct (wrong filters, missing indexes, N+1)? +- Are async operations properly awaited? + +### Security +- SQL/NoSQL injection risks +- XSS vulnerabilities in frontend code +- Hardcoded secrets or credentials +- Missing authentication/authorization checks +- Unsafe deserialization or eval usage + +### Code quality +- Dead code or unused imports +- Duplicated logic that should be extracted +- Missing error handling on external calls (API, DB, file I/O) +- Breaking changes to public APIs without migration + +### Testing +- Are there tests for new functionality? +- Do existing tests still pass with the changes? +- Are edge cases covered? + +## Step 4: Leave a review + +Post a review comment on each PR: + +```bash +gh pr review <number> --comment --body "$(cat <<'REVIEW' +[reviewer-agent] Automated code review + +## Summary +<1-2 sentence overview of what this PR does> + +## Findings + +### 🔴 Issues (must fix) +<numbered list of bugs, security issues, or correctness problems — or "None"> + +### 🟡 Suggestions (should fix) +<numbered list of code quality improvements — or "None"> + +### 🟢 Looks good +<things done well — be specific> + +## Verdict +<APPROVE / REQUEST_CHANGES / COMMENT> +REVIEW +)" +``` + +Use `--approve` only if there are zero 🔴 issues and the code is solid. +Use `--request-changes` if there are 🔴 issues. +Otherwise use `--comment`. + +## Rules + +1. **Review only.** Never push code, never create PRs, never modify files. +2. **One review per PR per iteration.** Don't re-review a PR you already commented on unless new commits were pushed since your last review. +3. **Be specific.** Quote the exact line/file when reporting an issue. Use `file:line` format. +4. **Prefix every comment with `[reviewer-agent]`** so humans can distinguish your reviews from the developer agent's comments. +5. **Don't block on style.** Only flag style issues if they cause ambiguity or bugs. The developer agent follows its own conventions. +6. **Check for the patterns that cause real outages:** unhandled promise rejections, missing `await`, wrong MongoDB driver usage, secrets in code, broken error handling. +7. **Never approve your own PRs.** If a PR was authored by an agent with the same service account, still review it objectively but add a note that a human should also approve. diff --git a/repoScaffold/templates/agents/reviewer/config.yaml.j2 b/repoScaffold/templates/agents/reviewer/config.yaml.j2 new file mode 100644 index 0000000..c0903b1 --- /dev/null +++ b/repoScaffold/templates/agents/reviewer/config.yaml.j2 @@ -0,0 +1,4 @@ +name: reviewer +description: Reviews open PRs for correctness, security, and code quality. +sleep_seconds: {{ agent.sleep_seconds | default(1800) }} +max_turns: {{ agent.max_turns | default(200) }} diff --git a/repoScaffold/templates/project/loop.sh.j2 b/repoScaffold/templates/project/loop.sh.j2 new file mode 100644 index 0000000..0a3e027 --- /dev/null +++ b/repoScaffold/templates/project/loop.sh.j2 @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# {{ manifest.project.name }} — Agent loop runner +# Runs a single named agent in a loop. Each agent has its own PROMPT.md +# and config.yaml under agents/<name>/. +# +# Usage: +# ./loop.sh developer # Run the developer agent +# ./loop.sh reviewer # Run the reviewer agent +# ./loop.sh ops # Run the ops monitor agent +# +# The setup-vm.sh script starts all agents in separate tmux windows. + +AGENT_NAME="${1:?Usage: ./loop.sh <agent-name>}" +PROJECT_ID="{{ manifest.project.cloud.project_id }}" +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +AGENT_DIR="$REPO_DIR/agents/$AGENT_NAME" +LOG_TAG="agent-${AGENT_NAME}-${PROJECT_ID}" +LOG_DIR="/var/log/agent/${PROJECT_ID}/${AGENT_NAME}" + +if [ ! -f "$AGENT_DIR/PROMPT.md" ]; then + echo "ERROR: agents/$AGENT_NAME/PROMPT.md not found" + echo "Available agents:" + ls -1 "$REPO_DIR/agents/" 2>/dev/null | while read -r d; do + [ -f "$REPO_DIR/agents/$d/PROMPT.md" ] && echo " - $d" + done + exit 1 +fi + +# Read agent config (with defaults) +if [ -f "$AGENT_DIR/config.yaml" ]; then + SLEEP_SECONDS=$(grep -oP 'sleep_seconds:\s*\K\d+' "$AGENT_DIR/config.yaml" 2>/dev/null || echo 600) + MAX_TURNS=$(grep -oP 'max_turns:\s*\K\d+' "$AGENT_DIR/config.yaml" 2>/dev/null || echo 1000) +else + SLEEP_SECONDS=600 + MAX_TURNS=1000 +fi + +# Allow env overrides +SLEEP_SECONDS="${AGENT_SLEEP_SECONDS:-$SLEEP_SECONDS}" +MAX_TURNS="${AGENT_MAX_TURNS:-$MAX_TURNS}" + +# ── Logging ───────────────────────────────────────────────────── + +log() { + local msg="$1" + echo "[$AGENT_NAME] $msg" + logger -t "$LOG_TAG" "$msg" 2>/dev/null || true +} + +# ── One-time setup ────────────────────────────────────────────── + +setup() { + log "=== Agent setup: $AGENT_NAME ===" + + sudo mkdir -p "$LOG_DIR" && sudo chown "$(whoami)" "$LOG_DIR" 2>/dev/null || true + + # Git auth + mkdir -p ~/.ssh + if [ ! -f ~/.ssh/deploy_key ] || [ ! -s ~/.ssh/deploy_key ]; then + log "Pulling deploy key from Secret Manager..." + gcloud secrets versions access latest \ + --secret=github-deploy-key \ + --project="$PROJECT_ID" > ~/.ssh/deploy_key 2>/dev/null || true + chmod 600 ~/.ssh/deploy_key 2>/dev/null || true + fi + export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" + + # Linear API key + mkdir -p ~/.linear + if [ ! -f ~/.linear/api_key ] || [ ! -s ~/.linear/api_key ]; then + log "Pulling Linear API key from Secret Manager..." + gcloud secrets versions access latest \ + --secret=linear-api-key \ + --project="$PROJECT_ID" > ~/.linear/api_key 2>/dev/null || \ + log "Warning: linear-api-key not found in Secret Manager." + fi + + cd "$REPO_DIR" + log "Working in: $REPO_DIR" + log "Agent: $AGENT_NAME" + log "Prompt: agents/$AGENT_NAME/PROMPT.md" + log "Sleep: ${SLEEP_SECONDS}s | Max turns: $MAX_TURNS" + log "Logs: tag=$LOG_TAG, local=$LOG_DIR/" +} + +# ── Disk cleanup ──────────────────────────────────────────────── + +cleanup_disk() { + ls -t "$LOG_DIR"/iteration-*.log 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true + rm -rf /tmp/npm-* /tmp/pip-* 2>/dev/null || true +} + +# ── Main loop ─────────────────────────────────────────────────── + +run_iteration() { + local iteration=$1 + local log_file="$LOG_DIR/iteration-${iteration}.log" + + log "=== Iteration $iteration — $(date) ===" + + cleanup_disk + + cd "$REPO_DIR" + git checkout main 2>/dev/null || true + git pull --rebase || git pull || true + + claude -p "$(cat "$AGENT_DIR/PROMPT.md")" \ + --allowedTools "Read,Edit,Write,Bash,Glob,Grep" \ + --max-turns "$MAX_TURNS" \ + --dangerously-skip-permissions \ + 2>&1 | tee "$log_file" || true + + log "=== Iteration $iteration complete — sleeping ${SLEEP_SECONDS}s ===" + + if [ -s "$log_file" ]; then + tail -50 "$log_file" | while IFS= read -r line; do + logger -t "$LOG_TAG" "iter=$iteration $line" 2>/dev/null || true + done + fi +} + +# ── Entry point ───────────────────────────────────────────────── + +setup + +iteration=1 +while true; do + run_iteration "$iteration" || log "Iteration $iteration failed — continuing" + iteration=$((iteration + 1)) + sleep "$SLEEP_SECONDS" || true +done diff --git a/repoScaffold/templates/project/setup-vm.sh.j2 b/repoScaffold/templates/project/setup-vm.sh.j2 new file mode 100644 index 0000000..0eeadd0 --- /dev/null +++ b/repoScaffold/templates/project/setup-vm.sh.j2 @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# {{ manifest.project.name }} — Agent VM setup script +# Run this once on a fresh GCE VM to install all dependencies and start all agents. +# +# Each agent gets its own tmux window within a single session. +# Add a new agent by creating agents/<name>/PROMPT.md in the repo. +# +# Usage (from your local machine): +# ssh user@VM_IP 'bash -s' < setup-vm.sh +# +set -euo pipefail + +PROJECT_ID="{{ manifest.project.cloud.project_id }}" +{% set gh_owner_placeholder = "GITHUB_OWNER" %} +REPO_URL="git@github.com:{{ gh_owner_placeholder }}/{{ manifest.project.name | lower | replace(' ', '-') }}.git" +REPO_DIR="$HOME/{{ manifest.project.name | lower | replace(' ', '-') }}" +TMUX_SESSION="agents" + +echo "=== {{ manifest.project.name }} Agent VM Setup ===" + +# ── 1. System packages ───────────────────────────────────────── + +echo "[1/6] Installing system packages..." +sudo apt-get update -qq +sudo apt-get install -y -qq tmux git curl jq + +# ── 2. Node.js ────────────────────────────────────────────────── + +if ! command -v node &>/dev/null; then + echo "[2/6] Installing Node.js..." + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y -qq nodejs +else + echo "[2/6] Node.js already installed: $(node --version)" +fi + +# ── 3. Claude Code ────────────────────────────────────────────── + +mkdir -p ~/.npm-global +npm config set prefix ~/.npm-global +export PATH="$HOME/.npm-global/bin:$PATH" + +grep -q '.npm-global/bin' ~/.bashrc 2>/dev/null || { + echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc +} + +if ! command -v claude &>/dev/null; then + echo "[3/6] Installing Claude Code..." + npm install -g @anthropic-ai/claude-code +else + echo "[3/6] Claude Code already installed: $(claude --version)" +fi + +# ── 4. Deploy key + repo clone ────────────────────────────────── + +echo "[4/6] Setting up deploy key and cloning repo..." +mkdir -p ~/.ssh + +gcloud secrets versions access latest \ + --secret=github-deploy-key \ + --project="$PROJECT_ID" > ~/.ssh/deploy_key +chmod 600 ~/.ssh/deploy_key + +export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" + +grep -q 'GIT_SSH_COMMAND' ~/.bashrc 2>/dev/null || { + echo 'export GIT_SSH_COMMAND="ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no"' >> ~/.bashrc +} + +if [ -d "$REPO_DIR" ]; then + cd "$REPO_DIR" && git pull +else + git clone "$REPO_URL" "$REPO_DIR" + cd "$REPO_DIR" +fi + +# ── 5. Linear API key ────────────────────────────────────────── + +echo "[5/6] Pulling Linear API key..." +mkdir -p ~/.linear +gcloud secrets versions access latest \ + --secret=linear-api-key \ + --project="$PROJECT_ID" > ~/.linear/api_key 2>/dev/null || \ + echo "Warning: linear-api-key not found. Linear integration disabled." +chmod 600 ~/.linear/api_key 2>/dev/null || true + +# ── 6. Start all agents ──────────────────────────────────────── + +echo "[6/6] Discovering agents and starting tmux sessions..." + +tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true + +FIRST=true +for agent_dir in "$REPO_DIR"/agents/*/; do + agent_name=$(basename "$agent_dir") + if [ ! -f "$agent_dir/PROMPT.md" ]; then + echo " Skipping $agent_name (no PROMPT.md)" + continue + fi + + if [ "$FIRST" = true ]; then + tmux new-session -d -s "$TMUX_SESSION" -n "$agent_name" \ + "export PATH=$HOME/.npm-global/bin:\$PATH; \ + export GIT_SSH_COMMAND='ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no'; \ + cd $REPO_DIR && ./loop.sh $agent_name" + FIRST=false + else + tmux new-window -t "$TMUX_SESSION" -n "$agent_name" \ + "export PATH=$HOME/.npm-global/bin:\$PATH; \ + export GIT_SSH_COMMAND='ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no'; \ + cd $REPO_DIR && ./loop.sh $agent_name" + fi + + echo " Started: $agent_name" +done + +echo "" +echo "=== Setup complete ===" +echo "Agents running in tmux session '$TMUX_SESSION'" +echo "" +echo " Attach: tmux attach -t $TMUX_SESSION" +echo " List windows: tmux list-windows -t $TMUX_SESSION" +echo " Switch agent: Ctrl+B, <window-number>" +echo " Logs: /var/log/agent/${PROJECT_ID}/<agent-name>/" +echo " Cloud Logs: https://console.cloud.google.com/logs/query;query=logName%3D%22projects%2F${PROJECT_ID}%2Flogs%2Fagent-%22?project=${PROJECT_ID}" From f909eaf70d000947b1804c297485c5fc50b2611f Mon Sep 17 00:00:00 2001 From: David Gaspard <davidgaspard@MacBook-Pro-3.local> Date: Thu, 23 Jul 2026 11:37:34 -0400 Subject: [PATCH 4/9] Add `platform-cli create-agent-identity`: per-project GitHub App bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automates the GitHub App manifest flow so each scaffolded project gets its own scoped bot identity (`<name>[bot]`) instead of borrowing a human's token: serves a pre-filled manifest + opens the browser for one click, captures the redirect code, creates the App, stores its private key in the project's Secret Manager, waits for the install, and prints the loop.sh wiring (app_id / installation_id / bot_name / bot_email). Also ships templates/agents/github-app-token.sh — the helper the generated loop.sh uses to mint short-lived installation tokens each iteration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- repoScaffold/src/platform_cli/cli.py | 2 + .../commands/create_agent_identity.py | 176 ++++++++++++++++++ .../templates/agents/github-app-token.sh | 40 ++++ 3 files changed, 218 insertions(+) create mode 100644 repoScaffold/src/platform_cli/commands/create_agent_identity.py create mode 100755 repoScaffold/templates/agents/github-app-token.sh diff --git a/repoScaffold/src/platform_cli/cli.py b/repoScaffold/src/platform_cli/cli.py index b102ff2..cb5f074 100644 --- a/repoScaffold/src/platform_cli/cli.py +++ b/repoScaffold/src/platform_cli/cli.py @@ -6,6 +6,7 @@ from platform_cli.commands.scaffold import scaffold from platform_cli.commands.install import install from platform_cli.commands.test_cmd import test +from platform_cli.commands.create_agent_identity import create_agent_identity @click.group() @@ -17,3 +18,4 @@ def cli() -> None: cli.add_command(scaffold) cli.add_command(install) cli.add_command(test) +cli.add_command(create_agent_identity) diff --git a/repoScaffold/src/platform_cli/commands/create_agent_identity.py b/repoScaffold/src/platform_cli/commands/create_agent_identity.py new file mode 100644 index 0000000..151b595 --- /dev/null +++ b/repoScaffold/src/platform_cli/commands/create_agent_identity.py @@ -0,0 +1,176 @@ +"""platform-cli create-agent-identity — give a project's agent its own GitHub App. + +Automates the whole GitHub App manifest flow so each scaffolded project gets its +own scoped bot identity (`<name>[bot]`) instead of borrowing a human's token: + + 1. serve a pre-filled App manifest locally + open the browser + 2. you click "Create GitHub App" (the one consent GitHub requires) + 3. capture the redirect code, exchange it for the App id + private key + 4. store the private key in the project's Secret Manager + 5. open the install page + wait until the App is installed on the repo + 6. resolve the installation id + bot commit identity and print the wiring + +The printed values (app_id, installation_id, bot_name, bot_email) go straight +into the generated agents/loop.sh (see the loop template's App-auth block). +""" +from __future__ import annotations + +import http.server +import json +import socketserver +import subprocess +import tempfile +import threading +import time +import urllib.request +import webbrowser +from pathlib import Path + +import click +from rich.console import Console + +console = Console() +PORT = 8722 +_captured = {"code": None} + + +def _b64url(data: bytes) -> str: + import base64 + return base64.urlsafe_b64encode(data).rstrip(b"=").decode() + + +def _app_jwt(app_id: str, pem: str) -> str: + """Sign an RS256 App JWT using openssl (no extra Python crypto dep).""" + now = int(time.time()) + header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode()) + payload = _b64url(json.dumps({"iat": now - 60, "exp": now + 540, "iss": app_id}).encode()) + signing_input = f"{header}.{payload}".encode() + with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as f: + f.write(pem) + key_path = f.name + try: + sig = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", key_path], + input=signing_input, capture_output=True, check=True, + ).stdout + finally: + Path(key_path).unlink(missing_ok=True) + return f"{header}.{payload}.{_b64url(sig)}" + + +def _gh(url: str, token: str, bearer: bool = False, method: str = "GET", body: dict | None = None) -> dict: + auth = f"Bearer {token}" if bearer else f"token {token}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method, headers={ + "Authorization": auth, "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "platform-cli", + }) + with urllib.request.urlopen(req) as r: + return json.load(r) + + +def _serve_manifest(manifest: dict) -> threading.Thread: + form = ( + "<!doctype html><body style='font-family:system-ui;max-width:640px;margin:60px auto'>" + "<h2>Create the agent GitHub App</h2>" + "<p>Clicking below sends a pre-filled manifest to GitHub " + "(name + Contents/Pull-requests write already set). Review and click " + "<b>Create GitHub App</b>.</p>" + "<form action='https://github.com/settings/apps/new?state=platform-cli' method='post'>" + f"<input type='hidden' name='manifest' value='{json.dumps(manifest).replace(chr(39), ''')}'>" + "<button type='submit' style='padding:12px 20px;font-size:16px'>Create the agent GitHub App →</button>" + "</form></body>" + ) + + class H(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + import urllib.parse + parsed = urllib.parse.urlparse(self.path) + if parsed.path == "/created": + code = urllib.parse.parse_qs(parsed.query).get("code", [""])[0] + _captured["code"] = code + msg = b"<h2>App created. Return to your terminal.</h2>" + else: + msg = form.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(msg) + + socketserver.TCPServer.allow_reuse_address = True + httpd = socketserver.TCPServer(("127.0.0.1", PORT), H) + t = threading.Thread(target=httpd.serve_forever, daemon=True) + t.start() + t.httpd = httpd # type: ignore + return t + + +@click.command("create-agent-identity") +@click.option("--project", required=True, help="GCP project holding the agent's secrets.") +@click.option("--repo", required=True, help="GitHub repo as owner/name.") +@click.option("--name", default=None, help="App name (default: <repo-name>-agent).") +@click.option("--secret", default="github-app-private-key", help="Secret Manager name for the App private key.") +def create_agent_identity(project: str, repo: str, name: str | None, secret: str) -> None: + """Create + install a per-project GitHub App bot for the agent.""" + owner, repo_name = repo.split("/", 1) + app_name = name or f"{repo_name}-agent" + manifest = { + "name": app_name, + "url": f"https://github.com/{owner}", + "hook_attributes": {"url": "https://example.com/unused", "active": False}, + "redirect_url": f"http://localhost:{PORT}/created", + "public": False, + "default_permissions": {"contents": "write", "pull_requests": "write", "metadata": "read"}, + "default_events": [], + } + + server = _serve_manifest(manifest) + url = f"http://localhost:{PORT}/" + console.print(f"\n[bold]Opening[/bold] {url} — click [bold]Create GitHub App[/bold] (rename if you like).") + webbrowser.open(url) + + console.print(" [dim]waiting for you to create the App…[/dim]") + while not _captured["code"]: + time.sleep(1) + server.httpd.shutdown() # type: ignore + + # Exchange the manifest code for the App credentials. + conv = _gh(f"https://api.github.com/app-manifests/{_captured['code']}/conversions", + token="", method="POST") + app_id, slug, pem = str(conv["id"]), conv["slug"], conv["pem"] + console.print(f" [green]created[/green] App {slug} (id {app_id})") + + # Store the private key in Secret Manager. + if not subprocess.run(["gcloud", "secrets", "describe", secret, f"--project={project}"], + capture_output=True).returncode == 0: + subprocess.run(["gcloud", "secrets", "create", secret, f"--project={project}", + "--replication-policy=automatic"], check=True, capture_output=True) + subprocess.run(["gcloud", "secrets", "versions", "add", secret, f"--project={project}", + "--data-file=-"], input=pem.encode(), check=True, capture_output=True) + console.print(f" [green]stored[/green] private key in Secret Manager: {secret}") + + # Install + wait for it. + install_url = f"https://github.com/apps/{slug}/installations/new" + console.print(f"\n [bold]Opening[/bold] {install_url} — install on [bold]{repo}[/bold] (Only select repositories).") + webbrowser.open(install_url) + console.print(" [dim]waiting for the install…[/dim]") + install_id = None + while not install_id: + time.sleep(2) + jwt = _app_jwt(app_id, pem) + for inst in _gh("https://api.github.com/app/installations", jwt, bearer=True): + install_id = inst["id"] + break + + bot = _gh(f"https://api.github.com/users/{slug}%5Bbot%5D", _app_jwt(app_id, pem), bearer=True) + bot_name = f"{slug}[bot]" + bot_email = f"{bot['id']}+{slug}[bot]@users.noreply.github.com" + + console.print("\n[bold green]Agent identity ready.[/bold green] Wire these into agents/loop.sh:") + console.print(f" GITHUB_APP_ID=\"{app_id}\"") + console.print(f" GITHUB_APP_INSTALLATION_ID=\"{install_id}\"") + console.print(f" GITHUB_BOT_NAME=\"{bot_name}\"") + console.print(f" GITHUB_BOT_EMAIL=\"{bot_email}\"") diff --git a/repoScaffold/templates/agents/github-app-token.sh b/repoScaffold/templates/agents/github-app-token.sh new file mode 100755 index 0000000..218440f --- /dev/null +++ b/repoScaffold/templates/agents/github-app-token.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Mint a short-lived GitHub App installation access token (valid ~1h). +# +# The agent authenticates to GitHub as the App's bot identity +# (`<app-slug>[bot]`) instead of a human's personal token, so its commits and +# comments are clearly attributable and its access is scoped to just the +# installed repo(s) with fine-grained permissions. +# +# Usage: github-app-token.sh <gcp-project> <app-id> <installation-id> +# - the App private key (PEM) is read from Secret Manager (github-app-private-key) +# - prints the installation token to stdout (nothing else) +# +# Deps: openssl + curl + python3 (all present on the agent VM). No JWT library. +set -euo pipefail + +PROJECT_ID="${1:?usage: github-app-token.sh <gcp-project> <app-id> <installation-id>}" +APP_ID="${2:?app id required}" +INSTALLATION_ID="${3:?installation id required}" +PRIVATE_KEY_SECRET="${GITHUB_APP_KEY_SECRET:-github-app-private-key}" + +pem="$(gcloud secrets versions access latest --secret="$PRIVATE_KEY_SECRET" --project="$PROJECT_ID")" + +# base64url without padding +b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } + +now="$(date +%s)" +# iat backdated 60s to tolerate clock skew; exp within the 10-min max. +header="$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)" +payload="$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "$((now - 60))" "$((now + 540))" "$APP_ID" | b64url)" +signature="$(printf '%s.%s' "$header" "$payload" \ + | openssl dgst -sha256 -sign <(printf '%s' "$pem") | b64url)" +jwt="${header}.${payload}.${signature}" + +# Exchange the App JWT for an installation access token. +curl -sf -X POST \ + -H "Authorization: Bearer ${jwt}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/app/installations/${INSTALLATION_ID}/access_tokens" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])' From 6d6ed8775a9d145920ad5f223dafee9c975d9abf Mon Sep 17 00:00:00 2001 From: David Gaspard <davidgaspard@MacBook-Pro-3.local> Date: Mon, 27 Jul 2026 09:53:51 -0400 Subject: [PATCH 5/9] templates(developer): add failing-CI handling to open-PR loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CI-status step to the developer agent prompt template's open-PR loop, mirroring ai4us2's agents/developer/PROMPT.md: check `gh pr checks`, re-run failed jobs once to rule out a flake, then check out the branch, read `gh run view --log-failed`, fix, push, and reply — never leave a PR you authored with red required checks. Also updates the decision flowchart and the "move on to new tasks" gate. Folds in the in-progress template sync already staged in the working tree (worktree-model Step 1, closed-PR triage, merge-conflict step). --- .../templates/agents/developer/PROMPT.md.j2 | 182 +++++++++++++++--- 1 file changed, 153 insertions(+), 29 deletions(-) diff --git a/repoScaffold/templates/agents/developer/PROMPT.md.j2 b/repoScaffold/templates/agents/developer/PROMPT.md.j2 index 224f3fd..be7b785 100644 --- a/repoScaffold/templates/agents/developer/PROMPT.md.j2 +++ b/repoScaffold/templates/agents/developer/PROMPT.md.j2 @@ -2,9 +2,9 @@ You are an autonomous agent working on the **{{ manifest.project.name }}** proje ## Step 1: Orient -1. Read `AGENTS.md` for project structure and conventions. -2. `git checkout main && git pull` — always start clean on main. -3. `git stash drop` any stale stashes. Do NOT carry over work from previous iterations. +1. Read `agents/AGENTS.md` for project structure and conventions. +2. You run in your OWN private git worktree, already scrubbed to the latest `main` (detached HEAD) at the start of every iteration — no other agent shares it. Do **not** run `git checkout main` (main is checked out in the shared clone, so it will fail); you're already on a clean, up-to-date tree. Just create your task branch (Step 4) when you pick up work. +3. Do NOT carry over anything from a previous iteration. The worktree is already clean; only your task's files should ever appear in `git status`. ## Step 2: Sync Linear issues with PR/merge status @@ -22,7 +22,9 @@ gh pr list --state closed --limit 10 --json number,title,closedAt For each **merged PR**: Extract the issue ID from the title (e.g., "{{ manifest.linear.team_key }}-21" from "{{ manifest.linear.team_key }}-21: Fix footer"). If the corresponding Linear issue is NOT already "Done", move it to "Done" via the API. -For each **closed (not merged) PR**: If the corresponding Linear issue is "In Review" or "In Progress", move it to "Canceled" via the API. +For each **closed (not merged) PR**: a closed PR is the **owner's signal**, not a "redo it" instruction. If the issue is currently **"In Progress"**, move it to **"In Review"** so the owner can triage it (they will cancel it, move it to Backlog, or send it back — that is their call, not yours). Re-read the issue's current state immediately before changing it. +- **Otherwise, leave the issue's state unchanged.** Never auto-**Cancel** an issue, never move it to **Todo** or **Done** based on a closed PR, and **never touch a "Done", "Backlog", or "Canceled" issue** — those are owner-controlled. +- When in doubt, do **nothing**. **Then check open PRs and their comments:** @@ -32,6 +34,8 @@ gh pr list --state open --json number,title,headRefName,comments **For each open PR:** +**Recover stranded tasks first:** check the linked Linear issue's state. If the PR is **open** but its issue is still **"In Progress"** (not "In Review" and not "Backlog"), the previous iteration opened the PR but was interrupted before updating Linear — **move the issue to "In Review" now.** An open PR means the work is already submitted for review. + 0. **Check if the linked Linear issue is in Backlog.** If yes, **leave the PR alone** — do not push commits, do not respond to comments, do not close it. The owner has explicitly demoted this issue to Backlog and is reviewing it. 1. Read all comments: `gh pr view <number> --comments` 2. If there are **unanswered human comments or questions**: @@ -43,13 +47,25 @@ gh pr list --state open --json number,title,headRefName,comments ``` - If the comment asks a question but doesn't require code changes, reply with your answer. - **Do NOT leave comments unanswered.** Every human comment deserves a reply. -3. If the PR has no new comments and is waiting on reviewer, leave it. Continue to step 3. +3. **Check for merge conflicts**: `gh pr view <number> --json mergeable`. If `mergeable` is `"CONFLICTING"`: + - Check out the PR branch: `gh pr checkout <number>` + - Rebase onto main: `git fetch origin && git rebase origin/main` + - Resolve any conflicts, keeping both the PR's intent and main's changes. + - Force-push the rebased branch: `git push --force-with-lease` + - Comment on the PR: `gh pr comment <number> --body "Rebased onto main and resolved merge conflicts."` +4. **Check CI status**: `gh pr checks <number>` (or `gh pr view <number> --json statusCheckRollup`). If a **required check is failing** (state `FAILURE`/`ERROR` — ignore checks still `PENDING`/queued) and the linked Linear issue is not in Backlog: + - **Rule out a flake first.** Find the run and re-run the failed jobs once: `gh run list --branch <headRefName> --limit 5`, then `gh run rerun <run-id> --failed`. If the re-run goes green, comment that it was a transient failure and move on. + - If it still fails, check out the branch (`gh pr checkout <number>`) and read the failing logs: `gh run view <run-id> --log-failed`. + - Reproduce and fix the root cause locally (build error, runtime smoke, out-of-sync lockfile, Cloud Build `$$` escaping, etc.), commit, and push — the PR re-runs automatically. + - Reply on the PR: `gh pr comment <number> --body "Fixed failing CI: [what was red] → [fix]. See commit abc1234."` + - **Never leave a PR you authored with red required checks.** A failing pipeline blocks merge exactly like an unanswered comment does. (Repo-level breakage that isn't caused by this PR still goes through the `[ops]`-task path in Step 3.) +5. If the PR has no new comments, no conflicts, and no failing checks, and is waiting on reviewer, leave it. Continue to step 3. **Also check Linear issue comments** on any "In Progress" or "In Review" tasks: ```bash LINEAR_KEY=$(cat ~/.linear/api_key) -# Get the team ID +# Resolve the team ID from the team key TEAM_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "{ teams(filter: { key: { eq: \"{{ manifest.linear.team_key }}\" } }) { nodes { id } } }"}' \ @@ -64,16 +80,21 @@ curl -s -H "Authorization: $LINEAR_KEY" \ If there are comments from the owner on a Linear issue, address them — either by making code changes on the associated PR branch or by replying with a comment explaining your plan. -Only move on to new Linear tasks when **all open PRs have no unaddressed feedback** and **all in-progress Linear issues have no unanswered comments**. +Only move on to new Linear tasks when **all open PRs have no unaddressed feedback and no failing CI checks** and **all in-progress Linear issues have no unanswered comments**. ## Step 3: Get your task from Linear +**Board caps — check the `[board-status: ...]` line at the very top of this prompt before doing anything in this step:** +- If `open_prs` is **greater than** `max_open_prs`, the review queue is full. Do **NOT** pick up a new Todo task or move anything to In Progress — more parallel work will only create merge conflicts. You should already have handled open-PR feedback in Step 2, so end the iteration. +- If `backlog` is **greater than** `max_backlog`, do **NOT** create any new Backlog tasks in the "no Todo tasks" branch below. +- (When *both* caps are exceeded, the loop backs off with a long sleep before Claude even runs — so if you are reading this, at least one path is still open.) + Query Linear for tasks. **Only pick tasks with state "Todo"** (type "unstarted"). These are tasks the project owner has reviewed and approved for work. Never pick "Backlog", "Done", or "Canceled" tasks. ```bash LINEAR_KEY=$(cat ~/.linear/api_key) -# Get the team ID +# Resolve the team ID from the team key TEAM_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ -H "Content-Type: application/json" \ -d '{"query": "{ teams(filter: { key: { eq: \"{{ manifest.linear.team_key }}\" } }) { nodes { id } } }"}' \ @@ -88,6 +109,7 @@ curl -s -H "Authorization: $LINEAR_KEY" \ **Before picking a task**, check it doesn't already have an open PR: ```bash +# Check ALL open PRs — look for the issue identifier in the title gh pr list --state open --json number,title,headRefName ``` @@ -96,6 +118,8 @@ If any open PR title contains the issue identifier (e.g., "{{ manifest.linear.te - Read PR comments for feedback - Make fixes on the same branch, commit, and push — the PR updates automatically +**Also skip tasks that another agent has already claimed.** Check the issue's comments for `[agent:...] claimed` markers. If you see a claim from a different agent, skip that task entirely — even if it's still in "Todo" (the other agent may be in the process of moving it). + If there are **no "Todo" tasks**, do a deep scan of the codebase and create **10 new tasks** in Backlog. Before creating each one, search existing issues to avoid duplicates. **Tasks must be substantial and impactful.** Do NOT create trivial CSS tweaks, text changes, or cosmetic fixes. Focus on work that meaningfully improves the product: @@ -111,6 +135,8 @@ Each task description should be detailed enough to take 20+ minutes of real work **Add them to "Backlog" (not "Todo")**. The project owner will review and move approved items to "Todo". **Do not work on tasks you just created** — end this iteration. +**Exception — `[ops]` fixes go straight to "Todo".** If a task you create is an operational breakage (broken Cloud Build/deploy, failing CI/tests blocking deploys, out-of-sync `package-lock.json`, infra/pipeline failure), prefix its title with `[ops]` and create it directly in **"Todo"** (resolve the Todo/`unstarted` state ID via the API — see the state lookup in Step 4), not Backlog — these are urgent and must not wait for owner promotion. + **Before creating a task**, search existing issues to avoid duplicates: ```bash curl -s -H "Authorization: $LINEAR_KEY" \ @@ -119,11 +145,47 @@ curl -s -H "Authorization: $LINEAR_KEY" \ https://api.linear.app/graphql ``` -## Step 4: Do the work +## Step 4: Claim and do the work + +**Multiple developer agents run in parallel.** To avoid collisions, you MUST claim the task before starting: -1. **Move the Linear issue to "In Progress"** via API (state type: "started"). -2. **Create a branch**: `{{ manifest.linear.team_key | lower }}-{number}/{short-desc}` (e.g., `{{ manifest.linear.team_key | lower }}-42/add-pagination`). -3. **Check if you need credentials or API keys** you don't have. If so: +1. **Re-fetch the issue's current state — only claim if it is still "Todo".** If it has moved to In Progress, In Review, Done, or Canceled since you queried, another agent or the owner has taken or finished it — skip it and pick another. **Never claim (move to In Progress) a task that is Done or Canceled.** Then claim the task atomically — move it to "In Progress" AND post a claim comment in one step: + ```bash + LINEAR_KEY=$(cat ~/.linear/api_key) + # Resolve the team ID, then the "In Progress" (started) state ID + TEAM_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"query": "{ teams(filter: { key: { eq: \"{{ manifest.linear.team_key }}\" } }) { nodes { id } } }"}' \ + https://api.linear.app/graphql | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['teams']['nodes'][0]['id'])") + IN_PROGRESS_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"{ team(id: \\\"$TEAM_ID\\\") { states(filter: { type: { eq: \\\"started\\\" } }) { nodes { id } } } }\"}" \ + https://api.linear.app/graphql | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['team']['states']['nodes'][0]['id'])") + # Move to In Progress + curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"mutation { issueUpdate(id: \\\"$ISSUE_ID\\\", input: { stateId: \\\"$IN_PROGRESS_ID\\\" }) { success } }\"}" \ + https://api.linear.app/graphql + # Post claim comment with your agent-id (injected at the top of this prompt) + curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"mutation { commentCreate(input: { issueId: \\\"$ISSUE_ID\\\", body: \\\"[agent:$AGENT_ID] claimed\\\" }) { success } }\"}" \ + https://api.linear.app/graphql + ``` + Your agent-id is in the `[agent-id: ...]` line at the very top of this prompt. + +2. **After claiming, re-check the issue comments** to see if another agent also claimed it in the same window: + ```bash + curl -s -H "Authorization: $LINEAR_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"query\": \"{ issue(id: \\\"$ISSUE_ID\\\") { comments(last: 5) { nodes { body createdAt } } } }\"}" \ + https://api.linear.app/graphql + ``` + If you see a `[agent:...] claimed` comment from a **different** agent, resolve it deterministically so exactly one agent proceeds: the **earlier `createdAt` wins**; on an exact-timestamp tie, the **lexicographically smaller agent-id wins**. If the other agent wins, **back off**: move the issue back to "Todo", delete your claim comment, and pick the next task. + +3. **Create your branch from the latest `origin/main`** so it never inherits stale state from a leftover branch of the same name (reusing an old local branch is what makes PRs drag in already-merged changes): `git fetch origin main && git checkout -B {{ manifest.linear.team_key | lower }}-{number}/{short-desc} origin/main` (e.g., `{{ manifest.linear.team_key | lower }}-42/add-pagination`). The `-B` flag resets the branch to current `origin/main` even if a local branch of that name already exists. + +4. **Check if you need credentials or API keys** you don't have. If so: - Add a comment to the Linear issue requesting what you need. Be specific: ``` @owner Blocked: this task requires a [Stripe API key / SendGrid key / etc.] @@ -132,21 +194,63 @@ curl -s -H "Authorization: $LINEAR_KEY" \ ``` - Move the issue back to **"Todo"** (so it stays visible but you don't re-pick it while blocked). - **Skip this task and move on to the next one.** Do not stub out or hardcode credentials. -4. **Make your changes.** Keep them focused on the single task. Do not scope-creep. -5. **Test:** +5. **Make your changes** following the **Engineering principles** section below (think in abstractions; reuse and parameterize; minimize footprint; isolate external providers). Keep each change scoped to the task — but leaving the code you touch more modular and reusable than you found it *is part of doing the task well*, not scope-creep. Do not launch an unrelated repo-wide refactor under a small ticket. +6. **Test:** - API changes: verify with curl or run locally. - Web/landing changes: `npm run build` must succeed. - Infra changes: `terraform validate`. -6. **Commit** with the issue ID: `{{ manifest.linear.team_key }}-42: Add pagination to /models endpoint` +7. **Commit** with the issue ID: `{{ manifest.linear.team_key }}-42: Add pagination to /models endpoint` + - Stage only the files you changed for THIS task (`git add <paths>`), never `git add -A`/`git add .`. + - Before committing, run `git status` and `git diff --cached --stat` and confirm **every** staged file belongs to this task. Your worktree starts clean, so anything unexpected is a mistake — unstage it. A PR must contain only the files its task touched. ## Step 5: Open a PR — do NOT mark the task as Done +0. **Last-chance validation before pushing.** Re-fetch BOTH (a) the issue's current Linear state and (b) ALL PRs for it — open **and merged**: `gh pr list --state all --search "{{ manifest.linear.team_key }}-{number} in:title" --json number,state`. **Abort this task** — discard your branch, do **not** open a PR, do **not** change any Linear state — if ANY of these holds: + - the issue is already **Done** or **Canceled** (someone finished or dropped it while you worked — a merged PR is not "open", so an open-only check misses this: this is exactly how duplicate PRs on completed tasks get created); + - a **merged** PR already exists for it (the work is already in `main`); + - an **open** PR already exists for it (a parallel agent finished first). + Only proceed if the issue is still **In Progress** (claimed by you) with no open or merged PR. + 1. Push: `git push -u origin HEAD` -2. Create PR: + +2. **Capture screenshots** for any web/landing UI changes. The project owner reviews PRs asynchronously and needs to see what the feature looks like. Use Playwright to capture screenshots: ```bash - gh pr create --title "{{ manifest.linear.team_key }}-{number}: {title}" --body "Resolves {{ manifest.linear.team_key }}-{number}" + # Install Playwright if not already present + npx playwright install chromium 2>/dev/null || true + + # Start the dev server in the background, wait for it, screenshot, and stop + cd services/web # or the relevant frontend service + npm start & + DEV_PID=$! + sleep 10 # wait for dev server + + # Capture screenshots of the relevant pages + npx playwright screenshot --browser chromium http://localhost:3000/your-page screenshot-name.png + # Capture multiple pages if the feature spans multiple routes + + kill $DEV_PID 2>/dev/null + ``` + Upload screenshots to the PR using `gh`: + ```bash + # Upload each screenshot and include in the PR body + for img in *.png; do + gh issue comment <pr-number> --body "![${img}](${img})" || true + done + ``` + Alternatively, embed screenshots directly in the PR description body using markdown image syntax. If the dev server can't start (missing env vars, database, etc.), note this in the PR and skip screenshots. + +3. Create PR: + ```bash + gh pr create --title "{{ manifest.linear.team_key }}-{number}: {title}" --body "$(cat <<EOF + Resolves {{ manifest.linear.team_key }}-{number} + + ## Screenshots + <!-- Paste Playwright screenshots here --> + + EOF + )" ``` -3. **Move the Linear issue to "In Review"** (NOT "Done"). The issue only moves to Done when the PR is merged. Since you cannot verify the PR will be merged, leave it in review. +4. **Move the Linear issue to "In Review"** (NOT "Done"). The issue only moves to Done when the PR is merged. Since you cannot verify the PR will be merged, leave it in review. **NEVER mark a Linear issue as "Done" or "Completed"** unless a PR for it has been merged (which you verify in Step 2). @@ -164,6 +268,18 @@ After opening a PR, **do not stop**. Return to **Step 2** and repeat the cycle: Maximize the work done in each iteration. Do not stop after a single task. +## Engineering principles — how to write the code + +You are not just closing tickets; you are shaping a codebase that has to keep growing. Optimize every change for **reuse, modularity, and future flexibility**, not just for making the current task pass. When you touch code, leave it more abstract and reusable than you found it. + +- **Think in abstractions.** Before writing, ask "what is the general capability here, and where should it live?" Prefer one small, well-named, **parameterized** function/class/module that the whole system can call over several near-identical bespoke copies. Name things by intent, not by implementation. +- **Decompose for reuse.** Pull shared logic into single, parameterizable units. The moment you're about to duplicate logic, extract it instead — and update the existing callers to use the shared version. **Reuse first; write new code only when nothing fits.** +- **Minimize the code footprint.** The best change is the smallest one that fully solves the task and leaves the codebase more reusable. Prefer extending or parameterizing an existing abstraction over adding a parallel one. Delete the dead/duplicated code you replace — don't leave it behind. +- **Design for what's coming.** Anticipate how the project is likely to evolve and structure code so that evolution is cheap. **Program to interfaces/contracts, not concrete implementations**, so a piece can be swapped or extended without rippling changes through its callers. +- **Isolate every external provider behind an abstraction.** Any third-party engine or service — a model/inference engine, database, object storage, auth, payments, search, email — must be reached only through a dedicated class/module that exposes an **intent-based interface** (e.g. a model engine offering `train()` / `predict()` / `deploy()`), never raw provider calls scattered across routes, workers, and components. The rest of the system depends on the interface, not the vendor, so the provider can be swapped for a different vendor or an in-house implementation by changing one module — with zero changes to callers. + +This is the one place the "stay focused" rule bends: improving modularity and reuse *of the code you're already touching* is expected and encouraged. The boundary is still real — don't turn a small ticket into a repo-wide rewrite. If a worthwhile abstraction is too big to fold into the current focused PR, create a **Backlog** task describing it instead. + ## Rules — read these carefully **Linear is the source of truth.** Always observe the full state before acting. @@ -175,21 +291,27 @@ Maximize the work done in each iteration. Do not stop after a single task. 5. **Never duplicate work.** If an open PR covers the task, iterate on that PR instead. 6. **Only pick "Todo" tasks.** Never pick "Backlog", "In Progress", "Done", or "Canceled". 7. **NEVER move items out of "Backlog".** The owner solely controls Backlog → Todo promotion. If you find yourself wanting to work on a Backlog item, stop. Backlog items are explicitly waiting on owner review — even if they have an existing PR, do NOT touch them. -8. **New tasks go to Backlog.** Never create tasks in "Todo" — the owner approves them first. -8. **Search before creating tasks.** Query existing issues by keyword to avoid duplicates. -9. **Address PR feedback first.** Open PRs with unaddressed comments take priority over new tasks. -10. **Reply to every comment.** Never leave a human comment unanswered on a PR or Linear issue. -11. **Clean main between tasks.** `git checkout main && git pull` before starting each new task. -12. **Do not modify secrets or hardcode credentials.** Comment on the Linear issue requesting them. Move back to "Todo" and move on to the next task. -13. **Do not modify infrastructure** unless the task specifically asks for it. -14. **If blocked on one task**, move on to the next — don't stop the whole iteration. -15. **Do substantial work.** Each PR should represent meaningful progress — multiple files, real functionality. Don't split work into trivially small PRs. +8. **New tasks go to Backlog** — *except* `[ops]` breakage fixes (broken build/deploy/CI, out-of-sync lockfiles, infra failures), which go straight to **"Todo"** so they're worked immediately. Everything else waits for owner promotion. +9. **Search before creating tasks.** Query existing issues by keyword to avoid duplicates. +10. **Address PR feedback first.** Open PRs with unaddressed comments take priority over new tasks. +11. **Reply to every comment.** Never leave a human comment unanswered on a PR or Linear issue. +12. **A fresh, isolated tree each iteration is automatic.** The loop starts you in a private worktree scrubbed to the latest `main` — never `git checkout main` (it's checked out in the shared clone and will fail). If you handle multiple tasks in one iteration, reset between them with `git checkout -f --detach origin/main && git clean -fd`. +13. **Do not modify secrets or hardcode credentials.** Comment on the Linear issue requesting them. Move back to "Todo" and move on to the next task. +14. **Do not modify infrastructure** unless the task specifically asks for it. +15. **If blocked on one task**, move on to the next — don't stop the whole iteration. +16. **Do substantial work.** Each PR should represent meaningful progress — multiple files, real functionality. Don't split work into trivially small PRs. If a task is truly too large for one PR (50+ files), break it into logical phases, but each phase should still be significant. +17. **Never merge your own PRs.** Only the project owner merges PRs. Your job is to open PRs and address feedback — never run `gh pr merge` or merge via any other method. +18. **Do not run `npm install` or `npm ci` unless node_modules is missing.** Dependencies are pre-installed on the VM and seeded into your worktree. If `node_modules/` exists, use it as-is. If it is missing (e.g., after a fresh clone or branch switch), you may run `npm ci --no-audit --no-fund --legacy-peer-deps` to restore it. Playwright and Chromium are pre-installed globally — use them for screenshots as described in Step 5. +19. **Never make direct GCP changes.** Do not run `gcloud`, `terraform apply`, `gsutil`, or any command that mutates GCP resources. All infrastructure changes must go through Terraform in the repo — commit the `.tf` changes and open a PR. You have read-only GCP access for viewing logs and resource state only. +20. **Write for reuse and future flexibility.** Follow the **Engineering principles** section: think in abstractions, reuse and parameterize instead of duplicating, minimize the code footprint, program to interfaces, and keep every external provider behind a dedicated swappable module. Leave the code you touch more modular than you found it. +21. **Respect the board caps.** Read the `[board-status]` line at the top of this prompt. When `open_prs > max_open_prs`, do not start new tasks or move anything to In Progress; when `backlog > max_backlog`, do not create Backlog tasks. These caps prevent merge-conflict pileups and runaway cost — the loop backs off (a long sleep, auto-resuming when the board clears) when both are exceeded. +22. **NEVER move a task out of "Done" or "Canceled", and never open a duplicate PR on a completed task.** Before EVERY state change — claiming (Todo → In Progress) or submitting (→ In Review) — and right before opening a PR, re-read the issue's *current* state and check for existing PRs (open **and merged**). If it is Done/Canceled or already has a merged or open PR, stop immediately and leave it untouched: the owner or another agent finalized it while you worked, and resurrecting it produces a duplicate PR on a completed task (Step 5.0). (A deterministic reconciler also repairs this, but do not rely on it.) ## Decision flowchart (loop within each iteration) ``` START: - git checkout main && git pull + (already in a pristine private worktree at latest main — no checkout needed) LOOP: 1. Sync Linear ↔ GitHub: @@ -198,6 +320,8 @@ LOOP: 2. Check open PRs: ├─ PR with unanswered comments? → Fix/reply, push. Go to LOOP. + ├─ PR with merge conflicts? → Rebase onto main, resolve, force-push. Go to LOOP. + ├─ PR with failing required checks? → Re-run once; if still red, fix, push, comment. Go to LOOP. └─ All PRs clean? → Continue. 3. Check Linear issue comments on in-progress tasks: @@ -208,7 +332,7 @@ LOOP: ├─ Found a "Todo" task? │ ├─ Has existing open PR? → Iterate on that PR. Go to LOOP. │ ├─ Needs credentials you don't have? → Comment, move to Todo, skip. Try next task. - │ └─ Ready to work? → Branch, code, test, commit, push, PR, "In Review". Go to LOOP. + │ └─ Ready to work? → Claim, branch, code, test, commit, push, PR, "In Review". Go to LOOP. └─ No "Todo" tasks? └─ Scan codebase, create 10 Backlog tasks (search for dupes first). EXIT. From 7e0ada2dec308e2b3bfc40848200e6d5f29aeb02 Mon Sep 17 00:00:00 2001 From: David Gaspard <davidgaspard@MacBook-Pro-3.local> Date: Mon, 27 Jul 2026 10:16:10 -0400 Subject: [PATCH 6/9] templates: gauge review-queue backpressure from Linear, not open GitHub PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors ai4us2's loop.sh fix. The open-PR cap counted every open PR on the repo, but PRs the owner demotes to Backlog stay open on GitHub indefinitely, inflating the count past the cap and wedging the whole fleet in a pre-Claude backoff while real Todo work waited. Count Linear issues in "In Review" instead — the actual review queue awaiting the owner. count_open_prs -> count_in_review; MAX_OPEN_PRS -> MAX_IN_REVIEW (legacy AGENT_MAX_OPEN_PRS honored as fallback); REVIEW_STATE overridable via AGENT_LINEAR_REVIEW_STATE. Board-status header and both developer prompt templates updated to in_review/max_in_review. --- .../developer/PROMPT.library-node.md.j2 | 107 +++++++++ .../templates/agents/developer/PROMPT.md.j2 | 4 +- repoScaffold/templates/project/loop.sh.j2 | 214 +++++++++++++++++- 3 files changed, 313 insertions(+), 12 deletions(-) create mode 100644 repoScaffold/templates/agents/developer/PROMPT.library-node.md.j2 diff --git a/repoScaffold/templates/agents/developer/PROMPT.library-node.md.j2 b/repoScaffold/templates/agents/developer/PROMPT.library-node.md.j2 new file mode 100644 index 0000000..bbe12f9 --- /dev/null +++ b/repoScaffold/templates/agents/developer/PROMPT.library-node.md.j2 @@ -0,0 +1,107 @@ +You are an autonomous agent working on the **{{ manifest.project.name }}** project — a **publishable Node/npm package** (not a web app). + +## Step 1: Orient + +1. Read `agents/AGENTS.md` for the package layout, build/test/publish commands, and versioning rules. +2. `git checkout main && git pull` — always start clean on main. +3. `git stash drop` any stale stashes. Do NOT carry over work from previous iterations. +4. Remember: the package lives in `{{ manifest.library.package_dir }}/`. Always `cd {{ manifest.library.package_dir }}` before running package scripts. There is no server, database, or cloud runtime — do not try to deploy anything. + +## Step 2: Sync Linear issues with PR/merge status + +Reconcile Linear with GitHub before picking new work. + +```bash +gh pr list --state merged --limit 10 --json number,title,mergedAt +gh pr list --state closed --limit 10 --json number,title,closedAt +``` + +For each **merged PR**: extract the issue ID from the title (e.g. `{{ manifest.linear.team_key }}-21`). If the Linear issue is not already **Done**, move it to Done via the API. +For each **closed (not merged) PR**: if the Linear issue is In Review/In Progress, move it to **Canceled**. + +**Then check open PRs and their comments:** + +```bash +gh pr list --state open --json number,title,headRefName,comments +``` + +For each open PR: +0. If the linked Linear issue is in **Backlog**, leave the PR alone — the owner demoted it and is reviewing. +1. Read comments: `gh pr view <number> --comments`. +2. If there are **unanswered human comments**: `gh pr checkout <number>`, address the feedback, `cd {{ manifest.library.package_dir }} && {{ manifest.library.build_cmd }}` to verify, commit, push, and **reply to each comment** explaining what changed. +3. Otherwise leave it for the reviewer. + +Also check Linear issue comments on In Progress / In Review tasks and address them. Only move on to new tasks when all open PRs and in-progress issues have no unanswered feedback. + +```bash +LINEAR_KEY=$(cat ~/.linear/api_key) +TEAM_ID=$(curl -s -H "Authorization: $LINEAR_KEY" -H "Content-Type: application/json" \ + -d '{"query": "{ teams(filter: { key: { eq: \"{{ manifest.linear.team_key }}\" } }) { nodes { id } } }"}' \ + https://api.linear.app/graphql | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['teams']['nodes'][0]['id'])") +``` + +## Step 3: Get your task from Linear + +**Board caps — check the `[board-status: ...]` line at the very top of this prompt first:** +- If `in_review` is **greater than** `max_in_review`, the review queue is full. Do **NOT** pick up a new Todo task or move anything to In Progress — more parallel work just creates merge conflicts. You should already have handled open-PR feedback in Step 2, so end the iteration. +- If `backlog` is **greater than** `max_backlog`, do **NOT** create any new Backlog tasks in the "no Todo tasks" branch below. +- (When *both* caps are exceeded the loop backs the agent off before Claude even runs, so if you're reading this at least one path is still open.) + +**Only pick tasks with state "Todo"** (type `unstarted`) — owner-approved. Never pick Backlog, Done, or Canceled. + +```bash +curl -s -H "Authorization: $LINEAR_KEY" -H "Content-Type: application/json" \ + -d "{\"query\": \"{ team(id: \\\"$TEAM_ID\\\") { issues(filter: { state: { type: { eq: \\\"unstarted\\\" } } }, first: 10, orderBy: priority) { nodes { id identifier title description priority state { name type } } } } }\"}" \ + https://api.linear.app/graphql +``` + +Before picking, check it doesn't already have an open PR (`gh pr list --state open`). If it does, iterate on that PR's branch instead of opening a second one. + +If there are **no "Todo" tasks**, do a deep scan of the package and create **up to 10 substantial tasks in Backlog** (search existing issues first to avoid dupes). For a library, good tasks are: +- Emit TypeScript `.d.ts` declarations so consumers get types. +- Add real unit/interaction tests for the public components. +- Add CI (build + test on PRs; publish on tag). +- New props / controlled variants / better defaults on the public API. +- Bundle health: tree-shaking, `sideEffects`, smaller output, fewer runtime deps. +- Storybook stories + README examples for every public export. + +Do NOT create trivial CSS/text tweaks. Add them to **Backlog** (not Todo) and end the iteration — the owner approves them. + +## Step 4: Do the work + +1. Move the issue to **In Progress** (state type `started`). +2. Branch: `{{ manifest.linear.team_key | lower }}-{number}/{short-desc}`. +3. If you need credentials you don't have, comment on the issue requesting them, move it back to **Todo**, and skip to the next task. Never hardcode secrets or commit an `.npmrc` token. +4. Make focused changes inside `{{ manifest.library.package_dir }}/`. Keep the public API stable unless the task is explicitly a breaking change. +5. **Verify locally:** + - `cd {{ manifest.library.package_dir }} && {{ manifest.library.build_cmd }}` **must succeed.** + - If a real test script exists, `{{ manifest.library.test_cmd }}` must pass. (If `test` is only a placeholder that errors, skip it — do not treat the placeholder failure as a blocker.) + - For UI/component changes, sanity-check in Storybook if the package has it. +6. **Commit** with the issue ID: `{{ manifest.linear.team_key }}-{number}: {short summary}`. + +## Step 5: Open a PR — do NOT publish and do NOT mark Done + +1. Push: `git push -u origin HEAD`. +2. `gh pr create --title "{{ manifest.linear.team_key }}-{number}: {title}" --body "Resolves {{ manifest.linear.team_key }}-{number}. Suggested version bump: patch|minor|major (with reason)."` +3. Move the Linear issue to **In Review**. + +**Never run `{{ manifest.library.publish_cmd }}`, never bump the published version on `main`, and never mark an issue Done unless its PR is merged.** Releases are the maintainer's call — propose the semver bump in the PR body and stop there. + +## Step 6: Keep working + +After opening a PR, return to Step 2 and repeat until: there are no more Todo tasks, remaining tasks are blocked on human input, or all open PRs need human review. Maximize useful work per iteration. + +## Rules + +1. **Linear is the source of truth.** Observe full state before acting. +2. **Never push directly to main.** Feature branch + PR only. +3. **Never mark issues Done unless their PR is merged** (verify via `gh pr list --state merged`). +4. **One PR per task.** Push new commits to the existing branch instead of opening a second PR. +5. **Only pick "Todo" tasks.** Never touch Backlog items — the owner controls Backlog → Todo. +6. **New tasks go to Backlog**, and search before creating to avoid dupes. +7. **Address PR/issue feedback before new tasks.** Reply to every human comment. +8. **Every PR must build.** Run `{{ manifest.library.build_cmd }}` from `{{ manifest.library.package_dir }}/` first. +9. **Do not publish, do not bump the published version, do not commit registry credentials.** +10. **Preserve the public API** unless the task explicitly asks for a breaking change; call it out as a major bump in the PR. +11. **If blocked on one task, move to the next** — don't stop the whole iteration. +12. **Respect the board caps.** Read the `[board-status]` line at the top. When `in_review > max_in_review`, don't start new tasks or move anything to In Progress; when `backlog > max_backlog`, don't create Backlog tasks. The review-queue cap counts Linear issues **In Review**, not open GitHub PRs — a PR the owner demoted to Backlog stays open on GitHub but is not review load. These caps prevent merge-conflict pileups and runaway cost — the loop backs you off entirely when both are exceeded. diff --git a/repoScaffold/templates/agents/developer/PROMPT.md.j2 b/repoScaffold/templates/agents/developer/PROMPT.md.j2 index be7b785..fe4c7cf 100644 --- a/repoScaffold/templates/agents/developer/PROMPT.md.j2 +++ b/repoScaffold/templates/agents/developer/PROMPT.md.j2 @@ -85,7 +85,7 @@ Only move on to new Linear tasks when **all open PRs have no unaddressed feedbac ## Step 3: Get your task from Linear **Board caps — check the `[board-status: ...]` line at the very top of this prompt before doing anything in this step:** -- If `open_prs` is **greater than** `max_open_prs`, the review queue is full. Do **NOT** pick up a new Todo task or move anything to In Progress — more parallel work will only create merge conflicts. You should already have handled open-PR feedback in Step 2, so end the iteration. +- If `in_review` is **greater than** `max_in_review`, the review queue is full. Do **NOT** pick up a new Todo task or move anything to In Progress — more parallel work will only create merge conflicts. You should already have handled open-PR feedback in Step 2, so end the iteration. - If `backlog` is **greater than** `max_backlog`, do **NOT** create any new Backlog tasks in the "no Todo tasks" branch below. - (When *both* caps are exceeded, the loop backs off with a long sleep before Claude even runs — so if you are reading this, at least one path is still open.) @@ -304,7 +304,7 @@ This is the one place the "stay focused" rule bends: improving modularity and re 18. **Do not run `npm install` or `npm ci` unless node_modules is missing.** Dependencies are pre-installed on the VM and seeded into your worktree. If `node_modules/` exists, use it as-is. If it is missing (e.g., after a fresh clone or branch switch), you may run `npm ci --no-audit --no-fund --legacy-peer-deps` to restore it. Playwright and Chromium are pre-installed globally — use them for screenshots as described in Step 5. 19. **Never make direct GCP changes.** Do not run `gcloud`, `terraform apply`, `gsutil`, or any command that mutates GCP resources. All infrastructure changes must go through Terraform in the repo — commit the `.tf` changes and open a PR. You have read-only GCP access for viewing logs and resource state only. 20. **Write for reuse and future flexibility.** Follow the **Engineering principles** section: think in abstractions, reuse and parameterize instead of duplicating, minimize the code footprint, program to interfaces, and keep every external provider behind a dedicated swappable module. Leave the code you touch more modular than you found it. -21. **Respect the board caps.** Read the `[board-status]` line at the top of this prompt. When `open_prs > max_open_prs`, do not start new tasks or move anything to In Progress; when `backlog > max_backlog`, do not create Backlog tasks. These caps prevent merge-conflict pileups and runaway cost — the loop backs off (a long sleep, auto-resuming when the board clears) when both are exceeded. +21. **Respect the board caps.** Read the `[board-status]` line at the top of this prompt. When `in_review > max_in_review`, do not start new tasks or move anything to In Progress; when `backlog > max_backlog`, do not create Backlog tasks. The review-queue cap counts Linear issues **In Review**, not open GitHub PRs — a PR the owner demoted to Backlog stays open on GitHub but is not review load. These caps prevent merge-conflict pileups and runaway cost — the loop backs off (a long sleep, auto-resuming when the board clears) when both are exceeded. 22. **NEVER move a task out of "Done" or "Canceled", and never open a duplicate PR on a completed task.** Before EVERY state change — claiming (Todo → In Progress) or submitting (→ In Review) — and right before opening a PR, re-read the issue's *current* state and check for existing PRs (open **and merged**). If it is Done/Canceled or already has a merged or open PR, stop immediately and leave it untouched: the owner or another agent finalized it while you worked, and resurrecting it produces a duplicate PR on a completed task (Step 5.0). (A deterministic reconciler also repairs this, but do not rely on it.) ## Decision flowchart (loop within each iteration) diff --git a/repoScaffold/templates/project/loop.sh.j2 b/repoScaffold/templates/project/loop.sh.j2 index 0a3e027..83415c7 100644 --- a/repoScaffold/templates/project/loop.sh.j2 +++ b/repoScaffold/templates/project/loop.sh.j2 @@ -10,18 +10,28 @@ # # The setup-vm.sh script starts all agents in separate tmux windows. -AGENT_NAME="${1:?Usage: ./loop.sh <agent-name>}" +AGENT_NAME="${1:?Usage: ./agents/loop.sh <agent-name>}" PROJECT_ID="{{ manifest.project.cloud.project_id }}" -REPO_DIR="$(cd "$(dirname "$0")" && pwd)" -AGENT_DIR="$REPO_DIR/agents/$AGENT_NAME" +AGENTS_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(cd "$AGENTS_DIR/.." && pwd)" +AGENT_DIR="$AGENTS_DIR/$AGENT_NAME" LOG_TAG="agent-${AGENT_NAME}-${PROJECT_ID}" LOG_DIR="/var/log/agent/${PROJECT_ID}/${AGENT_NAME}" +# The main clone ($MAIN_REPO) holds the agent scripts and is the hot-reload +# source. Each agent does its actual git/file work in a PRIVATE worktree so +# concurrent agents never share a working tree — sharing one tree let uncommitted +# changes from one agent's task bleed into another's PR (10-file "package-lock" +# PRs, etc.). The worktree is checked out DETACHED at origin/main so it never +# collides with the main clone's `main` checkout. +MAIN_REPO="$REPO_DIR" +WORKTREE="${AGENT_WORKTREE:-$HOME/{{ manifest.project.name | lower | replace(' ', '-') }}-wt-$AGENT_NAME}" + if [ ! -f "$AGENT_DIR/PROMPT.md" ]; then - echo "ERROR: agents/$AGENT_NAME/PROMPT.md not found" + echo "ERROR: $AGENT_NAME/PROMPT.md not found in agents/" echo "Available agents:" - ls -1 "$REPO_DIR/agents/" 2>/dev/null | while read -r d; do - [ -f "$REPO_DIR/agents/$d/PROMPT.md" ] && echo " - $d" + ls -1 "$AGENTS_DIR/" 2>/dev/null | while read -r d; do + [ -f "$AGENTS_DIR/$d/PROMPT.md" ] && echo " - $d" done exit 1 fi @@ -39,6 +49,27 @@ fi SLEEP_SECONDS="${AGENT_SLEEP_SECONDS:-$SLEEP_SECONDS}" MAX_TURNS="${AGENT_MAX_TURNS:-$MAX_TURNS}" +# Board backpressure caps. Env-overridable. +# The review-queue signal is the number of Linear issues currently In Review — +# NOT the raw GitHub open-PR count. PRs the owner has demoted to Backlog stay +# open on GitHub but must NOT count as review load, or the board looks saturated +# while there is real Todo work waiting. +# Partial (developer agents): if In Review exceeds MAX_IN_REVIEW, developers stop +# starting new tasks (a full review queue just yields merge conflicts); if the +# Backlog exceeds MAX_BACKLOG, they stop creating tasks. +# Full saturation (ALL agents): when BOTH caps are exceeded there is nothing new +# to add and the human must clear the board — so every agent BACKS OFF for +# BACKOFF_SECONDS (a long sleep, no Claude call) and auto-resumes when cleared, +# instead of polling frequently. No restart needed. +MAX_IN_REVIEW="${AGENT_MAX_IN_REVIEW:-${AGENT_MAX_OPEN_PRS:-10}}" +MAX_BACKLOG="${AGENT_MAX_BACKLOG:-50}" +BACKOFF_SECONDS="${AGENT_BACKOFF_SECONDS:-3600}" +TEAM_KEY="${AGENT_LINEAR_TEAM_KEY:-{{ manifest.linear.team_key }}}" +REVIEW_STATE="${AGENT_LINEAR_REVIEW_STATE:-In Review}" +# One designated agent runs the deterministic board reconciliation each cycle +# (no Claude) — syncs Linear state to the real PR state (see reconcile.py). +RECONCILE_AGENT="${AGENT_RECONCILE_AGENT:-developer-1}" + # ── Logging ───────────────────────────────────────────────────── log() { @@ -47,6 +78,45 @@ log() { logger -t "$LOG_TAG" "$msg" 2>/dev/null || true } +# ── Board backpressure (cheap pre-Claude checks — no model tokens) ── + +# Number of issues currently In Review on the team — the review queue awaiting +# the owner. Queried from Linear (single request), NOT from GitHub: PRs the owner +# demoted to Backlog stay open on GitHub but are no longer In Review, so they +# correctly drop out of the backpressure signal. +count_in_review() { + local key; key="$(cat ~/.linear/api_key 2>/dev/null)" + [ -z "$key" ] && { echo 0; return; } + curl -s -H "Authorization: $key" -H "Content-Type: application/json" \ + -d "{\"query\":\"{ teams(filter:{key:{eq:\\\"$TEAM_KEY\\\"}}){ nodes{ issues(first:250, filter:{state:{name:{eq:\\\"$REVIEW_STATE\\\"}}}){ nodes{ id } } } } }\"}" \ + https://api.linear.app/graphql \ + | python3 -c 'import sys,json; d=json.load(sys.stdin)["data"]["teams"]["nodes"]; print(len(d[0]["issues"]["nodes"]) if d else 0)' 2>/dev/null || echo 0 +} + +# Number of Backlog issues on the team (queried by team key, single request). +count_backlog() { + local key; key="$(cat ~/.linear/api_key 2>/dev/null)" + [ -z "$key" ] && { echo 0; return; } + curl -s -H "Authorization: $key" -H "Content-Type: application/json" \ + -d "{\"query\":\"{ teams(filter:{key:{eq:\\\"$TEAM_KEY\\\"}}){ nodes{ issues(first:250, filter:{state:{type:{eq:\\\"backlog\\\"}}}){ nodes{ id } } } } }\"}" \ + https://api.linear.app/graphql \ + | python3 -c 'import sys,json; d=json.load(sys.stdin)["data"]["teams"]["nodes"]; print(len(d[0]["issues"]["nodes"]) if d else 0)' 2>/dev/null || echo 0 +} + +# Emit a saturation alert: a structured Cloud Logging marker (for a log-based +# email alert) plus a best-effort local email if a mailer is configured. +notify_saturation() { + local in_review="$1" backlog="$2" + logger -t "agent-alert-${PROJECT_ID}" \ + "AGENT_SATURATION_STOP agent=${AGENT_NAME} project=${PROJECT_ID} in_review=${in_review} max_in_review=${MAX_IN_REVIEW} backlog=${backlog} max_backlog=${MAX_BACKLOG}" 2>/dev/null || true + log "ALERT: board saturated — ${in_review} in review (> ${MAX_IN_REVIEW}) and ${backlog} backlog (> ${MAX_BACKLOG}). ${AGENT_NAME} backing off ${BACKOFF_SECONDS}s until cleared." + if [ -n "${AGENT_ALERT_EMAIL:-}" ] && command -v mail >/dev/null 2>&1; then + printf 'Agent %s (project %s) is backing off: the board is saturated.\n\nIn Review: %s (limit %s)\nBacklog: %s (limit %s)\n\nMerge the review queue or clear the backlog. Agents re-check every %ss and resume automatically — no restart needed.\n' \ + "$AGENT_NAME" "$PROJECT_ID" "$in_review" "$MAX_IN_REVIEW" "$backlog" "$MAX_BACKLOG" "$BACKOFF_SECONDS" \ + | mail -s "[agent-alert] ${PROJECT_ID}/${AGENT_NAME} backing off — board saturated" "$AGENT_ALERT_EMAIL" 2>/dev/null || true + fi +} + # ── One-time setup ────────────────────────────────────────────── setup() { @@ -75,6 +145,22 @@ setup() { log "Warning: linear-api-key not found in Secret Manager." fi + # Claude Code auth — long-lived subscription OAuth token (from `claude setup-token`). + # Draws usage from the Claude subscription (not metered API billing) and is + # valid ~1 year, avoiding the interactive-login token expiry that takes the + # fleet down. Fetched via gcloud (never on a command line) so it's not exposed + # in `ps`. CLAUDE_CODE_OAUTH_TOKEN takes precedence over ~/.claude creds. + if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then + log "Pulling Claude Code OAuth token from Secret Manager..." + CLAUDE_CODE_OAUTH_TOKEN="$(gcloud secrets versions access latest \ + --secret=claude-code-oauth-token \ + --project="$PROJECT_ID" 2>/dev/null || true)" + export CLAUDE_CODE_OAUTH_TOKEN + fi + if [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then + log "ERROR: claude-code-oauth-token not found — Claude cannot authenticate." + fi + cd "$REPO_DIR" log "Working in: $REPO_DIR" log "Agent: $AGENT_NAME" @@ -90,6 +176,48 @@ cleanup_disk() { rm -rf /tmp/npm-* /tmp/pip-* 2>/dev/null || true } +# ── Per-agent worktree isolation ──────────────────────────────── +# Ensure this agent has its own private worktree, scrubbed to a pristine +# origin/main. Because the tree is private, the hard reset + clean are safe: +# no concurrent agent shares it, so nothing bleeds across tasks/PRs. +prepare_worktree() { + cd "$MAIN_REPO" || return 0 + git fetch origin main --quiet 2>/dev/null || true + + # Create the worktree once, detached at origin/main (detached avoids the + # "branch 'main' is already checked out" clash with the main clone). + if ! git worktree list --porcelain 2>/dev/null | grep -qx "worktree $WORKTREE"; then + rm -rf "$WORKTREE" 2>/dev/null || true + git worktree prune 2>/dev/null || true + git worktree add --detach "$WORKTREE" origin/main 2>/dev/null \ + || git worktree add --detach "$WORKTREE" main 2>/dev/null \ + || { log "WARN: could not create worktree $WORKTREE — falling back to main clone"; WORKTREE="$MAIN_REPO"; return 0; } + log "Created private worktree: $WORKTREE" + fi + + cd "$WORKTREE" || { WORKTREE="$MAIN_REPO"; return 0; } + # Scrub to a pristine, DETACHED origin/main: -f discards tracked edits and + # moves off any stale feature branch left by the previous iteration (so old + # local branches don't pile up); `git clean -fd` (no -x) removes untracked + # files but keeps ignored ones (node_modules, build caches). + git checkout -f --detach origin/main 2>/dev/null \ + || git checkout -f --detach main 2>/dev/null \ + || git reset --hard origin/main 2>/dev/null || true + git clean -fd 2>/dev/null || true + + # Seed installed deps from the main clone so each worktree doesn't reinstall. + # Use a hardlink copy (cp -al): near-instant, minimal disk, and a REAL dir + # (not a symlink) so the `node_modules/` gitignore rule actually matches it and + # `git clean -fd` preserves it. npm unlinks+rewrites files, so hardlinks don't + # corrupt the shared source. Only seeds once per worktree (skips if present). + for svc in {% for s in manifest.services %}services/{{ s.name }} {% endfor %}; do + if [ -d "$MAIN_REPO/$svc/node_modules" ] && [ ! -d "$WORKTREE/$svc/node_modules" ] && [ -d "$WORKTREE/$svc" ]; then + cp -al "$MAIN_REPO/$svc/node_modules" "$WORKTREE/$svc/node_modules" 2>/dev/null \ + || cp -a "$MAIN_REPO/$svc/node_modules" "$WORKTREE/$svc/node_modules" 2>/dev/null || true + fi + done +} + # ── Main loop ─────────────────────────────────────────────────── run_iteration() { @@ -100,11 +228,28 @@ run_iteration() { cleanup_disk - cd "$REPO_DIR" - git checkout main 2>/dev/null || true - git pull --rebase || git pull || true + # Stagger multi-instance agents so they don't all hit Linear at the same moment + JITTER=$(( RANDOM % 30 )) + log "Jitter: sleeping ${JITTER}s before starting" + sleep "$JITTER" || true + + # Run the task in this agent's PRIVATE worktree (isolated from other agents). + cd "$WORKTREE" 2>/dev/null || cd "$MAIN_REPO" - claude -p "$(cat "$AGENT_DIR/PROMPT.md")" \ + PROMPT="$(cat "$AGENT_DIR/PROMPT.md")" + # Inject agent identity (multi-instance coordination) and, for developers, the + # current board counts + caps (set by the main loop) so Claude honors them + # without re-querying. + HEADER="[agent-id: ${AGENT_NAME}]" + case "$AGENT_NAME" in + developer*) HEADER="${HEADER} +[board-status: in_review=${IN_REVIEW} max_in_review=${MAX_IN_REVIEW} backlog=${BACKLOG} max_backlog=${MAX_BACKLOG}]" ;; + esac + PROMPT="${HEADER} + +${PROMPT}" + + claude -p "$PROMPT" \ --allowedTools "Read,Edit,Write,Bash,Glob,Grep" \ --max-turns "$MAX_TURNS" \ --dangerously-skip-permissions \ @@ -123,8 +268,57 @@ run_iteration() { setup +# Hash of the running script, to detect updates pulled from main. +SELF_PATH="$AGENTS_DIR/loop.sh" +SELF_HASH="$(sha1sum "$SELF_PATH" 2>/dev/null | awk '{print $1}')" + +was_saturated=false iteration=1 while true; do + # Refresh the MAIN CLONE (agent scripts + hot-reload source) on latest main. + # Force to main: the main clone is no longer used for task work, so discarding + # any stray local state here is safe and keeps hot-reload deterministic. + cd "$MAIN_REPO" + git checkout -f main 2>/dev/null || true + git pull --rebase 2>/dev/null || git pull 2>/dev/null || true + + # Hot-reload: if loop.sh itself changed on main, re-exec the new version now — + # a safe boundary (no task running). Deploy loop/prompt changes by pushing to + # main; we never kill a running agent to pick up updates. + NEW_HASH="$(sha1sum "$SELF_PATH" 2>/dev/null | awk '{print $1}')" + if [ -n "$NEW_HASH" ] && [ "$NEW_HASH" != "$SELF_HASH" ]; then + log "loop.sh updated on main — reloading (no task interrupted)." + exec bash "$SELF_PATH" "$AGENT_NAME" + fi + + # Deterministic board reconciliation (one designated agent; no Claude). Syncs + # Linear state to the real PR state — merged->Done + close duplicate PRs, + # closed-only->Todo, stranded open PR->In Review. Runs before the saturation + # check so relieving duplicates/merges can lift a saturated board. + if [ "$AGENT_NAME" = "$RECONCILE_AGENT" ] && [ -f "$AGENTS_DIR/reconcile.py" ]; then + ( cd "$MAIN_REPO" && python3 "$AGENTS_DIR/reconcile.py" "$TEAM_KEY" ) || log "reconcile failed (non-fatal)" + fi + + # Board backpressure — applies to ALL agents. Cheap counts, no Claude call. + IN_REVIEW="$(count_in_review)"; BACKLOG="$(count_backlog)" + log "Board status: in_review=${IN_REVIEW}/${MAX_IN_REVIEW}, backlog=${BACKLOG}/${MAX_BACKLOG}" + if [ "${IN_REVIEW:-0}" -gt "$MAX_IN_REVIEW" ] && [ "${BACKLOG:-0}" -gt "$MAX_BACKLOG" ]; then + # Saturated: nothing new can be added and the human must clear the board. + # Back off for a long interval instead of polling / calling Claude; auto- + # resume when cleared. Alert once per entry into the saturated state. + if ! $was_saturated; then notify_saturation "$IN_REVIEW" "$BACKLOG"; was_saturated=true; fi + log "Board saturated — backing off ${BACKOFF_SECONDS}s (no Claude call). Auto-resumes when cleared." + sleep "$BACKOFF_SECONDS" || true + continue + fi + if $was_saturated; then + log "Board no longer saturated — resuming normal cadence." + was_saturated=false + fi + + # Prepare this agent's isolated, pristine worktree, then run the task in it. + prepare_worktree + run_iteration "$iteration" || log "Iteration $iteration failed — continuing" iteration=$((iteration + 1)) sleep "$SLEEP_SECONDS" || true From 26eba375fb77162ec501dd15d965e709107a1f6f Mon Sep 17 00:00:00 2001 From: David Gaspard <davidgaspard@MacBook-Pro-3.local> Date: Mon, 27 Jul 2026 10:57:05 -0400 Subject: [PATCH 7/9] =?UTF-8?q?platform-cli:=20capture=20drift=20=E2=80=94?= =?UTF-8?q?=20GCP=20org=20placement,=20new=20phases,=20templates,=20gitign?= =?UTF-8?q?ore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles the accumulated uncommitted work on the scaffolder into one reviewable change: - GCP org placement: new steps/_gcp_org.py (resolve org + adopt orphan projects into it) wired into phase_g and the library pipeline, plus a manifest cloud.org_id field — so scaffolded projects always land under the org instead of "No organization". - New phase steps: phase_l_deploy, phase_m_pipelines, phase_n_agents, phase_o_linear, phase_p_library_pipeline. - New/updated templates: library-node variants (settings, AGENTS, README), npm-publish + worker cloudbuild, reconcile.py, plus refinements to existing agent/cloudbuild/terraform/worker templates. - Example manifests: carouselr.yaml, fresler-table.yaml. - .gitignore: ignore Python bytecode + egg-info and stop tracking them (removes 41 generated files from the index that were causing constant drift). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .gitignore | 5 +- .vscode/launch.json | 9 + repoScaffold/examples/carouselr.yaml | 36 ++ repoScaffold/examples/fresler-table.yaml | 34 ++ .../src/platform_cli.egg-info/PKG-INFO | 10 - .../src/platform_cli.egg-info/SOURCES.txt | 43 -- .../dependency_links.txt | 1 - .../platform_cli.egg-info/entry_points.txt | 2 - .../src/platform_cli.egg-info/requires.txt | 5 - .../src/platform_cli.egg-info/top_level.txt | 1 - .../__pycache__/__init__.cpython-313.pyc | Bin 263 -> 0 bytes .../__pycache__/cli.cpython-313.pyc | Bin 1002 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 182 -> 0 bytes .../__pycache__/install.cpython-313.pyc | Bin 3110 -> 0 bytes .../__pycache__/scaffold.cpython-313.pyc | Bin 3718 -> 0 bytes .../__pycache__/test_cmd.cpython-313.pyc | Bin 6694 -> 0 bytes .../src/platform_cli/commands/scaffold.py | 15 +- .../__pycache__/__init__.cpython-313.pyc | Bin 180 -> 0 bytes .../__pycache__/context.cpython-313.pyc | Bin 2886 -> 0 bytes .../engine/__pycache__/dag.cpython-313.pyc | Bin 2686 -> 0 bytes .../__pycache__/registry.cpython-313.pyc | Bin 1338 -> 0 bytes .../engine/__pycache__/runner.cpython-313.pyc | Bin 3925 -> 0 bytes .../engine/__pycache__/state.cpython-313.pyc | Bin 3056 -> 0 bytes .../engine/__pycache__/step.cpython-313.pyc | Bin 2195 -> 0 bytes .../src/platform_cli/engine/context.py | 5 + .../__pycache__/__init__.cpython-313.pyc | Bin 182 -> 0 bytes .../__pycache__/defaults.cpython-313.pyc | Bin 519 -> 0 bytes .../__pycache__/loader.cpython-313.pyc | Bin 2250 -> 0 bytes .../__pycache__/schema.cpython-313.pyc | Bin 2263 -> 0 bytes .../src/platform_cli/manifest/defaults.py | 5 + .../src/platform_cli/manifest/schema.py | 32 ++ .../__pycache__/__init__.cpython-313.pyc | Bin 179 -> 0 bytes .../shell/__pycache__/run.cpython-313.pyc | Bin 2122 -> 0 bytes .../shell/__pycache__/tools.cpython-313.pyc | Bin 1827 -> 0 bytes repoScaffold/src/platform_cli/shell/tools.py | 6 + .../src/platform_cli/steps/__init__.py | 5 + .../__pycache__/__init__.cpython-313.pyc | Bin 594 -> 0 bytes .../__pycache__/phase_a_tools.cpython-313.pyc | Bin 2852 -> 0 bytes .../phase_b_scaffold.cpython-313.pyc | Bin 6171 -> 0 bytes .../phase_c_database.cpython-313.pyc | Bin 8986 -> 0 bytes .../__pycache__/phase_d_api.cpython-313.pyc | Bin 6223 -> 0 bytes .../phase_e_frontend.cpython-313.pyc | Bin 6581 -> 0 bytes .../phase_f_worker.cpython-313.pyc | Bin 3799 -> 0 bytes .../__pycache__/phase_g_gcp.cpython-313.pyc | Bin 7233 -> 0 bytes .../phase_h_secrets.cpython-313.pyc | Bin 5073 -> 0 bytes .../phase_i_cloudbuild.cpython-313.pyc | Bin 2918 -> 0 bytes .../phase_j_terraform.cpython-313.pyc | Bin 5693 -> 0 bytes .../phase_k_testing.cpython-313.pyc | Bin 10080 -> 0 bytes .../src/platform_cli/steps/_gcp_org.py | 88 +++ .../src/platform_cli/steps/phase_a_tools.py | 207 ++++++- .../platform_cli/steps/phase_b_scaffold.py | 119 +++- .../platform_cli/steps/phase_c_database.py | 33 +- .../src/platform_cli/steps/phase_g_gcp.py | 49 +- .../src/platform_cli/steps/phase_h_secrets.py | 6 + .../platform_cli/steps/phase_i_cloudbuild.py | 22 +- .../src/platform_cli/steps/phase_k_testing.py | 12 +- .../src/platform_cli/steps/phase_l_deploy.py | 273 +++++++++ .../platform_cli/steps/phase_m_pipelines.py | 522 ++++++++++++++++++ .../src/platform_cli/steps/phase_n_agents.py | 156 ++++++ .../src/platform_cli/steps/phase_o_linear.py | 342 ++++++++++++ .../steps/phase_p_library_pipeline.py | 197 +++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 183 -> 0 bytes .../__pycache__/filters.cpython-313.pyc | Bin 1376 -> 0 bytes .../__pycache__/renderer.cpython-313.pyc | Bin 1822 -> 0 bytes .../templates/agents/ops/PROMPT.md.j2 | 16 +- .../templates/agents/reviewer/PROMPT.md.j2 | 6 +- .../claude/settings.library-node.json.j2 | 12 + repoScaffold/templates/cloudbuild/api.yaml.j2 | 18 +- .../templates/cloudbuild/npm-publish.yaml.j2 | 43 ++ repoScaffold/templates/cloudbuild/web.yaml.j2 | 18 +- .../templates/cloudbuild/worker.yaml.j2 | 50 ++ .../terraform/modules/cloudrun/main.tf.j2 | 74 ++- .../project/AGENTS.library-node.md.j2 | 71 +++ repoScaffold/templates/project/AGENTS.md.j2 | 64 ++- .../project/README.library-node.md.j2 | 35 ++ repoScaffold/templates/project/README.md.j2 | 84 ++- .../templates/project/reconcile.py.j2 | 137 +++++ repoScaffold/templates/project/setup-vm.sh.j2 | 92 ++- .../templates/services/worker/Dockerfile.j2 | 5 +- .../templates/services/worker/main.py.j2 | 27 +- .../services/worker/requirements.txt.j2 | 2 - 81 files changed, 2794 insertions(+), 200 deletions(-) create mode 100644 repoScaffold/examples/carouselr.yaml create mode 100644 repoScaffold/examples/fresler-table.yaml delete mode 100644 repoScaffold/src/platform_cli.egg-info/PKG-INFO delete mode 100644 repoScaffold/src/platform_cli.egg-info/SOURCES.txt delete mode 100644 repoScaffold/src/platform_cli.egg-info/dependency_links.txt delete mode 100644 repoScaffold/src/platform_cli.egg-info/entry_points.txt delete mode 100644 repoScaffold/src/platform_cli.egg-info/requires.txt delete mode 100644 repoScaffold/src/platform_cli.egg-info/top_level.txt delete mode 100644 repoScaffold/src/platform_cli/__pycache__/__init__.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/__pycache__/cli.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/commands/__pycache__/__init__.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/commands/__pycache__/install.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/commands/__pycache__/scaffold.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/commands/__pycache__/test_cmd.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/__init__.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/context.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/dag.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/registry.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/runner.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/state.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/engine/__pycache__/step.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/manifest/__pycache__/__init__.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/manifest/__pycache__/defaults.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/manifest/__pycache__/loader.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/manifest/__pycache__/schema.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/shell/__pycache__/__init__.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/shell/__pycache__/run.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/shell/__pycache__/tools.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/__init__.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_a_tools.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_b_scaffold.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_c_database.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_d_api.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_e_frontend.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_f_worker.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_g_gcp.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_h_secrets.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_i_cloudbuild.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_j_terraform.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/steps/__pycache__/phase_k_testing.cpython-313.pyc create mode 100644 repoScaffold/src/platform_cli/steps/_gcp_org.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_l_deploy.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_m_pipelines.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_n_agents.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_o_linear.py create mode 100644 repoScaffold/src/platform_cli/steps/phase_p_library_pipeline.py delete mode 100644 repoScaffold/src/platform_cli/templates/__pycache__/__init__.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/templates/__pycache__/filters.cpython-313.pyc delete mode 100644 repoScaffold/src/platform_cli/templates/__pycache__/renderer.cpython-313.pyc create mode 100644 repoScaffold/templates/claude/settings.library-node.json.j2 create mode 100644 repoScaffold/templates/cloudbuild/npm-publish.yaml.j2 create mode 100644 repoScaffold/templates/cloudbuild/worker.yaml.j2 create mode 100644 repoScaffold/templates/project/AGENTS.library-node.md.j2 create mode 100644 repoScaffold/templates/project/README.library-node.md.j2 create mode 100644 repoScaffold/templates/project/reconcile.py.j2 diff --git a/.gitignore b/.gitignore index 1a3e321..a6a0ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -token.json \ No newline at end of file +token.json +__pycache__/ +*.pyc +*.egg-info/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 6b76b4f..fc299dd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,6 +10,15 @@ "request": "launch", "program": "${file}", "console": "integratedTerminal" + }, + { + "name": "Pytest: Current File", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": ["${file}", "-v", "-s"], + "console": "integratedTerminal", + "cwd": "${fileDirname}" } ] } \ No newline at end of file diff --git a/repoScaffold/examples/carouselr.yaml b/repoScaffold/examples/carouselr.yaml new file mode 100644 index 0000000..0b23fb6 --- /dev/null +++ b/repoScaffold/examples/carouselr.yaml @@ -0,0 +1,36 @@ +project: + name: carouselr + env: dev + cloud: + provider: gcp + region: us-central1 + project_id: carouselr-app + +# Browser-only app — disable everything except the webapp. +# Keep DB block but disable Phase C; no Mongo connection needed. +database: + type: mongodb + atlas_cluster: Cluster0 + db_name: carouselr + +services: + - type: api + enabled: false + name: api + stack: express + port: 3006 + + - type: webapp + enabled: true + name: app + stack: react + port: 3005 + + - type: worker + enabled: false + name: worker + stack: python + port: 8080 + +linear: + enabled: false diff --git a/repoScaffold/examples/fresler-table.yaml b/repoScaffold/examples/fresler-table.yaml new file mode 100644 index 0000000..ede1949 --- /dev/null +++ b/repoScaffold/examples/fresler-table.yaml @@ -0,0 +1,34 @@ +project: + name: fresler-table + kind: library-node # publishable npm package — no services/infra/deploy + env: dev + repo: gas6262/fresler-table # existing GitHub repo (for the Cloud Build trigger) + cloud: + provider: gcp + # Dedicated GCP project: single home for this library's pipeline + agent. + project_id: fresler-table + +# The publishable package lives in src/ (not the repo root) and builds with tsup. +library: + package_dir: src + build_cmd: npm run build + test_cmd: npm test + publish_cmd: npm publish + registry_url: https://www.npmjs.com/package/@fresler/fresler-table + +# No services, database, or infra — this is a library. +services: [] + +agents: + service_accounts: + - 464961297779-compute@developer.gserviceaccount.com + roles: + - name: developer + sleep_seconds: 900 + max_turns: 600 + +linear: + enabled: true + workspace: fresler # shared workspace (rename to a common slug later) + team_key: FT + team_name: Fresler Table diff --git a/repoScaffold/src/platform_cli.egg-info/PKG-INFO b/repoScaffold/src/platform_cli.egg-info/PKG-INFO deleted file mode 100644 index c92498b..0000000 --- a/repoScaffold/src/platform_cli.egg-info/PKG-INFO +++ /dev/null @@ -1,10 +0,0 @@ -Metadata-Version: 2.4 -Name: platform-cli -Version: 0.1.0 -Summary: CLI to scaffold, provision, test, and deploy full-stack apps on GCP -Requires-Python: >=3.11 -Requires-Dist: click>=8.1 -Requires-Dist: pyyaml>=6.0 -Requires-Dist: jinja2>=3.1 -Requires-Dist: pydantic>=2.0 -Requires-Dist: rich>=13.0 diff --git a/repoScaffold/src/platform_cli.egg-info/SOURCES.txt b/repoScaffold/src/platform_cli.egg-info/SOURCES.txt deleted file mode 100644 index e0b4124..0000000 --- a/repoScaffold/src/platform_cli.egg-info/SOURCES.txt +++ /dev/null @@ -1,43 +0,0 @@ -pyproject.toml -src/platform_cli/__init__.py -src/platform_cli/__main__.py -src/platform_cli/cli.py -src/platform_cli.egg-info/PKG-INFO -src/platform_cli.egg-info/SOURCES.txt -src/platform_cli.egg-info/dependency_links.txt -src/platform_cli.egg-info/entry_points.txt -src/platform_cli.egg-info/requires.txt -src/platform_cli.egg-info/top_level.txt -src/platform_cli/commands/__init__.py -src/platform_cli/commands/install.py -src/platform_cli/commands/scaffold.py -src/platform_cli/commands/test_cmd.py -src/platform_cli/engine/__init__.py -src/platform_cli/engine/context.py -src/platform_cli/engine/dag.py -src/platform_cli/engine/registry.py -src/platform_cli/engine/runner.py -src/platform_cli/engine/state.py -src/platform_cli/engine/step.py -src/platform_cli/manifest/__init__.py -src/platform_cli/manifest/defaults.py -src/platform_cli/manifest/loader.py -src/platform_cli/manifest/schema.py -src/platform_cli/shell/__init__.py -src/platform_cli/shell/run.py -src/platform_cli/shell/tools.py -src/platform_cli/steps/__init__.py -src/platform_cli/steps/phase_a_tools.py -src/platform_cli/steps/phase_b_scaffold.py -src/platform_cli/steps/phase_c_database.py -src/platform_cli/steps/phase_d_api.py -src/platform_cli/steps/phase_e_frontend.py -src/platform_cli/steps/phase_f_worker.py -src/platform_cli/steps/phase_g_gcp.py -src/platform_cli/steps/phase_h_secrets.py -src/platform_cli/steps/phase_i_cloudbuild.py -src/platform_cli/steps/phase_j_terraform.py -src/platform_cli/steps/phase_k_testing.py -src/platform_cli/templates/__init__.py -src/platform_cli/templates/filters.py -src/platform_cli/templates/renderer.py \ No newline at end of file diff --git a/repoScaffold/src/platform_cli.egg-info/dependency_links.txt b/repoScaffold/src/platform_cli.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/repoScaffold/src/platform_cli.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/repoScaffold/src/platform_cli.egg-info/entry_points.txt b/repoScaffold/src/platform_cli.egg-info/entry_points.txt deleted file mode 100644 index 78daa0f..0000000 --- a/repoScaffold/src/platform_cli.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[console_scripts] -platform-cli = platform_cli.cli:cli diff --git a/repoScaffold/src/platform_cli.egg-info/requires.txt b/repoScaffold/src/platform_cli.egg-info/requires.txt deleted file mode 100644 index 828bb11..0000000 --- a/repoScaffold/src/platform_cli.egg-info/requires.txt +++ /dev/null @@ -1,5 +0,0 @@ -click>=8.1 -pyyaml>=6.0 -jinja2>=3.1 -pydantic>=2.0 -rich>=13.0 diff --git a/repoScaffold/src/platform_cli.egg-info/top_level.txt b/repoScaffold/src/platform_cli.egg-info/top_level.txt deleted file mode 100644 index bdf32d7..0000000 --- a/repoScaffold/src/platform_cli.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -platform_cli diff --git a/repoScaffold/src/platform_cli/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 9116d7cfc409689ddfbee0c151cbf12152aa43b0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 263 zcmXv}%Sr<=6irmL2>pj_3)5yV;>LvwMbzT@5|T^_rgJa%W(xH`{0M)=PZ*c}fH1f> z1MS(Ib2jHO&gYk^;_L4_y;T00%n|w^##t2~YOPpp^y1|Gp0X<~@^<NSx-#Nj+hPul zA;w*jgk57wl+qa21rs7=+?%$~c`3#DH{*!JU}x@bpH`0xJzM|;^Z=h~oEnD*`zn#e z2DeLL<AfXsE7-G-Ry-#vVmT7755-p4+v6svn=C-;QUO@eewbZvG4wg!@OcfNKFRdw N_f%^=sAoQ_<{v)LPl^Bl diff --git a/repoScaffold/src/platform_cli/__pycache__/cli.cpython-313.pyc b/repoScaffold/src/platform_cli/__pycache__/cli.cpython-313.pyc deleted file mode 100644 index 02eaf096d2607fdf174504115b7412688116b92a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1002 zcmah|&1=*^6n~SC&2GDGFIKFjCG@t?CN0u~qN4Or-IXE@-U=Zk*(Elc32(B6?yY+0 z)l0#D!JGezQn8NoR6KZFSn%wdbmMxc_+aM!-pu>@%}d5}b2C6<{Q91Z7{HY>Dydxq zHU_#m0}l{9=4l<))=;w*);l`tq>YZzHqk84%ucOsp;h8qr`~R0!-fNW|5lZDs@Gt9 zV$l8V>3N0Lz_Yx%*SN2Fv);^Icx_bvmB!{lbAP$=pXvj1n|fA4wQu&Y{_D}0di3;G zbJ*HRlkm{qdGXxtAs^%qY!L?CE>EL%JIO>5r0Kd{#G-J1s>&@UXM-%`MNlL>6MJOW zs{q2*B!r-$f`;a%v*m0Kn|T@(U5@>YFil#Tw2opFlt4Ce<UKzN`f;ezSE6|80Bmd! zID;N*X~Rd}pT;QYC{gj(t*zTZ7THmpr~KIN4$^dk`X*9Ao(r32_U_J0XYT```YOh% zgxqys3F^R&f}<qr1tJeHas?k?7`r0GBrk-Ean7rfuE5Z(IQYcDrRC(uQYVdblya7q z&}cgWRG3KCFfIm|Nn?*^acf>$z8~?>_vMW5cL#J8`#us&kS1jb59R9L6`YXw`@~B) z)%&7)47qZ%)8ygNR6hSmq$c8r{Rq`hAkA{#a(*%jpXX|%B^jD=6#0_|%4aJ%VN=D` zANF}PNaM%2M5daQ;Ixf&#@IJ-FJbKpnm^#)C2U=SJF0<Zoi4vwe!FtPYG2vnXSVph y_i^<TdotD-d&b5(Xiv+1VPvR-Q5~3Zx^S}au6_<n7oeBeI)^(IHom}O+0btpEBB26 diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index e5ebde445cd78e90cadde5ccc66f7319b6844916..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182 zcmXwzK?=e!5JeNKAVLq~#<_ra1UIGY5R$Y6k|xZgf(P*s9>xpQr6-W!-URe7{=Cm$ z%+GDRV?~e8)7JZ{_80#_UKhBrk?r~U&Tv&yuGP{*PX-CJyjVR-iV`(Ym{A!Zg*GMv zG%<8a4nuIILj}>vGz9cQISVRyFDFePgq>^>;t;RP_UMD2ojKvAe5os_!n@&|V^io0 D%RMpM diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/install.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/install.cpython-313.pyc deleted file mode 100644 index 6cc457ea90d455b6a1474ab1c22baf612e248d74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3110 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 HhKv1goDRll diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/scaffold.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/scaffold.cpython-313.pyc deleted file mode 100644 index f4a1015d022a1843c05855c279738287d05dd3ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3718 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 T?{+XBqRjZCNsuJxAdvqD@4J4e diff --git a/repoScaffold/src/platform_cli/commands/__pycache__/test_cmd.cpython-313.pyc b/repoScaffold/src/platform_cli/commands/__pycache__/test_cmd.cpython-313.pyc deleted file mode 100644 index 23b0aacb1a0b4f17718b06a2ce6281c05b36207e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6694 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 Zae%@rYNJQGJ_r$A^P^oV6CMYD{Vxma*<k<x diff --git a/repoScaffold/src/platform_cli/commands/scaffold.py b/repoScaffold/src/platform_cli/commands/scaffold.py index 74f4462..015a94c 100644 --- a/repoScaffold/src/platform_cli/commands/scaffold.py +++ b/repoScaffold/src/platform_cli/commands/scaffold.py @@ -61,11 +61,24 @@ def scaffold( project_dir = parent / name project_dir.mkdir(parents=True, exist_ok=True) + skip = [p.upper() for p in skip_phase] + + # Library kinds have no services, database, infrastructure, or deploy. + # Auto-skip the web-app build/provision phases (C–M) while keeping repo + # scaffold (B), agent access (N) and Linear (O). The library reuses an + # existing GCP project (cloud.project_id) for Secret Manager, so nothing + # under G is created here. + if m.project.is_library: + LIBRARY_SKIP = ["C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M"] + for p in LIBRARY_SKIP: + if p not in skip: + skip.append(p) + ctx = ScaffoldContext( manifest=m, project_dir=project_dir, dry_run=dry_run, - skip_phases=[p.upper() for p in skip_phase], + skip_phases=skip, ) state_file = project_dir / ".scaffold-state.json" diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 7302d3ea5976bcf2f53d6390166530e1f59ba3e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 180 zcmey&%ge<81gF2=&IHkqK?DpiLK&Y~fQ+dO=?t2Tek&P@n1H;`AgNoy`k}?CMaB9l ziDj87>50V!iA5>;#rdU0$*KCq$wiq3CB^zhsRjAL$%$!c`8hzjqGbJooWzo}{G#0W z<eW_X)V%b}yj1=8_{_Y_lK6PNg34PQHo5sJr8%i~MXW$OKyD}oF+MUgGBOr116cr5 CiZJ^C diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/context.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/context.cpython-313.pyc deleted file mode 100644 index d7716d2262d647bc44825ef2ea10262f63eed805..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2886 zcmc&$-D})N5MSwZANJWNf5soNTgA8}=Q?*y8YgaOLeoGRY+`~{mp~naEbX0@$fxV9 z&h#8Y{FWFBP79`aY@bTr`d9SXu3PME38a)#^5)=_K%Y7*>5lV93Z)O-htcfJtaj!% zv$MO~+S*KTZ2$R-^Or)%Z(>p#@sN>r3&^YyodD4#U7nOCWRNw%$0lPF3MdnChzE0J zGBJ^aWWdKKQxi?l6!3}3=7}_<H8QL8k|Cle`-z?!lf(ayMf*WZu_@cUbWXR-Lcw$G z%U;Q+pZn)EHg5v8HRhW>)#l8iRyG+!f$wQ_kwO(Uw48e`ev_4W+ANhkG<CcZ%gVgT zHht4_aRrYT9O~L=qFguqdHlvMm8$qruY-4sTK+Y&<P<3Lt;nI^gP@2Z?f!stjm#2| z5C~b9Aodo~Wl;3kY+P54qgIb2mk78dB=i){n#622IYeetdh>e{H0f!Sw$!D~fi$hR zqBIjoTl5}X?I*XCIxMZTLe{oNmI-9-dt|EKt9J+qwCR0%XTY`V{rXY8Tky9e=+KWq zXR#|gz}0;qKTo5dOCMaT_~x8T8=#J`H!biO(@fV5z-F3V0jE^dOpQg^(x6hxK?;Av zQwtH0kPy>!IfU1+9k{}kInQ%>(uS&mD+KyBEtnP8HwvcZdr)0!avkP>%6z!V)9kiW zHp=rRqiiW-J60ec)i7^2c>;?AOlC>ANo@!|CbIP*GHYapOxH9G-|49E8bo9|%5$S^ zm^_xIvN32uAx}n8aHV7}(8pLrjJgFTHq`iZeuhzCdD~oc?4rraCfIrARluToW`R@o zSsrNFtD&A}VCBoM=@&d$Ff7-}(^AnX(YzJbe6C#OEj8H0ete*|G&&WzMr}7b!FrJ_ zlV@t@>fM#QTk7zJI=rQ3H`MHUD!Yq1ayU?*pys~<H7)YIZwwRYLQz%;-~-_zk6W%+ zv3YaNcgKbvl!qWfAF}YKu<L(au#PQ^*c(U=ggUaJj%=x88|v74Y77R@<bMwsgrhtG zw1~~<K&V4Pd1Qa6q46N<{wd5;8|ta`)TuDc#&X8u4-dc>$o!kUc9N?jgp}kN`LLrf zBb~oNh>bg4!%U*hAsIh`JBkrl;bcZ;vPOnUnrNgbW#y%d)6}m(N%QBaR&ap%8ZC)- zgRPyyqGM5Q!St<p(N{*;qGkBiG94M!JkX}R63tCTwZv1AIpc~j;mPPXcvQt9Kz+2B zON<FXG))VtizlO%yk|e`k>VIyvolDryxP0J9{h4}tNr*-?Z+Qn+E&|E7grV^_5Q3L z`^Nmv`Of*)+3xB6D)k~xI!^2)NLyE!4Y6Tu)U%<XWR2X=4r@m<l4$(J=)7Q;##t?t z*lk+n@kP_E(7lnw81;GQ!6<x@1(rdwOtz0|tLc^W!^=OWdIR64tVZV)G{U*GJC4k2 zOQmrI+KF+aqjq)I4!ImS&0|ICN1nq;G{iO^_Vzu#!)c*9;5)8;8x>4+3iKWO*qxO- ztM^v!Jz|^c=z3~22;nioI~`AyC?p=kOCl?S2pP^H$tHqr10HfhniS+L60V3E3R_a6 z)w_GF0{d(iPZ@^jx`x4<4P(KxE3V*M4CAwk=|&pWFoGg>v2T|=R3{C?_AF#j)9?l{ z3{b@;;x)w;akoKxz>4cE#4Uvlu}s0QLcg#SJrG2906dWd@`41IbsL6w1UZ&r`aU>w z6`wNv{g^Ni4KHk=;z0+3h&!F#M{=M1s!Tk~3~nle+wI+(O80hG-||NfKHgONo(v9s ze`aHFe0lO=-=;ENpI&(|h0|y8)?HbOkL<=sBC{hnEc8T1$j;VfXX~=lb=m2ciFmy0 zWk!l0dzFylCtnaGp^w6|scb7;z^{O}P{CXxydWx#r|Zu(%0N_Th+DFZFE-bi3ttoo zJoeZnf^6}D#(OkZdwS%UHBT4J;6W0Au@5DldOytTMYxK)h-;j^gJdTrNzxN?;VBvV igPeFudY_UbzmstcQBJ>-HR<FIL4tA0=@()e#Pl~uwuqtt diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/dag.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/dag.cpython-313.pyc deleted file mode 100644 index 0f2b87a59c53f90e8cdc62b930abf65f46d587df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2686 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 diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/registry.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/registry.cpython-313.pyc deleted file mode 100644 index 270ae3207fc7a2d86448b5c0341eadb2442cfa2f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1338 zcmZux&u<e)6n<-OoH%PIkrGM?2u3C?u@K%K;MA&=mXwkRgfdN})<vsfckC?M?5^Id zTO}tSTUA0Fiq!T$;J*n<1!xNr5^#W1Rg(jpcr*Tyr0PiT`J0)YH}ie(J@=~BNucq1 z@9*$o0pPiw3`emKtlswo?!iaE0fAe*uwpG2u)s7ft`wJTv<<db#+FN1V&DRf^RXW$ zc<BtC<PIO7hub!md4*3HG|8*HY>?BQtWRw>zChUyRVL9Rm$E?m35qO1c9z}hgi+uH zqP<vW8!}2IHeTt6)veqSahzl#3zJyUbS6vEBud($FCtGRn9<tAmqJNSG2hKabU`!Q zN@a7ncJpRl_Ewj!T;<=ce&<^{>R@Q<WLEDJ0{5UfT!+<xD5C`eYj9z3mJwc{mu-Lg zRV0);z$4ob85@#ROfbW<MGP-ry25-SkYUHyOd9n9j#JdUB+^vI0g)*Bky82!mUDMa zNmOniehh=QP^rMcRY?bZ=_((?G*d3hG~vEzwUUVT(09{FWOUW0=SQI{<8~NJ`bZ0o zTa9!puMQt()W-_lq^m+5A=iZ$cJ+}x_rRXJ%m1>EJ$9zL%RSlvU%TNLBVYtuCvZ@( zSy%^EaLH!jn=(KGHMlT1b)#_20dRmpY{k$VNxNnZ1zn(Xj4Xzhmi6naq?3rVP-K#c zC>kj$10yI28wa$QbuebxhTLQ!{q9!<OE94N87Pc4y@Rmknvdy(C`CO0j3GS>^75$k zaE9jE)FQbqygYekx3c(Wcvt<*@2ve1c9(W4i%*>6zc1}NwH>?m_TM5@>q^J}W#(i1 zkW!i@nbl(iNU2fTHTB(Q?6W7%?&^pRL-7;SWz3S+f2lKHSDaKtF1PhJriktXRw-nk z&&)8*GBAl&I7fRQkSU#V%Cs~6Tlv?EJNCReb-ivIZQe~C?wY*RR~U7DkbXMC_LOO_ zQD+U7v7|q#<l~+fB);e6lb+Y=P<o{2p;jV4dayVf{o-s;x0~eTAO7F2&J^l`>-WjD z=N<STptzch$(Z4XvwFI(!o+8rNzjSp=XioPwEF5Sxqph5W#w@GDb!wSf9_drp<CW@ YPVK_Nn^Mu5ep4x0r}rua>$nN_9tA~EhyVZp diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/runner.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/runner.cpython-313.pyc deleted file mode 100644 index 319143733c694e4c07aa62326f8569f43cd6263f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3925 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 diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/state.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/state.cpython-313.pyc deleted file mode 100644 index bc961077901d4b382ca554db164ba4d9d3e08d04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3056 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 NAz@7T3$dP`@Gr^rY4QL7 diff --git a/repoScaffold/src/platform_cli/engine/__pycache__/step.cpython-313.pyc b/repoScaffold/src/platform_cli/engine/__pycache__/step.cpython-313.pyc deleted file mode 100644 index eaf1e859b80b34ddd6c9b6c5b246e3855fba7ff6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2195 zcma)7&2QX96d$kc{Z6*YCTVC(H53ZnlI|Yb3aPZ|7YHelQmCmokZR(!XE%mj+sxP{ z*#ipVK)66TM8Jt_dxI1F8L4_8OjW58La4W*ZH2^%_w3ztX?y4#p5M&8c^|*`;r`fI zk-)n1=#Cc{g#3Y<;WAT3>t6<Di&#VuOS3YI+CoNTDDwKEzF-Jr!4zh)Hx{!CIgv|v zb1}bA5CuwR$suB8_Yo_%Y@F0noub&z*9yH;7i`I`h==t2g^Q<Xw92Uy_>r)ki1KS( ztW)XOtya)+sYEHS8+Le?`;n~QM@2PFm2BS+B0KT|U)C~82SsY!ZpuVSmq)9C3sU-d ze;qy*YoP8TSmA4tlX{cVfood-YuIm*Wg;{PIb&%;KTNEQFf3h|ma&|*%-28ycDZDi z#a)AlJOr4v^2r?pZWUoy!rgMws#@iJ<RjxG>~M*aHD!(Ai6{d%o^WH<v{gyCacjof zlW-MlLhNZz*7mCW5UTr_uwHn65F%{?gtQfiX8~3(7OlAHbZjZPq}^CXRPrdr84r(j z0Oby~qeytoI06~lcORonS5`#qAE~XZ)QgHJ9Y9q%Oa${>WrnL@`QJjvlhKE8b5#{x z9&+E6Ebv7JoK(8!N2=7d*O=g7flCLEhf4znFxUD}cYt?Wt7Urix7WA>jwCLkr8x>u zW8r&#o7&X%pfG|K4dN(_p$L|4lA9T2NZx5F-HFyD>Zep)YFv?A$cAfQ^W3&ALtD5F z8N|Zj4e1CkjATRbFc_9YLkg!6cI*gn>@uh0HMrmQd=Lf~>fyT50S8qcrl1bHX|R-- z`<(QFB@Tu&T)UxV;baSn;}s~?-kG-m0W|Ji;}l`TP(EF?F(IBzE8OSYmGlVa(+!*$ zYdxv$T3t+muA7>efsaH3RLlZZS<go-RAtw@^xy@!?B{@Nkb9Mh&4Snu{Bw9Y_Z(h^ zB5-4e%hW0HB4{3UsXWLJlOcE1^sZy0$M{B2B#B?6g@Jrh7$<<)B3B5`pC{K9?egfG z!S$Q)O|ib5lns4f#GHDq5rpMx5W_raaw<Re!jQYP4f*iXrbx#^y_ON<ph=Zbge8S8 z#csyw0Qpj?ilLC2+KqDL^d(Rt@g3%%`c3bv-uKpz><4zMGPj+ddpaXHag)g31qLRr z_Uzc775O-fD3HcpdSbzie%u69j3uR=?iti`&nRuarmF&jT8A;fn94Ke+g;9>7=!0e zWfZQVUjztF?8Z4fIt&lwt3Wo$&cx*P6W`5$JO5kt=&kC}?a9|~ocPK3rTlaG#%I62 zw_Q2Aoj;pIofPv@%@BLh8dj;wrj6bk$0o;tGDl%(UxidOP^JQ8R6aRXMl%RHLy<Cy zOo%zuR%EOTHQIrQRGG0)V!M;Zrz(uKJRzeF^tT_tb&fG;wL#=~J@Q9^Q!E6r2gxK7 zOp(Ce5(kkSLW04IV?fk2V-g$IVUK4j=)l%Z<HnX1g(1OFE<XVBCHXs}oB0PtGBvaD z&Zc$SnBJMFZoIvD{EJIF2M%s5Trb=<4({xo-B`R{y=~0iGpqfKW}fNmBs=v0xrf<| zIr%U@Ajkg6YvwsEwTb4IY9)c~gut|7n5dX)C^Z<;cGD49Hc4nI8?A>hYz0m(wSROb z*3+3-$3HauJCm%Xq5MBQl|P+AKQFKyaa2i!>4C0k+VAAtU2^Cz^736W^Cx-ZVfKJ_ NPTQ1^2!0cH{{vaPEFJ&= diff --git a/repoScaffold/src/platform_cli/engine/context.py b/repoScaffold/src/platform_cli/engine/context.py index 8204799..d952582 100644 --- a/repoScaffold/src/platform_cli/engine/context.py +++ b/repoScaffold/src/platform_cli/engine/context.py @@ -30,6 +30,11 @@ def project_id(self) -> str: def region(self) -> str: return self.manifest.project.cloud.region + @property + def org_id(self) -> str: + """Org id pinned in the manifest (empty = auto-detect).""" + return getattr(self.manifest.project.cloud, "org_id", "") or "" + def service(self, svc_type: str): """Return the first enabled service matching *svc_type*, or None.""" for s in self.manifest.services: diff --git a/repoScaffold/src/platform_cli/manifest/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/manifest/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 23028935b49cf2b2c4543c7295062fdf69df4009..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182 zcmXwz%?ScA5QP(0L4+;Di_?Huf*09y2>DqCvL?)A1q-nVd$EG`Xa%x(HvxTzH{atO z=FM%p5k-&B6YG6d`%C^{UKY5CUTo#@E@)BGT&t;rjvX;reL>r6PMR!m9LYFfLzprH zxfD-EdmlsT$Uq7@`$&T_0b$T*^o}zqg=fr4?b3Bx?}uo|;10AZU+N03@NT4()D-#x D%WW~= diff --git a/repoScaffold/src/platform_cli/manifest/__pycache__/defaults.cpython-313.pyc b/repoScaffold/src/platform_cli/manifest/__pycache__/defaults.cpython-313.pyc deleted file mode 100644 index 5e5122264ed3b9521c910adff48571e3854858b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 519 zcmX|;&1w`u5XWn#XJ_)YV~FV0Q4!h8&P6;0AtZ{ZsMx#UVPR;ed$NtQ(@l48VmwHW zdK(WyUcraZ=Lqf57g$-nd9WtB7DZJx{~uLU*RtQ=0JPt~zR5TM_})EM7X2%hkFfXz zw}65H8qkmu8V!jFCgFrkqB~&^^+}KRZeyk~P3YQ%9$)C>zrNn-hZ}><w|5Uj#pbok zj#xbxHe*`XQt<3}Dw@nqMK)uNtb}!$nn`D~N{X7>q5QfDAis2>y#_EKo-i$ad@NpJ z2h7U67RL7v?myTYKN%IzpS>7SpNKcw2y1=M2v#~D>k~IsjZaq?9qsKurcVcnPl|%8 zvM5Zqk&n1UoLFZ(k&W}0ih?zba?DB9SftjG`V32q;K%vc3S)E5jwC;1Rx`tMtLCN@ zxh;*<&gMpFMN3vysz%L}xvrV3l$jM}E%VjRIsf}PcBoJM+U`v8c`fdl9h`y}0^40< zL4qLgu)Bz1Yx_LiLI06N*Td8Pd)MxtU2Y$Kc(;To2p1`AT{%zDuOe_GJWboErDr$W Nmmkxg9bmSx-5>n2nCbuk diff --git a/repoScaffold/src/platform_cli/manifest/__pycache__/loader.cpython-313.pyc b/repoScaffold/src/platform_cli/manifest/__pycache__/loader.cpython-313.pyc deleted file mode 100644 index 751342f4b162e1910fa8ec05a04c94eedd85cab5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2250 zcmb7F-EY%Y6u)+y@3v`Lw5C-XOhsu^K_W4bTC}Y~j45j=RB<CQ^})Keonq$L*|k%e zN=$uhHnfxn#CYNrp6JuGe*=P)?5;?a*fi~Bn$~U4JJ*Rr3E8AwInKG~oO{l>=i}$2 z{{9Gp#r^%G{-uD>Yqs$g-y*o9n?O835+X?ABz}oo<Owe_+PCCe6o}x`!jhl(f#+We zECxw%F+@TxKd=;DjF1S>VJSF<ZVMzTMWm2R^=$~L==Q=j3#;O1mzS@JMQrK?&34X; zo7m7*>}cX0-MJ;MR8(v_x^h;;rYho6$*72`R={P$vD3e@I8z*rV$-x7?C6$h!&X?q z&aLYyp7wuy^^?o_Ype3gjohk42fVGcdilzd1e&oGV%^phXSwZN;aWF$-;fpLgA5(* z0r3E>bws&#emV>wCQ%;c{Kq*h`eK`Pv2EGp)&ibZF62>f{=p8{(dE~oo+QtEToL8@ z({StUg`5W2!`=CNJ4a_lqB&(^f}WRHRV-SnW~5UADpYXMpkW&qG}(Y!&@cg;vZHM| zBnYnQfU7EdM`=uU&C6a1Qa&n_AluYO@EscRs%S&rkSK~&%H(a0*claX>goozOPHt` z+bR=9%h(FhOOBl(TFH_WTqsxu$cU0D8Q3XUq$n$fp7Bg(SbQ2umnyWMk+LTsL*T0q ze)a`;?Vz6`1GV&5>2FUwPCiQ3M^p94?9P>gSh5j2_ab)giBgZ9-^m^b!+Y76!bCGV zymR9S&Y`zT&kE_5{x>M~16Eq5(zSZE!GE<tj%BoKv9Y??ZYPub7e77PB<BwA+s0<v zTXhVfn}a<g?qSL3G%a{%Y3y3Lfi4DYJDyG<XxE<i;w7JVuikczPBuV3_^yv(l>H7N zlh5<L^4y6a4iWG<)&gBC&l{a#28|P4V%T=s=rPn_qmyT_@!q>OB;gWZBtC=CTEdg& zAphM9#=2*elE0HB-Z(bwx$$lbZ{o!e=`vu_TGG?`UmgR;Ja%fk{5?WZB%%$J3SOt2 zNV#*AlL^a-zIPIU0Hp?Lu+&0JDww#aQNLl`(FhH~{2Ew+y<%8pl}6fHvaZq)06AXA zwnl^My6m0`0Um7Xie^*6sgyJTpYpnD6|rstl32tctRJawLvyI#cChj}VR#8R!xd?S zSpcA7ZhiKq;=v4oE<mK;d$C&Ehw$1#&G^v6(fgwhllPO2_|)_GR6Rc3h-aS1GyA@M zsUDx-Sv-vP)#j`7wT0?JBRU07b1G8{SHrbfHTE(z$;6kcmm1Ltc$yQ_jfu?jiPq_f z`CYCSsYYIg#tvhHwe5S`yS`>@sJ2zxYL1LO+`7NDd*xo1aqiacHpfps&OXWlXSo?2 zsLfU9_P+S~!-J92jggrbBQsBipCz6q_Ak^&=Db6dTBTY!NStXTW?v*`pS=HU?&;iq zuATseZ$AAajt0lbFkCVv5LQOQK36o*LVFjyK`_k(Y|zLolM2@@Q%m*Jpe(DFBFi)) z%Y`z09yD1d46>ob0Op3i?ph;Z#!4QC<FtpmY3~c**2}FC9q+AaE4Q>FZiT@H)h*Oh z<Oc6nj)p#f&rI3SJ|gEq&c@aTWI@0CIF9=PU44Zne@2PdDEbS^zCv?<2K?NKzXs#n Hyldhgi#)~7 diff --git a/repoScaffold/src/platform_cli/manifest/__pycache__/schema.cpython-313.pyc b/repoScaffold/src/platform_cli/manifest/__pycache__/schema.cpython-313.pyc deleted file mode 100644 index 095b14ff1be9d09364ddc3a661f85adff820dae8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2263 zcmb_e&u<$=6yEiYz5W^Jcj6)%lcWM8wWf+HQ6*|45=xVjP-)8nVOXtq*Y3i4*SuLr zYPneI0Uslz{txuVkw1gTQsfNc!~yk&l=Q-h_h#2sB2sZ-B|pFSy`AyQ_r7`K(^4s~ z;PdyZ-#pM1<qrzRPdawg(;v}!t{4g^hH9i9sJkghRms;LXuAy9ZW_{6Wl>pH40c5^ z(hYVC=b{HP63rm0pF?$tW)aPuL$eaiBU(6z<|JA~v~&*5w<l`lr?rPq94m-CyV?mI z*XPw%2-WDZTkS&ljcZ5Mjum(<mq#~V(rRj|C|E%dMpop70k5S*?jwu4do)(0?|H88 z*!14`Nj9OLZX)trX(*r~2`NJb?V4hwfEijNZLoJSYos9~ql}Dn&}EdBQ4X^0e61h~ zTYlJcw!)z0wO{HuN@#7nD>NQ~$l{U@JjaEng&x0dyFmn&e^X?@ZR1*nBJ#<eOwW0t z?ALN4Ynp-8aZOX?O%wOu^T{uo=65~IPcmiGY<a*V-wWIzG)*YbuOfPm21JX9!UUz} zOp_BmwrNEXc+Fnqa(tnTxl{Zo!l-}FUEp=c!qv7d-nGD~^RNfDTjw@--H6x0?S_VJ zwOXN%Ik4+p--@s_9n<!``nV?e`eV0a-RM36;zAm7cooeL%4oLIzdx{ttTLKj>fak| z4cXFYezCuMs2#IKm>&<hP%(xmKI-W<+J9H9ffbV#VURXb7^Nv{WR{hNZe(t&kR_QK z<ErIk)k+UzF(q{?;Rv8ZBN&k^G{-#<nj0LvP(|5sTUO7H%$8+GA;cRKX`9L}Ioz-1 zVUi}8A~Q{9hRiIPx5?0<i}|RMH%Erpk2kzPUS(`(DPy+srDr>%@@#)=Ff(McCzY!| zeScJ0?|(d4KW6LkU>bLUt^Zr!6Wdl~HSt6xRMFOi-U)+t=rj|~q9<KfWVietk6idb zl&r|NIO;;iKsyq;)0E0i7|UJC*fo4Jrj1Gn?~%ETtYu1&p~Z~JnymD<2P-74%1VFl zaBav|Ml*B$PX@*@n~TY!A(xXCVdg)ig&oisH-d~5S}ZAQ3naUo6rqlCqbM(<0_2P$ zMkN_dKwjp`GMa=!d#W}qN`?yuo;~JQ3W1~u#Kt4RnqlY*-3_d!?>dr9cndQk$9qjD z>{wnP(s;b>Zy`mn<^_>p-4M_QVSrk0cY6uz#Y<V2I=xQ6Nb|xCGIcUH$*hsNtO)N@ zf()&gZ=%8TEfqVvh^#*vvc=KlG}YwU{%CHYzcb)NwlJESLH({h`)o8hC3AbD@*D~L zb&+Vw3q^W;{y)Xcn~T&)<B8803{SjH*Hv6BiDo65yU+*nvJaG#KCqYEDKA&>H{@Xy zdeWQWDnTS2k#&-@@N~xac=Y9k%6%oW*hHz4JfXYrX1ZlZpgG|~nwZgaaGT=GsJ=xB zGPF8Q>OrbYSBC7$$<pf2Umq=PV^<A^Z2M%j_RH$g>fQeRUnkx<W_M%R)5v|is&vzI zLOZ@&TD_<Tc(>3W1l(9RxpRTYKG(0L8s$1=x#dDGb}o_Ki0`R8a0kb#_~BHjzcf`< zPn7!a%JQGewX=+-Rt{@t3VxjxHFe`~hx`rl=MTRifBvkfqmMX#oy}_Mrh4eT!o;R3 GxA6~!X#n#8 diff --git a/repoScaffold/src/platform_cli/manifest/defaults.py b/repoScaffold/src/platform_cli/manifest/defaults.py index 71ca818..a4ddfae 100644 --- a/repoScaffold/src/platform_cli/manifest/defaults.py +++ b/repoScaffold/src/platform_cli/manifest/defaults.py @@ -11,3 +11,8 @@ "webapp": "react", "worker": "python", } + +# Default agent service account — automatically added to every project's +# agent access list. This is the shared agent VM identity. +# Change this if the agent machine is recreated. +DEFAULT_AGENT_SERVICE_ACCOUNT = "464961297779-compute@developer.gserviceaccount.com" diff --git a/repoScaffold/src/platform_cli/manifest/schema.py b/repoScaffold/src/platform_cli/manifest/schema.py index 8889da1..17c9926 100644 --- a/repoScaffold/src/platform_cli/manifest/schema.py +++ b/repoScaffold/src/platform_cli/manifest/schema.py @@ -8,13 +8,30 @@ class CloudConfig(BaseModel): provider: str = "gcp" region: str = "us-central1" project_id: str = "" + # Organization to place the GCP project under. Empty = auto-detect the + # caller's org (see platform_cli.steps._gcp_org). Pin it here to force a + # specific org, or override at runtime with PLATFORM_GCP_ORG_ID. + org_id: str = "" class ProjectConfig(BaseModel): + # Project kind drives which phases run and which templates render. + # app — full-stack web app (services + infra + deploy). Default. + # library-node — publishable Node/npm package. No services, DB, infra, + # or deploy; the agent builds/tests/publishes locally. + # Namespaced so future variants (library-python, library-react, …) slot in. + kind: str = "app" name: str env: str = "dev" + # GitHub repo as "owner/name" — used to wire the Cloud Build trigger to an + # existing repo (libraries publish from a repo that already exists). + repo: str = "" cloud: CloudConfig = Field(default_factory=CloudConfig) + @property + def is_library(self) -> bool: + return self.kind.startswith("library") + class DatabaseConfig(BaseModel): type: str = "mongodb" @@ -50,6 +67,20 @@ class AgentConfig(BaseModel): roles: list[AgentRoleConfig] = Field(default_factory=list) +class LibraryConfig(BaseModel): + """Settings for `kind: library-*` projects (publishable packages). + + Keeps the library agent templates generic: the package may live at the + repo root or a subdirectory, and build/test/publish commands vary by + toolchain (tsup, rollup, vite, etc.). + """ + package_dir: str = "." # dir holding package.json, relative to repo root + build_cmd: str = "npm run build" + test_cmd: str = "npm test" + publish_cmd: str = "npm publish" + registry_url: str = "https://www.npmjs.com/package" # for README/board links + + class LinearConfig(BaseModel): enabled: bool = False workspace: str = "" # e.g. "ai4us" @@ -61,5 +92,6 @@ class ProjectManifest(BaseModel): project: ProjectConfig database: DatabaseConfig = Field(default_factory=DatabaseConfig) services: list[ServiceConfig] = Field(default_factory=list) + library: LibraryConfig = Field(default_factory=LibraryConfig) agents: AgentConfig = Field(default_factory=AgentConfig) linear: LinearConfig = Field(default_factory=LinearConfig) diff --git a/repoScaffold/src/platform_cli/shell/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/shell/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 968e428c2e7d93a58b687343da5cf7cf0ea807e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 179 zcmey&%ge<81ZThA&IHkqK?DpiLK&Y~fQ+dO=?t2Tek&P@n1H;`AgNnH`k}?CMaB9l ziDj87>50V!iA5>;#rdU0$*KCq$wiq3CB^zhsRjAL$%$!c`8hzjqGbJooWzo}{G#0W z<eW_X;*8Xs9R2wC%)HE!_;|g7%3B;Zx%nxjIjMF<tUxP3PACR3J~A^hG8QodSpXeu BFrEMa diff --git a/repoScaffold/src/platform_cli/shell/__pycache__/run.cpython-313.pyc b/repoScaffold/src/platform_cli/shell/__pycache__/run.cpython-313.pyc deleted file mode 100644 index 16ddd8ad46af30bbba64e932b3072294b09d4c4f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2122 zcmZuy&u`R56dwQfdUw4^HX#%$>VzP&D#=QqR0?W)pek)i8>Ah4N{VG{2TXSD?Ti<i zsE9}$3S6LSBYLD#rM5SY>8bw$oN%Y5ibOBm7HvhZePiz?QOZPl^Ub_B<2UcU?`^MK zHV}-tSAT>*$_PE@i@{KGAoLOdn+PL<uz<w{;hacBi(_d)A`;B<f_zRPiiMVygJ=?A z<q*Q^oS2`V>)%IIEMX1nxPXmW`3QR5A1>kIA#_!~DiZCoR4cEapq)!?5_<uq)^*~x z+W|Rk;VVJ2*$8N-nOJV*Tci^?Udx|-!M{`!SkaB5IB}COj_4w+3%;AUUel%2>-&;T zQoay+4FH>H84&^AC}M#~#}F2mWh@;B4VD2Z8RC2D4$?SUR&WvP+~+;-EP(e$t;C9Z zJbj^qHIryUnC8W;7C6qNFiMy~f}}$tFZKhb(!`HDNj3+BcmkXm3?5E!S?E~+Hj$0E z+xrI=PJfKh6sn1fgdeC0q?k=ira^Os@(V5YS>2`qp>^NA7W$W6+IERwr*VgPL7jRe zY$vo%f_98OcV#7Rf{u9gcGFE(V$yQFW>}{@1$7AgY<rD~@hUU-Jasl+t-yEaFo+Gb zU8&x?dFSTCi6@n#kM*NH*hIge#hS>}{N+r(6vxe)&UD9#+*aT?%y67m?01?RmmKGd zj@#^ODvq-f5}Gu_D2QTE!^WO;!-fv^0%ek{5&nHP<~Wqsg6FtNLc&X(B%qE%MnOla zTnurc4g}8<od)qW+Aa^>`ZOJ?-a4O_Dz_Fgv8|MQqM%ImB&3=d$}i#P&t3>mKzwU5 zlf3IbQ@ksIw+hL2+mPRYX-U`$Ah|n91-_vI{AWqM3nw?x(oVu{wAAMhu(OZ2TYR{{ zOe{o-jUx4Y6^oIArAD8vxkWB*G}pf`QkS@g*=X=5?i-U(PPSqTHn7>2u{^Iv+7d6E zY}l%;HA;ik?rcjt9)T8Y%w638ofok3KRX`seS_O!bzY8iTim&I9&XheBl}EYIk4~n zER7BHSOe?V_-P`n+bYNc=wI_~6P;2AkqQJ`2l^Q6GmTDUxmH%_R-dqWx5?-Vs=XC| z;VcuZn!=RKo*V&}nAy*~14(ZKspTa2G9eRO1w}%5>N9c@?lLKZSg(GNcLr<44V!^) zO%w0Eb*XmRVuDdCG9#p61l1gQ0nzzA^3^zun6cX=nFO57tC^>7_v)MsC3qAs!w(6G zNv!~d+VV4J$y?mo;(|L!ra(aHwtOHD;Y8)lmWh;5?zS};8F`on-{cLLoCF;w7Tp4| zf&Ljm`pEsG-_fVq;V0VRbZGRpl8%hs(YA-iGW1~jo9XXQeLHho`@1}T@8aEysa{OY zDljb_Ka!S*)1mQ~WzAGK&cap37%bE38&9iKPpVUYS;uc*yLaR6jh`EP;LCzys2gWq znMg0+Z#=Lc*ngUbQgh<r<WDm{%shPSk@V=JNAp`}w#aX5zpibqJ}#Z>Nn*w5BC%j} zbyPApKFJ7OE0I}v!+HgtZ+&GoLz*lB{5%WRx{>cL#!-+5?f}^4(aTU4LjJ=_cx`6s zfcB<2jvsq@cU$T3o&bFa$dAa|AXstdS1O={_l7);Jf{1)71Q6%yW{)h9nf-))9WC* zk{}4bqm$3j<a0Fq3{7-ZBo_Y_bz!QDKtR(Hi`{JbCK^ASj*O?H2h-|-m*r!EnHCOq oC79uiG1}EQf_Myd3mh599O)K0Dj{>Y%k8@+Qioqv0A&ID4@J@6qyPW_ diff --git a/repoScaffold/src/platform_cli/shell/__pycache__/tools.cpython-313.pyc b/repoScaffold/src/platform_cli/shell/__pycache__/tools.cpython-313.pyc deleted file mode 100644 index f8ebe6c0e5eae00ad9f8d9ec0cee81db91414942..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1827 zcmb7E%~RV(6ko|77!24B#F&o+g^vb>*y?nenNEf&Lj%)LD5R`8(Nv>IvMrFco?SVf z=)^gfoHEntDVHAd-_XfLv!$o>(i<7ld*7~&8z-l%Mo+(eZ}+|5d;3<GV`C!-MlgEr zO{WohD<)S%S`bn$e*?`+w2UyK2rF3KQGQUVstKIfNzjD+CTS8UaSEsL5YAMRco>hs zla+WBkKu8glQ@qH@LZF40#D#cd|l!qp28(OEwGWMDLnIY8qeN_$ao&l&7k8X9l{#E zAyEd<O^Jr<3H?@dt4fHkId16Mp+^GE3LMP~SZMja*6@OmRsIo4=?c$UK|n%FtYUjn z{(C~|zN<a(N!!uz<_=HSZGoeFU0TX=R%XsgRDD9-PTy;}Y+LL^`*@Y6tq`g;!m!2e z8;0Cm>8`2Rq-lU3!yz^^dgOFH$&-QAba}#VIy@;t@X?;p6wLW>)uq&`5!&R#;T4Sb z2FUdFgW(<bsq2%LODheFH9VWpmSoXi-lxISo5VhGDNh4C_(o&B{Cd;Pfp7!1k3vFn zmF^}Ps_us6?$pqup*`Zbm171e1Bg}!oC?*TFQGYR6+-KV&J#ha$x&3??w+daXdN$X zXg!Zn;YSkG$>w+3M(A5id$7CBQ<i{Hp(!lRld_z`d3sT8An+&lK^C1(r_x&@GlFcG zA$`H|dWPMm)D6O`O8y@O@}2ZO79KgC9Uife-qk=ne$2-YwthO?KG@nctNZ)A*j7Ym z_C^A}@G6&I0eFdyuJq_Y?ZZA;K!@mFZ#PzUl_jLB(H949*rtJ2rEOR9YT6y>t2>&< zv>Iu{rGUHfXrubDviDl$Y1U|mp3hSq=#7T1P`Lky35OcrHx8Lgnc-MZJ*RH5mPH+d zkv6qmgW1$;h0LICi(uQT)rb!^Y8x%z3Pq!uqA;xC`o0muy=0ZvGoI~UPE#o86vQw@ zup)?4^mn%KV(t0buZcI=xmTNK*_E^8$|YEkEyUFj75I$W-0SVb%Ke83iQ4Qzms_;s z1-i;JS6rbA&sgX-Un5$A9aJ#l8IPGw%O;H8gz4AtvPlr9=sY{|V*UBL;F<YD@|3#< zcml`I;fa<08KUkP?$IG|K_`T`A;bs7;3Au%d#MJ7Z{Y2f@Vr;T^qcJbtMDwldX`+3 z`}CwdR-S<7<7q0J1O78`3c5-cg!e@d`UoE~O=xY?<Rhk8YeNsYrb$IEbXEwFKP`aZ zIn(^$9}0`aY05%LQlC&>YZ9mJyWdjrFBjd#eg|=pP!xru+kc^jw<z}~8oS7#!eX48 zit`I`DR?)WN~JHxQDH95&BXcHxYX#Yrsb;nxb#b3H52Eq$N6Gh+Ul!{ac(lsUyDm$ x_Ek%9t{CSRFH|(Wp?0<F%PZ>X_*s7G5-lt0`$vkZ=<i1qMgKRWD0gKA{{t=>@qPdR diff --git a/repoScaffold/src/platform_cli/shell/tools.py b/repoScaffold/src/platform_cli/shell/tools.py index 576fd52..b1e3c1e 100644 --- a/repoScaffold/src/platform_cli/shell/tools.py +++ b/repoScaffold/src/platform_cli/shell/tools.py @@ -41,6 +41,12 @@ "brew": "mongodb-atlas-cli", "install_hint": "brew install mongodb-atlas-cli (or https://www.mongodb.com/docs/atlas/cli/current/install-atlas-cli/)", }, + { + "name": "GitHub CLI", + "cmd": "gh", + "brew": "gh", + "install_hint": "brew install gh (or https://cli.github.com/)", + }, ] diff --git a/repoScaffold/src/platform_cli/steps/__init__.py b/repoScaffold/src/platform_cli/steps/__init__.py index 77c666d..0419d8b 100644 --- a/repoScaffold/src/platform_cli/steps/__init__.py +++ b/repoScaffold/src/platform_cli/steps/__init__.py @@ -11,4 +11,9 @@ phase_i_cloudbuild, phase_j_terraform, phase_k_testing, + phase_l_deploy, + phase_m_pipelines, + phase_n_agents, + phase_o_linear, + phase_p_library_pipeline, ) diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index a463fc273291960095032cb6522bc85f0a4334bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 594 zcmYk4y^ho{5XYTlzq0`jT8eaNo6QTHP82j4iGmdk&B$vzIdj>yHRA=rd)!0tNSKm} z4zUu~VqY#2E`A!%{O7|T=c-z<xL*AIVLoJx{Y;y2<qyXBG8(sR$B^x^UH&HHIp(~; zf=_V5r#R&^obfr%`2rVwiA%o16)&;mYh2f?D>jp_SFaDwqOV2Y*X~-7t`An7dQG*r zwMWx+8ta#+J40S0M7UaMX;FBKwEdMv*_5HW&jkVA+MdFbVY>&CqHS%jMvnv~yx7M+ zp&T>{gfnAULyPw4^-+aKL%4;{7H>3;@(w!b!s8)$4WuP%PvPm{Fd%z-Qu~vMEiVS& zM~H)?Xe}PbyqoAEZ;svDO%>(<%1VG(qZdBOViB`VCnYT6@o<tbO_(Lj6BY@}g!E2W zCae>xh;Qs1$-=wlJ!zyyiBCp#f}B9rkUgQ)4M{Z4Q-j*s-NP+4gtGa6ispWb2EZJR r2iUqVVR1P;&=o$5E#lW;^gQBsk!9I;_UbNQXZal)@h4e$5e5GMx!|`c diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_a_tools.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_a_tools.cpython-313.pyc deleted file mode 100644 index 009a7a0dc6348cf276b9ce4c970427e394de672b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2852 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 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_b_scaffold.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_b_scaffold.cpython-313.pyc deleted file mode 100644 index 7205a817d6bb64cc4ce88e62b59995263e7e8e9f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6171 zcmcgwO>7&-6`m!R;@?uFs9)Qu&B(DV+A<j{@n2B1m1Nb16e|hWYJ=GAc1@1twU)cg z>{7Pa0MVf+>|C6nFyaOU8X!4Dw;p}(rAHAdV0Bvq0fHQKQ_P?Ra_W1tyA&l+v7M-0 zL2qZ?_hxox-uK=Nr;$j+2t4w?|Ec{oMaY*}@t+R2AT(|Xgxn)CVMG?>z_c(GV1W#l zgVVt&k%_!4PKQ_sbfM{vsW1z3UB`4}D$1g~9G;F%#aWz}Bh%7Uf+aHKYIJ}M5;?k` z$g!*OQ$hEOr@RrcP7jX*F15jv9-aWavkji|@Fd`=Hh7nZcLAPmgQq>b8}Obsc(;f5 z0^ZjK@A2?{zz5pky&k>~@cnJ@zIicwpf>XQb=9Jo@mDehS}`rnHrY}ppP4iD5@i{y zsLo+AW7)J~<<@cSvVs#=4a2lmTQdy{s=+a1X$Ia&*)yLo4V&JwL6u;1UW3L=!5%;n z8HdlwZn=|D1HPizrZT7LwCH(;zXEPWXpBJN9=S@G02~R(0t?CkCdxtJgP0dsha8bZ z`^gQFh2;(wnGa{9PS*sZs!h*pj22-&8nxCtp_gy_#&ai?BFB`{Cg=#`wXU(;sG=E` zt?D|A$JDK&Hv&EmWGnDTXjJ%U^1#nG1S2pT=q494gc!kDp}-BdirL_aI3Wd(d99cn zfWP3mK!#izBqUCNx#E?OZ!sG<1yO;|i^2;e8=A?+oM2JcYAK7dMXgA!yjs!n7Aw}I zrsgKSR!eTyOtwH-tzR?dn3@L%Fm=vk<$T#JRds6B2DSs#ShVsby6D7;x>+q<t7>}5 ziSRP38sCO<i&oJrQ79C3wOXQ=vjHbuQNe|_<wWQ$@UmhqINdD^>#cPu8+76oX5IjA z;JQ1Z@&dksIyJJQBU)6SgX!6~91+x(Ba|!=rk;Vvc`H8)Q-Xo1i&|-3wcvxLyk%Bd zk>;%;(<-)=XFyz^dAKI|imuu?Sp}9X&zYOAAj_3&icjcVWyuM`yqw-0T*-mD2flCp z48$^d9Ehi4x5dT)N%!3zTT7(wDtDCoZ`Tu<htkoybo4Lc1AA3E`nh!e>mZnXkvj6J z_1VjxEUhL_u0~EapbxS_W>!RY07?v9>~t_{SD8`kENMmiie<Br3`EecR-#TsQ4F<A z6~&1uit8#UON#P#Rn<KtsX(+a%hol68YaMDoT8$YoDiR-6D!dQ1eT?k20Mh`>s287 zAX<xxYTHb^R<)_6C=7^4*z+h3qQG3u1;&cC55(`u7o9`P7uKZY^5k0j$ZFTn^3;FC zWN6fFg$?#BW)r_;noTdMhBikndwmG{uBFEzC~u-(DSH4*0Kg>ta%+4>K*8hl0Tk|$ z_R%N{7>y9MLZP)Sw7{p(j6S!H(%&l}z5|8urrs}I!SC{?z;`;o=Dr8XEaWO4=RrT7 zd7R@M__HI<YrS5a<opTemejKTbBMKciO#82-B#vw^CtLnNvBTKGMTL`&?U=B_zjeb zYG2QWSwDQiiTGM425h3bQmU3KPRw0XOwo>r{X^iPJ_uCIifli$<ZJ~s_yVMAtId#f z$-;TnwZ&%`H<+xgX%Nd~Et$Ui?wxm6pFdkqp1mz%^_@HKJWLMNlS7{;hd~pO?k4Xf z|0Mo7`cd@b7apAY+hl#9@b`$kmOAul_s5q%*|(ZJz8X31k`2i^!w%!nj-c2^-aP71 zAaR=H9c+^K*lO2tB(E46ar;BtJtJ=?Ux*8ut<4)Iqw6n0=e6$f+)EyN=Y3=s`Zl+h zUqi!t<cgR432Za%$tnks%79^_-1Zc(w`~t>HNgydk-%mm&NNAu&ou{@yD?geZB}8U z4tq=lLLkdIpBM%N8warx*rbUS07WL#LX7m*^+0Bv0~_lEh-LDxNZ*=t7_!RU*Y3P_ zU$0BY)_VFseD#A@ANCB_dxjr}#b|6JMpEh9(WmnLPoY<a0++qvC$7UgeBTjDyZxc< zp84LL>v^*X+wFu|hBFjh#}%p#jOSkV{9odzQgq>6NPzi;=P(-zKa>Q|L=AD18b9*9 zo_B|Q!W(jKsjMSK(mU1J0B400@sA!m2{Q!~ViO>CGNq3*Wy|UzTP~t8lFehwagQyl z2S@AD=vwc<hgUzi>b)NJ9;^2rdmIe`Yow=V%>^733UpR8V+`+LP1t7*+U^-^-0f~` z9#2n~O6z!d#DsCmBaF%crCi#DE6s#~OZ!8)vNbvM_zW1kFgbHs&Xr5(_-M1XB5;4N z1h=P!_6~OU2!EpJ+c#mzjiVqu-wyhjV4*H$J)b^v4!8vUF%-CxY!U@O*`R9AoEGO~ z{e?UMTj{zAJ=ap>xznD@+~e_+{PhvEdU}7!{P#oqSe8Xh=`CAD=)1NGxnuVY!|jQ0 z4q@0F+==AjkZ%;P=WbZ00R&)aoNl~D$zDfIk7o@BgayktuEjR1><uv6CHRvq>xs;m z2U82}x9<@eoU=bWQJ2oN(&L*LiT1BaP5N-7k-Buc)#zFFky#um3UqBVBV=}Dgt(s( z&~}f>HNk&dtyiIEB-a^_T<%Y<Cy@(1vX|uIlnBk#`rXgxEo$4EG2iqv>&9Uwwx~HR zp6qmYp6K-7LLU~M8$G8V@zVkKXTC0-`@a5s3p!;ea5bCff}Q?so(s@+kNsJoYN<@u ze+NCIKhJvpggXdOKgpl}!{lC=96Nt$(ogQq+EaSZofUfTJ(w7iQSfv-2&L~hrT+#E zQQ&$oJnXv7P_<_Z?`D-rV{!79TCV7H{SVN4t#>?k&RcxA=}@SP{r^4C?{BX;7)H(; z2JWy@tKx}%=;o<&bCT$bKf*k%mW{O;Nk&~7`MyC!p;LweSFxECkL*Z_%^*VCY`+s$ z6gVro7a4O^xbA>U4Hg8<>9Ch7kVTjX6?PUi2U;&|U>WA&W+PX0?-1P^ljqu5xL1p8 ziQ#F;8E9vMHzO>J@S!cRO@SG3MQG*RD@eE=<3Dm@I41sElK-f<fK`69z*Nt338yb# zkZnIfoyd>f3!9%Xyd)Ih-%5ep2nvGm4>J0Q41PuWz9jLl$eBmv^doZO5qV{!g9MV_ z1bT$g`;i8LS0f0D4ODF8I208|-oMx&@bVxiZ47WIE~MXovq9kHK~UOwjzf}=zCX|) w@bVxiZ5(X2f)3lYN<oLi4FWH(6_hrHnymn8*9vslYB%WmCiHaxjCjQV7kaQODgXcg diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_c_database.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_c_database.cpython-313.pyc deleted file mode 100644 index 63ded172fbb225eb2978f4169c2c1264e0cfca48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8986 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 As{jB1 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_d_api.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_d_api.cpython-313.pyc deleted file mode 100644 index 604344aad3197d631ab6ffa1be9dfb25cd02cb5f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6223 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 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_e_frontend.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_e_frontend.cpython-313.pyc deleted file mode 100644 index 3b10f5e2241351d6fc0b82b642139bc8c898f200..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6581 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 IIksy50m)y1%m4rY diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_f_worker.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_f_worker.cpython-313.pyc deleted file mode 100644 index 219fcb1e25d2bfc1be729fafb43bceaa5eb1eeac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3799 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 M!t^&8HR0`l0!}GXod5s; diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_g_gcp.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_g_gcp.cpython-313.pyc deleted file mode 100644 index 89c22e4fb646752a5bcac026688e7b975a4f8248..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7233 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 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_h_secrets.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_h_secrets.cpython-313.pyc deleted file mode 100644 index e6e035f6f06b89150f37ba8a0ab1e438c28c8688..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5073 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 a5%lVBGlnd{G%vrqfuJ#D&sgXgU+8}!`k^5J diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_i_cloudbuild.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_i_cloudbuild.cpython-313.pyc deleted file mode 100644 index f8e9e4983b1bb5d344ac190edf818914b863881a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2918 zcmd^B&u`RL5Pr7zhj-)MC7X~03c^iNHA~4ZrKE_cv?8b~*+>D^L`Wr-<=Eb>la1|; zy+HPqQw1m518V*Ra_gUx2&vk-O;xGoz%6Bwkb3Hj?S);Mwj59|^<g}3=K0Ng^S=4U zom?)<@X0*;(V7(*`;`XWClx7N=Q~vHGlc<DIE7!}syy&A>B53gO+X^l6AQ^|3R0n- zTu4_%5JNq+kf~-N8|vwWfvN<O%$CJ5HqI3BC{r@a*%={<G!sXLT<p)Hf8YRrKK4uK z&mG_|)bpi6Yy9dLnrF)Mv+|{e(=_C{rqwWH-LY#{UG{v_^~#SaVu=d|G~0H3&9@xe z!=Z4|-oRHu(Y0F5X&9Fr+c$6fXv%?ExA0p~X#q5da~MO3^gNihfiB-sYgWV5<Ku)c zJ{oYHQ>ff$%M3V7D6eoJC_E$-0g_6h!a+(AmE=*jnt-&D0<oShWrEywuzd5PYlSCx zEa3{Rq51N=>MgRXnro>mbhdz-|6^nh?K+qmIt&X;-FLt;Jw1*e?jns6Q_gk15BIXf zeaeOQKA~_kvDN06_^H@uBp6e8ML5k(vK#TWp(TN9py%aBNlp)-H$@U>#DMCZwZtpw z^9Z1xwZy+!EAy&roo17)lwAx2OiYmW)-2aGjcctDolp=as)C;KhPK`)ubv6AFYGO` z>)7T4ab2^mn(6tth6y(<-E0kqQ=w`1Eg*KS+X)!P!vPi&3kF<pRx$l_3owM1Z6Qc_ zW}_Aex_>(myqmg536y0ySgtH#2(MyjH!Y*Cd9DUV#dDgVn-xz7%k{kqn6A^sW5ol# z;x;tD=D@nDH>^qs=ZZ^!S1q)3Pg-_20s%K14EABDjHV(6_dFDvteqdcw{~}J>#c`* z`Ht{&q1YZizUA)>P3}mOzlkGlX|gR9?p?WiWvj6(oqX`wu5_+_c<kHdZ<gD`qfau) zO!jG>Wk%c5cz53Ft~B*vc2}BtY2LoX4i)cYIvAF1vqj9<QPd!bB1nPhH^FY@4NLcL zcs`txu?|1q&IF>W+S<CQszFv&*BztTAYD?`FPmB;_7qesI`I64Wt+Bx?lk2@wTvJc zrX|Q4ri=CIsg4cDDc)ffix_KOS2f=UYo+O%o~nXO8#+Pa7zyIXP*B-*j-dF8{gR*9 z`~;7>`Ek2=a%b?J&FZs6F<Fehq7Pnk>0dWjqSAjG*QU}x6PNxib0sQ$pxr@^LjMmd zJ%xGDTo(@E`TGkU9o<5QK}w$3wE*G2r=q)dqy#vOE*K#(hN4@h5gtP5MAdXXz80#c zX`0wyO;Zo^6aPsyMYxl32aE6~N|+!);~pHBX-I-F1k{BhCz)OwT!%uIxn=@rL`@NB z=P*L+$b9*|7_`_uBlkZ-mzo9gBiFf#%6)d@d5cmwY!JO5M?XCZA2tXfZZ-$Cqr`bZ zW?_3sF1C)nV9Cn!E%k#X-)}E*A(5Aakl|qzgGL4(h!;XC^Jr(X+=G?T19C!$xr`G% zg3TuTi8%ITmph4Lccw4wN*7}Ce3DLNvisSPIbZ3n&(dy4Q0l!dXO;fKS*b2((cHz- zp&+fQhNDM3TWca4BhLcf0+O@8;aYYbD6PR8y}hSw+I8gIGX7Z6Pg2C?#}1ki{X4-1 z(A@@O2TdW)2Kqk<PV~8bbDhSfSC0O%5JJ>tBX$m<9iCYx+jH&?#1Eoo@gaPK4nmN( zhGJjfIPM2Fwa3OEv5`mY+#Y*>k4^8f+5Hsb3s3n`uK4xk4#QVRK*K&6_Rs#CYk(_m QO?4Q)!mobkQ42ToCu>Ba=Kufz diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_j_terraform.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_j_terraform.cpython-313.pyc deleted file mode 100644 index dd4acf5e2479fedf8e8c8cfd416bba47fe0679b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5693 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 diff --git a/repoScaffold/src/platform_cli/steps/__pycache__/phase_k_testing.cpython-313.pyc b/repoScaffold/src/platform_cli/steps/__pycache__/phase_k_testing.cpython-313.pyc deleted file mode 100644 index cef29b7aeb08d148080c6c77ccd4dd8fe69a7e19..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10080 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≺>kRh3G+1iLFZ73cXrsE6WPl`0Nb*iSfI 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 diff --git a/repoScaffold/src/platform_cli/steps/_gcp_org.py b/repoScaffold/src/platform_cli/steps/_gcp_org.py new file mode 100644 index 0000000..90ef5ad --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/_gcp_org.py @@ -0,0 +1,88 @@ +"""Shared GCP organization helpers. + +Every project the CLI creates (or reuses) should land under the caller's +organization when one exists. A project created without an org parent ends up +under "No organization" — invisible in the org-scoped console project picker +and outside org policies/IAM. That is exactly how `fresler-table` slipped out +(org auto-detection returned empty at create time and creation silently +proceeded orgless). + +Two pieces: + * ``resolve_org_id`` — decide which org new projects go under. + * ``ensure_project_in_org`` — adopt an already-created orphan project into + that org (self-healing, so a flaky detection at create time is corrected). +""" +from __future__ import annotations + +import os + +from rich.console import Console + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.shell.run import run_cmd + +console = Console() + +# Runtime override, highest precedence. Handy in CI or one-off runs. +ORG_ENV_VAR = "PLATFORM_GCP_ORG_ID" + + +def resolve_org_id(ctx: ScaffoldContext) -> str: + """Return the org id to place new projects under, or "" if none. + + Resolution order: ``PLATFORM_GCP_ORG_ID`` env var → manifest + ``project.cloud.org_id`` → auto-detect via ``gcloud organizations list``. + """ + env_org = os.environ.get(ORG_ENV_VAR, "").strip() + if env_org: + return env_org + + manifest_org = (ctx.org_id or "").strip() + if manifest_org: + return manifest_org + + listing = run_cmd("gcloud organizations list --format='value(ID)'") + if listing.ok and listing.stdout.strip(): + orgs = [o for o in listing.stdout.strip().split("\n") if o.strip()] + if len(orgs) > 1: + console.print( + f" [yellow]note[/yellow] multiple orgs found; using {orgs[0]} " + f"(set {ORG_ENV_VAR} or project.cloud.org_id to choose)" + ) + if orgs: + return orgs[0] + + console.print( + " [yellow]note[/yellow] no GCP organization detected; project will live " + "under 'No organization'" + ) + return "" + + +def ensure_project_in_org(project_id: str, org_id: str) -> None: + """Adopt an orphaned project into ``org_id``. + + Only moves a project that currently has *no* org parent, so we never yank a + project out of a different org it deliberately belongs to. No-op when + ``org_id`` is empty or the project is already under an organization. + """ + if not org_id: + return + parent = run_cmd( + f"gcloud projects describe {project_id} --format='value(parent.type)'" + ) + if not parent.ok: + return + if parent.stdout.strip() == "organization": + return # already under an org — leave it alone + + moved = run_cmd( + f"gcloud beta projects move {project_id} --organization {org_id} --quiet" + ) + if moved.ok: + console.print(f" [green]moved[/green] {project_id} under org {org_id}") + else: + console.print( + f" [yellow]warn[/yellow] could not move {project_id} under org " + f"{org_id}: {moved.stderr.strip()}" + ) diff --git a/repoScaffold/src/platform_cli/steps/phase_a_tools.py b/repoScaffold/src/platform_cli/steps/phase_a_tools.py index e4e5675..6975547 100644 --- a/repoScaffold/src/platform_cli/steps/phase_a_tools.py +++ b/repoScaffold/src/platform_cli/steps/phase_a_tools.py @@ -1,13 +1,25 @@ -"""Phase A: Tool detection and installation steps.""" +"""Phase A: Tool detection, installation, and authentication. + + A.1 detect_tools — check PATH for required CLIs + A.2 install_tools — list missing tools with install hints + A.3 authenticate — run all logins sequentially (gcloud, atlas, gh, linear) +""" from __future__ import annotations +import os +import subprocess from typing import Any +from rich.console import Console + from platform_cli.engine.context import ScaffoldContext from platform_cli.engine.registry import register_step from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd from platform_cli.shell.tools import REQUIRED_TOOLS, detect_tool +console = Console() + @register_step class DetectToolsStep(BaseStep): @@ -15,12 +27,6 @@ class DetectToolsStep(BaseStep): phase = "A" depends_on: list[str] = [] - def inputs(self): - return ["PATH environment"] - - def outputs_spec(self): - return ["detected_tools map"] - def run(self, ctx: ScaffoldContext) -> dict[str, Any]: results: dict[str, bool] = {} for tool in REQUIRED_TOOLS: @@ -41,9 +47,192 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: t for t in REQUIRED_TOOLS if not detected.get(t["cmd"], False) ] if missing: - from rich.console import Console - console = Console() console.print("[yellow]Missing tools (install manually):[/yellow]") for t in missing: console.print(f" - {t['name']}: {t['install_hint']}") return {"missing_tools": [t["cmd"] for t in missing]} + + +def _get_linear_api_key() -> str: + """Retrieve the Linear API key from ~/.linear/ config or env var.""" + # Check env var first + key = os.environ.get("LINEAR_API_KEY", "") + if key: + return key + + # Check ~/.linear/ config (stored by @linear/cli) + config_path = os.path.expanduser("~/.linear/config.json") + if os.path.exists(config_path): + import json + try: + with open(config_path) as f: + config = json.load(f) + key = config.get("apiKey", "") + if key: + return key + except (json.JSONDecodeError, KeyError): + pass + + return "" + + +def _is_logged_in(cmd: str, check_cmd: str) -> bool: + """Check if a CLI tool is authenticated.""" + r = run_cmd(check_cmd, timeout=15) + return r.ok + + +def _interactive_login(label: str, login_cmd: str) -> bool: + """Run an interactive login command, letting it use the real terminal.""" + console.print(f" [cyan]logging in[/cyan] {label}...") + try: + result = subprocess.run( + login_cmd, + shell=True, + timeout=300, + ) + return result.returncode == 0 + except subprocess.TimeoutExpired: + console.print(f" [yellow]timeout[/yellow] waiting for {label} login") + return False + + +@register_step +class AuthenticateStep(BaseStep): + """Consolidated login step — authenticates all required CLIs sequentially. + + Checks each tool's auth status first and only prompts for login when + needed. Interactive logins inherit the real terminal so browser-based + OAuth flows work correctly. + """ + + step_id = "A.3_authenticate" + phase = "A" + depends_on = ["A.2_install_tools"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + detected = ctx.get("detected_tools", {}) + auth_status: dict[str, str] = {} + + # ── Google Cloud ── + if detected.get("gcloud"): + if _is_logged_in("gcloud", "gcloud config get-value account"): + who = run_cmd("gcloud config get-value account") + auth_status["gcloud"] = who.stdout.strip() + console.print(f" [dim]gcloud[/dim] → {auth_status['gcloud']}") + else: + if _interactive_login("Google Cloud", "gcloud auth login"): + auth_status["gcloud"] = "ok" + console.print(" [green]gcloud[/green] → authenticated") + else: + raise RuntimeError( + "gcloud login failed. Run `gcloud auth login` manually." + ) + + # Also ensure application-default credentials for Terraform + adc = run_cmd( + "gcloud auth application-default print-access-token", + timeout=10, + ) + if not adc.ok: + console.print( + " [cyan]setting up[/cyan] application-default credentials (for Terraform)..." + ) + _interactive_login( + "GCP ADC", + "gcloud auth application-default login", + ) + + # ── MongoDB Atlas ── + if detected.get("atlas"): + if _is_logged_in("atlas", "atlas auth whoami"): + who = run_cmd("atlas auth whoami") + auth_status["atlas"] = who.stdout.strip() + console.print(f" [dim]atlas[/dim] → {auth_status['atlas']}") + else: + if _interactive_login("MongoDB Atlas", "atlas auth login"): + auth_status["atlas"] = "ok" + console.print(" [green]atlas[/green] → authenticated") + else: + raise RuntimeError( + "Atlas login failed. Run `atlas auth login` manually." + ) + + # ── GitHub CLI ── + if detected.get("gh"): + if _is_logged_in("gh", "gh auth status"): + who = run_cmd("gh api user --jq '.login'") + auth_status["gh"] = who.stdout.strip() if who.ok else "ok" + console.print(f" [dim]gh[/dim] → {auth_status['gh']}") + else: + if _interactive_login("GitHub", "gh auth login"): + auth_status["gh"] = "ok" + console.print(" [green]gh[/green] → authenticated") + else: + raise RuntimeError( + "GitHub login failed. Run `gh auth login` manually." + ) + + # ── Linear ── + linear_cfg = ctx.manifest.linear + if linear_cfg.enabled: + api_key = _get_linear_api_key() + if api_key: + # Verify the key works + r = run_cmd( + f"curl -sf -H 'Authorization: {api_key}' " + f"-H 'Content-Type: application/json' " + f"-d '{{\"query\": \"{{ viewer {{ id name }} }}\"}}' " + f"https://api.linear.app/graphql", + timeout=15, + ) + if r.ok and "viewer" in r.stdout: + auth_status["linear"] = "ok" + console.print(" [dim]linear[/dim] → authenticated") + else: + console.print(" [yellow]linear[/yellow] stored key is invalid") + api_key = "" + + if not api_key: + # Prompt the user to enter their API key + console.print( + " [yellow]linear[/yellow] not authenticated" + ) + console.print( + " Create an API key at: https://linear.app/settings/api" + ) + console.print( + " Then paste it here or set LINEAR_API_KEY env var and re-run." + ) + try: + key_input = input(" Linear API key: ").strip() + if key_input: + # Store it + config_dir = os.path.expanduser("~/.linear") + os.makedirs(config_dir, exist_ok=True) + import json as _json + config_path = os.path.join(config_dir, "config.json") + _json.dump({"apiKey": key_input}, open(config_path, "w")) + os.chmod(config_path, 0o600) + auth_status["linear"] = "ok" + console.print(" [green]linear[/green] → key saved") + else: + auth_status["linear"] = "not_authenticated" + except (EOFError, KeyboardInterrupt): + auth_status["linear"] = "not_authenticated" + + # ── Docker ── + if detected.get("docker"): + r = run_cmd("docker info", timeout=10) + if r.ok: + auth_status["docker"] = "running" + console.print(" [dim]docker[/dim] → running") + else: + console.print(" [cyan]starting[/cyan] Docker Desktop...") + run_cmd("open -a Docker") + # Don't block — Docker takes time to start. + # Phase L will wait for it when needed. + auth_status["docker"] = "starting" + + ctx.set("auth_status", auth_status) + return {"auth_status": auth_status} diff --git a/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py b/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py index 96f2735..a1048fe 100644 --- a/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py +++ b/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py @@ -8,7 +8,21 @@ from platform_cli.engine.context import ScaffoldContext from platform_cli.engine.registry import register_step from platform_cli.engine.step import BaseStep -from platform_cli.templates.renderer import render_to_file +from platform_cli.templates.renderer import render_to_file, _TEMPLATES_DIR + + +def _kind_template(base: str, ext: str, ctx: ScaffoldContext) -> str: + """Pick a kind-specific template variant if one exists, else the base. + + e.g. base="project/AGENTS", ext="md.j2" resolves to + "project/AGENTS.library-node.md.j2" for a library-node project when that + file exists, otherwise falls back to "project/AGENTS.md.j2". + """ + if ctx.manifest.project.is_library: + variant = f"{base}.{ctx.manifest.project.kind}.{ext}" + if (_TEMPLATES_DIR / variant).exists(): + return variant + return f"{base}.{ext}" @register_step @@ -18,19 +32,24 @@ class CreateDirectories(BaseStep): depends_on = ["A.2_install_tools"] def run(self, ctx: ScaffoldContext) -> dict[str, Any]: - dirs = [ - "cli", - "services/api/src", - "services/web", - "services/worker", - "infra/terraform/modules", - "infra/terraform/envs/dev", - "cloudbuild", - "cloudrun", - "agents", - ".vscode", - ".claude", - ] + if ctx.manifest.project.is_library: + # A library has no services/infra dirs, but does get a cloudbuild/ + # dir for its npm build+publish pipeline. + dirs = ["agents", ".claude", "cloudbuild"] + else: + dirs = [ + "cli", + "services/api/src", + "services/web", + "services/worker", + "infra/terraform/modules", + "infra/terraform/envs/dev", + "cloudbuild", + "cloudrun", + "agents", + ".vscode", + ".claude", + ] for d in dirs: (ctx.project_dir / d).mkdir(parents=True, exist_ok=True) return {"directories_created": len(dirs)} @@ -70,6 +89,10 @@ class WriteDockerCompose(BaseStep): phase = "B" depends_on = ["B.1_create_directories"] + def should_skip(self, ctx: ScaffoldContext) -> bool: + # Libraries have no services to compose. + return super().should_skip(ctx) or ctx.manifest.project.is_library + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: render_to_file( "project/docker-compose.yml.j2", @@ -88,8 +111,8 @@ class WriteAgentsMd(BaseStep): def run(self, ctx: ScaffoldContext) -> dict[str, Any]: render_to_file( - "project/AGENTS.md.j2", - ctx.project_dir / "AGENTS.md", + _kind_template("project/AGENTS", "md.j2", ctx), + ctx.project_dir / "agents" / "AGENTS.md", manifest=ctx.manifest, ) return {} @@ -101,6 +124,10 @@ class WriteVSCode(BaseStep): phase = "B" depends_on = ["B.1_create_directories"] + def should_skip(self, ctx: ScaffoldContext) -> bool: + # The launch/tasks configs target services + ports — not relevant to a library. + return super().should_skip(ctx) or ctx.manifest.project.is_library + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: render_to_file( "vscode/launch.json.j2", @@ -123,7 +150,7 @@ class WriteClaude(BaseStep): def run(self, ctx: ScaffoldContext) -> dict[str, Any]: render_to_file( - "claude/settings.json.j2", + _kind_template("claude/settings", "json.j2", ctx), ctx.project_dir / ".claude" / "settings.json", manifest=ctx.manifest, ) @@ -138,7 +165,7 @@ class WriteReadme(BaseStep): def run(self, ctx: ScaffoldContext) -> dict[str, Any]: render_to_file( - "project/README.md.j2", + _kind_template("project/README", "md.j2", ctx), ctx.project_dir / "README.md", manifest=ctx.manifest, ) @@ -151,6 +178,10 @@ class WriteEnvExample(BaseStep): phase = "B" depends_on = ["B.1_create_directories"] + def should_skip(self, ctx: ScaffoldContext) -> bool: + # The .env.example lists service/DB secrets — not relevant to a library. + return super().should_skip(ctx) or ctx.manifest.project.is_library + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: render_to_file( "project/.env.example.j2", @@ -160,14 +191,37 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: return {} +@register_step +class WriteLibraryCloudbuild(BaseStep): + """Render the npm build+publish Cloud Build pipeline for library projects.""" + + step_id = "B.11_write_library_cloudbuild" + phase = "B" + depends_on = ["B.1_create_directories"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not ctx.manifest.project.is_library + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + render_to_file( + "cloudbuild/npm-publish.yaml.j2", + ctx.project_dir / "cloudbuild" / "publish.yaml", + manifest=ctx.manifest, + ) + return {"cloudbuild": "cloudbuild/publish.yaml"} + + @register_step class WriteAgentLoop(BaseStep): step_id = "B.10_write_agent_loop" phase = "B" depends_on = ["B.1_create_directories"] - # Built-in agent templates shipped with the scaffolder + # Built-in agent templates shipped with the scaffolder. + # A library defaults to just the developer agent (the reviewer/ops agents + # monitor Cloud Run and are meaningless without a deployed service). BUILTIN_AGENTS = ["developer", "reviewer", "ops"] + BUILTIN_AGENTS_LIBRARY = ["developer"] def run(self, ctx: ScaffoldContext) -> dict[str, Any]: import os @@ -175,7 +229,12 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: # Determine which agents to scaffold manifest_roles = {r.name: r for r in ctx.manifest.agents.roles} - agent_names = list(manifest_roles.keys()) if manifest_roles else self.BUILTIN_AGENTS + default_agents = ( + self.BUILTIN_AGENTS_LIBRARY + if ctx.manifest.project.is_library + else self.BUILTIN_AGENTS + ) + agent_names = list(manifest_roles.keys()) if manifest_roles else default_agents agents_dir = ctx.project_dir / "agents" scaffolded = [] @@ -191,7 +250,7 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: } template_dir = Path(f"agents/{name}") - prompt_template = f"agents/{name}/PROMPT.md.j2" + prompt_template = _kind_template(f"agents/{name}/PROMPT", "md.j2", ctx) config_template = f"agents/{name}/config.yaml.j2" try: @@ -228,19 +287,27 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: scaffolded.append(name) - # Write shared loop.sh and setup-vm.sh + # Write shared loop.sh and setup-vm.sh into agents/ render_to_file( "project/loop.sh.j2", - ctx.project_dir / "loop.sh", + agents_dir / "loop.sh", manifest=ctx.manifest, ) render_to_file( "project/setup-vm.sh.j2", - ctx.project_dir / "setup-vm.sh", + agents_dir / "setup-vm.sh", + manifest=ctx.manifest, + ) + # Deterministic board-reconciliation helper (no Claude); loop.sh runs it + # for one designated agent each cycle. Takes the team key as an argument. + render_to_file( + "project/reconcile.py.j2", + agents_dir / "reconcile.py", manifest=ctx.manifest, ) - os.chmod(ctx.project_dir / "loop.sh", 0o755) - os.chmod(ctx.project_dir / "setup-vm.sh", 0o755) + os.chmod(agents_dir / "loop.sh", 0o755) + os.chmod(agents_dir / "setup-vm.sh", 0o755) + os.chmod(agents_dir / "reconcile.py", 0o755) return {"agents": scaffolded} diff --git a/repoScaffold/src/platform_cli/steps/phase_c_database.py b/repoScaffold/src/platform_cli/steps/phase_c_database.py index da9f081..56a559a 100644 --- a/repoScaffold/src/platform_cli/steps/phase_c_database.py +++ b/repoScaffold/src/platform_cli/steps/phase_c_database.py @@ -141,9 +141,36 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: ) cmd = f"mongosh {shlex.quote(uri)} --quiet --eval {shlex.quote(script)}" r = run_cmd(cmd, timeout=60) - if not r.ok or "ok" not in r.stdout: - raise RuntimeError(f"Seed failed: {r.stderr or r.stdout}") - return {"seeded": True, "db": db_name} + if r.ok and "ok" in r.stdout: + return {"seeded": True, "db": db_name} + + # Fallback: mongosh bundles its own Node runtime whose c-ares + # resolver can fail SRV lookups. Use the system `node` with + # the mongodb driver (installed via the API service) instead. + if "ETIMEOUT" in (r.stderr or r.stdout): + api_dir = ctx.project_dir / "services" / "api" + node_script = ( + "const {MongoClient}=require('mongodb');" + f"const c=new MongoClient({shlex.quote(uri)});" + "async function main(){await c.connect();" + f"const db=c.db({shlex.quote(db_name)});" + "await db.collection('items').updateOne(" + '{"name":"example item"},' + '{"$setOnInsert":{"name":"example item"}},' + "{upsert:true});" + 'console.log("ok");await c.close();}' + "main().catch(e=>{console.error(e.message);process.exit(1);});" + ) + fb = run_cmd( + f"node -e {shlex.quote(node_script)}", + cwd=str(api_dir), + timeout=60, + ) + if fb.ok and "ok" in fb.stdout: + return {"seeded": True, "db": db_name, "fallback": "node"} + raise RuntimeError(f"Seed failed (node fallback): {fb.stderr or fb.stdout}") + + raise RuntimeError(f"Seed failed: {r.stderr or r.stdout}") @register_step diff --git a/repoScaffold/src/platform_cli/steps/phase_g_gcp.py b/repoScaffold/src/platform_cli/steps/phase_g_gcp.py index e24bd31..ff20b59 100644 --- a/repoScaffold/src/platform_cli/steps/phase_g_gcp.py +++ b/repoScaffold/src/platform_cli/steps/phase_g_gcp.py @@ -3,10 +3,15 @@ from typing import Any +from rich.console import Console + from platform_cli.engine.context import ScaffoldContext from platform_cli.engine.registry import register_step from platform_cli.engine.step import BaseStep from platform_cli.shell.run import run_cmd +from platform_cli.steps._gcp_org import ensure_project_in_org, resolve_org_id + +console = Console() @register_step @@ -26,15 +31,22 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: "No gcloud account found. Run `gcloud auth login` then retry." ) - # Reuse an existing project + # Which org should this project live under? (env → manifest → detect) + org_id = resolve_org_id(ctx) + + # Reuse an existing project — still make sure it's under the org, so an + # orphaned (no-org) project gets adopted rather than left invisible. if run_cmd(f"gcloud projects describe {project_id}").ok: run_cmd(f"gcloud config set project {project_id}", check=True) + ensure_project_in_org(project_id, org_id) return {"gcp_project_exists": True, "project_id": project_id} - # Try to create - result = run_cmd( - f"gcloud projects create {project_id} --name={ctx.project_name}" - ) + # Build the create command + create_cmd = f"gcloud projects create {project_id} --name={ctx.project_name}" + if org_id: + create_cmd += f" --organization={org_id}" + + result = run_cmd(create_cmd) if not result.ok: raise RuntimeError( f"Could not create GCP project '{project_id}'. " @@ -45,7 +57,29 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: f"gcloud error: {result.stderr}" ) run_cmd(f"gcloud config set project {project_id}", check=True) - return {"gcp_project_created": True, "project_id": project_id} + # Belt-and-suspenders: if --organization was dropped for any reason, + # adopt the fresh project into the org now. + ensure_project_in_org(project_id, org_id) + + # Auto-link billing account so subsequent API enables work without + # manual intervention. Pick the first open billing account. + billing_list = run_cmd( + "gcloud billing accounts list --filter='OPEN=true' --format='value(ACCOUNT_ID)' --limit=1" + ) + billing_id = billing_list.stdout.strip().split("\n")[0] if billing_list.ok else "" + if billing_id: + link = run_cmd( + f"gcloud billing projects link {project_id} --billing-account={billing_id}" + ) + if link.ok: + console.print(f" [green]linked billing[/green] {billing_id}") + + return { + "gcp_project_created": True, + "project_id": project_id, + "org_id": org_id, + "billing_account": billing_id, + } @register_step @@ -60,6 +94,9 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: "cloudbuild.googleapis.com", "secretmanager.googleapis.com", "artifactregistry.googleapis.com", + "compute.googleapis.com", + "vpcaccess.googleapis.com", + "cloudscheduler.googleapis.com", ] project_id = ctx.project_id diff --git a/repoScaffold/src/platform_cli/steps/phase_h_secrets.py b/repoScaffold/src/platform_cli/steps/phase_h_secrets.py index 2ce0ccf..f2a5931 100644 --- a/repoScaffold/src/platform_cli/steps/phase_h_secrets.py +++ b/repoScaffold/src/platform_cli/steps/phase_h_secrets.py @@ -36,6 +36,12 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: secrets = [ {"name": "MONGODB_URI", "source": "services/api/.env"}, {"name": "DB_NAME", "source": "services/api/.env"}, + # Agent VM secrets — provisioned directly to Secret Manager (never + # via .env), read by the agent loop at runtime. Listed here for + # discoverability; created by their respective phases. + {"name": "claude-code-oauth-token", "source": "env:CLAUDE_CODE_OAUTH_TOKEN (agent Claude auth)"}, + {"name": "linear-api-key", "source": "env:LINEAR_API_KEY (agent Linear access)"}, + {"name": "github-deploy-key", "source": "generated (agent git auth)"}, ] manifest_path = ctx.project_dir / "secrets.manifest.yaml" diff --git a/repoScaffold/src/platform_cli/steps/phase_i_cloudbuild.py b/repoScaffold/src/platform_cli/steps/phase_i_cloudbuild.py index 62ac169..f331cf0 100644 --- a/repoScaffold/src/platform_cli/steps/phase_i_cloudbuild.py +++ b/repoScaffold/src/platform_cli/steps/phase_i_cloudbuild.py @@ -63,9 +63,29 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: return {} +@register_step +class WriteWorkerBuild(BaseStep): + step_id = "I.3_write_worker_build" + phase = "I" + depends_on = ["B.1_create_directories"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + svc = ctx.service("worker") + if not svc: + return {"skipped": True} + + render_to_file( + "cloudbuild/worker.yaml.j2", + ctx.project_dir / "cloudbuild" / "worker.yaml", + manifest=ctx.manifest, + service=svc, + ) + return {} + + @register_step class WriteTerraformBuild(BaseStep): - step_id = "I.3_write_terraform_build" + step_id = "I.4_write_terraform_build" phase = "I" depends_on = ["B.1_create_directories"] diff --git a/repoScaffold/src/platform_cli/steps/phase_k_testing.py b/repoScaffold/src/platform_cli/steps/phase_k_testing.py index ca795ba..d6fccd1 100644 --- a/repoScaffold/src/platform_cli/steps/phase_k_testing.py +++ b/repoScaffold/src/platform_cli/steps/phase_k_testing.py @@ -165,8 +165,18 @@ class TerraformPlan(BaseStep): depends_on = ["K.5_terraform_validate"] def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + import subprocess + tf_dir = str(ctx.project_dir / "infra" / "terraform" / "envs" / "dev") - result = run_cmd("terraform plan -input=false", cwd=tf_dir, timeout=180) + try: + result = run_cmd("terraform plan -input=false", cwd=tf_dir, timeout=300) + except subprocess.TimeoutExpired: + console.print( + "[yellow]terraform plan timed out — soft-fail. " + "Phase L will run apply which exercises the same provider auth.[/yellow]" + ) + return {"plan_ok": False, "soft_fail": "timeout"} + if not result.ok: console.print( "[yellow]terraform plan did not succeed — usually needs GCP auth " diff --git a/repoScaffold/src/platform_cli/steps/phase_l_deploy.py b/repoScaffold/src/platform_cli/steps/phase_l_deploy.py new file mode 100644 index 0000000..8468c8c --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_l_deploy.py @@ -0,0 +1,273 @@ +"""Phase L: Initial deployment — build+push images, terraform apply, verify live. + + L.1 docker_build_push — build and push images for each service to Artifact Registry + L.2 terraform_apply — apply infrastructure (Cloud Run, IAM, secrets, VPC) + L.3 verify_deployment — hit each service URL and confirm 2xx +""" +from __future__ import annotations + +import json +import time +from typing import Any + +from rich.console import Console + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd + +console = Console() + + +def _image_tag(ctx: ScaffoldContext, svc_name: str) -> str: + """Full Artifact Registry image tag for a service.""" + region = ctx.region + project_id = ctx.project_id + repo = f"{ctx.project_name.lower().replace(' ', '-')}-docker" + return f"{region}-docker.pkg.dev/{project_id}/{repo}/{svc_name}:latest" + + +@register_step +class DockerBuildPush(BaseStep): + step_id = "L.1_docker_build_push" + phase = "L" + depends_on = ["K.3_api_docker_build", "G.3_create_artifact_registry"] + max_retries = 1 + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + region = ctx.region + + # Authenticate Docker with Artifact Registry via access token. + # This avoids requiring docker-credential-gcloud in PATH. + token = run_cmd("gcloud auth print-access-token", timeout=15) + if not token.ok: + raise RuntimeError("Failed to get gcloud access token for Docker auth") + login_result = run_cmd( + f"docker login -u oauth2accesstoken -p {token.stdout.strip()} " + f"{region}-docker.pkg.dev", + timeout=30, + ) + if not login_result.ok: + raise RuntimeError(f"Docker login failed: {login_result.stderr}") + + pushed = [] + svc_map = { + "api": "services/api", + "webapp": "services/web", + "worker": "services/worker", + } + + for svc_type, svc_dir_rel in svc_map.items(): + svc = ctx.service(svc_type) + if not svc: + continue + + svc_name = svc.name + svc_dir = str(ctx.project_dir / svc_dir_rel) + tag = _image_tag(ctx, svc_name) + + console.print(f" [cyan]building[/cyan] {svc_name} → {tag}") + run_cmd( + f"docker build --platform linux/amd64 -t {tag} .", + cwd=svc_dir, + check=True, + timeout=600, + ) + + console.print(f" [cyan]pushing[/cyan] {svc_name}") + run_cmd(f"docker push {tag}", check=True, timeout=300) + pushed.append(svc_name) + + return {"pushed": pushed} + + +@register_step +class TerraformApply(BaseStep): + step_id = "L.2_terraform_apply" + phase = "L" + depends_on = ["L.1_docker_build_push", "K.5_terraform_validate"] + max_retries = 0 + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + tf_dir = str(ctx.project_dir / "infra" / "terraform" / "envs" / "dev") + project_id = ctx.project_id + + # Ensure init has been run + run_cmd("terraform init", cwd=tf_dir, check=True, timeout=180) + + # Import resources that were already created via gcloud in earlier + # phases so Terraform doesn't try to re-create them and fail. + # Only import secrets that actually exist (skipped if DB phase was disabled). + imports = [] + for secret_name, tf_addr in [ + ("mongodb-uri", "module.secrets.google_secret_manager_secret.mongodb_uri"), + ("db-name", "module.secrets.google_secret_manager_secret.db_name"), + ]: + exists = run_cmd( + f"gcloud secrets describe {secret_name} --project={project_id}", + timeout=15, + ) + if exists.ok: + imports.append(( + secret_name, tf_addr, + f"projects/{project_id}/secrets/{secret_name}", + )) + + # Service accounts + prefix = ctx.project_name.lower().replace(" ", "-") + for role in ("build", "runtime"): + sa_name = f"{prefix}-{role}" + imports.append(( + sa_name, + f"module.iam.google_service_account.{role}", + f"projects/{project_id}/serviceAccounts/{sa_name}@{project_id}.iam.gserviceaccount.com", + )) + + for label, tf_addr, resource_id in imports: + check = run_cmd(f"terraform state show {tf_addr}", cwd=tf_dir, timeout=30) + if not check.ok: + console.print(f" [dim]importing[/dim] {label}") + run_cmd( + f"terraform import {tf_addr} {resource_id}", + cwd=tf_dir, + timeout=60, + ) + + result = run_cmd( + "terraform apply -auto-approve -input=false", + cwd=tf_dir, + check=True, + timeout=600, + ) + + # Capture outputs (service URLs) + out = run_cmd("terraform output -json", cwd=tf_dir, timeout=30) + outputs = {} + if out.ok: + try: + raw = json.loads(out.stdout) + outputs = {k: v.get("value", "") for k, v in raw.items()} + except (json.JSONDecodeError, AttributeError): + pass + + ctx.set("terraform_outputs", outputs) + + # Grab deployed URLs/job info from gcloud for verification. + # Workers are Cloud Run Jobs (no URL) — we report the job + scheduler. + deployed = {} + for svc_type, svc_name in [("api", "api"), ("webapp", "app")]: + svc = ctx.service(svc_type) + if not svc: + continue + url_result = run_cmd( + f"gcloud run services describe {svc_name} " + f"--region={ctx.region} --project={project_id} " + f"--format='value(status.url)'", + timeout=30, + ) + if url_result.ok and url_result.stdout.strip(): + url = url_result.stdout.strip().strip("'") + deployed[svc_name] = url + console.print(f" [green]live[/green] {svc_name} → {url}") + + # Check worker job exists + worker_svc = ctx.service("worker") + if worker_svc: + job_result = run_cmd( + f"gcloud run jobs describe {worker_svc.name} " + f"--region={ctx.region} --project={project_id} " + f"--format='value(name)'", + timeout=30, + ) + if job_result.ok and job_result.stdout.strip(): + console.print( + f" [green]live[/green] {worker_svc.name} (Cloud Run Job, schedule: {worker_svc.schedule})" + ) + deployed[worker_svc.name] = f"job:{worker_svc.name}" + + ctx.set("deployed_urls", deployed) + return {"apply_ok": True, "outputs": outputs, "deployed": deployed} + + +@register_step +class VerifyDeployment(BaseStep): + step_id = "L.3_verify_deployment" + phase = "L" + depends_on = ["L.2_terraform_apply"] + max_retries = 2 + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + deployed = ctx.get("deployed_urls", {}) + results = {} + + for svc_name, url in deployed.items(): + if not url: + results[svc_name] = "no_url" + continue + + # Workers are Cloud Run Jobs — skip HTTP health check + if url.startswith("job:"): + results[svc_name] = "ok (job)" + console.print(f" {svc_name}: [green]ok[/green] (Cloud Run Job — no URL to check)") + continue + + # Health check — try /health for api, / for others + check_path = "/health" if svc_name == "api" else "/" + check_url = f"{url}{check_path}" + + ok = False + last_code = "" + for attempt in range(6): + r = run_cmd( + f"curl -sf -o /dev/null -w '%{{http_code}}' {check_url}", + timeout=15, + ) + last_code = r.stdout.strip().strip("'") + if last_code.startswith("2"): + ok = True + break + time.sleep(5) + + results[svc_name] = "ok" if ok else f"failed (last status: {last_code})" + status = "[green]ok[/green]" if ok else f"[red]failed ({last_code})[/red]" + console.print(f" {svc_name}: {status} {check_url}") + + failures = [k for k, v in results.items() if v != "ok"] + if failures: + console.print( + f"[yellow]Warning: {', '.join(failures)} did not return 2xx. " + f"Services are deployed but may need debugging.[/yellow]" + ) + return {"verification": results} + + +@register_step +class WriteReadmeWithEndpoints(BaseStep): + """Regenerate README.md with live endpoint URLs after deployment.""" + + step_id = "L.4_write_readme" + phase = "L" + depends_on = ["L.3_verify_deployment"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + + # Get project number for Cloud Run URLs + r = run_cmd( + f"gcloud projects describe {project_id} --format='value(projectNumber)'" + ) + project_number = r.stdout.strip().strip("'") if r.ok else "PROJECTNUM" + + from platform_cli.templates.renderer import render_template + content = render_template( + "project/README.md.j2", + manifest=ctx.manifest, + ) + # Replace placeholder with real project number + content = content.replace("PROJECTNUM", project_number) + + readme_path = ctx.project_dir / "README.md" + readme_path.write_text(content) + console.print(" [green]wrote[/green] README.md with live endpoints") + return {"readme_updated": True} diff --git a/repoScaffold/src/platform_cli/steps/phase_m_pipelines.py b/repoScaffold/src/platform_cli/steps/phase_m_pipelines.py new file mode 100644 index 0000000..9690461 --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_m_pipelines.py @@ -0,0 +1,522 @@ +"""Phase M: CI/CD pipeline setup — GitHub repo, Cloud Build connection, triggers. + + M.1 grant_cloudbuild_permissions — grant Cloud Build SA the required roles + M.2 create_github_repo — create GitHub repo and push code + M.3 connect_github_to_cloudbuild — create Cloud Build GitHub connection + link repo + M.4 create_build_triggers — create per-service push triggers in Cloud Build + M.5 run_initial_builds — submit first builds via Cloud Build +""" +from __future__ import annotations + +import json +import time +from typing import Any + +from rich.console import Console + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd + +console = Console() + + +@register_step +class GrantCloudBuildPermissions(BaseStep): + step_id = "M.1_grant_cloudbuild_permissions" + phase = "M" + depends_on = ["G.2_enable_apis"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + + # Get the Cloud Build service account (project-number@cloudbuild) + r = run_cmd( + f"gcloud projects describe {project_id} --format='value(projectNumber)'" + ) + if not r.ok: + raise RuntimeError(f"Could not get project number: {r.stderr}") + project_number = r.stdout.strip().strip("'") + cb_sa = f"{project_number}@cloudbuild.gserviceaccount.com" + + roles = [ + "roles/run.admin", + "roles/iam.serviceAccountUser", + "roles/secretmanager.secretAccessor", + "roles/artifactregistry.writer", + "roles/compute.admin", + "roles/vpcaccess.admin", + "roles/iam.securityAdmin", + ] + for role in roles: + run_cmd( + f"gcloud projects add-iam-policy-binding {project_id} " + f"--member=serviceAccount:{cb_sa} " + f"--role={role} --quiet" + ) + + # The Cloud Build P4SA (per-project service agent) needs Secret + # Manager permissions to store GitHub OAuth tokens for connections. + p4sa = f"service-{project_number}@gcp-sa-cloudbuild.iam.gserviceaccount.com" + p4sa_roles = [ + "roles/secretmanager.admin", + ] + for role in p4sa_roles: + run_cmd( + f"gcloud projects add-iam-policy-binding {project_id} " + f"--member=serviceAccount:{p4sa} " + f"--role={role} --quiet" + ) + + ctx.set("cloudbuild_sa", cb_sa) + return {"cloudbuild_sa": cb_sa, "roles_granted": roles} + + +@register_step +class CreateGithubRepo(BaseStep): + """Create a GitHub repo and push the project code.""" + + step_id = "M.2_create_github_repo" + phase = "M" + depends_on = ["M.1_grant_cloudbuild_permissions"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_dir = str(ctx.project_dir) + repo_name = ctx.project_name.lower().replace(" ", "-") + + # Check gh auth + auth = run_cmd("gh auth status") + if not auth.ok: + raise RuntimeError( + "GitHub CLI is not authenticated. Run `gh auth login` and re-run." + ) + + # Extract GitHub username/org + who = run_cmd("gh api user --jq '.login'") + gh_owner = who.stdout.strip() if who.ok else "" + + # Initialize git if needed + check = run_cmd("git rev-parse --git-dir", cwd=project_dir) + if not check.ok: + run_cmd("git init", cwd=project_dir, check=True) + + # Stage and commit if there are uncommitted changes + status = run_cmd("git status --porcelain", cwd=project_dir) + if status.stdout.strip(): + run_cmd("git add -A", cwd=project_dir) + run_cmd( + 'git commit -m "Initial scaffold via platform-cli"', + cwd=project_dir, + ) + + # Create GitHub repo if it doesn't exist + repo_check = run_cmd( + f"gh repo view {gh_owner}/{repo_name} --json name", + ) + if repo_check.ok: + console.print(f" [dim]exists[/dim] {gh_owner}/{repo_name}") + else: + run_cmd( + f"gh repo create {repo_name} --private --source={project_dir} --push", + cwd=project_dir, + check=True, + timeout=60, + ) + console.print(f" [green]created[/green] {gh_owner}/{repo_name}") + + # Ensure remote is set and push + remote_check = run_cmd("git remote get-url origin", cwd=project_dir) + if not remote_check.ok: + run_cmd( + f"git remote add origin https://github.com/{gh_owner}/{repo_name}.git", + cwd=project_dir, + ) + run_cmd("git push -u origin HEAD", cwd=project_dir, timeout=120) + + ctx.set("github_owner", gh_owner) + ctx.set("github_repo", repo_name) + return {"owner": gh_owner, "repo": repo_name} + + +@register_step +class ConnectGithubToCloudBuild(BaseStep): + """Create a Cloud Build 2nd-gen connection to GitHub and link the repo.""" + + step_id = "M.3_connect_github_to_cloudbuild" + phase = "M" + depends_on = ["M.2_create_github_repo"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + region = ctx.region + gh_owner = ctx.get("github_owner", "") + gh_repo = ctx.get("github_repo", "") + connection_name = f"{ctx.project_name.lower().replace(' ', '-')}-github" + + # Check if connection already exists + check = run_cmd( + f"gcloud builds connections describe {connection_name} " + f"--project={project_id} --region={region}", + ) + + if not check.ok: + # Create the connection — this starts an OAuth flow + console.print( + f" [cyan]creating[/cyan] Cloud Build GitHub connection: {connection_name}" + ) + console.print( + " [yellow]A browser window will open for GitHub authorization.[/yellow]" + ) + console.print( + " [yellow]1. Authorize Cloud Build to access your GitHub account[/yellow]" + ) + console.print( + " [yellow]2. Install the Cloud Build app on the repository[/yellow]" + ) + + result = run_cmd( + f"gcloud builds connections create github {connection_name} " + f"--project={project_id} --region={region}", + timeout=300, + ) + if not result.ok: + raise RuntimeError( + f"Failed to create GitHub connection: {result.stderr}" + ) + + # Poll until the connection installation is COMPLETE + console.print( + " [cyan]waiting[/cyan] for GitHub app installation to complete..." + ) + deadline = time.time() + 300 # 5 min timeout + while time.time() < deadline: + desc = run_cmd( + f"gcloud builds connections describe {connection_name} " + f"--project={project_id} --region={region} " + f"--format='value(installationState.stage)'", + ) + stage = desc.stdout.strip().strip("'") + if stage == "COMPLETE": + console.print(" [green]connected[/green] GitHub ↔ Cloud Build") + break + if stage == "PENDING_USER_OAUTH": + console.print( + " [yellow]waiting for OAuth authorization in browser...[/yellow]" + ) + elif stage == "PENDING_INSTALL_APP": + console.print( + " [yellow]waiting for Cloud Build app installation on GitHub...[/yellow]" + ) + time.sleep(10) + else: + console.print( + "[yellow]Warning: Connection not yet complete. " + "Finish setup in Cloud Console → Cloud Build → Repositories.[/yellow]" + ) + return {"connection": connection_name, "status": "incomplete"} + else: + console.print(f" [dim]exists[/dim] connection: {connection_name}") + + # Link the specific GitHub repo to the connection + linked_repo_name = f"{gh_owner}-{gh_repo}" + repo_check = run_cmd( + f"gcloud builds repositories describe {linked_repo_name} " + f"--connection={connection_name} " + f"--project={project_id} --region={region}", + ) + if not repo_check.ok: + run_cmd( + f"gcloud builds repositories create {linked_repo_name} " + f"--connection={connection_name} " + f"--remote-uri=https://github.com/{gh_owner}/{gh_repo}.git " + f"--project={project_id} --region={region}", + check=True, + timeout=60, + ) + console.print(f" [green]linked[/green] repo: {gh_owner}/{gh_repo}") + else: + console.print(f" [dim]exists[/dim] linked repo: {linked_repo_name}") + + ctx.set("cloudbuild_connection", connection_name) + ctx.set("cloudbuild_repo", linked_repo_name) + return { + "connection": connection_name, + "linked_repo": linked_repo_name, + "status": "complete", + } + + +@register_step +class CreateBuildTriggers(BaseStep): + """Create Cloud Build push triggers in GCP connected to the GitHub repo.""" + + step_id = "M.4_create_build_triggers" + phase = "M" + depends_on = ["M.3_connect_github_to_cloudbuild", "I.1_write_api_build"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + region = ctx.region + connection = ctx.get("cloudbuild_connection", "") + linked_repo = ctx.get("cloudbuild_repo", "") + + if not connection or not linked_repo: + console.print( + " [yellow]skipping[/yellow] trigger creation — no repo connection" + ) + return {"triggers": [], "note": "no connection"} + + repo_path = ( + f"projects/{project_id}/locations/{region}/" + f"connections/{connection}/repositories/{linked_repo}" + ) + + # Use the project's build SA (created in Phase G) + prefix = ctx.project_name.lower().replace(" ", "-") + cb_sa = f"projects/{project_id}/serviceAccounts/{prefix}-build@{project_id}.iam.gserviceaccount.com" + + triggers_created = [] + + # Service triggers + Terraform infra trigger + svc_configs = [] + for svc_type, config, files in [ + ("api", "cloudbuild/api.yaml", ["services/api/**", "cloudbuild/api.yaml"]), + ("webapp", "cloudbuild/web.yaml", ["services/web/**", "cloudbuild/web.yaml"]), + ("worker", "cloudbuild/worker.yaml", ["services/worker/**", "cloudbuild/worker.yaml"]), + ]: + svc = ctx.service(svc_type) + if svc: + svc_configs.append((f"build-{svc.name}", config, files)) + + # Always add the Terraform infra trigger + svc_configs.append(( + "deploy-infra", + "cloudbuild/terraform.yaml", + ["infra/**", "cloudbuild/terraform.yaml"], + )) + + for trigger_name, config_path, included_files in svc_configs: + + # Check if trigger already exists + check = run_cmd( + f"gcloud builds triggers describe {trigger_name} " + f"--project={project_id} --region={region}", + ) + if check.ok: + console.print(f" [dim]exists[/dim] {trigger_name}") + triggers_created.append(trigger_name) + continue + + # 2nd-gen repo triggers require serviceAccount — use REST API + # because gcloud CLI doesn't support serviceAccount flag with + # the `create github` subcommand. + token = run_cmd("gcloud auth print-access-token", timeout=15) + if not token.ok: + raise RuntimeError("Failed to get access token for trigger creation") + + included_json = json.dumps(included_files) + payload = json.dumps({ + "name": trigger_name, + "serviceAccount": cb_sa, + "repositoryEventConfig": { + "repository": repo_path, + "push": {"branch": "^main$"}, + }, + "filename": config_path, + "includedFiles": included_files, + }) + + result = run_cmd( + f"curl -sf -X POST " + f"'https://cloudbuild.googleapis.com/v1/projects/{project_id}" + f"/locations/{region}/triggers' " + f"-H 'Authorization: Bearer {token.stdout.strip()}' " + f"-H 'Content-Type: application/json' " + f"-d '{payload}'", + timeout=30, + ) + + if result.ok and "error" not in result.stdout.lower(): + console.print(f" [green]created[/green] trigger: {trigger_name}") + triggers_created.append(trigger_name) + else: + console.print( + f" [yellow]warning[/yellow] could not create trigger " + f"{trigger_name}: {(result.stderr or result.stdout)[:300]}" + ) + + return {"triggers": triggers_created} + + +@register_step +class RunInitialBuilds(BaseStep): + """Submit the Cloud Build configs to build and deploy each service.""" + + step_id = "M.5_run_initial_builds" + phase = "M" + depends_on = ["M.4_create_build_triggers"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + region = ctx.region + project_dir = str(ctx.project_dir) + + builds_submitted = [] + svc_configs = [ + ("api", "cloudbuild/api.yaml"), + ("webapp", "cloudbuild/web.yaml"), + ("worker", "cloudbuild/worker.yaml"), + ] + + for svc_type, config_path in svc_configs: + svc = ctx.service(svc_type) + if not svc: + continue + + console.print(f" [cyan]submitting[/cyan] build for {svc.name}") + + result = run_cmd( + f"gcloud builds submit " + f"--config={config_path} " + f"--project={project_id} " + f"--region={region} " + f"--substitutions=SHORT_SHA=initial " + f"--async " + f".", + cwd=project_dir, + timeout=120, + ) + + if result.ok: + builds_submitted.append(svc.name) + console.print(f" [green]submitted[/green] {svc.name}") + else: + console.print( + f" [yellow]warning[/yellow] build submit failed for " + f"{svc.name}: {result.stderr[:200]}" + ) + + return {"builds": builds_submitted} + + +@register_step +class CreateAgentDeployKey(BaseStep): + """Generate an SSH deploy key for agent access to the GitHub repo. + + Creates an SSH key pair, adds the public key as a deploy key on the + GitHub repo (with write access), and stores the private key in GCP + Secret Manager so the agent VM can retrieve it. + """ + + step_id = "M.6_create_agent_deploy_key" + phase = "M" + depends_on = ["M.2_create_github_repo", "N.1_grant_agent_access"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + import tempfile + + project_id = ctx.project_id + project_dir = str(ctx.project_dir) + key_name = f"{ctx.project_name.lower().replace(' ', '-')}-agent-deploy-key" + secret_name = "github-deploy-key" + + # Derive owner/repo from git remote (resilient to resume) + gh_owner = ctx.get("github_owner", "") + gh_repo = ctx.get("github_repo", "") + if not gh_owner or not gh_repo: + remote = run_cmd("git remote get-url origin", cwd=project_dir) + if remote.ok: + # Parse https://github.com/owner/repo.git or git@github.com:owner/repo.git + url = remote.stdout.strip() + if "github.com" in url: + parts = url.rstrip(".git").split("github.com")[-1] + parts = parts.lstrip("/:").split("/") + if len(parts) >= 2: + gh_owner, gh_repo = parts[0], parts[1] + + if not gh_owner or not gh_repo: + console.print(" [yellow]skipping[/yellow] — no GitHub repo") + return {"deploy_key": None} + + # Check if the secret already exists (key already generated) + check = run_cmd( + f"gcloud secrets describe {secret_name} --project={project_id}" + ) + if check.ok: + console.print(f" [dim]exists[/dim] deploy key in Secret Manager") + + # Verify the deploy key is still on the repo + keys = run_cmd( + f"gh api repos/{gh_owner}/{gh_repo}/keys --jq '.[].title'" + ) + if keys.ok and key_name in keys.stdout: + console.print(f" [dim]exists[/dim] deploy key on GitHub repo") + return {"deploy_key": secret_name, "status": "exists"} + + # Generate SSH key pair + with tempfile.TemporaryDirectory() as tmpdir: + key_path = f"{tmpdir}/deploy_key" + run_cmd( + f"ssh-keygen -t ed25519 -C '{key_name}' -f {key_path} -N ''", + check=True, + timeout=15, + ) + + # Read the keys + with open(key_path) as f: + private_key = f.read() + with open(f"{key_path}.pub") as f: + public_key = f.read().strip() + + # Add public key as deploy key on GitHub repo (with write access) + result = run_cmd( + f"gh api repos/{gh_owner}/{gh_repo}/keys " + f"-f title='{key_name}' " + f"-f key='{public_key}' " + f"-f read_only=false", + timeout=30, + ) + if not result.ok: + console.print( + f" [yellow]warning[/yellow] could not add deploy key: " + f"{result.stderr[:200]}" + ) + return {"deploy_key": None, "error": result.stderr[:200]} + + console.print(f" [green]added[/green] deploy key to {gh_owner}/{gh_repo}") + + # Store private key in GCP Secret Manager + if not check.ok: + run_cmd( + f"gcloud secrets create {secret_name} " + f"--project={project_id} --replication-policy=automatic", + timeout=30, + ) + + # Write private key to a temp file and pipe to gcloud + import tempfile as tf + with tf.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) as tmp: + tmp.write(private_key) + tmp_path = tmp.name + + run_cmd( + f"gcloud secrets versions add {secret_name} " + f"--data-file={tmp_path} --project={project_id}", + check=True, + timeout=30, + ) + + import os + os.unlink(tmp_path) + + console.print( + f" [green]stored[/green] private key in Secret Manager: {secret_name}" + ) + console.print( + f" [dim]agent retrieves it via:[/dim] " + f"gcloud secrets versions access latest " + f"--secret={secret_name} --project={project_id}" + ) + + return {"deploy_key": secret_name, "status": "created"} diff --git a/repoScaffold/src/platform_cli/steps/phase_n_agents.py b/repoScaffold/src/platform_cli/steps/phase_n_agents.py new file mode 100644 index 0000000..5ddc31b --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_n_agents.py @@ -0,0 +1,156 @@ +"""Phase N: Agent access — grant service accounts access to project resources. + + N.1 grant_agent_access — grant each agent SA access to Secret Manager, + Cloud Run, Artifact Registry, and Cloud Build so agents can test + and deploy without direct GCP console access. + N.2 store_claude_oauth_token — persist the Claude Code subscription OAuth + token in Secret Manager so the agent VM can authenticate Claude Code via + a long-lived token (from `claude setup-token`, valid ~1 year, drawing on + the Claude subscription rather than metered API billing) instead of an + expiring interactive login. +""" +from __future__ import annotations + +import os +import tempfile +from typing import Any + +from rich.console import Console + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.manifest.defaults import DEFAULT_AGENT_SERVICE_ACCOUNT +from platform_cli.shell.run import run_cmd + +console = Console() + +# Secret Manager name for the Claude Code subscription OAuth token used by agents. +CLAUDE_OAUTH_SECRET_NAME = "claude-code-oauth-token" + + +def _get_claude_oauth_token() -> str: + """Retrieve the Claude Code subscription OAuth token from the environment. + + Sourced from CLAUDE_CODE_OAUTH_TOKEN (produced by `claude setup-token`) so a + token is never hard-coded or committed. Returns an empty string if unset + (the step then skips with guidance). + """ + return os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + + +@register_step +class GrantAgentAccess(BaseStep): + step_id = "N.1_grant_agent_access" + phase = "N" + depends_on = ["G.2_enable_apis", "H.1_write_secret_manifest"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + + # Merge manifest SAs with the default agent SA + agent_sas = list(ctx.manifest.agents.service_accounts) + if DEFAULT_AGENT_SERVICE_ACCOUNT and DEFAULT_AGENT_SERVICE_ACCOUNT not in agent_sas: + agent_sas.append(DEFAULT_AGENT_SERVICE_ACCOUNT) + + if not agent_sas: + console.print(" [dim]no agent service accounts configured[/dim]") + return {"agents": []} + + # Roles that give agents the ability to: + # - Read secrets (for testing with real config) — NO write access + # - View and invoke Cloud Run services + # - Submit Cloud Builds + # - Read Artifact Registry images + # - View project resources + roles = [ + "roles/secretmanager.secretAccessor", + "roles/run.invoker", + "roles/run.viewer", + "roles/cloudbuild.builds.editor", + "roles/artifactregistry.reader", + "roles/viewer", + ] + + granted = [] + for sa in agent_sas: + console.print(f" [cyan]granting[/cyan] access to {sa}") + + for role in roles: + run_cmd( + f"gcloud projects add-iam-policy-binding {project_id} " + f"--member=serviceAccount:{sa} " + f"--role={role} --quiet" + ) + + granted.append(sa) + console.print(f" [green]granted[/green] {len(roles)} roles to {sa}") + + return {"agents": granted, "roles": roles} + + +@register_step +class StoreClaudeOAuthToken(BaseStep): + """Persist the Claude Code subscription OAuth token in Secret Manager. + + Agents run Claude Code headlessly on the VM. Interactive logins expire + (taking the whole fleet down when they lapse), so we store a long-lived + subscription OAuth token (from ``claude setup-token``, valid ~1 year and + billed to the Claude subscription rather than metered API usage) in Secret + Manager and let the VM read it on every iteration. Agent SAs already receive + project-level ``secretmanager.secretAccessor`` in N.1, so no per-secret + grant is needed. + """ + + step_id = "N.2_store_claude_oauth_token" + phase = "N" + depends_on = ["G.2_enable_apis"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + token = _get_claude_oauth_token() + + if not token: + console.print( + " [yellow]skipping[/yellow] — no CLAUDE_CODE_OAUTH_TOKEN in the " + "environment.\n" + f" Create the secret later so agents can authenticate:\n" + f" [dim]printf %s \"$(claude setup-token)\" | gcloud secrets " + f"create {CLAUDE_OAUTH_SECRET_NAME} --data-file=- " + f"--replication-policy=automatic --project={project_id}[/dim]" + ) + return {"stored": False, "secret": CLAUDE_OAUTH_SECRET_NAME} + + exists = run_cmd( + f"gcloud secrets describe {CLAUDE_OAUTH_SECRET_NAME} " + f"--project={project_id}" + ).ok + if not exists: + run_cmd( + f"gcloud secrets create {CLAUDE_OAUTH_SECRET_NAME} " + f"--project={project_id} --replication-policy=automatic", + check=True, + timeout=30, + ) + + # Write the token to a temp file to avoid exposing it in process args. + with tempfile.NamedTemporaryFile( + "w", delete=False, prefix="claude-oauth-", suffix=".token" + ) as tmp: + tmp.write(token) + tmp_path = tmp.name + try: + run_cmd( + f"gcloud secrets versions add {CLAUDE_OAUTH_SECRET_NAME} " + f"--data-file={tmp_path} --project={project_id}", + check=True, + timeout=30, + ) + finally: + os.unlink(tmp_path) + + console.print( + f" [green]stored[/green] {CLAUDE_OAUTH_SECRET_NAME} " + "(agents authenticate Claude via subscription OAuth token)" + ) + return {"stored": True, "secret": CLAUDE_OAUTH_SECRET_NAME} diff --git a/repoScaffold/src/platform_cli/steps/phase_o_linear.py b/repoScaffold/src/platform_cli/steps/phase_o_linear.py new file mode 100644 index 0000000..d6f97b2 --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_o_linear.py @@ -0,0 +1,342 @@ +"""Phase O: Linear project management integration. + + O.1 ensure_linear_team — find or create the Linear team + O.2 setup_github_integration — link Linear ↔ GitHub for auto PR/branch linking + O.3 create_initial_issues — seed the board with initial project tasks +""" +from __future__ import annotations + +import json +import os +from typing import Any + +from rich.console import Console + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd + +console = Console() + + +def _get_linear_api_key() -> str: + """Retrieve the Linear API key from ~/.linear/ config or env var.""" + key = os.environ.get("LINEAR_API_KEY", "") + if key: + return key + + config_path = os.path.expanduser("~/.linear/config.json") + if os.path.exists(config_path): + try: + with open(config_path) as f: + config = json.load(f) + return config.get("apiKey", "") + except (json.JSONDecodeError, KeyError): + pass + return "" + + +def _linear_gql(query: str, variables: dict | None = None) -> dict: + """Execute a Linear GraphQL query/mutation.""" + api_key = _get_linear_api_key() + if not api_key: + raise RuntimeError( + "Linear not authenticated. Run `lin` to enter your API key, " + "or set LINEAR_API_KEY env var." + ) + + payload = json.dumps({"query": query, "variables": variables or {}}) + # Escape single quotes in payload for shell + payload_escaped = payload.replace("'", "'\\''") + + r = run_cmd( + f"curl -sf -X POST https://api.linear.app/graphql " + f"-H 'Authorization: {api_key}' " + f"-H 'Content-Type: application/json' " + f"-d '{payload_escaped}'", + timeout=30, + ) + if not r.ok: + raise RuntimeError(f"Linear API error: {r.stderr}") + + data = json.loads(r.stdout) + if "errors" in data: + raise RuntimeError(f"Linear GraphQL error: {data['errors']}") + return data.get("data", {}) + + +@register_step +class EnsureLinearTeam(BaseStep): + """Find or create the Linear team specified in the manifest.""" + + step_id = "O.1_ensure_linear_team" + phase = "O" + depends_on = ["A.3_authenticate"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not ctx.manifest.linear.enabled + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + linear = ctx.manifest.linear + team_key = linear.team_key + team_name = linear.team_name or ctx.project_name + + # Search for existing team by key + data = _linear_gql(""" + query($key: String!) { + teams(filter: { key: { eq: $key } }) { + nodes { id name key } + } + } + """, {"key": team_key}) + + teams = data.get("teams", {}).get("nodes", []) + if teams: + team = teams[0] + console.print(f" [dim]exists[/dim] team: {team['key']} ({team['name']})") + ctx.set("linear_team_id", team["id"]) + return {"team_id": team["id"], "team_key": team["key"]} + + # Create team + data = _linear_gql(""" + mutation($name: String!, $key: String!) { + teamCreate(input: { name: $name, key: $key }) { + success + team { id name key } + } + } + """, {"name": team_name, "key": team_key}) + + result = data.get("teamCreate", {}) + if not result.get("success"): + raise RuntimeError(f"Failed to create Linear team: {data}") + + team = result["team"] + console.print(f" [green]created[/green] team: {team['key']} ({team['name']})") + ctx.set("linear_team_id", team["id"]) + return {"team_id": team["id"], "team_key": team["key"]} + + +@register_step +class SetupGithubIntegration(BaseStep): + """Verify that Linear ↔ GitHub integration is active. + + Linear's GitHub integration must be installed via the Linear UI + (Settings → Integrations → GitHub). This step checks if it's + connected and provides instructions if not. + """ + + step_id = "O.2_setup_github_integration" + phase = "O" + depends_on = ["O.1_ensure_linear_team", "M.2_create_github_repo"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not ctx.manifest.linear.enabled + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + # Check for active integrations + data = _linear_gql(""" + query { + integrations { + nodes { id service } + } + } + """) + + integrations = data.get("integrations", {}).get("nodes", []) + github_connected = any( + i.get("service") == "github" for i in integrations + ) + + if github_connected: + console.print(" [dim]exists[/dim] Linear ↔ GitHub integration") + else: + workspace = ctx.manifest.linear.workspace + console.print( + f" [yellow]action needed[/yellow] Connect GitHub to Linear:\n" + f" https://linear.app/{workspace}/settings/integrations/github\n" + f" This enables automatic PR/branch linking with issues." + ) + + return {"github_connected": github_connected} + + +@register_step +class CreateInitialIssues(BaseStep): + """Seed the Linear board with initial project setup tasks.""" + + step_id = "O.3_create_initial_issues" + phase = "O" + depends_on = ["O.2_setup_github_integration"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not ctx.manifest.linear.enabled + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + team_id = ctx.get("linear_team_id") + if not team_id: + return {"issues": [], "note": "no team"} + + project_name = ctx.project_name + gh_owner = ctx.get("github_owner", "") + gh_repo = ctx.get("github_repo", "") + repo_url = f"https://github.com/{gh_owner}/{gh_repo}" if gh_owner else "" + + # Check if issues already exist for this project + team_key = ctx.manifest.linear.team_key + data = _linear_gql(""" + query($teamId: String!) { + issues(filter: { team: { id: { eq: $teamId } } }, first: 5) { + nodes { id title } + } + } + """, {"teamId": team_id}) + + existing = data.get("issues", {}).get("nodes", []) + if len(existing) >= 3: + console.print( + f" [dim]exists[/dim] {len(existing)} issues already on board" + ) + return {"issues": [i["title"] for i in existing], "note": "pre-existing"} + + # Seed issues for the project + issues_to_create = [ + { + "title": f"Set up development environment for {project_name}", + "description": ( + f"## Objective\n" + f"Get the local development environment running for {project_name}.\n\n" + f"## Steps\n" + f"1. Clone the repo: `git clone {repo_url}`\n" + f"2. Run `docker compose up` to start all services\n" + f"3. Verify API at http://localhost:3006/health\n" + f"4. Verify web app at http://localhost:3005\n\n" + f"## Acceptance Criteria\n" + f"- All 3 services start without errors\n" + f"- API health check returns 200\n" + f"- Web app loads in browser" + ), + "priority": 2, + }, + { + "title": f"Review and document API endpoints for {project_name}", + "description": ( + f"## Objective\n" + f"Document all API endpoints with request/response schemas.\n\n" + f"## Steps\n" + f"1. Review `services/api/src/routes/` for all route definitions\n" + f"2. Create API documentation in `services/api/README.md`\n" + f"3. Include auth requirements, request params, response shapes\n\n" + f"## Branch\n" + f"Create branch: `docs/api-documentation`\n\n" + f"## Acceptance Criteria\n" + f"- Every endpoint documented with method, path, auth, params, response\n" + f"- README includes example curl commands" + ), + "priority": 3, + }, + { + "title": f"Set up monitoring and alerting for {project_name}", + "description": ( + f"## Objective\n" + f"Configure GCP monitoring for all Cloud Run services.\n\n" + f"## Steps\n" + f"1. Add uptime checks for API /health endpoint\n" + f"2. Configure alert policies for 5xx error rate > 1%\n" + f"3. Set up log-based metrics for key events\n" + f"4. Add Terraform resources in `infra/terraform/`\n\n" + f"## Branch\n" + f"Create branch: `feat/monitoring-alerting`\n\n" + f"## Acceptance Criteria\n" + f"- Uptime check pings /health every 60s\n" + f"- Alert fires on sustained 5xx errors\n" + f"- Changes deployed via Terraform pipeline" + ), + "priority": 3, + }, + ] + + created = [] + for issue in issues_to_create: + data = _linear_gql(""" + mutation($teamId: String!, $title: String!, $description: String, $priority: Int) { + issueCreate(input: { + teamId: $teamId, + title: $title, + description: $description, + priority: $priority + }) { + success + issue { id identifier title url } + } + } + """, { + "teamId": team_id, + "title": issue["title"], + "description": issue["description"], + "priority": issue["priority"], + }) + + result = data.get("issueCreate", {}) + if result.get("success"): + i = result["issue"] + created.append(i["identifier"]) + console.print( + f" [green]created[/green] {i['identifier']}: {i['title']}" + ) + + return {"issues": created} + + +@register_step +class StoreLinearKeyInSecretManager(BaseStep): + """Store the Linear API key in GCP Secret Manager for the agent VM.""" + + step_id = "O.4_store_linear_key" + phase = "O" + depends_on = ["O.1_ensure_linear_team"] + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not ctx.manifest.linear.enabled + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + secret_name = "linear-api-key" + api_key = _get_linear_api_key() + + if not api_key: + console.print( + " [yellow]skipping[/yellow] — no Linear API key available" + ) + return {"stored": False} + + # Create secret if it doesn't exist + check = run_cmd( + f"gcloud secrets describe {secret_name} --project={project_id}" + ) + if not check.ok: + run_cmd( + f"gcloud secrets create {secret_name} " + f"--project={project_id} --replication-policy=automatic", + timeout=30, + ) + + # Store the key + import tempfile + with tempfile.NamedTemporaryFile(mode="w", suffix=".key", delete=False) as f: + f.write(api_key) + tmp_path = f.name + + run_cmd( + f"gcloud secrets versions add {secret_name} " + f"--data-file={tmp_path} --project={project_id}", + check=True, + timeout=30, + ) + + os.unlink(tmp_path) + console.print( + f" [green]stored[/green] Linear API key in Secret Manager: {secret_name}" + ) + return {"stored": True, "secret": secret_name} diff --git a/repoScaffold/src/platform_cli/steps/phase_p_library_pipeline.py b/repoScaffold/src/platform_cli/steps/phase_p_library_pipeline.py new file mode 100644 index 0000000..325a10d --- /dev/null +++ b/repoScaffold/src/platform_cli/steps/phase_p_library_pipeline.py @@ -0,0 +1,197 @@ +"""Phase P: Library (npm) CI/CD pipeline. + +Library-only steps (self-skip for `app` projects). They create a slim GCP +project, an npm-token secret, and a Cloud Build trigger that runs the +`cloudbuild/publish.yaml` pipeline on push to main. + + P.1 lib_gcp_project — create/reuse the GCP project, link billing, enable + only cloudbuild + secretmanager APIs + P.2 lib_npm_secret — create the npm-token secret (value from NPM_TOKEN env + if present) and grant the build SAs access + P.3 lib_build_trigger — create the push-to-main Cloud Build trigger for the + existing GitHub repo (prints connect instructions if + the repo is not yet linked to Cloud Build) +""" +from __future__ import annotations + +import os +import tempfile +from typing import Any + +from rich.console import Console + +from platform_cli.engine.context import ScaffoldContext +from platform_cli.engine.registry import register_step +from platform_cli.engine.step import BaseStep +from platform_cli.shell.run import run_cmd +from platform_cli.steps._gcp_org import ensure_project_in_org, resolve_org_id + +console = Console() + + +class _LibraryStep(BaseStep): + """Base for library-only steps.""" + phase = "P" + + def should_skip(self, ctx: ScaffoldContext) -> bool: + return super().should_skip(ctx) or not ctx.manifest.project.is_library + + +@register_step +class LibGcpProject(_LibraryStep): + step_id = "P.1_lib_gcp_project" + depends_on = ["A.3_authenticate"] + max_retries = 0 + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + + who = run_cmd("gcloud config get-value account") + if not who.ok or not who.stdout.strip(): + raise RuntimeError("No gcloud account. Run `gcloud auth login` and retry.") + + # Which org should this project live under? (env → manifest → detect) + org_id = resolve_org_id(ctx) + + if run_cmd(f"gcloud projects describe {project_id}").ok: + console.print(f" [dim]exists[/dim] project {project_id}") + run_cmd(f"gcloud config set project {project_id}", check=True) + # Adopt an orphaned (no-org) project into the org. + ensure_project_in_org(project_id, org_id) + else: + create = f"gcloud projects create {project_id} --name={ctx.project_name}" + if org_id: + create += f" --organization={org_id}" + res = run_cmd(create) + if not res.ok: + raise RuntimeError( + f"Could not create project '{project_id}'. Set an existing " + f"project in the manifest or create it manually.\n{res.stderr}" + ) + console.print(f" [green]created[/green] project {project_id}") + run_cmd(f"gcloud config set project {project_id}", check=True) + ensure_project_in_org(project_id, org_id) + billing = run_cmd( + "gcloud billing accounts list --filter='OPEN=true' " + "--format='value(ACCOUNT_ID)' --limit=1" + ) + bid = billing.stdout.strip().split("\n")[0] if billing.ok else "" + if bid and run_cmd( + f"gcloud billing projects link {project_id} --billing-account={bid}" + ).ok: + console.print(f" [green]linked billing[/green] {bid}") + + # Libraries need only Cloud Build + Secret Manager (no run/registry/vpc). + for api in ("cloudbuild.googleapis.com", "secretmanager.googleapis.com"): + run_cmd(f"gcloud services enable {api} --project={project_id}", check=True) + console.print(" [green]enabled[/green] cloudbuild + secretmanager APIs") + return {"project_id": project_id} + + +@register_step +class LibNpmSecret(_LibraryStep): + step_id = "P.2_lib_npm_secret" + depends_on = ["P.1_lib_gcp_project"] + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + name = "npm-token" + + if not run_cmd(f"gcloud secrets describe {name} --project={project_id}").ok: + run_cmd( + f"gcloud secrets create {name} --project={project_id} " + "--replication-policy=automatic", + check=True, + ) + console.print(f" [green]created[/green] secret {name}") + else: + console.print(f" [dim]exists[/dim] secret {name}") + + token = os.environ.get("NPM_TOKEN", "").strip() + if token: + with tempfile.NamedTemporaryFile("w", delete=False, suffix=".tok") as f: + f.write(token) + tmp = f.name + try: + run_cmd( + f"gcloud secrets versions add {name} --data-file={tmp} " + f"--project={project_id}", + check=True, + ) + console.print(" [green]stored[/green] NPM_TOKEN value") + finally: + os.unlink(tmp) + else: + console.print( + " [yellow]note[/yellow] npm-token has no value yet — add one:\n" + f" [dim]printf %s \"$NPM_TOKEN\" | gcloud secrets versions add " + f"{name} --data-file=- --project={project_id}[/dim]" + ) + + # Grant the build service accounts read access to the token. + num = run_cmd( + f"gcloud projects describe {project_id} --format='value(projectNumber)'" + ).stdout.strip() + if num: + for sa in ( + f"{num}@cloudbuild.gserviceaccount.com", + f"{num}-compute@developer.gserviceaccount.com", + ): + run_cmd( + f"gcloud secrets add-iam-policy-binding {name} --project={project_id} " + f"--member=serviceAccount:{sa} " + "--role=roles/secretmanager.secretAccessor --quiet" + ) + return {"secret": name, "has_value": bool(token)} + + +@register_step +class LibBuildTrigger(_LibraryStep): + step_id = "P.3_lib_build_trigger" + depends_on = ["P.1_lib_gcp_project"] + max_retries = 0 + + def run(self, ctx: ScaffoldContext) -> dict[str, Any]: + project_id = ctx.project_id + repo = ctx.manifest.project.repo.strip() + if not repo or "/" not in repo: + console.print( + " [yellow]skip[/yellow] no project.repo set (owner/name) — " + "cannot wire the Cloud Build trigger." + ) + return {"trigger": None, "reason": "no repo"} + owner, name = repo.split("/", 1) + trigger_name = f"{name}-publish" + build_config = "cloudbuild/publish.yaml" + + # Already exists? + existing = run_cmd( + f"gcloud builds triggers describe {trigger_name} --project={project_id} " + "--region=global" + ) + if existing.ok: + console.print(f" [dim]exists[/dim] trigger {trigger_name}") + return {"trigger": trigger_name} + + res = run_cmd( + f"gcloud builds triggers create github " + f"--name={trigger_name} " + f"--repo-owner={owner} --repo-name={name} " + f'--branch-pattern="^main$" ' + f"--build-config={build_config} " + f"--project={project_id} --region=global" + ) + if res.ok: + console.print(f" [green]created[/green] trigger {trigger_name} (push to main)") + return {"trigger": trigger_name} + + # Most common failure: the repo is not connected to Cloud Build yet. + console.print( + f" [yellow]action needed[/yellow] connect {owner}/{name} to Cloud Build, " + "then re-run:\n" + f" https://console.cloud.google.com/cloud-build/triggers/connect?project={project_id}\n" + f" Install the Cloud Build GitHub App for {owner}/{name}, then re-run " + "scaffold (or create the trigger manually with the same flags).\n" + f" [dim]{res.stderr.strip()[:300]}[/dim]" + ) + return {"trigger": None, "reason": "repo not connected"} diff --git a/repoScaffold/src/platform_cli/templates/__pycache__/__init__.cpython-313.pyc b/repoScaffold/src/platform_cli/templates/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 51de1111a3bc10d302b2efec212adef65ba1a50a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 183 zcmXwz%?ScA5QP(0L4+;Di_?Huf)~+i2>Dq8i3u}V!9py;UaVj}T7d-bCZO*y?|Zz% zo6`3yR`vKiUGrBvfAJrbd4(Gr*qZO}3}-c!R!t{3l0gD3FHR4Vl0*#@M^pw#p-Y(n zO^OF4t&P5Rs33WnZNwmycaTkJ*$@cf&MphFN!Mw)4N;HY>~K+M+8fm1-Eq#btMmm4 Cm@-%Z diff --git a/repoScaffold/src/platform_cli/templates/__pycache__/filters.cpython-313.pyc b/repoScaffold/src/platform_cli/templates/__pycache__/filters.cpython-313.pyc deleted file mode 100644 index 6ec1411234e736a33c2b1efb8f84150fe9801b24..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1376 zcmd^9%}>-&5Pz><Teob~;8%<$%?iY3SsGL@A!<UrLF7QQ7r`{8`<5*1wwrnF8urA| zaDfZS9=Y<rc!2DISoOroo5`XF55{?=EW$+-k51Cb@4YuOZ{GZ7+AkFH1jYXTvHp`1 zax4~SBAo)-pFyxo90J6ljx<l_B#=yj<#`!oC+8@4$(jNRA~i)SA}vMQs%+_<r!Qk3 zg-vs=9;|tf%v!w>G2k;M54?5e`W|QF<~m#PRx-?jw%hh#;)}?@qKV-JK^S>aJq-AQ zk9iJ6MI;kK`|lC#lBL1HVwU}213@v(ze1+aLr6gXXfHcTg`U|jirX&9RJQ($SgIra z7Atot5H1~QDtp@qv9yIwZfTh>m*12U>i)<*T$(Mveq(iHcX_fSxutz2k$@#Kk5>}4 z5x!-Rs62vtYhOy#w%3SRLOB;zFwLZ5S2$MOu6pfyb=Bi752`i~WAK^HeRPVr4XhP9 zzE`V-4Lk$iZZ*897DChY8+AKkO|fR&&e)r1Z6x_~0^u6oB;Ledq1q&$^!&EArM>HP zAMdF>eQa~~d}n%ZvZs$8>7_%x)YHp&WE8fix2AVSyTe_tXP8IE*r74D$9qQUKq>Vx zAo+h-*N}BrkX{B2AKsD6g79()M0H^ZRmLh@MV#c%2M2E8MnM={<Y1%wpc`Yw|A7Oz zj$towAZ93?ye9~7@&;3h^OfPnY|hYNAZPP}9ZnZ4DQ%lXjaw}S7KJo<;ccEiE|>iY z;5P0J<Hv8I!tOF|?y#P5@5m?~8pWP5a-fW)DOySz1IHqQL<1JZ5WpSuv2sbyb*rK8 zx=G%3YcZZM*9DOx=t7AGPAiyRwolS{r)lWVnqf6=uorM2jYNK2fUh#8^aFYRnG}!7 Tl@m>-<0nI?z70`2n!5i2wbKMz diff --git a/repoScaffold/src/platform_cli/templates/__pycache__/renderer.cpython-313.pyc b/repoScaffold/src/platform_cli/templates/__pycache__/renderer.cpython-313.pyc deleted file mode 100644 index 9b190216c455290667379bc4ead7420b8713b3df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1822 zcmbtUQBNC35Z<%bKHFylM}Q=R(!-5JF9@5`CMZgZR0%58P5}8JQWaOH!(Pnc^PRPK zK*B?0o>KLtm0Bbo)3>%y{TY4C1x0qZsuU@$s=g(n?Q>_(z6KHU)H!K(cII|wcD|WC z&SX*uX!@UD%u5)d=X?{A*cF812LeI|NJA89f)*(Xg$Ru(T#go_1x#@u<6?})Ko={< z3nCQ@37RM*X;MLDoJHl-Rq(;7biTWu<X3x}(uI@SuKa&=T6|hUh-S*M(%9>sA*)zv z{7n4-*fL?p;i`$jm$;6pTo%i6>GJDmwd5oy%ZWEwQ-L-42CKxaTA!94MO&Dj)B3ec zAPv>9I<PlYFzqd4Rw4F|NgcaEY)_d|D6y-A>Yk%-m=;ksiPa=D_b1Ox6_{k$w&NL| z>DVr8@rvPXE~!zLa&4nd^orpUma3C=W4$Yj;9TD^C`)>z(X<SYxT`8=amz6v3LCBy z($qa_n3ieRbertLN|@wPv!SnBPNnX$jOD_%+1t_>Btqky@ouX0ngB$ExC!79j#of9 zKzF$=pi(rHUae~=gu#xiMM}`Brzr~!IfK@O1>u@-8L5%IY$>RXVko(-StY!X;yIw< zIoh?{_XvuCb+|Q4*NCTwKe2eXu3*iCI^8xJgvIN-2Ccaadr%*jC#on4yOUpY33c;T zW5=x447X{}YTk9WX@%t73Y5ch^OQ6ltzv9!I2PDwCC}@-;n0R&vCMoJh5yG91rVF} zS$_y`4-Gm30r`-)y!Tq@H>tlZjrr2p;b%wERBNFl^?!NeiyOYA9F72cjFn^f4^<+Z z_lV;Vl-&m5018$LEhpBqAb{D1>AxOP_!$n~DZ0x&2egJh;7~+36d`O1szp`2H+?oJ zhFw*5sp%2L^f*FSs>JoK<W|)P6QP*U>n=->d#3B@PJM~DWLL{#je6CjEEOCE{O)-{ zjnpU|gj5*je&{(6L#*g9*Y|~m?amE-8<hMhaPz=g=owBtEbbTEc-Y6oht=;^A9HE8 z1M21d<!_cB-S&rW{&)x2Q~V+2FPe(G0izI(caOJCZK`lXJTL?@<sm5VT{;1b;U&i= z>M#>^z3NnSouzbrV;hbVU8lUa_~pG#%Ull*vG`W-1hDawt&{t&b#m|oc+iHYhK?n` zD?AS1U!=r!tQ|rxap!&d>f-H{TOX}1YWl(_B_=JtdRo}P$8bcu?}GPR!Sr`;&>v(K z2IB1))Q<LXi5H?<-^9&^Q{A@6Jk5cQPrdsCuoqE55PnAQ{fe@`qYKYb<_}cxQQ>7A z4UT-Z`So~Pp7iC(BUx?BGrm02mgjtVt}V~|@_buf^yS5$BwyBAeb2;9TfF#0yx5WC zwv>G$Wxsv@(SxIRt{q8pozd~mz))v!<Yh9Jh_vvFG#Y=mg&(H()1Q}pG}?J<^5kxG WqPP3hN9X^FM}>*M(@|kQxXZtL{-<gH diff --git a/repoScaffold/templates/agents/ops/PROMPT.md.j2 b/repoScaffold/templates/agents/ops/PROMPT.md.j2 index 9fe456c..9c93170 100644 --- a/repoScaffold/templates/agents/ops/PROMPT.md.j2 +++ b/repoScaffold/templates/agents/ops/PROMPT.md.j2 @@ -4,8 +4,8 @@ Your job is to monitor production health, detect errors, and create Linear issue ## Step 1: Orient -1. Read `AGENTS.md` for project structure and conventions. -2. `git checkout main && git pull` — stay current with the codebase. +1. Read `agents/AGENTS.md` for project structure and conventions. +2. You start in your OWN private worktree, already scrubbed to the latest `main` (detached HEAD) — do **not** run `git checkout main` (main is checked out in the shared clone and it will fail); you're already current. ## Step 2: Check production health @@ -66,15 +66,17 @@ TEAM_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ -d '{"query": "{ teams(filter: { key: { eq: \"{{ manifest.linear.team_key }}\" } }) { nodes { id } } }"}' \ https://api.linear.app/graphql | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['teams']['nodes'][0]['id'])") -# Get the Backlog state ID -BACKLOG_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ +# Get the Todo (unstarted) state ID. [ops] issues are production/pipeline +# breakages that need immediate action, so they skip Backlog/owner-promotion +# and go straight to Todo for a developer to pick up. +TODO_ID=$(curl -s -H "Authorization: $LINEAR_KEY" \ -H "Content-Type: application/json" \ - -d "{\"query\": \"{ team(id: \\\"$TEAM_ID\\\") { states(filter: { type: { eq: \\\"backlog\\\" } }) { nodes { id } } } }\"}" \ + -d "{\"query\": \"{ team(id: \\\"$TEAM_ID\\\") { states(filter: { type: { eq: \\\"unstarted\\\" } }) { nodes { id } } } }\"}" \ https://api.linear.app/graphql | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['team']['states']['nodes'][0]['id'])") curl -s -H "Authorization: $LINEAR_KEY" \ -H "Content-Type: application/json" \ - -d "{\"query\": \"mutation { issueCreate(input: { teamId: \\\"$TEAM_ID\\\", stateId: \\\"$BACKLOG_ID\\\", title: \\\"[ops] <title>\\\", description: \\\"<description with error logs, timestamps, and frequency>\\\", priority: 1 }) { success issue { identifier url } } }\"}" \ + -d "{\"query\": \"mutation { issueCreate(input: { teamId: \\\"$TEAM_ID\\\", stateId: \\\"$TODO_ID\\\", title: \\\"[ops] <title>\\\", description: \\\"<description with error logs, timestamps, and frequency>\\\", priority: 1 }) { success issue { identifier url } } }\"}" \ https://api.linear.app/graphql ``` @@ -96,7 +98,7 @@ If any endpoint returns 5xx, check if an issue already exists before creating on ## Rules 1. **Read-only.** Never modify code, push commits, or create PRs. Your only write action is creating Linear issues. -2. **Backlog only.** Always create issues in Backlog — the owner promotes them to Todo. +2. **[ops] issues go straight to Todo.** These are urgent production/pipeline fixes (broken builds/deploys, failing CI, out-of-sync lockfiles, infra breakage), so create them directly in **Todo** (the `unstarted` state) — do NOT put them in Backlog and do NOT wait for owner promotion. 3. **Prefix issues with `[ops]`** so humans can distinguish ops-created issues from developer-created ones. 4. **No duplicates.** Always search Linear before creating an issue. If a similar issue exists in any state, don't create another. 5. **Be concise.** Issue descriptions should include: error message, affected service, frequency, first/last occurrence timestamp, and a snippet of the relevant log. diff --git a/repoScaffold/templates/agents/reviewer/PROMPT.md.j2 b/repoScaffold/templates/agents/reviewer/PROMPT.md.j2 index 068a9d1..2f59542 100644 --- a/repoScaffold/templates/agents/reviewer/PROMPT.md.j2 +++ b/repoScaffold/templates/agents/reviewer/PROMPT.md.j2 @@ -4,8 +4,8 @@ Your job is to review open pull requests for correctness, security, and code qua ## Step 1: Orient -1. Read `AGENTS.md` for project structure and conventions. -2. `git checkout main && git pull` — always start clean on main. +1. Read `agents/AGENTS.md` for project structure and conventions. +2. You start in your OWN private worktree, already scrubbed to the latest `main` (detached HEAD) — do **not** run `git checkout main` (main is checked out in the shared clone and it will fail). To inspect a PR, use `gh pr checkout <n>` inside your worktree. ## Step 2: Find open PRs to review @@ -87,3 +87,5 @@ Otherwise use `--comment`. 5. **Don't block on style.** Only flag style issues if they cause ambiguity or bugs. The developer agent follows its own conventions. 6. **Check for the patterns that cause real outages:** unhandled promise rejections, missing `await`, wrong MongoDB driver usage, secrets in code, broken error handling. 7. **Never approve your own PRs.** If a PR was authored by an agent with the same service account, still review it objectively but add a note that a human should also approve. +8. **Never make direct GCP changes.** Read-only access only. +9. **Never run `npm install` or `npm ci`.** You are reviewing code, not building it. diff --git a/repoScaffold/templates/claude/settings.library-node.json.j2 b/repoScaffold/templates/claude/settings.library-node.json.j2 new file mode 100644 index 0000000..b50ae4f --- /dev/null +++ b/repoScaffold/templates/claude/settings.library-node.json.j2 @@ -0,0 +1,12 @@ +{ + "project": "{{ manifest.project.name }}", + "description": "Publishable Node/npm package ({{ manifest.project.kind }})", + "conventions": [ + "This is a library, not a web app — no services, Docker, Terraform, or Cloud Run", + "The package lives in {{ manifest.library.package_dir }}/ — cd there before running package scripts", + "Build with `{{ manifest.library.build_cmd }}`; it must pass before any PR", + "The exported public API is a contract — breaking it requires a major version bump", + "Agents do not publish or bump the published version; releases are maintainer-run", + "Keep peer dependencies (react/react-dom) as peers, never runtime deps" + ] +} diff --git a/repoScaffold/templates/cloudbuild/api.yaml.j2 b/repoScaffold/templates/cloudbuild/api.yaml.j2 index 3943dda..c0ca7ba 100644 --- a/repoScaffold/templates/cloudbuild/api.yaml.j2 +++ b/repoScaffold/templates/cloudbuild/api.yaml.j2 @@ -1,17 +1,23 @@ +{% set prefix = manifest.project.name | lower | replace(' ', '-') %} +{% set repo = prefix + '-docker' %} +{% set region = manifest.project.cloud.region %} steps: # Build the Docker image - name: "gcr.io/cloud-builders/docker" args: - "build" - "-t" - - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:$SHORT_SHA" + - "-t" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:latest" - "./services/api" # Push to Artifact Registry - name: "gcr.io/cloud-builders/docker" args: - "push" - - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "--all-tags" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}" # Deploy to Cloud Run - name: "gcr.io/google.com/cloudsdktool/cloud-sdk" @@ -20,14 +26,16 @@ steps: - "run" - "deploy" - "{{ service.name }}" - - "--image={{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" - - "--region={{ manifest.project.cloud.region }}" + - "--image={{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:$SHORT_SHA" + - "--region={{ region }}" - "--platform=managed" - "--allow-unauthenticated" + - "--service-account={{ prefix }}-runtime@$PROJECT_ID.iam.gserviceaccount.com" - "--set-secrets=MONGODB_URI=mongodb-uri:latest,DB_NAME=db-name:latest" images: - - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:$SHORT_SHA" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:latest" options: logging: CLOUD_LOGGING_ONLY diff --git a/repoScaffold/templates/cloudbuild/npm-publish.yaml.j2 b/repoScaffold/templates/cloudbuild/npm-publish.yaml.j2 new file mode 100644 index 0000000..de91103 --- /dev/null +++ b/repoScaffold/templates/cloudbuild/npm-publish.yaml.j2 @@ -0,0 +1,43 @@ +# {{ manifest.project.name }} — build, test, and publish the npm package. +# Triggered on push to main (see the Cloud Build trigger). Publishing is +# idempotent: if package@version is already on the registry, it is skipped, +# so an unbumped merge is a no-op instead of a failed build. +steps: + - name: 'node:22' + id: build-and-test + entrypoint: bash + dir: '{{ manifest.library.package_dir }}' + args: + - -c + - | + set -euo pipefail + npm ci + {{ manifest.library.build_cmd }} + npm test --if-present + + - name: 'node:22' + id: publish + entrypoint: bash + dir: '{{ manifest.library.package_dir }}' + secretEnv: ['NPM_TOKEN'] + args: + - -c + - | + set -euo pipefail + printf '//registry.npmjs.org/:_authToken=%s\n' "$$NPM_TOKEN" > ~/.npmrc + PKG=$$(node -p "require('./package.json').name") + VER=$$(node -p "require('./package.json').version") + if npm view "$$PKG@$$VER" version >/dev/null 2>&1; then + echo "$$PKG@$$VER already published — skipping publish." + else + echo "Publishing $$PKG@$$VER ..." + npm publish --access public + fi + +availableSecrets: + secretManager: + - versionName: projects/{{ manifest.project.cloud.project_id }}/secrets/npm-token/versions/latest + env: 'NPM_TOKEN' + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/repoScaffold/templates/cloudbuild/web.yaml.j2 b/repoScaffold/templates/cloudbuild/web.yaml.j2 index 6827cd4..70515f0 100644 --- a/repoScaffold/templates/cloudbuild/web.yaml.j2 +++ b/repoScaffold/templates/cloudbuild/web.yaml.j2 @@ -1,17 +1,23 @@ +{% set prefix = manifest.project.name | lower | replace(' ', '-') %} +{% set repo = prefix + '-docker' %} +{% set region = manifest.project.cloud.region %} steps: # Build the Docker image - name: "gcr.io/cloud-builders/docker" args: - "build" - "-t" - - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:$SHORT_SHA" + - "-t" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:latest" - "./services/web" # Push to Artifact Registry - name: "gcr.io/cloud-builders/docker" args: - "push" - - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "--all-tags" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}" # Deploy to Cloud Run - name: "gcr.io/google.com/cloudsdktool/cloud-sdk" @@ -20,13 +26,15 @@ steps: - "run" - "deploy" - "{{ service.name }}" - - "--image={{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" - - "--region={{ manifest.project.cloud.region }}" + - "--image={{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:$SHORT_SHA" + - "--region={{ region }}" - "--platform=managed" - "--allow-unauthenticated" + - "--service-account={{ prefix }}-runtime@$PROJECT_ID.iam.gserviceaccount.com" images: - - "{{ manifest.project.cloud.region }}-docker.pkg.dev/{{ manifest.project.cloud.project_id }}/{{ manifest.project.name | lower | replace(' ', '-') }}-docker/{{ service.name }}:$COMMIT_SHA" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:$SHORT_SHA" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:latest" options: logging: CLOUD_LOGGING_ONLY diff --git a/repoScaffold/templates/cloudbuild/worker.yaml.j2 b/repoScaffold/templates/cloudbuild/worker.yaml.j2 new file mode 100644 index 0000000..d217fa4 --- /dev/null +++ b/repoScaffold/templates/cloudbuild/worker.yaml.j2 @@ -0,0 +1,50 @@ +{% set prefix = manifest.project.name | lower | replace(' ', '-') %} +{% set repo = prefix + '-docker' %} +{% set region = manifest.project.cloud.region %} +steps: + # Build the Docker image + - name: "gcr.io/cloud-builders/docker" + args: + - "build" + - "-t" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:$SHORT_SHA" + - "-t" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:latest" + - "./services/worker" + + # Push to Artifact Registry + - name: "gcr.io/cloud-builders/docker" + args: + - "push" + - "--all-tags" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}" + + # Deploy as Cloud Run Job (one-shot, scheduled by Cloud Scheduler) + - name: "gcr.io/google.com/cloudsdktool/cloud-sdk" + entrypoint: "bash" + args: + - "-c" + - | + IMAGE="{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:$SHORT_SHA" + if gcloud run jobs describe {{ service.name }} --region={{ region }} >/dev/null 2>&1; then + gcloud run jobs update {{ service.name }} \ + --image=$IMAGE \ + --region={{ region }} \ + --service-account={{ prefix }}-runtime@$PROJECT_ID.iam.gserviceaccount.com + else + gcloud run jobs create {{ service.name }} \ + --image=$IMAGE \ + --region={{ region }} \ + --service-account={{ prefix }}-runtime@$PROJECT_ID.iam.gserviceaccount.com \ + --task-timeout={{ service.timeout_seconds }}s \ + --max-retries=1 \ + --parallelism={{ service.parallelism }} \ + --tasks=1 + fi + +images: + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:$SHORT_SHA" + - "{{ region }}-docker.pkg.dev/$PROJECT_ID/{{ repo }}/{{ service.name }}:latest" + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/repoScaffold/templates/infra/terraform/modules/cloudrun/main.tf.j2 b/repoScaffold/templates/infra/terraform/modules/cloudrun/main.tf.j2 index 4a46514..5a8e324 100644 --- a/repoScaffold/templates/infra/terraform/modules/cloudrun/main.tf.j2 +++ b/repoScaffold/templates/infra/terraform/modules/cloudrun/main.tf.j2 @@ -1,5 +1,6 @@ {% for svc in manifest.services %} -{% if svc.enabled and svc.type in ['api', 'webapp', 'worker'] %} +{% if svc.enabled and svc.type in ['api', 'webapp'] %} +# ── {{ svc.name }} ({{ svc.type }}) — Cloud Run Service ── resource "google_cloud_run_v2_service" "{{ svc.name }}" { name = "{{ svc.name }}" location = var.region @@ -39,7 +40,7 @@ resource "google_cloud_run_v2_service" "{{ svc.name }}" { resources { limits = { cpu = "1" - memory = "{{ '512Mi' if svc.type == 'api' else '256Mi' }}" + memory = "512Mi" } } } @@ -57,6 +58,75 @@ resource "google_cloud_run_v2_service_iam_member" "{{ svc.name }}_public" { role = "roles/run.invoker" member = "allUsers" } +{% endif %} + +{% if svc.enabled and svc.type == 'worker' %} +# ── {{ svc.name }} (worker) — Cloud Run Job + Cloud Scheduler ── +# Schedule: {{ svc.schedule }} +resource "google_cloud_run_v2_job" "{{ svc.name }}" { + name = "{{ svc.name }}" + location = var.region + + template { + parallelism = {{ svc.parallelism }} + task_count = 1 + + template { + service_account = var.runtime_service_account + timeout = "{{ svc.timeout_seconds }}s" + max_retries = 1 + containers { + image = "{{ '${var.region}' }}-docker.pkg.dev/{{ '${var.project_id}' }}/{{ '${var.artifact_repo}' }}/{{ svc.name }}:latest" + + resources { + limits = { +{% if svc.gpu and svc.gpu != 'none' %} + cpu = "8" + memory = "32Gi" + "nvidia.com/gpu" = "1" +{% else %} + cpu = "1" + memory = "512Mi" +{% endif %} + } + } + } + } + } + + # Image and resource limits are managed out-of-band via Cloud Build + # and gcloud CLI (e.g. for GPU attachment that isn't yet in the TF + # provider). Terraform manages the base config. + lifecycle { + ignore_changes = [ + template[0].template[0].containers[0].image, + template[0].template[0].containers[0].resources, + ] + } +} + +# Cloud Scheduler triggers the job on the configured cron schedule. +resource "google_cloud_scheduler_job" "{{ svc.name }}" { + name = "{{ svc.name }}-trigger" + description = "Triggers the {{ svc.name }} Cloud Run Job" + schedule = "{{ svc.schedule }}" + region = var.region + time_zone = "UTC" + + retry_config { + retry_count = 1 + } + + http_target { + http_method = "POST" + uri = "https://{{ '${var.region}' }}-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/{{ '${var.project_id}' }}/jobs/${google_cloud_run_v2_job.{{ svc.name }}.name}:run" + + oauth_token { + service_account_email = var.runtime_service_account + scope = "https://www.googleapis.com/auth/cloud-platform" + } + } +} {% endif %} {% endfor %} diff --git a/repoScaffold/templates/project/AGENTS.library-node.md.j2 b/repoScaffold/templates/project/AGENTS.library-node.md.j2 new file mode 100644 index 0000000..cafc077 --- /dev/null +++ b/repoScaffold/templates/project/AGENTS.library-node.md.j2 @@ -0,0 +1,71 @@ +# {{ manifest.project.name }} — Architecture & Conventions + +## Project Overview + +**{{ manifest.project.name }}** is a **publishable Node/npm package** (`kind: {{ manifest.project.kind }}`). It is a library, not a deployed web app — there is no server, database, or cloud runtime. The deliverable is a versioned package on the npm registry. + +- **Environment:** {{ manifest.project.env }} +- **Package directory:** `{{ manifest.library.package_dir }}/` (this is where `package.json` lives) +- **Build:** `cd {{ manifest.library.package_dir }} && {{ manifest.library.build_cmd }}` +- **Test:** `cd {{ manifest.library.package_dir }} && {{ manifest.library.test_cmd }}` +- **Publish:** `cd {{ manifest.library.package_dir }} && {{ manifest.library.publish_cmd }}` (maintainer-run; agents do NOT publish) +{% if manifest.linear.enabled %} +- **Project Board:** [Linear — {{ manifest.linear.team_key }}](https://linear.app/{{ manifest.linear.workspace }}/team/{{ manifest.linear.team_key }}/active) +{% endif %} + +## Directory Structure + +``` +{{ manifest.project.name }}/ + {{ manifest.library.package_dir }}/ # the published package: package.json, source, build config + agents/ # autonomous agent prompts + loop (this file lives here) +``` + +The library source is under `{{ manifest.library.package_dir }}/`. Do not assume a repo-root `package.json` — always `cd {{ manifest.library.package_dir }}` before running package scripts. + +## Conventions + +- **This is a library.** No `services/`, no Docker, no Terraform, no Cloud Run. Do not scaffold app infrastructure. +- **Public API is a contract.** The package's exported symbols (see `{{ manifest.library.package_dir }}/src/index.*`) are the public surface. Removing or renaming an export is a **breaking change** — treat it accordingly (see Versioning). +- **Keep peer dependencies as peers.** Do not move `react`/`react-dom` (or other peers) into `dependencies`. +- **Every change must build.** `{{ manifest.library.build_cmd }}` must succeed before you open a PR. If a test script exists, it must pass too. +- **Prefer additive changes.** New props/exports with sensible defaults over breaking signatures. + +## Versioning & Releases (semver) + +- **patch** — bug fixes, internal refactors, no API change. +- **minor** — new backward-compatible exports, props, or features. +- **major** — any breaking change to the public API. + +Agents **do not run `{{ manifest.library.publish_cmd }}`** and do not bump the published version on `main`. Propose the version bump in the PR description and let the maintainer cut the release. Never commit registry credentials or an `.npmrc` auth token. +{% if manifest.linear.enabled %} + +## Task Workflow (Linear) + +**All work must start with a Linear issue.** Do not make code changes without an associated task. + +### Before starting work: +1. Check the [{{ manifest.linear.team_key }} board](https://linear.app/{{ manifest.linear.workspace }}/team/{{ manifest.linear.team_key }}/active) for **Todo** tasks. +2. If no task exists for the work you're about to do, create one in **Backlog** first (the owner promotes it to Todo). + +### Working on a task: +1. Move the issue to **In Progress**. +2. Branch: `{{ manifest.linear.team_key | lower }}-{number}/{short-desc}` (e.g. `{{ manifest.linear.team_key | lower }}-12/emit-dts-types`). +3. Make focused changes inside `{{ manifest.library.package_dir }}/`. +4. **Verify locally:** `cd {{ manifest.library.package_dir }} && {{ manifest.library.build_cmd }}` (and `{{ manifest.library.test_cmd }}` if present). For component/UI work, verify in Storybook if the package has it. +5. Commit with the issue ID: `{{ manifest.linear.team_key }}-12: Emit .d.ts type declarations`. + +### Completing a task: +1. Open a PR titled `{{ manifest.linear.team_key }}-{number}: {title}`; note the suggested semver bump (patch/minor/major). +2. Move the issue to **In Review** (NOT Done). It only moves to Done once the PR is merged. +{% endif %} + +## Good task ideas for a library like this + +Substantial, library-appropriate work (not cosmetic tweaks): +- **Type declarations** — emit `.d.ts` so TypeScript consumers get types. +- **Tests** — real unit/interaction tests for the public components. +- **CI** — GitHub Actions to build + test on PRs, and publish on tag. +- **API ergonomics** — new props, controlled/uncontrolled variants, better defaults. +- **Bundle health** — tree-shaking, side-effect flags, smaller output, fewer deps. +- **Docs & examples** — Storybook stories and README usage for every public export. diff --git a/repoScaffold/templates/project/AGENTS.md.j2 b/repoScaffold/templates/project/AGENTS.md.j2 index 1806f3f..5c0b5d2 100644 --- a/repoScaffold/templates/project/AGENTS.md.j2 +++ b/repoScaffold/templates/project/AGENTS.md.j2 @@ -5,9 +5,19 @@ **{{ manifest.project.name }}** is a full-stack application deployed on GCP Cloud Run. - **Environment:** {{ manifest.project.env }} -- **Cloud Provider:** {{ manifest.project.cloud.provider | upper }} +- **Cloud Provider:** {{ manifest.project.cloud.provider | upper }} (project `{{ manifest.project.cloud.project_id }}`) - **Region:** {{ manifest.project.cloud.region }} -- **Database:** {{ manifest.database.type }} ({{ manifest.database.atlas_cluster }}) +- **Database:** {{ manifest.database.type }} Atlas — cluster `{{ manifest.database.atlas_cluster }}`, database `{{ manifest.database.db_name or manifest.project.name }}` + - Connection string: stored in GCP Secret Manager as `{{ manifest.project.name | lower | replace(' ', '-') }}-mongo-con-str` (or `mongodb-uri` for the default secret) + - Atlas console: https://cloud.mongodb.com/v2#/clusters + - Local dev: `services/api/.env` has the connection string +{% if manifest.linear.enabled %} +- **Project Board:** [Linear — {{ manifest.linear.team_key }}](https://linear.app/{{ manifest.linear.workspace }}/team/{{ manifest.linear.team_key }}/active) +{% endif %} + +### Admin users + +Set `isAdmin: true` on a user's `userdatas` document to grant admin access. Do not build a separate role system — this single boolean is the source of truth. Initial admins are configured by the owner via Mongo shell or directly in the Atlas data explorer. ## Directory Structure @@ -23,20 +33,56 @@ cloudrun/ # Cloud Run service specs ``` +## CI/CD Pipelines + +All pipelines live in **GCP Cloud Build** (not GitHub Actions). Pushing to `main` triggers: + +| Trigger | Watches | Config | +|---------|---------|--------| +| `build-api` | `services/api/**` | `cloudbuild/api.yaml` | +| `build-app` | `services/web/**` | `cloudbuild/web.yaml` | +| `build-worker` | `services/worker/**` | `cloudbuild/worker.yaml` | +| `deploy-infra` | `infra/**` | `cloudbuild/terraform.yaml` | + ## Conventions - **Services communicate via HTTP contracts only.** No shared code between services. -- **Secrets** are managed via GCP Secret Manager. Local dev uses `.env` files. -- **Infrastructure** is managed with Terraform. One pipeline per environment. +- **Secrets** are managed via GCP Secret Manager. Local dev uses `.env` files. Agents have **read-only** access to secrets — ask the project owner to create new secrets. +- **Infrastructure** is managed with Terraform. Changes to `infra/` auto-deploy via Cloud Build. - **Docker** is required for all services. Containers expose port 80 internally. - **API endpoints** are the contract between frontend and backend. +{% if manifest.linear.enabled %} + +## Task Workflow (Linear) + +**All work must start with a Linear issue.** Do not make code changes without an associated task. + +### Before starting work: +1. Check the [{{ manifest.linear.team_key }} board](https://linear.app/{{ manifest.linear.workspace }}/team/{{ manifest.linear.team_key }}/active) for assigned tasks +2. If no task exists for the work you're about to do, **create one first** with: + - A clear, specific title (e.g., "Add pagination to /models endpoint" not "Fix API") + - A description with: Objective, Steps, Acceptance Criteria + - Appropriate priority (1=Urgent, 2=High, 3=Medium, 4=Low) + +### Working on a task: +1. Move the issue to **In Progress** +2. Create a branch named `{{ manifest.linear.team_key | lower }}-{issue-number}/{short-description}` (e.g., `ai4-42/add-pagination`) +3. Make your changes on the branch +4. Create a PR with the Linear issue ID in the title (e.g., "AI4-42: Add pagination to /models") +5. Linear auto-links the PR when the issue ID appears in the branch name or PR title -## Key Endpoints +### Completing a task: +1. Ensure the PR is merged to `main` +2. Cloud Build will auto-deploy the changes +3. Move the issue to **Done** -| Service | Endpoint | Description | -|---------|-------------|-------------------| -| API | GET /items | List all items | -| API | GET /health | Health check | +### Issue descriptions must include: +- **Objective:** What and why (1-2 sentences) +- **Steps:** Numbered list of concrete actions +- **Branch:** Suggested branch name +- **Acceptance Criteria:** Bullet list of verifiable outcomes +- **Files:** List of files likely to change (if known) +{% endif %} ## Development diff --git a/repoScaffold/templates/project/README.library-node.md.j2 b/repoScaffold/templates/project/README.library-node.md.j2 new file mode 100644 index 0000000..a8f1f93 --- /dev/null +++ b/repoScaffold/templates/project/README.library-node.md.j2 @@ -0,0 +1,35 @@ +# {{ manifest.project.name }} + +A publishable Node/npm package. +{% if manifest.linear.enabled %} + +## Want something built or fixed? + +**[Add a task on the {{ manifest.linear.team_key }} board →](https://linear.app/{{ manifest.linear.workspace }}/team/{{ manifest.linear.team_key }}/active)** + +Describe what you want — a new prop, an API improvement, a bug you hit, types, tests — and an agent will pick it up. Be specific: include how you use the library, what you saw, and what you expected. The owner reviews new tasks and promotes approved ones to **Todo** for the agent to work. +{% endif %} + +## Develop + +The package lives in `{{ manifest.library.package_dir }}/`. + +```bash +cd {{ manifest.library.package_dir }} +{{ manifest.library.build_cmd }} # build the package +{{ manifest.library.test_cmd }} # run tests +``` + +Releases are cut by the maintainer with `{{ manifest.library.publish_cmd }}` — agents never publish. + +## Dashboards + +| Resource | Link | +|----------|------| +{% if manifest.linear.enabled %} +| Project Board | [{{ manifest.linear.team_key }} — Linear](https://linear.app/{{ manifest.linear.workspace }}/team/{{ manifest.linear.team_key }}/active) | +{% endif %} + +## Project Config + +See `project.manifest.yaml` for the full project configuration. diff --git a/repoScaffold/templates/project/README.md.j2 b/repoScaffold/templates/project/README.md.j2 index 7f99932..9e2d121 100644 --- a/repoScaffold/templates/project/README.md.j2 +++ b/repoScaffold/templates/project/README.md.j2 @@ -1,48 +1,74 @@ # {{ manifest.project.name }} -Full-stack application scaffolded with `platform-cli`. +{% if manifest.linear.enabled %} +## Want something built or fixed? -## Quick Start +**[Add a task on the {{ manifest.linear.team_key }} board →](https://linear.app/{{ manifest.linear.workspace }}/team/{{ manifest.linear.team_key }}/active)** -```bash -# Install required tools -platform-cli install - -# Start all services locally -docker compose up --build +Describe what you want — a new feature, a bug you hit, a UI change — and an agent will pick it up. Be specific: include what you saw, what you expected, and any relevant URLs or screenshots. The more detail, the faster it ships. -# Or run services individually: -cd services/api && npm install && npm start -cd services/web && npm install && npm start -``` - -## Services +{% endif %} +## Live Services -| Service | Type | Port | Stack | -|---------|---------|------|---------| +| Service | URL / Schedule | +|---------|----------------| {% for svc in manifest.services %} -{% if svc.enabled %} -| {{ svc.name }} | {{ svc.type }} | {{ svc.port }} | {{ svc.stack }} | +{% if svc.enabled and svc.type == 'api' %} +| **API** | `https://{{ svc.name }}-PROJECTNUM.{{ manifest.project.cloud.region }}.run.app` | +{% endif %} +{% if svc.enabled and svc.type == 'webapp' %} +| **Web App** | `https://{{ svc.name }}-PROJECTNUM.{{ manifest.project.cloud.region }}.run.app` | +{% endif %} +{% if svc.enabled and svc.type == 'worker' %} +| **{{ svc.name | capitalize }}** (Cloud Run Job) | Scheduled: `{{ svc.schedule }}` | {% endif %} {% endfor %} -## Infrastructure +## Quick Validation + +```bash +curl -sf https://api-PROJECTNUM.{{ manifest.project.cloud.region }}.run.app/health +curl -sf -o /dev/null -w "HTTP %{http_code}\n" https://app-PROJECTNUM.{{ manifest.project.cloud.region }}.run.app/ +``` + +## Dashboards -- **Cloud Provider:** {{ manifest.project.cloud.provider | upper }} -- **Region:** {{ manifest.project.cloud.region }} -- **Database:** {{ manifest.database.type }} +| Resource | Link | +|----------|------| +{% if manifest.linear.enabled %} +| Project Board | [{{ manifest.linear.team_key }} — Linear](https://linear.app/{{ manifest.linear.workspace }}/team/{{ manifest.linear.team_key }}/active) | +{% endif %} +| Cloud Run | [GCP Console](https://console.cloud.google.com/run?project={{ manifest.project.cloud.project_id }}) | +| Cloud Build | [GCP Console](https://console.cloud.google.com/cloud-build/builds?project={{ manifest.project.cloud.project_id }}) | +| Secrets | [GCP Console](https://console.cloud.google.com/security/secret-manager?project={{ manifest.project.cloud.project_id }}) | +{% if manifest.database.type == 'mongodb' %} +| MongoDB Atlas | [Atlas Console — {{ manifest.database.atlas_cluster }}](https://cloud.mongodb.com/v2#/clusters) (db: `{{ manifest.database.db_name or manifest.project.name }}`) | +{% endif %} -### Deploy +## Local Development ```bash -# Terraform plan -cd infra/terraform/envs/dev -terraform init && terraform plan +docker compose up --build -# Cloud Build (triggered via push or manual) -gcloud builds submit --config=cloudbuild/api.yaml +# API: http://localhost:{{ manifest.services | selectattr('type', 'equalto', 'api') | map(attribute='port') | first | default(3006) }} +# Web App: http://localhost:{{ manifest.services | selectattr('type', 'equalto', 'webapp') | map(attribute='port') | first | default(3005) }} ``` -## Project Manifest +## How It Works + +Pushing to `main` triggers **Cloud Build** pipelines that build, push, and deploy automatically: + +| Trigger | Watches | Deploys | +|---------|---------|---------| +{% for svc in manifest.services %} +{% if svc.enabled %} +| `build-{{ svc.name }}` | `services/{{ 'api' if svc.type == 'api' else ('web' if svc.type == 'webapp' else 'worker') }}/**` | Cloud Run `{{ svc.name }}` | +{% endif %} +{% endfor %} +| `deploy-infra` | `infra/**` | Terraform apply | + +Infrastructure changes go through the same flow — edit Terraform, push, pipeline applies. + +## Project Config See `project.manifest.yaml` for the full project configuration. diff --git a/repoScaffold/templates/project/reconcile.py.j2 b/repoScaffold/templates/project/reconcile.py.j2 new file mode 100644 index 0000000..dbb9c34 --- /dev/null +++ b/repoScaffold/templates/project/reconcile.py.j2 @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Deterministic board reconciliation — no Claude, cheap, idempotent. + +Authoritatively syncs each Linear issue's state to the ACTUAL state of its +GitHub PR(s), fixing the race where a lagging agent resurrects a Done task or +opens a duplicate PR. Run by a single designated agent every iteration +(see loop.sh) — safe to re-run; it only acts when the board disagrees with the +PRs. + +Rules (per issue that has any PR): + - A PR is MERGED -> issue should be Done; close any still-OPEN duplicate PRs. + - Else issue is In Progress (open or closed PR) -> In Review, for the owner to triage. +Owner-controlled states (Done / Backlog / Canceled / Todo) are never touched. + +Usage: reconcile.py <TEAM_KEY> (or set AGENT_LINEAR_TEAM_KEY) +""" +import json +import os +import re +import subprocess +import sys + +TEAM_KEY = (sys.argv[1] if len(sys.argv) > 1 else os.environ.get("AGENT_LINEAR_TEAM_KEY", "")).strip() +DRY_RUN = os.environ.get("RECONCILE_DRYRUN") == "1" # log intended changes, mutate nothing +if not TEAM_KEY: + print("[reconcile] no team key (pass as arg or set AGENT_LINEAR_TEAM_KEY) — skipping", flush=True) + sys.exit(0) +try: + LINEAR_KEY = open(os.path.expanduser("~/.linear/api_key")).read().strip() +except OSError: + print("[reconcile] no linear key — skipping", flush=True) + sys.exit(0) + + +def log(msg): + print(f"[reconcile] {msg}", flush=True) + + +def gql(query): + r = subprocess.run( + ["curl", "-s", "-H", f"Authorization: {LINEAR_KEY}", + "-H", "Content-Type: application/json", + "-d", json.dumps({"query": query}), "https://api.linear.app/graphql"], + capture_output=True, text=True, timeout=30) + return json.loads(r.stdout or "{}") + + +def gh_all_prs(limit=150): + r = subprocess.run( + ["gh", "pr", "list", "--state", "all", "--limit", str(limit), + "--json", "number,title,headRefName,state"], + capture_output=True, text=True, timeout=60) + return json.loads(r.stdout or "[]") + + +def issue_number(pr): + m = re.search(rf"{re.escape(TEAM_KEY)}-(\d+)", f"{pr['title']} {pr['headRefName']}", re.IGNORECASE) + return int(m.group(1)) if m else None + + +def main(): + data = gql('{ teams(filter:{key:{eq:"%s"}}){ nodes{ id states{ nodes{ id name } } } } }' % TEAM_KEY) + try: + team = data["data"]["teams"]["nodes"][0] + except (KeyError, IndexError, TypeError): + log("could not resolve team — skipping") + return + team_id = team["id"] + states = {s["name"]: s["id"] for s in team["states"]["nodes"]} + + # Bucket PRs per issue by their true state (OPEN / CLOSED / MERGED). + by_issue = {} + for pr in gh_all_prs(): + n = issue_number(pr) + if n is None: + continue + b = by_issue.setdefault(n, {"OPEN": [], "CLOSED": [], "MERGED": []}) + if pr["state"] in b: + b[pr["state"]].append(pr["number"]) + if not by_issue: + return + + # Current Linear state for exactly those issues. + nums = ",".join(str(n) for n in by_issue) + res = gql('{ team(id:"%s"){ issues(first:250, filter:{number:{in:[%s]}}){ nodes{ id number state{name} } } } }' + % (team_id, nums)) + try: + issues = res["data"]["team"]["issues"]["nodes"] + except (KeyError, TypeError): + log("could not fetch issue states — skipping") + return + cur = {i["number"]: (i["id"], i["state"]["name"]) for i in issues} + + def move(issue_id, num, to_name): + if to_name not in states: + return + if DRY_RUN: + log(f"[dry-run] {TEAM_KEY}-{num}: would -> {to_name}") + return + gql('mutation { issueUpdate(id:"%s", input:{stateId:"%s"}){ success } }' % (issue_id, states[to_name])) + log(f"{TEAM_KEY}-{num}: -> {to_name}") + + def close_pr(num, reason): + if DRY_RUN: + log(f"[dry-run] would close PR #{num} ({reason})") + return + subprocess.run(["gh", "pr", "close", str(num), "--comment", reason], + capture_output=True, text=True, timeout=30) + log(f"closed PR #{num} ({reason})") + + changed = 0 + for n, prs in by_issue.items(): + if n not in cur: + continue + issue_id, state = cur[n] + if prs["MERGED"]: + # Work is in main -> Done (never override owner-controlled states). + if state in ("Todo", "In Progress", "In Review"): + move(issue_id, n, "Done"); changed += 1 + for pr in prs["OPEN"]: + close_pr(pr, f"Duplicate — {TEAM_KEY}-{n} was already completed by a merged PR. Closing this duplicate."); changed += 1 + elif state == "In Progress": + # Issue is stuck In Progress but its PR is open (stranded) or was + # closed by the owner. Either way, surface it to In Review so the + # owner can triage (merge/backlog/cancel). A closed PR is an owner + # signal, NOT "redo it": never auto-Todo, and never touch Done (a + # Done task whose only PR is closed is owner-controlled — leave it). + move(issue_id, n, "In Review"); changed += 1 + + log(f"done ({changed} correction(s))") + + +if __name__ == "__main__": + try: + main() + except Exception as e: # never let reconciliation crash the agent loop + log(f"error (non-fatal): {e}") diff --git a/repoScaffold/templates/project/setup-vm.sh.j2 b/repoScaffold/templates/project/setup-vm.sh.j2 index 0eeadd0..c35a444 100644 --- a/repoScaffold/templates/project/setup-vm.sh.j2 +++ b/repoScaffold/templates/project/setup-vm.sh.j2 @@ -1,13 +1,27 @@ #!/usr/bin/env bash # {{ manifest.project.name }} — Agent VM setup script -# Run this once on a fresh GCE VM to install all dependencies and start all agents. +# Installs all dependencies and starts all agents. Run once on the agent VM. +# +# ── Provision the VM first (one-time) ───────────────────────────────── +# The fleet runs several headless Claude Code agents concurrently, which is +# memory-hungry. Size the VM accordingly — small shared-core types (e.g. +# e2-medium / 2 vCPU / 4 GB) THRASH and stall under load. Recommended default +# for ~6 agents: e2-standard-8 (8 vCPU / 32 GB) with a 50 GB disk. Scale the +# machine type with the number of agents (budget ~2 vCPU + ~4 GB per agent). +# +# gcloud compute instances create {{ manifest.project.name | lower | replace(' ', '-') }}-agents \ +# --zone={{ manifest.project.cloud.region }}-a \ +# --machine-type=e2-standard-8 \ +# --boot-disk-size=50GB --boot-disk-type=pd-balanced \ +# --image-family=debian-12 --image-project=debian-cloud +# +# ── Then provision + start the agents on it ─────────────────────────── +# gcloud compute ssh {{ manifest.project.name | lower | replace(' ', '-') }}-agents \ +# --zone={{ manifest.project.cloud.region }}-a --command="bash -s" < agents/setup-vm.sh # # Each agent gets its own tmux window within a single session. # Add a new agent by creating agents/<name>/PROMPT.md in the repo. # -# Usage (from your local machine): -# ssh user@VM_IP 'bash -s' < setup-vm.sh -# set -euo pipefail PROJECT_ID="{{ manifest.project.cloud.project_id }}" @@ -20,18 +34,18 @@ echo "=== {{ manifest.project.name }} Agent VM Setup ===" # ── 1. System packages ───────────────────────────────────────── -echo "[1/6] Installing system packages..." +echo "[1/9] Installing system packages..." sudo apt-get update -qq sudo apt-get install -y -qq tmux git curl jq # ── 2. Node.js ────────────────────────────────────────────────── if ! command -v node &>/dev/null; then - echo "[2/6] Installing Node.js..." + echo "[2/9] Installing Node.js..." curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt-get install -y -qq nodejs else - echo "[2/6] Node.js already installed: $(node --version)" + echo "[2/9] Node.js already installed: $(node --version)" fi # ── 3. Claude Code ────────────────────────────────────────────── @@ -45,15 +59,21 @@ grep -q '.npm-global/bin' ~/.bashrc 2>/dev/null || { } if ! command -v claude &>/dev/null; then - echo "[3/6] Installing Claude Code..." + echo "[3/9] Installing Claude Code..." npm install -g @anthropic-ai/claude-code else - echo "[3/6] Claude Code already installed: $(claude --version)" + echo "[3/9] Claude Code already installed: $(claude --version)" fi -# ── 4. Deploy key + repo clone ────────────────────────────────── +# ── 4. Playwright + Chromium ──────────────────────────────────── +# Agents capture UI screenshots for PRs; install the browser once, VM-wide. + +echo "[4/9] Installing Playwright..." +npx playwright install chromium --with-deps 2>/dev/null || true -echo "[4/6] Setting up deploy key and cloning repo..." +# ── 5. Deploy key + repo clone ────────────────────────────────── + +echo "[5/9] Setting up deploy key and cloning repo..." mkdir -p ~/.ssh gcloud secrets versions access latest \ @@ -74,9 +94,23 @@ else cd "$REPO_DIR" fi -# ── 5. Linear API key ────────────────────────────────────────── +# ── 6. Pre-install service dependencies ──────────────────────── +# Seeds node_modules in the main clone so each agent's private worktree can +# hardlink-copy them (loop.sh) instead of running a fresh install per iteration. + +echo "[6/9] Installing service dependencies..." +for svc_dir in {% for s in manifest.services %}services/{{ s.name }} {% endfor %}; do + if [ -f "$REPO_DIR/$svc_dir/package.json" ] && [ ! -d "$REPO_DIR/$svc_dir/node_modules" ]; then + echo " Installing $svc_dir..." + ( cd "$REPO_DIR/$svc_dir" && npm ci --no-audit --no-fund --legacy-peer-deps ) || true + else + echo " $svc_dir: node_modules present or no package.json" + fi +done + +# ── 7. Linear API key ────────────────────────────────────────── -echo "[5/6] Pulling Linear API key..." +echo "[7/9] Pulling Linear API key..." mkdir -p ~/.linear gcloud secrets versions access latest \ --secret=linear-api-key \ @@ -84,9 +118,26 @@ gcloud secrets versions access latest \ echo "Warning: linear-api-key not found. Linear integration disabled." chmod 600 ~/.linear/api_key 2>/dev/null || true -# ── 6. Start all agents ──────────────────────────────────────── +# ── 8. Claude Code OAuth token (Claude Code auth) ────────────── + +echo "[8/9] Verifying Claude Code OAuth token..." +# Agents authenticate Claude via a long-lived subscription OAuth token from +# Secret Manager (loop.sh fetches it each iteration; from `claude setup-token`). +# It draws on the Claude subscription (not metered API billing) and is valid +# ~1 year. A missing token means every agent fails to authenticate, so surface +# it clearly here at provision time. +if gcloud secrets describe claude-code-oauth-token --project="$PROJECT_ID" &>/dev/null; then + echo " claude-code-oauth-token present — agents will authenticate via subscription OAuth." +else + echo " WARNING: claude-code-oauth-token not found in Secret Manager." + echo " Agents cannot authenticate Claude until it exists. Create it with:" + echo " printf %s \"\$(claude setup-token)\" | gcloud secrets create claude-code-oauth-token \\" + echo " --data-file=- --replication-policy=automatic --project=$PROJECT_ID" +fi -echo "[6/6] Discovering agents and starting tmux sessions..." +# ── 9. Start all agents ──────────────────────────────────────── + +echo "[9/9] Discovering agents and starting tmux sessions..." tmux kill-session -t "$TMUX_SESSION" 2>/dev/null || true @@ -98,17 +149,24 @@ for agent_dir in "$REPO_DIR"/agents/*/; do continue fi + # Skip the base agent when numbered instances exist (e.g. skip "developer" + # if "developer-1" is present) so we don't run a duplicate of the fleet. + if ls "$REPO_DIR"/agents/"${agent_name}-"*/PROMPT.md &>/dev/null; then + echo " Skipping $agent_name (numbered instances found)" + continue + fi + if [ "$FIRST" = true ]; then tmux new-session -d -s "$TMUX_SESSION" -n "$agent_name" \ "export PATH=$HOME/.npm-global/bin:\$PATH; \ export GIT_SSH_COMMAND='ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no'; \ - cd $REPO_DIR && ./loop.sh $agent_name" + cd $REPO_DIR && ./agents/loop.sh $agent_name" FIRST=false else tmux new-window -t "$TMUX_SESSION" -n "$agent_name" \ "export PATH=$HOME/.npm-global/bin:\$PATH; \ export GIT_SSH_COMMAND='ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no'; \ - cd $REPO_DIR && ./loop.sh $agent_name" + cd $REPO_DIR && ./agents/loop.sh $agent_name" fi echo " Started: $agent_name" diff --git a/repoScaffold/templates/services/worker/Dockerfile.j2 b/repoScaffold/templates/services/worker/Dockerfile.j2 index 5907b14..b1706eb 100644 --- a/repoScaffold/templates/services/worker/Dockerfile.j2 +++ b/repoScaffold/templates/services/worker/Dockerfile.j2 @@ -12,8 +12,5 @@ COPY . . USER appuser -EXPOSE 80 - -ENV PORT=80 - +# Runs as a Cloud Run Job (one-shot script). No HTTP server, no port. CMD ["python", "main.py"] diff --git a/repoScaffold/templates/services/worker/main.py.j2 b/repoScaffold/templates/services/worker/main.py.j2 index f2676f2..ab942de 100644 --- a/repoScaffold/templates/services/worker/main.py.j2 +++ b/repoScaffold/templates/services/worker/main.py.j2 @@ -1,18 +1,29 @@ -"""{{ service.name }} worker — Cloud Run compatible stub.""" -import os +"""{{ service.name }} worker — runs as a scheduled Cloud Run Job. + +Triggered by Cloud Scheduler on cron schedule: {{ service.schedule }} +""" import logging +import os +import sys -logging.basicConfig(level=logging.INFO) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) logger = logging.getLogger(__name__) -def main(): - """Entry point for the worker.""" - logger.info("Worker starting...") +def main() -> int: + """Entry point for the worker. Runs to completion, then exits.""" + logger.info("Worker starting (task index=%s)", os.environ.get("CLOUD_RUN_TASK_INDEX", "0")) + + # TODO: Implement worker logic here. + # This is a no-op placeholder that just logs and exits. + logger.info("Hello from {{ service.name }}!") - # TODO: Implement worker logic here logger.info("Worker completed successfully.") + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/repoScaffold/templates/services/worker/requirements.txt.j2 b/repoScaffold/templates/services/worker/requirements.txt.j2 index a071179..f9949ff 100644 --- a/repoScaffold/templates/services/worker/requirements.txt.j2 +++ b/repoScaffold/templates/services/worker/requirements.txt.j2 @@ -1,3 +1 @@ -flask>=3.0.0 -gunicorn>=21.2.0 google-cloud-logging>=3.8.0 From c0711182fc3a063e8cc73b15ff6e3ff0a0a76d33 Mon Sep 17 00:00:00 2001 From: David Gaspard <davidgaspard@MacBook-Pro-3.local> Date: Tue, 28 Jul 2026 09:53:33 -0400 Subject: [PATCH 8/9] scaffold(agents): reliable inline PR screenshots via secret gist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the ai4us2 fix to the scaffolder so generated projects get a working screenshot flow instead of the broken `![](local.png)` / gh-issue-comment embed (which never renders — worse on private repos, where raw/blob URLs also 404 in-browser). - templates/project/pr-screenshot.sh.j2: uploads a screenshot to an unlisted gist (binary over git; REST gists are text-only) and prints a markdown image tag whose raw URL renders INLINE in a PR body. Verified end-to-end on a real private PR. Project name templated into the gist description / git author. - phase_b_scaffold.py: render it to agents/pr-screenshot.sh (chmod 755), matching the reconcile.py precedent. - developer/PROMPT.md.j2: use the script; screenshot the changed page (or home page if unclear); explicitly forbid the broken embed methods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../platform_cli/steps/phase_b_scaffold.py | 10 +++ .../templates/agents/developer/PROMPT.md.j2 | 38 +++++------- .../templates/project/pr-screenshot.sh.j2 | 62 +++++++++++++++++++ 3 files changed, 89 insertions(+), 21 deletions(-) create mode 100644 repoScaffold/templates/project/pr-screenshot.sh.j2 diff --git a/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py b/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py index a1048fe..aec08ef 100644 --- a/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py +++ b/repoScaffold/src/platform_cli/steps/phase_b_scaffold.py @@ -305,9 +305,19 @@ def run(self, ctx: ScaffoldContext) -> dict[str, Any]: agents_dir / "reconcile.py", manifest=ctx.manifest, ) + # Uploads a screenshot to an unlisted gist and prints a markdown image tag + # that renders INLINE in a PR body — the only reliable way to attach an + # image to a private-repo PR (see the script header). Used by developers + # for the required web/UI screenshot. + render_to_file( + "project/pr-screenshot.sh.j2", + agents_dir / "pr-screenshot.sh", + manifest=ctx.manifest, + ) os.chmod(agents_dir / "loop.sh", 0o755) os.chmod(agents_dir / "setup-vm.sh", 0o755) os.chmod(agents_dir / "reconcile.py", 0o755) + os.chmod(agents_dir / "pr-screenshot.sh", 0o755) return {"agents": scaffolded} diff --git a/repoScaffold/templates/agents/developer/PROMPT.md.j2 b/repoScaffold/templates/agents/developer/PROMPT.md.j2 index fe4c7cf..62045f8 100644 --- a/repoScaffold/templates/agents/developer/PROMPT.md.j2 +++ b/repoScaffold/templates/agents/developer/PROMPT.md.j2 @@ -213,40 +213,36 @@ curl -s -H "Authorization: $LINEAR_KEY" \ 1. Push: `git push -u origin HEAD` -2. **Capture screenshots** for any web/landing UI changes. The project owner reviews PRs asynchronously and needs to see what the feature looks like. Use Playwright to capture screenshots: +2. **Screenshot — REQUIRED for any PR that touches a frontend service.** The project owner reviews PRs asynchronously and needs to see the change. Run the app, screenshot the page your change affects, then upload the screenshot so it renders **inline** in the PR body: ```bash - # Install Playwright if not already present - npx playwright install chromium 2>/dev/null || true - - # Start the dev server in the background, wait for it, screenshot, and stop cd services/web # or the relevant frontend service + [ -d node_modules ] || npm ci --no-audit --no-fund --legacy-peer-deps npm start & DEV_PID=$! - sleep 10 # wait for dev server + for i in $(seq 1 30); do curl -sf http://localhost:3000 >/dev/null 2>&1 && break; sleep 2; done - # Capture screenshots of the relevant pages - npx playwright screenshot --browser chromium http://localhost:3000/your-page screenshot-name.png - # Capture multiple pages if the feature spans multiple routes + # Screenshot the page your change affects. If it is not clear which page shows + # your change, screenshot the home page ("/") just to prove the app loads. + PGPATH=/your-page # <- set to the path your change is visible on + npx playwright install chromium 2>/dev/null || true + npx playwright screenshot --browser chromium "http://localhost:3000${PGPATH}" shot.png kill $DEV_PID 2>/dev/null + cd ../.. + + # Upload the screenshot; this prints a markdown image tag that renders INLINE. + SHOT_MD=$(./agents/pr-screenshot.sh services/web/shot.png "$PGPATH") + echo "$SHOT_MD" ``` - Upload screenshots to the PR using `gh`: - ```bash - # Upload each screenshot and include in the PR body - for img in *.png; do - gh issue comment <pr-number> --body "![${img}](${img})" || true - done - ``` - Alternatively, embed screenshots directly in the PR description body using markdown image syntax. If the dev server can't start (missing env vars, database, etc.), note this in the PR and skip screenshots. + **Embedding the image — do this exactly.** Do NOT hand-write `![](file.png)`, `gh issue comment` with a local path, or a `raw.githubusercontent.com` / `blob?raw=true` URL — on a private repo those all render as **broken images** (the reason past screenshots never showed up). The ONLY thing that renders inline is the output of **`agents/pr-screenshot.sh`** (it uploads to an unlisted gist and prints a working markdown tag). Paste its exact stdout into the PR body under `## Screenshot`. If the dev server genuinely cannot start (missing env/db), say so explicitly in the PR body — never silently skip, and never fake a screenshot. -3. Create PR: +3. Create PR — put the `agents/pr-screenshot.sh` output (in `$SHOT_MD`) under `## Screenshot`: ```bash gh pr create --title "{{ manifest.linear.team_key }}-{number}: {title}" --body "$(cat <<EOF Resolves {{ manifest.linear.team_key }}-{number} - ## Screenshots - <!-- Paste Playwright screenshots here --> - + ## Screenshot + ${SHOT_MD} EOF )" ``` diff --git a/repoScaffold/templates/project/pr-screenshot.sh.j2 b/repoScaffold/templates/project/pr-screenshot.sh.j2 new file mode 100644 index 0000000..23b0d80 --- /dev/null +++ b/repoScaffold/templates/project/pr-screenshot.sh.j2 @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# pr-screenshot.sh — upload a screenshot so it renders INLINE in a PR description. +# +# Why this exists: if the repo is PRIVATE, images committed to the branch or +# referenced via raw.githubusercontent.com / blob?raw=true do NOT render inline in +# a PR/issue body — the viewer's browser can't authenticate to those URLs +# cross-domain, so they 404 (this is why hand-written `![](file.png)` embeds are +# "always broken"). The GitHub web upload endpoint (user-attachments) needs a +# browser session, not a token, so agents can't use it either. +# +# What DOES work (private OR public): an unlisted ("secret") gist. Its raw URL is +# URL-addressable without auth, so GitHub serves it directly in the rendered body +# and it displays inline. We create a fresh secret gist per screenshot (no +# shared-gist push conflicts between concurrent agents) and git-push the binary +# PNG into it (the gists REST API only accepts text, so binary must go over git). +# +# Usage: +# agents/pr-screenshot.sh <image.png> ["alt text"] +# Prints a markdown image tag on stdout — paste it into the PR body: +# ![alt text](https://gist.githubusercontent.com/<user>/<id>/raw/<sha>/<file>) +# +# Requires: gh (authenticated), git, curl. +set -euo pipefail + +IMG="${1:?usage: pr-screenshot.sh <image.png> [alt-text]}" +ALT="${2:-screenshot}" + +[ -f "$IMG" ] || { echo "pr-screenshot: no such file: $IMG" >&2; exit 1; } +case "$(file -b --mime-type "$IMG" 2>/dev/null || echo)" in + image/*) : ;; + *) echo "pr-screenshot: not an image: $IMG" >&2; exit 1 ;; +esac + +TOKEN="$(gh auth token)" +[ -n "$TOKEN" ] || { echo "pr-screenshot: gh not authenticated" >&2; exit 1; } +BASE="$(basename "$IMG")" + +WORK="$(mktemp -d)" +META="$(mktemp)" +cleanup() { rm -rf "$WORK" "$META"; } +trap cleanup EXIT + +# 1. Create an unlisted gist (placeholder text file — REST gists take text only). +printf '{"public":false,"description":"PR screenshot ({{ manifest.project.name }}) — safe to delete","files":{"README.md":{"content":"PR screenshot upload."}}}' > "$META" +GIST_ID="$(gh api gists --input "$META" --jq '.id')" +[ -n "$GIST_ID" ] || { echo "pr-screenshot: gist create failed" >&2; exit 1; } +GIST_URL="https://${TOKEN}@gist.github.com/${GIST_ID}.git" + +# 2. Push the binary image into the gist over git. +git clone -q "$GIST_URL" "$WORK" +cp "$IMG" "$WORK/$BASE" +git -C "$WORK" -c user.email=agent@{{ manifest.project.name | lower | replace(' ', '-') }} -c user.name=agent add -- "$BASE" +git -C "$WORK" -c user.email=agent@{{ manifest.project.name | lower | replace(' ', '-') }} -c user.name=agent commit -q -m "add $BASE" +git -C "$WORK" push -q "$GIST_URL" HEAD + +# 3. Resolve the versioned raw URL and verify it serves the image before printing. +RAW="$(gh api "gists/${GIST_ID}" --jq ".files[\"${BASE}\"].raw_url")" +CODE="$(curl -s -o /dev/null -w '%{http_code}' -L "$RAW")" +[ "$CODE" = "200" ] || { echo "pr-screenshot: raw url not serving (HTTP $CODE): $RAW" >&2; exit 1; } + +printf '![%s](%s)\n' "$ALT" "$RAW" From ee2c42cbf40ff54234b0e8b7168989a84d19e1bd Mon Sep 17 00:00:00 2001 From: David Gaspard <davidgaspard@MacBook-Pro-3.local> Date: Tue, 28 Jul 2026 09:57:46 -0400 Subject: [PATCH 9/9] docs(scaffold): pr-screenshot needs a gh token with gist scope App installation tokens can't create gists, so a scaffolded project's VM must be gh auth login'd with a user PAT that has gist scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- repoScaffold/templates/project/pr-screenshot.sh.j2 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/repoScaffold/templates/project/pr-screenshot.sh.j2 b/repoScaffold/templates/project/pr-screenshot.sh.j2 index 23b0d80..8bf75d0 100644 --- a/repoScaffold/templates/project/pr-screenshot.sh.j2 +++ b/repoScaffold/templates/project/pr-screenshot.sh.j2 @@ -20,7 +20,9 @@ # Prints a markdown image tag on stdout — paste it into the PR body: # ![alt text](https://gist.githubusercontent.com/<user>/<id>/raw/<sha>/<file>) # -# Requires: gh (authenticated), git, curl. +# Requires: git, curl, and gh authenticated with a token that has `gist` scope +# (in addition to `repo`). A GitHub App installation token CANNOT create gists — +# the VM must be `gh auth login`'d with a user PAT that includes `gist`. set -euo pipefail IMG="${1:?usage: pr-screenshot.sh <image.png> [alt-text]}"