From 232be799c416cae2b1e4a062933c2f15f4d31702 Mon Sep 17 00:00:00 2001 From: chengpeiquan Date: Thu, 22 Oct 2026 23:58:12 +0800 Subject: [PATCH] feat: add preset include filters and safe apply command --- README.md | 15 +- docs/local-debugging.md | 18 +- docs/preset-configs.md | 46 ++- docs/private-presets.md | 21 + docs/src/contents/en/examples/skills.mdx | 10 +- .../src/contents/en/guide/getting-started.mdx | 10 + docs/src/contents/en/guide/preset-configs.md | 46 ++- docs/src/contents/en/guide/private-presets.md | 21 + docs/src/contents/zh/examples/skills.mdx | 10 +- .../src/contents/zh/guide/getting-started.mdx | 10 + docs/src/contents/zh/guide/preset-configs.md | 46 ++- docs/src/contents/zh/guide/private-presets.md | 21 + schema/preset.schema.json | 9 + scripts/test-package.js | 25 +- src/apply.rs | 371 ++++++++++++++++++ src/cli.rs | 27 +- src/commands/apply.rs | 46 +++ src/commands/init.rs | 50 ++- src/commands/mod.rs | 1 + src/main.rs | 22 +- src/preset.rs | 238 ++++++++++- src/types.rs | 15 + tests/cli.rs | 214 ++++++++++ 23 files changed, 1231 insertions(+), 61 deletions(-) create mode 100644 src/apply.rs create mode 100644 src/commands/apply.rs diff --git a/README.md b/README.md index 5b9c3ad..edc501c 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ package manager from the current npm user agent and falls back to npm. An unknown explicit `--from` value exits with an error; it never switches to interactive selection. Run `preset --list` to see registered IDs. -`--dry-run` prints a JSON creation plan without cloning repositories, running +`init --dry-run` prints a JSON creation plan without cloning repositories, running generators, resolving npm versions, or changing project files. Remote config URLs are still fetched and validated. `--yes` skips only the confirmation for replacing existing target files; official generators keep their own prompts. @@ -101,6 +101,17 @@ Private presets are generated in a temporary directory before replacing the target. Original target files are backed up while an official generator runs and restored if it fails or exits without creating a project. +## Apply presets to existing projects + +```bash +preset apply .agents/skills --from https://example.com/skills.json --include code-review --include testing +preset apply .agents/skills --from ./skills.json --dry-run +``` + +`apply` adds missing files, skips identical files, and fails on conflicts before writing. It preserves unrelated files and does not automatically rewrite `package.json.name`. It accepts JSON presets, including registered private configs. `--include` is repeatable on both `init` and `apply`, replaces the config's `include` list, and keeps configured exclusions active. + +`apply --dry-run` generates temporary files to report real additions and conflicts, so it clones the source and may resolve workspace dependencies. `init --dry-run` remains a plan-only preview. Neither changes target files. See [Preset configs](docs/preset-configs.md) for validation and rollback limits. + ## Official generators Official generators remain responsible for their own prompts, dependencies, @@ -145,7 +156,7 @@ starter into a new project: preset init my-docs --from https://example.com/preset.json ``` -Supported operations include selecting a monorepo directory, excluding files, +Supported operations include selecting a monorepo directory, including or excluding files, writing files, replacing text, merging JSON, and resolving `workspace:` dependencies. See [Preset configs](docs/preset-configs.md) and the published [schema](https://preset.js.org/schema/preset.schema.json). diff --git a/docs/local-debugging.md b/docs/local-debugging.md index 239fb75..22bd538 100644 --- a/docs/local-debugging.md +++ b/docs/local-debugging.md @@ -48,7 +48,7 @@ target/release/preset init demo --from vite --dry-run target/release/preset init demo --from ./preset.json --dry-run ``` -`--dry-run` prints the creation plan as JSON and does not run external commands +`init --dry-run` prints the creation plan as JSON and does not run external commands or modify the target. Remote config URLs are fetched and validated. Unknown `--from` values fail immediately instead of starting an interactive fallback. @@ -142,6 +142,22 @@ reset the package name. Test the public delivery path with an HTTPS URL: See [Preset configs](preset-configs.md) for the contract and limits. +## Apply and selection checks + +```bash +"$PRESET_BIN" apply .agents/skills --from ./skills.json --include code-review --dry-run +"$PRESET_BIN" apply .agents/skills --from ./skills.json --include code-review +``` + +Run from a temporary project. Check that only the selected skill appears and an unrelated local file survives. Repeat the command: identical files should move from `add` to `skip`. Edit one installed file and repeat: the command must exit nonzero, report a conflict, and leave all target files intact. `apply --dry-run` downloads and materializes the preset in temporary storage but must not create a missing target or its parents. + +Automated CLI and transaction tests cover empty/missing selections, exclusion precedence, CLI overrides, symbolic links, conflicts, and rollback after partial publication: + +```bash +cargo test --all-targets +cargo clippy --all-targets -- -D warnings +``` + ## Private preset test Create a local JSON file outside the repository, for example diff --git a/docs/preset-configs.md b/docs/preset-configs.md index 4a1d2c5..b95156c 100644 --- a/docs/preset-configs.md +++ b/docs/preset-configs.md @@ -63,12 +63,52 @@ Add `$schema` for editor completion and validation: `version` must be `1`. `source.repo` is required. `source.directory` selects a subdirectory inside the cloned repository and defaults to the repository root. +## Select files with include and exclude + +`include` lists exact files or directories relative to `source.directory`. Directories include their descendants; paths are not globs. Omit `include` to select all source files. With both fields present, `exclude` takes precedence. A selected directory is not flattened in the output. + +```json +{ + "version": 1, + "source": { + "repo": "https://github.com/example/team-resources.git", + "directory": "skills" + }, + "include": ["code-review", "testing"], + "exclude": ["testing/fixtures"] +} +``` + +An empty or null `include`, a missing selected path, or a selection with no files after exclusions fails. Symbolic links and paths outside the source are rejected. When `include` is present, file operations must stay within that selection and cannot recreate excluded files. Without `include`, existing transformation behavior is unchanged. + +Repeat `--include` on `init` or `apply` to replace the entire config selection. Configured exclusions still apply. This option requires a JSON preset; it is not passed to official generators. + +```bash +preset apply .agents/skills --from ./skills.json --include code-review --include writing +``` + +A static website can publish a base JSON config and turn checked items into repeated `--include` arguments. Department presets can keep their selections in JSON. + +## Add files to an existing project + +```bash +preset apply .agents/skills --from ./skills.json --dry-run +preset apply .agents/skills --from ./skills.json +``` + +`apply` accepts a local/remote JSON config or a registered private preset backed by a config. It adds missing files, skips identical files, and reports differing content or file types as conflicts before writing anything. On Unix, executable permission differences also count as conflicts. Unrelated files remain intact, and `package.json.name` is not automatically rewritten. Empty directories alone are not installed. + +The command prints JSON with `add`, `skip`, and `conflicts`; conflicts produce a nonzero exit status in both normal and dry-run modes. `apply` has no `--yes` or overwrite mode. It rejects symbolic links along destination paths and does not follow them when publishing files. It does not synchronize deletions or automatically update modified files. + +Unlike `init --dry-run`, `apply --dry-run` clones the source and runs configured transformations in temporary storage to compare actual files. It may query npm if workspace resolution is enabled, but does not create target directories or modify target files. Normal publication errors roll back this run's additions. Recovery failures report retained paths; forced termination or a system crash can leave partial additions. Avoid editing destination paths concurrently with an apply operation. + ## Operations After selecting the source directory, Create Preset copies its files with -`exclude` applied, then runs the remaining operations: +`include` and `exclude` applied, then runs the remaining operations: -- `exclude` skips repository paths before copying. +- `include` selects exact source-relative files or directories; omission selects all. +- `exclude` removes paths from that selection before copying. - `write` creates or replaces a UTF-8 text file from an array of lines. - `replace` replaces every exact text match in an existing UTF-8 file. - `json` applies a recursive merge patch to an existing JSON object. A `null` @@ -149,7 +189,7 @@ in that case. The runtime enforces these boundaries: - remote and local config files are limited to 1 MiB; -- config operation counts are limited to 1,000; +- config operation counts (including include/exclude paths) are limited to 1,000; - unknown fields and unsupported versions are rejected; - paths must stay relative to the generated project; - source symlinks are rejected; diff --git a/docs/private-presets.md b/docs/private-presets.md index 6894f05..dfa1661 100644 --- a/docs/private-presets.md +++ b/docs/private-presets.md @@ -114,3 +114,24 @@ preserved. For a `config` entry, the referenced plan controls the checkout and file transformations. See [Preset configs](preset-configs.md). + +## Apply a team preset + +Register a config-backed preset for a reusable team selection: + +```json +{ + "tech": "skills", + "name": "team-skills", + "config": "./skills.json" +} +``` + +After adding this entry to the bound manifest, use its name to add files to a project: + +```bash +preset apply .agents/skills --from team-skills +preset apply .agents/skills --from team-skills --include code-review --dry-run +``` + +`apply` accepts `config` entries, not bare `repo` entries or official generators. It preserves unrelated files, skips identical files, and stops on conflicts. Repeated `--include` arguments replace the config selection; exclusions remain active. See [Preset configs](preset-configs.md) for the JSON format and preview behavior. diff --git a/docs/src/contents/en/examples/skills.mdx b/docs/src/contents/en/examples/skills.mdx index ffdc6fe..7a7d5ba 100644 --- a/docs/src/contents/en/examples/skills.mdx +++ b/docs/src/contents/en/examples/skills.mdx @@ -20,12 +20,16 @@ If the agent reads skills from another location, change the target path in the c ## Existing skills -**For a nonempty target, the CLI asks to replace the entire directory. It does not merge individual skills.** Generate into a new temporary directory first: +Use `apply` to add skills while preserving the existing directory: ```bash -pnpm create preset init skills-preview --from ./presets/skills.json +pnpm create preset apply .agents/skills --from ./presets/skills.json --include code-review --include writing ``` -Check the files, then copy the skill folders needed into the project. Compare same-name files before overwriting them. Later updates can be fetched and compared the same way. Create Preset does not sync updates automatically or link skills into other agents' directories. +Repeated `--include` arguments replace the config's selection. Configured `exclude` paths still apply. Omit `--include` to use the config's list, or select all source files if it has no list. + +Add `--dry-run` to generate temporary files and compare them before installation. Identical files are skipped. Different content, file types, or Unix executable permissions are reported as conflicts before any target writes. Resolve conflicts manually; `apply` does not overwrite or delete existing files. `init` still replaces a nonempty target after confirmation. + +Create Preset does not sync updates automatically or link skills into other agents' directories. See [Preset configs](/guide/preset-configs) for selection and rollback details. Each preset reads one source repository. Skills from several repositories need collecting in one repository before this config can distribute them together. diff --git a/docs/src/contents/en/guide/getting-started.mdx b/docs/src/contents/en/guide/getting-started.mdx index 2c633e5..81dd771 100644 --- a/docs/src/contents/en/guide/getting-started.mdx +++ b/docs/src/contents/en/guide/getting-started.mdx @@ -79,6 +79,16 @@ preset init my-docs --from ./preset.json --yes `--yes` skips only Create Preset's replacement confirmation. Official generators keep their own prompts. +## Add files to an existing project + +Use a JSON preset with `apply` to preserve unrelated files and stop on conflicts: + +```bash +preset apply .agents/skills --from ./skills.json --include code-review --include testing +``` + +Add `--dry-run` to compare actual generated files without modifying the target. This preview clones the source into temporary storage. See [Preset configs](/guide/preset-configs) for selection and conflict rules. + ## Change the language Create Preset follows the system language and supports English, Simplified Chinese, Traditional Chinese, and Japanese. Set `PRESET_LANG` to choose a language explicitly. For example, switch to Japanese in a macOS or Linux terminal: diff --git a/docs/src/contents/en/guide/preset-configs.md b/docs/src/contents/en/guide/preset-configs.md index 17ed1ef..659e814 100644 --- a/docs/src/contents/en/guide/preset-configs.md +++ b/docs/src/contents/en/guide/preset-configs.md @@ -45,12 +45,52 @@ This config copies `apps/starter` from the repository, excludes test files, writ File operations run in this order: -- `exclude` skips paths before copying the selected template. +- `include` selects exact source-relative files or directories; omission selects all. +- `exclude` removes paths from that selection before copying. - `write` creates or replaces a UTF-8 text file from an array of lines. - `replace` replaces every exact text match in an existing UTF-8 file. - `json` applies a recursive merge patch to an existing JSON object; a `null` value removes a key. -The generated `package.json.name` is set to the project name. +With `init`, the generated `package.json.name` is set to the project name. + +## Select files with include and exclude + +`include` lists exact files or directories relative to `source.directory`. Directories include their descendants; paths are not globs. Omit `include` to select all source files. With both fields present, `exclude` takes precedence. A selected directory is not flattened in the output. + +```json +{ + "version": 1, + "source": { + "repo": "https://github.com/example/team-resources.git", + "directory": "skills" + }, + "include": ["code-review", "testing"], + "exclude": ["testing/fixtures"] +} +``` + +An empty or null `include`, a missing selected path, or a selection with no files after exclusions fails. Symbolic links and paths outside the source are rejected. When `include` is present, file operations must stay within that selection and cannot recreate excluded files. Without `include`, existing transformation behavior is unchanged. + +Repeat `--include` on `init` or `apply` to replace the entire config selection. Configured exclusions still apply. This option requires a JSON preset; it is not passed to official generators. + +```bash +preset apply .agents/skills --from ./skills.json --include code-review --include writing +``` + +A static website can publish a base JSON config and turn checked items into repeated `--include` arguments. Department presets can keep their selections in JSON. + +## Add files to an existing project + +```bash +preset apply .agents/skills --from ./skills.json --dry-run +preset apply .agents/skills --from ./skills.json +``` + +`apply` accepts a local/remote JSON config or a registered private preset backed by a config. It adds missing files, skips identical files, and reports differing content or file types as conflicts before writing anything. On Unix, executable permission differences also count as conflicts. Unrelated files remain intact, and `package.json.name` is not automatically rewritten. Empty directories alone are not installed. + +The command prints JSON with `add`, `skip`, and `conflicts`; conflicts produce a nonzero exit status in both normal and dry-run modes. `apply` has no `--yes` or overwrite mode. It rejects symbolic links along destination paths and does not follow them when publishing files. It does not synchronize deletions or automatically update modified files. + +Unlike `init --dry-run`, `apply --dry-run` clones the source and runs configured transformations in temporary storage to compare actual files. It may query npm if workspace resolution is enabled, but does not create target directories or modify target files. Normal publication errors roll back this run's additions. Recovery failures report retained paths; forced termination or a system crash can leave partial additions. Avoid editing destination paths concurrently with an apply operation. ## Preview and replace files @@ -96,7 +136,7 @@ Resolution covers `dependencies`, `devDependencies`, `peerDependencies`, and `op Use HTTPS for remote configs and their source repositories. HTTP is also accepted for trusted private networks, with a CLI warning. Remote configs cannot reference local files or SSH repository URLs. -Config files are limited to 1 MiB, with at most 1,000 operations in total across `exclude`, `write`, `replace`, and `json`. The CLI rejects unknown fields, unsupported versions, paths outside the generated project, and source symlinks. Config values never evaluate JavaScript or invoke a shell. The JSON Schema helps catch errors in the editor; the CLI validates the config again when it runs. +Config files are limited to 1 MiB, with at most 1,000 operations in total across `include`, `exclude`, `write`, `replace`, and `json`. The CLI rejects unknown fields, unsupported versions, paths outside the generated project, and source symlinks. Config values never evaluate JavaScript or invoke a shell. The JSON Schema helps catch errors in the editor; the CLI validates the config again when it runs. ## Blackwork example diff --git a/docs/src/contents/en/guide/private-presets.md b/docs/src/contents/en/guide/private-presets.md index 43fc56b..122aeff 100644 --- a/docs/src/contents/en/guide/private-presets.md +++ b/docs/src/contents/en/guide/private-presets.md @@ -68,6 +68,27 @@ Each entry uses either `repo` or `config`. The relative path above starts from t For custom stack labels and colors, use the format with a `version` field described in the [private preset reference](https://github.com/preset-cli/create-preset/blob/main/docs/private-presets.md). +## Apply a team preset + +Register a config-backed preset for a reusable team selection: + +```json +{ + "tech": "skills", + "name": "team-skills", + "config": "./skills.json" +} +``` + +After adding this entry to the bound manifest, use its name to add files to a project: + +```bash +preset apply .agents/skills --from team-skills +preset apply .agents/skills --from team-skills --include code-review --dry-run +``` + +`apply` accepts `config` entries, not bare `repo` entries or official generators. It preserves unrelated files, skips identical files, and stops on conflicts. Repeated `--include` arguments replace the config selection; exclusions remain active. See [Preset configs](/guide/preset-configs) for the JSON format and preview behavior. + ## Files kept in the new project When using `repo` directly, the CLI removes only the cloned `.git` metadata. Lockfiles, licenses, workflows, skills, and `AGENTS.md` files are preserved. If a `package.json` exists, its `name` is updated to match the new project name. diff --git a/docs/src/contents/zh/examples/skills.mdx b/docs/src/contents/zh/examples/skills.mdx index d4aade3..2c97dfb 100644 --- a/docs/src/contents/zh/examples/skills.mdx +++ b/docs/src/contents/zh/examples/skills.mdx @@ -20,12 +20,16 @@ order: 2 ## 已有 Skills 的处理 -**目标目录已有文件时,CLI 会询问是否替换整个目录,不会逐个合并 Skill。** 为保留项目中的现有内容,可以先生成到一个新的临时目录: +使用 `apply` 追加需要的 Skills,保留目录中的现有内容: ```bash -pnpm create preset init skills-preview --from ./presets/skills.json +pnpm create preset apply .agents/skills --from ./presets/skills.json --include code-review --include writing ``` -检查生成内容后,将需要的 Skill 文件夹复制到项目中;同名文件需要比较后再决定是否覆盖。源仓库更新后,也可以通过临时目录比较新旧文件。Create Preset 不会自动同步更新,也不会把 Skills 链接到其他 agent 的目录。 +多个 `--include` 参数会整体覆盖配置中的选择清单,配置中的 `exclude` 继续生效。省略 `--include` 时使用配置清单;配置也没有清单时,选择全部源文件。 + +添加 `--dry-run`,可以先在临时目录生成文件并比较。相同文件会跳过,内容、文件类型或 Unix 可执行权限不同则在写入前报告冲突。冲突需要手动处理,`apply` 不覆盖或删除已有文件。`init` 仍然会在确认后替换整个非空目标目录。 + +Create Preset 不会自动同步更新,也不会把 Skills 链接到其他 agent 的目录。选择规则和回滚边界见 [Preset 配置](/zh/guide/preset-configs)。 一份 Preset 只读取一个源仓库。如果常用 Skills 分散在多个仓库,需要先整理到同一个仓库,再用这份配置分发。 diff --git a/docs/src/contents/zh/guide/getting-started.mdx b/docs/src/contents/zh/guide/getting-started.mdx index e40e2df..5e97579 100644 --- a/docs/src/contents/zh/guide/getting-started.mdx +++ b/docs/src/contents/zh/guide/getting-started.mdx @@ -79,6 +79,16 @@ preset init my-docs --from ./preset.json --yes `--yes` 只跳过 Create Preset 的替换确认,官方脚手架仍保留自己的提问。 +## 向已有项目追加文件 + +使用 JSON 配置运行 `apply`,保留无关文件,遇到冲突时停止: + +```bash +preset apply .agents/skills --from ./skills.json --include code-review --include testing +``` + +添加 `--dry-run` 可比较真实生成文件而不修改目标。这种预览会在临时目录克隆源仓库。选择和冲突规则见 [Preset 配置](/zh/guide/preset-configs)。 + ## 切换语言 Create Preset 默认跟随系统语言,支持英文、简体中文、繁体中文和日文。用 `PRESET_LANG` 指定语言,例如在 macOS 或 Linux 中切换为日文: diff --git a/docs/src/contents/zh/guide/preset-configs.md b/docs/src/contents/zh/guide/preset-configs.md index 81b0328..42fb743 100644 --- a/docs/src/contents/zh/guide/preset-configs.md +++ b/docs/src/contents/zh/guide/preset-configs.md @@ -45,12 +45,52 @@ preset init my-docs --from https://example.com/preset.json 文件处理按以下顺序执行: -- `exclude`:复制选中的模板前,排除指定路径。 +- `include`:选择源目录内的指定文件或目录,省略时选择全部。 +- `exclude`:复制前从选区中排除指定路径。 - `write`:将字符串数组写成 UTF-8 文本文件,可新建或替换文件。 - `replace`:替换已有 UTF-8 文件中所有完全匹配的文本。 - `json`:递归合并已有 JSON 对象,值为 `null` 时移除对应字段。 -生成的 `package.json.name` 会设为新项目名称。 +使用 `init` 时,生成的 `package.json.name` 会设为新项目名称。 + +## 使用 include 和 exclude 选择文件 + +`include` 指定相对于 `source.directory` 的文件或目录,目录包含其子项,路径不支持 glob。省略 `include` 时选择全部源文件;同时配置两者时,`exclude` 优先。输出保留选中目录的层级,不会将文件铺平。 + +```json +{ + "version": 1, + "source": { + "repo": "https://github.com/example/team-resources.git", + "directory": "skills" + }, + "include": ["code-review", "testing"], + "exclude": ["testing/fixtures"] +} +``` + +`include` 为空数组或 `null`、选中路径不存在、排除后没有任何文件时,命令会失败。符号链接和越界路径会被拒绝。显式指定 `include` 时,后续文件操作必须位于选区内,不能重新写入已排除的文件。未指定 `include` 时,现有文件转换行为保持不变。 + +`init` 和 `apply` 都支持重复传入 `--include`,整体覆盖配置中的选择清单,配置中的 `exclude` 继续生效。该选项只用于 JSON Preset,不会转交给官方生成器。 + +```bash +preset apply .agents/skills --from ./skills.json --include code-review --include writing +``` + +静态网站可以发布一份基础 JSON,将用户勾选的项目转成多个 `--include` 参数。部门套餐则直接在 JSON 中维护清单。 + +## 向已有项目追加文件 + +```bash +preset apply .agents/skills --from ./skills.json --dry-run +preset apply .agents/skills --from ./skills.json +``` + +`apply` 接受本地或远程 JSON 配置,也支持注册为私有 Preset 的配置名称。缺失文件会被添加,相同文件会跳过,内容或文件类型不同时会在写入前报告冲突。在 Unix 上,可执行权限不同也视为冲突。无关文件保持原样,`package.json.name` 不会被自动改写。仅含空目录的内容不会被安装。 + +命令输出包含 `add`、`skip`、`conflicts` 的 JSON。正常执行和 dry-run 遇到冲突都会以非零状态退出。`apply` 不提供 `--yes` 或覆盖模式;目标路径中的符号链接会被拒绝。它不会同步删除文件,也不会自动更新有差异的文件。 + +与 `init --dry-run` 不同,`apply --dry-run` 会克隆源仓库并在临时目录中执行配置的转换,以比较真实文件。开启 workspace 解析时可能查询 npm,但不会创建目标目录或修改目标文件。正常写入失败时会撤销本次新增内容;恢复失败会报告保留路径。强制终止进程或系统崩溃可能留下部分新增文件。执行期间请避免并发修改目标路径。 ## 预览与替换文件 @@ -96,7 +136,7 @@ monorepo 模板中的 `workspace:` 依赖需要换成已发布版本,才能在 远程配置和源仓库建议使用 HTTPS。HTTP 会显示警告,可用于可信内网。远程配置不能引用本机文件或 SSH 仓库。 -配置文件最大为 1 MiB,`exclude`、`write`、`replace` 和 `json` 中的操作总数最多为 1,000。CLI 会拒绝未知字段、不支持的版本、超出生成项目范围的路径和源文件中的符号链接。配置值不会执行 JavaScript 或调用 shell。编辑器中的 JSON Schema 用于编写时检查,运行命令时仍会再次校验。 +配置文件最大为 1 MiB,`include`、`exclude`、`write`、`replace` 和 `json` 中的操作总数最多为 1,000。CLI 会拒绝未知字段、不支持的版本、超出生成项目范围的路径和源文件中的符号链接。配置值不会执行 JavaScript 或调用 shell。编辑器中的 JSON Schema 用于编写时检查,运行命令时仍会再次校验。 ## Blackwork 示例 diff --git a/docs/src/contents/zh/guide/private-presets.md b/docs/src/contents/zh/guide/private-presets.md index 4ca2065..55d6918 100644 --- a/docs/src/contents/zh/guide/private-presets.md +++ b/docs/src/contents/zh/guide/private-presets.md @@ -68,6 +68,27 @@ preset config remove 需要自定义技术栈名称和颜色时,使用带 `version` 字段的配置格式,详见[私有 Preset 配置说明](https://github.com/preset-cli/create-preset/blob/main/docs/private-presets.md)。 +## 应用团队套餐 + +可以注册一个引用配置文件的团队套餐: + +```json +{ + "tech": "skills", + "name": "team-skills", + "config": "./skills.json" +} +``` + +将该条目加入已绑定的清单后,按名称向项目追加文件: + +```bash +preset apply .agents/skills --from team-skills +preset apply .agents/skills --from team-skills --include code-review --dry-run +``` + +`apply` 接受 `config` 条目,不接受仅配置 `repo` 的条目或官方生成器。无关文件保持原样,相同文件跳过,遇到冲突时停止。多个 `--include` 参数会整体覆盖配置中的选择清单,排除规则继续生效。JSON 格式和预览行为见 [Preset 配置](/zh/guide/preset-configs)。 + ## 创建后保留哪些文件 直接使用 `repo` 创建时,CLI 只删除克隆得到的 `.git` 元数据,锁文件、许可证、工作流、skills 和 `AGENTS.md` 都会保留。如果存在 `package.json`,其中的 `name` 会更新为新项目名称。 diff --git a/schema/preset.schema.json b/schema/preset.schema.json index f89866e..728e5e1 100644 --- a/schema/preset.schema.json +++ b/schema/preset.schema.json @@ -17,6 +17,15 @@ "source": { "$ref": "#/$defs/source" }, + "include": { + "description": "Exact source-relative files or directories to copy. Omit to select all; exclude takes precedence.", + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { + "$ref": "#/$defs/relativePath" + } + }, "exclude": { "type": "array", "maxItems": 1000, diff --git a/scripts/test-package.js b/scripts/test-package.js index d9a567d..86e68af 100644 --- a/scripts/test-package.js +++ b/scripts/test-package.js @@ -91,7 +91,30 @@ try { const preview = JSON.parse(run(process.execPath, [wrapper, 'init', 'demo', '--from', 'vite', '--dry-run'], consumer)); assert.strictEqual(preview.source.id, 'vite'); assert(!fs.existsSync(path.join(consumer, 'demo'))); - console.log('Package smoke passed: artifact permissions, tarball contents, both installed commands, list and dry-run.'); + const source = path.join(temporary, 'skill-source'); + fs.mkdirSync(path.join(source, 'skills', 'review'), { recursive: true }); + fs.mkdirSync(path.join(source, 'skills', 'writing'), { recursive: true }); + fs.writeFileSync(path.join(source, 'skills', 'review', 'SKILL.md'), 'review'); + fs.writeFileSync(path.join(source, 'skills', 'writing', 'SKILL.md'), 'writing'); + run('git', ['init', '--quiet'], source); + run('git', ['add', '.'], source); + run('git', ['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.com', 'commit', '--quiet', '-m', 'fixture'], source); + const config = path.join(consumer, 'skills.json'); + fs.writeFileSync(config, JSON.stringify({ + version: 1, + source: { repo: require('url').pathToFileURL(source).href, directory: 'skills' }, + include: ['writing'], + })); + const args = [wrapper, 'apply', '.agents/skills', '--from', config, '--include', 'review']; + const applyPreview = JSON.parse(run(process.execPath, [...args, '--dry-run'], consumer)); + assert.strictEqual(applyPreview.add.length, 1); + assert(!fs.existsSync(path.join(consumer, '.agents'))); + run(process.execPath, args, consumer); + assert.strictEqual(fs.readFileSync(path.join(consumer, '.agents/skills/review/SKILL.md'), 'utf8'), 'review'); + assert(!fs.existsSync(path.join(consumer, '.agents/skills/writing'))); + const repeated = JSON.parse(run(process.execPath, args, consumer)); + assert.strictEqual(repeated.skip.length, 1); + console.log('Package smoke passed: artifact permissions, tarball contents, installed commands, include selection and repeatable apply.'); } finally { fs.rmSync(temporary, { recursive: true, force: true }); } diff --git a/src/apply.rs b/src/apply.rs new file mode 100644 index 0000000..9776444 --- /dev/null +++ b/src/apply.rs @@ -0,0 +1,371 @@ +use crate::error::{PresetError, Result}; +use serde::Serialize; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Serialize)] +pub struct Conflict { + path: PathBuf, + reason: String, +} + +#[derive(Debug, Serialize)] +pub struct ApplyPlan { + pub add: Vec, + pub skip: Vec, + pub conflicts: Vec, +} + +pub fn resolve_target(cwd: &Path, target: &str) -> Result { + if target.trim().is_empty() + || Path::new(target) + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(PresetError::ValidationError( + "Apply target must not be empty or contain parent traversal".to_string(), + )); + } + let root: PathBuf = cwd.join(target).components().collect(); + if root.parent().is_none() || (cwd != root && cwd.starts_with(&root)) { + return Err(PresetError::ValidationError( + "Apply target cannot be the filesystem root or an ancestor of the working directory" + .to_string(), + )); + } + validate_directories(&root)?; + Ok(root) +} + +// 每次检查完整父路径,拒绝目标本身及中间目录中的软链。 +fn validate_directories(path: &Path) -> Result<()> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if !metadata.is_dir() || metadata.file_type().is_symlink() => { + return Err(PresetError::ValidationError(format!( + "Target path is not a regular directory: {}", + current.display() + ))); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +fn collect_files(root: &Path, relative: &Path, files: &mut Vec) -> Result<()> { + for entry in fs::read_dir(root.join(relative))? { + let entry = entry?; + let path = relative.join(entry.file_name()); + let kind = entry.file_type()?; + if entry + .file_name() + .to_string_lossy() + .trim_end_matches(['.', ' ']) + .eq_ignore_ascii_case(".git") + || kind.is_symlink() + { + return Err(PresetError::ValidationError(format!( + "Cannot apply repository metadata or symbolic links: {}", + path.display() + ))); + } + if kind.is_dir() { + collect_files(root, &path, files)?; + } else if kind.is_file() { + files.push(path); + } else { + return Err(PresetError::ValidationError(format!( + "Cannot apply special file: {}", + path.display() + ))); + } + } + Ok(()) +} + +fn files_equal(left: &Path, right: &Path) -> Result { + let a = fs::metadata(left)?; + let b = fs::metadata(right)?; + if a.len() != b.len() { + return Ok(false); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if a.permissions().mode() & 0o111 != b.permissions().mode() & 0o111 { + return Ok(false); + } + } + let mut a = File::open(left)?; + let mut b = File::open(right)?; + let mut buffer_a = [0; 64 * 1024]; + let mut buffer_b = [0; 64 * 1024]; + loop { + let count = a.read(&mut buffer_a)?; + if count == 0 { + return Ok(b.read(&mut buffer_b[..1])? == 0); + } + match b.read_exact(&mut buffer_b[..count]) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(false), + Err(error) => return Err(error.into()), + } + if buffer_a[..count] != buffer_b[..count] { + return Ok(false); + } + } +} + +pub fn plan_apply(staged: &Path, root: &Path) -> Result { + validate_directories(root)?; + let mut files = Vec::new(); + collect_files(staged, Path::new(""), &mut files)?; + files.sort(); + if files.is_empty() { + return Err(PresetError::ValidationError( + "Preset selection contains no files to apply".to_string(), + )); + } + let mut plan = ApplyPlan { + add: Vec::new(), + skip: Vec::new(), + conflicts: Vec::new(), + }; + for relative in files { + let target = root.join(&relative); + if let Err(error) = validate_directories(target.parent().unwrap()) { + plan.conflicts.push(Conflict { + path: relative, + reason: error.to_string(), + }); + continue; + } + match fs::symlink_metadata(&target) { + Ok(metadata) + if metadata.is_file() + && !metadata.file_type().is_symlink() + && files_equal(&staged.join(&relative), &target)? => + { + plan.skip.push(relative) + } + Ok(_) => plan.conflicts.push(Conflict { + path: relative, + reason: "Existing content or file type differs".to_string(), + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => plan.add.push(relative), + Err(error) => return Err(error.into()), + } + } + Ok(plan) +} + +struct AdditionTransaction { + files: Vec<(PathBuf, PathBuf)>, + directories: Vec, + active: bool, +} + +impl AdditionTransaction { + fn new() -> Self { + Self { + files: Vec::new(), + directories: Vec::new(), + active: true, + } + } + + fn ensure_directory(&mut self, path: &Path) -> Result<()> { + validate_directories(path)?; + if path.is_dir() { + return Ok(()); + } + if let Some(parent) = path.parent() { + self.ensure_directory(parent)?; + } + match fs::create_dir(path) { + Ok(()) => self.directories.push(path.to_path_buf()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + validate_directories(path)? + } + Err(error) => return Err(error.into()), + } + Ok(()) + } + + fn add_file(&mut self, source: &Path, target: &Path) -> Result<()> { + let parent = target + .parent() + .ok_or_else(|| PresetError::ValidationError("Missing target parent".to_string()))?; + self.ensure_directory(parent)?; + let mut input = File::open(source)?; + // 先完整写入同目录临时文件,再无覆盖发布;晚到的同名文件也不能被覆盖。 + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + std::io::copy(&mut input, &mut temporary)?; + temporary.flush()?; + temporary + .as_file() + .set_permissions(input.metadata()?.permissions())?; + validate_directories(parent)?; + temporary.persist_noclobber(target).map_err(|error| { + PresetError::IoError(format!("Cannot add {}: {}", target.display(), error.error)) + })?; + self.files + .push((target.to_path_buf(), source.to_path_buf())); + Ok(()) + } + + fn rollback(&mut self) -> Result<()> { + self.active = false; + let mut failures = Vec::new(); + for (target, source) in self.files.iter().rev() { + let remove = (|| -> Result<()> { + validate_directories(target.parent().unwrap())?; + match fs::symlink_metadata(target) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + Ok(metadata) if !metadata.is_file() || metadata.file_type().is_symlink() => { + return Err(PresetError::IoError( + "Added path changed during apply".to_string(), + )); + } + Ok(_) => {} + } + if !files_equal(source, target)? { + return Err(PresetError::IoError( + "Added file was modified during apply".to_string(), + )); + } + fs::remove_file(target)?; + Ok(()) + })(); + if let Err(error) = remove { + failures.push(format!("{}: {}", target.display(), error)); + } + } + for directory in self.directories.iter().rev() { + let remove = validate_directories(directory) + .and_then(|_| fs::remove_dir(directory).map_err(Into::into)); + if let Err(error) = remove { + failures.push(format!("{}: {}", directory.display(), error)); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(PresetError::IoError(format!( + "Could not roll back added paths: {}", + failures.join("; ") + ))) + } + } +} + +impl Drop for AdditionTransaction { + fn drop(&mut self) { + if self.active { + if let Err(error) = self.rollback() { + eprintln!("{}", error); + } + } + } +} + +pub fn publish_additions(staged: &Path, root: &Path, plan: &ApplyPlan) -> Result<()> { + if !plan.conflicts.is_empty() { + return Err(PresetError::ValidationError( + "Apply has conflicts; no target files were changed".to_string(), + )); + } + let mut transaction = AdditionTransaction::new(); + for relative in &plan.add { + if let Err(error) = transaction.add_file(&staged.join(relative), &root.join(relative)) { + return match transaction.rollback() { + Ok(()) => Err(error), + Err(rollback) => Err(PresetError::IoError(format!("{}; {}", error, rollback))), + }; + } + } + transaction.active = false; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn late_conflict_rolls_back_only_our_additions() { + let temp = tempfile::tempdir().unwrap(); + let staged = temp.path().join("staged"); + let root = fs::canonicalize(temp.path()).unwrap().join("target"); + fs::create_dir(&staged).unwrap(); + fs::create_dir(&root).unwrap(); + fs::write(staged.join("a"), "added").unwrap(); + fs::write(staged.join("z"), "added").unwrap(); + fs::write(root.join("keep"), "original").unwrap(); + let plan = plan_apply(&staged, &root).unwrap(); + fs::write(root.join("z"), "late arrival").unwrap(); + assert!(publish_additions(&staged, &root, &plan).is_err()); + assert!(!root.join("a").exists()); + assert_eq!(fs::read_to_string(root.join("z")).unwrap(), "late arrival"); + assert_eq!(fs::read_to_string(root.join("keep")).unwrap(), "original"); + } + + #[test] + fn failed_publication_removes_created_parents() { + let temp = tempfile::tempdir().unwrap(); + let staged = temp.path().join("staged"); + let root = fs::canonicalize(temp.path()).unwrap().join("new/nested"); + fs::create_dir(&staged).unwrap(); + fs::write(staged.join("a"), "added").unwrap(); + fs::write(staged.join("z"), "added").unwrap(); + let plan = plan_apply(&staged, &root).unwrap(); + fs::remove_file(staged.join("z")).unwrap(); + assert!(publish_additions(&staged, &root, &plan).is_err()); + assert!(!root.parent().unwrap().exists()); + } + + #[test] + fn conflict_preflight_prevents_all_writes() { + let temp = tempfile::tempdir().unwrap(); + let staged = temp.path().join("staged"); + let root = fs::canonicalize(temp.path()).unwrap().join("target"); + fs::create_dir(&staged).unwrap(); + fs::create_dir(&root).unwrap(); + fs::write(staged.join("new"), "add").unwrap(); + fs::write(staged.join("conflict"), "incoming").unwrap(); + fs::write(root.join("conflict"), "local").unwrap(); + let plan = plan_apply(&staged, &root).unwrap(); + assert_eq!(plan.conflicts.len(), 1); + assert!(publish_additions(&staged, &root, &plan).is_err()); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + fn rejects_target_links_and_detects_executable_permission_changes() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let temp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(temp.path()).unwrap(); + let staged = base.join("staged"); + let root = base.join("target"); + fs::create_dir(&staged).unwrap(); + fs::create_dir(&root).unwrap(); + fs::write(staged.join("tool"), "script").unwrap(); + fs::write(root.join("tool"), "script").unwrap(); + fs::set_permissions(staged.join("tool"), fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(root.join("tool"), fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(plan_apply(&staged, &root).unwrap().conflicts.len(), 1); + symlink(&root, base.join("linked")).unwrap(); + assert!(resolve_target(&base, "linked/subdir").is_err()); + fs::remove_file(root.join("tool")).unwrap(); + symlink(staged.join("tool"), root.join("tool")).unwrap(); + assert_eq!(plan_apply(&staged, &root).unwrap().conflicts.len(), 1); + } +} diff --git a/src/cli.rs b/src/cli.rs index fe9d890..49dc893 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,7 @@ use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "preset")] #[command(args_conflicts_with_subcommands = true)] -#[command(author, version, disable_version_flag = true, about = "Create projects with official generators and declarative presets.", long_about = None)] +#[command(author, version, disable_version_flag = true, about = "Create projects and apply declarative presets.", long_about = None)] pub struct Cli { /// Output the version number #[arg(short = 'v', long = "version", action = clap::ArgAction::Version)] @@ -28,6 +28,10 @@ pub enum Commands { #[arg(long = "from", conflicts_with = "template")] source: Option, + /// Select a source-relative file or directory (repeatable; overrides config include) + #[arg(long, action = clap::ArgAction::Append, long_help = "Select an exact file or directory relative to source.directory. Repeat for multiple paths; directories include their descendants. Replaces the config include list, while exclude still takes precedence. JSON presets only; no glob patterns.")] + include: Vec, + /// Legacy alias for --from #[arg(long, hide = true)] template: Option, @@ -45,6 +49,27 @@ pub enum Commands { yes: bool, }, + /// Add preset files to a project, preserving unrelated files and rejecting conflicts + #[command( + after_long_help = "Examples:\n preset apply .agents/skills --from ./skills.json --include code-review --include testing\n preset apply .agents/skills --from ./skills.json --dry-run\n\nMissing files are added; identical files are skipped. Conflicts fail before any target writes. No overwrite or --yes mode is available. Normal publication failures roll back this run's additions." + )] + Apply { + /// Target directory + target: String, + + /// Local/remote preset.json or a registered private config name + #[arg(long = "from")] + source: String, + + /// Select a source-relative file or directory (repeatable; overrides config include) + #[arg(long, action = clap::ArgAction::Append, long_help = "Select an exact file or directory relative to source.directory. Repeat for multiple paths; directories include their descendants. Replaces the config include list, while exclude still takes precedence. JSON presets only; no glob patterns.")] + include: Vec, + + /// Clone and generate in temporary storage to compare files without changing the target + #[arg(long)] + dry_run: bool, + }, + /// Manage the private preset configuration Config { #[command(subcommand)] diff --git a/src/commands/apply.rs b/src/commands/apply.rs new file mode 100644 index 0000000..5ae1f87 --- /dev/null +++ b/src/commands/apply.rs @@ -0,0 +1,46 @@ +use super::init::{resolve_creation_source, CreationSource}; +use crate::apply::{plan_apply, publish_additions, resolve_target}; +use crate::error::{PresetError, Result}; +use crate::preset::{materialize_preset_plan, override_include}; +use serde_json::json; + +pub fn apply_command( + target: String, + source: String, + include: Vec, + dry_run: bool, +) -> Result<()> { + let mut preset = match resolve_creation_source(Some(&source))? { + CreationSource::Config(plan) => plan, + _ => { + return Err(PresetError::ValidationError( + "apply requires a preset JSON config, not a generator or bare repository" + .to_string(), + )) + } + }; + override_include(&mut preset, include)?; + let cwd = std::fs::canonicalize(std::env::current_dir()?)?; + let root = resolve_target(&cwd, &target)?; + // dry-run 也要生成实际文件以检查冲突,临时结果始终放在目标目录之外。 + let temporary = tempfile::tempdir()?; + let staged = temporary.path().join("output"); + materialize_preset_plan(&preset, &staged)?; + let plan = plan_apply(&staged, &root)?; + if !dry_run && plan.conflicts.is_empty() { + publish_additions(&staged, &root, &plan)?; + } + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "target": root, "dryRun": dry_run, "source": preset, + "add": plan.add, "skip": plan.skip, "conflicts": plan.conflicts, + }))? + ); + if !plan.conflicts.is_empty() { + return Err(PresetError::ValidationError( + "Apply has conflicts; no target files were changed".to_string(), + )); + } + Ok(()) +} diff --git a/src/commands/init.rs b/src/commands/init.rs index fd8cc19..cb1c39d 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -5,7 +5,7 @@ use crate::download::download_repo; use crate::error::{PresetError, Result}; use crate::generator::{generator_arguments, run_generator}; use crate::i18n::{format_message, generator_name, messages}; -use crate::preset::{load_preset_plan, materialize_preset_plan}; +use crate::preset::{load_preset_plan, materialize_preset_plan, override_include}; use crate::target::{staging_directory, validate_target, TargetTransaction}; use crate::types::{GeneratorConfig, PackageManager, PresetPlan, StarterChoice, TechStack}; use crate::ui::{dialoguer_error, spinner}; @@ -17,7 +17,7 @@ use dialoguer::{Confirm, Input, Select}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; -enum CreationSource { +pub(super) enum CreationSource { Official(GeneratorConfig), Repository(String), Config(PresetPlan), @@ -47,6 +47,7 @@ pub fn init_command( package_manager: Option, dry_run: bool, yes: bool, + include: Vec, ) -> Result<()> { if !dry_run { display_welcome(); @@ -59,25 +60,17 @@ pub fn init_command( None }; // Resolve and validate local configs before asking to replace their directory. - let creation = if let Some(config) = requested - .as_deref() - .filter(|value| is_preset_config_reference(value)) - { - CreationSource::Config(load_config(config)?) - } else { - match choose_starter(requested.as_deref(), ®istered_catalog()?)? { - StarterChoice::Official(generator) => CreationSource::Official(generator), - StarterChoice::Private(preset) => match (preset.repo, preset.config) { - (_, Some(config)) => CreationSource::Config(load_config(&config)?), - (Some(repo), None) => CreationSource::Repository(repo), - (None, None) => { - return Err(PresetError::ConfigError( - "Private preset has no source".to_string(), - )) - } - }, + let mut creation = resolve_creation_source(requested.as_deref())?; + if !include.is_empty() { + match &mut creation { + CreationSource::Config(plan) => override_include(plan, include)?, + _ => { + return Err(PresetError::ValidationError( + "--include requires a preset JSON config".to_string(), + )) + } } - }; + } let target_dir = match target_dir { Some(target) => target, None => prompt_target_dir(app_name)?, @@ -139,6 +132,23 @@ pub fn init_command( Ok(()) } +pub(super) fn resolve_creation_source(requested: Option<&str>) -> Result { + if let Some(config) = requested.filter(|value| is_preset_config_reference(value)) { + Ok(CreationSource::Config(load_config(config)?)) + } else { + match choose_starter(requested, ®istered_catalog()?)? { + StarterChoice::Official(generator) => Ok(CreationSource::Official(generator)), + StarterChoice::Private(preset) => match (preset.repo, preset.config) { + (_, Some(config)) => Ok(CreationSource::Config(load_config(&config)?)), + (Some(repo), None) => Ok(CreationSource::Repository(repo)), + (None, None) => Err(PresetError::ConfigError( + "Private preset has no source".to_string(), + )), + }, + } + } +} + fn load_config(reference: &str) -> Result { if reference.starts_with("http://") { eprintln!( diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8a43064..f91e345 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod apply; pub mod config; pub mod init; pub mod upgrade; diff --git a/src/main.rs b/src/main.rs index 6eb1595..e890e2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod apply; mod catalog; mod cli; mod commands; @@ -43,11 +44,28 @@ fn run() -> Result<()> { app_name, source, template, + include, package_manager, dry_run, yes, }) => { - commands::init_command(app_name, source, template, package_manager, dry_run, yes)?; + commands::init_command( + app_name, + source, + template, + package_manager, + dry_run, + yes, + include, + )?; + } + Some(Commands::Apply { + target, + source, + include, + dry_run, + }) => { + commands::apply::apply_command(target, source, include, dry_run)?; } Some(Commands::Config { action }) => { commands::config_command(action)?; @@ -57,7 +75,7 @@ fn run() -> Result<()> { } None => { // Default to init command - commands::init_command(None, None, None, None, false, false)?; + commands::init_command(None, None, None, None, false, false, Vec::new())?; } } diff --git a/src/preset.rs b/src/preset.rs index 582d98d..5d57453 100644 --- a/src/preset.rs +++ b/src/preset.rs @@ -111,7 +111,15 @@ pub fn materialize_preset_plan(plan: &PresetPlan, target: &Path) -> Result<()> { download_repo(&plan.source.repo, &checkout)?; let directory = safe_relative_path(&plan.source.directory, "source directory", true)?; - let source = checkout.join(directory); + let mut source = checkout.clone(); + for component in directory.components() { + source.push(component); + if fs::symlink_metadata(&source)?.file_type().is_symlink() { + return Err(PresetError::ValidationError( + "Source directory cannot traverse symbolic links".to_string(), + )); + } + } if !source.is_dir() { return Err(PresetError::ValidationError(format!( "Preset source directory does not exist: {}", @@ -126,12 +134,7 @@ pub fn materialize_preset_plan(plan: &PresetPlan, target: &Path) -> Result<()> { )); } - let excludes = plan - .exclude - .iter() - .map(|path| safe_relative_path(path, "excluded path", false)) - .collect::>>()?; - copy_filtered(&source_root, target, Path::new(""), &excludes)?; + copy_selection(&source_root, target, plan)?; apply_writes(target, &plan.write)?; apply_replacements(target, &plan.replace)?; apply_json_merges(target, &plan.json)?; @@ -166,8 +169,11 @@ fn validate_plan(plan: &PresetPlan, remote: bool) -> Result<()> { "Remote preset configs must use an HTTP(S) repository URL".to_string(), )); } - let operation_count = - plan.exclude.len() + plan.write.len() + plan.replace.len() + plan.json.len(); + let operation_count = plan.include.as_ref().map_or(0, Vec::len) + + plan.exclude.len() + + plan.write.len() + + plan.replace.len() + + plan.json.len(); if operation_count > MAX_OPERATIONS { return Err(PresetError::ValidationError(format!( "Preset config exceeds the {} operation limit", @@ -175,6 +181,40 @@ fn validate_plan(plan: &PresetPlan, remote: bool) -> Result<()> { ))); } safe_relative_path(&plan.source.directory, "source directory", true)?; + if let Some(includes) = &plan.include { + if includes.is_empty() { + return Err(PresetError::ValidationError( + "Preset include cannot be empty".to_string(), + )); + } + let includes = includes + .iter() + .map(|path| safe_relative_path(path, "included path", false)) + .collect::>>()?; + let excludes = plan + .exclude + .iter() + .map(|path| safe_relative_path(path, "excluded path", false)) + .collect::>>()?; + // 显式选区不能被后续文件操作绕过,也不能重新写入已排除的文件。 + for path in plan + .write + .iter() + .map(|item| &item.path) + .chain(plan.replace.iter().map(|item| &item.path)) + .chain(plan.json.iter().map(|item| &item.path)) + { + let path = safe_relative_path(path, "operation path", false)?; + if !includes.iter().any(|included| path.starts_with(included)) + || excludes.iter().any(|excluded| path.starts_with(excluded)) + { + return Err(PresetError::ValidationError(format!( + "Preset operation is outside the included selection: {}", + path.display() + ))); + } + } + } for path in &plan.exclude { safe_relative_path(path, "excluded path", false)?; } @@ -211,7 +251,17 @@ fn validate_plan(plan: &PresetPlan, remote: bool) -> Result<()> { fn safe_relative_path(value: &str, label: &str, allow_empty: bool) -> Result { let path = Path::new(value); - if (!allow_empty && value.trim().is_empty()) + if (!allow_empty + && (value.trim().is_empty() + || !path + .components() + .any(|part| matches!(part, Component::Normal(_))))) + || value.contains('\\') + || value.contains(':') + || value.contains('\0') + || path + .components() + .any(|part| matches!(part, Component::Normal(name) if name.to_string_lossy().trim_end_matches(['.', ' ']).eq_ignore_ascii_case(".git"))) || value.len() > 512 || path.is_absolute() || path.components().any(|component| { @@ -226,41 +276,124 @@ fn safe_relative_path(value: &str, label: &str, allow_empty: bool) -> Result) -> Result<()> { + if !include.is_empty() { + plan.include = Some(include); + } + // 来源已经验证;此处重新验证 CLI 覆盖后的路径和操作总数。 + validate_plan(plan, false) +} + +fn copy_selection(source: &Path, target: &Path, plan: &PresetPlan) -> Result<()> { + let excludes = plan + .exclude + .iter() + .map(|path| safe_relative_path(path, "excluded path", false)) + .collect::>>()?; + let includes = plan + .include + .as_ref() + .map(|paths| { + paths + .iter() + .map(|path| safe_relative_path(path, "included path", false)) + .collect::>>() + }) + .transpose()?; + if let Some(paths) = &includes { + for path in paths { + let mut current = source.to_path_buf(); + for component in path.components() { + current.push(component); + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + PresetError::ValidationError(format!( + "Cannot read included path {}: {}", + path.display(), + error + )) + })?; + if metadata.file_type().is_symlink() { + return Err(PresetError::ValidationError(format!( + "Included paths cannot traverse symbolic links: {}", + path.display() + ))); + } + } + } + } + let copied = copy_filtered( + source, + target, + Path::new(""), + includes.as_deref(), + &excludes, + )?; + if includes.is_some() && copied == 0 { + return Err(PresetError::ValidationError( + "Preset selection contains no files after exclusions".to_string(), + )); + } + Ok(()) } fn copy_filtered( source: &Path, target: &Path, relative: &Path, + includes: Option<&[PathBuf]>, excludes: &[PathBuf], -) -> Result<()> { +) -> Result { fs::create_dir_all(target)?; + let mut copied = 0; for entry in fs::read_dir(source)? { let entry = entry?; let child_relative = relative.join(entry.file_name()); - if child_relative == Path::new(".git") + if entry + .file_name() + .to_string_lossy() + .trim_end_matches(['.', ' ']) + .eq_ignore_ascii_case(".git") || excludes .iter() - .any(|excluded| child_relative == *excluded || child_relative.starts_with(excluded)) + .any(|excluded| child_relative.starts_with(excluded)) { continue; } + let selected = + includes.is_none_or(|paths| paths.iter().any(|path| child_relative.starts_with(path))); + let ancestor = includes + .is_some_and(|paths| paths.iter().any(|path| path.starts_with(&child_relative))); + if !selected && !ancestor { + continue; + } let source_path = entry.path(); let target_path = target.join(entry.file_name()); let file_type = entry.file_type()?; if file_type.is_dir() { - copy_filtered(&source_path, &target_path, &child_relative, excludes)?; - } else if file_type.is_file() { + copied += copy_filtered( + &source_path, + &target_path, + &child_relative, + includes, + excludes, + )?; + } else if file_type.is_file() && selected { fs::copy(source_path, target_path)?; + copied += 1; } else { return Err(PresetError::ValidationError(format!( - "Preset sources cannot contain symbolic links: {}", + "Preset sources must contain only regular files and directories: {}", child_relative.display() ))); } } - Ok(()) + Ok(copied) } fn apply_replacements(target: &Path, replacements: &[crate::types::TextReplacement]) -> Result<()> { @@ -458,7 +591,7 @@ mod tests { fs::write(source.join("README.md"), "hello starter").unwrap(); fs::write(source.join("cache/generated"), "skip").unwrap(); let excludes = vec![PathBuf::from("cache")]; - copy_filtered(&source, &target, Path::new(""), &excludes).unwrap(); + copy_filtered(&source, &target, Path::new(""), None, &excludes).unwrap(); apply_replacements( &target, &[TextReplacement { @@ -553,6 +686,7 @@ mod tests { repo: "https://example.com/repo".to_string(), directory: "../private".to_string(), }, + include: None, exclude: Vec::new(), write: Vec::new(), replace: Vec::new(), @@ -571,6 +705,7 @@ mod tests { repo: "file:///tmp/repo".to_string(), directory: String::new(), }, + include: None, exclude: Vec::new(), write: Vec::new(), replace: Vec::new(), @@ -589,6 +724,7 @@ mod tests { repo: "http://192.168.1.10/starter.git".to_string(), directory: String::new(), }, + include: None, exclude: Vec::new(), write: Vec::new(), replace: Vec::new(), @@ -597,4 +733,68 @@ mod tests { }; assert!(validate_plan(&plan, true).is_ok()); } + #[test] + fn directory_selection_excludes_descendants_and_ignores_new_siblings() { + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let target = temporary.path().join("target"); + for name in ["review", "review-extra", "new-skill"] { + fs::create_dir_all(source.join(name)).unwrap(); + fs::write(source.join(name).join("SKILL.md"), name).unwrap(); + } + fs::create_dir(source.join("review/tests")).unwrap(); + fs::write(source.join("review/tests/case"), "excluded").unwrap(); + let plan: PresetPlan = serde_json::from_value(json!({ + "version":1, "source":{"repo":"https://example.com/repo"}, + "include":["review", "review/SKILL.md"], "exclude":["./review/tests"] + })) + .unwrap(); + copy_selection(&source, &target, &plan).unwrap(); + assert!(target.join("review/SKILL.md").is_file()); + assert!(!target.join("review/tests").exists()); + assert!(!target.join("review-extra").exists()); + assert!(!target.join("new-skill").exists()); + } + + #[cfg(unix)] + #[test] + fn selected_source_symlinks_are_rejected_even_when_pointing_inside_source() { + use std::os::unix::fs::symlink; + let temporary = tempfile::tempdir().unwrap(); + let source = temporary.path().join("source"); + let target = temporary.path().join("target"); + fs::create_dir_all(source.join("real")).unwrap(); + fs::write(source.join("real/SKILL.md"), "content").unwrap(); + symlink("real", source.join("linked")).unwrap(); + let plan: PresetPlan = serde_json::from_value(json!({ + "version":1, "source":{"repo":"https://example.com/repo"}, "include":["linked/SKILL.md"] + })) + .unwrap(); + assert!(copy_selection(&source, &target, &plan).is_err()); + assert!(!target.exists()); + } + + #[test] + fn include_overrides_are_validated_and_count_toward_operation_limit() { + let base: PresetPlan = serde_json::from_value(json!({ + "version":1, "source":{"repo":"https://example.com/repo"} + })) + .unwrap(); + for path in [ + "", + ".", + "../escape", + "/absolute", + "C:/windows", + "a\\b", + ".GIT/config", + ] { + assert!( + override_include(&mut base.clone(), vec![path.to_string()]).is_err(), + "{}", + path + ); + } + assert!(override_include(&mut base.clone(), vec!["file".to_string(); 1001]).is_err()); + } } diff --git a/src/types.rs b/src/types.rs index 292a0ee..2ab88c1 100644 --- a/src/types.rs +++ b/src/types.rs @@ -57,6 +57,12 @@ pub struct PresetPlan { pub _schema: Option, pub version: u32, pub source: PresetSource, + #[serde( + default, + deserialize_with = "deserialize_include", + skip_serializing_if = "Option::is_none" + )] + pub include: Option>, #[serde(default)] pub exclude: Vec, #[serde(default)] @@ -69,6 +75,15 @@ pub struct PresetPlan { pub package_json: PresetPackageJson, } +fn deserialize_include<'de, D>( + deserializer: D, +) -> std::result::Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + Vec::::deserialize(deserializer).map(Some) +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct FileWrite { diff --git a/tests/cli.rs b/tests/cli.rs index 84dc97b..fd2b312 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -437,3 +437,217 @@ fn successful_exit_without_generated_files_restores_the_original_target() { "original" ); } + +#[test] +fn include_override_selects_exact_paths_and_exclude_wins() { + let fixture = Fixture::new(); + let config = fixture.plan(json!({"include":["package.json"], "exclude":[".github/config"]})); + let output = fixture + .command() + .args([ + "init", + "selected", + "--from", + config.to_str().unwrap(), + "--include", + "./README.md", + "--include", + ".github", + "--include", + "README.md", + ]) + .output() + .unwrap(); + success(&output); + let root = fixture.cwd.join("selected"); + assert_eq!( + fs::read_to_string(root.join("README.md")).unwrap(), + "hello starter" + ); + assert!(!root.join("package.json").exists()); + assert!(!root.join(".github/config").exists()); + assert!(!root.join("pnpm-lock.yaml").exists()); +} + +#[test] +fn invalid_or_empty_selections_never_modify_existing_target() { + for extra in [ + json!({"include":[]}), + json!({"include":null}), + json!({"include":["missing"]}), + json!({"include":["../escape"]}), + json!({"include":["."]}), + json!({"include":[".git/config"]}), + json!({"include":["README.md"],"exclude":["README.md"]}), + json!({"include":["README.md"],"write":[{"path":"other","lines":["escape"]}]}), + json!({"include":[".github"],"exclude":[".github/config"],"write":[{"path":".github/config","lines":["escape"]}]}), + ] { + let fixture = Fixture::new(); + let config = fixture.plan(extra); + fs::write(fixture.cwd.join("original"), "keep").unwrap(); + let output = fixture + .command() + .args(["init", ".", "--from", config.to_str().unwrap(), "--yes"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "unexpected success: {}", + String::from_utf8_lossy(&output.stdout) + ); + assert_eq!( + fs::read_to_string(fixture.cwd.join("original")).unwrap(), + "keep" + ); + assert!(config.exists()); + } +} + +#[test] +fn apply_adds_files_skips_identical_and_preserves_unrelated_content() { + let fixture = Fixture::new(); + let config = fixture.plan(json!({"include":["README.md", ".github", "package.json"]})); + fs::write(fixture.cwd.join("unrelated"), "local").unwrap(); + for run in 0..2 { + let output = fixture + .command() + .args(["apply", ".", "--from", config.to_str().unwrap()]) + .output() + .unwrap(); + success(&output); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + result[if run == 0 { "add" } else { "skip" }] + .as_array() + .unwrap() + .len(), + 3 + ); + assert!(result["conflicts"].as_array().unwrap().is_empty()); + } + assert_eq!( + fs::read_to_string(fixture.cwd.join("unrelated")).unwrap(), + "local" + ); + // apply 不以目标目录名重写 package.json。 + assert_eq!(read_json(&fixture.cwd.join("package.json"))["name"], "old"); + assert!(config.exists()); + assert!(!fixture.cwd.join("pnpm-lock.yaml").exists()); +} + +#[test] +fn apply_dry_run_checks_real_conflicts_without_creating_target_parents() { + let fixture = Fixture::new(); + let config = fixture.plan(json!({"include":["README.md"]})); + let output = fixture + .command() + .args([ + "apply", + "new/nested", + "--from", + config.to_str().unwrap(), + "--dry-run", + ]) + .output() + .unwrap(); + success(&output); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["add"], json!(["README.md"])); + assert!(!fixture.cwd.join("new").exists()); + fs::write(fixture.cwd.join("README.md"), "local edits").unwrap(); + for dry_run in [true, false] { + let mut command = fixture.command(); + command.args(["apply", ".", "--from", config.to_str().unwrap()]); + if dry_run { + command.arg("--dry-run"); + } + let output = command.output().unwrap(); + assert!(!output.status.success()); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["conflicts"][0]["path"], "README.md"); + assert_eq!( + fs::read_to_string(fixture.cwd.join("README.md")).unwrap(), + "local edits" + ); + } +} + +#[test] +fn apply_supports_registered_config_and_cli_selection_override() { + let fixture = Fixture::new(); + let config = fixture.plan(json!({"include":["package.json"]})); + let catalog = fixture.cwd.join("catalog.json"); + fs::write( + &catalog, + serde_json::to_vec(&json!([{"tech":"skills","name":"team-skills","config":config}])) + .unwrap(), + ) + .unwrap(); + success( + &fixture + .command() + .args(["config", "set", catalog.to_str().unwrap()]) + .output() + .unwrap(), + ); + let output = fixture + .command() + .args([ + "apply", + "skills", + "--from", + "team-skills", + "--include", + "README.md", + ]) + .output() + .unwrap(); + success(&output); + assert!(fixture.cwd.join("skills/README.md").is_file()); + assert!(!fixture.cwd.join("skills/package.json").exists()); +} + +#[test] +fn refuses_apply_generators_and_include_on_generators() { + let fixture = Fixture::new(); + for args in [ + vec!["apply", "new", "--from", "vite"], + vec![ + "init", + "new", + "--from", + "vite", + "--include", + "README.md", + "--yes", + ], + vec!["apply", "new", "--from", "vite", "--yes"], + ] { + assert!(!fixture + .command() + .args(args) + .output() + .unwrap() + .status + .success()); + assert!(!fixture.cwd.join("new").exists()); + } +} + +#[cfg(unix)] +#[test] +fn apply_rejects_nested_target_symlinks_without_touching_their_destination() { + use std::os::unix::fs::symlink; + let fixture = Fixture::new(); + let config = fixture.plan(json!({"include":[".github"]})); + let outside = fixture.temp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, fixture.cwd.join(".github")).unwrap(); + let output = fixture + .command() + .args(["apply", ".", "--from", config.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert_eq!(fs::read_dir(outside).unwrap().count(), 0); +}