diff --git a/Cargo.lock b/Cargo.lock index 36e3bce6c7..186de225a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4391,7 +4391,6 @@ dependencies = [ "ast-grep-core", "ast-grep-language", "serde_json", - "tokio", "vite_error", ] diff --git a/crates/vite_migration/Cargo.toml b/crates/vite_migration/Cargo.toml index ae43a1ef14..32fbd9ca35 100644 --- a/crates/vite_migration/Cargo.toml +++ b/crates/vite_migration/Cargo.toml @@ -11,11 +11,7 @@ ast-grep-config = { workspace = true } ast-grep-core = { workspace = true } ast-grep-language = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } -tokio = { workspace = true, features = ["fs"] } vite_error = { workspace = true } -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt"] } - [lints] workspace = true diff --git a/crates/vite_migration/src/lib.rs b/crates/vite_migration/src/lib.rs index cb398a6712..6e54c1f2a7 100644 --- a/crates/vite_migration/src/lib.rs +++ b/crates/vite_migration/src/lib.rs @@ -1,3 +1,3 @@ mod package; -pub use package::rewrite_package_json_scripts; +pub use package::rewrite_scripts; diff --git a/crates/vite_migration/src/package.rs b/crates/vite_migration/src/package.rs index 3745143a75..0240d3d8e9 100644 --- a/crates/vite_migration/src/package.rs +++ b/crates/vite_migration/src/package.rs @@ -1,25 +1,34 @@ -use std::path::Path; - use ast_grep_config::{GlobalRules, RuleConfig, from_yaml_string}; use ast_grep_core::replacer::Replacer; use ast_grep_language::{LanguageExt, SupportLang}; -use serde_json::Value; -use tokio::fs; +use serde_json::{Map, Value}; use vite_error::Error; /// load script rules from yaml file -async fn load_ast_grep_rules(yaml_path: &Path) -> Result>, Error> { - let yaml = fs::read_to_string(yaml_path).await?; +fn load_ast_grep_rules(yaml: &str) -> Result>, Error> { let globals = GlobalRules::default(); let rules: Vec> = from_yaml_string::(&yaml, &globals)?; Ok(rules) } +// Marker to replace "cross-env " before ast-grep processing +// Using a fake env var assignment that won't match our rules +const CROSS_ENV_MARKER: &str = "__CROSS_ENV__=1 "; +const CROSS_ENV_REPLACEMENT: &str = "cross-env "; + /// rewrite a single script command string using rules fn rewrite_script(script: &str, rules: &[RuleConfig]) -> String { - // current stores the current script text, and update it when the rule matches - let mut current = script.to_string(); + // Only handle cross-env replacement if it's present in the script + let has_cross_env = script.contains(CROSS_ENV_REPLACEMENT); + // Step 1: Replace "cross-env " with marker so ast-grep can see the actual commands + let mut current = if has_cross_env { + script.replace(CROSS_ENV_REPLACEMENT, CROSS_ENV_MARKER) + } else { + script.to_string() + }; + + // Step 2: Process with ast-grep for rule in rules { // only handle bash rules if rule.language != SupportLang::Bash { @@ -56,102 +65,100 @@ fn rewrite_script(script: &str, rules: &[RuleConfig]) -> String { } } - current + // Step 3: Replace marker back with "cross-env " (only if we replaced it) + if has_cross_env { current.replace(CROSS_ENV_MARKER, CROSS_ENV_REPLACEMENT) } else { current } } -/// rewrite scripts in package.json using rules from rules_yaml_path -pub async fn rewrite_package_json_scripts( - package_json_path: &Path, - rules_yaml_path: &Path, -) -> Result { - let content = fs::read_to_string(package_json_path).await?; - let mut json: Value = serde_json::from_str(&content)?; - let rules = load_ast_grep_rules(rules_yaml_path).await?; +/// rewrite scripts json content using rules from rules_yaml +pub fn rewrite_scripts(scripts_json: &str, rules_yaml: &str) -> Result, Error> { + let mut scripts: Map = serde_json::from_str(scripts_json)?; + let rules = load_ast_grep_rules(rules_yaml)?; let mut updated = false; // get scripts field (object) - if let Some(scripts) = json.get_mut("scripts").and_then(Value::as_object_mut) { - let keys: Vec = scripts.keys().cloned().collect(); - for key in keys { - if let Some(Value::String(script)) = scripts.get(&key) { - let new_script = rewrite_script(script, &rules); - if new_script != *script { + + for value in scripts.values_mut() { + if value.is_array() { + // lint-staged scripts can be an array of strings + // https://github.com/lint-staged/lint-staged?tab=readme-ov-file#packagejson-example + if let Some(sub_scripts) = value.as_array_mut() { + for sub_script in sub_scripts.iter_mut() { + if sub_script.is_string() + && let Some(raw_script) = sub_script.as_str() + { + let new_script = rewrite_script(raw_script, &rules); + if new_script != raw_script { + updated = true; + *sub_script = Value::String(new_script); + } + } + } + } + } else if value.is_string() { + if let Some(raw_script) = value.as_str() { + let new_script = rewrite_script(raw_script, &rules); + if new_script != raw_script { updated = true; - scripts.insert(key.clone(), Value::String(new_script)); + *value = Value::String(new_script); } } } } if updated { - // write back to file - let new_content = serde_json::to_string_pretty(&json)?; - fs::write(package_json_path, new_content).await?; + let new_content = serde_json::to_string_pretty(&scripts)?; + Ok(Some(new_content)) + } else { + Ok(None) } - - Ok(updated) } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_rewrite_script() { - let yaml = r#" -# vite => vite dev + const RULES_YAML: &str = r#" +# vite => vite dev (handles all cases: with/without env var prefix and flag args) +# Match command_name to preserve env var prefix and arguments +# Excludes subcommands like "vite build", "vite test", etc. --- -id: replace-vite-alone +id: replace-vite language: bash rule: - kind: command - has: - kind: command_name - regex: '^vite$' - not: - has: - kind: word - field: argument + kind: command_name + regex: '^vite$' + inside: + kind: command + not: + # ignore non-flag arguments (subcommands like build, test, etc.) + regex: 'vite\s+[^-]' fix: vite dev -# vite [OPTIONS] => vite dev [OPTIONS] +# oxlint => vite lint (handles all cases: with/without env var prefix and args) +# Match command_name to preserve env var prefix and arguments --- -id: replace-vite-with-args +id: replace-oxlint language: bash -severity: info rule: - pattern: vite $$$ARGS - not: - # ignore non-flag arguments - regex: 'vite\s+[^-]' -fix: vite dev $$$ARGS - -# oxlint => vite lint ---- -id: replace-oxlint-alone -language: bash -rule: - kind: command - has: - kind: command_name - regex: '^oxlint$' - not: - has: - kind: word - field: argument + kind: command_name + regex: '^oxlint$' fix: vite lint -# oxlint [OPTIONS] => vite lint [OPTIONS] +# vitest => vite test --- -id: replace-oxlint-with-args +id: replace-vitest language: bash rule: - pattern: oxlint $$$ARGS -fix: vite lint $$$ARGS -"#; + kind: command_name + regex: '^vitest$' +fix: vite test + "#; + + #[test] + fn test_rewrite_script() { let globals = GlobalRules::default(); let rules: Vec> = - from_yaml_string::(&yaml, &globals).unwrap(); + from_yaml_string::(&RULES_YAML, &globals).unwrap(); // vite commands assert_eq!(rewrite_script("vite", &rules), "vite dev"); assert_eq!(rewrite_script("vite dev", &rules), "vite dev"); @@ -217,6 +224,21 @@ fix: vite lint $$$ARGS ), "if [ -f file.txt ]; then vite dev --port 3000 && npm run lint; fi" ); + // env variable commands + assert_eq!( + rewrite_script("NODE_ENV=test VITE_CJS_IGNORE_WARNING=true vite", &rules), + "NODE_ENV=test VITE_CJS_IGNORE_WARNING=true vite dev" + ); + assert_eq!( + rewrite_script("FOO=bar vite --port 3000", &rules), + "FOO=bar vite dev --port 3000" + ); + // env variable with oxlint commands + assert_eq!(rewrite_script("DEBUG=1 oxlint", &rules), "DEBUG=1 vite lint"); + assert_eq!( + rewrite_script("NODE_ENV=test oxlint --type-aware", &rules), + "NODE_ENV=test vite lint --type-aware" + ); // oxlint commands assert_eq!(rewrite_script("oxlint", &rules), "vite lint"); assert_eq!(rewrite_script("oxlint --type-aware", &rules), "vite lint --type-aware"); @@ -230,4 +252,111 @@ fix: vite lint $$$ARGS "npm run type-check && vite lint --type-aware" ); } + + #[test] + fn test_rewrite_package_json_scripts_success() { + let package_json_scripts = r#" +{ + "dev": "vite" +} + "#; + let updated = rewrite_scripts(package_json_scripts, &RULES_YAML) + .expect("failed to rewrite package.json scripts"); + assert!(updated.is_some()); + assert_eq!( + updated.unwrap(), + r#" +{ + "dev": "vite dev" +} + "# + .trim() + ); + } + + #[test] + fn test_rewrite_package_json_scripts_with_env_variable_success() { + let package_json_scripts = r#" +{ + "dev:cjs": "VITE_CJS_IGNORE_WARNING=true vite", + "lint": "VITE_CJS_IGNORE_WARNING=true FOO=bar oxlint --fix" +} + "#; + let updated = rewrite_scripts(package_json_scripts, &RULES_YAML) + .expect("failed to rewrite package.json scripts"); + assert!(updated.is_some()); + assert_eq!( + updated.unwrap(), + r#" +{ + "dev:cjs": "VITE_CJS_IGNORE_WARNING=true vite dev", + "lint": "VITE_CJS_IGNORE_WARNING=true FOO=bar vite lint --fix" +} + "# + .trim() + ); + } + + #[test] + fn test_rewrite_package_json_scripts_using_cross_env() { + let package_json_scripts = r#" +{ + "dev:cjs": "cross-env VITE_CJS_IGNORE_WARNING=true vite && cross-env FOO=bar vitest run", + "lint": "cross-env VITE_CJS_IGNORE_WARNING=true FOO=bar oxlint --fix", + "test": "vite build && cross-env FOO=bar vitest run && echo ' cross-env test done ' || echo ' cross-env test failed '" +} + "#; + let updated = rewrite_scripts(package_json_scripts, &RULES_YAML) + .expect("failed to rewrite package.json scripts"); + assert!(updated.is_some()); + assert_eq!( + updated.unwrap(), + r#" +{ + "dev:cjs": "cross-env VITE_CJS_IGNORE_WARNING=true vite dev && cross-env FOO=bar vite test run", + "lint": "cross-env VITE_CJS_IGNORE_WARNING=true FOO=bar vite lint --fix", + "test": "vite build && cross-env FOO=bar vite test run && echo ' cross-env test done ' || echo ' cross-env test failed '" +} + "# + .trim() + ); + } + + #[test] + fn test_rewrite_package_json_scripts_lint_staged() { + let package_json_scripts = r#" + { + "*.js": ["oxlint --fix --type-aware", "oxfmt --fix"], + "*.ts": "oxfmt --fix" + } + "#; + let updated = rewrite_scripts(package_json_scripts, &RULES_YAML) + .expect("failed to rewrite package.json scripts"); + assert!(updated.is_some()); + assert_eq!( + updated.unwrap(), + r#" +{ + "*.js": [ + "vite lint --fix --type-aware", + "oxfmt --fix" + ], + "*.ts": "oxfmt --fix" +} + "# + .trim() + ); + } + + #[test] + fn test_rewrite_package_json_scripts_no_update() { + let package_json_scripts = r#" + { + "foo": "bar" + } + "#; + let updated = rewrite_scripts(package_json_scripts, &RULES_YAML) + .expect("failed to rewrite package.json scripts"); + assert!(updated.is_none()); + } } diff --git a/packages/cli/binding/index.d.ts b/packages/cli/binding/index.d.ts index e3abf34b31..2ea2cc4c65 100644 --- a/packages/cli/binding/index.d.ts +++ b/packages/cli/binding/index.d.ts @@ -138,28 +138,25 @@ export interface PathAccess { } /** - * Rewrite package.json scripts using rules from rules_yaml_path + * Rewrite scripts json content using rules from rules_yaml * * # Arguments * - * * `package_json_path` - The path to the package.json file - * * `rules_yaml_path` - The path to the ast-grep rules.yaml file + * * `scripts_json` - The scripts section of the package.json file as a JSON string + * * `rules_yaml` - The ast-grep rules.yaml as a YAML string * * # Returns * - * * `updated` - Whether the package.json scripts were updated + * * `updated` - The updated scripts section of the package.json file as a JSON string, or `null` if no updates were made * * # Example * * ```javascript - * const updated = await rewritePackageJsonScripts("package.json", "rules.yaml"); + * const updated = rewriteScripts("scripts section json content here", "ast-grep rules yaml content here"); * console.log(`Updated: ${updated}`); * ``` */ -export declare function rewritePackageJsonScripts( - packageJsonPath: string, - rulesYamlPath: string, -): Promise; +export declare function rewriteScripts(scriptsJson: string, rulesYaml: string): string | null; /** * Main entry point for the CLI, called from JavaScript. diff --git a/packages/cli/binding/index.js b/packages/cli/binding/index.js index c6125a0fb2..57a6c23bbc 100644 --- a/packages/cli/binding/index.js +++ b/packages/cli/binding/index.js @@ -744,10 +744,9 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`); } -const { detectWorkspace, downloadPackageManager, rewritePackageJsonScripts, run, runCommand } = - nativeBinding; +const { detectWorkspace, downloadPackageManager, rewriteScripts, run, runCommand } = nativeBinding; export { detectWorkspace }; export { downloadPackageManager }; -export { rewritePackageJsonScripts }; +export { rewriteScripts }; export { run }; export { runCommand }; diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index 0dc1a3cb5d..9d7c100e20 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -31,7 +31,7 @@ use vite_task::ResolveCommandResult; use crate::cli::{Args, CliOptions as ViteTaskCliOptions, Commands}; pub use crate::{ - migration::rewrite_package_json_scripts, + migration::rewrite_scripts, package_manager::{detect_workspace, download_package_manager}, }; diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 9e452aeb03..76792e5499 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -1,35 +1,26 @@ -use std::path::PathBuf; - use napi::{anyhow, bindgen_prelude::*}; use napi_derive::napi; -/// Rewrite package.json scripts using rules from rules_yaml_path +/// Rewrite scripts json content using rules from rules_yaml /// /// # Arguments /// -/// * `package_json_path` - The path to the package.json file -/// * `rules_yaml_path` - The path to the ast-grep rules.yaml file +/// * `scripts_json` - The scripts section of the package.json file as a JSON string +/// * `rules_yaml` - The ast-grep rules.yaml as a YAML string /// /// # Returns /// -/// * `updated` - Whether the package.json scripts were updated +/// * `updated` - The updated scripts section of the package.json file as a JSON string, or `null` if no updates were made /// /// # Example /// /// ```javascript -/// const updated = await rewritePackageJsonScripts("package.json", "rules.yaml"); +/// const updated = rewriteScripts("scripts section json content here", "ast-grep rules yaml content here"); /// console.log(`Updated: ${updated}`); /// ``` #[napi] -pub async fn rewrite_package_json_scripts( - package_json_path: String, - rules_yaml_path: String, -) -> Result { - let package_json_path = PathBuf::from(&package_json_path); - let rules_yaml_path = PathBuf::from(&rules_yaml_path); +pub fn rewrite_scripts(scripts_json: String, rules_yaml: String) -> Result> { let updated = - vite_migration::rewrite_package_json_scripts(&package_json_path, &rules_yaml_path) - .await - .map_err(anyhow::Error::from)?; + vite_migration::rewrite_scripts(&scripts_json, &rules_yaml).map_err(anyhow::Error::from)?; Ok(updated) } diff --git a/packages/cli/binding/src/package_manager.rs b/packages/cli/binding/src/package_manager.rs index ba2e504199..47f7bfa446 100644 --- a/packages/cli/binding/src/package_manager.rs +++ b/packages/cli/binding/src/package_manager.rs @@ -1,6 +1,6 @@ use napi::{Error, anyhow, bindgen_prelude::*}; use napi_derive::napi; -use vite_error::Error::UnsupportedPackageManager; +use vite_error::Error::{UnrecognizedPackageManager, UnsupportedPackageManager}; use vite_install::{PackageManagerType, get_package_manager_type_and_version}; use vite_path::AbsolutePathBuf; use vite_workspace::{Error::PackageJsonNotFound, WorkspaceFile, find_workspace_root}; @@ -150,12 +150,14 @@ pub async fn detect_workspace(cwd: String) -> Result { is_monorepo, root: Some(workspace_root_path), }), - Err(UnsupportedPackageManager(_)) => Ok(DetectWorkspaceResult { - package_manager_name: None, - package_manager_version: None, - is_monorepo, - root: Some(workspace_root_path), - }), + Err(UnsupportedPackageManager(_) | UnrecognizedPackageManager) => { + Ok(DetectWorkspaceResult { + package_manager_name: None, + package_manager_version: None, + is_monorepo, + root: Some(workspace_root_path), + }) + } Err(e) => { return Err(anyhow::Error::from(e).into()); } diff --git a/packages/global/package.json b/packages/global/package.json index 0a7d007ba4..11194ad724 100644 --- a/packages/global/package.json +++ b/packages/global/package.json @@ -32,15 +32,19 @@ }, "devDependencies": { "@clack/prompts": "catalog:", - "@std/yaml": "catalog:", "@types/cross-spawn": "catalog:", + "@types/semver": "catalog:", "@types/validate-npm-package-name": "catalog:", "@voidzero-dev/vite-plus-tools": "workspace:", + "detect-indent": "catalog:", + "detect-newline": "catalog:", "glob": "catalog:", "minimatch": "catalog:", "mri": "catalog:", "picocolors": "catalog:", - "rolldown": "workspace:*" + "rolldown": "workspace:*", + "semver": "catalog:", + "yaml": "catalog:" }, "engines": { "node": "^20.19.0 || >=22.12.0" diff --git a/packages/global/rules/package-json-scripts.yml b/packages/global/rules/package-json-scripts.yml deleted file mode 100644 index 7ef0588e06..0000000000 --- a/packages/global/rules/package-json-scripts.yml +++ /dev/null @@ -1,118 +0,0 @@ -# vite => vite dev ---- -id: replace-vite-alone -language: bash -rule: - kind: command - has: - kind: command_name - regex: '^vite$' - not: - has: - kind: word - field: argument -fix: vite dev - -# vite [OPTIONS] => vite dev [OPTIONS] ---- -id: replace-vite-with-args -language: bash -severity: info -rule: - pattern: vite $$$ARGS - not: - # ignore non-flag arguments - regex: 'vite\s+[^-]' -fix: vite dev $$$ARGS - -# oxlint => vite lint ---- -id: replace-oxlint-alone -language: bash -rule: - kind: command - has: - kind: command_name - regex: '^oxlint$' - not: - has: - kind: word - field: argument -fix: vite lint - -# oxlint [OPTIONS] => vite lint [OPTIONS] ---- -id: replace-oxlint-with-args -language: bash -rule: - pattern: oxlint $$$ARGS -fix: vite lint $$$ARGS - -# oxfmt => vite fmt ---- -id: replace-oxfmt-alone -language: bash -rule: - kind: command - has: - kind: command_name - regex: '^oxfmt$' - not: - has: - kind: word - field: argument -fix: vite fmt - -# oxfmt [OPTIONS] => vite fmt [OPTIONS] ---- -id: replace-oxfmt-with-args -language: bash -rule: - pattern: oxfmt $$$ARGS -fix: vite fmt $$$ARGS - -# vitest => vite test ---- -id: replace-vitest-alone -language: bash -rule: - kind: command - has: - kind: command_name - regex: '^vitest$' - not: - has: - kind: word - field: argument -fix: vite test - -# vitest [OPTIONS] => vite test [OPTIONS] ---- -id: replace-vitest-with-args -language: bash -rule: - pattern: vitest $$$ARGS -fix: vite test $$$ARGS - -# tsdown => vite lib ---- -id: replace-tsdown-alone -language: bash -rule: - kind: command - has: - kind: command_name - regex: '^tsdown$' - not: - has: - kind: word - field: argument -fix: vite lib - -# tsdown [OPTIONS] => vite lib [OPTIONS] ---- -id: replace-tsdown-with-args -language: bash -rule: - pattern: tsdown $$$ARGS -fix: vite lib $$$ARGS diff --git a/packages/global/rules/vite-tools.yml b/packages/global/rules/vite-tools.yml new file mode 100644 index 0000000000..a8b957e6fa --- /dev/null +++ b/packages/global/rules/vite-tools.yml @@ -0,0 +1,43 @@ +# vite => vite dev (handles all cases: with/without env var prefix and flag args) +# Match command_name to preserve env var prefix and arguments +# Excludes subcommands like "vite build", "vite test", etc. +--- +id: replace-vite +language: bash +rule: + kind: command_name + regex: '^vite$' + inside: + kind: command + not: + # ignore non-flag arguments (subcommands like build, test, etc.) + regex: 'vite\s+[^-]' +fix: vite dev + +# oxlint => vite lint (handles all cases: with/without env var prefix and args) +# Match command_name to preserve env var prefix and arguments +--- +id: replace-oxlint +language: bash +rule: + kind: command_name + regex: '^oxlint$' +fix: vite lint + +# oxfmt => vite fmt +--- +id: replace-oxfmt +language: bash +rule: + kind: command_name + regex: '^oxfmt$' +fix: vite fmt + +# vitest => vite test +--- +id: replace-vitest +language: bash +rule: + kind: command_name + regex: '^vitest$' +fix: vite test diff --git a/packages/global/snap-tests/command-config-yarn1/snap.txt b/packages/global/snap-tests/command-config-yarn1/snap.txt index b123c761a2..aed5837cf1 100644 --- a/packages/global/snap-tests/command-config-yarn1/snap.txt +++ b/packages/global/snap-tests/command-config-yarn1/snap.txt @@ -1,18 +1,15 @@ > vp pm config set vite-plus-pm-config-test-key test-value --location project # should set config value in project scope (shows warning for yarn@1) Warning: yarn@1 does not support --location, ignoring flag yarn config v -warning package.json: No license field success Set "vite-plus-pm-config-test-key" to "test-value". Done in ms. > vp pm config get vite-plus-pm-config-test-key --location project # should get config value from project scope (shows warning for yarn@1) Warning: yarn@1 does not support --location, ignoring flag -warning package.json: No license field test-value > vp pm config delete vite-plus-pm-config-test-key --location project # should delete config key from project scope (shows warning for yarn@1) Warning: yarn@1 does not support --location, ignoring flag yarn config v -warning package.json: No license field success Deleted "vite-plus-pm-config-test-key". Done in ms. diff --git a/packages/global/snap-tests/command-list-yarn1/snap.txt b/packages/global/snap-tests/command-list-yarn1/snap.txt index f2639c4dbf..47d6eaad28 100644 --- a/packages/global/snap-tests/command-list-yarn1/snap.txt +++ b/packages/global/snap-tests/command-list-yarn1/snap.txt @@ -1,8 +1,6 @@ > vp install # should install packages first yarn install v -warning package.json: No license field info No lockfile found. -warning command-list-yarn1@: No license field [1/4] Resolving packages... [2/4] Fetching packages... [3/4] Linking dependencies... @@ -13,24 +11,18 @@ Done in ms. > vp pm list # should list installed packages yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. > vp pm list testnpm2 # should list specific package yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field warning Filtering by arguments is deprecated. Please use the pattern option instead. └─ testnpm2@ Done in ms. > vp pm list --depth 0 # should list packages with depth limit yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. @@ -43,8 +35,6 @@ Done in ms. > vp pm list --prod # should show warning that --prod not supported by yarn@1 Warning: yarn@1 does not support --prod, ignoring --prod flag yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. @@ -52,8 +42,6 @@ Done in ms. > vp pm list --dev # should show warning that --dev not supported by yarn@1 Warning: yarn@1 does not support --dev, ignoring --dev flag yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. @@ -61,8 +49,6 @@ Done in ms. > vp pm list --no-optional # should show warning that --no-optional not supported by yarn@1 Warning: yarn@1 does not support --no-optional, ignoring --no-optional flag yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. @@ -70,8 +56,6 @@ Done in ms. > vp pm list --exclude-peers # should show warning that --exclude-peers not supported by yarn@1 Warning: yarn@1 does not support --exclude-peers, ignoring flag yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. @@ -79,8 +63,6 @@ Done in ms. > vp pm list --only-projects # should show warning that --only-projects not supported by yarn@1 Warning: yarn@1 does not support --only-projects, ignoring flag yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. @@ -88,8 +70,6 @@ Done in ms. > vp pm list --find-by customFinder # should show warning that --find-by not supported by yarn@1 Warning: yarn@1 does not support --find-by, ignoring flag yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. @@ -97,8 +77,6 @@ Done in ms. > vp pm list --recursive # should show warning that --recursive not supported by yarn@1 Warning: yarn@1 does not support --recursive, ignoring --recursive flag yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. @@ -106,16 +84,12 @@ Done in ms. > vp pm list --filter app # should show warning that --filter not supported by yarn@1 Warning: yarn@1 does not support --filter, ignoring --filter flag yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. > vp pm list -- --loglevel=warn # should support pass through arguments yarn list v -warning package.json: No license field -warning command-list-yarn1@: No license field ├─ test-vite-plus-package@ └─ testnpm2@ Done in ms. diff --git a/packages/global/snap-tests/gen-create-tsdown-with-scope-name/snap.txt b/packages/global/snap-tests/gen-create-tsdown-with-scope-name/snap.txt index 383921ab0c..5985c5fbaf 100644 --- a/packages/global/snap-tests/gen-create-tsdown-with-scope-name/snap.txt +++ b/packages/global/snap-tests/gen-create-tsdown-with-scope-name/snap.txt @@ -3,5 +3,5 @@ my-library/package.json > cat my-library/.npmrc # check .npmrc -//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} @voidzero-dev:registry=https://npm.pkg.github.com/ +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} \ No newline at end of file diff --git a/packages/global/snap-tests/gen-create-tsdown/snap.txt b/packages/global/snap-tests/gen-create-tsdown/snap.txt index 1827c6fba0..f1379015aa 100644 --- a/packages/global/snap-tests/gen-create-tsdown/snap.txt +++ b/packages/global/snap-tests/gen-create-tsdown/snap.txt @@ -1,11 +1,10 @@ -> vp gen vite:library --no-interactive # create vite library with default values +> vp gen vite:library # create vite library with default values > ls vite-plus-library/package.json # check package.json vite-plus-library/package.json > cat vite-plus-library/.npmrc # check .npmrc -//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} @voidzero-dev:registry=https://npm.pkg.github.com/ - -> vp gen vite:library --no-interactive --directory my-vue -- --template vue # create vite library with custom template +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} +> vp gen vite:library --directory my-vue -- --template vue # create vite library with custom template > ls my-vue/package.json # check package.json my-vue/package.json diff --git a/packages/global/snap-tests/gen-create-tsdown/steps.json b/packages/global/snap-tests/gen-create-tsdown/steps.json index 1cecba41cd..bc66907ee6 100644 --- a/packages/global/snap-tests/gen-create-tsdown/steps.json +++ b/packages/global/snap-tests/gen-create-tsdown/steps.json @@ -4,13 +4,13 @@ }, "commands": [ { - "command": "vp gen vite:library --no-interactive # create vite library with default values", + "command": "vp gen vite:library # create vite library with default values", "ignoreOutput": true }, "ls vite-plus-library/package.json # check package.json", "cat vite-plus-library/.npmrc # check .npmrc", { - "command": "vp gen vite:library --no-interactive --directory my-vue -- --template vue # create vite library with custom template", + "command": "vp gen vite:library --directory my-vue -- --template vue # create vite library with custom template", "ignoreOutput": true }, "ls my-vue/package.json # check package.json" diff --git a/packages/global/snap-tests/gen-create-vite-with-scope-name/snap.txt b/packages/global/snap-tests/gen-create-vite-with-scope-name/snap.txt index 1cc361e4a9..41d88936ba 100644 --- a/packages/global/snap-tests/gen-create-vite-with-scope-name/snap.txt +++ b/packages/global/snap-tests/gen-create-vite-with-scope-name/snap.txt @@ -3,5 +3,5 @@ my-vite-app/package.json > cat my-vite-app/.npmrc # check .npmrc -//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} @voidzero-dev:registry=https://npm.pkg.github.com/ +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} \ No newline at end of file diff --git a/packages/global/snap-tests/gen-create-vite/snap.txt b/packages/global/snap-tests/gen-create-vite/snap.txt index 0b34ecd334..541999f917 100644 --- a/packages/global/snap-tests/gen-create-vite/snap.txt +++ b/packages/global/snap-tests/gen-create-vite/snap.txt @@ -3,9 +3,8 @@ vite-plus-application/package.json > cat vite-plus-application/.npmrc # check .npmrc -//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} @voidzero-dev:registry=https://npm.pkg.github.com/ - +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} > vp gen vite:application --no-interactive --directory my-react-ts -- --template react-ts # create vite app with react-ts template > ls my-react-ts/package.json # check package.json my-react-ts/package.json diff --git a/packages/global/snap-tests/gen-vite-monorepo/snap.txt b/packages/global/snap-tests/gen-vite-monorepo/snap.txt index 3c8dea5b59..b35c8811c9 100644 --- a/packages/global/snap-tests/gen-vite-monorepo/snap.txt +++ b/packages/global/snap-tests/gen-vite-monorepo/snap.txt @@ -22,18 +22,7 @@ vite.config.ts "node": ">=22.12.0" }, "devDependencies": { - "vite-plus": "catalog:" - }, - "pnpm": { - "overrides": { - "vite": "catalog:", - "vitest": "catalog:" - }, - "peerDependencyRules": { - "allowAny": [ - "vite" - ] - } + "@voidzero-dev/vite-plus": "catalog:" }, "packageManager": "pnpm@" } @@ -44,19 +33,28 @@ packages: - packages/* - tools/* +catalogMode: prefer + catalog: '@types/node': ^24 - vite-plus: npm:@voidzero-dev/vite-plus@latest - vite: npm:@voidzero-dev/vite-plus@latest - vitest: npm:@voidzero-dev/vite-plus@latest typescript: ^5 - -catalogMode: prefer + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + '@voidzero-dev/vite-plus': latest +overrides: + vite: 'catalog:' + vitest: 'catalog:' +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' > cat vite-plus-monorepo/.npmrc # check .npmrc -//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} @voidzero-dev:registry=https://npm.pkg.github.com/ - +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} > test -d vite-plus-monorepo/.git && echo 'Git initialized' || echo 'No git' # check git init Git initialized @@ -107,8 +105,7 @@ vite-plus-generator }, "devDependencies": { "@types/node": "catalog:", - "typescript": "catalog:", - "vite-plus": "catalog:" + "typescript": "catalog:" } } @@ -136,18 +133,7 @@ vite.config.ts "node": ">=22.12.0" }, "devDependencies": { - "vite-plus": "catalog:" - }, - "pnpm": { - "overrides": { - "vite": "catalog:", - "vitest": "catalog:" - }, - "peerDependencyRules": { - "allowAny": [ - "vite" - ] - } + "@voidzero-dev/vite-plus": "catalog:" }, "packageManager": "pnpm@" } diff --git a/packages/global/snap-tests/migration-lintstagedrc/.lintstagedrc.json b/packages/global/snap-tests/migration-lintstagedrc/.lintstagedrc.json new file mode 100644 index 0000000000..55abdac0a0 --- /dev/null +++ b/packages/global/snap-tests/migration-lintstagedrc/.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "*.js": "oxlint --fix" +} diff --git a/packages/global/snap-tests/migration-lintstagedrc/package.json b/packages/global/snap-tests/migration-lintstagedrc/package.json new file mode 100644 index 0000000000..a515633fb9 --- /dev/null +++ b/packages/global/snap-tests/migration-lintstagedrc/package.json @@ -0,0 +1,12 @@ +{ + "name": "migration-lintstagedrc", + "lint-staged": { + "*.@(js|ts|tsx|yml|yaml|md|json|html|toml)": [ + "oxfmt --staged", + "eslint --fix" + ], + "*.@(js|ts|tsx)": [ + "oxlint --fix" + ] + } +} diff --git a/packages/global/snap-tests/migration-lintstagedrc/snap.txt b/packages/global/snap-tests/migration-lintstagedrc/snap.txt new file mode 100644 index 0000000000..88e6d1db08 --- /dev/null +++ b/packages/global/snap-tests/migration-lintstagedrc/snap.txt @@ -0,0 +1,65 @@ +> vp migration -h # migration help message +Usage: vite migration [PATH] [OPTIONS] + +Migrate standalone vite, vitest, oxlint, and oxfmt to unified vite-plus. + +Arguments: + PATH Target directory to migrate (default: current directory) + +Options: + --no-interactive Run in non-interactive mode (skip prompts and use defaults) + -h, --help Show this help message + +Examples: + # Migrate current package + vite migration + + # Migrate specific directory + vite migration my-app + + # Non-interactive mode + vite migration --no-interactive + +Aliases: migrate + + +> vp migration # migration work with lintstagedrc.json +┌ Vite+ Migration +│ +● Using default package manager: pnpm +│ +● pnpm@latest installing... +│ +● pnpm@ installed +│ +└ ✨ Migration completed! + + +> cat .lintstagedrc.json # check lintstagedrc.json +{ + "*.js": "vite lint --fix" +} + +> cat package.json # check package.json +{ + "name": "migration-lintstagedrc", + "lint-staged": { + "*.@(js|ts|tsx|yml|yaml|md|json|html|toml)": [ + "vite fmt --staged", + "eslint --fix" + ], + "*.@(js|ts|tsx)": [ + "vite lint --fix" + ] + }, + "pnpm": { + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + } + }, + "devDependencies": { + "@voidzero-dev/vite-plus": "latest" + }, + "packageManager": "pnpm@" +} diff --git a/packages/global/snap-tests/migration-lintstagedrc/steps.json b/packages/global/snap-tests/migration-lintstagedrc/steps.json new file mode 100644 index 0000000000..7c3592f75c --- /dev/null +++ b/packages/global/snap-tests/migration-lintstagedrc/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "vp migration -h # migration help message", + "vp migration # migration work with lintstagedrc.json", + "cat .lintstagedrc.json # check lintstagedrc.json", + "cat package.json # check package.json" + ] +} diff --git a/packages/global/snap-tests/migration-subpath/foo/package.json b/packages/global/snap-tests/migration-subpath/foo/package.json new file mode 100644 index 0000000000..23ecc4aeac --- /dev/null +++ b/packages/global/snap-tests/migration-subpath/foo/package.json @@ -0,0 +1,12 @@ +{ + "name": "migration-subpath", + "lint-staged": { + "*.@(js|ts|tsx|yml|yaml|md|json|html|toml)": [ + "oxfmt --staged", + "eslint --fix" + ], + "*.@(js|ts|tsx)": [ + "oxlint --fix" + ] + } +} diff --git a/packages/global/snap-tests/migration-subpath/snap.txt b/packages/global/snap-tests/migration-subpath/snap.txt new file mode 100644 index 0000000000..77f1119f31 --- /dev/null +++ b/packages/global/snap-tests/migration-subpath/snap.txt @@ -0,0 +1,35 @@ +> vp migration foo # migration work with subpath +┌ Vite+ Migration +│ +● Using default package manager: pnpm +│ +● pnpm@latest installing... +│ +● pnpm@ installed +│ +└ ✨ Migration completed! + + +> cat foo/package.json # check package.json +{ + "name": "migration-subpath", + "lint-staged": { + "*.@(js|ts|tsx|yml|yaml|md|json|html|toml)": [ + "vite fmt --staged", + "eslint --fix" + ], + "*.@(js|ts|tsx)": [ + "vite lint --fix" + ] + }, + "pnpm": { + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + } + }, + "devDependencies": { + "@voidzero-dev/vite-plus": "latest" + }, + "packageManager": "pnpm@" +} diff --git a/packages/global/snap-tests/migration-subpath/steps.json b/packages/global/snap-tests/migration-subpath/steps.json new file mode 100644 index 0000000000..f42393457c --- /dev/null +++ b/packages/global/snap-tests/migration-subpath/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp migration foo # migration work with subpath", + "cat foo/package.json # check package.json" + ] +} diff --git a/packages/global/src/gen/__tests__/__snapshots__/migration.spec.ts.snap b/packages/global/src/gen/__tests__/__snapshots__/migration.spec.ts.snap deleted file mode 100644 index 856d910997..0000000000 --- a/packages/global/src/gen/__tests__/__snapshots__/migration.spec.ts.snap +++ /dev/null @@ -1,31 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`migratePackageJson > should migrate package.json scripts 1`] = ` -{ - "build": "pnpm install &&vite build -r && vite run build --watch && vite lib && tsc || exit 1", - "dev": "vite dev", - "dev_analyze": "vite dev --analyze", - "dev_debug": "vite dev --debug", - "dev_help": "vite dev --help && vite dev -h", - "dev_host": "vite dev --host 0.0.0.0", - "dev_open": "vite dev --open", - "dev_port": "vite dev --port 3000", - "dev_profile": "vite dev --profile", - "dev_stats": "vite dev --stats", - "dev_trace": "vite dev --trace", - "dev_verbose": "vite dev --verbose", - "fmt": "vite fmt", - "fmt_config": "vite fmt --config .oxfmt.json", - "lib": "vite lib", - "lib_watch": "vite lib --watch", - "lint": "vite lint", - "lint_config": "vite lint --config .oxlint.json", - "lint_type_aware": "vite lint --type-aware", - "optimize": "vite optimize", - "preview": "vite preview", - "ready": "vite lint --fix --type-aware && vite test run && vite lib && vite fmt --fix", - "ready_new": "vite install && vite fmt && vite lint --type-aware && vite test -r && vite build -r", - "test": "vite test", - "test_run": "vite test run && vite test --ui", -} -`; diff --git a/packages/global/src/gen/__tests__/migration.spec.ts b/packages/global/src/gen/__tests__/migration.spec.ts deleted file mode 100644 index 93e303110c..0000000000 --- a/packages/global/src/gen/__tests__/migration.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import fs from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -import { migratePackageJson } from '../migration.ts'; - -describe('migratePackageJson', () => { - it('should migrate package.json scripts', async () => { - const tempDir = await fs.mkdtemp(path.join(tmpdir(), 'vite-plus-test-')); - await fs.writeFile( - path.join(tempDir, 'package.json'), - JSON.stringify( - { - scripts: { - test: 'vitest', - test_run: 'vitest run && vitest --ui', - lint: 'oxlint', - lint_config: 'oxlint --config .oxlint.json', - lint_type_aware: 'oxlint --type-aware', - fmt: 'oxfmt', - fmt_config: 'oxfmt --config .oxfmt.json', - lib: 'tsdown', - lib_watch: 'tsdown --watch', - preview: 'vite preview', - optimize: 'vite optimize', - build: - 'pnpm install &&vite build -r && vite run build --watch && tsdown && tsc || exit 1', - dev: 'vite', - dev_help: 'vite --help && vite -h', - dev_port: 'vite --port 3000', - dev_host: 'vite --host 0.0.0.0', - dev_open: 'vite --open', - dev_verbose: 'vite --verbose', - dev_debug: 'vite --debug', - dev_trace: 'vite --trace', - dev_profile: 'vite --profile', - dev_stats: 'vite --stats', - dev_analyze: 'vite --analyze', - ready: 'oxlint --fix --type-aware && vitest run && tsdown && oxfmt --fix', - ready_new: - 'vite install && vite fmt && vite lint --type-aware && vite test -r && vite build -r', - }, - }, - null, - 2, - ), - ); - const updated = await migratePackageJson(path.join(tempDir, 'package.json')); - const scripts = JSON.parse( - await fs.readFile(path.join(tempDir, 'package.json'), 'utf-8'), - ).scripts; - await fs.rm(tempDir, { recursive: true }); - expect(updated).toBe(true); - expect(scripts).toMatchSnapshot(); - }); -}); diff --git a/packages/global/src/gen.ts b/packages/global/src/gen/bin.ts similarity index 81% rename from packages/global/src/gen.ts rename to packages/global/src/gen/bin.ts index 9e0382389d..bba2ffd1bb 100644 --- a/packages/global/src/gen.ts +++ b/packages/global/src/gen/bin.ts @@ -2,38 +2,35 @@ import fs from 'node:fs'; import path from 'node:path'; import * as prompts from '@clack/prompts'; -import { downloadPackageManager } from '@voidzero-dev/vite-plus/binding'; import mri from 'mri'; import colors from 'picocolors'; -import { runCommandSilently } from './gen/command.ts'; -import { discoverTemplate } from './gen/discovery.ts'; -import { performAutoMigration } from './gen/migration.ts'; import { - cancelAndExit, - checkProjectDirExists, - promptPackageNameAndTargetDir, -} from './gen/prompts.ts'; -import { - executeBuiltinTemplate, - executeMonorepoTemplate, - executeRemoteTemplate, -} from './gen/templates/index.ts'; -import { - BuiltinTemplate, - DependencyType, - type ExecutionResult, - PackageManager, - TemplateType, - type ViteOptions, - type WorkspaceInfo, -} from './gen/types.ts'; -import { formatTargetDir, setPackageManager, templatesDir } from './gen/utils.ts'; + rewriteMonorepo, + rewriteMonorepoProject, + rewriteStandaloneProject, +} from '../migration/migrator.ts'; +import { DependencyType, type WorkspaceInfo } from '../types/index.ts'; import { + defaultInteractive, detectWorkspace, + selectPackageManager, + downloadPackageManager, updatePackageJsonWithDeps, updateWorkspaceConfig, -} from './gen/workspace.ts'; + runViteInstall, + templatesDir, +} from '../utils/index.ts'; +import type { ExecutionResult } from './command.ts'; +import { discoverTemplate } from './discovery.ts'; +import { cancelAndExit, checkProjectDirExists, promptPackageNameAndTargetDir } from './prompts.ts'; +import { + executeBuiltinTemplate, + executeMonorepoTemplate, + executeRemoteTemplate, +} from './templates/index.ts'; +import { BuiltinTemplate, TemplateType } from './templates/types.ts'; +import { formatTargetDir } from './utils.ts'; const { blue, cyan, green, gray, blueBright } = colors; @@ -96,6 +93,13 @@ Note: Templates are executed via npx / pnpm dlx / yarn dlx / bunx, Aliases: ${gray('g, generate, new')} `; +export interface Options { + directory?: string; + interactive: boolean; + list: boolean; + help: boolean; +} + // Parse CLI arguments: split on '--' separator function parseArgs() { const args = process.argv.slice(3); // Skip 'node', 'vite', 'gen' @@ -116,42 +120,42 @@ function parseArgs() { alias: { h: 'help' }, boolean: ['help', 'list', 'all', 'interactive'], string: ['directory'], - default: { interactive: process.stdin.isTTY }, + default: { interactive: defaultInteractive() }, }); const templateName = parsed._[0] as string | undefined; return { templateName, - viteOptions: { + options: { directory: parsed.directory, interactive: parsed.interactive, list: parsed.list || false, help: parsed.help || false, - } as ViteOptions, + } as Options, templateArgs, }; } async function main() { - const { templateName, viteOptions, templateArgs } = parseArgs(); + const { templateName, options, templateArgs } = parseArgs(); // #region Handle help flag - if (viteOptions.help) { + if (options.help) { console.log(helpMessage); return; } // #endregion // #region Handle list flag - if (viteOptions.list) { + if (options.list) { showAvailableTemplates(); return; } // #endregion // #region Handle required arguments - if (!templateName && !viteOptions.interactive) { + if (!templateName && !options.interactive) { console.error(` Template name is required when running in non-interactive mode @@ -168,7 +172,7 @@ Use \`vite gen --list\` to list all available templates, or run \`vite gen --hel // #endregion // #region Prepare Stage - if (viteOptions.interactive) { + if (options.interactive) { const logo = fs.readFileSync(path.join(templatesDir, 'vite-plus-logo.txt'), 'utf-8'); console.log(blueBright(logo)); } @@ -177,8 +181,8 @@ Use \`vite gen --list\` to list all available templates, or run \`vite gen --hel // check --directory option is valid let targetDir = ''; let packageName = ''; - if (viteOptions.directory) { - const formatted = formatTargetDir(viteOptions.directory); + if (options.directory) { + const formatted = formatTargetDir(options.directory); if (formatted.error) { prompts.log.error(formatted.error); cancelAndExit('The --directory option is invalid', 1); @@ -278,39 +282,14 @@ Use \`vite gen --list\` to list all available templates, or run \`vite gen --hel } // Prompt for package manager or use default - let packageManager: PackageManager = workspaceInfoOptional.packageManager as PackageManager; - if (!packageManager) { - if (viteOptions.interactive) { - const selected = await prompts.select({ - message: 'Which package manager would you like to use?', - options: [ - { value: PackageManager.pnpm, hint: 'recommended' }, - { value: PackageManager.yarn }, - { value: PackageManager.npm }, - ], - initialValue: PackageManager.pnpm, - }); - - if (prompts.isCancel(selected)) { - cancelAndExit(); - } - - packageManager = selected; - } else { - // --no-interactive: use pnpm as default - packageManager = PackageManager.pnpm; - prompts.log.info(`Using default package manager: ${cyan(packageManager)}`); - } - } - + const packageManager = + workspaceInfoOptional.packageManager ?? (await selectPackageManager(options.interactive)); // ensure the package manager is installed by vite-plus - const spinner = prompts.spinner(); - spinner.start(`${packageManager}@${workspaceInfoOptional.packageManagerVersion} installing...`); - const downloadResult = await downloadPackageManager({ - name: packageManager, - version: workspaceInfoOptional.packageManagerVersion, - }); - spinner.stop(`${packageManager}@${downloadResult.version} installed`); + const downloadResult = await downloadPackageManager( + packageManager, + workspaceInfoOptional.packageManagerVersion, + options.interactive, + ); const workspaceInfo: WorkspaceInfo = { ...workspaceInfoOptional, packageManager, @@ -322,7 +301,7 @@ Use \`vite gen --list\` to list all available templates, or run \`vite gen --hel selectedTemplateName, selectedTemplateArgs, workspaceInfo, - viteOptions.interactive, + options.interactive, ); // only for builtin templates @@ -349,27 +328,28 @@ Use \`vite gen --list\` to list all available templates, or run \`vite gen --hel if (!packageName) { const selected = await promptPackageNameAndTargetDir( 'vite-plus-monorepo', - viteOptions.interactive, + options.interactive, ); packageName = selected.packageName; targetDir = selected.targetDir; } prompts.log.info(`Target directory: ${cyan(targetDir)}`); - await checkProjectDirExists( - path.join(workspaceInfo.rootDir, targetDir), - viteOptions.interactive, - ); + await checkProjectDirExists(path.join(workspaceInfo.rootDir, targetDir), options.interactive); const result = await executeMonorepoTemplate( workspaceInfo, { ...templateInfo, packageName, targetDir }, - viteOptions.interactive, + options.interactive, ); if (result.exitCode !== 0) { cancelAndExit(`Failed to create monorepo, exit code: ${result.exitCode}`, result.exitCode); } - await runViteInstall(path.join(workspaceInfo.rootDir, result.projectDir!)); + // rewrite monorepo to add vite-plus dependencies + const fullPath = path.join(workspaceInfo.rootDir, result.projectDir!); + workspaceInfo.rootDir = fullPath; + rewriteMonorepo(workspaceInfo); + await runViteInstall(fullPath, options.interactive); prompts.outro(green('✨ Generation completed!')); showNextSteps(result.projectDir!); return; @@ -378,7 +358,7 @@ Use \`vite gen --list\` to list all available templates, or run \`vite gen --hel // #region Handle single project template - if (isMonorepo && viteOptions.interactive) { + if (isMonorepo && options.interactive) { if (!targetDir) { // no custom target directory provided, prompt for parent directory let parentDir: string | undefined; @@ -439,16 +419,13 @@ Use \`vite gen --list\` to list all available templates, or run \`vite gen --hel if (workspaceInfo.monorepoScope) { defaultPackageName = `${workspaceInfo.monorepoScope}/${defaultPackageName}`; } - const selected = await promptPackageNameAndTargetDir( - defaultPackageName, - viteOptions.interactive, - ); + const selected = await promptPackageNameAndTargetDir(defaultPackageName, options.interactive); packageName = selected.packageName; targetDir = templateInfo.parentDir ? path.join(templateInfo.parentDir, selected.targetDir) : selected.targetDir; } - await checkProjectDirExists(targetDir, viteOptions.interactive); + await checkProjectDirExists(targetDir, options.interactive); prompts.log.info(`Target directory: ${cyan(targetDir)}`); result = await executeBuiltinTemplate(workspaceInfo, { ...templateInfo, @@ -471,15 +448,13 @@ Use \`vite gen --list\` to list all available templates, or run \`vite gen --hel prompts.log.success(`Detected project directory: ${green(projectDir)}`); const fullPath = path.join(workspaceInfo.rootDir, projectDir); - // Auto-migration to vite-plus - await performAutoMigration(workspaceInfo, projectDir); - // Monorepo integration if (isMonorepo) { prompts.log.step('Monorepo integration...'); + rewriteMonorepoProject(fullPath, workspaceInfo.packageManager); if (workspaceInfo.packages.length > 0) { - if (viteOptions.interactive) { + if (options.interactive) { const selectedDepTypeOptions = await prompts.multiselect({ message: `Add workspace dependencies to the ${green(projectDir)}?`, options: [ @@ -534,13 +509,12 @@ Use \`vite gen --list\` to list all available templates, or run \`vite gen --hel updateWorkspaceConfig(projectDir, workspaceInfo); // install dependencies in the root of the monorepo - await runViteInstall(workspaceInfo.rootDir); + await runViteInstall(workspaceInfo.rootDir, options.interactive); } else { // single project - // set package manager in the project directory - setPackageManager(fullPath, workspaceInfo.downloadPackageManager); + rewriteStandaloneProject(fullPath, workspaceInfo); // install dependencies in the project directory - await runViteInstall(fullPath); + await runViteInstall(fullPath, options.interactive); } // Show comprehensive summary @@ -597,30 +571,6 @@ function showAvailableTemplates() { console.log(''); } -async function runViteInstall(cwd: string) { - // install dependencies on non-CI environment - if (process.env.CI) { - return; - } - - const spinner = prompts.spinner(); - spinner.start(`Running vite install...`); - const { exitCode, stderr, stdout } = await runCommandSilently({ - command: 'vite', - args: ['install'], - cwd, - envs: process.env, - }); - if (exitCode === 0) { - spinner.stop(`Dependencies installed`); - } else { - spinner.stop(`Install failed`); - prompts.log.info(stdout.toString()); - prompts.log.error(stderr.toString()); - prompts.log.info(`You may need to run it manually in ${cwd}`); - } -} - main().catch((err) => { prompts.log.error(err.message); console.error(err); diff --git a/packages/global/src/gen/command.ts b/packages/global/src/gen/command.ts index 23176bb7b2..3aed0cb886 100644 --- a/packages/global/src/gen/command.ts +++ b/packages/global/src/gen/command.ts @@ -4,7 +4,12 @@ import path from 'node:path'; import { runCommand as runCommandWithFspy } from '@voidzero-dev/vite-plus/binding'; import spawn from 'cross-spawn'; -import type { ExecutionResult, WorkspaceInfo } from './types.ts'; +import type { WorkspaceInfo } from '../types/index.ts'; + +export interface ExecutionResult { + exitCode: number; + projectDir?: string; +} export interface RunCommandOptions { command: string; @@ -106,35 +111,6 @@ export async function runCommand(options: RunCommandOptions): Promise { - const child = spawn(options.command, options.args, { - stdio: 'pipe', - cwd: options.cwd, - env: options.envs, - }); - const promise = new Promise((resolve, reject) => { - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - child.stdout?.on('data', (data) => { - stdout.push(data); - }); - child.stderr?.on('data', (data) => { - stderr.push(data); - }); - child.on('close', (code) => { - resolve({ - exitCode: code ?? 0, - stdout: Buffer.concat(stdout), - stderr: Buffer.concat(stderr), - }); - }); - child.on('error', (err) => { - reject(err); - }); - }); - return await promise; -} - // Get the package runner command for each package manager export function getPackageRunner(workspaceInfo: WorkspaceInfo) { switch (workspaceInfo.packageManager) { diff --git a/packages/global/src/gen/discovery.ts b/packages/global/src/gen/discovery.ts index 1419a714ee..8b2e569e5c 100644 --- a/packages/global/src/gen/discovery.ts +++ b/packages/global/src/gen/discovery.ts @@ -1,8 +1,9 @@ import path from 'node:path'; +import type { WorkspaceInfo } from '../types/index.ts'; +import { readJsonFile } from '../utils/index.ts'; import { prependToPathToEnvs } from './command.ts'; -import { BuiltinTemplate, type TemplateInfo, TemplateType, type WorkspaceInfo } from './types.ts'; -import { readJsonFile } from './utils.ts'; +import { BuiltinTemplate, type TemplateInfo, TemplateType } from './templates/types.ts'; // Check if template name is a GitHub URL export function isGitHubUrl(templateName: string): boolean { diff --git a/packages/global/src/gen/migration.ts b/packages/global/src/gen/migration.ts deleted file mode 100644 index 06974975e9..0000000000 --- a/packages/global/src/gen/migration.ts +++ /dev/null @@ -1,118 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import * as prompts from '@clack/prompts'; -import { rewritePackageJsonScripts } from '@voidzero-dev/vite-plus/binding'; -import colors from 'picocolors'; - -import type { WorkspaceInfo } from './types.ts'; -import { editJsonFile, editOrCreateFile, pkgRoot, readJsonFile, templatesDir } from './utils.ts'; - -const rulesDir = path.join(pkgRoot, 'rules'); -const { gray } = colors; -const viteTools = ['vite', 'vitest', 'oxlint', 'oxfmt', 'tsdown']; - -// Detect standalone vite-related tools in project -export function detectStandaloneViteTools(projectDir: string, cwd: string): string[] { - const packageJsonPath = path.join(cwd, projectDir, 'package.json'); - - if (!fs.existsSync(packageJsonPath)) { - return []; - } - - const pkg = readJsonFile(packageJsonPath); - const allDeps = { - ...pkg.dependencies, - ...pkg.devDependencies, - }; - - const detected: string[] = []; - for (const tool of viteTools) { - if (allDeps[tool]) { - detected.push(tool); - } - } - return detected; -} - -export async function migratePackageJson(jsonFile: string): Promise { - const rulesYamlPath = path.join(rulesDir, 'package-json-scripts.yml'); - return await rewritePackageJsonScripts(jsonFile, rulesYamlPath); -} - -// Migrate standalone vite tools to vite-plus -export async function migrateToVitePlus( - projectDir: string, - cwd: string, - isMonorepo: boolean, -): Promise { - const packageJsonFile = path.join(projectDir, 'package.json'); - const packageJsonPath = path.join(cwd, packageJsonFile); - editJsonFile(packageJsonPath, (pkg) => { - // Track where vite was originally located (dependencies or devDependencies) - const viteInDependencies = !!pkg.dependencies?.['vite']; - - // Remove standalone tools - for (const tool of viteTools) { - if (pkg.dependencies?.[tool]) { - delete pkg.dependencies[tool]; - } - if (pkg.devDependencies?.[tool]) { - delete pkg.devDependencies[tool]; - } - } - - // Add vite-plus to the same location where vite was originally - if (isMonorepo) { - // Use catalog for monorepo - if (viteInDependencies) { - pkg.dependencies['vite'] = 'catalog:'; - } else { - if (!pkg.devDependencies) pkg.devDependencies = {}; - pkg.devDependencies['vite'] = 'catalog:'; - } - if (pkg.devDependencies?.['typescript']) { - pkg.devDependencies['typescript'] = 'catalog:'; - } - } else { - // Use npm alias for standalone - // TODO: use stable version of vite-plus after released to npm - if (viteInDependencies) { - pkg.dependencies['vite'] = 'npm:@voidzero-dev/vite-plus@latest'; - } else { - if (!pkg.devDependencies) pkg.devDependencies = {}; - pkg.devDependencies['vite'] = 'npm:@voidzero-dev/vite-plus@latest'; - } - - // set .npmrc to use vite-plus - editOrCreateFile(path.join(cwd, projectDir, '.npmrc'), (content) => { - const npmrc = fs.readFileSync(path.join(templatesDir, 'config/_npmrc'), 'utf-8'); - return content ? `${content.trimEnd()}\n${npmrc}` : npmrc; - }); - } - - return pkg; - }); - - const updated = await migratePackageJson(packageJsonPath); - if (updated) { - prompts.log.info(` ${gray('•')} Updated ${packageJsonFile} scripts`); - } -} - -// Perform auto-migration with prompts and feedback -export async function performAutoMigration(workspaceInfo: WorkspaceInfo, projectDir: string) { - const standaloneTools = detectStandaloneViteTools(projectDir, workspaceInfo.rootDir); - if (standaloneTools.length === 0) { - return; // No migration needed - } - - await migrateToVitePlus(projectDir, workspaceInfo.rootDir, workspaceInfo.isMonorepo); - prompts.log.success(`Migrated to vite-plus ${gray('✓')}`); - prompts.log.info(` ${gray('•')} Removed: ${standaloneTools.join(', ')}`); - prompts.log.info( - ` ${gray('•')} Added: vite ${gray( - workspaceInfo.isMonorepo ? '(catalog:)' : '(npm:@voidzero-dev/vite-plus@latest)', - )}`, - ); -} diff --git a/packages/global/src/gen/prompts.ts b/packages/global/src/gen/prompts.ts index fa45e706b7..a80707ddac 100644 --- a/packages/global/src/gen/prompts.ts +++ b/packages/global/src/gen/prompts.ts @@ -1,10 +1,11 @@ import fs from 'node:fs'; +import path from 'node:path'; import * as prompts from '@clack/prompts'; import colors from 'picocolors'; import validateNpmPackageName from 'validate-npm-package-name'; -import { emptyDir, getProjectDirFromPackageName, isEmpty } from './utils.ts'; +import { getProjectDirFromPackageName } from './utils.ts'; const { cyan } = colors; @@ -86,3 +87,20 @@ export function cancelAndExit(message = 'Operation cancelled', exitCode = 0): ne prompts.cancel(message); process.exit(exitCode); } + +function isEmpty(path: string) { + const files = fs.readdirSync(path); + return files.length === 0 || (files.length === 1 && files[0] === '.git'); +} + +function emptyDir(dir: string) { + if (!fs.existsSync(dir)) { + return; + } + for (const file of fs.readdirSync(dir)) { + if (file === '.git') { + continue; + } + fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }); + } +} diff --git a/packages/global/src/gen/templates/builtin.ts b/packages/global/src/gen/templates/builtin.ts index 9106078350..a5c1f2cdaf 100644 --- a/packages/global/src/gen/templates/builtin.ts +++ b/packages/global/src/gen/templates/builtin.ts @@ -1,15 +1,12 @@ import assert from 'node:assert'; import path from 'node:path'; -import { - BuiltinTemplate, - type BuiltinTemplateInfo, - type ExecutionResult, - type WorkspaceInfo, -} from '../types.ts'; +import type { WorkspaceInfo } from '../../types/index.ts'; +import type { ExecutionResult } from '../command.ts'; import { setPackageName } from '../utils.ts'; import { executeGeneratorScaffold } from './generator.ts'; import { runRemoteTemplateCommand } from './remote.ts'; +import { BuiltinTemplate, type BuiltinTemplateInfo } from './types.ts'; export async function executeBuiltinTemplate( workspaceInfo: WorkspaceInfo, @@ -24,9 +21,10 @@ export async function executeBuiltinTemplate( if (templateInfo.command === BuiltinTemplate.application) { templateInfo.command = 'create-vite@latest'; - } - - if (templateInfo.command === BuiltinTemplate.library) { + if (!templateInfo.interactive) { + templateInfo.args.push('--no-interactive'); + } + } else if (templateInfo.command === BuiltinTemplate.library) { templateInfo.command = 'create-tsdown@latest'; if (!templateInfo.interactive) { // set default template for tsdown @@ -37,9 +35,6 @@ export async function executeBuiltinTemplate( } templateInfo.args.unshift(templateInfo.targetDir); - if (!templateInfo.interactive) { - templateInfo.args.push('--no-interactive'); - } // Handle remote/external templates with fspy monitoring const result = await runRemoteTemplateCommand( diff --git a/packages/global/src/gen/templates/generator.ts b/packages/global/src/gen/templates/generator.ts index 24a3ec9668..374d6b2537 100644 --- a/packages/global/src/gen/templates/generator.ts +++ b/packages/global/src/gen/templates/generator.ts @@ -3,8 +3,11 @@ import path from 'node:path'; import * as prompts from '@clack/prompts'; -import type { BuiltinTemplateInfo, ExecutionResult, WorkspaceInfo } from '../types.ts'; -import { copyDir, editJsonFile, templatesDir } from '../utils.ts'; +import type { WorkspaceInfo } from '../../types/index.ts'; +import { editJsonFile, templatesDir } from '../../utils/index.ts'; +import type { ExecutionResult } from '../command.ts'; +import { copyDir } from '../utils.ts'; +import type { BuiltinTemplateInfo } from './types.ts'; // Execute generator scaffold template export async function executeGeneratorScaffold( diff --git a/packages/global/src/gen/templates/monorepo.ts b/packages/global/src/gen/templates/monorepo.ts index d3c6e9ab6d..1e5d743381 100644 --- a/packages/global/src/gen/templates/monorepo.ts +++ b/packages/global/src/gen/templates/monorepo.ts @@ -5,24 +5,14 @@ import path from 'node:path'; import * as prompts from '@clack/prompts'; import spawn from 'cross-spawn'; +import { rewriteMonorepoProject } from '../../migration/migrator.ts'; +import { PackageManager, type WorkspaceInfo } from '../../types/index.ts'; +import { editJsonFile, templatesDir } from '../../utils/index.ts'; +import type { ExecutionResult } from '../command.ts'; import { discoverTemplate } from '../discovery.ts'; -import { migrateToVitePlus } from '../migration.ts'; -import { - type BuiltinTemplateInfo, - type ExecutionResult, - PackageManager, - type WorkspaceInfo, -} from '../types.ts'; -import { - copyDir, - editJsonFile, - getScopeFromPackageName, - renameFiles, - setPackageManager, - setPackageName, - templatesDir, -} from '../utils.ts'; +import { copyDir, setPackageName } from '../utils.ts'; import { runRemoteTemplateCommand } from './remote.ts'; +import { type BuiltinTemplateInfo } from './types.ts'; // Execute vite:monorepo - copy from templates/monorepo export async function executeMonorepoTemplate( @@ -88,7 +78,6 @@ export async function executeMonorepoTemplate( } } - setPackageManager(fullPath, workspaceInfo.downloadPackageManager); prompts.log.success('Monorepo template created'); // Ask user to init git repository or auto-init if --no-interactive @@ -143,20 +132,17 @@ export async function executeMonorepoTemplate( const appPackageName = workspaceInfo.monorepoScope ? `${workspaceInfo.monorepoScope}/website` : 'website'; - setPackageName(path.join(fullPath, appDir), appPackageName); + const appProjectPath = path.join(fullPath, appDir); + setPackageName(appProjectPath, appPackageName); // Perform auto-migration on the created app - await migrateToVitePlus( - appDir, - fullPath, // The monorepo directory - true, // Always in monorepo context - ); + rewriteMonorepoProject(appProjectPath, workspaceInfo.packageManager); // Automatically create a default library in packages/utils prompts.log.step('Creating default library in packages/utils...'); const libraryDir = 'packages/utils'; const libraryTemplateInfo = discoverTemplate( 'create-tsdown@latest', - [libraryDir, '--template', 'default', '--no-interactive'], + [libraryDir, '--template', 'default'], workspaceInfo, ); const libraryResult = await runRemoteTemplateCommand( @@ -172,13 +158,32 @@ export async function executeMonorepoTemplate( const libraryPackageName = workspaceInfo.monorepoScope ? `${workspaceInfo.monorepoScope}/utils` : 'utils'; - setPackageName(path.join(fullPath, libraryDir), libraryPackageName); + const libraryProjectPath = path.join(fullPath, libraryDir); + setPackageName(libraryProjectPath, libraryPackageName); // Perform auto-migration on the created library - await migrateToVitePlus( - libraryDir, - fullPath, // The monorepo directory - true, // Always in monorepo context - ); + rewriteMonorepoProject(libraryProjectPath, workspaceInfo.packageManager); return { exitCode: 0, projectDir: templateInfo.targetDir }; } + +const RENAME_FILES: Record = { + _gitignore: '.gitignore', + _npmrc: '.npmrc', + '_yarnrc.yml': '.yarnrc.yml', +}; + +function renameFiles(projectDir: string) { + for (const [from, to] of Object.entries(RENAME_FILES)) { + const fromPath = path.join(projectDir, from); + if (fs.existsSync(fromPath)) { + fs.renameSync(fromPath, path.join(projectDir, to)); + } + } +} + +function getScopeFromPackageName(packageName: string) { + if (packageName.startsWith('@')) { + return packageName.split('/')[0]; + } + return ''; +} diff --git a/packages/global/src/gen/templates/remote.ts b/packages/global/src/gen/templates/remote.ts index f8af149c6d..530fee4d1e 100644 --- a/packages/global/src/gen/templates/remote.ts +++ b/packages/global/src/gen/templates/remote.ts @@ -1,8 +1,14 @@ import * as prompts from '@clack/prompts'; import colors from 'picocolors'; -import { formatDlxCommand, runCommand, runCommandAndDetectProjectDir } from '../command.ts'; -import type { ExecutionResult, TemplateInfo, WorkspaceInfo } from '../types.ts'; +import type { WorkspaceInfo } from '../../types/index.ts'; +import { + type ExecutionResult, + formatDlxCommand, + runCommand, + runCommandAndDetectProjectDir, +} from '../command.ts'; +import type { TemplateInfo } from './types.ts'; const { gray, yellow } = colors; diff --git a/packages/global/src/gen/templates/types.ts b/packages/global/src/gen/templates/types.ts new file mode 100644 index 0000000000..dc9d1272df --- /dev/null +++ b/packages/global/src/gen/templates/types.ts @@ -0,0 +1,30 @@ +export const BuiltinTemplate = { + generator: 'vite:generator', + monorepo: 'vite:monorepo', + application: 'vite:application', + library: 'vite:library', +} as const; +export type BuiltinTemplate = (typeof BuiltinTemplate)[keyof typeof BuiltinTemplate]; + +export const TemplateType = { + builtin: 'builtin', + bingo: 'bingo', + remote: 'remote', +} as const; +export type TemplateType = (typeof TemplateType)[keyof typeof TemplateType]; + +export interface TemplateInfo { + command: string; + args: string[]; + envs: NodeJS.ProcessEnv; + type: TemplateType; + // The parent directory of the generated package, only for monorepo + // For example, "packages" + parentDir?: string; + interactive?: boolean; +} + +export interface BuiltinTemplateInfo extends Omit { + packageName: string; + targetDir: string; +} diff --git a/packages/global/src/gen/types.ts b/packages/global/src/gen/types.ts deleted file mode 100644 index 225beaf0e2..0000000000 --- a/packages/global/src/gen/types.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { DownloadPackageManagerResult } from '@voidzero-dev/vite-plus/binding'; - -export const BuiltinTemplate = { - generator: 'vite:generator', - monorepo: 'vite:monorepo', - application: 'vite:application', - library: 'vite:library', -} as const; -export type BuiltinTemplate = (typeof BuiltinTemplate)[keyof typeof BuiltinTemplate]; - -export const TemplateType = { - builtin: 'builtin', - bingo: 'bingo', - remote: 'remote', -} as const; -export type TemplateType = (typeof TemplateType)[keyof typeof TemplateType]; - -export interface TemplateInfo { - command: string; - args: string[]; - envs: NodeJS.ProcessEnv; - type: TemplateType; - // The parent directory of the generated package, only for monorepo - // For example, "packages" - parentDir?: string; - interactive?: boolean; -} - -export interface BuiltinTemplateInfo extends Omit { - packageName: string; - targetDir: string; -} - -export const PackageManager = { - pnpm: 'pnpm', - npm: 'npm', - yarn: 'yarn', -} as const; -export type PackageManager = (typeof PackageManager)[keyof typeof PackageManager]; - -export const DependencyType = { - dependencies: 'dependencies', - devDependencies: 'devDependencies', - peerDependencies: 'peerDependencies', - optionalDependencies: 'optionalDependencies', -} as const; -export type DependencyType = (typeof DependencyType)[keyof typeof DependencyType]; - -export interface WorkspaceInfo { - rootDir: string; - isMonorepo: boolean; - // The scope of the monorepo, e.g. @my - // This is used to determine the scope of the generated package - // For example, if the monorepo scope is @my, then the generated package will be @my/my-package - monorepoScope: string; - // The patterns of the workspace packages - // For example, ["apps/*", "packages/*", "services/*", "tools/*"] - workspacePatterns: string[]; - // The parent directories of the generated package - // For example, ["apps", "packages", "services", "tools"] - parentDirs: string[]; - packageManager: PackageManager; - packageManagerVersion: string; - downloadPackageManager: DownloadPackageManagerResult; - packages: WorkspacePackage[]; -} - -export interface WorkspaceInfoOptional extends Omit< - WorkspaceInfo, - 'packageManager' | 'downloadPackageManager' -> { - packageManager?: PackageManager; -} - -export interface WorkspacePackage { - name: string; - // The path of the package relative to the workspace root - path: string; - description?: string; - version?: string; - isTemplatePackage: boolean; -} - -export interface ViteOptions { - directory?: string; - interactive: boolean; - list: boolean; - help: boolean; -} - -export interface ExecutionResult { - exitCode: number; - projectDir?: string; -} diff --git a/packages/global/src/gen/utils.ts b/packages/global/src/gen/utils.ts index 3d2dfeb9a0..968f9c9fe0 100644 --- a/packages/global/src/gen/utils.ts +++ b/packages/global/src/gen/utils.ts @@ -1,18 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; -import { parse as parseYaml, stringify as stringifyYaml } from '@std/yaml'; -import type { DownloadPackageManagerResult } from '@voidzero-dev/vite-plus/binding'; import validateNpmPackageName from 'validate-npm-package-name'; -// Get the package root directory (packages/global) -// Built files are in dist/, templates are in templates/ -// So from dist/ we need to go up to the package root -export const pkgRoot = import.meta.dirname.endsWith('dist') - ? path.dirname(import.meta.dirname) - : path.join(import.meta.dirname, '../..'); - -export const templatesDir = path.join(pkgRoot, 'templates'); +import { editJsonFile } from '../utils/index.ts'; // Helper functions for file operations export function copy(src: string, dest: string) { @@ -33,67 +24,6 @@ export function copyDir(srcDir: string, destDir: string) { } } -export function editFile(file: string, callback: (content: string) => string) { - const content = fs.readFileSync(file, 'utf-8'); - fs.writeFileSync(file, callback(content), 'utf-8'); -} - -export function editOrCreateFile(file: string, callback: (content: string) => string) { - if (!fs.existsSync(file)) { - fs.writeFileSync(file, '', 'utf-8'); - } - editFile(file, callback); -} - -export function readYamlFile>(file: string): T { - const content = fs.readFileSync(file, 'utf-8'); - return parseYaml(content) as T; -} - -export function editYamlFile>(file: string, callback: (content: T) => T) { - const yaml = readYamlFile(file); - const newYaml = callback(yaml); - fs.writeFileSync(file, stringifyYaml(newYaml), 'utf-8'); -} - -export function editOrCreateYamlFile>( - file: string, - callback: (content: T) => T, -) { - if (!fs.existsSync(file)) { - fs.writeFileSync(file, '', 'utf-8'); - } - editYamlFile(file, callback); -} - -export function readJsonFile>(file: string): T { - const content = fs.readFileSync(file, 'utf-8'); - return JSON.parse(content) as T; -} - -export function editJsonFile>(file: string, callback: (content: T) => T) { - const json = readJsonFile(file); - const newJson = callback(json); - fs.writeFileSync(file, JSON.stringify(newJson, null, 2) + '\n', 'utf-8'); -} - -export function isEmpty(path: string) { - const files = fs.readdirSync(path); - return files.length === 0 || (files.length === 1 && files[0] === '.git'); -} - -export function emptyDir(dir: string) { - if (!fs.existsSync(dir)) { - return; - } - for (const file of fs.readdirSync(dir)) { - if (file === '.git') { - continue; - } - fs.rmSync(path.resolve(dir, file), { recursive: true, force: true }); - } -} - /** * Format the target directory into a valid directory name and package name * @@ -168,41 +98,6 @@ export function getProjectDirFromPackageName(packageName: string) { return packageName; } -export function getScopeFromPackageName(packageName: string) { - if (packageName.startsWith('@')) { - return packageName.split('/')[0]; - } - return ''; -} - -export const RENAME_FILES: Record = { - _gitignore: '.gitignore', - _npmrc: '.npmrc', - '_yarnrc.yml': '.yarnrc.yml', -}; - -export function renameFiles(projectDir: string) { - for (const [from, to] of Object.entries(RENAME_FILES)) { - const fromPath = path.join(projectDir, from); - if (fs.existsSync(fromPath)) { - fs.renameSync(fromPath, path.join(projectDir, to)); - } - } -} - -export function setPackageManager( - projectDir: string, - downloadPackageManager: DownloadPackageManagerResult, -) { - // set package manager - editJsonFile<{ packageManager?: string }>(path.join(projectDir, 'package.json'), (pkg) => { - if (!pkg.packageManager) { - pkg.packageManager = `${downloadPackageManager.name}@${downloadPackageManager.version}`; - } - return pkg; - }); -} - export function setPackageName(projectDir: string, packageName: string) { editJsonFile<{ name?: string }>(path.join(projectDir, 'package.json'), (pkg) => { pkg.name = packageName; diff --git a/packages/global/src/index.ts b/packages/global/src/index.ts index 2cd08e85bc..fe83b2cf2b 100644 --- a/packages/global/src/index.ts +++ b/packages/global/src/index.ts @@ -1,9 +1,11 @@ -// Parse command line arguments to intercept 'new' and 'gen' commands +// Parse command line arguments to intercept 'new', 'gen', and 'migration' commands const args = process.argv.slice(2); const command = args[0]; if (command === 'gen' || command === 'g' || command === 'generate' || command === 'new') { - import('./gen.ts'); + import('./gen/bin.ts'); +} else if (command === 'migration' || command === 'migrate') { + import('./migration/bin.ts'); } else { // Delegate all other commands to vite-plus CLI import('@voidzero-dev/vite-plus/bin'); diff --git a/packages/global/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap b/packages/global/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap new file mode 100644 index 0000000000..9b0675b3ea --- /dev/null +++ b/packages/global/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`rewritePackageJson > should remove vite tools from devDependencies and dependencies 1`] = ` +{ + "dependencies": { + "foo": "1.0.0", + "tsdown": "1.0.0", + }, + "devDependencies": {}, +} +`; + +exports[`rewritePackageJson > should rewrite package.json scripts 1`] = ` +{ + "lint-staged": { + "*.js": [ + "vite lint --fix --type-aware", + "vite fmt --fix", + ], + "*.ts": "vite fmt --fix", + }, + "scripts": { + "build": "pnpm install &&vite build -r && vite run build --watch && tsdown && tsc || exit 1", + "dev": "vite dev", + "dev_analyze": "vite dev --analyze", + "dev_cjs": "VITE_CJS_IGNORE_WARNING=true vite dev", + "dev_cjs_cross_env": "cross-env VITE_CJS_IGNORE_WARNING=true vite dev", + "dev_debug": "vite dev --debug", + "dev_help": "vite dev --help && vite dev -h", + "dev_host": "vite dev --host 0.0.0.0", + "dev_open": "vite dev --open", + "dev_port": "vite dev --port 3000", + "dev_profile": "vite dev --profile", + "dev_stats": "vite dev --stats", + "dev_trace": "vite dev --trace", + "dev_verbose": "vite dev --verbose", + "fmt": "vite fmt", + "fmt_config": "vite fmt --config .oxfmt.json", + "lib": "tsdown", + "lib_watch": "tsdown --watch", + "lint": "vite lint", + "lint_config": "vite lint --config .oxlint.json", + "lint_type_aware": "vite lint --type-aware", + "optimize": "vite optimize", + "preview": "vite preview", + "ready": "vite lint --fix --type-aware && vite test run && tsdown && vite fmt --fix", + "ready_env": "NODE_ENV=test FOO=bar vite lint --fix --type-aware && NODE_ENV=test FOO=bar vite test run && NODE_ENV=test FOO=bar tsdown && NODE_ENV=test FOO=bar vite fmt --fix", + "ready_new": "vite install && vite fmt && vite lint --type-aware && vite test -r && vite build -r", + "test": "vite test", + "test_run": "vite test run && vite test --ui", + }, +} +`; diff --git a/packages/global/src/migration/__tests__/migrator.spec.ts b/packages/global/src/migration/__tests__/migrator.spec.ts new file mode 100644 index 0000000000..0c9deed6c2 --- /dev/null +++ b/packages/global/src/migration/__tests__/migrator.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { rewritePackageJson } from '../migrator.ts'; + +describe('rewritePackageJson', () => { + it('should rewrite package.json scripts', async () => { + const pkg = { + scripts: { + test: 'vitest', + test_run: 'vitest run && vitest --ui', + lint: 'oxlint', + lint_config: 'oxlint --config .oxlint.json', + lint_type_aware: 'oxlint --type-aware', + fmt: 'oxfmt', + fmt_config: 'oxfmt --config .oxfmt.json', + lib: 'tsdown', + lib_watch: 'tsdown --watch', + preview: 'vite preview', + optimize: 'vite optimize', + build: 'pnpm install &&vite build -r && vite run build --watch && tsdown && tsc || exit 1', + dev: 'vite', + dev_cjs: 'VITE_CJS_IGNORE_WARNING=true vite', + dev_cjs_cross_env: 'cross-env VITE_CJS_IGNORE_WARNING=true vite', + dev_help: 'vite --help && vite -h', + dev_port: 'vite --port 3000', + dev_host: 'vite --host 0.0.0.0', + dev_open: 'vite --open', + dev_verbose: 'vite --verbose', + dev_debug: 'vite --debug', + dev_trace: 'vite --trace', + dev_profile: 'vite --profile', + dev_stats: 'vite --stats', + dev_analyze: 'vite --analyze', + ready: 'oxlint --fix --type-aware && vitest run && tsdown && oxfmt --fix', + ready_env: + 'NODE_ENV=test FOO=bar oxlint --fix --type-aware && NODE_ENV=test FOO=bar vitest run && NODE_ENV=test FOO=bar tsdown && NODE_ENV=test FOO=bar oxfmt --fix', + ready_new: + 'vite install && vite fmt && vite lint --type-aware && vite test -r && vite build -r', + }, + 'lint-staged': { + '*.js': ['oxlint --fix --type-aware', 'oxfmt --fix'], + '*.ts': 'oxfmt --fix', + }, + }; + rewritePackageJson(pkg); + expect(pkg).toMatchSnapshot(); + }); + + it('should remove vite tools from devDependencies and dependencies', async () => { + const pkg = { + devDependencies: { + oxlint: '1.0.0', + oxfmt: '1.0.0', + }, + dependencies: { + foo: '1.0.0', + tsdown: '1.0.0', + }, + }; + rewritePackageJson(pkg); + expect(pkg).toMatchSnapshot(); + }); +}); diff --git a/packages/global/src/migration/bin.ts b/packages/global/src/migration/bin.ts new file mode 100644 index 0000000000..1ab5add3f9 --- /dev/null +++ b/packages/global/src/migration/bin.ts @@ -0,0 +1,130 @@ +import path from 'node:path'; + +import * as prompts from '@clack/prompts'; +import mri from 'mri'; +import colors from 'picocolors'; +import semver from 'semver'; + +import type { WorkspaceInfo } from '../types/index.ts'; +import { + defaultInteractive, + detectWorkspace, + selectPackageManager, + downloadPackageManager, + runViteInstall, + upgradeYarn, +} from '../utils/index.ts'; +import { rewriteMonorepo, rewriteStandaloneProject } from './migrator.ts'; + +const { cyan, green, gray } = colors; + +// prettier-ignore +const helpMessage = `\ +Usage: vite migration [PATH] [OPTIONS] + +Migrate standalone vite, vitest, oxlint, and oxfmt to unified vite-plus. + +Arguments: + PATH Target directory to migrate (default: current directory) + +Options: + --no-interactive Run in non-interactive mode (skip prompts and use defaults) + -h, --help Show this help message + +Examples: + ${gray('# Migrate current package')} + vite migration + + ${gray('# Migrate specific directory')} + vite migration my-app + + ${gray('# Non-interactive mode')} + vite migration --no-interactive + +Aliases: ${gray('migrate')} +`; + +export interface MigrationOptions { + interactive: boolean; + help?: boolean; +} + +function parseArgs() { + const args = process.argv.slice(3); // Skip 'node', 'vite', 'migration' + + const parsed = mri<{ + help?: boolean; + interactive?: boolean; + }>(args, { + alias: { h: 'help' }, + boolean: ['help', 'interactive'], + default: { interactive: defaultInteractive() }, + }); + + let projectPath = parsed._[0] as string; + if (projectPath) { + projectPath = path.resolve(process.cwd(), projectPath); + } else { + projectPath = process.cwd(); + } + + return { + projectPath, + options: { + interactive: parsed.interactive, + help: parsed.help, + } as MigrationOptions, + }; +} + +async function main() { + const { projectPath, options } = parseArgs(); + + // Handle help flag + if (options.help) { + console.log(helpMessage); + return; + } + + // Start migration + prompts.intro(cyan('Vite+ Migration')); + + const workspaceInfoOptional = await detectWorkspace(projectPath); + // Prompt for package manager or use default + const packageManager = + workspaceInfoOptional.packageManager ?? (await selectPackageManager(options.interactive)); + + // ensure the package manager is installed by vite-plus + const downloadResult = await downloadPackageManager( + packageManager, + workspaceInfoOptional.packageManagerVersion, + options.interactive, + ); + const workspaceInfo: WorkspaceInfo = { + ...workspaceInfoOptional, + packageManager, + downloadPackageManager: downloadResult, + }; + + // support catalog require yarn@>=4.10.0 https://yarnpkg.com/features/catalogs + // if `yarn<4.10.0 && yarn>=4.0.0`, upgrade yarn to stable version + if (packageManager === 'yarn' && semver.satisfies(downloadResult.version, '>=4.0.0 <4.10.0')) { + await upgradeYarn(projectPath, options.interactive); + } + + if (workspaceInfo.isMonorepo) { + rewriteMonorepo(workspaceInfo); + } else { + rewriteStandaloneProject(projectPath, workspaceInfo); + } + + await runViteInstall(projectPath, options.interactive); + + prompts.outro(green('✨ Migration completed!')); +} + +main().catch((err) => { + prompts.log.error(err.message); + console.error(err); + process.exit(1); +}); diff --git a/packages/global/src/migration/detector.ts b/packages/global/src/migration/detector.ts new file mode 100644 index 0000000000..467449912c --- /dev/null +++ b/packages/global/src/migration/detector.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export interface ConfigFiles { + viteConfig?: string; + oxlintConfig?: string; + oxfmtConfig?: string; +} + +export function detectConfigs(projectPath: string): ConfigFiles { + const configs: ConfigFiles = {}; + + // Check for vite.config.* + // https://vite.dev/config/ + const viteConfigs = ['vite.config.ts', 'vite.config.js']; + for (const config of viteConfigs) { + if (fs.existsSync(path.join(projectPath, config))) { + configs.viteConfig = config; + break; + } + } + + // TODO: Check for vitest.config.* + // https://vitest.dev/config/ + // const vitestConfigs = [ + // 'vitest.config.ts', + // 'vitest.config.js', + // ]; + // for (const config of vitestConfigs) { + // if (fs.existsSync(path.join(projectPath, config))) { + // configs.vitestConfig = config; + // break; + // } + // } + + // TODO: Check for tsdown.config.* + // https://tsdown.dev/options/config-file + // const tsdownConfigs = [ + // 'tsdown.config.ts', + // 'tsdown.config.mts', + // 'tsdown.config.cts', + // 'tsdown.config.js', + // 'tsdown.config.mjs', + // 'tsdown.config.cjs', + // 'tsdown.config.json', + // 'tsdown.config', + // ]; + // Additionally, you can define your configuration directly in the `tsdown` field of your package.json file + // for (const config of tsdownConfigs) { + // if (fs.existsSync(path.join(projectPath, config))) { + // configs.tsdownConfig = config; + // break; + // } + // } + + // Check for oxlint configs + // https://oxc.rs/docs/guide/usage/linter/config.html#configuration-file-format + const oxlintConfigs = ['.oxlintrc.json']; + for (const config of oxlintConfigs) { + if (fs.existsSync(path.join(projectPath, config))) { + configs.oxlintConfig = config; + break; + } + } + + // Check for oxfmt configs + // https://oxc.rs/docs/guide/usage/formatter.html#configuration-file + const oxfmtConfigs = ['.oxfmtrc.json', '.oxfmtrc.jsonc']; + for (const config of oxfmtConfigs) { + if (fs.existsSync(path.join(projectPath, config))) { + configs.oxfmtConfig = config; + break; + } + } + + return configs; +} diff --git a/packages/global/src/migration/migrator.ts b/packages/global/src/migration/migrator.ts new file mode 100644 index 0000000000..39430dbf21 --- /dev/null +++ b/packages/global/src/migration/migrator.ts @@ -0,0 +1,416 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { rewriteScripts, type DownloadPackageManagerResult } from '@voidzero-dev/vite-plus/binding'; +import { Scalar, YAMLMap, YAMLSeq } from 'yaml'; + +import { PackageManager, type WorkspaceInfo } from '../types/index.ts'; +import { + scalarString, + editJsonFile, + editYamlFile, + rulesDir, + type YamlDocument, +} from '../utils/index.ts'; + +const VITE_PLUS_NAME = '@voidzero-dev/vite-plus'; +const VITE_PLUS_VERSION = 'latest'; +const OVERRIDE_PACKAGES = { + vite: 'npm:@voidzero-dev/vite-plus-core@latest', + vitest: 'npm:@voidzero-dev/vite-plus-test@latest', +} as const; +const REMOVE_PACKAGES = ['oxlint', 'oxlint-tsgolint', 'oxfmt']; + +/** + * Rewrite standalone project to add vite-plus dependencies + * @param projectPath - The path to the project + */ +export function rewriteStandaloneProject(projectPath: string, workspaceInfo: WorkspaceInfo): void { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return; + } + + const packageManager = workspaceInfo.packageManager; + editJsonFile<{ + overrides?: Record; + resolutions?: Record; + devDependencies?: Record; + dependencies?: Record; + scripts?: Record; + pnpm?: { + overrides?: Record; + // peerDependencyRules?: { + // allowAny?: string[]; + // allowedVersions?: Record; + // }; + }; + }>(packageJsonPath, (pkg) => { + if (packageManager === PackageManager.yarn) { + pkg.resolutions = { + ...pkg.resolutions, + ...OVERRIDE_PACKAGES, + }; + } else if (packageManager === PackageManager.npm) { + pkg.overrides = { + ...pkg.overrides, + ...OVERRIDE_PACKAGES, + }; + } else if (packageManager === PackageManager.pnpm) { + pkg.pnpm = { + ...pkg.pnpm, + overrides: { + ...pkg.pnpm?.overrides, + ...OVERRIDE_PACKAGES, + }, + }; + } + + for (const [key, version] of Object.entries(OVERRIDE_PACKAGES)) { + if (pkg.devDependencies?.[key]) { + pkg.devDependencies[key] = version; + } + if (pkg.dependencies?.[key]) { + pkg.dependencies[key] = version; + } + } + + // add vite-plus to devDependencies + pkg.devDependencies = { + ...pkg.devDependencies, + [VITE_PLUS_NAME]: VITE_PLUS_VERSION, + }; + + rewritePackageJson(pkg); + return pkg; + }); + + // set .npmrc to use vite-plus + rewriteNpmrc(projectPath); + rewriteLintStagedConfigFile(projectPath); + // set package manager + setPackageManager(projectPath, workspaceInfo.downloadPackageManager); +} + +/** + * Rewrite monorepo to add vite-plus dependencies + * @param workspaceInfo - The workspace info + */ +export function rewriteMonorepo(workspaceInfo: WorkspaceInfo): void { + // rewrite root workspace + if (workspaceInfo.packageManager === PackageManager.pnpm) { + rewritePnpmWorkspaceYaml(workspaceInfo.rootDir); + } else if (workspaceInfo.packageManager === PackageManager.yarn) { + rewriteYarnrcYml(workspaceInfo.rootDir); + } + rewriteRootWorkspacePackageJson(workspaceInfo.rootDir, workspaceInfo.packageManager); + + // rewrite packages + for (const pkg of workspaceInfo.packages) { + rewriteMonorepoProject( + path.join(workspaceInfo.rootDir, pkg.path), + workspaceInfo.packageManager, + ); + } + + // set .npmrc to use vite-plus + rewriteNpmrc(workspaceInfo.rootDir); + rewriteLintStagedConfigFile(workspaceInfo.rootDir); + // set package manager + setPackageManager(workspaceInfo.rootDir, workspaceInfo.downloadPackageManager); +} + +/** + * Rewrite monorepo project to add vite-plus dependencies + * @param projectPath - The path to the project + */ +export function rewriteMonorepoProject(projectPath: string, packageManager: PackageManager): void { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return; + } + + editJsonFile<{ + devDependencies?: Record; + dependencies?: Record; + scripts?: Record; + }>(packageJsonPath, (pkg) => { + const isNpm = packageManager === PackageManager.npm; + for (const [key, value] of Object.entries(OVERRIDE_PACKAGES)) { + const version = isNpm ? value : 'catalog:'; + if (pkg.devDependencies?.[key]) { + pkg.devDependencies[key] = version; + } + if (pkg.dependencies?.[key]) { + pkg.dependencies[key] = version; + } + } + + // rewrite scripts in package.json + rewritePackageJson(pkg); + return pkg; + }); +} + +/** + * Rewrite pnpm-workspace.yaml to add vite-plus dependencies + * @param projectPath - The path to the project + */ +function rewritePnpmWorkspaceYaml(projectPath: string): void { + const pnpmWorkspaceYamlPath = path.join(projectPath, 'pnpm-workspace.yaml'); + if (!fs.existsSync(pnpmWorkspaceYamlPath)) { + fs.writeFileSync(pnpmWorkspaceYamlPath, ''); + } + + editYamlFile(pnpmWorkspaceYamlPath, (doc) => { + // catalog + rewriteCatalog(doc); + + // overrides + for (const key of Object.keys(OVERRIDE_PACKAGES)) { + doc.setIn(['overrides', key], scalarString('catalog:')); + } + + // peerDependencyRules.allowAny + let allowAny = doc.getIn(['peerDependencyRules', 'allowAny']) as YAMLSeq>; + if (!allowAny) { + allowAny = new YAMLSeq>(); + } + const existing = new Set(allowAny.items.map((n) => n.value)); + for (const key of Object.keys(OVERRIDE_PACKAGES)) { + if (!existing.has(key)) { + allowAny.add(scalarString(key)); + } + } + doc.setIn(['peerDependencyRules', 'allowAny'], allowAny); + + // peerDependencyRules.allowedVersions + let allowedVersions = doc.getIn(['peerDependencyRules', 'allowedVersions']) as YAMLMap< + Scalar, + Scalar + >; + if (!allowedVersions) { + allowedVersions = new YAMLMap, Scalar>(); + } + for (const key of Object.keys(OVERRIDE_PACKAGES)) { + allowedVersions.set(scalarString(key), scalarString('*')); + } + doc.setIn(['peerDependencyRules', 'allowedVersions'], allowedVersions); + + // minimumReleaseAgeExclude + if (doc.has('minimumReleaseAge')) { + // add @voidzero-dev/*, vite, vitest to minimumReleaseAgeExclude + let minimumReleaseAgeExclude = doc.getIn(['minimumReleaseAgeExclude']) as YAMLSeq< + Scalar + >; + if (!minimumReleaseAgeExclude) { + minimumReleaseAgeExclude = new YAMLSeq(); + } + const existing = new Set(minimumReleaseAgeExclude.items.map((n) => n.value)); + if (!existing.has('@voidzero-dev/*')) { + minimumReleaseAgeExclude.add(scalarString('@voidzero-dev/*')); + } + for (const key of Object.keys(OVERRIDE_PACKAGES)) { + if (!existing.has(key)) { + minimumReleaseAgeExclude.add(scalarString(key)); + } + } + doc.setIn(['minimumReleaseAgeExclude'], minimumReleaseAgeExclude); + } + }); +} + +/** + * Rewrite .yarnrc.yml to add vite-plus dependencies + * @param projectPath - The path to the project + */ +function rewriteYarnrcYml(projectPath: string): void { + const yarnrcYmlPath = path.join(projectPath, '.yarnrc.yml'); + if (!fs.existsSync(yarnrcYmlPath)) { + fs.writeFileSync(yarnrcYmlPath, ''); + } + + editYamlFile(yarnrcYmlPath, (doc) => { + // catalog + rewriteCatalog(doc); + + // TODO: remove this when vite-plus is released to npm + // npmScopes: + // voidzero-dev: + // npmRegistryServer: 'https://npm.pkg.github.com' + // npmAuthToken: '${GITHUB_TOKEN}' + doc.setIn( + ['npmScopes', 'voidzero-dev', 'npmRegistryServer'], + scalarString('https://npm.pkg.github.com'), + ); + // don't set if it already exists + if (!doc.getIn(['npmScopes', 'voidzero-dev', 'npmAuthToken'])) { + doc.setIn(['npmScopes', 'voidzero-dev', 'npmAuthToken'], scalarString('${GITHUB_TOKEN}')); + } + }); +} + +/** + * Rewrite catalog in pnpm-workspace.yaml or .yarnrc.yml + * @param doc - The document to rewrite + */ +function rewriteCatalog(doc: YamlDocument): void { + for (const [key, value] of Object.entries(OVERRIDE_PACKAGES)) { + doc.setIn(['catalog', key], scalarString(value)); + } + doc.setIn(['catalog', VITE_PLUS_NAME], scalarString(VITE_PLUS_VERSION)); + for (const name of REMOVE_PACKAGES) { + doc.deleteIn(['catalog', name]); + } + + // TODO: rewrite `catalogs` when OVERRIDE_PACKAGES exists in catalog +} + +/** + * Rewrite root workspace package.json to add vite-plus dependencies + * @param projectPath - The path to the project + */ +function rewriteRootWorkspacePackageJson( + projectPath: string, + packageManager: PackageManager, +): void { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return; + } + + editJsonFile<{ + resolutions?: Record; + overrides?: Record; + devDependencies?: Record; + }>(packageJsonPath, (pkg) => { + if (packageManager === PackageManager.yarn) { + pkg.resolutions = { + ...pkg.resolutions, + // FIXME: yarn don't support catalog on resolutions + // https://github.com/yarnpkg/berry/issues/6979 + ...OVERRIDE_PACKAGES, + }; + } else if (packageManager === PackageManager.npm) { + pkg.overrides = { + ...pkg.overrides, + ...OVERRIDE_PACKAGES, + }; + } + // pnpm use overrides field at pnpm-workspace.yaml + + // add vite-plus to devDependencies + pkg.devDependencies = { + ...pkg.devDependencies, + [VITE_PLUS_NAME]: packageManager === PackageManager.npm ? VITE_PLUS_VERSION : 'catalog:', + }; + return pkg; + }); + + // rewrite package.json + rewriteMonorepoProject(projectPath, packageManager); +} + +const RULES_YAML_PATH = path.join(rulesDir, 'vite-tools.yml'); + +export function rewritePackageJson(pkg: { + scripts?: Record; + 'lint-staged'?: Record; + devDependencies?: Record; + dependencies?: Record; +}): void { + if (pkg.scripts) { + const updated = rewriteScripts( + JSON.stringify(pkg.scripts), + fs.readFileSync(RULES_YAML_PATH, 'utf8'), + ); + if (updated) { + pkg.scripts = JSON.parse(updated); + } + } + if (pkg['lint-staged']) { + const updated = rewriteScripts( + JSON.stringify(pkg['lint-staged']), + fs.readFileSync(RULES_YAML_PATH, 'utf8'), + ); + if (updated) { + pkg['lint-staged'] = JSON.parse(updated); + } + } + // remove packages that are replaced with vite-plus + for (const name of REMOVE_PACKAGES) { + if (pkg.devDependencies?.[name]) { + delete pkg.devDependencies[name]; + } + if (pkg.dependencies?.[name]) { + delete pkg.dependencies[name]; + } + } +} + +// https://github.com/lint-staged/lint-staged?tab=readme-ov-file#configuration +// only support json format +function rewriteLintStagedConfigFile(projectPath: string): void { + const names = ['.lintstagedrc.json', '.lintstagedrc']; + for (const name of names) { + const lintStagedConfigJsonPath = path.join(projectPath, name); + if (fs.existsSync(lintStagedConfigJsonPath)) { + editJsonFile>(lintStagedConfigJsonPath, (config) => { + const updated = rewriteScripts( + JSON.stringify(config), + fs.readFileSync(RULES_YAML_PATH, 'utf8'), + ); + if (updated) { + return JSON.parse(updated); + } + }); + } + } +} + +// TODO: should remove this function after vite-plus is released to npm +/** + * Rewrite .npmrc to add custom registry and auth token + * ``` + * @voidzero-dev:registry=https://npm.pkg.github.com/ + * //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} + * ``` + * @param projectPath - The path to the project + */ +function rewriteNpmrc(projectPath: string): void { + const npmrcPath = path.join(projectPath, '.npmrc'); + if (!fs.existsSync(npmrcPath)) { + fs.writeFileSync(npmrcPath, ''); + } + + let changed = false; + let content = fs.readFileSync(npmrcPath, 'utf-8'); + const customRegistry = `@voidzero-dev:registry=https://npm.pkg.github.com/`; + if (!content.includes(customRegistry)) { + content = content ? `${content.trimEnd()}\n${customRegistry}` : customRegistry; + changed = true; + } + // don't set if it already exists + let customAuthToken = '//npm.pkg.github.com/:_authToken='; + if (!content.includes(customAuthToken)) { + customAuthToken += '${GITHUB_TOKEN}'; + content = content ? `${content.trimEnd()}\n${customAuthToken}` : customAuthToken; + changed = true; + } + if (changed) { + fs.writeFileSync(npmrcPath, content); + } +} + +function setPackageManager( + projectDir: string, + downloadPackageManager: DownloadPackageManagerResult, +) { + // set package manager + editJsonFile<{ packageManager?: string }>(path.join(projectDir, 'package.json'), (pkg) => { + if (!pkg.packageManager) { + pkg.packageManager = `${downloadPackageManager.name}@${downloadPackageManager.version}`; + } + return pkg; + }); +} diff --git a/packages/global/src/types/index.ts b/packages/global/src/types/index.ts new file mode 100644 index 0000000000..cd89b6ba41 --- /dev/null +++ b/packages/global/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './package.ts'; +export * from './workspace.ts'; diff --git a/packages/global/src/types/package.ts b/packages/global/src/types/package.ts new file mode 100644 index 0000000000..8e55e3f48e --- /dev/null +++ b/packages/global/src/types/package.ts @@ -0,0 +1,14 @@ +export const PackageManager = { + pnpm: 'pnpm', + npm: 'npm', + yarn: 'yarn', +} as const; +export type PackageManager = (typeof PackageManager)[keyof typeof PackageManager]; + +export const DependencyType = { + dependencies: 'dependencies', + devDependencies: 'devDependencies', + peerDependencies: 'peerDependencies', + optionalDependencies: 'optionalDependencies', +} as const; +export type DependencyType = (typeof DependencyType)[keyof typeof DependencyType]; diff --git a/packages/global/src/types/workspace.ts b/packages/global/src/types/workspace.ts new file mode 100644 index 0000000000..1a96696023 --- /dev/null +++ b/packages/global/src/types/workspace.ts @@ -0,0 +1,38 @@ +import type { DownloadPackageManagerResult } from '@voidzero-dev/vite-plus/binding'; + +import type { PackageManager } from './package.ts'; + +export interface WorkspacePackage { + name: string; + // The path of the package relative to the workspace root + path: string; + description?: string; + version?: string; + isTemplatePackage: boolean; +} + +export interface WorkspaceInfo { + rootDir: string; + isMonorepo: boolean; + // The scope of the monorepo, e.g. @my + // This is used to determine the scope of the generated package + // For example, if the monorepo scope is @my, then the generated package will be @my/my-package + monorepoScope: string; + // The patterns of the workspace packages + // For example, ["apps/*", "packages/*", "services/*", "tools/*"] + workspacePatterns: string[]; + // The parent directories of the generated package + // For example, ["apps", "packages", "services", "tools"] + parentDirs: string[]; + packageManager: PackageManager; + packageManagerVersion: string; + downloadPackageManager: DownloadPackageManagerResult; + packages: WorkspacePackage[]; +} + +export interface WorkspaceInfoOptional extends Omit< + WorkspaceInfo, + 'packageManager' | 'downloadPackageManager' +> { + packageManager?: PackageManager; +} diff --git a/packages/global/src/utils/command.ts b/packages/global/src/utils/command.ts new file mode 100644 index 0000000000..99e70b8b1e --- /dev/null +++ b/packages/global/src/utils/command.ts @@ -0,0 +1,43 @@ +import spawn from 'cross-spawn'; + +export interface RunCommandOptions { + command: string; + args: string[]; + cwd: string; + envs: NodeJS.ProcessEnv; +} + +export interface RunCommandResult { + exitCode: number; + stdout: Buffer; + stderr: Buffer; +} + +export async function runCommandSilently(options: RunCommandOptions): Promise { + const child = spawn(options.command, options.args, { + stdio: 'pipe', + cwd: options.cwd, + env: options.envs, + }); + const promise = new Promise((resolve, reject) => { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout?.on('data', (data) => { + stdout.push(data); + }); + child.stderr?.on('data', (data) => { + stderr.push(data); + }); + child.on('close', (code) => { + resolve({ + exitCode: code ?? 0, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }); + }); + child.on('error', (err) => { + reject(err); + }); + }); + return await promise; +} diff --git a/packages/global/src/utils/index.ts b/packages/global/src/utils/index.ts new file mode 100644 index 0000000000..42fe7cdf10 --- /dev/null +++ b/packages/global/src/utils/index.ts @@ -0,0 +1,7 @@ +export * from './json.ts'; +export * from './package.ts'; +export * from './path.ts'; +export * from './prompts.ts'; +export * from './workspace.ts'; +export * from './yaml.ts'; +export * from './command.ts'; diff --git a/packages/global/src/utils/json.ts b/packages/global/src/utils/json.ts new file mode 100644 index 0000000000..8c21708525 --- /dev/null +++ b/packages/global/src/utils/json.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs'; + +import detectIndent from 'detect-indent'; +import { detectNewline } from 'detect-newline'; + +export function readJsonFile>(file: string): T { + const content = fs.readFileSync(file, 'utf-8'); + return JSON.parse(content) as T; +} + +export function writeJsonFile>(file: string, data: T) { + let newline = '\n'; + let indent = ' '; + if (fs.existsSync(file)) { + const content = fs.readFileSync(file, 'utf-8'); + // keep the original newline and indent + indent = detectIndent(content).indent; + newline = detectNewline(content) ?? ''; + } + fs.writeFileSync(file, JSON.stringify(data, null, indent) + newline, 'utf-8'); +} + +export function editJsonFile>( + file: string, + callback: (content: T) => T | undefined, +) { + const json = readJsonFile(file); + const newJson = callback(json); + if (newJson) { + writeJsonFile(file, newJson); + } +} diff --git a/packages/global/src/utils/package.ts b/packages/global/src/utils/package.ts new file mode 100644 index 0000000000..ee9e7b33a4 --- /dev/null +++ b/packages/global/src/utils/package.ts @@ -0,0 +1,6 @@ +export function getScopeFromPackageName(packageName: string): string { + if (packageName.startsWith('@')) { + return packageName.split('/')[0]; + } + return ''; +} diff --git a/packages/global/src/utils/path.ts b/packages/global/src/utils/path.ts new file mode 100644 index 0000000000..a1c81aa566 --- /dev/null +++ b/packages/global/src/utils/path.ts @@ -0,0 +1,11 @@ +import path from 'node:path'; + +// Get the package root directory (packages/global) +// Built files are in dist/, templates are in templates/ +// So from dist/ we need to go up to the package root +export const pkgRoot = import.meta.dirname.endsWith('dist') + ? path.dirname(import.meta.dirname) + : path.join(import.meta.dirname, '../..'); + +export const templatesDir = path.join(pkgRoot, 'templates'); +export const rulesDir = path.join(pkgRoot, 'rules'); diff --git a/packages/global/src/utils/prompts.ts b/packages/global/src/utils/prompts.ts new file mode 100644 index 0000000000..5ba67dfed5 --- /dev/null +++ b/packages/global/src/utils/prompts.ts @@ -0,0 +1,122 @@ +import * as prompts from '@clack/prompts'; +import { downloadPackageManager as downloadPackageManagerBinding } from '@voidzero-dev/vite-plus/binding'; +import colors from 'picocolors'; + +import { PackageManager } from '../types/index.ts'; +import { runCommandSilently } from './command.ts'; + +const { cyan } = colors; + +export function cancelAndExit(message = 'Operation cancelled', exitCode = 0): never { + prompts.cancel(message); + process.exit(exitCode); +} + +export async function selectPackageManager(interactive?: boolean) { + if (interactive) { + const selected = await prompts.select({ + message: 'Which package manager would you like to use?', + options: [ + { value: PackageManager.pnpm, hint: 'recommended' }, + { value: PackageManager.yarn }, + { value: PackageManager.npm }, + ], + initialValue: PackageManager.pnpm, + }); + + if (prompts.isCancel(selected)) { + cancelAndExit(); + } + + return selected; + } else { + // --no-interactive: use pnpm as default + prompts.log.info(`Using default package manager: ${cyan(PackageManager.pnpm)}`); + return PackageManager.pnpm; + } +} + +export async function downloadPackageManager( + packageManager: PackageManager, + version: string, + interactive?: boolean, +) { + const spinner = getSpinner(interactive); + spinner.start(`${packageManager}@${version} installing...`); + const downloadResult = await downloadPackageManagerBinding({ + name: packageManager, + version, + }); + spinner.stop(`${packageManager}@${downloadResult.version} installed`); + return downloadResult; +} + +export async function runViteInstall(cwd: string, interactive?: boolean) { + // install dependencies on non-CI environment + if (process.env.CI) { + return; + } + + const spinner = getSpinner(interactive); + spinner.start(`Running vite install...`); + const { exitCode, stderr, stdout } = await runCommandSilently({ + command: 'vite', + args: ['install'], + cwd, + envs: process.env, + }); + if (exitCode === 0) { + spinner.stop(`Dependencies installed`); + } else { + spinner.stop(`Install failed`); + prompts.log.info(stdout.toString()); + prompts.log.error(stderr.toString()); + prompts.log.info(`You may need to run "vite install" manually in ${cwd}`); + } +} + +export async function upgradeYarn(cwd: string, interactive?: boolean) { + const spinner = getSpinner(interactive); + spinner.start(`Running yarn set version stable...`); + const { exitCode, stderr, stdout } = await runCommandSilently({ + command: 'yarn', + args: ['set', 'version', 'stable'], + cwd, + envs: process.env, + }); + if (exitCode === 0) { + spinner.stop(`Yarn upgraded to stable version`); + } else { + spinner.stop(`yarn upgrade failed`); + prompts.log.info(stdout.toString()); + prompts.log.error(stderr.toString()); + } +} + +export function defaultInteractive() { + // If CI environment, use non-interactive mode by default + return !process.env.CI && process.stdin.isTTY; +} + +function getSpinner(interactive?: boolean) { + if (interactive) { + return prompts.spinner(); + } + return { + start: (msg?: string) => { + if (msg) { + prompts.log.info(msg); + } + }, + stop: (msg?: string) => { + if (msg) { + prompts.log.info(msg); + } + }, + message: (msg?: string) => { + if (msg) { + prompts.log.info(msg); + } + }, + }; +} diff --git a/packages/global/src/gen/workspace.ts b/packages/global/src/utils/workspace.ts similarity index 91% rename from packages/global/src/gen/workspace.ts rename to packages/global/src/utils/workspace.ts index 29c17e61f9..dbe6456939 100644 --- a/packages/global/src/gen/workspace.ts +++ b/packages/global/src/utils/workspace.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { detectWorkspace as detectWorkspaceBinding } from '@voidzero-dev/vite-plus/binding'; import { globSync } from 'glob'; import { minimatch } from 'minimatch'; +import { Scalar, YAMLSeq } from 'yaml'; import { DependencyType, @@ -11,9 +12,10 @@ import { type WorkspaceInfo, type WorkspaceInfoOptional, type WorkspacePackage, -} from './types.ts'; -import { getScopeFromPackageName } from './utils.ts'; -import { editJsonFile, editOrCreateYamlFile, readJsonFile, readYamlFile } from './utils.ts'; +} from '../types/index.ts'; +import { editJsonFile, readJsonFile } from './json.ts'; +import { getScopeFromPackageName } from './package.ts'; +import { editYamlFile, readYamlFile } from './yaml.ts'; export function findPackageJsonFilesFromPatterns(patterns: string[], cwd: string): string[] { if (patterns.length === 0) { @@ -180,13 +182,14 @@ export function updateWorkspaceConfig(projectPath: string, workspaceInfo: Worksp } if (workspaceInfo.packageManager === PackageManager.pnpm) { - editOrCreateYamlFile<{ packages?: string[] }>( - path.join(workspaceInfo.rootDir, 'pnpm-workspace.yaml'), - (workspaceConfig) => { - workspaceConfig.packages = [...(workspaceConfig.packages || []), pattern]; - return workspaceConfig; - }, - ); + editYamlFile(path.join(workspaceInfo.rootDir, 'pnpm-workspace.yaml'), (doc) => { + let packages = doc.getIn(['packages']) as YAMLSeq>; + if (!packages) { + packages = new YAMLSeq>(); + } + packages.add(new Scalar(pattern)); + doc.setIn(['packages'], packages); + }); } else { // Update package.json workspaces editJsonFile<{ workspaces?: string[] }>( diff --git a/packages/global/src/utils/yaml.ts b/packages/global/src/utils/yaml.ts new file mode 100644 index 0000000000..336daccc11 --- /dev/null +++ b/packages/global/src/utils/yaml.ts @@ -0,0 +1,22 @@ +import fs from 'node:fs'; + +import { type Document, type ParsedNode, parseDocument, parse as parseYaml, Scalar } from 'yaml'; + +export function readYamlFile>(file: string): T { + const content = fs.readFileSync(file, 'utf-8'); + return parseYaml(content) as T; +} + +export type YamlDocument = Document.Parsed; + +export function editYamlFile(file: string, callback: (doc: YamlDocument) => void) { + const content = fs.readFileSync(file, 'utf-8'); + const doc = parseDocument(content); + callback(doc); + // prefer single quotes + fs.writeFileSync(file, doc.toString({ singleQuote: true }), 'utf-8'); +} + +export function scalarString(value: string): Scalar { + return new Scalar(value); +} diff --git a/packages/global/templates/config/_npmrc b/packages/global/templates/config/_npmrc deleted file mode 100644 index df96cb23ef..0000000000 --- a/packages/global/templates/config/_npmrc +++ /dev/null @@ -1,2 +0,0 @@ -//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} -@voidzero-dev:registry=https://npm.pkg.github.com/ diff --git a/packages/global/templates/config/_yarnrc.yml b/packages/global/templates/config/_yarnrc.yml deleted file mode 100644 index f9a9a44413..0000000000 --- a/packages/global/templates/config/_yarnrc.yml +++ /dev/null @@ -1,5 +0,0 @@ -# used for install vite-plus -npmScopes: - voidzero-dev: - npmRegistryServer: 'https://npm.pkg.github.com' - npmAuthToken: '${GITHUB_TOKEN}' diff --git a/packages/global/templates/generator/package.json b/packages/global/templates/generator/package.json index b2ae79e56b..97873cebd5 100644 --- a/packages/global/templates/generator/package.json +++ b/packages/global/templates/generator/package.json @@ -22,7 +22,6 @@ }, "devDependencies": { "@types/node": "catalog:", - "typescript": "catalog:", - "vite-plus": "catalog:" + "typescript": "catalog:" } } diff --git a/packages/global/templates/monorepo/_npmrc b/packages/global/templates/monorepo/_npmrc deleted file mode 100644 index df96cb23ef..0000000000 --- a/packages/global/templates/monorepo/_npmrc +++ /dev/null @@ -1,2 +0,0 @@ -//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} -@voidzero-dev:registry=https://npm.pkg.github.com/ diff --git a/packages/global/templates/monorepo/_yarnrc.yml b/packages/global/templates/monorepo/_yarnrc.yml index f0f5e7a5cc..37ea06cc7d 100644 --- a/packages/global/templates/monorepo/_yarnrc.yml +++ b/packages/global/templates/monorepo/_yarnrc.yml @@ -4,11 +4,8 @@ npmScopes: npmRegistryServer: 'https://npm.pkg.github.com' npmAuthToken: '${GITHUB_TOKEN}' +catalogMode: prefer + catalog: '@types/node': ^24 - vite-plus: npm:@voidzero-dev/vite-plus@latest - vite: npm:@voidzero-dev/vite-plus@latest - vitest: npm:@voidzero-dev/vite-plus@latest typescript: ^5 - -catalogMode: prefer diff --git a/packages/global/templates/monorepo/package.json b/packages/global/templates/monorepo/package.json index 0de6e7a3b8..56def34a34 100644 --- a/packages/global/templates/monorepo/package.json +++ b/packages/global/templates/monorepo/package.json @@ -14,23 +14,5 @@ }, "engines": { "node": ">=22.12.0" - }, - "devDependencies": { - "vite-plus": "catalog:" - }, - "pnpm": { - "overrides": { - "vite": "catalog:", - "vitest": "catalog:" - }, - "peerDependencyRules": { - "allowAny": [ - "vite" - ] - } - }, - "resolutions": { - "vite": "catalog:", - "vitest": "catalog:" } } diff --git a/packages/global/templates/monorepo/pnpm-workspace.yaml b/packages/global/templates/monorepo/pnpm-workspace.yaml index dfa85ebd7a..81d0fef3a9 100644 --- a/packages/global/templates/monorepo/pnpm-workspace.yaml +++ b/packages/global/templates/monorepo/pnpm-workspace.yaml @@ -3,11 +3,8 @@ packages: - packages/* - tools/* +catalogMode: prefer + catalog: '@types/node': ^24 - vite-plus: npm:@voidzero-dev/vite-plus@latest - vite: npm:@voidzero-dev/vite-plus@latest - vitest: npm:@voidzero-dev/vite-plus@latest typescript: ^5 - -catalogMode: prefer diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index eef408b9e4..5507f2f61a 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -202,8 +202,7 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string) { let commandLine = `> ${cmd.command}`; if (exitCode !== 0) { - commandLine = - (exitCode === undefined ? '[timeout]' : `[${exitCode}]`) + commandLine; + commandLine = (exitCode === undefined ? '[timeout]' : `[${exitCode}]`) + commandLine; } else { // only allow ignore output if the command is successful if (cmd.ignoreOutput) { diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 1bd8a3c0f7..20b43dbd09 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -93,6 +93,8 @@ export function replaceUnstableOutput(output: string, cwd?: string) { .replaceAll(homedir(), '') // remove the newline after "Checking formatting..." .replaceAll(`Checking formatting...\n`, 'Checking formatting...') + // remove warning @: No license field + .replaceAll(/warning .+?: No license field\n/g, '') ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad2ff5d781..12c373d3d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,12 @@ catalogs: cross-spawn: specifier: ^7.0.5 version: 7.0.6 + detect-indent: + specifier: ^7.0.2 + version: 7.0.2 + detect-newline: + specifier: ^4.0.1 + version: 4.0.1 esbuild: specifier: ^0.27.0 version: 0.27.0 @@ -228,6 +234,9 @@ catalogs: ws: specifier: ^8.18.1 version: 8.18.3 + yaml: + specifier: ^2.8.1 + version: 2.8.1 overrides: rolldown: workspace:rolldown@* @@ -473,18 +482,24 @@ importers: '@clack/prompts': specifier: 'catalog:' version: 0.11.0 - '@std/yaml': - specifier: 'catalog:' - version: '@jsr/std__yaml@1.0.10' '@types/cross-spawn': specifier: 'catalog:' version: 6.0.6 + '@types/semver': + specifier: 'catalog:' + version: 7.7.1 '@types/validate-npm-package-name': specifier: 'catalog:' version: 4.0.2 '@voidzero-dev/vite-plus-tools': specifier: 'workspace:' version: link:../tools + detect-indent: + specifier: 'catalog:' + version: 7.0.2 + detect-newline: + specifier: 'catalog:' + version: 4.0.1 glob: specifier: 'catalog:' version: 13.0.0 @@ -500,6 +515,12 @@ importers: rolldown: specifier: workspace:rolldown@* version: link:../../rolldown/packages/rolldown + semver: + specifier: 'catalog:' + version: 7.7.3 + yaml: + specifier: 'catalog:' + version: 2.8.1 packages/test: dependencies: @@ -5220,6 +5241,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-indent@7.0.2: + resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} + engines: {node: '>=12.20'} + detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} @@ -5229,6 +5254,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-newline@4.0.1: + resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -11855,11 +11884,15 @@ snapshots: destr@2.0.5: {} + detect-indent@7.0.2: {} + detect-libc@1.0.3: optional: true detect-libc@2.1.2: {} + detect-newline@4.0.1: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d33b11e3bd..ac8a179572 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - rolldown/packages/* - rolldown-vite - rolldown-vite/packages/* + catalog: '@babel/core': ^7.24.7 '@babel/preset-env': ^7.24.7 @@ -14,13 +15,13 @@ catalog: '@napi-rs/wasm-runtime': ^1.0.0 '@oxc-node/cli': ^0.0.34 '@oxc-node/core': ^0.0.34 - '@oxc-project/runtime': =0.99.0 - '@oxc-project/types': =0.99.0 + '@oxc-project/runtime': '=0.99.0' + '@oxc-project/types': '=0.99.0' '@pnpm/find-workspace-packages': ^6.0.9 '@rollup/plugin-commonjs': ^29.0.0 '@rollup/plugin-json': ^6.1.0 '@rollup/plugin-node-resolve': ^16.0.0 - '@std/yaml': 'npm:@jsr/std__yaml@^1.0.10' + '@std/yaml': npm:@jsr/std__yaml@^1.0.10 '@types/babel__core': 7.20.5 '@types/connect': ^3.4.38 '@types/cross-spawn': ^6.0.6 @@ -51,6 +52,8 @@ catalog: cross-spawn: ^7.0.5 debug: ^4.4.3 dedent: ^1.5.3 + detect-indent: ^7.0.2 + detect-newline: ^4.0.1 diff: ^8.0.0 esbuild: ^0.27.0 estree-toolkit: ^1.7.8 @@ -61,6 +64,7 @@ catalog: fs-extra: ^11.2.0 glob: ^13.0.0 husky: ^9.1.7 + jsonc-parser: ^3.3.1 lint-staged: ^16.2.6 lodash-es: ^4.17.21 micromatch: ^4.0.9 @@ -68,9 +72,9 @@ catalog: mocha: ^11.0.0 mri: ^1.2.0 next: ^15.4.3 - oxc-minify: =0.97.0 - oxc-parser: =0.99.0 - oxc-transform: =0.99.0 + oxc-minify: '=0.97.0' + oxc-parser: '=0.99.0' + oxc-transform: '=0.99.0' oxfmt: ^0.15.0 oxlint: ^1.30.0 oxlint-tsgolint: ^0.8.3 @@ -107,15 +111,21 @@ catalog: vue: ^3.5.21 web-tree-sitter: ^0.25.0 ws: ^8.18.1 + yaml: ^2.8.1 zx: ^8.1.2 + catalogMode: prefer + ignoreScripts: true + minimumReleaseAge: 1440 + minimumReleaseAgeExclude: - '@napi-rs/*' - '@oxc-project/*' - '@rolldown/*' - '@vitest/*' + - '@vitejs/*' - '@types/*' - '@oxlint/*' - '@oxc-minify/*' @@ -137,12 +147,14 @@ minimumReleaseAgeExclude: - vite - vitepress - vitest + overrides: - rolldown: 'workspace:rolldown@*' - vite: 'workspace:@voidzero-dev/vite-plus-core@*' - vitest: 'workspace:@voidzero-dev/vite-plus-test@*' - vitest-dev: 'npm:vitest@^4.0.14' - '@rolldown/pluginutils': 'workspace:@rolldown/pluginutils@*' + '@rolldown/pluginutils': workspace:@rolldown/pluginutils@* + rolldown: workspace:rolldown@* + vite: workspace:@voidzero-dev/vite-plus-core@* + vitest: workspace:@voidzero-dev/vite-plus-test@* + vitest-dev: npm:vitest@^4.0.14 + packageExtensions: sass-embedded: peerDependencies: @@ -150,17 +162,19 @@ packageExtensions: peerDependenciesMeta: source-map-js: optional: true + patchedDependencies: - sirv@3.0.2: rolldown-vite/patches/sirv@3.0.2.patch chokidar@3.6.0: rolldown-vite/patches/chokidar@3.6.0.patch dotenv-expand@12.0.3: rolldown-vite/patches/dotenv-expand@12.0.3.patch + sirv@3.0.2: rolldown-vite/patches/sirv@3.0.2.patch + peerDependencyRules: allowAny: - vite - vitest - rolldown allowedVersions: - vite: '*' oxc-minify: '*' oxlint-tsgolint: '*' rolldown: '*' + vite: '*' diff --git a/rfcs/migration-command.md b/rfcs/migration-command.md new file mode 100644 index 0000000000..c803e4a272 --- /dev/null +++ b/rfcs/migration-command.md @@ -0,0 +1,432 @@ +# RFC: Vite+ Migration Command + +## Background + +When transitioning to vite+, projects typically use standalone tools like vite, oxlint, oxfmt, and vitest, each with their own dependencies and configuration files. The `vite migration` command automates the process of consolidating these tools into the unified vite+ toolchain. + +**Problem**: Manual migration is error-prone and time-consuming: + +- Multiple dependency entries to update in package.json +- Various configuration files to merge (vite.config.ts, .oxlintrc, .oxfmtrc, etc.) +- Risk of missing configurations or incorrect merging +- Tedious process when migrating multiple packages in a monorepo + +**Solution**: Automated migration using [ast-grep](https://ast-grep.github.io/) for intelligent code transformation. + +**Related Commands**: + +- `vite gen` - Uses this same migration engine after generating code (see [code-generator.md](./code-generator.md)) +- `vite migration` - This command, for migrating existing projects + +## Goals + +1. **Dependency Consolidation**: Replace standalone vite, vitest, oxlint, oxfmt dependencies with unified vite-plus +2. **Configuration Unification**: Merge .oxlintrc, .oxfmtrc into vite.config.ts +3. **Safe & Reversible**: Preview changes before applying, support rollback +4. **Intelligent**: Preserve custom configurations and user overrides +5. **Monorepo-Aware**: Migrate multiple packages efficiently + +## Scope + +**What this command migrates**: + +- ✅ **Dependencies**: vite, vitest, oxlint, oxfmt → vite-plus +- ✅ **Overrides**: Force vite → vite-plus (for all dependencies) + - npm/pnpm/bun: Adds `overrides.vite` mapping + - yarn: Adds `resolutions.vite` mapping + - **Benefit**: Code keeps `import from 'vite'` - automatically resolves to vite-plus +- ✅ **Configuration files**: + - .oxlintrc → vite.config.ts (lint section) + - .oxfmtrc → vite.config.ts (format section) + +**What this command does NOT migrate**: + +- ❌ ESLint → oxlint (different tools, not a version upgrade) +- ❌ Prettier → oxfmt (different tools, not a version upgrade) +- ❌ Package.json scripts → vite-task.json (different feature) +- ❌ TypeScript configuration changes +- ❌ Build tool changes (webpack/rollup → vite) + +These are **consolidation migrations**, not **feature migrations**. + +## Command Usage + +```bash +# Migrate current directory +vite migration + +# Migrate specific directory +vite migration packages/my-app + +# Aliases +vite migrate +``` + +## Migration Process + +### Step 1: Detection + +Analyze the project to detect which tools are being used: + +```typescript +interface DetectionResult { + hasVite: boolean; + hasVitest: boolean; + hasOxlint: boolean; + hasOxfmt: boolean; + dependencies: { + vite?: string; + vitest?: string; + oxlint?: string; + oxfmt?: string; + }; + configs: { + viteConfig?: string; // vite.config.ts + oxlintConfig?: string; // .oxlintrc + oxfmtConfig?: string; // .oxfmtrc, oxfmt.config.json + }; +} +``` + +### Step 2: Preview + +Show user what will change: + +```bash +$ vite migration + +◇ Analyzing project... +│ +◆ Detected standalone tools: +│ ✓ vite ^5.0.0 +│ ✓ vitest ^1.0.0 +│ ✓ oxlint ^0.1.0 +│ ✓ oxfmt ^0.1.0 +│ +◆ Configuration files found: +│ • vite.config.ts +│ • vitest.config.ts +│ • .oxlintrc +│ • .oxfmtrc +│ +◆ Migration plan: +│ +│ Dependencies (package.json): +│ - vite: ^5.0.0 +│ - vitest: ^1.0.0 +│ - oxlint: ^0.1.0 +│ - oxfmt: ^0.1.0 +│ + vite-plus: ^0.1.0 +│ +│ Configuration: +│ ✓ Merge vitest.config.ts → vite.config.ts +│ ✓ Merge .oxlintrc → vite.config.ts +│ ✓ Merge .oxfmtrc → vite.config.ts +│ ✓ Remove redundant config files +│ +◆ Proceed with migration? +│ ● Yes / ○ No / ○ Preview changes +``` + +### Step 3: Transformation + +Apply migrations using ast-grep: + +```bash +◇ Applying migrations... +│ ✓ Updated package.json dependencies +│ ✓ Added package.json overrides (vite → vite-plus) +│ ✓ Updated vitest imports in 18 files (vitest → vite/test) +│ ✓ Merged vitest.config.ts → vite.config.ts +│ ✓ Merged .oxlintrc → vite.config.ts +│ ✓ Merged .oxfmtrc → vite.config.ts +│ ✓ Removed vitest.config.ts +│ ✓ Removed .oxlintrc +│ ✓ Removed .oxfmtrc +│ +└ Migration completed! + +Next steps: + 1. Review vite.config.ts to ensure configurations are correct + 2. Run 'vite install' to update dependencies + 3. Run 'vite build' and 'vite test' to verify everything works +``` + +## Migration Rules + +### Package.json Dependencies & Overrides + +**Before:** + +```json +{ + "name": "my-package", + "dependencies": { + "react": "^18.2.0" + }, + "devDependencies": { + "vite": "^5.0.0", + "vitest": "^1.0.0", + "oxlint": "^0.1.0", + "oxfmt": "^0.1.0", + "@vitejs/plugin-react": "^4.2.0" + } +} +``` + +**After:** + +```json +{ + "name": "my-package", + "dependencies": { + "react": "^18.2.0" + }, + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest", + "@vitejs/plugin-react": "^4.2.0" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + } +} +``` + +**Important**: + +- `overrides.vite` ensures any dependency requiring `vite` gets `vite-plus` instead +- Code using `import from 'vite'` automatically resolves to vite-plus + +**Note**: For Yarn, use `resolutions` instead of `overrides`. + +### Oxlint Configuration + +**Before (.oxlintrc):** + +```json +{ + "rules": { + "no-unused-vars": "error", + "no-console": "warn" + }, + "ignorePatterns": ["dist", "node_modules"] +} +``` + +**After (merged into vite.config.ts):** + +```typescript +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: [], + + // Oxlint configuration + lint: { + rules: { + 'no-unused-vars': 'error', + 'no-console': 'warn', + }, + ignorePatterns: ['dist', 'node_modules'], + }, +}); +``` + +### Oxfmt Configuration + +**Before (.oxfmtrc):** + +```json +{ + "printWidth": 100, + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "trailingComma": "es5" +} +``` + +**After (merged into vite.config.ts):** + +```typescript +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: [], + + // Oxfmt configuration + format: { + printWidth: 100, + tabWidth: 2, + semi: true, + singleQuote: true, + trailingComma: 'es5', + }, +}); +``` + +### Complete Example + +**Before:** + +``` +my-package/ +├── package.json # Has vite, vitest, oxlint, oxfmt +├── vite.config.ts # Vite config +├── vitest.config.ts # Vitest config +├── .oxlintrc # Oxlint config +├── .oxfmtrc # Oxfmt config +└── src/ +``` + +**After:** + +``` +my-package/ +├── package.json # Only has vite-plus +├── vitest.config.ts # Vitest config +├── vite.config.ts # Unified config (all merged) +└── src/ +``` + +**vite.config.ts (after migration):** + +```typescript +// Import from 'vite' still works - overrides maps it to vite-plus +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // Vite configuration + plugins: [react()], + server: { + port: 3000, + }, + build: { + target: 'esnext', + }, + + // lint configuration (merged from .oxlintrc) + lint: { + rules: { + 'no-unused-vars': 'error', + 'no-console': 'warn', + }, + ignorePatterns: ['dist', 'node_modules'], + }, + + // format configuration (merged from .oxfmtrc) + format: { + printWidth: 100, + tabWidth: 2, + semi: true, + singleQuote: true, + trailingComma: 'es5', + }, +}); +``` + +## Monorepo Configuration Migration + +### for pnpm + +`pnpm-workspace.yaml` + +```yaml +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + +overrides: + vite: 'catalog:' + vitest: 'catalog:' + +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: '*' + vitest: '*' +``` + +### for npm + +`package.json` + +```json +{ + "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", + "vitest": "npm:@voidzero-dev/vite-plus-test@latest" + } +} +``` + +### for yarn 4.10.0+ (need catalog support) + +`.yarnrc.yml` + +```yaml +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest +``` + +`package.json` + +```json +{ + "resolutions": { + "vite": "catalog:", + "vitest": "catalog:" + } +} +``` + +### for yarn v1(not supported yet) + +TODO: Add support for yarn v1 + +## Success Criteria + +A successful migration should: + +1. ✅ Replace all standalone tool dependencies with vite-plus +2. ✅ **Add package.json overrides** to force vite → vite-plus (for transitive deps) +3. ✅ **Transform vitest imports** to vite/test (since vitest is removed) +4. ✅ Merge all configurations into vite.config.ts +5. ✅ Preserve all user customizations and settings +6. ✅ Remove redundant configuration files +7. ✅ Create backups before applying changes +8. ✅ Validate the result works correctly (build and test still work) +9. ✅ Provide clear feedback and next steps +10. ✅ Support rollback if something goes wrong +11. ✅ Handle monorepo migrations efficiently +12. ✅ Be safe and transparent about what changes + +## References + +### Code Transformation + +- [ast-grep](https://ast-grep.github.io/) - Structural search and replace tool +- [Turborepo Codemods](https://turborepo.com/docs/reference/turbo-codemod) - Similar migration approach +- [jscodeshift](https://github.com/facebook/jscodeshift) - Alternative AST transformation tool + +### Tools + +- [@ast-grep/napi](https://www.npmjs.com/package/@ast-grep/napi) - Node.js bindings for ast-grep +- [@clack/prompts](https://www.npmjs.com/package/@clack/prompts) - Beautiful CLI prompts +- [typescript](https://www.typescriptlang.org/) - For parsing TypeScript configs + +### Inspiration + +- [Vue 2 to Vue 3 Migration](https://v3-migration.vuejs.org/) - Similar migration tool +- [React Codemod](https://github.com/reactjs/react-codemod) - React migration scripts +- [Angular Update Guide](https://update.angular.io/) - Automated Angular migrations