Flat per-axis schema + fragment templates - #4
Conversation
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.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (97)
Disabled knowledge base sources:
WalkthroughThis 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. ChangesFoundation & Schema System
CLI Commands & UX
Web UI Alignment
Configuration & Cleanup
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
| 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", | ||
| }; |
There was a problem hiding this comment.
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.
| 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", | ||
| }; |
There was a problem hiding this comment.
The
hookdeckApiKey entry must be removed from KEY_MAP to prevent the secret from ever appearing in a shareable URL.
| 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", | |
| }; |
| const STRING_FIELDS = new Set<keyof DraftStack>([ | ||
| "projectName", | ||
| "org", | ||
| "domain", | ||
| "hookdeckApiKey", | ||
| ]); |
There was a problem hiding this comment.
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.
| const STRING_FIELDS = new Set<keyof DraftStack>([ | |
| "projectName", | |
| "org", | |
| "domain", | |
| "hookdeckApiKey", | |
| ]); | |
| const STRING_FIELDS = new Set<keyof DraftStack>([ | |
| "projectName", | |
| "org", | |
| "domain", | |
| ]); |
| 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 { |
There was a problem hiding this comment.
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.
| } | ||
| } | ||
| await runInit(decisions, { cwd, yes, preset: preset ?? undefined }); |
There was a problem hiding this comment.
--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.
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=noneno longer scaffolds a MinIOdocker-compose.yml.The work was done in six reviewed phases against
packages/schema,apps/cli,packages/templates, andapps/web.What changed
Schema (
packages/schema) — Singlez.objectwithstructure | cloudProvider | iac | runtime | frontend | backend | docs | api | database | databaseHost | orm | auth | storage | payments | addons | packageManager | git | installplus the retained legacy fields.FieldMetagainsvalueRules(per-valuedependencies+incompatibilities+reason) for BTS-style compat. Newpredicates.tsexportsevaluateField,isFieldVisible,validateDecisions.CLI (
apps/cli) — Init walks the flat schema, re-evaluating visibility every iteration; disabled values are filtered + logged before the prompt.--yessubstitutes 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 storespresetId. Plugin invocations are now declarativePluginStep[]withactivate(decisions)predicates, run viarunPluginGraph/runParallel. Destroy gates teardown on the same predicates.Templates (
packages/templates/files) — Newfragments/<axis>/<value>/tree composed at scaffold-time. MinIO moves tofragments/storage/r2/, hookdeck infra + SDK tofragments/hookdeck/true/, trigger config tofragments/trigger/true/. The{{#if hookdeck}}filename{{/if}}hacks and the_assets/hookdeck-sdkprogrammatic 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 callevaluateFieldto render disabled state with reason tooltips. Template preview composes fragments to mirror the CLI.Test plan
packages/schema: typecheck clean, 13 tests passapps/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)t-stack init --preset solo-cf-worker --storage noneproduces nodocker-compose.ymlt-stack init --storage r2produces a MinIOdocker-compose.ymlt-stack init --cloud-provider noneskips every cloudflare/doppler/github plugin stepdocker-compose.ymlappear/disappear)Summary by CodeRabbit
New Features
Enhancements
Documentation
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=nonestill produced a MinIOdocker-compose.yml.packages/schema): newz.objectwith 19 axes,FieldMeta.valueRulesfor dependency/incompatibility rules, and three new exports (evaluateField,isFieldVisible,validateDecisions) consumed by both the CLI and web app.apps/cli): theinitprompt loop re-evaluates field visibility on each iteration, preset bundles carry typeddefaultsthat seed the loop, plugin invocations are now declarativePluginStep[]run viarunPluginGraph/runParallel, anddestroyteardowns are correctly gated on the new flat axes.packages/templates,apps/web): per-axisfragments/<axis>/<value>/directories replace the old{{#if hookdeck}}filename{{/if}}hacks; the web form readsevaluateFielddirectly from the schema to render disabled states with reason tooltips;url.tsgains a full 19-axis serialiser but includeshookdeckApiKeyin 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
apps/web/src/lib/stack-builder/url.ts):hookdeckApiKeyis registered inKEY_MAP(short keyhk) andSTRING_FIELDS. Any non-empty value for this secret API key will be serialised into the query string byencodeStackand decoded back bydecodeStack, leaking through browser history, server access logs, and referrer headers. The field should be removed from both sets.validateDecisionscorrectly catches cross-field conflicts before any cloud resources are touched.Important Files Changed
isFieldVisible,evaluateField,validateDecisions. Logic is clean and well-tested; the intentional undecided-fields behaviour is clearly documented.z.object.walkFieldsre-evaluates visibility per iteration. Predicate-drivenvalueRulesare well-structured. Removal ofeffectiveDatabaseis clean.solo-cf-workerin--yesmode is a fragile default;validateDecisionscorrectly catches cross-field conflicts after overrides are applied.runPluginGraph(sequential with deps) andrunParallelare cleanly separated. Primitive-unwrapping logic is subtle but well-commented and well-tested.hookdeckApiKeyis present inKEY_MAPandSTRING_FIELDS, so a non-empty secret value would be encoded into shareable URLs. Also carries a drift risk from hard-codedENUM_VALUES.renderFragmentscleanly replaces the old programmaticcpcalls and Handlebars filename hacks. Boolean-to-string conversion and skip logic are correct.useFieldAvailabilitycorrectly plumbsevaluateFieldandisFieldVisiblefrom the schema package. Component decomposition is clean.cloudProvider,iac,databaseHost,git) instead of the oldeffectiveDatabase.--yesmode. Multi-passbuildCittyArgsworkaround 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]Reviews (1): Last reviewed commit: "feat: flat per-axis schema with BTS-styl..." | Re-trigger Greptile