This is the abridged developer documentation for TerraDart # Documentation > Guides for TerraDart — type-safe infrastructure-as-code for Dart, from the Stack to the app. TerraDart lets you write your infrastructure and your app in one typed Dart codebase. A `Stack` synthesizes standard Terraform JSON for Google Cloud, AWS, Cloudflare and Appwrite, and the values your app needs reach it as a generated Dart file instead of copied strings. Guides track the **0.35.x** line on pub.dev. Every Dart snippet on this site compiles against it in CI. ## Start [Section titled “Start”](#start) Pick the guide for where you start. Each is one path you can paste, from `dart pub global activate terradart_cli` to an apply, and none asks you to install Terraform. * [Start from an existing Flutter app](/docs/start/flutter-app/) — add `infra/` beside the app, apply it, and build the app with the outputs, typed, through `--dart-define-from-file` * [Start from an empty directory](/docs/start/new-project/) — `terradart init`, then `validate`, `plan` and `apply`, with a default environment and the state moved to a bucket * [Migrate an existing Terraform project](/docs/start/migrate-terraform/) — `terradart migrate`, a plan that reports *No changes*, then Dart one resource at a time * [Let an AI agent do it](/docs/start/ai-agent/) — the TerraDart Agent Skill, and the plan you approve * [Getting started](/docs/getting-started/) — the pieces built by hand: a Stack for your provider, synth, apply, and the values your app reads * [Why TerraDart](/docs/why-terradart/) — the problem, the design, and how it compares with HCL, CDKTF and Pulumi ## Guides [Section titled “Guides”](#guides) * [Writing arguments](/docs/arguments/) — which form an argument takes: a literal, a `ref`, an enum member, a variant, a variable, a secret * [Environments](/docs/environments/) — dev, staging and prod as a Dart enum: each with its own project and state, and its own define file for the client * [Outputs in client apps](/docs/client-outputs/) — build a Flutter, web or CLI client with apply-time values, typed, from the define file `terradart apply` writes * [Migrating from HCL](/docs/migrate-from-hcl/) — `terradart migrate` brings an existing Terraform tree over with a plan that reports *No changes* ## Providers [Section titled “Providers”](#providers) * [Google Cloud](/docs/providers/google/) — `terradart_google` and `terradart_google_beta` * [AWS](/docs/providers/aws/) — `terradart_aws`: Lambda, ECS Express Mode, Flutter Web on S3 + CloudFront * [Cloudflare](/docs/providers/cloudflare/) — `terradart_cloudflare`: the DNS and edge in front of your app * [Appwrite](/docs/providers/appwrite/) — `terradart_appwrite`: the backend of a Flutter app [`terradart_time`](https://pub.dev/packages/terradart_time) adds `TimeSleep`, the propagation wait any Stack can use, and everything builds on [`terradart_core`](https://pub.dev/packages/terradart_core). ## Reference [Section titled “Reference”](#reference) * [The terradart command](/docs/cli/) — `terradart synth`, `validate`, `plan`, `apply`, `destroy`, `outputs` and `migrate`, environments declared in Dart, and managed OpenTofu * [Coverage](/docs/coverage/) — every factory of every provider package, its barrel, and the examples that use it * [API reference](https://pub.dev/packages?q=terradart) — the dartdoc of every package on pub.dev * [llms.txt](/llms.txt) — these docs, condensed for LLMs ## Under the hood [Section titled “Under the hood”](#under-the-hood) * [How TerraDart works](/docs/how-it-works/) — the loop from Stack to `*.tf.json` to OpenTofu or Terraform, synth issues, and the constants and outputs that cross into your app * [How TerraDart is built](/docs/how-its-built/) — the generation pipeline and verification harness behind the factories; the maintainer CLI is [`terradart_codegen`](https://pub.dev/packages/terradart_codegen) (`terradart-codegen wrap`) ## Project [Section titled “Project”](#project) * [Upgrading](/docs/upgrading/) — breaking changes in the current minor; read before every minor bump * [Status & versioning](/docs/status/) — alpha, path to beta, 1.0 * [README](https://github.com/nozomi-koborinai/terradart/blob/main/README.md), [examples](https://github.com/nozomi-koborinai/terradart/tree/main/examples) and [cookbook](https://github.com/nozomi-koborinai/terradart/tree/main/cookbook) on GitHub # Getting started > Create a TerraDart project with terradart init, then see each piece by hand — the Stack, the synthesized *.tf.json, and the typed file your app imports. This guide matches the [README quickstart](https://github.com/nozomi-koborinai/terradart#quickstart) for the **0.35.x** line. TerraDart is **alpha** — breaking changes land only on **minor** bumps; see [Status & versioning](/docs/status/) for the change policy and the [path to beta](/docs/status/#path-to-beta). ## Choose your path [Section titled “Choose your path”](#choose-your-path) Each of these is one path you can paste, from installing the `terradart` command to an apply. The rest of this page builds the same pieces by hand, one at a time. [Start from an existing Flutter app ](/docs/start/flutter-app/)infra/ beside the app, and the outputs typed in the app through dart-define. [Start from an empty directory ](/docs/start/new-project/)terradart init, validate, plan and apply. [Migrate an existing Terraform project ](/docs/start/migrate-terraform/)terradart migrate and a plan that reports No changes. [Let an AI agent do it ](/docs/start/ai-agent/)The TerraDart Agent Skill, and the plan you approve. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * Dart SDK ≥ 3.10 * Nothing else to install for plan and apply: the [`terradart` command](/docs/cli/) downloads a checksum-verified OpenTofu on first use, or runs the `tofu` or `terraform` already on your `PATH`. * Appwrite only: [Terraform](https://developer.hashicorp.com/terraform/install) on your `PATH`. The `appwrite/appwrite` provider is published to the Terraform registry only, so OpenTofu cannot install it, and an Appwrite Stack runs on Terraform (`--engine terraform`, or `terradart: engine: terraform` in `pubspec.yaml`). * Credentials for your provider at apply time — Google Cloud Application Default Credentials (`gcloud auth application-default login`), the AWS SDK credential chain, `CLOUDFLARE_API_TOKEN`, or `APPWRITE_API_KEY`. Synth needs none, and none are written into the output. ## 1. Create a project [Section titled “1. Create a project”](#1-create-a-project) Install the [`terradart` command](/docs/cli/) once, and let it write the project: ```bash dart pub global activate terradart_cli terradart init --provider google --env dev,prd --gcp-project dev=acme-dev,prd=acme-prd --backend local cd infra terradart plan --env dev ``` `terradart init` writes `infra/` (or the directory you name), runs `dart pub get` in it, and prints what to run next. The project plans as it is: | File | Holds | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `pubspec.yaml` | `terradart_core` and the provider packages, at the command’s own version; `terradart: engine: terraform` with Appwrite | | `lib/env.dart` | the `Env` enum: each environment and its values — project or account ID, region, state bucket | | `lib/stack.dart` | a Stack with the provider, the backend, one resource and one output | | `bin/infra.dart` | the entry point, which hands the environments to `runEnvironments` | | `.gitignore` | `tf-out/` and `.terradart/` | | `README.md`, `AGENTS.md` | the next steps, and the commands and Agent Skill a coding agent works with | A value you did not give is a placeholder marked `TODO` in `lib/env.dart`. In a terminal, `terradart init` asks for what the flags leave out — the providers, the environment names, each environment’s project, region or account ID, whether a state bucket exists — and ends by printing the `terradart init ...` command that gives the same answers as flags. Asked whether a bucket for the state already exists, *no* keeps the state in a local file (move it later with `terradart state migrate`); *yes* takes its name and the backend of your provider. Without a terminal it never asks and never chooses for you: `--provider`, `--env` and `--backend` (or `--state-bucket`) are required, and a run without them prints the command to edit; `--defaults` accepts `dev,prd` and local state. `--dry-run` lists the files without writing them, and an existing file is never overwritten without `--force`. | Flag | Takes | | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `--provider` | `google`, `aws`, `cloudflare`, `appwrite`, comma-separated | | `--env` | any lowerCamelCase names, comma-separated | | `--gcp-project`, `--aws-region`, `--aws-account`, `--cloudflare-account`, `--appwrite-endpoint`, `--appwrite-project` | `=` pairs, or one value for every environment | | `--state-bucket` | a bucket that already exists, one name or `=` pairs: GCS for google, S3 for aws, R2 for cloudflare | | `--backend` | `local` (a file under `tf-out/`), `gcs`, `s3` or `r2` | | `--defaults` | `--env dev,prd --backend local` | | `--[no-]flutter` | wiring to the Flutter app in the current directory | Run inside a Flutter app, it also generates the reader the app imports into the app’s `lib/generated/`, and declares the [define file](/docs/client-outputs/) the app builds with: ```bash terradart init cd infra && terradart apply --env dev && cd .. flutter run --dart-define-from-file=infra/.terradart/dart_defines.dev.json ``` A directory that already holds `*.tf` files, at any depth, gets no scaffold: `terradart init` names where it found them and points at [`terradart migrate`](/docs/migrate-from-hcl/) instead, and in a terminal offers to run its report. The rest of this guide builds the same pieces by hand, one at a time. ## 2. Add dependencies [Section titled “2. Add dependencies”](#2-add-dependencies) `terradart_core` plus the package for each provider your Stack uses (all on [pub.dev](https://pub.dev/packages/terradart_core)). Every provider barrel re-exports `terradart_core`, so the Stack itself imports only the barrels: * Google Cloud pubspec.yaml ```yaml name: my_app environment: sdk: ^3.10.0 dependencies: terradart_core: ^0.35.0 terradart_google: ^0.35.0 # terradart_google_beta: ^0.35.0 # beta-only types (Firebase, ...) ``` * AWS pubspec.yaml ```yaml name: my_app environment: sdk: ^3.10.0 dependencies: terradart_core: ^0.35.0 terradart_aws: ^0.35.0 ``` * Cloudflare pubspec.yaml ```yaml name: my_app environment: sdk: ^3.10.0 dependencies: terradart_core: ^0.35.0 terradart_cloudflare: ^0.35.0 ``` * Appwrite pubspec.yaml ```yaml name: my_app environment: sdk: ^3.10.0 dependencies: terradart_core: ^0.35.0 terradart_appwrite: ^0.35.0 terradart: engine: terraform # Appwrite currently needs Terraform on PATH ``` ```bash dart pub get dart pub global activate terradart_cli ``` The second line installs the [`terradart` command](/docs/cli/), which synthesizes the Stack and runs `plan` or `apply` for you. To pin it per project instead, `dart pub add --dev terradart_cli` and run it as `dart run terradart_cli:terradart`. Each provider package wraps the whole catalog of its Terraform provider (Google’s beta-only types live in [`terradart_google_beta`](https://pub.dev/packages/terradart_google_beta)), so any resource the provider documents has a factory. One Stack can mix packages. ## 2. Define a Stack [Section titled “2. Define a Stack”](#2-define-a-stack) A Stack is one Terraform root module. `add` registers a resource; `addConstant` and `addOutput` declare what your app reads back — a constant at synth time, an output after apply — and `appExports` names the Dart file they are generated into: * Google Cloud lib/orders\_stack.dart ```dart import 'package:terradart_google/provider.dart'; import 'package:terradart_google/pubsub.dart'; final class OrdersStack extends Stack { OrdersStack({required String projectId}) : super( providers: [GoogleProvider(project: projectId)], appExports: AppExports('lib/generated/orders_stack.app.dart'), ) { final topic = add(GooglePubsubTopic( 'orders', name: .literal('orders-prod'), )); addConstant('ordersTopicName', .ref(topic.name)); addOutput('orders_topic_id', topic.id); } } ``` * AWS lib/queue\_stack.dart ```dart import 'package:terradart_aws/provider.dart'; import 'package:terradart_aws/sqs.dart'; final class QueueStack extends Stack { QueueStack() : super( providers: [const AwsProvider(region: 'us-east-1')], appExports: AppExports('lib/generated/queue_stack.app.dart'), ) { final queue = add(AwsSqsQueue( 'orders', name: .literal('orders-prod'), )); addConstant('ordersQueueName', .ref(queue.name)); addOutput('orders_queue_url', queue.url); } } ``` More on AWS — Lambda, ECS Express Mode, S3 + CloudFront: [AWS](/docs/providers/aws/). * Cloudflare lib/edge\_stack.dart ```dart import 'package:terradart_cloudflare/dns.dart'; import 'package:terradart_cloudflare/provider.dart'; import 'package:terradart_cloudflare/zone.dart'; final class EdgeStack extends Stack { EdgeStack({required String accountId}) : super( providers: [const CloudflareProvider()], appExports: AppExports('lib/generated/edge_stack.app.dart'), ) { final zone = add(CloudflareZone( 'main', name: .literal('example.com'), account: ZoneAccount(id: .literal(accountId)), )); final api = add(CloudflareDnsRecord( 'api', zoneId: zone.ref, // only a CloudflareZone fits here name: .literal('api.example.com'), type: .cname, ttl: .literal(1), content: .content(.literal('ghs.googlehosted.com')), proxied: .literal(true), )); addConstant('apiHost', .ref(api.name)); addOutput('zone_id', zone.id); } } ``` Runnable: [`examples/cloudflare_dns_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/cloudflare_dns_quickstart). * Appwrite lib/uploads\_stack.dart ```dart import 'package:terradart_appwrite/provider.dart'; import 'package:terradart_appwrite/storage.dart'; final class UploadsStack extends Stack { UploadsStack() : super( providers: [ const AppwriteProvider( endpoint: 'https://cloud.appwrite.io/v1', projectId: 'my-project', ), ], appExports: AppExports('lib/generated/uploads_stack.app.dart'), ) { final uploads = add(AppwriteStorageBucket( 'uploads', name: .literal('uploads'), fileSecurity: .literal(true), )); addOutput('uploads_bucket_id', uploads.id); } } ``` Runnable: [`examples/appwrite_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/appwrite_quickstart), which covers every Appwrite factory. `.literal(...)` is a value known now; `...` is one Terraform resolves during apply. An argument that names another resource takes that resource’s `ref` (`zoneId: zone.ref`) and does not compile with the wrong kind. Both are Dart 3.10 dot shorthands, like the enum in `.cname` — every argument is typed, so the type name can be left out. A block inside another block or inside a sealed choice is built the same way, with `.new(...)`: `delivery: .pushConfig(.new(pushEndpoint: ...))`. Keep the class name on a resource’s own arguments (`template: CloudRunV2ServiceTemplate(...)`), where it tells a reader which block starts. ### Writing arguments [Section titled “Writing arguments”](#writing-arguments) Pick the form by where the value comes from. The argument’s type tells you which forms it takes, and a dot shorthand (`.literal`, `.new`, `.providedAl2023`) names the constructor of that type. | The value is | Write | Example | | --------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------ | | known when you synth | `.literal(...)` | `functionName: .literal('hello')` | | another resource of this Stack (a `RefTo` input) | its `ref` | `role: role.ref` | | one attribute of another block | its getter | `assumeRolePolicy: trust.json` | | a resource outside this Stack (a `RefTo` input) | `.literal(id)` | `zoneId: .literal('023e105f4ecef8ad9ca31a8372d0c353')` | | one of a fixed set (an enum) | the member | `runtime: .providedAl2023` | | one of several exclusive arguments (a sealed type) | the variant | `code: .filename(.literal('build/fn.zip'))` | | a nested block | its helper class; `.new(...)` inside another block or a variant | `environment: LambdaFunctionEnvironment(...)` | | a Terraform variable | the handle `variable()` returns | `memorySize: memory` | | a variable in an enum or `RefTo` input | `.arg(handle)` | `runtime: .arg(runtimeName)` | | a secret (a `Sensitive` input) | a sensitive variable, never a literal | `value: .value(dbPassword)` | | a reference inside a literal list or map | the getter’s `.interpolation` | `{'ROLE_ARN': role.arn.interpolation}` | | anything else Terraform evaluates | `.expression(...)` | `.expression(r'${file("trust.json")}')` | ```dart final memory = variable('memory_mb'); final runtimeName = variable('runtime'); final dbPassword = variable('db_password', sensitive: true); final trust = add( DataAwsIamPolicyDocument( 'trust', statement: [ DataIamPolicyDocumentStatement( actions: .literal(['sts:AssumeRole']), principals: [ .new( type: .literal('Service'), identifiers: .literal(['lambda.amazonaws.com']), ), ], ), ], ), ); final role = add(AwsIamRole('hello', assumeRolePolicy: trust.json)); add( AwsLambdaFunction( 'hello', functionName: .literal('hello'), role: role.ref, runtime: .arg(runtimeName), code: .filename(.literal('build/fn.zip')), memorySize: memory, environment: LambdaFunctionEnvironment( variables: .literal({'ROLE_ARN': role.arn.interpolation}), ), ), ); add( AwsSsmParameter( 'db_password', name: .literal('/hello/db_password'), type: .securestring, value: .value(dbPassword), ), ); ``` More on each row: [Writing arguments](/docs/arguments/). ## 3. Synth Terraform JSON [Section titled “3. Synth Terraform JSON”](#3-synth-terraform-json) Synthesis is plain Dart: `bin/infra.dart`, the project’s entry point, constructs the Stack and hands it to `runStack`, which writes it. The rest of this guide follows the Google Cloud Stack; for the others, construct `QueueStack()`, `EdgeStack(accountId: ...)` or `UploadsStack()` the same way. bin/infra.dart ```dart import 'package:my_app/orders_stack.dart'; import 'package:terradart_core/terradart_core.dart'; Future main(List args) => runStack(args, () => OrdersStack(projectId: 'YOUR-PROJECT-ID')); ``` ```bash terradart synth ``` This runs `bin/infra.dart`, which writes `tf-out/main.tf.json` (with the `orders_topic_id` output) and `lib/generated/orders_stack.app.dart` (with the `ordersTopicName` constant and the `OrdersStackOutputs` reader). `dart run bin/infra.dart` does the same without the command. ## 4. Plan and apply [Section titled “4. Plan and apply”](#4-plan-and-apply) ```bash terradart plan terradart apply ``` Each command synthesizes first, then runs `init` and `plan` or `apply` in `tf-out/` with the `tofu` or `terraform` on your `PATH`, or a checksum-verified OpenTofu it downloads when there is neither. A Stack that uses Appwrite runs on the `terraform` on your `PATH`, and stops before `init` when there is none or `--engine tofu` asks for OpenTofu. For several environments, declare them as a Dart enum and hand it to `runEnvironments` in `bin/infra.dart`; `terradart apply --env prod` then runs one of them. See [Environments](/docs/environments/) and [The terradart command](/docs/cli/). On a new Google Cloud project every API is off, so the first apply fails with a 403 until the Stack turns on the ones it uses with `enableApis([.pubsub])` — see [Enabling APIs](/docs/providers/google/#enabling-apis). The project `terradart init` writes does it already. The output is ordinary Terraform JSON, so your existing remote state backend and modules stay unchanged — TerraDart only replaces HCL/JSON authoring. ## 5. Read it from your app [Section titled “5. Read it from your app”](#5-read-it-from-your-app) App code imports the generated file instead of repeating strings: lib/subscriber.dart ```dart import 'dart:io'; import 'generated/orders_stack.app.dart'; bool acceptsTopic(String eventTopic) => eventTopic == OrdersStackConstants.ordersTopicName; /// Known only after apply, so it comes from the generated outputs reader. String ordersTopicId() => OrdersStackOutputs.fromEnvironment(Platform.environment).ordersTopicId; ``` A deployed service reads outputs from its environment (`ORDERS_TOPIC_ID`) — the Stack passes them with `outputEnvironment()` — and a script can read the define file `terradart outputs` writes. A Flutter or web client is built with them instead. Add `addDartDefineOutput()` to the Stack, and `terradart apply` writes the define file the build compiles in: ```bash terradart apply flutter build web --dart-define-from-file=.terradart/dart_defines.json ``` With [environments](/docs/environments/), each one gets its own file: `terradart apply --env prod` writes `.terradart/dart_defines.prod.json`. The client reads each value with its type through the same `OrdersStackOutputs` reader; see [Outputs in client apps](/docs/client-outputs/). Rename `orders-prod` in the Stack and the subscriber follows on the next synth — there is no second copy of the string to update. Rename or remove the constant and `dart analyze` fails. See [How TerraDart works — the app boundary](/docs/how-it-works/#the-app-boundary-constants-and-outputs) and the runnable [pubsub quickstart](https://github.com/nozomi-koborinai/terradart/tree/main/examples/pubsub_quickstart) (`lib/subscriber_stub.dart`). ## Next steps [Section titled “Next steps”](#next-steps) * [Why TerraDart](/docs/why-terradart/) — motivation and comparisons * [Outputs in client apps](/docs/client-outputs/) — build a Flutter or web client with the values of an apply * [Environments](/docs/environments/) — dev, staging and prod as a Dart enum, one define file each * [How TerraDart works](/docs/how-it-works/) — synth, plan and apply, and the values your app reads back * [Coverage](/docs/coverage/) — every factory of every provider package, its barrel, and runnable examples * Providers: [Google Cloud](/docs/providers/google/) (with google-beta and Firebase), [AWS](/docs/providers/aws/), [Cloudflare](/docs/providers/cloudflare/), [Appwrite](/docs/providers/appwrite/) * [Migrating from HCL](/docs/migrate-from-hcl/) — bring an existing Terraform tree over * [Upgrading](/docs/upgrading/) — read before every minor bump * [Status & versioning](/docs/status/) — alpha vs beta vs 1.0 * [Examples](https://github.com/nozomi-koborinai/terradart/tree/main/examples) and [cookbook](https://github.com/nozomi-koborinai/terradart/tree/main/cookbook) for fuller stacks # Let an AI agent do it > Have a coding agent write and check TerraDart for you — the TerraDart Agent Skill, terradart init, validate and plan, and the apply you approve. A coding agent can do the whole loop: create the project, write the Stack, check it, and show you the plan. TerraDart’s factories are generated Dart in the provider packages, so the agent reads the exact constructor instead of guessing a name, and `terradart validate` checks its work without credentials. You decide the providers, the environments and where the state lives, and you approve every apply. ## 1. Give the agent the skill [Section titled “1. Give the agent the skill”](#1-give-the-agent-the-skill) The **TerraDart Agent Skill** tells the agent where to look and which commands to run. The `terradart` command bundles the skill of its own release, so the agent learns that CLI and not whatever is on `main`. In a project that already has a `pubspec.yaml`: ```bash terradart skill install ``` That writes `.agents/skills/terradart/SKILL.md` (Cursor, Codex, Gemini CLI, GitHub Copilot and most others) and `.claude/skills/terradart/SKILL.md` (Claude Code). Commit both. After upgrading the CLI, `terradart skill update` rewrites them, and leaves a copy you edited alone unless `--force`. A new project gets the same files from `terradart init --agent-skill`. Without the `terradart` command, use the [`skills` CLI](https://github.com/vercel-labs/skills), pinned to the release tag so the skill matches the release you depend on rather than `main`: ```bash npx skills add nozomi-koborinai/terradart#v0.35.0 --skill terradart ``` The `skills` CLI asks which agents you use and writes the skill where each looks for skills. It is the same file `terradart skill install` writes. The skill is [`skills/terradart/SKILL.md`](https://github.com/nozomi-koborinai/terradart/blob/main/skills/terradart/SKILL.md) in the repository, in the [Agent Skills](https://agentskills.io) format; copying it into one of those directories works too. ## 2. Start a project [Section titled “2. Start a project”](#2-start-a-project) Decide three things before the agent runs anything: the providers, the environment names, and whether the state goes in a bucket. `terradart init` never picks them for an agent — without a terminal it refuses to run until the flags name all three — and the skill tells the agent to ask you. A prompt that answers them up front: ```text Create a TerraDart project in my_app with terradart init: Google Cloud, environments dev and prd (projects my-app-dev and my-app-prd), local state, and the agent skill. Then run terradart validate --env dev. ``` The agent runs, with your answers as flags: ```bash terradart init my_app --provider google --env dev,prd --gcp-project dev=my-app-dev,prd=my-app-prd --backend local --agent-skill cd my_app terradart validate --env dev ``` The project comes with an `AGENTS.md`: the commands, where the environments and resources live, and the rules — never edit `tf-out/` or `.terradart/`, never install or call `terraform` or `tofu` directly, and plan before any apply a human has approved. Inside a Flutter app, the same `terradart init` writes `infra/` and wires it to the app ([Start from an existing Flutter app](/docs/start/flutter-app/)). ## 3. Ask for changes [Section titled “3. Ask for changes”](#3-ask-for-changes) Describe the infrastructure, and ask for the check and the plan in the same prompt: ```text Add a Cloud Run service "api" running us-docker.pkg.dev/cloudrun/container/hello in lib/stack.dart, with an output api_url, and enable the APIs it needs. Run dart analyze, terradart validate --env dev and terradart plan --env dev, and show me the plan. ``` The skill gives the agent the loop: | Step | Command | Needs | | --------------------------- | ------------------------------ | ------------------------------------------------- | | The Dart compiles | `dart analyze` | nothing | | The Stack synthesizes | `terradart synth` | nothing | | The configuration is valid | `terradart validate --env dev` | nothing; the first run downloads OpenTofu | | What the apply would change | `terradart plan --env dev` | credentials for the provider in the agent’s shell | An agent without credentials — a cloud agent, a CI job — still gets through `validate`. Have it name the environment with `--env`: there is no terminal for it to answer a question in, and an apply against an environment the command chose by default stops rather than guess. ## 4. Apply [Section titled “4. Apply”](#4-apply) Read the plan, then apply it yourself: ```bash terradart apply --env dev ``` In a terminal of your own, the apply shows the plan again and asks before it changes anything. In an agent’s shell `terradart` never asks: an apply without `--auto-approve` stops before `init` with exit code 3 and prints the command with the flag added. The skill and the generated `AGENTS.md` both tell the agent to run `apply` and `destroy` only when you ask, so an agent should pass `--auto-approve` only when you said so. After the apply, the define file for a Flutter or web client is in `.terradart/dart_defines.dev.json` ([Outputs in client apps](/docs/client-outputs/)). ## Migrate existing Terraform [Section titled “Migrate existing Terraform”](#migrate-existing-terraform) The migrator does the mechanical translation; the agent finishes what it kept in Terraform: ```text Migrate infra/ to TerraDart: run terradart migrate --report --dir infra, then terradart migrate --dir infra --out infra_dart. Port the kept blocks listed in infra_dart/MIGRATION.md into the Stack one at a time, keeping each local name, and run terradart plan after each: it must report No changes. Do not apply. ``` The skill tells the agent not to rewrite a tree by hand, to keep every resource address, to leave a block in the sidecar when no factory covers it, and to fix the Dart — never the plan — when a plan is not empty. The steps it follows are [Migrate an existing Terraform project](/docs/start/migrate-terraform/). ## What the skill tells the agent [Section titled “What the skill tells the agent”](#what-the-skill-tells-the-agent) * **Which package** owns a Terraform type: `google_*`, beta-only `google_*`, `aws_*`, `cloudflare_*`, `appwrite_*`. * **How to find the class and its barrel.** Each package’s generated `lib/src/_catalog.g.dart` maps every Terraform type to its class and barrel, and the class’s doc comment lists its arguments. * **How to write the arguments**: a literal, another resource’s `ref`, an enum member, a variant, a secret — the rules of [Writing arguments](/docs/arguments/). * **Where the runnable examples are**: more than 100 quickstarts under [`examples/`](https://github.com/nozomi-koborinai/terradart/tree/main/examples), each synthesized and validated in CI. * **How to check and deploy**: `dart analyze`, `terradart synth`, `terradart validate`, `terradart plan`, environments declared in Dart with `runEnvironments`, and the define file a client builds with. It never asks you to install or run Terraform. * **How to migrate**: `terradart migrate` first, then the leftovers one block at a time, until `terradart plan` reports *No changes*. ## Without a checkout [Section titled “Without a checkout”](#without-a-checkout) * [`/llms.txt`](/llms.txt) is these docs, condensed for LLMs, with these guides and the command reference first. * [Coverage](/docs/coverage/) lists every factory of every provider package with its barrel and the examples that use it. # Start from an existing Flutter app > Add an infra/ package to a Flutter app with terradart init, apply it with the terradart command, and build the app with the outputs, typed, through dart-define. Your Flutter app needs values that exist only after an apply: a topic ID, an API URL, a bucket. Here they reach the app typed, through a reader class TerraDart generates and the `--dart-define-from-file` flag Flutter already has. Dart and Flutter are all you install; the `terradart` command downloads a checksum-verified OpenTofu the first time it needs an engine. The steps use Google Cloud, with two projects: one for `dev`, one for `prd`. ## The layout [Section titled “The layout”](#the-layout) The infrastructure is a Dart package of its own, `infra/`, inside the app: ```text my_app/ ├── pubspec.yaml the Flutter app ├── lib/ │ ├── main.dart │ └── generated/infra.g.dart written by terradart synth: the typed reader └── infra/ ├── pubspec.yaml terradart_core and the provider packages ├── lib/env.dart dev and prd, and their values ├── lib/stack.dart the Stack ├── bin/infra.dart the entry point the terradart command runs └── .terradart/ dart_defines..json, written by terradart apply ``` It is a package of its own because it runs on the Dart VM (`dart run`, through the `terradart` command), where the app needs the Flutter SDK, and so the TerraDart packages never enter the app’s dependencies or its binary. The two meet at one generated file: the Stack writes `lib/generated/infra.g.dart` into the app, and the app imports it. ## 1. Install the command [Section titled “1. Install the command”](#1-install-the-command) ```bash dart pub global activate terradart_cli terradart --help ``` `terradart` lands in `~/.pub-cache/bin`. If the second line is not found, add that directory to your `PATH`, as `dart pub global activate` says. ## 2. Create `infra/` [Section titled “2. Create infra/”](#2-create-infra) From the app’s directory, the one with its `pubspec.yaml`: ```bash terradart init --provider google --env dev,prd --gcp-project dev=my-app-dev,prd=my-app-prd --backend local ``` Use your own project IDs. `terradart init` sees the Flutter app (a `pubspec.yaml` that depends on `flutter`) and wires `infra/` to it: ```text Created my_app_infra in infra (google; environments dev, prd; local state; wired to the Flutter app): pubspec.yaml lib/env.dart lib/stack.dart bin/infra.dart .gitignore README.md AGENTS.md Defaults: --flutter. Synth writes lib/generated/infra.g.dart for the app to read the Stack's outputs. > dart pub get ... Next: cd infra terradart apply --env dev cd .. flutter run --dart-define-from-file=infra/.terradart/dart_defines.dev.json ``` In a terminal, `terradart init` without flags asks for each of them instead, and asks before it wires the app. `--no-flutter` leaves the app alone. Every flag is in [The terradart command](/docs/cli/#creating-a-project). ## 3. Read the Stack [Section titled “3. Read the Stack”](#3-read-the-stack) infra/lib/env.dart ```dart /// The environments of the Stack: `terradart plan --env ` /// picks one. Replace each placeholder marked TODO. enum Env { dev(projectId: 'my-app-dev', region: 'us-central1'), prd(projectId: 'my-app-prd', region: 'us-central1'); const Env({required this.projectId, required this.region}); /// The Google Cloud project the Stack deploys to. final String projectId; /// The default Google Cloud region. final String region; } ``` Two lines connect the Stack to the app. `appExports` writes the reader into the app’s `lib/generated/`, and `addDartDefineOutput()` declares the define file — every non-sensitive output, as the strings `--dart-define-from-file` reads: infra/lib/stack.dart ```dart import 'package:terradart_google/project.dart'; import 'package:terradart_google/provider.dart'; import 'package:terradart_google/pubsub.dart'; import 'package:terradart_time/terradart_time.dart'; import 'env.dart'; /// The infrastructure of one environment. Replace the example resources /// with your own. final class MyAppInfraStack extends Stack { MyAppInfraStack({required Env env}) : super( providers: [ GoogleProvider(project: env.projectId, region: env.region), const TimeProvider(), ], backend: const LocalBackend(), appExports: AppExports('../lib/generated/infra.g.dart'), ) { // Enables the Pub/Sub API on the project and waits for it to propagate. final apis = enableApis([.pubsub]); final topic = add( GooglePubsubTopic( 'events', name: .literal('${env.name}-events'), dependsOn: apis, ), ); addOutput('events_topic_id', topic.id); // The outputs above, as the file `terradart apply --env ` writes // for `flutter run --dart-define-from-file`. addDartDefineOutput(); } } ``` Each `addOutput` becomes a typed getter of `MyAppInfraStackOutputs`; the `events_topic_id` output is `eventsTopicId`. Add the resources your app uses — a Cloud Run service, a bucket, Firebase — and an output for each value the app reads. Every resource of the provider has a factory, listed in [Coverage](/docs/coverage/google/). `infra/bin/infra.dart` gives `runEnvironments` the first environment as `defaultEnv`, so a command without `--env` runs against `dev` — see [Start from an empty directory](/docs/start/new-project/#4-the-default-environment). The commands below name the environment anyway, as a build script should. ## 4. Synth and validate [Section titled “4. Synth and validate”](#4-synth-and-validate) ```bash cd infra terradart synth terradart validate --env dev ``` `terradart synth` writes `tf-out/dev/` and `tf-out/prd/`, and the reader, `../lib/generated/infra.g.dart`. `terradart validate` checks the configuration without credentials or state; the first time, it downloads OpenTofu into your user cache: ```text env: dev (--env) Using OpenTofu 1.13.1 (no tofu or terraform on PATH; managed OpenTofu 1.13.1) > tofu init -backend=false -input=false (in tf-out/dev) ... > tofu validate (in tf-out/dev) Success! The configuration is valid. ``` ## 5. Plan and apply [Section titled “5. Plan and apply”](#5-plan-and-apply) Sign in as an account that can create resources in the project, then plan and apply `dev`: ```bash gcloud auth application-default login terradart plan --env dev terradart apply --env dev ``` The apply shows the plan and asks before it changes anything. Then it writes the outputs to the define file and prints the lines that build with it: ```text Wrote 1 dart-defines to .terradart/dart_defines.dev.json flutter run --dart-define-from-file=.terradart/dart_defines.dev.json flutter build --dart-define-from-file=.terradart/dart_defines.dev.json ``` Those paths are relative to `infra/`; from the app, the file is `infra/.terradart/dart_defines.dev.json`. ## 6. Read the outputs in the app [Section titled “6. Read the outputs in the app”](#6-read-the-outputs-in-the-app) The reader’s `fromDartDefine` constructor reads the values compiled into the app, so it is a constant: lib/main.dart ```dart import 'package:flutter/material.dart'; import 'generated/infra.g.dart'; const infra = MyAppInfraStackOutputs.fromDartDefine(); void main() => runApp( MaterialApp( home: Scaffold(body: Center(child: Text('Events: ${infra.eventsTopicId}'))), ), ); ``` Run it with the define file of `dev`: ```bash cd .. flutter run --dart-define-from-file=infra/.terradart/dart_defines.dev.json ``` A build without the file fails at the first getter it reads, with a `StateError` that names the variable and the output. Rename the output in the Stack and the getter changes on the next synth, so `flutter analyze` finds every place the app read it. ## 7. Production, and builds in CI [Section titled “7. Production, and builds in CI”](#7-production-and-builds-in-ci) Apply `prd` the same way, and build the release with its file: ```bash cd infra terradart apply --env prd cd .. flutter build web --dart-define-from-file=infra/.terradart/dart_defines.prd.json ``` A build job should read the applied state, not change it. `terradart outputs` synthesizes — which also writes the reader — then writes the define file from the state alone, with no plan and no apply: ```bash cd infra terradart outputs --env prd cd .. flutter build apk --dart-define-from-file=infra/.terradart/dart_defines.prd.json ``` For that job to see the state, it has to live in a bucket rather than in `infra/tf-out/` on your machine: create one, set the Stack’s `backend:` to `GcsBackend(bucket: 'my-app-tfstate', prefix: 'my_app_infra/${env.name}')`, and run `terradart state migrate --env dev` and `terradart state migrate --env prd` once, from `infra/`. Each asks before it copies the state. Only public values belong in a client: anything compiled into an app can be read by whoever has it. The define file leaves sensitive outputs out, and secrets stay on a server — see [Outputs in client apps](/docs/client-outputs/#secrets-stay-out). ## Next [Section titled “Next”](#next) * [Outputs in client apps](/docs/client-outputs/) — one define file per client, the `dart` command, servers and scripts * [Environments](/docs/environments/) — each environment’s own project, state and define file * [Appwrite](/docs/providers/appwrite/) and [Google Cloud with Firebase](/docs/providers/google/) — backends for a Flutter app * [Let an AI agent do it](/docs/start/ai-agent/) — the `AGENTS.md` `infra/` came with, and the Agent Skill # Migrate an existing Terraform project > Turn a Terraform tree into a TerraDart package with terradart migrate, prove it with a plan that reports No changes, and move to Dart one resource at a time. Bring an existing Terraform tree into Dart without a rewrite and without touching your infrastructure. `terradart migrate` translates what it can into typed Dart, keeps the rest in Terraform exactly as written, and keeps every resource address, so the first plan against your existing state reports *No changes*. From there you move the remaining blocks one at a time, with a plan after each. You need the Dart SDK, 3.10 or later, and the access to the state you have today. The migration reads files only: it never runs an engine, never reads or writes state, and never writes into your tree. ## 1. Install the command [Section titled “1. Install the command”](#1-install-the-command) ```bash dart pub global activate terradart_cli terradart --help ``` `terradart` lands in `~/.pub-cache/bin`. If the second line is not found, add that directory to your `PATH`, as `dart pub global activate` says. The migration needs no Dart project: run it from anywhere. ## 2. Size it [Section titled “2. Size it”](#2-size-it) `--report` runs the whole migration in memory and writes nothing: ```bash terradart migrate --report --dir infra ``` ```text terradart migrate 0.x.y --report: infra 2 of 2 resource and data blocks translate (100%); 2 of 2 types have a curated factory. Nothing was written. Types (2): google_pubsub_subscription [resource] x1: 1 translate -> GooglePubsubSubscription (terradart_google/pubsub) google_pubsub_topic [resource] x1: 1 translate -> GooglePubsubTopic (terradart_google/pubsub) By directory: . (root): 2 translate, 0 kept Next: terradart migrate --dir infra --out ``` Each type shows how many of its blocks become Dart and how many stay in Terraform, with the reason for each kept block — a non-literal `count`, a sensitive literal, a type with no factory yet. ## 3. Migrate [Section titled “3. Migrate”](#3-migrate) ```bash terradart migrate --dir infra --out infra_dart ``` ```text terradart migrate 0.x.y: infra → infra_dart (infra) modules: 1 (1 root, 0 children); migrated 3 blocks, kept 0 .: InfraStack — 3 migrated, 0 kept → tf-out Report: infra_dart/MIGRATION.md Next: cd infra_dart && dart pub get && terradart synth then terradart plan ``` `infra_dart/` is a Dart package: one Stack per module directory in `lib/`, `bin/infra.dart`, a `tf-out/` tree that mirrors your source, and beside each `main.tf.json` the **leftover sidecar** — the blocks that stay in Terraform, verbatim. `MIGRATION.md` lists every module and every kept block with its reason. Environments in sibling directories (`envs/dev`, `envs/prod`) can become one Stack and a Dart enum with `--merge-envs`. That and every other flag: [Migrating from HCL](/docs/migrate-from-hcl/). ## 4. Keep the engine your state came from [Section titled “4. Keep the engine your state came from”](#4-keep-the-engine-your-state-came-from) Your state was written by Terraform, so the generated `pubspec.yaml` keeps the `terradart` command on Terraform: ```yaml terradart: engine: terraform ``` `terradart plan` and `apply` then run the `terraform` you already use, on the state it wrote. The section says `engine: tofu` instead when the tree was run with OpenTofu. To leave Terraform behind, change it to `engine: tofu`: `terradart` downloads a checksum-verified OpenTofu when none is on your `PATH`, and OpenTofu rewrites the state for itself at the next apply — after which Terraform may not read it back, so decide this for the whole team. Without the section, `terradart` reads the state before it runs; when the other engine wrote it, it asks in a terminal, and without one stops: ```text terradart: Stopped before running OpenTofu on a state Terraform wrote. Pass --engine terraform to keep Terraform (or set terradart.engine: terraform in pubspec.yaml), or --engine tofu to move the state to OpenTofu. ``` More in [The terradart command](/docs/cli/#the-engine). ## 5. Validate [Section titled “5. Validate”](#5-validate) ```bash cd infra_dart dart pub get terradart validate ``` `terradart validate` synthesizes, then checks the configuration — the Dart and the sidecar together — without credentials, a backend or state. ## 6. Plan: No changes [Section titled “6. Plan: No changes”](#6-plan-no-changes) The Stack carries your backend (`GcsBackend`, `S3Backend`, `LocalBackend`; any other stays in the sidecar’s `backend.tf` as written), so the plan reads the same remote state as before. With a **local** state file, copy it next to `main.tf.json` first — the migrator never copies state: ```bash cp ../infra/terraform.tfstate tf-out/ terradart plan ``` The plan must report *No changes. Your infrastructure matches the configuration.* Nothing is applied by a plan; a diff means a block was translated differently from how it was written. Compare that resource in `tf-out/main.tf.json` with your original, and [open an issue](https://github.com/nozomi-koborinai/terradart/issues/new/choose) with both. A tree of several roots plans one at a time: `terradart plan --env dev` plans `tf-out/envs/dev`. With `--merge-envs`, `--env` names a member of the generated `Env` enum — and giving its `runEnvironments` a `defaultEnv`, or setting `TERRADART_ENV` for your shell, lets `--env` be left out ([Environments](/docs/environments/#run-one-environment)). Where `MIGRATION.md` lists `terraform init && terraform plan` as a next step, `terradart plan` runs both for you. ## 7. Move the rest into Dart [Section titled “7. Move the rest into Dart”](#7-move-the-rest-into-dart) For each block `MIGRATION.md` lists as kept, once its reason no longer holds: 1. look its factory up in [Coverage](/docs/coverage/); 2. add it to the Stack with the **same local name**, so its address does not change; 3. delete the block from the sidecar, and its `addExternalBlock('
')` line from the Stack when there is one; 4. run `terradart plan` and expect *No changes* again. A block whose type has no factory stays in the sidecar; the Stack and the sidecar plan and apply together. [What stays in Terraform](/docs/migrate-from-hcl/#what-stays-in-terraform) explains each reason. ## 8. Apply your next change [Section titled “8. Apply your next change”](#8-apply-your-next-change) From here, a change to the infrastructure is a change to the Dart: ```bash terradart plan terradart apply ``` The apply shows the plan and asks before it changes anything. A Stack with outputs can hand them to a Flutter or web client through a define file: add `addDartDefineOutput()`, and `terradart apply` writes it — see [Outputs in client apps](/docs/client-outputs/). ## 9. Move the state, when you want to [Section titled “9. Move the state, when you want to”](#9-move-the-state-when-you-want-to) A migration keeps your backend as it is. To move the state somewhere else later — a local file into a bucket, one bucket to another — change the Stack’s `backend:`, then: ```bash terradart state migrate ``` It synthesizes, names the backend the state moves from and to, asks, and runs the engine’s `init -migrate-state`. With several environments, pass `--env ` for each; without a terminal, `--auto-approve`. ## Next [Section titled “Next”](#next) * [Migrating from HCL](/docs/migrate-from-hcl/) — every flag, the sidecar, modules, `count` and `for_each`, `--merge-envs` * [Let an AI agent do it](/docs/start/ai-agent/#migrate-existing-terraform) — hand step 7 to a coding agent * [Environments](/docs/environments/) — `dev` and `prod` as a Dart enum after `--merge-envs` * [Writing arguments](/docs/arguments/) — the forms an argument takes, for the blocks you port by hand # Start from an empty directory > Create a TerraDart project with terradart init, then validate, plan and apply it with the terradart command — Dart is the only thing to install. From an empty directory to applied infrastructure, with nothing to install but the Dart SDK. The `terradart` command writes the project, checks it, and runs the plan and the apply; it downloads a checksum-verified OpenTofu the first time it needs an engine. You need: * the Dart SDK, 3.10 or later; * credentials for your provider when you plan and apply — not before. The steps use Google Cloud and two projects, one per environment; [other providers](#other-providers) take a different flag at step 2. ## 1. Install the command [Section titled “1. Install the command”](#1-install-the-command) ```bash dart pub global activate terradart_cli terradart --help ``` `terradart` lands in `~/.pub-cache/bin`. If the second line is not found, add that directory to your `PATH`, as `dart pub global activate` says. ## 2. Create the project [Section titled “2. Create the project”](#2-create-the-project) ```bash terradart init my_app --provider google --env dev,prd --gcp-project dev=my-app-dev,prd=my-app-prd --backend local cd my_app ``` Use your own project IDs. `terradart init` writes `my_app/`, runs `dart pub get` in it, and prints what to run next: ```text Created my_app in my_app (google; environments dev, prd; local state): pubspec.yaml lib/env.dart lib/stack.dart bin/infra.dart .gitignore README.md AGENTS.md > dart pub get ... Next: cd my_app terradart plan --env dev ``` Run in a terminal without the flags, `terradart init my_app` asks for each of them — the providers, the environment names, each environment’s project, whether a bucket for the state already exists — and ends with the `terradart init ...` line that gives the same answers. Without a terminal it never asks and never chooses for you: `--provider`, `--env` and `--backend` (or `--state-bucket`) are required. An ID you leave out becomes a placeholder marked `TODO` in `lib/env.dart`. Every flag is in [The terradart command](/docs/cli/#creating-a-project). | File | Holds | | ------------------------ | ----------------------------------------------------------------------- | | `lib/env.dart` | the `Env` enum: each environment and its values | | `lib/stack.dart` | the Stack: the provider, the state backend, one resource and one output | | `bin/infra.dart` | the entry point the `terradart` command runs | | `README.md`, `AGENTS.md` | the next steps, and the rules a coding agent follows here | | `.gitignore` | `tf-out/` and `.terradart/`, which the command writes | ## 3. Read the Stack [Section titled “3. Read the Stack”](#3-read-the-stack) The environments are a Dart enum, and each member carries its values: lib/env.dart ```dart /// The environments of the Stack: `terradart plan --env ` /// picks one. Replace each placeholder marked TODO. enum Env { dev(projectId: 'my-app-dev', region: 'us-central1'), prd(projectId: 'my-app-prd', region: 'us-central1'); const Env({required this.projectId, required this.region}); /// The Google Cloud project the Stack deploys to. final String projectId; /// The default Google Cloud region. final String region; } ``` The Stack is the infrastructure of one environment. It enables the API its resource needs first — a new Google Cloud project has every API off, and the apply would fail with a 403 without `enableApis` — then adds a Pub/Sub topic and an output: lib/stack.dart ```dart import 'package:terradart_google/project.dart'; import 'package:terradart_google/provider.dart'; import 'package:terradart_google/pubsub.dart'; import 'package:terradart_time/terradart_time.dart'; import 'env.dart'; /// The infrastructure of one environment. Replace the example resources /// with your own. final class MyAppStack extends Stack { MyAppStack({required Env env}) : super( providers: [ GoogleProvider(project: env.projectId, region: env.region), const TimeProvider(), ], backend: const LocalBackend(), ) { // Enables the Pub/Sub API on the project and waits for it to propagate. final apis = enableApis([.pubsub]); final topic = add( GooglePubsubTopic( 'events', name: .literal('${env.name}-events'), dependsOn: apis, ), ); addOutput('events_topic_id', topic.id); } } ``` Replace the topic with your own resources as you go: every resource of the provider has a factory, listed in [Coverage](/docs/coverage/google/), and [Writing arguments](/docs/arguments/) says which form each argument takes. ## 4. The default environment [Section titled “4. The default environment”](#4-the-default-environment) `bin/infra.dart` hands the environments to `runEnvironments`, with the first one as `defaultEnv`, so that a command without `--env` runs against `dev`: bin/infra.dart ```dart import 'package:my_app/env.dart'; import 'package:my_app/stack.dart'; import 'package:terradart_core/terradart_core.dart'; /// Writes `tf-out//main.tf.json` for each environment, or the one /// `--env` names. `terradart plan`, `apply` and `destroy` run it first. /// `dev` is the environment those commands use when neither /// `--env` nor `TERRADART_ENV` names one. Future main(List args) => runEnvironments( args, Env.values, (env) => MyAppStack(env: env), defaultEnv: Env.dev, ); ``` A command now runs against the first of: `--env `, the `TERRADART_ENV` environment variable, `defaultEnv`, or the only environment when there is one. It prints which one and why, `env: dev (default)`. Delete the `defaultEnv` line to make every command name its environment. More in [Environments](/docs/environments/#run-one-environment). ## 5. Synth [Section titled “5. Synth”](#5-synth) ```bash terradart synth ``` ```text > dart run bin/infra.dart synthesized tf-out/dev/main.tf.json (dev) synthesized tf-out/prd/main.tf.json (prd) ``` Synth is plain Dart: it needs no credentials and no engine. A Stack that would not make valid Terraform — a reference to a resource it never added, a secret written as a literal — fails here, with every issue at once. ## 6. Validate [Section titled “6. Validate”](#6-validate) ```bash terradart validate ``` ```text env: dev (default) Downloading OpenTofu 1.13.1 (linux_amd64) from https://github.com/opentofu/opentofu/releases/download/... Installed OpenTofu 1.13.1 at ~/.cache/terradart/opentofu/1.13.1/linux_amd64/tofu Using OpenTofu 1.13.1 (no tofu or terraform on PATH; managed OpenTofu 1.13.1) > tofu init -backend=false -input=false (in tf-out/dev) ... > tofu validate (in tf-out/dev) Success! The configuration is valid. ``` `terradart validate` installs the providers and checks the configuration without credentials, a backend or state, so it is also the check for CI. The first command that needs an engine downloads OpenTofu once and keeps it in your user cache; a `tofu` or `terraform` already on your `PATH` is used instead. ## 7. Plan [Section titled “7. Plan”](#7-plan) Sign in to Google Cloud as an account that can create resources in the project, then plan: ```bash gcloud auth application-default login terradart plan ``` `terradart plan` synthesizes, runs `init` and `plan` in `tf-out/dev`, and shows what the apply will create: the Pub/Sub API, a wait for it to propagate, and the `dev-events` topic. ## 8. Apply [Section titled “8. Apply”](#8-apply) ```bash terradart apply ``` `defaultEnv` chose the environment, so the command first asks which one you mean: ```text env: dev (default) Apply environment "dev" (default)? Only "yes" is accepted: ``` Then the engine shows the plan and asks again. After the apply, the command writes the outputs to `.terradart/dart_defines.dev.json` for an app to build with ([step 10](#10-hand-the-outputs-to-an-app)). `--env prd` (or `TERRADART_ENV=prd`) applies the other environment. In CI, name the environment with `--env` and pass `--auto-approve`: without a terminal to answer the question, an apply stops before it changes anything (exit code 3). ```bash terradart apply --env prd ``` ## 9. Move the state to a bucket [Section titled “9. Move the state to a bucket”](#9-move-the-state-to-a-bucket) The state is a local file, `tf-out//terraform.tfstate`, kept out of git. Before anyone else applies, keep it in a bucket: create one, set the Stack’s `backend:` to `GcsBackend(bucket: 'my-app-tfstate', prefix: 'my_app/${env.name}')`, and move each environment’s state once: ```bash terradart state migrate --env dev terradart state migrate --env prd ``` Each run synthesizes, names the backend the state moves from and to, asks, and runs the engine’s `init -migrate-state` in that environment’s directory. Without a terminal, pass `--auto-approve`. ## 10. Hand the outputs to an app [Section titled “10. Hand the outputs to an app”](#10-hand-the-outputs-to-an-app) The Stack’s outputs reach your app typed, from a file the Stack generates: * a Flutter or web client builds with the define file `terradart apply` writes — [Start from an existing Flutter app](/docs/start/flutter-app/) walks through it; * a server reads them from its environment, which the Stack sets with `outputEnvironment()` — see [Outputs in client apps](/docs/client-outputs/#servers-and-scripts); * `terradart outputs --env prd` writes the define file from the applied state, for a build job that may read the state but never apply. ## Other providers [Section titled “Other providers”](#other-providers) Step 2 takes another `--provider` and its IDs; the steps after it are the same. | Provider | `terradart init` | Sign in | | ---------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | AWS | `terradart init my_app --provider aws --env dev,prd --aws-region us-east-1 --backend local` | the AWS SDK credential chain (`AWS_PROFILE`, or the access key variables) | | Cloudflare | `terradart init my_app --provider cloudflare --env dev,prd --cloudflare-account --backend local` | `CLOUDFLARE_API_TOKEN` | `--provider google,aws` puts both in one Stack. An Appwrite project needs one more step today: see [Appwrite](/docs/providers/appwrite/). ## Next [Section titled “Next”](#next) * [Environments](/docs/environments/) — each environment’s own project, state and define file * [The terradart command](/docs/cli/) — every command and flag * [Let an AI agent do it](/docs/start/ai-agent/) — the `AGENTS.md` this project came with, and the Agent Skill * [Getting started](/docs/getting-started/) — the same pieces built by hand, one at a time # The terradart command > Synthesize, plan and apply a Stack with one command, per environment, with OpenTofu or Terraform — downloaded and checksum-verified when neither is installed — and write the define file a Flutter client builds with. `terradart` runs the whole loop of a TerraDart project: it runs the entry point that synthesizes the Stack, then `init` and `plan` or `apply` in the directory it wrote, then writes the [define file](/docs/client-outputs/) the client builds with. You never install or call Terraform yourself: `terradart` downloads a pinned OpenTofu release, checks it against the release’s SHA-256, and keeps it in your user cache. A `tofu` or `terraform` already on your `PATH` is used instead. ## Install [Section titled “Install”](#install) Once per machine: ```bash dart pub global activate terradart_cli terradart init ``` To pin the version per project, so everyone on it runs the same one, add it as a dev dependency of the infrastructure package instead: ```bash dart pub add --dev terradart_cli dart run terradart_cli:terradart apply ``` ## Commands [Section titled “Commands”](#commands) | Command | Runs | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `terradart init [dir]` | writes a new project (default `infra/`) and runs `dart pub get` in it; no project is required | | `terradart synth` | the entry point (`dart run bin/infra.dart`); arguments after `--` go to it | | `terradart validate` | synth, `init -backend=false`, `validate`: checks the configuration without credentials, a backend or state — the step for CI; arguments after `--` go to `validate` (`-- -json`) | | `terradart plan` | synth, `init`, `plan`; arguments after `--` go to the engine (`-- -target=...`); `--detailed-exitcode` exits 2 when the plan has changes | | `terradart apply` | synth, `init`, `apply`, then writes the define file; `--auto-approve` skips the prompt; `--dry-run` stops after `plan` | | `terradart destroy` | synth, `init`, `destroy`; `--auto-approve` skips the prompt; `--dry-run` stops after `plan -destroy` | | `terradart outputs` | synth, `init`, then writes the define file from the applied state — no plan, no apply; `--dry-run` prints the file and its keys and writes nothing | | `terradart state migrate` | synth, then `init -migrate-state`: moves the state to the backend the Stack now configures, after asking (`--auto-approve` skips the question, and is required when it cannot ask: no terminal, `--no-input`, CI or an agent) | | `terradart engine` | prints the engine binary it would run, downloading OpenTofu if that is the one | | `terradart migrate` | turns a Terraform tree into a Dart package. No project is required — it does not look for a `pubspec.yaml` | | `terradart skill install` / `update` / `status` | writes, updates and checks the [agent skill](#the-agent-skill) this CLI bundles | `--no-synth` reuses what the last synth wrote, `--project ` (`-C`) runs against another package, and `--engine tofu|terraform` or `--engine-path ` picks the engine for one run. `--json` prints one result object on stdout, and every exit code means one thing: [JSON and exit codes](#json-and-exit-codes). Every command’s `--help` ends with examples — the simplest use first, the one an agent runs last — and the topics to read next. `terradart help ` prints a short guide in the terminal, and `terradart help --list` lists them: `environments`, `outputs`, `backends`, `engines`, `migrate`, `exit-codes`, `json` and `agents`. Each ends with the page here that covers it in full. `terradart help migrate` prints the command’s usage, then the topic under `Guide:`. With `--json` the guide goes to stderr, like `--help`’s usage. ## Creating a project [Section titled “Creating a project”](#creating-a-project) `terradart init` writes a project that plans as it is — `pubspec.yaml`, an `Env` enum in `lib/env.dart`, a Stack with one resource and one output in `lib/stack.dart`, `bin/infra.dart`, a `.gitignore`, a `README.md` and an `AGENTS.md`, and with `--agent-skill` the [agent skill](#the-agent-skill) — then runs `dart pub get` in it (`--no-pub-get` skips that): ```bash terradart init --provider google --env dev,prd --gcp-project dev=myapp-dev,prd=myapp-prd --state-bucket myapp-tfstate terradart init --provider aws --env dev,stg,prd --aws-region prd=eu-west-1 --aws-account prd=123456789012 --backend local terradart init --provider cloudflare --defaults --cloudflare-account 0123abcd --agent-skill terradart init --dry-run --provider appwrite --defaults # lists the files, writes nothing ``` | Flag | Takes | Default | | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `--provider`, `-p` | `google`, `aws`, `cloudflare`, `appwrite`, comma-separated or repeated | required without a terminal | | `--env`, `-e` | lowerCamelCase environment names, comma-separated or repeated | required without a terminal; `--defaults`: `dev,prd` | | `--gcp-project`, `--aws-region`, `--cloudflare-account`, `--appwrite-endpoint`, `--appwrite-project` | `=` pairs, or one value for every environment | a placeholder marked `TODO` | | `--aws-account` | the same; the provider then refuses credentials of another account | any account | | `--state-bucket` | the bucket that already holds the state, one name or `=` pairs; the backend follows the provider — google `gcs`, aws `s3`, cloudflare `r2` | | | `--backend` | `local`, `gcs`, `s3`, `r2` (Cloudflare R2, with `--provider cloudflare`) | required without a terminal unless `--state-bucket`; `--defaults`: `local` | | `--defaults` | take `--env dev,prd`, `--backend local`, placeholder IDs and the Flutter wiring for what the flags leave out; never the providers | | | `--[no-]flutter` | wiring to the Flutter app in the current directory | on when there is one | | `--[no-]agent-skill` | write the agent skill this CLI bundles into the project | asked in a terminal (yes); off without one or with `--defaults` | | `--agents` | where `--agent-skill` writes it: `agents`, `claude`, `cursor`, `windsurf`, `copilot`, `all`; implies `--agent-skill` | `agents,claude` | | `--dry-run` | list the files, write nothing | | | `--force` | overwrite existing files, scaffold next to existing Terraform | | In a terminal, every flag left out is a question: the providers, the environments, each environment’s IDs (a blank answer leaves a placeholder marked `TODO`), then *Do you already have a bucket for Terraform state?* — yes asks for its name, one for every environment or one each, and takes the kind from the provider (asking when the providers do not settle it); no keeps the state local — and last whether to install the agent skill. Without the skill, `AGENTS.md` says how to add it: `terradart skill install`, or the pinned `npx skills add` line. The run ends with `Re-run with: terradart init ...`, the same answers as flags. Without a terminal it never asks, and never picks the providers, the environments or the state backend for you: a run that leaves out any of `--provider`, `--env` and `--backend` (or `--state-bucket`) fails with exit 64, names every missing flag, and prints a command to edit. `--defaults` accepts `dev,prd` and local state; the IDs it leaves out become placeholders, and the defaults it took are printed. A bucket’s state sits under `/`, so one bucket serves every environment. Local state moves to a bucket later with `terradart state migrate`; `terradart init` only scaffolds. Inside a Flutter app (a `pubspec.yaml` that depends on `flutter` in the current directory), the Stack’s `appExports` writes the reader the app imports to the app’s `lib/generated/infra.g.dart`, the Stack declares `addDartDefineOutput()`, and the steps printed end with `flutter run --dart-define-from-file=infra/.terradart/dart_defines..json`. A working or target directory holding `*.tf` or `*.tf.json` files, at any depth, gets no scaffold: `terradart init` names the directories that hold them (`Found Terraform in envs/dev, modules/net.`), prints the `terradart migrate --report` and `terradart migrate` commands for it and exits 64 — in a terminal it offers to run the report first. `--force` scaffolds anyway. Picking `appwrite` writes `terradart: engine: terraform` into `pubspec.yaml`: an Appwrite project plans and applies with Terraform on your `PATH` ([The engine](#the-engine)). ## The entry point [Section titled “The entry point”](#the-entry-point) `bin/infra.dart` builds the Stack and hands it to `runStack`, which writes `tf-out/` and tells `terradart` what it wrote: lib/orders\_stack.dart ```dart import 'package:terradart_google/provider.dart'; final class OrdersStack extends Stack { OrdersStack({required String projectId}) : super(providers: [GoogleProvider(project: projectId)]) { addOutput('project', .literal(projectId)); } } ``` bin/single.dart ```dart import 'package:my_app/orders_stack.dart'; import 'package:terradart_core/terradart_core.dart'; Future main(List args) => runStack(args, () => OrdersStack(projectId: 'acme-dev')); ``` An entry point that writes `tf-out/` itself (`await OrdersStack(...).writeTo('tf-out')`) works too; `terradart` then looks for `tf-out/main.tf.json`, or the directory `terradart.out` names. ## Environments [Section titled “Environments”](#environments) With an entry point that calls `runEnvironments`, `validate`, `plan`, `apply`, `destroy` and `outputs` run against one member of the project’s environment enum: the one `--env ` (`-e`) names, else the `TERRADART_ENV` environment variable, else the `defaultEnv` the entry point gives `runEnvironments`, else the only member; with none of these the command asks on a terminal, and otherwise stops with the names and the command to run. When `TERRADART_ENV` or `defaultEnv` chose it, `apply` and `destroy` ask first on a terminal unless `--auto-approve` is set. `terradart synth` without `--env` writes every environment. Each environment gets its own Terraform directory (`tf-out/` unless `runEnvironments` says otherwise) and define file (`.terradart/dart_defines..json`), and a name that is not a member stops before anything runs. `--workspace` and `--backend-config` override and extend, for one run, the workspace and backend settings `runEnvironments` gives an environment. How to declare the enum, keep each environment’s state apart, and build each client with its define file: [Environments](/docs/environments/). ## Terminals, CI and agents [Section titled “Terminals, CI and agents”](#terminals-ci-and-agents) `terradart` asks a question only on a terminal (stdin and stdout both one), and never with `--no-input`, `TERRADART_NO_INPUT=1`, `CI` set, or in an AI agent’s shell (`AI_AGENT`, `CURSOR_AGENT`, `CLAUDECODE`, `GEMINI_CLI`, `CODEX_SANDBOX`, `CODEX_THREAD_ID`, `OPENCODE`). Where it would ask, it uses the flag instead or stops and names it: | Question on a terminal | Without one | | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | | Which environment, when `--env`, `TERRADART_ENV`, `defaultEnv` and a single member leave it open | exit code 64, the names, and `Next: terradart plan --env ` | | `apply` / `destroy` approval, and `Apply environment "dev" (default)?` | exit code 3 before `init` unless `--auto-approve` | | `state migrate` copying the state | exit code 3 unless `--auto-approve` | | running one engine on a state the other wrote | exit code 3; `--engine tofu\|terraform` decides | An error a flag would fix prints the flag’s choices and the command to run next: ```text terradart: --env is required: bin/infra.dart declares dev, stg, prd and no defaultEnv. Pass --env, set TERRADART_ENV, or give runEnvironments a defaultEnv. Choices: dev, stg, prd Next: terradart plan --env dev ``` The engine gets `-input=false` whenever nobody can answer. `--quiet` (`-q`) leaves out the values terradart picks by itself (`env: dev (default)`, `Using OpenTofu ...`); errors and warnings still print. ## JSON and exit codes [Section titled “JSON and exit codes”](#json-and-exit-codes) `--json` prints one JSON object on stdout when the command ends — and nothing else there: progress, `--help`, the entry point’s and the engine’s output go to stderr. It implies `--no-input`, and it goes before or after the command (`terradart --json plan`, `terradart plan --json`). `terradart migrate` is the exception: its `--json`, before or after `migrate`, is the migrator’s own report (`terradart migrate --report --json`). ```bash terradart plan --env dev --json --detailed-exitcode ``` ```json { "schemaVersion": 1, "command": "plan", "ok": true, "exitCode": 2, "env": {"name": "dev", "source": "flag"}, "engine": {"kind": "tofu", "version": "1.13.1", "source": "path", "path": "/usr/local/bin/tofu"}, "outDir": "tf-out/dev", "plan": {"add": 3, "change": 0, "destroy": 0, "replace": 0}, "notices": [], "next": ["terradart apply --env dev"] } ``` | Field | Holds | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `schemaVersion` | `1`; raised only for a change that breaks a reader | | `command`, `ok`, `exitCode` | the command path (`state migrate`), whether it succeeded, and the exit code | | `env` | the environment and why: `flag`, `variable` (`TERRADART_ENV`), `default`, `only`, `prompt` | | `engine` | `kind` (`tofu`, `terraform`), `version`, `path`, and why: `engine_path`, `setting` (`--engine` or `pubspec.yaml`), `state` (the engine that last applied it), `path`, `managed` (the OpenTofu download) | | `outDir` | the Terraform directory | | `plan` | `plan`: the resource changes of the saved plan as `show -json` lists them; a replacement counts once, as `replace` | | `defineFile`, `keys` | `apply`, `outputs`: the define file and its keys — never the values, which may be secrets | | `notices` | one-line warnings with a `code`: `skill_outdated` / `skill_newer` when the project’s agent skill is older or newer than this CLI | | `error` | on failure: `code` from the table, `message`, and when they apply `flag`, `choices`, `engineExitCode` | | `dryRun` | `true` when `--dry-run` stopped the command before it changed anything | | `next` | the commands to run next: the failed one with the flag it lacks, `terradart apply` after a plan with changes, or the command without `--dry-run` | | Exit code | `error.code` | Means | | --------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 0 | | success | | 1 | `internal` | anything else: a bug, a cancelled question, a malformed engine output | | 2 | | `plan --detailed-exitcode` found changes — not a failure, `ok` is `true` | | 3 | `input_required` | an answer nobody can give: `--auto-approve`, or `--engine` on a state the other engine wrote | | 4 | `skill_drift` | `skill status --check`: the agent skill is missing, older, newer or edited | | 5 | `local_edits` | `skill update` kept a skill with local edits (`--force` overwrites it) | | 10 | `synth_failed` | the entry point exited non-zero | | 11 | `engine_unavailable` | no engine: not on `PATH`, a missing `engine_path`, or the OpenTofu download or its SHA-256 check failed | | 12 | `engine_failed` | `init`, `plan`, `apply`, `validate`, `output` … failed; `error.engineExitCode` is the engine’s own code | | 64 | `usage` / `missing_flag` | a wrong flag or argument, or a required one is missing (`--env`, `--force`) | | 65 | `project_config` | the `terradart:` section of `pubspec.yaml` is wrong, an `--env` the entry point does not declare, a Terraform directory or dart-define output that is not there | | 66 | `no_project` | no `pubspec.yaml` in the directory or above it | ## Moving the state [Section titled “Moving the state”](#moving-the-state) When the Stack changes its backend — `LocalBackend()` to `GcsBackend(...)`, one bucket to another, or back to a local file — the state has to follow it once: ```bash terradart state migrate --env prod ``` It synthesizes, names the full source and target configuration (the bucket and prefix, not only `gcs` → `gcs`), asks, and runs the engine’s `init -migrate-state` in that environment’s directory, with its `backendConfig`. Without a terminal it stops unless `--auto-approve` is given. Run it before the next `plan` or `apply` of that environment: those reconfigure the directory onto the new backend without copying. When several environments share the directory and pass `backendConfig`, the copy is the state of the environment that last initialized it. If that was another environment, or TerraDart has no record, it stops and tells you to run `terradart plan --env ` first; `--auto-approve` does not skip that. ## The define file [Section titled “The define file”](#the-define-file) After `apply`, a Stack with [`addDartDefineOutput()`](/docs/client-outputs/) gets its define file written to `.terradart/dart_defines.json` — `.terradart/dart_defines..json` with `--env` — and `terradart` prints the line that builds with it: ```text Wrote 2 dart-defines to .terradart/dart_defines.stg.json flutter run --dart-define-from-file=.terradart/dart_defines.stg.json flutter build --dart-define-from-file=.terradart/dart_defines.stg.json ``` `terradart outputs --env stg` writes the same file from the applied state without planning or applying: the command for a client’s build job, which can read the state but not change it. `.terradart/` gets its own `.gitignore`, so the file never reaches git. A Stack with several define outputs takes `--define-output `; `--define-file ` writes elsewhere. When the app starts without the defines, the generated reader says how to get them: ```text Dart define API_URL (Terraform output "api_url") is not set. Build the app with --dart-define-from-file=.terradart/dart_defines.json, which `terradart apply` and `terradart outputs` write (with --env : .terradart/dart_defines..json). ``` ## The engine [Section titled “The engine”](#the-engine) `terradart` picks the first of: 1. `--engine-path`, `--engine`, or `engine_path` / `engine` in `pubspec.yaml`; 2. the engine that last applied this environment’s state, as recorded in `.terradart/engines.json`; 3. `tofu` on `PATH`; 4. `terraform` on `PATH`; 5. OpenTofu downloaded from its GitHub release. The download is the release pinned in `terradart_cli` (`opentofu_version` in `pubspec.yaml` picks another), for Linux, macOS and Windows on amd64 and arm64. Its archive is checked against the SHA-256 checksum `terradart_cli` ships for the pinned release, or against the release’s `SHA256SUMS` for another; a mismatch is deleted and stops the run. The binary is kept in `~/.cache/terradart` on Linux, `~/Library/Caches/terradart` on macOS and `%LOCALAPPDATA%\terradart` on Windows (`TERRADART_CACHE_DIR` moves it; `TERRADART_OPENTOFU_MIRROR` downloads from a mirror of the release layout). Appwrite currently needs Terraform. The `appwrite/appwrite` provider is published to the Terraform registry only, which OpenTofu cannot install from, so a Stack that requires it runs on `terraform` from `PATH` whatever steps 2–5 would pick. When there is none, or `--engine tofu`, `engine: tofu` or a `tofu` `engine_path` asks for OpenTofu, `plan`, `apply`, `destroy` and `outputs` stop before `init` with a message that says so. Install [Terraform](https://developer.hashicorp.com/terraform/install) and run with `--engine terraform`, or set `engine: terraform` in `pubspec.yaml`. State written by one engine is not always readable by another: Terraform 1.6 and later and OpenTofu have diverged. When the engine about to run differs from the one that last applied the state, or is older, `terradart` warns before it runs. Without that record — right after a migration, or in a fresh clone, since `.terradart/` is not committed — `plan`, `apply` and `destroy` read the state itself: the local state file before `init`, and a remote backend’s state (`state pull`) after it. Its provider addresses say which engine wrote it (`registry.terraform.io` or `registry.opentofu.org`), as does a `terraform_version` older than OpenTofu. When the other engine wrote it, `terradart` warns; and when it picked the engine by itself (step 3 to 5), it asks before running on a terminal, and without one stops with the flag that decides: ```text terradart: Stopped before running OpenTofu on a state Terraform wrote. Pass --engine terraform to keep Terraform (or set terradart.engine: terraform in pubspec.yaml), or --engine tofu to move the state to OpenTofu. ``` `terradart migrate` writes `engine: terraform` into the package it generates, so a migrated project keeps the engine its state came from. ## The agent skill [Section titled “The agent skill”](#the-agent-skill) `terradart_cli` bundles the [TerraDart Agent Skill](/docs/start/ai-agent/) of its own release, so a coding agent learns the CLI it runs and not the one on `main`. `terradart skill install` writes it to the project (the directory holding `pubspec.yaml`, or `--project `): ```text .agents/skills/terradart/SKILL.md Cursor, Codex, Gemini CLI, GitHub Copilot and most other agents .claude/skills/terradart/SKILL.md Claude Code, which does not read .agents/skills ``` `--agents` picks other directories, comma-separated: `agents`, `claude`, `cursor` (`.cursor/skills`), `windsurf` (`.windsurf/skills`), `copilot` (`.github/skills`), or `all`. The files are copies, not symlinks; commit them with the project. Each copy records, under `metadata:` in its front matter, the CLI version that wrote it and the SHA-256 of its content (leaving those two lines out): ```yaml metadata: terradart-version: "0.34.0" terradart-sha256: "" ``` | Command | Does | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `terradart skill status` | shows each copy: `current`, `marker-only` (same content, older version), `outdated`, `newer` (written by a newer CLI), `edited`, `missing`, or `foreign` (no marker) | | `terradart skill status --check` | exits 4 when a copy is missing, outdated, newer, edited or foreign — for CI | | `terradart skill update` | rewrites every installed copy with the bundled one. A copy that is edited, foreign or newer is left alone (exit 5) unless `--force` | | `terradart skill update --dry-run` | prints the diff and writes nothing (`install` takes it too) | When a copy in the project is older than the CLI, every other command prints one line on stderr (`TERRADART_NO_SKILL_NOTICE=1` turns it off): ```text terradart: the terradart agent skill in .agents/skills is 0.33.0; this CLI is 0.34.0. Run: terradart skill update ``` Without the CLI, the [`skills` CLI](https://github.com/vercel-labs/skills) installs the same file. Pin it to the release tag, since it fetches `main` otherwise: ```bash npx skills add nozomi-koborinai/terradart#v0.35.0 --skill terradart ``` It is byte for byte the file `terradart skill install` writes, so `terradart skill status` reads it too, and points at `npx skills update terradart -p -y` when `skills-lock.json` records it. ## `pubspec.yaml` [Section titled “pubspec.yaml”](#pubspecyaml) Every key is optional. ```yaml terradart: entrypoint: bin/infra.dart # the Dart file whose main synthesizes out: tf-out # where an entry point without runStack writes engine: tofu # or terraform engine_path: tools/tofu # a binary of your own opentofu_version: 1.13.1 # the release to download dart_defines: output: dart_defines # the addDartDefineOutput name file: ../app/dart_defines.json # instead of .terradart/.json ``` Environments are not configured here; they are the enum `runEnvironments` takes ([Environments](/docs/environments/)). # Writing arguments > How to pass a value to a TerraDart factory — a literal, another resource, an enum member, a variant, a nested block, a variable, a secret or a Terraform expression — on every provider. Every factory argument has one shape per kind of value, and the analyzer tells you which kind it wants. The rules are the same on every provider package: Google Cloud, AWS, Cloudflare and Appwrite. ## The rules [Section titled “The rules”](#the-rules) Pick the form by where the value comes from. The argument’s type tells you which forms it takes, and a dot shorthand (`.literal`, `.new`, `.providedAl2023`) names the constructor of that type. | The value is | Write | Example | | --------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------ | | known when you synth | `.literal(...)` | `functionName: .literal('hello')` | | another resource of this Stack (a `RefTo` input) | its `ref` | `role: role.ref` | | one attribute of another block | its getter | `assumeRolePolicy: trust.json` | | a resource outside this Stack (a `RefTo` input) | `.literal(id)` | `zoneId: .literal('023e105f4ecef8ad9ca31a8372d0c353')` | | one of a fixed set (an enum) | the member | `runtime: .providedAl2023` | | one of several exclusive arguments (a sealed type) | the variant | `code: .filename(.literal('build/fn.zip'))` | | a nested block | its helper class; `.new(...)` inside another block or a variant | `environment: LambdaFunctionEnvironment(...)` | | a Terraform variable | the handle `variable()` returns | `memorySize: memory` | | a variable in an enum or `RefTo` input | `.arg(handle)` | `runtime: .arg(runtimeName)` | | a secret (a `Sensitive` input) | a sensitive variable, never a literal | `value: .value(dbPassword)` | | a reference inside a literal list or map | the getter’s `.interpolation` | `{'ROLE_ARN': role.arn.interpolation}` | | anything else Terraform evaluates | `.expression(...)` | `.expression(r'${file("trust.json")}')` | ```dart final memory = variable('memory_mb'); final runtimeName = variable('runtime'); final dbPassword = variable('db_password', sensitive: true); final trust = add( DataAwsIamPolicyDocument( 'trust', statement: [ DataIamPolicyDocumentStatement( actions: .literal(['sts:AssumeRole']), principals: [ .new( type: .literal('Service'), identifiers: .literal(['lambda.amazonaws.com']), ), ], ), ], ), ); final role = add(AwsIamRole('hello', assumeRolePolicy: trust.json)); add( AwsLambdaFunction( 'hello', functionName: .literal('hello'), role: role.ref, runtime: .arg(runtimeName), code: .filename(.literal('build/fn.zip')), memorySize: memory, environment: LambdaFunctionEnvironment( variables: .literal({'ROLE_ARN': role.arn.interpolation}), ), ), ); add( AwsSsmParameter( 'db_password', name: .literal('/hello/db_password'), type: .securestring, value: .value(dbPassword), ), ); ``` The same table is in the [README](https://github.com/nozomi-koborinai/terradart#writing-arguments), on the dartdoc pages of `TfArg`, `RefTo` and every provider package, and in the [agent skill](/docs/start/ai-agent/). The sections below say more about each row. ## Literals [Section titled “Literals”](#literals) A value known when you synth is `.literal(...)`. Numbers, booleans, lists and maps work the same way: ```dart add( AppwriteWebhook( 'orders', name: .literal('orders'), url: .literal('https://example.com/hooks/orders'), events: .literal(['databases.*.tables.*.rows.*.create']), tls: .literal(true), ), ); ``` An argument can only be `null` when it is optional; leave it out instead of passing `.literal(null)`. ## References [Section titled “References”](#references) An input that names another resource is typed `RefTo`. Pass the resource’s `ref`, and the input writes the attribute the provider expects (`self_link`, `id`, `arn`, `name`), so you never pick one, and a resource of the wrong type does not compile: ```dart final zone = add( CloudflareZone('site', name: .literal('example.com'), account: ZoneAccount(id: .literal('acc'))), ); add( CloudflareDnsRecord( 'www', zoneId: zone.ref, name: .literal('www'), type: .cname, ttl: .literal(1), content: .content(.literal('ghs.googlehosted.com')), proxied: .literal(true), ), ); ``` Every attribute also has a getter (`zone.id`, `role.arn`, `topic.name`) that is a `TfArg` of its type, so it goes into any argument that takes one. A resource outside the Stack goes into a `RefTo` input as `.literal(...)`, its name, ID or ARN. A module output or another string you cannot type goes in as `.arg(...)`, unchecked. ## Enums and exclusive arguments [Section titled “Enums and exclusive arguments”](#enums-and-exclusive-arguments) An input with a fixed set of values takes the member, bare: `type: .cname`, `runtime: .providedAl2023`, `ingress: .all`. A list of them is a Dart list: `architectures: [.x8664]`. When the provider accepts exactly one (or at most one) of several arguments, they are one sealed argument, and each variant sets one of them. Its name is the concept (`code`, `content`, `name`, `delivery`), and its variants are named after the Terraform arguments: ```dart add( AwsIamRole( 'deploy', assumeRolePolicy: .expression(r'${file("trust.json")}'), name: .namePrefix(.literal('deploy-')), ), ); ``` A variant whose block has no fields takes no argument (`format: .avroFormat()`). ## Nested blocks [Section titled “Nested blocks”](#nested-blocks) A nested block is a helper class. Name the class on the resource’s own arguments, where it tells a reader which block starts, and write `.new(...)` for a block inside another block or inside a variant (`delivery: .pushConfig(.new(...))`). A block that repeats is a Dart `List` of helpers: ```dart add( GoogleStorageBucket( 'logs', name: .literal('my-project-logs'), location: .literal('EU'), lifecycleRule: [ StorageBucketLifecycleRule( action: .new(type: .delete), condition: .new(age: .literal(30)), ), ], ), ); ``` ## Variables and secrets [Section titled “Variables and secrets”](#variables-and-secrets) `variable()` declares a Terraform variable on the Stack and returns its handle, which a `TfArg` input takes as is. An enum or `RefTo` input takes it through `.arg(handle)`. An input the provider marks sensitive is `Sensitive`. It takes a sensitive variable, an expression or another block’s attribute, but has no `.literal`, so a secret never ends up in `main.tf.json`: ```dart final dbPassword = variable('db_password', sensitive: true); add( GoogleSecretManagerSecretVersion( 'db_password', secret: .literal('projects/my-project/secrets/db-password'), payload: .plaintext(dbPassword), ), ); ``` ## Expressions and references inside literals [Section titled “Expressions and references inside literals”](#expressions-and-references-inside-literals) Anything Terraform has to evaluate — a function call, a conditional, `terraform.workspace` — is `.expression(...)`, written as a Terraform template. Use a raw string (`r'...'`) so Dart leaves `${` alone. A literal list or map holds plain values, so a reference inside one goes in as the getter’s `.interpolation` (`${aws_iam_role.hello.arn}`): `variables: .literal({'ROLE_ARN': role.arn.interpolation})`. Synth checks what the analyzer cannot: a variable you never declared, a reference to a block you never added, a negative timeout. `stack.validate()` lists every issue at once. # Outputs in client apps > Build a Flutter, Dart web or command-line client with the values Terraform knows only after apply — a URL, a bucket, a list of hosts — typed, from a define file the Stack declares, on every provider. A client app needs values that exist only after apply: the URL of the API it calls, the bucket it uploads to. TerraDart hands them over without a copied string and without tying the app’s release to the apply. The Stack declares one more output, the **define file**; `terradart apply` or `terradart outputs` writes it from the applied state, the client’s build compiles it in with `--dart-define-from-file`, and reads each value with its type through the generated `Outputs` reader. It works the same on every provider package — Google Cloud, AWS, Cloudflare, Appwrite — because it lives on `Stack`, not on a provider. ## Declare the define file [Section titled “Declare the define file”](#declare-the-define-file) `addDartDefineOutput()` adds a Terraform output, `dart_defines`, whose value is a JSON object with one string per non-sensitive output: the variable named after the output in SCREAMING\_SNAKE\_CASE, holding a `String` output as it is and any other type as JSON. These are the same variables `outputEnvironment()` passes to a server, so a client and a service read one set of names. lib/app\_stack.dart ```dart import 'package:terradart_google/cloud_run.dart'; import 'package:terradart_google/provider.dart'; import 'package:terradart_google/storage.dart'; final class AppStack extends Stack { AppStack({required String projectId}) : super( providers: [GoogleProvider(project: projectId)], appExports: AppExports('lib/generated/app_stack.app.dart'), ) { final api = add( GoogleCloudRunV2Service( 'api', name: .literal('app-api'), location: .literal('asia-northeast1'), template: CloudRunV2ServiceTemplate( containers: [ .new(image: .literal('us-docker.pkg.dev/cloudrun/container/hello')), ], ), ), ); final uploads = add( GoogleStorageBucket( 'uploads', name: .literal('$projectId-uploads'), location: .literal('ASIA-NORTHEAST1'), ), ); addOutput('api_url', api.uri, description: 'Base URL of the API.'); addOutput('api_urls', api.urls); addOutput('uploads_bucket', uploads.name); addDartDefineOutput(); } } ``` bin/infra.dart ```dart import 'package:my_app/app_stack.dart'; import 'package:terradart_core/terradart_core.dart'; Future main(List args) => runStack(args, () => AppStack(projectId: 'my-project')); ``` `terradart synth` writes it into `tf-out/main.tf.json` after the three outputs it carries: ```json { "output": { "dart_defines": { "value": { "API_URL": "${google_cloud_run_v2_service.api.uri}", "API_URLS": "${jsonencode(google_cloud_run_v2_service.api.urls)}", "UPLOADS_BUCKET": "${google_storage_bucket.uploads.name}" } } } } ``` The call can come before the outputs it carries: the define file is resolved at synth, from every output the Stack registered by then. ## Read the values in the client [Section titled “Read the values in the client”](#read-the-values-in-the-client) `lib/generated/app_stack.app.dart` holds `AppStackOutputs`, one getter per non-sensitive output, typed like the output’s value. Its `fromDartDefine` constructor reads the values compiled into the app, so the reader is a constant: lib/api\_client.dart ```dart import 'generated/app_stack.app.dart'; const outputs = AppStackOutputs.fromDartDefine(); Uri endpoint(String path) => Uri.parse(outputs.apiUrl).resolve(path); List mirrors() => [for (final url in outputs.apiUrls) Uri.parse(url)]; String uploadsBucket() => outputs.uploadsBucket; ``` Each getter reads when it is called. A getter whose define is not set throws a `StateError` that names the variable and the output (`Dart define API_URL (Terraform output "api_url") is not set.`), and so does one whose value is not JSON or not of the getter’s type — so a client built without the file fails on the first value it reads, saying which one. ## Build the client [Section titled “Build the client”](#build-the-client) The client’s pipeline reads the applied state; it never plans or applies. [`terradart apply`](/docs/cli/) writes the define file to `.terradart/dart_defines.json` after the apply, and `terradart outputs` writes it from the applied state alone, for a build job that may read the state but not change it. A Flutter build reads it as it is: ```bash terradart outputs flutter build web --dart-define-from-file=.terradart/dart_defines.json flutter run --dart-define-from-file=.terradart/dart_defines.json ``` The app ships when it is ready, against whatever was applied last; a new apply reaches it on its next build. The [Flutter client quickstart](https://github.com/nozomi-koborinai/terradart/tree/main/examples/flutter_client_quickstart) is that build, end to end: `terradart apply --env dev` writes `.terradart/dart_defines.dev.json`, and `flutter run --dart-define-from-file=.terradart/dart_defines.dev.json` compiles it into the app, which reads `FlutterClientStackOutputs.fromDartDefine()`. ### One define file per environment [Section titled “One define file per environment”](#one-define-file-per-environment) With [environments](/docs/environments/), each environment’s apply writes its own file, `.terradart/dart_defines..json`, and each client build names the one it is for: ```bash terradart outputs --env stg flutter run --dart-define-from-file=.terradart/dart_defines.stg.json terradart outputs --env prod flutter build web --dart-define-from-file=.terradart/dart_defines.prod.json ``` The generated reader is the same for every environment; only the values compiled in differ. `terradart outputs --env prod` runs `init` with that environment’s backend, then the engine’s `output -json dart_defines`, and writes the file. Running the raw engine command yourself also works — `tofu output -json dart_defines > dart_defines.json` (or `terraform output ...`) in the environment’s Terraform directory, after `init` — and prints the same JSON object. ### Clients built with the `dart` command [Section titled “Clients built with the dart command”](#clients-built-with-the-dart-command) `dart run` and `dart compile` take one define per flag, so pass each value of the define file as it is — every value in it is already the string the reader expects: ```bash terradart outputs dart run -DAPI_URL="$(jq -r .API_URL .terradart/dart_defines.json)" -DAPI_URLS="$(jq -r .API_URLS .terradart/dart_defines.json)" bin/client.dart dart compile js -DAPI_URL="$(jq -r .API_URL .terradart/dart_defines.json)" -o web/main.dart.js web/main.dart ``` The [AWS Lambda quickstart](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_lambda_quickstart) (`bin/client.dart`) calls its function URL this way. ## When the values exist [Section titled “When the values exist”](#when-the-values-exist) The two halves come at different times: 1. **Synth**, before any apply: `terradart synth` (or `dart run bin/infra.dart`) writes the reader class, `AppStackOutputs`. Its getters and their types are known, so the client compiles against it, but it holds no values. 2. **Apply**, then the client’s build: the values exist only once the Stack is applied. `terradart apply` (or `terradart outputs` in the client’s build) writes them to `.terradart/dart_defines.json` (`.terradart/dart_defines..json` with `--env`), and `flutter build web --dart-define-from-file=.terradart/dart_defines.json` (or `apk`, `ios`, …) compiles them in. A build without them fails at the first read, with a `StateError` that names the variable and the command that writes it. `terradart outputs` reads the state from the Stack’s backend: a local state file, or a remote bucket such as a `GcsBackend` or `S3Backend`. The machine or CI job that builds the client runs `init` against that backend — `terradart outputs` does — and needs read access to the state; it never needs permission to apply. ## One file per client [Section titled “One file per client”](#one-file-per-client) Give each client only what it reads. `only` picks outputs by name, and `name` names the output: ```dart final uploads = add( GoogleStorageBucket( 'uploads', name: .literal('my-project-uploads'), location: .literal('ASIA-NORTHEAST1'), ), ); final reports = add( GoogleStorageBucket( 'reports', name: .literal('my-project-reports'), location: .literal('ASIA-NORTHEAST1'), ), ); addOutput('uploads_bucket', uploads.name); addOutput('uploads_url', uploads.url); addOutput('reports_bucket', reports.name); addDartDefineOutput( name: 'mobile_defines', only: ['uploads_bucket', 'uploads_url'], ); addDartDefineOutput(name: 'reports_defines', only: ['reports_bucket']); ``` `terradart outputs --define-output mobile_defines` writes the mobile app’s file, `.terradart/mobile_defines.json`, and `--define-output reports_defines` the reporting tool’s. Both build from the same generated reader; a getter of an output their file does not carry throws when called. ## Secrets stay out [Section titled “Secrets stay out”](#secrets-stay-out) Everything compiled into a client can be read by anyone who has the app, so the define file never carries a sensitive output: it is left out of the default set, and synth reports an `InvalidDartDefineOutput` issue when `only` names one. Synth reports the same issue when `only` names an output that is not registered, when two outputs share a variable, and when the file would carry no output at all. Build a client from the define file only: it is the one view of the outputs that leaves the sensitive ones out. The full output set of the state holds them in plain text. The rule is wider than Terraform: a dart-define is compiled into the app binary or its JavaScript, where anyone can extract it, so never pass a secret to a client that way, wherever it comes from — Secret Manager, a GitHub Actions secret, a sensitive output. A client gets only public values, such as an API URL or a Firebase web config, and work that needs a secret runs on a server: * **A server reads secrets at runtime** from its secret store: a Cloud Run service through a secret environment reference (`source: .valueSource(.new(secretKeyRef: ...))` on `CloudRunV2ServiceEnv`), a Lambda function from AWS Secrets Manager with the SDK, under its execution role. * **CI secrets are credentials for the pipeline** — reading the state, deploying — not values for the app. ## Servers and scripts [Section titled “Servers and scripts”](#servers-and-scripts) The same reader serves the other places an app runs: | Where the app runs | How it gets the values | Reader | | --------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------- | | A Flutter, web or CLI client | `--dart-define-from-file` with the define file, or `-D` per variable | `AppStackOutputs.fromDartDefine()` | | A Cloud Run service or a function | its environment, set by the Stack with `outputEnvironment()` | `AppStackOutputs.fromEnvironment(Platform.environment)` | | A script or test after apply | the define file, read at run time | `AppStackOutputs.fromEnvironment(defines)` | A script reads the define file `terradart outputs` wrote as the environment it stands for: lib/applied\_outputs.dart ```dart import 'dart:convert'; import 'dart:io'; import 'generated/app_stack.app.dart'; AppStackOutputs appliedOutputs() { final defines = File('.terradart/dart_defines.json').readAsStringSync(); final values = jsonDecode(defines) as Map; return AppStackOutputs.fromEnvironment(values.cast()); } ``` See [How TerraDart works — the app boundary](/docs/how-it-works/#the-app-boundary-constants-and-outputs) for `addOutput`, `outputEnvironment()` and the constants a Stack knows at synth, which need no define at all. # Environments > Declare dev, staging and prod as a Dart enum, give each its own project and state, and build each client with that environment's define file. A TerraDart project declares its environments in Dart: an enum of your own, one member per environment, each carrying that environment’s values. `runEnvironments` builds one Stack per member, and `terradart apply --env ` runs one of them and writes that environment’s [define file](/docs/client-outputs/) for the client. Nothing is configured in `pubspec.yaml`. ## Declare the environments [Section titled “Declare the environments”](#declare-the-environments) Any members, any fields. The Stack takes one member and derives everything from it — the project, the sizes, and where its state lives, so one environment can keep a local state file while another uses a GCS bucket: lib/env.dart ```dart enum Env { dev(projectId: 'acme-dev', stateBucket: null), stg(projectId: 'acme-stg', stateBucket: 'acme-stg-tfstate'), prod(projectId: 'acme-prod', stateBucket: 'acme-prod-tfstate'); const Env({required this.projectId, required this.stateBucket}); final String projectId; /// The GCS bucket of the state; `null` keeps a local file. final String? stateBucket; } ``` lib/app\_stack.dart ```dart import 'package:my_app/env.dart'; import 'package:terradart_google/provider.dart'; final class AppStack extends Stack { AppStack({required Env env}) : super( providers: [GoogleProvider(project: env.projectId)], backend: switch (env.stateBucket) { final bucket? => GcsBackend(bucket: bucket, prefix: 'app'), null => LocalBackend(path: 'state/${env.name}.tfstate'), }, ) { addOutput('api_url', .literal('https://api.${env.projectId}.example.com')); addDartDefineOutput(); } } ``` `runEnvironments` takes the members and builds the Stack for the one the command names: bin/infra.dart ```dart import 'package:my_app/app_stack.dart'; import 'package:my_app/env.dart'; import 'package:terradart_core/terradart_core.dart'; Future main(List args) => runEnvironments(args, Env.values, (env) => AppStack(env: env)); ``` ## Run one environment [Section titled “Run one environment”](#run-one-environment) `terradart synth` without `--env` writes every environment. `validate`, `plan`, `apply`, `destroy` and `outputs` run against one, the first of: 1. `--env ` (`-e`), a member’s name; 2. the `TERRADART_ENV` environment variable — ignored by an entry point that calls `runStack`; 3. the `defaultEnv` the entry point gives `runEnvironments`; 4. the only environment, when the enum has one member. With none of these, the command stops and lists the names. It prints which one it chose and why — `env: dev (--env)`, `env: dev (TERRADART_ENV)`, `env: dev (default)`, `env: dev (only environment)`: bin/infra\_default\_env.dart ```dart import 'package:my_app/app_stack.dart'; import 'package:my_app/env.dart'; import 'package:terradart_core/terradart_core.dart'; Future main(List args) => runEnvironments( args, Env.values, (env) => AppStack(env: env), defaultEnv: Env.dev, ); ``` When `TERRADART_ENV` or `defaultEnv` chose the environment, `apply` and `destroy` ask first, and run only on `yes`: ```text $ terradart apply > dart run bin/infra.dart ... env: dev (default) Apply environment "dev" (default)? Only "yes" is accepted: ``` `--auto-approve` skips the question, as it skips the engine’s. Without an answer to read (no terminal, or input closed), the command stops before `init`; pass `--env` or `--auto-approve` in CI. ```bash terradart validate --env stg terradart plan --env stg terradart apply --env stg ``` The names are the enum’s, not a fixed set: `qa`, `sandbox`, `euWest` work the same. A name that is not a member stops before anything runs, listing the ones that are: ```text $ terradart plan --env staging > dart run bin/infra.dart --env staging infra: unknown environment "staging"; known envs: dev, stg, prod. terradart: synth failed: bin/infra.dart exited 64. ``` ## Build each client with its environment [Section titled “Build each client with its environment”](#build-each-client-with-its-environment) Each environment gets its own Terraform directory and its own define file, named after it. After `apply`, `terradart` writes the define file and prints the line that builds the client with it: | Command | Terraform directory | Define file | Client build | | ---------------------------- | ------------------- | ----------------------------------- | ----------------------------------------------------------------------------- | | `terradart apply --env dev` | `tf-out/dev` | `.terradart/dart_defines.dev.json` | `flutter run --dart-define-from-file=.terradart/dart_defines.dev.json` | | `terradart apply --env stg` | `tf-out/stg` | `.terradart/dart_defines.stg.json` | `flutter build web --dart-define-from-file=.terradart/dart_defines.stg.json` | | `terradart apply --env prod` | `tf-out/prod` | `.terradart/dart_defines.prod.json` | `flutter build ipa --dart-define-from-file=.terradart/dart_defines.prod.json` | A client’s build job does not apply anything: `terradart outputs --env prod` writes the same file from the applied state, and the build compiles it in. ```bash terradart outputs --env prod flutter build web --dart-define-from-file=.terradart/dart_defines.prod.json ``` The app reads every value with its type through the generated `Outputs` reader, whichever environment it was built for. [Outputs in client apps](/docs/client-outputs/) covers the reader, the values a define file may carry, and why a secret never goes in one. ## One directory, one backend, several states [Section titled “One directory, one backend, several states”](#one-directory-one-backend-several-states) When every environment keeps its state in the same kind of backend and only its settings differ, the environments can share one directory and tell their states apart when `terradart` initializes it. `runEnvironments` says how, in Dart: * **Partial backend configuration.** The Stack’s backend leaves the settings out (`const GcsBackend()`); `backendConfig` names a `-backend-config` file (relative to the package) or `key=value` pairs per environment. `terradart` runs `init -reconfigure` with them on every command, so the directory never keeps another environment’s backend. ```dart import 'package:my_app/app_stack.dart'; import 'package:my_app/env.dart'; import 'package:terradart_core/terradart_core.dart'; Future main(List args) => runEnvironments( args, Env.values, (env) => AppStack(env: env), dir: (_) => 'tf-out', backendConfig: (env) => ['backend/${env.name}.gcs.tfbackend'], ); ``` * **Workspaces.** `workspace` names the Terraform workspace `terradart` selects after `init`, creating it on the first `plan` or `apply`. ```dart import 'package:my_app/app_stack.dart'; import 'package:my_app/env.dart'; import 'package:terradart_core/terradart_core.dart'; Future main(List args) => runEnvironments( args, Env.values, (env) => AppStack(env: env), dir: (_) => 'tf-out', workspace: (env) => env.name, ); ``` Two environments may share a directory only when one of these tells them apart; `runEnvironments` throws otherwise. `--workspace` and `--backend-config` on the command line override and extend them for one run. ## Moving an environment’s state [Section titled “Moving an environment’s state”](#moving-an-environments-state) An environment’s backend is Dart too, so moving its state starts with a change to the Stack — giving `stg` a `stateBucket`, say, where it kept a local file. The state then follows once: ```bash terradart state migrate --env stg ``` It synthesizes `stg`, names the full source and target configuration (the bucket and prefix, not only the backend type), asks, and runs the engine’s `init -migrate-state` in `stg`’s directory with its `backendConfig`. `--auto-approve` skips the question, and is required when it cannot ask (no terminal, `--no-input`, CI or an agent). Run it before the next `plan` or `apply` of `stg`: those reconfigure the directory onto the new backend without copying. When environments share that directory, the copy is the state of the environment that last initialized it. If that was another environment, or TerraDart has no record of which one, it stops and tells you to run `terradart plan --env stg` first — it does not copy, and `--auto-approve` does not skip the stop. The other environments are not touched. ## Migrated environments [Section titled “Migrated environments”](#migrated-environments) `terradart migrate --merge-envs` writes an `Env` enum whose members carry the directory each environment came from (`path`), and a `bin/infra.dart` that calls `runEnvironments` with `dir: (env) => 'tf-out/${env.path}'`. `terradart plan --env prodEu` plans the matching environment. A single-module migration calls `runStack` instead, so `terradart plan` with no `--env` is enough. See [Migrating from HCL](/docs/migrate-from-hcl/). # How TerraDart works > A Stack in Dart is synthesized to Terraform JSON, planned and applied by OpenTofu or Terraform through the terradart command, and the values your app needs come back to it as typed Dart. TerraDart is an authoring layer over Terraform. You describe infrastructure as a Dart `Stack`; TerraDart turns it into standard Terraform JSON, and OpenTofu or Terraform plan and apply it exactly as they would hand-written configuration. Dart gives you types, refactoring and `dart analyze`; state, planning and apply stay with the engine. The same synth also writes a Dart file your app imports, so the values that cross from infrastructure to app — a topic name, a service URL — are typed and never copied by hand. ## The loop [Section titled “The loop”](#the-loop) ``` graph LR subgraph pkg["Your Dart package"] ST["Stack
lib/app_stack.dart"] EP["bin/infra.dart
runStack / runEnvironments"] end ST --> EP EP -->|"terradart synth"| TF["tf-out/main.tf.json"] EP -->|"terradart synth"| AD["lib/generated/app_stack.app.dart
Constants · Outputs reader"] TF -->|"terradart plan / apply"| EN["OpenTofu or Terraform"] EN <--> STATE[("State
local or remote backend")] EN -->|"creates"| CLOUD["Cloud resources"] STATE -->|"terradart apply / outputs"| DF[".terradart/dart_defines.json"] AD --> APP["Your app"] DF -->|"--dart-define-from-file"| APP CLOUD -->|"outputEnvironment()"| APP ``` 1. **Author.** A `final class AppStack extends Stack` adds resources built with the factories of the provider packages — `terradart_google`, `terradart_aws`, `terradart_cloudflare`, `terradart_appwrite` — and declares the values the app reads back with `addConstant` and `addOutput`. 2. **Synth.** `terradart synth` runs `bin/infra.dart`, whose `runStack` (or `runEnvironments`, for [several environments](/docs/environments/)) builds the Stack and writes `tf-out/main.tf.json` plus the generated app file. `dart run bin/infra.dart` does the same without the command. 3. **Plan and apply.** `terradart plan` and `terradart apply` synthesize, then run `init` and `plan` or `apply` in `tf-out/` with a checksum-verified OpenTofu the command downloads, or the `tofu` or `terraform` already on your `PATH` — you never install Terraform yourself. State lives in the backend the Stack declares. 4. **Hand off.** Constants are known at synth and compiled into the app. Outputs exist after apply and reach the app through the generated reader: a deployed service from its environment, a Flutter or web client from the define file `terradart apply` writes, a script from the same file. The [`terradart` command](/docs/cli/) runs steps 2 to 4 in one go. ## Synth [Section titled “Synth”](#synth) **Synth** is the in-memory step: the Stack is walked and assembled into the Terraform JSON tree. In code, `stack.synth()` returns a `SynthResult`, and `stack.writeTo(outDir)` writes it. `runStack` calls `writeTo('tf-out')` and tells the `terradart` command what it wrote. The [landing page](/) says you **generate** `*.tf.json`; this is the same step. ``` graph LR S[Stack instance] --> SY["synth()"] SY --> SR[SynthResult] SR --> W["writeTo(outDir)"] W --> TF[main.tf.json] W -.optional.-> AD[".app.dart constants and outputs reader"] ``` `synth()` is pure. It walks the resources you registered with `add(...)`, applies lifecycle wiring, dedups, and produces a `SynthResult` whose `tfJson` field carries the Terraform JSON tree. `writeTo(outDir)` is the file-I/O wrapper. It always writes `main.tf.json`. When the Stack was constructed with `appExports: AppExports('lib/generated/.app.dart')`, it also writes that generated Dart file, rewritten in full on every synth. Synth runs before any write, so a Stack that cannot synthesize throws with nothing written — the failure mode is atomic, never partial. ### Synth issues [Section titled “Synth issues”](#synth-issues) Before it encodes anything, synth checks the whole Stack and collects every problem it finds into one `SynthException`, so a broken Stack reports all of its issues in one run instead of one per fix. Each problem is a subtype of the sealed `SynthIssue`, carrying the address of the block that holds it and a message that says how to fix it: | Issue | When | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `NoProviders` / `MissingProvider` / `ProviderConflict` | no provider is registered, a block needs one that is not, or two registrations clash (a repeated alias, different version pins for one name) | | `UndeclaredVariable` | a `.variable('x')` or `var.x` names a variable neither `variable` nor `externalVariable` declared | | `UnregisteredReference` | a block reads (or `depends_on`) a resource, data source or module that was built but never passed to `add` / `addModule` | | `SensitiveLiteral` | a sensitive field is set to a literal, which would write the secret into `main.tf.json` | | `InvalidTimeout` | a `TfTimeouts` value is not a Go duration string | | `InvalidMoveTarget` | an `addMoved` target names no resource of the Stack | | `UnresolvableConstant` | an `addConstant(...)` attribute has no literal value at synth | `stack.validate()` returns the same list without throwing — the shape a test or a pre-commit check wants. The sealed hierarchy lets a `switch` handle the issues it cares about: lib/explain\_stack.dart ```dart import 'package:terradart_core/terradart_core.dart'; /// Prints why [stack] cannot synthesize, or nothing when it can. void explain(Stack stack) { for (final issue in stack.validate()) { switch (issue) { case UnregisteredReference(:final address, :final target): print('$address reads $target, which was never added'); case SensitiveLiteral(:final address, :final field): print('$address would write the secret $field into main.tf.json'); default: print(issue); } } } ``` A block declared in a hand-written `.tf` file beside `main.tf.json` is the one legitimate unregistered reference: declare it with `addExternalBlock('google_pubsub_topic.legacy')`, the counterpart of `externalVariable` for variables (`terradart migrate` does this for the blocks it leaves in the sidecar). Names are checked earlier, where they are registered: `add`, `addModule`, `variable` and `addOutput` throw `ArgumentError` for a name that is not a Terraform identifier. `Stack`, `Resource`, and `Data` are `abstract base class`. Your subclasses must declare a class modifier: ```dart final class AppInfraStack extends Stack { AppInfraStack() : super(providers: [GoogleProvider(project: 'my-project')]); } ``` `base` and `sealed` are also valid; what is rejected is plain `class` (or `implements Stack`, which would bypass the base-class state that synth depends on). ## Plan, apply and state [Section titled “Plan, apply and state”](#plan-apply-and-state) TerraDart never reads or writes state itself. The Stack declares where state lives — `LocalBackend`, `GcsBackend`, `S3Backend`, or a partial backend completed per environment — and the engine keeps it there. The `terradart` command picks the engine: the one named in `pubspec.yaml` or on the command line, the one that last applied this state, `tofu` or `terraform` on `PATH`, or a pinned OpenTofu release it downloads and checks against its SHA-256 ([The engine](/docs/cli/#the-engine)). Because the output is standard Terraform JSON, everything around the engine stays as it is: remote state, `-target`, `moved` blocks (`addMoved`), existing modules called with `addModule`, and HCL files beside `main.tf.json` that feed the same apply. With [environments](/docs/environments/), each one gets its own Terraform directory and state, selected with `--env`. ## The app boundary: constants and outputs [Section titled “The app boundary: constants and outputs”](#the-app-boundary-constants-and-outputs) A Stack hands values to the Dart code that uses its resources — a Cloud Run service, a Lambda function, a Flutter client — in two ways, each with its own method: ``` graph LR AO["addOutput(name, value)"] --> TFO["Terraform output block (main.tf.json)"] AO --> RD[".app.dart Outputs reader"] AC["addConstant(name, constant)"] --> DC[".app.dart Constants class"] ST[Application code] --> DC ST --> RD TF[OpenTofu or Terraform] --> TFO TFO -.environment / define file.-> RD ``` * **`addOutput`** declares a Terraform `output` for a value known after apply — a resource ID, a Cloud Run URL — and a getter of the generated reader (see below). It takes any `TfArg`, so the value is a dot shorthand like every resource argument: `addOutput('service_url', service.uri, description: 'Cloud Run URL')`. It checks at registration that the name is a Terraform identifier, is not taken, and — unless `sensitive: true` — that the value does not read a sensitive field. * **`addConstant`** declares a `static const` on the generated `Constants` class, for a value known at synth. The value is a sealed `AppConstant`, picked with a dot shorthand: * `topic.name` — the literal an attribute of the Stack is set to (`name: .literal('orders-prod')`), so the string is written once. Every input has such a getter, named `Ref` (`scope.scopeId` for `scope_id`), and other resources can take it too (`scopeId: scope.scopeId`). Synth reports an `UnresolvableConstant` issue that says what the attribute is set by when it is not a literal (a reference, a variable, an expression, or left for the provider to compute) or is a sensitive field. * `.value(5)` — a value that belongs to no resource, such as an API version. * `.fromEnvironment('API_BASE_URL')` — a `String.fromEnvironment` read at the app’s compile time. `T` can be `String`, `int`, `double`, `num`, `bool`, `Object`, or a `List` / `String`-keyed `Map` of them, each optionally nullable; `addConstant` rejects any other type, a name that is not a public Dart identifier, and a duplicate. The constants file is configured once, on the constructor, and `addConstant` throws when it is missing: lib/orders\_stack.dart ```dart import 'package:terradart_google/provider.dart'; import 'package:terradart_google/pubsub.dart'; final class OrdersStack extends Stack { OrdersStack({required String projectId}) : super( providers: [GoogleProvider(project: projectId)], appExports: AppExports('lib/generated/orders_stack.app.dart'), ) { final topic = add(GooglePubsubTopic('orders', name: .literal('orders-prod'))); addConstant('ordersTopicName', .ref(topic.name)); addOutput('orders_topic_id', topic.id); addDartDefineOutput(); } } ``` `terradart synth` runs `bin/infra.dart`, which writes `tf-out/main.tf.json` and the generated file: bin/infra.dart ```dart import 'package:my_app/orders_stack.dart'; import 'package:terradart_core/terradart_core.dart'; Future main(List args) => runStack(args, () => OrdersStack(projectId: 'my-project')); ``` ### Reading outputs in the app [Section titled “Reading outputs in the app”](#reading-outputs-in-the-app) The same file holds `Outputs`, a typed reader of the Stack’s outputs: one getter per non-sensitive output, named in lowerCamelCase and typed like the output’s value (`addOutput('orders_topic_id', topic.id)` becomes `String get ordersTopicId`). Its `const` constructors pick the source — the environment (`fromEnvironment`), or the defines compiled into a client (`fromDartDefine()`, see [Outputs in client apps](/docs/client-outputs/)): lib/read\_outputs.dart ```dart import 'dart:convert'; import 'dart:io'; import 'generated/orders_stack.app.dart'; /// A script or test that runs after `terradart apply` or `terradart outputs`, /// reading the define file they write. String appliedTopicId() { final defines = File('.terradart/dart_defines.json').readAsStringSync(); final values = jsonDecode(defines) as Map; return OrdersStackOutputs.fromEnvironment( values.cast(), ).ordersTopicId; } /// A deployed service, whose environment carries ORDERS_TOPIC_ID. String deployedTopicId() => OrdersStackOutputs.fromEnvironment(Platform.environment).ordersTopicId; ``` `fromEnvironment` and `fromDartDefine` read each output from the variable named after it in SCREAMING\_SNAKE\_CASE (`ORDERS_TOPIC_ID`); a `String` output is the variable’s value and any other type is JSON. Each getter reads when called, so a service whose environment sets only some of the variables can read those, and a missing or mistyped value throws a `StateError` naming the output and the variable. A sensitive output has no getter — read a secret from its secret store. With `appExports` set, `addOutput` rejects an output whose getter is not a usable Dart identifier, or whose getter or variable another output already has. ### Passing outputs to a service [Section titled “Passing outputs to a service”](#passing-outputs-to-a-service) `outputEnvironment()` returns exactly those variables — each non-sensitive output registered so far, as a `(name, value)` record of the variable name and a `TfArg`: a `String` output as is, any other as `jsonencode(...)` — so a Cloud Run service (or a function) receives the outputs it reads, with no variable name written twice: ```dart final topic = add(GooglePubsubTopic('orders', name: .literal('orders-prod'))); addOutput('orders_topic_id', topic.id); final service = add(GoogleCloudRunV2Service( 'api', name: .literal('orders-api'), location: .literal('asia-northeast1'), template: CloudRunV2ServiceTemplate(containers: [ .new( image: .literal('us-docker.pkg.dev/cloudrun/container/hello'), env: [ for (final (:name, :value) in outputEnvironment()) .new(name: .literal(name), source: .value(value)), ], ), ]), )); addOutput('service_uri', service.uri); // after: a service cannot read itself ``` Where a provider takes the environment as one map — an AWS Lambda function, a Cloud Function — pass `variables`, the same environment as a `TfArg>`: ```dart final table = add(AwsDynamodbTable( 'items', name: .literal('items'), hashKey: .literal('id'), attribute: [.new(name: .literal('id'), type: .s)], )); addOutput('table_name', table.name); add(AwsLambdaFunction( 'api', functionName: .literal('items-api'), role: .literal('arn:aws:iam::123456789012:role/items-api'), runtime: .providedAl2023, handler: .literal('bootstrap'), code: .filename(.literal('build/bootstrap.zip')), environment: .new(variables: outputEnvironment().variables), )); ``` `outputEnvironment(only: [...])` picks outputs by name. Register an output that reads the service itself after the call — a resource whose environment references its own attributes is a Terraform cycle. ### Building a client with them [Section titled “Building a client with them”](#building-a-client-with-them) A client app — Flutter, Dart web, a CLI — gets the same variables as a file instead. `addDartDefineOutput()` declares an output whose value is the JSON that `--dart-define-from-file` reads; `terradart apply` writes it to `.terradart/dart_defines.json` (`.terradart/dart_defines..json` with `--env`), and the client reads it with `const OrdersStackOutputs.fromDartDefine()`. [Outputs in client apps](/docs/client-outputs/) walks through it. `AppExports(path, name: 'Orders')` renames the generated classes (`OrdersConstants`, `OrdersOutputs`). The path is relative to the directory `bin/infra.dart` runs in, and the generated file imports only `dart:convert`, so it can live in a small package the app and the infra package both depend on, keeping the app free of provider packages — the [Lunch Concierge recipe](https://github.com/nozomi-koborinai/terradart/tree/main/cookbook/lunch-concierge) writes it into its `shared/` package. Runnable pattern: [pubsub quickstart](https://github.com/nozomi-koborinai/terradart/tree/main/examples/pubsub_quickstart) (`lib/subscriber_stub.dart`). A worked end-to-end example lives in the [cookbook `single-project-app` recipe](https://github.com/nozomi-koborinai/terradart/tree/main/cookbook/single-project-app): the outputs `db_instance` and `db_name` passed to the Cloud Run service with `outputEnvironment()`, the Terraform output `coffee_service_uri`, and the constants `serviceName` and `region` read from the Cloud Run service’s own `name` and `location`. ## Provider packages [Section titled “Provider packages”](#provider-packages) Each provider package wraps one official Terraform provider, and its factories mirror that provider’s schema — one per resource and data source, with typed arguments, enums, nested blocks and references: * **[`terradart_google`](https://pub.dev/packages/terradart_google)** — the GA HashiCorp [`google`](https://registry.terraform.io/providers/hashicorp/google/latest) provider catalog, every type on [Google Cloud coverage](/docs/coverage/google/). * **[`terradart_google_beta`](https://pub.dev/packages/terradart_google_beta)** — the types only `hashicorp/google-beta` has; types GA also has stay in `terradart_google`. * **[`terradart_aws`](https://pub.dev/packages/terradart_aws)** — HashiCorp [`aws`](https://registry.terraform.io/providers/hashicorp/aws/latest), filled at its pinned release. * **[`terradart_cloudflare`](https://pub.dev/packages/terradart_cloudflare)** — the official `cloudflare/cloudflare` provider, filled at its pinned release. * **[`terradart_appwrite`](https://pub.dev/packages/terradart_appwrite)** — the official `appwrite/appwrite` provider, filled at its pinned release. **[`terradart_time`](https://pub.dev/packages/terradart_time)** is a small hand-written package beside them: `TimeProvider` / `TimeSleep` for HashiCorp [`time`](https://registry.terraform.io/providers/hashicorp/time/latest), usable from a Stack on any provider. One Stack can mix packages. Each provider has a page under Providers — [Google Cloud](/docs/providers/google/), [AWS](/docs/providers/aws/), [Cloudflare](/docs/providers/cloudflare/), [Appwrite](/docs/providers/appwrite/) — and [How TerraDart is built](/docs/how-its-built/) covers how the factories are generated from the provider schemas and kept current. ## Non-goals [Section titled “Non-goals”](#non-goals) * Not a Terraform replacement — state and apply stay in OpenTofu or Terraform. * Not a multi-cloud abstraction layer — wrappers faithfully mirror provider schemas rather than imposing cross-cloud abstractions. * Not a constructs framework in the pre-1.0 cycle. * Not a module system — `addModule(ModuleCall(...))` calls an existing Terraform module by its `source`, and HCL files beside the generated `*.tf.json` feed the same apply; TerraDart does not turn modules into Dart. See [README — Non-goals](https://github.com/nozomi-koborinai/terradart#non-goals) for the canonical list. ## Further reading [Section titled “Further reading”](#further-reading) * [The terradart command](/docs/cli/) — every command, the entry point, the engine. * [Environments](/docs/environments/) — one Stack per environment, each with its own state and define file. * [Outputs in client apps](/docs/client-outputs/) — the define file and the reader, step 4 in depth. * [How TerraDart is built](/docs/how-its-built/) — the generation pipeline and verification harness behind the factories. * [`terradart_core` on pub.dev](https://pub.dev/packages/terradart_core) — the `Stack`, `TfArg` and `AppConstant` API reference. * [`examples/` on GitHub](https://github.com/nozomi-koborinai/terradart/tree/main/examples) — runnable quickstart Stacks. # How TerraDart is built > The generation pipeline, the curation philosophy, the verification harness, and why a single-maintainer project can still be a safe dependency. If you are deciding whether to depend on TerraDart, two questions matter more than any feature list: *why should I trust hundreds of generated factory classes?* and *why should I expect this project to keep up with its providers?* This page answers both with mechanisms you can inspect in the repository — every claim below names something that actually runs, not an aspiration. ## The generation pipeline [Section titled “The generation pipeline”](#the-generation-pipeline) Curated factories are not written by hand. They are generated by a maintainer CLI (`terradart-codegen wrap`) from pinned inputs: the **Terraform provider schema** of each provider (`hashicorp/google`, `hashicorp/google-beta`, `hashicorp/aws`, `cloudflare/cloudflare`, `appwrite/appwrite`), plus what the schema leaves out. For Google that is **Magic Modules metadata** — the upstream YAML Google uses to generate the provider itself, synced per-resource with provenance recorded for every file. For AWS, Cloudflare and Appwrite it is **validator hints** extracted from the provider’s Go source at the pinned release: the value sets that become enums and the exclusive groups that become sealed types. Generation is **byte-deterministic**: CI re-runs the generator against the pinned inputs on every pull request and fails if a single generated file differs from what is committed. There is no “mostly generated, occasionally hand-patched” gray zone — generated files carry a do-not-edit banner, repo tooling blocks direct edits, and the parser that extracts enum values from schema descriptions is the same code the coverage checker runs, so the generator and its auditor cannot drift apart. ### Where the Google inputs come from [Section titled “Where the Google inputs come from”](#where-the-google-inputs-come-from) Google generates its own Terraform provider from Magic Modules, and TerraDart reads both ends of that pipeline: The MM YAML overlay carries metadata the provider schema does not surface — enum values, exactly-one-of groups, sensitive fields, `force_new`, free-form description text — read at the Magic Modules commit the provider release was generated from. Google tracking is pinned to GA `~> 8.0`; beta-only types live in `terradart_google_beta`, and names GA also has stay in `terradart_google`. The weekly loop under [Sustainability](#sustainability) refreshes these fixtures. You don’t need to track it to use TerraDart; it is documented so that, when a release adds factories or shifts an existing one, you can see which upstream change drove it. ## The curation philosophy [Section titled “The curation philosophy”](#the-curation-philosophy) The rule is simple: **facts are derived, judgment is declared, and both are auditable.** * Anything the schema, Magic Modules or the provider’s validators can state — parameters, enum values, output getters, nested block shapes, documentation — is *derived*, so it moves automatically when upstream moves. * Human decisions — how to model an exactly-one-of block as a sealed class hierarchy, what to name it, and which surfaces to freeze deliberately — live in thin, reviewable override files. * Every escape hatch is a **fail-closed ledger**: frozen surfaces, coverage debts, and migrator round-trip exceptions are recorded lines with reasons, and CI fails when an entry goes stale. Forgetting is impossible to do silently. This is also why the API is opinionated: IAM adjuncts default to additive `*_iam_member` variants, and mutually-exclusive nested blocks are enforced at compile time with sealed classes instead of documented conventions. ## The verification harness [Section titled “The verification harness”](#the-verification-harness) Type-checking generated code proves it compiles, not that it works. Verification is layered from cheap to expensive: The second layer asks Terraform itself. On every pull request, each example’s synthesized JSON is checked with `terraform validate` against the real provider plugin, and the migrator round-trips every example — synthesize, migrate the JSON back to Dart, synthesize again — and requires identical output. CI does not apply examples to a live project; real-apply evidence is tracked through the cookbook (see [Status](/docs/status/)). When you upgrade TerraDart and your synthesized JSON is unchanged, that is not luck; it is the property this harness exists to protect. ## Sustainability [Section titled “Sustainability”](#sustainability) TerraDart is a **single-maintainer project**, and you should weigh that honestly. Here is what makes it a smaller risk than the label suggests: the routine work does not depend on the maintainer being present. * **The update loop runs on a schedule.** Provider schema updates arrive as automatic weekly pull requests, one per provider: Google (with google-beta) on Sundays, AWS on Mondays, Cloudflare on Tuesdays. A new resource type arrives as a generated factory queued for API polish. On Google and AWS, a bump with no removed type and no breaking change to the generated Dart API enables auto-merge and lands once the full CI battery is green, without waiting on anyone’s availability. Cloudflare bumps always wait for review, and the Appwrite pin moves by hand. * **The repository is deliberately maintainable by others — human or AI.** A single agent guide is the source of truth for how to work on the codebase; the ledgers make every standing decision explicit; determinism makes every regeneration verifiable. A successor does not need tribal knowledge, and routine maintenance runs under the gates above. * **Lock-in is shallow by design.** The output is standard Terraform JSON. If the project stopped tomorrow, your existing configurations would keep working with plain OpenTofu or Terraform — and migrating away is a file-format decision, not a re-architecture. What is *not* promised: there is no SLA. Issue triage and reviews are best-effort, and security response targets are documented in [SECURITY.md](https://github.com/nozomi-koborinai/terradart/blob/main/SECURITY.md). The [status page](/docs/status/) states the current release phase and change policy in the same spirit. ## Read more [Section titled “Read more”](#read-more) * [How TerraDart works](/docs/how-it-works/) — what a Stack becomes: synth, plan and apply, and the constants and outputs your app reads. * [Coverage](/docs/coverage/) — every factory of every provider package and the examples that exercise it; the [provider pages](/docs/providers/google/) cover the rest. * Contributing or pointing an agent at the repo? Start with [AGENTS.md](https://github.com/nozomi-koborinai/terradart/blob/main/AGENTS.md). # Migrating from HCL > Bring an existing Terraform tree into TerraDart with terradart migrate — one Stack per module directory, a leftover sidecar for the rest, and a plan that reports No changes. `terradart migrate` turns a Terraform source tree (`*.tf` and `*.tf.json`) into a TerraDart package: one `Stack` class per module directory, a `tf-out/` tree that mirrors the source, and a **leftover sidecar** beside every synthesized `main.tf.json` holding, verbatim, each block the curated factories do not cover yet. Resource addresses are preserved, so `terradart plan` against your existing state reports **No changes** — you migrate one resource at a time, at your own pace, with no big-bang rewrite. It reads files only. It never runs Terraform, never reads or writes state, never writes into the source tree, and never writes outside the output directory. ## Install [Section titled “Install”](#install) ```sh dart pub global activate terradart_cli ``` `terradart` lands in `~/.pub-cache/bin`; add that directory to your `PATH` if `dart pub global activate` asks you to. Migration runs before a Dart project exists, so a global install is enough — there is no `pubspec.yaml` to find. The only prerequisite is a Dart SDK, which the migrated package needs anyway. **From a checkout:** `dart run bin/terradart.dart migrate --help` in `packages/terradart_cli`. ```sh terradart migrate --version ``` The generated package needs the Dart SDK (`dart pub get`, `terradart plan`) and, for a state Terraform wrote, Terraform on your `PATH` for the plan ([The engine](#the-engine)); the migrator itself needs neither. The `terradart-migrate` executable still runs the same flags and prints that it is deprecated. ## The engine [Section titled “The engine”](#the-engine) Your existing state was written by Terraform, so the generated `pubspec.yaml` keeps `terradart plan` and `apply` on Terraform: ```yaml terradart: engine: terraform ``` Without it, `terradart` would run the `tofu` on your `PATH`, or download OpenTofu — and OpenTofu rewrites the state for itself at its first apply, after which Terraform may not read it back. When the tree was run with OpenTofu (a `.tofu` file, or a `.terraform.lock.hcl` naming `registry.opentofu.org`), the section says `engine: tofu` instead. To move the state to OpenTofu, change it to `engine: tofu` (or delete the section): the next `apply` rewrites the state for OpenTofu. Either way, `terradart plan`, `apply` and `destroy` look at the state before they run, and warn when the other engine wrote it. Without the section, where `terradart` picks the engine by itself, they also ask first on a terminal, and without one stop with the `--engine` flag that decides ([The terradart command](/docs/cli/#the-engine)). ## Size it first [Section titled “Size it first”](#size-it-first) `--report` runs the whole migration in memory and writes nothing — no `--out`, no files, no `terraform`: ```sh terradart migrate --report --dir infra ``` It lists every `resource` and `data` type in the tree with how many of its blocks translate, how many stay in Terraform, and the curated factory it maps to — or `not in any catalog`. Because it is the migration itself, not a catalog lookup, a type with a factory can still show kept blocks: a non-literal `count`, a sensitive literal, an argument with no Dart parameter. Every kept block is listed with its reason, and a `module` call whose source is outside the tree (a registry or git module) is listed as not scanned, since its resources are not counted. A literal `count` / `for_each` counts once per instance; a child module counts once however many times it is called. `--dir` defaults to the current directory, and `--json` prints the same report as JSON. ```text terradart migrate 0.x.y --report: infra 37 of 42 resource and data blocks translate (88%); 19 of 20 types have a curated factory. Nothing was written. Types (20): google_storage_bucket [resource] x6: 5 translate, 1 kept -> GoogleStorageBucket (terradart_google/storage) acme_widget [resource] x1: not in any catalog ... ``` ## Migrate a tree [Section titled “Migrate a tree”](#migrate-a-tree) ```sh terradart migrate --dir infra --out infra_dart ``` Every directory under `--dir` that holds `.tf` / `.tf.json` files is a module (hidden directories such as `.terraform` are skipped). Given a tree like ```text infra/ ├── envs/ │ ├── dev/ main.tf backend.tf terraform.tfvars │ └── prod/ main.tf backend.tf terraform.tfvars └── modules/ └── network/ main.tf variables.tf outputs.tf ``` `infra_dart/` holds: | Path | Content | | :-------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pubspec.yaml` | lockstep pins on `terradart_core` and the provider packages the Stacks use, and the engine `terradart` runs the existing state with ([The engine](#the-engine)) | | `bin/infra.dart` | synthesizes every Stack. One Stack calls `runStack`; `--merge-envs` calls `runEnvironments`, so `terradart plan --env ` runs one environment | | `lib/dev_stack.dart`, `lib/prod_stack.dart`, `lib/network_stack.dart` | one Stack per module directory (`dev` → `DevStack`) | | `lib/network_module.dart` | one typed `ModuleCall` wrapper per local module directory a `module` block calls (`modules/network` → `NetworkModule`), from its `variable` and `output` blocks | | `tf-out/envs/dev/`, `tf-out/envs/prod/`, `tf-out/modules/network/` | each module’s Terraform directory, mirroring the source so `source = "../../modules/network"` keeps resolving: `main.tf.json` (written by synth) next to the sidecar files, plus `terraform.tfvars`, `*.auto.tfvars` and `.terraform.lock.hcl` copied from the source | | `MIGRATION.md` | the report: every module, every block that stays in Terraform with its reason and file, warnings, and how `dev` and `prod` differ | A single-module `--dir` synthesizes into `tf-out/` directly. The summary on stdout says what happened per directory; `--json` prints the same report as JSON. ```text terradart migrate 0.x.y: infra → infra_dart (infra) modules: 3 (2 roots, 1 child); migrated 41 blocks, kept 5 envs/dev: DevStack — 17 migrated, 2 kept → tf-out/envs/dev envs/prod: ProdStack — 19 migrated, 3 kept → tf-out/envs/prod modules/network: NetworkStack — 5 migrated, 0 kept → tf-out/modules/network Report: infra_dart/MIGRATION.md Next: cd infra_dart && dart pub get && terradart synth then terradart plan --env dev ``` ## Plan with no changes [Section titled “Plan with no changes”](#plan-with-no-changes) ```sh cd infra_dart dart pub get terradart plan # one Stack: synth, init, plan ``` A tree of several roots (the `envs/dev` and `envs/prod` layout above) synthesizes every directory from one `bin/infra.dart`. Name the one to plan with `--env`: `terradart` runs the entry point, then `init` and `plan` in the `tf-out/` directory of that name. ```sh terradart plan --env dev # No changes. Your infrastructure matches the configuration. ``` With `--merge-envs`, the generated entry point calls `runEnvironments`, and `--env dev` selects that member of the generated `Env` enum. Terraform merges every file in a directory, so the synthesized `main.tf.json` and the sidecar files form the same module the source was. The Stack carries the backend (`GcsBackend`, `S3Backend`, `LocalBackend`; any other backend stays in `backend.tf` as written), so the `init` that `terradart plan` runs in the new directory connects to the same remote state. With a **local backend**, copy `terraform.tfstate` into the new directory first — the migrator never copies state. `terraform.tfvars` and `*.auto.tfvars` are copied; other `*.tfvars` files are listed in `MIGRATION.md`; pass one after `--` (`terradart plan --env dev -- -var-file=extra.tfvars`). A plan that is not empty means a block was translated differently from how it was written. Nothing has been applied: compare that resource in `main.tf.json` with the original and [open an issue](https://github.com/nozomi-koborinai/terradart/issues/new/choose) with both. ## What stays in Terraform [Section titled “What stays in Terraform”](#what-stays-in-terraform) Translation is **resource-atomic**: a resource becomes a factory call only when every one of its arguments translates. One untranslatable argument keeps the whole block in Terraform, verbatim, preceded by the reason: ```hcl # terradart-migrate: count = var.workers is not a literal number; only a literal count is unrolled into resources with fixed addresses resource "google_pubsub_subscription" "workers" { count = var.workers name = "workers-${count.index}" topic = google_pubsub_topic.events.name } ``` The sidecar is split the way Terraform code usually is: * `terradart_leftover.tf` — resources, data sources, module calls the Stack cannot express, and `moved` blocks whose target stays here too; * `backend.tf` — `terraform { }` settings the Stack does not own: a backend without a TerraDart type, `cloud`, and the `required_providers` entries of providers that have no factory; * `variables.tf`, `locals.tf`, `outputs.tf` — the variables that did not become `variable(...)`, the `locals` entries that stay in Terraform, and the outputs that did not become `addOutput`. Nothing the Stack owns is repeated (its `required_providers`, its provider configurations — aliased ones included — its backend, the variables it declares), because Terraform rejects those twice. A `provider "google" { alias = "eu" }` block becomes a second `GoogleProvider(alias: 'eu', ...)` on the Stack, and a resource’s `provider = google.eu` becomes `provider: googleEuProvider` on its factory, where `final googleEuProvider = addProvider(GoogleProvider(alias: 'eu', ...))` registers that configuration (`provider = google-beta` on a GA type is translated the same way, as `googleBetaProvider`). Expressions the migrator cannot type — templates, function calls, conditionals, `local.x` — become `TfArg.expression(...)`, verbatim, on any argument. A `timeouts { ... }` block becomes `const TfTimeouts(create: Duration(minutes: 30), ...)` on the factory, a bare `terraform.workspace` becomes `TfArg.workspace()`, and a partial backend (`backend "gcs" {}`, whose values come from `terraform init -backend-config`) becomes the typed backend with those parameters left out. A `module` block becomes `addModule(...)`: a call whose `source` points at a directory in the scanned tree gets a typed wrapper generated from that module’s `variable` and `output` blocks (`CloudRunModule('cloud_run_bff', source: '../modules/cloud_run', name: TfArg.literal('app-bff'))`, and `bff.serviceName` wherever a resource attribute goes), while registry, git and out-of-tree sources become a bare `ModuleCall` with `source` / `version` verbatim and an untyped `inputs` map — either way the address keeps its `module..` prefix. A literal `count` or `for_each` is unrolled into one resource per instance — `google_pubsub_topic.t[0]` becomes `google_pubsub_topic.t_0`, `google_pubsub_topic.t["eu"]` becomes `google_pubsub_topic.t_eu` — with `count.index` / `each.key` / `each.value` substituted, every reference in the module (indexed, splat or bare) pointed at the new addresses, and a `moved` entry per instance (`Stack.addMoved`) so the plan shows moves, never a destroy and create; the module’s own `moved` blocks follow their targets into the Stack. What keeps a block in Terraform today: a type outside the four curated catalogs, a `count` / `for_each` that is not a literal (its instance set cannot be known without evaluating it) or one on a `module` call (whose instances are addressed `module.x[0]`), a `module` call with a computed `source` or an input the called module does not declare, `dynamic` / `provisioner`, a `timeouts` key that is not a Terraform operation or whose value is not a duration string (or is finer than a microsecond), a `provider = x.alias` the module does not configure (a child module that does not configure it declares `configuration_aliases` instead; a provider block inside the child stays in Terraform, with the resource that selects it), an argument with no Dart parameter, an expression inside a typed list, a sensitive literal (never copied into Dart), and `depends_on` on a resource that stays in Terraform. `MIGRATION.md` lists every kept block with its reason and file, and every unrolled one with its new addresses. A block’s own leading comments come across with it: the `#`, `//` or `/* */` lines above a `resource`, `data` or `module` block are written as `//` lines above the `add(...)` it became, so the documentation lives where the code now does. A `count` / `for_each` block documents its unrolled instances once, above the first. The migrator’s own `# terradart-migrate:` reasons are not carried — they are annotations, not prose, and a sidecar migrated again is full of them. A merged Stack (`--merge-envs`) carries none: its bodies are lined up statement by statement across environments that may document the same block differently. ## Finishing the migration [Section titled “Finishing the migration”](#finishing-the-migration) The migrator does the mechanical part once; what it kept is yours to port, by hand or with a coding agent (the [agent skill](/docs/start/ai-agent/) describes the same loop): 1. Pick a block from `MIGRATION.md` whose reason you can resolve — an argument the migrator had no typed slot for, a `depends_on` on a block you have since ported, a literal `locals` entry that could be a Dart `final`. 2. Write it in the Stack with the same `localName`, so its address does not change, and delete it from the sidecar — and the `addExternalBlock('
')` line the migrator wrote for it when the Stack reads it (synth accepts a reference to a block it does not hold only once it is declared external). A local leaves `locals.tf` only once nothing still in the sidecar reads it. 3. `terradart synth` (or `dart run bin/infra.dart`), then `terradart plan` — with `--env ` when the package was migrated with `--merge-envs`. *No changes* means the port is faithful; anything else is the diff to fix in Dart. A later catalog release may cover a type that is `not in any catalog` today: `terradart migrate --report` over a copy of the sidecar files lists what translates now. ## Modules and environments [Section titled “Modules and environments”](#modules-and-environments) The migrator infers the role of each directory: * a directory some `module` block’s `./` or `../` `source` points at is a **child**, migrated in child-module mode: providers are registered without configuration (synth emits only `required_providers`), an alias the child selects without configuring is registered with `addConfigurationAlias` (synth emits `configuration_aliases`), `variable` becomes `variable(...)`, `output` becomes `addOutput`, and a provider configuration or backend found there stays in the sidecar. The caller’s `module` block becomes `addModule(...)`, whose `source` still points at the child’s directory in the mirrored `tf-out/` tree, so plan addresses keep their `module..` prefix; * every other directory is a **root**; roots sharing a parent directory (`envs/dev`, `envs/prod`) are **environment** siblings. Each keeps its own Stack, Terraform directory and backend; `MIGRATION.md` reports the addresses they share, the ones only some of them declare, and the arguments that differ. `--merge-envs` folds the group into one Stack instead (below); * a directory where nothing translates — no curated resource and no known provider — gets no Stack and stays Terraform; its sidecar files are its whole output. `--roots ` forces a directory to be a root even when a `module` block references it; `--env-dirs ` names the environments explicitly. Both are relative to `--dir` and repeatable. ### One Stack for every environment [Section titled “One Stack for every environment”](#one-stack-for-every-environment) `envs/dev` and `envs/prod` are usually the same configuration with a handful of different values. Migrated one Stack each, that duplication carries straight into Dart. `--merge-envs` folds each group into one Stack instead: lib/app\_stack.dart ```dart import 'package:terradart_google/provider.dart'; import 'package:terradart_google/storage.dart'; import 'env.dart'; final class AppStack extends Stack { AppStack({required this.env}) : super( providers: [const GoogleProvider()], backend: GcsBackend(bucket: env.backendBucket, prefix: env.backendPrefix), ) { add(GoogleStorageBucket( 'assets', name: .literal(env.assetsName), // "app-dev-assets" / "app-prod-assets" location: .literal('ASIA-NORTHEAST1'), )); if (env.isProd) { add(GoogleStorageBucket( 'backups', name: .literal('app-prod-backups'), location: .literal('ASIA-NORTHEAST1'), )); } } final Env env; } ``` `lib/env.dart` holds the generated enum — one member per root, carrying its `path` (`tf-out/`), every value the roots disagree on, and a flag per group of blocks only some of them declare (shortened here): lib/env.dart ```dart enum Env { dev( path: 'dev', assetsName: 'app-dev-assets', backendBucket: 'app-dev-tfstate', backendPrefix: 'infra/dev', ), prod( path: 'prod', assetsName: 'app-prod-assets', backendBucket: 'app-prod-tfstate', backendPrefix: 'infra/prod', isProd: true, ); const Env({ required this.path, required this.assetsName, required this.backendBucket, required this.backendPrefix, this.isProd = false, }); final String path; final String assetsName; final String backendBucket; final String backendPrefix; /// True in prod: blocks only that environment declares. final bool isProd; } ``` `dart run bin/infra.dart` and `terradart synth` write every environment into its own `tf-out/` directory. The generated `bin/infra.dart` calls `runEnvironments` over the `Env` enum, with `dir` set to `tf-out/`, so `terradart plan --env dev` plans that environment and `dart run bin/infra.dart --env dev` writes only it. Omitting `--env` writes every environment, and `terradart plan` then asks which one to run. A value lifts when every root writes it as a plain scalar: a resource argument, a `module` call input, a `variable` default or description, a provider argument, a backend argument. The constant is typed as the argument takes it, so an enum-valued one is a typed member (`storageClass: env.assetsStorageClass`, with `BucketStorageClass.nearline` on the enum). Anything else — a reference, a nested block, a list, an interpolated string, a `sensitive` variable’s default (never copied into Dart), a different provider or backend, a different block order — keeps one Stack per root, with the reason in `MIGRATION.md`. What merging never changes is the plan: the fixture gate proves the merged Stack synthesizes, per environment, exactly the JSON the separate Stacks did. ### The workspace as a parameter [Section titled “The workspace as a parameter”](#the-workspace-as-a-parameter) `--lift-workspace` turns `terraform.workspace` into a `workspace` parameter on the Stack: a bare reference becomes `.literal(workspace)`, a template around it becomes Dart interpolation (`.literal('orders-$workspace')`), and one inside a list or map becomes the value. `terradart plan --workspace prod` and `dart run bin/infra.dart --workspace prod` then synthesize for that workspace by name (the default name is `default`), and `terradart` selects it after `init`. It is opt-in because it moves the decision: the JSON names a workspace instead of leaving `${terraform.workspace}` for `terraform workspace select`, so it is faithful for the workspace it names and only that one. A template mixing the workspace with another reference stays a Terraform expression, with a warning naming it. ## Options [Section titled “Options”](#options) | Flag | Meaning | | :----------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--report` | Report what a migration would translate and keep, per type, and write nothing. Takes no `--out`; `--dir` defaults to the current directory. | | `--dir ` | The Terraform source tree to migrate. Never written. | | `--out ` | Where the Dart package goes. Must not exist or be empty unless `--force` is given; nothing is written outside it. | | `--name ` | The package name and the root Stack class. Defaults to the base name of `--dir`. | | `--roots ` | Treat `` as a root module even when a `module` block references it. | | `--env-dirs ` | Root directories that are environments of one deployment. | | `--merge-envs` | Fold each group of environment siblings into one Stack taking a generated `Env` enum. `bin/infra.dart` calls `runEnvironments`, so `terradart plan --env ` runs one environment. | | `--lift-workspace` | Turn `terraform.workspace` into a `workspace` parameter on the Stack. | | `--json` | Print the report as JSON instead of the summary. | | `--force` | Write into a non-empty `--out`, overwriting only the files the migrator generates. | Exit codes follow sysexits: `0` success, `64` usage, `65` unreadable input or no Terraform files, `70` internal error, `73` output directory not empty or not writable. ## Library [Section titled “Library”](#library) `terradart migrate` is the command. The pipeline stays a library in `terradart_migrate`: `scanModuleTree` reads the tree, `migrateTree` produces every file, `migrateModule` migrates one module. See the [package README](https://github.com/nozomi-koborinai/terradart/tree/main/packages/terradart_migrate) for the API and the conversion rules, and the [migrator design](https://github.com/nozomi-koborinai/terradart/issues/655) for how the migration manifests tie the recipe to the generated factories. ## What comes next [Section titled “What comes next”](#what-comes-next) The blockers above shrink as the runtime grows. Progress is tracked on the [migrator epic](https://github.com/nozomi-koborinai/terradart/issues/80). # Appwrite > terradart_appwrite — the appwrite/appwrite provider as typed Dart, for the backend of a Flutter app. [`terradart_appwrite`](https://pub.dev/packages/terradart_appwrite) wraps the official `appwrite/appwrite` provider — every resource and data source at its pinned version. A Flutter team on Appwrite can declare its project, databases, storage, functions and messaging in Dart, next to the app that uses them. ## Install [Section titled “Install”](#install) pubspec.yaml ```yaml dependencies: terradart_core: ^0.35.0 terradart_appwrite: ^0.35.0 ``` ## Terraform, not OpenTofu [Section titled “Terraform, not OpenTofu”](#terraform-not-opentofu) Appwrite currently needs Terraform on your `PATH`. The `appwrite/appwrite` provider is published to the Terraform registry only, and OpenTofu cannot install it from there. A Stack that uses it runs on `terraform` from `PATH` without being asked; pin it in `pubspec.yaml` (`terradart init --provider appwrite` writes this) or pass `--engine terraform`: pubspec.yaml ```yaml terradart: engine: terraform ``` With only OpenTofu available, or with `--engine tofu`, `terradart plan`, `apply`, `destroy` and `outputs` stop before `init` and say so ([The engine](/docs/cli/#the-engine)). ## Credentials [Section titled “Credentials”](#credentials) `AppwriteProvider` takes the endpoint and the project or organization, but no API key, so credentials never enter the synthesized JSON, and synth needs none. `terradart plan` and `terradart apply` authenticate with `APPWRITE_API_KEY` (project resources) or `APPWRITE_ORGANIZATION_API_KEY` (organization resources). With them in place, [`terradart apply`](/docs/cli/) synthesizes the Stack and applies it. ## A backend for a Flutter app [Section titled “A backend for a Flutter app”](#a-backend-for-a-flutter-app) lib/backend\_stack.dart ```dart import 'package:terradart_appwrite/provider.dart'; import 'package:terradart_appwrite/storage.dart'; import 'package:terradart_appwrite/tablesdb.dart'; final class BackendStack extends Stack { BackendStack() : super( providers: [ const AppwriteProvider( endpoint: 'https://cloud.appwrite.io/v1', projectId: 'my-project', ), ], appExports: AppExports('lib/generated/backend_stack.app.dart'), ) { final db = add(AppwriteTablesdb('main', name: .literal('main'))); final notes = add(AppwriteTablesdbTable( 'notes', databaseId: db.ref, // only an AppwriteTablesdb fits here name: .literal('notes'), rowSecurity: .literal(true), )); final uploads = add(AppwriteStorageBucket( 'uploads', name: .literal('uploads'), fileSecurity: .literal(true), maximumFileSize: .literal(10485760), )); addOutput('database_id', db.id); addOutput('notes_table_id', notes.id); addOutput('uploads_bucket_id', uploads.id); } } ``` Appwrite assigns the IDs on create, so they are outputs, each with a typed getter on `BackendStackOutputs` (`databaseId`, `notesTableId`, `uploadsBucketId`). For the Flutter app, add `addDartDefineOutput()` to the Stack: `terradart apply` (or `terradart outputs`) then writes the define file `flutter build --dart-define-from-file` reads, and `const BackendStackOutputs.fromDartDefine()` reads the IDs in the app — see [Outputs in client apps](/docs/client-outputs/). A script reads the same define file at run time with `BackendStackOutputs.fromEnvironment(...)`. Who may read or change a bucket, file, table or row is a list of `AppwritePermission` (from `package:terradart_appwrite/auth.dart`), one per action and role, so a misspelled role does not compile: lib/uploads\_stack.dart ```dart import 'package:terradart_appwrite/auth.dart'; import 'package:terradart_appwrite/provider.dart'; import 'package:terradart_appwrite/storage.dart'; final class UploadsStack extends Stack { UploadsStack() : super( providers: [ const AppwriteProvider( endpoint: 'https://cloud.appwrite.io/v1', projectId: 'my-project', ), ], ) { final editors = add( AppwriteAuthTeam('editors', name: .literal('Editors')), ); add(AppwriteStorageBucket( 'uploads', name: .literal('uploads'), permissions: .literal([ .read(.any), .create(.users(verified: true)), .write(.team(editors.ref, role: 'owner')), ]), )); } } ``` It synthesizes to the provider’s strings (`read("any")`, `write("team:${appwrite_auth_team.editors.id}/owner")`). The roles are `.any`, `.guests`, `.users()`, `.user(user.ref)`, `.team(team.ref)`, `.member(id)` and `.label(name)`; `.literal('read("any")')` takes a permission string as it is. Inputs with a fixed value set are Dart enums, taken from the provider’s validators. A sensitive input, such as a backup provider’s secret key, is best passed as a Terraform variable (`final key = variable('backup_secret_key', sensitive: true)`, then `secretKey: key`) so its value arrives at apply time rather than in the Dart source. ## Examples [Section titled “Examples”](#examples) * [`examples/appwrite_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/appwrite_quickstart) — every Appwrite factory in one Stack, with placeholder IDs to replace before applying. ## Reference [Section titled “Reference”](#reference) * [`terradart_appwrite` API docs](https://pub.dev/documentation/terradart_appwrite/latest/) * [Appwrite coverage](/docs/coverage/appwrite/) — every factory, its barrel and its example * [`lib/src/_catalog.g.dart`](https://github.com/nozomi-koborinai/terradart/blob/main/packages/terradart_appwrite/lib/src/_catalog.g.dart) — Terraform type → Dart class and import, for every factory # AWS > Run a Dart backend on Lambda or ECS Express Mode and a Flutter Web build on S3 + CloudFront, with terradart_aws. [`terradart_aws`](https://github.com/nozomi-koborinai/terradart/tree/main/packages/terradart_aws) wraps the full `hashicorp/aws` provider — every resource and data source at its exact pin. This page covers the shapes a Dart team usually needs on AWS: a Dart function on Lambda, that function as an HTTP API in front of DynamoDB, a Dart server on ECS Express Mode, and a Flutter Web build on S3 behind CloudFront. ## Install [Section titled “Install”](#install) pubspec.yaml ```yaml dependencies: terradart_core: ^0.35.0 terradart_aws: ^0.35.0 ``` Check [pub.dev](https://pub.dev/packages/terradart_aws) for the latest patch, then run `dart pub get`. ## Credentials [Section titled “Credentials”](#credentials) **Credentials never appear in synth output.** `AwsProvider` has no `access_key`, `secret_key` or `token` parameter, so there is nothing secret to write into `tf-out/`. `terradart plan` and `terradart apply` authenticate through the AWS SDK credential chain: * `AWS_PROFILE` with your shared config, including IAM Identity Center (SSO) profiles after `aws sso login` * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` * an instance role through IMDS, or an ECS task role, when `terradart` runs on AWS Synth needs no credentials at all. `assumeRole`, `defaultTags`, `ignoreTags` and `endpoints` are typed settings on `AwsProvider`. ## A Dart function on Lambda [Section titled “A Dart function on Lambda”](#a-dart-function-on-lambda) Lambda has no Dart runtime, but its `provided.al2023` custom runtime runs any Linux binary named `bootstrap`. `dart compile exe` produces one: lib/hello\_lambda\_stack.dart ```dart import 'package:terradart_aws/cloudwatch.dart'; import 'package:terradart_aws/data.dart'; import 'package:terradart_aws/iam.dart'; import 'package:terradart_aws/lambda.dart'; import 'package:terradart_aws/provider.dart'; final class HelloLambdaStack extends Stack { HelloLambdaStack() : super(providers: [ const AwsProvider( region: 'us-east-1', defaultTags: {'app': 'hello-dart'}, ), ]) { final trust = DataAwsIamPolicyDocument( 'lambda_trust', statement: [ DataIamPolicyDocumentStatement( actions: .literal(['sts:AssumeRole']), principals: [ .new( type: .literal('Service'), identifiers: .literal(['lambda.amazonaws.com']), ), ], ), ], ); add(trust); final role = AwsIamRole( 'hello', name: .name(.literal('hello-dart')), assumeRolePolicy: trust.json, ); add(role); add(AwsIamRolePolicyAttachment( 'hello_logs', role: role.ref, policyArn: .literal( 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', ), )); final logs = AwsCloudwatchLogGroup( 'hello', name: .name(.literal('/aws/lambda/hello-dart')), retentionInDays: .literal(14), ); add(logs); final fn = AwsLambdaFunction( 'hello', functionName: .literal('hello-dart'), role: role.ref, runtime: .providedAl2023, handler: .literal('bootstrap'), code: .filename(.literal('../build/bootstrap.zip')), loggingConfig: LambdaFunctionLoggingConfig( logFormat: .text, logGroup: logs.ref, ), ); add(fn); add(AwsLambdaFunctionUrl( 'hello', functionName: fn.ref, authorizationType: .none, )); } } ``` `policyArn` takes an `AwsIamPolicy`: pass `policy.ref` for a policy the stack creates, or `.literal(...)` with the ARN of an AWS managed policy, as here. Build the zip before `terradart apply`. Synth does not build it, and the `--target-os` / `--target-arch` flags cross-compile from macOS or Windows (Dart 3.8 or later): ```bash mkdir -p build dart compile exe bin/bootstrap.dart -o build/bootstrap \ --target-os linux --target-arch x64 (cd build && zip bootstrap.zip bootstrap) ``` Then hand the Stack to `runStack` in `bin/infra.dart` and apply it: bin/infra.dart ```dart import 'package:my_app/hello_lambda_stack.dart'; import 'package:terradart_core/terradart_core.dart'; Future main(List args) => runStack(args, HelloLambdaStack.new); ``` ```bash AWS_PROFILE=my-profile terradart apply ``` [`terradart apply`](/docs/cli/) runs `bin/infra.dart`, then `init` and `apply` in `tf-out/`; `terradart plan` stops at the plan. The other Stacks on this page run the same way. The runnable version, including a minimal `bin/bootstrap.dart` that long-polls the Lambda Runtime API, is [`examples/aws_lambda_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_lambda_quickstart). ## An HTTP API in front of DynamoDB [Section titled “An HTTP API in front of DynamoDB”](#an-http-api-in-front-of-dynamodb) The same custom runtime can sit behind an API Gateway HTTP API and talk to one DynamoDB table. The execution role’s inline policy names that table and that log group — `GetItem`, `PutItem` and `DeleteItem` on the table ARN, and log writes on the function’s log streams — instead of a managed policy with `Resource: "*"`. The table name is a Terraform output. `outputEnvironment()` puts it in the function’s environment, and the handler reads it with the generated outputs reader. The API’s invoke URL is an output too, but it stays off the function: the stage that publishes the URL is what invokes the function, so putting it in the function’s environment would be a cycle. A client reads both after apply. lib/serverless\_api\_stack.dart ```dart import 'package:terradart_aws/apigatewayv2.dart'; import 'package:terradart_aws/cloudwatch.dart'; import 'package:terradart_aws/data.dart'; import 'package:terradart_aws/dynamodb.dart'; import 'package:terradart_aws/iam.dart'; import 'package:terradart_aws/lambda.dart'; import 'package:terradart_aws/provider.dart'; final class ServerlessApiStack extends Stack { ServerlessApiStack() : super( providers: [ const AwsProvider( region: 'us-east-1', defaultTags: {'app': 'items-api'}, ), ], appExports: AppExports('lib/generated/serverless_api_stack.app.dart'), ) { final trust = add(DataAwsIamPolicyDocument( 'lambda_trust', statement: [ DataIamPolicyDocumentStatement( actions: .literal(['sts:AssumeRole']), principals: [ .new( type: .literal('Service'), identifiers: .literal(['lambda.amazonaws.com']), ), ], ), ], )); final role = add(AwsIamRole( 'api', name: .name(.literal('items-api')), assumeRolePolicy: trust.json, )); final table = add(AwsDynamodbTable( 'items', name: .literal('items'), billingMode: .payPerRequest, hashKey: .literal('id'), attribute: [ DynamodbTableAttribute(name: .literal('id'), type: .s), ], )); final logs = add(AwsCloudwatchLogGroup( 'api', name: .name(.literal('/aws/lambda/items-api')), retentionInDays: .literal(14), )); final policy = add(DataAwsIamPolicyDocument( 'api', statement: [ DataIamPolicyDocumentStatement( actions: .literal(['logs:CreateLogStream', 'logs:PutLogEvents']), resources: .literal([ logs.arn.interpolation, '${logs.arn.interpolation}:*', ]), ), DataIamPolicyDocumentStatement( actions: .literal([ 'dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:DeleteItem', ]), resources: .literal([table.arn.interpolation]), ), ], )); add(AwsIamRolePolicy( 'api', name: .name(.literal('items-api')), role: role.ref, policy: policy.json, )); addOutput('table_name', table.name); final fn = add(AwsLambdaFunction( 'api', functionName: .literal('items-api'), role: role.ref, runtime: .providedAl2023, handler: .literal('bootstrap'), code: .filename(.literal('build/bootstrap.zip')), environment: LambdaFunctionEnvironment( variables: outputEnvironment(only: ['table_name']).variables, ), loggingConfig: LambdaFunctionLoggingConfig( logFormat: .text, logGroup: logs.ref, ), )); final api = add(AwsApigatewayv2Api( 'items', name: .literal('items-api'), protocolType: .http, )); final integration = add(AwsApigatewayv2Integration( 'items', apiId: api.id, integrationType: .awsProxy, integrationUri: fn.invokeArn, payloadFormatVersion: .v2p0, )); final route = add(AwsApigatewayv2Route( 'get', apiId: api.id, routeKey: .literal('GET /items/{id}'), target: .literal('integrations/${integration.id.interpolation}'), authorizationType: .none, )); final stage = add(AwsApigatewayv2Stage( 'default', apiId: api.id, name: .literal(r'$default'), autoDeploy: .literal(true), dependsOn: [route], )); add(AwsLambdaPermission( 'get', statementId: .statementId(.literal('apigw-get')), action: .literal('lambda:InvokeFunction'), functionName: fn.ref, principal: .literal('apigateway.amazonaws.com'), sourceArn: .literal( '${api.executionArn.interpolation}/${stage.name.interpolation}/GET/items/*', ), )); addOutput('api_url', stage.invokeUrl); } } ``` bin/synth\_items.dart ```dart import 'package:my_app/serverless_api_stack.dart'; Future main() async { await ServerlessApiStack().writeTo('tf-out'); } ``` The handler and the client import the file synth writes. A missing variable fails at startup instead of becoming an empty string: lib/items\_client.dart ```dart import 'dart:io'; import 'generated/serverless_api_stack.app.dart'; /// TABLE_NAME, from the function environment the stack passed in. String tableName() => ServerlessApiStackOutputs.fromEnvironment(Platform.environment).tableName; /// API_URL, exported from the define file `terradart outputs` writes. String apiUrl() => ServerlessApiStackOutputs.fromEnvironment(Platform.environment).apiUrl; ``` The runnable version — three routes, the SigV4 DynamoDB client, `bin/bootstrap.dart` and `bin/client.dart` — is [`examples/aws_serverless_api_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_serverless_api_quickstart). ## A Dart server on ECS Express Mode [Section titled “A Dart server on ECS Express Mode”](#a-dart-server-on-ecs-express-mode) For a long-running `dart:io` or shelf server, use ECS Express Mode. App Runner stopped accepting new customers on 2026-04-30. An `AwsEcsExpressGatewayService` takes one container image and provisions the load balancer, target group, security groups and auto scaling itself, using an infrastructure role: ```dart import 'package:terradart_aws/data.dart'; import 'package:terradart_aws/ecs.dart'; import 'package:terradart_aws/iam.dart'; import 'package:terradart_aws/provider.dart'; final class DartServerStack extends Stack { DartServerStack({required String image}) : super(providers: [const AwsProvider(region: 'us-east-1')]) { AwsIamRole roleFor(String name, String service, String policyArn) { final trust = DataAwsIamPolicyDocument( '${name}_trust', statement: [ DataIamPolicyDocumentStatement( actions: .literal(['sts:AssumeRole']), principals: [ .new( type: .literal('Service'), identifiers: .literal([service]), ), ], ), ], ); add(trust); final role = AwsIamRole( name, name: .name(.literal('dart-server-$name')), assumeRolePolicy: trust.json, ); add(role); add(AwsIamRolePolicyAttachment( name, role: role.ref, policyArn: .literal(policyArn), )); return role; } final execution = roleFor( 'execution', 'ecs-tasks.amazonaws.com', 'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy', ); final infrastructure = roleFor( 'infrastructure', 'ecs.amazonaws.com', 'arn:aws:iam::aws:policy/service-role/' 'AmazonECSInfrastructureRoleforExpressGatewayServices', ); final cluster = AwsEcsCluster( 'server', name: .literal('dart-server'), ); add(cluster); add(AwsEcsExpressGatewayService( 'server', serviceName: .literal('dart-server'), cluster: cluster.ref, executionRoleArn: execution.ref, infrastructureRoleArn: infrastructure.ref, cpu: .literal('256'), memory: .literal('512'), healthCheckPath: .literal('/'), primaryContainer: [ EcsExpressGatewayServicePrimaryContainer( image: .literal(image), containerPort: .literal(8080), ), ], )); } } ``` The image must exist before `terradart apply`. A two-stage Dockerfile that runs `dart compile exe` and copies the binary onto `scratch` keeps it small. The service bills by the hour while it runs, and without network settings it uses the account’s default VPC. After apply, the service’s public endpoint is in its `ingress_paths` attribute. The runnable version, with an ECR repository, a log group, the `dart:io` server and its `Dockerfile`, is [`examples/aws_ecs_express_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_ecs_express_quickstart). Its README lists what apply needs and what it bills. ## A Flutter Web build on S3 + CloudFront [Section titled “A Flutter Web build on S3 + CloudFront”](#a-flutter-web-build-on-s3--cloudfront) `flutter build web` produces static files. Serve them from a private S3 bucket that only CloudFront can read, through an origin access control and a bucket policy scoped to the distribution: ```dart import 'package:terradart_aws/cloudfront.dart'; import 'package:terradart_aws/data.dart'; import 'package:terradart_aws/provider.dart'; import 'package:terradart_aws/s3.dart'; final class FlutterWebStack extends Stack { FlutterWebStack() : super(providers: [const AwsProvider(region: 'us-east-1')]) { final bucket = AwsS3Bucket( 'site', name: .bucketPrefix(.literal('flutter-web-')), ); add(bucket); add(AwsS3BucketPublicAccessBlock( 'site', bucket: bucket.ref, blockPublicAcls: .literal(true), blockPublicPolicy: .literal(true), ignorePublicAcls: .literal(true), restrictPublicBuckets: .literal(true), )); final oac = AwsCloudfrontOriginAccessControl( 'site', name: .literal('flutter-web'), originAccessControlOriginType: .s3, signingBehavior: .always, signingProtocol: .sigv4, ); add(oac); final cachePolicy = DataAwsCloudfrontCachePolicy( 'caching_optimized', name: .literal('Managed-CachingOptimized'), ); add(cachePolicy); final distribution = AwsCloudfrontDistribution( 'site', enabled: .literal(true), defaultRootObject: .literal('index.html'), origin: [ CloudfrontDistributionOrigin( originId: .literal('site'), domainName: bucket.bucketRegionalDomainName, originAccessControlId: oac.ref, ), ], defaultCacheBehavior: CloudfrontDistributionDefaultCacheBehavior( targetOriginId: .literal('site'), viewerProtocolPolicy: CloudfrontDistributionViewerProtocolPolicy.redirectToHttps, allowedMethods: .literal(['GET', 'HEAD']), cachedMethods: .literal(['GET', 'HEAD']), cachePolicyId: cachePolicy.ref, ), // Flutter Web routes are client-side: serve index.html for unknown paths. customErrorResponse: [ for (final code in [403, 404]) CloudfrontDistributionCustomErrorResponse( errorCode: .literal(code), responseCode: .literal(200), responsePagePath: .literal('/index.html'), ), ], restrictions: CloudfrontDistributionRestrictions( geoRestriction: .new( restrictionType: CloudfrontDistributionRestrictionType.none, ), ), viewerCertificate: CloudfrontDistributionViewerCertificate( cloudfrontDefaultCertificate: .literal(true), ), ); add(distribution); final readFromCloudFront = DataAwsIamPolicyDocument( 'site_bucket', statement: [ DataIamPolicyDocumentStatement( actions: .literal(['s3:GetObject']), resources: .literal(['${bucket.arn.interpolation}/*']), principals: [ .new( type: .literal('Service'), identifiers: .literal(['cloudfront.amazonaws.com']), ), ], condition: [ .new( test: .literal('StringEquals'), variable: .literal('AWS:SourceArn'), values: .literal([distribution.arn.interpolation]), ), ], ), ], ); add(readFromCloudFront); add(AwsS3BucketPolicy( 'site', bucket: bucket.ref, policy: readFromCloudFront.json, )); } } ``` This serves the app on the distribution’s `*.cloudfront.net` domain. For a custom domain, add an `AwsAcmCertificate` in `us-east-1` (CloudFront only accepts certificates from that region), validate it through an `AwsRoute53Record`, and point `viewerCertificate.acmCertificateArn` and `aliases` at it. After `terradart apply`, upload the build and invalidate the cache: ```bash flutter build web aws s3 sync build/web s3:// --delete aws cloudfront create-invalidation --distribution-id --paths '/*' ``` The runnable version, with the custom domain, ACM certificate and Route 53 aliases wired in, is [`examples/aws_static_site_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_static_site_quickstart). Its README lists the hosted zone apply needs. ## Examples [Section titled “Examples”](#examples) The rest of the catalog sits on the same per-service barrels, such as `package:terradart_aws/ec2.dart`, `rds.dart`, `dynamodb.dart` and `sqs.dart`. * [`examples/aws_lambda_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_lambda_quickstart) — a Dart binary on Lambda behind a function URL. * [`examples/aws_serverless_api_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_serverless_api_quickstart) — that binary as an HTTP API in front of DynamoDB, with least-privilege IAM and typed outputs. * [`examples/aws_ecs_express_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_ecs_express_quickstart) — a Dart server on ECS Express Mode. * [`examples/aws_static_site_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_static_site_quickstart) — a Flutter Web build on S3 + CloudFront. * [`examples/aws_leftover_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/aws_leftover_quickstart) — every other factory with placeholder values; it synthesizes and validates, and is never applied. ## Reference [Section titled “Reference”](#reference) * [Getting started](/docs/getting-started/) for Stacks, synth, and the outputs / constants boundary * [How TerraDart is built](/docs/how-its-built/) for how provider packages are generated * [`terradart_aws` README](https://github.com/nozomi-koborinai/terradart/tree/main/packages/terradart_aws) for the typed nested helpers on the most deeply nested resources * [Examples](https://github.com/nozomi-koborinai/terradart/tree/main/examples) for runnable stacks * [AWS coverage](/docs/coverage/aws/) for every factory, its barrel and its example # Cloudflare > terradart_cloudflare — every cloudflare/cloudflare resource and data source as typed Dart, for the DNS and edge in front of your Dart app. [`terradart_cloudflare`](https://pub.dev/packages/terradart_cloudflare) wraps the official `cloudflare/cloudflare` provider (v5) — every resource and data source at its pinned version. Its place in a TerraDart project is the edge around your Dart app: the zone, DNS records, rules and certificates in front of a backend on Cloud Run, AWS or Firebase Hosting, in the same Dart codebase as that backend. ## Install [Section titled “Install”](#install) pubspec.yaml ```yaml dependencies: terradart_core: ^0.35.0 terradart_cloudflare: ^0.35.0 ``` ## Credentials [Section titled “Credentials”](#credentials) `CloudflareProvider` takes no token, so credentials never enter the synthesized JSON, and synth needs none. `terradart plan` and `terradart apply` authenticate with `CLOUDFLARE_API_TOKEN` (or the other `CLOUDFLARE_*` variables the provider reads). With them in place, [`terradart apply`](/docs/cli/) synthesizes the Stack and applies it. ## A zone in front of your API [Section titled “A zone in front of your API”](#a-zone-in-front-of-your-api) lib/edge\_stack.dart ```dart import 'package:terradart_cloudflare/dns.dart'; import 'package:terradart_cloudflare/provider.dart'; import 'package:terradart_cloudflare/zone.dart'; final class EdgeStack extends Stack { EdgeStack({required String accountId}) : super( providers: [const CloudflareProvider()], appExports: AppExports('lib/generated/edge_stack.app.dart'), ) { final zone = add(CloudflareZone( 'main', name: .literal('example.com'), account: ZoneAccount(id: .literal(accountId)), )); final api = add(CloudflareDnsRecord( 'api', zoneId: zone.ref, // only a CloudflareZone fits here name: .literal('api.example.com'), type: .cname, ttl: .literal(1), content: .content(.literal('ghs.googlehosted.com')), proxied: .literal(true), )); addConstant('apiHost', .ref(api.name)); } } ``` The app reads the host it is served from as `EdgeStackConstants.apiHost` instead of a second copy of the string. The Stack can sit next to a Google, AWS or Appwrite Stack in the same package, or share one Stack with them by listing both providers. ## What the types carry [Section titled “What the types carry”](#what-the-types-carry) * **Enums.** Inputs with a fixed value set, such as a DNS record’s `type`, are Dart enums (`.cname`). The sets come from the provider’s documentation and its Go validators, re-extracted on every schema bump. * **Sealed choices.** Arguments the provider declares mutually exclusive are one sealed argument: a DNS record has `content` *or* structured `data`, written `content: .content(...)` or `content: .data(...)`. * **Typed nested objects.** The plugin-framework provider describes objects as nested attributes; each becomes a helper class (`ZoneAccount`) rather than a `Map`. * **References.** `zoneId: zone.ref`, `accountId: account.ref`: an argument that names another resource takes that resource. A user group’s members take the account member (`CloudflareUserGroupMembers(userGroupId: group.ref, members: [.new(id: member.ref)])`). ## Examples [Section titled “Examples”](#examples) * [`examples/cloudflare_dns_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/cloudflare_dns_quickstart) — the zone and record above, runnable. * [`examples/cloudflare_leftover_quickstart`](https://github.com/nozomi-koborinai/terradart/tree/main/examples/cloudflare_leftover_quickstart) — every other factory with placeholder values; it synthesizes and validates, but is never applied. ## Reference [Section titled “Reference”](#reference) * [`terradart_cloudflare` API docs](https://pub.dev/documentation/terradart_cloudflare/latest/) * [Cloudflare coverage](/docs/coverage/cloudflare/) — every factory, its barrel and its example * [`lib/src/_catalog.g.dart`](https://github.com/nozomi-koborinai/terradart/blob/main/packages/terradart_cloudflare/lib/src/_catalog.g.dart) — Terraform type → Dart class and import, for every factory # Google Cloud > terradart_google and terradart_google_beta — the whole hashicorp/google catalog plus the beta-only types, as typed Dart factories. [`terradart_google`](https://pub.dev/packages/terradart_google) wraps the whole GA `hashicorp/google` catalog — every resource and data source — and [`terradart_google_beta`](https://pub.dev/packages/terradart_google_beta) adds the types that exist only in `hashicorp/google-beta` (Firebase app registration, preview services). A type that is also in GA stays in `terradart_google`. Both are generated from the provider schema and the Magic Modules definitions Google publishes, so enums, exactly-one-of groups and resource references carry over as Dart types. ## Install [Section titled “Install”](#install) pubspec.yaml ```yaml dependencies: terradart_core: ^0.35.0 terradart_google: ^0.35.0 terradart_google_beta: ^0.35.0 # only for beta-only types ``` ## Credentials [Section titled “Credentials”](#credentials) `GoogleProvider` takes no key, so credentials never enter the synthesized JSON, and synth needs none. `terradart plan` and `terradart apply` authenticate with Application Default Credentials (`gcloud auth application-default login`), or a service account in CI. With them in place, [`terradart apply`](/docs/cli/) synthesizes the Stack and applies it. ## Imports [Section titled “Imports”](#imports) Each Google service is its own barrel, so IDE completion stays scoped to what a file uses: `package:terradart_google/cloud_run.dart`, `pubsub.dart`, `bigquery.dart`, `compute.dart`, and so on, plus `provider.dart` for `GoogleProvider`. The [Google Cloud coverage](/docs/coverage/google/) page lists every factory with its barrel and a runnable example. `package:terradart_google/terradart_google.dart` still re-exports everything. ## Enabling APIs [Section titled “Enabling APIs”](#enabling-apis) A fresh project has most APIs off. `enableApis` registers a `google_project_service` for every API the factories of the given barrels need, plus a propagation wait (`TimeSleep` from [`terradart_time`](https://pub.dev/packages/terradart_time)) that resources depend on: lib/events\_stack.dart ```dart import 'package:terradart_google/project.dart'; import 'package:terradart_google/provider.dart'; import 'package:terradart_google/pubsub.dart'; import 'package:terradart_time/terradart_time.dart'; final class EventsStack extends Stack { EventsStack({required String projectId}) : super(providers: [GoogleProvider(project: projectId), const TimeProvider()]) { final apis = enableApis( [.pubsub]); add(GooglePubsubTopic( 'events', name: .literal('events'), dependsOn: apis, )); } } ``` ## IAM grants [Section titled “IAM grants”](#iam-grants) An IAM adjunct takes its parent as a reference (`bucket: uploads.ref`) and who the grant is for as an `IamPrincipal`. A service account, a service agent or a default service account data source hands out its own as `principal`; anyone else takes the constructor of their kind: `.user(email)`, `.group(email)`, `.serviceAccount(email)`, `.domain(domain)`, `.allUsers`, `.allAuthenticatedUsers`, or `.principalSet(pool, attribute)` for Workload Identity Federation. ```dart final runtime = add( GoogleServiceAccount('runtime', accountId: .literal('runtime')), ); final uploads = add( GoogleStorageBucket( 'uploads', name: .literal('uploads'), location: .literal('US'), ), ); add( GoogleStorageBucketIamMember( 'runtime_writer', bucket: uploads.ref, role: .literal('roles/storage.objectCreator'), member: runtime.principal, ), ); add( GoogleStorageBucketIamBinding( 'admins', bucket: uploads.ref, role: .literal('roles/storage.admin'), members: .literal([.group('sre@example.com'), .user('alice@example.com')]), ), ); ``` ## GA and beta in one Stack (Firebase + Google Cloud) [Section titled “GA and beta in one Stack (Firebase + Google Cloud)”](#ga-and-beta-in-one-stack-firebase--google-cloud) You can seamlessly combine Google Cloud GA resources (`terradart_google`) with beta-only resources (`terradart_google_beta`, such as Firebase project configuration and Web App registration) in a single `Stack`: ``` graph TB subgraph Client["Firebase App (Beta)"] WebApp["GoogleFirebaseWebApp
(Frontend Client)"] end subgraph GCP["Google Cloud Infrastructure (GA)"] CloudRun["GoogleCloudRunV2Service
(Backend API)"] Firestore["GoogleFirestoreDatabase
(Native Mode / (default))"] Storage["GoogleStorageBucket
(User Uploads)"] CloudRun -->|Read/Write Data| Firestore CloudRun -->|Store Assets| Storage end WebApp -.REST API Calls.-> CloudRun ``` pubspec.yaml ```yaml dependencies: terradart_core: ^0.35.0 terradart_google: ^0.35.0 terradart_google_beta: ^0.35.0 ``` ```dart // GA: Google Cloud backend infrastructure import 'package:terradart_google/cloud_run.dart'; import 'package:terradart_google/firestore.dart'; import 'package:terradart_google/provider.dart'; import 'package:terradart_google/storage.dart'; // Beta: Firebase project and app registration import 'package:terradart_google_beta/firebase.dart'; import 'package:terradart_google_beta/provider.dart'; final class MobileAppBackendStack extends Stack { MobileAppBackendStack({required String projectId}) : super( providers: [ GoogleProvider(project: projectId, region: 'asia-northeast1'), GoogleBetaProvider(project: projectId, region: 'asia-northeast1'), ], ) { // 1. [Beta] Enable Firebase on the project final fb = add(GoogleFirebaseProject( 'firebase', project: .literal(projectId), )); // 2. [Beta] Register Firebase client app add(GoogleFirebaseWebApp( 'web_client', displayName: .literal('Web Client'), project: .literal(projectId), dependsOn: [fb], )); // 3. [GA] Firestore Database (Native mode) final db = add(GoogleFirestoreDatabase( 'db', name: .literal('(default)'), locationId: .literal('asia-northeast1'), type: .firestoreNative, dependsOn: [fb], )); // 4. [GA] Cloud Storage for user uploads final uploadsBucket = add(GoogleStorageBucket( 'uploads', name: .literal('$projectId-uploads'), location: .literal('ASIA-NORTHEAST1'), storageClass: .standard, uniformBucketLevelAccess: .literal(true), )); // 5. [GA] Cloud Run v2 backend service add(GoogleCloudRunV2Service( 'api', name: .literal('api-server'), location: .literal('asia-northeast1'), template: CloudRunV2ServiceTemplate( containers: [ .new( name: .literal('server'), image: .literal( 'us-docker.pkg.dev/cloudrun/container/hello', ), env: [ .new( name: .literal('UPLOAD_BUCKET'), source: .value(uploadsBucket.name), ), ], ), ], ), dependsOn: [db], )); } } ``` Wrappers from `terradart_google_beta` automatically attach `provider = "google-beta"` in the synthesized Terraform JSON. See the complete runnable recipe in [`cookbook/firebase-app-backend`](https://github.com/nozomi-koborinai/terradart/tree/main/cookbook/firebase-app-backend). ## Several provider configurations [Section titled “Several provider configurations”](#several-provider-configurations) Every factory also takes a `provider:` parameter — Terraform’s `provider` meta-argument. Register a second configuration of a provider with `alias:` through `addProvider`, which returns the instance, and pass that instance per resource; everything else keeps using the default configuration: ```dart final class MultiRegionStack extends Stack { MultiRegionStack({required String projectId}) : super(providers: [ GoogleProvider(project: projectId, region: 'asia-northeast1'), ]) { final eu = addProvider( GoogleProvider(alias: 'eu', project: projectId, region: 'europe-west1'), ); add(GoogleStorageBucket( 'assets_eu', name: .literal('my-app-assets-eu'), location: .literal('EUROPE-WEST1'), provider: eu, // provider = google.eu )); } } ``` Synth emits `provider.google` as a list when a name has more than one configuration, and rejects a `provider:` instance the Stack does not register (an equal-looking copy included, since its settings may differ). A registered `GoogleBetaProvider` passed as `provider:` on a GA-catalog factory puts that one resource on the beta provider. ## Examples [Section titled “Examples”](#examples) Every Google quickstart is synthesized and validated against the provider in CI: * **Foundational & IAM**: [Pub/Sub](https://github.com/nozomi-koborinai/terradart/tree/main/examples/pubsub_quickstart), [Cloud Tasks](https://github.com/nozomi-koborinai/terradart/tree/main/examples/cloud_tasks_quickstart), [Secret Manager](https://github.com/nozomi-koborinai/terradart/tree/main/examples/secret_manager_quickstart), [IAM](https://github.com/nozomi-koborinai/terradart/tree/main/examples/iam_quickstart) * **Compute & Networking**: [Compute & Firewall](https://github.com/nozomi-koborinai/terradart/tree/main/examples/compute_quickstart), [GKE](https://github.com/nozomi-koborinai/terradart/tree/main/examples/gke_quickstart), [Cloud DNS](https://github.com/nozomi-koborinai/terradart/tree/main/examples/dns_quickstart) * **Data & Storage**: [Cloud Storage](https://github.com/nozomi-koborinai/terradart/tree/main/examples/storage_quickstart), [BigQuery](https://github.com/nozomi-koborinai/terradart/tree/main/examples/bigquery_quickstart), [Cloud Bigtable](https://github.com/nozomi-koborinai/terradart/tree/main/examples/bigtable_quickstart), [KMS](https://github.com/nozomi-koborinai/terradart/tree/main/examples/kms_quickstart) * **Application Platform**: [Cloud Run v2](https://github.com/nozomi-koborinai/terradart/tree/main/examples/cloud_run_quickstart), [Cloud Monitoring](https://github.com/nozomi-koborinai/terradart/tree/main/examples/monitoring_quickstart), [Workflows](https://github.com/nozomi-koborinai/terradart/tree/main/examples/workflows_quickstart), [Eventarc](https://github.com/nozomi-koborinai/terradart/tree/main/examples/eventarc_quickstart) * **AI & Agents**: [Vertex AI](https://github.com/nozomi-koborinai/terradart/tree/main/examples/vertex_ai_quickstart), [Agentic Applications](https://github.com/nozomi-koborinai/terradart/tree/main/examples/agentic_applications_quickstart) Full applications, infrastructure and app together, are in the [cookbook](https://github.com/nozomi-koborinai/terradart/tree/main/cookbook): `single-project-app` (Cloud Run + Cloud SQL), `firebase-app-backend`, `lunch-concierge`. ## Reference [Section titled “Reference”](#reference) * [Coverage](/docs/coverage/google/) — every Google factory, its barrel and its example * [`terradart_google` API docs](https://pub.dev/documentation/terradart_google/latest/) and [`terradart_google_beta` API docs](https://pub.dev/documentation/terradart_google_beta/latest/) # Status & versioning > Alpha expectations, the path to beta, and how TerraDart versions releases. TerraDart is **alpha** on the **0.35.x** line today. Alpha means the maintainer-side quality gates are done and the [change policy](#change-policy-from-alpha-onward) below is in force; what still separates alpha from **beta** is external validation — see [Path to beta](#path-to-beta). There are no SemVer guarantees until **v1.0.0**, but breaking changes land only on **minor** bumps: pin with `^0.35.0`, take patch releases freely, and read [MIGRATING.md](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md) before every minor bump. Check [pub.dev](https://pub.dev/packages/terradart_core) for the latest patch. ## Release phases [Section titled “Release phases”](#release-phases) | Phase | Version line | What we promise | | --------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Alpha** | 0.35.x (current) | Maintainer-side gates are done (docs, CI, example validation). **No breaking changes within a minor** (`^0.N.x`); breaking changes only on minor bumps, always documented in `MIGRATING.md`. Not SemVer until 1.0.0. | | **Beta** | TBD (needs external validation) | The same change policy, proven against real external usage — see [Path to beta](#path-to-beta). | | **1.0.0** | TBD | Stable SemVer for `terradart_core`, every provider package (`terradart_google`, `terradart_google_beta`, `terradart_aws`, `terradart_cloudflare`, `terradart_appwrite`, `terradart_time`) and `terradart_cli`. The maintainer tooling (`terradart_codegen`) and the migrator libraries (`terradart_migrate`, `terradart_hcl`) stay outside the promise. | ## What to expect today (alpha) [Section titled “What to expect today (alpha)”](#what-to-expect-today-alpha) * Breaking changes to the public Dart API or the emitted Terraform JSON land only on **minor** bumps, each with a [MIGRATING.md](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md) section; patch releases within `^0.N.x` are safe to take. * Use hosted `^0.35.0` carets on [pub.dev](https://pub.dev/packages/terradart_core) — not legacy `0.x.y-dev` pre-release tags. * Only the **curated** surfaces are supported for users. The GA `hashicorp/google` catalog is filled in `terradart_google`; beta-only types ship in `terradart_google_beta` (112 resource factories). `terradart_appwrite` is filled at `2.0.0-beta.1` (38 resource factories + 24 data sources). `terradart_cloudflare` and `terradart_aws` are filled at their exact provider pins (every resource and data source). ## Change policy (from alpha onward) [Section titled “Change policy (from alpha onward)”](#change-policy-from-alpha-onward) * **Patch releases** (`0.N.x` → `0.N.y`): no intentional breaking changes to the public APIs of `terradart_core`, the provider packages or `terradart_cli`. * **Minor releases** (`0.N.x` → `0.M.x`): breaking changes allowed only with a `MIGRATING.md` section for the previous minor. * **Curated factory additions** continue (additive waves); renaming or removing curated factories still counts as breaking. ## What alpha required [Section titled “What alpha required”](#what-alpha-required) Every maintainer-side gate is in place and runs on each pull request: docs consistency, every example synthesized and checked with `terraform validate`, example coverage of every catalog entry (or a reasoned entry in the example ledger), and the change policy above. ## Path to beta [Section titled “Path to beta”](#path-to-beta) Beta is the alpha change policy **proven against real external usage**. We will label the project beta when: * [ ] **External quickstart**: someone outside the core team completes the README path once; feedback captured in an issue or discussion. * [ ] **Real apply dogfood** via the [cookbook](https://github.com/nozomi-koborinai/terradart/tree/main/cookbook): at least one non-trivial recipe documents a successful `terradart apply`. * [ ] **`terradart migrate`**: [Migrating from HCL](/docs/migrate-from-hcl/) verified on a clean machine (installed with `dart pub global activate terradart_cli`), with one real Terraform tree that migrates and plans with *No changes*. Toward **1.0.0** (does not block beta): * [x] **`MIGRATING.md` / site migration guide** — [Upgrading](/docs/upgrading/) covers the current minor’s breaking changes; [MIGRATING.md on GitHub](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md) remains canonical for older releases. *Two-minor depth across minors remains a quality bar for current and future minor releases.* * [ ] **1.0.0 criteria** drafted (what “stable” means for curated names and `terradart_core` API). ## What alpha does *not* mean [Section titled “What alpha does not mean”](#what-alpha-does-not-mean) * **Not** a freeze on new curated factories. * **Not** [constructs / composite frameworks](https://github.com/nozomi-koborinai/terradart#non-goals). * **Not** SemVer until 1.0.0. * **Not** on-demand generation of arbitrary `google_*` bindings outside the curated surface. ## Reporting issues [Section titled “Reporting issues”](#reporting-issues) Use the [bug or question template](https://github.com/nozomi-koborinai/terradart/issues/new/choose) on GitHub. # Upgrading > Upgrade notes for every TerraDart package — the 0.34.x → 0.35.0, 0.33.x → 0.34.0, 0.32.x → 0.33.0 and 0.31.x → 0.32.0 changes, and where to find older ones. Read this page before every **minor** bump. Breaking changes land only on minor releases, and every one has a section in [MIGRATING.md on GitHub](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md), which stays the full, canonical history. This page summarizes the latest two. ## 0.34.x → 0.35.0 [Section titled “0.34.x → 0.35.0”](#034x--0350) 0.35.0 changes no Stack code: the Dart API, synth output and every provider pin stay as they were. It changes how the `terradart` command behaves in CI and scripts. 1. **Raise every TerraDart constraint to `^0.35.0` by hand**, then run `dart pub upgrade`, and `dart pub global activate terradart_cli` for the new command. 2. **Pass `--auto-approve` where nobody can answer.** Without a terminal — or with `--no-input`, `CI`, or in an AI agent’s shell — `terradart apply` and `terradart destroy` stop before `init` with exit code 3 instead of starting the engine. A script that piped `yes` into them passes `--auto-approve` instead. 3. **Check exit codes against the new table.** A failed engine step exits 12 (the engine’s own code is `error.engineExitCode` under `--json`), a failed entry point 10, an unknown `--env` 65. A script that tested `$? -eq 1` tests `$? -ne 0`, or reads `error.code` from `--json`. See [JSON and exit codes](/docs/cli/#json-and-exit-codes). 4. **Optional: install the agent skill.** `terradart skill install` writes the TerraDart Agent Skill of the CLI’s release into the project; `terradart skill status --check` keeps it current in CI. See [Let an AI agent do it](/docs/start/ai-agent/). ## 0.33.x → 0.34.0 [Section titled “0.33.x → 0.34.0”](#033x--0340) 0.34.0 changes no Stack code: the Dart API, synth output and every provider pin stay as they were. 1. **Raise every TerraDart constraint to `^0.34.0` by hand**, then run `dart pub upgrade`, and `dart pub global activate terradart_cli` for the new command. 2. **Optional: name a default environment.** `runEnvironments(args, Env.values, build, defaultEnv: Env.dev)` lets `terradart plan` run without `--env`; `TERRADART_ENV` overrides it. See [Environments](/docs/environments/). 3. **Appwrite Stacks run on Terraform.** The `terradart` command now picks the `terraform` on `PATH` for a Stack that uses `terradart_appwrite`, and stops before `init` when there is none or OpenTofu is asked for. 4. **A state another engine wrote now needs an answer.** When `.terradart/engines.json` has no record (a fresh clone, or right after a migration), `plan`, `apply` and `destroy` read the state first. If the engine the command picked by itself is not the one that wrote the state, they ask on a terminal. Without a terminal they stop with exit code 64; pass `--engine`, or set `terradart: engine:` in `pubspec.yaml`, in CI. ## 0.32.x → 0.33.0 [Section titled “0.32.x → 0.33.0”](#032x--0330) 0.33.0 adds the [`terradart` command](/docs/cli/) (`terradart_cli`) and changes no Stack code: the provider packages keep their Dart API, no provider pin moves, and synth output is unchanged. 1. **Raise every TerraDart constraint to `^0.33.0` by hand**, then run `dart pub upgrade`: ```yaml dependencies: terradart_core: ^0.33.0 terradart_google: ^0.33.0 # and ^0.33.0 for terradart_google_beta, terradart_aws, # terradart_cloudflare, terradart_appwrite or terradart_time ``` 2. **Switch to the command.** `dart pub global activate terradart_cli`, then `terradart apply` replaces `dart run bin/infra.dart`, `cd tf-out`, `terraform init` and `terraform apply`, and writes the define file a client reads its outputs from. An existing `bin/infra.dart` keeps working; calling `runStack` or `runEnvironments` from it lets the command select an environment with `--env `. See [The terradart command](/docs/cli/). 3. **`terradart-migrate` users:** run `terradart migrate` with the same flags. `terradart-migrate` still runs, prints that it is deprecated, and goes away in a later release. 4. **Maintainers only:** `dart pub global activate terradart_codegen` now installs `terradart-codegen` (`terradart-codegen wrap ...`), because `terradart` is the user command. Deactivate and reactivate `terradart_codegen` before activating `terradart_cli`; `dart run terradart_codegen:terradart` is unchanged. ## 0.31.x → 0.32.0 [Section titled “0.31.x → 0.32.0”](#031x--0320) 0.32.0 makes every argument take what it means — an attribute getter, an enum member, a variable handle, a provider instance, the blocks a resource depends on — so most of the upgrade is deleting wrappers. It breaks the Dart API of every package, not your Terraform: no provider pin moves, and synthesized JSON changes only where a typed reference now emits the attribute the provider expects, an IAM adjunct now carries its parent’s `project` / `location`, or an explicit `false` lifecycle flag is now written. ### Upgrade steps [Section titled “Upgrade steps”](#upgrade-steps) 1. **Raise every TerraDart constraint to `^0.32.0` by hand**, then run `dart pub upgrade`. The Dart SDK minimum stays 3.10: ```yaml dependencies: terradart_core: ^0.32.0 terradart_google: ^0.32.0 # and ^0.32.0 for terradart_google_beta, terradart_aws, # terradart_cloudflare, terradart_appwrite or terradart_time ``` 2. **Drop `import 'package:terradart_core/terradart_core.dart';`** where a file imports a provider barrel: every barrel re-exports it. 3. **Fix the compile errors** with the [upgrade guide in MIGRATING.md](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md#upgrade-guide), which lists the groups in order of how many stacks they touch, each with a before / after table. 4. **Read `terradart plan` before you apply.** A few typed references emit a different attribute than the one a stack passed ([the list](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md#more-arguments-take-reftor)); `.ref.pinned('id')` keeps the old one. 5. **`terradart-migrate` users:** `dart pub global activate terradart_migrate` installs 0.32.0, which writes the new API. ### The common changes [Section titled “The common changes”](#the-common-changes) | Before (0.31) | After (0.32) | | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | | `GooglePubsubTopic(localName: 'orders', ...)` | `GooglePubsubTopic('orders', ...)` | | `topic.nameRef`, `labels: .ref(other.labels)` | `topic.name`, `labels: other.labels` | | `routingMode: .literal(.regional)` | `routingMode: .regional` | | `addVariable('region', const TfVariable(type: 'string'))`, then `TfArg.variable('region')` | `final region = variable('region');`, then `location: region` | | `dependsOn: [ResourceDependency(api)]`, `addData(...)` | `dependsOn: [api]`, `add(...)` | | `provider: 'google.eu'` | `provider: eu`, from `final eu = addProvider(GoogleProvider(alias: 'eu'));` | | `setBackend(...)`, `TfTimeouts(create: '30m')` | `super(backend: ...)`, `TfTimeouts(create: Duration(minutes: 30))` | | `Apis.enable(this, barrels: [Barrels.cloudRun])` | `enableApis([.cloudRun])` | | `member: .ref(sa.iamMember)`, `member: .literal('user:a@example.com')` | `member: sa.principal`, `member: .user('a@example.com')` | | `name: .ref(api.nameRef), location: ...` on an IAM member | `service: api.ref` | | `password: .literal('...')` on a sensitive argument | a variable or an expression; `Sensitive` has no `.literal` | | `ignoreChanges: ['target_size']` | `ignoreChanges: .of(['target_size'])` | | `on StateError` / `on SensitiveLiteralError` around `synth()` | `on SynthException`, or `stack.validate()` | Together: lib/publisher\_stack.dart ```dart import 'package:terradart_google/iam.dart'; import 'package:terradart_google/provider.dart'; import 'package:terradart_google/pubsub.dart'; final class PublisherStack extends Stack { PublisherStack({required String projectId}) : super(providers: [GoogleProvider(project: projectId)]) { final topic = add(GooglePubsubTopic('orders', name: .literal('orders'))); final publisher = add( GoogleServiceAccount('publisher', accountId: .literal('orders-publisher')), ); add( GooglePubsubTopicIamMember( 'publish', topic: topic.ref, // was name: .ref(topic.nameRef) role: .literal('roles/pubsub.publisher'), member: publisher.principal, // was .ref(publisher.iamMember) ), ); addOutput('orders_topic_id', topic.id); } } ``` New in 0.32.0 and not breaking: [typed outputs in Flutter and web clients](/docs/client-outputs/) (`addDartDefineOutput`), and provider aliases in migrated child modules. ## 0.30.x → 0.31.0 [Section titled “0.30.x → 0.31.0”](#030x--0310) 0.31.0 reshapes the Dart API of every package for type safety, but not your Terraform: no provider pin moves, and synthesized JSON changes only where a typed reference now emits a different attribute. `dart analyze` lists every break, and code completion on the argument offers the replacement. ### Upgrade steps [Section titled “Upgrade steps”](#upgrade-steps-1) 1. **Install Dart 3.10 or later** and set `sdk: ^3.10.0` in your stack’s `pubspec.yaml`. Dot shorthands need it. 2. **Raise every TerraDart constraint to `^0.31.0` by hand** — below 1.0 a caret never crosses a minor — then run `dart pub upgrade`. The packages release in lockstep: ```yaml environment: sdk: ^3.10.0 dependencies: terradart_core: ^0.31.0 terradart_google: ^0.31.0 # and ^0.31.0 for terradart_google_beta, terradart_aws, # terradart_cloudflare, terradart_appwrite or terradart_time ``` 3. **Fix the compile errors**, group by group (each section of MIGRATING.md has a before / after table): [sealed arguments](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md#sealed-arguments-and-dot-shorthands), [typed references](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md#typed-references), [outputs and constants](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md#outputs-and-constants), [typed nested helpers](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md#typed-nested-helpers), [type names](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md#type-names). 4. **Read `terradart plan` before you apply.** Typed references emit the attribute the argument expects — Google `network` / `subnetwork` emit `id` where many stacks passed `self_link`. Pin the old one with `.ref.pinned('self_link')` to keep the old value exactly. 5. **`terradart-migrate` users:** `dart pub global activate terradart_migrate` installs 0.31.0, which writes the new API. ### Sealed arguments and dot shorthands [Section titled “Sealed arguments and dot shorthands”](#sealed-arguments-and-dot-shorthands) An argument that takes exactly one (or at most one) of several inputs is one sealed argument, named by concept like a protobuf `oneof`. Each member is a factory constructor, picked with a Dart 3.10 dot shorthand. Leaving it out, or setting two, no longer compiles: ```dart final role = add(AwsIamRole( 'fn', name: .namePrefix(.literal('app-')), // was name / namePrefix assumeRolePolicy: .literal('{}'), )); add(AwsLambdaFunction( 'fn', functionName: .literal('hello'), role: role.ref, code: .filename(.literal('bootstrap.zip')), // was filenameOrImageUriOrS3Bucket )); ``` The same shorthand works for every `TfArg` and enum: `.literal('orders')`, `sa.principal`, `.postgres15`. ### Typed references [Section titled “Typed references”](#typed-references) An argument that names another resource takes `RefTo`, so passing the wrong kind of resource does not compile. Take it from the target’s `ref` getter; the argument picks the attribute it emits: | Before (0.30) | After | | ----------------------------------- | ------------------------------ | | `network: TfArg.ref(vpc.selfLink)` | `network: vpc.ref` | | `role: TfArg.ref(role.arn)` | `role: role.ref` | | `zoneId: TfArg.ref(zone.id)` | `zoneId: zone.ref` | | `network: TfArg.literal('default')` | `network: .literal('default')` | ```dart final vpc = add(GoogleComputeNetwork('vpc', name: .literal('app'))); add(GoogleComputeSubnetwork( 'app', name: .literal('app'), ipCidrRange: .literal('10.0.0.0/24'), network: vpc.ref, // emits id; vpc.ref.pinned('self_link') keeps the 0.30 value )); ``` Every input also has a `Ref` getter (`topic.nameRef`) for wiring one resource’s argument into another, or into a constant. ### Outputs and constants [Section titled “Outputs and constants”](#outputs-and-constants) `addExport` is gone. `addOutput` declares a Terraform output, `addConstant` a Dart constant, and the generated file is configured once with `appExports:`: lib/orders\_stack.dart ```dart import 'package:terradart_google/provider.dart'; import 'package:terradart_google/pubsub.dart'; final class OrdersStack extends Stack { OrdersStack({required String projectId}) : super( providers: [GoogleProvider(project: projectId)], appExports: AppExports('lib/generated/orders_stack.app.dart'), ) { final topic = add(GooglePubsubTopic('orders', name: .literal('orders-prod'))); addConstant('ordersTopicName', .ref(topic.name)); addOutput('orders_topic_id', topic.id); } } ``` The same file now also holds `OrdersStackOutputs`, a typed reader of the outputs, and `outputEnvironment()` hands them to a service as its environment. See [How TerraDart works — the app boundary](/docs/how-it-works/#the-app-boundary-constants-and-outputs). ### Typed nested helpers and type names [Section titled “Typed nested helpers and type names”](#typed-nested-helpers-and-type-names) Google blocks take helper classes derived from the provider schema (no Google `TfArg` block is left), and derived types are named `` without repeated words — `CloudRunV2ServiceContainers`, not `CloudRunV2ServiceServiceContainer`. Arguments and synth output do not change; `dart analyze` lists the old names, and completion offers the new ones. ## Older releases [Section titled “Older releases”](#older-releases) Every earlier breaking change — `TfArg.expression` and provider aliases in 0.28.0, sealed exactly-one slots in 0.12.12, typed enums in 0.12.10, and more — is documented in [MIGRATING.md](https://github.com/nozomi-koborinai/terradart/blob/main/MIGRATING.md). ## Next steps [Section titled “Next steps”](#next-steps) * [Status & versioning](/docs/status/) — alpha expectations and change policy * [Examples](https://github.com/nozomi-koborinai/terradart/tree/main/examples) — every quickstart is on the current API # Why TerraDart > Why Dart teams adopt TerraDart — the infra/app boundary, Terraform-compatible generation, curated provider factories, and how it compares to HCL, CDKTF, and Pulumi. If you read the [landing page](/) first: TerraDart keeps **Terraform as the execution layer** while you **author infrastructure in Dart**. This page goes deeper on motivation, the type system, and where TerraDart sits next to other IaC tools. If your team ships Flutter on the client and Dart on the server — on **Cloud Run**, **Lambda**, **ECS**, an **Appwrite** backend, or any other host — there is usually one layer left where Dart’s tooling does not reach: **infrastructure definitions** — often maintained in HCL, a Terraform-compatible pipeline, or cloud consoles. TerraDart closes that gap without asking you to give up Terraform itself. ## The last non-Dart layer [Section titled “The last non-Dart layer”](#the-last-non-dart-layer) | Layer | Already Dart? | | --------------------------------------------- | ------------------------------------------------- | | Mobile UI (Flutter) | ✅ | | Backend handlers (Cloud Run / Lambda / Shelf) | ✅ | | Application business logic | ✅ | | Infrastructure definitions | ❌ — HCL, Terraform-compatible tooling, or console | That bottom row is what people mean by the **last non-Dart layer**: everything above it shares `dart analyze`, refactors, and IDE navigation; infrastructure often does not. ### A boundary held together by string literals [Section titled “A boundary held together by string literals”](#a-boundary-held-together-by-string-literals) Topic names typed in HCL or the console and again in your function handlers. Renames do not reach subscribers via `dart fix`. IAM member strings drift with no compiler visibility. Infra values flowing into app code are often the place Dart’s type system never sees. TerraDart pulls infrastructure into the same authoring surface as the rest of your code, and hands its values back: `addConstant` and `addOutput` become a generated Dart file the app imports, so a topic name or a service URL has one source. `dart analyze` catches resource-name typos. Refactoring a Stack class is the same operation as refactoring any other Dart class. The same `final` / `sealed` discipline that already protects your domain model can protect your network topology. ## Terraform stays the execution layer [Section titled “Terraform stays the execution layer”](#terraform-stays-the-execution-layer) TerraDart does not replace Terraform. The [`terradart` command](/docs/cli/) runs `plan` and `apply` with the `tofu` or `terraform` you already have (or a pinned OpenTofu it downloads), but it does not own your state file, and it does not introduce a new provider or a new lock format. What it does is take Terraform’s authoring model — provider blocks, resource blocks, references, lifecycle hooks — and express it as Dart code that **generates** the same `*.tf.json` Terraform would have accepted from HCL. Planning, applying and your remote state backend stay exactly where they are. This is intentionally narrower than Pulumi, which has its own runtime, its own state model, and its own resource graph. With TerraDart the boundary is sharp: Dart is the **authoring** layer, Terraform is the **execution** layer. If you migrate off TerraDart tomorrow, your existing state and your existing modules are unchanged. CDKTF used to occupy this same “code-first authoring of Terraform” niche, but only for TypeScript / Python / Java / Go — never Dart. CDKTF was [archived in December 2025](https://github.com/hashicorp/terraform-cdk), leaving a Dart-shaped hole that TerraDart now fills. ## Dart types where HCL cannot help [Section titled “Dart types where HCL cannot help”](#dart-types-where-hcl-cannot-help) Three concrete patterns where the Dart type system catches what HCL cannot: ### Enums for fixed-value fields [Section titled “Enums for fixed-value fields”](#enums-for-fixed-value-fields) Terraform schemas often have fields that accept one of a small, fixed set of strings — `INGRESS_TRAFFIC_ALL` vs `INGRESS_TRAFFIC_INTERNAL_ONLY`, for example. TerraDart emits these as Dart enums: ```dart add(GoogleCloudRunV2Service( 'api', name: .literal('api'), location: .literal('us-central1'), ingress: .all, // .al → compile error template: CloudRunV2ServiceTemplate(containers: [ .new(image: .literal('us-docker.pkg.dev/cloudrun/container/hello')), ]), )); ``` ### Sealed classes for exactly-one-of nested blocks [Section titled “Sealed classes for exactly-one-of nested blocks”](#sealed-classes-for-exactly-one-of-nested-blocks) Some Terraform blocks accept exactly one variant from a set — for instance, a BigQuery dataset’s `access` block has 8 mutually exclusive variants (`user_by_email`, `group_by_email`, `special_group`, `domain`, `iam_member`, `view`, `dataset`, `routine`). TerraDart emits these as a sealed class hierarchy: ```dart final reader = add(GoogleServiceAccount('reader', accountId: .literal('reader'))); final runSa = add(GoogleServiceAccount('run', accountId: .literal('run'))); add(GoogleBigqueryDataset( 'events', datasetId: .literal('events'), access: [ .userByEmail(userByEmail: reader.email, role: .literal('OWNER')), .iamMember(iamMember: runSa.principal, role: .literal('READER')), ], )); ``` The compiler enforces that exactly one variant is constructed per entry. Each variant is a factory constructor on the sealed type (`BigqueryDatasetAccess.userByEmail`), so a Dart 3.10 dot shorthand picks it and IDE completion lists the choices. A sealed argument is named for what its members are alternatives of, like a protobuf `oneof`: `AwsLambdaFunction(code: .filename(...))`, `CloudflareDnsRecord(content: .content(...))`. ### Final classes for Stack subclasses [Section titled “Final classes for Stack subclasses”](#final-classes-for-stack-subclasses) `Stack`, `Resource`, and `Data` are declared `abstract base class`. Your own subclasses must use a class modifier: ```dart final class AppInfraStack extends Stack { AppInfraStack() : super(providers: [GoogleProvider(project: 'my-project')]); } ``` This prevents two foot-guns: forgetting to extend (using `implements` would skip the base-class state that synth needs), and accidentally subclassing an internal class that was not meant to be extended further. Both are compile errors. ## How it fits next to other IaC [Section titled “How it fits next to other IaC”](#how-it-fits-next-to-other-iac) TerraDart fits teams already centered on **Dart**, already running **Terraform** (or a compatible pipeline) or managing infrastructure in cloud **consoles**, where the seam between infra and app code hurts. | | TerraDart | HCL | CDKTF | Pulumi | | ----------------------------- | ----------------- | ------------------------------- | --------------------------------- | ---------------------- | | Dart authoring | Yes | No | No (TypeScript, Python, Java, Go) | Community runtime only | | Type-safe handoff to your app | Yes, compile-time | No (`terraform output` + parse) | No Dart | No typed Dart export | | Runs on OpenTofu / Terraform | Yes | Yes | Yes | Different model | | Curated Dart factories | Yes | No | No | No | | Project status | Alpha | Mature | Archived 2025 | Active | HCL has provider schema types; CDKTF bindings are typed in TypeScript and other languages. TerraDart’s difference is **Dart-native authoring** without replacing your Terraform execution pipeline. **HCL** is the native, mature option. If your team is comfortable maintaining HCL alongside your Dart application code, there is no urgency to switch. TerraDart’s value proposition only matters once the cost of context-switching between Dart and HCL — and the absence of refactor / typecheck across that boundary — starts to bite. **CDKTF** would have been the obvious model to follow, except it was archived by HashiCorp in December 2025 and never targeted Dart in any case. TerraDart is the Dart-shaped slot CDKTF did not reach. **Pulumi** is a different model entirely: its own runtime, its own state, and its own resource graph. If your team is already invested in Pulumi and wants to author in Dart, consider [Pulumi Dart](https://github.com/kingwill101/pulumi-dart) (`kingwill101/pulumi-dart`), an active community language runtime for Pulumi. TerraDart is for teams who already use **Terraform** and want type-safe Dart authoring without replacing their Terraform state or execution pipeline. **TerraDart** makes sense when you are already centered on Dart (Flutter plus a Dart backend, on whichever cloud), already using Terraform (or heading there), and the gap between those two — IaC stuck outside your Dart repo — is the part that hurts. ## Provider coverage [Section titled “Provider coverage”](#provider-coverage) Each provider package wraps the whole catalog of its Terraform provider at its pinned release: Google Cloud in [`terradart_google`](https://pub.dev/packages/terradart_google), with the beta-only types in [`terradart_google_beta`](https://pub.dev/packages/terradart_google_beta); [`terradart_aws`](https://pub.dev/packages/terradart_aws); [`terradart_cloudflare`](https://pub.dev/packages/terradart_cloudflare); and [`terradart_appwrite`](https://pub.dev/packages/terradart_appwrite). Each provider has its own page — [Google Cloud](/docs/providers/google/), [AWS](/docs/providers/aws/), [Cloudflare](/docs/providers/cloudflare/), [Appwrite](/docs/providers/appwrite/) — and every provider’s factory list with example pointers is on [Coverage](/docs/coverage/); see also [status](/docs/status/) and [How TerraDart works — Provider packages](/docs/how-it-works/#provider-packages). Runnable stacks live in [examples](https://github.com/nozomi-koborinai/terradart/tree/main/examples) and the [cookbook](https://github.com/nozomi-koborinai/terradart/tree/main/cookbook). Upgrading across minors? Read [Upgrading](/docs/upgrading/) first. ## Non-goals [Section titled “Non-goals”](#non-goals) * Not a Terraform replacement — state and apply stay in Terraform. * Not a multi-cloud abstraction layer — wrappers faithfully mirror provider schemas rather than imposing cross-cloud abstractions. * Not a constructs framework in the pre-1.0 cycle. * Not a module system — `addModule(ModuleCall(...))` calls an existing Terraform module by its `source`, and HCL files beside the generated `*.tf.json` feed the same apply; TerraDart does not turn modules into Dart. ## Next steps [Section titled “Next steps”](#next-steps) * [How TerraDart works](/docs/how-it-works/) — synth, plan and apply, and the constants and outputs your app reads * [Getting started](/docs/getting-started/) — install and first `*.tf.json` output * [How it’s built](/docs/how-its-built/) — generation pipeline, verification, sustainability * [Status](/docs/status/) — alpha expectations