From e5e1c8abb965e4100e28402194717f60a3c920ee Mon Sep 17 00:00:00 2001 From: wangcch Date: Sat, 5 Sep 2026 02:59:59 +0800 Subject: [PATCH 01/31] feat(init): add guided and reviewable project setup --- Cargo.lock | 46 +- Cargo.toml | 2 + crates/tysel-cli/Cargo.toml | 2 + crates/tysel-cli/src/check.rs | 52 ++- crates/tysel-cli/src/init.rs | 572 +++++++++++++++++++++-- crates/tysel-cli/src/init/plan.rs | 147 +++++- crates/tysel-cli/src/init/project.rs | 129 +++++ crates/tysel-cli/src/init/prompt.rs | 325 ++++++++++++- crates/tysel-cli/src/init/templates.rs | 45 +- crates/tysel-cli/src/init/transaction.rs | 56 ++- crates/tysel-cli/src/main.rs | 52 ++- crates/tysel-cli/tests/dev_check.rs | 179 +++++++ 12 files changed, 1520 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de6b945..a316255 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -434,6 +434,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -703,6 +715,16 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", +] + [[package]] name = "digest" version = "0.10.7" @@ -783,6 +805,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -805,7 +833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2931,7 +2959,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3028,7 +3056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3139,6 +3167,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -3331,7 +3365,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3751,6 +3785,8 @@ version = "0.2.0" dependencies = [ "anyhow", "clap", + "dialoguer", + "json-strip-comments", "libc", "notify", "reqwest", @@ -4637,7 +4673,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c65b460..6101d2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,8 @@ thiserror = "2" serde = { version = "1", features = ["derive"] } toml = "0.8" clap = { version = "4", features = ["derive"] } +dialoguer = { version = "0.12", default-features = false } +json-strip-comments = "3.1.2" tracing = "0.1" tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros", "net", "io-util", "process", "signal"] } # Candidate QuickJS-NG 0.16.2 adapter. Keep the immutable revision until the diff --git a/crates/tysel-cli/Cargo.toml b/crates/tysel-cli/Cargo.toml index 3b22c20..ffcf009 100644 --- a/crates/tysel-cli/Cargo.toml +++ b/crates/tysel-cli/Cargo.toml @@ -18,6 +18,8 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true clap.workspace = true +dialoguer.workspace = true +json-strip-comments.workspace = true tokio.workspace = true tysel-distribution.workspace = true tysel-manifest.workspace = true diff --git a/crates/tysel-cli/src/check.rs b/crates/tysel-cli/src/check.rs index 0245df7..5864338 100644 --- a/crates/tysel-cli/src/check.rs +++ b/crates/tysel-cli/src/check.rs @@ -5,6 +5,14 @@ use anyhow::{Context, Result, anyhow}; use tysel_manifest::Manifest; pub fn run(manifest_path: &Path) -> Result<()> { + run_with_options(manifest_path, false) +} + +pub(crate) fn run_requiring_types(manifest_path: &Path) -> Result<()> { + run_with_options(manifest_path, true) +} + +fn run_with_options(manifest_path: &Path, require_types: bool) -> Result<()> { let manifest = Manifest::from_path(manifest_path) .with_context(|| format!("failed to read {}", manifest_path.display()))?; let root = manifest_path.parent().unwrap_or(Path::new(".")); @@ -33,9 +41,15 @@ pub fn run(manifest_path: &Path) -> Result<()> { Typecheck::Skipped(reason) => println!(" types skipped ({reason})"), Typecheck::Failed(_) => println!(" types fail"), } - if let Typecheck::Failed(output) = types { - eprint!("{output}"); - return Err(anyhow!("TypeScript check failed")); + match types { + Typecheck::Failed(output) => { + eprint!("{output}"); + return Err(anyhow!("TypeScript check failed")); + } + Typecheck::Skipped(reason) if require_types => { + return Err(anyhow!("TypeScript check was skipped: {reason}")); + } + Typecheck::Ok | Typecheck::Skipped(_) => {} } if !is_component { let specifiers = crate::node_scan::scan_file(&entry)?; @@ -99,3 +113,35 @@ fn find_tsc(start: &Path) -> Option { } None } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn strict_check_rejects_a_skipped_typecheck() { + let root = std::env::temp_dir().join(format!( + "tysel-strict-check-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("tysel.toml"), + "[app]\nname = \"strict-check\"\nentry = \"src/index.ts\"\n", + ) + .unwrap(); + fs::write( + root.join("src/index.ts"), + "export default { fetch() { return new Response('ok'); } };\n", + ) + .unwrap(); + fs::write(root.join("tsconfig.json"), "{}\n").unwrap(); + + let error = run_requiring_types(&root.join("tysel.toml")).unwrap_err(); + assert!(error.to_string().contains("TypeScript check was skipped")); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/tysel-cli/src/init.rs b/crates/tysel-cli/src/init.rs index 3a31ec4..19af678 100644 --- a/crates/tysel-cli/src/init.rs +++ b/crates/tysel-cli/src/init.rs @@ -1,5 +1,6 @@ use std::io::IsTerminal; use std::path::{Path, PathBuf}; +use std::process::Command; use anyhow::{Context, Result}; use tysel_manifest::ManifestFormat; @@ -10,7 +11,7 @@ mod prompt; mod templates; mod transaction; -use prompt::{configure, prompt_yes_no}; +use prompt::{configure, configure_tty, confirm_tty}; use transaction::ProjectTransaction; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -29,15 +30,86 @@ pub enum Template { Minimal, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PackageManager { + Npm, + Pnpm, + Yarn, + Bun, +} + +impl PackageManager { + fn detect(root: &Path) -> Self { + let mut cursor = if root.is_dir() { Some(root) } else { root.parent() }; + while let Some(directory) = cursor { + for (lockfile, manager) in [ + ("pnpm-lock.yaml", Self::Pnpm), + ("bun.lock", Self::Bun), + ("bun.lockb", Self::Bun), + ("yarn.lock", Self::Yarn), + ("package-lock.json", Self::Npm), + ] { + if directory.join(lockfile).is_file() { + return manager; + } + } + cursor = directory.parent(); + } + Self::Npm + } + + fn command(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::Pnpm => "pnpm", + Self::Yarn => "yarn", + Self::Bun => "bun", + } + } + + fn is_available(self) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path) + .any(|directory| command_is_executable(&directory, self.command())) + } +} + +#[cfg(unix)] +fn command_is_executable(directory: &Path, command: &str) -> bool { + use std::os::unix::fs::PermissionsExt; + + std::fs::metadata(directory.join(command)) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +#[cfg(windows)] +fn command_is_executable(directory: &Path, command: &str) -> bool { + ["exe", "cmd", "bat", "com"] + .iter() + .any(|extension| directory.join(command).with_extension(extension).is_file()) +} + +#[cfg(not(any(unix, windows)))] +fn command_is_executable(directory: &Path, command: &str) -> bool { + directory.join(command).is_file() +} + pub struct Request { - pub root: PathBuf, + pub root: Option, pub template: Option