diff --git a/README.md b/README.md index 6d1d728941..e9019fc152 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,16 @@ vp create You can run `vp create` inside of a project to add new apps or libraries to your project. +Organizations can expose a curated set of templates under their npm scope by +publishing `@org/create` with a `createConfig.templates` manifest in its `package.json`. +Once published, `vp create @org` opens an interactive picker over those +templates, and setting `create: { defaultTemplate: '@org' }` in +`vite.config.ts` makes it the default for bare `vp create`. See the +[Organization Templates guide](https://viteplus.dev/guide/create#organization-templates) +for the authoring workflow and +[`create.defaultTemplate`](https://viteplus.dev/config/create) for the +config reference. + ### Migrating an existing project You can migrate an existing project to Vite+: diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 2f731b0d8e..932500090b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -168,6 +168,7 @@ export default extendConfig( text: 'Configuration', items: [ { text: 'Configuring Vite+', link: '/config/' }, + { text: 'Create', link: '/config/create' }, { text: 'Run', link: '/config/run' }, { text: 'Format', link: '/config/fmt' }, { text: 'Lint', link: '/config/lint' }, diff --git a/docs/config/create.md b/docs/config/create.md new file mode 100644 index 0000000000..ee0746b2e5 --- /dev/null +++ b/docs/config/create.md @@ -0,0 +1,35 @@ +# Create Config + +`vp create` reads the `create` block in `vite.config.ts` to set per-repo defaults. See the [Creating a Project guide](/guide/create#organization-templates) for the full `@org` template workflow. + +## `create.defaultTemplate` + +When `vp create` is invoked with no `TEMPLATE` argument, Vite+ uses this value as if the user had typed it. Typically set to an npm scope whose `@scope/create` package publishes a `createConfig.templates` manifest — so bare `vp create` drops into the org picker. + +```ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { + defaultTemplate: '@your-org', + }, +}); +``` + +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. + +## Precedence + +CLI argument > `create.defaultTemplate` > the standard built-in picker. + +Explicit specifiers always win, so scripts and CI can bypass the configured default: + +```bash +# Uses create.defaultTemplate +vp create + +# Explicitly ignores the default +vp create vite:library +``` + +The org picker also appends a trailing "Vite+ built-in templates" entry — selecting it routes to the `vite:monorepo` / `vite:application` / `vite:library` / `vite:generator` flow, so built-ins stay reachable interactively even when a default is configured. diff --git a/docs/guide/create.md b/docs/guide/create.md index ce3fbcb831..315922b01f 100644 --- a/docs/guide/create.md +++ b/docs/guide/create.md @@ -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 `@acme/generator-*` +- Use local templates such as `./tools/create-ui-component` or `@your-org/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. @@ -86,3 +86,150 @@ vp create create-next-app vp create github:user/repo vp create https://github.com/user/template-repo ``` + +## 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. + +### Pick from an org + +```bash +# Open an interactive picker over @your-org/create's manifest +vp create @your-org + +# Run a specific manifest entry directly +vp create @your-org:web + +# Pin to an exact version or a dist-tag +vp create @your-org@1.2.3 +vp create @your-org:web@next + +# Set the org as the default for a repo (see create.defaultTemplate config) +vp create +``` + +Behind the scenes, `vp create @org` maps to `@org/create` (the existing npm `create-*` convention). If that package has no `createConfig.templates` field, Vite+ falls back to running the package normally — so adopting the manifest is zero-risk for orgs that already publish `@org/create`. + +Private registries work automatically: Vite+ reads `.npmrc` files from the project root and `~/`, honoring `@your-org:registry=...` scope mappings and `//host/:_authToken=...` credentials. + +### Authoring `@org/create` + +There are two common layouts. Pick the one that matches the org's template count and release cadence. + +**Bundled (recommended for most orgs).** All templates live as subdirectories of `@org/create` itself. Manifest entries use relative `./path` values. One repo, one publish, one versioning story — the same pattern used by `create-vite` and `create-next-app`. + +``` +@your-org/create/ +├── package.json # "createConfig": { "templates": [{ "template": "./templates/web" }, ...] } +├── templates/ +│ ├── web/ +│ │ ├── package.json +│ │ └── src/... +│ └── library/... +└── README.md +``` + +**Manifest-only.** When the org already publishes independent `@org/template-*` packages (or hosts them on GitHub), `@org/create` stays a thin index. + +``` +@your-org/create/ +├── package.json # "createConfig": { "templates": [{ "template": "@your-org/template-web" }, ...] } +└── README.md +``` + +The two layouts can be mixed — a manifest can point most entries at external packages and keep a few as bundled subdirectories. + +Optionally, provide a `bin` script so `npm create @org` (the legacy path) keeps working for non-Vite+ users. `vp create @org` reads the manifest directly and never runs the `bin`. + +### Manifest schema + +The manifest lives at `createConfig.templates` in `@org/create`'s `package.json`: + +```json +{ + "name": "@your-org/create", + "version": "1.0.0", + "createConfig": { + "templates": [ + { + "name": "monorepo", + "description": "Monorepo", + "template": "@your-org/template-monorepo", + "monorepo": true + }, + { + "name": "web", + "description": "Web app template (Vite + React)", + "template": "@your-org/template-web" + }, + { + "name": "demo", + "description": "Bundled demo template", + "template": "./templates/demo" + } + ] + } +} +``` + +Each entry supports: + +| Field | Required | Notes | +| ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | yes | Kebab-case identifier. Used by `vp create @org:` for direct selection. Must be unique within the array. | +| `description` | yes | One-line description shown in the picker. | +| `template` | yes | An npm specifier (`@org/template-foo`, optionally `@version`), a GitHub URL (`github:user/repo`), a `vite:*` builtin, a local workspace package name, or a relative path (`./templates/foo`) that resolves against the `@org/create` root. | +| `monorepo` | no | If `true`, marks this entry as a monorepo-creating template. Hidden from the picker when `vp create` runs inside an existing monorepo, mirroring the built-in `vite:monorepo` filter. | + +An invalid manifest is a hard error, not a silent fall-through — a maintainer who shipped a manifest should hear about the offending field, e.g. `@your-org/create: createConfig.templates[2].template must be a non-empty string`. + +### Bundled subdirectory templates + +Relative `./...` paths resolve against the enclosing `@org/create` package root — **not** the user's cwd. The referenced directory is copied verbatim into the target project (no template-engine processing). Paths that escape the package root are rejected. + +### Make the org the default in a repo + +Commit this in `vite.config.ts` at the project root: + +```ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { defaultTemplate: '@your-org' }, +}); +``` + +Now `vp create` (with no argument) drops straight into the `@your-org` picker. See [`create.defaultTemplate`](/config/create) for details. + +The picker always appends a trailing **Vite+ built-in templates** entry so `vite:monorepo` / `vite:application` / `vite:library` / `vite:generator` stay reachable from the picker — selecting it routes to the standard built-in flow. For scripts and CI, explicit specifiers (`vp create vite:library`) bypass the configured default. + +### Non-interactive inspection + +`vp create @org --no-interactive` prints the manifest as a table and exits 1: + +``` +A template name is required when running `vp create @your-org` in non-interactive mode. + +Available templates in @your-org/create: + + NAME DESCRIPTION TEMPLATE + web Web app template (Vite + React) @your-org/template-web + library TypeScript library template @your-org/template-library + demo Bundled demo template ./templates/demo + +Examples: + # Scaffold a specific template from the org + vp create @your-org:web --no-interactive + + # Or use a Vite+ built-in template + vp create vite:application --no-interactive +``` + +### Publishing checklist + +1. Create `@org/create` (scoped npm package) if you don't already have one. +2. Add a `createConfig.templates` array to `package.json`. (Bundle the templates under `./templates/...` or point at external packages.) +3. (Optional) Provide a `bin` launcher for `npm create @org` compatibility. +4. Publish. +5. Verify: `vp create @org --no-interactive` prints the manifest table; `vp create @org` opens the picker. +6. (Optional) Commit `create: { defaultTemplate: '@org' }` in your internal template repos. diff --git a/packages/cli/README.md b/packages/cli/README.md index e5b23b0441..8c3fcd71b0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -150,6 +150,16 @@ vp create You can run `vp create` inside of a project to add new apps or libraries to your project. +Organizations can expose a curated set of templates under their npm scope by +publishing `@org/create` with a `createConfig.templates` manifest in its `package.json`. +Once published, `vp create @org` opens an interactive picker over those +templates, and setting `create: { defaultTemplate: '@org' }` in +`vite.config.ts` makes it the default for bare `vp create`. See the +[Organization Templates guide](https://viteplus.dev/guide/create#organization-templates) +for the authoring workflow and +[`create.defaultTemplate`](https://viteplus.dev/config/create) for the +config reference. + ### Migrating an existing project You can migrate an existing project to Vite+: diff --git a/packages/cli/package.json b/packages/cli/package.json index 2416708991..bc7d3f3614 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -353,6 +353,7 @@ "lint-staged": "catalog:", "minimatch": "catalog:", "mri": "catalog:", + "nanotar": "catalog:", "picocolors": "catalog:", "rolldown-plugin-dts": "catalog:", "semver": "catalog:", 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 c70972a1bf..ab69160242 100644 --- a/packages/cli/snap-tests-global/command-create-help/snap.txt +++ b/packages/cli/snap-tests-global/command-create-help/snap.txt @@ -9,6 +9,9 @@ Arguments: - 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 + - 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. Options: --directory DIR Target directory for the generated project. @@ -49,6 +52,10 @@ Examples: vp create github:user/repo vp create https://github.com/user/template-repo + # Pick from an org that publishes @scope/create with createConfig.templates + vp create @your-org # interactive picker + vp create @your-org:web # direct manifest-entry selection + Documentation: https://viteplus.dev/guide/create @@ -63,6 +70,9 @@ Arguments: - 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 + - 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. Options: --directory DIR Target directory for the generated project. @@ -103,6 +113,10 @@ Examples: vp create github:user/repo vp create https://github.com/user/template-repo + # Pick from an org that publishes @scope/create with createConfig.templates + vp create @your-org # interactive picker + vp create @your-org:web # direct manifest-entry selection + Documentation: https://viteplus.dev/guide/create @@ -117,6 +131,9 @@ Arguments: - 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 + - 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. Options: --directory DIR Target directory for the generated project. @@ -157,5 +174,9 @@ Examples: vp create github:user/repo vp create https://github.com/user/template-repo + # Pick from an org that publishes @scope/create with createConfig.templates + vp create @your-org # interactive picker + vp create @your-org:web # direct manifest-entry selection + Documentation: https://viteplus.dev/guide/create diff --git a/packages/cli/snap-tests-global/new-check/snap.txt b/packages/cli/snap-tests-global/new-check/snap.txt index 729eb1f044..4602d3ef40 100644 --- a/packages/cli/snap-tests-global/new-check/snap.txt +++ b/packages/cli/snap-tests-global/new-check/snap.txt @@ -9,6 +9,9 @@ Arguments: - 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 + - 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. Options: --directory DIR Target directory for the generated project. @@ -49,6 +52,10 @@ Examples: vp create github:user/repo vp create https://github.com/user/template-repo + # Pick from an org that publishes @scope/create with createConfig.templates + vp create @your-org # interactive picker + vp create @your-org:web # direct manifest-entry selection + Documentation: https://viteplus.dev/guide/create diff --git a/packages/cli/snap-tests/.shared/mock-npm-registry.mjs b/packages/cli/snap-tests/.shared/mock-npm-registry.mjs new file mode 100644 index 0000000000..6c278ad3c1 --- /dev/null +++ b/packages/cli/snap-tests/.shared/mock-npm-registry.mjs @@ -0,0 +1,89 @@ +// Minimal mock npm registry used by `create-org-*` snap-tests. +// +// Reads `./mock-manifest.json` (keyed by URL path, e.g. `"@your-org/create"`) and +// optionally serves `.tgz` tarballs from `./tarballs/`. Picks an +// ephemeral port, sets `NPM_CONFIG_REGISTRY` on the child environment, spawns +// the wrapped command, and tears down when the child exits. +// +// Usage: node mock-server.mjs -- [args...] + +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import path from 'node:path'; + +const manifest = JSON.parse(readFileSync('./mock-manifest.json', 'utf-8')); +const UPSTREAM_REGISTRY = 'https://registry.npmjs.org'; + +function rewriteRegistry(value, registry) { + return JSON.parse(JSON.stringify(value).replaceAll('{REGISTRY}', registry)); +} + +async function proxyToUpstream(req, res) { + try { + const upstream = await fetch(`${UPSTREAM_REGISTRY}${req.url ?? '/'}`, { + method: req.method, + headers: { accept: req.headers.accept ?? 'application/json' }, + }); + const body = Buffer.from(await upstream.arrayBuffer()); + res.writeHead(upstream.status, { + 'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream', + }); + res.end(body); + } catch (error) { + res.writeHead(502); + res.end(`proxy error: ${error.message}`); + } +} + +const server = createServer(async (req, res) => { + const key = decodeURIComponent(req.url ?? '/').replace(/^\/+/, ''); + if (Object.hasOwn(manifest, key)) { + const address = server.address(); + const registry = + address && typeof address !== 'string' ? `http://127.0.0.1:${address.port}` : ''; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(rewriteRegistry(manifest[key], registry))); + return; + } + const tarMatch = key.match(/\/-\/([^/]+\.tgz)$/); + if (tarMatch) { + try { + const bytes = readFileSync(path.resolve('./tarballs', tarMatch[1])); + res.writeHead(200, { 'content-type': 'application/octet-stream' }); + res.end(bytes); + return; + } catch { + // fall through to proxy + } + } + // Proxy anything we don't mock (pnpm/latest, tarball downloads for real + // packages, etc.) to the upstream registry. Keeps the fixture scoped to + // just the @org/create manifest while letting vp's other startup work + // (package-manager download) succeed normally. + await proxyToUpstream(req, res); +}); + +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + console.error('mock-server: failed to bind'); + process.exit(1); + } + const registry = `http://127.0.0.1:${address.port}`; + const separatorIndex = process.argv.indexOf('--'); + if (separatorIndex === -1) { + console.error('usage: node mock-server.mjs -- [args...]'); + server.close(() => process.exit(2)); + return; + } + const [cmd, ...args] = process.argv.slice(separatorIndex + 1); + const child = spawn(cmd, args, { + env: { ...process.env, NPM_CONFIG_REGISTRY: registry }, + stdio: 'inherit', + }); + child.on('exit', (code, signal) => { + const exitCode = code ?? (signal ? 128 : 0); + server.close(() => process.exit(exitCode)); + }); +}); diff --git a/packages/cli/snap-tests/create-org-bundled-escape-check/mock-manifest.json b/packages/cli/snap-tests/create-org-bundled-escape-check/mock-manifest.json new file mode 100644 index 0000000000..75c48ab0f8 --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-escape-check/mock-manifest.json @@ -0,0 +1,24 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "{REGISTRY}/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-fake" + }, + "createConfig": { + "templates": [ + { + "name": "escape", + "description": "Relative path that escapes the package root", + "template": "../outside" + } + ] + } + } + } + } +} diff --git a/packages/cli/snap-tests/create-org-bundled-escape-check/snap.txt b/packages/cli/snap-tests/create-org-bundled-escape-check/snap.txt new file mode 100644 index 0000000000..caab4b616e --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-escape-check/snap.txt @@ -0,0 +1,3 @@ +[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # `../outside` path rejected at schema-validation, before any tarball fetch +@your-org/create: createConfig.templates[0].template escapes the package root: ../outside + diff --git a/packages/cli/snap-tests/create-org-bundled-escape-check/steps.json b/packages/cli/snap-tests/create-org-bundled-escape-check/steps.json new file mode 100644 index 0000000000..5a5f63bd1a --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-escape-check/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # `../outside` path rejected at schema-validation, before any tarball fetch" + ] +} diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/mock-manifest.json b/packages/cli/snap-tests/create-org-bundled-monorepo/mock-manifest.json new file mode 100644 index 0000000000..b5cbbc1a88 --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/mock-manifest.json @@ -0,0 +1,25 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "{REGISTRY}/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-uIdfZ0qxDoaaESGePfk1CxcdTcfn91L2sqg0EQZelwxnPRFJRCLKaj12l95rXZdsV+e1wSL0XC+ZsKxhI95h+g==" + }, + "createConfig": { + "templates": [ + { + "name": "workspace", + "description": "Bundled monorepo workspace", + "template": "./templates/workspace", + "monorepo": true + } + ] + } + } + } + } +} diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt new file mode 100644 index 0000000000..7c4209e2bd --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/snap.txt @@ -0,0 +1,42 @@ +> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:workspace --no-interactive --directory my-mono # bundled monorepo: extract tarball, scaffold, inject create.defaultTemplate +◇ Scaffolded my-mono +• Node pnpm +→ Next: cd my-mono && vp run + +> cat my-mono/vite.config.ts # create.defaultTemplate auto-set to @your-org +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + staged: { + "*": "vp check --fix", + }, + create: { defaultTemplate: "@your-org" }, + fmt: {}, + lint: { options: { typeAware: true, typeCheck: true } }, + run: { cache: true }, +}); + +> cat my-mono/pnpm-workspace.yaml # workspace markers preserved +packages: + - apps/* + - packages/* +catalog: + vite: npm:@voidzero-dev/vite-plus-core@latest + vitest: npm:@voidzero-dev/vite-plus-test@latest + vite-plus: latest +overrides: + vite: "catalog:" + vitest: "catalog:" +peerDependencyRules: + allowAny: + - vite + - vitest + allowedVersions: + vite: "*" + vitest: "*" + +> test -d my-mono/.git && echo 'Git initialized' || echo 'No git' # git-init prompt covers bundled monorepo path +Git initialized + +> cat my-mono/.gitignore # node_modules excluded even though tarball shipped no .gitignore +node_modules diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/steps.json b/packages/cli/snap-tests/create-org-bundled-monorepo/steps.json new file mode 100644 index 0000000000..c299dc0d00 --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:workspace --no-interactive --directory my-mono # bundled monorepo: extract tarball, scaffold, inject create.defaultTemplate", + "cat my-mono/vite.config.ts # create.defaultTemplate auto-set to @your-org", + "cat my-mono/pnpm-workspace.yaml # workspace markers preserved", + "test -d my-mono/.git && echo 'Git initialized' || echo 'No git' # git-init prompt covers bundled monorepo path", + "cat my-mono/.gitignore # node_modules excluded even though tarball shipped no .gitignore" + ] +} diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/tarballs/README.md b/packages/cli/snap-tests/create-org-bundled-monorepo/tarballs/README.md new file mode 100644 index 0000000000..5373623899 --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled-monorepo/tarballs/README.md @@ -0,0 +1,31 @@ +# Tarball regeneration + +`create-1.0.0.tgz` is committed binary. The mock manifest's +`integrity: "sha512-…"` field locks the bytes — regenerating without +matching the original bit-for-bit will break the snap-test. + +To regenerate (e.g. after editing one of the bundled template files): + +```sh +mkdir -p /tmp/vp-bundled-monorepo-build/package/templates/workspace +# … recreate the template files inside that workspace dir … + +cd /tmp/vp-bundled-monorepo-build +COPYFILE_DISABLE=1 tar --no-mac-metadata -czf \ + /packages/cli/snap-tests/create-org-bundled-monorepo/tarballs/create-1.0.0.tgz \ + package/ + +# Then update mock-manifest.json's `integrity` field: +node -e " + const fs = require('node:fs'); + const crypto = require('node:crypto'); + const buf = fs.readFileSync( + '/packages/cli/snap-tests/create-org-bundled-monorepo/tarballs/create-1.0.0.tgz', + ); + console.log('sha512-' + crypto.createHash('sha512').update(buf).digest('base64')); +" +``` + +`COPYFILE_DISABLE=1` and `--no-mac-metadata` keep macOS from injecting +`._*` AppleDouble files that pollute the extracted output and break the +snap-test. diff --git a/packages/cli/snap-tests/create-org-bundled-monorepo/tarballs/create-1.0.0.tgz b/packages/cli/snap-tests/create-org-bundled-monorepo/tarballs/create-1.0.0.tgz new file mode 100644 index 0000000000..3f2d58141a Binary files /dev/null and b/packages/cli/snap-tests/create-org-bundled-monorepo/tarballs/create-1.0.0.tgz differ diff --git a/packages/cli/snap-tests/create-org-bundled/mock-manifest.json b/packages/cli/snap-tests/create-org-bundled/mock-manifest.json new file mode 100644 index 0000000000..8f04735408 --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled/mock-manifest.json @@ -0,0 +1,24 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "{REGISTRY}/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-swwZCXG8RDBJNTmiw3zCOBCQx9bCg7wHqoW37aV7BgGYfBUU7n7c+efbSCg0+FpdLhgeUjEMP5IAO+8exmeFKg==" + }, + "createConfig": { + "templates": [ + { + "name": "demo", + "description": "Bundled demo template", + "template": "./templates/demo" + } + ] + } + } + } + } +} diff --git a/packages/cli/snap-tests/create-org-bundled/snap.txt b/packages/cli/snap-tests/create-org-bundled/snap.txt new file mode 100644 index 0000000000..8518d0425f --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled/snap.txt @@ -0,0 +1,24 @@ +> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template: extract tarball, copy subdir +◇ Scaffolded my-demo-app +• Node pnpm +→ Next: cd my-demo-app && vp run + +> cat my-demo-app/package.json # verify package.json name was rewritten +{ + "name": "my-demo-app", + "version": "0.0.0", + "scripts": { + "dev": "vp dev", + "prepare": "vp config" + }, + "devDependencies": { + "vite-plus": "catalog:" + }, + "packageManager": "pnpm@" +} + +> cat my-demo-app/src/index.ts # verify bundled source copied +export const name = "demo"; + +> ls my-demo-app/README.md # verify README copied +my-demo-app/README.md diff --git a/packages/cli/snap-tests/create-org-bundled/steps.json b/packages/cli/snap-tests/create-org-bundled/steps.json new file mode 100644 index 0000000000..0c8f9babe1 --- /dev/null +++ b/packages/cli/snap-tests/create-org-bundled/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:demo --no-interactive --directory my-demo-app # bundled template: extract tarball, copy subdir", + "cat my-demo-app/package.json # verify package.json name was rewritten", + "cat my-demo-app/src/index.ts # verify bundled source copied", + "ls my-demo-app/README.md # verify README copied" + ] +} diff --git a/packages/cli/snap-tests/create-org-bundled/tarballs/create-1.0.0.tgz b/packages/cli/snap-tests/create-org-bundled/tarballs/create-1.0.0.tgz new file mode 100644 index 0000000000..b73b8ed8cd Binary files /dev/null and b/packages/cli/snap-tests/create-org-bundled/tarballs/create-1.0.0.tgz differ diff --git a/packages/cli/snap-tests/create-org-config-default/mock-manifest.json b/packages/cli/snap-tests/create-org-config-default/mock-manifest.json new file mode 100644 index 0000000000..6a506fccc2 --- /dev/null +++ b/packages/cli/snap-tests/create-org-config-default/mock-manifest.json @@ -0,0 +1,24 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "{REGISTRY}/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-fake" + }, + "createConfig": { + "templates": [ + { + "name": "web", + "description": "Web app template", + "template": "@your-org/template-web" + } + ] + } + } + } + } +} diff --git a/packages/cli/snap-tests/create-org-config-default/package.json b/packages/cli/snap-tests/create-org-config-default/package.json new file mode 100644 index 0000000000..5209edeb65 --- /dev/null +++ b/packages/cli/snap-tests/create-org-config-default/package.json @@ -0,0 +1,5 @@ +{ + "name": "config-default-fixture", + "private": true, + "type": "module" +} diff --git a/packages/cli/snap-tests/create-org-config-default/snap.txt b/packages/cli/snap-tests/create-org-config-default/snap.txt new file mode 100644 index 0000000000..76b3970a87 --- /dev/null +++ b/packages/cli/snap-tests/create-org-config-default/snap.txt @@ -0,0 +1,15 @@ +[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create --no-interactive # bare vp create picks up create.defaultTemplate from vite.config.ts + +A template name is required when running `vp create @your-org` in non-interactive mode. + +Available templates in @your-org/create: + + NAME DESCRIPTION TEMPLATE + web Web app template @your-org/template-web + +Examples: + # Scaffold a specific template from the org + vp create @your-org:web --no-interactive + + # Or use a Vite+ built-in template + vp create vite:application --no-interactive diff --git a/packages/cli/snap-tests/create-org-config-default/steps.json b/packages/cli/snap-tests/create-org-config-default/steps.json new file mode 100644 index 0000000000..96d50da95f --- /dev/null +++ b/packages/cli/snap-tests/create-org-config-default/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create --no-interactive # bare vp create picks up create.defaultTemplate from vite.config.ts" + ] +} diff --git a/packages/cli/snap-tests/create-org-config-default/vite.config.ts b/packages/cli/snap-tests/create-org-config-default/vite.config.ts new file mode 100644 index 0000000000..ae461d0b2f --- /dev/null +++ b/packages/cli/snap-tests/create-org-config-default/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + create: { + defaultTemplate: '@your-org', + }, +}); diff --git a/packages/cli/snap-tests/create-org-invalid-manifest/mock-manifest.json b/packages/cli/snap-tests/create-org-invalid-manifest/mock-manifest.json new file mode 100644 index 0000000000..26f169963f --- /dev/null +++ b/packages/cli/snap-tests/create-org-invalid-manifest/mock-manifest.json @@ -0,0 +1,23 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "{REGISTRY}/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-fake" + }, + "createConfig": { + "templates": [ + { + "name": "broken-entry", + "description": "Entry missing the required template field" + } + ] + } + } + } + } +} diff --git a/packages/cli/snap-tests/create-org-invalid-manifest/snap.txt b/packages/cli/snap-tests/create-org-invalid-manifest/snap.txt new file mode 100644 index 0000000000..6cee8fa983 --- /dev/null +++ b/packages/cli/snap-tests/create-org-invalid-manifest/snap.txt @@ -0,0 +1,3 @@ +[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # invalid manifest -> schema error +@your-org/create: createConfig.templates[0].template must be a non-empty string + diff --git a/packages/cli/snap-tests/create-org-invalid-manifest/steps.json b/packages/cli/snap-tests/create-org-invalid-manifest/steps.json new file mode 100644 index 0000000000..b1c5f62be4 --- /dev/null +++ b/packages/cli/snap-tests/create-org-invalid-manifest/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # invalid manifest -> schema error" + ] +} diff --git a/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/mock-manifest.json b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/mock-manifest.json new file mode 100644 index 0000000000..b0223753cf --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/mock-manifest.json @@ -0,0 +1,25 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "{REGISTRY}/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-fake" + }, + "createConfig": { + "templates": [ + { + "name": "monorepo", + "description": "Monorepo scaffold", + "template": "@your-org/template-monorepo", + "monorepo": true + } + ] + } + } + } + } +} diff --git a/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/package.json b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/package.json new file mode 100644 index 0000000000..4a0bd3b994 --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/package.json @@ -0,0 +1,5 @@ +{ + "name": "monorepo-direct-fixture", + "private": true, + "packageManager": "pnpm@10.0.0" +} diff --git a/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/pnpm-workspace.yaml b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/snap.txt b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/snap.txt new file mode 100644 index 0000000000..0d0243b201 --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/snap.txt @@ -0,0 +1,6 @@ +[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:monorepo --no-interactive # direct-selecting a monorepo entry inside a monorepo -> refuse + +You are already in a monorepo workspace. +Use a different template or run this command outside the monorepo +Cannot create a monorepo inside an existing monorepo + diff --git a/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/steps.json b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/steps.json new file mode 100644 index 0000000000..a1c18156dc --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-direct-in-monorepo/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org:monorepo --no-interactive # direct-selecting a monorepo entry inside a monorepo -> refuse" + ] +} diff --git a/packages/cli/snap-tests/create-org-monorepo-filter/mock-manifest.json b/packages/cli/snap-tests/create-org-monorepo-filter/mock-manifest.json new file mode 100644 index 0000000000..5c6b4403fc --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-filter/mock-manifest.json @@ -0,0 +1,35 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "{REGISTRY}/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-fake" + }, + "createConfig": { + "templates": [ + { + "name": "monorepo", + "description": "Monorepo scaffold", + "template": "@your-org/template-monorepo", + "monorepo": true + }, + { + "name": "web", + "description": "Web app template (Vite + React)", + "template": "@your-org/template-web" + }, + { + "name": "library", + "description": "TypeScript library template", + "template": "@your-org/template-library" + } + ] + } + } + } + } +} diff --git a/packages/cli/snap-tests/create-org-monorepo-filter/package.json b/packages/cli/snap-tests/create-org-monorepo-filter/package.json new file mode 100644 index 0000000000..48ddb7d7a4 --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-filter/package.json @@ -0,0 +1,5 @@ +{ + "name": "monorepo-filter-fixture", + "private": true, + "packageManager": "pnpm@10.0.0" +} diff --git a/packages/cli/snap-tests/create-org-monorepo-filter/pnpm-workspace.yaml b/packages/cli/snap-tests/create-org-monorepo-filter/pnpm-workspace.yaml new file mode 100644 index 0000000000..924b55f42e --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-filter/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/packages/cli/snap-tests/create-org-monorepo-filter/snap.txt b/packages/cli/snap-tests/create-org-monorepo-filter/snap.txt new file mode 100644 index 0000000000..72c7991362 --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-filter/snap.txt @@ -0,0 +1,18 @@ +[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # inside monorepo, hides monorepo:true entries + shows omitted footer + +A template name is required when running `vp create @your-org` in non-interactive mode. + +Available templates in @your-org/create: + + NAME DESCRIPTION TEMPLATE + web Web app template (Vite + React) @your-org/template-web + library TypeScript library template @your-org/template-library + +(omitted 1 monorepo-only entry because this workspace is already a monorepo) + +Examples: + # Scaffold a specific template from the org + vp create @your-org:web --no-interactive + + # Or use a Vite+ built-in template + vp create vite:application --no-interactive diff --git a/packages/cli/snap-tests/create-org-monorepo-filter/steps.json b/packages/cli/snap-tests/create-org-monorepo-filter/steps.json new file mode 100644 index 0000000000..482bcedd8f --- /dev/null +++ b/packages/cli/snap-tests/create-org-monorepo-filter/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # inside monorepo, hides monorepo:true entries + shows omitted footer" + ] +} diff --git a/packages/cli/snap-tests/create-org-no-interactive-error/mock-manifest.json b/packages/cli/snap-tests/create-org-no-interactive-error/mock-manifest.json new file mode 100644 index 0000000000..8671cd2fb7 --- /dev/null +++ b/packages/cli/snap-tests/create-org-no-interactive-error/mock-manifest.json @@ -0,0 +1,29 @@ +{ + "@your-org/create": { + "name": "@your-org/create", + "dist-tags": { "latest": "1.0.0" }, + "versions": { + "1.0.0": { + "version": "1.0.0", + "dist": { + "tarball": "http://placeholder/@your-org/create/-/create-1.0.0.tgz", + "integrity": "sha512-fake" + }, + "createConfig": { + "templates": [ + { + "name": "web", + "description": "Web app template", + "template": "@your-org/template-web" + }, + { + "name": "library", + "description": "TypeScript library template", + "template": "@your-org/template-library" + } + ] + } + } + } + } +} diff --git a/packages/cli/snap-tests/create-org-no-interactive-error/snap.txt b/packages/cli/snap-tests/create-org-no-interactive-error/snap.txt new file mode 100644 index 0000000000..65e01c911e --- /dev/null +++ b/packages/cli/snap-tests/create-org-no-interactive-error/snap.txt @@ -0,0 +1,16 @@ +[1]> node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # prints manifest table, exits 1 + +A template name is required when running `vp create @your-org` in non-interactive mode. + +Available templates in @your-org/create: + + NAME DESCRIPTION TEMPLATE + web Web app template @your-org/template-web + library TypeScript library template @your-org/template-library + +Examples: + # Scaffold a specific template from the org + vp create @your-org:web --no-interactive + + # Or use a Vite+ built-in template + vp create vite:application --no-interactive diff --git a/packages/cli/snap-tests/create-org-no-interactive-error/steps.json b/packages/cli/snap-tests/create-org-no-interactive-error/steps.json new file mode 100644 index 0000000000..f04dd8d5e6 --- /dev/null +++ b/packages/cli/snap-tests/create-org-no-interactive-error/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "node $SNAP_CASES_DIR/.shared/mock-npm-registry.mjs -- vp create @your-org --no-interactive # prints manifest table, exits 1" + ] +} diff --git a/packages/cli/src/create/__tests__/discovery.spec.ts b/packages/cli/src/create/__tests__/discovery.spec.ts index dee6803fb0..cdd016f20a 100644 --- a/packages/cli/src/create/__tests__/discovery.spec.ts +++ b/packages/cli/src/create/__tests__/discovery.spec.ts @@ -108,4 +108,36 @@ describe('GitHub template helpers', () => { expect(template.command).toBe('degit'); expect(template.args).toEqual(['nkzw-tech/fate-template', 'my-app']); }); + + it('should keep manifest-resolved specifiers literal when skipShorthand=true', () => { + const workspace = { + rootDir: '/tmp/workspace', + isMonorepo: false, + monorepoScope: '', + workspacePatterns: [], + parentDirs: [], + packageManager: 'pnpm', + packageManagerVersion: 'latest', + downloadPackageManager: { binPrefix: '/tmp/bin', version: '10.0.0' } as never, + packages: [], + } as never; + + // A manifest entry like `{ template: '@your-org/template-web' }` must + // NOT be rewritten into `@your-org/create-template-web` by the create + // shorthand expander — the manifest author already gave the exact + // npm package name they want. + const fromManifest = discoverTemplate( + '@your-org/template-web', + [], + workspace, + false, + undefined, + true, + ); + expect(fromManifest.command).toBe('@your-org/template-web'); + + // But without the flag, the existing shorthand rules still apply. + const withoutFlag = discoverTemplate('@your-org/template-web', [], workspace); + expect(withoutFlag.command).toBe('@your-org/create-template-web'); + }); }); diff --git a/packages/cli/src/create/__tests__/org-manifest.spec.ts b/packages/cli/src/create/__tests__/org-manifest.spec.ts new file mode 100644 index 0000000000..efb65d0a11 --- /dev/null +++ b/packages/cli/src/create/__tests__/org-manifest.spec.ts @@ -0,0 +1,349 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + filterManifestForContext, + OrgManifestSchemaError, + parseOrgScopedSpec, + readOrgManifest, + type OrgTemplateEntry, +} from '../org-manifest.js'; + +describe('parseOrgScopedSpec', () => { + it('returns null for non-scoped specs', () => { + expect(parseOrgScopedSpec('create-vite')).toBeNull(); + expect(parseOrgScopedSpec('vite')).toBeNull(); + expect(parseOrgScopedSpec('./local')).toBeNull(); + expect(parseOrgScopedSpec('')).toBeNull(); + }); + + it('parses @scope without a name', () => { + expect(parseOrgScopedSpec('@your-org')).toEqual({ scope: '@your-org' }); + }); + + it('parses @scope@version without a name', () => { + expect(parseOrgScopedSpec('@your-org@latest')).toEqual({ + scope: '@your-org', + version: 'latest', + }); + }); + + it('parses @scope:name', () => { + expect(parseOrgScopedSpec('@your-org:web')).toEqual({ scope: '@your-org', name: 'web' }); + }); + + it('parses @scope:name@version', () => { + expect(parseOrgScopedSpec('@your-org:web@1.2.3')).toEqual({ + scope: '@your-org', + name: 'web', + version: '1.2.3', + }); + }); + + it('treats @scope: (empty name) as scope-only', () => { + expect(parseOrgScopedSpec('@your-org:')).toEqual({ scope: '@your-org' }); + }); + + it('returns null for the @scope/name slash form (reserved for existing shorthand)', () => { + expect(parseOrgScopedSpec('@your-org/web')).toBeNull(); + expect(parseOrgScopedSpec('@your-org/create-web')).toBeNull(); + expect(parseOrgScopedSpec('@your-org/')).toBeNull(); + }); +}); + +describe('filterManifestForContext', () => { + const templates: OrgTemplateEntry[] = [ + { name: 'monorepo', description: 'root', template: './m', monorepo: true }, + { name: 'web', description: 'web', template: './w' }, + { name: 'library', description: 'lib', template: './l' }, + ]; + + it('keeps all entries when not inside a monorepo', () => { + expect(filterManifestForContext(templates, false)).toEqual(templates); + }); + + it('drops monorepo:true entries when inside a monorepo', () => { + const filtered = filterManifestForContext(templates, true); + expect(filtered.map((e) => e.name)).toEqual(['web', 'library']); + }); +}); + +function packument( + vpTemplates: unknown, + extra: Record = {}, + extraVersions: Record = {}, +) { + return { + name: '@your-org/create', + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + version: '1.0.0', + dist: { + tarball: 'https://registry.npmjs.org/@your-org/create/-/create-1.0.0.tgz', + integrity: 'sha512-fake', + }, + createConfig: vpTemplates !== undefined ? { templates: vpTemplates } : undefined, + ...extra, + }, + ...extraVersions, + }, + }; +} + +function mockFetchJson(body: unknown, status = 200): ReturnType { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + status, + ok: status >= 200 && status < 300, + async json() { + return body; + }, + } as unknown as Response); +} + +describe('readOrgManifest', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns null on 404', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ status: 404, ok: false } as Response); + expect(await readOrgManifest('@your-org')).toBeNull(); + }); + + it('returns null when the package has no createConfig.templates field', async () => { + mockFetchJson(packument(undefined)); + expect(await readOrgManifest('@your-org')).toBeNull(); + }); + + it('returns null when createConfig.templates is an empty array', async () => { + mockFetchJson(packument([])); + expect(await readOrgManifest('@your-org')).toBeNull(); + }); + + it('parses a valid manifest', async () => { + mockFetchJson( + packument([ + { name: 'web', description: 'Web app', template: '@your-org/template-web' }, + { name: 'demo', description: 'Demo', template: './templates/demo', monorepo: true }, + ]), + ); + const manifest = await readOrgManifest('@your-org'); + expect(manifest).not.toBeNull(); + expect(manifest?.packageName).toBe('@your-org/create'); + expect(manifest?.version).toBe('1.0.0'); + expect(manifest?.tarballUrl).toMatch(/create-1\.0\.0\.tgz$/); + expect(manifest?.integrity).toBe('sha512-fake'); + expect(manifest?.templates).toHaveLength(2); + expect(manifest?.templates[1].monorepo).toBe(true); + }); + + it('throws on non-array createConfig.templates', async () => { + mockFetchJson(packument('nope')); + await expect(readOrgManifest('@your-org')).rejects.toBeInstanceOf(OrgManifestSchemaError); + }); + + it('throws on an entry missing required fields', async () => { + mockFetchJson(packument([{ name: 'web', description: 'no template yet' }])); + await expect(readOrgManifest('@your-org')).rejects.toThrow( + /createConfig\.templates\[0]\.template/, + ); + }); + + it('throws on duplicate entry names', async () => { + mockFetchJson( + packument([ + { name: 'web', description: 'one', template: '@a/one' }, + { name: 'web', description: 'two', template: '@a/two' }, + ]), + ); + await expect(readOrgManifest('@your-org')).rejects.toThrow(/duplicates an earlier entry/); + }); + + it('throws when a bundled path escapes the package root', async () => { + mockFetchJson(packument([{ name: 'demo', description: 'x', template: '../outside' }])); + await expect(readOrgManifest('@your-org')).rejects.toThrow(/escapes the package root/); + }); + + it('throws when an entry name uses the reserved `__vp_` prefix', async () => { + mockFetchJson( + packument([{ name: '__vp_builtin_escape__', description: 'collides', template: '@a/b' }]), + ); + await expect(readOrgManifest('@your-org')).rejects.toThrow(/uses the reserved `__vp_` prefix/); + }); + + it('throws on non-boolean monorepo field', async () => { + mockFetchJson( + packument([ + { + name: 'web', + description: 'x', + template: '@a/b', + monorepo: 'yes', + }, + ]), + ); + await expect(readOrgManifest('@your-org')).rejects.toThrow(/monorepo must be a boolean/); + }); + + it('throws when dist.tarball is missing', async () => { + mockFetchJson({ + name: '@your-org/create', + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + version: '1.0.0', + dist: {}, + createConfig: { templates: [{ name: 'a', description: 'a', template: '@a/a' }] }, + }, + }, + }); + await expect(readOrgManifest('@your-org')).rejects.toThrow(/missing dist\.tarball/); + }); + + it('throws when the registry responds with a non-404 error', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + status: 500, + ok: false, + } as Response); + await expect(readOrgManifest('@your-org')).rejects.toThrow(/500/); + }); + + it('honors NPM_CONFIG_REGISTRY when fetching the packument', async () => { + const original = process.env.NPM_CONFIG_REGISTRY; + process.env.NPM_CONFIG_REGISTRY = 'https://registry.example.com/'; + try { + const mockFetch = mockFetchJson( + packument([{ name: 'a', description: 'a', template: '@a/a' }]), + ); + await readOrgManifest('@your-org'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://registry.example.com/@your-org/create', + expect.any(Object), + ); + } finally { + if (original === undefined) { + delete process.env.NPM_CONFIG_REGISTRY; + } else { + process.env.NPM_CONFIG_REGISTRY = original; + } + } + }); + + it('honors scope-specific npm_config_@scope:registry env', async () => { + const key = 'npm_config_@your-org:registry'; + const original = process.env[key]; + process.env[key] = 'https://private.example.com/'; + try { + const mockFetch = mockFetchJson( + packument([{ name: 'a', description: 'a', template: '@a/a' }]), + ); + await readOrgManifest('@your-org'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://private.example.com/@your-org/create', + expect.any(Object), + ); + } finally { + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } + }); + + it('retries with Bearer auth after a 401 when a matching _authToken is configured', async () => { + const registryKey = 'npm_config_@your-org:registry'; + const tokenKey = 'npm_config_//private.example.com/:_authToken'; + const originals = { + [registryKey]: process.env[registryKey], + [tokenKey]: process.env[tokenKey], + }; + process.env[registryKey] = 'https://private.example.com/'; + process.env[tokenKey] = 'SECRET-TOKEN'; + try { + const body = packument([{ name: 'a', description: 'a', template: '@a/a' }]); + const mockFetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ status: 401, ok: false } as Response) + .mockResolvedValueOnce({ + status: 200, + ok: true, + async json() { + return body; + }, + } as unknown as Response); + await readOrgManifest('@your-org'); + expect(mockFetch).toHaveBeenCalledTimes(2); + const [, firstInit] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect( + (firstInit.headers as Record | undefined)?.authorization, + ).toBeUndefined(); + const [, secondInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect((secondInit.headers as Record).authorization).toBe( + 'Bearer SECRET-TOKEN', + ); + } finally { + for (const [k, v] of Object.entries(originals)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + } + }); + + it('does not send auth when the first request succeeds', async () => { + const mockFetch = mockFetchJson(packument([{ name: 'a', description: 'a', template: '@a/a' }])); + await readOrgManifest('@your-org'); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).authorization).toBeUndefined(); + }); + + it('resolves a pinned version instead of dist-tags.latest', async () => { + const body = packument( + [{ name: 'web', description: 'v1', template: '@your-org/template-web' }], + {}, + { + '2.0.0-beta.1': { + version: '2.0.0-beta.1', + dist: { + tarball: 'https://registry.npmjs.org/@your-org/create/-/create-2.0.0-beta.1.tgz', + integrity: 'sha512-beta', + }, + createConfig: { + templates: [ + { name: 'web', description: 'beta v2', template: '@your-org/template-web' }, + ], + }, + }, + }, + ); + mockFetchJson(body); + const manifest = await readOrgManifest('@your-org', '2.0.0-beta.1'); + expect(manifest?.version).toBe('2.0.0-beta.1'); + expect(manifest?.templates[0].description).toBe('beta v2'); + expect(manifest?.tarballUrl).toMatch(/create-2\.0\.0-beta\.1\.tgz$/); + }); + + it('resolves a dist-tag alias when passed as a version', async () => { + const body = packument( + [{ name: 'web', description: 'v1', template: '@your-org/template-web' }], + {}, + ); + (body as { 'dist-tags': Record })['dist-tags'].next = '1.0.0'; + mockFetchJson(body); + const manifest = await readOrgManifest('@your-org', 'next'); + expect(manifest?.version).toBe('1.0.0'); + }); + + it('throws when a pinned version is unknown', async () => { + mockFetchJson( + packument([{ name: 'web', description: 'v1', template: '@your-org/template-web' }]), + ); + await expect(readOrgManifest('@your-org', '9.9.9')).rejects.toThrow( + /version "9\.9\.9" not found/, + ); + }); +}); diff --git a/packages/cli/src/create/__tests__/org-picker.spec.ts b/packages/cli/src/create/__tests__/org-picker.spec.ts new file mode 100644 index 0000000000..01f7f866e9 --- /dev/null +++ b/packages/cli/src/create/__tests__/org-picker.spec.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { OrgManifest } from '../org-manifest.js'; +import { + formatManifestTable, + ORG_PICKER_BUILTIN_ESCAPE, + ORG_PICKER_CANCEL, + pickOrgTemplate, +} from '../org-picker.js'; + +const { mockSelect, mockIsCancel } = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockIsCancel: vi.fn((value: unknown) => value === '__cancel__'), +})); + +vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + select: mockSelect, + isCancel: mockIsCancel, +})); + +function manifest(overrides?: Partial): OrgManifest { + return { + scope: '@your-org', + packageName: '@your-org/create', + version: '1.0.0', + tarballUrl: 'https://example/create-1.0.0.tgz', + integrity: 'sha512-fake', + templates: [ + { name: 'monorepo', description: 'Full scaffold', template: './m', monorepo: true }, + { name: 'web', description: 'Web app', template: '@your-org/template-web' }, + { name: 'library', description: 'Library', template: '@your-org/template-library' }, + ], + ...overrides, + }; +} + +describe('pickOrgTemplate', () => { + afterEach(() => { + mockSelect.mockReset(); + mockIsCancel.mockClear(); + }); + + it('appends a built-in escape-hatch entry as the last option', async () => { + mockSelect.mockResolvedValue('web'); + await pickOrgTemplate(manifest(), { isMonorepo: false }); + const passedOptions = mockSelect.mock.calls[0][0].options; + expect(passedOptions.map((o: { value: string }) => o.value).slice(0, -1)).toEqual([ + 'monorepo', + 'web', + 'library', + ]); + expect(passedOptions.at(-1)).toMatchObject({ label: 'Vite+ built-in templates' }); + }); + + it('filters monorepo:true entries when isMonorepo is true', async () => { + mockSelect.mockResolvedValue('web'); + await pickOrgTemplate(manifest(), { isMonorepo: true }); + const passedOptions = mockSelect.mock.calls[0][0].options; + expect(passedOptions.map((o: { value: string }) => o.value).slice(0, -1)).toEqual([ + 'web', + 'library', + ]); + expect(passedOptions.at(-1)).toMatchObject({ label: 'Vite+ built-in templates' }); + }); + + it('returns the entry for a non-escape selection', async () => { + mockSelect.mockResolvedValue('web'); + const result = await pickOrgTemplate(manifest(), { isMonorepo: false }); + expect(result).toEqual({ + kind: 'entry', + entry: expect.objectContaining({ name: 'web' }), + }); + }); + + it('returns the escape-hatch sentinel when the escape entry is picked', async () => { + // Emulate `select` resolving with whatever value the picker assigned + // to its escape-hatch option. If the option isn't in the list at all, + // the assertion below fails. + mockSelect.mockImplementation( + async (opts: { options: { value: string; label: string }[] }) => + opts.options.find((o) => o.label === 'Vite+ built-in templates')?.value, + ); + expect(await pickOrgTemplate(manifest(), { isMonorepo: false })).toBe( + ORG_PICKER_BUILTIN_ESCAPE, + ); + }); + + it('returns the cancel sentinel when the prompt is cancelled', async () => { + mockSelect.mockResolvedValue('__cancel__'); + expect(await pickOrgTemplate(manifest(), { isMonorepo: false })).toBe(ORG_PICKER_CANCEL); + }); + + it('returns the escape-hatch sentinel when every entry is filtered out', async () => { + const allMonorepo = manifest({ + templates: [ + { name: 'a', description: 'a', template: './a', monorepo: true }, + { name: 'b', description: 'b', template: './b', monorepo: true }, + ], + }); + const result = await pickOrgTemplate(allMonorepo, { isMonorepo: true }); + expect(result).toBe(ORG_PICKER_BUILTIN_ESCAPE); + expect(mockSelect).not.toHaveBeenCalled(); + }); +}); + +describe('formatManifestTable', () => { + it('renders a stable, whitespace-aligned table', () => { + const { lines, filteredCount } = formatManifestTable(manifest(), false); + expect(filteredCount).toBe(0); + expect(lines[0]).toMatch(/^ {2}NAME\s+DESCRIPTION\s+TEMPLATE/); + // Every row includes name, description, and template specifier. + expect(lines[1]).toMatch(/monorepo\s+Full scaffold\s+\.\/m/); + expect(lines[2]).toMatch(/web\s+Web app\s+@your-org\/template-web/); + }); + + it('filters monorepo entries inside a monorepo and reports the count', () => { + const { lines, filteredCount } = formatManifestTable(manifest(), true); + expect(filteredCount).toBe(1); + expect(lines.some((line) => line.includes('monorepo '))).toBe(false); + expect(lines.some((line) => line.includes('web'))).toBe(true); + }); +}); diff --git a/packages/cli/src/create/__tests__/org-resolve.spec.ts b/packages/cli/src/create/__tests__/org-resolve.spec.ts new file mode 100644 index 0000000000..b1f480f8bd --- /dev/null +++ b/packages/cli/src/create/__tests__/org-resolve.spec.ts @@ -0,0 +1,56 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { getConfiguredDefaultTemplate } from '../org-resolve.js'; + +describe('getConfiguredDefaultTemplate', () => { + let repoRoot: string; + + beforeEach(() => { + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-org-resolve-')); + }); + + afterEach(() => { + fs.rmSync(repoRoot, { recursive: true, force: true }); + }); + + function writeMonorepoConfig(defaultTemplate: string): void { + fs.writeFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n"); + // Plain object export instead of `defineConfig` — the test only + // needs the shape to be readable, and dropping the `vite-plus` + // import avoids noisy module-not-found warnings from vite's loader. + fs.writeFileSync( + path.join(repoRoot, 'vite.config.ts'), + `export default { create: { defaultTemplate: '${defaultTemplate}' } };\n`, + ); + fs.writeFileSync(path.join(repoRoot, 'package.json'), '{"name":"fixture"}'); + } + + it('reads the defaultTemplate from the workspace root when invoked at the root', async () => { + writeMonorepoConfig('@your-org'); + expect(await getConfiguredDefaultTemplate(repoRoot)).toBe('@your-org'); + }); + + it('walks up from a workspace subdirectory to find the root config', async () => { + writeMonorepoConfig('@your-org'); + const deep = path.join(repoRoot, 'apps', 'web', 'src'); + fs.mkdirSync(deep, { recursive: true }); + expect(await getConfiguredDefaultTemplate(deep)).toBe('@your-org'); + }); + + it('returns undefined when no vite.config exists anywhere up the tree', async () => { + const deep = path.join(repoRoot, 'nested'); + fs.mkdirSync(deep, { recursive: true }); + expect(await getConfiguredDefaultTemplate(deep)).toBeUndefined(); + }); + + it('returns undefined when vite.config has no create.defaultTemplate', async () => { + fs.writeFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), "packages:\n - 'apps/*'\n"); + fs.writeFileSync(path.join(repoRoot, 'vite.config.ts'), 'export default {};\n'); + fs.writeFileSync(path.join(repoRoot, 'package.json'), '{"name":"fixture"}'); + expect(await getConfiguredDefaultTemplate(repoRoot)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/create/__tests__/org-tarball.spec.ts b/packages/cli/src/create/__tests__/org-tarball.spec.ts new file mode 100644 index 0000000000..2347270f4e --- /dev/null +++ b/packages/cli/src/create/__tests__/org-tarball.spec.ts @@ -0,0 +1,175 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + cleanupStaleStagingDirs, + normalizeEntryName, + parseEntryMode, + resolveBundledPath, + sanitizeHostForPath, +} from '../org-tarball.js'; + +describe('resolveBundledPath', () => { + const scratchDirs: string[] = []; + + afterEach(() => { + for (const dir of scratchDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function tmpExtractedRoot(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-org-tarball-')); + scratchDirs.push(dir); + // Populate it with a fake template directory. + fs.mkdirSync(path.join(dir, 'templates', 'demo'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'templates', 'demo', 'package.json'), '{"name":"demo"}'); + return dir; + } + + it('resolves a simple ./subdir path', () => { + const root = tmpExtractedRoot(); + expect(resolveBundledPath(root, './templates/demo')).toBe(path.join(root, 'templates', 'demo')); + }); + + it('rejects paths that escape the root via ..', () => { + const root = tmpExtractedRoot(); + expect(() => resolveBundledPath(root, '../outside')).toThrow(/escapes the package root/); + }); + + it('rejects absolute paths', () => { + const root = tmpExtractedRoot(); + expect(() => resolveBundledPath(root, '/etc/passwd')).toThrow(/must be relative/); + }); + + it('returns the resolved path even when it does not exist (caller handles ENOENT)', () => { + const root = tmpExtractedRoot(); + expect(resolveBundledPath(root, './templates/ghost')).toBe( + path.join(root, 'templates', 'ghost'), + ); + }); + + it('normalizes trailing slashes', () => { + const root = tmpExtractedRoot(); + expect(resolveBundledPath(root, './templates/demo/')).toBe( + path.join(root, 'templates', 'demo'), + ); + }); +}); + +describe('normalizeEntryName', () => { + it('strips the `package/` prefix', () => { + expect(normalizeEntryName('package/README.md')).toBe('README.md'); + expect(normalizeEntryName('package/src/index.ts')).toBe('src/index.ts'); + }); + + it('normalizes leading `./` and backslashes to forward slashes', () => { + expect(normalizeEntryName('./package/src/index.ts')).toBe('src/index.ts'); + expect(normalizeEntryName('package\\src\\index.ts')).toBe('src/index.ts'); + }); + + it('returns null for the root `package/` directory and empty names', () => { + expect(normalizeEntryName('package')).toBeNull(); + expect(normalizeEntryName('package/')).toBeNull(); + expect(normalizeEntryName('')).toBeNull(); + }); + + it('returns null for PaxHeader metadata entries', () => { + expect(normalizeEntryName('PaxHeader/foo')).toBeNull(); + expect(normalizeEntryName('package/PaxHeader/foo')).toBeNull(); + }); + + it('returns null for entries outside the `package/` root', () => { + expect(normalizeEntryName('not-package/foo.ts')).toBeNull(); + expect(normalizeEntryName('node_modules/foo/package.json')).toBeNull(); + }); +}); + +describe('sanitizeHostForPath', () => { + it('passes through plain hostnames untouched', () => { + expect(sanitizeHostForPath('registry.npmjs.org')).toBe('registry.npmjs.org'); + expect(sanitizeHostForPath('private.example.com')).toBe('private.example.com'); + }); + + it('replaces the port `:` so the cache path is valid on Windows', () => { + expect(sanitizeHostForPath('localhost:4873')).toBe('localhost_4873'); + }); + + it('strips IPv6 brackets and colons from the literal', () => { + expect(sanitizeHostForPath('[::1]:4873')).toBe('___1__4873'); + }); +}); + +describe('parseEntryMode', () => { + it('parses octal `755` as 0o755', () => { + expect(parseEntryMode('755')).toBe(0o755); + }); + + it('parses a longer octal string and masks to permission bits', () => { + expect(parseEntryMode('100755')).toBe(0o755); + // `104755` carries the setuid bit (0o4000) — drop it. + expect(parseEntryMode('104755')).toBe(0o755); + }); + + it('returns undefined for missing or unparsable modes', () => { + expect(parseEntryMode(undefined)).toBeUndefined(); + expect(parseEntryMode('')).toBeUndefined(); + expect(parseEntryMode('not-a-number')).toBeUndefined(); + }); +}); + +describe('cleanupStaleStagingDirs', () => { + const scratchDirs: string[] = []; + + afterEach(() => { + for (const dir of scratchDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function tmpDestDir(): { destDir: string; parent: string; base: string } { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-org-cleanup-')); + scratchDirs.push(parent); + const base = 'create-1.0.0'; + return { destDir: path.join(parent, base), parent, base }; + } + + function makeStaging(parent: string, base: string, ageMs: number): string { + const name = `${base}.tmp-${process.pid}-${Date.now() - ageMs}`; + const dir = path.join(parent, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'marker'), ''); + const then = new Date(Date.now() - ageMs); + fs.utimesSync(dir, then, then); + return dir; + } + + it('deletes siblings older than 24h', async () => { + const { destDir, parent, base } = tmpDestDir(); + const stale = makeStaging(parent, base, 25 * 60 * 60 * 1000); + await cleanupStaleStagingDirs(destDir); + expect(fs.existsSync(stale)).toBe(false); + }); + + it('leaves fresh siblings in place (concurrency safety)', async () => { + const { destDir, parent, base } = tmpDestDir(); + const fresh = makeStaging(parent, base, 60 * 1000); + await cleanupStaleStagingDirs(destDir); + expect(fs.existsSync(fresh)).toBe(true); + }); + + it('ignores unrelated siblings (different basename prefix)', async () => { + const { destDir, parent } = tmpDestDir(); + const other = makeStaging(parent, 'unrelated-2.0.0', 48 * 60 * 60 * 1000); + await cleanupStaleStagingDirs(destDir); + expect(fs.existsSync(other)).toBe(true); + }); + + it('tolerates a missing parent directory', async () => { + const destDir = path.join(os.tmpdir(), 'vp-org-cleanup-missing', 'nope'); + await expect(cleanupStaleStagingDirs(destDir)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cli/src/create/__tests__/utils.spec.ts b/packages/cli/src/create/__tests__/utils.spec.ts index fd01ce4ed8..c2618b3be1 100644 --- a/packages/cli/src/create/__tests__/utils.spec.ts +++ b/packages/cli/src/create/__tests__/utils.spec.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { deriveDefaultPackageName, + ensureGitignoreNodeModules, formatTargetDir, getProjectDirFromPackageName, } from '../utils.js'; @@ -87,3 +92,69 @@ describe('deriveDefaultPackageName', () => { expect(result.length).toBeGreaterThan(0); }); }); + +describe('ensureGitignoreNodeModules', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-gitignore-')); + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + function gitignore(): string { + return fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf-8'); + } + + it('creates a fresh `.gitignore` with `node_modules` when none exists', () => { + ensureGitignoreNodeModules(projectDir); + expect(gitignore()).toBe('node_modules\n'); + }); + + it('appends `node_modules` to an existing `.gitignore` that omits it', () => { + fs.writeFileSync(path.join(projectDir, '.gitignore'), 'dist\n*.log\n'); + ensureGitignoreNodeModules(projectDir); + expect(gitignore()).toBe('dist\n*.log\nnode_modules\n'); + }); + + it('terminates the last line first when the existing file lacks a trailing newline', () => { + fs.writeFileSync(path.join(projectDir, '.gitignore'), 'dist'); + ensureGitignoreNodeModules(projectDir); + expect(gitignore()).toBe('dist\nnode_modules\n'); + }); + + it('is a no-op when `node_modules` already appears as a standalone line', () => { + const existing = '# Logs\n*.log\nnode_modules\ndist\n'; + fs.writeFileSync(path.join(projectDir, '.gitignore'), existing); + ensureGitignoreNodeModules(projectDir); + expect(gitignore()).toBe(existing); + }); + + it('treats `node_modules/` (with trailing slash) as a match', () => { + const existing = 'node_modules/\ndist\n'; + fs.writeFileSync(path.join(projectDir, '.gitignore'), existing); + ensureGitignoreNodeModules(projectDir); + expect(gitignore()).toBe(existing); + }); + + it('handles CRLF line endings without re-appending', () => { + const existing = 'node_modules\r\ndist\r\n'; + fs.writeFileSync(path.join(projectDir, '.gitignore'), existing); + ensureGitignoreNodeModules(projectDir); + expect(gitignore()).toBe(existing); + }); + + it('does not consider a `node_modules/sub` subpath as already excluded', () => { + fs.writeFileSync(path.join(projectDir, '.gitignore'), 'node_modules/sub\n'); + ensureGitignoreNodeModules(projectDir); + expect(gitignore()).toBe('node_modules/sub\nnode_modules\n'); + }); + + it('does not match `!node_modules` (an explicit un-ignore override)', () => { + fs.writeFileSync(path.join(projectDir, '.gitignore'), '!node_modules\n'); + ensureGitignoreNodeModules(projectDir); + expect(gitignore()).toBe('!node_modules\nnode_modules\n'); + }); +}); diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 059c3358bb..a1152fcc08 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { styleText } from 'node:util'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import spawn from 'cross-spawn'; import mri from 'mri'; import { vitePlusHeader } from '../../binding/index.js'; @@ -12,6 +13,7 @@ import { detectFramework, detectPrettierProject, hasFrameworkShim, + injectCreateDefaultTemplate, installGitHooks, promptEslintMigration, promptPrettierMigration, @@ -47,6 +49,11 @@ import { import type { ExecutionResult } from './command.ts'; import { discoverTemplate, inferGitHubRepoName, inferParentDir, isGitHubUrl } from './discovery.ts'; import { getInitialTemplateOptions } from './initial-template-options.ts'; +import { + getConfiguredDefaultTemplate, + type OrgResolution, + resolveOrgManifestForCreate, +} from './org-resolve.ts'; import { cancelAndExit, checkProjectDirExists, @@ -57,11 +64,12 @@ import { import { getRandomProjectName } from './random-name.ts'; import { executeBuiltinTemplate, + executeBundledTemplate, executeMonorepoTemplate, executeRemoteTemplate, } from './templates/index.ts'; import { BuiltinTemplate, TemplateType } from './templates/types.ts'; -import { deriveDefaultPackageName, formatTargetDir } from './utils.ts'; +import { deriveDefaultPackageName, ensureGitignoreNodeModules, formatTargetDir } from './utils.ts'; const helpMessage = renderCliDoc({ usage: 'vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS]', @@ -79,6 +87,9 @@ const helpMessage = renderCliDoc({ '- 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', + `- 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.`, ], }, ], @@ -139,6 +150,10 @@ const helpMessage = renderCliDoc({ ` ${muted('# Use templates from GitHub (via degit)')}`, ` ${accent('vp create github:user/repo')}`, ` ${accent('vp create https://github.com/user/template-repo')}`, + '', + ` ${muted('# Pick from an org that publishes @scope/create with createConfig.templates')}`, + ` ${accent('vp create @your-org')} ${muted('# interactive picker')}`, + ` ${accent('vp create @your-org:web')} ${muted('# direct manifest-entry selection')}`, ], }, ], @@ -383,23 +398,6 @@ async function main() { } // #endregion - // #region Handle required arguments - if (!templateName && !options.interactive) { - console.error(` -A template name is required when running in non-interactive mode - -Usage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS] - -Example: - ${muted('# Create a new application in non-interactive mode with a custom target directory')} - vp create vite:application --no-interactive --directory=apps/my-app - -Use \`vp create --list\` to list all available templates, or run \`vp create --help\` for more information. -`); - process.exit(1); - } - // #endregion - // #region Prepare Stage if (options.interactive) { prompts.intro(vitePlusHeader()); @@ -448,8 +446,53 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h let selectedParentDir: string | undefined; let remoteTargetDir: string | undefined; let shouldSetupHooks = false; + let bundled: Extract | undefined; + let skipShorthandExpansion = false; const installArgs = process.env.CI ? ['--no-frozen-lockfile'] : undefined; + if (!selectedTemplateName) { + const defaultTemplate = await getConfiguredDefaultTemplate(workspaceInfoOptional.rootDir); + if (defaultTemplate) { + selectedTemplateName = defaultTemplate; + } + } + + if (selectedTemplateName) { + const resolved = await resolveOrgManifestForCreate({ + templateName: selectedTemplateName, + isMonorepo, + interactive: options.interactive, + }); + if (resolved.kind === 'replaced') { + selectedTemplateName = resolved.templateName; + // Manifest entries are fully-qualified by their author; prevent + // `expandCreateShorthand` from rewriting `@your-org/template-web` + // into `@your-org/create-template-web`. + skipShorthandExpansion = true; + } else if (resolved.kind === 'bundled') { + bundled = resolved; + } else if (resolved.kind === 'escape-hatch') { + selectedTemplateName = ''; + } + } + + // Guard runs after the arg → `create.defaultTemplate` → @org resolution + // chain so `--no-interactive` works with a configured default. + if (!selectedTemplateName && !options.interactive) { + console.error(` +A template name is required when running in non-interactive mode + +Usage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS] + +Example: + ${muted('# Create a new application in non-interactive mode with a custom target directory')} + vp create vite:application --no-interactive --directory=apps/my-app + +Use \`vp create --list\` to list all available templates, or run \`vp create --help\` for more information. +`); + process.exit(1); + } + if (!selectedTemplateName) { const template = await prompts.select({ message: '', @@ -464,15 +507,21 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h } const isBuiltinTemplate = selectedTemplateName.startsWith('vite:'); + const isBundledTemplate = bundled !== undefined; + const isBundledMonorepo = bundled?.monorepo === true; + const isDirectScaffoldTemplate = isBuiltinTemplate || isBundledTemplate; // Remote templates (e.g., @tanstack/cli, custom templates) run their own // interactive CLI, so verbose mode is needed to show their output. - if (!isBuiltinTemplate) { + if (!isDirectScaffoldTemplate) { compactOutput = false; } - if (targetDir && !isBuiltinTemplate) { - cancelAndExit('The --directory option is only available for builtin templates', 1); + if (targetDir && !isDirectScaffoldTemplate) { + cancelAndExit( + 'The --directory option is only available for builtin and bundled @org templates', + 1, + ); } if (selectedTemplateName === BuiltinTemplate.monorepo && isMonorepo) { prompts.log.info( @@ -586,17 +635,19 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h } } - if (isBuiltinTemplate && (!targetDir || targetDir === '.')) { + const directScaffoldFallbackName = bundled + ? `vite-plus-${bundled.entryName}` + : selectedTemplateName === BuiltinTemplate.monorepo + ? 'vite-plus-monorepo' + : `vite-plus-${selectedTemplateName.split(':')[1]}`; + + if (isDirectScaffoldTemplate && (!targetDir || targetDir === '.')) { if (targetDir === '.') { // Current directory: auto-derive package name from cwd, no prompt - const fallbackName = - selectedTemplateName === BuiltinTemplate.monorepo - ? 'vite-plus-monorepo' - : `vite-plus-${selectedTemplateName.split(':')[1]}`; packageName = deriveDefaultPackageName( cwd, workspaceInfoOptional.monorepoScope, - fallbackName, + directScaffoldFallbackName, ); if (isMonorepo) { if (!cwdRelativeToRoot) { @@ -631,7 +682,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h } else { const defaultPackageName = getRandomProjectName({ scope: workspaceInfoOptional.monorepoScope, - fallbackName: `vite-plus-${selectedTemplateName.split(':')[1]}`, + fallbackName: directScaffoldFallbackName, }); const selected = await promptPackageNameAndTargetDir(defaultPackageName, options.interactive); packageName = selected.packageName; @@ -748,6 +799,8 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h selectedTemplateArgs, workspaceInfo, options.interactive, + bundled?.bundledLocalPath, + skipShorthandExpansion, ); if (selectedParentDir) { @@ -772,15 +825,39 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h // #endregion // #region Handle monorepo template - if (templateInfo.command === BuiltinTemplate.monorepo) { + if (templateInfo.command === BuiltinTemplate.monorepo || isBundledMonorepo) { + // Ask up-front so the prompt isn't buried under scaffold output. + let shouldInitGit = true; + if (options.interactive && !compactOutput) { + pauseCreateProgress(); + const selected = await prompts.confirm({ + message: 'Initialize git repository:', + initialValue: true, + }); + resumeCreateProgress(); + if (prompts.isCancel(selected)) { + prompts.log.info('Operation cancelled. Skipping git initialization'); + shouldInitGit = false; + } else { + shouldInitGit = selected; + } + } else if (!compactOutput) { + prompts.log.info('Initializing git repository (default: yes)'); + } + updateCreateProgress('Creating monorepo'); await checkProjectDirExists(path.join(workspaceInfo.rootDir, targetDir), options.interactive); - const result = await executeMonorepoTemplate( - workspaceInfo, - { ...templateInfo, packageName, targetDir }, - options.interactive, - { silent: compactOutput }, - ); + const result = isBundledMonorepo + ? await executeBundledTemplate(workspaceInfo, { + ...templateInfo, + packageName, + targetDir, + }) + : await executeMonorepoTemplate( + workspaceInfo, + { ...templateInfo, packageName, targetDir }, + { silent: compactOutput }, + ); const { projectDir } = result; if (result.exitCode !== 0 || !projectDir) { failCreateProgress('Scaffolding failed'); @@ -789,6 +866,20 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h // rewrite monorepo to add vite-plus dependencies const fullPath = path.join(workspaceInfo.rootDir, projectDir); + if (shouldInitGit) { + const gitResult = spawn.sync('git', ['init'], { stdio: 'pipe', cwd: fullPath }); + if (gitResult.status === 0) { + if (!compactOutput) { + prompts.log.success('Git repository initialized'); + } + ensureGitignoreNodeModules(fullPath); + } else { + prompts.log.warn('Failed to initialize git repository'); + if (gitResult.stderr) { + prompts.log.info(gitResult.stderr.toString()); + } + } + } updateCreateProgress('Writing agent instructions'); pauseCreateProgress(); await writeAgentInstructions({ @@ -811,6 +902,15 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h workspaceInfo.rootDir = fullPath; updateCreateProgress('Integrating monorepo'); rewriteMonorepo(workspaceInfo, undefined, compactOutput); + if (bundled?.monorepo) { + // Wire `create.defaultTemplate: ''` into the new workspace's + // vite.config.ts so a bare `vp create` from inside it opens the + // same org's picker. Only triggers when the user just scaffolded + // from `vp create @scope:` — for builtin `vite:monorepo`, + // even a scoped package name doesn't imply the user wants that + // scope as their template default. + injectCreateDefaultTemplate(fullPath, bundled.scope, compactOutput); + } if (shouldSetupHooks) { installGitHooks(fullPath, compactOutput); } @@ -836,7 +936,17 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h // #region Handle single project template let result: ExecutionResult; - if (templateInfo.type === TemplateType.builtin) { + if (templateInfo.type === TemplateType.bundled) { + pauseCreateProgress(); + await checkProjectDirExists(path.join(workspaceInfo.rootDir, targetDir), options.interactive); + resumeCreateProgress(); + updateCreateProgress('Copying template files'); + result = await executeBundledTemplate(workspaceInfo, { + ...templateInfo, + packageName, + targetDir, + }); + } else if (templateInfo.type === TemplateType.builtin) { // prompt for package name if not provided if (!targetDir) { const defaultPackageName = getRandomProjectName({ diff --git a/packages/cli/src/create/discovery.ts b/packages/cli/src/create/discovery.ts index cedd99a687..806220a834 100644 --- a/packages/cli/src/create/discovery.ts +++ b/packages/cli/src/create/discovery.ts @@ -46,11 +46,24 @@ export function discoverTemplate( templateArgs: string[], workspaceInfo: WorkspaceInfo, interactive?: boolean, + bundledLocalPath?: string, + skipShorthand?: boolean, ): TemplateInfo { const envs = prependToPathToEnvs(workspaceInfo.downloadPackageManager.binPrefix, { ...process.env, }); const parentDir = inferParentDir(templateName, workspaceInfo); + if (bundledLocalPath) { + return { + command: '', + args: [...templateArgs], + envs, + type: TemplateType.bundled, + parentDir, + interactive, + localPath: bundledLocalPath, + }; + } // Check for built-in templates if (templateName.startsWith('vite:')) { return { @@ -116,7 +129,9 @@ export function discoverTemplate( } } - const expandedName = expandCreateShorthand(templateName); + // Manifest-resolved entries are already fully qualified by the author — + // `@scope/template-web` means that exact package, not `@scope/create-template-web`. + const expandedName = skipShorthand ? templateName : expandCreateShorthand(templateName); return { command: expandedName, args: [...templateArgs], diff --git a/packages/cli/src/create/org-manifest.ts b/packages/cli/src/create/org-manifest.ts new file mode 100644 index 0000000000..8ca5afedd3 --- /dev/null +++ b/packages/cli/src/create/org-manifest.ts @@ -0,0 +1,306 @@ +import path from 'node:path'; + +import { fetchNpmResource, getNpmRegistry } from '../utils/npm-config.ts'; + +/** + * A single entry in an org's template manifest. + */ +export interface OrgTemplateEntry { + name: string; + description: string; + template: string; + monorepo?: boolean; +} + +/** + * The resolved manifest for an `@scope/create` package — the subset of the + * registry response that the create flow actually needs. + */ +export interface OrgManifest { + scope: string; + packageName: string; + version: string; + tarballUrl: string; + integrity?: string; + templates: OrgTemplateEntry[]; +} + +/** + * Parse the org picker specifier: `@scope` (scope only → picker) or + * `@scope:name` (direct manifest-entry selection). Colon mirrors the + * existing `vite:monorepo` / `vite:library` builtin-template syntax and + * keeps manifest entries syntactically distinct from real + * `@scope/package-name` npm specifiers. + * + * Returns `null` for anything else — including the plain `@scope/name` + * form, which routes to the existing `@scope/create-name` shorthand as + * it did before the org-manifest feature. + * + * The optional `version` suffix (`@scope@1.2.3`, `@scope:name@1.2.3`) + * pins `@scope/create` to a specific release rather than `dist-tags.latest`. + */ +export function parseOrgScopedSpec( + spec: string, +): { scope: string; name?: string; version?: string } | null { + if (!spec.startsWith('@')) { + return null; + } + // Reject `@scope/anything` — let that form fall through to the + // pre-feature shorthand path in `expandCreateShorthand`. + if (spec.includes('/')) { + return null; + } + const colonIndex = spec.indexOf(':'); + if (colonIndex === -1) { + // `@scope` or `@scope@version` → scope-only picker. + const atIndex = spec.indexOf('@', 1); + if (atIndex === -1) { + return { scope: spec }; + } + const version = spec.slice(atIndex + 1); + return version ? { scope: spec.slice(0, atIndex), version } : { scope: spec.slice(0, atIndex) }; + } + const scope = spec.slice(0, colonIndex); + const rest = spec.slice(colonIndex + 1); + // `@scope:name@version` — split out the optional version suffix. + const atIndex = rest.indexOf('@'); + const name = atIndex === -1 ? rest : rest.slice(0, atIndex); + const version = atIndex === -1 ? '' : rest.slice(atIndex + 1); + if (!name) { + return version ? { scope, version } : { scope }; + } + return version ? { scope, name, version } : { scope, name }; +} + +/** + * Schema-level failure. Never falls through silently — a maintainer who + * shipped an invalid manifest should see the offending field. + */ +export class OrgManifestSchemaError extends Error { + constructor( + message: string, + public readonly packageName: string, + ) { + super(`${packageName}: ${message}`); + this.name = 'OrgManifestSchemaError'; + } +} + +export function isRelativePath(spec: string): boolean { + return spec.startsWith('./') || spec.startsWith('../'); +} + +function validateEntry(entry: unknown, index: number, packageName: string): OrgTemplateEntry { + if (!entry || typeof entry !== 'object') { + throw new OrgManifestSchemaError( + `createConfig.templates[${index}] must be an object`, + packageName, + ); + } + 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, + ); + } + 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. + if (name.startsWith('__vp_')) { + throw new OrgManifestSchemaError( + `createConfig.templates[${index}].name uses the reserved \`__vp_\` prefix`, + packageName, + ); + } + 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, + ); + } + } + + return { + name, + description, + template, + ...(monorepo !== undefined ? { monorepo } : {}), + }; +} + +function validateManifest(raw: unknown, packageName: string): OrgTemplateEntry[] | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const createConfig = (raw as { createConfig?: unknown }).createConfig; + if (!createConfig || typeof createConfig !== 'object') { + return null; + } + const templates = (createConfig as { templates?: unknown }).templates; + if (templates === undefined) { + return null; + } + if (!Array.isArray(templates)) { + throw new OrgManifestSchemaError('createConfig.templates must be an array', packageName); + } + if (templates.length === 0) { + // 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 entries; +} + +interface RegistryPackument { + name?: string; + 'dist-tags'?: Record; + versions?: Record; +} + +interface RegistryVersionMeta { + version?: string; + createConfig?: unknown; + dist?: { + tarball?: string; + integrity?: string; + }; +} + +async function fetchPackument( + scope: string, + packageName: string, +): Promise { + // npm's registry URLs keep `@` and `/` unencoded + // (`https://registry.npmjs.org/@scope/name`). Match that — private + // registries often route on the literal path. + const url = `${getNpmRegistry(scope)}/${packageName}`; + const response = await fetchNpmResource(url, { + headers: { accept: 'application/json' }, + timeoutMs: 5000, + }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`npm registry responded with ${response.status} for ${packageName}`); + } + return (await response.json()) as RegistryPackument; +} + +/** + * Fetch `@scope/create` from the npm registry and parse its `createConfig.templates` + * manifest. + * + * Returns `null` when: + * - the package does not exist on the registry (404), or + * - the package exists but has no `createConfig.templates` field + * + * Throws when: + * - the `createConfig.templates` field is present but malformed (`OrgManifestSchemaError`), or + * - the registry request fails for any non-404 reason + * + * `requestedVersion` pins the lookup to a specific `versions[...]` entry + * (equivalent to `vp create @scope@1.2.3`); omit it to resolve `dist-tags.latest`. + */ +export async function readOrgManifest( + scope: string, + requestedVersion?: string, +): Promise { + if (!scope.startsWith('@')) { + return null; + } + const packageName = `${scope}/create`; + const packument = await fetchPackument(scope, packageName); + if (!packument) { + return null; + } + let resolvedVersion: string | undefined; + if (requestedVersion) { + resolvedVersion = + packument['dist-tags']?.[requestedVersion] ?? + (packument.versions?.[requestedVersion] ? requestedVersion : undefined); + if (!resolvedVersion) { + throw new OrgManifestSchemaError( + `version "${requestedVersion}" not found (known tags: ${Object.keys(packument['dist-tags'] ?? {}).join(', ') || 'none'})`, + packageName, + ); + } + } else { + resolvedVersion = packument['dist-tags']?.latest; + if (!resolvedVersion) { + return null; + } + } + const meta = packument.versions?.[resolvedVersion]; + if (!meta) { + return null; + } + const templates = validateManifest(meta, packageName); + if (!templates) { + return null; + } + if (!meta.dist?.tarball) { + throw new OrgManifestSchemaError(`missing dist.tarball for ${resolvedVersion}`, packageName); + } + return { + scope, + packageName, + version: resolvedVersion, + tarballUrl: meta.dist.tarball, + integrity: meta.dist.integrity, + templates, + }; +} + +/** + * Apply the in-monorepo filter rule from the RFC: entries with + * `monorepo: true` are hidden when the command is invoked inside an + * existing monorepo, mirroring `initial-template-options.ts:9-31`. + */ +export function filterManifestForContext( + templates: readonly OrgTemplateEntry[], + isMonorepo: boolean, +): OrgTemplateEntry[] { + if (!isMonorepo) { + return [...templates]; + } + return templates.filter((entry) => !entry.monorepo); +} diff --git a/packages/cli/src/create/org-picker.ts b/packages/cli/src/create/org-picker.ts new file mode 100644 index 0000000000..5976d7b98d --- /dev/null +++ b/packages/cli/src/create/org-picker.ts @@ -0,0 +1,105 @@ +import { randomUUID } from 'node:crypto'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; + +import { + filterManifestForContext, + type OrgManifest, + type OrgTemplateEntry, +} from './org-manifest.ts'; + +export const ORG_PICKER_CANCEL = Symbol('org-picker-cancel'); +export const ORG_PICKER_BUILTIN_ESCAPE = Symbol('org-picker-builtin-escape'); + +export type OrgPickerResult = + | { kind: 'entry'; entry: OrgTemplateEntry } + | typeof ORG_PICKER_CANCEL + | typeof ORG_PICKER_BUILTIN_ESCAPE; + +const ESCAPE_HATCH = Symbol('builtin-escape'); + +/** + * Render the interactive picker for an org manifest. Always appends a + * trailing "Vite+ built-in templates" escape-hatch entry. + * + * Context-filters entries with `monorepo: true` when running inside an + * existing monorepo, mirroring `initial-template-options.ts:9-31`. + * + * Returns `ORG_PICKER_BUILTIN_ESCAPE` when the escape hatch is selected, + * or `ORG_PICKER_CANCEL` when the user hits Ctrl-C. + */ +export async function pickOrgTemplate( + manifest: OrgManifest, + opts: { isMonorepo: boolean }, +): Promise { + const filtered = filterManifestForContext(manifest.templates, opts.isMonorepo); + if (filtered.length === 0) { + // Caller surfaces the context-specific reason before falling through. + return ORG_PICKER_BUILTIN_ESCAPE; + } + + // Per-invocation nonce — guarantees the escape hatch's `value` can't + // collide with any user-provided manifest entry name no matter what + // they chose. + const escapeValue = `__vp_builtin_escape__::${randomUUID()}`; + const lookup = new Map(); + const options: { value: string; label: string; hint?: string }[] = filtered.map((entry) => { + lookup.set(entry.name, entry); + return { value: entry.name, label: entry.name, hint: entry.description }; + }); + lookup.set(escapeValue, ESCAPE_HATCH); + // Mirror `getInitialTemplateOptions(isMonorepo)`: `monorepo` is hidden + // inside an existing monorepo (and would be rejected at scaffold time + // anyway); `generator` isn't part of the builtin picker at all. + const builtinHint = opts.isMonorepo + ? 'Use defaults (application / library)' + : 'Use defaults (monorepo / application / library)'; + options.push({ value: escapeValue, label: 'Vite+ built-in templates', hint: builtinHint }); + + const picked = await prompts.select({ + message: `Pick a template from ${manifest.scope}`, + options, + }); + + if (prompts.isCancel(picked)) { + return ORG_PICKER_CANCEL; + } + const found = lookup.get(picked); + if (found === ESCAPE_HATCH) { + return ORG_PICKER_BUILTIN_ESCAPE; + } + if (!found) { + // Unreachable: every option's `value` was just registered in `lookup` + // a few lines above. Throw rather than masquerade as a cancel — a + // missing entry would mean a real internal bug. + throw new Error(`org-picker: prompts.select returned an unregistered value: ${picked}`); + } + return { kind: 'entry', entry: found }; +} + +/** + * Render the manifest as a plain-text table for the `--no-interactive` + * error output. Fixed column order so AI agents and scripts can recover + * available template names without a `--json` flag. + */ +export function formatManifestTable( + manifest: OrgManifest, + isMonorepo: boolean, +): { lines: string[]; filteredCount: number } { + const visible = filterManifestForContext(manifest.templates, isMonorepo); + const filteredCount = manifest.templates.length - visible.length; + + const nameWidth = Math.max('NAME'.length, ...visible.map((entry) => entry.name.length)); + const descWidth = Math.max( + 'DESCRIPTION'.length, + ...visible.map((entry) => entry.description.length), + ); + const lines: string[] = []; + lines.push(` ${'NAME'.padEnd(nameWidth)} ${'DESCRIPTION'.padEnd(descWidth)} TEMPLATE`); + for (const entry of visible) { + lines.push( + ` ${entry.name.padEnd(nameWidth)} ${entry.description.padEnd(descWidth)} ${entry.template}`, + ); + } + return { lines, filteredCount }; +} diff --git a/packages/cli/src/create/org-resolve.ts b/packages/cli/src/create/org-resolve.ts new file mode 100644 index 0000000000..5a326104fc --- /dev/null +++ b/packages/cli/src/create/org-resolve.ts @@ -0,0 +1,229 @@ +import * as prompts from '@voidzero-dev/vite-plus-prompts'; + +import { findWorkspaceRoot, hasViteConfig, resolveViteConfig } from '../resolve-vite-config.ts'; +import { + filterManifestForContext, + isRelativePath, + OrgManifestSchemaError, + parseOrgScopedSpec, + readOrgManifest, + type OrgManifest, + type OrgTemplateEntry, +} from './org-manifest.ts'; +import { + formatManifestTable, + ORG_PICKER_BUILTIN_ESCAPE, + ORG_PICKER_CANCEL, + pickOrgTemplate, +} from './org-picker.ts'; +import { ensureOrgPackageExtracted, resolveBundledPath } from './org-tarball.ts'; +import { cancelAndExit } from './prompts.ts'; + +/** + * Resolution outcome for an org template spec. + * + * - `passthrough`: no manifest applied; caller keeps the original spec. + * - `replaced`: manifest entry resolves to a non-bundled specifier (npm, + * github, vite:*, local). Caller uses `templateName`. + * - `bundled`: manifest entry uses a relative path; tarball has been + * extracted; caller passes `bundledLocalPath` into `discoverTemplate`. + * `scope` carries the resolved `@org` so a `monorepo: true` scaffold + * can wire `create.defaultTemplate` back to that same org. `monorepo` + * flows the caller into the monorepo scaffold path (parent-dir prompt + * + `rewriteMonorepo` integration). + * - `escape-hatch`: user picked "Vite+ built-in templates" from the picker. + */ +export type OrgResolution = + | { kind: 'passthrough' } + | { kind: 'replaced'; templateName: string } + | { + kind: 'bundled'; + bundledLocalPath: string; + entryName: string; + scope: string; + monorepo?: true; + } + | { kind: 'escape-hatch' }; + +function printNonInteractiveTable( + manifest: OrgManifest, + orgSpec: { scope: string }, + isMonorepo: boolean, +): void { + const { lines, filteredCount } = formatManifestTable(manifest, isMonorepo); + const [firstVisible] = filterManifestForContext(manifest.templates, isMonorepo); + const body: string[] = [ + '', + `A template name is required when running \`vp create ${orgSpec.scope}\` in non-interactive mode.`, + '', + `Available templates in ${manifest.packageName}:`, + '', + ...lines, + ]; + if (filteredCount > 0) { + body.push( + '', + `(omitted ${filteredCount} monorepo-only ${ + filteredCount === 1 ? 'entry' : 'entries' + } because this workspace is already a monorepo)`, + ); + } + body.push('', 'Examples:'); + if (firstVisible) { + body.push( + ' # Scaffold a specific template from the org', + ` vp create ${orgSpec.scope}:${firstVisible.name} --no-interactive`, + '', + ); + } + body.push( + ' # Or use a Vite+ built-in template', + ' vp create vite:application --no-interactive', + '', + ); + process.stderr.write(body.join('\n')); +} + +function rejectMonorepoEntryInsideMonorepo(entry: OrgTemplateEntry, isMonorepo: boolean): void { + if (entry.monorepo && isMonorepo) { + prompts.log.info( + 'You are already in a monorepo workspace.\nUse a different template or run this command outside the monorepo', + ); + cancelAndExit('Cannot create a monorepo inside an existing monorepo', 1); + } +} + +async function resolveEntry( + manifest: OrgManifest, + entry: OrgTemplateEntry, +): Promise { + if (isRelativePath(entry.template)) { + const extracted = await ensureOrgPackageExtracted(manifest); + const bundledLocalPath = resolveBundledPath(extracted, entry.template); + return { + kind: 'bundled', + bundledLocalPath, + entryName: entry.name, + scope: manifest.scope, + ...(entry.monorepo === true ? { monorepo: true as const } : {}), + }; + } + return { kind: 'replaced', templateName: entry.template }; +} + +/** + * If `selectedTemplateName` points at an `@scope[/name]` org whose + * `@scope/create` package publishes a `createConfig.templates` manifest, apply the + * manifest rules (picker / direct lookup / escape hatch / bundled + * extraction) and report the outcome. + * + * The caller — `packages/cli/src/create/bin.ts` — decides what to do next + * based on the returned variant. + */ +export async function resolveOrgManifestForCreate(args: { + templateName: string; + isMonorepo: boolean; + interactive: boolean; +}): Promise { + const orgSpec = parseOrgScopedSpec(args.templateName); + if (!orgSpec) { + return { kind: 'passthrough' }; + } + + // Never silently skip the picker when the user explicitly typed `@org`. + let manifest: OrgManifest | null; + try { + manifest = await readOrgManifest(orgSpec.scope, orgSpec.version); + } catch (error) { + const message = + error instanceof OrgManifestSchemaError + ? error.message + : `Failed to read ${orgSpec.scope}/create manifest: ${(error as Error).message}`; + cancelAndExit(message, 1); + } + + if (!manifest) { + if (orgSpec.name !== undefined) { + // `@org:name` is an explicit manifest lookup; no manifest → hard error. + cancelAndExit( + `No \`createConfig.templates\` manifest in ${orgSpec.scope}/create — \`@org:name\` requires one.`, + 1, + ); + } + // Scope-only input (`vp create @org`) strongly implies the user + // expected the picker. Be explicit about why it didn't engage, so a + // later `ERR_NO_BIN` from the package manager doesn't look mysterious. + prompts.log.info( + `No \`createConfig.templates\` manifest in ${orgSpec.scope}/create — running it as a normal package.`, + ); + return { kind: 'passthrough' }; + } + + if (orgSpec.name === undefined) { + if (!args.interactive) { + printNonInteractiveTable(manifest, orgSpec, args.isMonorepo); + process.exit(1); + } + const picked = await pickOrgTemplate(manifest, { isMonorepo: args.isMonorepo }); + if (picked === ORG_PICKER_CANCEL) { + cancelAndExit(); + } + if (picked === ORG_PICKER_BUILTIN_ESCAPE) { + // Only the in-monorepo filter can empty the list today; the message + // stays in sync if more context-specific filters are added here. + if (args.isMonorepo && manifest.templates.every((t) => t.monorepo)) { + prompts.log.info( + `No templates from ${manifest.packageName} are applicable inside a monorepo — showing Vite+ built-in templates instead.`, + ); + } + return { kind: 'escape-hatch' }; + } + rejectMonorepoEntryInsideMonorepo(picked.entry, args.isMonorepo); + return resolveEntry(manifest, picked.entry); + } + + const entry = manifest.templates.find((candidate) => candidate.name === orgSpec.name); + if (!entry) { + // `@scope:name` is an explicit manifest lookup — no ambiguous fall-through. + const available = filterManifestForContext(manifest.templates, args.isMonorepo) + .map((t) => t.name) + .join(', '); + cancelAndExit( + `No template named "${orgSpec.name}" in ${manifest.packageName}. Available: ${available || '(none applicable in this context)'}.`, + 1, + ); + } + rejectMonorepoEntryInsideMonorepo(entry, args.isMonorepo); + return resolveEntry(manifest, entry); +} + +/** + * Read `create.defaultTemplate` from the workspace root's `vite.config.ts`. + * + * 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. + * + * Best-effort: if there's no config file or evaluation fails, return + * `undefined` so the create flow behaves as if no default was set. + */ +export async function getConfiguredDefaultTemplate(startDir: string): Promise { + const projectRoot = findWorkspaceRoot(startDir) ?? startDir; + if (!hasViteConfig(projectRoot)) { + return undefined; + } + try { + const config = (await resolveViteConfig(projectRoot)) as { + create?: { defaultTemplate?: unknown }; + }; + const value = config.create?.defaultTemplate; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } catch { + // Unresolvable config → treat as no default. + } + return undefined; +} diff --git a/packages/cli/src/create/org-tarball.ts b/packages/cli/src/create/org-tarball.ts new file mode 100644 index 0000000000..a5f218b1a3 --- /dev/null +++ b/packages/cli/src/create/org-tarball.ts @@ -0,0 +1,298 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { parseTarGzip } from 'nanotar'; + +import { fetchNpmResource } from '../utils/npm-config.ts'; +import type { OrgManifest } from './org-manifest.ts'; + +function getCacheRoot(): string { + const home = process.env.VP_HOME || path.join(os.homedir(), '.vite-plus'); + return path.join(home, 'tmp', 'create-org'); +} + +/** + * Replace characters that are illegal in Windows path segments + * (`\ / : * ? " < > |` plus the IPv6 bracket pair `[ ]`). The host + * comes from `new URL(...).host` which can carry a port (`:4873`) or + * IPv6 literal (`[::1]`); both end up in the cache path otherwise. + */ +export function sanitizeHostForPath(host: string): string { + return host.replaceAll(/[\\/:*?"<>|[\]]/g, '_'); +} + +/** + * Cache extracted tarballs under `//create/` so two + * repos resolving the same `@` through different registries + * (via `.npmrc` scope mappings) don't share a cache slot. The registry + * guarantees `manifest.tarballUrl` is a valid URL, so any parse failure + * here is a real bug worth surfacing. + */ +function getExtractionDir(manifest: OrgManifest): string { + const { host } = new URL(manifest.tarballUrl); + return path.join( + getCacheRoot(), + sanitizeHostForPath(host), + manifest.scope, + 'create', + manifest.version, + ); +} + +function parseIntegrity(integrity: string): { algorithm: string; expected: string } | null { + // Subresource Integrity format: `sha512-` (optionally comma-separated alternatives). + const first = integrity.split(/\s+/)[0]; + const match = first.match(/^(sha\d+)-(.+)$/); + if (!match) { + return null; + } + return { algorithm: match[1], expected: match[2] }; +} + +function verifyIntegrity(bytes: Uint8Array, integrity: string | undefined): void { + if (!integrity) { + return; + } + const parsed = parseIntegrity(integrity); + if (!parsed) { + // Unknown integrity format — skip verification. Registry responses + // normally advertise `sha512-`; anything else is unusual + // enough that we'd rather let the extract continue than fail hard + // on a format we don't understand. + return; + } + const hash = createHash(parsed.algorithm); + hash.update(bytes); + const actual = hash.digest('base64'); + if (actual !== parsed.expected) { + throw new Error( + `integrity check failed: expected ${integrity}, got ${parsed.algorithm}-${actual}`, + ); + } +} + +const MAX_TARBALL_BYTES = 50 * 1024 * 1024; + +async function downloadTarball(url: string): Promise { + const response = await fetchNpmResource(url, { timeoutMs: 30_000 }); + if (!response.ok) { + throw new Error(`failed to download tarball (${response.status}): ${url}`); + } + // Cheap pre-check when the server reports Content-Length; streaming loop + // below is authoritative for servers that omit the header. + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_TARBALL_BYTES) { + throw new Error(`tarball exceeds ${MAX_TARBALL_BYTES} byte size limit: ${url}`); + } + // Stream the body so a 1 GB response is rejected before it's fully + // buffered. Real-world create-* packages are tens of KB, so the cap is + // only ever a safety net for malicious or misconfigured publishers. + const reader = response.body?.getReader(); + if (!reader) { + throw new Error(`tarball response has no body: ${url}`); + } + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.byteLength; + if (total > MAX_TARBALL_BYTES) { + await reader.cancel(); + throw new Error(`tarball exceeds ${MAX_TARBALL_BYTES} byte size limit: ${url}`); + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +const STAGING_SUFFIX_PREFIX = '.tmp-'; + +/** + * Parse a tar entry's stored mode (always octal) into the numeric + * permission bits (low 9 bits — `rwxrwxrwx`). Returns `undefined` when + * the mode is missing or unparsable so the caller leaves the file with + * its default (umask-derived) permissions instead of downgrading. + */ +export function parseEntryMode(raw: string | undefined): number | undefined { + if (!raw) { + return undefined; + } + const parsed = Number.parseInt(raw, 8); + if (!Number.isFinite(parsed)) { + return undefined; + } + return parsed & 0o777; +} + +/** + * Strip the `package/` prefix from an `npm pack` tarball entry. Returns + * `null` for entries to skip (root dir, PaxHeader, anything outside + * `package/`). + */ +export function normalizeEntryName(rawName: string): string | null { + const name = rawName.replace(/^\.\//, '').replace(/\\/g, '/'); + if (!name || name === 'package' || name === 'package/') { + return null; + } + if (name.startsWith('PaxHeader/') || name.includes('/PaxHeader/')) { + return null; + } + if (!name.startsWith('package/')) { + return null; + } + return name.slice('package/'.length); +} + +async function extractTarballTo(bytes: Uint8Array, destDir: string): Promise { + const entries = await parseTarGzip(bytes); + // Extract into a staging directory first so partial failures don't leave + // a half-populated final cache path that future runs would skip. + const stagingDir = `${destDir}${STAGING_SUFFIX_PREFIX}${process.pid}-${Date.now()}`; + await fs.promises.mkdir(stagingDir, { recursive: true }); + const resolvedStaging = path.resolve(stagingDir); + try { + for (const entry of entries) { + const relativeName = normalizeEntryName(entry.name); + if (relativeName === null) { + continue; + } + const targetPath = path.join(stagingDir, relativeName); + // Defense-in-depth: make sure the resolved path is still inside the + // staging directory (no `..` escapes via crafted tar entries). + const resolvedTarget = path.resolve(targetPath); + if ( + resolvedTarget !== resolvedStaging && + !resolvedTarget.startsWith(`${resolvedStaging}${path.sep}`) + ) { + throw new Error(`tarball entry escapes extraction root: ${entry.name}`); + } + if (entry.type === 'directory' || relativeName.endsWith('/')) { + await fs.promises.mkdir(targetPath, { recursive: true }); + continue; + } + await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); + const data = entry.data ?? new Uint8Array(0); + await fs.promises.writeFile(targetPath, data); + // Preserve the tar entry's mode so bundled templates can ship + // executable files (e.g. `gradlew`, `mvnw`, `scripts/*.sh`). Mask + // to the permission bits only — setuid/setgid/sticky have no + // business in a scaffolded project template. + const mode = parseEntryMode(entry.attrs?.mode); + if (mode !== undefined) { + await fs.promises.chmod(targetPath, mode); + } + } + try { + await fs.promises.rename(stagingDir, destDir); + } catch (error) { + // `rename` reports ENOTEMPTY/EEXIST when a concurrent extractor + // already populated `destDir`. Confirm that's actually what happened + // (rather than permissions / read-only FS masquerading as a race) + // before swallowing the error and treating our work as redundant. + const code = (error as NodeJS.ErrnoException).code; + if ( + (code === 'ENOTEMPTY' || code === 'EEXIST') && + fs.existsSync(path.join(destDir, 'package.json')) + ) { + await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => {}); + return; + } + throw error; + } + } catch (error) { + await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => {}); + throw error; + } +} + +const STAGING_STALE_MS = 24 * 60 * 60 * 1000; + +/** + * Remove `.tmp-*` siblings left behind by a previous crash so + * repeated aborts don't accumulate orphaned staging trees. Only deletes + * entries whose mtime is older than 24 hours — a concurrent `vp create` + * that's still actively extracting will always be younger than that, so + * the age gate keeps this safe to run at the top of every extract. + */ +export async function cleanupStaleStagingDirs(destDir: string): Promise { + const parent = path.dirname(destDir); + const basename = path.basename(destDir); + const prefix = `${basename}${STAGING_SUFFIX_PREFIX}`; + let entries: string[]; + try { + entries = await fs.promises.readdir(parent); + } catch { + return; + } + const cutoff = Date.now() - STAGING_STALE_MS; + await Promise.all( + entries + .filter((name) => name.startsWith(prefix)) + .map(async (name) => { + const fullPath = path.join(parent, name); + try { + const stats = await fs.promises.stat(fullPath); + if (stats.mtimeMs < cutoff) { + await fs.promises.rm(fullPath, { recursive: true, force: true }); + } + } catch { + // Entry vanished between readdir and stat/rm — nothing to do. + } + }), + ); +} + +/** + * Ensure the `@org/create` package tarball for the given manifest has been + * downloaded and extracted locally. Returns the absolute path to the + * extracted package root (i.e. the directory that contains + * `package.json`). + * + * Idempotent: subsequent calls for the same `` reuse the + * cached extraction. Concurrent calls race on the final rename; the loser + * cleans up and returns the existing directory. + */ +export async function ensureOrgPackageExtracted(manifest: OrgManifest): Promise { + const extractedRoot = getExtractionDir(manifest); + if (fs.existsSync(path.join(extractedRoot, 'package.json'))) { + return extractedRoot; + } + const parent = path.dirname(extractedRoot); + await fs.promises.mkdir(parent, { recursive: true }); + await cleanupStaleStagingDirs(extractedRoot); + const bytes = await downloadTarball(manifest.tarballUrl); + verifyIntegrity(bytes, manifest.integrity); + await extractTarballTo(bytes, extractedRoot); + return extractedRoot; +} + +/** + * Resolve a manifest entry's relative `./...` path against an already- + * extracted package root, rejecting any path that escapes the root (via + * `..` walks or an absolute specifier). + * + * Existence is NOT checked here — the subsequent `copyDir` surfaces any + * missing-directory error with a clearer errno. + */ +export function resolveBundledPath(extractedRoot: string, relativePath: string): string { + if (path.isAbsolute(relativePath)) { + throw new Error(`bundled template path must be relative, got ${relativePath}`); + } + const resolvedRoot = path.resolve(extractedRoot); + const resolvedTarget = path.resolve(extractedRoot, relativePath); + if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error(`bundled template path escapes the package root: ${relativePath}`); + } + return resolvedTarget; +} diff --git a/packages/cli/src/create/templates/bundled.ts b/packages/cli/src/create/templates/bundled.ts new file mode 100644 index 0000000000..03e3541878 --- /dev/null +++ b/packages/cli/src/create/templates/bundled.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert'; +import path from 'node:path'; + +import type { WorkspaceInfo } from '../../types/index.ts'; +import type { ExecutionResult } from '../command.ts'; +import { copyDir, setPackageName } from '../utils.ts'; +import type { BuiltinTemplateInfo } from './types.ts'; + +/** + * Scaffold a bundled template by copying the pre-extracted directory at + * `localPath` into `workspaceInfo.rootDir/targetDir`. + */ +export async function executeBundledTemplate( + workspaceInfo: WorkspaceInfo, + templateInfo: BuiltinTemplateInfo, +): Promise { + assert(templateInfo.localPath, 'localPath is required for bundled templates'); + assert(templateInfo.targetDir, 'targetDir is required'); + assert(templateInfo.packageName, 'packageName is required'); + + const destDir = path.join(workspaceInfo.rootDir, templateInfo.targetDir); + try { + copyDir(templateInfo.localPath, destDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`bundled template directory not found: ${templateInfo.localPath}`, { + cause: error, + }); + } + throw error; + } + + try { + setPackageName(destDir, templateInfo.packageName); + } catch { + // Template without a valid package.json — leave files as-is. + } + + return { exitCode: 0, projectDir: templateInfo.targetDir }; +} diff --git a/packages/cli/src/create/templates/index.ts b/packages/cli/src/create/templates/index.ts index 0947fa2a97..06539e4531 100644 --- a/packages/cli/src/create/templates/index.ts +++ b/packages/cli/src/create/templates/index.ts @@ -1,3 +1,4 @@ export * from './builtin.ts'; +export * from './bundled.ts'; export * from './monorepo.ts'; export * from './remote.ts'; diff --git a/packages/cli/src/create/templates/monorepo.ts b/packages/cli/src/create/templates/monorepo.ts index 75e5ca51c7..edc57a290f 100644 --- a/packages/cli/src/create/templates/monorepo.ts +++ b/packages/cli/src/create/templates/monorepo.ts @@ -3,7 +3,6 @@ import fs from 'node:fs'; import path from 'node:path'; import * as prompts from '@voidzero-dev/vite-plus-prompts'; -import spawn from 'cross-spawn'; import { rewriteMonorepoProject } from '../../migration/migrator.ts'; import { PackageManager, type WorkspaceInfo } from '../../types/index.ts'; @@ -21,7 +20,6 @@ export const InitialMonorepoAppDir = 'apps/website'; export async function executeMonorepoTemplate( workspaceInfo: WorkspaceInfo, templateInfo: BuiltinTemplateInfo, - interactive: boolean, options?: { silent?: boolean }, ): Promise { assert(templateInfo.packageName, 'packageName is required'); @@ -30,25 +28,6 @@ export async function executeMonorepoTemplate( workspaceInfo.monorepoScope = getScopeFromPackageName(templateInfo.packageName); const fullPath = path.join(workspaceInfo.rootDir, templateInfo.targetDir); - // Ask user to init git repository before creation starts. - let initGit = true; // Default to yes - if (interactive && !options?.silent) { - const selected = await prompts.confirm({ - message: `Initialize git repository:`, - initialValue: true, - }); - if (prompts.isCancel(selected)) { - prompts.log.info('Operation cancelled. Skipping git initialization'); - initGit = false; - } else { - initGit = selected; - } - } else { - if (!options?.silent) { - prompts.log.info(`Initializing git repository (default: yes)`); - } - } - if (!options?.silent) { prompts.log.info(`Target directory: ${formatDisplayTargetDir(templateInfo.targetDir)}`); prompts.log.step('Creating Vite+ monorepo...'); @@ -109,24 +88,6 @@ export async function executeMonorepoTemplate( prompts.log.success('Monorepo template created'); } - if (initGit) { - const gitResult = spawn.sync('git', ['init'], { - stdio: 'pipe', - cwd: fullPath, - }); - - if (gitResult.status === 0) { - if (!options?.silent) { - prompts.log.success('Git repository initialized'); - } - } else { - prompts.log.warn('Failed to initialize git repository'); - if (gitResult.stderr) { - prompts.log.info(gitResult.stderr.toString()); - } - } - } - // Automatically create a default application in apps/website if (!options?.silent) { prompts.log.step('Creating default application in apps/website...'); diff --git a/packages/cli/src/create/templates/types.ts b/packages/cli/src/create/templates/types.ts index f829788fa9..ab03773549 100644 --- a/packages/cli/src/create/templates/types.ts +++ b/packages/cli/src/create/templates/types.ts @@ -12,6 +12,7 @@ export const TemplateType = { builtin: 'builtin', bingo: 'bingo', remote: 'remote', + bundled: 'bundled', } as const; export type TemplateType = (typeof TemplateType)[keyof typeof TemplateType]; @@ -24,6 +25,9 @@ export interface TemplateInfo { // For example, "packages" parentDir?: string; interactive?: boolean; + // Absolute path to an extracted template directory. Only set for + // `TemplateType.bundled` entries sourced from a manifest's relative path. + localPath?: string; } export interface BuiltinTemplateInfo extends Omit { diff --git a/packages/cli/src/create/utils.ts b/packages/cli/src/create/utils.ts index 6a6850627a..0f6fcea105 100644 --- a/packages/cli/src/create/utils.ts +++ b/packages/cli/src/create/utils.ts @@ -112,6 +112,29 @@ export function setPackageName(projectDir: string, packageName: string) { }); } +/** + * Make sure the scaffolded project's `.gitignore` excludes `node_modules`. + * + * Called right after `git init` so even bundled `@org` templates (which + * may ship without a `.gitignore`) don't end up tracking installed + * dependencies on the user's first commit. No-op when an existing + * `.gitignore` already lists `node_modules`. + */ +export function ensureGitignoreNodeModules(projectDir: string): void { + const gitignorePath = path.join(projectDir, '.gitignore'); + let content = ''; + try { + content = fs.readFileSync(gitignorePath, 'utf-8'); + } catch { + // No existing .gitignore — we'll write a fresh one below. + } + if (/^\s*node_modules\/?\s*$/m.test(content)) { + return; + } + const prefix = content === '' || content.endsWith('\n') ? '' : '\n'; + fs.appendFileSync(gitignorePath, `${prefix}node_modules\n`); +} + export function formatDisplayTargetDir(targetDir: string) { const normalized = targetDir.split(path.sep).join('/'); if (normalized === '' || normalized === '.') { diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index a55d9fbe7e..df4f333c36 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -24,6 +24,21 @@ declare module '@voidzero-dev/vite-plus-core' { run?: RunConfig; staged?: StagedConfig; + + /** + * Options for `vp create`. + * + * See `rfcs/create-org-default-templates.md` for the full specification. + */ + create?: { + /** + * 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. + */ + defaultTemplate?: string; + }; } } diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 729064a217..4ab0cf8692 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -25,6 +25,7 @@ const { detectFramework, hasFrameworkShim, addFrameworkShim, + injectCreateDefaultTemplate, } = await import('../migrator.js'); describe('rewritePackageJson', () => { @@ -833,4 +834,47 @@ describe('framework shim', () => { expect(content).toContain('/// '); }); }); + + describe('injectCreateDefaultTemplate', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-migrator-create-default-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeViteConfig(body: string): void { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus';\n\nexport default defineConfig(${body});\n`, + ); + } + + it('injects `create.defaultTemplate` when scope is set and no `create:` exists', () => { + writeViteConfig('{ run: { cache: true } }'); + injectCreateDefaultTemplate(tmpDir, '@your-org', true); + const content = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(content).toContain('create:'); + expect(content).toContain('"defaultTemplate":"@your-org"'); + }); + + it('skips injection when scope is empty (no scope to default to)', () => { + writeViteConfig('{ run: { cache: true } }'); + injectCreateDefaultTemplate(tmpDir, '', true); + const content = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(content).not.toContain('create:'); + expect(content).not.toContain('defaultTemplate'); + }); + + it('preserves an existing `create:` block instead of overwriting it', () => { + writeViteConfig("{ create: { defaultTemplate: '@other' }, run: { cache: true } }"); + injectCreateDefaultTemplate(tmpDir, '@your-org', true); + const content = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(content).toContain("'@other'"); + expect(content).not.toContain('@your-org'); + }); + }); }); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index be37e0442d..da6acb907f 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1742,6 +1742,33 @@ export function injectFmtDefaults( ); } +/** + * Wire `create.defaultTemplate: ''` into the new monorepo's + * `vite.config.ts`. The caller is `bin.ts`, only when scaffolding a + * monorepo from a bundled `@org` manifest entry — that's the case where + * the user just picked a template from a specific org and naturally + * wants subsequent `vp create` invocations from the workspace to default + * to that same org's picker. + */ +export function injectCreateDefaultTemplate( + projectPath: string, + scope: string, + silent = false, + report?: MigrationReport, +): void { + if (!scope) { + return; + } + injectConfigDefaults( + projectPath, + 'create', + '.vite-plus-create-init.json', + JSON.stringify({ defaultTemplate: scope }), + silent, + report, + ); +} + function injectConfigDefaults( projectPath: string, configKey: string, diff --git a/packages/cli/src/resolve-vite-config.ts b/packages/cli/src/resolve-vite-config.ts index 085a77529e..77c0e381a5 100644 --- a/packages/cli/src/resolve-vite-config.ts +++ b/packages/cli/src/resolve-vite-config.ts @@ -34,7 +34,7 @@ export function findViteConfigUp(startDir: string, stopDir: string): string | un return undefined; } -function hasViteConfig(dir: string): boolean { +export function hasViteConfig(dir: string): boolean { return VITE_CONFIG_FILES.some((f) => fs.existsSync(path.join(dir, f))); } @@ -42,7 +42,7 @@ function hasViteConfig(dir: string): boolean { * Find the workspace root by walking up from `startDir` looking for * monorepo indicators (pnpm-workspace.yaml, workspaces in package.json, lerna.json). */ -function findWorkspaceRoot(startDir: string): string | undefined { +export function findWorkspaceRoot(startDir: string): string | undefined { let dir = path.resolve(startDir); while (true) { if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { diff --git a/packages/cli/src/utils/__tests__/npm-config.spec.ts b/packages/cli/src/utils/__tests__/npm-config.spec.ts new file mode 100644 index 0000000000..18e96b69eb --- /dev/null +++ b/packages/cli/src/utils/__tests__/npm-config.spec.ts @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getNpmAuthHeader, getNpmRegistry } from '../npm-config.js'; + +describe('getNpmRegistry / getNpmAuthHeader', () => { + let homeDir: string; + let projectDir: string; + let originalEnv: Record; + + beforeEach(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-npm-home-')); + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-npm-proj-')); + originalEnv = {}; + for (const key of Object.keys(process.env)) { + if (key.toLowerCase().startsWith('npm_config_')) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } + } + originalEnv.HOME = process.env.HOME; + process.env.HOME = homeDir; + vi.spyOn(os, 'homedir').mockReturnValue(homeDir); + vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(homeDir, { recursive: true, force: true }); + fs.rmSync(projectDir, { recursive: true, force: true }); + for (const [k, v] of Object.entries(originalEnv)) { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + } + }); + + it('falls back to the public registry when nothing is configured', () => { + expect(getNpmRegistry()).toBe('https://registry.npmjs.org'); + }); + + it('reads project-level `.npmrc` when it exists', () => { + fs.writeFileSync(path.join(projectDir, '.npmrc'), 'registry=https://proj.example.com/\n'); + expect(getNpmRegistry()).toBe('https://proj.example.com'); + }); + + it('gives project `.npmrc` precedence over user `.npmrc`', () => { + fs.writeFileSync(path.join(homeDir, '.npmrc'), 'registry=https://user.example.com/\n'); + fs.writeFileSync(path.join(projectDir, '.npmrc'), 'registry=https://proj.example.com/\n'); + expect(getNpmRegistry()).toBe('https://proj.example.com'); + }); + + it('uses the user `.npmrc` when the project has none', () => { + fs.writeFileSync(path.join(homeDir, '.npmrc'), 'registry=https://user.example.com/\n'); + expect(getNpmRegistry()).toBe('https://user.example.com'); + }); + + it('resolves `@scope:registry=` overrides ahead of the default', () => { + fs.writeFileSync( + path.join(projectDir, '.npmrc'), + [ + 'registry=https://default.example.com/', + '@your-org:registry=https://scoped.example.com/', + ].join('\n') + '\n', + ); + expect(getNpmRegistry('@your-org')).toBe('https://scoped.example.com'); + expect(getNpmRegistry('@other')).toBe('https://default.example.com'); + }); + + it('extracts `_authToken` credentials for a matching host', () => { + fs.writeFileSync(path.join(projectDir, '.npmrc'), '//private.example.com/:_authToken=SECRET\n'); + expect(getNpmAuthHeader('https://private.example.com/some/path')).toBe('Bearer SECRET'); + }); + + it('returns undefined when no credential matches the URL host', () => { + fs.writeFileSync(path.join(projectDir, '.npmrc'), '//private.example.com/:_authToken=SECRET\n'); + expect(getNpmAuthHeader('https://other.example.com/pkg')).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/npm-config.ts b/packages/cli/src/utils/npm-config.ts new file mode 100644 index 0000000000..9c0e15e99b --- /dev/null +++ b/packages/cli/src/utils/npm-config.ts @@ -0,0 +1,191 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +type NpmConfig = Map; + +function expandNpmrcValue(raw: string): string { + // Strip surrounding quotes and expand `${VAR}` references. Covers only + // the value shapes used by the keys we actually read (registry / + // @scope:registry / :_authToken / :_auth / :username / :_password). + // Intentionally NOT handled: `\$` backslash escapes, `${VAR-default}` + // fallbacks, inline `;` comments after a value, and `key[]=` list + // syntax. Extend when a caller needs any of those. + let value = raw.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + return value.replaceAll(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => process.env[name] ?? ''); +} + +function parseNpmrc(contents: string, into: NpmConfig): void { + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || line.startsWith(';')) { + continue; + } + const eq = line.indexOf('='); + if (eq === -1) { + continue; + } + const key = line.slice(0, eq).trim(); + const value = expandNpmrcValue(line.slice(eq + 1)); + if (key) { + into.set(key, value); + } + } +} + +function loadFileInto(filePath: string, config: NpmConfig): void { + try { + parseNpmrc(fs.readFileSync(filePath, 'utf8'), config); + } catch { + // Missing / unreadable .npmrc is fine — nothing to layer in. + } +} + +/** + * Rebuilt on every call so tests that mutate `process.env` mid-run see + * fresh config. Each `vp create` hits this ≤4 times (registry + auth on + * packument + auth on tarball), which is cheap enough vs. the network + * work that a cache isn't worth the test-determinism cost. + */ +function getNpmConfig(): NpmConfig { + const config: NpmConfig = new Map(); + // Layer in order of increasing precedence: user → project → env. + const homeNpmrc = path.resolve(os.homedir(), '.npmrc'); + loadFileInto(homeNpmrc, config); + // Collect project `.npmrc` paths from cwd up to the filesystem root, + // then apply them in reverse (root-side first, cwd last) so the + // innermost file wins. Skip the `$HOME/.npmrc` we already loaded so + // it doesn't re-overwrite project-level settings when cwd is under + // `$HOME`. + const projectRcs: string[] = []; + let dir = path.resolve(process.cwd()); + const seen = new Set(); + while (dir && !seen.has(dir)) { + seen.add(dir); + const candidate = path.resolve(dir, '.npmrc'); + if (candidate !== homeNpmrc && fs.existsSync(candidate)) { + projectRcs.push(candidate); + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + for (let i = projectRcs.length - 1; i >= 0; i -= 1) { + loadFileInto(projectRcs[i], config); + } + for (const [envKey, envValue] of Object.entries(process.env)) { + if (envValue === undefined) { + continue; + } + if (envKey.startsWith('npm_config_')) { + config.set(envKey.slice('npm_config_'.length), envValue); + } else if (envKey.startsWith('NPM_CONFIG_')) { + config.set(envKey.slice('NPM_CONFIG_'.length).toLowerCase(), envValue); + } + } + return config; +} + +function normalizeRegistryUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +/** + * Resolve the npm registry base URL for the given scope (or the default + * registry when `scope` is omitted). Honors `@scope:registry=...` entries + * in `.npmrc` files and the matching `npm_config_@scope:registry` env + * vars so private / mirrored registries work for org manifest fetches. + */ +export function getNpmRegistry(scope?: string): string { + const config = getNpmConfig(); + if (scope) { + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}`; + const scoped = config.get(`${normalizedScope}:registry`); + if (scoped) { + return normalizeRegistryUrl(scoped); + } + } + const registry = config.get('registry') || 'https://registry.npmjs.org'; + return normalizeRegistryUrl(registry); +} + +/** + * Build the `Authorization` header value for a registry URL by matching + * the URL against `//host[/path]/:_authToken=...` / `:_auth=...` entries + * in `.npmrc`. Returns `undefined` when no credential is configured. + */ +export function getNpmAuthHeader(registryOrUrl: string): string | undefined { + let parsed: URL; + try { + parsed = new URL(registryOrUrl); + } catch { + return undefined; + } + const config = getNpmConfig(); + // npm keys a credential by the protocol-less URL with a trailing slash, + // e.g. `//registry.example.com/foo/:_authToken`. Walk up the path so + // `/foo/bar` also matches a credential set for `/foo` or the host root. + const segments = parsed.pathname.split('/').filter(Boolean); + const candidates: string[] = []; + for (let i = segments.length; i >= 0; i -= 1) { + const subPath = i === 0 ? '/' : `/${segments.slice(0, i).join('/')}/`; + candidates.push(`//${parsed.host}${subPath}`); + } + for (const prefix of candidates) { + const token = config.get(`${prefix}:_authToken`); + if (token) { + return `Bearer ${token}`; + } + const basic = config.get(`${prefix}:_auth`); + if (basic) { + return `Basic ${basic}`; + } + const username = config.get(`${prefix}:username`); + const passwordB64 = config.get(`${prefix}:_password`); + if (username && passwordB64) { + const password = Buffer.from(passwordB64, 'base64').toString('utf8'); + return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + } + } + return undefined; +} + +/** + * `fetch` wrapper for npm registry URLs that retries with an + * `Authorization` header on 401/403. Public registries never see the + * token — we only reach into `.npmrc` when the server challenges us. + * + * `init.headers` is forwarded verbatim on both attempts (the retry + * merges in the discovered auth header on top). + */ +export async function fetchNpmResource( + url: string, + init: Omit & { timeoutMs: number }, +): Promise { + const { timeoutMs, headers: callerHeaders, ...rest } = init; + const first = await fetch(url, { + ...rest, + headers: callerHeaders, + signal: AbortSignal.timeout(timeoutMs), + }); + if (first.status !== 401 && first.status !== 403) { + return first; + } + const authorization = getNpmAuthHeader(url); + if (!authorization) { + return first; + } + return fetch(url, { + ...rest, + headers: { ...(callerHeaders as Record | undefined), authorization }, + signal: AbortSignal.timeout(timeoutMs), + }); +} diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts index 7b2cd00878..ef3faccecf 100644 --- a/packages/cli/src/utils/package.ts +++ b/packages/cli/src/utils/package.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { VITE_PLUS_NAME } from './constants.ts'; import { readJsonFile } from './json.ts'; +import { fetchNpmResource, getNpmRegistry } from './npm-config.ts'; export function getScopeFromPackageName(packageName: string): string { if (packageName.startsWith('@')) { @@ -65,17 +66,18 @@ export function hasVitePlusDependency( } /** - * Check if an npm package exists in the public registry. + * Check if an npm package exists on its resolved registry. * Returns true if the package exists or if the check could not be performed (network error, timeout). * Returns false only if the registry definitively responds with 404. */ export async function checkNpmPackageExists(packageName: string): Promise { const atIndex = packageName.indexOf('@', 2); const name = atIndex === -1 ? packageName : packageName.slice(0, atIndex); + const scope = getScopeFromPackageName(name); try { - const response = await fetch(`https://registry.npmjs.org/${name}`, { + const response = await fetchNpmResource(`${getNpmRegistry(scope)}/${name}`, { method: 'HEAD', - signal: AbortSignal.timeout(3000), + timeoutMs: 3000, }); return response.status !== 404; } catch { diff --git a/packages/tools/src/snap-test.ts b/packages/tools/src/snap-test.ts index e86b1d6f37..11c1bc08d8 100755 --- a/packages/tools/src/snap-test.ts +++ b/packages/tools/src/snap-test.ts @@ -362,6 +362,10 @@ async function runTestCase(name: string, tempTmpDir: string, casesDir: string, b VP_SKIP_INSTALL: '1', // make sure npm install global packages to the temporary directory NPM_CONFIG_PREFIX: path.join(tempTmpDir, NPM_GLOBAL_PREFIX_DIR), + // Absolute path to the source casesDir, so fixtures can reference + // shared helper scripts under `/.shared/` without + // duplicating them into every fixture directory. + SNAP_CASES_DIR: casesDir, // A test case can override/unset environment variables above. // For example, VP_CLI_TEST/CI can be unset to test the real-world outputs. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80a13dbaf7..6c7290a399 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ catalogs: mri: specifier: ^1.2.0 version: 1.2.0 + nanotar: + specifier: ^0.3.0 + version: 0.3.0 oxc-parser: specifier: '=0.127.0' version: 0.127.0 @@ -398,6 +401,9 @@ importers: mri: specifier: 'catalog:' version: 1.2.0 + nanotar: + specifier: 'catalog:' + version: 0.3.0 picocolors: specifier: 'catalog:' version: 1.1.1 @@ -7662,6 +7668,9 @@ packages: engines: {node: ^18 || >=20} hasBin: true + nanotar@0.3.0: + resolution: {integrity: sha512-Kv2JYYiCzt16Kt5QwAc9BFG89xfPNBx+oQL4GQXD9nLqPkZBiNaqaCWtwnbk/q7UVsTYevvM1b0UF8zmEI4pCg==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -15375,6 +15384,8 @@ snapshots: nanoid@5.1.9: {} + nanotar@0.3.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c0ef723614..5360340a23 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -78,6 +78,7 @@ catalog: minimatch: ^10.0.3 mocha: ^11.7.5 mri: ^1.2.0 + nanotar: ^0.3.0 next: ^15.4.3 oxc-minify: =0.127.0 oxc-parser: =0.127.0 diff --git a/rfcs/create-org-default-templates.md b/rfcs/create-org-default-templates.md new file mode 100644 index 0000000000..6d7a982e09 --- /dev/null +++ b/rfcs/create-org-default-templates.md @@ -0,0 +1,1012 @@ +# RFC: Organization Default Templates for `vp create` + +> Status: **Implemented** on branch `vp-create-support-org` (PR #1398). +> Sections below describe the design as shipped; the trailing "Resolved +> Decisions" list reflects every decision that landed during +> implementation, including ones that emerged from review (`.npmrc` +> registry/auth, `__vp_` reserved prefix, sanitized cache host segment, +> and others). The "Implementation State" section near the bottom +> points at the concrete files. + +## Summary + +Give organizations a single, branded entry point into their curated set of +project templates through `vp create @org`. When `@org/create` publishes a +`createConfig.templates` manifest in its `package.json`, Vite+ renders an interactive +picker over the listed templates; when it doesn't, the command executes +`@org/create` as a normal template (current behavior). A +`create.defaultTemplate` option in `vite.config.ts` lets a repo promote an +org's picker to the default for a bare `vp create`. + +## Background + +Organizations often maintain a collection of internal project templates +(web app, mobile app, server, library, etc.) and need a first-class way to +expose them as a single, branded entry point — so that engineers can pick +from an interactive list of "web / mobile / server / library" style choices +without having to remember individual per-template package names. + +Reference: + +- [RFC: Vite+ Code Generator](./code-generator.md) — the parent RFC that + establishes `vp create` as a dual-mode (bingo + universal `create-*`) tool. + This RFC is a consumer-facing extension on top of the existing universal + `create-*` mode. +- [npm `create-*` convention](https://docs.npmjs.com/cli/v10/commands/npm-init) + — the ecosystem convention `vp create` already honors via + `expandCreateShorthand` (`packages/cli/src/create/discovery.ts:148-216`). + +## Motivation + +### The problem + +Companies that own a portfolio of internal templates (web apps, libraries, +service scaffolds, CLI tools) have no clean way to present those templates as a +single product surface to their engineers. Today, to pick one of an org's +four templates, an engineer has to: + +1. Know the exact package name of the template they want. +2. Type the full command: `vp create @your-org/create-web`, + `vp create @your-org/create-mobile`, etc. +3. Find these names in a README, a wiki, or Slack. + +This works, but it isn't discoverable, and it forces the org to document +package names in a medium that ages badly. The industry convention for +frameworks (Vite, Next, Nuxt) is "one command per framework" precisely because +a single memorable entry point outperforms a list of names. + +### What engineers should be able to type + +```bash +# Interactively pick a template from the @your-org org +vp create @your-org + +# Pick a specific manifest entry directly +vp create @your-org:web + +# Inside a repo that sets @your-org as the default: +vp create +``` + +The goal is that "the company's scaffolding toolchain" is spelled `@org`, not a +twelve-line README. + +### Why not just document better READMEs? + +READMEs can list templates, but: + +- They don't power an interactive picker. +- They rot faster than code does. +- They can't be a project-level default that every clone of a repo inherits. + +A manifest inside `@org/create`'s own `package.json` gives the org a single +source of truth, discoverable via `npm view`, versioned alongside the package. + +## Existing Behavior (What Already Works) + +This RFC is additive. A non-trivial amount of the feature already ships. + +`packages/cli/src/create/discovery.ts:148-216` defines +`expandCreateShorthand`, which maps: + +- `@org` → `@org/create` +- `@org/name` → `@org/create-name` +- `name` → `create-name` (with special cases for `nitro`, `svelte`, + `@tanstack/start`) + +So the following already works today: + +```bash +# Already works: runs @your-org/create +vp create @your-org + +# Already works: runs @your-org/create-web +vp create @your-org/web +``` + +The piece that doesn't exist yet is **discovering and choosing between multiple +templates owned by the same org**. That is what this RFC specifies. + +## Proposed Solution + +### High-level flow + +1. User runs `vp create @org`. +2. `expandCreateShorthand` maps this to `@org/create` (unchanged). +3. Before dispatching to the template runner, `vp create` reads + `@org/create`'s `package.json` from the npm registry. +4. If the `package.json` contains a `createConfig.templates` field, Vite+ renders an + interactive picker over those entries. +5. After the user picks (or passes `@org:` directly — colon separator + mirrors the existing `vite:monorepo` / `vite:library` builtin syntax and + keeps manifest entries syntactically distinct from real `@org/package` + npm specifiers), Vite+ resolves the selected entry's `template` field + through the existing `discoverTemplate` pipeline — which supports npm, + GitHub, builtin `vite:*`, and local workspace packages. +6. If `createConfig.templates` is **absent**, Vite+ falls through to today's behavior + and executes `@org/create` as a normal template. This keeps the feature + zero-risk for org owners who haven't opted in. + +### Command matrix + +| Command | Manifest present? | Behavior | +| -------------------------------- | ----------------- | ------------------------------------------------------------------------------ | +| `vp create @org` | yes | Fetch manifest → picker → run chosen template | +| `vp create @org` | no | Run `@org/create` as today (unchanged) | +| `vp create @org:name` | yes, has `name` | Run manifest entry `name` | +| `vp create @org:name` | yes, no `name` | Hard error listing available manifest entry names | +| `vp create @org:name` | no | Same hard error — `:`-form is explicit manifest lookup, no silent fall-through | +| `vp create @org/name` | n/a | Unchanged from pre-feature: existing `@org/create-name` shorthand | +| `vp create` (in configured repo) | yes | Same as `vp create @org` where `@org` is the configured default | +| `vp create ` | n/a | Unchanged | + +## Manifest Schema + +The manifest lives at `createConfig.templates` in `@org/create`'s `package.json`. + +```json +{ + "name": "@your-org/create", + "version": "1.0.0", + "description": "Project templates from the @your-org org", + "createConfig": { + "templates": [ + { + "name": "monorepo", + "description": "Monorepo scaffold", + "template": "@your-org/template-monorepo", + "monorepo": true + }, + { + "name": "web", + "description": "Web app template (Vite + React)", + "template": "@your-org/template-web" + }, + { + "name": "mobile", + "description": "Mobile app (React Native) template", + "template": "@your-org/template-mobile" + }, + { + "name": "server", + "description": "Server template (Node + Fastify)", + "template": "github:your-org/template-server" + }, + { + "name": "library", + "description": "TypeScript library template", + "template": "@your-org/template-library" + }, + { + "name": "demo", + "description": "Bundled demo template (lives inside @your-org/create)", + "template": "./templates/demo" + } + ] + } +} +``` + +### Field reference + +| Field | Type | Required | Notes | +| -------------------------------------- | ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createConfig.templates` | `TemplateEntry[]` | yes | Non-empty array. Empty arrays are treated as "no manifest" (fall through to `@org/create` run). | +| `createConfig.templates[].name` | `string` | yes | Kebab-case. Used for `vp create @org:` direct selection. Must be unique within the array. Names starting with `__vp_` are reserved for internal sentinel values and rejected at schema validation. | +| `createConfig.templates[].description` | `string` | yes | One-line description shown in the picker. | +| `createConfig.templates[].template` | `string` | yes | One of: (a) an npm package specifier (`@your-org/template-web`, optionally `@version`), (b) a GitHub URL (`github:user/repo`, `https://github.com/...`), (c) a `vite:*` builtin, (d) a local workspace package name, or (e) a relative path (`./templates/demo`, `../foo`) that resolves against the enclosing `@org/create` package root. See "Bundled subdirectory templates" below. | +| `createConfig.templates[].monorepo` | `boolean` | no | If `true`, marks this entry as a _monorepo-creating_ template. Hidden from the picker when `vp create` is invoked inside an existing monorepo. Mirrors the built-in behavior that filters `vite:monorepo` out of `getInitialTemplateOptions` (`packages/cli/src/create/initial-template-options.ts:9-31`). Defaults to `false`. | + +### Invalid manifests + +A present-but-invalid `createConfig.templates` field should **not** silently fall through +to the shorthand. It should produce a schema error with the offending field +path (e.g. `@your-org/create: createConfig.templates[2].template is required`), because the +maintainer clearly intended to provide a manifest and should be told what's +wrong. + +### Namespacing under `vp` + +Using the `vp` object — rather than a top-level `vpTemplates` — keeps room for +future Vite+ package metadata without polluting the `package.json` root. +Conventions like `engines`, `bin`, and `files` already live in top-level +slots; tool-specific metadata is usually nested (cf. `jest`, `eslint`, +`prettier`). + +### Bundled subdirectory templates + +A very common real-world pattern — used by `create-vite`, `create-next-app`, +and many enterprise scaffolding kits — is a single package that contains +_all_ of its templates as subdirectories. For this pattern, a manifest entry +can use a relative path as the `template` value: + +```json +{ + "name": "demo", + "description": "Bundled demo template", + "template": "./templates/demo" +} +``` + +Semantics: + +- Paths starting with `./` or `../` resolve against the enclosing + `@org/create` package root (the directory containing the published + `package.json` — **not** the user's current working directory). +- The path must stay inside the package. Escapes via `../../..` that would + reach outside the extracted tarball are rejected at schema-validation + time. +- The referenced directory is scaffolded verbatim: file contents are copied + to the target directory with no template-engine processing. (Variable + substitution, Bingo-style transforms, etc. remain the domain of the + `@org/template-*` or `bingo-template` branches.) +- Files like `package.json` inside the template subdirectory are used + as-is. Org maintainers can pre-rewrite the package name at scaffold time + via the existing `vp create` post-processing (name prompt, package- + manager detection, etc.), matching today's builtin behavior. + +**Why bundled paths matter for adoption**: without this, orgs with three or +four templates have to publish three or four packages, maintain their +independent release cadence, and document the mapping. With bundled paths, +a single `@org/create` package — containing the manifest and the templates +themselves — is the entire on-disk surface they need to ship. + +**Tarball fetch and extract**: when `vp create` resolves a bundled path, it +fetches the tarball URL from the registry JSON it already pulled for the +manifest (`dist.tarball`), downloads it directly over HTTPS (honoring +`.npmrc` scope registries + `NPM_CONFIG_REGISTRY`), and extracts it to a +per-version cache under +`$VP_HOME/tmp/create-org///create//`. The leading +`` segment (sanitized for Windows-illegal characters) keeps two repos +that resolve the same `@` through different registries from +sharing a cache slot. Subsequent invocations against the same host reuse +the cached extraction. A small tar-reader implementation (no external +install step, no spawning `npm pack`) keeps resolution fast and independent +of the user's package manager. + +## Resolution Flow (implementation shape) + +Hook point: inside `discoverTemplate` +(`packages/cli/src/create/discovery.ts:44-128`), immediately before the final +`expandCreateShorthand` branch at line 119. + +Pseudo-code: + +```ts +// After built-in / GitHub / local checks, before expandCreateShorthand. +if (templateName.startsWith('@')) { + const { scope, name } = parseScoped(templateName); + const manifest = await readOrgManifest(scope); // fetches @scope/create package.json + + if (manifest) { + const entry = + name === undefined + ? await pickTemplate(manifest.templates, { interactive }) + : manifest.templates.find((t) => t.name === name); + + if (entry) { + // Bundled subdirectory: resolve against the extracted tarball. + if (entry.template.startsWith('./') || entry.template.startsWith('../')) { + const extractedRoot = await ensureOrgPackageExtracted( + manifest.packageName, + manifest.version, + manifest.tarballUrl, + ); + const absPath = resolveBundledPath(extractedRoot, entry.template); + return { command: 'copy-dir', args: [absPath, ...templateArgs], type: TemplateType.local, /* ... */ }; + } + + // Everything else: recurse through existing discoverTemplate. + return discoverTemplate(entry.template, templateArgs, workspaceInfo, interactive); + } + // `vp create @org:name` with no matching entry → hard error (no fall-through). + } +} + +// Existing expandCreateShorthand path. +const expandedName = expandCreateShorthand(templateName); +... +``` + +`readOrgManifest` lives in `packages/cli/src/create/org-manifest.ts`. It: + +- Fetches the packument from the scope's registry (resolved via + `getNpmRegistry(scope)` in `packages/cli/src/utils/npm-config.ts`, which + layers `~/.npmrc` → project `.npmrc` → `npm_config_*` env vars and + honors `@scope:registry=...` overrides). +- Anonymous on the first request; retries with the matching `_authToken` + / `_auth` / `username:_password` from `.npmrc` only if the server + returns 401/403, so public registries never see the token. +- Resolves the manifest version: when `parseOrgScopedSpec` extracted a + version (`@scope@1.2.3`, `@scope:web@next`), looks it up in + `dist-tags[...]` first, then `versions[...]` directly; otherwise + `dist-tags.latest`. Unknown versions are a hard error. +- Returns `null` on 404 (package doesn't exist → scope-only input falls + through to the existing shorthand path; `@org:name` is a hard error). +- **Throws** on non-404 HTTP errors and on schema violations. +- Carries `tarballUrl` and `integrity` on the returned manifest so + bundled-path entries can be extracted without a second registry + round-trip. + +`ensureOrgPackageExtracted` (`packages/cli/src/create/org-tarball.ts`): + +- Computes the cache path + `$VP_HOME/tmp/create-org///create//`. The + `` segment comes from `manifest.tarballUrl` (sanitized via + `sanitizeHostForPath` to replace Windows-illegal characters like `:` + in `localhost:4873`); two repos resolving the same + `@` through different registries don't collide on + one cache slot. +- Returns the cached root immediately if the extraction already exists. +- Otherwise streams the tarball over HTTPS (auth retry mirrors the + manifest fetch), enforces a 50 MB cap, validates `dist.integrity`, + and extracts with `nanotar` to a staging dir that's atomically + renamed into place. Sibling `.tmp-*` staging dirs older than 24h are + pruned at the start of each fresh extract. +- Tar entries outside `package/` are skipped; their stored mode bits + are preserved (so `gradlew` and friends stay executable). +- `resolveBundledPath(extractedRoot, entry.template)` normalizes the + relative path and rejects any result that escapes `extractedRoot` + (`../` sequences that would leave the package root). + +## Default Org Config + +Add a `create` field to `UserConfig` in +`packages/cli/src/define-config.ts:14-35`: + +```ts +declare module '@voidzero-dev/vite-plus-core' { + interface UserConfig { + // ... existing fields ... + + create?: { + /** + * When `vp create` is invoked with no template argument, use this org + * as the default (equivalent to `vp create `). + * + * Accepts any value that would work as the first argument to + * `vp create` — typically a scope like `@your-org`. + */ + defaultTemplate?: string; + }; + } +} +``` + +Example `vite.config.ts`: + +```ts +import { defineConfig } from '@voidzero-dev/vite-plus'; + +export default defineConfig({ + create: { + defaultTemplate: '@your-org', + }, +}); +``` + +### Precedence + +`CLI argument` > `vite.config.ts create.defaultTemplate` > interactive +prompt for template name (today's behavior when bare `vp create` is typed with +no argument and no default). + +### Keeping access to the Vite+ built-in templates + +Setting `create.defaultTemplate` should never _hide_ the Vite+ built-in +defaults (`vite:monorepo`, `vite:application`, `vite:library`, +`vite:generator`) from an engineer who needs them. Without an escape hatch, +a repo that ships this config would force every contributor to remember the +exact `vite:*` specifier name, which defeats the purpose of interactive +discovery. + +The org picker therefore always appends a trailing "Vite+ built-in +templates" entry. Selecting it drops the user into the existing +`getInitialTemplateOptions` picker +(`packages/cli/src/create/initial-template-options.ts:9-31`) unchanged: + +``` +? Pick a template from @your-org +❯ monorepo Monorepo scaffold + web Web app template (Vite + React) + mobile Mobile app (React Native) template + server Server template (Node + Fastify) + library TypeScript library template + ────────────────── + › Vite+ built-in templates Use defaults (monorepo / application / library) +``` + +The hint trailing "Vite+ built-in templates" matches what +`getInitialTemplateOptions` actually offers for the current workspace +context — inside an existing monorepo the hint reads "Use defaults +(application / library)" since `vite:monorepo` is filtered out and +`vite:generator` isn't part of the picker. + +Rules: + +- The escape-hatch entry is appended by Vite+, not by the org manifest. It + cannot be suppressed by the org — this is an intentional + "user-agency-trumps-config" decision, similar to how most modern package + managers always expose `--help` regardless of project config. +- Selecting it re-enters the standard flow: the picker shown is identical + to what `vp create` renders in a repo without `defaultTemplate` set, and + is itself already context-aware (omits `vite:monorepo` inside a monorepo, + requires a monorepo for `vite:generator`, etc.). +- The entry is placed last, below a separator, so the org's own templates + remain the visually dominant choice. + +For scripted / non-interactive use, engineers can bypass the configured +default by passing any template argument directly — `vp create vite:library`, +`vp create vite:application`, etc. No new CLI flag is added; the existing +"pass an explicit specifier" escape hatch is sufficient for CI and scripts. + +The `--no-interactive` error output for `vp create @org` mentions this in +the hint line, so an agent reading the table can pivot: + +``` +hint: rerun with an explicit selection, e.g. `vp create @your-org:web`, + or use a Vite+ built-in template like `vp create vite:application`. +``` + +### Intentionally out of scope + +- **User-level default** at `~/.vite-plus/config.json`. Deferred to a future + RFC to keep this one tight. Callers who want a personal default can commit + the project config. +- **Multiple defaults** (e.g. a picker spanning `['@your-org', '@vercel']`). + If that need surfaces later, it warrants a separate field + (`defaultTemplates: string[]`) rather than overloading the singular form. + +## Interactive UX + +### Picker + +When `@org/create`'s manifest is found, `vp create @org` displays a list +prompt over the **context-filtered** entries (see "Context-aware filtering" +below), followed by a trailing **Vite+ built-in templates** entry (see +"Keeping access to the Vite+ built-in templates" above). Sketch: + +``` +? Pick a template from @your-org +❯ web Web app template (Vite + React) + mobile Mobile app (React Native) template + server Server template (Node + Fastify) + library TypeScript library template + ────────────────── + › Vite+ built-in templates Use defaults (monorepo / application / library) +``` + +### Context-aware filtering + +The picker hides entries that don't make sense for the current workspace, +mirroring the existing logic in +`packages/cli/src/create/initial-template-options.ts:9-31` that omits +`vite:monorepo` when Vite+ already detects a monorepo root. + +Rule: + +- If an entry has `monorepo: true` **and** `vp create` was invoked inside an + existing monorepo (`workspaceInfoOptional.isMonorepo === true`), the entry + is filtered out before the picker renders. +- All other entries are shown. + +If filtering empties the list entirely, `vp create @org` prints an +`info:` note ("No templates from `@org/create` are applicable inside a +monorepo — showing Vite+ built-in templates instead.") and routes to the +built-in picker, so the user never sees an empty picker and isn't left +at a dead end. + +### Direct-selection behavior + +`vp create @org:` bypasses the picker, so filtering does not apply — +but an explicit selection of a `monorepo: true` entry from _within_ a +monorepo is almost certainly a mistake. Vite+ already refuses +`vite:monorepo` in this situation at `packages/cli/src/create/bin.ts:468-472`. +The same error ("Cannot create a monorepo inside an existing monorepo") +extends to manifest entries with `monorepo: true`. + +Keyword search: typing filters against `name`, `description`, and +`keywords`. Arrow keys + Enter select; Ctrl-C cancels. + +**Decision**: reuse the `@voidzero-dev/vite-plus-prompts` `select` primitive +already wired into `vp create` (`packages/cli/src/create/bin.ts:5`, which +wraps `@clack/core`), with prefix filtering over `name` / `description` / +`keywords`. If real usage surfaces friction (e.g., orgs with many +templates), revisit with a fuzzy-search picker (e.g. based on +`@voidzero-dev/vite-plus-prompts`' `autocomplete`) in a follow-up. + +### `--no-interactive` + +When `@org` is passed without a name and interactive mode is disabled, the +command errors and prints the full manifest table — the same table a +dedicated `--list` flag would have produced. This keeps the surface small +(no extra flag), and, critically, gives AI agents reading the output enough +context (name, description, underlying template) to pick an appropriate +option and retry with `vp create @org:`: + +``` +A template name is required when running `vp create @your-org` in non-interactive mode. + +Available templates in @your-org/create: + + NAME DESCRIPTION TEMPLATE + web Web app template (Vite + React) @your-org/template-web + mobile Mobile app (React Native) template @your-org/template-mobile + server Server template (Node + Fastify) github:your-org/template-server + library TypeScript library template @your-org/template-library + demo Bundled demo template ./templates/demo + +Examples: + # Scaffold a specific template from the org + vp create @your-org:web --no-interactive + + # Or use a Vite+ built-in template + vp create vite:application --no-interactive +``` + +Shape matches the existing `vp create` missing-argument message +(`packages/cli/src/create/bin.ts:387-399`) — same opening sentence pattern, +same `Examples:` block — so users see a consistent shape for any +missing-template error across the command. + +Notes: + +- Output is stable and machine-parseable (fixed column order, whitespace- + separated). Agents can parse it without a `--json` flag; if that turns out + to be insufficient, a `--json` output mode is a cheap follow-up. +- The table includes `TEMPLATE` (the resolved specifier) so that a reader + can understand what each choice actually scaffolds — e.g. whether it + points to npm, GitHub, or a builtin. +- The table is **context-filtered**: entries with `monorepo: true` are + omitted when the command runs inside an existing monorepo, matching the + interactive picker's behavior. A footer line + (`omitted 1 monorepo-only entry because this workspace is already a monorepo`) + makes the filtering visible to both humans and agents. +- The error is written to stderr; the table itself can go to stdout so it + remains usable when redirected. + +## Authoring Guide for Org Maintainers + +The manifest convention is intentionally cheap for orgs to adopt. There are +two common layouts; pick whichever matches the org's template count and +release cadence. + +**Layout 1: Bundled templates in a single package (recommended for most +orgs).** All templates live as subdirectories of `@org/create` itself; +manifest entries use `./relative/path` to reference them. This is the same +pattern used by `create-vite`, `create-next-app`, and most enterprise +scaffolding kits — one repo, one publish, one versioning story. + +``` +@your-org/create/ +├── package.json # "createConfig": { "templates": [{ "template": "./templates/demo" }, ...] } +├── templates/ +│ ├── demo/ +│ │ ├── package.json +│ │ └── src/... +│ ├── web/... +│ └── library/... +└── README.md +``` + +**Layout 2: Manifest-only, pointing to external packages.** Useful when the +org already publishes independent `@org/template-*` packages (or hosts +templates on GitHub) and wants `@org/create` to be a thin index. Manifest +entries use npm specifiers or `github:` URLs. + +``` +@your-org/create/ +├── package.json # "createConfig": { "templates": [{ "template": "@org/template-web" }, ...] } +└── README.md +``` + +The two layouts can also be mixed — the example manifest higher up uses +external packages for most entries and `./templates/demo` for one. + +No code is required if the manifest is your only surface. However, it is +strongly recommended that `@org/create` remains **runnable as a classic +`create-*` package** too, as a fallback for users on plain +`npm create` / `yarn create`. Typical layout adds a bin script: + +``` +@your-org/create/ +├── package.json # "bin": { "create": "./bin.js" }, and "createConfig.templates" +├── bin.js # small launcher that runs the picker for npm users +├── templates/... # (if using Layout 1) +└── README.md +``` + +This gives you: + +- `npm create @your-org` / `yarn create @your-org` → runs your `bin.js` (legacy path). +- `vp create @your-org` → reads the manifest directly, no `bin.js` execution. + +### Choosing what the manifest entries point to + +Each `template` field is a specifier passed to Vite+'s `discoverTemplate`. +Common choices: + +| Choice | When to use it | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `./templates/foo` (bundled path) | The template lives as a subdirectory of `@org/create` itself. Lowest authoring overhead; recommended for most orgs. | +| `@org/template-foo` (npm package) | The template is independently published and versioned. | +| `github:org/template-foo` | The template lives in a GitHub repo, not npm. Uses `degit`. | +| `vite:monorepo` / other builtins | Defer to Vite+ builtins with your own wrapper entry. | +| Local workspace package name | Template lives inside the same monorepo as `@org/create`. See bingo/local path in `discoverTemplate`. | + +### Marking monorepo-only templates + +If a manifest entry scaffolds a **monorepo** (i.e., it creates a workspace +root, not a single package), mark it with `monorepo: true`. Vite+ will then +hide that entry from the picker when a user runs `vp create @org` from +inside an existing monorepo, and will error with a clear message if the +user explicitly types `vp create @org/` in that context. This mirrors +how Vite+ already filters its own `vite:monorepo` builtin in +`packages/cli/src/create/initial-template-options.ts:9-31`. + +Typical usage: an org's `@org/create` manifest lists one `monorepo: true` +entry (for greenfield consumers) alongside several single-package entries +(web / mobile / server / library) that can also be used to scaffold +individual packages inside the monorepo. + +### Versioning + +The manifest is resolved against `@org/create@latest` by default. Org +maintainers can pin a specific version per entry (e.g. +`@your-org/template-web@2.3.0`) inside the `template` field. We do not add a +separate `version` field on the manifest entry to avoid two competing knobs. + +### Publishing checklist + +1. Create `@org/create` (scoped npm package) if you don't already have one. +2. Add a `createConfig.templates` array to `package.json`. +3. (Optional) Provide a `bin` launcher for `npm create @org` compatibility. +4. Publish. +5. Verify with `vp create @org --no-interactive` (prints the available + template names) or `vp create @org` (opens the picker). +6. (Optional) Commit `create: { defaultTemplate: '@org' }` in your + internal template repos. + +### Backwards compatibility + +If you already publish `@org/create` as a single-template package, **adding +`createConfig.templates` is not a breaking change for `vp create` users** — the picker +replaces the direct execution, and each manifest entry can still point to +your existing template. Users on plain `npm create @org` are unaffected +either way; they continue to run your `bin` script. + +## Error Handling + +| Situation | Behavior | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@org/create` does not exist on npm | Same "template not found" error as today. | +| `@org/create` exists, no `createConfig.templates` | Fall through to today's behavior: run `@org/create`. No error. | +| `createConfig.templates` is not an array | Schema error: `@org/create: createConfig.templates must be an array`. | +| Manifest entry missing `name` / `description` / `template` | Schema error with the offending index and field. | +| Manifest entry has duplicate `name` | Schema error listing the duplicate. | +| Chosen template fails to resolve (404, bad URL) | Downstream error with context: `selected 'web' from @your-org/create: `. | +| Network failure fetching manifest | Hard error. Never silently skip the picker when the user explicitly typed `@org`. | +| `--no-interactive` without `@org:` | Error listing valid names (see above). | +| All manifest entries filtered (e.g. all `monorepo: true` inside a monorepo) | Print an `info:` note (`"No templates from @org/create are applicable inside a monorepo — showing Vite+ built-in templates instead."`) and route to the built-in picker. Keeps the user out of a dead end. | +| `vp create @org:` where `name` has `monorepo: true` and cwd is a monorepo | Same error as the builtin: `Cannot create a monorepo inside an existing monorepo` (mirrors `bin.ts:468-472`). | +| `vp create @org:` where `name` isn't in the manifest (or manifest is absent) | Hard error listing the available entries — no silent fall-through to the `@org/create-name` shorthand, which is reserved for the slash-form. | +| Bundled path (`./foo`) resolves outside `@org/create` root | Schema error at manifest-validation time: `createConfig.templates[i].template escapes the package root`. | +| Bundled path points to a directory that does not exist in the tarball | Scaffolding error: `selected 'demo' from @your-org/create: ./templates/demo not found in @your-org/create@1.0.0`. | +| Tarball download or extraction fails | Hard error with the upstream cause. Cached partial extractions are cleaned up before retry. | + +## Alternatives Considered + +### (a) Dedicated `@org/vp-templates` package + +An earlier proposal suggested a dedicated `@org/vp-templates` package, +which would introduce a new shorthand rule (`vp create @org` → +`@org/vp-templates`). **Rejected** because: + +- The existing `@org/create` shorthand already matches the ecosystem + convention (`npm create @org`, `yarn create @org`). +- Gating picker behavior on manifest presence cleanly separates the two + modes without a new rule. +- Orgs that already publish `@org/create` don't need to publish a second + package to adopt Vite+. + +### (b) Separate `templates.json` file inside the package + +**Rejected** because `package.json` `createConfig.templates` is readable via a single +`npm view` / registry HEAD request without fetching the package tarball. +`templates.json` would require either tarball download or degit-style git +fetch, both of which are slower and have more failure modes. + +### (c) User-level default at `~/.vite-plus/config.json` + +**Deferred** to a future RFC. Project-level config is the clear priority: +companies set this once in their repo and every clone inherits it. Solo +users who want a personal default can use a shell alias until the follow-up +RFC lands. + +### (d) `exports['./templates']` JS-native manifest + +**Rejected** because executing the package to enumerate templates means +network download + sandboxed run for what should be a static list. Also +forces every implementation of the picker (Vite+, future ports, docs tools) +to spin up a JS runtime. + +### (e) Special-case `@org` at the CLI layer + +**Rejected** because it's less composable with the existing +`discoverTemplate` pipeline. Hooking into `discoverTemplate` reuses all the +existing template-resolution, parent-directory inference, and runner +plumbing. + +## Implementation State + +Shipped on branch `vp-create-support-org` (PR #1398). Concrete +landings: + +| Module | Role | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `packages/cli/src/create/org-manifest.ts` | `parseOrgScopedSpec`, `readOrgManifest`, schema validation (incl. `__vp_` reserved-prefix check). | +| `packages/cli/src/create/org-resolve.ts` | `resolveOrgManifestForCreate`, `getConfiguredDefaultTemplate`, picker / `--no-interactive` table dispatch. | +| `packages/cli/src/create/org-picker.ts` | `pickOrgTemplate` interactive picker, escape-hatch entry, context-aware filtering. | +| `packages/cli/src/create/org-tarball.ts` | `ensureOrgPackageExtracted`, `resolveBundledPath`, `sanitizeHostForPath`, integrity verification, mode preservation. | +| `packages/cli/src/create/templates/bundled.ts` | `executeBundledTemplate` (directory-copy scaffold for relative-path manifest entries). | +| `packages/cli/src/create/discovery.ts` | `bundledLocalPath` + `skipShorthand` parameters threading manifest results into the existing template flow. | +| `packages/cli/src/create/bin.ts` | Unified monorepo branch (builtin + bundled), git-init prompt, `injectCreateDefaultTemplate` for `@org` monorepos. | +| `packages/cli/src/create/utils.ts` | `ensureGitignoreNodeModules` post-`git init` guarantee. | +| `packages/cli/src/define-config.ts` | `create: { defaultTemplate?: string }` augmentation on `UserConfig`. | +| `packages/cli/src/migration/migrator.ts` | `injectCreateDefaultTemplate` helper (called from `bin.ts`, gated on bundled monorepo). | +| `packages/cli/src/utils/npm-config.ts` | `.npmrc` parser, `getNpmRegistry(scope?)`, `getNpmAuthHeader(url)`, `fetchNpmResource` (401/403 retry). | +| `packages/cli/src/resolve-vite-config.ts` | `findWorkspaceRoot` exported for the default-template walk-up. | +| `docs/guide/create.md`, `docs/config/create.md` | Authoring guide and `create.defaultTemplate` reference. | + +## Testing + +End-to-end snap-test fixtures under `packages/cli/snap-tests/` use a +shared local mock registry (`.shared/mock-npm-registry.mjs`) that +serves a per-fixture `mock-manifest.json` and any tarballs in +`/tarballs/`. CI stays fast and offline. + +| Fixture | What it verifies | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `create-org-bundled` | `vp create @org:` extracts the tarball and scaffolds a single-project bundled subdirectory. | +| `create-org-bundled-escape-check` | `./../outside` paths rejected at schema validation before any tarball fetch. | +| `create-org-bundled-monorepo` | Bundled `monorepo: true` entry: scaffold + `git init` + `create.defaultTemplate: '@org'` injection + `node_modules` in `.gitignore`. | +| `create-org-config-default` | `vp create` in a repo with `create.defaultTemplate` uses the configured org. | +| `create-org-invalid-manifest` | Invalid `createConfig.templates` produces a schema error. | +| `create-org-monorepo-filter` | `monorepo: true` entries hidden from picker / `--no-interactive` output when run inside a monorepo. | +| `create-org-monorepo-direct-in-monorepo` | `vp create @org:` inside a monorepo errors loudly. | +| `create-org-no-interactive-error` | `--no-interactive` without a name errors and prints the full manifest table (name + description + template). | +| `snap-tests-global/new-vite-monorepo` | Builtin `vp create vite:monorepo` does NOT auto-inject `create.defaultTemplate` (negative case for the gating). | + +Unit tests under `packages/cli/src/**/__tests__/`: + +- `org-manifest.spec.ts` — `parseOrgScopedSpec` (incl. `@scope@version`, + `@scope:name@version` forms), `filterManifestForContext`, + `readOrgManifest` happy path + schema errors + version pinning + auth + retry on 401/403. +- `org-tarball.spec.ts` — `parseEntryMode`, `normalizeEntryName`, + `cleanupStaleStagingDirs`, `resolveBundledPath` path-escape, + `sanitizeHostForPath` (Windows-illegal chars). +- `org-picker.spec.ts` — interactive picker filtering + escape-hatch + routing + per-call UUID sentinel. +- `org-resolve.spec.ts` — `getConfiguredDefaultTemplate` walk-up via + monorepo markers. +- `utils.spec.ts` — `ensureGitignoreNodeModules` (fresh / append / + no-newline / no-op / trailing-slash / CRLF / `node_modules/sub` / + `!node_modules` cases). +- `migrator.spec.ts` — `injectCreateDefaultTemplate` (injects when + scope is set, skips when empty, preserves an existing `create:`). +- `npm-config.spec.ts` (`packages/cli/src/utils/__tests__/`) — + `.npmrc` precedence (project > user), scoped registry resolution, + `_authToken` extraction. + +The snap-tests use stubbed `fetch` for unit-level scenarios and the +mock registry for end-to-end scenarios. We do **not** publish a +dedicated `@voidzero-dev/create-test-fixture` package; registry-surface +regressions are low-frequency and can be patched downstream. + +## CLI Help Output + +The relevant additions to `vp create --help`: + +``` +Usage: vp create [TEMPLATE] [OPTIONS] [-- TEMPLATE_OPTIONS] + +Arguments: + TEMPLATE Template to scaffold from. May be: + - an org scope (e.g. @your-org) for org templates + - an org entry (e.g. @your-org:web) for a specific + manifest entry + - any value accepted today: create-*, github:*, vite:*, + @scope/package, local package name + When omitted, uses `create.defaultTemplate` from + vite.config.ts if set. + +Options: + ...existing flags... + +Configuration (vite.config.ts): + create.defaultTemplate Default org/template used by bare `vp create`. +``` + +## Compatibility + +- **Org packages that already exist as single-template `@org/create`**: keep + working unchanged until they opt in by adding `createConfig.templates`. +- **Plain `npm create @org` / `yarn create @org`**: unaffected. Those + consumers run the package's `bin` script, which is outside Vite+'s scope. +- **Existing `@org/name` shorthand**: untouched. `vp create @org/foo` + still expands to `@org/create-foo` exactly as it did before this + feature. Manifest lookup is only triggered by the `:` separator + (`vp create @org:foo`), so there's no collision with real + `@org/anything` npm packages. + +## Real-World Usage Examples + +### Org with a published `@org/create` manifest + +```bash +# Discovery +vp create @your-org +# → picker with: web, mobile, server, library + +# Direct +vp create @your-org:server + +# Non-interactive (CI) +vp create @your-org:library --no-interactive --directory ./packages/new-lib +``` + +### Enterprise monorepo with a default + +```ts +// vite.config.ts at the company's template-seed repo +export default defineConfig({ + create: { defaultTemplate: '@your-org' }, +}); +``` + +```bash +# Inside that repo: engineers just type `vp create` +vp create +# → picker from @your-org/create, plus a trailing +# "Vite+ built-in templates" entry for users who need vite:library etc. + +# Explicit builtin (bypasses the configured default) +vp create vite:library +``` + +### Mixed-specifier manifest + +```json +{ + "createConfig": { + "templates": [ + { "name": "web", "description": "Next.js app", "template": "@your-org/template-web" }, + { "name": "docs", "description": "Docs site", "template": "github:acme/template-docs" }, + { "name": "tool", "description": "CLI tool", "template": "vite:library" } + ] + } +} +``` + +## Future Enhancements + +- **User-level default org** at `~/.vite-plus/config.json`. +- **Multiple default orgs** (picker spans multiple scopes when the config is + an array). +- **Non-npm manifest sources** (raw URL, git repo) for orgs that don't + publish to npm. +- **Manifest groups/categories** for orgs with >~10 templates. +- **Post-install hints** surfacing `vp create @org` when a user installs + `@org/create` directly. + +## Resolved Decisions + +- **Picker implementation**: plain `@voidzero-dev/vite-plus-prompts` + `select` with prefix filtering. Upgrade to a fuzzy-search picker (e.g. + the wrapper's `autocomplete`) in a follow-up if real usage reports + friction. +- **No `--list` flag**: manifest inspection goes through + `vp create @org --no-interactive`, which prints the full manifest table + (name, description, resolved template specifier) as part of its error + output. This gives scripts, CI logs, and AI agents enough context to pick + a template without needing a dedicated `--list` flag. +- **Network failure = hard error**: never silently skip the picker when the + user explicitly typed `@org`. Users on flaky networks get a clear, + actionable error instead of mysteriously running a single-template + fallback. +- **Built-in templates always reachable from the org picker**: when + `create.defaultTemplate` is set, the org picker appends a trailing "Vite+ + built-in templates" entry that routes to the existing + `getInitialTemplateOptions` flow. No new CLI flag; explicit specifiers + like `vp create vite:application` remain the scripted escape hatch. + (Resolves review feedback on #1398.) +- **Bundled subdirectory templates**: manifest entries may use relative + paths (`./templates/demo`) that resolve against the enclosing + `@org/create` package root. Vite+ fetches and extracts the tarball once + per `//` into + `$VP_HOME/tmp/create-org///create//`, then + scaffolds by directory copy. This lets an org ship N templates in a + single package rather than publishing N independent `@org/template-*` + packages — the dominant pattern in `create-*` ecosystems + (`create-vite`, `create-next-app`, enterprise kits). Paths that escape + the package root are rejected at schema validation. +- **Local test fixtures only**: snap-tests and unit tests use a local mock + registry / stubbed `fetch`. No dedicated published fixture package — + registry-surface regressions are low-frequency and caught downstream. +- **Config field name `defaultTemplate` (singular)**: reads naturally for a + single value, which is all this RFC ships. If support for multiple default + orgs is added later, it will live under a separate `defaultTemplates: +string[]` field rather than overloading the singular form. +- **No `--json` output mode on day one**: the fixed-column text table from + `--no-interactive` is already machine-parseable. Revisit if downstream + tooling reports friction. + +### Decisions added during implementation + +- **`@scope:name` (colon) as the manifest-entry separator**: not + `@scope/name` (which collides with real npm package specifiers and + the existing `@scope/create-name` shorthand). Mirrors the existing + `vite:monorepo` / `vite:library` syntax for builtin templates. +- **`createConfig.templates` (not `vp.templates`)**: tool-neutral key + name mirroring the existing `publishConfig` precedent. Other + scaffolders can adopt the same convention without an opinionated + `vp` namespace. +- **Pinned versions are honored**: `@scope@1.2.3` and + `@scope:name@next` resolve through `dist-tags[...]` first then + `versions[...]`. Unknown versions are a hard error. +- **`.npmrc` registry + auth, retry on challenge**: the resolver layers + user / project `.npmrc` with `npm_config_*` env vars and honors + `@scope:registry=...` overrides. The first request goes anonymous; + the resolver only sends the matching `_authToken` / `_auth` / + username:\_password on a 401/403 challenge so public registries never + see the token. +- **Reserved `__vp_` prefix on entry names**: schema validation rejects + manifest names starting with `__vp_`. Internal sentinel values (e.g. + the picker's escape-hatch UUID) live under that prefix and can never + collide with a user-authored entry. +- **Registry-aware cache key**: cache path includes a + `sanitizeHostForPath()` segment so two repos that + resolve the same `@` through different `.npmrc` + scope mappings don't share a slot. Sanitization replaces + Windows-illegal characters (`\ / : * ? " < > |` plus IPv6 brackets) + with `_`. +- **Atomic extract with stale-staging cleanup**: tarballs extract into + `.tmp--` and atomically rename into place; + rename-races resolve the loser to a cache hit. Sibling staging dirs + older than 24h are pruned at the start of each fresh extract. +- **Tar-entry mode preservation**: `gradlew`, `mvnw`, `bin/*`, and + similar files keep their `0755` bits through the extract. `setuid`, + `setgid`, and sticky bits are stripped — those have no place in a + user-land scaffold. +- **`keywords` field dropped**: prototyped in early rounds but never + consumed by the picker. Removed from the schema entirely (YAGNI) + rather than left validated-but-unused. +- **`create.defaultTemplate` auto-injection is gated**: only fires + when the user just scaffolded from `vp create @scope:` AND + the entry is `monorepo: true`. Builtin `vp create vite:monorepo` + with a scoped package name does NOT auto-inject — the scope there + is just an npm-publish detail, not a template-org choice. +- **Git-init prompt unified across monorepo paths**: prompt + spawn + live in `bin.ts`'s monorepo branch where `vite:monorepo` and + bundled `@org` monorepos converge; both ask, both default to yes + in non-interactive mode. +- **`.gitignore` always excludes `node_modules` after `git init`**: + bundled `@org` templates may ship without a `.gitignore`. After + `git init` succeeds, `ensureGitignoreNodeModules` either creates a + fresh `node_modules\n` file or appends the line if missing, with + CRLF/`node_modules/`/`!node_modules` edge cases handled. No-op when + the line is already present. +- **`findWorkspaceRoot` stays monorepo-marker-only**: extending it to + recognize `.git` was prototyped and reverted. Standalone repos with + no monorepo markers don't get config walk-up — call sites either + point at the right starting directory or accept the deferral. + +## Conclusion + +- `vp create @org` becomes a branded entry point backed by an org-owned + manifest. +- Opt-in via a single `createConfig.templates` field in `@org/create`'s `package.json`. +- Adopt in a repo via `create: { defaultTemplate: '@org' }`. +- Zero-risk for existing `@org/create` publishers. +- Consistent with the `code-generator.md` dual-mode strategy.