Skip to content

CLI Reference ​

mantle — Serverless TypeScript framework CLI.

bash
npx mantle <command> [flags]
# or install globally: pnpm add -g @j0nathan-ll0yd/cli

Every command is documented below under its own heading. The heading anchors are the linkage targets declared in docs/docs-map.yaml; pnpm docs:check fails when a command, subcommand, or flag exists in the source but not here.


mantle build ​

Bundle Lambda functions with esbuild.

FlagDescription
-w, --watchWatch for changes and rebuild
-f, --function <name>Build a specific function only
--analyzeGenerate bundle analysis metafiles
--sourcemapGenerate source maps
--no-minifySkip minification
--skip-containerSkip container Lambda builds (no Docker needed)

Scans src/lambdas/, outputs build/lambdas/<Name>/index.mjs. Target: Node.js 24 / ESM.

--skip-container is the flag to reach for on a machine without Docker, and in CI jobs that only need the zip bundles: esbuild still runs for every Lambda, and packageType: 'container' Lambdas are left alone.


mantle dev ​

Start a local development server with hot reload. Watches src/lambdas/ and simulates API Gateway routing locally.

FlagDefaultDescription
-p, --port <port>3000Port to listen on
--localstack—Enable LocalStack integration

With --localstack, the server waits for LocalStack on http://localhost:4566, provisions the project's resources, builds, deploys the Lambdas into it, and wires the event sources.

mantle dev invoke <type> <name> ​

Invoke one Lambda handler directly with a synthetic event, without starting the server. <type> is eventbridge, scheduled, sqs, s3, or websocket; <name> is the PascalCase function name. The handler is rebuilt first.

bash
mantle dev invoke eventbridge ExportHealthData --detail '{"userId":"abc"}'
mantle dev invoke s3 IndexUpload --bucket uploads --key a/b.json
FlagApplies toDescription
--detail-type <detailType>eventbridgeDetail type; defaults to the Lambda name
--detail <json>eventbridgeDetail payload as JSON
--source <source>eventbridgeEvent source
--time <iso>scheduledEvent time, ISO 8601
--rule-arn <arn>scheduledCloudWatch rule ARN
--region <region>scheduledAWS region
--body <json>sqs, websocketMessage body as JSON
--bucket <bucket>s3Bucket name
--key <key>s3Object key
--route-key <routeKey>websocketRoute key, e.g. $connect, $default
--connection-id <id>websocketConnection ID

mantle deploy ​

Deploy via OpenTofu.

bash
mantle deploy --stage staging
mantle deploy --stage production
FlagDescription
--stage <env>Target stage — required (dev, staging, production)

Runs tofu init, tofu plan, tofu apply. Requires AWS credentials. Never run without --stage — dev has no tfvars and prompts interactively.

Guards: when allowedStages is set in mantle.config.ts, any other --stage is hard-refused before any work (dry-run included, not bypassable with --yes). An apply whose initialized state lists zero addresses — an empty local file or an empty remote key (e.g. a stale checkout pointing at a pre-rename backend key) — is refused when the account already has that stage's Lambdas (--allow-empty-state overrides for a genuine first bootstrap).

FlagDescription
--dry-runRun tofu plan without applying
--skip-buildSkip the build step and deploy the bundles already in build/
--skip-generateUse infra/ as it is on disk instead of regenerating it; errors when absent
--modules-path <path>Path to mantle modules for infra regeneration (auto-detected if omitted)
--force-redeployForce an API Gateway redeployment after apply
-y, --yesSkip confirmation prompts, including the production gate
--allow-empty-statePermit an apply from empty local state (genuine first bootstrap only)
--allow-dirty-deployDeploy despite uncommitted changes when provenance is active; the refusal becomes a loud warning and the derivation log is marked (emergency use only)

--skip-generate is the flag to use after hand-editing a generated file you have deliberately ejected; without it, deploy regenerates infra/ from source and overwrites the edit.

--dry-run is read-only end to end. For a project with Docker-built container Lambdas it resolves each image_uri_<name> from the build output and passes it to tofu plan, but it creates no ECR repository, pushes no image, deletes none, and needs no Docker on the machine. The mutations it declined are named in the output ("Would create ECR repository", "Would push"), so the plan says what an apply would do rather than having quietly done part of it already.

ECR image retention runs AFTER a successful apply, never during the push. It keeps the most recent containerRegistry.imageRetentionCount images (default 5, the same key the generated aws_ecr_lifecycle_policy reads) and never expires the image the deployment just wired. A failed deploy therefore still has its previous images to roll back to.

--skip-build and deployment provenance: mantle build records the checkout it bundled in build/.mantle-build.json. When the build is skipped and any Lambda declares provenance, deploy binds source_revision to THAT revision, not to the checkout running the command — source_revision describes the deployed bundle, and the two are the same thing only when this command did the build. A divergence is reported loudly. An absent record, or one from a build made outside a checkout, refuses the deploy before any remote call: the producing revision is unknown, and the alternative is stamping a guess. A prebuilt container image (imageDigestFile) in a provenance-declaring project must carry a 40-hex sourceRevision in its pin file for the same reason, and it must agree with the revision the rest of the deployment stamps.

App-declared variable values: when a customVariables entry in mantle.config.ts carries valueFrom, deploy invokes that project-owned module and passes the string it returns as -var, for both plan and apply. Resolution runs before any side effect — before the build, the ECR push, and the apply — so a missing provider, a throwing provider, a non-string or blank return, or a value failing the declared validation refuses the deploy while nothing has been written. Binding is by variable name, so a variable declared through defineLambda({env}) is bound just as one declared in customVariables is. An explicit TF_VAR_<name> wins with the same precedence and the same divergence warning the provenance facts use, and a sensitive variable's values are redacted in that warning. A project declaring no valueFrom deploys exactly as before. See Custom Terraform Variables.

Deployment provenance: when any Lambda declares provenance in defineLambda, deploy derives the source revision (from the checkout's git HEAD), the bind-time UTC instant, and GitHub workflow identity, then passes them as -var=. An explicitly-set, non-blank TF_VAR_<name> wins and the derivation fills the rest; a mismatch is reported. Derivation runs before any side effect, so an unresolvable revision exits 1 before images are pushed. Derivation also begins with a working-tree cleanliness gate: uncommitted changes — tracked edits and untracked non-ignored files alike — refuse the deploy and name the offending paths, because the build bundles the bytes on disk while source_revision names the last commit. --allow-dirty-deploy downgrades that refusal to a loud warning and marks the derivation log. A project with no provenance declaration is not gated. See Deployment Provenance.


mantle bootstrap-state ​

Create and harden the S3 Terraform state bucket declared in backend.s3 (idempotent; safe to re-run). Solves the chicken-and-egg problem: the state bucket cannot be managed by the state it stores.

bash
AWS_PROFILE=mantle-MyApp mantle bootstrap-state

Applies: bucket creation, versioning, AES256 encryption, public-access-block, TLS-deny bucket policy, and a 90-day noncurrent-version expiry lifecycle. Verifies versioning is Enabled before reporting success. Fails hard if the bucket name is taken by another account.

State key naming convention: infra-<stage>.tfstate (e.g. infra-staging.tfstate) in a per-instance bucket named mantle-<name>-tfstate.


mantle generate ​

Alias: mantle g <subcommand>. Sixteen generators, each idempotent and safe to re-run.

mantle generate infra ​

Generate OpenTofu configuration from project structure and mantle.config.ts.

FlagDescription
--modules-path <path>Path to mantle modules (auto-detected if omitted)

Scans src/lambdas/, derives API routes from filesystem, auto-wires env vars from getRequiredEnv() / getOptionalEnv() calls. Safe to run repeatedly — skips ejected files.

Observability alarms (observability.tf) ​

Opt-in. When mantle.config.ts sets an observability.alerts block, generate infra emits observability.tf sourcing modules/observability: an SNS alerts topic with email subscription(s) plus CloudWatch alarms on free native metrics only — per-function Lambda Errors/Throttles, DLQ ApproximateNumberOfMessagesVisible for every queue with a DLQ, and API Gateway 5XXError when the project exposes an API. Alarm actions are wired to the SNS topic. With no alerts block, no file is generated (zero cost, zero behavior change) and any previously generated observability.tf is removed.

typescript
observability: {
  alerts: {
    email: "ops@example.com", // string or string[]; validated at load time
    dashboard: false, // optional; opt-in CloudWatch dashboard (~$3/mo once >50 metrics), default false
  },
}

Alarm count and estimated monthly cost ($0.10/alarm beyond the first 10 free) are written into the generated file's header comment for review.

Reconciliation with per-resource DLQ alarm flags: the enableDlqAlarm (queues) and dlqAlarm (eventbridge) flags were set to false across the instances during the April 2026 alarm removal because there was no SNS fan-out target. Opting into observability.alerts supplies that target, so observability assumes ownership of those previously-silenced DLQ alarms. Setting a flag to true keeps ownership in the resource's own module, and observability defers on that DLQ to avoid a duplicate alarm.

mantle generate permissions ​

Extract @RequiresTable metadata and generate least-privilege DSQL permissions.

FlagDefaultDescription
--entity-dir <dir>src/entities/queriesEntity queries directory
--output-dir <dir>buildBuild output directory
--terraform-dir <dir>infraTerraform output directory
--permissions-dir <dir>permissionsPermissions output directory

mantle generate openapi ​

Generate an OpenAPI 3.1 specification from defineApiHandler metadata. Resolves Zod request/response schemas end-to-end via esbuild bundling and sibling extraction, producing fully-typed components/schemas with $ref operation bodies. Error responses are derived from each handler's auth mode and emitted as $refs to Models.ErrorResponse / Models.UnauthorizedError / Models.ForbiddenError / Models.InternalServerError when those schemas are sibling-extracted.

FlagDefaultDescription
--output <path>openapi.jsonOutput file path. Extension controls format — .json emits JSON, .yaml/.yml emits YAML.
--title <title>APIAPI title
--version <version>1.0.0API version
--server-url <url>—Optional server URL in info.servers[0].url
--schema-prefix <prefix>—Prepend a namespace to every component name (e.g., Models.). The Schema suffix is stripped first, so fileListResponseSchema becomes Models.FileListResponse. Preserves TypeSpec-era naming for iOS/Swift consumers.
--html <path>—Also write a Redoc HTML docs bundle to the given path. Uses @redocly/cli build-docs under the hood (auto-installed via npx).
--verbosefalsePrint per-schema resolution diagnostics — which schemas resolved, which fell back to placeholders, and which inline handler-body schemas were promoted.
--strictfalseExit non-zero when enum, string, or const drift is detected across schema pairs. The default is warn-only.
--addressing-file <path>—JSON file of addressing values (api_url, storage_data_cloudfront_domain, websocket_api_endpoint) injected as servers[].url plus x-lifegames-* root extensions.

Default error response behavior ​

buildOperation computes the default error set from the handler's auth mode:

Auth modeStatus codes emitted
none (or unset)200, 400, 500
bearer200, 400, 401, 500
session200, 400, 401, 500
authorizer200, 400, 401, 403, 500

Each error code is only emitted when its matching component schema is present in the resolved schema set, so instances without an api-schema/schemas.ts barrel degrade gracefully to 200-only responses instead of producing broken $refs. The component name mapping is fixed:

  • 400 → ErrorResponse
  • 401 → UnauthorizedError
  • 403 → ForbiddenError
  • 500 → InternalServerError

Per-handler error override ​

Any handler may override the auth-derived defaults via openapi.errors:

typescript
const api = defineApiHandler({
  auth: "bearer",
  openapi: {
    summary: "Fetch by ID",
    errors: [400, 404, 500], // omits 401 even though auth is bearer
  },
});

The extractor parses the array literal via ts-morph; only integer literals are supported. Unknown codes (those without a ERROR_RESPONSES mapping) are silently dropped — useful for documenting non-standard codes in prose without producing broken refs.

Query parameters ​

defineApiHandler({ querySchema: MyQuerySchema }) emits OpenAPI parameters: [{in: 'query', ...}] — not requestBody. Each top-level property of the resolved query schema becomes a separate query parameter entry with its constraints (type, enum, minLength, etc.). Query parameters are appended after any path parameters on the same operation.

$ref promotion ​

After resolving all schemas to JSON Schema, the generator promotes inlined sub-schemas to $ref pointers when they are structurally identical to a top-level component. This produces clean Swift types (e.g., [HealthQuantity] instead of [HealthSyncRequestPayload.QuantitiesItem]). Self-matches are skipped — a component never $refs itself.

Schema resolution notes ​

  • Sibling extraction: schemas imported via import * as all surface every exported Zod schema in the module, so barrel files like src/types/api-schema/schemas.ts expose sub-schemas as named components even when no handler references them directly.
  • Inline handler schemas: const Schema = z.object(...) declared inside a handler body is promoted to export const at bundle time and resolved as a top-level component.
  • Zod identity: the generator bundles each schema together with its toJSONSchema() call via esbuild to avoid cross-package instanceof mismatches between different Zod copies. Do not import { z } from 'zod' directly in handler files — use @j0nathan-ll0yd/validation's re-export so all schemas share one Zod instance.
  • format: date-time: Zod 4's z.string().datetime() natively emits format: "date-time" via toJSONSchema(). Use .datetime() on timestamp schema fields to get proper date formatting in the spec.

mantle generate dataflow ​

Extract four committed dataflow manifests into dataflow/. They record what a service emits, what it subscribes to, what it writes, and what feeds each artifact — the four relations an external graph reconciler cannot derive by reading infrastructure alone.

FlagDefaultDescription
--entity-dir <dir>src/entities/queriesEntity queries directory
--output-dir <dir>dataflowDataflow manifest directory
FileShapeDerived from
emits.jsonlambdas: {<FunctionName>: [<detailType>]}emitEvent / emitEvents call sites
subscriptions.jsonlambdas: {<FunctionName>: [<detailType>]}defineEventBridgeHandler({detailTypes}) declarations
artifacts.jsonlambdas: {<FunctionName>: [{key} | {keyPrefix}]}exportToS3 / putObject / createUpload write targets
export-sources.jsonartifacts: {<artifactKey>: [<table>]}entity query calls reaching each write's payload expression

subscriptions.json is the inverse of emits.json: it names the Lambda on the receiving end of each detail type. Only eventbridge Lambdas appear, because only they get an EventBridge rule. One detail type routing to several Lambdas is a normal fan-out and every subscriber is listed. An eventbridge Lambda that declares no detailTypes is declared under detail-types-absent, never recorded against its own function name. Rule generation is guarded on eventbridgeDetailTypes.length > 0, so that Lambda gets no EventBridge rule at all and a function-name entry would invent an edge nothing deployed carries.

subscriptions.json is scheduled for retirement — do not build new consumers on it. Atlas decision 0142 asked whether it should be adopted as a dormancy measurer or retired; the answer is retired, and the reasoning is recorded here rather than left implicit.

It has no consumer. The relation it was scoped for in decision 0069 (triggers / triggered_by) is derived instead from committed generated Terraform — one aws_cloudwatch_event_rule per subscription, read at a pinned ref. That source is one seam further down than this manifest: it observes the generator's OUTPUT, where this observes the same detailTypes read the generator itself performs. Under the measurer criterion (atlas decisions 0122/0126/0128), a measurer that cannot name a seam distinct from an existing one is a duplicate, and this cannot.

The substitute also got strictly better in the change that recorded this decision: EventBridge rule names are now per function per detail-type, so several subscribers to one detail type are distinguishable in the Terraform the arm reads. Before that they collapsed onto one physical rule and the arm could not tell them apart — which is part of why this manifest looked necessary.

Precondition on the retirement, not a deferral of it: the Terraform arm parses rule names, and the rename above changes the bytes it parses. The manifest is not removed until that arm is confirmed green against the new names. Removing it first would leave the relation measured by nothing for the length of that window, which is the failure the retirement criterion exists to prevent.

Commit dataflow/ and gate it with mantle check dataflow-fresh. The manifests are read by consumers at a pinned origin/main, so a gitignored build/ artifact would be invisible to them — this follows the permissions/permissions.sql precedent, not the build/permissions.json one.

Every manifest carries specVersion: 1 and an unresolved array. Output is deterministic: keys and arrays sorted, no timestamp. A generatedAt field would make the freshness gate churn on every run. An unresolved site does carry its source position, because two sites in one module can otherwise render identically; that churns only when the source moves, which is what the gate exists to catch.

unresolved is load-bearing ​

A call site the extractor cannot resolve statically is declared in unresolved, never dropped:

json
{
  "scope": "ComposeLlmContent",
  "reason": "s3-key-not-static",
  "detail": "src/lambdas/eventbridge/ComposeLlmContent/composerHelpers.ts:63:24 :: key"
}

detail is <file>:<line>:<column> :: <expression>. The position is what separates two write sites in one module that forward through the same parameter name — without it both render composerHelpers.ts :: key and a consumer cannot tell one unreadable write from two. It is a report, not a deduplication key: two sites remain two rows.

scope is the Lambda function name for emits, subscriptions, and artifacts, and the artifact key for export-sources — except s3-write-shape-not-recognized, which names the Lambda, because an unreadable write shape hides the artifact key itself. A consumer turns a non-empty unresolved into an indeterminate verdict scoped to that function or artifact. Unmeasured is never a pass.

subscriptions never falls back to the function-name default for a Lambda whose detailTypes it could not read. A spread of an out-of-scope binding, an options object it cannot resolve, or a computed list lands under detail-types-not-static or handler-options-not-static, and the Lambda is left out of lambdas entirely. Resolution is per element, so ['A', ...IMPORTED] records A and declares the spread: an incomplete set the consumer knows is incomplete, never a short set it reads as complete.

Detection is separate from resolution, and both feed the array. A site whose callee the extractor models but whose value is dynamic lands under detail-type-not-static, detail-types-not-static, handler-options-not-static, s3-key-not-static, detail-type-property-not-found, s3-key-property-not-found, event-list-not-static, emit-call-without-arguments, or payload-source-not-static. A site whose call shape the extractor does not model at all lands under emit-shape-not-recognized or s3-write-shape-not-recognized:

  • Any callee matching /^(emit|put|publish|send)Event/i outside emitEvent and emitEvents. Raw putEvents() is the case that matters — C34 permits it when full control is needed, so a convention-compliant Lambda emits through a shape the extractor cannot read.
  • A PutEventsCommand, PutObjectCommand, or Upload constructor site.

A callee declared inside the Lambda's own files does not count as unrecognized: the extractor already walks that function's body. Nor does a constructor inside the body of a helper the extractor models.

What resolves ​

Extraction is pure syntactic AST analysis over each Lambda's own file set — its handler plus every local module it transitively imports. No TypeChecker, no tsconfig loading, no build step.

  • A module-scope constant referenced by identifier: const T = 'ExportGitHubActivity' then emitEvent({detailType: T}).
  • A local array filled before a batch call: entries.push({detailType: 'ExportSleep'}) then emitEvents(entries).
  • A parameter forwarded from a named function's callers, up to four hops, so a key computed in one helper and written in another still resolves.
  • A computed key reduces to its static prefix: `images/books/${asin}.webp` becomes {"keyPrefix": "images/books/"}.
  • A declaration filled by later assignment: let data assigned inside a try.
  • An import alias: import {emitEvent as fire} then fire({detailType: 'ExportSleep'}) resolves like an unaliased call.
  • A detailTypes list held in a constant, including one imported from a colocated module: detailTypes: SHARED_DETAIL_TYPES.

export-sources attributes per artifact, not per Lambda. A handler that reads four tables and writes three artifacts has distinct attribution per artifact; joining at the Lambda level would emit a four-by-three cross-product of edges that do not exist. The extractor walks the identifiers inside each write's payload expression instead.

Write sites in a module the Lambda imports but never calls are ignored. Import granularity is per file, so pulling one helper out of a shared module otherwise drags every other function in it into scope.

mantle generate handler <Name> ​

Scaffold a new Lambda handler with matching test file.

FlagDefaultDescription
--trigger <type>apiTrigger type: api, sqs, s3, schedule, authorizer

mantle generate entity <name> ​

Scaffold a Drizzle entity schema.

FlagDescription
--fields <fields>Comma-separated name:type pairs (types: string, number, boolean, timestamp, uuid)
--queriesAlso generate entity query class with @RequiresTable methods

mantle generate migration <name> ​

Create a timestamped migration SQL file in migrations/.

mantle generate graph ​

Generate file-level dependency graph to build/graph.json. Auto-run during mantle build.

mantle generate graph:knowledge ​

Generate GraphRAG knowledge graph to graphrag/knowledge-graph.json.

mantle generate inventory ​

Inject the generated package/module inventory between <!-- BEGIN generated:inventory --> / <!-- END generated:inventory --> markers in ARCHITECTURE.md, docs/public/llms.txt, and openspec/project.md. Counts are derived from the filesystem, so the inventory is correct by construction. Graceful no-op in directories without markers (e.g. instance roots).

FlagDescription
--checkFreshness mode: exit 1 if regeneration would change any surface (CI gate; runs in the framework-fitness job and the inventory-freshness instance CI step)

mantle generate hooks ​

Generate Husky git hooks and a safe linked-worktree provisioner. The provisioner copies only missing files from the primary checkout (including .claude/settings.local.json, local env files, and local Terraform var files), then installs pnpm dependencies. It never copies directories or overwrites files.

FlagDescription
--no-pre-commitSkip pre-commit hook
--no-pre-pushSkip pre-push hook
--no-worktree-provisioningSkip .husky/post-checkout and scripts/worktree-setup.sh

Existing hooks and setup scripts are left unchanged. Use WORKTREE_SKIP_INSTALL=1 git worktree add … when a fresh worktree should skip the dependency install. For an existing instance, commit the generated files before using git worktree add, so the new checkout contains its post-checkout hook.

mantle generate ci-templates ​

Generate GitHub Actions CI/CD workflow files (.github/workflows/).

Takes no flags. The generated workflows resolve the Node.js version from .nvmrc via actions/setup-node's node-version-file.

mantle generate agents ​

Generate AGENTS.md for AI assistants. Preserves content between <!-- CUSTOM:START --> and <!-- CUSTOM:END --> markers.

mantle generate repomix ​

Generate repomix.config.json for AI context packing (XML format, tree-sitter compression).

mantle generate deps-config ​

Generate .dependency-cruiser.cjs with Mantle architecture rules.

mantle generate integration-tests ​

Generate integration test scaffold with Docker Compose (LocalStack + PostgreSQL).

mantle generate knip-config ​

Generate knip.config.ts with Mantle-aware entry points for dead code detection.


mantle db ​

Database management. Wraps Drizzle Kit where a wrapper is enough, and replaces it where DSQL needs different behaviour.

mantle db migrate ​

Run pending migrations using the Mantle migration runner.

FlagDefaultDescription
--folder <path>./migrationsMigrations folder
--provider <type>auto-detectedaurora-dsql, aurora-serverless-v2, neon
--endpoint <url>DSQL_ENDPOINT envDSQL cluster endpoint
--connection-string <url>DATABASE_URL envConnection string (non-DSQL)
--region <region>AWS_REGION envAWS region
--no-lock—Skip migration lock
--dry-run—Validate against a preview schema, apply nothing

Auto-detects endpoint and region from Terraform state when infra/ is initialized.

--dry-run runs every pending migration against a throwaway preview schema and reports the per-statement DSQL classification (OK, INDEX, RECREATION, STRIP). It takes no lock and mutates nothing, so it is safe against a live cluster.

mantle db generate ​

Generate migration file from schema diff (wraps drizzle-kit generate).

FlagDescription
--name <name>Migration file name
--customGenerate empty migration for manual SQL
--prefix <type>File prefix: index, timestamp, unix, none

mantle db check-dsql ​

Classify migration statements for Aurora DSQL compatibility.

FlagDefaultDescription
--migrations-dir <path>./migrationsMigrations directory

Output labels: OK (compatible), INDEX (auto-converted to ASYNC), STRIP (skipped — DSQL-unsupported), RECREATION (requires table recreation).

mantle db apply-permissions ​

Apply DSQL role permissions from permissions/ folder.

FlagDefaultDescription
--folder <path>./permissionsPermissions folder
--stage <stage>—Stage whose IAM roles the grants target; sets RESOURCE_PREFIX
--endpoint <url>DSQL_ENDPOINT envDSQL endpoint (auto-detected from Terraform state when omitted)
--region <region>AWS_REGION envAWS region (inferred from the endpoint when omitted)

The generated SQL's AWS IAM GRANT ARNs reference ${AWS_ACCOUNT_ID} and ${RESOURCE_PREFIX} placeholders. Both are resolved automatically — RESOURCE_PREFIX from --stage, AWS_ACCOUNT_ID from the caller's STS identity — so the typical invocation is just:

bash
AWS_PROFILE=mantle-{InstanceName} npx mantle db apply-permissions --stage staging

Explicit environment variables always take precedence; any placeholder that cannot be resolved fails fast with an aggregated list before anything is applied.

mantle db mark-applied ​

Record a migration in the tracking table without executing it. Use it after applying SQL out of band -- for example to recover from a partially-applied deploy -- so the tracking table and the database agree again.

FlagDefaultDescription
--file <path>requiredMigration SQL file to mark applied (relative or absolute)
--folder <path>./migrationsMigrations folder
--tag <tag>—Migration tag to mark, e.g. 0007_add_files_index
--endpoint <url>DSQL_ENDPOINT envDSQL endpoint
--region <region>AWS_REGION envAWS region
--reset—Clear the tracking table before marking
--yes—Skip the confirmation prompt

--reset rewrites migration history. It exists for a recovered database whose tracking table is wrong; it is not part of any normal workflow.

mantle db clone ​

Clone a remote Aurora DSQL database into a local Docker PostgreSQL container so migrations can be tested against real data without touching the remote cluster. Uses IAM auth tokens plus pg_dump / pg_restore, and rewrites the dump through adaptForStandardPg so DSQL-flavoured SQL runs on standard PostgreSQL.

bash
mantle db clone --stage staging
FlagDefaultDescription
--stage <stage>—Stage to clone from
--endpoint <url>resolved from stageSource DSQL endpoint
--port <port>5433Local PostgreSQL port
--compose-file <path>docker-compose.clone.ymlCompose file for the local container
--skip-docker—Assume the local PostgreSQL is already running
--allow-production—Permit cloning from the production stage

mantle db push ​

Push schema directly to the database (dev only, wraps drizzle-kit push).

mantle db studio ​

Open Drizzle Studio (wraps drizzle-kit studio).


mantle ci ​

Run the full local CI pipeline.

FlagDescription
--fullAdd integration tests and extended quality checks
--step <name>Run one step by its stable name (for example, typecheck)

Phases: Setup → Build → Validate → Test → Quality → Integration (full only). Exits non-zero on any CRITICAL or HIGH failure. Missing tools (ShellCheck, tofu) are skipped gracefully.


mantle check ​

Validate codebase against 69 validation rules (67 ts-morph rules + 2 auto-fixes).

FlagDefaultDescription
--fast—CRITICAL rules only
--severity <level>—Minimum severity: CRITICAL, HIGH, MEDIUM
--rule <name>—Run one specific rule
--entity-dir <dir>src/entities/queriesEntity queries directory

Suppress one rule at one site with a // mantle-ignore comment, and say why.

The subcommands below are separate checks with their own flags. They are not covered by a bare mantle check.

mantle check deps ​

Enforce dependency architecture rules (no handler-to-handler imports, no circular deps, entity/service isolation).

mantle check bundles ​

Check Lambda bundle sizes against thresholds.

FlagDefaultDescription
--threshold <bytes>5000000Max bundle size in bytes
--json—JSON output for CI

mantle check dead-code ​

Detect unused exports via Knip. Generate config first with mantle generate knip-config.

mantle check test-output [file] ​

Validate test output for problematic patterns (EMF metrics on stdout, unhandled rejections, deprecation warnings). Reads from stdin if no file given.

mantle check ci-workflows ​

Validate GitHub Actions workflow YAML for consistency: action pins, Node version resolution, and the job-name conventions the estate's required status checks depend on. Exit 1 on any error-severity finding.

mantle check docs ​

Validate documentation link integrity across AGENTS.md, CLAUDE.md, and the wiki. Reports every broken relative link with its file and line, and exits 1 when any is error-severity.

This checks that links resolve. It does not check that the docs describe the code -- that is pnpm docs:check, driven by docs/docs-map.yaml.

mantle check env-vars ​

Validate Lambda environment variable usage against the generated infrastructure. Discovers every Lambda, extracts the variables each one reads through getRequiredEnv() / getOptionalEnv(), and reports any that infrastructure never injects -- the failure that otherwise surfaces as a cold-start crash in a deployed stage.

mantle check migrations ​

Validate that migration SQL files are consistent with the Drizzle journal: every journal entry has a file, every file has an entry, and no file was left as the --custom placeholder that drizzle-kit writes.

FlagDefaultDescription
--dir <path>./migrationsMigrations directory
--strictfalsePromote placeholder-only warnings to errors (exit 1)

mantle check versions ​

Compare an instance's dependency versions against the Mantle catalog in pnpm-workspace.yaml. Each dependency is reported as OK, DRIFT, REDUNDANT (pinned to the same version the catalog already supplies), IGNORED, or MISSING.

FlagDescription
--ignore <dep>Exclude a dependency from the comparison; repeatable

mantle check openspec ​

Verify the OpenSpec drift tether: every ### Requirement: in openspec/specs/**/spec.md has ≥1 covering test (line-leading // covers: <capability>#<Requirement Name> annotation), every annotation points at a real requirement, no spec restates a Zod/TypeScript shape, and no stray hand-authored package/module counts exist outside the generated inventory snippet.

FlagDescription
--cwd <dir>Project root to check (cross-repo runs, e.g. against an instance)
--blockingExit 1 on any finding (CI gate mode — used by the framework's framework-fitness job). Default is advisory: findings print as warnings, exit 0
--drift-onlyRun only the diff-scoped spec-drift gate: owned paths changed without a spec or covers: change. Requires openspec/path-ownership.json; skips gracefully without it
--base <ref>Base ref for the --drift-only diff. Default origin/main

mantle check observability ​

Verify infra/observability.tf stays within the 10 free-tier CloudWatch alarm-metrics (C144). Parses the file on disk — catches ejected or hand-edited files that bypass the generator throw.

The alarm-metric count formula (single source of truth shared with the generator):

text
alarmMetrics = 2 × lambda_function_names.length
             + sqs_dlq_names.length
             + (api_gateway_name ? 1 : 0)
             + 2 × eventbridge_rule_names.length
             + sqs_age_queue_names.length
             + custom_alarms.length

The 2 × lambda_function_names term holds regardless of enable_per_function_lambda_alarms — CloudWatch metric-math alarms bill per referenced metric, not per alarm object.

Exit 1 when alarmMetrics > 10. No-op (exit 0) when infra/observability.tf is absent.

Config fields that affect the alarm budget:

FieldTypeDefaultEffect
mode'cost-optimized' | 'per-function''cost-optimized'cost-optimized limits Lambda alarms to criticalFunctions; per-function alarms all discovered Lambdas
criticalFunctionsstring[][]Lambda names that get per-function Errors + Throttles alarms in cost-optimized mode (2 alarm-metrics each)
errorLogNotifierbooleantrue in cost-optimized, false in per-functionEnables the Tier B account-level subscription filter → LogNotifier Lambda → SNS email for comprehensive error coverage at $0

See mantle/docs/reference/observability-alerting.md for architecture details and the billing trap explanation (C145).

mantle check package-boundary ​

Enforce the framework's 7-tier package dependency DAG (e.g. database must never import auth). Reads each packages/*/package.json's runtime dependencies; a package may only depend on its own tier or lower. Blocking on tier violations; warns on unregistered packages. No-op outside a framework root. Known limitation: same-tier cycles are not detected.

External leaves. A few @j0nathan-ll0yd/* names are not built here — they are owned by another repo and consumed from GitHub Packages at an exact pin (today: @j0nathan-ll0yd/estate-contracts). They sit at tier 0, so every workspace tier may depend on them. The direction the model forbids for them is inward: an external leaf appearing as a workspace package under packages/ is a blocking error, because that means a package this repo is meant to consume from the registry was vendored back in.

mantle check package-versions ​

Publish-payload drift gate (C147). For every publishable workspace package it asks one question: does the payload this checkout would publish differ from the payload already published under the version this checkout declares?

The reference is the registry, never a historical commit. That makes the gate checkout-independent: a shallow clone, a rewritten history, a detached worktree and a missing origin/main all produce identical verdicts.

How it works: enumerate packages from the UNION of two sources — the workspace globs a package manager declares (pnpm-workspace.yaml packages:, or the root manifest's workspaces) and a plain directory scan for package.json, minus anything git ignores — → resolve a registry token → fetch each packument with plain fetch() (never npm view, which serves ~/.npm/_cacache even with the registry down) → build the workspace once → pnpm pack each package (never npm pack, which does not rewrite workspace:*) → screen packed files that are neither git-tracked, nor under a declared turbo.json build output, nor npm-injected → compare canonical digests against the reference tarball, verified against its own dist.integrity.

VerdictExitMeaning
CLEAN0The declared version is published and the payloads match
PENDING_PUBLISH0/2Bump is ahead of the registry and the payload differs; blocking on --lane=post-publish only
BUMP_NOT_NEEDED0Bump is ahead but the payload is identical — the bump would publish a byte-identical artifact
NEVER_PUBLISHED0/2Packument 404; blocking on --lane=post-publish only
DRIFT2The declared version IS published and the payloads differ — changeset publish will silently skip
PENDING_CHANGESET0/2A DRIFT an adequate pending changeset covers; blocking on --lane=post-publish only
SURFACE_BREAK2The exports surface moved by more than the declared (or projected) bump admits
VERSION_REGRESSION2The declared version is below the newest published one
LEAKED_ARTIFACT2An untracked, non-build-output path entered the tarball
INDETERMINATE3Registry unreachable, no token, or integrity mismatch — explicitly not a pass
BUILD_FAILED4The build exited non-zero, or left a declared output directory absent or empty
SKIPPED0private: true, or publishing somewhere other than GitHub Packages
NO_PUBLISHABLE_PACKAGES3Discovery inventoried nothing publishable — whole-workspace, and explicitly not a pass

The process exit code is the worst row, ordered 4 > 3 > 2 > 0.

Flags: --lane <pre-push|branch|post-publish> (changes only the exit code of PENDING_PUBLISH/NEVER_PUBLISHED, never a verdict), --json, --strict-maps (include source maps no consumer can resolve), --registry <url>, --changeset-root <dir>, --self-test.

The changesets probe, and where it looks. A bare changeset is a promise to bump, not a bump: the gate measures package.json on disk, so a PR carrying a good .changeset/*.md would otherwise read as DRIFT. Once per run the gate asks the repo's own @changesets/get-release-plan what version changeset version would write for each package, and a projection that is itself a clean PENDING_PUBLISH relabels DRIFT to PENDING_CHANGESET and sizes the export-surface rule.

It looks in the repository root and in every publishable package directory, because a changesets project does not have to sit at the git toplevel. mantle-LifegamesPortal keeps its project at packages/portal-contract/ — its root pnpm-workspace.yaml carries no packages: key, so a root-level project would discover nothing, and its release workflow runs changeset version with cwd: packages/portal-contract. Both the module resolution and the child process are anchored at whichever project answered. Rules:

  • No .changeset/config.json anywhere it looks → not measured. No excuse; DRIFT stands. This is the state for a repo that does not use changesets at all.
  • Two projects planning a release for one package name → indeterminate (exit 3). There is no single projected version to credit.
  • A project that cannot be measured — a malformed changeset, a .changeset/ holding .md files with no config.json, or @changesets/get-release-plan not resolvable from it — → indeterminate (exit 3), naming the directory. Never a partial merge, and never a silent "not measured".

A repo with a changesets project must be able to resolve @changesets/get-release-plan. Resolution walks up from the project's own package.json, so a root devDependency reaches a nested project. This is deliberately not bundled into the CLI: the version that answers must be the version that will actually run changeset version in that repo. Two consumer-side notes, both measured against mantle-LifegamesPortal. @changesets/get-release-plan@^4 pulls @manypkg/get-packages@1.1.3, which depends on read-yaml-file@1.1.0; that version calls yaml.safeLoad, removed in js-yaml 4. A repo pinning js-yaml to 4 must therefore force read-yaml-file to ^2.1.0 as a package-manager override — a root devDependency does not work, because pnpm keeps the nested 1.1.0 copy that @manypkg/get-packages resolves. Without the override the probe reports INDETERMINATE with Function yaml.safeLoad is removed in js-yaml 4, which is a blocked run, not a wrong answer. And do not add the dependency to the published leaf itself — devDependencies ship in the published manifest, so that is a payload change and this gate will (correctly) report it as drift.

--changeset-root <dir> pins the probe at one directory and searches nowhere else — the narrow escape hatch for a layout the search does not reach. A directory holding no .changeset/config.json is INDETERMINATE, never a silent pass.

Discovery is deliberately not tied to one package manager. It used to be pnpm list -r --depth -1 --json alone, and in mantle-LifegamesPortal — whose pnpm-workspace.yaml is settings-only and carries no packages: key — that returns the private root and nothing else. The gate reported 1 SKIPPED, exit 0, on a repo with a real published package in packages/portal-contract. The empty-set guard closes the same hole from the other side: zero publishable packages is NO_PUBLISHABLE_PACKAGES and exit 3, never a silent pass.

Token resolution, first hit wins: DRIFT_REGISTRY_TOKEN, NODE_AUTH_TOKEN, GITHUB_TOKEN, //npm.pkg.github.com/:_authToken read directly from ~/.npmrc, gh auth token. GitHub Packages rejects anonymous reads even for public packages, so there is no offline mode; git push --no-verify is the documented escape.

--self-test builds a throwaway git repository and an in-process npm registry and drives the full pipeline through thirty-five scenarios with no seam stubbed. Four of them (nested-changeset-*) give a fixture package its own nested .changeset/ project and a release planner installed only inside that package, so a probe anchored at the git toplevel fails them. It runs in the package-version-drift CI job, which is the stable status context to require on main.

The other half of A2b — proving the suite can still FAIL — lives in the test suite rather than the binary: mutation.test.ts patches the gate's own source text (asserting each anchor matches exactly once), imports the patched module and asserts every one of nine deliberate defects turns that ladder red. Mutants are deliberately NOT selectable at runtime; a flag the shipped binary can read is a test-only branch in production, and it makes the mutant exercise a different expression than the one production runs. cli-exit.test.ts spawns the real built binary and asserts the actual process exit status — the one boundary every other suite skips.

The digest scheme itself is not defined here. It is defined by atlas/contracts/package-digest/reference.mjs; its generated conformance fixture, checksum sidecar and shared runner are vendored verbatim into packages/cli/test/fixtures/ and asserted on every run, so this implementation, design-system's and Atlas's cannot diverge unnoticed. SPEC_VERSION means one thing: same number iff byte-identical normalization.

mantle check permissions-fresh ​

Verify permissions/permissions.sql is up to date with the current @RequiresTable/defineQuery annotations and Lambda inventory (C111): regenerates in-memory and diffs against the committed file. Exit 1 on drift or if the committed file is missing. Runs in instance CI/pre-push via the permissions-freshness step (triggered by changes matching src/|permissions/ — the generator is fed by discoverLambdas, so adding or renaming a handler changes the file even when no table annotation moves). Fix drift with mantle generate permissions.

FlagDefaultDescription
--entity-dir <dir>src/entities/queriesEntity queries directory
--permissions-dir <dir>permissionsPermissions output directory

mantle check dataflow-fresh ​

Verify the committed dataflow/*.json manifests match the current emit, subscription, S3-write, and entity-query call sites: regenerates all four in memory and diffs against the committed files. Exit 1 on drift or on a missing file. Fix drift with mantle generate dataflow.

FlagDefaultDescription
--entity-dir <dir>src/entities/queriesEntity queries directory
--dataflow-dir <dir>dataflowDataflow manifest directory

Same shape as mantle check permissions-fresh (C111), and the same reason: the manifests are hand-committed source of truth that nothing regenerates on deploy. Consumers read them at a pinned origin/main, so a stale committed file is a silently wrong answer rather than a local inconvenience.

The check reports the number of declared unresolved sites on the green path. That count is a warning, not a failure — the manifest is fresh, but the scopes it names stay indeterminate downstream until the code makes them statically resolvable.

Instance CI and pre-push run it via the dataflow-freshness step, triggered by changes matching src/|dataflow/. The pattern covers all of src/ because the generator's scope is each Lambda's transitive import closure — an emit added to a reachable service module changes the manifest, and a lambdas/|entities/ pattern would skip the gate that catches it.


mantle mcp-server ​

Start the Model Context Protocol server for AI-assisted development (Claude Code, Cursor, and similar). Uses the stdio transport, so it is launched by the editor rather than run by hand.

Provides 19 tools across Validation, Infrastructure, Reference, Workflow, Data Queries, and Performance categories.

This server answers questions about the Mantle framework itself. It is a different thing from an MCP server built with @j0nathan-ll0yd/mcp, which runs as a Lambda and answers questions about a deployed stage.