From 6566214d156321cebdb5244d58dbba45d2751266 Mon Sep 17 00:00:00 2001 From: daguanren21 <1363917353@qq.com> Date: Mon, 7 Sep 2026 10:54:01 +0800 Subject: [PATCH 1/6] feat(cli)!: support third-party registries Add registry selection, shadcn-vue exports, integration tests, and bilingual docs. BREAKING CHANGE: resolveRegistryItems now returns a Promise; callers must await it. Refs #6 --- .changeset/yummy-adults-hope.md | 5 + apps/docs/blocks/build-your-own.md | 31 +++- apps/docs/en/blocks/build-your-own.md | 31 +++- apps/docs/en/guide/installation.md | 2 + apps/docs/guide/installation.md | 2 + packages/cli/README.md | 42 +++++ packages/cli/src/index.ts | 156 ++++++++++++---- packages/cli/src/remote-registry.ts | 53 ++++++ packages/cli/tests/add.test.ts | 48 ++--- packages/cli/tests/remote-registry.test.ts | 201 +++++++++++++++++++++ 10 files changed, 510 insertions(+), 61 deletions(-) create mode 100644 .changeset/yummy-adults-hope.md create mode 100644 packages/cli/src/remote-registry.ts create mode 100644 packages/cli/tests/remote-registry.test.ts diff --git a/.changeset/yummy-adults-hope.md b/.changeset/yummy-adults-hope.md new file mode 100644 index 00000000..52b43670 --- /dev/null +++ b/.changeset/yummy-adults-hope.md @@ -0,0 +1,5 @@ +--- +"@varo-ui/cli": major +--- + +Support third-party local and HTTP registries and self-contained shadcn-vue exports. Registry resolution now returns a Promise; await resolveRegistryItems(). diff --git a/apps/docs/blocks/build-your-own.md b/apps/docs/blocks/build-your-own.md index a5c36c39..7abad90c 100644 --- a/apps/docs/blocks/build-your-own.md +++ b/apps/docs/blocks/build-your-own.md @@ -259,7 +259,7 @@ pnpm test # 打包 CLI 后在临时目录验证安装 pnpm --filter @varo-ui/cli build -pnpm dlx @varo-ui/cli add --target weapp blocks/status-filter +pnpm dlx @varo-ui/cli add --registry ./registry --target weapp blocks/status-filter ``` 确认: @@ -269,8 +269,37 @@ pnpm dlx @varo-ui/cli add --target weapp blocks/status-filter - 需要覆盖时使用显式 force 流程 - 安装结果不带私有域名、token、内部任务号 +### 独立发布第三方 Registry + +不必先贡献回 Varo。把 `registry/` 目录及其所有传递依赖的 manifest 和源码部署到自己的静态站点,即可从业务项目安装: + +```bash +pnpm dlx @varo-ui/cli add --registry https://ui.example.com/registry/ --target weapp blocks/status-filter +``` + +CLI 从该地址下的 `blocks/status-filter/registry.json` 读取元数据,按 `files.from` 去掉 `registry/` 前缀后的路径下载源码。普通和 target-specific 的 `registryDependencies` 都在同一个 Registry 内解析;缺失依赖会报错,不会回退到官方 Registry。也可以用 `--registry ./registry` 验证本地目录。 + +只安装信任的源码。远端地址只支持 HTTP(S),不能携带凭证、查询参数或 fragment,不跟随重定向;单个响应上限为 10 MiB、30 秒。安装保留 `src/` 路径限制、符号链接检查、文件冲突检查和失败回滚,不自动安装 npm 依赖。 + +### 供 shadcn-vue 生态安装 + +导出一个条目时会内联当前 target 的全部传递依赖和源码,生成符合 [shadcn-vue Registry 协议](https://www.shadcn-vue.com/docs/registry/registry-item-json) 的 JSON: + +```bash +mkdir -p public/r +pnpm dlx @varo-ui/cli export --registry ./registry --target weapp blocks/status-filter > public/r/status-filter.json +# 部署 public/ 后,在已配置 components.json 和 TypeScript 路径别名的消费项目执行: +pnpm dlx shadcn-vue@latest add https://ui.example.com/r/status-filter.json +``` + +输出采用 `registry:file`、内联 `content` 和显式 `~/src/...` 安装路径,可由 shadcn-vue 以及委托它安装的生态工具消费;已用 shadcn-vue 2.8.2 验证。Varo 的 `add --registry` 读取原生 Varo manifest,导出 JSON 则由 shadcn-vue 安装。 + +H5 与 Weapp 必须分别导出。`meta.varo.target` 记录目标,但第三方安装器不会替你检查运行时,也不会把 Vue 转成 Wevu;消费工程仍需安装匹配的依赖并接入主题。CLI API 调用方须使用 `await resolveRegistryItems(...)`,现在本地与远端解析均返回 Promise。 + ## 10. 贡献回 Varo +这是可选的上游贡献流程,不是发布第三方 Registry 的前置条件。 + 提交前过一遍隐私与可移植清单: - [ ] 无真实 API / 凭证 / 私有 URL diff --git a/apps/docs/en/blocks/build-your-own.md b/apps/docs/en/blocks/build-your-own.md index bd36bb81..3fc26f65 100644 --- a/apps/docs/en/blocks/build-your-own.md +++ b/apps/docs/en/blocks/build-your-own.md @@ -259,7 +259,7 @@ pnpm test # pack the CLI and install into a temporary fixture pnpm --filter @varo-ui/cli build -pnpm dlx @varo-ui/cli add --target weapp blocks/status-filter +pnpm dlx @varo-ui/cli add --registry ./registry --target weapp blocks/status-filter ``` Confirm: @@ -269,8 +269,37 @@ Confirm: - overwrite requires an explicit force path - installed files contain no private domains, tokens, or internal IDs +### Publish an independent registry + +No upstream contribution is required. Host the `registry/` directory, including manifests and source for every transitive dependency, on your own static site: + +```bash +pnpm dlx @varo-ui/cli add --registry https://ui.example.com/registry/ --target weapp blocks/status-filter +``` + +The CLI loads `blocks/status-filter/registry.json` below that URL. Source URLs use `files.from` with the leading `registry/` removed. Both ordinary and target-specific `registryDependencies` resolve within the selected registry; missing dependencies fail rather than falling back to Varo's bundled registry. Use `--registry ./registry` to verify a local directory. + +Only install sources you trust. Remote roots use HTTP(S), cannot contain credentials, queries, or fragments, and do not follow redirects. Each response is limited to 10 MiB and 30 seconds. Installs retain `src/` confinement, symlink checks, collision checks, and rollback. npm dependencies are reported, not installed. + +### Install through the shadcn-vue ecosystem + +Exporting one item inlines its selected target's complete dependency closure and source into a [shadcn-vue registry item](https://www.shadcn-vue.com/docs/registry/registry-item-json): + +```bash +mkdir -p public/r +pnpm dlx @varo-ui/cli export --registry ./registry --target weapp blocks/status-filter > public/r/status-filter.json +# Host public/, then run in a consumer configured with components.json and TypeScript path aliases: +pnpm dlx shadcn-vue@latest add https://ui.example.com/r/status-filter.json +``` + +The payload uses `registry:file`, inline `content`, and explicit `~/src/...` destinations. shadcn-vue and tools delegating installation to it can consume this JSON; compatibility was exercised with shadcn-vue 2.8.2. Varo's `add --registry` reads native Varo manifests; exported JSON is installed with shadcn-vue. + +Export H5 and Weapp separately. `meta.varo.target` records the target, but external installers do not enforce it or convert Vue to Wevu. Consumers still need matching runtime dependencies and theme setup. Programmatic CLI consumers must use `await resolveRegistryItems(...)`: both local and remote resolution now return a Promise. + ## 10. Contribute to Varo +Upstream contribution is optional, not a prerequisite for publishing a third-party registry. + Privacy and portability checklist: - [ ] no real APIs / credentials / private URLs diff --git a/apps/docs/en/guide/installation.md b/apps/docs/en/guide/installation.md index 498cf5c3..94e50e8b 100644 --- a/apps/docs/en/guide/installation.md +++ b/apps/docs/en/guide/installation.md @@ -44,6 +44,8 @@ Components land in `src/components/ui/*`; blocks land in `src/components/blocks/ The H5 registry covers all 56 runtime component families. The mini-program registry covers 45 high-consensus families. Copy-owned mini-program renderers ship as target-specific native Wevu SFCs that compile directly to WXML/WXSS/JSON; pure adapters may re-export target primitives, and only types, pure functions, and headless primitives are shared across targets. +Third-party components do not need to be merged upstream: use `add --registry ` to install an independent registry. Authors can also use `export --target h5|weapp ` to generate JSON for shadcn-vue. See [Publish an independent registry](/en/blocks/build-your-own#publish-an-independent-registry) for layouts, publishing commands, and runtime boundaries. + ## Agent streaming `@varo-ui/ai` is model-provider neutral. A backend emits `message.start`, `text.delta`, `reasoning.*`, `tool.*`, `approval.*`, `message.end`, and `done` events. H5 can connect Fetch/SSE; a mini program can feed `wx.request({ enableChunked: true })` chunks into `createAgentSseEventSource()`. diff --git a/apps/docs/guide/installation.md b/apps/docs/guide/installation.md index c99a06db..105add75 100644 --- a/apps/docs/guide/installation.md +++ b/apps/docs/guide/installation.md @@ -44,6 +44,8 @@ pnpm dlx @varo-ui/cli add --target h5 button select card components/agent-ui H5 Registry 覆盖 56 个 runtime 组件族;小程序 Registry 覆盖 45 个高共识组件族。copy-owned 小程序 renderer 均以 target-specific 原生 Wevu SFC 交付并直接编译为 WXML/WXSS/JSON;纯 adapter 可重导出目标 primitives,双端只共享类型、纯函数和 headless primitives。 +第三方组件无需先合并到 Varo:用 `add --registry <本地目录或 HTTP(S) 地址>` 安装独立 Registry。作者也可以用 `export --target h5|weapp <条目>` 生成供 shadcn-vue 安装的 JSON。完整目录约定、发布命令和运行时边界见 [独立发布第三方 Registry](/blocks/build-your-own#独立发布第三方-registry)。 + ## Agent 流式接入 `@varo-ui/ai` 不绑定模型厂商。服务端只需输出 `message.start`、`text.delta`、`reasoning.*`、`tool.*`、`approval.*`、`message.end` 与 `done` 事件;H5 可接 Fetch/SSE,小程序可把 `wx.request({ enableChunked: true })` 的分块交给 `createAgentSseEventSource()`。 diff --git a/packages/cli/README.md b/packages/cli/README.md index 08256ab6..4182a2b4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,4 +11,46 @@ pnpm dlx @varo-ui/cli add --target weapp button input card The CLI copies source into your project. It does not hide rendering behind a cross-platform runtime. Existing files are preserved unless `--force` is provided. +## Third-party registries + +Install from an independently maintained Varo Registry without contributing it upstream: + +```bash +pnpm dlx @varo-ui/cli add --registry ./registry --target h5 blocks/my-block +pnpm dlx @varo-ui/cli add --registry https://ui.example.com/registry/ --target weapp blocks/my-block +``` + +`--registry` selects a local directory or an HTTP(S) root with the same layout as Varo's authored `registry/`. For example, `blocks/my-block` loads `blocks/my-block/registry.json` below that root. A file's `from: "registry/blocks/my-block/weapp-vite.vue"` loads `blocks/my-block/weapp-vite.vue` below the same root. All transitive `registryDependencies`, including target-specific dependencies, resolve there; include those manifests and sources in your registry. There is no fallback to the bundled registry. + +Only install sources you trust. Remote roots cannot contain credentials, queries, or fragments; redirects are rejected. Each HTTP response is limited to 10 MiB and 30 seconds. File destinations remain confined to the project's `src/`, with the same symlink, collision, no-clobber, and rollback protections as bundled installs. npm dependencies are reported, not installed. + +## Export for shadcn-vue + +Export one item and its selected target's complete dependency closure as a self-contained [shadcn-vue registry item](https://www.shadcn-vue.com/docs/registry/registry-item-json): + +```bash +mkdir -p public/r +pnpm dlx @varo-ui/cli export --registry ./registry --target h5 blocks/my-block > public/r/my-block.json +# Serve public/ on your own static host, then run in an initialized shadcn-vue project: +pnpm dlx shadcn-vue@latest add https://ui.example.com/r/my-block.json +``` + +The JSON uses `registry:file`, inline `content`, and explicit `~/src/...` targets so shadcn-vue preserves Varo's installation paths. Transitive files and npm dependencies are included; no Varo-specific dependency names remain for the external installer to resolve. Use separate exports for H5 and Weapp. The export records `meta.varo.target`, but external installers do not enforce Varo's runtime choice: use the matching project, runtime packages, and theme setup. This is registry-protocol compatibility, not Vue-to-Wevu conversion. + +Compatibility was exercised with shadcn-vue 2.8.2 in a project with `components.json` and a TypeScript path alias configuration. Other tools that delegate to shadcn-vue can consume the same hosted JSON. Varo's own `add --registry` reads Varo manifests; use shadcn-vue to install the exported JSON. + +## Programmatic API + +`resolveRegistryItems()` is asynchronous for both local and remote registries. Existing synchronous consumers must add `await`; failures reject the returned Promise. `PlannedRegistryFile.sourcePath` is a local absolute path or an HTTP(S) URL. + +```ts +import { exportRegistryItem, resolveRegistryItems } from '@varo-ui/cli' + +const plan = await resolveRegistryItems(['button'], { target: 'h5' }) +const payload = await exportRegistryItem('blocks/my-block', { + registryRoot: 'https://ui.example.com/registry/', + target: 'h5', +}) +``` + [Installation guide](https://daguanren21.github.io/Varo/guide/installation) · [Repository](https://github.com/daguanren21/Varo) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bc18ea60..2db0335b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,12 +1,14 @@ #!/usr/bin/env node import type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry' -import { validateRegistryItem } from '@varo/registry/source' +import type { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' -import { mkdir, open, readFile, rename, rm, rmdir } from './file-system.ts' import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' +import { validateRegistryItem } from '@varo/registry/source' +import { mkdir, open, readFile, rename, rm, rmdir } from './file-system.ts' +import { fetchRegistryFile, getRemoteRegistryRoot, registryUrl } from './remote-registry.ts' export type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry' @@ -58,29 +60,35 @@ function normalizeRegistryName(name: string): string { return normalized } -function resolveRegistryItem(name: string, registryRoot: string): RegistryItem { +async function resolveRegistryItem(name: string, registryRoot: string, remoteRoot?: URL): Promise { const normalizedName = normalizeRegistryName(name) - const unresolvedPath = resolve(registryRoot, normalizedName, 'registry.json') - if (!existsSync(unresolvedPath)) { + const unresolvedPath = remoteRoot + ? registryUrl(remoteRoot, `${normalizedName}/registry.json`) + : resolve(registryRoot, normalizedName, 'registry.json') + if (!remoteRoot && !existsSync(unresolvedPath)) { throw new Error(`Unknown registry item: ${name}`) } - const canonicalRegistryRoot = realpathSync(registryRoot) - const path = realpathSync(unresolvedPath) - if (!isWithinRoot(canonicalRegistryRoot, path)) { - throw new Error( - `Invalid registry item ${normalizedName} at ${unresolvedPath}: manifest is outside the registry root`, - ) - } - if (!lstatSync(path).isFile()) { - throw new Error( - `Invalid registry item ${normalizedName} at ${unresolvedPath}: manifest must be a regular file`, - ) + let path = unresolvedPath + if (!remoteRoot) { + const canonicalRegistryRoot = realpathSync(registryRoot) + path = realpathSync(unresolvedPath) + if (!isWithinRoot(canonicalRegistryRoot, path)) { + throw new Error( + `Invalid registry item ${normalizedName} at ${unresolvedPath}: manifest is outside the registry root`, + ) + } + if (!lstatSync(path).isFile()) { + throw new Error( + `Invalid registry item ${normalizedName} at ${unresolvedPath}: manifest must be a regular file`, + ) + } } + const bytes = remoteRoot ? await fetchRegistryFile(path) : readFileSync(path) let input: unknown try { - input = JSON.parse(readFileSync(path, 'utf8')) as unknown + input = JSON.parse(bytes.toString('utf8')) as unknown } catch (error) { const reason = error instanceof Error ? error.message : String(error) @@ -155,15 +163,16 @@ function resolveProjectTarget(canonicalRoot: string, to: string): string { return targetPath } -export function resolveRegistryItems(names: string[], options: ResolveRegistryOptions = {}): RegistryInstallPlan { +export async function resolveRegistryItems(names: string[], options: ResolveRegistryOptions = {}): Promise { const registryRoot = options.registryRoot ?? defaultRegistryRoot + const remoteRoot = getRemoteRegistryRoot(registryRoot) const target = options.target ?? 'weapp' const items: RegistryItem[] = [] const seen = new Set() const visiting = new Set() const dependencyStack: string[] = [] - function visit(requestName: string) { + async function visit(requestName: string) { const itemPathName = normalizeRegistryName(requestName) if (seen.has(itemPathName)) { return } if (visiting.has(itemPathName)) { @@ -174,12 +183,14 @@ export function resolveRegistryItems(names: string[], options: ResolveRegistryOp visiting.add(itemPathName) dependencyStack.push(itemPathName) - const item = resolveRegistryItem(requestName, registryRoot) + const item = await resolveRegistryItem(requestName, registryRoot, remoteRoot) try { if (!item.targets.includes(target)) { throw new Error(`Registry item ${itemPathName} does not support target ${target}`) } - [...item.registryDependencies, ...(item.targetRegistryDependencies?.[target] ?? [])].forEach(visit) + for (const dependency of [...item.registryDependencies, ...(item.targetRegistryDependencies?.[target] ?? [])]) { + await visit(dependency) + } } finally { dependencyStack.pop() @@ -190,7 +201,9 @@ export function resolveRegistryItems(names: string[], options: ResolveRegistryOp items.push(item) } - names.forEach(visit) + for (const name of names) { + await visit(name) + } const dependencies = Array.from( new Set(items.flatMap(item => [...(item.dependencies ?? []), ...(item.targetDependencies?.[target] ?? [])])), @@ -204,7 +217,9 @@ export function resolveRegistryItems(names: string[], options: ResolveRegistryOp .map(file => ({ ...file, item: item.name, - sourcePath: resolveRegistrySource(registryRoot, file.from), + sourcePath: remoteRoot + ? registryUrl(remoteRoot, file.from.slice('registry/'.length)) + : resolveRegistrySource(registryRoot, file.from), targetPath: file.to, })), ) @@ -212,8 +227,64 @@ export function resolveRegistryItems(names: string[], options: ResolveRegistryOp return { dependencies, devDependencies, files, items, target } } +async function readRegistryFile(file: PlannedRegistryFile): Promise { + return /^https?:\/\//.test(file.sourcePath) + ? fetchRegistryFile(file.sourcePath) + : readFile(file.sourcePath) +} + +function assertUniqueTargets(targets: { file: RegistryFile, targetIdentity: string }[]) { + const seen = new Set() + for (const { file, targetIdentity } of targets) { + if (seen.has(targetIdentity)) { + throw new Error(`Registry items target the same file: ${file.to}`) + } + seen.add(targetIdentity) + } +} + +export interface ShadcnRegistryItem { + $schema: string + name: string + type: 'registry:file' + title: string + description: string + dependencies: string[] + devDependencies: string[] + registryDependencies: string[] + files: { path: string, type: 'registry:file', target: string, content: string }[] + meta: { varo: { target: RegistryTarget } } +} + +export async function exportRegistryItem(name: string, options: ResolveRegistryOptions = {}): Promise { + const plan = await resolveRegistryItems([name], options) + const item = plan.items[plan.items.length - 1]! + assertUniqueTargets(plan.files.map(file => ({ + file, + targetIdentity: file.to.normalize('NFC').toLowerCase().normalize('NFC'), + }))) + return { + $schema: 'https://shadcn-vue.com/schema/registry-item.json', + name: item.name, + // Universal files preserve Varo's destinations without a shadcn style or framework preset. + type: 'registry:file', + title: item.title, + description: item.description, + dependencies: plan.dependencies, + devDependencies: plan.devDependencies, + registryDependencies: [], + files: await Promise.all(plan.files.map(async file => ({ + path: file.to, + type: 'registry:file' as const, + target: `~/${file.to}`, + content: (await readRegistryFile(file)).toString('utf8'), + }))), + meta: { varo: { target: plan.target } }, + } +} + export async function installRegistryItems(names: string[], options: InstallRegistryOptions): Promise { - const plan = resolveRegistryItems(names, options) + const plan = await resolveRegistryItems(names, options) const canonicalProjectRoot = realpathSync(options.projectRoot) const plannedTargets = plan.files.map((file) => { const targetPath = resolveProjectTarget(canonicalProjectRoot, file.to) @@ -226,20 +297,14 @@ export async function installRegistryItems(names: string[], options: InstallRegi targetPath, } }) - const seenTargetIdentities = new Set() - for (const plannedTarget of plannedTargets) { - const { file, hadOriginal, targetIdentity } = plannedTarget - if (seenTargetIdentities.has(targetIdentity)) { - throw new Error(`Registry items target the same file: ${file.to}`) - } - seenTargetIdentities.add(targetIdentity) - + assertUniqueTargets(plannedTargets) + for (const { file, hadOriginal } of plannedTargets) { if (hadOriginal && !options.force) { throw new Error(`Refusing to overwrite existing file: ${file.to}`) } } - const sourceBytes = await Promise.all(plannedTargets.map(({ file }) => readFile(file.sourcePath))) + const sourceBytes = await Promise.all(plannedTargets.map(({ file }) => readRegistryFile(file))) const commitStates = plannedTargets.map((plannedTarget, index) => ({ ...plannedTarget, backupPath: plannedTarget.hadOriginal @@ -367,6 +432,7 @@ async function runCli(argv: string[]) { const [command, ...args] = argv let force = false let target: RegistryTarget = 'weapp' + let registryRoot: string | undefined const items: string[] = [] for (let index = 0; index < args.length; index += 1) { @@ -395,6 +461,15 @@ async function runCli(argv: string[]) { continue } + if (arg === '--registry' || arg.startsWith('--registry=')) { + const value = arg === '--registry' ? args[++index] : arg.slice('--registry='.length) + if (!value || value.startsWith('--')) { + throw new Error('Missing registry directory or URL') + } + registryRoot = value + continue + } + if (arg.startsWith('--')) { throw new Error(`Unknown option: ${arg}`) } @@ -402,17 +477,28 @@ async function runCli(argv: string[]) { items.push(arg) } - if (command !== 'add' || items.length === 0) { + if ((command !== 'add' && command !== 'export') || items.length === 0) { process.stderr.write( - 'Usage: varo add [--target h5|weapp] [--force] [...items]\n', + 'Usage: varo add [--registry directory|url] [--target h5|weapp] [--force] [...items]\n' + + ' varo export [--registry directory|url] [--target h5|weapp] \n', ) process.exitCode = 1 return } + if (command === 'export') { + if (items.length !== 1 || force) { + throw new Error('Export requires exactly one registry item and does not accept --force') + } + const item = await exportRegistryItem(items[0]!, { registryRoot, target }) + process.stdout.write(`${JSON.stringify(item, null, 2)}\n`) + return + } + const plan = await installRegistryItems(items, { force, projectRoot: process.cwd(), + registryRoot, target, }) const output = [`Installed ${plan.items.map(item => item.name).join(', ')} for ${plan.target}`] diff --git a/packages/cli/src/remote-registry.ts b/packages/cli/src/remote-registry.ts new file mode 100644 index 00000000..3e82c714 --- /dev/null +++ b/packages/cli/src/remote-registry.ts @@ -0,0 +1,53 @@ +import { Buffer } from 'node:buffer' + +const maximumResponseBytes = 10 * 1024 * 1024 + +export function getRemoteRegistryRoot(root: string): URL | undefined { + // A drive-letter path is local; other explicit schemes must be HTTP(S). + if (!/^[a-z][a-z\d+.-]*:/i.test(root) || /^[a-z]:[\\/]/i.test(root)) { return } + const url = new URL(root) + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('Remote registry must use HTTP or HTTPS') + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('Registry URL must not contain credentials, query parameters, or a fragment') + } + if (!url.pathname.endsWith('/')) { url.pathname += '/' } + return url +} + +export function registryUrl(root: URL, path: string): string { + // Manifest paths are validated before use. Encode literal URL delimiters in file names. + return new URL(path.split('/').map(encodeURIComponent).join('/'), root).href +} + +export async function fetchRegistryFile(url: string): Promise { + const response = await fetch(url, { + redirect: 'manual', + signal: AbortSignal.timeout(30_000), + }) + if (!response.ok || !response.body) { + await response.body?.cancel() + throw new Error(`Failed to fetch registry file ${url}: HTTP ${response.status}; redirects are not followed`) + } + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let length = 0 + try { + while (true) { + const { done, value } = await reader.read() + if (done) { break } + length += value.byteLength + if (length > maximumResponseBytes) { + throw new Error(`Registry response exceeds 10 MiB: ${url}`) + } + chunks.push(value) + } + } + finally { + await reader.cancel() + reader.releaseLock() + } + return Buffer.concat(chunks, length) +} diff --git a/packages/cli/tests/add.test.ts b/packages/cli/tests/add.test.ts index 645e66e6..59412387 100644 --- a/packages/cli/tests/add.test.ts +++ b/packages/cli/tests/add.test.ts @@ -121,8 +121,8 @@ function writeRegistryItem( } describe('varo add targets', () => { - it('resolves H5 dependencies and files before the requested component', () => { - const plan = resolveRegistryItems(['button'], { registryRoot, target: 'h5' }) + it('resolves H5 dependencies and files before the requested component', async () => { + const plan = await resolveRegistryItems(['button'], { registryRoot, target: 'h5' }) expect(plan.target).toBe('h5') expect(plan.items.map(item => item.name)).toEqual(['base', 'cn', 'primitives', 'button']) @@ -138,8 +138,8 @@ describe('varo add targets', () => { expect(plan.dependencies).not.toContain('@weapp-tailwindcss/merge') }) - it('resolves mini-program-specific runtime and merge packages by default', () => { - const plan = resolveRegistryItems(['button'], { registryRoot }) + it('resolves mini-program-specific runtime and merge packages by default', async () => { + const plan = await resolveRegistryItems(['button'], { registryRoot }) expect(plan.target).toBe('weapp') expect(plan.dependencies).toEqual( @@ -156,18 +156,18 @@ describe('varo add targets', () => { expect(plan.dependencies).not.toContain('vue') }) - it('resolves target-specific registry dependencies without copying H5 helpers into weapp', () => { - const h5 = resolveRegistryItems(['checkbox'], { registryRoot, target: 'h5' }) - const weapp = resolveRegistryItems(['checkbox'], { registryRoot, target: 'weapp' }) + it('resolves target-specific registry dependencies without copying H5 helpers into weapp', async () => { + const h5 = await resolveRegistryItems(['checkbox'], { registryRoot, target: 'h5' }) + const weapp = await resolveRegistryItems(['checkbox'], { registryRoot, target: 'weapp' }) expect(h5.items.map(item => item.name)).toEqual(['base', 'icon', 'primitives', 'selection', 'checkbox']) expect(weapp.items.map(item => item.name)).toEqual(['base', 'cn', 'icon', 'primitives', 'checkbox']) expect(weapp.files.map(file => file.to)).toContain('src/components/ui/v-checkbox.vue') expect(weapp.files.map(file => file.to)).not.toContain('src/components/ui/selection.ts') }) - it('installs one shadcn Form entry with target-owned renderers', () => { - const h5 = resolveRegistryItems(['form'], { registryRoot, target: 'h5' }) - const weapp = resolveRegistryItems(['form'], { registryRoot, target: 'weapp' }) + it('installs one shadcn Form entry with target-owned renderers', async () => { + const h5 = await resolveRegistryItems(['form'], { registryRoot, target: 'h5' }) + const weapp = await resolveRegistryItems(['form'], { registryRoot, target: 'weapp' }) expect(h5.files.map(file => file.to)).toEqual([ 'src/styles/varo.css', @@ -257,7 +257,7 @@ describe('varo add targets', () => { expect(existsSync(join(projectRoot, 'src/components/agent-ui/AgentShell.vue'))).toBe(true) }) - it('reports unsupported CLI and registry targets clearly', () => { + it('reports unsupported CLI and registry targets clearly', async () => { projectRoot = mkdtempSync(join(tmpdir(), 'varo-cli-')) const binPath = join(projectRoot, 'varo-cli.ts') symlinkSync(resolve(workspaceRoot, 'packages/cli/src/index.ts'), binPath) @@ -279,15 +279,15 @@ describe('varo add targets', () => { ).toThrow(/Unsupported registry target: weapp-vite/) const fixtureRegistry = writeRegistryItem(projectRoot, 'components/weapp-only', { targets: ['weapp'] }) - expect(() => resolveRegistryItems(['weapp-only'], { registryRoot: fixtureRegistry, target: 'h5' })).toThrow( + await expect(resolveRegistryItems(['weapp-only'], { registryRoot: fixtureRegistry, target: 'h5' })).rejects.toThrow( 'Registry item components/weapp-only does not support target h5', ) }) }) describe('varo add safety', () => { - it('reports unknown registry items with the original request name', () => { - expect(() => resolveRegistryItems(['components/not-found'], { registryRoot })).toThrow( + it('reports unknown registry items with the original request name', async () => { + await expect(resolveRegistryItems(['components/not-found'], { registryRoot })).rejects.toThrow( 'Unknown registry item: components/not-found', ) }) @@ -324,10 +324,10 @@ describe('varo add safety', () => { const consumerRoot = join(projectRoot, 'consumer') mkdirSync(consumerRoot) - expect(() => resolveRegistryItems(['blocks/../../outside'], { registryRoot: fixtureRegistry })).toThrow( + await expect(resolveRegistryItems(['blocks/../../outside'], { registryRoot: fixtureRegistry })).rejects.toThrow( 'Invalid registry item name: blocks/../../outside', ) - expect(() => resolveRegistryItems(['source-escape'], { registryRoot: fixtureRegistry })).toThrow( + await expect(resolveRegistryItems(['source-escape'], { registryRoot: fixtureRegistry })).rejects.toThrow( 'file.from must start with registry/: ../outside.ts', ) await expect( @@ -336,7 +336,7 @@ describe('varo add safety', () => { expect(existsSync(join(projectRoot, 'outside.ts'))).toBe(false) }) - it('reports cyclic registry dependencies with their chain', () => { + it('reports cyclic registry dependencies with their chain', async () => { projectRoot = mkdtempSync(join(tmpdir(), 'varo-cli-')) const fixtureRegistry = writeRegistryItem(projectRoot, 'components/alpha', { registryDependencies: ['components/beta'], @@ -345,7 +345,7 @@ describe('varo add safety', () => { registryDependencies: ['components/alpha'], }) - expect(() => resolveRegistryItems(['alpha'], { registryRoot: fixtureRegistry })).toThrow( + await expect(resolveRegistryItems(['alpha'], { registryRoot: fixtureRegistry })).rejects.toThrow( 'Cyclic registry dependency: components/alpha -> components/beta -> components/alpha', ) }) @@ -427,11 +427,11 @@ describe('varo add safety', () => { 'src/components/ui /alpha.ts', 'src/components/ui/victim.ts:stream', 'src/components/ui/CON.txt', - ])('rejects non-portable target path %s', (target) => { + ])('rejects non-portable target path %s', async (target) => { projectRoot = mkdtempSync(join(tmpdir(), 'varo-cli-')) const fixtureRegistry = writeRegistryItem(projectRoot, 'components/alpha', { to: target }) - expect(() => resolveRegistryItems(['alpha'], { registryRoot: fixtureRegistry })).toThrow( + await expect(resolveRegistryItems(['alpha'], { registryRoot: fixtureRegistry })).rejects.toThrow( `file.to must use portable path segments: ${target}`, ) }) @@ -465,7 +465,7 @@ describe('varo add safety', () => { expect(readFileSync(packagePath, 'utf8')).toBe('{ "name": "consumer" }\n') }) - it('validates malformed custom registry manifests with item and path context', () => { + it('validates malformed custom registry manifests with item and path context', async () => { projectRoot = mkdtempSync(join(tmpdir(), 'varo-cli-')) const fixtureRegistry = writeRegistryItem(projectRoot, 'components/malformed') writeFileSync( @@ -473,12 +473,12 @@ describe('varo add safety', () => { JSON.stringify({ files: 'malformed', name: 'malformed' }), ) - expect(() => resolveRegistryItems(['malformed'], { registryRoot: fixtureRegistry })).toThrow( + await expect(resolveRegistryItems(['malformed'], { registryRoot: fixtureRegistry })).rejects.toThrow( /Invalid registry item components\/malformed at .*registry\.json: .*files must be an array/, ) }) - it('rejects registry manifests that resolve outside the registry root before parsing them', () => { + it('rejects registry manifests that resolve outside the registry root before parsing them', async () => { projectRoot = mkdtempSync(join(tmpdir(), 'varo-cli-')) const fixtureRegistry = writeRegistryItem(projectRoot, 'components/escape') const outsideManifest = join(projectRoot, 'outside-registry.json') @@ -487,7 +487,7 @@ describe('varo add safety', () => { rmSync(manifestPath) symlinkSync(outsideManifest, manifestPath) - expect(() => resolveRegistryItems(['escape'], { registryRoot: fixtureRegistry })).toThrow( + await expect(resolveRegistryItems(['escape'], { registryRoot: fixtureRegistry })).rejects.toThrow( `Invalid registry item components/escape at ${manifestPath}: manifest is outside the registry root`, ) }) diff --git a/packages/cli/tests/remote-registry.test.ts b/packages/cli/tests/remote-registry.test.ts new file mode 100644 index 00000000..576d7596 --- /dev/null +++ b/packages/cli/tests/remote-registry.test.ts @@ -0,0 +1,201 @@ +import type { Server } from 'node:http' +// @vitest-environment node +import type { RegistryItem } from '../src/index.ts' +import { execFile } from 'node:child_process' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import { exportRegistryItem, installRegistryItems, resolveRegistryItems } from '../src/index.ts' + +const execute = promisify(execFile) +const cli = resolve(__dirname, '../src/index.ts') +const roots: string[] = [] +const servers: Server[] = [] + +function temporaryProject() { + const root = mkdtempSync(join(tmpdir(), 'varo-third-party-')) + roots.push(root) + return root +} + +function item(name: string, overrides: Partial = {}): RegistryItem { + return { + name, + type: 'component', + title: name, + description: `${name} from a third party`, + docs: `/components/${name}`, + targets: ['h5', 'weapp'], + registryDependencies: [], + files: ['h5', 'weapp'].map(target => ({ + target: target as 'h5' | 'weapp', + from: `registry/components/${name}/${target}.ts`, + to: `src/components/${name}.ts`, + })), + ...overrides, + } +} + +async function serve(routes: Record) { + const requests: string[] = [] + const server = createServer((request, response) => { + const path = request.url! + requests.push(path) + const route = routes[path] + if (route === undefined) { response.writeHead(404).end(); return } + if (typeof route === 'string') { response.end(route); return } + response.writeHead(route.status, route.location ? { location: route.location } : {}).end() + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { throw new Error('No HTTP fixture address') } + return { requests, url: `http://127.0.0.1:${address.port}` } +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()) + server.closeAllConnections() + }))) + for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } +}) + +describe('third-party registries', () => { + it('installs from the selected HTTP root, scopes dependencies there, and encodes source paths', async () => { + const custom = item('custom', { + registryDependencies: ['helper'], + files: [{ target: 'h5', from: 'registry/components/custom/组件 +@[demo].ts', to: 'src/components/custom.ts' }], + targets: ['h5'], + targetDependencies: { h5: ['vue'], weapp: ['wevu'] }, + targetDevDependencies: { h5: ['typescript'] }, + }) + const { url, requests } = await serve({ + '/nested/registry/components/custom/registry.json': JSON.stringify(custom), + '/nested/registry/components/helper/registry.json': JSON.stringify(item('helper')), + '/nested/registry/components/helper/h5.ts': 'export const helper = 42\n', + '/nested/registry/components/custom/%E7%BB%84%E4%BB%B6%20%2B%40%5Bdemo%5D.ts': 'export { helper } from "./helper"\n', + }) + const projectRoot = temporaryProject() + const { stdout } = await execute(process.execPath, [ + cli, + 'add', + '--registry', + `${url}/nested/registry`, + '--target=h5', + 'custom', + 'helper', + ], { cwd: projectRoot }) + expect(readFileSync(join(projectRoot, 'src/components/custom.ts'), 'utf8')).toBe('export { helper } from "./helper"\n') + expect(readFileSync(join(projectRoot, 'src/components/helper.ts'), 'utf8')).toBe('export const helper = 42\n') + expect(stdout).toContain('Dependencies: vue') + expect(stdout).toContain('Dev dependencies: typescript') + expect(stdout).not.toContain('wevu') + expect(requests.filter(path => path.endsWith('/helper/registry.json'))).toHaveLength(1) + expect(readdirSync(projectRoot)).toEqual(['src']) + }) + + it('rejects remote cycles before fetching source or writing consumer files', async () => { + const { url, requests } = await serve({ + '/components/alpha/registry.json': JSON.stringify(item('alpha', { registryDependencies: ['beta'] })), + '/components/beta/registry.json': JSON.stringify(item('beta', { registryDependencies: ['alpha'] })), + }) + const projectRoot = temporaryProject() + await expect(installRegistryItems(['alpha'], { registryRoot: url, projectRoot })).rejects.toThrow(/Cyclic registry dependency/) + expect(requests).toHaveLength(2) + expect(readdirSync(projectRoot)).toEqual([]) + }) + + it('does not replace existing files when any remote source fails to download', async () => { + const { url } = await serve({ + '/components/alpha/registry.json': JSON.stringify(item('alpha', { registryDependencies: ['beta'] })), + '/components/beta/registry.json': JSON.stringify(item('beta')), + '/components/beta/h5.ts': 'replacement\n', + '/components/alpha/h5.ts': { status: 503 }, + }) + const projectRoot = temporaryProject() + const existing = join(projectRoot, 'src/components/beta.ts') + mkdirSync(dirname(existing), { recursive: true }) + writeFileSync(existing, 'consumer customization\n') + await expect(installRegistryItems(['alpha'], { + registryRoot: url, + projectRoot, + target: 'h5', + force: true, + })).rejects.toThrow(/HTTP 503/) + expect(readFileSync(existing, 'utf8')).toBe('consumer customization\n') + expect(readdirSync(dirname(existing))).toEqual(['beta.ts']) + }) + + it('rejects remote traversal before fetching an escaped source', async () => { + const { url, requests } = await serve({ + '/components/escape/registry.json': JSON.stringify(item('escape', { + targets: ['h5'], + files: [{ target: 'h5', from: 'registry/../secret.ts', to: 'src/escape.ts' }], + })), + }) + const projectRoot = temporaryProject() + await expect(installRegistryItems(['escape'], { registryRoot: url, projectRoot, target: 'h5' })).rejects.toThrow(/file.from must stay within/) + expect(requests).toEqual(['/components/escape/registry.json']) + expect(readdirSync(projectRoot)).toEqual([]) + }) + + it('refuses redirects instead of following a registry outside its selected root', async () => { + const { url, requests } = await serve({ + '/components/redirect/registry.json': { status: 302, location: '/outside.json' }, + '/outside.json': JSON.stringify(item('redirect')), + }) + await expect(resolveRegistryItems(['redirect'], { registryRoot: url })).rejects.toThrow(/HTTP 302/) + expect(requests).toEqual(['/components/redirect/registry.json']) + }) + + it('rejects oversized remote payloads before creating consumer files', async () => { + const { url } = await serve({ + '/components/large/registry.json': JSON.stringify(item('large')), + '/components/large/weapp.ts': 'x'.repeat(10 * 1024 * 1024 + 1), + }) + const projectRoot = temporaryProject() + await expect(installRegistryItems(['large'], { registryRoot: url, projectRoot })).rejects.toThrow(/exceeds 10 MiB/) + expect(readdirSync(projectRoot)).toEqual([]) + }) + + it('exports self-contained target-specific shadcn payloads through the executable', async () => { + const root = temporaryProject() + const registryRoot = join(root, 'registry') + const custom = item('custom', { targetRegistryDependencies: { h5: ['helper'] } }) + for (const manifest of [custom, item('helper', { dependencies: ['vue'] })]) { + const directory = join(registryRoot, 'components', manifest.name) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'registry.json'), JSON.stringify(manifest)) + for (const target of ['h5', 'weapp']) { + writeFileSync(join(directory, `${target}.ts`), `export const ${manifest.name} = '${target}'\n`) + } + } + const { stdout } = await execute(process.execPath, [cli, 'export', `--registry=${registryRoot}`, '--target', 'h5', 'custom']) + const exported = JSON.parse(stdout) + expect(exported.registryDependencies).toEqual([]) + expect(exported.dependencies).toEqual(['vue']) + expect(exported.files.map((file: { target: string, content: string }) => [file.target, file.content])).toEqual([ + ['~/src/components/helper.ts', 'export const helper = \'h5\'\n'], + ['~/src/components/custom.ts', 'export const custom = \'h5\'\n'], + ]) + const weapp = await exportRegistryItem('custom', { registryRoot, target: 'weapp' }) + expect(weapp.files.map(file => file.content)).toEqual(['export const custom = \'weapp\'\n']) + expect(weapp.dependencies).toEqual([]) + const consumer = temporaryProject() + await execute(process.execPath, [cli, 'add', '--registry', registryRoot, '--target', 'weapp', 'custom'], { cwd: consumer }) + expect(readFileSync(join(consumer, 'src/components/custom.ts'), 'utf8')).toBe('export const custom = \'weapp\'\n') + }) + + it('rejects export collisions rather than leaving overwrites to external installers', async () => { + const file = { target: 'h5' as const, from: 'registry/shared.ts', to: 'src/shared.ts' } + const { url } = await serve({ + '/components/alpha/registry.json': JSON.stringify(item('alpha', { targets: ['h5'], files: [file], registryDependencies: ['beta'] })), + '/components/beta/registry.json': JSON.stringify(item('beta', { targets: ['h5'], files: [{ ...file, to: 'src/SHARED.ts' }] })), + }) + await expect(exportRegistryItem('alpha', { registryRoot: url, target: 'h5' })).rejects.toThrow(/target the same file/) + }) +}) From 062bdfbd91799aab291a97ada77085b152d269e8 Mon Sep 17 00:00:00 2001 From: daguanren21 <1363917353@qq.com> Date: Mon, 7 Sep 2026 11:19:01 +0800 Subject: [PATCH 2/6] fix(cli): resolve registry types before build Use the Registry source entry for types and emit eager bundled declarations. Verify cold typechecking and standalone consumer types without Registry dist artifacts. Refs #6 --- packages/cli/src/index.ts | 4 ++-- packages/cli/tsdown.config.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 packages/cli/tsdown.config.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2db0335b..d8a9b31c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry' +import type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry/source' import type { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' @@ -10,7 +10,7 @@ import { validateRegistryItem } from '@varo/registry/source' import { mkdir, open, readFile, rename, rm, rmdir } from './file-system.ts' import { fetchRegistryFile, getRemoteRegistryRoot, registryUrl } from './remote-registry.ts' -export type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry' +export type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry/source' export interface PlannedRegistryFile extends RegistryFile { item: string diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts new file mode 100644 index 00000000..13fb0e66 --- /dev/null +++ b/packages/cli/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + clean: true, + // Load source-only workspace types before bundling the runtime graph. + dts: { eager: true }, + deps: { + alwaysBundle: [/^@varo\/registry(?:\/|$)/], + }, + entry: ['src/index.ts'], + format: 'esm', + outDir: 'dist', + sourcemap: true, +}) From 4ee0adeb6bd147b762242992b3e0afe861023da1 Mon Sep 17 00:00:00 2001 From: daguanren21 <1363917353@qq.com> Date: Mon, 7 Sep 2026 11:46:43 +0800 Subject: [PATCH 3/6] fix(realworld): typecheck without prerequisite package builds Use an authored typecheck config with workspace theme type aliases. Keep public runtime imports and migration boundary checks unchanged. Refs #6 --- apps/realworld-weapp/package.json | 2 +- apps/realworld-weapp/tsconfig.json | 2 +- apps/realworld-weapp/tsconfig.typecheck.json | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 apps/realworld-weapp/tsconfig.typecheck.json diff --git a/apps/realworld-weapp/package.json b/apps/realworld-weapp/package.json index 48cd496b..906c888c 100644 --- a/apps/realworld-weapp/package.json +++ b/apps/realworld-weapp/package.json @@ -12,7 +12,7 @@ "postinstall": "wv prepare -p weapp", "prepare:weapp": "wv prepare -p weapp", "test": "vitest run --passWithNoTests", - "typecheck": "vue-tsc --noEmit -p .weapp-vite/tsconfig.app.json", + "typecheck": "vue-tsc --noEmit -p tsconfig.typecheck.json", "verify:migration": "node scripts/verify-migration.mjs" }, "dependencies": { diff --git a/apps/realworld-weapp/tsconfig.json b/apps/realworld-weapp/tsconfig.json index faa443b6..104ef662 100644 --- a/apps/realworld-weapp/tsconfig.json +++ b/apps/realworld-weapp/tsconfig.json @@ -1,7 +1,7 @@ { "references": [ { - "path": "./.weapp-vite/tsconfig.app.json" + "path": "./tsconfig.typecheck.json" }, { "path": "./.weapp-vite/tsconfig.server.json" diff --git a/apps/realworld-weapp/tsconfig.typecheck.json b/apps/realworld-weapp/tsconfig.typecheck.json new file mode 100644 index 00000000..c5cdd08c --- /dev/null +++ b/apps/realworld-weapp/tsconfig.typecheck.json @@ -0,0 +1,10 @@ +{ + "extends": "./.weapp-vite/tsconfig.app.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "weapp-vite/typed-components": ["./.weapp-vite/typed-components.d.ts"], + "@varo-ui/theme/weapp": ["../../packages/theme/src/weapp.ts"] + } + } +} From e54125a068052b5375f2db9645f0c816539acb13 Mon Sep 17 00:00:00 2001 From: daguanren21 <1363917353@qq.com> Date: Mon, 7 Sep 2026 11:55:59 +0800 Subject: [PATCH 4/6] fix(realworld): isolate unit tests from build configuration Keep native Wevu store tests independent of generated theme runtime artifacts. Resolve public theme imports to workspace sources only in the test runner. Refs #6 --- apps/realworld-weapp/vitest.config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/realworld-weapp/vitest.config.ts diff --git a/apps/realworld-weapp/vitest.config.ts b/apps/realworld-weapp/vitest.config.ts new file mode 100644 index 00000000..bf0ba67b --- /dev/null +++ b/apps/realworld-weapp/vitest.config.ts @@ -0,0 +1,15 @@ +import { resolve } from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + }, + resolve: { + alias: { + '@': resolve(import.meta.dirname, 'src'), + 'src': resolve(import.meta.dirname, 'src'), + '@varo-ui/theme': resolve(import.meta.dirname, '../../packages/theme/src'), + }, + }, +}) From fcbcbf373e817621386577b6234004c3e4f49acd Mon Sep 17 00:00:00 2001 From: daguanren21 <1363917353@qq.com> Date: Mon, 7 Sep 2026 13:52:09 +0800 Subject: [PATCH 5/6] fix(cli): reject lossy exports and invalid destination trees Validate UTF-8 export content and reject file/ancestor collisions before writes. Preserve binary installs and cover both target orders plus text byte integrity. Refs #6 --- .changeset/yummy-adults-hope.md | 2 + apps/docs/blocks/build-your-own.md | 2 + apps/docs/en/blocks/build-your-own.md | 2 + packages/cli/README.md | 2 + packages/cli/src/index.ts | 37 +++++++++--- packages/cli/tests/remote-registry.test.ts | 65 ++++++++++++++++++++++ 6 files changed, 101 insertions(+), 9 deletions(-) diff --git a/.changeset/yummy-adults-hope.md b/.changeset/yummy-adults-hope.md index 52b43670..9e94acab 100644 --- a/.changeset/yummy-adults-hope.md +++ b/.changeset/yummy-adults-hope.md @@ -3,3 +3,5 @@ --- Support third-party local and HTTP registries and self-contained shadcn-vue exports. Registry resolution now returns a Promise; await resolveRegistryItems(). + +Reject lossy non-UTF-8 exports and conflicting file/ancestor destinations before emitting an export or writing consumer files, while preserving byte-for-byte binary installs. diff --git a/apps/docs/blocks/build-your-own.md b/apps/docs/blocks/build-your-own.md index 7abad90c..9c1e17da 100644 --- a/apps/docs/blocks/build-your-own.md +++ b/apps/docs/blocks/build-your-own.md @@ -294,6 +294,8 @@ pnpm dlx shadcn-vue@latest add https://ui.example.com/r/status-filter.json 输出采用 `registry:file`、内联 `content` 和显式 `~/src/...` 安装路径,可由 shadcn-vue 以及委托它安装的生态工具消费;已用 shadcn-vue 2.8.2 验证。Varo 的 `add --registry` 读取原生 Varo manifest,导出 JSON 则由 shadcn-vue 安装。 +导出要求文件内容为有效 UTF-8;非 UTF-8 字节会明确报错,不会被替换字符静默损坏。`add` 仍按原始字节复制二进制资源。安装与导出都会拒绝“同一路径既是文件又是其他文件的父目录”的冲突,包括仅大小写不同的路径冲突。 + H5 与 Weapp 必须分别导出。`meta.varo.target` 记录目标,但第三方安装器不会替你检查运行时,也不会把 Vue 转成 Wevu;消费工程仍需安装匹配的依赖并接入主题。CLI API 调用方须使用 `await resolveRegistryItems(...)`,现在本地与远端解析均返回 Promise。 ## 10. 贡献回 Varo diff --git a/apps/docs/en/blocks/build-your-own.md b/apps/docs/en/blocks/build-your-own.md index 3fc26f65..efbcdc5b 100644 --- a/apps/docs/en/blocks/build-your-own.md +++ b/apps/docs/en/blocks/build-your-own.md @@ -294,6 +294,8 @@ pnpm dlx shadcn-vue@latest add https://ui.example.com/r/status-filter.json The payload uses `registry:file`, inline `content`, and explicit `~/src/...` destinations. shadcn-vue and tools delegating installation to it can consume this JSON; compatibility was exercised with shadcn-vue 2.8.2. Varo's `add --registry` reads native Varo manifests; exported JSON is installed with shadcn-vue. +Exports require valid UTF-8 contents; non-UTF-8 bytes fail explicitly instead of being silently replaced. `add` still copies binary assets byte-for-byte. Installation and export both reject destinations where a file is also another file's parent directory, including case-insensitive conflicts. + Export H5 and Weapp separately. `meta.varo.target` records the target, but external installers do not enforce it or convert Vue to Wevu. Consumers still need matching runtime dependencies and theme setup. Programmatic CLI consumers must use `await resolveRegistryItems(...)`: both local and remote resolution now return a Promise. ## 10. Contribute to Varo diff --git a/packages/cli/README.md b/packages/cli/README.md index 4182a2b4..de01bddf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -37,6 +37,8 @@ pnpm dlx shadcn-vue@latest add https://ui.example.com/r/my-block.json The JSON uses `registry:file`, inline `content`, and explicit `~/src/...` targets so shadcn-vue preserves Varo's installation paths. Transitive files and npm dependencies are included; no Varo-specific dependency names remain for the external installer to resolve. Use separate exports for H5 and Weapp. The export records `meta.varo.target`, but external installers do not enforce Varo's runtime choice: use the matching project, runtime packages, and theme setup. This is registry-protocol compatibility, not Vue-to-Wevu conversion. +Exports require valid UTF-8 file contents; non-UTF-8 bytes are rejected instead of silently replaced. `add` still copies binary assets byte-for-byte. Both installation and export reject destination trees where a file is also another file's parent directory, including case-insensitive conflicts. + Compatibility was exercised with shadcn-vue 2.8.2 in a project with `components.json` and a TypeScript path alias configuration. Other tools that delegate to shadcn-vue can consume the same hosted JSON. Varo's own `add --registry` reads Varo manifests; use shadcn-vue to install the exported JSON. ## Programmatic API diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d8a9b31c..aa1fa911 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry/source' import type { Buffer } from 'node:buffer' +import { isUtf8 } from 'node:buffer' import { randomUUID } from 'node:crypto' import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' @@ -234,12 +235,24 @@ async function readRegistryFile(file: PlannedRegistryFile): Promise { } function assertUniqueTargets(targets: { file: RegistryFile, targetIdentity: string }[]) { - const seen = new Set() + const filesByIdentity = new Map() for (const { file, targetIdentity } of targets) { - if (seen.has(targetIdentity)) { + if (filesByIdentity.has(targetIdentity)) { throw new Error(`Registry items target the same file: ${file.to}`) } - seen.add(targetIdentity) + filesByIdentity.set(targetIdentity, file) + } + for (const { file, targetIdentity } of targets) { + let ancestor = targetIdentity + while (true) { + const parent = dirname(ancestor) + if (parent === ancestor) { break } + const parentFile = filesByIdentity.get(parent) + if (parentFile) { + throw new Error(`Registry items target a file and its descendant: ${parentFile.to}, ${file.to}`) + } + ancestor = parent + } } } @@ -273,12 +286,18 @@ export async function exportRegistryItem(name: string, options: ResolveRegistryO dependencies: plan.dependencies, devDependencies: plan.devDependencies, registryDependencies: [], - files: await Promise.all(plan.files.map(async file => ({ - path: file.to, - type: 'registry:file' as const, - target: `~/${file.to}`, - content: (await readRegistryFile(file)).toString('utf8'), - }))), + files: await Promise.all(plan.files.map(async (file) => { + const bytes = await readRegistryFile(file) + if (!isUtf8(bytes)) { + throw new Error(`Cannot export non-UTF-8 registry file: ${file.to}`) + } + return { + path: file.to, + type: 'registry:file' as const, + target: `~/${file.to}`, + content: bytes.toString('utf8'), + } + })), meta: { varo: { target: plan.target } }, } } diff --git a/packages/cli/tests/remote-registry.test.ts b/packages/cli/tests/remote-registry.test.ts index 576d7596..e1797125 100644 --- a/packages/cli/tests/remote-registry.test.ts +++ b/packages/cli/tests/remote-registry.test.ts @@ -1,6 +1,7 @@ import type { Server } from 'node:http' // @vitest-environment node import type { RegistryItem } from '../src/index.ts' +import { Buffer } from 'node:buffer' import { execFile } from 'node:child_process' import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' @@ -198,4 +199,68 @@ describe('third-party registries', () => { }) await expect(exportRegistryItem('alpha', { registryRoot: url, target: 'h5' })).rejects.toThrow(/target the same file/) }) + + it('rejects non-UTF8 exports without changing byte-preserving installs', async () => { + const root = temporaryProject() + const registryRoot = join(root, 'registry') + const directory = join(registryRoot, 'components/binary') + const bytes = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'asset.png'), bytes) + writeFileSync(join(directory, 'registry.json'), JSON.stringify(item('binary', { + targets: ['h5'], + files: [{ target: 'h5', from: 'registry/components/binary/asset.png', to: 'src/assets/asset.png' }], + }))) + const consumer = temporaryProject() + + await installRegistryItems(['binary'], { registryRoot, projectRoot: consumer, target: 'h5' }) + expect(readFileSync(join(consumer, 'src/assets/asset.png'))).toEqual(bytes) + await expect(exportRegistryItem('binary', { registryRoot, target: 'h5' })).rejects.toThrow(/UTF-8/) + }) + + it('preserves Unicode, BOM, line endings, and empty UTF8 files in exports', async () => { + const root = temporaryProject() + const registryRoot = join(root, 'registry') + const directory = join(registryRoot, 'components/text') + const bytes = Buffer.from('\uFEFFexport const 标题 = "你好";\r\n') + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'text.ts'), bytes) + writeFileSync(join(directory, 'empty.ts'), '') + writeFileSync(join(directory, 'registry.json'), JSON.stringify(item('text', { + targets: ['h5'], + files: ['text', 'empty'].map(name => ({ + target: 'h5', + from: `registry/components/text/${name}.ts`, + to: `src/lib/${name}.ts`, + })), + }))) + + const exported = await exportRegistryItem('text', { registryRoot, target: 'h5' }) + expect(Buffer.from(exported.files[0]!.content)).toEqual(bytes) + expect(exported.files[1]!.content).toBe('') + }) + + it.each([false, true])('rejects file/directory conflicts regardless of order (reversed: %s)', async (reverse) => { + const root = temporaryProject() + const registryRoot = join(root, 'registry') + const directory = join(registryRoot, 'components/tree') + const files = ['src/Shared.ts', 'src/shared.ts/child.ts'].map((to, index) => ({ + target: 'h5' as const, + from: `registry/components/tree/file-${index}.ts`, + to, + })) + mkdirSync(directory, { recursive: true }) + files.forEach(file => writeFileSync(join(root, file.from), 'export const value = true\n')) + if (reverse) { files.reverse() } + writeFileSync(join(directory, 'registry.json'), JSON.stringify(item('tree', { targets: ['h5'], files }))) + const consumer = temporaryProject() + + await expect(exportRegistryItem('tree', { registryRoot, target: 'h5' })).rejects.toThrow(/file and its descendant/) + await expect(installRegistryItems(['tree'], { + registryRoot, + projectRoot: consumer, + target: 'h5', + })).rejects.toThrow(/file and its descendant/) + expect(readdirSync(consumer)).toEqual([]) + }) }) From eec2d05fa41ac8c3f6b0fbb23a8b76618a87cc6a Mon Sep 17 00:00:00 2001 From: daguanren21 <1363917353@qq.com> Date: Tue, 8 Sep 2026 10:10:51 +0800 Subject: [PATCH 6/6] feat(cli): accept standard shadcn registry inputs --- .changeset/yummy-adults-hope.md | 2 + apps/docs/blocks/build-your-own.md | 17 +- apps/docs/en/blocks/build-your-own.md | 17 +- packages/cli/README.md | 35 +- packages/cli/package.json | 4 + packages/cli/src/index.ts | 66 +- packages/cli/src/standard-files.ts | 1024 ++++++++++++++++++ packages/cli/src/standard-registry.ts | 575 ++++++++++ packages/cli/src/standard-types.ts | 63 ++ packages/cli/tests/standard-registry.test.ts | 309 ++++++ pnpm-lock.yaml | 7 + 11 files changed, 2093 insertions(+), 26 deletions(-) create mode 100644 packages/cli/src/standard-files.ts create mode 100644 packages/cli/src/standard-registry.ts create mode 100644 packages/cli/src/standard-types.ts create mode 100644 packages/cli/tests/standard-registry.test.ts diff --git a/.changeset/yummy-adults-hope.md b/.changeset/yummy-adults-hope.md index 9e94acab..4f5b3ad9 100644 --- a/.changeset/yummy-adults-hope.md +++ b/.changeset/yummy-adults-hope.md @@ -5,3 +5,5 @@ Support third-party local and HTTP registries and self-contained shadcn-vue exports. Registry resolution now returns a Promise; await resolveRegistryItems(). Reject lossy non-UTF-8 exports and conflicting file/ancestor destinations before emitting an export or writing consumer files, while preserving byte-for-byte binary installs. + +Accept standard shadcn-vue catalog and item definitions for local/HTTP installation and self-contained export, resolve consumer aliases, and relocate imports between installed Vue/TypeScript files without requiring Varo-specific manifest fields. diff --git a/apps/docs/blocks/build-your-own.md b/apps/docs/blocks/build-your-own.md index 9c1e17da..21859da8 100644 --- a/apps/docs/blocks/build-your-own.md +++ b/apps/docs/blocks/build-your-own.md @@ -281,6 +281,21 @@ CLI 从该地址下的 `blocks/status-filter/registry.json` 读取元数据, 只安装信任的源码。远端地址只支持 HTTP(S),不能携带凭证、查询参数或 fragment,不跟随重定向;单个响应上限为 10 MiB、30 秒。安装保留 `src/` 路径限制、符号链接检查、文件冲突检查和失败回滚,不自动安装 npm 依赖。 +### 直接使用标准 shadcn-vue 清单 + +也支持标准 `registry.json` 目录(`name`、`homepage`、`items`)和独立 `registry-item.json`,无需改写成 Varo 的 `from/to/targets`: + +```bash +pnpm dlx @varo-ui/cli add --registry ./registry.json hello-world +pnpm dlx @varo-ui/cli add --registry ./hello-world.json hello-world +pnpm dlx @varo-ui/cli add --registry https://ui.example.com/r/registry.json hello-world +pnpm dlx @varo-ui/cli export --registry ./registry.json hello-world > hello-world.json +``` + +`files.path` 相对清单目录读取;已包含 `content` 的发布条目无需源文件。组件、UI、hook、lib 默认分别进入 `src/components`、`src/components/ui`、`src/composables`、`src/lib`,保留组件子目录;显式 `target` 优先,但必须留在 `src/`。也会读取消费项目的 `components.json` 和 TypeScript/JSONC 别名配置,并用 AST 重写随文件移动而变化的 JS/TS/Vue script 导入。 + +标准清单默认 H5;Weapp 条目需明确声明 `meta.varo.target: "weapp"`。目录内依赖按名称解析,独立条目可读取同目录的 `.json`,跨 Registry 依赖使用明确的 HTTP(S) 条目 URL。支持 `registry:block/component/ui/hook/composable/lib/page/file/theme/style` 的文件语义;`page/file` 文件必须提供明确目标。不支持 `registry:base/font`、框架转换或 npm 自动安装;`css`、`cssVars`、`tailwind`、`envVars` 与样式继承 `extends` 会明确报错,不会静默忽略,也不会自动回退到公共 Registry。 + ### 供 shadcn-vue 生态安装 导出一个条目时会内联当前 target 的全部传递依赖和源码,生成符合 [shadcn-vue Registry 协议](https://www.shadcn-vue.com/docs/registry/registry-item-json) 的 JSON: @@ -292,7 +307,7 @@ pnpm dlx @varo-ui/cli export --registry ./registry --target weapp blocks/status- pnpm dlx shadcn-vue@latest add https://ui.example.com/r/status-filter.json ``` -输出采用 `registry:file`、内联 `content` 和显式 `~/src/...` 安装路径,可由 shadcn-vue 以及委托它安装的生态工具消费;已用 shadcn-vue 2.8.2 验证。Varo 的 `add --registry` 读取原生 Varo manifest,导出 JSON 则由 shadcn-vue 安装。 +输出采用 `registry:file`、内联 `content` 和显式 `~/src/...` 安装路径,可由 shadcn-vue 以及委托它安装的生态工具消费;已用 shadcn-vue 2.8.2 验证。Varo 的 `add --registry` 也可以直接读取导出的单项 JSON。 导出要求文件内容为有效 UTF-8;非 UTF-8 字节会明确报错,不会被替换字符静默损坏。`add` 仍按原始字节复制二进制资源。安装与导出都会拒绝“同一路径既是文件又是其他文件的父目录”的冲突,包括仅大小写不同的路径冲突。 diff --git a/apps/docs/en/blocks/build-your-own.md b/apps/docs/en/blocks/build-your-own.md index efbcdc5b..4f21b6e4 100644 --- a/apps/docs/en/blocks/build-your-own.md +++ b/apps/docs/en/blocks/build-your-own.md @@ -281,6 +281,21 @@ The CLI loads `blocks/status-filter/registry.json` below that URL. Source URLs u Only install sources you trust. Remote roots use HTTP(S), cannot contain credentials, queries, or fragments, and do not follow redirects. Each response is limited to 10 MiB and 30 seconds. Installs retain `src/` confinement, symlink checks, collision checks, and rollback. npm dependencies are reported, not installed. +### Use standard shadcn-vue manifests directly + +Standard `registry.json` catalogs (`name`, `homepage`, `items`) and individual `registry-item.json` definitions are accepted without adding Varo `from/to/targets` fields: + +```bash +pnpm dlx @varo-ui/cli add --registry ./registry.json hello-world +pnpm dlx @varo-ui/cli add --registry ./hello-world.json hello-world +pnpm dlx @varo-ui/cli add --registry https://ui.example.com/r/registry.json hello-world +pnpm dlx @varo-ui/cli export --registry ./registry.json hello-world > hello-world.json +``` + +`files.path` is relative to the manifest directory; published files with inline `content` need no backing source file. Components, UI, hooks, and libs default to `src/components`, `src/components/ui`, `src/composables`, and `src/lib`, preserving nested component directories. Explicit `target` wins but must stay inside `src/`. Consumer `components.json` and TypeScript/JSONC aliases are honored, and JS/TS/Vue script imports between relocated files are rewritten through AST parsing. + +Standard manifests default to H5; Weapp items must declare `meta.varo.target: "weapp"`. Catalog dependencies resolve by name, individual items can load sibling `.json` files, and cross-registry dependencies use explicit HTTP(S) item URLs. Supported file semantics cover `registry:block/component/ui/hook/composable/lib/page/file/theme/style`; `page/file` entries require an explicit target. `registry:base/font`, framework conversion, and npm auto-install are unsupported. `css`, `cssVars`, `tailwind`, `envVars`, and style inheritance through `extends` fail explicitly rather than being silently ignored. There is no implicit public-registry fallback. + ### Install through the shadcn-vue ecosystem Exporting one item inlines its selected target's complete dependency closure and source into a [shadcn-vue registry item](https://www.shadcn-vue.com/docs/registry/registry-item-json): @@ -292,7 +307,7 @@ pnpm dlx @varo-ui/cli export --registry ./registry --target weapp blocks/status- pnpm dlx shadcn-vue@latest add https://ui.example.com/r/status-filter.json ``` -The payload uses `registry:file`, inline `content`, and explicit `~/src/...` destinations. shadcn-vue and tools delegating installation to it can consume this JSON; compatibility was exercised with shadcn-vue 2.8.2. Varo's `add --registry` reads native Varo manifests; exported JSON is installed with shadcn-vue. +The payload uses `registry:file`, inline `content`, and explicit `~/src/...` destinations. shadcn-vue and tools delegating installation to it can consume this JSON; compatibility was exercised with shadcn-vue 2.8.2. Varo's `add --registry` can also read the exported single-item JSON directly. Exports require valid UTF-8 contents; non-UTF-8 bytes fail explicitly instead of being silently replaced. `add` still copies binary assets byte-for-byte. Installation and export both reject destinations where a file is also another file's parent directory, including case-insensitive conflicts. diff --git a/packages/cli/README.md b/packages/cli/README.md index de01bddf..39f171a2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -20,10 +20,39 @@ pnpm dlx @varo-ui/cli add --registry ./registry --target h5 blocks/my-block pnpm dlx @varo-ui/cli add --registry https://ui.example.com/registry/ --target weapp blocks/my-block ``` -`--registry` selects a local directory or an HTTP(S) root with the same layout as Varo's authored `registry/`. For example, `blocks/my-block` loads `blocks/my-block/registry.json` below that root. A file's `from: "registry/blocks/my-block/weapp-vite.vue"` loads `blocks/my-block/weapp-vite.vue` below the same root. All transitive `registryDependencies`, including target-specific dependencies, resolve there; include those manifests and sources in your registry. There is no fallback to the bundled registry. +For native Varo manifests, `--registry` selects a local directory or an HTTP(S) root with the same layout as Varo's authored `registry/`. For example, `blocks/my-block` loads `blocks/my-block/registry.json` below that root. A file's `from: "registry/blocks/my-block/weapp-vite.vue"` loads `blocks/my-block/weapp-vite.vue` below the same root. All transitive `registryDependencies`, including target-specific dependencies, resolve there; include those manifests and sources in your registry. There is no fallback to the bundled registry. Only install sources you trust. Remote roots cannot contain credentials, queries, or fragments; redirects are rejected. Each HTTP response is limited to 10 MiB and 30 seconds. File destinations remain confined to the project's `src/`, with the same symlink, collision, no-clobber, and rollback protections as bundled installs. npm dependencies are reported, not installed. +## Standard shadcn-vue inputs + +`add` and `export` also accept standard `registry.json` catalogs (`name`, `homepage`, `items`) and individual `registry-item.json` definitions. Keep the standard `files.path` / `files.type` fields; no Varo `from`, `to`, or `targets` fields are required. + +```bash +pnpm dlx @varo-ui/cli add --registry ./registry.json hello-world +pnpm dlx @varo-ui/cli add --registry ./hello-world.json hello-world +pnpm dlx @varo-ui/cli add --registry https://ui.example.com/r/registry.json hello-world +pnpm dlx @varo-ui/cli export --registry ./registry.json hello-world > hello-world.json +``` + +Without inline `content`, file paths resolve relative to the selected manifest's directory. Published items with inline `content`, including empty strings, need no backing source file. A directory containing `registry.json` is also accepted; remote standard documents use explicit `.json` URLs rather than the native directory-root convention. + +Standard definitions default to H5. A Varo export can declare `meta.varo.target: "weapp"`; an incompatible explicit `--target` is rejected rather than converting Vue to Wevu. Native Varo registries retain their default Weapp target. + +Default destinations preserve nested component directories: + +| File type | Destination | +| -------------------------------------- | -------------------------- | +| `registry:component`, `registry:block` | `src/components` | +| `registry:ui` | `src/components/ui` | +| `registry:hook`, `registry:composable` | `src/composables` | +| `registry:lib` | `src/lib` | +| `registry:file`, `registry:page` | Explicit `target` required | + +Explicit targets win and must remain under `src/` (`~/src/...` is accepted). Consumer `components.json` aliases are resolved through the project's TypeScript/JSONC configuration. Imports between installed JS/TS/Vue script files are relocated with AST parsing when their destinations differ; unrelated code and npm imports are not rewritten. + +Dependency names in a catalog resolve in that catalog. Individual item files can reference sibling `.json` items; explicit HTTP(S) item URLs support cross-registry dependencies. Unknown names do not silently resolve to a public registry. Supported item/file types are `registry:block`, `registry:component`, `registry:ui`, `registry:hook`, `registry:composable`, `registry:lib`, `registry:page`, `registry:file`, `registry:theme`, and `registry:style`; file/page entries require an explicit target. This implements file-based registry inputs, not shadcn-vue project configuration: `css`, `cssVars`, `tailwind`, `envVars`, and style `extends` are rejected. Npm auto-install, framework conversion, `registry:base`, and `registry:font` are not supported. + ## Export for shadcn-vue Export one item and its selected target's complete dependency closure as a self-contained [shadcn-vue registry item](https://www.shadcn-vue.com/docs/registry/registry-item-json): @@ -39,11 +68,11 @@ The JSON uses `registry:file`, inline `content`, and explicit `~/src/...` target Exports require valid UTF-8 file contents; non-UTF-8 bytes are rejected instead of silently replaced. `add` still copies binary assets byte-for-byte. Both installation and export reject destination trees where a file is also another file's parent directory, including case-insensitive conflicts. -Compatibility was exercised with shadcn-vue 2.8.2 in a project with `components.json` and a TypeScript path alias configuration. Other tools that delegate to shadcn-vue can consume the same hosted JSON. Varo's own `add --registry` reads Varo manifests; use shadcn-vue to install the exported JSON. +Compatibility was exercised with shadcn-vue 2.8.2 in a project with `components.json` and a TypeScript path alias configuration. Other tools that delegate to shadcn-vue can consume the same hosted JSON. Varo's `add --registry` can also consume the exported single-item JSON directly. ## Programmatic API -`resolveRegistryItems()` is asynchronous for both local and remote registries. Existing synchronous consumers must add `await`; failures reject the returned Promise. `PlannedRegistryFile.sourcePath` is a local absolute path or an HTTP(S) URL. +`resolveRegistryItems()` is asynchronous for both local and remote registries. Existing synchronous consumers must add `await`; failures reject the returned Promise. `PlannedRegistryFile.sourcePath` identifies a local absolute path or HTTP(S) source. Inline published files additionally carry `content`, so their provenance path need not exist on disk. Pass `projectRoot` when resolving consumer-specific aliases programmatically. ```ts import { exportRegistryItem, resolveRegistryItems } from '@varo-ui/cli' diff --git a/packages/cli/package.json b/packages/cli/package.json index db87807e..c65db239 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,6 +50,10 @@ "test": "vitest run --passWithNoTests", "test:e2e": "echo cli has no e2e" }, + "dependencies": { + "@vue/compiler-sfc": "3.5.42", + "get-tsconfig": "4.14.3" + }, "devDependencies": { "@varo/registry": "workspace:*" } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index aa1fa911..f906cd14 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry/source' -import type { Buffer } from 'node:buffer' -import { isUtf8 } from 'node:buffer' +import type { StandardFileOrigin } from './standard-types.ts' +import { Buffer, isUtf8 } from 'node:buffer' import { randomUUID } from 'node:crypto' import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' @@ -10,6 +10,8 @@ import { fileURLToPath } from 'node:url' import { validateRegistryItem } from '@varo/registry/source' import { mkdir, open, readFile, rename, rm, rmdir } from './file-system.ts' import { fetchRegistryFile, getRemoteRegistryRoot, registryUrl } from './remote-registry.ts' +import { rewriteStandardFileImports } from './standard-files.ts' +import { resolveStandardRegistryItems } from './standard-registry.ts' export type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry/source' @@ -17,6 +19,8 @@ export interface PlannedRegistryFile extends RegistryFile { item: string sourcePath: string targetPath: string + content?: string + standard?: StandardFileOrigin } export interface RegistryInstallPlan { @@ -31,6 +35,7 @@ export interface RegistryInstallPlan { export interface ResolveRegistryOptions { registryRoot?: string target?: RegistryTarget + projectRoot?: string } export interface InstallRegistryOptions extends ResolveRegistryOptions { @@ -165,6 +170,13 @@ function resolveProjectTarget(canonicalRoot: string, to: string): string { } export async function resolveRegistryItems(names: string[], options: ResolveRegistryOptions = {}): Promise { + if (options.registryRoot !== undefined) { + const standardPlan = await resolveStandardRegistryItems(names, options) + if (standardPlan !== undefined) { + return standardPlan + } + } + const registryRoot = options.registryRoot ?? defaultRegistryRoot const remoteRoot = getRemoteRegistryRoot(registryRoot) const target = options.target ?? 'weapp' @@ -216,7 +228,9 @@ export async function resolveRegistryItems(names: string[], options: ResolveRegi item.files .filter(file => file.target === target) .map(file => ({ - ...file, + target: file.target, + from: file.from, + to: file.to, item: item.name, sourcePath: remoteRoot ? registryUrl(remoteRoot, file.from.slice('registry/'.length)) @@ -229,6 +243,9 @@ export async function resolveRegistryItems(names: string[], options: ResolveRegi } async function readRegistryFile(file: PlannedRegistryFile): Promise { + if (file.content !== undefined) { + return Buffer.from(file.content) + } return /^https?:\/\//.test(file.sourcePath) ? fetchRegistryFile(file.sourcePath) : readFile(file.sourcePath) @@ -276,6 +293,21 @@ export async function exportRegistryItem(name: string, options: ResolveRegistryO file, targetIdentity: file.to.normalize('NFC').toLowerCase().normalize('NFC'), }))) + const sourceBytes = await Promise.all(plan.files.map(file => readRegistryFile(file))) + const rewrittenBytes = rewriteStandardFileImports(plan.files, sourceBytes) + const files = plan.files.map((file, index) => { + const bytes = rewrittenBytes[index]! + if (!isUtf8(bytes)) { + throw new Error(`Cannot export non-UTF-8 registry file: ${file.to}`) + } + return { + path: file.to, + type: 'registry:file' as const, + target: `~/${file.to}`, + content: bytes.toString('utf8'), + } + }) + return { $schema: 'https://shadcn-vue.com/schema/registry-item.json', name: item.name, @@ -286,18 +318,7 @@ export async function exportRegistryItem(name: string, options: ResolveRegistryO dependencies: plan.dependencies, devDependencies: plan.devDependencies, registryDependencies: [], - files: await Promise.all(plan.files.map(async (file) => { - const bytes = await readRegistryFile(file) - if (!isUtf8(bytes)) { - throw new Error(`Cannot export non-UTF-8 registry file: ${file.to}`) - } - return { - path: file.to, - type: 'registry:file' as const, - target: `~/${file.to}`, - content: bytes.toString('utf8'), - } - })), + files, meta: { varo: { target: plan.target } }, } } @@ -323,7 +344,10 @@ export async function installRegistryItems(names: string[], options: InstallRegi } } - const sourceBytes = await Promise.all(plannedTargets.map(({ file }) => readRegistryFile(file))) + const sourceBytes = rewriteStandardFileImports( + plan.files, + await Promise.all(plannedTargets.map(({ file }) => readRegistryFile(file))), + ) const commitStates = plannedTargets.map((plannedTarget, index) => ({ ...plannedTarget, backupPath: plannedTarget.hadOriginal @@ -450,7 +474,7 @@ export async function installRegistryItems(names: string[], options: InstallRegi async function runCli(argv: string[]) { const [command, ...args] = argv let force = false - let target: RegistryTarget = 'weapp' + let target: RegistryTarget | undefined let registryRoot: string | undefined const items: string[] = [] @@ -483,7 +507,7 @@ async function runCli(argv: string[]) { if (arg === '--registry' || arg.startsWith('--registry=')) { const value = arg === '--registry' ? args[++index] : arg.slice('--registry='.length) if (!value || value.startsWith('--')) { - throw new Error('Missing registry directory or URL') + throw new Error('Missing registry path or URL') } registryRoot = value continue @@ -498,8 +522,8 @@ async function runCli(argv: string[]) { if ((command !== 'add' && command !== 'export') || items.length === 0) { process.stderr.write( - 'Usage: varo add [--registry directory|url] [--target h5|weapp] [--force] [...items]\n' - + ' varo export [--registry directory|url] [--target h5|weapp] \n', + 'Usage: varo add [--registry directory|json|url] [--target h5|weapp] [--force] [...items]\n' + + ' varo export [--registry directory|json|url] [--target h5|weapp] \n', ) process.exitCode = 1 return @@ -509,7 +533,7 @@ async function runCli(argv: string[]) { if (items.length !== 1 || force) { throw new Error('Export requires exactly one registry item and does not accept --force') } - const item = await exportRegistryItem(items[0]!, { registryRoot, target }) + const item = await exportRegistryItem(items[0]!, { projectRoot: process.cwd(), registryRoot, target }) process.stdout.write(`${JSON.stringify(item, null, 2)}\n`) return } diff --git a/packages/cli/src/standard-files.ts b/packages/cli/src/standard-files.ts new file mode 100644 index 00000000..3efd3026 --- /dev/null +++ b/packages/cli/src/standard-files.ts @@ -0,0 +1,1024 @@ +import type { TsConfigResult } from 'get-tsconfig' +import type { StandardFileDestination, StandardFilePlanEntry, StandardRegistryFile, StandardRegistryItem } from './standard-types.ts' +import { Buffer, isUtf8 } from 'node:buffer' +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { dirname, extname, isAbsolute, posix, relative, resolve, sep } from 'node:path' +import { babelParse, parse as parseSfc } from '@vue/compiler-sfc' +import { createPathsMatcher, getTsconfig, parseTsconfig } from 'get-tsconfig' + +const defaultAliases = { + components: '@/components', + ui: '@/components/ui', + lib: '@/lib', + composables: '@/composables', + utils: '@/lib/utils', +} as const + +const defaultDirectories = { + components: 'src/components', + ui: 'src/components/ui', + lib: 'src/lib', + composables: 'src/composables', +} as const + +const codeExtensions: Record = { + '.cjs': true, + '.cts': true, + '.js': true, + '.jsx': true, + '.mjs': true, + '.mts': true, + '.ts': true, + '.tsx': true, + '.vue': true, +} +const windowsInvalidPathCharacterPattern = /[<>:"|?*]/ +const windowsReservedPathNamePattern = /^(?:aux|com[1-9¹²³]|con|conin\$|conout\$|lpt[1-9¹²³]|nul|prn)$/i + +interface ConsumerPaths { + aliases: string[] + directories: Record +} + +interface SyntaxNode { + type: string + start?: number | null + end?: number | null + value?: unknown + [key: string]: unknown +} + +interface TextPatch { + start: number + end: number + value: string +} + +type AliasForm = 'exact' | 'stem' | 'index' + +interface KnownStandardFile { + entry: StandardFilePlanEntry + index: number + sourceRoot: string + sourceUrl: URL +} + +interface FileAlias { + file: KnownStandardFile + form: AliasForm +} + +interface FileMatch { + file: KnownStandardFile + form: AliasForm + style: 'relative' | 'source-alias' | 'src-alias' | 'project-alias' +} + +interface StandardFileIndex { + byEntryIndex: (KnownStandardFile | undefined)[] + logicalAliases: Map + sourceAliases: Map + sourceRoots: URL[] +} + +function errorReason(error: unknown): string { + if (error instanceof Error) { + return error.message + } + if (error !== null && typeof error === 'object' && 'message' in error && typeof error.message === 'string') { + return error.message + } + return String(error) +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code < 32 || code === 127) { + return true + } + } + return false +} + +function normalizePortablePath(value: string, label: string): string { + if (value.length === 0 || value.includes('\\') || hasControlCharacter(value)) { + throw new Error(`${label} must be a non-empty portable relative path: ${value}`) + } + const firstCode = value.charCodeAt(0) + const hasWindowsDrive = value.length >= 2 && value[1] === ':' + && ((firstCode >= 65 && firstCode <= 90) || (firstCode >= 97 && firstCode <= 122)) + if (posix.isAbsolute(value) || hasWindowsDrive) { + throw new Error(`${label} must be relative: ${value}`) + } + + const segments = value.split('/') + if (segments.some((segment) => { + const extensionIndex = segment.indexOf('.') + const basename = extensionIndex === -1 ? segment : segment.slice(0, extensionIndex) + return segment === '' || segment === '.' || segment === '..' + || segment.endsWith('.') || segment.endsWith(' ') + || windowsInvalidPathCharacterPattern.test(segment) + || windowsReservedPathNamePattern.test(basename) + })) { + throw new Error(`${label} is outside its allowed root: ${value}`) + } + return segments.join('/') +} + +function normalizeProjectSourcePath(value: string, label: string): string { + const withoutHome = value.startsWith('~/') ? value.slice(2) : value + const normalized = normalizePortablePath(withoutHome, label) + if (!normalized.startsWith('src/')) { + throw new Error(`${label} is outside the project src directory: ${value}`) + } + return normalized +} + +function isWithinOrEqual(root: string, candidate: string): boolean { + const relativePath = relative(root, candidate) + return relativePath === '' + || (relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)) +} + +function projectRelativeSourcePath(projectRoot: string, candidate: string, label: string): string { + const sourceRoot = resolve(projectRoot, 'src') + const absoluteCandidate = resolve(candidate) + if (!isWithinOrEqual(sourceRoot, absoluteCandidate)) { + throw new Error(`${label} is outside the project src directory: ${absoluteCandidate}`) + } + const sourceRelative = relative(sourceRoot, absoluteCandidate).split(sep).join('/') + return sourceRelative === '' ? 'src' : `src/${sourceRelative}` +} + +function findCommonRoot(paths: readonly string[], needle: string): string { + const needleDirectory = needle.split('/').slice(0, -1).join('/') + if (needleDirectory === '') { + return '' + } + + const needleSegments = needleDirectory.split('/') + for (let length = needleSegments.length; length > 0; length -= 1) { + const candidate = needleSegments.slice(0, length).join('/') + if (paths.some(path => path !== needle && path.startsWith(`${candidate}/`))) { + return candidate + } + } + return needleDirectory +} + +function normalizeNestedAlias(value: string): string { + let normalized = value.startsWith('@/') || value.startsWith('~/') ? value.slice(2) : value + while (normalized.startsWith('/')) { + normalized = normalized.slice(1) + } + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1) + } + return normalized +} + +function resolveNestedFilePath(filePath: string, commonRoot: string, aliases: readonly string[]): string { + const normalizedAliases = aliases + .map(normalizeNestedAlias) + .filter(alias => alias !== '') + .sort((left, right) => right.length - left.length) + + for (const alias of normalizedAliases) { + if (commonRoot.includes(alias)) { + const aliasEnd = filePath.indexOf(alias) + alias.length + const nested = filePath.slice(aliasEnd) + return nested.startsWith('/') ? nested.slice(1) : nested + } + } + + const lastCommonRootSegment = commonRoot.split('/').pop() ?? '' + return `${lastCommonRootSegment}${filePath.replace(commonRoot, '')}` +} + +function referencedTsconfigPath(parentPath: string, referencePath: string): string | undefined { + const unresolved = resolve(dirname(parentPath), referencePath) + const candidates = extname(unresolved) === '.json' + ? [unresolved] + : [`${unresolved}.json`, resolve(unresolved, 'tsconfig.json'), unresolved] + return candidates.find(candidate => existsSync(candidate)) +} + +function collectProjectTsconfigs(projectRoot: string): TsConfigResult[] { + const root = resolve(projectRoot) + const initial = getTsconfig(root) + if (initial === null || !isWithinOrEqual(root, initial.path)) { + return [] + } + + const results: TsConfigResult[] = [] + const seen = new Set() + + function visit(result: TsConfigResult) { + const configPath = resolve(result.path) + if (seen.has(configPath)) { + return + } + seen.add(configPath) + results.push({ path: configPath, config: result.config }) + + for (const reference of result.config.references ?? []) { + const childPath = referencedTsconfigPath(configPath, reference.path) + if (childPath === undefined) { + throw new Error(`Referenced TypeScript config does not exist: ${reference.path} from ${configPath}`) + } + if (!isWithinOrEqual(root, childPath)) { + continue + } + try { + visit({ path: childPath, config: parseTsconfig(childPath) }) + } + catch (error) { + throw new Error(`Cannot read referenced TypeScript config ${childPath}: ${errorReason(error)}`, { cause: error }) + } + } + } + + visit(initial) + return results +} + +function baseUrlCandidate(tsconfig: TsConfigResult, alias: string): string | undefined { + const baseUrl = tsconfig.config.compilerOptions?.baseUrl + if (baseUrl === undefined || alias.startsWith('@/') || alias.startsWith('~/')) { + return undefined + } + const absoluteBaseUrl = isAbsolute(baseUrl) ? baseUrl : resolve(dirname(tsconfig.path), baseUrl) + return resolve(absoluteBaseUrl, alias) +} + +function resolveConfiguredAlias(alias: string, projectRoot: string, tsconfigs: readonly TsConfigResult[]): string { + const root = resolve(projectRoot) + const sourceRoot = resolve(root, 'src') + const outsideCandidates: string[] = [] + + if (isAbsolute(alias)) { + if (!isWithinOrEqual(sourceRoot, alias)) { + throw new Error(`Configured component alias resolves outside the project src directory: ${alias}`) + } + return resolve(alias) + } + + if (alias.startsWith('./') || alias.startsWith('../')) { + const candidate = resolve(root, alias) + if (!isWithinOrEqual(sourceRoot, candidate)) { + throw new Error(`Configured component alias resolves outside the project src directory: ${alias}`) + } + return candidate + } + + if (alias === 'src' || alias.startsWith('src/')) { + return resolve(root, normalizePortablePath(alias, 'Configured component alias')) + } + + for (let index = 0; index < tsconfigs.length; index += 1) { + const tsconfig = tsconfigs[index]! + const matcher = createPathsMatcher(tsconfig) + const matched = matcher?.(alias) ?? [] + const candidate = matched[0] ?? baseUrlCandidate(tsconfig, alias) + if (candidate === undefined) { + continue + } + if (isWithinOrEqual(sourceRoot, candidate)) { + return resolve(candidate) + } + if (index === 0 && matched.length > 0) { + throw new Error(`Configured component alias resolves outside the project src directory: ${alias} -> ${candidate}`) + } + outsideCandidates.push(candidate) + } + + if (outsideCandidates.length > 0) { + throw new Error(`Configured component alias resolves outside the project src directory: ${alias} -> ${outsideCandidates[0]}`) + } + throw new Error(`Configured component alias cannot be resolved through the project TypeScript config: ${alias}`) +} + +async function resolveConsumerPaths(projectRoot?: string): Promise { + const aliases = new Set(Object.values(defaultAliases)) + const directories: ConsumerPaths['directories'] = { ...defaultDirectories } + if (projectRoot === undefined) { + return { aliases: [...aliases], directories } + } + + const root = resolve(projectRoot) + const componentsPath = resolve(root, 'components.json') + if (!existsSync(componentsPath)) { + return { aliases: [...aliases], directories } + } + + let input: unknown + try { + input = JSON.parse(await readFile(componentsPath, 'utf8')) as unknown + } + catch (error) { + throw new Error(`Cannot read ${componentsPath}: ${errorReason(error)}`, { cause: error }) + } + if (input === null || typeof input !== 'object' || Array.isArray(input) + || !('aliases' in input) || input.aliases === null + || typeof input.aliases !== 'object' || Array.isArray(input.aliases)) { + throw new Error(`Invalid components.json aliases in ${componentsPath}`) + } + const configuredAliases: Record = {} + + for (const [name, value] of Object.entries(input.aliases)) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Invalid components.json alias ${name} in ${componentsPath}`) + } + configuredAliases[name] = value + aliases.add(value) + } + + const tsconfigs = collectProjectTsconfigs(root) + for (const name of Object.keys(defaultDirectories) as (keyof typeof defaultDirectories)[]) { + const configured = configuredAliases[name] + if (configured !== undefined) { + directories[name] = projectRelativeSourcePath( + root, + resolveConfiguredAlias(configured, root, tsconfigs), + `Configured ${name} alias`, + ) + } + } + return { aliases: [...aliases], directories } +} + +function defaultDirectoryForFile( + file: StandardRegistryFile, + directories: Readonly>, +): string | undefined { + switch (file.type) { + case 'registry:ui': + return directories.ui + case 'registry:block': + case 'registry:component': + return directories.components + case 'registry:lib': + return directories.lib + case 'registry:hook': + case 'registry:composable': + return directories.composables + default: + return undefined + } +} + +export async function resolveStandardFileTargets( + item: StandardRegistryItem, + projectRoot?: string, +): Promise { + const files = item.files ?? [] + if (files.length === 0) { + return [] + } + + const normalizedPaths = files.map(file => normalizePortablePath(file.path, `Registry source path for ${item.name}`)) + const consumerPaths = await resolveConsumerPaths(projectRoot) + + return files.map((file, index) => { + const commonRoot = findCommonRoot(normalizedPaths, normalizedPaths[index]!) + const nestedPath = normalizePortablePath( + resolveNestedFilePath(normalizedPaths[index]!, commonRoot, consumerPaths.aliases), + `Registry destination path for ${file.path}`, + ) + const canonicalDirectory = defaultDirectoryForFile(file, defaultDirectories) + const defaultTo = canonicalDirectory === undefined + ? undefined + : normalizeProjectSourcePath(posix.join(canonicalDirectory, nestedPath), `Default registry target for ${file.path}`) + + if (file.target !== undefined) { + const to = normalizeProjectSourcePath(file.target, `Registry target for ${file.path}`) + return { file, to, defaultTo: defaultTo ?? to } + } + const consumerDirectory = defaultDirectoryForFile(file, consumerPaths.directories) + if (consumerDirectory === undefined || defaultTo === undefined) { + throw new Error(`Registry file type ${file.type} requires an explicit target: ${file.path}`) + } + const to = normalizeProjectSourcePath( + posix.join(consumerDirectory, nestedPath), + `Registry target for ${file.path}`, + ) + return { file, to, defaultTo } + }) +} + +function normalizedUrl(value: string, directory: boolean): URL { + let url: URL + try { + url = new URL(value) + } + catch (error) { + throw new Error(`Invalid standard file URL: ${value}`, { cause: error }) + } + url.search = '' + url.hash = '' + if (directory && !url.pathname.endsWith('/')) { + url.pathname += '/' + } + return url +} + +function urlIdentity(url: URL): string { + const normalized = new URL(url.href) + normalized.search = '' + normalized.hash = '' + if (normalized.pathname.length > 1 && normalized.pathname.endsWith('/')) { + normalized.pathname = normalized.pathname.slice(0, -1) + } + return normalized.href +} + +function addAlias(map: Map, key: string, alias: FileAlias) { + const existing = map.get(key) + if (existing === undefined) { + map.set(key, [alias]) + return + } + if (!existing.some(candidate => candidate.file.index === alias.file.index && candidate.form === alias.form)) { + existing.push(alias) + } +} + +function addLogicalAliases(map: Map, path: string, file: KnownStandardFile) { + addAlias(map, path, { file, form: 'exact' }) + const extension = posix.extname(path).toLowerCase() + if (codeExtensions[extension] !== true) { + return + } + + const stem = path.slice(0, -extension.length) + addAlias(map, stem, { file, form: 'stem' }) + if (posix.basename(stem) === 'index') { + addAlias(map, posix.dirname(stem), { file, form: 'index' }) + } +} + +function addSourceAliases(map: Map, source: URL, file: KnownStandardFile) { + addAlias(map, urlIdentity(source), { file, form: 'exact' }) + const extension = posix.extname(source.pathname).toLowerCase() + if (codeExtensions[extension] !== true) { + return + } + + const stem = new URL(source.href) + stem.pathname = stem.pathname.slice(0, -extension.length) + addAlias(map, urlIdentity(stem), { file, form: 'stem' }) + if (posix.basename(stem.pathname) === 'index') { + const directory = new URL(stem.href) + directory.pathname = posix.dirname(directory.pathname) + addAlias(map, urlIdentity(directory), { file, form: 'index' }) + } +} + +function buildStandardFileIndex(files: readonly StandardFilePlanEntry[]): StandardFileIndex { + const byEntryIndex: (KnownStandardFile | undefined)[] = Array.from({ length: files.length }) + const logicalAliases = new Map() + const sourceAliases = new Map() + const sourceRoots = new Map() + + for (let index = 0; index < files.length; index += 1) { + const entry = files[index]! + if (entry.standard === undefined) { + continue + } + + const sourceUrl = normalizedUrl(entry.standard.sourceKey, false) + const sourceRoot = normalizedUrl(entry.standard.sourceRoot, true) + const known: KnownStandardFile = { + entry, + index, + sourceRoot: sourceRoot.href, + sourceUrl, + } + byEntryIndex[index] = known + sourceRoots.set(sourceRoot.href, sourceRoot) + + addSourceAliases(sourceAliases, sourceUrl, known) + addLogicalAliases( + logicalAliases, + normalizeProjectSourcePath(entry.standard.defaultTo, `Default registry target for ${entry.standard.path}`), + known, + ) + addLogicalAliases( + logicalAliases, + normalizeProjectSourcePath(entry.to, `Registry target for ${entry.standard.path}`), + known, + ) + } + + return { + byEntryIndex, + logicalAliases, + sourceAliases, + sourceRoots: [...sourceRoots.values()], + } +} + +function aliasRank(form: AliasForm): number { + switch (form) { + case 'exact': return 0 + case 'stem': return 1 + case 'index': return 2 + } +} + +function chooseAlias( + candidates: readonly FileAlias[] | undefined, + importer: KnownStandardFile, + specifier: string, +): FileAlias | undefined { + if (candidates === undefined || candidates.length === 0) { + return undefined + } + + let bestRank = Number.POSITIVE_INFINITY + for (const candidate of candidates) { + bestRank = Math.min(bestRank, aliasRank(candidate.form)) + } + const preferSameRegistry = candidates.some(candidate => + aliasRank(candidate.form) === bestRank && candidate.file.sourceRoot === importer.sourceRoot, + ) + + let selected: FileAlias | undefined + for (const candidate of candidates) { + if (aliasRank(candidate.form) !== bestRank + || (preferSameRegistry && candidate.file.sourceRoot !== importer.sourceRoot)) { + continue + } + if (selected !== undefined && selected.file.index !== candidate.file.index) { + throw new Error(`Ambiguous installed registry import ${specifier} in ${importer.entry.standard!.path}`) + } + selected = candidate + } + return selected +} + +function stripCodeExtensionFromUrl(url: URL, requestPath: string): URL | undefined { + const extension = posix.extname(requestPath).toLowerCase() + if (codeExtensions[extension] !== true) { + return undefined + } + const stem = new URL(url.href) + stem.pathname = stem.pathname.slice(0, -extension.length) + return stem +} + +function lookupSourceAlias( + url: URL, + requestPath: string, + importer: KnownStandardFile, + index: StandardFileIndex, + specifier: string, +): FileAlias | undefined { + const exact = chooseAlias(index.sourceAliases.get(urlIdentity(url)), importer, specifier) + if (exact !== undefined) { + return exact + } + const stem = stripCodeExtensionFromUrl(url, requestPath) + return stem === undefined + ? undefined + : chooseAlias(index.sourceAliases.get(urlIdentity(stem)), importer, specifier) +} + +function lookupLogicalAlias( + path: string, + requestPath: string, + importer: KnownStandardFile, + index: StandardFileIndex, + specifier: string, +): FileAlias | undefined { + const normalized = posix.normalize(path) + const exact = chooseAlias(index.logicalAliases.get(normalized), importer, specifier) + if (exact !== undefined) { + return exact + } + + const extension = posix.extname(requestPath).toLowerCase() + if (codeExtensions[extension] !== true) { + return undefined + } + return chooseAlias( + index.logicalAliases.get(normalized.slice(0, -extension.length)), + importer, + specifier, + ) +} + +function resolveRelativeImport( + requestPath: string, + importer: KnownStandardFile, + index: StandardFileIndex, + specifier: string, +): FileMatch | undefined { + const sourceUrl = new URL(requestPath, importer.sourceUrl) + const sourceMatch = lookupSourceAlias(sourceUrl, requestPath, importer, index, specifier) + if (sourceMatch !== undefined) { + return { ...sourceMatch, style: 'relative' } + } + + const logicalBases = [importer.entry.standard!.defaultTo, importer.entry.to] + for (const base of logicalBases) { + const candidate = posix.resolve('/', posix.dirname(base), requestPath).slice(1) + const logicalMatch = lookupLogicalAlias(candidate, requestPath, importer, index, specifier) + if (logicalMatch !== undefined) { + return { ...logicalMatch, style: 'relative' } + } + } + return undefined +} + +function resolveSourceAliasImport( + requestPath: string, + importer: KnownStandardFile, + index: StandardFileIndex, + specifier: string, +): FileAlias | undefined { + const suffix = requestPath.slice(2) + const importerRoot = normalizedUrl(importer.sourceRoot, true) + const direct = lookupSourceAlias(new URL(suffix, importerRoot), suffix, importer, index, specifier) + if (direct !== undefined) { + return direct + } + + const matches = new Map() + for (const sourceRoot of index.sourceRoots) { + if (sourceRoot.href === importer.sourceRoot) { + continue + } + const match = lookupSourceAlias(new URL(suffix, sourceRoot), suffix, importer, index, specifier) + if (match !== undefined) { + matches.set(match.file.index, match) + } + } + if (matches.size > 1) { + throw new Error(`Ambiguous installed registry import ${specifier} in ${importer.entry.standard!.path}`) + } + return matches.values().next().value as FileAlias | undefined +} + +function resolveAliasedImport( + requestPath: string, + importer: KnownStandardFile, + index: StandardFileIndex, + specifier: string, +): FileMatch | undefined { + const sourceMatch = resolveSourceAliasImport(requestPath, importer, index, specifier) + if (sourceMatch !== undefined) { + return { ...sourceMatch, style: 'source-alias' } + } + + const suffix = requestPath.slice(2) + const srcMatch = lookupLogicalAlias(`src/${suffix}`, suffix, importer, index, specifier) + if (srcMatch !== undefined) { + return { ...srcMatch, style: 'src-alias' } + } + if (suffix === 'src' || suffix.startsWith('src/')) { + const projectMatch = lookupLogicalAlias(suffix, suffix, importer, index, specifier) + if (projectMatch !== undefined) { + return { ...projectMatch, style: 'project-alias' } + } + } + return undefined +} + +function splitSpecifier(specifier: string): { path: string, suffix: string } { + const query = specifier.indexOf('?') + const hash = specifier.indexOf('#') + let split = specifier.length + if (query >= 0) { + split = query + } + if (hash >= 0 && hash < split) { + split = hash + } + return { path: specifier.slice(0, split), suffix: specifier.slice(split) } +} + +function formatTargetPath(match: FileMatch, requestPath: string): string { + let target = match.file.entry.to + const targetExtension = posix.extname(target) + if (codeExtensions[targetExtension.toLowerCase()] !== true) { + return target + } + + const requestExtension = posix.extname(requestPath) + if (match.form === 'index' && posix.basename(target, targetExtension) === 'index') { + target = posix.dirname(target) + } + else if (requestExtension === '') { + target = target.slice(0, -targetExtension.length) + } + else if (codeExtensions[requestExtension.toLowerCase()] === true) { + target = `${target.slice(0, -targetExtension.length)}${requestExtension}` + } + return target +} + +function relocatedSpecifier(match: FileMatch, importer: KnownStandardFile, requestPath: string): string { + const target = formatTargetPath(match, requestPath) + if (match.style === 'relative' || match.style === 'source-alias') { + const relocated = posix.relative(posix.dirname(importer.entry.to), target) + return relocated.startsWith('.') ? relocated : `./${relocated}` + } + + const prefix = requestPath.slice(0, 2) + if (match.style === 'project-alias') { + return `${prefix}${target}` + } + return `${prefix}${target.slice('src/'.length)}` +} + +function resolveModuleSpecifier( + specifier: string, + importer: KnownStandardFile, + index: StandardFileIndex, +): string | undefined { + const split = splitSpecifier(specifier) + let match: FileMatch | undefined + if (split.path === '.' || split.path === '..' || split.path.startsWith('./') || split.path.startsWith('../')) { + match = resolveRelativeImport(split.path, importer, index, specifier) + } + else if (split.path.startsWith('@/') || split.path.startsWith('~/')) { + match = resolveAliasedImport(split.path, importer, index, specifier) + } + if (match === undefined) { + return undefined + } + + const relocated = `${relocatedSpecifier(match, importer, split.path)}${split.suffix}` + return relocated === specifier ? undefined : relocated +} + +function isSyntaxNode(value: unknown): value is SyntaxNode { + return value !== null && typeof value === 'object' && 'type' in value && typeof value.type === 'string' +} + +function moduleLiteral(node: SyntaxNode): SyntaxNode | undefined { + let value: unknown + switch (node.type) { + case 'ImportDeclaration': + case 'ExportNamedDeclaration': + case 'ExportAllDeclaration': + value = node.source + break + case 'ImportExpression': + value = node.source + break + case 'TSImportType': + value = node.argument ?? node.source + break + case 'TSExternalModuleReference': + value = node.expression + break + case 'CallExpression': { + const callee = node.callee + if (isSyntaxNode(callee) && callee.type === 'Import' && Array.isArray(node.arguments)) { + value = node.arguments[0] + } + break + } + } + if (!isSyntaxNode(value)) { + return undefined + } + if (value.type === 'StringLiteral') { + return value + } + return value.type === 'TemplateLiteral' + && Array.isArray(value.expressions) + && value.expressions.length === 0 + ? value + : undefined +} + +function moduleLiteralValue(literal: SyntaxNode): string | undefined { + if (literal.type === 'StringLiteral') { + return typeof literal.value === 'string' ? literal.value : undefined + } + + const quasis = literal.quasis + if (!Array.isArray(quasis) || quasis.length !== 1) { + return undefined + } + const quasi = quasis[0] + if (!isSyntaxNode(quasi) || quasi.type !== 'TemplateElement') { + return undefined + } + const value = quasi.value + return value !== null && typeof value === 'object' + && 'cooked' in value && typeof value.cooked === 'string' + ? value.cooked + : undefined +} + +function walkSyntax(root: unknown, visit: (node: SyntaxNode) => void) { + const stack: unknown[] = [root] + const seen = new WeakSet() + while (stack.length > 0) { + const value = stack.pop() + if (value === null || typeof value !== 'object' || seen.has(value)) { + continue + } + seen.add(value) + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + stack.push(value[index]) + } + continue + } + + if (isSyntaxNode(value)) { + visit(value) + } + for (const [key, child] of Object.entries(value)) { + if (key !== 'loc' && key !== 'extra' && key !== 'comments' && key !== 'tokens' && key !== 'errors') { + stack.push(child) + } + } + } +} + +function parserPlugins(language: string | undefined, label: string): NonNullable[1]>['plugins']> { + const normalized = language?.toLowerCase() + const plugins: NonNullable[1]>['plugins']> = ['decorators-legacy'] + if (normalized === 'ts' || normalized === 'typescript' || normalized === 'tsx') { + plugins.push('typescript') + } + else if (normalized !== undefined && normalized !== 'js' && normalized !== 'javascript' && normalized !== 'jsx') { + throw new Error(`Cannot rewrite imports in ${label}: unsupported script language ${language}`) + } + if (normalized === undefined || normalized === 'js' || normalized === 'javascript' || normalized === 'jsx' || normalized === 'tsx') { + plugins.push('jsx') + } + return plugins +} + +type LiteralQuote = '\'' | '"' | '`' + +function escapedLiteralContent(value: string, quote: LiteralQuote): string { + const escaped = value + .replaceAll('\\', '\\\\') + .replaceAll('\r', '\\r') + .replaceAll('\n', '\\n') + .replaceAll('\u2028', '\\u2028') + .replaceAll('\u2029', '\\u2029') + if (quote === '\'') { + return escaped.replaceAll('\'', '\\\'') + } + if (quote === '"') { + return escaped.replaceAll('"', '\\"') + } + return escaped + .replaceAll('`', '\\`') + .replaceAll('${', '\\${') +} + +function collectScriptPatches( + source: string, + sourceOffset: number, + language: string | undefined, + importer: KnownStandardFile, + index: StandardFileIndex, +): TextPatch[] { + let ast: unknown + try { + ast = babelParse(source, { + plugins: parserPlugins(language, importer.entry.standard!.path), + sourceType: 'unambiguous', + }) + } + catch (error) { + throw new Error(`Cannot rewrite imports in ${importer.entry.standard!.path}: ${errorReason(error)}`, { cause: error }) + } + + const patches: TextPatch[] = [] + walkSyntax(ast, (node) => { + const literal = moduleLiteral(node) + if (literal === undefined) { + return + } + const value = moduleLiteralValue(literal) + const start = literal.start + const end = literal.end + if (value === undefined || typeof start !== 'number' || typeof end !== 'number' + || !Number.isInteger(start) || !Number.isInteger(end) + || start < 0 || end > source.length || end - start < 2) { + throw new Error(`Cannot rewrite an unsupported import literal in ${importer.entry.standard!.path}`) + } + + let quote: LiteralQuote + if (literal.type === 'TemplateLiteral') { + if (source[start] !== '`' || source[end - 1] !== '`') { + throw new Error(`Cannot rewrite an unsupported import literal in ${importer.entry.standard!.path}`) + } + quote = '`' + } + else { + const sourceQuote = source[start] + if ((sourceQuote !== '\'' && sourceQuote !== '"') || source[end - 1] !== sourceQuote) { + throw new Error(`Cannot rewrite an unsupported import literal in ${importer.entry.standard!.path}`) + } + quote = sourceQuote + } + const relocated = resolveModuleSpecifier(value, importer, index) + if (relocated === undefined) { + return + } + patches.push({ + start: sourceOffset + start + 1, + end: sourceOffset + end - 1, + value: escapedLiteralContent(relocated, quote), + }) + }) + return patches +} + +function collectVuePatches(source: string, importer: KnownStandardFile, index: StandardFileIndex): TextPatch[] { + const parsed = parseSfc(source, { filename: importer.entry.standard!.path }) + if (parsed.errors.length > 0) { + throw new Error( + `Cannot rewrite imports in ${importer.entry.standard!.path}: ${parsed.errors.map(errorReason).join('; ')}`, + ) + } + + const patches: TextPatch[] = [] + for (const block of [parsed.descriptor.script, parsed.descriptor.scriptSetup]) { + if (block === null || block.content === '') { + continue + } + const offset = block.loc.start.offset + if (source.slice(offset, offset + block.content.length) !== block.content) { + throw new Error(`Cannot locate a Vue script block in ${importer.entry.standard!.path}`) + } + patches.push(...collectScriptPatches(block.content, offset, block.lang, importer, index)) + } + return patches +} + +function codeLanguage(path: string): string | undefined { + const extension = posix.extname(path).toLowerCase() + switch (extension) { + case '.ts': + case '.mts': + case '.cts': + return 'ts' + case '.tsx': + return 'tsx' + case '.jsx': + return 'jsx' + case '.js': + case '.mjs': + case '.cjs': + return 'js' + default: + return undefined + } +} + +function applyPatches(source: string, patches: TextPatch[]): string { + let rewritten = source + patches.sort((left, right) => right.start - left.start) + for (const patch of patches) { + rewritten = `${rewritten.slice(0, patch.start)}${patch.value}${rewritten.slice(patch.end)}` + } + return rewritten +} + +export function rewriteStandardFileImports( + files: readonly StandardFilePlanEntry[], + contents: readonly Buffer[], +): Buffer[] { + if (files.length !== contents.length) { + throw new Error(`Registry file/content count mismatch: ${files.length} files, ${contents.length} contents`) + } + + const index = buildStandardFileIndex(files) + return files.map((entry, fileIndex) => { + const content = contents[fileIndex]! + const known = index.byEntryIndex[fileIndex] + if (known === undefined) { + return content + } + + const extension = posix.extname(entry.standard!.path).toLowerCase() + const language = codeLanguage(entry.standard!.path) + if (extension !== '.vue' && language === undefined) { + return content + } + if (!isUtf8(content)) { + throw new Error(`Cannot rewrite non-UTF-8 registry code file: ${entry.standard!.path}`) + } + + const source = content.toString('utf8') + const patches = extension === '.vue' + ? collectVuePatches(source, known, index) + : collectScriptPatches(source, 0, language, known, index) + return patches.length === 0 ? content : Buffer.from(applyPatches(source, patches), 'utf8') + }) +} diff --git a/packages/cli/src/standard-registry.ts b/packages/cli/src/standard-registry.ts new file mode 100644 index 00000000..e79973c4 --- /dev/null +++ b/packages/cli/src/standard-registry.ts @@ -0,0 +1,575 @@ +import type { RegistryFile, RegistryItem, RegistryTarget } from '@varo/registry/source' +import type { RegistryInstallPlan, ResolveRegistryOptions } from './index.ts' +import type { + StandardRegistryCatalog, + StandardRegistryFile, + StandardRegistryItem, + StandardRegistryType, +} from './standard-types.ts' +import { lstatSync, readFileSync, realpathSync } from 'node:fs' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { fetchRegistryFile, registryUrl } from './remote-registry.ts' +import { resolveStandardFileTargets } from './standard-files.ts' + +const nativeTypeByStandardType: Record = { + 'registry:block': 'block', + 'registry:component': 'component', + 'registry:ui': 'component', + 'registry:hook': 'hook', + 'registry:composable': 'hook', + 'registry:lib': 'util', + 'registry:file': 'util', + 'registry:page': 'template', + 'registry:theme': 'theme', + 'registry:style': 'theme', + 'registry:item': 'util', +} + +const unsupportedSideEffectFields = ['css', 'cssVars', 'envVars', 'extends', 'tailwind'] as const +function hasInvalidWindowsPathCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) < 32 || '<>:"|?*'.includes(value[index]!)) { + return true + } + } + return false +} +const windowsReservedPathNamePattern = /^(?:aux|com[1-9¹²³]|con|conin\$|conout\$|lpt[1-9¹²³]|nul|prn)$/i + +interface LocalDocumentLocation { + kind: 'local' + key: string + manifestPath: string + sourceRoot: string + canonicalSourceRoot: string +} + +interface RemoteDocumentLocation { + kind: 'remote' + key: string + manifestUrl: string + sourceRoot: string +} + +type DocumentLocation = LocalDocumentLocation | RemoteDocumentLocation + +interface LoadedCatalog { + kind: 'catalog' + location: DocumentLocation + catalog: StandardRegistryCatalog + itemsByName: Map +} + +interface LoadedItem { + kind: 'item' + location: DocumentLocation + item: StandardRegistryItem +} + +type LoadedDocument = LoadedCatalog | LoadedItem + +interface StandardItemContext { + identity: string + item: StandardRegistryItem + location: DocumentLocation + catalog?: LoadedCatalog +} + +function assertObject(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`) + } + return value as Record +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function isWithinRoot(root: string, candidate: string): boolean { + const relativePath = relative(root, candidate) + return relativePath !== '' && relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath) +} + +function assertPortableRelativePath(value: string, label: string) { + if ( + !isNonEmptyString(value) + || value !== value.trim() + || isAbsolute(value) + || /^[a-z]:[\\/]/i.test(value) + || value.includes('\\') + || value.includes('\0') + ) { + throw new Error(`${label} must be a portable relative path: ${value}`) + } + + for (const segment of value.split('/')) { + const extensionIndex = segment.indexOf('.') + const basename = extensionIndex === -1 ? segment : segment.slice(0, extensionIndex) + if ( + segment === '' + || segment === '.' + || segment === '..' + || segment.endsWith('.') + || segment.endsWith(' ') + || hasInvalidWindowsPathCharacter(segment) + || windowsReservedPathNamePattern.test(basename) + ) { + throw new Error(`${label} must be a portable relative path: ${value}`) + } + } +} + +function assertOptionalString(record: Record, field: string, label: string, allowEmpty = false) { + const value = record[field] + if (value !== undefined && (typeof value !== 'string' || (!allowEmpty && value.trim().length === 0))) { + throw new Error(`${label}.${field} must be ${allowEmpty ? 'a string' : 'a non-empty string'}`) + } +} + +function assertStringArray(record: Record, field: string, label: string) { + const value = record[field] + if (value !== undefined && (!Array.isArray(value) || value.some(entry => !isNonEmptyString(entry)))) { + throw new Error(`${label}.${field} must be an array of non-empty strings`) + } +} + +function validateStandardFile(value: unknown, label: string): StandardRegistryFile { + const input = assertObject(value, label) + if (!isNonEmptyString(input.path)) { + throw new Error(`${label}.path must be a non-empty string`) + } + assertPortableRelativePath(input.path, `${label}.path`) + if (!Object.hasOwn(nativeTypeByStandardType, String(input.type))) { + throw new Error(`${label}.type is unsupported: ${String(input.type)}`) + } + if (input.target !== undefined) { + if (!isNonEmptyString(input.target)) { + throw new Error(`${label}.target must be a non-empty string`) + } + const targetPath = input.target.startsWith('~/') ? input.target.slice(2) : input.target + assertPortableRelativePath(targetPath, `${label}.target`) + } + if (input.content !== undefined && typeof input.content !== 'string') { + throw new Error(`${label}.content must be a string`) + } + return input as unknown as StandardRegistryFile +} + +function validateStandardItem(value: unknown, label: string): StandardRegistryItem { + const input = assertObject(value, label) + if (!isNonEmptyString(input.name)) { + throw new Error(`${label}.name must be a non-empty string`) + } + if (!Object.hasOwn(nativeTypeByStandardType, String(input.type))) { + throw new Error(`${label}.type is unsupported: ${String(input.type)}`) + } + + assertOptionalString(input, 'author', label, true) + assertOptionalString(input, '$schema', label) + assertOptionalString(input, 'title', label, true) + assertOptionalString(input, 'description', label, true) + assertOptionalString(input, 'docs', label, true) + assertStringArray(input, 'dependencies', label) + assertStringArray(input, 'categories', label) + assertStringArray(input, 'devDependencies', label) + assertStringArray(input, 'registryDependencies', label) + + if (input.files !== undefined) { + if (!Array.isArray(input.files)) { + throw new TypeError(`${label}.files must be an array`) + } + input.files.forEach((file, index) => validateStandardFile(file, `${label}.files[${index}]`)) + } + + if (input.meta !== undefined) { + const meta = assertObject(input.meta, `${label}.meta`) + if (meta.varo !== undefined) { + const varo = assertObject(meta.varo, `${label}.meta.varo`) + const target = varo.target + if (target !== undefined && target !== 'h5' && target !== 'weapp') { + throw new Error(`${label}.meta.varo.target is unsupported: ${String(target)}`) + } + } + } + + for (const field of unsupportedSideEffectFields) { + if (Object.hasOwn(input, field)) { + throw new Error(`${label}.${field} requires unsupported project configuration side effects`) + } + } + + return input as unknown as StandardRegistryItem +} + +function validateStandardDocument(value: unknown, location: DocumentLocation): LoadedDocument { + const label = `Standard registry document at ${location.key}` + const input = assertObject(value, label) + + if (Object.hasOwn(input, 'items')) { + if (!isNonEmptyString(input.name)) { + throw new Error(`${label}.name must be a non-empty string`) + } + if (!isNonEmptyString(input.homepage)) { + throw new Error(`${label}.homepage must be a non-empty string`) + } + assertOptionalString(input, '$schema', label) + if (!Array.isArray(input.items)) { + throw new TypeError(`${label}.items must be an array`) + } + + const itemsByName = new Map() + for (let index = 0; index < input.items.length; index += 1) { + const item = validateStandardItem(input.items[index], `${label}.items[${index}]`) + if (itemsByName.has(item.name)) { + throw new Error(`${label} contains duplicate item name: ${item.name}`) + } + itemsByName.set(item.name, item) + } + return { + kind: 'catalog', + location, + catalog: input as unknown as StandardRegistryCatalog, + itemsByName, + } + } + + return { + kind: 'item', + location, + item: validateStandardItem(input, label), + } +} + +function remoteUrl(value: string, label: string): URL | undefined { + if (!/^[a-z][a-z\d+.-]*:/i.test(value) || /^[a-z]:[\\/]/i.test(value)) { + return undefined + } + + let url: URL + try { + url = new URL(value) + } + catch (error) { + throw new Error(`${label} is not a valid URL: ${value}`, { cause: error }) + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`${label} must use HTTP or HTTPS`) + } + if (url.username || url.password || url.search || url.hash) { + throw new Error(`${label} must not contain credentials, query parameters, or a fragment`) + } + return url +} + +function remoteDocumentLocation(url: URL): RemoteDocumentLocation { + return { + kind: 'remote', + key: url.href, + manifestUrl: url.href, + sourceRoot: new URL('.', url).href, + } +} + +function localDocumentLocation(manifestPath: string, boundary?: string): LocalDocumentLocation { + let canonicalManifestPath: string + try { + canonicalManifestPath = realpathSync(manifestPath) + } + catch (error) { + throw new Error(`Cannot read standard registry document: ${manifestPath}`, { cause: error }) + } + + if (boundary !== undefined && !isWithinRoot(boundary, canonicalManifestPath)) { + throw new Error(`Standard registry document is outside its source root: ${manifestPath}`) + } + if (!lstatSync(canonicalManifestPath).isFile()) { + throw new Error(`Standard registry document must be a regular file: ${manifestPath}`) + } + + const canonicalSourceRoot = dirname(canonicalManifestPath) + return { + kind: 'local', + key: pathToFileURL(canonicalManifestPath).href, + manifestPath: canonicalManifestPath, + sourceRoot: pathToFileURL(`${canonicalSourceRoot}${sep}`).href, + canonicalSourceRoot, + } +} + +function selectedDocumentLocation(registryRoot: string): DocumentLocation | undefined { + const url = remoteUrl(registryRoot, 'Registry URL') + if (url !== undefined) { + return url.pathname.endsWith('.json') ? remoteDocumentLocation(url) : undefined + } + + const selectedPath = resolve(registryRoot) + const selectedEntry = lstatSync(selectedPath, { throwIfNoEntry: false }) + if (selectedEntry === undefined) { + throw new Error(`Registry path does not exist: ${registryRoot}`) + } + + let canonicalSelectedPath: string + try { + canonicalSelectedPath = realpathSync(selectedPath) + } + catch (error) { + throw new Error(`Cannot resolve registry path: ${registryRoot}`, { cause: error }) + } + const selectedStats = lstatSync(canonicalSelectedPath) + if (selectedStats.isFile()) { + return localDocumentLocation(canonicalSelectedPath) + } + if (!selectedStats.isDirectory()) { + throw new Error(`Registry path must be a directory or JSON file: ${registryRoot}`) + } + + const manifestPath = resolve(canonicalSelectedPath, 'registry.json') + if (lstatSync(manifestPath, { throwIfNoEntry: false }) === undefined) { + return undefined + } + return localDocumentLocation(manifestPath, canonicalSelectedPath) +} + +async function loadDocument( + location: DocumentLocation, + cache: Map>, +): Promise { + const existing = cache.get(location.key) + if (existing !== undefined) { + return existing + } + + const pending = (async () => { + const bytes = location.kind === 'remote' + ? await fetchRegistryFile(location.manifestUrl) + : readFileSync(location.manifestPath) + let input: unknown + try { + input = JSON.parse(bytes.toString('utf8')) as unknown + } + catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw new Error(`Invalid standard registry JSON at ${location.key}: ${reason}`, { cause: error }) + } + return validateStandardDocument(input, location) + })() + + cache.set(location.key, pending) + return pending +} + +function itemContext(document: LoadedItem): StandardItemContext +function itemContext(document: LoadedCatalog, item: StandardRegistryItem): StandardItemContext +function itemContext(document: LoadedDocument, item?: StandardRegistryItem): StandardItemContext { + const standardItem = document.kind === 'item' ? document.item : item! + return { + identity: document.kind === 'catalog' + ? `catalog:${document.location.key}#${encodeURIComponent(standardItem.name)}` + : `item:${document.location.key}#${encodeURIComponent(standardItem.name)}`, + item: standardItem, + location: document.location, + ...(document.kind === 'catalog' ? { catalog: document } : {}), + } +} + +function namedSiblingLocation(context: StandardItemContext, name: string): DocumentLocation { + assertPortableRelativePath(name, 'Standard registry dependency name') + const manifestName = `${name}.json` + if (context.location.kind === 'remote') { + return remoteDocumentLocation(new URL(registryUrl(new URL(context.location.sourceRoot), manifestName))) + } + + const manifestPath = resolve(context.location.canonicalSourceRoot, manifestName) + if (lstatSync(manifestPath, { throwIfNoEntry: false }) === undefined) { + throw new Error(`Unknown standard registry dependency: ${name}`) + } + return localDocumentLocation(manifestPath, context.location.canonicalSourceRoot) +} + +async function resolveDependency( + context: StandardItemContext, + dependency: string, + cache: Map>, +): Promise { + const dependencyUrl = remoteUrl(dependency, 'Standard registry dependency URL') + if (dependencyUrl !== undefined) { + const document = await loadDocument(remoteDocumentLocation(dependencyUrl), cache) + if (document.kind !== 'item') { + throw new Error(`Standard registry dependency URL must resolve to a single item: ${dependency}`) + } + return itemContext(document) + } + + if (context.catalog !== undefined) { + const item = context.catalog.itemsByName.get(dependency) + if (item === undefined) { + throw new Error(`Unknown standard registry dependency in ${context.catalog.catalog.name}: ${dependency}`) + } + return itemContext(context.catalog, item) + } + + const document = await loadDocument(namedSiblingLocation(context, dependency), cache) + if (document.kind !== 'item') { + throw new Error(`Standard registry dependency must resolve to a single item: ${dependency}`) + } + if (document.item.name !== dependency) { + throw new Error( + `Standard registry dependency ${dependency} resolved to item ${document.item.name} at ${document.location.key}`, + ) + } + return itemContext(document) +} + +function declaredTarget(item: StandardRegistryItem): RegistryTarget { + return item.meta?.varo?.target ?? 'h5' +} + +function localSourcePath(location: LocalDocumentLocation, path: string, sourceKey: string): string { + const unresolvedPath = fileURLToPath(sourceKey) + let canonicalPath: string + try { + canonicalPath = realpathSync(unresolvedPath) + } + catch (error) { + throw new Error(`Cannot read standard registry source ${path} from ${location.key}`, { cause: error }) + } + if (!isWithinRoot(location.canonicalSourceRoot, canonicalPath)) { + throw new Error(`Standard registry source is outside its source root: ${path}`) + } + if (!lstatSync(canonicalPath).isFile()) { + throw new Error(`Standard registry source must be a regular file: ${path}`) + } + return canonicalPath +} + +export async function resolveStandardRegistryItems( + names: string[], + options: ResolveRegistryOptions, +): Promise { + if (options.registryRoot === undefined) { + return undefined + } + + const rootLocation = selectedDocumentLocation(options.registryRoot) + if (rootLocation === undefined) { + return undefined + } + + const cache = new Map>() + const rootDocument = await loadDocument(rootLocation, cache) + const requested: StandardItemContext[] = names.map((name) => { + if (rootDocument.kind === 'catalog') { + const item = rootDocument.itemsByName.get(name) + if (item === undefined) { + throw new Error(`Unknown standard registry item in ${rootDocument.catalog.name}: ${name}`) + } + return itemContext(rootDocument, item) + } + if (rootDocument.item.name !== name) { + throw new Error(`Standard registry document contains ${rootDocument.item.name}, not requested item ${name}`) + } + return itemContext(rootDocument) + }) + + const requestedTargets = new Set(requested.map(context => declaredTarget(context.item))) + if (options.target === undefined && requestedTargets.size > 1) { + throw new Error(`Requested standard registry items target multiple runtimes: ${[...requestedTargets].join(', ')}`) + } + const target = options.target ?? requestedTargets.values().next().value ?? 'h5' + const seen = new Set() + const visiting = new Set() + const dependencyStack: StandardItemContext[] = [] + const ordered: StandardItemContext[] = [] + + async function visit(context: StandardItemContext) { + if (seen.has(context.identity)) { + return + } + if (visiting.has(context.identity)) { + const cycleStart = dependencyStack.findIndex(entry => entry.identity === context.identity) + const cycle = [...dependencyStack.slice(cycleStart), context].map(entry => entry.item.name) + throw new Error(`Cyclic standard registry dependency: ${cycle.join(' -> ')}`) + } + + const itemTarget = declaredTarget(context.item) + if (itemTarget !== target) { + throw new Error(`Standard registry item ${context.item.name} targets ${itemTarget}, not ${target}`) + } + + visiting.add(context.identity) + dependencyStack.push(context) + try { + for (const dependency of context.item.registryDependencies ?? []) { + await visit(await resolveDependency(context, dependency, cache)) + } + } + finally { + dependencyStack.pop() + visiting.delete(context.identity) + } + + seen.add(context.identity) + ordered.push(context) + } + + for (const context of requested) { + await visit(context) + } + + const items: RegistryItem[] = [] + const files: RegistryInstallPlan['files'] = [] + for (const context of ordered) { + const destinations = await resolveStandardFileTargets(context.item, options.projectRoot) + const normalizedFiles: RegistryFile[] = [] + + for (const destination of destinations) { + const standardFile = destination.file + const sourceKey = registryUrl(new URL(context.location.sourceRoot), standardFile.path) + const sourcePath = context.location.kind === 'remote' + ? sourceKey + : standardFile.content !== undefined + ? fileURLToPath(sourceKey) + : localSourcePath(context.location, standardFile.path, sourceKey) + const normalizedFile: RegistryFile = { + target, + from: standardFile.path, + to: destination.to, + } + normalizedFiles.push(normalizedFile) + files.push({ + ...normalizedFile, + item: context.item.name, + sourcePath, + targetPath: destination.to, + ...(standardFile.content !== undefined ? { content: standardFile.content } : {}), + standard: { + path: standardFile.path, + sourceKey, + sourceRoot: context.location.sourceRoot, + defaultTo: destination.defaultTo, + }, + }) + } + + items.push({ + name: context.item.name, + type: nativeTypeByStandardType[context.item.type], + title: context.item.title ?? context.item.name, + description: context.item.description ?? `Standard registry item ${context.item.name}`, + docs: context.item.docs + ?? context.catalog?.catalog.homepage + ?? `Standard registry item ${context.item.name}`, + targets: [target], + dependencies: [...(context.item.dependencies ?? [])], + devDependencies: [...(context.item.devDependencies ?? [])], + registryDependencies: [...(context.item.registryDependencies ?? [])], + files: normalizedFiles, + }) + } + + const dependencies = Array.from(new Set(ordered.flatMap(context => context.item.dependencies ?? []))).sort() + const devDependencies = Array.from(new Set(ordered.flatMap(context => context.item.devDependencies ?? []))).sort() + return { dependencies, devDependencies, files, items, target } +} diff --git a/packages/cli/src/standard-types.ts b/packages/cli/src/standard-types.ts new file mode 100644 index 00000000..aadef528 --- /dev/null +++ b/packages/cli/src/standard-types.ts @@ -0,0 +1,63 @@ +import type { RegistryTarget } from '@varo/registry/source' + +export type StandardRegistryType + = | 'registry:block' + | 'registry:component' + | 'registry:ui' + | 'registry:hook' + | 'registry:composable' + | 'registry:lib' + | 'registry:file' + | 'registry:page' + | 'registry:theme' + | 'registry:style' + | 'registry:item' + +export interface StandardRegistryFile { + path: string + type: StandardRegistryType + target?: string + content?: string +} + +export interface StandardRegistryItem { + $schema?: string + name: string + type: StandardRegistryType + title?: string + description?: string + docs?: string + files?: StandardRegistryFile[] + dependencies?: string[] + devDependencies?: string[] + registryDependencies?: string[] + meta?: { varo?: { target?: RegistryTarget }, [key: string]: unknown } + [key: string]: unknown +} + +export interface StandardRegistryCatalog { + $schema?: string + name: string + homepage: string + items: StandardRegistryItem[] +} + +export interface StandardFileDestination { + file: StandardRegistryFile + to: string + defaultTo: string +} + +export interface StandardFileOrigin { + path: string + sourceKey: string + sourceRoot: string + defaultTo: string +} + +export interface StandardFilePlanEntry { + to: string + sourcePath: string + content?: string + standard?: StandardFileOrigin +} diff --git a/packages/cli/tests/standard-registry.test.ts b/packages/cli/tests/standard-registry.test.ts new file mode 100644 index 00000000..b040ecfe --- /dev/null +++ b/packages/cli/tests/standard-registry.test.ts @@ -0,0 +1,309 @@ +// @vitest-environment node +import type { Server } from 'node:http' +import { execFile } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import { exportRegistryItem, installRegistryItems } from '../src/index.ts' + +const execute = promisify(execFile) +const workspaceRoot = resolve(__dirname, '../../..') +const cli = resolve(__dirname, '../src/index.ts') +const roots: string[] = [] +const servers: Server[] = [] +const componentSource = '\n\n' +const hookSource = 'export function useHelloWorld() { return "Hello from the relocated hook" }\n' + +function temporaryRoot() { + const root = mkdtempSync(join(tmpdir(), 'varo-standard-')) + roots.push(root) + return root +} + +function writeJson(path: string, value: unknown) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(value, null, 2)) +} + +function sourceItem(withHook = true) { + return { + $schema: 'https://shadcn-vue.com/schema/registry-item.json', + name: 'hello-world', + type: 'registry:block', + title: 'Hello World', + description: 'A simple hello world component.', + files: [ + { path: 'registry/new-york/HelloWorld/HelloWorld.vue', type: 'registry:component' }, + ...(withHook ? [{ path: 'registry/new-york/HelloWorld/useHelloWorld.ts', type: 'registry:hook' }] : []), + ], + } +} + +function writeSourceFiles(root: string, withHook = true) { + const directory = join(root, 'registry/new-york/HelloWorld') + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'HelloWorld.vue'), withHook ? componentSource : '\n') + if (withHook) { writeFileSync(join(directory, 'useHelloWorld.ts'), hookSource) } +} + +async function serve(routes: Record) { + const requests: string[] = [] + const server = createServer((request, response) => { + const path = request.url! + requests.push(path) + if (!(path in routes)) { response.writeHead(404).end(); return } + const value = routes[path] + response.end(typeof value === 'string' ? value : JSON.stringify(value)) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { throw new Error('Missing fixture server address') } + return { base: `http://127.0.0.1:${address.port}`, requests } +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()) + server.closeAllConnections() + }))) + for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } +}) + +describe('standard shadcn Registry inputs', () => { + it('installs the supplied catalog schema without Varo-specific fields', async () => { + const sourceRoot = temporaryRoot() + const projectRoot = temporaryRoot() + const registryRoot = join(sourceRoot, 'registry.json') + writeSourceFiles(sourceRoot, false) + writeJson(registryRoot, { + $schema: 'https://shadcn-vue.com/schema/registry.json', + name: 'shadcn', + homepage: 'https://shadcn-vue.com', + items: [sourceItem(false)], + }) + const { stdout } = await execute(process.execPath, [cli, 'add', '--registry', registryRoot, 'hello-world'], { cwd: projectRoot }) + expect(stdout).toContain('for h5') + expect(readFileSync(join(projectRoot, 'src/components/HelloWorld/HelloWorld.vue'), 'utf8')) + .toBe('\n') + }) + + it('installs the supplied item schema and leaves a compilable Vue/hook consumer', async () => { + const sourceRoot = temporaryRoot() + const projectRoot = temporaryRoot() + const registryRoot = join(sourceRoot, 'hello-world.json') + writeSourceFiles(sourceRoot) + writeJson(registryRoot, sourceItem()) + await installRegistryItems(['hello-world'], { registryRoot, projectRoot }) + + expect(readFileSync(join(projectRoot, 'src/composables/HelloWorld/useHelloWorld.ts'), 'utf8')).toBe(hookSource) + const installedComponent = readFileSync(join(projectRoot, 'src/components/HelloWorld/HelloWorld.vue'), 'utf8') + expect(installedComponent).toContain('const literal = "./useHelloWorld"') + writeJson(join(projectRoot, 'package.json'), { type: 'module' }) + writeJson(join(projectRoot, 'tsconfig.json'), { + compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', strict: true, noEmit: true, skipLibCheck: true, types: [] }, + include: ['src/**/*.ts', 'src/**/*.vue'], + }) + symlinkSync(join(workspaceRoot, 'node_modules'), join(projectRoot, 'node_modules'), 'dir') + await execute(process.execPath, [join(workspaceRoot, 'node_modules/vue-tsc/bin/vue-tsc.js'), '--noEmit', '-p', join(projectRoot, 'tsconfig.json')], { cwd: projectRoot }) + + const exported = await exportRegistryItem('hello-world', { registryRoot, target: 'h5', projectRoot }) + expect(exported.files.map(file => file.target)).toEqual([ + '~/src/components/HelloWorld/HelloWorld.vue', + '~/src/composables/HelloWorld/useHelloWorld.ts', + ]) + expect(exported.files[0]!.content).toBe(installedComponent) + }) + + it('honors consumer aliases resolved from a referenced JSONC app tsconfig', async () => { + const sourceRoot = temporaryRoot() + const projectRoot = temporaryRoot() + writeSourceFiles(sourceRoot) + writeFileSync(join(sourceRoot, 'registry/new-york/HelloWorld/HelloWorld.vue'), componentSource.replace('from "./useHelloWorld"', 'from "@/composables/HelloWorld/useHelloWorld"')) + writeJson(join(sourceRoot, 'registry.json'), { name: 'custom', homepage: 'https://example.com', items: [sourceItem()] }) + writeJson(join(projectRoot, 'components.json'), { aliases: { components: '@/widgets', composables: '@/domain', ui: '@/widgets/ui', lib: '@/lib', utils: '@/lib/utils' } }) + writeJson(join(projectRoot, 'tsconfig.json'), { files: [], references: [{ path: './tsconfig.app.json' }] }) + writeFileSync(join(projectRoot, 'tsconfig.app.json'), '{\n // Consumer-owned alias configuration\n "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "noEmit": true, "skipLibCheck": true, "types": [], "paths": { "@/*": ["./src/*"], }, },\n "include": ["src/**/*.ts", "src/**/*.vue"],\n}\n') + await installRegistryItems(['hello-world'], { registryRoot: sourceRoot, projectRoot }) + expect(existsSync(join(projectRoot, 'src/widgets/HelloWorld/HelloWorld.vue'))).toBe(true) + expect(readFileSync(join(projectRoot, 'src/domain/HelloWorld/useHelloWorld.ts'), 'utf8')).toBe(hookSource) + expect(existsSync(join(projectRoot, 'src/components'))).toBe(false) + symlinkSync(join(workspaceRoot, 'node_modules'), join(projectRoot, 'node_modules'), 'dir') + await execute(process.execPath, [join(workspaceRoot, 'node_modules/vue-tsc/bin/vue-tsc.js'), '--noEmit', '-p', join(projectRoot, 'tsconfig.app.json')], { cwd: projectRoot }) + }) + + it('installs inline published files including empty content and explicit Weapp metadata', async () => { + const sourceRoot = temporaryRoot() + const projectRoot = temporaryRoot() + const registryRoot = join(sourceRoot, 'published.json') + writeJson(registryRoot, { + name: 'published', + type: 'registry:file', + meta: { varo: { target: 'weapp' } }, + files: [{ path: 'not-present.ts', type: 'registry:file', target: '~/src/lib/empty.ts', content: '' }], + }) + const plan = await installRegistryItems(['published'], { registryRoot, projectRoot }) + expect(plan.target).toBe('weapp') + expect(readFileSync(join(projectRoot, 'src/lib/empty.ts'), 'utf8')).toBe('') + await expect(installRegistryItems(['published'], { registryRoot, projectRoot, target: 'h5', force: true })).rejects.toThrow(/target|h5/i) + }) + + it('reads HTTP catalogs and encodes literal source path delimiters', async () => { + const projectRoot = temporaryRoot() + const filePath = 'registry/new-york/HelloWorld/Hello #世界.vue' + const source = '\n' + const { base, requests } = await serve({ + '/r/registry.json': { name: 'remote', homepage: 'https://example.com', items: [{ ...sourceItem(false), files: [{ path: filePath, type: 'registry:component' }] }] }, + '/r/registry/new-york/HelloWorld/Hello%20%23%E4%B8%96%E7%95%8C.vue': source, + }) + await installRegistryItems(['hello-world'], { registryRoot: `${base}/r/registry.json`, projectRoot }) + expect(readFileSync(join(projectRoot, 'src/components/HelloWorld/Hello #世界.vue'), 'utf8')).toBe(source) + expect(requests).toContain('/r/registry/new-york/HelloWorld/Hello%20%23%E4%B8%96%E7%95%8C.vue') + }) + + it('keeps equal dependency names from distinct URL origins separate', async () => { + const sourceRoot = temporaryRoot() + const projectRoot = temporaryRoot() + const { base } = await serve({ + '/a/shared.json': { name: 'shared', type: 'registry:file', files: [{ path: 'shared.ts', type: 'registry:file', target: '~/src/lib/a.ts', content: 'export const a = 1\n' }] }, + '/b/shared.json': { name: 'shared', type: 'registry:file', files: [{ path: 'shared.ts', type: 'registry:file', target: '~/src/lib/b.ts', content: 'export const b = 2\n' }] }, + }) + const registryRoot = join(sourceRoot, 'root.json') + writeJson(registryRoot, { name: 'root', type: 'registry:block', registryDependencies: [`${base}/a/shared.json`, `${base}/b/shared.json`], files: [{ path: 'root.ts', type: 'registry:file', target: '~/src/lib/root.ts', content: 'export const root = true\n' }] }) + await installRegistryItems(['root'], { registryRoot, projectRoot }) + expect(readFileSync(join(projectRoot, 'src/lib/a.ts'), 'utf8')).toBe('export const a = 1\n') + expect(readFileSync(join(projectRoot, 'src/lib/b.ts'), 'utf8')).toBe('export const b = 2\n') + }) + + it('rejects cyclic catalog dependencies before creating consumer files', async () => { + const sourceRoot = temporaryRoot() + const projectRoot = temporaryRoot() + const registryRoot = join(sourceRoot, 'registry.json') + writeJson(registryRoot, { name: 'cycles', homepage: 'https://example.com', items: [ + { name: 'alpha', type: 'registry:block', registryDependencies: ['beta'], files: [{ path: 'alpha.ts', type: 'registry:file', target: '~/src/alpha.ts', content: 'alpha' }] }, + { name: 'beta', type: 'registry:block', registryDependencies: ['alpha'], files: [{ path: 'beta.ts', type: 'registry:file', target: '~/src/beta.ts', content: 'beta' }] }, + ] }) + await expect(installRegistryItems(['alpha'], { registryRoot, projectRoot })).rejects.toThrow(/cycl/i) + expect(existsSync(join(projectRoot, 'src'))).toBe(false) + }) + + it('rejects source symlinks outside the selected standard Registry root', async () => { + const root = temporaryRoot() + const sourceRoot = join(root, 'registry-project') + const projectRoot = temporaryRoot() + writeSourceFiles(sourceRoot, false) + const sourcePath = join(sourceRoot, 'registry/new-york/HelloWorld/HelloWorld.vue') + const outside = join(root, 'outside.vue') + writeFileSync(outside, '') + rmSync(sourcePath) + symlinkSync(outside, sourcePath) + const registryRoot = join(sourceRoot, 'hello-world.json') + writeJson(registryRoot, sourceItem(false)) + await expect(installRegistryItems(['hello-world'], { registryRoot, projectRoot })).rejects.toThrow(/outside|within|escape/i) + expect(existsSync(join(projectRoot, 'src'))).toBe(false) + }) + + it('refuses standard targets outside src even with force', async () => { + const root = temporaryRoot() + const projectRoot = temporaryRoot() + const registryRoot = join(root, 'escape.json') + writeJson(join(projectRoot, 'package.json'), { name: 'consumer' }) + const original = readFileSync(join(projectRoot, 'package.json'), 'utf8') + writeJson(registryRoot, { name: 'escape', type: 'registry:file', files: [{ path: 'package.json', type: 'registry:file', target: '~/package.json', content: '{"name":"overwrite"}' }] }) + await expect(installRegistryItems(['escape'], { registryRoot, projectRoot, force: true })).rejects.toThrow(/src|target/i) + expect(readFileSync(join(projectRoot, 'package.json'), 'utf8')).toBe(original) + }) + + it('accepts empty optional standard metadata without rejecting valid files', async () => { + const root = temporaryRoot() + const projectRoot = temporaryRoot() + const registryRoot = join(root, 'blank.json') + writeJson(registryRoot, { + name: 'blank', + type: 'registry:block', + author: '', + title: '', + description: '', + docs: '', + files: [{ path: 'value.ts', type: 'registry:file', target: '~/src/lib/value.ts', content: 'export const value = 42\n' }], + }) + await installRegistryItems(['blank'], { registryRoot, projectRoot }) + expect(readFileSync(join(projectRoot, 'src/lib/value.ts'), 'utf8')).toBe('export const value = 42\n') + }) + + it('does not follow sibling dependency manifests outside their source root', async () => { + const root = temporaryRoot() + const sourceRoot = join(root, 'registry-project') + const projectRoot = temporaryRoot() + const registryRoot = join(sourceRoot, 'root.json') + writeJson(registryRoot, { + name: 'root', + type: 'registry:block', + registryDependencies: ['shared'], + files: [{ path: 'root.ts', type: 'registry:file', target: '~/src/lib/root.ts', content: 'export const root = true\n' }], + }) + const outside = join(root, 'outside.json') + writeJson(outside, { + name: 'shared', + type: 'registry:file', + files: [{ path: 'secret.ts', type: 'registry:file', target: '~/src/lib/secret.ts', content: 'secret' }], + }) + symlinkSync(outside, join(sourceRoot, 'shared.json')) + await expect(installRegistryItems(['root'], { registryRoot, projectRoot })).rejects.toThrow(/outside|source root/i) + expect(existsSync(join(projectRoot, 'src'))).toBe(false) + }) + + it.each([{ content: '' }, { standard: { path: 'untrusted.ts' } }])('preserves native source authority despite reserved extension fields %j', async (extension) => { + const root = temporaryRoot() + const projectRoot = temporaryRoot() + const registryRoot = join(root, 'registry') + const itemDirectory = join(registryRoot, 'utils/native') + writeJson(join(itemDirectory, 'registry.json'), { + name: 'native', + type: 'util', + title: 'Native', + description: 'Native source authority', + targets: ['h5'], + dependencies: [], + registryDependencies: [], + docs: '/native', + files: [{ target: 'h5', from: 'registry/utils/native/value.ts', to: 'src/lib/value.ts', ...extension }], + }) + writeFileSync(join(itemDirectory, 'value.ts'), 'export const value = "authored native source"\n') + await installRegistryItems(['utils/native'], { registryRoot, projectRoot, target: 'h5' }) + expect(readFileSync(join(projectRoot, 'src/lib/value.ts'), 'utf8')).toBe('export const value = "authored native source"\n') + }) + + it('rejects unsupported style inheritance before writing consumer files', async () => { + const sourceRoot = temporaryRoot() + const projectRoot = temporaryRoot() + const registryRoot = join(sourceRoot, 'style.json') + writeJson(registryRoot, { + name: 'style', + type: 'registry:style', + extends: 'new-york', + files: [{ path: 'theme.css', type: 'registry:file', target: '~/src/theme.css', content: ':root { color: red }\n' }], + }) + await expect(installRegistryItems(['style'], { registryRoot, projectRoot })).rejects.toThrow(/extends|inherit/i) + expect(existsSync(join(projectRoot, 'src'))).toBe(false) + }) + + it('keeps no-substitution dynamic imports executable after relocation', async () => { + const sourceRoot = temporaryRoot() + const projectRoot = temporaryRoot() + const registryRoot = join(sourceRoot, 'hello-world.json') + const item = sourceItem() + item.files[0]!.path = 'registry/new-york/HelloWorld/load.ts' + writeSourceFiles(sourceRoot) + // Exercise the consumer's lazy module-loading boundary; a static import would miss this regression. + writeFileSync(join(sourceRoot, item.files[0]!.path), 'export const load = () => import(`./useHelloWorld.ts`)\n') + writeJson(registryRoot, item) + writeJson(join(projectRoot, 'package.json'), { type: 'module' }) + await installRegistryItems(['hello-world'], { registryRoot, projectRoot }) + const { stdout } = await execute(process.execPath, ['--input-type=module', '-e', 'const { load } = await import("./src/components/HelloWorld/load.ts"); console.log((await load()).useHelloWorld())'], { cwd: projectRoot }) + expect(stdout.trim()).toBe('Hello from the relocated hook') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f61c01ad..982600b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -298,6 +298,13 @@ importers: packages/build: {} packages/cli: + dependencies: + '@vue/compiler-sfc': + specifier: 3.5.42 + version: 3.5.42 + get-tsconfig: + specifier: 4.14.3 + version: 4.14.3 devDependencies: '@varo/registry': specifier: workspace:*