Zabaca stack bootstrap — zbc CLI + infrastructure modules
TypeScript
2
341 commits
updated Sep 23, 2026
Zabaca's open-source stack bootstrap. It holds the zbc CLI, the infrastructure modules that make deploying an application easy, and a small set of applications most software companies want (inbox, warehouse, …) as app templates.
Who it's for: zbc is open source, and its consumers are other software companies, not just Zabaca. A consumer forks this repo or installs just the CLI, then vendors the engine and built-in modules as a git subtree at vendor/zbc (ADR-0005). Nothing is Zabaca-hosted — every consumer runs their own copy of everything, including any supporting service.
Two kinds of thing ship to consumers. A module provisions or deploys one resource (turso, cloudflare, r2, …). An app template is a whole application scaffolded into the consumer's repo — it earns its place by being something most software companies would want, or by being required for the zbc workflow itself (secret-relay); anything specific to one project belongs in that project, consuming zbc.
Domain vocabulary and principles live in CONTEXT.md; the map of contexts is CONTEXT-MAP.md.
Projects live here under packages/<project>/; per-environment module instances live under packages/infra/environments/<env>/. Applying an environment is one command — zbc apply <env> — which discovers every instance in the environment directory, resolves the dependency graph from imports, decrypts secrets, and converges each service to the desired state.
├── zbc.config.ts # project metadata + environment list
├── .sops.yaml # age public keys for secrets encryption
│
├── packages/
│ ├── cli/ # zbc — CLI source + bundled module templates
│ │ ├── src/
│ │ │ ├── commands/ # init, add, apply, destroy
│ │ │ ├── engine/ # apply graph + secret loading
│ │ │ └── utils/
│ │ └── templates/ # source of truth scaffolded by `zbc init` / `zbc add`
│ │ ├── infra/
│ │ │ ├── modules/ # turso, cloudflare, … (each with registry.json)
│ │ │ └── src/ # defineModule helpers + shared types
│ │ ├── workflows/ # CI workflows (preview.yml, production.yml)
│ │ ├── root/ # package.json, tsconfig.json scaffolds
│ │ ├── sops.yaml
│ │ └── zbc.config.ts
│ └── infra/ # live consumer of the templates above
│ ├── modules → ../cli/templates/infra/modules # symlink
│ ├── src → ../cli/templates/infra/src # symlink
│ └── environments/
│ ├── production/ # add module instances per project
│ └── preview/ # ephemeral preview resources
│
└── .github/
└── workflows/
├── production.yml # zbc apply production on push to main
└── preview.yml # zbc apply/destroy preview on PR events
zbc init [project] [--ci github] # scaffold zbc into a repo (greenfield or existing)
zbc init --subtree # …vendoring engine + modules as a subtree at vendor/zbc
zbc add <module> # add a built-in module (turso, cloudflare, …)
zbc apply <env> # apply all module instances for an environment
zbc apply <env> <instance> # apply a specific instance (+ its dependencies)
zbc apply <env> --json <path> # …and write the result (instance outputs) as JSON
zbc list <env> # list what an environment declares, in dependency order
zbc run <env> <instance> <action> # run one operator-invoked module action (--yes if irreversible)
zbc destroy <env> # tear down every instance that defines destroy
zbc secret get <env> <key> # print one decrypted secret value on stdout
zbc update # bring the vendored engine + built-in modules up to this CLI's version
init is the one-time scaffold. It drops zbc.config.ts, .sops.yaml, the packages/infra/ skeleton, and (with --ci github) the workflows. It does not add modules — those come on demand. With --subtree (the standard for new consumers — ADR-0005) it instead runs git subtree add --squash of Zabaca/zbc-core at vendor/zbc — pinned to the tag matching the CLI version — and skips copying packages/infra/src/; the engine and built-in modules are then real files with upstream history. zbc-core is generated by CI (.github/workflows/publish-core.yml, dispatched by /release, splits packages/cli/templates/infra/ and tags it); never PR zbc-core directly.
add brings in a single module: in a subtree project it resolves the module from vendor/zbc/modules/<name>/ (nothing to copy — it installs dependencies and prints the secrets/instructions); in copy mode it copies index.ts into packages/infra/modules/<name>/ first. Either way it runs bun add for declared dependencies and prints the secrets you need to put in secrets.yaml along with the provider's signup/token URLs.
update pulls a newer zbc-core tag into vendor/zbc via git subtree pull --squash. In a copy-mode project — where there is no subtree to pull — it re-lays the templates this CLI carries over what is there: packages/infra/src/ plus every built-in module the project already added, deleting files this CLI no longer ships at that path so the project isn't left running two engine vintages at once. It never adds a built-in the project didn't ask for, never touches a consumer-authored module, skips symlinked paths (this repo's own packages/infra/{src,modules}), demands a clean git tree first (it overwrites and deletes), and names — never installs — any dependency a refreshed module declares that the project doesn't have. A stamp saying subtree with no engine under the prefix is refused rather than relabelled: that is the vendoring-never-landed failure, and it prints the re-vendoring command. Copy mode is still a dead end by design, and every zbc apply says so; zbc update just means an engine fix no longer has to be hand-diffed in. After the pull, update audits the prefix and names every file under vendor/zbc/ that zbc-core does not ship at that path — three surveyed consumers each wrote their own vendor/zbc/VENDORING.md, which a subtree push would deliver upstream. It is a warning by default (a different core vintage produces the same signal) and a hard failure under --strict. Consumer-authored files belong outside the prefix.
The vintage stamp. Both init modes and update write .zbc-vendor.json at the project root — mode, the CLI version that vendored, the zbc-core ref, and when. Commit it. zbc apply reads it and warns when the vendored engine is behind (or ahead of) the CLI running the apply, when the vintage is unknown, or whenever the project is in copy mode. It is never fatal, and it is silent for a subtree project on the matching version. The stamp lives at the root and not in vendor/zbc/ for the reason above: a file in the prefix rides a subtree push.
Contributions flow the other way with git subtree push — which is why consumer commits must stay purely inside or purely outside vendor/zbc/: a mixed commit gets half-split upstream. Consumer-owned modules belong outside the prefix (packages/infra/modules/), committed normally.
Getting things back out. Three surfaces exist because consumers kept rebuilding them in shell:
zbc secret get <env> <key> prints one decrypted value and nothing else,
so a local script can do TOKEN=$(zbc secret get production TURSO_API_TOKEN)
instead of sops -d … | grep. Absence and blankness are ctx.secret's rules,
not new ones: a missing key exits non-zero with the key named and writes
nothing to stdout, and a present-but-empty one needs --allow-blank.zbc apply <env> --json <path> writes { env, instances: [{ name, module, outputs }] } once the apply succeeds — the preview workflow reads its deploy
URLs from it with jq rather than grepping them out of the log. It takes a
path rather than printing to stdout because modules run build steps with
inherited stdio, so stdout is not a channel the CLI can promise to keep clean.
A value a module declared as a secret output is written as [redacted]
(see below), wherever in the document it appears — so a minted credential does
not reach the file. Everything else is verbatim, so still treat it as
sensitive and delete it after reading, as the workflow does.zbc list <env> [--json] reports what the environment declares, in the
order apply would run it: module, ephemeral, whether the module defines a
destroy, and each instance's imports. It runs no module and calls no
provider. It answers "what should exist"; enumerating what a provider
actually holds is not something the engine can do yet.Actions (ADR-0017)
are the third verb. apply converges and destroy tears down; neither is a home
for a one-shot irreversible act an operator performs deliberately — buying a
domain, rotating a key — so consumers put those outside zbc, in scripts no
module imports, and therefore outside the graph, the decrypted secrets and the
imports edge the act needs. A module may declare actions: { <name>: { description, irreversible?, run } }, reachable only as zbc run <env> <instance> <action>: never from apply or destroy, never applying the instance itself,
emitting nothing, and refused without --yes when irreversible — before the
config is parsed or any import applied. Its imports resolve as a
full-environment destroy's do — applied when the body asks — except for an
irreversible action, whose imports are applied up front so the body is never
re-entered mid-purchase. An action body must read imports through
ctx.output/ctx.outputValue, never ctx.imports. zbc run <env> <instance>
(no action) lists what an instance declares, and zbc list reports the same.
Outputs are values, not only strings. ctx.output still returns a string,
because a worker secret, a --var and a binding field all are one. An output
whose shape is the point — nameServers: string[] — is read with
ctx.outputValue(ref, field): the same edge and the same three absence
failures, without the string rule and without allowBlank (0, false and
'' are values). ctx.output on a present non-string now names the type it
found and points here.
Testing a module. createTestContext (exported from packages/infra/src,
vendor/zbc/src in a subtree project) builds an ApplyContext over stubbed
secrets and imports, so a module's real apply is callable from a test with no
engine and no provider. It applies the engine's own secret/output rules —
so a test sees the failure a deploy would — and records what the module asked
for as ctx.secretsRead / ctx.outputsRead.
const ctx = createTestContext({
secrets: { CLOUDFLARE_API_TOKEN: 'tok' },
imports: { 'main-db': { databaseUrl: 'libsql://test' } },
})
const outputs = await myModule.apply({ … }, ctx)
apply is declarative and idempotent. Run it the first time — everything is provisioned and deployed. Run it again — no-op except code deploy. Config changed — it converges. Same command locally and in CI.
An instance marked ephemeral: true is destroyed and re-applied on every run, so each run starts from a clean resource. That is the engine's rule, not a module's: ephemeral sits on the instance, any module that defines destroy can be ephemeral, and one that doesn't is a hard error before anything is applied. (ephemeral inside a module's config is the pre-0.14 spelling. It is still honoured — with a deprecation line on every apply — but only for the four modules that ever declared it: turso, r2, fly, cloudflare-token. Everywhere else that key was always stripped by the config schema, and it stays inert.) zbc destroy is separate: it tears down every instance that defines a destroy, ephemeral or not, in reverse dependency order, and is what cleans up when a PR is closed.
Modules live in packages/infra/modules/ (consumer-side) — really at packages/cli/templates/infra/modules/<name>/ (source of truth). Each module is a directory with two files:
index.ts — schema (zod) + apply/destroy logic via defineModuleregistry.json — manifest read by zbc add: files to copy, npm dependencies, required secrets, signup/token URLs, post-install instructionsNot every directory under modules/ is a module. registry.json's kind says which of three it is: module (the default), library — code the modules beside it import as ../<name>, defining no module (cloudflare-api, gcp-api, host-exec, incus-core, provision-core) — and app. A manifest's modules key names the siblings its code imports, and zbc add installs that graph first, transitively, so a copy-mode install never leaves a relative import dangling (ADR-0015).
A new built-in module = drop a directory under packages/cli/templates/infra/modules/<name>/ containing those two files. It's then available via zbc add <name> in any consumer repo.
Module shape (index.ts):
import { z } from 'zod'
import { defineModule } from '../../src/define-module'
export const tursoModule = defineModule({
name: 'turso',
configSchema: z.object({
orgName: z.string(),
dbName: z.string(),
group: z.string().default('default'),
primaryLocation: z.string().default('iad'),
}),
outputs: z.object({
databaseUrl: z.string(),
authToken: z.string(),
}),
async apply(config, ctx) {
// idempotently ensure database exists
return { databaseUrl, authToken }
},
})
Instances live in packages/infra/environments/<env>/ and wire a module to specific config:
// packages/infra/environments/production/main-db.ts
import { tursoModule } from '../../modules/turso'
export default tursoModule.instance({
name: 'main-db',
config: {
orgName: 'zabaca',
dbName: 'myproject-production',
primaryLocation: 'aws-us-west-2',
},
})
// packages/infra/environments/production/web.ts
import { cloudflareModule } from '../../modules/cloudflare'
export default cloudflareModule.instance({
name: 'web',
config: {
workdir: 'packages/web',
accountId: '<cloudflare account id>',
build: {
command: 'bun run build -- --filter=@myproject/web',
cwd: '.',
},
workerSecrets: ['SOME_RUNTIME_SECRET'],
},
})
Imports (imports: [mainDb]) are between instances, typed, refactor-safe, with outputs flowing from dependency to dependent, and a module's apply reads them through ctx.output({ from, output }, field) — the engine's one rule for that lookup, which fails by name rather than handing the module undefined. A destroy reads them the same way: the engine applies the imported instance on demand when one asks. Whether they're wired into the deployed service is up to the module and is explicit per entry: the cloudflare module lets each workerSecrets/workerVars entry be either a plain name (a key in this environment's secrets.yaml) or a { name, from, output } reference that pulls a value from an imported instance's outputs — from must be an instance listed in this instance's imports, and output a key that instance emits, or apply fails with a hard error naming both. Secrets are pushed via wrangler secret put (stdin-piped); vars via wrangler deploy --var (command-line visible — never route sensitive values through workerVars).
Bindings (ADR-0014) are the same edge, one level deeper. workerSecrets/workerVars reach the worker's environment; a binding lives in the file wrangler reads at deploy time, and wrangler has no CLI flag for it — which is why four consumers wrote a d1 module and every one of them still hardcodes database_id in wrangler.jsonc. The cloudflare module's bindings key closes it for any resource type: { type: 'd1_databases', binding: 'DB', field: 'database_id', from: 'app-db', output: 'databaseId' } — type is the wrangler config key holding the binding array (dotted for nested ones, queues.producers), field the key on the matched entry to set, and the value is either that { from, output } reference or a value literal. The binding must already be declared in the package's own wrangler config (placeholder id and all): wrangler still owns worker topology, zbc only supplies the identifier the provisioning instance just produced, and a binding no declaration matches is a hard error before wrangler runs (with wranglerEnv set that means the env.<name> block specifically — wrangler does not inherit binding keys into a named environment, so a top-level declaration is not the one that would ship). r2Bindings is the R2 shorthand for the same code path and is unchanged. The module patches a generated copy of the config, deploys it with --config, and deletes it.
Readiness (ADR-0013) is the second rule on that edge. Every provider returns success from a create call before the created thing works — a fresh Cloudflare token is refused by the very scope it was granted, a fresh GCP service account 404s its own keys endpoint — and four consumers each hand-rolled a retry loop inside their module because there was nowhere else to put one. A module may now declare ready: { proves, probe, timeoutMs?, intervalMs? } alongside apply, and the engine holds that instance's outputs at every imports edge until the probe passes, retrying while it throws or returns false. The probe belongs to the module because readiness is a claim about the capability the caller will use: leeandco measured /tokens/verify answering 200 at ~112ms while the scope-gated call was still refusing at ~1621ms. An instance nothing imports is never probed, and a module that declares no ready pays nothing. cloudflare-token is the first to declare one — it probes the minted token against the read permission groups it was granted (write does not imply read on Cloudflare), falling back to the account-owned token verify when it was granted no probeable read group.
Secret outputs (ADR-0016) are the third rule on that edge, and the survey's largest convergent case: four consumers mint a credential inside apply — a GCP service-account key, a Tailscale auth key, a scoped Cloudflare token — and each hand-rolled the discipline of keeping it out of logs and off disk. A module now declares which outputs are credentials: secretOutputs: { tokenValue: { rotates: 'each-apply' } }. The value still crosses an imports edge in memory verbatim; the engine replaces it with [redacted: <instance>.<output>] in every message it prints or throws (a provider echoing the Authorization header it refused is how the leak actually happens), and with [redacted] in zbc apply --json (by declared key on the minting instance, and by value everywhere else in the document, so an importer re-emitting it does not put it on disk either). rotates names who consumes the credential — 'each-apply' when the apply itself does, so rolling is free, or 'never' when a holder outside the apply does, in which case an ephemeral: true instance of that module is refused before anything applies, because destroy-and-recreate is a silent rotation. It cannot reach a module's own console.log, a spawned child's stdio, or a credential the minting module leaks before it returns — the engine never sees those bytes, and in the last case has not yet been told the value. cloudflare-token is the first declarer.
d1 provisions a Cloudflare D1 database (idempotent list→create, destroy tolerating an already-absent database) and emits { databaseName, databaseId }. It exists because five consumers each wrote it and none could close the gap after it: they all still hardcoded database_id in wrangler.jsonc. With ADR-0014's bindings they no longer have to — { type: 'd1_databases', binding: 'DB', field: 'database_id', from: 'app-db', output: 'databaseId' } fills it in at deploy time. It also converges schema inside apply: statements is end-state DDL replayed every run (so each must be idempotent), and additiveColumns issues the one thing SQLite has no IF NOT EXISTS spelling for, treating "duplicate column name" as success. A versioned migrations directory is not here — that needs somewhere to run after the deploy. CLOUDFLARE_API_TOKEN needs Account → D1: Edit.
cloudflare-email provisions Cloudflare Email Service (public beta) for a domain via the REST API (the first REST-direct CF module — wrangler has no Email onboarding surface): outbound sending (SPF/DKIM/DMARC/bounce-MX auto-provisioned) and inbound routing (literal rules + catch-all → forward / worker / drop). It reuses CLOUDFLARE_API_TOKEN but needs extra token scopes (Email Routing Rules Edit, Zone Settings Edit, and DNS Edit on the zone; Email Sending Edit and Email Routing Addresses Edit on the account) and a Workers Paid plan for sending. Beta caveats: 5 MiB outbound cap, unpublished rate limits (pilot before high-volume use), and forward destinations require a manual email-click verification — apply triggers the email, then fails with instructions until you re-run. In this repo it powers mail.cedarpad.com, whose catch-all routes into the zbc-inbox worker (packages/inbox/) — an agent-accessible inbox with a bearer-authed JSON API (threads/messages/search/send/drafts/scheduled/webhooks/labels), an MCP server at /mcp (Streamable HTTP, same bearer token — point Claude Code or claude.ai at it directly), and a minimal web UI.
gcp-service-account converges one Google service account and mints a fresh key on every apply, pruning the account's user-managed keys to maxKeys oldest-first so the key the previous apply handed out keeps working while a deploy is in flight. It reads the Google REST API through the gcp-api library (a hand-rolled RS256 JWT bearer grant — no SDK, no gcloud) authenticated by one bootstrap key in secrets.yaml (GCP_SERVICE_ACCOUNT_KEY, per-instance via credentialSecret). The minted saKey is declared a secret output (rotates: 'each-apply'), so it crosses imports in memory and reaches neither the log nor zbc apply --json, and its readiness probe is the exchange a dependent will make — the minted key for an access token — so no importer gets a key the token endpoint has not seen yet. (The provider's other lateness, an account that 404s its own keys collection seconds after being created, happens before apply returns and so is retried inside it; no engine hook reaches between two statements of one apply.) It ships the service-account half only — two surveyed consumers welded Google Calendar provisioning into the same module because the Calendar had to be created as the account just minted, and imports plus readiness is what that ordering actually needs. A module wanting Calendar (or any other Google resource) imports this one and reads saKey. Unlike those consumers it defines destroy, so a preview environment can own an ephemeral account of its own — which is how a preview rotation is kept from invalidating production's credential. IAM's delete is a 30-day soft delete, so re-applying a destroyed id undeletes it (handled in apply); a per-PR serviceAccountId skips that round trip, within IAM's 30-character limit.
inbox (app template) — the inbox worker above is also available to any zbc project as a scaffoldable app: zbc add inbox auto-vendors its module dependencies (cloudflare, cloudflare-email, r2), copies the full package verbatim into packages/inbox/, runs bun install, and prints the three instance files to create. App templates live at packages/cli/templates/apps/<name>/ and declare kind: "app", a targetDir, and their modules dependencies in registry.json. The template is placeholder-free: all per-project identity lives in the instance files (cloudflare module workerName, r2Bindings → an r2 module instance, and a workerVars literal for DEFAULT_FROM), so this repo's packages/inbox/ is a plain symlink into the template (the template path is also an explicit workspace entry in the root package.json, since bun's workspace glob doesn't follow symlinks) — no mirroring needed.
Ephemeral preview instances use dynamic naming and destroy+recreate on every apply:
export default tursoModule.instance({
name: 'main-db',
ephemeral: true,
config: {
dbName: `myproject-preview-pr-${process.env.PR_NUMBER}`,
},
})
zbc apply production, dispatched deliberately by /release (gh workflow run production.yml -f instances=…). A merge to main deploys nothingzbc apply preview, ephemeral per-PR resources, triggered on PR open/push, cleaned up on PR close via zbc destroy previewAll secrets are committed to the repo, encrypted with SOPS + age. Each developer and CI environment has their own age keypair.
.sops.yaml lists all age public keys (committed to repo) as recipientsage-keygen.sops.yamlsops updatekeys <secrets.yaml>~/Library/Application Support/sops/age/keys.txt~/.config/sops/age/keys.txt.sops.yamlsops updatekeys <secrets.yaml>CI has its own age keypair. The private key is stored as a single GitHub Actions secret (SOPS_AGE_KEY). The public key is listed in .sops.yaml alongside developer keys.
packages/<project>/.packages/infra/environments/<env>/ — typically a Turso database and a Cloudflare Worker deploy, wired via imports.packages/infra/environments/<env>/secrets.yaml, encrypted via SOPS.zbc apply <env> locally to validate. Preview still applies automatically on PRs; production does not — it ships when someone runs /release.Nothing releases on merge. Pushing to main deploys nothing, publishes
nothing and tags nothing — releasing is one deliberate act, run through the
/release skill (.claude/skills/release/SKILL.md). That skill is the
reference; this is the summary.
It became manual on 2026-09-03, after both failure directions happened. #115
shipped the instance-level ephemeral rule with no version bump and never
reached npm. Earlier, two commits landed under the split prefix between v0.10.6
and v0.10.7 with no version naming them — one renaming provision-core's marker
directory, which re-provisions a consumer's whole fleet. Neither was reported,
because neither was a failure: the workflows did what they said.
bun scripts/release.ts # dry run: what would ship, and every refusal
bun scripts/release.ts minor --push # bump, commit, tag zbc-cli-v<version>, push main
gh workflow run publish-npm.yml # never publish locally — it stages and burns the version
gh workflow run publish-core.yml # tags zbc-core-v<version> — every release; init/update pin it exactly
gh workflow run production.yml -f instances=ALL
Write the CHANGELOG entry before the bump, and only when the release changes
something a consumer already depends on — packages/cli/CHANGELOG.md is for
releases you must read before upgrading, not a commit log.
scripts/release.ts refuses eight ways a release goes wrong, all at once:
not-main, dirty, not-synced, tag-exists, cli-tag-exists,
npm-published, nothing-to-release, not-ahead.
Note: existing scaffolded repos have their own checked-in workflows from
whenever they last ran zbc init — template changes do not flow into them
automatically. They need a re-scaffold or manual patch to pick up workflow
updates.
The Prose design system is split across two packages:
packages/design-system/ — pure component library. No build, no app. Exports React components + CSS tokens.packages/design-system-viewer/ — Astro showcase app that consumes the library via @zbc/design-system. The first proving ground for the consumer pattern.Run the viewer locally:
bun run dev # turbo dispatches to @zbc/design-system-viewer
Opens at http://localhost:3000. The viewer shows all components and pages in isolation, with dark/light toggle.
packages/infra/modules/ and packages/infra/src/ are symlinks into packages/cli/templates/infra/. The cli/templates/ tree is the source of truth (it's what zbc init scaffolds into new projects); this repo is a live consumer of its own templates. Edit modules at packages/cli/templates/infra/modules/<name>/, not via the symlink.bun everywhere (bun install, bun run, bunx). Do not use npm or yarn.@zabaca/zbc: dispatch publish-npm.yml; do not publish from your machine. npm restricts 2FA-bypass tokens for direct publishing, so a local bun publish stages a version that never commits and can never be published again (0.16.2 and 0.16.3 were burned this way). CI uses bun publish, never npm publish — npm strips non-node shebangs from bin entries and breaks the CLI.@import "tailwindcss" syntax and CSS-first config. No tailwind.config.js.packages/design-system/ is purpose-built for Zabaca. Do not treat it as a generic component library..claude/ directory — mostly gitignored. The exception is .claude/skills/, which is committed and contains AI slash command definitions./mode-b and /visual-review.Issues live in Fredrin as tickets, managed via the fredrin CLI — not GitHub Issues. See docs/agents/issue-tracker.md.
Scope a ticket to what a Worker can do from a worktree. A Worker cannot deploy to production, verify against it, publish, tag, merge, or touch a provider dashboard — those are operator steps after merge, and a ticket must not list them as acceptance criteria. Full list in docs/agents/issue-tracker.md under "What a Worker cannot do".
Linked at the top of this file. Infrastructure keeps its glossary at the root (CONTEXT.md + docs/adr/) because it spans packages/cli/ and packages/infra/; Agent keeps its own under packages/agent/; walgit keeps its own with the package, so it ships to consumers (edit it at packages/cli/templates/apps/walgit/CONTEXT.md, never through the symlink); agentgit keeps its own under packages/agentgit/. See docs/agents/domain.md for how to maintain them.
MIT — Copyright (c) 2026 Zabaca.
341 commits
TypeScript
89.9%
Python
7.2%
JavaScript
1.2%
Zabaca stack bootstrap — zbc CLI + infrastructure modules
TypeScript
2
341 commits
updated Sep 23, 2026
Zabaca's open-source stack bootstrap. It holds the zbc CLI, the infrastructure modules that make deploying an application easy, and a small set of applications most software companies want (inbox, warehouse, …) as app templates.
Who it's for: zbc is open source, and its consumers are other software companies, not just Zabaca. A consumer forks this repo or installs just the CLI, then vendors the engine and built-in modules as a git subtree at vendor/zbc (ADR-0005). Nothing is Zabaca-hosted — every consumer runs their own copy of everything, including any supporting service.
Two kinds of thing ship to consumers. A module provisions or deploys one resource (turso, cloudflare, r2, …). An app template is a whole application scaffolded into the consumer's repo — it earns its place by being something most software companies would want, or by being required for the zbc workflow itself (secret-relay); anything specific to one project belongs in that project, consuming zbc.
Domain vocabulary and principles live in CONTEXT.md; the map of contexts is CONTEXT-MAP.md.
Projects live here under packages/<project>/; per-environment module instances live under packages/infra/environments/<env>/. Applying an environment is one command — zbc apply <env> — which discovers every instance in the environment directory, resolves the dependency graph from imports, decrypts secrets, and converges each service to the desired state.
├── zbc.config.ts # project metadata + environment list
├── .sops.yaml # age public keys for secrets encryption
│
├── packages/
│ ├── cli/ # zbc — CLI source + bundled module templates
│ │ ├── src/
│ │ │ ├── commands/ # init, add, apply, destroy
│ │ │ ├── engine/ # apply graph + secret loading
│ │ │ └── utils/
│ │ └── templates/ # source of truth scaffolded by `zbc init` / `zbc add`
│ │ ├── infra/
│ │ │ ├── modules/ # turso, cloudflare, … (each with registry.json)
│ │ │ └── src/ # defineModule helpers + shared types
│ │ ├── workflows/ # CI workflows (preview.yml, production.yml)
│ │ ├── root/ # package.json, tsconfig.json scaffolds
│ │ ├── sops.yaml
│ │ └── zbc.config.ts
│ └── infra/ # live consumer of the templates above
│ ├── modules → ../cli/templates/infra/modules # symlink
│ ├── src → ../cli/templates/infra/src # symlink
│ └── environments/
│ ├── production/ # add module instances per project
│ └── preview/ # ephemeral preview resources
│
└── .github/
└── workflows/
├── production.yml # zbc apply production on push to main
└── preview.yml # zbc apply/destroy preview on PR events
zbc init [project] [--ci github] # scaffold zbc into a repo (greenfield or existing)
zbc init --subtree # …vendoring engine + modules as a subtree at vendor/zbc
zbc add <module> # add a built-in module (turso, cloudflare, …)
zbc apply <env> # apply all module instances for an environment
zbc apply <env> <instance> # apply a specific instance (+ its dependencies)
zbc apply <env> --json <path> # …and write the result (instance outputs) as JSON
zbc list <env> # list what an environment declares, in dependency order
zbc run <env> <instance> <action> # run one operator-invoked module action (--yes if irreversible)
zbc destroy <env> # tear down every instance that defines destroy
zbc secret get <env> <key> # print one decrypted secret value on stdout
zbc update # bring the vendored engine + built-in modules up to this CLI's version
init is the one-time scaffold. It drops zbc.config.ts, .sops.yaml, the packages/infra/ skeleton, and (with --ci github) the workflows. It does not add modules — those come on demand. With --subtree (the standard for new consumers — ADR-0005) it instead runs git subtree add --squash of Zabaca/zbc-core at vendor/zbc — pinned to the tag matching the CLI version — and skips copying packages/infra/src/; the engine and built-in modules are then real files with upstream history. zbc-core is generated by CI (.github/workflows/publish-core.yml, dispatched by /release, splits packages/cli/templates/infra/ and tags it); never PR zbc-core directly.
add brings in a single module: in a subtree project it resolves the module from vendor/zbc/modules/<name>/ (nothing to copy — it installs dependencies and prints the secrets/instructions); in copy mode it copies index.ts into packages/infra/modules/<name>/ first. Either way it runs bun add for declared dependencies and prints the secrets you need to put in secrets.yaml along with the provider's signup/token URLs.
update pulls a newer zbc-core tag into vendor/zbc via git subtree pull --squash. In a copy-mode project — where there is no subtree to pull — it re-lays the templates this CLI carries over what is there: packages/infra/src/ plus every built-in module the project already added, deleting files this CLI no longer ships at that path so the project isn't left running two engine vintages at once. It never adds a built-in the project didn't ask for, never touches a consumer-authored module, skips symlinked paths (this repo's own packages/infra/{src,modules}), demands a clean git tree first (it overwrites and deletes), and names — never installs — any dependency a refreshed module declares that the project doesn't have. A stamp saying subtree with no engine under the prefix is refused rather than relabelled: that is the vendoring-never-landed failure, and it prints the re-vendoring command. Copy mode is still a dead end by design, and every zbc apply says so; zbc update just means an engine fix no longer has to be hand-diffed in. After the pull, update audits the prefix and names every file under vendor/zbc/ that zbc-core does not ship at that path — three surveyed consumers each wrote their own vendor/zbc/VENDORING.md, which a subtree push would deliver upstream. It is a warning by default (a different core vintage produces the same signal) and a hard failure under --strict. Consumer-authored files belong outside the prefix.
The vintage stamp. Both init modes and update write .zbc-vendor.json at the project root — mode, the CLI version that vendored, the zbc-core ref, and when. Commit it. zbc apply reads it and warns when the vendored engine is behind (or ahead of) the CLI running the apply, when the vintage is unknown, or whenever the project is in copy mode. It is never fatal, and it is silent for a subtree project on the matching version. The stamp lives at the root and not in vendor/zbc/ for the reason above: a file in the prefix rides a subtree push.
Contributions flow the other way with git subtree push — which is why consumer commits must stay purely inside or purely outside vendor/zbc/: a mixed commit gets half-split upstream. Consumer-owned modules belong outside the prefix (packages/infra/modules/), committed normally.
Getting things back out. Three surfaces exist because consumers kept rebuilding them in shell:
zbc secret get <env> <key> prints one decrypted value and nothing else,
so a local script can do TOKEN=$(zbc secret get production TURSO_API_TOKEN)
instead of sops -d … | grep. Absence and blankness are ctx.secret's rules,
not new ones: a missing key exits non-zero with the key named and writes
nothing to stdout, and a present-but-empty one needs --allow-blank.zbc apply <env> --json <path> writes { env, instances: [{ name, module, outputs }] } once the apply succeeds — the preview workflow reads its deploy
URLs from it with jq rather than grepping them out of the log. It takes a
path rather than printing to stdout because modules run build steps with
inherited stdio, so stdout is not a channel the CLI can promise to keep clean.
A value a module declared as a secret output is written as [redacted]
(see below), wherever in the document it appears — so a minted credential does
not reach the file. Everything else is verbatim, so still treat it as
sensitive and delete it after reading, as the workflow does.zbc list <env> [--json] reports what the environment declares, in the
order apply would run it: module, ephemeral, whether the module defines a
destroy, and each instance's imports. It runs no module and calls no
provider. It answers "what should exist"; enumerating what a provider
actually holds is not something the engine can do yet.Actions (ADR-0017)
are the third verb. apply converges and destroy tears down; neither is a home
for a one-shot irreversible act an operator performs deliberately — buying a
domain, rotating a key — so consumers put those outside zbc, in scripts no
module imports, and therefore outside the graph, the decrypted secrets and the
imports edge the act needs. A module may declare actions: { <name>: { description, irreversible?, run } }, reachable only as zbc run <env> <instance> <action>: never from apply or destroy, never applying the instance itself,
emitting nothing, and refused without --yes when irreversible — before the
config is parsed or any import applied. Its imports resolve as a
full-environment destroy's do — applied when the body asks — except for an
irreversible action, whose imports are applied up front so the body is never
re-entered mid-purchase. An action body must read imports through
ctx.output/ctx.outputValue, never ctx.imports. zbc run <env> <instance>
(no action) lists what an instance declares, and zbc list reports the same.
Outputs are values, not only strings. ctx.output still returns a string,
because a worker secret, a --var and a binding field all are one. An output
whose shape is the point — nameServers: string[] — is read with
ctx.outputValue(ref, field): the same edge and the same three absence
failures, without the string rule and without allowBlank (0, false and
'' are values). ctx.output on a present non-string now names the type it
found and points here.
Testing a module. createTestContext (exported from packages/infra/src,
vendor/zbc/src in a subtree project) builds an ApplyContext over stubbed
secrets and imports, so a module's real apply is callable from a test with no
engine and no provider. It applies the engine's own secret/output rules —
so a test sees the failure a deploy would — and records what the module asked
for as ctx.secretsRead / ctx.outputsRead.
const ctx = createTestContext({
secrets: { CLOUDFLARE_API_TOKEN: 'tok' },
imports: { 'main-db': { databaseUrl: 'libsql://test' } },
})
const outputs = await myModule.apply({ … }, ctx)
apply is declarative and idempotent. Run it the first time — everything is provisioned and deployed. Run it again — no-op except code deploy. Config changed — it converges. Same command locally and in CI.
An instance marked ephemeral: true is destroyed and re-applied on every run, so each run starts from a clean resource. That is the engine's rule, not a module's: ephemeral sits on the instance, any module that defines destroy can be ephemeral, and one that doesn't is a hard error before anything is applied. (ephemeral inside a module's config is the pre-0.14 spelling. It is still honoured — with a deprecation line on every apply — but only for the four modules that ever declared it: turso, r2, fly, cloudflare-token. Everywhere else that key was always stripped by the config schema, and it stays inert.) zbc destroy is separate: it tears down every instance that defines a destroy, ephemeral or not, in reverse dependency order, and is what cleans up when a PR is closed.
Modules live in packages/infra/modules/ (consumer-side) — really at packages/cli/templates/infra/modules/<name>/ (source of truth). Each module is a directory with two files:
index.ts — schema (zod) + apply/destroy logic via defineModuleregistry.json — manifest read by zbc add: files to copy, npm dependencies, required secrets, signup/token URLs, post-install instructionsNot every directory under modules/ is a module. registry.json's kind says which of three it is: module (the default), library — code the modules beside it import as ../<name>, defining no module (cloudflare-api, gcp-api, host-exec, incus-core, provision-core) — and app. A manifest's modules key names the siblings its code imports, and zbc add installs that graph first, transitively, so a copy-mode install never leaves a relative import dangling (ADR-0015).
A new built-in module = drop a directory under packages/cli/templates/infra/modules/<name>/ containing those two files. It's then available via zbc add <name> in any consumer repo.
Module shape (index.ts):
import { z } from 'zod'
import { defineModule } from '../../src/define-module'
export const tursoModule = defineModule({
name: 'turso',
configSchema: z.object({
orgName: z.string(),
dbName: z.string(),
group: z.string().default('default'),
primaryLocation: z.string().default('iad'),
}),
outputs: z.object({
databaseUrl: z.string(),
authToken: z.string(),
}),
async apply(config, ctx) {
// idempotently ensure database exists
return { databaseUrl, authToken }
},
})
Instances live in packages/infra/environments/<env>/ and wire a module to specific config:
// packages/infra/environments/production/main-db.ts
import { tursoModule } from '../../modules/turso'
export default tursoModule.instance({
name: 'main-db',
config: {
orgName: 'zabaca',
dbName: 'myproject-production',
primaryLocation: 'aws-us-west-2',
},
})
// packages/infra/environments/production/web.ts
import { cloudflareModule } from '../../modules/cloudflare'
export default cloudflareModule.instance({
name: 'web',
config: {
workdir: 'packages/web',
accountId: '<cloudflare account id>',
build: {
command: 'bun run build -- --filter=@myproject/web',
cwd: '.',
},
workerSecrets: ['SOME_RUNTIME_SECRET'],
},
})
Imports (imports: [mainDb]) are between instances, typed, refactor-safe, with outputs flowing from dependency to dependent, and a module's apply reads them through ctx.output({ from, output }, field) — the engine's one rule for that lookup, which fails by name rather than handing the module undefined. A destroy reads them the same way: the engine applies the imported instance on demand when one asks. Whether they're wired into the deployed service is up to the module and is explicit per entry: the cloudflare module lets each workerSecrets/workerVars entry be either a plain name (a key in this environment's secrets.yaml) or a { name, from, output } reference that pulls a value from an imported instance's outputs — from must be an instance listed in this instance's imports, and output a key that instance emits, or apply fails with a hard error naming both. Secrets are pushed via wrangler secret put (stdin-piped); vars via wrangler deploy --var (command-line visible — never route sensitive values through workerVars).
Bindings (ADR-0014) are the same edge, one level deeper. workerSecrets/workerVars reach the worker's environment; a binding lives in the file wrangler reads at deploy time, and wrangler has no CLI flag for it — which is why four consumers wrote a d1 module and every one of them still hardcodes database_id in wrangler.jsonc. The cloudflare module's bindings key closes it for any resource type: { type: 'd1_databases', binding: 'DB', field: 'database_id', from: 'app-db', output: 'databaseId' } — type is the wrangler config key holding the binding array (dotted for nested ones, queues.producers), field the key on the matched entry to set, and the value is either that { from, output } reference or a value literal. The binding must already be declared in the package's own wrangler config (placeholder id and all): wrangler still owns worker topology, zbc only supplies the identifier the provisioning instance just produced, and a binding no declaration matches is a hard error before wrangler runs (with wranglerEnv set that means the env.<name> block specifically — wrangler does not inherit binding keys into a named environment, so a top-level declaration is not the one that would ship). r2Bindings is the R2 shorthand for the same code path and is unchanged. The module patches a generated copy of the config, deploys it with --config, and deletes it.
Readiness (ADR-0013) is the second rule on that edge. Every provider returns success from a create call before the created thing works — a fresh Cloudflare token is refused by the very scope it was granted, a fresh GCP service account 404s its own keys endpoint — and four consumers each hand-rolled a retry loop inside their module because there was nowhere else to put one. A module may now declare ready: { proves, probe, timeoutMs?, intervalMs? } alongside apply, and the engine holds that instance's outputs at every imports edge until the probe passes, retrying while it throws or returns false. The probe belongs to the module because readiness is a claim about the capability the caller will use: leeandco measured /tokens/verify answering 200 at ~112ms while the scope-gated call was still refusing at ~1621ms. An instance nothing imports is never probed, and a module that declares no ready pays nothing. cloudflare-token is the first to declare one — it probes the minted token against the read permission groups it was granted (write does not imply read on Cloudflare), falling back to the account-owned token verify when it was granted no probeable read group.
Secret outputs (ADR-0016) are the third rule on that edge, and the survey's largest convergent case: four consumers mint a credential inside apply — a GCP service-account key, a Tailscale auth key, a scoped Cloudflare token — and each hand-rolled the discipline of keeping it out of logs and off disk. A module now declares which outputs are credentials: secretOutputs: { tokenValue: { rotates: 'each-apply' } }. The value still crosses an imports edge in memory verbatim; the engine replaces it with [redacted: <instance>.<output>] in every message it prints or throws (a provider echoing the Authorization header it refused is how the leak actually happens), and with [redacted] in zbc apply --json (by declared key on the minting instance, and by value everywhere else in the document, so an importer re-emitting it does not put it on disk either). rotates names who consumes the credential — 'each-apply' when the apply itself does, so rolling is free, or 'never' when a holder outside the apply does, in which case an ephemeral: true instance of that module is refused before anything applies, because destroy-and-recreate is a silent rotation. It cannot reach a module's own console.log, a spawned child's stdio, or a credential the minting module leaks before it returns — the engine never sees those bytes, and in the last case has not yet been told the value. cloudflare-token is the first declarer.
d1 provisions a Cloudflare D1 database (idempotent list→create, destroy tolerating an already-absent database) and emits { databaseName, databaseId }. It exists because five consumers each wrote it and none could close the gap after it: they all still hardcoded database_id in wrangler.jsonc. With ADR-0014's bindings they no longer have to — { type: 'd1_databases', binding: 'DB', field: 'database_id', from: 'app-db', output: 'databaseId' } fills it in at deploy time. It also converges schema inside apply: statements is end-state DDL replayed every run (so each must be idempotent), and additiveColumns issues the one thing SQLite has no IF NOT EXISTS spelling for, treating "duplicate column name" as success. A versioned migrations directory is not here — that needs somewhere to run after the deploy. CLOUDFLARE_API_TOKEN needs Account → D1: Edit.
cloudflare-email provisions Cloudflare Email Service (public beta) for a domain via the REST API (the first REST-direct CF module — wrangler has no Email onboarding surface): outbound sending (SPF/DKIM/DMARC/bounce-MX auto-provisioned) and inbound routing (literal rules + catch-all → forward / worker / drop). It reuses CLOUDFLARE_API_TOKEN but needs extra token scopes (Email Routing Rules Edit, Zone Settings Edit, and DNS Edit on the zone; Email Sending Edit and Email Routing Addresses Edit on the account) and a Workers Paid plan for sending. Beta caveats: 5 MiB outbound cap, unpublished rate limits (pilot before high-volume use), and forward destinations require a manual email-click verification — apply triggers the email, then fails with instructions until you re-run. In this repo it powers mail.cedarpad.com, whose catch-all routes into the zbc-inbox worker (packages/inbox/) — an agent-accessible inbox with a bearer-authed JSON API (threads/messages/search/send/drafts/scheduled/webhooks/labels), an MCP server at /mcp (Streamable HTTP, same bearer token — point Claude Code or claude.ai at it directly), and a minimal web UI.
gcp-service-account converges one Google service account and mints a fresh key on every apply, pruning the account's user-managed keys to maxKeys oldest-first so the key the previous apply handed out keeps working while a deploy is in flight. It reads the Google REST API through the gcp-api library (a hand-rolled RS256 JWT bearer grant — no SDK, no gcloud) authenticated by one bootstrap key in secrets.yaml (GCP_SERVICE_ACCOUNT_KEY, per-instance via credentialSecret). The minted saKey is declared a secret output (rotates: 'each-apply'), so it crosses imports in memory and reaches neither the log nor zbc apply --json, and its readiness probe is the exchange a dependent will make — the minted key for an access token — so no importer gets a key the token endpoint has not seen yet. (The provider's other lateness, an account that 404s its own keys collection seconds after being created, happens before apply returns and so is retried inside it; no engine hook reaches between two statements of one apply.) It ships the service-account half only — two surveyed consumers welded Google Calendar provisioning into the same module because the Calendar had to be created as the account just minted, and imports plus readiness is what that ordering actually needs. A module wanting Calendar (or any other Google resource) imports this one and reads saKey. Unlike those consumers it defines destroy, so a preview environment can own an ephemeral account of its own — which is how a preview rotation is kept from invalidating production's credential. IAM's delete is a 30-day soft delete, so re-applying a destroyed id undeletes it (handled in apply); a per-PR serviceAccountId skips that round trip, within IAM's 30-character limit.
inbox (app template) — the inbox worker above is also available to any zbc project as a scaffoldable app: zbc add inbox auto-vendors its module dependencies (cloudflare, cloudflare-email, r2), copies the full package verbatim into packages/inbox/, runs bun install, and prints the three instance files to create. App templates live at packages/cli/templates/apps/<name>/ and declare kind: "app", a targetDir, and their modules dependencies in registry.json. The template is placeholder-free: all per-project identity lives in the instance files (cloudflare module workerName, r2Bindings → an r2 module instance, and a workerVars literal for DEFAULT_FROM), so this repo's packages/inbox/ is a plain symlink into the template (the template path is also an explicit workspace entry in the root package.json, since bun's workspace glob doesn't follow symlinks) — no mirroring needed.
Ephemeral preview instances use dynamic naming and destroy+recreate on every apply:
export default tursoModule.instance({
name: 'main-db',
ephemeral: true,
config: {
dbName: `myproject-preview-pr-${process.env.PR_NUMBER}`,
},
})
zbc apply production, dispatched deliberately by /release (gh workflow run production.yml -f instances=…). A merge to main deploys nothingzbc apply preview, ephemeral per-PR resources, triggered on PR open/push, cleaned up on PR close via zbc destroy previewAll secrets are committed to the repo, encrypted with SOPS + age. Each developer and CI environment has their own age keypair.
.sops.yaml lists all age public keys (committed to repo) as recipientsage-keygen.sops.yamlsops updatekeys <secrets.yaml>~/Library/Application Support/sops/age/keys.txt~/.config/sops/age/keys.txt.sops.yamlsops updatekeys <secrets.yaml>CI has its own age keypair. The private key is stored as a single GitHub Actions secret (SOPS_AGE_KEY). The public key is listed in .sops.yaml alongside developer keys.
packages/<project>/.packages/infra/environments/<env>/ — typically a Turso database and a Cloudflare Worker deploy, wired via imports.packages/infra/environments/<env>/secrets.yaml, encrypted via SOPS.zbc apply <env> locally to validate. Preview still applies automatically on PRs; production does not — it ships when someone runs /release.Nothing releases on merge. Pushing to main deploys nothing, publishes
nothing and tags nothing — releasing is one deliberate act, run through the
/release skill (.claude/skills/release/SKILL.md). That skill is the
reference; this is the summary.
It became manual on 2026-09-03, after both failure directions happened. #115
shipped the instance-level ephemeral rule with no version bump and never
reached npm. Earlier, two commits landed under the split prefix between v0.10.6
and v0.10.7 with no version naming them — one renaming provision-core's marker
directory, which re-provisions a consumer's whole fleet. Neither was reported,
because neither was a failure: the workflows did what they said.
bun scripts/release.ts # dry run: what would ship, and every refusal
bun scripts/release.ts minor --push # bump, commit, tag zbc-cli-v<version>, push main
gh workflow run publish-npm.yml # never publish locally — it stages and burns the version
gh workflow run publish-core.yml # tags zbc-core-v<version> — every release; init/update pin it exactly
gh workflow run production.yml -f instances=ALL
Write the CHANGELOG entry before the bump, and only when the release changes
something a consumer already depends on — packages/cli/CHANGELOG.md is for
releases you must read before upgrading, not a commit log.
scripts/release.ts refuses eight ways a release goes wrong, all at once:
not-main, dirty, not-synced, tag-exists, cli-tag-exists,
npm-published, nothing-to-release, not-ahead.
Note: existing scaffolded repos have their own checked-in workflows from
whenever they last ran zbc init — template changes do not flow into them
automatically. They need a re-scaffold or manual patch to pick up workflow
updates.
The Prose design system is split across two packages:
packages/design-system/ — pure component library. No build, no app. Exports React components + CSS tokens.packages/design-system-viewer/ — Astro showcase app that consumes the library via @zbc/design-system. The first proving ground for the consumer pattern.Run the viewer locally:
bun run dev # turbo dispatches to @zbc/design-system-viewer
Opens at http://localhost:3000. The viewer shows all components and pages in isolation, with dark/light toggle.
packages/infra/modules/ and packages/infra/src/ are symlinks into packages/cli/templates/infra/. The cli/templates/ tree is the source of truth (it's what zbc init scaffolds into new projects); this repo is a live consumer of its own templates. Edit modules at packages/cli/templates/infra/modules/<name>/, not via the symlink.bun everywhere (bun install, bun run, bunx). Do not use npm or yarn.@zabaca/zbc: dispatch publish-npm.yml; do not publish from your machine. npm restricts 2FA-bypass tokens for direct publishing, so a local bun publish stages a version that never commits and can never be published again (0.16.2 and 0.16.3 were burned this way). CI uses bun publish, never npm publish — npm strips non-node shebangs from bin entries and breaks the CLI.@import "tailwindcss" syntax and CSS-first config. No tailwind.config.js.packages/design-system/ is purpose-built for Zabaca. Do not treat it as a generic component library..claude/ directory — mostly gitignored. The exception is .claude/skills/, which is committed and contains AI slash command definitions./mode-b and /visual-review.Issues live in Fredrin as tickets, managed via the fredrin CLI — not GitHub Issues. See docs/agents/issue-tracker.md.
Scope a ticket to what a Worker can do from a worktree. A Worker cannot deploy to production, verify against it, publish, tag, merge, or touch a provider dashboard — those are operator steps after merge, and a ticket must not list them as acceptance criteria. Full list in docs/agents/issue-tracker.md under "What a Worker cannot do".
Linked at the top of this file. Infrastructure keeps its glossary at the root (CONTEXT.md + docs/adr/) because it spans packages/cli/ and packages/infra/; Agent keeps its own under packages/agent/; walgit keeps its own with the package, so it ships to consumers (edit it at packages/cli/templates/apps/walgit/CONTEXT.md, never through the symlink); agentgit keeps its own under packages/agentgit/. See docs/agents/domain.md for how to maintain them.
MIT — Copyright (c) 2026 Zabaca.
341 commits
TypeScript
89.9%
Python
7.2%
JavaScript
1.2%