Skip to content

Flat per-axis schema + fragment templates - #4

Merged
timothygithinji merged 10 commits into
mainfrom
improve-cli-ui
May 21, 2026
Merged

Flat per-axis schema + fragment templates#4
timothygithinji merged 10 commits into
mainfrom
improve-cli-ui

Conversation

@timothygithinji

@timothygithinji timothygithinji commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

Replaces the archetype-discriminated schema with a flat 19-axis model (Better-T-Stack–style), adds a predicate engine for value-level disable rules, restructures templates into composable fragments, and gates plugin invocations on per-axis predicates. The headline fix: picking storage=none no longer scaffolds a MinIO docker-compose.yml.

The work was done in six reviewed phases against packages/schema, apps/cli, packages/templates, and apps/web.

What changed

Schema (packages/schema) — Single z.object with structure | cloudProvider | iac | runtime | frontend | backend | docs | api | database | databaseHost | orm | auth | storage | payments | addons | packageManager | git | install plus the retained legacy fields. FieldMeta gains valueRules (per-value dependencies + incompatibilities + reason) for BTS-style compat. New predicates.ts exports evaluateField, isFieldVisible, validateDecisions.

CLI (apps/cli) — Init walks the flat schema, re-evaluating visibility every iteration; disabled values are filtered + logged before the prompt. --yes substitutes the first enabled value when the schema default is disabled. Per-axis CLI flags. Presets become bundles with {id, name, defaults, templates, run}; --preset <id> preloads defaults, CLI flags override. State stores presetId. Plugin invocations are now declarative PluginStep[] with activate(decisions) predicates, run via runPluginGraph / runParallel. Destroy gates teardown on the same predicates.

Templates (packages/templates/files) — New fragments/<axis>/<value>/ tree composed at scaffold-time. MinIO moves to fragments/storage/r2/, hookdeck infra + SDK to fragments/hookdeck/true/, trigger config to fragments/trigger/true/. The {{#if hookdeck}}filename{{/if}} hacks and the _assets/hookdeck-sdk programmatic copy are gone.

Web (apps/web)DraftStack = InitDecisions. New 9-category form (project / structure / infra / app / data / features / addons / tooling / toggles). Per-axis URL short keys with sorted addons CSV. Option cards call evaluateField to render disabled state with reason tooltips. Template preview composes fragments to mirror the CLI.

Test plan

  • packages/schema: typecheck clean, 13 tests pass
  • apps/cli: typecheck clean, 122 tests across 18 files pass (Phase 6 adds plugin-graph, preset, scaffold-fragments suites)
  • apps/web: typecheck clean (no test suite configured)
  • Pre-push hook ran the full repo test suite — green
  • Manual smoke: t-stack init --preset solo-cf-worker --storage none produces no docker-compose.yml
  • Manual smoke: t-stack init --storage r2 produces a MinIO docker-compose.yml
  • Manual smoke: t-stack init --cloud-provider none skips every cloudflare/doppler/github plugin step
  • Manual smoke: web preview reflects fragment composition (toggle storage in the form, see docker-compose.yml appear/disappear)

Summary by CodeRabbit

  • New Features

    • Added plugin-graph-based preset orchestration for improved step execution and parallelisation.
    • Introduced schema-driven field visibility and value validation for smarter conditional prompts.
    • Added fragment-based template composition for flexible feature layering.
    • Added Tigris object storage integration documentation.
  • Enhancements

    • Improved CLI help output with grouped command layout and better organisation.
    • Enhanced error messages with actionable hints and improved cancellation flow.
    • Preset system now includes configurable defaults and metadata for better user guidance.
  • Documentation

    • Added integration guide for Tigris storage backend.

Review Change Stack

Greptile Summary

This PR replaces the archetype-discriminated schema with a flat 19-axis model, adds a predicate engine for per-value compatibility rules, and restructures templates into composable fragments assembled at scaffold-time — fixing the headline bug where storage=none still produced a MinIO docker-compose.yml.

  • Schema & predicates (packages/schema): new z.object with 19 axes, FieldMeta.valueRules for dependency/incompatibility rules, and three new exports (evaluateField, isFieldVisible, validateDecisions) consumed by both the CLI and web app.
  • CLI (apps/cli): the init prompt loop re-evaluates field visibility on each iteration, preset bundles carry typed defaults that seed the loop, plugin invocations are now declarative PluginStep[] run via runPluginGraph/runParallel, and destroy teardowns are correctly gated on the new flat axes.
  • Templates & web (packages/templates, apps/web): per-axis fragments/<axis>/<value>/ directories replace the old {{#if hookdeck}}filename{{/if}} hacks; the web form reads evaluateField directly from the schema to render disabled states with reason tooltips; url.ts gains a full 19-axis serialiser but includes hookdeckApiKey in its key map, risking secret exposure in shareable links.

Confidence Score: 3/5

Safe to merge after removing hookdeckApiKey from the URL key map — everything else is well-tested and architecturally sound.

The core schema, predicate engine, plugin graph, and fragment-composition changes are solid and covered by 122 tests across 18 files. The one concrete defect is in apps/web/src/lib/stack-builder/url.ts: hookdeckApiKey is registered in both KEY_MAP and STRING_FIELDS, so a non-empty key value would be serialised into the shareable query string and exposed in browser history, server logs, and referrer headers. The fix is a two-line deletion. The secondary concern is ENUM_VALUES being a hand-maintained copy of the schema, creating a drift risk that silently drops unrecognised values from decoded URLs.

apps/web/src/lib/stack-builder/url.ts — remove hookdeckApiKey from KEY_MAP and STRING_FIELDS before merging.

Security Review

  • Secret in shareable URL (apps/web/src/lib/stack-builder/url.ts): hookdeckApiKey is registered in KEY_MAP (short key hk) and STRING_FIELDS. Any non-empty value for this secret API key will be serialised into the query string by encodeStack and decoded back by decodeStack, leaking through browser history, server access logs, and referrer headers. The field should be removed from both sets.
  • No injection, CSRF, or auth-boundary issues were identified in the CLI or schema layers. validateDecisions correctly catches cross-field conflicts before any cloud resources are touched.

Important Files Changed

Filename Overview
packages/schema/src/predicates.ts New predicate engine: isFieldVisible, evaluateField, validateDecisions. Logic is clean and well-tested; the intentional undecided-fields behaviour is clearly documented.
packages/schema/src/init.ts Replaced discriminated union with flat 19-axis z.object. walkFields re-evaluates visibility per iteration. Predicate-driven valueRules are well-structured. Removal of effectiveDatabase is clean.
apps/cli/src/commands/init.ts Predicate-aware prompt loop, preset seeding, and post-loop flag overrides are logically sound. Hardcoded solo-cf-worker in --yes mode is a fragile default; validateDecisions correctly catches cross-field conflicts after overrides are applied.
apps/cli/src/core/plugin-graph.ts New declarative plugin execution model. runPluginGraph (sequential with deps) and runParallel are cleanly separated. Primitive-unwrapping logic is subtle but well-commented and well-tested.
apps/web/src/lib/stack-builder/url.ts Major rework of URL serialisation for the new 19-axis model. Contains a security issue: hookdeckApiKey is present in KEY_MAP and STRING_FIELDS, so a non-empty secret value would be encoded into shareable URLs. Also carries a drift risk from hard-coded ENUM_VALUES.
apps/cli/src/commands/scaffold.ts Fragment composition via renderFragments cleanly replaces the old programmatic cp calls and Handlebars filename hacks. Boolean-to-string conversion and skip logic are correct.
apps/web/src/components/stack-builder/field-renderer.tsx Schema-driven rendering via useFieldAvailability correctly plumbs evaluateField and isFieldVisible from the schema package. Component decomposition is clean.
apps/cli/src/commands/destroy.ts Teardown steps correctly gated on the new flat axes (cloudProvider, iac, databaseHost, git) instead of the old effectiveDatabase.
apps/cli/src/core/schema-runtime.ts Predicate-aware resolver correctly filters disabled values from prompts and substitutes schema defaults in --yes mode. Multi-pass buildCittyArgs workaround is noted as technical debt for Phase 3.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[t-stack init] --> B{preset flag?}
    B -- explicit --> C[loadPreset by id]
    B -- yes-mode --> D[loadPreset solo-cf-worker]
    B -- interactive --> E[pickPreset prompt]
    E -- custom --> F[null preset]
    E -- chosen --> C
    C --> G[Seed values from preset.defaults]
    D --> G
    F --> H[Empty seed]
    G --> I[Mark preset keys resolved]
    H --> I
    I --> J[Prompt loop walkFields each iteration]
    J --> K{next unresolved visible field?}
    K -- yes --> L[readFlag or resolveField filter disabled values]
    L --> M[resolved.add field]
    M --> J
    K -- no --> N[Apply CLI flag overrides for preset fields]
    N --> O[initSchema.parse]
    O --> P[validateDecisions cross-field check]
    P -- violations --> Q[bail]
    P -- clean --> R[runInit]
    R --> S[runScaffold]
    R --> T[runProvision]
    S --> U[renderTemplate base]
    S --> V[renderTemplate preset overlay]
    S --> W[renderFragments per-axis]
    W --> X[storage/r2 docker-compose.yml]
    W --> Y[hookdeck/true infra/hookdeck tree]
    W --> Z[trigger/true trigger.config.ts]
Loading

Fix All in Claude Code Fix All in Codex

Reviews (1): Last reviewed commit: "feat: flat per-axis schema with BTS-styl..." | Re-trigger Greptile

Greptile also left 5 inline comments on this PR.

Reorder keys consistently across all 6 workspace package.json files
(identity → metadata → module shape → scripts → engines → deps),
alphabetize dependencies, and rename root t-stack-monorepo → t-stack
so bun stops auto-normalizing it on install.

Bump apps/web: shiki/@shikijs/* 1.29.1 → 4.1.0, lucide-react
0.474.0 → 1.16.0 (replace removed Github brand icon with inline SVG),
tailwind-merge 2.6.0 → 3.6.0.
The CLI's "dev" script (bun run src/cli.ts) just ran the entrypoint
once with no args — not useful for iterative work, and conflicts
with the turbo "dev" pipeline (which is persistent). Remove it.

Enable turbo's TUI for a clearer per-task view when running
multi-package tasks.
After removing apps/cli's "dev" script, predev no longer fires.
The four sub-packages had "build: echo 'no build...'" only to give
turbo a task to print — turbo skips packages without the script
just fine, so drop them along with their now-pointless per-package
overrides in turbo.json.
…s with scoped overrides

Drop the kitchen-sink "rules off" block at the root of biome.json
in favor of per-path overrides that only suppress rules where the
pattern is genuinely intentional. Also add the ultracite/biome/react
preset (missed earlier — apps/web is React) and switch lint scripts
from "bunx ultracite" to "ultracite" (it's already a devDep, so
node_modules/.bin is on PATH).

Scoped overrides:
- apps/cli/**: noNamespaceImport (facade-style imports of internal
  plugins), useTopLevelRegex (one-off parse regex), noVoid +
  useAwait (fire-and-forget step patterns), noExcessiveCognitiveComplexity.
- apps/cli/scripts/**: noEmptyBlockStatements (WIP diff-preview code).
- **/test/**, **/*.test.ts: useAwait, noEmptyBlockStatements,
  noEmptySource (test mocks and setup files are intentionally light).
- apps/web/src/components/ui/**: noNamespaceImport (shadcn pattern
  with @radix-ui/* Primitives).
- packages/*/src/index.ts: noBarrelFile (package public entrypoints
  are barrels by design).

Code changes that came out of the audit:
- Remove dead "void X" import-keepalives in categories.ts,
  in-memory.ts, init.test.ts and drop their now-unused imports.
- Strip dead filteredRunner / makeStepRunner re-exports from
  provision.ts (no callers anywhere).
- Refactor 2 nested ternaries (neon.ts list-shape detection,
  option-card.tsx state classes) into if/else.
- Collapse else-if in turso.ts.
- Hoist 3 web regexes (preview-panel.tsx, use-stack-builder.ts) to
  module scope.
- Convert 3 import-and-reexport sites to export-from (_ctx.ts,
  scaffold.ts, provision.ts).
- Biome --write applied De Morgan / arrow-return / negation-else
  cleanups across CLI commands, plugins, and tests.
- One inline biome-ignore for `void import("react-grab")` (genuine
  fire-and-forget dynamic import).
- Force meta.name to "t-stack" so help shows "t-stack init [OPTIONS]"
  instead of "@timothygithinji/t-stack init [OPTIONS]".
- On bare "t-stack" invocation, print usage plus a "Quick start"
  hint pointing at "t-stack init <project-name>". Guard against
  citty calling the top-level run after every subcommand by
  short-circuiting when the first argv is a known subcommand.
- Frame the init wizard with p.intro / p.outro so errors and the
  final "Ready · https://<domain>" land inside a closed clack frame
  instead of next to a stray "│" divider. Route both bail() and the
  top-level catch through p.cancel for a proper "└" close.
- In non-TTY spinner fallback, drop the redundant "▶ msg" on start
  and print the stop message verbatim. The previous fallback emitted
  two lines per check and (with doctor's own "✓ msg" formatting)
  produced "✓ ✓ msg" duplication. Doctor's output is now one line
  per check, no doubled glyphs.
Mirror the init wizard polish across the remaining commands so each
run opens with "┌ t-stack <name> · <context>" and closes with either
"└ <summary>" (success) or "└ <error>" (failure) instead of orphan │
dividers and stray log.error lines.

Covers: deploy, destroy, doctor, login (already had intro/outro,
just routed errors through p.cancel), provision, scaffold, secrets
sync/pull, and every org subcommand (add, list, show, remove, zone
add/discover/list/remove, trigger list/discover/set).

provision.ts pulls in @clack/prompts for the wrapper; runProvision
itself still emits its detailed error via ctx.logger so the in-frame
diagnostic survives, with p.cancel only closing the frame.
Provide a custom showUsage to citty's runMain that, for the top-level
command only, renders subcommands in four labelled groups instead of
the flat declaration-order list:

  Create   init, scaffold, provision
  Operate  deploy, secrets, destroy
  Inspect  doctor
  Setup    login, org

Subcommand --help (e.g. `t-stack init --help`, `t-stack org --help`)
falls through to citty's default rendering since it already handles
ARGUMENTS / OPTIONS sections well.

The hint after the bare-`t-stack` invocation switches to the same
cyan helper for consistency.
…iew step

Three follow-ups to the framing pass.

Error remediation hints
- deploy/destroy/secrets sync/secrets pull/init now print a "Hint: ..."
  line via p.log.info inside the frame, just before p.cancel closes
  it. Most point at t-stack doctor (the natural next step for token
  or state issues); destroy also surfaces the --force escape hatch.
- Validation errors (e.g. invalid --target) stay hint-free since the
  user just made the mistake — the message itself is enough.
- provision keeps using its own internal resume hint from runProvision.

--yes flag on destructive org subcommands
- org remove and org zone remove now accept --yes to skip the
  confirmation prompt, matching destroy's escape hatch so the
  subcommands are scriptable for CI / cleanup automation. Without
  --yes the existing initialValue=false confirm still fires.

init prompt review
- "Which org?" → "Which org owns this project?"; "Archetype?" → "Pick
  an archetype"; archetype options carry short descriptions so the
  difference between solo-cf-worker and monorepo-cf is visible without
  reading the README.
- Before scaffolding (interactive only), render a p.note "Review"
  block listing the resolved decisions (project, archetype, org,
  domain, database, envs, add-ons) and require a final confirm. Catches
  cases where the user wanted to back out after a long prompt chain
  without having any files on disk yet.
Replace the archetype-discriminated union with a flat 19-axis schema, add a
predicate engine for value-level disable rules, restructure templates to
composable fragments, and gate plugin invocations on per-axis predicates so
choosing storage=none no longer scaffolds a MinIO docker-compose.

Schema (packages/schema): single z.object over structure, cloudProvider, iac,
runtime, frontend, backend, docs, api, database, databaseHost, orm, auth,
storage, payments, addons, packageManager, git, install, plus retained legacy
fields. FieldMeta gains valueRules (dependencies/incompatibilities + reason)
mirroring better-t-stack. New predicates.ts: evaluateField, isFieldVisible,
validateDecisions.

CLI (apps/cli): init walks walkFields(values) per iteration with disabled
values filtered + logged; --yes substitutes the first enabled value when the
schema default is disabled. Per-axis CLI flags via buildCittyArgs. Presets
become bundles with {id, name, defaults, templates, run}; --preset preloads
defaults that CLI flags can override. State tracks presetId. Plugin
invocations become declarative PluginStep[] with activate(decisions)
predicates, run sequentially via runPluginGraph or in parallel via
runParallel. Destroy gates teardown on the same predicates.

Templates (packages/templates/files): new fragments/<axis>/<value>/ tree
composed at scaffold-time. MinIO docker-compose moves to
fragments/storage/r2/, hookdeck infra + SDK to fragments/hookdeck/true/,
trigger config to fragments/trigger/true/. The {{#if hookdeck}} filename
hacks and _assets/hookdeck-sdk programmatic copy are gone.

Web (apps/web): DraftStack = InitDecisions, 9 categories grouping the new
axes (project, structure, infra, app, data, features, addons, tooling,
toggles), per-axis URL short keys with sorted addons CSV. Option cards call
evaluateField to render disabled state with reason tooltips. Template
preview composes fragments to mirror the CLI.

Tests: schema 13, CLI 122 across 18 files, web typecheck clean.
@timothygithinji
timothygithinji merged commit 81243a5 into main May 21, 2026
1 of 2 checks passed
@timothygithinji
timothygithinji deleted the improve-cli-ui branch May 21, 2026 17:03
@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f741b409-734f-4fb5-89d9-7eb8de0306a0

📥 Commits

Reviewing files that changed from the base of the PR and between f180dfa and 4faaf1a.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (97)
  • apps/cli/package.json
  • apps/cli/presets/monorepo-cf.ts
  • apps/cli/presets/solo-cf-worker.ts
  • apps/cli/src/cli.ts
  • apps/cli/src/commands/_ctx.ts
  • apps/cli/src/commands/deploy.ts
  • apps/cli/src/commands/destroy.ts
  • apps/cli/src/commands/doctor.ts
  • apps/cli/src/commands/init.ts
  • apps/cli/src/commands/login.ts
  • apps/cli/src/commands/org.ts
  • apps/cli/src/commands/provision.ts
  • apps/cli/src/commands/scaffold.ts
  • apps/cli/src/commands/secrets.ts
  • apps/cli/src/core/log.ts
  • apps/cli/src/core/plugin-graph.ts
  • apps/cli/src/core/preset.ts
  • apps/cli/src/core/schema-runtime.ts
  • apps/cli/src/core/state.ts
  • apps/cli/src/core/zones.ts
  • apps/cli/src/plugins/cloudflare.ts
  • apps/cli/src/plugins/doppler.ts
  • apps/cli/src/plugins/github.ts
  • apps/cli/src/plugins/neon.ts
  • apps/cli/src/plugins/turso.ts
  • apps/cli/test/_helpers.ts
  • apps/cli/test/commands/preset.test.ts
  • apps/cli/test/commands/scaffold-fragments.test.ts
  • apps/cli/test/core/plugin-graph.test.ts
  • apps/cli/test/core/schema-runtime.test.ts
  • apps/cli/test/plugins/doppler.test.ts
  • apps/cli/test/plugins/github.test.ts
  • apps/cli/test/plugins/neon.test.ts
  • apps/cli/test/plugins/turso.test.ts
  • apps/web/package.json
  • apps/web/src/components/stack-builder/code-viewer.tsx
  • apps/web/src/components/stack-builder/field-renderer.tsx
  • apps/web/src/components/stack-builder/option-card.tsx
  • apps/web/src/components/stack-builder/preview-panel.tsx
  • apps/web/src/lib/stack-builder/categories.ts
  • apps/web/src/lib/stack-builder/command.ts
  • apps/web/src/lib/stack-builder/presets.ts
  • apps/web/src/lib/stack-builder/templates.ts
  • apps/web/src/lib/stack-builder/types.ts
  • apps/web/src/lib/stack-builder/url.ts
  • apps/web/src/lib/stack-builder/use-stack-builder.ts
  • apps/web/src/routes/__root.tsx
  • apps/web/src/routes/new.tsx
  • biome.json
  • package.json
  • packages/presets/package.json
  • packages/schema/package.json
  • packages/schema/src/index.ts
  • packages/schema/src/init.ts
  • packages/schema/src/meta.ts
  • packages/schema/src/predicates.ts
  • packages/schema/test/init.test.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/Pulumi.production.yaml
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/Pulumi.yaml
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/package.json
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/.gitattributes
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/.gitignore
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/README.md
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/config/index.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/config/vars.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/connection.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/destination.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/getConnection.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/getDestination.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/getSource.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/index.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/package.json
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/provider.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/scripts/postinstall.js
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/source.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/sourceAuth.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/transformation.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/tsconfig.json
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/types/index.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/types/input.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/types/output.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/utilities.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/sdks/hookdeck/webhookRegistration.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/src/index.ts
  • packages/templates/files/fragments/hookdeck/true/infra/hookdeck/tsconfig.json
  • packages/templates/files/fragments/storage/r2/docker-compose.yml
  • packages/templates/files/fragments/storage/tigris/README.md
  • packages/templates/files/fragments/trigger/true/trigger.config.ts
  • packages/templates/files/monorepo-cf/infra/hookdeck/src/{{#if hookdeck}}index.ts{{/if}}
  • packages/templates/files/monorepo-cf/infra/hookdeck/{{#if hookdeck}}Pulumi.production.yaml{{/if}}
  • packages/templates/files/monorepo-cf/infra/hookdeck/{{#if hookdeck}}Pulumi.yaml{{/if}}
  • packages/templates/files/monorepo-cf/infra/hookdeck/{{#if hookdeck}}package.json{{/if}}
  • packages/templates/files/monorepo-cf/infra/hookdeck/{{#if hookdeck}}tsconfig.json{{/if}}
  • packages/templates/package.json
  • packages/templating/package.json
  • packages/templating/src/in-memory.ts
  • turbo.json

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

This PR executes a fundamental architectural migration from an archetype-discriminated schema to a preset-based framework with plugin graph orchestration. The CLI now resolves presets explicitly, discovers them by scanning the filesystem, and uses a unified schema with field-level metadata to drive visibility, validation, and availability. The web UI reorganises categories to reflect the flattened schema and generates commands dynamically from defaults. All commands adopt clack-based lifecycle messaging (intro/outro/cancel) for consistent UX.

Changes

Foundation & Schema System

Layer / File(s) Summary
Plugin Graph Execution System
apps/cli/src/core/plugin-graph.ts, apps/cli/test/core/plugin-graph.test.ts
Introduces PluginStep interface with activation predicates and async run methods. Implements runPluginGraph for sequential execution with dependency threading and runParallel for concurrent execution, both tracking outputs keyed by step id.
Preset Metadata and Resolution
apps/cli/src/core/preset.ts, apps/cli/src/commands/_ctx.ts, apps/cli/test/commands/preset.test.ts
Presets now carry name and defaults: Partial<InitDecisions>. resolvePresetId selects id from explicit arg, persisted state, or structure-derived fallback. loadPreset scans filesystem and multiple candidate paths with improved error reporting. buildCtx resolves and loads preset when missing.
Field Metadata and Predicate System
packages/schema/src/meta.ts, packages/schema/src/predicates.ts
FieldMeta expands with ui: multiselect and valueRules declaring per-value dependencies and incompatibilities. isFieldVisible and evaluateField compute field visibility and per-value availability. validateDecisions cross-validates fully-populated decisions against value rules.
Init Schema and Field Walking
packages/schema/src/init.ts, packages/schema/test/init.test.ts
Schema migrates from archetype-discriminated union to single flat z.object with all decision fields. walkFields(decisions) iterates visible fields in declaration order, filtering by visibleIf predicates. Exports validatDecisions for conflict detection.
Schema-Driven CLI and Defaults
apps/cli/src/core/schema-runtime.ts, apps/cli/test/core/schema-runtime.test.ts
buildCittyArgs generates preset flag and schema-driven field flags via walkFields. defaultResolver computes enum availability via evaluateField, substitutes disabled defaults in non-interactive mode, and filters disabled options in prompts with reason logging.
Preset Implementations
apps/cli/presets/monorepo-cf.ts, apps/cli/presets/solo-cf-worker.ts
Both presets refactored to plugin graph. Define name and defaults spanning structure, cloud provider, IaC, runtime, frontend, backend, database/host, ORM, auth, storage, payments, addons, package manager, and git/install toggles. Orchestrate provision → secrets → finalize via runPluginGraph and runParallel with conditional database creation (Neon or Turso) and dependency-aware outputs.
State and Context Integration
apps/cli/src/core/state.ts, apps/cli/src/commands/_ctx.ts
StateProject uses presetId instead of archetype. buildCtx resolves and loads preset when missing, threading it through init, scaffold, and provision steps.

CLI Commands & UX

Layer / File(s) Summary
Core CLI Infrastructure
apps/cli/src/cli.ts, apps/cli/src/core/log.ts
Grouped top-level help rendering with custom showUsage handler. Main command suppresses hint on subcommand. Spinner logging simplified for non-TTY contexts (no glyph prefixes).
Init Command
apps/cli/src/commands/init.ts
Resolves preset up-front, seeds prompt state from preset defaults, walks visible fields via walkFields, parses decisions with initSchema, runs validateDecisions, and shows grouped review summary. Threads preset through scaffold/provision/GitHub steps. readFlag parses CSV multiselect values into trimmed arrays.
Scaffold Command
apps/cli/src/commands/scaffold.ts, apps/cli/test/commands/scaffold-fragments.test.ts
Accepts preset or presetId, resolves via resolvePresetId, validates template directory. deriveVars maps databaseHost to legacy vars and exposes full decisions as d. Renders base, preset overlay, then axis-conditional fragments (including multi-valued addons). Sets project.presetId in state. CLI arg changed from archetype to preset.
Provision Command
apps/cli/src/commands/provision.ts
ProvisionOptions accepts optional presetId. Removes exported filteredRunner and makeStepRunner. Command wraps execution with intro/outro and uses p.cancel for failures.
Deploy, Destroy, Doctor Commands
apps/cli/src/commands/deploy.ts, apps/cli/src/commands/destroy.ts, apps/cli/src/commands/doctor.ts
All compute context up-front, add intro/outro, validate via p.cancel. Destroy gated on explicit cloudProvider/databaseHost/iac fields instead of effectiveDatabase. Failures reported via info hint + p.cancel instead of error logs.
Login, Org, Secrets Commands
apps/cli/src/commands/login.ts, apps/cli/src/commands/org.ts, apps/cli/src/commands/secrets.ts
Login expands decisions with explicit preset-like fields and loads solo-cf-worker preset. Org commands add --yes flag support, intro/outro, and p.cancel for failures. Secrets commands extract env/cwd, add intro/outro, report errors via hint + p.cancel.
Plugin Implementations and Tests
apps/cli/src/plugins/*.ts, apps/cli/test/plugins/*.test.ts, apps/cli/test/_helpers.ts
Plugins updated with early returns and improved error handling. Test helpers include defaultPreset stub and expanded defaultDecisions. Mock setups refactored with explicit vi.hoisted typing.

Web UI Alignment

Layer / File(s) Summary
Type Definitions and Defaults
apps/web/src/lib/stack-builder/types.ts
DraftStack now aliases InitDecisions directly. DEFAULT_STACK expanded to full schema defaults. asInitDecisions simplified to identity function.
Categories and Command Generation
apps/web/src/lib/stack-builder/categories.ts, apps/web/src/lib/stack-builder/command.ts
CategoryKey expanded to structure/infra/app/data/features/addons/tooling/toggles. CategoryDef supports multiselect and grouped variants. CATEGORIES rebuilt with new sections. generateCommand loops over DEFAULT_STACK keys with special handling for org, addons, and booleans.
Preset Selection and Template Rendering
apps/web/src/lib/stack-builder/presets.ts, apps/web/src/lib/stack-builder/templates.ts
STACK_PRESETS expanded with explicit field sets. randomStackPatch rewritten to choose structure then database pairing. Templates derive preset id and database slug from stack. buildVars exposes archetype, database, neon/turso booleans, and full stack as d. Renders fragments with stricter skip rules.
URL Encoding and Stack Builder
apps/web/src/lib/stack-builder/url.ts, apps/web/src/lib/stack-builder/use-stack-builder.ts
encodeStack/decodeStack use KEY_MAP with short keys and per-field allow-lists. Addons sorted and encoded as CSV. Archetype-specific logic removed. setStack simplified to direct patch merge. LEADING_QUESTION_MARK helper added.
Field and Option Rendering
apps/web/src/components/stack-builder/field-renderer.tsx, apps/web/src/components/stack-builder/option-card.tsx, apps/web/src/components/stack-builder/preview-panel.tsx, apps/web/src/components/stack-builder/code-viewer.tsx
FieldRenderer uses schema-driven visibility and useFieldAvailability hook. Delegates rendering to SingleSelectGrid, MultiSelectGrid, GroupedSubSection. OptionCard refactored to stateClasses variable. Hard-coded guards replaced with evaluateField-based disabling. Preview panel uses precompiled regexes. CodeViewer label logic fixed.
Routes
apps/web/src/routes/__root.tsx, apps/web/src/routes/new.tsx
Root route updates meta description and adds biome-ignore for react-grab. New route inlines GitHub SVG icon component.

Configuration & Cleanup

Layer / File(s) Summary
Package Manifests
apps/cli/package.json, apps/web/package.json, package.json, packages/*/package.json
CLI dependencies reordered, scripts reordered (pretest moved, predev removed). Web updates shiki and tailwind-merge versions. Root changes name to t-stack and updates lint scripts. Build script entries removed from source-only packages.
Linting and Build Config
biome.json, turbo.json
biome.json extends React preset, updates exclude patterns, and uses path-scoped linter rule overrides. turbo.json adds ui: tui setting and removes @t-stack#build tasks.
Template Fragments and Documentation
packages/templates/files/fragments/storage/tigris/README.md, packages/templating/src/in-memory.ts
Tigris storage fragment README added. Pathe imports cleaned up.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch improve-cli-ui

Comment on lines 16 to 40
domain: "d",
structure: "st",
cloudProvider: "cp",
iac: "iac",
runtime: "rt",
frontend: "fe",
backend: "be",
docs: "dc",
api: "api",
database: "db",
databaseHost: "dh",
orm: "orm",
auth: "au",
storage: "sto",
payments: "pay",
addons: "ad",
packageManager: "pm",
git: "g",
install: "i",
envs: "e",
trigger: "t",
access: "ac",
hookdeck: "h",
hookdeckApiKey: "hk",
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Secret field serialised into shareable URL

hookdeckApiKey is registered in KEY_MAP (short key "hk") and in STRING_FIELDS. encodeStack will write any non-empty, non-undefined value straight into the query string, and decodeStack will read it back. If a URL containing ?hk=<real-key> is ever constructed (e.g. round-tripped from a manually crafted URL), the secret will appear in browser history, server access logs, and analytics tooling that captures referrer URLs. The field is marked meta.secret = true for exactly this reason — it should not live in a URL. Remove hookdeckApiKey from KEY_MAP and STRING_FIELDS entirely; command.ts already excludes it via SKIP_IN_LOOP.

Fix in Claude Code Fix in Codex

Comment on lines 13 to 40
const KEY_MAP: Record<keyof DraftStack, string> = {
archetype: "a",
projectName: "n",
org: "o",
domain: "d",
structure: "st",
cloudProvider: "cp",
iac: "iac",
runtime: "rt",
frontend: "fe",
backend: "be",
docs: "dc",
api: "api",
database: "db",
databaseHost: "dh",
orm: "orm",
auth: "au",
storage: "sto",
payments: "pay",
addons: "ad",
packageManager: "pm",
git: "g",
install: "i",
envs: "e",
trigger: "t",
access: "ac",
hookdeck: "h",
hookdeckApiKey: "hk",
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security The hookdeckApiKey entry must be removed from KEY_MAP to prevent the secret from ever appearing in a shareable URL.

Suggested change
const KEY_MAP: Record<keyof DraftStack, string> = {
archetype: "a",
projectName: "n",
org: "o",
domain: "d",
structure: "st",
cloudProvider: "cp",
iac: "iac",
runtime: "rt",
frontend: "fe",
backend: "be",
docs: "dc",
api: "api",
database: "db",
databaseHost: "dh",
orm: "orm",
auth: "au",
storage: "sto",
payments: "pay",
addons: "ad",
packageManager: "pm",
git: "g",
install: "i",
envs: "e",
trigger: "t",
access: "ac",
hookdeck: "h",
hookdeckApiKey: "hk",
};
// hookdeckApiKey intentionally omitted: it is a secret and must not appear
// in a shareable URL. command.ts already gates it via SKIP_IN_LOOP.
const KEY_MAP: Omit<Record<keyof DraftStack, string>, "hookdeckApiKey"> = {
projectName: "n",
org: "o",
domain: "d",
structure: "st",
cloudProvider: "cp",
iac: "iac",
runtime: "rt",
frontend: "fe",
backend: "be",
docs: "dc",
api: "api",
database: "db",
databaseHost: "dh",
orm: "orm",
auth: "au",
storage: "sto",
payments: "pay",
addons: "ad",
packageManager: "pm",
git: "g",
install: "i",
envs: "e",
trigger: "t",
access: "ac",
hookdeck: "h",
};

Fix in Claude Code Fix in Codex

Comment on lines +83 to +88
const STRING_FIELDS = new Set<keyof DraftStack>([
"projectName",
"org",
"domain",
"hookdeckApiKey",
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security hookdeckApiKey should also be removed from STRING_FIELDS so encodeStack's string-field branch can never emit the key even if the KEY_MAP type is later relaxed.

Suggested change
const STRING_FIELDS = new Set<keyof DraftStack>([
"projectName",
"org",
"domain",
"hookdeckApiKey",
]);
const STRING_FIELDS = new Set<keyof DraftStack>([
"projectName",
"org",
"domain",
]);

Fix in Claude Code Fix in Codex

Comment on lines +75 to 100
const BOOLEAN_FIELDS = new Set<keyof DraftStack>([
"git",
"install",
"trigger",
"access",
"hookdeck",
]);

const STRING_FIELDS = new Set<keyof DraftStack>([
"projectName",
"org",
"domain",
"hookdeckApiKey",
]);

function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
if (a.length !== b.length) {
return false;
}
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((v, i) => v === sortedB[i]);
}

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-field encoding requires branching by kind (array | bool | string | enum).
export function encodeStack(stack: DraftStack): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Enum allow-list duplicated from schema — drift risk

ENUM_VALUES hard-codes the allowed values for every enum axis. Adding a new value to the schema (e.g. a new storage provider) will cause the URL decoder to silently reject it and fall back to the previous default, so users sharing a URL with the new value land on a wrong configuration. Consider exporting an enumChoicesForField helper from the schema package and calling it at module load time, or wiring a build-time assertion that compares both sets.

Fix in Claude Code Fix in Codex

Comment on lines +379 to +381
}
}
await runInit(decisions, { cwd, yes, preset: preset ?? undefined });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 --yes mode always falls back to solo-cf-worker

pickPreset hard-codes solo-cf-worker when args.yes is true and no --preset flag is supplied. If that preset file is missing or renamed, every automated t-stack init --yes call will throw a confusing "Preset not found" error with no indication that --preset is required. Consider selecting the first available preset via listPresetIds and logging a warning instead.

Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant