Status: Draft · Last updated: 2026-06-01 · Owner: Robertino Martinez
This is the canonical architecture document. It supersedes the legacy root-level
REQUIREMENTS.mdandARCHITECTURE.md(now deleted; available in git history). Read PRD.md for the why and for whom; this document owns the how. Detailed contracts, data shapes, and edge cases live in TECH_SPEC.md; sequencing lives in ROADMAP.md.
Five principles drive every structural decision in the codebase. When a tradeoff arises, these are the tie-breakers.
-
The interface contract is the core abstraction. Every tool template conforms to a shared set of conventions (canonical blueprint path, standard Justfile tasks, standard
.envvariables). Because each template independently conforms, any producer composes with any consumer without per-pair integration code. Composition is generic over the set of roles present, never over which tools fill them. This is what makes the system scale as O(tools) rather than O(tools²). -
Tools are data-driven; roles are a fixed code vocabulary. Tools and templates are declarative data embedded at compile time: adding a tool is a data change (a TOML file + a template directory + a recompile), never a change to CLI logic. Roles, by contrast, are a small fixed vocabulary defined in code (the
Roleenum, §3.1): the registry references roles but cannot introduce them. The set is not frozen at a particular number (it can grow) but growing it is a deliberate, rare code change, not a data change. -
Pure core, impure edges.
registry/,scaffold/,contract, and the pure part ofdoctor/are pure logic over data with zero dependency oncli/. All user interaction, terminal formatting, network, and system probing live at the edges (cli/,web/, the impure half ofdoctor/). This keeps the core testable and makes future extraction (e.g. WASM) straightforward. -
Deterministic generation. Identical inputs produce byte-identical output. This is a hard requirement for coding-agent trust, reproducibility, and snapshot tests. Determinism is guaranteed at the planning phase (§6.4).
-
Offline and self-contained. The registry and all templates are embedded in the binary; generation makes no network calls. Network is used only for installing toolchains (the doctor) and a best-effort version-update notice (§9). The binary, for a given version, is the single source of truth for what it generates.
A single Rust crate. The boundary between "library logic" and "CLI concerns" is enforced by the module dependency graph (§2.2), not by separate crates. Items marked (planned) are introduced by the PRD and not yet implemented.
cardano-init/
├── Cargo.toml
├── src/
│ ├── main.rs # Entry point: delegates to cli::run()
│ │
│ ├── cli/ # Impure edge: user interaction, formatting, process control
│ │ ├── mod.rs # Arg parsing (clap), dispatch, top-level error type
│ │ ├── interactive.rs # Guided interactive flow (dialoguer)
│ │ ├── oneshot.rs # Flag → Selection, validation, machine-readable errors
│ │ ├── output.rs # Presenter: renders results/errors as human text or JSON
│ │ └── update.rs # (planned) cached, pre-generation, fail-silent update check (§9)
│ │
│ ├── registry/ # Pure: tool + role definitions from embedded TOML
│ │ ├── mod.rs
│ │ ├── types.rs # Role, ToolDef, RoleConfig, Selection, Network, …
│ │ └── loader.rs # rust-embed → Registry (indexed by id and by role)
│ │
│ ├── scaffold/ # Pure: project generation pipeline
│ │ ├── mod.rs # Orchestrator (scaffold / dry_run) + embedded templates
│ │ ├── context.rs # Phase 1: Selection + Registry → TemplateContext
│ │ ├── planner.rs # Phase 2: → FilePlan (canonical order; dry-run stops here)
│ │ ├── renderer.rs # Phase 3: MiniJinja render / pass-through
│ │ └── writer.rs # Phase 4: the only phase with disk side effects
│ │
│ ├── doctor/ # (planned) dependency detection + install advice (§8)
│ │ ├── mod.rs # Pure: (deps, environment) → missing + advice
│ │ ├── catalog.rs # Pure: dep id → per-platform check/install knowledge
│ │ └── probe.rs # Impure: detect OS, package managers, PATH
│ │
│ ├── web/ # Impure edge: local web builder server
│ │ ├── mod.rs # Hand-rolled HTTP server; /, /api/registry, /api/plan
│ │ └── ui.html # Embedded single-page UI
│ │
│ └── contract.rs # Interface-contract constants (paths, env vars, dirs)
│
├── registry/tools/ # Embedded data: one TOML per tool
│ ├── aiken.toml meshjs.toml scalus.toml blaster.toml
│
└── templates/ # Embedded data: tool/role template trees
├── _base/ (Justfile.jinja, README.md.jinja, gitignore, env.jinja)
├── _nix/ (flake.nix.jinja)
└── <tool>/<role>/ (manifest.toml + template files)
Assets are embedded with rust-embed via #[folder = "registry/"] and #[folder = "templates/"]. There is no build.rs: embedding is handled by the derive macro directly. (The legacy architecture doc referenced a build.rs asset manifest; that is obsolete.)
The graph flows strictly downward; there are no cycles. The key invariant: registry, scaffold, contract, and the pure part of doctor never depend on cli or web.
main.rs
│
├── cli/ ──────┬─▶ scaffold/ ─▶ registry/ web/ ─┬─▶ scaffold::planner
│ ├─▶ doctor/ ─▶ registry/ └─▶ registry/
│ ├─▶ registry/ (web is an edge, like cli)
│ └─▶ contract
│
scaffold/, doctor/(pure), registry/ ──▶ contract
cli/ and web/ are sibling edges: both orchestrate the pure core and present results. Neither is depended upon by the core.
All core types live in registry/types.rs (and scaffold/ for pipeline-internal types). Exact field-level definitions and invariants are in TECH_SPEC; this is the shape and intent.
pub enum Role { OnChain, OffChain, Infrastructure, Devnet, FormalMethods }Role::ALLdefines the canonical order used for deterministic output.- Each role maps to a kebab string (
on-chain,formal-methods, …) for TOML/flags, aDisplayname for humans, and a contract directory (dir()→ §4). - The enum is the sole source of truth for the role vocabulary: roles are not defined by the repository data. A tool's
[roles.<kebab>]blocks merely reference existing roles; the registry cannot introduce a new one. Role strings are validated against the enum at load time viaRole::from_kebab(an unknown role →RegistryError::UnknownRole). What the registry data determines is which tools exist and which of these fixed roles each can fill, not the set of roles itself. - Adding a role is therefore a deliberate code change touching every site that names roles: a new
Rolevariant +Role::ALL+from_kebab/as_kebab/dir()/Display, acontract::DIR_*constant,TemplateContexthandling, a CLI flag, and the web query params. Adding a tool, by contrast, is pure data. The role set is small and grows rarely. - The fullstack
protocol/component (§3.2) is a related but distinct kind of code change: it adds acontract::DIR_PROTOCOLconstant andTemplateContexthandling, but noRolevariant — it is a fused component derived from two existing roles, not a sixth role. New fullstack tools remain pure data (a[fullstack]table + a template dir).
pub struct ToolDef {
pub id, name, description, website: String,
pub languages: Vec<String>,
pub nix_packages: Vec<String>, // toolchains for the Nix dev shell
pub roles: HashMap<Role, RoleConfig>, // which roles this tool can fill
pub fullstack: Option<RoleConfig>, // unified on-chain+off-chain template (opt-in)
}
pub struct RoleConfig { pub template: String } // path under templates/system_deps is declared in the tool TOML and consumed by the doctor (§8). One tool can fill multiple roles (e.g. Scalus: on-chain + off-chain), each with its own template path.
Fullstack tools. A tool that fills both on-chain and off-chain may also declare a [fullstack] template. When the same tool fills both roles, the two collapse into one fused protocol/ component (built from fullstack.template) instead of two folders — the value of a same-language stack (shared types, one build, no plutus.json round-trip between the halves). This is a tool capability, not a new role: protocol has no Role variant and Role::ALL stays at five; the collapse is derived from the selection at planning time, mirroring the infrastructure aggregation (§6.2). The protocol/ component still conforms to the interface contract — its build writes the blueprint and it reads/writes .env — so it composes with every other role like a normal on-chain producer (§4).
pub struct Selection {
pub project_name: String,
pub assignments: Vec<RoleAssignment>, // Infrastructure may appear multiple times
pub network: Network, // Preview | Preprod | Mainnet (default Preview)
pub nix: bool,
}
pub struct RoleAssignment { pub role: Role, pub tool_id: String }Constraint enforcement is by construction. Role uniqueness (one tool per role, except Infrastructure) is enforced at the edge: interactive mode only allows one tool per non-infra role; one-shot uses single-value flags per role (--infra is repeatable). A Selection that exists is valid: there is no separate validation module.
pub struct TemplateContext { … } // per-role flags + RoleContexts + contract constants
pub struct RoleContext { tool_id, tool_name, language, dir }
pub struct FilePlan { entries: Vec<FileEntry> }
pub struct FileEntry { dest: PathBuf, source: TemplateSource, render: bool }
pub enum TemplateSource { Base(String), Role(String), Optional(String), Inline(Vec<u8>) }TemplateContext is Serialize and is the entire surface templates can see. It carries has_* booleans per role, an Option<RoleContext> per single-tool role, infra_tools: Vec<InfraToolContext> plus infra_context_name and the resolved infra_env (the aggregated infra component, TECH_SPEC §4.6), the contract constants (blueprint_path, env_vars), and Nix info. render is derived from the .jinja extension.
The contract is a set of constants every template conforms to. It is the seam that makes composition generic.
pub const BLUEPRINT_PATH: &str = "blueprint/plutus.json";
pub const DIR_ON_CHAIN = "on-chain"; DIR_OFF_CHAIN = "off-chain";
pub const DIR_INFRA = "infra"; DIR_DEVNET = "devnet"; DIR_FORMAL_METHODS = "formal-methods";
pub const DIR_PROTOCOL = "protocol"; // fused on-chain+off-chain component (fullstack, §3.2)
pub const ENV_INDEXER_URL = "INDEXER_URL"; ENV_INDEXER_PORT = "INDEXER_PORT";
pub const ENV_NODE_SOCKET_PATH = "NODE_SOCKET_PATH"; ENV_NETWORK = "CARDANO_NETWORK";DIR_PROTOCOL is the one directory not backed by a Role (§3.2): a fullstack tool's fused
component. Its build still produces BLUEPRINT_PATH — it is the on-chain producer for its
project — so it is a full contract citizen; fullstack only privatizes the internal
on-chain↔off-chain link between its two halves.
Compliance checklist (enforced mechanically by contract-compliance tests):
- Every template ships a
Justfileexposingbuild,test,clean, and works independently (itsjust buildsucceeds with no other roles present). A no-op-for-this-tool target among those three still exists (printing a message is fine).devis optional — provided only when the tool has a real watch/daemon/devnet mode. The top level aggregates onlybuild/test/clean;dev, where present, is per-component (§6.2 / TECH_SPEC §7.2), never aggregated. - On-chain produces the CIP-57 blueprint at
../blueprint/plutus.jsonduringbuild. Other roles read it from that path if present. - The component that provisions a local chain endpoint writes the standard connection vars to
../.envduring itsdev. This is role-agnostic — usually an infrastructure service, but a local devnet such as Yaci DevKit in the devnet role does it too. Role = a tool's purpose; writing.env= the orthogonal capability of exposing a local endpoint. Consumers react to the presence ofINDEXER_URL, never to which role set it (principle 1). - Off-chain / devnet / formal-methods read the blueprint and
.envif present, and degrade gracefully when absent.
The blueprint/ directory is scaffolded whenever any blueprint-producing-or-consuming
role is present, every project except infrastructure-only (§6.2), so the canonical
path exists wherever it's meaningful; the plutus.json file within it may still be
absent (no on-chain role, or no build yet), which is why consumers must tolerate its
absence. The CLI never tracks which tools produce/consume blueprints: it is a
template-level convention verified by tests, not registry metadata.
Each tool is one TOML file under registry/tools/:
[tool]
id = "aiken"
name = "Aiken"
description = "…newcomer-friendly explanation…"
website = "https://aiken-lang.org"
languages = ["aiken"]
system_deps = ["aiken"] # abstract dep ids → resolved via registry/deps.toml (§8)
nix_packages = ["aiken"] # packages for the generated Nix dev shell
[roles.on-chain]
template = "aiken/on-chain" # path under templates/loader.rs iterates embedded assets, parses each TOML into a ToolDef, and builds a Registry with two indexes: by_id (lookup) and by_role (list tools for a role). Loading rejects duplicate ids and an empty registry. The registry is immutable after load.
Four independent, individually testable phases. --dry-run stops after phase 2.
Selection + Registry
│
▼
┌────────────────┐ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐
│ 1. Context │──▶│ 2. Plan │──▶│ 3. Render │──▶│ 4. Write │
│ build_context()│ │ plan() │ │ render() │ │ write() │
│ (pure) │ │ (pure) │ │ (pure) │ │ (side effects)│
└────────────────┘ └──────────────┘ └──────────────┘ └───────────────┘
│
--dry-run exits here (returns FilePlan)
Walks selection.assignments, resolves each tool against the registry, and builds the TemplateContext: per-role has_* flags and RoleContexts, deduplicated nix_packages, contract constants, and .env variable seeds. Errors on unknown tool or role mismatch.
Produces the ordered FilePlan:
- Base layer (always):
Justfile,README.md,.gitignore,.env. - Blueprint dir:
blueprint/.gitkeep, emitted whenever the selection includes any blueprint-producing-or-consuming role: i.e., any role except infrastructure (equivalently: present unless the project is infrastructure-only). - Role layers: for each assignment, read the template's
manifest.tomland add its files. Two cases aggregate instead of emitting one directory per assignment:- Fullstack collapses: when the same tool fills on-chain + off-chain and declares a
[fullstack]template (§3.2), the two assignments emit oneprotocol/component (fullstack.template), emitted once on the first of the pair; the second is skipped.has_fullstackis set;has_on_chain/has_off_chainare not. - Infrastructure is special — it aggregates: all selected infra tools share one driver template (
_infra/cardano-up) emitted once atinfra/, rendered over the full set (TemplateContext.infra_tools). This is because the infra engine (cardano-up) manages the whole stack as a single unit, not per service. Every other role is one tool → one directory.
- Fullstack collapses: when the same tool fills on-chain + off-chain and declares a
- Optional layer:
flake.nix+.envrcwhennixis set.
No I/O: only embedded assets are read. render is set from the .jinja extension.
The blueprint/ directory gives every blueprint-consuming role (off-chain, devnet, formal-methods) a stable, predictable path to read from, and lets a user drop a hand-supplied or externally-built plutus.json into the same place even when on-chain isn't scaffolded in this project. It is omitted only for infrastructure-only projects, where no role produces or consumes a blueprint. Only the directory (via .gitkeep) is created; the plutus.json file is produced by on-chain build, so consumers must still handle its absence gracefully (§4).
Code note: the current
planner.rscreatesblueprint/.gitkeeponly when on-chain is present (guarded by ahas_on_chaincheck). The rule above broadens that guard to "any non-infrastructure role present".
Render processes each entry whose source is a .jinja template through MiniJinja with the TemplateContext. Render-ness is derived from the file extension at plan time: the planner sets FileEntry.render = source.ends_with(".jinja") (§6.2); it is not an authored manifest field (manifests list only source/dest). Non-.jinja files, and Inline sources, pass through verbatim. Write is the only phase that touches disk: it creates parent directories and writes each file's content.
Determinism is a guarantee of the planning phase. The planner emits entries in a fixed order: base layer → blueprint dir (when any non-infrastructure role is present) → role layers in Role::ALL order → optional layer. Within Infrastructure (the only multi-tool role), tools are ordered by sorted tool id. Any HashMap (e.g. env_vars) is iterated through a sorted/canonical view before it reaches output. Snapshot tests over --dry-run and rendered output enforce byte-stability. No other phase may introduce nondeterministic ordering.
Current code stores
rolesin aHashMapand preservesassignmentsin flag order; formalizing the canonical ordering above (esp. sorting infra by id and iteratingassignmentsinRole::ALLorder) is the concrete work item this rule mandates.
cli/mod.rs parses args with clap. There is an optional subcommand and a flattened set of init flags:
- One-shot (
--name+ role flags): flags →Selectioninoneshot.rs, non-interactive, deterministic. Primary path for agents and CI. - Interactive (no
--name): guideddialoguerflow ininteractive.rs. websubcommand: launches the local builder server (§10).listsubcommand: capability discovery; lists roles/tools, human by default,--format jsonfor agents (see §7.3).
A safety check refuses to overwrite an existing target directory.
To serve both humans and agents without scattering format branches:
- A global
--format human|jsonflag (defaulthuman;jsonimplies non-interactive: JSON mode never prompts). output.rsas a presenter: the core returns structured results and typed errors; only the presenter knows about colors, tables, or JSON. Every command's JSON wraps in the §2.4 envelope viaoutput::emit_json_ok/print_error. Adding a new output is a presenter change, nothing else.
- Errors carry a stable string code (e.g.
unknown_tool,tool_role_mismatch,name_required,dir_exists) plus context (offending input + valid alternatives) and map to meaningful exit codes. In--format json, errors serialize to a stable shape on stderr; the core never falls back to interactive prompting in non-interactive mode.CliError::code()/context()carry the code + serializable context (§2.5). - Discovery is the
listsubcommand (cardano-init list) that emits the registry (roles, tools, the roles each fills, languages). Human by default,--format jsonfor agents (§8 schema). Bothlistandweb::build_registry_jsonrender from one shared model,registry::view(role_views()/tool_views()), so the JSON cannot drift; the human tool block reusescli::format_tool(shared with--help).
Scope (DX.02, implemented): the standalone cardano-init doctor command plus check + advise after generation. The doctor is a dependency checker/advisor, not a project validator — it reports presence and prints install plans; it never asserts a component actually builds (that's just build/just test, §7), never offers alternative runtimes (templates fix their toolchain), and checks presence only (no versions). Auto-install (running the resolved plan with consent) is a later nice-to-have install command (DX.05); see ROADMAP and the scope note in TECH_SPEC §9. The dependency catalog is a small graph, split between code and data along the purity invariant:
doctor/
├── mod.rs Pure: resolve(targets, catalog, env) -> Report (recursive, cycle-safe)
├── installers.rs Pure (code): the closed `Installer` vocabulary. Per installer: detect binaries, command template, and a `bootstrap` list of dep ids
├── catalog.rs Loads embedded registry/deps.toml -> DepCatalog (dep id → recipe)
└── probe.rs Impure: detect OS + which installers are on PATH -> Environment
- Two-tier inputs. The selection yields required deps =
{just}(universal task runner) ∪ thesystem_depsof all selected tools (unioned, deduped); and recommended deps (soft notes, never blocking). The two-tier mechanism stands, but there is currently no recommended dep: the formerprocess-compose/≥2-infra case existed only to smooth a multi-service top-leveljust dev, which no longer exists (the top level no longer aggregatesdev; long-running services start per-component — TECH_SPEC §7.2/§9.1).justis a base/derived dep owned by no tool. - Installers vs deps: the key model. An installer is just another dependency. Code owns a closed
Installervocabulary (Brew,Apt,Dnf,Pacman,Winget,Nix,Go,Cargo,Npm,Aikup,CardanoUp,Curl,PowerShell); each declares its detect-binaries, a command template (brew install {arg},npm install -g {arg},curl -sSfL {arg} | sh, …), and abootstraplist of dep ids. An emptybootstraplist ⇒ terminal (we detect it, never install it: system package managers,nix, the OS shells); a non-empty list ⇒ bootstrappable by installing any one of those deps in order (npm→["node"],aikup→["aikup"],cargo→["rustup","rust"]). This is what makes the catalog a graph rather than a flat list. - Recipes live in data. Per-dep recipes are an embedded TOML file (
registry/deps.toml), keyed by dep id:binaries(presence check),docs(universal fallback), and an orderedinstalllist of{ installer = arg }methods. Installer names are validated against the code enum at load (unknown installer → load error, like an unknownRole). See §8.1 for why code/data split this way. - Resolver (
resolve, pure, recursive). A dep is present if any of itsbinariesis onPATH. For a missing dep, the walk is two-pass over the orderedinstallmethods: Pass 1 returns the first method whose installer is detected (a one-step command); only if none is directly available does Pass 2 walk the methods again and, for the first bootstrappable installer, recurse to satisfy one of itsbootstrapdeps and prepend those steps. The result is an ordered, possibly multi-step plan (e.g.aikenmissing with nonix/aikup→ installaikupvianpm, thenaikup install). Two passes — rather than bootstrapping each method before trying later ones — are exactly why thenixpath needs noaikupwhennixis present (a single method is still chosen per dep). Cycle detection guards the walk;docsis the fallback when nothing resolves (advice never empty, FR-20). Version constraints are out of scope for v1 (presence only); doctor output is host-dependent by design (not part of the byte-identical generation contract). Full algorithm in TECH_SPEC §9.4. - Infrastructure deps install via
cardano-up(theCardanoUpinstaller);cardano-upis itself a dep inregistry/deps.toml(bootstrappable via its own installer methods). Auto-installing it arrives with the DX.05 install command; bootstrappingcardano-upwhen absent may follow post-RC (ROADMAP). - Project scan (no metadata file). The standalone doctor derives its target set by scanning the cwd: each contract role directory present is matched against the
detectsignatures of the tools that fill that role; an identified tool contributes itssystem_deps, and an unmatched directory is reported as unrecognized. Aprotocol/directory (the fullstack fused component, §3.2) is scanned by a dedicated branch against the tools that declare a[fullstack]template; the identified tool is a real registry tool, so itssystem_depsfeed the required set through the normalregistry.getpath (unlike the synthetic infra driver). Signatures are tool-author data inregistry/tools/<tool>.toml(detect = [...]), either a bare path (existence) or{ file, contains }(content) — the content form keeps generic filenames likepackage.jsonfrom mislabeling foreign projects without claiming to validate viability. Full algorithm + schema in TECH_SPEC §9.6. - Boundary:
mod.rs/installers.rs/catalog.rsare pure and unit-tested with syntheticEnvironments; onlyprobe.rstouches the system (PATH/OS probes + the project scan).doctordepends onregistry/contract, never oncli.
The catalog is a graph with two kinds of node, split by what each kind is:
- Installers are code (
installers.rs). Detection, command templating, and thebootstrapedges are logic, and the set is a closed vocabulary, so it earns compile-time safety (installer references are un-typo-able; a removed installer fails to compile) and one tested home for platform quirks. Adding an installer is a deliberate code change, done only when a real recipe needs it on a supported platform. - Recipes are data (
registry/deps.toml). This is what honors the project's extensibility promise: a tool author adds a tool by writingsystem_deps = [...]and, if a dep is new, aregistry/deps.tomlentry that chooses from the existing installer vocabulary, with no Rust. Recipes are deduplicated by dep id (shared deps likenode/jvmare defined once and referenced by many tools), and installer names are validated against the enum at load.
This split is the reversal of the earlier "in-code catalog" : the common case (a new tool whose deps install via existing installers) becomes pure data, which is the whole point of the registry model. The narrow case that still needs code (a brand-new installer) is rare and benefits from maintainer review anyway. Safety is preserved because data only ever names a closed, code-defined installer plus an arg; it never carries free-form command logic.
# registry/deps.toml: keyed by dep id; install = ordered [{ installer = arg }]
[node]
binaries = ["node"]
docs = "https://nodejs.org/en/download"
install = [ { brew = "node" }, { apt = "nodejs" }, { winget = "OpenJS.NodeJS" }, { nix = "nodejs" } ]
[aikup]
binaries = ["aikup"]
docs = "https://aiken-lang.org/installation-instructions"
install = [ { npm = "@aiken-lang/aikup" }, { curl = "https://install.aiken-lang.org" }, { powershell = "https://windows.aiken-lang.org" } ]
[aiken]
binaries = ["aiken"]
docs = "https://aiken-lang.org/installation-instructions"
install = [ { aikup = "" }, { nix = "aiken" } ]Referential integrity (tests): every system_deps id (plus the base dep just) has a registry/deps.toml entry; every installer named in the data exists in the Installer enum; every dep id in an installer's bootstrap list exists. The full field-by-field schema and the resolver algorithm are in TECH_SPEC §9.
The chosen mechanism for template freshness without runtime template fetching (PRD A-3/FR-24). It is a thin cli/ concern (UX, network, never core):
- Best-effort check against the GitHub releases API; the notice (if any) is surfaced before the write phase, so the user can update and regenerate rather than discovering it post-write. It informs, never gates (the user may Ctrl-C to update first); it never alters generated output.
- Latency is hidden, not added. In interactive mode the check fires async at startup and completes during tool selection: zero added latency. In human one-shot there's no think-time to hide it, so the result is joined with a ≤1s deadline behind a spinner before writing (worst case +1s, once/day).
- Cached once/day (small file in the OS cache dir): already-checked-today → zero network, zero latency.
- Gated and fail-silent: only when stdout is a TTY and not
--format json(agents/CI: no network, no spinner, no notice). Offline/timeout/parse error → no-op. Preserves offline operation and determinism (A-3).
The CLI is the single source of truth; the web UI never generates a project: it configures, previews structure, and emits a copyable cardano-init … command.
A hand-rolled, zero-dependency HTTP/1.1 server (TcpListener + threads) chosen to keep the "single static binary, zero runtime deps" goal. Routes:
GET /→ embeddedui.html.GET /api/registry→ registry as JSON (prebuilt once).GET /api/plan?…→ runs the actual Rust planner and returns the file tree.
Because /api/plan calls scaffold::planner, the local server's preview is guaranteed to match real generation: no duplicated logic.
A hosted page has no binary behind it. The key observation: the command string is trivial to assemble in JS (concatenate flags) and needs no planner. Only the live file-tree preview needs planner logic. So the resolution is staged:
- RC (DX.05): static builder. Ship the registry as static JSON and assemble the
cardano-init …command in plain JS. No binary, no planner, no drift on the command string. The planner-backed live tree preview is dropped for the RC (the command output is the deliverable). Hostable as a pure static site. - Post-RC: WASM live-preview. Compile the pure registry+planner to WASM so the hosted builder shows the exact file tree with zero logic duplication (realizes the "future extraction" goal). Deferred to Phase 2 (ROADMAP): adds a WASM build/bindings workstream not worth the RC-deadline risk.
The local serve path (10.1) ships regardless and keeps its planner-backed preview. If a JS tree-preview approximation is ever added before WASM, it must be tested against the planner's output to bound drift.
- Unit (pure core): registry loading (every TOML parses, fields present); context building; planning (exact file set + order); rendering (context + template → expected output); doctor
resolveover synthetic environments (incl. multi-step bootstrap chains and the cycle guard). - Contract compliance (mechanical): for each template, assert the Justfile exposes
build/test/clean(devis optional); for on-chain, assertjust buildproducesblueprint/plutus.json. This is what lets us avoid testing tool combinations. - Per-tool build smoke tests: scaffold each tool in isolation and, where CI has the toolchain (or via Nix), run
just build && just test. New tools must add these (PRD SM-1). - Scheduled maintenance gate: the per-tool smoke tests also run on a schedule (weekly cron + manual dispatch,
.github/workflows/scheduled-smoke.yml), not only on PR/commit. This is what detects a generated project breaking with no repo change — a Cardano hardfork, a breaking upstream tool release, or an unmaintained dependency (templates pin floating version ranges). A failure opens a tracking issue. It is distinct from the PR gates, which catch regressions we introduce. - Determinism / snapshot tests:
--dry-runand rendered output compared against committed snapshots for a set of selections; guards §6.4. - No combinatorial testing: composition is guaranteed by the contract, so we verify each tool individually rather than every pair.
- Add
registry/tools/<tool>.tomlwith metadata,system_deps,nix_packages, and a[roles.<role>]block per supported role. - Add
templates/<tool>/<role>/with amanifest.tomland template files (conforming to the contract, §4). - If the tool introduces a new
system_depsid, add aregistry/deps.tomlentry (pure data; code is needed only if the dep requires a brand-new installer, §8). - Add the per-tool tests (§11).
- Recompile (assets are embedded at compile time).
No CLI/core code changes are required for a new tool. Contract conformance guarantees it composes with every existing tool in other roles.
None currently open.