@dragonmastery/tamer
v0.57.1
Published
Tamer: Cloudflare Workers infra CLI (sync, apply, deploy, migrate, destroy) and Wrangler-oriented TypeScript types.
Maintainers
Readme
@dragonmastery/tamer
Tamer tooling monorepo — Cloudflare Workers infrastructure CLI (sync, apply, deploy, migrate, destroy), Wrangler-oriented TypeScript types (generated from the official Wrangler config schema), reference fixtures, and tests for the published @dragonmastery/tamer npm package.
Using Tamer in a downstream repo? Install from npm and follow Quickstart. The rest of this README is for contributors hacking Tamer and for CLI/config reference.
Installation
Contributors (this repo):
bun installRequires Bun and Node.js 22+ (engines.node). Run the CLI with bun run tamer -- … or bun src/cli/index.ts … (see Developing).
Published package (downstream repos):
npm install -D @dragonmastery/tamer wranglerAlso requires the wrangler peer (>=4.0.0). Install, scripts, stack layout, and first deploy: Quickstart.
Consumers (downstream repos)
Install from npm and walk through bootstrap → apply → deploy in docs/guide/quickstart.md. Adopting existing D1/R2/workers without CF IDs in config: docs/guide/brownfield-adoption.md. Worker secrets (vault + push): docs/guide/secrets.md. Resource kinds, ${tamer:import:…}, outputs, and CLI flags remain documented below for reference.
Per-env config overlays: alongside tamer/project.config.ts you can add tamer/env/<env>.config.ts (or tamer.env.<env>.ts for the flat layout) exporting defineProjectOverlay({...}) — Tamer deep-merges it onto the project config for that --env. Overlays may only patch workers that already exist in the project config.
Types usage
import type {
WranglerConfig,
WranglerEnvironment,
WranglerD1Database,
WranglerR2Bucket,
WranglerKVNamespace,
} from "@dragonmastery/tamer";
const config: WranglerConfig = {
name: "my-worker",
main: "src/index.ts",
compatibility_date: "2025-01-01",
d1_databases: [{ binding: "DB", database_id: "xxx" }],
};| Type | Description |
| --------------------- | ------------------------------------------- |
| WranglerConfig | Full wrangler config (alias: RawConfig) |
| WranglerEnvironment | Environment block (alias: RawEnvironment) |
| WranglerD1Database | D1 binding entry |
| WranglerR2Bucket | R2 binding entry |
| WranglerKVNamespace | KV binding entry |
CLI
tamer bootstrap Create account-scoped Tamer metadata: tamer-state (D1), tamer-artifacts (R2), tamer-secrets (D1). Run once per account
tamer sync Sync local state from Cloudflare (no writes)
tamer apply Provision missing D1 / R2 / KV / … / dispatch namespaces (creates shard 0 for each `resources.shardGroups[]` entry)
tamer migrate Run D1 migrations per worker (includes stack-level shard groups)
tamer seed Run D1 seed SQL per worker (`seedDir`; after migrate on reset/provision)
tamer types Generate worker-configuration.d.ts per worker (wrangler types)
tamer shard add Append the next physical D1 to a stack-level shard group; regenerates wrangler + `src/shard-bindings.ts`
tamer deploy wrangler deploy per worker (after sync), then apply zone-name `tamerRoutes` via Workers Routes API
tamer dev wrangler dev (use --all for every worker)
tamer status Show config vs state (optional `--tenant product:workspace`)
tamer events Print operation history timeline from state (--limit N, --json)
tamer drift Compare state vs Cloudflare and report differences (read-only)
tamer plan CloudFormation-style preview: what `apply`+`deploy` would create (read-only)
tamer import Register an existing Cloudflare resource into state by logical name
tamer doctor Verify `CLOUDFLARE_*` credentials against the account API (`--json` supported)
tamer destroy Remove workers + storage + namespaces for an env
tamer reset Delete + recreate D1 database(s), rerun migrations, redeploy bound workers (dev-loop; d1 only)
tamer env list List all environments recorded in state (ages, resource counts)
tamer env gc Destroy ephemeral envs older than --max-age (default 72h; --dry-run, --force, --json)
tamer wfp put Upload a single-module Worker to a dispatch namespace
tamer wfp delete Delete a Worker from a dispatch namespace
tamer wfp tenant provision Runtime: create per-tenant shard groups + utility D1s + upload dispatch scripts (`--namespace`, `--main` optional)
tamer wfp tenant destroy Runtime: remove tenant scripts + D1s + state (shared envs: `--confirm-tenant`)
tamer wfp tenant list Runtime: list tenants (`product:workspace`) and their provisioning status (`--json`)
tamer wfp tenant status Runtime: show one tenant's dispatch scripts, shard groups, and provisioning status
tamer wfp tenant migrate Runtime: run D1 migrations on tenant shard groups + utility D1s
tamer wfp tenant seed Runtime: run D1 seed SQL on tenant shard groups + utility D1s (`seedDir`)
tamer wfp tenant add-shard Runtime: append the next physical shard to a tenant shard group and re-upload the dispatch script
tamer wfp tenant reset Runtime: delete + recreate tenant D1(s), migrate, reupload dispatch scripts (greenfield squash loop)
tamer secrets Encrypted vault: init, set, load, copy, get, list, rm, verify, push, migrate (see docs/guide/secrets.md)Common flags: --env <name> (required for every command; tamer deploy --env <name> no longer silently defaults to prod), --worker <name>, --config <path>, --force, --confirm-env <name> (destroy: required for any env in the root-level protectedEnvs from tamer/project.config.ts — defaults to ["prod","production"] when unset; pass protectedEnvs: ["prod","production","production-eu","qa"] etc. to widen the gate, or [] to opt out — unless --force), --confirm-tenant <workspace> (wfp tenant destroy: same rule, sourced from root-level protectedEnvs), --skip-workers (destroy), --wipe-metadata (destroy: after resources, deletes the env's rows from the shared tamer-state D1 and the env's rows from the shared tamer-secrets D1 — neither database itself is dropped, both are account-scoped and shared across envs; the tamer-artifacts R2 bucket is not cleaned per-env. Use on the last stack in a multi-stack teardown), --dispatch-namespace <name> (deploy), --namespace <name> (wfp tenant: select dispatch namespace; defaults to the only configured namespace), --json (drift / plan: emit machine-readable JSON), --detailed-exitcode (plan: exit 2 instead of 0 when there are pending changes — Terraform-style CI gate), --destroy (plan: preview deletions instead of creates/updates — read-only tamer destroy dry-run). Flags take space-separated values (--env dev); the --flag=value form is not supported.
tamer import flags: --target <kind>:<logical> (<kind> ∈ d1 | r2 | kv | queue | hyperdrive | vectorize | ai_gateway | pipeline | workflow | secret_store | dns_record | dispatch_namespace | worker_route; <logical> from tamer/project.config.ts, or worker key for worker_route), --cf-id <id> (D1 uuid, KV id, R2 bucket name, queue id, hyperdrive config id, vectorize index name, AI gateway id, pipeline id, workflow id, secrets store id, DNS record id, or dispatch namespace name), --shard-date <YYYY-MM-DD> (sharded D1 only), --created-date <YYYY-MM-DD> (R2: optional, defaults to extracting from name), --route-id <id> and --zone-name <z> (worker_route).
Environment variables
Tamer uses the same env vars as Wrangler — see system environment variables:
CLOUDFLARE_ACCOUNT_IDCLOUDFLARE_API_TOKENR2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY(optional) — R2 S3 API credentials. When both are set,tamer destroyempties managed R2 buckets (delete all objects, abort incomplete multipart uploads) before removing the bucket. Not the same asCLOUDFLARE_API_TOKEN.TAMER_DEV_BASE_PORT(default8787, used bydev --all)
Bun loads .env from the current working directory automatically when you run bun src/cli/index.ts ..., so you can keep credentials in a per-project .env (gitignored) instead of exporting them.
API token scopes
Custom token, scoped to the account you target. Names match Cloudflare’s API token permissions reference:
| Permission | Scope | Used by |
| ------------------------- | ------- | ------------------------------------------------------------------------ |
| Workers Scripts: Edit | Account | deploy, destroy (worker delete), wfp put, dispatch-namespace ops |
| Workers KV Storage: Edit | Account | KV apply / destroy / sync |
| Workers R2 Storage: Edit | Account | R2 apply / destroy / sync |
| D1: Edit | Account | D1 apply / destroy / migrate / sync |
| Queues: Edit | Account | Queues apply / destroy / sync / drift / import |
| Hyperdrive: Edit | Account | Hyperdrive apply / destroy / sync / drift / import |
| Vectorize: Edit | Account | Vectorize apply / destroy / sync / drift / import |
| AI Gateway: Edit | Account | AI Gateway apply / destroy / sync / drift / import |
| Pipelines: Edit | Account | Pipelines apply / destroy / sync / drift / import |
| Workflows: Edit | Account | Workflows apply / destroy / sync / drift / import |
| Secrets Store: Edit | Account | Secrets Store apply / destroy / sync / drift / import |
| DNS: Edit | Zone | DNS records apply / destroy / sync / drift / import (zones declared in dnsRecords[]) |
| Logs: Edit (or Logpush: Edit) | Account | logpushJobs in tamer/project.config.ts: create/delete Workers trace Logpush jobs (Workers Logpush) |
| Workers R2 Data Catalog: Edit | Account | pipelinesAuto (apply token): calls POST …/r2-catalog/{bucket}/enable and …/credential on your CLOUDFLARE_API_TOKEN — not the minted “catalog+sink” sub-token. With Workers R2 Storage: Edit on the catalog bucket for Pipelines. |
| API Tokens: Edit (or equivalent) | Account | pipelinesAuto (apply token): list permission groups and create / delete the two account sub-tokens only. |
| Account Settings: Read | Account | Account-scoped reads (and required by Wrangler subprocesses) |
| Workers Tail: Read | Account | Optional, only for wrangler tail |
| Workers Routes: Edit | Zone | Zone-name tamerRoutes: list/create/delete routes (deploy, sync, destroy, drift). Skip if you only use wrangler-native routes or custom-domain tamerRoutes. |
| Zone: Read | Zone | Resolving zone_name → zone id for those routes (same zones as above). |
The dashboard template “Edit Cloudflare Workers” covers most of these; add D1: Edit manually.
Workers trace → Pipelines → Iceberg (pipelinesAuto): On tamer apply, Tamer mints two account sub-tokens, in line with the dashboard: Workers R2 Data Catalog (Edit/Write) only for the stored catalog credential and the r2_data_catalog sink, and Workers Pipelines Send only for Logpush stream ingest. Values are stored in the logpush_pipelines state entry; treat that state as sensitive. Your CLOUDFLARE_API_TOKEN must also be allowed to call the R2 Data Catalog account HTTP API (/accounts/.../r2-catalog/... for enable + credential) on its own — that uses the apply token, not the sub-tokens. The same apply token also needs the account Token API, Logpush, Pipelines, etc. (table above, Account Token API). If sink creation fails with HTTP 422 / code 1012 (existing catalog table), change pipelinesAuto.tableName and/or namespace to a name that does not already exist in that catalog bucket.
For read-only flows (status, dry runs), use the Read variants instead.
Resource kinds
Tamer-managed resources are declared on a worker under resources. Each kind expands to a Wrangler binding plus a state row keyed by derived name.
defineWorker({
resources: {
d1: [{ logicalName: "settings", type: "single" }],
r2: [{ logicalName: "assets" }],
kv: [{ logicalName: "cache" }],
queues: [
{ logicalName: "events" }, // producer binding emitted
{ logicalName: "audit", consumerOnly: true }, // queue created, no producer binding
],
hyperdrive: [
{
logicalName: "primary",
origin: {
scheme: "postgres",
host: "db.example.com",
port: 5432,
database: "app",
user: "app",
password: { fromEnv: "PG_PW" }, // never persisted in state
},
localConnectionString: "postgres://localhost/app",
},
],
vectorize: [
{ logicalName: "embeddings", dimensions: 768, metric: "cosine" },
],
aiGateway: [
{
logicalName: "openai",
cacheTtl: 60, // seconds; 0 = caching off (default)
authentication: true, // require Authorization header on gateway endpoint
rateLimitingInterval: 60,
rateLimitingLimit: 100,
},
],
pipelines: [
{
logicalName: "events",
// Arroyo SQL — `events_stream` and `events_sink` must already exist on Cloudflare.
sql: "insert into events_sink select * from events_stream;",
},
],
workflows: [
{
logicalName: "billing",
className: "BillingWorkflow", // class exported from this worker's entrypoint
// scriptName defaults to the owning worker's deployed script — pin it
// here only to bind to a different worker's exported class.
limits: { steps: 50 }, // optional — tracked in state for drift
},
],
secretsStores: [
// Tamer manages the *store* (account-scoped container). Secret values
// inside it are written out-of-band via wrangler / dashboard / CI.
{ logicalName: "apiKeys" },
],
secretsStoreSecrets: [
// Wire one previously-uploaded secret into this worker. The wrangler
// generator resolves `store: "apiKeys"` to the live store_id from state.
{ binding: "STRIPE_KEY", store: "apiKeys", secretName: "stripe_key" },
],
},
vars: {
// No wrangler binding for AI Gateway — pull the derived id into env vars instead.
AI_GATEWAY_ID: "${tamer:ai_gateway:openai.name}",
},
});Notes:
- Queues: Tamer creates the queue itself on
applyand emitsqueues.producers[]in the generated wrangler config. Consumer subscriptions stay wrangler-side — set them on the worker config underqueues.consumers(passed through verbatim). - Hyperdrive: the origin connection (including
passwordand optionalaccess_client_secret) is sent to Cloudflare onapplyand discarded — Tamer's state only records the resulting config id, scheme, host and database name. Use{ fromEnv: "VAR" }to source secrets from the shell environment so they never entertamer/project.config.ts.localConnectionStringis written to the generated wrangler config forwrangler devonly. - Vectorize:
dimensionsandmetric(cosine|euclidean|dot-product) are immutable once the index is created. To change them, runtamer destroy --env <env> --target vectorize:<logicalName>and re-apply. The generated wrangler config emitsvectorize[]bindings using the derived index name. - AI Gateway: gateways are account-scoped and have no Wrangler binding kind — Workers reference them per-request via
env.AI.run(model, opts, { gateway: { id } })(or by URL on the OpenAI-compatible endpoint). Tamer therefore emits no wrangler fragment for AI Gateway; use the cross-resource ref${tamer:ai_gateway:<logical>.name}(or.id) in workervarsto inject the derived gateway id. Derived id pattern:aigw-{logical}-{env}. All listedcacheTtl/rateLimiting*/authentication/cacheInvalidateOnUpdate/collectLogsfields are optional with sensible defaults (caching off, rate limiting off, logs on, fixed-window). - Pipelines (V1, SQL): Tamer creates the pipeline against
/accounts/{id}/pipelines/v1/pipelinesand emitspipelines[]in the generated wrangler config. Thesqlfield is mandatory and references streams (sources) and sinks (destinations) by name — those upstream/downstream resources are not Tamer-managed in this iteration; create them via the Cloudflare dashboard or Wrangler before applying, otherwise the pipeline will exist in non-running status. Pipeline SQL changes on existing pipelines requiretamer destroy --env <env> --target pipeline:<logicalName>followed by re-apply (Cloudflare V1 has no PATCH endpoint today). Derived name pattern:pipe-{logical}-{env}. Default binding key:PIPE_{LOGICAL}(override withbinding). - Workflows: Tamer registers the workflow with Cloudflare via
PUT /accounts/{id}/workflows/{name}and emitsworkflows[]in the generated wrangler config.classNameis the exported class on the owning worker;script_namedefaults to that worker's deployed script (env-suffixed for shared envs), so workflow classes co-located with their owning worker need no extra config. SetscriptNameexplicitly to bind to a different worker. Optionallimits.steps(positive integer) is tracked in state and reapplied on drift. Derived name pattern:wf-{logical}-{env}. Default binding key:WF_{LOGICAL}(override withbinding). Workflow registrations are immutable in shape but mutable in target — class-name or script-name changes are issued as in-place PUTs; deletes go throughDELETE /accounts/{id}/workflows/{name}andtamer destroyremoves them transactionally. - Durable Objects: Managed
resources.durableObjects[]+ worker/templatedoMigrations(append-only wranglermigrations[]). Emitted ontamer deploy/wfp tenant provision— nottamer migrate. Instances and in-DO SQL schema are app-owned. Newdeleted_classestags require--confirm-do-delete. See docs/guide/durable-objects.md. - DNS records: declared at the stack root (not per worker), since the same record shouldn't be redeclared by every worker that happens to live behind it. Add
dnsRecords[]directly on thedefineConfig({ ... })document. Tamer creates each record viaPOST /zones/{zoneId}/dns_recordsand stamps a stable attribution comment (tamer:{stack}:{env}:{logicalName}) so subsequentsync/applyruns can re-adopt the record from a Cloudflare listing even after state loss. Mutable-field drift (content,ttl,proxied,priority,comment) is patched in place viaPATCH /zones/{zoneId}/dns_records/{recordId};typechanges follow Cloudflare's delete-and-recreate convention. State keydns_record:{zoneId}:{type}:{name}. Thelocalenv is always implicitly skipped (DNS is a real-world side effect —wrangler devdoes not own real DNS); useskipEnvsto opt out of additional envs. SetpreserveOnDestroy: trueon a record to keep it on Cloudflare pasttamer destroy(the state row is dropped either way for clean teardown). Requires the DNS: Edit zone-scoped permission for every zone referenced indnsRecords[]. - Secrets Store: Tamer manages account-scoped stores (containers) — never the secret values inside them. On
apply, each declaredsecretsStores[]entry is created viaPOST /accounts/{id}/secrets_store/stores(idempotent — a matching name insecretsStoreListAllshort-circuits to the existingid) and the assigned id is recorded in state. To wire a stored secret into a worker, declare asecretsStoreSecrets[]entry referencing the store by its logical name (store: "apiKeys"); the wrangler generator resolves it to the livestore_idand emits asecrets_store_secrets[]row with{ binding, store_id, secret_name }. The named secret itself must already exist in the store — create it out-of-band viawrangler secrets-store secret create(or the dashboard) so secret material never enterstamer/project.config.tsortamer-state. Derived store name:sec-{logical}-{env}. Cross-resource refs supported (${tamer:secret_store:<logical>.name|id|binding}).tamer destroyremoves managed stores transactionally — Cloudflare cascades the deletion to all contained secrets, so make sure no live worker still binds into the store first.
Stack-scoped DNS records (dnsRecords)
Declared once on the defineConfig({ ... }) root, not per worker. Each entry pins the zone (zoneId), the record type, the DNS name, and the content; everything else is optional.
import { defineConfig } from "@dragonmastery/tamer";
export default defineConfig({
workers: { /* ... */ },
dnsRecords: [
{
logicalName: "apex",
zoneId: "0123456789abcdef0123456789abcdef",
type: "A",
name: "todo.com",
content: "192.0.2.1",
proxied: true,
},
{
logicalName: "www",
zoneId: "0123456789abcdef0123456789abcdef",
type: "CNAME",
name: "www.todo.com",
content: "todo.com",
proxied: true,
},
{
logicalName: "spf",
zoneId: "0123456789abcdef0123456789abcdef",
type: "TXT",
name: "todo.com",
content: "v=spf1 -all",
ttl: 3600,
comment: "anti-spoofing baseline", // appended after Tamer's attribution comment
},
{
logicalName: "mailRouting",
zoneId: "0123456789abcdef0123456789abcdef",
type: "MX",
name: "todo.com",
content: "mail.todo.com",
priority: 10,
preserveOnDestroy: true, // survive `tamer destroy`
},
{
logicalName: "stagingApex",
zoneId: "0123456789abcdef0123456789abcdef",
type: "A",
name: "todo.com",
content: "192.0.2.2",
skipEnvs: ["dev", "prod"], // only created in `staging`
},
],
});Cross-resource references (${tamer:...})
CloudFormation !Ref / !GetAtt analogue. Embed ${tamer:<kind>:<logicalName>.<field>} in worker vars or in tamerRoutes[].host / tamerRoutes[].zone to interpolate values from already-applied state at wrangler-config generation time.
| Kind | name (default) | id | binding |
| --------------------- | ----------------------------- | --------------------------------- | ---------------------- |
| d1 | derived database name | D1 UUID | wrangler binding key |
| r2 | derived bucket name | (unsupported — buckets use name)| wrangler binding key |
| kv | derived namespace title | KV namespace id | wrangler binding key |
| queue | derived queue name | queue id | wrangler binding key |
| hyperdrive | derived config name | hyperdrive config id | wrangler binding key |
| vectorize | derived index name | vectorize index id | wrangler binding key |
| ai_gateway | derived gateway id | derived gateway id | stable cross-ref key (no wrangler binding) |
| pipeline | derived pipeline name | server-assigned pipeline id | wrangler binding key |
| workflow | derived workflow name | server-assigned workflow id | wrangler binding key |
| secret_store | derived store name | server-assigned store id | stable cross-ref key (no wrangler binding) |
| dispatch_namespace | resolved per-env namespace | namespace name | — |
| worker | env-suffixed deployed script | — | — |
Examples:
defineWorker({
resources: { r2: [{ logicalName: "assets" }], queues: [{ logicalName: "events" }] },
vars: {
ASSETS_BUCKET: "${tamer:r2:assets.name}",
EVENTS_QUEUE: "${tamer:queue:events.name}",
INTERPOLATED: "queue=${tamer:queue:events.name};kv=${tamer:kv:cache.id}",
},
tamerRoutes: [
// hosts/zones can also embed references resolved against state
{ host: "api-${tamer:worker:edge.name}.todo.com", zone: "todo.com" },
],
});cf binding DSL (equivalent, typed)
The cf helper builds the same references with autocompletion instead of raw strings:
import { cf, defineConfig } from "@dragonmastery/tamer";
vars: {
ASSETS_BUCKET: cf.r2("assets").name,
EVENTS_QUEUE: cf.queue("events").name,
API_SCRIPT: cf.worker("api").name,
EDGE_QUEUE: cf.import("net", "edgeQueue"),
},Each resource helper exposes .name / .id / .binding; cf.import(stack, output) is the ${tamer:import:…} analogue. Both styles resolve identically.
Resolution behavior:
- Strict mode (
apply,deploy,destroy): unresolved references throwTamerReferenceErrorwith the field path (e.g.worker[default].vars.ASSETS_BUCKET) and a "run apply first" hint, so a typo in<logicalName>or a forgotten apply fails fast. - Tolerant mode (
plan,drift,status,sync): unresolved references are left in place as their original${tamer:...}placeholder, so read-only commands work on a fresh checkout before the first apply.
Stack outputs (outputs)
CloudFormation Outputs analogue: declare named values your stack publishes after a successful apply. Each value is a Tamer reference string (${tamer:<kind>:<logical>.<field>}) — full-string OR interpolated — resolved against the just-completed state and persisted under TamerState.stackOutputs so it survives across runs and is visible to tamer status (and to sibling stacks via ${tamer:import:<stack>.<output>}, shipping next).
export default defineConfig({
stack: "ext",
worker: { main: "src/index.ts", resources: { d1: [{ logicalName: "users", type: "single" }] } },
outputs: {
usersDbId: "${tamer:d1:users.id}",
eventsQueue: "${tamer:queue:events.name}",
adminUrl: "https://admin.example.com/?db=${tamer:d1:users.id}",
pinnedLiteral: "v1.0.0",
},
});Behavior:
- Output names must match
^[a-zA-Z][a-zA-Z0-9_-]*$(CloudFormation-style identifier — keeps them safe in env-var exports, filenames, and the${tamer:import:…}parser). Validated at config-load time. - Resolution runs at the end of a successful
apply, after every resource is in state. A typo in any output (unknown logical name, unknown field, unknown kind) fails the apply withTamerReferenceErrortaggedoutputs.<name>and rolls back when--rollback-on-failureis set. - Persisted entry shape:
{ value, source, resolvedAt }— keeps both the resolved literal and the original${tamer:...}source sostatuscan flag drift. tamer statusprints a per-output row taggedresolved(declared source matches persisted source),pending(declared but never applied),stale(config edit since last apply — re-run apply), ororphan(persisted but no longer declared in config).tamer destroyclearsstackOutputsafter teardown so a futurestatusdoesn't surface refs to deleted resources.- Re-applies are structurally idempotent: when value + source are unchanged,
resolvedAtis updated in memory but state isn't dirtied (no gratuitousrevisionbump).
Cross-stack imports (${tamer:import:<stack>.<output>})
CloudFormation Fn::ImportValue analogue. One stack publishes named values via outputs: (above); a sibling stack consumes them by referencing ${tamer:import:<stack>.<output>} anywhere a regular ${tamer:...} reference is allowed (worker vars, tamerRoutes[].host / .zone, services[].service, dispatch_namespaces[].namespace, even another stack's outputs). Multiple stacks now coexist in the same account-scoped tamer-state D1 by namespacing the state row as tamer_state:{env}:{stack}, so the consumer just reads the producer's row and looks up the named output.
// fixtures/network/tamer/project.config.ts (the "producer")
export default defineConfig({
stack: "net",
description: "Network plane: shared edge router + queue",
worker: { main: "src/edge.ts", resources: { queues: [{ logicalName: "events" }] } },
outputs: {
edgeQueue: "${tamer:queue:events.name}",
region: "iad",
},
});
// fixtures/app/tamer/project.config.ts (the "consumer")
export default defineConfig({
stack: "app",
worker: {
main: "src/index.ts",
vars: {
EDGE_QUEUE: "${tamer:import:net.edgeQueue}",
REGION: "${tamer:import:net.region}",
QUEUE_URL: "https://${tamer:import:net.region}.example.com/q/${tamer:import:net.edgeQueue}",
},
tamerRoutes: [{ host: "${tamer:import:net.region}.app.example.com", zone: "example.com" }],
},
outputs: {
region: "${tamer:import:net.region}", // republish a sibling's value
},
});Behavior:
- Stack identity.
stack: <string>(required) pins the producer's row key (tamer_state:{env}:{stack});description?is surfaced bytamer status. Output names match^[a-zA-Z][a-zA-Z0-9_-]*$(same asoutputs:). - Pre-fetch once per command. At command start (
apply,deploy,dev,migrate,types,plan,drift,sync,status) Tamer scans the config for${tamer:import:…}sites, hydrates a read-onlyStateManagerper referenced sibling, and flattens everystackOutputs.<output>.valueinto an in-memoryimportsmap. Subsequent reference resolution is a pure map lookup — no D1 round-trips mid-build, no partial-failure modes. - Strict at write time (
apply,deploy): a typo, a sibling that hasn't been applied yet, or an output the sibling never published all fail withTamerReferenceErrortagged with the field path (e.g.worker[default].vars.EDGE_QUEUE) and anAvailable outputs on stack "<name>": …hint. Tolerant at read time (drift,status,sync): unresolved import refs stay in place as their literal${tamer:import:…}placeholder, and sibling-fetch errors degrade to warnings.planis partially strict: it tolerates siblings that exist but were never applied (placeholders preserved), but a failure reading the state database aborts the plan — a plan computed from degraded imports would be wrong, and plan output feeds CI gates andapply --plan. - Self-imports are filtered. A stack that mistakenly references its own name in
${tamer:import:…}is dropped at scan time so the typo doesn't silently resolve via the row apply is about to write — it surfaces as the same "no imported stack available" error a missing sibling would produce. tamer statusshows imports. A "Stack imports:" panel lists every${tamer:import:…}site grouped by source stack, with the resolved value (or(missing — run \tamer apply` on that stack)) and a per-outputresolved/unresolved` tag — the operator-facing equivalent of CloudFormation's stack-export view.- Greenfield row key. No compat alias for the legacy literal
cfi_staterow key (pre-0.26) — first run on an env D1 created by an olderbootstrapwill see no state and rebuild fromsync. localenv skips import pre-fetch entirely (no shared state DB exists), so cross-stack composition is a non-localdeployment concern.
Adding a new resource kind (resource registry)
Every Tamer-managed Cloudflare resource (D1, R2, KV, Queues, Hyperdrive, Vectorize, ...) is wired through a single registry at src/core/registry/registry.ts. The CLI commands (apply, sync, drift, destroy, status, import) and the wrangler generator iterate resourceModules — none of them special-case any kind. (Caveat: this holds for the worker-scoped storage kinds. The stack-rooted kinds — dispatch namespaces, DNS records, Logpush jobs — plus worker routes, tenants, and secrets are hand-wired inside apply / drift / destroy and are not picked up by the registry loop; a new stack-rooted kind needs explicit command wiring.)
To add a new kind (e.g. Pipelines, Workflows, Pages):
- Add the config + state types to
src/types.ts(<Kind>ResourceConfig,<Kind>StateEntry, append toWorkerResourcesandStateEntry). - Implement the per-feature module under
src/features/<kind>/(<kind>.apply.ts,<kind>.sync.ts,<kind>.drift.ts,<kind>.destroy.ts,<kind>.status.ts,<kind>.generate.ts). - Author
src/features/<kind>/<kind>.module.tsexporting aResourceModule(seesrc/core/registry/types.ts); it wires the per-feature functions into the genericapply/sync/drift/destroy/status/generate/importOne/pickResources/fetchAllshape and declares itskind,label,configKey, andstateEntryType. - Append the module to
resourceModulesinsrc/core/registry/registry.ts.
That's it — every command picks the kind up automatically and tamer status, tamer drift, tamer plan, tamer import --target <kind>:<logical>, and the generated wrangler.json will all include it. This 4-step recipe applies to worker-scoped storage kinds; stack-rooted kinds additionally need hand-wiring in the commands listed above.
Routes (tamerRoutes)
Workers can declare HTTP routes that Tamer expands per env:
- prod / production → bare apex (
todo.com,admin.platform.com). - any non-prod env (
dev,staging, ephemeralpr-1234, …) →{env}.{apex}(dev.todo.com,pr-1234.todo.com). - local → no route.
wfp.ephemeralEnvPattern does not affect routing — ephemeral envs follow the same non-prod rule. The pattern controls dispatch-namespace sharing, the env.dev override fallback, and env gc eligibility.
defineWorker({
scriptName: "portal-ui",
tamerRoutes: [
{ host: "portal.todo.com" }, // → {env}.portal.todo.com/* (zone_name=portal.todo.com)
{ host: "api.todo.com", zone: "todo.com" }, // explicit parent zone
{ host: "todo.com", customDomain: true }, // CustomDomainRoute
],
});How routes land in Wrangler vs the Cloudflare API:
- Zone-name routes (
zone_name+pattern, including defaulttamerRoutesexpansion) are not written into generatedwrangler.json. They are tracked asworker_routeentries intamer-stateand applied with the Workers Routes API after a successfulwrangler deployfor that script (so the worker exists before the route binds).tamer syncreconciles listings into state;tamer destroyremoves API routes before deleting workers;tamer driftreports mismatches for these routes. - Custom-domain
tamerRoutes(customDomain: true) and any staticroutesyou declare in worker config are still merged into generatedwrangler.jsonand deployed by Wrangler only.
To opt out per env, set skipEnvs (default ["local"]) or prodEnvs (default ["prod", "production"]). Tamer never strips or rewrites the wrangler-native routes field — declare both if you need static escape-hatch routes alongside tamerRoutes.
Workspace tenants (runtime)
TamerState (D1 tamer-state) includes a tenants map keyed product:workspace for signup-time resources not declared in tamer/project.config.ts. The model is configured under wfp.namespaces.<product>.workers.<service> — one dispatch namespace per product, each holding one or more worker templates (services) that every provisioned tenant gets a copy of.
export default defineConfig({
stack: "platform",
wfp: {
ephemeralEnvPattern: "^pr-",
tenantDirectory: {
worker: "dispatch",
shardGroup: "directory",
service: "tenant",
},
namespaces: {
// key = product name = dispatch namespace logical name
todo: {
workers: {
default: { // service template
main: "apps/tenant/src/index.ts",
shardGroups: [
{ name: "primary", binding: "DB", migrationsDir: "db/primary" },
],
d1: [
{ logicalName: "system", binding: "DB_SYSTEM", migrationsDir: "db/system" },
],
vars: { REGION: "iad" },
services: [...], // service bindings
secrets: [...], // dispatch secrets
},
// ...additional service templates per tenant
},
},
},
},
});tamer wfp tenant provision --env dev --workspace acme [--product todo] [--namespace todo]— selects the dispatch namespace (--namespace, or the single configured one;--productdefaults to the namespace logical name), then iterates every worker template innamespace.workers: for each[service, template]it creates shard 0 for everytemplate.shardGroups[]entry (physical namedb_{group}_000_{product}_{workspace}_{env}, binding{prefix}_000), one D1 pertemplate.d1[]utility database, assigns a monotonictenantNumberand writes the tenant directory row whenwfp.tenantDirectoryis configured, and uploads the dispatch script named{service}-{workspace}inside the namespace ({service}-{workspace}-{env}for ephemeral envs).--mainoverrides every service'stemplate.main; otherwise each template deploys its own configuredmain. RecordsTenantStateEntryasready. Use--artifact-key <path/in/tamer-artifacts>instead of--mainto deploy from R2.--shardsis accepted for backward compatibility but is a no-op under the shard-group model. Additional flags:--compatibility-date <date>/--compatibility-flags <a,b>(override the template's wrangler compat settings for the uploaded script),--module-name <file>(entry module name inside the bundle), and--no-migrate/--no-seed(skip D1 migrations / seed before script upload; both run by default). Re-runs are idempotent for databases (existing shards adopted, missing ones added) but always re-upload scripts. Pass--jsonfor a trailing JSON envelope on stdout — designed for the Cloudflare Container caller (provision-workflow, see container invocation contract).tamer wfp tenant add-shard --env dev --workspace acme --group primary [--service default]— appends the next physical D1 to a tenant shard group, runs migrations + seed, and re-uploads the dispatch script with the new binding.tamer wfp tenant reset --env dev --workspace acme [--product todo] [--kind d1 | --target d1:system | --target shard_group:primary]— deletes and recreates tenant D1(s) (same derived name, new UUID), updates tenant state, runs migrations + seed, and reuploads dispatch scripts. Requires exactly one scope flag. Does not remove the tenant record or dispatch namespace (unlikedestroy). Shared envs require--confirm-tenant <workspace>or--force.tamer wfp tenant destroy --env dev --workspace acme [--product todo] --confirm-tenant acme— deletes every dispatch script, every tenant D1 recorded in state (plus any discovered via CF-name identity that state lost — e.g. after a namespace rename), and removes the tenant record (shared envs require confirmation or--force).--namespace <name>also accepts a namespace no longer in config when a tenant in state still references it, so a renamed/removed namespace's tenant can be cleaned up without state surgery. Pass--jsonfor{ status: "destroyed"|"noop"|"failed", removed: { workers: { ... } }, errors }.
Namespace keys are immutable identity. A wfp.namespaces.<product> key flows into the dispatch namespace name, tenant state key, and D1/R2 names — renaming it (e.g. aat → tenant) orphans the old scripts/databases/buckets (Cloudflare can't rename them). Recover with tamer sync (refills state + flags the old namespace) → tamer drift (shows ghosts) → tamer wfp tenant destroy --namespace <old> --workspace <ws> (one tenant) or --namespace <old> --orphans (every tenant under the old key) → tamer destroy --orphans. tamer sync --strict-namespace-guard exits nonzero in CI when tenant entries reference namespaces no longer in config.
tamer status --env dev --tenant todo:acme— shows provisioning status and bound resources.
Container invocation contract
The tenant runtime commands are designed to be invoked by a Cloudflare Container (the same image CI uses — see Dockerfile). Build and run:
docker build -t tamer .
docker run --rm \
-e CLOUDFLARE_ACCOUNT_ID -e CLOUDFLARE_API_TOKEN \
-v "$PWD":/work -w /work \
tamer wfp tenant provision --env dev --product todo --workspace acme \
--artifact-key todo/worker.js --jsonContract:
- Exit code is the source of truth.
0on success or no-op, non-zero on failure.provision-workflowshould branch on this, not on log scraping. - Stdout's last line is a JSON envelope when
--jsonis passed (other lines remain human-readable). Failure path also emits an envelope (status: "failed",error: <message>) before the non-zero exit. - No interactive prompts. Shared-env safety still applies — destroy callers must pass
--confirm-tenant <workspace>(or--force) explicitly. - Idempotent. Workflow retries are safe: re-invoking
wfp tenant provisionwith the same args producesstatus: "noop"once the tenant isready; partial-failure resumes pick up from the last persisted shard.
If two writers update state concurrently, persist throws StateConflictError and the CLI exits with code 3; re-run after refreshing state.
Plan (tamer plan)
CloudFormation-style preview of apply + deploy. Reads only — never mutates state or Cloudflare. Shows every D1 / R2 / KV / dispatch namespace / Workers zone route declared in tamer/project.config.ts that isn't yet on Cloudflare, plus declared global Worker scripts that have no deployment (queried directly via GET /accounts/.../workers/scripts/{name}; dispatch-namespace tenant scripts are skipped — those belong to wfp tenant provision / WFP).
tamer plan --env dev # human-readable summary, exit 0
tamer plan --env dev --json # machine-readable for CI
tamer plan --env dev --detailed-exitcode
# ↑ exit 0 if no changes, 2 if changes pending (Terraform convention),
# non-zero on errors as usual
tamer plan --env dev --out plan.json # also persist a verifiable plan file
tamer plan --env dev --destroy # preview what `tamer destroy` would remove
tamer plan --env dev --destroy --json --detailed-exitcode
# ↑ exit 2 when there is anything to delete (CI gate before stack teardown)Scoped plan / apply / destroy (--target / --kind) — Limit preview, provisioning, or teardown to a single declared resource (--target <kind>:<logicalName>, e.g. d1:settings, r2:assets, dispatch_namespace:workspace, dns_record:apex_txt) or to every resource of one kind (--kind <kind>, e.g. --kind d1). The two flags are mutually exclusive. A scoped tamer apply still runs sync, still regenerates every worker’s wrangler.json, and still resolves the full outputs: block at the end so state stays coherent. A scoped tamer destroy deletes exactly that resource's state entries (every shard, for a sharded D1; or every resource of the kind for --kind) and nothing else — workers, routes, stack outputs, and metadata are untouched; the protected-env gate (--confirm-env) still applies; --wipe-metadata / --skip-workers / --plan are rejected. Destroy additionally accepts shard_group:<groupName> as a --target. Because scoped deletes swallow Cloudflare 404s and still prune state, tamer destroy --target is also the recovery path when a resource was deleted out-of-band (drift missingFromCloudflare) and apply refuses to recreate it. --target / --kind are incompatible with apply --plan and plan --out — saved plan files attest the whole stack. worker_route and worker_script are not supported here (zone routes apply after tamer deploy; scripts are deployed by wrangler).
tamer reset (D1 greenfield loop) — Chains targeted destroy → targeted apply → tamer migrate → tamer seed → redeploy for workers that bind the reset D1(s): tamer reset --env dev --target d1:app-db or --kind d1 for every declared stack D1. Squashing migration files on disk is manual; reset wipes the Cloudflare database and replays migrations + current seed from scratch. --skip-deploy opts out of the redeploy step (workers keep stale UUID bindings until tamer deploy). D1 only. For local SQLite, tamer reset --env local --target d1:app-db deletes miniflare files and reruns local migrations + seed (no deploy). Protected envs require --confirm-env. See Squashing migrations and Seed.
The plan reuses the drift engine for "would create" detection, so the rules are the same: drift filters by current-stack logicalNames in shared-state setups, and local envs only show storage that would be created in-memory.
For tracked resources whose recorded state has drifted from the declared config, tamer plan also emits update and replace items with terraform-style attribute change detail:
DNS records (1):
~ apex -> A todo.com (content: 192.0.2.1 -> 192.0.2.99, proxied: false -> true)
Vectorize (1):
± embeddings -> vec-embeddings-dev (dimensions: 768 -> 1536, metric: cosine -> euclidean)
Summary: 0 to create, 1 to update, 1 to replace.+ is create, ~ is update (in-place PATCH on apply), ± is replace (Cloudflare's API rejects PATCH on the changed field, so apply must delete + recreate — DNS record type change, Vectorize dimensions / metric, Pipelines V1 sql), - is delete (destroy plan only). --json includes the same diffs as items[].changes[]: { field, from, to, kind: "mutable" | "immutable" }. Today the update / replace engine covers DNS records, Workflows, Vectorize, Pipelines, Hyperdrive, AI Gateway, and Logpush jobs; other kinds emit only create / delete.
Destroy preview (tamer plan --destroy)
Mutually exclusive with the forward plan. Walks tamer-state, filters to entries owned by the current tamer/project.config.ts stack (same scoping as tamer destroy / tamer drift), and emits a delete PlanItem per managed kind — D1, R2, KV, Queues, Hyperdrive, Vectorize, AI Gateway, Pipelines, Workflows, Secrets Stores, dispatch namespaces, DNS records, and zone tamerRoutes. Declared global Worker scripts that currently exist on Cloudflare also appear as worker_script deletes. Dispatch-namespace tenant scripts are intentionally excluded — those are managed via wfp tenant provision / wfp tenant destroy. Read-only: never mutates state or Cloudflare.
--out destroy.json writes the destroy plan with the same (config, state, cloudflare) attestation as forward plans (report.mode: "destroy"); tamer destroy --plan destroy.json then recomputes the three hashes and refuses to proceed unless they all match (override with --allow-stale). Mode mismatch is non-overridable: apply --plan rejects destroy plans and destroy --plan rejects forward plans regardless of --allow-stale, so a saved plan can only ever execute the operation the operator reviewed.
tamer plan --env dev --destroy --out destroy.json
git diff destroy.json && code review ...
tamer destroy --env dev --confirm-env dev --plan destroy.json
# Refuses with one of:
# "config has changed since plan was generated; ..."
# "recorded state has changed since plan was generated; ..."
# "Cloudflare-side resources drifted since plan was generated (out-of-band create/delete); ..."
tamer destroy --env dev --confirm-env dev --plan destroy.json --allow-staleSaved plans + transactional apply
--out plan.json writes a tamer-plan/v1 document containing the plan report and an attestation: SHA-256 hashes of (a) the parsed config, (b) the recorded state's resources/tenants/stack (timestamps and revision counters are excluded so legitimate noise doesn't trigger false mismatches), and (c) the live Cloudflare snapshot at plan time — the per-kind fetchAll + sync result against a fresh in-memory state, with timestamps stripped. cloudflareHash is omitted for env: local and for legacy plans written before drift-aware refresh.
tamer apply --plan plan.json recomputes all three hashes against the current (config, state, live Cloudflare) triple and refuses to proceed if any drifted, mirroring terraform apply <planfile> plus terraform refresh. Out-of-band creates or deletes on Cloudflare between plan and apply are caught here; the recorded state alone wouldn't notice. Pass --allow-stale only when you've reviewed the diff and want to override:
tamer plan --env dev --out plan.json
git diff plan.json && code review ...
tamer apply --env dev --plan plan.json
# Refuses with one of:
# "config has changed since plan was generated; ..."
# "recorded state has changed since plan was generated; ..."
# "Cloudflare-side resources drifted since plan was generated (out-of-band create/delete); ..."
tamer apply --env dev --plan plan.json --allow-stale # opt-in overridetamer apply --rollback-on-failure snapshots state-entry keys at start and, on any failure, walks every key created during the run in reverse insertion order and asks the owning module's destroyOne to delete the underlying Cloudflare resource (best-effort; failures log a warning and never mask the original error). State entries whose type no module owns are left in place with a warning rather than rolled back. Use it in CI to keep your account clean when an apply fails halfway:
tamer apply --env dev --plan plan.json --rollback-on-failureImport (tamer import)
CloudFormation import_resources analogue. When state is missing one specific resource that already exists on Cloudflare (created out-of-band, or after a partial restore), import registers it under its logical name without touching the resource itself. Verifies the Cloudflare object exists and that the cf-side name matches Tamer's derived name for the <logical> in --target <kind>:<logical> before writing the state entry.
tamer import --env dev --target d1:settings --cf-id <d1-uuid>
tamer import --env dev --target d1:events --shard-date 2026-01-15 --cf-id <d1-uuid>
tamer import --env dev --target r2:assets --cf-id r2-assets-dev
tamer import --env dev --target kv:cache --cf-id <kv-namespace-id>
tamer import --env dev --target queue:events --cf-id <queue-id>
tamer import --env dev --target hyperdrive:primary --cf-id <hyperdrive-config-id>
tamer import --env dev --target dispatch_namespace:workspace-workers --cf-id workspace-workers-dev
tamer import --env dev --target worker_route:portal_ui --route-id <route-id> --zone-name portal.todo.com
tamer import --env dev --target dns_record:apex --cf-id <dns-record-id>Use tamer sync for bulk discovery instead. import refuses to overwrite an existing state row that points at a different Cloudflare id.
Drift (tamer drift)
Read-only diff between Tamer state, Cloudflare reality, and the current tamer/project.config.ts. Useful for CI guards and pre-deploy sanity checks; never writes to state or to Cloudflare.
For each managed resource kind (D1, R2, KV, Queues, Hyperdrive, dispatch namespaces, HTTP routes when you use zone-name tamerRoutes, DNS records, Logpush jobs, workspace tenants, and worker secrets (declared-secret reconciliation), and Worker scripts for declared global workers) the report lists three buckets:
missingFromCloudflare— state references a resource that no longer exists on Cloudflare (deleted out-of-band).applywill not recreate it while the stale state entry exists; runtamer destroy --env <env> --target <kind>:<logical>to prune the entry (the delete tolerates the 404), thentamer applyto recreate.unrecordedInState— Cloudflare has a resource that matches a declared logical name in this stack's config, but no state entry tracks it (runtamer sync).undeployed— declared in this stack's config, present in neither state nor Cloudflare (runtamer apply).
tamer drift --env dev # human-readable; non-zero exit when drift
tamer drift --env dev --json # machine-readable for CIDrift filters by the current config's logicalNames, so multi-stack environments sharing one tamer-state D1 only report on resources owned by the current stack. Zone-name tamerRoutes appear under HTTP routes (Workers Routes API); custom-domain and wrangler-only routes are not checked here (Wrangler remains their source of truth). The Worker scripts section only emits undeployed (declared but missing on Cloudflare) since global script ids aren't tracked in state today.
State and artifacts
For non-local envs, tamer bootstrap provisions three pieces of account-scoped metadata Tamer owns directly (run once per account — the same resources serve every env and every stack):
tamer-state(D1): stores deployment state as JSON (logical resources mapped to Cloudflare IDs — D1 UUIDs, KV IDs, dispatch namespaces, Workers zone route ids for API-managedtamerRoutes, etc.) plus optional CloudFormation-style stack metadata (stack.name,stack.owner) and a last operation marker (lastOperation: command name,in_progress/succeeded/failed, timestamps, error message) updated byapply/deploy/destroy/import. Each successful or failed run also appends a snapshot to operationHistory (newest first, capped at 50 entries) so operators can review recent changes.tamer events(read-only) prints that timeline, optional--limit N(1–100; rejected above 100), and--jsonfor automation.syncmerges API listings into this document; Cloudflare remains the source of truth for what exists. The state schema auto-migrates older versions in place on read. Each env+stack combo is isolated by row keytamer_state:{env}:{stack}(where{stack}comes from the root-levelstackfield intamer/project.config.ts), so multiple envs and multiple stacks share the sametamer-stateD1 without overwriting each other — required by${tamer:import:<stack>.<output>}cross-stack references.tamer-artifacts(R2): holds Tamer-managed bundles keyed by{resource-type}/{name}/{version}/.... Used by runtime provisioning paths (e.g.wfp tenant provision --artifact-key, read-only at deploy time otherwise).tamer-secrets(D1): account-scoped encrypted secrets vault (see docs/guide/secrets.md).tamer destroy --wipe-metadatadeletes the env's rows from here.
Multi-stack flows (fixtures/platform, fixtures/portal, and fixtures/internal) share one tamer-state D1, one tamer-artifacts R2 bucket, and one tamer-secrets D1 per account when you use the normal bring-up scripts; tamer:down passes --wipe-metadata only on the platform destroy (fixtures/platform) so the env's metadata rows are removed from tamer-state + tamer-secrets once at the end. The shared databases themselves persist (other envs may still rely on them); the tamer-artifacts bucket is not emptied per-env (disposable build bundles age out). An operator who wants to remove Tamer entirely from an account must drop the three metadata resources manually via the Cloudflare dashboard.
See Runbook for the platform + portal + internal fixtures (spin-up, tear-down, flags).
Developing
From the repo root after bun install:
- CLI:
bun run tamer -- <command>(rootpackage.json→bun src/cli/index.ts) or invokebun src/cli/index.tsdirectly. - Fixtures: reference stacks under
fixtures/platform,fixtures/portal, andfixtures/internal. Bring up / tear down withbun run tamer:up/bun run tamer:down— see Runbook. - Tests:
bun run typecheckandbun run test. CI usesbun run test:report(JUnit →reports/junit.xml, dots output). Coverage:bun run test:coverage→coverage/lcov.info. Details: Testing. - CI:
.github/workflows/ci.yaml— typecheck + unit tests on pushes and PRs tomain/apex. - Publish: npm releases via
.github/workflows/publish-npm.yaml(Trusted Publishing / OIDC). Packaging: npm-packaging.md. Cross-repo contracts: publish.md.
Fixture smoke test (requires .env under each fixture directory):
bun run tamer:up -- --env dev
# …
bun run tamer:down -- --env dev --confirm-env devDocumentation
| Doc | Audience |
| --- | --- |
| Consumer quickstart | Downstream repos — install from npm, first deploy, version pinning |
| Brownfield adoption | Downstream repos — legacy CF names, naming hooks, sync/plan flow (no IDs in config) |
| Single-Product Multi-Tenant | Architecture guide — WFP tenants, dispatch routing, per-tenant D1 shards |
| CLI reference | All commands and flags |
| Resource kinds | Declaring resources; adding new kinds |
| Field boundary | Which config fields are Tamer-only vs Wrangler passthrough |
| Values lifecycle | Where vars/secrets/.env values come from and land |
| Config types | Exported TypeScript types; derived name patterns |
| Testing | Contributors — test runners, JUnit, coverage |
| Publish / integration requirements | Maintainers — cross-repo contracts for DragonMastery stacks |
| npm CLI packaging strategy | Maintainers — npm package build and release layout |
| Runbook | Contributors — platform + portal + internal fixture walkthrough |
| Platform architecture | Platform owners — repos, stacks, runtime model, rollout |
Full documentation site: bun run docs:dev (VitePress).
License
TAMER EVALUATION LICENSE. See LICENSE for terms.
