From 17d2da2303931396b3f36c40ee016a85f320d852 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Mon, 6 Jul 2026 15:46:04 -0400 Subject: [PATCH 1/8] molt --- .cargo/config.toml | 2 + .github/workflows/check.yml | 13 + CLAUDE.md | 89 +++- Cargo.lock | 135 ++++++ Cargo.toml | 55 +++ README.md | 58 ++- clippy.toml | 3 + crates/app_cli/Cargo.toml | 14 + crates/app_cli/src/error.rs | 38 ++ crates/app_cli/src/main.rs | 65 +++ crates/fuz_template/Cargo.toml | 14 + crates/fuz_template/src/anchors.rs | 53 +++ crates/fuz_template/src/apply.rs | 230 ++++++++++ crates/fuz_template/src/check.rs | 83 ++++ crates/fuz_template/src/cli.rs | 60 +++ crates/fuz_template/src/config.rs | 132 ++++++ crates/fuz_template/src/error.rs | 83 ++++ crates/fuz_template/src/features.rs | 170 +++++++ crates/fuz_template/src/git.rs | 70 +++ crates/fuz_template/src/main.rs | 240 ++++++++++ crates/fuz_template/src/plan.rs | 421 ++++++++++++++++++ crates/fuz_template/src/templates.rs | 68 +++ crates/fuz_template/src/wizard.rs | 52 +++ crates/fuz_template/templates/CLAUDE.md.in | 36 ++ crates/fuz_template/templates/README.md.in | 37 ++ .../templates/claude_rust_section.md.in | 19 + crates/fuz_template/templates/page.svelte.in | 13 + .../templates/readme_rust_section.md.in | 12 + .../templates/workspace_cargo.toml.in | 55 +++ rust-toolchain.toml | 5 + 30 files changed, 2309 insertions(+), 16 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 clippy.toml create mode 100644 crates/app_cli/Cargo.toml create mode 100644 crates/app_cli/src/error.rs create mode 100644 crates/app_cli/src/main.rs create mode 100644 crates/fuz_template/Cargo.toml create mode 100644 crates/fuz_template/src/anchors.rs create mode 100644 crates/fuz_template/src/apply.rs create mode 100644 crates/fuz_template/src/check.rs create mode 100644 crates/fuz_template/src/cli.rs create mode 100644 crates/fuz_template/src/config.rs create mode 100644 crates/fuz_template/src/error.rs create mode 100644 crates/fuz_template/src/features.rs create mode 100644 crates/fuz_template/src/git.rs create mode 100644 crates/fuz_template/src/main.rs create mode 100644 crates/fuz_template/src/plan.rs create mode 100644 crates/fuz_template/src/templates.rs create mode 100644 crates/fuz_template/src/wizard.rs create mode 100644 crates/fuz_template/templates/CLAUDE.md.in create mode 100644 crates/fuz_template/templates/README.md.in create mode 100644 crates/fuz_template/templates/claude_rust_section.md.in create mode 100644 crates/fuz_template/templates/page.svelte.in create mode 100644 crates/fuz_template/templates/readme_rust_section.md.in create mode 100644 crates/fuz_template/templates/workspace_cargo.toml.in create mode 100644 rust-toolchain.toml diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..57bdc60 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +molt = "run --package fuz_template --" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 19440b0..ad78459 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -45,3 +45,16 @@ jobs: cache: npm - run: npm ci - run: npx @fuzdev/gro check --workspace --build + + rust: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + # rustup auto-installs the toolchain pinned in rust-toolchain.toml + - run: cargo fmt --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace diff --git a/CLAUDE.md b/CLAUDE.md index 24fe082..3ac455f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,28 +57,100 @@ fuz_template is a **SvelteKit starter template**: ## Using the template -Clone with degit or use GitHub's "Use this template" button: +Use GitHub's "Use this template" button or clone directly: ```bash -npx degit fuzdev/fuz_template myproject +git clone https://github.com/fuzdev/fuz_template.git myproject cd myproject npm i ``` -**Files to customize:** +Then transform it into your own project with molt (see below): -- `package.json` - name, version, description, homepage, repository -- `svelte.config.js` - update origin URL -- `src/routes/+layout.svelte` - update `` +```bash +cargo molt +``` + +**Files molt customizes (or do it by hand):** + +- `package.json` - name, description, homepage, repository, glyph/logo fields +- `src/routes/+layout.svelte` - `<title>`, template logo - `src/routes/+page.svelte` - replace demo content +- `src/routes/about/+page.svelte` - heading - `static/CNAME` - update or delete for your domain -- `.github/FUNDING.yml` - update or delete +- `.github/FUNDING.yml` and `.github/ISSUE_TEMPLATE/` - update or delete +- `README.md` and `CLAUDE.md` - regenerate for the new project + +## molt (self-eject CLI) + +`crates/fuz_template` is molt — a one-shot wizard that personalizes the +clone and then deletes itself (like create-react-app's eject, or a spider +shedding its skin). Invoked via the cargo alias in `.cargo/config.toml`: + +```bash +cargo molt # interactive wizard: prompts, prints the plan, confirms +cargo molt check # verify molt's anchors still match the template +cargo molt --help # all flags, for non-interactive use +``` + +Key behaviors: + +- **Requires a git repo with a clean tree** (exit 2 otherwise; `--force` + overrides dirty, nothing overrides no-git) so it's always undoable. +- **Plan-then-apply**: every file edit is anchored on exact current content; + one unmatched anchor aborts before any write. Non-interactive runs write + nothing without `--wetrun`. +- **Feature registry**: every optional feature is a keep/strip choice — + `rust` (the whole workspace: `Cargo.toml`, `crates/`, `.cargo/`, + `rust-toolchain.toml`, `clippy.toml`, and the `rust` CI job), `cli` (the + starter crate `crates/app_cli`, renamed to `crates/{name}` with every + `app_cli` occurrence substituted), `docs` (`src/routes/docs/` + + `src/routes/library.ts` + the starter page's docs link), and + `github-extras` (FUNDING.yml + issue templates; the only default-strip). + One prompt each in the wizard, or `--keep`/`--strip` id lists + (comma-separated or repeated). Stripping `rust` cascades to `cli`. The + registry lives in `crates/fuz_template/src/features.rs` — new features are + one entry + a plan fragment there. +- **Identity fields**: name (required), npm name, description, domain, + repo url — derived from the git origin when it isn't the template's. +- **Self-verifying**: `cargo molt check` and the crate's tests verify every + anchor against the working tree, so a template edit that would break + ejection fails `cargo test` (and CI) at the same commit. When you edit an + anchored file, update `crates/fuz_template/src/anchors.rs` (and the + embedded templates in `crates/fuz_template/templates/`) in the same change. + +## Rust workspace + +The repo doubles as a Rust workspace following the fuz ecosystem's +conventions: root `Cargo.toml` with the canonical lint block +(`unsafe_code = "forbid"`, clippy pedantic/nursery at warn) and release +profile, `clippy.toml` test allowances, and the toolchain pinned in +`rust-toolchain.toml`. Do not use Gro for the Rust side — run cargo +directly: + +```bash +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --check +``` + +CI runs these in the `rust` job of `.github/workflows/check.yml`. Errors +follow thiserror enums with `.exit_code()`/`.hint()` helpers and +`fn main() -> ExitCode`; exit codes: `0` success, `2` caller-must-fix +(usage, preconditions), `1` everything else. Arg parsing uses argh with an +explicit `from_args` so usage errors exit `2`. ## Architecture ### Directory structure ``` +Cargo.toml # Rust workspace (lints, profile, deps) +crates/ +├── app_cli/ # starter CLI crate — molt renames it to yours +└── fuz_template/ # molt — the self-eject CLI (deletes itself) + ├── src/ # plan/verify/apply, wizard, features, anchors + └── templates/ # embedded output templates (*.in) src/ ├── app.html # HTML entry with theme detection ├── lib/ # your library code @@ -186,7 +258,8 @@ Deploy with `gro deploy` (builds and pushes to deploy branch). - TypeScript strict mode - Svelte 5 with runes API - Prettier with tabs, 100 char width -- Node >= 22.15 +- Node >= 24.14 +- Rust pinned via `rust-toolchain.toml` (edition 2024) - Private package (not published to npm) ## Related projects diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f2f1da9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,135 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "app_cli" +version = "0.0.1" +dependencies = [ + "argh", + "thiserror", +] + +[[package]] +name = "argh" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033" +dependencies = [ + "argh_derive", + "argh_shared", +] + +[[package]] +name = "argh_derive" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d" +dependencies = [ + "argh_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "argh_shared" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1" +dependencies = [ + "serde", +] + +[[package]] +name = "fuz_template" +version = "0.0.1" +dependencies = [ + "argh", + "thiserror", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..802d217 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,55 @@ +[workspace] +resolver = "2" +members = ["crates/app_cli", "crates/fuz_template"] + +[workspace.package] +version = "0.0.1" +edition = "2024" +license = "MIT" +publish = false + +[workspace.dependencies] +argh = "0.1" +thiserror = "2" + +[workspace.lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" +trivial_casts = "warn" +trivial_numeric_casts = "warn" +unused_lifetimes = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +# Enable lint groups (priority -1 so individual lints can override) +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } + +# Pedantic overrides +module_name_repetitions = "allow" +must_use_candidate = "allow" +similar_names = "allow" +too_many_lines = "allow" + +# Nursery overrides +significant_drop_tightening = "allow" + +# Cargo overrides (private repos) +cargo_common_metadata = "allow" +multiple_crate_versions = "allow" + +# Restriction lints (panic points need explicit #[allow] with justification) +clone_on_ref_ptr = "warn" +dbg_macro = "warn" +expect_used = "warn" +panic = "warn" +todo = "warn" +unwrap_used = "warn" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/README.md b/README.md index 2bb3705..31d396c 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,10 @@ so the normal commands like `vite dev` work as expected. It also uses [Gro](https://github.com/fuzdev/gro) for tasks like deploying and more. -If you're logged into GitHub, click "Use this template" above or clone with -[`degit`](https://github.com/Rich-Harris/degit): +If you're logged into GitHub, click "Use this template" above, or clone directly: ```bash -npx degit fuzdev/fuz_template cooltoy +git clone https://github.com/fuzdev/fuz_template.git cooltoy cd cooltoy npm i # then @@ -75,21 +74,64 @@ with no further configuration. To learn how to swap it out for another deployment target, see [the SvelteKit adapter docs](https://svelte.dev/docs/kit/adapters). -To make it your own, change `@fuzdev/fuz_template` and `template.fuz.dev` -to your project name in the following files: +To make it your own, run the molt wizard (requires [Rust](https://rustup.rs/) and +a clean git tree — commit or stash first): + +```bash +cargo molt +``` + +It renames the project, strips the demo components, and deletes itself — see +[molt](#molt) below. + +Prefer to do it by hand (or skip installing Rust)? Change `@fuzdev/fuz_template` +and `template.fuz.dev` to your project name in the following files: - [`package.json`](package.json) -- [`svelte.config.js`](svelte.config.js) - [`src/routes/+layout.svelte`](src/routes/+layout.svelte) - [`src/routes/+page.svelte`](src/routes/+page.svelte) -- update or delete [`src/static/CNAME`](src/static/CNAME) +- [`src/routes/about/+page.svelte`](src/routes/about/+page.svelte) +- update or delete [`static/CNAME`](static/CNAME) and ./.github/FUNDING.yml +And to remove the Rust workspace, delete `Cargo.toml`, `Cargo.lock`, `crates/`, +`.cargo/`, `rust-toolchain.toml`, `clippy.toml`, and the `rust` job in +[`.github/workflows/check.yml`](.github/workflows/check.yml). + Then run `npm i` to update `package-lock.json`. Optionally add a [license file](https://choosealicense.com/) and [`package.json` value](https://spdx.org/licenses/), like `"license": "MIT"`. +## molt + +The template doubles as a Rust workspace, and its one crate is `molt` — a +one-shot wizard that transforms this clone into your own project, then deletes +itself. Like a spider shedding its template skin. 🕷 + +```bash +cargo molt # interactive wizard: prompts, prints the full plan, confirms +cargo molt check # verify molt's file anchors still match the template +``` + +What it does, driven by your answers: + +- renames the project everywhere (`package.json`, page titles, headings) +- updates or removes `static/CNAME`, `homepage`, and `repository` +- deletes the demo components and writes a minimal starting page +- regenerates `README.md` and `CLAUDE.md` for your project +- keeps or strips each optional feature — `rust` (the whole workspace), + `cli` (the starter crate at [`crates/app_cli`](crates/app_cli), renamed to + your project name), `docs` (the docs system), and `github-extras` + (funding + issue templates) — via prompts or + `--keep`/`--strip` lists (e.g. `--strip rust` or `--keep github-extras --strip docs`) +- deletes its own crate + +It refuses to run without a clean git tree, so undo is always +`git checkout .` away. Run with flags instead of prompts for non-interactive +use (`cargo molt --help`); in that mode nothing is written without +`--wetrun`. + ## build ```bash @@ -111,7 +153,7 @@ gro test -- --forwarded-args 'to vitest' ``` See [Vitest](https://github.com/vitest-dev/vitest), -[`src/lib/example.test.ts`](src/lib/example.test.ts), +[`src/routes/example.test.ts`](src/routes/example.test.ts), and [Gro's test docs](https://github.com/fuzdev/gro/blob/main/src/docs/test.md) for more. ## deploy diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..7bada54 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,3 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true diff --git a/crates/app_cli/Cargo.toml b/crates/app_cli/Cargo.toml new file mode 100644 index 0000000..920d95b --- /dev/null +++ b/crates/app_cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "app_cli" +description = "a CLI scaffolded by fuz_template's molt" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +argh.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/app_cli/src/error.rs b/crates/app_cli/src/error.rs new file mode 100644 index 0000000..a6c53d7 --- /dev/null +++ b/crates/app_cli/src/error.rs @@ -0,0 +1,38 @@ +use thiserror::Error; + +/// Errors surfaced by the `app_cli` CLI. +/// +/// Exit-code dialect: `0` success; `2` = the caller must change something +/// local (arguments, config) before retrying; `1` = everything else. +#[derive(Debug, Error)] +pub enum CliError { + /// The invocation itself is wrong. + #[error("{0}")] + Usage(String), +} + +impl CliError { + /// Maps the error to its stable exit code. + pub const fn exit_code(&self) -> u8 { + match self { + Self::Usage(_) => 2, + } + } + + /// An optional remediation hint printed under the error. + pub const fn hint(&self) -> Option<&'static str> { + match self { + Self::Usage(_) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_codes_are_stable() { + assert_eq!(CliError::Usage(String::new()).exit_code(), 2); + } +} diff --git a/crates/app_cli/src/main.rs b/crates/app_cli/src/main.rs new file mode 100644 index 0000000..56c424a --- /dev/null +++ b/crates/app_cli/src/main.rs @@ -0,0 +1,65 @@ +//! The starter CLI: replace this with your program. molt renames the crate +//! (and every `app_cli` occurrence) to your project name. + +mod error; + +use std::process::ExitCode; + +use argh::FromArgs; + +use crate::error::CliError; + +/// The `app_cli` CLI. +#[derive(FromArgs, Debug)] +struct TopLevel { + /// print the version and exit + #[argh(switch)] + version: bool, + + /// who to greet + #[argh(positional)] + who: Option<String>, +} + +fn main() -> ExitCode { + let args: Vec<String> = std::env::args().collect(); + let bin = args.first().map_or("app_cli", String::as_str); + let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); + // Parse explicitly rather than `argh::from_env`, which hard-exits `1` on any + // parse failure — a usage error is `2` by shell convention. + let top = match TopLevel::from_args(&[bin], &rest) { + Ok(top) => top, + Err(early_exit) => { + return if early_exit.status.is_ok() { + println!("{}", early_exit.output); + ExitCode::SUCCESS + } else { + eprintln!("{}", early_exit.output); + ExitCode::from(2) + }; + } + }; + match run(&top) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + if let Some(hint) = e.hint() { + eprintln!("hint: {hint}"); + } + ExitCode::from(e.exit_code()) + } + } +} + +fn run(top: &TopLevel) -> Result<(), CliError> { + if top.version { + println!("app_cli {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + let who = top.who.as_deref().unwrap_or("world"); + if who.is_empty() { + return Err(CliError::Usage("who must not be empty".to_owned())); + } + println!("hello {who}, from app_cli"); + Ok(()) +} diff --git a/crates/fuz_template/Cargo.toml b/crates/fuz_template/Cargo.toml new file mode 100644 index 0000000..e60aa99 --- /dev/null +++ b/crates/fuz_template/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fuz_template" +description = "molt — transforms this template clone into your own project, then deletes itself" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +argh.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/fuz_template/src/anchors.rs b/crates/fuz_template/src/anchors.rs new file mode 100644 index 0000000..886bddd --- /dev/null +++ b/crates/fuz_template/src/anchors.rs @@ -0,0 +1,53 @@ +//! Exact-content anchors into the template's files. +//! +//! molt and the template travel in the same repo at the same commit, so these +//! can be exact strings. `cargo molt check` (and this crate's tests) verify +//! every anchor still matches — when you edit an anchored spot in the +//! template, update the anchor here in the same change. + +pub const PACKAGE_JSON_NAME: &str = " \"name\": \"@fuzdev/fuz_template\",\n"; +pub const PACKAGE_JSON_DESCRIPTION: &str = " \"description\": \"a static web app and Node library template with TypeScript, Svelte, SvelteKit, Vite, esbuild, Gro, and Fuz\",\n"; +pub const PACKAGE_JSON_GLYPH: &str = " \"glyph\": \"\u{2744}\",\n"; +pub const PACKAGE_JSON_LOGO: &str = " \"logo\": \"logo.svg\",\n"; +pub const PACKAGE_JSON_LOGO_ALT: &str = + " \"logo_alt\": \"a friendly pixelated spider facing you\",\n"; +pub const PACKAGE_JSON_HOMEPAGE: &str = " \"homepage\": \"https://template.fuz.dev/\",\n"; +pub const PACKAGE_JSON_REPOSITORY: &str = + " \"repository\": \"https://github.com/fuzdev/fuz_template\",\n"; + +pub const LAYOUT_LOGO_IMPORT: &str = + "\timport {logo_fuz_template} from '@fuzdev/fuz_ui/logos.ts';\n"; +pub const LAYOUT_SITE_STATE: &str = "\t// `glyph` and `repo_url` derive from `pkg_json`; `icon` stays explicit (structured `SvgData`).\n\tsite_context.set(new SiteState({icon: logo_fuz_template, pkg_json}));"; +pub const LAYOUT_SITE_STATE_REPLACEMENT: &str = "\t// `glyph` and `repo_url` derive from `pkg_json`.\n\tsite_context.set(new SiteState({pkg_json}));"; +pub const LAYOUT_TITLE: &str = "<title>@fuzdev/fuz_template"; + +pub const PAGE_MREOWS_IMPORT: &str = "import Mreows, {mreow_items} from '$lib/Mreows.svelte';"; +pub const H1_FUZ_TEMPLATE: &str = "

fuz_template

"; + +pub const README_H1: &str = "# @fuzdev/fuz_template \u{2744}"; +pub const CLAUDE_H1: &str = "# fuz_template\n"; +pub const CNAME_CONTENT: &str = "template.fuz.dev"; +pub const WORKSPACE_MEMBERS: &str = "members = [\"crates/app_cli\", \"crates/fuz_template\"]"; + +/// The starter CLI crate's name — molt renames every occurrence (and the +/// crate directory) to the chosen project name. +pub const APP_CLI_TOKEN: &str = "app_cli"; +/// The starter CLI crate's placeholder description line. +pub const APP_CLI_DESCRIPTION: &str = "description = \"a CLI scaffolded by fuz_template's molt\"\n"; + +/// The `rust` job appended to `.github/workflows/check.yml` — kept here as an +/// exact-match anchor so stripping the `rust` feature can remove it. +pub const CI_RUST_JOB: &str = r" + rust: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + # rustup auto-installs the toolchain pinned in rust-toolchain.toml + - run: cargo fmt --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace +"; diff --git a/crates/fuz_template/src/apply.rs b/crates/fuz_template/src/apply.rs new file mode 100644 index 0000000..23ec79c --- /dev/null +++ b/crates/fuz_template/src/apply.rs @@ -0,0 +1,230 @@ +use std::fs; +use std::path::Path; + +use crate::error::CliError; +use crate::plan::Action; + +/// Applies a verified plan at `root`. Callers must run `plan::verify` first — +/// apply assumes anchors match and targets exist. +pub fn apply(root: &Path, plan: &[Action]) -> Result<(), CliError> { + for action in plan { + match action { + Action::ReplaceOnce { + path, + anchor, + replacement, + .. + } => { + let full = root.join(path); + let content = fs::read_to_string(&full).map_err(|source| CliError::Io { + path: full.clone(), + source, + })?; + let updated = content.replacen(anchor.as_str(), replacement, 1); + fs::write(&full, updated).map_err(|source| CliError::Io { path: full, source })?; + } + Action::ReplaceAll { path, from, to, .. } => { + let full = root.join(path); + let content = fs::read_to_string(&full).map_err(|source| CliError::Io { + path: full.clone(), + source, + })?; + let updated = content.replace(from.as_str(), to); + fs::write(&full, updated).map_err(|source| CliError::Io { path: full, source })?; + } + Action::ReplaceFile { path, content, .. } => { + let full = root.join(path); + fs::write(&full, content).map_err(|source| CliError::Io { path: full, source })?; + } + Action::RenameDir { from, to } => { + let from_full = root.join(from); + let to_full = root.join(to); + fs::rename(&from_full, &to_full).map_err(|source| CliError::Io { + path: from_full, + source, + })?; + } + Action::DeleteFile { path } => { + let full = root.join(path); + fs::remove_file(&full).map_err(|source| CliError::Io { path: full, source })?; + } + Action::DeleteDir { path } => { + let full = root.join(path); + fs::remove_dir_all(&full).map_err(|source| CliError::Io { path: full, source })?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::check::sample_configs; + use crate::plan::{build_plan, verify}; + + fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf() + } + + /// Copies the parts of the repo that molt touches into a scratch dir. + fn copy_template(destination: &Path) { + let root = repo_root(); + for dir in ["src", "static", ".github", ".cargo", "crates"] { + copy_dir(&root.join(dir), &destination.join(dir)); + } + for file in [ + "package.json", + "README.md", + "CLAUDE.md", + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + "clippy.toml", + ] { + fs::copy(root.join(file), destination.join(file)).unwrap(); + } + } + + fn copy_dir(source: &Path, destination: &Path) { + fs::create_dir_all(destination).unwrap(); + for entry in fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + let target = destination.join(entry.file_name()); + let file_type = entry.file_type().unwrap(); + if file_type.is_dir() { + copy_dir(&entry.path(), &target); + } else if file_type.is_file() { + fs::copy(entry.path(), &target).unwrap(); + } + // symlinks (e.g. AGENTS.md at the root) aren't under the copied dirs + } + } + + fn scratch_dir(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "fuz_template_molt_test_{label}_{}", + std::process::id() + )); + if dir.exists() { + fs::remove_dir_all(&dir).unwrap(); + } + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn read(root: &Path, path: &str) -> String { + fs::read_to_string(root.join(path)).unwrap() + } + + #[test] + fn apply_keep_rust_sample() { + let [config, _] = sample_configs(); + let dir = scratch_dir("keep_rust"); + copy_template(&dir); + + let plan = build_plan(&config); + let issues = verify(&dir, &plan).unwrap(); + assert!(issues.is_empty(), "verify issues: {issues:#?}"); + apply(&dir, &plan).unwrap(); + + let package_json = read(&dir, "package.json"); + assert!(package_json.contains("\"name\": \"@sample/sample_app\"")); + assert!(!package_json.contains("glyph")); + assert!(package_json.contains("\"homepage\": \"https://sample.example.com/\"")); + assert_eq!(read(&dir, "static/CNAME"), "sample.example.com\n"); + + let layout = read(&dir, "src/routes/+layout.svelte"); + assert!(!layout.contains("logo_fuz_template")); + assert!(layout.contains("@sample/sample_app")); + + let page = read(&dir, "src/routes/+page.svelte"); + assert!(!page.contains("Mreows")); + assert!(page.contains("

sample_app

")); + assert!(page.contains("resolve('/docs')")); + assert!(!dir.join("src/lib/Mreows.svelte").exists()); + assert!(!dir.join("src/lib/Positioned.svelte").exists()); + + // docs kept + assert!(dir.join("src/routes/docs").is_dir()); + assert!(dir.join("src/routes/library.ts").is_file()); + + assert!(read(&dir, "README.md").starts_with("# @sample/sample_app")); + let claude = read(&dir, "CLAUDE.md"); + assert!(claude.starts_with("# sample_app")); + assert!(claude.contains("## Rust workspace")); + assert!(claude.contains("src/routes/docs")); + + assert!(!dir.join(".github/FUNDING.yml").exists()); + assert!(!dir.join(".github/ISSUE_TEMPLATE").exists()); + assert!(read(&dir, ".github/workflows/check.yml").contains("cargo clippy")); + + let workspace = read(&dir, "Cargo.toml"); + assert!(workspace.contains("members = [\"crates/sample_app\"]")); + assert!(!dir.join("crates/fuz_template").exists()); + assert!(!dir.join("crates/app_cli").exists()); + assert!(!dir.join(".cargo").exists()); + let crate_manifest = read(&dir, "crates/sample_app/Cargo.toml"); + assert!(crate_manifest.contains("name = \"sample_app\"")); + assert!(crate_manifest.contains("description = \"a sample app\"")); + let main_rs = read(&dir, "crates/sample_app/src/main.rs"); + assert!(main_rs.contains("hello {who}, from sample_app")); + assert!(!main_rs.contains("app_cli")); + assert!(!read(&dir, "crates/sample_app/src/error.rs").contains("app_cli")); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn apply_strip_rust_sample() { + let [_, config] = sample_configs(); + let dir = scratch_dir("strip_rust"); + copy_template(&dir); + + let plan = build_plan(&config); + let issues = verify(&dir, &plan).unwrap(); + assert!(issues.is_empty(), "verify issues: {issues:#?}"); + apply(&dir, &plan).unwrap(); + + let package_json = read(&dir, "package.json"); + assert!(package_json.contains("\"name\": \"plain_app\"")); + assert!(!package_json.contains("homepage")); + assert!(!package_json.contains("repository")); + assert!(!dir.join("static/CNAME").exists()); + + assert!(!dir.join("Cargo.toml").exists()); + assert!(!dir.join("Cargo.lock").exists()); + assert!(!dir.join("crates").exists()); + assert!(!dir.join(".cargo").exists()); + assert!(!dir.join("rust-toolchain.toml").exists()); + assert!(!dir.join("clippy.toml").exists()); + + let workflow = read(&dir, ".github/workflows/check.yml"); + assert!(!workflow.contains("cargo")); + assert!(workflow.contains("npx @fuzdev/gro check")); + + // docs stripped + assert!(!dir.join("src/routes/docs").exists()); + assert!(!dir.join("src/routes/library.ts").exists()); + let page = read(&dir, "src/routes/+page.svelte"); + assert!(!page.contains("resolve('/docs')")); + assert!(page.contains("resolve('/about')")); + + // extras kept in this sample + assert!(dir.join(".github/FUNDING.yml").exists()); + + let claude = read(&dir, "CLAUDE.md"); + assert!(!read(&dir, "README.md").contains("## rust")); + assert!(!claude.contains("## Rust workspace")); + assert!(!claude.contains("src/routes/docs")); + + fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/fuz_template/src/check.rs b/crates/fuz_template/src/check.rs new file mode 100644 index 0000000..425fdd1 --- /dev/null +++ b/crates/fuz_template/src/check.rs @@ -0,0 +1,83 @@ +use std::collections::BTreeSet; +use std::path::Path; +use std::process::ExitCode; + +use crate::config::MoltConfig; +use crate::error::CliError; +use crate::features; +use crate::plan::{build_plan, verify}; + +/// Runs `molt check`: verifies every anchor against the tree at `root`. +pub fn run(root: &Path) -> Result { + let issues = check_all(root)?; + if issues.is_empty() { + println!("molt check passed: all anchors match the template"); + Ok(ExitCode::SUCCESS) + } else { + eprintln!("molt check failed — the template drifted from molt's anchors:"); + for issue in &issues { + eprintln!(" {issue}"); + } + eprintln!("(update crates/fuz_template/src/anchors.rs in the same change)"); + Ok(ExitCode::FAILURE) + } +} + +/// Verifies the plans for both sample configs, covering every anchor molt can +/// touch (each feature exercised kept in one config and stripped in the other). +pub fn check_all(root: &Path) -> Result, CliError> { + let mut issues = Vec::new(); + for config in sample_configs() { + issues.extend(verify(root, &build_plan(&config))?); + } + issues.sort(); + issues.dedup(); + Ok(issues) +} + +/// Two configs that together exercise every plan branch: one keeps +/// rust/cli/docs and sets every optional value, one strips them and clears +/// the optional values (while keeping the github extras). +pub fn sample_configs() -> [MoltConfig; 2] { + [ + MoltConfig { + name: "sample_app".to_owned(), + npm_name: "@sample/sample_app".to_owned(), + description: "a sample app".to_owned(), + domain: Some("sample.example.com".to_owned()), + repo_url: Some("https://github.com/sample/sample_app".to_owned()), + kept: BTreeSet::from([features::RUST, features::CLI, features::DOCS]), + }, + MoltConfig { + name: "plain_app".to_owned(), + npm_name: "plain_app".to_owned(), + description: String::new(), + domain: None, + repo_url: None, + kept: BTreeSet::from([features::GITHUB_EXTRAS]), + }, + ] +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn anchors_match_the_template() { + let root: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .to_path_buf(); + let issues = check_all(&root).unwrap(); + assert!( + issues.is_empty(), + "template drifted from molt's anchors:\n{}", + issues.join("\n") + ); + } +} diff --git a/crates/fuz_template/src/cli.rs b/crates/fuz_template/src/cli.rs new file mode 100644 index 0000000..ab8e415 --- /dev/null +++ b/crates/fuz_template/src/cli.rs @@ -0,0 +1,60 @@ +use argh::FromArgs; + +/// molt — transform this `fuz_template` clone into your own project, then +/// molt deletes itself. Run with no arguments for the interactive wizard. +/// Without a terminal, `--name` is required and nothing is written unless +/// `--wetrun` is passed. +#[derive(FromArgs, Debug)] +pub struct TopLevel { + /// project name (`snake_case`; used for crate names, headings, and defaults) + #[argh(option)] + pub name: Option, + + /// npm package name (defaults to the project name; may be scoped like @you/name) + #[argh(option)] + pub npm_name: Option, + + /// one-line project description + #[argh(option)] + pub description: Option, + + /// custom domain written to static/CNAME (omit to delete CNAME and homepage) + #[argh(option)] + pub domain: Option, + + /// repository url (defaults to the git origin remote when it isn't the template's) + #[argh(option)] + pub repo: Option, + + /// features to keep, comma-separated or repeated + /// (rust, cli, docs, github-extras) + #[argh(option)] + pub keep: Vec, + + /// features to strip, comma-separated or repeated + /// (rust, cli, docs, github-extras) + #[argh(option)] + pub strip: Vec, + + /// apply the plan (the default prints the plan without writing) + #[argh(switch)] + pub wetrun: bool, + + /// proceed even if the git tree is dirty + #[argh(switch)] + pub force: bool, + + #[argh(subcommand)] + pub subcommand: Option, +} + +#[derive(FromArgs, Debug)] +#[argh(subcommand)] +pub enum Subcommand { + Check(CheckCommand), +} + +/// Verify molt's file anchors still match the template (used by CI and tests). +#[derive(FromArgs, Debug)] +#[argh(subcommand, name = "check")] +pub struct CheckCommand {} diff --git a/crates/fuz_template/src/config.rs b/crates/fuz_template/src/config.rs new file mode 100644 index 0000000..97ee72a --- /dev/null +++ b/crates/fuz_template/src/config.rs @@ -0,0 +1,132 @@ +use std::collections::BTreeSet; +use std::fmt::Write as _; + +use crate::error::CliError; + +/// Fully-resolved molt choices, assembled from flags and wizard answers. +/// `kept` holds the feature ids (from `features::FEATURES`) to keep. +#[derive(Debug)] +pub struct MoltConfig { + pub name: String, + pub npm_name: String, + pub description: String, + pub domain: Option, + pub repo_url: Option, + pub kept: BTreeSet<&'static str>, +} + +impl MoltConfig { + pub fn keeps(&self, feature_id: &str) -> bool { + self.kept.contains(feature_id) + } +} + +/// Validates a project name: `snake_case`, usable as a crate name. +pub fn validate_name(name: &str) -> Result<(), CliError> { + let valid_first = name.chars().next().is_some_and(|c| c.is_ascii_lowercase()); + let valid_rest = name + .chars() + .skip(1) + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'); + if !valid_first || !valid_rest { + return Err(CliError::Usage(format!( + "invalid name {name:?}: use snake_case starting with a letter (e.g. my_app)" + ))); + } + if matches!(name, "fuz_template" | "app_cli" | "xtask") { + return Err(CliError::Usage(format!( + "name {name:?} is reserved — pick your own project name" + ))); + } + Ok(()) +} + +/// Validates an npm package name loosely (scoped names like `@you/name` allowed). +pub fn validate_npm_name(name: &str) -> Result<(), CliError> { + let valid = !name.is_empty() + && name.chars().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.' | '/' | '@') + }); + if valid { + Ok(()) + } else { + Err(CliError::Usage(format!( + "invalid npm package name {name:?}" + ))) + } +} + +/// Validates a bare domain like `example.com` (no scheme, no path). +pub fn validate_domain(domain: &str) -> Result<(), CliError> { + let valid = !domain.is_empty() + && domain.contains('.') + && domain + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '.')); + if valid { + Ok(()) + } else { + Err(CliError::Usage(format!( + "invalid domain {domain:?}: expected a bare domain like example.com" + ))) + } +} + +/// Escapes a string for embedding in a JSON string literal (also valid for +/// TOML basic strings, which share the `\"`/`\\`/`\n` escapes). +pub fn json_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + c if u32::from(c) < 0x20 => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn name_validation() { + assert!(validate_name("my_app").is_ok()); + assert!(validate_name("app2").is_ok()); + assert!(validate_name("My_App").is_err()); + assert!(validate_name("2app").is_err()); + assert!(validate_name("my-app").is_err()); + assert!(validate_name("").is_err()); + assert!(validate_name("fuz_template").is_err()); + assert!(validate_name("app_cli").is_err()); + } + + #[test] + fn npm_name_validation() { + assert!(validate_npm_name("my_app").is_ok()); + assert!(validate_npm_name("@you/my-app").is_ok()); + assert!(validate_npm_name("").is_err()); + assert!(validate_npm_name("My App").is_err()); + } + + #[test] + fn domain_validation() { + assert!(validate_domain("example.com").is_ok()); + assert!(validate_domain("sub.example.co.uk").is_ok()); + assert!(validate_domain("https://example.com").is_err()); + assert!(validate_domain("nodots").is_err()); + } + + #[test] + fn json_escaping() { + assert_eq!(json_escape("plain"), "plain"); + assert_eq!(json_escape("a \"b\" c"), "a \\\"b\\\" c"); + assert_eq!(json_escape("line\nbreak"), "line\\nbreak"); + } +} diff --git a/crates/fuz_template/src/error.rs b/crates/fuz_template/src/error.rs new file mode 100644 index 0000000..72446b4 --- /dev/null +++ b/crates/fuz_template/src/error.rs @@ -0,0 +1,83 @@ +use std::path::PathBuf; + +use thiserror::Error; + +/// Errors surfaced by molt. +/// +/// Exit-code dialect: `0` success; `2` = the caller must change something +/// local (arguments, git state) before retrying; `1` = everything else. +#[derive(Debug, Error)] +pub enum CliError { + /// The invocation itself is wrong (bad flag values, missing required args). + #[error("{0}")] + Usage(String), + + /// The environment isn't ready (not a git repo, dirty tree, wrong directory). + #[error("{message}")] + Precondition { + message: String, + hint: Option<&'static str>, + }, + + /// The template's files no longer match molt's anchors — a template bug, + /// not something the caller can fix. + #[error("template drift detected:\n{0}")] + Drift(String), + + /// Filesystem failure while planning or applying. + #[error("io error at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// Terminal input/output failed mid-wizard. + #[error("terminal io failed: {0}")] + Terminal(#[from] std::io::Error), +} + +impl CliError { + pub fn precondition(message: impl Into, hint: Option<&'static str>) -> Self { + Self::Precondition { + message: message.into(), + hint, + } + } + + /// Maps the error to its stable exit code. + pub const fn exit_code(&self) -> u8 { + match self { + Self::Usage(_) | Self::Precondition { .. } => 2, + Self::Drift(_) | Self::Io { .. } | Self::Terminal(_) => 1, + } + } + + /// An optional remediation hint printed under the error. + pub const fn hint(&self) -> Option<&'static str> { + match self { + Self::Precondition { hint, .. } => *hint, + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_codes_are_stable() { + assert_eq!(CliError::Usage(String::new()).exit_code(), 2); + assert_eq!(CliError::precondition("x", None).exit_code(), 2); + assert_eq!(CliError::Drift(String::new()).exit_code(), 1); + assert_eq!( + CliError::Io { + path: PathBuf::new(), + source: std::io::Error::other("x"), + } + .exit_code(), + 1 + ); + } +} diff --git a/crates/fuz_template/src/features.rs b/crates/fuz_template/src/features.rs new file mode 100644 index 0000000..1b88ba7 --- /dev/null +++ b/crates/fuz_template/src/features.rs @@ -0,0 +1,170 @@ +//! The keep/strip feature registry. +//! +//! Every molt-selectable feature is one entry here: a wizard prompt, a +//! `--keep`/`--strip` id, and a plan fragment in `plan.rs` keyed off the +//! resolved set. Adding a feature means adding a registry entry, its plan +//! fragment, and covering its anchors in `check.rs`'s sample configs. + +use std::collections::BTreeSet; + +use crate::error::CliError; + +/// A molt-selectable feature of the template. +#[derive(Debug)] +pub struct Feature { + /// Stable id used by `--keep`/`--strip` (kebab-case). + pub id: &'static str, + /// Wizard prompt, phrased as "keep X?". + pub prompt: &'static str, + /// Whether the feature is kept when unspecified. + pub default_keep: bool, + /// A feature this one is part of — stripping the parent strips this too. + pub requires: Option<&'static str>, +} + +pub const RUST: &str = "rust"; +pub const CLI: &str = "cli"; +pub const DOCS: &str = "docs"; +pub const GITHUB_EXTRAS: &str = "github-extras"; + +pub const FEATURES: [Feature; 4] = [ + Feature { + id: RUST, + prompt: "keep the Rust workspace?", + default_keep: true, + requires: None, + }, + Feature { + id: CLI, + prompt: "keep the starter CLI crate? (renamed to crates/)", + default_keep: true, + requires: Some(RUST), + }, + Feature { + id: DOCS, + prompt: "keep the docs system? (src/routes/docs, auto-generated API docs)", + default_keep: true, + requires: None, + }, + Feature { + id: GITHUB_EXTRAS, + prompt: "keep .github/FUNDING.yml and the issue templates?", + default_keep: false, + requires: None, + }, +]; + +/// Splits repeatable/CSV flag values into feature ids, validating each. +pub fn parse_ids(values: &[String]) -> Result, CliError> { + let mut ids = Vec::new(); + for value in values { + for raw in value.split(',') { + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + let Some(feature) = FEATURES.iter().find(|f| f.id == raw) else { + return Err(CliError::Usage(format!( + "unknown feature {raw:?} — valid: {}", + FEATURES.map(|f| f.id).join(", ") + ))); + }; + ids.push(feature.id); + } + } + Ok(ids) +} + +/// Resolves the kept-feature set from `--keep`/`--strip` flags, applying +/// defaults and the `requires` cascade. `explicit` returns which features the +/// flags decided (the wizard prompts only for the rest). +pub fn resolve( + keep: &[String], + strip: &[String], +) -> Result<(BTreeSet<&'static str>, BTreeSet<&'static str>), CliError> { + let keep_ids = parse_ids(keep)?; + let strip_ids = parse_ids(strip)?; + if let Some(id) = keep_ids.iter().find(|id| strip_ids.contains(id)) { + return Err(CliError::Usage(format!( + "feature {id:?} passed to both --keep and --strip" + ))); + } + for id in &keep_ids { + if let Some(feature) = FEATURES.iter().find(|f| f.id == *id) + && let Some(parent) = feature.requires + && strip_ids.contains(&parent) + { + return Err(CliError::Usage(format!( + "--keep {id} conflicts with --strip {parent} ({id} is part of {parent})" + ))); + } + } + let mut kept = BTreeSet::new(); + let mut explicit = BTreeSet::new(); + for feature in &FEATURES { + let choice = if keep_ids.contains(&feature.id) { + explicit.insert(feature.id); + true + } else if strip_ids.contains(&feature.id) { + explicit.insert(feature.id); + false + } else { + feature.default_keep + }; + if choice { + kept.insert(feature.id); + } + } + cascade(&mut kept); + Ok((kept, explicit)) +} + +/// Strips any feature whose `requires` parent is stripped. +pub fn cascade(kept: &mut BTreeSet<&'static str>) { + for feature in &FEATURES { + if let Some(parent) = feature.requires + && !kept.contains(parent) + { + kept.remove(feature.id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|v| (*v).to_owned()).collect() + } + + #[test] + fn defaults() { + let (kept, explicit) = resolve(&[], &[]).unwrap(); + assert_eq!(kept.into_iter().collect::>(), vec![CLI, DOCS, RUST]); + assert!(explicit.is_empty()); + } + + #[test] + fn csv_and_repeats() { + let (kept, _) = resolve(&strings(&["github-extras,docs"]), &strings(&["cli"])).unwrap(); + assert!(kept.contains(GITHUB_EXTRAS)); + assert!(kept.contains(DOCS)); + assert!(kept.contains(RUST)); + assert!(!kept.contains(CLI)); + } + + #[test] + fn strip_rust_cascades_to_cli() { + let (kept, _) = resolve(&[], &strings(&["rust"])).unwrap(); + assert!(!kept.contains(RUST)); + assert!(!kept.contains(CLI)); + } + + #[test] + fn conflicts_error() { + assert!(resolve(&strings(&["rust"]), &strings(&["rust"])).is_err()); + assert!(resolve(&strings(&["cli"]), &strings(&["rust"])).is_err()); + assert!(resolve(&strings(&["nope"]), &[]).is_err()); + } +} diff --git a/crates/fuz_template/src/git.rs b/crates/fuz_template/src/git.rs new file mode 100644 index 0000000..6494e52 --- /dev/null +++ b/crates/fuz_template/src/git.rs @@ -0,0 +1,70 @@ +use std::path::Path; +use std::process::Command; + +use crate::error::CliError; + +/// Runs a git command at `root`, returning its stdout on success and `None` +/// on a nonzero exit (e.g. no `origin` remote configured). +pub fn output(root: &Path, args: &[&str]) -> Result, CliError> { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .map_err(|source| { + CliError::precondition( + format!("failed to run git: {source}"), + Some("molt needs git on the PATH"), + ) + })?; + if output.status.success() { + Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned())) + } else { + Ok(None) + } +} + +/// Normalizes a git remote url to an https repository url, returning `None` +/// for the template's own remote (a plain `git clone` of `fuz_template` +/// keeps origin pointed at the template — deriving that would be wrong). +pub fn normalize_remote_url(url: &str) -> Option { + let url = url.trim(); + if url.contains("fuzdev/fuz_template") { + return None; + } + let https = url.strip_prefix("git@").map_or_else( + || url.to_owned(), + |rest| format!("https://{}", rest.replacen(':', "/", 1)), + ); + let trimmed = https.strip_suffix(".git").unwrap_or(&https); + trimmed.starts_with("https://").then(|| trimmed.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_url_normalization() { + assert_eq!( + normalize_remote_url("git@github.com:you/app.git\n"), + Some("https://github.com/you/app".to_owned()) + ); + assert_eq!( + normalize_remote_url("https://github.com/you/app.git"), + Some("https://github.com/you/app".to_owned()) + ); + assert_eq!( + normalize_remote_url("https://github.com/you/app"), + Some("https://github.com/you/app".to_owned()) + ); + assert_eq!( + normalize_remote_url("git@github.com:fuzdev/fuz_template.git"), + None + ); + assert_eq!( + normalize_remote_url("https://github.com/fuzdev/fuz_template"), + None + ); + assert_eq!(normalize_remote_url("/local/path"), None); + } +} diff --git a/crates/fuz_template/src/main.rs b/crates/fuz_template/src/main.rs new file mode 100644 index 0000000..56e5f0a --- /dev/null +++ b/crates/fuz_template/src/main.rs @@ -0,0 +1,240 @@ +//! molt — transforms this `fuz_template` clone into your own project, then +//! deletes itself. See the repo `README.md` for the full story. + +mod anchors; +mod apply; +mod check; +mod cli; +mod config; +mod error; +mod features; +mod git; +mod plan; +mod templates; +mod wizard; + +use std::env; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use argh::FromArgs as _; + +use crate::cli::TopLevel; +use crate::config::MoltConfig; +use crate::error::CliError; + +fn main() -> ExitCode { + let args: Vec = env::args().collect(); + let bin = args.first().map_or("molt", String::as_str); + let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); + // Parse explicitly rather than `argh::from_env`, which hard-exits `1` on any + // parse failure — a usage error is `2` by shell convention. + let top = match TopLevel::from_args(&[bin], &rest) { + Ok(top) => top, + Err(early_exit) => { + return if early_exit.status.is_ok() { + println!("{}", early_exit.output); + ExitCode::SUCCESS + } else { + eprintln!("{}", early_exit.output); + ExitCode::from(2) + }; + } + }; + match run(&top) { + Ok(code) => code, + Err(e) => { + eprintln!("error: {e}"); + if let Some(hint) = e.hint() { + eprintln!("hint: {hint}"); + } + ExitCode::from(e.exit_code()) + } + } +} + +fn run(top: &TopLevel) -> Result { + let root = locate_root()?; + if top.subcommand.is_some() { + check::run(&root) + } else { + molt(top, &root) + } +} + +/// Walks up from the current directory to the template's repo root. +fn locate_root() -> Result { + let mut dir = env::current_dir().map_err(|source| CliError::Io { + path: PathBuf::from("."), + source, + })?; + loop { + if dir.join("package.json").is_file() && dir.join("crates/fuz_template").is_dir() { + return Ok(dir); + } + if !dir.pop() { + return Err(CliError::precondition( + "not inside the fuz_template repo (no package.json + crates/fuz_template found)", + Some("run `cargo molt` from your clone of fuz_template"), + )); + } + } +} + +fn molt(top: &TopLevel, root: &Path) -> Result { + let interactive = wizard::interactive(); + + if !root.join(".git").exists() { + return Err(CliError::precondition( + "not a git repository — molt refuses to run without an undo path", + Some("git init && git add -A && git commit -m 'init from fuz_template'"), + )); + } + let clean = + git::output(root, &["status", "--porcelain"])?.is_some_and(|out| out.trim().is_empty()); + if !clean && !top.force { + return Err(CliError::precondition( + "the git tree is dirty — molt wants a clean tree so it stays undoable", + Some("commit or stash your changes, or pass --force to proceed anyway"), + )); + } + + let config = resolve_config(top, root, interactive)?; + let plan = plan::build_plan(&config); + let issues = plan::verify(root, &plan)?; + if !issues.is_empty() { + return Err(CliError::Drift(issues.join("\n"))); + } + + println!("\nmolt plan ({} actions):", plan.len()); + for action in &plan { + println!(" {}", action.describe()); + } + + let apply_now = if top.wetrun { + true + } else if interactive { + println!(); + wizard::prompt_bool( + "apply this plan? the template becomes your project and molt deletes itself", + false, + )? + } else { + println!("\ndry run — nothing written. pass --wetrun to apply."); + false + }; + if !apply_now { + return Ok(ExitCode::SUCCESS); + } + + apply::apply(root, &plan)?; + print_next_steps(&config); + Ok(ExitCode::SUCCESS) +} + +fn resolve_config(top: &TopLevel, root: &Path, interactive: bool) -> Result { + let name = match &top.name { + Some(name) => name.clone(), + None if interactive => wizard::prompt("project name (snake_case)", None)?, + None => { + return Err(CliError::Usage( + "--name is required when not running interactively".to_owned(), + )); + } + }; + config::validate_name(&name)?; + + let npm_name = match &top.npm_name { + Some(npm_name) => npm_name.clone(), + None if interactive => wizard::prompt("npm package name", Some(&name))?, + None => name.clone(), + }; + config::validate_npm_name(&npm_name)?; + + let description = match &top.description { + Some(description) => description.clone(), + None if interactive => wizard::prompt("one-line description (optional)", Some(""))?, + None => String::new(), + }; + + let domain = match &top.domain { + Some(domain) => non_empty(domain), + None if interactive => non_empty(&wizard::prompt( + "custom domain like example.com (optional; sets CNAME + homepage)", + Some(""), + )?), + None => None, + }; + if let Some(domain) = &domain { + config::validate_domain(domain)?; + } + + let derived_repo = git::output(root, &["remote", "get-url", "origin"])? + .and_then(|url| git::normalize_remote_url(&url)); + let repo_url = match &top.repo { + Some(repo) => non_empty(repo), + None if interactive => non_empty(&wizard::prompt( + "repository url (optional)", + derived_repo.as_deref(), + )?), + None => derived_repo, + }; + + let (mut kept, explicit) = features::resolve(&top.keep, &top.strip)?; + if interactive { + // registry order puts parents before dependents, so `requires` is + // already decided when a dependent feature comes up + for feature in &features::FEATURES { + if explicit.contains(feature.id) { + continue; + } + if let Some(parent) = feature.requires + && !kept.contains(parent) + { + kept.remove(feature.id); + continue; + } + if wizard::prompt_bool(feature.prompt, feature.default_keep)? { + kept.insert(feature.id); + } else { + kept.remove(feature.id); + } + } + features::cascade(&mut kept); + } + + Ok(MoltConfig { + name, + npm_name, + description, + domain, + repo_url, + kept, + }) +} + +fn non_empty(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} + +fn print_next_steps(config: &MoltConfig) { + println!("\nmolt complete — {} is yours. next steps:", config.name); + println!(" git status # review what changed"); + println!(" npm i # refresh package-lock.json for the new name"); + println!(" gro check # typecheck, test, lint, format"); + if config.keeps(features::RUST) { + println!(" cargo check # refresh Cargo.lock for your crate"); + } + println!( + " git add -A && git commit -m \"chore: molt fuz_template into {}\"", + config.name + ); + println!( + "\nstatic/logo.svg and static/favicon.png still carry the template's spider — replace them when ready." + ); +} diff --git a/crates/fuz_template/src/plan.rs b/crates/fuz_template/src/plan.rs new file mode 100644 index 0000000..a662dcb --- /dev/null +++ b/crates/fuz_template/src/plan.rs @@ -0,0 +1,421 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::anchors; +use crate::config::{MoltConfig, json_escape}; +use crate::error::CliError; +use crate::features; +use crate::templates; + +/// A single filesystem transformation in a molt plan. Paths are relative to +/// the repo root. +#[derive(Debug)] +pub enum Action { + /// Replace `anchor` (which must appear exactly once) with `replacement`. + ReplaceOnce { + path: PathBuf, + anchor: String, + replacement: String, + label: String, + }, + /// Replace every occurrence of `from` (which must appear at least once). + ReplaceAll { + path: PathBuf, + from: String, + to: String, + label: String, + }, + /// Replace the whole file; every `anchor` must appear in the current + /// content (guarding against silent divergence from the template). + ReplaceFile { + path: PathBuf, + anchors: Vec, + content: String, + label: String, + }, + /// Rename a directory; `to` must not exist yet. + RenameDir { from: PathBuf, to: PathBuf }, + /// Delete a file. + DeleteFile { path: PathBuf }, + /// Delete a directory recursively. + DeleteDir { path: PathBuf }, +} + +impl Action { + pub fn describe(&self) -> String { + match self { + Self::ReplaceOnce { path, label, .. } | Self::ReplaceAll { path, label, .. } => { + format!("edit {} — {label}", path.display()) + } + Self::ReplaceFile { path, label, .. } => { + format!("rewrite {} — {label}", path.display()) + } + Self::RenameDir { from, to } => { + format!("rename {}/ → {}/", from.display(), to.display()) + } + Self::DeleteFile { path } => format!("delete {}", path.display()), + Self::DeleteDir { path } => format!("delete {}/", path.display()), + } + } +} + +fn replace_once( + path: &str, + anchor: &str, + replacement: impl Into, + label: impl Into, +) -> Action { + Action::ReplaceOnce { + path: PathBuf::from(path), + anchor: anchor.to_owned(), + replacement: replacement.into(), + label: label.into(), + } +} + +/// Builds the full molt plan from resolved choices. Pure — reads nothing. +pub fn build_plan(config: &MoltConfig) -> Vec { + let mut plan = Vec::new(); + let name = config.name.as_str(); + let npm_name = config.npm_name.as_str(); + + // package.json identity + plan.push(replace_once( + "package.json", + anchors::PACKAGE_JSON_NAME, + format!(" \"name\": \"{}\",\n", json_escape(npm_name)), + format!("name \u{2192} {npm_name}"), + )); + let description_replacement = if config.description.is_empty() { + String::new() + } else { + format!( + " \"description\": \"{}\",\n", + json_escape(&config.description) + ) + }; + plan.push(replace_once( + "package.json", + anchors::PACKAGE_JSON_DESCRIPTION, + description_replacement, + "description", + )); + for (anchor, label) in [ + (anchors::PACKAGE_JSON_GLYPH, "remove template glyph"), + (anchors::PACKAGE_JSON_LOGO, "remove template logo"), + (anchors::PACKAGE_JSON_LOGO_ALT, "remove template logo_alt"), + ] { + plan.push(replace_once("package.json", anchor, String::new(), label)); + } + let homepage_replacement = config.domain.as_ref().map_or_else(String::new, |domain| { + format!(" \"homepage\": \"https://{domain}/\",\n") + }); + plan.push(replace_once( + "package.json", + anchors::PACKAGE_JSON_HOMEPAGE, + homepage_replacement, + "homepage", + )); + let repository_replacement = config.repo_url.as_ref().map_or_else(String::new, |url| { + format!(" \"repository\": \"{}\",\n", json_escape(url)) + }); + plan.push(replace_once( + "package.json", + anchors::PACKAGE_JSON_REPOSITORY, + repository_replacement, + "repository", + )); + + // custom domain + if let Some(domain) = &config.domain { + plan.push(Action::ReplaceFile { + path: PathBuf::from("static/CNAME"), + anchors: vec![anchors::CNAME_CONTENT.to_owned()], + content: format!("{domain}\n"), + label: format!("custom domain \u{2192} {domain}"), + }); + } else { + plan.push(Action::DeleteFile { + path: PathBuf::from("static/CNAME"), + }); + } + + // root layout: title + template logo + plan.push(replace_once( + "src/routes/+layout.svelte", + anchors::LAYOUT_LOGO_IMPORT, + String::new(), + "remove template logo import", + )); + plan.push(replace_once( + "src/routes/+layout.svelte", + anchors::LAYOUT_SITE_STATE, + anchors::LAYOUT_SITE_STATE_REPLACEMENT, + "drop template icon", + )); + plan.push(replace_once( + "src/routes/+layout.svelte", + anchors::LAYOUT_TITLE, + format!("{npm_name}"), + format!("title \u{2192} {npm_name}"), + )); + + // starter page + demo components + let docs_link = if config.keeps(features::DOCS) { + templates::PAGE_DOCS_LINK + } else { + "" + }; + plan.push(Action::ReplaceFile { + path: PathBuf::from("src/routes/+page.svelte"), + anchors: vec![ + anchors::PAGE_MREOWS_IMPORT.to_owned(), + anchors::H1_FUZ_TEMPLATE.to_owned(), + ], + content: templates::render( + templates::PAGE_SVELTE, + &[("__NAME__", name), ("__DOCS_LINK__", docs_link)], + ), + label: "minimal starter page".to_owned(), + }); + plan.push(replace_once( + "src/routes/about/+page.svelte", + anchors::H1_FUZ_TEMPLATE, + format!("

{name}

"), + format!("heading \u{2192} {name}"), + )); + plan.push(Action::DeleteFile { + path: PathBuf::from("src/lib/Mreows.svelte"), + }); + plan.push(Action::DeleteFile { + path: PathBuf::from("src/lib/Positioned.svelte"), + }); + + // docs system + if !config.keeps(features::DOCS) { + plan.push(Action::DeleteDir { + path: PathBuf::from("src/routes/docs"), + }); + plan.push(Action::DeleteFile { + path: PathBuf::from("src/routes/library.ts"), + }); + } + + // regenerated docs + let description_block = if config.description.is_empty() { + String::new() + } else { + format!("> {}\n\n", config.description) + }; + let (readme_rust, claude_rust) = if config.keeps(features::RUST) { + ( + templates::README_RUST_SECTION, + templates::CLAUDE_RUST_SECTION, + ) + } else { + ("", "") + }; + let claude_docs_bullet = if config.keeps(features::DOCS) { + templates::CLAUDE_DOCS_BULLET + } else { + "" + }; + plan.push(Action::ReplaceFile { + path: PathBuf::from("README.md"), + anchors: vec![anchors::README_H1.to_owned()], + content: templates::render( + templates::README_MD, + &[ + ("__NPM_NAME__", npm_name), + ("__DESCRIPTION_BLOCK__", &description_block), + ("__RUST_SECTION__", readme_rust), + ], + ), + label: "regenerate for the new project".to_owned(), + }); + plan.push(Action::ReplaceFile { + path: PathBuf::from("CLAUDE.md"), + anchors: vec![anchors::CLAUDE_H1.to_owned()], + content: templates::render( + templates::CLAUDE_MD, + &[ + ("__NAME__", name), + ("__DESCRIPTION_BLOCK__", &description_block), + ("__DOCS_BULLET__", claude_docs_bullet), + ("__RUST_SECTION__", claude_rust), + ], + ), + label: "regenerate for the new project (AGENTS.md symlinks here)".to_owned(), + }); + + // .github extras + if !config.keeps(features::GITHUB_EXTRAS) { + plan.push(Action::DeleteFile { + path: PathBuf::from(".github/FUNDING.yml"), + }); + plan.push(Action::DeleteDir { + path: PathBuf::from(".github/ISSUE_TEMPLATE"), + }); + } + + // the Rust workspace, and molt's own crate + if config.keeps(features::RUST) { + let members = if config.keeps(features::CLI) { + format!("\"crates/{name}\"") + } else { + String::new() + }; + plan.push(Action::ReplaceFile { + path: PathBuf::from("Cargo.toml"), + anchors: vec![anchors::WORKSPACE_MEMBERS.to_owned()], + content: templates::render( + templates::WORKSPACE_CARGO_TOML, + &[("__MEMBERS__", &members)], + ), + label: "workspace without molt's crate".to_owned(), + }); + if config.keeps(features::CLI) { + let description_replacement = if config.description.is_empty() { + String::new() + } else { + format!("description = \"{}\"\n", json_escape(&config.description)) + }; + plan.push(replace_once( + "crates/app_cli/Cargo.toml", + anchors::APP_CLI_DESCRIPTION, + description_replacement, + "description", + )); + for path in [ + "crates/app_cli/Cargo.toml", + "crates/app_cli/src/main.rs", + "crates/app_cli/src/error.rs", + ] { + plan.push(Action::ReplaceAll { + path: PathBuf::from(path), + from: anchors::APP_CLI_TOKEN.to_owned(), + to: name.to_owned(), + label: format!("{} \u{2192} {name}", anchors::APP_CLI_TOKEN), + }); + } + plan.push(Action::RenameDir { + from: PathBuf::from("crates/app_cli"), + to: PathBuf::from(format!("crates/{name}")), + }); + } else { + plan.push(Action::DeleteDir { + path: PathBuf::from("crates/app_cli"), + }); + } + plan.push(Action::DeleteDir { + path: PathBuf::from("crates/fuz_template"), + }); + } else { + plan.push(replace_once( + ".github/workflows/check.yml", + anchors::CI_RUST_JOB, + String::new(), + "remove the rust job", + )); + for path in [ + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + "clippy.toml", + ] { + plan.push(Action::DeleteFile { + path: PathBuf::from(path), + }); + } + plan.push(Action::DeleteDir { + path: PathBuf::from("crates"), + }); + } + plan.push(Action::DeleteDir { + path: PathBuf::from(".cargo"), + }); + + plan +} + +/// Verifies every action's preconditions against the tree at `root`, +/// returning human-readable issues (empty = the plan is applicable). +pub fn verify(root: &Path, plan: &[Action]) -> Result, CliError> { + let mut issues = Vec::new(); + for action in plan { + match action { + Action::ReplaceOnce { path, anchor, .. } => match read(root, path)? { + Some(content) => { + let count = content.matches(anchor.as_str()).count(); + if count != 1 { + issues.push(format!( + "{}: anchor matched {count} times (expected exactly 1): {anchor:?}", + path.display() + )); + } + } + None => issues.push(format!("{}: file missing", path.display())), + }, + Action::ReplaceAll { path, from, .. } => match read(root, path)? { + Some(content) => { + if !content.contains(from.as_str()) { + issues.push(format!( + "{}: expected occurrences of {from:?}, found none", + path.display() + )); + } + } + None => issues.push(format!("{}: file missing", path.display())), + }, + Action::ReplaceFile { path, anchors, .. } => match read(root, path)? { + Some(content) => { + for anchor in anchors { + if !content.contains(anchor.as_str()) { + issues.push(format!( + "{}: expected content not found: {anchor:?}", + path.display() + )); + } + } + } + None => issues.push(format!("{}: file missing", path.display())), + }, + Action::RenameDir { from, to } => { + if !root.join(from).is_dir() { + issues.push(format!( + "{}: expected a directory to rename", + from.display() + )); + } + if root.join(to).exists() { + issues.push(format!("{}: rename target already exists", to.display())); + } + } + Action::DeleteFile { path } => { + let full = root.join(path); + if !full.is_file() { + issues.push(format!("{}: expected a file to delete", path.display())); + } + } + Action::DeleteDir { path } => { + let full = root.join(path); + if !full.is_dir() { + issues.push(format!( + "{}: expected a directory to delete", + path.display() + )); + } + } + } + } + Ok(issues) +} + +fn read(root: &Path, path: &Path) -> Result, CliError> { + let full = root.join(path); + match fs::read_to_string(&full) { + Ok(content) => Ok(Some(content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(CliError::Io { path: full, source }), + } +} diff --git a/crates/fuz_template/src/templates.rs b/crates/fuz_template/src/templates.rs new file mode 100644 index 0000000..914a574 --- /dev/null +++ b/crates/fuz_template/src/templates.rs @@ -0,0 +1,68 @@ +//! Embedded output templates, substituted with `__PLACEHOLDER__` tokens. + +pub const PAGE_SVELTE: &str = include_str!("../templates/page.svelte.in"); +pub const README_MD: &str = include_str!("../templates/README.md.in"); +pub const CLAUDE_MD: &str = include_str!("../templates/CLAUDE.md.in"); +pub const README_RUST_SECTION: &str = include_str!("../templates/readme_rust_section.md.in"); +pub const CLAUDE_RUST_SECTION: &str = include_str!("../templates/claude_rust_section.md.in"); +pub const WORKSPACE_CARGO_TOML: &str = include_str!("../templates/workspace_cargo.toml.in"); + +/// The starter page's docs link, substituted only when docs are kept. +pub const PAGE_DOCS_LINK: &str = " \u{b7} docs"; +/// The generated CLAUDE.md's docs bullet, substituted only when docs are kept. +pub const CLAUDE_DOCS_BULLET: &str = "- `src/routes/docs/` \u{2014} documentation pages with auto-generated API docs from\n the `svelte-docinfo` Vite plugin\n"; + +/// Substitutes `__PLACEHOLDER__` tokens in an embedded template. +pub fn render(template: &str, substitutions: &[(&str, &str)]) -> String { + let mut out = template.to_owned(); + for (token, value) in substitutions { + out = out.replace(token, value); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_substitutes_all_occurrences() { + assert_eq!( + render("hi __NAME__, __NAME__!", &[("__NAME__", "sam")]), + "hi sam, sam!" + ); + } + + #[test] + fn no_template_placeholders_leak() { + // Removing every known placeholder must leave no `__` behind, so a + // typo'd token in a template fails here instead of leaking into output. + let known = [ + "__NAME__", + "__NPM_NAME__", + "__DESCRIPTION_BLOCK__", + "__RUST_SECTION__", + "__DOCS_LINK__", + "__DOCS_BULLET__", + "__MEMBERS__", + ]; + for template in [ + PAGE_SVELTE, + README_MD, + CLAUDE_MD, + README_RUST_SECTION, + CLAUDE_RUST_SECTION, + WORKSPACE_CARGO_TOML, + ] { + let mut stripped = template.to_owned(); + for token in known { + stripped = stripped.replace(token, ""); + } + assert!( + !stripped.contains("__"), + "unknown __ token in template: {:?}", + &stripped[stripped.find("__").unwrap_or(0)..] + ); + } + } +} diff --git a/crates/fuz_template/src/wizard.rs b/crates/fuz_template/src/wizard.rs new file mode 100644 index 0000000..e5fcba9 --- /dev/null +++ b/crates/fuz_template/src/wizard.rs @@ -0,0 +1,52 @@ +use std::io::{self, BufRead as _, IsTerminal as _, Write as _}; + +use crate::error::CliError; + +/// Whether both stdin and stdout are terminals, enabling the wizard. +pub fn interactive() -> bool { + io::stdin().is_terminal() && io::stdout().is_terminal() +} + +/// Prompts for a line of input; empty input (or EOF) selects `default`. +pub fn prompt(label: &str, default: Option<&str>) -> Result { + let mut stdout = io::stdout().lock(); + match default { + Some(d) if !d.is_empty() => write!(stdout, "{label} [{d}]: ")?, + _ => write!(stdout, "{label}: ")?, + } + stdout.flush()?; + drop(stdout); + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + let trimmed = line.trim(); + Ok(if trimmed.is_empty() { + default.unwrap_or("").to_owned() + } else { + trimmed.to_owned() + }) +} + +/// Prompts for a yes/no answer; empty input (or EOF) selects `default`. +pub fn prompt_bool(label: &str, default: bool) -> Result { + let suffix = if default { "[Y/n]" } else { "[y/N]" }; + loop { + let mut stdout = io::stdout().lock(); + write!(stdout, "{label} {suffix}: ")?; + stdout.flush()?; + drop(stdout); + let mut line = String::new(); + let bytes_read = io::stdin().lock().read_line(&mut line)?; + let answer = line.trim().to_ascii_lowercase(); + if bytes_read == 0 || answer.is_empty() { + return Ok(default); + } + match answer.as_str() { + "y" | "yes" => return Ok(true), + "n" | "no" => return Ok(false), + _ => { + let mut stdout = io::stdout().lock(); + writeln!(stdout, "please answer y or n")?; + } + } + } +} diff --git a/crates/fuz_template/templates/CLAUDE.md.in b/crates/fuz_template/templates/CLAUDE.md.in new file mode 100644 index 0000000..5e70027 --- /dev/null +++ b/crates/fuz_template/templates/CLAUDE.md.in @@ -0,0 +1,36 @@ +# __NAME__ + +__DESCRIPTION_BLOCK____NAME__ is a static web app built with the fuz stack: +TypeScript, Svelte 5 (runes), SvelteKit with the static adapter, fuz_css, +fuz_ui, and Gro as the task runner. + +## Gro commands + +```bash +gro check # typecheck, test, lint, format check (run before committing) +gro typecheck # typecheck only (faster iteration) +gro test # run tests with vitest +gro build # build for production (static adapter) +gro deploy # build, commit, and push to deploy branch +gro sync # regenerate files and run svelte-kit sync +``` + +IMPORTANT for AI agents: Do NOT run `gro dev` — the developer manages the +dev server. + +## Architecture + +- `src/routes/` — SvelteKit routes; `+layout.ts` exports `prerender = true` + for full static generation +- `src/lib/` — library code (components, utilities) +__DOCS_BULLET__- fuz_css utility classes are generated on demand by the + `vite_plugin_fuz_css` Vite plugin via the `virtual:fuz.css` module +- Dark/light theme detection runs in `src/app.html` before render to prevent + theme flash + +## Project standards + +- TypeScript strict mode +- Svelte 5 with runes API +- Prettier with tabs, 100 char width +__RUST_SECTION__ \ No newline at end of file diff --git a/crates/fuz_template/templates/README.md.in b/crates/fuz_template/templates/README.md.in new file mode 100644 index 0000000..d3f2f30 --- /dev/null +++ b/crates/fuz_template/templates/README.md.in @@ -0,0 +1,37 @@ +# __NPM_NAME__ + +__DESCRIPTION_BLOCK__Built with the [fuz stack](https://www.fuz.dev/) from +[fuz_template](https://github.com/fuzdev/fuz_template): TypeScript, +[Svelte](https://github.com/sveltejs/svelte), +[SvelteKit](https://github.com/sveltejs/kit) with the static adapter, +[fuz_css](https://github.com/fuzdev/fuz_css), +[fuz_ui](https://github.com/fuzdev/fuz_ui), +and [Gro](https://github.com/fuzdev/gro). + +## usage + +```bash +npm i +npm run dev # or `gro dev` +``` + +## check + +```bash +gro check # typecheck, test, lint, format check +``` + +## build + +```bash +gro build +``` + +## deploy + +Build, commit, and push to the `deploy` branch, e.g. for GitHub Pages: + +```bash +gro deploy +``` +__RUST_SECTION__ \ No newline at end of file diff --git a/crates/fuz_template/templates/claude_rust_section.md.in b/crates/fuz_template/templates/claude_rust_section.md.in new file mode 100644 index 0000000..90c126d --- /dev/null +++ b/crates/fuz_template/templates/claude_rust_section.md.in @@ -0,0 +1,19 @@ + +## Rust workspace + +The repo doubles as a Rust workspace: root `Cargo.toml` with the fuz +ecosystem's canonical lints (`unsafe_code = "forbid"`, clippy +pedantic/nursery at warn) and release profile, crates under `crates/`, and +the toolchain pinned in `rust-toolchain.toml`. Do not use Gro for the Rust +side — run cargo directly: + +```bash +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --check +``` + +CI runs these in the `rust` job of `.github/workflows/check.yml`. Errors +follow thiserror enums with `.exit_code()`/`.hint()` and +`fn main() -> ExitCode`; exit codes: `0` success, `2` caller-must-fix, +`1` everything else. diff --git a/crates/fuz_template/templates/page.svelte.in b/crates/fuz_template/templates/page.svelte.in new file mode 100644 index 0000000..3359a1a --- /dev/null +++ b/crates/fuz_template/templates/page.svelte.in @@ -0,0 +1,13 @@ + + +
+
+

__NAME__

+

Welcome to __NAME__ — this page is yours to replace.

+

+ about__DOCS_LINK__ +

+
+
diff --git a/crates/fuz_template/templates/readme_rust_section.md.in b/crates/fuz_template/templates/readme_rust_section.md.in new file mode 100644 index 0000000..323ecf7 --- /dev/null +++ b/crates/fuz_template/templates/readme_rust_section.md.in @@ -0,0 +1,12 @@ + +## rust + +The repo doubles as a Rust workspace (`Cargo.toml`, `crates/`) sharing the +fuz ecosystem's lints and release profile, with the toolchain pinned in +`rust-toolchain.toml`: + +```bash +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --check +``` diff --git a/crates/fuz_template/templates/workspace_cargo.toml.in b/crates/fuz_template/templates/workspace_cargo.toml.in new file mode 100644 index 0000000..575ce56 --- /dev/null +++ b/crates/fuz_template/templates/workspace_cargo.toml.in @@ -0,0 +1,55 @@ +[workspace] +resolver = "2" +members = [__MEMBERS__] + +[workspace.package] +version = "0.0.1" +edition = "2024" +license = "MIT" +publish = false + +[workspace.dependencies] +argh = "0.1" +thiserror = "2" + +[workspace.lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" +trivial_casts = "warn" +trivial_numeric_casts = "warn" +unused_lifetimes = "warn" +unused_qualifications = "warn" + +[workspace.lints.clippy] +# Enable lint groups (priority -1 so individual lints can override) +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } + +# Pedantic overrides +module_name_repetitions = "allow" +must_use_candidate = "allow" +similar_names = "allow" +too_many_lines = "allow" + +# Nursery overrides +significant_drop_tightening = "allow" + +# Cargo overrides (private repos) +cargo_common_metadata = "allow" +multiple_crate_versions = "allow" + +# Restriction lints (panic points need explicit #[allow] with justification) +clone_on_ref_ptr = "warn" +dbg_macro = "warn" +expect_used = "warn" +panic = "warn" +todo = "warn" +unwrap_used = "warn" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ae5f984 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +# Pinned because clippy's nursery lints are warn-level and CI denies warnings — +# a floating toolchain would break the build on new lints. +[toolchain] +channel = "1.94.1" +components = ["rustfmt", "clippy"] From dbf380f8a4493021f256a53e4582924ce49249f8 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Mon, 6 Jul 2026 15:52:38 -0400 Subject: [PATCH 2/8] wetrun --- CLAUDE.md | 5 ++ README.md | 14 ++--- crates/fuz_template/src/main.rs | 92 +++++++++++++++++++++++++++++---- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3ac455f..305dbd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,11 @@ Key behaviors: - **Requires a git repo with a clean tree** (exit 2 otherwise; `--force` overrides dirty, nothing overrides no-git) so it's always undoable. + Applying to a dirty tree (only reachable via `--force`) always demands an + in-the-moment interactive confirmation — `--wetrun` alone never skips it, + and without a terminal the dirty apply is refused (exit 2). The only + ungated write path is `--wetrun` on a clean tree, where `git checkout` is + a full undo. - **Plan-then-apply**: every file edit is anchored on exact current content; one unmatched anchor aborts before any write. Non-interactive runs write nothing without `--wetrun`. diff --git a/README.md b/README.md index 31d396c..7d168ff 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,9 @@ and [`package.json` value](https://spdx.org/licenses/), like `"license": "MIT"`. ## molt -The template doubles as a Rust workspace, and its one crate is `molt` — a -one-shot wizard that transforms this clone into your own project, then deletes -itself. Like a spider shedding its template skin. 🕷 +The template doubles as a Rust workspace, and `crates/fuz_template` is +`molt` — a one-shot wizard that transforms this clone into your own project, +then deletes itself. Like a spider shedding its template skin. 🕷 ```bash cargo molt # interactive wizard: prompts, prints the full plan, confirms @@ -128,9 +128,11 @@ What it does, driven by your answers: - deletes its own crate It refuses to run without a clean git tree, so undo is always -`git checkout .` away. Run with flags instead of prompts for non-interactive -use (`cargo molt --help`); in that mode nothing is written without -`--wetrun`. +`git checkout .` away. `--force` lets a dirty tree through for _planning_, +but applying to a dirty tree always requires an interactive confirmation — +without a terminal it refuses (exit 2), because there'd be no clean undo +point. Run with flags instead of prompts for non-interactive use +(`cargo molt --help`); in that mode nothing is written without `--wetrun`. ## build diff --git a/crates/fuz_template/src/main.rs b/crates/fuz_template/src/main.rs index 56e5f0a..580739b 100644 --- a/crates/fuz_template/src/main.rs +++ b/crates/fuz_template/src/main.rs @@ -81,6 +81,37 @@ fn locate_root() -> Result { } } +/// What stands between a printed plan and applying it, given the run mode. +/// +/// The one combination with no gate at all is `--wetrun` on a clean tree — +/// there `git checkout` is always a full undo. A dirty tree (reachable only +/// via `--force`) never applies without an in-the-moment confirmation, and +/// without a terminal it never applies at all: "commit first" is always +/// available, so an override flag would just recreate the hole. +#[derive(Debug, PartialEq, Eq)] +enum ApplyGate { + /// Apply without further confirmation (clean tree + `--wetrun`). + Apply, + /// Ask the standard confirm prompt. + Confirm, + /// Ask a scarier prompt: applying with no clean undo point. + ConfirmDirty, + /// Print the dry-run note and stop. + DryRun, + /// Refuse: destructive apply on a dirty tree needs a terminal (exit 2). + RefuseDirty, +} + +const fn apply_gate(wetrun: bool, clean: bool, interactive: bool) -> ApplyGate { + match (wetrun, clean, interactive) { + (true, true, _) => ApplyGate::Apply, + (true, false, true) => ApplyGate::ConfirmDirty, + (true, false, false) => ApplyGate::RefuseDirty, + (false, _, true) => ApplyGate::Confirm, + (false, _, false) => ApplyGate::DryRun, + } +} + fn molt(top: &TopLevel, root: &Path) -> Result { let interactive = wizard::interactive(); @@ -98,6 +129,14 @@ fn molt(top: &TopLevel, root: &Path) -> Result { Some("commit or stash your changes, or pass --force to proceed anyway"), )); } + let gate = apply_gate(top.wetrun, clean, interactive); + if gate == ApplyGate::RefuseDirty { + // refuse before prompting/planning — this is an invocation problem + return Err(CliError::precondition( + "refusing to apply to a dirty git tree without a terminal — there would be no clean undo point", + Some("commit or stash first, or run interactively to confirm the dirty apply"), + )); + } let config = resolve_config(top, root, interactive)?; let plan = plan::build_plan(&config); @@ -111,17 +150,28 @@ fn molt(top: &TopLevel, root: &Path) -> Result { println!(" {}", action.describe()); } - let apply_now = if top.wetrun { - true - } else if interactive { - println!(); - wizard::prompt_bool( - "apply this plan? the template becomes your project and molt deletes itself", - false, - )? - } else { - println!("\ndry run — nothing written. pass --wetrun to apply."); - false + let apply_now = match gate { + ApplyGate::Apply => true, + ApplyGate::ConfirmDirty => { + println!(); + wizard::prompt_bool( + "the git tree is DIRTY — apply anyway, with no clean undo point?", + false, + )? + } + ApplyGate::Confirm => { + println!(); + wizard::prompt_bool( + "apply this plan? the template becomes your project and molt deletes itself", + false, + )? + } + ApplyGate::DryRun => { + println!("\ndry run — nothing written. pass --wetrun to apply."); + false + } + // handled above, before planning + ApplyGate::RefuseDirty => unreachable!(), }; if !apply_now { return Ok(ExitCode::SUCCESS); @@ -238,3 +288,23 @@ fn print_next_steps(config: &MoltConfig) { "\nstatic/logo.svg and static/favicon.png still carry the template's spider — replace them when ready." ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_gate_only_clean_wetrun_applies_ungated() { + assert_eq!(apply_gate(true, true, true), ApplyGate::Apply); + assert_eq!(apply_gate(true, true, false), ApplyGate::Apply); + // a dirty tree never applies without an in-the-moment confirmation + assert_eq!(apply_gate(true, false, true), ApplyGate::ConfirmDirty); + // ...and never at all without a terminal + assert_eq!(apply_gate(true, false, false), ApplyGate::RefuseDirty); + // without --wetrun nothing is written regardless + assert_eq!(apply_gate(false, true, true), ApplyGate::Confirm); + assert_eq!(apply_gate(false, false, true), ApplyGate::Confirm); + assert_eq!(apply_gate(false, true, false), ApplyGate::DryRun); + assert_eq!(apply_gate(false, false, false), ApplyGate::DryRun); + } +} From 6de2425be7fa78e0f2e03af2d2eacf6c74afe15f Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Mon, 6 Jul 2026 16:47:12 -0400 Subject: [PATCH 3/8] wip --- CLAUDE.md | 41 +++++++++++------- README.md | 10 +++-- crates/app_cli/src/main.rs | 3 +- crates/fuz_template/src/check.rs | 25 ++++++++++- crates/fuz_template/src/features.rs | 22 ++++++++++ crates/fuz_template/src/main.rs | 33 ++++++++++++--- crates/fuz_template/src/plan.rs | 64 +++++++++++++---------------- 7 files changed, 133 insertions(+), 65 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 305dbd3..1f6ef04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,10 +77,15 @@ cargo molt - `src/routes/+layout.svelte` - ``, template logo - `src/routes/+page.svelte` - replace demo content - `src/routes/about/+page.svelte` - heading +- `src/lib/Mreows.svelte` and `src/lib/Positioned.svelte` - deleted (demo components) - `static/CNAME` - update or delete for your domain - `.github/FUNDING.yml` and `.github/ISSUE_TEMPLATE/` - update or delete - `README.md` and `CLAUDE.md` - regenerate for the new project +Not customized: `static/logo.svg` and `static/favicon.png` keep the template's +spider (molt prints a reminder), and `package-lock.json` keeps the old name +until you run `npm i`. + ## molt (self-eject CLI) `crates/fuz_template` is molt — a one-shot wizard that personalizes the @@ -97,32 +102,38 @@ Key behaviors: - **Requires a git repo with a clean tree** (exit 2 otherwise; `--force` overrides dirty, nothing overrides no-git) so it's always undoable. - Applying to a dirty tree (only reachable via `--force`) always demands an - in-the-moment interactive confirmation — `--wetrun` alone never skips it, - and without a terminal the dirty apply is refused (exit 2). The only - ungated write path is `--wetrun` on a clean tree, where `git checkout` is - a full undo. + Applying to a dirty tree (only reachable via `--force`) always demands the + dirty-specific in-the-moment confirmation — on the `--wetrun` path and the + wizard path alike; `--wetrun` alone never skips it, and without a terminal + the dirty apply is refused (exit 2). The only ungated write path is + `--wetrun` on a clean tree, where `git checkout` is a full undo. - **Plan-then-apply**: every file edit is anchored on exact current content; one unmatched anchor aborts before any write. Non-interactive runs write nothing without `--wetrun`. - **Feature registry**: every optional feature is a keep/strip choice — - `rust` (the whole workspace: `Cargo.toml`, `crates/`, `.cargo/`, + `rust` (the whole workspace: `Cargo.toml`, `Cargo.lock`, `crates/`, `rust-toolchain.toml`, `clippy.toml`, and the `rust` CI job), `cli` (the starter crate `crates/app_cli`, renamed to `crates/{name}` with every `app_cli` occurrence substituted), `docs` (`src/routes/docs/` + `src/routes/library.ts` + the starter page's docs link), and `github-extras` (FUNDING.yml + issue templates; the only default-strip). One prompt each in the wizard, or `--keep`/`--strip` id lists - (comma-separated or repeated). Stripping `rust` cascades to `cli`. The - registry lives in `crates/fuz_template/src/features.rs` — new features are - one entry + a plan fragment there. + (comma-separated or repeated). Stripping `rust` cascades to `cli`; + stripping `cli` while keeping `rust` is rejected (cargo can't load an + empty workspace) — declining the starter crate in the wizard strips + `rust` too, with a printed note. `.cargo/` (which holds only the + `cargo molt` alias) is deleted unconditionally. The registry lives in + `crates/fuz_template/src/features.rs` — new features are one entry + a + plan fragment there. - **Identity fields**: name (required), npm name, description, domain, repo url — derived from the git origin when it isn't the template's. - **Self-verifying**: `cargo molt check` and the crate's tests verify every - anchor against the working tree, so a template edit that would break - ejection fails `cargo test` (and CI) at the same commit. When you edit an - anchored file, update `crates/fuz_template/src/anchors.rs` (and the - embedded templates in `crates/fuz_template/templates/`) in the same change. + anchor against the working tree — and that the embedded workspace manifest + template stays byte-identical to the live root `Cargo.toml` apart from the + members line — so a template edit that would break ejection fails + `cargo test` (and CI) at the same commit. When you edit an anchored file, + update `crates/fuz_template/src/anchors.rs` (and the embedded templates in + `crates/fuz_template/templates/`) in the same change. ## Rust workspace @@ -191,8 +202,8 @@ Replace these with your actual components. - `+layout.ts` exports `prerender = true` and `ssr = true` for full static generation -- `svelte.config.js` enables runes mode and configures CSP via - `create_csp_directives()` from fuz_ui +- `svelte.config.js` enables runes mode and includes a commented-out example + CSP config using `create_csp_directives()` from fuz_ui - Uses `@sveltejs/adapter-static` for static output ### Theme detection diff --git a/README.md b/README.md index 7d168ff..b3f5462 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,13 @@ It renames the project, strips the demo components, and deletes itself — see Prefer to do it by hand (or skip installing Rust)? Change `@fuzdev/fuz_template` and `template.fuz.dev` to your project name in the following files: -- [`package.json`](package.json) +- [`package.json`](package.json) — also remove or replace the `glyph`, + `logo`, and `logo_alt` fields - [`src/routes/+layout.svelte`](src/routes/+layout.svelte) - [`src/routes/+page.svelte`](src/routes/+page.svelte) - [`src/routes/about/+page.svelte`](src/routes/about/+page.svelte) -- update or delete [`static/CNAME`](static/CNAME) - and ./.github/FUNDING.yml +- update or delete [`static/CNAME`](static/CNAME), + ./.github/FUNDING.yml, and ./.github/ISSUE_TEMPLATE/ And to remove the Rust workspace, delete `Cargo.toml`, `Cargo.lock`, `crates/`, `.cargo/`, `rust-toolchain.toml`, `clippy.toml`, and the `rust` job in @@ -124,7 +125,8 @@ What it does, driven by your answers: `cli` (the starter crate at [`crates/app_cli`](crates/app_cli), renamed to your project name), `docs` (the docs system), and `github-extras` (funding + issue templates) — via prompts or - `--keep`/`--strip` lists (e.g. `--strip rust` or `--keep github-extras --strip docs`) + `--keep`/`--strip` lists (e.g. `--strip rust` or `--keep github-extras --strip docs`); + stripping `cli` alone is rejected — a kept workspace needs a crate - deletes its own crate It refuses to run without a clean git tree, so undo is always diff --git a/crates/app_cli/src/main.rs b/crates/app_cli/src/main.rs index 56c424a..8e92bcc 100644 --- a/crates/app_cli/src/main.rs +++ b/crates/app_cli/src/main.rs @@ -1,5 +1,4 @@ -//! The starter CLI: replace this with your program. molt renames the crate -//! (and every `app_cli` occurrence) to your project name. +//! The starter CLI: replace this with your program. mod error; diff --git a/crates/fuz_template/src/check.rs b/crates/fuz_template/src/check.rs index 425fdd1..b99c497 100644 --- a/crates/fuz_template/src/check.rs +++ b/crates/fuz_template/src/check.rs @@ -1,11 +1,14 @@ use std::collections::BTreeSet; +use std::fs; use std::path::Path; use std::process::ExitCode; +use crate::anchors; use crate::config::MoltConfig; use crate::error::CliError; use crate::features; use crate::plan::{build_plan, verify}; +use crate::templates; /// Runs `molt check`: verifies every anchor against the tree at `root`. pub fn run(root: &Path) -> Result<ExitCode, CliError> { @@ -18,18 +21,36 @@ pub fn run(root: &Path) -> Result<ExitCode, CliError> { for issue in &issues { eprintln!(" {issue}"); } - eprintln!("(update crates/fuz_template/src/anchors.rs in the same change)"); + eprintln!("(update crates/fuz_template/src/anchors.rs or templates/ in the same change)"); Ok(ExitCode::FAILURE) } } /// Verifies the plans for both sample configs, covering every anchor molt can -/// touch (each feature exercised kept in one config and stripped in the other). +/// touch (each feature exercised kept in one config and stripped in the other), +/// plus the embedded-template invariants that anchors alone can't see. pub fn check_all(root: &Path) -> Result<Vec<String>, CliError> { let mut issues = Vec::new(); for config in sample_configs() { issues.extend(verify(root, &build_plan(&config))?); } + // the embedded workspace template must stay byte-identical to the live + // root Cargo.toml apart from the members line — otherwise an edit to the + // live lints/profile/deps would silently ship a stale workspace to every + // molted project while the members anchor still matched + let live_path = root.join("Cargo.toml"); + let live = fs::read_to_string(&live_path).map_err(|source| CliError::Io { + path: live_path, + source, + })?; + let rendered = templates::WORKSPACE_CARGO_TOML + .replace("members = [__MEMBERS__]", anchors::WORKSPACE_MEMBERS); + if live != rendered { + issues.push( + "Cargo.toml: drifted from crates/fuz_template/templates/workspace_cargo.toml.in (only the members line may differ)" + .to_owned(), + ); + } issues.sort(); issues.dedup(); Ok(issues) diff --git a/crates/fuz_template/src/features.rs b/crates/fuz_template/src/features.rs index 1b88ba7..b323de6 100644 --- a/crates/fuz_template/src/features.rs +++ b/crates/fuz_template/src/features.rs @@ -130,6 +130,16 @@ pub fn cascade(kept: &mut BTreeSet<&'static str>) { } } +/// Features that contribute workspace member crates. A kept `rust` needs at +/// least one — cargo refuses to load a virtual manifest with no members. +const CRATE_FEATURES: [&str; 1] = [CLI]; + +/// Whether `kept` keeps the Rust workspace with no member crates — an invalid +/// combination the caller must reject (or repair by stripping `rust` too). +pub fn rust_without_crates(kept: &BTreeSet<&'static str>) -> bool { + kept.contains(RUST) && !CRATE_FEATURES.iter().any(|id| kept.contains(id)) +} + #[cfg(test)] mod tests { use super::*; @@ -167,4 +177,16 @@ mod tests { assert!(resolve(&strings(&["cli"]), &strings(&["rust"])).is_err()); assert!(resolve(&strings(&["nope"]), &[]).is_err()); } + + #[test] + fn rust_without_crates_detection() { + let (kept, _) = resolve(&[], &[]).unwrap(); + assert!(!rust_without_crates(&kept)); + let (kept, _) = resolve(&[], &strings(&["rust"])).unwrap(); + assert!(!rust_without_crates(&kept)); + // stripping the last crate feature while keeping rust is the invalid + // combination `resolve_config` rejects + let (kept, _) = resolve(&[], &strings(&["cli"])).unwrap(); + assert!(rust_without_crates(&kept)); + } } diff --git a/crates/fuz_template/src/main.rs b/crates/fuz_template/src/main.rs index 580739b..4a43fa0 100644 --- a/crates/fuz_template/src/main.rs +++ b/crates/fuz_template/src/main.rs @@ -85,7 +85,8 @@ fn locate_root() -> Result<PathBuf, CliError> { /// /// The one combination with no gate at all is `--wetrun` on a clean tree — /// there `git checkout` is always a full undo. A dirty tree (reachable only -/// via `--force`) never applies without an in-the-moment confirmation, and +/// via `--force`) never applies without the dirty-specific in-the-moment +/// confirmation — on the `--wetrun` path and the wizard path alike — and /// without a terminal it never applies at all: "commit first" is always /// available, so an override flag would just recreate the hole. #[derive(Debug, PartialEq, Eq)] @@ -105,9 +106,9 @@ enum ApplyGate { const fn apply_gate(wetrun: bool, clean: bool, interactive: bool) -> ApplyGate { match (wetrun, clean, interactive) { (true, true, _) => ApplyGate::Apply, - (true, false, true) => ApplyGate::ConfirmDirty, + (_, false, true) => ApplyGate::ConfirmDirty, (true, false, false) => ApplyGate::RefuseDirty, - (false, _, true) => ApplyGate::Confirm, + (false, true, true) => ApplyGate::Confirm, (false, _, false) => ApplyGate::DryRun, } } @@ -252,6 +253,24 @@ fn resolve_config(top: &TopLevel, root: &Path, interactive: bool) -> Result<Molt } features::cascade(&mut kept); } + // a kept rust workspace with no member crates can't build — repair the + // wizard case (both choices came from prompts), reject explicit flags + if interactive + && features::rust_without_crates(&kept) + && !explicit.contains(features::RUST) + && !explicit.contains(features::CLI) + { + println!( + "note: declining the starter crate leaves the Rust workspace empty — stripping rust too" + ); + kept.remove(features::RUST); + features::cascade(&mut kept); + } + if features::rust_without_crates(&kept) { + return Err(CliError::Usage( + "a kept Rust workspace would have no crates (cargo rejects an empty workspace) — keep cli, or strip rust too".to_owned(), + )); + } Ok(MoltConfig { name, @@ -297,13 +316,15 @@ mod tests { fn apply_gate_only_clean_wetrun_applies_ungated() { assert_eq!(apply_gate(true, true, true), ApplyGate::Apply); assert_eq!(apply_gate(true, true, false), ApplyGate::Apply); - // a dirty tree never applies without an in-the-moment confirmation + // a dirty tree never applies without the dirty-specific confirmation + // (the wizard's confirm can apply too, so it gets the same warning) assert_eq!(apply_gate(true, false, true), ApplyGate::ConfirmDirty); + assert_eq!(apply_gate(false, false, true), ApplyGate::ConfirmDirty); // ...and never at all without a terminal assert_eq!(apply_gate(true, false, false), ApplyGate::RefuseDirty); - // without --wetrun nothing is written regardless + // a clean interactive run confirms; non-interactive without --wetrun + // never writes assert_eq!(apply_gate(false, true, true), ApplyGate::Confirm); - assert_eq!(apply_gate(false, false, true), ApplyGate::Confirm); assert_eq!(apply_gate(false, true, false), ApplyGate::DryRun); assert_eq!(apply_gate(false, false, false), ApplyGate::DryRun); } diff --git a/crates/fuz_template/src/plan.rs b/crates/fuz_template/src/plan.rs index a662dcb..3b0877a 100644 --- a/crates/fuz_template/src/plan.rs +++ b/crates/fuz_template/src/plan.rs @@ -258,13 +258,11 @@ pub fn build_plan(config: &MoltConfig) -> Vec<Action> { }); } - // the Rust workspace, and molt's own crate + // the Rust workspace, and molt's own crate; `cli` is always kept here — + // `resolve_config` rejects a kept `rust` with no member crates, since + // cargo refuses to load an empty workspace if config.keeps(features::RUST) { - let members = if config.keeps(features::CLI) { - format!("\"crates/{name}\"") - } else { - String::new() - }; + let members = format!("\"crates/{name}\""); plan.push(Action::ReplaceFile { path: PathBuf::from("Cargo.toml"), anchors: vec![anchors::WORKSPACE_MEMBERS.to_owned()], @@ -274,39 +272,33 @@ pub fn build_plan(config: &MoltConfig) -> Vec<Action> { ), label: "workspace without molt's crate".to_owned(), }); - if config.keeps(features::CLI) { - let description_replacement = if config.description.is_empty() { - String::new() - } else { - format!("description = \"{}\"\n", json_escape(&config.description)) - }; - plan.push(replace_once( - "crates/app_cli/Cargo.toml", - anchors::APP_CLI_DESCRIPTION, - description_replacement, - "description", - )); - for path in [ - "crates/app_cli/Cargo.toml", - "crates/app_cli/src/main.rs", - "crates/app_cli/src/error.rs", - ] { - plan.push(Action::ReplaceAll { - path: PathBuf::from(path), - from: anchors::APP_CLI_TOKEN.to_owned(), - to: name.to_owned(), - label: format!("{} \u{2192} {name}", anchors::APP_CLI_TOKEN), - }); - } - plan.push(Action::RenameDir { - from: PathBuf::from("crates/app_cli"), - to: PathBuf::from(format!("crates/{name}")), - }); + let description_replacement = if config.description.is_empty() { + String::new() } else { - plan.push(Action::DeleteDir { - path: PathBuf::from("crates/app_cli"), + format!("description = \"{}\"\n", json_escape(&config.description)) + }; + plan.push(replace_once( + "crates/app_cli/Cargo.toml", + anchors::APP_CLI_DESCRIPTION, + description_replacement, + "description", + )); + for path in [ + "crates/app_cli/Cargo.toml", + "crates/app_cli/src/main.rs", + "crates/app_cli/src/error.rs", + ] { + plan.push(Action::ReplaceAll { + path: PathBuf::from(path), + from: anchors::APP_CLI_TOKEN.to_owned(), + to: name.to_owned(), + label: format!("{} \u{2192} {name}", anchors::APP_CLI_TOKEN), }); } + plan.push(Action::RenameDir { + from: PathBuf::from("crates/app_cli"), + to: PathBuf::from(format!("crates/{name}")), + }); plan.push(Action::DeleteDir { path: PathBuf::from("crates/fuz_template"), }); From e937c0efc98eaf0840d671909b152d4b04bd2e19 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson <mail@ryanatkn.com> Date: Mon, 6 Jul 2026 17:06:55 -0400 Subject: [PATCH 4/8] wip --- .github/workflows/check.yml | 2 + CLAUDE.md | 11 +++-- README.md | 4 +- crates/fuz_template/src/anchors.rs | 14 ++++++ crates/fuz_template/src/apply.rs | 29 +++++++++-- crates/fuz_template/src/check.rs | 24 ++++++--- crates/fuz_template/src/cli.rs | 3 +- crates/fuz_template/src/error.rs | 17 ++++--- crates/fuz_template/src/features.rs | 3 +- crates/fuz_template/src/main.rs | 52 +++++++++++++++----- crates/fuz_template/src/plan.rs | 51 +++++++++++++++++-- crates/fuz_template/src/templates.rs | 32 ++++++++++-- crates/fuz_template/src/wizard.rs | 29 +++++++++-- crates/fuz_template/templates/funding.yml.in | 3 ++ src/routes/+page.svelte | 10 ++++ 15 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 crates/fuz_template/templates/funding.yml.in diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ad78459..5b8e111 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -47,6 +47,8 @@ jobs: - run: npx @fuzdev/gro check --workspace --build rust: + # molt anchors this job (crates/fuz_template/src/anchors.rs) so stripping + # the rust feature can remove it — update the anchor when editing. runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/CLAUDE.md b/CLAUDE.md index 1f6ef04..883f669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,8 +115,11 @@ Key behaviors: `rust-toolchain.toml`, `clippy.toml`, and the `rust` CI job), `cli` (the starter crate `crates/app_cli`, renamed to `crates/{name}` with every `app_cli` occurrence substituted), `docs` (`src/routes/docs/` + - `src/routes/library.ts` + the starter page's docs link), and - `github-extras` (FUNDING.yml + issue templates; the only default-strip). + `src/routes/library.ts` + the starter page's docs link + the + svelte-docinfo tooling: devDependency, Vite plugin, ambient types), and + `github-extras` (FUNDING.yml + issue templates; the only default-strip — + kept copies are personalized with funding placeholders and discussion + links pointed at your repo url). One prompt each in the wizard, or `--keep`/`--strip` id lists (comma-separated or repeated). Stripping `rust` cascades to `cli`; stripping `cli` while keeping `rust` is rejected (cargo can't load an @@ -153,8 +156,8 @@ cargo fmt --check CI runs these in the `rust` job of `.github/workflows/check.yml`. Errors follow thiserror enums with `.exit_code()`/`.hint()` helpers and `fn main() -> ExitCode`; exit codes: `0` success, `2` caller-must-fix -(usage, preconditions), `1` everything else. Arg parsing uses argh with an -explicit `from_args` so usage errors exit `2`. +(usage, preconditions, anchor drift), `1` everything else. Arg parsing uses +argh with an explicit `from_args` so usage errors exit `2`. ## Architecture diff --git a/README.md b/README.md index b3f5462..2c10d32 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ gro sync # called by `gro dev`, refreshes generated files and calls `svelte-kit > [Vite](https://github.com/vitejs/vite), [Gro](https://github.com/fuzdev/gro), > and [fuz_ui](https://github.com/fuzdev/fuz_ui) -> [Windows will not be suported](https://github.com/fuzdev/fuz_template/issues/4) because +> [Windows will not be supported](https://github.com/fuzdev/fuz_template/issues/4) because > I chose Bash instead - Fuz recommends [WSL](https://docs.microsoft.com/en-us/windows/wsl/about) The template includes @@ -124,7 +124,7 @@ What it does, driven by your answers: - keeps or strips each optional feature — `rust` (the whole workspace), `cli` (the starter crate at [`crates/app_cli`](crates/app_cli), renamed to your project name), `docs` (the docs system), and `github-extras` - (funding + issue templates) — via prompts or + (funding + issue templates, personalized when kept) — via prompts or `--keep`/`--strip` lists (e.g. `--strip rust` or `--keep github-extras --strip docs`); stripping `cli` alone is rejected — a kept workspace needs a crate - deletes its own crate diff --git a/crates/fuz_template/src/anchors.rs b/crates/fuz_template/src/anchors.rs index 886bddd..ea3d379 100644 --- a/crates/fuz_template/src/anchors.rs +++ b/crates/fuz_template/src/anchors.rs @@ -24,6 +24,18 @@ pub const LAYOUT_TITLE: &str = "<title>@fuzdev/fuz_template"; pub const PAGE_MREOWS_IMPORT: &str = "import Mreows, {mreow_items} from '$lib/Mreows.svelte';"; pub const H1_FUZ_TEMPLATE: &str = "

fuz_template

"; +// the docs system's tooling, stripped with the `docs` feature +pub const PACKAGE_JSON_SVELTE_DOCINFO: &str = " \"svelte-docinfo\": \"^0.5.3\",\n"; +pub const VITE_DOCINFO_IMPORT: &str = "import svelte_docinfo from 'svelte-docinfo/vite.js';\n"; +pub const VITE_DOCINFO_PLUGIN: &str = "svelte_docinfo(), "; +pub const APP_D_TS_DOCINFO: &str = "// Registers ambient types for the `virtual:svelte-docinfo` module (Vite plugin).\n// eslint-disable-next-line @typescript-eslint/triple-slash-reference\n/// \n"; + +// the github extras, personalized when kept +pub const FUNDING_GITHUB: &str = "github: ryanatkn"; +/// The template's repo url as it appears in the issue-template discussion +/// links — replaced with the molted project's repo url when derivable. +pub const TEMPLATE_REPO_URL: &str = "https://github.com/fuzdev/fuz_template"; + pub const README_H1: &str = "# @fuzdev/fuz_template \u{2744}"; pub const CLAUDE_H1: &str = "# fuz_template\n"; pub const CNAME_CONTENT: &str = "template.fuz.dev"; @@ -39,6 +51,8 @@ pub const APP_CLI_DESCRIPTION: &str = "description = \"a CLI scaffolded by fuz_t /// exact-match anchor so stripping the `rust` feature can remove it. pub const CI_RUST_JOB: &str = r" rust: + # molt anchors this job (crates/fuz_template/src/anchors.rs) so stripping + # the rust feature can remove it — update the anchor when editing. runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/crates/fuz_template/src/apply.rs b/crates/fuz_template/src/apply.rs index 23ec79c..55461ed 100644 --- a/crates/fuz_template/src/apply.rs +++ b/crates/fuz_template/src/apply.rs @@ -84,6 +84,7 @@ mod tests { "package.json", "README.md", "CLAUDE.md", + "vite.config.ts", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", @@ -162,10 +163,24 @@ mod tests { assert!(claude.contains("## Rust workspace")); assert!(claude.contains("src/routes/docs")); - assert!(!dir.join(".github/FUNDING.yml").exists()); - assert!(!dir.join(".github/ISSUE_TEMPLATE").exists()); + // github-extras kept: personalized, never the template author's links + let funding = read(&dir, ".github/FUNDING.yml"); + assert!(!funding.contains("ryanatkn")); + assert!(funding.contains("your-github-username")); + let issue_config = read(&dir, ".github/ISSUE_TEMPLATE/config.yml"); + assert!( + issue_config.contains("https://github.com/sample/sample_app/discussions/new/choose") + ); + assert!(!issue_config.contains("fuzdev/fuz_template")); + assert!( + !read(&dir, ".github/ISSUE_TEMPLATE/preapproved.md").contains("fuzdev/fuz_template") + ); assert!(read(&dir, ".github/workflows/check.yml").contains("cargo clippy")); + // docs kept: the svelte-docinfo tooling stays + assert!(package_json.contains("svelte-docinfo")); + assert!(read(&dir, "vite.config.ts").contains("svelte_docinfo()")); + let workspace = read(&dir, "Cargo.toml"); assert!(workspace.contains("members = [\"crates/sample_app\"]")); assert!(!dir.join("crates/fuz_template").exists()); @@ -210,15 +225,19 @@ mod tests { assert!(!workflow.contains("cargo")); assert!(workflow.contains("npx @fuzdev/gro check")); - // docs stripped + // docs stripped, along with the svelte-docinfo tooling assert!(!dir.join("src/routes/docs").exists()); assert!(!dir.join("src/routes/library.ts").exists()); let page = read(&dir, "src/routes/+page.svelte"); assert!(!page.contains("resolve('/docs')")); assert!(page.contains("resolve('/about')")); + assert!(!package_json.contains("svelte-docinfo")); + assert!(!read(&dir, "vite.config.ts").contains("svelte_docinfo")); + assert!(!read(&dir, "src/app.d.ts").contains("svelte-docinfo")); - // extras kept in this sample - assert!(dir.join(".github/FUNDING.yml").exists()); + // extras stripped in this sample + assert!(!dir.join(".github/FUNDING.yml").exists()); + assert!(!dir.join(".github/ISSUE_TEMPLATE").exists()); let claude = read(&dir, "CLAUDE.md"); assert!(!read(&dir, "README.md").contains("## rust")); diff --git a/crates/fuz_template/src/check.rs b/crates/fuz_template/src/check.rs index b99c497..de1983a 100644 --- a/crates/fuz_template/src/check.rs +++ b/crates/fuz_template/src/check.rs @@ -10,14 +10,17 @@ use crate::features; use crate::plan::{build_plan, verify}; use crate::templates; -/// Runs `molt check`: verifies every anchor against the tree at `root`. +/// Runs `molt check`: verifies every anchor and embedded-template invariant +/// against the tree at `root`. pub fn run(root: &Path) -> Result { let issues = check_all(root)?; if issues.is_empty() { - println!("molt check passed: all anchors match the template"); + println!("molt check passed: all anchors and embedded templates match"); Ok(ExitCode::SUCCESS) } else { - eprintln!("molt check failed — the template drifted from molt's anchors:"); + eprintln!( + "molt check failed — the template drifted from molt's anchors or embedded templates:" + ); for issue in &issues { eprintln!(" {issue}"); } @@ -56,9 +59,9 @@ pub fn check_all(root: &Path) -> Result, CliError> { Ok(issues) } -/// Two configs that together exercise every plan branch: one keeps -/// rust/cli/docs and sets every optional value, one strips them and clears -/// the optional values (while keeping the github extras). +/// Two configs that together exercise every plan branch: one keeps every +/// feature and sets every optional value, one strips every feature and +/// clears the optional values. pub fn sample_configs() -> [MoltConfig; 2] { [ MoltConfig { @@ -67,7 +70,12 @@ pub fn sample_configs() -> [MoltConfig; 2] { description: "a sample app".to_owned(), domain: Some("sample.example.com".to_owned()), repo_url: Some("https://github.com/sample/sample_app".to_owned()), - kept: BTreeSet::from([features::RUST, features::CLI, features::DOCS]), + kept: BTreeSet::from([ + features::RUST, + features::CLI, + features::DOCS, + features::GITHUB_EXTRAS, + ]), }, MoltConfig { name: "plain_app".to_owned(), @@ -75,7 +83,7 @@ pub fn sample_configs() -> [MoltConfig; 2] { description: String::new(), domain: None, repo_url: None, - kept: BTreeSet::from([features::GITHUB_EXTRAS]), + kept: BTreeSet::new(), }, ] } diff --git a/crates/fuz_template/src/cli.rs b/crates/fuz_template/src/cli.rs index ab8e415..e0ef773 100644 --- a/crates/fuz_template/src/cli.rs +++ b/crates/fuz_template/src/cli.rs @@ -54,7 +54,8 @@ pub enum Subcommand { Check(CheckCommand), } -/// Verify molt's file anchors still match the template (used by CI and tests). +/// Verify molt's anchors and embedded templates still match the template +/// (used by CI and tests). #[derive(FromArgs, Debug)] #[argh(subcommand, name = "check")] pub struct CheckCommand {} diff --git a/crates/fuz_template/src/error.rs b/crates/fuz_template/src/error.rs index 72446b4..6ddfd9a 100644 --- a/crates/fuz_template/src/error.rs +++ b/crates/fuz_template/src/error.rs @@ -5,7 +5,8 @@ use thiserror::Error; /// Errors surfaced by molt. /// /// Exit-code dialect: `0` success; `2` = the caller must change something -/// local (arguments, git state) before retrying; `1` = everything else. +/// local (arguments, git state, modified files) before retrying; `1` = +/// everything else. #[derive(Debug, Error)] pub enum CliError { /// The invocation itself is wrong (bad flag values, missing required args). @@ -19,8 +20,9 @@ pub enum CliError { hint: Option<&'static str>, }, - /// The template's files no longer match molt's anchors — a template bug, - /// not something the caller can fix. + /// The tree no longer matches molt's anchors — usually the clone was + /// hand-edited (restorable by the caller), else the template itself + /// drifted (`molt check` reports that as its own failure). #[error("template drift detected:\n{0}")] Drift(String), @@ -48,8 +50,8 @@ impl CliError { /// Maps the error to its stable exit code. pub const fn exit_code(&self) -> u8 { match self { - Self::Usage(_) | Self::Precondition { .. } => 2, - Self::Drift(_) | Self::Io { .. } | Self::Terminal(_) => 1, + Self::Usage(_) | Self::Precondition { .. } | Self::Drift(_) => 2, + Self::Io { .. } | Self::Terminal(_) => 1, } } @@ -57,6 +59,9 @@ impl CliError { pub const fn hint(&self) -> Option<&'static str> { match self { Self::Precondition { hint, .. } => *hint, + Self::Drift(_) => { + Some("restore the listed files (e.g. git checkout) or run from a fresh clone") + } _ => None, } } @@ -70,7 +75,7 @@ mod tests { fn exit_codes_are_stable() { assert_eq!(CliError::Usage(String::new()).exit_code(), 2); assert_eq!(CliError::precondition("x", None).exit_code(), 2); - assert_eq!(CliError::Drift(String::new()).exit_code(), 1); + assert_eq!(CliError::Drift(String::new()).exit_code(), 2); assert_eq!( CliError::Io { path: PathBuf::new(), diff --git a/crates/fuz_template/src/features.rs b/crates/fuz_template/src/features.rs index b323de6..283f4d9 100644 --- a/crates/fuz_template/src/features.rs +++ b/crates/fuz_template/src/features.rs @@ -3,7 +3,8 @@ //! Every molt-selectable feature is one entry here: a wizard prompt, a //! `--keep`/`--strip` id, and a plan fragment in `plan.rs` keyed off the //! resolved set. Adding a feature means adding a registry entry, its plan -//! fragment, and covering its anchors in `check.rs`'s sample configs. +//! fragment, and covering its anchors in `check.rs`'s sample configs — and +//! joining `CRATE_FEATURES` if it contributes a workspace member crate. use std::collections::BTreeSet; diff --git a/crates/fuz_template/src/main.rs b/crates/fuz_template/src/main.rs index 4a43fa0..6715fe2 100644 --- a/crates/fuz_template/src/main.rs +++ b/crates/fuz_template/src/main.rs @@ -185,22 +185,30 @@ fn molt(top: &TopLevel, root: &Path) -> Result { fn resolve_config(top: &TopLevel, root: &Path, interactive: bool) -> Result { let name = match &top.name { - Some(name) => name.clone(), - None if interactive => wizard::prompt("project name (snake_case)", None)?, + Some(name) => { + config::validate_name(name)?; + name.clone() + } + None if interactive => { + wizard::prompt_validated("project name (snake_case)", None, config::validate_name)? + } None => { return Err(CliError::Usage( "--name is required when not running interactively".to_owned(), )); } }; - config::validate_name(&name)?; let npm_name = match &top.npm_name { - Some(npm_name) => npm_name.clone(), - None if interactive => wizard::prompt("npm package name", Some(&name))?, + Some(npm_name) => { + config::validate_npm_name(npm_name)?; + npm_name.clone() + } + None if interactive => { + wizard::prompt_validated("npm package name", Some(&name), config::validate_npm_name)? + } None => name.clone(), }; - config::validate_npm_name(&npm_name)?; let description = match &top.description { Some(description) => description.clone(), @@ -209,16 +217,27 @@ fn resolve_config(top: &TopLevel, root: &Path, interactive: bool) -> Result non_empty(domain), - None if interactive => non_empty(&wizard::prompt( + Some(domain) => { + let domain = non_empty(domain); + if let Some(domain) = &domain { + config::validate_domain(domain)?; + } + domain + } + None if interactive => non_empty(&wizard::prompt_validated( "custom domain like example.com (optional; sets CNAME + homepage)", Some(""), + |value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + Ok(()) + } else { + config::validate_domain(trimmed) + } + }, )?), None => None, }; - if let Some(domain) = &domain { - config::validate_domain(domain)?; - } let derived_repo = git::output(root, &["remote", "get-url", "origin"])? .and_then(|url| git::normalize_remote_url(&url)); @@ -306,6 +325,17 @@ fn print_next_steps(config: &MoltConfig) { println!( "\nstatic/logo.svg and static/favicon.png still carry the template's spider — replace them when ready." ); + if config.keeps(features::GITHUB_EXTRAS) { + if config.repo_url.is_some() { + println!( + ".github/FUNDING.yml now holds placeholder funding links — fill in or delete." + ); + } else { + println!( + ".github/FUNDING.yml now holds placeholder funding links, and the issue-template discussion links still point at the template (no repo url to derive) — update or delete them." + ); + } + } } #[cfg(test)] diff --git a/crates/fuz_template/src/plan.rs b/crates/fuz_template/src/plan.rs index 3b0877a..eb29e4b 100644 --- a/crates/fuz_template/src/plan.rs +++ b/crates/fuz_template/src/plan.rs @@ -191,7 +191,7 @@ pub fn build_plan(config: &MoltConfig) -> Vec { path: PathBuf::from("src/lib/Positioned.svelte"), }); - // docs system + // docs system, and the svelte-docinfo tooling that exists only for it if !config.keeps(features::DOCS) { plan.push(Action::DeleteDir { path: PathBuf::from("src/routes/docs"), @@ -199,6 +199,30 @@ pub fn build_plan(config: &MoltConfig) -> Vec { plan.push(Action::DeleteFile { path: PathBuf::from("src/routes/library.ts"), }); + plan.push(replace_once( + "package.json", + anchors::PACKAGE_JSON_SVELTE_DOCINFO, + String::new(), + "remove the svelte-docinfo devDependency", + )); + plan.push(replace_once( + "vite.config.ts", + anchors::VITE_DOCINFO_IMPORT, + String::new(), + "remove the svelte-docinfo import", + )); + plan.push(replace_once( + "vite.config.ts", + anchors::VITE_DOCINFO_PLUGIN, + String::new(), + "remove the svelte-docinfo plugin", + )); + plan.push(replace_once( + "src/app.d.ts", + anchors::APP_D_TS_DOCINFO, + String::new(), + "remove the svelte-docinfo ambient types", + )); } // regenerated docs @@ -248,8 +272,29 @@ pub fn build_plan(config: &MoltConfig) -> Vec { label: "regenerate for the new project (AGENTS.md symlinks here)".to_owned(), }); - // .github extras - if !config.keeps(features::GITHUB_EXTRAS) { + // .github extras: personalized when kept (the template's funding handles + // and discussion links must never ship in someone else's project) + if config.keeps(features::GITHUB_EXTRAS) { + plan.push(Action::ReplaceFile { + path: PathBuf::from(".github/FUNDING.yml"), + anchors: vec![anchors::FUNDING_GITHUB.to_owned()], + content: templates::FUNDING_YML.to_owned(), + label: "funding placeholders (fill in or delete)".to_owned(), + }); + if let Some(repo_url) = &config.repo_url { + for path in [ + ".github/ISSUE_TEMPLATE/config.yml", + ".github/ISSUE_TEMPLATE/preapproved.md", + ] { + plan.push(Action::ReplaceAll { + path: PathBuf::from(path), + from: anchors::TEMPLATE_REPO_URL.to_owned(), + to: repo_url.clone(), + label: format!("discussions url \u{2192} {repo_url}"), + }); + } + } + } else { plan.push(Action::DeleteFile { path: PathBuf::from(".github/FUNDING.yml"), }); diff --git a/crates/fuz_template/src/templates.rs b/crates/fuz_template/src/templates.rs index 914a574..1a5ada3 100644 --- a/crates/fuz_template/src/templates.rs +++ b/crates/fuz_template/src/templates.rs @@ -6,6 +6,7 @@ pub const CLAUDE_MD: &str = include_str!("../templates/CLAUDE.md.in"); pub const README_RUST_SECTION: &str = include_str!("../templates/readme_rust_section.md.in"); pub const CLAUDE_RUST_SECTION: &str = include_str!("../templates/claude_rust_section.md.in"); pub const WORKSPACE_CARGO_TOML: &str = include_str!("../templates/workspace_cargo.toml.in"); +pub const FUNDING_YML: &str = include_str!("../templates/funding.yml.in"); /// The starter page's docs link, substituted only when docs are kept. pub const PAGE_DOCS_LINK: &str = " \u{b7} docs"; @@ -13,10 +14,25 @@ pub const PAGE_DOCS_LINK: &str = " \u{b7} docs"; pub const CLAUDE_DOCS_BULLET: &str = "- `src/routes/docs/` \u{2014} documentation pages with auto-generated API docs from\n the `svelte-docinfo` Vite plugin\n"; /// Substitutes `__PLACEHOLDER__` tokens in an embedded template. +/// +/// Single-pass over the template: inserted values are never re-scanned, so +/// user-provided text (a description containing `__RUST_SECTION__`, say) +/// can't corrupt the output. pub fn render(template: &str, substitutions: &[(&str, &str)]) -> String { - let mut out = template.to_owned(); - for (token, value) in substitutions { - out = out.replace(token, value); + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while !rest.is_empty() { + let earliest = substitutions + .iter() + .filter_map(|(token, value)| rest.find(token).map(|idx| (idx, *token, *value))) + .min_by_key(|(idx, ..)| *idx); + let Some((idx, token, value)) = earliest else { + out.push_str(rest); + break; + }; + out.push_str(&rest[..idx]); + out.push_str(value); + rest = &rest[idx + token.len()..]; } out } @@ -33,6 +49,15 @@ mod tests { ); } + #[test] + fn render_does_not_rescan_inserted_values() { + // a value containing another token must pass through literally + assert_eq!( + render("a __X__ b __Y__", &[("__X__", "__Y__"), ("__Y__", "z")]), + "a __Y__ b z" + ); + } + #[test] fn no_template_placeholders_leak() { // Removing every known placeholder must leave no `__` behind, so a @@ -53,6 +78,7 @@ mod tests { README_RUST_SECTION, CLAUDE_RUST_SECTION, WORKSPACE_CARGO_TOML, + FUNDING_YML, ] { let mut stripped = template.to_owned(); for token in known { diff --git a/crates/fuz_template/src/wizard.rs b/crates/fuz_template/src/wizard.rs index e5fcba9..861ef95 100644 --- a/crates/fuz_template/src/wizard.rs +++ b/crates/fuz_template/src/wizard.rs @@ -9,6 +9,28 @@ pub fn interactive() -> bool { /// Prompts for a line of input; empty input (or EOF) selects `default`. pub fn prompt(label: &str, default: Option<&str>) -> Result { + Ok(prompt_raw(label, default)?.0) +} + +/// Prompts until `validate` accepts the input, echoing the validation error +/// between attempts. On EOF the error surfaces instead of looping forever. +pub fn prompt_validated( + label: &str, + default: Option<&str>, + validate: impl Fn(&str) -> Result<(), CliError>, +) -> Result { + loop { + let (input, eof) = prompt_raw(label, default)?; + match validate(&input) { + Ok(()) => return Ok(input), + Err(e) if eof => return Err(e), + Err(e) => println!("{e}"), + } + } +} + +/// Prompts for a line; returns the resolved value and whether stdin hit EOF. +fn prompt_raw(label: &str, default: Option<&str>) -> Result<(String, bool), CliError> { let mut stdout = io::stdout().lock(); match default { Some(d) if !d.is_empty() => write!(stdout, "{label} [{d}]: ")?, @@ -17,13 +39,14 @@ pub fn prompt(label: &str, default: Option<&str>) -> Result { stdout.flush()?; drop(stdout); let mut line = String::new(); - io::stdin().lock().read_line(&mut line)?; + let bytes_read = io::stdin().lock().read_line(&mut line)?; let trimmed = line.trim(); - Ok(if trimmed.is_empty() { + let value = if trimmed.is_empty() { default.unwrap_or("").to_owned() } else { trimmed.to_owned() - }) + }; + Ok((value, bytes_read == 0)) } /// Prompts for a yes/no answer; empty input (or EOF) selects `default`. diff --git a/crates/fuz_template/templates/funding.yml.in b/crates/fuz_template/templates/funding.yml.in new file mode 100644 index 0000000..17022a4 --- /dev/null +++ b/crates/fuz_template/templates/funding.yml.in @@ -0,0 +1,3 @@ +# Funding links shown via your repo's Sponsor button — fill in or delete. +# github: your-github-username +# patreon: your-patreon-username diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4a14f4d..8a96e49 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -26,6 +26,16 @@ docs + +
+

+ This is the deployed demo of + fuz_template, a starter for building apps + with the fuz stack. Clone it (or use GitHub's "Use this + template"), then run cargo molt to make it yours — the readme also covers a manual + path that skips Rust. +

+

color scheme

From a3cb4d38178b1adc38c2cf8ffa071416c5d8aa3d Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Tue, 7 Jul 2026 07:39:48 -0400 Subject: [PATCH 5/8] add license and cleanup features --- CLAUDE.md | 27 ++++--- LICENSE | 21 +++++ README.md | 14 ++-- crates/fuz_template/src/anchors.rs | 9 ++- crates/fuz_template/src/apply.rs | 13 ++- crates/fuz_template/src/check.rs | 35 ++++---- crates/fuz_template/src/cli.rs | 20 ++++- crates/fuz_template/src/config.rs | 42 ++++++++++ crates/fuz_template/src/error.rs | 6 +- crates/fuz_template/src/features.rs | 7 +- crates/fuz_template/src/git.rs | 22 +++-- crates/fuz_template/src/main.rs | 80 ++++++++++++++++--- crates/fuz_template/src/plan.rs | 37 ++++++--- crates/fuz_template/src/templates.rs | 1 + crates/fuz_template/src/wizard.rs | 22 +++-- .../templates/workspace_cargo.toml.in | 3 +- package.json | 3 +- src/routes/+page.svelte | 2 +- src/{routes => test}/example.test.ts | 0 19 files changed, 288 insertions(+), 76 deletions(-) create mode 100644 LICENSE rename src/{routes => test}/example.test.ts (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 883f669..69f3140 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,8 @@ cargo molt - `src/routes/+page.svelte` - replace demo content - `src/routes/about/+page.svelte` - heading - `src/lib/Mreows.svelte` and `src/lib/Positioned.svelte` - deleted (demo components) +- `LICENSE` and the `license` fields - deleted (the MIT license is the + template's; your project chooses its own) - `static/CNAME` - update or delete for your domain - `.github/FUNDING.yml` and `.github/ISSUE_TEMPLATE/` - update or delete - `README.md` and `CLAUDE.md` - regenerate for the new project @@ -101,14 +103,17 @@ cargo molt --help # all flags, for non-interactive use Key behaviors: - **Requires a git repo with a clean tree** (exit 2 otherwise; `--force` - overrides dirty, nothing overrides no-git) so it's always undoable. + overrides dirty, nothing overrides no-git); an applied plan is undone with + `git reset --hard && git clean -fd` (the tree was clean, so `git clean` + removes only files molt created). Applying to a dirty tree (only reachable via `--force`) always demands the dirty-specific in-the-moment confirmation — on the `--wetrun` path and the wizard path alike; `--wetrun` alone never skips it, and without a terminal the dirty apply is refused (exit 2). The only ungated write path is - `--wetrun` on a clean tree, where `git checkout` is a full undo. + `--wetrun` on a clean tree. - **Plan-then-apply**: every file edit is anchored on exact current content; - one unmatched anchor aborts before any write. Non-interactive runs write + one unmatched anchor aborts before any write, and anchors re-verify after + a confirm prompt, immediately before writing. Non-interactive runs write nothing without `--wetrun`. - **Feature registry**: every optional feature is a keep/strip choice — `rust` (the whole workspace: `Cargo.toml`, `Cargo.lock`, `crates/`, @@ -124,10 +129,15 @@ Key behaviors: (comma-separated or repeated). Stripping `rust` cascades to `cli`; stripping `cli` while keeping `rust` is rejected (cargo can't load an empty workspace) — declining the starter crate in the wizard strips - `rust` too, with a printed note. `.cargo/` (which holds only the - `cargo molt` alias) is deleted unconditionally. The registry lives in + `rust` too, with a printed note, and a prompt whose answer explicit flags + already force (`--keep rust` forces the crate, `--keep cli` forces the + workspace, `--strip cli` forces stripping rust) is skipped with a note. + `.cargo/` (which holds only the `cargo molt` alias) and the MIT `LICENSE` + + `license` fields are deleted unconditionally — the license is fuz.dev's, + not the new project's, so keeping it is never right (same reasoning as the + personalized github-extras). The registry lives in `crates/fuz_template/src/features.rs` — new features are one entry + a - plan fragment there. + plan fragment there (the check sample configs derive from the registry). - **Identity fields**: name (required), npm name, description, domain, repo url — derived from the git origin when it isn't the template's. - **Self-verifying**: `cargo molt check` and the crate's tests verify every @@ -175,12 +185,13 @@ src/ ├── lib/ # your library code │ ├── Mreows.svelte # example component (replace me) │ └── Positioned.svelte # example component (replace me) +├── test/ +│ └── example.test.ts # test file example └── routes/ ├── +layout.svelte # root layout with fuz_css imports ├── +layout.ts # prerender: true, ssr: true ├── +page.svelte # home page ├── style.css # custom global styles - ├── example.test.ts # test file example ├── about/+page.svelte └── docs/ # documentation pages ├── +layout.svelte # wraps docs in Docs component @@ -269,8 +280,6 @@ Deploy with `gro deploy` (builds and pushes to deploy branch). production use - **Minimal test coverage** - Only one example test file included - **Static only** - No dynamic server-side content -- **Tests colocated** - Tests in routes (`example.test.ts`) rather than - `src/test/` directory ## Project standards diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a6c15d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) fuz.dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 2c10d32..bd688cc 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ And to remove the Rust workspace, delete `Cargo.toml`, `Cargo.lock`, `crates/`, Then run `npm i` to update `package-lock.json`. -Optionally add a [license file](https://choosealicense.com/) -and [`package.json` value](https://spdx.org/licenses/), like `"license": "MIT"`. +The template is MIT-licensed ([`LICENSE`](LICENSE), plus `license` fields in +`package.json` and `Cargo.toml`) — keep MIT for your project or swap in +[another license](https://choosealicense.com/). ## molt @@ -121,6 +122,8 @@ What it does, driven by your answers: - updates or removes `static/CNAME`, `homepage`, and `repository` - deletes the demo components and writes a minimal starting page - regenerates `README.md` and `CLAUDE.md` for your project +- deletes the template's MIT `LICENSE` and `license` fields — your project + chooses its own license - keeps or strips each optional feature — `rust` (the whole workspace), `cli` (the starter crate at [`crates/app_cli`](crates/app_cli), renamed to your project name), `docs` (the docs system), and `github-extras` @@ -129,8 +132,9 @@ What it does, driven by your answers: stripping `cli` alone is rejected — a kept workspace needs a crate - deletes its own crate -It refuses to run without a clean git tree, so undo is always -`git checkout .` away. `--force` lets a dirty tree through for _planning_, +It refuses to run without a clean git tree, so an applied plan can be undone +with `git reset --hard && git clean -fd` (the tree was clean, so `git clean` +removes only files molt created). `--force` lets a dirty tree through for _planning_, but applying to a dirty tree always requires an interactive confirmation — without a terminal it refuses (exit 2), because there'd be no clean undo point. Run with flags instead of prompts for non-interactive use @@ -157,7 +161,7 @@ gro test -- --forwarded-args 'to vitest' ``` See [Vitest](https://github.com/vitest-dev/vitest), -[`src/routes/example.test.ts`](src/routes/example.test.ts), +[`src/test/example.test.ts`](src/test/example.test.ts), and [Gro's test docs](https://github.com/fuzdev/gro/blob/main/src/docs/test.md) for more. ## deploy diff --git a/crates/fuz_template/src/anchors.rs b/crates/fuz_template/src/anchors.rs index ea3d379..016afe9 100644 --- a/crates/fuz_template/src/anchors.rs +++ b/crates/fuz_template/src/anchors.rs @@ -6,11 +6,12 @@ //! template, update the anchor here in the same change. pub const PACKAGE_JSON_NAME: &str = " \"name\": \"@fuzdev/fuz_template\",\n"; -pub const PACKAGE_JSON_DESCRIPTION: &str = " \"description\": \"a static web app and Node library template with TypeScript, Svelte, SvelteKit, Vite, esbuild, Gro, and Fuz\",\n"; +pub const PACKAGE_JSON_DESCRIPTION: &str = " \"description\": \"a static web app template built with the fuz stack: TypeScript, Svelte, SvelteKit, and Gro\",\n"; pub const PACKAGE_JSON_GLYPH: &str = " \"glyph\": \"\u{2744}\",\n"; pub const PACKAGE_JSON_LOGO: &str = " \"logo\": \"logo.svg\",\n"; pub const PACKAGE_JSON_LOGO_ALT: &str = " \"logo_alt\": \"a friendly pixelated spider facing you\",\n"; +pub const PACKAGE_JSON_LICENSE: &str = " \"license\": \"MIT\",\n"; pub const PACKAGE_JSON_HOMEPAGE: &str = " \"homepage\": \"https://template.fuz.dev/\",\n"; pub const PACKAGE_JSON_REPOSITORY: &str = " \"repository\": \"https://github.com/fuzdev/fuz_template\",\n"; @@ -40,12 +41,18 @@ pub const README_H1: &str = "# @fuzdev/fuz_template \u{2744}"; pub const CLAUDE_H1: &str = "# fuz_template\n"; pub const CNAME_CONTENT: &str = "template.fuz.dev"; pub const WORKSPACE_MEMBERS: &str = "members = [\"crates/app_cli\", \"crates/fuz_template\"]"; +/// The workspace manifest's license line — stripped on eject along with the +/// `LICENSE` file (a molted project chooses its own license). +pub const WORKSPACE_LICENSE: &str = "license = \"MIT\"\n"; /// The starter CLI crate's name — molt renames every occurrence (and the /// crate directory) to the chosen project name. pub const APP_CLI_TOKEN: &str = "app_cli"; /// The starter CLI crate's placeholder description line. pub const APP_CLI_DESCRIPTION: &str = "description = \"a CLI scaffolded by fuz_template's molt\"\n"; +/// The starter CLI crate's license inheritance line, stripped on eject +/// (the workspace's license line goes with it). +pub const APP_CLI_LICENSE: &str = "license.workspace = true\n"; /// The `rust` job appended to `.github/workflows/check.yml` — kept here as an /// exact-match anchor so stripping the `rust` feature can remove it. diff --git a/crates/fuz_template/src/apply.rs b/crates/fuz_template/src/apply.rs index 55461ed..4fa8038 100644 --- a/crates/fuz_template/src/apply.rs +++ b/crates/fuz_template/src/apply.rs @@ -84,6 +84,7 @@ mod tests { "package.json", "README.md", "CLAUDE.md", + "LICENSE", "vite.config.ts", "Cargo.toml", "Cargo.lock", @@ -142,6 +143,10 @@ mod tests { assert!(package_json.contains("\"homepage\": \"https://sample.example.com/\"")); assert_eq!(read(&dir, "static/CNAME"), "sample.example.com\n"); + // the template's MIT license never ships in a molted project + assert!(!dir.join("LICENSE").exists()); + assert!(!package_json.contains("\"license\"")); + let layout = read(&dir, "src/routes/+layout.svelte"); assert!(!layout.contains("logo_fuz_template")); assert!(layout.contains("@sample/sample_app")); @@ -183,12 +188,16 @@ mod tests { let workspace = read(&dir, "Cargo.toml"); assert!(workspace.contains("members = [\"crates/sample_app\"]")); + assert!(!workspace.contains("license")); assert!(!dir.join("crates/fuz_template").exists()); assert!(!dir.join("crates/app_cli").exists()); assert!(!dir.join(".cargo").exists()); let crate_manifest = read(&dir, "crates/sample_app/Cargo.toml"); assert!(crate_manifest.contains("name = \"sample_app\"")); - assert!(crate_manifest.contains("description = \"a sample app\"")); + // the user's description survives verbatim — the app_cli token rename + // runs before the description insert + assert!(crate_manifest.contains("description = \"a sample app that replaces app_cli\"")); + assert!(!crate_manifest.contains("license")); let main_rs = read(&dir, "crates/sample_app/src/main.rs"); assert!(main_rs.contains("hello {who}, from sample_app")); assert!(!main_rs.contains("app_cli")); @@ -212,7 +221,9 @@ mod tests { assert!(package_json.contains("\"name\": \"plain_app\"")); assert!(!package_json.contains("homepage")); assert!(!package_json.contains("repository")); + assert!(!package_json.contains("\"license\"")); assert!(!dir.join("static/CNAME").exists()); + assert!(!dir.join("LICENSE").exists()); assert!(!dir.join("Cargo.toml").exists()); assert!(!dir.join("Cargo.lock").exists()); diff --git a/crates/fuz_template/src/check.rs b/crates/fuz_template/src/check.rs index de1983a..5ae89c2 100644 --- a/crates/fuz_template/src/check.rs +++ b/crates/fuz_template/src/check.rs @@ -25,7 +25,8 @@ pub fn run(root: &Path) -> Result { eprintln!(" {issue}"); } eprintln!("(update crates/fuz_template/src/anchors.rs or templates/ in the same change)"); - Ok(ExitCode::FAILURE) + // drift is caller-must-fix, same dialect as `CliError::Drift` + Ok(ExitCode::from(2)) } } @@ -38,19 +39,24 @@ pub fn check_all(root: &Path) -> Result, CliError> { issues.extend(verify(root, &build_plan(&config))?); } // the embedded workspace template must stay byte-identical to the live - // root Cargo.toml apart from the members line — otherwise an edit to the - // live lints/profile/deps would silently ship a stale workspace to every - // molted project while the members anchor still matched + // root Cargo.toml apart from the members and license lines — otherwise + // an edit to the live lints/profile/deps would silently ship a stale + // workspace to every molted project while the members anchor still matched let live_path = root.join("Cargo.toml"); let live = fs::read_to_string(&live_path).map_err(|source| CliError::Io { path: live_path, source, })?; - let rendered = templates::WORKSPACE_CARGO_TOML - .replace("members = [__MEMBERS__]", anchors::WORKSPACE_MEMBERS); + let rendered = templates::render( + templates::WORKSPACE_CARGO_TOML, + &[ + ("members = [__MEMBERS__]", anchors::WORKSPACE_MEMBERS), + ("__LICENSE__", anchors::WORKSPACE_LICENSE), + ], + ); if live != rendered { issues.push( - "Cargo.toml: drifted from crates/fuz_template/templates/workspace_cargo.toml.in (only the members line may differ)" + "Cargo.toml: drifted from crates/fuz_template/templates/workspace_cargo.toml.in (only the members and license lines may differ)" .to_owned(), ); } @@ -60,22 +66,19 @@ pub fn check_all(root: &Path) -> Result, CliError> { } /// Two configs that together exercise every plan branch: one keeps every -/// feature and sets every optional value, one strips every feature and -/// clears the optional values. +/// registry feature (derived from `features::FEATURES`, so a new feature is +/// covered without touching this) and sets every optional value, one strips +/// every feature and clears the optional values. pub fn sample_configs() -> [MoltConfig; 2] { [ MoltConfig { name: "sample_app".to_owned(), npm_name: "@sample/sample_app".to_owned(), - description: "a sample app".to_owned(), + // contains "app_cli" to prove the crate rename can't corrupt it + description: "a sample app that replaces app_cli".to_owned(), domain: Some("sample.example.com".to_owned()), repo_url: Some("https://github.com/sample/sample_app".to_owned()), - kept: BTreeSet::from([ - features::RUST, - features::CLI, - features::DOCS, - features::GITHUB_EXTRAS, - ]), + kept: features::FEATURES.iter().map(|f| f.id).collect(), }, MoltConfig { name: "plain_app".to_owned(), diff --git a/crates/fuz_template/src/cli.rs b/crates/fuz_template/src/cli.rs index e0ef773..1f7a508 100644 --- a/crates/fuz_template/src/cli.rs +++ b/crates/fuz_template/src/cli.rs @@ -36,7 +36,8 @@ pub struct TopLevel { #[argh(option)] pub strip: Vec, - /// apply the plan (the default prints the plan without writing) + /// apply the plan (without it, non-interactive runs write nothing and + /// the wizard asks before applying) #[argh(switch)] pub wetrun: bool, @@ -48,6 +49,23 @@ pub struct TopLevel { pub subcommand: Option, } +impl TopLevel { + /// Whether any molt-run flag was passed — they're meaningless combined + /// with the `check` subcommand, so the caller rejects that instead of + /// silently ignoring them. + pub const fn has_molt_flags(&self) -> bool { + self.name.is_some() + || self.npm_name.is_some() + || self.description.is_some() + || self.domain.is_some() + || self.repo.is_some() + || !self.keep.is_empty() + || !self.strip.is_empty() + || self.wetrun + || self.force + } +} + #[derive(FromArgs, Debug)] #[argh(subcommand)] pub enum Subcommand { diff --git a/crates/fuz_template/src/config.rs b/crates/fuz_template/src/config.rs index 97ee72a..bf08ece 100644 --- a/crates/fuz_template/src/config.rs +++ b/crates/fuz_template/src/config.rs @@ -21,6 +21,18 @@ impl MoltConfig { } } +/// Names cargo refuses as package names: Rust keywords (strict + reserved, +/// including 2024's `gen`) plus `test`, which conflicts with the built-in +/// test library. The name becomes the starter crate's name, so a keyword +/// would leave the molted workspace unable to build. +const RESERVED_CRATE_NAMES: &[&str] = &[ + "abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate", + "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl", + "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref", + "return", "self", "static", "struct", "super", "test", "trait", "true", "try", "type", + "typeof", "unsafe", "unsized", "use", "virtual", "where", "while", "yield", +]; + /// Validates a project name: `snake_case`, usable as a crate name. pub fn validate_name(name: &str) -> Result<(), CliError> { let valid_first = name.chars().next().is_some_and(|c| c.is_ascii_lowercase()); @@ -33,6 +45,11 @@ pub fn validate_name(name: &str) -> Result<(), CliError> { "invalid name {name:?}: use snake_case starting with a letter (e.g. my_app)" ))); } + if RESERVED_CRATE_NAMES.contains(&name) { + return Err(CliError::Usage(format!( + "name {name:?} can't be used as a crate name (Rust keyword or built-in) — pick another" + ))); + } if matches!(name, "fuz_template" | "app_cli" | "xtask") { return Err(CliError::Usage(format!( "name {name:?} is reserved — pick your own project name" @@ -41,6 +58,17 @@ pub fn validate_name(name: &str) -> Result<(), CliError> { Ok(()) } +/// Validates a project description: a single line, no control characters +/// (it lands in `package.json`, TOML, and markdown blockquotes). +pub fn validate_description(description: &str) -> Result<(), CliError> { + if description.chars().any(char::is_control) { + return Err(CliError::Usage( + "description must be a single line without control characters".to_owned(), + )); + } + Ok(()) +} + /// Validates an npm package name loosely (scoped names like `@you/name` allowed). pub fn validate_npm_name(name: &str) -> Result<(), CliError> { let valid = !name.is_empty() @@ -105,6 +133,20 @@ mod tests { assert!(validate_name("").is_err()); assert!(validate_name("fuz_template").is_err()); assert!(validate_name("app_cli").is_err()); + // Rust keywords and `test` can't be crate names + assert!(validate_name("match").is_err()); + assert!(validate_name("loop").is_err()); + assert!(validate_name("test").is_err()); + assert!(validate_name("gen").is_err()); + assert!(validate_name("matcher").is_ok()); + } + + #[test] + fn description_validation() { + assert!(validate_description("").is_ok()); + assert!(validate_description("a fine one-liner").is_ok()); + assert!(validate_description("line\nbreak").is_err()); + assert!(validate_description("tab\there").is_err()); } #[test] diff --git a/crates/fuz_template/src/error.rs b/crates/fuz_template/src/error.rs index 6ddfd9a..dbe82b8 100644 --- a/crates/fuz_template/src/error.rs +++ b/crates/fuz_template/src/error.rs @@ -34,9 +34,11 @@ pub enum CliError { source: std::io::Error, }, - /// Terminal input/output failed mid-wizard. + /// Terminal input/output failed mid-wizard. Deliberately not `#[from]` — + /// only the wizard maps into this, so a stray `?` on a filesystem error + /// can't get mislabeled as a terminal failure. #[error("terminal io failed: {0}")] - Terminal(#[from] std::io::Error), + Terminal(std::io::Error), } impl CliError { diff --git a/crates/fuz_template/src/features.rs b/crates/fuz_template/src/features.rs index 283f4d9..faccba7 100644 --- a/crates/fuz_template/src/features.rs +++ b/crates/fuz_template/src/features.rs @@ -2,9 +2,10 @@ //! //! Every molt-selectable feature is one entry here: a wizard prompt, a //! `--keep`/`--strip` id, and a plan fragment in `plan.rs` keyed off the -//! resolved set. Adding a feature means adding a registry entry, its plan -//! fragment, and covering its anchors in `check.rs`'s sample configs — and -//! joining `CRATE_FEATURES` if it contributes a workspace member crate. +//! resolved set. Adding a feature means adding a registry entry and its plan +//! fragment (`check.rs`'s keep-all sample config derives from the registry, +//! so anchor coverage follows automatically) — and joining `CRATE_FEATURES` +//! if it contributes a workspace member crate. use std::collections::BTreeSet; diff --git a/crates/fuz_template/src/git.rs b/crates/fuz_template/src/git.rs index 6494e52..40e99e0 100644 --- a/crates/fuz_template/src/git.rs +++ b/crates/fuz_template/src/git.rs @@ -23,17 +23,23 @@ pub fn output(root: &Path, args: &[&str]) -> Result, CliError> { } } -/// Normalizes a git remote url to an https repository url, returning `None` -/// for the template's own remote (a plain `git clone` of `fuz_template` -/// keeps origin pointed at the template — deriving that would be wrong). +/// Normalizes a git remote url (https, scp-style `git@host:`, or +/// `ssh://git@host/`) to an https repository url, returning `None` for the +/// template's own remote (a plain `git clone` of `fuz_template` keeps origin +/// pointed at the template — deriving that would be wrong). pub fn normalize_remote_url(url: &str) -> Option { let url = url.trim(); if url.contains("fuzdev/fuz_template") { return None; } - let https = url.strip_prefix("git@").map_or_else( - || url.to_owned(), - |rest| format!("https://{}", rest.replacen(':', "/", 1)), + let https = url.strip_prefix("ssh://git@").map_or_else( + || { + url.strip_prefix("git@").map_or_else( + || url.to_owned(), + |rest| format!("https://{}", rest.replacen(':', "/", 1)), + ) + }, + |rest| format!("https://{rest}"), ); let trimmed = https.strip_suffix(".git").unwrap_or(&https); trimmed.starts_with("https://").then(|| trimmed.to_owned()) @@ -57,6 +63,10 @@ mod tests { normalize_remote_url("https://github.com/you/app"), Some("https://github.com/you/app".to_owned()) ); + assert_eq!( + normalize_remote_url("ssh://git@github.com/you/app.git"), + Some("https://github.com/you/app".to_owned()) + ); assert_eq!( normalize_remote_url("git@github.com:fuzdev/fuz_template.git"), None diff --git a/crates/fuz_template/src/main.rs b/crates/fuz_template/src/main.rs index 6715fe2..3d89905 100644 --- a/crates/fuz_template/src/main.rs +++ b/crates/fuz_template/src/main.rs @@ -1,5 +1,5 @@ //! molt — transforms this `fuz_template` clone into your own project, then -//! deletes itself. See the repo `README.md` for the full story. +//! deletes itself. See the repo `README.md` for details. mod anchors; mod apply; @@ -56,6 +56,11 @@ fn main() -> ExitCode { fn run(top: &TopLevel) -> Result { let root = locate_root()?; if top.subcommand.is_some() { + if top.has_molt_flags() { + return Err(CliError::Usage( + "`molt check` takes no other flags".to_owned(), + )); + } check::run(&root) } else { molt(top, &root) @@ -84,11 +89,13 @@ fn locate_root() -> Result { /// What stands between a printed plan and applying it, given the run mode. /// /// The one combination with no gate at all is `--wetrun` on a clean tree — -/// there `git checkout` is always a full undo. A dirty tree (reachable only -/// via `--force`) never applies without the dirty-specific in-the-moment -/// confirmation — on the `--wetrun` path and the wizard path alike — and -/// without a terminal it never applies at all: "commit first" is always -/// available, so an override flag would just recreate the hole. +/// there `git reset --hard && git clean -fd` restores the pre-molt state +/// (the tree was clean, so `git clean` removes only files molt created). A +/// dirty tree (reachable only via `--force`) never applies without the +/// dirty-specific in-the-moment confirmation — on the `--wetrun` path and +/// the wizard path alike — and without a terminal it never applies at all: +/// "commit first" is always available, so an override flag would just +/// recreate the hole. #[derive(Debug, PartialEq, Eq)] enum ApplyGate { /// Apply without further confirmation (clean tree + `--wetrun`). @@ -178,8 +185,16 @@ fn molt(top: &TopLevel, root: &Path) -> Result { return Ok(ExitCode::SUCCESS); } + if matches!(gate, ApplyGate::Confirm | ApplyGate::ConfirmDirty) { + // the tree may have changed while the prompt waited — apply would + // silently skip an edit whose anchor disappeared, so verify again + let issues = plan::verify(root, &plan)?; + if !issues.is_empty() { + return Err(CliError::Drift(issues.join("\n"))); + } + } apply::apply(root, &plan)?; - print_next_steps(&config); + print_next_steps(&config, clean); Ok(ExitCode::SUCCESS) } @@ -211,7 +226,11 @@ fn resolve_config(top: &TopLevel, root: &Path, interactive: bool) -> Result description.clone(), + Some(description) => { + let description = description.trim().to_owned(); + config::validate_description(&description)?; + description + } None if interactive => wizard::prompt("one-line description (optional)", Some(""))?, None => String::new(), }; @@ -264,6 +283,38 @@ fn resolve_config(top: &TopLevel, root: &Path, interactive: bool) -> Result Option { } } -fn print_next_steps(config: &MoltConfig) { - println!("\nmolt complete — {} is yours. next steps:", config.name); +fn print_next_steps(config: &MoltConfig, clean: bool) { + println!( + "\nmolt complete — the project is now {}. next steps:", + config.name + ); println!(" git status # review what changed"); println!(" npm i # refresh package-lock.json for the new name"); println!(" gro check # typecheck, test, lint, format"); @@ -322,9 +376,15 @@ fn print_next_steps(config: &MoltConfig) { " git add -A && git commit -m \"chore: molt fuz_template into {}\"", config.name ); + if clean { + println!("\nto undo the molt: git reset --hard && git clean -fd"); + } println!( "\nstatic/logo.svg and static/favicon.png still carry the template's spider — replace them when ready." ); + println!( + "molt deleted the template's MIT LICENSE and license fields — choose your own: https://choosealicense.com/" + ); if config.keeps(features::GITHUB_EXTRAS) { if config.repo_url.is_some() { println!( diff --git a/crates/fuz_template/src/plan.rs b/crates/fuz_template/src/plan.rs index eb29e4b..b0dd91f 100644 --- a/crates/fuz_template/src/plan.rs +++ b/crates/fuz_template/src/plan.rs @@ -104,9 +104,18 @@ pub fn build_plan(config: &MoltConfig) -> Vec { (anchors::PACKAGE_JSON_GLYPH, "remove template glyph"), (anchors::PACKAGE_JSON_LOGO, "remove template logo"), (anchors::PACKAGE_JSON_LOGO_ALT, "remove template logo_alt"), + ( + anchors::PACKAGE_JSON_LICENSE, + "remove license (choose your own)", + ), ] { plan.push(replace_once("package.json", anchor, String::new(), label)); } + + // the template's MIT license is fuz.dev's, not the new project's + plan.push(Action::DeleteFile { + path: PathBuf::from("LICENSE"), + }); let homepage_replacement = config.domain.as_ref().map_or_else(String::new, |domain| { format!(" \"homepage\": \"https://{domain}/\",\n") }); @@ -313,21 +322,18 @@ pub fn build_plan(config: &MoltConfig) -> Vec { anchors: vec![anchors::WORKSPACE_MEMBERS.to_owned()], content: templates::render( templates::WORKSPACE_CARGO_TOML, - &[("__MEMBERS__", &members)], + &[("__MEMBERS__", &members), ("__LICENSE__", "")], ), - label: "workspace without molt's crate".to_owned(), + label: "workspace without molt's crate or the template's license".to_owned(), }); - let description_replacement = if config.description.is_empty() { - String::new() - } else { - format!("description = \"{}\"\n", json_escape(&config.description)) - }; plan.push(replace_once( "crates/app_cli/Cargo.toml", - anchors::APP_CLI_DESCRIPTION, - description_replacement, - "description", + anchors::APP_CLI_LICENSE, + String::new(), + "remove the license inheritance (the workspace line is gone)", )); + // rename the token before inserting the user's description, which may + // itself contain "app_cli" and must survive verbatim for path in [ "crates/app_cli/Cargo.toml", "crates/app_cli/src/main.rs", @@ -340,6 +346,17 @@ pub fn build_plan(config: &MoltConfig) -> Vec { label: format!("{} \u{2192} {name}", anchors::APP_CLI_TOKEN), }); } + let description_replacement = if config.description.is_empty() { + String::new() + } else { + format!("description = \"{}\"\n", json_escape(&config.description)) + }; + plan.push(replace_once( + "crates/app_cli/Cargo.toml", + anchors::APP_CLI_DESCRIPTION, + description_replacement, + "description", + )); plan.push(Action::RenameDir { from: PathBuf::from("crates/app_cli"), to: PathBuf::from(format!("crates/{name}")), diff --git a/crates/fuz_template/src/templates.rs b/crates/fuz_template/src/templates.rs index 1a5ada3..2671411 100644 --- a/crates/fuz_template/src/templates.rs +++ b/crates/fuz_template/src/templates.rs @@ -70,6 +70,7 @@ mod tests { "__DOCS_LINK__", "__DOCS_BULLET__", "__MEMBERS__", + "__LICENSE__", ]; for template in [ PAGE_SVELTE, diff --git a/crates/fuz_template/src/wizard.rs b/crates/fuz_template/src/wizard.rs index 861ef95..12efe7b 100644 --- a/crates/fuz_template/src/wizard.rs +++ b/crates/fuz_template/src/wizard.rs @@ -7,6 +7,12 @@ pub fn interactive() -> bool { io::stdin().is_terminal() && io::stdout().is_terminal() } +/// Maps a terminal io failure to `CliError::Terminal` — the only place raw +/// io errors become that variant. +fn terminal(result: io::Result) -> Result { + result.map_err(CliError::Terminal) +} + /// Prompts for a line of input; empty input (or EOF) selects `default`. pub fn prompt(label: &str, default: Option<&str>) -> Result { Ok(prompt_raw(label, default)?.0) @@ -33,13 +39,13 @@ pub fn prompt_validated( fn prompt_raw(label: &str, default: Option<&str>) -> Result<(String, bool), CliError> { let mut stdout = io::stdout().lock(); match default { - Some(d) if !d.is_empty() => write!(stdout, "{label} [{d}]: ")?, - _ => write!(stdout, "{label}: ")?, + Some(d) if !d.is_empty() => terminal(write!(stdout, "{label} [{d}]: "))?, + _ => terminal(write!(stdout, "{label}: "))?, } - stdout.flush()?; + terminal(stdout.flush())?; drop(stdout); let mut line = String::new(); - let bytes_read = io::stdin().lock().read_line(&mut line)?; + let bytes_read = terminal(io::stdin().lock().read_line(&mut line))?; let trimmed = line.trim(); let value = if trimmed.is_empty() { default.unwrap_or("").to_owned() @@ -54,11 +60,11 @@ pub fn prompt_bool(label: &str, default: bool) -> Result { let suffix = if default { "[Y/n]" } else { "[y/N]" }; loop { let mut stdout = io::stdout().lock(); - write!(stdout, "{label} {suffix}: ")?; - stdout.flush()?; + terminal(write!(stdout, "{label} {suffix}: "))?; + terminal(stdout.flush())?; drop(stdout); let mut line = String::new(); - let bytes_read = io::stdin().lock().read_line(&mut line)?; + let bytes_read = terminal(io::stdin().lock().read_line(&mut line))?; let answer = line.trim().to_ascii_lowercase(); if bytes_read == 0 || answer.is_empty() { return Ok(default); @@ -68,7 +74,7 @@ pub fn prompt_bool(label: &str, default: bool) -> Result { "n" | "no" => return Ok(false), _ => { let mut stdout = io::stdout().lock(); - writeln!(stdout, "please answer y or n")?; + terminal(writeln!(stdout, "please answer y or n"))?; } } } diff --git a/crates/fuz_template/templates/workspace_cargo.toml.in b/crates/fuz_template/templates/workspace_cargo.toml.in index 575ce56..bbd9376 100644 --- a/crates/fuz_template/templates/workspace_cargo.toml.in +++ b/crates/fuz_template/templates/workspace_cargo.toml.in @@ -5,8 +5,7 @@ members = [__MEMBERS__] [workspace.package] version = "0.0.1" edition = "2024" -license = "MIT" -publish = false +__LICENSE__publish = false [workspace.dependencies] argh = "0.1" diff --git a/package.json b/package.json index 4b74e0f..aa7f1a1 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "@fuzdev/fuz_template", "version": "0.0.1", - "description": "a static web app and Node library template with TypeScript, Svelte, SvelteKit, Vite, esbuild, Gro, and Fuz", + "description": "a static web app template built with the fuz stack: TypeScript, Svelte, SvelteKit, and Gro", "glyph": "❄", "logo": "logo.svg", "logo_alt": "a friendly pixelated spider facing you", + "license": "MIT", "private": true, "homepage": "https://template.fuz.dev/", "repository": "https://github.com/fuzdev/fuz_template", diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8a96e49..55c6187 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -32,7 +32,7 @@ This is the deployed demo of fuz_template, a starter for building apps with the fuz stack. Clone it (or use GitHub's "Use this - template"), then run cargo molt to make it yours — the readme also covers a manual + template"), then run cargo molt to personalize it — the readme also covers a manual path that skips Rust.

diff --git a/src/routes/example.test.ts b/src/test/example.test.ts similarity index 100% rename from src/routes/example.test.ts rename to src/test/example.test.ts From 464f4b8d6bf8b5b7992c5abba120fedb659a0333 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 10:04:50 -0400 Subject: [PATCH 6/8] wip --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b329d3..d4803f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,12 @@ "": { "name": "@fuzdev/fuz_template", "version": "0.0.1", + "license": "MIT", "devDependencies": { "@fuzdev/blake3_wasm": "^0.1.1", "@fuzdev/fuz_code": "^0.46.1", "@fuzdev/fuz_css": "^0.63.2", - "@fuzdev/fuz_ui": "^0.206.5", + "@fuzdev/fuz_ui": "^0.206.6", "@fuzdev/fuz_util": "^0.65.2", "@fuzdev/gro": "^0.205.1", "@fuzdev/mdz": "^0.2.0", @@ -776,9 +777,9 @@ } }, "node_modules/@fuzdev/fuz_ui": { - "version": "0.206.5", - "resolved": "https://registry.npmjs.org/@fuzdev/fuz_ui/-/fuz_ui-0.206.5.tgz", - "integrity": "sha512-P+9U7xuGs1O1NrusiItcCQF/Sx7HPs/Tukg6cyWqDOatdRtro/nIVlzASZvk7/34fzGrwXYiHobRmY1jBchxHA==", + "version": "0.206.6", + "resolved": "https://registry.npmjs.org/@fuzdev/fuz_ui/-/fuz_ui-0.206.6.tgz", + "integrity": "sha512-LJzI0GgqGYojwhpR/vjTX5CEY5XJN3Nz2Qg/akgB0foa7u8YMrwT3nXPbH3LmTkjvP6eGI6j4lELHUdEEouPnw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index c6d7558..ccac7d3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@fuzdev/blake3_wasm": "^0.1.1", "@fuzdev/fuz_code": "^0.46.1", "@fuzdev/fuz_css": "^0.63.2", - "@fuzdev/fuz_ui": "^0.206.5", + "@fuzdev/fuz_ui": "^0.206.6", "@fuzdev/fuz_util": "^0.65.2", "@fuzdev/gro": "^0.205.1", "@fuzdev/mdz": "^0.2.0", From 6cfb2cf6c5956c8916cfafe9469df597b2ebfcef Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 10:05:08 -0400 Subject: [PATCH 7/8] wip --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4803f7..e727f8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@ryanatkn/eslint-config": "^0.12.1", "@sveltejs/acorn-typescript": "^1.0.10", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.69.1", + "@sveltejs/kit": "^2.69.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@types/estree": "^1.0.8", "@types/node": "^24.12.4", @@ -1791,9 +1791,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.69.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", - "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", + "version": "2.69.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.2.tgz", + "integrity": "sha512-CMdPDbYjRwRu4KXTxBVMuOpFPCt1i/v0ANennotec+K9Cmb2e3w2yYzJiC6Vh/WSvm9Khi5sJMZa0rJPqfHlDw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ccac7d3..784e5a5 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@ryanatkn/eslint-config": "^0.12.1", "@sveltejs/acorn-typescript": "^1.0.10", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.69.1", + "@sveltejs/kit": "^2.69.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@types/estree": "^1.0.8", "@types/node": "^24.12.4", From 3dc706409da9acea4ae3b95cd225229bbd3e9b89 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Fri, 10 Jul 2026 10:32:04 -0400 Subject: [PATCH 8/8] cleanup --- CLAUDE.md | 11 ++++++----- README.md | 9 ++++++++- crates/fuz_template/src/cli.rs | 1 + crates/fuz_template/src/features.rs | 6 ++++-- crates/fuz_template/src/main.rs | 30 +++++++++++++++++++---------- 5 files changed, 39 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 69f3140..f6af6d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,13 +125,14 @@ Key behaviors: `github-extras` (FUNDING.yml + issue templates; the only default-strip — kept copies are personalized with funding placeholders and discussion links pointed at your repo url). - One prompt each in the wizard, or `--keep`/`--strip` id lists + One prompt each in the wizard — except `cli`, which rides with the `rust` + prompt while it's the only crate feature (a kept workspace needs a member + crate, so there is no separate decision) — or `--keep`/`--strip` id lists (comma-separated or repeated). Stripping `rust` cascades to `cli`; stripping `cli` while keeping `rust` is rejected (cargo can't load an - empty workspace) — declining the starter crate in the wizard strips - `rust` too, with a printed note, and a prompt whose answer explicit flags - already force (`--keep rust` forces the crate, `--keep cli` forces the - workspace, `--strip cli` forces stripping rust) is skipped with a note. + empty workspace), and a prompt whose answer explicit flags already force + (`--keep cli` forces the workspace, `--strip cli` forces stripping rust) + is skipped with a note. `.cargo/` (which holds only the `cargo molt` alias) and the MIT `LICENSE` + `license` fields are deleted unconditionally — the license is fuz.dev's, not the new project's, so keeping it is never right (same reasoning as the diff --git a/README.md b/README.md index bd688cc..9860292 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,17 @@ And to remove the Rust workspace, delete `Cargo.toml`, `Cargo.lock`, `crates/`, `.cargo/`, `rust-toolchain.toml`, `clippy.toml`, and the `rust` job in [`.github/workflows/check.yml`](.github/workflows/check.yml). +To remove the docs system, delete `src/routes/docs/` and +[`src/routes/library.ts`](src/routes/library.ts), remove the docs link in +[`src/routes/+page.svelte`](src/routes/+page.svelte), and drop the +`svelte-docinfo` devDependency along with its wiring in +[`vite.config.ts`](vite.config.ts) and [`src/app.d.ts`](src/app.d.ts). + Then run `npm i` to update `package-lock.json`. The template is MIT-licensed ([`LICENSE`](LICENSE), plus `license` fields in -`package.json` and `Cargo.toml`) — keep MIT for your project or swap in +`package.json` and `Cargo.toml`), copyright fuz.dev — to keep MIT for your +project, replace the copyright holder with your own; or swap in [another license](https://choosealicense.com/). ## molt diff --git a/crates/fuz_template/src/cli.rs b/crates/fuz_template/src/cli.rs index 1f7a508..1db0b43 100644 --- a/crates/fuz_template/src/cli.rs +++ b/crates/fuz_template/src/cli.rs @@ -4,6 +4,7 @@ use argh::FromArgs; /// molt deletes itself. Run with no arguments for the interactive wizard. /// Without a terminal, `--name` is required and nothing is written unless /// `--wetrun` is passed. +// when adding a flag, include it in `has_molt_flags` below #[derive(FromArgs, Debug)] pub struct TopLevel { /// project name (`snake_case`; used for crate names, headings, and defaults) diff --git a/crates/fuz_template/src/features.rs b/crates/fuz_template/src/features.rs index faccba7..ae05e80 100644 --- a/crates/fuz_template/src/features.rs +++ b/crates/fuz_template/src/features.rs @@ -32,11 +32,13 @@ pub const GITHUB_EXTRAS: &str = "github-extras"; pub const FEATURES: [Feature; 4] = [ Feature { id: RUST, - prompt: "keep the Rust workspace?", + prompt: "keep the Rust workspace? (includes the starter CLI crate, renamed to crates/)", default_keep: true, requires: None, }, Feature { + // the wizard skips this prompt while `cli` is the only crate feature — + // a kept workspace forces it, so the rust prompt covers the pair id: CLI, prompt: "keep the starter CLI crate? (renamed to crates/)", default_keep: true, @@ -134,7 +136,7 @@ pub fn cascade(kept: &mut BTreeSet<&'static str>) { /// Features that contribute workspace member crates. A kept `rust` needs at /// least one — cargo refuses to load a virtual manifest with no members. -const CRATE_FEATURES: [&str; 1] = [CLI]; +pub const CRATE_FEATURES: [&str; 1] = [CLI]; /// Whether `kept` keeps the Rust workspace with no member crates — an invalid /// combination the caller must reject (or repair by stripping `rust` too). diff --git a/crates/fuz_template/src/main.rs b/crates/fuz_template/src/main.rs index 3d89905..44f0651 100644 --- a/crates/fuz_template/src/main.rs +++ b/crates/fuz_template/src/main.rs @@ -129,8 +129,16 @@ fn molt(top: &TopLevel, root: &Path) -> Result { Some("git init && git add -A && git commit -m 'init from fuz_template'"), )); } - let clean = - git::output(root, &["status", "--porcelain"])?.is_some_and(|out| out.trim().is_empty()); + // a failed `git status` is its own problem, not a dirty tree + let clean = match git::output(root, &["status", "--porcelain"])? { + Some(out) => out.trim().is_empty(), + None => { + return Err(CliError::precondition( + "`git status` failed in this repo", + Some("make sure `git status --porcelain` succeeds here, then rerun molt"), + )); + } + }; if !clean && !top.force { return Err(CliError::precondition( "the git tree is dirty — molt wants a clean tree so it stays undoable", @@ -182,6 +190,9 @@ fn molt(top: &TopLevel, root: &Path) -> Result { ApplyGate::RefuseDirty => unreachable!(), }; if !apply_now { + if gate != ApplyGate::DryRun { + println!("declined — nothing written"); + } return Ok(ExitCode::SUCCESS); } @@ -305,13 +316,10 @@ fn resolve_config(top: &TopLevel, root: &Path, interactive: bool) -> Result Result