npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dragonmastery/tamer

v0.29.0

Published

Tamer: Cloudflare Workers infra CLI (sync, apply, deploy, migrate, destroy) and Wrangler-oriented TypeScript types.

Downloads

281

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 Consumer quickstart. The rest of this README is for contributors hacking Tamer and for CLI/config reference.

Installation

Contributors (this repo):

bun install

Requires 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 wrangler

Also requires the wrangler peer (>=4.0.0). Install, scripts, stack layout, and first deploy: Consumer quickstart.

Consumers (downstream repos)

Install from npm and walk through bootstrap → apply → deploy in docs/consumer-quickstart.md. Adopting existing D1/R2/workers without CF IDs in config: docs/brownfield-adoption.md. Resource kinds, ${tamer:import:…}, outputs, and CLI flags remain documented below for reference.

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 per-env Tamer metadata: `tamer-state-<env>` (D1) and `tamer-artifacts-<env>` (R2)
tamer sync       Sync local state from Cloudflare (no writes)
tamer apply      Provision missing D1 / R2 / KV / Queues / Hyperdrive / Vectorize / AI Gateways / Pipelines / Workflows / Secrets Stores / DNS records / dispatch namespaces
tamer migrate    Run D1 migrations per worker
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 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 provision-tenant  Runtime: create tenant D1 + upload dispatch script (`--main` or `--artifact-key`)
tamer destroy-tenant    Runtime: remove tenant script + D1 + state (shared envs: `--confirm-tenant`)
tamer destroy    Remove workers + storage + namespaces for an env
tamer wfp put    Upload a single-module Worker to a dispatch namespace
tamer wfp delete Delete a Worker from a dispatch namespace

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 tenant.protectedEnvs from tamer.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> (destroy-tenant: same rule, sourced from tenant.protectedEnvs), --skip-workers (destroy), --wipe-metadata (destroy: delete shared tamer-state-<env> D1 and tamer-artifacts-<env> R2; use on the last stack in a multi-stack teardown), --dispatch-namespace <name> (deploy), --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).

tamer import flags: --kind d1|r2|kv|queue|hyperdrive|vectorize|ai_gateway|pipeline|workflow|secret_store|dns_record|dispatch_namespace|worker_route, --logical <name> (logical name from tamer.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_ID
  • CLOUDFLARE_API_TOKEN
  • R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY (optional) — R2 S3 API credentials. When both are set, tamer destroy empties managed R2 buckets (delete all objects, abort incomplete multipart uploads) before removing the bucket. Not the same as CLOUDFLARE_API_TOKEN.
  • TAMER_DEV_BASE_PORT (default 8787, used by dev --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.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_TOKENnot 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 apply and emits queues.producers[] in the generated wrangler config. Consumer subscriptions stay wrangler-side — set them on the worker config under queues.consumers (passed through verbatim).
  • Hyperdrive: the origin connection (including password and optional access_client_secret) is sent to Cloudflare on apply and 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 enter tamer.config.ts. localConnectionString is written to the generated wrangler config for wrangler dev only.
  • Vectorize: dimensions and metric (cosine | euclidean | dot-product) are immutable once the index is created. To change them, run tamer destroy --resource <logicalName> (or scope to the kind) and re-apply. The generated wrangler config emits vectorize[] 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 worker vars to inject the derived gateway id. Derived id pattern: aigw-{logical}-t-{tenantId}-{env}. All listed cacheTtl / rateLimiting* / authentication / cacheInvalidateOnUpdate / collectLogs fields 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/pipelines and emits pipelines[] in the generated wrangler config. The sql field 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 require tamer destroy --resource <logicalName> followed by re-apply (Cloudflare V1 has no PATCH endpoint today). Derived name pattern: pipe-{logical}-t-{tenantId}-{env}. Default binding key: PIPE_{LOGICAL}_T_{TENANT} (override with binding).
  • Workflows: Tamer registers the workflow with Cloudflare via PUT /accounts/{id}/workflows/{name} and emits workflows[] in the generated wrangler config. className is the exported class on the owning worker; script_name defaults to that worker's deployed script (env-suffixed for shared envs), so workflow classes co-located with their owning worker need no extra config. Set scriptName explicitly to bind to a different worker. Optional limits.steps (positive integer) is tracked in state and reapplied on drift. Derived name pattern: wf-{logical}-t-{tenantId}-{env}. Default binding key: WF_{LOGICAL}_T_{TENANT} (override with binding). Workflow registrations are immutable in shape but mutable in target — class-name or script-name changes are issued as in-place PUTs; deletes go through DELETE /accounts/{id}/workflows/{name} and tamer destroy removes them transactionally.
  • 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 the defineConfig({ ... }) document. Tamer creates each record via POST /zones/{zoneId}/dns_records and stamps a stable attribution comment (tamer:<tenantId>:<env>:<logicalName>) so subsequent sync / apply runs 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 via PATCH /zones/{zoneId}/dns_records/{recordId}; type changes follow Cloudflare's delete-and-recreate convention. State key dns_record:{zoneId}:{type}:{name}. The local env is always implicitly skipped (DNS is a real-world side effect — wrangler dev does not own real DNS); use skipEnvs to opt out of additional envs. Set preserveOnDestroy: true on a record to keep it on Cloudflare past tamer destroy (the state row is dropped either way for clean teardown). Requires the DNS: Edit zone-scoped permission for every zone referenced in dnsRecords[].
  • Secrets Store: Tamer manages account-scoped stores (containers) — never the secret values inside them. On apply, each declared secretsStores[] entry is created via POST /accounts/{id}/secrets_store/stores (idempotent — a matching name in secretsStoreListAll short-circuits to the existing id) and the assigned id is recorded in state. To wire a stored secret into a worker, declare a secretsStoreSecrets[] entry referencing the store by its logical name (store: "apiKeys"); the wrangler generator resolves it to the live store_id and emits a secrets_store_secrets[] row with { binding, store_id, secret_name }. The named secret itself must already exist in the store — create it out-of-band via wrangler secrets-store secret create (or the dashboard) so secret material never enters tamer.config.ts or tamer-state-*. Derived store name: sec-{logical}-t-{tenantId}-{env}. Cross-resource refs supported (${tamer:secret_store:<logical>.name|id|binding}). tamer destroy removes 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" },
  ],
});

Resolution behavior:

  • Strict mode (apply, deploy, destroy): unresolved references throw TamerReferenceError with 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 CfiState.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({
  tenant: { id: "platform", name: "Platform", slug: "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 with TamerReferenceError tagged outputs.<name> and rolls back when --rollback-on-failure is set.
  • Persisted entry shape: { value, source, resolvedAt } — keeps both the resolved literal and the original ${tamer:...} source so status can flag drift.
  • tamer status prints a per-output row tagged resolved (declared source matches persisted source), pending (declared but never applied), stale (config edit since last apply — re-run apply), or orphan (persisted but no longer declared in config).
  • tamer destroy clears stackOutputs after teardown so a future status doesn't surface refs to deleted resources.
  • Re-applies are structurally idempotent: when value + source are unchanged, resolvedAt is updated in memory but state isn't dirtied (no gratuitous revision bump).

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 tamer-state-<env> D1 by namespacing the state row as cfi_state:{stackName}, so the consumer just reads the producer's row and looks up the named output.

// fixtures/network/tamer.config.ts (the "producer")
export default defineConfig({
  stack: { name: "net", description: "Network plane: shared edge router + queue" },
  tenant: { id: "platform", name: "Platform", slug: "net" },
  worker: { main: "src/edge.ts", resources: { queues: [{ logicalName: "events" }] } },
  outputs: {
    edgeQueue: "${tamer:queue:events.name}",
    region:    "iad",
  },
});

// fixtures/app/tamer.config.ts (the "consumer")
export default defineConfig({
  stack: { name: "app" },
  tenant: { id: "platform", name: "Platform", slug: "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: { name?, description? } pins the producer's row key; name defaults to tenant.slug, so existing single-stack configs keep working unchanged. Output names match ^[a-zA-Z][a-zA-Z0-9_-]*$ (same as outputs:).
  • 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-only StateManager per referenced sibling, and flattens every stackOutputs.<output>.value into an in-memory imports map. 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 with TamerReferenceError tagged with the field path (e.g. worker[default].vars.EDGE_QUEUE) and an Available outputs on stack "<name>": … hint. Tolerant at read time (plan, drift, status, sync): unresolved import refs stay in place as their literal ${tamer:import:…} placeholder so a fresh checkout works before the first apply.
  • 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 status shows imports. A new "Inbound 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-output resolved/unresolved` tag — the operator-facing equivalent of CloudFormation's stack-export view.
  • Greenfield row key. No compat alias for the legacy literal cfi_state row key — first run on an env D1 created by an older bootstrap will see no state and rebuild from sync.
  • local env skips import pre-fetch entirely (no shared state DB exists), so cross-stack composition is a non-local deployment 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.

To add a new kind (e.g. Pipelines, Workflows, Pages):

  1. Add the config + state types to src/types.ts (<Kind>ResourceConfig, <Kind>StateEntry, append to WorkerResources and StateEntry).
  2. 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).
  3. Author src/features/<kind>/<kind>.module.ts exporting a ResourceModule (see src/core/registry/types.ts); it wires the per-feature functions into the generic apply / sync / drift / destroy / status / generate / importOne / pickResources / fetchAll shape and declares its kind, label, configKey, and stateEntryType.
  4. Append the module to resourceModules in src/core/registry/registry.ts.

That's it — every command picks the kind up automatically and tamer status, tamer drift, tamer plan, tamer import --kind <kind>, and the generated wrangler.json will all include it.

Routes (tamerRoutes)

Workers can declare HTTP routes that Tamer expands per env per handoff §6:

  • prod / production → bare apex (todo.com, admin.platform.com).
  • other shared envs (dev, staging, …) → {env}.{apex} (dev.todo.com).
  • ephemeral envs (any env matching tenant.ephemeralEnvPattern in tamer.config.ts — e.g. "^pr-" for pr-1234) → {env}.{apex} (pr-1234.todo.com).
  • local → no route.
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 default tamerRoutes expansion) are not written into generated wrangler.json. They are tracked as worker_route entries in tamer-state-{env} and applied with the Workers Routes API after a successful wrangler deploy for that script (so the worker exists before the route binds). tamer sync reconciles listings into state; tamer destroy removes API routes before deleting workers; tamer drift reports mismatches for these routes.
  • Custom-domain tamerRoutes (customDomain: true) and any static routes you declare in worker config are still merged into generated wrangler.json and 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)

CfiState (D1 tamer-state-{env}) includes a tenants map keyed product:workspace for signup-time resources not declared in tamer.config.ts.

  • tamer provision-tenant --env dev --product todo --workspace acme --main ./worker.js — for every role declared in tenant.d1Shards in tamer.config.ts, creates the per-tenant D1 (db_{role}_{w}_{p}_t_{tid}_{env}); then uploads the dispatch script to the first configured dispatch namespace and records TenantStateEntry as ready. Tamer ships no built-in shard layout — the engine is opinion-free, so a Dragoncore-style product picks d1Shards: ["system", "app", "history"], a single-DB tenant picks ["main"], a billing/content split picks ["billing", "content"], and a tenant that needs no per-tenant DB at all simply omits d1Shards and gets the dispatch script alone. Use --artifact-key <path/in/tamer-artifacts-{env}> instead of --main to deploy from R2. Pass --shards a,b to trim the configured layout to a subset for ephemeral previews; the CLI flag cannot extend the configured set (config is the source of truth — typos surface with the configured roles in the error message). Re-runs are idempotent: existing shards are adopted, missing shards are added in place, so editing tenant.d1Shards to add a new role and re-running picks up the new shard without disturbing the others. Pass --json for a single trailing JSON line on stdout { status: "ready"|"noop"|"failed", tenantKey, scriptName, dispatchNamespaceName, shards: [{role, derivedName, cfId}] } — designed for the Cloudflare Container caller (provision-workflow, see container invocation contract).
  • tamer destroy-tenant --env dev --product todo --workspace acme --confirm-tenant acme — deletes the dispatch script, every tenant D1 shard recorded in state, and removes the tenant record (shared envs require confirmation or --force). Pass --json for { status: "destroyed"|"noop"|"failed", removed: { scriptName, dispatchNamespaceName, shards }, errors }.
  • 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 (provision-workflow per docs/handoff.md §7) — same image CI uses (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 provision-tenant --env dev --product todo --workspace acme \
    --artifact-key todo/worker.js --json

Contract:

  • Exit code is the source of truth. 0 on success or no-op, non-zero on failure. provision-workflow should branch on this, not on log scraping.
  • Stdout's last line is a JSON envelope when --json is 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 provision-tenant with the same args produces status: "noop" once the tenant is ready; 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.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 provision-tenant / 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 (--target) — Limit preview or provisioning to a single declared resource: --target <kind>:<logicalName> (e.g. d1:settings, r2:assets, dispatch_namespace:workspace, dns_record:apex_txt). Uses the same kind strings as tamer import --kind. 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. --target is 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).

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-t-acme-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, and Pipelines; other kinds emit only create / delete.

Destroy preview (tamer plan --destroy)

Mutually exclusive with the forward plan. Walks tamer-state-{env}, filters to entries owned by the current tamer.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 provision-tenant / destroy-tenant. 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-stale

Saved 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 override

tamer 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). Use it in CI to keep your account clean when an apply fails halfway:

tamer apply --env dev --plan plan.json --rollback-on-failure

Import (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 --logical before writing the state entry.

tamer import --env dev --kind d1 --logical settings --cf-id <d1-uuid>
tamer import --env dev --kind d1 --logical events --shard-date 2026-01-15 --cf-id <d1-uuid>
tamer import --env dev --kind r2 --logical assets --cf-id r2-assets-t-acme-dev
tamer import --env dev --kind kv  --logical cache --cf-id <kv-namespace-id>
tamer import --env dev --kind queue --logical events --cf-id <queue-id>
tamer import --env dev --kind hyperdrive --logical primary --cf-id <hyperdrive-config-id>
tamer import --env dev --kind dispatch_namespace --logical workspace-workers --cf-id workspace-workers-dev
tamer import --env dev --kind worker_route --logical portal_ui --route-id <route-id> --zone-name portal.todo.com
tamer import --env dev --kind dns_record --logical 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.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, 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; consider rerunning apply or pruning state).
  • unrecordedInState — Cloudflare has a resource that matches a declared logical name in this stack's config, but no state entry tracks it (run tamer sync).
  • undeployed — declared in this stack's config, present in neither state nor Cloudflare (run tamer apply).
tamer drift --env dev          # human-readable; non-zero exit when drift
tamer drift --env dev --json   # machine-readable for CI

Drift filters by the current config's logicalNames, so multi-stack environments sharing one tamer-state-<env> 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 --env <env> provisions two pieces of per-env metadata Tamer owns directly:

  • tamer-state-<env> (D1): stores deployment state as JSON (logical resources mapped to Cloudflare IDs — D1 UUIDs, KV IDs, dispatch namespaces, Workers zone route ids for API-managed tamerRoutes, 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 by bootstrap / apply / 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, and --json for automation. sync merges API listings into this document; Cloudflare remains the source of truth for what exists. State schema is on v4 (auto-migrates v2/v3 in place on read). Each stack's document lives at row key cfi_state:{stackName} (where stackName comes from stack: { name? } in tamer.config.ts, defaulting to tenant.slug), so multiple stacks can share the same tamer-state-<env> D1 without overwriting each other — required by ${tamer:import:<stack>.<output>} cross-stack references.
  • tamer-artifacts-<env> (R2): holds Tamer-managed bundles keyed by {resource-type}/{name}/{version}/.... Used by future runtime provisioning paths (read-only at deploy time today).

Multi-stack flows (fixtures/platform, fixtures/portal, and fixtures/internal) share one tamer-state-<env> and one tamer-artifacts-<env> per account when you use the normal bring-up scripts; tamer:down passes --wipe-metadata only on the platform destroy (fixtures/platform) so the metadata DB and artifacts bucket are removed once at the end. The bucket delete is best-effort — if it still has objects, the operator must clean them and re-run --wipe-metadata.

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> (root package.jsonbun src/cli/index.ts) or invoke bun src/cli/index.ts directly.
  • Fixtures: reference stacks under fixtures/platform, fixtures/portal, and fixtures/internal. Bring up / tear down with bun run tamer:up / bun run tamer:down — see Runbook.
  • Tests: bun run typecheck and bun run test. CI uses bun run test:report (JUnit → reports/junit.xml, dots output). Coverage: bun run test:coveragecoverage/lcov.info. Details: Testing.
  • CI: .github/workflows/ci.yaml — typecheck + unit tests on pushes and PRs to main / apex.
  • Publish: npm releases via .github/workflows/publish-npm.yaml (Trusted Publishing / OIDC). Packaging: npm-cli-packaging-strategy.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 dev

Documentation

| 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) | | 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 |

Other material lives under docs/.

License

TAMER EVALUATION LICENSE. See LICENSE for terms.