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..5b8e111 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -45,3 +45,18 @@ jobs: cache: npm - run: npm ci - 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 + + 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..f6af6d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,39 +57,142 @@ 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 +- `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` - update or delete +- `.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 +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); 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. +- **Plan-then-apply**: every file edit is anchored on exact current content; + 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/`, + `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 + 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 — 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), 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 + personalized github-extras). The registry lives in + `crates/fuz_template/src/features.rs` — new features are one entry + a + 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 + 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 + +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, anchor drift), `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 │ ├── 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 @@ -114,8 +217,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 @@ -178,15 +281,14 @@ 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 - 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/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 2bb3705..9860292 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 @@ -65,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 @@ -75,20 +74,78 @@ 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): -- [`package.json`](package.json) -- [`svelte.config.js`](svelte.config.js) +```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) — 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) -- update or delete [`src/static/CNAME`](src/static/CNAME) - and ./.github/FUNDING.yml +- [`src/routes/about/+page.svelte`](src/routes/about/+page.svelte) +- 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 +[`.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`. -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`), 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 + +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 +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 +- 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` + (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 + +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 +(`cargo molt --help`); in that mode nothing is written without `--wetrun`. ## build @@ -111,7 +168,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/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/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..8e92bcc --- /dev/null +++ b/crates/app_cli/src/main.rs @@ -0,0 +1,64 @@ +//! The starter CLI: replace this with your program. + +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..016afe9 --- /dev/null +++ b/crates/fuz_template/src/anchors.rs @@ -0,0 +1,74 @@ +//! 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 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"; + +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

"; + +// 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"; +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. +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 + + 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..4fa8038 --- /dev/null +++ b/crates/fuz_template/src/apply.rs @@ -0,0 +1,260 @@ +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", + "LICENSE", + "vite.config.ts", + "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"); + + // 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")); + + 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")); + + // 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!(!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\"")); + // 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")); + 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!(!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()); + 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, 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 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")); + 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..5ae89c2 --- /dev/null +++ b/crates/fuz_template/src/check.rs @@ -0,0 +1,115 @@ +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 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 and embedded templates match"); + Ok(ExitCode::SUCCESS) + } else { + eprintln!( + "molt check failed — the template drifted from molt's anchors or embedded templates:" + ); + for issue in &issues { + eprintln!(" {issue}"); + } + eprintln!("(update crates/fuz_template/src/anchors.rs or templates/ in the same change)"); + // drift is caller-must-fix, same dialect as `CliError::Drift` + Ok(ExitCode::from(2)) + } +} + +/// 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), +/// plus the embedded-template invariants that anchors alone can't see. +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))?); + } + // the embedded workspace template must stay byte-identical to the live + // 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::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 and license lines may differ)" + .to_owned(), + ); + } + issues.sort(); + issues.dedup(); + Ok(issues) +} + +/// Two configs that together exercise every plan branch: one keeps every +/// 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(), + // 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: features::FEATURES.iter().map(|f| f.id).collect(), + }, + MoltConfig { + name: "plain_app".to_owned(), + npm_name: "plain_app".to_owned(), + description: String::new(), + domain: None, + repo_url: None, + kept: BTreeSet::new(), + }, + ] +} + +#[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..1db0b43 --- /dev/null +++ b/crates/fuz_template/src/cli.rs @@ -0,0 +1,80 @@ +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. +// 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) + #[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 (without it, non-interactive runs write nothing and + /// the wizard asks before applying) + #[argh(switch)] + pub wetrun: bool, + + /// proceed even if the git tree is dirty + #[argh(switch)] + pub force: bool, + + #[argh(subcommand)] + 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 { + Check(CheckCommand), +} + +/// 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/config.rs b/crates/fuz_template/src/config.rs new file mode 100644 index 0000000..bf08ece --- /dev/null +++ b/crates/fuz_template/src/config.rs @@ -0,0 +1,174 @@ +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) + } +} + +/// 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()); + 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 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" + ))); + } + 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() + && 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()); + // 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] + 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..dbe82b8 --- /dev/null +++ b/crates/fuz_template/src/error.rs @@ -0,0 +1,90 @@ +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, modified files) 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 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), + + /// 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. 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(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 { .. } | Self::Drift(_) => 2, + 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, + Self::Drift(_) => { + Some("restore the listed files (e.g. git checkout) or run from a fresh clone") + } + _ => 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(), 2); + 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..ae05e80 --- /dev/null +++ b/crates/fuz_template/src/features.rs @@ -0,0 +1,196 @@ +//! 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 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; + +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? (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, + 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); + } + } +} + +/// Features that contribute workspace member crates. A kept `rust` needs at +/// least one — cargo refuses to load a virtual manifest with no members. +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). +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::*; + + 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()); + } + + #[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/git.rs b/crates/fuz_template/src/git.rs new file mode 100644 index 0000000..40e99e0 --- /dev/null +++ b/crates/fuz_template/src/git.rs @@ -0,0 +1,80 @@ +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 (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("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()) +} + +#[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("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 + ); + 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..44f0651 --- /dev/null +++ b/crates/fuz_template/src/main.rs @@ -0,0 +1,431 @@ +//! molt — transforms this `fuz_template` clone into your own project, then +//! deletes itself. See the repo `README.md` for details. + +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() { + if top.has_molt_flags() { + return Err(CliError::Usage( + "`molt check` takes no other flags".to_owned(), + )); + } + 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"), + )); + } + } +} + +/// 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 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`). + 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, + (_, false, true) => ApplyGate::ConfirmDirty, + (true, false, false) => ApplyGate::RefuseDirty, + (false, true, true) => ApplyGate::Confirm, + (false, _, false) => ApplyGate::DryRun, + } +} + +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'"), + )); + } + // 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", + 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); + 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 = 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 { + if gate != ApplyGate::DryRun { + println!("declined — nothing written"); + } + 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, clean); + Ok(ExitCode::SUCCESS) +} + +fn resolve_config(top: &TopLevel, root: &Path, interactive: bool) -> Result { + let name = match &top.name { + 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(), + )); + } + }; + + let npm_name = match &top.npm_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(), + }; + + let description = match &top.description { + 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(), + }; + + let domain = match &top.domain { + 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, + }; + + 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; + } + // a prompt whose answer explicit flags already force is skipped + // with a note instead of contradicting the flag after the fact + if let Some(child) = features::FEATURES.iter().find(|f| { + f.requires == Some(feature.id) && explicit.contains(f.id) && kept.contains(f.id) + }) { + println!( + "note: keeping {} — --keep {} needs it", + feature.id, child.id + ); + kept.insert(feature.id); + continue; + } + // cargo rejects an empty workspace, and `cli` is its only member crate + // TODO: generalize via `CRATE_FEATURES` when more crate features exist + if feature.id == features::RUST + && explicit.contains(features::CLI) + && !kept.contains(features::CLI) + { + println!("note: --strip cli leaves the Rust workspace empty — stripping rust too"); + kept.remove(feature.id); + continue; + } + // while a feature is the only crate feature, keeping rust forces + // it (cargo rejects an empty workspace) — the rust prompt covers + // the pair, so there's no separate decision to prompt for + if features::CRATE_FEATURES == [feature.id] && kept.contains(features::RUST) { + kept.insert(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); + } + // a kept rust workspace with no member crates can't build — repair the + // wizard case (both choices came from prompts), reject explicit flags. + // unreachable while `CRATE_FEATURES` has one member (the wizard skips + // that prompt); kept for when a second crate feature returns the prompts + 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, + 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, 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"); + 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 + ); + 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!( + ".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)] +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 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); + // 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, 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 new file mode 100644 index 0000000..b0dd91f --- /dev/null +++ b/crates/fuz_template/src/plan.rs @@ -0,0 +1,475 @@ +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"), + ( + 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") + }); + 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, 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"), + }); + 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 + 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: 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"), + }); + plan.push(Action::DeleteDir { + path: PathBuf::from(".github/ISSUE_TEMPLATE"), + }); + } + + // 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 = format!("\"crates/{name}\""); + 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), ("__LICENSE__", "")], + ), + label: "workspace without molt's crate or the template's license".to_owned(), + }); + plan.push(replace_once( + "crates/app_cli/Cargo.toml", + 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", + "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), + }); + } + 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}")), + }); + 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..2671411 --- /dev/null +++ b/crates/fuz_template/src/templates.rs @@ -0,0 +1,95 @@ +//! 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"); +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"; +/// 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. +/// +/// 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 = 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 +} + +#[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 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 + // 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__", + "__LICENSE__", + ]; + for template in [ + PAGE_SVELTE, + README_MD, + CLAUDE_MD, + README_RUST_SECTION, + CLAUDE_RUST_SECTION, + WORKSPACE_CARGO_TOML, + FUNDING_YML, + ] { + 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..12efe7b --- /dev/null +++ b/crates/fuz_template/src/wizard.rs @@ -0,0 +1,81 @@ +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() +} + +/// 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) +} + +/// 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() => terminal(write!(stdout, "{label} [{d}]: "))?, + _ => terminal(write!(stdout, "{label}: "))?, + } + terminal(stdout.flush())?; + drop(stdout); + let mut line = String::new(); + 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() + } else { + trimmed.to_owned() + }; + Ok((value, bytes_read == 0)) +} + +/// 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(); + terminal(write!(stdout, "{label} {suffix}: "))?; + terminal(stdout.flush())?; + drop(stdout); + let mut line = String::new(); + 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); + } + match answer.as_str() { + "y" | "yes" => return Ok(true), + "n" | "no" => return Ok(false), + _ => { + let mut stdout = io::stdout().lock(); + terminal(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/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/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..bbd9376 --- /dev/null +++ b/crates/fuz_template/templates/workspace_cargo.toml.in @@ -0,0 +1,54 @@ +[workspace] +resolver = "2" +members = [__MEMBERS__] + +[workspace.package] +version = "0.0.1" +edition = "2024" +__LICENSE__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/package-lock.json b/package-lock.json index 3b329d3..e727f8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,18 +7,19 @@ "": { "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", "@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", @@ -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": { @@ -1790,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 a081cbd..784e5a5 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", @@ -25,14 +26,14 @@ "@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", "@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", 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"] diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 4a14f4d..55c6187 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 personalize it — the readme also covers a manual + path that skips Rust. +

+

color scheme

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