diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..adf5db8 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,28 @@ +{ + "name": "oka", + "owner": { + "name": "Arenukvern", + "url": "https://github.com/Arenukvern/oka" + }, + "plugins": [ + { + "name": "oka", + "source": "./plugin", + "description": "No-Gradle Flutter Android build system: flutter assemble + direct Android SDK tools for 3-5x faster incremental builds, with plugin packaging, AAB support, and dependency recovery. Bundles agent skills.", + "version": "0.1.6", + "keywords": [ + "flutter", + "android", + "build-system", + "gradle", + "apk", + "aab", + "dart", + "cli" + ], + "skills": [ + "./skills" + ] + } + ] +} diff --git a/.cursor/commands/update-faq.md b/.cursor/commands/update-faq.md new file mode 100644 index 0000000..093cfa4 --- /dev/null +++ b/.cursor/commands/update-faq.md @@ -0,0 +1,12 @@ +# Update FAQ + +After a code change, sync documentation. Stay short & smart. + +1. Classify the change: + - Architectural / internal trade-off → `docs/guides/design_faq.md` (edit existing Q&A first) or new ADR in `docs/decisions/` + - Command / API usage pattern → `docs/guides/build_and_config.md` + - Both → both, but never duplicate paragraphs between them +2. Verify against the actual codebase — document what exists, not wishful APIs. +3. Remove or supersede Q&As that no longer apply. +4. Keep answers ≤3 sentences; long examples belong in code/examples. +5. If a design fork was decided, ensure an accepted ADR exists in `docs/decisions/` and update its `index.md`. diff --git a/.cursor/rules/faq_usage.mdc b/.cursor/rules/faq_usage.mdc new file mode 100644 index 0000000..6269916 --- /dev/null +++ b/.cursor/rules/faq_usage.mdc @@ -0,0 +1,23 @@ +--- +description: Router for oka docs — when to read the design FAQ vs build guide vs ADRs +alwaysApply: true +--- + +# FAQ usage — oka + +Before answering questions about this repo, route to the right doc: + +| Question type | Read | +|---|---| +| **Why** is it designed this way? | `docs/guides/design_faq.md`, then `docs/decisions/` | +| **How** do I run/build/test? | `docs/guides/build_and_config.md` | +| What does oka own / not own? | `docs/start_here/why_this_repo_matters.md` | +| Phase status / evidence | `docs/PHASE_CHECKLIST.md` | +| Behavior of code | The code and tests themselves — docs only link | + +Rules: + +- Never paraphrase implementation in answers; link to the authoritative file. +- After changing architecture → add/update a Q&A in `docs/guides/design_faq.md` (or ADR). +- After changing commands/API usage → update `docs/guides/build_and_config.md`. +- Design forks require a decision checkpoint + ADR before coding. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb793b0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + # --no-example: example/ is a Flutter app; plain-dart resolution of it + # fails (flutter_test is sdk-sourced). Dart >= 3.13 resolves example/ + # by default. The root suite (ADR conformance + real-pipeline evidence) + # skips gracefully without the Flutter/Android SDKs. + - run: dart pub get --no-example + - run: dart analyze --fatal-infos + - name: Root test suite (cross-package conformance) + run: dart test + - name: Package test suites + run: | + set -e + for pkg in packages/*/; do + if [ -d "$pkg/test" ]; then + echo "=== $pkg" + (cd "$pkg" && dart test) + fi + done + + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - name: Contract gates (version sync, docs drift, changelog hygiene) + env: + OKA_ROOT: ${{ github.workspace }} + run: bash tool/contracts/check_contracts.sh + + validate: + # pub.dev dry-run for every publishable package (the workspace root is + # publish_to: none). Runs on PRs touching packaging. + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get --no-example + - name: Publish dry-runs + run: | + set -e + for pkg in packages/*/; do + if [ -f "$pkg/pubspec.yaml" ] && ! grep -q "publish_to: none" "$pkg/pubspec.yaml"; then + echo "=== $pkg" + (cd "$pkg" && dart pub publish --dry-run) + fi + done diff --git a/.github/workflows/pub_publish.yml b/.github/workflows/pub_publish.yml new file mode 100644 index 0000000..5a6550d --- /dev/null +++ b/.github/workflows/pub_publish.yml @@ -0,0 +1,69 @@ +name: Publish to pub.dev + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: pub-dev-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Assert release tag matches VERSION + if: github.event_name == 'push' + run: | + tag="${{ github.ref_name }}" + version="$(tr -d '[:space:]' < VERSION)" + tag_version="${tag#v}" + if [[ "$tag_version" != "$version" ]]; then + echo "FAIL: tag ($tag) does not match VERSION ($version)" >&2 + exit 1 + fi + bash tool/release/check_version_sync.sh + + # --no-example: example/ is a Flutter app; dart >= 3.13 resolves it by + # default, which fails under plain Dart (flutter_test is sdk-sourced). + - run: dart pub get --no-example + + # Split packages (ADR-0006) publish in dependency order: + # oka_core -> oka_android -> oka (the CLI, packages/oka). Hosted deps + # resolve against pub.dev, so the first release trains publish core + # before android/cli. + - name: Publish oka_core (dry-run preflight) + working-directory: packages/oka_core + run: dart pub publish --dry-run + - name: Publish oka_android (dry-run preflight) + working-directory: packages/oka_android + run: dart pub publish --dry-run + - name: Publish oka (dry-run preflight) + working-directory: packages/oka + run: dart pub publish --dry-run + + - name: Publish oka_core + if: github.event_name == 'push' + working-directory: packages/oka_core + run: dart pub publish --force + - name: Publish oka_android + if: github.event_name == 'push' + working-directory: packages/oka_android + run: dart pub publish --force + - name: Publish oka + if: github.event_name == 'push' + working-directory: packages/oka + run: dart pub publish --force diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..feb3aab --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,21 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - name: Release Please + uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.github/workflows/release_pr_sync_versions.yml b/.github/workflows/release_pr_sync_versions.yml new file mode 100644 index 0000000..ed41c64 --- /dev/null +++ b/.github/workflows/release_pr_sync_versions.yml @@ -0,0 +1,82 @@ +name: Release PR — sync versions + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: [main] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: release-pr-sync-versions-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + detect-release-pr: + runs-on: ubuntu-latest + outputs: + is_release_pr: ${{ steps.check.outputs.is_release_pr }} + steps: + - id: check + name: Detect release-please PR + run: | + title="${{ github.event.pull_request.title }}" + head="${{ github.head_ref }}" + if [[ "$head" == release-please--* ]] || [[ "$title" == *": release "* ]]; then + echo "is_release_pr=true" >> "$GITHUB_OUTPUT" + else + echo "is_release_pr=false" >> "$GITHUB_OUTPUT" + fi + + auto-sync-versions: + needs: detect-release-pr + if: needs.detect-release-pr.outputs.is_release_pr == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Sync release version touchpoints + run: bash tool/release/sync_version.sh + + - name: Commit and push if changed + run: | + if git diff --quiet; then + echo "release version touchpoints already in sync" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + VERSION \ + pubspec.yaml \ + .claude-plugin/marketplace.json \ + plugin/.cursor-plugin/plugin.json \ + plugin/.codex-plugin/plugin.json \ + plugin/.claude-plugin/plugin.json + git commit -m "chore: sync release version touchpoints" + git push origin "HEAD:${{ github.event.pull_request.head.ref }}" + + contract-gates: + needs: detect-release-pr + if: needs.detect-release-pr.outputs.is_release_pr == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Run contract gates + env: + OKA_ROOT: ${{ github.workspace }} + run: bash tool/contracts/check_contracts.sh diff --git a/.gitignore b/.gitignore index 3de8a9e..5c49338 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ pubspec.lock *.iml *.ipr *.iws - +rust_wrapper/flutter_assets/ # Build cache .oka_cache/ @@ -26,4 +26,8 @@ coverage/ # Local configuration *.local.yaml +example/pubspec_overrides.yaml + +# Skill Steward benchmark evidence (machine-local) +.steward/benchmark-summaries/*.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..0f24e47 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.6" +} diff --git a/.scratch/discover_app.dart b/.scratch/discover_app.dart new file mode 100644 index 0000000..5abda52 --- /dev/null +++ b/.scratch/discover_app.dart @@ -0,0 +1,20 @@ +import 'dart:io'; +import 'package:oka/src/build/plugin_discovery.dart'; + +void main() async { + final path = '${Platform.environment['HOME']}/xs/vitamins_quiz_bot/vitamin_shippic_app'; + final d = PluginDiscovery(verbose: true); + final r = await d.discover(path); + print('plugins total: ${r.plugins.length}'); + print('android: ${r.androidPlugins.length}'); + print('unsupported: ${r.unsupported.length}'); + for (final p in r.androidPlugins) { + print(' ${p.name} class=${p.qualifiedClass} unsupported=${p.unsupportedNative} reason=${p.unsupportedReason ?? "-"}'); + } + try { + d.ensureSupported(r, strict: true); + print('ensureSupported: OK'); + } catch (e) { + print('ensureSupported: FAIL\n$e'); + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d91506f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# AGENTS.md — oka + +Oka is a declarative, compositional, AI-native build system: **one code for +every platform build** — created not because Gradle is slow but because +platform build configs are locked, scattered, and endlessly repeated. It +collapses them into one typed, copyable Dart surface, starting with +**no-Gradle Flutter Android** (`flutter assemble` + direct Android SDK +tools). Agents execute; humans steer. This file is a **map**, not a manual — +follow links. + +## Non-negotiables + +- Default build path must **never** shell out to `flutter build apk` / Gradle as success (Phase 0 invariant). +- One build path only: the no-Gradle pipeline. The cargo-apk/Rust hybrid was removed (ADR-0009) — do not reintroduce it without a new ADR. +- Mark a phase done only when its tests/evidence exist (`docs/PHASE_CHECKLIST.md`). +- Design forks → decision checkpoint + ADR before coding (see `docs/decisions/`). + +## Map — "I want to…" + +| I want to… | Read | +|---|---| +| Understand what oka owns / boundaries | `docs/start_here/why_this_repo_matters.md` | +| Know **why** a design choice was made | `docs/guides/design_faq.md`, `docs/decisions/` | +| Know **how** to run/build/test | `docs/guides/build_and_config.md`, `docs/start_here/quick_recipes.md` | +| Migrate an existing Gradle app | `docs/guides/gradle_migration.md` (works / needs config / unsupported + verification loop) | +| Check phase status & evidence | `docs/PHASE_CHECKLIST.md` | +| Browse the docs site | `docs/` (published via docs.page) | +| See CLI commands | `packages/oka/bin/oka.dart`, `packages/oka/lib/src/cli/` | +| Understand/extend the build pipeline | `lib/src/pipeline/` (steps in `pipeline/steps/`, tool invocations in `pipeline/toolchain.dart`) — see ADR 0002 | +| Add a dependency / fix missing-class crashes | Build guide → Dependencies Station; table in `lib/src/build/dependency_suggest.dart` | +| Local .aar files / AAR natives & res | Build guide → Assets & Icon Station (`local_aars`); `extractAarPayload` in `dependency_cache.dart` | +| Compose a custom pipeline in Dart | `example/tool/oka_pipeline.dart`; contracts in `lib/src/pipeline/pipeline.dart` | +| Icons, deeplinks, extra assets config | Build guide → Assets & Icon Station | +| Enable hot reload / dev loop (`oka dev`) | `docs/decisions/0011-hot-reload-run-loop.md`, `docs/guides/hot_reload_plan.md` | +| Launch/declare browser sessions (Chrome, WebMCP flags) for testing | `docs/decisions/0017-browser-session-targets.md`, `packages/oka_web/lib/src/session/` | +| Cut a release / version sync | `docs/contributing/contribution_guide.md` → Releases; bundled skill `oka-maintenance` | +| Install agent skills | `npx skills add Arenukvern/oka --skill oka-maintenance` | + +## Skill Steward + +Oka is under [Skill Steward](https://github.com/Arenukvern/skill_steward) +stewardship (`steward.yaml`, archetype `cli_tool`). Agent workflow: + +1. Start with `steward doctor --json`, then `steward actions list --json`. +2. Inspect any intended action before execution: + `steward action inspect --json`. +3. Contract smoke scenario (all four release gates): + `steward benchmark --scenario oka.contract-status-smoke --strict --json`. +4. Build benchmarks: `just bench` (evidence in `.steward/benchmark-summaries`, + gitignored; summarized in `docs/evidence/`). +5. `steward validate skills/` when touching `skills/`. + +## Commands + +```bash +just install # dart pub get +just test # dart test +just lint # dart analyze +just global # reinstall global oka (clears snapshot cache) +just check-contracts # release gates: version sync, docs drift, changelog hygiene +``` + +Behavior SSOT is code + tests. Docs link; they never paraphrase implementation. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index f870998..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,52 +0,0 @@ -# Changelog - -All notable changes to the Oka project will be documented in this file. - -## [0.1.0] - 2025-01-10 - -### Added - -- **Phase 1: Foundation & AI Infrastructure** - - - Project structure with CLI support - - Extension type models for configuration (OkaConfig, AndroidConfig, Dependency, etc.) - - AI agent integration with Apple Foundation Models and Gemini fallback - - Prompt templates for Gradle conversion and manifest merging - - Configuration caching system - -- **Phase 2: Core Build Pipeline** - - - Android SDK tool locator (aapt2, d8, r8, kotlinc, javac, etc.) - - APK builder with resource compilation, Kotlin/Java compilation, DEX conversion - - APK packaging and signing with debug keystore - - Basic incremental build support - -- **CLI Commands** - - - `oka init` - Initialize oka.yaml from Gradle or create default config - - `oka build apk` - Build debug or release APK - - `oka doctor` - Check system requirements and configuration - - `oka clean` - Clean build caches - - `oka dev` - Stub for development mode (planned) - -- **Documentation** - - README with quick start guide - - Architecture overview - - Extension type model patterns - - Configuration examples - -### Known Limitations - -- Dev mode (hot reload) not yet implemented -- AAR dependency processing not implemented -- Plugin discovery not implemented -- Manifest merging needs testing -- No AAB (Android App Bundle) support yet - -### Next Steps - -- Complete Phase 3: Dependency Resolution -- Complete Phase 4: Plugin Integration -- Complete Phase 5: Hot Reload -- Create example app with monetization + crashlytics -- Integration testing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6344c59 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing to oka + +Full guide lives in the docs: **[Contribution guide](docs/contributing/contribution_guide.md)**. + +TL;DR: + +```bash +make install && make lint && make test +``` + +- Non-negotiables in [`AGENTS.md`](AGENTS.md) — especially: the default build + path must never fall back to Gradle. +- Behavior SSOT is code + tests; docs link, never paraphrase. +- Design forks need an ADR in `docs/decisions/` before coding. diff --git a/Makefile b/Makefile deleted file mode 100644 index 9b0a89c..0000000 --- a/Makefile +++ /dev/null @@ -1,37 +0,0 @@ -.PHONY: help install global clean test lint logcat - -help: ## Show this help message - @echo 'Usage: make [target]' - @echo '' - @echo 'Available targets:' - @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) - -install: ## Install dependencies - dart pub get - -global: ## Rebuild and install global version (clears snapshot cache) - @echo "🔄 Deactivating current version..." - @dart pub global deactivate oka 2>/dev/null || true - @echo "🗑️ Clearing snapshot cache..." - @rm -rf .dart_tool/pub/bin/oka - @echo "📦 Installing global version..." - @dart pub global activate --source path . - @echo "✅ Done! Test with: oka --version" - -clean: ## Clean build artifacts and caches - @rm -rf .dart_tool - @rm -rf build - @echo "✅ Cleaned build artifacts" - -test: ## Run tests - dart test - -lint: ## Run linter - dart analyze - -dev: ## Run oka locally without global install - dart run bin/oka.dart - -logcat: ## View logcat - adb logcat | grep com.example.example/com.example.example.MainActivity - diff --git a/README.md b/README.md index efe3030..9698f31 100644 --- a/README.md +++ b/README.md @@ -1,217 +1,324 @@ -# Oka: AI-Powered Flutter Android Build System - -Oka is a modern build system that replaces Gradle for Flutter Android builds, providing **3-5x faster builds** with integrated hot reload and AI-assisted configuration. - -## Features - -- 🚀 **3-5x Faster Builds** - Direct Android SDK tool invocation, no Gradle overhead -- 🤖 **AI-Assisted Configuration** - Automatic Gradle-to-oka.yaml conversion -- ⚡ **Hot Reload Integration** - <200ms Dart hot reload, incremental native builds -- 📦 **Simple Configuration** - YAML-based, pub-style dependency resolution -- 🎯 **Zero Runtime Overhead** - Extension type models for type safety -- 🔧 **Developer Friendly** - Clear errors, verbose mode, integrated doctor command - -## Quick Start - -### Installation +# Oka — one code for every platform build + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Docs](https://img.shields.io/badge/docs-docs.page-02569B)](https://docs.page/arenukvern/oka) +[![CI](https://github.com/Arenukvern/oka/actions/workflows/ci.yml)](https://github.com/Arenukvern/oka/actions/workflows/ci.yml) + +**Oka is a declarative, compositional, AI-native build system.** Platform +build configs are locked, scattered, and endlessly repeated — gradle DSL, +XML manifests, plists, per-store `index.html` surgery. Oka collapses them +into one typed, copyable Dart surface that both humans and agents run, +inspect, and fix. The promise is kept **deepest for Android** — a no-Gradle +pipeline (`flutter assemble` + direct Android SDK tools: no daemon, no AGP) — and extends to +**web distribution** (the shell station: per-store `web/index.html` as +typed, drift-checked Dart + deploy targets). Three words carry the design: + +- **Declarative** — the build is typed values (`AndroidBuild`, + `ManifestSpec`, `WebShellSpec`, …) composed in a project-owned Dart + entrypoint. Config is code: checkable, diffable, copyable between projects. +- **Compositional** — every capability is a `BuildStep` or typed value; the + whole chain is validated before any tool runs. A store target, a platform, + or a deploy flow is another package over the same kernel. +- **AI-native** — self-describing plans (`oka explain`), single-step probes, + byte-equivalence gates, and failures that name the fix. _An agent can set + up and fix a platform build from oka's messages alone._ + +Production-validated on real apps (18-plugin production app; +bundletool-validated release AABs). + +## Quickstart: first build in under two minutes + +Install once — either the one-liner (installs via `dart pub global +activate`; requires Dart, not Flutter): ```bash -# Clone repository -git clone https://github.com/yourusername/oka.git -cd oka - -# Install dependencies -dart pub get - -# Activate globally -dart pub global activate --source path . +curl -fsSL https://raw.githubusercontent.com/Arenukvern/oka/main/install.sh | bash ``` -### Setup - -1. **Check system requirements:** +or directly: ```bash -oka doctor +dart pub global activate oka +oka get android-sdk # one-time SDK bootstrap into ~/.oka/android-sdk +oka doctor # verify everything ``` -2. **Set up Gemini API key** (for AI-assisted Gradle conversion): +**Build.** In your Flutter project, `oka init` scaffolds the config as a +typed Dart entrypoint (`tool/oka_pipeline.dart`) — or converts an existing +`oka.yaml` 1:1 with `oka init --from-yaml`. Then build release, no Gradle: ```bash -export GEMINI_API_KEY="your-api-key" -# Get your key from: https://makersuite.google.com/app/apikey -``` - -### Usage - -**Initialize a Flutter project:** - -```bash -cd your-flutter-project oka init +oka build apk --release # → .oka_cache/build/release/app-release.apk ``` -**Build APK:** +**Run on a device.** One command installs the newest built APK, launches it, +and scans the device log for failure signatures (`oka launch` = same +dispatch): ```bash -# Debug build (default) -oka build apk - -# Release build -oka build apk --release - -# With verbose output -oka build apk --verbose +oka run device ``` -**Development mode** (coming soon): +**Dev loop.** Hot reload / hot restart against an oka-built APK — parity +check → install → launch → attach session. Humans get `r` / `R` / `q` / `d`; +agents get `--json` events on stdout and control lines on stdin, or the +hands-free `--watch` loop: ```bash -oka dev +oka dev # TTY session: r hot reload · R hot restart · q quit · d detach +oka dev --watch --json # agent loop: Dart edits auto-reload; native edits + # print the honest full-rebuild command ``` -**Clean cache:** +Hot reload is Dart-only — native/res/manifest changes always need +`oka build apk --debug` + reinstall ([ADR-0011](docs/decisions/0011-hot-reload-run-loop.md)). -```bash -# Clean build cache -oka clean +Existing project on `oka.yaml`? `oka init --from-yaml` converts it 1:1. +Migrating from Gradle: the [migration guide](https://docs.page/arenukvern/oka/guides/gradle_migration). -# Clean everything including dependencies -oka clean --full +## Publishing: targets are project-declared -# Clean AI conversion cache -oka clean --ai-cache -``` +Play and AppGallery builds are **one Android app, composed differently** — +not new CLIs, not new platforms ([ADR-0014](docs/decisions/0014-distribution-targets-secrets-model.md)). +Declare publish targets in `tool/oka_pipeline.dart` and run them with +`oka run `. Both are **dry-run by default**: the plan names the +endpoint, track, artifact, and metadata before anything ships, and +succeeds without credentials. Real, from the [example app](example/tool/oka_pipeline.dart): -## Configuration +```dart +targets: const [ + DeviceTarget(), + // Dry-run by default: `oka run publish-play` prints the plan, zero HTTP. + // Real run: `dryRun: false` + a service-account JSON referenced BY PATH + // (tier-2 credential — never a dart-define, never a value). + PlayPublishTarget(), + // GMS-excluded variant + AppGallery Connect tail: `oka run publish-huawei`. + HuaweiPublishTarget( + release: HuaweiReleaseConfig(appId: '110012345'), + ), +), +``` -Oka uses `oka.yaml` for configuration: - -```yaml -name: my_app -version: 1.0.0 - -android: - compile_sdk: "34" - min_sdk: "21" - target_sdk: "34" - package_name: com.example.myapp - version_code: 1 - version_name: 1.0.0 - source_dirs: - - src/main/java - - src/main/kotlin - res_dirs: - - src/main/res - abis: - - arm64-v8a - - armeabi-v7a - -dependencies: - - name: androidx.core:core-ktx - version: 1.10.0 - source: maven - - name: androidx.appcompat:appcompat - version: 1.6.1 - source: maven +```bash +oka build aab --release --verify-aab +oka run publish-play # oka run publish-huawei ``` -## Architecture +Secrets follow the ADR-0014 tier rule: dart-defines carry app-visible +non-secrets; credentials are **paths** resolved via typed config → +`OKA__*` env var → `~/.oka/credentials//`. Full console +setup, file formats, and failure playbook: the +[publishing guide](https://docs.page/arenukvern/oka/guides/publishing). -### Extension Type Models +## Web: the shell station, not a platform -All data models use Dart extension types for zero runtime overhead: +Web apps get the same treatment at the configuration-and-distribution +layer — where the real pain lives (per-store `index.html` surgery, +branch-per-store drift). `oka_web` composes the shell (SDK scripts with +declarative ordering phases, preconnects, PWA manifest, icons) as typed, +const, **drift-checked** Dart; store packages ship contributions; deploy +targets push to GitHub Pages and itch.io — dry-run by default. The +compile stays an honest, named delegation to `flutter build web` +([ADR-0016](docs/decisions/0016-web-shell-station-store-contributions.md)): ```dart -extension type const OkaConfig(Map value) { - factory OkaConfig.fromJson(dynamic json) => OkaConfig(jsonDecodeMap(json)); - - AndroidConfig get android => AndroidConfig.fromJson(value['android']); - List get dependencies => /* ... */; - - Map toJson() => value; -} +targets: const [ + WebShellTarget(spec: mySpec, contributions: [MyStoreContribution()]), + WebBuildTarget(), // delegates to flutter build web + GhPagesDeployTarget(), // oka run publish-gh-pages +], ``` -### AI-Assisted Conversion +One codebase, one composition — no per-store branches. Full walkthrough: +the [web shell station guide](https://docs.page/arenukvern/oka/guides/web_shell_station). -Oka uses AI (Apple Foundation Models on macOS, Gemini fallback) to convert Gradle configurations: +## The agent surface -1. Reads `build.gradle` as text (no parsing) -2. Sends to AI with structured prompts -3. AI extracts dependencies, SDK versions, configuration -4. Converts to oka.yaml format -5. Caches conversion for offline use +Every operation is checkable and scriptable — this is what "AI-native" +means here, not a chat wrapper: -### Build Pipeline +``` +| Command | What an agent gets | +|---|---| +| `oka explain` / `oka build --dry-run` | The validated plan: steps, artifact chain, signing, versions — zero tools invoked | +| `oka explain --targets` | Every project-declared target with its compiled step chain (ADR-0015) | +| `oka debug step ` | One pipeline step re-run against `.oka_cache` — 10-minute loops become 30-second probes | +| `oka compare a.apk b.apk` | Byte-equivalence gate (badging + zip entries) — refactors prove, not claim | +| `oka cache list/gc/why` | Inspectable views over the shared artifact store (ADR-0013) | +| `oka doctor` | Full environment + build-health audit, including secret-tier and dev-loop readiness | +``` -1. **Resource Compilation** - `aapt2 compile` and `link` -2. **Source Compilation** - `kotlinc` and `javac` -3. **DEX Conversion** - `d8` (debug) or `r8` (release with optimization) -4. **APK Packaging** - ZIP structure with resources and DEX -5. **Signing** - `apksigner` with debug or release keystore -6. **Zipalign** - APK optimization +## Configuration -## Requirements +Config is a typed Dart entrypoint — programmable, refactorable, +agent-writable: -- **Flutter SDK** - Latest stable version -- **Android SDK** - With build-tools, platform-tools -- **JDK** - Version 11 or later -- **Kotlin** - Optional (will be downloaded if needed) -- **Gemini API Key** - For AI-assisted Gradle conversion +```dart +// tool/oka_pipeline.dart +Future main(List args) => okaRun( + args, + oka: const Oka( + pipelines: [ + AndroidPipeline( + config: AndroidBuild( + name: 'my_app', + packageName: 'com.example.my_app', + minSdk: '23', targetSdk: '36', compileSdk: '36', + versionCode: 51, versionName: '1.0.0', + javaVersion: 17, + ), + overrides: PipelineOverrides( + resourceConfigs: ['en', 'ru'], + manifest: ManifestSpec(permissions: [/* … */]), + deeplinks: [DeeplinkConfig(scheme: 'https', host: 'my.app')], + ), + steps: [...AndroidPipeline.defaultSteps], + ), + ], + ), +); +``` -Run `oka doctor` to verify all requirements. +Prefer YAML? `oka.yaml` fast-settings remain fully supported +(`oka init --yaml`); `oka init --from-yaml` migrates. Precedence: +defaults < `oka.yaml` < Dart config < CLI args. + +Full reference: [build & configuration guide](https://docs.page/arenukvern/oka/guides/build_and_config). + +## How it works + +1. Host checks + codegen (manifest, `MainActivity`, adaptive vector icons) +2. `flutter assemble` (assets / kernel / AOT) — never `flutter build apk` +3. Engine `libflutter.so` extraction from the Flutter cache +4. Plugin packaging + Maven/AAR resolution (parallel, deterministic) +5. `aapt2` → `javac`/`kotlinc` → `d8` compile-and-dex (sorted, reproducible) +6. Extra assets, zip staging, `zipalign -p 4`, `apksigner` +7. Layout validation + post-build lint + +Every step is a `BuildStep` with declared `requires`/`provides` typed +artifacts; the whole chain is validated **before any tool runs**. Copyable +canonical config: [example/tool/oka_pipeline.dart](example/tool/oka_pipeline.dart). + +## FAQ + +**Do I need Gradle?** +No — ever. The default path never falls back to `flutter build apk` +(enforced by tests). What you give up is real and listed: no full +Gradle/AGP compatibility (AIDL, RenderScript, data binding, NDK), and some +plugins with complex native Android code fail loudly instead of silently +([ADR-0001](docs/decisions/0001-no-gradle-default-build-path.md)) — the +[migration guide](https://docs.page/arenukvern/oka/guides/gradle_migration) +maps what transfers, what needs config, and what oka does not do. + +**Does it work with my Flutter version / SDK layout?** +Oka drives `flutter assemble` and the Flutter cache, so it tracks your +installed Flutter SDK (`fvm`-managed included) rather than pinning one. The +Android build-tools/platforms are self-resolved — `oka get android-sdk` +bootstraps a managed SDK into `~/.oka/android-sdk`, or point at an existing +one; resolution order is explicit config → env vars → oka-managed → system, +printed by `oka doctor`. Run `oka doctor` to verify your layout. + +**Where is the cache, and can I inspect it?** +Yes — that's the contract ([ADR-0013](docs/decisions/0013-toolchain-provisioning-artifact-store.md)). +The shared store is a plain directory with human-decodable layout +(`~/.oka/store/aapt2/8.0.2-/…`), relocatable via `OKA_CACHE`; +per-project build outputs stay in `.oka_cache/`. Inspect with `ls` — or +with `oka cache list`, `oka cache gc --older-than=30d`, and +`oka cache why androidx/annotation-jvm/1.9.1`. + +**How do secrets work?** +By tier ([ADR-0014](docs/decisions/0014-distribution-targets-secrets-model.md)): +`--dart-define` values are compile-time constants baked into the shipped +binary — non-secrets only. Credential contents (service-account JSON, +keystores) are build-host files referenced **by path**, resolved through +typed config → `OKA__*` env var → `~/.oka/credentials//`, +kept out of git and out of every log, plan, and state dump. `oka doctor` +audits define keys against secret-ish patterns. + +**Why Dart instead of YAML?** +YAML keys are silent typos; Dart is typed, refactorable, programmable +(flavor logic, shared bases), and exactly as writable by agents as by +humans. `oka.yaml` fast-settings still cover the 90% case — and the design +law is that YAML growth is frozen; everything else is Dart +([ADR-0010](docs/decisions/0010-typed-dart-project-config.md)). + +**Which platforms?** +Android is the first and deepest platform — APK and AAB, debug and release, +with the full agent dev loop. Web is served at the +configuration-and-distribution layer (the shell station + deploy targets, +ADR-0016) — the web compile stays an honest delegation to +`flutter build web`. Further platforms (iOS, desktop) are +**criteria-gated**, not calendar-driven: depth before breadth (the dev loop +is the product), no platform detail in `oka_core`, and a new platform only +when an ADR proves the pipeline model maps. Store targets (Play, +AppGallery) are not platforms — they're compositions over the same Android +pipeline. See [the long game](#the-long-game). + +## Packages -## Performance Targets +``` +| Package | Purpose | +|---|---| +| [`oka`](https://pub.dev/packages/oka) | CLI + agent surface (this repo) | +| [`oka_core`](https://pub.dev/packages/oka_core) | Platform-agnostic contracts: pipeline, artifacts, composition root, typed config | +| [`oka_android`](https://pub.dev/packages/oka_android) | Android pipelines, toolchain, plugin packaging | +| [`oka_play`](https://pub.dev/packages/oka_play) | Google Play publish target (dry-run-first, path-based credentials) | +| [`oka_huawei`](https://pub.dev/packages/oka_huawei) | AppGallery Connect target (GMS-excluded variant + upload tail) | +| [`oka_web`](https://pub.dev/packages/oka_web) | Web shell station: per-store `web/index.html` as typed Dart, emitters, deploy targets | +``` -- **Initial build:** Match or beat Gradle -- **Incremental build:** 3-5x faster than Gradle -- **Hot reload:** <200ms for Dart changes -- **Native rebuild:** <5s (vs 30s+ with Gradle) +## The long game -## Limitations +One platform proves the model; the model is built for many. The architecture +is already split for it: `oka_core` is platform-agnostic, and a platform is +just another `PlatformPipeline` selected by `oka --platform`. Expansion is +deliberately **criteria-gated**, not calendar-driven: -Current version does not support: +1. **Depth before breadth.** Android ships the full agent loop + (`oka dev`, ADR-0011) before a second platform starts — the loop is the + product. +2. **Abstraction hygiene.** No platform detail leaks into `oka_core`; any + capability that can't be expressed as `PlatformPipeline`/`BuildStep`/typed + value is a design smell to fix first. +3. **Second platform = iOS** (the strongest candidate: full CLI toolchain, + highest Flutter demand, signing/provisioning is exactly what agents need + help with) — gated on an ADR proving the pipeline model maps + (assemble → compile → codesign → validate). Desktop (MSIX et al.) is the + cheap third. Web's compile step needs nothing oka-shaped — but its + configuration and store layer does, served by the ADR-0016 shell + station without a platform pipeline. -- ❌ build_runner / code generation (use Flutter tools separately) -- ❌ Complex Android features (AIDL, RenderScript, data binding) -- ❌ NDK/native C++ compilation -- ❌ Android App Bundle (AAB) - planned for future -- ❌ 100% Gradle compatibility - targets common use cases +Full charter: [why oka matters](https://docs.page/arenukvern/oka/start_here/why_this_repo_matters). -## Example Apps +## Documentation -See `example_app/` for a test app with: +Published via docs.page: **[docs.page/arenukvern/oka](https://docs.page/arenukvern/oka)** -- In-app purchases (monetization) -- Firebase Crashlytics integration -- Basic UI to validate real-world plugin compatibility +``` +| I want to… | Read | +|---|---| +| Copy-paste the common loops | [Quick recipes](https://docs.page/arenukvern/oka/start_here/quick_recipes) | +| Run/build/test/configure | [Build & configuration guide](https://docs.page/arenukvern/oka/guides/build_and_config) | +| Publish to Play / AppGallery | [Publishing guide](https://docs.page/arenukvern/oka/guides/publishing) | +| Ship a web app to stores / hosting | [Web shell station guide](https://docs.page/arenukvern/oka/guides/web_shell_station) | +| Migrate an existing Gradle app | [Gradle migration guide](https://docs.page/arenukvern/oka/guides/gradle_migration) | +| Know why it's designed this way | [Design FAQ](https://docs.page/arenukvern/oka/guides/design_faq) | +| Check phase status | [`docs/PHASE_CHECKLIST.md`](docs/PHASE_CHECKLIST.md) | +``` ## Contributing -Contributions welcome! This is an experimental project exploring: +Contributions welcome! See the +[contribution guide](https://docs.page/arenukvern/oka/contributing/contribution_guide). +Releases are automated via release-please — use conventional commits +(`feat:`, `fix:`, `docs:`); run `just check-contracts` before merging. +Agents: start from [`AGENTS.md`](AGENTS.md). -- AI-assisted build configuration -- Direct Android SDK tool usage -- Modern Dart patterns (extension types) -- Flutter build system alternatives +## Security -## License +See [`SECURITY.md`](SECURITY.md). Please report vulnerabilities privately. -MIT License - see LICENSE file for details - -## Roadmap - -- [ ] Complete hot reload integration -- [ ] AAR dependency processing -- [ ] Plugin system for custom build steps -- [ ] Android App Bundle (AAB) support -- [ ] Support for 10-15 popular Flutter plugins -- [ ] Build cache sharing across machines -- [ ] CI/CD integration examples - -## Acknowledgments +## License -- Inspired by the need for faster Flutter Android builds -- Uses Apple Foundation Models and Google Gemini for AI assistance -- Built with modern Dart features (extension types, from_json_to_json) +MIT — see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..cc922c9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Supported versions + +Oka is pre-1.0; only the latest published version receives security fixes. + +## Reporting a vulnerability + +Please do **not** open a public issue for security problems. + +- Email: use the GitHub contact for [@Arenukvern](https://github.com/Arenukvern) + or open a [GitHub security advisory](https://github.com/Arenukvern/oka/security/advisories/new). + +Include: affected version/commit, reproduction steps, and impact. You can +expect an initial response within 7 days. + +## Scope notes + +- Oka executes local build tooling (`aapt2`, `javac`, `d8`, `apksigner`, …) + and downloads artifacts from Google Maven / Maven Central into + `~/.oka/cache/maven`. Reports involving artifact integrity, command + injection via `oka.yaml` fields, or path traversal in cache handling are + in scope. +- The AI-assisted Gradle conversion sends `build.gradle` text to the + configured model provider (Apple Foundation Models / Gemini). Do not put + secrets in Gradle files. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..c946ee6 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.6 diff --git a/analysis_options.yaml b/analysis_options.yaml index 6fd349f..191196f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,25 +1,54 @@ -include: package:lints/recommended.yaml +include: package:xsoulspace_lints/library.yaml analyzer: + exclude: + # The example is a Flutter app: analyzed by `flutter analyze` in example/. + - example/** + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** language: strict-casts: true strict-inference: true strict-raw-types: true errors: + # xsoulspace_lints 0.1.2 enables prefer_final_parameters, deprecated in + # Dart 3.13; we disable the rule below and silence the deprecation + # diagnostic until the lint set is updated. + deprecated_lint: ignore missing_required_param: error missing_return: error todo: ignore linter: rules: - - always_declare_return_types - - always_put_control_body_on_new_line - - avoid_print: false - - avoid_relative_lib_imports - - prefer_const_constructors - - prefer_const_declarations - - prefer_final_fields - - prefer_final_locals - - unnecessary_null_checks - - use_super_parameters + # Deprecated upstream (Dart 3.13); house style keeps parameters + # non-final — silencing avoids ~150 mechanical rewrites. + prefer_final_parameters: false + # oka is a CLI build tool: stdout progress reporting is part of the UX. + avoid_print: false + # The build pipeline deliberately uses synchronous dart:io for small, + # deterministic file operations inside build steps. + avoid_slow_async_io: false + # Build steps report tool failures as build errors; broad catch clauses + # are intentional at tool boundaries. + avoid_catches_without_on_clauses: false + # Retrofitting ~200 one-line `if` bodies would churn history without + # safety value; keep the existing house style. + always_put_control_body_on_new_line: false + # Doc comments contain long URLs and tool output verbatim. + lines_longer_than_80_chars: false + # Sequential builder calls read clearer than cascades in pipeline code. + cascade_invocations: false + always_declare_return_types: true + prefer_const_constructors: true + prefer_const_declarations: true + prefer_final_fields: true + prefer_final_locals: true + unnecessary_null_checks: true + use_super_parameters: true diff --git a/bin/oka.dart b/bin/oka.dart deleted file mode 100644 index ff3a457..0000000 --- a/bin/oka.dart +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env dart - -import 'dart:io'; - -import 'package:args/args.dart'; -import 'package:oka/src/cli/build_command.dart'; -import 'package:oka/src/cli/clean_command.dart'; -import 'package:oka/src/cli/dev_command.dart'; -import 'package:oka/src/cli/doctor_command.dart'; -import 'package:oka/src/cli/get_command.dart'; -import 'package:oka/src/cli/init_command.dart'; -import 'package:oka/src/version.dart'; - -void main(List arguments) async { - final parser = ArgParser() - ..addFlag('help', abbr: 'h', negatable: false, help: 'Show help') - ..addFlag('version', abbr: 'v', negatable: false, help: 'Show version') - ..addFlag('verbose', negatable: false, help: 'Verbose output'); - - try { - if (arguments.isEmpty) { - _printUsage(parser); - exit(0); - } - - final command = arguments[0]; - final commandArgs = arguments.skip(1).toList(); - - if (command == '--help' || command == '-h') { - _printUsage(parser); - exit(0); - } - - if (command == '--version' || command == '-v') { - final okaVersion = getOkaVersion(); - print('Oka version $okaVersion'); - exit(0); - } - - switch (command) { - case 'init': - await InitCommand().run(commandArgs); - break; - case 'build': - await BuildCommand().run(commandArgs); - break; - case 'dev': - await DevCommand().run(commandArgs); - break; - case 'doctor': - await DoctorCommand().run(commandArgs); - break; - case 'clean': - await CleanCommand().run(commandArgs); - break; - case 'get': - await GetCommand().run(commandArgs); - break; - default: - print('Unknown command: $command'); - _printUsage(parser); - exit(1); - } - } catch (e, stackTrace) { - print('❌ Error: $e'); - if (arguments.contains('--verbose')) { - print(stackTrace); - } - exit(1); - } -} - -void _printUsage(ArgParser parser) { - print(''' -Oka - AI-powered Flutter Android build system - -Usage: oka [options] - -Commands: - init Initialize oka.yaml configuration from existing Gradle project - build Build APK or AAB - dev Start development mode with hot reload - doctor Check system requirements and configuration - get Install missing Android SDK dependencies - clean Clean build cache - -Options: -${parser.usage} - -Examples: - oka init # Initialize oka.yaml from Gradle - oka build apk --release # Build release APK - oka dev # Start dev mode with hot reload - oka doctor # Check system setup - oka get r8 # Install R8 optimizer - -For more information, visit https://github.com/yourusername/oka -'''); -} diff --git a/docs.json b/docs.json new file mode 100644 index 0000000..b477e54 --- /dev/null +++ b/docs.json @@ -0,0 +1,160 @@ +{ + "$schema": "https://docs.page/schema.json", + "name": "Oka", + "description": "No-Gradle build system for Flutter Android: flutter assemble + direct Android SDK tools for 3-5x faster incremental builds.", + "github": "https://github.com/Arenukvern/oka", + "theme": { + "defaultTheme": "system", + "primary": "#02569B" + }, + "header": { + "showName": true, + "showThemeToggle": true, + "showGitHubCard": true, + "links": [ + { + "title": "GitHub", + "href": "https://github.com/Arenukvern/oka" + }, + { + "title": "Get started", + "href": "/start_here/quick_recipes", + "cta": true + } + ] + }, + "anchors": [ + { + "icon": "github", + "title": "GitHub", + "href": "https://github.com/Arenukvern/oka" + }, + { + "icon": "book-open", + "title": "pub.dev", + "href": "https://pub.dev/packages/oka" + } + ], + "social": { + "github": "Arenukvern" + }, + "seo": { + "noindex": false + }, + "variables": { + "repo": "https://github.com/Arenukvern/oka", + "docs": "https://docs.page/arenukvern/oka" + }, + "content": { + "headerDepth": 3, + "zoomImages": true, + "automaticallyInferNextPrevious": true, + "showPageTitle": true + }, + "sidebar": [ + { + "group": "Start Here", + "pages": [ + { + "title": "Overview", + "href": "/" + }, + { + "title": "Why This Repo Matters", + "href": "/start_here/why_this_repo_matters" + }, + { + "title": "Quick Recipes", + "href": "/start_here/quick_recipes" + }, + { + "title": "Docs Map", + "href": "/start_here/docs_map" + }, + { + "title": "Roadmap", + "href": "/start_here/roadmap" + } + ] + }, + { + "group": "Guides", + "pages": [ + { + "title": "Build & Configuration", + "href": "/guides/build_and_config" + }, + { + "title": "Gradle Migration", + "href": "/guides/gradle_migration" + }, + { + "title": "Publishing", + "href": "/guides/publishing" + }, + { + "title": "Web Shell Station", + "href": "/guides/web_shell_station" + }, + { + "title": "Design FAQ", + "href": "/guides/design_faq" + } + ] + }, + { + "group": "Decisions", + "pages": [ + { + "title": "Overview", + "href": "/decisions/index" + }, + { + "title": "0000 — Adopt ADRs + doc lattice", + "href": "/decisions/0000-adopt-adr-and-doc-lattice" + }, + { + "title": "0001 — No-Gradle default build path", + "href": "/decisions/0001-no-gradle-default-build-path" + }, + { + "title": "0002 — Composable build pipeline", + "href": "/decisions/0002-composable-build-pipeline" + }, + { + "title": "0003 — Vector-first launcher icons", + "href": "/decisions/0003-vector-first-launcher-icons" + }, + { + "title": "0004 — No-Gradle AAB bundle", + "href": "/decisions/0004-no-gradle-aab-bundle" + }, + { + "title": "0005 — Release tooling + plugin distribution", + "href": "/decisions/0005-release-tooling-and-plugin-distribution" + } + ] + }, + { + "group": "Project", + "pages": [ + { + "title": "Phase Checklist", + "href": "/PHASE_CHECKLIST" + }, + { + "title": "Contributing", + "href": "/contributing/contribution_guide" + }, + { + "title": "Changelog", + "href": "https://github.com/Arenukvern/oka/blob/main/CHANGELOG.md" + }, + { + "title": "Security", + "href": "https://github.com/Arenukvern/oka/blob/main/SECURITY.md" + } + ] + } + ] +} diff --git a/docs/PHASE_CHECKLIST.md b/docs/PHASE_CHECKLIST.md new file mode 100644 index 0000000..515fadb --- /dev/null +++ b/docs/PHASE_CHECKLIST.md @@ -0,0 +1,158 @@ +# Phase Checklist — open work + +Only items that need addressing live here. This is the **execution view**: +what + what gates it, per horizon. The human-facing "where oka is going and +why" is [start_here/roadmap.md](start_here/roadmap.md). + +Completed phases are archived with their evidence: + +- ADR-0013 (T0–T2), ADR-0014 (P0–P2), ADR-0015 (C0–C2), ADR-0011 (H0–H5), + ADR-0010 typed config, ADR-0009 hybrid removal → + [archive/PHASE_CHECKLIST_2026-09.md](archive/PHASE_CHECKLIST_2026-09.md) +- ADR-0006/0007/0008 (composition API, self-resolving builds, dep-plan + dry-run) → + [archive/PHASE_CHECKLIST_adr0006-0008.md](archive/PHASE_CHECKLIST_adr0006-0008.md) + +A phase is done only when its tests and evidence exist (see `AGENTS.md`). +Nothing below carries a checkbox — items move out of here into an archive +with evidence, not into a checked box. + +## Now (unblocked, code-ready) + +- **Done (ADR-0016 W0–W2, pending archive with evidence):** + **W0** — `oka_web` shell station package (typed spec/contribution/entries, + generate + inject emitters, emit + zip steps, `web-shell`/`web-build` + targets). Evidence: `packages/oka_web` tests. **W1** — shell drift gate + (pure `checkShellDrift` + post-emit idempotency check in + `EmitWebShellStep`), `oka explain --targets` shell render via the generic + `Target.explainDetails` hook, `docs/guides/web_shell_station.md`. Evidence: + `packages/oka_web/test/drift_test.dart`, `test/adr0015_explain_targets_test.dart` + + this suite. **W2** — `publish-gh-pages`, `publish-itch`, `WebZipStep`, + directory-artifact convention asserted in conformance. Evidence: + `packages/oka_web/test/{gh_pages,itch}_target_test.dart`. +- **S0: Chrome session target (ADR-0017).** `BrowserSessionSpec` + + `DebugProtocol` + `profilePersistence` typed values, okaOwned launcher + (spawn with `--remote-debugging-port`, `/json/version` readiness probe, + idempotent reuse, ephemeral teardown), `chrome-session` target producing + `session-chrome--handle` / `session-chrome--cdp-port` + artifacts, `chromeWebMcp` profile const, pure launch-args construction. + No CDP client, no new deps, Chrome only (Servo/Ladybird and matrices + deferred by ADR-0017). Gate: ADR-0017. Evidence: scripted-fake + unit + tests in `packages/oka_web/test/`. +- **S1: browser doctor + provisioning.** `oka doctor` browser detection + (engines, versions, flag support); chrome-for-testing into the ADR-0013 + artifact store. Gate: S0. +- **S2: dev-loop session + delegated retirement.** `oka dev` composes a + long-lived chrome session; `flutterDelegated` shrinks to the documented + migration path; `EmulatorTarget` gains the `session--handle` + conforming alias (non-breaking). Gate: S0 + dev-loop evidence. + +- **`oka dev --control-port` (delegation channel) — shipped with + evidence; archive on next checklist pass.** The oka side of the frozen + contract is live: a loopback (`127.0.0.1`-only, **no auth** — a + localhost-only dev tool, documented as such) TCP JSON-lines server maps + `reload` / `restart` / `stop` / `status` onto the owning flutter-tool + daemon session — the only compile-capable channel (VM-service + `ServiceRegistered` events never replay already-registered services, so + late-attach tool reloads are silent no-ops). The chosen port and the + forwarded `vm_service_uri` are published to the spec-v2 + `.flutter_mcp/runner-session.json` (toolkit-neutral Dart dev session + contract; `runner: "oka-dev"` display metadata, schema 1) at each + `session.ready` and both discovery files are cleared on every exit + path. Evidence: `test/adr0011_control_server_test.dart` (real + loopback sockets: reload/restart incl. fallback-then-EOF, stop, status, + malformed JSON, unknown method, sequential clients, timeout, + port-from-runner-session), runner-session schema rejection + lifecycle + (`readRunnerSessionFile` rejects unknown schema; write-on-ready / + clear-on-exit; the toolkit's `.flutter_mcp/state.json` untouched; the + old `.oka_cache/dev/session.json` path no longer written), `just lint` + + `just test` green. Editor/agent wiring: + `docs/guides/hot_reload_plan.md` → "Wiring an editor or agent to the + delegation channel". + +- **Web icon rasterization decision.**- **Web icon rasterization decision.** Web manifest icons need PNGs + (unlike Android's vector XML, ADR-0003); decide the image toolkit for + generating sized PNGs from one source. Gate: image-toolkit checkpoint. +- **Real store uploads (Play + AppGallery).** Flip `PlayPublishTarget` / + `HuaweiPublishTarget` from dry-run to a real upload with maintainer + service-account / AGC credentials and record one live upload each as + evidence. Why now: P1/P2 dry-run plans, full offline conformance suites, + and flow tests exist — only credentials block; the synthetic test key + guards nothing. +- **CI emulator tier for the dev-loop e2e.** Reproduce the H0/H2/H3 + headless-emulator chain (`oka run device` → `oka dev --json` → reload / + restart / detach) as a CI job on a KVM-capable runner. Why now: the + evidence is machine-local; a CI tier turns it into a regression gate for + the whole dev loop. +- **Doctor secret-audit hardening (if gaps surface).** Extend + `secretishKeyPatterns` / `auditDartDefines` coverage where real-world + dart-define keys slip through the pattern table. Why now: the audit is + live and cheap to extend, but pattern tables need evidence of real gaps + before growing. +- **`oka cache gc` polish: age/size reporting.** Make `oka cache gc` + report reclaimable bytes and last-use age before deleting, so purging is + inspectable like everything else. Why now: the store layout + (`oka_store.json` per entry) already records what's needed; this is pure + reporting on existing data. +- **Leakage-ratchet final exception: the `--skip-badging` flag name.** + Decide whether to keep it as public CLI surface or deprecate it toward + the typed `DeviceTarget`/`AndroidBuild` config and empty the ratchet. + Why now: it is the only remaining exception in + `test/adr0015_cli_platform_leakage_gate_test.dart`; either closing or + ratifying it lets the gate go fully green-by-construction. + +## Next (needs a checkpoint / ADR) + +- **Session matrices / mesh (ADR-gated, future).** N concurrent + cross-engine sessions with rendezvous artifacts (the mesh case: one app + alive in several browsers) as a composition over ADR-0017 + `session--handle` artifacts. Gate: S0 evidence + its own ADR — + parallelism, failure isolation, and teardown-on-partial-failure are + explicit decisions, not defaults. +- **iOS platform candidate.** First second-platform: an `oka_ios` package + with typed `IosBuild` + pipeline, per the north-star criteria. Gate: + design fork → checkpoint + ADR before coding (which parts of + `flutter assemble`/Xcode CLI tools oka owns vs delegates; no-Gradle law + needs an iOS analogue). +- **W3: store-contribution pilot (ADR-0016) — done as pilot; store-package + adoption still pending.** The in-repo flagship + (`packages/oka_web/example/crazygames/`) and a real-world adoption + (word_by_word_game adopted the shell contribution into its own repo via + the inject emitter) prove the §3 pattern. Still open: shipping a const + `WebShellContribution` from an actual store package — gated on `oka_web` + publishing to pub.dev (store packages must not depend on unpublished + packages). +- **RuStore / Yandex publish targets.** `oka_rustore` / `oka_yandex` + following the `oka_play`/`oka_huawei` package shape (`PublishTarget` + + conformance suite + injectable client). Gate: same checkpoint/ADR + question — target packages as a productized third-party extension point + vs first-party additions — before the pattern gets copied twice more. +- **`oka cache gc` scheduling / daemon question.** Whether gc stays a + manual verb or gains a scheduled/daemon form. Gate: ADR — a background + process conflicts with oka's no-daemon posture (ADR-0001) and needs an + explicit decision, not a default. +- **Hot-reload CI tier.** Promote the one-shot emulator e2e (Now) into a + repeatable `oka dev` session tier (watch-loop classification, reconnect + paths). Gate: needs the CI emulator tier to exist and a decision on + runtime budget (session tests are minutes, not seconds). + +## Later (north-star alignment) + +- **Container & server session instances (ADR-0017 future).** Extend the + `session--handle` convention beyond browsers: `apple/container` / + Docker sessions (images via the ADR-0013 artifact store; handle = + container ID + mapped ports — enables Linux testing of oka and Dart + packages) and Dart server process sessions (handle = base URL + health + probe — serves intentcall/mcp-server targets). Independent targets + conforming to the convention; no base class. Gate: S0 evidence + a + lightweight checkpoint per instance. +- **Second/third platform beyond iOS.** Each new platform per the + north-star criteria: typed values, validated pipelines, no hidden glue, + agent-operable from oka's messages alone. Gate: platform evidence (the + iOS ADR outcome) plus a per-platform checkpoint; nothing starts on + vibes. +- **Distribution-target conformance for third-party package authors.** + Expose the ADR-0014 conformance suite (`oka_conformance`) as the + contract third parties satisfy to ship `PublishTarget` packages. Gate: + depends on the Next checkpoint on target packages being first-party vs + an ecosystem surface. diff --git a/docs/archive/PHASE_CHECKLIST.md b/docs/archive/PHASE_CHECKLIST.md new file mode 100644 index 0000000..b098752 --- /dev/null +++ b/docs/archive/PHASE_CHECKLIST.md @@ -0,0 +1,83 @@ +# Oka no-Gradle Flutter APK — phase checklist + +Issue checklist for compiling Flutter apps/games without Gradle (Android SDK +CLI tools only). Mark a phase done only when its tests/evidence exist. + +Evidence roots: + +- Unit tests under `test/` +- Scratch verification logs (goal runs): see harness `{SCRATCH}` + +## Phase 0 — Stop bleeding + +- [x] In-repo phase 0–5 checklist (this file) +- [x] Default build path never shells out to `flutter build apk` / Gradle as success +- [x] Default build never mutates/corrupts shared `rust_wrapper/Cargo.toml` +- [x] cargo-apk hybrid demoted: `--flutter` / cargo path does not rewrite shared TOML +- [x] Tests: `test/phase0_no_gradle_fallback_test.dart`, `test/cargo_apk_manifest_test.dart` + +## Phase 1 — Debug Flutter APK layout (assemble + embedding + package) + +- [x] `flutter assemble` command construction + orchestration (`flutter_assemble.dart`) +- [x] MainActivity + GeneratedPluginRegistrant host codegen (`host_codegen.dart`) +- [x] Package layout: `classes.dex`, `assets/flutter_assets/**`, `lib//libflutter.so` (`apk_layout.dart`) +- [x] Engine artifact extraction from Flutter SDK `flutter.jar` (`engine_artifacts.dart`) +- [x] Default `oka build apk` wires no-Gradle orchestrator (`flutter_apk_builder.dart`) +- [x] Doctor-oriented error when Android SDK / build-tools missing (non-zero exit) +- [x] Tests: `test/flutter_assemble_test.dart`, `test/apk_layout_test.dart`, `test/host_codegen_test.dart` + +## Phase 2 — Release AOT / multi-ABI + +- [x] Release assemble target / `libapp.so` packaging paths +- [x] Multi-ABI selection from `android.abis` / config +- [x] Tests: `test/release_abi_test.dart` + +## Phase 3 — Minimal deps without Gradle + +- [x] Fixed-set embedding + AndroidX resolve/cache +- [x] AAR → `classes.jar` extraction +- [x] Tests: `test/dependency_cache_test.dart` + +## Phase 4 — Plugin discovery + +- [x] Enumerate Flutter plugins from project (`.flutter-plugins-dependencies` / pubspec) +- [x] GeneratedPluginRegistrant for zero plugins and discovered names +- [x] Clear failure for unsupported native complexity +- [x] Tests: `test/plugin_discovery_test.dart` + +## Phase 5 — Rust hybrid quarantine + +- [x] `rust_wrapper` Cargo.toml valid TOML (or documented unused by default) +- [x] Hybrid path not default success path; README/docs note demotion +- [x] Full `dart test` green; example CLI build evidence under scratch + +## Evidence index (tests) + +| Phase | Test file(s) | +|-------|----------------| +| 0 | `phase0_no_gradle_fallback_test.dart`, `cargo_apk_manifest_test.dart` | +| 1 | `flutter_assemble_test.dart`, `apk_layout_test.dart`, `host_codegen_test.dart`, `aapt2_commands_test.dart`, `layout_validation_fail_test.dart`, `packaging_tools_test.dart` | +| 2 | `release_abi_test.dart` | +| 3 | `dependency_cache_test.dart` | +| 4 | `plugin_discovery_test.dart` | +| 5 | rust wrapper validity checked in `cargo_apk_manifest_test.dart` / verify scripts | + +## Definition of done (product) + +1. `oka build apk` in a Flutter project uses assemble + SDK tools only (no Gradle). +2. With Android SDK: debug APK contains dex + flutter_assets + libflutter.so. +3. Without Android SDK: non-zero exit + clear message (run `oka doctor`). +4. Release/multi-ABI and deps/plugin discovery covered by unit tests on real shipped APIs. + +## Post-phase additions (evidence) + +| Feature | ADR | Tests | Notes | +|---|---|---|---| +| Composable pipeline | [0002](decisions/0002-composable-build-pipeline.md) | full `dart test` suite; example app built + installed on device | steps in `lib/src/pipeline/` | +| resources.arsc stored uncompressed + zipalign -p 4 | — | device install verification (Android 11+ rejects otherwise) | `apk_layout.dart` | +| Dependency recovery (`oka get dep`, crash mapping) | 0002 | `test/dependency_suggest_test.dart` | `dependency_suggest.dart` | +| AAR processing (natives + res from Maven & local AARs) | 0002 | `test/aar_processing_test.dart`; verified: local AAR natives + res in compiled APK | `dependency_cache.dart` (`extractAarPayload`), `_LocalAarsStep` | +| Custom pipeline Dart API example | 0002 | `example/bin/custom_pipeline.dart` ran end-to-end with custom steps | — | +| Extra assets / deeplinks fast-settings | 0002 | `test/asset_steps_test.dart`; verified in APK + on-device deeplink launch | `asset_steps.dart` | +| Vector-first launcher icons | [0003](decisions/0003-vector-first-launcher-icons.md) | `test/launcher_icon_test.dart`; `aapt2 dump badging` shows icon | `launcher_icon.dart` | +| No-Gradle AAB (hand-assembled bundle) | [0004](decisions/0004-no-gradle-aab-bundle.md) | `test/aab_layout_test.dart`, `test/aab_pipeline_test.dart` | `aab_layout.dart`, proto link in `toolchain.dart` | diff --git a/docs/archive/PHASE_CHECKLIST_2026-09.md b/docs/archive/PHASE_CHECKLIST_2026-09.md new file mode 100644 index 0000000..a929c50 --- /dev/null +++ b/docs/archive/PHASE_CHECKLIST_2026-09.md @@ -0,0 +1,478 @@ +# Archived phase checklist — ADR-0013/0014/0015 + ADR-0011 phases (2026-09) + +> Archived 2026-09: all listed phases complete with evidence. +> Open work now lives in [docs/PHASE_CHECKLIST.md](../PHASE_CHECKLIST.md). +> Earlier completed phases (ADR-0006/0007/0008) are archived in +> [PHASE_CHECKLIST_adr0006-0008.md](PHASE_CHECKLIST_adr0006-0008.md). + +# Phase Checklist (archived) + +Evidence-based phase tracking (see `AGENTS.md` non-negotiables). A phase is +done only when its tests and evidence exist. + +## ADR-0013 — Toolchain, provisioning, artifact store + +- [x] **T0 — ArtifactStore contract + cache unification.** + `ArtifactStore`/`ContentKey` in `oka_core`; plain-directory + `LocalArtifactStore` with human-decodable layout; unify + `~/.oka/cache/androidx`, `~/.oka/tools`, and dependency caches behind + it; `oka cache list/gc/why` as interface views. Inputs shared, + outputs per-project. Tests: key-function + store round-trip; cache + listing evidence. + Done. Evidence: `packages/oka_core/lib/src/store/artifact_store.dart` + (`ContentKey`, `ArtifactStore`, `LocalArtifactStore` — layout + `///-//` + per-entry + `oka_store.json`, `OKA_CACHE`-pointable root); AndroidX + Kotlin + provisioning through the store (`sdk_locator.dart`, + `auto_resolve.dart`), legacy flat caches still resolve read-only; + Maven resolver registers entries into the store index + (`maven_resolver.dart`); `packages/oka/lib/src/cli/cache_command.dart` (`oka cache + list/gc/why` — `bin/oka.dart` wiring pending, see file header); + `test/artifact_store_test.dart` (22 tests). `dart analyze` clean, + `dart test` 245 passing. Stdin prompts removed from AndroidX + download and SDKMAN paths. +- [x] **T1 — Toolchain resolution as data.** `Toolchain`/ + `ToolProvider` contract; dissolve `SdkLocator` into an ordered, + printable resolution policy injected as a `ResolvedToolchain` + artifact; provisioning goes through the store; **stdin prompts + removed** from all build paths. Tests: precedence-policy unit tests; + `oka doctor` prints resolved policy; `oka compare` byte-equivalence + preserved across the refactor. + Evidence: contracts in `packages/oka_core/lib/src/toolchain/` + (`ToolQuery`, `ResolvedTool`, `ToolSource`, `ToolResolution`, + `ToolchainException`, `Toolchain`); policy + injectable env in + `packages/oka_android/lib/src/build/toolchain.dart` + (`AndroidToolchain.describe/resolve`, `ResolvedToolchain` seeded into + `PipelineState.resolvedToolchain` by `AndroidPipeline.run`); + `sdk_locator.dart` reduced to a thin wrapper delegating to the + policy; provisioning (`AndroidxJarProvisioner`) still store-based; + `oka doctor` prints the resolved policy (paths + sources + fixes); + `test/toolchain_policy_test.dart` (19 precedence/version/doctor + tests, injected env), `dart analyze` clean, determinism test green. +- [x] **T2 — Device layer through the store (with H2).** + adb/emulator provisioning as platform-scoped tool providers; + install/launch steps consume `ResolvedToolchain`. Evidence: emulator + e2e already required by H2 — extend with store-backed provisioning. + Done (T2 scope: provisioning + resolution wiring; the emulator e2e + evidence itself landed with H2). Evidence: dev steps migrated off the `SdkLocator` wrapper onto + `ResolvedToolchain` (constructor value → `state.resolvedToolchain` → + default; `device_steps.dart`, `device_target.dart` — additive + `toolchain` param, DeviceTarget public shape otherwise unchanged); + device tools added to the T1 policy as data (`toolchain.dart`: + `emulator` → `emulator/emulator`, `avdmanager` → + `cmdline-tools/latest/bin/avdmanager`, `system-images` dir — ordered + candidates + remediation naming the exact `sdkmanager` command); + `oka doctor` prints them via the policy value (no CLI change); + store-backed provisioning (`dev/device_provisioning.dart`, + `AndroidDeviceProvisioner`): adb resolves policy-first, then store + (`platform-tools/adb` content key, host-OS-scoped), then a + **non-interactive** direct download from dl.google.com registered in + the store — prompt-dependent paths fail closed with + [ToolchainException] naming the exact command (system images: pointer + entry in the store, non-interactive `sdkmanager` only when + `/licenses/` pre-accepted, stdin never attached); no stdin + anywhere. Tests: `test/adr0013_t2_device_store_test.dart` (18 tests: + source contract — `dev/**` references no `SdkLocator`; policy + resolution with injected env; store round-trip with fake store + fake + curl zip — download exactly once, store hit spawns nothing; + fail-closed remediation; dev steps + full `DeviceTarget` pipeline on + an injected toolchain). `dart analyze` clean, `dart test` 325 + passing. (The real emulator e2e evidence landed under H2; AVD + creation/boot steps and `EmulatorSpec` typed fields on `DeviceTarget` + remained deferred until a boot step consumes them, per the + no-dead-config rule.) + +## ADR-0014 — Distribution targets + secrets model + +- [x] **P0 — PublishTarget contract + credential-path policy.** + `PublishTarget` in `oka_core` (extends `Target`; conformance laws: + dry-run without credentials, no stdin, no secret values in state/ + logs/events); credential-path resolution policy (explicit config + path → `OKA__*` env → `~/.oka/credentials//`, same + ordered-policy shape as T1); doctor secret audit (dart-define keys + matching secret-ish patterns fail with the tier rule); credential + file inside repo ⇒ must be gitignored. Tests: policy unit tests with + injected env, audit key-pattern table, dry-run conformance. + Done. Evidence: `packages/oka_core/lib/src/publish/` + (`publish_target.dart`: `PublishTarget`, `PublishPlan`, + `PublishPlanStep` — dry-run is law-as-code: `compile` substitutes + the upload tail with the plan step; `conformance.dart`: + `auditPublishConformance`/`expectPublishConformance` over the three + laws + `FixturePublishTarget`); + `packages/oka_core/lib/src/credentials/` (`credential_ref.dart` + redacting `CredentialRef`; `credential_policy.dart`: ordered + `CredentialResolver` with injected env, tried-candidates + + remediation, `describePolicyLines`, doctor discovery + repo + hygiene; `secret_audit.dart`: tested `secretishKeyPatterns` + constant + pure `auditDartDefines` + `doctorSecretAuditLines`; + `repo_hygiene.dart`: gitignore-checker seam + default matcher); + doctor wiring is parse-and-delegate only + (`packages/oka/lib/src/cli/doctor_command.dart`, `[Secret Audit (ADR-0014)]` + + `[Credential Policy (ADR-0014)]` blocks; `[Toolchain Policy]` + byte-identical; gate test `test/adr0014_doctor_delegation_test.dart`). + Tests: `test/adr0014_credential_policy_test.dart` (precedence, + hard-fail on configured-but-missing path, env tilde expansion, + tried+fix, doctor lines, gitignore table), + `test/adr0014_secret_audit_test.dart` (pattern table, values never + echoed), `test/adr0014_publish_conformance_test.dart` (three laws + incl. negative cases). `dart analyze` clean; `dart test` 390 + passing (338 pre-existing + 52 new). Shared-suite extraction landed + in P1 as `oka_conformance` (per the ADR-0014 phased plan). +- [x] **P1 — `oka_play` target package.** `PlayPublishTarget` + in `packages/oka_play` (`extends PublishTarget`; `publish-play`, + dry-run-by-default; compiles to `stage-aab` → `publish-plan` under + dry-run, `stage-aab` → `play-upload` real; `PlayTrack` typed config + with `internal` default, staged-rollout `userFraction`, + `releaseName`; metadata flows into the plan). Upload tail: + `play_upload_step.dart` — AAB by `Artifact('aab-path')`, + service-account JSON by *path* via the P0 `CredentialResolver` + (typed-config path → `OKA_PLAY_SERVICE_ACCOUNT_JSON` env → + well-known location; injected-env testable), shape-checked by field + name only (`play_credentials.dart`), JWT (RS256) → OAuth token + exchange via googleapis_auth over an injectable base client, then + the androidpublisher/v3 Edits flow (`play_publisher_client.dart`: + create edit → upload AAB → assign track → commit) over an injectable + `http.Client`; failures are `StepResult.failure` naming status + + remediation, never credential values. Shared suite extracted as + `packages/oka_conformance` (`expectPublishConformance` re-exported + from oka_core — the oka_core contract is untouched; plus + `expectPlanShape`/`expectPlanDescribes` plan-shape assertions, + `expectNoSecretMaterial`/`expectStateRedacted` redaction assertions, + and the scripted offline `FakeHttpTransport` that throws on + unexpected requests — the `universal_storage_conformance` pattern; + usable as-is by P2+ targets). CLI untouched (targets are + project-declared — the ADR-0015 payoff). Tests: `test/` + `play_conformance_test.dart` (full ADR-0014 suite over + `PlayPublishTarget` incl. no-stdin scan of `lib/src`, zero-HTTP + dry-run via `assertNoRequests`, plan shape, redaction, credential + policy with injected env incl. hard-fail on configured-but-missing + path), `play_upload_flow_test.dart` (token exchange + full Edits + flow against the fake transport — exact ordered request URLs, JWT + bearer-grant + RS256 header + `iss`/`scope`/`aud` claims, AAB bytes + verbatim, track release body incl. `inProgress`+fraction rollout, + failure paths: missing AAB / empty packageName / unresolvable + credential / malformed service-account / API 403 — all offline, no + network). Done. Evidence: dry-run plan + (`dart run` over `PlayPublishTarget()`): + `target: publish-play (dry run — nothing was uploaded)` / + `endpoint: Google Play Publisher API (androidpublisher/v3)` / + `track: internal` / `artifact: aab-path → …/app-release.aab` / + `metadata.packageName: dev.example.app` / + `credential: CredentialRef(play/service-account-json → + [redacted])`. Real upload gated on maintainer credentials (the + synthetic test key in `test/synthetic_credentials.dart` is generated + fixture material, labeled as such, and guards nothing). `dart + analyze` clean (root + both new packages); `dart test` 390 passing + at root (unchanged), 27 in `oka_play`, 11 in `oka_conformance`. +- [x] **P2 — `oka_huawei` target package.** AppGallery Connect + upload + GMS-exclusion build variant composed via `AndroidBuild` + (artifact validator must catch GMS-dependent steps in the excluded + composition). Done. Evidence: + `packages/oka_huawei/lib/src/` (`huawei_publish_target.dart`: + `HuaweiPublishTarget extends PublishTarget` — `publish-huawei`, + dry-run default; compiles to `huawei-stage-aab` → `publish-plan` + under dry-run, `huawei-stage-aab` → `agc-publish` real; composes + `HuaweiBuildVariant`; `agc_publish_step.dart`: + `HuaweiStageAabStep` (pure path resolution, no existence check — the + real tail enforces it) + `AgcPublishStep` (AGC token fetch → + upload-url → PUT artifact → submit, credential file resolved by path + via the P0 `CredentialResolver`, secrets never leave step-local + scope); `agc_api.dart`: `AgcClient` over an injectable `http.Client` + (`AgcEndpoints` default + `https://connect-api.cloud.huawei.com`), redacting `AgcToken` / + `AgcCredentials`, `AgcApiException` naming status + ret.code — never + bodies/credentials; `agc_credentials.dart`; + `huawei_release_config.dart`: typed track / staged-rollout / + release-note file paths; `gms_variant.dart`: `HuaweiBuildVariant` — + `extraDeps` filtered through `isGmsCoordinate` (oka_android seam), + `excludedGmsDeps` inspectable). **Additive seam in oka_android** + (justification: declaring GMS-dependency artifacts required typed ids + no existing file provides; no existing behavior modified): + `packages/oka_android/lib/src/gms_artifacts.dart` — `gms-dep:` + version-free artifact ids, `isGmsCoordinate` / `splitGmsDependencies` + (GMS group prefixes as data), `GmsDependencyProviderStep` (declares + + existence-checks resolved GMS jars; absent in GMS-excluded variants). + **Composition-validation demonstration:** a step requiring + `gmsDependencyArtifact('com.android.billingclient:billing-ktx')` in a + GMS-excluded composition fails `Pipeline.validate()` / + `describeTarget(...).isValid` naming + `gms-dep:com.android.billingclient:billing-ktx` and the step — + **before any tool runs** (the test's step records execution and + asserts it never happened); positive control: with + `GmsDependencyProviderStep` present the same step validates and runs. + Tests: `packages/oka_huawei/test/huawei_conformance_test.dart` (the + three ADR-0014 laws via the P1 shared suite + `oka_conformance`/`expectPublishConformance` + plan-shape and + redaction assertions + zero-HTTP proof), + `test/agc_flow_test.dart` (token → upload-url → PUT → submit against + the scripted `FakeHttpTransport`, byte-exact upload, ordered requests, + failure paths: missing artifact / missing credential file with + tried-candidates + fix / malformed credentials / HTTP 401 / ret.code + ≠ 0 / missing release-note file — every error asserted free of secret + material), `test/gms_composition_test.dart` (classification, variant + filtering, the validator rejection + positive control); + `packages/oka_android/test/gms_artifacts_test.dart` (seam unit tests). + `dart analyze` clean for oka_huawei/oka_android (remaining analyze + infos in packages/oka_play belonged to P1, in flight); root `dart test` + 390 passing (unchanged); `packages/oka_huawei` 31 passing, + `packages/oka_android` 11 passing. Sample dry-run plan + (`plan.describeLines()`): `target: publish-huawei (dry run — nothing + was uploaded)` / `endpoint: AppGallery Connect Publishing API + (https://connect-api.cloud.huawei.com)` / `track: beta` / `artifact: + aab-path → /app-release.aab` / `metadata.appId: 110012345` + / `credential: CredentialRef(huawei/agconnect-credentials → + [redacted])`. Real uploads against production AGC remain gated on + maintainer credentials. + +## ADR-0015 — CLI verb/target split + +- [x] **C0 — `Target` contract + `oka run` dispatcher.** + Typed, const-constructible `Target` (compiles to `Pipeline`) in + `oka_core`; `Oka(targets: [...])` in the composition root; `oka run + ` dispatch (core verbs reserved, targets cannot shadow); + unknown-verb errors name available targets; snapshot cache keyed on + entrypoint content hash. Tests: dispatch, collision rejection, + target→pipeline validation via existing artifact checker. + Done. Evidence: `test/adr0015_target_dispatch_test.dart` (18 tests: + dispatch, collision/validation, unknown-verb listing, no-entrypoint); + `packages/oka_core/lib/src/targets/target.dart`, + `packages/oka/lib/src/cli/run_command.dart`. Deferred (latency only, not a + correctness gate): snapshot-cached entrypoint evaluation keyed on + content hash — current dispatch shells out to `dart run` like `oka + build` already does. +- [x] **C1 — Fold platform leakage behind the boundary.** + `oka launch` → alias of `oka run device` (`DeviceTarget` shipped by + `oka_android`); `oka get` nouns route through ADR-0013 tool + providers; `oka debug dex` moves behind the Android package. Gate: + `bin/` + verb implementations contain no platform logic (grep gate + or import-lint test); `oka compare` byte-equivalence preserved. + Done. `DeviceTarget` + device steps (`packages/oka_android/lib/src/dev/`): + resolve-newest-APK → install → launch → logcat failure-signature scan, + compiled to a validated pipeline; dex probe moved to + `oka_android/lib/src/dev/dex_probe.dart` (pure Dart zip read); `oka + launch` shim (`packages/oka/lib/src/cli/launch_command.dart`) delegates to `oka run + device` with zero platform logic (moved flags → typed target config). + Gate: `test/adr0015_cli_platform_leakage_gate_test.dart` (C1-folded + files clean; ratchet emptied by the ADR-0015 follow-up — build, + compare, doctor, get are parse-and-delegate shims over + `oka_android`'s compare/doctor-checks/provisioning APIs; the only + remaining exception is the `--skip-badging` flag name). + Evidence: `test/adr0015_device_target_test.dart` (18 tests: compile + validation, scripted fake-adb/aapt2 flows, pure helpers), + `test/adr0015_launch_alias_test.dart` (dispatch equivalence + flags), + `test/adr0015_dex_probe_test.dart` (probe + CLI delegation); example + composition root declares `DeviceTarget` (`oka run device` demo). +- [x] **C2 — `oka explain --targets`.** Discovered targets + listed with their step chains via the validated-plan surface; + `oka --help` stays static (core verbs + pointer). Evidence: explain + output for a project declaring a custom target. Done: + `describeTarget` (pure, `packages/oka_core/lib/src/targets/describe.dart`) + + `--oka-describe-targets` machine mode in `okaRun`; + `oka explain --targets` in `packages/oka/lib/src/cli/explain_command.dart` (no tool + execution, no device probing; entrypoint-less → `oka init`); + `test/adr0015_explain_targets_test.dart` (chains, no-execution, + validation failures, plain-explain regression). + +## ADR-0011 — Agent-first dev loop + +- [x] **H0 — Hot-reload prerequisite audit.** Prove the oka-built + debug APK is hot-reload-capable (kernel_blob.bin, VM service reachable, + attach probe) and record evidence in + [hot_reload_plan.md](../guides/hot_reload_plan.md). Tests: APK-content + assertions. + Done. Evidence (full transcripts in the plan's H0 evidence block): + oka-built debug APK zip contains `kernel_blob.bin` (46 MB) + + `isolate_snapshot_data` + `libflutter.so`, **no AOT `libapp.so`**; + `-dTrackWidgetCreation=true` asserted; headless emulator (API 34 + arm64) live chain: `oka run device` install → launch → logscan clean, + oka's `AdbTool` scraped the VM service URI (`The Dart VM service is + listening on http://127.0.0.1:38635/…/` — newer Flutter wording, + parser now matches both spellings), `adb forward tcp:0` + HTTP + `getVM` over the forwarded port returned the VM + 1 isolate + (`PROBE_OK`); `flutter attach --machine` connects to the oka-built + APK and reaches `app.started` (discovers the service URI itself); + `flutter run --machine --use-application-binary` is Gradle-free (0 + mentions in the transcript) but re-installs the APK itself. + **Decision recorded:** H3 integrates via `attach --machine`; oka owns + install → launch. Tests: `test/adr0011_apk_contents_test.dart` + (static arg assertions + real-pipeline zip-content assertions with + SDK-present skip). No blockers remain. +- [x] **H1 — Session manifest / flag parity.** + `run_session.json` recorded at build time; `oka dev` validates and + refuses mismatch; flutter binary resolved from recorded SDK path. + Done. **Format (schema 1, documented in `run_session.dart`):** + `run_session.json` next to the APK with fixed field order — `schema` + (1, readers refuse unknown schemas), `oka_version`, `recorded_at` + (ISO8601 UTC, the only volatile field), `flutter_sdk_path`, + `engine_revision` (`bin/internal/engine.version` of the recording + SDK), `target_file`, `build_mode`, `dart_defines` (merged + `--dart-define` + `--dart-define-from-file`, trimmed, sorted keys), + `application_id`, `abis`, `apk_path`, `flavor`, + `track_widget_creation`. Evidence: + `RecordRunSessionStep` appended to both default pipelines (APK+AAB) + and to the example composition root — `oka build apk --debug` on + `example/` emits the manifest (golden-file test asserts the exact + JSON modulo `recorded_at`); `validateRunSession` refuses on mismatch + naming every differing field + fix (never warn-and-continue); + `checkDevSession` (oka_android) = `oka dev` preflight: refuses + no-APK / pre-manifest builds, flag mismatches (target/defines/mode/app + id), engine drift at the recorded path, missing flutter binary — and + resolves the session flutter binary from the **recorded SDK path**, + never PATH. CLI stays parse-and-delegate (`packages/oka/lib/src/cli/dev_command.dart`): + live transcripts — happy path validates and prints the session line; + `oka dev --dart-define=STORE=sideload` refuses with + `dart_defines (STORE): recorded "", requested "STORE=sideload"`, exit 1; + `oka dev --target=lib/alt.dart` refuses naming `target_file`. Tests: + `test/adr0011_run_session_test.dart` (33 tests: round-trip, + deterministic encoding, unknown-field tolerance, corrupt/unknown- + schema fail-closed, per-field mismatches, CLI↔manifest define + normalization parity, golden step output, `checkDevSession` table). +- [x] **H2 — Device layer.** adb install/launch/logcat-scrape/ + forward in `oka_android/src/dev/`; command-construction + parser tests; + emulator e2e evidence. + Done. Evidence: `adb_tool.dart` (pure argv builders `adbDevicesArgs` / + `adbInstallArgs` / `adbLaunchArgs` / `adbForwardArgs` / logcat args; + parsers `parseAdbDevices` / `parseVmServiceUri` / `parseForwardPort`; + `classifyAdbFailure` → typed kind + oka-style fix for unauthorized / + no-device / device-offline / signing-mismatch / install-failed / + adb-missing / unknown; `AdbTool` executor with injectable path + + process runner; bounded `awaitVmServiceUri` poll); + `AwaitVmServiceStep` / `ForwardVmServiceStep` in the validated chain + (artifacts `vm_service_uri`, `vm_service_local_port`). + `test/adr0011_adb_tool_test.dart` (24 tests incl. scripted fake adb + binaries — the `adr0015` pattern — and the emulator-captured + announcement line as a regression fixture). Emulator e2e evidence: + provisioned non-interactively (licenses pre-accepted; see the H0 + evidence block for the exact one-time commands), then live + `oka run device` on `emulator-5554` (install → launch → pid alive → + no failure signatures) and the AdbTool scrape+forward+getVM probe + (`PROBE_OK`). Device selection (`oka dev -d`) and its zero/multiple- + device errors landed with H3's session wiring (parse-and-delegate — + the `AdbDevice.ready` contract is already in place). +- [x] **H3 — `oka dev` v1 daemon session.** + `flutter attach --machine` adapter, human TTY loop + `--json` agent + stream; scripted-fake protocol tests; headless reload e2e evidence. + Done. Evidence (full transcripts in the plan's H3 evidence block): + `daemon_adapter.dart` (oka_android/src/dev — the only file that knows + wire details) spawns the recorded-SDK binary with + `attach --machine -d ` (never ambient PATH) and prepends the + resolved tool directory to the child's PATH (live finding: the + oka-managed SDK is otherwise invisible to the attach child). + **Live-probed protocol correction (supersedes one H0 assumption):** + `app.reload` does not exist in flutter_tools 3.47.0-0.4.pre — hot + reload = `app.restart {appId, fullRestart: false}`, hot restart = + `fullRestart: true`, `app.stop`/`app.detach` take the `appId` + announced by `app.start`; the older `app.reload` spelling is kept as + a feature-detected fallback. Live e2e on the headless AVD (API 34): + `oka run device` → `oka dev --json -d emulator-5554` reaches + `session.ready` (VM service scraped + forwarded via the H2 steps), + then programmatic reload completes (`reload.result ok:true`, + `progressId: hot.reload`) and hot restart completes + (`restart.result ok:true`, `progressId: hot.restart`), then + `detach` sends `app.detach` and the app keeps running (pid verified + after detach; the `quit` → `app.stop` + `daemon.shutdown` tear-down + is asserted at the scripted-fake tier) — the agent-usable acceptance + test passes headless. Also verified live: with the oka-managed SDK + absent from the invoking shell's PATH, `oka dev` still reaches + `session.ready` (the adapter prepends the resolved tools dir to the + attach child's PATH). + Refusals: profile/release, H1 manifest mismatch, `-d` selection + (zero/multiple/unauthorized → `classifyAdbFailure`). CLI stays a + parse-and-delegate shim (leakage gate green). Tests: + `test/adr0011_daemon_adapter_test.dart` (11: scripted stdio fake, + no real flutter) + `test/adr0011_dev_session_test.dart` (23: + control tables, device-selection table, human/JSON rendering, + diagnostics, `DevFlow` rebuild-then-reattach). +- [x] **H4 — `--watch` change classification.** Dart → reload; + native/res/manifest → full rebuild routing; debounce + table tests. + Done. Evidence: `watch.dart` (`classifyChanges` table — Dart under + `lib/` → reload; non-Dart under `lib/` (bundled assets) → rebuild; + test/tool/bin → ignore; android/res/manifest/assets/oka.yaml/ + pubspec → rebuild; build/meta → ignore; strongest action wins), + quiet-period `debounceStream`, `watchCommandStream` routing into the + session control loop, `--rebuild-on-native` → `DevFlow` rebuild → + reinstall → relaunch → re-attach (reusing the same device steps); + `watcher` is a direct `oka_android` dependency. Live headless watch + loop on the emulator: edit `lib/main.dart` → + `reload.result ok:true` without any keyboard; touch + `AndroidManifest.xml` → `rebuild.required` + the exact command + (`fullRebuildMessage`), reload never suggested. Tests: + `test/adr0011_watch_test.dart` (17: table classification, debounce, + routing, fixture-tree watch paths, one real-watcher smoke test). +- [x] **H5 — Hot restart + doctor + docs.** `app.restart` + semantics, `oka doctor` readiness checks, user-facing docs updated. + Done. Evidence: hot restart live on the emulator (`restart.result + ok:true` — full kernel recompile + restart under flutter_tools + semantics; state-loss stated in the TTY help, `--json` usage, and + docs); `oka doctor` gained `[Dev Loop (ADR-0011)]` readiness checks + (`devLoopDoctorChecks`: debug session manifest present, recorded-SDK + flutter binary, adb + ready device; manifest/binary failures + blocking, device absence advisory) — live transcript in the plan's + H5 evidence block, unit-tested with scripted fakes + (`test/adr0011_dev_doctor_test.dart`, 7). User-facing docs updated: + `build_and_config.md` Dev-loop section, `quick_recipes.md` + three-flow recipes, README — presenting `oka run device` (= `oka + launch`, one-shot install+launch+logscan, no session) and `oka dev` + (parity check → device steps → attach session) as one coherent + story; docs link to behavior, never paraphrase. + +## Done (cross-cutting, archived together) + +- [x] **Typed per-project config in Dart (ADR-0010 — accepted & executed).** + `AndroidBuild`/`FlutterBuild` typed values (oka_core), deep-merge over + oka.yaml in `okaRun` (+ `--print-config`), entrypoint discovery + (`tool/oka_pipeline.dart` -> `bin/oka_pipeline.dart`) in build/explain/ + doctor/debug, `oka init --from-yaml` 1:1 converter + `--dart` scaffold. + Example app **and last_answer** migrated to full-Dart (oka.yaml deleted + in both). Evidence: yaml-config vs Dart-config builds **byte-equivalent** + (badging + zip entries) in both projects; 13 tests in + `test/adr0010_typed_config_test.dart` incl. end-to-end + `--print-config`; `dart test` all green. +- [x] **Skill Steward adoption + benchmarks.** `steward.yaml` (archetype + `cli_tool`; governance AGENTS.md, validate `just check-contracts`, + registry `skills.sh.json`); four typed contract actions + smoke + scenario `oka.contract-status-smoke` — all pass under `--strict` + (32–747ms per gate); build benchmarks via `just bench` + (`tool/benchmarks/build_benchmarks.sh`): explain 1.18s, incremental + build 20.41s, compare 1.35s, debug step 2.76s (example project, + machine-local evidence in `docs/evidence/`). +- [x] **Multi-dex determinism (ADR-0007 item).** d8 program/lib jar lists + sorted (parallel dependency resolution made argument order — and hence + the classesN.dex split — vary between runs); `zipStagingToApk` / + `zipBundle` write entries in sorted path order instead of filesystem + order. Evidence: `test/determinism_test.dart` (byte-identical APK/AAB + across runs and directory orders; real-d8 reproducibility when an SDK + is present). +- [x] **oka init: full-Dart by default (ADR-0010).** Default scaffold is + `tool/oka_pipeline.dart` (typed config from pubspec name/version, no + oka.yaml); `--yaml` opts into the legacy YAML-first flow (with AI + gradle conversion); `--from-yaml` converts existing yaml 1:1. + Non-interactive terminals skip overwrite prompts (`--force` forces). + `test/init_command_test.dart` covers all three flows. +- [x] **OSS publish readiness (ADR-0006 package split).** Split packages get + LICENSE/README/CHANGELOG; `oka_android` depends on hosted + `oka_core: ^0.1.6` (path deps are a publish blocker); root resolves + siblings via `dependency_overrides` for local dev. Publish train + `oka_core` → `oka_android` → `oka` wired into + `.github/workflows/pub_publish.yml` with per-package dry-run + preflight; version-sync gate extended to the split packages. + Evidence: `dart pub publish --dry-run` — oka_core and oka publishable + (oka_android resolves once oka_core's first release is up; host + projects bootstrap with `dependency_overrides`, documented in the + build guide). +- [x] **Adoption fixes surfaced by last_answer (behavior-preserving):** + `archive` bumped to ^4 (unblocks host apps using image / + flutter_native_splash); pipeline-level overrides seeded into + `PipelineState` — explicit `steps:` lists now get fast-settings + (exclude_plugins, extra_deps, manifest, icon, signing, + resource_configs, extra_assets, local_aars, max_size_mb) instead of + silently dropping them (pre-existing ADR-0006 gap); `flutter assemble` + subprocesses receive the located `ANDROID_SDK_ROOT` (projects with a + stale android/local.properties no longer fail build_hooks). +- [x] **Remove demoted cargo-apk hybrid (ADR-0009 — accepted & executed).** + `rust_wrapper/`, `CargoApkManifest`, `CargoApkConfig` + barrel exports, + `FlutterAndroidBuilder` wrapper, quarantine test, `example/oka.yaml` + `cargo_apk:` section removed; docs + AGENTS.md updated. Evidence: + `dart test` **fully green** (quarantine baseline gone), + `grep -ri cargo bin/ lib/ packages/*/lib` clean, `oka explain` on + `example/` unchanged. diff --git a/docs/archive/PHASE_CHECKLIST_adr0006-0008.md b/docs/archive/PHASE_CHECKLIST_adr0006-0008.md new file mode 100644 index 0000000..b30e0f3 --- /dev/null +++ b/docs/archive/PHASE_CHECKLIST_adr0006-0008.md @@ -0,0 +1,118 @@ +# Archived phase checklist — ADR-0006/0007/0008 phases + +> Archived 2026-09-06: all listed phases complete with evidence. +> Open work now lives in [docs/PHASE_CHECKLIST.md](../PHASE_CHECKLIST.md). + +# Phase Checklist + +Evidence-based phase tracking (see `AGENTS.md` non-negotiables). A phase is +done only when its tests and evidence exist. + +## Phase: ADR-0006 declarative composition API + package split + +- **Status:** in progress (core complete; e2e validated; docs pending final pass) +- **ADR:** [0006](decisions/0006-dart-entrypoint-hooks.md) (accepted) + +### Done + +- [x] Package split: `oka_core` (contracts) + `oka_android` (platform) + `oka` (CLI) +- [x] Typed `BuildContext` value class with `copyWith` (replaces map extension type) +- [x] `Artifact` typed artifact keys + `requires`/`provides` on all default steps +- [x] Composition-time artifact-chain validation in `Pipeline.run` (fails before any tool runs) +- [x] `Oka` / `PlatformPipeline` composition root + `okaRun` entrypoint +- [x] `oka build` delegation via `pipeline.dart_entrypoint` (ADR-0006) +- [x] `oka init` generates `dart_entrypoint`-aware scaffold (commented + `pipeline.dart_entrypoint` example + pointer to + `example/bin/custom_pipeline.dart`) — `test/init_command_test.dart` +- [x] `ManifestSpec` typed manifest value rendered by host-codegen (byte-compat with legacy) +- [x] Dart-define plumbing: `--dart-define`, `--dart-define-from-file`, `--target` (G1/G3) +- [x] Signing config: `SigningConfig` (yaml env-indirection + `android/key.properties`) (G2) +- [x] Resource configs: aapt2 `-c en,ru` threading (G4) +- [x] Transitive dependency resolution: parent-POM version resolution, BOM imports, + parallel BFS, per-artifact failure isolation, per-run memoization (G6) +- [x] Incremental step cache: `plugin-packaging`, `flutter-assemble`, `release-aot`, + `compile-and-dex` fingerprinted (content-hash for small files) +- [x] Tests: 173 passing (1 pre-existing rust_wrapper quarantine failure, unchanged baseline) +- [x] E2E #1: `example/` app — debug APK via default pipeline (188s cold) +- [x] E2E #2: `example/` app — declarative hook (`bin/custom_pipeline.dart` via + `dart_entrypoint`) with custom steps interleaved +- [x] E2E #3: **last_answer** (first production app) — debug APK 157s cold / + **23.9s incremental**; verified: package `dev.xsoulspace.lastanswer`, + versionCode 51 / versionName 3.22.0, 3 permissions, 3 deeplinks + (incl. scheme-only RuStore), cleartext flag, label, `en/ru` resource configs +- [x] E2E #4: **last_answer release AAB** with exact store flags + (`--release --dart-define-from-file=configs/envs/prod.json + --dart-define=STORE=googlePlay --target lib/main_prod.dart`) — 76.9s warm; + **bundletool 1.17 `validate` PASSES** (exit 0). 67.69 MB after fixing + dex-part accumulation and BundleConfig.pb bundletool_version field. + Debug-signed (no release keystore on this machine — expected warning + printed); supply `android/key.properties` for store upload. + +### Notable fixes surfaced by the last_answer e2e (evidence of the pipeline model) + +- `Accept-Encoding: gzip` hang on vkpartner artifactory → explicit `identity` +- jar→aar packaging fallback (ML Kit ships AAR-only) +- Google-Maven routing for `com.google.mlkit`/`firebase` groups +- Maven parent-POM + BOM-import version resolution (tika/slf4j class of POMs) +- `add("implementation", "g:a:v")` Kotlin DSL parsing +- Java 17 sources (pattern matching) — `java_version` threading +- `exclude_plugins` for test-only plugins (`integration_test`) + +### Open + +- [ ] Multi-dex determinism: d8 part-file count can vary between runs (stale parts + now cleaned; consider fixed part count) + +## Phase: ADR-0007 self-resolving, self-checking builds + +- **Status:** in progress +- **Scope:** see [hardening roadmap](guides/hardening_roadmap.md) + +### Done + +- [x] Roadmap doc + ADR-0007 accepted +- [x] `MavenResolver` + `MavenRepoRegistry` (declarative repo routing) + single + `MavenCoordinate` in `oka_core` (duplicate removed) +- [x] `PipelineEvent`s (StepStarted/Finished, CacheEvent, BuildWarning, Log) + + `ProcessRunner` in `oka_core` +- [x] `oka build --dry-run` / `oka explain` — validated plan, zero tool invocations +- [x] `PostBuildLintStep` wired into APK/AAB pipelines: manifest version rule, + debug-sign gate (`--allow-debug-signing` escape), size budget + (`pipeline.max_size_mb`), BundleConfig version check +- [x] Auto-resolve: kotlinc self-install (`OKA_NO_AUTO_INSTALL=1` escape), + java-level auto-bump (detects `VERSION_NN` in plugin gradle), + pubspec version fallback, dev-dep plugin exclusion (release) +- [x] Gradle fixture corpus (mobile_scanner, file_picker, rustore) + auto-resolve tests +- [x] `oka doctor`: build-health section (kotlinc, bundletool, maven cache, + incremental cache, package_config staleness) +- [x] E2E re-verified after refactor: last_answer explain ✅, incremental + build **22.3s** ✅ +- [x] `oka compare ` formal byte-equivalence gate: `aapt2 dump + badging` diff (package, versionCode/Name, permissions, intent-filter + metadata) + zip entry diff (only-in / crc32-changed); exit 1 on + differences, `--quiet` escape — `packages/oka_android/lib/src/compare.dart`, + `packages/oka/lib/src/cli/compare_command.dart`, `test/compare_test.dart` +- [x] `oka debug step ` single-step probe runner: default-pipeline prefix + (upstream artifact providers) against the project's `.oka_cache`, + okaRun-identical context, `--list` discovery from + `AndroidPipeline.defaultSteps` — `packages/oka/lib/src/cli/debug_command.dart`, + `test/debug_command_test.dart` +- [x] Conditional-dep dedup: `inConditional`/`conditionalGroup` on + `ParsedGradleDep` (if/else scope tracking in the parser); if/else variants + collapse to gradle's default branch (first variant) with a printed notice + (mobile_scanner ML Kit bundled/unbundled) — `packages/oka_android/lib/src/ + build/gradle_dep_parser.dart` + `plugin_packager.dart`, + `test/gradle_conditional_dedup_test.dart` over `test/fixtures/gradle/` +- [x] Q&A blocks for init / compare / debug-step in `docs/guides/build_and_config.md` +- [x] Dependency-plan dry-run `oka explain --deps` (+ `--network`): shared + declared-deps collector on `PluginPackager`, `onFailure` observability on + `MavenResolver.resolveWithTransitives`, typed `DependencyPlanReport`; + cache-only default, network mode gates with exit 1 — ADR-0008, + `packages/oka_android/lib/src/dependency_plan.dart`, + `test/dependency_plan_test.dart`; example: 27 roots → 20 cache hits + offline / 78 jars with transitives online, 0 findings + +### Open + +- [ ] Multi-dex determinism: d8 part-file count can vary between runs (tracked + under the ADR-0006 phase) diff --git a/maven_rsolver.plan.md b/docs/archive/maven_rsolver.plan.md similarity index 99% rename from maven_rsolver.plan.md rename to docs/archive/maven_rsolver.plan.md index 8c1adbc..df3d8e6 100644 --- a/maven_rsolver.plan.md +++ b/docs/archive/maven_rsolver.plan.md @@ -194,7 +194,7 @@ New command for dependency management: 6. `lib/src/maven/aar_processor.dart` - AAR handler 7. `lib/src/maven/plugin_scanner.dart` - Plugin discovery 8. `lib/src/maven/cache_manager.dart` - Cache management -9. `lib/src/cli/deps_command.dart` - CLI command +9. `packages/oka/lib/src/cli/deps_command.dart` - CLI command ## Files to Modify diff --git a/oka-build-system.plan.md b/docs/archive/oka-build-system.plan.md similarity index 89% rename from oka-build-system.plan.md rename to docs/archive/oka-build-system.plan.md index 8d3efe9..1c0960f 100644 --- a/oka-build-system.plan.md +++ b/docs/archive/oka-build-system.plan.md @@ -1,5 +1,13 @@ # Oka: Modern Flutter Android Build System +> **Superseded in part (2026-09-06):** the hot reload / dev-mode design now +> lives in [ADR-0011](../decisions/0011-hot-reload-run-loop.md) and +> [docs/guides/hot_reload_plan.md](../guides/hot_reload_plan.md). Phase 4 +> below is historical; its 4.4 (incremental native deploy) was **rejected** +> — Android's runtime cannot hot-swap classes in an installed APK — and its +> 4.1 `_flutter.hotRestart` RPC does not exist as written. This document is +> kept for background only. + ## Overview Build a standalone Dart CLI tool that replaces Gradle for Flutter Android builds by directly invoking Android SDK command-line tools, providing 3-5x faster builds, integrated hot reload, and seamless developer experience. @@ -127,6 +135,9 @@ Implement `HotReloadManager`: - Discover main isolate - Implement hot reload via `ext.flutter.reassemble` - Implement hot restart via `_flutter.hotRestart` + *(historical note: no such RPC exists — hot restart is a full + non-incremental kernel compile plus app restart, handled by flutter_tools' + run/attach session; see ADR-0011)* **4.2 File Watcher** @@ -145,6 +156,11 @@ Create `ChangeDetector`: **4.4 Incremental Native Deploy** +> **REJECTED (ADR-0011 §5).** Android's runtime cannot hot-swap classes in an +> installed APK; there is no supported path to push incremental DEX into a +> running app. Native, resource, and manifest changes always route to a full +> rebuild + reinstall. Kept for the historical record only. + For native code changes: - Compile only changed Java/Kotlin files diff --git a/docs/contributing/contribution_guide.md b/docs/contributing/contribution_guide.md new file mode 100644 index 0000000..9391ee9 --- /dev/null +++ b/docs/contributing/contribution_guide.md @@ -0,0 +1,102 @@ +--- +title: Contribution guide +--- + +# Contributing + +Thanks for your interest in oka! The project is agent-first: agents execute, +humans steer. That shapes how contributions work. + +## Setup + +```bash +git clone https://github.com/Arenukvern/oka.git && cd oka +just install # dart pub get +just test # dart test +just lint # dart analyze +``` + +## Ground rules + +1. **Non-negotiables** (see `AGENTS.md`): + - The default build path must never shell out to `flutter build apk` / + Gradle as success. + - Only the no-Gradle pipeline exists; no cargo/Gradle fallbacks (ADR-0009). +2. **Behavior SSOT is code + tests.** Docs link to implementation; they never + paraphrase it. +3. **Design forks need an ADR first.** If your change settles a trade-off, + open/propose an ADR in `docs/decisions/` before coding. +4. **Phases are done only with evidence.** Update + [`docs/PHASE_CHECKLIST.md`](../PHASE_CHECKLIST.md) with test references. + +## Docs sync + +After any behavior change, update the matching doc layer — see +[`docs/start_here/docs_map.md`](../start_here/docs_map.md): + +| Change type | Update | +|---|---| +| Internal trade-off / architecture | `docs/guides/design_faq.md` Q&A and/or new ADR | +| Public API / usage / config key | `docs/guides/build_and_config.md` (copy-paste valid) | +| Settled strategic decision | `docs/decisions/NNNN-*.md` + index row | +| Phase-level completion | `docs/PHASE_CHECKLIST.md` evidence table | + +## Commit messages + +Use [Conventional Commits](https://www.conventionalcommits.org/) so +[release-please](https://github.com/googleapis/release-please) can build the +changelog and version bumps: + +- `feat:` — new capability (minor bump) +- `fix:` — bug fix (patch bump) +- `docs:` — documentation (patch bump when it is the only change in a release) +- `perf:` — performance improvement +- `chore:` / `refactor:` / `test:` — hidden in the generated changelog + +## Releases + +Releases are automated on `main` via **release-please**: + +1. Merge PRs to `main` with conventional commit titles. +2. release-please opens or updates a **Release PR** (e.g. + `chore: release 0.1.7`) with `CHANGELOG.md` and `VERSION`. + [`.github/workflows/release_pr_sync_versions.yml`](../../.github/workflows/release_pr_sync_versions.yml) + derives `pubspec.yaml`, plugin manifests, and the marketplace catalog from + that one version and commits any drift. +3. Review the Release PR, run `just check-contracts`, then merge it. +4. release-please creates the `vX.Y.Z` tag and GitHub release **with changelog + notes**. +5. [`.github/workflows/pub_publish.yml`](../../.github/workflows/pub_publish.yml) + runs on the tag: asserts tag == `VERSION`, per-package dry-run preflight, + then publishes to pub.dev **in dependency order**: `oka_core` → + `oka_android` → `oka` (the split packages share the release train's + version; `oka_android` declares a hosted `oka_core` constraint, so the + first train must publish core before android/root). Host projects that + need the packages before their first release bootstrap with + `dependency_overrides` (see the build guide). + +Manual fallback (when automation is blocked): + +```bash +bash tool/release/sync_version.sh --version 0.1.7 # or: just sync-version +# edit CHANGELOG.md, bump .release-please-manifest.json +just check-contracts +git commit -am "chore: release 0.1.7" && git tag v0.1.7 && git push --tags +``` + +## Contract gates + +Run before every merge (`just check-contracts`): + +| Gate | Checks | +|---|---| +| `check_version_sync.sh` | VERSION == pubspec == plugin manifests == marketplace | +| `check_docs_drift.sh` | Invariants documented; fast-settings keys present; docs.json sidebar resolves | +| `check_no_personal_paths.sh` | No `/Users//` paths in tracked files | +| `check_changelog_markdown.sh` | MD052 disable header kept; no bare `[bracket]` identifiers | + +## Pull requests + +- Keep changes minimal and focused; match existing style. +- Add/adjust tests for anything that changes packaging or pipeline behavior. +- Run `just lint && just test && just check-contracts` before pushing. diff --git a/docs/decisions/0000-adopt-adr-and-doc-lattice.md b/docs/decisions/0000-adopt-adr-and-doc-lattice.md new file mode 100644 index 0000000..b519741 --- /dev/null +++ b/docs/decisions/0000-adopt-adr-and-doc-lattice.md @@ -0,0 +1,27 @@ +# 0000 — Adopt ADRs + concept doc lattice + +- **Status:** accepted +- **Date:** 2026-08-24 + +## Context + +Oka is agent-executed (`AGENTS.md` as map), but knowledge lived in README prose +and a single phase checklist. Design rationale (why no Gradle, why the Rust +hybrid is demoted) had no durable home, so agents risk re-litigating settled +forks. + +## Decision + +Adopt a vectorless doc lattice: + +- `docs/start_here/why_this_repo_matters.md` — charter: ownership + boundaries +- `docs/decisions/` — MADR-style ADRs, append-only, this index +- `docs/guides/design_faq.md` / `docs/guides/build_and_config.md` — why / how Q&A per FAQ-driven development +- `AGENTS.md` stays a router (~100 lines), never an encyclopedia + +Docs link to code; code + tests remain the behavior SSOT. + +## Consequences + +Design forks require a decision checkpoint + ADR before coding. Docs must not +paraphrase implementation — link with `Authoritative source:` footers. diff --git a/docs/decisions/0001-no-gradle-default-build-path.md b/docs/decisions/0001-no-gradle-default-build-path.md new file mode 100644 index 0000000..68d613a --- /dev/null +++ b/docs/decisions/0001-no-gradle-default-build-path.md @@ -0,0 +1,31 @@ +# 0001 — No-Gradle default build path; demote cargo-apk hybrid + +- **Status:** accepted +- **Date:** 2026-08-24 + +## Context + +Early oka explored three build paths: direct Android SDK tools, +pure native-Android shell pipeline, and a cargo-apk + Rust NativeActivity +hybrid. The hybrid rewrote shared `rust_wrapper/Cargo.toml`, corrupting state, +and Gradle fallback masked failures of oka's own pipeline. + +## Decision + +1. The default `oka build apk` path is the no-Gradle orchestrator: + `flutter assemble` → engine artifact extraction → host codegen → AndroidX + resolve → `aapt2`/`javac`/`d8` → package → sign. It must never report + success by shelling out to `flutter build apk`/Gradle. +2. The Rust/cargo-apk hybrid is demoted to experimental (`--flutter` opt-in); + default builds never mutate `rust_wrapper/Cargo.toml`. + *(Completed by [ADR-0009](0009-remove-cargo-apk-hybrid.md): the hybrid was + removed entirely in 2026-09 — no `rust_wrapper/`, no cargo code paths.)* +3. Phase completion requires test evidence in `docs/PHASE_CHECKLIST.md`. + +## Consequences + +- Faster, deterministic builds; clear failure modes via `oka doctor` +- Plugin support limited to what plugin discovery + registrant codegen can handle +- Native-complex plugins fail loudly instead of silently falling back + +**Authoritative source:** `lib/src/build/flutter_apk_builder.dart`, `test/phase0_no_gradle_fallback_test.dart` diff --git a/docs/decisions/0002-composable-build-pipeline.md b/docs/decisions/0002-composable-build-pipeline.md new file mode 100644 index 0000000..ab45203 --- /dev/null +++ b/docs/decisions/0002-composable-build-pipeline.md @@ -0,0 +1,58 @@ +# 0002 — Composable build pipeline (steps + YAML overrides + Dart composition) + +- **Status:** accepted +- **Date:** 2026-08-24 +- **Decision-makers:** Anton, oka agent + +## Context + +`FlutterApkBuilder.build()` is a monolithic orchestrator: pipeline stages +(assemble → engine extract → codegen → dependency resolve → aapt2/javac/d8 → +package → sign) are hardcoded in one method with a fixed AndroidX dependency +list and hardcoded tool flags. Real-device validation showed the cost: four +consecutive `NoClassDefFoundError` crashes required editing oka's source to fix +the dependency set. Users cannot extend dependencies, flags, or steps without +forking oka. + +## Considered options + +- **A. Pipeline-as-composition** — extract stages into composable step classes; + YAML fast-settings for common overrides; optional Dart entrypoint for full + composition. +- **B. YAML-only hooks** — keep monolith, add `extra_deps` / `pre_build` / + `post_build`. Not composable; cannot reorder or replace steps. +- **C. Full plugin registry + event bus** — most powerful, too much machinery + for current needs. + +## Decision + +Chosen option: **A, staged**, because it fixes the extensibility pain at the +root while preserving ADR-0001's no-Gradle default path unchanged. + +1. Extract pipeline stages into `lib/src/pipeline/steps/*.dart`; each step + implements `Future run(BuildContext)`. +2. Default pipeline composes the same steps in the same order as today's + `FlutterApkBuilder.build()` — behavior-preserving refactor first. +3. `oka.yaml` gains a `pipeline:` section for per-step fast-settings + (`extra_deps`, tool flag overrides) without writing Dart. +4. Optional `oka.pipeline.dart` project entrypoint for full Dart composition. +5. Dependency resolution becomes extensible and gains missing-dependency + recovery: unresolved classes map to Maven coordinates via cached POM / + maven-index search, surfaced as `oka get ` suggestions. + +Option C's registry/event machinery is deferred until a concrete need appears. + +## Consequences + +Good: +- Users close dependency gaps without patching oka +- Steps testable in isolation; pipeline visible as data +- Directly addresses the whack-a-mole runtime crash class + +Bad / Neutral: +- New quasi-public API surface (`BuildContext`, `StepResult`, `BuildStep`) + needs semver discipline +- Two config layers (YAML ⊂ Dart) require documented precedence: + defaults < `oka.yaml` < `oka.pipeline.dart` + +**Authoritative source:** `lib/src/pipeline/`, `docs/decisions/0002-*.md` diff --git a/docs/decisions/0003-vector-first-launcher-icons.md b/docs/decisions/0003-vector-first-launcher-icons.md new file mode 100644 index 0000000..97d7b8e --- /dev/null +++ b/docs/decisions/0003-vector-first-launcher-icons.md @@ -0,0 +1,52 @@ +# 0003 — Vector-first adaptive launcher icons (no PNG tooling) + +- **Status:** accepted +- **Date:** 2026-08-24 +- **Decision-makers:** Anton, oka agent + +## Context + +Generated APKs had no `android:icon` at all (`aapt2 dump badging` reported +`icon=''`) and zero icon resources — launchers showed the default system icon. +`generateLauncherIconXml()` existed in `host_codegen.dart` but was dead code. +Android 8.0+ (API 26) uses adaptive icons; two supply formats exist: +VectorDrawable XML (sharp at any size, no tooling) and raster PNG/WebP density +buckets (universal legacy support, requires image encoding/resizing). + +## Considered options + +- **A. Vector-first adaptive icons** — pure XML resource set, optional + user-supplied foreground/monochrome vectors; no binary dependencies. +- **B. Raster generation** — resize a source PNG into mipmap density buckets; + requires an image codec dependency (e.g. `image` package). +- **C. Both from day one** — maximum compatibility, new dependency + more + surface area before validating demand. + +## Decision + +Chosen option: **A**, because adaptive icons cover API 26+ (~98% of active +devices) with pure XML — preserving oka's zero-dependency packaging path +(ADR 0001 spirit). Raster `png:` remains an opt-in future extension. + +1. `IconConfig` parsed from `oka.yaml` → `android.icon` + (`background_color`, `vector`, `monochrome`). +2. `stageLauncherIcons()` in `lib/src/build/launcher_icon.dart` writes: + `mipmap-anydpi-v26/ic_launcher.xml`, `drawable/ic_launcher_foreground.xml` + (user vector or default oka glyph), `values/ic_launcher_background.xml`, + optionally `drawable/ic_launcher_monochrome.xml`. +3. Manifest emits `android:icon="@mipmap/ic_launcher"` when resources staged. +4. Invalid colors / missing sources fail the host-codegen step loudly. + +## Consequences + +Good: +- Every oka build gets a real launcher icon with zero image tooling +- Custom branding is one YAML entry + one XML file away +- Themed-icon (monochrome) support for Android 13+ + +Bad / Neutral: +- Pre-API-26 devices get no icon until the raster path exists +- Users must author VectorDrawable XML for custom glyphs (no SVG conversion) + +**Authoritative source:** `lib/src/build/launcher_icon.dart`, +`test/launcher_icon_test.dart`, [build guide](../guides/build_and_config.md) → Assets & Icon Station diff --git a/docs/decisions/0004-no-gradle-aab-bundle.md b/docs/decisions/0004-no-gradle-aab-bundle.md new file mode 100644 index 0000000..2bce2f3 --- /dev/null +++ b/docs/decisions/0004-no-gradle-aab-bundle.md @@ -0,0 +1,72 @@ +# 0004 — No-Gradle AAB via hand-assembled bundle (aapt2 proto-format, no bundletool) + +- **Status:** accepted +- **Date:** 2026-08-24 +- **Decision-makers:** Anton, oka agent + +## Context + +`oka build aab` currently accepts the flag but falls back to the APK pipeline +with a warning. Play Store delivery needs a real App Bundle: proto-format +manifest (`AndroidManifest.xml` as protobuf), `resources.pb`, compiled `res/`, +and per-module dex/lib/assets under a `base/` module. + +## Considered options + +- **A. Hand-assemble**: reuse the existing APK pipeline; swap the resource link + to `aapt2 link --proto-format`, stage a `base/` layout, zip, sign with + `jarsigner` (bundles use v1 JAR signing; `apksigner` does not sign bundles). +- **B. bundletool**: download Google's `bundletool.jar`, feed it the same + inputs, let it build/sign the bundle. Adds a large Java tool dependency, a + download/bootstrap path (`oka get`-style), and a second packaging authority + whose output oka cannot structurally validate or control. + +## Decision + +Chosen option: **A**, consistent with ADR-0001 (oka owns packaging with SDK +tools only). The APK and AAB pipelines share steps up to dexing; they diverge at +resource linking (proto vs binary format) and packaging (`base/` module zip + +jarsigner vs staging + zipalign + apksigner). Users may still use bundletool +locally to expand/install from an oka-built `.aab`; oka itself never requires it. + +Consequences: + +- `aapt2_commands.dart` gains a proto-format link argv builder. +- New `aab_layout.dart` mirrors `apk_layout.dart` (staging + validation). +- Pipeline gains proto compile/dex, bundle package/sign, and bundle validation + steps; `defaultAabPipeline` composes them. +- Debug AABs are for local verification only — Play requires release/AOT + bundles; both modes are produced identically otherwise. +- Signing is v1-only (jarsigner) which is what bundletool/Play accept for + `.aab`; upload keys are managed by Play after upload. + +## Spec corrections (post-review) + +Checked against the App Bundle format spec: + +1. The module manifest lives at **`base/manifest/AndroidManifest.xml`**, not + `base/AndroidManifest.xml`. The proto-format link output explodes with the + manifest relocated into `manifest/`. +2. A **`BundleConfig.pb`** must exist at the bundle root; oka writes a minimal + (empty-message) one. +3. An `.aab` **cannot be installed on a device directly** — bundles are an + upload format; Play/bundletool generate the installable split APKs. + +## Verification loop + +bundletool is a *verification* dependency only (never a build dependency): + +```bash +oka get bundletool # downloads bundletool.jar into ~/.oka/tools +oka build aab --verify-aab # build-apks --mode=universal against the bundle +adb install -r .oka_cache/build//universal/app-universal.apk +``` + +`build-apks` exercises the same parsing/generation path as Google Play, so a +structurally invalid bundle fails there before upload. Structural unit tests +(`test/aab_layout_test.dart`) assert the manifest path and BundleConfig.pb +requirements so regressions fail fast without bundletool. + +**Authoritative source:** `lib/src/build/aab_layout.dart`, +`lib/src/build/bundletool.dart`, `lib/src/pipeline/default_pipeline.dart`, +this file. diff --git a/docs/decisions/0005-release-tooling-and-plugin-distribution.md b/docs/decisions/0005-release-tooling-and-plugin-distribution.md new file mode 100644 index 0000000..50cf81c --- /dev/null +++ b/docs/decisions/0005-release-tooling-and-plugin-distribution.md @@ -0,0 +1,79 @@ +# 0005 — Release tooling: release-please + one-version train; skills via plugin distribution + +- **Status:** Accepted +- **Date:** 2026-08-24 + +## Context + +Oka is preparing for OSS publishing. Releases previously meant hand-editing +`pubspec.yaml` and `CHANGELOG.md` with no automation, no tag discipline, and +no way for agents to answer "what changed in vX?" from git alone. Separately, +agent-facing knowledge (the `oka-maintenance` skill) lived only in the +maintainer's global `~/.agents/skills/` — invisible to contributors and not +installable. + +Two decisions were needed: + +1. Which release/changelog generator fits a single-package Dart CLI. +2. How to distribute agent skills alongside the product. + +## Considered options + +**Release generator:** + +- *Changesets* — explicit intent files; excellent for JS monorepos, alien to + pub's tag + CHANGELOG culture. +- *Melos* — Dart monorepo versioning; oka is a single package, so Melos adds + a workspace layer for nothing. +- *release-please* — conventional commits → Release PR → tag, native support + for syncing extra files (pubspec, JSON manifests) via `extra-files`. + +**Skill distribution:** + +- *Keep skills in the maintainer's global directory* — zero setup, but not + discoverable or installable by anyone else. +- *Separate skills repo* — splits product and its procedures across repos. +- *In-repo plugin tree (`plugin/skills/`) + root symlink* — the mcp_flutter + pattern: one repo ships CLI on pub.dev AND skills via `npx skills add` AND + Claude/Codex/Cursor marketplaces. + +## Decision + +Chosen option: **release-please with a one-version train**, and **in-repo +plugin distribution**. + +1. release-please on `main` (`release-please-config.json`, + `.release-please-manifest.json`). Conventional commits drive changelog + + version. `VERSION` is the single source; `tool/release/sync_version.sh` + derives `pubspec.yaml`, plugin manifests, and `.claude-plugin/marketplace.json` + from it. A Release PR workflow auto-commits drift. +2. Tag-triggered `pub_publish.yml`: asserts tag == VERSION, dry-run preflight, + then `dart publish`. +3. Skills ship in `plugin/skills/oka-maintenance/`; root `skills` symlink makes + them installable via `npx skills add Arenukvern/oka --skill oka-maintenance`; + `.claude-plugin/marketplace.json` enables `/plugin marketplace add Arenukvern/oka`. +4. Mechanical gates in `tool/contracts/check_contracts.sh` (version sync, docs + drift, no personal paths, changelog hygiene), wired into CI and the justfile. + +## Consequences + +**Good** + +- Changelog lives in git; agents can read "what shipped" without GitHub API. +- One version source; drift is mechanically impossible to merge. +- Skills are versioned with the product they describe. + +**Bad / trade-offs** + +- Conventional commit discipline is now required on every PR title. +- Plugin manifest versions must be bumped by the sync script — adding a new + manifest means updating three places (config `extra-files`, sync script, + check script). + +## Authoritative sources + +- `release-please-config.json`, `.release-please-manifest.json`, `VERSION` +- `tool/release/sync_version.sh`, `tool/release/check_version_sync.sh` +- `tool/contracts/check_contracts.sh` +- `.github/workflows/release-please.yml`, `release_pr_sync_versions.yml`, `pub_publish.yml` +- `plugin/` (skills + agent manifests), `skills` symlink, `skills.sh.json` diff --git a/docs/decisions/0006-dart-entrypoint-hooks.md b/docs/decisions/0006-dart-entrypoint-hooks.md new file mode 100644 index 0000000..672b59f --- /dev/null +++ b/docs/decisions/0006-dart-entrypoint-hooks.md @@ -0,0 +1,153 @@ +# 0006 — Declarative Dart composition API as the single extension surface + +- **Status:** proposed +- **Date:** 2026-09-04 +- **Decision-makers:** Anton, oka agent +- **Extends:** 0002 (composable build pipeline — implements its staged item 4) +- **Supersedes (draft iteration):** earlier 0006 draft proposing a mutable + `defaults.insertAfter()` hook API — rejected during design review: list + surgery is imperative state mutation, not composition. + +## Context + +ADR-0002 staged extensibility as YAML fast-settings plus an optional Dart +entrypoint. The fast-settings layer works, but every capability request +(manifest fragments, signing extras, platform steps) drifts toward new YAML +keys — a config field per attribute is the Gradle trap. Meanwhile the internal +API cannot support a typed composition layer: + +- `BuildContext`, `OkaConfig`, `AndroidConfig`, `FlutterConfig` are extension + types over `Map` — stringly, untyped, no `copyWith`, + defaults scattered across `jsonDecode` calls. +- `PipelineState` is a string-keyed mutable blackboard; step coupling and + ordering contracts are invisible; bad keys fail at runtime, not composition. +- `BuildStep` mixes configuration with behavior and digs config out of context + at run time; steps are not const-constructible values. +- Host manifest attributes are hardcoded in `host_codegen.dart`. +- `lib/oka.dart` exports everything (including AI/HTTP clients); hook authors + importing oka would drag heavy transitive deps (http, analyzer-sensitive + trees) into host apps with aggressive `dependency_overrides`. + +oka distributes as an AOT snapshot, so user Dart code cannot be loaded +in-process; the hook boundary must be a subprocess. + +## Considered options + +- **A. More YAML surface.** Rejected: DSL accretion; expression limited to + what oka anticipated. +- **B. Mutable hook API** (`defaults.insertAfter/replace/remove`). Rejected: + imperative list surgery; steps mutate a shared plan; ordering contracts + stay implicit; not declarative, not const. +- **C. In-process loading / wire-protocol plugin registry.** Rejected: fights + the AOT snapshot; machinery without need (ADR-0002 option C stays deferred). +- **D. Declarative value model** — Flutter's discipline applied to builds: + immutable const pipelines/steps/specs, typed artifact exchange, a single + mutable runtime scope, `copyWith` as the only override mechanism. Chosen. + +## Decision + +Chosen option **D**, staged: + +### 1. Value model (external API) + +```dart +// tool/oka_pipeline.dart +Future main(List args) => okaRun( + args, // parses --mode/--aab/--abi/--target + dart-defines; merges oka.yaml + oka: const Oka( + pipelines: [ + AndroidPipeline( + config: AndroidBuild(/* typed spec; copyWith to override */), + steps: [...AndroidPipeline.standard, MyStep()], + ), + ], + ), +); +``` + +- `Oka` is the composition root: a const list of `PlatformPipeline`s + (`AndroidPipeline` now; `oka_ios` etc. later, same shape). +- Pipelines, steps, and config specs are **immutable, const-constructible + values**. Configuration enters via constructors (injection), never by + digging out of context at run time. +- **`copyWith` on typed specs is the only override mechanism.** Precedence: + code defaults < `oka.yaml` < hook `copyWith`. No string-keyed config, no + raw-XML patching; the host manifest becomes a typed `ManifestSpec` value + that `host-codegen` renders. +- `okaRun` performs the boilerplate (arg parsing, yaml merge, SDK resolution, + cache dirs) and the **composition-time validation** below. + +### 2. Typed artifact exchange (internal API) + +- `Artifact` typed keys replace `PipelineState`'s string blackboard + (`flutterAssets = Artifact('flutter-assets')`, …). +- Steps declare `requires` / `provides` as artifact sets. The runner validates + the full chain **before any tool runs** and fails naming the missing + artifact and its expected producer. +- Pipeline stays a linear phase list (debuggable; matches sequential tool + invocation); typed artifacts make memoization — and later incremental + builds — a runtime concern, not an API change. +- `BuildScope` is the only mutable layer: memoized artifact reads, typed + `HostEnv` (mode, defines, SDK paths, cache dirs), injectable + `ProcessRunner` so tests run without real SDKs. + +### 3. Package split (dependency isolation) + +``` +oka → CLI: bin/, AI client, http, global snapshot (heavy) +oka → core contracts: Oka, BuildStep, Artifact, BuildScope, specs (light) +oka_android → pipelines, steps, AndroidBuild, Android toolchain (light) +``` + +- Platform packages keep minimal transitive deps (path/args/yaml class) — + explicitly no http/analyzer-adjacent trees — so host apps with strict + `dependency_overrides` can depend on them without resolution conflicts. +- One package per platform (`oka_android`, future `oka_ios`) rather than a + single `oka_platform` monolith, to avoid re-accumulating a kitchen sink. +- Hooks pin `oka_android` via the project's own `dev_dependencies`; the global + oka snapshot never determines hook API versions. + +### 4. Execution and config freeze + +- `oka build` detects a `pipeline.dart_entrypoint` in `oka.yaml` (the only new + key) and spawns `dart run ` with mode/aab/abi/target/defines via + args and env. Projects without the key keep the AOT fast path unchanged. +- `pipeline.dart_entrypoint` is the **last** extensibility key oka.yaml gains. + Future capability requests become hooks, or promoted core steps once two or + more projects need them. + +### 5. Staged implementation (behavior-preserving first) + +1. Package split; contracts exported from the core barrel (no `src/` imports + in user code; barrel contents are semver-covered). +2. Replace map extension types with typed value classes + `copyWith`; JSON + parsing confined to the CLI/yaml boundary. Tests green at every step. +3. Introduce `Artifact`/`BuildScope`; re-express the 13 default steps as + const values with requires/provides. +4. Runner validation + `okaRun` entrypoint + delegation. +5. Gate: the default path produces byte-equivalent APKs before and after the + refactor; the no-Gradle invariant (ADR-0001) is untouched throughout. + +## Consequences + +Good: +- Extension surface = ordinary Dart + pub packages; declarative, const, + strictly typed — no DSL accretion, no stringly maps +- Ordering/coupling contracts checked at composition time with actionable + errors; runtime key typos become compile-time types +- Memoized artifact reads give incremental builds without API churn +- Per-project version pinning; dependency-light platform packages isolate + host apps from oka's heavy CLI deps +- Any future platform is another `PlatformPipeline` value; oka core stays + Android-deep until steps prove reusable + +Bad / Neutral: +- Sized refactor: every step, config, and the runner change shape; contract + and source-contract tests must move with it +- Barrel exports become semver contract — discipline required +- Two execution paths (AOT default / `dart run` delegation) to test +- `dart run` adds ~1–3 s cold-start to hooked builds (kernel-cached after) + +**Authoritative source:** `lib/src/pipeline/` (contracts), `lib/oka.dart` +(barrel), `bin/oka.dart` + `packages/oka/lib/src/cli/build_command.dart` (delegation), +`oka_android/` (platform package). diff --git a/docs/decisions/0007-self-resolving-builds.md b/docs/decisions/0007-self-resolving-builds.md new file mode 100644 index 0000000..037f047 --- /dev/null +++ b/docs/decisions/0007-self-resolving-builds.md @@ -0,0 +1,55 @@ +# 0007 — Self-resolving, self-checking builds + +- **Status:** accepted +- **Date:** 2026-09-05 +- **Decision-makers:** Anton, oka agent +- **Extends:** 0006 (declarative composition API) + +## Context + +The first production e2e (`last_answer`, 18 plugins) surfaced a class of +failures that each cost a full build cycle to diagnose: missing tools (kotlin), +stale package configs, test-only plugins requiring junit on the compile +classpath, Java-level mismatches, silently empty version attributes, and +debug-key signing on release artifacts. Every one of these is detectable +before or checkable after the expensive steps — and most are mechanically +resolvable without user input. + +## Decision + +**Policy: automatic with loud notices; escapes are env/flag, never new YAML +fields.** All mechanisms are composable (steps, typed values, resolver +services) per ADR-0006's design law. + +1. **Dev-only plugin exclusion.** Plugins contributed solely by + `dev_dependencies` (e.g. `integration_test`) are excluded from release + builds automatically and included in debug. `pipeline.exclude_plugins` + adds names; it never re-includes a dev-only plugin in release. +2. **Version fallback.** When `oka.yaml` lacks `version_code`/`version_name`, + they are read from the app `pubspec.yaml` (`x.y.z+nn`). Explicit yaml wins. +3. **Java-level auto-bump.** Plugin gradle files declaring + `sourceCompatibility JavaVersion.VERSION_NN` raise the effective javac + source/target for the compile step to the detected maximum, with a printed + notice. Explicit `java_version` is honored when >= detected max. +4. **Tool auto-install.** Missing kotlinc/bundletool trigger the existing + `oka get ` flow mid-build with a printed notice. Escapes: + `OKA_NO_AUTO_INSTALL=1`. +5. **Post-build lint** (`PostBuildLintStep`): manifest version presence, + debug-signed release artifact (hard fail unless `--allow-debug-signing`), + size budget `pipeline.max_size_mb`, BundleConfig version byte-check, + duplicate-class pre-warning. +6. **`oka build --dry-run`** composes and validates the pipeline — steps, + artifact chain, signing resolution, version injection, plugin plan — with + zero tool invocations. + +## Consequences + +Good: builds self-heal; failures that remain are genuine (network, code); +diagnosis moves from full builds to dry-runs in seconds. + +Bad / Neutral: auto-install mutates `~/.oka` mid-build (bounded, opt-out); +dev-only exclusion changes default behavior for release artifacts (desired); +lint hard-fails require an escape flag for exotic store flows. + +**Authoritative source:** `packages/oka/lib/src/cli/`, `packages/oka_android/lib/src/`, +`docs/guides/hardening_roadmap.md`. diff --git a/docs/decisions/0008-dependency-plan-dry-run.md b/docs/decisions/0008-dependency-plan-dry-run.md new file mode 100644 index 0000000..e7298a3 --- /dev/null +++ b/docs/decisions/0008-dependency-plan-dry-run.md @@ -0,0 +1,57 @@ +# 0008 — Dependency-plan dry-run (`oka explain --deps`) + +- **Status:** accepted +- **Date:** 2026-09-05 +- **Decision-makers:** Anton, oka agent +- **Extends:** 0007 (self-resolving builds), 0006 (declarative composition) + +## Context + +`oka explain` / `oka build --dry-run` validate the pipeline composition — +artifact chain, signing, version injection, plugin plan — with zero tool +invocations. But plugin discovery is file-only: Maven coordinates parsed from +plugin gradle files are never resolved at dry-run time. A bad version string, +an unresolvable parent-POM, or a 404 artifact still costs a full build cycle +to surface. The resolver (`MavenResolver`, ADR-0007) already isolates failures +per artifact — but `resolveWithTransitives` swallows them (verbose-only +prints), so nothing upstream can report them. + +## Decision + +1. **One shared declared-deps collector.** The gradle-parsed, conditionally + deduplicated root coordinates + plugin repos (`collectDeclaredDeps` on + `PluginPackager`) are extracted from `packageOne` so the packaging step and + the dry-run plan see identical inputs. No second parser, no drift. +2. **Resolver reports failures.** `resolveWithTransitives` gains an optional + `onFailure(coord, error)` callback — the per-artifact isolation stays, but + failures become observable instead of verbose-only. +3. **`oka explain --deps`** composes the dependency plan from the declared + inputs (plugins + `pipeline.extra_deps`) and resolves it through + `DependencyCache`: + - **cache-only by default** (`allowNetwork: false`): explain stays fast and + side-effect-free; artifacts missing from the local `~/.oka` cache are + reported as `⚠️ not in local cache`, not failures. + - **`--network` opts in** to full resolution (downloads like a build). + Hard resolution failures (404, unresolvable POM) print `❌` and make + explain exit 1 — the dry-run becomes a gate, matching the `oka compare` + philosophy: a claim is a command. +4. **Design law:** no new YAML keys, no stringly maps. The plan is a typed + value (`DependencyPlanReport` with entries/findings/resolved jars) built + from the existing resolver service — the same capability as the build's + `DependencyResolveStep`, composed read-only. + +## Consequences + +Good: resolution problems are a dry-run finding (seconds) instead of a +mid-build failure (minutes); cache-only default keeps explain offline-safe; +plugin packaging and the plan share one collector so they cannot disagree. + +Bad / Neutral: cache-only mode cannot expand POM transitives (metadata fetch +needs network) — the report states this explicitly; `--network` makes network +requests during a "dry-run", so it is opt-in, not default; explain now can +exit non-zero under `--deps --network` (previously it never failed). + +**Authoritative source:** `packages/oka_android/lib/src/dependency_plan.dart`, +`packages/oka/lib/src/cli/explain_command.dart`, `packages/oka_android/lib/src/build/ +plugin_packager.dart` (`collectDeclaredDeps`), `maven_resolver.dart` +(`onFailure`). diff --git a/docs/decisions/0009-remove-cargo-apk-hybrid.md b/docs/decisions/0009-remove-cargo-apk-hybrid.md new file mode 100644 index 0000000..95e0793 --- /dev/null +++ b/docs/decisions/0009-remove-cargo-apk-hybrid.md @@ -0,0 +1,71 @@ +# 0009 — Remove the demoted cargo-apk hybrid completely + +- **Status:** accepted +- **Date:** 2026-09-05 +- **Decision-makers:** Anton +- **Completes:** 0001 (no-Gradle default build path; demote cargo-apk hybrid) +- **Executed:** 2026-09-05 — removal applied, `dart test` fully green + +## Context + +ADR-0001 demoted the cargo-apk + Rust NativeActivity hybrid to experimental; +it has never been un-demoted. Today's inventory: + +| Piece | State | +|---|---| +| `rust_wrapper/` (24 KB, NativeActivity crate) | Header comment says "demoted; unused by default oka builds". Links `#[link(name = "flutter_engine")]` against a static library oka never ships — it cannot produce a working artifact as-is. Nothing in `bin/`, `lib/`, or `packages/` reads it. | +| `test/cargo_apk_manifest_test.dart` (quarantine test) | The **only** consumer of `rust_wrapper/` — and the repo's known-failing baseline: it shells out to `cargo metadata`, failing on every machine without a working cargo toolchain, including `ci.yml` (`ubuntu-latest`, no rust setup step). | +| `CargoApkManifest` (`oka_android/src/build/cargo_apk_manifest.dart`) | Exported from the barrel, called by tests only. No production path. | +| `CargoApkConfig` (`oka_core/src/config/cargo_apk_config.dart`) + `OkaConfig.cargoApk` | Exported from the barrel; the getter has **zero callers** in `bin/`/`lib/`/`packages/`. Dead config surface (a `cargo_apk:` section still sits in `example/oka.yaml`). | +| `FlutterAndroidBuilder` | A compatibility wrapper whose sole job is to *refuse* the cargo path and delegate to `FlutterApkBuilder` (asserted by a source-contract test). | + +Everything else matching "rust" in the repo is **RuStore** (vendor repo +routing, gradle fixtures, `STORE=rustore` defines) — unrelated, stays. + +## Options + +- **A. Keep as-is.** Cost: permanently failing test (local + CI), quarantine + guardrails in `AGENTS.md` for a file nobody writes, dead public API surface + in semver-covered barrels (ADR-0006), "≤1 failure" baseline caveat in every + test claim. Benefit: none identified — the experiment cannot link against a + shipped engine and has sat untouched since demotion. +- **B. Keep the crate, drop only the failing test.** Half-measure: the dead + API surface and guardrails remain; `rust_wrapper/` still implies an option + that does not exist. +- **C. Remove completely.** Git history preserves the experiment; resurrecting + it later is `git revert` + a new ADR. Aligns the codebase with ADR-0001's + actual state: one build path, no hybrid. + +## Decision (accepted) + +Option **C** — remove (executed 2026-09-05): + +1. `rust_wrapper/` (entire directory) +2. `test/cargo_apk_manifest_test.dart` (the failing quarantine test) +3. `oka_android/src/build/cargo_apk_manifest.dart` + barrel export +4. `oka_core/src/config/cargo_apk_config.dart` + barrel export + + `OkaConfig.cargoApk` getter +5. `FlutterAndroidBuilder` wrapper + its source-contract test (the delegation + it guards is the only behavior left; `oka build` already routes to + `FlutterApkBuilder` directly) +6. `example/oka.yaml`: drop the dead `cargo_apk:` section +7. Docs: ADR-0001 gains a "completed by 0009" note; `why_this_repo_matters.md`, + `design_faq.md`, `contribution_guide.md`, `AGENTS.md` non-negotiables update + +Not in scope: the legacy `--native-android` pure-SDK pipeline (no cargo; its +fate is a separate question), RuStore vendor routing. + +## Consequences + +Good: `dart test` becomes fully green (baseline "151 pass / ≤1 rust_wrapper +failure" → all pass), CI stops failing, ~600 lines of dead code/config/docs +guardrails gone, barrels match reality. + +Bad / Neutral: breaking change for the (theoretical) hook author importing +`CargoApkConfig`/`CargoApkManifest` — acceptable pre-1.0 (0.1.6) and the +surface was already unusable; NativeActivity experiments would need a fresh +ADR + new engine-linking design regardless. + +**Verification after execution:** `dart test` 100% green with no baseline +caveat; `grep -ri cargo` over `bin/ lib/ packages/*/lib` returns only this +ADR and history docs; `oka explain` on `example/` unchanged. diff --git a/docs/decisions/0010-typed-dart-project-config.md b/docs/decisions/0010-typed-dart-project-config.md new file mode 100644 index 0000000..4b554d4 --- /dev/null +++ b/docs/decisions/0010-typed-dart-project-config.md @@ -0,0 +1,107 @@ +# 0010 — Typed per-project config in Dart (oka.yaml becomes optional) + +- **Status:** accepted +- **Executed:** 2026-09-05 — all stages landed; byte-equivalence gate passed + (see Consequences) +- **Date:** 2026-09-05 +- **Decision-makers:** Anton +- **Extends:** 0006 (declarative Dart composition API), 0007 (self-resolving builds) + +## Context + +ADR-0006 made the Dart entrypoint the extension surface, but the *base* +per-platform config (`android:` SDK levels, package name, ABIs, versions; +`flutter:` entrypoint, build args) still lives in `oka.yaml`. The hook only +overrides fast-settings (`PipelineOverrides`). YAML stays the single source +of truth for the parts every project must set — stringly, untyped, and not +programmable (no conditionals, no sharing, no code-reuse across flavors). + +Research question: can the whole per-platform config move into the project's +Dart pipeline file, strictly typed, with `oka.yaml` becoming optional? + +### What oka.yaml actually carries (audited, example app) + +| Section | Consumed via | Typed today? | +|---|---|---| +| `android:` SDK levels, package_name, version_code/name, java_version, abis, source_dirs | `ctx.config.android.*` (~30 sites across toolchain/steps/lint/signing) | Read-side view only (`AndroidConfig` extension type over map) — **no writable value** | +| `android:` icon, manifest, res_dirs, signing | `PipelineOverrides.fromOkaYaml` → `IconConfig`/`ManifestSpec`/`SigningConfig` | ✅ fully typed (ADR-0006) | +| `pipeline:` extra_deps/assets, deeplinks, local_aars, resource_configs, exclude_plugins, max_size_mb | `PipelineOverrides` | ✅ fully typed + `copyWith` | +| `flutter:` entrypoint, build_args, target_platform, tree_shake_icons | `ctx.config.flutter.*` | Read-side view only | +| `name`/`version` | cosmetic / pubspec fallback exists (ADR-0007) | — | + +### The key seam (why this is cheap) + +**Every config consumer reads `ctx.config` — an `OkaConfig` extension type +over `Map` with `toJson() => value`.** A typed Dart config +value that can materialize that map (same shape as today's YAML doc) plugs in +without touching a single step, tool invocation, or fingerprint. The pipeline +cannot tell whether the map came from YAML or from Dart. + +### Missing pieces (the actual work) + +1. **Writable typed values.** `AndroidConfig`/`FlutterConfig` are read views. + Need const-constructible `AndroidBuild` / `FlutterBuild` values with + defaults, `copyWith`, and `toMap()` emitting the oka.yaml-shaped map. +2. **Precedence in `okaRun`.** Today it always calls `loadOkaYaml`. Needs: + code defaults < `oka.yaml` (if present) < Dart `AndroidBuild` < CLI args + (mode/abi/target/defines, unchanged). +3. **Hook discovery without oka.yaml.** `oka build` requires `oka.yaml` today + (entrypoint key lives there). A full-Dart project needs a discovery rule + that adds **no new YAML key** (0006 law). +4. **`oka doctor`** tolerates missing `oka.yaml` already (prints a warning) — + should recognize a discovered entrypoint instead. + +## Decision (proposed) + +1. **`AndroidBuild` + `FlutterBuild`** typed values in `oka_core`: const + constructors carrying the defaults currently scattered across parsers + (minSdk 21, java 11, abis `[arm64-v8a, armeabi-v7a]`, …), `copyWith`, and + `toMap()`. `AndroidPipeline` gains `config:` (null → yaml-only, today's + behavior). +2. **`okaRun` merges**: `AndroidPipeline.config.toMap()` deep-merged over + `loadOkaYaml()`'s map over `OkaConfig.empty`. Steps, caches, lint, + version fallback: unchanged — they keep reading `ctx.config`. +3. **Hook discovery (no new YAML):** when `oka.yaml` is absent, `oka build` / + `oka explain` / `oka doctor` look for `tool/oka_pipeline.dart` (then + `bin/oka_pipeline.dart`). Present + `oka.yaml` absent → delegate like the + `dart_entrypoint` path. Neither present → today's "run `oka init`" error. +4. **`oka init --from-yaml`**: converts an existing `oka.yaml` into a typed + entrypoint 1:1 (this is the migration tool for **last_answer**). `oka init` + (fresh) gains `--dart` to scaffold the entrypoint instead of YAML. +5. **YAML stays fully supported.** No removal, no deprecation yet — full-Dart + is opt-in per project. Precedence is printed in `oka explain` so dual + sources of truth are never silent. +6. **Staging:** + - Stage 1 — `oka_core` values + `okaRun`/`AndroidPipeline.config` merge + + tests (behavior-preserving: no project changes). + - Stage 2 — **example app first target** (executed): all three sections + moved into `example/tool/oka_pipeline.dart` (discovery convention), + `oka.yaml` deleted. Equivalence gate: same-hook A/B (config from + oka.yaml vs from `AndroidBuild`) → badging, `AndroidManifest.xml`, + `resources.arsc`, `classes.dex` **byte-identical**; only the example's + timestamped `build_info.txt` stamp (custom step) and its signature + cascade differ. + - Stage 3 (executed here): `oka init --from-yaml` converter landed — + run it in the last_answer repo to migrate (`oka explain --deps` + + `oka compare` as gates). **Stage 4 (executed):** discovery in `oka + build`/`explain`/`doctor`; `oka debug step` materializes hook config + via `okaRun --print-config`. + - Stage 4 — `oka doctor`/`init` discovery support. + +## Consequences + +Good: strictly typed, programmable config (flavor logic, shared base configs +as Dart values, compile-time typos instead of silent YAML key typos); oka.yaml +sprawl frozen for good; the hook owns *everything*, not just overrides; zero +step-layer changes thanks to the map seam. + +Bad / Neutral: two config sources during transition (mitigated by printed +precedence in explain); `toMap()` re-couples the typed values to the legacy +map shape — acceptable: that map is the de-facto internal contract, and +`AndroidConfig`/`FlutterConfig` views already encode it; projects mixing both +sources must understand precedence (documented + printed). + +**Authoritative source:** `packages/oka_core/lib/src/config/` (new typed +values), `packages/oka_core/lib/src/oka_run.dart` (merge), +`packages/oka_android/lib/src/android_pipeline.dart` (`config:`), +`packages/oka/lib/src/cli/` (discovery, `oka init --from-yaml`). diff --git a/docs/decisions/0011-hot-reload-run-loop.md b/docs/decisions/0011-hot-reload-run-loop.md new file mode 100644 index 0000000..176d837 --- /dev/null +++ b/docs/decisions/0011-hot-reload-run-loop.md @@ -0,0 +1,147 @@ +# 0011 — Agent-first dev loop: hot reload/hot restart via the flutter_tools daemon protocol + +- **Status:** accepted +- **Date:** 2026-09-06 +- **Decision-makers:** Anton, oka agent +- **Extends:** 0001 (no-Gradle default build path), 0002 (composable pipeline) +- **Executable plan:** [docs/guides/hot_reload_plan.md](../guides/hot_reload_plan.md) + +## Context + +oka's north star is agentic workflows: "an agent can fix a broken build from +oka's error messages alone" ([why this repo matters](../start_here/why_this_repo_matters.md)). +The build side is done; the **dev loop** is not — `oka dev` is a stub that +tells users to fall back to `adb install` + `flutter attach`. + +Hot reload decomposes into five mechanisms: + +| # | Mechanism | Owner today | +|---|---|---| +| 1 | JIT debug APK (`kernel_blob.bin`, `-dTrackWidgetCreation=true`) | ✅ oka (`flutter_assemble.dart`) | +| 2 | Launch with VM service enabled; scrape service URI + auth code from logcat | ❌ flutter_tools | +| 3 | Reach the VM service (adb forward/reverse) | ❌ flutter_tools | +| 4 | Resident kernel compiler (frontend_server, incremental dill deltas) | ❌ flutter_tools | +| 5 | VM-service client / session loop (`reloadSources`, `ext.flutter.reassemble`, restart) | ❌ flutter_tools | + +The Phase-0 invariant bans Gradle / `flutter build apk` as the default build +path — it does **not** ban flutter_tools binaries (oka already shells out to +`flutter assemble` on the default path). The real design question is not +"delegate or not" but **which contract we delegate and which surface we own**. + +### The two contract types (the decisive distinction) + +- `flutter assemble` is a **batch, non-interactive CLI** with stable argv and + parseable stdout. Delegating it is proven safe. +- `flutter attach`'s default interface is an **interactive TUI keystroke loop** + (`r`/`R`/`q`), explicitly not a stable contract. Its output is not designed + for machine consumption. + +oka's primary user — an AI agent — cannot press `r` in a TTY. Any option whose +only control surface is attach's TUI is unusable by the primary user except +via fragile PTY driving. + +### The critical insight: the VM service is the easy 20% + +`reloadSources` (kernel bytes travel over the RPC — no adb push) + +`ext.flutter.reassemble` are simple calls. The hard 80% is the **resident +frontend_server** (incremental compile against the previous dill, protocol +specced only by flutter_tools internals, version-locked). Any option that +avoids reimplementing #4 avoids most of the cost and risk. + +## Options considered + +| Option | Machinery owner | Agent-usable? | Contract stability | Cost | +|---|---|---|---|---| +| A. `oka dev` execs `flutter attach` (TUI) | flutter_tools | ❌ humans only | TUI — none | trivial | +| B. Drive attach's TUI via PTY | flutter_tools | ⚠️ hack | TUI — none | moderate, fragile | +| C. Full DIY resident runner in oka | oka | ✅ | none to rely on | weeks; version-locked to engine internals | +| D. **Embed the flutter_tools daemon** (`flutter attach --machine` / `flutter run --machine`) | flutter_tools (#2–5) | ✅ native | JSON daemon protocol — purpose-built for embedding (this is what VS Code/IntelliJ use) | small–moderate | + +Notes: + +- **A** was the leading candidate in an earlier evaluation. It fails the + primary-user test: no batch, non-TTY path to trigger a reload. Rejected as + the main path; retained as a human-facing escape hatch (`oka dev --tui`). +- **B** admits the TUI is not a contract, then depends on it anyway. Rejected. +- **C** pays the full version-lock cost of `ResidentRunner`/`resident_compiler` + for near-zero differentiation *today*. However, owning the resident compiler + is the only way to (a) overlap the initial kernel compile with + packaging/install (attach starts strictly after install, so its 2–5 s + initial compile is always serial on the critical path) and (b) keep a warm + compiler daemon across `oka build`/`oka dev` invocations. That is a real + future win — but it must be *evidence-driven*, not assumed. +- **D** delegates mechanisms #2–5 to flutter_tools over its machine-readable + daemon protocol, while oka owns build → install → launch and the entire + event/UX surface. JSON commands (`app.reload`, `app.restart`, …) and events + arrive on stdio — batch-friendly, TTY-free, and far more deliberate than + the TUI. + +### Decision criterion (explicit) + +The primary criterion is **agent-usability of the control surface** (headless, +non-TTY reload triggers, structured events oka can parse, route, and improve), +not latency. Latency differences between options are seconds; control-surface +differences are categorical. Secondary criteria: contract stability, and the +surface area of flutter_tools internals oka couples to. + +## Decision (accepted) + +**Option D — hybrid: oka owns everything except the Dart VM session; +the session runs over the flutter_tools daemon protocol; oka owns the +interface to it.** + +1. **oka owns the device layer**: build (existing), `adb install -r`, + `am start`, logcat service-URI scrape, `adb forward`. No Gradle anywhere. +2. **The session is `flutter attach --machine`** (fallback explored in H0: + `flutter run --machine --use-application-binary `, which skips Gradle + for a prebuilt APK — decide with evidence which discovery path is + sturdier). oka speaks the daemon JSON protocol on stdio and maps events + onto oka's structured event surface (`pipeline_events.dart` style). +3. **oka owns the UX for both audiences**: + - Humans: keyboard loop (`r`/`R`/`q`) and human-readable progress — + rendered *by oka*, from daemon events, not by attach's TUI. + - Agents: `oka dev --json` (machine event stream on stdout) and + `oka dev --watch` (non-TTY file watching + automatic reload/restart + dispatch + oka-branded diagnostics when a change requires a full rebuild). +4. **Flag parity is a subsystem, not a gotcha.** `oka dev` may only attach to + an APK whose build fingerprint matches the session it requests. The build + records (Flutter SDK path + engine revision from `flutter_assemble.dart`, + target, build mode, dart-defines) into a session manifest; `oka dev` + validates and replays them, and **refuses loudly on mismatch** — a + mismatched attach silently compiles a kernel that corrupts the running + app, failing at runtime, not at command time. +5. **Scope cuts (accepted):** + - **No incremental native DEX push.** Android's runtime cannot hot-swap + classes in an installed APK; native/res/manifest changes always route to + a full `oka build` + reinstall. (The archived plan's Phase 4.4 is + rejected.) + - **Debug (JIT) only.** Profile/release refuse `oka dev` loudly. + - **No DevFS, no DDS dependency.** Not needed for the Android + daemon-protocol path. + - `_flutter.hotRestart` (as written in the archived plan) does not exist; + hot restart is a full non-incremental kernel compile + app restart + handled by the daemon (`app.restart`). +6. **Escalation path to Option C is open but gated.** If, after H3/H4 + evidence (see plan), the daemon protocol proves limiting (missing events, + latency that matters, SDK-version breakage), a **new ADR** proposing a + minimal DIY resident compiler (frontend_server client + `reloadSources` + + `reassemble`, no DevFS/DDS) is required before coding. It must be justified + by the compile/build-overlap and warm-daemon wins, measured, not assumed. + +## Consequences + +Good: agents get a first-class, headless, structured reload loop; humans get +a real `oka dev`; zero kernel-compiler code to maintain; every upgrade risk +is concentrated in one protocol adapter; the no-Gradle invariant is +untouched (attach/run in machine mode never invokes Gradle when consuming a +prebuilt APK). + +Bad / Neutral: oka couples to the flutter_tools daemon protocol (stable in +practice — IDEs depend on it — but not semver'd; pin + feature-detect + +tolerate-unknown-fields, and keep `--tui` escape hatch); initial compile is +serial after install until/unless Option C is justified later; hot-restart +semantics follow flutter_tools, not a custom contract. + +**Authoritative source:** `packages/oka/lib/src/cli/dev_command.dart`, +`packages/oka_android/lib/src/` (device layer, session manifest), +[hot reload plan](../guides/hot_reload_plan.md). diff --git a/docs/decisions/0012-remove-embedded-ai-client.md b/docs/decisions/0012-remove-embedded-ai-client.md new file mode 100644 index 0000000..2d9566b --- /dev/null +++ b/docs/decisions/0012-remove-embedded-ai-client.md @@ -0,0 +1,50 @@ +# 0012 — Remove the embedded AI client; oka is agent-driven, not LLM-embedding + +- **Status:** accepted +- **Date:** 2026-09-12 +- **Decision-makers:** Anton, oka agent +- **Extends:** 0006 (Dart entrypoint hooks), 0007 (self-resolving builds) +- **Supersedes:** the "AI conversion" section of `docs/guides/design_faq.md` + +## Context + +oka shipped `lib/src/ai/` — an embedded `OkaAiAgent` (Apple Foundation Models +on macOS, Gemini fallback) that converted legacy `build.gradle` files to +`oka.yaml` during `oka init --yaml`, plus prompt templates for manifest +merging and error explanation. The output types (`ManifestMergeResult`, +`MergeRules`) lived in oka_core's public barrel. + +oka's thesis (AGENTS.md) is "Agents execute; humans steer." The primary user +is an AI agent **driving** oka. That changes the calculus for any feature +that embeds an LLM inside the tool. + +## Decision + +Remove the embedded AI client, prompt templates, and their output types from +the published packages. + +1. The driving agent is the LLM. Asking oka to call Gemini with its own API + key is redundant: the agent invoking `oka init` already has full repo + context and better tools than frozen prompt templates. Conversion is an + agent task, not a build-tool task. +2. A build system must be deterministic. Hidden network calls (API keys, + provider outages, non-deterministic output, `$HOME` prompt caches) inside + `oka init` violate that. +3. The migration path remains, deterministically: when `oka init` finds a + legacy `android/app/build.gradle`, it prints precise conversion + instructions (sections to port, pointer to `oka explain` and + `dependency_suggest`'s known-class table) so the driving agent or human + performs the conversion in reviewable diff space. +4. `ManifestMergeResult` / `MergeRules` are removed from oka_core: they were + the AI merge output type and have no other consumer. Manifest merging + stays a deterministic, spec-driven step (`manifest_spec.dart`). + +## Consequences + +- `lib/src/ai/` deleted; `http` and `crypto` dropped from the root package. +- `oka init --yaml` no longer converts Gradle automatically; it instructs. +- Agent workflows are unchanged or improved: `oka explain`, typed + `oka.yaml`/entrypoint config, and the known-class dependency table give + the driving agent everything the embedded client approximated. +- Re-introducing any LLM-backed command requires a new ADR with evidence + that the driving agent cannot perform the task better. diff --git a/docs/decisions/0013-toolchain-provisioning-artifact-store.md b/docs/decisions/0013-toolchain-provisioning-artifact-store.md new file mode 100644 index 0000000..366c6e3 --- /dev/null +++ b/docs/decisions/0013-toolchain-provisioning-artifact-store.md @@ -0,0 +1,146 @@ +# 0013 — Toolchain, provisioning, and artifact store as composable surfaces + +- **Status:** accepted +- **Date:** 2026-09-06 +- **Decision-makers:** Anton, oka agent + +## Context + +Everything that surrounds the build — locating the SDK, resolving Java, +downloading build-tools and Kotlin, adb, emulators, Maven artifacts — is +today opaque oka behavior, not oka surface. Concretely: + +- `SdkLocator` is a god-object: resolution precedence + (`OKA_ANDROID_SDK` → `~/.oka/android-sdk` → `ANDROID_HOME` → common paths) + is encoded in control flow — invisible, untestable, unprintable. It also + downloads (ad-hoc `curl`, three near-identical methods) and even reads + `stdin` interactively inside the build path — CI-breaking and agent-hostile. +- Caching is scattered across at least four disconnected stores: + `/step_cache.json` (per-project, well-designed), + `~/.oka/cache/androidx`, `~/.oka/tools`, and the dependency cache. Nothing + is inspectable or purgeable as a unit; nothing is shareable across projects + except by accident of path. +- Demand exists for device/emulator orchestration and store publishing + (Google Play, Huawei AppGallery, RuStore), each of which forces more + toolchain setup today. Doing these as more baked-in behavior would grow the + black box oka exists to eliminate. + +Framing analogy: **Nix semantics, Flutter ergonomics.** Take from Nix the +inspectable, addressable store and plans that can be evaluated without +executing. Leave behind Nix's own language, laziness, and purity dogma — +oka composes typed Dart values with sensible defaults instead. + +## The two-axis model + +A central correction drives this ADR: **distribution targets are not +platforms.** + +``` +Platform axis (PlatformPipeline, --platform): + android (today) · ios · harmony · linux · windows · web ← criteria-gated (north star) + +Distribution axis (targets composed ON a platform build): + sideload · play · huawei · rustore · ci-upload · ... +``` + +- Google Play / Huawei / RuStore builds are **one application** on one + platform; a store target = a **build-variant composition** (e.g. Huawei: + no GMS deps, `agconnect-services.json`, its own signing rules) **plus a + publish tail** (upload API, metadata). These are separate packages + (`oka_play`, `oka_huawei`, …) depending on `oka_core` + the platform + package — never a fork of the platform pipeline, never a "Play pipeline" + vs "Huawei pipeline" split. +- Toolchains (Android SDK, NDK, adb, emulators, JDK) are + **platform-scoped providers**. The artifact store is the one primitive + that is cross-platform by nature and therefore lives in `oka_core`. + +## Decision + +Chosen: **contracts in core, components in packages, defaults as +replaceable implementations.** + +1. **`oka_core` gains three small contracts** (kernel stays ruthlessly + small — same discipline as `universal_storage_interface`): + - `ArtifactStore` + `ContentKey` — content-addressed storage: + `key = hash(inputs) + toolVersion + platform`. Implementations fetch + through `fetch(key, miss)`. + - `Toolchain` / `ToolProvider` — tool resolution becomes **data**: an + ordered, printable resolution policy (explicit config → env vars → + oka-managed roots → system), and provisioning that must go through the + store. `oka doctor` prints the resolved policy; `oka explain` stays + tool-free. + - A `ResolvedToolchain` artifact injected into steps via the existing + `PipelineState` — no step calls a god-object. +2. **Default implementations ship as components, not behavior:** a plain + `LocalArtifactStore` (directory + JSON index, human-decodable layout like + `~/.oka/store/aapt2/8.0.2-/bin/aapt2` — inspectable with plain + `ls`/`find`/`du`), and Android tool providers in `oka_android`. +3. **Cache scope rule:** share **inputs** across projects (SDKs, NDK, Maven + artifacts, emulator images — `OKA_CACHE`-pointable for team/network + sharing); **outputs** stay per-project in `buildDir/` (cross-machine + output caches are where nondeterminism lives). The four scattered caches + unify behind `ArtifactStore`. +4. **No stdin in any build path, ever.** Interactivity is only an explicit + opt-in policy/flag; otherwise steps fail with `StepResult.failure` + naming the fix (agent-first law, ADR-0007). +5. **`oka cache list / gc / why` are views over the interface** — and the + Dart API (`store.entries()`) is the real agent surface: agents script the + store in ten lines instead of learning subcommands. +6. **Distribution targets are deferred to their own ADR (0014)** — store API + clients, auth/secret handling, and metadata formats are a design area of + their own. This ADR only fixes the axis and the package boundary + (`oka_play`/`oka_huawei` as target packages). +7. **`Target` grouping in the composition root is deferred.** Targets ship + as `Pipeline`s first; a grouping concept can be added later but never + removed. + +### What oka does / does not do (scope law) + +Oka **does**: composable toolchain resolution + provisioning, a content- +addressed artifact store with replaceable defaults, platform pipelines, and +device/emulator lifecycle as pipeline steps (platform-scoped). + +Oka **does not**: own store API clients or credentials (target packages, +ADR-0014), act as a general orchestrator (bazel/just/melos territory), or +promise Nix-grade reproducibility proofs — inspectability is the default, +purity is not dogma. + +## Alternatives considered + +- **Bake cache/toolchain/emulator behavior into oka** — rejected: it is the + black box being solved; control requires contracts, not features. +- **Everything configurable (Gradle-style knob zoo)** — rejected: recreates + the config sprawl oka replaces; typed values + composition win. +- **Store targets as platforms** (`GooglePlayPipeline` vs + `HuaweiPipeline`) — rejected: wrong axis; both are one Android app, and + treating stores as platforms would fork toolchains and force the split + onto every future store. + +## Consequences + +Good: +- Cache stops being a black box: known key function, self-describing layout, + one interface to inspect/purge/replace/share. +- Env-var precedence becomes printable data (doctor), testable, and + overridable per project without editing oka. +- Emulator/device and future store work compose over the same kernel — + no new mechanisms. +- Third-party target/toolchain packages become possible and gateable + (conformance tests: dry-run without credentials, no stdin, no secrets in + state — mirrors the `universal_storage_conformance` pattern). + +Bad / Neutral: +- New quasi-public API in `oka_core` → semver discipline on the kernel. +- Migration cost: `SdkLocator` dissolution touches every tool step; + behavior-preserving refactor first, byte-equivalence gates (`oka compare`) + as evidence. +- Two store layers to explain (per-project `StepCache` vs shared + `ArtifactStore`) — documented distinction: outputs vs inputs. + +## Phased plan + +Tracked in `docs/PHASE_CHECKLIST.md` (T0–T3). Distribution-target ADR +(0014) is a separate checkpoint, gated on T0/T1 landing. + +**Authoritative source:** `packages/oka_core` (contracts), this ADR, +`docs/PHASE_CHECKLIST.md` (progress). diff --git a/docs/decisions/0014-distribution-targets-secrets-model.md b/docs/decisions/0014-distribution-targets-secrets-model.md new file mode 100644 index 0000000..250586d --- /dev/null +++ b/docs/decisions/0014-distribution-targets-secrets-model.md @@ -0,0 +1,139 @@ +# 0014 — Distribution targets and the three-tier secrets model + +- **Status:** accepted +- **Date:** 2026-09-06 +- **Decision-makers:** Anton, oka agent +- **Depends on:** ADR-0013 (two-axis model, ArtifactStore, T0/T1 landed), + ADR-0015 (verb/target split, `oka run` landed) + +## Context + +ADR-0013 fixed the axis — store targets are build-variant compositions plus +publish tails, shipped as packages (`oka_play`, `oka_huawei`, …) — and +deferred the design of the target contract and credential handling. The +maintainer decision on credentials: support Flutter-native mechanisms +(`String.fromEnvironment`, `--dart-define`, `--dart-define-from-file`) and +the asset pattern (paths to secret files) rather than inventing an oka +secret store. + +## Critical analysis of the Flutter patterns + +The three mechanisms are *not interchangeable*; each belongs to exactly one +tier. Treating them as one bag of options is how secrets end up in binaries. + +**`String.fromEnvironment` / `--dart-define` / `--dart-define-from-file` +(compilation-time constants)** +- Mechanics: values are resolved by the compiler and **baked into the + binary** (Dart AOT snapshot). They are recoverable from `libapp.so` by + snapshot analysis; they appear in CI logs, in `flutter run --verbose`, and + in process listings (`ps` shows the full flag list). +- Therefore: correct for **app-visible, non-secret build config** — API base + URLs, feature flags, build channel names, flavor identity. They are + compile-time `const`, tree-shakeable, and type-checkable at the use site. +- Therefore: **wrong for credential contents.** A service-account private + key in a dart-define is in the artifact you ship, in every log that echoed + the command, and on every machine that ran the build. +- Also: they cannot express file indirection at compile time — the value is + fixed before the app runs, so "the app reads a secret at runtime" is + simply a different problem (that is runtime configuration, e.g. + `--asset-bundle` payloads or a backend fetch, and out of scope here). + +**Paths to secret files (the asset pattern)** +- Store-publishing credentials are **build-host credentials**, not + app-embedded ones: the Play Publisher API service-account JSON, the + AppGallery Connect API credentials, keystores. They are consumed by oka + steps (`apksigner`, upload clients) on the machine running the build — + the app binary never needs them. +- The right shape is the same as assets: typed config holds a **path** (or + an env-var name resolving to a path); the file itself stays out of git + (`gitignore`-gated) and out of the binary. This matches the existing + `signing_config.dart` (keystore path + password indirection). + +## Decision + +### 1. PublishTarget contract (oka_core) + +A `PublishTarget` is a `Target` (ADR-0015: typed, const-constructible, +compiles to a validated pipeline) with additional conformance laws: + +- **Dry-run without credentials must succeed** and print exactly what a real + run would do (upload endpoint, track, artifact, metadata) — the ADR-0008 + plan law applied to publishing. +- **No stdin, ever** (ADR-0013 law). +- **No secret values in `PipelineState`, logs, or events** — paths and + resolved booleans only. State can be dumped by tooling; treat it as + public. +- Publish tails compose the platform build's artifacts via the standard + artifact mechanism (e.g. requires `Artifact('aab-path')`). + +### 2. Three-tier secrets model + +| Tier | Mechanism | Contents | Resolved by | Example | +|---|---|---|---|---| +| App build config | `String.fromEnvironment` via `--dart-define` / `--dart-define-from-file` | non-secrets only | compiler, into the binary | `API_BASE_URL`, feature flags, channel name | +| Build-host credentials | **path references** (asset pattern) | secret files | oka pipeline at build time | Play service-account JSON, keystore, agconnect creds | +| CI indirection | env vars naming **paths** (not values) | — | oka resolution policy | `OKA_PLAY_SERVICE_ACCOUNT` | + +Resolution policy for credential paths reuses the T1 ordered-policy shape +(inspectable as data, printed by doctor): explicit typed-config path → +`OKA__*` env var → well-known location (`~/.oka/credentials//…`). +Failures name every candidate tried and the fix — same remediation law as +tool resolution. + +### 3. Guards (oka enforces the model) + +- **Doctor secret audit:** dart-define keys (inline or from file) are + checked against secret-ish key patterns (`password`, `token`, `secret`, + `apikey`, `private_key`, …). A hit is a **failure** naming the tier rule: + move the value to a credential file and reference the path. +- **Repo hygiene:** a credential file resolved inside the project must be + gitignored — oka checks and warns (leveraging the existing + check-contracts machinery where applicable). +- **Define passthrough:** `--dart-define` / `--dart-define-from-file` flow + through `okaRun` into `flutter assemble` unchanged (Flutter-native, no oka + re-typing beyond the audit); typed config may reference the same keys for + compile-time-constant consumption in build steps. + +### 4. Package layout + +`oka_play`, `oka_huawei`, … are target packages per ADR-0013: each ships its +`PublishTarget` + upload steps + credential resolution entries. GMS-exclusion +variants are compositions of the Android build config, validated at +composition time by the artifact checker. Conformance tests (dry-run, no +stdin, no secrets in state) live in a shared suite any target must pass — +mirroring the `universal_storage_conformance` pattern. + +## Alternatives considered + +- **Dart-defines for everything (single mechanism)** — rejected: bakes + secrets into shipped binaries and CI logs (analysis above). +- **Oka-owned secret store / vault integration** — rejected: invents a + credential manager; users already have one (env, CI secret managers, + keychains). Oka references paths; storage stays external. +- **Runtime secret fetch (app queries a backend at startup)** — a valid app + architecture but orthogonal: it removes secrets from the binary without + oka involvement. Publish credentials are build-host-side and unaffected. + +## Consequences + +Good: +- Flutter-native paths for everything the app genuinely needs embedded + (`fromEnvironment` stays first-class); no new secret format. +- Store credentials never enter the artifact, git history, logs, or state. +- Credential resolution is inspectable data (doctor), testable with + injected env, and dry-runnable without credentials — agents can operate + publishing end-to-end without ever touching a real secret. + +Bad / Neutral: +- Two mechanisms to explain (defines vs credential paths) — the doctor + audit makes the boundary mechanical, not tribal knowledge. +- Path-based credentials shift storage responsibility to the user/CI — + documented per-target setup; conformance suite keeps targets honest. + +## Phased plan + +Tracked in `docs/PHASE_CHECKLIST.md` (P0–P2). P0 unblocks package work; +`oka_play` (P1) before `oka_huawei` (P2) — Play is the reference target. + +**Authoritative source:** this ADR, `packages/oka_core` (PublishTarget, +credential policy), `docs/PHASE_CHECKLIST.md` (progress). diff --git a/docs/decisions/0015-cli-verb-target-split.md b/docs/decisions/0015-cli-verb-target-split.md new file mode 100644 index 0000000..2130928 --- /dev/null +++ b/docs/decisions/0015-cli-verb-target-split.md @@ -0,0 +1,104 @@ +# 0015 — CLI verb/target split and project-declared target discovery + +- **Status:** accepted +- **Date:** 2026-09-06 +- **Decision-makers:** Anton, oka agent + +## Context + +The CLI is a hardcoded switch (`bin/oka.dart`) that grows one case per +capability — and platform detail is already leaking into the verb layer: + +- `oka launch` performs adb install + logcat scanning (Android device logic + in a top-level verb). +- `oka get android-sdk` — `get` is generically a provisioning verb, but its + only noun is Android. +- `oka debug dex` probes DEX symbols in the dispatcher layer. + +Continuing this pattern makes the CLI the place where platforms accrete: +iOS (ADR-0013's second-platform candidate), emulators, and store targets +(`oka_play`, `oka_huawei`) would each add switch cases and Android-specific +code to the core CLI — hardcoding platforms into the layer that must stay +platform-agnostic. Meanwhile the project composition root (`tool/oka_pipeline.dart`) +is already a program oka loads and evaluates (ADR-0010), so oka can *ask the +project what it can do* instead of guessing in a switch. + +## Decision + +Chosen: **split the CLI into two axes — static verbs and discovered +targets — with a hard law: verbs never know platforms.** + +| | Verbs | Targets | +|---|---|---| +| Owner | oka core | project composition root (+ target packages) | +| Nature | static, stable, agent-contract | dynamic, discovered, project-declared | +| Examples | `build`, `explain`, `doctor`, `compare`, `debug step`, `clean`, `cache`, `get` | `device`, `publish-play`, `publish-huawei`, custom flows | + +1. **`oka_core` gains a `Target` contract**: a typed, const-constructible + value that compiles to a `Pipeline` (steps, artifacts, validation — the + full ADR-0002 machinery). Targets are *not* arbitrary + `(List) -> Future` functions; keeping them values is what + preserves `oka explain`, composition-time validation, and the + no-execution-before-plan law. +2. **The composition root lists targets declaratively**: + `Oka(pipelines: [...], targets: [DeviceTarget(...), PlayPublishTarget(...)])`. + Target *implementations* ship in platform/target packages + (`oka_android` ships `DeviceTarget`; `oka_play`/`oka_huawei` per + ADR-0013/0014) — the core CLI never grows for a new platform or store. +3. **One new verb: `oka run `.** Dispatch order: core verbs resolve + first (reserved names; targets cannot shadow them); an unknown verb loads + the entrypoint (same path `oka build` already uses) and either dispatches + to a matching target or fails naming the available targets. +4. **`oka explain --targets`** lists each discovered target's step chain — + the same validated-plan surface as builds. +5. **Top-level `oka --help` stays static** (core verbs + a pointer to + `oka explain --targets`). Loading user code to print help is slow and + surprising. +6. **Latency:** entrypoint evaluation for unknown-verb dispatch is + snapshot-cached, keyed on entrypoint content hash (snapshot + infrastructure already exists for the global install). +7. **Fold existing violations behind the boundary**: `launch` becomes an + alias of `oka run device` (device target shipped by `oka_android`); the + Android nouns of `get` route through ADR-0013 tool providers; `debug dex` + moves behind the Android package. Verb implementations in `bin/` and + `packages/oka/lib/src/cli/` contain no platform logic. + +## Alternatives considered + +- **Keep growing the switch** — rejected: it hardcodes platforms into the + CLI, the exact failure mode this ADR prevents. Test: after this ADR, + adding Huawei publishing must not change a single line in `bin/`. +- **Fully dynamic command functions** (`Oka(commands: {'foo': myFn})`) — + rejected: forks the CLI into "oka verbs" vs "whatever the project hacked + up", breaks the explain/validation contract, and makes `oka --help` + non-comparable across projects. Agents lose the stable surface. (Melos + scripts / cargo custom subcommands are this degenerate version.) +- **No change; convention over mechanism** — rejected: the leak already + happened (`launch`, `debug dex`); conventions don't survive growth. + +## Consequences + +Good: +- The core CLI stops growing; platforms and stores arrive as target + packages over the same kernel (completes the ADR-0013 two-axis model on + the CLI surface). +- Projects and agents compose custom flows (device loops, publish runs, + test harnesses) as typed values — discoverable, explainable, validatable. +- Stable agent contract: core verbs are comparable across all projects. + +Bad / Neutral: +- New quasi-public `Target` API in `oka_core` → semver discipline. +- Two-step dispatch (verb → target) to document; `oka run` vs direct verbs + needs a clear help/drive story. +- Snapshot cache adds an invalidation surface (content-hash keyed; worst + case is a recompile, never staleness). + +## Phased plan + +Tracked in `docs/PHASE_CHECKLIST.md` (C0–C2). C0/C1 are gated on nothing; +C2's explain integration builds on C0. ADR-0014 (distribution targets) +defines the first non-trivial targets. + +**Authoritative source:** `bin/oka.dart`, `packages/oka/lib/src/cli/`, +`packages/oka_core` (Target contract), this ADR, +`docs/PHASE_CHECKLIST.md` (progress). diff --git a/docs/decisions/0016-web-shell-station-store-contributions.md b/docs/decisions/0016-web-shell-station-store-contributions.md new file mode 100644 index 0000000..55a0c59 --- /dev/null +++ b/docs/decisions/0016-web-shell-station-store-contributions.md @@ -0,0 +1,179 @@ +# 0016 — Web shell station and store contribution contract (not a platform) + +- **Status:** accepted +- **Date:** 2026-09-07 +- **Decision-makers:** Anton, oka agent + +## Context + +Web app distribution is fragmented across many "stores" — Yandex Games, +CrazyGames, VK Play, itch.io, Discord Activities, Snap, Steam — plus plain +hosting (GitHub Pages, Firebase Hosting). Each store requires its own +`web/index.html` edits (SDK `') + ..writeln('') + ..writeln(''); + return buffer.toString(); + } + + /// Renders `manifest.json` (4-space indent, Flutter template shape). + String renderManifestJson(final WebShell manifestShell) { + final manifest = manifestShell.manifest; + final icons = manifest.icons; + final manifestIcons = >[ + if (icons.icon192.isNotEmpty) + {'src': icons.icon192, 'sizes': '192x192', 'type': 'image/png'}, + if (icons.icon512.isNotEmpty) + {'src': icons.icon512, 'sizes': '512x512', 'type': 'image/png'}, + if (icons.maskable.isNotEmpty) + { + 'src': icons.maskable, + 'sizes': '512x512', + 'type': 'image/png', + 'purpose': 'maskable', + }, + ]; + final value = { + 'name': manifest.name, + 'short_name': manifest.shortName, + 'start_url': manifest.startUrl, + 'display': manifest.display, + if (manifest.themeColor.isNotEmpty) 'theme_color': manifest.themeColor, + if (manifest.backgroundColor.isNotEmpty) + 'background_color': manifest.backgroundColor, + if (manifest.description.isNotEmpty) + 'description': manifest.description, + 'orientation': manifest.orientation, + 'prefer_related_applications': manifest.preferRelatedApplications, + 'icons': manifestIcons, + }; + const encoder = JsonEncoder.withIndent(' '); + return ''' + +${encoder.convert(value)} +'''; + } +} diff --git a/packages/oka_web/lib/src/emitters/inject_emitter.dart b/packages/oka_web/lib/src/emitters/inject_emitter.dart new file mode 100644 index 0000000..3ffa2e9 --- /dev/null +++ b/packages/oka_web/lib/src/emitters/inject_emitter.dart @@ -0,0 +1,197 @@ +/// The inject emitter (ADR-0016 §1.2): first-party day one — the migration +/// path for hand-maintained `web/index.html` files. +/// +/// Injects composed entries between explicit markers +/// (`` … ``, body +/// equivalents). When markers are missing, it **fails with an actionable +/// error** naming exactly how to add them; it never rewrites unowned +/// regions — everything outside the markers is preserved byte-for-byte. +library; + +import 'package:meta/meta.dart'; + +import '../composition.dart'; +import '../emitter.dart'; +import 'render.dart'; + +/// Head injection markers. +/// Begin marker for the head injection region. +const String headBeginMarker = ''; + +/// End marker for the head injection region. +const String headEndMarker = ''; + +/// Begin marker for the body injection region. +const String bodyBeginMarker = ''; + +/// End marker for the body injection region. +const String bodyEndMarker = ''; + +/// The inject emitter: injects composed entries into an existing, +/// hand-maintained `index.html` between explicit oka markers. +/// +/// Owns only `index.html` — and only the marker-delimited regions inside +/// it. `manifest.json` is NOT owned: the project's hand-maintained file +/// remains the source of truth for everything outside the markers. +@immutable +class InjectShellEmitter extends ShellEmitter { + /// Const constructor — ships as `const InjectShellEmitter()`. + const InjectShellEmitter(); + + /// Emitter name: `inject`. + @override + String get name => 'inject'; + + /// Owns only the marker-delimited regions of `index.html`. + @override + Set get ownedPaths => const {'index.html'}; + + /// Injects composed entries into the marker regions of + /// [existingIndexHtml]; throws [ShellInjectionException] when the file + /// is missing or its markers are absent/unbalanced. + @override + ShellOutput emit( + final WebShell shell, { + final String? existingIndexHtml, + }) { + if (existingIndexHtml == null) { + throw const ShellInjectionException( + 'index.html not found. The `inject` emitter injects into an ' + 'existing, hand-maintained web/index.html and never creates one. ' + 'Either create web/index.html with the oka markers (see below) or ' + 'use the `generate` emitter, which owns the whole file.\n' + '\n' + 'Add the markers around your head and body injection points:\n' + '\n' + '```html\n' + '\n' + ' ...your hand-maintained tags...\n' + ' \n' + ' \n' + '\n' + '\n' + ' \n' + ' \n' + '\n' + '```\n' + '\n' + 'Then re-run. The `inject` emitter never rewrites regions outside ' + 'the markers.', + ); + } + + final notes = []; + var html = _injectRegion( + html: existingIndexHtml, + beginMarker: headBeginMarker, + endMarker: headEndMarker, + content: _renderHeadBlock(shell), + region: 'head', + notes: notes, + ); + + if (shell.body.isNotEmpty) { + html = _injectRegion( + html: html, + beginMarker: bodyBeginMarker, + endMarker: bodyEndMarker, + content: _renderBodyBlock(shell), + region: 'body', + notes: notes, + ); + } else { + notes.add('inject: no body entries — body region left untouched'); + } + + return ShellOutput( + files: {'index.html': html}, + notes: notes, + ); + } + + String _renderHeadBlock(final WebShell shell) => [ + for (final entry in shell.head) renderHeadEntry(entry), + ].join('\n'); + + String _renderBodyBlock(final WebShell shell) => [ + for (final entry in shell.body) ...renderBodyEntry(entry), + ].join('\n'); + + /// Replaces the region between [beginMarker] and [endMarker] with + /// [content], preserving everything else byte-for-byte. Throws an + /// actionable [ShellInjectionException] when markers are missing or + /// unbalanced. + String _injectRegion({ + required final String html, + required final String beginMarker, + required final String endMarker, + required final String content, + required final String region, + required final List notes, + }) { + final hasBegin = html.contains(beginMarker); + final hasEnd = html.contains(endMarker); + if (!hasBegin && !hasEnd) { + throw ShellInjectionException(_missingMarkersMessage(region)); + } + if (hasBegin != hasEnd) { + throw ShellInjectionException( + 'index.html has an unbalanced $region marker pair: ' + '${hasBegin ? beginMarker : endMarker} found but ' + '${hasBegin ? endMarker : beginMarker} missing.\n' + 'Add the missing marker so the pair wraps the region the web ' + 'shell may manage, e.g.:\n' + '\n' + ' ${region == 'head' ? '' : ''}\n' + ' ...\n' + ' $beginMarker\n' + ' ...oka-managed entries live here...\n' + ' $endMarker\n' + ' ${region == 'head' ? '' : ''}', + ); + } + final beginIndex = html.indexOf(beginMarker); + final endIndex = html.indexOf(endMarker); + if (endIndex < beginIndex) { + throw ShellInjectionException( + 'index.html $region markers are inverted: $endMarker appears ' + 'BEFORE $beginMarker. Reorder them so $beginMarker comes first ' + 'and $endMarker closes the region.', + ); + } + // Preserve the indentation of the marker lines (byte-for-byte outside + // the region includes surrounding whitespace shape). Entry lines keep + // their own two-space render indent. + final lineStart = html.lastIndexOf('\n', endIndex) + 1; + final indent = html.substring(lineStart, endIndex); + notes.add( + 'inject: $region region rewritten between markers ' + '(${endIndex - beginIndex + endMarker.length} bytes → ' + '${content.length} bytes); everything else ' + 'preserved byte-for-byte', + ); + return html.replaceRange( + beginIndex, + endIndex + endMarker.length, + '$beginMarker\n$content\n$indent$endMarker', + ); + } +} + +String _missingMarkersMessage(final String region) { + final isHead = region == 'head'; + return 'index.html has no $region markers ' + '(${isHead ? headBeginMarker : bodyBeginMarker} / ' + '${isHead ? headEndMarker : bodyEndMarker}).\n' + 'Add them around the region the web shell may manage, e.g.:\n' + '\n' + ' ${isHead ? '' : ''}\n' + ' ...your hand-maintained tags...\n' + ' ${isHead ? headBeginMarker : bodyBeginMarker}\n' + ' ${isHead ? headEndMarker : bodyEndMarker}\n' + ' ${isHead ? '' : ''}\n' + '\n' + 'Then re-run. The `inject` emitter never rewrites regions outside ' + 'the markers — it would silently erase your customizations ' + 'otherwise.'; +} diff --git a/packages/oka_web/lib/src/emitters/render.dart b/packages/oka_web/lib/src/emitters/render.dart new file mode 100644 index 0000000..841e407 --- /dev/null +++ b/packages/oka_web/lib/src/emitters/render.dart @@ -0,0 +1,106 @@ +/// Shared rendering helpers for shell emitters (ADR-0016). +/// +/// Pure string builders — no I/O — unit-tested for exact output. +library; + +import '../spec/body_entry.dart'; +import '../spec/head_entry.dart'; + +/// HTML-escapes an attribute value (`&`, `"`, `<`, `>`). +String escapeAttribute(final String value) => value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + +/// HTML-escapes text content (`&`, `<`, `>`). +String escapeText(final String value) => value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); + +/// Renders one [WebHeadEntry] as a single HTML line, indented two spaces. +String renderHeadEntry(final WebHeadEntry entry) { + final buffer = StringBuffer(' '); + switch (entry) { + case final WebMetaEntry e: + if (e.charset != null) { + buffer.write(''); + } else if (e.name != null) { + buffer.write( + '', + ); + } else if (e.property != null) { + buffer.write( + '', + ); + } else if (e.httpEquiv != null) { + buffer.write( + '', + ); + } + case final WebLinkEntry e: + buffer.write(''); + case final WebScriptEntry e: + final mode = [ + if (e.async) 'async', + if (e.defer) 'defer', + ]; + if (e.src != null) { + buffer.write(''); + } else { + buffer.write('') + ..write(e.content ?? '') + ..write(''); + } + } + return buffer.toString(); +} + +/// Renders one [WebBodyEntry] as an indented HTML line (or lines). +List renderBodyEntry(final WebBodyEntry entry) => switch (entry) { + final WebHtmlEntry e => e.html + .split('\n') + .map((final line) => ' $line') + .toList(), + final WebElementEntry e => [ + ' <${_openTag(e)}>${escapeText(e.text)}', + ], + }; + +String _openTag(final WebElementEntry e) { + final buffer = StringBuffer(e.tag); + if (e.id != null) buffer.write(' id="${escapeAttribute(e.id!)}"'); + if (e.classes.isNotEmpty) { + buffer.write(' class="${escapeAttribute(e.classes.join(' '))}"'); + } + for (final attr in e.attributes.entries) { + buffer.write( + ' ${escapeAttribute(attr.key)}="${escapeAttribute(attr.value)}"', + ); + } + return buffer.toString(); +} diff --git a/packages/oka_web/lib/src/publish/gh_pages_deploy_target.dart b/packages/oka_web/lib/src/publish/gh_pages_deploy_target.dart new file mode 100644 index 0000000..089d48c --- /dev/null +++ b/packages/oka_web/lib/src/publish/gh_pages_deploy_target.dart @@ -0,0 +1,524 @@ +/// The `publish-gh-pages` target (ADR-0016 W2): deploy a **directory** +/// artifact to GitHub Pages by pushing a commit to a `gh-pages` branch. +/// +/// Auth model: **ambient git credentials only** — no token inputs, no +/// credential files, no interactive input. Whatever `git push` resolves on +/// the build host (ssh agent, credential helper) is what authenticates. +/// +/// Composition (ADR-0016 §2, targets not pipelines): the target consumes +/// the directory artifact produced by an oka web build chain — +/// +/// ```dart +/// // Simplest: the target stages `build/web` (Flutter's default output) +/// // itself; run `flutter build web` (oka `web-build`) first. +/// targets: [ +/// WebBuildTarget(baseHref: '/my-app/'), +/// GhPagesDeployTarget( +/// // dryRun: false, // deploys are destructive — explicit flip +/// ), +/// ] +/// +/// // Fully composed: one chain that builds then deploys — declare a +/// // custom Target in your entrypoint and reuse the deploy tail: +/// Target( +/// name: 'web-deploy', +/// description: 'flutter build web + push to gh-pages', +/// compile: (ctx) => [ +/// FlutterWebBuildStep(baseHref: '/my-app/'), +/// ...const GhPagesDeployTarget(dryRun: false).compile(ctx), +/// ], +/// ) +/// ``` +/// +/// The target is **independently composable** — it never runs a Flutter +/// build itself and never couples to `WebShellTarget`; point [sourceDir] +/// (typed config) or the [directoryArtifactId] artifact at any directory +/// you want to publish. +/// +/// Why [dryRun] defaults to `true` here (unlike the `PublishTarget` field +/// default): this deploy is **destructive and push-based** — it rewrites a +/// remote branch — and its auth is ambient, so a misconfigured run can +/// succeed end-to-end without any explicit credential step. A real push +/// must always be an explicit decision (`dryRun: false` in typed config). +library; + +import 'dart:io'; + +import 'package:meta/meta.dart'; +import 'package:oka_core/oka_core.dart'; +import 'package:path/path.dart' as p; + +import 'stage_web_dir_step.dart'; + +/// The `publish-gh-pages` [PublishTarget]. +@immutable +class GhPagesDeployTarget extends PublishTarget { + const GhPagesDeployTarget({ + this.dryRun = true, + this.directoryArtifactId = StageWebDirectoryStep.defaultDirectoryArtifactId, + this.sourceDir, + this.branch = 'gh-pages', + this.commitMessage = 'Deploy web build (oka publish-gh-pages)', + this.remote = 'origin', + this.subdirectory, + }); + + /// Typed dry-run flag — `true` by default. Deploys are destructive: the + /// upload tail rewrites the remote [branch] via `git push`, and auth is + /// ambient (no explicit credential step stands between you and the + /// push), so a real push is always an explicit, deliberate flip. + @override + final bool dryRun; + + /// The directory artifact id this target consumes (default: + /// `web-build-output`, produced by `FlutterWebBuildStep`). + final String directoryArtifactId; + + /// Explicit source directory override (typed config). Null → the staged + /// state artifact → `/build/web`. + final String? sourceDir; + + /// Branch to push to (default `gh-pages`). Validated by + /// [validateConfig] — a safe git ref name, no shell metacharacters. + final String branch; + + /// Commit message for the deployment commit. + final String commitMessage; + + /// Git remote to fetch from and push to (default `origin`). + final String remote; + + /// Optional subdirectory filter — publish only this subdirectory of the + /// source directory (e.g. `web` when the artifact is the project root). + /// Must be a relative path without `..` segments. + final String? subdirectory; + + /// Publishes a directory artifact, not a single file. + @override + bool get artifactIsDirectory => true; + + /// Target name: `publish-gh-pages`. + @override + String get name => 'publish-gh-pages'; + + /// Explain-text: where the build directory goes, the auth model, and + /// whether it's a dry run. + @override + String get description => + 'Push the web build directory to GitHub Pages ($remote/$branch, ' + 'ambient git auth${dryRun ? ', dry run' : ''})'; + + /// Remote endpoint summary for the deploy plan. + @override + String get endpoint => 'GitHub Pages (git push to $remote/$branch)'; + + /// Publish track: the target branch. + @override + String get track => branch; + + /// Consumed artifact: the staged web build directory. + @override + String get artifactId => directoryArtifactId; + + /// Publish metadata: branch, remote, and optional subdirectory. + @override + Map get metadata => { + 'branch': branch, + 'remote': remote, + if (subdirectory != null && subdirectory!.isNotEmpty) + 'subdirectory': subdirectory!, + }; + + /// Stages the consumed directory artifact from typed config or the + /// default `build/web` location. + @override + List publishSteps(final BuildContext ctx) => [ + StageWebDirectoryStep( + artifactId: directoryArtifactId, + sourceDir: sourceDir, + ), + ]; + + /// The upload tail: [GhPagesUploadStep]. + @override + BuildStep uploadStep(final BuildContext ctx) => GhPagesUploadStep(this); + + /// Typed-config validation issues (empty = valid). Pure. Git receives + /// [branch]/[remote] as separate argv elements (no shell), but refs and + /// remotes with spaces, leading dashes, or shell metacharacters are + /// rejected anyway — a mistyped config should fail at composition time, + /// not produce a surprising remote call. + List validateConfig() { + final issues = []; + if (branch.isEmpty) { + issues.add('branch is empty — set the target branch, e.g. "gh-pages"'); + } else if (!_gitRefPattern.hasMatch(branch) || branch.contains('..')) { + issues.add( + 'branch "$branch" is not a safe git ref name — use letters, digits, ' + 'dots, dashes, slashes (no spaces, no leading dash, no ".."), ' + 'e.g. "gh-pages"', + ); + } + if (remote.isEmpty) { + issues.add('remote is empty — set the git remote, e.g. "origin"'); + } else if (!_gitRemotePattern.hasMatch(remote)) { + issues.add( + 'remote "$remote" is not a safe remote name — use letters, digits, ' + 'dots, dashes, underscores (no spaces, no leading dash), ' + 'e.g. "origin"', + ); + } + if (commitMessage.isEmpty) { + issues.add( + 'commitMessage is empty — a deployment commit needs a message', + ); + } + final sub = subdirectory; + if (sub != null && sub.isNotEmpty) { + if (p.isAbsolute(sub) || + sub.split('/').contains('..') || + sub.contains(r'\')) { + issues.add( + 'subdirectory "$sub" must be a relative path inside the source ' + 'directory (no absolute paths, no ".." segments)', + ); + } + } + return issues; + } + + /// Debug string: remote/branch plus dry-run marker. + @override + String toString() => 'GhPagesDeployTarget($remote/$branch' + '${dryRun ? ' [dry-run]' : ''})'; + + /// ADR-0016 W1: pure deploy-posture lines for `oka explain --targets` — + /// no I/O, no plan resolution (the artifact path resolves at run time). + @override + List explainDetails(final BuildContext ctx) { + final artifactLine = 'artifact: $artifactId ' + '(directory — ADR-0016 directory-artifact convention)'; + final dryRunLine = dryRun + ? 'dry run: yes — nothing is pushed; flip dryRun: false to deploy ' + 'for real' + : 'dry run: NO — a real git push to $remote/$branch runs'; + return ['deploy plan: $endpoint', artifactLine, 'track: $track', dryRunLine]; + } +} + +/// Safe git ref name: letters/digits then letters, digits, `.`, `-`, `_`, +/// `/`. No spaces, no shell metacharacters, no leading dash. +final RegExp _gitRefPattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._/-]*$'); + +/// Safe remote name (no `/` — that is a ref namespace separator). +final RegExp _gitRemotePattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]*$'); + +/// Pure git command builders (extensions on [GhPagesDeployTarget]). +/// +/// Every git invocation is a separate argv (no shell anywhere — oka never +/// spawns a shell), so values cannot smuggle metacharacters; the typed +/// [GhPagesDeployTarget.validateConfig] rejects unsafe names earlier with +/// actionable errors. +extension GhPagesGitCommands on GhPagesDeployTarget { + /// Does the remote-tracking ref exist locally? + List verifyRemoteBranchArgs() => + ['rev-parse', '--verify', '--quiet', '$remote/$branch']; + + /// Refresh the remote-tracking ref for [branch]. + List fetchBranchArgs() => ['fetch', remote, branch]; + + /// Existing branch: check out the remote branch detached in a temp + /// worktree (``). + List worktreeAddRemoteBranchArgs(final String worktreeDir) => + ['worktree', 'add', '--detach', worktreeDir, '$remote/$branch']; + + /// First deploy (no remote branch yet): detach on the current HEAD; the + /// step then creates an orphan branch inside the worktree. + List worktreeAddHeadArgs(final String worktreeDir) => + ['worktree', 'add', '--detach', worktreeDir, 'HEAD']; + + /// First deploy: orphan branch (no history carried over). + List checkoutOrphanArgs() => ['checkout', '--orphan', branch]; + + /// Stage everything (including deletions from the content sync). + List addAllArgs() => ['add', '-A']; + + /// Detect a content change (allow-empty is false as code: empty output + /// → the deploy step fails actionably instead of committing nothing). + List statusPorcelainArgs() => ['status', '--porcelain']; + + /// Deployment commit arguments. + List commitArgs() => ['commit', '-m', commitMessage]; + + /// Push arguments: the worktree HEAD to the remote branch. + List pushArgs() => ['push', remote, 'HEAD:refs/heads/$branch']; + + /// Worktree removal arguments (cleanup, run with `--force`). + List worktreeRemoveArgs(final String worktreeDir) => + ['worktree', 'remove', '--force', worktreeDir]; + + /// Worktree prune arguments (cleanup of stale worktree metadata). + List worktreePruneArgs() => ['worktree', 'prune']; +} + +/// The real upload tail: sync the source directory into a temporary +/// worktree of the remote/branch checkout and push the deployment +/// commit. +/// +/// Sequence (every call through `ctx.runner`, no shell, no interactive +/// input, ambient git auth — no token inputs ever): +/// +/// 1. `rev-parse --verify --quiet REMOTE/BRANCH` — is the remote branch +/// known locally? +/// 2. Not known: `fetch REMOTE BRANCH`, retry. Still unknown (first +/// deploy): `worktree add --detach DIR HEAD` + `checkout --orphan +/// BRANCH` inside the worktree. +/// 3. Known: `worktree add --detach DIR REMOTE/BRANCH`. +/// 4. Content sync (pure Dart I/O, deterministic): wipe everything in the +/// worktree except the `.git` entry, then copy the source directory +/// (default ignore set: `.git`). +/// 5. `add -A`, `status --porcelain` — empty status fails actionably +/// (allow-empty false as code: nothing changed → nothing to publish). +/// 6. `commit -m `, `push HEAD:refs/heads/`. +/// 7. Cleanup (best effort): `worktree remove --force`, then `prune`. +class GhPagesUploadStep extends BuildStep { + /// Wraps [target]. + GhPagesUploadStep(this.target); + + /// The deploy target whose config this step executes. + final GhPagesDeployTarget target; + + /// Step name: `gh-pages-upload`. + @override + String get name => 'gh-pages-upload'; + + /// Requires the staged web build directory artifact. + @override + Set> get requires => + {Artifact(target.directoryArtifactId)}; + + /// Git environment for the child processes: ambient host environment + /// plus `GIT_TERMINAL_PROMPT=0` — git must never hang waiting for + /// interactive input; ambient credentials only. + static Map gitEnvironment() => { + ...Platform.environment, + 'GIT_TERMINAL_PROMPT': '0', + }; + + /// Executes the deploy sequence documented on this class: validate → + /// resolve artifact/source → worktree setup → content sync → commit → + /// push → cleanup. Fails actionably at each stage. + @override + Future run(final BuildContext ctx, final PipelineState state) async { + final configIssues = target.validateConfig(); + if (configIssues.isNotEmpty) { + return StepResult.failure( + 'GhPagesDeployTarget config is invalid:\n' + '${configIssues.map((final i) => ' - $i').join('\n')}', + ); + } + + final artifactPath = state[target.directoryArtifactId]; + if (artifactPath is! String || artifactPath.isEmpty) { + return StepResult.failure( + 'artifact "${target.directoryArtifactId}" is missing — the gh-pages ' + 'deploy consumes the web build directory (compose after ' + '`flutter build web`, or set sourceDir in the typed config)', + ); + } + var sourceDir = artifactPath; + final sub = target.subdirectory; + if (sub != null && sub.isNotEmpty) sourceDir = p.join(artifactPath, sub); + final source = Directory(sourceDir); + if (!source.existsSync()) { + return StepResult.failure( + 'source directory "$sourceDir" does not exist — build the web ' + 'output first (`flutter build web` via the web-build target), or ' + 'point sourceDir / "${target.directoryArtifactId}" at an existing ' + 'directory', + ); + } + + final worktreeDir = + p.join(ctx.buildDir, 'gh-pages', 'worktree'); + final worktree = Directory(worktreeDir); + if (worktree.existsSync()) worktree.deleteSync(recursive: true); + await Directory(p.dirname(worktreeDir)).create(recursive: true); + + final env = gitEnvironment(); + Future git( + final List args, { + required final String cwd, + }) => + ctx.runner.run('git', args, workingDirectory: cwd, environment: env); + + final projectPath = ctx.projectPath; + + // 1–3: obtain a worktree on the right base commit. + final known = await git( + target.verifyRemoteBranchArgs(), + cwd: projectPath, + ); + var orphan = false; + if (known.ok) { + final added = await git( + target.worktreeAddRemoteBranchArgs(worktreeDir), + cwd: projectPath, + ); + if (!added.ok) { + return StepResult.failure( + 'git worktree add failed (exit ${added.exitCode}):\n' + '${added.stderr}${added.stdout}', + ); + } + } else { + final fetched = await git( + target.fetchBranchArgs(), + cwd: projectPath, + ); + final knownAfterFetch = fetched.ok && + (await git( + target.verifyRemoteBranchArgs(), + cwd: projectPath, + )) + .ok; + if (knownAfterFetch) { + final added = await git( + target.worktreeAddRemoteBranchArgs(worktreeDir), + cwd: projectPath, + ); + if (!added.ok) { + return StepResult.failure( + 'git worktree add failed (exit ${added.exitCode}):\n' + '${added.stderr}${added.stdout}', + ); + } + } else { + // First deploy: the remote branch does not exist yet. Detach on + // HEAD and create an orphan branch inside the worktree. + orphan = true; + final added = await git( + target.worktreeAddHeadArgs(worktreeDir), + cwd: projectPath, + ); + if (!added.ok) { + return StepResult.failure( + 'git worktree add failed (exit ${added.exitCode}):\n' + '${added.stderr}${added.stdout}', + ); + } + final orphanCheckout = await git( + target.checkoutOrphanArgs(), + cwd: worktreeDir, + ); + if (!orphanCheckout.ok) { + return StepResult.failure( + 'git checkout --orphan ${target.branch} failed ' + '(exit ${orphanCheckout.exitCode}):\n' + '${orphanCheckout.stderr}${orphanCheckout.stdout}', + ); + } + } + } + + try { + // The faked-in-tests (or real-git) worktree checkout must exist as a + // directory before the content sync. + await Directory(worktreeDir).create(recursive: true); + // 4: content sync — wipe everything except `.git`, then copy the + // source directory (default ignore set: `.git`). + _syncDirectory(source: sourceDir, into: worktreeDir); + + // 5: stage + change detection (allow-empty false, as code). + final add = await git(target.addAllArgs(), cwd: worktreeDir); + if (!add.ok) { + return StepResult.failure( + 'git add failed (exit ${add.exitCode}):\n' + '${add.stderr}${add.stdout}', + ); + } + final status = + await git(target.statusPorcelainArgs(), cwd: worktreeDir); + if (!status.ok) { + return StepResult.failure( + 'git status failed (exit ${status.exitCode}):\n' + '${status.stderr}${status.stdout}', + ); + } + if (status.stdout.trim().isEmpty) { + return StepResult.failure( + 'nothing to publish: the source directory is identical to the ' + 'current content of ${target.remote}/${target.branch} (allow-empty ' + 'commits are disabled). Rebuild the web output, or publish a ' + 'different directory via sourceDir / subdirectory.', + ); + } + + // 6: commit + push (ambient auth — never any token input). + final commit = await git( + target.commitArgs(), + cwd: worktreeDir, + ); + if (!commit.ok) { + return StepResult.failure( + 'git commit failed (exit ${commit.exitCode}):\n' + '${commit.stderr}${commit.stdout}', + ); + } + final push = await git( + target.pushArgs(), + cwd: worktreeDir, + ); + if (!push.ok) { + return StepResult.failure( + 'git push to ${target.remote}/${target.branch} failed ' + '(exit ${push.exitCode}) — ambient git credentials did not ' + 'resolve; check `git push` on this host manually:\n' + '${push.stderr}${push.stdout}', + ); + } + } finally { + // 7: cleanup (best effort) — the worktree must not outlive the run. + await git( + target.worktreeRemoveArgs(worktreeDir), + cwd: projectPath, + ); + await git(target.worktreePruneArgs(), cwd: projectPath); + } + + return StepResult.success({ + 'gh-pages-branch': target.branch, + 'gh-pages-remote': target.remote, + if (orphan) 'gh-pages-orphan': 'true', + 'artifact-path': sourceDir, + }); + } + + /// Deterministic content sync: remove every worktree entry except the + /// `.git` marker, then copy [source] recursively (skipping `.git`). + static void _syncDirectory({ + required final String source, + required final String into, + }) { + final root = Directory(into); + for (final entity in root.listSync()) { + if (p.basename(entity.path) == '.git') continue; + entity.deleteSync(recursive: true); + } + _copyTree(Directory(source), into); + } + + static void _copyTree(final Directory dir, final String targetPath) { + for (final entity in dir.listSync()) { + final name = p.basename(entity.path); + if (name == '.git') continue; // default ignore set + final destination = p.join(targetPath, name); + if (entity is Directory) { + Directory(destination).createSync(recursive: true); + _copyTree(entity, destination); + } else if (entity is File) { + File(entity.path).copySync(destination); + } + } + } +} diff --git a/packages/oka_web/lib/src/publish/itch_deploy_target.dart b/packages/oka_web/lib/src/publish/itch_deploy_target.dart new file mode 100644 index 0000000..add6280 --- /dev/null +++ b/packages/oka_web/lib/src/publish/itch_deploy_target.dart @@ -0,0 +1,359 @@ +/// The `publish-itch` target (ADR-0016 W2): deploy a **directory** artifact +/// to itch.io via `butler push /:`. +/// +/// Auth model: butler's own credential store plus an optional API key +/// surfaced **only** as a [CredentialRef] (`BUTLER_API_KEY` env var or the +/// well-known file). The step may pass the key to the child process via +/// its environment — the value is never logged, echoed, or stored: no +/// secret value ever enters [PipelineState], step data, or events. +/// +/// Composition (ADR-0016 §2): identical to `GhPagesDeployTarget` — the +/// target consumes the directory artifact of an oka web build chain +/// (`web-build-output` → `build/web` by default) and is independently +/// composable (point [sourceDir] at any directory, e.g. a packaged zip +/// directory for HTML5 games). +/// +/// Why [dryRun] defaults to `true`: a real `butler push` is a destructive +/// remote publish; it must always be an explicit decision (flip +/// `dryRun: false` in typed config). +library; + +import 'dart:io'; + +import 'package:meta/meta.dart'; +import 'package:oka_core/oka_core.dart'; +import 'package:path/path.dart' as p; + +import 'stage_web_dir_step.dart'; + +/// The `publish-itch` [PublishTarget]. +@immutable +class ItchDeployTarget extends PublishTarget { + const ItchDeployTarget({ + required this.user, + required this.game, + this.dryRun = true, + this.channel = 'web', + this.directoryArtifactId = StageWebDirectoryStep.defaultDirectoryArtifactId, + this.sourceDir, + this.butlerBinary = 'butler', + this.apiKeyPath, + this.apiKeyEnvVar, + }); + + /// Typed dry-run flag — `true` by default. Deploys are destructive: the + /// upload tail publishes to a live itch.io channel, so a real push is + /// always an explicit, deliberate flip. + @override + final bool dryRun; + + /// itch.io user (account) name. Part of the butler push channel + /// address — validated by [validateConfig]. + final String user; + + /// itch.io game (project) name. Part of the butler push channel + /// address — validated by [validateConfig]. + final String game; + + /// itch.io channel (default `web` — browser-playable builds). + final String channel; + + /// The directory artifact id this target consumes (default: + /// `web-build-output`, produced by `FlutterWebBuildStep`). + final String directoryArtifactId; + + /// Explicit source directory override (typed config). Null → the staged + /// state artifact → `/build/web`. + final String? sourceDir; + + /// Butler executable name (default `butler`; override for a pinned + /// toolchain path). + final String butlerBinary; + + /// Explicit API-key **file path** (typed config, path only — the file + /// contains the key; the key value never enters oka). Highest-precedence + /// resolution source. + final String? apiKeyPath; + + /// Overrides the API-key env var name (default `BUTLER_API_KEY`). + /// + /// Credential-model note (documented deviation): unlike the ADR-0014 + /// path-ref tier-2, this env var names a **value** (butler's own + /// convention), not a credential file path. It is still surfaced only as + /// a redacting [CredentialRef]; the value flows straight to the butler + /// child-process environment and is never logged or stored. + final String? apiKeyEnvVar; + + /// The redacting API-key reference (path/value-location only, never a + /// value). + CredentialRef get apiKeyRef => CredentialRef( + target: 'itch', + kind: 'butler-api-key', + explicitPath: apiKeyPath, + envVar: apiKeyEnvVar ?? 'BUTLER_API_KEY', + ); + + /// The butler push channel address: `/:`. + String get channelAddress => '$user/$game:$channel'; + + /// Publishes a directory artifact, not a single file. + @override + bool get artifactIsDirectory => true; + + /// Target name: `publish-itch`. + @override + String get name => 'publish-itch'; + + /// Explain-text: where the build directory goes and whether it's a dry + /// run. + @override + String get description => + 'Push the web build directory to itch.io ($channelAddress via ' + 'butler${dryRun ? ', dry run' : ''})'; + + /// Remote endpoint summary for the deploy plan. + @override + String get endpoint => 'itch.io (butler push)'; + + /// Publish track: the itch.io channel. + @override + String get track => channel; + + /// Consumed artifact: the staged web build directory. + @override + String get artifactId => directoryArtifactId; + + /// Publish metadata: user, game, and channel. + @override + Map get metadata => { + 'user': user, + 'game': game, + 'channel': channel, + }; + + /// Credentials consumed by the upload tail: the redacting butler API + /// key reference. + @override + List get credentialRefs => [apiKeyRef]; + + /// Stages the consumed directory artifact from typed config or the + /// default `build/web` location. + @override + List publishSteps(final BuildContext ctx) => [ + StageWebDirectoryStep( + artifactId: directoryArtifactId, + sourceDir: sourceDir, + ), + ]; + + /// The upload tail: [ButlerUploadStep]. + @override + BuildStep uploadStep(final BuildContext ctx) => ButlerUploadStep(this); + + /// Typed-config validation issues (empty = valid). Pure. The push + /// address is passed as a single argv element (oka never spawns a + /// shell), but user/game/channel are still restricted to safe + /// identifier characters — spaces or shell metacharacters in a store + /// address indicate a mistyped config and fail here with actionable + /// errors, before any tool runs. + List validateConfig() { + final issues = []; + void check(final String what, final String value) { + if (value.isEmpty) { + issues.add('$what is empty — set it in the ItchDeployTarget typed ' + 'config (butler cannot address a channel without it)'); + } else if (!_identifierPattern.hasMatch(value)) { + issues.add( + '$what "$value" is not a safe itch.io identifier — use letters, ' + 'digits, dots, dashes, underscores (no spaces, no shell ' + 'metacharacters, no leading dash)', + ); + } + } + + check('user', user); + check('game', game); + check('channel', channel); + if (butlerBinary.isEmpty || + butlerBinary.contains(RegExp('[^A-Za-z0-9_./-]'))) { + issues.add( + 'butlerBinary "$butlerBinary" is not a safe executable name — use a ' + 'plain name or path without spaces/metacharacters, e.g. "butler"', + ); + } + return issues; + } + + /// Debug string: channel address plus dry-run marker. + @override + String toString() => 'ItchDeployTarget($channelAddress' + '${dryRun ? ' [dry-run]' : ''})'; + + /// ADR-0016 W1: pure deploy-posture lines for `oka explain --targets` — + /// no I/O, no plan resolution (the artifact path resolves at run time). + @override + List explainDetails(final BuildContext ctx) { + final artifactLine = 'artifact: $artifactId ' + '(directory — ADR-0016 directory-artifact convention)'; + final dryRunLine = dryRun + ? 'dry run: yes — nothing is pushed; flip dryRun: false to deploy ' + 'for real' + : 'dry run: NO — a real butler push runs'; + return [ + 'deploy plan: $endpoint', + artifactLine, + 'channel: $channelAddress', + dryRunLine, + ]; + } +} + +/// Safe itch.io identifier: letters/digits then letters, digits, `.`, `-`, +/// `_`. No spaces, no shell metacharacters, no leading dash. +final RegExp _identifierPattern = RegExp(r'^[A-Za-z0-9][A-Za-z0-9._-]*$'); + +/// Pure command builders for butler invocations (extension on +/// [ItchDeployTarget]). +extension ButlerCommands on ItchDeployTarget { + /// ButlER push arguments (the executable is passed separately to the + /// process runner): `push DIR USER/GAME:CHANNEL`. + List pushArgs({required final String directory}) => + ['push', directory, channelAddress]; + + /// `--version` — availability probe before the real push. + List versionArgs() => ['--version']; +} + +/// The real upload tail: `butler push /:` via +/// `ctx.runner` (no shell, no interactive input). +/// +/// API-key resolution (in order, matching the [CredentialRef] policy +/// tiers): typed-config file path ([ItchDeployTarget.apiKeyPath]) → +/// `BUTLER_API_KEY` env var (the key **value**, butler's convention) → +/// well-known file `~/.oka/credentials/itch/butler-api-key`. When resolved +/// from a file, the trimmed file content is passed to the butler child +/// process via its environment (`BUTLER_API_KEY`) — the value never enters +/// [PipelineState], step data, logs, or events. When nothing resolves, the +/// step fails actionably (butler must never be left to prompt). +class ButlerUploadStep extends BuildStep { + /// Wraps [target]; [environment] defaults to the host environment + /// (injectable for tests). + ButlerUploadStep(this.target, {final Map? environment}) + : environment = environment ?? Platform.environment; + + /// The deploy target whose config this step executes. + final ItchDeployTarget target; + + /// Host environment (injectable for tests); the butler child process + /// inherits this plus the resolved `BUTLER_API_KEY` when the key came + /// from a file. + final Map environment; + + /// Step name: `butler-push`. + @override + String get name => 'butler-push'; + + /// Requires the staged web build directory artifact. + @override + Set> get requires => + {Artifact(target.directoryArtifactId)}; + + /// Validates config, resolves the directory artifact and API key, then + /// runs `butler push` via [BuildContext.runner] (no shell, no + /// interactive input). Fails actionably on missing config/artifact/key + /// or non-zero butler exit. + @override + Future run(final BuildContext ctx, final PipelineState state) async { + final configIssues = target.validateConfig(); + if (configIssues.isNotEmpty) { + return StepResult.failure( + 'ItchDeployTarget config is invalid:\n' + '${configIssues.map((final i) => ' - $i').join('\n')}', + ); + } + + final dirPath = state[target.directoryArtifactId]; + if (dirPath is! String || dirPath.isEmpty) { + return StepResult.failure( + 'artifact "${target.directoryArtifactId}" is missing — the itch ' + 'deploy consumes the web build directory (compose after ' + '`flutter build web`, or set sourceDir in the typed config)', + ); + } + final dir = Directory(dirPath); + if (!dir.existsSync()) { + return StepResult.failure( + 'directory "$dirPath" does not exist — build the web output first ' + '(`flutter build web` via the web-build target), or point ' + 'sourceDir / "${target.directoryArtifactId}" at an existing ' + 'directory', + ); + } + + // Credential resolution — the value stays in this local scope and is + // handed to the child process environment only. Never logged/stored. + final apiKey = _resolveApiKey(); + if (apiKey == null) { + return StepResult.failure( + 'no butler API key resolved — tried (in order): the typed-config ' + 'path (${target.apiKeyRef.explicitPath ?? 'not set'}), the env var ' + '${target.apiKeyRef.envVarName}, and the well-known location ' + '${target.apiKeyRef.wellKnownPath}. Put the key in one of those ' + 'sources (paths/env only — oka never stores key values); or log ' + 'in once with `butler login` so butler uses its own store.', + ); + } + + final childEnvironment = { + ...environment, + target.apiKeyRef.envVarName: apiKey, + }; + + final result = await ctx.runner.run( + target.butlerBinary, + target.pushArgs(directory: dirPath), + workingDirectory: ctx.projectPath, + environment: childEnvironment, + ); + if (!result.ok) { + return StepResult.failure( + 'butler push failed (exit ${result.exitCode}) — butler diagnostics ' + 'follow verbatim (the API key was passed via the child-process ' + 'environment, never as an argument, and is never stored in oka ' + 'state):\n' + '${result.stderr}${result.stdout}', + ); + } + + return StepResult.success({ + 'itch-channel': target.channelAddress, + 'artifact-path': dirPath, + }); + } + + /// Ordered API-key resolution: typed-config path → env var → well-known + /// file. Returns the raw value (caller keeps it in local scope) or null. + String? _resolveApiKey() { + final explicitPath = target.apiKeyPath; + if (explicitPath != null && explicitPath.isNotEmpty) { + final file = File(explicitPath); + if (!file.existsSync()) return null; // configured-but-missing: no + // fall-through (ADR-0014 credential policy, tier 1). + return file.readAsStringSync().trim(); + } + final envValue = environment[target.apiKeyRef.envVarName]; + if (envValue != null && envValue.isNotEmpty) return envValue; + final home = environment['HOME']; + if (home != null && home.isNotEmpty) { + final wellKnown = File(p.join( + home, + '.oka', + 'credentials', + 'itch', + target.apiKeyRef.wellKnownFileNameOrDefault, + )); + if (wellKnown.existsSync()) return wellKnown.readAsStringSync().trim(); + } + return null; + } +} diff --git a/packages/oka_web/lib/src/publish/stage_web_dir_step.dart b/packages/oka_web/lib/src/publish/stage_web_dir_step.dart new file mode 100644 index 0000000..6c88f8f --- /dev/null +++ b/packages/oka_web/lib/src/publish/stage_web_dir_step.dart @@ -0,0 +1,74 @@ +/// Staging for the directory-artifact convention (ADR-0016 §2): web deploy +/// targets consume a **directory** path as the publish artifact. +/// +/// Mirrors the `StageAabStep` precedence contract from the file-artifact +/// targets (ADR-0014), applied to directories: the typed [sourceDir] +/// override wins; then an upstream artifact with the same id already in +/// state (e.g. `web-build-output` provided by `FlutterWebBuildStep`); the +/// fallback is Flutter's default web output location `/build/web`. +/// +/// The *existence/kind* check happens in the deploy steps (dry-run must +/// succeed without a produced build — the plan describes what a real run +/// would deploy). +library; + +import 'package:oka_core/oka_core.dart'; +import 'package:path/path.dart' as p; + +import '../steps/flutter_web_build_step.dart'; + +/// Stages the publish directory artifact ([artifactId]). +/// +/// Pure with respect to the filesystem: it only *resolves and records a +/// path* in [PipelineState] — it never creates, validates, or uploads +/// anything. Deploy steps own the existence checks so a dry run succeeds +/// without a build. +class StageWebDirectoryStep extends BuildStep { + /// Wraps the artifact id and optional typed source override. + StageWebDirectoryStep({ + required this.artifactId, + this.sourceDir, + }); + + /// Default artifact id: the directory artifact produced by + /// [FlutterWebBuildStep] (`web-build-output` → `build/web`). + static const defaultDirectoryArtifactId = 'web-build-output'; + + /// The directory artifact the deploy tail consumes. + final String artifactId; + + /// Typed-config directory override (highest precedence). Null → state → + /// Flutter's default web output location. + final String? sourceDir; + + /// Step name: `stage-web-dir`. + @override + String get name => 'stage-web-dir'; + + /// Produces the directory artifact. + @override + Set> get provides => {Artifact(artifactId)}; + + /// Resolves the publish path (typed override → state → default web + /// output) and records it in [PipelineState]; never touches the + /// filesystem. + @override + Future run(final BuildContext ctx, final PipelineState state) { + final path = sourceDir ?? _resolveStaged(ctx, state); + if (path.isNotEmpty) state[artifactId] = path; + return Future.value(StepResult.success()); + } + + /// State resolution (typed override already handled): an upstream + /// artifact with [artifactId] wins over the default output layout. + String _resolveStaged(final BuildContext ctx, final PipelineState state) { + final existing = state[artifactId]; + if (existing is String && existing.isNotEmpty) return existing; + return defaultWebOutput(ctx); + } + + /// Flutter's default web output location: `/build/web` (where + /// `flutter build web` writes — keep in sync with FlutterWebBuildStep). + static String defaultWebOutput(final BuildContext ctx) => + p.join(ctx.projectPath, 'build', 'web'); +} diff --git a/packages/oka_web/lib/src/session/browser_session_spec.dart b/packages/oka_web/lib/src/session/browser_session_spec.dart new file mode 100644 index 0000000..60c764f --- /dev/null +++ b/packages/oka_web/lib/src/session/browser_session_spec.dart @@ -0,0 +1,213 @@ +/// The browser session "what" seam (ADR-0017 §1): a typed, const- +/// constructible description of a browser session — binary, flags, debug +/// port, headless posture, profile persistence, boot timeout, window size, +/// and the declared debug protocol. +/// +/// No hidden merging (ADR-0010): users compose specs explicitly in the +/// entrypoint. Oka owns the *configuration value* only — the WebMCP +/// protocol, CDP client logic, and any runtime probing beyond the +/// readiness check stay out of oka (ADR-0017 out-of-scope; ADR-0016 +/// boundary). +library; + +import 'package:path/path.dart' as p; + +/// How the session's browser profile persists across runs (ADR-0017 §5: +/// lifecycle is a parameter, not a type hierarchy). +enum ProfilePersistence { + /// Fresh temporary profile dir per session (test / agent posture): + /// no cookie/login state leaks between runs, and the dir is deleted by + /// teardown. + ephemeral, + + /// Stable profile dir under the build directory (dev posture): logins + /// and browser state survive the command. + persistent; + + /// Human-readable name used in step logs. + String get label => switch (this) { + ephemeral => 'ephemeral', + persistent => 'persistent', + }; +} + +/// The debug protocol a browser engine speaks (ADR-0017: engines differ in +/// capability, and the difference is *declared*, not assumed). +/// +/// Declared from day one so deferred engines (Servo speaks partial +/// WebDriver; Ladybird has none yet) cost only a field value, not new +/// architecture. Chrome requires [cdp]; ChromeSessionTarget fails closed +/// on any other value (ADR-0017 §3). +enum DebugProtocol { + /// No debug protocol — a plain browser window (future engines). + none, + + /// WebDriver (partial on Servo; future engines). + webdriver, + + /// Chrome DevTools Protocol — what Chromium speaks. The readiness probe + /// polls `GET /json/version` over plain HTTP; this is a readiness probe + /// only, never a CDP client (ADR-0017 out-of-scope line). + cdp; + + /// Human-readable name used in errors and logs. + String get label => switch (this) { + none => 'none', + webdriver => 'webdriver', + cdp => 'cdp', + }; +} + +/// Chrome flags oka owns: these are derived from typed spec fields by +/// [chromeLaunchArgs] (see chrome_session_target.dart), so a raw flag in +/// [BrowserSessionSpec.launchFlags] that collides with one of them is +/// rejected — fail closed, naming the typed surface to use instead +/// (ADR-0017 §3: unknown/colliding flags fail closed with the accepted +/// surface named). +const List okaOwnedChromeFlags = [ + '--remote-debugging-port', + '--user-data-dir', + '--headless', + '--window-size', + '--no-first-run', + '--no-default-browser-check', +]; + +/// A typed, const-constructible browser session description (ADR-0017 §1, +/// the "what" seam). +/// +/// A spec is a pure value: it never spawns, probes, or touches the +/// filesystem. The "how" seam lives in the session target/launcher +/// (`ChromeSessionTarget`, ADR-0017 §1 launcher 1 `okaOwned`). Third +/// parties ship specs as const values the way store packages ship shell +/// contributions (ADR-0017 §4) — see `chromeWebMcp` in profiles.dart for +/// the first-party example. +/// +/// ```dart +/// const spec = BrowserSessionSpec( +/// binaryPath: '/Applications/Google Chrome.app/Contents/MacOS/' +/// 'Google Chrome', +/// launchFlags: chromeWebMcpFlags, +/// ); +/// ``` +class BrowserSessionSpec { + /// Creates a session spec. All fields have safe defaults except + /// [binaryPath] — browser binary provisioning is a deferred concern + /// (ADR-0017 out of scope, S1), so day one consumes an explicit path or + /// well-known install. + const BrowserSessionSpec({ + required this.binaryPath, + this.launchFlags = const [], + this.debugPort, + this.headless = true, + this.profilePersistence = ProfilePersistence.ephemeral, + this.bootTimeout = const Duration(seconds: 30), + this.windowSize, + this.debugProtocol = DebugProtocol.cdp, + }); + + /// Absolute path (or resolvable command name) of the browser binary. + /// + /// Explicit by design: provisioning (chrome-for-testing into the store) + /// is deferred (ADR-0017 out of scope, S1), and a silent "whatever + /// Chrome I find" default would make sessions non-hermetic and + /// un-reproducible. + final String binaryPath; + + /// Extra Chromium launch flags, appended after the oka-owned args (last + /// flag wins in Chromium, so user flags intentionally override nothing + /// oka sets — oka-owned flags are rejected in [validate] instead). + /// + /// Example first-party value: `chromeWebMcpFlags` (profiles.dart). + final List launchFlags; + + /// Fixed CDP debug port, or null = auto-assign a free ephemeral port. + /// + /// Explicit ports enable the idempotent reuse path (a port that already + /// answers CDP is reused, never spawned against — EmulatorTarget + /// semantics, ADR-0017 §1); auto-assigned ports cannot be reused because + /// nothing is listening before spawn. + final int? debugPort; + + /// Headless posture — `true` (default) for test / agent sessions: no + /// window, no first-run dialogs, deterministic in CI. + final bool headless; + + /// Profile persistence (default [ProfilePersistence.ephemeral]): test + /// sessions get a throwaway temp profile; dev sessions a stable one + /// (ADR-0017 §5). + final ProfilePersistence profilePersistence; + + /// How long the readiness probe (`GET /json/version`) may poll before + /// the session fails, naming the exact remedy. + final Duration bootTimeout; + + /// Initial window size (`--window-size=x`), or null = browser + /// default. Kept minimal on purpose — window management is not a session + /// concern (ADR-0017 keeps the spec to the fields the launch actually + /// needs). + final ({int width, int height})? windowSize; + + /// The debug protocol the engine speaks (default [DebugProtocol.cdp]). + /// Declared per ADR-0017 §3 so future engines (webdriver/none) fit the + /// same seam; Chrome accepts only [DebugProtocol.cdp] — enforced + /// fail-closed by the chrome session target. + final DebugProtocol debugProtocol; + + /// Pure validation issues (empty = valid). Fail-closed per ADR-0017 §3: + /// a mis-typed spec must fail at composition/run start with the accepted + /// surface named, never produce a surprising browser invocation. + /// + /// Engine-specific rules (e.g. Chrome requires [DebugProtocol.cdp]) live + /// with the engine target (`chromeSessionIssues`) — the spec itself is + /// engine-agnostic. + List validate() { + final issues = []; + if (binaryPath.trim().isEmpty) { + issues.add( + 'binaryPath is empty — set an explicit browser binary path ' + '(browser provisioning is deferred, ADR-0017 out-of-scope/S1).', + ); + } + final port = debugPort; + if (port != null && (port < 0 || port > 65535)) { + issues.add( + 'debugPort $port is not a valid TCP port (0–65535) — or leave ' + 'debugPort null to auto-assign a free port.', + ); + } + if (bootTimeout <= Duration.zero) { + issues.add( + 'bootTimeout must be positive — got $bootTimeout. The readiness ' + 'probe polls /json/version for this long before failing.', + ); + } + final size = windowSize; + if (size != null && (size.width <= 0 || size.height <= 0)) { + issues.add( + 'windowSize ${size.width}x${size.height} is invalid — both ' + 'dimensions must be positive.', + ); + } + for (final flag in launchFlags) { + final owned = okaOwnedChromeFlags.where(flag.startsWith).firstOrNull; + if (owned != null) { + issues.add( + 'launchFlags contains "$flag", which collides with the oka-owned ' + 'flag "$owned" — fail-closed (ADR-0017 §3). Use the typed spec ' + 'surface instead: debugPort, profilePersistence, headless, ' + 'windowSize.', + ); + } + } + return issues; + } + + /// Debug string: browser basename, profile persistence, debug protocol, + /// and headless marker. + @override + String toString() => + 'BrowserSessionSpec(${p.basename(binaryPath)}, ' + '${profilePersistence.label}, ${debugProtocol.label}' + '${headless ? ', headless' : ''})'; +} diff --git a/packages/oka_web/lib/src/session/chrome_session_target.dart b/packages/oka_web/lib/src/session/chrome_session_target.dart new file mode 100644 index 0000000..720f882 --- /dev/null +++ b/packages/oka_web/lib/src/session/chrome_session_target.dart @@ -0,0 +1,525 @@ +/// The `chrome-session` target (ADR-0017 §3): Chrome-first browser session +/// as a composable target — the second instance of the EmulatorTarget +/// pattern (ADR-0017 Context), not a new mechanism. +/// +/// What it does, per run: +/// +/// 1. **Ensure session, idempotently** — if something already answers CDP +/// on the chosen port, it is *reused* (never spawned against; the same +/// semantics as EmulatorTarget reusing a running emulator). Otherwise +/// the binary is spawned with `--remote-debugging-port`, an ephemeral +/// temp profile dir (unless [BrowserSessionSpec.profilePersistence] is +/// `persistent`), and the spec's flags. +/// 2. **Readiness probe** — poll `GET http://127.0.0.1:/json/version` +/// until it answers or [BrowserSessionSpec.bootTimeout] elapses. This is +/// a plain-HTTP readiness probe ONLY: no CDP client, no websocket, no +/// protocol negotiation (ADR-0017 out-of-scope line — that logic lives +/// in the toolkit). +/// 3. **Artifacts** — the session-handle convention (ADR-0017 §2): +/// `session-chrome--handle` ([String], the CDP base URL like +/// `http://127.0.0.1:9222`) and `session-chrome--cdp-port` +/// ([int]). Downstream steps consume these and never know the session +/// kind. Sub-handles `…-pid` / `…-profile-dir` support teardown. +/// +/// ```dart +/// targets: [ +/// ChromeSessionTarget( +/// spec: chromeWebMcp(binaryPath: '/usr/bin/google-chrome'), +/// ), +/// ] +/// ``` +/// +/// Teardown is an explicitly composed [StopChromeSessionStep] (the +/// StopEmulatorStep precedent) — the `Target`/step contract has no +/// automatic end-of-run hook, so ephemeral teardown is a step, not magic. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:oka_core/oka_core.dart'; +import 'package:path/path.dart' as p; + +import 'browser_session_spec.dart'; + +// -- Artifact id convention (ADR-0017 §2) ------------------------------------ + +/// `session--handle`: the opaque primary handle. For Chrome the +/// value is the CDP base URL (`http://127.0.0.1:9222`) — downstream steps +/// build DevTools/HTTP endpoints on it without knowing how the session got +/// there. Frozen naming contract: renaming breaks consumers (ADR-0017 +/// Consequences). +String chromeSessionHandleArtifactId(final String name) => + 'session-chrome-$name-handle'; + +/// `session--cdp-port`: the engine-specific sub-handle carrying the +/// CDP port as an [int]. +String chromeSessionCdpPortArtifactId(final String name) => + 'session-chrome-$name-cdp-port'; + +/// `session--pid`: the spawned browser OS process id. Recorded so a +/// composed [StopChromeSessionStep] can stop exactly the process oka +/// started (and only that one). +String chromeSessionPidArtifactId(final String name) => + 'session-chrome-$name-pid'; + +/// `session--profile-dir`: the ephemeral profile directory oka +/// created for this session. Recorded only for ephemeral spawns so +/// teardown deletes only dirs oka owns — never a reused session's profile, +/// never a persistent one. +String chromeSessionProfileDirArtifactId(final String name) => + 'session-chrome-$name-profile-dir'; + +// -- Pure command construction (scripted-fake testable) ---------------------- + +/// Chromium launch args for one session — pure, golden-tested (house +/// style: `avdManagerCreateArgs`). +/// +/// Oka-owned flags come first (debug port, profile dir, headless, first-run +/// suppression, window size), then [flags] verbatim — Chromium resolves +/// repeated switches last-wins, so user flags are appended *after* and can +/// therefore only ever add, never silently override, what oka sets +/// (collisions are rejected earlier, fail-closed, by +/// [BrowserSessionSpec.validate]). +List chromeLaunchArgs({ + required final int debugPort, + required final String profileDir, + required final List flags, + required final bool headless, + final ({int width, int height})? windowSize, +}) => + [ + '--remote-debugging-port=$debugPort', + '--user-data-dir=$profileDir', + if (headless) '--headless', + // Automation posture: suppress first-run / default-browser prompts — + // headful sessions must boot unattended too (agents never click + // through dialogs). + '--no-first-run', + '--no-default-browser-check', + if (windowSize != null) '--window-size=${windowSize.width}x${windowSize.height}', + ...flags, + ]; + +/// Parses a CDP `/json/version` response body, extracting the `Browser` +/// field (e.g. `Chrome/126.0.6478.126`). +/// +/// Pure and total: invalid JSON, non-object JSON, or a missing/empty +/// `Browser` field all return null — the readiness probe treats null as +/// "not ready" and keeps polling. Deliberately extracts nothing else: +/// parsing deeper CDP surfaces would be the first step down the CDP-client +/// slope oka explicitly stays off (ADR-0017 out-of-scope line). +String? parseVersionJson(final String body) { + final Object? decoded; + try { + decoded = jsonDecode(body); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final browser = decoded['Browser']; + if (browser is! String || browser.isEmpty) return null; + return browser; +} + +/// Assigns a free TCP port on the loopback interface (bind to port 0, read +/// the assigned port, release). +/// +/// There is an inherent bind-race (the port could be taken between release +/// and Chrome's bind) — accepted because the alternative (Chromium's +/// `--remote-debugging-port=0` + DevToolsActivePort file) would split the +/// readiness story across two mechanisms; the HTTP probe remains the single +/// readiness seam (ADR-0017 §1). Pass an explicit [BrowserSessionSpec.debugPort] +/// to avoid the race entirely. +Future assignEphemeralPort() async { + final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = socket.port; + await socket.close(); + return port; +} + +/// Engine-agnostic spec issues plus Chrome's capability declaration +/// (ADR-0017 §3): Chrome speaks CDP only. Pure — surfaced by +/// [EnsureChromeSessionStep] at run start and usable by `oka explain` / +/// tests without any I/O. +List chromeSessionIssues(final BrowserSessionSpec spec) { + final issues = List.of(spec.validate()); + if (spec.debugProtocol != DebugProtocol.cdp) { + issues.add( + 'chrome-session requires debugProtocol: DebugProtocol.cdp — Chrome ' + 'speaks CDP only (ADR-0017 §3). Accepted surface for Chrome: ' + 'cdp; webdriver/none belong to future engines (Servo/Ladybird, ' + 'deferred).', + ); + } + return issues; +} + +// -- Injectable seams -------------------------------------------------------- + +/// HTTP fetch seam for the readiness probe: returns the response body on +/// HTTP 200, null otherwise (connection refused, timeout, non-200). +/// +/// Injectable so lifecycle tests script probe answers without sockets +/// (scripted-fake style, see EmulatorTarget's injected `runProcess`). +typedef CdpProbe = Future Function(Uri url); + +/// Default [CdpProbe]: plain `dart:io` HttpClient GET. This is a readiness +/// probe only — no websocket upgrade, no CDP commands, no protocol logic +/// (ADR-0017 out-of-scope line). +Future httpCdpProbe(final Uri url) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 2); + try { + final request = await client.getUrl(url).timeout(const Duration(seconds: 2)); + final response = await request.close().timeout(const Duration(seconds: 2)); + if (response.statusCode != HttpStatus.ok) return null; + return await utf8.decoder.bind(response).join(); + } on Exception { + return null; + } finally { + client.close(force: true); + } +} + +/// Minimal handle over a spawned browser process — deliberately narrower +/// than `dart:io` [Process] so scripted-fake tests implement two members +/// instead of the full abstract class. +abstract interface class SessionProcess { + /// OS process id (recorded as the `…-pid` sub-handle artifact). + int get pid; + + /// Best-effort termination (`Process.kill` — SIGTERM semantics). + bool kill(); +} + +/// Spawn seam: starts the browser binary with [chromeLaunchArgs]-built +/// argv. Injectable for scripted-fake lifecycle tests. +typedef SessionProcessStarter = Future Function( + String executable, + List arguments, +); + +final class _IoSessionProcess implements SessionProcess { + /// Wraps a real [Process] as a [SessionProcess]. + _IoSessionProcess(this._process); + + final Process _process; + + @override + int get pid => _process.pid; + + @override + bool kill() => _process.kill(); +} + +/// Default [SessionProcessStarter]: `dart:io` `Process.start` (detached — +/// the browser outlives the step; the step only records the pid). +Future startSessionProcess( + final String executable, + final List arguments, +) async { + final process = await Process.start(executable, arguments, mode: ProcessStartMode.detached); + return _IoSessionProcess(process); +} + +// -- Target ------------------------------------------------------------------ + +/// The `chrome-session` target (ADR-0017 §3): ensure a Chrome browser +/// session is running, idempotently, and provide the session-handle +/// artifacts (ADR-0017 §2 convention). +class ChromeSessionTarget extends Target { + /// Creates the target. [spec] is required (binaryPath has no safe + /// default — provisioning is deferred, ADR-0017 S1). + const ChromeSessionTarget({required this.spec, this.sessionName = 'main'}); + + /// The session description (the "what" seam, ADR-0017 §1). + final BrowserSessionSpec spec; + + /// Session instance name, namespacing the artifact ids: + /// `session-chrome--handle`. Distinct names allow + /// concurrent distinct sessions without artifact-id collisions. + /// + /// Named `sessionName` rather than `name` because [Target.name] is + /// already the CLI identifier (`chrome-session`). + final String sessionName; + + /// Target name: `chrome-session`. + @override + String get name => 'chrome-session'; + + /// Explain-text: idempotency posture, probe mechanism, profile + /// persistence, and the provided session-handle artifact. + @override + String get description => + 'Ensure a Chrome browser session is running (idempotent CDP reuse, ' + 'readiness via /json/version probe, ' + '${spec.profilePersistence.label} profile) — provides ' + '${chromeSessionHandleArtifactId(sessionName)} ' + '(ADR-0017 session-handle convention)'; + + /// Compile to the ensure step (single step; stop is a separate target). + @override + List compile(final BuildContext ctx) => [ + EnsureChromeSessionStep(spec: spec, sessionName: sessionName), + ]; + + /// Debug string: session name plus browser binary basename. + @override + String toString() => + 'ChromeSessionTarget($sessionName, ${p.basename(spec.binaryPath)})'; +} + +// -- Steps ------------------------------------------------------------------- + +/// Ensures a Chrome session is answering CDP: reuse if the port already +/// answers (idempotent, EmulatorTarget semantics), else spawn + readiness +/// probe. Provides the ADR-0017 §2 session-handle artifacts. +/// +/// Injectable seams ([CdpProbe], [SessionProcessStarter], ephemeral-port +/// assigner) default to real implementations; lifecycle tests substitute +/// scripted fakes exactly like EmulatorTarget's injected `runProcess`. +class EnsureChromeSessionStep extends BuildStep { + /// Creates the step. [probe], [startProcess], and [assignPort] default + /// to the real implementations; tests inject fakes. + EnsureChromeSessionStep({ + required this.spec, + this.sessionName = 'main', + this.pollInterval = const Duration(milliseconds: 200), + CdpProbe? probe, + SessionProcessStarter? startProcess, + Future Function()? assignPort, + bool Function(int pid)? killProcess, + }) : _probe = probe ?? httpCdpProbe, + _startProcess = startProcess ?? startSessionProcess, + _assignPort = assignPort ?? assignEphemeralPort, + _killProcess = killProcess ?? Process.killPid; + + /// The session spec (validated fail-closed at run start, ADR-0017 §3). + final BrowserSessionSpec spec; + + /// Session instance name — namespaces the artifact ids. + final String sessionName; + + /// Poll cadence for the readiness probe (tests shrink this). + final Duration pollInterval; + + final CdpProbe _probe; + final SessionProcessStarter _startProcess; + final Future Function() _assignPort; + final bool Function(int pid) _killProcess; + + /// `session-chrome--handle` — the CDP base URL ([String]). + late final Artifact handleArtifact = + Artifact(chromeSessionHandleArtifactId(sessionName)); + + /// `session-chrome--cdp-port` — the CDP port ([int]). + late final Artifact portArtifact = + Artifact(chromeSessionCdpPortArtifactId(sessionName)); + + /// `session-chrome--pid` — spawned browser pid (spawn path only). + late final Artifact pidArtifact = + Artifact(chromeSessionPidArtifactId(sessionName)); + + /// `session-chrome--profile-dir` — the ephemeral profile dir oka + /// created (ephemeral spawn path only; never set for reused or + /// persistent sessions so teardown can only ever delete oka-owned dirs). + late final Artifact profileDirArtifact = + Artifact(chromeSessionProfileDirArtifactId(sessionName)); + + /// Step name: `ensure-chrome-session`. + @override + String get name => 'ensure-chrome-session'; + + @override + Set> get provides => {handleArtifact, portArtifact}; + + @override + Future run( + final BuildContext ctx, + final PipelineState state, + ) async { + // Fail-closed spec validation (ADR-0017 §3): a mis-typed spec fails + // with the accepted surface named before any process is touched. + final issues = chromeSessionIssues(spec); + if (issues.isNotEmpty) { + return StepResult.failure( + 'Chrome session "$sessionName" spec is invalid (fail-closed, ' + 'ADR-0017 §3):\n' + '${issues.map((final i) => ' - $i').join('\n')}', + ); + } + + final port = spec.debugPort ?? await _assignPort(); + final baseUrl = 'http://127.0.0.1:$port'; + final probeUrl = Uri.parse('$baseUrl/json/version'); + + // Idempotent reuse: a port that already answers CDP is reused, never + // spawned against (EmulatorTarget reuse semantics, ADR-0017 §1). + final existing = await _probe(probeUrl); + final existingBrowser = existing == null ? null : parseVersionJson(existing); + if (existingBrowser != null) { + print( + '✅ Chrome session "$sessionName" already answering CDP ' + '($existingBrowser) — reusing $baseUrl.', + ); + state[handleArtifact.id] = baseUrl; + state[portArtifact.id] = port; + return StepResult.success({ + handleArtifact.id: baseUrl, + 'reused': 'true', + 'browser': existingBrowser, + }); + } + + // Profile dir: ephemeral = fresh system-temp dir (test/agent posture, + // deleted by StopChromeSessionStep); persistent = stable dir under the + // build dir (dev posture, survives the command — ADR-0017 §5). + final bool ephemeralDir; + final String profileDir; + if (spec.profilePersistence == ProfilePersistence.ephemeral) { + ephemeralDir = true; + profileDir = (await Directory.systemTemp + .createTemp('oka-chrome-$sessionName-')) + .path; + } else { + ephemeralDir = false; + profileDir = p.join(ctx.buildDir, 'chrome-profiles', sessionName); + await Directory(profileDir).create(recursive: true); + } + + final args = chromeLaunchArgs( + debugPort: port, + profileDir: profileDir, + flags: spec.launchFlags, + headless: spec.headless, + windowSize: spec.windowSize, + ); + + print( + '🌐 Starting Chrome session "$sessionName" ' + '(${p.basename(spec.binaryPath)}, CDP port $port, ' + '${spec.profilePersistence.label} profile)…', + ); + + final SessionProcess process; + try { + process = await _startProcess(spec.binaryPath, args); + } on Exception catch (e) { + return StepResult.failure( + 'Failed to start "${spec.binaryPath}": $e\n' + 'Remedies: confirm binaryPath points at a Chromium binary (browser ' + 'provisioning is deferred, ADR-0017 out-of-scope/S1); run ' + '"${spec.binaryPath} ${args.take(2).join(' ')}" manually to see ' + 'startup errors.', + ); + } + // The browser process runs for the session's lifetime — never awaited + // (same posture as the emulator boot step); only the pid is recorded. + + // Readiness probe: poll /json/version until it answers within the + // boot timeout. Plain HTTP only — no CDP client (ADR-0017). + final deadline = DateTime.now().add(spec.bootTimeout); + while (DateTime.now().isBefore(deadline)) { + final body = await _probe(probeUrl); + final browser = body == null ? null : parseVersionJson(body); + if (browser != null) { + state[handleArtifact.id] = baseUrl; + state[portArtifact.id] = port; + state[pidArtifact.id] = process.pid; + if (ephemeralDir) state[profileDirArtifact.id] = profileDir; + print( + '✅ Chrome session "$sessionName" ready ($browser) — ' + 'CDP at $baseUrl.', + ); + return StepResult.success({ + handleArtifact.id: baseUrl, + 'browser': browser, + }); + } + await Future.delayed(pollInterval); + } + + // Timed out: never leave a half-booted browser behind. + _killProcess(process.pid); + return StepResult.failure( + 'Chrome session "$sessionName" did not answer CDP at ' + '$baseUrl/json/version within ${spec.bootTimeout.inSeconds}s.\n' + 'Remedies: (1) confirm binaryPath "${spec.binaryPath}" is a Chromium ' + 'binary that starts (try it manually with the same ' + '--remote-debugging-port); (2) if another process holds port $port, ' + 'stop it or set debugPort explicitly; (3) try headless: false to ' + 'surface startup dialogs or crashes.', + ); + } +} + +/// Stops a Chrome session started by [EnsureChromeSessionStep] and deletes +/// its ephemeral profile dir (best effort) — compose into teardown targets, +/// the StopEmulatorStep precedent. +/// +/// Scoped by what oka recorded: only the pid oka spawned is killed, and +/// only an ephemeral dir oka created is deleted — a reused (pre-existing) +/// session is left running and untouched, matching the reuse contract. +class StopChromeSessionStep extends BuildStep { + /// Creates the step. [pid] overrides the recorded pid (explicit + /// composition without the ensure step upstream); [killProcess] + /// defaults to `Process.killPid` (tests inject a recording fake). + StopChromeSessionStep({ + this.sessionName = 'main', + this.pid, + bool Function(int pid)? killProcess, + }) : _killProcess = killProcess ?? Process.killPid; + + /// Session instance name — must match the ensure step's name. + final String sessionName; + + /// Explicit pid override; null → the recorded `…-pid` artifact. + final int? pid; + + final bool Function(int pid) _killProcess; + + /// Step name: `stop-chrome-session`. + @override + String get name => 'stop-chrome-session'; + + @override + Future run( + final BuildContext ctx, + final PipelineState state, + ) async { + final pidValue = pid ?? state[chromeSessionPidArtifactId(sessionName)]; + final profileDir = + state[chromeSessionProfileDirArtifactId(sessionName)] as String?; + + if (pidValue is! int && (profileDir == null || profileDir.isEmpty)) { + return StepResult.failure( + 'No chrome session "$sessionName" recorded to stop — run the ' + 'chrome-session target first (or declare StopChromeSessionStep ' + '(pid: ...) explicitly). A reused session records no pid and is ' + 'deliberately not stopped.', + ); + } + + if (pidValue is int) { + final killed = _killProcess(pidValue); + print( + killed + ? '🛑 Chrome session "$sessionName" stopped (pid $pidValue).' + : '⚠️ Chrome session "$sessionName" pid $pidValue was not ' + 'running (already stopped?).', + ); + } + if (profileDir != null && profileDir.isNotEmpty) { + try { + Directory(profileDir).deleteSync(recursive: true); + } on FileSystemException catch (e) { + // Best effort: the OS clears system temp eventually; a locked dir + // must not fail an otherwise-successful teardown. + print('⚠️ Could not delete ephemeral profile dir "$profileDir": ' + '${e.message}'); + } + } + return StepResult.success(); + } +} diff --git a/packages/oka_web/lib/src/session/profiles.dart b/packages/oka_web/lib/src/session/profiles.dart new file mode 100644 index 0000000..7899838 --- /dev/null +++ b/packages/oka_web/lib/src/session/profiles.dart @@ -0,0 +1,59 @@ +/// Named browser session profiles (ADR-0017 §4): first-party const spec +/// contributions — the same contribution law as ADR-0016 §3 (store shell +/// contributions): intentcall (or any package) may ship its own +/// `BrowserSessionSpec`s; oka ships the ones its own testing needs. +library; + +import 'browser_session_spec.dart'; + +/// The two Chromium flags that expose WebMCP `modelContext` (pre-stable +/// feature), sourced verbatim from the flutter_mcp_toolkit `webmcp` +/// command (`kWebmcpChromeBrowserFlags`). +/// +/// flutter_mcp_toolkit / intentcall is the **source of truth** for this +/// surface: when the feature ships stable or the flags change, the toolkit +/// changes first and this const follows. Manual fallback for a browser +/// launched by hand: `chrome://flags/#enable-webmcp-testing`. +/// +/// Boundary (ADR-0017 §3, ADR-0016): oka owns the *configuration value* +/// only — the WebMCP protocol, `modelContext` negotiation, and any CDP +/// client logic live in the toolkit, never in oka. +const List chromeWebMcpFlags = [ + '--enable-features=WebModelContext', + '--enable-experimental-web-platform-features', +]; + +/// A [BrowserSessionSpec] carrying exactly the [chromeWebMcpFlags] — the +/// pre-stable WebMCP posture for Chrome sessions (agent / E2E testing). +/// +/// ```dart +/// ChromeSessionTarget( +/// spec: chromeWebMcp(binaryPath: '/usr/bin/google-chrome'), +/// ) +/// ``` +/// +/// Dartdoc contract: flags sourced from the flutter_mcp_toolkit `webmcp` +/// command (pre-stable `WebModelContext`; manual fallback +/// `chrome://flags/#enable-webmcp-testing`). WebMCP protocol logic lives in +/// the toolkit, not oka (ADR-0017 §3). +/// +/// [binaryPath] is required (no provisioning default — ADR-0017 S1); +/// everything else keeps [BrowserSessionSpec] defaults (headless, ephemeral +/// profile, auto-assigned CDP port) unless overridden. +BrowserSessionSpec chromeWebMcp({ + required final String binaryPath, + final int? debugPort, + final bool headless = true, + final ProfilePersistence profilePersistence = ProfilePersistence.ephemeral, + final Duration bootTimeout = const Duration(seconds: 30), + final ({int width, int height})? windowSize, +}) => + BrowserSessionSpec( + binaryPath: binaryPath, + launchFlags: chromeWebMcpFlags, + debugPort: debugPort, + headless: headless, + profilePersistence: profilePersistence, + bootTimeout: bootTimeout, + windowSize: windowSize, + ); diff --git a/packages/oka_web/lib/src/spec/body_entry.dart b/packages/oka_web/lib/src/spec/body_entry.dart new file mode 100644 index 0000000..83ff631 --- /dev/null +++ b/packages/oka_web/lib/src/spec/body_entry.dart @@ -0,0 +1,67 @@ +/// Typed, immutable body-entry values (ADR-0016). +/// +/// Body entries describe raw containers/elements the shell injects into +/// `` — loading containers, noscript blocks. No JS execution logic +/// lives here: a body entry is markup, never behavior. +library; + +import 'package:meta/meta.dart'; + +/// One declarative `` element. +@immutable +sealed class WebBodyEntry { + /// Const constructor for const entries. + const WebBodyEntry(); +} + +/// A raw HTML snippet, rendered verbatim into ``. +/// +/// Use for elements oka has no typed variant for (e.g. a store's +/// `