diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1601369b37..0f7dddf42c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,45 @@ vp --version This builds all packages, compiles the Rust `vp` binary, and installs the CLI to `~/.vite-plus`. +## Validate the local build against a real project + +Unit and snap tests don't cover everything. Interactive flows in particular (prompts, pickers, scaffolding) are easiest to validate by running your work-in-progress CLI inside a real Vite+ project. + +First, understand how `vp` picks which `vite-plus` to run: for JS-backed commands (such as `vp create`), the global `vp` binary resolves `vite-plus` from the project's `node_modules` first and only falls back to the global installation in `~/.vite-plus`. If your test project has `vite-plus` installed from npm, `pnpm bootstrap-cli` alone will not make it run your local code. + +Build the local CLI package after each change: + +```bash +pnpm -F vite-plus build # TypeScript + native NAPI binding +pnpm -F vite-plus build-ts # faster, when only TypeScript changed +``` + +### `pnpm link` the local package + +Link your checkout into the test project. The global `vp` then delegates to the project-local CLI, and re-entrant `vp` sub-commands (for example `vp create` running `vp install` and `vp fmt` after scaffolding) resolve back to the same linked checkout: + +```bash +cd /path/to/test-project +pnpm link /path/to/vite-plus/packages/cli + +vp create # now runs your local checkout +``` + +Verify the link with `ls -l node_modules/vite-plus` (it should be a symlink into your checkout). Notes: + +- pnpm records the link as a `vite-plus: link:...` override (in `pnpm-workspace.yaml` for workspace projects, otherwise under `pnpm.overrides` in `package.json`), so it survives later installs. Don't commit that override in the test project. +- `pnpm link` may also add a `packageManager` field to the test project's `package.json`; revert it if unwanted. +- Undo with `pnpm unlink vite-plus`, or remove the override and run `pnpm install`. + +### Global CLI (Rust) changes + +`pnpm link` only swaps the JS side; the `vp` binary on `PATH` (and the Rust-backed commands it handles directly, such as package-manager commands) is still whatever is installed in `~/.vite-plus`. For changes to the Rust global CLI (`crates/`), install it from source, and combine with `pnpm link` when the change spans both layers: + +```bash +pnpm bootstrap-cli +vp --version +``` + ## Workflow for build and test You can run this command to build, test and check if there are any snapshot changes: diff --git a/crates/vite_migration/src/lib.rs b/crates/vite_migration/src/lib.rs index f84ada2476..78ab12872f 100644 --- a/crates/vite_migration/src/lib.rs +++ b/crates/vite_migration/src/lib.rs @@ -19,5 +19,6 @@ pub use file_walker::{WalkResult, find_ts_files}; pub use import_rewriter::{BatchRewriteResult, rewrite_imports_in_directory}; pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts}; pub use vite_config::{ - MergeResult, has_config_key, merge_json_config, merge_tsdown_config, wrap_lazy_plugins, + MergeResult, has_config_key, merge_json_config, merge_tsdown_config, upsert_json_config, + wrap_lazy_plugins, }; diff --git a/crates/vite_migration/src/vite_config.rs b/crates/vite_migration/src/vite_config.rs index 2ee38e8e00..dd10b6062b 100644 --- a/crates/vite_migration/src/vite_config.rs +++ b/crates/vite_migration/src/vite_config.rs @@ -114,6 +114,156 @@ fn merge_json_config_content( Ok(MergeResult { content, updated, uses_function_callback }) } +/// Set the value of a top-level config key in vite.config.ts/js (upsert). +/// +/// Unlike [`merge_json_config`], which *prepends* a new key (and duplicates it +/// when the key already exists), this function targets only **direct** config +/// objects — `defineConfig({...})`, `defineConfig(() => ({...}))`, direct +/// `return {...}` in a `defineConfig` callback, `export default {...}`, and +/// the `satisfies` variants. In each such object it replaces the value of an +/// existing `config_key` (pair or shorthand property) or inserts the key when +/// absent. Objects nested deeper (e.g. a plugin's `config()` return) are never +/// touched, and unrecognized shapes (`module.exports`, `return someVar`) +/// report `updated: false` so the caller can surface the failure instead of +/// writing a key that is dead at runtime. +/// +/// This is intended for the case where the JS side wants to write back a fully +/// recomputed key (e.g. regenerate `create:`) and must not corrupt anything +/// else in the file. +/// +/// # Arguments +/// +/// * `vite_config_path` - Path to the vite.config.ts or vite.config.js file +/// * `json_config_path` - Path to the JSON config file whose contents become the new value +/// * `config_key` - The top-level key whose value should be set +/// +/// # Returns +/// +/// Returns a `MergeResult`. `updated` is `true` only when at least one direct +/// config object was found and updated; otherwise the original content is +/// returned unchanged. +pub fn upsert_json_config( + vite_config_path: &Path, + json_config_path: &Path, + config_key: &str, +) -> Result { + // Read the vite config file + let vite_config_content = std::fs::read_to_string(vite_config_path)?; + + // Read the JSON/JSONC config file directly + // JSON/JSONC content is valid JS (comments are valid in JS too) + let js_config = std::fs::read_to_string(json_config_path)?; + + upsert_json_config_content(&vite_config_content, &js_config, config_key) +} + +/// Set `config_key` to `ts_config` in every direct config object (see +/// [`upsert_json_config`]). Splices are raw byte-range edits; the JS caller is +/// expected to reformat afterwards, so indentation is not handled here. +fn upsert_json_config_content( + vite_config_content: &str, + ts_config: &str, + config_key: &str, +) -> Result { + // Check if the config uses a function callback (for informational purposes) + let uses_function_callback = check_function_callback(vite_config_content)?; + + // Strip "$schema" property — it's a JSON Schema annotation not valid in the config type. + let ts_config = strip_schema_property(ts_config); + + let grep = SupportLang::TypeScript.ast_grep(vite_config_content); + let root = grep.root(); + + // Byte-range edits: (start, end, replacement). An empty range is an insert. + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + // Direct config objects, keyed by range start, with whether the key exists. + let mut direct_objects: Vec<(usize, bool)> = Vec::new(); + + for node in root.dfs() { + match node.kind().as_ref() { + "object" => { + if is_direct_recognized_config_object(&node) { + direct_objects.push((node.range().start, false)); + } + } + "pair" => { + let Some(key_node) = node.field("key") else { continue }; + if !pair_key_matches(&key_node, config_key) { + continue; + } + let Some(parent_object) = node.parent() else { continue }; + if !mark_direct_object_keyed(&mut direct_objects, &parent_object) { + continue; + } + let Some(value_node) = node.field("value") else { continue }; + let range = value_node.range(); + edits.push((range.start, range.end, ts_config.to_string())); + } + // `{ create }` shorthand: replace the whole identifier with a pair. + // The caller already evaluated the config, so the recomputed value + // is the runtime value the shorthand variable held. + "shorthand_property_identifier" => { + if node.text() != config_key { + continue; + } + let Some(parent_object) = node.parent() else { continue }; + if !mark_direct_object_keyed(&mut direct_objects, &parent_object) { + continue; + } + let range = node.range(); + edits.push((range.start, range.end, format!("{config_key}: {ts_config}"))); + } + _ => {} + } + } + + // Insert the key into direct config objects that do not have it. + for (object_start, has_key) in &direct_objects { + if !has_key { + edits.push(( + object_start + 1, + object_start + 1, + format!(" {config_key}: {ts_config},"), + )); + } + } + + if edits.is_empty() { + return Ok(MergeResult { + content: vite_config_content.to_owned(), + updated: false, + uses_function_callback, + }); + } + + edits.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start)); + let mut content = vite_config_content.to_owned(); + for (start, end, replacement) in edits { + content.replace_range(start..end, &replacement); + } + + Ok(MergeResult { content, updated: true, uses_function_callback }) +} + +/// If `parent_object` is a tracked direct config object, mark it as already +/// containing the key and return `true`; otherwise return `false`. +fn mark_direct_object_keyed( + direct_objects: &mut [(usize, bool)], + parent_object: &Node<'_, D>, +) -> bool { + if parent_object.kind() != "object" { + return false; + } + let start = parent_object.range().start; + for entry in direct_objects.iter_mut() { + if entry.0 == start { + entry.1 = true; + return true; + } + } + false +} + /// Regex to match `"$schema": "..."` lines (with optional trailing comma). static RE_SCHEMA: LazyLock = LazyLock::new(|| Regex::new(r#"(?m)^\s*"\$schema"\s*:\s*"[^"]*"\s*,?\s*\n"#).unwrap()); @@ -210,7 +360,7 @@ fn wrap_lazy_plugins_content( if parent_object.kind() != "object" { continue; } - if !is_recognized_config_object_for_lazy_plugins(&parent_object) { + if !is_direct_recognized_config_object(&parent_object) { continue; } let Some(value_node) = node.field("value") else { continue }; @@ -332,7 +482,14 @@ static RE_DEFAULT_LAZY_PLUGINS_IMPORT: LazyLock = static RE_NAMESPACE_LAZY_PLUGINS_IMPORT: LazyLock = LazyLock::new(|| Regex::new(r#"^\s*import\s+\*\s+as\s+lazyPlugins\b"#).unwrap()); -fn is_recognized_config_object_for_lazy_plugins(object_node: &Node<'_, D>) -> bool { +/// A **direct** config object: the object literal that *is* the config — +/// `defineConfig({...})`'s argument, an `export default {...}` (with or +/// without `satisfies`), a `defineConfig` arrow body, or a direct `return` +/// in a `defineConfig` callback. Unlike [`is_recognized_config_object`], +/// returns inside nested functions (e.g. an inline plugin's `config()` hook) +/// do NOT match, so destructive edits never touch them. Used by transforms +/// that rewrite in place (`wrap_lazy_plugins`, `upsert_json_config`). +fn is_direct_recognized_config_object(object_node: &Node<'_, D>) -> bool { let Some(parent) = object_node.parent() else { return false }; match parent.kind().as_ref() { "export_statement" => true, @@ -2180,4 +2337,280 @@ export default defineConfig({});"#; let result = merge_tsdown_config_content(vite_config, "./tsdown.config.cjs").unwrap(); assert!(result.content.contains("import tsdownConfig from './tsdown.config.cjs'")); } + + // ── upsert_json_config_content ──────────────────────────────────────── + + #[test] + fn test_upsert_json_config_content_replaces_existing_value() { + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { defaultTemplate: "@a" }, + plugins: [], +});"#; + + let new_value = r#"{ defaultTemplate: "@b", generators: ["./gen"] }"#; + + let result = upsert_json_config_content(vite_config, new_value, "create").unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { defaultTemplate: "@b", generators: ["./gen"] }, + plugins: [], +});"# + ); + // Rest of the file is untouched. + assert!(result.content.contains("plugins: []")); + assert!(!result.content.contains(r#""@a""#)); + assert!(!result.uses_function_callback); + } + + #[test] + fn test_upsert_json_config_content_inserts_missing_key() { + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: [], +});"#; + + let result = upsert_json_config_content(vite_config, "{ foo: 1 }", "create").unwrap(); + assert!(result.updated); + assert_eq!( + result.content, + r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ create: { foo: 1 }, + plugins: [], +});"# + ); + } + + #[test] + fn test_upsert_json_config_content_inserts_into_empty_object() { + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({});"#; + + let result = upsert_json_config_content(vite_config, "{ foo: 1 }", "create").unwrap(); + assert!(result.updated); + assert!(result.content.contains("defineConfig({ create: { foo: 1 },})")); + } + + #[test] + fn test_upsert_json_config_content_unrecognized_shapes_unchanged() { + // No direct config object at all — the caller must handle `updated: + // false` (warn and point at a manual edit) instead of corrupting these. + for vite_config in [ + // CommonJS export. + "module.exports = {\n create: { defaultTemplate: \"@a\" },\n};", + // `export default someVar` — the object is behind a variable. + "const config = { create: { defaultTemplate: \"@a\" } };\nexport default config;", + // `return someVar` from a defineConfig callback. + "export default defineConfig(() => {\n const cfg = { create: { defaultTemplate: \"@a\" } };\n return cfg;\n});", + ] { + let result = + upsert_json_config_content(vite_config, r#"{ defaultTemplate: "@b" }"#, "create") + .unwrap(); + assert!(!result.updated, "should not update: {vite_config}"); + assert_eq!(result.content, vite_config); + } + } + + #[test] + fn test_upsert_json_config_content_ignores_nested_create_key() { + // `create:` here is nested inside a plugin call argument, NOT a direct + // member of the recognized defineConfig object. It must not be touched; + // the key is inserted at the top level instead. + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: [ + somePlugin({ + create: { defaultTemplate: "@nested" }, + }), + ], +});"#; + + let result = + upsert_json_config_content(vite_config, r#"{ defaultTemplate: "@new" }"#, "create") + .unwrap(); + assert!(result.updated); + // The nested value is preserved verbatim. + assert!(result.content.contains(r#"create: { defaultTemplate: "@nested" }"#)); + // The new value lands in the defineConfig object itself. + assert!(result.content.contains(r#"defineConfig({ create: { defaultTemplate: "@new" },"#)); + } + + #[test] + fn test_upsert_json_config_content_replaces_only_top_level_not_nested() { + // A top-level `create` exists AND a nested `create` exists. Only the + // top-level (recognized config object) value is replaced. + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { defaultTemplate: "@a" }, + plugins: [ + somePlugin({ + create: { defaultTemplate: "@nested" }, + }), + ], +});"#; + + let result = + upsert_json_config_content(vite_config, r#"{ defaultTemplate: "@b" }"#, "create") + .unwrap(); + assert!(result.updated); + assert!(result.content.contains(r#"create: { defaultTemplate: "@b" }"#)); + // Nested create is untouched. + assert!(result.content.contains(r#"create: { defaultTemplate: "@nested" }"#)); + assert!(!result.content.contains(r#""@a""#)); + } + + #[test] + fn test_upsert_json_config_content_ignores_nested_function_return() { + // A `create` key inside an inline plugin's `config()` hook return is + // NOT the top-level config key, even though the hook sits inside the + // defineConfig call. The loose `is_recognized_config_object` matches + // this shape (mirroring the merge rules); the upsert must not. + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: [ + { + name: 'my-plugin', + config() { + return { create: { custom: 1 } }; + }, + }, + ], + create: { defaultTemplate: "@a" }, +});"#; + + let result = + upsert_json_config_content(vite_config, r#"{ defaultTemplate: "@b" }"#, "create") + .unwrap(); + assert!(result.updated); + // The plugin's return is preserved verbatim. + assert!(result.content.contains("return { create: { custom: 1 } };")); + // The top-level value is replaced. + assert!(result.content.contains(r#"create: { defaultTemplate: "@b" }"#)); + assert!(!result.content.contains(r#""@a""#)); + } + + #[test] + fn test_upsert_json_config_content_replaces_shorthand_property() { + // `{ create }` shorthand: the recomputed value replaces the variable + // reference, so the written key is live (a prepended duplicate would be + // overridden by the later shorthand at runtime). + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +const create = { defaultTemplate: "@a" }; + +export default defineConfig({ + create, + plugins: [], +});"#; + + let result = + upsert_json_config_content(vite_config, r#"{ defaultTemplate: "@b" }"#, "create") + .unwrap(); + assert!(result.updated); + assert!(result.content.contains(r#"create: { defaultTemplate: "@b" },"#)); + // No duplicate key was introduced. + assert_eq!(result.content.matches("create:").count(), 1); + // The original variable declaration is untouched. + assert!(result.content.contains(r#"const create = { defaultTemplate: "@a" };"#)); + } + + #[test] + fn test_upsert_json_config_content_conditional_returns() { + // Both direct returns get the key, mirroring the merge rules. + let vite_config = r#"export default defineConfig(({ command }) => { + if (command === 'serve') { + return { create: { defaultTemplate: "@dev" } }; + } + return { plugins: [] }; +});"#; + + let result = + upsert_json_config_content(vite_config, r#"{ defaultTemplate: "@b" }"#, "create") + .unwrap(); + assert!(result.updated); + assert!(result.content.contains(r#"return { create: { defaultTemplate: "@b" } };"#)); + assert!( + result + .content + .contains(r#"return { create: { defaultTemplate: "@b" }, plugins: [] };"#) + ); + assert!(!result.content.contains(r#""@dev""#)); + } + + #[test] + fn test_upsert_json_config_content_callback_shape() { + // `defineConfig((env) => ({ ... }))` arrow-body object literal. + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig((env) => ({ + create: { defaultTemplate: "@a" }, + plugins: [], +}));"#; + + let result = + upsert_json_config_content(vite_config, r#"{ defaultTemplate: "@b" }"#, "create") + .unwrap(); + assert!(result.updated); + assert!(result.uses_function_callback); + assert!(result.content.contains(r#"create: { defaultTemplate: "@b" }"#)); + assert!(!result.content.contains(r#""@a""#)); + assert!(result.content.contains("(env) =>")); + } + + #[test] + fn test_upsert_json_config_content_strips_schema() { + let vite_config = r#"import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { defaultTemplate: "@a" }, +});"#; + + let new_value = r#"{ + "$schema": "https://example.com/schema.json", + "defaultTemplate": "@b" +}"#; + + let result = upsert_json_config_content(vite_config, new_value, "create").unwrap(); + assert!(result.updated); + assert!(!result.content.contains("$schema")); + assert!(result.content.contains(r#""defaultTemplate": "@b""#)); + } + + #[test] + fn test_upsert_json_config_with_files() { + let temp_dir = tempdir().unwrap(); + + let vite_config_path = temp_dir.path().join("vite.config.ts"); + let json_config_path = temp_dir.path().join("create.json"); + + let mut vite_file = std::fs::File::create(&vite_config_path).unwrap(); + write!( + vite_file, + r#"import {{ defineConfig }} from 'vite-plus'; + +export default defineConfig({{ + create: {{ defaultTemplate: "@a" }}, +}});"# + ) + .unwrap(); + + let mut json_file = std::fs::File::create(&json_config_path).unwrap(); + write!(json_file, r#"{{ "defaultTemplate": "@b" }}"#).unwrap(); + + let result = upsert_json_config(&vite_config_path, &json_config_path, "create").unwrap(); + assert!(result.updated); + assert!(result.content.contains(r#"create: { "defaultTemplate": "@b" }"#)); + assert!(!result.content.contains(r#""@a""#)); + } } diff --git a/docs/config/create.md b/docs/config/create.md index ee0746b2e5..9d4e1dba7f 100644 --- a/docs/config/create.md +++ b/docs/config/create.md @@ -16,7 +16,42 @@ export default defineConfig({ }); ``` -Any value accepted by `vp create` as a first argument works here — `@your-org` for an org picker, `@your-org:web` for a direct manifest entry, `vite:application` for a built-in, etc. +Any value accepted by `vp create` as a first argument works here: `@your-org` for an org picker, `@your-org:web` for a direct manifest entry, `vite:application` for a built-in, or the `name` of a local `create.templates` entry (see below). + +## `create.templates` + +Declare local templates available to `vp create` inside a monorepo. Each entry is listed in the `vp create` picker, and selecting it (or passing its `name` as the template argument) runs the resolved `template`. + +```ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { + templates: [ + { + name: 'component', + description: 'Internal UI component', + template: './tools/create-component', + }, + { name: 'service', description: 'Backend service', template: 'service-generator' }, + ], + }, +}); +``` + +Each entry has: + +| Field | Required | Notes | +| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | yes | Identifier shown in the picker and accepted as `vp create `. Must be unique within the array. The `vite:` prefix is reserved for built-in templates. | +| `description` | yes | One-line description shown in the picker. | +| `template` | yes | A workspace package name, a relative `./path` to a local package's directory (resolved against the workspace root), a `vite:*` built-in, a GitHub URL, or a full npm package name (e.g. `create-foo`). It is run as-is (not shorthand-expanded). | + +`create.templates` is the source of truth for local templates: only entries listed here appear in the picker. Vite+ does not infer templates from package.json keywords. A `create.templates` entry whose `template` does not match any workspace package, or resolves to a local package without a `bin`, is reported as an error rather than falling through to an unrelated npm package. + +[`vp create vite:generator`](/guide/create#code-generators) adds an entry here automatically (idempotently, preserving `defaultTemplate`); you can also edit the list by hand. + +`create.defaultTemplate` can name a local entry, so bare `vp create` opens it directly. ## Precedence diff --git a/docs/guide/create.md b/docs/guide/create.md index 7f155d0fca..9bafcecfdc 100644 --- a/docs/guide/create.md +++ b/docs/guide/create.md @@ -27,7 +27,7 @@ Vite+ ships with these built-in templates: - `vite:monorepo` creates a new monorepo - `vite:application` creates a new application - `vite:library` creates a new library -- `vite:generator` creates a new generator +- `vite:generator` creates a new code generator (monorepo only, see [Code Generators](#code-generators)) ## Template Sources @@ -35,7 +35,7 @@ Vite+ ships with these built-in templates: - Use shorthand templates like `vite`, `@tanstack/start`, `svelte`, `next-app`, `nuxt`, `react-router`, and `vue` - Use full package names like `create-vite` or `create-next-app` -- Use local templates such as `./tools/create-ui-component` or `@your-org/generator-*` +- Use local monorepo templates declared in [`create.templates`](#code-generators) (for example an internal component or service generator) - Use remote templates such as `github:user/repo` or `https://github.com/user/template-repo` Run `vp create --list` to see the built-in templates and the common shorthand templates Vite+ recognizes. @@ -92,6 +92,96 @@ vp create github:user/repo vp create https://github.com/user/template-repo ``` +## Code Generators + +Monorepos often need to scaffold their own building blocks: a UI component, a service, or an internal package that follows house conventions. Vite+ supports this through generator packages powered by [Bingo](https://www.create.bingo/) templates. + +### Scaffold a generator + +Inside a Vite+ monorepo, run: + +```bash +vp create vite:generator +``` + +This requires a monorepo workspace. If you don't have one yet, create it first with `vp create vite:monorepo`. + +The scaffolded generator package contains: + +- `src/template.ts` defines the template using `createTemplate` from `bingo`: an options schema built with [Zod](https://zod.dev/) and a `produce()` function that returns the files to generate +- `bin/index.ts` is the CLI entry, powered by Bingo's `runTemplateCLI` + +If the monorepo has a parent directory matching `generators` or `tools`, the new package is placed there by default. + +### Registration + +Local generators are declared in [`create.templates`](/config/create#create-templates) in the monorepo's `vite.config.ts`. This is the source of truth: only registered templates appear in the `vp create` picker. + +`vp create vite:generator` registers the generator for you, adding an entry to `create.templates` in the root `vite.config.ts`: + +```ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { + templates: [ + { name: 'my-generator', description: 'Generate new components', template: 'my-generator' }, + ], + }, +}); +``` + +Re-running is idempotent (no duplicate entries), and an existing `create.defaultTemplate` is preserved. You can also add entries by hand, for example to register a template you didn't scaffold this way. The `template` value is the generator's workspace package name, or a relative `./path` to it. + +### Run a generator + +Inside the monorepo, run `vp create` and pick the generator from the template list, or pass its entry `name` directly: + +```bash +# Interactive mode lists registered local templates alongside the built-ins +vp create + +# Run a registered template by its name +vp create component + +# Pass options to the generator after -- +vp create component -- --name @your-org/button +``` + +When the generator depends on `bingo`, Vite+ appends `--skip-requests` automatically so it skips Bingo's outbound network requests (such as GitHub API calls). + +After the generator runs, the created package goes through the regular monorepo integration: workspace registration, dependency installation, and formatting. + +### Customize a generator + +Edit `src/template.ts` to define the options and the files to produce: + +```ts +import { createTemplate } from 'bingo'; +import { z } from 'zod'; + +export default createTemplate({ + options: { + name: z.string().describe('Package name'), + }, + async produce({ options }) { + return { + files: { + 'package.json': JSON.stringify({ name: options.name, version: '0.0.0' }, null, 2), + src: { + 'index.ts': `export const name = '${options.name}';\n`, + }, + }, + }; + }, +}); +``` + +- `options` defines the generator's prompts and flags using Zod schemas +- `produce()` returns the [files](https://www.create.bingo/build/concepts/creations#files) to create, plus optional [scripts](https://www.create.bingo/build/concepts/creations#scripts) to run after generation and [suggestions](https://www.create.bingo/build/concepts/creations#suggestions) to print for the user + +See the [Bingo documentation](https://www.create.bingo/) for the full template API. + ## Organization Templates An organization can publish a curated set of templates under a single npm scope by shipping an `@org/create` package whose `package.json` carries a `createConfig.templates` manifest. Once published, `vp create @org` opens an interactive picker over those templates. diff --git a/package.json b/package.json index ff1b3c929a..62ea75c54f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "@voidzero-dev/vite-plus-tools": "workspace:*", - "bingo": "catalog:", "husky": "catalog:", "lint-staged": "catalog:", "oxfmt": "catalog:", @@ -34,8 +33,7 @@ "typescript": "catalog:", "vite": "catalog:", "vite-plus": "workspace:*", - "vitest": "catalog:", - "zod": "catalog:" + "vitest": "catalog:" }, "lint-staged": { "*.@(js|ts|tsx|md|yaml|yml)": "vp check --fix", diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index e2353c6521..1f068314fd 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -844,5 +844,6 @@ module.exports.rewriteScripts = nativeBinding.rewriteScripts; module.exports.run = nativeBinding.run; module.exports.runCommand = nativeBinding.runCommand; module.exports.shouldPrintVitePlusHeader = nativeBinding.shouldPrintVitePlusHeader; +module.exports.upsertJsonConfig = nativeBinding.upsertJsonConfig; module.exports.vitePlusHeader = nativeBinding.vitePlusHeader; module.exports.wrapLazyPlugins = nativeBinding.wrapLazyPlugins; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 0747496b26..e997584e16 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3613,6 +3613,45 @@ export interface RunCommandResult { */ export declare function shouldPrintVitePlusHeader(): boolean; +/** + * Set the value of a top-level config key in a vite config file (upsert) + * + * Unlike `mergeJsonConfig`, which prepends a new key (and duplicates it when + * the key already exists), this targets only direct config objects + * (`defineConfig({...})`, `export default {...}`, direct callback returns): + * it replaces the value of an existing `config_key` (pair or shorthand + * property) or inserts the key when absent. Unrecognized shapes (e.g. + * `module.exports`, `return someVar`) report `updated: false` instead of + * being corrupted. The splice is raw, the JS caller is expected to reformat + * afterwards. + * + * # Arguments + * + * * `vite_config_path` - Path to the vite.config.ts or vite.config.js file + * * `json_config_path` - Path to the JSON config file whose contents become the new value + * * `config_key` - The top-level key whose value should be set + * + * # Returns + * + * Returns a `MergeJsonConfigResult`. `updated` is `true` only when at least + * one direct config object was updated; otherwise the original content is + * returned unchanged. + * + * # Example + * + * ```javascript + * const result = upsertJsonConfig('vite.config.ts', 'create.json', 'create'); + * if (result.updated) { + * fs.writeFileSync('vite.config.ts', result.content); + * } + * ``` + */ +export declare function upsertJsonConfig( + viteConfigPath: string, + jsonConfigPath: string, + configKey: string, +): MergeJsonConfigResult; + /** Render the Vite+ header using the Rust implementation. */ export declare function vitePlusHeader(): string; diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 619fe3e371..059f8607ee 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -120,6 +120,57 @@ pub fn merge_json_config( }) } +/// Set the value of a top-level config key in a vite config file (upsert) +/// +/// Unlike `mergeJsonConfig`, which prepends a new key (and duplicates it when +/// the key already exists), this targets only direct config objects +/// (`defineConfig({...})`, `export default {...}`, direct callback returns): +/// it replaces the value of an existing `config_key` (pair or shorthand +/// property) or inserts the key when absent. Unrecognized shapes (e.g. +/// `module.exports`, `return someVar`) report `updated: false` instead of +/// being corrupted. The splice is raw, the JS caller is expected to reformat +/// afterwards. +/// +/// # Arguments +/// +/// * `vite_config_path` - Path to the vite.config.ts or vite.config.js file +/// * `json_config_path` - Path to the JSON config file whose contents become the new value +/// * `config_key` - The top-level key whose value should be set +/// +/// # Returns +/// +/// Returns a `MergeJsonConfigResult`. `updated` is `true` only when at least +/// one direct config object was updated; otherwise the original content is +/// returned unchanged. +/// +/// # Example +/// +/// ```javascript +/// const result = upsertJsonConfig('vite.config.ts', 'create.json', 'create'); +/// if (result.updated) { +/// fs.writeFileSync('vite.config.ts', result.content); +/// } +/// ``` +#[napi] +pub fn upsert_json_config( + vite_config_path: String, + json_config_path: String, + config_key: String, +) -> Result { + let result = vite_migration::upsert_json_config( + Path::new(&vite_config_path), + Path::new(&json_config_path), + &config_key, + ) + .map_err(anyhow::Error::from)?; + + Ok(MergeJsonConfigResult { + content: result.content, + updated: result.updated, + uses_function_callback: result.uses_function_callback, + }) +} + /// Whether `config_key` is already declared as a top-level property in the /// vite config's `defineConfig({...})` (or equivalent) object literal. /// diff --git a/packages/cli/package.json b/packages/cli/package.json index a93c86a1fe..ef6bd95d1a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -355,6 +355,7 @@ "@types/validate-npm-package-name": "catalog:", "@voidzero-dev/vite-plus-prompts": "workspace:*", "@voidzero-dev/vite-plus-tools": "workspace:*", + "bingo": "catalog:", "cac": "catalog:", "cross-spawn": "catalog:", "detect-indent": "catalog:", @@ -371,7 +372,8 @@ "tsdown": "catalog:", "validate-npm-package-name": "catalog:", "vite": "workspace:*", - "yaml": "catalog:" + "yaml": "catalog:", + "zod": "catalog:" }, "napi": { "binaryName": "vite-plus", diff --git a/packages/cli/snap-tests-global/command-create-help/snap.txt b/packages/cli/snap-tests-global/command-create-help/snap.txt index df152ec38c..06abe360e3 100644 --- a/packages/cli/snap-tests-global/command-create-help/snap.txt +++ b/packages/cli/snap-tests-global/command-create-help/snap.txt @@ -8,7 +8,7 @@ Arguments: - Default: vite:monorepo, vite:application, vite:library, vite:generator - Remote: vite, @tanstack/start, create-next-app, create-nuxt, github:user/repo, https://github.com/user/template-repo, etc. - - Local: @company/generator-*, ./tools/create-ui-component + - Local: a `create.templates` entry name from vite.config.ts (monorepo) - Org scope: @your-org → picker from @your-org/create's createConfig.templates manifest - Org entry: @your-org:web → manifest entry "web" from @your-org/create When omitted, uses `create.defaultTemplate` from vite.config.ts if set. @@ -73,7 +73,7 @@ Arguments: - Default: vite:monorepo, vite:application, vite:library, vite:generator - Remote: vite, @tanstack/start, create-next-app, create-nuxt, github:user/repo, https://github.com/user/template-repo, etc. - - Local: @company/generator-*, ./tools/create-ui-component + - Local: a `create.templates` entry name from vite.config.ts (monorepo) - Org scope: @your-org → picker from @your-org/create's createConfig.templates manifest - Org entry: @your-org:web → manifest entry "web" from @your-org/create When omitted, uses `create.defaultTemplate` from vite.config.ts if set. @@ -138,7 +138,7 @@ Arguments: - Default: vite:monorepo, vite:application, vite:library, vite:generator - Remote: vite, @tanstack/start, create-next-app, create-nuxt, github:user/repo, https://github.com/user/template-repo, etc. - - Local: @company/generator-*, ./tools/create-ui-component + - Local: a `create.templates` entry name from vite.config.ts (monorepo) - Org scope: @your-org → picker from @your-org/create's createConfig.templates manifest - Org entry: @your-org:web → manifest entry "web" from @your-org/create When omitted, uses `create.defaultTemplate` from vite.config.ts if set. diff --git a/packages/cli/snap-tests-global/new-check/snap.txt b/packages/cli/snap-tests-global/new-check/snap.txt index d522792dba..9960abd194 100644 --- a/packages/cli/snap-tests-global/new-check/snap.txt +++ b/packages/cli/snap-tests-global/new-check/snap.txt @@ -8,7 +8,7 @@ Arguments: - Default: vite:monorepo, vite:application, vite:library, vite:generator - Remote: vite, @tanstack/start, create-next-app, create-nuxt, github:user/repo, https://github.com/user/template-repo, etc. - - Local: @company/generator-*, ./tools/create-ui-component + - Local: a `create.templates` entry name from vite.config.ts (monorepo) - Org scope: @your-org → picker from @your-org/create's createConfig.templates manifest - Org entry: @your-org:web → manifest entry "web" from @your-org/create When omitted, uses `create.defaultTemplate` from vite.config.ts if set. diff --git a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt index e192b25f9c..03159d9206 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt @@ -206,7 +206,6 @@ vite-plus-generator "private": true, "description": "A starter for creating a Vite+ code generator.", "keywords": [ - "bingo-template", "vite-plus-generator" ], "bin": "./bin/index.ts", @@ -216,7 +215,7 @@ vite-plus-generator "dev": "node bin/index.ts" }, "dependencies": { - "bingo": "^0.7.0", + "bingo": "^0.9.3", "zod": "^3.25.76" }, "devDependencies": { @@ -224,7 +223,7 @@ vite-plus-generator "typescript": "catalog:" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.18.0" } } diff --git a/packages/cli/snap-tests/create-generator-monorepo/package.json b/packages/cli/snap-tests/create-generator-monorepo/package.json new file mode 100644 index 0000000000..fbfc68f0bd --- /dev/null +++ b/packages/cli/snap-tests/create-generator-monorepo/package.json @@ -0,0 +1,5 @@ +{ + "name": "generator-monorepo-fixture", + "private": true, + "packageManager": "pnpm@10.0.0" +} diff --git a/packages/cli/snap-tests/create-generator-monorepo/pnpm-workspace.yaml b/packages/cli/snap-tests/create-generator-monorepo/pnpm-workspace.yaml new file mode 100644 index 0000000000..654bc6c487 --- /dev/null +++ b/packages/cli/snap-tests/create-generator-monorepo/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - apps/* + - tools/* diff --git a/packages/cli/snap-tests/create-generator-monorepo/snap.txt b/packages/cli/snap-tests/create-generator-monorepo/snap.txt new file mode 100644 index 0000000000..809ba18c13 --- /dev/null +++ b/packages/cli/snap-tests/create-generator-monorepo/snap.txt @@ -0,0 +1,90 @@ +> vp create vite:generator --no-interactive --directory tools/my-generator # scaffold a generator; auto-registers it in create.templates +◇ Scaffolded tools/my-generator with generator scaffold +• Node pnpm +→ Next: cd tools/my-generator && vp run + +> cat vite.config.ts # create.templates entry appended, existing defaultTemplate preserved +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + create: { + defaultTemplate: "@acme", + templates: [ + { + name: "my-generator", + description: "A starter for creating a Vite+ code generator.", + template: "./tools/my-generator", + }, + ], + }, +}); + +> cat tools/my-generator/package.json # generator package (bingo dependency is the run hint; no marker keyword) +{ + "name": "my-generator", + "version": "0.0.0", + "private": true, + "description": "A starter for creating a Vite+ code generator.", + "keywords": [ + "vite-plus-generator" + ], + "bin": "./bin/index.ts", + "type": "module", + "scripts": { + "test": "vp test", + "dev": "node bin/index.ts" + }, + "dependencies": { + "bingo": "^0.9.3", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} + +> vp create my-generator --no-interactive -- --name demo-pkg --directory demo-pkg --offline # resolve via the registered create.templates entry + +Generating project… + +Running: node /tools/my-generator/bin/index.ts --name demo-pkg --directory demo-pkg --offline --skip-requests +┌ ✨ my-generator@ ✨ +│ +◇ Running with mode --setup +│ +│ --offline enabled. You'll need to git push any changes manually. +│ +◇ Inferred default options from system +│ +◇ Ran the my-generator template +│ +◇ Prepared local Git repository +│ +● Run npx index.ts --remote in ./demo-pkg +│ to create and sync a remote repository on GitHub. +│ +└ Thanks for using my-generator! 💝 + + +Monorepo integration... + +Formatting code... + +Code formatted +◇ Scaffolded tools/demo-pkg +• Node pnpm +→ Next: cd tools/demo-pkg && vp run + +> cat tools/demo-pkg/package.json # generated next to the generator under tools/, not the apps/ parent +{ + "name": "demo-pkg", + "version": "0.0.0", + "type": "module" +} + +> cat tools/demo-pkg/src/index.ts +export const name = "demo-pkg"; diff --git a/packages/cli/snap-tests/create-generator-monorepo/steps.json b/packages/cli/snap-tests/create-generator-monorepo/steps.json new file mode 100644 index 0000000000..a6ec0bd063 --- /dev/null +++ b/packages/cli/snap-tests/create-generator-monorepo/steps.json @@ -0,0 +1,10 @@ +{ + "commands": [ + "vp create vite:generator --no-interactive --directory tools/my-generator # scaffold a generator; auto-registers it in create.templates", + "cat vite.config.ts # create.templates entry appended, existing defaultTemplate preserved", + "cat tools/my-generator/package.json # generator package (bingo dependency is the run hint; no marker keyword)", + "vp create my-generator --no-interactive -- --name demo-pkg --directory demo-pkg --offline # resolve via the registered create.templates entry", + "cat tools/demo-pkg/package.json # generated next to the generator under tools/, not the apps/ parent", + "cat tools/demo-pkg/src/index.ts" + ] +} diff --git a/packages/cli/snap-tests/create-generator-monorepo/vite.config.ts b/packages/cli/snap-tests/create-generator-monorepo/vite.config.ts new file mode 100644 index 0000000000..75700bd572 --- /dev/null +++ b/packages/cli/snap-tests/create-generator-monorepo/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { + defaultTemplate: '@acme', + }, +}); diff --git a/packages/cli/src/create/__tests__/discovery.spec.ts b/packages/cli/src/create/__tests__/discovery.spec.ts index cdd016f20a..a429766f7e 100644 --- a/packages/cli/src/create/__tests__/discovery.spec.ts +++ b/packages/cli/src/create/__tests__/discovery.spec.ts @@ -1,12 +1,199 @@ -import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { WorkspaceInfo, WorkspaceInfoOptional } from '../../types/index.js'; import { discoverTemplate, expandCreateShorthand, inferGitHubRepoName, + inferParentDir, parseGitHubUrl, } from '../discovery.js'; +// The local-package branch only engages for a resolved create.templates entry. +function discoverLocal(workspaceInfo: WorkspaceInfo) { + return discoverTemplate('my-template', [], workspaceInfo, undefined, undefined, undefined, true); +} + +describe('discoverTemplate', () => { + let rootDir: string; + + afterEach(() => { + if (rootDir) { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + + function createWorkspaceWithPackage(packageJson: Record): WorkspaceInfo { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-discovery-spec-')); + const packageDir = path.join(rootDir, 'tools/my-template'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify(packageJson)); + return { + rootDir, + isMonorepo: true, + monorepoScope: '', + workspacePatterns: ['tools/*'], + parentDirs: ['tools'], + downloadPackageManager: { binPrefix: '' }, + packages: [{ name: 'my-template', path: 'tools/my-template' }], + } as unknown as WorkspaceInfo; + } + + it('runs a local bingo template through its bin entry', () => { + const workspaceInfo = createWorkspaceWithPackage({ + name: 'my-template', + dependencies: { bingo: '^0.9.3' }, + bin: './bin/index.ts', + }); + + const templateInfo = discoverLocal(workspaceInfo); + expect(templateInfo.command).toBe('node'); + expect(templateInfo.type).toBe('bingo'); + expect(templateInfo.args).toContain('--skip-requests'); + }); + + it('runs a local template referenced by a relative path', () => { + const workspaceInfo = createWorkspaceWithPackage({ + name: 'my-template', + dependencies: { bingo: '^0.9.3' }, + bin: './bin/index.ts', + }); + + const templateInfo = discoverTemplate( + './tools/my-template', + [], + workspaceInfo, + undefined, + undefined, + undefined, + true, + ); + expect(templateInfo.command).toBe('node'); + expect(templateInfo.type).toBe('bingo'); + expect(templateInfo.args[0]).toMatch(/tools[/\\]my-template[/\\]bin[/\\]index\.ts$/); + }); + + it('rejects a relative-path template with no package.json', () => { + const workspaceInfo = createWorkspaceWithPackage({ name: 'my-template' }); + expect(() => + discoverTemplate('./tools/missing', [], workspaceInfo, undefined, undefined, undefined, true), + ).toThrow(/no package\.json|has no "bin" entry/); + }); + + it('does not treat a bare workspace package as a template without the flag', () => { + const workspaceInfo = createWorkspaceWithPackage({ + name: 'my-template', + dependencies: { bingo: '^0.9.3' }, + bin: './bin/index.ts', + }); + + // Without the localTemplate flag, a workspace package name is not a template; + // it expands to the `create-my-template` npm package instead. + const templateInfo = discoverTemplate('my-template', [], workspaceInfo); + expect(templateInfo.command).toBe('create-my-template'); + }); + + it('rejects a declared by-name template that matches no workspace package', () => { + const workspaceInfo = createWorkspaceWithPackage({ + name: 'my-template', + bin: './bin/index.ts', + }); + + // A stale/typo'd `create.templates` entry must error instead of falling + // through to a same-named npm package. + expect(() => + discoverTemplate('my-renamed', [], workspaceInfo, undefined, undefined, true, true), + ).toThrow(/does not match any workspace package/); + }); + + it('rejects a declared local template without a bin entry', () => { + const workspaceInfo = createWorkspaceWithPackage({ name: 'my-template' }); + + // Must not fall through to the `create-my-template` npm package + expect(() => discoverLocal(workspaceInfo)).toThrow(/has no "bin" entry/); + }); + + it('uses a single-entry object bin', () => { + const workspaceInfo = createWorkspaceWithPackage({ + name: 'my-template', + bin: { whatever: './bin/cli.ts' }, + }); + + expect(discoverLocal(workspaceInfo).args[0]).toMatch( + /tools[/\\]my-template[/\\]bin[/\\]cli\.ts$/, + ); + }); + + it('prefers the bin entry named after the package for multi-bin packages', () => { + const workspaceInfo = createWorkspaceWithPackage({ + name: 'my-template', + bin: { other: './bin/other.ts', 'my-template': './bin/index.ts' }, + }); + + expect(discoverLocal(workspaceInfo).args[0]).toMatch( + /tools[/\\]my-template[/\\]bin[/\\]index\.ts$/, + ); + }); + + it('rejects an ambiguous multi-bin package with no entry named after it', () => { + const workspaceInfo = createWorkspaceWithPackage({ + name: 'my-template', + bin: { one: './bin/one.ts', two: './bin/two.ts' }, + }); + + expect(() => discoverLocal(workspaceInfo)).toThrow(/multiple "bin" entries/); + }); +}); + +// inferParentDir only reads parentDirs and packages off the workspace. +function inferParentDirWorkspace( + parentDirs: string[], + packages: WorkspaceInfoOptional['packages'] = [], +): WorkspaceInfoOptional { + return { parentDirs, packages } as unknown as WorkspaceInfoOptional; +} + +describe('inferParentDir', () => { + it('places a local generator next to itself, not in the apps parent', () => { + const ws = inferParentDirWorkspace( + ['apps', 'packages', 'tools'], + [{ name: 'my-generator', path: 'tools/my-generator' }], + ); + // Must NOT fall back to the default `apps` rule for a local generator; + // output is co-located with the matched workspace package. + expect(inferParentDir('my-generator', ws, true)).toBe('tools'); + }); + + it('co-locates a relative-path template next to its directory', () => { + const ws = inferParentDirWorkspace(['apps', 'packages', 'tools']); + expect(inferParentDir('./tools/my-generator', ws, true)).toBe('tools'); + }); + + it('co-locates under a nested (multi-segment) parent directory', () => { + const ws = inferParentDirWorkspace(['apps', 'tools/generators']); + expect(inferParentDir('./tools/generators/my-gen', ws, true)).toBe('tools/generators'); + }); + + it('falls back to the app rule when the name is not a local package', () => { + const ws = inferParentDirWorkspace(['apps', 'packages', 'tools']); + expect(inferParentDir('vite', ws, true)).toBe('apps'); + }); + + it('ignores a colliding workspace package when the template is not local', () => { + // `vp create vue` running the npm `create-vue` template must use the app + // rule even when an unrelated workspace package is also named `vue`. + const ws = inferParentDirWorkspace( + ['apps', 'packages', 'tools'], + [{ name: 'vue', path: 'tools/vue' }], + ); + expect(inferParentDir('vue', ws)).toBe('apps'); + }); +}); + describe('expandCreateShorthand', () => { it('should expand unscoped names to create-* packages', () => { expect(expandCreateShorthand('vite')).toBe('create-vite'); diff --git a/packages/cli/src/create/__tests__/generator-template.spec.ts b/packages/cli/src/create/__tests__/generator-template.spec.ts new file mode 100644 index 0000000000..bd1a1bab16 --- /dev/null +++ b/packages/cli/src/create/__tests__/generator-template.spec.ts @@ -0,0 +1,24 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { templatesDir } from '../../utils/path.ts'; + +const generatorTemplateDir = path.join(templatesDir, 'generator'); + +describe('generator template', () => { + // The scaffolded generator is executed directly with `node bin/index.ts` + // (see discoverTemplate), so its imports must resolve under Node type + // stripping, which does not remap `.js` specifiers to `.ts` files. + it('bin/index.ts runs directly with node', () => { + const result = spawnSync(process.execPath, ['bin/index.ts', '--help'], { + cwd: generatorTemplateDir, + encoding: 'utf8', + timeout: 30_000, + }); + + expect(result.stderr).not.toContain('ERR_MODULE_NOT_FOUND'); + expect(result.status).toBe(0); + }); +}); diff --git a/packages/cli/src/create/__tests__/initial-template-options.spec.ts b/packages/cli/src/create/__tests__/initial-template-options.spec.ts index fc0d8778f9..352e33e401 100644 --- a/packages/cli/src/create/__tests__/initial-template-options.spec.ts +++ b/packages/cli/src/create/__tests__/initial-template-options.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { getInitialTemplateOptions } from '../initial-template-options.js'; +import type { CreateTemplateEntry } from '../org-manifest.js'; describe('getInitialTemplateOptions', () => { it('shows only built-in monorepo, application, and library options outside a monorepo', () => { @@ -37,4 +38,38 @@ describe('getInitialTemplateOptions', () => { }, ]); }); + + it('lists local create.templates entries inside a monorepo, by name', () => { + const templates: CreateTemplateEntry[] = [ + { + name: 'component', + description: 'Internal UI component', + template: './tools/create-component', + }, + { name: 'service', description: 'Backend service', template: 'service-generator' }, + ]; + + const options = getInitialTemplateOptions(true, templates); + const values = options.map((option) => option.value); + + // Built-in templates are still offered + expect(values).toContain('vite:application'); + expect(values).toContain('vite:library'); + // Each local template is offered and selectable by its entry name + expect(values).toContain('component'); + expect(values).toContain('service'); + + const componentOption = options.find((option) => option.value === 'component'); + expect(componentOption?.label).toBe('component'); + expect(componentOption?.hint).toBe('Internal UI component'); + }); + + it('does not include local templates outside a monorepo', () => { + const templates: CreateTemplateEntry[] = [ + { name: 'component', description: 'Internal UI component', template: 'component-generator' }, + ]; + + const values = getInitialTemplateOptions(false, templates).map((option) => option.value); + expect(values).not.toContain('component'); + }); }); diff --git a/packages/cli/src/create/__tests__/org-manifest.spec.ts b/packages/cli/src/create/__tests__/org-manifest.spec.ts index efb65d0a11..3636c26019 100644 --- a/packages/cli/src/create/__tests__/org-manifest.spec.ts +++ b/packages/cli/src/create/__tests__/org-manifest.spec.ts @@ -1,13 +1,84 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + CreateConfigSchemaError, filterManifestForContext, OrgManifestSchemaError, parseOrgScopedSpec, readOrgManifest, + validateCreateTemplates, type OrgTemplateEntry, } from '../org-manifest.js'; +describe('validateCreateTemplates', () => { + it('returns an empty array when create.templates is absent', () => { + expect(validateCreateTemplates(undefined)).toEqual([]); + }); + + it('returns an empty array for an empty list', () => { + expect(validateCreateTemplates([])).toEqual([]); + }); + + it('accepts valid lean entries and drops org-only fields', () => { + const entries = validateCreateTemplates([ + { name: 'component', description: 'UI component', template: './tools/create-component' }, + // a stray `monorepo` field is not part of the lean schema and is ignored + { + name: 'service', + description: 'Backend service', + template: 'million-finding', + monorepo: true, + }, + ]); + expect(entries).toEqual([ + { name: 'component', description: 'UI component', template: './tools/create-component' }, + { name: 'service', description: 'Backend service', template: 'million-finding' }, + ]); + }); + + it('throws on a non-array value', () => { + expect(() => validateCreateTemplates({})).toThrow(CreateConfigSchemaError); + expect(() => validateCreateTemplates({})).toThrow(/must be an array/); + }); + + it('throws on a missing or empty required field', () => { + expect(() => validateCreateTemplates([{ name: 'a', description: 'a' }])).toThrow( + /create\.templates\[0]\.template must be a non-empty string/, + ); + expect(() => validateCreateTemplates([{ name: '', description: 'a', template: 'a' }])).toThrow( + /create\.templates\[0]\.name must be a non-empty string/, + ); + }); + + it('throws on the reserved __vp_ name prefix', () => { + expect(() => + validateCreateTemplates([{ name: '__vp_x', description: 'a', template: 'a' }]), + ).toThrow(/uses the reserved `__vp_` prefix/); + }); + + it('throws on the reserved vite: name prefix', () => { + // A local entry named like a builtin would shadow it in `vp create`. + expect(() => + validateCreateTemplates([{ name: 'vite:application', description: 'a', template: 'a' }]), + ).toThrow(/uses the reserved `vite:` prefix/); + }); + + it('throws on a relative template that escapes the root', () => { + expect(() => + validateCreateTemplates([{ name: 'a', description: 'a', template: '../outside' }]), + ).toThrow(/escapes the package root/); + }); + + it('throws on duplicate names', () => { + expect(() => + validateCreateTemplates([ + { name: 'dup', description: 'a', template: 'a' }, + { name: 'dup', description: 'b', template: 'b' }, + ]), + ).toThrow(/duplicates an earlier entry/); + }); +}); + describe('parseOrgScopedSpec', () => { it('returns null for non-scoped specs', () => { expect(parseOrgScopedSpec('create-vite')).toBeNull(); diff --git a/packages/cli/src/create/__tests__/register-template.spec.ts b/packages/cli/src/create/__tests__/register-template.spec.ts new file mode 100644 index 0000000000..37ad22f627 --- /dev/null +++ b/packages/cli/src/create/__tests__/register-template.spec.ts @@ -0,0 +1,217 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveViteConfig } from '../../resolve-vite-config.js'; +import type { CreateTemplateEntry } from '../org-manifest.js'; +import { registerLocalTemplate } from '../register-template.js'; + +const ENTRY_A: CreateTemplateEntry = { + name: 'my-generator', + description: 'A local generator', + template: './templates/my-generator', +}; + +const ENTRY_B: CreateTemplateEntry = { + name: 'other-generator', + description: 'Another local generator', + template: './templates/other-generator', +}; + +describe('registerLocalTemplate', () => { + let workspaceRoot: string; + + beforeEach(() => { + // Self-contained workspace in the OS temp dir with a stubbed `vite-plus` + // so the `import { defineConfig } from 'vite-plus'` lines the helper + // writes (and that `resolveViteConfig` evaluates) resolve. Using a shared + // fixture dir inside the repo's real node_modules would race concurrent + // test runs and leave junk behind on a killed run. + workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-register-template-')); + fs.writeFileSync(path.join(workspaceRoot, 'package.json'), '{"type":"module"}'); + const stubDir = path.join(workspaceRoot, 'node_modules', 'vite-plus'); + fs.mkdirSync(stubDir, { recursive: true }); + fs.writeFileSync(path.join(stubDir, 'package.json'), '{"type":"module","main":"index.js"}'); + fs.writeFileSync(path.join(stubDir, 'index.js'), 'export const defineConfig = (c) => c;\n'); + }); + + afterEach(() => { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function writeViteConfig(body: string): void { + fs.writeFileSync( + path.join(workspaceRoot, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus';\n\nexport default defineConfig(${body});\n`, + ); + } + + async function readCreate(): Promise<{ + defaultTemplate?: string; + templates?: CreateTemplateEntry[]; + }> { + const config = (await resolveViteConfig(workspaceRoot)) as { + create?: { defaultTemplate?: string; templates?: CreateTemplateEntry[] }; + }; + return config.create ?? {}; + } + + it('creates a vite.config.ts with create.templates when none exists', async () => { + expect(fs.existsSync(path.join(workspaceRoot, 'vite.config.ts'))).toBe(false); + + await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + + expect(fs.existsSync(path.join(workspaceRoot, 'vite.config.ts'))).toBe(true); + const create = await readCreate(); + expect(create.defaultTemplate).toBeUndefined(); + expect(create.templates).toEqual([ENTRY_A]); + }); + + it('targets an existing vite.config.mts instead of creating a stray vite.config.ts', async () => { + // A monorepo whose only config is a .mts (or .cts/.cjs) file must be the + // registration target. Missing those extensions would create a new + // vite.config.ts and write to it, leaving the real config untouched. + fs.writeFileSync( + path.join(workspaceRoot, 'vite.config.mts'), + `import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({ create: { defaultTemplate: '@your-org' } });\n`, + ); + + await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + + expect(fs.existsSync(path.join(workspaceRoot, 'vite.config.ts'))).toBe(false); + const create = await readCreate(); + expect(create.defaultTemplate).toBe('@your-org'); + expect(create.templates).toEqual([ENTRY_A]); + }); + + it('appends templates while preserving an existing defaultTemplate', async () => { + writeViteConfig("{ create: { defaultTemplate: '@your-org' } }"); + + await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + + const create = await readCreate(); + expect(create.defaultTemplate).toBe('@your-org'); + expect(create.templates).toEqual([ENTRY_A]); + }); + + it('is a no-op when an entry with the same name already exists', async () => { + writeViteConfig( + `{ create: { templates: [{ name: '${ENTRY_A.name}', description: 'pre-existing', template: './pre-existing' }] } }`, + ); + const before = fs.readFileSync(path.join(workspaceRoot, 'vite.config.ts'), 'utf8'); + + await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + + const after = fs.readFileSync(path.join(workspaceRoot, 'vite.config.ts'), 'utf8'); + expect(after).toBe(before); + const create = await readCreate(); + // The pre-existing entry (with its original description) is untouched. + expect(create.templates).toEqual([ + { name: ENTRY_A.name, description: 'pre-existing', template: './pre-existing' }, + ]); + }); + + it('appends a second, different entry after the first', async () => { + await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + await registerLocalTemplate(workspaceRoot, ENTRY_B, true); + + const create = await readCreate(); + expect(create.templates).toEqual([ENTRY_A, ENTRY_B]); + }); + + it('preserves defaultTemplate and prior templates across appends', async () => { + writeViteConfig("{ create: { defaultTemplate: '@your-org' } }"); + + await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + await registerLocalTemplate(workspaceRoot, ENTRY_B, true); + + const create = await readCreate(); + expect(create.defaultTemplate).toBe('@your-org'); + expect(create.templates).toEqual([ENTRY_A, ENTRY_B]); + }); + + it('does not clobber a pre-existing .vite-plus-create-register.json in the workspace', async () => { + // The temp file used for the merge must not collide with a user file of a + // fixed name in the workspace root. + const sentinel = path.join(workspaceRoot, '.vite-plus-create-register.json'); + fs.writeFileSync(sentinel, '{"keep":true}'); + + await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + + expect(fs.existsSync(sentinel)).toBe(true); + expect(JSON.parse(fs.readFileSync(sentinel, 'utf8'))).toEqual({ keep: true }); + }); + + it('aborts without clobbering when the existing config cannot be evaluated', async () => { + // The config exists with a real create block but fails to evaluate (missing + // import). Treating that as empty would replace the block with only the new + // entry, dropping defaultTemplate and prior templates. It must abort. + const configPath = path.join(workspaceRoot, 'vite.config.ts'); + const original = `import { defineConfig } from 'vite-plus';\nimport 'vite-plus-nonexistent-module-xyz';\n\nexport default defineConfig({ create: { defaultTemplate: '@your-org', templates: [{ name: 'pre', description: 'p', template: './pre' }] } });\n`; + fs.writeFileSync(configPath, original); + + await expect(registerLocalTemplate(workspaceRoot, ENTRY_A, true)).rejects.toThrow(); + expect(fs.readFileSync(configPath, 'utf8')).toBe(original); + }); + + it('preserves unrelated sibling config when adding a create block', async () => { + writeViteConfig('{ run: { cache: true } }'); + + await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + + const config = (await resolveViteConfig(workspaceRoot)) as { + run?: { cache?: boolean }; + create?: { templates?: CreateTemplateEntry[] }; + }; + expect(config.run?.cache).toBe(true); + expect(config.create?.templates).toEqual([ENTRY_A]); + }); + + it('warns when a same-name entry already points at a different template', async () => { + const warnSpy = vi.spyOn(prompts.log, 'warn').mockImplementation(() => {}); + writeViteConfig( + `{ create: { templates: [{ name: '${ENTRY_A.name}', description: 'pre', template: './old-path' }] } }`, + ); + + const result = await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + + // Still a no-op, but the stale entry is called out instead of silently + // shadowing the new generator. + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('./old-path')); + }); + + it('throws on an unsupported config shape instead of writing nothing', async () => { + // `export default someVar` has no direct config object the upsert can + // edit. Reporting success while writing nothing would silently leave the + // generator unregistered. + const configPath = path.join(workspaceRoot, 'vite.config.ts'); + const original = 'const config = { create: { templates: [] } };\n\nexport default config;\n'; + fs.writeFileSync(configPath, original); + + await expect(registerLocalTemplate(workspaceRoot, ENTRY_A, true)).rejects.toThrow( + /supported config object/, + ); + expect(fs.readFileSync(configPath, 'utf8')).toBe(original); + }); + + it('replaces a shorthand `create` property with the recomputed block', async () => { + // `defineConfig({ create })` — a prepended duplicate key would be + // overridden by the shorthand at runtime; the shorthand itself must be + // replaced so the registered entry is live. + fs.writeFileSync( + path.join(workspaceRoot, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus';\n\nconst create = { defaultTemplate: '@your-org' };\n\nexport default defineConfig({ create });\n`, + ); + + await registerLocalTemplate(workspaceRoot, ENTRY_A, true); + + const create = await readCreate(); + expect(create.defaultTemplate).toBe('@your-org'); + expect(create.templates).toEqual([ENTRY_A]); + }); +}); diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 2d3645f9d7..a1a7b17aae 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -33,13 +33,14 @@ import { import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../utils/editor.ts'; import { createInitialCommit, initGitRepository } from '../utils/git.ts'; import { renderCliDoc } from '../utils/help.ts'; +import { readJsonFile } from '../utils/json.ts'; import { displayRelative } from '../utils/path.ts'; import { type CommandRunSummary, defaultInteractive, downloadPackageManager, promptGitHooks, - promptGitInit, + resolveGitInit, runViteFmt, runViteInstall, selectPackageManager, @@ -53,7 +54,9 @@ import { import type { ExecutionWithProjectDir } from './command.ts'; import { discoverTemplate, inferGitHubRepoName, inferParentDir, isGitHubUrl } from './discovery.ts'; import { getInitialTemplateOptions } from './initial-template-options.ts'; +import { CreateConfigSchemaError, type CreateTemplateEntry } from './org-manifest.ts'; import { + getConfiguredCreate, getConfiguredDefaultTemplate, type OrgResolution, resolveOrgManifestForCreate, @@ -66,6 +69,7 @@ import { suggestAvailableTargetDir, } from './prompts.ts'; import { getRandomProjectName } from './random-name.ts'; +import { registerLocalTemplate } from './register-template.ts'; import { executeBuiltinTemplate, executeBundledTemplate, @@ -97,7 +101,7 @@ const helpMessage = renderCliDoc({ `- Default: ${accent('vite:monorepo')}, ${accent('vite:application')}, ${accent('vite:library')}, ${accent('vite:generator')}`, '- Remote: vite, @tanstack/start, create-next-app,', ' create-nuxt, github:user/repo, https://github.com/user/template-repo, etc.', - '- Local: @company/generator-*, ./tools/create-ui-component', + '- Local: a `create.templates` entry name from vite.config.ts (monorepo)', `- Org scope: ${accent('@your-org')} → picker from ${accent('@your-org/create')}'s ${accent('createConfig.templates')} manifest`, `- Org entry: ${accent('@your-org:web')} → manifest entry "web" from ${accent('@your-org/create')}`, `When omitted, uses \`create.defaultTemplate\` from vite.config.ts if set.`, @@ -500,15 +504,55 @@ async function main() { let shouldSetupHooks = false; let bundled: Extract | undefined; let skipShorthandExpansion = false; + // Root config path written by generator auto-registration, formatted as part + // of the monorepo format pass below rather than in a separate step. + let registeredConfigPath: string | undefined; const installArgs = process.env.CI ? ['--no-frozen-lockfile'] : undefined; - if (!selectedTemplateName) { - const defaultTemplate = await getConfiguredDefaultTemplate(workspaceInfoOptional.rootDir); + // Local templates declared in `create.templates` are only offered inside a + // monorepo and resolved by entry `name`. Inside a monorepo, read the default + // template and the local templates in a single config evaluation. A schema + // error is a real misconfiguration: exit cleanly with the message. An + // unevaluable config only disables local templates: warn so a registered + // name does not silently fall through to an npm package. + let localTemplates: CreateTemplateEntry[] = []; + if (isMonorepo) { + try { + const configuredCreate = await getConfiguredCreate(workspaceInfoOptional.rootDir, { + throwOnReadError: true, + }); + localTemplates = configuredCreate.templates; + if (!selectedTemplateName && configuredCreate.defaultTemplate) { + selectedTemplateName = configuredCreate.defaultTemplate; + } + } catch (error) { + if (error instanceof CreateConfigSchemaError) { + cancelAndExit(error.message, 1); + } + prompts.log.warn( + `Could not read \`create\` config from the workspace vite.config (${(error as Error).message}); local templates are unavailable`, + ); + } + } else if (!selectedTemplateName) { + let defaultTemplate: string | undefined; + try { + defaultTemplate = await getConfiguredDefaultTemplate(workspaceInfoOptional.rootDir); + } catch (error) { + if (error instanceof CreateConfigSchemaError) { + cancelAndExit(error.message, 1); + } + throw error; + } if (defaultTemplate) { selectedTemplateName = defaultTemplate; } } + // Set once an org manifest produces the final specifier, so the local + // `create.templates` match below is not re-applied to an org entry's + // `template` value (e.g. an org entry `{ name: 'web', template: 'component' }` + // must not be redirected to a local entry also named `component`). + let resolvedByOrg = false; if (selectedTemplateName) { const resolved = await resolveOrgManifestForCreate({ templateName: selectedTemplateName, @@ -521,8 +565,10 @@ async function main() { // `expandCreateShorthand` from rewriting `@your-org/template-web` // into `@your-org/create-template-web`. skipShorthandExpansion = true; + resolvedByOrg = true; } else if (resolved.kind === 'bundled') { bundled = resolved; + resolvedByOrg = true; } else if (resolved.kind === 'escape-hatch') { selectedTemplateName = ''; } @@ -548,7 +594,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h if (!selectedTemplateName) { const template = await prompts.select({ message: '', - options: getInitialTemplateOptions(isMonorepo), + options: getInitialTemplateOptions(isMonorepo, localTemplates), }); if (prompts.isCancel(template)) { @@ -558,6 +604,20 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h selectedTemplateName = template; } + // Resolve a `create.templates` entry: the picker value (and `vp create `) + // is the entry `name`; run its `template` specifier instead. Entry templates + // are author-provided and fully qualified, so skip shorthand expansion. Skip + // this when an org manifest already resolved the specifier — its `template` + // is not a local picker selection and must not be re-matched locally. + const matchedLocalTemplate = resolvedByOrg + ? undefined + : localTemplates.find((entry) => entry.name === selectedTemplateName); + if (matchedLocalTemplate) { + selectedTemplateName = matchedLocalTemplate.template; + skipShorthandExpansion = true; + } + const isLocalTemplate = matchedLocalTemplate !== undefined; + const isBuiltinTemplate = selectedTemplateName.startsWith('vite:'); const isBundledTemplate = bundled !== undefined; const isBundledMonorepo = bundled?.monorepo === true; @@ -626,7 +686,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h const defaultParentDir = shouldOfferCwdOption ? cwdRelativeToRoot - : (inferParentDir(selectedTemplateName, workspaceInfoOptional) ?? + : (inferParentDir(selectedTemplateName, workspaceInfoOptional, isLocalTemplate) ?? workspaceInfoOptional.parentDirs[0]); const selected = await prompts.select({ @@ -667,7 +727,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h prompts.log.info(`Use ${accent('--directory')} to specify a different target location.`); } const inferredParentDir = - inferParentDir(selectedTemplateName, workspaceInfoOptional) ?? + inferParentDir(selectedTemplateName, workspaceInfoOptional, isLocalTemplate) ?? workspaceInfoOptional.parentDirs[0]; selectedParentDir = inferredParentDir; } @@ -816,9 +876,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h })); } - // vite:generator adds a package to an existing monorepo, not a new repository. - const shouldSetupGit = - selectedTemplateName === BuiltinTemplate.generator ? false : await promptGitInit(options); + const shouldSetupGit = await resolveGitInit(options, isMonorepo); if (!isMonorepo) { shouldSetupHooks = await promptGitHooks(options); } @@ -873,6 +931,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h options.interactive, bundled?.bundledLocalPath, skipShorthandExpansion, + isLocalTemplate, ); if (selectedParentDir) { @@ -1079,6 +1138,45 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h } const fullPath = path.join(workspaceInfo.rootDir, projectDir); + + // Register a scaffolded generator in `create.templates` so it appears in the + // `vp create` picker (and resolves by name) without a manual config edit. + if (selectedTemplateName === BuiltinTemplate.generator && isMonorepo) { + updateCreateProgress('Registering generator'); + pauseCreateProgress(); + // Register by a relative `./path` to the generator's directory: it is + // explicit and survives a package rename, unlike resolving by name. + const generatorTemplatePath = `./${projectDir.split(path.sep).join('/')}`; + let generatorName = packageName; + try { + // Inside the try: the generator is already scaffolded; a registration + // failure (an unreadable package.json or root config) must not abort + // the create or clobber config. Warn and point at the manual edit. + const generatorPkg = readJsonFile(path.join(fullPath, 'package.json')) as { + name?: string; + description?: string; + }; + generatorName = generatorPkg.name ?? packageName; + if (generatorName) { + registeredConfigPath = await registerLocalTemplate( + workspaceInfo.rootDir, + { + name: generatorName, + description: generatorPkg.description || `Run the ${generatorName} generator`, + template: generatorTemplatePath, + }, + compactOutput, + ); + } + } catch (error) { + prompts.log.warn( + `Could not register the generator in create.templates (${(error as Error).message}).\n` + + `Add it by hand: { name: '${generatorName || path.basename(projectDir)}', template: '${generatorTemplatePath}' }`, + ); + } + resumeCreateProgress(); + } + const agentInstructionsRoot = isMonorepo ? workspaceInfo.rootDir : fullPath; updateCreateProgress('Writing agent instructions'); pauseCreateProgress(); @@ -1234,14 +1332,16 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h packageManagerVersion: workspaceInfo.downloadPackageManager.version, }); updateCreateProgress('Formatting code'); - await runViteFmt(workspaceInfo.rootDir, options.interactive, [projectDir], { + // Also format the root config when generator registration rewrote it (the + // merge writes a JSON-style block), so no separate format step is needed. + const fmtPaths = registeredConfigPath + ? [projectDir, path.relative(workspaceInfo.rootDir, registeredConfigPath)] + : [projectDir]; + await runViteFmt(workspaceInfo.rootDir, options.interactive, fmtPaths, { silent: compactOutput, }); - if (shouldSetupGit) { - updateCreateProgress('Creating initial commit'); - await initGitRepository(workspaceInfo.rootDir); - await createInitialCommit(workspaceInfo.rootDir); - } + // No git setup here: `resolveGitInit` always returns false inside an + // existing monorepo (the package shares the monorepo's repository). } else { if (shouldMigrateLintFmtTools) { await installAndMigrate(fullPath); diff --git a/packages/cli/src/create/discovery.ts b/packages/cli/src/create/discovery.ts index 806220a834..0f2de3082f 100644 --- a/packages/cli/src/create/discovery.ts +++ b/packages/cli/src/create/discovery.ts @@ -1,8 +1,11 @@ +import fs from 'node:fs'; import path from 'node:path'; import type { WorkspaceInfo, WorkspaceInfoOptional } from '../types/index.ts'; import { readJsonFile } from '../utils/json.ts'; +import { isBingoTemplate } from '../utils/workspace.ts'; import { prependToPathToEnvs } from './command.ts'; +import { isRelativePath } from './org-manifest.ts'; import { BuiltinTemplate, type TemplateInfo, TemplateType } from './templates/types.ts'; // Check if template name is a GitHub URL @@ -40,6 +43,53 @@ export function inferGitHubRepoName(templateName: string): string | null { return repoName || null; } +// Resolve a declared local template (by workspace package name or a relative +// `./path`) to its directory, relative to the workspace root and using forward +// slashes (so it matches `parentDirs` and joins cleanly on any platform). +function localTemplateDir( + workspaceInfo: WorkspaceInfoOptional, + templateName: string, +): string | undefined { + if (isRelativePath(templateName)) { + return templateName.replace(/^\.\//, ''); + } + return workspaceInfo.packages.find((pkg) => pkg.name === templateName)?.path; +} + +// Resolve the bin script a local template package should be executed through. +// A single bin (string, or a one-entry object) is unambiguous. For multiple +// bin entries, prefer the one named after the package (scoped or unscoped) and +// fail clearly otherwise, since a local generator is run directly by `node`. +function resolveLocalBinPath( + localPackagePath: string, + packageName: string, + bin: Record | string | undefined, +): string | undefined { + if (!bin) { + return undefined; + } + if (typeof bin === 'string') { + return path.join(localPackagePath, bin); + } + const entries = Object.entries(bin); + if (entries.length === 0) { + return undefined; + } + if (entries.length === 1) { + return path.join(localPackagePath, entries[0][1]); + } + const unscopedName = packageName.slice(packageName.lastIndexOf('/') + 1); + const preferred = bin[packageName] ?? bin[unscopedName]; + if (preferred) { + return path.join(localPackagePath, preferred); + } + throw new Error( + `Local template package "${packageName}" defines multiple "bin" entries (${entries + .map(([name]) => name) + .join(', ')}); add a "bin" entry named "${packageName}" so the template entry is unambiguous`, + ); +} + // Discover and identify a template export function discoverTemplate( templateName: string, @@ -48,11 +98,15 @@ export function discoverTemplate( interactive?: boolean, bundledLocalPath?: string, skipShorthand?: boolean, + // True when `templateName` was resolved from a `create.templates` entry, so a + // matching workspace package should run as a local template (and a missing + // `bin` is an error rather than an npm fall-through). + localTemplate?: boolean, ): TemplateInfo { const envs = prependToPathToEnvs(workspaceInfo.downloadPackageManager.binPrefix, { ...process.env, }); - const parentDir = inferParentDir(templateName, workspaceInfo); + const parentDir = inferParentDir(templateName, workspaceInfo, localTemplate); if (bundledLocalPath) { return { command: '', @@ -91,42 +145,57 @@ export function discoverTemplate( } } - // Check for local package - const localPackage = workspaceInfo.packages.find((pkg) => pkg.name === templateName); - if (localPackage) { - const localPackagePath = path.join(workspaceInfo.rootDir, localPackage.path); + // Resolve a declared `create.templates` entry that points at a local package, + // either by workspace package name or by a relative `./path` to its directory + // (resolved against the workspace root). Only when `localTemplate` is set — + // `create.templates` is the source of truth; a bare workspace name is not a + // template otherwise. Relative paths are escape-checked at config validation. + if (localTemplate) { + const localDir = localTemplateDir(workspaceInfo, templateName); + // A declared local template that resolves to nothing (a renamed/removed + // workspace package, or a typo) must fail clearly instead of falling + // through to an unrelated same-named npm package. + if (!localDir) { + throw new Error( + `Local template "${templateName}" does not match any workspace package; ` + + `update the \`create.templates\` entry in vite.config.ts`, + ); + } + const localPackagePath = path.join(workspaceInfo.rootDir, localDir); const packageJsonPath = path.join(localPackagePath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error( + `Local template "${templateName}" has no package.json, so it cannot be run as a template`, + ); + } const pkg = readJsonFile(packageJsonPath) as { + name?: string; dependencies?: Record; - keywords?: string[]; bin?: Record | string; }; - let binPath = ''; - if (pkg.bin) { - if (typeof pkg.bin === 'string') { - binPath = path.join(localPackagePath, pkg.bin); - } else { - const binName = Object.keys(pkg.bin)[0]; - binPath = path.join(localPackagePath, pkg.bin[binName]); - } + const binPath = resolveLocalBinPath(localPackagePath, pkg.name ?? templateName, pkg.bin); + // A declared template without a bin entry cannot be executed. Fail clearly + // instead of falling through to an unrelated `create-` npm package. + if (!binPath) { + throw new Error( + `Local template "${templateName}" has no "bin" entry in its package.json, so it cannot be run as a template`, + ); } const args = [binPath, ...templateArgs]; let type: TemplateType = TemplateType.remote; - if (pkg.keywords?.includes('bingo-template') || !!pkg.dependencies?.bingo) { + if (isBingoTemplate(pkg)) { type = TemplateType.bingo; // add `--skip-requests` by default for bingo templates args.push('--skip-requests'); } - if (binPath) { - return { - command: 'node', - args, - envs, - type, - parentDir, - interactive, - }; - } + return { + command: 'node', + args, + envs, + type, + parentDir, + interactive, + }; } // Manifest-resolved entries are already fully qualified by the author — @@ -234,10 +303,23 @@ export function expandCreateShorthand(templateName: string): string { export function inferParentDir( templateName: string, workspaceInfo: WorkspaceInfoOptional, + localTemplate = false, ): string | undefined { if (workspaceInfo.parentDirs.length === 0) { return undefined; } + // Output generated from a local package belongs next to that package, in the + // parent directory it already lives in, rather than defaulting to the `apps` + // rule below. Gated like `discoverTemplate`: only a `create.templates` + // resolution makes the name local — an npm template that merely collides + // with a workspace package name must not be co-located with it. + const localDir = localTemplate ? localTemplateDir(workspaceInfo, templateName) : undefined; + if (localDir) { + const ownParentDir = path.dirname(localDir); + if (workspaceInfo.parentDirs.includes(ownParentDir)) { + return ownParentDir; + } + } // apps/applications by default let rule = /app/i; if (templateName === BuiltinTemplate.library) { diff --git a/packages/cli/src/create/initial-template-options.ts b/packages/cli/src/create/initial-template-options.ts index c9d2912c31..dcb11e479d 100644 --- a/packages/cli/src/create/initial-template-options.ts +++ b/packages/cli/src/create/initial-template-options.ts @@ -1,3 +1,4 @@ +import type { CreateTemplateEntry } from './org-manifest.ts'; import { BuiltinTemplate } from './templates/types.ts'; export interface InitialTemplateOption { @@ -6,7 +7,10 @@ export interface InitialTemplateOption { hint: string; } -export function getInitialTemplateOptions(isMonorepo: boolean): InitialTemplateOption[] { +export function getInitialTemplateOptions( + isMonorepo: boolean, + templates: CreateTemplateEntry[] = [], +): InitialTemplateOption[] { return [ ...(!isMonorepo ? [ @@ -27,5 +31,15 @@ export function getInitialTemplateOptions(isMonorepo: boolean): InitialTemplateO value: BuiltinTemplate.library, hint: 'Create vite libraries', }, + // Local templates declared in `create.templates` (vite.config.ts) are only + // relevant inside the monorepo that owns them. They are selected by `name` + // and resolved to their `template` specifier in the create flow. + ...(isMonorepo + ? templates.map((entry) => ({ + label: entry.name, + value: entry.name, + hint: entry.description, + })) + : []), ]; } diff --git a/packages/cli/src/create/org-manifest.ts b/packages/cli/src/create/org-manifest.ts index 8ca5afedd3..b2f41365ac 100644 --- a/packages/cli/src/create/org-manifest.ts +++ b/packages/cli/src/create/org-manifest.ts @@ -3,12 +3,20 @@ import path from 'node:path'; import { fetchNpmResource, getNpmRegistry } from '../utils/npm-config.ts'; /** - * A single entry in an org's template manifest. + * A single template entry shared by org manifests (`createConfig.templates`) + * and local monorepo config (`create.templates` in `vite.config.ts`). */ -export interface OrgTemplateEntry { +export interface CreateTemplateEntry { name: string; description: string; template: string; +} + +/** + * A single entry in an org's template manifest. Extends the shared + * {@link CreateTemplateEntry} with the org-only `monorepo` flag. + */ +export interface OrgTemplateEntry extends CreateTemplateEntry { monorepo?: boolean; } @@ -90,68 +98,90 @@ export function isRelativePath(spec: string): boolean { return spec.startsWith('./') || spec.startsWith('../'); } -function validateEntry(entry: unknown, index: number, packageName: string): OrgTemplateEntry { +/** + * Validate the `{ name, description, template }` fields shared by org manifest + * entries and local `create.templates` entries. `label` is the config path + * used in error messages (e.g. `createConfig.templates` or `create.templates`) + * and `makeError` builds the thrown error so each source uses its own type. + */ +export function validateTemplateEntry( + entry: unknown, + index: number, + label: string, + makeError: (message: string) => Error, +): CreateTemplateEntry { if (!entry || typeof entry !== 'object') { - throw new OrgManifestSchemaError( - `createConfig.templates[${index}] must be an object`, - packageName, - ); + throw makeError(`${label}[${index}] must be an object`); } const raw = entry as Record; const requireString = (field: string): string => { const value = raw[field]; if (typeof value !== 'string' || value.length === 0) { - throw new OrgManifestSchemaError( - `createConfig.templates[${index}].${field} must be a non-empty string`, - packageName, - ); + throw makeError(`${label}[${index}].${field} must be a non-empty string`); } return value; }; const name = requireString('name'); // `__vp_` is reserved for internal sentinel values (e.g. the // org-picker's escape-hatch nonce in `org-picker.ts`). Reject the - // prefix at schema time so a manifest entry can never collide with - // those sentinels regardless of what the picker does internally. + // prefix at schema time so an entry can never collide with those + // sentinels regardless of what the picker does internally. if (name.startsWith('__vp_')) { - throw new OrgManifestSchemaError( - `createConfig.templates[${index}].name uses the reserved \`__vp_\` prefix`, - packageName, - ); + throw makeError(`${label}[${index}].name uses the reserved \`__vp_\` prefix`); } const description = requireString('description'); const template = requireString('template'); - let monorepo: boolean | undefined; - if (raw.monorepo !== undefined) { - if (typeof raw.monorepo !== 'boolean') { - throw new OrgManifestSchemaError( - `createConfig.templates[${index}].monorepo must be a boolean`, - packageName, - ); - } - monorepo = raw.monorepo; - } - if (isRelativePath(template)) { // Defense-in-depth only: `resolveBundledPath` enforces the authoritative // check after extraction. We reject obvious root-escapes here so schema // errors surface before any tarball download happens. const resolved = path.posix.resolve('/root', template.replaceAll('\\', '/')); if (resolved !== '/root' && !resolved.startsWith('/root/')) { - throw new OrgManifestSchemaError( - `createConfig.templates[${index}].template escapes the package root: ${template}`, - packageName, - ); + throw makeError(`${label}[${index}].template escapes the package root: ${template}`); } } - return { - name, - description, - template, - ...(monorepo !== undefined ? { monorepo } : {}), - }; + return { name, description, template }; +} + +/** + * Validate a list of entries, rejecting duplicate `name`s. Shared by org + * manifests and local `create.templates`. + */ +export function validateTemplateEntries( + templates: readonly unknown[], + label: string, + makeError: (message: string) => Error, + validateOne: (entry: unknown, index: number) => T, +): T[] { + const entries: T[] = []; + const seen = new Set(); + for (let index = 0; index < templates.length; index += 1) { + const entry = validateOne(templates[index], index); + if (seen.has(entry.name)) { + throw makeError(`${label}[${index}].name duplicates an earlier entry: "${entry.name}"`); + } + seen.add(entry.name); + entries.push(entry); + } + return entries; +} + +function validateEntry(entry: unknown, index: number, packageName: string): OrgTemplateEntry { + const makeError = (message: string) => new OrgManifestSchemaError(message, packageName); + const base = validateTemplateEntry(entry, index, 'createConfig.templates', makeError); + + let monorepo: boolean | undefined; + const raw = entry as Record; + if (raw.monorepo !== undefined) { + if (typeof raw.monorepo !== 'boolean') { + throw makeError(`createConfig.templates[${index}].monorepo must be a boolean`); + } + monorepo = raw.monorepo; + } + + return { ...base, ...(monorepo !== undefined ? { monorepo } : {}) }; } function validateManifest(raw: unknown, packageName: string): OrgTemplateEntry[] | null { @@ -173,20 +203,48 @@ function validateManifest(raw: unknown, packageName: string): OrgTemplateEntry[] // Treat empty array as "no manifest" — fall through to normal @org/create behavior. return null; } - const entries: OrgTemplateEntry[] = []; - const seen = new Set(); - for (let index = 0; index < templates.length; index += 1) { - const entry = validateEntry(templates[index], index, packageName); - if (seen.has(entry.name)) { - throw new OrgManifestSchemaError( - `createConfig.templates[${index}].name duplicates an earlier entry: "${entry.name}"`, - packageName, - ); - } - seen.add(entry.name); - entries.push(entry); + return validateTemplateEntries( + templates, + 'createConfig.templates', + (message) => new OrgManifestSchemaError(message, packageName), + (entry, index) => validateEntry(entry, index, packageName), + ); +} + +/** + * Schema-level failure for `create.templates` in `vite.config.ts`. A misconfigured + * local template should surface clearly rather than silently disappear. + */ +export class CreateConfigSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = 'CreateConfigSchemaError'; } - return entries; +} + +/** + * Validate `create.templates` from `vite.config.ts`. Returns `[]` when the field + * is absent or an empty array; throws {@link CreateConfigSchemaError} when present + * but malformed. + */ +export function validateCreateTemplates(templates: unknown): CreateTemplateEntry[] { + if (templates === undefined) { + return []; + } + if (!Array.isArray(templates)) { + throw new CreateConfigSchemaError('create.templates must be an array'); + } + const makeError = (message: string) => new CreateConfigSchemaError(message); + return validateTemplateEntries(templates, 'create.templates', makeError, (entry, index) => { + const validated = validateTemplateEntry(entry, index, 'create.templates', makeError); + // `vite:*` names are builtin templates; a local entry resolves before the + // builtin in `vp create `, so allowing the prefix would let config + // silently shadow e.g. `vite:application`. + if (validated.name.startsWith('vite:')) { + throw makeError(`create.templates[${index}].name uses the reserved \`vite:\` prefix`); + } + return validated; + }); } interface RegistryPackument { diff --git a/packages/cli/src/create/org-resolve.ts b/packages/cli/src/create/org-resolve.ts index 5a326104fc..3fd50d1d2b 100644 --- a/packages/cli/src/create/org-resolve.ts +++ b/packages/cli/src/create/org-resolve.ts @@ -2,11 +2,14 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts'; import { findWorkspaceRoot, hasViteConfig, resolveViteConfig } from '../resolve-vite-config.ts'; import { + CreateConfigSchemaError, + type CreateTemplateEntry, filterManifestForContext, isRelativePath, OrgManifestSchemaError, parseOrgScopedSpec, readOrgManifest, + validateCreateTemplates, type OrgManifest, type OrgTemplateEntry, } from './org-manifest.ts'; @@ -198,32 +201,69 @@ export async function resolveOrgManifestForCreate(args: { } /** - * Read `create.defaultTemplate` from the workspace root's `vite.config.ts`. + * Read the `create` config (`defaultTemplate` + validated `templates`) from + * a workspace's `vite.config.ts` in a single config evaluation. * - * Walks up from `startDir` via `findWorkspaceRoot` (monorepo markers - * only — `pnpm-workspace.yaml`, `workspaces` in `package.json`, - * `lerna.json`) so monorepo invocations from any subdirectory still - * pick up the root config. Standalone repos without a monorepo marker - * only see a config that sits at `startDir` itself. + * By default, walks up from `startDir` via `findWorkspaceRoot` (monorepo + * markers only — `pnpm-workspace.yaml`, `workspaces` in `package.json`, + * `lerna.json`) so monorepo invocations from any subdirectory still pick up + * the root config. Pass `walkUp: false` to read `startDir` directly when the + * caller already holds the exact workspace root. * - * Best-effort: if there's no config file or evaluation fails, return - * `undefined` so the create flow behaves as if no default was set. + * Best-effort for resolution: a missing or unresolvable config reads as + * empty. A present-but-malformed `create.templates` still throws a + * {@link CreateConfigSchemaError} so the misconfiguration surfaces. + * + * Pass `throwOnReadError: true` for read-modify-write callers (registration): + * if a config file exists but cannot be evaluated, an empty read would let a + * later write clobber the real `create` block, so the eval error is rethrown + * instead of swallowed. */ -export async function getConfiguredDefaultTemplate(startDir: string): Promise { - const projectRoot = findWorkspaceRoot(startDir) ?? startDir; +export async function getConfiguredCreate( + startDir: string, + options?: { walkUp?: boolean; throwOnReadError?: boolean }, +): Promise<{ defaultTemplate?: string; templates: CreateTemplateEntry[] }> { + const projectRoot = + options?.walkUp === false ? startDir : (findWorkspaceRoot(startDir) ?? startDir); if (!hasViteConfig(projectRoot)) { - return undefined; + return { templates: [] }; } + let create: { defaultTemplate?: unknown; templates?: unknown } | undefined; try { const config = (await resolveViteConfig(projectRoot)) as { - create?: { defaultTemplate?: unknown }; + create?: { defaultTemplate?: unknown; templates?: unknown }; }; - const value = config.create?.defaultTemplate; - if (typeof value === 'string' && value.length > 0) { - return value; + create = config.create; + } catch (error) { + if (options?.throwOnReadError) { + throw error; + } + // Unresolvable config → treat as no create config. + return { templates: [] }; + } + const defaultTemplate = + typeof create?.defaultTemplate === 'string' && create.defaultTemplate.length > 0 + ? create.defaultTemplate + : undefined; + // Validation errors are intentionally NOT swallowed: a malformed + // `create.templates` should be reported, not silently dropped. + const templates = validateCreateTemplates(create?.templates); + return { ...(defaultTemplate !== undefined ? { defaultTemplate } : {}), templates }; +} + +/** + * Read `create.defaultTemplate` only. Best-effort for missing or unresolvable + * configs (returns `undefined`), but a malformed `create.templates` still + * rethrows its {@link CreateConfigSchemaError}: swallowing it here would + * silently drop a valid `defaultTemplate` along with the diagnostic. + */ +export async function getConfiguredDefaultTemplate(startDir: string): Promise { + try { + return (await getConfiguredCreate(startDir)).defaultTemplate; + } catch (error) { + if (error instanceof CreateConfigSchemaError) { + throw error; } - } catch { - // Unresolvable config → treat as no default. + return undefined; } - return undefined; } diff --git a/packages/cli/src/create/register-template.ts b/packages/cli/src/create/register-template.ts new file mode 100644 index 0000000000..232f5ea554 --- /dev/null +++ b/packages/cli/src/create/register-template.ts @@ -0,0 +1,123 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; + +import { upsertJsonConfig } from '../../binding/index.js'; +import { findViteConfig } from '../resolve-vite-config.ts'; +import { VITE_PLUS_NAME } from '../utils/constants.ts'; +import { displayRelative } from '../utils/path.ts'; +import type { CreateTemplateEntry } from './org-manifest.ts'; +import { getConfiguredCreate } from './org-resolve.ts'; + +/** + * Register a local template into `create.templates` in a monorepo's root + * `vite.config.ts`. Used after `vp create vite:generator` scaffolds a + * generator so the generated template shows up in this workspace's + * `vp create` picker. + * + * Behavior: + * - Reads the existing `create` config from the workspace root's `vite.config.*`. + * - If an entry with the same `name` already exists → no-op (idempotent), + * warning when it points at a different `template` so a stale entry does + * not silently shadow the new generator. + * - Otherwise appends `entry` to `create.templates`, preserving any sibling + * `create.defaultTemplate` and any existing entries, and writes back. + * - If there is no `vite.config.*` yet, or no `create` block, it is created. + * + * Read-modify-write: the existing `create` object is read in full first and + * the complete, recomputed object is written back via `upsertJsonConfig` + * (replace the existing `create` value, or insert the key), so + * `defaultTemplate` and prior `templates` are kept. Throws when the config + * shape is not supported by the upsert, rather than writing nothing or a key + * that is dead at runtime. + * + * Returns the absolute path of the config file written, so the caller can fold + * it into its own formatting pass (the upsert writes a JSON-style block that + * needs reformatting). Returns `undefined` for the idempotent no-op. + */ +export async function registerLocalTemplate( + workspaceRoot: string, + entry: CreateTemplateEntry, + silent = false, +): Promise { + const configPath = findViteConfig(workspaceRoot); + + // Read the current create config so we can recompute the full object. + // `walkUp: false`: the caller passes the exact monorepo root, so read it + // directly rather than searching for an enclosing workspace. + // `throwOnReadError`: if the config exists but cannot be evaluated, abort + // rather than overwrite its `create` block with only the new entry. + const existing = await getConfiguredCreate(workspaceRoot, { + walkUp: false, + throwOnReadError: true, + }); + + // Idempotent: an entry with the same name is left untouched. + const existingEntry = existing.templates.find((t) => t.name === entry.name); + if (existingEntry) { + if (existingEntry.template !== entry.template) { + prompts.log.warn( + `create.templates already has an entry named '${entry.name}' pointing at '${existingEntry.template}'; left unchanged.\n` + + `Update it by hand if it should run '${entry.template}' instead.`, + ); + } + return undefined; + } + + const nextCreate: { defaultTemplate?: string; templates: CreateTemplateEntry[] } = { + ...(existing.defaultTemplate !== undefined + ? { defaultTemplate: existing.defaultTemplate } + : {}), + templates: [...existing.templates, entry], + }; + + const targetPath = configPath ?? ensureViteConfig(workspaceRoot, silent); + writeCreateBlock(targetPath, nextCreate); + return targetPath; +} + +/** + * Create a minimal `vite.config.ts` (matching the migrator's + * `ensureViteConfig` shape) and return its absolute path. + */ +function ensureViteConfig(workspaceRoot: string, silent: boolean): string { + const configPath = path.join(workspaceRoot, 'vite.config.ts'); + fs.writeFileSync( + configPath, + `import { defineConfig } from '${VITE_PLUS_NAME}';\n\nexport default defineConfig({});\n`, + ); + if (!silent) { + prompts.log.success(`✔ Created vite.config.ts in ${displayRelative(configPath)}`); + } + return configPath; +} + +/** + * Write the full `create` object into vite.config.ts via the shared config + * upsert: replace the existing `create:` value in place, or insert the key + * when absent. The caller reformats the file afterward, so the JSON-style + * block written here is normalized to the surrounding style. + * + * Throws when the config shape is not supported (`updated: false`, e.g. + * `module.exports` or `export default someVar`), so the caller can warn and + * point at a manual edit instead of reporting a registration that never + * happened. + */ +function writeCreateBlock(configPath: string, create: object): void { + // A unique OS-temp path: a fixed name in the workspace could collide with a + // user's own file and be overwritten/deleted by the merge. + const tempPath = path.join(os.tmpdir(), `vite-plus-create-register-${randomUUID()}.json`); + fs.writeFileSync(tempPath, JSON.stringify(create)); + try { + const result = upsertJsonConfig(configPath, tempPath, 'create'); + if (!result.updated) { + throw new Error(`could not find a supported config object in ${displayRelative(configPath)}`); + } + fs.writeFileSync(configPath, result.content); + } finally { + fs.rmSync(tempPath, { force: true }); + } +} diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index df4f333c36..4a861014be 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -6,6 +6,7 @@ import { import type { OxfmtConfig } from 'oxfmt'; import type { OxlintConfig } from 'oxlint'; +import type { CreateTemplateEntry } from './create/org-manifest.ts'; import type { PackUserConfig } from './pack.ts'; import type { RunConfig } from './run-config.ts'; import type { StagedConfig } from './staged-config.ts'; @@ -35,9 +36,18 @@ declare module '@voidzero-dev/vite-plus-core' { * When `vp create` is invoked with no template argument, use this * value as if the user had typed it — typically a scope like * `'@your-org'` paired with a `@your-org/create` package that exposes a - * `createConfig.templates` manifest. + * `createConfig.templates` manifest. Can also name a local + * `create.templates` entry. */ defaultTemplate?: string; + + /** + * Local templates available to `vp create` inside this monorepo. Each + * entry is shown in the `vp create` picker by `name`/`description`; its + * `template` resolves like any specifier (a workspace package name, a + * relative `./path`, a `vite:*` builtin, a GitHub URL, or an npm package). + */ + templates?: CreateTemplateEntry[]; }; } } diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index d927f4460e..f0971b1570 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -719,9 +719,7 @@ describe('detectIncompatibleEslintIntegration', () => { devDependencies: { '@nuxt/eslint': '^1.0.0' }, }); expect( - detectIncompatibleEslintIntegration(tmpDir, [ - { name: 'app', path: 'packages/app', isTemplatePackage: false }, - ]), + detectIncompatibleEslintIntegration(tmpDir, [{ name: 'app', path: 'packages/app' }]), ).toBe('@nuxt/eslint'); }); @@ -1920,7 +1918,7 @@ export default defineConfig({ workspaceInfo.isMonorepo = true; workspaceInfo.workspacePatterns = ['apps/*']; workspaceInfo.parentDirs = ['apps']; - workspaceInfo.packages = [{ name: 'web', path: 'apps/web', isTemplatePackage: false }]; + workspaceInfo.packages = [{ name: 'web', path: 'apps/web' }]; const report = createMigrationReport(); rewriteMonorepo(workspaceInfo, true, true, report); diff --git a/packages/cli/src/resolve-vite-config.ts b/packages/cli/src/resolve-vite-config.ts index 77c0e381a5..cdd9c14869 100644 --- a/packages/cli/src/resolve-vite-config.ts +++ b/packages/cli/src/resolve-vite-config.ts @@ -1,12 +1,17 @@ import fs from 'node:fs'; import path from 'node:path'; +// Mirrors Vite's own DEFAULT_CONFIG_FILES order so finders here pick the same +// file Vite loads when a directory contains more than one config (e.g. a +// `vite.config.js` next to a stray `vite.config.ts`). Readers evaluate via +// Vite's loader, so a different order would make read and write target +// different files. const VITE_CONFIG_FILES = [ - 'vite.config.ts', 'vite.config.js', 'vite.config.mjs', - 'vite.config.mts', + 'vite.config.ts', 'vite.config.cjs', + 'vite.config.mts', 'vite.config.cts', ]; @@ -34,8 +39,18 @@ export function findViteConfigUp(startDir: string, stopDir: string): string | un return undefined; } +/** + * Find a vite config file directly in `dir` (no walking up). Returns the + * absolute path of the first config file found, or undefined. Covers every + * supported extension (`.ts/.js/.mjs/.mts/.cjs/.cts`). + */ +export function findViteConfig(dir: string): string | undefined { + const filename = VITE_CONFIG_FILES.find((f) => fs.existsSync(path.join(dir, f))); + return filename ? path.join(dir, filename) : undefined; +} + export function hasViteConfig(dir: string): boolean { - return VITE_CONFIG_FILES.some((f) => fs.existsSync(path.join(dir, f))); + return findViteConfig(dir) !== undefined; } /** diff --git a/packages/cli/src/types/workspace.ts b/packages/cli/src/types/workspace.ts index 94ca1ca37b..97d1d2c8a1 100644 --- a/packages/cli/src/types/workspace.ts +++ b/packages/cli/src/types/workspace.ts @@ -7,7 +7,6 @@ export interface WorkspacePackage { path: string; description?: string; version?: string; - isTemplatePackage: boolean; } export interface WorkspaceInfo { diff --git a/packages/cli/src/utils/__tests__/prompts.spec.ts b/packages/cli/src/utils/__tests__/prompts.spec.ts index 1698806174..0e4c26c3c9 100644 --- a/packages/cli/src/utils/__tests__/prompts.spec.ts +++ b/packages/cli/src/utils/__tests__/prompts.spec.ts @@ -1,7 +1,24 @@ import { describe, expect, it } from 'vitest'; import { PackageManager } from '../../types/index.ts'; -import { shouldIgnoreScriptsForAutoInstall } from '../prompts.ts'; +import { resolveGitInit, shouldIgnoreScriptsForAutoInstall } from '../prompts.ts'; + +describe('resolveGitInit', () => { + it('never initializes git when adding a package to an existing monorepo', async () => { + // A sub-package shares the monorepo's repository, so git setup must be + // skipped even if `--git` is forced — and no interactive prompt is shown. + expect(await resolveGitInit({ git: true, interactive: true }, true)).toBe(false); + expect(await resolveGitInit({ git: undefined, interactive: true }, true)).toBe(false); + expect(await resolveGitInit({ git: undefined, interactive: false }, true)).toBe(false); + }); + + it('respects the git option for a new standalone project', async () => { + expect(await resolveGitInit({ git: true, interactive: false }, false)).toBe(true); + expect(await resolveGitInit({ git: false, interactive: false }, false)).toBe(false); + // non-interactive default is no git + expect(await resolveGitInit({ git: undefined, interactive: false }, false)).toBe(false); + }); +}); describe('shouldIgnoreScriptsForAutoInstall', () => { it('returns true for pnpm >= 11.0.0', () => { diff --git a/packages/cli/src/utils/__tests__/workspace.spec.ts b/packages/cli/src/utils/__tests__/workspace.spec.ts index 3c7112256b..a8b860c021 100644 --- a/packages/cli/src/utils/__tests__/workspace.spec.ts +++ b/packages/cli/src/utils/__tests__/workspace.spec.ts @@ -185,30 +185,11 @@ describe('discoverWorkspacePackages', () => { const names = packages.map((p) => p.name).toSorted(); expect(names).toEqual(['bar', 'foo']); const foo = packages.find((p) => p.name === 'foo')!; - expect(foo.path).toBe(path.join('packages', 'foo')); + // pkg.path uses forward slashes on every platform (normalized from glob's + // native separators), so it compares cleanly against workspace patterns. + expect(foo.path).toBe('packages/foo'); expect(foo.description).toBe('a foo'); expect(foo.version).toBe('1.0.0'); - expect(foo.isTemplatePackage).toBe(false); - }); - - it('flags packages keyworded as vite-plus-template / bingo-template', () => { - writeJson(path.join(tmpDir, 'pkgs/vp/package.json'), { - name: 'vp', - keywords: ['vite-plus-template'], - }); - writeJson(path.join(tmpDir, 'pkgs/bg/package.json'), { - name: 'bg', - keywords: ['bingo-template'], - }); - writeJson(path.join(tmpDir, 'pkgs/bd/package.json'), { - name: 'bd', - dependencies: { bingo: '*' }, - }); - writeJson(path.join(tmpDir, 'pkgs/plain/package.json'), { name: 'plain' }); - - const packages = discoverWorkspacePackages(['pkgs/*'], tmpDir); - const map = Object.fromEntries(packages.map((p) => [p.name, p.isTemplatePackage])); - expect(map).toEqual({ vp: true, bg: true, bd: true, plain: false }); }); it('ignores node_modules during discovery', () => { diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts index 3b0a023564..7564e052d9 100644 --- a/packages/cli/src/utils/prompts.ts +++ b/packages/cli/src/utils/prompts.ts @@ -239,6 +239,19 @@ export async function promptGitInit(options: { return false; // non-interactive default } +// Git initialization only applies to a brand-new standalone project or +// monorepo. A package added to an existing monorepo shares that monorepo's +// repository, so git setup (and its prompt) is skipped. +export async function resolveGitInit( + options: { git?: boolean; interactive: boolean }, + isMonorepo: boolean, +): Promise { + if (isMonorepo) { + return false; + } + return promptGitInit(options); +} + export function defaultInteractive() { // If CI environment, use non-interactive mode by default return !process.env.CI && process.stdin.isTTY; diff --git a/packages/cli/src/utils/workspace.ts b/packages/cli/src/utils/workspace.ts index 5ed75b1de5..f10d2450b8 100644 --- a/packages/cli/src/utils/workspace.ts +++ b/packages/cli/src/utils/workspace.ts @@ -108,6 +108,14 @@ export async function detectWorkspace(rootDir: string): Promise }): boolean { + return !!pkg.dependencies?.bingo; +} + // Discover all workspace packages export function discoverWorkspacePackages( workspacePatterns: string[], @@ -134,22 +142,18 @@ export function discoverWorkspacePackages( name?: string; description?: string; version?: string; - dependencies?: Record; - keywords?: string[]; }; if (!pkg.name) { continue; } - const isTemplatePackage = - pkg.keywords?.includes('vite-plus-template') || - pkg.keywords?.includes('bingo-template') || - !!pkg.dependencies?.bingo; packages.push({ name: pkg.name, - path: path.dirname(packageJsonRelativePath), + // glob returns native separators; normalize to forward slashes so the + // path compares cleanly against workspace-pattern-derived values like + // `parentDirs` on Windows (path.join accepts either separator). + path: path.dirname(packageJsonRelativePath).split(path.sep).join('/'), description: pkg.description, version: pkg.version, - isTemplatePackage, }); } diff --git a/packages/cli/templates/generator/bin/index.ts b/packages/cli/templates/generator/bin/index.ts index 2d02121f59..224ea43817 100755 --- a/packages/cli/templates/generator/bin/index.ts +++ b/packages/cli/templates/generator/bin/index.ts @@ -1,7 +1,10 @@ #!/usr/bin/env node -import { runTemplateCLI } from 'bingo'; +import { runTemplateCLI, type Template } from 'bingo'; -import template from '../src/template.js'; +import template from '../src/template.ts'; -process.exitCode = await runTemplateCLI(template); +// runTemplateCLI accepts the base `Template` type, which is wider than the +// strongly typed template returned by createTemplate(). Cast through `unknown` +// to bridge the two. +process.exitCode = await runTemplateCLI(template as unknown as Template); diff --git a/packages/cli/templates/generator/package.json b/packages/cli/templates/generator/package.json index bf11b92eb9..5045ff5fb7 100644 --- a/packages/cli/templates/generator/package.json +++ b/packages/cli/templates/generator/package.json @@ -4,7 +4,6 @@ "private": true, "description": "A starter for creating a Vite+ code generator.", "keywords": [ - "bingo-template", "vite-plus-generator" ], "bin": "./bin/index.ts", @@ -14,7 +13,7 @@ "dev": "node bin/index.ts" }, "dependencies": { - "bingo": "^0.7.0", + "bingo": "^0.9.3", "zod": "^3.25.76" }, "devDependencies": { @@ -22,6 +21,6 @@ "typescript": "catalog:" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.18.0" } } diff --git a/packages/cli/templates/generator/src/template.ts b/packages/cli/templates/generator/src/template.ts index f348ab7ae8..97b77d9d18 100644 --- a/packages/cli/templates/generator/src/template.ts +++ b/packages/cli/templates/generator/src/template.ts @@ -22,7 +22,6 @@ export default createTemplate({ files: { 'package.json': JSON.stringify( { - // @ts-expect-error name: options.name, version: '0.0.0', type: 'module', @@ -32,7 +31,6 @@ export default createTemplate({ 2, ), src: { - // @ts-expect-error 'index.ts': `export const name = '${options.name}'; `, }, diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index c24b3c767d..1bf4438273 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -18,6 +18,13 @@ describe('replaceUnstableOutput()', () => { expect(replaceUnstableOutput(output)).toBe('line 1\nline 2\nline 3'); }); + test('strip clack spinner frames', () => { + const output = '│\n◒ Preparing local Git repository...\n◇ Prepared local Git repository\n'; + expect(replaceUnstableOutput(output)).toBe('│\n◇ Prepared local Git repository\n'); + // a frame at end-of-output without a trailing newline is stripped too + expect(replaceUnstableOutput('text\n◐ Working...')).toBe('text\n'); + }); + test('replace unstable semver version', () => { const output = ` foo v1.0.0 diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 6ade18759d..b28bba21a6 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -93,6 +93,9 @@ export function replaceUnstableOutput(output: string, cwd?: string) { .replaceAll(/\+{2,}\n/g, '+\n') // ignore pnpm registry request error warning log .replaceAll(/ ?WARN\s+GET\s+https:\/\/registry\..+?\n/g, '') + // ignore clack spinner frames (e.g. `◒ Preparing local Git repository...`), + // they appear intermittently depending on timing; the final `◇`/`◆` line stays + .replaceAll(/^[◐◓◑◒]\s[^\n]*\n?/gm, '') // ignore bun resolution progress (appears intermittently depending on cache state) .replaceAll(/Resolving dependencies\n/g, '') .replaceAll(/Resolved, downloaded and extracted \[\d+\]\n/g, '') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e003d65732..abd5ccba36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,8 +97,8 @@ catalogs: specifier: ^1.9.0 version: 1.9.0 bingo: - specifier: ^0.9.2 - version: 0.9.2 + specifier: ^0.9.3 + version: 0.9.3 buble: specifier: ^0.20.0 version: 0.20.0 @@ -250,8 +250,8 @@ catalogs: specifier: ^2.8.1 version: 2.9.0 zod: - specifier: ^4.3.5 - version: 4.3.5 + specifier: ^3.25.76 + version: 3.25.76 overrides: rolldown: workspace:rolldown@* @@ -295,9 +295,6 @@ importers: '@voidzero-dev/vite-plus-tools': specifier: workspace:* version: link:packages/tools - bingo: - specifier: 'catalog:' - version: 0.9.2 husky: specifier: 'catalog:' version: 9.1.7 @@ -325,9 +322,6 @@ importers: vitest: specifier: workspace:@voidzero-dev/vite-plus-test@* version: link:packages/test - zod: - specifier: 'catalog:' - version: 4.3.5 packages/cli: dependencies: @@ -377,6 +371,9 @@ importers: '@voidzero-dev/vite-plus-tools': specifier: workspace:* version: link:../tools + bingo: + specifier: 'catalog:' + version: 0.9.3 cac: specifier: 'catalog:' version: 7.0.0 @@ -428,6 +425,9 @@ importers: yaml: specifier: 'catalog:' version: 2.9.0 + zod: + specifier: 'catalog:' + version: 3.25.76 packages/core: dependencies: @@ -5318,8 +5318,8 @@ packages: resolution: {integrity: sha512-n8ah6qwk0JojolpoILxDvQWaw9b5pzAujrzDYMNppDNtPYQMqGsqJGvUtSw6i+mKPJ/WWqVbpCskm0f8cZjtrQ==} engines: {node: '>=18'} - bingo@0.9.2: - resolution: {integrity: sha512-Ws3UNIoxeaGSYY2Vv38NRxTIWx6ZEnUh/sfJJ/QuMi7NWcHM4FiwzylBxqbFSdy0ockEDc6A+9eBSWGz6b5n+Q==} + bingo@0.9.3: + resolution: {integrity: sha512-bIo91Ac1RAyFnUotI3+iIbl35Wk5yZrJIE6JQQOt33NneQj/A34j64bkyYxeVGLxAfNSfKE9cfIyDDy7JLyNiw==} engines: {node: '>=18'} hasBin: true @@ -12454,7 +12454,7 @@ snapshots: get-github-auth-token: 0.1.2 octokit: 5.0.5 - bingo@0.9.2: + bingo@0.9.3: dependencies: '@clack/prompts': 0.11.0 all-properties-lazy: 0.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7e967d3063..67e3731ac4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -49,7 +49,7 @@ catalog: acorn: ^8.12.1 acorn-import-assertions: ^1.9.0 astring: ^1.9.0 - bingo: ^0.9.2 + bingo: ^0.9.3 buble: ^0.20.0 cac: ^7.0.0 chalk: ^5.3.0 @@ -121,7 +121,9 @@ catalog: web-tree-sitter: ^0.26.0 ws: ^8.20.1 yaml: ^2.8.1 - zod: ^4.3.5 + # bingo introspects template option schemas via zod 3 internals; + # keep zod on v3 until bingo supports zod 4 + zod: ^3.25.76 zx: ^8.1.2 catalogMode: prefer ignoreScripts: true diff --git a/rfcs/code-generator.md b/rfcs/code-generator.md index 0b7d4a2c66..4547267921 100644 --- a/rfcs/code-generator.md +++ b/rfcs/code-generator.md @@ -876,9 +876,12 @@ Templates can be located in multiple places: **Template Type Detection**: -- **Bingo template**: Has `bingo` dependency or `bingo-template` keyword -- **Universal template**: Has `bin` entry in package.json -- Both types are executed the same way and get the same post-processing +- **Registered**: Listed in `create.templates` in the monorepo's + `vite.config.ts` (the source of truth for local templates). +- **Bingo template**: Has a `bingo` dependency, so `--skip-requests` is + appended when running it (execution hint only). +- **Universal template**: Has a `bin` entry in package.json. +- All templates are executed the same way and get the same post-processing. **Example Workspace Structure:** @@ -1487,7 +1490,7 @@ monorepo/ "bin": { "create-ui-lib": "./bin/index.js" }, - "keywords": ["bingo-template", "vite-plus-generator"], + "keywords": ["vite-plus-generator"], "scripts": { "test": "vitest" }, @@ -1950,10 +1953,21 @@ vp create vite:library --name=shared-utils ### 3. Template Detection -- **package.json Parsing**: Read package.json to check for bingo dependency -- **Bin Entry**: Look for bin field to find the executable -- **Keywords**: Check for "bingo-template" keyword as fallback -- **Validation**: Warn if package doesn't look like a valid bingo template +Local templates are registered in `create.templates` in the monorepo's +`vite.config.ts` (see the [Organization Default Templates +RFC](./create-org-default-templates.md#local-templates-createtemplates)). +That config is the source of truth for which packages are templates; Vite+ +does not infer template packages from package.json keywords. +`vp create vite:generator` writes the entry automatically (idempotently, +preserving any existing `defaultTemplate`), so a freshly scaffolded +generator shows up in the picker without a manual edit. + +- **Config Lookup**: Resolve `vp create ` against `create.templates` + by entry `name`, then resolve the entry's `template` specifier. +- **Bin Entry**: Look for the `bin` field to find the executable. Throw a + clear error when a declared local template has no `bin`. +- **Bingo Execution Hint**: A `bingo` dependency means the template is a + Bingo generator, so `--skip-requests` is appended when running it. ### 4. Monorepo Integration @@ -2013,24 +2027,24 @@ import { describe, expect, it } from 'vitest'; import { detectBingoTemplate, loadWorkspacePackages } from './discovery'; describe('Template Detection', () => { - it('detects bingo template from package.json', async () => { + it('treats a package with a bingo dependency as a bingo template', async () => { const pkg = { name: 'create-typescript-app', dependencies: { bingo: '^0.5.0' }, bin: { 'create-typescript-app': './bin/index.js' }, }; + // `bingo` dependency is the execution hint that appends `--skip-requests`. expect(detectBingoTemplate(pkg)).toBe(true); }); - it('detects bingo template from keywords', async () => { + it('does not treat a package without a bingo dependency as a bingo template', async () => { const pkg = { name: 'my-template', - keywords: ['bingo-template'], bin: { 'my-template': './index.js' }, }; - expect(detectBingoTemplate(pkg)).toBe(true); + expect(detectBingoTemplate(pkg)).toBe(false); }); }); diff --git a/rfcs/create-org-default-templates.md b/rfcs/create-org-default-templates.md index 6d7a982e09..a41a06b35d 100644 --- a/rfcs/create-org-default-templates.md +++ b/rfcs/create-org-default-templates.md @@ -385,6 +385,67 @@ export default defineConfig({ }); ``` +### Local templates (`create.templates`) + +A monorepo declares its own local templates (for example an internal +component or service generator) in the same `create` config: + +```ts +export default defineConfig({ + create: { + templates: [ + { + name: 'component', + description: 'Internal UI component', + template: './tools/create-component', + }, + { name: 'service', description: 'Backend service', template: 'service-generator' }, + ], + }, +}); +``` + +Each entry reuses the manifest entry schema (the lean +`CreateTemplateEntry` = `{ name, description, template }`, validated by the +same code that validates an org manifest's `createConfig.templates`). The +org-only `monorepo` flag is not part of the local schema. The `template` +field is a workspace package name, a relative `./path` to a local package's +directory (resolved against the workspace root), a `vite:*` built-in, a +GitHub URL, or a full npm package name (`create-foo`). It is run as-is (not +shorthand-expanded). + +`create.templates` is the **source of truth** for local templates: + +- The `vp create` picker lists exactly these entries (by `name` / + `description`) when run inside a monorepo. Vite+ does not infer template + packages from package.json keywords; declaring a template is explicit. +- Selecting an entry, or typing `vp create `, resolves the entry's + `template` through the existing `discoverTemplate` path. A workspace + package name or a relative `./path` runs that package's `bin`; if it + carries a `bingo` dependency, `--skip-requests` is appended (execution + hint only). +- An entry whose `template` resolves to a local package without a `bin` + fails with a clear error instead of falling through to an unrelated + `create-` npm package. + +`vp create vite:generator` registers the scaffolded generator +automatically: it reads the existing `create` config, appends a +`{ name, description, template }` entry to `create.templates` (idempotent +by `name`), and writes the merged `create` object back to `vite.config.ts` +via the same config-merge machinery used by `injectCreateDefaultTemplate` +(`packages/cli/src/migration/migrator.ts`), preserving any existing +`defaultTemplate`. Entries can also be added by hand. + +`create.defaultTemplate` may name a local entry, so bare `vp create` can +open a local template directly. + +Why config rather than package.json keywords (`bingo-template` / +`vite-plus-template`)? Keyword detection is implicit and answers "is this a +template?" ambiguously. A single `create.templates` list next to the +existing `create.defaultTemplate` keeps create configuration in one place +(`vite.config.ts`) and makes the set of local templates explicit and +reviewable. + ### Precedence `CLI argument` > `vite.config.ts create.defaultTemplate` > interactive